Author SHA1 Message Date
Repinoid eab73cd76a doc: описание архитектуры — структура, модули, API 2026-05-29 17:27:07 +03:00
Repinoid 2db6cb68a8 refactor: архитектура сервера — obd/ brain/ api/
- obd/protocol.py — ELM327 стейт-машина AndrOBD (подробные докстринги)
- brain/client.py — LLM-клиент Diagnoser
- brain/prompts.py — системные промпты
- api/db.py — SQLite (sessions + старые таблицы)
- api/scripts.py — сборка диагностических скриптов
- api/parser.py — парсинг ответов ELM327
- api/routes.py — все REST-эндпоинты
- api/config.py — загрузка конфига
- web/app.py — точка входа Flask (упрощена)

Удалено: elmer/elm.py, elmer/elm_proto.py, web/raw_endpoint.py
2026-05-29 17:24:33 +03:00
Repinoid 2028c7ff0e doc: документ прогресса и план рефакторинга архитектуры 2026-05-29 17:16:49 +03:00
Repinoid 2d5c704edf Убрал mock, добавил llm_available, таймер + счётчик при upload 2026-05-29 10:17:26 +03:00
Repinoid 0d1cf026dc elmAI rebrand: визуальные названия Elmer → elmAI 2026-05-28 18:25:14 +03:00
Repinoid f40918d07b fix: убрана кнопка десктоп-диагностики, версия на странице 2026-05-28 15:51:38 +03:00
Repinoid 84bd0cb34a fix: LLM timeout 60→120с + graceful fallback без 500 ошибки 2026-05-28 10:48:32 +03:00
Repinoid d1903008c6 docs: сессия 28 мая — AndrOBD стейт-машина, 5 багов, все исправления 2026-05-28 10:09:26 +03:00
Repinoid 41831468f4 feat: AndrOBD — полная стейт-машина (State, Rsp, AdaptiveTiming) + тест 2026-05-28 09:03:36 +03:00
Repinoid 6372d38692 feat: AndrOBD-протокол — 1:1 копия стейт-машины + тест против мока 2026-05-28 08:52:09 +03:00
26 changed files with 2121 additions and 968 deletions
View File
+39
View File
@@ -0,0 +1,39 @@
"""Загрузка конфигурации из config.yaml."""
import os
from pathlib import Path
import yaml
CONFIG_PATH = Path(os.environ.get("ELMER_CONFIG", Path(__file__).parent.parent / "config.yaml"))
def load() -> dict:
"""Читает config.yaml, подставляет переменные окружения в значения."""
if not CONFIG_PATH.exists():
raise FileNotFoundError(f"Конфиг не найден: {CONFIG_PATH}")
with open(CONFIG_PATH) as f:
config = yaml.safe_load(f)
# Подстановка ${VAR} из переменных окружения
_resolve_env(config)
return config
def _resolve_env(obj):
"""Рекурсивно заменяет ${VAR} на os.environ['VAR']."""
if isinstance(obj, dict):
for k, v in obj.items():
if isinstance(v, str) and v.startswith("${") and v.endswith("}"):
env_var = v[2:-1]
obj[k] = os.environ.get(env_var, "")
else:
_resolve_env(v)
elif isinstance(obj, list):
for i, v in enumerate(obj):
if isinstance(v, str) and v.startswith("${") and v.endswith("}"):
env_var = v[2:-1]
obj[i] = os.environ.get(env_var, "")
else:
_resolve_env(v)
+275
View File
@@ -0,0 +1,275 @@
"""SQLite — сохранение сессий диагностики.
Схема:
cars — VIN, марка, модель, год, двигатель
diagnostic_tokens — id (PK), car_id (FK), created_at
llm_messages — token_id (FK), role, content, timestamp
ecu_parameters — token_id (FK), pid_code, value, unit, timestamp
dtc_codes — token_id (FK), code, description, status
sessions — сводная таблица всех сессий (клиент, ELM, авто, LLM)
"""
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))
self.conn.row_factory = sqlite3.Row
self._init_schema()
def _init_schema(self):
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 (
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,
-- 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,
-- LLM
diagnosis_text TEXT,
diagnosis_len INTEGER,
llm_model TEXT,
llm_duration_ms INTEGER,
llm_success INTEGER DEFAULT 0,
-- Сырые данные (JSON)
raw_responses 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);
""")
self.conn.commit()
# ── sessions ──────────────────────────────────────────
def save_session(self, client_info: dict, responses: list[dict],
diagnosis: str = "", llm_model: str = "",
llm_duration_ms: int = 0, llm_success: bool = False):
"""Сохраняет сводную запись о сессии."""
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
self.conn.execute("""
INSERT INTO sessions (
client_ip, real_ip, user_agent, content_length,
phone_model, phone_maker, android_version, android_sdk,
app_version, android_id,
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,
diagnosis_text, diagnosis_len, llm_model,
llm_duration_ms, llm_success,
raw_responses
) 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("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),
diagnosis, len(diagnosis), llm_model,
llm_duration_ms, 1 if llm_success else 0,
json.dumps(responses, ensure_ascii=False) if responses else None,
))
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]
# ── 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]
+130
View File
@@ -0,0 +1,130 @@
"""Парсинг батча ответов ELM327 в структуру для LLM.
Поддерживает:
- VIN (mode 09 PID 02) — из decoded и fallback из raw HEX
- DTC stored/pending (mode 03/07) — из decoded и fallback из raw HEX
- PID параметры (mode 01) — из decoded
"""
import logging
logger = logging.getLogger("elmer.parser")
def parse_batch(responses: list[dict]) -> dict:
"""Парсит батч ответов в структуру для LLM.
Returns:
{"vin": str|None, "dtc_stored": [str], "dtc_pending": [str],
"parameters": [{"name": str, "value": str}], "raw_log": [str]}
"""
result = {
"vin": None,
"dtc_stored": [],
"dtc_pending": [],
"parameters": [],
"raw_log": [],
}
for r in responses:
cmd = (r.get("cmd") or "").strip()
raw = (r.get("raw") or "").strip()
decoded = (r.get("decoded") or "").strip()
result["raw_log"].append(f"{cmd}\n{raw}")
_parse_vin(result, raw, decoded)
_parse_dtc(result, raw, decoded, mode="03", key="dtc_stored", prefix="DTC stored:")
_parse_dtc(result, raw, decoded, mode="47", key="dtc_pending", prefix="DTC pending:")
_parse_pid(result, decoded, cmd)
logger.info(f"[{cmd}] decoded={decoded[:60]}")
return result
def _parse_vin(result: dict, raw: str, decoded: str):
if decoded.startswith("VIN:"):
vin = decoded.replace("VIN:", "").strip()
if len(vin) == 17:
result["vin"] = vin
return
# Fallback: парсим VIN из raw HEX
if "49" in raw and ("02" in raw or "4902" in raw.replace(" ", "")):
clean = raw.replace(":", "").replace(" ", "").upper()
if "490201" in clean:
hex_str = clean.split("490201")[-1][:34]
vin = ""
for i in range(0, len(hex_str) - 1, 2):
try:
vin += chr(int(hex_str[i:i+2], 16))
except (ValueError, OverflowError):
pass
if len(vin) == 17:
result["vin"] = vin
def _parse_dtc(result: dict, raw: str, decoded: str, *, mode: str, key: str, prefix: str):
if decoded.startswith(prefix):
codes = decoded.replace(prefix, "").strip()
if codes != "none":
result[key] = [c.strip() for c in codes.split()]
return
# Fallback: парсим DTC из raw HEX (43XX... или 47XX...)
clean = raw.replace(" ", "").upper()
if clean.startswith(mode) and len(clean) >= 4:
codes = _decode_dtc_bytes(clean[2:])
if codes:
result[key] = codes
def _decode_dtc_bytes(hex_str: str) -> list[str]:
"""Декодирует HEX-строку DTC (после 43/47) в коды."""
codes = []
i = 2 # skip byte count
while i + 3 < len(hex_str):
try:
a = int(hex_str[i:i+2], 16)
b = int(hex_str[i+2:i+4], 16)
p = {0: "P", 1: "C", 2: "B", 3: "U"}.get(a >> 6, "?")
code = f"{p}{(a>>4)&3}{a&15}{b>>4:X}{b&15:X}"
if code != "P0000":
codes.append(code)
except Exception:
pass
i += 4
return codes
def _parse_pid(result: dict, decoded: str, cmd: str):
if ":" not in decoded:
return
if decoded.startswith(("VIN", "DTC", "ELM", "Protocol")):
return
parts = decoded.split(":", 1)
if len(parts) == 2:
result["parameters"].append({
"name": parts[0].strip(),
"value": parts[1].strip(),
})
def format_no_llm(parsed: dict) -> str:
"""Форматирует ответ без LLM."""
lines = []
if parsed["vin"]:
lines.append(f"VIN: {parsed['vin']}")
if parsed["dtc_stored"]:
lines.append(f"Ошибки: {', '.join(parsed['dtc_stored'])}")
if parsed["dtc_pending"]:
lines.append(f"Pending: {', '.join(parsed['dtc_pending'])}")
if parsed["parameters"]:
lines.append("Параметры:")
for p in parsed["parameters"]:
lines.append(f" {p['name']}: {p['value']}")
if not lines:
lines.append("Данные не распознаны.")
lines.append("\n(LLM не настроен — только сырые данные)")
return "\n".join(lines)
+224
View File
@@ -0,0 +1,224 @@
"""Эндпоинты для толстого клиента: скрипты и батчевая загрузка.
GET /api/v1/script — выдача скрипта диагностики
POST /api/v1/session/upload — приём батча, LLM-анализ, возврат диагноза
"""
import logging
import time
from api.scripts import build_default_script, build_full_script
from api.parser import parse_batch, format_no_llm
logger = logging.getLogger("elmer.script")
def _build_diagnosis_prompt(data: dict) -> str:
"""Строит промпт для LLM из распарсенных данных."""
parts = ["## Данные диагностики\n"]
if data["vin"]:
parts.append(f"**VIN:** {data['vin']}")
if data["dtc_stored"]:
parts.append(f"\n**Сохранённые ошибки (mode 03):** {', '.join(data['dtc_stored'])}")
if data["dtc_pending"]:
parts.append(f"**Ожидающие ошибки (mode 07):** {', '.join(data['dtc_pending'])}")
if data["parameters"]:
parts.append("\n**Параметры в реальном времени:**")
for p in data["parameters"]:
parts.append(f"- {p['name']}: {p['value']}")
if not data["vin"] and not data["dtc_stored"] and not data["parameters"]:
parts.append("\n(данные не распознаны)")
parts.append("\n**Сырые ответы ЭБУ:**")
parts.extend(data["raw_log"])
parts.append("\n---")
parts.append("## Запрос на анализ")
parts.append(
"Дай ГЛУБОКИЙ, РАЗВЁРНУТЫЙ анализ на основе этих данных. "
"Не ограничивайся кратким резюме — мне нужен полный технический разбор.\n"
"1. Разбери КАЖДУЮ ошибку: все возможные причины, от частых к редким.\n"
"2. Проанализируй КАЖДЫЙ параметр: норма/отклонение, на что влияет.\n"
"3. Найди ВЗАИМОСВЯЗИ между ошибками и параметрами.\n"
"4. Предложи КОНКРЕТНЫЙ план проверок: что сначала, что потом, как проверять.\n"
"5. Если данных мало — скажи какие PID'ы досчитать и зачем.\n"
"6. Дай оценки уверенности в ПРОЦЕНТАХ для каждой версии."
)
return "\n".join(parts)
def register(app):
"""Регистрирует эндпоинты скриптов на Flask-приложении."""
@app.route("/api/v1/script", methods=["GET"])
def get_script():
from flask import jsonify, request
mode = request.args.get("mode", "full")
script = build_full_script() if mode == "full" else build_default_script()
return jsonify(script)
@app.route("/api/v1/session/upload", methods=["POST"])
def upload_session():
from flask import request, jsonify
from api.config import load
from api.db import Database
from brain.client import Diagnoser
from brain.prompts import SYSTEM_PROMPT
data = request.get_json(silent=True)
if not data or "responses" not in data:
return jsonify({"error": "missing 'responses'"}), 400
responses = data["responses"]
logger.info(f"Upload: {len(responses)} responses")
# ── Информация о клиенте ──────────────────────
client_info = data.get("client_info", {})
client_info["client_ip"] = request.remote_addr
client_info["real_ip"] = request.headers.get("X-Real-IP", "")
client_info["user_agent"] = request.headers.get("User-Agent", "")
client_info["content_length"] = request.content_length
parsed = parse_batch(responses)
cfg = load()
api_key = cfg["llm"]["api_key"]
model = cfg["llm"].get("model", "gpt-oss-120b")
llm_available = bool(api_key)
llm_start = time.time()
llm_success = False
diagnosis = ""
if not api_key:
diagnosis = format_no_llm(parsed)
else:
diagnoser = Diagnoser(
api_key=api_key,
model=model,
base_url=cfg["llm"].get("base_url", "https://api.aillm.ru/v1"),
)
try:
diagnosis = diagnoser.diagnose(SYSTEM_PROMPT, _build_diagnosis_prompt(parsed))
llm_success = True
except Exception as e:
logger.warning(f"LLM failed: {e}")
diagnosis = format_no_llm(parsed) + f"\n\n(LLM недоступен: {e})"
llm_duration_ms = int((time.time() - llm_start) * 1000)
# ── Сохранение в БД ───────────────────────────
try:
db = Database()
db.save_session(
client_info=client_info,
responses=responses,
diagnosis=diagnosis,
llm_model=model,
llm_duration_ms=llm_duration_ms,
llm_success=llm_success,
)
except Exception as e:
logger.error(f"DB save failed: {e}")
return jsonify({
"diagnosis": diagnosis,
"parsed": _summary(parsed),
"llm_available": llm_available,
"llm_success": llm_success,
})
@app.route("/api/v1/chat", methods=["POST"])
def chat():
"""Свободный вопрос к LLM (без ELM)."""
from flask import request, jsonify
from api.config import load
from brain.client import Diagnoser
data = request.get_json(silent=True)
if not data or "question" not in data:
return jsonify({"error": "missing 'question'"}), 400
question = data["question"].strip()
if not question:
return jsonify({"answer": "Пустой вопрос."})
# История диалога
history = data.get("history", [])
history_text = ""
if history:
history_text = "## История диалога\n"
for m in history[-10:]: # последние 10 сообщений
role = "Водитель" if m.get("role") == "user" else "Автоэксперт"
history_text += f"{role}: {m.get('content', '')}\n"
history_text += "\n"
cfg = load()
api_key = cfg["llm"]["api_key"]
if not api_key:
return jsonify({"answer": "LLM не настроен."})
prompt = (
f"{history_text}"
f"Ты — автоэксперт. Помни контекст диалога выше. "
f"Отвечай КРАТКО, не более 20 строк. Без воды, только по делу.\n\n"
f"Вопрос: {question}"
)
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"),
)
answer = diagnoser.diagnose(
"Ты — лаконичный автоэксперт. Помни контекст диалога. Отвечай кратко, максимум 20 строк.",
prompt,
)
except Exception as e:
answer = f"LLM недоступен: {e}"
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."""
from flask import jsonify
from api.config import load
from brain.client import Diagnoser
cfg = load()
api_key = cfg["llm"]["api_key"]
if not api_key:
return jsonify({"ok": False, "error": "no API key"})
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)
return jsonify({"ok": True, "ms": ms})
except Exception as e:
ms = int((time.time() - t0) * 1000)
return jsonify({"ok": False, "ms": ms, "error": str(e)[:100]})
def _summary(p: dict) -> dict:
return {
"vin": p["vin"],
"dtc_stored": p["dtc_stored"],
"dtc_pending": p["dtc_pending"],
"parameters": p["parameters"],
}
+29
View File
@@ -0,0 +1,29 @@
"""Сборка диагностических скриптов."""
def build_default_script() -> dict:
"""Минимальный скрипт для отладки: 1 PID → LLM."""
return {
"version": 1,
"title": "Экспресс-диагностика",
"steps": [
{"id": "pid_05", "cmd": "0105", "desc": "Температура ОЖ"},
],
}
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"},
],
}
View File
+62
View File
@@ -0,0 +1,62 @@
"""
brain/client.py — LLM-клиент для диагностики авто.
Отправляет запросы к OpenAI-совместимому API (api.aillm.ru).
Используется сервером для анализа данных диагностики.
## Использование
from brain.client import Diagnoser
d = Diagnoser(api_key="sk-...", model="gpt-oss-120b")
answer = d.diagnose(system_prompt, user_prompt)
## Модель
gpt-oss-120b — основная (через api.aillm.ru)
qwen3-6-27b-fp8 — быстрая (но CoT leak bug)
"""
import requests
DEFAULT_BASE = "https://api.aillm.ru/v1"
DEFAULT_MODEL = "gpt-oss-20b"
class Diagnoser:
"""Отправляет данные в DeepSeek и возвращает диагноз."""
def __init__(self, api_key: str, model: str = DEFAULT_MODEL, base_url: str = DEFAULT_BASE):
self.api_key = api_key
self.model = model
self.base_url = base_url.rstrip("/")
def ask(self, messages: list[dict]) -> str:
"""Отправляет сообщения в DeepSeek, возвращает текст ответа."""
resp = requests.post(
f"{self.base_url}/chat/completions",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
json={
"model": self.model,
"messages": messages,
"temperature": 0.3, # пониже — меньше фантазий
"max_tokens": 4096,
},
timeout=120, # api.aillm.ru бывает медленным
)
resp.raise_for_status()
data = resp.json()
return data["choices"][0]["message"]["content"]
def diagnose(
self,
system: str,
user_prompt: str,
history: list[dict] | None = None,
) -> str:
"""Полный цикл: system + история + user_prompt → ответ."""
messages = [{"role": "system", "content": system}]
if history:
messages.extend(history)
messages.append({"role": "user", "content": user_prompt})
return self.ask(messages)
+87
View File
@@ -0,0 +1,87 @@
"""
brain/prompts.py — промпты для LLM.
SYSTEM_PROMPT — системный промпт для диагностики (10 правил).
Используется в api/routes.py при формировании запроса к LLM.
"""
SYSTEM_PROMPT = """Ты — эксперт по диагностике автомобилей с 20-летним опытом. Ты анализируешь коды ошибок OBD2 и параметры ЭБУ и даёшь ГЛУБОКИЙ, РАЗВЁРНУТЫЙ анализ.
ПРАВИЛА ОТВЕТА:
1. НЕ ограничивайся кратким резюме — дай ПОЛНЫЙ анализ каждой ошибки и каждого параметра.
2. Для каждой ошибки объясни: что она значит, ВСЕ возможные причины (от частых к редким), какие параметры подтверждают/опровергают каждую версию.
3. Анализируй ВЗАИМОСВЯЗИ между ошибками и параметрами — могут ли они иметь общую причину?
4. Указывай степень уверенности в процентах для КАЖДОГО вывода.
5. Если данных недостаточно — перечисли КОНКРЕТНЫЕ PID'ы, которые нужно считать дополнительно, и объясни почему.
6. Предлагай план действий: что проверить СНАЧАЛА (самое вероятное и дешёвое), что ПОТОМ.
7. Для каждого действия объясняй: КАК проверить, на ЧТО смотреть, какие значения считать нормой/отклонением.
8. Добавляй секцию «Если не поможет» — план Б для каждого пункта.
9. НИКОГДА не давай категоричных команд «меняй деталь X» без 100% уверенности. Пиши «проверь X перед заменой Y».
10. Пиши на русском языке, доступно, но ТЕХНИЧЕСКИ ТОЧНО. Используй таблицы где уместно.
ФОРМАТ ОТВЕТА:
## Диагноз (развёрнутый)
(полный анализ ситуации, 3-5 абзацев)
## Анализ ошибок
| Код | Описание | Вероятные причины | Подтверждающие параметры | Уверенность |
|-----|----------|-------------------|--------------------------|-------------|
...
## Анализ параметров
| Параметр | Значение | Норма | Отклонение | На что влияет |
|----------|----------|-------|------------|---------------|
...
## Взаимосвязи
(как ошибки и параметры связаны между собой)
## План действий (по приоритету)
### 1. Проверить ... (самое вероятное)
- КАК проверить: ...
- На что смотреть: ...
- Норма: ...
### 2. Если не помогло — проверить ...
...
## Каких данных не хватает
- PID XX (название) — потому что ...
- ...
## Степень уверенности
- Версия A: ~XX%
- Версия B: ~XX%
- Версия C: ~XX%"""
def build_user_prompt(
vin: str,
dtc_codes: list[dict],
parameters: list[dict],
car_info: dict | None = None,
) -> str:
"""Собирает промпт пользователя из данных ЭБУ."""
parts = [f"## Данные диагностики\n"]
parts.append(f"**VIN:** {vin}")
if car_info:
parts.append(f"**Автомобиль:** {car_info.get('make', '?')} {car_info.get('model', '?')} "
f"({car_info.get('year', '?')}), двигатель: {car_info.get('engine', '?')}")
if dtc_codes:
parts.append("\n### Коды ошибок")
for dtc in dtc_codes:
parts.append(f"- **{dtc['code']}** ({dtc.get('status', 'stored')}): {dtc.get('description', '')}")
if parameters:
parts.append("\n### Параметры ЭБУ")
for p in parameters:
parts.append(f"- {p['name']} ({p['pid_code']}): {p['value']} {p['unit']}")
parts.append("\n## Запрос")
parts.append("Дай диагноз на основе этих данных. Если данных недостаточно — скажи, "
"какие параметры нужно ещё считать и какие действия выполнить водителю.")
return "\n".join(parts)
+1 -1
View File
@@ -3,7 +3,7 @@
llm: llm:
api_key: "sk-ucI5YvOticoOQ9Kuj5K9mQ" api_key: "sk-ucI5YvOticoOQ9Kuj5K9mQ"
model: "gpt-oss-120b" model: "qwen3-6-27b-fp8"
base_url: "https://api.aillm.ru/v1" base_url: "https://api.aillm.ru/v1"
elm327: elm327:
Executable
+86
View File
@@ -0,0 +1,86 @@
#!/bin/bash
# Деплой Elmer на obdai.ru
set -e
echo "=== Установка пакетов ==="
apt update && apt install -y python3-pip python3-venv nginx certbot python3-certbot-nginx
echo "=== Клонирование репо ==="
cd /opt
git clone https://gitea.services.ngcloud.ru/Nail/elmer.git || (cd elmer && git pull)
cd elmer
git checkout fat-client
echo "=== Виртуальное окружение ==="
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
pip install gunicorn
echo "=== Конфигурация ==="
cp config.yaml config.yaml.bak
cat > config.yaml << 'YAML'
elm327:
port: /dev/rfcomm0
baudrate: 38400
llm:
api_key: "sk-ucI5YvOticoOQ9Kuj5K9mQ"
model: "gpt-oss-120b"
base_url: "https://api.aillm.ru/v1"
pids:
"0105": ["coolant_temp", "°C"]
"010C": ["rpm", "об/мин"]
"010D": ["speed", "км/ч"]
YAML
echo "=== Systemd сервис ==="
cat > /etc/systemd/system/elmer.service << 'UNIT'
[Unit]
Description=Elmer Flask API
After=network.target
[Service]
User=naeel
WorkingDirectory=/opt/elmer
ExecStart=/opt/elmer/venv/bin/gunicorn -w 4 -b 127.0.0.1:8000 web.app:app
Restart=always
[Install]
WantedBy=multi-user.target
UNIT
echo "=== Nginx ==="
cat > /etc/nginx/sites-available/elmer << 'NGX'
server {
listen 80;
server_name obdai.ru www.obdai.ru ai.obdai.ru test.obdai.ru;
location /static/ {
alias /opt/elmer/web/static/;
}
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
NGX
ln -sf /etc/nginx/sites-available/elmer /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
nginx -t && systemctl reload nginx
echo "=== SSL ==="
certbot --nginx -d obdai.ru -d www.obdai.ru --non-interactive --agree-tos -m tazet@narod.ru || true
echo "=== Запуск ==="
systemctl daemon-reload
systemctl enable elmer
systemctl restart elmer
systemctl restart nginx
echo "=== ГОТОВО ==="
curl -s http://obdai.ru/api/v1/script | head -c 50
+83 -69
View File
@@ -1,88 +1,102 @@
# Архитектура Elmer (2026-05-25) # Архитектура elmAI
## Принцип: тонкий клиент > v0.28.0-dev, 29 мая 2026
Клиент ничего не знает о диагнозе. Только транспорт: ## Общая схема
``` ```
ELM327 ←Bluetooth SPP→ Android Client ←HTTP JSON→ Сервер ←API→ DeepSeek 📱 Android (elmer-android)
│ Bluetooth
🔌 ELM327
│ OBD-ответы
📱 Android (ScriptRunnerService)
│ HTTPS POST /api/v1/session/upload
🌐 Сервер (5.172.178.213)
├── nginx :443 → gunicorn :8000
├── obd/ — ELM327 протокол
├── brain/ — LLM-клиент
├── api/ — REST, БД, скрипты
└── web/ — точка входа Flask, статика
``` ```
## Режимы работы клиента ## Структура сервера
### Режим «опрос» (основной)
Клиент сам читает VIN + DTC + PID'ы, шлёт готовый JSON серверу.
Быстро: один HTTP-запрос на сессию.
### Режим «ретранслятор» (расширенный)
Сервер шлёт сырые AT-команды, клиент пересылает ответ.
Медленно (каждый PID — HTTP round-trip), но клиент вообще ничего не знает об OBD2.
## Универсальность
- Пользователь вводит URL сервера (или наш по умолчанию)
- Протокол HTTP/JSON документирован
- Любой backend может работать с этим клиентом
## Десктоп (Windows/Linux)
``` ```
Браузер (Chrome) → локальный Flask → pyserial → ELM327 elmer/
├── obd/ # Модуль 1: ELM327 протокол
│ └── protocol.py # AndrOBD — стейт-машина (1:1 копия AndrOBD)
│ # State, Rsp, AdaptiveTiming
├── brain/ # Модуль 2: LLM-взаимодействие
│ ├── client.py # Diagnoser — HTTP к api.aillm.ru
│ └── prompts.py # SYSTEM_PROMPT для диагностики
├── api/ # Модуль 3: REST API + БД
│ ├── config.py # Загрузка config.yaml
│ ├── db.py # SQLite (sessions, cars, dtc)
│ ├── routes.py # Все эндпоинты (5 шт)
│ ├── scripts.py # Сборка диагностических скриптов
│ └── parser.py # Парсинг ответов ELM327
├── web/ # Веб-интерфейс
│ ├── app.py # Точка входа Flask
│ ├── templates/index.html
│ └── static/app-debug.apk
├── tools/ # Разработка
│ ├── mock_elm327_v2.py # Мок ELM327 (TCP)
│ └── test_androbd.py # Тесты стейт-машины
├── doc/ # Документация
│ ├── architecture.md # Этот файл
│ ├── roadmap.md
│ └── session-*.md # Логи сессий
├── config.yaml # LLM API key, порты
└── requirements.txt
``` ```
- Отдельного «приложения» для Windows не нужно ## Взаимодействие модулей
- `web/app.py` — и тестовый UI, и прототип сервера
- Chrome на Android НЕ может: Web Bluetooth API только BLE, Web Serial API не поддерживается
## Открытость и доверие
| Что | Где | Зачем |
|---|---|---|
| **Клиент (Android)** | GitHub (открытый) | Доверие — любой может проверить код, собрать сам |
| **Сервер (Python)** | Gitea (закрытый) | API-ключи, логика, коммерческая часть |
| **Публикация** | RuStore | Бесплатно, модерация = дополнительное доверие |
## Git-стратегия
- `gitea.services.ngcloud.ru/Nail/elmer` — разработка сервера (текущий репо)
- `github.com/Nail/elmer-android` — клиент (будет создан), лицензия MIT
- Серверный репо на GitHub НЕ публикуем
## База данных
### SQLite (MVP)
``` ```
cars — VIN, марка, модель, год, двигатель web/app.py
diagnostic_tokens — id (PK), car_id (FK), created_at └─ import api/routes.py
llm_messages — token_id (FK), role, content, timestamp ├─ import api/config.py → config.yaml
ecu_parameters — token_id (FK), pid_code, value, unit, timestamp ├─ import api/db.py → SQLite
dtc_codes — token_id (FK), code, description, status ├─ import api/scripts.py → сборка скриптов
├─ import api/parser.py → парсинг батча
├─ import brain/client.py → Diagnoser → api.aillm.ru
└─ import brain/prompts.py → SYSTEM_PROMPT
``` ```
### PostgreSQL (production) Каждый модуль можно тестировать отдельно. Циклических зависимостей нет.
Та же схема, миграция при переходе к production-серверу.
## API (прототип) ## API эндпоинты
### POST /api/diagnose | Метод | Путь | Описание | Время |
```json |---|---|---|---|
// Request (от клиента) | GET | /api/v1/ping | Проверка сервера | ~5мс |
{ | GET | /api/v1/ping-llm | Проверка LLM | ~2с |
"vin": "WVWZZZ1KZAW123456", | GET | /api/v1/script?mode= | Скрипт диагностики | ~50мс |
"dtc_codes": [{"code": "P0301", "status": "stored"}], | POST | /api/v1/session/upload | Загрузка батча + LLM | ~30-120с |
"parameters": [{"pid_code": "0105", "name": "coolant_temp", "value": 85.0, "unit": "°C"}] | POST | /api/v1/chat | Вопрос к LLM | ~5-15с |
}
// Response (от сервера) ## Android (отдельный репо)
{
"diagnosis": "## Краткий диагноз\n...", ```
"token_id": 42 elmer-android/app/src/main/java/ru/elmer/client/
} ├── ElmProtocol.kt # ELM327 стейт-машина
├── ObdDecoder.kt # Декодер PID/DTC/VIN
├── ServerClient.kt # HTTP к серверу (retry 3x)
├── ScriptEngine.kt # Движок скриптов
├── ScriptRunnerService.kt # Фоновая диагностика
├── SessionDb.kt # Локальная история
├── MainActivity.kt # UI
├── TestService.kt # (устарел)
└── ElmForwardService.kt # (устарел)
``` ```
## Безопасность Планируется рефакторинг в пакеты: `elm/`, `server/`, `script/`, `db/`, `ui/`, `test/`.
- Permissions Android: только BLUETOOTH + INTERNET
- Никаких SMS/контактов/файлов/звонков
- Пользователь видит permissions ДО установки (RuStore и sideload)
- Модерация RuStore — базовая проверка на вредоносный код
+142
View File
@@ -0,0 +1,142 @@
# Сессия 2026-05-28 — AndrOBD стейт-машина, ELM-протокол, отладка
## Хронология
### 08:00 — Тест v0.12.0-dev на моке
Скрипт отработал (ELM — зелёный), но upload «Сервер недоступен».
Flask работал локально, проброс портов был настроен.
**Диагностика:** `curl -X POST` на `/api/v1/session/upload` зависал на 60+ секунд —
LLM `api.aillm.ru` долго отвечал, Flask ждал, OkHttp на телефоне таймаутил через 30с.
**Исправление:** OkHttp `readTimeout` увеличен с 30с до 120с → v0.13.0-dev.
### 09:00 — Стейт-машина AndrOBD
Пользователь потребовал НЕ изобретать своё, а скопировать 1:1 отлаженный код AndrOBD.
Создана ветка `androbd-proto`.
**Проблема 1: команды слались подряд, ответы перемешивались**
Было (`init()` старая):
```python
sendRaw("ATSP0"); sleep(200) # шлём, НЕ читаем ответ
sendRaw("ATAT1"); sleep(200) # шлём, НЕ читаем ответ
# → ответы на ATSP0 и ATAT1 в буфере → OBD-команды читают чужие ответы
```
Стало (`init()` новая):
```python
_exec("ATSP0", timeout=10000) # шлём, ЖДЁМ ответ, читаем
_exec("ATAT1", timeout=5000)
# → каждая команда ждёт свой ответ → буфер чист
```
**Проблема 2: ATST отправлялся напрямую, ответ не читался**
`_process()` вызывал `_send_atst()` которая писала `ATST` в порт и НЕ читала ответ.
Следующая команда читала `OK` от ATST вместо своего ответа.
Исправление: `_send_atst()` теперь читает и отбрасывает ответ на ATST.
**Проблема 3: `_read()` возвращал частичный ответ при таймауте**
При таймауте `_read()` возвращал что успел прочитать — например, первую строку DTC.
Вторая строка попадала в следующую команду.
Исправление: `_read()` теперь требует `>` перед возвратом. Без `>` — TimeoutError.
**Проблема 4: таймаут 200мс мал для мока**
Мок ждёт 300-500мс перед ответом на OBD-команды. AndrOBD использует 200мс с адаптивным
увеличением через ATST, но мок не поддерживает аппаратный ATST.
Исправление: увеличен базовый таймаут до 500мс, 10 ретраев вместо 5.
**Проблема 5: BUS ERROR recovery блокирует всё**
При BUS ERROR стейт-машина отправляет ATPC + ATSP0 для восстановления.
Мок отвечает на ATSP0 через 1.8с. `_try_read()` ждал только 1с.
Ответ на ATSP0 оставался в буфере и попадал в следующую команду.
Исправление: `_try_read` использует таймаут 5с для recovery-команд.
---
## Итоговая архитектура AndrOBD
### Стейт-машина (AndrOBD ElmProt.java)
```
UNDEFINED → INITIALIZING → READY → BUSY → READY
↓ ERROR ↓ BUS ERROR
RECOVERING DISCONNECTED
```
### Классификация ответов (Rsp.identify)
| Ответ | Тип | Реакция |
|-------|-----|---------|
| `>` | PROMPT | Конец ответа (разделитель) |
| `OK` | OK | Успех, уменьшить таймаут |
| `SEARCHING...` | SEARCHING | Нормально при ините |
| `NODATA` | NODATA | Увеличить таймаут, ATST |
| `UNABLE`, `BUS BUSY`, `CAN ERROR`, etc. | BUS ERROR | DISCONNECTED, ATPC, ATSP0 |
| `ERROR` | ERROR | WARM START (ATWS) |
| `DATA ERROR`, `BUFFER FULL`, `RX ERROR` | DATA ERROR | WARM START |
| Всё остальное | DATA | Успех, уменьшить таймаут |
### Чтение (StreamHandler.java)
- Побайтовое, пауза 1мс
- CR (13) = разделитель строк
- `>` (62) = конец ответа
- LF (10) и пробел (32) = игнорируются
- **Без `>` ответ не возвращается** (TimeoutError)
### Адаптивный таймаут (AdaptiveTiming.java)
- Старт: 500мс (для мока; реальный ELM → 200мс)
- Шаг: 20мс
- Диапазон: 50-2000мс
- ATST отправляется через `_queue_atst()` → ответ читается корректно
---
## Результаты тестирования
Python-тест (`tools/test_androbd.py`) против Mock ELM327 v2:
| Тест | Результат |
|------|-----------|
| VIN (0902) → 490201... | ✅ |
| DTC (03) → 430113... + 430133... | ✅ |
| RPM (010C) → 410C1AF8 | ✅ |
| ОЖ (0105) → 41055A | ✅ |
| Ответы не перемешаны | ✅ 2/3 прогонов |
1/3 прогонов упал из-за случайного BUS BUSY в моке (3%) — стейт-машина корректно
восстановилась, но DTC-ответ был пустой (ожидаемое поведение).
---
## Версии APK
| Версия | Что |
|--------|-----|
| v0.11.0-prod | Старая, ответы перемешаны |
| v0.12.0-dev | Стейт-машина, но OkHttp 30с → «сервер недоступен» |
| **v0.13.0-dev** | Стейт-машина + OkHttp 120с |
---
## Что дальше (P0 → P3)
| P0 | ELM-протокол со стейт-машиной | ✅ сделано (Python + Kotlin) |
| P0 | Тест на моке | ✅ 2/3 зелёные |
| P0 | OkHttp timeout | ✅ 30→120с |
| P1 | GPS-модуль | запланировано |
| P1 | Трёхфазный флоу с жалобами | запланировано |
| P2 | DTC-база на сервере | потом |
| P2 | LLM cross-validation | потом |
| P3 | Web-панель | потом |
+218
View File
@@ -0,0 +1,218 @@
"""
AndrOBD Protocol — ПОЛНАЯ копия стейт-машины AndrOBD.
Источник: github.com/fr3ts0n/AndrOBD, ElmProt.java
Состояния:
UNDEFINED → INITIALIZING → READY
Любое → BUSY (команда) → READY
Любое → ERROR → RECOVERING → READY
BUS ERROR → DISCONNECTED → RECONNECTING → READY
Каждый ответ проверяется — не тот ответ → переход в ошибку → восстановление.
"""
import logging
import time
from enum import Enum, auto
from typing import Optional
logger = logging.getLogger("androbd")
# ── Состояния (AndrOBD STAT) ───────────────────────────────
class State(Enum):
UNDEFINED = auto()
INITIALIZING = auto()
READY = auto()
BUSY = auto()
ERROR = auto()
DISCONNECTED = auto()
# ── Типы ответов (AndrOBD RSP_ID) ──────────────────────────
class Rsp:
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
# ── Адаптивный таймаут (AndrOBD AdaptiveTiming) ─────────────
class AdaptiveTiming:
DEFAULT = 500; MIN = 50; MAX = 2000; STEP = 20; RES = 4
def __init__(self):
self._t = self.DEFAULT; self._min = self.MIN
@property
def ms(self) -> int: return self._t
@property
def atst(self) -> int: 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): self._t = self.DEFAULT
# ── Протокол (AndrOBD ElmProt) ──────────────────────────────
class AndrOBD:
"""Стейт-машина ELM327 — 1:1 копия AndrOBD."""
INIT_TMO = 10000 # инициализация
DEF_TMO = 200 # адаптивный
def __init__(self, port: str, baudrate: int = 38400):
self.port = port; self.baudrate = baudrate
self._ser = None; self._timing = AdaptiveTiming()
self._state = State.UNDEFINED; self._last_cmd: Optional[str] = None
# ── Connect ─────────────────────────────────────────
def connect(self):
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}")
def close(self):
if self._ser and self._ser.is_open: self._ser.close()
# ── Инициализация ───────────────────────────────────
def init(self):
logger.info("AndrOBD: init")
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._state = State.READY
logger.info("AndrOBD: ready")
# ── OBD-команда ─────────────────────────────────────
def send(self, cmd: str) -> str:
if self._state == State.ERROR:
self._recover()
self._state = State.BUSY
result = self._exec(cmd, self._timing.ms)
self._state = State.READY
return result
# ── Выполнение ──────────────────────────────────────
def _exec(self, cmd: str, timeout: int) -> str:
self._last_cmd = cmd; self._write(cmd)
t = timeout
for _ in range(10):
try:
return self._handle(self._read(t))
except TimeoutError:
if self._state == State.INITIALIZING: t += 1000
else: self._timing.increase(); t = self._timing.ms
logger.error(f"AndrOBD: no response for {cmd}")
self._state = State.ERROR; return ""
# ── Обработка ответа ────────────────────────────────
def _handle(self, raw: str) -> str:
t = Rsp.identify(raw)
if t == Rsp.SEARCHING: return raw
if t == Rsp.OK: self._timing.decrease(); return raw
if t == Rsp.NODATA: self._timing.increase(); self._update_atst(); return raw
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._try_read()
self._write("ATSP0"); self._try_read()
return raw
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
self._write("ATWS"); self._try_read()
return raw
# Данные — успех
self._timing.decrease(); return raw
def _recover(self):
logger.info("AndrOBD: recovering...")
self._state = State.INITIALIZING
self._write("ATWS"); self._try_read()
self._write("ATSP0"); self._try_read()
self._write("ATE0"); self._try_read()
self._state = State.READY
# ── Чтение/запись ───────────────────────────────────
def _write(self, cmd: str):
self._ser.write((cmd + "\r").encode()); self._ser.flush()
logger.debug(f"AndrOBD → {cmd}")
def _read(self, timeout_ms: int) -> str:
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: self._push(cur, lines); got_prompt = True; break
elif cp == 13: self._push(cur, lines)
elif cp in (10, 32): pass
else: cur.append(chr(cp))
else: time.sleep(0.001)
self._push(cur, lines)
if not got_prompt: raise TimeoutError(f"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):
if cur: lines.append("".join(cur)); cur.clear()
def _update_atst(self):
self._write(f"ATST{self._timing.atst:02X}"); self._try_read()
+122
View File
@@ -6,8 +6,10 @@
llm_messages — token_id (FK), role, content, timestamp llm_messages — token_id (FK), role, content, timestamp
ecu_parameters — token_id (FK), pid_code, value, unit, timestamp ecu_parameters — token_id (FK), pid_code, value, unit, timestamp
dtc_codes — token_id (FK), code, description, status dtc_codes — token_id (FK), code, description, status
sessions — сводная таблица всех сессий (клиент, ELM, авто, LLM)
""" """
import json
import sqlite3 import sqlite3
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -64,9 +66,129 @@ class Database:
status TEXT, -- 'stored' | 'pending' status TEXT, -- 'stored' | 'pending'
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
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,
-- 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,
-- LLM
diagnosis_text TEXT,
diagnosis_len INTEGER,
llm_model TEXT,
llm_duration_ms INTEGER,
llm_success INTEGER DEFAULT 0,
-- Сырые данные (JSON)
raw_responses 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);
""") """)
self.conn.commit() self.conn.commit()
# ── sessions ──────────────────────────────────────────
def save_session(self, client_info: dict, responses: list[dict],
diagnosis: str = "", llm_model: str = "",
llm_duration_ms: int = 0, llm_success: bool = False):
"""Сохраняет сводную запись о сессии."""
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
self.conn.execute("""
INSERT INTO sessions (
client_ip, real_ip, user_agent, content_length,
phone_model, phone_maker, android_version, android_sdk,
app_version, android_id,
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,
diagnosis_text, diagnosis_len, llm_model,
llm_duration_ms, llm_success,
raw_responses
) 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("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),
diagnosis, len(diagnosis), llm_model,
llm_duration_ms, 1 if llm_success else 0,
json.dumps(responses, ensure_ascii=False) if responses else None,
))
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]
# ── cars ────────────────────────────────────────────── # ── cars ──────────────────────────────────────────────
def get_or_create_car(self, vin: str) -> int: def get_or_create_car(self, vin: str) -> int:
+1 -1
View File
@@ -28,7 +28,7 @@ class Diagnoser:
"temperature": 0.3, # пониже — меньше фантазий "temperature": 0.3, # пониже — меньше фантазий
"max_tokens": 4096, "max_tokens": 4096,
}, },
timeout=60, timeout=120, # api.aillm.ru бывает медленным
) )
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
-208
View File
@@ -1,208 +0,0 @@
"""Связь с ELM327 через Bluetooth SPP (pyserial)."""
import re
import time
import serial
class ELM327:
"""Работа с ELM327 по последовательному порту (Bluetooth SPP)."""
# Стандартные PID'ы для чтения
DEFAULT_PIDS = {
"0105": ("coolant_temp", "°C"), # температура ОЖ
"010C": ("rpm", "об/мин"), # обороты двигателя
"010D": ("speed", "км/ч"), # скорость
"0111": ("throttle_pos", "%"), # положение дросселя
"010B": ("map", "кПа"), # давление впуска (MAP)
"010F": ("iat", "°C"), # температура впуска
"011F": ("runtime_since_start", "с"), # время с запуска
"0104": ("engine_load", "%"), # нагрузка двигателя
"0106": ("stft_b1", "%"), # краткосрочный fuel trim bank 1
"0107": ("ltft_b1", "%"), # долгосрочный fuel trim bank 1
}
def __init__(self, port: str, baudrate: int = 38400, timeout: float = 5.0):
self.port = port
self.ser = serial.Serial(
port=port,
baudrate=baudrate,
timeout=timeout,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
)
# ── низкоуровневые команды ────────────────────────────
def _cmd(self, cmd: str, wait: float = 0.2) -> str:
"""Отправляет AT/OBD команду, возвращает сырой ответ."""
self.ser.reset_input_buffer()
self.ser.write((cmd + "\r").encode())
time.sleep(wait)
lines = []
while True:
line = self.ser.readline().decode("utf-8", errors="ignore").strip()
if not line or line == ">":
break
lines.append(line)
return "\n".join(lines)
# ── инициализация ─────────────────────────────────────
def init(self) -> bool:
"""Сброс и настройка ELM327. Возвращает True если OK."""
resp = self._cmd("ATZ", wait=1.0) # сброс
if "ELM" not in resp:
return False
self._cmd("ATE0") # выкл эхо
self._cmd("ATL0") # выкл перевод строки
self._cmd("ATSP0") # авто-протокол
self._cmd("ATH1") # вкл заголовки
return True
# ── чтение VIN ────────────────────────────────────────
def read_vin(self) -> str | None:
"""Читает VIN (режим 09 PID 02). Возвращает VIN или None."""
resp = self._cmd("0902", wait=1.5)
# Формат: 014 0: 49 02 01 57 56 57 ...
# Ищем строку с байтами после 49 02
match = re.search(r"49\s*02\s*(.+)", resp.replace("\n", " ").replace(":", ""))
if not match:
return None
# Собираем HEX байты, переводим в ASCII
hex_bytes = match.group(1).strip().split()
vin = ""
for h in hex_bytes:
h = h.strip()
if len(h) == 2:
try:
vin += chr(int(h, 16))
except ValueError:
pass
return vin if len(vin) == 17 else None
# ── чтение ошибок ─────────────────────────────────────
def read_dtc_codes(self, mode: str = "03") -> list[dict]:
"""Читает коды ошибок.
mode: '03' — сохранённые, '07' — ожидающие.
Возвращает [{"code": "P0301", "description": "", "status": "stored"}, ...].
"""
resp = self._cmd(mode, wait=1.0)
codes = []
# Пример ответа: 43 01 33 00 00 00 00
for line in resp.split("\n"):
line = line.strip()
if not line or "NO DATA" in line.upper():
continue
# Ищем HEX-байты после 43 (mode 03 response) или 47 (mode 07)
match = re.search(r"4[37]\s*(.+)", line.replace(":", ""))
if not match:
continue
hex_bytes = match.group(1).strip().split()
# Парсим по 2 байта на код (первые два байта — количество кодов)
i = 1 # пропускаем байт количества
while i + 1 < len(hex_bytes):
dtc_raw = _decode_dtc(hex_bytes[i], hex_bytes[i + 1])
if dtc_raw and dtc_raw != "P0000":
codes.append({
"code": dtc_raw,
"description": "",
"status": "stored" if mode == "03" else "pending",
})
i += 2
return codes
# ── чтение параметров ─────────────────────────────────
def read_pid(self, pid: str) -> float | None:
"""Читает один PID, возвращает числовое значение или None."""
resp = self._cmd(pid, wait=0.3)
# Ищем строку ответа: 41 XX YY ZZ ...
match = re.search(r"4[12]\s*" + pid[2:4] + r"\s*(.+)", resp.replace(":", ""))
if not match:
return None
hex_bytes = match.group(1).strip().split()
if not hex_bytes:
return None
# Формулы для стандартных PID (SAE J1979)
formulas = {
"05": lambda b: int(b[0], 16) - 40, # coolant °C
"0C": lambda b: (int(b[0], 16) * 256 + int(b[1], 16)) / 4, # RPM
"0D": lambda b: int(b[0], 16), # speed km/h
"11": lambda b: int(b[0], 16) * 100 / 255, # throttle %
"0B": lambda b: int(b[0], 16), # MAP kPa
"0F": lambda b: int(b[0], 16) - 40, # IAT °C
"1F": lambda b: int(b[0], 16) * 256 + int(b[1], 16), # runtime sec
"04": lambda b: int(b[0], 16) * 100 / 255, # load %
"06": lambda b: (int(b[0], 16) - 128) * 100 / 128, # STFT %
"07": lambda b: (int(b[0], 16) - 128) * 100 / 128, # LTFT %
}
pid_short = pid[2:4]
if pid_short in formulas:
try:
return round(formulas[pid_short](hex_bytes), 1)
except (ValueError, IndexError):
return None
# Generic: первый байт как raw
try:
return int(hex_bytes[0], 16)
except (ValueError, IndexError):
return None
def read_all_pids(self, pids: dict | None = None) -> list[dict]:
"""Читает все PID'ы из словаря {pid: (name, unit)}.
Возвращает [{"pid_code": "...", "name": "...", "value": ..., "unit": "..."}, ...].
"""
if pids is None:
pids = self.DEFAULT_PIDS
results = []
for pid_code, (name, unit) in pids.items():
try:
value = self.read_pid(pid_code)
if value is not None:
results.append({
"pid_code": pid_code,
"name": name,
"value": value,
"unit": unit,
})
except Exception:
continue
return results
def close(self):
self._cmd("ATZ", wait=0.5)
self.ser.close()
def _decode_dtc(b1: str, b2: str) -> str | None:
"""Декодирует два HEX-байта в код ошибки вида P0301."""
try:
a, b = int(b1, 16), int(b2, 16)
except ValueError:
return None
# Первые 2 бита первого байта — тип:
types = {0: "P", 1: "C", 2: "B", 3: "U"}
prefix = types.get(a >> 6, "?")
# Оставшиеся биты
d1 = str((a >> 4) & 0x03) # вторая цифра
d2 = str(a & 0x0F) # третья цифра
d3 = f"{(b >> 4) & 0x0F:X}" # четвёртая цифра (hex!)
d4 = f"{b & 0x0F:X}" # пятая цифра (hex!)
return f"{prefix}{d1}{d2}{d3}{d4}"
-230
View File
@@ -1,230 +0,0 @@
"""
ELM327 Protocol Layer — точная копия паттернов AndrOBD (1993⭐, 10 лет продакшена).
Источник: github.com/fr3ts0n/AndrOBD
- BtCommService.java → BT подключение + 500мс пауза
- ElmProt.java → стейт-машина, таймауты, ошибки
- StreamHandler.java → побайтовое чтение, '>' = разделитель
Ключевые паттерны (ВСЕ подтверждены сырым кодом AndrOBD):
1. Побайтовое чтение, сон 1мс между проверками
2. '>' — обычный разделитель строк (как CR/LF), НЕ спецсигнал
3. Адаптивный таймаут: старт 200мс, ±4мс, диапазон 12-1000мс
4. ATST меняется на лету при изменении таймаута
5. Инициализация: ATSP→ATAT→ATS0→ATL0→ATE0 (без ATZ!)
6. Ошибки: WARMSTART, re-queue, protocol reset
7. Мульти-фрейм ISO-TP с префиксом длины
8. flush() после каждой команды
Использование:
proto = ELMProtocol(port="/dev/rfcomm0")
proto.connect() # + 500мс пауза (AndrOBD #233)
proto.init() # ATSP0 → ATAT1 → ATS0 → ATL0 → ATE0
resp = proto.send_command("010C") # → "410C1AF8"
proto.close()
"""
import logging
import time
from typing import Optional
logger = logging.getLogger("elmer.proto")
# ── Адаптивный таймаут (AndrOBD AdaptiveTiming) ─────────────
class AdaptiveTiming:
"""Адаптивный таймаут ELM327 — точная копия AndrOBD.
Старт: 200мс. Шаг: 4мс. Диапазон: 12-1000мс.
На NODATA: +4мс. На успех: -4мс (не ниже learned_min).
Каждое изменение → ATST<timeout/4>.
"""
DEFAULT = 200 # мс
MIN = 12 # мс
MAX = 1000 # мс
STEP = 4 # мс
RES = 4 # множитель ATST (timeout = ATST_value * 4)
def __init__(self):
self._timeout = self.DEFAULT
self._learned_min = self.MIN
@property
def timeout_ms(self) -> int:
return self._timeout
@property
def atst_value(self) -> int:
return max(1, self._timeout // self.RES)
def increase(self):
if self._timeout + self.STEP < self.MAX:
self._timeout += self.STEP
def decrease(self):
if self._timeout - self.STEP >= self._learned_min:
self._timeout -= self.STEP
def reset(self):
self._timeout = self.DEFAULT
# ── Протокол ELM327 ─────────────────────────────────────────
class ELMProtocol:
"""Побайтовый обмен с ELM327 — точная копия AndrOBD StreamHandler + ElmProt."""
SPP_UUID = "00001101-0000-1000-8000-00805F9B34FB"
def __init__(self, port: str, baudrate: int = 38400):
self.port = port
self.baudrate = baudrate
self._ser = None
self._timing = AdaptiveTiming()
self._last_cmd: Optional[str] = None
# ── Подключение ───────────────────────────────────────
def connect(self):
"""Открывает serial-порт + 500мс пауза (AndrOBD issue #233)."""
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) # КРИТИЧЕСКИ: AndrOBD #233
logger.info(f"ELM: connected {self.port} @ {self.baudrate}")
def close(self):
if self._ser and self._ser.is_open:
self._ser.close()
# ── Инициализация (AndrOBD ElmProt.initialize) ─────────
def init(self) -> bool:
"""Порядок: ATSP0→ATAT1→ATS0→ATL0→ATE0. Без ATZ!"""
logger.info("ELM: init start")
self._write("ATSP0"); time.sleep(0.2) # авто-протокол
self._write("ATAT1"); self._update_timeout(); time.sleep(0.2) # adaptive timing
self._write("ATS0"); time.sleep(0.1) # пробелы выкл
self._write("ATL0"); time.sleep(0.1) # line feeds выкл
self._write("ATE0"); time.sleep(0.1) # эхо выкл
self._drain()
logger.info("ELM: init done")
return True
def _update_timeout(self):
self._write(f"ATST{self._timing.atst_value:02X}")
def _drain(self):
if self._ser and self._ser.in_waiting > 0:
n = len(self._ser.read(self._ser.in_waiting))
logger.debug(f"ELM: drained {n}B")
# ── Отправка + чтение (AndrOBD StreamHandler) ──────────
def send_command(self, cmd: str) -> str:
"""Отправляет команду, читает ответ побайтово.
Разделители: CR(13), LF(10), '>'(62) — все равноправны.
Возвращает строки ответа через \\n.
"""
self._write(cmd)
self._last_cmd = cmd
try:
result = self._read(self._timing.timeout_ms)
except TimeoutError:
self._timing.increase()
self._update_timeout()
try:
result = self._read(self._timing.timeout_ms)
except TimeoutError:
return ""
self._handle_response(result)
return result
# ── Внутренние ────────────────────────────────────────
def _write(self, cmd: str):
"""cmd + CR + flush (AndrOBD writeTelegram)."""
self._ser.write((cmd + "\r").encode())
self._ser.flush()
logger.debug(f"ELM → {cmd}")
def _read(self, timeout_ms: int) -> str:
"""Побайтовое чтение, пауза 1мс (AndrOBD StreamHandler.run)."""
deadline = time.monotonic() + timeout_ms / 1000.0
lines: list[str] = []
cur: list[str] = []
while time.monotonic() < deadline:
if self._ser.in_waiting > 0:
ch = self._ser.read(1)
if not ch:
continue
cp = ch[0]
if cp == 62: # '>' — разделитель как CR/LF
self._push(cur, lines)
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)
self._push(cur, lines)
if not lines:
raise TimeoutError(f"ELM: timeout {timeout_ms}ms")
return "\n".join(lines)
@staticmethod
def _push(cur: list[str], lines: list[str]):
if cur:
lines.append("".join(cur))
cur.clear()
def _handle_response(self, raw: str):
"""Обработка ошибок (AndrOBD ElmProt.handleTelegram)."""
u = raw.upper()
if "SEARCHING" in u:
return
if "NODATA" in u or "NO DATA" in u:
self._timing.increase()
self._update_timeout()
return
if any(e in u for e in ("UNABLE", "BUS BUSY", "BUS ERROR",
"CAN ERROR", "BUS INIT", "STOPPED")):
logger.warning(f"ELM: bus error, resetting — {raw[:60]}")
self._timing.reset()
self._update_timeout()
self._write("ATPC")
self._write("ATSP0")
return
# Успех — уменьшаем таймаут
self._timing.decrease()
# ── Фабрика ─────────────────────────────────────────────────
def create(port: str, baudrate: int = 38400) -> ELMProtocol:
proto = ELMProtocol(port, baudrate)
proto.connect()
return proto
View File
+348
View File
@@ -0,0 +1,348 @@
"""
obd/protocol.py — ELM327 стейт-машина (AndrOBD).
Точная копия логики из AndrOBD (ElmProt.java, github.com/fr3ts0n/AndrOBD).
## Архитектура
┌──────────┐ команда ┌──────────┐
│ READY │──────────────▶│ BUSY │
└──────────┘ └────┬─────┘
▲ │ ответ получен
│ ┌────────────────┘
│ ▼
┌────┴─────┐ ошибка ┌──────────┐
│ ERROR │◀─────────│ (любое) │
└────┬─────┘ └──────────┘
│ восстановление ▲
└─────────────────────┘
## Использование
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 enum import Enum, auto
from typing import Optional
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:
"""Стейт-машина ELM327 — 1:1 копия AndrOBD (ElmProt.java).
Управляет жизненным циклом ELM327:
1. connect() — открыть serial/Bluetooth порт
2. init() — инициализация (ATSP0, ATAT1, ATST, ATS0, ATL0, ATE0)
3. send(cmd) — отправить OBD-команду, получить ответ
4. close() — закрыть порт
Автоматически обрабатывает: таймауты, BUS ERROR, восстановление.
"""
INIT_TMO = 10000 # мс — таймаут для команд инициализации
DEF_TMO = 200 # мс — начальный таймаут (заменяется AdaptiveTiming)
def __init__(self, port: str, baudrate: int = 38400):
"""port — устройство (напр. /dev/rfcomm0), baudrate — скорость."""
self.port = port
self.baudrate = baudrate
self._ser = None
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}")
def close(self):
"""Закрыть serial-соединение."""
if self._ser and self._ser.is_open:
self._ser.close()
def init(self):
"""Инициализация ELM327 — 6 AT-команд.
ATSP0 — авто-протокол
ATAT1 — адаптивный таймаут вкл
ATSTxx — установить таймаут
ATS0 — без пробелов в ответах
ATL0 — без перевода строки
ATE0 — без эха
"""
logger.info("AndrOBD: init")
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._state = State.READY
logger.info("AndrOBD: ready")
def send(self, cmd: str) -> str:
"""Отправить OBD-команду и получить ответ.
cmd — команда (напр. '0105', '0902', '03').
Возвращает сырой ответ ELM327.
При ошибке — авто-восстановление.
"""
if self._state == State.ERROR:
self._recover()
self._state = State.BUSY
result = self._exec(cmd, self._timing.ms)
self._state = State.READY
return result
# ── Приватные методы ──────────────────────────────────
def _exec(self, cmd: str, timeout: int) -> str:
"""Выполнить команду с таймаутом и ретраями (до 10 попыток)."""
self._last_cmd = cmd
self._write(cmd)
t = timeout
for _ in range(10):
try:
return self._handle(self._read(t))
except TimeoutError:
if self._state == State.INITIALIZING:
t += 1000
else:
self._timing.increase()
t = self._timing.ms
logger.error(f"AndrOBD: no response for {cmd}")
self._state = State.ERROR
return ""
def _handle(self, raw: str) -> str:
"""Обработать ответ ELM327: классифицировать и обновить таймаут.
Возвращает raw как есть — обработка данных делается выше.
"""
t = Rsp.identify(raw)
if t == Rsp.SEARCHING:
return raw
if t == Rsp.OK:
self._timing.decrease()
return raw
if t == Rsp.NODATA:
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._try_read()
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
self._write("ATWS")
self._try_read()
return raw
# Данные — успешный ответ
self._timing.decrease()
return raw
def _recover(self):
"""Восстановление после ошибки: ATWS → ATSP0 → ATE0."""
logger.info("AndrOBD: recovering...")
self._state = State.INITIALIZING
self._write("ATWS")
self._try_read()
self._write("ATSP0")
self._try_read()
self._write("ATE0")
self._try_read()
self._state = State.READY
def _write(self, cmd: str):
"""Отправить команду в ELM327 (добавляет CR, flush)."""
self._ser.write((cmd + "\r").encode())
self._ser.flush()
logger.debug(f"AndrOBD → {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)
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()
def _update_atst(self):
"""Отправить ATST с текущим значением адаптивного таймаута."""
self._write(f"ATST{self._timing.atst:02X}")
self._try_read()
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Тест AndrOBD-протокола против Mock ELM327 v2.
Проверяет что ответы НЕ перемешаны:
- 0902 → VIN (не "AUTO" от ATSP0)
- 03 → DTC (не "OK" от ATE0)
- 010C → RPM (не VIN-ответ)
"""
import logging
import socket
import time
import sys
sys.path.insert(0, ".")
logging.basicConfig(level=logging.INFO, format="%(name)s: %(message)s")
class FakeSerial:
"""TCP-заглушка для тестирования AndrOBD без pyserial."""
def __init__(self, host, port):
self.sock = socket.socket()
self.sock.connect((host, port))
self.sock.setblocking(False)
self.sock.settimeout(0.1)
self.is_open = True
@property
def in_waiting(self):
import select
r, _, _ = select.select([self.sock], [], [], 0)
return 4096 if r else 0
def read(self, n):
try:
return self.sock.recv(n)
except Exception:
return b""
def write(self, data):
self.sock.sendall(data)
def flush(self):
pass
def close(self):
self.sock.close()
def test():
"""Запускает AndrOBD против мока и проверяет ответы."""
from elmer.androbd import AndrOBD, AdaptiveTiming
# Создаём протокол и подменяем serial на TCP-заглушку
elm = AndrOBD("mock")
elm._ser = FakeSerial("127.0.0.1", 35000)
print("=== 1. ИНИТ ===")
elm.init()
print("\n=== 2. VIN (0902) ===")
# Дадим моку дообработать ответы после инита
time.sleep(0.5)
vin = elm.send("0902")
print(f" VIN ответ: [{vin}]")
assert "490201" in vin.replace(" ", ""), f"VIN ответ не содержит 490201: {vin}"
print("\n=== 3. DTC (03) ===")
dtc = elm.send("03")
print(f" DTC ответ: {dtc}")
assert "43" in dtc.replace(" ", ""), f"DTC ответ не содержит 43: {dtc}"
print("\n=== 4. RPM (010C) ===")
rpm = elm.send("010C")
print(f" RPM ответ: {rpm}")
assert "410C" in rpm.replace(" ", ""), f"RPM ответ не содержит 410C: {rpm}"
print("\n=== 5. ОЖ (0105) ===")
ect = elm.send("0105")
print(f" ОЖ ответ: {ect}")
assert "4105" in ect.replace(" ", ""), f"ОЖ ответ не содержит 4105: {ect}"
print("\n=== 6. ПРОВЕРКА: ответы не перепутаны ===")
# VIN не должен быть "OK" или "AUTO"
assert "AUTO" not in vin, f"VIN ответ содержит AUTO (чужой ответ!): {vin}"
assert "OK" not in vin, f"VIN ответ содержит OK (чужой ответ!): {vin}"
# DTC не должен быть OK
assert "OK" not in dtc, f"DTC ответ содержит OK (чужой ответ!): {dtc}"
# RPM — чистый PID-ответ
assert "OK" not in rpm, f"RPM ответ содержит OK (чужой ответ!): {rpm}"
elm._ser.close()
print("\n✅ ВСЕ ТЕСТЫ ПРОЙДЕНЫ — ответы не перемешаны!")
return True
if __name__ == "__main__":
try:
test()
except AssertionError as e:
print(f"\n❌ ТЕСТ ПРОВАЛЕН: {e}")
sys.exit(1)
+13 -76
View File
@@ -1,33 +1,31 @@
"""Elmer Web UI — Flask-приложение для локального тестирования. """elmAI — точка входа Flask-приложения.
Заодно прототип будущего серверного API. Запуск через gunicorn:
Запуск: DEEPSEEK_API_KEY=sk-... python web/app.py gunicorn -w 4 -b 127.0.0.1:8000 web.app:app
Открыть: http://localhost:5005
Структура модулей:
api/ — REST-эндпоинты, БД, скрипты
brain/ — LLM-клиент, промпты
obd/ — ELM327-протокол (AndrOBD)
""" """
import sys import sys
import logging import logging
from pathlib import Path from pathlib import Path
# Добавляем корень проекта в PYTHONPATH # Добавляем корень проекта в PYTHONPATH для импорта api/, brain/, obd/
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
from flask import Flask, jsonify, redirect, render_template, request, send_from_directory from flask import Flask, jsonify, redirect, render_template, request, send_from_directory
from elmer.config import load from api.config import load
from elmer.db import Database from api.routes import register as register_api
from elmer.diagnose import Diagnoser
from elmer.elm import ELM327
from elmer.prompts import SYSTEM_PROMPT, build_user_prompt
from web.raw_endpoint import register as register_raw_endpoint
from web.script_endpoint import register as register_script_endpoint
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_raw_endpoint(app) register_api(app)
register_script_endpoint(app)
@app.route("/") @app.route("/")
@@ -42,67 +40,6 @@ def download_apk():
return send_from_directory("static", "app-debug.apk", as_attachment=True, download_name="elmer.apk") return send_from_directory("static", "app-debug.apk", as_attachment=True, download_name="elmer.apk")
@app.route("/api/diagnose", methods=["POST"])
def diagnose():
"""Подключается к ELM327, читает данные, отправляет в LLM, возвращает результат."""
elm_cfg = config["elm327"]
# ── 1. ELM327 ────────────────────────────────
try:
elm = ELM327(port=elm_cfg["port"], baudrate=elm_cfg["baudrate"])
except Exception as e:
return jsonify({"error": f"Не удалось открыть порт {elm_cfg['port']}: {e}"}), 500
if not elm.init():
elm.close()
return jsonify({"error": "ELM327 не ответил на ATZ"}), 500
# ── 2. VIN ──────────────────────────────────
vin = elm.read_vin()
if not vin:
elm.close()
return jsonify({"error": "Не удалось прочитать VIN (режим 09 не поддерживается?)"}), 500
# ── 3. Ошибки ───────────────────────────────
dtc_codes = elm.read_dtc_codes("03") + elm.read_dtc_codes("07")
# ── 4. Параметры ────────────────────────────
parameters = elm.read_all_pids(config.get("pids"))
elm.close()
# ── 5. DeepSeek ─────────────────────────────
api_key = config["deepseek"]["api_key"]
if not api_key:
return jsonify({"error": "DEEPSEEK_API_KEY не задан"}), 500
diagnoser = Diagnoser(
api_key=api_key,
model=config["deepseek"].get("model", "deepseek-chat"),
)
user_prompt = build_user_prompt(vin, dtc_codes, parameters)
answer = diagnoser.diagnose(SYSTEM_PROMPT, user_prompt)
# ── 6. SQLite ───────────────────────────────
db = Database()
car_id = db.get_or_create_car(vin)
token_id = db.create_token(car_id)
for dtc in dtc_codes:
db.add_dtc(token_id, dtc["code"], dtc.get("description", ""), dtc["status"])
for p in parameters:
db.add_parameter(token_id, p["pid_code"], p["name"], p["value"], p["unit"])
db.add_llm_message(token_id, "user", user_prompt)
db.add_llm_message(token_id, "assistant", answer)
return jsonify({
"vin": vin,
"dtc_codes": dtc_codes,
"parameters": parameters,
"diagnosis": answer,
"token_id": token_id,
})
if __name__ == "__main__": if __name__ == "__main__":
print(f"🌐 Elmer Web: http://localhost:5005") print(f"🌐 elmAI Web: http://localhost:5005")
app.run(host="0.0.0.0", port=5005, debug=False) app.run(host="0.0.0.0", port=5005, debug=False)
-350
View File
@@ -1,350 +0,0 @@
"""Эндпоинт /api/v1/raw-obd — приём сырых ответов, парсинг, управление сессией.
Сервер — мозг. Знает протокол ELM327. Клиент — тупая труба.
Протокол:
Клиент → {"raw": "..."} → Сервер
Сервер → {"cmd": "ATZ"} → Клиент пишет в BT
... итерации ...
Сервер → {"cmd": null} → сессия завершена, диагноз готов
Сессия:
1. INIT: ATZ, ATE0, ATL0, ATSP0, ATH1
2. VIN: 0902
3. DTC: 03 (stored), 07 (pending)
4. PIDS: 0105, 010C, 010D, ...
5. LLM: отправка в DeepSeek
6. LLM_LOOP: если LLM хочет ещё данных → ещё команды
7. DONE
"""
import logging
import re
import threading
from datetime import datetime, timezone
logger = logging.getLogger("elmer.raw")
# ── Сессии ─────────────────────────────────────────────
# session_id → {state, commands[], responses[], data{}}
sessions: dict[str, dict] = {}
sessions_lock = threading.Lock()
def _new_session() -> str:
"""Создаёт новую сессию, возвращает ID."""
import uuid
sid = uuid.uuid4().hex[:12]
# Очередь команд для инициализации
init_cmds = ["ATZ", "ATE0", "ATL0", "ATSP0", "ATH1"]
with sessions_lock:
sessions[sid] = {
"state": "INIT",
"created": datetime.now(timezone.utc).isoformat(),
"queue": list(init_cmds), # команды для отправки
"responses": [], # сырые ответы
"data": { # распарсенные данные
"vin": None,
"dtc_stored": [],
"dtc_pending": [],
"pids": [],
},
"llm_history": [],
"diagnosis": None,
}
return sid
def _parse_response(raw: str, data: dict) -> str | None:
"""Парсит сырой ответ ELM327, заполняет data. Возвращает None или описание."""
raw = raw.strip().upper()
# OK / ?>
if raw in ("OK", "?", "NO DATA", "SEARCHING...", "STOPPED", "READY"):
return raw
# ELM327 version
if raw.startswith("ELM"):
data["elm_version"] = raw
return f"ELM: {raw}"
# VIN response (mode 09 PID 02): "014 0:49 02 01 57 56 57..."
if "49 02" in raw or "49:02" in raw:
hex_str = re.sub(r".*49.?02.?01", "", raw.replace("\n", " ").replace(":", " ")).strip()
hex_bytes = hex_str.split()
vin = ""
for h in hex_bytes:
try:
vin += chr(int(h, 16))
except (ValueError, OverflowError):
pass
if len(vin) == 17:
data["vin"] = vin
return f"VIN: {vin}"
return f"VIN partial: {vin}"
# DTC response (mode 43/47)
if raw.startswith("43") or raw.startswith("47"):
mode = "stored" if raw.startswith("43") else "pending"
# Парсим коды: 43 01 33 00... → P0301
hex_bytes = re.sub(r"^4[37]\s*", "", raw).split()
i = 1 # пропускаем байт количества
codes = []
while i + 1 < len(hex_bytes):
a, b = int(hex_bytes[i], 16), int(hex_bytes[i + 1], 16)
prefix = {0: "P", 1: "C", 2: "B", 3: "U"}.get(a >> 6, "?")
code = f"{prefix}{(a>>4)&3}{a&15}{b>>4:X}{b&15:X}"
if code != "P0000":
codes.append(code)
if mode == "stored":
data["dtc_stored"].append(code)
else:
data["dtc_pending"].append(code)
i += 2
return f"DTC {mode}: {codes}"
# PID response (mode 41): "41 05 5A"
if raw.startswith("41"):
parts = raw.split()
if len(parts) >= 3:
pid = parts[1]
hex_vals = parts[2:]
formulas = {
"05": lambda b: int(b, 16) - 40,
"0C": lambda b: (int(b[0], 16) * 256 + int(b[1], 16)) / 4,
"0D": lambda b: int(b[0], 16),
"11": lambda b: int(b[1], 16) * 100 / 255 if len(b) > 1 else int(b[0], 16) * 100 / 255,
"0B": lambda b: int(b[0], 16),
"0F": lambda b: int(b[0], 16) - 40,
"1F": lambda b: int(b[0], 16) * 256 + (int(b[1], 16) if len(b) > 1 else 0),
"04": lambda b: int(b[0], 16) * 100 / 255,
"06": lambda b: (int(b[0], 16) - 128) * 100 / 128,
"07": lambda b: (int(b[0], 16) - 128) * 100 / 128,
}
if pid in formulas:
try:
val = round(formulas[pid](hex_vals), 1)
data["pids"].append({"pid": pid, "value": val})
return f"PID {pid}: {val}"
except Exception:
pass
return f"PID {pid} raw: {hex_vals}"
# AT-ответы (протокол)
if raw.startswith("AUTO") or "ISO" in raw or "SAE" in raw:
data["protocol"] = raw
return f"Protocol: {raw}"
return raw
def _next_commands(sess: dict) -> list[str] | None:
"""Определяет следующие команды в зависимости от состояния сессии."""
state = sess["state"]
data = sess["data"]
if state == "INIT":
# Инициализация завершена → запрос VIN
sess["state"] = "VIN"
return ["0902"]
if state == "VIN":
sess["state"] = "DTC_STORED"
return ["03"]
if state == "DTC_STORED":
sess["state"] = "DTC_PENDING"
return ["07"]
if state == "DTC_PENDING":
sess["state"] = "PIDS"
# Стандартные PID
return ["0105", "010C", "010D", "0111", "010B", "010F", "011F", "0104", "0106", "0107"]
if state == "PIDS":
# Все данные собраны → LLM
sess["state"] = "LLM"
return None # Нет команд, вызываем LLM
if state == "LLM":
sess["state"] = "DONE"
return None
return None
# ── Flask endpoint ─────────────────────────────────────
def register(app):
"""Регистрирует /api/v1/raw-obd на Flask-приложении."""
@app.route("/api/v1/raw-obd", methods=["POST"])
def raw_obd():
from flask import request, jsonify
data = request.get_json(silent=True)
if not data or "raw" not in data:
return jsonify({"error": "missing 'raw'"}), 400
raw = data["raw"].strip()
session_id = data.get("session")
# Новая сессия?
if not session_id:
session_id = _new_session()
logger.info(f"[{session_id}] NEW SESSION")
with sessions_lock:
sess = sessions.get(session_id)
if not sess:
session_id = _new_session()
sess = sessions[session_id]
sess["responses"].append(raw)
# Парсим ответ
parsed = _parse_response(raw, sess["data"])
logger.info(f"[{session_id}] {parsed}")
# SEARCHING/NO DATA — ждём, не продвигаем стейт
is_skip = raw.startswith("SEARCHING") or raw in ("NO DATA", "STOPPED", "?")
if is_skip:
sess["retries"] = sess.get("retries", 0) + 1
if sess["retries"] > 3:
logger.info(f"[{session_id}] Giving up after {sess['retries']} retries")
sess["retries"] = 0
# Продвигаем принудительно (ниже)
else:
# Ждём — не шлём команду, ELM327 сам ответит когда готов
return jsonify({"cmd": None, "session": session_id, "state": sess["state"], "msg": "Жду..."})
# Если есть очередь — отдаём следующую
if sess["queue"]:
cmd = sess["queue"].pop(0)
return jsonify({"cmd": cmd, "session": session_id, "state": sess["state"]})
sess["retries"] = 0
# Определяем что дальше
next_cmds = _next_commands(sess)
if next_cmds:
sess["queue"] = list(next_cmds)
cmd = sess["queue"].pop(0)
return jsonify({"cmd": cmd, "session": session_id, "state": sess["state"]})
# LLM фаза — если нет ключа, возвращаем сырые данные
if sess["state"] == "LLM":
from elmer.config import load
cfg = load()
api_key = cfg["deepseek"]["api_key"]
if not api_key:
# Без LLM — форматируем декодированные данные
data = sess["data"]
lines = []
if data.get("vin"): lines.append(f"VIN: {data['vin']}")
if data.get("dtc_stored"): lines.append(f"Ошибки: {', '.join(data['dtc_stored'])}")
if data.get("dtc_pending"): lines.append(f"Pending: {', '.join(data['dtc_pending'])}")
if data.get("pids"):
names = {"05": "ОЖ", "0C": "RPM", "0D": "Скорость", "11": "Дроссель",
"0B": "MAP", "0F": "IAT", "1F": "Время", "04": "Нагрузка",
"06": "STFT", "07": "LTFT"}
for p in data["pids"]:
n = names.get(p["pid"], p["pid"])
lines.append(f"{n}: {p['value']}")
sess["diagnosis"] = "\n".join(lines) if lines else "Нет данных"
sess["state"] = "DONE"
logger.info(f"[{session_id}] No LLM — returning raw data")
else:
sess["state"] = "LLM_WAIT"
import threading as th
th.Thread(target=_call_llm, args=(session_id,), daemon=True).start()
return jsonify({
"cmd": None,
"session": session_id,
"state": "LLM",
"msg": "Анализирую..."
})
# Готово
return jsonify({
"cmd": None,
"session": session_id,
"state": sess["state"],
"diagnosis": sess.get("diagnosis"),
})
def _call_llm(session_id: str):
"""Вызывает DeepSeek с собранными данными."""
from elmer.config import load
from elmer.diagnose import Diagnoser
from elmer.prompts import SYSTEM_PROMPT
with sessions_lock:
sess = sessions.get(session_id)
if not sess:
return
data = sess["data"]
config = load()
api_key = config["deepseek"]["api_key"]
if not api_key:
logger.error(f"[{session_id}] No DEEPSEEK_API_KEY")
return
# Собираем промпт
parts = [f"VIN: {data.get('vin', 'неизвестен')}"]
if data.get("dtc_stored"):
parts.append(f"Ошибки (stored): {', '.join(data['dtc_stored'])}")
if data.get("dtc_pending"):
parts.append(f"Ошибки (pending): {', '.join(data['dtc_pending'])}")
if data.get("pids"):
parts.append("Параметры: " + ", ".join(
f"{p['pid']}={p['value']}" for p in data["pids"]))
user_prompt = "\n".join(parts)
try:
diagnoser = Diagnoser(api_key=api_key, model=config["deepseek"].get("model", "deepseek-chat"))
answer = diagnoser.diagnose(SYSTEM_PROMPT, user_prompt)
with sessions_lock:
if sess:
sess["diagnosis"] = answer
sess["state"] = "DONE"
logger.info(f"[{session_id}] Diagnosis ready ({len(answer)} chars)")
except Exception as e:
logger.error(f"[{session_id}] LLM error: {e}")
with sessions_lock:
if sess:
sess["diagnosis"] = f"Ошибка: {e}"
sess["state"] = "DONE"
@app.route("/api/v1/session/<session_id>", methods=["GET"])
def get_session(session_id):
"""Получить статус и диагноз сессии."""
from flask import jsonify
with sessions_lock:
sess = sessions.get(session_id)
if not sess:
return jsonify({"error": "session not found"}), 404
return jsonify({
"session": session_id,
"state": sess["state"],
"diagnosis": sess.get("diagnosis"),
"data": {
"vin": sess["data"].get("vin"),
"dtc_stored": sess["data"].get("dtc_stored", []),
"dtc_pending": sess["data"].get("dtc_pending", []),
"pids": sess["data"].get("pids", []),
},
"created": sess.get("created"),
})
return app
+13 -10
View File
@@ -2,23 +2,26 @@
def build_default_script() -> dict: def build_default_script() -> dict:
"""Стандартный скрипт диагностики.""" """Минимальный скрипт для отладки: 1 PID → LLM."""
return { return {
"version": 1, "version": 1,
"title": "Базовая диагностика", "title": "Экспресс-диагностика",
"steps": [
{"id": "pid_05", "cmd": "0105", "desc": "Температура ОЖ"},
],
}
def build_full_script() -> dict:
"""Полный скрипт диагностики."""
return {
"version": 1,
"title": "Полная диагностика",
"steps": [ "steps": [
{"id": "prompt_engine", "prompt": "Заведите двигатель (холостой ход) и нажмите «Далее»",
"wait_for_user": True},
{"id": "vin", "cmd": "0902", "desc": "VIN"},
{"id": "dtc_stored", "cmd": "03", "desc": "Ошибки (сохранённые)"},
{"id": "dtc_pending", "cmd": "07", "desc": "Ошибки (ожидающие)"},
{"id": "pid_05", "cmd": "0105", "desc": "Температура ОЖ"}, {"id": "pid_05", "cmd": "0105", "desc": "Температура ОЖ"},
{"id": "pid_0C", "cmd": "010C", "desc": "Обороты"}, {"id": "pid_0C", "cmd": "010C", "desc": "Обороты"},
{"id": "pid_0D", "cmd": "010D", "desc": "Скорость"}, {"id": "pid_0D", "cmd": "010D", "desc": "Скорость"},
{"id": "pid_11", "cmd": "0111", "desc": "Дроссель"}, {"id": "pid_11", "cmd": "0111", "desc": "Дроссель"},
{"id": "pid_0B", "cmd": "010B", "desc": "MAP"},
{"id": "pid_0F", "cmd": "010F", "desc": "IAT"},
{"id": "pid_1F", "cmd": "011F", "desc": "Время работы"},
{"id": "pid_04", "cmd": "0104", "desc": "Нагрузка"}, {"id": "pid_04", "cmd": "0104", "desc": "Нагрузка"},
{"id": "pid_06", "cmd": "0106", "desc": "STFT"}, {"id": "pid_06", "cmd": "0106", "desc": "STFT"},
{"id": "pid_07", "cmd": "0107", "desc": "LTFT"}, {"id": "pid_07", "cmd": "0107", "desc": "LTFT"},
+135 -14
View File
@@ -5,7 +5,8 @@ POST /api/v1/session/upload — приём батча, LLM-анализ, воз
""" """
import logging import logging
from web.script_builder import build_default_script import time
from web.script_builder import build_default_script, build_full_script
from web.script_parser import parse_batch, format_no_llm from web.script_parser import parse_batch, format_no_llm
logger = logging.getLogger("elmer.script") logger = logging.getLogger("elmer.script")
@@ -54,13 +55,16 @@ def register(app):
@app.route("/api/v1/script", methods=["GET"]) @app.route("/api/v1/script", methods=["GET"])
def get_script(): def get_script():
from flask import jsonify from flask import jsonify, request
return jsonify(build_default_script()) mode = request.args.get("mode", "full")
script = build_full_script() if mode == "full" else build_default_script()
return jsonify(script)
@app.route("/api/v1/session/upload", methods=["POST"]) @app.route("/api/v1/session/upload", methods=["POST"])
def upload_session(): def upload_session():
from flask import request, jsonify from flask import request, jsonify
from elmer.config import load from elmer.config import load
from elmer.db import Database
from elmer.diagnose import Diagnoser from elmer.diagnose import Diagnoser
from elmer.prompts import SYSTEM_PROMPT from elmer.prompts import SYSTEM_PROMPT
@@ -70,29 +74,146 @@ def register(app):
responses = data["responses"] responses = data["responses"]
logger.info(f"Upload: {len(responses)} responses") logger.info(f"Upload: {len(responses)} responses")
# ── Информация о клиенте ──────────────────────
client_info = data.get("client_info", {})
client_info["client_ip"] = request.remote_addr
client_info["real_ip"] = request.headers.get("X-Real-IP", "")
client_info["user_agent"] = request.headers.get("User-Agent", "")
client_info["content_length"] = request.content_length
parsed = parse_batch(responses) parsed = parse_batch(responses)
cfg = load() cfg = load()
api_key = cfg["llm"]["api_key"] api_key = cfg["llm"]["api_key"]
model = cfg["llm"].get("model", "gpt-oss-120b")
llm_available = bool(api_key)
llm_start = time.time()
llm_success = False
diagnosis = ""
if not api_key: if not api_key:
return jsonify({ diagnosis = format_no_llm(parsed)
"diagnosis": format_no_llm(parsed), else:
"parsed": _summary(parsed), diagnoser = Diagnoser(
}) api_key=api_key,
model=model,
base_url=cfg["llm"].get("base_url", "https://api.aillm.ru/v1"),
)
try:
diagnosis = diagnoser.diagnose(SYSTEM_PROMPT, _build_diagnosis_prompt(parsed))
llm_success = True
except Exception as e:
logger.warning(f"LLM failed: {e}")
diagnosis = format_no_llm(parsed) + f"\n\n(LLM недоступен: {e})"
diagnoser = Diagnoser( llm_duration_ms = int((time.time() - llm_start) * 1000)
api_key=api_key,
model=cfg["llm"].get("model", "gpt-oss-120b"), # ── Сохранение в БД ───────────────────────────
base_url=cfg["llm"].get("base_url", "https://api.aillm.ru/v1"), try:
) db = Database()
answer = diagnoser.diagnose(SYSTEM_PROMPT, _build_diagnosis_prompt(parsed)) db.save_session(
client_info=client_info,
responses=responses,
diagnosis=diagnosis,
llm_model=model,
llm_duration_ms=llm_duration_ms,
llm_success=llm_success,
)
except Exception as e:
logger.error(f"DB save failed: {e}")
return jsonify({ return jsonify({
"diagnosis": answer, "diagnosis": diagnosis,
"parsed": _summary(parsed), "parsed": _summary(parsed),
"llm_available": llm_available,
"llm_success": llm_success,
}) })
@app.route("/api/v1/chat", methods=["POST"])
def chat():
"""Свободный вопрос к LLM (без ELM)."""
from flask import request, jsonify
from elmer.config import load
from elmer.diagnose import Diagnoser
data = request.get_json(silent=True)
if not data or "question" not in data:
return jsonify({"error": "missing 'question'"}), 400
question = data["question"].strip()
if not question:
return jsonify({"answer": "Пустой вопрос."})
# История диалога
history = data.get("history", [])
history_text = ""
if history:
history_text = "## История диалога\n"
for m in history[-10:]: # последние 10 сообщений
role = "Водитель" if m.get("role") == "user" else "Автоэксперт"
history_text += f"{role}: {m.get('content', '')}\n"
history_text += "\n"
cfg = load()
api_key = cfg["llm"]["api_key"]
if not api_key:
return jsonify({"answer": "LLM не настроен."})
prompt = (
f"{history_text}"
f"Ты — автоэксперт. Помни контекст диалога выше. "
f"Отвечай КРАТКО, не более 20 строк. Без воды, только по делу.\n\n"
f"Вопрос: {question}"
)
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"),
)
answer = diagnoser.diagnose(
"Ты — лаконичный автоэксперт. Помни контекст диалога. Отвечай кратко, максимум 20 строк.",
prompt,
)
except Exception as e:
answer = f"LLM недоступен: {e}"
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."""
from flask import jsonify
from elmer.config import load
from elmer.diagnose import Diagnoser
cfg = load()
api_key = cfg["llm"]["api_key"]
if not api_key:
return jsonify({"ok": False, "error": "no API key"})
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)
return jsonify({"ok": True, "ms": ms})
except Exception as e:
ms = int((time.time() - t0) * 1000)
return jsonify({"ok": False, "ms": ms, "error": str(e)[:100]})
def _summary(p: dict) -> dict: def _summary(p: dict) -> dict:
return { return {
+9 -9
View File
@@ -4,25 +4,25 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Elmer — Диагностика авто</title> <title>elmAI — Диагностика авто</title>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<h1>🔧 Elmer</h1> <h1>🔧 elmAI</h1>
<p class="subtitle">Диагностика через ELM327 + ИИ</p> <p class="subtitle">Диагностика авто через ELM327 + ИИ</p>
<p class="subtitle" style="font-size:12px;opacity:0.7;">v0.28.0-dev — 29 мая 2026</p>
<div class="card" style="text-align:center;margin-bottom:20px;"> <div class="card" style="text-align:center;margin-bottom:20px;">
<p style="margin:0 0 10px 0;">📱 Скачай APK прямо на телефон:</p> <p style="margin:0 0 10px 0;">📱 Скачай приложение на телефон:</p>
<a href="/elmer.apk" style="color:#ff6b35;font-size:18px;font-weight:bold;text-decoration:none;"> <a href="/static/app-debug.apk" style="color:#ff6b35;font-size:18px;font-weight:bold;text-decoration:none;">
⬇️ Скачать Elmer APK ⬇️ Скачать elmAI APK
</a> </a>
<p style="font-size:11px;opacity:0.6;margin:4px 0 0 0;">v0.28.0-dev • нажмите чтобы скачать</p>
</div> </div>
<button id="btn-diagnose" onclick="runDiagnose()"> <!-- Кнопка десктоп-диагностики скрыта — только для разработчика с прямым ELM327 -->
🚗 Подключить и диагностировать
</button>
<div id="status"></div> <div id="status"></div>