feat: raw ELM327 console — сырой слой, API, интерактивная консоль
This commit is contained in:
+169
@@ -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})
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Executable
+298
@@ -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 <ms> — установить таймаут чтения."""
|
||||
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 <sec> — пауза между командой и чтением."""
|
||||
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 <file.json> — сохранить лог в 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()
|
||||
+18
@@ -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("/")
|
||||
|
||||
Reference in New Issue
Block a user