v0.48.0 — пробинг ELM327, трехуровневый профиль, рефакторинг obd/

- obd/probe.py: трехуровневый каскад (L0/L1/L2)
- obd/commands.py: каталог всех AT-команд с метаданными
- obd/classifier.py: классификация ответов + определение уровня
- obd/connection.py: транспортный слой (SerialTransport)
- obd/protocol.py: init() только база, без ATAT1/ATST
- api/db.py: таблица device_profiles по BT MAC
- api/scripts.py: три уровня скриптов (l0/l1/l2)
- api/routes.py: /elm/probe, /elm/profile/<mac>, /script?level=
- web/templates/index.html: v0.48.0
- CHANGELOG.md, doc/architecture.md, resume.txt: версии
This commit is contained in:
“Naeel”
2026-06-07 05:55:02 +04:00
parent 7ae0fc5702
commit aa37a1a70c
13 changed files with 918 additions and 158 deletions
+107
View File
@@ -0,0 +1,107 @@
"""
obd/classifier.py — Классификация ответов ELM327 и определение уровня.
Отдельный сервис:
- classify(raw) → (tag: str, is_ok: bool)
- determine_level(responses: dict) → int (-1/0/1/2)
Использует obd/commands.py для списков команд по уровням.
"""
import logging
from typing import Dict, Optional, Tuple
from obd.state import Rsp
from obd.commands import L0_NAMES, L1_NAMES, L2_NAMES
logger = logging.getLogger("elmer.classifier")
def classify(raw: str) -> Tuple[str, bool]:
"""Классифицирует сырой ответ ELM327.
Returns:
(tag, is_ok)
tag: Rsp.OK / Rsp.ERROR / Rsp.UNKNOWN / ...
is_ok: True если устройство ответило нормально (OK или данные)
"""
tag = Rsp.identify(raw)
is_ok = tag in (Rsp.OK, Rsp.UNKNOWN, Rsp.NODATA, Rsp.SEARCHING)
return (tag, is_ok)
def determine_level(responses: Dict[str, str]) -> dict:
"""Определяет уровень устройства по ответам на пробинг-команды.
Args:
responses: {cmd_name: raw_response} — ответы на команды пробинга.
Returns:
Профиль: {level, elm_version, protocol, voltage, supported, unsupported, errors}
"""
result: dict = {
"level": -1,
"elm_version": None,
"protocol": None,
"voltage": None,
"supported": [],
"unsupported": [],
"errors": [],
}
# ── Уровень 0 ──────────────────────────────────
l0_ok = True
for cmd in L0_NAMES:
raw = responses.get(cmd, "")
tag, ok = classify(raw)
if ok and raw:
result["supported"].append(cmd)
if cmd == "ATI":
result["elm_version"] = raw.strip()
elif cmd == "ATDPN":
result["protocol"] = raw.strip()
elif cmd == "ATRV":
result["voltage"] = raw.strip()
else:
result["unsupported"].append(cmd)
result["errors"].append(f"{cmd}: {tag if tag else 'no response'}")
l0_ok = False
if not l0_ok:
logger.warning("classifier: L0 failed")
return result
result["level"] = 0
# ── Уровень 1 ──────────────────────────────────
l1_ok = True
for cmd in L1_NAMES:
raw = responses.get(cmd, "")
tag, ok = classify(raw)
if ok and raw:
result["supported"].append(cmd)
else:
result["unsupported"].append(cmd)
l1_ok = False
if not l1_ok:
return result
result["level"] = 1
# ── Уровень 2 ──────────────────────────────────
l2_ok = True
for cmd in L2_NAMES:
raw = responses.get(cmd, "")
tag, ok = classify(raw)
if ok and raw:
result["supported"].append(cmd)
else:
result["unsupported"].append(cmd)
l2_ok = False
if l2_ok:
result["level"] = 2
logger.info(f"classifier: level={result['level']}")
return result
+143
View File
@@ -0,0 +1,143 @@
"""
obd/commands.py — Каталог AT-команд ELM327.
Все известные команды ELM327 с метаданными:
- name: имя команды (напр. "ATE0")
- desc: краткое описание
- level: минимальный уровень устройства (0/1/2)
- safe: можно ли слать при пробинге (False для опасных)
Принцип:
- Нет в профиле → не слать.
- Опасные команды (меняют baud rate, мониторинг) — никогда не пробингуются.
"""
from dataclasses import dataclass
from typing import List
@dataclass
class AtCommand:
"""AT-команда ELM327 с метаданными."""
name: str # "ATE0"
desc: str # "Echo off"
level: int = 0 # минимальный уровень (0=все, 1=ATAT1, 2=CAF1/CFC1)
safe: bool = True # можно ли пробинговать
# ══════════════════════════════════════════════════════════
# Уровень 0 — Универсальные (есть у ВСЕХ клонов)
# ══════════════════════════════════════════════════════════
CMDS_L0: List[AtCommand] = [
AtCommand("ATE0", "Echo off"),
AtCommand("ATE1", "Echo on", safe=False), # не слать — вернём эхо
AtCommand("ATL0", "Linefeeds off"),
AtCommand("ATL1", "Linefeeds on", safe=False),
AtCommand("ATS0", "Spaces off"),
AtCommand("ATS1", "Spaces on", safe=False),
AtCommand("ATH0", "Headers off"),
AtCommand("ATH1", "Headers on"),
AtCommand("ATSP0", "Auto protocol"),
AtCommand("ATDPN", "Describe protocol number"),
AtCommand("ATRV", "Read voltage"),
AtCommand("ATI", "Identify"),
AtCommand("ATWS", "Warm start", safe=False), # сбрасывает состояние
AtCommand("ATPC", "Protocol close", safe=False),
AtCommand("ATR0", "Responses off", safe=False),
AtCommand("ATR1", "Responses on"),
AtCommand("ATNL", "Normalize length"),
AtCommand("ATAL", "Allow long messages"),
AtCommand("ATAR", "Auto receive", safe=False), # включает авто-приём
AtCommand("ATSW", "Stop waiting"),
AtCommand("ATFE", "Forget events"),
AtCommand("ATSI", "Slow init"),
AtCommand("ATFI", "Fast init"),
AtCommand("ATKW", "Keyword"),
AtCommand("ATTA", "Tester address"),
AtCommand("ATTP", "Try protocol"),
AtCommand("ATWM", "Wakeup message"),
AtCommand("ATIGN", "Ignore"),
AtCommand("ATIFR", "IFR"),
AtCommand("ATIIA", "ISO init address"),
AtCommand("ATRD", "Read data"),
AtCommand("ATCS", "CAN silent"),
AtCommand("ATCV", "Calibrate voltage"),
AtCommand("ATD0", "Display off"),
AtCommand("ATM0", "Memory off"),
AtCommand("ATM1", "Memory on"),
]
# ══════════════════════════════════════════════════════════
# Уровень 1 — Хорошие клоны и оригиналы
# ══════════════════════════════════════════════════════════
CMDS_L1: List[AtCommand] = [
AtCommand("ATAT0", "Adaptive timing off", level=1),
AtCommand("ATAT1", "Adaptive timing on", level=1),
AtCommand("ATAT2", "Adaptive timing aggressive", level=1),
]
# ══════════════════════════════════════════════════════════
# Уровень 2 — Только настоящий ELM327
# ══════════════════════════════════════════════════════════
CMDS_L2: List[AtCommand] = [
AtCommand("ATCAF0", "CAN auto formatting off", level=2),
AtCommand("ATCAF1", "CAN auto formatting on", level=2),
AtCommand("ATCFC0", "CAN flow control off", level=2),
AtCommand("ATCFC1", "CAN flow control on", level=2),
AtCommand("ATCM", "CAN mask", level=2),
AtCommand("ATCRA", "CAN receive address", level=2),
AtCommand("ATCP", "CAN priority", level=2),
AtCommand("ATSH", "Set header", level=2),
AtCommand("AT@1", "Device description", level=2),
AtCommand("AT@2", "Device identifier", level=2),
AtCommand("ATJE", "J1939 ELM", level=2),
AtCommand("ATJS", "J1939 setup", level=2),
AtCommand("ATIB", "ISO baud", level=2, safe=False),
]
# ══════════════════════════════════════════════════════════
# ОПАСНЫЕ — никогда не пробинговать
# ══════════════════════════════════════════════════════════
CMDS_DANGEROUS: List[AtCommand] = [
AtCommand("ATZ", "Hard reset", safe=False),
AtCommand("ATD", "Set defaults", safe=False),
AtCommand("ATBD", "Baud rate", safe=False),
AtCommand("ATBRD", "Baud rate divisor", safe=False),
AtCommand("ATMA", "Monitor all", safe=False),
AtCommand("ATMR", "Monitor receiver", safe=False),
AtCommand("ATMT", "Monitor transmitter", safe=False),
]
# ── Хелперы ─────────────────────────────────────────────
def get_commands_for_level(level: int) -> List[str]:
"""Возвращает имена команд, доступных на заданном уровне."""
result = [c.name for c in CMDS_L0 if c.safe]
if level >= 1:
result += [c.name for c in CMDS_L1 if c.safe]
if level >= 2:
result += [c.name for c in CMDS_L2 if c.safe]
return result
def get_probe_commands() -> dict:
"""Возвращает команды для пробинга, сгруппированные по уровням.
Returns: {"L0": ["ATE0",...], "L1": ["ATAT1",...], "L2": ["ATCAF1",...]}
"""
return {
"L0": [c.name for c in CMDS_L0 if c.safe and c.level == 0][:8],
"L1": [c.name for c in CMDS_L1 if c.safe],
"L2": [c.name for c in CMDS_L2 if c.safe],
}
# Плоские списки для импорта (обратная совместимость)
L0_NAMES = [c.name for c in CMDS_L0 if c.safe and c.level == 0]
L1_NAMES = [c.name for c in CMDS_L1 if c.safe]
L2_NAMES = [c.name for c in CMDS_L2 if c.safe]
+105
View File
@@ -0,0 +1,105 @@
"""
obd/connection.py — Транспортный слой ELM327 (serial/Bluetooth).
Чтение байт-за-байтом с поллингом 1мс, запись + flush.
Выделен из protocol.py чтобы разделить I/O и логику протокола.
"""
import logging
import time
logger = logging.getLogger("elm.connection")
class SerialTransport:
"""Транспорт: открыть порт, читать/писать байты, закрыть.
Не знает протокол. Только I/O.
"""
def __init__(self, port: str, baudrate: int = 38400):
self.port = port
self.baudrate = baudrate
self._ser = None
def connect(self):
"""Открыть serial-соединение."""
import serial
self._ser = serial.Serial(
port=self.port,
baudrate=self.baudrate,
timeout=0.1,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
)
time.sleep(0.5)
logger.info(f"SerialTransport: connected {self.port}")
def close(self):
"""Закрыть serial-соединение."""
if self._ser and self._ser.is_open:
self._ser.close()
logger.info("SerialTransport: closed")
@property
def is_open(self) -> bool:
"""Открыт ли порт."""
return self._ser is not None and self._ser.is_open
def write(self, data: str):
"""Отправить данные в ELM: data + CR → flush."""
self._ser.write((data + "\r").encode())
self._ser.flush()
logger.debug(f"→ {data}")
def read(self, timeout_ms: int) -> str:
"""Прочитать ответ ELM327 байт-за-байтом.
Читает до символа '>' (промпт) или до таймаута.
Возвращает сырой ответ без '>'.
Raises:
TimeoutError: если '>' не получен за timeout_ms.
"""
dl = time.monotonic() + timeout_ms / 1000.0
lines, cur = [], []
got_prompt = False
while time.monotonic() < dl:
if self._ser.in_waiting > 0:
ch = self._ser.read(1)
if not ch:
continue
cp = ch[0]
if cp == 62: # '>' — промпт ELM327
self._push(cur, lines)
got_prompt = True
break
elif cp == 13: # CR — конец строки
self._push(cur, lines)
elif cp in (10, 32): # LF и пробел — игнорируем
pass
else:
cur.append(chr(cp))
else:
time.sleep(0.001) # поллинг 1мс
self._push(cur, lines)
if not got_prompt:
raise TimeoutError(f"read timeout {timeout_ms}ms")
return "\n".join(lines)
def try_read(self, timeout: int = 5000):
"""Прочитать и проигнорировать ответ."""
try:
self.read(timeout)
except TimeoutError:
pass
@staticmethod
def _push(cur, lines):
"""Добавить накопленные байты как строку в lines."""
if cur:
lines.append("".join(cur))
cur.clear()
+86
View File
@@ -0,0 +1,86 @@
"""
obd/probe.py — Пробинг ELM327: определение уровня устройства.
Трехуровневый каскад (использует obd/commands.py + obd/classifier.py):
Уровень 0 (база, все клоны):
ATE0 ATL0 ATS0 ATH1 ATSP0 ATDPN ATRV ATI
→ хоть одна не ответила → НЕИСПРАВЕН (-1)
Уровень 1 (хорошие клоны):
ATAT1 → уровень 1
Уровень 2 (настоящие ELM):
ATCAF1 ATCFC1 → уровень 2
Принцип:
- Каждая команда: ОДИН запрос, короткий таймаут (500мс)
- Нет ретраев на неизвестные команды
- Команды и классификация — в отдельных модулях
"""
import logging
import time
from obd.commands import L0_NAMES, L1_NAMES, L2_NAMES
from obd.classifier import classify, determine_level
logger = logging.getLogger("elmer.probe")
# Таймауты
PROBE_TMO = 500 # мс — таймаут на одну команду при пробинге
INTER_CMD_DELAY = 0.05 # с — пауза между командами
def probe(elm) -> dict:
"""Пробинг ELM327: определяет уровень и профиль устройства.
Args:
elm: AndrOBD с открытым connect().
Returns:
{"level": 0|1|2|-1, "elm_version": str|None, ...}
level = -1 → устройство нерабочее.
"""
# Собираем ответы на все команды пробинга
responses = _probe_commands(elm, L0_NAMES + L1_NAMES + L2_NAMES)
# Классифицируем — определяем уровень
result = determine_level(responses)
if result["level"] >= 0:
logger.info(f"probe: device level={result['level']}")
else:
logger.warning("probe: device UNUSABLE")
return result
def _probe_commands(elm, cmds: list[str]) -> dict[str, str]:
"""Посылает список команд и собирает ответы.
Returns:
{cmd_name: raw_response}
"""
responses = {}
for cmd in cmds:
ok, raw = _try_one(elm, cmd)
responses[cmd] = raw if ok else ""
return responses
def _try_one(elm, cmd: str) -> tuple[bool, str]:
"""Посылает ОДНУ команду в ELM, ждёт PROBE_TMO мс.
Returns:
(ok, raw_response)
"""
elm._write(cmd)
time.sleep(INTER_CMD_DELAY)
try:
raw = elm._read(PROBE_TMO)
except TimeoutError:
return False, ""
_, ok = classify(raw)
return (ok, raw)
+55 -122
View File
@@ -1,7 +1,7 @@
"""
obd/protocol.py — ELM327 стейт-машина (AndrOBD).
Точная копия логики из AndrOBD (ElmProt.java, github.com/fr3ts0n/AndrOBD).
Основана на AndrOBD (ElmProt.java, github.com/fr3ts0n/AndrOBD).
## Архитектура
┌──────────┐ команда ┌──────────┐
@@ -16,26 +16,26 @@ obd/protocol.py — ELM327 стейт-машина (AndrOBD).
│ восстановление ▲
└─────────────────────┘
## Зависимости
obd/connection.py — транспорт (SerialTransport)
obd/state.py — состояния/ответы (State, Rsp)
obd/timing.py — адаптивный таймаут (AdaptiveTiming)
obd/commands.py — каталог команд
obd/classifier.py — классификация ответов
## Использование
elm = AndrOBD("/dev/rfcomm0", 38400)
elm.connect()
elm.init()
vin = elm.send("0902")
rpm = elm.send("010C")
elm.close()
## Ключевые особенности
- Байт-за-байтом чтение с 1мс поллингом
- `>` как разделитель ответов (промпт ELM327)
- Адаптивный таймаут (200мс ± 4мс, ATST)
- Восстановление после BUS ERROR (ATPC → ATSP0)
- Не тот ответ → переход в ERROR → восстановление
"""
import logging
import time
from typing import Optional
from obd.connection import SerialTransport
from obd.state import State, Rsp
from obd.timing import AdaptiveTiming
@@ -43,11 +43,11 @@ logger = logging.getLogger("androbd")
class AndrOBD:
"""Стейт-машина ELM327 — 1:1 копия AndrOBD (ElmProt.java).
"""Стейт-машина ELM327.
Управляет жизненным циклом ELM327:
1. connect() — открыть serial/Bluetooth порт
2. init() — инициализация (ATSP0, ATAT1, ATST, ATS0, ATL0, ATE0)
2. init() — базовая инициализация (уровень 0)
3. send(cmd) — отправить OBD-команду, получить ответ
4. close() — закрыть порт
@@ -55,60 +55,58 @@ class AndrOBD:
"""
INIT_TMO = 10000 # мс — таймаут для команд инициализации
DEF_TMO = 200 # мс — начальный таймаут (заменяется AdaptiveTiming)
DEF_TMO = 200 # мс — начальный таймаут
def __init__(self, port: str, baudrate: int = 38400):
"""port — устройство (напр. /dev/rfcomm0), baudrate — скорость."""
self.port = port
self.baudrate = baudrate
self._ser = None
self._transport = SerialTransport(port, baudrate)
self._timing = AdaptiveTiming()
self._state = State.UNDEFINED
self._last_cmd: Optional[str] = None
def connect(self):
"""Открыть serial-соединение с ELM327."""
import serial
self._ser = serial.Serial(
port=self.port, baudrate=self.baudrate, timeout=0.1,
bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE)
time.sleep(0.5)
logger.info(f"AndrOBD: connected {self.port}")
self._transport.connect()
logger.info(f"AndrOBD: connected {self._transport.port}")
def close(self):
"""Закрыть serial-соединение."""
if self._ser and self._ser.is_open:
self._ser.close()
self._transport.close()
def init(self):
"""Инициализация ELM327 — 6 AT-команд.
"""Базовая инициализация ELM327 (уровень 0 — все клоны).
ATSP0 — авто-протокол
ATAT1 — адаптивный таймаут вкл
ATSTxx — установить таймаут
ATS0 — без пробелов в ответах
ATL0 — без перевода строки
ATE0 — без эха
ТОЛЬКО команды которые есть у ВСЕХ клонов:
ATE0 ATL0 ATS0 ATH1 ATSP0
"""
logger.info("AndrOBD: init")
logger.info("AndrOBD: init (L0)")
self._state = State.INITIALIZING
self._exec("ATSP0", self.INIT_TMO)
self._exec("ATAT1", self.DEF_TMO * 5)
self._update_atst()
self._exec("ATS0", self.DEF_TMO * 5)
self._exec("ATL0", self.DEF_TMO * 5)
self._exec("ATE0", self.DEF_TMO * 5)
self._exec("ATL0", self.DEF_TMO * 5)
self._exec("ATS0", self.DEF_TMO * 5)
self._exec("ATH1", self.DEF_TMO * 5)
self._exec("ATSP0", self.INIT_TMO)
self._state = State.READY
logger.info("AndrOBD: ready")
logger.info("AndrOBD: ready (L0)")
def init_l1(self):
"""Инициализация уровня 1: база + адаптивный тайминг."""
logger.info("AndrOBD: init L1 (+ATAT1)")
self._state = State.INITIALIZING
self._exec("ATAT1", self.DEF_TMO * 5)
self._state = State.READY
logger.info("AndrOBD: ready (L1)")
def init_l2(self):
"""Инициализация уровня 2: L1 + CAN автоформат + flow control."""
logger.info("AndrOBD: init L2 (+ATCAF1 +ATCFC1)")
self._state = State.INITIALIZING
self._exec("ATCAF1", self.DEF_TMO * 5)
self._exec("ATCFC1", self.DEF_TMO * 5)
self._state = State.READY
logger.info("AndrOBD: ready (L2)")
def send(self, cmd: str) -> str:
"""Отправить OBD-команду и получить ответ.
cmd — команда (напр. '0105', '0902', '03').
Возвращает сырой ответ ELM327.
При ошибке — авто-восстановление.
"""
"""Отправить OBD-команду и получить ответ."""
if self._state == State.ERROR:
self._recover()
self._state = State.BUSY
@@ -117,17 +115,10 @@ class AndrOBD:
self._state = State.READY
return result
# ── Приватные методы ──────────────────────────────────
# ── Приватные методы ──────────────────────────────
def _exec(self, cmd: str, timeout: int) -> str:
"""Выполнить команду ELM327 с ретраями (до 10 попыток).
Архитектура AndrOBD: ELM327 может не ответить, если:
- Адаптер ещё не инициализирован (SEARCHING)
- ЭБУ медленно отвечает (увеличиваем AdaptiveTiming)
- Плохой контакт / потеря связи (Busy→Timeout→Error)
10 ретраев — жёсткий лимит из AndrOBD, после — ERROR.
"""
"""Выполнить команду с ретраями (до 10)."""
self._last_cmd = cmd
self._write(cmd)
t = timeout
@@ -135,11 +126,9 @@ class AndrOBD:
try:
return self._handle(self._read(t))
except TimeoutError:
# Инициализация: каждый ретрай +1с (ELM может быть занят init)
if self._state == State.INITIALIZING:
t += 1000
else:
# Активный режим: растём таймаут адаптивно
self._timing.increase()
t = self._timing.ms
logger.error(f"AndrOBD: no response for {cmd}")
@@ -147,10 +136,7 @@ class AndrOBD:
return ""
def _handle(self, raw: str) -> str:
"""Обработать ответ ELM327: классификация → обновление таймаута.
Возвращает raw как есть — декодирование данных на уровень выше.
"""
"""Обработать ответ ELM327."""
t = Rsp.identify(raw)
if t == Rsp.SEARCHING:
@@ -159,26 +145,20 @@ class AndrOBD:
self._timing.decrease()
return raw
if t == Rsp.NODATA:
# NODATA = ЭБУ жив, но не может ответить (например, PID недоступен)
# Увеличиваем таймаут — возможно ЭБУ медленный
self._timing.increase()
self._update_atst()
return raw
# BUS ERROR — сброс протокола
if t in (Rsp.UNABLE, Rsp.BUS_BUSY, Rsp.BUS_ERROR,
Rsp.CAN_ERROR, Rsp.BUS_INIT, Rsp.STOPPED):
logger.warning(f"AndrOBD: BUS ERROR ({t})")
self._state = State.DISCONNECTED
self._timing.reset()
self._update_atst()
self._write("ATPC") # закрыть протокол
self._write("ATPC")
self._try_read()
self._write("ATSP0") # переоткрыть авто-протокол
self._write("ATSP0")
self._try_read()
return raw
# Другие ошибки — warm start
if t in (Rsp.ERROR, Rsp.DATA_ERROR, Rsp.BUFFER_FULL, Rsp.RX_ERROR):
logger.warning(f"AndrOBD: {t} — warm start")
self._state = State.ERROR
@@ -186,13 +166,11 @@ class AndrOBD:
self._try_read()
return raw
# Данные — успешный ответ
self._timing.decrease()
return raw
def _recover(self):
"""Восстановление после ошибки: ATWS → ATSP0 → ATE0."""
"""Восстановление после ошибки: ATWS → ATSP0 → ATE0."""
"""Восстановление после ошибки."""
logger.info("AndrOBD: recovering...")
self._state = State.INITIALIZING
self._write("ATWS")
@@ -204,59 +182,14 @@ class AndrOBD:
self._state = State.READY
def _write(self, cmd: str):
"""Отправить команду в ELM327: сброс буфера → write + CR → flush."""
self._ser.reset_input_buffer() # сбросить хвосты предыдущего ответа
self._ser.write((cmd + "\r").encode())
self._ser.flush()
logger.debug(f"AndrOBD → {cmd}")
"""Отправить команду в ELM327."""
self._transport.write(cmd)
def _read(self, timeout_ms: int) -> str:
"""Прочитать ответ ELM327 байт-за-байтом.
Читает до символа `>` (промпт) или до таймаута.
Возвращает сырой ответ без `>`.
"""
dl = time.monotonic() + timeout_ms / 1000.0
lines, cur = [], []
got_prompt = False
while time.monotonic() < dl:
if self._ser.in_waiting > 0:
ch = self._ser.read(1)
if not ch:
continue
cp = ch[0]
if cp == 62: # '>' — промпт ELM327
self._push(cur, lines)
got_prompt = True
break
elif cp == 13: # CR — конец строки
self._push(cur, lines)
elif cp in (10, 32): # LF и пробел — игнорируем
pass
else:
cur.append(chr(cp))
else:
time.sleep(0.001) # поллинг 1мс
self._push(cur, lines)
if not got_prompt:
raise TimeoutError(f"timeout {timeout_ms}ms")
return "\n".join(lines)
"""Прочитать ответ ELM327."""
return self._transport.read(timeout_ms)
def _try_read(self, timeout: int = 5000):
"""Прочитать и проигнорировать ответ (для команд восстановления)."""
try:
self._read(timeout)
except TimeoutError:
pass
"""Прочитать и проигнорировать ответ."""
self._transport.try_read(timeout)
@staticmethod
def _push(cur, lines):
"""Добавить накопленные байты как строку в lines."""
if cur:
lines.append("".join(cur))
cur.clear()
def _update_atst(self):
"""Отправить ATST с текущим значением адаптивного таймаута."""
self._write(f"ATST{self._timing.atst:02X}")
self._try_read()