Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bc1f901d7 | ||
|
|
f40918d07b | ||
|
|
84bd0cb34a | ||
|
|
d1903008c6 | ||
|
|
41831468f4 | ||
|
|
6372d38692 |
@@ -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
|
||||
@@ -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-панель | потом |
|
||||
@@ -0,0 +1,51 @@
|
||||
# Сессия 2026-05-29 — Сравнение LLM, модели DeepSeek, модели aillm.ru
|
||||
|
||||
## Хронология
|
||||
|
||||
### 10:00 — Вопрос о модели LLM
|
||||
- Используем `gpt-oss-120b` (120B open-source) на `api.aillm.ru/v1`
|
||||
- Изначально задумано как DeepSeek, но провайдер — aillm.ru
|
||||
|
||||
### 10:15 — DeepSeek V4: модели и цены
|
||||
**DeepSeek V4 Flash (`deepseek-v4-flash`):**
|
||||
- Кеш hit: $0.0028 / 1M токенов
|
||||
- Кеш miss: $0.14 / 1M
|
||||
- Выход: $0.28 / 1M
|
||||
- 1M контекст, 384K max output, 2500 concurrent
|
||||
|
||||
**DeepSeek V4 Pro (`deepseek-v4-pro`):**
|
||||
- До 31.05 акция 75% off
|
||||
- С 01.06 официальная цена = ¼ original (т.е. та же, что со скидкой)
|
||||
- Кеш hit: $0.0145 / 1M
|
||||
- Вход: $1.74 → фикс $0.435 / 1M
|
||||
- Выход: $3.48 → фикс $0.87 / 1M
|
||||
- 500 concurrent
|
||||
|
||||
### 10:30 — Создан `tools/compare_llm.py`
|
||||
Скрипт сравнения трёх моделей на одинаковом промпте (VIN VW, DTC P0301+P0171+P0420, параметры ЭБУ).
|
||||
|
||||
### 10:45 — Запуск сравнения
|
||||
|
||||
| Модель | Время | Вход | Выход | Стоимость |
|
||||
|--------|-------|------|-------|-----------|
|
||||
| DeepSeek V4 Flash | 56.9с | 427 | 6,494 | $0.0019 |
|
||||
| DeepSeek V4 Pro | 223.5с | 427 | 10,323 | $0.0030 |
|
||||
| Aillm.ru (gpt-oss-120b) | 103.3с | 492 | 7,003 | ? |
|
||||
|
||||
- V4 Flash: быстрый, конкретика по VW, лучший price/quality
|
||||
- V4 Pro: самый глубокий ответ (думал 223с — режим thinking)
|
||||
- Aillm 120b: адекватно, структурированно, но медленнее Flash
|
||||
|
||||
**Вывод:** 120b юзабелен для прототипа, DeepSeek V4 Flash — оптимальная замена при переходе в прод.
|
||||
|
||||
### 11:00 — Модели aillm.ru
|
||||
Запросом к `/v1/models` получен список:
|
||||
- `gpt-oss-120b` ← текущая
|
||||
- `qwen3-6-27b-fp8` (27B)
|
||||
- `qwen3.5-9b` (9B)
|
||||
- `whisper-large-v3-turbo` (речь)
|
||||
|
||||
Для диагноза альтернатив 120b нет.
|
||||
|
||||
### 11:15 — Dify-совместимость
|
||||
aillm.ru отдаёт OpenAI-совместимый API → Dify подключается как кастомный провайдер.
|
||||
@@ -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()
|
||||
+1
-1
@@ -28,7 +28,7 @@ class Diagnoser:
|
||||
"temperature": 0.3, # пониже — меньше фантазий
|
||||
"max_tokens": 4096,
|
||||
},
|
||||
timeout=60,
|
||||
timeout=120, # api.aillm.ru бывает медленным
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
+7
-7
@@ -108,16 +108,16 @@ class ELMProtocol:
|
||||
# ── Инициализация (AndrOBD ElmProt.initialize) ─────────
|
||||
|
||||
def init(self) -> bool:
|
||||
"""Порядок: ATSP0→ATAT1→ATS0→ATL0→ATE0. Без ATZ!"""
|
||||
"""ATSP0→ATAT1→ATS0→ATL0→ATE0. Каждая команда с чтением ответа."""
|
||||
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.send_command("ATSP0") # авто-протокол
|
||||
self.send_command("ATAT1") # adaptive timing
|
||||
self._update_timeout()
|
||||
self.send_command("ATS0") # пробелы выкл
|
||||
self.send_command("ATL0") # line feeds выкл
|
||||
self.send_command("ATE0") # эхо выкл
|
||||
|
||||
self._drain()
|
||||
logger.info("ELM: init done")
|
||||
return True
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare three LLMs: DeepSeek V4 Flash, DeepSeek V4 Pro, Aillm.ru.
|
||||
|
||||
Usage:
|
||||
DEEPSEEK_API_KEY=sk-... python tools/compare_llm.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
import yaml
|
||||
|
||||
TEST_DATA = {
|
||||
"vin": "WVWZZZ1KZAW123456",
|
||||
"dtc_codes": [
|
||||
{"code": "P0301", "status": "stored", "description": "Misfire cylinder 1"},
|
||||
{"code": "P0171", "status": "stored", "description": "System too lean bank 1"},
|
||||
{"code": "P0420", "status": "pending", "description": "Catalyst efficiency low bank 1"},
|
||||
],
|
||||
"parameters": [
|
||||
{"pid_code": "0105", "name": "coolant_temp", "value": 87.0, "unit": "C"},
|
||||
{"pid_code": "010C", "name": "rpm", "value": 680, "unit": "rpm"},
|
||||
{"pid_code": "010D", "name": "speed", "value": 0, "unit": "km/h"},
|
||||
{"pid_code": "010B", "name": "map", "value": 35, "unit": "kPa"},
|
||||
{"pid_code": "010F", "name": "iat", "value": 31, "unit": "C"},
|
||||
{"pid_code": "0104", "name": "engine_load", "value": 18.0, "unit": "%"},
|
||||
{"pid_code": "0106", "name": "stft_b1", "value": -8.6, "unit": "%"},
|
||||
{"pid_code": "0107", "name": "ltft_b1", "value": -12.5, "unit": "%"},
|
||||
{"pid_code": "0111", "name": "throttle_pos", "value": 12, "unit": "%"},
|
||||
],
|
||||
}
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are an auto diagnostic expert with 20 years of experience. "
|
||||
"Analyze OBD2 error codes and ECU parameters. Give a DEEP, DETAILED analysis.\n\n"
|
||||
"RULES:\n"
|
||||
"1. Do NOT limit yourself to a brief summary - give FULL analysis of each error and parameter.\n"
|
||||
"2. For each error: explain what it means, ALL possible causes (common to rare), "
|
||||
"which parameters confirm/refute each version.\n"
|
||||
"3. Analyze RELATIONSHIPS between errors and parameters.\n"
|
||||
"4. Give confidence percentages for EACH conclusion.\n"
|
||||
"5. If data is insufficient - list SPECIFIC PIDs to read additionally, explain why.\n"
|
||||
"6. Suggest action plan: what to check FIRST (most likely and cheapest), THEN what.\n"
|
||||
"7. For each action: HOW to check, WHAT to look for, normal/deviant values.\n"
|
||||
"8. Add 'If not helped' section - plan B for each item.\n"
|
||||
"9. NEVER give categorical commands 'replace part X' without 100% confidence. "
|
||||
"Write 'check X before replacing Y'.\n"
|
||||
"10. Write in Russian, accessible but TECHNICALLY PRECISE. Use tables where appropriate.\n\n"
|
||||
"OUTPUT FORMAT:\n"
|
||||
"## Diagnosis (detailed)\n...\n## Error analysis\n...\n## Parameter analysis\n...\n"
|
||||
"## Relationships\n...\n## Action plan (by priority)\n...\n"
|
||||
"## What data is missing\n...\n## Confidence\n..."
|
||||
)
|
||||
|
||||
|
||||
def build_user_prompt(data: dict) -> str:
|
||||
lines = [f"**VIN:** {data['vin']}", ""]
|
||||
|
||||
if data["dtc_codes"]:
|
||||
lines.append("**Error codes:**")
|
||||
for d in data["dtc_codes"]:
|
||||
lines.append(f"- {d['code']} ({d['status']}): {d.get('description', '')}")
|
||||
lines.append("")
|
||||
|
||||
if data["parameters"]:
|
||||
lines.append("**ECU parameters:**")
|
||||
for p in data["parameters"]:
|
||||
lines.append(f"- {p['name']}: {p['value']} {p['unit']}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("Conduct a full diagnosis.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def call_llm(api_key: str, base_url: str, model: str, messages: list[dict]) -> dict:
|
||||
start = time.time()
|
||||
resp = requests.post(
|
||||
f"{base_url.rstrip('/')}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 32000,
|
||||
},
|
||||
timeout=300,
|
||||
)
|
||||
elapsed = time.time() - start
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
usage = data.get("usage", {})
|
||||
return {
|
||||
"content": data["choices"][0]["message"]["content"],
|
||||
"elapsed": elapsed,
|
||||
"prompt_tokens": usage.get("prompt_tokens", "?"),
|
||||
"completion_tokens": usage.get("completion_tokens", "?"),
|
||||
"total_tokens": usage.get("total_tokens", "?"),
|
||||
}
|
||||
|
||||
|
||||
def print_answer(label: str, result: dict | None):
|
||||
if result is None:
|
||||
return
|
||||
print()
|
||||
border = "=" * 20
|
||||
print(f"{border} {label} {border}")
|
||||
print()
|
||||
content = result["content"]
|
||||
print(content)
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
config_path = Path(__file__).parent.parent / "config.yaml"
|
||||
with open(config_path) as f:
|
||||
cfg = yaml.safe_load(f)
|
||||
|
||||
aillm_key = cfg["llm"]["api_key"]
|
||||
aillm_model = cfg["llm"].get("model", "gpt-oss-120b")
|
||||
aillm_url = cfg["llm"].get("base_url", "https://api.aillm.ru/v1")
|
||||
|
||||
ds_key = os.environ.get("DEEPSEEK_API_KEY")
|
||||
if not ds_key:
|
||||
print("ERROR: DEEPSEEK_API_KEY not set")
|
||||
sys.exit(1)
|
||||
|
||||
ds_url = "https://api.deepseek.com/v1"
|
||||
|
||||
user_text = build_user_prompt(TEST_DATA)
|
||||
messages = [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_text},
|
||||
]
|
||||
|
||||
# Print the prompt being sent
|
||||
print("=" * 70)
|
||||
print("PROMPT BEING SENT")
|
||||
print("=" * 70)
|
||||
print()
|
||||
print("--- SYSTEM ---")
|
||||
print(SYSTEM_PROMPT[:300] + "...")
|
||||
print()
|
||||
print("--- USER ---")
|
||||
print(user_text)
|
||||
print()
|
||||
|
||||
models = [
|
||||
("DeepSeek V4 Flash", "deepseek-v4-flash", ds_key, ds_url),
|
||||
("DeepSeek V4 Pro", "deepseek-v4-pro", ds_key, ds_url),
|
||||
("Aillm.ru", aillm_model, aillm_key, aillm_url),
|
||||
]
|
||||
|
||||
results = {}
|
||||
for label, model, key, url in models:
|
||||
print("-" * 70)
|
||||
print(f" Calling {label} ({model})...", end=" ", flush=True)
|
||||
try:
|
||||
r = call_llm(key, url, model, messages)
|
||||
results[label] = r
|
||||
print(f"DONE {r['elapsed']:.1f}s "
|
||||
f"in={r['prompt_tokens']} out={r['completion_tokens']}")
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}")
|
||||
results[label] = None
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
for label in [m[0] for m in models]:
|
||||
print_answer(label, results[label])
|
||||
|
||||
# Summary table
|
||||
print("=" * 70)
|
||||
print("SUMMARY")
|
||||
print("=" * 70)
|
||||
print()
|
||||
print(f"{'Model':<25} {'Time':>8} {'Input':>8} {'Output':>8} {'Cost':>12}")
|
||||
print("-" * 65)
|
||||
for label, _, key, url in models:
|
||||
r = results[label]
|
||||
if r is None:
|
||||
print(f"{label:<25} {'FAIL':>8}")
|
||||
else:
|
||||
if "DeepSeek" in label:
|
||||
cost = (r["prompt_tokens"] / 1_000_000 * 0.14 +
|
||||
r["completion_tokens"] / 1_000_000 * 0.28)
|
||||
cost_str = f"${cost:.6f}"
|
||||
else:
|
||||
cost_str = "?"
|
||||
print(f"{label:<25} {r['elapsed']:>7.1f}s {r['prompt_tokens']:>8} "
|
||||
f"{r['completion_tokens']:>8} {cost_str:>12}")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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
-10
@@ -2,23 +2,26 @@
|
||||
|
||||
|
||||
def build_default_script() -> dict:
|
||||
"""Стандартный скрипт диагностики."""
|
||||
"""Минимальный скрипт для отладки: 1 PID → LLM."""
|
||||
return {
|
||||
"version": 1,
|
||||
"title": "Базовая диагностика",
|
||||
"title": "Экспресс-диагностика",
|
||||
"steps": [
|
||||
{"id": "pid_05", "cmd": "0105", "desc": "Температура ОЖ"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_full_script() -> dict:
|
||||
"""Полный скрипт диагностики."""
|
||||
return {
|
||||
"version": 1,
|
||||
"title": "Полная диагностика",
|
||||
"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_0C", "cmd": "010C", "desc": "Обороты"},
|
||||
{"id": "pid_0D", "cmd": "010D", "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_06", "cmd": "0106", "desc": "STFT"},
|
||||
{"id": "pid_07", "cmd": "0107", "desc": "LTFT"},
|
||||
|
||||
+10
-4
@@ -5,7 +5,7 @@ POST /api/v1/session/upload — приём батча, LLM-анализ, воз
|
||||
"""
|
||||
|
||||
import logging
|
||||
from web.script_builder import build_default_script
|
||||
from web.script_builder import build_default_script, build_full_script
|
||||
from web.script_parser import parse_batch, format_no_llm
|
||||
|
||||
logger = logging.getLogger("elmer.script")
|
||||
@@ -54,8 +54,10 @@ def register(app):
|
||||
|
||||
@app.route("/api/v1/script", methods=["GET"])
|
||||
def get_script():
|
||||
from flask import jsonify
|
||||
return jsonify(build_default_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():
|
||||
@@ -86,7 +88,11 @@ def register(app):
|
||||
model=cfg["llm"].get("model", "gpt-oss-120b"),
|
||||
base_url=cfg["llm"].get("base_url", "https://api.aillm.ru/v1"),
|
||||
)
|
||||
answer = diagnoser.diagnose(SYSTEM_PROMPT, _build_diagnosis_prompt(parsed))
|
||||
try:
|
||||
answer = diagnoser.diagnose(SYSTEM_PROMPT, _build_diagnosis_prompt(parsed))
|
||||
except Exception as e:
|
||||
logger.warning(f"LLM failed: {e}")
|
||||
answer = format_no_llm(parsed) + f"\n\n(LLM недоступен: {e})"
|
||||
|
||||
return jsonify({
|
||||
"diagnosis": answer,
|
||||
|
||||
@@ -11,18 +11,18 @@
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🔧 Elmer</h1>
|
||||
<p class="subtitle">Диагностика через ELM327 + ИИ</p>
|
||||
<p class="subtitle">Диагностика авто через ELM327 + ИИ</p>
|
||||
<p class="subtitle" style="font-size:12px;opacity:0.7;">v0.16.0-dev — 28 мая 2026</p>
|
||||
|
||||
<div class="card" style="text-align:center;margin-bottom:20px;">
|
||||
<p style="margin:0 0 10px 0;">📱 Скачай APK прямо на телефон:</p>
|
||||
<a href="/elmer.apk" style="color:#ff6b35;font-size:18px;font-weight:bold;text-decoration:none;">
|
||||
<p style="margin:0 0 10px 0;">📱 Скачай приложение на телефон:</p>
|
||||
<a href="/static/app-debug.apk" style="color:#ff6b35;font-size:18px;font-weight:bold;text-decoration:none;">
|
||||
⬇️ Скачать Elmer APK
|
||||
</a>
|
||||
<p style="font-size:11px;opacity:0.6;margin:4px 0 0 0;">v0.16.0-dev • нажмите чтобы скачать</p>
|
||||
</div>
|
||||
|
||||
<button id="btn-diagnose" onclick="runDiagnose()">
|
||||
🚗 Подключить и диагностировать
|
||||
</button>
|
||||
<!-- Кнопка десктоп-диагностики скрыта — только для разработчика с прямым ELM327 -->
|
||||
|
||||
<div id="status"></div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user