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: версии
This commit is contained in:
“Naeel”
2026-06-07 05:55:02 +04:00
parent 7ae0fc5702
commit aa37a1a70c
13 changed files with 918 additions and 158 deletions
+13 -1
View File
@@ -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/<mac>`
- `api/routes.py``/script?level=0|1|2`
#### v0.36.0-dev (31 мая 2026)
- Ребрендинг Elmer → elmAI (лого, сайт)
- Opus review: серверные фиксы (WAL, идемпотентность, таймауты LLM, кэш ping-llm)
+71 -10
View File
@@ -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()
+77 -3
View File
@@ -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/<mac>", methods=["GET"])
def get_elm_profile(mac: str):
"""Возвращает сохранённый профиль устройства по MAC."""
with Database() as db:
p = db.get_device_profile(mac)
if p is None:
return jsonify({"error": "not found"}), 404
return jsonify(p)
def _save_profile(mac: str, profile: dict):
"""Сохраняет профиль в БД (best-effort)."""
try:
with Database() as db:
db.save_device_profile(mac, profile)
except Exception as e:
logger.error(f"Failed to save device profile for {mac}: {e}")
def _summary(p: dict) -> dict:
return {
"vin": p["vin"],
+99 -19
View File
@@ -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()
+1 -1
View File
@@ -1,6 +1,6 @@
# Архитектура elmAI
> v0.36.0-dev, 31 мая 2026
> v0.48.0, 7 июня 2026
## Общая схема
+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).
## Ключевое правило
**Нет в профиле → не слать. Никаких ретраев на неизвестное.**
+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)
+55 -122
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,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()
+63
View File
@@ -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
+2 -2
View File
@@ -13,14 +13,14 @@
<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.42.0-dev6 июня 2026</p>
<p class="subtitle" style="font-size:12px;opacity:0.7;">v0.48.0 — 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;">
⬇️ Скачать elmAI APK
</a>
<p style="font-size:11px;opacity:0.6;margin:4px 0 0 0;">v0.42.0-dev • нажмите чтобы скачать</p>
<p style="font-size:11px;opacity:0.6;margin:4px 0 0 0;">v0.48.0 • нажмите чтобы скачать</p>
</div>
<!-- Кнопка десктоп-диагностики скрыта — только для разработчика с прямым ELM327 -->