From aa37a1a70c0458e1389cbb2bca82785a2787d32f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sun, 7 Jun 2026 05:55:02 +0400 Subject: [PATCH] =?UTF-8?q?v0.48.0=20=E2=80=94=20=D0=BF=D1=80=D0=BE=D0=B1?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=20ELM327,=20=D1=82=D1=80=D0=B5=D1=85=D1=83?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=BD=D0=B5=D0=B2=D1=8B=D0=B9=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=84=D0=B8=D0=BB=D1=8C,=20=D1=80=D0=B5=D1=84=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D1=80=D0=B8=D0=BD=D0=B3=20obd/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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/, /script?level= - web/templates/index.html: v0.48.0 - CHANGELOG.md, doc/architecture.md, resume.txt: версии --- CHANGELOG.md | 14 ++- api/db.py | 81 ++++++++++++++--- api/routes.py | 80 ++++++++++++++++- api/scripts.py | 118 +++++++++++++++++++++---- doc/architecture.md | 2 +- doc/history/2026-06-07.md | 96 +++++++++++++++++++++ obd/classifier.py | 107 +++++++++++++++++++++++ obd/commands.py | 143 ++++++++++++++++++++++++++++++ obd/connection.py | 105 ++++++++++++++++++++++ obd/probe.py | 86 ++++++++++++++++++ obd/protocol.py | 177 ++++++++++++-------------------------- resume.txt | 63 ++++++++++++++ web/templates/index.html | 4 +- 13 files changed, 918 insertions(+), 158 deletions(-) create mode 100644 doc/history/2026-06-07.md create mode 100644 obd/classifier.py create mode 100644 obd/commands.py create mode 100644 obd/connection.py create mode 100644 obd/probe.py create mode 100644 resume.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 12df41f..1c3082e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # elmAI — Changelog / Полное описание проекта > Файл для нового агента: прочитай — и ты в курсе всего. -> Актуально: v0.36.0-dev, 31 мая 2026 +> Актуально: v0.48.0, 7 июня 2026 --- @@ -132,6 +132,18 @@ ### История версий (сервер) +#### 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/` +- `api/routes.py` — `/script?level=0|1|2` + #### v0.36.0-dev (31 мая 2026) - Ребрендинг Elmer → elmAI (лого, сайт) - Opus review: серверные фиксы (WAL, идемпотентность, таймауты LLM, кэш ping-llm) diff --git a/api/db.py b/api/db.py index b25b4ec..38b8f5a 100644 --- a/api/db.py +++ b/api/db.py @@ -1,15 +1,11 @@ -"""SQLite — сохранение сессий диагностики. +"""SQLite — сохранение сессий диагностики + профили ELM-устройств. Таблица 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, car_info - diagnosis_text, diagnosis_len, llm_model, llm_duration_ms, llm_success - raw_responses, request_id, response_json + ... (см. ниже) + +Таблица device_profiles: + mac (TEXT PK), level (INT), elm_version, elm_desc, protocol, + supported (JSON), unsupported (JSON), first_seen, last_seen """ import json @@ -108,6 +104,22 @@ class Database: CREATE INDEX IF NOT EXISTS idx_sessions_aid ON sessions(android_id); CREATE INDEX IF NOT EXISTS idx_sessions_request_id ON sessions(request_id); CREATE INDEX IF NOT EXISTS idx_sessions_uuid ON sessions(device_uuid); + + -- Профили ELM-устройств (по BT MAC) + CREATE TABLE IF NOT EXISTS device_profiles ( + mac TEXT PRIMARY KEY, -- BT MAC-адрес + level INTEGER NOT NULL, -- 0/1/2 (-1 = нерабочее) + elm_version TEXT, -- ATI ответ + elm_desc TEXT, -- AT@1 (если есть) + protocol TEXT, -- ATDPN + voltage TEXT, -- ATRV + supported TEXT, -- JSON: ["ATE0","ATL0",...] + unsupported TEXT, -- JSON: ["ATAT1",...] + errors TEXT, -- JSON: ["ATCFC1: no response",...] + 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); """) self.conn.commit() @@ -221,3 +233,52 @@ class Database: )) self.conn.commit() + # ── device_profiles ────────────────────────────────── + + def get_device_profile(self, mac: str) -> dict | None: + """Возвращает сохранённый профиль устройства по MAC, или None.""" + row = self.conn.execute( + "SELECT * FROM device_profiles WHERE mac = ?", (mac,) + ).fetchone() + if not row: + return None + p = dict(row) + for f in ("supported", "unsupported", "errors"): + p[f] = json.loads(p[f]) if p.get(f) else [] + return p + + def save_device_profile(self, mac: str, profile: dict): + """Сохраняет или обновляет профиль устройства. + + profile — результат obd.probe.probe(). + """ + now = datetime.now(timezone.utc).isoformat() + self.conn.execute(""" + INSERT INTO device_profiles + (mac, level, elm_version, elm_desc, protocol, voltage, + supported, unsupported, errors, first_seen, last_seen) + VALUES (?,?,?,?,?,?, ?,?,?, ?,?) + ON CONFLICT(mac) DO UPDATE SET + level = excluded.level, + elm_version = excluded.elm_version, + elm_desc = excluded.elm_desc, + protocol = excluded.protocol, + voltage = excluded.voltage, + supported = excluded.supported, + unsupported = excluded.unsupported, + errors = excluded.errors, + last_seen = excluded.last_seen + """, ( + mac, + profile.get("level", -1), + profile.get("elm_version"), + profile.get("elm_desc"), + profile.get("protocol"), + profile.get("voltage"), + json.dumps(profile.get("supported", []), ensure_ascii=False), + json.dumps(profile.get("unsupported", []), ensure_ascii=False), + json.dumps(profile.get("errors", []), ensure_ascii=False), + now, now, + )) + self.conn.commit() + diff --git a/api/routes.py b/api/routes.py index 7280284..85f24e6 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1,8 +1,9 @@ -"""Эндпоинты: скрипт, загрузка сессии, чат. +"""Эндпоинты: скрипт, загрузка сессии, чат, пробинг ELM. GET /api/v1/script — выдача скрипта диагностики POST /api/v1/session/upload — приём батча + LLM POST /api/v1/chat — свободный вопрос к LLM +POST /api/v1/elm/probe — пробинг ELM327, определение уровня См. также: api/dtc.py (DTC), api/ping.py (ping) """ @@ -15,7 +16,7 @@ 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 +from api.scripts import build_default_script, build_full_script, build_script_for_level from brain.client import Diagnoser, LLMError from brain.prompts import SYSTEM_PROMPT @@ -70,7 +71,16 @@ def register(app): @app.route("/api/v1/script", methods=["GET"]) def get_script(): 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 == "full": + script = build_full_script() + else: + script = build_default_script() return jsonify(script) @app.route("/api/v1/session/upload", methods=["POST"]) @@ -195,6 +205,70 @@ def register(app): return jsonify({"answer": answer}) + @app.route("/api/v1/elm/probe", methods=["POST"]) + def probe_elm(): + """Пробинг ELM327: определение уровня устройства. + + Принимает MAC и сырые ответы на команды пробинга от Android-клиента. + Клиент посылает команды из списка, сервер классифицирует ответы. + + Body: { + "mac": "AA:BB:CC:...", + "responses": [ + {"cmd": "ATE0", "raw": "OK"}, + {"cmd": "ATL0", "raw": "OK"}, + ... + ] + } + + Returns: профиль устройства (level, supported, unsupported, ...) + """ + data = request.get_json(silent=True) + if not data or "mac" not in data or "responses" not in data: + return jsonify({"error": "missing 'mac' or 'responses'"}), 400 + + mac = data["mac"].strip() + responses = data["responses"] + + if 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/", methods=["GET"]) + def get_elm_profile(mac: str): + """Возвращает сохранённый профиль устройства по MAC.""" + with Database() as db: + p = db.get_device_profile(mac) + if p is None: + return jsonify({"error": "not found"}), 404 + return jsonify(p) + + +def _save_profile(mac: str, profile: dict): + """Сохраняет профиль в БД (best-effort).""" + try: + with Database() as db: + db.save_device_profile(mac, profile) + except Exception as e: + logger.error(f"Failed to save device profile for {mac}: {e}") + + def _summary(p: dict) -> dict: return { "vin": p["vin"], diff --git a/api/scripts.py b/api/scripts.py index 28a8ccb..ad6d252 100644 --- a/api/scripts.py +++ b/api/scripts.py @@ -1,29 +1,109 @@ -"""Сборка диагностических скриптов.""" +"""Сборка диагностических скриптов. + +Три уровня в зависимости от возможностей 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: - """Полный скрипт диагностики.""" - return { - "version": 1, - "title": "Полная диагностика", - "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_06", "cmd": "0106", "desc": "STFT"}, - {"id": "pid_07", "cmd": "0107", "desc": "LTFT"}, - ], - } + """Полный скрипт (уровень 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() diff --git a/doc/architecture.md b/doc/architecture.md index 30a4aa1..8f701b8 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -1,6 +1,6 @@ # Архитектура elmAI -> v0.36.0-dev, 31 мая 2026 +> v0.48.0, 7 июня 2026 ## Общая схема diff --git a/doc/history/2026-06-07.md b/doc/history/2026-06-07.md new file mode 100644 index 0000000..3738f42 --- /dev/null +++ b/doc/history/2026-06-07.md @@ -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/` — достаёт из кэша +- `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). + +## Ключевое правило + +**Нет в профиле → не слать. Никаких ретраев на неизвестное.** diff --git a/obd/classifier.py b/obd/classifier.py new file mode 100644 index 0000000..5fa7372 --- /dev/null +++ b/obd/classifier.py @@ -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 diff --git a/obd/commands.py b/obd/commands.py new file mode 100644 index 0000000..d6cc699 --- /dev/null +++ b/obd/commands.py @@ -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] diff --git a/obd/connection.py b/obd/connection.py new file mode 100644 index 0000000..e25a61d --- /dev/null +++ b/obd/connection.py @@ -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() diff --git a/obd/probe.py b/obd/probe.py new file mode 100644 index 0000000..c86e6ad --- /dev/null +++ b/obd/probe.py @@ -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) + diff --git a/obd/protocol.py b/obd/protocol.py index db2cf61..5243fb8 100644 --- a/obd/protocol.py +++ b/obd/protocol.py @@ -1,7 +1,7 @@ """ obd/protocol.py — ELM327 стейт-машина (AndrOBD). -Точная копия логики из AndrOBD (ElmProt.java, github.com/fr3ts0n/AndrOBD). +Основана на AndrOBD (ElmProt.java, github.com/fr3ts0n/AndrOBD). ## Архитектура ┌──────────┐ команда ┌──────────┐ @@ -16,26 +16,26 @@ 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 typing import Optional +from obd.connection import SerialTransport from obd.state import State, Rsp from obd.timing import AdaptiveTiming @@ -43,11 +43,11 @@ logger = logging.getLogger("androbd") 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() — закрыть порт @@ -55,60 +55,58 @@ 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 @@ -117,17 +115,10 @@ class AndrOBD: self._state = State.READY return result - # ── Приватные методы ────────────────────────────────── + # ── Приватные методы ────────────────────────────── def _exec(self, cmd: str, timeout: int) -> str: - """Выполнить команду ELM327 с ретраями (до 10 попыток). - - Архитектура AndrOBD: ELM327 может не ответить, если: - - Адаптер ещё не инициализирован (SEARCHING) - - ЭБУ медленно отвечает (увеличиваем AdaptiveTiming) - - Плохой контакт / потеря связи (Busy→Timeout→Error) - 10 ретраев — жёсткий лимит из AndrOBD, после — ERROR. - """ + """Выполнить команду с ретраями (до 10).""" self._last_cmd = cmd self._write(cmd) t = timeout @@ -135,11 +126,9 @@ class AndrOBD: try: return self._handle(self._read(t)) except TimeoutError: - # Инициализация: каждый ретрай +1с (ELM может быть занят init) if self._state == State.INITIALIZING: t += 1000 else: - # Активный режим: растём таймаут адаптивно self._timing.increase() t = self._timing.ms logger.error(f"AndrOBD: no response for {cmd}") @@ -147,10 +136,7 @@ class AndrOBD: return "" def _handle(self, raw: str) -> str: - """Обработать ответ ELM327: классификация → обновление таймаута. - - Возвращает raw как есть — декодирование данных на уровень выше. - """ + """Обработать ответ ELM327.""" t = Rsp.identify(raw) if t == Rsp.SEARCHING: @@ -159,26 +145,20 @@ class AndrOBD: self._timing.decrease() return raw if t == Rsp.NODATA: - # NODATA = ЭБУ жив, но не может ответить (например, PID недоступен) - # Увеличиваем таймаут — возможно ЭБУ медленный 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 @@ -186,13 +166,11 @@ class AndrOBD: self._try_read() return raw - # Данные — успешный ответ self._timing.decrease() return raw def _recover(self): - """Восстановление после ошибки: ATWS → ATSP0 → ATE0.""" - """Восстановление после ошибки: ATWS → ATSP0 → ATE0.""" + """Восстановление после ошибки.""" logger.info("AndrOBD: recovering...") self._state = State.INITIALIZING self._write("ATWS") @@ -204,59 +182,14 @@ class AndrOBD: self._state = State.READY def _write(self, cmd: str): - """Отправить команду в ELM327: сброс буфера → write + CR → flush.""" - self._ser.reset_input_buffer() # сбросить хвосты предыдущего ответа - 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() diff --git a/resume.txt b/resume.txt new file mode 100644 index 0000000..4ebee2d --- /dev/null +++ b/resume.txt @@ -0,0 +1,63 @@ +## 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 (ветка master) +- Сервер живёт на 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) + app/src/.../elm/ — ElmProtocol.kt, ElmChecker.kt + app/src/.../ui/ — MainActivity.kt + api/ — Flask API (Python) + brain/ — LLM-клиент (Python) + obd/ — ELM-протокол (Python) + doc/ — Документация, сессии + web/ — Flask web, статика +``` + +### Ключевые файлы +- `android/app/build.gradle.kts` — versionName (менять при КАЖДОМ деплое) +- `android/.../elm/ElmProtocol.kt` — стейт-машина AndrOBD (init, sendCommand) +- `android/.../elm/ElmChecker.kt` — checkDevice, checkEcu, scanDtc +- `android/.../ui/MainActivity.kt` — UI (~470 строк, надо разбить) + +### Что уже исправлено (сессия 2026-06-06) +- ELM v1.5 совместимость (init без ретраев, skip v2-команд) +- Статус-строка: append вместо overwrite +- BT-подключение: connect() идемпотентный +- Все таймауты сокращены (2000-3000мс вместо 5000-10000мс) + +### Что НЕ сделано (TODO) +- Разбить MainActivity.kt (~470 -> <=200 строк) +- Разбить ElmChecker.kt (~270 -> <=200 строк) +- Подробные комментарии перед функциями +- Полевой тест на машине + +### Важные правила +- ELM327 v1.5 — фейк (клоны v1.0/v1.4), НЕ слать ATAT1/AT@1/AT@2/ATST +- Все статусы через appendStatus("\n..."), не tvStatus.text = +- Таймер инита макс 14 секунд (было 88) +- Подробности: doc/session-2026-06-06.md diff --git a/web/templates/index.html b/web/templates/index.html index 98d3f46..c1fc9db 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -13,14 +13,14 @@ elmAI

elmAI

Диагностика авто через ELM327 + ИИ

-

v0.42.0-dev — 6 июня 2026

+

v0.48.0 — 7 июня 2026

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

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

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

+

v0.48.0 • нажмите чтобы скачать