- 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: версии
285 lines
12 KiB
Python
285 lines
12 KiB
Python
"""SQLite — сохранение сессий диагностики + профили ELM-устройств.
|
||
|
||
Таблица sessions (35+ колонок):
|
||
... (см. ниже)
|
||
|
||
Таблица device_profiles:
|
||
mac (TEXT PK), level (INT), elm_version, elm_desc, protocol,
|
||
supported (JSON), unsupported (JSON), first_seen, last_seen
|
||
"""
|
||
|
||
import json
|
||
import sqlite3
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
|
||
class Database:
|
||
def __init__(self, path: str | Path = "elmer.db"):
|
||
self.path = Path(path)
|
||
self.conn = sqlite3.connect(str(self.path), timeout=30, check_same_thread=False)
|
||
self.conn.row_factory = sqlite3.Row
|
||
self.conn.execute("PRAGMA journal_mode=WAL")
|
||
self.conn.execute("PRAGMA busy_timeout=30000")
|
||
self._init_schema()
|
||
|
||
def __enter__(self):
|
||
"""Контекстный менеджер: with Database() as db."""
|
||
return self
|
||
|
||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||
"""Закрытие соединения при выходе из with-блока."""
|
||
self.close()
|
||
return False
|
||
|
||
def close(self):
|
||
"""Закрыть соединение с SQLite."""
|
||
if self.conn:
|
||
self.conn.close()
|
||
self.conn = None
|
||
|
||
def _init_schema(self):
|
||
self.conn.executescript("""
|
||
CREATE TABLE IF NOT EXISTS sessions (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
||
-- Сервер
|
||
client_ip TEXT,
|
||
real_ip TEXT,
|
||
user_agent TEXT,
|
||
content_length INTEGER,
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||
|
||
-- Телефон
|
||
phone_model TEXT,
|
||
phone_maker TEXT,
|
||
android_version TEXT,
|
||
android_sdk INTEGER,
|
||
app_version TEXT,
|
||
android_id TEXT,
|
||
device_uuid TEXT,
|
||
phone_lang TEXT,
|
||
phone_tz TEXT,
|
||
phone_display TEXT,
|
||
|
||
-- ELM327
|
||
elm_mac TEXT,
|
||
elm_bt_name TEXT,
|
||
obd_protocol TEXT,
|
||
|
||
-- Авто
|
||
vin TEXT,
|
||
dtc_count INTEGER DEFAULT 0,
|
||
pid_count INTEGER DEFAULT 0,
|
||
|
||
-- Сессия
|
||
duration_ms INTEGER,
|
||
response_count INTEGER DEFAULT 0,
|
||
error_count INTEGER DEFAULT 0,
|
||
retry_count INTEGER DEFAULT 0,
|
||
timeout_count INTEGER DEFAULT 0,
|
||
script_mode TEXT,
|
||
transport TEXT, -- 'bt' | 'tcp'
|
||
mock_mode INTEGER DEFAULT 0,
|
||
car_info TEXT,
|
||
|
||
-- LLM
|
||
diagnosis_text TEXT,
|
||
diagnosis_len INTEGER,
|
||
llm_model TEXT,
|
||
llm_duration_ms INTEGER,
|
||
llm_success INTEGER DEFAULT 0,
|
||
|
||
-- Сырые данные (JSON)
|
||
raw_responses TEXT,
|
||
|
||
-- Идемпотентность
|
||
request_id TEXT UNIQUE,
|
||
response_json TEXT
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_sessions_created ON sessions(created_at);
|
||
CREATE INDEX IF NOT EXISTS idx_sessions_vin ON sessions(vin);
|
||
CREATE INDEX IF NOT EXISTS idx_sessions_mac ON sessions(elm_mac);
|
||
CREATE INDEX IF NOT EXISTS idx_sessions_aid ON sessions(android_id);
|
||
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()
|
||
|
||
# ── sessions ──────────────────────────────────────────
|
||
|
||
def get_cached_response(self, request_id: str) -> dict | None:
|
||
"""Возвращает сохранённый ответ сессии по request_id, или None."""
|
||
row = self.conn.execute(
|
||
"SELECT response_json FROM sessions WHERE request_id = ?", (request_id,)
|
||
).fetchone()
|
||
if row and row["response_json"]:
|
||
return json.loads(row["response_json"])
|
||
return None
|
||
|
||
def save_session(self, client_info: dict, responses: list[dict],
|
||
diagnosis: str = "", llm_model: str = "",
|
||
llm_duration_ms: int = 0, llm_success: bool = False,
|
||
request_id: str = "", response_json: dict | None = None):
|
||
"""Сохраняет сводную запись о сессии.
|
||
|
||
Если request_id передан и уже существует — silently return (идемпотентность).
|
||
"""
|
||
ci = client_info
|
||
|
||
# Подсчёт DTC/PID из ответов
|
||
dtc_count = 0
|
||
pid_count = 0
|
||
for r in responses:
|
||
dec = (r.get("decoded") or "").lower()
|
||
if dec.startswith("dtc"):
|
||
dtc_count += 1
|
||
elif ":" in dec and not dec.startswith(("vin", "dtc", "elm", "protocol")):
|
||
pid_count += 1
|
||
|
||
# VIN из ответов
|
||
vin = None
|
||
for r in responses:
|
||
dec = (r.get("decoded") or "")
|
||
if dec.startswith("VIN:"):
|
||
vin = dec[4:].strip()
|
||
if len(vin) != 17:
|
||
vin = None
|
||
break
|
||
|
||
resp_json_str = json.dumps(response_json, ensure_ascii=False) if response_json else None
|
||
|
||
self.conn.execute("""
|
||
INSERT OR IGNORE INTO sessions (
|
||
client_ip, real_ip, user_agent, content_length,
|
||
phone_model, phone_maker, android_version, android_sdk,
|
||
app_version, android_id, device_uuid, phone_lang, phone_tz, phone_display,
|
||
elm_mac, elm_bt_name, obd_protocol,
|
||
vin, dtc_count, pid_count,
|
||
duration_ms, response_count, error_count,
|
||
retry_count, timeout_count, script_mode,
|
||
transport, mock_mode, car_info,
|
||
diagnosis_text, diagnosis_len, llm_model,
|
||
llm_duration_ms, llm_success,
|
||
raw_responses, request_id, response_json
|
||
) VALUES (?,?,?,?, ?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?, ?,?,?, ?,?,?, ?,?, ?,?,?,?, ?,?,?,?,?)
|
||
|
||
""", (
|
||
ci.get("client_ip"), ci.get("real_ip"), ci.get("user_agent"),
|
||
ci.get("content_length"),
|
||
ci.get("phone_model"), ci.get("phone_maker"), ci.get("android_version"),
|
||
ci.get("android_sdk"), ci.get("app_version"), ci.get("android_id"),
|
||
ci.get("device_uuid"),
|
||
ci.get("phone_lang"), ci.get("phone_tz"), ci.get("phone_display"),
|
||
ci.get("elm_mac"), ci.get("elm_bt_name"), ci.get("obd_protocol"),
|
||
vin, dtc_count, pid_count,
|
||
ci.get("duration_ms"), len(responses), ci.get("error_count", 0),
|
||
ci.get("retry_count", 0), ci.get("timeout_count", 0),
|
||
ci.get("script_mode"), ci.get("transport"), ci.get("mock_mode", 0),
|
||
ci.get("car_info", ""),
|
||
diagnosis, len(diagnosis), llm_model,
|
||
llm_duration_ms, 1 if llm_success else 0,
|
||
json.dumps(responses, ensure_ascii=False) if responses else None,
|
||
request_id if request_id else None,
|
||
resp_json_str,
|
||
))
|
||
self.conn.commit()
|
||
|
||
def get_recent_sessions(self, limit: int = 50) -> list[dict]:
|
||
"""Последние N сессий."""
|
||
rows = self.conn.execute(
|
||
"SELECT * FROM sessions ORDER BY created_at DESC LIMIT ?", (limit,)
|
||
).fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
def save_dtc_scan(self, client_info: dict, dtc_codes: list[str]):
|
||
"""Сохраняет быстрый скан кодов ошибок."""
|
||
self.conn.execute("""
|
||
INSERT INTO sessions (
|
||
client_ip, real_ip, user_agent,
|
||
phone_model, phone_maker, android_version, android_sdk,
|
||
app_version, android_id, device_uuid,
|
||
elm_mac, elm_bt_name,
|
||
dtc_count, response_count,
|
||
script_mode, transport,
|
||
raw_responses
|
||
) VALUES (?,?,?, ?,?,?,?, ?,?,?, ?,?, ?,?, ?,?,?)
|
||
""", (
|
||
client_info.get("client_ip"), client_info.get("real_ip"), client_info.get("user_agent"),
|
||
client_info.get("phone_model"), client_info.get("phone_maker"), client_info.get("android_version"),
|
||
client_info.get("android_sdk"), client_info.get("app_version"), client_info.get("android_id"),
|
||
client_info.get("device_uuid"),
|
||
client_info.get("elm_mac"), client_info.get("elm_bt_name"),
|
||
len(dtc_codes), 0,
|
||
"dtc_scan", client_info.get("transport", "bt"),
|
||
json.dumps([{"decoded": f"DTC stored: {c}"} for c in dtc_codes], ensure_ascii=False)
|
||
))
|
||
self.conn.commit()
|
||
|
||
# ── 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()
|
||
|