4 changed files with 471 additions and 7 deletions
+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()
+7 -7
View File
@@ -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
+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)