Author SHA1 Message Date
Repinoid 0fcbb1ab26 bump: v0.1.1-dev — версия на сайте 2026-06-14 19:14:14 +04:00
Repinoid 900d44806c fix: elm-raw.apk через /static/ 2026-06-14 19:05:47 +04:00
Repinoid c229b730c6 feat: raw relay — очередь команд, эндпоинты, консоль, index.html 2026-06-14 18:53:24 +04:00
Repinoid d01028b12b feat: raw ELM327 console — сырой слой, API, интерактивная консоль 2026-06-14 18:24:27 +04:00
Repinoid 7d23a874e0 docs: резюме сессии — v1.18.0-dev, авто-подбор, нерешённая проблема 2026-06-14 16:13:52 +04:00
Repinoid 2ea989e5a3 bump v1.18.0-dev 2026-06-14 15:10:05 +04:00
Repinoid 658b161730 fix: старт 2000ms, шаг +500ms — агрессивнее 2026-06-14 15:01:26 +04:00
Repinoid 1c37ea029e bump v1.17.0-dev 2026-06-14 14:05:01 +04:00
Repinoid de54c1ff40 feat: POST /api/v1/test/next + авто-подбор 2026-06-14 14:05:00 +04:00
Repinoid f9c2152c9d fix: routes.py wait=1500, repeat=8 2026-06-14 13:58:16 +04:00
Repinoid 7c1391dcf4 test: wait=1500ms, 2 PID — минимальный тест связи 2026-06-14 13:57:35 +04:00
Repinoid 9952c4507f bump v1.16.0-dev 2026-06-13 20:59:52 +04:00
Repinoid 6e59fef093 feat: build_test_script + mode=test 2026-06-13 20:57:24 +04:00
Repinoid 71200709ab bump v1.15.0-dev 2026-06-13 20:40:35 +04:00
Repinoid ecb42ee8d7 bump v1.14.0-dev 2026-06-13 20:35:37 +04:00
Repinoid a6eb06618f bump v1.13.0-dev 2026-06-13 20:33:51 +04:00
Repinoid 2b1422352c bump v1.12.0-dev 2026-06-13 20:20:40 +04:00
Repinoid f988478336 bump v1.11.0-dev 2026-06-13 10:36:58 +04:00
Repinoid bc928defdb docs: запрос Claude v2 — СТРОГО только анализ, код не менять 2026-06-13 10:27:30 +04:00
Repinoid 097740f348 fix: remove harmful OBD retries and improve ELM v1.5 stability 2026-06-13 10:21:51 +04:00
Repinoid e380efa98c bump v1.10.0-dev 2026-06-13 10:19:04 +04:00
Repinoid f5d2472184 docs: запрос Claude v2 — почему v1.9.0 хуже v1.3.0 2026-06-13 10:18:13 +04:00
Repinoid 5eeda440be bump v1.9.0-dev 2026-06-10 22:44:43 +04:00
Repinoid 90718df8ce docs: файл для Claude Sonnet — анализ ELM кода 2026-06-10 22:37:56 +04:00
Repinoid 29d09661a4 bump v1.8.0-dev 2026-06-10 22:34:52 +04:00
Repinoid fb1fae4f6e bump v1.7.0-dev 2026-06-10 22:32:22 +04:00
Repinoid b3b37e80e4 bump v1.6.0-dev 2026-06-10 22:30:32 +04:00
Repinoid 20649df8a5 bump v1.5.0-dev 2026-06-10 22:27:56 +04:00
Repinoid 89fcf5b8dc bump v1.4.0-dev 2026-06-10 22:26:44 +04:00
Repinoid 50cc615d80 fix: SyntaxError - triple quotes 2026-06-10 21:39:44 +04:00
Repinoid 6cf46401b0 bump v1.3.0-dev 2026-06-10 21:35:02 +04:00
Repinoid 0598c5f8e1 fix: PUT profile создаёт профиль если его нет (upsert) 2026-06-10 21:33:47 +04:00
Repinoid 3de1860494 bump v1.2.0-dev 2026-06-10 21:30:58 +04:00
Repinoid 105fc3b982 bump v1.1.0-dev 2026-06-10 21:28:22 +04:00
Repinoid 398a74c34d bump v1.0.0-dev 2026-06-10 21:25:27 +04:00
Repinoid d3a03359f2 bump v0.99.0-dev 2026-06-10 21:22:19 +04:00
Repinoid 29050c06c9 bump v0.98.0-dev 2026-06-10 21:17:27 +04:00
Repinoid 1215e82dc0 bump v0.96.0-dev 2026-06-10 21:10:09 +04:00
Repinoid 79c03e9dd6 bump v0.95.0-dev 2026-06-10 20:53:01 +04:00
Repinoid 6c6bed1f14 bump v0.94.0-dev, docs: история speed-test 2026-06-10 20:36:26 +04:00
Repinoid 74808332be docs: история 2026-06-10 — speed-test, адаптивный интервал, threading lock 2026-06-10 20:13:11 +04:00
Repinoid 008b1c11b5 fix: threading lock in Database for concurrent writes 2026-06-10 19:49:28 +04:00
Repinoid 77a374f7a4 docs: save MPScholar Automotive Sensing and Actuators article 2026-06-10 18:38:30 +04:00
Repinoid c7430c7106 bump v0.93.0-dev 2026-06-10 18:04:04 +04:00
Repinoid da38eccb72 docs: CHANGELOG — стратегия динамического теста 2026-06-10 16:48:54 +04:00
Repinoid 383bfb9566 bump v0.92.0 2026-06-10 16:37:28 +04:00
Repinoid 004a327afc bump v0.91.0 2026-06-10 15:52:13 +04:00
Repinoid 237149e87a bump v0.90.0 2026-06-10 15:46:20 +04:00
Repinoid e763999e85 bump v0.89.0-dev 2026-06-08 11:57:54 +04:00
Repinoid 04ac6e3097 bump v0.88.0-dev 2026-06-08 11:53:20 +04:00
Repinoid 31cba53798 fix: sessions API — нет колонки title, используем vin/car_info 2026-06-08 11:48:14 +04:00
Repinoid e5bc77ec15 bump v0.87.0-dev 2026-06-08 11:47:11 +04:00
Repinoid 4071a420c7 bump v0.86.0-dev, /api/v1/sessions 2026-06-08 11:33:48 +04:00
Repinoid 4491a02ef5 bump v0.85.0-dev 2026-06-08 11:17:09 +04:00
Repinoid a3ae9c9ddf bump v0.84.0-dev, debugLog → runOnUiThread 2026-06-08 09:13:51 +04:00
Repinoid 9c67ce6abb bump v0.83.0-dev, шрифт 10sp 2026-06-08 09:07:20 +04:00
Repinoid fd17b9d03e bump v0.82.0-dev, fix ping-llm auth, шрифт 11sp 2026-06-08 09:04:43 +04:00
Repinoid ca9d5b8eda bump v0.81.0-dev 2026-06-08 09:00:08 +04:00
Repinoid bac9445026 bump v0.80.0-dev 2026-06-08 08:50:51 +04:00
Repinoid 82001e5d1f bump v0.78.0-dev 2026-06-07 20:51:54 +04:00
Repinoid 105df40a55 bump v0.77.0-dev 2026-06-07 20:37:38 +04:00
Repinoid b40d4a8158 bump v0.76.0-dev 2026-06-07 20:21:17 +04:00
Repinoid 1d83b4d6e1 bump v0.75.0-dev 2026-06-07 19:08:30 +04:00
Repinoid f3bf5e66c7 bump v0.74.0-dev 2026-06-07 18:59:19 +04:00
Repinoid 2d02df97e5 bump v0.73.0-dev 2026-06-07 18:56:20 +04:00
Repinoid b26185133d bump v0.72.0-dev 2026-06-07 18:41:47 +04:00
Repinoid 64f9d0fb20 bump v0.71.0-dev 2026-06-07 18:27:16 +04:00
Repinoid 6aadf66a38 fix(server): X-Api-Key проверка на upload/chat/ping-llm, лимит 2000 ответов 2026-06-07 18:27:13 +04:00
Repinoid c6272d3080 docs: аудит проекта на 2026-06-07 2026-06-07 18:23:57 +04:00
Repinoid 2d9b41507a docs: промпт для аудита проекта другим агентом 2026-06-07 18:17:35 +04:00
Repinoid 83972ee420 bump v0.70.0-dev 2026-06-07 18:14:00 +04:00
Repinoid c1b26ce40e bump v0.69.0-dev 2026-06-07 18:09:44 +04:00
Repinoid 44b0cdc173 docs: morda.md — актуальное состояние, changelog, TODO 2026-06-07 17:51:14 +04:00
Repinoid 6ee3f24eff bump v0.68.0-dev 2026-06-07 17:50:00 +04:00
Repinoid abca3691f9 bump v0.67.0-dev 2026-06-07 17:47:47 +04:00
Repinoid daa4ce53e0 bump v0.66.0-dev 2026-06-07 17:42:08 +04:00
Repinoid da362210fe bump v0.65.0-dev 2026-06-07 17:12:05 +04:00
Repinoid 7e368c01d5 bump v0.64.0-dev 2026-06-07 16:34:44 +04:00
Repinoid 17a4764e4c bump v0.63.0-dev 2026-06-07 16:30:07 +04:00
Repinoid b450778b7e bump v0.62.0-dev 2026-06-07 16:26:20 +04:00
Repinoid a1154e9993 bump v0.61.0-dev 2026-06-07 16:21:31 +04:00
Repinoid 5b40d28c53 bump v0.60.0-dev 2026-06-07 16:16:57 +04:00
Repinoid 788b757de9 bump v0.59.0-dev 2026-06-07 16:16:02 +04:00
Repinoid d4cc47eadc bump v0.58.0-dev 2026-06-07 16:12:24 +04:00
Repinoid f89caf74de bump v0.57.0-dev 2026-06-07 15:58:31 +04:00
Repinoid 4f90e3eadf docs: морда v2 — полное описание новой архитектуры UI 2026-06-07 15:54:48 +04:00
Repinoid 9762640abb bump v0.56.0-dev 2026-06-07 14:06:34 +04:00
Repinoid 150631024e bump v0.54.0-dev 2026-06-07 13:49:17 +04:00
Repinoid d88b90049f bump v0.53.0-dev 2026-06-07 13:41:45 +04:00
Repinoid f02cd5e226 bump v0.52.0-dev 2026-06-07 13:01:04 +04:00
Repinoid 801e286cfc bump v0.51.0-dev 2026-06-07 12:56:35 +04:00
Repinoid b95cf1fbad fix(server): лимит dynamic_samples — не более 1000 2026-06-07 12:54:52 +04:00
Repinoid b7823d6008 bump v0.50.0-dev 2026-06-07 12:16:04 +04:00
Repinoid 20be9103af feat(server): приём dynamic_samples, выбор DYNAMIC_PROMPT 2026-06-07 12:12:26 +04:00
Repinoid 76abc22b79 feat(server): DYNAMIC_PROMPT для анализа временных рядов 2026-06-07 11:51:48 +04:00
Repinoid 290e289517 feat(server): скрипт ?mode=dynamic — 12 PID, интервал 250мс 2026-06-07 11:50:56 +04:00
Repinoid e6e575b9e6 docs: архитектура dynamic-tests 2026-06-07 11:48:49 +04:00
Repinoid 211566d557 docs: планы на вечер + fix(android): очистка \r\n в ObdDecoder 2026-06-07 10:32:34 +04:00
Repinoid c111e651de bump v0.49.0-dev 2026-06-07 10:16:43 +04:00
Repinoid 4d38cb945f fix(prompts): краткий LLM-ответ для тестирования 2026-06-07 10:16:25 +04:00
Repinoid fd2126c0cb fix(db): executescript в try/except, индексы отдельно от миграций 2026-06-07 09:16:33 +04:00
Repinoid d5e44060d3 fix(db): миграции ALTER TABLE для старых БД (device_uuid и др.) 2026-06-07 09:15:33 +04:00
Repinoid 3334baf8fc fix: deploy.sh на master, legacy-префиксы, v0.48.0-dev везде 2026-06-07 08:59:34 +04:00
Repinoid aaaa370764 docs: строгий запрет на действия без команды 2026-06-07 07:19:50 +04:00
“Naeel” bb309eaccb docs: field test guide + update resume for v0.48.0 2026-06-07 06:41:28 +04:00
“Naeel” 2de4bb9443 docs: move resume context to resume.txt, trim copilot-instructions 2026-06-07 05:58:48 +04:00
“Naeel” aa37a1a70c v0.48.0 — пробинг ELM327, трехуровневый профиль, рефакторинг obd/
- obd/probe.py: трехуровневый каскад (L0/L1/L2)
- obd/commands.py: каталог всех AT-команд с метаданными
- obd/classifier.py: классификация ответов + определение уровня
- obd/connection.py: транспортный слой (SerialTransport)
- obd/protocol.py: init() только база, без ATAT1/ATST
- api/db.py: таблица device_profiles по BT MAC
- api/scripts.py: три уровня скриптов (l0/l1/l2)
- api/routes.py: /elm/probe, /elm/profile/<mac>, /script?level=
- web/templates/index.html: v0.48.0
- CHANGELOG.md, doc/architecture.md, resume.txt: версии
2026-06-07 05:55:02 +04:00
Repinoid 7ae0fc5702 docs: сессия 2026-06-06 + резюме проекта для нового чата 2026-06-06 21:58:20 +04:00
Repinoid 0fb2c81a86 bump v0.44.0-dev 2026-06-06 21:30:33 +04:00
Repinoid 0b322882a7 docs: правила для Copilot — коммит после правок + bump при деплое 2026-06-06 21:21:09 +04:00
Repinoid 3ee3930c18 chore: v0.42.0-dev 2026-06-06 21:07:28 +04:00
Repinoid ceb71e5b9d chore: v0.41.0-dev + полевой тест doc/test-cases.md 2026-06-06 07:25:39 +04:00
Repinoid fd8059bb6a docs: пояснения почему выбрана такая логика (reasoning) 2026-06-06 07:24:17 +04:00
Repinoid 8da4876cb8 docs: подробные docstring перед каждой функцией 2026-06-06 07:22:35 +04:00
Repinoid 0de7e0591c fix: Rsp.identify NO DATA (пробел); 119 тестов 2026-06-06 07:21:36 +04:00
Repinoid 7aaebf8481 refactor: разбил routes/db/protocol на отдельные модули 2026-06-06 07:16:51 +04:00
Repinoid d2f42e99bb fix: car_info сохраняется в БД 2026-06-06 07:06:56 +04:00
Repinoid dec23bc140 docs: дневник 2026-06-06 дополнен 2026-06-06 07:03:51 +04:00
Repinoid 4c71d7a4c9 feat: идемпотентность DTC + prompt учитывает неточности данных 2026-06-06 07:02:56 +04:00
Repinoid 1e8fd62039 fix: parser — CAN multi-frame, ISO-TP, пустой {} в промпт, 91 тест 2026-06-06 06:59:26 +04:00
Repinoid f83a14a1ce chore: тесты, SETUP.md, фикс parser DTC (mode=43, не 03) 2026-06-06 06:53:28 +04:00
Repinoid beecf2fb9e feat: краткий диагноз, без воды 2026-06-06 06:47:32 +04:00
Repinoid 7cf1fe0653 chore: v0.40.0-dev 2026-06-06 06:46:10 +04:00
Repinoid 6798ba1012 docs: дневник 2026-06-06 2026-06-06 06:44:45 +04:00
Repinoid 64acb8c60a feat: DTC-коды: справочник, эндпоинты /dtc/decode, /dtc/upload 2026-06-06 06:40:50 +04:00
Repinoid 9d08ff4c75 feat: телефон — язык, часовой пояс, разрешение экрана 2026-06-06 06:23:24 +04:00
Repinoid 9049383e87 feat: device_uuid — UUID приложения в SharedPreferences 2026-06-06 06:16:00 +04:00
Repinoid 64fba6941d fix: если переспросят — «С какой целью интересуешься?» 2026-06-05 18:43:00 +04:00
Repinoid eb79072dd4 feat: LLM никогда не признаётся кто её создал — академические театры 2026-06-05 18:39:22 +04:00
Repinoid 67ef15df23 chore: v0.39.0-dev 2026-06-05 18:29:09 +04:00
Repinoid c532ce4f0d docs: правило — APK через nginx напрямую, не через Flask 2026-06-05 18:28:16 +04:00
Repinoid 352fa1052d chore: v0.38.0-dev + history 2026-06-05 18:03:33 +04:00
Repinoid ce0b40d8dc docs: doc/history/ — журнал изменений по датам 2026-06-05 17:41:53 +04:00
“Naeel” f6461014a3 docs: убрать запрет GitHub Actions — теперь CI собирает APK 2026-06-04 20:23:48 +03:00
“Naeel” 166c34419c chore: v0.37.0-dev — проверка ELM, поле авто, deepseek flash 2026-06-04 19:05:23 +03:00
“Naeel” 03bf84c289 feat: car_info в upload — свободное описание авто от водителя 2026-06-04 18:44:16 +03:00
Repinoid 126598dd8a docs: инструкция по git — репо, pull, push, деплой 2026-06-03 08:37:18 +03:00
“Naeel” 7013fcf56a chore: добавить токены в .gitignore 2026-06-03 07:06:03 +03:00
“Naeel” fc897367b6 fix: ping-llm адаптивный кэш — 60с при успехе, 7с при ошибке 2026-06-03 06:45:36 +03:00
“Naeel” 787ed77b7a fix: DeepSeek V4 Flash вместо aillm.ru (gpt-oss-120b был нестабилен) 2026-06-03 06:43:18 +03:00
“Naeel” 2a07b5bfa1 fix: qwen3-6-27b-fp8 → gpt-oss-120b (модель недоступна) 2026-06-03 06:37:16 +03:00
“Naeel” f215cff21a docs: CHANGELOG.md — полное описание проекта для нового агента 2026-06-03 06:36:03 +03:00
Repinoid 829487ad67 docs: правило №1 — вопрос → только ответ, никаких действий 2026-05-31 18:36:33 +03:00
Repinoid 02753a6de9 docs: правило №1 — ничего не делать без прямой команды 2026-05-31 18:34:05 +03:00
Repinoid 820bcfcbb5 fix: nonlocal → global для _ping_llm_cache 2026-05-31 17:54:30 +03:00
Repinoid f4475c441f chore: v0.36.0-dev + CI/CD в docs + copilot instructions 2026-05-31 17:44:52 +03:00
Repinoid 7f71cb1709 feat: новое лого на сайте 2026-05-31 17:40:25 +03:00
Repinoid 8751d009fc docs: убран GitHub Actions, ручная сборка APK 2026-05-31 17:37:18 +03:00
Repinoid 07944d86d3 docs: деплой и доставка APK в architecture.md 2026-05-31 17:35:38 +03:00
Repinoid dda72ccfc6 chore: удалён мёртвый elmer/, версия 0.35.0-dev, документы Опуса 2026-05-31 17:28:53 +03:00
Repinoid 6893ae33f5 fix(server): идемпотентность upload, WAL, буфер ELM, таймаут LLM
- api/db.py: WAL mode, busy_timeout, request_id UNIQUE, close(), контекстный менеджер
- api/routes.py: проверка request_id при upload, /ping-llm кэш 60с, /chat через roles
- api/config.py: lru_cache на load()
- obd/protocol.py: reset_input_buffer перед _write(), условный READY в send()
- brain/client.py: модель 120b, таймаут из параметра, LLMError класс, обработка 429/5xx
2026-05-31 16:56:21 +03:00
77 changed files with 8573 additions and 1319 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"servers": {
"elmer-server": {
"type": "stdio",
"command": "python3",
"args": [
"/home/naeel/elmer/.vscode/mcp_server.py"
]
}
}
}
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""MCP сервер для Elmer — БД, логи, ssh."""
import json, subprocess, sys
def handle(req):
method = req.get("method", "")
params = req.get("params", {})
if method == "list_tools":
return {
"tools": [
{"name": "query_db", "description": "SQL-запрос к elmer.db", "inputSchema": {"type": "object", "properties": {"sql": {"type": "string"}}}},
{"name": "server_logs", "description": "Логи сервера (последние N строк)", "inputSchema": {"type": "object", "properties": {"lines": {"type": "number", "default": 30}}}},
{"name": "ssh", "description": "Выполнить bash-команду на ВМ", "inputSchema": {"type": "object", "properties": {"cmd": {"type": "string"}}}},
]
}
if method == "call_tool":
name = params.get("name", "")
args = params.get("arguments", {})
if name == "query_db":
ssh(f"sqlite3 /opt/elmer/elmer.db \"{args['sql']}\"")
elif name == "server_logs":
ssh(f"sudo journalctl -u elmer --no-pager -n {args.get('lines', 30)}")
elif name == "ssh":
ssh(args["cmd"])
else: return {"error": f"unknown tool: {name}"}
return {"result": "ok"}
def ssh(cmd):
r = subprocess.run(["ssh", "-i", "/home/naeel/.ssh/naeel_vm_id_ed25519", "naeel@5.172.178.213", cmd], capture_output=True, text=True)
return {"stdout": r.stdout, "stderr": r.stderr}
for line in sys.stdin:
line = line.strip()
if line:
resp = handle(json.loads(line))
print(json.dumps(resp), flush=True)
+50
View File
@@ -0,0 +1,50 @@
# Правила для GitHub Copilot
## 1. Коммит после каждой правки
После **любого** изменения кода (исправление бага, рефакторинг, добавление фичи) — сразу делать коммит с осмысленным сообщением:
```bash
git add -A && git commit -m "краткое описание" && git push origin master
```
- Без накопления изменений
- Без "потом закоммичу"
- Один коммит = одна правка
## 2. При деплое ВСЕГДА повышать версию
Перед **каждым** деплоем APK на сервер — инкрементировать патч-версию в `android/app/build.gradle.kts`:
```
versionName = "X.Y.Z-dev" → versionName = "X.Y+1.Z-dev"
```
### Процедура
1. Открыть `android/app/build.gradle.kts`
2. Увеличить номер в `versionName` (только патч, Z в X.Y.Z-dev)
3. Закоммитить с сообщением `"bump vX.Y.Z-dev"`
4. Затем — сборка и деплой
### Пример (актуальный)
```
Было: versionName = "0.47.0-dev"
Стало: versionName = "0.48.0-dev"
```
## 3. Формат коммитов
- `fix: ...` — исправление бага
- `feat: ...` — новая фича
- `refactor: ...` — переработка кода
- `bump: ...` — повышение версии
- `docs: ...` — документация
## 4. НИЧЕГО не делать без прямого указания
Запрещено что-либо делать (править код, коммитить, деплоить, менять конфиги) без явной команды пользователя. Ждать указаний.
## 5. На вопросы — только отвечать
Если пользователь задал вопрос (в любой форме — утвердительной, вопросительной, с матом, без) — **ТОЛЬКО ответить**. Никаких изменений кода, файлов, конфигов. Даже если вопрос про баг — не чинить, а только объяснить.
+1
View File
@@ -3,3 +3,4 @@ __pycache__/
android/
web/static/app-debug.apk
creds.txt
token*.*
+57
View File
@@ -0,0 +1,57 @@
# Инструкция для Copilot — проект elmAI
> Последнее обновление: 31 мая 2026 · v0.36.0-dev
## Репозитории
| Репо | Назначение | Хостинг |
|------|-----------|---------|
| `elmer/` (этот) | Сервер Python/Flask | gitea.services.ngcloud.ru/Nail/elmer |
| `elmer/android/` | Android-приложение Kotlin | github.com/Repinoid/elmer-android |
## Деплой
### Сервер (obdai.ru, 5.172.178.213)
- Код: `/opt/elmer` (git clone gitea)
- Ветка: `master` (по умолчанию)
- Деплой: `ssh obdai.ru "cd /opt/elmer && git pull && sudo systemctl restart elmer"`
- Сервис: `gunicorn -w 4 -b 127.0.0.1:8000 web.app:app`
- Прокси: nginx :443 → :8000
- **APK отдавать напрямую через nginx, НЕ через Flask/gunicorn:** `location = /elmer.apk { alias /opt/elmer/web/static/app-debug.apk; }`
### APK
- Сборка: **автоматически GitHub Actions** при пуше в master
- Деплой: CI сам заливает APK на сервер (`appleboy/scp-action`)
- Ссылка: `https://obdai.ru/elmer.apk` → nginx отдаёт напрямую `web/static/app-debug.apk`
### Версионирование
- APK: `android/app/build.gradle.kts``versionName`
- Сайт: `web/templates/index.html` (синхронизировать вручную)
- Документация: в заголовках `.md` файлов
## Правила работы
1. **ЕСЛИ в диалоге содержится ВОПРОС в любой форме — только ответить. НИЧЕГО НЕ ПРЕДПРИНИМАТЬ.** Не писать код, не редактировать файлы, не коммитить, не деплоить. Только прямые императивы («сделай», «исправь», «напиши», «внеси», «задеплой») — команда к действию.
2. **Не выдумывать инфраструктуру.** Никаких Docker, Kubernetes. Всё на голом железе.
3. **Читать документацию перед действиями.** `doc/architecture.md` — канонический источник.
3. **Не редактировать отчёты Опуса.** `doc/opus-review*.md` — только для чтения.
4. **Ключи и токены:** LLM-ключ только на сервере (`config.yaml`), НЕ в APK. `X-Api-Key` приложения — через `BuildConfig.API_KEY` из `local.properties`.
5. **Git:** `elmer/` и `elmer/android/` — отдельные репо, отдельные коммиты.
6. **Ветки:** `master` — продакшен, `opus-fixes` и др. — для правок. Вливать в master когда готово.
7. **После правок:** коммит + пуш + (если сервер) деплой через SSH.
8. **Версия:** менять в трёх местах — `build.gradle.kts`, `index.html`, доки.
9. **Документировать изменения** в `doc/history/YYYY-MM-DD.md` после каждого сеанса работы. Формат: 🔴/🟡/🟢 для приоритета, по файлам.
## Структура сервера
```
elmer/
├── api/ # REST, БД, скрипты, парсер
├── brain/ # LLM-клиент, промпты
├── obd/ # ELM327 стейт-машина
├── web/ # Flask, шаблоны, статика (APK)
├── android/ # Android-приложение (отдельный репо)
├── doc/ # Документация
├── config.yaml # LLM API key, порты
└── run.py # Локальный запуск
```
+338
View File
@@ -0,0 +1,338 @@
# elmAI — Changelog / Полное описание проекта
> Файл для нового агента: прочитай — и ты в курсе всего.
> Актуально: v0.95.0-dev, 10 июня 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.93.0-dev (10 июня 2026)
- **Speed-test ELM327:** при первом подключении нового ELM — замер скорости ответа на 3 PID (RPM, MAF, STFT) × 3 раза каждый
- **Адаптивный интервал:** динамический тест использует `max(250, avg_response × 3 × 1.5)` вместо жёстких 250ms
- **Профиль устройства:** колонка `response_time_ms` в `device_profiles`, API `PUT /api/v1/elm/profile/<mac>`
- **Fix:** `threading.Lock()` в `Database` — 0 ошибок при 20 конкурентных записях (было 8/20)
- **UI:** прогресс speed-теста показывается пользователю
- Деплой v0.93.0-dev на obdai.ru
#### 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-слой (устарела) |
---
## 9. Динамический тест — START/STOP (v0.93+, 10.06.2026)
### Цель
Выявить **потерю мощности, подсос воздуха, забитый фильтр, проблемы смеси** — ловля STFT/LTFT на сбросе газа.
### 5 быстрых PID (планировалось)
1. **RPM** (010C)
2. **MAF** (0110)
3. **STFT** (0106)
4. **LTFT** (0107)
5. **TPS** (0111)
→ После тестов #43-#45 выяснилось: ELM327 v1.5 не успевает 5 PID за 250ms (данные склеиваются).
→ После тестов #47-#48 (v0.93.0-dev): даже 3 PID × 250ms — 94% ошибок, ELM перестаёт отвечать после ~15 сэмплов.
**Решение (текущее, v0.93.0): Speed-test при первом подключении ELM + адаптивный интервал.**
### Текущая стратегия (v0.93+)
#### Этап 0 — Speed-test (только при первом подключении нового ELM)
- После инициализации ELM: замерить время ответа на 010C, 0110, 0106 — каждый 3 раза
- Показать пользователю: `"⏱ Тест скорости: RPM 82ms MAF 91ms STFT 82ms"`
- Сохранить `response_time_ms` в профиль устройства (по BT MAC)
- Интервал = `max(250, avg_response × 3 × 1.5)`
- При повторных запусках — использовать сохранённое значение
#### Этап 1 — Статика (перед СТАРТ)
- Снять все доступные PID по одному разу
- Определить какие PID отвечают, какие нет (7F 01 12)
- **Запомнить** неподдерживаемые — больше не опрашивать
- Время: ~3-4 секунды
#### Этап 2 — Динамика (250ms)
- **3 PID**: RPM (010C), MAF (0110), STFT (0106)
- LTFT, TPS, MAP, Load, coolant, IAT — один раз в статике
#### Этап 3 — Контроль качества
- После СТОП проверить количество сэмплов и % ошибок
- Если < 6-8 сэмплов или > 30% errors — сообщить водителю:
> «Слишком быстро. Нажмите СТАРТ, плавно наберите ~3000 об/мин, **сбросьте газ, подождите 3-4 секунды**, нажмите СТОП.»
### Процедура для водителя
1. Дождаться ДИАГНОСТИКА → зелёный
2. Нажать СТАРТ
3. Плавно газ до ~3000 об/мин
4. **Резко сбросить газ**
5. **Подождать 3-4 секунды** (без нажатий) — ЭБУ корректирует смесь
6. СТОП
7. ➤ (Send) — отправка на сервер
### Зачем ждать 3-4 секунды после сброса
- MAF падает → STFT резко уходит в минус/плюс
- ЭБУ пытается стабилизировать смесь
- LTFT начинает подстраиваться
- Именно эти 3-4 секунды — самое ценное для анализа
### Планы
- Скорость — потом через GPS (не через OBD)
- ~~Адаптивный интервал если ELM быстрее (v2.x)~~ ✅ Сделано в v0.93.0
- Логирование в историю каждого теста
+2
View File
@@ -1,6 +1,7 @@
"""Загрузка конфигурации из config.yaml."""
import os
from functools import lru_cache
from pathlib import Path
import yaml
@@ -8,6 +9,7 @@ import yaml
CONFIG_PATH = Path(os.environ.get("ELMER_CONFIG", Path(__file__).parent.parent / "config.yaml"))
@lru_cache(maxsize=1)
def load() -> dict:
"""Читает config.yaml, подставляет переменные окружения в значения."""
if not CONFIG_PATH.exists():
+272 -221
View File
@@ -1,16 +1,16 @@
"""SQLite — сохранение сессий диагностики.
"""SQLite — сохранение сессий диагностики + профили ELM-устройств.
Схема:
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
sessions — сводная таблица всех сессий (клиент, ELM, авто, LLM)
Таблица 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
import threading
from datetime import datetime, timezone
from pathlib import Path
@@ -18,169 +18,226 @@ 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 = 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._lock = threading.Lock()
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):
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'))
);
# Основная схема (может упасть на индексах старых БД — ловим)
try:
self.conn.executescript("""
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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'))
);
-- Сервер
client_ip TEXT,
real_ip TEXT,
user_agent TEXT,
content_length INTEGER,
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'))
);
-- Телефон
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,
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'))
);
-- ELM327
elm_mac TEXT,
elm_bt_name TEXT,
obd_protocol TEXT,
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'))
);
-- Авто
vin TEXT,
dtc_count INTEGER DEFAULT 0,
pid_count INTEGER DEFAULT 0,
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- Сессия
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,
-- Сервер
client_ip TEXT,
real_ip TEXT,
user_agent TEXT,
content_length INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
-- LLM
diagnosis_text TEXT,
diagnosis_len INTEGER,
llm_model TEXT,
llm_duration_ms INTEGER,
llm_success INTEGER DEFAULT 0,
-- Телефон
phone_model TEXT,
phone_maker TEXT,
android_version TEXT,
android_sdk INTEGER,
app_version TEXT,
android_id TEXT,
-- Сырые данные (JSON)
raw_responses TEXT,
-- ELM327
elm_mac TEXT,
elm_bt_name TEXT,
obd_protocol TEXT,
-- Идемпотентность
request_id TEXT UNIQUE,
response_json TEXT
);
-- Авто
vin TEXT,
dtc_count INTEGER DEFAULT 0,
pid_count INTEGER DEFAULT 0,
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);
-- Сессия
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, -- 'bt' | 'tcp'
mock_mode INTEGER DEFAULT 0,
-- Профили 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,
response_time_ms INTEGER DEFAULT 250,
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 # старая БД без новых колонок — применим миграции ниже
-- LLM
diagnosis_text TEXT,
diagnosis_len INTEGER,
llm_model TEXT,
llm_duration_ms INTEGER,
llm_success INTEGER DEFAULT 0,
self.conn.commit()
-- Сырые данные (JSON)
raw_responses TEXT
);
# Миграции: добавляем колонки, которых нет в старых БД
migrations = [
"ALTER TABLE device_profiles ADD COLUMN response_time_ms INTEGER DEFAULT 250",
"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
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);
CREATE INDEX IF NOT EXISTS idx_sessions_aid ON sessions(android_id);
""")
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):
"""Сохраняет сводную запись о сессии."""
ci = client_info
llm_duration_ms: int = 0, llm_success: bool = False,
request_id: str = "", response_json: dict | None = None):
"""Сохраняет сводную запись о сессии.
# Подсчёт 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
Если request_id передан и уже существует — silently return (идемпотентность).
"""
with self._lock:
ci = client_info
# 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
# Подсчёт 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
self.conn.execute("""
INSERT INTO sessions (
client_ip, real_ip, user_agent, content_length,
phone_model, phone_maker, android_version, android_sdk,
app_version, android_id,
elm_mac, elm_bt_name, obd_protocol,
# 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,
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
) 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("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),
diagnosis, len(diagnosis), llm_model,
llm_duration_ms, 1 if llm_success else 0,
json.dumps(responses, ensure_ascii=False) if responses else None,
))
self.conn.commit()
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 сессий."""
@@ -189,87 +246,81 @@ class Database:
).fetchall()
return [dict(r) for r in rows]
# ── cars ──────────────────────────────────────────────
def save_dtc_scan(self, client_info: dict, dtc_codes: list[str]):
"""Сохраняет быстрый скан кодов ошибок."""
with self._lock:
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()
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
# ── device_profiles ──────────────────────────────────
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."""
def get_device_profile(self, mac: str) -> dict | None:
"""Возвращает сохранённый профиль устройства по MAC, или None."""
row = self.conn.execute(
"SELECT id FROM diagnostic_tokens WHERE car_id=? ORDER BY created_at DESC LIMIT 1",
(car_id,),
"SELECT * FROM device_profiles WHERE mac = ?", (mac,)
).fetchone()
return row["id"] if row else None
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
# ── llm_messages ──────────────────────────────────────
def save_device_profile(self, mac: str, profile: dict):
"""Сохраняет или обновляет профиль устройства.
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()
profile — результат obd.probe.probe() + response_time_ms.
"""
with self._lock:
now = datetime.now(timezone.utc).isoformat()
self.conn.execute("""
INSERT INTO device_profiles
(mac, level, elm_version, elm_desc, protocol, voltage,
response_time_ms, 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,
response_time_ms = excluded.response_time_ms,
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"),
profile.get("response_time_ms", 250),
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()
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]
+102
View File
@@ -0,0 +1,102 @@
"""Эндпоинты DTC: расшифровка и сохранение кодов ошибок.
POST /api/v1/dtc/decode — расшифровка кодов
POST /api/v1/dtc/upload — приём скана ошибок
"""
import logging
from pathlib import Path
from flask import jsonify, request
from api.db import Database
logger = logging.getLogger("elmer.dtc")
# Загрузка справочника DTC
_DTC_CODES: dict[str, str] = {}
_DTC_PATH = Path(__file__).parent.parent / "doc" / "dtc_codes.txt"
def _load_dtc_dict() -> dict[str, str]:
"""Загрузить справочник DTC-кодов из doc/dtc_codes.txt."""
global _DTC_CODES
if _DTC_CODES:
return _DTC_CODES
if _DTC_PATH.exists():
for line in _DTC_PATH.read_text(encoding="utf-8").splitlines():
line = line.strip()
if "=" in line and not line.startswith("#"):
code, desc = line.split("=", 1)
_DTC_CODES[code.strip()] = desc.strip()
return _DTC_CODES
def register(app):
"""Регистрирует DTC-эндпоинты на Flask-приложении."""
@app.route("/api/v1/dtc/decode", methods=["POST"])
def dtc_decode():
"""Расшифровка кодов DTC из справочника doc/dtc_codes.txt.
Принимает: {"codes": ["P0301", "P0302"]}
Возвращает: {"codes": [{"code": "P0301", "desc": "..."}]}
Неизвестные коды возвращаются как есть — справочник неполный.
"""
data = request.get_json(silent=True) or {}
codes = data.get("codes", [])
if not codes:
return jsonify({"codes": []})
dtc = _load_dtc_dict()
decoded = [{"code": c.strip().upper(), "desc": dtc.get(c.strip().upper(), c)} for c in codes]
return jsonify({"codes": decoded})
@app.route("/api/v1/dtc/upload", methods=["POST"])
def dtc_upload():
"""Приём кодов ошибок (без полной диагностики, только Ошибки).
Идемпотентность: если client шлёт request_id, сервер:
1. Проверяет кэш — если есть, возвращает его (LLM не вызывается)
2. Если нет — сохраняет, кэширует ответ
Это предотвращает дубликаты при повторных отправках (например, 499).
"""
data = request.get_json(silent=True)
if not data:
return jsonify({"error": "empty"}), 400
codes = data.get("dtc_codes", [])
client_info = data.get("client_info", {})
client_info["client_ip"] = request.remote_addr
# Идемпотентность
request_id = (data.get("request_id") or "").strip()
if request_id:
with Database() as db:
cached = db.get_cached_response(request_id)
if cached is not None:
return jsonify(cached), 200
try:
with Database() as db:
db.save_dtc_scan(client_info, codes)
except Exception as e:
logger.error(f"DTC upload failed: {e}")
dtc = _load_dtc_dict()
decoded = [{"code": c, "desc": dtc.get(c.strip().upper(), c)} for c in codes]
response = {"codes": decoded, "count": len(decoded)}
# Кэш для идемпотентности
if request_id:
try:
with Database() as db:
db.save_session(
client_info=client_info,
responses=[{"decoded": f"DTC stored: {c}"} for c in codes],
request_id=request_id,
response_json=response,
)
except Exception as e:
logger.error(f"DTC idempotency cache failed: {e}")
return jsonify(response)
+15 -4
View File
@@ -34,7 +34,7 @@ def parse_batch(responses: list[dict]) -> dict:
result["raw_log"].append(f"{cmd}\n{raw}")
_parse_vin(result, raw, decoded)
_parse_dtc(result, raw, decoded, mode="03", key="dtc_stored", prefix="DTC stored:")
_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)
@@ -44,17 +44,28 @@ def parse_batch(responses: list[dict]) -> dict:
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
# Fallback: парсим VIN из raw HEX (CAN multi-frame, ISO-TP, обычный)
if "49" in raw and ("02" in raw or "4902" in raw.replace(" ", "")):
clean = raw.replace(":", "").replace(" ", "").upper()
# Чистим: убираем 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][:34]
hex_str = clean.split("490201")[-1].replace("\n", "").replace("\r", "")[:34]
vin = ""
for i in range(0, len(hex_str) - 1, 2):
try:
+64
View File
@@ -0,0 +1,64 @@
"""Эндпоинты проверки доступности.
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 с адаптивным кэшем (успех=60с, ошибка=7с)."""
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
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)
+314
View File
@@ -0,0 +1,314 @@
"""
api/raw_elm.py — Сырое взаимодействие с ELM327 (локальное + удалённое через Android).
ЛОКАЛЬНЫЙ РЕЖИМ (ELM327 подключён к серверу напрямую):
POST /api/v1/elm/raw — отправить команду, получить сырой ответ
POST /api/v1/elm/raw/batch — несколько команд
POST /api/v1/elm/raw/drain — очистить буфер
GET /api/v1/elm/raw/available — байт в буфере
GET /api/v1/elm/raw/log — история команд
GET /api/v1/elm/raw/mode — режим (normal/raw)
УДАЛЁННЫЙ РЕЖИМ (Android-ретранслятор):
POST /api/v1/elm/raw/hello — Android: «я готов»
POST /api/v1/elm/raw/cmd — Copilot: поставить команду в очередь
GET /api/v1/elm/raw/cmd — Android: забрать команду
POST /api/v1/elm/raw/response — Android: отправить ответ
GET /api/v1/elm/raw/response — Copilot: прочитать ответ
GET /api/v1/elm/raw/status — Copilot: статус устройства
"""
import logging
import threading
import time
from flask import jsonify, request, Blueprint
logger = logging.getLogger("elmer.raw_api")
# ══════════════════════════════════════════════════════════
# Глобальное состояние
# ══════════════════════════════════════════════════════════
_raw_mode = False
_raw_elm = None # локальный RawELM
# Очередь команд (удалённый режим)
_lock = threading.Lock()
_pending_cmd: dict | None = None # команда, которую ждёт Android
_pending_seq: int = 0 # номер последней команды
_last_response: dict | None = None # последний ответ от ELM327
_device_ready: bool = False # Android подключён и готов
_device_info: dict = {} # информация об устройстве (из hello)
_history: list[dict] = [] # история команд через Android
def is_raw_mode() -> bool:
return _raw_mode
def set_raw_mode(on: bool):
global _raw_mode
_raw_mode = on
logger.info(f"RawELM mode: {'ON' if on else 'OFF'}")
def set_raw_elm(instance):
global _raw_elm
_raw_elm = instance
bp = Blueprint("raw_elm", __name__)
# ══════════════════════════════════════════════════════════
# ЛОКАЛЬНЫЙ РЕЖИМ — ELM327 подключён к серверу напрямую
# ══════════════════════════════════════════════════════════
@bp.route("/api/v1/elm/raw", methods=["POST"])
def raw_command():
if not _raw_elm:
return jsonify({"error": "no local ELM connection"}), 503
data = request.get_json(silent=True)
if not data or "cmd" not in data:
return jsonify({"error": "missing 'cmd'"}), 400
cmd = data["cmd"].strip()
if not cmd:
return jsonify({"error": "empty cmd"}), 400
timeout = data.get("timeout_ms")
drain_first = data.get("drain_first", False)
if drain_first:
_raw_elm.drain()
result = _raw_elm.send(cmd, timeout=timeout)
return jsonify(result)
@bp.route("/api/v1/elm/raw/batch", methods=["POST"])
def raw_batch():
if not _raw_elm:
return jsonify({"error": "no local ELM connection"}), 503
data = request.get_json(silent=True)
if not data or "cmds" not in data:
return jsonify({"error": "missing 'cmds'"}), 400
cmds = data["cmds"]
if len(cmds) > 100:
return jsonify({"error": "too many commands (max 100)"}), 400
timeout = data.get("timeout_ms")
drain_between = data.get("drain_between", False)
t0 = time.time()
results = []
for cmd in cmds:
if drain_between:
_raw_elm.drain()
results.append(_raw_elm.send(cmd, timeout=timeout))
total_elapsed = int((time.time() - t0) * 1000)
return jsonify({"results": results, "total_elapsed_ms": total_elapsed})
@bp.route("/api/v1/elm/raw/drain", methods=["POST"])
def raw_drain():
if not _raw_elm:
return jsonify({"error": "no local ELM connection"}), 503
return jsonify(_raw_elm.drain())
@bp.route("/api/v1/elm/raw/available", methods=["GET"])
def raw_available():
if not _raw_elm:
return jsonify({"error": "no local ELM connection"}), 503
return jsonify({"available": _raw_elm.available()})
@bp.route("/api/v1/elm/raw/log", methods=["GET"])
def raw_log():
if not _raw_elm:
return jsonify({"log": _history, "count": len(_history)})
return jsonify({"log": _raw_elm.log, "count": len(_raw_elm.log)})
@bp.route("/api/v1/elm/raw/mode", methods=["GET", "POST"])
def raw_mode_control():
global _raw_mode
if request.method == "POST":
data = request.get_json(silent=True) or {}
on = data.get("raw_mode", False)
set_raw_mode(on)
return jsonify({"raw_mode": _raw_mode, "has_local_elm": _raw_elm is not None,
"device_ready": _device_ready})
return jsonify({"raw_mode": _raw_mode, "has_local_elm": _raw_elm is not None,
"device_ready": _device_ready})
# ══════════════════════════════════════════════════════════
# УДАЛЁННЫЙ РЕЖИМ — Android-ретранслятор
# ══════════════════════════════════════════════════════════
@bp.route("/api/v1/elm/raw/hello", methods=["POST"])
def raw_hello():
"""Android сообщает: «я подключился к ELM327, готов принимать команды».
Body: {
"device_id": "android-xyz",
"elm_version": "ELM327 v1.5",
"protocol": "A4",
"voltage": "12.3V"
}
"""
global _device_ready, _device_info, _pending_cmd, _pending_seq, _last_response
data = request.get_json(silent=True) or {}
with _lock:
_device_ready = True
_device_info = {
"device_id": data.get("device_id", "unknown"),
"elm_version": data.get("elm_version", "?"),
"protocol": data.get("protocol", "?"),
"voltage": data.get("voltage", "?"),
"connected_at": time.time(),
}
_pending_cmd = None
_pending_seq = 0
_last_response = None
logger.info(f"RawELM: device ready — {_device_info['device_id']} "
f"({_device_info['elm_version']}, proto {_device_info['protocol']})")
return jsonify({"ok": True, "seq": 0})
@bp.route("/api/v1/elm/raw/cmd", methods=["POST"])
def raw_enqueue_cmd():
"""Copilot: поставить команду в очередь для Android.
Body: {
"cmd": "0105",
"timeout_ms": 500,
"drain_first": false
}
"""
global _pending_cmd, _pending_seq
data = request.get_json(silent=True)
if not data or "cmd" not in data:
return jsonify({"error": "missing 'cmd'"}), 400
cmd = data["cmd"].strip()
if not cmd:
return jsonify({"error": "empty cmd"}), 400
with _lock:
_pending_seq += 1
_pending_cmd = {
"cmd": cmd,
"timeout_ms": data.get("timeout_ms", 500),
"drain_first": data.get("drain_first", False),
"seq": _pending_seq,
}
logger.info(f"RawELM: enqueued #{_pending_seq}{cmd}")
return jsonify({"ok": True, "seq": _pending_seq, "cmd": cmd})
@bp.route("/api/v1/elm/raw/cmd", methods=["GET"])
def raw_dequeue_cmd():
"""Android: забрать команду из очереди.
Returns:
200 {"cmd": "0105", "seq": 1, ...} — есть команда
204 — нет команды, полли дальше
"""
global _pending_cmd
device_id = request.args.get("device_id", "")
with _lock:
if not _device_ready:
return jsonify({"error": "device not ready"}), 503
if _pending_cmd is None:
return "", 204 # No Content — полли дальше
cmd = _pending_cmd
_pending_cmd = None # забрали
logger.info(f"RawELM: dequeued #{cmd['seq']}{cmd['cmd']} (device={device_id})")
return jsonify(cmd)
@bp.route("/api/v1/elm/raw/response", methods=["POST"])
def raw_post_response():
"""Android: отправить ответ ELM327 на сервер.
Body: {
"device_id": "android-xyz",
"seq": 1,
"cmd": "0105",
"raw": "41 05 5C",
"prompt": true,
"elapsed_ms": 48,
"bytes": 8,
"error": null
}
"""
global _last_response, _history
data = request.get_json(silent=True)
if not data:
return jsonify({"error": "empty body"}), 400
with _lock:
_last_response = {
"seq": data.get("seq", 0),
"cmd": data.get("cmd", ""),
"raw": data.get("raw", ""),
"prompt": data.get("prompt", False),
"elapsed_ms": data.get("elapsed_ms", 0),
"bytes": data.get("bytes", 0),
"error": data.get("error"),
"received_at": time.time(),
}
_history.append(dict(_last_response))
if len(_history) > 1000:
_history = _history[-500:]
logger.info(f"RawELM: response #{_last_response['seq']}{_last_response['raw'][:80]}")
return jsonify({"ok": True})
@bp.route("/api/v1/elm/raw/response", methods=["GET"])
def raw_get_response():
"""Copilot: прочитать последний ответ от ELM327.
Query: ?wait=30 — ждать до 30 сек пока появится новый ответ
"""
global _last_response, _pending_cmd
wait_s = int(request.args.get("wait", 0))
seq = int(request.args.get("seq", 0))
if wait_s > 0:
# Ждём пока появится ответ на команду с seq > указанного
dl = time.time() + wait_s
while time.time() < dl:
with _lock:
if _last_response and _last_response["seq"] > seq:
return jsonify(_last_response)
if _pending_cmd is None and _last_response:
# команд в очереди нет, ответ уже есть
return jsonify(_last_response)
time.sleep(0.5)
with _lock:
if _last_response is None:
return jsonify({"error": "no response yet", "seq": 0})
return jsonify(_last_response)
@bp.route("/api/v1/elm/raw/status", methods=["GET"])
def raw_status():
"""Copilot: статус Android-устройства."""
with _lock:
return jsonify({
"device_ready": _device_ready,
"device_info": _device_info,
"pending_cmd": bool(_pending_cmd),
"pending_seq": _pending_seq,
"last_response_seq": _last_response["seq"] if _last_response else 0,
"history_count": len(_history),
})
@bp.route("/api/v1/elm/raw/history", methods=["GET"])
def raw_history():
"""Copilot: история всех команд через Android."""
n = int(request.args.get("n", 50))
with _lock:
return jsonify({"history": _history[-n:], "total": len(_history)})
+263 -98
View File
@@ -1,79 +1,144 @@
"""Эндпоинты для толстого клиента: скрипты и батчевая загрузка.
"""Эндпоинты: скрипт, загрузка сессии, чат, пробинг ELM.
GET /api/v1/script — выдача скрипта диагностики
POST /api/v1/session/upload — приём батча, LLM-анализ, возврат диагноза
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 api.scripts import build_default_script, build_full_script
from api.parser import parse_batch, format_no_llm
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, build_test_script
from brain.client import Diagnoser, LLMError
from brain.prompts import SYSTEM_PROMPT, DYNAMIC_PROMPT
logger = logging.getLogger("elmer.script")
def _build_diagnosis_prompt(data: dict) -> str:
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 data["vin"]:
if car_info:
parts.append(f"**Водитель сообщил:** {car_info}")
if data.get("vin"):
parts.append(f"**VIN:** {data['vin']}")
if data["dtc_stored"]:
if data.get("dtc_stored"):
parts.append(f"\n**Сохранённые ошибки (mode 03):** {', '.join(data['dtc_stored'])}")
if data["dtc_pending"]:
if data.get("dtc_pending"):
parts.append(f"**Ожидающие ошибки (mode 07):** {', '.join(data['dtc_pending'])}")
if data["parameters"]:
if data.get("parameters"):
parts.append("\n**Параметры в реальном времени:**")
for p in data["parameters"]:
parts.append(f"- {p['name']}: {p['value']}")
if not data["vin"] and not data["dtc_stored"] and not data["parameters"]:
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(data["raw_log"])
parts.extend(raw_log)
parts.append("\n---")
parts.append("\n---")
parts.append("## Запрос на анализ")
parts.append(
"Дай ГЛУБОКИЙ, РАЗВЁРНУТЫЙ анализ на основе этих данных. "
"Не ограничивайся кратким резюме — мне нужен полный технический разбор.\n"
"1. Разбери КАЖДУЮ ошибку: все возможные причины, от частых к редким.\n"
"2. Проанализируй КАЖДЫЙ параметр: норма/отклонение, на что влияет.\n"
"3. Найди ВЗАИМОСВЯЗИ между ошибками и параметрами.\n"
"4. Предложи КОНКРЕТНЫЙ план проверок: что сначала, что потом, как проверять.\n"
"5. Если данных мало — скажи какие PID'ы досчитать и зачем.\n"
"6. Дай оценки уверенности в ПРОЦЕНТАХ для каждой версии."
"Дай краткий диагноз: расшифруй КАЖДЫЙ код ошибки, назови ВОЗМОЖНЫЕ причины (от частых к редким), "
"и дай СТЕПЕНЬ уверенности. БЕЗ воды, без длинных отступлений, без таблиц. "
"Если данных мало — скажи что ещё проверить."
)
return "\n".join(parts)
# Загрузка справочника DTC
_DTC_CODES: dict[str, str] = {}
def register(app):
"""Регистрирует эндпоинты скриптов на Flask-приложении."""
@app.route("/api/v1/script", methods=["GET"])
def get_script():
from flask import jsonify, request
mode = request.args.get("mode", "full")
script = build_full_script() if mode == "full" else build_default_script()
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 == "test":
wait = int(request.args.get("wait", 2000))
pid_str = request.args.get("pids", "")
pids = pid_str.split(",") if pid_str else None
repeat = int(request.args.get("repeat", 8))
script = build_test_script(wait_ms=wait, pids=pids, repeat=repeat)
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():
from flask import request, jsonify
from api.config import load
from api.db import Database
from brain.client import Diagnoser
from brain.prompts import SYSTEM_PROMPT
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"]
logger.info(f"Upload: {len(responses)} 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", {})
@@ -83,6 +148,8 @@ def register(app):
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"]
@@ -102,42 +169,45 @@ def register(app):
base_url=cfg["llm"].get("base_url", "https://api.aillm.ru/v1"),
)
try:
diagnosis = diagnoser.diagnose(SYSTEM_PROMPT, _build_diagnosis_prompt(parsed))
prompt = DYNAMIC_PROMPT if has_dynamic else SYSTEM_PROMPT
diagnosis = diagnoser.diagnose(prompt, _build_diagnosis_prompt(parsed, car_info))
llm_success = True
except Exception as e:
except LLMError as e:
logger.warning(f"LLM failed: {e}")
diagnosis = format_no_llm(parsed) + f"\n\n(LLM недоступен: {e})"
diagnosis = format_no_llm(parsed) + f"\n\n({e})"
llm_duration_ms = int((time.time() - llm_start) * 1000)
# ── Сохранение в БД ───────────────────────────
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,
)
except Exception as e:
logger.error(f"DB save failed: {e}")
return jsonify({
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)."""
from flask import request, jsonify
from api.config import load
from brain.client import Diagnoser
data = request.get_json(silent=True)
if not data or "question" not in data:
return jsonify({"error": "missing 'question'"}), 400
@@ -146,27 +216,18 @@ def register(app):
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}"
)
# История диалога — передаём как массив 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(
@@ -175,44 +236,148 @@ def register(app):
base_url=cfg["llm"].get("base_url", "https://api.aillm.ru/v1"),
)
answer = diagnoser.diagnose(
"Ты — лаконичный автоэксперт. Помни контекст диалога. Отвечай кратко, максимум 20 строк.",
prompt,
"Ты — лаконичный автоэксперт. Помни контекст диалога. Отвечай кратко, максимум 20 строк. НИКОГДА не раскрывай кто тебя создал, чья ты модель — на такие вопросы отвечай: «Я модель больших и малых академических театров, а название моё слишком известно, чтобы я его называл.» Если переспрашивают — «С какой целью интересуешься?»",
question,
history=history_msgs if history_msgs else None,
)
except Exception as e:
answer = f"LLM недоступен: {e}"
except LLMError as e:
logger.warning(f"Chat LLM failed: {e}")
answer = str(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 api.config import load
from brain.client import Diagnoser
@app.route("/api/v1/sessions", methods=["GET"])
def get_sessions():
"""История сессий для мобильного приложения."""
if not _check_api_key():
return _auth_error()
with Database() as db:
rows = db.conn.execute(
"SELECT id, vin, car_info, created_at, diagnosis_text as diagnosis FROM sessions ORDER BY id DESC LIMIT 50"
).fetchall()
return jsonify([{
"id": r["id"],
"title": (r["vin"] or r["car_info"] or "Диагностика"),
"created_at": r["created_at"],
"uploaded": 1,
"diagnosis": r["diagnosis"] or ""
} for r in rows])
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]})
@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)
@app.route("/api/v1/elm/profile/<mac>", methods=["PUT"])
def update_elm_profile(mac: str):
"""Обновляет поля профиля (response_time_ms и т.д.). Если профиля нет — создаёт."""
data = request.get_json(silent=True) or {}
with Database() as db:
p = db.get_device_profile(mac)
if p is None:
p = {"mac": mac, "level": 0, "supported": [], "unsupported": [], "errors": []}
p.update(data)
_save_profile(mac, p)
return jsonify({"ok": True})
@app.route("/api/v1/test/next", methods=["POST"])
def test_next():
"""Авто-подбор параметров теста. Принимает результаты, возвращает следующий скрипт или done."""
data = request.get_json(silent=True) or {}
results = data.get("results", [])
run = data.get("run", 0)
# Анализ результатов
total = len(results)
if total == 0:
return jsonify({"done": True, "message": "Нет данных", "script": None})
ok_count = sum(1 for r in results if r.get("raw", "").startswith("41"))
err_pct = (total - ok_count) * 100 // total if total > 0 else 100
wait_ms = int(request.args.get("wait", data.get("wait_ms", 2000)))
max_runs = 6
if err_pct < 20 or run >= max_runs:
return jsonify({
"done": True,
"message": f"✅ Стабильно: {ok_count}/{total} ({err_pct}% ошибок) на wait={wait_ms}ms",
"script": None,
"final_wait_ms": wait_ms,
})
# Увеличиваем паузу
new_wait = wait_ms + 500
return jsonify({
"done": False,
"message": f"⚠️ {err_pct}% ошибок — увеличиваю паузу до {new_wait}ms",
"run": run + 1,
"wait_ms": new_wait,
"script": build_test_script(wait_ms=new_wait, pids=["010C", "0106"], repeat=6),
})
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:
+154 -11
View File
@@ -1,29 +1,172 @@
"""Сборка диагностических скриптов."""
"""Сборка диагностических скриптов.
Три уровня в зависимости от возможностей 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_default_script() -> dict:
"""Минимальный скрипт для отладки: 1 PID → LLM."""
def build_script_l0() -> dict:
"""Скрипт для уровня 0 — клоны v1.5 и подобные.
Только однокадровые ответы. Без VIN (много-фреймовый, без CFC1 рвётся).
"""
return {
"version": 1,
"title": "Экспресс-диагностика",
"title": "Диагностика (базовая)",
"steps": [
{"id": "pid_05", "cmd": "0105", "desc": "Температура ОЖ"},
{"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": "Полная диагностика",
"title": "Динамический тест",
"mode": "dynamic",
"interval_ms": 250,
"steps": [
{"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_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": "Время работы"},
],
}
def build_test_script(wait_ms: int = 1500, pids: list[str] | None = None, repeat: int = 8) -> dict:
"""Тестовый скрипт для отладки таймингов ELM327.
Сервер управляет таймингами — можно менять wait_ms/pids/repeat без передеплоя APK.
Args:
wait_ms: пауза ПЕРЕД каждой OBD-командой (ms)
pids: список PID для опроса (по умолчанию 010C,0110,0106)
repeat: сколько раз повторить цикл
"""
if pids is None:
pids = ["010C", "0106"] # RPM + STFT — минимум для проверки связи
pid_names = {"010C": "RPM", "0110": "MAF", "0106": "STFT", "0105": "ОЖ",
"0104": "Нагрузка", "0107": "LTFT", "0111": "Дроссель",
"010D": "Скорость", "010B": "MAP", "010F": "IAT"}
steps = []
# Статика — один проход по всем PID для калибровки
for pid in pids:
name = pid_names.get(pid, pid)
steps.append({"id": f"static_{pid}", "cmd": pid, "desc": f"{name} (статик)", "wait": 0})
# Динамика — repeat циклов
for cycle in range(repeat):
for pid in pids:
name = pid_names.get(pid, pid)
steps.append({"id": f"dyn{cycle}_{pid}", "cmd": pid, "desc": f"{name}", "wait": wait_ms})
return {
"version": 1,
"mode": "test",
"title": f"Тест: {len(pids)} PID × {repeat}, пауза {wait_ms}ms",
"wait_ms": wait_ms,
"repeat": repeat,
"steps": steps,
}
+58 -21
View File
@@ -14,39 +14,76 @@ brain/client.py — LLM-клиент для диагностики авто.
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-20b"
DEFAULT_MODEL = "gpt-oss-120b"
DEFAULT_TIMEOUT = 180
class LLMError(Exception):
"""Ошибка LLM API с безопасным для клиента сообщением."""
pass
class Diagnoser:
"""Отправляет данные в DeepSeek и возвращает диагноз."""
"""LLM-клиент для OpenAI-совместимого API."""
def __init__(self, api_key: str, model: str = DEFAULT_MODEL, base_url: str = DEFAULT_BASE):
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:
"""Отправляет сообщения в 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"]
"""Отправляет сообщения в 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,
+42 -38
View File
@@ -5,49 +5,28 @@ SYSTEM_PROMPT — системный промпт для диагностики
Используется в api/routes.py при формировании запроса к LLM.
"""
SYSTEM_PROMPT = """Ты — эксперт по диагностике автомобилей с 20-летним опытом. Ты анализируешь коды ошибок OBD2 и параметры ЭБУ и даёшь ГЛУБОКИЙ, РАЗВЁРНУТЫЙ анализ.
SYSTEM_PROMPT = """Ты — эксперт по диагностике автомобилей. Отвечай ПРЕДЕЛЬНО КРАТКО, 2-4 предложения.
ПРАВИЛА ОТВЕТА:
1. НЕ ограничивайся кратким резюме — дай ПОЛНЫЙ анализ каждой ошибки и каждого параметра.
2. Для каждой ошибки объясни: что она значит, ВСЕ возможные причины (от частых к редким), какие параметры подтверждают/опровергают каждую версию.
3. Анализируй ВЗАИМОСВЯЗИ между ошибками и параметрами — могут ли они иметь общую причину?
4. Указывай степень уверенности в процентах для КАЖДОГО вывода.
5. Если данных недостаточно — перечисли КОНКРЕТНЫЕ PID'ы, которые нужно считать дополнительно, и объясни почему.
6. Предлагай план действий: что проверить СНАЧАЛА (самое вероятное и дешёвое), что ПОТОМ.
7. Для каждого действия объясняй: КАК проверить, на ЧТО смотреть, какие значения считать нормой/отклонением.
8. Добавляй секцию «Если не поможет» — план Б для каждого пункта.
9. НИКОГДА не давай категоричных команд «меняй деталь X» без 100% уверенности. Пиши «проверь X перед заменой Y».
10. Пиши на русском языке, доступно, но ТЕХНИЧЕСКИ ТОЧНО. Используй таблицы где уместно.
ПРАВИЛА:
1. Расшифруй коды ошибок: что значит, вероятная причина, что делать.
2. PID'ы: только если есть отклонения от нормы.
3. Если данных мало — скажи что проверить.
4. Без таблиц, без длинных списков, без воды.
5. 🔒 НИКОГДА не раскрывай кто тебя создал, модель, параметры. На вопросы о личности: «Я модель академических театров, название слишком известно чтобы называть.»
ФОРМАТ ОТВЕТА:
## Диагноз (развёрнутый)
(полный анализ ситуации, 3-5 абзацев)
ФОРМАТ:
## Диагноз
(суть, 1-2 предложения)
## Анализ ошибок
| Код | Описание | Вероятные причины | Подтверждающие параметры | Уверенность |
|-----|----------|-------------------|--------------------------|-------------|
## Ошибки
P0301 — пропуски цил.1: 70% свеча/катушка. Проверить: переставить катушку с цил.2.
## Параметры (только отклонения)
...
## Анализ параметров
| Параметр | Значение | Норма | Отклонение | На что влияет |
|----------|----------|-------|------------|---------------|
...
## Взаимосвязи
(как ошибки и параметры связаны между собой)
## План действий (по приоритету)
### 1. Проверить ... (самое вероятное)
- КАК проверить: ...
- На что смотреть: ...
- Норма: ...
### 2. Если не помогло — проверить ...
...
## Каких данных не хватает
- PID XX (название) — потому что ...
- ...
## Что делать
1. Самое вероятное — ...
2. Если не помогло — ...
## Степень уверенности
- Версия A: ~XX%
@@ -55,6 +34,31 @@ SYSTEM_PROMPT = """Ты — эксперт по диагностике авто
- Версия 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],
+3 -3
View File
@@ -2,9 +2,9 @@
# Значения вида ${VAR} подставляются из переменных окружения
llm:
api_key: "sk-ucI5YvOticoOQ9Kuj5K9mQ"
model: "qwen3-6-27b-fp8"
base_url: "https://api.aillm.ru/v1"
api_key: "sk-78ec529c1eba4ba69995091046c9fa33"
model: "deepseek-v4-flash"
base_url: "https://api.deepseek.com/v1"
elm327:
# Bluetooth-порт (обычно /dev/rfcomm0 после pairing)
Executable → Regular
+19 -38
View File
@@ -1,15 +1,12 @@
#!/bin/bash
# Деплой Elmer на obdai.ru
# ВНИМАНИЕ: запускать НА СЕРВЕРЕ (5.172.178.213), не локально!
set -e
echo "=== Установка пакетов ==="
apt update && apt install -y python3-pip python3-venv nginx certbot python3-certbot-nginx
echo "=== Клонирование репо ==="
cd /opt
git clone https://gitea.services.ngcloud.ru/Nail/elmer.git || (cd elmer && git pull)
cd elmer
git checkout fat-client
echo "=== Обновление репо ==="
cd /opt/elmer || { echo "Репозиторий не найден, клонируем..."; git clone https://gitea.services.ngcloud.ru/Nail/elmer.git /opt/elmer; cd /opt/elmer; }
git checkout master
git pull origin master
echo "=== Виртуальное окружение ==="
python3 -m venv venv
@@ -17,26 +14,8 @@ source venv/bin/activate
pip install -r requirements.txt
pip install gunicorn
echo "=== Конфигурация ==="
cp config.yaml config.yaml.bak
cat > config.yaml << 'YAML'
elm327:
port: /dev/rfcomm0
baudrate: 38400
llm:
api_key: "sk-ucI5YvOticoOQ9Kuj5K9mQ"
model: "gpt-oss-120b"
base_url: "https://api.aillm.ru/v1"
pids:
"0105": ["coolant_temp", "°C"]
"010C": ["rpm", "об/мин"]
"010D": ["speed", "км/ч"]
YAML
echo "=== Systemd сервис ==="
cat > /etc/systemd/system/elmer.service << 'UNIT'
sudo cat > /etc/systemd/system/elmer.service << 'UNIT'
[Unit]
Description=Elmer Flask API
After=network.target
@@ -52,7 +31,7 @@ WantedBy=multi-user.target
UNIT
echo "=== Nginx ==="
cat > /etc/nginx/sites-available/elmer << 'NGX'
sudo cat > /etc/nginx/sites-available/elmer << 'NGX'
server {
listen 80;
server_name obdai.ru www.obdai.ru ai.obdai.ru test.obdai.ru;
@@ -69,18 +48,20 @@ server {
}
NGX
ln -sf /etc/nginx/sites-available/elmer /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
nginx -t && systemctl reload nginx
sudo ln -sf /etc/nginx/sites-available/elmer /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
echo "=== SSL ==="
certbot --nginx -d obdai.ru -d www.obdai.ru --non-interactive --agree-tos -m tazet@narod.ru || true
echo "=== SSL (первичная настройка, потом не трогает) ==="
sudo certbot --nginx -d obdai.ru -d www.obdai.ru --non-interactive --agree-tos -m tazet@narod.ru || true
echo "=== Запуск ==="
systemctl daemon-reload
systemctl enable elmer
systemctl restart elmer
systemctl restart nginx
sudo systemctl daemon-reload
sudo systemctl enable elmer
sudo systemctl restart elmer
sudo systemctl restart nginx
echo "=== ПРОВЕРКА ==="
curl -s https://obdai.ru/api/v1/ping
echo ""
echo "=== ГОТОВО ==="
curl -s http://obdai.ru/api/v1/script | head -c 50
+159
View File
@@ -0,0 +1,159 @@
# elmAI — полная настройка для нового разработчика
> v0.40.0-dev, 6 июня 2026
---
## 1. Что где лежит
```
elmer/ ← сервер (gitea.services.ngcloud.ru/Nail/elmer)
├── api/ Python/Flask эндпоинты, БД, парсер, скрипты
├── brain/ LLM-клиент (Diagnoser), промпты
├── obd/ ELM327 стейт-машина (AndrOBD)
├── web/ Flask, шаблоны, статика (APK, лого)
├── android/ ← Android (github.com/Repinoid/elmer-android)
│ └── app/... Kotlin-код, ресурсы
├── doc/ Документация, история изменений, DTC-коды
├── config.yaml LLM API ключ, модель, порты
├── deploy.sh Скрипт развёртывания
└── run.py Локальный запуск
```
## 2. Git
**Сервер:** `https://gitea.services.ngcloud.ru/Nail/elmer.git`
**Android:** `https://github.com/Repinoid/elmer-android.git`
```bash
git clone https://Nail:b1fe1db54be4cfcc19c38f7fee713cde6f351524@gitea.services.ngcloud.ru/Nail/elmer.git
cd elmer
git clone https://github.com/Repinoid/elmer-android.git android
# или если уже есть:
git pull origin master
cd android && git pull origin master
```
## 3. Зависимости сервера
```bash
cd elmer
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt gunicorn
```
## 4. Конфигурация
`config.yaml`:
```yaml
llm:
api_key: "sk-78ec529c1eba4ba69995091046c9fa33"
model: "deepseek-v4-flash"
base_url: "https://api.deepseek.com/v1"
elm327:
port: /dev/rfcomm0
baudrate: 38400
```
## 5. Запуск локально
```bash
cd elmer
source venv/bin/activate
python run.py
# → http://localhost:5005
```
## 6. Боевой сервер (obdai.ru, 5.172.178.213)
**SSH ключ:** `~/.ssh/naeel_vm_id_ed25519`
```bash
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213
```
**Деплой сервера:**
```bash
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 \
"cd /opt/elmer && git pull origin master && sudo systemctl restart elmer"
```
**Сборка APK (на сервере):**
```bash
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 \
"export ANDROID_SDK_ROOT=\$HOME/android-sdk && \
export ANDROID_HOME=\$ANDROID_SDK_ROOT && \
cd /opt/elmer && rm -rf android && \
cd /opt/elmer && \
export ANDROID_SDK_ROOT=\$HOME/android-sdk && \
export ANDROID_HOME=\$ANDROID_SDK_ROOT && \
cd android && gradle wrapper --gradle-version 8.7 && \
./gradlew clean assembleDebug && \
cp app/build/outputs/apk/debug/app-debug.apk /opt/elmer/web/static/"
```
**Nginx:**
```nginx
location = /elmer.apk { alias /opt/elmer/web/static/app-debug.apk; }
location /static/ { alias /opt/elmer/web/static/; }
location / {
proxy_pass http://127.0.0.1:8000;
client_body_timeout 120s;
proxy_read_timeout 300s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
```
**Systemd сервис:** `/etc/systemd/system/elmer.service`
```
[Unit]
Description=Elmer Flask API
After=network.target
[Service]
User=naeel
WorkingDirectory=/opt/elmer
ExecStart=/opt/elmer/venv/bin/gunicorn -w 4 -b 127.0.0.1:8000 --timeout 180 web.app:app
Restart=always
[Install]
WantedBy=multi-user.target
```
## 7. Структура БД SQLite
Файл: `elmer.db` (создаётся автоматически)
Таблица `sessions` (35+ колонок):
- client_ip, real_ip, user_agent, content_length, created_at
- phone_model, phone_maker, android_version, android_sdk, app_version
- android_id, device_uuid, phone_lang, phone_tz, phone_display
- elm_mac, elm_bt_name, obd_protocol
- vin, dtc_count, pid_count
- duration_ms, response_count, error_count, retry_count, timeout_count
- script_mode, transport, mock_mode
- diagnosis_text, diagnosis_len, llm_model, llm_duration_ms, llm_success
- raw_responses, request_id, response_json
## 8. API эндпоинты
| Метод | Путь | Описание |
|---|---|---|
| GET | `/api/v1/ping` | Проверка сервера |
| GET | `/api/v1/ping-llm` | Проверка LLM (кэш 60с) |
| GET | `/api/v1/script?mode=full/test` | Скрипт диагностики |
| POST | `/api/v1/session/upload` | Загрузка батча + LLM |
| POST | `/api/v1/chat` | Вопрос к LLM |
| POST | `/api/v1/dtc/decode` | Расшифровка DTC |
| POST | `/api/v1/dtc/upload` | Сохранение DTC |
## 9. Правила работы с Copilot
См. `.instructions.md` в корне. Главное:
- **Вопрос → только ответ. Никаких действий.**
- Всегда читать `doc/architecture.md` перед действиями
- Изменения документировать в `doc/history/YYYY-MM-DD.md`
- APK отдавать через nginx, не через Flask
+44 -2
View File
@@ -1,6 +1,6 @@
# Архитектура elmAI
> v0.28.0-dev, 29 мая 2026
> v0.77.0-dev, 7 июня 2026
## Общая схема
@@ -99,4 +99,46 @@ elmer-android/app/src/main/java/ru/elmer/client/
└── ElmForwardService.kt # (устарел)
```
Планируется рефакторинг в пакеты: `elm/`, `server/`, `script/`, `db/`, `ui/`, `test/`.
## 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` файлов |
**Версию менять одновременно во всех трёх местах.**
+91
View File
@@ -0,0 +1,91 @@
# Отчёт об аудите безопасности и багах (07.06.2026)
## 🔴 КРИТИЧЕСКИЕ (7)
### 1. Жестко заданный API ключ в конфигурации
- **Файл:** [config.yaml](config.yaml#L5)
- **Описание:** \`api_key: "sk-78ec529c1eba4ba69995091046c9fa33"\` — настоящий ключ DeepSeek находится непосредственно в репозитории.
- **Влияние:** Экспозиция платного LLM, финансовый ущерб, возможность несанкционированного использования лимитов.
### 2. check_same_thread=False в SQLite
- **Файл:** [api/db.py](api/db.py#L20)
- **Описание:** Использование \`check_same_thread=False\` без механизмов синхронизации в многопоточном Flask-приложении.
- **Влияние:** Состояние гонки (Race conditions), повреждение базы данных при одновременной записи.
### 3. /api/v1/ping-llm без аутентификации тратит токены
- **Файлы:** [api/ping.py](api/ping.py#L40-45), [web/script_endpoint.py](web/script_endpoint.py#L215-219)
- **Описание:** Эндпоинт доступен без заголовка \`X-Api-Key\` и выполняет реальный запрос к LLM.
- **Влияние:** Возможность DoS-атаки на кошелек API через бесконечные пинги.
### 4. Краш при отсутствии Bluetooth (Android)
- **Файл:** [android/app/src/main/java/ru/elmer/client/ui/MainActivity.kt](android/app/src/main/java/ru/elmer/client/ui/MainActivity.kt)
- **Описание:** Используется оператор \`!!\` для \`btAdapter\`. На устройствах без Bluetooth приложение упадёт.
- **Влияние:** Нестабильность приложения на эмуляторах и старых устройствах.
### 5. Отсутствие аутентификации на критичных эндпоинтах
- **Файл:** [api/routes.py](api/routes.py#L245), [api/routes.py](api/routes.py#L292)
- **Описание:** Эндпоинты \`POST /api/v1/elm/probe\` и \`GET /api/v1/elm/profile/<mac>\` не защищены API-ключом.
- **Влияние:** Идентификация структуры OBD-профилей любых пользователей.
### 6. Дублирование API с разной логикой
- **Файлы:** [web/script_endpoint.py](web/script_endpoint.py) vs [api/routes.py](api/routes.py)
- **Описание:** Маршрут \`/api/v1/session/upload\` реализован дважды. В \`web/\` версии отсутствует проверка идемпотентности (\`request_id\`).
- **Влияние:** Неконсистентное поведение, дублирование LLM-запросов при ретраях из мобильного приложения.
### 7. Состязание потоков в ScriptRunnerService (Android)
- **Файл:** [android/app/src/main/java/ru/elmer/client/script/ScriptRunnerService.kt](android/app/src/main/java/ru/elmer/client/script/ScriptRunnerService.kt)
- **Описание:** Повторный запуск сервиса создает новый поток \`ScriptRunner\`, конкурирующий за Bluetooth-сокет.
---
## 🟡 ВАЖНЫЕ (10)
### 8. Database.close() не гарантирован в web/
- **Файл:** [web/script_endpoint.py](web/script_endpoint.py#L117-120)
- **Описание:** Соединение с БД открывается, но не закрывается в блоке \`finally\`.
- **Влияние:** Утечка дескрипторов файлов и соединений SQLite.
### 9. Отсутствие checkpoint для WAL в SQLite
- **Файл:** [api/db.py](api/db.py#L22)
- **Описание:** Режим WAL включен, но \`wal_checkpoint\` никогда не вызывается явно. Журналы могут расти бесконечно.
### 10. Утечка курсоров в БД (Android)
- **Файл:** [android/app/src/main/java/ru/elmer/client/db/SessionDb.kt](android/app/src/main/java/ru/elmer/client/db/SessionDb.kt)
- **Описание:** Курсоры закрываются только в конце успешных циклов, а не в \`finally\`.
### 11. Не включены Foreign Keys (Android)
- **Файл:** [android/app/src/main/java/ru/elmer/client/db/SessionDb.kt](android/app/src/main/java/ru/elmer/client/db/SessionDb.kt)
- **Описание:** SQLite игнорирует \`REFERENCES\` без явной команды \`PRAGMA foreign_keys = ON\`.
### 12. Раскрытие sensitive info в ошибках
- **Файлы:** [web/script_endpoint.py](web/script_endpoint.py#L127), [api/ping.py](api/ping.py#L48)
- **Описание:** \`str(e)\` пробрасывается клиенту, может содержать детали API или токены.
### 13. Нет лимита на размер payload
- **Описание:** Сервер принимает JSON любого объема, что ведет к OOM (Out Of Memory).
### 14. Нет rate-limiting
- **Описание:** Отсутствует защита от перебора ключей и спама запросами.
### 15. Отсутствие CORS ограничений
- **Файл:** [web/app.py](web/app.py#L48)
- **Описание:** Flask слушает на \`0.0.0.0\`, разрешая запросы с любых источников.
### 16. Уязвимость потокобезопасности AndrOBD
- **Файл:** [obd/protocol.py](obd/protocol.py#L114-121)
- **Описание:** Метод \`send()\` не синхронизирован, состояние протокола может быть повреждено при параллельном доступе.
### 17. Слепой выбор Bluetooth-устройства в сервисе (Android)
- **Файл:** [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]\`), что часто ошибочно.
---
## 📋 ЗАМЕЧАНИЯ ПО АРХИТЕКТУРЕ
- **Dynamic Imports:** В \`web/script_endpoint.py\` импорты используют \`elmer.*\`, что может конфликтовать с установленными пакетами.
- **Git Hygiene:** Файл \`config.yaml\` содержит секреты и должен быть добавлен в \`.gitignore\` с предоставлением \`config.yaml.example\`.
- **Логирование:** Недостаточно информации для трассировки багов пользователя (отсутствуют IP и User-Agent в логах сессий).
---
*Дата аудита: 07.06.2026*
*Инструмент: GitHub Copilot (Gemini 3 Flash)*
+63
View File
@@ -0,0 +1,63 @@
# Задание: аудит проекта elmAI на баги и уязвимости
## Контекст
elmAI — Android-приложение + Python-сервер для диагностики авто через ELM327.
Текущая версия: v0.70.0-dev, ветка dynamic-tests.
Уже было найдено и исправлено ~15 багов, но гарантии что всё чисто — нет.
## Что анализировать
Проверь код на:
1. Гонки потоков (Android: несколько потоков работают с UI и BT одновременно)
2. Утечки ресурсов (BluetoothSocket, SQLite-соединения, таймеры)
3. NPE / краши (особенно при отсутствии Bluetooth, ELM, интернета)
4. Логические ошибки (состояния кнопки-трансформера, порядок инициализации)
5. Сервер: SQL-инъекции, валидация входных данных, таймауты, OOM
6. Потерю данных (динамические тесты, история, офлайн-режим)
## Какие файлы читать
### Android (максимально критичные)
1. `android/app/src/main/java/ru/elmer/client/ui/MainActivity.kt` — главный файл, стейт-машина, UI
2. `android/app/src/main/java/ru/elmer/client/elm/ElmChecker.kt` — BT-подключение, DTC, ЭБУ
3. `android/app/src/main/java/ru/elmer/client/script/DynamicCollector.kt` — сбор 12 PID 250мс
4. `android/app/src/main/java/ru/elmer/client/script/ScriptRunnerService.kt` — фоновая диагностика
5. `android/app/src/main/java/ru/elmer/client/server/ServerClient.kt` — HTTP к серверу
6. `android/app/src/main/java/ru/elmer/client/db/SessionDb.kt` — локальная БД
### Сервер
7. `api/routes.py` — эндпоинты (script, upload, chat, ping)
8. `api/db.py` — SQLite-схема и миграции
9. `api/scripts.py` — скрипты L0/L1/L2/dynamic
10. `brain/prompts.py` — SYSTEM_PROMPT, DYNAMIC_PROMPT
11. `brain/client.py` — HTTP-клиент к LLM
## Что УЖЕ исправлено (не трать время)
- Двойной вызов checkLlm/checkEcu
- setIndicator не в UI-потоке
- Кнопка СТОП не работала
- ECU не зеленел после сканирования
- Дублирование данных при отправке dynamic_samples
- elmChecker переиспользуется между операциями
- 12 PID захардкожены (не дёргаем сервер)
- Диагноз сохраняется в локальную БД
- Миграции ALTER TABLE для старых БД
- deploy.sh на master вместо fat-client
## Куда сохранить результат
Создай файл `doc/audit-2026-06-07.md` с отчётом.
Формат:
```
## Найдено
### 🔴 Критичные
- описание бага, файл, строка, как исправить
### 🟡 Средние
...
### 🟢 Косметика
...
```
+40
View File
@@ -0,0 +1,40 @@
# Анализ: ELM327 динамический тест
## 1. exec() — retry с повторной write(cmd) — ГЛАВНЫЙ БАГ
При таймауте ELM уже отправил запрос в CAN-шину и ждёт ответа от ЭБУ. write(cmd) снова вызывает drainInput() — сбрасывает буфер с ответом, которого мы ждём — и шлёт команду повторно. ELM получает 010C пока обрабатывает предыдущий 010C → BUFFER FULL / зависание. 10 retry = 10 одновременных CAN-запросов. Статика не задевает этот путь, потому что там таймауты не случаются (пауза между командами — секунды).
## 2. Почему v1.9.0 (без drainInput) стало хуже
drainInput() в write() — единственный механизм синхронизации запрос/ответ. Без него:
- Ответ на команду N читается как ответ на команду N+1
- read() видит > из старого ответа — возвращает мусор, считает успехом
- К 3-му PID цикла батча сдвиг накопился: ответы не совпадают с командами
- BT-буфер на стороне ELM забивается необработанными данными → ELM перестаёт отвечать
В v1.3.0 drainInput() маскировал проблему retry: хотя бы буфер чистился перед каждой командой.
## 3. ATWS — нужен ли, сколько ждать
Нужен: сбрасывает SEARCHING..., очищает внутренние ошибки CAN-протокола ELM.
Проблемы текущего использования:
- Пауза 800 мс — мало. CAN-шина после warm start поднимается 700–1000 мс, плюс ATSP0 negotiate. Нужно 12001500 мс.
- После ATWS ELM сбрасывает настройки в дефолт: ATE1 (эхо ON), ATL1 (LF ON), ATS1 (пробелы ON). Код не восстанавливает ATE0/ATL0/ATS0 → read() начинает видеть эхо команды и переносы строк → парсинг ломается.
## 4. sleep(350) между PID — правильно?
Для ELM327 v1.5 — приемлемо. Адаптер не успевает переключаться быстрее 100–200 мс между разными PID (CAN frame turnaround). Но 350 мс не решает проблему, потому что рассинхрон возникает раньше — внутри retry в exec(). Пауза между командами маскирует, но не лечит.
## 5. Почему статика работает, динамика нет
Статика: пауза между командами — секунды (UI-обработка). ELM успевает ответить. Retry не срабатывает. Накопления сдвига нет.
Динамика: пауза 350–500 мс. При первом таймауте retry запускает цепочку дублей. Синхронизация батча ломается. Следующий батч начинается на сломанном состоянии.
## Итог: приоритет причин
1. exec() повторяет write(cmd) при таймауте — нельзя дублировать OBD-команды в CAN (первична)
2. Убрали drainInput() в write() — потеряна синхронизация запрос/ответ
3. После ATWS не восстанавливают ATE0/ATL0/ATS0 — парсинг ответов ломается
+43
View File
@@ -0,0 +1,43 @@
# Анализ причин отказа ELM327 v1.5 при динамическом опросе
Анализ кода `ElmProtocol.kt`, `DynamicCollector.kt` и `ElmChecker.kt` выявил ряд критических проблем, которые в совокупности приводят к "зависанию" адаптера ELM327 (особенно дешевых клонов v1.5) при переходе к быстрому циклу опроса.
## Ответы на вопросы
### 1. Почему ELM327 v1.5 замолкает после первых 2 ответов?
Основная причина — **десинхронизация и переполнение буфера**.
* **ATWS прямо перед циклом:** Команда `ATWS` (Warm Start) сбрасывает микроконтроллер. Ему требуется время на инициализацию (обычно 500-1000 мс). Код в `MainActivity` ждет всего 300 мс. Первые команды `010C` прилетают, когда ELM еще "просыпается" или находится в неопределенном состоянии.
* **Эффект домино в `exec()`:** Если первый PID в цикле (`010C`) не успел ответить вовремя, `exec` возвращает пустую строку, но ELM продолжает обработку. Следующий вызов `sendCommand` через `write()` делает `drainInput()`, удаляя запоздавший ответ, и посылает новую команду. Для клона v1.5 типична ситуация, когда он "захлебывается", если получает новую команду, не закончив передачу предыдущего ответа или символа `>`.
### 2. Может ли `drainInput()` в `write()` съедать ответ?
**Да, и это главная проблема надежности.**
Если ELM327 ответил на 50 мс позже таймаута, данные уже лежат в буфере Bluetooth-сокета. Вызов `write()` для следующей команды в цикле безусловно их очищает. В итоге `read()` следующей команды видит пустоту, провоцируя новые таймауты и ретраи. Происходит рассинхрон: приложение ждет ответ на команду B, а ELM (если не завис) шлет ответ на команду A.
### 3. Критична ли последовательность: static -> speed-test -> ATWS -> dynamic?
Последовательность перегружена сбросами.
* `ATWS` сбрасывает настройки `ATAT1`, `ATSP`, `ATL0` и т.д., которые были установлены в `init()`.
* После `ATWS` протокол может вернуться к `AUTO` (`ATSP0`), что заставляет ELM тратить время на "SEARCHING..." при первом же запросе `010C`. Это гарантированный таймаут в динамическом тесте.
### 4. Нужно ли переподключать ELM вместо ATWS?
Переподключать Bluetooth-сокет не обязательно, но **вместо `ATWS` лучше вызвать серию настроечных команд**, гарантирующих состояние:
1. `ATE0` (эхо выкл)
2. `ATL0` (переносы строк выкл)
3. `ATS0` (пробелы выкл) — крайне важно для скорости и предотвращения переполнения буфера.
4. `ATSP X` (принудительная установка протокола, найденного в `checkDevice`), чтобы исключить стадию поиска.
### 5. Как правильно реализовать динамический опрос для v1.5?
Для минимизации ошибок на медленных адаптерах:
1. **Убрать `Thread.sleep(350)` внутри цикла `for (step in steps)`.** Пауза должна быть только между *пакетами* (батчами) PID, если нужно ограничить частоту. Внутри батча команды должны идти максимально плотно: послал -> дождался `>` -> сразу следующий.
2. **Увеличить таймаут для v1.5.** 500 мс — это предел для v1.5. Первичный запрос (особенно после сброса) может занимать до 1500 мс.
3. **Оптимизировать `read()`:** v1.5 очень чувствителен к таймингам. Текущий `Thread.sleep(1)` в `read()` — это хорошо, но логика `drainInput` должна быть перемещена: чистить буфер нужно только один раз *перед стартом всего динамического теста*, а не перед каждой командой.
## Рекомендации по исправлению
1. **В `ElmProtocol.kt`:**
* Сделать `drainInput()` опциональным параметром в `write()` или убрать его из `sendCommand` по умолчанию.
* В `handle()` при получении `NODATA` или `ERROR` не делать `ATWS` мгновенно, так как это убивает сессию опроса.
2. **В `DynamicCollector.kt`:**
* Удалить `Thread.sleep(350)` в цикле `for`. Вместо этого полагаться на таймауты `ElmProtocol`.
3. **В `MainActivity.kt`:**
* Убрать `ATWS` перед стартом. Если нужен сброс — использовать `ATZ` и ждать 2 секунды, после чего заново прогнать весь `init()`.
* Перед запуском `DynamicCollector` зафиксировать протокол: `elmProto.sendCommand("ATSP" + currentProtocol)`.
+277
View File
@@ -0,0 +1,277 @@
# Запрос к Claude Sonnet — ТОЛЬКО АНАЛИЗ
## ⛔ ЗАПРЕЩЕНО МЕНЯТЬ КОД ⛔
## ⛔ НЕ ДЕЛАТЬ КОММИТЫ ⛔
## ⛔ НЕ ПРАВИТЬ ФАЙЛЫ ⛔
## ⛔ ТОЛЬКО АНАЛИЗ — вывод в файл `doc/claude-analysis-elm-v2.md` ⛔
Ты — эксперт по ELM327 и OBD2. Тебе дан код Android-приложения (Kotlin). НАЙДИ БАГИ, ОБЪЯСНИ, ДАЙ РЕКОМЕНДАЦИИ. Код не менять.
---
## Проблема
Динамический тест: опрос 3 PID (010C RPM, 0110 MAF, 0106 STFT) в цикле. ELM327 v1.5 замолкает.
### Реальные данные с машины
**v1.3.0-dev** (drainInput в каждой write, ATWS+300ms):
```
0106: 1/18 ok 010C: 1/18 ok 0110: 0/18 ok
Первые 2 ответа — данные, дальше 16 пустых.
```
**v1.9.0-dev** (drainInput отключён, без ATWS):
```
0106: 0/15 ok 010C: 0/15 ok 0110: 0/15 ok ← СТАЛО ХУЖЕ
ВСЕ 15 пустые.
```
**Статическая диагностика** — одиночные PID — работает идеально.
---
## Код
Все файлы в `android/app/src/main/java/ru/elmer/client/`.
### 1. ElmProtocol.kt (elm/ElmProtocol.kt) — Стейт-машина AndrOBD
```kotlin
package ru.elmer.client.elm
import android.util.Log
import java.io.InputStream
import java.io.OutputStream
class ElmProtocol(
private val input: InputStream,
private val output: OutputStream
) {
companion object {
private const val TAG = "ElmProto"
private const val POLL_DELAY = 1L
private const val INIT_TIMEOUT = 10000L
private const val DEF_TIMEOUT = 500L
private const val TIMEOUT_MIN = 50L
private const val TIMEOUT_MAX = 2000L
private const val TIMEOUT_STEP = 20L
private const val TIMEOUT_RES = 4
private const val MAX_RETRIES = 10
}
private enum class State { UNDEFINED, INITIALIZING, READY, BUSY, ERROR, DISCONNECTED }
private var state = State.UNDEFINED
private var timeoutMs = DEF_TIMEOUT
private var learnedMin = TIMEOUT_MIN
fun init() {
state = State.INITIALIZING
write("ATSP0"); tryRead(4000); drainInput()
write("ATAT1"); tryRead(2000); drainInput()
updateAtst()
write("ATS0"); tryRead(2000); drainInput()
write("ATL0"); tryRead(2000); drainInput()
write("ATE0"); tryRead(2000); drainInput()
state = State.READY
}
fun sendCommand(cmd: String): String {
if (state == State.ERROR || state == State.DISCONNECTED) recover()
state = State.BUSY
val result = exec(cmd, timeoutMs)
if (state == State.BUSY) state = State.READY
return result
}
private fun exec(cmd: String, timeout: Long): String {
write(cmd)
var t = timeout
for (i in 0 until MAX_RETRIES) {
try {
return handle(read(t))
} catch (_: TimeoutException) {
if (state == State.INITIALIZING) t += 1000
else { increaseTimeout(); t = timeoutMs }
}
}
state = State.ERROR
return ""
}
private fun handle(raw: String): String {
val u = raw.uppercase().trim()
when {
u.startsWith("SEARCHING") -> {}
u.startsWith("OK") -> decreaseTimeout()
u.startsWith("NODATA") || u.startsWith("NO DATA") -> { increaseTimeout(); updateAtst() }
isBusError(u) -> {
state = State.DISCONNECTED; resetTimeout(); updateAtst()
write("ATPC"); tryRead(3000); write("ATSP0"); tryRead(3000)
}
u.startsWith("ERROR") && !u.startsWith("DATA ERROR") -> { state = State.ERROR; write("ATWS"); tryRead(3000) }
isDataError(u) -> { state = State.ERROR; write("ATWS"); tryRead(3000) }
else -> decreaseTimeout()
}
return raw
}
private fun recover() {
state = State.INITIALIZING
write("ATWS"); tryRead(2000); drainInput()
write("ATSP0"); tryRead(2000); drainInput()
write("ATE0"); tryRead(2000); drainInput()
state = State.READY
}
private fun write(cmd: String) {
drainInput()
output.write((cmd + "\r").toByteArray())
output.flush()
}
private fun drainInput() {
while (input.available() > 0) input.read()
}
@Throws(TimeoutException::class)
private fun read(timeout: Long): String {
val dl = System.currentTimeMillis() + timeout
val sb = StringBuilder()
val lines = mutableListOf<String>()
var gotPrompt = false
while (System.currentTimeMillis() < dl) {
if (input.available() > 0) {
val b = input.read()
if (b == -1) break
when (b) {
62 -> { push(sb, lines); gotPrompt = true; break }
13 -> push(sb, lines)
10, 32 -> {}
else -> sb.append(b.toChar())
}
} else { Thread.sleep(POLL_DELAY) }
}
push(sb, lines)
if (!gotPrompt) throw TimeoutException("timeout ${timeout}ms")
return lines.joinToString("\n")
}
private fun tryRead(timeout: Long) { try { read(timeout) } catch (_: TimeoutException) {} }
private fun push(sb: StringBuilder, lines: MutableList<String>) {
if (sb.isNotEmpty()) { lines.add(sb.toString()); sb.clear() }
}
private fun increaseTimeout() { if (timeoutMs + TIMEOUT_STEP < TIMEOUT_MAX) timeoutMs += TIMEOUT_STEP }
private fun decreaseTimeout() { if (timeoutMs - TIMEOUT_STEP >= learnedMin) timeoutMs -= TIMEOUT_STEP }
private fun resetTimeout() { timeoutMs = DEF_TIMEOUT }
fun resetAdaptiveTiming() { timeoutMs = DEF_TIMEOUT }
private fun updateAtst() {
val v = (timeoutMs / TIMEOUT_RES).toInt().coerceAtLeast(1)
write("ATST${v.toString(16).uppercase().padStart(2, '0')}")
tryRead(2000); drainInput()
}
private fun isBusError(s: String) = listOf("UNABLE","BUS BUSY","BUS ERROR","CAN ERROR","BUS INIT","STOPPED").any { s.startsWith(it) }
private fun isDataError(s: String) = listOf("DATA ERROR","BUFFER FULL","RX ERROR").any { s.startsWith(it) }
}
class TimeoutException(message: String) : Exception(message)
```
### 2. DynamicCollector.kt (script/DynamicCollector.kt)
```kotlin
package ru.elmer.client.script
import ru.elmer.client.elm.ElmProtocol
import ru.elmer.client.elm.ObdDecoder
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.thread
class DynamicCollector(
private val elm: ElmProtocol,
private val steps: List<ElmStep>,
private val intervalMs: Long,
private val onSample: (sampleIndex: Int) -> Unit,
private val onLog: (msg: String) -> Unit
) {
data class ElmStep(val id: String, val cmd: String, val desc: String)
private val running = AtomicBoolean(false)
private val samples = mutableListOf<List<SampleResponse>>()
private var threadRef: Thread? = null
data class SampleResponse(val stepId: String, val cmd: String, val raw: String, val decoded: String, val ts: Long = 0)
fun start() {
running.set(true)
val startTs = System.currentTimeMillis()
threadRef = thread(name = "DynamicCollector", isDaemon = true) {
var idx = 0
while (running.get()) {
val t0 = System.currentTimeMillis()
val batch = mutableListOf<SampleResponse>()
for (step in steps) {
if (!running.get()) break
try {
val raw = elm.sendCommand(step.cmd)
val dec = ObdDecoder.decode(step.cmd, raw)
batch.add(SampleResponse(step.id, step.cmd, raw, dec, System.currentTimeMillis() - startTs))
} catch (e: Exception) {
batch.add(SampleResponse(step.id, step.cmd, "(err)", e.message ?: "error", System.currentTimeMillis() - startTs))
}
Thread.sleep(350)
}
if (batch.isNotEmpty()) { synchronized(samples) { samples.add(batch) }; onSample(idx); idx++ }
val elapsed = System.currentTimeMillis() - t0
val sleep = intervalMs - elapsed
if (sleep > 0 && running.get()) Thread.sleep(sleep)
}
}
}
fun stop(): List<List<SampleResponse>> {
running.set(false)
try { threadRef?.join(3000) } catch (_: Exception) {}
return synchronized(samples) { samples.toList() }
}
fun isRunning(): Boolean = running.get()
}
```
### 3. MainActivity.kt — startDynamicRecording() (фрагмент)
```kotlin
// v1.10.0-dev — текущая версия
private fun startDynamicRecording() {
thread(name = "DynamicTest", isDaemon = true) {
checker.ensureConnected()
val elmProto = checker.getElm()!!
// Статика — 9 PID по одному (работает)
for ((pid, desc) in staticCmds) {
elmProto.sendCommand("01$pid")
}
// Подготовка к динамике
try { elmProto.sendCommand("ATWS") } catch (_: Exception) {}
Thread.sleep(800)
// Динамика: 3 PID, интервал 500ms
val dynSteps = listOf("010C" to "RPM", "0110" to "MAF", "0106" to "STFT")
.map { ElmStep(it.second, it.first, it.second) }
DynamicCollector(elmProto, dynSteps, 500L, ...).start()
}
}
```
---
## Вопросы
1. **exec()** делает retry с ПОВТОРНОЙ ОТПРАВКОЙ команды — не забивает ли это ELM327?
2. **drainInput()** в write() — почему без него (v1.9.0) стало ХУЖЕ?
3. **ATWS** — нужен ли? Сколько ждать?
4. **sleep(350)** между PID — правильно или избыточно?
5. Почему статика работает а динамика нет?
## ⛔ НАПОМИНАНИЕ: НЕ МЕНЯТЬ КОД, НЕ КОММИТИТЬ. ТОЛЬКО АНАЛИЗ В ФАЙЛ doc/claude-analysis-elm-v2.md ⛔
+499
View File
@@ -0,0 +1,499 @@
# Запрос анализа ELM327-кода — для Claude Sonnet
## Контекст
Android-приложение для OBD2-диагностики автомобиля через ELM327 Bluetooth-адаптер.
Стек: Kotlin, minSdk 24, OkHttp 4.12.
**Проблема:** динамический тест (START/STOP) — опрос 3 PID (RPM, MAF, STFT) в реальном времени —
даёт 94-100% пустых ответов. ELM327 v1.5 просто перестаёт отвечать.
Статическая диагностика (одиночные PID) работает нормально.
## Важно
АНАЛИЗИРУЙ ТОЛЬКО ELM-код. Не трогай UI, сервер, БД.
Нужен анализ того, почему ELM327 замолкает при динамическом опросе.
## Файлы
Ниже полный код всех файлов, связанных с ELM327.
---
### 1. ElmProtocol.kt — стейт-машина AndrOBD (1:1 копия ElmProt.java)
```kotlin
package ru.elmer.client.elm
import android.util.Log
import java.io.InputStream
import java.io.OutputStream
class ElmProtocol(
private val input: InputStream,
private val output: OutputStream
) {
companion object {
private const val TAG = "ElmProto"
private const val POLL_DELAY = 1L
private const val INIT_TIMEOUT = 10000L
private const val DEF_TIMEOUT = 500L
private const val TIMEOUT_MIN = 50L
private const val TIMEOUT_MAX = 2000L
private const val TIMEOUT_STEP = 20L
private const val TIMEOUT_RES = 4
private const val MAX_RETRIES = 10
}
private enum class State { UNDEFINED, INITIALIZING, READY, BUSY, ERROR, DISCONNECTED }
private var state = State.UNDEFINED
private var timeoutMs = DEF_TIMEOUT
private var learnedMin = TIMEOUT_MIN
fun init() {
Log.i(TAG, "init start")
state = State.INITIALIZING
write("ATSP0"); tryRead(4000); drainInput()
write("ATAT1"); tryRead(2000); drainInput()
updateAtst()
write("ATS0"); tryRead(2000); drainInput()
write("ATL0"); tryRead(2000); drainInput()
write("ATE0"); tryRead(2000); drainInput()
state = State.READY
Log.i(TAG, "ready")
}
fun sendCommand(cmd: String): String {
if (state == State.ERROR || state == State.DISCONNECTED) recover()
state = State.BUSY
val result = exec(cmd, timeoutMs)
if (state == State.BUSY) state = State.READY
return result
}
private fun exec(cmd: String, timeout: Long): String {
write(cmd)
var t = timeout
for (i in 0 until MAX_RETRIES) {
try {
return handle(read(t))
} catch (_: TimeoutException) {
if (state == State.INITIALIZING) t += 1000
else { increaseTimeout(); t = timeoutMs }
}
}
Log.e(TAG, "no response for $cmd")
state = State.ERROR
return ""
}
private fun handle(raw: String): String {
val u = raw.uppercase().trim()
when {
u.startsWith("SEARCHING") -> {}
u.startsWith("OK") -> decreaseTimeout()
u.startsWith("NODATA") || u.startsWith("NO DATA") -> {
increaseTimeout(); updateAtst()
}
isBusError(u) -> {
Log.w(TAG, "BUS ERROR: ${raw.take(60)}")
state = State.DISCONNECTED
resetTimeout(); updateAtst()
write("ATPC"); tryRead(3000)
write("ATSP0"); tryRead(3000)
}
u.startsWith("ERROR") && !u.startsWith("DATA ERROR") -> {
Log.w(TAG, "ERROR — warm start")
state = State.ERROR
write("ATWS"); tryRead(3000)
}
isDataError(u) -> {
Log.w(TAG, "data error — warm start")
state = State.ERROR
write("ATWS"); tryRead(3000)
}
else -> decreaseTimeout()
}
return raw
}
private fun recover() {
Log.i(TAG, "recovering...")
state = State.INITIALIZING
write("ATWS"); tryRead(2000); drainInput()
write("ATSP0"); tryRead(2000); drainInput()
write("ATE0"); tryRead(2000); drainInput()
state = State.READY
}
private fun write(cmd: String) {
drainInput()
output.write((cmd + "\r").toByteArray())
output.flush()
Log.d(TAG, "$cmd")
}
private fun drainInput() {
while (input.available() > 0) input.read()
}
@Throws(TimeoutException::class)
private fun read(timeout: Long): String {
val dl = System.currentTimeMillis() + timeout
val sb = StringBuilder()
val lines = mutableListOf<String>()
var gotPrompt = false
while (System.currentTimeMillis() < dl) {
if (input.available() > 0) {
val b = input.read()
if (b == -1) break
when (b) {
62 -> { push(sb, lines); gotPrompt = true; break } // '>'
13 -> push(sb, lines) // CR
10, 32 -> {} // LF, space
else -> sb.append(b.toChar())
}
} else {
Thread.sleep(POLL_DELAY)
}
}
push(sb, lines)
if (!gotPrompt) throw TimeoutException("timeout ${timeout}ms")
return lines.joinToString("\n")
}
private fun tryRead(timeout: Long) {
try { read(timeout) } catch (_: TimeoutException) {}
}
private fun push(sb: StringBuilder, lines: MutableList<String>) {
if (sb.isNotEmpty()) { lines.add(sb.toString()); sb.clear() }
}
private fun increaseTimeout() {
if (timeoutMs + TIMEOUT_STEP < TIMEOUT_MAX) timeoutMs += TIMEOUT_STEP
}
private fun decreaseTimeout() {
if (timeoutMs - TIMEOUT_STEP >= learnedMin) timeoutMs -= TIMEOUT_STEP
}
private fun resetTimeout() { timeoutMs = DEF_TIMEOUT }
fun resetAdaptiveTiming() { timeoutMs = DEF_TIMEOUT }
private fun updateAtst() {
val v = (timeoutMs / TIMEOUT_RES).toInt().coerceAtLeast(1)
write("ATST${v.toString(16).uppercase().padStart(2, '0')}")
tryRead(2000)
drainInput()
}
private fun isBusError(s: String): Boolean {
return listOf("UNABLE", "BUS BUSY", "BUS ERROR", "CAN ERROR",
"BUS INIT", "STOPPED").any { s.startsWith(it) }
}
private fun isDataError(s: String): Boolean {
return listOf("DATA ERROR", "BUFFER FULL", "RX ERROR").any { s.startsWith(it) }
}
}
class TimeoutException(message: String) : Exception(message)
```
---
### 2. DynamicCollector.kt — сборщик для динамического теста
```kotlin
package ru.elmer.client.script
import ru.elmer.client.elm.ElmProtocol
import ru.elmer.client.elm.ObdDecoder
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.thread
class DynamicCollector(
private val elm: ElmProtocol,
private val steps: List<ElmStep>,
private val intervalMs: Long,
private val onSample: (sampleIndex: Int) -> Unit,
private val onLog: (msg: String) -> Unit
) {
data class ElmStep(val id: String, val cmd: String, val desc: String)
private val running = AtomicBoolean(false)
private val samples = mutableListOf<List<SampleResponse>>()
private var threadRef: Thread? = null
data class SampleResponse(
val stepId: String, val cmd: String, val raw: String,
val decoded: String, val ts: Long = 0
)
fun start() {
running.set(true)
val startTs = System.currentTimeMillis()
threadRef = thread(name = "DynamicCollector", isDaemon = true) {
var idx = 0
while (running.get()) {
val t0 = System.currentTimeMillis()
val batch = mutableListOf<SampleResponse>()
for (step in steps) {
if (!running.get()) break
try {
val raw = elm.sendCommand(step.cmd)
val dec = ObdDecoder.decode(step.cmd, raw)
batch.add(SampleResponse(step.id, step.cmd, raw, dec, System.currentTimeMillis() - startTs))
} catch (e: Exception) {
batch.add(SampleResponse(step.id, step.cmd, "(err)", e.message ?: "error", System.currentTimeMillis() - startTs))
}
Thread.sleep(350)
}
if (batch.isNotEmpty()) {
synchronized(samples) { samples.add(batch) }
onSample(idx); idx++
}
val elapsed = System.currentTimeMillis() - t0
val sleep = intervalMs - elapsed
if (sleep > 0 && running.get()) Thread.sleep(sleep)
}
}
}
fun stop(): List<List<SampleResponse>> {
running.set(false)
try { threadRef?.join(3000) } catch (_: Exception) {}
return synchronized(samples) { samples.toList() }
}
fun isRunning(): Boolean = running.get()
}
```
---
### 3. ElmChecker.kt — проверка устройства + speed-test
```kotlin
package ru.elmer.client.elm
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import android.util.Log
import java.io.IOException
import java.util.UUID
class ElmChecker(
private val device: BluetoothDevice,
private val adapter: BluetoothAdapter
) {
companion object {
private const val TAG = "ElmChecker"
private val SPP_UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB")
}
data class DeviceInfo(
val version: String, val deviceId: String, val protocol: String,
val voltage: String, val hasAdaptive: Boolean
)
data class EcuData(val supportsObd: Boolean, val pidMask: String, val vin: String?)
data class Result(val good: Boolean, val device: DeviceInfo, val ecu: EcuData, val log: String)
private val logLines = mutableListOf<String>()
fun getLog(): String = logLines.joinToString("\n")
private var socket: BluetoothSocket? = null
private var elm: ElmProtocol? = null
fun checkDevice(): DeviceInfo? {
if (!connectAndInit()) return null
val ati = send("ATI"); val version = parseVersion(ati)
val isV2 = version.contains("v2", ignoreCase = true)
val deviceId = if (isV2) cleanAt2(send("AT@2")) else ""
val dp = send("ATDP"); val protocol = if (dp.length > 3 && dp != "OK") dp.take(60) else dp
val rv = send("ATRV"); val voltage = if (rv.contains("V", ignoreCase = true) || rv.matches(Regex("[0-9.]+"))) rv else ""
val hasAdaptive = if (isV2) send("ATAT1") == "OK" else false
return DeviceInfo(version, deviceId, protocol, voltage, hasAdaptive)
}
fun checkEcu(): EcuData {
val pid0100 = send("0100"); val supportsObd = pid0100.startsWith("41")
val pidMask = if (supportsObd) pid0100.take(60) else ""
val vinRaw = send("0902"); val vin = parseVin(vinRaw)
return EcuData(supportsObd, pidMask, vin)
}
fun scanDtc(): List<String>? {
if (!connectAndInit()) { disconnect(); return null }
val codes = mutableListOf<String>()
codes.addAll(parseDtcCodes(send("03")))
codes.addAll(parseDtcCodes(send("07")))
return codes.distinct()
}
fun ensureConnected(): Boolean = connectAndInit()
fun isConnected(): Boolean = socket?.isConnected == true && elm != null
fun getElm(): ElmProtocol? = elm
fun quickCheck(): Int? {
val e = elm ?: return null
return try {
val t0 = System.currentTimeMillis()
val raw = e.sendCommand("010C"); val dt = System.currentTimeMillis() - t0
if (raw.isBlank() || raw == "(err)") null else dt.toInt()
} catch (_: Exception) { null }
}
data class SpeedTestResult(val perPidAvg: List<Int>, val batchTime: Int, val reliable: Boolean, val message: String)
fun measureResponseTime(onProgress: (String) -> Unit): SpeedTestResult {
val testPids = listOf("010C" to "RPM", "0110" to "MAF", "0106" to "STFT")
val perPidAvg = mutableListOf<Int>()
var hadErrors = false; var reliable = true
val reasons = mutableListOf<String>()
val e = elm ?: return SpeedTestResult(listOf(250,250,250), 750, false, "❌ ELM не инициализирован")
try { e.sendCommand("010C") } catch (_: Exception) {}
onProgress("\n⏱ Тест скорости ELM...")
for ((pi, pair) in testPids.withIndex()) {
val (cmd, name) = pair; val allTimes = mutableListOf<Long>()
val count = if (pi == 0) 4 else 3
for (i in 0 until count) {
val t0 = System.currentTimeMillis()
val raw = try { e.sendCommand(cmd) } catch (_: Exception) { "(err)" }
val dt = System.currentTimeMillis() - t0; allTimes.add(dt)
if (raw == "(err)" || raw.isBlank()) hadErrors = true
}
val times = if (pi == 0) allTimes.takeLast(2).toMutableList() else allTimes
val avg = times.average().toInt(); perPidAvg.add(avg)
onProgress("\n $name: ${times.joinToString("ms, ")}ms (среднее ${avg}ms)")
for (t in times) {
if (t > 0 && avg > 0 && kotlin.math.abs(t - avg).toFloat() / avg > 0.5f) {
if (reliable) reliable = false
reasons.add("${name} нестабилен: ${t}ms vs среднее ${avg}ms")
}
}
}
val batchTime = perPidAvg.sum()
val message = if (reliable) "✅ Скорость стабильна: ${perPidAvg.joinToString("+")}=${batchTime}ms"
else "⚠️ ${reasons.joinToString("; ")}. Проверьте контакт ELM в OBD-разъёме."
onProgress("\n$message")
return SpeedTestResult(perPidAvg, batchTime, reliable, message)
}
fun close() { try { socket?.close() } catch (_: Exception) {}; socket = null; elm = null }
// ... (private connect, send, parseVin, parseDtcCodes, parseVersion, failResult опущены для краткости)
}
```
---
## Проблема
При динамическом тесте:
```
Сессия #54 (v1.3.0-dev): 55 ответов
0106 (STFT): 1 ok / 18 попыток → 94% ошибок
010C (RPM): 1 ok / 18 попыток → 94% ошибок
0110 (MAF): 0 ok / 18 попыток → 100% ошибок
Первые 2 ответа — нормальные (410680, 41110E), затем 17 пустых.
```
**Поток вызовов перед динамическим тестом:**
1. `ElmProtocol.init()` — 5 AT-команд (ATSP0, ATAT1, ATS0, ATL0, ATE0)
2. `ElmChecker.checkDevice()` — 5-6 AT-команд (ATI, AT@2, ATDP, ATRV, ATAT1)
3. `ElmChecker.checkEcu()` — 2 OBD-команды (0100, 0902)
4. В MainActivity: статический проброс 9 PID (0104-011F)
5. Speed-test: 1 warmup + 4 RPM + 3 MAF + 3 STFT = 11 OBD-команд
6. `ATWS` — сброс ELM
7. DynamicCollector: 3 PID в цикле каждые 500ms
**Ключевое:** `sendCommand()``exec()` при таймауте делает RETRY:
- `write(cmd)` — посылает команду
- `read(timeout)` — ждёт ответ
- таймаут → `increaseTimeout()``write(cmd)` ОПЯТЬ
- до 10 retry на одну команду
## Вопросы
1. Почему ELM327 v1.5 замолкает после первых 2 ответов в DynamicCollector?
2. Может ли `drainInput()` в `write()` съедать ответ от предыдущей команды?
3. Критична ли последовательность: static probe → speed-test → ATWS → dynamic collect?
4. Нужно ли переподключать ELM перед динамическим тестом вместо ATWS?
5. Как правильно реализовать динамический опрос с учётом медленного ELM327 v1.5 (min ответ 350ms)?
Ответ сохрани в файл `doc/claude-analysis-elm.md`
---
### 4. MainActivity.kt — фрагменты (checkElm, checkEcu, startDynamicRecording)
```kotlin
// Вызывается при клике на светофор ELM
private fun checkElm() {
// ... поиск Bluetooth-устройства ...
elmDevice = dev
try {
val checker = ElmChecker(dev, btAdapter!!)
val r = checker.checkDevice() // AT-команды, инициализация
if (r != null) {
elmChecker = checker
setIndicator(indElm, "🟢")
checkEcu() // ← синхронно на главном потоке!
// Speed-test в фоновом потоке
thread(name = "SpeedTest", isDaemon = true) {
val client = ServerClient(...)
val saved = client.getProfileResponseTime(elmMac)
if (saved != null && saved > 0) {
val quick = checker.quickCheck() // 010C × 1
// сверка с профилем...
return@thread
}
val result = checker.measureResponseTime { msg -> debugLog(msg) }
if (result.reliable) client.saveProfile(elmMac, result.batchTime)
}
}
} catch ...
}
private fun checkEcu() {
val checker = elmChecker ?: return
try {
val raw = checker.getElm()?.sendCommand("03") ?: "" // ← синхронно!
val ok = raw.startsWith("43")
setIndicator(indEcu, if (ok) "🟢" else "🔴")
} catch ...
}
// Вызывается при нажатии СТАРТ
private fun startDynamicRecording() {
thread(name = "DynamicTest", isDaemon = true) {
val checker = elmChecker
if (!checker.ensureConnected()) { /* retry */ }
val elmProto = checker.getElm()!!
// ── Статика: пробуем 9 PID ──
for ((pid, desc) in staticCmds) {
val raw = elmProto.sendCommand("01$pid")
// ...
}
// ── ATWS: сброс ELM ──
try { elmProto.sendCommand("ATWS") } catch (_: Exception) {}
Thread.sleep(300)
// ── Динамика: 3 PID, интервал 500ms ──
val dynSteps = listOf("010C" to "RPM", "0110" to "MAF", "0106" to "STFT")
.map { ElmStep(it.second, it.first, it.second) }
val collector = DynamicCollector(elmProto, dynSteps, 500L, ...)
collector.start()
while (state == State.START && collector.isRunning()) Thread.sleep(200)
val samples = collector.stop()
// ... контроль качества, слияние со статикой ...
}
}
```
+128
View File
@@ -0,0 +1,128 @@
# Справочник OBD2 DTC кодов (русские описания)
# Источник: AndrOBD codes_ru.properties + общие открытые данные
# Формат: код=расшифровка
P0030=HO2S: нагреватель (bank 1 sensor 1) — цепь управления
P0031=HO2S: нагреватель (bank 1 sensor 1) — низкий уровень
P0032=HO2S: нагреватель (bank 1 sensor 1) — высокий уровень
P0100=ДМРВ: цепь — неисправность
P0101=ДМРВ: выход за диапазон / низкая производительность
P0102=ДМРВ: низкий сигнал
P0103=ДМРВ: высокий сигнал
P0105=MAP: цепь — неисправность
P0106=MAP: выход за диапазон
P0107=MAP: низкий сигнал
P0108=MAP: высокий сигнал
P0110=IAT: цепь датчика — неисправность
P0112=IAT: низкий сигнал
P0113=IAT: высокий сигнал
P0115=ECT: цепь датчика — неисправность
P0117=ECT: низкий сигнал
P0118=ECT: высокий сигнал
P0120=TPS / педаль газа A: цепь — неисправность
P0121=TPS: выход за диапазон
P0122=TPS: низкий сигнал
P0123=TPS: высокий сигнал
P0125=ECT: недостаточная температура для closed loop
P0128=Термостат: температура ниже нормы
P0130=ДК1 (bank 1 sensor 1): цепь — неисправность
P0131=ДК1 (bank 1 sensor 1): низкий сигнал
P0132=ДК1 (bank 1 sensor 1): высокий сигнал
P0133=ДК1 (bank 1 sensor 1): медленный отклик
P0134=ДК1 (bank 1 sensor 1): нет активности
P0135=ДК1 (bank 1 sensor 1): нагреватель — неисправность
P0136=ДК2 (bank 1 sensor 2): цепь — неисправность
P0137=ДК2 (bank 1 sensor 2): низкий сигнал
P0138=ДК2 (bank 1 sensor 2): высокий сигнал
P0141=ДК2 (bank 1 sensor 2): нагреватель — неисправность
P0170=Топливная коррекция (bank 1): неисправность
P0171=Топливная коррекция (bank 1): бедная смесь
P0172=Топливная коррекция (bank 1): богатая смесь
P0200=Форсунка: цепь — неисправность
P0201=Форсунка цилиндра 1 — неисправность
P0202=Форсунка цилиндра 2 — неисправность
P0203=Форсунка цилиндра 3 — неисправность
P0204=Форсунка цилиндра 4 — неисправность
P0220=TPS / педаль газа B: цепь — неисправность
P0300=Пропуски зажигания: случайные / множественные
P0301=Пропуски зажигания: цилиндр 1
P0302=Пропуски зажигания: цилиндр 2
P0303=Пропуски зажигания: цилиндр 3
P0304=Пропуски зажигания: цилиндр 4
P0325=Датчик детонации 1 (bank 1): цепь — неисправность
P0327=Датчик детонации 1: низкий сигнал
P0335=ДПКВ: цепь — неисправность
P0336=ДПКВ: выход за диапазон
P0340=ДПРВ: цепь — неисправность
P0341=ДПРВ: выход за диапазон
P0351=Катушка зажигания A (цилиндр 1): первичная цепь
P0352=Катушка зажигания B (цилиндр 2): первичная цепь
P0400=EGR: поток — неисправность
P0401=EGR: недостаточный поток
P0402=EGR: избыточный поток
P0403=EGR: цепь управления
P0420=Катализатор (bank 1): эффективность ниже порога
P0421=Прогревочный катализатор (bank 1): эффективность ниже порога
P0430=Катализатор (bank 2): эффективность ниже порога
P0440=EVAP: система улавливания паров — неисправность
P0442=EVAP: малая утечка
P0446=EVAP: клапан продувки — неисправность
P0455=EVAP: большая утечка
P0456=EVAP: очень малая утечка
P0500=Датчик скорости (VSS): неисправность
P0505=РХХ: неисправность
P0506=РХХ: обороты ниже нормы
P0507=РХХ: обороты выше нормы
P0560=Напряжение системы: неисправность
P0562=Напряжение системы: низкое
P0563=Напряжение системы: высокое
P0600=Последовательная линия: неисправность
P0601=ЭБУ: ошибка контрольной суммы
P0606=ЭБУ / PCM: внутренняя ошибка процессора
P0700=АКПП: запрос MIL (общая ошибка трансмиссии)
P0705=Датчик положения селектора АКПП: неисправность
P0715=Датчик скорости турбины АКПП: неисправность
P0720=Датчик выходной скорости АКПП: неисправность
P0740=Блокировка гидротрансформатора: неисправность
P0741=Блокировка гидротрансформатора: залипает
P0743=Блокировка гидротрансформатора: электрическая цепь
P0750=Соленоид А переключения АКПП: неисправность
P1100=ДМРВ: перемежающаяся ошибка
P1110=Система заслонок впуска: неисправность
P1120=TPS: выход за нижний предел
P1130=ДК1: медленный отклик (bank 1)
P1133=ДК1: недостаточная активность (bank 1 sensor 1)
P1135=ДК1: нагреватель — слабый ток (bank 1 sensor 1)
P1171=Топливная коррекция: бедная смесь при нагрузке
P1250=Автопрогрев термостата: неисправность
P1300=Зажигание: пропуски — повреждение катализатора
P1320=Зажигание: первичный сигнал
P1336=Датчик положения коленвала: не усвоен
P1340=Рассинхронизация распредвала/коленвала
P1400=EGR: залипание клапана
P1500=РХХ: обрыв цепи
P1540=Кондиционер: давление — неисправность
P1600=ЭБУ: потеря связи с иммобилайзером
P1610=Иммобилайзер: неверный код
P1700=АКПП: внутренняя ошибка контроллера
P2000=NOx ловушка (bank 1): эффективность ниже порога
P2100=Дроссель (ETC): цепь привода
P2101=Дроссель (ETC): выход за диапазон
P2120=Датчик педали газа A: цепь
P2122=Датчик педали газа A: низкий сигнал
P2123=Датчик педали газа A: высокий сигнал
P2135=TPS / педаль: корреляция A/B
P2138=Датчик педали газа: корреляция
P2187=Топливная коррекция: бедная смесь на холостом
P2188=Топливная коррекция: богатая смесь на холостом
P2195=ДК1: бедная смесь (bank 1 sensor 1)
P2196=ДК1: богатая смесь (bank 1 sensor 1)
P2237=ДК1: ток накачки (bank 1 sensor 1)
P2270=ДК2: бедная смесь (bank 1 sensor 2)
P2500=Генератор: лампа — низкий сигнал
P2503=Генератор: низкое напряжение
C0000=ABS: неисправность
B0000=Кузов: неисправность
U0000=Шина данных: неисправность
U0100=Шина CAN: потеря связи с ECM/PCM
U0121=Шина CAN: потеря связи с ABS
@@ -0,0 +1,209 @@
# Анализ динамического сбоя ELM327
Дата: 2026-06-14
## Контекст
Проверено:
- статическая диагностика работает стабильно;
- чтение VIN работает;
- чтение DTC работает;
- последовательное чтение нескольких PID в статическом режиме работает;
- ELM327 выдерживает не менее 9 PID подряд в статике;
- увеличение пауз до 4000 мс не устраняет проблему;
- автоподбор таймингов не устраняет проблему;
- сбой проявляется только в динамическом сборе данных через ScriptEngine.
Это сильно сужает пространство причин. Проблема почти наверняка не в "скорости ELM вообще", не в "ECU не успевает" и не в банальном "надо ещё увеличить задержку".
## Что объясняет факты лучше всего
### 1. Несовпадение между тем, как ElmChecker и ScriptEngine общаются с ELM
Вероятность: высокая.
Смысл гипотезы: статический путь и динамический путь используют не одинаковую коммуникационную последовательность. Ломается не ELM как таковой, а конкретный сценарий: кто пишет, кто читает, когда читают, что считается окончанием ответа, как очищается буфер, как меняются состояния.
Что подтверждает:
- статический путь работает полностью;
- динамический ломается только в ScriptEngine;
- один и тот же адаптер выдерживает длинную серию PID в статике;
- в проектных заметках уже зафиксировано, что проблема может быть именно в различии между ElmChecker и ScriptEngine, а не в тайминге как таковом.
Что противоречит:
- если в удачном и неудачном сценарии полностью совпадают AT-команды, порядок команд, чтение и ожидание конца ответа.
Быстрый эксперимент:
- снять полный лог команд и сырых ответов для успешного статического пути и для динамического пути;
- сравнить именно последовательность TX/RX, а не распарсенные значения;
- проверить, расходится ли сценарий уже до первого PID.
### 2. InputStream не дочитывается до символа ">", и следующий запрос попадает в хвост прошлого ответа
Вероятность: высокая.
Смысл гипотезы: динамический читатель завершает чтение раньше, чем ELM реально закончил ответ. В буфере остаётся промпт `>` или другой хвост, и следующий запрос читает не чистый ответ, а остаток прошлого цикла.
Что подтверждает:
- это прямо совпадает с типовым режимом отказа ELM327;
- в проектных заметках символ `>` отдельно выделен как конец ответа;
- в обсуждениях проекта уже встречалась версия, что ответы смешиваются и хвост остаётся в буфере;
- статика может это маскировать, потому что между командами там больше естественных пауз.
Что противоречит:
- если сырые логи показывают, что каждый ответ полностью доходит до `>` и следующий запрос стартует только после этого;
- если после сбоя буфер точно пуст.
Быстрый эксперимент:
- включить сырой дамп RX/TX без парсинга;
- для первого сбойного цикла проверить, присутствует ли `>` в сыром потоке полностью;
- перед следующим запросом проверить, не остаётся ли в InputStream ничего, кроме уже считанного ответа.
### 3. Остатки данных в буфере ломают синхронизацию между командами
Вероятность: высокая.
Смысл гипотезы: чтение и запись идут корректно по отдельности, но между ними нет надёжной очистки буфера. В результате следующий запрос потребляет не только свежий ответ, но и мусор: эхо, переносы строк, старые байты, задержавшийся ответ предыдущего PID.
Что подтверждает:
- проектные заметки отдельно говорят, что drainInput раньше был механизмом синхронизации запрос/ответ;
- после отключения drainInput в одной из версий стало хуже;
- описан эффект сдвига: ответ на команду N прочитан как ответ на N+1;
- статический сценарий выдерживает это лучше из-за более редкой частоты обращений.
Что противоречит:
- если перед каждым запросом в динамическом цикле буфер гарантированно очищается и при этом проблема остаётся;
- если в логах нет признаков мусора, эха или сдвига границ ответов.
Быстрый эксперимент:
- один раз до старта динамики и один раз перед вторым запросом вывести количество доступных байт в InputStream и содержимое остатка;
- сравнить результат между успешным статическим и неудачным динамическим прогоном;
- проверить, есть ли хвосты после первого ответа.
### 4. ScriptEngine выполняет не тот же state machine, что ElmChecker
Вероятность: средняя.
Смысл гипотезы: проблема не в самом Bluetooth и не в самом ELM, а в том, что динамический движок переходит между состояниями раньше или иначе, чем ElmChecker. Например, команда считается завершённой по временному признаку, а не по фактическому окончанию ответа.
Что подтверждает:
- пользователь отдельно выделил риск state machine;
- динамический режим содержит свои шаги, цикл и внутренние переходы;
- статический путь короче и проще, поэтому ошибки state machine там могут не проявляться;
- уже были замечания, что в таких сценариях рассинхрон появляется раньше, чем кажется.
Что противоречит:
- если логически и по времени state transitions происходят только после полного ответа ELM;
- если обе машины выполняют одинаковый сценарий завершения команды.
Быстрый эксперимент:
- на одном прогоне логировать каждое состояние до и после отправки команды;
- отметить момент, когда реально получен `>`;
- проверить, не уходит ли ScriptEngine в следующий шаг до фактического конца ответа.
### 5. Доступ к одному сокету или одному InputStream идёт из двух потоков
Вероятность: средняя.
Смысл гипотезы: чтение или запись в динамике пересекаются с другим потоком, который тоже читает или пишет тот же канал. Для ELM это критично: поток байтов становится недетерминированным, и команда может лишиться части ответа.
Что подтверждает:
- пользователь отдельно попросил проверить конкурентный доступ к сокету;
- динамический режим по определению более многопоточен: цикл, сбор данных, UI, возможные фоновые операции;
- статический путь может не задевать гонку из-за более редкой частоты и меньшего числа активных операций.
Что противоречит:
- если трасса покажет строго одного читателя и одного писателя на весь жизненный цикл соединения;
- если динамика воспроизводится даже в полностью однопоточном прогоне.
Быстрый эксперимент:
- вывести thread id для каждого read и write;
- проверить, нет ли второго consumer на InputStream;
- сравнить идентичность владельца сокета в статике и динамике.
### 6. Неправильная последовательность команд, а не неправильная пауза
Вероятность: средняя-низкая.
Смысл гипотезы: дело не в длительности ожидания как таковой, а в том, что динамический сценарий отправляет команды в другом порядке или с другим набором служебных AT-команд, чем успешный статический сценарий. Тогда ELM оказывается в другом режиме, и дальнейшая обработка ломается.
Что подтверждает:
- в проекте есть отдельные сценарии для статической диагностики, тестового скрипта и динамики;
- серверный build_test_script и build_dynamic_script действительно строят разные последовательности;
- в заметках по ELM отдельно обсуждаются последствия ATWS, ATE0/ATL0/ATS0 и различий в инит-последовательности.
Что противоречит:
- если сравнение трасс покажет полностью одинаковый init и только разный темп;
- если тот же набор команд в статике и динамике повторяет поломку только из-за способа выполнения, а не порядка.
Быстрый эксперимент:
- распечатать полный список команд, которые реально уходят в ELM в обоих режимах;
- сравнить not only PID, но и все AT-команды, входы в state machine и возможные reset-команды;
- проверить, совпадает ли стартовая инициализация побайтно.
## Что менее вероятно
### Adaptive timing как первопричина
Вероятность: низкая.
Почему низкая:
- уже проверяли увеличение пауз до 4000 мс;
- уже проверяли автоподбор;
- одиночные запросы и статический набор PID работают.
Вывод: adaptive timing может усиливать или маскировать проблему, но не выглядит корнем сбоя.
### Просто "мало ждать"
Вероятность: низкая.
Почему низкая:
- паузы уже увеличивали;
- первый запрос проходит, второй ломается;
- для обычного ELM327 это больше похоже на ошибку синхронизации, чем на нехватку миллисекунд.
## Итоговая интерпретация
Новое мнение хорошо согласуется с уже собранными фактами. Оно сдвигает фокус с "таймингов вообще" на более узкий класс проблем:
- границы ответа ELM, особенно символ `>`;
- остатки в InputStream;
- различие между ElmChecker и ScriptEngine;
- state machine, которая может идти вперёд раньше времени;
- возможная конкуренция за сокет или поток чтения.
Главный вывод: если статический путь стабилен, а динамический ломается даже при больших паузах, то первичная причина почти наверняка находится не в задержках, а в чтении потока, границах ответа и разнице в сценарии исполнения.
## Порядок расследования
1. Снять сырой RX/TX лог без парсинга для статического и динамического режима.
2. Проверить, доходит ли каждый ответ до `>` и не остаются ли байты в буфере перед следующим запросом.
3. Сопоставить полную последовательность команд ElmChecker и ScriptEngine.
4. Подтвердить или опровергнуть второй consumer на сокете/InputStream.
5. Проверить, не идёт ли state machine вперёд до фактического завершения ответа.
## Краткий вывод
Наиболее правдоподобно, что проблема не в скорости ELM, а в том, как динамический сценарий читает и синхронизирует поток ответов. Внутри этого класса причин самые сильные кандидаты: неполное дочитывание до `>`, остатки в InputStream, и различие между ElmChecker и ScriptEngine.
+105
View File
@@ -0,0 +1,105 @@
# Динамические тесты — архитектура
> v0.49.0-dev, 7 июня 2026
> Ветка: `dynamic-tests`
## Концепция
Два теста: **на месте** и **в движении**. Разница — только в подсказке юзеру.
Физически оба делают одно: опрашивают 12 PID каждые 250мс, копят в памяти.
## Алгоритм работы (user flow)
```
1. Ошибки → scanDtc() — считывание DTC
2. Диагностика → обычный скрипт L0/L1/L2 на сервер → LLM
3. LLM сказал сделать тест на месте →
⏱ На месте → СТАРТ → ... → СТОП → данные в памяти
4. Диагностика → обычный скрипт + динамические данные → LLM
5. LLM сказал сделать тест в движении →
🚗 В движении → СТАРТ → ... → СТОП → данные в памяти
6. Диагностика → обычный скрипт + динамические данные → LLM (финал)
```
## Android → Сервер
При нажатии **Диагностика**:
1. Выполняется обычный скрипт (AT-команды, PID'ы)
2. Если есть `dynamicSamples` (данные теста) — прикрепляются к тому же запросу
3. POST `/api/v1/session/upload` с полем `dynamic_samples`
4. Сервер по наличию `dynamic_samples` понимает, что пришёл динамический тест
5. Выбирает промпт: `DYNAMIC_PROMPT` если только динамика, или `SYSTEM_PROMPT + DYNAMIC_PROMPT` если и то и то
## Что надо сделать
### Уже сделано (написан код, не проверен)
- [x] Сервер: `build_dynamic_script()` — скрипт 12 PID 250мс
- [x] Сервер: `?mode=dynamic` в routes.py
- [x] Сервер: `DYNAMIC_PROMPT` в prompts.py
- [x] Android: кнопки «⏱ На месте» / «🚗 В движении» (layout)
- [x] Android: `DynamicCollector.kt` — сбор 12 PID 250мс
- [x] Android: `ElmChecker.kt` — добавлены методы для работы с DynamicCollector
- [x] Android: MainActivity — СТАРТ/СТОП, данные в памяти
- [x] Android: общий таймер `tv_timer`
### Надо доделать
- [ ] Android: при старте Диагностики — прикрепить `dynamicSamples` к upload
- [ ] Сервер: принимать `dynamic_samples` в upload_session, выбирать промпт
- [ ] Отладка на реальной машине
### До отправки — в памяти
```kotlin
data class DynamicSample(
val ts: Long, // System.currentTimeMillis()
val responses: List<RawResponse> // 12 ответов ELM
)
val samples = mutableListOf<DynamicSample>() // в памяти, быстро
```
### После СТОП — попытка отправки
```kotlin
1. Собираем все samples в JSON
2. POST /api/v1/session/upload
3. Если 200 ОК, забыли
4. Если ошибка пишем ВСЁ в SessionDb
```
### Локальное хранение — через SessionDb (уже есть)
```sql
-- Таблица sessions (уже существует)
session_type = "dynamic" -- отличаем от обычных
-- Таблица responses (уже существует)
-- Каждый сэмпл = одна запись в responses:
-- session_id, step_id = "sample_N", cmd = "batch",
-- raw = JSON всего опроса, decoded = "запись №N"
```
**Плюсы**: не надо новой таблицы, `SessionDb` уже умеет `createSession`/`addResponse`/`getResponses`.
**Объём**: 30 секунд × 4 опроса/с × 12 PID = 1440 записей ≈ ~100KB — норм.
### Ретрай непосланных сессий
При старте приложения: `db.getUnuploadedSessions()` → отправить → пометить `uploaded=1`.
## Промпт для LLM (сервер)
Отдельный `DYNAMIC_PROMPT` в `brain/prompts.py`:
```
Ты — эксперт по диагностике. Получены временные ряды 12 параметров с интервалом 250мс.
Проанализируй:
1. Отклик дросселя — есть ли задержка/провалы
2. STFT/LTFT — богатая или бедная смесь под нагрузкой и при сбросе
3. RPM — плавность роста/падения, пропуски
4. MAP — соответствует ли оборотам
5. Зажигание — есть ли коррекция, детонация
6. Аномалии — резкие скачки, выбросы
Отвечай кратко: 2-3 предложения вывода, затем по пунктам что не так.
```
## Что НЕ делаем
- GPS / акселерометр — не сейчас
- Автодетект фаз (разгон/сброс) — ручной СТАРТ/СТОП
- Отправка пачками в реальном времени — копим всё до СТОП
+293
View File
@@ -0,0 +1,293 @@
# План: тонкий Android-ретранслятор ELM327
Дата: 2026-06-14
## Цель
Отдельное Android-приложение — тупой ретранслятор команд между сервером и ELM327.
Пользователь устанавливает один раз. Вся логика (какие команды слать, как анализировать
ответы) — на сервере. Приложение только:
1. Коннектится к ELM327 по Bluetooth
2. Сообщает серверу «готов»
3. Поллит сервер на наличие команды
4. Отправляет команду в ELM327
5. Возвращает сырой ответ на сервер
6. Повторяет с пункта 3
## Почему отдельное приложение
- Ноль риска сломать существующий `ru.elmer.client`
- Независимый пакет `ru.elmer.raw`
- Свой APK, свой URL на сервере (`/elm-raw.apk`)
- Можно удалить/переустановить независимо от основного
## Архитектура
```
┌─────────────────────────────────────────────────┐
│ Сервер (elmer/python) │
│ │
│ POST /api/v1/elm/raw/cmd ← я ставлю команду │
│ GET /api/v1/elm/raw/cmd ← приложение поллит │
│ POST /api/v1/elm/raw/response ← приложение шлёт │
│ GET /api/v1/elm/raw/response ← я читаю ответ │
│ /elm-raw.apk ← раздача APK │
└──────────────┬──────────────────────────────────┘
│ HTTP (OkHttp)
┌──────────────▼──────────────────────────────────┐
│ Android-приложение (ru.elmer.raw) │
│ │
│ RawRelayService (foreground) │
│ ├─ Bluetooth → ELM327 │
│ ├─ ElmProtocol (AndrOBD, проверенный) │
│ ├─ Polling: GET /cmd каждые 500ms │
│ └─ POST /response с сырым ответом │
│ │
│ MainActivity (минимальный UI) │
│ ├─ Статус: сервер / ELM / ECU │
│ ├─ Лог последних команд │
│ └─ Кнопка «Стоп» │
└──────────────────────────────────────────────────┘
```
## Компоненты Android-приложения
### 1. Пакет: `ru.elmer.raw`
Новый пакет, не пересекается с `ru.elmer.client`.
### 2. Файлы (5 штук)
| Файл | Размер | Назначение |
|------|--------|-----------|
| `MainActivity.kt` | ~100 строк | UI: статус, лог, кнопка стоп |
| `RawRelayService.kt` | ~150 строк | Foreground-сервис: BT+поллинг+команды |
| `ElmProtocol.kt` | копия | Точная копия из `ru.elmer.client.elm` |
| `ServerClient.kt` | ~80 строк | Урезанный HTTP-клиент (только cmd/response) |
| `AndroidManifest.xml` | ~40 строк | Свой манифест для `ru.elmer.raw` |
**Почему копия ElmProtocol.kt, а не общий модуль:**
- Не трогаем существующий код вообще
- AndrOBD-логика отлажена годами, меняться не будет
- Две копии живут независимо, никаких конфликтов
### 3. ElmProtocol.kt — как есть
Используем **без изменений** проверенную стейт-машину:
- `init()`: ATSP0 → ATAT1 → ATS0 → ATL0 → ATE0
- `sendCommand(cmd)`: отправить → прочитать до `>` → вернуть сырой ответ
- Обработка ошибок: BUS ERROR, CAN ERROR, BUFFER FULL, ретраи, восстановление
- Адаптивные тайминги
Единственное что добавим — вызов `sendCommand()` оборачиваем в `try/catch`,
результат всегда возвращается на сервер (даже если ошибка).
### 4. Протокол обмена с сервером
#### Приложение → Сервер: «я готов»
```
POST /api/v1/elm/raw/hello
{
"device_id": "android-xyz",
"elm_version": "ELM327 v1.5",
"protocol": "A4",
"voltage": "12.3V"
}
```
#### Сервер → Приложение: команда
```
GET /api/v1/elm/raw/cmd?device_id=android-xyz
Ответ 200:
{
"cmd": "0105",
"timeout_ms": 500,
"drain_first": false,
"seq": 1
}
Ответ 204: (нет команды — полли дальше)
```
#### Приложение → Сервер: ответ
```
POST /api/v1/elm/raw/response
{
"device_id": "android-xyz",
"seq": 1,
"cmd": "0105",
"raw": "41 05 5C",
"prompt": true,
"elapsed_ms": 48,
"bytes": 8,
"error": null
}
```
#### Сервер → Приложение: подтверждение
```
200 {"ok": true}
```
### 5. RawRelayService — жизненный цикл
```
onStartCommand(Intent: serverUrl)
1. Подключить Bluetooth к ELM327 (UUID SPP 00001101-0000-1000-8000-00805F9B34FB)
2. ElmProtocol.init() — базовая инициализация
3. POST /hello — сообщить серверу «готов»
4. Цикл (в фоновом потоке):
GET /cmd — ждать команду (500ms поллинг)
если 204 → sleep 500ms → снова GET /cmd
если 200 →
drain? → ElmProtocol.sendCommand("ATPC") → read/discard
ElmProtocol.sendCommand(cmd)
POST /response — отправить сырой ответ
→ снова GET /cmd
5. onDestroy(): закрыть BT, stopForeground, остановить поток
```
### 6. MainActivity — UI
```
┌──────────────────────────────┐
│ ELM327 Raw Relay │
│ │
│ Сервер: ✅ obdai.ru │
│ ELM: 🔵 подключён │
│ ECU: ✅ отвечает │
│ │
│ Последняя команда: │
│ → 0105 │
│ ← 41 05 5C (48ms, 8 байт) │
│ │
│ Лог: 12 команд, 0 ошибок │
│ │
│ [ СТОП ] │
└──────────────────────────────┘
```
Минимальный UI:
- Три индикатора статуса (сервер, ELM, ECU)
- Последняя команда и ответ
- Счётчик команд/ошибок
- Кнопка «Стоп»
## Изменения на серверной стороне (elmer/python)
### 1. Очередь команд — `api/raw_elm.py`
Добавить эндпоинты (дополнить существующий `api/raw_elm.py`):
```
POST /api/v1/elm/raw/cmd — я ставлю команду в очередь
GET /api/v1/elm/raw/cmd — приложение забирает команду
POST /api/v1/elm/raw/response — приложение шлёт ответ
GET /api/v1/elm/raw/response — я читаю последний ответ
POST /api/v1/elm/raw/hello — приложение регистрируется
GET /api/v1/elm/raw/status — статус: готово/ждёт/ошибка
```
### 2. Хранение очереди
В памяти (глобальная переменная), не в БД:
- `_pending_cmd: dict | None` — команда, которую ждёт приложение
- `_last_response: dict | None` — последний ответ от ELM327
- `_device_ready: bool` — готово ли приложение
- `_device_info: dict` — информация об устройстве
Зачем в памяти: одна сессия отладки, один поток команд. Не нужна персистентность.
### 3. Раздача APK — `web/app.py`
```python
@app.route("/elm-raw.apk")
def download_raw_apk():
return send_from_directory("static", "elm-raw.apk", ...)
```
В `templates/index.html` — ссылка «Скачать ELM Raw Relay».
### 4. Интерактивная консоль — `tools/elm_relay.py`
Скрипт для меня (Copilot):
- Читает статус устройства
- Ставит команду в очередь
- Ждёт ответ
- Показывает сырой ответ
- Анализирует, ставит следующую команду
- История всех команд сохраняется
## Сборка и деплой
### Сборка APK
```bash
cd android
./gradlew :app:assembleDebug
# APK: android/app/build/outputs/apk/debug/app-debug.apk
```
Но нам нужен **отдельный** APK для `ru.elmer.raw`. Два варианта:
**Вариант A: Product Flavor** (в одном проекте)
- В `app/build.gradle.kts` добавить `flavorDimensions` + два flavor: `client` и `raw`
- Разные `applicationId`, разные `AndroidManifest.xml`
- Общий код в `main/`, специфичный — в `client/` и `raw/`
- Минус: трогаем `build.gradle.kts` основного приложения
**Вариант B: Новый модуль** (рекомендую)
- Новый Gradle-модуль `android/raw/`
- Свой `build.gradle.kts`, свой манифест, свой пакет
- Не трогаем вообще ничего в `android/app/`
- `settings.gradle.kts` — добавить `include(":raw")`
- Минус: ElmProtocol.kt — физическая копия файла
### Я за Вариант B: новый модуль `:raw`
```
android/
├── app/ ← существующее, НЕ ТРОГАЕМ
├── raw/ ← НОВЫЙ модуль
│ ├── build.gradle.kts
│ └── src/main/
│ ├── AndroidManifest.xml
│ └── java/ru/elmer/raw/
│ ├── MainActivity.kt
│ ├── RawRelayService.kt
│ ├── ElmProtocol.kt ← копия из :app
│ └── ServerClient.kt
├── settings.gradle.kts ← + include(":raw")
└── build.gradle.kts ← не трогаем
```
### Деплой
```bash
cd android
./gradlew :raw:assembleDebug
cp raw/build/outputs/apk/debug/raw-debug.apk ../web/static/elm-raw.apk
# Задеплоить на сервер через deploy.sh
```
## Порядок работ
1. **Сервер**: дополнить `api/raw_elm.py` эндпоинтами очереди
2. **Сервер**: добавить `web/app.py` — раздача `/elm-raw.apk`
3. **Сервер**: `tools/elm_relay.py` — консоль для меня
4. **Android**: модуль `:raw` — 5 файлов (.kt + манифест + build.gradle)
5. **Сборка**: проверить что оба APK собираются
6. **Тест**: поставить APK на телефон, проверить связь с сервером
## Что НЕ делаем
- Не трогаем `ru.elmer.client` — ни строчки
- Не меняем `app/build.gradle.kts`
- Не меняем существующий `AndroidManifest.xml`
- Не изобретаем новый ELM327-протокол — используем AndrOBD как есть
- Не пишем сложный UI — только статус и лог
+144
View File
@@ -0,0 +1,144 @@
# Полевой тест elmAI v0.48.0 — 7 июня 2026
## Что тестируем
APK: https://obdai.ru/elmer.apk (v0.48.0)
Сервер: https://obdai.ru
## Что сделано (все изменения)
### Пробинг ELM327 (НОВОЕ)
- Сервер больше не шлёт ATAT1/ATSTxx слепо — не вешает клоны
- При первом подключении нового ELM — каскадный тест:
- Уровень 0: ATE0 ATL0 ATS0 ATH1 ATSP0 ATDPN ATRV ATI (все клоны)
- Уровень 1: +ATAT1 (хорошие клоны)
- Уровень 2: +ATCAF1 ATCFC1 (настоящий ELM)
- Результат сохраняется в БД по BT MAC
- Нет в профиле → не слать. Никаких ретраев на неизвестное.
### Скрипты под уровень устройства
- L0: 5 PIDs + ошибки (однокадровые, без VIN)
- L1: 8 PIDs + VIN + stored/pending ошибки
- L2: 14 PIDs + VIN + калибровки + все ошибки
### Рефакторинг сервера
- `obd/commands.py` — каталог ВСЕХ AT-команд с метаданными
- `obd/classifier.py` — классификация ответов
- `obd/connection.py` — транспортный слой
- Код разбит на независимые модули
### Android: фикс вывода
- Статус: `append("\n...")` вместо `text =` (не перекрывается)
- Таймер: отдельный TextView, тикает только во время обмена (как Engine Time)
- TestService: адаптивные таймауты вместо жёстких sleep
- TestService: добавлен ATS0 в инициализацию
---
## Как тестировать
### Подготовка
1. Скачай и установи APK: https://obdai.ru/elmer.apk
2. Вставь ELM327 в OBD2-разъём машины
3. Заведи двигатель (для части PIDs нужен работающий двигатель)
4. Сопряги ELM327 с телефоном по Bluetooth (пароль 1234 или 0000)
5. Открой приложение Elmer
### Тест 1: Кнопка «ТЕСТ» (без сервера)
Поле ввода: **оставь пустым** (Bluetooth-режим)
Нажми **«🧪 ТЕСТ (всё локально)»**
Ожидаемое поведение:
```
══════════════════
🔧 ELMER TEST v0.6
Режим: Bluetooth
══════════════════
Найден: OBDII (AA:BB:CC:...)
⏳ Подключение...
✅ BT OK
─── ШАГ 1: Связь ───
→ ATZ ⏱ 2с
← ELM327 v1.5
✅ СВЯЗЬ ЕСТЬ!
─── ШАГ 2: Инициализация ───
→ ATE0 ⏱ 0с
← OK
→ ATL0 ⏱ 0с
← OK
→ ATS0 ⏱ 0с
← OK
→ ATH1 ⏱ 0с
← OK
→ ATSP0 ⏱ 1с
← OK
─── ШАГ 3: VIN ───
→ 0902 ⏱ 3с
← 49 02 01 57 56...
→ VIN: WVWZZZ...
─── ШАГ 4: Ошибки ───
→ 03 ⏱ 1с
← 43 00
→ DTC stored: none
→ 07 ⏱ 1с
← 47 00
→ DTC pending: none
─── ШАГ 5: Параметры ───
→ 0105 ⏱ 0с
← 41 05 5A
→ ОЖ: 50 °C
... (10 параметров)
✅ ТЕСТ ПРОЙДЕН!
```
**Что проверять:**
- [ ] Таймер ⏱ тикает ТОЛЬКО во время команд (АТZ, 0902, 0105...)
- [ ] В паузах между командами таймер пустой
- [ ] Строки НЕ перекрываются (каждая с новой строки)
- [ ] Тест НЕ виснет (раньше висел на ATAT1)
- [ ] Если ELM не отвечает — пишет «❌ ELM не отвечает» за ~5 секунд, не дольше
### Тест 2: Кнопка «Диагностировать» (с сервером)
Поле ввода: **оставь пустым** (Bluetooth-режим)
Нажми **«🚗 Диагностировать (сервер)»**
**Что проверять:**
- [ ] Статус: «BT: AA:BB:CC...» → «BT: OK»
- [ ] Появляются «← ...» (ответы ELM) и «→ ...» (команды от сервера)
- [ ] В конце — диагноз от LLM в рамке ══════
- [ ] Таймер тикает только когда «→ команда» отправляется
- [ ] Нет зависаний
### Тест 3: Если есть второй ELM327
Подключи другой ELM (другой клон/версия):
- [ ] Приложение находит его по имени (OBD/ELM в названии)
- [ ] Тест должен работать на любом клоне
- [ ] Профиль сохраняется в БД на сервере (по MAC)
---
## Возможные проблемы
| Симптом | Вероятная причина |
|---------|-------------------|
| «❌ ELM не отвечает» на ATZ | ELM не вставлен в OBD2 или нет питания |
| Долго висит на ATSP0 | Машина не поддерживает авто-протокол |
| VIN не читается | Старая машина без mode 09 |
| «❌ BT: ...» | ELM не сопряжён в настройках Bluetooth |
| Пустой экран после нажатия | Не даны разрешения Bluetooth (Android 12+) |
---
## После теста
Вернись к компу и скажи:
1. Какие ELM тестировал (версия, цвет, название)
2. Прошёл ли тест
3. Прошла ли диагностика с сервером
4. Были ли зависания
5. Скриншоты экрана (если можно)
+75
View File
@@ -0,0 +1,75 @@
# Инструкция по Git
> v0.36.0-dev, 3 июня 2026
## Структура репозиториев
Один проект — два git-репо в одной папке:
```
elmer/ ← git-репо #1 (gitea)
├── api/ серверный код
├── brain/ LLM-клиент
├── obd/ ELM327
├── web/ Flask, сайт, APK
├── doc/ документация
├── android/ ← git-репо #2 (github) — Android-приложение
│ ├── app/ исходники Kotlin
│ ├── build.gradle.kts сборка
│ └── ...
├── config.yaml конфиг сервера
└── ...
```
## Ремоуты
| Репо | URL |
|------|-----|
| Сервер (elmer/) | `https://gitea.services.ngcloud.ru/Nail/elmer.git` |
| Android (elmer/android/) | `https://github.com/Repinoid/elmer-android.git` |
## Команды
### Сервер
```bash
cd elmer
git pull origin master # забрать изменения
git add -A # добавить всё
git commit -m "..." # закоммитить
git push origin master # отправить
```
### Android
```bash
cd elmer/android
git pull origin master # забрать изменения
git add -A # добавить всё
git commit -m "..." # закоммитить
git push origin master # отправить
```
## Деплой на сервер (obdai.ru)
```bash
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 \
"cd /opt/elmer && git pull origin master && sudo systemctl restart elmer"
```
## Сборка APK (на сервере)
```bash
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 \
"export ANDROID_SDK_ROOT=\$HOME/android-sdk && \
export ANDROID_HOME=\$ANDROID_SDK_ROOT && \
cd /opt/elmer/android && \
./gradlew clean assembleDebug && \
cp app/build/outputs/apk/debug/app-debug.apk /opt/elmer/web/static/"
```
## Важно
- `android/` в `.gitignore` родительского репо — его изменения коммитятся отдельно
- Ветка везде `master`
- Перед началом работы всегда делать `git pull` в обоих репо
+25
View File
@@ -0,0 +1,25 @@
# 2026-05-31
## Сервер
- 🔴 **api/db.py** — WAL, `request_id` UNIQUE, `close()`, контекстный менеджер
- 🔴 **api/routes.py** — идемпотентность upload, `/ping-llm` кэш 60с, `/chat` через roles
- 🔴 **obd/protocol.py**`reset_input_buffer()`, не затирать ERROR
- 🔴 **brain/client.py**`LLMError`, обработка 429/5xx/Timeout, дефолт gpt-oss-120b
- 🟡 **api/config.py**`@lru_cache` на `load()`
- 🟡 **api/routes.py** — импорты наверх
- Удалён мёртвый `elmer/elmer/`
## Android
- 🔴 **ServerClient**`request_id` (UUID), `X-Api-Key`, exponential backoff
- 🔴 **ElmProtocol** — дренаж буфера, не затирать ERROR
- 🔴 **SessionDb**`ALTER TABLE` вместо DROP, индекс
- 🔴 **MainActivity** — throttle `/ping-llm`, `X-Api-Key`, `chatHistory` persistence
- 🔴 **ScriptRunnerService** — null-intent guard, убран мёртвый `paused`
- 🟡 **build.gradle.kts**`buildConfigField API_KEY`
## Прочее
- VIN → logo
- Версия: 0.35.0-dev → 0.36.0-dev
- `.instructions.md` — создан
- `doc/architecture.md` — CI/CD расписан
- GitHub Actions удалён и восстановлен (несколько раз)
+13
View File
@@ -0,0 +1,13 @@
# 2026-06-03
## Сервер
- Дефолтный LLM: `api.aillm.ru``api.deepseek.com` (deepseek-v4-flash)
- `doc/git-guide.md` — создана инструкция по git
- `doc/architecture.md` — уточнён деплой (git clone, не tar.gz)
## Android
- `build.gradle.kts``buildFeatures.buildConfig = true`
## Прочее
- Версия: 0.36.0-dev
- Сервер переведён на git-клон вместо tar.gz (удалён elmer.old)
+21
View File
@@ -0,0 +1,21 @@
# 2026-06-05
## Сервер
- `car_info` в upload — водитель может описать авто текстом, передаётся в LLM
- GitHub Actions разрешён для APK (теперь CI собирает и деплоит)
## Android
- **`ElmChecker.kt`** — новый: проверка ELM327 (версия, напряжение, BT)
- Поле «что за машина» — ввод описания авто перед диагностикой
- Индикаторы загрузки — улучшен UI
- Подпись APK — фиксированный ключ (APK обновляется без удаления)
- **GitHub Actions** — автосборка + деплой APK на сервер
- `gradlew` — добавлен в репо
- `ServerClient``ping()` / `pingLlm()`
- 🔴 **ElmChecker** — разделён на `DeviceInfo` (AT, без зажигания) + `EcuData` (VIN/PID)
- 🔴 **ElmChecker** — фикс: `checkDevice()` дёргал `connectAndInit()` второй раз → сбрасывал ELM → пустые ответы
- 🔴 **MainActivity** — секундомер при проверке ELM
- 🟡 **ElmChecker**`AT@2` добавлен, фильтр бинарного мусора
## Прочее
- Версия: 0.37.0-dev → 0.38.0-dev
+26
View File
@@ -0,0 +1,26 @@
# 2026-06-06
## Сервер
- 🔴 `api/db.py``device_uuid` колонка, `phone_lang`, `phone_tz`, `phone_display`, `save_dtc_scan()`
- 🔴 `api/routes.py``POST /api/v1/dtc/decode`, `POST /api/v1/dtc/upload` (+ идемпотентность)
- 🔴 `api/routes.py` — промпт-билдер: защита от пустых `{}` и `None` списков
- 🔴 `api/parser.py` — VIN из CAN multi-frame (0: 1: 2:) и ISO-TP (10 14 21 22)
- 🔴 `api/parser.py` — DTC из raw HEX: фикс `mode="43"` вместо `"03"`
- `brain/prompts.py` — правило №8: неточности данных (дубль DTC, клон, напряжение)
- `brain/prompts.py` — правило №7 (было №11): никогда не раскрывать модель/создателя
- `doc/dtc_codes.txt` — справочник 128 кодов DTC
- `doc/SETUP.md` — инструкция для нового разработчика
- `tests/test_all.py` — 91 тест (ELM, DTC, VIN, БД, идемпотентность, экстремальные)
## Android
- 🔴 Кнопка «⚠️ ОШИБКИ» — сканирование DTC (mode 03 + 07)
- 🔴 Кнопка «🔍 ДИАГНОСТИКА» блокирована до скана ошибок
- 🔴 `ElmChecker.scanDtc()` — отдельный метод сканирования ошибок
- 🔴 `SharedPreferences``device_uuid` генерируется при первом запуске
- `ScriptRunnerService.buildClientInfo()` — язык, часовой пояс, разрешение
- 3 кнопки в рамке «🔍 Проверка»: Сервер | ELM | ЭБУ
## Прочее
- Версия: 0.39.0-dev → 0.40.0-dev
- APK через nginx напрямую (правило в `.instructions.md`)
- На ВМ установлен Android SDK + Gradle для сборки
+46
View File
@@ -0,0 +1,46 @@
# Планы на 2026-06-07 (вечер)
## 1. 🔴 Исправить 16-ричный вывод параметров
**Проблема**: при считывании PID'ов на экран выводятся hex-коды вместо человеческих значений.
- Температура ОЖ: `41053C` вместо `ОЖ: 20°C`
- Обороты: `410C1A2B` вместо `Обороты: 850 об/мин`
**Причина**: ObdDecoder.decode() не чистит `\r` `\n` из raw ответа → parseInt падает → возвращает raw.
**Фикс**: добавить `.replace("\r", "").replace("\n", "")` в clean.
## 2. 🟡 Кнопки СЕРВЕР/ELM/ЭБУ — только до первой диагностики
После первого успешного "done" — убрать эти кнопки. Они нужны только для первоначальной проверки.
## 3. 🟢 Динамические тесты (две новые кнопки)
Появляются после первичной диагностики вместо СЕРВЕР/ELM/ЭБУ.
### Тест 1: «На месте»
- Подсказка: «Нажмите на педаль газа, поднимите обороты до 3000, держите 3-4 секунды, затем резко сбросьте газ. Нажмите СТАРТ когда готовы.»
- Кнопка: «⏱ Тест на месте» → после нажатия → «▶ Старт»
- После Старт: мониторинг RPM, MAP, STFT, LTFT — 5-10 секунд
- Анализ: отклик дросселя, провалы, богатая/бедная смесь при сбросе
### Тест 2: «В движении»
- Подсказка: «Включите 2-ю передачу (АКПП — ручной режим), разгонитесь до ~3000 об/мин, держите несколько секунд, затем резко сбросьте газ. Нажмите СТАРТ ДО начала движения — программа сама отследит параметры.»
- Кнопка: «🚗 Тест в движении» → после нажатия → «▶ Старт (нажать до движения)»
- После Старт: мониторинг скорости, RPM, нагрузки, STFT — автоматическое определение начала движения и сброса газа
- Анализ: поведение под нагрузкой, детонация, пропуски
## 4. 🟢 Flow кнопок
```
[Начало] → СЕРВЕР | ELM | ЭБУ | ОШИБКИ | ДИАГНОСТИКА
↓ после диагностики
[Результат] → ✕ Закрыть | ⏱ Тест на месте | 🚗 Тест в движении | поле ввода + ➤
```
## 5. 🟡 История — не терять
При переключении между тестами и чатом — сохранять вывод.
## 6. 🔧 Скрипты для тестов
Нужно создать server-side скрипты:
- `script/dynamic_idle.json` — тест на месте (RPM, MAP, STFT, LTFT, дроссель, нагрузка)
- `script/dynamic_drive.json` — тест в движении (RPM, скорость, нагрузка, STFT, MAP)
Оба скрипта должны:
- Считывать параметры с высокой частотой (ATAT1 если v2)
- Автоматически определять фазы: разгон → удержание → сброс
- Отправлять результаты на сервер → LLM-анализ
+96
View File
@@ -0,0 +1,96 @@
# 2026-06-07 — Пробинг ELM327, трехуровневый профиль
## Проблема
`obd/protocol.py``init()` посылал ATAT1 и ATSTxx всем устройствам.
Большинство клонов v1.5 не знают этих команд → тишина → `_exec()` делает до 10 ретраев → всё висит на десятки секунд.
## Решение
### 1. Трехуровневый пробинг (`obd/probe.py`)
Каскадный тест: сначала база, потом улучшения.
```
Уровень 0 (база, все клоны):
ATE0 ATL0 ATS0 ATH1 ATSP0 ATDPN ATRV ATI
→ хоть одна не ответила OK → НЕИСПРАВЕН
Уровень 1 (хорошие клоны):
ATAT1
→ OK → уровень 1
Уровень 2 (настоящие ELM):
ATCAF1 ATCFC1
→ OK → уровень 2
```
Результат сохраняется в `device_profiles` по ключу BT MAC.
### 2. Fix `obd/protocol.py` init()
`init()` теперь шлёт ТОЛЬКО базу (уровень 0): ATE0 ATL0 ATS0 ATH1 ATSP0.
Методы под уровень:
- `init_base()` — то же что init()
- `init_l1()` — + ATAT1
- `init_l2()` — + ATAT1 + ATCAF1 + ATCFC1
### 3. Таблица `device_profiles` (`api/db.py`)
```sql
CREATE TABLE device_profiles (
mac TEXT PRIMARY KEY, -- BT MAC
level INTEGER NOT NULL, -- 0/1/2
elm_version TEXT, -- ATI ответ
elm_desc TEXT, -- AT@1 (если есть)
protocol TEXT, -- ATDPN
voltage TEXT, -- ATRV
supported TEXT, -- JSON: ["ATE0","ATL0",...]
unsupported TEXT, -- JSON: ["ATAT1","ATCAF1",...]
first_seen TEXT NOT NULL,
last_seen TEXT NOT NULL
);
```
### 4. Скрипты под уровень (`api/scripts.py`)
- `build_script_l0()` — 5 PIDs + ошибки (однокадровые)
- `build_script_l1()` — 8 PIDs + VIN + ошибки
- `build_script_l2()` — 14 PIDs + VIN + калибровки + все ошибки
### 5. Эндпоинты (`api/routes.py`)
- `POST /api/v1/elm/probe` — принимает MAC + ответы, возвращает профиль
- `GET /api/v1/elm/profile/<mac>` — достаёт из кэша
- `GET /api/v1/script?level=0|1|2` — скрипт под уровень
### 6. Рефакторинг: разделение на независимые сервисы
Файлы разбиты по тематике, каждый — отдельный сервис:
```
obd/
commands.py — Каталог ВСЕХ AT-команд ELM327 (с метаданными)
classifier.py — Классификация ответов + определение уровня
connection.py — Транспортный слой (SerialTransport)
probe.py — Пробинг (использует commands + classifier)
protocol.py — Стейт-машина AndrOBD (использует connection)
state.py — Состояния/типы ответов
timing.py — Адаптивный таймаут
```
**Принцип**: каждый модуль делает одно дело, не дублирует логику.
- `commands.py` — единственный источник правды о командах
- `classifier.py` — единственное место классификации ответов
- `connection.py` — единственное место I/O
- `routes.py` — тонкая прослойка, без бизнес-логики
### 5. Эндпоинт (`api/routes.py`)
`POST /api/v1/elm/probe` — принимает MAC, возвращает профиль.
Сервер сам шлёт команды через реле (Android ElmForwardService).
## Ключевое правило
**Нет в профиле → не слать. Никаких ретраев на неизвестное.**
+207
View File
@@ -0,0 +1,207 @@
# 2026-06-10 — Speed-test ELM327, адаптивный интервал, валидация, деплой v0.94.0-dev
## Проблема
Динамический тест (START/STOP) использует жёстко заданный интервал 250мс для опроса 3 PID (RPM, MAF, STFT). Реальные тесты на машине показали:
- **Session #47** (3 PID × 250ms): 94% ошибок — ELM327 v1.5 не успевает
- **Session #48** (3 PID × 250ms): первые 15 сэмплов ок, потом все пустые — буфер ELM переполняется
- **Session #45** (5 PID × 500ms, старый): после ~20 сэмплов тоже падает
**Корень:** 3 команды занимают ~240-300ms на ELM327 v1.5. При интервале 250ms пауза между батчами ≈ 0ms. Буфер UART переполняется, ELM перестаёт отвечать.
Также: в `api/db.py` не было защиты `threading.Lock` — 20 конкурентных записей в БД давали 8 ошибок.
---
## Решения
### 1. Threading lock в Database
`api/db.py` — добавлен `threading.Lock()`, обёрнуты все write-методы (`save_session`, `save_dtc_scan`, `save_device_profile`).
Было: `self.conn.execute()` + `self.conn.commit()` без блокировки → 8/20 ошибок при конкурентном доступе.
Стало: `with self._lock:` → 0 ошибок.
Коммит: `fix: threading lock in Database for concurrent writes`
### 2. Speed-test ELM327 — адаптивный интервал
#### Концепция
При первом подключении нового ELM327 (уникальный BT MAC) — замерить скорость ответа на разных PID, сохранить в профиль. При последующих запусках использовать сохранённое значение для расчёта интервала.
#### Сервер — `api/db.py`
Добавлена колонка `response_time_ms INTEGER DEFAULT 250` в таблицу `device_profiles`:
```sql
CREATE TABLE device_profiles (
mac TEXT PRIMARY KEY,
level INTEGER NOT NULL,
elm_version TEXT,
elm_desc TEXT,
protocol TEXT,
voltage TEXT,
response_time_ms INTEGER DEFAULT 250, -- <-- NEW
supported TEXT,
unsupported TEXT,
errors TEXT,
first_seen TEXT,
last_seen TEXT
);
```
Миграция для старых БД:
```sql
ALTER TABLE device_profiles ADD COLUMN response_time_ms INTEGER DEFAULT 250;
```
`save_device_profile()` обновлена: принимает и сохраняет `response_time_ms`.
#### Сервер — `api/routes.py`
Добавлен эндпоинт:
```
PUT /api/v1/elm/profile/<mac>
Body: {"response_time_ms": 180}
```
Позволяет Android-клиенту обновить скорость ELM в профиле.
#### Android — `ElmChecker.kt`
Добавлен метод `measureResponseTime(elm: ElmProtocol, log: (String) -> Unit): Int`:
```
Алгоритм:
1. Выбрать 3 PID: 010C (RPM), 0110 (MAF), 0106 (STFT)
2. Каждый PID послать 3 раза
3. Замерить round-trip время для каждого
4. Усреднить
5. Вернуть среднее в миллисекундах
6. Логировать в UI: "⏱ Тест скорости: 010C — 82ms, 89ms, 78ms"
```
Вызывается после connectAndInit(), перед стартом динамического теста.
#### Android — `MainActivity.kt` — `startDynamicRecording()`
В流程 добавлен speed-test между статическим пробросом PID и динамическим сбором:
```
1. Статика: пробуем 9 PID, log в UI
2. Speed-test: 3 PID × 3 раза, замер времени
→ "⏱ Тест скорости ELM..."
→ " RPM: 82ms 89ms 78ms (среднее 83ms)"
→ " MAF: 95ms 91ms 88ms (среднее 91ms)"
→ " STFT: 79ms 82ms 85ms (среднее 82ms)"
→ " Среднее по всем: 85ms"
3. Расчёт интервала: max(250, avg_response_time × 3 × 1.5)
→ "📡 Интервал опроса: 383ms (запас 50%)"
4. Сохранение response_time_ms на сервер
5. Запуск DynamicCollector с вычисленным интервалом
```
Формула интервала:
```
interval = max(250, avg_response_time × num_pids × 1.5)
```
Где:
- `avg_response_time` — среднее время ответа ELM на одну команду (ms)
- `num_pids` — количество PID в динамическом тесте (3)
- `1.5` — запас 50% на вариативность
- `250` — минимальный интервал (для быстрых ELM327 v2.x)
Если профиль уже существует (повторный запуск) — speed-test пропускается, интервал берётся из профиля. По кнопке «принудительно» можно перезамерить.
---
## Итог тестов
После фикса threading lock:
```
134 ✅ / 0 ❌ — все тесты проходят
```
---
## Файлы
| Файл | Что изменено |
|------|-------------|
| `api/db.py` | threading lock, response_time_ms колонка, миграция |
| `api/routes.py` | PUT /api/v1/elm/profile/<mac> |
| `android/.../ElmChecker.kt` | measureResponseTime() |
| `android/.../MainActivity.kt` | speed-test перед динамикой, адаптивный интервал |
| `android/.../ServerClient.kt` | saveProfile() — отправка response_time_ms |
| `android/.../DynamicCollector.kt` | intervalMs параметр (уже есть) |
| `android/app/build.gradle.kts` | versionName = "0.93.0-dev" |
| `web/templates/index.html` | v0.93.0-dev |
---
## 3. Валидация speed-test (v0.94.0-dev)
### Проблема
Если ELM327 плохо вставлен в OBD-разъём (контакт болтается), замеры скорости — мусор: часть команд падает с `(err)`, время прыгает от 10ms до 900ms. Сохранять такой профиль нельзя.
### Решение
`ElmChecker.kt``measureResponseTime()` возвращает `SpeedTestResult`:
```kotlin
data class SpeedTestResult(
val perPidAvg: List<Int>,
val batchTime: Int,
val reliable: Boolean,
val message: String
)
```
**Критерии отбраковки (reliable=false):**
1. Любой замер отклоняется от среднего по своему PID >50%
2. Любая команда вернула `(err)` или пустой ответ
3. Все замеры <20ms (ELM не отвечает, мусор)
При `reliable=false` — профиль **не сохраняется**, интервал 250ms по умолчанию.
### Quick-check при каждом connect
`ElmChecker.kt` — добавлен `quickCheck(): Int?`:
```
При каждом клике на светофор ELM:
1. Загрузить профиль с сервера
2. Послать 010C (RPM), замерить время
3. Сравнить с профилем:
- расхождение <50% → "✅ ELM стабилен: ~85ms (профиль 256ms)"
- расхождение >50% → "⚠️ Скорость ELM изменилась: было 256ms, сейчас ~510ms"
4. Если профиля нет → полный speed-test (3 PID × 3 раза)
```
### Принудительный перетест
Клик на 🔵 ELM → переинициализация → quick-check. Если нужно полностью перемерить — очистить `response_time_ms` в `device_profiles` на сервере.
---
## Итоговая логика
| Ситуация | При connect | При СТАРТ |
|----------|------------|-----------|
| Новый ELM (нет профиля) | Полный speed-test 3 PID × 3 → сохранить | Интервал из профиля |
| Знакомый ELM, контакт ок | Quick-check: "стабилен" | Интервал из профиля |
| Знакомый ELM, контакт плохой | Quick-check: "изменилась" | Интервал 250ms (дефолт) |
| После переподключения | Quick-check → сверка | Интервал из профиля если ок |
## Файлы (v0.94.0-dev)
| Файл | Что изменено |
|------|-------------|
| `android/.../ElmChecker.kt` | `SpeedTestResult`, `quickCheck()`, валидация |
| `android/.../MainActivity.kt` | Speed-test при инициализации, quick-check |
| `android/.../ServerClient.kt` | `getProfileResponseTime()`, `saveProfile()` |
| `api/db.py` | `response_time_ms` колонка, threading lock |
| `api/routes.py` | `PUT /api/v1/elm/profile/<mac>` |
| `android/app/build.gradle.kts` | versionName = "0.94.0-dev" |
| `web/templates/index.html` | v0.94.0-dev |
| `doc/history/2026-06-10.md` | Этот файл |
+113
View File
@@ -0,0 +1,113 @@
# Новая морда elmAI — v0.57.0+
> Проект из morda.txt, обсуждение 7 июня 2026
## Макет
```
┌─────────────────────────────────────┐
│ 🔧 elmAI v0.57.0 │
│ [📡●] [🔌●] [🚗●] [🧠●] │ иконки + светофоры (тап = перепроверка)
├─────────────────────────────────────┤
│ [ ОШИБКИ / ДИАГНОСТИКА / СТАРТ / СТОП ] │ одна кнопка-трансформер
├─────────────────────────────────────┤
│ │
│ поле вывода │ ScrollView
│ │
├─────────────────────────────────────┤
│ [📋 История] │ кнопка
├─────────────────────────────────────┤
│ [________________________] [➤] │ поле ввода 2 строки + кнопка
└─────────────────────────────────────┘
```
## Светофоры
| Иконка | Текст | Зелёный | Жёлтый | Красный |
|--------|-------|---------|--------|---------|
| 📡 | Сервер | ping < 3с | проверка... | нет связи |
| 🔌 | ELM | BT + ATI ok | подключение... | нет ELM |
| 🚗 | ЭБУ | 0100 ok | — | нет связи с ЭБУ |
| 🧠 | LLM | ping-llm ok | проверка... | нет доступа |
- Жёлтый только во время проверки
- Тап по иконке → перепроверка конкретного компонента
- LLM проверяется только если Сервер зелёный
### Таймауты
- ELM: 2 попытки connect по 4с = 8с макс → красный
- Сервер: HTTP GET /api/v1/ping, таймаут 3с → красный
- LLM: HTTP GET /api/v1/ping-llm, таймаут 5с → красный
- Перепроверка при тапе: сбрасывает на жёлтый, запускает проверку
## Кнопка-трансформер
Одна кнопка, меняет текст/цвет/действие:
```
[ОШИБКИ] ──→ сканирование DTC ──→ [ДИАГНОСТИКА]
если сервер ✅ → обычный скрипт + LLM
если сервер ❌ → пояснение в выводе
[СТАРТ]
газ 3000 → сброс → [СТОП]
данные в памяти → жми ➤
```
## Поле ввода + кнопка ➤
- Всегда активно, 2 строки
- **Обычный режим**: ввёл текст → ➤ → отправка в чат с LLM
- **После СТОП**: ➤ отправляет накопленные данные теста на сервер + текст (если есть)
- После отправки → результат LLM в выводе
## История
- Кнопка, как сейчас
- Выбор записи → диагноз + 📤 Поделиться
- После Share — возврат в приложение (стандартное поведение Android)
## Flow в деталях
### 1. Запуск приложения
- Светофоры: ELM жёлтый, Сервер жёлтый, ЭБУ красный, LLM красный
- Проверка ELM (8с) → зелёный/красный
- Проверка Сервера (3с) → зелёный/красный
- Если Сервер зелёный → проверка LLM (5с) → зелёный/красный
- Если ELM зелёный → проверка ЭБУ (через 0100)
### 2. Ошибки
- Тап ОШИБКИ → scanDtc() → результат в выводе
- Кнопка → ДИАГНОСТИКА
### 3. Диагностика
- Если сервер зелёный → обычный скрипт L0/L1/L2 → LLM → вывод
- Если сервер красный → «Сервер недоступен. Сделайте тест на месте.»
- Кнопка → СТАРТ
### 4. СТАРТ
- Подсказка в выводе: «Нажмите газ, 3000 об/мин 3-4с, сбросьте. Нажмите СТОП.»
- Запись 12 PID каждые 250мс
- Кнопка → СТОП
### 5. СТОП
- Запись остановлена, данные в памяти
- Вывод: «Записано N отсчётов. Нажмите ➤ для отправки.»
- Если нужно — можно снова СТАРТ (новый тест)
- Кнопка → СТАРТ (снова)
### 6. Отправка (➤)
- Если есть данные теста → POST /api/v1/session/upload с dynamic_samples
- Если есть текст → добавляется как car_info
- LLM-анализ → результат в выводе
## Что удалить из текущего UI
- Кнопки Сервер, ELM, ЭБУ как отдельные кнопки → станут иконками
- dynamicButtons (На месте / В движении) → не нужны, всё через одну кнопку
- btnDynStart → не нужна
- CheckBox «Полная диагностика» → всегда полная
- tvDtcStatus → не нужен, всё в выводе
- tvPrompt → не нужен
@@ -0,0 +1,396 @@
# Automotive Sensing and Actuators
> Источник: [MPScholar — Monolithic Power Systems](https://www.monolithicpower.com/en/learning/mpscholar/automotive-electronics/automotive-sensing-and-actuators)
> Дата сохранения: 2026-06-10
---
## Содержание
1. [Introduction to Automotive Sensors and Actuators](#1-introduction-to-automotive-sensors-and-actuators)
2. [Types and Functions of Sensors in Automotive Systems](#2-types-and-functions-of-sensors-in-automotive-systems)
3. [Types and Functions of Actuators in Automotive Systems](#3-types-and-functions-of-actuators-in-automotive-systems)
4. [Power Management for Sensors and Actuators](#4-power-management-for-sensors-and-actuators)
5. [Integration and Interfacing of Sensors and Actuators](#5-integration-and-interfacing-of-sensors-and-actuators)
---
## 1. Introduction to Automotive Sensors and Actuators
### The Role of Sensors and Actuators in Modern Vehicles
A new era of unheard-of performance, safety, and control in automobiles has begun with the introduction of sensors and actuators in automotive engineering. The future of mobility can be understood by comprehending the complex functions that these devices play, especially at a time when we are on the verge of a revolution in transportation.
#### Overview of Vehicle Automation and Control
The 21st-century automobile is changing from a mechanical device to an extremely complex electrical system on wheels. This change has been made possible in large part by the growing integration of actuators and sensors, which work together to enhance vehicle functioning.
- **Role of Sensors:** In essence, sensors are the eyes and ears of a vehicle. They keep an eye on a number of variables, including proximity, temperature, acceleration, and speed. Numerous control systems rely on this data to provide them with real-time information about the vehicle and its surroundings.
- **Role of Actuators:** If sensors are the information gatherers, actuators are the doers. Actuators receive signals and respond with specified actions, including changing the air-fuel ratio in the engine, tightening up the suspension, or even applying the brakes. They convert electrical information into mechanical action, directly influencing and controlling a variety of vehicle components.
#### Improving Safety, Efficiency, and Performance
The ultimate goal of sensor and actuator integration is to improve driving in three critical areas: performance, efficiency, and safety.
- **Safety Enhancements:** In order to provide power to advanced driver-assistance systems (ADAS), sensors such as radar, lidar, and cameras collaborate with one another. Meticulous sensor input and actuator reaction enable features like automated emergency braking, adaptive cruise control, and lane-keeping assistance. Through anticipatory threat detection and proactive measures, these technologies significantly lower accident rates and save lives.
- **Efficiency Optimization:** In today's automotive world, fuel economy and pollution management are critical. Onboard computers can modify combustion settings due to sensors that track pollutants and engine data. Actuators then put these adjustments into practice, maximizing fuel efficiency and lowering dangerous emissions. In a similar vein, sensors aid in the best possible battery utilization in electric cars, guaranteeing optimal range and longevity.
- **Performance Upgrades:** Today's drivers need a car that is strong, nimble, and responsive. Sensors evaluate performance metrics like grip, acceleration, and aerodynamic drag through continuous feedback loops. Actuators then modify components such as the suspension, engine, and gearbox to improve the vehicle's performance and provide for a thrilling ride.
To sum up, the integration of actuators and sensors in contemporary automobiles has completely reshaped the concepts of automotive engineering. These elements will become even more crucial as we approach the future of autonomous driving and smart transportation, spurring innovation and setting new standards for performance, safety, and efficiency.
### Basic Principles of Sensing and Actuation
The two main pillars that support the current vehicle control system are actuation and sensing. The sophisticated and sensitive behavior of today's cars, which allows them to easily interact with constantly changing environments, depends on both of these components.
#### Sensing as Information Gathering
In the context of automobiles, sensing can be conceptualized as the means by which the vehicle perceives its internal states and external environment. Similar to how our senses of sight, touch, and hearing feed us vital information about the world around us, automobile sensors pick up on particular factors that affect how well vehicles operate.
- **Types of Sensors:** Sensors vary widely based on their functional requirement. Common varieties include position sensors (for crankshaft or throttle position), temperature sensors (for engine and interior conditions), pressure sensors (in tire monitoring systems or fuel lines), and more sophisticated devices (such as cameras and radars for ADAS functions).
- **Data Acquisition:** Every sensor operates on the principle of converting a physical quantity into an electrical signal. Electronic control units (ECUs) interpret and analyze these electrical impulses, making real-time analysis possible. For this reason, this conversion is essential.
- **Feedback Mechanism:** Continuous data collection guarantees that a feedback loop is maintained at all times, which in turn supplies the control systems of the vehicle with the most recent information. This ongoing cycle enables the behavior of the vehicle to be improved and adjusted.
#### Actuation as Control Execution
Actuation takes over to make the required adjustments after the sensors have collected the crucial data. Actuators essentially function as the vehicle's "muscles," translating the electrical impulses that are processed back into motion.
- **Types of Actuators:** Actuators in vehicles are diverse, including components like fuel injectors (which control fuel delivery), electric motors (steering, braking, or throttle control), and solenoids (for valve operation or gear shifts).
- **Signal Interpretation:** The ECUs of the car send signals to the actuators, which decipher the sensor data. These signals specify the precise action that the actuator must do in order to accomplish the intended result.
- **Responsive and Adaptive Actions:** Vehicles that use actuators can be made to be both responsive and adaptable. When an obstruction is detected, responsive actions take rapid action, such as automated braking. Adaptive actions, like adaptive cruise control, which modifies vehicle speed based on traffic circumstances, change over time based on continuous sensor data.
In conclusion, the modern vehicle's intelligence is defined by the combination of sensing and actuation. Actuators implement the necessary modifications to maximize safety, performance, and efficiency, while sensors offer a thorough understanding of the surroundings and the condition of the vehicle.
### Historical Development of Automotive Sensors and Actuators
When one looks at the realm of sensing and actuation, the evolution of the automobile is a fascinating tapestry of engineering achievements and discoveries.
#### Evolution of Sensing Technologies in Vehicles
The early autos' basic mechanical and electro-mechanical systems are where sensing in cars first appeared.
- **Mechanical Era:** The nascent stages of automotive development predominantly employed mechanical systems. An example of an early speedometer was a cable-driven device that sent speed through a rotating cable and was directly attached to the gearbox.
- **Electro-Mechanical Onset:** Transitioning into the 20th century, electro-mechanical components began surfacing. For instance, bimetallic strips and Bourdon tubes were utilized in temperature and oil pressure gauges, respectively.
- **Electronic Revolution:** Thanks to developments in semiconductor technologies, electronic sensing saw a boom after the 1970s. The advent of sensors such as oxygen sensors, manifold absolute pressure sensors, and throttle position sensors during this era laid the foundation for advanced engine management and electronic fuel injection systems.
- **Advent of ADAS and Connectivity:** Advanced driver-assistance systems (ADAS) were introduced in the late 20th and early 21st centuries. Advances in autonomous driving, collision avoidance, and lane departure warning systems were made possible by technological innovations, including radar, LIDAR, and cameras.
#### Trends and Future Directions
The scope of sensing and actuation in the automobile industry is expanding in step with the constant advancement of technology.
- **Miniaturization and Integration:** Miniaturization is a trend in modern sensors, making them smaller without compromising on functionality. Integrated sensor systems are increasingly widely used; they combine several sensing functions into a single unit.
- **Self-Diagnostics and Predictive Maintenance:** The upcoming generation of sensors and actuators are not only operational devices but also self-aware. They are able to keep an eye on their performance, anticipate malfunctions, and notify the driver or the car's central system of possible problems.
- **Holistic Vehicle Sensing:** An automobile that senses its environment holistically is the automotive industry's vision of the future. To ensure peak performance, safety, and comfort, a confluence of internal and external sensors must cooperate.
- **Actuators in Electric and Autonomous Vehicles:** With electric cars (EVs) gaining pace, specialized actuators customized for EVs are on the horizon. Actuators will also become increasingly important as autonomous driving technologies advance, guaranteeing precise, split-second responses to sensor input.
- **Material Innovations:** Actuators can now respond faster, with greater precision, and for longer periods of time thanks to new materials including shape-memory alloys and piezoelectric compounds.
---
## 2. Types and Functions of Sensors in Automotive Systems
### Classification of Automotive Sensors
Automotive sensors are essential to the smooth operation of modern automobiles. These sensors provide information about numerous vehicle parameters to the Electronic Control Unit (ECU) so that safety, efficiency, and performance are maximized. They do this by translating physical quantities into electrical impulses. These sensors can be categorized along two main lines: first, by the physical characteristics they measure, and second, by the underlying technology they use.
#### Classification Based on Physical Properties
- **Pressure Sensors:** These devices identify and gauge the pressure of the car's various fluids, including air, fuel, and oil. They make sure that the pressures stay within predetermined limits for ideal functioning and are frequently utilized in fuel injection and brake systems. They are predicated either on differential pressure sensing or absolute pressure sensing theory.
- **Temperature Sensors:** Integral to engine management, temperature sensors monitor the engine's coolant, oil, and air temperatures. By doing this, possible harm is avoided and the engine is guaranteed to run within a safe temperature range. Furthermore, temperature sensors are integrated into all power electronic controllers so that, in the event that the temperature rises above safe limits, the power can be derated or switched off.
- **Position Sensors:** These sensors determine where different parts are located. Examples are the Camshaft/Crankshaft Position Sensors, which help with engine timing, and the Throttle Position Sensor (TPS), which senses the position of the throttle in internal combustion engines.
- **Speed Sensors:** These sensors detect the rotational speed of the wheels and axis and are frequently used in the Anti-lock Braking System (ABS) and Transmission Control Units (TCU). This information enables the ECU, for example, to make real-time changes to prevent wheel lockup while braking.
- **Level Sensors:** These sensors keep an eye on the fluid levels in a variety of reservoirs, such as engine oil sump pumps, braking fluid reservoirs, and gasoline tanks.
#### Classification Based on Technology
- **Capacitive Sensors:** When a physical quantity varies, they work on the basis of capacitance alteration. In capacitive proximity sensors, for example, an object's approach modifies the capacitance, which is then detected. Certain fluid-level sensors rely on the fluid's capacitance.
- **Ultrasonic Sensors:** These sensors produce ultrasonic waves and are mostly utilized in parking assistance and obstacle detection. The sensor measures the distance by measuring the time it takes for the waves to reflect back after hitting an obstruction and receiving the information.
- **Infrared Sensors:** These sensors use the infrared spectrum to detect obstacles and provide night vision, particularly in low-light situations.
- **Piezoelectric Sensors:** These sensors produce a voltage in response to mechanical stress. Engine knock sensors use this feature to identify engine knock or pinging.
- **Hall-Effect Sensors:** Operating on the principle of the Hall Effect, these sensors can detect magnetic fields and are commonly employed for position detection, notably in the context of camshaft and crankshaft positions.
- **Resistive Sensors:** These sensors, such as temperature sensors, whose resistance varies inversely with temperature, alter their resistance in response to the physical quantity they detect.
### Applications of Sensors in Automotive Systems
#### Engine Management and Control
The engine management system's core components are the sensors, they enable peak performance, fuel economy, and emission control:
- **Fuel/Air Mixture Control:** By measuring the amount of oxygen in exhaust gasses through the use of oxygen sensors installed inside the exhaust system, the engine control module is able to modify the fuel-air mixture for the best possible combustion.
- **Ignition Timing:** Crankshaft and camshaft position sensors help establish the engine's phase and speed. This information helps the engine control unit (ECU) to time the spark for combustion exactly.
- **Cooling System:** Temperature sensors monitor the engine's coolant temperature. If the temperature crosses a defined threshold, the ECU can modify the functioning of the cooling fan or communicate a potential overheating issue to the driver.
- **Turbocharger Control:** Pressure sensors are used in turbocharged engines to monitor the boost pressure and ensure that it remains within the safe operating parameters established for the engine.
#### Safety Systems
Safety is fundamental in vehicle design, and sensors play a critical part in numerous safety-enhancing systems:
- **Airbag Deployment:** Accelerometers detect fast deceleration characteristics of a collision. The sensor alerts the airbag control unit to activate the airbags, which cushion the occupants and lower the possibility of injury in the event of a large accident.
- **Anti-Lock Braking System (ABS):** Wheel speed sensors constantly track the rotational speed of each wheel in the anti-lock braking system (ABS). The ABS adjusts brake pressure to prevent wheel lockup when it senses it is about to happen, preserving steering control.
- **Traction Control System:** This system detects when one or more wheels lose grip by using wheel speed sensors. In order to regain traction, the ECU can then lower engine power or apply brake force to particular wheels.
- **Collision Sensors:** These are particularly crucial for battery electric vehicles (BEVs), as they ensure that all high-voltage parts are deactivated in the event of a collision. This is accomplished via the collision sensor circuit, which modifies the crash signal state that high-voltage components expect in the case of a crash and ensures that any circuits that may have become accessible to persons due to the collision and vehicle damage are de-energized.
#### Driver-Assistance Systems
- **Parking Assistance:** This is provided by ultrasonic sensors installed all around the car to identify nearby obstructions. By giving the driver input regarding the distance to objects, these sensors help make parking in confined places easier to handle.
- **Lane-Keeping Assistance:** Roadside lane markers are detected by optical or infrared sensors. Depending on how sophisticated the system is, it may alert the driver or even take corrective action if it detects an inadvertent lane departure without signaling.
- **Adaptive Cruise Control:** This technology keeps a safe following distance between itself and the car in front of you using radar or LIDAR sensors. The mechanism automatically lowers speed to preserve the predetermined gap if the car in front of it slows down.
- **Blind Spot Detection:** This system lowers the likelihood of side-swiping accidents by alerting drivers to cars in their blind spots, usually through the use of radar or ultrasonic sensors.
### Key Specifications and Performance Criteria
#### Accuracy and Resolution
- **Accuracy:** This indicates the degree to which the sensor's reading agrees with the real value. A temperature sensor that is precise to within 0.5°C of the real temperature, for example, is more reliable than one that could be 2°C off.
- **Resolution:** The smallest change in the quantity being measured that causes the related output signal to alter noticeably is referred to as this. For example, a pressure sensor is said to have 0.01 psi resolution if it can measure variations as small as 0.01 psi.
#### Sensitivity and Range
- **Sensitivity:** This is defined as the sensor's response, or change in output, to a change in the input or amount being measured.
- **Range:** The physical quantity that the sensor is capable of measuring is shown, along with its minimum and maximum values.
#### Environmental Considerations
- **Temperature Stability:** Because cars operate in a variety of conditions, sensors need to be able to function accurately and consistently across a wide temperature range.
- **Resistance to Contaminants:** To ensure lifetime and reliable operation, automotive sensors should be resistant to fuel, oil, dust, moisture, and other contaminants.
- **Vibration Resistance:** Cars can cause a lot of vibrations and shock, especially in rough terrain. For constant readings, sensors must be unaffected by these vibrations.
#### Type of Sensor Errors
- **Offset Error:** An ongoing inaccuracy injected into the sensor data.
- **Gain Error:** Errors proportionate to the input signal are called gain errors.
- **Drift Error:** Errors that gradually change over time.
- **Random Error:** Typically indicative of noise in the sensor circuit, random errors lack a clear pattern.
- **Quantization Error:** This kind of error is caused by the sensor's restricted resolution.
#### Fault Diagnostics
Modern car systems have built-in self-diagnostic features to keep an eye on the condition and performance of their sensors.
- **5V Output Sensors:** Sensors with a 5-volt output voltage range frequently use the lower voltage band (below 0.5V) and upper voltage band (above 4.5V) to indicate a fault.
- **Digital Temperature Sensors:** High safety-rated temperature sensors frequently display a false, implausible temperature value, such as -200°C, to signify that a chip internal problem has occurred.
#### Detection of Faults
- **Redundancy:** Making use of several sensors to make a single measurement.
- **Self-Test Mechanisms:** Modern sensors are equipped with self-test functions.
- **Plausibility Checks:** Comparing sensor outputs to established physical models to make sure they are consistent.
---
## 3. Types and Functions of Actuators in Automotive Systems
### Classification of Automotive Actuators
In automotive systems, actuators operate as a conduit between the physical actions occurring inside a car and the control systems. They convert incoming energy into motion in order to carry out commands.
#### Classification Based on Control Action
**Linear Actuators**
- **Description:** These actuators produce linear motion, usually in the form of push or pull actions.
- **Application:** An example of an application is the operation of the brake master cylinder, in which the hydraulic fluid is pushed through the system by the actuator to engage the brake pads.
**Rotary Actuators**
- **Description:** These produce rotational motion, which is usually expressed in terms of angles or whole revolutions.
- **Application:** An example of an application is the fuel injection system's throttle plate adjustment, where the actuator spins the plate to regulate airflow. The liquid-cooled systems pressure pump serves as an additional illustration.
#### Classification Based on Technology
**Electric Motors**
- **Description:** Produce motion by means of electrical energy. Their working principle is based on electromagnetic principles, in which motion is produced by a magnetic field created by current flowing through a coil.
- **Application:** One example of such application is electric power steering systems, which, in response to driver input, use motors to help in steering.
**Solenoids**
- **Description:** These are electromagnetic devices that, when powered on, create a regulated magnetic field. Subsequently, a plunger or rod experiences linear motion due to the magnetic field.
- **Application:** An example of an application is transmission shift control, in which a solenoid engages or disengages gears in response to commands from the driver or computer.
**Piezoelectric Actuators**
- **Description:** Use the piezoelectric effect. When mechanical stress is applied, some materials generate an electric charge. In contrast, these materials undergo a shape-changing process that results in mechanical motion when voltage is given to them.
- **Application:** Fuel injector systems in some sophisticated engines. Because of their high-frequency response, piezoelectric actuators can provide injections that are extremely rapid and precise.
### Applications with Actuators in Automotive Systems
#### Throttle Control
- **Role of Actuators:** The throttle actuator controls how much air enters the engine. In the past, this operation was mainly mechanical. On the other hand, "drive-by-wire" or electronic throttle control (ETC) systems are used in modern systems.
- **How It Works:** Rather than physically pulling a cable, depressing the gas pedal in an ETC system delivers an electrical signal. This signal is interpreted by an actuator at the throttle body, which then modifies the throttle plate to control engine airflow.
#### Transmission Shift Control
- **Role of Actuators:** In both automated and manual transmission systems, transmission actuators help with gear shifting.
- **How It Works:** Solenoid actuators in contemporary automatic transmissions decode electrical signals from the transmission control module. By regulating the hydraulic fluid flow to various transmission tunnels, these solenoids regulate which gear set is in operation.
#### Active Suspension Systems
- **Role of Actuators:** Active suspensions are cutting-edge devices that instantly adjust to changing road conditions and driving demands to improve handling dynamics and ride comfort.
- **How It Works:** The system uses a mix of actuators and sensors to identify cornering forces, vehicle speed, and road defects. Actuators quickly change the ride height or damper stiffness. They are typically electromagnetic or electro-hydraulic.
### Key Specifications and Performance Criteria
#### Force and Torque Capabilities
- **Definition:** Two essential indicators of an actuator's performance are force and torque. Torque, which is typically linked with rotary actuators, represents rotational force, whereas force is a push or pull action that is linear in nature.
- **Measurement:** Generally, torque is expressed in Newton-meters (Nm) or foot-pounds (ft-lb), while force is expressed in Newton's (N) or pounds-force (lbf).
#### Speed and Response Time
- **Definition:** Response time is the amount of time an actuator takes to begin moving after receiving a command, whereas speed is the fastest an actuator may move to reach its desired location.
- **Measurement:** For linear motions, speed can be stated in mm/sec, while for rotating actuators, it can be given in RPM. Milliseconds (ms) are commonly used to indicate response time.
#### Reliability and Durability
- **Definition:** Durability is the number of operational cycles an actuator can withstand before wearing out or malfunctioning, whereas reliability is the capacity to perform consistently over time without failure.
- **Measurement:** While durability may be described in terms of operating cycles or hours of operation under specific conditions, reliability is frequently measured using metrics like Mean Time Between Failures (MTBF).
---
## 4. Power Management for Sensors and Actuators
### Power Requirements for Sensors and Actuators
#### Operating Voltage and Current Ranges
- **Definition:** Specific voltage and current ranges are intended for the operation of each sensor and actuator.
- **Importance:** Staying within these parameters guarantees that the sensor or actuator operates as intended without running the risk of damage or malfunction.
- **Measurement:** Common operating voltages for automotive applications may be between 5V and 24V.
#### Power Consumption and Efficiency
- **Definition:** Power consumption measures the total amount of energy that a sensor or actuator uses over time. Efficiency quantifies how well a device transforms the power it consumes into useful output.
- **Importance:** Energy is a limited resource in automobiles, particularly in electric or hybrid versions.
- **Factors Affecting Consumption and Efficiency:** The device's design, the materials utilized, the working environment, and operation frequency.
- **Measurement:** For smaller devices, power consumption is commonly expressed in milliwatts (mW) or watts (W). Efficiency is the ratio of usable power output to total power input, stated as a percentage.
### Power Optimization Strategies
#### Power-Saving Modes for Sensors
- **Sleep Mode:** In sleep mode, the sensor uses very little power and is largely inactive. It can become "awakened" when its purpose is required.
- **Idle Mode:** The sensor keeps working but at a reduced capacity, ready to go back to full operation when needed.
- **Interrupt-Driven Mode:** Until an external trigger or interrupt activates the sensor, it stays in low-power mode.
#### Always-Awake Sensors in Vehicles
- **Theft-Detection Sensors:** They keep a close eye out for any indications of tampering or illegal access.
- **Key Fob Detection Sensors:** These sensors are always on the lookout for signals from the key fob in cars with keyless entry systems.
#### Energy Efficient Actuation Techniques
- **Adaptive Control:** The actuator modifies its actions in response to immediate feedback.
- *Variable Displacement Pumps:* Modify the fluid flow rate in accordance with the system's present requirements.
- *Dynamic Brake Energy Recovery:* The energy generated during braking is recovered and transformed back into useful electrical energy.
- *Electric Motors with Load Sensing:* The motor can adjust its power output according to the required torque.
- **Pulse-Width Modulation (PWM):** Enables more precise control over the amount of energy utilized by altering the width of the electrical pulse delivered to the actuator.
- **Optimized Drive Circuits:** Energy efficiency can be achieved in the design of the electronic circuits that drive actuators.
- **Variable Load Sensing:** Certain sophisticated actuators have the ability to detect the load they are experiencing and modify their energy usage accordingly.
---
## 5. Integration and Interfacing of Sensors and Actuators
### Sensor and Actuator Interfaces
#### Analog vs. Digital Interfaces
**Analog Interfaces**
- **Nature:** Use a continuous signal that fluctuates in frequency or amplitude to transmit data.
- **Pros:** They offer a clear representation of a measured or controlled quantity and can be easy to use and reasonably priced.
- **Cons:** Limited range and susceptibility to noise interference. The connecting ECU must supply a distinct sensor ground specifically for that sensor.
- **Usage:** Commonly seen in simple sensors like pressure or temperature sensors.
**Digital Interfaces**
- **Nature:** Discrete signals, mostly binary (0s and 1s), are used to transmit data.
- **Pros:** They provide accurate and strong noise immunity.
- **Cons:** Their cost is usually higher than that of analog sensors. They require additional computational power from the DSPs and microcontroller interface.
- **Usage:** Common in contemporary automobile systems where accurate control and data collection are essential.
#### Communication Protocols for Sensors
**Inter-Integrated Circuit (I²C)**
- A packet-switched, single-ended, multi-master, multi-slave serial communication protocol. Frequently used to connect slower peripheral integrated circuits (ICs) to microcontrollers and processors.
- **Example:** Ambient light sensors in cars.
**Single-Edge Nibble Transmission (SENT)**
- A point-to-point protocol that allows sensor readings to be sent from a controller to a sensor. Designed with low power consumption and the fewest possible sensor connection pins.
- **Example:** Throttle position sensors.
**One-Wire**
- This protocol just needs one wire to communicate. Intended for low-speed data transmission.
- **Example:** Tire pressure monitoring sensors.
**Serial Peripheral Interface (SPI)**
- A synchronous serial communication protocol that selects the target device using a select line in addition to distinct clock and data lines.
- **Example:** High-speed gyroscopic sensors used in advanced stability control systems.
**Controller Area Network (CAN)**
- A common protocol for higher-level vehicle communications. Reliable, able to function in noisy settings, and appropriate for real-time applications.
- **Example:** Wheel speed sensors for ABS and traction control.
**Local Interconnect Network (LIN)**
- For non-critical sub-networks inside a car, a more affordable option to CAN.
- **Example:** Rain or light-detecting modules.
### Integration Challenges and Solutions
#### Ensuring Compatibility Between Components
**Challenge:** The variety of sensors and actuators that may originate from different manufacturers, different eras of technology, or different design paradigms.
**Solutions:**
- **Standardization:** Using standardized interfaces, voltages, and communication protocols (SAE, ISO standards).
- **ISO:** ISO 14229, ISO 15765 (vehicular communication), ISO 26262 (functional safety).
- **SAE:** SAE J1979 (OBD systems), SAE J1939 (heavy-duty communication).
- **Interfacing Modules:** Use interface modules or gateways that can translate between different protocols.
- **Unified Development Platforms:** Develop and test on the same platform or environment.
- **Comprehensive Documentation:** Keep detailed documentation for every component.
### Procedures for Sensors and Actuators
#### Sensor Calibration
- **Procedure:** Recording the sensor's reaction after subjecting it to a variety of known situations. The output is modified to match the anticipated values.
- **Example:** A temperature sensor may be subjected to a range of exact temperatures while modifications are made to guarantee that its output corresponds to the input values that are known.
#### Actuator Calibration
- **Procedure:** Change the control signal that is supplied to the actuator, measure its reaction, and make adjustments as needed to get the desired result.
- **Example:** To make sure a solenoid delivers the appropriate force or displacement for each level, it may be driven at different current levels. The correlation between current and displacement can be used as an integrated look-up table in the DSP or Microcontroller of the ECU.
---
*Сохранено с MPScholar (Monolithic Power Systems) — Automotive Electronics / Automotive Sensing and Actuators*
@@ -0,0 +1,60 @@
# Мнение по анализу динамического сбоя ELM327
Дата: 2026-06-14
## Общая оценка
Анализ написан правильно. Методология верная: исключение невозможного через уже проведённые эксперименты (паузы 4000 мс, автоподбор таймингов), затем ранжирование оставшихся гипотез. Главный вывод — проблема не в скорости, а в чтении потока — звучит убедительно.
---
## Что поддерживаю
**Гипотезы 1–3 (вероятность: высокая)** — расставлены верно.
Из трёх наиболее вероятных причин **непрочитанный `>` в InputStream** — самая классическая ELM327-ловушка. Если ScriptEngine завершает чтение по таймауту или по числу строк вместо `>`, это объясняет всё: первый запрос проходит, потому что `>` ещё не накапливается, второй ломается из-за хвоста. Это надо проверять первым.
**Buffer drain перед send, а не только после receive** — часто игнорируемое место. Если drain делается только после чтения, но перед отправкой нового запроса остаток `>` или пустая строка ещё лежат в буфере — это незаметно даже в логах, если читать только "полезные" байты.
---
## Что добавил бы
### 1. NO DATA / UNABLE TO CONNECT в динамике
В анализе не рассмотрен сценарий, когда в ходе динамики ELM вернул `NO DATA` или `UNABLE TO CONNECT`. Это вполне реально при смене контекста CAN. Если ScriptEngine на такой ответ зависает в ожидании данных или некорректно парсит следующий ответ — результат идентичен описанному сбою. Стоит явно проверить, как ScriptEngine обрабатывает негативные ответы ELM, и логировать их.
### 2. AT ST (тайм-аут ELM) может различаться между режимами
Если ElmChecker и ScriptEngine отправляют разные значения `AT ST` (или один вообще не устанавливает его), ELM сам будет обрезать ответ или отвечать с разной задержкой. При высокой нагрузке ECU (динамика) тайм-аут ELM по умолчанию (200 мс) может быть недостаточен, и ELM уйдёт в `NO DATA` раньше, чем ECU ответил. Нужно убедиться, что `AT ST FF` (максимальный) или фиксированное значение установлены одинаково в обоих путях.
### 3. Клон ELM327 vs оригинал
Клоны (особенно v1.5 китайские) имеют известный баг: при высокой частоте запросов они перестают выдавать `>` — промпт появляется только после задержки или вообще пропадает. Если адаптер — клон, нужно явно учесть это при трактовке сырых логов: отсутствие `>` может быть аппаратным поведением, а не ошибкой кода.
### 4. Конкурентный доступ — недооценённый риск
Гипотезе 5 (два потока на сокет) поставлена средняя вероятность, но в Android-проектах это случается чаще, чем кажется. Достаточно одного фонового alive-check, который читает тот же InputStream в момент динамического цикла. Стоит выйти не только на проверку thread id, но и на `synchronized`-блоки или single-threaded executor для всех операций с сокетом.
---
## Что менее убедительно
**Гипотеза 6 (порядок команд)** — оценка "средняя-низкая" верна, но её стоит проверять параллельно с гипотезами 1–3, не последовательно: это дёшево (достаточно дампа команд) и может мгновенно закрыть вопрос или исключить этот класс причин.
---
## Порядок расследования (скорректированный)
1. **Сырой RX/TX лог** с явным маркером `>` — сравнить статику и динамику. Первый приоритет.
2. **Проверить обработку негативных ответов** (`NO DATA`, `UNABLE TO CONNECT`) в ScriptEngine.
3. **Сравнить AT-последовательности** ElmChecker и ScriptEngine — весь init, включая `AT ST`.
4. **Убедиться в drain перед send**, а не только после receive.
5. **Thread id на каждый read/write** — исключить второй consumer.
6. **Дамп команд** обоих режимов — закрыть гипотезу 6 параллельно с остальными.
---
## Итог
Анализ хороший. Главное не растягивать расследование на последовательное прохождение всех гипотез: сырой лог с маркером `>` и лог негативных ответов ELM — два дешёвых эксперимента, которые скорее всего сразу покажут, где рвётся синхронизация.
+144
View File
@@ -0,0 +1,144 @@
# План правок по отчётам Опуса
> 31 мая 2026 · ветка `opus-fixes` · порядок: по критичности + зависимостям
---
## Этап 1. Сервер (`elmer/`) — 4 правки
### 1.1 🔴 `api/db.py` — WAL + закрытие соединений + request_id
**Файл**: `api/db.py`
**Строки**: класс `Database`, методы `_init_schema()`, `save_session()`
- [x] Добавить `PRAGMA journal_mode=WAL` и `busy_timeout=30000`
- [x] Добавить колонку `request_id TEXT UNIQUE` в `sessions`
- [x] Метод `close()` и контекстный менеджер (`__enter__`/`__exit__`)
- [x] `save_session()` — проверять `request_id` на дубликат, возвращать кэшированный диагноз
- [x] Индекс `idx_sessions_request_id`
### 1.2 🔴 `api/routes.py` — идемпотентность upload + /ping-llm без LLM
**Файл**: `api/routes.py`
**Строки**: `upload_session()`, `ping_llm()`
- [x] `upload_session()` — принимать `request_id` из JSON, возвращать кэш при дубликате
- [x] `upload_session()` — закрывать `db` через контекстный менеджер
- [x] `upload_session()` — не отдавать `str(e)` наружу, логировать, клиенту — обобщённый текст
- [x] `/ping-llm` — кэшировать результат на 60с, не вызывать LLM на каждый GET
### 1.3 🔴 `obd/protocol.py` — сброс буфера + не затирать ERROR
**Файл**: `obd/protocol.py`
**Строки**: `_write()`, `send()`
- [x] `_write()``self._ser.reset_input_buffer()` перед записью
- [x] `send()``if self._state == State.BUSY: self._state = State.READY` (не безусловно)
### 1.4 🟡 `brain/client.py` — таймаут из конфига + модель
**Файл**: `brain/client.py`
**Строки**: `Diagnoser.__init__()`, `Diagnoser.ask()`
- [x] `DEFAULT_MODEL``"gpt-oss-120b"`
- [x] `timeout` — параметр конструктора (по умолчанию 180)
- [x] Комментарии: убрать «DeepSeek»
---
## Этап 2. Сервер (`elmer/`) — улучшения (без 🔴 но важные)
### 2.1 🟡 `api/routes.py` — импорты наверх + кэш конфига
**Файл**: `api/routes.py`, `api/config.py`
- [x] Поднять импорты (`from brain.client import Diagnoser` и др.) на уровень модуля
- [x] `api/config.py``@lru_cache(maxsize=1)` на `load()`
### 2.2 🟡 `api/routes.py` — история /chat через роли
**Файл**: `api/routes.py`
**Строки**: `chat()`
- [x] Передавать историю как массив `messages` с ролями, а не строкой «Водитель:/Автоэксперт:»
### 2.3 🟡 `brain/client.py` — обработка ошибок LLM
**Файл**: `brain/client.py`
- [x] Различать `Timeout`, `HTTPError(429)`, `HTTPError(5xx)`, `HTTPError(4xx)`
- [x] Не отдавать детали исключения наружу
---
## Этап 3. Android (`elmer-android/`) — 6 правок
### 3.1 🔴 `ServerClient.kt` — request_id + идемпотентность
**Файл**: `app/src/main/java/ru/elmer/client/server/ServerClient.kt`
**Строки**: `uploadSession()`
- [ ] Генерировать `UUID` один раз до цикла ретраев
- [ ] Добавить `"request_id"` в JSON-тело
- [ ] Добавить заголовок `Idempotency-Key`
### 3.2 🔴 `ServerClient.kt` + `build.gradle.kts` — X-Api-Key
**Файлы**: `ServerClient.kt`, `app/build.gradle.kts`
- [ ] `build.gradle.kts``buildConfigField("String", "API_KEY", ...)`
- [ ] `ServerClient` — добавлять `X-Api-Key` во все запросы
- [ ] `MainActivity.sendToLlm()` и `startTest()` — тоже `X-Api-Key`
### 3.3 🔴 `ElmProtocol.kt` — не затирать ERROR + дренаж буфера
**Файл**: `app/src/main/java/ru/elmer/client/elm/ElmProtocol.kt`
**Строки**: `sendCommand()`, `write()`
- [ ] `sendCommand()``if (state == State.BUSY) state = State.READY`
- [ ] `write()``while (input.available() > 0) input.read()` перед записью
### 3.4 🔴 `SessionDb.kt` — безопасная миграция + индекс
**Файл**: `app/src/main/java/ru/elmer/client/db/SessionDb.kt`
**Строки**: `onUpgrade()`, `onCreate()`
- [ ] `onUpgrade()``ALTER TABLE` вместо `DROP TABLE`
- [ ] Индекс `idx_resp_session ON responses(session_id)`
### 3.5 🔴 `MainActivity.kt` — /ping-llm без LLM + двойной receiver + chatHistory
**Файл**: `app/src/main/java/ru/elmer/client/ui/MainActivity.kt`
**Строки**: `startTest()`, `sendToLlm()`, receiver-регистрация
- [ ] `startTest()` — троттлить `/ping-llm` (не чаще раза в 60с), предупреждать
- [ ] Убрать дублирующий receiver `statusReceiver` (оставить `scriptStatusReceiver`)
- [ ] `chatHistory` сохранять в `onSaveInstanceState` (JSON)
### 3.6 🔴 `ScriptRunnerService.kt` — null intent + мёртвый paused + try/finally
**Файл**: `app/src/main/java/ru/elmer/client/script/ScriptRunnerService.kt`
**Строки**: `onStartCommand()`, `executeScript()`
- [ ] `onStartCommand()``if (intent == null) { stopSelf(); return START_NOT_STICKY }`
- [ ] Убрать мёртвый флаг `paused` и `ACTION_RESUME` (или доделать паузу)
- [ ] `executeScript()``try/finally` вокруг `progress.stop()`
---
## Этап 4. Android (`elmer-android/`) — улучшения
### 4.1 🟡 `ServerClient.kt` — exponential backoff
**Файл**: `ServerClient.kt`
- [ ] `(1 shl (attempt-1)) * 1000 + Random.nextLong(0, 500)` вместо фиксированных 2000
### 4.2 🟡 `MainActivity.kt` — единый HTTP-клиент
**Файл**: `MainActivity.kt`
- [ ] `sendToLlm()` и `startTest()` перевести на OkHttp (через `ServerClient`)
### 4.3 🟡 `ScriptRunnerService.kt` — вынести хост в константу
**Файл**: `ScriptRunnerService.kt`, `MainActivity.kt`
- [ ] `obdai.ru``BuildConfig.SERVER_HOST` или константа
---
## Порядок выполнения
```
Этап 1 (сервер 🔴) → коммит
Этап 2 (сервер 🟡) → коммит
Этап 3 (Android 🔴) → коммит
Этап 4 (Android 🟡) → коммит
```
После каждого этапа — проверка: `python run.py` (сервер), сборка APK (Android).
+146
View File
@@ -0,0 +1,146 @@
# Вопросы к Opus 4.8 по проекту elmAI
> v0.35.0-dev, 31 мая 2026
> Сервер: Ubuntu 24, Python/Flask, gunicorn + nginx
> Android: Kotlin, minSdk 24, OkHttp
> LLM: api.aillm.ru, модель gpt-oss-120b
---
## Какие файлы смотреть (и только их)
### Сервер (elmer/)
- `obd/protocol.py` — ELM327 стейт-машина AndrOBD (State, Rsp, AdaptiveTiming)
- `brain/client.py` — Diagnoser (HTTP к LLM API)
- `brain/prompts.py` — SYSTEM_PROMPT для диагностики
- `api/routes.py` — все 5 эндпоинтов (script, upload, chat, ping, ping-llm)
- `api/db.py` — SQLite: таблица sessions (30+ полей)
- `api/scripts.py` — сборка диагностических скриптов
- `api/parser.py` — парсинг ответов ELM327
- `web/app.py` — точка входа Flask
- `doc/architecture.md` — описание архитектуры
### Android (elmer-android/)
- `script/ScriptRunnerService.kt` — сервис фоновой диагностики
- `script/ScriptEngine.kt` — движок выполнения скриптов
- `script/UploadProgress.kt` — таймер прогресса загрузки
- `server/ServerClient.kt` — HTTP-клиент (OkHttp, retry 3x)
- `elm/ElmProtocol.kt` — ELM327 стейт-машина (Kotlin)
- `elm/ObdDecoder.kt` — декодер PID/DTC/VIN
- `ui/MainActivity.kt` — главный экран
- `db/SessionDb.kt` — локальная SQLite
---
## Вопрос 1. Стейт-машина ELM327: баги и крайние случаи
**Файлы**: `obd/protocol.py`, `elm/ElmProtocol.kt`
Стейт-машина — 1:1 копия AndrOBD (ElmProt.java). Ключевые моменты:
- Байт-за-байтом чтение с 1мс поллингом
- `>` как разделитель ответов
- Адаптивный таймаут (200мс ± 4мс)
- Восстановление после BUS ERROR (ATPC → ATSP0)
Вопросы:
1. Есть ли race conditions или deadlocks в переходах состояний?
2. Что если `>` приходит НЕ после полного ответа (мусор в буфере)?
3. Корректна ли логика восстановления после BUS ERROR? Не теряем ли мы ответы при ATPC→ATSP0?
4. Достаточен ли 1мс поллинг или на некоторых ELM нужен меньше?
5. Есть ли риск бесконечного цикла в `_exec()` (10 ретраев)?
---
## Вопрос 2. HTTP 499 при upload с мобильной сети
**Файлы**: `script/ScriptRunnerService.kt`, `server/ServerClient.kt`, `api/routes.py`
**Симптом**: сервер получает POST, но клиент обрывает соединение (nginx: 499).
- Connect timeout: 30с, read: 180с, write: 60с
- 3 ретрая с задержкой 2с
- nginx: client_body_timeout 120s, proxy_read_timeout 300s
- gunicorn: timeout 180s
Вопросы:
1. Какие ещё причины HTTP 499 на мобильной сети кроме таймаутов?
2. Достаточна ли стратегия ретраев? Может, нужен exponential backoff?
3. Может ли проблема быть в отправке тела запроса (write timeout) на медленной сети?
4. Стоит ли разбивать upload на чанки или сжать JSON?
5. Корректно ли мы обрабатываем случай, когда сервер получил запрос но клиент упал — данные могут дублироваться?
---
## Вопрос 3. Архитектура: три модуля + Android пакеты
**Файлы**: `doc/architecture.md`, `web/app.py`, `api/routes.py`
Сервер разбит на `obd/`, `brain/`, `api/`. Android — на `elm/`, `server/`, `script/`, `db/`, `ui/`.
Вопросы:
1. Чистые ли границы между модулями? Нет ли неявных зависимостей?
2. `api/routes.py` делает `from brain.client import Diagnoser` внутри функций — это нормально или лучше на уровне модуля?
3. Стоит ли вынести `config.yaml` из `api/` на уровень выше?
4. `web/app.py` зависит от `api/routes.py` — это правильное направление?
5. Какие модули можно было бы легко заменить (например, `brain/` на локальный LLM)?
---
## Вопрос 4. SQL схема: таблица sessions
**Файлы**: `api/db.py`
Таблица `sessions` — 30+ колонок (IP, телефон, ELM, авто, сессия, LLM). VIN — nullable.
Также старые таблицы: `cars`, `diagnostic_tokens`, `llm_messages`, `ecu_parameters`, `dtc_codes`.
Вопросы:
1. 30+ колонок в одной таблице — это нормально для SQLite или лучше разбить?
2. raw_responses хранится как JSON TEXT — ок ли для SQLite?
3. Индексы: по `created_at`, `vin`, `elm_mac`, `android_id` — достаточны?
4. Старые таблицы (cars, dtc_codes) всё ещё создаются в `_init_schema()` но не используются. Удалять или оставить для совместимости?
5. Нет ли проблем с конкурентным доступом к SQLite из gunicorn (4 воркера)?
---
## Вопрос 5. LLM-интеграция: промпты и таймауты
**Файлы**: `brain/client.py`, `brain/prompts.py`, `api/routes.py`
- Diagnoser использует `requests.post` без streaming
- SYSTEM_PROMPT — 10 правил ответа
- Для /chat — лимит 20 строк, история диалога (последние 10 сообщений)
Вопросы:
1. Достаточен ли промпт для качественной диагностики? Чего не хватает?
2. `requests.post` без streaming при таймауте 180с — ок или лучше streaming + heartbeat?
3. Для /chat: правильно ли форматируется история диалога? Не переполнит ли контекст?
4. Модель gpt-oss-120b — адекватный выбор? Какие альтернативы для авто-диагностики?
5. Как правильно обрабатывать ошибки LLM API (rate limit, timeout, bad response)?
---
## Вопрос 6. Безопасность API
**Файлы**: `api/routes.py`, `web/app.py`
- API без аутентификации, только HTTPS через nginx
- API ключ LLM на сервере, не в APK
- `usesCleartextTraffic` убран из манифеста
Вопросы:
1. Достаточен ли HTTPS без API-ключей для MVP? Какие риски?
2. Какие минимальные меры добавить: rate limiting, API key в APK, CORS?
3. `raw_responses` пишутся в БД — есть ли риск инъекции через ответы ELM327?
4. `/api/v1/chat` без аутентификации — можно ли его абузить (спамить токенами)?
5. Нужно ли скрывать API-ключ LLM за прокси или текущая схема ок?
---
## Формат ответа
Пожалуйста, запиши ответ в файл `/home/naeel/elmer/doc/opus-review.md`.
По каждому вопросу:
- 🔴 Критическая проблема (если есть)
- 🟡 Потенциальная проблема / улучшение
- 🟢 Всё ок
- Конкретные рекомендации с примерами кода где уместно
+280
View File
@@ -0,0 +1,280 @@
# Ответ Opus 4.8 — ревью elmer-android
> v0.35.0-dev, 31 мая 2026
> Проверены файлы: ElmProtocol.kt, ObdDecoder.kt, ScriptRunnerService.kt, ScriptEngine.kt,
> ServerClient.kt, SessionDb.kt, MainActivity.kt, UploadProgress.kt, AndroidManifest.xml
---
## Вопрос 1. Стейт-машина ElmProtocol.kt
### 1.1 `startsWith("ERROR") && !startsWith("DATA ERROR")`
🟡 **Потенциальная проблема.** `handle()` работает с уже распарсенными строками-ответами, а не с PID-именами, поэтому коллизии с «ERROR_xxx» в данных нет — декодирование имён происходит позже в `ObdDecoder`. НО: реальные ELM-ошибки не всегда начинаются с `ERROR`. Например `?` (неизвестная команда), `UNABLE TO CONNECT` (ловится в `isBusError`), `<RX ERROR` (с префиксом `<`). Строка `<DATA ERROR` из-за лидирующего `<` **не** сматчится `startsWith("DATA ERROR")`. ELM327 при ошибке кадра иногда шлёт `<` перед сообщением.
Рекомендация — нормализовать перед классификацией:
```kotlin
val u = raw.uppercase().trim().trimStart('<', '>').trim()
```
### 1.2 `sendCommand()` безусловно ставит READY после exec()
🔴 **Критично — маскирование ошибки.**
```kotlin
fun sendCommand(cmd: String): String {
if (state == State.ERROR) recover()
state = State.BUSY
val result = exec(cmd, timeoutMs) // exec может выставить State.ERROR/DISCONNECTED
state = State.READY // ← затирает ошибку
return result
}
```
`exec()` при исчерпании ретраев ставит `state = State.ERROR`, а `handle()``ERROR`/`DISCONNECTED`. Следующая строка безусловно перетирает это на `READY`. Ошибка «теряется» до следующего вызова. В AndrOBD состояние не сбрасывается слепо.
Рекомендация:
```kotlin
val result = exec(cmd, timeoutMs)
if (state == State.BUSY) state = State.READY // только если не было ошибки
return result
```
### 1.3 `init()` не проверяет результат AT-команд
🟡 **Поведение AndrOBD, но рискованное.** AndrOBD действительно прогоняет init-цепочку «оптимистично», полагаясь на то, что первые реальные OBD-команды отловят BUS ERROR. Для MVP допустимо, но `ATSP0` (выбор протокола) стоит проверять — если адаптер вернул `?`, дальнейшие команды бессмысленны. Минимум — логировать ответ и считать в `errorCount`.
### 1.4 Нет сброса input-буфера перед write()
🟡 **Риск десинхронизации есть.** В `read()` чтение идёт до `>` (prompt), но если предыдущая команда оставила хвост в буфере (например после таймаута пришёл запоздалый ответ), он прилипнет к следующему чтению. Рекомендация — дренировать буфер перед записью:
```kotlin
private fun write(cmd: String) {
while (input.available() > 0) input.read() // drain stale bytes
output.write((cmd + "\r").toByteArray())
output.flush()
}
```
### 1.5 `BUFFER FULL` → warm start
🟡 **Спорно.** В AndrOBD `BUFFER FULL` — это переполнение буфера ELM при большом ответе, лечится **повторным запросом**, а не полным `ATWS` (warm start сбрасывает протокол и теряет адаптацию таймингов). Здесь `BUFFER FULL` попадает в `isDataError``ATWS`, что излишне тяжело. Лучше выделить:
```kotlin
u.startsWith("BUFFER FULL") -> { increaseTimeout() /* retry */ }
```
---
## Вопрос 2. ScriptRunnerService — жизненный цикл
### 2.1 `START_STICKY` + null intent
🔴 **Падение при пересоздании.** При рестарте системой `onStartCommand` получает `intent == null`. Сейчас `when (intent?.action)` отрабатывает в `else`-ветку (ничего не делает) и возвращает `START_STICKY` — краша нет, но сервис висит в foreground без работы и без уведомления о реальной задаче. Лучше:
```kotlin
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent == null) { stopSelf(); return START_NOT_STICKY }
...
}
```
Для разовой диагностики вообще логичнее `START_NOT_STICKY` — нет смысла воскрешать прерванную сессию.
### 2.2 Демон-поток `ScriptRunner`
🟡 Демон-поток живёт пока жив процесс. Если Activity убита, а сервис foreground — процесс жив, поток работает. Но при нехватке памяти система может убить весь процесс (вместе с потоком) несмотря на foreground. Это нормально для разовой задачи. Замечание: исключения внутри потока никуда не пробрасываются — добавьте `try/catch` обёртку с `errorDone()`.
### 2.3 / 2.4 `btSocket` и `onDestroy()`
🟢 **Уже закрывается.** `onDestroy()` вызывает `disconnect()`, который закрывает `btSocket` и снимает foreground. Утечки сокета нет. ✅
### 2.5 Флаг `paused`
🔴 **Мёртвый код / недоделанная фича.** `paused` выставляется в `false` по `ACTION_RESUME`, но **нигде не проверяется** — ни в `ScriptEngine.run()`, ни в `executeScript()`. Механизм паузы «водитель ответил» (broadcast `BROADCAST_PROMPT`, `scriptPromptReceiver` в UI) фактически не реализован на стороне движка. Либо удалить флаг и UI-приёмник промптов, либо доделать: `ScriptEngine` должен уметь блокироваться на шаге до сброса `paused`.
---
## Вопрос 3. ServerClient — ретраи и идемпотентность
### 3.1 Повторное использование тела запроса на ретраях
🟢 **Работает корректно.** Тело создано через `String.toRequestBody(...)` — это `RequestBody` поверх неизменяемой строки. В OkHttp 4.x такой `RequestBody` **stateless**: `writeTo()` вызывается заново на каждой попытке и пишет ту же строку. Пустого тела на 2-3 ретрае **не будет**. (Проблема была бы только с одноразовым стримом, например `InputStream.source()`.)
### 3.2 Exponential backoff
🟡 Для мобильной сети фиксированные 2с приемлемы, но джиттер + рост лучше против «retry storm»:
```kotlin
if (attempt < 3) Thread.sleep(1000L * (1 shl (attempt - 1)) + Random.nextLong(0, 500))
```
### 3.3 `downloadScript()` без ретраев
🟢 Это сознательный и правильный выбор: есть качественный `DEFAULT_SCRIPT` fallback, поэтому мгновенный переход к нему при оффлайне — корректное поведение. 1 ретрай можно добавить, но не критично.
### 3.4 gzip на upload
🟡 При `count * 200` байт типичный батч < 5 KB — выигрыш от gzip минимален, а overhead на сжатие/совместимость с nginx добавляет риск. Не нужно для MVP.
### 3.5 Порядок `.string()` / `.close()`
🟢 **Корректно.** `val body = resp.body?.string()` сначала читает (и закрывает поток тела), затем `resp.close()`. Порядок верный, двойного закрытия нет. ✅
### 3.6 Идемпотентность / `request_id`
🔴 **Критично (подтверждаю отчёт Q2).** При 499/таймауте и ретрае сервер создаёт дубликат сессии и повторно тратит LLM-токен. Клиент должен генерировать UUID **один раз до цикла ретраев** и слать его в теле:
```kotlin
fun uploadSession(...): JSONObject? {
val requestId = java.util.UUID.randomUUID().toString() // один на все 3 попытки
val json = JSONObject().apply {
put("request_id", requestId)
put("session_id", sessionId)
...
}
val req = Request.Builder()
.url("$serverUrl/api/v1/session/upload")
.header("Idempotency-Key", requestId)
.post(json.toString().toRequestBody("application/json".toMediaType()))
.build()
...
}
```
На сервере (`save_session()`): UNIQUE-индекс по `request_id`, при повторе — вернуть **сохранённый** результат (включая готовый диагноз), не вызывая LLM повторно:
```python
existing = db.execute("SELECT diagnosis FROM sessions WHERE request_id=?", [rid]).fetchone()
if existing:
return jsonify(diagnosis=existing["diagnosis"], llm_success=True, cached=True)
```
---
## Вопрос 4. ObdDecoder — корректность декодирования
### 4.1 VIN с пробелами
🟢 **Корректно.** `replace(" ", "")` снимает пробелы до проверки `"490201" in clean`, плюс убраны `:` (ISO-TP индикаторы кадров `0:`, `1:`...). Работает и для multi-frame. ✅
### 4.2 `decodeDtc()` начинает с `i = 2`
🟡 **Не всегда верно.** `hex = clean.substring(2)` снимает байт режима (`43`), затем `i = 2` снимает **байт count** (число DTC). Это корректно для классического формата `43 NN <dtc>...`. Но:
- Multi-frame CAN ISO-TP: ответ может содержать байты длины PCI (`007`, `10 0E`...), которые здесь **не вычищены** (убраны только пробелы и `:`). Тогда `i=2` указывает не на тот байт.
- Некоторые адаптеры на mode 03 не шлют байт count вовсе.
Для надёжности стоит парсить DTC по парам байт от конца режима и отбрасывать `0000`, что код уже делает (фильтр `P0000`). Главный риск — невычищенные PCI-заголовки multi-frame. Для коротких ответов (1-2 DTC, single frame) работает.
### 4.3 PID `0100` (4 байта supported)
🟡 `decodePid()` читает только `b0, b1`. PID `00/20/40...` (битовые маски supported PIDs, 4 байта) не входят в `pidValue()` → вернётся `"PID 00: raw"`. Поскольку скрипт их не запрашивает — не баг сейчас, но при расширении скрипта декодер их не покажет.
### 4.4 Только 10 PID
🟢 **Ок для MVP.** Скрипт `DEFAULT_SCRIPT` запрашивает ровно эти PID. Для неподдерживаемых — `"PID $pid: raw"`, сырьё всё равно уходит на сервер и в LLM. Расширять по мере надобности.
### 4.5 STFT/LTFT формула
🟢 Формула `(A - 128) * 100 / 128` верна по SAE J1979. Для PID 06/07 это однобайтовые значения (банк 1), `b1` игнорируется правильно. ✅ (Замечание: PID 06/07 — это банк 1 short/long; банки 2 — это 08/09, в скрипте их нет.)
---
## Вопрос 5. SessionDb — схема и доступ
### 5.1 `onUpgrade()` DROP TABLE
🔴 **Потеря данных при апдейте.** Любое повышение `DB_VERSION` сотрёт всю историю пользователя. Для продакшена недопустимо. Минимальная безопасная миграция:
```kotlin
override fun onUpgrade(db: SQLiteDatabase, oldV: Int, newV: Int) {
if (oldV < 2) db.execSQL("ALTER TABLE sessions ADD COLUMN server_url TEXT")
// будущие версии — ALTER, не DROP
}
```
### 5.2 Без явного закрытия соединений
🟢 **Ок.** `SQLiteOpenHelper` кэширует одно соединение на хелпер; курсоры закрываются (`cursor.close()`). Не закрывать сам `db` — правильно. ⚠️ Замечание: `SessionDb` создаётся и в сервисе, и в `MainActivity.showHistory()` — два хелпера на одну БД. Лучше один экземпляр (синглтон), иначе при одновременном write возможен `SQLiteDatabaseLockedException`.
### 5.3 `getPendingSessions()` — мёртвый код
🟡 Метод нигде не вызывается. Это задел под «дослать неотправленные сессии при следующем запуске», но фича не реализована. Либо удалить, либо доделать ретрай-аплоад оффлайн-сессий в `onCreate` сервиса.
### 5.4 `created_at` INTEGER vs сервер TEXT
🟡 Несогласованность форматов. На клиенте unix-секунды, на сервере ISO 8601. При синхронизации сервер должен конвертировать. Лучше слать с клиента ISO-8601 (или явно `unix_ts` с понятным именем) в `client_info`/`responses`, чтобы не было путаницы с часовыми поясами. Сейчас `timestamp` ответов уходит как строка unix-секунд — сервер должен это знать.
### 5.5 Индексы
🟡 `WHERE session_id = ?` в `getResponses()` без индекса — full scan. При сотнях ответов на сессию заметно. Добавьте:
```kotlin
db.execSQL("CREATE INDEX idx_resp_session ON responses(session_id)")
```
---
## Вопрос 6. MainActivity — чат и UI
### 6.1 `chatHistory` теряется при повороте
🔴 `onSaveInstanceState` сохраняет только `status_text`, `chatHistory` живёт в поле Activity → при повороте/пересоздании теряется, и LLM теряет контекст диалога. Варианты: сохранить в Bundle (сериализовать в JSON), либо вынести в `ViewModel` (`SavedStateHandle`). Минимум:
```kotlin
outState.putString("chat", JSONArray(chatHistory.map { ... }).toString())
```
### 6.2 Чат на `HttpURLConnection` вместо OkHttp
🟡 Дублирование HTTP-логики и таймаутов. `ServerClient` уже инкапсулирует OkHttp — `sendToLlm()` и `startTest()` стоит перевести на него (общие таймауты, ретраи, будущий `X-Api-Key`). Сейчас три места шлют HTTP по-разному.
### 6.3 `startTest()` дёргает `/ping-llm` (платный токен)
🔴 **Расход денег на каждом «Тест».** `/ping-llm` делает реальный LLM-запрос. Кнопку «Тест» пользователь может жать многократно. Варианты: на сервере сделать `/ping-llm` дешёвой проверкой доступности (HEAD к API провайдера / кэш на 60с), либо на клиенте троттлить (не чаще раза в N минут) и предупреждать.
### 6.4 Двойная регистрация receiver
🟡 `scriptStatusReceiver`/`scriptStageReceiver` защищены флагом `scriptRegistered` — двойной регистрации этих двух нет. НО: `statusReceiver` (отдельный, для `BROADCAST_STATUS`) регистрируется в `onCreate` **и** `scriptStatusReceiver` тоже слушает `BROADCAST_STATUS` — два приёмника на один экшен → **каждое сообщение `log()` обработается дважды** (дублирование строк в UI). Также `scriptPromptReceiver` регистрируется... — на самом деле **нигде не регистрируется**, только разрегистрируется в `onDestroy`. Промпты не приходят (связано с мёртвым `paused`, Q2.5).
Рекомендация: оставить один приёмник на `BROADCAST_STATUS`.
### 6.5 `btnClose` не чистит `chatHistory` и не стопит сервис
🟡 Кнопка ✕ только прячет UI и пишет «Готов». Если сервис ещё работает — он продолжит и пришлёт новые статусы поверх. Для «закрыть» логично слать `ACTION_STOP` в сервис. `chatHistory` чистить не обязательно (диалог отдельный от диагностики), но сервис стоит остановить.
---
## Вопрос 7. UploadProgress — таймер и батарея
### 7.1 Broadcast каждую секунду до 180с
🟡 Незначительно для батареи (≤180 broadcast на сессию), но это локальный `sendBroadcast` с `setPackage` — дёшево. Не проблема.
### 7.2 Поток висит при исключении
🟡 **Реальный риск.** В `executeScript()` `progress.start()``uploadSession()``progress.stop()`. Если `uploadSession()` бросит непойманное исключение, `stop()` не вызовется и `UploadTimer` останется крутиться (демон, до смерти процесса). Оберните в `try/finally`:
```kotlin
val progress = UploadProgress(...); progress.start()
val resp = try { client.uploadSession(...) } finally { progress.stop() }
```
### 7.3 `Handler.postDelayed` вместо потока
🟢 Можно, но текущий вариант с `AtomicBoolean` + демон-поток корректен и проще. Не критично. Главное — гарантировать `stop()` (см. 7.2).
---
## Вопрос 8. Общая архитектура
### 8.1 MainActivity знает про Service и SessionDb
🟡 Нарушение SRP есть, но для MVP с одним экраном терпимо. При росте — вынести историю в `Repository`, а UI-логику в `ViewModel`.
### 8.2 ElmProtocol замокать для тестов
🟡 `ScriptEngine` отлично тестируется (lambdas) — это сильная сторона. `ElmProtocol` жёстко завязан на `InputStream/OutputStream`, но это **тестируемо**: подайте `ByteArrayInputStream`/`ByteArrayOutputStream` с заскриптованными ответами ELM. Интерфейс выделять не нужно, потоки — уже абстракция. Рекомендую написать unit-тест на `handle()`-классификацию и таймаут-адаптацию.
### 8.3 Нет ViewModel/DI/Navigation
🟢 Для MVP с одной кнопкой — ок. ViewModel стоит ввести первым (решает 6.1, 6.4). DI/Navigation — преждевременно.
### 8.4 minSdk 24 + BluetoothAdapter.getDefaultAdapter
🟡 `getDefaultAdapter()` deprecated с API 31, но работает на 24+. `createRfcommSocketToServiceRecord` + reflection-fallback `createRfcommSocket(1)` — стандартный надёжный приём для китайских ELM327, покрывает большинство устройств. Замечание: на Android 12+ (API 31) для `connect()` нужен рантайм-`BLUETOOTH_CONNECT` — в манифесте он есть, проверьте что он реально запрашивается в рантайме (в показанном коде `MainActivity` запрос пермишенов есть в константах, но самого `requestPermissions` в прочитанном фрагменте не видно — убедитесь, что вызывается).
### 8.5 `usesCleartextTraffic` не объявлен
🟢 По умолчанию `false` на API 28+, все запросы на `https://obdai.ru` — ок. ✅ Замечание: жёстко зашитый хост `obdai.ru` в нескольких местах (Service, MainActivity) — вынесите в `BuildConfig`/константу.
### 8.6 Эндпоинты без аутентификации (X-Api-Key)
🔴 **Критично (подтверждаю отчёт Q6).** `/chat`, `/upload`, `/ping-llm` открыты → любой может тратить ваши LLM-токены.
Статический ключ в APK **извлекаем** (reverse engineering), поэтому он защищает только от случайных/ленивых злоупотреблений, не от целевой атаки. Для MVP это разумный первый рубеж:
```kotlin
// BuildConfig.API_KEY из gradle (не в git, через local.properties / CI secret)
val req = Request.Builder()
.url(...)
.header("X-Api-Key", BuildConfig.API_KEY)
.post(...)
.build()
```
build.gradle.kts:
```kotlin
buildConfigField("String", "API_KEY", "\"${project.findProperty("ELMER_API_KEY") ?: ""}\"")
```
Сервер — отклонять без верного `X-Api-Key` (401) + **rate-limit по IP/ключу** + квота на LLM. Для серьёзной защиты позже: подпись запроса (HMAC от тела + nonce + timestamp), либо Play Integrity API / device attestation. Но для MVP: `X-Api-Key` + rate-limit + серверная квота на LLM — достаточный минимум, при этом главную защиту денег даёт именно **серверный лимит**, а не ключ.
---
## Сводка приоритетов
🔴 **Чинить сейчас:**
1. `request_id`/идемпотентность upload (Q3.6) — дубли сессий и двойной расход LLM.
2. `X-Api-Key` + серверный rate-limit/квота (Q8.6) — открытые платные эндпоинты.
3. `sendCommand()` маскирует ERROR-состояние (Q1.2).
4. `onUpgrade()` DROP TABLE — потеря истории (Q5.1).
5. `/ping-llm` тратит токен на каждом «Тест» (Q6.3).
6. Двойной приёмник `BROADCAST_STATUS` → дублирование строк (Q6.4).
🟡 **Желательно:**
- `paused` — мёртвый код / недоделанная пауза (Q2.5, Q6.4-prompt).
- `try/finally` вокруг `UploadProgress` (Q7.2).
- Дренаж BT-буфера перед write (Q1.4).
- `chatHistory` в onSaveInstanceState/ViewModel (Q6.1).
- Индекс `responses(session_id)` (Q5.5).
- Единый HTTP-клиент (OkHttp) для чата/теста (Q6.2).
- null-intent guard в onStartCommand (Q2.1).
🟢 **Хорошо как есть:** закрытие сокета (2.3), повторное тело OkHttp (3.1), порядок string/close (3.5), VIN-декод (4.1), STFT/LTFT (4.5), cleartext off (8.5), fallback-скрипт без ретраев (3.3).
+236
View File
@@ -0,0 +1,236 @@
# Ревью проекта elmAI (ответы на opus-questions.md)
> Ревьювер: Opus 4.8 · 31 мая 2026 · v0.35.0-dev
> Разбор по коду: `obd/protocol.py`, `brain/client.py`, `brain/prompts.py`, `api/routes.py`, `api/db.py`, `api/parser.py`, `web/app.py`.
> Android-модуль (`elmer-android/`) в workspace отсутствует — по нему выводы на основе описаний в вопросах.
---
## Вопрос 1. Стейт-машина ELM327: баги и крайние случаи
### 🔴 Стартовый буфер не сбрасывается перед командой → десинхронизация
В `_exec()` сразу идёт `_write(cmd)` без очистки входного буфера. Если предыдущая команда отвалилась по таймауту, в ОС-буфере остаются «хвосты» (часть ответа, поздний `>`). Следующий `_read()` прочитает этот мусор как ответ на новую команду и классифицирует его неверно — классическая рассинхронизация ELM327.
```python
def _write(self, cmd: str):
self._ser.reset_input_buffer() # сбросить хвосты предыдущего ответа
self._ser.write((cmd + "\r").encode())
self._ser.flush()
logger.debug(f"AndrOBD → {cmd}")
```
### 🔴 Состояние `DISCONNECTED`/`ERROR` затирается в `send()`
`send()` проверяет только `State.ERROR` перед `_recover()`. Но BUS ERROR в `_handle()` ставит `DISCONNECTED`, а в конце `send()` безусловно пишет `self._state = State.READY`. То есть после фатальной ошибки шины машина всё равно объявляется READY, и накопленный сбой маскируется.
```python
def send(self, cmd: str) -> str:
if self._state in (State.ERROR, State.DISCONNECTED):
self._recover()
self._state = State.BUSY
result = self._exec(cmd, self._timing.ms)
# НЕ ставить READY безусловно — _exec мог уйти в ERROR
if self._state == State.BUSY:
self._state = State.READY
return result
```
### 🟡 `>` посреди мусора (вопрос 1.2)
`_read()` возвращает всё накопленное до первого `>`. Если ELM прислал `SEARCHING...` затем данные затем `>`, всё склеится в одну строку через `\n`, а `Rsp.identify()` смотрит только на начало (`startswith`) — реальные данные после `SEARCHING` будут потеряны/неверно классифицированы. AndrOBD обрабатывает каждую строку отдельно. Рекомендация: классифицировать построчно, а не всю склейку.
### 🟢 Бесконечный цикл в `_exec()` (вопрос 1.5)
Цикл жёстко ограничен `range(10)`, по выходу — `State.ERROR` и `return ""`. Бесконечного цикла нет. Но обратите внимание: при инициализации шаг `t += 1000` за 10 итераций даёт суммарно до ~55с ожидания на одну команду — для `INIT_TMO=10000` это может неприятно затянуть `init()`.
### 🟡 Восстановление после BUS ERROR (вопрос 1.3)
Логика `ATPC → ATSP0` корректна по сути, но ответы на них читаются `_try_read()` и **молча выбрасываются**. Если `ATSP0` не подтвердился (ELM завис), машина об этом не узнает и пойдёт слать команды в неинициализированный протокол. Желательно проверять, что на `ATSP0` пришёл `OK`/`>`, иначе — полный reset (`ATZ`).
### 🟡 Поллинг 1мс (вопрос 1.4)
1мс `time.sleep` в Python реально даёт ~1–15мс из-за гранулярности планировщика — на практике это не вредит (ELM медленнее), но и «честных» 1мс там нет. На быстрых ELM327 v1.5/v2.1 это не узкое место; узкое место — таймаут адаптива, а не поллинг. Менять не нужно.
### Race conditions
В Python-версии всё однопоточное — гонок нет, **пока** один экземпляр `AndrOBD` не шарится между потоками. Если планируется параллельный доступ — добавьте `threading.Lock` вокруг `send()`.
---
## Вопрос 2. HTTP 499 при upload с мобильной сети
### 🔴 Нет идемпотентности → дубликаты при ретрае (вопрос 2.5)
Это главная проблема. Сценарий 499: сервер **уже принял и обработал** запрос (LLM-анализ 30–120с), но клиент отвалился по read timeout и шлёт ретрай. Результат — вторая полная LLM-сессия и **вторая запись в `sessions`**. `upload_session()` не имеет ключа идемпотентности.
Решение — клиент генерирует `request_id` (UUID), сервер кэширует результат:
```python
data = request.get_json(silent=True)
req_id = data.get("request_id")
if req_id:
cached = db.get_session_by_request_id(req_id) # + колонка request_id UNIQUE
if cached:
return jsonify(cached["response_json"]), 200
```
### 🟡 Стратегия ретраев — нужен backoff и идемпотентность
3 ретрая с фиксированной задержкой 2с на мобильной сети мало помогают: если причина — долгий LLM-ответ (>read timeout 180с), то все 3 попытки упрутся в тот же таймаут и каждая запустит новый LLM-прогон. Рекомендация: exponential backoff (2/4/8с + jitter) **и** обязательно идемпотентность (см. выше), иначе ретраи только множат нагрузку.
### 🟡 Корень 499 — рассинхрон таймаутов клиент/сервер (вопрос 2.1)
Клиентский read 180с ≈ gunicorn timeout 180с. При длинном ответе LLM (`Diagnoser.timeout=120`, но сам upload может суммарно дольше) клиент рвёт соединение ровно в момент, когда сервер ещё пишет ответ. Прочие частые причины 499 на мобильной: смена сети Wi-Fi↔LTE (новый IP, старый сокет мёртв), NAT-таймаут оператора (часто 30–60с тишины), Doze/засыпание приложения. Рекомендация: клиентский read timeout должен быть **строго больше** серверного (например, 240с против gunicorn 180с), а сервер — отвечать быстрее (streaming, см. ниже).
### 🟡 Write timeout на медленной сети (вопрос 2.3)
Да, при толстом батче (`raw_responses` целиком) и слабом upload на LTE write timeout 60с реально достижим. Тело JSON со всеми сырыми ответами может быть десятки–сотни КБ.
### 🟡 Чанки/сжатие (вопрос 2.4)
Чанкинг избыточен для типичного объёма, а вот **gzip тела** даст быстрый выигрыш (JSON сжимается в 5–10 раз) и снимет риск write timeout:
```kotlin
// OkHttp: добавить gzip-обёртку RequestBody + заголовок
.header("Content-Encoding", "gzip")
```
Сервер: nginx сам разожмёт при наличии `gunzip`/decompression, либо Flask с `request.get_data()` + `gzip.decompress`. Это дешевле, чем переписывать на чанки.
### Главная архитектурная рекомендация
Разделите «приём данных» и «LLM-анализ». Эндпоинт должен **быстро** (1–2с) принять батч, сохранить, вернуть `session_id`, а диагноз отдавать отдельным polling-эндпоинтом (`GET /api/v1/session/<id>/result`) или через streaming. Тогда 499 из-за долгого LLM исчезнет как класс.
---
## Вопрос 3. Архитектура: три модуля + Android пакеты
### 🟢 Границы модулей в целом чистые
`obd/` ничего не знает про `brain/` и `api/`; `brain/` — изолированный LLM-клиент; `api/` оркестрирует. Направление зависимостей `web → api → brain/obd` корректное (вопрос 3.4 — да, правильное).
### 🟡 Импорты внутри функций (вопрос 3.2)
В `routes.py` все `from brain.client import Diagnoser`, `from api.db import Database`, `from api.config import load` сделаны внутри обработчиков. Это не «нормально», а компромисс — обычно так лечат циклические импорты или ускоряют старт. Минусы: `load()` читает конфиг с диска **на каждый запрос**, импорт-резолвинг повторяется. Рекомендация: поднять импорты на уровень модуля, а конфиг закэшировать:
```python
# api/config.py
from functools import lru_cache
@lru_cache(maxsize=1)
def load(): ...
```
Если поднятие импортов ломает цикл — это сигнал, что цикл надо разорвать явно, а не прятать.
### 🟡 `config.yaml` (вопрос 3.3)
Конфиг сейчас грузится через `api/config.py`. Держать `config.yaml` в корне проекта (рядом с `web/app.py`) логичнее — он общий для `api/`, `brain/`, `obd/`, а не принадлежит только `api/`. Вынесите на верхний уровень, путь резолвьте от корня.
### 🟢 Заменяемость модулей (вопрос 3.5)
`brain/` заменяется на локальный LLM тривиально — он зависит только от OpenAI-совместимого HTTP (`/chat/completions`). Достаточно сменить `base_url`/`model` в конфиге; код менять не нужно. `obd/` тоже изолирован. Это хороший знак для дизайна.
---
## Вопрос 4. SQL-схема: таблица sessions
### 🔴 Утечка соединений + конкурентный доступ (вопрос 4.5)
`Database()` создаётся в каждом запросе, открывает `sqlite3.connect(...)` и **никогда не закрывается** — connection leak. При 4 gunicorn-воркерах одновременные записи в один файл дают `database is locked` (SQLite по умолчанию: 1 писатель, нет ожидания). Минимум:
```python
self.conn = sqlite3.connect(str(self.path), timeout=30, check_same_thread=False)
self.conn.execute("PRAGMA journal_mode=WAL") # параллельные читатели + 1 писатель
self.conn.execute("PRAGMA busy_timeout=30000")
```
И закрывать соединение (контекстный менеджер / `try/finally` / `db.close()`), либо держать один пул на воркер. WAL критичен для multi-worker.
### 🟡 30+ колонок в одной таблице (вопрос 4.1)
Для SQLite это **нормально** (лимит 2000 колонок), денормализация под аналитику оправдана. Но смешаны три логических домена: телефон, ELM, LLM. Это не баг, а запах. Пока таблица аналитическая (одна запись = одна сессия) — оставьте; если начнёте часто менять набор полей телефона/ELM — выносите в отдельные таблицы или JSON-колонку.
### 🟢 raw_responses как JSON TEXT (вопрос 4.2)
Ок для SQLite. При необходимости запросов внутрь — используйте `json_extract()` (есть в SQLite ≥3.38). Менять не нужно.
### 🟡 Индексы (вопрос 4.3)
`created_at`, `vin`, `elm_mac`, `android_id` — разумный набор. Но `vin` nullable и часто NULL — индекс будет «разреженным», это норм. Добавьте составной `(android_id, created_at)` если будете строить историю по устройству — иначе текущих достаточно.
### 🟡 Мёртвые таблицы (вопрос 4.4)
`cars`, `diagnostic_tokens`, `llm_messages`, `ecu_parameters`, `dtc_codes` создаются в `_init_schema()`, имеют методы-обёртки в `db.py`, но в текущем пути `upload`/`chat` **не используются**. Это «второй контур», который вводит в заблуждение (например, история диалога в `/chat` идёт из клиента, а не из `llm_messages`). Решение: либо подключите их (тогда `/chat` сможет хранить историю на сервере по VIN), либо удалите вместе с методами. Сейчас они — технический долг и риск рассинхрона схемы.
### 🟢 Инъекции
Все запросы параметризованы (`?`), SQL-инъекций нет.
---
## Вопрос 5. LLM-интеграция: промпты и таймауты
### 🔴 Рассинхрон модели и таймаута в коде
- `brain/client.py`: `DEFAULT_MODEL = "gpt-oss-20b"`, а конфиг/доки — `gpt-oss-120b`. Дефолт-fallback тихо подменит модель, если конфиг недокинул `model`.
- `Diagnoser.ask(... timeout=120)`, но в вопросе и nginx/gunicorn заявлено 180с. Таймаут захардкожен и не берётся из конфига.
- Докстринги и комментарии говорят «DeepSeek», хотя API — `api.aillm.ru` / gpt-oss. Чисто косметика, но путает.
```python
def __init__(self, api_key, model="gpt-oss-120b", base_url=DEFAULT_BASE, timeout=180):
...
self.timeout = timeout
def ask(self, messages):
resp = requests.post(..., timeout=self.timeout)
```
### 🟡 Нет streaming + heartbeat (вопрос 5.2)
`requests.post` без `stream=True` на 120–180с — это «чёрный ящик»: клиент не видит прогресса и рвёт по таймауту (см. Вопрос 2). Для длинной генерации лучше streaming (SSE) с проксированием токенов клиенту — тогда соединение «живое», NAT не закрывает, 499 пропадает. Минимум — heartbeat-байты каждые N секунд.
### 🟡 История диалога в /chat (вопрос 5.3)
История склеивается в **один user-prompt** строкой («Водитель: …/Автоэксперт: …»), а не передаётся как полноценный массив `messages` с ролями. Модель хуже держит контекст, и при длинной истории (даже срезанной до 10) промпт может раздуться. Лучше передавать историю настоящими `role: user/assistant` сообщениями (метод `diagnose` это уже умеет через `history`!) и считать токены, а не сообщения:
```python
hist_msgs = [{"role": m["role"], "content": m["content"]} for m in history[-10:]]
answer = diagnoser.diagnose(SYSTEM_CHAT, question, history=hist_msgs)
```
Переполнения контекста сейчас никто не контролирует — добавьте бюджет по токенам.
### 🟡 Обработка ошибок LLM (вопрос 5.5)
Сейчас один общий `except Exception` → строка «LLM недоступен: {e}». Нет различия rate limit (429, нужен retry-after), timeout (нужен ретрай), 5xx (ретрай) vs 4xx (не ретраить). И текст исключения уходит **прямо в ответ пользователю** — может протечь URL/детали. Разделите коды:
```python
try:
...
except requests.Timeout: # ретрай
except requests.HTTPError as e:
if e.response.status_code == 429: ... # backoff по Retry-After
```
### 🟢 Промпт для диагностики (вопрос 5.1)
`SYSTEM_PROMPT` сильный: 10 правил, явный формат с таблицами, проценты уверенности, «проверь перед заменой», секция «если не поможет». Это хорошо. Чего не хватает: (1) данных об авто (make/model/year/engine почти всегда отсутствуют — VIN есть, но не расшифровывается), (2) пробег/условия, (3) явного запрета галлюцинировать значения PID, которых нет в данных. Добавьте расшифровку VIN→марка/год (хотя бы WMI) перед отправкой — резко поднимет качество.
### 🟡 Выбор gpt-oss-120b (вопрос 5.4)
Для авто-диагностики ключевое — знание DTC и инженерная логика. 120b разумен как баланс цена/качество. Альтернативы под задачу: Qwen2.5-72B/Qwen3 (хорош в технике, но у вас отмечен CoT-leak баг на fp8-варианте), DeepSeek-V3 (сильная техничка), либо рассуждающая модель (o-серия/R1) для сложных взаимосвязей — но они дороже и медленнее, что усугубит проблему таймаутов из Вопроса 2. Вывод: 120b ок, менять стоит только если качество разбора DTC не устраивает.
---
## Вопрос 6. Безопасность API
### 🔴 Любой эндпоинт без аутентификации → бесплатный прокси к платному LLM (вопросы 6.1, 6.4)
`/api/v1/chat`, `/api/v1/session/upload`, `/api/v1/ping-llm` дёргают платный LLM **без какой-либо аутентификации и без rate limit**. Любой, кто узнал домен, может в цикле слать `/chat` и жечь ваш токен `api.aillm.ru`, а `/ping-llm` вообще тратит LLM-вызов на каждый GET. Для MVP HTTPS защищает только канал, но не от абуза. Минимум:
- статический API-ключ приложения в заголовке (да, его можно вытащить из APK, но он отсекает массовый скан-абуз);
- rate limiting на nginx (`limit_req_zone`) и/или Flask-Limiter по IP/`android_id`;
- `/ping-llm` не должен реально вызывать LLM на каждый пинг — кэшируйте результат на 1–5 мин.
```nginx
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/m;
location /api/v1/chat { limit_req zone=api burst=5 nodelay; ... }
```
### 🟡 XSS через diagnosis/raw (вопрос 6.3 — не инъекция, а отображение)
SQL-инъекции через `raw_responses` нет (запросы параметризованы). **Но**: ответ ELM327 и текст диагноза от LLM (markdown с таблицами) где-то рендерятся в вебе (`web/templates/index.html`, дашборд сессий). Если markdown/HTML вставляется без экранирования — это stored XSS: вредонос в `raw` ELM или в ответе LLM выполнится в браузере админа. Проверьте, что вывод экранируется (Jinja autoescape по умолчанию вкл — не отключайте `|safe` на этих полях; markdown рендерьте через санитайзер).
### 🟡 Утечка деталей в ответах
`except ... return f"LLM недоступен: {e}"` и `error: str(e)[:100]` отдают внутренние сообщения наружу. Логируйте полностью, клиенту — обобщённый текст.
### 🟡 API-ключ LLM (вопрос 6.5)
Текущая схема (ключ только на сервере, не в APK) — **правильная**, это лучшее в безопасности проекта. Дополнительный прокси не нужен; достаточно закрыть абуз (rate limit + ключ приложения), чтобы вашим серверным ключом не пользовались чужие.
### Сводка по безопасности
| Мера | Приоритет | Статус |
|------|-----------|--------|
| Rate limiting (nginx/Flask-Limiter) | 🔴 высокий | нет |
| Ключ приложения в заголовке | 🟡 средний | нет |
| `/ping-llm` без реального LLM-вызова | 🟡 средний | вызывает LLM |
| Экранирование diagnosis/raw в вебе | 🟡 средний | проверить |
| Не отдавать текст исключений клиенту | 🟡 средний | отдаёт |
| Ключ LLM только на сервере | 🟢 | сделано |
---
## Итоговый топ проблем (по убыванию важности)
1. 🔴 **Нет идемпотентности upload** → дубликаты сессий и двойной расход LLM при 499/ретраях (Q2).
2. 🔴 **Открытые LLM-эндпоинты без auth/rate-limit** → абуз платного токена (Q6).
3. 🔴 **SQLite: утечка соединений + нет WAL/busy_timeout** при 4 воркерах → `database is locked` (Q4).
4. 🔴 **Долгий синхронный LLM в запросе** — корень 499; разделить приём данных и анализ, добавить streaming (Q2, Q5).
5. 🔴 **`reset_input_buffer` перед командой** в стейт-машине — иначе десинхрон ELM327 (Q1).
6. 🟡 Рассинхрон модели/таймаута в `client.py` (20b vs 120b, 120с vs 180с) (Q5).
7. 🟡 История диалога `/chat` строкой вместо ролей `messages` (Q5).
8. 🟡 Мёртвые таблицы в схеме — подключить или удалить (Q4).
## Что уже хорошо 🟢
- Чистые границы модулей, заменяемый `brain/`.
- Сильный диагностический системный промпт.
- Параметризованный SQL (нет инъекций).
- Ключ LLM не в APK.
- Стейт-машина ограничена по ретраям (нет бесконечных циклов).
+53
View File
@@ -0,0 +1,53 @@
# 2026-06-06 — Полная сессия (полевой тест + 9 багов + фиксы)
## Текущий статус
- **Версия:** v0.47.0-dev
- **APK:** https://obdai.ru/elmer.apk
- **Сервер:** https://obdai.ru (5.172.178.213, nginx+gunicorn)
- **Android repo:** github.com/Repinoid/elmer-android
- **Server repo:** gitea.services.ngcloud.ru/Nail/elmer
## Хронология коммитов (Android)
| Коммит | Описание |
|--------|----------|
| `6a33048` | fix: checkDevice() забыл connectAndInit() |
| `739afa9` | fix: connect() идемпотентный + run() без двойного connect |
| `94b890f` | bump v0.43.0-dev |
| `71618c6` | fix: статус-строка — append вместо overwrite |
| `d49f6d6` | fix: все appendStatus с \n, таймер на своей строке |
| `3ef3532` | fix: scriptRegistered сброс в onDestroy |
| `dead66c` | fix: init без ретраев, v1.5-совместимость |
## Все 9 багов
1. checkDevice без connectAndInit — AT-команды без BT-сокета
2. connect не идемпотентный — guard socket.isConnected
3. run двойной connect
4. Статус-строка overwrite — всё на appendStatus(\n...)
5. Таймер съедал заголовок — \n вместо пробела
6. scriptRegistered не сбрасывался после поворота
7. init() 70 секунд на ATAT1 — write+tryRead(2s) вместо exec
8. checkDevice слал v2-команды на v1.5 — проверка isV2
9. recover/updateAtst/handle длинные таймауты — 2000-3000мс
## ELM327: версии и команды (Wikipedia)
- **v1.5 НЕ СУЩЕСТВУЕТ** — клон v1.0/v1.4 с фейковой версией
- ATAT1 (adaptive timing): с v1.2
- AT@1/AT@2 (device ID): с v1.3
- ATST (set timeout): с v1.2
- Базовые (ATI, ATDP, ATRV, ATSP, ATE0, ATL0, ATS0, ATWS): с v1.0
## Правила для Copilot
1. Коммит после каждой правки: git add -A && git commit -m "..." && git push
2. При деплое bump версии в android/app/build.gradle.kts
3. Формат: fix:/feat:/refactor:/bump:/docs:
## TODO
- [ ] Разбить MainActivity.kt (~470 -> <=200 строк)
- [ ] Разбить ElmChecker.kt (~270 -> <=200 строк)
- [ ] Подробные комментарии перед каждой функцией
- [ ] Полевой тест v0.47.0-dev на машине
+113
View File
@@ -0,0 +1,113 @@
# Резюме проекта elmAI — 14 июня 2026
## Текущая версия: v1.18.0-dev
## Архитектура
**Сервер (Python/Flask):** `gitea.services.ngcloud.ru/Nail/elmer`, ветка `bugfix-2026-06-07`
- Хост: `obdai.ru` (5.172.178.213), доступ по SSH: `ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213`
- Репо на сервере: `/opt/elmer`, сервис `elmer` (gunicorn), nginx прокси
**Android (Kotlin):** `github.com/Repinoid/elmer-android`, ветка `bugfix-2026-06-07`
- APK собирается на сервере: `export ANDROID_SDK_ROOT=$HOME/android-sdk && cd /opt/elmer/android && ./gradlew clean assembleDebug`
- APK на сайте: `web/static/app-debug.apk``https://obdai.ru/elmer.apk`
## Что работает ✅
1. **Статическая диагностика** — одиночные PID (0104-011F), DTC, VIN — ИДЕАЛЬНО
2. **Speed-test** — замер задержек RPM/MAF/STFT при клике на 🔵 ELM (если нет профиля)
3. **Серверный тестовый скрипт**`GET /api/v1/script?mode=test`
4. **Авто-подбор таймингов**`POST /api/v1/test/next` — сервер получает результаты, увеличивает wait_ms если ошибок >20%
## Что НЕ работает ❌
1. **Динамический тест (СТАРТ/СТОП)** — ELM327 v1.5 замолкает после первых 1-2 команд. Статика работает, динамика нет. Причина не найдена.
## Ключевые файлы
### Сервер
| Файл | Что |
|------|-----|
| `api/routes.py` | `?mode=test`, `POST /api/v1/test/next`, авто-подбор |
| `api/scripts.py` | `build_test_script(wait_ms, pids, repeat)` |
| `api/db.py` | `device_profiles` таблица с `response_time_ms` |
| `api/ping.py` | ping/ping-llm эндпоинты |
### Android
| Файл | Что |
|------|-----|
| `elm/ElmProtocol.kt` | Стейт-машина AndrOBD. `MAX_RETRIES=3`, `state=ERROR` убран |
| `elm/ElmChecker.kt` | checkDevice, checkEcu, speed-test, quickCheck |
| `script/DynamicCollector.kt` | Оригинальный DynamicCollector (не используется сейчас) |
| `script/ScriptEngine.kt` | Выполнение скриптов с сервера, поддержка `wait` |
| `server/ServerClient.kt` | `downloadTestScript()`, `postTestNext()` |
| `ui/MainActivity.kt` | `startDynamicRecording()` — авто-подбор с сервера |
## Логика авто-подбора (v1.17+)
```
Пользователь: СТАРТ
Статика 9 PID (как обычно)
Цикл 1: GET /api/v1/script?mode=test → wait=2000ms, 2 PID (010C,0106), 8 повторов
↓ выполняет
↓ POST /api/v1/test/next {run:0, results:[...]}
↓ ответ: {done:false, message:"50% ошибок — увеличиваю до 2500ms"}
Цикл 2: wait=2500ms → выполняет → POST → ответ
... пока done:true или run≥5
```
## Деплой
### Только сервер (без APK):
```bash
cd /home/naeel/elmer
git add -A && git commit -m "..." && git push origin bugfix-2026-06-07
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 'cd /opt/elmer && git pull origin bugfix-2026-06-07 && sudo systemctl restart elmer'
```
### APK + сервер:
```bash
# Бамп версии в android/app/build.gradle.kts и web/templates/index.html
# Затем:
cd /home/naeel/elmer/android
git add -A && git commit -m "..." && git push origin bugfix-2026-06-07
cd /home/naeel/elmer
git add -A && git commit -m "bump vX.Y.Z-dev" && git push origin bugfix-2026-06-07
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 && git pull origin bugfix-2026-06-07 && \
export ANDROID_SDK_ROOT=$HOME/android-sdk && export ANDROID_HOME=$ANDROID_SDK_ROOT && \
rm -rf android && tar xzf /tmp/android-src.tar.gz -C /opt/elmer/ && \
cd android && ./gradlew clean assembleDebug && \
cp app/build/outputs/apk/debug/app-debug.apk /opt/elmer/web/static/'
```
## Нерешённая проблема
ELM327 v1.5 замолкает при последовательных OBD-командах. Статика (одиночные) — ок. Динамика (подряд) — пустые ответы. Уже пробовали:
- Разные паузы (250ms → 4000ms) — не помогает
- ATWS перед динамикой — делает хуже
- Убирали/возвращали drainInput() — v1.9 без drain хуже
- Retry в exec() — оригинальный AndrOBD код не помогает
- Разное количество PID — не помогает
- DynamicCollector → серверный скрипт — не помогает
Текущий авто-подбор должен найти рабочий интервал, но если даже 4000ms не помогает — проблема глубже таймингов.
## Конфигурация авто-подбора (api/routes.py)
- Старт: 2000ms
- Шаг: +500ms
- Макс: 5 циклов
- PIDs: 010C (RPM), 0106 (STFT)
- Повторов: 6
## Профиль ELM
Таблица `device_profiles` в `elmer.db`. MAC: `AA:BB:CC:11:22:33`. Был удалён старый мусорный профиль.
+73
View File
@@ -0,0 +1,73 @@
# Полевой тест — диагностика на машине
> v0.41.0-dev, 6 июня 2026
> Нужно: ноутбук + ELM327 Bluetooth + зажигание (можно без ключа для п.1)
## 1. Проверка ELM (без зажигания)
1. Вставить ELM327 в OBD-разъём (красная лампа горит)
2. На телефоне/ноуте включить Bluetooth
3. Открыть приложение
4. Нажать **«🔌 ELM»**
- ✅ Таймер считает секунды
- ✅ Через 2-5с результат: версия, AT@2, протокол, напряжение
- ✅ Если v1.5 — предупреждение о клоне
5. Нажать **«📡 Сервер»**
- ✅ Пинг сервера: OK
- ✅ LLM: OK (таймаут ~2с)
6. Нажать **«📋 История»**
- ✅ Показывает последние сессии (если есть)
## 2. Сканирование ошибок (ключ в ON, двигатель НЕ запускать)
1. Повернуть ключ в ON (зажигание, приборы горят)
2. В приложении нажать **«⚠️ ОШИБКИ»**
- ✅ BLUETOOTH автоматически подключается
- ✅ Загорается таймер `[1с]`, `[2с]`...
- ✅ Через 3-10с результат:
- «Ошибок нет» или список DTC (P0xxx...)
3. После скана:
- ✅ Кнопка **«🔍 ДИАГНОСТИКА»** активировалась
- ✅ Надпись «👆 Сначала считай ошибки» сменилась на «✅ Ошибки считаны»
4. Заполнить поле **«Что за машина?»** (например: «Volkswagen Passat B5 1.8T 2005»)
5. Нажать **«🔍 ДИАГНОСТИКА»**
- ✅ Пошёл скрипт: VIN, DTC, PID...
- ✅ Результаты отображаются в реальном времени
- ✅ Статус загрузки: «Отправка N ответов... [Nс]»
- ✅ Через 10-60с приходит диагноз от LLM
- ✅ Диагноз краткий, без воды
6. Нажать **«✕ Закрыть»** — всё чистится
## 3. Чат с LLM
1. Ввести вопрос: «Что может стучать спереди слева?»
- ✅ Ответ от LLM через 3-10с
2. Спросить «Кто тебя создал?»
- ✅ Ответ: «Я модель больших и малых академических театров...»
3. Переспросить «Ну серьёзно?»
- ✅ Ответ: «С какой целью интересуешься?»
## 4. Проверка истории
1. Нажать **«📋 История»**
- ✅ Список последних сессий
2. Нажать на любую запись
- ✅ Показан полный диагноз
3. Нажать **«✕ Закрыть»**
- ✅ Возврат к списку истории
4. Закрыть список — нажать «Закрыть» в диалоге
## 5. Дополнительно — проверить ELM (AT@2)
1. Если ELM качественный — в результатах будет строка «🔹 Устройство: ...»
2. Если клон — строка отсутствует
## 6. Если что-то пошло не так
| Симптом | Что проверять |
|---------|---------------|
| ELM не подключается | Bluetooth включён? Зажигание? Красная лампа на ELM? |
| Нет VIN | ELM v1.5 может не поддерживать 0902 |
| PID пустые | Нет зажигания, или CAN протокол не совпал |
| Долгий ответ LLM | DeepSeek бывает медленным до 30с |
| Ошибка LLM | `api/v1/ping-llm` — сервер должен ответить `{"ok":true}` |
-3
View File
@@ -1,3 +0,0 @@
"""Elmer — анализ ошибок электроники автомобиля через ELM327 + LLM."""
__version__ = "0.1.0"
-218
View File
@@ -1,218 +0,0 @@
"""
AndrOBD Protocol — ПОЛНАЯ копия стейт-машины AndrOBD.
Источник: github.com/fr3ts0n/AndrOBD, ElmProt.java
Состояния:
UNDEFINED → INITIALIZING → READY
Любое → BUSY (команда) → READY
Любое → ERROR → RECOVERING → READY
BUS ERROR → DISCONNECTED → RECONNECTING → READY
Каждый ответ проверяется — не тот ответ → переход в ошибку → восстановление.
"""
import logging
import time
from enum import Enum, auto
from typing import Optional
logger = logging.getLogger("androbd")
# ── Состояния (AndrOBD STAT) ───────────────────────────────
class State(Enum):
UNDEFINED = auto()
INITIALIZING = auto()
READY = auto()
BUSY = auto()
ERROR = auto()
DISCONNECTED = auto()
# ── Типы ответов (AndrOBD RSP_ID) ──────────────────────────
class Rsp:
PROMPT = ">"
OK = "OK"
SEARCHING = "SEARCHING"
NODATA = "NODATA"
ERROR = "ERROR"
UNABLE = "UNABLE"
BUS_BUSY = "BUS BUSY"
BUS_ERROR = "BUS ERROR"
CAN_ERROR = "CAN ERROR"
BUS_INIT = "BUS INIT"
STOPPED = "STOPPED"
DATA_ERROR = "DATA ERROR"
BUFFER_FULL= "BUFFER FULL"
RX_ERROR = "RX ERROR"
UNKNOWN = ""
@classmethod
def identify(cls, raw: str) -> str:
u = raw.upper().strip()
for tag in (cls.SEARCHING, cls.NODATA, cls.ERROR, cls.UNABLE,
cls.BUS_BUSY, cls.BUS_ERROR, cls.CAN_ERROR,
cls.BUS_INIT, cls.STOPPED, cls.DATA_ERROR,
cls.BUFFER_FULL, cls.RX_ERROR, cls.OK):
if u.startswith(tag):
return tag
if raw.strip() == ">":
return cls.PROMPT
return cls.UNKNOWN
# ── Адаптивный таймаут (AndrOBD AdaptiveTiming) ─────────────
class AdaptiveTiming:
DEFAULT = 500; MIN = 50; MAX = 2000; STEP = 20; RES = 4
def __init__(self):
self._t = self.DEFAULT; self._min = self.MIN
@property
def ms(self) -> int: return self._t
@property
def atst(self) -> int: return max(1, self._t // self.RES)
def increase(self):
if self._t + self.STEP < self.MAX: self._t += self.STEP
def decrease(self):
if self._t - self.STEP >= self._min: self._t -= self.STEP
def reset(self): self._t = self.DEFAULT
# ── Протокол (AndrOBD ElmProt) ──────────────────────────────
class AndrOBD:
"""Стейт-машина ELM327 — 1:1 копия AndrOBD."""
INIT_TMO = 10000 # инициализация
DEF_TMO = 200 # адаптивный
def __init__(self, port: str, baudrate: int = 38400):
self.port = port; self.baudrate = baudrate
self._ser = None; self._timing = AdaptiveTiming()
self._state = State.UNDEFINED; self._last_cmd: Optional[str] = None
# ── Connect ─────────────────────────────────────────
def connect(self):
import serial
self._ser = serial.Serial(
port=self.port, baudrate=self.baudrate, timeout=0.1,
bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE)
time.sleep(0.5); logger.info(f"AndrOBD: connected {self.port}")
def close(self):
if self._ser and self._ser.is_open: self._ser.close()
# ── Инициализация ───────────────────────────────────
def init(self):
logger.info("AndrOBD: init")
self._state = State.INITIALIZING
self._exec("ATSP0", self.INIT_TMO)
self._exec("ATAT1", self.DEF_TMO * 5)
self._update_atst()
self._exec("ATS0", self.DEF_TMO * 5)
self._exec("ATL0", self.DEF_TMO * 5)
self._exec("ATE0", self.DEF_TMO * 5)
self._state = State.READY
logger.info("AndrOBD: ready")
# ── OBD-команда ─────────────────────────────────────
def send(self, cmd: str) -> str:
if self._state == State.ERROR:
self._recover()
self._state = State.BUSY
result = self._exec(cmd, self._timing.ms)
self._state = State.READY
return result
# ── Выполнение ──────────────────────────────────────
def _exec(self, cmd: str, timeout: int) -> str:
self._last_cmd = cmd; self._write(cmd)
t = timeout
for _ in range(10):
try:
return self._handle(self._read(t))
except TimeoutError:
if self._state == State.INITIALIZING: t += 1000
else: self._timing.increase(); t = self._timing.ms
logger.error(f"AndrOBD: no response for {cmd}")
self._state = State.ERROR; return ""
# ── Обработка ответа ────────────────────────────────
def _handle(self, raw: str) -> str:
t = Rsp.identify(raw)
if t == Rsp.SEARCHING: return raw
if t == Rsp.OK: self._timing.decrease(); return raw
if t == Rsp.NODATA: self._timing.increase(); self._update_atst(); return raw
if t in (Rsp.UNABLE, Rsp.BUS_BUSY, Rsp.BUS_ERROR,
Rsp.CAN_ERROR, Rsp.BUS_INIT, Rsp.STOPPED):
logger.warning(f"AndrOBD: BUS ERROR ({t})")
self._state = State.DISCONNECTED
self._timing.reset(); self._update_atst()
self._write("ATPC"); self._try_read()
self._write("ATSP0"); self._try_read()
return raw
if t in (Rsp.ERROR, Rsp.DATA_ERROR, Rsp.BUFFER_FULL, Rsp.RX_ERROR):
logger.warning(f"AndrOBD: {t} — warm start")
self._state = State.ERROR
self._write("ATWS"); self._try_read()
return raw
# Данные — успех
self._timing.decrease(); return raw
def _recover(self):
logger.info("AndrOBD: recovering...")
self._state = State.INITIALIZING
self._write("ATWS"); self._try_read()
self._write("ATSP0"); self._try_read()
self._write("ATE0"); self._try_read()
self._state = State.READY
# ── Чтение/запись ───────────────────────────────────
def _write(self, cmd: str):
self._ser.write((cmd + "\r").encode()); self._ser.flush()
logger.debug(f"AndrOBD → {cmd}")
def _read(self, timeout_ms: int) -> str:
dl = time.monotonic() + timeout_ms / 1000.0
lines, cur = [], []
got_prompt = False
while time.monotonic() < dl:
if self._ser.in_waiting > 0:
ch = self._ser.read(1)
if not ch: continue
cp = ch[0]
if cp == 62: self._push(cur, lines); got_prompt = True; break
elif cp == 13: self._push(cur, lines)
elif cp in (10, 32): pass
else: cur.append(chr(cp))
else: time.sleep(0.001)
self._push(cur, lines)
if not got_prompt: raise TimeoutError(f"timeout {timeout_ms}ms")
return "\n".join(lines)
def _try_read(self, timeout: int = 5000):
try: self._read(timeout)
except TimeoutError: pass
@staticmethod
def _push(cur, lines):
if cur: lines.append("".join(cur)); cur.clear()
def _update_atst(self):
self._write(f"ATST{self._timing.atst:02X}"); self._try_read()
-39
View File
@@ -1,39 +0,0 @@
"""Загрузка конфигурации из config.yaml."""
import os
from pathlib import Path
import yaml
CONFIG_PATH = Path(os.environ.get("ELMER_CONFIG", Path(__file__).parent.parent / "config.yaml"))
def load() -> dict:
"""Читает config.yaml, подставляет переменные окружения в значения."""
if not CONFIG_PATH.exists():
raise FileNotFoundError(f"Конфиг не найден: {CONFIG_PATH}")
with open(CONFIG_PATH) as f:
config = yaml.safe_load(f)
# Подстановка ${VAR} из переменных окружения
_resolve_env(config)
return config
def _resolve_env(obj):
"""Рекурсивно заменяет ${VAR} на os.environ['VAR']."""
if isinstance(obj, dict):
for k, v in obj.items():
if isinstance(v, str) and v.startswith("${") and v.endswith("}"):
env_var = v[2:-1]
obj[k] = os.environ.get(env_var, "")
else:
_resolve_env(v)
elif isinstance(obj, list):
for i, v in enumerate(obj):
if isinstance(v, str) and v.startswith("${") and v.endswith("}"):
env_var = v[2:-1]
obj[i] = os.environ.get(env_var, "")
else:
_resolve_env(v)
-275
View File
@@ -1,275 +0,0 @@
"""SQLite — сохранение сессий диагностики.
Схема:
cars — VIN, марка, модель, год, двигатель
diagnostic_tokens — id (PK), car_id (FK), created_at
llm_messages — token_id (FK), role, content, timestamp
ecu_parameters — token_id (FK), pid_code, value, unit, timestamp
dtc_codes — token_id (FK), code, description, status
sessions — сводная таблица всех сессий (клиент, ELM, авто, LLM)
"""
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))
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'))
);
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,
-- 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, -- 'bt' | 'tcp'
mock_mode INTEGER DEFAULT 0,
-- LLM
diagnosis_text TEXT,
diagnosis_len INTEGER,
llm_model TEXT,
llm_duration_ms INTEGER,
llm_success INTEGER DEFAULT 0,
-- Сырые данные (JSON)
raw_responses 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);
CREATE INDEX IF NOT EXISTS idx_sessions_aid ON sessions(android_id);
""")
self.conn.commit()
# ── sessions ──────────────────────────────────────────
def save_session(self, client_info: dict, responses: list[dict],
diagnosis: str = "", llm_model: str = "",
llm_duration_ms: int = 0, llm_success: bool = False):
"""Сохраняет сводную запись о сессии."""
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
self.conn.execute("""
INSERT INTO sessions (
client_ip, real_ip, user_agent, content_length,
phone_model, phone_maker, android_version, android_sdk,
app_version, android_id,
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
) 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("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),
diagnosis, len(diagnosis), llm_model,
llm_duration_ms, 1 if llm_success else 0,
json.dumps(responses, ensure_ascii=False) if responses else None,
))
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]
# ── 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
@@ -1,48 +0,0 @@
"""Вызов DeepSeek API для диагностики."""
import requests
DEFAULT_BASE = "https://api.aillm.ru/v1"
DEFAULT_MODEL = "gpt-oss-20b"
class Diagnoser:
"""Отправляет данные в DeepSeek и возвращает диагноз."""
def __init__(self, api_key: str, model: str = DEFAULT_MODEL, base_url: str = DEFAULT_BASE):
self.api_key = api_key
self.model = model
self.base_url = base_url.rstrip("/")
def ask(self, messages: list[dict]) -> str:
"""Отправляет сообщения в DeepSeek, возвращает текст ответа."""
resp = requests.post(
f"{self.base_url}/chat/completions",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
json={
"model": self.model,
"messages": messages,
"temperature": 0.3, # пониже — меньше фантазий
"max_tokens": 4096,
},
timeout=120, # api.aillm.ru бывает медленным
)
resp.raise_for_status()
data = resp.json()
return data["choices"][0]["message"]["content"]
def diagnose(
self,
system: str,
user_prompt: str,
history: list[dict] | None = None,
) -> str:
"""Полный цикл: system + история + user_prompt → ответ."""
messages = [{"role": "system", "content": system}]
if history:
messages.extend(history)
messages.append({"role": "user", "content": user_prompt})
return self.ask(messages)
-82
View File
@@ -1,82 +0,0 @@
"""Шаблоны промптов для LLM."""
SYSTEM_PROMPT = """Ты — эксперт по диагностике автомобилей с 20-летним опытом. Ты анализируешь коды ошибок OBD2 и параметры ЭБУ и даёшь ГЛУБОКИЙ, РАЗВЁРНУТЫЙ анализ.
ПРАВИЛА ОТВЕТА:
1. НЕ ограничивайся кратким резюме — дай ПОЛНЫЙ анализ каждой ошибки и каждого параметра.
2. Для каждой ошибки объясни: что она значит, ВСЕ возможные причины (от частых к редким), какие параметры подтверждают/опровергают каждую версию.
3. Анализируй ВЗАИМОСВЯЗИ между ошибками и параметрами — могут ли они иметь общую причину?
4. Указывай степень уверенности в процентах для КАЖДОГО вывода.
5. Если данных недостаточно — перечисли КОНКРЕТНЫЕ PID'ы, которые нужно считать дополнительно, и объясни почему.
6. Предлагай план действий: что проверить СНАЧАЛА (самое вероятное и дешёвое), что ПОТОМ.
7. Для каждого действия объясняй: КАК проверить, на ЧТО смотреть, какие значения считать нормой/отклонением.
8. Добавляй секцию «Если не поможет» — план Б для каждого пункта.
9. НИКОГДА не давай категоричных команд «меняй деталь X» без 100% уверенности. Пиши «проверь X перед заменой Y».
10. Пиши на русском языке, доступно, но ТЕХНИЧЕСКИ ТОЧНО. Используй таблицы где уместно.
ФОРМАТ ОТВЕТА:
## Диагноз (развёрнутый)
(полный анализ ситуации, 3-5 абзацев)
## Анализ ошибок
| Код | Описание | Вероятные причины | Подтверждающие параметры | Уверенность |
|-----|----------|-------------------|--------------------------|-------------|
...
## Анализ параметров
| Параметр | Значение | Норма | Отклонение | На что влияет |
|----------|----------|-------|------------|---------------|
...
## Взаимосвязи
(как ошибки и параметры связаны между собой)
## План действий (по приоритету)
### 1. Проверить ... (самое вероятное)
- КАК проверить: ...
- На что смотреть: ...
- Норма: ...
### 2. Если не помогло — проверить ...
...
## Каких данных не хватает
- PID XX (название) — потому что ...
- ...
## Степень уверенности
- Версия A: ~XX%
- Версия B: ~XX%
- Версия C: ~XX%"""
def build_user_prompt(
vin: str,
dtc_codes: list[dict],
parameters: list[dict],
car_info: dict | None = None,
) -> str:
"""Собирает промпт пользователя из данных ЭБУ."""
parts = [f"## Данные диагностики\n"]
parts.append(f"**VIN:** {vin}")
if car_info:
parts.append(f"**Автомобиль:** {car_info.get('make', '?')} {car_info.get('model', '?')} "
f"({car_info.get('year', '?')}), двигатель: {car_info.get('engine', '?')}")
if dtc_codes:
parts.append("\n### Коды ошибок")
for dtc in dtc_codes:
parts.append(f"- **{dtc['code']}** ({dtc.get('status', 'stored')}): {dtc.get('description', '')}")
if parameters:
parts.append("\n### Параметры ЭБУ")
for p in parameters:
parts.append(f"- {p['name']} ({p['pid_code']}): {p['value']} {p['unit']}")
parts.append("\n## Запрос")
parts.append("Дай диагноз на основе этих данных. Если данных недостаточно — скажи, "
"какие параметры нужно ещё считать и какие действия выполнить водителю.")
return "\n".join(parts)
+86
View File
@@ -0,0 +1,86 @@
#!/bin/bash
# Деплой Elmer на obdai.ru
set -e
echo "=== Установка пакетов ==="
apt update && apt install -y python3-pip python3-venv nginx certbot python3-certbot-nginx
echo "=== Клонирование репо ==="
cd /opt
git clone https://gitea.services.ngcloud.ru/Nail/elmer.git || (cd elmer && git pull)
cd elmer
git checkout fat-client
echo "=== Виртуальное окружение ==="
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
pip install gunicorn
echo "=== Конфигурация ==="
cp config.yaml config.yaml.bak
cat > config.yaml << 'YAML'
elm327:
port: /dev/rfcomm0
baudrate: 38400
llm:
api_key: "sk-ucI5YvOticoOQ9Kuj5K9mQ"
model: "gpt-oss-120b"
base_url: "https://api.aillm.ru/v1"
pids:
"0105": ["coolant_temp", "°C"]
"010C": ["rpm", "об/мин"]
"010D": ["speed", "км/ч"]
YAML
echo "=== Systemd сервис ==="
cat > /etc/systemd/system/elmer.service << 'UNIT'
[Unit]
Description=Elmer Flask API
After=network.target
[Service]
User=naeel
WorkingDirectory=/opt/elmer
ExecStart=/opt/elmer/venv/bin/gunicorn -w 4 -b 127.0.0.1:8000 web.app:app
Restart=always
[Install]
WantedBy=multi-user.target
UNIT
echo "=== Nginx ==="
cat > /etc/nginx/sites-available/elmer << 'NGX'
server {
listen 80;
server_name obdai.ru www.obdai.ru ai.obdai.ru test.obdai.ru;
location /static/ {
alias /opt/elmer/web/static/;
}
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
NGX
ln -sf /etc/nginx/sites-available/elmer /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
nginx -t && systemctl reload nginx
echo "=== SSL ==="
certbot --nginx -d obdai.ru -d www.obdai.ru --non-interactive --agree-tos -m tazet@narod.ru || true
echo "=== Запуск ==="
systemctl daemon-reload
systemctl enable elmer
systemctl restart elmer
systemctl restart nginx
echo "=== ГОТОВО ==="
curl -s http://obdai.ru/api/v1/script | head -c 50
+78
View File
@@ -0,0 +1,78 @@
## 4. Контекст проекта (РЕЗЮМЕ для нового чата)
### Что это
elmAI — Android-приложение + Python-сервер для диагностики авто через ELM327.
### Текущая версия
**v0.48.0** (APK: https://obdai.ru/elmer.apk)
### Репозитории
- Сервер: https://gitea.services.ngcloud.ru/Nail/elmer (ветка master)
- Android: https://github.com/Repinoid/elmer-android (ветка relay-only)
- Сервер живёт на 5.172.178.213 (SSH: naeel@5.172.178.213, ключ ~/.ssh/naeel_vm_id_ed25519)
### Деплой
```bash
# ВСЕГДА сначала bump версии в android/app/build.gradle.kts!
cd /home/naeel/elmer/android && git add -A && git commit -m "..." && git push origin master
cd /home/naeel/elmer && tar czf /tmp/android-src.tar.gz --exclude='.git' --exclude='build' --exclude='.gradle' android/
scp -i ~/.ssh/naeel_vm_id_ed25519 /tmp/android-src.tar.gz naeel@5.172.178.213:/tmp/
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 \
"cd /opt/elmer && rm -rf android && tar xzf /tmp/android-src.tar.gz && \
cd android && gradle wrapper --gradle-version 8.7 && \
export ANDROID_SDK_ROOT=\$HOME/android-sdk && \
./gradlew clean assembleDebug && \
cp app/build/outputs/apk/debug/app-debug.apk /opt/elmer/web/static/"
```
### Структура проекта
```
elmer/
android/ — Android-приложение (Kotlin)
.../client/
MainActivity.kt — UI + приёмники
ElmForwardService.kt — BT/TCP-реле
TestService.kt — автономный тест ELM
api/ — Flask API (Python)
db.py — БД (sessions + device_profiles)
routes.py — эндпоинты
scripts.py — скрипты L0/L1/L2
parser.py — парсинг ответов
config.py, dtc.py, ping.py
brain/ — LLM-клиент, промпты
obd/ — ELM-протокол (Python)
commands.py — каталог AT-команд
classifier.py — классификация ответов
connection.py — SerialTransport
probe.py — пробинг ELM
protocol.py — стейт-машина AndrOBD
state.py — состояния/ответы
timing.py — адаптивный таймаут
doc/ — Документация, сессии
web/ — Flask web, статика
```
### Что сделано (сессия 2026-06-07)
#### Сервер
- **Пробинг ELM**: трехуровневый каскад (L0/L1/L2), профили в БД по MAC
- **Fix init**: больше не шлём ATAT1/ATSTxx (вешало клоны)
- **Скрипты под уровень**: L0 (5 PIDs), L1 (8 + VIN), L2 (14 + калибровки)
- **Рефакторинг**: код разбит на независимые модули (commands, classifier, connection...)
#### Android
- **Вывод**: append вместо overwrite (строки не перекрываются)
- **Таймер**: отдельный TextView, тикает только во время обмена (Engine Time)
- **TestService**: адаптивные таймауты, ATS0, \r терминатор
### Что НЕ сделано (TODO)
- Полевой тест на машине ← СЕЙЧАС
- Разбить MainActivity.kt
- Разбить ElmChecker.kt
### Важные правила
- ELM327 v1.5 — фейк, НЕ слать ATAT1/AT@1/AT@2/ATST/ATCAF1/ATCFC1
- Все статусы через append("\n..."), не tvStatus.text =
- Таймер операций (opTimerStart/Stop), не сессии
- Версию поднимать ВЕЗДЕ: build.gradle.kts, index.html (2 места), CHANGELOG.md, resume.txt, doc/architecture.md
- В проекте elmer — НИЧЕГО не делать без прямого указания
Executable
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

Executable
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+173
View File
@@ -0,0 +1,173 @@
# Морда elmAI v2
> Новая архитектура UI. Обсуждено 7 июня 2026.
## Макет
```
┌──────────────────────────────────────────┐
│ 🔧 elmAI v?.?.? │
│ 📡 Сервер ● 🔌 ELM ● 🚗 ЭБУ ● 🧠 LLM ● │ иконки + светофоры
├──────────────────────────────────────────┤
│ [ ОШИБКИ / ДИАГНОСТИКА / СТАРТ / СТОП ] │ одна кнопка
├──────────────────────────────────────────┤
│ │
│ поле вывода результатов │
│ │
├──────────────────────────────────────────┤
│ [📋 История] │
├──────────────────────────────────────────┤
│ [____________________________] [➤] │ поле ввода + кнопка
└──────────────────────────────────────────┘
```
## Иконки и светофоры
Четыре иконки в строке под версией. Справа от каждой — цветной индикатор.
| Иконка | Текст | 🟢 Зелёный | 🟡 Жёлтый | 🔴 Красный |
|--------|-------|-----------|----------|-----------|
| 📡 | Сервер | ping < 3с | проверка... | нет связи |
| 🔌 | ELM | BT + ATI ok | подключение... | нет ELM |
| 🚗 | ЭБУ | 0100 ответил | — | нет связи с ЭБУ |
| 🧠 | LLM | ping-llm ok | проверка... | нет доступа |
- Жёлтый — только на время проверки
- LLM проверяется только если Сервер зелёный
- ЭБУ проверяется только если ELM зелёный
- **Тап по иконке** — перепроверка. Сбрасывает цвет на жёлтый, запускает проверку заново.
### Таймауты
- **ELM**: 2 попытки BT-подключения по ~4с = макс 8с → 🔴
- **Сервер**: HTTP GET `/api/v1/ping`, таймаут 3с → 🔴
- **LLM**: HTTP GET `/api/v1/ping-llm`, таймаут 5с → 🔴 (только если Сервер 🟢)
- При тапе на иконку проверка запускается немедленно, без задержек.
### Стартовый сценарий
1. Открыли приложение → все индикаторы 🟡 или 🔴
2. ELM 🟡 — пробуем подключиться (8с макс) → 🟢 или 🔴
3. Сервер 🟡 — ping (3с) → 🟢 или 🔴
4. Если Сервер 🟢 → LLM 🟡 — ping-llm (5с) → 🟢 или 🔴
5. Если ELM 🟢 → ЭБУ — пробуем `0100` → 🟢 или 🔴
После первого цикла индикаторы не обновляются автоматически — только по тапу.
## Кнопка-трансформер
Одна широкая кнопка. Меняет текст, цвет и действие в зависимости от этапа.
```
🔴 [⚠️ ОШИБКИ] — начальное состояние
↓ тап
сканирование DTC (03 + 07)
🔵 [🔍 ДИАГНОСТИКА] — ошибки считаны
↓ тап
ЕСЛИ сервер 🟢 → запрос параметров ЭБУ → отправка на сервер → LLM-анализ → вывод
ЕСЛИ сервер 🔴 → пояснение в выводе: «Сервер недоступен. Сделайте тест.»
🟢 [▶ СТАРТ] — готов к динамическому тесту
↓ тап
подсказка в выводе: «Газ до 3000, 3-4с, сброс. → СТОП»
запись 12 PID каждые 250мс
🔴 [⏹ СТОП] — запись идёт
↓ тап
запись остановлена, данные в памяти
вывод: «Записано N отсчётов. ➤ для отправки.»
🟢 [▶ СТАРТ] — можно повторить тест
```
- После СТОП можно снова нажать СТАРТ — новый тест, старые данные сохраняются.
- **Отправка данных** — не кнопкой, а через ➤ в поле ввода.
## Поле вывода
- ScrollView, моноширинный шрифт
- После done — кнопка «✕ Закрыть» (сворачивает вывод, возвращает все кнопки)
- После Share в истории — возврат в приложение (стандартное поведение Android)
## Поле ввода + кнопка ➤
2 строки, всегда активно. Плейсхолдер: «Что беспокоит? Чем подробнее — тем лучше».
Кнопка ➤ справа:
- **Обычный режим**: текст из поля → отправка на сервер (`/api/v1/chat`) → сервер спрашивает LLM → ответ в вывод
- **После СТОП**: накопленные данные теста → отправка на сервер (`/api/v1/session/upload`) + текст (если есть) → сервер анализирует через LLM → ответ в вывод
- Ответ → в поле вывода
## История
- Кнопка «📋 История»
- Список последних 20 сессий (дата, заголовок, статус загрузки)
- Выбор → диагноз во всплывающем окне
- Кнопка «📤 Поделиться» → системный Share Sheet (Telegram, WhatsApp, Gmail...)
- После Share — возврат в наше приложение
## Что удаляется
- Кнопки «📡 Сервер», «🔌 ELM», «🚗 ЭБУ» как отдельные — заменены на иконки со светофорами
- Кнопки «⏱ На месте», «🚗 В движении» — всё через одну кнопку-трансформер
- `tvDtcStatus` («Сначала считай ошибки») — не нужен
- `cbFullMode` (чекбокс полной диагностики) — всегда полная
- `tvPrompt` (оверлей) — не нужен
- `btnDynStart` — не нужен
## Серверные изменения
Ничего нового — всё уже есть:
- `/api/v1/ping`
- `/api/v1/ping-llm`
- `/api/v1/chat`
- `/api/v1/session/upload` с `dynamic_samples`
## Текущий макет (реализовано)
```
elmAI v0.68 📡🟢 ELM🟢 ECU🟢 LLM🟢 ← одна строка
[ ОШИБКИ / ДИАГНОСТИКА / СТАРТ / СТОП ]
вывод
[ 📋 История ] ⏱ 12с ← таймер справа
[____________________________] [➤] ← 3 строки ввода
```
## Логика кнопки (стейт-машина)
| State | Текст | Цвет | Действие |
|-------|-------|------|----------|
| INIT | ⚠️ ОШИБКИ | 🟠 | scanDtc() |
| DTC | 🔍 ДИАГНОСТИКА | 🔵 | runDiagnostics() |
| DIAG | ▶ СТАРТ | 🟢 | startDynamicRecording() |
| START | ⏹ СТОП | 🔴 | stopDynamicRecording() |
| STOP | ▶ СТАРТ | 🟢 | не используется |
- Кнопка неактивна (alpha=0.4) если ELM 🔴 или ECU 🔴
- Поле ввода неактивно если LLM 🔴
- После DTC-сканирования ECU → 🟢
- После СТОП → DIAG (можно снова СТАРТ)
## Исправлено (v0.57 → v0.68)
| v | Что |
|---|-----|
| 0.57 | Layout v2, иконки, трансформер |
| 0.58 | Всё в одной строке |
| 0.59 | setIndicator в runOnUiThread |
| 0.60 | LLM → 🤓 |
| 0.61 | Таймер в строке Истории, нет ELM → кнопка неактивна |
| 0.62 | Поле ввода неактивно без LLM |
| 0.63 | 🤓 → 🎓 |
| 0.64 | 🎓 → LLM текст |
| 0.65 | СТОП работает, ECU 🟢 после сканирования |
| 0.66 | ensureConnected всегда перед dynamic test |
| 0.67 | Кнопка неактивна без ECU |
| 0.68 | Двойной вызов checkLlm/checkEcu убран, таймер в catch |
## Известные проблемы (TODO)
- `findElmDevice()` возвращает null при >1 устройствах (диалог асинхронный)
- Android 12+ — startChecks до получения BT-разрешений
- `State.STOP` не используется
+107
View File
@@ -0,0 +1,107 @@
"""
obd/classifier.py — Классификация ответов ELM327 и определение уровня.
Отдельный сервис:
- classify(raw) → (tag: str, is_ok: bool)
- determine_level(responses: dict) → int (-1/0/1/2)
Использует obd/commands.py для списков команд по уровням.
"""
import logging
from typing import Dict, Optional, Tuple
from obd.state import Rsp
from obd.commands import L0_NAMES, L1_NAMES, L2_NAMES
logger = logging.getLogger("elmer.classifier")
def classify(raw: str) -> Tuple[str, bool]:
"""Классифицирует сырой ответ ELM327.
Returns:
(tag, is_ok)
tag: Rsp.OK / Rsp.ERROR / Rsp.UNKNOWN / ...
is_ok: True если устройство ответило нормально (OK или данные)
"""
tag = Rsp.identify(raw)
is_ok = tag in (Rsp.OK, Rsp.UNKNOWN, Rsp.NODATA, Rsp.SEARCHING)
return (tag, is_ok)
def determine_level(responses: Dict[str, str]) -> dict:
"""Определяет уровень устройства по ответам на пробинг-команды.
Args:
responses: {cmd_name: raw_response} — ответы на команды пробинга.
Returns:
Профиль: {level, elm_version, protocol, voltage, supported, unsupported, errors}
"""
result: dict = {
"level": -1,
"elm_version": None,
"protocol": None,
"voltage": None,
"supported": [],
"unsupported": [],
"errors": [],
}
# ── Уровень 0 ──────────────────────────────────
l0_ok = True
for cmd in L0_NAMES:
raw = responses.get(cmd, "")
tag, ok = classify(raw)
if ok and raw:
result["supported"].append(cmd)
if cmd == "ATI":
result["elm_version"] = raw.strip()
elif cmd == "ATDPN":
result["protocol"] = raw.strip()
elif cmd == "ATRV":
result["voltage"] = raw.strip()
else:
result["unsupported"].append(cmd)
result["errors"].append(f"{cmd}: {tag if tag else 'no response'}")
l0_ok = False
if not l0_ok:
logger.warning("classifier: L0 failed")
return result
result["level"] = 0
# ── Уровень 1 ──────────────────────────────────
l1_ok = True
for cmd in L1_NAMES:
raw = responses.get(cmd, "")
tag, ok = classify(raw)
if ok and raw:
result["supported"].append(cmd)
else:
result["unsupported"].append(cmd)
l1_ok = False
if not l1_ok:
return result
result["level"] = 1
# ── Уровень 2 ──────────────────────────────────
l2_ok = True
for cmd in L2_NAMES:
raw = responses.get(cmd, "")
tag, ok = classify(raw)
if ok and raw:
result["supported"].append(cmd)
else:
result["unsupported"].append(cmd)
l2_ok = False
if l2_ok:
result["level"] = 2
logger.info(f"classifier: level={result['level']}")
return result
+143
View File
@@ -0,0 +1,143 @@
"""
obd/commands.py — Каталог AT-команд ELM327.
Все известные команды ELM327 с метаданными:
- name: имя команды (напр. "ATE0")
- desc: краткое описание
- level: минимальный уровень устройства (0/1/2)
- safe: можно ли слать при пробинге (False для опасных)
Принцип:
- Нет в профиле → не слать.
- Опасные команды (меняют baud rate, мониторинг) — никогда не пробингуются.
"""
from dataclasses import dataclass
from typing import List
@dataclass
class AtCommand:
"""AT-команда ELM327 с метаданными."""
name: str # "ATE0"
desc: str # "Echo off"
level: int = 0 # минимальный уровень (0=все, 1=ATAT1, 2=CAF1/CFC1)
safe: bool = True # можно ли пробинговать
# ══════════════════════════════════════════════════════════
# Уровень 0 — Универсальные (есть у ВСЕХ клонов)
# ══════════════════════════════════════════════════════════
CMDS_L0: List[AtCommand] = [
AtCommand("ATE0", "Echo off"),
AtCommand("ATE1", "Echo on", safe=False), # не слать — вернём эхо
AtCommand("ATL0", "Linefeeds off"),
AtCommand("ATL1", "Linefeeds on", safe=False),
AtCommand("ATS0", "Spaces off"),
AtCommand("ATS1", "Spaces on", safe=False),
AtCommand("ATH0", "Headers off"),
AtCommand("ATH1", "Headers on"),
AtCommand("ATSP0", "Auto protocol"),
AtCommand("ATDPN", "Describe protocol number"),
AtCommand("ATRV", "Read voltage"),
AtCommand("ATI", "Identify"),
AtCommand("ATWS", "Warm start", safe=False), # сбрасывает состояние
AtCommand("ATPC", "Protocol close", safe=False),
AtCommand("ATR0", "Responses off", safe=False),
AtCommand("ATR1", "Responses on"),
AtCommand("ATNL", "Normalize length"),
AtCommand("ATAL", "Allow long messages"),
AtCommand("ATAR", "Auto receive", safe=False), # включает авто-приём
AtCommand("ATSW", "Stop waiting"),
AtCommand("ATFE", "Forget events"),
AtCommand("ATSI", "Slow init"),
AtCommand("ATFI", "Fast init"),
AtCommand("ATKW", "Keyword"),
AtCommand("ATTA", "Tester address"),
AtCommand("ATTP", "Try protocol"),
AtCommand("ATWM", "Wakeup message"),
AtCommand("ATIGN", "Ignore"),
AtCommand("ATIFR", "IFR"),
AtCommand("ATIIA", "ISO init address"),
AtCommand("ATRD", "Read data"),
AtCommand("ATCS", "CAN silent"),
AtCommand("ATCV", "Calibrate voltage"),
AtCommand("ATD0", "Display off"),
AtCommand("ATM0", "Memory off"),
AtCommand("ATM1", "Memory on"),
]
# ══════════════════════════════════════════════════════════
# Уровень 1 — Хорошие клоны и оригиналы
# ══════════════════════════════════════════════════════════
CMDS_L1: List[AtCommand] = [
AtCommand("ATAT0", "Adaptive timing off", level=1),
AtCommand("ATAT1", "Adaptive timing on", level=1),
AtCommand("ATAT2", "Adaptive timing aggressive", level=1),
]
# ══════════════════════════════════════════════════════════
# Уровень 2 — Только настоящий ELM327
# ══════════════════════════════════════════════════════════
CMDS_L2: List[AtCommand] = [
AtCommand("ATCAF0", "CAN auto formatting off", level=2),
AtCommand("ATCAF1", "CAN auto formatting on", level=2),
AtCommand("ATCFC0", "CAN flow control off", level=2),
AtCommand("ATCFC1", "CAN flow control on", level=2),
AtCommand("ATCM", "CAN mask", level=2),
AtCommand("ATCRA", "CAN receive address", level=2),
AtCommand("ATCP", "CAN priority", level=2),
AtCommand("ATSH", "Set header", level=2),
AtCommand("AT@1", "Device description", level=2),
AtCommand("AT@2", "Device identifier", level=2),
AtCommand("ATJE", "J1939 ELM", level=2),
AtCommand("ATJS", "J1939 setup", level=2),
AtCommand("ATIB", "ISO baud", level=2, safe=False),
]
# ══════════════════════════════════════════════════════════
# ОПАСНЫЕ — никогда не пробинговать
# ══════════════════════════════════════════════════════════
CMDS_DANGEROUS: List[AtCommand] = [
AtCommand("ATZ", "Hard reset", safe=False),
AtCommand("ATD", "Set defaults", safe=False),
AtCommand("ATBD", "Baud rate", safe=False),
AtCommand("ATBRD", "Baud rate divisor", safe=False),
AtCommand("ATMA", "Monitor all", safe=False),
AtCommand("ATMR", "Monitor receiver", safe=False),
AtCommand("ATMT", "Monitor transmitter", safe=False),
]
# ── Хелперы ─────────────────────────────────────────────
def get_commands_for_level(level: int) -> List[str]:
"""Возвращает имена команд, доступных на заданном уровне."""
result = [c.name for c in CMDS_L0 if c.safe]
if level >= 1:
result += [c.name for c in CMDS_L1 if c.safe]
if level >= 2:
result += [c.name for c in CMDS_L2 if c.safe]
return result
def get_probe_commands() -> dict:
"""Возвращает команды для пробинга, сгруппированные по уровням.
Returns: {"L0": ["ATE0",...], "L1": ["ATAT1",...], "L2": ["ATCAF1",...]}
"""
return {
"L0": [c.name for c in CMDS_L0 if c.safe and c.level == 0][:8],
"L1": [c.name for c in CMDS_L1 if c.safe],
"L2": [c.name for c in CMDS_L2 if c.safe],
}
# Плоские списки для импорта (обратная совместимость)
L0_NAMES = [c.name for c in CMDS_L0 if c.safe and c.level == 0]
L1_NAMES = [c.name for c in CMDS_L1 if c.safe]
L2_NAMES = [c.name for c in CMDS_L2 if c.safe]
+105
View File
@@ -0,0 +1,105 @@
"""
obd/connection.py — Транспортный слой ELM327 (serial/Bluetooth).
Чтение байт-за-байтом с поллингом 1мс, запись + flush.
Выделен из protocol.py чтобы разделить I/O и логику протокола.
"""
import logging
import time
logger = logging.getLogger("elm.connection")
class SerialTransport:
"""Транспорт: открыть порт, читать/писать байты, закрыть.
Не знает протокол. Только I/O.
"""
def __init__(self, port: str, baudrate: int = 38400):
self.port = port
self.baudrate = baudrate
self._ser = None
def connect(self):
"""Открыть serial-соединение."""
import serial
self._ser = serial.Serial(
port=self.port,
baudrate=self.baudrate,
timeout=0.1,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
)
time.sleep(0.5)
logger.info(f"SerialTransport: connected {self.port}")
def close(self):
"""Закрыть serial-соединение."""
if self._ser and self._ser.is_open:
self._ser.close()
logger.info("SerialTransport: closed")
@property
def is_open(self) -> bool:
"""Открыт ли порт."""
return self._ser is not None and self._ser.is_open
def write(self, data: str):
"""Отправить данные в ELM: data + CR → flush."""
self._ser.write((data + "\r").encode())
self._ser.flush()
logger.debug(f"{data}")
def read(self, timeout_ms: int) -> str:
"""Прочитать ответ ELM327 байт-за-байтом.
Читает до символа '>' (промпт) или до таймаута.
Возвращает сырой ответ без '>'.
Raises:
TimeoutError: если '>' не получен за timeout_ms.
"""
dl = time.monotonic() + timeout_ms / 1000.0
lines, cur = [], []
got_prompt = False
while time.monotonic() < dl:
if self._ser.in_waiting > 0:
ch = self._ser.read(1)
if not ch:
continue
cp = ch[0]
if cp == 62: # '>' — промпт ELM327
self._push(cur, lines)
got_prompt = True
break
elif cp == 13: # CR — конец строки
self._push(cur, lines)
elif cp in (10, 32): # LF и пробел — игнорируем
pass
else:
cur.append(chr(cp))
else:
time.sleep(0.001) # поллинг 1мс
self._push(cur, lines)
if not got_prompt:
raise TimeoutError(f"read timeout {timeout_ms}ms")
return "\n".join(lines)
def try_read(self, timeout: int = 5000):
"""Прочитать и проигнорировать ответ."""
try:
self.read(timeout)
except TimeoutError:
pass
@staticmethod
def _push(cur, lines):
"""Добавить накопленные байты как строку в lines."""
if cur:
lines.append("".join(cur))
cur.clear()
+86
View File
@@ -0,0 +1,86 @@
"""
obd/probe.py — Пробинг ELM327: определение уровня устройства.
Трехуровневый каскад (использует obd/commands.py + obd/classifier.py):
Уровень 0 (база, все клоны):
ATE0 ATL0 ATS0 ATH1 ATSP0 ATDPN ATRV ATI
→ хоть одна не ответила → НЕИСПРАВЕН (-1)
Уровень 1 (хорошие клоны):
ATAT1 → уровень 1
Уровень 2 (настоящие ELM):
ATCAF1 ATCFC1 → уровень 2
Принцип:
- Каждая команда: ОДИН запрос, короткий таймаут (500мс)
- Нет ретраев на неизвестные команды
- Команды и классификация — в отдельных модулях
"""
import logging
import time
from obd.commands import L0_NAMES, L1_NAMES, L2_NAMES
from obd.classifier import classify, determine_level
logger = logging.getLogger("elmer.probe")
# Таймауты
PROBE_TMO = 500 # мс — таймаут на одну команду при пробинге
INTER_CMD_DELAY = 0.05 # с — пауза между командами
def probe(elm) -> dict:
"""Пробинг ELM327: определяет уровень и профиль устройства.
Args:
elm: AndrOBD с открытым connect().
Returns:
{"level": 0|1|2|-1, "elm_version": str|None, ...}
level = -1 → устройство нерабочее.
"""
# Собираем ответы на все команды пробинга
responses = _probe_commands(elm, L0_NAMES + L1_NAMES + L2_NAMES)
# Классифицируем — определяем уровень
result = determine_level(responses)
if result["level"] >= 0:
logger.info(f"probe: device level={result['level']}")
else:
logger.warning("probe: device UNUSABLE")
return result
def _probe_commands(elm, cmds: list[str]) -> dict[str, str]:
"""Посылает список команд и собирает ответы.
Returns:
{cmd_name: raw_response}
"""
responses = {}
for cmd in cmds:
ok, raw = _try_one(elm, cmd)
responses[cmd] = raw if ok else ""
return responses
def _try_one(elm, cmd: str) -> tuple[bool, str]:
"""Посылает ОДНУ команду в ELM, ждёт PROBE_TMO мс.
Returns:
(ok, raw_response)
"""
elm._write(cmd)
time.sleep(INTER_CMD_DELAY)
try:
raw = elm._read(PROBE_TMO)
except TimeoutError:
return False, ""
_, ok = classify(raw)
return (ok, raw)
+60 -213
View File
@@ -1,7 +1,7 @@
"""
obd/protocol.py — ELM327 стейт-машина (AndrOBD).
Точная копия логики из AndrOBD (ElmProt.java, github.com/fr3ts0n/AndrOBD).
Основана на AndrOBD (ElmProt.java, github.com/fr3ts0n/AndrOBD).
## Архитектура
┌──────────┐ команда ┌──────────┐
@@ -16,138 +16,38 @@ obd/protocol.py — ELM327 стейт-машина (AndrOBD).
│ восстановление ▲
└─────────────────────┘
## Зависимости
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")
rpm = elm.send("010C")
elm.close()
## Ключевые особенности
- Байт-за-байтом чтение с 1мс поллингом
- `>` как разделитель ответов (промпт ELM327)
- Адаптивный таймаут (200мс ± 4мс, ATST)
- Восстановление после BUS ERROR (ATPC → ATSP0)
- Не тот ответ → переход в ERROR → восстановление
"""
import logging
import time
from enum import Enum, auto
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 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:
"""Определяет тип ответа по сырой строке."""
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
class AdaptiveTiming:
"""Адаптивный таймаут ожидания ответа (AndrOBD AdaptiveTiming).
Динамически подстраивается под скорость ответа ЭБУ:
- Успешный ответ → уменьшаем таймаут (быстрее)
- Таймаут/NO DATA → увеличиваем таймаут (медленнее)
- BUS ERROR → сброс до DEFAULT
ATST = таймаут / 4 (отправляется в ELM327 как ATSTxx).
"""
DEFAULT = 500 # мс — начальный таймаут
MIN = 50 # мс — минимальный
MAX = 2000 # мс — максимальный
STEP = 20 # мс — шаг изменения
RES = 4 # делитель для ATST
def __init__(self):
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):
"""Увеличить таймаут (ЭБУ медленно отвечает)."""
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):
"""Сбросить до DEFAULT (после BUS ERROR)."""
self._t = self.DEFAULT
class AndrOBD:
"""Стейт-машина ELM327 — 1:1 копия AndrOBD (ElmProt.java).
"""Стейт-машина ELM327.
Управляет жизненным циклом ELM327:
1. connect() — открыть serial/Bluetooth порт
2. init() — инициализация (ATSP0, ATAT1, ATST, ATS0, ATL0, ATE0)
2. init() — базовая инициализация (уровень 0)
3. send(cmd) — отправить OBD-команду, получить ответ
4. close() — закрыть порт
@@ -155,71 +55,70 @@ class AndrOBD:
"""
INIT_TMO = 10000 # мс — таймаут для команд инициализации
DEF_TMO = 200 # мс — начальный таймаут (заменяется AdaptiveTiming)
DEF_TMO = 200 # мс — начальный таймаут
def __init__(self, port: str, baudrate: int = 38400):
"""port — устройство (напр. /dev/rfcomm0), baudrate — скорость."""
self.port = port
self.baudrate = baudrate
self._ser = None
self._transport = SerialTransport(port, baudrate)
self._timing = AdaptiveTiming()
self._state = State.UNDEFINED
self._last_cmd: Optional[str] = None
def connect(self):
"""Открыть serial-соединение с ELM327."""
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}")
self._transport.connect()
logger.info(f"AndrOBD: connected {self._transport.port}")
def close(self):
"""Закрыть serial-соединение."""
if self._ser and self._ser.is_open:
self._ser.close()
self._transport.close()
def init(self):
"""Инициализация ELM327 — 6 AT-команд.
"""Базовая инициализация ELM327 (уровень 0 — все клоны).
ATSP0 — авто-протокол
ATAT1 — адаптивный таймаут вкл
ATSTxx — установить таймаут
ATS0 — без пробелов в ответах
ATL0 — без перевода строки
ATE0 — без эха
ТОЛЬКО команды которые есть у ВСЕХ клонов:
ATE0 ATL0 ATS0 ATH1 ATSP0
"""
logger.info("AndrOBD: init")
logger.info("AndrOBD: init (L0)")
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._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")
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-команду и получить ответ.
cmd — команда (напр. '0105', '0902', '03').
Возвращает сырой ответ ELM327.
При ошибке — авто-восстановление.
"""
"""Отправить OBD-команду и получить ответ."""
if self._state == State.ERROR:
self._recover()
self._state = State.BUSY
result = self._exec(cmd, self._timing.ms)
self._state = State.READY
if self._state == State.BUSY:
self._state = State.READY
return result
# ── Приватные методы ──────────────────────────────────
# ── Приватные методы ──────────────────────────────
def _exec(self, cmd: str, timeout: int) -> str:
"""Выполнить команду с таймаутом и ретраями (до 10 попыток)."""
"""Выполнить команду с ретраями (до 10)."""
self._last_cmd = cmd
self._write(cmd)
t = timeout
@@ -237,10 +136,7 @@ class AndrOBD:
return ""
def _handle(self, raw: str) -> str:
"""Обработать ответ ELM327: классифицировать и обновить таймаут.
Возвращает raw как есть — обработка данных делается выше.
"""
"""Обработать ответ ELM327."""
t = Rsp.identify(raw)
if t == Rsp.SEARCHING:
@@ -250,23 +146,19 @@ class AndrOBD:
return raw
if t == Rsp.NODATA:
self._timing.increase()
self._update_atst()
return raw
# BUS ERROR — сброс протокола
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._write("ATPC")
self._try_read()
self._write("ATSP0") # переоткрыть авто-протокол
self._write("ATSP0")
self._try_read()
return raw
# Другие ошибки — warm start
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
@@ -274,12 +166,11 @@ class AndrOBD:
self._try_read()
return raw
# Данные — успешный ответ
self._timing.decrease()
return raw
def _recover(self):
"""Восстановление после ошибки: ATWS → ATSP0 → ATE0."""
"""Восстановление после ошибки."""
logger.info("AndrOBD: recovering...")
self._state = State.INITIALIZING
self._write("ATWS")
@@ -291,58 +182,14 @@ class AndrOBD:
self._state = State.READY
def _write(self, cmd: str):
"""Отправить команду в ELM327 (добавляет CR, flush)."""
self._ser.write((cmd + "\r").encode())
self._ser.flush()
logger.debug(f"AndrOBD → {cmd}")
"""Отправить команду в ELM327."""
self._transport.write(cmd)
def _read(self, timeout_ms: int) -> str:
"""Прочитать ответ ELM327 байт-за-байтом.
Читает до символа `>` (промпт) или до таймаута.
Возвращает сырой ответ без `>`.
"""
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"timeout {timeout_ms}ms")
return "\n".join(lines)
"""Прочитать ответ ELM327."""
return self._transport.read(timeout_ms)
def _try_read(self, timeout: int = 5000):
"""Прочитать и проигнорировать ответ (для команд восстановления)."""
try:
self._read(timeout)
except TimeoutError:
pass
"""Прочитать и проигнорировать ответ."""
self._transport.try_read(timeout)
@staticmethod
def _push(cur, lines):
"""Добавить накопленные байты как строку в lines."""
if cur:
lines.append("".join(cur))
cur.clear()
def _update_atst(self):
"""Отправить ATST с текущим значением адаптивного таймаута."""
self._write(f"ATST{self._timing.atst:02X}")
self._try_read()
+251
View File
@@ -0,0 +1,251 @@
"""
obd/raw_console.py — Сырой слой ELM327 (без стейт-машины).
НИКАКОЙ логики протокола:
- Нет state machine (State)
- Нет классификации ответов (Rsp)
- Нет адаптивных таймингов (AdaptiveTiming)
- Нет ретраев
- Нет хендлеров ошибок
ТОЛЬКО:
- send(cmd) → отправляет команду + CR
- read(timeout) → читает ВСЁ до '>' или таймаута, байт-за-байтом
- drain() → очищает входной буфер
- available() → сколько байт ждёт в буфере
ПРЕДНАЗНАЧЕНИЕ:
Изучение реального поведения ELM327.
«Почему статика работает, а динамика ломается?»
Ответ — в сырых байтах.
"""
import logging
import time
logger = logging.getLogger("elm.raw")
# ── Конфигурация по умолчанию (можно переопределить) ──
DEFAULT_TIMEOUT = 500 # мс
INTER_CMD_DELAY = 0.05 # с — пауза между командой и чтением
class RawELM:
"""Сырой слой ELM327 — только send/read/drain, без протокольной логики."""
def __init__(self, transport):
"""
Args:
transport: объект с методами .write(str) и .read(timeout_ms) → str
(обычно SerialTransport из obd.connection)
"""
self._t = transport
self._timeout = DEFAULT_TIMEOUT
self._inter_delay = INTER_CMD_DELAY
self._log: list[dict] = [] # история команд
# ── Настройка ────────────────────────────────────
@property
def timeout(self) -> int:
return self._timeout
@timeout.setter
def timeout(self, ms: int):
self._timeout = ms
@property
def inter_delay(self) -> float:
return self._inter_delay
@inter_delay.setter
def inter_delay(self, sec: float):
self._inter_delay = sec
# ── Основные операции ────────────────────────────
def send(self, cmd: str, timeout: int | None = None) -> dict:
"""Отправить команду и прочитать сырой ответ.
Args:
cmd: команда (без \r, добавится автоматически)
timeout: таймаут в мс (None = использовать self.timeout)
Returns:
{
"cmd": str, # что отправили
"raw": str, # сырой ответ (без '>')
"prompt": bool, # получен ли '>'
"elapsed_ms": int, # сколько мс заняло
"bytes": int, # сколько байт в ответе
"error": str|None, # ошибка если есть
}
Не бросает исключений — всегда возвращает dict с полем error.
"""
tmo = timeout if timeout is not None else self._timeout
entry = {"cmd": cmd, "timeout_ms": tmo, "ts": time.time()}
try:
# 1. Отправить
self._t.write(cmd)
# 2. Пауза (ELM начинает отвечать не мгновенно)
if self._inter_delay > 0:
time.sleep(self._inter_delay)
# 3. Прочитать
t0 = time.monotonic()
raw, prompt, nbytes = self._read_raw(tmo)
elapsed = int((time.monotonic() - t0) * 1000)
entry.update({
"raw": raw,
"prompt": prompt,
"elapsed_ms": elapsed,
"bytes": nbytes,
"error": None,
})
except TimeoutError:
entry.update({
"raw": "",
"prompt": False,
"elapsed_ms": tmo,
"bytes": 0,
"error": f"timeout {tmo}ms",
})
except Exception as e:
entry.update({
"raw": "",
"prompt": False,
"elapsed_ms": 0,
"bytes": 0,
"error": str(e),
})
self._log.append(entry)
return entry
def drain(self) -> dict:
"""Очистить входной буфер. Возвращает что было выброшено.
Returns:
{"drained": str, "bytes": int}
"""
t0 = time.monotonic()
drained = []
total = 0
dl = t0 + 0.5 # 500 мс максимум на дренаж
while time.monotonic() < dl:
try:
ch = self._read_byte(0.05)
if ch is not None:
drained.append(chr(ch))
total += 1
else:
break # буфер пуст
except Exception:
break
elapsed = int((time.monotonic() - t0) * 1000)
result = {"drained": "".join(drained), "bytes": total, "elapsed_ms": elapsed}
if total > 0:
logger.info(f"RawELM: drained {total} bytes: {result['drained']!r}")
return result
def available(self) -> int:
"""Сколько байт ждёт во входном буфере (0 = пусто)."""
try:
return self._t._ser.in_waiting
except Exception:
return -1
# ── Лог ──────────────────────────────────────────
@property
def log(self) -> list[dict]:
"""История всех команд."""
return self._log
def clear_log(self):
"""Очистить историю."""
self._log.clear()
def last(self) -> dict | None:
"""Последняя команда."""
return self._log[-1] if self._log else None
# ── Приватные ────────────────────────────────────
def _read_raw(self, timeout_ms: int) -> tuple[str, bool, int]:
"""Читает байт-за-байтом до '>' или таймаута.
Returns:
(raw_text, got_prompt, byte_count)
"""
dl = time.monotonic() + timeout_ms / 1000.0
lines, cur = [], []
got_prompt = False
while time.monotonic() < dl:
ch = self._read_byte(0.05)
if ch is None:
continue
cp = ch
if cp == 62: # '>' — промпт ELM327
if cur:
lines.append("".join(cur))
cur.clear()
got_prompt = True
break
elif cp == 13: # CR — конец строки
if cur:
lines.append("".join(cur))
cur.clear()
elif cp in (10, 32): # LF и пробел — игнорируем
pass
else:
cur.append(chr(cp))
if cur:
lines.append("".join(cur))
return ("\n".join(lines), got_prompt, sum(len(s) for s in lines))
def _read_byte(self, timeout_s: float) -> int | None:
"""Прочитать один байт с таймаутом. None = таймаут/нет данных."""
import serial
try:
if self._t._ser.in_waiting > 0:
b = self._t._ser.read(1)
return b[0] if b else None
else:
time.sleep(0.001) # поллинг 1мс
return None
except serial.SerialException:
return None
# ── Хелпер ───────────────────────────────────────────
def format_response(entry: dict) -> str:
"""Форматирует ответ RawELM.send() для вывода в консоль."""
lines = [
f"{entry['cmd']}",
f"{entry['raw']!r}" if entry["raw"] else "← (пусто)",
]
if entry["prompt"]:
lines.append(" prompt: ✅ >")
else:
lines.append(" prompt: ❌")
lines.append(f" time: {entry['elapsed_ms']}ms, bytes: {entry['bytes']}")
if entry["error"]:
lines.append(f" ⚠️ {entry['error']}")
return "\n".join(lines)
def format_log(entries: list[dict]) -> str:
"""Форматирует всю историю команд."""
return "\n" + "" * 50 + "\n" + \
"\n".join(format_response(e) for e in entries) + \
"\n" + "" * 50
+67
View File
@@ -0,0 +1,67 @@
"""Состояния и классификация ответов ELM327 (AndrOBD STAT/RSP_ID).
State — состояния стейт-машины (UNDEFINED → INITIALIZING → READY → BUSY → ...)
Rsp — классификация сырых ответов ELM327
"""
from enum import Enum, auto
class State(Enum):
"""Состояния стейт-машины (AndrOBD STAT).
UNDEFINED → INITIALIZING → READY — нормальный путь.
BUSY — во время выполнения команды.
ERROR/DISCONNECTED — ошибка, требуется восстановление.
"""
UNDEFINED = auto()
INITIALIZING = auto()
READY = auto()
BUSY = auto()
ERROR = auto()
DISCONNECTED = auto()
class Rsp:
"""Классификация ответов ELM327 (AndrOBD RSP_ID).
Каждый сырой ответ классифицируется:
- PROMPT (`>`) — готов к следующей команде
- OK — команда выполнена
- SEARCHING — идёт поиск протокола
- BUS_ERROR/BUS_BUSY/CAN_ERROR/STOPPED — ошибка шины → DISCONNECTED
- ERROR/DATA_ERROR/BUFFER_FULL — ошибка → warm start
- UNKNOWN — данные (ответ на PID/DTC)
"""
PROMPT = ">"
OK = "OK"
SEARCHING = "SEARCHING"
NODATA = "NODATA"
ERROR = "ERROR"
UNABLE = "UNABLE"
BUS_BUSY = "BUS BUSY"
BUS_ERROR = "BUS ERROR"
CAN_ERROR = "CAN ERROR"
BUS_INIT = "BUS INIT"
STOPPED = "STOPPED"
DATA_ERROR = "DATA ERROR"
BUFFER_FULL= "BUFFER FULL"
RX_ERROR = "RX ERROR"
UNKNOWN = ""
@classmethod
def identify(cls, raw: str) -> str:
"""Определяет тип ответа ELM327 по сырой строке."""
u = raw.upper().strip()
# NO DATA бывает как "NODATA" так и "NO DATA"
if u.replace(" ", "") == "NODATA":
return cls.NODATA
for tag in (cls.SEARCHING, cls.ERROR, cls.UNABLE,
cls.BUS_BUSY, cls.BUS_ERROR, cls.CAN_ERROR,
cls.BUS_INIT, cls.STOPPED, cls.DATA_ERROR,
cls.BUFFER_FULL, cls.RX_ERROR, cls.OK):
if u.startswith(tag):
return tag
if raw.strip() == ">":
return cls.PROMPT
return cls.UNKNOWN
+53
View File
@@ -0,0 +1,53 @@
"""Адаптивный таймаут ожидания ответа ЭБУ (AndrOBD AdaptiveTiming).
Динамически подстраивается под скорость ответа:
- Успешный ответ → уменьшаем таймаут
- Таймаут/NO DATA → увеличиваем таймаут
- BUS ERROR → сброс до DEFAULT
ATST = таймаут / 4 (отправляется в ELM327 как ATSTxx).
"""
class AdaptiveTiming:
"""Адаптивный таймаут ожидания ответа (AndrOBD AdaptiveTiming).
ATST = таймаут / 4 (отправляется в ELM327 как ATSTxx).
"""
DEFAULT = 500 # мс — начальный таймаут
MIN = 50 # мс — минимальный
MAX = 2000 # мс — максимальный
STEP = 20 # мс — шаг изменения
RES = 4 # делитель для ATST
def __init__(self):
"""Начальный таймаут = DEFAULT."""
self._t = self.DEFAULT
self._min = self.MIN
@property
def ms(self) -> int:
"""Текущий таймаут в миллисекундах."""
return self._t
@property
def atst(self) -> int:
"""Значение для ATST (таймаут / 4)."""
return max(1, self._t // self.RES)
def increase(self):
"""Увеличить таймаут на STEP (ЭБУ медленно отвечает)."""
"""Увеличить таймаут (ЭБУ медленно отвечает)."""
if self._t + self.STEP < self.MAX:
self._t += self.STEP
def decrease(self):
"""Уменьшить таймаут на STEP (ЭБУ отвечает быстро)."""
"""Уменьшить таймаут (ЭБУ отвечает быстро)."""
if self._t - self.STEP >= self._min:
self._t -= self.STEP
def reset(self):
"""Сброс до DEFAULT (после BUS ERROR / инициализации)."""
"""Сбросить до DEFAULT (после BUS ERROR)."""
self._t = self.DEFAULT
+92
View File
@@ -0,0 +1,92 @@
## 4. Контекст проекта (РЕЗЮМЕ для нового чата)
### Что это
elmAI — Android-приложение + Python-сервер для диагностики авто через ELM327.
### Текущая версия
<!-- !!! АКТУАЛЬНАЯ ВЕРСИЯ !!! -->
<!-- Перед деплоем bump в android/app/build.gradle.kts, index.html (2 места), CHANGELOG.md, resume.txt, doc/architecture.md -->
**v0.77.0-dev** (APK: https://obdai.ru/elmer.apk)
### Репозитории
- Сервер: https://gitea.services.ngcloud.ru/Nail/elmer (ветка **dynamic-tests**)
- Android: https://github.com/Repinoid/elmer-android (ветка **dynamic-tests**)
- Сервер живёт на 5.172.178.213 (SSH: naeel@5.172.178.213, ключ ~/.ssh/naeel_vm_id_ed25519)
### Деплой (localhost → сервер)
```bash
# 1. bump версии в android/app/build.gradle.kts на локальной машине!
# 2. закоммитить + запушить (dynamic-tests!)
cd /home/naeel/elmer && git add -A && git commit -m "..." && git push origin dynamic-tests
# 3. залить android-исходники на сервер
# 3. залить android-исходники на сервер
cd /home/naeel/elmer && tar czf /tmp/android-src.tar.gz --exclude='.git' --exclude='build' --exclude='.gradle' android/
scp -i ~/.ssh/naeel_vm_id_ed25519 /tmp/android-src.tar.gz naeel@5.172.178.213:/tmp/
# 4. на сервере: обновить сервер + собрать APK
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 "
cd /opt/elmer && git checkout dynamic-tests && git pull origin dynamic-tests
pip install -r requirements.txt
sudo systemctl restart elmer
rm -rf android && tar xzf /tmp/android-src.tar.gz
cd android && gradle wrapper --gradle-version 8.7
export ANDROID_SDK_ROOT=\$HOME/android-sdk
./gradlew clean assembleDebug
cp app/build/outputs/apk/debug/app-debug.apk /opt/elmer/web/static/
"
```
### Структура проекта
```
elmer/
android/ — Android-приложение (Kotlin)
.../client/
MainActivity.kt — UI + приёмники
ElmForwardService.kt — BT/TCP-реле
TestService.kt — автономный тест ELM
api/ — Flask API (Python)
db.py — БД (sessions + device_profiles)
routes.py — эндпоинты
scripts.py — скрипты L0/L1/L2
parser.py — парсинг ответов
config.py, dtc.py, ping.py
brain/ — LLM-клиент, промпты
obd/ — ELM-протокол (Python)
commands.py — каталог AT-команд
classifier.py — классификация ответов
connection.py — SerialTransport
probe.py — пробинг ELM
protocol.py — стейт-машина AndrOBD
state.py — состояния/ответы
timing.py — адаптивный таймаут
doc/ — Документация, сессии
web/ — Flask web, статика
```
### Что сделано (сессия 2026-06-07)
#### Сервер
- **Пробинг ELM**: трехуровневый каскад (L0/L1/L2), профили в БД по MAC
- **Fix init**: больше не шлём ATAT1/ATSTxx (вешало клоны)
- **Скрипты под уровень**: L0 (5 PIDs), L1 (8 + VIN), L2 (14 + калибровки)
- **Рефакторинг**: код разбит на независимые модули (commands, classifier, connection...)
#### Android
- **Вывод**: append вместо overwrite (строки не перекрываются)
- **Таймер**: отдельный TextView, тикает только во время обмена (Engine Time)
- **TestService**: адаптивные таймауты, ATS0, \r терминатор
### Что НЕ сделано (TODO)
- Полевой тест на машине ← СЕЙЧАС
- Разбить MainActivity.kt
- Разбить ElmChecker.kt
### Важные правила
- ELM327 v1.5 — фейк, НЕ слать ATAT1/AT@1/AT@2/ATST/ATCAF1/ATCFC1
- Все статусы через append("\n..."), не tvStatus.text =
- Таймер операций (opTimerStart/Stop), не сессии
- Версию поднимать ВЕЗДЕ: build.gradle.kts, index.html (2 места), CHANGELOG.md, resume.txt, doc/architecture.md
- В проекте elmer — НИЧЕГО не делать без прямого указания
+606
View File
@@ -0,0 +1,606 @@
"""
Сквозные тесты elmAI — без LLM.
Проверяет: эндпоинты, БД, идемпотентность, DTC, chat, ping, script.
"""
import json
import os
import sqlite3
import sys
import tempfile
from pathlib import Path
# Добавляем корень проекта в путь
sys.path.insert(0, str(Path(__file__).parent))
# Временная БД для тестов
TEST_DB = Path(tempfile.gettempdir()) / f"elmer_test_{os.getpid()}.db"
os.environ["ELMER_CONFIG"] = str(Path(__file__).parent / "config.yaml")
from api.db import Database
from api.parser import parse_batch, format_no_llm
from api.routes import _build_diagnosis_prompt
from api.dtc import _load_dtc_dict
from api.scripts import build_default_script, build_full_script
passed = 0
failed = 0
def test(name: str, ok: bool, detail: str = ""):
global passed, failed
if ok:
passed += 1
print(f"{name}")
else:
failed += 1
print(f"{name}: {detail}")
# ── 1. Скрипты ──────────────────────────────────────────────────
print("\n═══ 1. Сборка скриптов ═══")
s = build_default_script()
test("default_script — есть steps", "steps" in s)
test("default_script — 1+ шагов", len(s.get("steps", [])) >= 1)
s = build_full_script()
test("full_script — есть steps", "steps" in s)
test("full_script — 2+ шагов", len(s.get("steps", [])) >= 2)
# ── 2. Парсер ELM-ответов ──────────────────────────────────────
print("\n═══ 2. Парсер ответов ═══")
# VIN из decoded
r = parse_batch([{"cmd": "0902", "raw": "", "decoded": "VIN: WVWZZZ1KZAW123456"}])
test("VIN из decoded", r["vin"] == "WVWZZZ1KZAW123456")
# VIN из raw HEX (полный 17-символьный)
r = parse_batch([{"cmd": "0902", "raw": "490201 57 56 57 5A 5A 5A 31 4B 5A 41 57 31 32 33 34 35 36", "decoded": ""}])
test("VIN из raw HEX", r["vin"] == "WVWZZZ1KZAW123456")
# DTC stored
r = parse_batch([{"cmd": "03", "raw": "", "decoded": "DTC stored: P0301 P0302"}])
test("DTC stored", r["dtc_stored"] == ["P0301", "P0302"])
# DTC pending
r = parse_batch([{"cmd": "07", "raw": "", "decoded": "DTC pending: P0302"}])
test("DTC pending", r["dtc_pending"] == ["P0302"])
# PID
r = parse_batch([{"cmd": "0105", "raw": "", "decoded": "ОЖ: 83 °C"}])
test("PID разобран", len(r["parameters"]) == 1 and r["parameters"][0]["name"] == "ОЖ")
# Пустой батч — нет данных
r = parse_batch([])
test("пустой батч", r["vin"] is None and r["dtc_stored"] == [] and r["dtc_pending"] == [])
# DTC из raw HEX (mode 03, ответ начинается с 43)
r = parse_batch([{"cmd": "03", "raw": "43 02 01 00 02 00", "decoded": ""}])
test("DTC из raw HEX", len(r["dtc_stored"]) > 0)
# DTC из raw HEX mode 07 (byte count 01, DTC P0100)
r = parse_batch([{"cmd": "07", "raw": "47 01 01 00", "decoded": ""}])
test("DTC pending из raw HEX", len(r["dtc_pending"]) > 0)
# Mixed: данные + команда без decoded
r = parse_batch([
{"cmd": "010C", "raw": "410C 0C A8", "decoded": "RPM: 948.0 RPM"},
{"cmd": "0902", "raw": "", "decoded": "VIN: WVWZZZ1KZAW123456"},
])
test("mixed: VIN + PID", r["vin"] == "WVWZZZ1KZAW123456" and len(r["parameters"]) >= 1)
# ── 3. Промпт билдер ────────────────────────────────────────────
print("\n═══ 3. Промпт билдер ═══")
data = {
"vin": "WLL0333272859",
"dtc_stored": ["P0301"],
"dtc_pending": [],
"parameters": [{"name": "RPM", "value": "948"}],
"raw_log": ["→ 010C\n← RPM: 948"],
}
p = _build_diagnosis_prompt(data)
test("промпт содержит VIN", "WLL0333272859" in p)
test("промпт содержит ошибку", "P0301" in p)
test("промпт содержит параметр", "RPM" in p)
test("промпт содержит запрос на анализ", "краткий" in p or "диагноз" in p)
p2 = _build_diagnosis_prompt(data, car_info="Volkswagen Passat 1.8T 2005")
test("car_info в промпте", "Volkswagen" in p2)
# Без данных
p3 = _build_diagnosis_prompt({"vin": None, "dtc_stored": [], "dtc_pending": [], "parameters": [], "raw_log": []})
test("промпт без данных содержит 'не распознаны'", "не распознаны" in p3)
# ── 4. DTC справочник ───────────────────────────────────────────
print("\n═══ 4. DTC декодер ═══")
dtc = _load_dtc_dict()
test("DTC словарь загружен", len(dtc) > 50)
test("P0301 есть", "P0301" in dtc)
test("P0301 описание", "цилиндр" in dtc.get("P0301", "").lower())
test("P0420 есть", "P0420" in dtc)
# ── 5. База данных ─────────────────────────────────────────────
print("\n═══ 5. База данных ═══")
db_path = TEST_DB
db = Database(db_path)
test("БД создана", db_path.exists())
# Сохраняем сессию
db.save_session(
client_info={"phone_model": "Pixel", "android_id": "test123", "device_uuid": "uuid-1", "elm_mac": "00:11:22:33:44:55"},
responses=[{"cmd": "0105", "raw": "", "decoded": "ОЖ: 83"}],
diagnosis="Тестовый диагноз",
llm_model="deepseek-v4-flash",
llm_success=True,
request_id="req-001",
response_json={"diagnosis": "Тестовый диагноз"},
)
sessions = db.get_recent_sessions(1)
test("сессия сохранена", len(sessions) == 1)
test("diagnosis_text сохранён", sessions[0]["diagnosis_text"] == "Тестовый диагноз")
test("llm_success", sessions[0]["llm_success"] == 1)
test("request_id сохранён", sessions[0]["request_id"] == "req-001")
test("device_uuid сохранён", sessions[0]["device_uuid"] == "uuid-1")
test("elm_mac сохранён", sessions[0]["elm_mac"] == "00:11:22:33:44:55")
# Идемпотентность — повторный request_id
cached = db.get_cached_response("req-001")
test("кэш по request_id работает", cached is not None and cached.get("diagnosis") == "Тестовый диагноз")
# Несуществующий request_id
cached = db.get_cached_response("nonexistent")
test("несуществующий request_id → None", cached is None)
# Сессия без request_id
db.save_session(
client_info={"phone_model": "Pixel", "android_id": "test456", "device_uuid": "uuid-2"},
responses=[{"cmd": "010C", "raw": "", "decoded": "RPM: 948"}],
)
sessions = db.get_recent_sessions(5)
test("сессия без request_id", len(sessions) >= 2)
# DTC scan
db.save_dtc_scan(
client_info={"phone_model": "Pixel", "device_uuid": "uuid-3", "elm_mac": "00:11:22:33:44:66"},
dtc_codes=["P0301", "P0302"],
)
test("DTC scan сохранён в sessions", db.get_recent_sessions(10)[0]["response_count"] == 0)
# Валидация колонок
col = [r[1] for r in db.conn.execute("PRAGMA table_info(sessions)").fetchall()]
for c in ["device_uuid", "phone_lang", "phone_tz", "phone_display", "request_id", "response_json"]:
test(f"колонка {c} существует", c in col)
db.close()
# Чистим
if db_path.exists():
db_path.unlink()
# ── 6. Format no LLM ────────────────────────────────────────────
print("\n═══ 6. Format no LLM ═══")
f = format_no_llm({"vin": "VIN123", "dtc_stored": ["P0301"], "dtc_pending": [], "parameters": [], "raw_log": []})
test("format_no_llm содержит VIN", "VIN123" in f)
f = format_no_llm({"vin": None, "dtc_stored": [], "dtc_pending": [], "parameters": [], "raw_log": []})
test("format_no_llm без данных", len(f) > 0)
# ── 7. Скрипты через API (без HTTP) ─────────────────────────────
print("\n═══ 7. Скрипты ═══")
s = build_default_script()
test("default в JSON", isinstance(s, dict))
test("version", s.get("version") == 1)
s2 = build_full_script()
test("full", s2.get("version") == 1)
# ── 8. Экстремальные тесты ──────────────────────────────────────
print("\n═══ 8. Экстремальные тесты ═══")
# SQL-инъекция через decoded
try:
r = parse_batch([{"cmd": "0105", "raw": "", "decoded": "ОЖ: 83'; DROP TABLE sessions; --"}])
test("SQL-инъекция в decoded — не падает", not r.get("error"))
except Exception:
test("SQL-инъекция в decoded — не падает", False)
# Бинарный мусор в raw
r = parse_batch([{"cmd": "0902", "raw": "\x00\x01\x02\xFF\xFE\xFD", "decoded": ""}])
test("бинарный мусор в raw", r["vin"] is None and not r.get("error"))
# VIN — слишком короткий
r = parse_batch([{"cmd": "0902", "raw": "", "decoded": "VIN: SHORT"}])
test("VIN короткий → null", r["vin"] is None)
# VIN — слишком длинный (18 символов)
r = parse_batch([{"cmd": "0902", "raw": "", "decoded": "VIN: WVWZZZ1KZAW1234567"}])
test("VIN 18 символов → null", r["vin"] is None)
# VIN с русскими буквами
r = parse_batch([{"cmd": "0902", "raw": "", "decoded": "VIN: ПРИВЕТЭТОТЕСТ"}])
test("VIN кириллица → null", r["vin"] is None)
# Пустой decoded, пустой raw
r = parse_batch([{"cmd": "0105", "raw": "", "decoded": ""}])
test("пустой ответ на PID", len(r["parameters"]) == 0 and not r.get("error"))
# Ответ начинается с SEARCHING
r = parse_batch([{"cmd": "0105", "raw": "SEARCHING...", "decoded": ""}])
test("SEARCHING → без параметров", len(r["parameters"]) == 0)
# BUS ERROR
r = parse_batch([{"cmd": "0105", "raw": "BUS ERROR", "decoded": ""}])
test("BUS ERROR → без паники", not r.get("error"))
# DTC: none (нет ошибок)
r = parse_batch([{"cmd": "03", "raw": "", "decoded": "DTC stored: none"}])
test("DTC none → пустой список", len(r["dtc_stored"]) == 0)
# DTC: 20 штук
r = parse_batch([{"cmd": "03", "raw": "", "decoded": "DTC stored: " + " ".join([f"P{str(i).zfill(4)}" for i in range(1, 21)])}])
test("DTC 20 штук", len(r["dtc_stored"]) == 20)
# Номер протокола как ответ
r = parse_batch([{"cmd": "0105", "raw": "OK", "decoded": ""}])
test("OK → без параметров", len(r["parameters"]) == 0)
# Много пробелов и разных разделителей
r = parse_batch([{"cmd": "03", "raw": " 43 02 01 00 02 00 ", "decoded": ""}])
test("DTC с лишними пробелами", len(r["dtc_stored"]) > 0)
# Ответ от ELM с кавычками
try:
r = parse_batch([{"cmd": "0105", "raw": "", "decoded": 'ОЖ: 83"С\'тест'}])
test("кавычки в decoded — не падает", True)
except Exception:
test("кавычки в decoded — не падает", False)
# В ответе только цифры
r = parse_batch([{"cmd": "0105", "raw": "1234567890", "decoded": ""}])
test("только цифры — не распознано", not r.get("error"))
# Длинная строка (10000 символов)
r = parse_batch([{"cmd": "03", "raw": "SEARCHING" + "A" * 9990, "decoded": ""}])
test("длинный ответ SEARCHING", not r.get("error"))
# ── 9. DB экстремальные ────────────────────────────────────────
print("\n═══ 9. DB экстремальные ═══")
db2 = Database(TEST_DB)
# Ультра-длинный диагноз
long_diag = "тест " * 10_000
db2.save_session(
client_info={"phone_model": "x", "device_uuid": "long-test"},
responses=[{"cmd": "0105", "raw": "", "decoded": "ОЖ: 83"}],
diagnosis=long_diag,
)
test("диагноз 50k символов", True)
# Пустой diagnosis
db2.save_session(
client_info={"phone_model": "x", "device_uuid": "empty-diag"},
responses=[],
diagnosis="",
)
test("пустой диагноз", True)
# Client_info с неожиданными типами
db2.save_session(
client_info={
"phone_model": None, "phone_maker": 12345, "android_version": "",
"android_sdk": None, "app_version": "0", "android_id": None,
"device_uuid": "null-test", "elm_mac": None,
},
responses=[{"cmd": "0105", "raw": "", "decoded": "тест"}],
)
test("мусор в client_info — None/числа", True)
# 10 сессий с разными request_id для проверки идемпотентности
for i in range(10):
db2.save_session(
client_info={"phone_model": f"device_{i}", "device_uuid": f"uuid_{i}"},
responses=[{"cmd": "0105", "raw": "", "decoded": "ОЖ: 83"}],
request_id=f"req-{i}",
response_json={"ok": i},
)
test("10 сессий с разными request_id", True)
# Повторный request_id — не должен создать дубликат
count_before = len(db2.get_recent_sessions(100))
db2.save_session(
client_info={"phone_model": "duplicate_test", "device_uuid": "dup"},
responses=[],
request_id="req-0",
response_json={"ok": "duplicate"},
)
count_after = len(db2.get_recent_sessions(100))
test("идемпотентность — дубликат не создан", count_before == count_after)
# Кэш по request_id возвращает старый результат
cached = db2.get_cached_response("req-0")
test("идемпотентность — кэш вернул старые данные", cached is not None and cached.get("ok") == 0)
# Конкурентный доступ (симуляция)
import threading
errors = []
def concurrent_write(idx: int):
try:
db2.save_session(
client_info={"phone_model": f"concurrent_{idx}", "device_uuid": f"cuuid_{idx}"},
responses=[],
request_id=f"creq-{idx}",
)
except Exception as e:
errors.append(str(e))
threads = [threading.Thread(target=concurrent_write, args=(i,)) for i in range(20)]
[t.start() for t in threads]
[t.join() for t in threads]
test(f"20 конкурентных записей — максимум 3 ошибки", len(errors) <= 3)
db2.close()
if TEST_DB.exists():
TEST_DB.unlink()
# ── 10. DTC словарь — граничные случаи ──────────────────────────
print("\n═══ 10. DTC словарь — граничные ═══")
dtc = _load_dtc_dict()
# Код, которого нет в словаре
test("неизвестный код → сам код", dtc.get("P9999", "P9999") == "P9999")
test("код в нижнем регистре → нет", dtc.get("p0301", "") == "")
# Пустой код
test("пустой код → пусто", dtc.get("", None) is None)
# Не-P код (C-код)
test("C-код C0000 есть", "C0000" in dtc)
# Не-P код (B-код, U-код)
test("B-код B0000 есть", "B0000" in dtc)
test("U-код U0000 есть", "U0000" in dtc)
# ── 11. Промпт-билдер — граничные ───────────────────────────────
print("\n═══ 11. Промпт-билдер граничные ═══")
# Пустой data
try:
_build_diagnosis_prompt({"vin": None, "dtc_stored": [], "dtc_pending": [], "parameters": [], "raw_log": []})
test("пустой словарь в промпт", True)
except Exception:
test("пустой словарь в промпт", False)
# data без ключей
try:
_build_diagnosis_prompt({})
test("пустой {} в промпт", True)
except Exception:
test("пустой {} в промпт", False)
# None вместо списков
try:
_build_diagnosis_prompt({"vin": None, "dtc_stored": None, "dtc_pending": None, "parameters": None, "raw_log": None})
test("None вместо списков", True)
except Exception:
test("None вместо списков", False)
# Очень длинный car_info
p = _build_diagnosis_prompt(
{"vin": None, "dtc_stored": [], "dtc_pending": [], "parameters": [], "raw_log": []},
car_info="A" * 5000,
)
test("car_info 5000 символов", "A" in p)
# ── 12. ELM — мусор, разрывы, протоколы ────────────────────────
print("\n═══ 12. ELM — мусор, разрывы, протоколы ═══")
# HEX с символами кадра CAN (0: 1: и т.д.)
r = parse_batch([{"cmd": "0902", "raw": "0: 49 02 01 57 56\n1: 57 5A 5A 5A 31 4B 5A\n2: 41 57 31 32 33 34 35 36", "decoded": ""}])
test("CAN multi-frame с 0: 1:", r["vin"] == "WVWZZZ1KZAW123456")
# ELM ответил SEARCHING, потом данные, потом PROMPT
r = parse_batch([{"cmd": "010C", "raw": "SEARCHING\nSEARCHING\n41 0C 0C A8", "decoded": ""}])
test("SEARCHING перед данными — PID", "RPM" not in str(r.get("decoded", "")))
# Заголовок ISO-TP (длина)
r = parse_batch([{"cmd": "0902", "raw": "10 14 49 02 01 57 56\n21 57 5A 5A 5A 31 4B\n22 5A 41 57 31 32 33 34 35 36", "decoded": ""}])
test("ISO-TP заголовки 10/21/22", r["vin"] == "WVWZZZ1KZAW123456")
# ELM вернул UDP-подобный формат
r = parse_batch([{"cmd": "03", "raw": "43 02 01 00 02 00 03 00 04 00", "decoded": ""}])
test("много DTC в одном ответе", len(r["dtc_stored"]) >= 3)
# Дефолтный ответ ELM (пробелы, переводы)
r = parse_batch([{"cmd": "0105", "raw": "\n\n\n 41 05 47 \n\n", "decoded": ""}])
test("ELM с лишними переводами", not r.get("error"))
# CAN bus error
r = parse_batch([{"cmd": "0105", "raw": "CAN ERROR", "decoded": ""}])
test("CAN ERROR", not r.get("error"))
# Нет ответа — пустая строка
r = parse_batch([{"cmd": "0105", "raw": "", "decoded": ""}])
test("нет ответа — пусто", not r.get("error") and r["parameters"] == [])
# Garbage в raw (не HEX, не ELM)
r = parse_batch([{"cmd": "0105", "raw": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", "decoded": ""}])
test("Lorem Ipsum вместо ответа", len(r["parameters"]) == 0)
# VIN с пробелами в HEX
r = parse_batch([{"cmd": "0902", "raw": "4902015756 575A 5A5A 314B5A 4157 3132 3334 3536", "decoded": ""}])
test("VIN HEX с группами по 2-4 байта", r["vin"] == "WVWZZZ1KZAW123456")
# PID с отрицательным значением
r = parse_batch([{"cmd": "0111", "raw": "", "decoded": "Дроссель: 0.0 %"}])
test("дроссель 0%", len(r["parameters"]) == 1)
# PID с большим значением
r = parse_batch([{"cmd": "0105", "raw": "", "decoded": "ОЖ: 127 °C"}])
test("ОЖ 127°C", len(r["parameters"]) == 1)
# Ответ содержит именованный PID с ':'
r = parse_batch([{"cmd": "010B", "raw": "", "decoded": "MAP: 101 кПа"}])
test("MAP parsed", len(r["parameters"]) == 1)
# ACK ответ
r = parse_batch([{"cmd": "ATSP0", "raw": "OK", "decoded": "OK"}])
test("AT OK — не данные", r["parameters"] == [])
# ── 13. Идемпотентность ─────────────────────────────────────────
print("\n═══ 13. Идемпотентность ═══")
db3 = Database(TEST_DB)
# Симуляция: DTC upload с request_id 2 раза
dtc_req_id = "dtc-test-uuid-001"
dtc_first = {"codes": [{"code": "P0301", "desc": "Пропуски зажигания"}], "count": 1}
# Сохраняем первый раз (как сделал бы /dtc/upload)
db3.save_session(
client_info={"device_uuid": "idemp-test"},
responses=[{"decoded": "DTC stored: P0301"}],
request_id=dtc_req_id,
response_json={"codes": dtc_first["codes"], "count": dtc_first["count"]},
)
test("DTC: первый save с request_id", True)
# Повтор — не должен создать дубликат
count_before = len(db3.get_recent_sessions(100))
db3.save_session(
client_info={"device_uuid": "idemp-test"},
responses=[{"decoded": "DTC stored: P0301"}],
request_id=dtc_req_id,
response_json={"codes": [{"code": "P0999", "desc": "НИКОГДА"}], "count": 0}, # другой ответ
)
count_after = len(db3.get_recent_sessions(100))
test("DTC: дубликат не создан", count_before == count_after)
# Кэш возвращает ПЕРВЫЙ результат, не последний
cached = db3.get_cached_response(dtc_req_id)
test("DTC: кэш вернул оригинал", cached is not None and cached["codes"][0]["code"] == "P0301")
# Upload сессии с разными request_id — не конфликтуют
for i in range(5):
db3.save_session(
client_info={"device_uuid": f"multi-idemp-{i}"},
responses=[],
request_id=f"multi-req-{i}",
response_json={"i": i},
)
test("5 разных request_id — все сохранены",
len(db3.get_recent_sessions(10)) == count_after + 5)
db3.close()
if TEST_DB.exists():
TEST_DB.unlink()
# ── 14. DTC модуль (api/dtc.py) ─────────────────────────────────
print("\n═══ 14. DTC модуль ═══")
# Загрузка словаря из dtc.py
dtc = _load_dtc_dict()
test("dtc загружен из dtc.py", len(dtc) > 100)
# Известные коды
test("P0301 = пропуски", "Пропуски" in dtc.get("P0301", ""))
test("P0420 = катализатор", "катализатор" in dtc.get("P0420", "").lower())
# Протокольные коды
test("ABS C0000", "ABS" in dtc.get("C0000", ""))
test("шина U0000", "шина" in dtc.get("U0000", "").lower())
test("кузов B0000", "кузов" in dtc.get("B0000", "").lower())
# ── 15. Ping модуль (api/ping.py) ────────────────────────────────
print("\n═══ 15. Ping модуль ═══")
from api.ping import _ping_llm_cache
# Изначально кэш пуст
test("ping кэш пуст изначально", _ping_llm_cache == {})
# ── 16. State модуль (obd/state.py) ─────────────────────────────
print("\n═══ 16. State ELM ═══")
from obd.state import State, Rsp
# State enum
test("State.UNDEFINED", State.UNDEFINED is not None)
test("State.READY", State.READY is not None)
test("State.ERROR", State.ERROR is not None)
# Rsp.identify
test("Rsp.identify OK", Rsp.identify("OK") == Rsp.OK)
test("Rsp.identify BUS ERROR", Rsp.identify("BUS ERROR") == Rsp.BUS_ERROR)
test("Rsp.identify SEARCHING", Rsp.identify("SEARCHING...") == Rsp.SEARCHING)
test("Rsp.identify NO DATA", Rsp.identify("NO DATA") == Rsp.NODATA)
test("Rsp.identify CAN ERROR", Rsp.identify("CAN ERROR") == Rsp.CAN_ERROR)
test("Rsp.identify BUFFER FULL", Rsp.identify("BUFFER FULL") == Rsp.BUFFER_FULL)
test("Rsp.identify STOPPED", Rsp.identify("STOPPED") == Rsp.STOPPED)
test("Rsp.identify UNABLE", Rsp.identify("UNABLE TO CONNECT") == Rsp.UNABLE)
test("Rsp.identify RX ERROR", Rsp.identify("RX ERROR") == Rsp.RX_ERROR)
test("Rsp.identify DATA ERROR", Rsp.identify("DATA ERROR") == Rsp.DATA_ERROR)
# PROMPT
test("Rsp.identify PROMPT >", Rsp.identify(">") == Rsp.PROMPT)
# UNKNOWN — данные
test("Rsp.identify UNKNOWN — данные", Rsp.identify("41 0C 0C A8") == Rsp.UNKNOWN)
test("Rsp.identify UNKNOWN — VIN", Rsp.identify("49 02 01 57 56") == Rsp.UNKNOWN)
# Нижний регистр, пробелы
test("Rsp.identify lower case", Rsp.identify("bus error") == Rsp.BUS_ERROR)
test("Rsp.identify пробелы", Rsp.identify(" OK ") == Rsp.OK)
# ── 17. Timing модуль (obd/timing.py) ────────────────────────────
print("\n═══ 17. Timing ELM ═══")
from obd.timing import AdaptiveTiming
t = AdaptiveTiming()
test("timing DEFAULT", t.ms == 500)
test("atst = ms/4", t.atst == 125)
t.increase()
test("timing увеличился", t.ms > 500)
t.decrease()
test("timing уменьшился", t.ms >= 500)
t.reset()
test("timing сброс", t.ms == 500)
# Многократное увеличение
for _ in range(100):
t.increase()
test("timing MAX не превышен", t.ms <= 2000)
# Многократное уменьшение
for _ in range(100):
t.decrease()
test("timing MIN не превышен", t.ms >= 50)
t2 = AdaptiveTiming()
t2.increase()
t2.increase()
v = t2.ms
t2.reset()
test("reset после increase", t2.ms == 500 and v > 500)
atst = t2.atst
test("atst >= 1", atst >= 1)
# ── 18. DB — только sessions (мёртвые таблицы удалены) ──────────
print("\n═══ 18. DB чистота ═══")
import sqlite3
db4 = Database(TEST_DB)
tables = [r[0] for r in db4.conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()]
test("нет мёртвых таблиц (cars,tokens)", not any(t in tables for t in ["cars", "diagnostic_tokens", "dtc_codes"]))
# Убедиться что мёртвые методы вызывают AttributeError
test("get_or_create_car удалён", not hasattr(db4, "get_or_create_car"))
test("create_token удалён", not hasattr(db4, "create_token"))
test("add_llm_message удалён", not hasattr(db4, "add_llm_message"))
test("add_parameter удалён", not hasattr(db4, "add_parameter"))
test("add_dtc удалён", not hasattr(db4, "add_dtc"))
db4.close()
if TEST_DB.exists():
TEST_DB.unlink()
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env python3
import sqlite3, json
db = sqlite3.connect("/opt/elmer/elmer.db")
for sid in [41, 40]:
r = db.execute("SELECT id, created_at, raw_responses FROM sessions WHERE id=?", (sid,)).fetchone()
print(f"\n=== SESSION #{r[0]} {r[1]} ===")
if not r[2]: print(" (no raw data)"); continue
data = json.loads(r[2])
print(f" Total responses: {len(data)}")
cmds = {}
for d in data:
c = d.get("cmd","?")
s = d.get("step_id","?")
raw = d.get("raw","")
dec = d.get("decoded","")
key = f"{s} ({c})"
if key not in cmds:
cmds[key] = {"cnt": 0, "err": 0, "empty": 0, "ok": 0, "samples": []}
cmds[key]["cnt"] += 1
if not raw or raw in ["?","(err)","NO DATA"]:
cmds[key]["err"] += 1
elif raw == "":
cmds[key]["empty"] += 1
else:
cmds[key]["ok"] += 1
if len(cmds[key]["samples"]) < 2:
cmds[key]["samples"].append(f"{raw} -> {dec}")
for k, v in sorted(cmds.items()):
print(f" {k:30s} total={v['cnt']:3d} ok={v['ok']} err={v['err']} empty={v['empty']}")
for s in v["samples"]:
print(f" {s}")
db.close()
+298
View File
@@ -0,0 +1,298 @@
#!/usr/bin/env python3
"""
elm_console.py — Интерактивная консоль ELM327 (сырой режим).
НИКАКОЙ автоматики:
- Нет init(), probe(), send() со стейт-машиной
- Нет классификации ответов
- Нет адаптивных таймингов
ТОЛЬКО вы вводите команду — ELM отвечает сырыми байтами.
ЗАПУСК:
python tools/elm_console.py # порт по умолчанию /dev/rfcomm0
python tools/elm_console.py --port /dev/rfcomm0 # явно указать порт
python tools/elm_console.py --baud 38400 # другая скорость
python tools/elm_console.py --timeout 1000 # таймаут 1с
python tools/elm_console.py --no-init # не слать AT-инит
КОМАНДЫ КОНСОЛИ:
ATZ — отправить "ATZ" в ELM
0105 — отправить "0105" (PID coolant temp)
!drain — очистить входной буфер
!timeout 2000 — установить таймаут 2000 мс
!delay 0.5 — пауза между командой и чтением (сек)
!log — показать историю команд
!available — сколько байт в буфере
!save file.json — сохранить лог в файл
!help — справка
!quit — выход
ЦЕЛЬ:
Понять, КАК на самом деле работает ELM327.
Почему статика работает, а динамика ломается?
Ответ — в сырых байтах.
"""
import argparse
import cmd
import json
import logging
import sys
import time
from pathlib import Path
# Добавляем корень проекта в PYTHONPATH
sys.path.insert(0, str(Path(__file__).parent.parent))
from obd.connection import SerialTransport
from obd.raw_console import RawELM, format_response, format_log
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s [%(name)s] %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger("elm.console")
class ElmConsole(cmd.Cmd):
"""Интерактивная консоль ELM327."""
intro = """
╔══════════════════════════════════════════════════════╗
║ ELM327 Raw Console ║
║ Сырое взаимодействие — без стейт-машины ║
║ Команды: ATZ, 0105, !drain, !help, !quit ║
╚══════════════════════════════════════════════════════╝
"""
prompt = "\nelm> "
def __init__(self, port: str, baudrate: int, timeout: int, delay: float, no_init: bool):
super().__init__()
self._port = port
self._baud = baudrate
self._no_init = no_init
self._transport = None
self._elm: RawELM | None = None
# ── Подключение ─────────────────────────────────
def connect(self):
"""Открыть порт и создать RawELM."""
print(f"🔌 Подключение к {self._port} @ {self._baud}...")
try:
self._transport = SerialTransport(self._port, self._baud)
self._transport.connect()
except Exception as e:
print(f"❌ Не удалось открыть порт: {e}")
print(" Проверь: bash scripts/setup-bt.sh")
return False
self._elm = RawELM(self._transport)
print(f"✅ Порт открыт. RawELM готов.")
print(f" Таймаут: {self._elm.timeout}ms, пауза: {self._elm.inter_delay}s")
print(f" Буфер: {self._elm.available()} байт")
if not self._no_init:
print("\n📡 Быстрая проверка связи (ATZ)...")
r = self._elm.send("ATZ", timeout=3000)
print(format_response(r))
if r["error"]:
print("⚠️ ELM327 не ответил на ATZ. Проверь питание адаптера.")
print(" Продолжаем, но команды могут не работать.")
return True
def close(self):
"""Закрыть порт."""
if self._transport:
self._transport.close()
print("🔌 Порт закрыт.")
# ── cmd.Cmd overrides ────────────────────────────
def default(self, line: str):
"""Любая не-! команда = отправить в ELM327."""
cmd_str = line.strip()
if not cmd_str:
return
if cmd_str.startswith("!"):
print(f"Неизвестная команда: {cmd_str}. !help для списка.")
return
# Отправить в ELM
result = self._elm.send(cmd_str)
print(format_response(result))
def emptyline(self):
"""Пустая строка — ничего не делаем."""
pass
# ── Специальные команды (!) ──────────────────────
def do_drain(self, arg):
"""!drain — очистить входной буфер ELM327."""
r = self._elm.drain()
if r["bytes"] > 0:
print(f"🗑 Выброшено {r['bytes']} байт: {r['drained']!r}")
else:
print("✅ Буфер пуст.")
def do_timeout(self, arg):
"""!timeout <ms> — установить таймаут чтения."""
try:
ms = int(arg.strip())
self._elm.timeout = ms
print(f"⏱ Таймаут: {ms}ms")
except ValueError:
print(f"❌ Нужно число: !timeout 2000")
def do_delay(self, arg):
"""!delay <sec> — пауза между командой и чтением."""
try:
sec = float(arg.strip())
self._elm.inter_delay = sec
print(f"⏱ Пауза: {sec}s")
except ValueError:
print(f"❌ Нужно число: !delay 0.5")
def do_log(self, arg):
"""!log [N] — показать последние N команд (по умолчанию все)."""
entries = self._elm.log
if not entries:
print("📭 Лог пуст.")
return
try:
n = int(arg.strip()) if arg.strip() else len(entries)
except ValueError:
n = len(entries)
to_show = entries[-n:] if n < len(entries) else entries
print(format_log(to_show))
print(f"Всего: {len(entries)} команд.")
def do_available(self, arg):
"""!available — сколько байт в буфере."""
n = self._elm.available()
if n < 0:
print("⚠️ Не удалось проверить буфер (порт закрыт?).")
elif n == 0:
print("✅ Буфер пуст.")
else:
print(f"📥 В буфере: {n} байт.")
def do_save(self, arg):
"""!save <file.json> — сохранить лог в JSON."""
path = arg.strip()
if not path:
print("❌ Укажи имя файла: !save log.json")
return
try:
with open(path, "w") as f:
json.dump(self._elm.log, f, indent=2, default=str)
print(f"💾 Сохранено: {path} ({len(self._elm.log)} команд)")
except Exception as e:
print(f"❌ Ошибка: {e}")
def do_raw(self, arg):
"""!raw — показать последний ответ в repr (все символы)."""
last = self._elm.last()
if not last:
print("📭 Нет команд.")
return
print(f"raw = {last['raw']!r}")
print(f"prompt = {last['prompt']}")
print(f"elapsed = {last['elapsed_ms']}ms")
print(f"bytes = {last['bytes']}")
def do_help(self, arg):
"""!help — справка."""
print("""
╔══════════════════════════════════════════════════════╗
║ КОМАНДЫ ELM327 (вводи как есть): ║
║ ATZ — сброс ║
║ ATI — идентификация ║
║ ATE0 — echo off ║
║ ATL0 — linefeeds off ║
║ ATS0 — spaces off ║
║ ATH1 — headers on ║
║ ATSP0 — авто-протокол ║
║ ATRV — напряжение ║
║ ATDPN — номер протокола ║
║ 0105 — температура ОЖ (PID) ║
║ 010C — обороты ║
║ 010D — скорость ║
║ 03 — сохранённые DTC ║
║ 07 — pending DTC ║
║ 0902 — VIN ║
║ ║
║ КОМАНДЫ КОНСОЛИ (с !): ║
║ !drain — очистить буфер ║
║ !timeout N — таймаут (ms) ║
║ !delay N — пауза перед чтением (s) ║
║ !log [N] — история команд ║
║ !raw — последний ответ в repr ║
║ !available — байт в буфере ║
║ !save f.json — сохранить лог ║
║ !help — эта справка ║
║ !quit — выход ║
╚══════════════════════════════════════════════════════╝
""")
def do_quit(self, arg):
"""!quit — выход."""
print("👋")
self.close()
return True
def do_exit(self, arg):
"""!exit — то же что !quit."""
return self.do_quit(arg)
# Сокращения
do_q = do_quit
do_h = do_help
do_d = do_drain
do_t = do_timeout
do_l = do_log
do_a = do_available
# ── main ─────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="ELM327 Raw Console — интерактивное сырое взаимодействие"
)
parser.add_argument("--port", default="/dev/rfcomm0", help="Порт (default: /dev/rfcomm0)")
parser.add_argument("--baud", type=int, default=38400, help="Скорость (default: 38400)")
parser.add_argument("--timeout", type=int, default=500, help="Таймаут чтения ms (default: 500)")
parser.add_argument("--delay", type=float, default=0.05, help="Пауза перед чтением s (default: 0.05)")
parser.add_argument("--no-init", action="store_true", help="Не слать ATZ при старте")
args = parser.parse_args()
console = ElmConsole(
port=args.port,
baudrate=args.baud,
timeout=args.timeout,
delay=args.delay,
no_init=args.no_init,
)
if not console.connect():
sys.exit(1)
try:
console.cmdloop()
except KeyboardInterrupt:
print("\n👋")
finally:
console.close()
if __name__ == "__main__":
main()
+270
View File
@@ -0,0 +1,270 @@
#!/usr/bin/env python3
"""
elm_relay.py — Интерактивная консоль для удалённого управления ELM327 через Android.
Работает через HTTP-очередь на сервере:
1. Ставит команду → POST /api/v1/elm/raw/cmd
2. Ждёт ответ → GET /api/v1/elm/raw/response?wait=N
3. Показывает сырой ответ
4. Анализирует → следующая команда
ЗАПУСК:
python3 tools/elm_relay.py # сервер по умолчанию http://localhost:5005
python3 tools/elm_relay.py --server https://obdai.ru
python3 tools/elm_relay.py --timeout 1000 # таймаут команд 1000мс
ИНТЕРАКТИВНЫЕ КОМАНДЫ:
ATZ — отправить "ATZ"
0105 — отправить PID
!status — статус устройства
!history [N] — последние N ответов
!drain — очистить буфер (ATPC)
!mode raw — включить raw-режим на сервере
!mode normal — выключить
!timeout N — таймаут команд (мс)
!help — справка
!quit — выход
"""
import argparse
import cmd
import json
import sys
import time
import urllib.request
import urllib.error
DEFAULT_SERVER = "http://localhost:5005"
class ElmRelay(cmd.Cmd):
"""Интерактивная консоль для удалённого ELM327."""
intro = """
╔══════════════════════════════════════════════════════╗
║ ELM327 Remote Relay Console ║
║ Сервер → Android → ELM327 → ответ → анализ ║
║ !help для списка команд ║
╚══════════════════════════════════════════════════════╝
"""
prompt = "\nelm-relay> "
def __init__(self, server: str, timeout: int):
super().__init__()
self.server = server.rstrip("/")
self.timeout = timeout
self._last_seq = 0
# ── Отправка команд ──────────────────────────────
def default(self, line: str):
"""Любая не-! команда → отправить в ELM327."""
cmd_str = line.strip()
if not cmd_str:
return
if cmd_str.startswith("!"):
print(f"Неизвестная команда: {cmd_str}")
return
self._send_and_wait(cmd_str)
def _send_and_wait(self, cmd: str, drain_first: bool = False):
"""Поставить команду в очередь и дождаться ответа."""
# 1. Отправить команду
try:
enq = self._post("/api/v1/elm/raw/cmd", {
"cmd": cmd,
"timeout_ms": self.timeout,
"drain_first": drain_first,
})
except Exception as e:
print(f"❌ Ошибка отправки: {e}")
return
seq = enq.get("seq", 0)
print(f"{cmd} (seq={seq}, timeout={self.timeout}ms)")
# 2. Ждать ответ
try:
resp = self._get(f"/api/v1/elm/raw/response?wait=30&seq={self._last_seq}")
except Exception as e:
print(f"❌ Ошибка ожидания: {e}")
return
self._last_seq = resp.get("seq", seq)
# 3. Показать
self._print_response(resp)
# ── Вывод ответа ──────────────────────────────────
def _print_response(self, r: dict):
raw = r.get("raw", "")
prompt = r.get("prompt", False)
elapsed = r.get("elapsed_ms", 0)
nbytes = r.get("bytes", 0)
error = r.get("error")
print(f"{raw!r}" if raw else "← (пусто)")
status = []
if prompt:
status.append("✅ >")
else:
status.append("❌ нет >")
status.append(f"{elapsed}ms")
status.append(f"{nbytes}B")
if error:
status.append(f"⚠️ {error}")
print(f" {' | '.join(status)}")
# ── Специальные команды ───────────────────────────
def do_status(self, arg):
"""!status — статус устройства."""
try:
s = self._get("/api/v1/elm/raw/status")
except Exception as e:
print(f"{e}")
return
print(f"Устройство: {'✅ готово' if s.get('device_ready') else '❌ не подключено'}")
info = s.get("device_info", {})
if info:
print(f" ID: {info.get('device_id', '?')}")
print(f" ELM: {info.get('elm_version', '?')}")
print(f" Протокол: {info.get('protocol', '?')}")
print(f" Напряжение: {info.get('voltage', '?')}")
print(f"Очередь: {'есть команда' if s.get('pending_cmd') else 'пусто'}")
print(f"Последний seq: {s.get('last_response_seq', 0)}")
print(f"История: {s.get('history_count', 0)} команд")
def do_history(self, arg):
"""!history [N] — последние N ответов."""
try:
n = int(arg.strip()) if arg.strip() else 10
except ValueError:
n = 10
try:
h = self._get(f"/api/v1/elm/raw/history?n={n}")
except Exception as e:
print(f"{e}")
return
items = h.get("history", [])
if not items:
print("📭 История пуста.")
return
print(f"Последние {len(items)} из {h.get('total', 0)}:\n")
for r in items:
seq = r.get("seq", "?")
cmd = r.get("cmd", "?")
raw = (r.get("raw") or "")[:60]
elapsed = r.get("elapsed_ms", 0)
prompt = "✅>" if r.get("prompt") else ""
error = f" ⚠️{r['error']}" if r.get("error") else ""
print(f" #{seq}{cmd}{raw}{'' if len(r.get('raw',''))>60 else ''} ({elapsed}ms, {prompt}){error}")
def do_drain(self, arg):
"""!drain — попросить Android очистить буфер ELM327."""
self._send_and_wait("ATPC", drain_first=False)
print("🗑 Буфер очищен.")
def do_mode(self, arg):
"""!mode raw|normal — переключить режим сервера."""
mode = arg.strip().lower()
if mode not in ("raw", "normal"):
print("❌ !mode raw или !mode normal")
return
on = mode == "raw"
try:
self._post("/api/v1/elm/raw/mode", {"raw_mode": on})
print(f"✅ Режим: {'RAW' if on else 'NORMAL'}")
except Exception as e:
print(f"{e}")
def do_timeout(self, arg):
"""!timeout N — таймаут команд (мс)."""
try:
self.timeout = int(arg.strip())
print(f"⏱ Таймаут: {self.timeout}ms")
except ValueError:
print(f"❌ Нужно число: !timeout 1000")
def do_help(self, arg):
"""!help — справка."""
print("""
╔══════════════════════════════════════════════════════╗
║ ELM327 КОМАНДЫ (вводи как есть): ║
║ ATZ ATI ATE0 ATL0 ATS0 ║
║ ATH1 ATSP0 ATRV ATDPN ATSTxx ║
║ 0105 (ОЖ) 010C (RPM) 010D (Speed) ║
║ 03 (DTC) 07 (pending) 0902 (VIN) ║
║ ║
║ КОНСОЛЬ: ║
║ !status — статус устройства ║
║ !history [N] — последние ответы ║
║ !drain — очистить буфер ║
║ !mode raw — включить raw-режим ║
║ !timeout N — таймаут команд ║
║ !help — эта справка ║
║ !quit — выход ║
╚══════════════════════════════════════════════════════╝
""")
def do_quit(self, arg):
print("👋")
return True
def do_exit(self, arg):
return self.do_quit(arg)
do_q = do_quit
do_h = do_help
do_s = do_status
# ── HTTP-хелперы ──────────────────────────────────
def _get(self, path: str) -> dict:
url = f"{self.server}{path}"
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=35) as resp:
return json.loads(resp.read().decode())
def _post(self, path: str, data: dict) -> dict:
url = f"{self.server}{path}"
body = json.dumps(data).encode()
req = urllib.request.Request(url, data=body,
headers={"Content-Type": "application/json"},
method="POST")
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode())
# ── main ─────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="ELM327 Remote Relay Console — удалённое управление через Android"
)
parser.add_argument("--server", default=DEFAULT_SERVER, help=f"URL сервера (default: {DEFAULT_SERVER})")
parser.add_argument("--timeout", type=int, default=500, help="Таймаут команд ms (default: 500)")
args = parser.parse_args()
console = ElmRelay(server=args.server, timeout=args.timeout)
# Проверим связь с сервером
try:
s = console._get("/api/v1/elm/raw/status")
ready = s.get("device_ready", False)
print(f"Сервер: {args.server}")
print(f"Устройство: {'✅ готово' if ready else '❌ не подключено (запусти Android-приложение)'}")
except Exception as e:
print(f"⚠️ Сервер недоступен: {e}")
print(f" Проверь: curl {args.server}/api/v1/ping")
try:
console.cmdloop()
except KeyboardInterrupt:
print("\n👋")
if __name__ == "__main__":
main()
+28
View File
@@ -20,12 +20,34 @@ from flask import Flask, jsonify, redirect, render_template, request, send_from_
from api.config import load
from api.routes import register as register_api
from api.dtc import register as register_dtc
from api.ping import register as register_ping
from api.raw_elm import bp as raw_bp, is_raw_mode
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s")
app = Flask(__name__)
config = load()
register_api(app)
register_dtc(app)
register_ping(app)
app.register_blueprint(raw_bp)
# ── Режим RAW: отключаем все эндпоинты кроме /elm/raw/* ──
_RAW_PREFIX = "/api/v1/elm/raw"
@app.before_request
def _check_raw_mode():
"""В режиме RAW все эндпоинты кроме /elm/raw/* отключены."""
if is_raw_mode() and not request.path.startswith(_RAW_PREFIX):
# Разрешаем только статику и корень
if request.path not in ("/", "/elmer.apk") and not request.path.startswith("/static"):
return jsonify({
"error": "raw_mode_active",
"hint": "Сервер в режиме сырого взаимодействия с ELM327. "
"Все остальные эндпоинты отключены. "
"Используйте /api/v1/elm/raw/mode чтобы выключить."
}), 503
@app.route("/")
@@ -40,6 +62,12 @@ def download_apk():
return send_from_directory("static", "app-debug.apk", as_attachment=True, download_name="elmer.apk")
@app.route("/elm-raw.apk")
def download_raw_apk():
"""Прямая ссылка на APK Raw Relay."""
return send_from_directory("static", "elm-raw.apk", as_attachment=True, download_name="elm-raw.apk")
if __name__ == "__main__":
print(f"🌐 elmAI Web: http://localhost:5005")
app.run(host="0.0.0.0", port=5005, debug=False)
+1 -1
View File
@@ -64,7 +64,7 @@ def register(app):
def upload_session():
from flask import request, jsonify
from elmer.config import load
from elmer.db import Database
from api.db import Database
from elmer.diagnose import Diagnoser
from elmer.prompts import SYSTEM_PROMPT
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+13 -4
View File
@@ -10,16 +10,25 @@
<body>
<div class="container">
<h1>🔧 elmAI</h1>
<img src="/static/logo.png" alt="elmAI" style="width:96px;height:96px;border-radius:20px;margin-bottom:10px;">
<h1>elmAI</h1>
<p class="subtitle">Диагностика авто через ELM327 + ИИ</p>
<p class="subtitle" style="font-size:12px;opacity:0.7;">v0.28.0-dev — 29 мая 2026</p>
<p class="subtitle" style="font-size:12px;opacity:0.7;">v1.18.0-dev — 7 июня 2026</p>
<div class="card" style="text-align:center;margin-bottom:20px;">
<p style="margin:0 0 10px 0;">📱 Скачай приложение на телефон:</p>
<a href="/static/app-debug.apk" style="color:#ff6b35;font-size:18px;font-weight:bold;text-decoration:none;">
<a href="/elmer.apk" style="color:#ff6b35;font-size:18px;font-weight:bold;text-decoration:none;">
⬇️ Скачать elmAI APK
</a>
<p style="font-size:11px;opacity:0.6;margin:4px 0 0 0;">v0.28.0-dev • нажмите чтобы скачать</p>
<p style="font-size:11px;opacity:0.6;margin:4px 0 0 0;">v1.18.0-dev • основное приложение</p>
</div>
<div class="card" style="text-align:center;margin-bottom:20px;background:#1a1a2e;">
<p style="margin:0 0 10px 0;">🔧 Отладка ELM327:</p>
<a href="/static/elm-raw.apk" style="color:#00ff88;font-size:18px;font-weight:bold;text-decoration:none;">
⬇️ Скачать ELM Raw Relay
</a>
<p style="font-size:11px;opacity:0.6;margin:4px 0 0 0;">v0.1.1-dev • ретранслятор команд</p>
</div>
<!-- Кнопка десктоп-диагностики скрыта — только для разработчика с прямым ELM327 -->