diff --git a/api/raw_elm.py b/api/raw_elm.py new file mode 100644 index 0000000..0a4e9fd --- /dev/null +++ b/api/raw_elm.py @@ -0,0 +1,169 @@ +""" +api/raw_elm.py — Эндпоинты сырого взаимодействия с ELM327. + +POST /api/v1/elm/raw — отправить команду, получить сырой ответ +POST /api/v1/elm/raw/batch — отправить несколько команд +GET /api/v1/elm/raw/mode — проверить/установить режим (normal/raw) + +Режим RAW: + - Все остальные эндпоинты (/script, /session/upload) ОТКЛЮЧЕНЫ + - Только сырые команды через /elm/raw + - Никакой пробинг, классификация, стейт-машина — только send/read +""" + +import logging +from flask import jsonify, request, Blueprint + +logger = logging.getLogger("elmer.raw_api") + +# Глобальное состояние — raw_mode +_raw_mode = False +_raw_elm = None # экземпляр RawELM, если есть локальное соединение + + +def is_raw_mode() -> bool: + """Включён ли режим сырого взаимодействия.""" + return _raw_mode + + +def set_raw_mode(on: bool): + """Включить/выключить режим сырого взаимодействия.""" + global _raw_mode + _raw_mode = on + logger.info(f"RawELM mode: {'ON' if on else 'OFF'}") + + +def set_raw_elm(instance): + """Установить экземпляр RawELM для локального соединения.""" + global _raw_elm + _raw_elm = instance + + +bp = Blueprint("raw_elm", __name__) + + +@bp.route("/api/v1/elm/raw", methods=["POST"]) +def raw_command(): + """Отправить одну сырую команду в ELM327. + + Body: + { + "cmd": "0105", // команда (без \\r) + "timeout_ms": 500, // опционально, default 500 + "drain_first": false // опционально, очистить буфер перед командой + } + + Returns: + { + "cmd": "0105", + "raw": "41 05 5C", + "prompt": true, + "elapsed_ms": 48, + "bytes": 8, + "error": null + } + """ + if not _raw_elm: + return jsonify({"error": "no local ELM connection", "hint": "ELM327 не подключён к серверу локально"}), 503 + + data = request.get_json(silent=True) + if not data or "cmd" not in data: + return jsonify({"error": "missing 'cmd'"}), 400 + + cmd = data["cmd"].strip() + if not cmd: + return jsonify({"error": "empty cmd"}), 400 + + timeout = data.get("timeout_ms") + drain_first = data.get("drain_first", False) + + if drain_first: + _raw_elm.drain() + + result = _raw_elm.send(cmd, timeout=timeout) + return jsonify(result) + + +@bp.route("/api/v1/elm/raw/batch", methods=["POST"]) +def raw_batch(): + """Отправить несколько команд подряд. + + Body: + { + "cmds": ["0105", "010C", "010D"], + "timeout_ms": 500, + "drain_between": false // очищать буфер между командами + } + + Returns: + {"results": [...], "total_elapsed_ms": 1234} + """ + if not _raw_elm: + return jsonify({"error": "no local ELM connection"}), 503 + + data = request.get_json(silent=True) + if not data or "cmds" not in data: + return jsonify({"error": "missing 'cmds'"}), 400 + + cmds = data["cmds"] + if len(cmds) > 100: + return jsonify({"error": "too many commands (max 100)"}), 400 + + timeout = data.get("timeout_ms") + drain_between = data.get("drain_between", False) + + import time + t0 = time.time() + results = [] + for cmd in cmds: + if drain_between: + _raw_elm.drain() + results.append(_raw_elm.send(cmd, timeout=timeout)) + + total_elapsed = int((time.time() - t0) * 1000) + return jsonify({"results": results, "total_elapsed_ms": total_elapsed}) + + +@bp.route("/api/v1/elm/raw/drain", methods=["POST"]) +def raw_drain(): + """Очистить входной буфер ELM327. Возвращает что было выброшено.""" + if not _raw_elm: + return jsonify({"error": "no local ELM connection"}), 503 + + result = _raw_elm.drain() + return jsonify(result) + + +@bp.route("/api/v1/elm/raw/available", methods=["GET"]) +def raw_available(): + """Сколько байт ждёт во входном буфере.""" + if not _raw_elm: + return jsonify({"error": "no local ELM connection"}), 503 + + return jsonify({"available": _raw_elm.available()}) + + +@bp.route("/api/v1/elm/raw/log", methods=["GET"]) +def raw_log(): + """Вся история команд в сыром режиме.""" + if not _raw_elm: + return jsonify({"log": []}) + + return jsonify({"log": _raw_elm.log, "count": len(_raw_elm.log)}) + + +@bp.route("/api/v1/elm/raw/mode", methods=["GET", "POST"]) +def raw_mode_control(): + """GET: проверить режим. POST: включить/выключить raw mode. + + POST body: {"raw_mode": true/false} + """ + global _raw_mode + + if request.method == "POST": + data = request.get_json(silent=True) or {} + on = data.get("raw_mode", False) + set_raw_mode(on) + return jsonify({"raw_mode": _raw_mode, "has_local_elm": _raw_elm is not None}) + + return jsonify({"raw_mode": _raw_mode, "has_local_elm": _raw_elm is not None}) diff --git a/doc/dynamic-diagnostics-analysis-2026-06-14.md b/doc/dynamic-diagnostics-analysis-2026-06-14.md new file mode 100644 index 0000000..32b989e --- /dev/null +++ b/doc/dynamic-diagnostics-analysis-2026-06-14.md @@ -0,0 +1,209 @@ +# Анализ динамического сбоя ELM327 + +Дата: 2026-06-14 + +## Контекст + +Проверено: + +- статическая диагностика работает стабильно; +- чтение VIN работает; +- чтение DTC работает; +- последовательное чтение нескольких PID в статическом режиме работает; +- ELM327 выдерживает не менее 9 PID подряд в статике; +- увеличение пауз до 4000 мс не устраняет проблему; +- автоподбор таймингов не устраняет проблему; +- сбой проявляется только в динамическом сборе данных через ScriptEngine. + +Это сильно сужает пространство причин. Проблема почти наверняка не в "скорости ELM вообще", не в "ECU не успевает" и не в банальном "надо ещё увеличить задержку". + +## Что объясняет факты лучше всего + +### 1. Несовпадение между тем, как ElmChecker и ScriptEngine общаются с ELM + +Вероятность: высокая. + +Смысл гипотезы: статический путь и динамический путь используют не одинаковую коммуникационную последовательность. Ломается не ELM как таковой, а конкретный сценарий: кто пишет, кто читает, когда читают, что считается окончанием ответа, как очищается буфер, как меняются состояния. + +Что подтверждает: + +- статический путь работает полностью; +- динамический ломается только в ScriptEngine; +- один и тот же адаптер выдерживает длинную серию PID в статике; +- в проектных заметках уже зафиксировано, что проблема может быть именно в различии между ElmChecker и ScriptEngine, а не в тайминге как таковом. + +Что противоречит: + +- если в удачном и неудачном сценарии полностью совпадают AT-команды, порядок команд, чтение и ожидание конца ответа. + +Быстрый эксперимент: + +- снять полный лог команд и сырых ответов для успешного статического пути и для динамического пути; +- сравнить именно последовательность TX/RX, а не распарсенные значения; +- проверить, расходится ли сценарий уже до первого PID. + +### 2. InputStream не дочитывается до символа ">", и следующий запрос попадает в хвост прошлого ответа + +Вероятность: высокая. + +Смысл гипотезы: динамический читатель завершает чтение раньше, чем ELM реально закончил ответ. В буфере остаётся промпт `>` или другой хвост, и следующий запрос читает не чистый ответ, а остаток прошлого цикла. + +Что подтверждает: + +- это прямо совпадает с типовым режимом отказа ELM327; +- в проектных заметках символ `>` отдельно выделен как конец ответа; +- в обсуждениях проекта уже встречалась версия, что ответы смешиваются и хвост остаётся в буфере; +- статика может это маскировать, потому что между командами там больше естественных пауз. + +Что противоречит: + +- если сырые логи показывают, что каждый ответ полностью доходит до `>` и следующий запрос стартует только после этого; +- если после сбоя буфер точно пуст. + +Быстрый эксперимент: + +- включить сырой дамп RX/TX без парсинга; +- для первого сбойного цикла проверить, присутствует ли `>` в сыром потоке полностью; +- перед следующим запросом проверить, не остаётся ли в InputStream ничего, кроме уже считанного ответа. + +### 3. Остатки данных в буфере ломают синхронизацию между командами + +Вероятность: высокая. + +Смысл гипотезы: чтение и запись идут корректно по отдельности, но между ними нет надёжной очистки буфера. В результате следующий запрос потребляет не только свежий ответ, но и мусор: эхо, переносы строк, старые байты, задержавшийся ответ предыдущего PID. + +Что подтверждает: + +- проектные заметки отдельно говорят, что drainInput раньше был механизмом синхронизации запрос/ответ; +- после отключения drainInput в одной из версий стало хуже; +- описан эффект сдвига: ответ на команду N прочитан как ответ на N+1; +- статический сценарий выдерживает это лучше из-за более редкой частоты обращений. + +Что противоречит: + +- если перед каждым запросом в динамическом цикле буфер гарантированно очищается и при этом проблема остаётся; +- если в логах нет признаков мусора, эха или сдвига границ ответов. + +Быстрый эксперимент: + +- один раз до старта динамики и один раз перед вторым запросом вывести количество доступных байт в InputStream и содержимое остатка; +- сравнить результат между успешным статическим и неудачным динамическим прогоном; +- проверить, есть ли хвосты после первого ответа. + +### 4. ScriptEngine выполняет не тот же state machine, что ElmChecker + +Вероятность: средняя. + +Смысл гипотезы: проблема не в самом Bluetooth и не в самом ELM, а в том, что динамический движок переходит между состояниями раньше или иначе, чем ElmChecker. Например, команда считается завершённой по временному признаку, а не по фактическому окончанию ответа. + +Что подтверждает: + +- пользователь отдельно выделил риск state machine; +- динамический режим содержит свои шаги, цикл и внутренние переходы; +- статический путь короче и проще, поэтому ошибки state machine там могут не проявляться; +- уже были замечания, что в таких сценариях рассинхрон появляется раньше, чем кажется. + +Что противоречит: + +- если логически и по времени state transitions происходят только после полного ответа ELM; +- если обе машины выполняют одинаковый сценарий завершения команды. + +Быстрый эксперимент: + +- на одном прогоне логировать каждое состояние до и после отправки команды; +- отметить момент, когда реально получен `>`; +- проверить, не уходит ли ScriptEngine в следующий шаг до фактического конца ответа. + +### 5. Доступ к одному сокету или одному InputStream идёт из двух потоков + +Вероятность: средняя. + +Смысл гипотезы: чтение или запись в динамике пересекаются с другим потоком, который тоже читает или пишет тот же канал. Для ELM это критично: поток байтов становится недетерминированным, и команда может лишиться части ответа. + +Что подтверждает: + +- пользователь отдельно попросил проверить конкурентный доступ к сокету; +- динамический режим по определению более многопоточен: цикл, сбор данных, UI, возможные фоновые операции; +- статический путь может не задевать гонку из-за более редкой частоты и меньшего числа активных операций. + +Что противоречит: + +- если трасса покажет строго одного читателя и одного писателя на весь жизненный цикл соединения; +- если динамика воспроизводится даже в полностью однопоточном прогоне. + +Быстрый эксперимент: + +- вывести thread id для каждого read и write; +- проверить, нет ли второго consumer на InputStream; +- сравнить идентичность владельца сокета в статике и динамике. + +### 6. Неправильная последовательность команд, а не неправильная пауза + +Вероятность: средняя-низкая. + +Смысл гипотезы: дело не в длительности ожидания как таковой, а в том, что динамический сценарий отправляет команды в другом порядке или с другим набором служебных AT-команд, чем успешный статический сценарий. Тогда ELM оказывается в другом режиме, и дальнейшая обработка ломается. + +Что подтверждает: + +- в проекте есть отдельные сценарии для статической диагностики, тестового скрипта и динамики; +- серверный build_test_script и build_dynamic_script действительно строят разные последовательности; +- в заметках по ELM отдельно обсуждаются последствия ATWS, ATE0/ATL0/ATS0 и различий в инит-последовательности. + +Что противоречит: + +- если сравнение трасс покажет полностью одинаковый init и только разный темп; +- если тот же набор команд в статике и динамике повторяет поломку только из-за способа выполнения, а не порядка. + +Быстрый эксперимент: + +- распечатать полный список команд, которые реально уходят в ELM в обоих режимах; +- сравнить not only PID, но и все AT-команды, входы в state machine и возможные reset-команды; +- проверить, совпадает ли стартовая инициализация побайтно. + +## Что менее вероятно + +### Adaptive timing как первопричина + +Вероятность: низкая. + +Почему низкая: + +- уже проверяли увеличение пауз до 4000 мс; +- уже проверяли автоподбор; +- одиночные запросы и статический набор PID работают. + +Вывод: adaptive timing может усиливать или маскировать проблему, но не выглядит корнем сбоя. + +### Просто "мало ждать" + +Вероятность: низкая. + +Почему низкая: + +- паузы уже увеличивали; +- первый запрос проходит, второй ломается; +- для обычного ELM327 это больше похоже на ошибку синхронизации, чем на нехватку миллисекунд. + +## Итоговая интерпретация + +Новое мнение хорошо согласуется с уже собранными фактами. Оно сдвигает фокус с "таймингов вообще" на более узкий класс проблем: + +- границы ответа ELM, особенно символ `>`; +- остатки в InputStream; +- различие между ElmChecker и ScriptEngine; +- state machine, которая может идти вперёд раньше времени; +- возможная конкуренция за сокет или поток чтения. + +Главный вывод: если статический путь стабилен, а динамический ломается даже при больших паузах, то первичная причина почти наверняка находится не в задержках, а в чтении потока, границах ответа и разнице в сценарии исполнения. + +## Порядок расследования + +1. Снять сырой RX/TX лог без парсинга для статического и динамического режима. +2. Проверить, доходит ли каждый ответ до `>` и не остаются ли байты в буфере перед следующим запросом. +3. Сопоставить полную последовательность команд ElmChecker и ScriptEngine. +4. Подтвердить или опровергнуть второй consumer на сокете/InputStream. +5. Проверить, не идёт ли state machine вперёд до фактического завершения ответа. + +## Краткий вывод + +Наиболее правдоподобно, что проблема не в скорости ELM, а в том, как динамический сценарий читает и синхронизирует поток ответов. Внутри этого класса причин самые сильные кандидаты: неполное дочитывание до `>`, остатки в InputStream, и различие между ElmChecker и ScriptEngine. \ No newline at end of file diff --git a/obd/raw_console.py b/obd/raw_console.py new file mode 100644 index 0000000..d09e653 --- /dev/null +++ b/obd/raw_console.py @@ -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 diff --git a/tools/elm_console.py b/tools/elm_console.py new file mode 100755 index 0000000..385c92d --- /dev/null +++ b/tools/elm_console.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +""" +elm_console.py — Интерактивная консоль ELM327 (сырой режим). + +НИКАКОЙ автоматики: + - Нет init(), probe(), send() со стейт-машиной + - Нет классификации ответов + - Нет адаптивных таймингов + +ТОЛЬКО вы вводите команду — ELM отвечает сырыми байтами. + +ЗАПУСК: + python tools/elm_console.py # порт по умолчанию /dev/rfcomm0 + python tools/elm_console.py --port /dev/rfcomm0 # явно указать порт + python tools/elm_console.py --baud 38400 # другая скорость + python tools/elm_console.py --timeout 1000 # таймаут 1с + python tools/elm_console.py --no-init # не слать AT-инит + +КОМАНДЫ КОНСОЛИ: + ATZ — отправить "ATZ" в ELM + 0105 — отправить "0105" (PID coolant temp) + !drain — очистить входной буфер + !timeout 2000 — установить таймаут 2000 мс + !delay 0.5 — пауза между командой и чтением (сек) + !log — показать историю команд + !available — сколько байт в буфере + !save file.json — сохранить лог в файл + !help — справка + !quit — выход + +ЦЕЛЬ: + Понять, КАК на самом деле работает ELM327. + Почему статика работает, а динамика ломается? + Ответ — в сырых байтах. +""" + +import argparse +import cmd +import json +import logging +import sys +import time +from pathlib import Path + +# Добавляем корень проекта в PYTHONPATH +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from obd.connection import SerialTransport +from obd.raw_console import RawELM, format_response, format_log + +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s [%(name)s] %(message)s", + datefmt="%H:%M:%S", +) + +logger = logging.getLogger("elm.console") + + +class ElmConsole(cmd.Cmd): + """Интерактивная консоль ELM327.""" + + intro = """ +╔══════════════════════════════════════════════════════╗ +║ ELM327 Raw Console ║ +║ Сырое взаимодействие — без стейт-машины ║ +║ Команды: ATZ, 0105, !drain, !help, !quit ║ +╚══════════════════════════════════════════════════════╝ +""" + prompt = "\nelm> " + + def __init__(self, port: str, baudrate: int, timeout: int, delay: float, no_init: bool): + super().__init__() + self._port = port + self._baud = baudrate + self._no_init = no_init + self._transport = None + self._elm: RawELM | None = None + + # ── Подключение ───────────────────────────────── + + def connect(self): + """Открыть порт и создать RawELM.""" + print(f"🔌 Подключение к {self._port} @ {self._baud}...") + try: + self._transport = SerialTransport(self._port, self._baud) + self._transport.connect() + except Exception as e: + print(f"❌ Не удалось открыть порт: {e}") + print(" Проверь: bash scripts/setup-bt.sh") + return False + + self._elm = RawELM(self._transport) + print(f"✅ Порт открыт. RawELM готов.") + print(f" Таймаут: {self._elm.timeout}ms, пауза: {self._elm.inter_delay}s") + print(f" Буфер: {self._elm.available()} байт") + + if not self._no_init: + print("\n📡 Быстрая проверка связи (ATZ)...") + r = self._elm.send("ATZ", timeout=3000) + print(format_response(r)) + if r["error"]: + print("⚠️ ELM327 не ответил на ATZ. Проверь питание адаптера.") + print(" Продолжаем, но команды могут не работать.") + + return True + + def close(self): + """Закрыть порт.""" + if self._transport: + self._transport.close() + print("🔌 Порт закрыт.") + + # ── cmd.Cmd overrides ──────────────────────────── + + def default(self, line: str): + """Любая не-! команда = отправить в ELM327.""" + cmd_str = line.strip() + if not cmd_str: + return + + if cmd_str.startswith("!"): + print(f"Неизвестная команда: {cmd_str}. !help для списка.") + return + + # Отправить в ELM + result = self._elm.send(cmd_str) + print(format_response(result)) + + def emptyline(self): + """Пустая строка — ничего не делаем.""" + pass + + # ── Специальные команды (!) ────────────────────── + + def do_drain(self, arg): + """!drain — очистить входной буфер ELM327.""" + r = self._elm.drain() + if r["bytes"] > 0: + print(f"🗑 Выброшено {r['bytes']} байт: {r['drained']!r}") + else: + print("✅ Буфер пуст.") + + def do_timeout(self, arg): + """!timeout — установить таймаут чтения.""" + try: + ms = int(arg.strip()) + self._elm.timeout = ms + print(f"⏱ Таймаут: {ms}ms") + except ValueError: + print(f"❌ Нужно число: !timeout 2000") + + def do_delay(self, arg): + """!delay — пауза между командой и чтением.""" + try: + sec = float(arg.strip()) + self._elm.inter_delay = sec + print(f"⏱ Пауза: {sec}s") + except ValueError: + print(f"❌ Нужно число: !delay 0.5") + + def do_log(self, arg): + """!log [N] — показать последние N команд (по умолчанию все).""" + entries = self._elm.log + if not entries: + print("📭 Лог пуст.") + return + + try: + n = int(arg.strip()) if arg.strip() else len(entries) + except ValueError: + n = len(entries) + + to_show = entries[-n:] if n < len(entries) else entries + print(format_log(to_show)) + print(f"Всего: {len(entries)} команд.") + + def do_available(self, arg): + """!available — сколько байт в буфере.""" + n = self._elm.available() + if n < 0: + print("⚠️ Не удалось проверить буфер (порт закрыт?).") + elif n == 0: + print("✅ Буфер пуст.") + else: + print(f"📥 В буфере: {n} байт.") + + def do_save(self, arg): + """!save — сохранить лог в JSON.""" + path = arg.strip() + if not path: + print("❌ Укажи имя файла: !save log.json") + return + try: + with open(path, "w") as f: + json.dump(self._elm.log, f, indent=2, default=str) + print(f"💾 Сохранено: {path} ({len(self._elm.log)} команд)") + except Exception as e: + print(f"❌ Ошибка: {e}") + + def do_raw(self, arg): + """!raw — показать последний ответ в repr (все символы).""" + last = self._elm.last() + if not last: + print("📭 Нет команд.") + return + print(f"raw = {last['raw']!r}") + print(f"prompt = {last['prompt']}") + print(f"elapsed = {last['elapsed_ms']}ms") + print(f"bytes = {last['bytes']}") + + def do_help(self, arg): + """!help — справка.""" + print(""" +╔══════════════════════════════════════════════════════╗ +║ КОМАНДЫ ELM327 (вводи как есть): ║ +║ ATZ — сброс ║ +║ ATI — идентификация ║ +║ ATE0 — echo off ║ +║ ATL0 — linefeeds off ║ +║ ATS0 — spaces off ║ +║ ATH1 — headers on ║ +║ ATSP0 — авто-протокол ║ +║ ATRV — напряжение ║ +║ ATDPN — номер протокола ║ +║ 0105 — температура ОЖ (PID) ║ +║ 010C — обороты ║ +║ 010D — скорость ║ +║ 03 — сохранённые DTC ║ +║ 07 — pending DTC ║ +║ 0902 — VIN ║ +║ ║ +║ КОМАНДЫ КОНСОЛИ (с !): ║ +║ !drain — очистить буфер ║ +║ !timeout N — таймаут (ms) ║ +║ !delay N — пауза перед чтением (s) ║ +║ !log [N] — история команд ║ +║ !raw — последний ответ в repr ║ +║ !available — байт в буфере ║ +║ !save f.json — сохранить лог ║ +║ !help — эта справка ║ +║ !quit — выход ║ +╚══════════════════════════════════════════════════════╝ +""") + + def do_quit(self, arg): + """!quit — выход.""" + print("👋") + self.close() + return True + + def do_exit(self, arg): + """!exit — то же что !quit.""" + return self.do_quit(arg) + + # Сокращения + do_q = do_quit + do_h = do_help + do_d = do_drain + do_t = do_timeout + do_l = do_log + do_a = do_available + + +# ── main ───────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="ELM327 Raw Console — интерактивное сырое взаимодействие" + ) + parser.add_argument("--port", default="/dev/rfcomm0", help="Порт (default: /dev/rfcomm0)") + parser.add_argument("--baud", type=int, default=38400, help="Скорость (default: 38400)") + parser.add_argument("--timeout", type=int, default=500, help="Таймаут чтения ms (default: 500)") + parser.add_argument("--delay", type=float, default=0.05, help="Пауза перед чтением s (default: 0.05)") + parser.add_argument("--no-init", action="store_true", help="Не слать ATZ при старте") + args = parser.parse_args() + + console = ElmConsole( + port=args.port, + baudrate=args.baud, + timeout=args.timeout, + delay=args.delay, + no_init=args.no_init, + ) + + if not console.connect(): + sys.exit(1) + + try: + console.cmdloop() + except KeyboardInterrupt: + print("\n👋") + finally: + console.close() + + +if __name__ == "__main__": + main() diff --git a/web/app.py b/web/app.py index 8796431..fe03615 100644 --- a/web/app.py +++ b/web/app.py @@ -22,6 +22,7 @@ from api.config import load from api.routes import register as register_api from api.dtc import register as register_dtc from api.ping import register as register_ping +from api.raw_elm import bp as raw_bp, is_raw_mode logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s") @@ -30,6 +31,23 @@ config = load() register_api(app) register_dtc(app) register_ping(app) +app.register_blueprint(raw_bp) + +# ── Режим RAW: отключаем все эндпоинты кроме /elm/raw/* ── +_RAW_PREFIX = "/api/v1/elm/raw" + +@app.before_request +def _check_raw_mode(): + """В режиме RAW все эндпоинты кроме /elm/raw/* отключены.""" + if is_raw_mode() and not request.path.startswith(_RAW_PREFIX): + # Разрешаем только статику и корень + if request.path not in ("/", "/elmer.apk") and not request.path.startswith("/static"): + return jsonify({ + "error": "raw_mode_active", + "hint": "Сервер в режиме сырого взаимодействия с ELM327. " + "Все остальные эндпоинты отключены. " + "Используйте /api/v1/elm/raw/mode чтобы выключить." + }), 503 @app.route("/")