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:
+13
-1
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
||||
# Архитектура elmAI
|
||||
|
||||
> v0.36.0-dev, 31 мая 2026
|
||||
> v0.48.0, 7 июня 2026
|
||||
|
||||
## Общая схема
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
## Ключевое правило
|
||||
|
||||
**Нет в профиле → не слать. Никаких ретраев на неизвестное.**
|
||||
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
obd/classifier.py — Классификация ответов ELM327 и определение уровня.
|
||||
|
||||
Отдельный сервис:
|
||||
- classify(raw) → (tag: str, is_ok: bool)
|
||||
- determine_level(responses: dict) → int (-1/0/1/2)
|
||||
|
||||
Использует obd/commands.py для списков команд по уровням.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
from obd.state import Rsp
|
||||
from obd.commands import L0_NAMES, L1_NAMES, L2_NAMES
|
||||
|
||||
logger = logging.getLogger("elmer.classifier")
|
||||
|
||||
|
||||
def classify(raw: str) -> Tuple[str, bool]:
|
||||
"""Классифицирует сырой ответ ELM327.
|
||||
|
||||
Returns:
|
||||
(tag, is_ok)
|
||||
tag: Rsp.OK / Rsp.ERROR / Rsp.UNKNOWN / ...
|
||||
is_ok: True если устройство ответило нормально (OK или данные)
|
||||
"""
|
||||
tag = Rsp.identify(raw)
|
||||
is_ok = tag in (Rsp.OK, Rsp.UNKNOWN, Rsp.NODATA, Rsp.SEARCHING)
|
||||
return (tag, is_ok)
|
||||
|
||||
|
||||
def determine_level(responses: Dict[str, str]) -> dict:
|
||||
"""Определяет уровень устройства по ответам на пробинг-команды.
|
||||
|
||||
Args:
|
||||
responses: {cmd_name: raw_response} — ответы на команды пробинга.
|
||||
|
||||
Returns:
|
||||
Профиль: {level, elm_version, protocol, voltage, supported, unsupported, errors}
|
||||
"""
|
||||
result: dict = {
|
||||
"level": -1,
|
||||
"elm_version": None,
|
||||
"protocol": None,
|
||||
"voltage": None,
|
||||
"supported": [],
|
||||
"unsupported": [],
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
# ── Уровень 0 ──────────────────────────────────
|
||||
l0_ok = True
|
||||
for cmd in L0_NAMES:
|
||||
raw = responses.get(cmd, "")
|
||||
tag, ok = classify(raw)
|
||||
if ok and raw:
|
||||
result["supported"].append(cmd)
|
||||
if cmd == "ATI":
|
||||
result["elm_version"] = raw.strip()
|
||||
elif cmd == "ATDPN":
|
||||
result["protocol"] = raw.strip()
|
||||
elif cmd == "ATRV":
|
||||
result["voltage"] = raw.strip()
|
||||
else:
|
||||
result["unsupported"].append(cmd)
|
||||
result["errors"].append(f"{cmd}: {tag if tag else 'no response'}")
|
||||
l0_ok = False
|
||||
|
||||
if not l0_ok:
|
||||
logger.warning("classifier: L0 failed")
|
||||
return result
|
||||
|
||||
result["level"] = 0
|
||||
|
||||
# ── Уровень 1 ──────────────────────────────────
|
||||
l1_ok = True
|
||||
for cmd in L1_NAMES:
|
||||
raw = responses.get(cmd, "")
|
||||
tag, ok = classify(raw)
|
||||
if ok and raw:
|
||||
result["supported"].append(cmd)
|
||||
else:
|
||||
result["unsupported"].append(cmd)
|
||||
l1_ok = False
|
||||
|
||||
if not l1_ok:
|
||||
return result
|
||||
|
||||
result["level"] = 1
|
||||
|
||||
# ── Уровень 2 ──────────────────────────────────
|
||||
l2_ok = True
|
||||
for cmd in L2_NAMES:
|
||||
raw = responses.get(cmd, "")
|
||||
tag, ok = classify(raw)
|
||||
if ok and raw:
|
||||
result["supported"].append(cmd)
|
||||
else:
|
||||
result["unsupported"].append(cmd)
|
||||
l2_ok = False
|
||||
|
||||
if l2_ok:
|
||||
result["level"] = 2
|
||||
|
||||
logger.info(f"classifier: level={result['level']}")
|
||||
return result
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
obd/commands.py — Каталог AT-команд ELM327.
|
||||
|
||||
Все известные команды ELM327 с метаданными:
|
||||
- name: имя команды (напр. "ATE0")
|
||||
- desc: краткое описание
|
||||
- level: минимальный уровень устройства (0/1/2)
|
||||
- safe: можно ли слать при пробинге (False для опасных)
|
||||
|
||||
Принцип:
|
||||
- Нет в профиле → не слать.
|
||||
- Опасные команды (меняют baud rate, мониторинг) — никогда не пробингуются.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
|
||||
@dataclass
|
||||
class AtCommand:
|
||||
"""AT-команда ELM327 с метаданными."""
|
||||
name: str # "ATE0"
|
||||
desc: str # "Echo off"
|
||||
level: int = 0 # минимальный уровень (0=все, 1=ATAT1, 2=CAF1/CFC1)
|
||||
safe: bool = True # можно ли пробинговать
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# Уровень 0 — Универсальные (есть у ВСЕХ клонов)
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
CMDS_L0: List[AtCommand] = [
|
||||
AtCommand("ATE0", "Echo off"),
|
||||
AtCommand("ATE1", "Echo on", safe=False), # не слать — вернём эхо
|
||||
AtCommand("ATL0", "Linefeeds off"),
|
||||
AtCommand("ATL1", "Linefeeds on", safe=False),
|
||||
AtCommand("ATS0", "Spaces off"),
|
||||
AtCommand("ATS1", "Spaces on", safe=False),
|
||||
AtCommand("ATH0", "Headers off"),
|
||||
AtCommand("ATH1", "Headers on"),
|
||||
AtCommand("ATSP0", "Auto protocol"),
|
||||
AtCommand("ATDPN", "Describe protocol number"),
|
||||
AtCommand("ATRV", "Read voltage"),
|
||||
AtCommand("ATI", "Identify"),
|
||||
AtCommand("ATWS", "Warm start", safe=False), # сбрасывает состояние
|
||||
AtCommand("ATPC", "Protocol close", safe=False),
|
||||
AtCommand("ATR0", "Responses off", safe=False),
|
||||
AtCommand("ATR1", "Responses on"),
|
||||
AtCommand("ATNL", "Normalize length"),
|
||||
AtCommand("ATAL", "Allow long messages"),
|
||||
AtCommand("ATAR", "Auto receive", safe=False), # включает авто-приём
|
||||
AtCommand("ATSW", "Stop waiting"),
|
||||
AtCommand("ATFE", "Forget events"),
|
||||
AtCommand("ATSI", "Slow init"),
|
||||
AtCommand("ATFI", "Fast init"),
|
||||
AtCommand("ATKW", "Keyword"),
|
||||
AtCommand("ATTA", "Tester address"),
|
||||
AtCommand("ATTP", "Try protocol"),
|
||||
AtCommand("ATWM", "Wakeup message"),
|
||||
AtCommand("ATIGN", "Ignore"),
|
||||
AtCommand("ATIFR", "IFR"),
|
||||
AtCommand("ATIIA", "ISO init address"),
|
||||
AtCommand("ATRD", "Read data"),
|
||||
AtCommand("ATCS", "CAN silent"),
|
||||
AtCommand("ATCV", "Calibrate voltage"),
|
||||
AtCommand("ATD0", "Display off"),
|
||||
AtCommand("ATM0", "Memory off"),
|
||||
AtCommand("ATM1", "Memory on"),
|
||||
]
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# Уровень 1 — Хорошие клоны и оригиналы
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
CMDS_L1: List[AtCommand] = [
|
||||
AtCommand("ATAT0", "Adaptive timing off", level=1),
|
||||
AtCommand("ATAT1", "Adaptive timing on", level=1),
|
||||
AtCommand("ATAT2", "Adaptive timing aggressive", level=1),
|
||||
]
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# Уровень 2 — Только настоящий ELM327
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
CMDS_L2: List[AtCommand] = [
|
||||
AtCommand("ATCAF0", "CAN auto formatting off", level=2),
|
||||
AtCommand("ATCAF1", "CAN auto formatting on", level=2),
|
||||
AtCommand("ATCFC0", "CAN flow control off", level=2),
|
||||
AtCommand("ATCFC1", "CAN flow control on", level=2),
|
||||
AtCommand("ATCM", "CAN mask", level=2),
|
||||
AtCommand("ATCRA", "CAN receive address", level=2),
|
||||
AtCommand("ATCP", "CAN priority", level=2),
|
||||
AtCommand("ATSH", "Set header", level=2),
|
||||
AtCommand("AT@1", "Device description", level=2),
|
||||
AtCommand("AT@2", "Device identifier", level=2),
|
||||
AtCommand("ATJE", "J1939 ELM", level=2),
|
||||
AtCommand("ATJS", "J1939 setup", level=2),
|
||||
AtCommand("ATIB", "ISO baud", level=2, safe=False),
|
||||
]
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# ОПАСНЫЕ — никогда не пробинговать
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
CMDS_DANGEROUS: List[AtCommand] = [
|
||||
AtCommand("ATZ", "Hard reset", safe=False),
|
||||
AtCommand("ATD", "Set defaults", safe=False),
|
||||
AtCommand("ATBD", "Baud rate", safe=False),
|
||||
AtCommand("ATBRD", "Baud rate divisor", safe=False),
|
||||
AtCommand("ATMA", "Monitor all", safe=False),
|
||||
AtCommand("ATMR", "Monitor receiver", safe=False),
|
||||
AtCommand("ATMT", "Monitor transmitter", safe=False),
|
||||
]
|
||||
|
||||
|
||||
# ── Хелперы ─────────────────────────────────────────────
|
||||
|
||||
def get_commands_for_level(level: int) -> List[str]:
|
||||
"""Возвращает имена команд, доступных на заданном уровне."""
|
||||
result = [c.name for c in CMDS_L0 if c.safe]
|
||||
if level >= 1:
|
||||
result += [c.name for c in CMDS_L1 if c.safe]
|
||||
if level >= 2:
|
||||
result += [c.name for c in CMDS_L2 if c.safe]
|
||||
return result
|
||||
|
||||
|
||||
def get_probe_commands() -> dict:
|
||||
"""Возвращает команды для пробинга, сгруппированные по уровням.
|
||||
|
||||
Returns: {"L0": ["ATE0",...], "L1": ["ATAT1",...], "L2": ["ATCAF1",...]}
|
||||
"""
|
||||
return {
|
||||
"L0": [c.name for c in CMDS_L0 if c.safe and c.level == 0][:8],
|
||||
"L1": [c.name for c in CMDS_L1 if c.safe],
|
||||
"L2": [c.name for c in CMDS_L2 if c.safe],
|
||||
}
|
||||
|
||||
|
||||
# Плоские списки для импорта (обратная совместимость)
|
||||
L0_NAMES = [c.name for c in CMDS_L0 if c.safe and c.level == 0]
|
||||
L1_NAMES = [c.name for c in CMDS_L1 if c.safe]
|
||||
L2_NAMES = [c.name for c in CMDS_L2 if c.safe]
|
||||
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
obd/connection.py — Транспортный слой ELM327 (serial/Bluetooth).
|
||||
|
||||
Чтение байт-за-байтом с поллингом 1мс, запись + flush.
|
||||
Выделен из protocol.py чтобы разделить I/O и логику протокола.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
logger = logging.getLogger("elm.connection")
|
||||
|
||||
|
||||
class SerialTransport:
|
||||
"""Транспорт: открыть порт, читать/писать байты, закрыть.
|
||||
|
||||
Не знает протокол. Только I/O.
|
||||
"""
|
||||
|
||||
def __init__(self, port: str, baudrate: int = 38400):
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self._ser = None
|
||||
|
||||
def connect(self):
|
||||
"""Открыть serial-соединение."""
|
||||
import serial
|
||||
self._ser = serial.Serial(
|
||||
port=self.port,
|
||||
baudrate=self.baudrate,
|
||||
timeout=0.1,
|
||||
bytesize=serial.EIGHTBITS,
|
||||
parity=serial.PARITY_NONE,
|
||||
stopbits=serial.STOPBITS_ONE,
|
||||
)
|
||||
time.sleep(0.5)
|
||||
logger.info(f"SerialTransport: connected {self.port}")
|
||||
|
||||
def close(self):
|
||||
"""Закрыть serial-соединение."""
|
||||
if self._ser and self._ser.is_open:
|
||||
self._ser.close()
|
||||
logger.info("SerialTransport: closed")
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
"""Открыт ли порт."""
|
||||
return self._ser is not None and self._ser.is_open
|
||||
|
||||
def write(self, data: str):
|
||||
"""Отправить данные в ELM: data + CR → flush."""
|
||||
self._ser.write((data + "\r").encode())
|
||||
self._ser.flush()
|
||||
logger.debug(f"→ {data}")
|
||||
|
||||
def read(self, timeout_ms: int) -> str:
|
||||
"""Прочитать ответ ELM327 байт-за-байтом.
|
||||
|
||||
Читает до символа '>' (промпт) или до таймаута.
|
||||
Возвращает сырой ответ без '>'.
|
||||
|
||||
Raises:
|
||||
TimeoutError: если '>' не получен за timeout_ms.
|
||||
"""
|
||||
dl = time.monotonic() + timeout_ms / 1000.0
|
||||
lines, cur = [], []
|
||||
got_prompt = False
|
||||
|
||||
while time.monotonic() < dl:
|
||||
if self._ser.in_waiting > 0:
|
||||
ch = self._ser.read(1)
|
||||
if not ch:
|
||||
continue
|
||||
cp = ch[0]
|
||||
if cp == 62: # '>' — промпт ELM327
|
||||
self._push(cur, lines)
|
||||
got_prompt = True
|
||||
break
|
||||
elif cp == 13: # CR — конец строки
|
||||
self._push(cur, lines)
|
||||
elif cp in (10, 32): # LF и пробел — игнорируем
|
||||
pass
|
||||
else:
|
||||
cur.append(chr(cp))
|
||||
else:
|
||||
time.sleep(0.001) # поллинг 1мс
|
||||
|
||||
self._push(cur, lines)
|
||||
if not got_prompt:
|
||||
raise TimeoutError(f"read timeout {timeout_ms}ms")
|
||||
return "\n".join(lines)
|
||||
|
||||
def try_read(self, timeout: int = 5000):
|
||||
"""Прочитать и проигнорировать ответ."""
|
||||
try:
|
||||
self.read(timeout)
|
||||
except TimeoutError:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _push(cur, lines):
|
||||
"""Добавить накопленные байты как строку в lines."""
|
||||
if cur:
|
||||
lines.append("".join(cur))
|
||||
cur.clear()
|
||||
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
obd/probe.py — Пробинг ELM327: определение уровня устройства.
|
||||
|
||||
Трехуровневый каскад (использует obd/commands.py + obd/classifier.py):
|
||||
Уровень 0 (база, все клоны):
|
||||
ATE0 ATL0 ATS0 ATH1 ATSP0 ATDPN ATRV ATI
|
||||
→ хоть одна не ответила → НЕИСПРАВЕН (-1)
|
||||
|
||||
Уровень 1 (хорошие клоны):
|
||||
ATAT1 → уровень 1
|
||||
|
||||
Уровень 2 (настоящие ELM):
|
||||
ATCAF1 ATCFC1 → уровень 2
|
||||
|
||||
Принцип:
|
||||
- Каждая команда: ОДИН запрос, короткий таймаут (500мс)
|
||||
- Нет ретраев на неизвестные команды
|
||||
- Команды и классификация — в отдельных модулях
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from obd.commands import L0_NAMES, L1_NAMES, L2_NAMES
|
||||
from obd.classifier import classify, determine_level
|
||||
|
||||
logger = logging.getLogger("elmer.probe")
|
||||
|
||||
# Таймауты
|
||||
PROBE_TMO = 500 # мс — таймаут на одну команду при пробинге
|
||||
INTER_CMD_DELAY = 0.05 # с — пауза между командами
|
||||
|
||||
|
||||
def probe(elm) -> dict:
|
||||
"""Пробинг ELM327: определяет уровень и профиль устройства.
|
||||
|
||||
Args:
|
||||
elm: AndrOBD с открытым connect().
|
||||
|
||||
Returns:
|
||||
{"level": 0|1|2|-1, "elm_version": str|None, ...}
|
||||
level = -1 → устройство нерабочее.
|
||||
"""
|
||||
# Собираем ответы на все команды пробинга
|
||||
responses = _probe_commands(elm, L0_NAMES + L1_NAMES + L2_NAMES)
|
||||
|
||||
# Классифицируем — определяем уровень
|
||||
result = determine_level(responses)
|
||||
|
||||
if result["level"] >= 0:
|
||||
logger.info(f"probe: device level={result['level']}")
|
||||
else:
|
||||
logger.warning("probe: device UNUSABLE")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _probe_commands(elm, cmds: list[str]) -> dict[str, str]:
|
||||
"""Посылает список команд и собирает ответы.
|
||||
|
||||
Returns:
|
||||
{cmd_name: raw_response}
|
||||
"""
|
||||
responses = {}
|
||||
for cmd in cmds:
|
||||
ok, raw = _try_one(elm, cmd)
|
||||
responses[cmd] = raw if ok else ""
|
||||
return responses
|
||||
|
||||
|
||||
def _try_one(elm, cmd: str) -> tuple[bool, str]:
|
||||
"""Посылает ОДНУ команду в ELM, ждёт PROBE_TMO мс.
|
||||
|
||||
Returns:
|
||||
(ok, raw_response)
|
||||
"""
|
||||
elm._write(cmd)
|
||||
time.sleep(INTER_CMD_DELAY)
|
||||
try:
|
||||
raw = elm._read(PROBE_TMO)
|
||||
except TimeoutError:
|
||||
return False, ""
|
||||
|
||||
_, ok = classify(raw)
|
||||
return (ok, raw)
|
||||
|
||||
+55
-122
@@ -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
@@ -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
|
||||
@@ -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-dev — 6 июня 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 -->
|
||||
|
||||
Reference in New Issue
Block a user