diff --git a/api/raw_elm.py b/api/raw_elm.py index 0a4e9fd..9b2500c 100644 --- a/api/raw_elm.py +++ b/api/raw_elm.py @@ -1,40 +1,56 @@ """ -api/raw_elm.py — Эндпоинты сырого взаимодействия с ELM327. +api/raw_elm.py — Сырое взаимодействие с ELM327 (локальное + удалённое через Android). -POST /api/v1/elm/raw — отправить команду, получить сырой ответ -POST /api/v1/elm/raw/batch — отправить несколько команд -GET /api/v1/elm/raw/mode — проверить/установить режим (normal/raw) +ЛОКАЛЬНЫЙ РЕЖИМ (ELM327 подключён к серверу напрямую): + POST /api/v1/elm/raw — отправить команду, получить сырой ответ + POST /api/v1/elm/raw/batch — несколько команд + POST /api/v1/elm/raw/drain — очистить буфер + GET /api/v1/elm/raw/available — байт в буфере + GET /api/v1/elm/raw/log — история команд + GET /api/v1/elm/raw/mode — режим (normal/raw) -Режим RAW: - - Все остальные эндпоинты (/script, /session/upload) ОТКЛЮЧЕНЫ - - Только сырые команды через /elm/raw - - Никакой пробинг, классификация, стейт-машина — только send/read +УДАЛЁННЫЙ РЕЖИМ (Android-ретранслятор): + POST /api/v1/elm/raw/hello — Android: «я готов» + POST /api/v1/elm/raw/cmd — Copilot: поставить команду в очередь + GET /api/v1/elm/raw/cmd — Android: забрать команду + POST /api/v1/elm/raw/response — Android: отправить ответ + GET /api/v1/elm/raw/response — Copilot: прочитать ответ + GET /api/v1/elm/raw/status — Copilot: статус устройства """ import logging +import threading +import time from flask import jsonify, request, Blueprint logger = logging.getLogger("elmer.raw_api") -# Глобальное состояние — raw_mode +# ══════════════════════════════════════════════════════════ +# Глобальное состояние +# ══════════════════════════════════════════════════════════ + _raw_mode = False -_raw_elm = None # экземпляр RawELM, если есть локальное соединение +_raw_elm = None # локальный RawELM + +# Очередь команд (удалённый режим) +_lock = threading.Lock() +_pending_cmd: dict | None = None # команда, которую ждёт Android +_pending_seq: int = 0 # номер последней команды +_last_response: dict | None = None # последний ответ от ELM327 +_device_ready: bool = False # Android подключён и готов +_device_info: dict = {} # информация об устройстве (из hello) +_history: list[dict] = [] # история команд через Android 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 @@ -42,30 +58,129 @@ def set_raw_elm(instance): bp = Blueprint("raw_elm", __name__) +# ══════════════════════════════════════════════════════════ +# ЛОКАЛЬНЫЙ РЕЖИМ — ELM327 подключён к серверу напрямую +# ══════════════════════════════════════════════════════════ + @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 + return jsonify({"error": "no local ELM connection"}), 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(): + 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) + 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(): + if not _raw_elm: + return jsonify({"error": "no local ELM connection"}), 503 + return jsonify(_raw_elm.drain()) + + +@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": _history, "count": len(_history)}) + 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(): + 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, + "device_ready": _device_ready}) + return jsonify({"raw_mode": _raw_mode, "has_local_elm": _raw_elm is not None, + "device_ready": _device_ready}) + + +# ══════════════════════════════════════════════════════════ +# УДАЛЁННЫЙ РЕЖИМ — Android-ретранслятор +# ══════════════════════════════════════════════════════════ + +@bp.route("/api/v1/elm/raw/hello", methods=["POST"]) +def raw_hello(): + """Android сообщает: «я подключился к ELM327, готов принимать команды». + + Body: { + "device_id": "android-xyz", + "elm_version": "ELM327 v1.5", + "protocol": "A4", + "voltage": "12.3V" + } + """ + global _device_ready, _device_info, _pending_cmd, _pending_seq, _last_response + data = request.get_json(silent=True) or {} + with _lock: + _device_ready = True + _device_info = { + "device_id": data.get("device_id", "unknown"), + "elm_version": data.get("elm_version", "?"), + "protocol": data.get("protocol", "?"), + "voltage": data.get("voltage", "?"), + "connected_at": time.time(), + } + _pending_cmd = None + _pending_seq = 0 + _last_response = None + logger.info(f"RawELM: device ready — {_device_info['device_id']} " + f"({_device_info['elm_version']}, proto {_device_info['protocol']})") + return jsonify({"ok": True, "seq": 0}) + + +@bp.route("/api/v1/elm/raw/cmd", methods=["POST"]) +def raw_enqueue_cmd(): + """Copilot: поставить команду в очередь для Android. + + Body: { + "cmd": "0105", + "timeout_ms": 500, + "drain_first": false + } + """ + global _pending_cmd, _pending_seq data = request.get_json(silent=True) if not data or "cmd" not in data: return jsonify({"error": "missing 'cmd'"}), 400 @@ -74,96 +189,126 @@ def raw_command(): 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 // очищать буфер между командами + with _lock: + _pending_seq += 1 + _pending_cmd = { + "cmd": cmd, + "timeout_ms": data.get("timeout_ms", 500), + "drain_first": data.get("drain_first", False), + "seq": _pending_seq, } + logger.info(f"RawELM: enqueued #{_pending_seq} → {cmd}") + return jsonify({"ok": True, "seq": _pending_seq, "cmd": cmd}) + + +@bp.route("/api/v1/elm/raw/cmd", methods=["GET"]) +def raw_dequeue_cmd(): + """Android: забрать команду из очереди. Returns: - {"results": [...], "total_elapsed_ms": 1234} + 200 {"cmd": "0105", "seq": 1, ...} — есть команда + 204 — нет команды, полли дальше """ - if not _raw_elm: - return jsonify({"error": "no local ELM connection"}), 503 + global _pending_cmd + device_id = request.args.get("device_id", "") + with _lock: + if not _device_ready: + return jsonify({"error": "device not ready"}), 503 + if _pending_cmd is None: + return "", 204 # No Content — полли дальше + cmd = _pending_cmd + _pending_cmd = None # забрали + + logger.info(f"RawELM: dequeued #{cmd['seq']} → {cmd['cmd']} (device={device_id})") + return jsonify(cmd) + + +@bp.route("/api/v1/elm/raw/response", methods=["POST"]) +def raw_post_response(): + """Android: отправить ответ ELM327 на сервер. + + Body: { + "device_id": "android-xyz", + "seq": 1, + "cmd": "0105", + "raw": "41 05 5C", + "prompt": true, + "elapsed_ms": 48, + "bytes": 8, + "error": null + } + """ + global _last_response, _history data = request.get_json(silent=True) - if not data or "cmds" not in data: - return jsonify({"error": "missing 'cmds'"}), 400 + if not data: + return jsonify({"error": "empty body"}), 400 - cmds = data["cmds"] - if len(cmds) > 100: - return jsonify({"error": "too many commands (max 100)"}), 400 + with _lock: + _last_response = { + "seq": data.get("seq", 0), + "cmd": data.get("cmd", ""), + "raw": data.get("raw", ""), + "prompt": data.get("prompt", False), + "elapsed_ms": data.get("elapsed_ms", 0), + "bytes": data.get("bytes", 0), + "error": data.get("error"), + "received_at": time.time(), + } + _history.append(dict(_last_response)) + if len(_history) > 1000: + _history = _history[-500:] - 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}) + logger.info(f"RawELM: response #{_last_response['seq']} ← {_last_response['raw'][:80]}") + return jsonify({"ok": True}) -@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 +@bp.route("/api/v1/elm/raw/response", methods=["GET"]) +def raw_get_response(): + """Copilot: прочитать последний ответ от ELM327. - 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} + Query: ?wait=30 — ждать до 30 сек пока появится новый ответ """ - global _raw_mode + global _last_response, _pending_cmd + wait_s = int(request.args.get("wait", 0)) + seq = int(request.args.get("seq", 0)) - 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}) + if wait_s > 0: + # Ждём пока появится ответ на команду с seq > указанного + dl = time.time() + wait_s + while time.time() < dl: + with _lock: + if _last_response and _last_response["seq"] > seq: + return jsonify(_last_response) + if _pending_cmd is None and _last_response: + # команд в очереди нет, ответ уже есть + return jsonify(_last_response) + time.sleep(0.5) + + with _lock: + if _last_response is None: + return jsonify({"error": "no response yet", "seq": 0}) + return jsonify(_last_response) + + +@bp.route("/api/v1/elm/raw/status", methods=["GET"]) +def raw_status(): + """Copilot: статус Android-устройства.""" + with _lock: + return jsonify({ + "device_ready": _device_ready, + "device_info": _device_info, + "pending_cmd": bool(_pending_cmd), + "pending_seq": _pending_seq, + "last_response_seq": _last_response["seq"] if _last_response else 0, + "history_count": len(_history), + }) + + +@bp.route("/api/v1/elm/raw/history", methods=["GET"]) +def raw_history(): + """Copilot: история всех команд через Android.""" + n = int(request.args.get("n", 50)) + with _lock: + return jsonify({"history": _history[-n:], "total": len(_history)}) - return jsonify({"raw_mode": _raw_mode, "has_local_elm": _raw_elm is not None}) diff --git a/doc/elm-raw-relay-plan.md b/doc/elm-raw-relay-plan.md new file mode 100644 index 0000000..19ebe12 --- /dev/null +++ b/doc/elm-raw-relay-plan.md @@ -0,0 +1,293 @@ +# План: тонкий Android-ретранслятор ELM327 + +Дата: 2026-06-14 + +## Цель + +Отдельное Android-приложение — тупой ретранслятор команд между сервером и ELM327. +Пользователь устанавливает один раз. Вся логика (какие команды слать, как анализировать +ответы) — на сервере. Приложение только: + +1. Коннектится к ELM327 по Bluetooth +2. Сообщает серверу «готов» +3. Поллит сервер на наличие команды +4. Отправляет команду в ELM327 +5. Возвращает сырой ответ на сервер +6. Повторяет с пункта 3 + +## Почему отдельное приложение + +- Ноль риска сломать существующий `ru.elmer.client` +- Независимый пакет `ru.elmer.raw` +- Свой APK, свой URL на сервере (`/elm-raw.apk`) +- Можно удалить/переустановить независимо от основного + +## Архитектура + +``` +┌─────────────────────────────────────────────────┐ +│ Сервер (elmer/python) │ +│ │ +│ POST /api/v1/elm/raw/cmd ← я ставлю команду │ +│ GET /api/v1/elm/raw/cmd ← приложение поллит │ +│ POST /api/v1/elm/raw/response ← приложение шлёт │ +│ GET /api/v1/elm/raw/response ← я читаю ответ │ +│ /elm-raw.apk ← раздача APK │ +└──────────────┬──────────────────────────────────┘ + │ HTTP (OkHttp) +┌──────────────▼──────────────────────────────────┐ +│ Android-приложение (ru.elmer.raw) │ +│ │ +│ RawRelayService (foreground) │ +│ ├─ Bluetooth → ELM327 │ +│ ├─ ElmProtocol (AndrOBD, проверенный) │ +│ ├─ Polling: GET /cmd каждые 500ms │ +│ └─ POST /response с сырым ответом │ +│ │ +│ MainActivity (минимальный UI) │ +│ ├─ Статус: сервер / ELM / ECU │ +│ ├─ Лог последних команд │ +│ └─ Кнопка «Стоп» │ +└──────────────────────────────────────────────────┘ +``` + +## Компоненты Android-приложения + +### 1. Пакет: `ru.elmer.raw` + +Новый пакет, не пересекается с `ru.elmer.client`. + +### 2. Файлы (5 штук) + +| Файл | Размер | Назначение | +|------|--------|-----------| +| `MainActivity.kt` | ~100 строк | UI: статус, лог, кнопка стоп | +| `RawRelayService.kt` | ~150 строк | Foreground-сервис: BT+поллинг+команды | +| `ElmProtocol.kt` | копия | Точная копия из `ru.elmer.client.elm` | +| `ServerClient.kt` | ~80 строк | Урезанный HTTP-клиент (только cmd/response) | +| `AndroidManifest.xml` | ~40 строк | Свой манифест для `ru.elmer.raw` | + +**Почему копия ElmProtocol.kt, а не общий модуль:** +- Не трогаем существующий код вообще +- AndrOBD-логика отлажена годами, меняться не будет +- Две копии живут независимо, никаких конфликтов + +### 3. ElmProtocol.kt — как есть + +Используем **без изменений** проверенную стейт-машину: +- `init()`: ATSP0 → ATAT1 → ATS0 → ATL0 → ATE0 +- `sendCommand(cmd)`: отправить → прочитать до `>` → вернуть сырой ответ +- Обработка ошибок: BUS ERROR, CAN ERROR, BUFFER FULL, ретраи, восстановление +- Адаптивные тайминги + +Единственное что добавим — вызов `sendCommand()` оборачиваем в `try/catch`, +результат всегда возвращается на сервер (даже если ошибка). + +### 4. Протокол обмена с сервером + +#### Приложение → Сервер: «я готов» +``` +POST /api/v1/elm/raw/hello +{ + "device_id": "android-xyz", + "elm_version": "ELM327 v1.5", + "protocol": "A4", + "voltage": "12.3V" +} +``` + +#### Сервер → Приложение: команда +``` +GET /api/v1/elm/raw/cmd?device_id=android-xyz +Ответ 200: +{ + "cmd": "0105", + "timeout_ms": 500, + "drain_first": false, + "seq": 1 +} +Ответ 204: (нет команды — полли дальше) +``` + +#### Приложение → Сервер: ответ +``` +POST /api/v1/elm/raw/response +{ + "device_id": "android-xyz", + "seq": 1, + "cmd": "0105", + "raw": "41 05 5C", + "prompt": true, + "elapsed_ms": 48, + "bytes": 8, + "error": null +} +``` + +#### Сервер → Приложение: подтверждение +``` +200 {"ok": true} +``` + +### 5. RawRelayService — жизненный цикл + +``` +onStartCommand(Intent: serverUrl) + ↓ +1. Подключить Bluetooth к ELM327 (UUID SPP 00001101-0000-1000-8000-00805F9B34FB) + ↓ +2. ElmProtocol.init() — базовая инициализация + ↓ +3. POST /hello — сообщить серверу «готов» + ↓ +4. Цикл (в фоновом потоке): + GET /cmd — ждать команду (500ms поллинг) + если 204 → sleep 500ms → снова GET /cmd + если 200 → + drain? → ElmProtocol.sendCommand("ATPC") → read/discard + ElmProtocol.sendCommand(cmd) + POST /response — отправить сырой ответ + → снова GET /cmd + ↓ +5. onDestroy(): закрыть BT, stopForeground, остановить поток +``` + +### 6. MainActivity — UI + +``` +┌──────────────────────────────┐ +│ ELM327 Raw Relay │ +│ │ +│ Сервер: ✅ obdai.ru │ +│ ELM: 🔵 подключён │ +│ ECU: ✅ отвечает │ +│ │ +│ Последняя команда: │ +│ → 0105 │ +│ ← 41 05 5C (48ms, 8 байт) │ +│ │ +│ Лог: 12 команд, 0 ошибок │ +│ │ +│ [ СТОП ] │ +└──────────────────────────────┘ +``` + +Минимальный UI: +- Три индикатора статуса (сервер, ELM, ECU) +- Последняя команда и ответ +- Счётчик команд/ошибок +- Кнопка «Стоп» + +## Изменения на серверной стороне (elmer/python) + +### 1. Очередь команд — `api/raw_elm.py` + +Добавить эндпоинты (дополнить существующий `api/raw_elm.py`): + +``` +POST /api/v1/elm/raw/cmd — я ставлю команду в очередь +GET /api/v1/elm/raw/cmd — приложение забирает команду +POST /api/v1/elm/raw/response — приложение шлёт ответ +GET /api/v1/elm/raw/response — я читаю последний ответ +POST /api/v1/elm/raw/hello — приложение регистрируется +GET /api/v1/elm/raw/status — статус: готово/ждёт/ошибка +``` + +### 2. Хранение очереди + +В памяти (глобальная переменная), не в БД: +- `_pending_cmd: dict | None` — команда, которую ждёт приложение +- `_last_response: dict | None` — последний ответ от ELM327 +- `_device_ready: bool` — готово ли приложение +- `_device_info: dict` — информация об устройстве + +Зачем в памяти: одна сессия отладки, один поток команд. Не нужна персистентность. + +### 3. Раздача APK — `web/app.py` + +```python +@app.route("/elm-raw.apk") +def download_raw_apk(): + return send_from_directory("static", "elm-raw.apk", ...) +``` + +В `templates/index.html` — ссылка «Скачать ELM Raw Relay». + +### 4. Интерактивная консоль — `tools/elm_relay.py` + +Скрипт для меня (Copilot): +- Читает статус устройства +- Ставит команду в очередь +- Ждёт ответ +- Показывает сырой ответ +- Анализирует, ставит следующую команду +- История всех команд сохраняется + +## Сборка и деплой + +### Сборка APK + +```bash +cd android +./gradlew :app:assembleDebug +# APK: android/app/build/outputs/apk/debug/app-debug.apk +``` + +Но нам нужен **отдельный** APK для `ru.elmer.raw`. Два варианта: + +**Вариант A: Product Flavor** (в одном проекте) +- В `app/build.gradle.kts` добавить `flavorDimensions` + два flavor: `client` и `raw` +- Разные `applicationId`, разные `AndroidManifest.xml` +- Общий код в `main/`, специфичный — в `client/` и `raw/` +- Минус: трогаем `build.gradle.kts` основного приложения + +**Вариант B: Новый модуль** (рекомендую) +- Новый Gradle-модуль `android/raw/` +- Свой `build.gradle.kts`, свой манифест, свой пакет +- Не трогаем вообще ничего в `android/app/` +- `settings.gradle.kts` — добавить `include(":raw")` +- Минус: ElmProtocol.kt — физическая копия файла + +### Я за Вариант B: новый модуль `:raw` + +``` +android/ +├── app/ ← существующее, НЕ ТРОГАЕМ +├── raw/ ← НОВЫЙ модуль +│ ├── build.gradle.kts +│ └── src/main/ +│ ├── AndroidManifest.xml +│ └── java/ru/elmer/raw/ +│ ├── MainActivity.kt +│ ├── RawRelayService.kt +│ ├── ElmProtocol.kt ← копия из :app +│ └── ServerClient.kt +├── settings.gradle.kts ← + include(":raw") +└── build.gradle.kts ← не трогаем +``` + +### Деплой + +```bash +cd android +./gradlew :raw:assembleDebug +cp raw/build/outputs/apk/debug/raw-debug.apk ../web/static/elm-raw.apk +# Задеплоить на сервер через deploy.sh +``` + +## Порядок работ + +1. **Сервер**: дополнить `api/raw_elm.py` эндпоинтами очереди +2. **Сервер**: добавить `web/app.py` — раздача `/elm-raw.apk` +3. **Сервер**: `tools/elm_relay.py` — консоль для меня +4. **Android**: модуль `:raw` — 5 файлов (.kt + манифест + build.gradle) +5. **Сборка**: проверить что оба APK собираются +6. **Тест**: поставить APK на телефон, проверить связь с сервером + +## Что НЕ делаем + +- Не трогаем `ru.elmer.client` — ни строчки +- Не меняем `app/build.gradle.kts` +- Не меняем существующий `AndroidManifest.xml` +- Не изобретаем новый ELM327-протокол — используем AndrOBD как есть +- Не пишем сложный UI — только статус и лог diff --git a/doc/opinion-dynamic-diagnostics-2026-06-14.md b/doc/opinion-dynamic-diagnostics-2026-06-14.md new file mode 100644 index 0000000..c9ed320 --- /dev/null +++ b/doc/opinion-dynamic-diagnostics-2026-06-14.md @@ -0,0 +1,60 @@ +# Мнение по анализу динамического сбоя ELM327 + +Дата: 2026-06-14 + +## Общая оценка + +Анализ написан правильно. Методология верная: исключение невозможного через уже проведённые эксперименты (паузы 4000 мс, автоподбор таймингов), затем ранжирование оставшихся гипотез. Главный вывод — проблема не в скорости, а в чтении потока — звучит убедительно. + +--- + +## Что поддерживаю + +**Гипотезы 1–3 (вероятность: высокая)** — расставлены верно. + +Из трёх наиболее вероятных причин **непрочитанный `>` в InputStream** — самая классическая ELM327-ловушка. Если ScriptEngine завершает чтение по таймауту или по числу строк вместо `>`, это объясняет всё: первый запрос проходит, потому что `>` ещё не накапливается, второй ломается из-за хвоста. Это надо проверять первым. + +**Buffer drain перед send, а не только после receive** — часто игнорируемое место. Если drain делается только после чтения, но перед отправкой нового запроса остаток `>` или пустая строка ещё лежат в буфере — это незаметно даже в логах, если читать только "полезные" байты. + +--- + +## Что добавил бы + +### 1. NO DATA / UNABLE TO CONNECT в динамике + +В анализе не рассмотрен сценарий, когда в ходе динамики ELM вернул `NO DATA` или `UNABLE TO CONNECT`. Это вполне реально при смене контекста CAN. Если ScriptEngine на такой ответ зависает в ожидании данных или некорректно парсит следующий ответ — результат идентичен описанному сбою. Стоит явно проверить, как ScriptEngine обрабатывает негативные ответы ELM, и логировать их. + +### 2. AT ST (тайм-аут ELM) может различаться между режимами + +Если ElmChecker и ScriptEngine отправляют разные значения `AT ST` (или один вообще не устанавливает его), ELM сам будет обрезать ответ или отвечать с разной задержкой. При высокой нагрузке ECU (динамика) тайм-аут ELM по умолчанию (200 мс) может быть недостаточен, и ELM уйдёт в `NO DATA` раньше, чем ECU ответил. Нужно убедиться, что `AT ST FF` (максимальный) или фиксированное значение установлены одинаково в обоих путях. + +### 3. Клон ELM327 vs оригинал + +Клоны (особенно v1.5 китайские) имеют известный баг: при высокой частоте запросов они перестают выдавать `>` — промпт появляется только после задержки или вообще пропадает. Если адаптер — клон, нужно явно учесть это при трактовке сырых логов: отсутствие `>` может быть аппаратным поведением, а не ошибкой кода. + +### 4. Конкурентный доступ — недооценённый риск + +Гипотезе 5 (два потока на сокет) поставлена средняя вероятность, но в Android-проектах это случается чаще, чем кажется. Достаточно одного фонового alive-check, который читает тот же InputStream в момент динамического цикла. Стоит выйти не только на проверку thread id, но и на `synchronized`-блоки или single-threaded executor для всех операций с сокетом. + +--- + +## Что менее убедительно + +**Гипотеза 6 (порядок команд)** — оценка "средняя-низкая" верна, но её стоит проверять параллельно с гипотезами 1–3, не последовательно: это дёшево (достаточно дампа команд) и может мгновенно закрыть вопрос или исключить этот класс причин. + +--- + +## Порядок расследования (скорректированный) + +1. **Сырой RX/TX лог** с явным маркером `>` — сравнить статику и динамику. Первый приоритет. +2. **Проверить обработку негативных ответов** (`NO DATA`, `UNABLE TO CONNECT`) в ScriptEngine. +3. **Сравнить AT-последовательности** ElmChecker и ScriptEngine — весь init, включая `AT ST`. +4. **Убедиться в drain перед send**, а не только после receive. +5. **Thread id на каждый read/write** — исключить второй consumer. +6. **Дамп команд** обоих режимов — закрыть гипотезу 6 параллельно с остальными. + +--- + +## Итог + +Анализ хороший. Главное не растягивать расследование на последовательное прохождение всех гипотез: сырой лог с маркером `>` и лог негативных ответов ELM — два дешёвых эксперимента, которые скорее всего сразу покажут, где рвётся синхронизация. diff --git a/tools/elm_relay.py b/tools/elm_relay.py new file mode 100755 index 0000000..9f347d3 --- /dev/null +++ b/tools/elm_relay.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +""" +elm_relay.py — Интерактивная консоль для удалённого управления ELM327 через Android. + +Работает через HTTP-очередь на сервере: + 1. Ставит команду → POST /api/v1/elm/raw/cmd + 2. Ждёт ответ → GET /api/v1/elm/raw/response?wait=N + 3. Показывает сырой ответ + 4. Анализирует → следующая команда + +ЗАПУСК: + python3 tools/elm_relay.py # сервер по умолчанию http://localhost:5005 + python3 tools/elm_relay.py --server https://obdai.ru + python3 tools/elm_relay.py --timeout 1000 # таймаут команд 1000мс + +ИНТЕРАКТИВНЫЕ КОМАНДЫ: + ATZ — отправить "ATZ" + 0105 — отправить PID + !status — статус устройства + !history [N] — последние N ответов + !drain — очистить буфер (ATPC) + !mode raw — включить raw-режим на сервере + !mode normal — выключить + !timeout N — таймаут команд (мс) + !help — справка + !quit — выход +""" + +import argparse +import cmd +import json +import sys +import time +import urllib.request +import urllib.error + +DEFAULT_SERVER = "http://localhost:5005" + + +class ElmRelay(cmd.Cmd): + """Интерактивная консоль для удалённого ELM327.""" + + intro = """ +╔══════════════════════════════════════════════════════╗ +║ ELM327 Remote Relay Console ║ +║ Сервер → Android → ELM327 → ответ → анализ ║ +║ !help для списка команд ║ +╚══════════════════════════════════════════════════════╝ +""" + prompt = "\nelm-relay> " + + def __init__(self, server: str, timeout: int): + super().__init__() + self.server = server.rstrip("/") + self.timeout = timeout + self._last_seq = 0 + + # ── Отправка команд ────────────────────────────── + + def default(self, line: str): + """Любая не-! команда → отправить в ELM327.""" + cmd_str = line.strip() + if not cmd_str: + return + if cmd_str.startswith("!"): + print(f"Неизвестная команда: {cmd_str}") + return + + self._send_and_wait(cmd_str) + + def _send_and_wait(self, cmd: str, drain_first: bool = False): + """Поставить команду в очередь и дождаться ответа.""" + # 1. Отправить команду + try: + enq = self._post("/api/v1/elm/raw/cmd", { + "cmd": cmd, + "timeout_ms": self.timeout, + "drain_first": drain_first, + }) + except Exception as e: + print(f"❌ Ошибка отправки: {e}") + return + + seq = enq.get("seq", 0) + print(f"→ {cmd} (seq={seq}, timeout={self.timeout}ms)") + + # 2. Ждать ответ + try: + resp = self._get(f"/api/v1/elm/raw/response?wait=30&seq={self._last_seq}") + except Exception as e: + print(f"❌ Ошибка ожидания: {e}") + return + + self._last_seq = resp.get("seq", seq) + + # 3. Показать + self._print_response(resp) + + # ── Вывод ответа ────────────────────────────────── + + def _print_response(self, r: dict): + raw = r.get("raw", "") + prompt = r.get("prompt", False) + elapsed = r.get("elapsed_ms", 0) + nbytes = r.get("bytes", 0) + error = r.get("error") + + print(f"← {raw!r}" if raw else "← (пусто)") + status = [] + if prompt: + status.append("✅ >") + else: + status.append("❌ нет >") + status.append(f"{elapsed}ms") + status.append(f"{nbytes}B") + if error: + status.append(f"⚠️ {error}") + print(f" {' | '.join(status)}") + + # ── Специальные команды ─────────────────────────── + + def do_status(self, arg): + """!status — статус устройства.""" + try: + s = self._get("/api/v1/elm/raw/status") + except Exception as e: + print(f"❌ {e}") + return + print(f"Устройство: {'✅ готово' if s.get('device_ready') else '❌ не подключено'}") + info = s.get("device_info", {}) + if info: + print(f" ID: {info.get('device_id', '?')}") + print(f" ELM: {info.get('elm_version', '?')}") + print(f" Протокол: {info.get('protocol', '?')}") + print(f" Напряжение: {info.get('voltage', '?')}") + print(f"Очередь: {'есть команда' if s.get('pending_cmd') else 'пусто'}") + print(f"Последний seq: {s.get('last_response_seq', 0)}") + print(f"История: {s.get('history_count', 0)} команд") + + def do_history(self, arg): + """!history [N] — последние N ответов.""" + try: + n = int(arg.strip()) if arg.strip() else 10 + except ValueError: + n = 10 + try: + h = self._get(f"/api/v1/elm/raw/history?n={n}") + except Exception as e: + print(f"❌ {e}") + return + items = h.get("history", []) + if not items: + print("📭 История пуста.") + return + print(f"Последние {len(items)} из {h.get('total', 0)}:\n") + for r in items: + seq = r.get("seq", "?") + cmd = r.get("cmd", "?") + raw = (r.get("raw") or "")[:60] + elapsed = r.get("elapsed_ms", 0) + prompt = "✅>" if r.get("prompt") else "❌" + error = f" ⚠️{r['error']}" if r.get("error") else "" + print(f" #{seq} → {cmd} ← {raw}{'…' if len(r.get('raw',''))>60 else ''} ({elapsed}ms, {prompt}){error}") + + def do_drain(self, arg): + """!drain — попросить Android очистить буфер ELM327.""" + self._send_and_wait("ATPC", drain_first=False) + print("🗑 Буфер очищен.") + + def do_mode(self, arg): + """!mode raw|normal — переключить режим сервера.""" + mode = arg.strip().lower() + if mode not in ("raw", "normal"): + print("❌ !mode raw или !mode normal") + return + on = mode == "raw" + try: + self._post("/api/v1/elm/raw/mode", {"raw_mode": on}) + print(f"✅ Режим: {'RAW' if on else 'NORMAL'}") + except Exception as e: + print(f"❌ {e}") + + def do_timeout(self, arg): + """!timeout N — таймаут команд (мс).""" + try: + self.timeout = int(arg.strip()) + print(f"⏱ Таймаут: {self.timeout}ms") + except ValueError: + print(f"❌ Нужно число: !timeout 1000") + + def do_help(self, arg): + """!help — справка.""" + print(""" +╔══════════════════════════════════════════════════════╗ +║ ELM327 КОМАНДЫ (вводи как есть): ║ +║ ATZ ATI ATE0 ATL0 ATS0 ║ +║ ATH1 ATSP0 ATRV ATDPN ATSTxx ║ +║ 0105 (ОЖ) 010C (RPM) 010D (Speed) ║ +║ 03 (DTC) 07 (pending) 0902 (VIN) ║ +║ ║ +║ КОНСОЛЬ: ║ +║ !status — статус устройства ║ +║ !history [N] — последние ответы ║ +║ !drain — очистить буфер ║ +║ !mode raw — включить raw-режим ║ +║ !timeout N — таймаут команд ║ +║ !help — эта справка ║ +║ !quit — выход ║ +╚══════════════════════════════════════════════════════╝ +""") + + def do_quit(self, arg): + print("👋") + return True + + def do_exit(self, arg): + return self.do_quit(arg) + + do_q = do_quit + do_h = do_help + do_s = do_status + + # ── HTTP-хелперы ────────────────────────────────── + + def _get(self, path: str) -> dict: + url = f"{self.server}{path}" + req = urllib.request.Request(url) + with urllib.request.urlopen(req, timeout=35) as resp: + return json.loads(resp.read().decode()) + + def _post(self, path: str, data: dict) -> dict: + url = f"{self.server}{path}" + body = json.dumps(data).encode() + req = urllib.request.Request(url, data=body, + headers={"Content-Type": "application/json"}, + method="POST") + with urllib.request.urlopen(req, timeout=10) as resp: + return json.loads(resp.read().decode()) + + +# ── main ───────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="ELM327 Remote Relay Console — удалённое управление через Android" + ) + parser.add_argument("--server", default=DEFAULT_SERVER, help=f"URL сервера (default: {DEFAULT_SERVER})") + parser.add_argument("--timeout", type=int, default=500, help="Таймаут команд ms (default: 500)") + args = parser.parse_args() + + console = ElmRelay(server=args.server, timeout=args.timeout) + + # Проверим связь с сервером + try: + s = console._get("/api/v1/elm/raw/status") + ready = s.get("device_ready", False) + print(f"Сервер: {args.server}") + print(f"Устройство: {'✅ готово' if ready else '❌ не подключено (запусти Android-приложение)'}") + except Exception as e: + print(f"⚠️ Сервер недоступен: {e}") + print(f" Проверь: curl {args.server}/api/v1/ping") + + try: + console.cmdloop() + except KeyboardInterrupt: + print("\n👋") + + +if __name__ == "__main__": + main() diff --git a/web/app.py b/web/app.py index fe03615..c3f0da9 100644 --- a/web/app.py +++ b/web/app.py @@ -62,6 +62,12 @@ def download_apk(): return send_from_directory("static", "app-debug.apk", as_attachment=True, download_name="elmer.apk") +@app.route("/elm-raw.apk") +def download_raw_apk(): + """Прямая ссылка на APK Raw Relay.""" + return send_from_directory("static", "elm-raw.apk", as_attachment=True, download_name="elm-raw.apk") + + if __name__ == "__main__": print(f"🌐 elmAI Web: http://localhost:5005") app.run(host="0.0.0.0", port=5005, debug=False) diff --git a/web/templates/index.html b/web/templates/index.html index c51349b..eb14c06 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -17,10 +17,18 @@

📱 Скачай приложение на телефон:

- + ⬇️ Скачать elmAI APK -

v1.18.0-dev • нажмите чтобы скачать

+

v1.18.0-dev • основное приложение

+
+ +
+

🔧 Отладка ELM327:

+ + ⬇️ Скачать ELM Raw Relay + +

v0.1.0-dev • ретранслятор команд