feat: raw relay — очередь команд, эндпоинты, консоль, index.html

This commit is contained in:
Repinoid
2026-06-14 18:53:24 +04:00
parent d01028b12b
commit c229b730c6
6 changed files with 898 additions and 116 deletions
+293
View File
@@ -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 — только статус и лог
@@ -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 — два дешёвых эксперимента, которые скорее всего сразу покажут, где рвётся синхронизация.