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
+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()