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
+169
View File
@@ -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.
+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
+298
View File
@@ -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
View File
@@ -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("/")