67 changed files with 1749 additions and 5742 deletions
-50
View File
@@ -1,50 +0,0 @@
# Правила для 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. На вопросы — только отвечать
Если пользователь задал вопрос (в любой форме — утвердительной, вопросительной, с матом, без) — **ТОЛЬКО ответить**. Никаких изменений кода, файлов, конфигов. Даже если вопрос про баг — не чинить, а только объяснить.
-1
View File
@@ -3,4 +3,3 @@ __pycache__/
android/
web/static/app-debug.apk
creds.txt
token*.*
-57
View File
@@ -1,57 +0,0 @@
# Инструкция для 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
View File
@@ -1,267 +0,0 @@
# 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-слой (устарела) |
View File
-317
View File
@@ -1,317 +0,0 @@
"""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
View File
@@ -1,102 +0,0 @@
"""Эндпоинты 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
View File
@@ -1,141 +0,0 @@
"""Парсинг батча ответов 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
View File
@@ -1,67 +0,0 @@
"""Эндпоинты проверки доступности.
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
View File
@@ -1,316 +0,0 @@
"""Эндпоинты: скрипт, загрузка сессии, чат, пробинг 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
View File
@@ -1,133 +0,0 @@
"""Сборка диагностических скриптов.
Три уровня в зависимости от возможностей 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": "Время работы"},
],
}
View File
-99
View File
@@ -1,99 +0,0 @@
"""
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)
-91
View File
@@ -1,91 +0,0 @@
"""
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
View File
@@ -2,9 +2,9 @@
# Значения вида ${VAR} подставляются из переменных окружения
llm:
api_key: "sk-78ec529c1eba4ba69995091046c9fa33"
model: "deepseek-v4-flash"
base_url: "https://api.deepseek.com/v1"
api_key: "sk-ucI5YvOticoOQ9Kuj5K9mQ"
model: "gpt-oss-120b"
base_url: "https://api.aillm.ru/v1"
elm327:
# Bluetooth-порт (обычно /dev/rfcomm0 после pairing)
Regular → Executable
+38 -19
View File
@@ -1,12 +1,15 @@
#!/bin/bash
# Деплой Elmer на obdai.ru
# ВНИМАНИЕ: запускать НА СЕРВЕРЕ (5.172.178.213), не локально!
set -e
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 "=== Установка пакетов ==="
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
@@ -14,8 +17,26 @@ 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 сервис ==="
sudo cat > /etc/systemd/system/elmer.service << 'UNIT'
cat > /etc/systemd/system/elmer.service << 'UNIT'
[Unit]
Description=Elmer Flask API
After=network.target
@@ -31,7 +52,7 @@ WantedBy=multi-user.target
UNIT
echo "=== Nginx ==="
sudo cat > /etc/nginx/sites-available/elmer << 'NGX'
cat > /etc/nginx/sites-available/elmer << 'NGX'
server {
listen 80;
server_name obdai.ru www.obdai.ru ai.obdai.ru test.obdai.ru;
@@ -48,20 +69,18 @@ server {
}
NGX
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
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 (первичная настройка, потом не трогает) ==="
sudo certbot --nginx -d obdai.ru -d www.obdai.ru --non-interactive --agree-tos -m tazet@narod.ru || true
echo "=== SSL ==="
certbot --nginx -d obdai.ru -d www.obdai.ru --non-interactive --agree-tos -m tazet@narod.ru || true
echo "=== Запуск ==="
sudo systemctl daemon-reload
sudo systemctl enable elmer
sudo systemctl restart elmer
sudo systemctl restart nginx
systemctl daemon-reload
systemctl enable elmer
systemctl restart elmer
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
View File
@@ -1,159 +0,0 @@
# 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
+68 -124
View File
@@ -1,144 +1,88 @@
# Архитектура elmAI
# Архитектура Elmer (2026-05-25)
> v0.77.0-dev, 7 июня 2026
## Принцип: тонкий клиент
## Общая схема
Клиент ничего не знает о диагнозе. Только транспорт:
```
📱 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, статика
ELM327 ←Bluetooth SPP→ Android Client ←HTTP JSON→ Сервер ←API→ DeepSeek
```
## Структура сервера
## Режимы работы клиента
### Режим «опрос» (основной)
Клиент сам читает VIN + DTC + PID'ы, шлёт готовый JSON серверу.
Быстро: один HTTP-запрос на сессию.
### Режим «ретранслятор» (расширенный)
Сервер шлёт сырые AT-команды, клиент пересылает ответ.
Медленно (каждый PID — HTTP round-trip), но клиент вообще ничего не знает об OBD2.
## Универсальность
- Пользователь вводит URL сервера (или наш по умолчанию)
- Протокол HTTP/JSON документирован
- Любой backend может работать с этим клиентом
## Десктоп (Windows/Linux)
```
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
Браузер (Chrome) → локальный Flask → pyserial → ELM327
```
## Взаимодействие модулей
- Отдельного «приложения» для 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)
```
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
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
```
Каждый модуль можно тестировать отдельно. Циклических зависимостей нет.
### PostgreSQL (production)
Та же схема, миграция при переходе к production-серверу.
## API эндпоинты
## API (прототип)
| Метод | Путь | Описание | Время |
|---|---|---|---|
| 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с |
### 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"}]
}
## 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 # (устарел)
// Response (от сервера)
{
"diagnosis": "## Краткий диагноз\n...",
"token_id": 42
}
```
## CI/CD и деплой
## Безопасность
**Никаких сторонних 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` файлов |
**Версию менять одновременно во всех трёх местах.**
- Permissions Android: только BLUETOOTH + INTERNET
- Никаких SMS/контактов/файлов/звонков
- Пользователь видит permissions ДО установки (RuStore и sideload)
- Модерация RuStore — базовая проверка на вредоносный код
-65
View File
@@ -1,65 +0,0 @@
# Отчёт об аудите (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`, что стоит денег (токенов). Кэш есть, но при перезапуске сервера или по таймауту он всё равно будет жечь токены на "пустые" проверки.
-63
View File
@@ -1,63 +0,0 @@
# Задание: аудит проекта 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` с отчётом.
Формат:
```
## Найдено
### 🔴 Критичные
- описание бага, файл, строка, как исправить
### 🟡 Средние
...
### 🟢 Косметика
...
```
-128
View File
@@ -1,128 +0,0 @@
# Справочник 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
-105
View File
@@ -1,105 +0,0 @@
# Динамические тесты — архитектура
> 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 / акселерометр — не сейчас
- Автодетект фаз (разгон/сброс) — ручной СТАРТ/СТОП
- Отправка пачками в реальном времени — копим всё до СТОП
-144
View File
@@ -1,144 +0,0 @@
# Полевой тест 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. Скриншоты экрана (если можно)
-75
View File
@@ -1,75 +0,0 @@
# Инструкция по 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` в обоих репо
-25
View File
@@ -1,25 +0,0 @@
# 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 удалён и восстановлен (несколько раз)
-13
View File
@@ -1,13 +0,0 @@
# 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)
-21
View File
@@ -1,21 +0,0 @@
# 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
-26
View File
@@ -1,26 +0,0 @@
# 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 для сборки
-46
View File
@@ -1,46 +0,0 @@
# Планы на 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-анализ
-96
View File
@@ -1,96 +0,0 @@
# 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
View File
@@ -1,113 +0,0 @@
# Новая морда 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 → не нужен
-144
View File
@@ -1,144 +0,0 @@
# План правок по отчётам Опуса
> 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).
-146
View File
@@ -1,146 +0,0 @@
# Вопросы к 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`.
По каждому вопросу:
- 🔴 Критическая проблема (если есть)
- 🟡 Потенциальная проблема / улучшение
- 🟢 Всё ок
- Конкретные рекомендации с примерами кода где уместно
-280
View File
@@ -1,280 +0,0 @@
# Ответ 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).
-236
View File
@@ -1,236 +0,0 @@
# Ревью проекта 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.
- Стейт-машина ограничена по ретраям (нет бесконечных циклов).
+51
View File
@@ -0,0 +1,51 @@
# Сессия 2026-05-29 — Сравнение LLM, модели DeepSeek, модели aillm.ru
## Хронология
### 10:00 — Вопрос о модели LLM
- Используем `gpt-oss-120b` (120B open-source) на `api.aillm.ru/v1`
- Изначально задумано как DeepSeek, но провайдер — aillm.ru
### 10:15 — DeepSeek V4: модели и цены
**DeepSeek V4 Flash (`deepseek-v4-flash`):**
- Кеш hit: $0.0028 / 1M токенов
- Кеш miss: $0.14 / 1M
- Выход: $0.28 / 1M
- 1M контекст, 384K max output, 2500 concurrent
**DeepSeek V4 Pro (`deepseek-v4-pro`):**
- До 31.05 акция 75% off
- С 01.06 официальная цена = ¼ original (т.е. та же, что со скидкой)
- Кеш hit: $0.0145 / 1M
- Вход: $1.74 → фикс $0.435 / 1M
- Выход: $3.48 → фикс $0.87 / 1M
- 500 concurrent
### 10:30 — Создан `tools/compare_llm.py`
Скрипт сравнения трёх моделей на одинаковом промпте (VIN VW, DTC P0301+P0171+P0420, параметры ЭБУ).
### 10:45 — Запуск сравнения
| Модель | Время | Вход | Выход | Стоимость |
|--------|-------|------|-------|-----------|
| DeepSeek V4 Flash | 56.9с | 427 | 6,494 | $0.0019 |
| DeepSeek V4 Pro | 223.5с | 427 | 10,323 | $0.0030 |
| Aillm.ru (gpt-oss-120b) | 103.3с | 492 | 7,003 | ? |
- V4 Flash: быстрый, конкретика по VW, лучший price/quality
- V4 Pro: самый глубокий ответ (думал 223с — режим thinking)
- Aillm 120b: адекватно, структурированно, но медленнее Flash
**Вывод:** 120b юзабелен для прототипа, DeepSeek V4 Flash — оптимальная замена при переходе в прод.
### 11:00 — Модели aillm.ru
Запросом к `/v1/models` получен список:
- `gpt-oss-120b` ← текущая
- `qwen3-6-27b-fp8` (27B)
- `qwen3.5-9b` (9B)
- `whisper-large-v3-turbo` (речь)
Для диагноза альтернатив 120b нет.
### 11:15 — Dify-совместимость
aillm.ru отдаёт OpenAI-совместимый API → Dify подключается как кастомный провайдер.
-53
View File
@@ -1,53 +0,0 @@
# 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 на машине
-73
View File
@@ -1,73 +0,0 @@
# Полевой тест — диагностика на машине
> 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}` |
+3
View File
@@ -0,0 +1,3 @@
"""Elmer — анализ ошибок электроники автомобиля через ELM327 + LLM."""
__version__ = "0.1.0"
+218
View File
@@ -0,0 +1,218 @@
"""
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()
-2
View File
@@ -1,7 +1,6 @@
"""Загрузка конфигурации из config.yaml."""
import os
from functools import lru_cache
from pathlib import Path
import yaml
@@ -9,7 +8,6 @@ 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():
+153
View File
@@ -0,0 +1,153 @@
"""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]
+48
View File
@@ -0,0 +1,48 @@
"""Вызов 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
View File
@@ -0,0 +1,208 @@
"""Связь с 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}"
+230
View File
@@ -0,0 +1,230 @@
"""
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
+82
View File
@@ -0,0 +1,82 @@
"""Шаблоны промптов для 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)
-86
View File
@@ -1,86 +0,0 @@
#!/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
-78
View File
@@ -1,78 +0,0 @@
## 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 — НИЧЕГО не делать без прямого указания
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

-173
View File
@@ -1,173 +0,0 @@
# Морда 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` не используется
View File
-107
View File
@@ -1,107 +0,0 @@
"""
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
View File
@@ -1,143 +0,0 @@
"""
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]
-105
View File
@@ -1,105 +0,0 @@
"""
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()
-86
View File
@@ -1,86 +0,0 @@
"""
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
View File
@@ -1,195 +0,0 @@
"""
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)
-67
View File
@@ -1,67 +0,0 @@
"""Состояния и классификация ответов 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
-53
View File
@@ -1,53 +0,0 @@
"""Адаптивный таймаут ожидания ответа ЭБУ (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
View File
@@ -1,92 +0,0 @@
## 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 — НИЧЕГО не делать без прямого указания
-606
View File
@@ -1,606 +0,0 @@
"""
Сквозные тесты 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()
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""Compare three LLMs: DeepSeek V4 Flash, DeepSeek V4 Pro, Aillm.ru.
Usage:
DEEPSEEK_API_KEY=sk-... python tools/compare_llm.py
"""
import os
import sys
import time
from pathlib import Path
import requests
import yaml
TEST_DATA = {
"vin": "WVWZZZ1KZAW123456",
"dtc_codes": [
{"code": "P0301", "status": "stored", "description": "Misfire cylinder 1"},
{"code": "P0171", "status": "stored", "description": "System too lean bank 1"},
{"code": "P0420", "status": "pending", "description": "Catalyst efficiency low bank 1"},
],
"parameters": [
{"pid_code": "0105", "name": "coolant_temp", "value": 87.0, "unit": "C"},
{"pid_code": "010C", "name": "rpm", "value": 680, "unit": "rpm"},
{"pid_code": "010D", "name": "speed", "value": 0, "unit": "km/h"},
{"pid_code": "010B", "name": "map", "value": 35, "unit": "kPa"},
{"pid_code": "010F", "name": "iat", "value": 31, "unit": "C"},
{"pid_code": "0104", "name": "engine_load", "value": 18.0, "unit": "%"},
{"pid_code": "0106", "name": "stft_b1", "value": -8.6, "unit": "%"},
{"pid_code": "0107", "name": "ltft_b1", "value": -12.5, "unit": "%"},
{"pid_code": "0111", "name": "throttle_pos", "value": 12, "unit": "%"},
],
}
SYSTEM_PROMPT = (
"You are an auto diagnostic expert with 20 years of experience. "
"Analyze OBD2 error codes and ECU parameters. Give a DEEP, DETAILED analysis.\n\n"
"RULES:\n"
"1. Do NOT limit yourself to a brief summary - give FULL analysis of each error and parameter.\n"
"2. For each error: explain what it means, ALL possible causes (common to rare), "
"which parameters confirm/refute each version.\n"
"3. Analyze RELATIONSHIPS between errors and parameters.\n"
"4. Give confidence percentages for EACH conclusion.\n"
"5. If data is insufficient - list SPECIFIC PIDs to read additionally, explain why.\n"
"6. Suggest action plan: what to check FIRST (most likely and cheapest), THEN what.\n"
"7. For each action: HOW to check, WHAT to look for, normal/deviant values.\n"
"8. Add 'If not helped' section - plan B for each item.\n"
"9. NEVER give categorical commands 'replace part X' without 100% confidence. "
"Write 'check X before replacing Y'.\n"
"10. Write in Russian, accessible but TECHNICALLY PRECISE. Use tables where appropriate.\n\n"
"OUTPUT FORMAT:\n"
"## Diagnosis (detailed)\n...\n## Error analysis\n...\n## Parameter analysis\n...\n"
"## Relationships\n...\n## Action plan (by priority)\n...\n"
"## What data is missing\n...\n## Confidence\n..."
)
def build_user_prompt(data: dict) -> str:
lines = [f"**VIN:** {data['vin']}", ""]
if data["dtc_codes"]:
lines.append("**Error codes:**")
for d in data["dtc_codes"]:
lines.append(f"- {d['code']} ({d['status']}): {d.get('description', '')}")
lines.append("")
if data["parameters"]:
lines.append("**ECU parameters:**")
for p in data["parameters"]:
lines.append(f"- {p['name']}: {p['value']} {p['unit']}")
lines.append("")
lines.append("Conduct a full diagnosis.")
return "\n".join(lines)
def call_llm(api_key: str, base_url: str, model: str, messages: list[dict]) -> dict:
start = time.time()
resp = requests.post(
f"{base_url.rstrip('/')}/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": model,
"messages": messages,
"temperature": 0.3,
"max_tokens": 32000,
},
timeout=300,
)
elapsed = time.time() - start
resp.raise_for_status()
data = resp.json()
usage = data.get("usage", {})
return {
"content": data["choices"][0]["message"]["content"],
"elapsed": elapsed,
"prompt_tokens": usage.get("prompt_tokens", "?"),
"completion_tokens": usage.get("completion_tokens", "?"),
"total_tokens": usage.get("total_tokens", "?"),
}
def print_answer(label: str, result: dict | None):
if result is None:
return
print()
border = "=" * 20
print(f"{border} {label} {border}")
print()
content = result["content"]
print(content)
print()
def main():
config_path = Path(__file__).parent.parent / "config.yaml"
with open(config_path) as f:
cfg = yaml.safe_load(f)
aillm_key = cfg["llm"]["api_key"]
aillm_model = cfg["llm"].get("model", "gpt-oss-120b")
aillm_url = cfg["llm"].get("base_url", "https://api.aillm.ru/v1")
ds_key = os.environ.get("DEEPSEEK_API_KEY")
if not ds_key:
print("ERROR: DEEPSEEK_API_KEY not set")
sys.exit(1)
ds_url = "https://api.deepseek.com/v1"
user_text = build_user_prompt(TEST_DATA)
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_text},
]
# Print the prompt being sent
print("=" * 70)
print("PROMPT BEING SENT")
print("=" * 70)
print()
print("--- SYSTEM ---")
print(SYSTEM_PROMPT[:300] + "...")
print()
print("--- USER ---")
print(user_text)
print()
models = [
("DeepSeek V4 Flash", "deepseek-v4-flash", ds_key, ds_url),
("DeepSeek V4 Pro", "deepseek-v4-pro", ds_key, ds_url),
("Aillm.ru", aillm_model, aillm_key, aillm_url),
]
results = {}
for label, model, key, url in models:
print("-" * 70)
print(f" Calling {label} ({model})...", end=" ", flush=True)
try:
r = call_llm(key, url, model, messages)
results[label] = r
print(f"DONE {r['elapsed']:.1f}s "
f"in={r['prompt_tokens']} out={r['completion_tokens']}")
except Exception as e:
print(f"ERROR: {e}")
results[label] = None
print()
print("=" * 70)
print()
for label in [m[0] for m in models]:
print_answer(label, results[label])
# Summary table
print("=" * 70)
print("SUMMARY")
print("=" * 70)
print()
print(f"{'Model':<25} {'Time':>8} {'Input':>8} {'Output':>8} {'Cost':>12}")
print("-" * 65)
for label, _, key, url in models:
r = results[label]
if r is None:
print(f"{label:<25} {'FAIL':>8}")
else:
if "DeepSeek" in label:
cost = (r["prompt_tokens"] / 1_000_000 * 0.14 +
r["completion_tokens"] / 1_000_000 * 0.28)
cost_str = f"${cost:.6f}"
else:
cost_str = "?"
print(f"{label:<25} {r['elapsed']:>7.1f}s {r['prompt_tokens']:>8} "
f"{r['completion_tokens']:>8} {cost_str:>12}")
print()
if __name__ == "__main__":
main()
+76 -17
View File
@@ -1,35 +1,33 @@
"""elmAI — точка входа Flask-приложения.
"""Elmer Web UI — Flask-приложение для локального тестирования.
Запуск через gunicorn:
gunicorn -w 4 -b 127.0.0.1:8000 web.app:app
Структура модулей:
api/ — REST-эндпоинты, БД, скрипты
brain/ — LLM-клиент, промпты
obd/ — ELM327-протокол (AndrOBD)
Заодно прототип будущего серверного API.
Запуск: DEEPSEEK_API_KEY=sk-... python web/app.py
Открыть: http://localhost:5005
"""
import sys
import logging
from pathlib import Path
# Добавляем корень проекта в PYTHONPATH для импорта api/, brain/, obd/
# Добавляем корень проекта в PYTHONPATH
sys.path.insert(0, str(Path(__file__).parent.parent))
from flask import Flask, jsonify, redirect, render_template, request, send_from_directory
from api.config import load
from api.routes import register as register_api
from api.dtc import register as register_dtc
from api.ping import register as register_ping
from 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
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s")
app = Flask(__name__)
config = load()
register_api(app)
register_dtc(app)
register_ping(app)
register_raw_endpoint(app)
register_script_endpoint(app)
@app.route("/")
@@ -44,6 +42,67 @@ 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"🌐 elmAI Web: http://localhost:5005")
print(f"🌐 Elmer Web: http://localhost:5005")
app.run(host="0.0.0.0", port=5005, debug=False)
+350
View File
@@ -0,0 +1,350 @@
"""Эндпоинт /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
+13 -128
View File
@@ -5,7 +5,6 @@ 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
@@ -64,7 +63,6 @@ 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
@@ -74,146 +72,33 @@ 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:
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})"
return jsonify({
"diagnosis": format_no_llm(parsed),
"parsed": _summary(parsed),
})
llm_duration_ms = int((time.time() - llm_start) * 1000)
# ── Сохранение в БД ───────────────────────────
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"),
)
try:
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,
)
answer = diagnoser.diagnose(SYSTEM_PROMPT, _build_diagnosis_prompt(parsed))
except Exception as e:
logger.error(f"DB save failed: {e}")
logger.warning(f"LLM failed: {e}")
answer = format_no_llm(parsed) + f"\n\n(LLM недоступен: {e})"
return jsonify({
"diagnosis": diagnosis,
"diagnosis": answer,
"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.

Before

Width:  |  Height:  |  Size: 29 KiB

+5 -6
View File
@@ -4,23 +4,22 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>elmAI — Диагностика авто</title>
<title>Elmer — Диагностика авто</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<img src="/static/logo.png" alt="elmAI" style="width:96px;height:96px;border-radius:20px;margin-bottom:10px;">
<h1>elmAI</h1>
<h1>🔧 Elmer</h1>
<p class="subtitle">Диагностика авто через ELM327 + ИИ</p>
<p class="subtitle" style="font-size:12px;opacity:0.7;">v0.78.0-dev — 7 июня 2026</p>
<p class="subtitle" style="font-size:12px;opacity:0.7;">v0.16.0-dev — 28 мая 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;">
⬇️ Скачать elmAI APK
⬇️ Скачать Elmer APK
</a>
<p style="font-size:11px;opacity:0.6;margin:4px 0 0 0;">v0.78.0-dev • нажмите чтобы скачать</p>
<p style="font-size:11px;opacity:0.6;margin:4px 0 0 0;">v0.16.0-dev • нажмите чтобы скачать</p>
</div>
<!-- Кнопка десктоп-диагностики скрыта — только для разработчика с прямым ELM327 -->