""" 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