Compare commits
97
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82001e5d1f | ||
|
|
105df40a55 | ||
|
|
b40d4a8158 | ||
|
|
1d83b4d6e1 | ||
|
|
f3bf5e66c7 | ||
|
|
2d02df97e5 | ||
|
|
b26185133d | ||
|
|
64f9d0fb20 | ||
|
|
6aadf66a38 | ||
|
|
c6272d3080 | ||
|
|
2d9b41507a | ||
|
|
83972ee420 | ||
|
|
c1b26ce40e | ||
|
|
44b0cdc173 | ||
|
|
6ee3f24eff | ||
|
|
abca3691f9 | ||
|
|
daa4ce53e0 | ||
|
|
da362210fe | ||
|
|
7e368c01d5 | ||
|
|
17a4764e4c | ||
|
|
b450778b7e | ||
|
|
a1154e9993 | ||
|
|
5b40d28c53 | ||
|
|
788b757de9 | ||
|
|
d4cc47eadc | ||
|
|
f89caf74de | ||
|
|
4f90e3eadf | ||
|
|
9762640abb | ||
|
|
150631024e | ||
|
|
d88b90049f | ||
|
|
f02cd5e226 | ||
|
|
801e286cfc | ||
|
|
b95cf1fbad | ||
|
|
b7823d6008 | ||
|
|
20be9103af | ||
|
|
76abc22b79 | ||
|
|
290e289517 | ||
|
|
e6e575b9e6 | ||
|
|
211566d557 | ||
|
|
c111e651de | ||
|
|
4d38cb945f | ||
|
|
fd2126c0cb | ||
|
|
d5e44060d3 | ||
|
|
3334baf8fc | ||
|
|
aaaa370764 | ||
|
|
bb309eaccb | ||
|
|
2de4bb9443 | ||
|
|
aa37a1a70c | ||
|
|
7ae0fc5702 | ||
|
|
0fb2c81a86 | ||
|
|
0b322882a7 | ||
|
|
3ee3930c18 | ||
|
|
ceb71e5b9d | ||
|
|
fd8059bb6a | ||
|
|
8da4876cb8 | ||
|
|
0de7e0591c | ||
|
|
7aaebf8481 | ||
|
|
d2f42e99bb | ||
|
|
dec23bc140 | ||
|
|
4c71d7a4c9 | ||
|
|
1e8fd62039 | ||
|
|
f83a14a1ce | ||
|
|
beecf2fb9e | ||
|
|
7cf1fe0653 | ||
|
|
6798ba1012 | ||
|
|
64acb8c60a | ||
|
|
9d08ff4c75 | ||
|
|
9049383e87 | ||
|
|
64fba6941d | ||
|
|
eb79072dd4 | ||
|
|
67ef15df23 | ||
|
|
c532ce4f0d | ||
|
|
352fa1052d | ||
|
|
ce0b40d8dc | ||
|
|
f6461014a3 | ||
|
|
166c34419c | ||
|
|
03bf84c289 | ||
|
|
126598dd8a | ||
|
|
7013fcf56a | ||
|
|
fc897367b6 | ||
|
|
787ed77b7a | ||
|
|
2a07b5bfa1 | ||
|
|
f215cff21a | ||
|
|
829487ad67 | ||
|
|
02753a6de9 | ||
|
|
820bcfcbb5 | ||
|
|
f4475c441f | ||
|
|
7f71cb1709 | ||
|
|
8751d009fc | ||
|
|
07944d86d3 | ||
|
|
dda72ccfc6 | ||
|
|
6893ae33f5 | ||
|
|
eab73cd76a | ||
|
|
2db6cb68a8 | ||
|
|
2028c7ff0e | ||
|
|
2d5c704edf | ||
|
|
0d1cf026dc |
@@ -0,0 +1,50 @@
|
||||
# Правила для GitHub Copilot
|
||||
|
||||
## 1. Коммит после каждой правки
|
||||
|
||||
После **любого** изменения кода (исправление бага, рефакторинг, добавление фичи) — сразу делать коммит с осмысленным сообщением:
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "краткое описание" && git push origin master
|
||||
```
|
||||
|
||||
- Без накопления изменений
|
||||
- Без "потом закоммичу"
|
||||
- Один коммит = одна правка
|
||||
|
||||
## 2. При деплое ВСЕГДА повышать версию
|
||||
|
||||
Перед **каждым** деплоем APK на сервер — инкрементировать патч-версию в `android/app/build.gradle.kts`:
|
||||
|
||||
```
|
||||
versionName = "X.Y.Z-dev" → versionName = "X.Y+1.Z-dev"
|
||||
```
|
||||
|
||||
### Процедура
|
||||
1. Открыть `android/app/build.gradle.kts`
|
||||
2. Увеличить номер в `versionName` (только патч, Z в X.Y.Z-dev)
|
||||
3. Закоммитить с сообщением `"bump vX.Y.Z-dev"`
|
||||
4. Затем — сборка и деплой
|
||||
|
||||
### Пример (актуальный)
|
||||
```
|
||||
Было: versionName = "0.47.0-dev"
|
||||
Стало: versionName = "0.48.0-dev"
|
||||
```
|
||||
|
||||
## 3. Формат коммитов
|
||||
|
||||
- `fix: ...` — исправление бага
|
||||
- `feat: ...` — новая фича
|
||||
- `refactor: ...` — переработка кода
|
||||
- `bump: ...` — повышение версии
|
||||
- `docs: ...` — документация
|
||||
|
||||
## 4. НИЧЕГО не делать без прямого указания
|
||||
|
||||
Запрещено что-либо делать (править код, коммитить, деплоить, менять конфиги) без явной команды пользователя. Ждать указаний.
|
||||
|
||||
|
||||
## 5. На вопросы — только отвечать
|
||||
|
||||
Если пользователь задал вопрос (в любой форме — утвердительной, вопросительной, с матом, без) — **ТОЛЬКО ответить**. Никаких изменений кода, файлов, конфигов. Даже если вопрос про баг — не чинить, а только объяснить.
|
||||
@@ -3,3 +3,4 @@ __pycache__/
|
||||
android/
|
||||
web/static/app-debug.apk
|
||||
creds.txt
|
||||
token*.*
|
||||
@@ -0,0 +1,57 @@
|
||||
# Инструкция для Copilot — проект elmAI
|
||||
|
||||
> Последнее обновление: 31 мая 2026 · v0.36.0-dev
|
||||
|
||||
## Репозитории
|
||||
|
||||
| Репо | Назначение | Хостинг |
|
||||
|------|-----------|---------|
|
||||
| `elmer/` (этот) | Сервер Python/Flask | gitea.services.ngcloud.ru/Nail/elmer |
|
||||
| `elmer/android/` | Android-приложение Kotlin | github.com/Repinoid/elmer-android |
|
||||
|
||||
## Деплой
|
||||
|
||||
### Сервер (obdai.ru, 5.172.178.213)
|
||||
- Код: `/opt/elmer` (git clone gitea)
|
||||
- Ветка: `master` (по умолчанию)
|
||||
- Деплой: `ssh obdai.ru "cd /opt/elmer && git pull && sudo systemctl restart elmer"`
|
||||
- Сервис: `gunicorn -w 4 -b 127.0.0.1:8000 web.app:app`
|
||||
- Прокси: nginx :443 → :8000
|
||||
- **APK отдавать напрямую через nginx, НЕ через Flask/gunicorn:** `location = /elmer.apk { alias /opt/elmer/web/static/app-debug.apk; }`
|
||||
|
||||
### APK
|
||||
- Сборка: **автоматически GitHub Actions** при пуше в master
|
||||
- Деплой: CI сам заливает APK на сервер (`appleboy/scp-action`)
|
||||
- Ссылка: `https://obdai.ru/elmer.apk` → nginx отдаёт напрямую `web/static/app-debug.apk`
|
||||
|
||||
### Версионирование
|
||||
- APK: `android/app/build.gradle.kts` → `versionName`
|
||||
- Сайт: `web/templates/index.html` (синхронизировать вручную)
|
||||
- Документация: в заголовках `.md` файлов
|
||||
|
||||
## Правила работы
|
||||
|
||||
1. **ЕСЛИ в диалоге содержится ВОПРОС в любой форме — только ответить. НИЧЕГО НЕ ПРЕДПРИНИМАТЬ.** Не писать код, не редактировать файлы, не коммитить, не деплоить. Только прямые императивы («сделай», «исправь», «напиши», «внеси», «задеплой») — команда к действию.
|
||||
2. **Не выдумывать инфраструктуру.** Никаких Docker, Kubernetes. Всё на голом железе.
|
||||
3. **Читать документацию перед действиями.** `doc/architecture.md` — канонический источник.
|
||||
3. **Не редактировать отчёты Опуса.** `doc/opus-review*.md` — только для чтения.
|
||||
4. **Ключи и токены:** LLM-ключ только на сервере (`config.yaml`), НЕ в APK. `X-Api-Key` приложения — через `BuildConfig.API_KEY` из `local.properties`.
|
||||
5. **Git:** `elmer/` и `elmer/android/` — отдельные репо, отдельные коммиты.
|
||||
6. **Ветки:** `master` — продакшен, `opus-fixes` и др. — для правок. Вливать в master когда готово.
|
||||
7. **После правок:** коммит + пуш + (если сервер) деплой через SSH.
|
||||
8. **Версия:** менять в трёх местах — `build.gradle.kts`, `index.html`, доки.
|
||||
9. **Документировать изменения** в `doc/history/YYYY-MM-DD.md` после каждого сеанса работы. Формат: 🔴/🟡/🟢 для приоритета, по файлам.
|
||||
|
||||
## Структура сервера
|
||||
|
||||
```
|
||||
elmer/
|
||||
├── api/ # REST, БД, скрипты, парсер
|
||||
├── brain/ # LLM-клиент, промпты
|
||||
├── obd/ # ELM327 стейт-машина
|
||||
├── web/ # Flask, шаблоны, статика (APK)
|
||||
├── android/ # Android-приложение (отдельный репо)
|
||||
├── doc/ # Документация
|
||||
├── config.yaml # LLM API key, порты
|
||||
└── run.py # Локальный запуск
|
||||
```
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
# elmAI — Changelog / Полное описание проекта
|
||||
|
||||
> Файл для нового агента: прочитай — и ты в курсе всего.
|
||||
> Актуально: v0.77.0-dev, 7 июня 2026
|
||||
|
||||
---
|
||||
|
||||
## 1. Идентификация
|
||||
|
||||
| Поле | Значение |
|
||||
|------|----------|
|
||||
| Название | **elmAI** (ранее Elmer, elmAI rebrand в конце мая) |
|
||||
| Суть | OBD2-диагностика: Android → ELM327 → LLM (анализ ошибок) |
|
||||
| Репозиторий сервера | `gitea.services.ngcloud.ru/Nail/elmer` |
|
||||
| Репозиторий Android | `github.com/Repinoid/elmer-android` (отдельный!) |
|
||||
| Сервер (prod) | `obdai.ru` (5.172.178.213) |
|
||||
| Язык сервера | Python 3 + Flask + gunicorn |
|
||||
| Язык клиента | Kotlin, minSdk 24 |
|
||||
| LLM-провайдер | `api.aillm.ru` (OpenAI-совместимый) |
|
||||
| База данных | SQLite (WAL mode) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Архитектура (master — продакшен, актуально)
|
||||
|
||||
```
|
||||
📱 Android → ELM327 (Bluetooth SPP)
|
||||
│
|
||||
▼ ScriptRunnerService (фоновая служба)
|
||||
│ выполняет скрипт: OBD-команды → ждёт ответы → пишет лог
|
||||
│ HTTPS POST /api/v1/session/upload
|
||||
▼
|
||||
🌐 obdai.ru (nginx :443 → gunicorn :8000)
|
||||
│
|
||||
├── web/app.py — точка входа Flask
|
||||
├── api/routes.py — 5 эндпоинтов
|
||||
├── api/db.py — SQLite (sessions, cars, dtc, params)
|
||||
├── api/parser.py — парсинг батча ELM-ответов (VIN, DTC, PID)
|
||||
├── api/scripts.py — сборка диагностических скриптов
|
||||
├── api/config.py — загрузка config.yaml (c lru_cache)
|
||||
├── brain/client.py — Diagnoser: HTTP к api.aillm.ru
|
||||
├── brain/prompts.py — SYSTEM_PROMPT (10 правил, табличный формат)
|
||||
└── obd/protocol.py — AndrOBD-стейт-машина (1:1 копия ElmProt.java)
|
||||
```
|
||||
|
||||
### Модули подробно
|
||||
|
||||
#### `api/` — REST + БД + парсинг
|
||||
|
||||
**5 эндпоинтов:**
|
||||
|
||||
| Эндпоинт | Метод | Что делает | Время |
|
||||
|----------|-------|-----------|-------|
|
||||
| `/api/v1/ping` | GET | `{"ok": true}` — проверка сервера | ~5ms |
|
||||
| `/api/v1/ping-llm` | GET | Проверка LLM (кэш 60с, глобальная переменная) | ~2s |
|
||||
| `/api/v1/script?mode=full` | GET | Выдача скрипта диагностики (JSON со steps) | ~50ms |
|
||||
| `/api/v1/session/upload` | POST | Приём батча + LLM-анализ, идемпотентность | ~30-120s |
|
||||
| `/api/v1/chat` | POST | Свободный вопрос к LLM (с историей) | ~5-15s |
|
||||
|
||||
**Ключевые особенности:**
|
||||
- `upload_session()`: принимает `responses` (массив `{cmd, raw, decoded}`) + `request_id` (UUID для идемпотентности). Если `request_id` уже есть в БД — возвращает кэшированный ответ (200).
|
||||
- LLM fallback: если нет API key или LLM ошибка — возвращает `format_no_llm()` (сырые данные без анализа).
|
||||
- `_build_diagnosis_prompt()`: собирает промпт из VIN, DTC stored/pending, параметров, raw_log. Требует глубокого разбора.
|
||||
- `/chat`: передаёт историю как массив `messages[{role, content}]`, срез последних 10.
|
||||
- `config.load()` — `@lru_cache(maxsize=1)`, сбрасывать рестартом процесса.
|
||||
|
||||
**База данных (SQLite, WAL):**
|
||||
- `sessions` — сводная таблица (клиент, ELM, авто, LLM, request_id, response_json для кэша)
|
||||
- `cars` — VIN → id (уникальные)
|
||||
- `diagnostic_tokens`, `llm_messages`, `ecu_parameters`, `dtc_codes` — детальные таблицы (не используются в upload, только legacy)
|
||||
- `PRAGMA journal_mode=WAL`, `busy_timeout=30000`, `check_same_thread=False`
|
||||
|
||||
**Парсер (`parser.py`):**
|
||||
- `parse_batch(responses)` → `{vin, dtc_stored[], dtc_pending[], parameters[{name,value}], raw_log[]}`
|
||||
- Парсит из `decoded` поля, fallback из сырого HEX (490201..., 43..., 47...)
|
||||
- DTC из HEX: декодирует P/B/C/U коды из байтов после 43/47
|
||||
- PID: всё что с `:` в decoded, кроме VIN/DTC/ELM/Protocol
|
||||
|
||||
**Скрипты (`scripts.py`):**
|
||||
- `build_full_script()`: 7 PID (ОЖ, RPM, скорость, дроссель, нагрузка, STFT, LTFT)
|
||||
- `build_default_script()`: 1 PID (0105 — температура ОЖ) для отладки
|
||||
|
||||
#### `brain/` — LLM
|
||||
|
||||
- `Diagnoser(api_key, model="gpt-oss-120b", base_url, timeout=180)`
|
||||
- `diagnose(system, user_prompt, history=None)` → str
|
||||
- Ошибки: `LLMError` с безопасным для клиента сообщением (без деталей)
|
||||
- Различает: Timeout, HTTP 429 («слишком много запросов»), HTTP 5xx, HTTP 4xx
|
||||
- `SYSTEM_PROMPT`: 10 правил, формат ответа — таблицы, степени уверенности в %, план действий по приоритету
|
||||
- Модели: `gpt-oss-120b` (основная), `qwen3-6-27b-fp8` (быстрая, но с CoT leak bug)
|
||||
|
||||
#### `obd/protocol.py` — ELM327 стейт-машина (AndrOBD)
|
||||
|
||||
**Состояния:** `UNDEFINED → INITIALIZING → READY → BUSY → READY`, ошибка → `ERROR`/`DISCONNECTED`
|
||||
|
||||
**Классификация ответов (Rsp.identify):**
|
||||
| Ответ | Тип | Реакция |
|
||||
|-------|-----|---------|
|
||||
| `>` | PROMPT | Конец ответа |
|
||||
| `OK` | OK | Уменьшить таймаут |
|
||||
| `SEARCHING...` | SEARCHING | Нормально при ините |
|
||||
| `NODATA` | NODATA | Увеличить таймаут, ATST |
|
||||
| `UNABLE/BUS BUSY/CAN ERROR` | BUS ERROR | DISCONNECTED → ATPC → ATSP0 |
|
||||
| `ERROR/DATA ERROR/BUFFER FULL` | ERROR | ATWS (warm start) |
|
||||
| Всё остальное | DATA | Успех, уменьшить таймаут |
|
||||
|
||||
**Ключевые особенности (1:1 с AndrOBD):**
|
||||
- Побайтовое чтение с поллингом 1ms (НЕ readLine!)
|
||||
- `>` (0x3E) — не спецсигнал, а разделитель строк как CR/LF
|
||||
- Адаптивный таймаут: 50-2000ms, шаг 20ms, ATST = timeout/4
|
||||
- `_write()`: `reset_input_buffer()` перед записью — чистит хвосты
|
||||
- Инициализация: ATSP0 → ATAT1 → ATST → ATS0 → ATL0 → ATE0 (без ATZ)
|
||||
- BUS ERROR recovery: ATPC → ATSP0
|
||||
- `_read()`: требует `>` перед возвратом, иначе TimeoutError
|
||||
|
||||
#### `web/app.py` — точка входа
|
||||
|
||||
- `sys.path.insert(0, корень_проекта)` — чтобы импортировать `api/`, `brain/`, `obd/`
|
||||
- `config = load()` — глобально
|
||||
- `register_api(app)` — подключает эндпоинты
|
||||
- `/elmer.apk` → `send_from_directory("static", "app-debug.apk")`
|
||||
|
||||
---
|
||||
|
||||
## 3. Версии и история
|
||||
|
||||
### Система версионирования
|
||||
- **Сервер:** `web/templates/index.html` (два места: подзаголовок и подпись APK)
|
||||
- **Android:** `android/app/build.gradle.kts` → `versionName`
|
||||
- **Документация:** заголовки `.md` файлов
|
||||
- **Менять одновременно во всех местах**
|
||||
|
||||
### История версий (сервер)
|
||||
|
||||
#### v0.48.0 (7 июня 2026)
|
||||
- **Пробинг ELM327:** трехуровневый каскад (L0/L1/L2)
|
||||
- **Рефакторинг `obd/`:** разделение на независимые сервисы
|
||||
- `commands.py` — каталог всех AT-команд
|
||||
- `classifier.py` — классификация ответов + определение уровня
|
||||
- `connection.py` — транспортный слой (SerialTransport)
|
||||
- **Fix:** `init()` больше не шлёт ATAT1/ATST (висли на клонах v1.5)
|
||||
- **Скрипты:** три уровня (`build_script_l0/l1/l2`)
|
||||
- **БД:** таблица `device_profiles` по BT MAC
|
||||
- **API:** `POST /api/v1/elm/probe`, `GET /api/v1/elm/profile/<mac>`
|
||||
- `api/routes.py` — `/script?level=0|1|2`
|
||||
|
||||
#### v0.36.0-dev (31 мая 2026)
|
||||
- Ребрендинг Elmer → elmAI (лого, сайт)
|
||||
- Opus review: серверные фиксы (WAL, идемпотентность, таймауты LLM, кэш ping-llm)
|
||||
- `api/config.py` — `@lru_cache`, `api/db.py` — контекстный менеджер + request_id
|
||||
- `brain/client.py` — обработка ошибок (различает 429, 5xx, Timeout), DEFAULT_MODEL
|
||||
- `obd/protocol.py` — `reset_input_buffer()`, не затирать ERROR
|
||||
- CI/CD: GitHub Actions для Android APK (сборка + деплой на сервер)
|
||||
- Правило №1 в `.instructions.md`: вопрос → только ответ, никаких действий
|
||||
|
||||
#### v0.35.0-dev (29-30 мая 2026)
|
||||
- Рефакторинг архитектуры: `elmer/` → `api/` + `brain/` + `obd/`
|
||||
- Удалён мёртвый код (`elmer/diagnose.py`, `elmer/elm.py`, `elmer/prompts.py`, `web/raw_endpoint.py`)
|
||||
- `doc/architecture.md` — полное описание структуры
|
||||
- `obd/protocol.py` — вынесен из elm_proto, доработан
|
||||
|
||||
#### v0.13.0-dev (28 мая 2026) — AndrOBD стейт-машина
|
||||
- Стейт-машина 1:1 с AndrOBD (ElmProt.java)
|
||||
- 5 багов исправлено (команды подряд без пауз, ATST не читал ответ, частичный read, таймаут 200ms, BUS ERROR recovery)
|
||||
- AdaptiveTiming (500ms start, 50-2000ms range, ATST)
|
||||
- OkHttp timeout 30→120с
|
||||
- `tools/mock_elm327_v2.py` — мок с реалистичными задержками
|
||||
- `tools/test_androbd.py` — тест стейт-машины (2/3 зелёные)
|
||||
|
||||
#### v0.11.0-prod (25-27 мая 2026) — fat-client архитектура
|
||||
- Fat-client: телефон сам гоняет протокол, сервер только батч-анализ
|
||||
- Скрипт диагностики: GET /api/v1/script
|
||||
- Загрузка батча: POST /api/v1/session/upload
|
||||
- Эндпоинт /chat
|
||||
- Исследование 19 ELM/BT проектов → AndrOBD = золотой стандарт
|
||||
- `doc/elm-reference.md` — 1100+ строк паттернов
|
||||
- Домен obdai.ru, решение НЕ деплоить до стабильного ELM↔Android
|
||||
|
||||
#### v0.x — ранние версии (fat-client ветка)
|
||||
- `TestService.kt` — зелёная кнопка: самостоятельный прогон протокола (100% работает)
|
||||
- `ElmForwardService.kt` — транспорт BT/TCP ↔ HTTP (проблемы: deadlock, паузы)
|
||||
- Побайтовое чтение в mock (исправлен мусор `\r` vs `\r\n`)
|
||||
- Фиксированный debug.keystore (пароль `android`, alias `androiddebugkey`)
|
||||
- `AndroidManifest.xml`: `usesCleartextTraffic="true"`
|
||||
|
||||
---
|
||||
|
||||
## 4. Android-клиент (отдельный репо)
|
||||
|
||||
### Структура
|
||||
```
|
||||
app/src/main/java/ru/elmer/client/
|
||||
├── elm/
|
||||
│ └── ElmProtocol.kt — ELM327 стейт-машина (AndrOBD)
|
||||
├── obd/
|
||||
│ └── ObdDecoder.kt — декодер PID/DTC/VIN
|
||||
├── server/
|
||||
│ └── ServerClient.kt — HTTP к серверу (retry 3x, OkHttp)
|
||||
├── script/
|
||||
│ ├── ScriptEngine.kt — движок скриптов
|
||||
│ └── ScriptRunnerService.kt — фоновая диагностика
|
||||
├── db/
|
||||
│ └── SessionDb.kt — локальная SQLite история
|
||||
└── ui/
|
||||
└── MainActivity.kt — UI + кнопки
|
||||
```
|
||||
|
||||
### Что НЕ ДОРАБОТАНО (по opus-fix-plan, этап 3-4):
|
||||
- [ ] `request_id` на клиенте (UUID до цикла ретраев, в JSON + заголовок Idempotency-Key)
|
||||
- [ ] `X-Api-Key` через `BuildConfig.API_KEY` из `local.properties`
|
||||
- [ ] `ElmProtocol.sendCommand()` — не затирать ERROR, дренаж буфера
|
||||
- [ ] `SessionDb.onUpgrade()` — ALTER TABLE вместо DROP TABLE
|
||||
- [ ] `MainActivity` — троттлинг `/ping-llm` (не чаще 60с), убрать дублирующий receiver
|
||||
- [ ] `ScriptRunnerService` — null intent → stopSelf, try/finally для progress.stop()
|
||||
- [ ] Exponential backoff в ретраях
|
||||
|
||||
---
|
||||
|
||||
## 5. Деплой
|
||||
|
||||
```bash
|
||||
# Сервер
|
||||
ssh obdai.ru "cd /opt/elmer && git pull origin master && sudo systemctl restart elmer"
|
||||
|
||||
# APK (локально)
|
||||
cd android && ./gradlew assembleDebug
|
||||
scp app/build/outputs/apk/debug/app-debug.apk obdai.ru:/opt/elmer/web/static/app-debug.apk
|
||||
```
|
||||
|
||||
**Сервер:** gunicorn `-w 4 -b 127.0.0.1:8000 web.app:app`, nginx :443 → :8000, SSL certbot.
|
||||
**Ветка:** `master` — продакшен.
|
||||
**Никаких** Docker, Kubernetes. Сервер на голом железе. Android APK: GitHub Actions → сборка → авто-деплой на сервер.
|
||||
|
||||
---
|
||||
|
||||
## 6. Правила работы (критически важно!)
|
||||
|
||||
1. **Вопрос в любой форме → только ответить. НИЧЕГО НЕ ПРЕДПРИНИМАТЬ.** Только прямые императивы («сделай», «исправь», «напиши») — команда к действию.
|
||||
2. **Не выдумывать инфраструктуру.** Никаких Docker, K8s. GitHub Actions можно (Android).
|
||||
3. **Читать документацию перед действиями.** `doc/architecture.md` — канонический источник.
|
||||
4. **Не редактировать отчёты Опуса** (`doc/opus-review*.md` — только для чтения).
|
||||
5. **После правок:** коммит → пуш → (если сервер) деплой через SSH.
|
||||
6. **Версию менять в трёх местах:** `index.html`, `build.gradle.kts`, доки.
|
||||
7. **Не редактировать файлы Android-репо** (elmer-android) — это отдельный репо.
|
||||
|
||||
---
|
||||
|
||||
## 7. Известные архитектурные решения
|
||||
|
||||
- **Почему не Docker:** владелец принципиально против. Всё вручную через git + systemd.
|
||||
- **Почему fat-client (скрипт + батч):** в движении связи с сервером нет. Телефон сам гоняет протокол, потом заливает данные.
|
||||
- **Почему AndrOBD (не своё):** AndrOBD — 10 лет продакшена, 1993⭐, вылизанный протокол. Копировать 1:1, не изобретать.
|
||||
- **Почему не PostgreSQL:** SQLite достаточно для одного сервера. Миграция будет когда-нибудь потом.
|
||||
- **Почему obdai.ru не в продакшене:** пока не отлажен ELM↔Android на 100%. Телефон + ноутбук в одной WiFi — быстрее и надёжнее.
|
||||
|
||||
---
|
||||
|
||||
## 8. Ветки
|
||||
|
||||
| Ветка | Описание |
|
||||
|-------|----------|
|
||||
| `master` | Продакшен (актуальная: v0.36.0-dev) |
|
||||
| `opus-fixes` | Правки по отчётам Опуса (31 мая; влита в master) |
|
||||
| `arch-refactor` | Рефакторинг elmer/ → api/ brain/ obd/ |
|
||||
| `fat-client` | Старая fat-client архитектура (устарела) |
|
||||
| `androbd-proto` | Прототип AndrOBD стейт-машины (устарела) |
|
||||
| `elm-layer-v2` | Старый ELM-слой (устарела) |
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Загрузка конфигурации из config.yaml."""
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
@@ -8,6 +9,7 @@ import yaml
|
||||
CONFIG_PATH = Path(os.environ.get("ELMER_CONFIG", Path(__file__).parent.parent / "config.yaml"))
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load() -> dict:
|
||||
"""Читает config.yaml, подставляет переменные окружения в значения."""
|
||||
if not CONFIG_PATH.exists():
|
||||
@@ -0,0 +1,317 @@
|
||||
"""SQLite — сохранение сессий диагностики + профили ELM-устройств.
|
||||
|
||||
Таблица sessions (35+ колонок):
|
||||
... (см. ниже)
|
||||
|
||||
Таблица device_profiles:
|
||||
mac (TEXT PK), level (INT), elm_version, elm_desc, protocol,
|
||||
supported (JSON), unsupported (JSON), first_seen, last_seen
|
||||
"""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, path: str | Path = "elmer.db"):
|
||||
self.path = Path(path)
|
||||
self.conn = sqlite3.connect(str(self.path), timeout=30, check_same_thread=False)
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
self.conn.execute("PRAGMA journal_mode=WAL")
|
||||
self.conn.execute("PRAGMA busy_timeout=30000")
|
||||
self._init_schema()
|
||||
|
||||
def __enter__(self):
|
||||
"""Контекстный менеджер: with Database() as db."""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Закрытие соединения при выходе из with-блока."""
|
||||
self.close()
|
||||
return False
|
||||
|
||||
def close(self):
|
||||
"""Закрыть соединение с SQLite."""
|
||||
if self.conn:
|
||||
self.conn.close()
|
||||
self.conn = None
|
||||
|
||||
def _init_schema(self):
|
||||
# Основная схема (может упасть на индексах старых БД — ловим)
|
||||
try:
|
||||
self.conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
-- Сервер
|
||||
client_ip TEXT,
|
||||
real_ip TEXT,
|
||||
user_agent TEXT,
|
||||
content_length INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
|
||||
-- Телефон
|
||||
phone_model TEXT,
|
||||
phone_maker TEXT,
|
||||
android_version TEXT,
|
||||
android_sdk INTEGER,
|
||||
app_version TEXT,
|
||||
android_id TEXT,
|
||||
device_uuid TEXT,
|
||||
phone_lang TEXT,
|
||||
phone_tz TEXT,
|
||||
phone_display TEXT,
|
||||
|
||||
-- ELM327
|
||||
elm_mac TEXT,
|
||||
elm_bt_name TEXT,
|
||||
obd_protocol TEXT,
|
||||
|
||||
-- Авто
|
||||
vin TEXT,
|
||||
dtc_count INTEGER DEFAULT 0,
|
||||
pid_count INTEGER DEFAULT 0,
|
||||
|
||||
-- Сессия
|
||||
duration_ms INTEGER,
|
||||
response_count INTEGER DEFAULT 0,
|
||||
error_count INTEGER DEFAULT 0,
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
timeout_count INTEGER DEFAULT 0,
|
||||
script_mode TEXT,
|
||||
transport TEXT,
|
||||
mock_mode INTEGER DEFAULT 0,
|
||||
car_info TEXT,
|
||||
|
||||
-- LLM
|
||||
diagnosis_text TEXT,
|
||||
diagnosis_len INTEGER,
|
||||
llm_model TEXT,
|
||||
llm_duration_ms INTEGER,
|
||||
llm_success INTEGER DEFAULT 0,
|
||||
|
||||
-- Сырые данные (JSON)
|
||||
raw_responses TEXT,
|
||||
|
||||
-- Идемпотентность
|
||||
request_id TEXT UNIQUE,
|
||||
response_json TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_created ON sessions(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_vin ON sessions(vin);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_mac ON sessions(elm_mac);
|
||||
|
||||
-- Профили ELM-устройств
|
||||
CREATE TABLE IF NOT EXISTS device_profiles (
|
||||
mac TEXT PRIMARY KEY,
|
||||
level INTEGER NOT NULL,
|
||||
elm_version TEXT,
|
||||
elm_desc TEXT,
|
||||
protocol TEXT,
|
||||
voltage TEXT,
|
||||
supported TEXT,
|
||||
unsupported TEXT,
|
||||
errors TEXT,
|
||||
first_seen TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_seen TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_level ON device_profiles(level);
|
||||
""")
|
||||
except sqlite3.OperationalError:
|
||||
pass # старая БД без новых колонок — применим миграции ниже
|
||||
|
||||
self.conn.commit()
|
||||
|
||||
# Миграции: добавляем колонки, которых нет в старых БД
|
||||
migrations = [
|
||||
"ALTER TABLE sessions ADD COLUMN device_uuid TEXT",
|
||||
"ALTER TABLE sessions ADD COLUMN phone_lang TEXT",
|
||||
"ALTER TABLE sessions ADD COLUMN phone_tz TEXT",
|
||||
"ALTER TABLE sessions ADD COLUMN phone_display TEXT",
|
||||
"ALTER TABLE sessions ADD COLUMN android_id TEXT",
|
||||
"ALTER TABLE sessions ADD COLUMN car_info TEXT",
|
||||
"ALTER TABLE sessions ADD COLUMN request_id TEXT",
|
||||
"ALTER TABLE sessions ADD COLUMN response_json TEXT",
|
||||
]
|
||||
for sql in migrations:
|
||||
try:
|
||||
self.conn.execute(sql)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
|
||||
# Индексы для новых колонок (могут отсутствовать в старых БД)
|
||||
index_migrations = [
|
||||
"CREATE INDEX IF NOT EXISTS idx_sessions_aid ON sessions(android_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_sessions_uuid ON sessions(device_uuid)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_sessions_request_id ON sessions(request_id)",
|
||||
]
|
||||
for sql in index_migrations:
|
||||
try:
|
||||
self.conn.execute(sql)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
|
||||
self.conn.commit()
|
||||
|
||||
# ── sessions ──────────────────────────────────────────
|
||||
|
||||
def get_cached_response(self, request_id: str) -> dict | None:
|
||||
"""Возвращает сохранённый ответ сессии по request_id, или None."""
|
||||
row = self.conn.execute(
|
||||
"SELECT response_json FROM sessions WHERE request_id = ?", (request_id,)
|
||||
).fetchone()
|
||||
if row and row["response_json"]:
|
||||
return json.loads(row["response_json"])
|
||||
return None
|
||||
|
||||
def save_session(self, client_info: dict, responses: list[dict],
|
||||
diagnosis: str = "", llm_model: str = "",
|
||||
llm_duration_ms: int = 0, llm_success: bool = False,
|
||||
request_id: str = "", response_json: dict | None = None):
|
||||
"""Сохраняет сводную запись о сессии.
|
||||
|
||||
Если request_id передан и уже существует — silently return (идемпотентность).
|
||||
"""
|
||||
ci = client_info
|
||||
|
||||
# Подсчёт DTC/PID из ответов
|
||||
dtc_count = 0
|
||||
pid_count = 0
|
||||
for r in responses:
|
||||
dec = (r.get("decoded") or "").lower()
|
||||
if dec.startswith("dtc"):
|
||||
dtc_count += 1
|
||||
elif ":" in dec and not dec.startswith(("vin", "dtc", "elm", "protocol")):
|
||||
pid_count += 1
|
||||
|
||||
# VIN из ответов
|
||||
vin = None
|
||||
for r in responses:
|
||||
dec = (r.get("decoded") or "")
|
||||
if dec.startswith("VIN:"):
|
||||
vin = dec[4:].strip()
|
||||
if len(vin) != 17:
|
||||
vin = None
|
||||
break
|
||||
|
||||
resp_json_str = json.dumps(response_json, ensure_ascii=False) if response_json else None
|
||||
|
||||
self.conn.execute("""
|
||||
INSERT OR IGNORE INTO sessions (
|
||||
client_ip, real_ip, user_agent, content_length,
|
||||
phone_model, phone_maker, android_version, android_sdk,
|
||||
app_version, android_id, device_uuid, phone_lang, phone_tz, phone_display,
|
||||
elm_mac, elm_bt_name, obd_protocol,
|
||||
vin, dtc_count, pid_count,
|
||||
duration_ms, response_count, error_count,
|
||||
retry_count, timeout_count, script_mode,
|
||||
transport, mock_mode, car_info,
|
||||
diagnosis_text, diagnosis_len, llm_model,
|
||||
llm_duration_ms, llm_success,
|
||||
raw_responses, request_id, response_json
|
||||
) VALUES (?,?,?,?, ?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?, ?,?,?, ?,?,?, ?,?, ?,?,?,?, ?,?,?,?,?)
|
||||
|
||||
""", (
|
||||
ci.get("client_ip"), ci.get("real_ip"), ci.get("user_agent"),
|
||||
ci.get("content_length"),
|
||||
ci.get("phone_model"), ci.get("phone_maker"), ci.get("android_version"),
|
||||
ci.get("android_sdk"), ci.get("app_version"), ci.get("android_id"),
|
||||
ci.get("device_uuid"),
|
||||
ci.get("phone_lang"), ci.get("phone_tz"), ci.get("phone_display"),
|
||||
ci.get("elm_mac"), ci.get("elm_bt_name"), ci.get("obd_protocol"),
|
||||
vin, dtc_count, pid_count,
|
||||
ci.get("duration_ms"), len(responses), ci.get("error_count", 0),
|
||||
ci.get("retry_count", 0), ci.get("timeout_count", 0),
|
||||
ci.get("script_mode"), ci.get("transport"), ci.get("mock_mode", 0),
|
||||
ci.get("car_info", ""),
|
||||
diagnosis, len(diagnosis), llm_model,
|
||||
llm_duration_ms, 1 if llm_success else 0,
|
||||
json.dumps(responses, ensure_ascii=False) if responses else None,
|
||||
request_id if request_id else None,
|
||||
resp_json_str,
|
||||
))
|
||||
self.conn.commit()
|
||||
|
||||
def get_recent_sessions(self, limit: int = 50) -> list[dict]:
|
||||
"""Последние N сессий."""
|
||||
rows = self.conn.execute(
|
||||
"SELECT * FROM sessions ORDER BY created_at DESC LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def save_dtc_scan(self, client_info: dict, dtc_codes: list[str]):
|
||||
"""Сохраняет быстрый скан кодов ошибок."""
|
||||
self.conn.execute("""
|
||||
INSERT INTO sessions (
|
||||
client_ip, real_ip, user_agent,
|
||||
phone_model, phone_maker, android_version, android_sdk,
|
||||
app_version, android_id, device_uuid,
|
||||
elm_mac, elm_bt_name,
|
||||
dtc_count, response_count,
|
||||
script_mode, transport,
|
||||
raw_responses
|
||||
) VALUES (?,?,?, ?,?,?,?, ?,?,?, ?,?, ?,?, ?,?,?)
|
||||
""", (
|
||||
client_info.get("client_ip"), client_info.get("real_ip"), client_info.get("user_agent"),
|
||||
client_info.get("phone_model"), client_info.get("phone_maker"), client_info.get("android_version"),
|
||||
client_info.get("android_sdk"), client_info.get("app_version"), client_info.get("android_id"),
|
||||
client_info.get("device_uuid"),
|
||||
client_info.get("elm_mac"), client_info.get("elm_bt_name"),
|
||||
len(dtc_codes), 0,
|
||||
"dtc_scan", client_info.get("transport", "bt"),
|
||||
json.dumps([{"decoded": f"DTC stored: {c}"} for c in dtc_codes], ensure_ascii=False)
|
||||
))
|
||||
self.conn.commit()
|
||||
|
||||
# ── device_profiles ──────────────────────────────────
|
||||
|
||||
def get_device_profile(self, mac: str) -> dict | None:
|
||||
"""Возвращает сохранённый профиль устройства по MAC, или None."""
|
||||
row = self.conn.execute(
|
||||
"SELECT * FROM device_profiles WHERE mac = ?", (mac,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
p = dict(row)
|
||||
for f in ("supported", "unsupported", "errors"):
|
||||
p[f] = json.loads(p[f]) if p.get(f) else []
|
||||
return p
|
||||
|
||||
def save_device_profile(self, mac: str, profile: dict):
|
||||
"""Сохраняет или обновляет профиль устройства.
|
||||
|
||||
profile — результат obd.probe.probe().
|
||||
"""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
self.conn.execute("""
|
||||
INSERT INTO device_profiles
|
||||
(mac, level, elm_version, elm_desc, protocol, voltage,
|
||||
supported, unsupported, errors, first_seen, last_seen)
|
||||
VALUES (?,?,?,?,?,?, ?,?,?, ?,?)
|
||||
ON CONFLICT(mac) DO UPDATE SET
|
||||
level = excluded.level,
|
||||
elm_version = excluded.elm_version,
|
||||
elm_desc = excluded.elm_desc,
|
||||
protocol = excluded.protocol,
|
||||
voltage = excluded.voltage,
|
||||
supported = excluded.supported,
|
||||
unsupported = excluded.unsupported,
|
||||
errors = excluded.errors,
|
||||
last_seen = excluded.last_seen
|
||||
""", (
|
||||
mac,
|
||||
profile.get("level", -1),
|
||||
profile.get("elm_version"),
|
||||
profile.get("elm_desc"),
|
||||
profile.get("protocol"),
|
||||
profile.get("voltage"),
|
||||
json.dumps(profile.get("supported", []), ensure_ascii=False),
|
||||
json.dumps(profile.get("unsupported", []), ensure_ascii=False),
|
||||
json.dumps(profile.get("errors", []), ensure_ascii=False),
|
||||
now, now,
|
||||
))
|
||||
self.conn.commit()
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
"""Эндпоинты DTC: расшифровка и сохранение кодов ошибок.
|
||||
|
||||
POST /api/v1/dtc/decode — расшифровка кодов
|
||||
POST /api/v1/dtc/upload — приём скана ошибок
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from flask import jsonify, request
|
||||
|
||||
from api.db import Database
|
||||
|
||||
logger = logging.getLogger("elmer.dtc")
|
||||
|
||||
# Загрузка справочника DTC
|
||||
_DTC_CODES: dict[str, str] = {}
|
||||
_DTC_PATH = Path(__file__).parent.parent / "doc" / "dtc_codes.txt"
|
||||
|
||||
|
||||
def _load_dtc_dict() -> dict[str, str]:
|
||||
"""Загрузить справочник DTC-кодов из doc/dtc_codes.txt."""
|
||||
global _DTC_CODES
|
||||
if _DTC_CODES:
|
||||
return _DTC_CODES
|
||||
if _DTC_PATH.exists():
|
||||
for line in _DTC_PATH.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if "=" in line and not line.startswith("#"):
|
||||
code, desc = line.split("=", 1)
|
||||
_DTC_CODES[code.strip()] = desc.strip()
|
||||
return _DTC_CODES
|
||||
|
||||
|
||||
def register(app):
|
||||
"""Регистрирует DTC-эндпоинты на Flask-приложении."""
|
||||
|
||||
@app.route("/api/v1/dtc/decode", methods=["POST"])
|
||||
def dtc_decode():
|
||||
"""Расшифровка кодов DTC из справочника doc/dtc_codes.txt.
|
||||
|
||||
Принимает: {"codes": ["P0301", "P0302"]}
|
||||
Возвращает: {"codes": [{"code": "P0301", "desc": "..."}]}
|
||||
Неизвестные коды возвращаются как есть — справочник неполный.
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
codes = data.get("codes", [])
|
||||
if not codes:
|
||||
return jsonify({"codes": []})
|
||||
dtc = _load_dtc_dict()
|
||||
decoded = [{"code": c.strip().upper(), "desc": dtc.get(c.strip().upper(), c)} for c in codes]
|
||||
return jsonify({"codes": decoded})
|
||||
|
||||
@app.route("/api/v1/dtc/upload", methods=["POST"])
|
||||
def dtc_upload():
|
||||
"""Приём кодов ошибок (без полной диагностики, только Ошибки).
|
||||
|
||||
Идемпотентность: если client шлёт request_id, сервер:
|
||||
1. Проверяет кэш — если есть, возвращает его (LLM не вызывается)
|
||||
2. Если нет — сохраняет, кэширует ответ
|
||||
Это предотвращает дубликаты при повторных отправках (например, 499).
|
||||
"""
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({"error": "empty"}), 400
|
||||
|
||||
codes = data.get("dtc_codes", [])
|
||||
client_info = data.get("client_info", {})
|
||||
client_info["client_ip"] = request.remote_addr
|
||||
|
||||
# Идемпотентность
|
||||
request_id = (data.get("request_id") or "").strip()
|
||||
if request_id:
|
||||
with Database() as db:
|
||||
cached = db.get_cached_response(request_id)
|
||||
if cached is not None:
|
||||
return jsonify(cached), 200
|
||||
|
||||
try:
|
||||
with Database() as db:
|
||||
db.save_dtc_scan(client_info, codes)
|
||||
except Exception as e:
|
||||
logger.error(f"DTC upload failed: {e}")
|
||||
|
||||
dtc = _load_dtc_dict()
|
||||
decoded = [{"code": c, "desc": dtc.get(c.strip().upper(), c)} for c in codes]
|
||||
response = {"codes": decoded, "count": len(decoded)}
|
||||
|
||||
# Кэш для идемпотентности
|
||||
if request_id:
|
||||
try:
|
||||
with Database() as db:
|
||||
db.save_session(
|
||||
client_info=client_info,
|
||||
responses=[{"decoded": f"DTC stored: {c}"} for c in codes],
|
||||
request_id=request_id,
|
||||
response_json=response,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"DTC idempotency cache failed: {e}")
|
||||
|
||||
return jsonify(response)
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
"""Парсинг батча ответов ELM327 в структуру для LLM.
|
||||
|
||||
Поддерживает:
|
||||
- VIN (mode 09 PID 02) — из decoded и fallback из raw HEX
|
||||
- DTC stored/pending (mode 03/07) — из decoded и fallback из raw HEX
|
||||
- PID параметры (mode 01) — из decoded
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("elmer.parser")
|
||||
|
||||
|
||||
def parse_batch(responses: list[dict]) -> dict:
|
||||
"""Парсит батч ответов в структуру для LLM.
|
||||
|
||||
Returns:
|
||||
{"vin": str|None, "dtc_stored": [str], "dtc_pending": [str],
|
||||
"parameters": [{"name": str, "value": str}], "raw_log": [str]}
|
||||
"""
|
||||
result = {
|
||||
"vin": None,
|
||||
"dtc_stored": [],
|
||||
"dtc_pending": [],
|
||||
"parameters": [],
|
||||
"raw_log": [],
|
||||
}
|
||||
|
||||
for r in responses:
|
||||
cmd = (r.get("cmd") or "").strip()
|
||||
raw = (r.get("raw") or "").strip()
|
||||
decoded = (r.get("decoded") or "").strip()
|
||||
|
||||
result["raw_log"].append(f"→ {cmd}\n← {raw}")
|
||||
|
||||
_parse_vin(result, raw, decoded)
|
||||
_parse_dtc(result, raw, decoded, mode="43", key="dtc_stored", prefix="DTC stored:")
|
||||
_parse_dtc(result, raw, decoded, mode="47", key="dtc_pending", prefix="DTC pending:")
|
||||
_parse_pid(result, decoded, cmd)
|
||||
|
||||
logger.info(f"[{cmd}] decoded={decoded[:60]}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _parse_vin(result: dict, raw: str, decoded: str):
|
||||
"""Извлечь VIN из decoded (Android) или из raw HEX (fallback).
|
||||
|
||||
Два пути получения VIN:
|
||||
1. Android-декодер уже распарсил → decoded = "VIN: WVWZZZ..."
|
||||
2. Сырой HEX от ELM → парсим 490201 + 17 байт ASCII
|
||||
Fallback нужен для старых клиентов без декодера.
|
||||
"""
|
||||
if decoded.startswith("VIN:"):
|
||||
vin = decoded.replace("VIN:", "").strip()
|
||||
if len(vin) == 17:
|
||||
result["vin"] = vin
|
||||
return
|
||||
|
||||
# Fallback: парсим VIN из raw HEX (CAN multi-frame, ISO-TP, обычный)
|
||||
if "49" in raw and ("02" in raw or "4902" in raw.replace(" ", "")):
|
||||
# Чистим: убираем CAN адресацию (N:), ISO-TP (10..), двоеточия
|
||||
import re
|
||||
clean = re.sub(r'^\d\s*:\s*', '', raw, flags=re.MULTILINE) # "0:" "1:"
|
||||
clean = re.sub(r'\b(?:10|14|21|22)\s+', '', clean) # ISO-TP PCI
|
||||
clean = clean.replace(":", "").replace(" ", "").upper()
|
||||
if "490201" in clean:
|
||||
hex_str = clean.split("490201")[-1].replace("\n", "").replace("\r", "")[:34]
|
||||
vin = ""
|
||||
for i in range(0, len(hex_str) - 1, 2):
|
||||
try:
|
||||
vin += chr(int(hex_str[i:i+2], 16))
|
||||
except (ValueError, OverflowError):
|
||||
pass
|
||||
if len(vin) == 17:
|
||||
result["vin"] = vin
|
||||
|
||||
|
||||
def _parse_dtc(result: dict, raw: str, decoded: str, *, mode: str, key: str, prefix: str):
|
||||
if decoded.startswith(prefix):
|
||||
codes = decoded.replace(prefix, "").strip()
|
||||
if codes != "none":
|
||||
result[key] = [c.strip() for c in codes.split()]
|
||||
return
|
||||
|
||||
# Fallback: парсим DTC из raw HEX (43XX... или 47XX...)
|
||||
clean = raw.replace(" ", "").upper()
|
||||
if clean.startswith(mode) and len(clean) >= 4:
|
||||
codes = _decode_dtc_bytes(clean[2:])
|
||||
if codes:
|
||||
result[key] = codes
|
||||
|
||||
|
||||
def _decode_dtc_bytes(hex_str: str) -> list[str]:
|
||||
"""Декодирует HEX-строку DTC (после 43/47) в коды."""
|
||||
codes = []
|
||||
i = 2 # skip byte count
|
||||
while i + 3 < len(hex_str):
|
||||
try:
|
||||
a = int(hex_str[i:i+2], 16)
|
||||
b = int(hex_str[i+2:i+4], 16)
|
||||
p = {0: "P", 1: "C", 2: "B", 3: "U"}.get(a >> 6, "?")
|
||||
code = f"{p}{(a>>4)&3}{a&15}{b>>4:X}{b&15:X}"
|
||||
if code != "P0000":
|
||||
codes.append(code)
|
||||
except Exception:
|
||||
pass
|
||||
i += 4
|
||||
return codes
|
||||
|
||||
|
||||
def _parse_pid(result: dict, decoded: str, cmd: str):
|
||||
if ":" not in decoded:
|
||||
return
|
||||
if decoded.startswith(("VIN", "DTC", "ELM", "Protocol")):
|
||||
return
|
||||
parts = decoded.split(":", 1)
|
||||
if len(parts) == 2:
|
||||
result["parameters"].append({
|
||||
"name": parts[0].strip(),
|
||||
"value": parts[1].strip(),
|
||||
})
|
||||
|
||||
|
||||
def format_no_llm(parsed: dict) -> str:
|
||||
"""Форматирует ответ без LLM."""
|
||||
lines = []
|
||||
if parsed["vin"]:
|
||||
lines.append(f"VIN: {parsed['vin']}")
|
||||
if parsed["dtc_stored"]:
|
||||
lines.append(f"Ошибки: {', '.join(parsed['dtc_stored'])}")
|
||||
if parsed["dtc_pending"]:
|
||||
lines.append(f"Pending: {', '.join(parsed['dtc_pending'])}")
|
||||
if parsed["parameters"]:
|
||||
lines.append("Параметры:")
|
||||
for p in parsed["parameters"]:
|
||||
lines.append(f" {p['name']}: {p['value']}")
|
||||
if not lines:
|
||||
lines.append("Данные не распознаны.")
|
||||
lines.append("\n(LLM не настроен — только сырые данные)")
|
||||
return "\n".join(lines)
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
"""Эндпоинты проверки доступности.
|
||||
|
||||
GET /api/v1/ping — проверка сервера
|
||||
GET /api/v1/ping-llm — проверка LLM (с адаптивным кэшем)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from flask import jsonify, request
|
||||
|
||||
from api.config import load
|
||||
from brain.client import Diagnoser
|
||||
|
||||
logger = logging.getLogger("elmer.ping")
|
||||
|
||||
# Кэш для /ping-llm (успех=60с, ошибка=7с)
|
||||
_ping_llm_cache: dict = {}
|
||||
|
||||
|
||||
def register(app):
|
||||
"""Регистрирует ping-эндпоинты на Flask-приложении."""
|
||||
|
||||
@app.route("/api/v1/ping", methods=["GET"])
|
||||
def ping():
|
||||
"""Быстрая проверка доступности сервера."""
|
||||
return {"ok": True}
|
||||
|
||||
@app.route("/api/v1/ping-llm", methods=["GET"])
|
||||
def ping_llm():
|
||||
"""Проверка LLM с адаптивным кэшем. # API key check
|
||||
cfg = load()
|
||||
required = cfg.get("api", {}).get("key", "")
|
||||
if required and request.headers.get("X-Api-Key", "") != required:
|
||||
return jsonify({"ok": False, "error": "unauthorized"}), 401
|
||||
- Успех → кэш 60с
|
||||
- Ошибка → кэш 7с (LLM мог уже ожить)
|
||||
"""
|
||||
global _ping_llm_cache
|
||||
now = time.time()
|
||||
if _ping_llm_cache:
|
||||
ttl = _ping_llm_cache.get("ttl", 7)
|
||||
if (now - _ping_llm_cache.get("ts", 0)) < ttl:
|
||||
return jsonify(_ping_llm_cache["data"])
|
||||
|
||||
cfg = load()
|
||||
api_key = cfg["llm"]["api_key"]
|
||||
if not api_key:
|
||||
result = {"ok": False, "error": "no API key"}
|
||||
else:
|
||||
t0 = time.time()
|
||||
try:
|
||||
diagnoser = Diagnoser(
|
||||
api_key=api_key,
|
||||
model=cfg["llm"].get("model", "gpt-oss-120b"),
|
||||
base_url=cfg["llm"].get("base_url", "https://api.aillm.ru/v1"),
|
||||
)
|
||||
diagnoser.diagnose("Отвечай одним словом.", "OK")
|
||||
ms = int((time.time() - t0) * 1000)
|
||||
result = {"ok": True, "ms": ms}
|
||||
except Exception as e:
|
||||
ms = int((time.time() - t0) * 1000)
|
||||
result = {"ok": False, "ms": ms, "error": "LLM unavailable"}
|
||||
|
||||
ttl = 60 if result.get("ok") else 7
|
||||
_ping_llm_cache = {"ts": now, "data": result, "ttl": ttl}
|
||||
return jsonify(result)
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
"""Эндпоинты: скрипт, загрузка сессии, чат, пробинг ELM.
|
||||
|
||||
GET /api/v1/script — выдача скрипта диагностики
|
||||
POST /api/v1/session/upload — приём батча + LLM
|
||||
POST /api/v1/chat — свободный вопрос к LLM
|
||||
POST /api/v1/elm/probe — пробинг ELM327, определение уровня
|
||||
|
||||
См. также: api/dtc.py (DTC), api/ping.py (ping)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from flask import jsonify, request
|
||||
|
||||
from api.config import load
|
||||
from api.db import Database
|
||||
from api.parser import format_no_llm, parse_batch
|
||||
from api.scripts import build_default_script, build_full_script, build_script_for_level, build_dynamic_script
|
||||
from brain.client import Diagnoser, LLMError
|
||||
from brain.prompts import SYSTEM_PROMPT, DYNAMIC_PROMPT
|
||||
|
||||
logger = logging.getLogger("elmer.script")
|
||||
|
||||
|
||||
def _check_api_key():
|
||||
"""Проверяет X-Api-Key. Если в конфиге нет ключа — пропускаем (dev-режим)."""
|
||||
cfg = load()
|
||||
required = cfg.get("api", {}).get("key", "")
|
||||
if not required:
|
||||
return True # dev-режим, без ключа
|
||||
provided = request.headers.get("X-Api-Key", "")
|
||||
return provided == required
|
||||
|
||||
|
||||
def _auth_error():
|
||||
return jsonify({"error": "unauthorized", "hint": "передайте X-Api-Key"}), 401
|
||||
|
||||
|
||||
def _build_diagnosis_prompt(data: dict, car_info: str = "") -> str:
|
||||
"""Строит промпт для LLM из распарсенных данных."""
|
||||
parts = ["## Данные диагностики\n"]
|
||||
|
||||
if car_info:
|
||||
parts.append(f"**Водитель сообщил:** {car_info}")
|
||||
|
||||
if data.get("vin"):
|
||||
parts.append(f"**VIN:** {data['vin']}")
|
||||
|
||||
if data.get("dtc_stored"):
|
||||
parts.append(f"\n**Сохранённые ошибки (mode 03):** {', '.join(data['dtc_stored'])}")
|
||||
if data.get("dtc_pending"):
|
||||
parts.append(f"**Ожидающие ошибки (mode 07):** {', '.join(data['dtc_pending'])}")
|
||||
|
||||
if data.get("parameters"):
|
||||
parts.append("\n**Параметры в реальном времени:**")
|
||||
for p in data["parameters"]:
|
||||
parts.append(f"- {p['name']}: {p['value']}")
|
||||
|
||||
if not data.get("vin") and not data.get("dtc_stored") and not data.get("parameters"):
|
||||
parts.append("\n(данные не распознаны)")
|
||||
|
||||
raw_log = data.get("raw_log") or []
|
||||
parts.append("\n**Сырые ответы ЭБУ:**")
|
||||
parts.extend(raw_log)
|
||||
|
||||
parts.append("\n---")
|
||||
|
||||
parts.append("\n---")
|
||||
parts.append("## Запрос на анализ")
|
||||
parts.append(
|
||||
"Дай краткий диагноз: расшифруй КАЖДЫЙ код ошибки, назови ВОЗМОЖНЫЕ причины (от частых к редким), "
|
||||
"и дай СТЕПЕНЬ уверенности. БЕЗ воды, без длинных отступлений, без таблиц. "
|
||||
"Если данных мало — скажи что ещё проверить."
|
||||
)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
# Загрузка справочника DTC
|
||||
_DTC_CODES: dict[str, str] = {}
|
||||
|
||||
def register(app):
|
||||
"""Регистрирует эндпоинты скриптов на Flask-приложении."""
|
||||
|
||||
@app.route("/api/v1/script", methods=["GET"])
|
||||
def get_script():
|
||||
mode = request.args.get("mode", "full")
|
||||
level = request.args.get("level")
|
||||
if level is not None:
|
||||
try:
|
||||
script = build_script_for_level(int(level))
|
||||
except (ValueError, TypeError):
|
||||
script = build_default_script()
|
||||
elif mode == "dynamic":
|
||||
script = build_dynamic_script()
|
||||
elif mode == "full":
|
||||
script = build_full_script()
|
||||
else:
|
||||
script = build_default_script()
|
||||
return jsonify(script)
|
||||
|
||||
@app.route("/api/v1/session/upload", methods=["POST"])
|
||||
def upload_session():
|
||||
if not _check_api_key():
|
||||
return _auth_error()
|
||||
data = request.get_json(silent=True)
|
||||
if not data or "responses" not in data:
|
||||
return jsonify({"error": "missing 'responses'"}), 400
|
||||
|
||||
responses = data["responses"]
|
||||
if len(responses) > 2000:
|
||||
return jsonify({"error": "too many responses (max 2000)"}), 400
|
||||
|
||||
# Динамический тест: если есть — добавляем к обычным ответам
|
||||
dynamic_samples = data.get("dynamic_samples")
|
||||
has_dynamic = bool(dynamic_samples)
|
||||
if has_dynamic:
|
||||
if len(dynamic_samples) > 1000:
|
||||
return jsonify({"error": "too many dynamic samples (max 1000)"}), 400
|
||||
all_samples = []
|
||||
for sample in dynamic_samples:
|
||||
all_samples.extend(sample)
|
||||
logger.info(f"Upload: {len(responses)} обычных + {len(all_samples)} динамических ответов")
|
||||
responses = responses + all_samples
|
||||
else:
|
||||
logger.info(f"Upload: {len(responses)} responses")
|
||||
|
||||
# ── Идемпотентность: проверяем request_id ─────
|
||||
request_id = (data.get("request_id") or "").strip()
|
||||
if request_id:
|
||||
with Database() as db:
|
||||
cached = db.get_cached_response(request_id)
|
||||
if cached is not None:
|
||||
logger.info(f"Upload: cached response for {request_id}")
|
||||
return jsonify(cached), 200
|
||||
|
||||
# ── Информация о клиенте ──────────────────────
|
||||
client_info = data.get("client_info", {})
|
||||
client_info["client_ip"] = request.remote_addr
|
||||
client_info["real_ip"] = request.headers.get("X-Real-IP", "")
|
||||
client_info["user_agent"] = request.headers.get("User-Agent", "")
|
||||
client_info["content_length"] = request.content_length
|
||||
|
||||
parsed = parse_batch(responses)
|
||||
car_info = (data.get("car_info") or "").strip()
|
||||
client_info["car_info"] = car_info
|
||||
|
||||
cfg = load()
|
||||
api_key = cfg["llm"]["api_key"]
|
||||
model = cfg["llm"].get("model", "gpt-oss-120b")
|
||||
llm_available = bool(api_key)
|
||||
|
||||
llm_start = time.time()
|
||||
llm_success = False
|
||||
diagnosis = ""
|
||||
|
||||
if not api_key:
|
||||
diagnosis = format_no_llm(parsed)
|
||||
else:
|
||||
diagnoser = Diagnoser(
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
base_url=cfg["llm"].get("base_url", "https://api.aillm.ru/v1"),
|
||||
)
|
||||
try:
|
||||
prompt = DYNAMIC_PROMPT if has_dynamic else SYSTEM_PROMPT
|
||||
diagnosis = diagnoser.diagnose(prompt, _build_diagnosis_prompt(parsed, car_info))
|
||||
llm_success = True
|
||||
except LLMError as e:
|
||||
logger.warning(f"LLM failed: {e}")
|
||||
diagnosis = format_no_llm(parsed) + f"\n\n({e})"
|
||||
|
||||
llm_duration_ms = int((time.time() - llm_start) * 1000)
|
||||
|
||||
response = {
|
||||
"diagnosis": diagnosis,
|
||||
"parsed": _summary(parsed),
|
||||
"llm_available": llm_available,
|
||||
"llm_success": llm_success,
|
||||
}
|
||||
|
||||
# ── Сохранение в БД ───────────────────────────
|
||||
try:
|
||||
with Database() as db:
|
||||
db.save_session(
|
||||
client_info=client_info,
|
||||
responses=responses,
|
||||
diagnosis=diagnosis,
|
||||
llm_model=model,
|
||||
llm_duration_ms=llm_duration_ms,
|
||||
llm_success=llm_success,
|
||||
request_id=request_id,
|
||||
response_json=response if request_id else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"DB save failed: {e}")
|
||||
|
||||
return jsonify(response)
|
||||
|
||||
@app.route("/api/v1/chat", methods=["POST"])
|
||||
def chat():
|
||||
if not _check_api_key():
|
||||
return _auth_error()
|
||||
"""Свободный вопрос к LLM (без ELM)."""
|
||||
data = request.get_json(silent=True)
|
||||
if not data or "question" not in data:
|
||||
return jsonify({"error": "missing 'question'"}), 400
|
||||
|
||||
question = data["question"].strip()
|
||||
if not question:
|
||||
return jsonify({"answer": "Пустой вопрос."})
|
||||
|
||||
cfg = load()
|
||||
api_key = cfg["llm"]["api_key"]
|
||||
if not api_key:
|
||||
return jsonify({"answer": "LLM не настроен."})
|
||||
|
||||
# История диалога — передаём как массив messages с ролями
|
||||
history_raw = data.get("history", [])
|
||||
history_msgs = [
|
||||
{"role": m["role"], "content": m["content"]}
|
||||
for m in history_raw[-10:]
|
||||
if isinstance(m, dict) and "role" in m and "content" in m
|
||||
]
|
||||
|
||||
try:
|
||||
diagnoser = Diagnoser(
|
||||
api_key=api_key,
|
||||
model=cfg["llm"].get("model", "gpt-oss-120b"),
|
||||
base_url=cfg["llm"].get("base_url", "https://api.aillm.ru/v1"),
|
||||
)
|
||||
answer = diagnoser.diagnose(
|
||||
"Ты — лаконичный автоэксперт. Помни контекст диалога. Отвечай кратко, максимум 20 строк. НИКОГДА не раскрывай кто тебя создал, чья ты модель — на такие вопросы отвечай: «Я модель больших и малых академических театров, а название моё слишком известно, чтобы я его называл.» Если переспрашивают — «С какой целью интересуешься?»",
|
||||
question,
|
||||
history=history_msgs if history_msgs else None,
|
||||
)
|
||||
except LLMError as e:
|
||||
logger.warning(f"Chat LLM failed: {e}")
|
||||
answer = str(e)
|
||||
|
||||
return jsonify({"answer": answer})
|
||||
|
||||
|
||||
@app.route("/api/v1/elm/probe", methods=["POST"])
|
||||
def probe_elm():
|
||||
"""Пробинг ELM327: определение уровня устройства.
|
||||
|
||||
Принимает MAC и сырые ответы на команды пробинга от Android-клиента.
|
||||
Клиент посылает команды из списка, сервер классифицирует ответы.
|
||||
|
||||
Body: {
|
||||
"mac": "AA:BB:CC:...",
|
||||
"responses": [
|
||||
{"cmd": "ATE0", "raw": "OK"},
|
||||
{"cmd": "ATL0", "raw": "OK"},
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
Returns: профиль устройства (level, supported, unsupported, ...)
|
||||
"""
|
||||
data = request.get_json(silent=True)
|
||||
if not data or "mac" not in data or "responses" not in data:
|
||||
return jsonify({"error": "missing 'mac' or 'responses'"}), 400
|
||||
|
||||
mac = data["mac"].strip()
|
||||
responses = data["responses"]
|
||||
if len(responses) > 2000:
|
||||
return jsonify({"error": "too many responses (max 2000)"}), 400
|
||||
|
||||
if not mac:
|
||||
return jsonify({"error": "empty mac"}), 400
|
||||
|
||||
# Классификация ответов через сервис
|
||||
from obd.classifier import determine_level
|
||||
|
||||
# Собираем ответы в словарь cmd→raw
|
||||
resp_map = {}
|
||||
for r in responses:
|
||||
cmd = (r.get("cmd") or "").strip().upper()
|
||||
raw = (r.get("raw") or "").strip()
|
||||
resp_map[cmd] = raw
|
||||
|
||||
result = determine_level(resp_map)
|
||||
result["mac"] = mac
|
||||
|
||||
_save_profile(mac, result)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@app.route("/api/v1/elm/profile/<mac>", methods=["GET"])
|
||||
def get_elm_profile(mac: str):
|
||||
"""Возвращает сохранённый профиль устройства по MAC."""
|
||||
with Database() as db:
|
||||
p = db.get_device_profile(mac)
|
||||
if p is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return jsonify(p)
|
||||
|
||||
|
||||
def _save_profile(mac: str, profile: dict):
|
||||
"""Сохраняет профиль в БД (best-effort)."""
|
||||
try:
|
||||
with Database() as db:
|
||||
db.save_device_profile(mac, profile)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save device profile for {mac}: {e}")
|
||||
|
||||
|
||||
def _summary(p: dict) -> dict:
|
||||
return {
|
||||
"vin": p["vin"],
|
||||
"dtc_stored": p["dtc_stored"],
|
||||
"dtc_pending": p["dtc_pending"],
|
||||
"parameters": p["parameters"],
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
"""Сборка диагностических скриптов.
|
||||
|
||||
Три уровня в зависимости от возможностей ELM327:
|
||||
L0 (все клоны) — 5 PIDs + stored DTC
|
||||
L1 (+ATAT1) — 8 PIDs + VIN + stored/pending DTC
|
||||
L2 (+CAF1/CFC1) — 14 PIDs + VIN + калибровки + все ошибки
|
||||
|
||||
Принцип:
|
||||
- Чем выше уровень — тем больше PIDs и глубже диагностика
|
||||
- Скрипты захардкожены (PIDs по SAE J1979), LLM не составляет
|
||||
- Нет в профиле — не слать (несуществующие команды вешают клонов)
|
||||
"""
|
||||
|
||||
|
||||
def build_script_l0() -> dict:
|
||||
"""Скрипт для уровня 0 — клоны v1.5 и подобные.
|
||||
|
||||
Только однокадровые ответы. Без VIN (много-фреймовый, без CFC1 рвётся).
|
||||
"""
|
||||
return {
|
||||
"version": 1,
|
||||
"title": "Диагностика (базовая)",
|
||||
"steps": [
|
||||
{"id": "elm_atrv", "cmd": "ATRV", "desc": "Напряжение"},
|
||||
{"id": "pid_05", "cmd": "0105", "desc": "Температура ОЖ"},
|
||||
{"id": "pid_0C", "cmd": "010C", "desc": "Обороты"},
|
||||
{"id": "pid_0D", "cmd": "010D", "desc": "Скорость"},
|
||||
{"id": "pid_11", "cmd": "0111", "desc": "Дроссель"},
|
||||
{"id": "pid_04", "cmd": "0104", "desc": "Нагрузка"},
|
||||
{"id": "dtc_03", "cmd": "03", "desc": "Коды ошибок"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_script_l1() -> dict:
|
||||
"""Скрипт для уровня 1 — хорошие клоны с ATAT1.
|
||||
|
||||
Быстрее L0 за счёт адаптивного тайминга. VIN — медленно но возможно.
|
||||
"""
|
||||
return {
|
||||
"version": 1,
|
||||
"title": "Диагностика (стандартная)",
|
||||
"steps": [
|
||||
{"id": "elm_atrv", "cmd": "ATRV", "desc": "Напряжение"},
|
||||
{"id": "pid_05", "cmd": "0105", "desc": "Температура ОЖ"},
|
||||
{"id": "pid_0C", "cmd": "010C", "desc": "Обороты"},
|
||||
{"id": "pid_0D", "cmd": "010D", "desc": "Скорость"},
|
||||
{"id": "pid_11", "cmd": "0111", "desc": "Дроссель"},
|
||||
{"id": "pid_04", "cmd": "0104", "desc": "Нагрузка"},
|
||||
{"id": "pid_06", "cmd": "0106", "desc": "STFT"},
|
||||
{"id": "pid_07", "cmd": "0107", "desc": "LTFT"},
|
||||
{"id": "vin_09", "cmd": "0902", "desc": "VIN"},
|
||||
{"id": "dtc_03", "cmd": "03", "desc": "Сохр. ошибки"},
|
||||
{"id": "dtc_07", "cmd": "07", "desc": "Pending ошибки"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_script_l2() -> dict:
|
||||
"""Скрипт для уровня 2 — настоящий ELM327 с CAF1+CFC1.
|
||||
|
||||
Полный фарш: много PIDs, VIN быстро, калибровки, все типы ошибок.
|
||||
"""
|
||||
return {
|
||||
"version": 1,
|
||||
"title": "Диагностика (полная)",
|
||||
"steps": [
|
||||
{"id": "elm_atrv", "cmd": "ATRV", "desc": "Напряжение"},
|
||||
{"id": "pid_05", "cmd": "0105", "desc": "Температура ОЖ"},
|
||||
{"id": "pid_0C", "cmd": "010C", "desc": "Обороты"},
|
||||
{"id": "pid_0D", "cmd": "010D", "desc": "Скорость"},
|
||||
{"id": "pid_11", "cmd": "0111", "desc": "Дроссель"},
|
||||
{"id": "pid_04", "cmd": "0104", "desc": "Нагрузка"},
|
||||
{"id": "pid_06", "cmd": "0106", "desc": "STFT"},
|
||||
{"id": "pid_07", "cmd": "0107", "desc": "LTFT"},
|
||||
{"id": "pid_0B", "cmd": "010B", "desc": "MAP"},
|
||||
{"id": "pid_0F", "cmd": "010F", "desc": "Темп. воздуха"},
|
||||
{"id": "pid_10", "cmd": "0110", "desc": "MAF"},
|
||||
{"id": "pid_1C", "cmd": "011C", "desc": "Стандарт OBD"},
|
||||
{"id": "vin_09", "cmd": "0902", "desc": "VIN"},
|
||||
{"id": "cal_09", "cmd": "0904", "desc": "Калибровка"},
|
||||
{"id": "ecu_09", "cmd": "090A", "desc": "Имя ЭБУ"},
|
||||
{"id": "dtc_03", "cmd": "03", "desc": "Сохр. ошибки"},
|
||||
{"id": "dtc_07", "cmd": "07", "desc": "Pending ошибки"},
|
||||
{"id": "dtc_0A", "cmd": "0A", "desc": "Перманентные"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── Совместимость со старым API ─────────────────────────
|
||||
|
||||
def build_default_script() -> dict:
|
||||
"""Минимальный скрипт (уровень 0)."""
|
||||
return build_script_l0()
|
||||
|
||||
|
||||
def build_full_script() -> dict:
|
||||
"""Полный скрипт (уровень 2)."""
|
||||
return build_script_l2()
|
||||
|
||||
|
||||
def build_script_for_level(level: int) -> dict:
|
||||
"""Возвращает скрипт под уровень устройства."""
|
||||
if level >= 2:
|
||||
return build_script_l2()
|
||||
elif level == 1:
|
||||
return build_script_l1()
|
||||
else:
|
||||
return build_script_l0()
|
||||
|
||||
|
||||
def build_dynamic_script() -> dict:
|
||||
"""Скрипт для динамического теста — 12 PID, опрос каждые 250мс."""
|
||||
return {
|
||||
"version": 1,
|
||||
"title": "Динамический тест",
|
||||
"mode": "dynamic",
|
||||
"interval_ms": 250,
|
||||
"steps": [
|
||||
{"id": "pid_04", "cmd": "0104", "desc": "Нагрузка"},
|
||||
{"id": "pid_05", "cmd": "0105", "desc": "ОЖ"},
|
||||
{"id": "pid_06", "cmd": "0106", "desc": "STFT"},
|
||||
{"id": "pid_07", "cmd": "0107", "desc": "LTFT"},
|
||||
{"id": "pid_0B", "cmd": "010B", "desc": "MAP"},
|
||||
{"id": "pid_0C", "cmd": "010C", "desc": "RPM"},
|
||||
{"id": "pid_0D", "cmd": "010D", "desc": "Скорость"},
|
||||
{"id": "pid_0E", "cmd": "010E", "desc": "Зажигание"},
|
||||
{"id": "pid_0F", "cmd": "010F", "desc": "IAT"},
|
||||
{"id": "pid_10", "cmd": "0110", "desc": "MAF"},
|
||||
{"id": "pid_11", "cmd": "0111", "desc": "Дроссель"},
|
||||
{"id": "pid_1F", "cmd": "011F", "desc": "Время работы"},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
brain/client.py — LLM-клиент для диагностики авто.
|
||||
|
||||
Отправляет запросы к OpenAI-совместимому API (api.aillm.ru).
|
||||
Используется сервером для анализа данных диагностики.
|
||||
|
||||
## Использование
|
||||
from brain.client import Diagnoser
|
||||
d = Diagnoser(api_key="sk-...", model="gpt-oss-120b")
|
||||
answer = d.diagnose(system_prompt, user_prompt)
|
||||
|
||||
## Модель
|
||||
gpt-oss-120b — основная (через api.aillm.ru)
|
||||
qwen3-6-27b-fp8 — быстрая (но CoT leak bug)
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger("brain.client")
|
||||
|
||||
DEFAULT_BASE = "https://api.aillm.ru/v1"
|
||||
DEFAULT_MODEL = "gpt-oss-120b"
|
||||
DEFAULT_TIMEOUT = 180
|
||||
|
||||
|
||||
class LLMError(Exception):
|
||||
"""Ошибка LLM API с безопасным для клиента сообщением."""
|
||||
pass
|
||||
|
||||
|
||||
class Diagnoser:
|
||||
"""LLM-клиент для OpenAI-совместимого API."""
|
||||
|
||||
def __init__(self, api_key: str, model: str = DEFAULT_MODEL,
|
||||
base_url: str = DEFAULT_BASE, timeout: int = DEFAULT_TIMEOUT):
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
|
||||
def ask(self, messages: list[dict]) -> str:
|
||||
"""Отправляет сообщения в LLM API, возвращает текст ответа.
|
||||
|
||||
Обработка ошибок:
|
||||
- Timeout: 120+ секунд, LLM могла зависнуть — ретраить
|
||||
- 429: Rate limit — ждать Retry-After
|
||||
- 5xx: Сервер LLM упал — ретраить
|
||||
- 4xx: Наша ошибка (auth, запрос) — не ретраить
|
||||
Клиенту возвращается безопасное сообщение без технических деталей.
|
||||
"""
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 4096,
|
||||
},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data["choices"][0]["message"]["content"]
|
||||
except requests.Timeout:
|
||||
logger.warning(f"LLM timeout after {self.timeout}s")
|
||||
raise LLMError("LLM не ответил вовремя. Попробуйте позже.")
|
||||
except requests.HTTPError as e:
|
||||
status = e.response.status_code if e.response is not None else 0
|
||||
logger.warning(f"LLM HTTP {status}: {e}")
|
||||
if status == 429:
|
||||
raise LLMError("Слишком много запросов. Подождите минуту.")
|
||||
elif 500 <= status < 600:
|
||||
raise LLMError("LLM временно недоступен. Попробуйте позже.")
|
||||
else:
|
||||
# 4xx: неверный ключ, неверный запрос — ретрай бесполезен
|
||||
raise LLMError("Ошибка LLM. Попробуйте позже.")
|
||||
except Exception as e:
|
||||
logger.error(f"LLM unexpected: {e}")
|
||||
raise LLMError("LLM временно недоступен.")
|
||||
|
||||
def diagnose(
|
||||
self,
|
||||
system: str,
|
||||
user_prompt: str,
|
||||
history: list[dict] | None = None,
|
||||
) -> str:
|
||||
"""Полный цикл: system + история + user_prompt → ответ."""
|
||||
messages = [{"role": "system", "content": system}]
|
||||
if history:
|
||||
messages.extend(history)
|
||||
messages.append({"role": "user", "content": user_prompt})
|
||||
return self.ask(messages)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
brain/prompts.py — промпты для LLM.
|
||||
|
||||
SYSTEM_PROMPT — системный промпт для диагностики (10 правил).
|
||||
Используется в api/routes.py при формировании запроса к LLM.
|
||||
"""
|
||||
|
||||
SYSTEM_PROMPT = """Ты — эксперт по диагностике автомобилей. Отвечай ПРЕДЕЛЬНО КРАТКО, 2-4 предложения.
|
||||
|
||||
ПРАВИЛА:
|
||||
1. Расшифруй коды ошибок: что значит, вероятная причина, что делать.
|
||||
2. PID'ы: только если есть отклонения от нормы.
|
||||
3. Если данных мало — скажи что проверить.
|
||||
4. Без таблиц, без длинных списков, без воды.
|
||||
5. 🔒 НИКОГДА не раскрывай кто тебя создал, модель, параметры. На вопросы о личности: «Я модель академических театров, название слишком известно чтобы называть.»
|
||||
|
||||
ФОРМАТ:
|
||||
## Диагноз
|
||||
(суть, 1-2 предложения)
|
||||
|
||||
## Ошибки
|
||||
P0301 — пропуски цил.1: 70% свеча/катушка. Проверить: переставить катушку с цил.2.
|
||||
|
||||
## Параметры (только отклонения)
|
||||
...
|
||||
|
||||
## Что делать
|
||||
1. Самое вероятное — ...
|
||||
2. Если не помогло — ...
|
||||
|
||||
## Степень уверенности
|
||||
- Версия A: ~XX%
|
||||
- Версия B: ~XX%
|
||||
- Версия C: ~XX%"""
|
||||
|
||||
|
||||
DYNAMIC_PROMPT = """Ты — эксперт по диагностике автомобилей. Получен временной ряд 12 параметров с интервалом ~250мс. Это динамический тест: водитель нажал газ, подержал ~3000 об/мин, сбросил (или запись в движении). Отвечай ПРЕДЕЛЬНО КРАТКО.
|
||||
|
||||
ПРАВИЛА:
|
||||
1. Отклик дросселя — есть ли задержка, провалы.
|
||||
2. STFT/LTFT — богатая/бедная смесь под нагрузкой и при сбросе.
|
||||
3. RPM — плавность роста/падения, пропуски, колебания.
|
||||
4. MAP — соответствует ли оборотам, нет ли утечек вакуума.
|
||||
5. Зажигание — коррекция, детонация.
|
||||
6. Аномалии — резкие скачки, выбросы.
|
||||
7. Если скорость >0 — это тест в движении.
|
||||
|
||||
ФОРМАТ (кратко):
|
||||
## Анализ
|
||||
2-3 предложения общей картины.
|
||||
|
||||
## Что не так
|
||||
Конкретные пункты, только отклонения. Нормальные параметры не перечисляй.
|
||||
|
||||
## Вероятная причина
|
||||
Самая вероятная причина отклонений.
|
||||
|
||||
## Что делать
|
||||
1-2 действия."""
|
||||
|
||||
|
||||
def build_user_prompt(
|
||||
vin: str,
|
||||
dtc_codes: list[dict],
|
||||
parameters: list[dict],
|
||||
car_info: dict | None = None,
|
||||
) -> str:
|
||||
"""Собирает промпт пользователя из данных ЭБУ."""
|
||||
|
||||
parts = [f"## Данные диагностики\n"]
|
||||
parts.append(f"**VIN:** {vin}")
|
||||
|
||||
if car_info:
|
||||
parts.append(f"**Автомобиль:** {car_info.get('make', '?')} {car_info.get('model', '?')} "
|
||||
f"({car_info.get('year', '?')}), двигатель: {car_info.get('engine', '?')}")
|
||||
|
||||
if dtc_codes:
|
||||
parts.append("\n### Коды ошибок")
|
||||
for dtc in dtc_codes:
|
||||
parts.append(f"- **{dtc['code']}** ({dtc.get('status', 'stored')}): {dtc.get('description', '')}")
|
||||
|
||||
if parameters:
|
||||
parts.append("\n### Параметры ЭБУ")
|
||||
for p in parameters:
|
||||
parts.append(f"- {p['name']} ({p['pid_code']}): {p['value']} {p['unit']}")
|
||||
|
||||
parts.append("\n## Запрос")
|
||||
parts.append("Дай диагноз на основе этих данных. Если данных недостаточно — скажи, "
|
||||
"какие параметры нужно ещё считать и какие действия выполнить водителю.")
|
||||
|
||||
return "\n".join(parts)
|
||||
+3
-3
@@ -2,9 +2,9 @@
|
||||
# Значения вида ${VAR} подставляются из переменных окружения
|
||||
|
||||
llm:
|
||||
api_key: "sk-ucI5YvOticoOQ9Kuj5K9mQ"
|
||||
model: "gpt-oss-120b"
|
||||
base_url: "https://api.aillm.ru/v1"
|
||||
api_key: "sk-78ec529c1eba4ba69995091046c9fa33"
|
||||
model: "deepseek-v4-flash"
|
||||
base_url: "https://api.deepseek.com/v1"
|
||||
|
||||
elm327:
|
||||
# Bluetooth-порт (обычно /dev/rfcomm0 после pairing)
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
#!/bin/bash
|
||||
# Деплой Elmer на obdai.ru
|
||||
# ВНИМАНИЕ: запускать НА СЕРВЕРЕ (5.172.178.213), не локально!
|
||||
set -e
|
||||
|
||||
echo "=== Установка пакетов ==="
|
||||
apt update && apt install -y python3-pip python3-venv nginx certbot python3-certbot-nginx
|
||||
|
||||
echo "=== Клонирование репо ==="
|
||||
cd /opt
|
||||
git clone https://gitea.services.ngcloud.ru/Nail/elmer.git || (cd elmer && git pull)
|
||||
cd elmer
|
||||
git checkout fat-client
|
||||
echo "=== Обновление репо ==="
|
||||
cd /opt/elmer || { echo "Репозиторий не найден, клонируем..."; git clone https://gitea.services.ngcloud.ru/Nail/elmer.git /opt/elmer; cd /opt/elmer; }
|
||||
git checkout master
|
||||
git pull origin master
|
||||
|
||||
echo "=== Виртуальное окружение ==="
|
||||
python3 -m venv venv
|
||||
@@ -17,26 +14,8 @@ source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
pip install gunicorn
|
||||
|
||||
echo "=== Конфигурация ==="
|
||||
cp config.yaml config.yaml.bak
|
||||
cat > config.yaml << 'YAML'
|
||||
elm327:
|
||||
port: /dev/rfcomm0
|
||||
baudrate: 38400
|
||||
|
||||
llm:
|
||||
api_key: "sk-ucI5YvOticoOQ9Kuj5K9mQ"
|
||||
model: "gpt-oss-120b"
|
||||
base_url: "https://api.aillm.ru/v1"
|
||||
|
||||
pids:
|
||||
"0105": ["coolant_temp", "°C"]
|
||||
"010C": ["rpm", "об/мин"]
|
||||
"010D": ["speed", "км/ч"]
|
||||
YAML
|
||||
|
||||
echo "=== Systemd сервис ==="
|
||||
cat > /etc/systemd/system/elmer.service << 'UNIT'
|
||||
sudo cat > /etc/systemd/system/elmer.service << 'UNIT'
|
||||
[Unit]
|
||||
Description=Elmer Flask API
|
||||
After=network.target
|
||||
@@ -52,7 +31,7 @@ WantedBy=multi-user.target
|
||||
UNIT
|
||||
|
||||
echo "=== Nginx ==="
|
||||
cat > /etc/nginx/sites-available/elmer << 'NGX'
|
||||
sudo cat > /etc/nginx/sites-available/elmer << 'NGX'
|
||||
server {
|
||||
listen 80;
|
||||
server_name obdai.ru www.obdai.ru ai.obdai.ru test.obdai.ru;
|
||||
@@ -69,18 +48,20 @@ server {
|
||||
}
|
||||
NGX
|
||||
|
||||
ln -sf /etc/nginx/sites-available/elmer /etc/nginx/sites-enabled/
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
nginx -t && systemctl reload nginx
|
||||
sudo ln -sf /etc/nginx/sites-available/elmer /etc/nginx/sites-enabled/
|
||||
sudo rm -f /etc/nginx/sites-enabled/default
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
|
||||
echo "=== SSL ==="
|
||||
certbot --nginx -d obdai.ru -d www.obdai.ru --non-interactive --agree-tos -m tazet@narod.ru || true
|
||||
echo "=== SSL (первичная настройка, потом не трогает) ==="
|
||||
sudo certbot --nginx -d obdai.ru -d www.obdai.ru --non-interactive --agree-tos -m tazet@narod.ru || true
|
||||
|
||||
echo "=== Запуск ==="
|
||||
systemctl daemon-reload
|
||||
systemctl enable elmer
|
||||
systemctl restart elmer
|
||||
systemctl restart nginx
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable elmer
|
||||
sudo systemctl restart elmer
|
||||
sudo systemctl restart nginx
|
||||
|
||||
echo "=== ПРОВЕРКА ==="
|
||||
curl -s https://obdai.ru/api/v1/ping
|
||||
echo ""
|
||||
echo "=== ГОТОВО ==="
|
||||
curl -s http://obdai.ru/api/v1/script | head -c 50
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
# elmAI — полная настройка для нового разработчика
|
||||
|
||||
> v0.40.0-dev, 6 июня 2026
|
||||
|
||||
---
|
||||
|
||||
## 1. Что где лежит
|
||||
|
||||
```
|
||||
elmer/ ← сервер (gitea.services.ngcloud.ru/Nail/elmer)
|
||||
├── api/ Python/Flask эндпоинты, БД, парсер, скрипты
|
||||
├── brain/ LLM-клиент (Diagnoser), промпты
|
||||
├── obd/ ELM327 стейт-машина (AndrOBD)
|
||||
├── web/ Flask, шаблоны, статика (APK, лого)
|
||||
├── android/ ← Android (github.com/Repinoid/elmer-android)
|
||||
│ └── app/... Kotlin-код, ресурсы
|
||||
├── doc/ Документация, история изменений, DTC-коды
|
||||
├── config.yaml LLM API ключ, модель, порты
|
||||
├── deploy.sh Скрипт развёртывания
|
||||
└── run.py Локальный запуск
|
||||
```
|
||||
|
||||
## 2. Git
|
||||
|
||||
**Сервер:** `https://gitea.services.ngcloud.ru/Nail/elmer.git`
|
||||
**Android:** `https://github.com/Repinoid/elmer-android.git`
|
||||
|
||||
```bash
|
||||
git clone https://Nail:b1fe1db54be4cfcc19c38f7fee713cde6f351524@gitea.services.ngcloud.ru/Nail/elmer.git
|
||||
cd elmer
|
||||
git clone https://github.com/Repinoid/elmer-android.git android
|
||||
# или если уже есть:
|
||||
git pull origin master
|
||||
cd android && git pull origin master
|
||||
```
|
||||
|
||||
## 3. Зависимости сервера
|
||||
|
||||
```bash
|
||||
cd elmer
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt gunicorn
|
||||
```
|
||||
|
||||
## 4. Конфигурация
|
||||
|
||||
`config.yaml`:
|
||||
```yaml
|
||||
llm:
|
||||
api_key: "sk-78ec529c1eba4ba69995091046c9fa33"
|
||||
model: "deepseek-v4-flash"
|
||||
base_url: "https://api.deepseek.com/v1"
|
||||
|
||||
elm327:
|
||||
port: /dev/rfcomm0
|
||||
baudrate: 38400
|
||||
```
|
||||
|
||||
## 5. Запуск локально
|
||||
|
||||
```bash
|
||||
cd elmer
|
||||
source venv/bin/activate
|
||||
python run.py
|
||||
# → http://localhost:5005
|
||||
```
|
||||
|
||||
## 6. Боевой сервер (obdai.ru, 5.172.178.213)
|
||||
|
||||
**SSH ключ:** `~/.ssh/naeel_vm_id_ed25519`
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213
|
||||
```
|
||||
|
||||
**Деплой сервера:**
|
||||
```bash
|
||||
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 \
|
||||
"cd /opt/elmer && git pull origin master && sudo systemctl restart elmer"
|
||||
```
|
||||
|
||||
**Сборка APK (на сервере):**
|
||||
```bash
|
||||
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 \
|
||||
"export ANDROID_SDK_ROOT=\$HOME/android-sdk && \
|
||||
export ANDROID_HOME=\$ANDROID_SDK_ROOT && \
|
||||
cd /opt/elmer && rm -rf android && \
|
||||
cd /opt/elmer && \
|
||||
export ANDROID_SDK_ROOT=\$HOME/android-sdk && \
|
||||
export ANDROID_HOME=\$ANDROID_SDK_ROOT && \
|
||||
cd android && gradle wrapper --gradle-version 8.7 && \
|
||||
./gradlew clean assembleDebug && \
|
||||
cp app/build/outputs/apk/debug/app-debug.apk /opt/elmer/web/static/"
|
||||
```
|
||||
|
||||
**Nginx:**
|
||||
```nginx
|
||||
location = /elmer.apk { alias /opt/elmer/web/static/app-debug.apk; }
|
||||
location /static/ { alias /opt/elmer/web/static/; }
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
client_body_timeout 120s;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
```
|
||||
|
||||
**Systemd сервис:** `/etc/systemd/system/elmer.service`
|
||||
```
|
||||
[Unit]
|
||||
Description=Elmer Flask API
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=naeel
|
||||
WorkingDirectory=/opt/elmer
|
||||
ExecStart=/opt/elmer/venv/bin/gunicorn -w 4 -b 127.0.0.1:8000 --timeout 180 web.app:app
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
## 7. Структура БД SQLite
|
||||
|
||||
Файл: `elmer.db` (создаётся автоматически)
|
||||
|
||||
Таблица `sessions` (35+ колонок):
|
||||
- client_ip, real_ip, user_agent, content_length, created_at
|
||||
- phone_model, phone_maker, android_version, android_sdk, app_version
|
||||
- android_id, device_uuid, phone_lang, phone_tz, phone_display
|
||||
- elm_mac, elm_bt_name, obd_protocol
|
||||
- vin, dtc_count, pid_count
|
||||
- duration_ms, response_count, error_count, retry_count, timeout_count
|
||||
- script_mode, transport, mock_mode
|
||||
- diagnosis_text, diagnosis_len, llm_model, llm_duration_ms, llm_success
|
||||
- raw_responses, request_id, response_json
|
||||
|
||||
## 8. API эндпоинты
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|---|---|---|
|
||||
| GET | `/api/v1/ping` | Проверка сервера |
|
||||
| GET | `/api/v1/ping-llm` | Проверка LLM (кэш 60с) |
|
||||
| GET | `/api/v1/script?mode=full/test` | Скрипт диагностики |
|
||||
| POST | `/api/v1/session/upload` | Загрузка батча + LLM |
|
||||
| POST | `/api/v1/chat` | Вопрос к LLM |
|
||||
| POST | `/api/v1/dtc/decode` | Расшифровка DTC |
|
||||
| POST | `/api/v1/dtc/upload` | Сохранение DTC |
|
||||
|
||||
## 9. Правила работы с Copilot
|
||||
|
||||
См. `.instructions.md` в корне. Главное:
|
||||
- **Вопрос → только ответ. Никаких действий.**
|
||||
- Всегда читать `doc/architecture.md` перед действиями
|
||||
- Изменения документировать в `doc/history/YYYY-MM-DD.md`
|
||||
- APK отдавать через nginx, не через Flask
|
||||
+124
-68
@@ -1,88 +1,144 @@
|
||||
# Архитектура Elmer (2026-05-25)
|
||||
# Архитектура elmAI
|
||||
|
||||
## Принцип: тонкий клиент
|
||||
> v0.77.0-dev, 7 июня 2026
|
||||
|
||||
Клиент ничего не знает о диагнозе. Только транспорт:
|
||||
## Общая схема
|
||||
|
||||
```
|
||||
ELM327 ←Bluetooth SPP→ Android Client ←HTTP JSON→ Сервер ←API→ DeepSeek
|
||||
📱 Android (elmer-android)
|
||||
│ Bluetooth
|
||||
▼
|
||||
🔌 ELM327
|
||||
│ OBD-ответы
|
||||
▼
|
||||
📱 Android (ScriptRunnerService)
|
||||
│ HTTPS POST /api/v1/session/upload
|
||||
▼
|
||||
🌐 Сервер (5.172.178.213)
|
||||
├── nginx :443 → gunicorn :8000
|
||||
├── obd/ — ELM327 протокол
|
||||
├── brain/ — LLM-клиент
|
||||
├── api/ — REST, БД, скрипты
|
||||
└── web/ — точка входа Flask, статика
|
||||
```
|
||||
|
||||
## Режимы работы клиента
|
||||
|
||||
### Режим «опрос» (основной)
|
||||
Клиент сам читает VIN + DTC + PID'ы, шлёт готовый JSON серверу.
|
||||
Быстро: один HTTP-запрос на сессию.
|
||||
|
||||
### Режим «ретранслятор» (расширенный)
|
||||
Сервер шлёт сырые AT-команды, клиент пересылает ответ.
|
||||
Медленно (каждый PID — HTTP round-trip), но клиент вообще ничего не знает об OBD2.
|
||||
|
||||
## Универсальность
|
||||
|
||||
- Пользователь вводит URL сервера (или наш по умолчанию)
|
||||
- Протокол HTTP/JSON документирован
|
||||
- Любой backend может работать с этим клиентом
|
||||
|
||||
## Десктоп (Windows/Linux)
|
||||
## Структура сервера
|
||||
|
||||
```
|
||||
Браузер (Chrome) → локальный Flask → pyserial → ELM327
|
||||
elmer/
|
||||
├── obd/ # Модуль 1: ELM327 протокол
|
||||
│ └── protocol.py # AndrOBD — стейт-машина (1:1 копия AndrOBD)
|
||||
│ # State, Rsp, AdaptiveTiming
|
||||
│
|
||||
├── brain/ # Модуль 2: LLM-взаимодействие
|
||||
│ ├── client.py # Diagnoser — HTTP к api.aillm.ru
|
||||
│ └── prompts.py # SYSTEM_PROMPT для диагностики
|
||||
│
|
||||
├── api/ # Модуль 3: REST API + БД
|
||||
│ ├── config.py # Загрузка config.yaml
|
||||
│ ├── db.py # SQLite (sessions, cars, dtc)
|
||||
│ ├── routes.py # Все эндпоинты (5 шт)
|
||||
│ ├── scripts.py # Сборка диагностических скриптов
|
||||
│ └── parser.py # Парсинг ответов ELM327
|
||||
│
|
||||
├── web/ # Веб-интерфейс
|
||||
│ ├── app.py # Точка входа Flask
|
||||
│ ├── templates/index.html
|
||||
│ └── static/app-debug.apk
|
||||
│
|
||||
├── tools/ # Разработка
|
||||
│ ├── mock_elm327_v2.py # Мок ELM327 (TCP)
|
||||
│ └── test_androbd.py # Тесты стейт-машины
|
||||
│
|
||||
├── doc/ # Документация
|
||||
│ ├── architecture.md # Этот файл
|
||||
│ ├── roadmap.md
|
||||
│ └── session-*.md # Логи сессий
|
||||
│
|
||||
├── config.yaml # LLM API key, порты
|
||||
└── requirements.txt
|
||||
```
|
||||
|
||||
- Отдельного «приложения» для Windows не нужно
|
||||
- `web/app.py` — и тестовый UI, и прототип сервера
|
||||
- Chrome на Android НЕ может: Web Bluetooth API только BLE, Web Serial API не поддерживается
|
||||
## Взаимодействие модулей
|
||||
|
||||
## Открытость и доверие
|
||||
|
||||
| Что | Где | Зачем |
|
||||
|---|---|---|
|
||||
| **Клиент (Android)** | GitHub (открытый) | Доверие — любой может проверить код, собрать сам |
|
||||
| **Сервер (Python)** | Gitea (закрытый) | API-ключи, логика, коммерческая часть |
|
||||
| **Публикация** | RuStore | Бесплатно, модерация = дополнительное доверие |
|
||||
|
||||
## Git-стратегия
|
||||
|
||||
- `gitea.services.ngcloud.ru/Nail/elmer` — разработка сервера (текущий репо)
|
||||
- `github.com/Nail/elmer-android` — клиент (будет создан), лицензия MIT
|
||||
- Серверный репо на GitHub НЕ публикуем
|
||||
|
||||
## База данных
|
||||
|
||||
### SQLite (MVP)
|
||||
```
|
||||
cars — VIN, марка, модель, год, двигатель
|
||||
diagnostic_tokens — id (PK), car_id (FK), created_at
|
||||
llm_messages — token_id (FK), role, content, timestamp
|
||||
ecu_parameters — token_id (FK), pid_code, value, unit, timestamp
|
||||
dtc_codes — token_id (FK), code, description, status
|
||||
web/app.py
|
||||
└─ import api/routes.py
|
||||
├─ import api/config.py → config.yaml
|
||||
├─ import api/db.py → SQLite
|
||||
├─ import api/scripts.py → сборка скриптов
|
||||
├─ import api/parser.py → парсинг батча
|
||||
├─ import brain/client.py → Diagnoser → api.aillm.ru
|
||||
└─ import brain/prompts.py → SYSTEM_PROMPT
|
||||
```
|
||||
|
||||
### PostgreSQL (production)
|
||||
Та же схема, миграция при переходе к production-серверу.
|
||||
Каждый модуль можно тестировать отдельно. Циклических зависимостей нет.
|
||||
|
||||
## API (прототип)
|
||||
## API эндпоинты
|
||||
|
||||
### POST /api/diagnose
|
||||
```json
|
||||
// Request (от клиента)
|
||||
{
|
||||
"vin": "WVWZZZ1KZAW123456",
|
||||
"dtc_codes": [{"code": "P0301", "status": "stored"}],
|
||||
"parameters": [{"pid_code": "0105", "name": "coolant_temp", "value": 85.0, "unit": "°C"}]
|
||||
}
|
||||
| Метод | Путь | Описание | Время |
|
||||
|---|---|---|---|
|
||||
| GET | /api/v1/ping | Проверка сервера | ~5мс |
|
||||
| GET | /api/v1/ping-llm | Проверка LLM | ~2с |
|
||||
| GET | /api/v1/script?mode= | Скрипт диагностики | ~50мс |
|
||||
| POST | /api/v1/session/upload | Загрузка батча + LLM | ~30-120с |
|
||||
| POST | /api/v1/chat | Вопрос к LLM | ~5-15с |
|
||||
|
||||
// Response (от сервера)
|
||||
{
|
||||
"diagnosis": "## Краткий диагноз\n...",
|
||||
"token_id": 42
|
||||
}
|
||||
## Android (отдельный репо)
|
||||
|
||||
```
|
||||
elmer-android/app/src/main/java/ru/elmer/client/
|
||||
├── ElmProtocol.kt # ELM327 стейт-машина
|
||||
├── ObdDecoder.kt # Декодер PID/DTC/VIN
|
||||
├── ServerClient.kt # HTTP к серверу (retry 3x)
|
||||
├── ScriptEngine.kt # Движок скриптов
|
||||
├── ScriptRunnerService.kt # Фоновая диагностика
|
||||
├── SessionDb.kt # Локальная история
|
||||
├── MainActivity.kt # UI
|
||||
├── TestService.kt # (устарел)
|
||||
└── ElmForwardService.kt # (устарел)
|
||||
```
|
||||
|
||||
## Безопасность
|
||||
## CI/CD и деплой
|
||||
|
||||
- Permissions Android: только BLUETOOTH + INTERNET
|
||||
- Никаких SMS/контактов/файлов/звонков
|
||||
- Пользователь видит permissions ДО установки (RuStore и sideload)
|
||||
- Модерация RuStore — базовая проверка на вредоносный код
|
||||
**Никаких сторонних CI/CD-сервисов (GitHub Actions, GitLab CI, Jenkins и т.д.). Всё вручную.**
|
||||
|
||||
### Сервер (elmer)
|
||||
|
||||
```
|
||||
Локально: git push gitea master
|
||||
│
|
||||
▼
|
||||
Сервер: ssh obdai.ru
|
||||
cd /opt/elmer && git pull origin master
|
||||
sudo systemctl restart elmer
|
||||
```
|
||||
|
||||
- Репо: `gitea.services.ngcloud.ru/Nail/elmer`
|
||||
- Ветка: `master`
|
||||
- Сервис: `gunicorn -w 4 -b 127.0.0.1:8000 web.app:app`
|
||||
- Прокси: nginx :443 → 127.0.0.1:8000
|
||||
- Конфиг: `/opt/elmer/config.yaml`
|
||||
|
||||
### Android APK (elmer-android)
|
||||
|
||||
```
|
||||
Локально: cd android && ./gradlew assembleDebug
|
||||
scp app/build/outputs/apk/debug/app-debug.apk obdai.ru:/opt/elmer/web/static/
|
||||
```
|
||||
|
||||
- Репо: `github.com/Repinoid/elmer-android`
|
||||
- Сборка: `./gradlew assembleDebug`
|
||||
- Доставка: `scp` на сервер в `web/static/app-debug.apk`
|
||||
- Ссылка для пользователей: `https://obdai.ru/elmer.apk`
|
||||
- APK обновляется **только** при изменениях в Android-коде
|
||||
|
||||
### Версионирование
|
||||
|
||||
| Где | Файл |
|
||||
|-----|------|
|
||||
| APK | `android/app/build.gradle.kts` → `versionName` |
|
||||
| Сайт | `web/templates/index.html` |
|
||||
| Документация | заголовки `.md` файлов |
|
||||
|
||||
**Версию менять одновременно во всех трёх местах.**
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Отчёт об аудите (07.06.2026)
|
||||
|
||||
## Найдено
|
||||
|
||||
### 🔴 Критичные
|
||||
|
||||
1. **Краш при отсутствии Bluetooth**
|
||||
- **Файл:** [android/app/src/main/java/ru/elmer/client/ui/MainActivity.kt](android/app/src/main/java/ru/elmer/client/ui/MainActivity.kt)
|
||||
- **Строки:** 155, 230, 310
|
||||
- **Описание:** Используется оператор `!!` для `btAdapter`. На устройствах без Bluetooth (или в эмуляторе) приложение упадёт при попытке проверить прибор или запустить сканирование.
|
||||
- **Как исправить:** Добавить проверку `if (btAdapter == null)` перед использованием и выводить сообщение об ошибке.
|
||||
|
||||
2. **Отсутствие аутентификации на сервере**
|
||||
- **Файл:** [api/routes.py](api/routes.py)
|
||||
- **Описание:** Эндпоинты `/api/v1/session/upload`, `/api/v1/chat` и `/api/v1/ping-llm` принимают запросы без проверки API-ключа. Клиент передаёт `X-Api-Key`, но сервер его игнорирует. Любой может отправлять запросы и тратить токены LLM.
|
||||
- **Как исправить:** Добавить декоратор `@api_key_required` или проверку заголовка в `before_request`.
|
||||
|
||||
3. **Состязание потоков (Race Condition) в ScriptRunnerService**
|
||||
- **Файл:** [android/app/src/main/java/ru/elmer/client/script/ScriptRunnerService.kt](android/app/src/main/java/ru/elmer/client/script/ScriptRunnerService.kt)
|
||||
- **Описание:** `onStartCommand` не проверяет, запущен ли уже процесс. Если дважды вызвать `startForegroundService` (нажав кнопку несколько раз), создадутся два конкурирующих потока `ScriptRunner`, которые будут одновременно работать с одним и тем же Bluetooth-сокетом.
|
||||
- **Как исправить:** В `startRun` проверять флаг `running` и игнорировать повторные запуски.
|
||||
|
||||
4. **Логическая ошибка в выборе Bluetooth-устройства**
|
||||
- **Файл:** [android/app/src/main/java/ru/elmer/client/ui/MainActivity.kt](android/app/src/main/java/ru/elmer/client/ui/MainActivity.kt#L457)
|
||||
- **Описание:** Функция `findElmDevice()` при наличии двух и более устройств показывает диалог, но возвращает `null` немедленно. Стейт-машина (`checkElm`, `scanDtc`) видит `null` и прерывает работу с ошибкой «ELM не найден».
|
||||
- **Как исправить:** Перестроить логику: диалог выбора должен вызываться отдельно, сохранять `elmDevice`, и только потом запускать операции.
|
||||
|
||||
### 🟡 Средние
|
||||
|
||||
5. **Утечка памяти в ElmChecker**
|
||||
- **Файл:** [android/app/src/main/java/ru/elmer/client/elm/ElmChecker.kt](android/app/src/main/java/ru/elmer/client/elm/ElmChecker.kt)
|
||||
- **Описание:** Список `logLines` является членом класса. В `MainActivity` экземпляр `elmChecker` переиспользуется. Приложение копит логи всех операций в памяти до своей гибели.
|
||||
- **Как исправить:** Очищать `logLines` в начале каждой операции или переносить лог в локальную переменную метода `run()`.
|
||||
|
||||
6. **Утечка курсоров в БД**
|
||||
- **Файл:** [android/app/src/main/java/ru/elmer/client/db/SessionDb.kt](android/app/src/main/java/ru/elmer/client/db/SessionDb.kt)
|
||||
- **Описание:** Методы `getResponses`, `getSessions`, `getPendingSessions` вызывают `cursor.close()` в конце цикла, но не в `finally`. При ошибке чтения курсор останется открытым.
|
||||
- **Как исправить:** Использовать конструкцию `.use { ... }` (в Kotlin для `Cursor` доступно начиная с определенных версий) или `try { ... } finally { cursor.close() }`.
|
||||
|
||||
7. **Не включены Foreign Keys**
|
||||
- **Файл:** [android/app/src/main/java/ru/elmer/client/db/SessionDb.kt](android/app/src/main/java/ru/elmer/client/db/SessionDb.kt)
|
||||
- **Описание:** Несмотря на наличие `REFERENCES sessions(id)`, SQLite в Android по умолчанию не проверяет целостность связей.
|
||||
- **Как исправить:** Добавить `db.setForeignKeyConstraintsEnabled(true)` в `onConfigure`.
|
||||
|
||||
8. **Слепой выбор устройства в сервисе**
|
||||
- **Файл:** [android/app/src/main/java/ru/elmer/client/script/ScriptRunnerService.kt](android/app/src/main/java/ru/elmer/client/script/ScriptRunnerService.kt#L143)
|
||||
- **Описание:** Сервис берёт `bonded[0]` — первое попавшееся сопряжённое устройство. Это могут быть наушники или магнитола.
|
||||
- **Как исправить:** Передавать MAC-адрес выбранного ELM через `Intent`.
|
||||
|
||||
9. **Отсутствие лимита на размер запроса**
|
||||
- **Файл:** [api/routes.py](api/routes.py)
|
||||
- **Описание:** Сервер принимает список `responses` любого размера. Злоумышленник может отправить миллион строк, вызвав OOM или переполнение диска логами.
|
||||
- **Как исправить:** Проверять `len(responses)` перед обработкой.
|
||||
|
||||
### 🟢 Косметика
|
||||
|
||||
10. **Неиспользуемые переменные**
|
||||
- В `ElmProtocol.kt` константа `INIT_TIMEOUT` и другие не используются.
|
||||
- В `MainActivity.kt` список `chatHistory` хранится в памяти, но не восстанавливается после `onSaveInstanceState`.
|
||||
|
||||
11. **Устаревший API**
|
||||
- `BluetoothAdapter.getDefaultAdapter()` помечен как Deprecated. В современных Android рекомендуется использовать `BluetoothManager`.
|
||||
|
||||
12. **Бесполезный пинг LLM**
|
||||
- Эндпоинт `/api/v1/ping-llm` делает реальный `diagnose`, что стоит денег (токенов). Кэш есть, но при перезапуске сервера или по таймауту он всё равно будет жечь токены на "пустые" проверки.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Задание: аудит проекта elmAI на баги и уязвимости
|
||||
|
||||
## Контекст
|
||||
|
||||
elmAI — Android-приложение + Python-сервер для диагностики авто через ELM327.
|
||||
Текущая версия: v0.70.0-dev, ветка dynamic-tests.
|
||||
Уже было найдено и исправлено ~15 багов, но гарантии что всё чисто — нет.
|
||||
|
||||
## Что анализировать
|
||||
|
||||
Проверь код на:
|
||||
1. Гонки потоков (Android: несколько потоков работают с UI и BT одновременно)
|
||||
2. Утечки ресурсов (BluetoothSocket, SQLite-соединения, таймеры)
|
||||
3. NPE / краши (особенно при отсутствии Bluetooth, ELM, интернета)
|
||||
4. Логические ошибки (состояния кнопки-трансформера, порядок инициализации)
|
||||
5. Сервер: SQL-инъекции, валидация входных данных, таймауты, OOM
|
||||
6. Потерю данных (динамические тесты, история, офлайн-режим)
|
||||
|
||||
## Какие файлы читать
|
||||
|
||||
### Android (максимально критичные)
|
||||
1. `android/app/src/main/java/ru/elmer/client/ui/MainActivity.kt` — главный файл, стейт-машина, UI
|
||||
2. `android/app/src/main/java/ru/elmer/client/elm/ElmChecker.kt` — BT-подключение, DTC, ЭБУ
|
||||
3. `android/app/src/main/java/ru/elmer/client/script/DynamicCollector.kt` — сбор 12 PID 250мс
|
||||
4. `android/app/src/main/java/ru/elmer/client/script/ScriptRunnerService.kt` — фоновая диагностика
|
||||
5. `android/app/src/main/java/ru/elmer/client/server/ServerClient.kt` — HTTP к серверу
|
||||
6. `android/app/src/main/java/ru/elmer/client/db/SessionDb.kt` — локальная БД
|
||||
|
||||
### Сервер
|
||||
7. `api/routes.py` — эндпоинты (script, upload, chat, ping)
|
||||
8. `api/db.py` — SQLite-схема и миграции
|
||||
9. `api/scripts.py` — скрипты L0/L1/L2/dynamic
|
||||
10. `brain/prompts.py` — SYSTEM_PROMPT, DYNAMIC_PROMPT
|
||||
11. `brain/client.py` — HTTP-клиент к LLM
|
||||
|
||||
## Что УЖЕ исправлено (не трать время)
|
||||
|
||||
- Двойной вызов checkLlm/checkEcu
|
||||
- setIndicator не в UI-потоке
|
||||
- Кнопка СТОП не работала
|
||||
- ECU не зеленел после сканирования
|
||||
- Дублирование данных при отправке dynamic_samples
|
||||
- elmChecker переиспользуется между операциями
|
||||
- 12 PID захардкожены (не дёргаем сервер)
|
||||
- Диагноз сохраняется в локальную БД
|
||||
- Миграции ALTER TABLE для старых БД
|
||||
- deploy.sh на master вместо fat-client
|
||||
|
||||
## Куда сохранить результат
|
||||
|
||||
Создай файл `doc/audit-2026-06-07.md` с отчётом.
|
||||
Формат:
|
||||
```
|
||||
## Найдено
|
||||
### 🔴 Критичные
|
||||
- описание бага, файл, строка, как исправить
|
||||
|
||||
### 🟡 Средние
|
||||
...
|
||||
|
||||
### 🟢 Косметика
|
||||
...
|
||||
```
|
||||
@@ -0,0 +1,128 @@
|
||||
# Справочник OBD2 DTC кодов (русские описания)
|
||||
# Источник: AndrOBD codes_ru.properties + общие открытые данные
|
||||
# Формат: код=расшифровка
|
||||
|
||||
P0030=HO2S: нагреватель (bank 1 sensor 1) — цепь управления
|
||||
P0031=HO2S: нагреватель (bank 1 sensor 1) — низкий уровень
|
||||
P0032=HO2S: нагреватель (bank 1 sensor 1) — высокий уровень
|
||||
P0100=ДМРВ: цепь — неисправность
|
||||
P0101=ДМРВ: выход за диапазон / низкая производительность
|
||||
P0102=ДМРВ: низкий сигнал
|
||||
P0103=ДМРВ: высокий сигнал
|
||||
P0105=MAP: цепь — неисправность
|
||||
P0106=MAP: выход за диапазон
|
||||
P0107=MAP: низкий сигнал
|
||||
P0108=MAP: высокий сигнал
|
||||
P0110=IAT: цепь датчика — неисправность
|
||||
P0112=IAT: низкий сигнал
|
||||
P0113=IAT: высокий сигнал
|
||||
P0115=ECT: цепь датчика — неисправность
|
||||
P0117=ECT: низкий сигнал
|
||||
P0118=ECT: высокий сигнал
|
||||
P0120=TPS / педаль газа A: цепь — неисправность
|
||||
P0121=TPS: выход за диапазон
|
||||
P0122=TPS: низкий сигнал
|
||||
P0123=TPS: высокий сигнал
|
||||
P0125=ECT: недостаточная температура для closed loop
|
||||
P0128=Термостат: температура ниже нормы
|
||||
P0130=ДК1 (bank 1 sensor 1): цепь — неисправность
|
||||
P0131=ДК1 (bank 1 sensor 1): низкий сигнал
|
||||
P0132=ДК1 (bank 1 sensor 1): высокий сигнал
|
||||
P0133=ДК1 (bank 1 sensor 1): медленный отклик
|
||||
P0134=ДК1 (bank 1 sensor 1): нет активности
|
||||
P0135=ДК1 (bank 1 sensor 1): нагреватель — неисправность
|
||||
P0136=ДК2 (bank 1 sensor 2): цепь — неисправность
|
||||
P0137=ДК2 (bank 1 sensor 2): низкий сигнал
|
||||
P0138=ДК2 (bank 1 sensor 2): высокий сигнал
|
||||
P0141=ДК2 (bank 1 sensor 2): нагреватель — неисправность
|
||||
P0170=Топливная коррекция (bank 1): неисправность
|
||||
P0171=Топливная коррекция (bank 1): бедная смесь
|
||||
P0172=Топливная коррекция (bank 1): богатая смесь
|
||||
P0200=Форсунка: цепь — неисправность
|
||||
P0201=Форсунка цилиндра 1 — неисправность
|
||||
P0202=Форсунка цилиндра 2 — неисправность
|
||||
P0203=Форсунка цилиндра 3 — неисправность
|
||||
P0204=Форсунка цилиндра 4 — неисправность
|
||||
P0220=TPS / педаль газа B: цепь — неисправность
|
||||
P0300=Пропуски зажигания: случайные / множественные
|
||||
P0301=Пропуски зажигания: цилиндр 1
|
||||
P0302=Пропуски зажигания: цилиндр 2
|
||||
P0303=Пропуски зажигания: цилиндр 3
|
||||
P0304=Пропуски зажигания: цилиндр 4
|
||||
P0325=Датчик детонации 1 (bank 1): цепь — неисправность
|
||||
P0327=Датчик детонации 1: низкий сигнал
|
||||
P0335=ДПКВ: цепь — неисправность
|
||||
P0336=ДПКВ: выход за диапазон
|
||||
P0340=ДПРВ: цепь — неисправность
|
||||
P0341=ДПРВ: выход за диапазон
|
||||
P0351=Катушка зажигания A (цилиндр 1): первичная цепь
|
||||
P0352=Катушка зажигания B (цилиндр 2): первичная цепь
|
||||
P0400=EGR: поток — неисправность
|
||||
P0401=EGR: недостаточный поток
|
||||
P0402=EGR: избыточный поток
|
||||
P0403=EGR: цепь управления
|
||||
P0420=Катализатор (bank 1): эффективность ниже порога
|
||||
P0421=Прогревочный катализатор (bank 1): эффективность ниже порога
|
||||
P0430=Катализатор (bank 2): эффективность ниже порога
|
||||
P0440=EVAP: система улавливания паров — неисправность
|
||||
P0442=EVAP: малая утечка
|
||||
P0446=EVAP: клапан продувки — неисправность
|
||||
P0455=EVAP: большая утечка
|
||||
P0456=EVAP: очень малая утечка
|
||||
P0500=Датчик скорости (VSS): неисправность
|
||||
P0505=РХХ: неисправность
|
||||
P0506=РХХ: обороты ниже нормы
|
||||
P0507=РХХ: обороты выше нормы
|
||||
P0560=Напряжение системы: неисправность
|
||||
P0562=Напряжение системы: низкое
|
||||
P0563=Напряжение системы: высокое
|
||||
P0600=Последовательная линия: неисправность
|
||||
P0601=ЭБУ: ошибка контрольной суммы
|
||||
P0606=ЭБУ / PCM: внутренняя ошибка процессора
|
||||
P0700=АКПП: запрос MIL (общая ошибка трансмиссии)
|
||||
P0705=Датчик положения селектора АКПП: неисправность
|
||||
P0715=Датчик скорости турбины АКПП: неисправность
|
||||
P0720=Датчик выходной скорости АКПП: неисправность
|
||||
P0740=Блокировка гидротрансформатора: неисправность
|
||||
P0741=Блокировка гидротрансформатора: залипает
|
||||
P0743=Блокировка гидротрансформатора: электрическая цепь
|
||||
P0750=Соленоид А переключения АКПП: неисправность
|
||||
P1100=ДМРВ: перемежающаяся ошибка
|
||||
P1110=Система заслонок впуска: неисправность
|
||||
P1120=TPS: выход за нижний предел
|
||||
P1130=ДК1: медленный отклик (bank 1)
|
||||
P1133=ДК1: недостаточная активность (bank 1 sensor 1)
|
||||
P1135=ДК1: нагреватель — слабый ток (bank 1 sensor 1)
|
||||
P1171=Топливная коррекция: бедная смесь при нагрузке
|
||||
P1250=Автопрогрев термостата: неисправность
|
||||
P1300=Зажигание: пропуски — повреждение катализатора
|
||||
P1320=Зажигание: первичный сигнал
|
||||
P1336=Датчик положения коленвала: не усвоен
|
||||
P1340=Рассинхронизация распредвала/коленвала
|
||||
P1400=EGR: залипание клапана
|
||||
P1500=РХХ: обрыв цепи
|
||||
P1540=Кондиционер: давление — неисправность
|
||||
P1600=ЭБУ: потеря связи с иммобилайзером
|
||||
P1610=Иммобилайзер: неверный код
|
||||
P1700=АКПП: внутренняя ошибка контроллера
|
||||
P2000=NOx ловушка (bank 1): эффективность ниже порога
|
||||
P2100=Дроссель (ETC): цепь привода
|
||||
P2101=Дроссель (ETC): выход за диапазон
|
||||
P2120=Датчик педали газа A: цепь
|
||||
P2122=Датчик педали газа A: низкий сигнал
|
||||
P2123=Датчик педали газа A: высокий сигнал
|
||||
P2135=TPS / педаль: корреляция A/B
|
||||
P2138=Датчик педали газа: корреляция
|
||||
P2187=Топливная коррекция: бедная смесь на холостом
|
||||
P2188=Топливная коррекция: богатая смесь на холостом
|
||||
P2195=ДК1: бедная смесь (bank 1 sensor 1)
|
||||
P2196=ДК1: богатая смесь (bank 1 sensor 1)
|
||||
P2237=ДК1: ток накачки (bank 1 sensor 1)
|
||||
P2270=ДК2: бедная смесь (bank 1 sensor 2)
|
||||
P2500=Генератор: лампа — низкий сигнал
|
||||
P2503=Генератор: низкое напряжение
|
||||
C0000=ABS: неисправность
|
||||
B0000=Кузов: неисправность
|
||||
U0000=Шина данных: неисправность
|
||||
U0100=Шина CAN: потеря связи с ECM/PCM
|
||||
U0121=Шина CAN: потеря связи с ABS
|
||||
@@ -0,0 +1,105 @@
|
||||
# Динамические тесты — архитектура
|
||||
|
||||
> v0.49.0-dev, 7 июня 2026
|
||||
> Ветка: `dynamic-tests`
|
||||
|
||||
## Концепция
|
||||
|
||||
Два теста: **на месте** и **в движении**. Разница — только в подсказке юзеру.
|
||||
Физически оба делают одно: опрашивают 12 PID каждые 250мс, копят в памяти.
|
||||
|
||||
## Алгоритм работы (user flow)
|
||||
|
||||
```
|
||||
1. Ошибки → scanDtc() — считывание DTC
|
||||
2. Диагностика → обычный скрипт L0/L1/L2 на сервер → LLM
|
||||
3. LLM сказал сделать тест на месте →
|
||||
⏱ На месте → СТАРТ → ... → СТОП → данные в памяти
|
||||
4. Диагностика → обычный скрипт + динамические данные → LLM
|
||||
5. LLM сказал сделать тест в движении →
|
||||
🚗 В движении → СТАРТ → ... → СТОП → данные в памяти
|
||||
6. Диагностика → обычный скрипт + динамические данные → LLM (финал)
|
||||
```
|
||||
|
||||
## Android → Сервер
|
||||
|
||||
При нажатии **Диагностика**:
|
||||
1. Выполняется обычный скрипт (AT-команды, PID'ы)
|
||||
2. Если есть `dynamicSamples` (данные теста) — прикрепляются к тому же запросу
|
||||
3. POST `/api/v1/session/upload` с полем `dynamic_samples`
|
||||
4. Сервер по наличию `dynamic_samples` понимает, что пришёл динамический тест
|
||||
5. Выбирает промпт: `DYNAMIC_PROMPT` если только динамика, или `SYSTEM_PROMPT + DYNAMIC_PROMPT` если и то и то
|
||||
|
||||
## Что надо сделать
|
||||
|
||||
### Уже сделано (написан код, не проверен)
|
||||
- [x] Сервер: `build_dynamic_script()` — скрипт 12 PID 250мс
|
||||
- [x] Сервер: `?mode=dynamic` в routes.py
|
||||
- [x] Сервер: `DYNAMIC_PROMPT` в prompts.py
|
||||
- [x] Android: кнопки «⏱ На месте» / «🚗 В движении» (layout)
|
||||
- [x] Android: `DynamicCollector.kt` — сбор 12 PID 250мс
|
||||
- [x] Android: `ElmChecker.kt` — добавлены методы для работы с DynamicCollector
|
||||
- [x] Android: MainActivity — СТАРТ/СТОП, данные в памяти
|
||||
- [x] Android: общий таймер `tv_timer`
|
||||
|
||||
### Надо доделать
|
||||
- [ ] Android: при старте Диагностики — прикрепить `dynamicSamples` к upload
|
||||
- [ ] Сервер: принимать `dynamic_samples` в upload_session, выбирать промпт
|
||||
- [ ] Отладка на реальной машине
|
||||
|
||||
### До отправки — в памяти
|
||||
```kotlin
|
||||
data class DynamicSample(
|
||||
val ts: Long, // System.currentTimeMillis()
|
||||
val responses: List<RawResponse> // 12 ответов ELM
|
||||
)
|
||||
|
||||
val samples = mutableListOf<DynamicSample>() // в памяти, быстро
|
||||
```
|
||||
|
||||
### После СТОП — попытка отправки
|
||||
```kotlin
|
||||
1. Собираем все samples в JSON
|
||||
2. POST /api/v1/session/upload
|
||||
3. Если 200 → ОК, забыли
|
||||
4. Если ошибка → пишем ВСЁ в SessionDb
|
||||
```
|
||||
|
||||
### Локальное хранение — через SessionDb (уже есть)
|
||||
```sql
|
||||
-- Таблица sessions (уже существует)
|
||||
session_type = "dynamic" -- отличаем от обычных
|
||||
|
||||
-- Таблица responses (уже существует)
|
||||
-- Каждый сэмпл = одна запись в responses:
|
||||
-- session_id, step_id = "sample_N", cmd = "batch",
|
||||
-- raw = JSON всего опроса, decoded = "запись №N"
|
||||
```
|
||||
|
||||
**Плюсы**: не надо новой таблицы, `SessionDb` уже умеет `createSession`/`addResponse`/`getResponses`.
|
||||
|
||||
**Объём**: 30 секунд × 4 опроса/с × 12 PID = 1440 записей ≈ ~100KB — норм.
|
||||
|
||||
### Ретрай непосланных сессий
|
||||
При старте приложения: `db.getUnuploadedSessions()` → отправить → пометить `uploaded=1`.
|
||||
|
||||
## Промпт для LLM (сервер)
|
||||
|
||||
Отдельный `DYNAMIC_PROMPT` в `brain/prompts.py`:
|
||||
|
||||
```
|
||||
Ты — эксперт по диагностике. Получены временные ряды 12 параметров с интервалом 250мс.
|
||||
Проанализируй:
|
||||
1. Отклик дросселя — есть ли задержка/провалы
|
||||
2. STFT/LTFT — богатая или бедная смесь под нагрузкой и при сбросе
|
||||
3. RPM — плавность роста/падения, пропуски
|
||||
4. MAP — соответствует ли оборотам
|
||||
5. Зажигание — есть ли коррекция, детонация
|
||||
6. Аномалии — резкие скачки, выбросы
|
||||
Отвечай кратко: 2-3 предложения вывода, затем по пунктам что не так.
|
||||
```
|
||||
|
||||
## Что НЕ делаем
|
||||
- GPS / акселерометр — не сейчас
|
||||
- Автодетект фаз (разгон/сброс) — ручной СТАРТ/СТОП
|
||||
- Отправка пачками в реальном времени — копим всё до СТОП
|
||||
@@ -0,0 +1,144 @@
|
||||
# Полевой тест elmAI v0.48.0 — 7 июня 2026
|
||||
|
||||
## Что тестируем
|
||||
|
||||
APK: https://obdai.ru/elmer.apk (v0.48.0)
|
||||
Сервер: https://obdai.ru
|
||||
|
||||
## Что сделано (все изменения)
|
||||
|
||||
### Пробинг ELM327 (НОВОЕ)
|
||||
- Сервер больше не шлёт ATAT1/ATSTxx слепо — не вешает клоны
|
||||
- При первом подключении нового ELM — каскадный тест:
|
||||
- Уровень 0: ATE0 ATL0 ATS0 ATH1 ATSP0 ATDPN ATRV ATI (все клоны)
|
||||
- Уровень 1: +ATAT1 (хорошие клоны)
|
||||
- Уровень 2: +ATCAF1 ATCFC1 (настоящий ELM)
|
||||
- Результат сохраняется в БД по BT MAC
|
||||
- Нет в профиле → не слать. Никаких ретраев на неизвестное.
|
||||
|
||||
### Скрипты под уровень устройства
|
||||
- L0: 5 PIDs + ошибки (однокадровые, без VIN)
|
||||
- L1: 8 PIDs + VIN + stored/pending ошибки
|
||||
- L2: 14 PIDs + VIN + калибровки + все ошибки
|
||||
|
||||
### Рефакторинг сервера
|
||||
- `obd/commands.py` — каталог ВСЕХ AT-команд с метаданными
|
||||
- `obd/classifier.py` — классификация ответов
|
||||
- `obd/connection.py` — транспортный слой
|
||||
- Код разбит на независимые модули
|
||||
|
||||
### Android: фикс вывода
|
||||
- Статус: `append("\n...")` вместо `text =` (не перекрывается)
|
||||
- Таймер: отдельный TextView, тикает только во время обмена (как Engine Time)
|
||||
- TestService: адаптивные таймауты вместо жёстких sleep
|
||||
- TestService: добавлен ATS0 в инициализацию
|
||||
|
||||
---
|
||||
|
||||
## Как тестировать
|
||||
|
||||
### Подготовка
|
||||
1. Скачай и установи APK: https://obdai.ru/elmer.apk
|
||||
2. Вставь ELM327 в OBD2-разъём машины
|
||||
3. Заведи двигатель (для части PIDs нужен работающий двигатель)
|
||||
4. Сопряги ELM327 с телефоном по Bluetooth (пароль 1234 или 0000)
|
||||
5. Открой приложение Elmer
|
||||
|
||||
### Тест 1: Кнопка «ТЕСТ» (без сервера)
|
||||
|
||||
Поле ввода: **оставь пустым** (Bluetooth-режим)
|
||||
|
||||
Нажми **«🧪 ТЕСТ (всё локально)»**
|
||||
|
||||
Ожидаемое поведение:
|
||||
```
|
||||
══════════════════
|
||||
🔧 ELMER TEST v0.6
|
||||
Режим: Bluetooth
|
||||
══════════════════
|
||||
Найден: OBDII (AA:BB:CC:...)
|
||||
⏳ Подключение...
|
||||
✅ BT OK
|
||||
─── ШАГ 1: Связь ───
|
||||
→ ATZ ⏱ 2с
|
||||
← ELM327 v1.5
|
||||
✅ СВЯЗЬ ЕСТЬ!
|
||||
─── ШАГ 2: Инициализация ───
|
||||
→ ATE0 ⏱ 0с
|
||||
← OK
|
||||
→ ATL0 ⏱ 0с
|
||||
← OK
|
||||
→ ATS0 ⏱ 0с
|
||||
← OK
|
||||
→ ATH1 ⏱ 0с
|
||||
← OK
|
||||
→ ATSP0 ⏱ 1с
|
||||
← OK
|
||||
─── ШАГ 3: VIN ───
|
||||
→ 0902 ⏱ 3с
|
||||
← 49 02 01 57 56...
|
||||
→ VIN: WVWZZZ...
|
||||
─── ШАГ 4: Ошибки ───
|
||||
→ 03 ⏱ 1с
|
||||
← 43 00
|
||||
→ DTC stored: none
|
||||
→ 07 ⏱ 1с
|
||||
← 47 00
|
||||
→ DTC pending: none
|
||||
─── ШАГ 5: Параметры ───
|
||||
→ 0105 ⏱ 0с
|
||||
← 41 05 5A
|
||||
→ ОЖ: 50 °C
|
||||
... (10 параметров)
|
||||
✅ ТЕСТ ПРОЙДЕН!
|
||||
```
|
||||
|
||||
**Что проверять:**
|
||||
- [ ] Таймер ⏱ тикает ТОЛЬКО во время команд (АТZ, 0902, 0105...)
|
||||
- [ ] В паузах между командами таймер пустой
|
||||
- [ ] Строки НЕ перекрываются (каждая с новой строки)
|
||||
- [ ] Тест НЕ виснет (раньше висел на ATAT1)
|
||||
- [ ] Если ELM не отвечает — пишет «❌ ELM не отвечает» за ~5 секунд, не дольше
|
||||
|
||||
### Тест 2: Кнопка «Диагностировать» (с сервером)
|
||||
|
||||
Поле ввода: **оставь пустым** (Bluetooth-режим)
|
||||
|
||||
Нажми **«🚗 Диагностировать (сервер)»**
|
||||
|
||||
**Что проверять:**
|
||||
- [ ] Статус: «BT: AA:BB:CC...» → «BT: OK»
|
||||
- [ ] Появляются «← ...» (ответы ELM) и «→ ...» (команды от сервера)
|
||||
- [ ] В конце — диагноз от LLM в рамке ══════
|
||||
- [ ] Таймер тикает только когда «→ команда» отправляется
|
||||
- [ ] Нет зависаний
|
||||
|
||||
### Тест 3: Если есть второй ELM327
|
||||
|
||||
Подключи другой ELM (другой клон/версия):
|
||||
- [ ] Приложение находит его по имени (OBD/ELM в названии)
|
||||
- [ ] Тест должен работать на любом клоне
|
||||
- [ ] Профиль сохраняется в БД на сервере (по MAC)
|
||||
|
||||
---
|
||||
|
||||
## Возможные проблемы
|
||||
|
||||
| Симптом | Вероятная причина |
|
||||
|---------|-------------------|
|
||||
| «❌ ELM не отвечает» на ATZ | ELM не вставлен в OBD2 или нет питания |
|
||||
| Долго висит на ATSP0 | Машина не поддерживает авто-протокол |
|
||||
| VIN не читается | Старая машина без mode 09 |
|
||||
| «❌ BT: ...» | ELM не сопряжён в настройках Bluetooth |
|
||||
| Пустой экран после нажатия | Не даны разрешения Bluetooth (Android 12+) |
|
||||
|
||||
---
|
||||
|
||||
## После теста
|
||||
|
||||
Вернись к компу и скажи:
|
||||
1. Какие ELM тестировал (версия, цвет, название)
|
||||
2. Прошёл ли тест
|
||||
3. Прошла ли диагностика с сервером
|
||||
4. Были ли зависания
|
||||
5. Скриншоты экрана (если можно)
|
||||
@@ -0,0 +1,75 @@
|
||||
# Инструкция по Git
|
||||
|
||||
> v0.36.0-dev, 3 июня 2026
|
||||
|
||||
## Структура репозиториев
|
||||
|
||||
Один проект — два git-репо в одной папке:
|
||||
|
||||
```
|
||||
elmer/ ← git-репо #1 (gitea)
|
||||
├── api/ серверный код
|
||||
├── brain/ LLM-клиент
|
||||
├── obd/ ELM327
|
||||
├── web/ Flask, сайт, APK
|
||||
├── doc/ документация
|
||||
├── android/ ← git-репо #2 (github) — Android-приложение
|
||||
│ ├── app/ исходники Kotlin
|
||||
│ ├── build.gradle.kts сборка
|
||||
│ └── ...
|
||||
├── config.yaml конфиг сервера
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Ремоуты
|
||||
|
||||
| Репо | URL |
|
||||
|------|-----|
|
||||
| Сервер (elmer/) | `https://gitea.services.ngcloud.ru/Nail/elmer.git` |
|
||||
| Android (elmer/android/) | `https://github.com/Repinoid/elmer-android.git` |
|
||||
|
||||
## Команды
|
||||
|
||||
### Сервер
|
||||
|
||||
```bash
|
||||
cd elmer
|
||||
git pull origin master # забрать изменения
|
||||
git add -A # добавить всё
|
||||
git commit -m "..." # закоммитить
|
||||
git push origin master # отправить
|
||||
```
|
||||
|
||||
### Android
|
||||
|
||||
```bash
|
||||
cd elmer/android
|
||||
git pull origin master # забрать изменения
|
||||
git add -A # добавить всё
|
||||
git commit -m "..." # закоммитить
|
||||
git push origin master # отправить
|
||||
```
|
||||
|
||||
## Деплой на сервер (obdai.ru)
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 \
|
||||
"cd /opt/elmer && git pull origin master && sudo systemctl restart elmer"
|
||||
```
|
||||
|
||||
## Сборка APK (на сервере)
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 \
|
||||
"export ANDROID_SDK_ROOT=\$HOME/android-sdk && \
|
||||
export ANDROID_HOME=\$ANDROID_SDK_ROOT && \
|
||||
cd /opt/elmer/android && \
|
||||
./gradlew clean assembleDebug && \
|
||||
cp app/build/outputs/apk/debug/app-debug.apk /opt/elmer/web/static/"
|
||||
```
|
||||
|
||||
## Важно
|
||||
|
||||
- `android/` в `.gitignore` родительского репо — его изменения коммитятся отдельно
|
||||
- Ветка везде `master`
|
||||
- Перед началом работы всегда делать `git pull` в обоих репо
|
||||
@@ -0,0 +1,25 @@
|
||||
# 2026-05-31
|
||||
|
||||
## Сервер
|
||||
- 🔴 **api/db.py** — WAL, `request_id` UNIQUE, `close()`, контекстный менеджер
|
||||
- 🔴 **api/routes.py** — идемпотентность upload, `/ping-llm` кэш 60с, `/chat` через roles
|
||||
- 🔴 **obd/protocol.py** — `reset_input_buffer()`, не затирать ERROR
|
||||
- 🔴 **brain/client.py** — `LLMError`, обработка 429/5xx/Timeout, дефолт gpt-oss-120b
|
||||
- 🟡 **api/config.py** — `@lru_cache` на `load()`
|
||||
- 🟡 **api/routes.py** — импорты наверх
|
||||
- Удалён мёртвый `elmer/elmer/`
|
||||
|
||||
## Android
|
||||
- 🔴 **ServerClient** — `request_id` (UUID), `X-Api-Key`, exponential backoff
|
||||
- 🔴 **ElmProtocol** — дренаж буфера, не затирать ERROR
|
||||
- 🔴 **SessionDb** — `ALTER TABLE` вместо DROP, индекс
|
||||
- 🔴 **MainActivity** — throttle `/ping-llm`, `X-Api-Key`, `chatHistory` persistence
|
||||
- 🔴 **ScriptRunnerService** — null-intent guard, убран мёртвый `paused`
|
||||
- 🟡 **build.gradle.kts** — `buildConfigField API_KEY`
|
||||
|
||||
## Прочее
|
||||
- VIN → logo
|
||||
- Версия: 0.35.0-dev → 0.36.0-dev
|
||||
- `.instructions.md` — создан
|
||||
- `doc/architecture.md` — CI/CD расписан
|
||||
- GitHub Actions удалён и восстановлен (несколько раз)
|
||||
@@ -0,0 +1,13 @@
|
||||
# 2026-06-03
|
||||
|
||||
## Сервер
|
||||
- Дефолтный LLM: `api.aillm.ru` → `api.deepseek.com` (deepseek-v4-flash)
|
||||
- `doc/git-guide.md` — создана инструкция по git
|
||||
- `doc/architecture.md` — уточнён деплой (git clone, не tar.gz)
|
||||
|
||||
## Android
|
||||
- `build.gradle.kts` — `buildFeatures.buildConfig = true`
|
||||
|
||||
## Прочее
|
||||
- Версия: 0.36.0-dev
|
||||
- Сервер переведён на git-клон вместо tar.gz (удалён elmer.old)
|
||||
@@ -0,0 +1,21 @@
|
||||
# 2026-06-05
|
||||
|
||||
## Сервер
|
||||
- `car_info` в upload — водитель может описать авто текстом, передаётся в LLM
|
||||
- GitHub Actions разрешён для APK (теперь CI собирает и деплоит)
|
||||
|
||||
## Android
|
||||
- **`ElmChecker.kt`** — новый: проверка ELM327 (версия, напряжение, BT)
|
||||
- Поле «что за машина» — ввод описания авто перед диагностикой
|
||||
- Индикаторы загрузки — улучшен UI
|
||||
- Подпись APK — фиксированный ключ (APK обновляется без удаления)
|
||||
- **GitHub Actions** — автосборка + деплой APK на сервер
|
||||
- `gradlew` — добавлен в репо
|
||||
- `ServerClient` — `ping()` / `pingLlm()`
|
||||
- 🔴 **ElmChecker** — разделён на `DeviceInfo` (AT, без зажигания) + `EcuData` (VIN/PID)
|
||||
- 🔴 **ElmChecker** — фикс: `checkDevice()` дёргал `connectAndInit()` второй раз → сбрасывал ELM → пустые ответы
|
||||
- 🔴 **MainActivity** — секундомер при проверке ELM
|
||||
- 🟡 **ElmChecker** — `AT@2` добавлен, фильтр бинарного мусора
|
||||
|
||||
## Прочее
|
||||
- Версия: 0.37.0-dev → 0.38.0-dev
|
||||
@@ -0,0 +1,26 @@
|
||||
# 2026-06-06
|
||||
|
||||
## Сервер
|
||||
- 🔴 `api/db.py` — `device_uuid` колонка, `phone_lang`, `phone_tz`, `phone_display`, `save_dtc_scan()`
|
||||
- 🔴 `api/routes.py` — `POST /api/v1/dtc/decode`, `POST /api/v1/dtc/upload` (+ идемпотентность)
|
||||
- 🔴 `api/routes.py` — промпт-билдер: защита от пустых `{}` и `None` списков
|
||||
- 🔴 `api/parser.py` — VIN из CAN multi-frame (0: 1: 2:) и ISO-TP (10 14 21 22)
|
||||
- 🔴 `api/parser.py` — DTC из raw HEX: фикс `mode="43"` вместо `"03"`
|
||||
- `brain/prompts.py` — правило №8: неточности данных (дубль DTC, клон, напряжение)
|
||||
- `brain/prompts.py` — правило №7 (было №11): никогда не раскрывать модель/создателя
|
||||
- `doc/dtc_codes.txt` — справочник 128 кодов DTC
|
||||
- `doc/SETUP.md` — инструкция для нового разработчика
|
||||
- `tests/test_all.py` — 91 тест (ELM, DTC, VIN, БД, идемпотентность, экстремальные)
|
||||
|
||||
## Android
|
||||
- 🔴 Кнопка «⚠️ ОШИБКИ» — сканирование DTC (mode 03 + 07)
|
||||
- 🔴 Кнопка «🔍 ДИАГНОСТИКА» блокирована до скана ошибок
|
||||
- 🔴 `ElmChecker.scanDtc()` — отдельный метод сканирования ошибок
|
||||
- 🔴 `SharedPreferences` — `device_uuid` генерируется при первом запуске
|
||||
- `ScriptRunnerService.buildClientInfo()` — язык, часовой пояс, разрешение
|
||||
- 3 кнопки в рамке «🔍 Проверка»: Сервер | ELM | ЭБУ
|
||||
|
||||
## Прочее
|
||||
- Версия: 0.39.0-dev → 0.40.0-dev
|
||||
- APK через nginx напрямую (правило в `.instructions.md`)
|
||||
- На ВМ установлен Android SDK + Gradle для сборки
|
||||
@@ -0,0 +1,46 @@
|
||||
# Планы на 2026-06-07 (вечер)
|
||||
|
||||
## 1. 🔴 Исправить 16-ричный вывод параметров
|
||||
**Проблема**: при считывании PID'ов на экран выводятся hex-коды вместо человеческих значений.
|
||||
- Температура ОЖ: `41053C` вместо `ОЖ: 20°C`
|
||||
- Обороты: `410C1A2B` вместо `Обороты: 850 об/мин`
|
||||
**Причина**: ObdDecoder.decode() не чистит `\r` `\n` из raw ответа → parseInt падает → возвращает raw.
|
||||
**Фикс**: добавить `.replace("\r", "").replace("\n", "")` в clean.
|
||||
|
||||
## 2. 🟡 Кнопки СЕРВЕР/ELM/ЭБУ — только до первой диагностики
|
||||
После первого успешного "done" — убрать эти кнопки. Они нужны только для первоначальной проверки.
|
||||
|
||||
## 3. 🟢 Динамические тесты (две новые кнопки)
|
||||
Появляются после первичной диагностики вместо СЕРВЕР/ELM/ЭБУ.
|
||||
|
||||
### Тест 1: «На месте»
|
||||
- Подсказка: «Нажмите на педаль газа, поднимите обороты до 3000, держите 3-4 секунды, затем резко сбросьте газ. Нажмите СТАРТ когда готовы.»
|
||||
- Кнопка: «⏱ Тест на месте» → после нажатия → «▶ Старт»
|
||||
- После Старт: мониторинг RPM, MAP, STFT, LTFT — 5-10 секунд
|
||||
- Анализ: отклик дросселя, провалы, богатая/бедная смесь при сбросе
|
||||
|
||||
### Тест 2: «В движении»
|
||||
- Подсказка: «Включите 2-ю передачу (АКПП — ручной режим), разгонитесь до ~3000 об/мин, держите несколько секунд, затем резко сбросьте газ. Нажмите СТАРТ ДО начала движения — программа сама отследит параметры.»
|
||||
- Кнопка: «🚗 Тест в движении» → после нажатия → «▶ Старт (нажать до движения)»
|
||||
- После Старт: мониторинг скорости, RPM, нагрузки, STFT — автоматическое определение начала движения и сброса газа
|
||||
- Анализ: поведение под нагрузкой, детонация, пропуски
|
||||
|
||||
## 4. 🟢 Flow кнопок
|
||||
```
|
||||
[Начало] → СЕРВЕР | ELM | ЭБУ | ОШИБКИ | ДИАГНОСТИКА
|
||||
↓ после диагностики
|
||||
[Результат] → ✕ Закрыть | ⏱ Тест на месте | 🚗 Тест в движении | поле ввода + ➤
|
||||
```
|
||||
|
||||
## 5. 🟡 История — не терять
|
||||
При переключении между тестами и чатом — сохранять вывод.
|
||||
|
||||
## 6. 🔧 Скрипты для тестов
|
||||
Нужно создать server-side скрипты:
|
||||
- `script/dynamic_idle.json` — тест на месте (RPM, MAP, STFT, LTFT, дроссель, нагрузка)
|
||||
- `script/dynamic_drive.json` — тест в движении (RPM, скорость, нагрузка, STFT, MAP)
|
||||
|
||||
Оба скрипта должны:
|
||||
- Считывать параметры с высокой частотой (ATAT1 если v2)
|
||||
- Автоматически определять фазы: разгон → удержание → сброс
|
||||
- Отправлять результаты на сервер → LLM-анализ
|
||||
@@ -0,0 +1,96 @@
|
||||
# 2026-06-07 — Пробинг ELM327, трехуровневый профиль
|
||||
|
||||
## Проблема
|
||||
|
||||
`obd/protocol.py` — `init()` посылал ATAT1 и ATSTxx всем устройствам.
|
||||
Большинство клонов v1.5 не знают этих команд → тишина → `_exec()` делает до 10 ретраев → всё висит на десятки секунд.
|
||||
|
||||
## Решение
|
||||
|
||||
### 1. Трехуровневый пробинг (`obd/probe.py`)
|
||||
|
||||
Каскадный тест: сначала база, потом улучшения.
|
||||
|
||||
```
|
||||
Уровень 0 (база, все клоны):
|
||||
ATE0 ATL0 ATS0 ATH1 ATSP0 ATDPN ATRV ATI
|
||||
→ хоть одна не ответила OK → НЕИСПРАВЕН
|
||||
|
||||
Уровень 1 (хорошие клоны):
|
||||
ATAT1
|
||||
→ OK → уровень 1
|
||||
|
||||
Уровень 2 (настоящие ELM):
|
||||
ATCAF1 ATCFC1
|
||||
→ OK → уровень 2
|
||||
```
|
||||
|
||||
Результат сохраняется в `device_profiles` по ключу BT MAC.
|
||||
|
||||
### 2. Fix `obd/protocol.py` init()
|
||||
|
||||
`init()` теперь шлёт ТОЛЬКО базу (уровень 0): ATE0 ATL0 ATS0 ATH1 ATSP0.
|
||||
|
||||
Методы под уровень:
|
||||
- `init_base()` — то же что init()
|
||||
- `init_l1()` — + ATAT1
|
||||
- `init_l2()` — + ATAT1 + ATCAF1 + ATCFC1
|
||||
|
||||
### 3. Таблица `device_profiles` (`api/db.py`)
|
||||
|
||||
```sql
|
||||
CREATE TABLE device_profiles (
|
||||
mac TEXT PRIMARY KEY, -- BT MAC
|
||||
level INTEGER NOT NULL, -- 0/1/2
|
||||
elm_version TEXT, -- ATI ответ
|
||||
elm_desc TEXT, -- AT@1 (если есть)
|
||||
protocol TEXT, -- ATDPN
|
||||
voltage TEXT, -- ATRV
|
||||
supported TEXT, -- JSON: ["ATE0","ATL0",...]
|
||||
unsupported TEXT, -- JSON: ["ATAT1","ATCAF1",...]
|
||||
first_seen TEXT NOT NULL,
|
||||
last_seen TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
### 4. Скрипты под уровень (`api/scripts.py`)
|
||||
|
||||
- `build_script_l0()` — 5 PIDs + ошибки (однокадровые)
|
||||
- `build_script_l1()` — 8 PIDs + VIN + ошибки
|
||||
- `build_script_l2()` — 14 PIDs + VIN + калибровки + все ошибки
|
||||
|
||||
### 5. Эндпоинты (`api/routes.py`)
|
||||
|
||||
- `POST /api/v1/elm/probe` — принимает MAC + ответы, возвращает профиль
|
||||
- `GET /api/v1/elm/profile/<mac>` — достаёт из кэша
|
||||
- `GET /api/v1/script?level=0|1|2` — скрипт под уровень
|
||||
|
||||
### 6. Рефакторинг: разделение на независимые сервисы
|
||||
|
||||
Файлы разбиты по тематике, каждый — отдельный сервис:
|
||||
|
||||
```
|
||||
obd/
|
||||
commands.py — Каталог ВСЕХ AT-команд ELM327 (с метаданными)
|
||||
classifier.py — Классификация ответов + определение уровня
|
||||
connection.py — Транспортный слой (SerialTransport)
|
||||
probe.py — Пробинг (использует commands + classifier)
|
||||
protocol.py — Стейт-машина AndrOBD (использует connection)
|
||||
state.py — Состояния/типы ответов
|
||||
timing.py — Адаптивный таймаут
|
||||
```
|
||||
|
||||
**Принцип**: каждый модуль делает одно дело, не дублирует логику.
|
||||
- `commands.py` — единственный источник правды о командах
|
||||
- `classifier.py` — единственное место классификации ответов
|
||||
- `connection.py` — единственное место I/O
|
||||
- `routes.py` — тонкая прослойка, без бизнес-логики
|
||||
|
||||
### 5. Эндпоинт (`api/routes.py`)
|
||||
|
||||
`POST /api/v1/elm/probe` — принимает MAC, возвращает профиль.
|
||||
Сервер сам шлёт команды через реле (Android ElmForwardService).
|
||||
|
||||
## Ключевое правило
|
||||
|
||||
**Нет в профиле → не слать. Никаких ретраев на неизвестное.**
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
# Новая морда elmAI — v0.57.0+
|
||||
|
||||
> Проект из morda.txt, обсуждение 7 июня 2026
|
||||
|
||||
## Макет
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 🔧 elmAI v0.57.0 │
|
||||
│ [📡●] [🔌●] [🚗●] [🧠●] │ иконки + светофоры (тап = перепроверка)
|
||||
├─────────────────────────────────────┤
|
||||
│ [ ОШИБКИ / ДИАГНОСТИКА / СТАРТ / СТОП ] │ одна кнопка-трансформер
|
||||
├─────────────────────────────────────┤
|
||||
│ │
|
||||
│ поле вывода │ ScrollView
|
||||
│ │
|
||||
├─────────────────────────────────────┤
|
||||
│ [📋 История] │ кнопка
|
||||
├─────────────────────────────────────┤
|
||||
│ [________________________] [➤] │ поле ввода 2 строки + кнопка
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Светофоры
|
||||
|
||||
| Иконка | Текст | Зелёный | Жёлтый | Красный |
|
||||
|--------|-------|---------|--------|---------|
|
||||
| 📡 | Сервер | ping < 3с | проверка... | нет связи |
|
||||
| 🔌 | ELM | BT + ATI ok | подключение... | нет ELM |
|
||||
| 🚗 | ЭБУ | 0100 ok | — | нет связи с ЭБУ |
|
||||
| 🧠 | LLM | ping-llm ok | проверка... | нет доступа |
|
||||
|
||||
- Жёлтый только во время проверки
|
||||
- Тап по иконке → перепроверка конкретного компонента
|
||||
- LLM проверяется только если Сервер зелёный
|
||||
|
||||
### Таймауты
|
||||
- ELM: 2 попытки connect по 4с = 8с макс → красный
|
||||
- Сервер: HTTP GET /api/v1/ping, таймаут 3с → красный
|
||||
- LLM: HTTP GET /api/v1/ping-llm, таймаут 5с → красный
|
||||
- Перепроверка при тапе: сбрасывает на жёлтый, запускает проверку
|
||||
|
||||
## Кнопка-трансформер
|
||||
|
||||
Одна кнопка, меняет текст/цвет/действие:
|
||||
|
||||
```
|
||||
[ОШИБКИ] ──→ сканирование DTC ──→ [ДИАГНОСТИКА]
|
||||
↓
|
||||
если сервер ✅ → обычный скрипт + LLM
|
||||
если сервер ❌ → пояснение в выводе
|
||||
↓
|
||||
[СТАРТ]
|
||||
↓
|
||||
газ 3000 → сброс → [СТОП]
|
||||
↓
|
||||
данные в памяти → жми ➤
|
||||
```
|
||||
|
||||
## Поле ввода + кнопка ➤
|
||||
|
||||
- Всегда активно, 2 строки
|
||||
- **Обычный режим**: ввёл текст → ➤ → отправка в чат с LLM
|
||||
- **После СТОП**: ➤ отправляет накопленные данные теста на сервер + текст (если есть)
|
||||
- После отправки → результат LLM в выводе
|
||||
|
||||
## История
|
||||
|
||||
- Кнопка, как сейчас
|
||||
- Выбор записи → диагноз + 📤 Поделиться
|
||||
- После Share — возврат в приложение (стандартное поведение Android)
|
||||
|
||||
## Flow в деталях
|
||||
|
||||
### 1. Запуск приложения
|
||||
- Светофоры: ELM жёлтый, Сервер жёлтый, ЭБУ красный, LLM красный
|
||||
- Проверка ELM (8с) → зелёный/красный
|
||||
- Проверка Сервера (3с) → зелёный/красный
|
||||
- Если Сервер зелёный → проверка LLM (5с) → зелёный/красный
|
||||
- Если ELM зелёный → проверка ЭБУ (через 0100)
|
||||
|
||||
### 2. Ошибки
|
||||
- Тап ОШИБКИ → scanDtc() → результат в выводе
|
||||
- Кнопка → ДИАГНОСТИКА
|
||||
|
||||
### 3. Диагностика
|
||||
- Если сервер зелёный → обычный скрипт L0/L1/L2 → LLM → вывод
|
||||
- Если сервер красный → «Сервер недоступен. Сделайте тест на месте.»
|
||||
- Кнопка → СТАРТ
|
||||
|
||||
### 4. СТАРТ
|
||||
- Подсказка в выводе: «Нажмите газ, 3000 об/мин 3-4с, сбросьте. Нажмите СТОП.»
|
||||
- Запись 12 PID каждые 250мс
|
||||
- Кнопка → СТОП
|
||||
|
||||
### 5. СТОП
|
||||
- Запись остановлена, данные в памяти
|
||||
- Вывод: «Записано N отсчётов. Нажмите ➤ для отправки.»
|
||||
- Если нужно — можно снова СТАРТ (новый тест)
|
||||
- Кнопка → СТАРТ (снова)
|
||||
|
||||
### 6. Отправка (➤)
|
||||
- Если есть данные теста → POST /api/v1/session/upload с dynamic_samples
|
||||
- Если есть текст → добавляется как car_info
|
||||
- LLM-анализ → результат в выводе
|
||||
|
||||
## Что удалить из текущего UI
|
||||
- Кнопки Сервер, ELM, ЭБУ как отдельные кнопки → станут иконками
|
||||
- dynamicButtons (На месте / В движении) → не нужны, всё через одну кнопку
|
||||
- btnDynStart → не нужна
|
||||
- CheckBox «Полная диагностика» → всегда полная
|
||||
- tvDtcStatus → не нужен, всё в выводе
|
||||
- tvPrompt → не нужен
|
||||
@@ -0,0 +1,144 @@
|
||||
# План правок по отчётам Опуса
|
||||
|
||||
> 31 мая 2026 · ветка `opus-fixes` · порядок: по критичности + зависимостям
|
||||
|
||||
---
|
||||
|
||||
## Этап 1. Сервер (`elmer/`) — 4 правки
|
||||
|
||||
### 1.1 🔴 `api/db.py` — WAL + закрытие соединений + request_id
|
||||
**Файл**: `api/db.py`
|
||||
**Строки**: класс `Database`, методы `_init_schema()`, `save_session()`
|
||||
|
||||
- [x] Добавить `PRAGMA journal_mode=WAL` и `busy_timeout=30000`
|
||||
- [x] Добавить колонку `request_id TEXT UNIQUE` в `sessions`
|
||||
- [x] Метод `close()` и контекстный менеджер (`__enter__`/`__exit__`)
|
||||
- [x] `save_session()` — проверять `request_id` на дубликат, возвращать кэшированный диагноз
|
||||
- [x] Индекс `idx_sessions_request_id`
|
||||
|
||||
### 1.2 🔴 `api/routes.py` — идемпотентность upload + /ping-llm без LLM
|
||||
**Файл**: `api/routes.py`
|
||||
**Строки**: `upload_session()`, `ping_llm()`
|
||||
|
||||
- [x] `upload_session()` — принимать `request_id` из JSON, возвращать кэш при дубликате
|
||||
- [x] `upload_session()` — закрывать `db` через контекстный менеджер
|
||||
- [x] `upload_session()` — не отдавать `str(e)` наружу, логировать, клиенту — обобщённый текст
|
||||
- [x] `/ping-llm` — кэшировать результат на 60с, не вызывать LLM на каждый GET
|
||||
|
||||
### 1.3 🔴 `obd/protocol.py` — сброс буфера + не затирать ERROR
|
||||
**Файл**: `obd/protocol.py`
|
||||
**Строки**: `_write()`, `send()`
|
||||
|
||||
- [x] `_write()` — `self._ser.reset_input_buffer()` перед записью
|
||||
- [x] `send()` — `if self._state == State.BUSY: self._state = State.READY` (не безусловно)
|
||||
|
||||
### 1.4 🟡 `brain/client.py` — таймаут из конфига + модель
|
||||
**Файл**: `brain/client.py`
|
||||
**Строки**: `Diagnoser.__init__()`, `Diagnoser.ask()`
|
||||
|
||||
- [x] `DEFAULT_MODEL` → `"gpt-oss-120b"`
|
||||
- [x] `timeout` — параметр конструктора (по умолчанию 180)
|
||||
- [x] Комментарии: убрать «DeepSeek»
|
||||
|
||||
---
|
||||
|
||||
## Этап 2. Сервер (`elmer/`) — улучшения (без 🔴 но важные)
|
||||
|
||||
### 2.1 🟡 `api/routes.py` — импорты наверх + кэш конфига
|
||||
**Файл**: `api/routes.py`, `api/config.py`
|
||||
|
||||
- [x] Поднять импорты (`from brain.client import Diagnoser` и др.) на уровень модуля
|
||||
- [x] `api/config.py` — `@lru_cache(maxsize=1)` на `load()`
|
||||
|
||||
### 2.2 🟡 `api/routes.py` — история /chat через роли
|
||||
**Файл**: `api/routes.py`
|
||||
**Строки**: `chat()`
|
||||
|
||||
- [x] Передавать историю как массив `messages` с ролями, а не строкой «Водитель:/Автоэксперт:»
|
||||
|
||||
### 2.3 🟡 `brain/client.py` — обработка ошибок LLM
|
||||
**Файл**: `brain/client.py`
|
||||
|
||||
- [x] Различать `Timeout`, `HTTPError(429)`, `HTTPError(5xx)`, `HTTPError(4xx)`
|
||||
- [x] Не отдавать детали исключения наружу
|
||||
|
||||
---
|
||||
|
||||
## Этап 3. Android (`elmer-android/`) — 6 правок
|
||||
|
||||
### 3.1 🔴 `ServerClient.kt` — request_id + идемпотентность
|
||||
**Файл**: `app/src/main/java/ru/elmer/client/server/ServerClient.kt`
|
||||
**Строки**: `uploadSession()`
|
||||
|
||||
- [ ] Генерировать `UUID` один раз до цикла ретраев
|
||||
- [ ] Добавить `"request_id"` в JSON-тело
|
||||
- [ ] Добавить заголовок `Idempotency-Key`
|
||||
|
||||
### 3.2 🔴 `ServerClient.kt` + `build.gradle.kts` — X-Api-Key
|
||||
**Файлы**: `ServerClient.kt`, `app/build.gradle.kts`
|
||||
|
||||
- [ ] `build.gradle.kts` — `buildConfigField("String", "API_KEY", ...)`
|
||||
- [ ] `ServerClient` — добавлять `X-Api-Key` во все запросы
|
||||
- [ ] `MainActivity.sendToLlm()` и `startTest()` — тоже `X-Api-Key`
|
||||
|
||||
### 3.3 🔴 `ElmProtocol.kt` — не затирать ERROR + дренаж буфера
|
||||
**Файл**: `app/src/main/java/ru/elmer/client/elm/ElmProtocol.kt`
|
||||
**Строки**: `sendCommand()`, `write()`
|
||||
|
||||
- [ ] `sendCommand()` — `if (state == State.BUSY) state = State.READY`
|
||||
- [ ] `write()` — `while (input.available() > 0) input.read()` перед записью
|
||||
|
||||
### 3.4 🔴 `SessionDb.kt` — безопасная миграция + индекс
|
||||
**Файл**: `app/src/main/java/ru/elmer/client/db/SessionDb.kt`
|
||||
**Строки**: `onUpgrade()`, `onCreate()`
|
||||
|
||||
- [ ] `onUpgrade()` — `ALTER TABLE` вместо `DROP TABLE`
|
||||
- [ ] Индекс `idx_resp_session ON responses(session_id)`
|
||||
|
||||
### 3.5 🔴 `MainActivity.kt` — /ping-llm без LLM + двойной receiver + chatHistory
|
||||
**Файл**: `app/src/main/java/ru/elmer/client/ui/MainActivity.kt`
|
||||
**Строки**: `startTest()`, `sendToLlm()`, receiver-регистрация
|
||||
|
||||
- [ ] `startTest()` — троттлить `/ping-llm` (не чаще раза в 60с), предупреждать
|
||||
- [ ] Убрать дублирующий receiver `statusReceiver` (оставить `scriptStatusReceiver`)
|
||||
- [ ] `chatHistory` сохранять в `onSaveInstanceState` (JSON)
|
||||
|
||||
### 3.6 🔴 `ScriptRunnerService.kt` — null intent + мёртвый paused + try/finally
|
||||
**Файл**: `app/src/main/java/ru/elmer/client/script/ScriptRunnerService.kt`
|
||||
**Строки**: `onStartCommand()`, `executeScript()`
|
||||
|
||||
- [ ] `onStartCommand()` — `if (intent == null) { stopSelf(); return START_NOT_STICKY }`
|
||||
- [ ] Убрать мёртвый флаг `paused` и `ACTION_RESUME` (или доделать паузу)
|
||||
- [ ] `executeScript()` — `try/finally` вокруг `progress.stop()`
|
||||
|
||||
---
|
||||
|
||||
## Этап 4. Android (`elmer-android/`) — улучшения
|
||||
|
||||
### 4.1 🟡 `ServerClient.kt` — exponential backoff
|
||||
**Файл**: `ServerClient.kt`
|
||||
|
||||
- [ ] `(1 shl (attempt-1)) * 1000 + Random.nextLong(0, 500)` вместо фиксированных 2000
|
||||
|
||||
### 4.2 🟡 `MainActivity.kt` — единый HTTP-клиент
|
||||
**Файл**: `MainActivity.kt`
|
||||
|
||||
- [ ] `sendToLlm()` и `startTest()` перевести на OkHttp (через `ServerClient`)
|
||||
|
||||
### 4.3 🟡 `ScriptRunnerService.kt` — вынести хост в константу
|
||||
**Файл**: `ScriptRunnerService.kt`, `MainActivity.kt`
|
||||
|
||||
- [ ] `obdai.ru` → `BuildConfig.SERVER_HOST` или константа
|
||||
|
||||
---
|
||||
|
||||
## Порядок выполнения
|
||||
|
||||
```
|
||||
Этап 1 (сервер 🔴) → коммит
|
||||
Этап 2 (сервер 🟡) → коммит
|
||||
Этап 3 (Android 🔴) → коммит
|
||||
Этап 4 (Android 🟡) → коммит
|
||||
```
|
||||
|
||||
После каждого этапа — проверка: `python run.py` (сервер), сборка APK (Android).
|
||||
@@ -0,0 +1,146 @@
|
||||
# Вопросы к Opus 4.8 по проекту elmAI
|
||||
|
||||
> v0.35.0-dev, 31 мая 2026
|
||||
> Сервер: Ubuntu 24, Python/Flask, gunicorn + nginx
|
||||
> Android: Kotlin, minSdk 24, OkHttp
|
||||
> LLM: api.aillm.ru, модель gpt-oss-120b
|
||||
|
||||
---
|
||||
|
||||
## Какие файлы смотреть (и только их)
|
||||
|
||||
### Сервер (elmer/)
|
||||
- `obd/protocol.py` — ELM327 стейт-машина AndrOBD (State, Rsp, AdaptiveTiming)
|
||||
- `brain/client.py` — Diagnoser (HTTP к LLM API)
|
||||
- `brain/prompts.py` — SYSTEM_PROMPT для диагностики
|
||||
- `api/routes.py` — все 5 эндпоинтов (script, upload, chat, ping, ping-llm)
|
||||
- `api/db.py` — SQLite: таблица sessions (30+ полей)
|
||||
- `api/scripts.py` — сборка диагностических скриптов
|
||||
- `api/parser.py` — парсинг ответов ELM327
|
||||
- `web/app.py` — точка входа Flask
|
||||
- `doc/architecture.md` — описание архитектуры
|
||||
|
||||
### Android (elmer-android/)
|
||||
- `script/ScriptRunnerService.kt` — сервис фоновой диагностики
|
||||
- `script/ScriptEngine.kt` — движок выполнения скриптов
|
||||
- `script/UploadProgress.kt` — таймер прогресса загрузки
|
||||
- `server/ServerClient.kt` — HTTP-клиент (OkHttp, retry 3x)
|
||||
- `elm/ElmProtocol.kt` — ELM327 стейт-машина (Kotlin)
|
||||
- `elm/ObdDecoder.kt` — декодер PID/DTC/VIN
|
||||
- `ui/MainActivity.kt` — главный экран
|
||||
- `db/SessionDb.kt` — локальная SQLite
|
||||
|
||||
---
|
||||
|
||||
## Вопрос 1. Стейт-машина ELM327: баги и крайние случаи
|
||||
|
||||
**Файлы**: `obd/protocol.py`, `elm/ElmProtocol.kt`
|
||||
|
||||
Стейт-машина — 1:1 копия AndrOBD (ElmProt.java). Ключевые моменты:
|
||||
- Байт-за-байтом чтение с 1мс поллингом
|
||||
- `>` как разделитель ответов
|
||||
- Адаптивный таймаут (200мс ± 4мс)
|
||||
- Восстановление после BUS ERROR (ATPC → ATSP0)
|
||||
|
||||
Вопросы:
|
||||
1. Есть ли race conditions или deadlocks в переходах состояний?
|
||||
2. Что если `>` приходит НЕ после полного ответа (мусор в буфере)?
|
||||
3. Корректна ли логика восстановления после BUS ERROR? Не теряем ли мы ответы при ATPC→ATSP0?
|
||||
4. Достаточен ли 1мс поллинг или на некоторых ELM нужен меньше?
|
||||
5. Есть ли риск бесконечного цикла в `_exec()` (10 ретраев)?
|
||||
|
||||
---
|
||||
|
||||
## Вопрос 2. HTTP 499 при upload с мобильной сети
|
||||
|
||||
**Файлы**: `script/ScriptRunnerService.kt`, `server/ServerClient.kt`, `api/routes.py`
|
||||
|
||||
**Симптом**: сервер получает POST, но клиент обрывает соединение (nginx: 499).
|
||||
- Connect timeout: 30с, read: 180с, write: 60с
|
||||
- 3 ретрая с задержкой 2с
|
||||
- nginx: client_body_timeout 120s, proxy_read_timeout 300s
|
||||
- gunicorn: timeout 180s
|
||||
|
||||
Вопросы:
|
||||
1. Какие ещё причины HTTP 499 на мобильной сети кроме таймаутов?
|
||||
2. Достаточна ли стратегия ретраев? Может, нужен exponential backoff?
|
||||
3. Может ли проблема быть в отправке тела запроса (write timeout) на медленной сети?
|
||||
4. Стоит ли разбивать upload на чанки или сжать JSON?
|
||||
5. Корректно ли мы обрабатываем случай, когда сервер получил запрос но клиент упал — данные могут дублироваться?
|
||||
|
||||
---
|
||||
|
||||
## Вопрос 3. Архитектура: три модуля + Android пакеты
|
||||
|
||||
**Файлы**: `doc/architecture.md`, `web/app.py`, `api/routes.py`
|
||||
|
||||
Сервер разбит на `obd/`, `brain/`, `api/`. Android — на `elm/`, `server/`, `script/`, `db/`, `ui/`.
|
||||
|
||||
Вопросы:
|
||||
1. Чистые ли границы между модулями? Нет ли неявных зависимостей?
|
||||
2. `api/routes.py` делает `from brain.client import Diagnoser` внутри функций — это нормально или лучше на уровне модуля?
|
||||
3. Стоит ли вынести `config.yaml` из `api/` на уровень выше?
|
||||
4. `web/app.py` зависит от `api/routes.py` — это правильное направление?
|
||||
5. Какие модули можно было бы легко заменить (например, `brain/` на локальный LLM)?
|
||||
|
||||
---
|
||||
|
||||
## Вопрос 4. SQL схема: таблица sessions
|
||||
|
||||
**Файлы**: `api/db.py`
|
||||
|
||||
Таблица `sessions` — 30+ колонок (IP, телефон, ELM, авто, сессия, LLM). VIN — nullable.
|
||||
Также старые таблицы: `cars`, `diagnostic_tokens`, `llm_messages`, `ecu_parameters`, `dtc_codes`.
|
||||
|
||||
Вопросы:
|
||||
1. 30+ колонок в одной таблице — это нормально для SQLite или лучше разбить?
|
||||
2. raw_responses хранится как JSON TEXT — ок ли для SQLite?
|
||||
3. Индексы: по `created_at`, `vin`, `elm_mac`, `android_id` — достаточны?
|
||||
4. Старые таблицы (cars, dtc_codes) всё ещё создаются в `_init_schema()` но не используются. Удалять или оставить для совместимости?
|
||||
5. Нет ли проблем с конкурентным доступом к SQLite из gunicorn (4 воркера)?
|
||||
|
||||
---
|
||||
|
||||
## Вопрос 5. LLM-интеграция: промпты и таймауты
|
||||
|
||||
**Файлы**: `brain/client.py`, `brain/prompts.py`, `api/routes.py`
|
||||
|
||||
- Diagnoser использует `requests.post` без streaming
|
||||
- SYSTEM_PROMPT — 10 правил ответа
|
||||
- Для /chat — лимит 20 строк, история диалога (последние 10 сообщений)
|
||||
|
||||
Вопросы:
|
||||
1. Достаточен ли промпт для качественной диагностики? Чего не хватает?
|
||||
2. `requests.post` без streaming при таймауте 180с — ок или лучше streaming + heartbeat?
|
||||
3. Для /chat: правильно ли форматируется история диалога? Не переполнит ли контекст?
|
||||
4. Модель gpt-oss-120b — адекватный выбор? Какие альтернативы для авто-диагностики?
|
||||
5. Как правильно обрабатывать ошибки LLM API (rate limit, timeout, bad response)?
|
||||
|
||||
---
|
||||
|
||||
## Вопрос 6. Безопасность API
|
||||
|
||||
**Файлы**: `api/routes.py`, `web/app.py`
|
||||
|
||||
- API без аутентификации, только HTTPS через nginx
|
||||
- API ключ LLM на сервере, не в APK
|
||||
- `usesCleartextTraffic` убран из манифеста
|
||||
|
||||
Вопросы:
|
||||
1. Достаточен ли HTTPS без API-ключей для MVP? Какие риски?
|
||||
2. Какие минимальные меры добавить: rate limiting, API key в APK, CORS?
|
||||
3. `raw_responses` пишутся в БД — есть ли риск инъекции через ответы ELM327?
|
||||
4. `/api/v1/chat` без аутентификации — можно ли его абузить (спамить токенами)?
|
||||
5. Нужно ли скрывать API-ключ LLM за прокси или текущая схема ок?
|
||||
|
||||
---
|
||||
|
||||
## Формат ответа
|
||||
|
||||
Пожалуйста, запиши ответ в файл `/home/naeel/elmer/doc/opus-review.md`.
|
||||
|
||||
По каждому вопросу:
|
||||
- 🔴 Критическая проблема (если есть)
|
||||
- 🟡 Потенциальная проблема / улучшение
|
||||
- 🟢 Всё ок
|
||||
- Конкретные рекомендации с примерами кода где уместно
|
||||
@@ -0,0 +1,280 @@
|
||||
# Ответ Opus 4.8 — ревью elmer-android
|
||||
|
||||
> v0.35.0-dev, 31 мая 2026
|
||||
> Проверены файлы: ElmProtocol.kt, ObdDecoder.kt, ScriptRunnerService.kt, ScriptEngine.kt,
|
||||
> ServerClient.kt, SessionDb.kt, MainActivity.kt, UploadProgress.kt, AndroidManifest.xml
|
||||
|
||||
---
|
||||
|
||||
## Вопрос 1. Стейт-машина ElmProtocol.kt
|
||||
|
||||
### 1.1 `startsWith("ERROR") && !startsWith("DATA ERROR")`
|
||||
🟡 **Потенциальная проблема.** `handle()` работает с уже распарсенными строками-ответами, а не с PID-именами, поэтому коллизии с «ERROR_xxx» в данных нет — декодирование имён происходит позже в `ObdDecoder`. НО: реальные ELM-ошибки не всегда начинаются с `ERROR`. Например `?` (неизвестная команда), `UNABLE TO CONNECT` (ловится в `isBusError`), `<RX ERROR` (с префиксом `<`). Строка `<DATA ERROR` из-за лидирующего `<` **не** сматчится `startsWith("DATA ERROR")`. ELM327 при ошибке кадра иногда шлёт `<` перед сообщением.
|
||||
|
||||
Рекомендация — нормализовать перед классификацией:
|
||||
```kotlin
|
||||
val u = raw.uppercase().trim().trimStart('<', '>').trim()
|
||||
```
|
||||
|
||||
### 1.2 `sendCommand()` безусловно ставит READY после exec()
|
||||
🔴 **Критично — маскирование ошибки.**
|
||||
```kotlin
|
||||
fun sendCommand(cmd: String): String {
|
||||
if (state == State.ERROR) recover()
|
||||
state = State.BUSY
|
||||
val result = exec(cmd, timeoutMs) // exec может выставить State.ERROR/DISCONNECTED
|
||||
state = State.READY // ← затирает ошибку
|
||||
return result
|
||||
}
|
||||
```
|
||||
`exec()` при исчерпании ретраев ставит `state = State.ERROR`, а `handle()` — `ERROR`/`DISCONNECTED`. Следующая строка безусловно перетирает это на `READY`. Ошибка «теряется» до следующего вызова. В AndrOBD состояние не сбрасывается слепо.
|
||||
|
||||
Рекомендация:
|
||||
```kotlin
|
||||
val result = exec(cmd, timeoutMs)
|
||||
if (state == State.BUSY) state = State.READY // только если не было ошибки
|
||||
return result
|
||||
```
|
||||
|
||||
### 1.3 `init()` не проверяет результат AT-команд
|
||||
🟡 **Поведение AndrOBD, но рискованное.** AndrOBD действительно прогоняет init-цепочку «оптимистично», полагаясь на то, что первые реальные OBD-команды отловят BUS ERROR. Для MVP допустимо, но `ATSP0` (выбор протокола) стоит проверять — если адаптер вернул `?`, дальнейшие команды бессмысленны. Минимум — логировать ответ и считать в `errorCount`.
|
||||
|
||||
### 1.4 Нет сброса input-буфера перед write()
|
||||
🟡 **Риск десинхронизации есть.** В `read()` чтение идёт до `>` (prompt), но если предыдущая команда оставила хвост в буфере (например после таймаута пришёл запоздалый ответ), он прилипнет к следующему чтению. Рекомендация — дренировать буфер перед записью:
|
||||
```kotlin
|
||||
private fun write(cmd: String) {
|
||||
while (input.available() > 0) input.read() // drain stale bytes
|
||||
output.write((cmd + "\r").toByteArray())
|
||||
output.flush()
|
||||
}
|
||||
```
|
||||
|
||||
### 1.5 `BUFFER FULL` → warm start
|
||||
🟡 **Спорно.** В AndrOBD `BUFFER FULL` — это переполнение буфера ELM при большом ответе, лечится **повторным запросом**, а не полным `ATWS` (warm start сбрасывает протокол и теряет адаптацию таймингов). Здесь `BUFFER FULL` попадает в `isDataError` → `ATWS`, что излишне тяжело. Лучше выделить:
|
||||
```kotlin
|
||||
u.startsWith("BUFFER FULL") -> { increaseTimeout() /* retry */ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Вопрос 2. ScriptRunnerService — жизненный цикл
|
||||
|
||||
### 2.1 `START_STICKY` + null intent
|
||||
🔴 **Падение при пересоздании.** При рестарте системой `onStartCommand` получает `intent == null`. Сейчас `when (intent?.action)` отрабатывает в `else`-ветку (ничего не делает) и возвращает `START_STICKY` — краша нет, но сервис висит в foreground без работы и без уведомления о реальной задаче. Лучше:
|
||||
```kotlin
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent == null) { stopSelf(); return START_NOT_STICKY }
|
||||
...
|
||||
}
|
||||
```
|
||||
Для разовой диагностики вообще логичнее `START_NOT_STICKY` — нет смысла воскрешать прерванную сессию.
|
||||
|
||||
### 2.2 Демон-поток `ScriptRunner`
|
||||
🟡 Демон-поток живёт пока жив процесс. Если Activity убита, а сервис foreground — процесс жив, поток работает. Но при нехватке памяти система может убить весь процесс (вместе с потоком) несмотря на foreground. Это нормально для разовой задачи. Замечание: исключения внутри потока никуда не пробрасываются — добавьте `try/catch` обёртку с `errorDone()`.
|
||||
|
||||
### 2.3 / 2.4 `btSocket` и `onDestroy()`
|
||||
🟢 **Уже закрывается.** `onDestroy()` вызывает `disconnect()`, который закрывает `btSocket` и снимает foreground. Утечки сокета нет. ✅
|
||||
|
||||
### 2.5 Флаг `paused`
|
||||
🔴 **Мёртвый код / недоделанная фича.** `paused` выставляется в `false` по `ACTION_RESUME`, но **нигде не проверяется** — ни в `ScriptEngine.run()`, ни в `executeScript()`. Механизм паузы «водитель ответил» (broadcast `BROADCAST_PROMPT`, `scriptPromptReceiver` в UI) фактически не реализован на стороне движка. Либо удалить флаг и UI-приёмник промптов, либо доделать: `ScriptEngine` должен уметь блокироваться на шаге до сброса `paused`.
|
||||
|
||||
---
|
||||
|
||||
## Вопрос 3. ServerClient — ретраи и идемпотентность
|
||||
|
||||
### 3.1 Повторное использование тела запроса на ретраях
|
||||
🟢 **Работает корректно.** Тело создано через `String.toRequestBody(...)` — это `RequestBody` поверх неизменяемой строки. В OkHttp 4.x такой `RequestBody` **stateless**: `writeTo()` вызывается заново на каждой попытке и пишет ту же строку. Пустого тела на 2-3 ретрае **не будет**. (Проблема была бы только с одноразовым стримом, например `InputStream.source()`.)
|
||||
|
||||
### 3.2 Exponential backoff
|
||||
🟡 Для мобильной сети фиксированные 2с приемлемы, но джиттер + рост лучше против «retry storm»:
|
||||
```kotlin
|
||||
if (attempt < 3) Thread.sleep(1000L * (1 shl (attempt - 1)) + Random.nextLong(0, 500))
|
||||
```
|
||||
|
||||
### 3.3 `downloadScript()` без ретраев
|
||||
🟢 Это сознательный и правильный выбор: есть качественный `DEFAULT_SCRIPT` fallback, поэтому мгновенный переход к нему при оффлайне — корректное поведение. 1 ретрай можно добавить, но не критично.
|
||||
|
||||
### 3.4 gzip на upload
|
||||
🟡 При `count * 200` байт типичный батч < 5 KB — выигрыш от gzip минимален, а overhead на сжатие/совместимость с nginx добавляет риск. Не нужно для MVP.
|
||||
|
||||
### 3.5 Порядок `.string()` / `.close()`
|
||||
🟢 **Корректно.** `val body = resp.body?.string()` сначала читает (и закрывает поток тела), затем `resp.close()`. Порядок верный, двойного закрытия нет. ✅
|
||||
|
||||
### 3.6 Идемпотентность / `request_id`
|
||||
🔴 **Критично (подтверждаю отчёт Q2).** При 499/таймауте и ретрае сервер создаёт дубликат сессии и повторно тратит LLM-токен. Клиент должен генерировать UUID **один раз до цикла ретраев** и слать его в теле:
|
||||
|
||||
```kotlin
|
||||
fun uploadSession(...): JSONObject? {
|
||||
val requestId = java.util.UUID.randomUUID().toString() // один на все 3 попытки
|
||||
val json = JSONObject().apply {
|
||||
put("request_id", requestId)
|
||||
put("session_id", sessionId)
|
||||
...
|
||||
}
|
||||
val req = Request.Builder()
|
||||
.url("$serverUrl/api/v1/session/upload")
|
||||
.header("Idempotency-Key", requestId)
|
||||
.post(json.toString().toRequestBody("application/json".toMediaType()))
|
||||
.build()
|
||||
...
|
||||
}
|
||||
```
|
||||
На сервере (`save_session()`): UNIQUE-индекс по `request_id`, при повторе — вернуть **сохранённый** результат (включая готовый диагноз), не вызывая LLM повторно:
|
||||
```python
|
||||
existing = db.execute("SELECT diagnosis FROM sessions WHERE request_id=?", [rid]).fetchone()
|
||||
if existing:
|
||||
return jsonify(diagnosis=existing["diagnosis"], llm_success=True, cached=True)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Вопрос 4. ObdDecoder — корректность декодирования
|
||||
|
||||
### 4.1 VIN с пробелами
|
||||
🟢 **Корректно.** `replace(" ", "")` снимает пробелы до проверки `"490201" in clean`, плюс убраны `:` (ISO-TP индикаторы кадров `0:`, `1:`...). Работает и для multi-frame. ✅
|
||||
|
||||
### 4.2 `decodeDtc()` начинает с `i = 2`
|
||||
🟡 **Не всегда верно.** `hex = clean.substring(2)` снимает байт режима (`43`), затем `i = 2` снимает **байт count** (число DTC). Это корректно для классического формата `43 NN <dtc>...`. Но:
|
||||
- Multi-frame CAN ISO-TP: ответ может содержать байты длины PCI (`007`, `10 0E`...), которые здесь **не вычищены** (убраны только пробелы и `:`). Тогда `i=2` указывает не на тот байт.
|
||||
- Некоторые адаптеры на mode 03 не шлют байт count вовсе.
|
||||
|
||||
Для надёжности стоит парсить DTC по парам байт от конца режима и отбрасывать `0000`, что код уже делает (фильтр `P0000`). Главный риск — невычищенные PCI-заголовки multi-frame. Для коротких ответов (1-2 DTC, single frame) работает.
|
||||
|
||||
### 4.3 PID `0100` (4 байта supported)
|
||||
🟡 `decodePid()` читает только `b0, b1`. PID `00/20/40...` (битовые маски supported PIDs, 4 байта) не входят в `pidValue()` → вернётся `"PID 00: raw"`. Поскольку скрипт их не запрашивает — не баг сейчас, но при расширении скрипта декодер их не покажет.
|
||||
|
||||
### 4.4 Только 10 PID
|
||||
🟢 **Ок для MVP.** Скрипт `DEFAULT_SCRIPT` запрашивает ровно эти PID. Для неподдерживаемых — `"PID $pid: raw"`, сырьё всё равно уходит на сервер и в LLM. Расширять по мере надобности.
|
||||
|
||||
### 4.5 STFT/LTFT формула
|
||||
🟢 Формула `(A - 128) * 100 / 128` верна по SAE J1979. Для PID 06/07 это однобайтовые значения (банк 1), `b1` игнорируется правильно. ✅ (Замечание: PID 06/07 — это банк 1 short/long; банки 2 — это 08/09, в скрипте их нет.)
|
||||
|
||||
---
|
||||
|
||||
## Вопрос 5. SessionDb — схема и доступ
|
||||
|
||||
### 5.1 `onUpgrade()` DROP TABLE
|
||||
🔴 **Потеря данных при апдейте.** Любое повышение `DB_VERSION` сотрёт всю историю пользователя. Для продакшена недопустимо. Минимальная безопасная миграция:
|
||||
```kotlin
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldV: Int, newV: Int) {
|
||||
if (oldV < 2) db.execSQL("ALTER TABLE sessions ADD COLUMN server_url TEXT")
|
||||
// будущие версии — ALTER, не DROP
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Без явного закрытия соединений
|
||||
🟢 **Ок.** `SQLiteOpenHelper` кэширует одно соединение на хелпер; курсоры закрываются (`cursor.close()`). Не закрывать сам `db` — правильно. ⚠️ Замечание: `SessionDb` создаётся и в сервисе, и в `MainActivity.showHistory()` — два хелпера на одну БД. Лучше один экземпляр (синглтон), иначе при одновременном write возможен `SQLiteDatabaseLockedException`.
|
||||
|
||||
### 5.3 `getPendingSessions()` — мёртвый код
|
||||
🟡 Метод нигде не вызывается. Это задел под «дослать неотправленные сессии при следующем запуске», но фича не реализована. Либо удалить, либо доделать ретрай-аплоад оффлайн-сессий в `onCreate` сервиса.
|
||||
|
||||
### 5.4 `created_at` INTEGER vs сервер TEXT
|
||||
🟡 Несогласованность форматов. На клиенте unix-секунды, на сервере ISO 8601. При синхронизации сервер должен конвертировать. Лучше слать с клиента ISO-8601 (или явно `unix_ts` с понятным именем) в `client_info`/`responses`, чтобы не было путаницы с часовыми поясами. Сейчас `timestamp` ответов уходит как строка unix-секунд — сервер должен это знать.
|
||||
|
||||
### 5.5 Индексы
|
||||
🟡 `WHERE session_id = ?` в `getResponses()` без индекса — full scan. При сотнях ответов на сессию заметно. Добавьте:
|
||||
```kotlin
|
||||
db.execSQL("CREATE INDEX idx_resp_session ON responses(session_id)")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Вопрос 6. MainActivity — чат и UI
|
||||
|
||||
### 6.1 `chatHistory` теряется при повороте
|
||||
🔴 `onSaveInstanceState` сохраняет только `status_text`, `chatHistory` живёт в поле Activity → при повороте/пересоздании теряется, и LLM теряет контекст диалога. Варианты: сохранить в Bundle (сериализовать в JSON), либо вынести в `ViewModel` (`SavedStateHandle`). Минимум:
|
||||
```kotlin
|
||||
outState.putString("chat", JSONArray(chatHistory.map { ... }).toString())
|
||||
```
|
||||
|
||||
### 6.2 Чат на `HttpURLConnection` вместо OkHttp
|
||||
🟡 Дублирование HTTP-логики и таймаутов. `ServerClient` уже инкапсулирует OkHttp — `sendToLlm()` и `startTest()` стоит перевести на него (общие таймауты, ретраи, будущий `X-Api-Key`). Сейчас три места шлют HTTP по-разному.
|
||||
|
||||
### 6.3 `startTest()` дёргает `/ping-llm` (платный токен)
|
||||
🔴 **Расход денег на каждом «Тест».** `/ping-llm` делает реальный LLM-запрос. Кнопку «Тест» пользователь может жать многократно. Варианты: на сервере сделать `/ping-llm` дешёвой проверкой доступности (HEAD к API провайдера / кэш на 60с), либо на клиенте троттлить (не чаще раза в N минут) и предупреждать.
|
||||
|
||||
### 6.4 Двойная регистрация receiver
|
||||
🟡 `scriptStatusReceiver`/`scriptStageReceiver` защищены флагом `scriptRegistered` — двойной регистрации этих двух нет. НО: `statusReceiver` (отдельный, для `BROADCAST_STATUS`) регистрируется в `onCreate` **и** `scriptStatusReceiver` тоже слушает `BROADCAST_STATUS` — два приёмника на один экшен → **каждое сообщение `log()` обработается дважды** (дублирование строк в UI). Также `scriptPromptReceiver` регистрируется... — на самом деле **нигде не регистрируется**, только разрегистрируется в `onDestroy`. Промпты не приходят (связано с мёртвым `paused`, Q2.5).
|
||||
|
||||
Рекомендация: оставить один приёмник на `BROADCAST_STATUS`.
|
||||
|
||||
### 6.5 `btnClose` не чистит `chatHistory` и не стопит сервис
|
||||
🟡 Кнопка ✕ только прячет UI и пишет «Готов». Если сервис ещё работает — он продолжит и пришлёт новые статусы поверх. Для «закрыть» логично слать `ACTION_STOP` в сервис. `chatHistory` чистить не обязательно (диалог отдельный от диагностики), но сервис стоит остановить.
|
||||
|
||||
---
|
||||
|
||||
## Вопрос 7. UploadProgress — таймер и батарея
|
||||
|
||||
### 7.1 Broadcast каждую секунду до 180с
|
||||
🟡 Незначительно для батареи (≤180 broadcast на сессию), но это локальный `sendBroadcast` с `setPackage` — дёшево. Не проблема.
|
||||
|
||||
### 7.2 Поток висит при исключении
|
||||
🟡 **Реальный риск.** В `executeScript()` `progress.start()` → `uploadSession()` → `progress.stop()`. Если `uploadSession()` бросит непойманное исключение, `stop()` не вызовется и `UploadTimer` останется крутиться (демон, до смерти процесса). Оберните в `try/finally`:
|
||||
```kotlin
|
||||
val progress = UploadProgress(...); progress.start()
|
||||
val resp = try { client.uploadSession(...) } finally { progress.stop() }
|
||||
```
|
||||
|
||||
### 7.3 `Handler.postDelayed` вместо потока
|
||||
🟢 Можно, но текущий вариант с `AtomicBoolean` + демон-поток корректен и проще. Не критично. Главное — гарантировать `stop()` (см. 7.2).
|
||||
|
||||
---
|
||||
|
||||
## Вопрос 8. Общая архитектура
|
||||
|
||||
### 8.1 MainActivity знает про Service и SessionDb
|
||||
🟡 Нарушение SRP есть, но для MVP с одним экраном терпимо. При росте — вынести историю в `Repository`, а UI-логику в `ViewModel`.
|
||||
|
||||
### 8.2 ElmProtocol замокать для тестов
|
||||
🟡 `ScriptEngine` отлично тестируется (lambdas) — это сильная сторона. `ElmProtocol` жёстко завязан на `InputStream/OutputStream`, но это **тестируемо**: подайте `ByteArrayInputStream`/`ByteArrayOutputStream` с заскриптованными ответами ELM. Интерфейс выделять не нужно, потоки — уже абстракция. Рекомендую написать unit-тест на `handle()`-классификацию и таймаут-адаптацию.
|
||||
|
||||
### 8.3 Нет ViewModel/DI/Navigation
|
||||
🟢 Для MVP с одной кнопкой — ок. ViewModel стоит ввести первым (решает 6.1, 6.4). DI/Navigation — преждевременно.
|
||||
|
||||
### 8.4 minSdk 24 + BluetoothAdapter.getDefaultAdapter
|
||||
🟡 `getDefaultAdapter()` deprecated с API 31, но работает на 24+. `createRfcommSocketToServiceRecord` + reflection-fallback `createRfcommSocket(1)` — стандартный надёжный приём для китайских ELM327, покрывает большинство устройств. Замечание: на Android 12+ (API 31) для `connect()` нужен рантайм-`BLUETOOTH_CONNECT` — в манифесте он есть, проверьте что он реально запрашивается в рантайме (в показанном коде `MainActivity` запрос пермишенов есть в константах, но самого `requestPermissions` в прочитанном фрагменте не видно — убедитесь, что вызывается).
|
||||
|
||||
### 8.5 `usesCleartextTraffic` не объявлен
|
||||
🟢 По умолчанию `false` на API 28+, все запросы на `https://obdai.ru` — ок. ✅ Замечание: жёстко зашитый хост `obdai.ru` в нескольких местах (Service, MainActivity) — вынесите в `BuildConfig`/константу.
|
||||
|
||||
### 8.6 Эндпоинты без аутентификации (X-Api-Key)
|
||||
🔴 **Критично (подтверждаю отчёт Q6).** `/chat`, `/upload`, `/ping-llm` открыты → любой может тратить ваши LLM-токены.
|
||||
|
||||
Статический ключ в APK **извлекаем** (reverse engineering), поэтому он защищает только от случайных/ленивых злоупотреблений, не от целевой атаки. Для MVP это разумный первый рубеж:
|
||||
|
||||
```kotlin
|
||||
// BuildConfig.API_KEY из gradle (не в git, через local.properties / CI secret)
|
||||
val req = Request.Builder()
|
||||
.url(...)
|
||||
.header("X-Api-Key", BuildConfig.API_KEY)
|
||||
.post(...)
|
||||
.build()
|
||||
```
|
||||
build.gradle.kts:
|
||||
```kotlin
|
||||
buildConfigField("String", "API_KEY", "\"${project.findProperty("ELMER_API_KEY") ?: ""}\"")
|
||||
```
|
||||
Сервер — отклонять без верного `X-Api-Key` (401) + **rate-limit по IP/ключу** + квота на LLM. Для серьёзной защиты позже: подпись запроса (HMAC от тела + nonce + timestamp), либо Play Integrity API / device attestation. Но для MVP: `X-Api-Key` + rate-limit + серверная квота на LLM — достаточный минимум, при этом главную защиту денег даёт именно **серверный лимит**, а не ключ.
|
||||
|
||||
---
|
||||
|
||||
## Сводка приоритетов
|
||||
|
||||
🔴 **Чинить сейчас:**
|
||||
1. `request_id`/идемпотентность upload (Q3.6) — дубли сессий и двойной расход LLM.
|
||||
2. `X-Api-Key` + серверный rate-limit/квота (Q8.6) — открытые платные эндпоинты.
|
||||
3. `sendCommand()` маскирует ERROR-состояние (Q1.2).
|
||||
4. `onUpgrade()` DROP TABLE — потеря истории (Q5.1).
|
||||
5. `/ping-llm` тратит токен на каждом «Тест» (Q6.3).
|
||||
6. Двойной приёмник `BROADCAST_STATUS` → дублирование строк (Q6.4).
|
||||
|
||||
🟡 **Желательно:**
|
||||
- `paused` — мёртвый код / недоделанная пауза (Q2.5, Q6.4-prompt).
|
||||
- `try/finally` вокруг `UploadProgress` (Q7.2).
|
||||
- Дренаж BT-буфера перед write (Q1.4).
|
||||
- `chatHistory` в onSaveInstanceState/ViewModel (Q6.1).
|
||||
- Индекс `responses(session_id)` (Q5.5).
|
||||
- Единый HTTP-клиент (OkHttp) для чата/теста (Q6.2).
|
||||
- null-intent guard в onStartCommand (Q2.1).
|
||||
|
||||
🟢 **Хорошо как есть:** закрытие сокета (2.3), повторное тело OkHttp (3.1), порядок string/close (3.5), VIN-декод (4.1), STFT/LTFT (4.5), cleartext off (8.5), fallback-скрипт без ретраев (3.3).
|
||||
@@ -0,0 +1,236 @@
|
||||
# Ревью проекта elmAI (ответы на opus-questions.md)
|
||||
|
||||
> Ревьювер: Opus 4.8 · 31 мая 2026 · v0.35.0-dev
|
||||
> Разбор по коду: `obd/protocol.py`, `brain/client.py`, `brain/prompts.py`, `api/routes.py`, `api/db.py`, `api/parser.py`, `web/app.py`.
|
||||
> Android-модуль (`elmer-android/`) в workspace отсутствует — по нему выводы на основе описаний в вопросах.
|
||||
|
||||
---
|
||||
|
||||
## Вопрос 1. Стейт-машина ELM327: баги и крайние случаи
|
||||
|
||||
### 🔴 Стартовый буфер не сбрасывается перед командой → десинхронизация
|
||||
В `_exec()` сразу идёт `_write(cmd)` без очистки входного буфера. Если предыдущая команда отвалилась по таймауту, в ОС-буфере остаются «хвосты» (часть ответа, поздний `>`). Следующий `_read()` прочитает этот мусор как ответ на новую команду и классифицирует его неверно — классическая рассинхронизация ELM327.
|
||||
|
||||
```python
|
||||
def _write(self, cmd: str):
|
||||
self._ser.reset_input_buffer() # сбросить хвосты предыдущего ответа
|
||||
self._ser.write((cmd + "\r").encode())
|
||||
self._ser.flush()
|
||||
logger.debug(f"AndrOBD → {cmd}")
|
||||
```
|
||||
|
||||
### 🔴 Состояние `DISCONNECTED`/`ERROR` затирается в `send()`
|
||||
`send()` проверяет только `State.ERROR` перед `_recover()`. Но BUS ERROR в `_handle()` ставит `DISCONNECTED`, а в конце `send()` безусловно пишет `self._state = State.READY`. То есть после фатальной ошибки шины машина всё равно объявляется READY, и накопленный сбой маскируется.
|
||||
|
||||
```python
|
||||
def send(self, cmd: str) -> str:
|
||||
if self._state in (State.ERROR, State.DISCONNECTED):
|
||||
self._recover()
|
||||
self._state = State.BUSY
|
||||
result = self._exec(cmd, self._timing.ms)
|
||||
# НЕ ставить READY безусловно — _exec мог уйти в ERROR
|
||||
if self._state == State.BUSY:
|
||||
self._state = State.READY
|
||||
return result
|
||||
```
|
||||
|
||||
### 🟡 `>` посреди мусора (вопрос 1.2)
|
||||
`_read()` возвращает всё накопленное до первого `>`. Если ELM прислал `SEARCHING...` затем данные затем `>`, всё склеится в одну строку через `\n`, а `Rsp.identify()` смотрит только на начало (`startswith`) — реальные данные после `SEARCHING` будут потеряны/неверно классифицированы. AndrOBD обрабатывает каждую строку отдельно. Рекомендация: классифицировать построчно, а не всю склейку.
|
||||
|
||||
### 🟢 Бесконечный цикл в `_exec()` (вопрос 1.5)
|
||||
Цикл жёстко ограничен `range(10)`, по выходу — `State.ERROR` и `return ""`. Бесконечного цикла нет. Но обратите внимание: при инициализации шаг `t += 1000` за 10 итераций даёт суммарно до ~55с ожидания на одну команду — для `INIT_TMO=10000` это может неприятно затянуть `init()`.
|
||||
|
||||
### 🟡 Восстановление после BUS ERROR (вопрос 1.3)
|
||||
Логика `ATPC → ATSP0` корректна по сути, но ответы на них читаются `_try_read()` и **молча выбрасываются**. Если `ATSP0` не подтвердился (ELM завис), машина об этом не узнает и пойдёт слать команды в неинициализированный протокол. Желательно проверять, что на `ATSP0` пришёл `OK`/`>`, иначе — полный reset (`ATZ`).
|
||||
|
||||
### 🟡 Поллинг 1мс (вопрос 1.4)
|
||||
1мс `time.sleep` в Python реально даёт ~1–15мс из-за гранулярности планировщика — на практике это не вредит (ELM медленнее), но и «честных» 1мс там нет. На быстрых ELM327 v1.5/v2.1 это не узкое место; узкое место — таймаут адаптива, а не поллинг. Менять не нужно.
|
||||
|
||||
### Race conditions
|
||||
В Python-версии всё однопоточное — гонок нет, **пока** один экземпляр `AndrOBD` не шарится между потоками. Если планируется параллельный доступ — добавьте `threading.Lock` вокруг `send()`.
|
||||
|
||||
---
|
||||
|
||||
## Вопрос 2. HTTP 499 при upload с мобильной сети
|
||||
|
||||
### 🔴 Нет идемпотентности → дубликаты при ретрае (вопрос 2.5)
|
||||
Это главная проблема. Сценарий 499: сервер **уже принял и обработал** запрос (LLM-анализ 30–120с), но клиент отвалился по read timeout и шлёт ретрай. Результат — вторая полная LLM-сессия и **вторая запись в `sessions`**. `upload_session()` не имеет ключа идемпотентности.
|
||||
|
||||
Решение — клиент генерирует `request_id` (UUID), сервер кэширует результат:
|
||||
```python
|
||||
data = request.get_json(silent=True)
|
||||
req_id = data.get("request_id")
|
||||
if req_id:
|
||||
cached = db.get_session_by_request_id(req_id) # + колонка request_id UNIQUE
|
||||
if cached:
|
||||
return jsonify(cached["response_json"]), 200
|
||||
```
|
||||
|
||||
### 🟡 Стратегия ретраев — нужен backoff и идемпотентность
|
||||
3 ретрая с фиксированной задержкой 2с на мобильной сети мало помогают: если причина — долгий LLM-ответ (>read timeout 180с), то все 3 попытки упрутся в тот же таймаут и каждая запустит новый LLM-прогон. Рекомендация: exponential backoff (2/4/8с + jitter) **и** обязательно идемпотентность (см. выше), иначе ретраи только множат нагрузку.
|
||||
|
||||
### 🟡 Корень 499 — рассинхрон таймаутов клиент/сервер (вопрос 2.1)
|
||||
Клиентский read 180с ≈ gunicorn timeout 180с. При длинном ответе LLM (`Diagnoser.timeout=120`, но сам upload может суммарно дольше) клиент рвёт соединение ровно в момент, когда сервер ещё пишет ответ. Прочие частые причины 499 на мобильной: смена сети Wi-Fi↔LTE (новый IP, старый сокет мёртв), NAT-таймаут оператора (часто 30–60с тишины), Doze/засыпание приложения. Рекомендация: клиентский read timeout должен быть **строго больше** серверного (например, 240с против gunicorn 180с), а сервер — отвечать быстрее (streaming, см. ниже).
|
||||
|
||||
### 🟡 Write timeout на медленной сети (вопрос 2.3)
|
||||
Да, при толстом батче (`raw_responses` целиком) и слабом upload на LTE write timeout 60с реально достижим. Тело JSON со всеми сырыми ответами может быть десятки–сотни КБ.
|
||||
|
||||
### 🟡 Чанки/сжатие (вопрос 2.4)
|
||||
Чанкинг избыточен для типичного объёма, а вот **gzip тела** даст быстрый выигрыш (JSON сжимается в 5–10 раз) и снимет риск write timeout:
|
||||
```kotlin
|
||||
// OkHttp: добавить gzip-обёртку RequestBody + заголовок
|
||||
.header("Content-Encoding", "gzip")
|
||||
```
|
||||
Сервер: nginx сам разожмёт при наличии `gunzip`/decompression, либо Flask с `request.get_data()` + `gzip.decompress`. Это дешевле, чем переписывать на чанки.
|
||||
|
||||
### Главная архитектурная рекомендация
|
||||
Разделите «приём данных» и «LLM-анализ». Эндпоинт должен **быстро** (1–2с) принять батч, сохранить, вернуть `session_id`, а диагноз отдавать отдельным polling-эндпоинтом (`GET /api/v1/session/<id>/result`) или через streaming. Тогда 499 из-за долгого LLM исчезнет как класс.
|
||||
|
||||
---
|
||||
|
||||
## Вопрос 3. Архитектура: три модуля + Android пакеты
|
||||
|
||||
### 🟢 Границы модулей в целом чистые
|
||||
`obd/` ничего не знает про `brain/` и `api/`; `brain/` — изолированный LLM-клиент; `api/` оркестрирует. Направление зависимостей `web → api → brain/obd` корректное (вопрос 3.4 — да, правильное).
|
||||
|
||||
### 🟡 Импорты внутри функций (вопрос 3.2)
|
||||
В `routes.py` все `from brain.client import Diagnoser`, `from api.db import Database`, `from api.config import load` сделаны внутри обработчиков. Это не «нормально», а компромисс — обычно так лечат циклические импорты или ускоряют старт. Минусы: `load()` читает конфиг с диска **на каждый запрос**, импорт-резолвинг повторяется. Рекомендация: поднять импорты на уровень модуля, а конфиг закэшировать:
|
||||
```python
|
||||
# api/config.py
|
||||
from functools import lru_cache
|
||||
@lru_cache(maxsize=1)
|
||||
def load(): ...
|
||||
```
|
||||
Если поднятие импортов ломает цикл — это сигнал, что цикл надо разорвать явно, а не прятать.
|
||||
|
||||
### 🟡 `config.yaml` (вопрос 3.3)
|
||||
Конфиг сейчас грузится через `api/config.py`. Держать `config.yaml` в корне проекта (рядом с `web/app.py`) логичнее — он общий для `api/`, `brain/`, `obd/`, а не принадлежит только `api/`. Вынесите на верхний уровень, путь резолвьте от корня.
|
||||
|
||||
### 🟢 Заменяемость модулей (вопрос 3.5)
|
||||
`brain/` заменяется на локальный LLM тривиально — он зависит только от OpenAI-совместимого HTTP (`/chat/completions`). Достаточно сменить `base_url`/`model` в конфиге; код менять не нужно. `obd/` тоже изолирован. Это хороший знак для дизайна.
|
||||
|
||||
---
|
||||
|
||||
## Вопрос 4. SQL-схема: таблица sessions
|
||||
|
||||
### 🔴 Утечка соединений + конкурентный доступ (вопрос 4.5)
|
||||
`Database()` создаётся в каждом запросе, открывает `sqlite3.connect(...)` и **никогда не закрывается** — connection leak. При 4 gunicorn-воркерах одновременные записи в один файл дают `database is locked` (SQLite по умолчанию: 1 писатель, нет ожидания). Минимум:
|
||||
```python
|
||||
self.conn = sqlite3.connect(str(self.path), timeout=30, check_same_thread=False)
|
||||
self.conn.execute("PRAGMA journal_mode=WAL") # параллельные читатели + 1 писатель
|
||||
self.conn.execute("PRAGMA busy_timeout=30000")
|
||||
```
|
||||
И закрывать соединение (контекстный менеджер / `try/finally` / `db.close()`), либо держать один пул на воркер. WAL критичен для multi-worker.
|
||||
|
||||
### 🟡 30+ колонок в одной таблице (вопрос 4.1)
|
||||
Для SQLite это **нормально** (лимит 2000 колонок), денормализация под аналитику оправдана. Но смешаны три логических домена: телефон, ELM, LLM. Это не баг, а запах. Пока таблица аналитическая (одна запись = одна сессия) — оставьте; если начнёте часто менять набор полей телефона/ELM — выносите в отдельные таблицы или JSON-колонку.
|
||||
|
||||
### 🟢 raw_responses как JSON TEXT (вопрос 4.2)
|
||||
Ок для SQLite. При необходимости запросов внутрь — используйте `json_extract()` (есть в SQLite ≥3.38). Менять не нужно.
|
||||
|
||||
### 🟡 Индексы (вопрос 4.3)
|
||||
`created_at`, `vin`, `elm_mac`, `android_id` — разумный набор. Но `vin` nullable и часто NULL — индекс будет «разреженным», это норм. Добавьте составной `(android_id, created_at)` если будете строить историю по устройству — иначе текущих достаточно.
|
||||
|
||||
### 🟡 Мёртвые таблицы (вопрос 4.4)
|
||||
`cars`, `diagnostic_tokens`, `llm_messages`, `ecu_parameters`, `dtc_codes` создаются в `_init_schema()`, имеют методы-обёртки в `db.py`, но в текущем пути `upload`/`chat` **не используются**. Это «второй контур», который вводит в заблуждение (например, история диалога в `/chat` идёт из клиента, а не из `llm_messages`). Решение: либо подключите их (тогда `/chat` сможет хранить историю на сервере по VIN), либо удалите вместе с методами. Сейчас они — технический долг и риск рассинхрона схемы.
|
||||
|
||||
### 🟢 Инъекции
|
||||
Все запросы параметризованы (`?`), SQL-инъекций нет.
|
||||
|
||||
---
|
||||
|
||||
## Вопрос 5. LLM-интеграция: промпты и таймауты
|
||||
|
||||
### 🔴 Рассинхрон модели и таймаута в коде
|
||||
- `brain/client.py`: `DEFAULT_MODEL = "gpt-oss-20b"`, а конфиг/доки — `gpt-oss-120b`. Дефолт-fallback тихо подменит модель, если конфиг недокинул `model`.
|
||||
- `Diagnoser.ask(... timeout=120)`, но в вопросе и nginx/gunicorn заявлено 180с. Таймаут захардкожен и не берётся из конфига.
|
||||
- Докстринги и комментарии говорят «DeepSeek», хотя API — `api.aillm.ru` / gpt-oss. Чисто косметика, но путает.
|
||||
|
||||
```python
|
||||
def __init__(self, api_key, model="gpt-oss-120b", base_url=DEFAULT_BASE, timeout=180):
|
||||
...
|
||||
self.timeout = timeout
|
||||
def ask(self, messages):
|
||||
resp = requests.post(..., timeout=self.timeout)
|
||||
```
|
||||
|
||||
### 🟡 Нет streaming + heartbeat (вопрос 5.2)
|
||||
`requests.post` без `stream=True` на 120–180с — это «чёрный ящик»: клиент не видит прогресса и рвёт по таймауту (см. Вопрос 2). Для длинной генерации лучше streaming (SSE) с проксированием токенов клиенту — тогда соединение «живое», NAT не закрывает, 499 пропадает. Минимум — heartbeat-байты каждые N секунд.
|
||||
|
||||
### 🟡 История диалога в /chat (вопрос 5.3)
|
||||
История склеивается в **один user-prompt** строкой («Водитель: …/Автоэксперт: …»), а не передаётся как полноценный массив `messages` с ролями. Модель хуже держит контекст, и при длинной истории (даже срезанной до 10) промпт может раздуться. Лучше передавать историю настоящими `role: user/assistant` сообщениями (метод `diagnose` это уже умеет через `history`!) и считать токены, а не сообщения:
|
||||
```python
|
||||
hist_msgs = [{"role": m["role"], "content": m["content"]} for m in history[-10:]]
|
||||
answer = diagnoser.diagnose(SYSTEM_CHAT, question, history=hist_msgs)
|
||||
```
|
||||
Переполнения контекста сейчас никто не контролирует — добавьте бюджет по токенам.
|
||||
|
||||
### 🟡 Обработка ошибок LLM (вопрос 5.5)
|
||||
Сейчас один общий `except Exception` → строка «LLM недоступен: {e}». Нет различия rate limit (429, нужен retry-after), timeout (нужен ретрай), 5xx (ретрай) vs 4xx (не ретраить). И текст исключения уходит **прямо в ответ пользователю** — может протечь URL/детали. Разделите коды:
|
||||
```python
|
||||
try:
|
||||
...
|
||||
except requests.Timeout: # ретрай
|
||||
except requests.HTTPError as e:
|
||||
if e.response.status_code == 429: ... # backoff по Retry-After
|
||||
```
|
||||
|
||||
### 🟢 Промпт для диагностики (вопрос 5.1)
|
||||
`SYSTEM_PROMPT` сильный: 10 правил, явный формат с таблицами, проценты уверенности, «проверь перед заменой», секция «если не поможет». Это хорошо. Чего не хватает: (1) данных об авто (make/model/year/engine почти всегда отсутствуют — VIN есть, но не расшифровывается), (2) пробег/условия, (3) явного запрета галлюцинировать значения PID, которых нет в данных. Добавьте расшифровку VIN→марка/год (хотя бы WMI) перед отправкой — резко поднимет качество.
|
||||
|
||||
### 🟡 Выбор gpt-oss-120b (вопрос 5.4)
|
||||
Для авто-диагностики ключевое — знание DTC и инженерная логика. 120b разумен как баланс цена/качество. Альтернативы под задачу: Qwen2.5-72B/Qwen3 (хорош в технике, но у вас отмечен CoT-leak баг на fp8-варианте), DeepSeek-V3 (сильная техничка), либо рассуждающая модель (o-серия/R1) для сложных взаимосвязей — но они дороже и медленнее, что усугубит проблему таймаутов из Вопроса 2. Вывод: 120b ок, менять стоит только если качество разбора DTC не устраивает.
|
||||
|
||||
---
|
||||
|
||||
## Вопрос 6. Безопасность API
|
||||
|
||||
### 🔴 Любой эндпоинт без аутентификации → бесплатный прокси к платному LLM (вопросы 6.1, 6.4)
|
||||
`/api/v1/chat`, `/api/v1/session/upload`, `/api/v1/ping-llm` дёргают платный LLM **без какой-либо аутентификации и без rate limit**. Любой, кто узнал домен, может в цикле слать `/chat` и жечь ваш токен `api.aillm.ru`, а `/ping-llm` вообще тратит LLM-вызов на каждый GET. Для MVP HTTPS защищает только канал, но не от абуза. Минимум:
|
||||
- статический API-ключ приложения в заголовке (да, его можно вытащить из APK, но он отсекает массовый скан-абуз);
|
||||
- rate limiting на nginx (`limit_req_zone`) и/или Flask-Limiter по IP/`android_id`;
|
||||
- `/ping-llm` не должен реально вызывать LLM на каждый пинг — кэшируйте результат на 1–5 мин.
|
||||
|
||||
```nginx
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/m;
|
||||
location /api/v1/chat { limit_req zone=api burst=5 nodelay; ... }
|
||||
```
|
||||
|
||||
### 🟡 XSS через diagnosis/raw (вопрос 6.3 — не инъекция, а отображение)
|
||||
SQL-инъекции через `raw_responses` нет (запросы параметризованы). **Но**: ответ ELM327 и текст диагноза от LLM (markdown с таблицами) где-то рендерятся в вебе (`web/templates/index.html`, дашборд сессий). Если markdown/HTML вставляется без экранирования — это stored XSS: вредонос в `raw` ELM или в ответе LLM выполнится в браузере админа. Проверьте, что вывод экранируется (Jinja autoescape по умолчанию вкл — не отключайте `|safe` на этих полях; markdown рендерьте через санитайзер).
|
||||
|
||||
### 🟡 Утечка деталей в ответах
|
||||
`except ... return f"LLM недоступен: {e}"` и `error: str(e)[:100]` отдают внутренние сообщения наружу. Логируйте полностью, клиенту — обобщённый текст.
|
||||
|
||||
### 🟡 API-ключ LLM (вопрос 6.5)
|
||||
Текущая схема (ключ только на сервере, не в APK) — **правильная**, это лучшее в безопасности проекта. Дополнительный прокси не нужен; достаточно закрыть абуз (rate limit + ключ приложения), чтобы вашим серверным ключом не пользовались чужие.
|
||||
|
||||
### Сводка по безопасности
|
||||
| Мера | Приоритет | Статус |
|
||||
|------|-----------|--------|
|
||||
| Rate limiting (nginx/Flask-Limiter) | 🔴 высокий | нет |
|
||||
| Ключ приложения в заголовке | 🟡 средний | нет |
|
||||
| `/ping-llm` без реального LLM-вызова | 🟡 средний | вызывает LLM |
|
||||
| Экранирование diagnosis/raw в вебе | 🟡 средний | проверить |
|
||||
| Не отдавать текст исключений клиенту | 🟡 средний | отдаёт |
|
||||
| Ключ LLM только на сервере | 🟢 | сделано |
|
||||
|
||||
---
|
||||
|
||||
## Итоговый топ проблем (по убыванию важности)
|
||||
|
||||
1. 🔴 **Нет идемпотентности upload** → дубликаты сессий и двойной расход LLM при 499/ретраях (Q2).
|
||||
2. 🔴 **Открытые LLM-эндпоинты без auth/rate-limit** → абуз платного токена (Q6).
|
||||
3. 🔴 **SQLite: утечка соединений + нет WAL/busy_timeout** при 4 воркерах → `database is locked` (Q4).
|
||||
4. 🔴 **Долгий синхронный LLM в запросе** — корень 499; разделить приём данных и анализ, добавить streaming (Q2, Q5).
|
||||
5. 🔴 **`reset_input_buffer` перед командой** в стейт-машине — иначе десинхрон ELM327 (Q1).
|
||||
6. 🟡 Рассинхрон модели/таймаута в `client.py` (20b vs 120b, 120с vs 180с) (Q5).
|
||||
7. 🟡 История диалога `/chat` строкой вместо ролей `messages` (Q5).
|
||||
8. 🟡 Мёртвые таблицы в схеме — подключить или удалить (Q4).
|
||||
|
||||
## Что уже хорошо 🟢
|
||||
- Чистые границы модулей, заменяемый `brain/`.
|
||||
- Сильный диагностический системный промпт.
|
||||
- Параметризованный SQL (нет инъекций).
|
||||
- Ключ LLM не в APK.
|
||||
- Стейт-машина ограничена по ретраям (нет бесконечных циклов).
|
||||
@@ -0,0 +1,53 @@
|
||||
# 2026-06-06 — Полная сессия (полевой тест + 9 багов + фиксы)
|
||||
|
||||
## Текущий статус
|
||||
- **Версия:** v0.47.0-dev
|
||||
- **APK:** https://obdai.ru/elmer.apk
|
||||
- **Сервер:** https://obdai.ru (5.172.178.213, nginx+gunicorn)
|
||||
- **Android repo:** github.com/Repinoid/elmer-android
|
||||
- **Server repo:** gitea.services.ngcloud.ru/Nail/elmer
|
||||
|
||||
## Хронология коммитов (Android)
|
||||
|
||||
| Коммит | Описание |
|
||||
|--------|----------|
|
||||
| `6a33048` | fix: checkDevice() забыл connectAndInit() |
|
||||
| `739afa9` | fix: connect() идемпотентный + run() без двойного connect |
|
||||
| `94b890f` | bump v0.43.0-dev |
|
||||
| `71618c6` | fix: статус-строка — append вместо overwrite |
|
||||
| `d49f6d6` | fix: все appendStatus с \n, таймер на своей строке |
|
||||
| `3ef3532` | fix: scriptRegistered сброс в onDestroy |
|
||||
| `dead66c` | fix: init без ретраев, v1.5-совместимость |
|
||||
|
||||
## Все 9 багов
|
||||
|
||||
1. checkDevice без connectAndInit — AT-команды без BT-сокета
|
||||
2. connect не идемпотентный — guard socket.isConnected
|
||||
3. run двойной connect
|
||||
4. Статус-строка overwrite — всё на appendStatus(\n...)
|
||||
5. Таймер съедал заголовок — \n вместо пробела
|
||||
6. scriptRegistered не сбрасывался после поворота
|
||||
7. init() 70 секунд на ATAT1 — write+tryRead(2s) вместо exec
|
||||
8. checkDevice слал v2-команды на v1.5 — проверка isV2
|
||||
9. recover/updateAtst/handle длинные таймауты — 2000-3000мс
|
||||
|
||||
## ELM327: версии и команды (Wikipedia)
|
||||
|
||||
- **v1.5 НЕ СУЩЕСТВУЕТ** — клон v1.0/v1.4 с фейковой версией
|
||||
- ATAT1 (adaptive timing): с v1.2
|
||||
- AT@1/AT@2 (device ID): с v1.3
|
||||
- ATST (set timeout): с v1.2
|
||||
- Базовые (ATI, ATDP, ATRV, ATSP, ATE0, ATL0, ATS0, ATWS): с v1.0
|
||||
|
||||
## Правила для Copilot
|
||||
|
||||
1. Коммит после каждой правки: git add -A && git commit -m "..." && git push
|
||||
2. При деплое bump версии в android/app/build.gradle.kts
|
||||
3. Формат: fix:/feat:/refactor:/bump:/docs:
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] Разбить MainActivity.kt (~470 -> <=200 строк)
|
||||
- [ ] Разбить ElmChecker.kt (~270 -> <=200 строк)
|
||||
- [ ] Подробные комментарии перед каждой функцией
|
||||
- [ ] Полевой тест v0.47.0-dev на машине
|
||||
@@ -0,0 +1,73 @@
|
||||
# Полевой тест — диагностика на машине
|
||||
|
||||
> v0.41.0-dev, 6 июня 2026
|
||||
> Нужно: ноутбук + ELM327 Bluetooth + зажигание (можно без ключа для п.1)
|
||||
|
||||
## 1. Проверка ELM (без зажигания)
|
||||
|
||||
1. Вставить ELM327 в OBD-разъём (красная лампа горит)
|
||||
2. На телефоне/ноуте включить Bluetooth
|
||||
3. Открыть приложение
|
||||
4. Нажать **«🔌 ELM»**
|
||||
- ✅ Таймер считает секунды
|
||||
- ✅ Через 2-5с результат: версия, AT@2, протокол, напряжение
|
||||
- ✅ Если v1.5 — предупреждение о клоне
|
||||
5. Нажать **«📡 Сервер»**
|
||||
- ✅ Пинг сервера: OK
|
||||
- ✅ LLM: OK (таймаут ~2с)
|
||||
6. Нажать **«📋 История»**
|
||||
- ✅ Показывает последние сессии (если есть)
|
||||
|
||||
## 2. Сканирование ошибок (ключ в ON, двигатель НЕ запускать)
|
||||
|
||||
1. Повернуть ключ в ON (зажигание, приборы горят)
|
||||
2. В приложении нажать **«⚠️ ОШИБКИ»**
|
||||
- ✅ BLUETOOTH автоматически подключается
|
||||
- ✅ Загорается таймер `[1с]`, `[2с]`...
|
||||
- ✅ Через 3-10с результат:
|
||||
- «Ошибок нет» или список DTC (P0xxx...)
|
||||
3. После скана:
|
||||
- ✅ Кнопка **«🔍 ДИАГНОСТИКА»** активировалась
|
||||
- ✅ Надпись «👆 Сначала считай ошибки» сменилась на «✅ Ошибки считаны»
|
||||
4. Заполнить поле **«Что за машина?»** (например: «Volkswagen Passat B5 1.8T 2005»)
|
||||
5. Нажать **«🔍 ДИАГНОСТИКА»**
|
||||
- ✅ Пошёл скрипт: VIN, DTC, PID...
|
||||
- ✅ Результаты отображаются в реальном времени
|
||||
- ✅ Статус загрузки: «Отправка N ответов... [Nс]»
|
||||
- ✅ Через 10-60с приходит диагноз от LLM
|
||||
- ✅ Диагноз краткий, без воды
|
||||
6. Нажать **«✕ Закрыть»** — всё чистится
|
||||
|
||||
## 3. Чат с LLM
|
||||
|
||||
1. Ввести вопрос: «Что может стучать спереди слева?»
|
||||
- ✅ Ответ от LLM через 3-10с
|
||||
2. Спросить «Кто тебя создал?»
|
||||
- ✅ Ответ: «Я модель больших и малых академических театров...»
|
||||
3. Переспросить «Ну серьёзно?»
|
||||
- ✅ Ответ: «С какой целью интересуешься?»
|
||||
|
||||
## 4. Проверка истории
|
||||
|
||||
1. Нажать **«📋 История»**
|
||||
- ✅ Список последних сессий
|
||||
2. Нажать на любую запись
|
||||
- ✅ Показан полный диагноз
|
||||
3. Нажать **«✕ Закрыть»**
|
||||
- ✅ Возврат к списку истории
|
||||
4. Закрыть список — нажать «Закрыть» в диалоге
|
||||
|
||||
## 5. Дополнительно — проверить ELM (AT@2)
|
||||
|
||||
1. Если ELM качественный — в результатах будет строка «🔹 Устройство: ...»
|
||||
2. Если клон — строка отсутствует
|
||||
|
||||
## 6. Если что-то пошло не так
|
||||
|
||||
| Симптом | Что проверять |
|
||||
|---------|---------------|
|
||||
| ELM не подключается | Bluetooth включён? Зажигание? Красная лампа на ELM? |
|
||||
| Нет VIN | ELM v1.5 может не поддерживать 0902 |
|
||||
| PID пустые | Нет зажигания, или CAN протокол не совпал |
|
||||
| Долгий ответ LLM | DeepSeek бывает медленным до 30с |
|
||||
| Ошибка LLM | `api/v1/ping-llm` — сервер должен ответить `{"ok":true}` |
|
||||
@@ -1,3 +0,0 @@
|
||||
"""Elmer — анализ ошибок электроники автомобиля через ELM327 + LLM."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -1,218 +0,0 @@
|
||||
"""
|
||||
AndrOBD Protocol — ПОЛНАЯ копия стейт-машины AndrOBD.
|
||||
|
||||
Источник: github.com/fr3ts0n/AndrOBD, ElmProt.java
|
||||
|
||||
Состояния:
|
||||
UNDEFINED → INITIALIZING → READY
|
||||
Любое → BUSY (команда) → READY
|
||||
Любое → ERROR → RECOVERING → READY
|
||||
BUS ERROR → DISCONNECTED → RECONNECTING → READY
|
||||
|
||||
Каждый ответ проверяется — не тот ответ → переход в ошибку → восстановление.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from enum import Enum, auto
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger("androbd")
|
||||
|
||||
|
||||
# ── Состояния (AndrOBD STAT) ───────────────────────────────
|
||||
|
||||
class State(Enum):
|
||||
UNDEFINED = auto()
|
||||
INITIALIZING = auto()
|
||||
READY = auto()
|
||||
BUSY = auto()
|
||||
ERROR = auto()
|
||||
DISCONNECTED = auto()
|
||||
|
||||
|
||||
# ── Типы ответов (AndrOBD RSP_ID) ──────────────────────────
|
||||
|
||||
class Rsp:
|
||||
PROMPT = ">"
|
||||
OK = "OK"
|
||||
SEARCHING = "SEARCHING"
|
||||
NODATA = "NODATA"
|
||||
ERROR = "ERROR"
|
||||
UNABLE = "UNABLE"
|
||||
BUS_BUSY = "BUS BUSY"
|
||||
BUS_ERROR = "BUS ERROR"
|
||||
CAN_ERROR = "CAN ERROR"
|
||||
BUS_INIT = "BUS INIT"
|
||||
STOPPED = "STOPPED"
|
||||
DATA_ERROR = "DATA ERROR"
|
||||
BUFFER_FULL= "BUFFER FULL"
|
||||
RX_ERROR = "RX ERROR"
|
||||
UNKNOWN = ""
|
||||
|
||||
@classmethod
|
||||
def identify(cls, raw: str) -> str:
|
||||
u = raw.upper().strip()
|
||||
for tag in (cls.SEARCHING, cls.NODATA, cls.ERROR, cls.UNABLE,
|
||||
cls.BUS_BUSY, cls.BUS_ERROR, cls.CAN_ERROR,
|
||||
cls.BUS_INIT, cls.STOPPED, cls.DATA_ERROR,
|
||||
cls.BUFFER_FULL, cls.RX_ERROR, cls.OK):
|
||||
if u.startswith(tag):
|
||||
return tag
|
||||
if raw.strip() == ">":
|
||||
return cls.PROMPT
|
||||
return cls.UNKNOWN
|
||||
|
||||
|
||||
# ── Адаптивный таймаут (AndrOBD AdaptiveTiming) ─────────────
|
||||
|
||||
class AdaptiveTiming:
|
||||
DEFAULT = 500; MIN = 50; MAX = 2000; STEP = 20; RES = 4
|
||||
|
||||
def __init__(self):
|
||||
self._t = self.DEFAULT; self._min = self.MIN
|
||||
|
||||
@property
|
||||
def ms(self) -> int: return self._t
|
||||
@property
|
||||
def atst(self) -> int: return max(1, self._t // self.RES)
|
||||
|
||||
def increase(self):
|
||||
if self._t + self.STEP < self.MAX: self._t += self.STEP
|
||||
def decrease(self):
|
||||
if self._t - self.STEP >= self._min: self._t -= self.STEP
|
||||
def reset(self): self._t = self.DEFAULT
|
||||
|
||||
|
||||
# ── Протокол (AndrOBD ElmProt) ──────────────────────────────
|
||||
|
||||
class AndrOBD:
|
||||
"""Стейт-машина ELM327 — 1:1 копия AndrOBD."""
|
||||
|
||||
INIT_TMO = 10000 # инициализация
|
||||
DEF_TMO = 200 # адаптивный
|
||||
|
||||
def __init__(self, port: str, baudrate: int = 38400):
|
||||
self.port = port; self.baudrate = baudrate
|
||||
self._ser = None; self._timing = AdaptiveTiming()
|
||||
self._state = State.UNDEFINED; self._last_cmd: Optional[str] = None
|
||||
|
||||
# ── Connect ─────────────────────────────────────────
|
||||
|
||||
def connect(self):
|
||||
import serial
|
||||
self._ser = serial.Serial(
|
||||
port=self.port, baudrate=self.baudrate, timeout=0.1,
|
||||
bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE,
|
||||
stopbits=serial.STOPBITS_ONE)
|
||||
time.sleep(0.5); logger.info(f"AndrOBD: connected {self.port}")
|
||||
|
||||
def close(self):
|
||||
if self._ser and self._ser.is_open: self._ser.close()
|
||||
|
||||
# ── Инициализация ───────────────────────────────────
|
||||
|
||||
def init(self):
|
||||
logger.info("AndrOBD: init")
|
||||
self._state = State.INITIALIZING
|
||||
self._exec("ATSP0", self.INIT_TMO)
|
||||
self._exec("ATAT1", self.DEF_TMO * 5)
|
||||
self._update_atst()
|
||||
self._exec("ATS0", self.DEF_TMO * 5)
|
||||
self._exec("ATL0", self.DEF_TMO * 5)
|
||||
self._exec("ATE0", self.DEF_TMO * 5)
|
||||
self._state = State.READY
|
||||
logger.info("AndrOBD: ready")
|
||||
|
||||
# ── OBD-команда ─────────────────────────────────────
|
||||
|
||||
def send(self, cmd: str) -> str:
|
||||
if self._state == State.ERROR:
|
||||
self._recover()
|
||||
self._state = State.BUSY
|
||||
result = self._exec(cmd, self._timing.ms)
|
||||
self._state = State.READY
|
||||
return result
|
||||
|
||||
# ── Выполнение ──────────────────────────────────────
|
||||
|
||||
def _exec(self, cmd: str, timeout: int) -> str:
|
||||
self._last_cmd = cmd; self._write(cmd)
|
||||
t = timeout
|
||||
for _ in range(10):
|
||||
try:
|
||||
return self._handle(self._read(t))
|
||||
except TimeoutError:
|
||||
if self._state == State.INITIALIZING: t += 1000
|
||||
else: self._timing.increase(); t = self._timing.ms
|
||||
logger.error(f"AndrOBD: no response for {cmd}")
|
||||
self._state = State.ERROR; return ""
|
||||
|
||||
# ── Обработка ответа ────────────────────────────────
|
||||
|
||||
def _handle(self, raw: str) -> str:
|
||||
t = Rsp.identify(raw)
|
||||
if t == Rsp.SEARCHING: return raw
|
||||
if t == Rsp.OK: self._timing.decrease(); return raw
|
||||
if t == Rsp.NODATA: self._timing.increase(); self._update_atst(); return raw
|
||||
|
||||
if t in (Rsp.UNABLE, Rsp.BUS_BUSY, Rsp.BUS_ERROR,
|
||||
Rsp.CAN_ERROR, Rsp.BUS_INIT, Rsp.STOPPED):
|
||||
logger.warning(f"AndrOBD: BUS ERROR ({t})")
|
||||
self._state = State.DISCONNECTED
|
||||
self._timing.reset(); self._update_atst()
|
||||
self._write("ATPC"); self._try_read()
|
||||
self._write("ATSP0"); self._try_read()
|
||||
return raw
|
||||
|
||||
if t in (Rsp.ERROR, Rsp.DATA_ERROR, Rsp.BUFFER_FULL, Rsp.RX_ERROR):
|
||||
logger.warning(f"AndrOBD: {t} — warm start")
|
||||
self._state = State.ERROR
|
||||
self._write("ATWS"); self._try_read()
|
||||
return raw
|
||||
|
||||
# Данные — успех
|
||||
self._timing.decrease(); return raw
|
||||
|
||||
def _recover(self):
|
||||
logger.info("AndrOBD: recovering...")
|
||||
self._state = State.INITIALIZING
|
||||
self._write("ATWS"); self._try_read()
|
||||
self._write("ATSP0"); self._try_read()
|
||||
self._write("ATE0"); self._try_read()
|
||||
self._state = State.READY
|
||||
|
||||
# ── Чтение/запись ───────────────────────────────────
|
||||
|
||||
def _write(self, cmd: str):
|
||||
self._ser.write((cmd + "\r").encode()); self._ser.flush()
|
||||
logger.debug(f"AndrOBD → {cmd}")
|
||||
|
||||
def _read(self, timeout_ms: int) -> str:
|
||||
dl = time.monotonic() + timeout_ms / 1000.0
|
||||
lines, cur = [], []
|
||||
got_prompt = False
|
||||
while time.monotonic() < dl:
|
||||
if self._ser.in_waiting > 0:
|
||||
ch = self._ser.read(1)
|
||||
if not ch: continue
|
||||
cp = ch[0]
|
||||
if cp == 62: self._push(cur, lines); got_prompt = True; break
|
||||
elif cp == 13: self._push(cur, lines)
|
||||
elif cp in (10, 32): pass
|
||||
else: cur.append(chr(cp))
|
||||
else: time.sleep(0.001)
|
||||
self._push(cur, lines)
|
||||
if not got_prompt: raise TimeoutError(f"timeout {timeout_ms}ms")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _try_read(self, timeout: int = 5000):
|
||||
try: self._read(timeout)
|
||||
except TimeoutError: pass
|
||||
|
||||
@staticmethod
|
||||
def _push(cur, lines):
|
||||
if cur: lines.append("".join(cur)); cur.clear()
|
||||
|
||||
def _update_atst(self):
|
||||
self._write(f"ATST{self._timing.atst:02X}"); self._try_read()
|
||||
-153
@@ -1,153 +0,0 @@
|
||||
"""SQLite — сохранение сессий диагностики.
|
||||
|
||||
Схема:
|
||||
cars — VIN, марка, модель, год, двигатель
|
||||
diagnostic_tokens — id (PK), car_id (FK), created_at
|
||||
llm_messages — token_id (FK), role, content, timestamp
|
||||
ecu_parameters — token_id (FK), pid_code, value, unit, timestamp
|
||||
dtc_codes — token_id (FK), code, description, status
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, path: str | Path = "elmer.db"):
|
||||
self.path = Path(path)
|
||||
self.conn = sqlite3.connect(str(self.path))
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
self._init_schema()
|
||||
|
||||
def _init_schema(self):
|
||||
self.conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS cars (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
vin TEXT NOT NULL UNIQUE,
|
||||
make TEXT,
|
||||
model TEXT,
|
||||
year INTEGER,
|
||||
engine TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS diagnostic_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
car_id INTEGER NOT NULL REFERENCES cars(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS llm_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token_id INTEGER NOT NULL REFERENCES diagnostic_tokens(id),
|
||||
role TEXT NOT NULL, -- 'system' | 'user' | 'assistant'
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ecu_parameters (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token_id INTEGER NOT NULL REFERENCES diagnostic_tokens(id),
|
||||
pid_code TEXT NOT NULL, -- напр. '0105', '010C'
|
||||
name TEXT, -- напр. 'coolant_temp', 'rpm'
|
||||
value REAL,
|
||||
unit TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dtc_codes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token_id INTEGER NOT NULL REFERENCES diagnostic_tokens(id),
|
||||
code TEXT NOT NULL, -- напр. 'P0301'
|
||||
description TEXT,
|
||||
status TEXT, -- 'stored' | 'pending'
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
""")
|
||||
self.conn.commit()
|
||||
|
||||
# ── cars ──────────────────────────────────────────────
|
||||
|
||||
def get_or_create_car(self, vin: str) -> int:
|
||||
"""Возвращает car_id по VIN, создаёт запись если нет."""
|
||||
row = self.conn.execute("SELECT id FROM cars WHERE vin = ?", (vin,)).fetchone()
|
||||
if row:
|
||||
return row["id"]
|
||||
cur = self.conn.execute("INSERT INTO cars (vin) VALUES (?)", (vin,))
|
||||
self.conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
def update_car_info(self, car_id: int, make: str, model: str, year: int, engine: str):
|
||||
self.conn.execute(
|
||||
"UPDATE cars SET make=?, model=?, year=?, engine=? WHERE id=?",
|
||||
(make, model, year, engine, car_id),
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
# ── tokens ────────────────────────────────────────────
|
||||
|
||||
def create_token(self, car_id: int) -> int:
|
||||
"""Создаёт новую сессию диагностики, возвращает token_id."""
|
||||
cur = self.conn.execute(
|
||||
"INSERT INTO diagnostic_tokens (car_id) VALUES (?)", (car_id,)
|
||||
)
|
||||
self.conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
def last_token_for_car(self, car_id: int) -> int | None:
|
||||
"""Последняя сессия для VIN (для продолжения диалога), или None."""
|
||||
row = self.conn.execute(
|
||||
"SELECT id FROM diagnostic_tokens WHERE car_id=? ORDER BY created_at DESC LIMIT 1",
|
||||
(car_id,),
|
||||
).fetchone()
|
||||
return row["id"] if row else None
|
||||
|
||||
# ── llm_messages ──────────────────────────────────────
|
||||
|
||||
def add_llm_message(self, token_id: int, role: str, content: str):
|
||||
self.conn.execute(
|
||||
"INSERT INTO llm_messages (token_id, role, content) VALUES (?, ?, ?)",
|
||||
(token_id, role, content),
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
def get_llm_messages(self, token_id: int) -> list[dict]:
|
||||
"""Возвращает историю диалога для токена."""
|
||||
rows = self.conn.execute(
|
||||
"SELECT role, content FROM llm_messages WHERE token_id=? ORDER BY id",
|
||||
(token_id,),
|
||||
).fetchall()
|
||||
return [{"role": r["role"], "content": r["content"]} for r in rows]
|
||||
|
||||
# ── ecu_parameters ────────────────────────────────────
|
||||
|
||||
def add_parameter(self, token_id: int, pid_code: str, name: str, value: float, unit: str):
|
||||
self.conn.execute(
|
||||
"INSERT INTO ecu_parameters (token_id, pid_code, name, value, unit) VALUES (?, ?, ?, ?, ?)",
|
||||
(token_id, pid_code, name, value, unit),
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
def get_parameters(self, token_id: int) -> list[dict]:
|
||||
rows = self.conn.execute(
|
||||
"SELECT pid_code, name, value, unit FROM ecu_parameters WHERE token_id=? ORDER BY id",
|
||||
(token_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ── dtc_codes ─────────────────────────────────────────
|
||||
|
||||
def add_dtc(self, token_id: int, code: str, description: str = "", status: str = "stored"):
|
||||
self.conn.execute(
|
||||
"INSERT INTO dtc_codes (token_id, code, description, status) VALUES (?, ?, ?, ?)",
|
||||
(token_id, code, description, status),
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
def get_dtcs(self, token_id: int) -> list[dict]:
|
||||
rows = self.conn.execute(
|
||||
"SELECT code, description, status FROM dtc_codes WHERE token_id=? ORDER BY id",
|
||||
(token_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
@@ -1,48 +0,0 @@
|
||||
"""Вызов DeepSeek API для диагностики."""
|
||||
|
||||
import requests
|
||||
|
||||
DEFAULT_BASE = "https://api.aillm.ru/v1"
|
||||
DEFAULT_MODEL = "gpt-oss-20b"
|
||||
|
||||
|
||||
class Diagnoser:
|
||||
"""Отправляет данные в DeepSeek и возвращает диагноз."""
|
||||
|
||||
def __init__(self, api_key: str, model: str = DEFAULT_MODEL, base_url: str = DEFAULT_BASE):
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.base_url = base_url.rstrip("/")
|
||||
|
||||
def ask(self, messages: list[dict]) -> str:
|
||||
"""Отправляет сообщения в DeepSeek, возвращает текст ответа."""
|
||||
resp = requests.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"temperature": 0.3, # пониже — меньше фантазий
|
||||
"max_tokens": 4096,
|
||||
},
|
||||
timeout=120, # api.aillm.ru бывает медленным
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data["choices"][0]["message"]["content"]
|
||||
|
||||
def diagnose(
|
||||
self,
|
||||
system: str,
|
||||
user_prompt: str,
|
||||
history: list[dict] | None = None,
|
||||
) -> str:
|
||||
"""Полный цикл: system + история + user_prompt → ответ."""
|
||||
messages = [{"role": "system", "content": system}]
|
||||
if history:
|
||||
messages.extend(history)
|
||||
messages.append({"role": "user", "content": user_prompt})
|
||||
return self.ask(messages)
|
||||
-208
@@ -1,208 +0,0 @@
|
||||
"""Связь с ELM327 через Bluetooth SPP (pyserial)."""
|
||||
|
||||
import re
|
||||
import time
|
||||
|
||||
import serial
|
||||
|
||||
|
||||
class ELM327:
|
||||
"""Работа с ELM327 по последовательному порту (Bluetooth SPP)."""
|
||||
|
||||
# Стандартные PID'ы для чтения
|
||||
DEFAULT_PIDS = {
|
||||
"0105": ("coolant_temp", "°C"), # температура ОЖ
|
||||
"010C": ("rpm", "об/мин"), # обороты двигателя
|
||||
"010D": ("speed", "км/ч"), # скорость
|
||||
"0111": ("throttle_pos", "%"), # положение дросселя
|
||||
"010B": ("map", "кПа"), # давление впуска (MAP)
|
||||
"010F": ("iat", "°C"), # температура впуска
|
||||
"011F": ("runtime_since_start", "с"), # время с запуска
|
||||
"0104": ("engine_load", "%"), # нагрузка двигателя
|
||||
"0106": ("stft_b1", "%"), # краткосрочный fuel trim bank 1
|
||||
"0107": ("ltft_b1", "%"), # долгосрочный fuel trim bank 1
|
||||
}
|
||||
|
||||
def __init__(self, port: str, baudrate: int = 38400, timeout: float = 5.0):
|
||||
self.port = port
|
||||
self.ser = serial.Serial(
|
||||
port=port,
|
||||
baudrate=baudrate,
|
||||
timeout=timeout,
|
||||
bytesize=serial.EIGHTBITS,
|
||||
parity=serial.PARITY_NONE,
|
||||
stopbits=serial.STOPBITS_ONE,
|
||||
)
|
||||
|
||||
# ── низкоуровневые команды ────────────────────────────
|
||||
|
||||
def _cmd(self, cmd: str, wait: float = 0.2) -> str:
|
||||
"""Отправляет AT/OBD команду, возвращает сырой ответ."""
|
||||
self.ser.reset_input_buffer()
|
||||
self.ser.write((cmd + "\r").encode())
|
||||
time.sleep(wait)
|
||||
lines = []
|
||||
while True:
|
||||
line = self.ser.readline().decode("utf-8", errors="ignore").strip()
|
||||
if not line or line == ">":
|
||||
break
|
||||
lines.append(line)
|
||||
return "\n".join(lines)
|
||||
|
||||
# ── инициализация ─────────────────────────────────────
|
||||
|
||||
def init(self) -> bool:
|
||||
"""Сброс и настройка ELM327. Возвращает True если OK."""
|
||||
resp = self._cmd("ATZ", wait=1.0) # сброс
|
||||
if "ELM" not in resp:
|
||||
return False
|
||||
self._cmd("ATE0") # выкл эхо
|
||||
self._cmd("ATL0") # выкл перевод строки
|
||||
self._cmd("ATSP0") # авто-протокол
|
||||
self._cmd("ATH1") # вкл заголовки
|
||||
return True
|
||||
|
||||
# ── чтение VIN ────────────────────────────────────────
|
||||
|
||||
def read_vin(self) -> str | None:
|
||||
"""Читает VIN (режим 09 PID 02). Возвращает VIN или None."""
|
||||
resp = self._cmd("0902", wait=1.5)
|
||||
# Формат: 014 0: 49 02 01 57 56 57 ...
|
||||
# Ищем строку с байтами после 49 02
|
||||
match = re.search(r"49\s*02\s*(.+)", resp.replace("\n", " ").replace(":", ""))
|
||||
if not match:
|
||||
return None
|
||||
# Собираем HEX байты, переводим в ASCII
|
||||
hex_bytes = match.group(1).strip().split()
|
||||
vin = ""
|
||||
for h in hex_bytes:
|
||||
h = h.strip()
|
||||
if len(h) == 2:
|
||||
try:
|
||||
vin += chr(int(h, 16))
|
||||
except ValueError:
|
||||
pass
|
||||
return vin if len(vin) == 17 else None
|
||||
|
||||
# ── чтение ошибок ─────────────────────────────────────
|
||||
|
||||
def read_dtc_codes(self, mode: str = "03") -> list[dict]:
|
||||
"""Читает коды ошибок.
|
||||
mode: '03' — сохранённые, '07' — ожидающие.
|
||||
Возвращает [{"code": "P0301", "description": "", "status": "stored"}, ...].
|
||||
"""
|
||||
resp = self._cmd(mode, wait=1.0)
|
||||
codes = []
|
||||
|
||||
# Пример ответа: 43 01 33 00 00 00 00
|
||||
for line in resp.split("\n"):
|
||||
line = line.strip()
|
||||
if not line or "NO DATA" in line.upper():
|
||||
continue
|
||||
|
||||
# Ищем HEX-байты после 43 (mode 03 response) или 47 (mode 07)
|
||||
match = re.search(r"4[37]\s*(.+)", line.replace(":", ""))
|
||||
if not match:
|
||||
continue
|
||||
|
||||
hex_bytes = match.group(1).strip().split()
|
||||
# Парсим по 2 байта на код (первые два байта — количество кодов)
|
||||
i = 1 # пропускаем байт количества
|
||||
while i + 1 < len(hex_bytes):
|
||||
dtc_raw = _decode_dtc(hex_bytes[i], hex_bytes[i + 1])
|
||||
if dtc_raw and dtc_raw != "P0000":
|
||||
codes.append({
|
||||
"code": dtc_raw,
|
||||
"description": "",
|
||||
"status": "stored" if mode == "03" else "pending",
|
||||
})
|
||||
i += 2
|
||||
|
||||
return codes
|
||||
|
||||
# ── чтение параметров ─────────────────────────────────
|
||||
|
||||
def read_pid(self, pid: str) -> float | None:
|
||||
"""Читает один PID, возвращает числовое значение или None."""
|
||||
resp = self._cmd(pid, wait=0.3)
|
||||
# Ищем строку ответа: 41 XX YY ZZ ...
|
||||
match = re.search(r"4[12]\s*" + pid[2:4] + r"\s*(.+)", resp.replace(":", ""))
|
||||
if not match:
|
||||
return None
|
||||
|
||||
hex_bytes = match.group(1).strip().split()
|
||||
if not hex_bytes:
|
||||
return None
|
||||
|
||||
# Формулы для стандартных PID (SAE J1979)
|
||||
formulas = {
|
||||
"05": lambda b: int(b[0], 16) - 40, # coolant °C
|
||||
"0C": lambda b: (int(b[0], 16) * 256 + int(b[1], 16)) / 4, # RPM
|
||||
"0D": lambda b: int(b[0], 16), # speed km/h
|
||||
"11": lambda b: int(b[0], 16) * 100 / 255, # throttle %
|
||||
"0B": lambda b: int(b[0], 16), # MAP kPa
|
||||
"0F": lambda b: int(b[0], 16) - 40, # IAT °C
|
||||
"1F": lambda b: int(b[0], 16) * 256 + int(b[1], 16), # runtime sec
|
||||
"04": lambda b: int(b[0], 16) * 100 / 255, # load %
|
||||
"06": lambda b: (int(b[0], 16) - 128) * 100 / 128, # STFT %
|
||||
"07": lambda b: (int(b[0], 16) - 128) * 100 / 128, # LTFT %
|
||||
}
|
||||
|
||||
pid_short = pid[2:4]
|
||||
if pid_short in formulas:
|
||||
try:
|
||||
return round(formulas[pid_short](hex_bytes), 1)
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
# Generic: первый байт как raw
|
||||
try:
|
||||
return int(hex_bytes[0], 16)
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
def read_all_pids(self, pids: dict | None = None) -> list[dict]:
|
||||
"""Читает все PID'ы из словаря {pid: (name, unit)}.
|
||||
Возвращает [{"pid_code": "...", "name": "...", "value": ..., "unit": "..."}, ...].
|
||||
"""
|
||||
if pids is None:
|
||||
pids = self.DEFAULT_PIDS
|
||||
|
||||
results = []
|
||||
for pid_code, (name, unit) in pids.items():
|
||||
try:
|
||||
value = self.read_pid(pid_code)
|
||||
if value is not None:
|
||||
results.append({
|
||||
"pid_code": pid_code,
|
||||
"name": name,
|
||||
"value": value,
|
||||
"unit": unit,
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
return results
|
||||
|
||||
def close(self):
|
||||
self._cmd("ATZ", wait=0.5)
|
||||
self.ser.close()
|
||||
|
||||
|
||||
def _decode_dtc(b1: str, b2: str) -> str | None:
|
||||
"""Декодирует два HEX-байта в код ошибки вида P0301."""
|
||||
try:
|
||||
a, b = int(b1, 16), int(b2, 16)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
# Первые 2 бита первого байта — тип:
|
||||
types = {0: "P", 1: "C", 2: "B", 3: "U"}
|
||||
prefix = types.get(a >> 6, "?")
|
||||
|
||||
# Оставшиеся биты
|
||||
d1 = str((a >> 4) & 0x03) # вторая цифра
|
||||
d2 = str(a & 0x0F) # третья цифра
|
||||
d3 = f"{(b >> 4) & 0x0F:X}" # четвёртая цифра (hex!)
|
||||
d4 = f"{b & 0x0F:X}" # пятая цифра (hex!)
|
||||
|
||||
return f"{prefix}{d1}{d2}{d3}{d4}"
|
||||
@@ -1,230 +0,0 @@
|
||||
"""
|
||||
ELM327 Protocol Layer — точная копия паттернов AndrOBD (1993⭐, 10 лет продакшена).
|
||||
|
||||
Источник: github.com/fr3ts0n/AndrOBD
|
||||
- BtCommService.java → BT подключение + 500мс пауза
|
||||
- ElmProt.java → стейт-машина, таймауты, ошибки
|
||||
- StreamHandler.java → побайтовое чтение, '>' = разделитель
|
||||
|
||||
Ключевые паттерны (ВСЕ подтверждены сырым кодом AndrOBD):
|
||||
1. Побайтовое чтение, сон 1мс между проверками
|
||||
2. '>' — обычный разделитель строк (как CR/LF), НЕ спецсигнал
|
||||
3. Адаптивный таймаут: старт 200мс, ±4мс, диапазон 12-1000мс
|
||||
4. ATST меняется на лету при изменении таймаута
|
||||
5. Инициализация: ATSP→ATAT→ATS0→ATL0→ATE0 (без ATZ!)
|
||||
6. Ошибки: WARMSTART, re-queue, protocol reset
|
||||
7. Мульти-фрейм ISO-TP с префиксом длины
|
||||
8. flush() после каждой команды
|
||||
|
||||
Использование:
|
||||
proto = ELMProtocol(port="/dev/rfcomm0")
|
||||
proto.connect() # + 500мс пауза (AndrOBD #233)
|
||||
proto.init() # ATSP0 → ATAT1 → ATS0 → ATL0 → ATE0
|
||||
resp = proto.send_command("010C") # → "410C1AF8"
|
||||
proto.close()
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger("elmer.proto")
|
||||
|
||||
|
||||
# ── Адаптивный таймаут (AndrOBD AdaptiveTiming) ─────────────
|
||||
|
||||
class AdaptiveTiming:
|
||||
"""Адаптивный таймаут ELM327 — точная копия AndrOBD.
|
||||
|
||||
Старт: 200мс. Шаг: 4мс. Диапазон: 12-1000мс.
|
||||
На NODATA: +4мс. На успех: -4мс (не ниже learned_min).
|
||||
Каждое изменение → ATST<timeout/4>.
|
||||
"""
|
||||
|
||||
DEFAULT = 200 # мс
|
||||
MIN = 12 # мс
|
||||
MAX = 1000 # мс
|
||||
STEP = 4 # мс
|
||||
RES = 4 # множитель ATST (timeout = ATST_value * 4)
|
||||
|
||||
def __init__(self):
|
||||
self._timeout = self.DEFAULT
|
||||
self._learned_min = self.MIN
|
||||
|
||||
@property
|
||||
def timeout_ms(self) -> int:
|
||||
return self._timeout
|
||||
|
||||
@property
|
||||
def atst_value(self) -> int:
|
||||
return max(1, self._timeout // self.RES)
|
||||
|
||||
def increase(self):
|
||||
if self._timeout + self.STEP < self.MAX:
|
||||
self._timeout += self.STEP
|
||||
|
||||
def decrease(self):
|
||||
if self._timeout - self.STEP >= self._learned_min:
|
||||
self._timeout -= self.STEP
|
||||
|
||||
def reset(self):
|
||||
self._timeout = self.DEFAULT
|
||||
|
||||
|
||||
# ── Протокол ELM327 ─────────────────────────────────────────
|
||||
|
||||
class ELMProtocol:
|
||||
"""Побайтовый обмен с ELM327 — точная копия AndrOBD StreamHandler + ElmProt."""
|
||||
|
||||
SPP_UUID = "00001101-0000-1000-8000-00805F9B34FB"
|
||||
|
||||
def __init__(self, port: str, baudrate: int = 38400):
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self._ser = None
|
||||
self._timing = AdaptiveTiming()
|
||||
self._last_cmd: Optional[str] = None
|
||||
|
||||
# ── Подключение ───────────────────────────────────────
|
||||
|
||||
def connect(self):
|
||||
"""Открывает serial-порт + 500мс пауза (AndrOBD issue #233)."""
|
||||
import serial
|
||||
self._ser = serial.Serial(
|
||||
port=self.port,
|
||||
baudrate=self.baudrate,
|
||||
timeout=0.1,
|
||||
bytesize=serial.EIGHTBITS,
|
||||
parity=serial.PARITY_NONE,
|
||||
stopbits=serial.STOPBITS_ONE,
|
||||
)
|
||||
time.sleep(0.5) # КРИТИЧЕСКИ: AndrOBD #233
|
||||
logger.info(f"ELM: connected {self.port} @ {self.baudrate}")
|
||||
|
||||
def close(self):
|
||||
if self._ser and self._ser.is_open:
|
||||
self._ser.close()
|
||||
|
||||
# ── Инициализация (AndrOBD ElmProt.initialize) ─────────
|
||||
|
||||
def init(self) -> bool:
|
||||
"""ATSP0→ATAT1→ATS0→ATL0→ATE0. Каждая команда с чтением ответа."""
|
||||
logger.info("ELM: init start")
|
||||
|
||||
self.send_command("ATSP0") # авто-протокол
|
||||
self.send_command("ATAT1") # adaptive timing
|
||||
self._update_timeout()
|
||||
self.send_command("ATS0") # пробелы выкл
|
||||
self.send_command("ATL0") # line feeds выкл
|
||||
self.send_command("ATE0") # эхо выкл
|
||||
|
||||
logger.info("ELM: init done")
|
||||
return True
|
||||
|
||||
def _update_timeout(self):
|
||||
self._write(f"ATST{self._timing.atst_value:02X}")
|
||||
|
||||
def _drain(self):
|
||||
if self._ser and self._ser.in_waiting > 0:
|
||||
n = len(self._ser.read(self._ser.in_waiting))
|
||||
logger.debug(f"ELM: drained {n}B")
|
||||
|
||||
# ── Отправка + чтение (AndrOBD StreamHandler) ──────────
|
||||
|
||||
def send_command(self, cmd: str) -> str:
|
||||
"""Отправляет команду, читает ответ побайтово.
|
||||
|
||||
Разделители: CR(13), LF(10), '>'(62) — все равноправны.
|
||||
Возвращает строки ответа через \\n.
|
||||
"""
|
||||
self._write(cmd)
|
||||
self._last_cmd = cmd
|
||||
|
||||
try:
|
||||
result = self._read(self._timing.timeout_ms)
|
||||
except TimeoutError:
|
||||
self._timing.increase()
|
||||
self._update_timeout()
|
||||
try:
|
||||
result = self._read(self._timing.timeout_ms)
|
||||
except TimeoutError:
|
||||
return ""
|
||||
|
||||
self._handle_response(result)
|
||||
return result
|
||||
|
||||
# ── Внутренние ────────────────────────────────────────
|
||||
|
||||
def _write(self, cmd: str):
|
||||
"""cmd + CR + flush (AndrOBD writeTelegram)."""
|
||||
self._ser.write((cmd + "\r").encode())
|
||||
self._ser.flush()
|
||||
logger.debug(f"ELM → {cmd}")
|
||||
|
||||
def _read(self, timeout_ms: int) -> str:
|
||||
"""Побайтовое чтение, пауза 1мс (AndrOBD StreamHandler.run)."""
|
||||
deadline = time.monotonic() + timeout_ms / 1000.0
|
||||
lines: list[str] = []
|
||||
cur: list[str] = []
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
if self._ser.in_waiting > 0:
|
||||
ch = self._ser.read(1)
|
||||
if not ch:
|
||||
continue
|
||||
cp = ch[0]
|
||||
|
||||
if cp == 62: # '>' — разделитель как CR/LF
|
||||
self._push(cur, lines)
|
||||
break
|
||||
elif cp == 13: # CR
|
||||
self._push(cur, lines)
|
||||
elif cp in (10, 32): # LF и пробел — игнорируем
|
||||
pass
|
||||
else:
|
||||
cur.append(chr(cp))
|
||||
else:
|
||||
time.sleep(0.001)
|
||||
|
||||
self._push(cur, lines)
|
||||
if not lines:
|
||||
raise TimeoutError(f"ELM: timeout {timeout_ms}ms")
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _push(cur: list[str], lines: list[str]):
|
||||
if cur:
|
||||
lines.append("".join(cur))
|
||||
cur.clear()
|
||||
|
||||
def _handle_response(self, raw: str):
|
||||
"""Обработка ошибок (AndrOBD ElmProt.handleTelegram)."""
|
||||
u = raw.upper()
|
||||
|
||||
if "SEARCHING" in u:
|
||||
return
|
||||
|
||||
if "NODATA" in u or "NO DATA" in u:
|
||||
self._timing.increase()
|
||||
self._update_timeout()
|
||||
return
|
||||
|
||||
if any(e in u for e in ("UNABLE", "BUS BUSY", "BUS ERROR",
|
||||
"CAN ERROR", "BUS INIT", "STOPPED")):
|
||||
logger.warning(f"ELM: bus error, resetting — {raw[:60]}")
|
||||
self._timing.reset()
|
||||
self._update_timeout()
|
||||
self._write("ATPC")
|
||||
self._write("ATSP0")
|
||||
return
|
||||
|
||||
# Успех — уменьшаем таймаут
|
||||
self._timing.decrease()
|
||||
|
||||
|
||||
# ── Фабрика ─────────────────────────────────────────────────
|
||||
|
||||
def create(port: str, baudrate: int = 38400) -> ELMProtocol:
|
||||
proto = ELMProtocol(port, baudrate)
|
||||
proto.connect()
|
||||
return proto
|
||||
@@ -1,82 +0,0 @@
|
||||
"""Шаблоны промптов для LLM."""
|
||||
|
||||
SYSTEM_PROMPT = """Ты — эксперт по диагностике автомобилей с 20-летним опытом. Ты анализируешь коды ошибок OBD2 и параметры ЭБУ и даёшь ГЛУБОКИЙ, РАЗВЁРНУТЫЙ анализ.
|
||||
|
||||
ПРАВИЛА ОТВЕТА:
|
||||
1. НЕ ограничивайся кратким резюме — дай ПОЛНЫЙ анализ каждой ошибки и каждого параметра.
|
||||
2. Для каждой ошибки объясни: что она значит, ВСЕ возможные причины (от частых к редким), какие параметры подтверждают/опровергают каждую версию.
|
||||
3. Анализируй ВЗАИМОСВЯЗИ между ошибками и параметрами — могут ли они иметь общую причину?
|
||||
4. Указывай степень уверенности в процентах для КАЖДОГО вывода.
|
||||
5. Если данных недостаточно — перечисли КОНКРЕТНЫЕ PID'ы, которые нужно считать дополнительно, и объясни почему.
|
||||
6. Предлагай план действий: что проверить СНАЧАЛА (самое вероятное и дешёвое), что ПОТОМ.
|
||||
7. Для каждого действия объясняй: КАК проверить, на ЧТО смотреть, какие значения считать нормой/отклонением.
|
||||
8. Добавляй секцию «Если не поможет» — план Б для каждого пункта.
|
||||
9. НИКОГДА не давай категоричных команд «меняй деталь X» без 100% уверенности. Пиши «проверь X перед заменой Y».
|
||||
10. Пиши на русском языке, доступно, но ТЕХНИЧЕСКИ ТОЧНО. Используй таблицы где уместно.
|
||||
|
||||
ФОРМАТ ОТВЕТА:
|
||||
## Диагноз (развёрнутый)
|
||||
(полный анализ ситуации, 3-5 абзацев)
|
||||
|
||||
## Анализ ошибок
|
||||
| Код | Описание | Вероятные причины | Подтверждающие параметры | Уверенность |
|
||||
|-----|----------|-------------------|--------------------------|-------------|
|
||||
...
|
||||
|
||||
## Анализ параметров
|
||||
| Параметр | Значение | Норма | Отклонение | На что влияет |
|
||||
|----------|----------|-------|------------|---------------|
|
||||
...
|
||||
|
||||
## Взаимосвязи
|
||||
(как ошибки и параметры связаны между собой)
|
||||
|
||||
## План действий (по приоритету)
|
||||
### 1. Проверить ... (самое вероятное)
|
||||
- КАК проверить: ...
|
||||
- На что смотреть: ...
|
||||
- Норма: ...
|
||||
|
||||
### 2. Если не помогло — проверить ...
|
||||
...
|
||||
|
||||
## Каких данных не хватает
|
||||
- PID XX (название) — потому что ...
|
||||
- ...
|
||||
|
||||
## Степень уверенности
|
||||
- Версия A: ~XX%
|
||||
- Версия B: ~XX%
|
||||
- Версия C: ~XX%"""
|
||||
|
||||
|
||||
def build_user_prompt(
|
||||
vin: str,
|
||||
dtc_codes: list[dict],
|
||||
parameters: list[dict],
|
||||
car_info: dict | None = None,
|
||||
) -> str:
|
||||
"""Собирает промпт пользователя из данных ЭБУ."""
|
||||
|
||||
parts = [f"## Данные диагностики\n"]
|
||||
parts.append(f"**VIN:** {vin}")
|
||||
|
||||
if car_info:
|
||||
parts.append(f"**Автомобиль:** {car_info.get('make', '?')} {car_info.get('model', '?')} "
|
||||
f"({car_info.get('year', '?')}), двигатель: {car_info.get('engine', '?')}")
|
||||
|
||||
if dtc_codes:
|
||||
parts.append("\n### Коды ошибок")
|
||||
for dtc in dtc_codes:
|
||||
parts.append(f"- **{dtc['code']}** ({dtc.get('status', 'stored')}): {dtc.get('description', '')}")
|
||||
|
||||
if parameters:
|
||||
parts.append("\n### Параметры ЭБУ")
|
||||
for p in parameters:
|
||||
parts.append(f"- {p['name']} ({p['pid_code']}): {p['value']} {p['unit']}")
|
||||
|
||||
parts.append("\n## Запрос")
|
||||
parts.append("Дай диагноз на основе этих данных. Если данных недостаточно — скажи, "
|
||||
"какие параметры нужно ещё считать и какие действия выполнить водителю.")
|
||||
|
||||
return "\n".join(parts)
|
||||
Executable
+86
@@ -0,0 +1,86 @@
|
||||
#!/bin/bash
|
||||
# Деплой Elmer на obdai.ru
|
||||
set -e
|
||||
|
||||
echo "=== Установка пакетов ==="
|
||||
apt update && apt install -y python3-pip python3-venv nginx certbot python3-certbot-nginx
|
||||
|
||||
echo "=== Клонирование репо ==="
|
||||
cd /opt
|
||||
git clone https://gitea.services.ngcloud.ru/Nail/elmer.git || (cd elmer && git pull)
|
||||
cd elmer
|
||||
git checkout fat-client
|
||||
|
||||
echo "=== Виртуальное окружение ==="
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
pip install gunicorn
|
||||
|
||||
echo "=== Конфигурация ==="
|
||||
cp config.yaml config.yaml.bak
|
||||
cat > config.yaml << 'YAML'
|
||||
elm327:
|
||||
port: /dev/rfcomm0
|
||||
baudrate: 38400
|
||||
|
||||
llm:
|
||||
api_key: "sk-ucI5YvOticoOQ9Kuj5K9mQ"
|
||||
model: "gpt-oss-120b"
|
||||
base_url: "https://api.aillm.ru/v1"
|
||||
|
||||
pids:
|
||||
"0105": ["coolant_temp", "°C"]
|
||||
"010C": ["rpm", "об/мин"]
|
||||
"010D": ["speed", "км/ч"]
|
||||
YAML
|
||||
|
||||
echo "=== Systemd сервис ==="
|
||||
cat > /etc/systemd/system/elmer.service << 'UNIT'
|
||||
[Unit]
|
||||
Description=Elmer Flask API
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=naeel
|
||||
WorkingDirectory=/opt/elmer
|
||||
ExecStart=/opt/elmer/venv/bin/gunicorn -w 4 -b 127.0.0.1:8000 web.app:app
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
|
||||
echo "=== Nginx ==="
|
||||
cat > /etc/nginx/sites-available/elmer << 'NGX'
|
||||
server {
|
||||
listen 80;
|
||||
server_name obdai.ru www.obdai.ru ai.obdai.ru test.obdai.ru;
|
||||
|
||||
location /static/ {
|
||||
alias /opt/elmer/web/static/;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
NGX
|
||||
|
||||
ln -sf /etc/nginx/sites-available/elmer /etc/nginx/sites-enabled/
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
nginx -t && systemctl reload nginx
|
||||
|
||||
echo "=== SSL ==="
|
||||
certbot --nginx -d obdai.ru -d www.obdai.ru --non-interactive --agree-tos -m tazet@narod.ru || true
|
||||
|
||||
echo "=== Запуск ==="
|
||||
systemctl daemon-reload
|
||||
systemctl enable elmer
|
||||
systemctl restart elmer
|
||||
systemctl restart nginx
|
||||
|
||||
echo "=== ГОТОВО ==="
|
||||
curl -s http://obdai.ru/api/v1/script | head -c 50
|
||||
@@ -0,0 +1,78 @@
|
||||
## 4. Контекст проекта (РЕЗЮМЕ для нового чата)
|
||||
|
||||
### Что это
|
||||
elmAI — Android-приложение + Python-сервер для диагностики авто через ELM327.
|
||||
|
||||
### Текущая версия
|
||||
**v0.48.0** (APK: https://obdai.ru/elmer.apk)
|
||||
|
||||
### Репозитории
|
||||
- Сервер: https://gitea.services.ngcloud.ru/Nail/elmer (ветка master)
|
||||
- Android: https://github.com/Repinoid/elmer-android (ветка relay-only)
|
||||
- Сервер живёт на 5.172.178.213 (SSH: naeel@5.172.178.213, ключ ~/.ssh/naeel_vm_id_ed25519)
|
||||
|
||||
### Деплой
|
||||
```bash
|
||||
# ВСЕГДА сначала bump версии в android/app/build.gradle.kts!
|
||||
cd /home/naeel/elmer/android && git add -A && git commit -m "..." && git push origin master
|
||||
cd /home/naeel/elmer && tar czf /tmp/android-src.tar.gz --exclude='.git' --exclude='build' --exclude='.gradle' android/
|
||||
scp -i ~/.ssh/naeel_vm_id_ed25519 /tmp/android-src.tar.gz naeel@5.172.178.213:/tmp/
|
||||
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 \
|
||||
"cd /opt/elmer && rm -rf android && tar xzf /tmp/android-src.tar.gz && \
|
||||
cd android && gradle wrapper --gradle-version 8.7 && \
|
||||
export ANDROID_SDK_ROOT=\$HOME/android-sdk && \
|
||||
./gradlew clean assembleDebug && \
|
||||
cp app/build/outputs/apk/debug/app-debug.apk /opt/elmer/web/static/"
|
||||
```
|
||||
|
||||
### Структура проекта
|
||||
```
|
||||
elmer/
|
||||
android/ — Android-приложение (Kotlin)
|
||||
.../client/
|
||||
MainActivity.kt — UI + приёмники
|
||||
ElmForwardService.kt — BT/TCP-реле
|
||||
TestService.kt — автономный тест ELM
|
||||
api/ — Flask API (Python)
|
||||
db.py — БД (sessions + device_profiles)
|
||||
routes.py — эндпоинты
|
||||
scripts.py — скрипты L0/L1/L2
|
||||
parser.py — парсинг ответов
|
||||
config.py, dtc.py, ping.py
|
||||
brain/ — LLM-клиент, промпты
|
||||
obd/ — ELM-протокол (Python)
|
||||
commands.py — каталог AT-команд
|
||||
classifier.py — классификация ответов
|
||||
connection.py — SerialTransport
|
||||
probe.py — пробинг ELM
|
||||
protocol.py — стейт-машина AndrOBD
|
||||
state.py — состояния/ответы
|
||||
timing.py — адаптивный таймаут
|
||||
doc/ — Документация, сессии
|
||||
web/ — Flask web, статика
|
||||
```
|
||||
|
||||
### Что сделано (сессия 2026-06-07)
|
||||
|
||||
#### Сервер
|
||||
- **Пробинг ELM**: трехуровневый каскад (L0/L1/L2), профили в БД по MAC
|
||||
- **Fix init**: больше не шлём ATAT1/ATSTxx (вешало клоны)
|
||||
- **Скрипты под уровень**: L0 (5 PIDs), L1 (8 + VIN), L2 (14 + калибровки)
|
||||
- **Рефакторинг**: код разбит на независимые модули (commands, classifier, connection...)
|
||||
|
||||
#### Android
|
||||
- **Вывод**: append вместо overwrite (строки не перекрываются)
|
||||
- **Таймер**: отдельный TextView, тикает только во время обмена (Engine Time)
|
||||
- **TestService**: адаптивные таймауты, ATS0, \r терминатор
|
||||
|
||||
### Что НЕ сделано (TODO)
|
||||
- Полевой тест на машине ← СЕЙЧАС
|
||||
- Разбить MainActivity.kt
|
||||
- Разбить ElmChecker.kt
|
||||
|
||||
### Важные правила
|
||||
- ELM327 v1.5 — фейк, НЕ слать ATAT1/AT@1/AT@2/ATST/ATCAF1/ATCFC1
|
||||
- Все статусы через append("\n..."), не tvStatus.text =
|
||||
- Таймер операций (opTimerStart/Stop), не сессии
|
||||
- Версию поднимать ВЕЗДЕ: build.gradle.kts, index.html (2 места), CHANGELOG.md, resume.txt, doc/architecture.md
|
||||
- В проекте elmer — НИЧЕГО не делать без прямого указания
|
||||
@@ -0,0 +1,173 @@
|
||||
# Морда elmAI v2
|
||||
|
||||
> Новая архитектура UI. Обсуждено 7 июня 2026.
|
||||
|
||||
## Макет
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ 🔧 elmAI v?.?.? │
|
||||
│ 📡 Сервер ● 🔌 ELM ● 🚗 ЭБУ ● 🧠 LLM ● │ иконки + светофоры
|
||||
├──────────────────────────────────────────┤
|
||||
│ [ ОШИБКИ / ДИАГНОСТИКА / СТАРТ / СТОП ] │ одна кнопка
|
||||
├──────────────────────────────────────────┤
|
||||
│ │
|
||||
│ поле вывода результатов │
|
||||
│ │
|
||||
├──────────────────────────────────────────┤
|
||||
│ [📋 История] │
|
||||
├──────────────────────────────────────────┤
|
||||
│ [____________________________] [➤] │ поле ввода + кнопка
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Иконки и светофоры
|
||||
|
||||
Четыре иконки в строке под версией. Справа от каждой — цветной индикатор.
|
||||
|
||||
| Иконка | Текст | 🟢 Зелёный | 🟡 Жёлтый | 🔴 Красный |
|
||||
|--------|-------|-----------|----------|-----------|
|
||||
| 📡 | Сервер | ping < 3с | проверка... | нет связи |
|
||||
| 🔌 | ELM | BT + ATI ok | подключение... | нет ELM |
|
||||
| 🚗 | ЭБУ | 0100 ответил | — | нет связи с ЭБУ |
|
||||
| 🧠 | LLM | ping-llm ok | проверка... | нет доступа |
|
||||
|
||||
- Жёлтый — только на время проверки
|
||||
- LLM проверяется только если Сервер зелёный
|
||||
- ЭБУ проверяется только если ELM зелёный
|
||||
- **Тап по иконке** — перепроверка. Сбрасывает цвет на жёлтый, запускает проверку заново.
|
||||
|
||||
### Таймауты
|
||||
|
||||
- **ELM**: 2 попытки BT-подключения по ~4с = макс 8с → 🔴
|
||||
- **Сервер**: HTTP GET `/api/v1/ping`, таймаут 3с → 🔴
|
||||
- **LLM**: HTTP GET `/api/v1/ping-llm`, таймаут 5с → 🔴 (только если Сервер 🟢)
|
||||
- При тапе на иконку проверка запускается немедленно, без задержек.
|
||||
|
||||
### Стартовый сценарий
|
||||
|
||||
1. Открыли приложение → все индикаторы 🟡 или 🔴
|
||||
2. ELM 🟡 — пробуем подключиться (8с макс) → 🟢 или 🔴
|
||||
3. Сервер 🟡 — ping (3с) → 🟢 или 🔴
|
||||
4. Если Сервер 🟢 → LLM 🟡 — ping-llm (5с) → 🟢 или 🔴
|
||||
5. Если ELM 🟢 → ЭБУ — пробуем `0100` → 🟢 или 🔴
|
||||
|
||||
После первого цикла индикаторы не обновляются автоматически — только по тапу.
|
||||
|
||||
## Кнопка-трансформер
|
||||
|
||||
Одна широкая кнопка. Меняет текст, цвет и действие в зависимости от этапа.
|
||||
|
||||
```
|
||||
🔴 [⚠️ ОШИБКИ] — начальное состояние
|
||||
↓ тап
|
||||
сканирование DTC (03 + 07)
|
||||
↓
|
||||
🔵 [🔍 ДИАГНОСТИКА] — ошибки считаны
|
||||
↓ тап
|
||||
ЕСЛИ сервер 🟢 → запрос параметров ЭБУ → отправка на сервер → LLM-анализ → вывод
|
||||
ЕСЛИ сервер 🔴 → пояснение в выводе: «Сервер недоступен. Сделайте тест.»
|
||||
↓
|
||||
🟢 [▶ СТАРТ] — готов к динамическому тесту
|
||||
↓ тап
|
||||
подсказка в выводе: «Газ до 3000, 3-4с, сброс. → СТОП»
|
||||
запись 12 PID каждые 250мс
|
||||
↓
|
||||
🔴 [⏹ СТОП] — запись идёт
|
||||
↓ тап
|
||||
запись остановлена, данные в памяти
|
||||
вывод: «Записано N отсчётов. ➤ для отправки.»
|
||||
↓
|
||||
🟢 [▶ СТАРТ] — можно повторить тест
|
||||
```
|
||||
|
||||
- После СТОП можно снова нажать СТАРТ — новый тест, старые данные сохраняются.
|
||||
- **Отправка данных** — не кнопкой, а через ➤ в поле ввода.
|
||||
|
||||
## Поле вывода
|
||||
|
||||
- ScrollView, моноширинный шрифт
|
||||
- После done — кнопка «✕ Закрыть» (сворачивает вывод, возвращает все кнопки)
|
||||
- После Share в истории — возврат в приложение (стандартное поведение Android)
|
||||
|
||||
## Поле ввода + кнопка ➤
|
||||
|
||||
2 строки, всегда активно. Плейсхолдер: «Что беспокоит? Чем подробнее — тем лучше».
|
||||
|
||||
Кнопка ➤ справа:
|
||||
- **Обычный режим**: текст из поля → отправка на сервер (`/api/v1/chat`) → сервер спрашивает LLM → ответ в вывод
|
||||
- **После СТОП**: накопленные данные теста → отправка на сервер (`/api/v1/session/upload`) + текст (если есть) → сервер анализирует через LLM → ответ в вывод
|
||||
- Ответ → в поле вывода
|
||||
|
||||
## История
|
||||
|
||||
- Кнопка «📋 История»
|
||||
- Список последних 20 сессий (дата, заголовок, статус загрузки)
|
||||
- Выбор → диагноз во всплывающем окне
|
||||
- Кнопка «📤 Поделиться» → системный Share Sheet (Telegram, WhatsApp, Gmail...)
|
||||
- После Share — возврат в наше приложение
|
||||
|
||||
## Что удаляется
|
||||
|
||||
- Кнопки «📡 Сервер», «🔌 ELM», «🚗 ЭБУ» как отдельные — заменены на иконки со светофорами
|
||||
- Кнопки «⏱ На месте», «🚗 В движении» — всё через одну кнопку-трансформер
|
||||
- `tvDtcStatus` («Сначала считай ошибки») — не нужен
|
||||
- `cbFullMode` (чекбокс полной диагностики) — всегда полная
|
||||
- `tvPrompt` (оверлей) — не нужен
|
||||
- `btnDynStart` — не нужен
|
||||
|
||||
## Серверные изменения
|
||||
|
||||
Ничего нового — всё уже есть:
|
||||
- `/api/v1/ping` ✅
|
||||
- `/api/v1/ping-llm` ✅
|
||||
- `/api/v1/chat` ✅
|
||||
- `/api/v1/session/upload` с `dynamic_samples` ✅
|
||||
|
||||
## Текущий макет (реализовано)
|
||||
|
||||
```
|
||||
elmAI v0.68 📡🟢 ELM🟢 ECU🟢 LLM🟢 ← одна строка
|
||||
[ ОШИБКИ / ДИАГНОСТИКА / СТАРТ / СТОП ]
|
||||
вывод
|
||||
[ 📋 История ] ⏱ 12с ← таймер справа
|
||||
[____________________________] [➤] ← 3 строки ввода
|
||||
```
|
||||
|
||||
## Логика кнопки (стейт-машина)
|
||||
|
||||
| State | Текст | Цвет | Действие |
|
||||
|-------|-------|------|----------|
|
||||
| INIT | ⚠️ ОШИБКИ | 🟠 | scanDtc() |
|
||||
| DTC | 🔍 ДИАГНОСТИКА | 🔵 | runDiagnostics() |
|
||||
| DIAG | ▶ СТАРТ | 🟢 | startDynamicRecording() |
|
||||
| START | ⏹ СТОП | 🔴 | stopDynamicRecording() |
|
||||
| STOP | ▶ СТАРТ | 🟢 | не используется |
|
||||
|
||||
- Кнопка неактивна (alpha=0.4) если ELM 🔴 или ECU 🔴
|
||||
- Поле ввода неактивно если LLM 🔴
|
||||
- После DTC-сканирования ECU → 🟢
|
||||
- После СТОП → DIAG (можно снова СТАРТ)
|
||||
|
||||
## Исправлено (v0.57 → v0.68)
|
||||
|
||||
| v | Что |
|
||||
|---|-----|
|
||||
| 0.57 | Layout v2, иконки, трансформер |
|
||||
| 0.58 | Всё в одной строке |
|
||||
| 0.59 | setIndicator в runOnUiThread |
|
||||
| 0.60 | LLM → 🤓 |
|
||||
| 0.61 | Таймер в строке Истории, нет ELM → кнопка неактивна |
|
||||
| 0.62 | Поле ввода неактивно без LLM |
|
||||
| 0.63 | 🤓 → 🎓 |
|
||||
| 0.64 | 🎓 → LLM текст |
|
||||
| 0.65 | СТОП работает, ECU 🟢 после сканирования |
|
||||
| 0.66 | ensureConnected всегда перед dynamic test |
|
||||
| 0.67 | Кнопка неактивна без ECU |
|
||||
| 0.68 | Двойной вызов checkLlm/checkEcu убран, таймер в catch |
|
||||
|
||||
## Известные проблемы (TODO)
|
||||
|
||||
- `findElmDevice()` возвращает null при >1 устройствах (диалог асинхронный)
|
||||
- Android 12+ — startChecks до получения BT-разрешений
|
||||
- `State.STOP` не используется
|
||||
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
obd/classifier.py — Классификация ответов ELM327 и определение уровня.
|
||||
|
||||
Отдельный сервис:
|
||||
- classify(raw) → (tag: str, is_ok: bool)
|
||||
- determine_level(responses: dict) → int (-1/0/1/2)
|
||||
|
||||
Использует obd/commands.py для списков команд по уровням.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
from obd.state import Rsp
|
||||
from obd.commands import L0_NAMES, L1_NAMES, L2_NAMES
|
||||
|
||||
logger = logging.getLogger("elmer.classifier")
|
||||
|
||||
|
||||
def classify(raw: str) -> Tuple[str, bool]:
|
||||
"""Классифицирует сырой ответ ELM327.
|
||||
|
||||
Returns:
|
||||
(tag, is_ok)
|
||||
tag: Rsp.OK / Rsp.ERROR / Rsp.UNKNOWN / ...
|
||||
is_ok: True если устройство ответило нормально (OK или данные)
|
||||
"""
|
||||
tag = Rsp.identify(raw)
|
||||
is_ok = tag in (Rsp.OK, Rsp.UNKNOWN, Rsp.NODATA, Rsp.SEARCHING)
|
||||
return (tag, is_ok)
|
||||
|
||||
|
||||
def determine_level(responses: Dict[str, str]) -> dict:
|
||||
"""Определяет уровень устройства по ответам на пробинг-команды.
|
||||
|
||||
Args:
|
||||
responses: {cmd_name: raw_response} — ответы на команды пробинга.
|
||||
|
||||
Returns:
|
||||
Профиль: {level, elm_version, protocol, voltage, supported, unsupported, errors}
|
||||
"""
|
||||
result: dict = {
|
||||
"level": -1,
|
||||
"elm_version": None,
|
||||
"protocol": None,
|
||||
"voltage": None,
|
||||
"supported": [],
|
||||
"unsupported": [],
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
# ── Уровень 0 ──────────────────────────────────
|
||||
l0_ok = True
|
||||
for cmd in L0_NAMES:
|
||||
raw = responses.get(cmd, "")
|
||||
tag, ok = classify(raw)
|
||||
if ok and raw:
|
||||
result["supported"].append(cmd)
|
||||
if cmd == "ATI":
|
||||
result["elm_version"] = raw.strip()
|
||||
elif cmd == "ATDPN":
|
||||
result["protocol"] = raw.strip()
|
||||
elif cmd == "ATRV":
|
||||
result["voltage"] = raw.strip()
|
||||
else:
|
||||
result["unsupported"].append(cmd)
|
||||
result["errors"].append(f"{cmd}: {tag if tag else 'no response'}")
|
||||
l0_ok = False
|
||||
|
||||
if not l0_ok:
|
||||
logger.warning("classifier: L0 failed")
|
||||
return result
|
||||
|
||||
result["level"] = 0
|
||||
|
||||
# ── Уровень 1 ──────────────────────────────────
|
||||
l1_ok = True
|
||||
for cmd in L1_NAMES:
|
||||
raw = responses.get(cmd, "")
|
||||
tag, ok = classify(raw)
|
||||
if ok and raw:
|
||||
result["supported"].append(cmd)
|
||||
else:
|
||||
result["unsupported"].append(cmd)
|
||||
l1_ok = False
|
||||
|
||||
if not l1_ok:
|
||||
return result
|
||||
|
||||
result["level"] = 1
|
||||
|
||||
# ── Уровень 2 ──────────────────────────────────
|
||||
l2_ok = True
|
||||
for cmd in L2_NAMES:
|
||||
raw = responses.get(cmd, "")
|
||||
tag, ok = classify(raw)
|
||||
if ok and raw:
|
||||
result["supported"].append(cmd)
|
||||
else:
|
||||
result["unsupported"].append(cmd)
|
||||
l2_ok = False
|
||||
|
||||
if l2_ok:
|
||||
result["level"] = 2
|
||||
|
||||
logger.info(f"classifier: level={result['level']}")
|
||||
return result
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
obd/commands.py — Каталог AT-команд ELM327.
|
||||
|
||||
Все известные команды ELM327 с метаданными:
|
||||
- name: имя команды (напр. "ATE0")
|
||||
- desc: краткое описание
|
||||
- level: минимальный уровень устройства (0/1/2)
|
||||
- safe: можно ли слать при пробинге (False для опасных)
|
||||
|
||||
Принцип:
|
||||
- Нет в профиле → не слать.
|
||||
- Опасные команды (меняют baud rate, мониторинг) — никогда не пробингуются.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
|
||||
@dataclass
|
||||
class AtCommand:
|
||||
"""AT-команда ELM327 с метаданными."""
|
||||
name: str # "ATE0"
|
||||
desc: str # "Echo off"
|
||||
level: int = 0 # минимальный уровень (0=все, 1=ATAT1, 2=CAF1/CFC1)
|
||||
safe: bool = True # можно ли пробинговать
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# Уровень 0 — Универсальные (есть у ВСЕХ клонов)
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
CMDS_L0: List[AtCommand] = [
|
||||
AtCommand("ATE0", "Echo off"),
|
||||
AtCommand("ATE1", "Echo on", safe=False), # не слать — вернём эхо
|
||||
AtCommand("ATL0", "Linefeeds off"),
|
||||
AtCommand("ATL1", "Linefeeds on", safe=False),
|
||||
AtCommand("ATS0", "Spaces off"),
|
||||
AtCommand("ATS1", "Spaces on", safe=False),
|
||||
AtCommand("ATH0", "Headers off"),
|
||||
AtCommand("ATH1", "Headers on"),
|
||||
AtCommand("ATSP0", "Auto protocol"),
|
||||
AtCommand("ATDPN", "Describe protocol number"),
|
||||
AtCommand("ATRV", "Read voltage"),
|
||||
AtCommand("ATI", "Identify"),
|
||||
AtCommand("ATWS", "Warm start", safe=False), # сбрасывает состояние
|
||||
AtCommand("ATPC", "Protocol close", safe=False),
|
||||
AtCommand("ATR0", "Responses off", safe=False),
|
||||
AtCommand("ATR1", "Responses on"),
|
||||
AtCommand("ATNL", "Normalize length"),
|
||||
AtCommand("ATAL", "Allow long messages"),
|
||||
AtCommand("ATAR", "Auto receive", safe=False), # включает авто-приём
|
||||
AtCommand("ATSW", "Stop waiting"),
|
||||
AtCommand("ATFE", "Forget events"),
|
||||
AtCommand("ATSI", "Slow init"),
|
||||
AtCommand("ATFI", "Fast init"),
|
||||
AtCommand("ATKW", "Keyword"),
|
||||
AtCommand("ATTA", "Tester address"),
|
||||
AtCommand("ATTP", "Try protocol"),
|
||||
AtCommand("ATWM", "Wakeup message"),
|
||||
AtCommand("ATIGN", "Ignore"),
|
||||
AtCommand("ATIFR", "IFR"),
|
||||
AtCommand("ATIIA", "ISO init address"),
|
||||
AtCommand("ATRD", "Read data"),
|
||||
AtCommand("ATCS", "CAN silent"),
|
||||
AtCommand("ATCV", "Calibrate voltage"),
|
||||
AtCommand("ATD0", "Display off"),
|
||||
AtCommand("ATM0", "Memory off"),
|
||||
AtCommand("ATM1", "Memory on"),
|
||||
]
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# Уровень 1 — Хорошие клоны и оригиналы
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
CMDS_L1: List[AtCommand] = [
|
||||
AtCommand("ATAT0", "Adaptive timing off", level=1),
|
||||
AtCommand("ATAT1", "Adaptive timing on", level=1),
|
||||
AtCommand("ATAT2", "Adaptive timing aggressive", level=1),
|
||||
]
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# Уровень 2 — Только настоящий ELM327
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
CMDS_L2: List[AtCommand] = [
|
||||
AtCommand("ATCAF0", "CAN auto formatting off", level=2),
|
||||
AtCommand("ATCAF1", "CAN auto formatting on", level=2),
|
||||
AtCommand("ATCFC0", "CAN flow control off", level=2),
|
||||
AtCommand("ATCFC1", "CAN flow control on", level=2),
|
||||
AtCommand("ATCM", "CAN mask", level=2),
|
||||
AtCommand("ATCRA", "CAN receive address", level=2),
|
||||
AtCommand("ATCP", "CAN priority", level=2),
|
||||
AtCommand("ATSH", "Set header", level=2),
|
||||
AtCommand("AT@1", "Device description", level=2),
|
||||
AtCommand("AT@2", "Device identifier", level=2),
|
||||
AtCommand("ATJE", "J1939 ELM", level=2),
|
||||
AtCommand("ATJS", "J1939 setup", level=2),
|
||||
AtCommand("ATIB", "ISO baud", level=2, safe=False),
|
||||
]
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# ОПАСНЫЕ — никогда не пробинговать
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
CMDS_DANGEROUS: List[AtCommand] = [
|
||||
AtCommand("ATZ", "Hard reset", safe=False),
|
||||
AtCommand("ATD", "Set defaults", safe=False),
|
||||
AtCommand("ATBD", "Baud rate", safe=False),
|
||||
AtCommand("ATBRD", "Baud rate divisor", safe=False),
|
||||
AtCommand("ATMA", "Monitor all", safe=False),
|
||||
AtCommand("ATMR", "Monitor receiver", safe=False),
|
||||
AtCommand("ATMT", "Monitor transmitter", safe=False),
|
||||
]
|
||||
|
||||
|
||||
# ── Хелперы ─────────────────────────────────────────────
|
||||
|
||||
def get_commands_for_level(level: int) -> List[str]:
|
||||
"""Возвращает имена команд, доступных на заданном уровне."""
|
||||
result = [c.name for c in CMDS_L0 if c.safe]
|
||||
if level >= 1:
|
||||
result += [c.name for c in CMDS_L1 if c.safe]
|
||||
if level >= 2:
|
||||
result += [c.name for c in CMDS_L2 if c.safe]
|
||||
return result
|
||||
|
||||
|
||||
def get_probe_commands() -> dict:
|
||||
"""Возвращает команды для пробинга, сгруппированные по уровням.
|
||||
|
||||
Returns: {"L0": ["ATE0",...], "L1": ["ATAT1",...], "L2": ["ATCAF1",...]}
|
||||
"""
|
||||
return {
|
||||
"L0": [c.name for c in CMDS_L0 if c.safe and c.level == 0][:8],
|
||||
"L1": [c.name for c in CMDS_L1 if c.safe],
|
||||
"L2": [c.name for c in CMDS_L2 if c.safe],
|
||||
}
|
||||
|
||||
|
||||
# Плоские списки для импорта (обратная совместимость)
|
||||
L0_NAMES = [c.name for c in CMDS_L0 if c.safe and c.level == 0]
|
||||
L1_NAMES = [c.name for c in CMDS_L1 if c.safe]
|
||||
L2_NAMES = [c.name for c in CMDS_L2 if c.safe]
|
||||
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
obd/connection.py — Транспортный слой ELM327 (serial/Bluetooth).
|
||||
|
||||
Чтение байт-за-байтом с поллингом 1мс, запись + flush.
|
||||
Выделен из protocol.py чтобы разделить I/O и логику протокола.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
logger = logging.getLogger("elm.connection")
|
||||
|
||||
|
||||
class SerialTransport:
|
||||
"""Транспорт: открыть порт, читать/писать байты, закрыть.
|
||||
|
||||
Не знает протокол. Только I/O.
|
||||
"""
|
||||
|
||||
def __init__(self, port: str, baudrate: int = 38400):
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self._ser = None
|
||||
|
||||
def connect(self):
|
||||
"""Открыть serial-соединение."""
|
||||
import serial
|
||||
self._ser = serial.Serial(
|
||||
port=self.port,
|
||||
baudrate=self.baudrate,
|
||||
timeout=0.1,
|
||||
bytesize=serial.EIGHTBITS,
|
||||
parity=serial.PARITY_NONE,
|
||||
stopbits=serial.STOPBITS_ONE,
|
||||
)
|
||||
time.sleep(0.5)
|
||||
logger.info(f"SerialTransport: connected {self.port}")
|
||||
|
||||
def close(self):
|
||||
"""Закрыть serial-соединение."""
|
||||
if self._ser and self._ser.is_open:
|
||||
self._ser.close()
|
||||
logger.info("SerialTransport: closed")
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
"""Открыт ли порт."""
|
||||
return self._ser is not None and self._ser.is_open
|
||||
|
||||
def write(self, data: str):
|
||||
"""Отправить данные в ELM: data + CR → flush."""
|
||||
self._ser.write((data + "\r").encode())
|
||||
self._ser.flush()
|
||||
logger.debug(f"→ {data}")
|
||||
|
||||
def read(self, timeout_ms: int) -> str:
|
||||
"""Прочитать ответ ELM327 байт-за-байтом.
|
||||
|
||||
Читает до символа '>' (промпт) или до таймаута.
|
||||
Возвращает сырой ответ без '>'.
|
||||
|
||||
Raises:
|
||||
TimeoutError: если '>' не получен за timeout_ms.
|
||||
"""
|
||||
dl = time.monotonic() + timeout_ms / 1000.0
|
||||
lines, cur = [], []
|
||||
got_prompt = False
|
||||
|
||||
while time.monotonic() < dl:
|
||||
if self._ser.in_waiting > 0:
|
||||
ch = self._ser.read(1)
|
||||
if not ch:
|
||||
continue
|
||||
cp = ch[0]
|
||||
if cp == 62: # '>' — промпт ELM327
|
||||
self._push(cur, lines)
|
||||
got_prompt = True
|
||||
break
|
||||
elif cp == 13: # CR — конец строки
|
||||
self._push(cur, lines)
|
||||
elif cp in (10, 32): # LF и пробел — игнорируем
|
||||
pass
|
||||
else:
|
||||
cur.append(chr(cp))
|
||||
else:
|
||||
time.sleep(0.001) # поллинг 1мс
|
||||
|
||||
self._push(cur, lines)
|
||||
if not got_prompt:
|
||||
raise TimeoutError(f"read timeout {timeout_ms}ms")
|
||||
return "\n".join(lines)
|
||||
|
||||
def try_read(self, timeout: int = 5000):
|
||||
"""Прочитать и проигнорировать ответ."""
|
||||
try:
|
||||
self.read(timeout)
|
||||
except TimeoutError:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _push(cur, lines):
|
||||
"""Добавить накопленные байты как строку в lines."""
|
||||
if cur:
|
||||
lines.append("".join(cur))
|
||||
cur.clear()
|
||||
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
obd/probe.py — Пробинг ELM327: определение уровня устройства.
|
||||
|
||||
Трехуровневый каскад (использует obd/commands.py + obd/classifier.py):
|
||||
Уровень 0 (база, все клоны):
|
||||
ATE0 ATL0 ATS0 ATH1 ATSP0 ATDPN ATRV ATI
|
||||
→ хоть одна не ответила → НЕИСПРАВЕН (-1)
|
||||
|
||||
Уровень 1 (хорошие клоны):
|
||||
ATAT1 → уровень 1
|
||||
|
||||
Уровень 2 (настоящие ELM):
|
||||
ATCAF1 ATCFC1 → уровень 2
|
||||
|
||||
Принцип:
|
||||
- Каждая команда: ОДИН запрос, короткий таймаут (500мс)
|
||||
- Нет ретраев на неизвестные команды
|
||||
- Команды и классификация — в отдельных модулях
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from obd.commands import L0_NAMES, L1_NAMES, L2_NAMES
|
||||
from obd.classifier import classify, determine_level
|
||||
|
||||
logger = logging.getLogger("elmer.probe")
|
||||
|
||||
# Таймауты
|
||||
PROBE_TMO = 500 # мс — таймаут на одну команду при пробинге
|
||||
INTER_CMD_DELAY = 0.05 # с — пауза между командами
|
||||
|
||||
|
||||
def probe(elm) -> dict:
|
||||
"""Пробинг ELM327: определяет уровень и профиль устройства.
|
||||
|
||||
Args:
|
||||
elm: AndrOBD с открытым connect().
|
||||
|
||||
Returns:
|
||||
{"level": 0|1|2|-1, "elm_version": str|None, ...}
|
||||
level = -1 → устройство нерабочее.
|
||||
"""
|
||||
# Собираем ответы на все команды пробинга
|
||||
responses = _probe_commands(elm, L0_NAMES + L1_NAMES + L2_NAMES)
|
||||
|
||||
# Классифицируем — определяем уровень
|
||||
result = determine_level(responses)
|
||||
|
||||
if result["level"] >= 0:
|
||||
logger.info(f"probe: device level={result['level']}")
|
||||
else:
|
||||
logger.warning("probe: device UNUSABLE")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _probe_commands(elm, cmds: list[str]) -> dict[str, str]:
|
||||
"""Посылает список команд и собирает ответы.
|
||||
|
||||
Returns:
|
||||
{cmd_name: raw_response}
|
||||
"""
|
||||
responses = {}
|
||||
for cmd in cmds:
|
||||
ok, raw = _try_one(elm, cmd)
|
||||
responses[cmd] = raw if ok else ""
|
||||
return responses
|
||||
|
||||
|
||||
def _try_one(elm, cmd: str) -> tuple[bool, str]:
|
||||
"""Посылает ОДНУ команду в ELM, ждёт PROBE_TMO мс.
|
||||
|
||||
Returns:
|
||||
(ok, raw_response)
|
||||
"""
|
||||
elm._write(cmd)
|
||||
time.sleep(INTER_CMD_DELAY)
|
||||
try:
|
||||
raw = elm._read(PROBE_TMO)
|
||||
except TimeoutError:
|
||||
return False, ""
|
||||
|
||||
_, ok = classify(raw)
|
||||
return (ok, raw)
|
||||
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
obd/protocol.py — ELM327 стейт-машина (AndrOBD).
|
||||
|
||||
Основана на AndrOBD (ElmProt.java, github.com/fr3ts0n/AndrOBD).
|
||||
|
||||
## Архитектура
|
||||
┌──────────┐ команда ┌──────────┐
|
||||
│ READY │──────────────▶│ BUSY │
|
||||
└──────────┘ └────┬─────┘
|
||||
▲ │ ответ получен
|
||||
│ ┌────────────────┘
|
||||
│ ▼
|
||||
┌────┴─────┐ ошибка ┌──────────┐
|
||||
│ ERROR │◀─────────│ (любое) │
|
||||
└────┬─────┘ └──────────┘
|
||||
│ восстановление ▲
|
||||
└─────────────────────┘
|
||||
|
||||
## Зависимости
|
||||
obd/connection.py — транспорт (SerialTransport)
|
||||
obd/state.py — состояния/ответы (State, Rsp)
|
||||
obd/timing.py — адаптивный таймаут (AdaptiveTiming)
|
||||
obd/commands.py — каталог команд
|
||||
obd/classifier.py — классификация ответов
|
||||
|
||||
## Использование
|
||||
elm = AndrOBD("/dev/rfcomm0", 38400)
|
||||
elm.connect()
|
||||
elm.init()
|
||||
vin = elm.send("0902")
|
||||
elm.close()
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from obd.connection import SerialTransport
|
||||
from obd.state import State, Rsp
|
||||
from obd.timing import AdaptiveTiming
|
||||
|
||||
logger = logging.getLogger("androbd")
|
||||
|
||||
|
||||
class AndrOBD:
|
||||
"""Стейт-машина ELM327.
|
||||
|
||||
Управляет жизненным циклом ELM327:
|
||||
1. connect() — открыть serial/Bluetooth порт
|
||||
2. init() — базовая инициализация (уровень 0)
|
||||
3. send(cmd) — отправить OBD-команду, получить ответ
|
||||
4. close() — закрыть порт
|
||||
|
||||
Автоматически обрабатывает: таймауты, BUS ERROR, восстановление.
|
||||
"""
|
||||
|
||||
INIT_TMO = 10000 # мс — таймаут для команд инициализации
|
||||
DEF_TMO = 200 # мс — начальный таймаут
|
||||
|
||||
def __init__(self, port: str, baudrate: int = 38400):
|
||||
self._transport = SerialTransport(port, baudrate)
|
||||
self._timing = AdaptiveTiming()
|
||||
self._state = State.UNDEFINED
|
||||
self._last_cmd: Optional[str] = None
|
||||
|
||||
def connect(self):
|
||||
"""Открыть serial-соединение с ELM327."""
|
||||
self._transport.connect()
|
||||
logger.info(f"AndrOBD: connected {self._transport.port}")
|
||||
|
||||
def close(self):
|
||||
"""Закрыть serial-соединение."""
|
||||
self._transport.close()
|
||||
|
||||
def init(self):
|
||||
"""Базовая инициализация ELM327 (уровень 0 — все клоны).
|
||||
|
||||
ТОЛЬКО команды которые есть у ВСЕХ клонов:
|
||||
ATE0 ATL0 ATS0 ATH1 ATSP0
|
||||
"""
|
||||
logger.info("AndrOBD: init (L0)")
|
||||
self._state = State.INITIALIZING
|
||||
self._exec("ATE0", self.DEF_TMO * 5)
|
||||
self._exec("ATL0", self.DEF_TMO * 5)
|
||||
self._exec("ATS0", self.DEF_TMO * 5)
|
||||
self._exec("ATH1", self.DEF_TMO * 5)
|
||||
self._exec("ATSP0", self.INIT_TMO)
|
||||
self._state = State.READY
|
||||
logger.info("AndrOBD: ready (L0)")
|
||||
|
||||
def init_l1(self):
|
||||
"""Инициализация уровня 1: база + адаптивный тайминг."""
|
||||
logger.info("AndrOBD: init L1 (+ATAT1)")
|
||||
self._state = State.INITIALIZING
|
||||
self._exec("ATAT1", self.DEF_TMO * 5)
|
||||
self._state = State.READY
|
||||
logger.info("AndrOBD: ready (L1)")
|
||||
|
||||
def init_l2(self):
|
||||
"""Инициализация уровня 2: L1 + CAN автоформат + flow control."""
|
||||
logger.info("AndrOBD: init L2 (+ATCAF1 +ATCFC1)")
|
||||
self._state = State.INITIALIZING
|
||||
self._exec("ATCAF1", self.DEF_TMO * 5)
|
||||
self._exec("ATCFC1", self.DEF_TMO * 5)
|
||||
self._state = State.READY
|
||||
logger.info("AndrOBD: ready (L2)")
|
||||
|
||||
def send(self, cmd: str) -> str:
|
||||
"""Отправить OBD-команду и получить ответ."""
|
||||
if self._state == State.ERROR:
|
||||
self._recover()
|
||||
self._state = State.BUSY
|
||||
result = self._exec(cmd, self._timing.ms)
|
||||
if self._state == State.BUSY:
|
||||
self._state = State.READY
|
||||
return result
|
||||
|
||||
# ── Приватные методы ──────────────────────────────
|
||||
|
||||
def _exec(self, cmd: str, timeout: int) -> str:
|
||||
"""Выполнить команду с ретраями (до 10)."""
|
||||
self._last_cmd = cmd
|
||||
self._write(cmd)
|
||||
t = timeout
|
||||
for _ in range(10):
|
||||
try:
|
||||
return self._handle(self._read(t))
|
||||
except TimeoutError:
|
||||
if self._state == State.INITIALIZING:
|
||||
t += 1000
|
||||
else:
|
||||
self._timing.increase()
|
||||
t = self._timing.ms
|
||||
logger.error(f"AndrOBD: no response for {cmd}")
|
||||
self._state = State.ERROR
|
||||
return ""
|
||||
|
||||
def _handle(self, raw: str) -> str:
|
||||
"""Обработать ответ ELM327."""
|
||||
t = Rsp.identify(raw)
|
||||
|
||||
if t == Rsp.SEARCHING:
|
||||
return raw
|
||||
if t == Rsp.OK:
|
||||
self._timing.decrease()
|
||||
return raw
|
||||
if t == Rsp.NODATA:
|
||||
self._timing.increase()
|
||||
return raw
|
||||
|
||||
if t in (Rsp.UNABLE, Rsp.BUS_BUSY, Rsp.BUS_ERROR,
|
||||
Rsp.CAN_ERROR, Rsp.BUS_INIT, Rsp.STOPPED):
|
||||
logger.warning(f"AndrOBD: BUS ERROR ({t})")
|
||||
self._state = State.DISCONNECTED
|
||||
self._timing.reset()
|
||||
self._write("ATPC")
|
||||
self._try_read()
|
||||
self._write("ATSP0")
|
||||
self._try_read()
|
||||
return raw
|
||||
|
||||
if t in (Rsp.ERROR, Rsp.DATA_ERROR, Rsp.BUFFER_FULL, Rsp.RX_ERROR):
|
||||
logger.warning(f"AndrOBD: {t} — warm start")
|
||||
self._state = State.ERROR
|
||||
self._write("ATWS")
|
||||
self._try_read()
|
||||
return raw
|
||||
|
||||
self._timing.decrease()
|
||||
return raw
|
||||
|
||||
def _recover(self):
|
||||
"""Восстановление после ошибки."""
|
||||
logger.info("AndrOBD: recovering...")
|
||||
self._state = State.INITIALIZING
|
||||
self._write("ATWS")
|
||||
self._try_read()
|
||||
self._write("ATSP0")
|
||||
self._try_read()
|
||||
self._write("ATE0")
|
||||
self._try_read()
|
||||
self._state = State.READY
|
||||
|
||||
def _write(self, cmd: str):
|
||||
"""Отправить команду в ELM327."""
|
||||
self._transport.write(cmd)
|
||||
|
||||
def _read(self, timeout_ms: int) -> str:
|
||||
"""Прочитать ответ ELM327."""
|
||||
return self._transport.read(timeout_ms)
|
||||
|
||||
def _try_read(self, timeout: int = 5000):
|
||||
"""Прочитать и проигнорировать ответ."""
|
||||
self._transport.try_read(timeout)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Состояния и классификация ответов ELM327 (AndrOBD STAT/RSP_ID).
|
||||
|
||||
State — состояния стейт-машины (UNDEFINED → INITIALIZING → READY → BUSY → ...)
|
||||
Rsp — классификация сырых ответов ELM327
|
||||
"""
|
||||
|
||||
from enum import Enum, auto
|
||||
|
||||
|
||||
class State(Enum):
|
||||
"""Состояния стейт-машины (AndrOBD STAT).
|
||||
|
||||
UNDEFINED → INITIALIZING → READY — нормальный путь.
|
||||
BUSY — во время выполнения команды.
|
||||
ERROR/DISCONNECTED — ошибка, требуется восстановление.
|
||||
"""
|
||||
UNDEFINED = auto()
|
||||
INITIALIZING = auto()
|
||||
READY = auto()
|
||||
BUSY = auto()
|
||||
ERROR = auto()
|
||||
DISCONNECTED = auto()
|
||||
|
||||
|
||||
class Rsp:
|
||||
"""Классификация ответов ELM327 (AndrOBD RSP_ID).
|
||||
|
||||
Каждый сырой ответ классифицируется:
|
||||
- PROMPT (`>`) — готов к следующей команде
|
||||
- OK — команда выполнена
|
||||
- SEARCHING — идёт поиск протокола
|
||||
- BUS_ERROR/BUS_BUSY/CAN_ERROR/STOPPED — ошибка шины → DISCONNECTED
|
||||
- ERROR/DATA_ERROR/BUFFER_FULL — ошибка → warm start
|
||||
- UNKNOWN — данные (ответ на PID/DTC)
|
||||
"""
|
||||
PROMPT = ">"
|
||||
OK = "OK"
|
||||
SEARCHING = "SEARCHING"
|
||||
NODATA = "NODATA"
|
||||
ERROR = "ERROR"
|
||||
UNABLE = "UNABLE"
|
||||
BUS_BUSY = "BUS BUSY"
|
||||
BUS_ERROR = "BUS ERROR"
|
||||
CAN_ERROR = "CAN ERROR"
|
||||
BUS_INIT = "BUS INIT"
|
||||
STOPPED = "STOPPED"
|
||||
DATA_ERROR = "DATA ERROR"
|
||||
BUFFER_FULL= "BUFFER FULL"
|
||||
RX_ERROR = "RX ERROR"
|
||||
UNKNOWN = ""
|
||||
|
||||
@classmethod
|
||||
def identify(cls, raw: str) -> str:
|
||||
"""Определяет тип ответа ELM327 по сырой строке."""
|
||||
u = raw.upper().strip()
|
||||
# NO DATA бывает как "NODATA" так и "NO DATA"
|
||||
if u.replace(" ", "") == "NODATA":
|
||||
return cls.NODATA
|
||||
for tag in (cls.SEARCHING, cls.ERROR, cls.UNABLE,
|
||||
cls.BUS_BUSY, cls.BUS_ERROR, cls.CAN_ERROR,
|
||||
cls.BUS_INIT, cls.STOPPED, cls.DATA_ERROR,
|
||||
cls.BUFFER_FULL, cls.RX_ERROR, cls.OK):
|
||||
if u.startswith(tag):
|
||||
return tag
|
||||
if raw.strip() == ">":
|
||||
return cls.PROMPT
|
||||
return cls.UNKNOWN
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Адаптивный таймаут ожидания ответа ЭБУ (AndrOBD AdaptiveTiming).
|
||||
|
||||
Динамически подстраивается под скорость ответа:
|
||||
- Успешный ответ → уменьшаем таймаут
|
||||
- Таймаут/NO DATA → увеличиваем таймаут
|
||||
- BUS ERROR → сброс до DEFAULT
|
||||
|
||||
ATST = таймаут / 4 (отправляется в ELM327 как ATSTxx).
|
||||
"""
|
||||
|
||||
|
||||
class AdaptiveTiming:
|
||||
"""Адаптивный таймаут ожидания ответа (AndrOBD AdaptiveTiming).
|
||||
|
||||
ATST = таймаут / 4 (отправляется в ELM327 как ATSTxx).
|
||||
"""
|
||||
DEFAULT = 500 # мс — начальный таймаут
|
||||
MIN = 50 # мс — минимальный
|
||||
MAX = 2000 # мс — максимальный
|
||||
STEP = 20 # мс — шаг изменения
|
||||
RES = 4 # делитель для ATST
|
||||
|
||||
def __init__(self):
|
||||
"""Начальный таймаут = DEFAULT."""
|
||||
self._t = self.DEFAULT
|
||||
self._min = self.MIN
|
||||
|
||||
@property
|
||||
def ms(self) -> int:
|
||||
"""Текущий таймаут в миллисекундах."""
|
||||
return self._t
|
||||
|
||||
@property
|
||||
def atst(self) -> int:
|
||||
"""Значение для ATST (таймаут / 4)."""
|
||||
return max(1, self._t // self.RES)
|
||||
|
||||
def increase(self):
|
||||
"""Увеличить таймаут на STEP (ЭБУ медленно отвечает)."""
|
||||
"""Увеличить таймаут (ЭБУ медленно отвечает)."""
|
||||
if self._t + self.STEP < self.MAX:
|
||||
self._t += self.STEP
|
||||
|
||||
def decrease(self):
|
||||
"""Уменьшить таймаут на STEP (ЭБУ отвечает быстро)."""
|
||||
"""Уменьшить таймаут (ЭБУ отвечает быстро)."""
|
||||
if self._t - self.STEP >= self._min:
|
||||
self._t -= self.STEP
|
||||
|
||||
def reset(self):
|
||||
"""Сброс до DEFAULT (после BUS ERROR / инициализации)."""
|
||||
"""Сбросить до DEFAULT (после BUS ERROR)."""
|
||||
self._t = self.DEFAULT
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
## 4. Контекст проекта (РЕЗЮМЕ для нового чата)
|
||||
|
||||
### Что это
|
||||
elmAI — Android-приложение + Python-сервер для диагностики авто через ELM327.
|
||||
|
||||
### Текущая версия
|
||||
<!-- !!! АКТУАЛЬНАЯ ВЕРСИЯ !!! -->
|
||||
<!-- Перед деплоем bump в android/app/build.gradle.kts, index.html (2 места), CHANGELOG.md, resume.txt, doc/architecture.md -->
|
||||
**v0.77.0-dev** (APK: https://obdai.ru/elmer.apk)
|
||||
|
||||
### Репозитории
|
||||
- Сервер: https://gitea.services.ngcloud.ru/Nail/elmer (ветка **dynamic-tests**)
|
||||
- Android: https://github.com/Repinoid/elmer-android (ветка **dynamic-tests**)
|
||||
- Сервер живёт на 5.172.178.213 (SSH: naeel@5.172.178.213, ключ ~/.ssh/naeel_vm_id_ed25519)
|
||||
|
||||
### Деплой (localhost → сервер)
|
||||
```bash
|
||||
# 1. bump версии в android/app/build.gradle.kts на локальной машине!
|
||||
# 2. закоммитить + запушить (dynamic-tests!)
|
||||
cd /home/naeel/elmer && git add -A && git commit -m "..." && git push origin dynamic-tests
|
||||
|
||||
# 3. залить android-исходники на сервер
|
||||
|
||||
# 3. залить android-исходники на сервер
|
||||
cd /home/naeel/elmer && tar czf /tmp/android-src.tar.gz --exclude='.git' --exclude='build' --exclude='.gradle' android/
|
||||
scp -i ~/.ssh/naeel_vm_id_ed25519 /tmp/android-src.tar.gz naeel@5.172.178.213:/tmp/
|
||||
|
||||
# 4. на сервере: обновить сервер + собрать APK
|
||||
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 "
|
||||
cd /opt/elmer && git checkout dynamic-tests && git pull origin dynamic-tests
|
||||
pip install -r requirements.txt
|
||||
sudo systemctl restart elmer
|
||||
|
||||
rm -rf android && tar xzf /tmp/android-src.tar.gz
|
||||
cd android && gradle wrapper --gradle-version 8.7
|
||||
export ANDROID_SDK_ROOT=\$HOME/android-sdk
|
||||
./gradlew clean assembleDebug
|
||||
cp app/build/outputs/apk/debug/app-debug.apk /opt/elmer/web/static/
|
||||
"
|
||||
```
|
||||
|
||||
### Структура проекта
|
||||
```
|
||||
elmer/
|
||||
android/ — Android-приложение (Kotlin)
|
||||
.../client/
|
||||
MainActivity.kt — UI + приёмники
|
||||
ElmForwardService.kt — BT/TCP-реле
|
||||
TestService.kt — автономный тест ELM
|
||||
api/ — Flask API (Python)
|
||||
db.py — БД (sessions + device_profiles)
|
||||
routes.py — эндпоинты
|
||||
scripts.py — скрипты L0/L1/L2
|
||||
parser.py — парсинг ответов
|
||||
config.py, dtc.py, ping.py
|
||||
brain/ — LLM-клиент, промпты
|
||||
obd/ — ELM-протокол (Python)
|
||||
commands.py — каталог AT-команд
|
||||
classifier.py — классификация ответов
|
||||
connection.py — SerialTransport
|
||||
probe.py — пробинг ELM
|
||||
protocol.py — стейт-машина AndrOBD
|
||||
state.py — состояния/ответы
|
||||
timing.py — адаптивный таймаут
|
||||
doc/ — Документация, сессии
|
||||
web/ — Flask web, статика
|
||||
```
|
||||
|
||||
### Что сделано (сессия 2026-06-07)
|
||||
|
||||
#### Сервер
|
||||
- **Пробинг ELM**: трехуровневый каскад (L0/L1/L2), профили в БД по MAC
|
||||
- **Fix init**: больше не шлём ATAT1/ATSTxx (вешало клоны)
|
||||
- **Скрипты под уровень**: L0 (5 PIDs), L1 (8 + VIN), L2 (14 + калибровки)
|
||||
- **Рефакторинг**: код разбит на независимые модули (commands, classifier, connection...)
|
||||
|
||||
#### Android
|
||||
- **Вывод**: append вместо overwrite (строки не перекрываются)
|
||||
- **Таймер**: отдельный TextView, тикает только во время обмена (Engine Time)
|
||||
- **TestService**: адаптивные таймауты, ATS0, \r терминатор
|
||||
|
||||
### Что НЕ сделано (TODO)
|
||||
- Полевой тест на машине ← СЕЙЧАС
|
||||
- Разбить MainActivity.kt
|
||||
- Разбить ElmChecker.kt
|
||||
|
||||
### Важные правила
|
||||
- ELM327 v1.5 — фейк, НЕ слать ATAT1/AT@1/AT@2/ATST/ATCAF1/ATCFC1
|
||||
- Все статусы через append("\n..."), не tvStatus.text =
|
||||
- Таймер операций (opTimerStart/Stop), не сессии
|
||||
- Версию поднимать ВЕЗДЕ: build.gradle.kts, index.html (2 места), CHANGELOG.md, resume.txt, doc/architecture.md
|
||||
- В проекте elmer — НИЧЕГО не делать без прямого указания
|
||||
@@ -0,0 +1,606 @@
|
||||
"""
|
||||
Сквозные тесты elmAI — без LLM.
|
||||
Проверяет: эндпоинты, БД, идемпотентность, DTC, chat, ping, script.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# Добавляем корень проекта в путь
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
# Временная БД для тестов
|
||||
TEST_DB = Path(tempfile.gettempdir()) / f"elmer_test_{os.getpid()}.db"
|
||||
os.environ["ELMER_CONFIG"] = str(Path(__file__).parent / "config.yaml")
|
||||
|
||||
from api.db import Database
|
||||
from api.parser import parse_batch, format_no_llm
|
||||
from api.routes import _build_diagnosis_prompt
|
||||
from api.dtc import _load_dtc_dict
|
||||
from api.scripts import build_default_script, build_full_script
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
def test(name: str, ok: bool, detail: str = ""):
|
||||
global passed, failed
|
||||
if ok:
|
||||
passed += 1
|
||||
print(f" ✅ {name}")
|
||||
else:
|
||||
failed += 1
|
||||
print(f" ❌ {name}: {detail}")
|
||||
|
||||
# ── 1. Скрипты ──────────────────────────────────────────────────
|
||||
print("\n═══ 1. Сборка скриптов ═══")
|
||||
s = build_default_script()
|
||||
test("default_script — есть steps", "steps" in s)
|
||||
test("default_script — 1+ шагов", len(s.get("steps", [])) >= 1)
|
||||
|
||||
s = build_full_script()
|
||||
test("full_script — есть steps", "steps" in s)
|
||||
test("full_script — 2+ шагов", len(s.get("steps", [])) >= 2)
|
||||
|
||||
# ── 2. Парсер ELM-ответов ──────────────────────────────────────
|
||||
print("\n═══ 2. Парсер ответов ═══")
|
||||
|
||||
# VIN из decoded
|
||||
r = parse_batch([{"cmd": "0902", "raw": "", "decoded": "VIN: WVWZZZ1KZAW123456"}])
|
||||
test("VIN из decoded", r["vin"] == "WVWZZZ1KZAW123456")
|
||||
|
||||
# VIN из raw HEX (полный 17-символьный)
|
||||
r = parse_batch([{"cmd": "0902", "raw": "490201 57 56 57 5A 5A 5A 31 4B 5A 41 57 31 32 33 34 35 36", "decoded": ""}])
|
||||
test("VIN из raw HEX", r["vin"] == "WVWZZZ1KZAW123456")
|
||||
|
||||
# DTC stored
|
||||
r = parse_batch([{"cmd": "03", "raw": "", "decoded": "DTC stored: P0301 P0302"}])
|
||||
test("DTC stored", r["dtc_stored"] == ["P0301", "P0302"])
|
||||
|
||||
# DTC pending
|
||||
r = parse_batch([{"cmd": "07", "raw": "", "decoded": "DTC pending: P0302"}])
|
||||
test("DTC pending", r["dtc_pending"] == ["P0302"])
|
||||
|
||||
# PID
|
||||
r = parse_batch([{"cmd": "0105", "raw": "", "decoded": "ОЖ: 83 °C"}])
|
||||
test("PID разобран", len(r["parameters"]) == 1 and r["parameters"][0]["name"] == "ОЖ")
|
||||
|
||||
# Пустой батч — нет данных
|
||||
r = parse_batch([])
|
||||
test("пустой батч", r["vin"] is None and r["dtc_stored"] == [] and r["dtc_pending"] == [])
|
||||
|
||||
# DTC из raw HEX (mode 03, ответ начинается с 43)
|
||||
r = parse_batch([{"cmd": "03", "raw": "43 02 01 00 02 00", "decoded": ""}])
|
||||
test("DTC из raw HEX", len(r["dtc_stored"]) > 0)
|
||||
|
||||
# DTC из raw HEX mode 07 (byte count 01, DTC P0100)
|
||||
r = parse_batch([{"cmd": "07", "raw": "47 01 01 00", "decoded": ""}])
|
||||
test("DTC pending из raw HEX", len(r["dtc_pending"]) > 0)
|
||||
|
||||
# Mixed: данные + команда без decoded
|
||||
r = parse_batch([
|
||||
{"cmd": "010C", "raw": "410C 0C A8", "decoded": "RPM: 948.0 RPM"},
|
||||
{"cmd": "0902", "raw": "", "decoded": "VIN: WVWZZZ1KZAW123456"},
|
||||
])
|
||||
test("mixed: VIN + PID", r["vin"] == "WVWZZZ1KZAW123456" and len(r["parameters"]) >= 1)
|
||||
|
||||
# ── 3. Промпт билдер ────────────────────────────────────────────
|
||||
print("\n═══ 3. Промпт билдер ═══")
|
||||
data = {
|
||||
"vin": "WLL0333272859",
|
||||
"dtc_stored": ["P0301"],
|
||||
"dtc_pending": [],
|
||||
"parameters": [{"name": "RPM", "value": "948"}],
|
||||
"raw_log": ["→ 010C\n← RPM: 948"],
|
||||
}
|
||||
p = _build_diagnosis_prompt(data)
|
||||
test("промпт содержит VIN", "WLL0333272859" in p)
|
||||
test("промпт содержит ошибку", "P0301" in p)
|
||||
test("промпт содержит параметр", "RPM" in p)
|
||||
test("промпт содержит запрос на анализ", "краткий" in p or "диагноз" in p)
|
||||
|
||||
p2 = _build_diagnosis_prompt(data, car_info="Volkswagen Passat 1.8T 2005")
|
||||
test("car_info в промпте", "Volkswagen" in p2)
|
||||
|
||||
# Без данных
|
||||
p3 = _build_diagnosis_prompt({"vin": None, "dtc_stored": [], "dtc_pending": [], "parameters": [], "raw_log": []})
|
||||
test("промпт без данных содержит 'не распознаны'", "не распознаны" in p3)
|
||||
|
||||
# ── 4. DTC справочник ───────────────────────────────────────────
|
||||
print("\n═══ 4. DTC декодер ═══")
|
||||
dtc = _load_dtc_dict()
|
||||
test("DTC словарь загружен", len(dtc) > 50)
|
||||
test("P0301 есть", "P0301" in dtc)
|
||||
test("P0301 описание", "цилиндр" in dtc.get("P0301", "").lower())
|
||||
test("P0420 есть", "P0420" in dtc)
|
||||
|
||||
# ── 5. База данных ─────────────────────────────────────────────
|
||||
print("\n═══ 5. База данных ═══")
|
||||
db_path = TEST_DB
|
||||
db = Database(db_path)
|
||||
test("БД создана", db_path.exists())
|
||||
|
||||
# Сохраняем сессию
|
||||
db.save_session(
|
||||
client_info={"phone_model": "Pixel", "android_id": "test123", "device_uuid": "uuid-1", "elm_mac": "00:11:22:33:44:55"},
|
||||
responses=[{"cmd": "0105", "raw": "", "decoded": "ОЖ: 83"}],
|
||||
diagnosis="Тестовый диагноз",
|
||||
llm_model="deepseek-v4-flash",
|
||||
llm_success=True,
|
||||
request_id="req-001",
|
||||
response_json={"diagnosis": "Тестовый диагноз"},
|
||||
)
|
||||
sessions = db.get_recent_sessions(1)
|
||||
test("сессия сохранена", len(sessions) == 1)
|
||||
test("diagnosis_text сохранён", sessions[0]["diagnosis_text"] == "Тестовый диагноз")
|
||||
test("llm_success", sessions[0]["llm_success"] == 1)
|
||||
test("request_id сохранён", sessions[0]["request_id"] == "req-001")
|
||||
test("device_uuid сохранён", sessions[0]["device_uuid"] == "uuid-1")
|
||||
test("elm_mac сохранён", sessions[0]["elm_mac"] == "00:11:22:33:44:55")
|
||||
|
||||
# Идемпотентность — повторный request_id
|
||||
cached = db.get_cached_response("req-001")
|
||||
test("кэш по request_id работает", cached is not None and cached.get("diagnosis") == "Тестовый диагноз")
|
||||
|
||||
# Несуществующий request_id
|
||||
cached = db.get_cached_response("nonexistent")
|
||||
test("несуществующий request_id → None", cached is None)
|
||||
|
||||
# Сессия без request_id
|
||||
db.save_session(
|
||||
client_info={"phone_model": "Pixel", "android_id": "test456", "device_uuid": "uuid-2"},
|
||||
responses=[{"cmd": "010C", "raw": "", "decoded": "RPM: 948"}],
|
||||
)
|
||||
sessions = db.get_recent_sessions(5)
|
||||
test("сессия без request_id", len(sessions) >= 2)
|
||||
|
||||
# DTC scan
|
||||
db.save_dtc_scan(
|
||||
client_info={"phone_model": "Pixel", "device_uuid": "uuid-3", "elm_mac": "00:11:22:33:44:66"},
|
||||
dtc_codes=["P0301", "P0302"],
|
||||
)
|
||||
test("DTC scan сохранён в sessions", db.get_recent_sessions(10)[0]["response_count"] == 0)
|
||||
|
||||
# Валидация колонок
|
||||
col = [r[1] for r in db.conn.execute("PRAGMA table_info(sessions)").fetchall()]
|
||||
for c in ["device_uuid", "phone_lang", "phone_tz", "phone_display", "request_id", "response_json"]:
|
||||
test(f"колонка {c} существует", c in col)
|
||||
|
||||
db.close()
|
||||
# Чистим
|
||||
if db_path.exists():
|
||||
db_path.unlink()
|
||||
|
||||
# ── 6. Format no LLM ────────────────────────────────────────────
|
||||
print("\n═══ 6. Format no LLM ═══")
|
||||
f = format_no_llm({"vin": "VIN123", "dtc_stored": ["P0301"], "dtc_pending": [], "parameters": [], "raw_log": []})
|
||||
test("format_no_llm содержит VIN", "VIN123" in f)
|
||||
|
||||
f = format_no_llm({"vin": None, "dtc_stored": [], "dtc_pending": [], "parameters": [], "raw_log": []})
|
||||
test("format_no_llm без данных", len(f) > 0)
|
||||
|
||||
# ── 7. Скрипты через API (без HTTP) ─────────────────────────────
|
||||
print("\n═══ 7. Скрипты ═══")
|
||||
s = build_default_script()
|
||||
test("default в JSON", isinstance(s, dict))
|
||||
test("version", s.get("version") == 1)
|
||||
|
||||
s2 = build_full_script()
|
||||
test("full", s2.get("version") == 1)
|
||||
|
||||
# ── 8. Экстремальные тесты ──────────────────────────────────────
|
||||
print("\n═══ 8. Экстремальные тесты ═══")
|
||||
|
||||
# SQL-инъекция через decoded
|
||||
try:
|
||||
r = parse_batch([{"cmd": "0105", "raw": "", "decoded": "ОЖ: 83'; DROP TABLE sessions; --"}])
|
||||
test("SQL-инъекция в decoded — не падает", not r.get("error"))
|
||||
except Exception:
|
||||
test("SQL-инъекция в decoded — не падает", False)
|
||||
|
||||
# Бинарный мусор в raw
|
||||
r = parse_batch([{"cmd": "0902", "raw": "\x00\x01\x02\xFF\xFE\xFD", "decoded": ""}])
|
||||
test("бинарный мусор в raw", r["vin"] is None and not r.get("error"))
|
||||
|
||||
# VIN — слишком короткий
|
||||
r = parse_batch([{"cmd": "0902", "raw": "", "decoded": "VIN: SHORT"}])
|
||||
test("VIN короткий → null", r["vin"] is None)
|
||||
|
||||
# VIN — слишком длинный (18 символов)
|
||||
r = parse_batch([{"cmd": "0902", "raw": "", "decoded": "VIN: WVWZZZ1KZAW1234567"}])
|
||||
test("VIN 18 символов → null", r["vin"] is None)
|
||||
|
||||
# VIN с русскими буквами
|
||||
r = parse_batch([{"cmd": "0902", "raw": "", "decoded": "VIN: ПРИВЕТЭТОТЕСТ"}])
|
||||
test("VIN кириллица → null", r["vin"] is None)
|
||||
|
||||
# Пустой decoded, пустой raw
|
||||
r = parse_batch([{"cmd": "0105", "raw": "", "decoded": ""}])
|
||||
test("пустой ответ на PID", len(r["parameters"]) == 0 and not r.get("error"))
|
||||
|
||||
# Ответ начинается с SEARCHING
|
||||
r = parse_batch([{"cmd": "0105", "raw": "SEARCHING...", "decoded": ""}])
|
||||
test("SEARCHING → без параметров", len(r["parameters"]) == 0)
|
||||
|
||||
# BUS ERROR
|
||||
r = parse_batch([{"cmd": "0105", "raw": "BUS ERROR", "decoded": ""}])
|
||||
test("BUS ERROR → без паники", not r.get("error"))
|
||||
|
||||
# DTC: none (нет ошибок)
|
||||
r = parse_batch([{"cmd": "03", "raw": "", "decoded": "DTC stored: none"}])
|
||||
test("DTC none → пустой список", len(r["dtc_stored"]) == 0)
|
||||
|
||||
# DTC: 20 штук
|
||||
r = parse_batch([{"cmd": "03", "raw": "", "decoded": "DTC stored: " + " ".join([f"P{str(i).zfill(4)}" for i in range(1, 21)])}])
|
||||
test("DTC 20 штук", len(r["dtc_stored"]) == 20)
|
||||
|
||||
# Номер протокола как ответ
|
||||
r = parse_batch([{"cmd": "0105", "raw": "OK", "decoded": ""}])
|
||||
test("OK → без параметров", len(r["parameters"]) == 0)
|
||||
|
||||
# Много пробелов и разных разделителей
|
||||
r = parse_batch([{"cmd": "03", "raw": " 43 02 01 00 02 00 ", "decoded": ""}])
|
||||
test("DTC с лишними пробелами", len(r["dtc_stored"]) > 0)
|
||||
|
||||
# Ответ от ELM с кавычками
|
||||
try:
|
||||
r = parse_batch([{"cmd": "0105", "raw": "", "decoded": 'ОЖ: 83"С\'тест'}])
|
||||
test("кавычки в decoded — не падает", True)
|
||||
except Exception:
|
||||
test("кавычки в decoded — не падает", False)
|
||||
|
||||
# В ответе только цифры
|
||||
r = parse_batch([{"cmd": "0105", "raw": "1234567890", "decoded": ""}])
|
||||
test("только цифры — не распознано", not r.get("error"))
|
||||
|
||||
# Длинная строка (10000 символов)
|
||||
r = parse_batch([{"cmd": "03", "raw": "SEARCHING" + "A" * 9990, "decoded": ""}])
|
||||
test("длинный ответ SEARCHING", not r.get("error"))
|
||||
|
||||
# ── 9. DB экстремальные ────────────────────────────────────────
|
||||
print("\n═══ 9. DB экстремальные ═══")
|
||||
|
||||
db2 = Database(TEST_DB)
|
||||
|
||||
# Ультра-длинный диагноз
|
||||
long_diag = "тест " * 10_000
|
||||
db2.save_session(
|
||||
client_info={"phone_model": "x", "device_uuid": "long-test"},
|
||||
responses=[{"cmd": "0105", "raw": "", "decoded": "ОЖ: 83"}],
|
||||
diagnosis=long_diag,
|
||||
)
|
||||
test("диагноз 50k символов", True)
|
||||
|
||||
# Пустой diagnosis
|
||||
db2.save_session(
|
||||
client_info={"phone_model": "x", "device_uuid": "empty-diag"},
|
||||
responses=[],
|
||||
diagnosis="",
|
||||
)
|
||||
test("пустой диагноз", True)
|
||||
|
||||
# Client_info с неожиданными типами
|
||||
db2.save_session(
|
||||
client_info={
|
||||
"phone_model": None, "phone_maker": 12345, "android_version": "",
|
||||
"android_sdk": None, "app_version": "0", "android_id": None,
|
||||
"device_uuid": "null-test", "elm_mac": None,
|
||||
},
|
||||
responses=[{"cmd": "0105", "raw": "", "decoded": "тест"}],
|
||||
)
|
||||
test("мусор в client_info — None/числа", True)
|
||||
|
||||
# 10 сессий с разными request_id для проверки идемпотентности
|
||||
for i in range(10):
|
||||
db2.save_session(
|
||||
client_info={"phone_model": f"device_{i}", "device_uuid": f"uuid_{i}"},
|
||||
responses=[{"cmd": "0105", "raw": "", "decoded": "ОЖ: 83"}],
|
||||
request_id=f"req-{i}",
|
||||
response_json={"ok": i},
|
||||
)
|
||||
test("10 сессий с разными request_id", True)
|
||||
|
||||
# Повторный request_id — не должен создать дубликат
|
||||
count_before = len(db2.get_recent_sessions(100))
|
||||
db2.save_session(
|
||||
client_info={"phone_model": "duplicate_test", "device_uuid": "dup"},
|
||||
responses=[],
|
||||
request_id="req-0",
|
||||
response_json={"ok": "duplicate"},
|
||||
)
|
||||
count_after = len(db2.get_recent_sessions(100))
|
||||
test("идемпотентность — дубликат не создан", count_before == count_after)
|
||||
|
||||
# Кэш по request_id возвращает старый результат
|
||||
cached = db2.get_cached_response("req-0")
|
||||
test("идемпотентность — кэш вернул старые данные", cached is not None and cached.get("ok") == 0)
|
||||
|
||||
# Конкурентный доступ (симуляция)
|
||||
import threading
|
||||
errors = []
|
||||
def concurrent_write(idx: int):
|
||||
try:
|
||||
db2.save_session(
|
||||
client_info={"phone_model": f"concurrent_{idx}", "device_uuid": f"cuuid_{idx}"},
|
||||
responses=[],
|
||||
request_id=f"creq-{idx}",
|
||||
)
|
||||
except Exception as e:
|
||||
errors.append(str(e))
|
||||
|
||||
threads = [threading.Thread(target=concurrent_write, args=(i,)) for i in range(20)]
|
||||
[t.start() for t in threads]
|
||||
[t.join() for t in threads]
|
||||
test(f"20 конкурентных записей — максимум 3 ошибки", len(errors) <= 3)
|
||||
|
||||
db2.close()
|
||||
if TEST_DB.exists():
|
||||
TEST_DB.unlink()
|
||||
|
||||
# ── 10. DTC словарь — граничные случаи ──────────────────────────
|
||||
print("\n═══ 10. DTC словарь — граничные ═══")
|
||||
dtc = _load_dtc_dict()
|
||||
|
||||
# Код, которого нет в словаре
|
||||
test("неизвестный код → сам код", dtc.get("P9999", "P9999") == "P9999")
|
||||
test("код в нижнем регистре → нет", dtc.get("p0301", "") == "")
|
||||
|
||||
# Пустой код
|
||||
test("пустой код → пусто", dtc.get("", None) is None)
|
||||
|
||||
# Не-P код (C-код)
|
||||
test("C-код C0000 есть", "C0000" in dtc)
|
||||
|
||||
# Не-P код (B-код, U-код)
|
||||
test("B-код B0000 есть", "B0000" in dtc)
|
||||
test("U-код U0000 есть", "U0000" in dtc)
|
||||
|
||||
# ── 11. Промпт-билдер — граничные ───────────────────────────────
|
||||
print("\n═══ 11. Промпт-билдер граничные ═══")
|
||||
|
||||
# Пустой data
|
||||
try:
|
||||
_build_diagnosis_prompt({"vin": None, "dtc_stored": [], "dtc_pending": [], "parameters": [], "raw_log": []})
|
||||
test("пустой словарь в промпт", True)
|
||||
except Exception:
|
||||
test("пустой словарь в промпт", False)
|
||||
|
||||
# data без ключей
|
||||
try:
|
||||
_build_diagnosis_prompt({})
|
||||
test("пустой {} в промпт", True)
|
||||
except Exception:
|
||||
test("пустой {} в промпт", False)
|
||||
|
||||
# None вместо списков
|
||||
try:
|
||||
_build_diagnosis_prompt({"vin": None, "dtc_stored": None, "dtc_pending": None, "parameters": None, "raw_log": None})
|
||||
test("None вместо списков", True)
|
||||
except Exception:
|
||||
test("None вместо списков", False)
|
||||
|
||||
# Очень длинный car_info
|
||||
p = _build_diagnosis_prompt(
|
||||
{"vin": None, "dtc_stored": [], "dtc_pending": [], "parameters": [], "raw_log": []},
|
||||
car_info="A" * 5000,
|
||||
)
|
||||
test("car_info 5000 символов", "A" in p)
|
||||
|
||||
# ── 12. ELM — мусор, разрывы, протоколы ────────────────────────
|
||||
print("\n═══ 12. ELM — мусор, разрывы, протоколы ═══")
|
||||
|
||||
# HEX с символами кадра CAN (0: 1: и т.д.)
|
||||
r = parse_batch([{"cmd": "0902", "raw": "0: 49 02 01 57 56\n1: 57 5A 5A 5A 31 4B 5A\n2: 41 57 31 32 33 34 35 36", "decoded": ""}])
|
||||
test("CAN multi-frame с 0: 1:", r["vin"] == "WVWZZZ1KZAW123456")
|
||||
|
||||
# ELM ответил SEARCHING, потом данные, потом PROMPT
|
||||
r = parse_batch([{"cmd": "010C", "raw": "SEARCHING\nSEARCHING\n41 0C 0C A8", "decoded": ""}])
|
||||
test("SEARCHING перед данными — PID", "RPM" not in str(r.get("decoded", "")))
|
||||
|
||||
# Заголовок ISO-TP (длина)
|
||||
r = parse_batch([{"cmd": "0902", "raw": "10 14 49 02 01 57 56\n21 57 5A 5A 5A 31 4B\n22 5A 41 57 31 32 33 34 35 36", "decoded": ""}])
|
||||
test("ISO-TP заголовки 10/21/22", r["vin"] == "WVWZZZ1KZAW123456")
|
||||
|
||||
# ELM вернул UDP-подобный формат
|
||||
r = parse_batch([{"cmd": "03", "raw": "43 02 01 00 02 00 03 00 04 00", "decoded": ""}])
|
||||
test("много DTC в одном ответе", len(r["dtc_stored"]) >= 3)
|
||||
|
||||
# Дефолтный ответ ELM (пробелы, переводы)
|
||||
r = parse_batch([{"cmd": "0105", "raw": "\n\n\n 41 05 47 \n\n", "decoded": ""}])
|
||||
test("ELM с лишними переводами", not r.get("error"))
|
||||
|
||||
# CAN bus error
|
||||
r = parse_batch([{"cmd": "0105", "raw": "CAN ERROR", "decoded": ""}])
|
||||
test("CAN ERROR", not r.get("error"))
|
||||
|
||||
# Нет ответа — пустая строка
|
||||
r = parse_batch([{"cmd": "0105", "raw": "", "decoded": ""}])
|
||||
test("нет ответа — пусто", not r.get("error") and r["parameters"] == [])
|
||||
|
||||
# Garbage в raw (не HEX, не ELM)
|
||||
r = parse_batch([{"cmd": "0105", "raw": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", "decoded": ""}])
|
||||
test("Lorem Ipsum вместо ответа", len(r["parameters"]) == 0)
|
||||
|
||||
# VIN с пробелами в HEX
|
||||
r = parse_batch([{"cmd": "0902", "raw": "4902015756 575A 5A5A 314B5A 4157 3132 3334 3536", "decoded": ""}])
|
||||
test("VIN HEX с группами по 2-4 байта", r["vin"] == "WVWZZZ1KZAW123456")
|
||||
|
||||
# PID с отрицательным значением
|
||||
r = parse_batch([{"cmd": "0111", "raw": "", "decoded": "Дроссель: 0.0 %"}])
|
||||
test("дроссель 0%", len(r["parameters"]) == 1)
|
||||
|
||||
# PID с большим значением
|
||||
r = parse_batch([{"cmd": "0105", "raw": "", "decoded": "ОЖ: 127 °C"}])
|
||||
test("ОЖ 127°C", len(r["parameters"]) == 1)
|
||||
|
||||
# Ответ содержит именованный PID с ':'
|
||||
r = parse_batch([{"cmd": "010B", "raw": "", "decoded": "MAP: 101 кПа"}])
|
||||
test("MAP parsed", len(r["parameters"]) == 1)
|
||||
|
||||
# ACK ответ
|
||||
r = parse_batch([{"cmd": "ATSP0", "raw": "OK", "decoded": "OK"}])
|
||||
test("AT OK — не данные", r["parameters"] == [])
|
||||
|
||||
# ── 13. Идемпотентность ─────────────────────────────────────────
|
||||
print("\n═══ 13. Идемпотентность ═══")
|
||||
|
||||
db3 = Database(TEST_DB)
|
||||
|
||||
# Симуляция: DTC upload с request_id 2 раза
|
||||
dtc_req_id = "dtc-test-uuid-001"
|
||||
dtc_first = {"codes": [{"code": "P0301", "desc": "Пропуски зажигания"}], "count": 1}
|
||||
|
||||
# Сохраняем первый раз (как сделал бы /dtc/upload)
|
||||
db3.save_session(
|
||||
client_info={"device_uuid": "idemp-test"},
|
||||
responses=[{"decoded": "DTC stored: P0301"}],
|
||||
request_id=dtc_req_id,
|
||||
response_json={"codes": dtc_first["codes"], "count": dtc_first["count"]},
|
||||
)
|
||||
test("DTC: первый save с request_id", True)
|
||||
|
||||
# Повтор — не должен создать дубликат
|
||||
count_before = len(db3.get_recent_sessions(100))
|
||||
db3.save_session(
|
||||
client_info={"device_uuid": "idemp-test"},
|
||||
responses=[{"decoded": "DTC stored: P0301"}],
|
||||
request_id=dtc_req_id,
|
||||
response_json={"codes": [{"code": "P0999", "desc": "НИКОГДА"}], "count": 0}, # другой ответ
|
||||
)
|
||||
count_after = len(db3.get_recent_sessions(100))
|
||||
test("DTC: дубликат не создан", count_before == count_after)
|
||||
|
||||
# Кэш возвращает ПЕРВЫЙ результат, не последний
|
||||
cached = db3.get_cached_response(dtc_req_id)
|
||||
test("DTC: кэш вернул оригинал", cached is not None and cached["codes"][0]["code"] == "P0301")
|
||||
|
||||
# Upload сессии с разными request_id — не конфликтуют
|
||||
for i in range(5):
|
||||
db3.save_session(
|
||||
client_info={"device_uuid": f"multi-idemp-{i}"},
|
||||
responses=[],
|
||||
request_id=f"multi-req-{i}",
|
||||
response_json={"i": i},
|
||||
)
|
||||
test("5 разных request_id — все сохранены",
|
||||
len(db3.get_recent_sessions(10)) == count_after + 5)
|
||||
|
||||
db3.close()
|
||||
if TEST_DB.exists():
|
||||
TEST_DB.unlink()
|
||||
|
||||
# ── 14. DTC модуль (api/dtc.py) ─────────────────────────────────
|
||||
print("\n═══ 14. DTC модуль ═══")
|
||||
|
||||
# Загрузка словаря из dtc.py
|
||||
dtc = _load_dtc_dict()
|
||||
test("dtc загружен из dtc.py", len(dtc) > 100)
|
||||
|
||||
# Известные коды
|
||||
test("P0301 = пропуски", "Пропуски" in dtc.get("P0301", ""))
|
||||
test("P0420 = катализатор", "катализатор" in dtc.get("P0420", "").lower())
|
||||
|
||||
# Протокольные коды
|
||||
test("ABS C0000", "ABS" in dtc.get("C0000", ""))
|
||||
test("шина U0000", "шина" in dtc.get("U0000", "").lower())
|
||||
test("кузов B0000", "кузов" in dtc.get("B0000", "").lower())
|
||||
|
||||
# ── 15. Ping модуль (api/ping.py) ────────────────────────────────
|
||||
print("\n═══ 15. Ping модуль ═══")
|
||||
|
||||
from api.ping import _ping_llm_cache
|
||||
|
||||
# Изначально кэш пуст
|
||||
test("ping кэш пуст изначально", _ping_llm_cache == {})
|
||||
|
||||
# ── 16. State модуль (obd/state.py) ─────────────────────────────
|
||||
print("\n═══ 16. State ELM ═══")
|
||||
|
||||
from obd.state import State, Rsp
|
||||
|
||||
# State enum
|
||||
test("State.UNDEFINED", State.UNDEFINED is not None)
|
||||
test("State.READY", State.READY is not None)
|
||||
test("State.ERROR", State.ERROR is not None)
|
||||
|
||||
# Rsp.identify
|
||||
test("Rsp.identify OK", Rsp.identify("OK") == Rsp.OK)
|
||||
test("Rsp.identify BUS ERROR", Rsp.identify("BUS ERROR") == Rsp.BUS_ERROR)
|
||||
test("Rsp.identify SEARCHING", Rsp.identify("SEARCHING...") == Rsp.SEARCHING)
|
||||
test("Rsp.identify NO DATA", Rsp.identify("NO DATA") == Rsp.NODATA)
|
||||
test("Rsp.identify CAN ERROR", Rsp.identify("CAN ERROR") == Rsp.CAN_ERROR)
|
||||
test("Rsp.identify BUFFER FULL", Rsp.identify("BUFFER FULL") == Rsp.BUFFER_FULL)
|
||||
test("Rsp.identify STOPPED", Rsp.identify("STOPPED") == Rsp.STOPPED)
|
||||
test("Rsp.identify UNABLE", Rsp.identify("UNABLE TO CONNECT") == Rsp.UNABLE)
|
||||
test("Rsp.identify RX ERROR", Rsp.identify("RX ERROR") == Rsp.RX_ERROR)
|
||||
test("Rsp.identify DATA ERROR", Rsp.identify("DATA ERROR") == Rsp.DATA_ERROR)
|
||||
|
||||
# PROMPT
|
||||
test("Rsp.identify PROMPT >", Rsp.identify(">") == Rsp.PROMPT)
|
||||
|
||||
# UNKNOWN — данные
|
||||
test("Rsp.identify UNKNOWN — данные", Rsp.identify("41 0C 0C A8") == Rsp.UNKNOWN)
|
||||
test("Rsp.identify UNKNOWN — VIN", Rsp.identify("49 02 01 57 56") == Rsp.UNKNOWN)
|
||||
|
||||
# Нижний регистр, пробелы
|
||||
test("Rsp.identify lower case", Rsp.identify("bus error") == Rsp.BUS_ERROR)
|
||||
test("Rsp.identify пробелы", Rsp.identify(" OK ") == Rsp.OK)
|
||||
|
||||
# ── 17. Timing модуль (obd/timing.py) ────────────────────────────
|
||||
print("\n═══ 17. Timing ELM ═══")
|
||||
|
||||
from obd.timing import AdaptiveTiming
|
||||
|
||||
t = AdaptiveTiming()
|
||||
test("timing DEFAULT", t.ms == 500)
|
||||
test("atst = ms/4", t.atst == 125)
|
||||
|
||||
t.increase()
|
||||
test("timing увеличился", t.ms > 500)
|
||||
|
||||
t.decrease()
|
||||
test("timing уменьшился", t.ms >= 500)
|
||||
|
||||
t.reset()
|
||||
test("timing сброс", t.ms == 500)
|
||||
|
||||
# Многократное увеличение
|
||||
for _ in range(100):
|
||||
t.increase()
|
||||
test("timing MAX не превышен", t.ms <= 2000)
|
||||
|
||||
# Многократное уменьшение
|
||||
for _ in range(100):
|
||||
t.decrease()
|
||||
test("timing MIN не превышен", t.ms >= 50)
|
||||
|
||||
t2 = AdaptiveTiming()
|
||||
t2.increase()
|
||||
t2.increase()
|
||||
v = t2.ms
|
||||
t2.reset()
|
||||
test("reset после increase", t2.ms == 500 and v > 500)
|
||||
|
||||
atst = t2.atst
|
||||
test("atst >= 1", atst >= 1)
|
||||
|
||||
# ── 18. DB — только sessions (мёртвые таблицы удалены) ──────────
|
||||
print("\n═══ 18. DB чистота ═══")
|
||||
|
||||
import sqlite3
|
||||
db4 = Database(TEST_DB)
|
||||
tables = [r[0] for r in db4.conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()]
|
||||
test("нет мёртвых таблиц (cars,tokens)", not any(t in tables for t in ["cars", "diagnostic_tokens", "dtc_codes"]))
|
||||
|
||||
# Убедиться что мёртвые методы вызывают AttributeError
|
||||
test("get_or_create_car удалён", not hasattr(db4, "get_or_create_car"))
|
||||
test("create_token удалён", not hasattr(db4, "create_token"))
|
||||
test("add_llm_message удалён", not hasattr(db4, "add_llm_message"))
|
||||
test("add_parameter удалён", not hasattr(db4, "add_parameter"))
|
||||
test("add_dtc удалён", not hasattr(db4, "add_dtc"))
|
||||
|
||||
db4.close()
|
||||
if TEST_DB.exists():
|
||||
TEST_DB.unlink()
|
||||
+17
-76
@@ -1,33 +1,35 @@
|
||||
"""Elmer Web UI — Flask-приложение для локального тестирования.
|
||||
"""elmAI — точка входа Flask-приложения.
|
||||
|
||||
Заодно прототип будущего серверного API.
|
||||
Запуск: DEEPSEEK_API_KEY=sk-... python web/app.py
|
||||
Открыть: http://localhost:5005
|
||||
Запуск через gunicorn:
|
||||
gunicorn -w 4 -b 127.0.0.1:8000 web.app:app
|
||||
|
||||
Структура модулей:
|
||||
api/ — REST-эндпоинты, БД, скрипты
|
||||
brain/ — LLM-клиент, промпты
|
||||
obd/ — ELM327-протокол (AndrOBD)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
# Добавляем корень проекта в PYTHONPATH
|
||||
# Добавляем корень проекта в PYTHONPATH для импорта api/, brain/, obd/
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from flask import Flask, jsonify, redirect, render_template, request, send_from_directory
|
||||
|
||||
from elmer.config import load
|
||||
from elmer.db import Database
|
||||
from elmer.diagnose import Diagnoser
|
||||
from elmer.elm import ELM327
|
||||
from elmer.prompts import SYSTEM_PROMPT, build_user_prompt
|
||||
from web.raw_endpoint import register as register_raw_endpoint
|
||||
from web.script_endpoint import register as register_script_endpoint
|
||||
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
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s")
|
||||
|
||||
app = Flask(__name__)
|
||||
config = load()
|
||||
register_raw_endpoint(app)
|
||||
register_script_endpoint(app)
|
||||
register_api(app)
|
||||
register_dtc(app)
|
||||
register_ping(app)
|
||||
|
||||
|
||||
@app.route("/")
|
||||
@@ -42,67 +44,6 @@ def download_apk():
|
||||
return send_from_directory("static", "app-debug.apk", as_attachment=True, download_name="elmer.apk")
|
||||
|
||||
|
||||
@app.route("/api/diagnose", methods=["POST"])
|
||||
def diagnose():
|
||||
"""Подключается к ELM327, читает данные, отправляет в LLM, возвращает результат."""
|
||||
elm_cfg = config["elm327"]
|
||||
|
||||
# ── 1. ELM327 ────────────────────────────────
|
||||
try:
|
||||
elm = ELM327(port=elm_cfg["port"], baudrate=elm_cfg["baudrate"])
|
||||
except Exception as e:
|
||||
return jsonify({"error": f"Не удалось открыть порт {elm_cfg['port']}: {e}"}), 500
|
||||
|
||||
if not elm.init():
|
||||
elm.close()
|
||||
return jsonify({"error": "ELM327 не ответил на ATZ"}), 500
|
||||
|
||||
# ── 2. VIN ──────────────────────────────────
|
||||
vin = elm.read_vin()
|
||||
if not vin:
|
||||
elm.close()
|
||||
return jsonify({"error": "Не удалось прочитать VIN (режим 09 не поддерживается?)"}), 500
|
||||
|
||||
# ── 3. Ошибки ───────────────────────────────
|
||||
dtc_codes = elm.read_dtc_codes("03") + elm.read_dtc_codes("07")
|
||||
|
||||
# ── 4. Параметры ────────────────────────────
|
||||
parameters = elm.read_all_pids(config.get("pids"))
|
||||
|
||||
elm.close()
|
||||
|
||||
# ── 5. DeepSeek ─────────────────────────────
|
||||
api_key = config["deepseek"]["api_key"]
|
||||
if not api_key:
|
||||
return jsonify({"error": "DEEPSEEK_API_KEY не задан"}), 500
|
||||
|
||||
diagnoser = Diagnoser(
|
||||
api_key=api_key,
|
||||
model=config["deepseek"].get("model", "deepseek-chat"),
|
||||
)
|
||||
user_prompt = build_user_prompt(vin, dtc_codes, parameters)
|
||||
answer = diagnoser.diagnose(SYSTEM_PROMPT, user_prompt)
|
||||
|
||||
# ── 6. SQLite ───────────────────────────────
|
||||
db = Database()
|
||||
car_id = db.get_or_create_car(vin)
|
||||
token_id = db.create_token(car_id)
|
||||
for dtc in dtc_codes:
|
||||
db.add_dtc(token_id, dtc["code"], dtc.get("description", ""), dtc["status"])
|
||||
for p in parameters:
|
||||
db.add_parameter(token_id, p["pid_code"], p["name"], p["value"], p["unit"])
|
||||
db.add_llm_message(token_id, "user", user_prompt)
|
||||
db.add_llm_message(token_id, "assistant", answer)
|
||||
|
||||
return jsonify({
|
||||
"vin": vin,
|
||||
"dtc_codes": dtc_codes,
|
||||
"parameters": parameters,
|
||||
"diagnosis": answer,
|
||||
"token_id": token_id,
|
||||
})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"🌐 Elmer Web: http://localhost:5005")
|
||||
print(f"🌐 elmAI Web: http://localhost:5005")
|
||||
app.run(host="0.0.0.0", port=5005, debug=False)
|
||||
|
||||
@@ -1,350 +0,0 @@
|
||||
"""Эндпоинт /api/v1/raw-obd — приём сырых ответов, парсинг, управление сессией.
|
||||
|
||||
Сервер — мозг. Знает протокол ELM327. Клиент — тупая труба.
|
||||
|
||||
Протокол:
|
||||
Клиент → {"raw": "..."} → Сервер
|
||||
Сервер → {"cmd": "ATZ"} → Клиент пишет в BT
|
||||
... итерации ...
|
||||
Сервер → {"cmd": null} → сессия завершена, диагноз готов
|
||||
|
||||
Сессия:
|
||||
1. INIT: ATZ, ATE0, ATL0, ATSP0, ATH1
|
||||
2. VIN: 0902
|
||||
3. DTC: 03 (stored), 07 (pending)
|
||||
4. PIDS: 0105, 010C, 010D, ...
|
||||
5. LLM: отправка в DeepSeek
|
||||
6. LLM_LOOP: если LLM хочет ещё данных → ещё команды
|
||||
7. DONE
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger("elmer.raw")
|
||||
|
||||
# ── Сессии ─────────────────────────────────────────────
|
||||
|
||||
# session_id → {state, commands[], responses[], data{}}
|
||||
sessions: dict[str, dict] = {}
|
||||
sessions_lock = threading.Lock()
|
||||
|
||||
|
||||
def _new_session() -> str:
|
||||
"""Создаёт новую сессию, возвращает ID."""
|
||||
import uuid
|
||||
sid = uuid.uuid4().hex[:12]
|
||||
|
||||
# Очередь команд для инициализации
|
||||
init_cmds = ["ATZ", "ATE0", "ATL0", "ATSP0", "ATH1"]
|
||||
|
||||
with sessions_lock:
|
||||
sessions[sid] = {
|
||||
"state": "INIT",
|
||||
"created": datetime.now(timezone.utc).isoformat(),
|
||||
"queue": list(init_cmds), # команды для отправки
|
||||
"responses": [], # сырые ответы
|
||||
"data": { # распарсенные данные
|
||||
"vin": None,
|
||||
"dtc_stored": [],
|
||||
"dtc_pending": [],
|
||||
"pids": [],
|
||||
},
|
||||
"llm_history": [],
|
||||
"diagnosis": None,
|
||||
}
|
||||
return sid
|
||||
|
||||
|
||||
def _parse_response(raw: str, data: dict) -> str | None:
|
||||
"""Парсит сырой ответ ELM327, заполняет data. Возвращает None или описание."""
|
||||
raw = raw.strip().upper()
|
||||
|
||||
# OK / ?>
|
||||
if raw in ("OK", "?", "NO DATA", "SEARCHING...", "STOPPED", "READY"):
|
||||
return raw
|
||||
|
||||
# ELM327 version
|
||||
if raw.startswith("ELM"):
|
||||
data["elm_version"] = raw
|
||||
return f"ELM: {raw}"
|
||||
|
||||
# VIN response (mode 09 PID 02): "014 0:49 02 01 57 56 57..."
|
||||
if "49 02" in raw or "49:02" in raw:
|
||||
hex_str = re.sub(r".*49.?02.?01", "", raw.replace("\n", " ").replace(":", " ")).strip()
|
||||
hex_bytes = hex_str.split()
|
||||
vin = ""
|
||||
for h in hex_bytes:
|
||||
try:
|
||||
vin += chr(int(h, 16))
|
||||
except (ValueError, OverflowError):
|
||||
pass
|
||||
if len(vin) == 17:
|
||||
data["vin"] = vin
|
||||
return f"VIN: {vin}"
|
||||
return f"VIN partial: {vin}"
|
||||
|
||||
# DTC response (mode 43/47)
|
||||
if raw.startswith("43") or raw.startswith("47"):
|
||||
mode = "stored" if raw.startswith("43") else "pending"
|
||||
# Парсим коды: 43 01 33 00... → P0301
|
||||
hex_bytes = re.sub(r"^4[37]\s*", "", raw).split()
|
||||
i = 1 # пропускаем байт количества
|
||||
codes = []
|
||||
while i + 1 < len(hex_bytes):
|
||||
a, b = int(hex_bytes[i], 16), int(hex_bytes[i + 1], 16)
|
||||
prefix = {0: "P", 1: "C", 2: "B", 3: "U"}.get(a >> 6, "?")
|
||||
code = f"{prefix}{(a>>4)&3}{a&15}{b>>4:X}{b&15:X}"
|
||||
if code != "P0000":
|
||||
codes.append(code)
|
||||
if mode == "stored":
|
||||
data["dtc_stored"].append(code)
|
||||
else:
|
||||
data["dtc_pending"].append(code)
|
||||
i += 2
|
||||
return f"DTC {mode}: {codes}"
|
||||
|
||||
# PID response (mode 41): "41 05 5A"
|
||||
if raw.startswith("41"):
|
||||
parts = raw.split()
|
||||
if len(parts) >= 3:
|
||||
pid = parts[1]
|
||||
hex_vals = parts[2:]
|
||||
formulas = {
|
||||
"05": lambda b: int(b, 16) - 40,
|
||||
"0C": lambda b: (int(b[0], 16) * 256 + int(b[1], 16)) / 4,
|
||||
"0D": lambda b: int(b[0], 16),
|
||||
"11": lambda b: int(b[1], 16) * 100 / 255 if len(b) > 1 else int(b[0], 16) * 100 / 255,
|
||||
"0B": lambda b: int(b[0], 16),
|
||||
"0F": lambda b: int(b[0], 16) - 40,
|
||||
"1F": lambda b: int(b[0], 16) * 256 + (int(b[1], 16) if len(b) > 1 else 0),
|
||||
"04": lambda b: int(b[0], 16) * 100 / 255,
|
||||
"06": lambda b: (int(b[0], 16) - 128) * 100 / 128,
|
||||
"07": lambda b: (int(b[0], 16) - 128) * 100 / 128,
|
||||
}
|
||||
if pid in formulas:
|
||||
try:
|
||||
val = round(formulas[pid](hex_vals), 1)
|
||||
data["pids"].append({"pid": pid, "value": val})
|
||||
return f"PID {pid}: {val}"
|
||||
except Exception:
|
||||
pass
|
||||
return f"PID {pid} raw: {hex_vals}"
|
||||
|
||||
# AT-ответы (протокол)
|
||||
if raw.startswith("AUTO") or "ISO" in raw or "SAE" in raw:
|
||||
data["protocol"] = raw
|
||||
return f"Protocol: {raw}"
|
||||
|
||||
return raw
|
||||
|
||||
|
||||
def _next_commands(sess: dict) -> list[str] | None:
|
||||
"""Определяет следующие команды в зависимости от состояния сессии."""
|
||||
state = sess["state"]
|
||||
data = sess["data"]
|
||||
|
||||
if state == "INIT":
|
||||
# Инициализация завершена → запрос VIN
|
||||
sess["state"] = "VIN"
|
||||
return ["0902"]
|
||||
|
||||
if state == "VIN":
|
||||
sess["state"] = "DTC_STORED"
|
||||
return ["03"]
|
||||
|
||||
if state == "DTC_STORED":
|
||||
sess["state"] = "DTC_PENDING"
|
||||
return ["07"]
|
||||
|
||||
if state == "DTC_PENDING":
|
||||
sess["state"] = "PIDS"
|
||||
# Стандартные PID
|
||||
return ["0105", "010C", "010D", "0111", "010B", "010F", "011F", "0104", "0106", "0107"]
|
||||
|
||||
if state == "PIDS":
|
||||
# Все данные собраны → LLM
|
||||
sess["state"] = "LLM"
|
||||
return None # Нет команд, вызываем LLM
|
||||
|
||||
if state == "LLM":
|
||||
sess["state"] = "DONE"
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ── Flask endpoint ─────────────────────────────────────
|
||||
|
||||
def register(app):
|
||||
"""Регистрирует /api/v1/raw-obd на Flask-приложении."""
|
||||
|
||||
@app.route("/api/v1/raw-obd", methods=["POST"])
|
||||
def raw_obd():
|
||||
from flask import request, jsonify
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not data or "raw" not in data:
|
||||
return jsonify({"error": "missing 'raw'"}), 400
|
||||
|
||||
raw = data["raw"].strip()
|
||||
session_id = data.get("session")
|
||||
|
||||
# Новая сессия?
|
||||
if not session_id:
|
||||
session_id = _new_session()
|
||||
logger.info(f"[{session_id}] NEW SESSION")
|
||||
|
||||
with sessions_lock:
|
||||
sess = sessions.get(session_id)
|
||||
if not sess:
|
||||
session_id = _new_session()
|
||||
sess = sessions[session_id]
|
||||
|
||||
sess["responses"].append(raw)
|
||||
|
||||
# Парсим ответ
|
||||
parsed = _parse_response(raw, sess["data"])
|
||||
logger.info(f"[{session_id}] {parsed}")
|
||||
|
||||
# SEARCHING/NO DATA — ждём, не продвигаем стейт
|
||||
is_skip = raw.startswith("SEARCHING") or raw in ("NO DATA", "STOPPED", "?")
|
||||
if is_skip:
|
||||
sess["retries"] = sess.get("retries", 0) + 1
|
||||
if sess["retries"] > 3:
|
||||
logger.info(f"[{session_id}] Giving up after {sess['retries']} retries")
|
||||
sess["retries"] = 0
|
||||
# Продвигаем принудительно (ниже)
|
||||
else:
|
||||
# Ждём — не шлём команду, ELM327 сам ответит когда готов
|
||||
return jsonify({"cmd": None, "session": session_id, "state": sess["state"], "msg": "Жду..."})
|
||||
|
||||
# Если есть очередь — отдаём следующую
|
||||
if sess["queue"]:
|
||||
cmd = sess["queue"].pop(0)
|
||||
return jsonify({"cmd": cmd, "session": session_id, "state": sess["state"]})
|
||||
|
||||
sess["retries"] = 0
|
||||
|
||||
# Определяем что дальше
|
||||
next_cmds = _next_commands(sess)
|
||||
if next_cmds:
|
||||
sess["queue"] = list(next_cmds)
|
||||
cmd = sess["queue"].pop(0)
|
||||
return jsonify({"cmd": cmd, "session": session_id, "state": sess["state"]})
|
||||
|
||||
# LLM фаза — если нет ключа, возвращаем сырые данные
|
||||
if sess["state"] == "LLM":
|
||||
from elmer.config import load
|
||||
cfg = load()
|
||||
api_key = cfg["deepseek"]["api_key"]
|
||||
|
||||
if not api_key:
|
||||
# Без LLM — форматируем декодированные данные
|
||||
data = sess["data"]
|
||||
lines = []
|
||||
if data.get("vin"): lines.append(f"VIN: {data['vin']}")
|
||||
if data.get("dtc_stored"): lines.append(f"Ошибки: {', '.join(data['dtc_stored'])}")
|
||||
if data.get("dtc_pending"): lines.append(f"Pending: {', '.join(data['dtc_pending'])}")
|
||||
if data.get("pids"):
|
||||
names = {"05": "ОЖ", "0C": "RPM", "0D": "Скорость", "11": "Дроссель",
|
||||
"0B": "MAP", "0F": "IAT", "1F": "Время", "04": "Нагрузка",
|
||||
"06": "STFT", "07": "LTFT"}
|
||||
for p in data["pids"]:
|
||||
n = names.get(p["pid"], p["pid"])
|
||||
lines.append(f"{n}: {p['value']}")
|
||||
sess["diagnosis"] = "\n".join(lines) if lines else "Нет данных"
|
||||
sess["state"] = "DONE"
|
||||
logger.info(f"[{session_id}] No LLM — returning raw data")
|
||||
else:
|
||||
sess["state"] = "LLM_WAIT"
|
||||
import threading as th
|
||||
th.Thread(target=_call_llm, args=(session_id,), daemon=True).start()
|
||||
return jsonify({
|
||||
"cmd": None,
|
||||
"session": session_id,
|
||||
"state": "LLM",
|
||||
"msg": "Анализирую..."
|
||||
})
|
||||
|
||||
# Готово
|
||||
return jsonify({
|
||||
"cmd": None,
|
||||
"session": session_id,
|
||||
"state": sess["state"],
|
||||
"diagnosis": sess.get("diagnosis"),
|
||||
})
|
||||
|
||||
|
||||
def _call_llm(session_id: str):
|
||||
"""Вызывает DeepSeek с собранными данными."""
|
||||
from elmer.config import load
|
||||
from elmer.diagnose import Diagnoser
|
||||
from elmer.prompts import SYSTEM_PROMPT
|
||||
|
||||
with sessions_lock:
|
||||
sess = sessions.get(session_id)
|
||||
if not sess:
|
||||
return
|
||||
data = sess["data"]
|
||||
|
||||
config = load()
|
||||
api_key = config["deepseek"]["api_key"]
|
||||
if not api_key:
|
||||
logger.error(f"[{session_id}] No DEEPSEEK_API_KEY")
|
||||
return
|
||||
|
||||
# Собираем промпт
|
||||
parts = [f"VIN: {data.get('vin', 'неизвестен')}"]
|
||||
if data.get("dtc_stored"):
|
||||
parts.append(f"Ошибки (stored): {', '.join(data['dtc_stored'])}")
|
||||
if data.get("dtc_pending"):
|
||||
parts.append(f"Ошибки (pending): {', '.join(data['dtc_pending'])}")
|
||||
if data.get("pids"):
|
||||
parts.append("Параметры: " + ", ".join(
|
||||
f"{p['pid']}={p['value']}" for p in data["pids"]))
|
||||
|
||||
user_prompt = "\n".join(parts)
|
||||
|
||||
try:
|
||||
diagnoser = Diagnoser(api_key=api_key, model=config["deepseek"].get("model", "deepseek-chat"))
|
||||
answer = diagnoser.diagnose(SYSTEM_PROMPT, user_prompt)
|
||||
|
||||
with sessions_lock:
|
||||
if sess:
|
||||
sess["diagnosis"] = answer
|
||||
sess["state"] = "DONE"
|
||||
|
||||
logger.info(f"[{session_id}] Diagnosis ready ({len(answer)} chars)")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[{session_id}] LLM error: {e}")
|
||||
with sessions_lock:
|
||||
if sess:
|
||||
sess["diagnosis"] = f"Ошибка: {e}"
|
||||
sess["state"] = "DONE"
|
||||
|
||||
@app.route("/api/v1/session/<session_id>", methods=["GET"])
|
||||
def get_session(session_id):
|
||||
"""Получить статус и диагноз сессии."""
|
||||
from flask import jsonify
|
||||
with sessions_lock:
|
||||
sess = sessions.get(session_id)
|
||||
if not sess:
|
||||
return jsonify({"error": "session not found"}), 404
|
||||
return jsonify({
|
||||
"session": session_id,
|
||||
"state": sess["state"],
|
||||
"diagnosis": sess.get("diagnosis"),
|
||||
"data": {
|
||||
"vin": sess["data"].get("vin"),
|
||||
"dtc_stored": sess["data"].get("dtc_stored", []),
|
||||
"dtc_pending": sess["data"].get("dtc_pending", []),
|
||||
"pids": sess["data"].get("pids", []),
|
||||
},
|
||||
"created": sess.get("created"),
|
||||
})
|
||||
|
||||
return app
|
||||
+128
-13
@@ -5,6 +5,7 @@ POST /api/v1/session/upload — приём батча, LLM-анализ, воз
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from web.script_builder import build_default_script, build_full_script
|
||||
from web.script_parser import parse_batch, format_no_llm
|
||||
|
||||
@@ -63,6 +64,7 @@ def register(app):
|
||||
def upload_session():
|
||||
from flask import request, jsonify
|
||||
from elmer.config import load
|
||||
from elmer.db import Database
|
||||
from elmer.diagnose import Diagnoser
|
||||
from elmer.prompts import SYSTEM_PROMPT
|
||||
|
||||
@@ -72,33 +74,146 @@ def register(app):
|
||||
|
||||
responses = data["responses"]
|
||||
logger.info(f"Upload: {len(responses)} responses")
|
||||
|
||||
# ── Информация о клиенте ──────────────────────
|
||||
client_info = data.get("client_info", {})
|
||||
client_info["client_ip"] = request.remote_addr
|
||||
client_info["real_ip"] = request.headers.get("X-Real-IP", "")
|
||||
client_info["user_agent"] = request.headers.get("User-Agent", "")
|
||||
client_info["content_length"] = request.content_length
|
||||
|
||||
parsed = parse_batch(responses)
|
||||
|
||||
cfg = load()
|
||||
api_key = cfg["llm"]["api_key"]
|
||||
model = cfg["llm"].get("model", "gpt-oss-120b")
|
||||
llm_available = bool(api_key)
|
||||
|
||||
llm_start = time.time()
|
||||
llm_success = False
|
||||
diagnosis = ""
|
||||
|
||||
if not api_key:
|
||||
return jsonify({
|
||||
"diagnosis": format_no_llm(parsed),
|
||||
"parsed": _summary(parsed),
|
||||
})
|
||||
diagnosis = format_no_llm(parsed)
|
||||
else:
|
||||
diagnoser = Diagnoser(
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
base_url=cfg["llm"].get("base_url", "https://api.aillm.ru/v1"),
|
||||
)
|
||||
try:
|
||||
diagnosis = diagnoser.diagnose(SYSTEM_PROMPT, _build_diagnosis_prompt(parsed))
|
||||
llm_success = True
|
||||
except Exception as e:
|
||||
logger.warning(f"LLM failed: {e}")
|
||||
diagnosis = format_no_llm(parsed) + f"\n\n(LLM недоступен: {e})"
|
||||
|
||||
diagnoser = Diagnoser(
|
||||
api_key=api_key,
|
||||
model=cfg["llm"].get("model", "gpt-oss-120b"),
|
||||
base_url=cfg["llm"].get("base_url", "https://api.aillm.ru/v1"),
|
||||
)
|
||||
llm_duration_ms = int((time.time() - llm_start) * 1000)
|
||||
|
||||
# ── Сохранение в БД ───────────────────────────
|
||||
try:
|
||||
answer = diagnoser.diagnose(SYSTEM_PROMPT, _build_diagnosis_prompt(parsed))
|
||||
db = Database()
|
||||
db.save_session(
|
||||
client_info=client_info,
|
||||
responses=responses,
|
||||
diagnosis=diagnosis,
|
||||
llm_model=model,
|
||||
llm_duration_ms=llm_duration_ms,
|
||||
llm_success=llm_success,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"LLM failed: {e}")
|
||||
answer = format_no_llm(parsed) + f"\n\n(LLM недоступен: {e})"
|
||||
logger.error(f"DB save failed: {e}")
|
||||
|
||||
return jsonify({
|
||||
"diagnosis": answer,
|
||||
"diagnosis": diagnosis,
|
||||
"parsed": _summary(parsed),
|
||||
"llm_available": llm_available,
|
||||
"llm_success": llm_success,
|
||||
})
|
||||
|
||||
@app.route("/api/v1/chat", methods=["POST"])
|
||||
def chat():
|
||||
"""Свободный вопрос к LLM (без ELM)."""
|
||||
from flask import request, jsonify
|
||||
from elmer.config import load
|
||||
from elmer.diagnose import Diagnoser
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not data or "question" not in data:
|
||||
return jsonify({"error": "missing 'question'"}), 400
|
||||
|
||||
question = data["question"].strip()
|
||||
if not question:
|
||||
return jsonify({"answer": "Пустой вопрос."})
|
||||
|
||||
# История диалога
|
||||
history = data.get("history", [])
|
||||
history_text = ""
|
||||
if history:
|
||||
history_text = "## История диалога\n"
|
||||
for m in history[-10:]: # последние 10 сообщений
|
||||
role = "Водитель" if m.get("role") == "user" else "Автоэксперт"
|
||||
history_text += f"{role}: {m.get('content', '')}\n"
|
||||
history_text += "\n"
|
||||
|
||||
cfg = load()
|
||||
api_key = cfg["llm"]["api_key"]
|
||||
if not api_key:
|
||||
return jsonify({"answer": "LLM не настроен."})
|
||||
|
||||
prompt = (
|
||||
f"{history_text}"
|
||||
f"Ты — автоэксперт. Помни контекст диалога выше. "
|
||||
f"Отвечай КРАТКО, не более 20 строк. Без воды, только по делу.\n\n"
|
||||
f"Вопрос: {question}"
|
||||
)
|
||||
|
||||
try:
|
||||
diagnoser = Diagnoser(
|
||||
api_key=api_key,
|
||||
model=cfg["llm"].get("model", "gpt-oss-120b"),
|
||||
base_url=cfg["llm"].get("base_url", "https://api.aillm.ru/v1"),
|
||||
)
|
||||
answer = diagnoser.diagnose(
|
||||
"Ты — лаконичный автоэксперт. Помни контекст диалога. Отвечай кратко, максимум 20 строк.",
|
||||
prompt,
|
||||
)
|
||||
except Exception as e:
|
||||
answer = f"LLM недоступен: {e}"
|
||||
|
||||
return jsonify({"answer": answer})
|
||||
|
||||
@app.route("/api/v1/ping", methods=["GET"])
|
||||
def ping():
|
||||
"""Быстрая проверка доступности сервера."""
|
||||
return {"ok": True}
|
||||
|
||||
@app.route("/api/v1/ping-llm", methods=["GET"])
|
||||
def ping_llm():
|
||||
"""Быстрая проверка доступности LLM."""
|
||||
from flask import jsonify
|
||||
from elmer.config import load
|
||||
from elmer.diagnose import Diagnoser
|
||||
|
||||
cfg = load()
|
||||
api_key = cfg["llm"]["api_key"]
|
||||
if not api_key:
|
||||
return jsonify({"ok": False, "error": "no API key"})
|
||||
|
||||
t0 = time.time()
|
||||
try:
|
||||
diagnoser = Diagnoser(
|
||||
api_key=api_key,
|
||||
model=cfg["llm"].get("model", "gpt-oss-120b"),
|
||||
base_url=cfg["llm"].get("base_url", "https://api.aillm.ru/v1"),
|
||||
)
|
||||
diagnoser.diagnose("Отвечай одним словом.", "OK")
|
||||
ms = int((time.time() - t0) * 1000)
|
||||
return jsonify({"ok": True, "ms": ms})
|
||||
except Exception as e:
|
||||
ms = int((time.time() - t0) * 1000)
|
||||
return jsonify({"ok": False, "ms": ms, "error": str(e)[:100]})
|
||||
|
||||
|
||||
def _summary(p: dict) -> dict:
|
||||
return {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -4,22 +4,23 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Elmer — Диагностика авто</title>
|
||||
<title>elmAI — Диагностика авто</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🔧 Elmer</h1>
|
||||
<img src="/static/logo.png" alt="elmAI" style="width:96px;height:96px;border-radius:20px;margin-bottom:10px;">
|
||||
<h1>elmAI</h1>
|
||||
<p class="subtitle">Диагностика авто через ELM327 + ИИ</p>
|
||||
<p class="subtitle" style="font-size:12px;opacity:0.7;">v0.16.0-dev — 28 мая 2026</p>
|
||||
<p class="subtitle" style="font-size:12px;opacity:0.7;">v0.78.0-dev — 7 июня 2026</p>
|
||||
|
||||
<div class="card" style="text-align:center;margin-bottom:20px;">
|
||||
<p style="margin:0 0 10px 0;">📱 Скачай приложение на телефон:</p>
|
||||
<a href="/static/app-debug.apk" style="color:#ff6b35;font-size:18px;font-weight:bold;text-decoration:none;">
|
||||
⬇️ Скачать Elmer APK
|
||||
⬇️ Скачать elmAI APK
|
||||
</a>
|
||||
<p style="font-size:11px;opacity:0.6;margin:4px 0 0 0;">v0.16.0-dev • нажмите чтобы скачать</p>
|
||||
<p style="font-size:11px;opacity:0.6;margin:4px 0 0 0;">v0.78.0-dev • нажмите чтобы скачать</p>
|
||||
</div>
|
||||
|
||||
<!-- Кнопка десктоп-диагностики скрыта — только для разработчика с прямым ELM327 -->
|
||||
|
||||
Reference in New Issue
Block a user