refactor: разбил routes/db/protocol на отдельные модули
This commit is contained in:
@@ -1,12 +1,15 @@
|
|||||||
"""SQLite — сохранение сессий диагностики.
|
"""SQLite — сохранение сессий диагностики.
|
||||||
|
|
||||||
Схема:
|
Таблица sessions (35+ колонок):
|
||||||
cars — VIN, марка, модель, год, двигатель
|
client_ip, real_ip, user_agent, content_length, created_at
|
||||||
diagnostic_tokens — id (PK), car_id (FK), created_at
|
phone_model, phone_maker, android_version, android_sdk, app_version
|
||||||
llm_messages — token_id (FK), role, content, timestamp
|
android_id, device_uuid, phone_lang, phone_tz, phone_display
|
||||||
ecu_parameters — token_id (FK), pid_code, value, unit, timestamp
|
elm_mac, elm_bt_name, obd_protocol
|
||||||
dtc_codes — token_id (FK), code, description, status
|
vin, dtc_count, pid_count
|
||||||
sessions — сводная таблица всех сессий (клиент, ELM, авто, LLM)
|
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
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -38,49 +41,6 @@ class Database:
|
|||||||
|
|
||||||
def _init_schema(self):
|
def _init_schema(self):
|
||||||
self.conn.executescript("""
|
self.conn.executescript("""
|
||||||
CREATE TABLE IF NOT EXISTS cars (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
vin TEXT NOT NULL UNIQUE,
|
|
||||||
make TEXT,
|
|
||||||
model TEXT,
|
|
||||||
year INTEGER,
|
|
||||||
engine TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS diagnostic_tokens (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
car_id INTEGER NOT NULL REFERENCES cars(id),
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS llm_messages (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
token_id INTEGER NOT NULL REFERENCES diagnostic_tokens(id),
|
|
||||||
role TEXT NOT NULL, -- 'system' | 'user' | 'assistant'
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ecu_parameters (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
token_id INTEGER NOT NULL REFERENCES diagnostic_tokens(id),
|
|
||||||
pid_code TEXT NOT NULL, -- напр. '0105', '010C'
|
|
||||||
name TEXT, -- напр. 'coolant_temp', 'rpm'
|
|
||||||
value REAL,
|
|
||||||
unit TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS dtc_codes (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
token_id INTEGER NOT NULL REFERENCES diagnostic_tokens(id),
|
|
||||||
code TEXT NOT NULL, -- напр. 'P0301'
|
|
||||||
description TEXT,
|
|
||||||
status TEXT, -- 'stored' | 'pending'
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|
||||||
@@ -258,87 +218,3 @@ class Database:
|
|||||||
))
|
))
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
# ── cars ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
def get_or_create_car(self, vin: str) -> int:
|
|
||||||
"""Возвращает car_id по VIN, создаёт запись если нет."""
|
|
||||||
row = self.conn.execute("SELECT id FROM cars WHERE vin = ?", (vin,)).fetchone()
|
|
||||||
if row:
|
|
||||||
return row["id"]
|
|
||||||
cur = self.conn.execute("INSERT INTO cars (vin) VALUES (?)", (vin,))
|
|
||||||
self.conn.commit()
|
|
||||||
return cur.lastrowid
|
|
||||||
|
|
||||||
def update_car_info(self, car_id: int, make: str, model: str, year: int, engine: str):
|
|
||||||
self.conn.execute(
|
|
||||||
"UPDATE cars SET make=?, model=?, year=?, engine=? WHERE id=?",
|
|
||||||
(make, model, year, engine, car_id),
|
|
||||||
)
|
|
||||||
self.conn.commit()
|
|
||||||
|
|
||||||
# ── tokens ────────────────────────────────────────────
|
|
||||||
|
|
||||||
def create_token(self, car_id: int) -> int:
|
|
||||||
"""Создаёт новую сессию диагностики, возвращает token_id."""
|
|
||||||
cur = self.conn.execute(
|
|
||||||
"INSERT INTO diagnostic_tokens (car_id) VALUES (?)", (car_id,)
|
|
||||||
)
|
|
||||||
self.conn.commit()
|
|
||||||
return cur.lastrowid
|
|
||||||
|
|
||||||
def last_token_for_car(self, car_id: int) -> int | None:
|
|
||||||
"""Последняя сессия для VIN (для продолжения диалога), или None."""
|
|
||||||
row = self.conn.execute(
|
|
||||||
"SELECT id FROM diagnostic_tokens WHERE car_id=? ORDER BY created_at DESC LIMIT 1",
|
|
||||||
(car_id,),
|
|
||||||
).fetchone()
|
|
||||||
return row["id"] if row else None
|
|
||||||
|
|
||||||
# ── llm_messages ──────────────────────────────────────
|
|
||||||
|
|
||||||
def add_llm_message(self, token_id: int, role: str, content: str):
|
|
||||||
self.conn.execute(
|
|
||||||
"INSERT INTO llm_messages (token_id, role, content) VALUES (?, ?, ?)",
|
|
||||||
(token_id, role, content),
|
|
||||||
)
|
|
||||||
self.conn.commit()
|
|
||||||
|
|
||||||
def get_llm_messages(self, token_id: int) -> list[dict]:
|
|
||||||
"""Возвращает историю диалога для токена."""
|
|
||||||
rows = self.conn.execute(
|
|
||||||
"SELECT role, content FROM llm_messages WHERE token_id=? ORDER BY id",
|
|
||||||
(token_id,),
|
|
||||||
).fetchall()
|
|
||||||
return [{"role": r["role"], "content": r["content"]} for r in rows]
|
|
||||||
|
|
||||||
# ── ecu_parameters ────────────────────────────────────
|
|
||||||
|
|
||||||
def add_parameter(self, token_id: int, pid_code: str, name: str, value: float, unit: str):
|
|
||||||
self.conn.execute(
|
|
||||||
"INSERT INTO ecu_parameters (token_id, pid_code, name, value, unit) VALUES (?, ?, ?, ?, ?)",
|
|
||||||
(token_id, pid_code, name, value, unit),
|
|
||||||
)
|
|
||||||
self.conn.commit()
|
|
||||||
|
|
||||||
def get_parameters(self, token_id: int) -> list[dict]:
|
|
||||||
rows = self.conn.execute(
|
|
||||||
"SELECT pid_code, name, value, unit FROM ecu_parameters WHERE token_id=? ORDER BY id",
|
|
||||||
(token_id,),
|
|
||||||
).fetchall()
|
|
||||||
return [dict(r) for r in rows]
|
|
||||||
|
|
||||||
# ── dtc_codes ─────────────────────────────────────────
|
|
||||||
|
|
||||||
def add_dtc(self, token_id: int, code: str, description: str = "", status: str = "stored"):
|
|
||||||
self.conn.execute(
|
|
||||||
"INSERT INTO dtc_codes (token_id, code, description, status) VALUES (?, ?, ?, ?)",
|
|
||||||
(token_id, code, description, status),
|
|
||||||
)
|
|
||||||
self.conn.commit()
|
|
||||||
|
|
||||||
def get_dtcs(self, token_id: int) -> list[dict]:
|
|
||||||
rows = self.conn.execute(
|
|
||||||
"SELECT code, description, status FROM dtc_codes WHERE token_id=? ORDER BY id",
|
|
||||||
(token_id,),
|
|
||||||
).fetchall()
|
|
||||||
return [dict(r) for r in rows]
|
|
||||||
|
|||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
"""Эндпоинты DTC: расшифровка и сохранение кодов ошибок.
|
||||||
|
|
||||||
|
POST /api/v1/dtc/decode — расшифровка кодов
|
||||||
|
POST /api/v1/dtc/upload — приём скана ошибок
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import jsonify, request
|
||||||
|
|
||||||
|
from api.db import Database
|
||||||
|
|
||||||
|
logger = logging.getLogger("elmer.dtc")
|
||||||
|
|
||||||
|
# Загрузка справочника DTC
|
||||||
|
_DTC_CODES: dict[str, str] = {}
|
||||||
|
_DTC_PATH = Path(__file__).parent.parent / "doc" / "dtc_codes.txt"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_dtc_dict() -> dict[str, str]:
|
||||||
|
global _DTC_CODES
|
||||||
|
if _DTC_CODES:
|
||||||
|
return _DTC_CODES
|
||||||
|
if _DTC_PATH.exists():
|
||||||
|
for line in _DTC_PATH.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if "=" in line and not line.startswith("#"):
|
||||||
|
code, desc = line.split("=", 1)
|
||||||
|
_DTC_CODES[code.strip()] = desc.strip()
|
||||||
|
return _DTC_CODES
|
||||||
|
|
||||||
|
|
||||||
|
def register(app):
|
||||||
|
"""Регистрирует DTC-эндпоинты на Flask-приложении."""
|
||||||
|
|
||||||
|
@app.route("/api/v1/dtc/decode", methods=["POST"])
|
||||||
|
def dtc_decode():
|
||||||
|
"""Расшифровка кодов DTC из справочника."""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
codes = data.get("codes", [])
|
||||||
|
if not codes:
|
||||||
|
return jsonify({"codes": []})
|
||||||
|
dtc = _load_dtc_dict()
|
||||||
|
decoded = [{"code": c.strip().upper(), "desc": dtc.get(c.strip().upper(), c)} for c in codes]
|
||||||
|
return jsonify({"codes": decoded})
|
||||||
|
|
||||||
|
@app.route("/api/v1/dtc/upload", methods=["POST"])
|
||||||
|
def dtc_upload():
|
||||||
|
"""Приём кодов ошибок (без полной диагностики)."""
|
||||||
|
data = request.get_json(silent=True)
|
||||||
|
if not data:
|
||||||
|
return jsonify({"error": "empty"}), 400
|
||||||
|
|
||||||
|
codes = data.get("dtc_codes", [])
|
||||||
|
client_info = data.get("client_info", {})
|
||||||
|
client_info["client_ip"] = request.remote_addr
|
||||||
|
|
||||||
|
# Идемпотентность
|
||||||
|
request_id = (data.get("request_id") or "").strip()
|
||||||
|
if request_id:
|
||||||
|
with Database() as db:
|
||||||
|
cached = db.get_cached_response(request_id)
|
||||||
|
if cached is not None:
|
||||||
|
return jsonify(cached), 200
|
||||||
|
|
||||||
|
try:
|
||||||
|
with Database() as db:
|
||||||
|
db.save_dtc_scan(client_info, codes)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"DTC upload failed: {e}")
|
||||||
|
|
||||||
|
dtc = _load_dtc_dict()
|
||||||
|
decoded = [{"code": c, "desc": dtc.get(c.strip().upper(), c)} for c in codes]
|
||||||
|
response = {"codes": decoded, "count": len(decoded)}
|
||||||
|
|
||||||
|
# Кэш для идемпотентности
|
||||||
|
if request_id:
|
||||||
|
try:
|
||||||
|
with Database() as db:
|
||||||
|
db.save_session(
|
||||||
|
client_info=client_info,
|
||||||
|
responses=[{"decoded": f"DTC stored: {c}"} for c in codes],
|
||||||
|
request_id=request_id,
|
||||||
|
response_json=response,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"DTC idempotency cache failed: {e}")
|
||||||
|
|
||||||
|
return jsonify(response)
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
"""Эндпоинты проверки доступности.
|
||||||
|
|
||||||
|
GET /api/v1/ping — проверка сервера
|
||||||
|
GET /api/v1/ping-llm — проверка LLM (с адаптивным кэшем)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from flask import jsonify, request
|
||||||
|
|
||||||
|
from api.config import load
|
||||||
|
from brain.client import Diagnoser
|
||||||
|
|
||||||
|
logger = logging.getLogger("elmer.ping")
|
||||||
|
|
||||||
|
# Кэш для /ping-llm (успех=60с, ошибка=7с)
|
||||||
|
_ping_llm_cache: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
def register(app):
|
||||||
|
"""Регистрирует ping-эндпоинты на Flask-приложении."""
|
||||||
|
|
||||||
|
@app.route("/api/v1/ping", methods=["GET"])
|
||||||
|
def ping():
|
||||||
|
"""Быстрая проверка доступности сервера."""
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@app.route("/api/v1/ping-llm", methods=["GET"])
|
||||||
|
def ping_llm():
|
||||||
|
"""Проверка LLM с адаптивным кэшем.
|
||||||
|
|
||||||
|
- Успех → кэш 60с
|
||||||
|
- Ошибка → кэш 7с (LLM мог уже ожить)
|
||||||
|
"""
|
||||||
|
global _ping_llm_cache
|
||||||
|
now = time.time()
|
||||||
|
if _ping_llm_cache:
|
||||||
|
ttl = _ping_llm_cache.get("ttl", 7)
|
||||||
|
if (now - _ping_llm_cache.get("ts", 0)) < ttl:
|
||||||
|
return jsonify(_ping_llm_cache["data"])
|
||||||
|
|
||||||
|
cfg = load()
|
||||||
|
api_key = cfg["llm"]["api_key"]
|
||||||
|
if not api_key:
|
||||||
|
result = {"ok": False, "error": "no API key"}
|
||||||
|
else:
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
diagnoser = Diagnoser(
|
||||||
|
api_key=api_key,
|
||||||
|
model=cfg["llm"].get("model", "gpt-oss-120b"),
|
||||||
|
base_url=cfg["llm"].get("base_url", "https://api.aillm.ru/v1"),
|
||||||
|
)
|
||||||
|
diagnoser.diagnose("Отвечай одним словом.", "OK")
|
||||||
|
ms = int((time.time() - t0) * 1000)
|
||||||
|
result = {"ok": True, "ms": ms}
|
||||||
|
except Exception as e:
|
||||||
|
ms = int((time.time() - t0) * 1000)
|
||||||
|
result = {"ok": False, "ms": ms, "error": "LLM unavailable"}
|
||||||
|
|
||||||
|
ttl = 60 if result.get("ok") else 7
|
||||||
|
_ping_llm_cache = {"ts": now, "data": result, "ttl": ttl}
|
||||||
|
return jsonify(result)
|
||||||
+5
-119
@@ -1,12 +1,14 @@
|
|||||||
"""Эндпоинты для толстого клиента: скрипты и батчевая загрузка.
|
"""Эндпоинты: скрипт, загрузка сессии, чат.
|
||||||
|
|
||||||
GET /api/v1/script — выдача скрипта диагностики
|
GET /api/v1/script — выдача скрипта диагностики
|
||||||
POST /api/v1/session/upload — приём батча, LLM-анализ, возврат диагноза
|
POST /api/v1/session/upload — приём батча + LLM
|
||||||
|
POST /api/v1/chat — свободный вопрос к LLM
|
||||||
|
|
||||||
|
См. также: api/dtc.py (DTC), api/ping.py (ping)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from flask import jsonify, request
|
from flask import jsonify, request
|
||||||
|
|
||||||
@@ -19,9 +21,6 @@ from brain.prompts import SYSTEM_PROMPT
|
|||||||
|
|
||||||
logger = logging.getLogger("elmer.script")
|
logger = logging.getLogger("elmer.script")
|
||||||
|
|
||||||
# Кэш для /ping-llm (60 секунд)
|
|
||||||
_ping_llm_cache: dict = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _build_diagnosis_prompt(data: dict, car_info: str = "") -> str:
|
def _build_diagnosis_prompt(data: dict, car_info: str = "") -> str:
|
||||||
"""Строит промпт для LLM из распарсенных данных."""
|
"""Строит промпт для LLM из распарсенных данных."""
|
||||||
@@ -65,20 +64,6 @@ def _build_diagnosis_prompt(data: dict, car_info: str = "") -> str:
|
|||||||
# Загрузка справочника DTC
|
# Загрузка справочника DTC
|
||||||
_DTC_CODES: dict[str, str] = {}
|
_DTC_CODES: dict[str, str] = {}
|
||||||
|
|
||||||
def _load_dtc_dict():
|
|
||||||
global _DTC_CODES
|
|
||||||
if _DTC_CODES:
|
|
||||||
return _DTC_CODES
|
|
||||||
path = Path(__file__).parent.parent / "doc" / "dtc_codes.txt"
|
|
||||||
if path.exists():
|
|
||||||
for line in path.read_text(encoding="utf-8").splitlines():
|
|
||||||
line = line.strip()
|
|
||||||
if "=" in line and not line.startswith("#"):
|
|
||||||
code, desc = line.split("=", 1)
|
|
||||||
_DTC_CODES[code.strip()] = desc.strip()
|
|
||||||
return _DTC_CODES
|
|
||||||
|
|
||||||
|
|
||||||
def register(app):
|
def register(app):
|
||||||
"""Регистрирует эндпоинты скриптов на Flask-приложении."""
|
"""Регистрирует эндпоинты скриптов на Flask-приложении."""
|
||||||
|
|
||||||
@@ -209,105 +194,6 @@ def register(app):
|
|||||||
|
|
||||||
return jsonify({"answer": answer})
|
return jsonify({"answer": answer})
|
||||||
|
|
||||||
@app.route("/api/v1/ping", methods=["GET"])
|
|
||||||
def ping():
|
|
||||||
"""Быстрая проверка доступности сервера."""
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
@app.route("/api/v1/ping-llm", methods=["GET"])
|
|
||||||
def ping_llm():
|
|
||||||
"""Проверка доступности LLM с адаптивным кэшем.
|
|
||||||
|
|
||||||
- Успех → кэш 60с (нет смысла переспрашивать)
|
|
||||||
- Ошибка → кэш 7с (возможно LLM уже ожил)
|
|
||||||
"""
|
|
||||||
global _ping_llm_cache
|
|
||||||
now = time.time()
|
|
||||||
if _ping_llm_cache:
|
|
||||||
ttl = _ping_llm_cache.get("ttl", 7)
|
|
||||||
if (now - _ping_llm_cache.get("ts", 0)) < ttl:
|
|
||||||
return jsonify(_ping_llm_cache["data"])
|
|
||||||
|
|
||||||
cfg = load()
|
|
||||||
api_key = cfg["llm"]["api_key"]
|
|
||||||
if not api_key:
|
|
||||||
result = {"ok": False, "error": "no API key"}
|
|
||||||
else:
|
|
||||||
t0 = time.time()
|
|
||||||
try:
|
|
||||||
diagnoser = Diagnoser(
|
|
||||||
api_key=api_key,
|
|
||||||
model=cfg["llm"].get("model", "gpt-oss-120b"),
|
|
||||||
base_url=cfg["llm"].get("base_url", "https://api.aillm.ru/v1"),
|
|
||||||
)
|
|
||||||
diagnoser.diagnose("Отвечай одним словом.", "OK")
|
|
||||||
ms = int((time.time() - t0) * 1000)
|
|
||||||
result = {"ok": True, "ms": ms}
|
|
||||||
except Exception as e:
|
|
||||||
ms = int((time.time() - t0) * 1000)
|
|
||||||
result = {"ok": False, "ms": ms, "error": "LLM unavailable"}
|
|
||||||
|
|
||||||
ttl = 60 if result.get("ok") else 7
|
|
||||||
_ping_llm_cache = {"ts": now, "data": result, "ttl": ttl}
|
|
||||||
return jsonify(result)
|
|
||||||
|
|
||||||
@app.route("/api/v1/dtc/decode", methods=["POST"])
|
|
||||||
def dtc_decode():
|
|
||||||
"""Расшифровка кодов DTC."""
|
|
||||||
data = request.get_json(silent=True) or {}
|
|
||||||
codes = data.get("codes", [])
|
|
||||||
if not codes:
|
|
||||||
return jsonify({"codes": []})
|
|
||||||
dtc = _load_dtc_dict()
|
|
||||||
decoded = []
|
|
||||||
for c in codes:
|
|
||||||
c = c.strip().upper()
|
|
||||||
decoded.append({"code": c, "desc": dtc.get(c, c)})
|
|
||||||
return jsonify({"codes": decoded})
|
|
||||||
|
|
||||||
@app.route("/api/v1/dtc/upload", methods=["POST"])
|
|
||||||
def dtc_upload():
|
|
||||||
"""Приём кодов ошибок без полной диагностики."""
|
|
||||||
data = request.get_json(silent=True)
|
|
||||||
if not data:
|
|
||||||
return jsonify({"error": "empty"}), 400
|
|
||||||
codes = data.get("dtc_codes", [])
|
|
||||||
client_info = data.get("client_info", {})
|
|
||||||
client_info["client_ip"] = request.remote_addr
|
|
||||||
|
|
||||||
# Идемпотентность
|
|
||||||
request_id = (data.get("request_id") or "").strip()
|
|
||||||
if request_id:
|
|
||||||
with Database() as db:
|
|
||||||
cached = db.get_cached_response(request_id)
|
|
||||||
if cached is not None:
|
|
||||||
return jsonify(cached), 200
|
|
||||||
|
|
||||||
try:
|
|
||||||
with Database() as db:
|
|
||||||
db.save_dtc_scan(client_info, codes)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"DTC upload failed: {e}")
|
|
||||||
|
|
||||||
dtc = _load_dtc_dict()
|
|
||||||
decoded = [{"code": c, "desc": dtc.get(c.strip().upper(), c)} for c in codes]
|
|
||||||
response = {"codes": decoded, "count": len(decoded)}
|
|
||||||
|
|
||||||
# Сохраняем кэш для идемпотентности
|
|
||||||
if request_id:
|
|
||||||
try:
|
|
||||||
with Database() as db:
|
|
||||||
db.save_session(
|
|
||||||
client_info=client_info,
|
|
||||||
responses=[{"decoded": f"DTC stored: {c}"} for c in codes],
|
|
||||||
request_id=request_id,
|
|
||||||
response_json=response,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"DTC idempotency cache failed: {e}")
|
|
||||||
|
|
||||||
return jsonify(response)
|
|
||||||
|
|
||||||
|
|
||||||
def _summary(p: dict) -> dict:
|
def _summary(p: dict) -> dict:
|
||||||
return {
|
return {
|
||||||
|
|||||||
+3
-103
@@ -34,114 +34,14 @@ obd/protocol.py — ELM327 стейт-машина (AndrOBD).
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from enum import Enum, auto
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from obd.state import State, Rsp
|
||||||
|
from obd.timing import AdaptiveTiming
|
||||||
|
|
||||||
logger = logging.getLogger("androbd")
|
logger = logging.getLogger("androbd")
|
||||||
|
|
||||||
|
|
||||||
class State(Enum):
|
|
||||||
"""Состояния стейт-машины (AndrOBD STAT).
|
|
||||||
|
|
||||||
UNDEFINED → INITIALIZING → READY — нормальный путь.
|
|
||||||
BUSY — во время выполнения команды.
|
|
||||||
ERROR/DISCONNECTED — ошибка, требуется восстановление.
|
|
||||||
"""
|
|
||||||
UNDEFINED = auto()
|
|
||||||
INITIALIZING = auto()
|
|
||||||
READY = auto()
|
|
||||||
BUSY = auto()
|
|
||||||
ERROR = auto()
|
|
||||||
DISCONNECTED = auto()
|
|
||||||
|
|
||||||
|
|
||||||
class Rsp:
|
|
||||||
"""Классификация ответов ELM327 (AndrOBD RSP_ID).
|
|
||||||
|
|
||||||
Каждый сырой ответ классифицируется:
|
|
||||||
- PROMPT (`>`) — готов к следующей команде
|
|
||||||
- OK — команда выполнена
|
|
||||||
- SEARCHING — идёт поиск протокола
|
|
||||||
- BUS_ERROR/BUS_BUSY/CAN_ERROR/STOPPED — ошибка шины → DISCONNECTED
|
|
||||||
- ERROR/DATA_ERROR/BUFFER_FULL — ошибка → warm start
|
|
||||||
- UNKNOWN — данные (ответ на PID/DTC)
|
|
||||||
"""
|
|
||||||
PROMPT = ">"
|
|
||||||
OK = "OK"
|
|
||||||
SEARCHING = "SEARCHING"
|
|
||||||
NODATA = "NODATA"
|
|
||||||
ERROR = "ERROR"
|
|
||||||
UNABLE = "UNABLE"
|
|
||||||
BUS_BUSY = "BUS BUSY"
|
|
||||||
BUS_ERROR = "BUS ERROR"
|
|
||||||
CAN_ERROR = "CAN ERROR"
|
|
||||||
BUS_INIT = "BUS INIT"
|
|
||||||
STOPPED = "STOPPED"
|
|
||||||
DATA_ERROR = "DATA ERROR"
|
|
||||||
BUFFER_FULL= "BUFFER FULL"
|
|
||||||
RX_ERROR = "RX ERROR"
|
|
||||||
UNKNOWN = ""
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def identify(cls, raw: str) -> str:
|
|
||||||
"""Определяет тип ответа по сырой строке."""
|
|
||||||
u = raw.upper().strip()
|
|
||||||
for tag in (cls.SEARCHING, cls.NODATA, cls.ERROR, cls.UNABLE,
|
|
||||||
cls.BUS_BUSY, cls.BUS_ERROR, cls.CAN_ERROR,
|
|
||||||
cls.BUS_INIT, cls.STOPPED, cls.DATA_ERROR,
|
|
||||||
cls.BUFFER_FULL, cls.RX_ERROR, cls.OK):
|
|
||||||
if u.startswith(tag):
|
|
||||||
return tag
|
|
||||||
if raw.strip() == ">":
|
|
||||||
return cls.PROMPT
|
|
||||||
return cls.UNKNOWN
|
|
||||||
|
|
||||||
|
|
||||||
class AdaptiveTiming:
|
|
||||||
"""Адаптивный таймаут ожидания ответа (AndrOBD AdaptiveTiming).
|
|
||||||
|
|
||||||
Динамически подстраивается под скорость ответа ЭБУ:
|
|
||||||
- Успешный ответ → уменьшаем таймаут (быстрее)
|
|
||||||
- Таймаут/NO DATA → увеличиваем таймаут (медленнее)
|
|
||||||
- BUS ERROR → сброс до DEFAULT
|
|
||||||
|
|
||||||
ATST = таймаут / 4 (отправляется в ELM327 как ATSTxx).
|
|
||||||
"""
|
|
||||||
DEFAULT = 500 # мс — начальный таймаут
|
|
||||||
MIN = 50 # мс — минимальный
|
|
||||||
MAX = 2000 # мс — максимальный
|
|
||||||
STEP = 20 # мс — шаг изменения
|
|
||||||
RES = 4 # делитель для ATST
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._t = self.DEFAULT
|
|
||||||
self._min = self.MIN
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ms(self) -> int:
|
|
||||||
"""Текущий таймаут в миллисекундах."""
|
|
||||||
return self._t
|
|
||||||
|
|
||||||
@property
|
|
||||||
def atst(self) -> int:
|
|
||||||
"""Значение для ATST (таймаут / 4)."""
|
|
||||||
return max(1, self._t // self.RES)
|
|
||||||
|
|
||||||
def increase(self):
|
|
||||||
"""Увеличить таймаут (ЭБУ медленно отвечает)."""
|
|
||||||
if self._t + self.STEP < self.MAX:
|
|
||||||
self._t += self.STEP
|
|
||||||
|
|
||||||
def decrease(self):
|
|
||||||
"""Уменьшить таймаут (ЭБУ отвечает быстро)."""
|
|
||||||
if self._t - self.STEP >= self._min:
|
|
||||||
self._t -= self.STEP
|
|
||||||
|
|
||||||
def reset(self):
|
|
||||||
"""Сбросить до DEFAULT (после BUS ERROR)."""
|
|
||||||
self._t = self.DEFAULT
|
|
||||||
|
|
||||||
|
|
||||||
class AndrOBD:
|
class AndrOBD:
|
||||||
"""Стейт-машина ELM327 — 1:1 копия AndrOBD (ElmProt.java).
|
"""Стейт-машина ELM327 — 1:1 копия AndrOBD (ElmProt.java).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Состояния и классификация ответов ELM327 (AndrOBD STAT/RSP_ID).
|
||||||
|
|
||||||
|
State — состояния стейт-машины (UNDEFINED → INITIALIZING → READY → BUSY → ...)
|
||||||
|
Rsp — классификация сырых ответов ELM327
|
||||||
|
"""
|
||||||
|
|
||||||
|
from enum import Enum, auto
|
||||||
|
|
||||||
|
|
||||||
|
class State(Enum):
|
||||||
|
"""Состояния стейт-машины (AndrOBD STAT).
|
||||||
|
|
||||||
|
UNDEFINED → INITIALIZING → READY — нормальный путь.
|
||||||
|
BUSY — во время выполнения команды.
|
||||||
|
ERROR/DISCONNECTED — ошибка, требуется восстановление.
|
||||||
|
"""
|
||||||
|
UNDEFINED = auto()
|
||||||
|
INITIALIZING = auto()
|
||||||
|
READY = auto()
|
||||||
|
BUSY = auto()
|
||||||
|
ERROR = auto()
|
||||||
|
DISCONNECTED = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class Rsp:
|
||||||
|
"""Классификация ответов ELM327 (AndrOBD RSP_ID).
|
||||||
|
|
||||||
|
Каждый сырой ответ классифицируется:
|
||||||
|
- PROMPT (`>`) — готов к следующей команде
|
||||||
|
- OK — команда выполнена
|
||||||
|
- SEARCHING — идёт поиск протокола
|
||||||
|
- BUS_ERROR/BUS_BUSY/CAN_ERROR/STOPPED — ошибка шины → DISCONNECTED
|
||||||
|
- ERROR/DATA_ERROR/BUFFER_FULL — ошибка → warm start
|
||||||
|
- UNKNOWN — данные (ответ на PID/DTC)
|
||||||
|
"""
|
||||||
|
PROMPT = ">"
|
||||||
|
OK = "OK"
|
||||||
|
SEARCHING = "SEARCHING"
|
||||||
|
NODATA = "NODATA"
|
||||||
|
ERROR = "ERROR"
|
||||||
|
UNABLE = "UNABLE"
|
||||||
|
BUS_BUSY = "BUS BUSY"
|
||||||
|
BUS_ERROR = "BUS ERROR"
|
||||||
|
CAN_ERROR = "CAN ERROR"
|
||||||
|
BUS_INIT = "BUS INIT"
|
||||||
|
STOPPED = "STOPPED"
|
||||||
|
DATA_ERROR = "DATA ERROR"
|
||||||
|
BUFFER_FULL= "BUFFER FULL"
|
||||||
|
RX_ERROR = "RX ERROR"
|
||||||
|
UNKNOWN = ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def identify(cls, raw: str) -> str:
|
||||||
|
"""Определяет тип ответа по сырой строке."""
|
||||||
|
u = raw.upper().strip()
|
||||||
|
for tag in (cls.SEARCHING, cls.NODATA, cls.ERROR, cls.UNABLE,
|
||||||
|
cls.BUS_BUSY, cls.BUS_ERROR, cls.CAN_ERROR,
|
||||||
|
cls.BUS_INIT, cls.STOPPED, cls.DATA_ERROR,
|
||||||
|
cls.BUFFER_FULL, cls.RX_ERROR, cls.OK):
|
||||||
|
if u.startswith(tag):
|
||||||
|
return tag
|
||||||
|
if raw.strip() == ">":
|
||||||
|
return cls.PROMPT
|
||||||
|
return cls.UNKNOWN
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Адаптивный таймаут ожидания ответа ЭБУ (AndrOBD AdaptiveTiming).
|
||||||
|
|
||||||
|
Динамически подстраивается под скорость ответа:
|
||||||
|
- Успешный ответ → уменьшаем таймаут
|
||||||
|
- Таймаут/NO DATA → увеличиваем таймаут
|
||||||
|
- BUS ERROR → сброс до DEFAULT
|
||||||
|
|
||||||
|
ATST = таймаут / 4 (отправляется в ELM327 как ATSTxx).
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class AdaptiveTiming:
|
||||||
|
"""Адаптивный таймаут ожидания ответа (AndrOBD AdaptiveTiming).
|
||||||
|
|
||||||
|
ATST = таймаут / 4 (отправляется в ELM327 как ATSTxx).
|
||||||
|
"""
|
||||||
|
DEFAULT = 500 # мс — начальный таймаут
|
||||||
|
MIN = 50 # мс — минимальный
|
||||||
|
MAX = 2000 # мс — максимальный
|
||||||
|
STEP = 20 # мс — шаг изменения
|
||||||
|
RES = 4 # делитель для ATST
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._t = self.DEFAULT
|
||||||
|
self._min = self.MIN
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ms(self) -> int:
|
||||||
|
"""Текущий таймаут в миллисекундах."""
|
||||||
|
return self._t
|
||||||
|
|
||||||
|
@property
|
||||||
|
def atst(self) -> int:
|
||||||
|
"""Значение для ATST (таймаут / 4)."""
|
||||||
|
return max(1, self._t // self.RES)
|
||||||
|
|
||||||
|
def increase(self):
|
||||||
|
"""Увеличить таймаут (ЭБУ медленно отвечает)."""
|
||||||
|
if self._t + self.STEP < self.MAX:
|
||||||
|
self._t += self.STEP
|
||||||
|
|
||||||
|
def decrease(self):
|
||||||
|
"""Уменьшить таймаут (ЭБУ отвечает быстро)."""
|
||||||
|
if self._t - self.STEP >= self._min:
|
||||||
|
self._t -= self.STEP
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""Сбросить до DEFAULT (после BUS ERROR)."""
|
||||||
|
self._t = self.DEFAULT
|
||||||
+2
-1
@@ -18,7 +18,8 @@ os.environ["ELMER_CONFIG"] = str(Path(__file__).parent / "config.yaml")
|
|||||||
|
|
||||||
from api.db import Database
|
from api.db import Database
|
||||||
from api.parser import parse_batch, format_no_llm
|
from api.parser import parse_batch, format_no_llm
|
||||||
from api.routes import _load_dtc_dict, _build_diagnosis_prompt
|
from api.routes import _build_diagnosis_prompt
|
||||||
|
from api.dtc import _load_dtc_dict
|
||||||
from api.scripts import build_default_script, build_full_script
|
from api.scripts import build_default_script, build_full_script
|
||||||
|
|
||||||
passed = 0
|
passed = 0
|
||||||
|
|||||||
@@ -20,12 +20,16 @@ from flask import Flask, jsonify, redirect, render_template, request, send_from_
|
|||||||
|
|
||||||
from api.config import load
|
from api.config import load
|
||||||
from api.routes import register as register_api
|
from api.routes import register as register_api
|
||||||
|
from api.dtc import register as register_dtc
|
||||||
|
from api.ping import register as register_ping
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s")
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
config = load()
|
config = load()
|
||||||
register_api(app)
|
register_api(app)
|
||||||
|
register_dtc(app)
|
||||||
|
register_ping(app)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
|
|||||||
Reference in New Issue
Block a user