feat: raw ELM327 console — сырой слой, API, интерактивная консоль

This commit is contained in:
Repinoid
2026-06-14 18:24:27 +04:00
parent 7d23a874e0
commit d01028b12b
5 changed files with 945 additions and 0 deletions
+251
View File
@@ -0,0 +1,251 @@
"""
obd/raw_console.py — Сырой слой ELM327 (без стейт-машины).
НИКАКОЙ логики протокола:
- Нет state machine (State)
- Нет классификации ответов (Rsp)
- Нет адаптивных таймингов (AdaptiveTiming)
- Нет ретраев
- Нет хендлеров ошибок
ТОЛЬКО:
- send(cmd) → отправляет команду + CR
- read(timeout) → читает ВСЁ до '>' или таймаута, байт-за-байтом
- drain() → очищает входной буфер
- available() → сколько байт ждёт в буфере
ПРЕДНАЗНАЧЕНИЕ:
Изучение реального поведения ELM327.
«Почему статика работает, а динамика ломается?»
Ответ — в сырых байтах.
"""
import logging
import time
logger = logging.getLogger("elm.raw")
# ── Конфигурация по умолчанию (можно переопределить) ──
DEFAULT_TIMEOUT = 500 # мс
INTER_CMD_DELAY = 0.05 # с — пауза между командой и чтением
class RawELM:
"""Сырой слой ELM327 — только send/read/drain, без протокольной логики."""
def __init__(self, transport):
"""
Args:
transport: объект с методами .write(str) и .read(timeout_ms) → str
(обычно SerialTransport из obd.connection)
"""
self._t = transport
self._timeout = DEFAULT_TIMEOUT
self._inter_delay = INTER_CMD_DELAY
self._log: list[dict] = [] # история команд
# ── Настройка ────────────────────────────────────
@property
def timeout(self) -> int:
return self._timeout
@timeout.setter
def timeout(self, ms: int):
self._timeout = ms
@property
def inter_delay(self) -> float:
return self._inter_delay
@inter_delay.setter
def inter_delay(self, sec: float):
self._inter_delay = sec
# ── Основные операции ────────────────────────────
def send(self, cmd: str, timeout: int | None = None) -> dict:
"""Отправить команду и прочитать сырой ответ.
Args:
cmd: команда (без \r, добавится автоматически)
timeout: таймаут в мс (None = использовать self.timeout)
Returns:
{
"cmd": str, # что отправили
"raw": str, # сырой ответ (без '>')
"prompt": bool, # получен ли '>'
"elapsed_ms": int, # сколько мс заняло
"bytes": int, # сколько байт в ответе
"error": str|None, # ошибка если есть
}
Не бросает исключений — всегда возвращает dict с полем error.
"""
tmo = timeout if timeout is not None else self._timeout
entry = {"cmd": cmd, "timeout_ms": tmo, "ts": time.time()}
try:
# 1. Отправить
self._t.write(cmd)
# 2. Пауза (ELM начинает отвечать не мгновенно)
if self._inter_delay > 0:
time.sleep(self._inter_delay)
# 3. Прочитать
t0 = time.monotonic()
raw, prompt, nbytes = self._read_raw(tmo)
elapsed = int((time.monotonic() - t0) * 1000)
entry.update({
"raw": raw,
"prompt": prompt,
"elapsed_ms": elapsed,
"bytes": nbytes,
"error": None,
})
except TimeoutError:
entry.update({
"raw": "",
"prompt": False,
"elapsed_ms": tmo,
"bytes": 0,
"error": f"timeout {tmo}ms",
})
except Exception as e:
entry.update({
"raw": "",
"prompt": False,
"elapsed_ms": 0,
"bytes": 0,
"error": str(e),
})
self._log.append(entry)
return entry
def drain(self) -> dict:
"""Очистить входной буфер. Возвращает что было выброшено.
Returns:
{"drained": str, "bytes": int}
"""
t0 = time.monotonic()
drained = []
total = 0
dl = t0 + 0.5 # 500 мс максимум на дренаж
while time.monotonic() < dl:
try:
ch = self._read_byte(0.05)
if ch is not None:
drained.append(chr(ch))
total += 1
else:
break # буфер пуст
except Exception:
break
elapsed = int((time.monotonic() - t0) * 1000)
result = {"drained": "".join(drained), "bytes": total, "elapsed_ms": elapsed}
if total > 0:
logger.info(f"RawELM: drained {total} bytes: {result['drained']!r}")
return result
def available(self) -> int:
"""Сколько байт ждёт во входном буфере (0 = пусто)."""
try:
return self._t._ser.in_waiting
except Exception:
return -1
# ── Лог ──────────────────────────────────────────
@property
def log(self) -> list[dict]:
"""История всех команд."""
return self._log
def clear_log(self):
"""Очистить историю."""
self._log.clear()
def last(self) -> dict | None:
"""Последняя команда."""
return self._log[-1] if self._log else None
# ── Приватные ────────────────────────────────────
def _read_raw(self, timeout_ms: int) -> tuple[str, bool, int]:
"""Читает байт-за-байтом до '>' или таймаута.
Returns:
(raw_text, got_prompt, byte_count)
"""
dl = time.monotonic() + timeout_ms / 1000.0
lines, cur = [], []
got_prompt = False
while time.monotonic() < dl:
ch = self._read_byte(0.05)
if ch is None:
continue
cp = ch
if cp == 62: # '>' — промпт ELM327
if cur:
lines.append("".join(cur))
cur.clear()
got_prompt = True
break
elif cp == 13: # CR — конец строки
if cur:
lines.append("".join(cur))
cur.clear()
elif cp in (10, 32): # LF и пробел — игнорируем
pass
else:
cur.append(chr(cp))
if cur:
lines.append("".join(cur))
return ("\n".join(lines), got_prompt, sum(len(s) for s in lines))
def _read_byte(self, timeout_s: float) -> int | None:
"""Прочитать один байт с таймаутом. None = таймаут/нет данных."""
import serial
try:
if self._t._ser.in_waiting > 0:
b = self._t._ser.read(1)
return b[0] if b else None
else:
time.sleep(0.001) # поллинг 1мс
return None
except serial.SerialException:
return None
# ── Хелпер ───────────────────────────────────────────
def format_response(entry: dict) -> str:
"""Форматирует ответ RawELM.send() для вывода в консоль."""
lines = [
f"→ {entry['cmd']}",
f"← {entry['raw']!r}" if entry["raw"] else "← (пусто)",
]
if entry["prompt"]:
lines.append(" prompt: ✅ >")
else:
lines.append(" prompt: ❌")
lines.append(f" time: {entry['elapsed_ms']}ms, bytes: {entry['bytes']}")
if entry["error"]:
lines.append(f" ⚠️ {entry['error']}")
return "\n".join(lines)
def format_log(entries: list[dict]) -> str:
"""Форматирует всю историю команд."""
return "\n" + "─" * 50 + "\n" + \
"\n".join(format_response(e) for e in entries) + \
"\n" + "─" * 50