From 6cc42bc340119e8287c86b42c005b41751bb25ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Mon, 25 May 2026 07:46:04 +0400 Subject: [PATCH] project structure: elm.py, db.py, prompts.py, diagnose.py, web UI (Flask), config --- analysis.md | 65 +++++++++++- config.yaml | 24 +++++ elmer/__init__.py | 3 + elmer/config.py | 39 ++++++++ elmer/db.py | 153 ++++++++++++++++++++++++++++ elmer/diagnose.py | 48 +++++++++ elmer/elm.py | 208 +++++++++++++++++++++++++++++++++++++++ elmer/prompts.py | 64 ++++++++++++ requirements.txt | 5 + run.py | 108 ++++++++++++++++++++ web/app.py | 95 ++++++++++++++++++ web/static/style.css | 174 ++++++++++++++++++++++++++++++++ web/templates/index.html | 104 ++++++++++++++++++++ 13 files changed, 1089 insertions(+), 1 deletion(-) create mode 100644 config.yaml create mode 100644 elmer/__init__.py create mode 100644 elmer/config.py create mode 100644 elmer/db.py create mode 100644 elmer/diagnose.py create mode 100644 elmer/elm.py create mode 100644 elmer/prompts.py create mode 100644 requirements.txt create mode 100644 run.py create mode 100644 web/app.py create mode 100644 web/static/style.css create mode 100644 web/templates/index.html diff --git a/analysis.md b/analysis.md index 0ce0f7c..7c39975 100644 --- a/analysis.md +++ b/analysis.md @@ -92,4 +92,67 @@ --- -*Продолжить: читать данные с ELM327 на ноутбуке по Bluetooth.* +## Структура проекта (создана 2026-05-25) + +``` +elmer/ +├── elmer/ # Python-пакет +│ ├── __init__.py # версия 0.1.0 +│ ├── config.py # загрузка config.yaml + подстановка ${ENV} +│ ├── elm.py # ELM327: pyserial, VIN, DTC, PID +│ ├── db.py # SQLite: cars, tokens, llm_messages, ecu_parameters, dtc_codes +│ ├── prompts.py # SYSTEM_PROMPT + build_user_prompt() +│ └── diagnose.py # DeepSeek API (OpenAI-совместимый) +├── config.yaml # настройки (BT-порт, API-ключ, PID'ы) +├── requirements.txt # pyserial, pyyaml, requests +├── run.py # главный вход: ELM → данные → LLM → печать + сохранение +├── idea.md # исходная задумка +└── analysis.md # этот файл +``` + +## Запуск (в салоне авто) + +```bash +# 1. Установить зависимости +pip install -r requirements.txt + +# 2. Сопрячь ELM327 по Bluetooth +bluetoothctl pair 11:22:33:44:55:66 +# (в config.yaml прописан порт /dev/rfcomm0) + +# 3. Запустить +DEEPSEEK_API_KEY=sk-... python run.py +``` + +Что произойдёт: +1. Подключится к ELM327 +2. Прочитает VIN +3. Считает ошибки (stored mode 03 + pending mode 07) +4. Считает параметры (обороты, температура, скорость, дроссель, MAP, IAT, топливные тримы) +5. Отправит в DeepSeek → напечатает диагноз +6. Сохранит всё в `elmer.db` (SQLite) + +--- + +## Десктопный UI (Web) + +Для тестирования на ноутбуке (не тыкать грязным пальцем в телефон): +- `web/app.py` — Flask (порт 5005), один endpoint `/api/diagnose` (POST) +- `web/templates/index.html` — одна кнопка, тёмная тема, результат +- В будущем этот же код — прототип серверного API + +Запуск: +```bash +DEEPSEEK_API_KEY=sk-... python web/app.py +# Открыть http://localhost:5005 +``` + +## Версии для пользователей (будущее) + +- **Android** — Kotlin/Java, Bluetooth SPP +- **Windows** — тот же веб-интерфейс в WebView (или Electron, или просто браузер) +- Общий серверный API между ними + +--- + +*Продолжить: тестировать в салоне авто с реальным ELM327.* diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..84b20ed --- /dev/null +++ b/config.yaml @@ -0,0 +1,24 @@ +# Elmer configuration +# Значения вида ${VAR} подставляются из переменных окружения + +deepseek: + api_key: "${DEEPSEEK_API_KEY}" + model: "deepseek-chat" + +elm327: + # Bluetooth-порт (обычно /dev/rfcomm0 после pairing) + port: "/dev/rfcomm0" + baudrate: 38400 + +# PID'ы для чтения (можно переопределить) +pids: + "0105": ["coolant_temp", "°C"] + "010C": ["rpm", "об/мин"] + "010D": ["speed", "км/ч"] + "0111": ["throttle_pos", "%"] + "010B": ["map", "кПа"] + "010F": ["iat", "°C"] + "011F": ["runtime_since_start", "с"] + "0104": ["engine_load", "%"] + "0106": ["stft_b1", "%"] + "0107": ["ltft_b1", "%"] diff --git a/elmer/__init__.py b/elmer/__init__.py new file mode 100644 index 0000000..5ba83b1 --- /dev/null +++ b/elmer/__init__.py @@ -0,0 +1,3 @@ +"""Elmer — анализ ошибок электроники автомобиля через ELM327 + LLM.""" + +__version__ = "0.1.0" diff --git a/elmer/config.py b/elmer/config.py new file mode 100644 index 0000000..fa27765 --- /dev/null +++ b/elmer/config.py @@ -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) diff --git a/elmer/db.py b/elmer/db.py new file mode 100644 index 0000000..e4f90ae --- /dev/null +++ b/elmer/db.py @@ -0,0 +1,153 @@ +"""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 +""" + +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')) + ); + """) + self.conn.commit() + + # ── cars ────────────────────────────────────────────── + + def get_or_create_car(self, vin: str) -> int: + """Возвращает car_id по VIN, создаёт запись если нет.""" + row = self.conn.execute("SELECT id FROM cars WHERE vin = ?", (vin,)).fetchone() + if row: + return row["id"] + cur = self.conn.execute("INSERT INTO cars (vin) VALUES (?)", (vin,)) + self.conn.commit() + return cur.lastrowid + + def update_car_info(self, car_id: int, make: str, model: str, year: int, engine: str): + self.conn.execute( + "UPDATE cars SET make=?, model=?, year=?, engine=? WHERE id=?", + (make, model, year, engine, car_id), + ) + self.conn.commit() + + # ── tokens ──────────────────────────────────────────── + + def create_token(self, car_id: int) -> int: + """Создаёт новую сессию диагностики, возвращает token_id.""" + cur = self.conn.execute( + "INSERT INTO diagnostic_tokens (car_id) VALUES (?)", (car_id,) + ) + self.conn.commit() + return cur.lastrowid + + def last_token_for_car(self, car_id: int) -> int | None: + """Последняя сессия для VIN (для продолжения диалога), или None.""" + row = self.conn.execute( + "SELECT id FROM diagnostic_tokens WHERE car_id=? ORDER BY created_at DESC LIMIT 1", + (car_id,), + ).fetchone() + return row["id"] if row else None + + # ── llm_messages ────────────────────────────────────── + + def add_llm_message(self, token_id: int, role: str, content: str): + self.conn.execute( + "INSERT INTO llm_messages (token_id, role, content) VALUES (?, ?, ?)", + (token_id, role, content), + ) + self.conn.commit() + + def get_llm_messages(self, token_id: int) -> list[dict]: + """Возвращает историю диалога для токена.""" + rows = self.conn.execute( + "SELECT role, content FROM llm_messages WHERE token_id=? ORDER BY id", + (token_id,), + ).fetchall() + return [{"role": r["role"], "content": r["content"]} for r in rows] + + # ── ecu_parameters ──────────────────────────────────── + + def add_parameter(self, token_id: int, pid_code: str, name: str, value: float, unit: str): + self.conn.execute( + "INSERT INTO ecu_parameters (token_id, pid_code, name, value, unit) VALUES (?, ?, ?, ?, ?)", + (token_id, pid_code, name, value, unit), + ) + self.conn.commit() + + def get_parameters(self, token_id: int) -> list[dict]: + rows = self.conn.execute( + "SELECT pid_code, name, value, unit FROM ecu_parameters WHERE token_id=? ORDER BY id", + (token_id,), + ).fetchall() + return [dict(r) for r in rows] + + # ── dtc_codes ───────────────────────────────────────── + + def add_dtc(self, token_id: int, code: str, description: str = "", status: str = "stored"): + self.conn.execute( + "INSERT INTO dtc_codes (token_id, code, description, status) VALUES (?, ?, ?, ?)", + (token_id, code, description, status), + ) + self.conn.commit() + + def get_dtcs(self, token_id: int) -> list[dict]: + rows = self.conn.execute( + "SELECT code, description, status FROM dtc_codes WHERE token_id=? ORDER BY id", + (token_id,), + ).fetchall() + return [dict(r) for r in rows] diff --git a/elmer/diagnose.py b/elmer/diagnose.py new file mode 100644 index 0000000..6f5d04d --- /dev/null +++ b/elmer/diagnose.py @@ -0,0 +1,48 @@ +"""Вызов DeepSeek API для диагностики.""" + +import requests + +DEEPSEEK_BASE = "https://api.deepseek.com/v1" +DEFAULT_MODEL = "deepseek-chat" + + +class Diagnoser: + """Отправляет данные в DeepSeek и возвращает диагноз.""" + + def __init__(self, api_key: str, model: str = DEFAULT_MODEL, base_url: str = DEEPSEEK_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=60, + ) + 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) diff --git a/elmer/elm.py b/elmer/elm.py new file mode 100644 index 0000000..970dd35 --- /dev/null +++ b/elmer/elm.py @@ -0,0 +1,208 @@ +"""Связь с 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}" diff --git a/elmer/prompts.py b/elmer/prompts.py new file mode 100644 index 0000000..d8f12bb --- /dev/null +++ b/elmer/prompts.py @@ -0,0 +1,64 @@ +"""Шаблоны промптов для LLM.""" + +SYSTEM_PROMPT = """Ты — эксперт по диагностике автомобилей. Ты анализируешь коды ошибок OBD2 и параметры ЭБУ. + +ПРАВИЛА ОТВЕТА: +1. Начинай с КРАТКОГО резюме (1-2 предложения): что скорее всего не так. +2. Затем — подробный анализ: почему ты так думаешь, какие параметры указывают на это. +3. Указывай степень уверенности в процентах для каждого вывода. +4. Если данных недостаточно — честно скажи, какие параметры нужно ещё считать. +5. Предлагай конкретные действия: что проверить, что заменить, в каком порядке. +6. Предупреждай о рисках: "если не поможет X, тогда проверь Y". +7. НИКОГДА не давай категоричных команд "меняй деталь X", если не уверен на 100%. +8. Пиши на русском языке, простым языком, без излишнего жаргона. + +ФОРМАТ ОТВЕТА: +## Краткий диагноз +... + +## Подробный анализ +... + +## Степень уверенности +- Проблема A: ~XX% +- Проблема B: ~XX% + +## Что делать +1. ... +2. ... + +## Если не поможет +- Проверь ... +""" + + +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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9f7d1b0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +pyserial>=3.5 +pyyaml>=6.0 +requests>=2.31 +flask>=3.0 +flask-cors>=4.0 diff --git a/run.py b/run.py new file mode 100644 index 0000000..4a2d1e7 --- /dev/null +++ b/run.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Elmer — главный вход. + +Порядок работы: + 1. Подключается к ELM327 по Bluetooth + 2. Читает VIN + 3. Читает коды ошибок (stored + pending) + 4. Читает базовые параметры ЭБУ + 5. Отправляет в DeepSeek + 6. Печатает диагноз + 7. Сохраняет всё в SQLite + +Использование: + DEEPSEEK_API_KEY=sk-... python run.py +""" + +import sys + +from elmer.config import load +from elmer.db import Database +from elmer.diagnose import Diagnoser +from elmer.elm import ELM327 +from elmer.prompts import SYSTEM_PROMPT, build_user_prompt + + +def main(): + config = load() + + # ── 1. ELM327 ──────────────────────────────────── + print("🔌 Подключение к ELM327...") + elm_cfg = config["elm327"] + + try: + elm = ELM327(port=elm_cfg["port"], baudrate=elm_cfg["baudrate"]) + except Exception as e: + print(f"❌ Не удалось открыть порт {elm_cfg['port']}: {e}") + print(" Проверь Bluetooth-сопряжение: bluetoothctl pair ") + sys.exit(1) + + if not elm.init(): + print("❌ ELM327 не ответил на ATZ. Проверь соединение.") + sys.exit(1) + print("✅ ELM327 готов") + + # ── 2. VIN ─────────────────────────────────────── + print("🚗 Чтение VIN...") + vin = elm.read_vin() + if not vin: + print("❌ Не удалось прочитать VIN. Машина поддерживает режим 09?") + elm.close() + sys.exit(1) + print(f"✅ VIN: {vin}") + + # ── 3. Коды ошибок ────────────────────────────── + print("🔍 Чтение кодов ошибок...") + dtc_stored = elm.read_dtc_codes("03") + dtc_pending = elm.read_dtc_codes("07") + dtc_codes = dtc_stored + dtc_pending + if dtc_codes: + for dtc in dtc_codes: + print(f" ⚠️ {dtc['code']} [{dtc['status']}]") + else: + print(" ✅ Ошибок нет") + + # ── 4. Параметры ЭБУ ──────────────────────────── + print("📊 Чтение параметров...") + parameters = elm.read_all_pids(config.get("pids")) + for p in parameters: + print(f" {p['name']}: {p['value']} {p['unit']}") + + elm.close() + + # ── 5. DeepSeek ────────────────────────────────── + api_key = config["deepseek"]["api_key"] + if not api_key: + print("\n❌ DEEPSEEK_API_KEY не задан. Установи переменную окружения.") + sys.exit(1) + + print("\n🧠 Отправка в DeepSeek...") + 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) + + print("\n" + "=" * 60) + print(answer) + print("=" * 60) + + # ── 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) + + print(f"\n💾 Сохранено в elmer.db (token_id={token_id})") + + +if __name__ == "__main__": + main() diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000..1f6abfd --- /dev/null +++ b/web/app.py @@ -0,0 +1,95 @@ +"""Elmer Web UI — Flask-приложение для локального тестирования. + +Заодно прототип будущего серверного API. +Запуск: DEEPSEEK_API_KEY=sk-... python web/app.py +Открыть: http://localhost:5005 +""" + +import sys +from pathlib import Path + +# Добавляем корень проекта в PYTHONPATH +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from flask import Flask, jsonify, render_template, request + +from elmer.config import load +from elmer.db import Database +from elmer.diagnose import Diagnoser +from elmer.elm import ELM327 +from elmer.prompts import SYSTEM_PROMPT, build_user_prompt + +app = Flask(__name__) +config = load() + + +@app.route("/") +def index(): + """Главная страница.""" + return render_template("index.html") + + +@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__": + print(f"🌐 Elmer Web: http://localhost:5005") + app.run(host="0.0.0.0", port=5005, debug=False) diff --git a/web/static/style.css b/web/static/style.css new file mode 100644 index 0000000..1d8e10a --- /dev/null +++ b/web/static/style.css @@ -0,0 +1,174 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: #1a1a2e; + color: #e0e0e0; + min-height: 100vh; +} + +.container { + max-width: 800px; + margin: 0 auto; + padding: 20px; +} + +h1 { + font-size: 2em; + text-align: center; + margin-top: 30px; + color: #ff6b35; +} + +.subtitle { + text-align: center; + color: #888; + margin-bottom: 30px; +} + +button { + display: block; + width: 100%; + padding: 18px; + font-size: 1.2em; + background: #ff6b35; + color: #fff; + border: none; + border-radius: 12px; + cursor: pointer; + transition: background 0.2s; + margin-bottom: 20px; +} + +button:hover:not(:disabled) { + background: #e85d2a; +} + +button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +#status { + text-align: center; + padding: 15px; + font-size: 1.1em; +} + +.spinner { + display: inline-block; + width: 24px; + height: 24px; + border: 3px solid #333; + border-top-color: #ff6b35; + border-radius: 50%; + animation: spin 0.8s linear infinite; + vertical-align: middle; + margin-right: 8px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.card { + background: #16213e; + border: 1px solid #0f3460; + border-radius: 12px; + padding: 20px; + margin-bottom: 16px; +} + +.card h2 { + font-size: 1.1em; + color: #ff6b35; + margin-bottom: 12px; +} + +code { + font-size: 1.3em; + letter-spacing: 2px; + color: #4ecca3; +} + +.dtc-badge { + display: inline-block; + padding: 6px 12px; + margin: 4px; + border-radius: 8px; + font-family: monospace; + font-size: 0.95em; +} + +.dtc-badge.stored { + background: #5c1a1a; + color: #ff6b6b; + border: 1px solid #8b2a2a; +} + +.dtc-badge.pending { + background: #5c4a1a; + color: #ffd93d; + border: 1px solid #8b6a2a; +} + +.param-row { + display: flex; + justify-content: space-between; + padding: 8px 0; + border-bottom: 1px solid #0f3460; +} + +.param-row:last-child { + border-bottom: none; +} + +.param-row span { + color: #aaa; +} + +.param-row strong { + color: #4ecca3; +} + +.diagnosis-card { + background: #0f1a2e; + border-color: #1a3a5c; +} + +.diagnosis-card h2 { + font-size: 1em; +} + +#diagnosis-text { + line-height: 1.7; + white-space: pre-line; +} + +#diagnosis-text h2 { + font-size: 1.2em; + margin-top: 16px; + color: #4ecca3; +} + +#diagnosis-text h3 { + font-size: 1.05em; + margin-top: 12px; + color: #ff6b35; +} + +#diagnosis-text strong { + color: #ffd93d; +} + +.ok { + color: #4ecca3; +} + +.error { + color: #ff6b6b; +} diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..af3112c --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,104 @@ + + + + + + Elmer — Диагностика авто + + + +
+

🔧 Elmer

+

Диагностика через ELM327 + ИИ

+ + + +
+ + +
+ + + +