From bb510dad227c2f60b16abe149877e0a6ba3e2454 Mon Sep 17 00:00:00 2001 From: Repinoid Date: Wed, 27 May 2026 10:03:28 +0300 Subject: [PATCH] =?UTF-8?q?docs:=20=D1=8D=D1=82=D0=B0=D0=BB=D0=BE=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=B0=D0=BD=D0=B0=D0=BB=D0=B8=D0=B7=205?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=BE=D0=B2=20=E2=80=94?= =?UTF-8?q?=20ELM327/BT=20=D0=BF=D0=B0=D1=82=D1=82=D0=B5=D1=80=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/elm-reference.md | 484 ++++++++++++++++++++++++++++++++++++++++ elmer/elm_proto.py | 251 +++++++++++++++++++++ tools/mock_elm327_v2.py | 166 ++++++++++++++ 3 files changed, 901 insertions(+) create mode 100644 doc/elm-reference.md create mode 100644 elmer/elm_proto.py create mode 100644 tools/mock_elm327_v2.py diff --git a/doc/elm-reference.md b/doc/elm-reference.md new file mode 100644 index 0000000..f3763a1 --- /dev/null +++ b/doc/elm-reference.md @@ -0,0 +1,484 @@ +# ELM327 Communication Patterns — анализ 5 отлаженных проектов + +> **Цель:** понять как РЕАЛЬНО работают проекты с ELM327, выбрать лучшие паттерны для Elmer. +> **Дата:** 2026-05-27 +> **Источники:** исходный код 5 проектов (Java, Kotlin, Python, C) + +--- + +## Сводная таблица + +| | OBD-Droid | OBD2AI | Automotive-AI | obd2-mcp-server | Vehicle-Diag-Assist | +|---|---|---|---|---|---| +| **Язык** | Java | Kotlin | Python | Python | C (W600) + Python | +| **Платформа** | Android | Android | Desktop | Desktop/Claude MCP | Embedded (MCU) | +| **LLM** | ChatGPT | gpt-5-mini | GPT-3.5/4 | Claude (MCP) | DeepSeek/Claude | +| **Чтение** | Побайтово, 1мс | kotlin-obd lib | readline() | Побайтово (BLE/SPP) | UART, семафор | +| **UUID** | 00001101... | 00001101... | N/A (pyserial) | BLE + serial | N/A (UART) | +| **Baud** | — | — | config.py | 38400 (auto-retry) | 38400 8N1 | +| **Timeout** | Адаптивный 5с | 400мс fix | 1с | 20с connect / 2с config | 2000мс | +| **Инит** | ATD→ATE0→ATL0→ATS0→ATH1→... | ATZ→ATE0→ATL0→ATSP0 | N/A | ATZ→ATE0→ATL0→ATS0→ATH1→ATCAF1→ATAT1→ATST64→ATSP0 | ATZ→... | +| **Ретраи** | requeue + SETPROT | 3 strikes → stop | Нет | [2,5,10]с backoff | Нет | +| **Simulator** | Встроенный demo | Нет | ELM327-emulator | Mock mode (Ford) | Gradio + HW sim | +| **DTC база** | Встроенная | Нет | Нет | 1937 Ford + generic | Нет | + +--- + +## 1. OBD-Droid (Wal33D) — Java Android ⭐ ЛУЧШИЙ + +### 1.1. StreamHandler.java — побайтовый I/O + +```java +// ЧТЕНИЕ: побайтово, сон 1мс между проверками +public void run() { + while (true) { + if (in.available() > 0) { + if ((chr = in.read()) > 0) { + processRxChar(chr); + } else break; + } else { + Thread.sleep(1); // ← 1 миллисекунда! + } + } +} + +// ОБРАБОТКА СИМВОЛОВ: '>' = такой же разделитель как CR/LF! +private void processRxChar(int chr) { + switch (chr) { + case 32: break; // пробел — игнорируем + case '>': // промпт ELM + message += (char) chr; + // fall through — НЕ отдельный случай! + case 10: // LF + case 13: // CR + messageHandler.handleTelegram(message.toCharArray()); + message = ""; + break; + default: + message += (char) chr; + } +} + +// ОТПРАВКА: BufferedWriter с буфером 1 байт = flush на каждом байте +out = new BufferedWriter(new OutputStreamWriter(outStream), 1); + +public int writeTelegram(final char[] buffer, int type, Object id) { + new Thread(() -> { + String msg = new String(buffer) + "\r"; // ELM ждёт CR + out.write(msg.toCharArray()); + out.flush(); // немедленный flush из-за буфера 1 байт + }).start(); + return buffer.length; +} +``` + +**Ключевые выводы:** +- `>` — НЕ спецсигнал «можно слать дальше». Это просто разделитель строк, как CR/LF. +- Буфер 1 байт на запись = каждый байт сразу уходит в порт. +- Отправка в отдельном потоке (не блокирует чтение). + +### 1.2. ElmProt.java — стейт-машина протокола + +**RSP_ID — все возможные ответы ELM327:** +```java +enum RSP_ID { + PROMPT(">"), OK("OK"), MODEL("ELM"), + NODATA("NODATA"), SEARCH("SEARCHING"), + ERROR("ERROR"), NOCONN("UNABLE"), NOCONN2("NABLETO"), + CANERROR("CANERROR"), BUSBUSY("BUSBUSY"), + BUSERROR("BUSERROR"), BUSINIERR("BUSINIT:ERR"), + BUSINIERR2("BUSINIT:BUS"), BUSINIERR3("BUSINIT:...ERR"), + FBERROR("FBERROR"), DATAERROR("DATAERROR"), + BUFFERFULL("BUFFERFULL"), STOPPED("STOPPED"), + RXERROR("<"), QMARK("?"), + UNKNOWN(""); +} +``` + +**STAT — состояния соединения:** +```java +UNDEFINED → INITIALIZING → INITIALIZED → ECU_DETECT → ECU_DETECTED +→ ECU_SELECTED → CONNECTING → CONNECTED +// Ошибки: +NODATA, STOPPED, DISCONNECTED, BUSERROR, DATAERROR, RXERROR, ERROR +``` + +**Инициализация (после ATZ → MODEL):** +``` +ATD // defaults +ATE0 // echo off +ATL0 // line feeds off +ATS0 // spaces off +ATH1 // headers ON (для обнаружения ЭБУ) +ATDP // узнать протокол +ATSPA1 // протокол AUTO +ATAT1 // adaptive timing ON +ATST // установить таймаут +``` + +**Обработка ошибок — детально:** +``` +SEARCHING → статус CONNECTING (не ошибка!) +NODATA → увеличить OBD timeout + переустановить протокол +ERROR → WARMSTART (ATWS) +DATAERROR → WARMSTART +RXERROR → WARMSTART +BUFFERFULL→ WARMSTART +BUS ERROR → DISCONNECTED → переустановить протокол + ретрай последней команды +UNABLE → DISCONNECTED → переустановить протокол + ретрай +``` + +**Мульти-фрейм ISO-TP:** +``` +Формат: "0:4100..." — первая строка с длиной + "1:4100..." — продолжение +charsExpected = байт_длины * 2 (каждый байт = 2 hex символа) +``` + +### 1.3. BluetoothCommService.java — BT SPP + +```java +final UUID SPP_UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB"); + +// Первая попытка: стандартный RFCOMM +tmp = device.createRfcommSocketToServiceRecord(SPP_UUID); // secure +// или +tmp = device.createInsecureRfcommSocketToServiceRecord(SPP_UUID); // insecure + +// FALLBACK: reflection-based RFCOMM channel 1 (для глючных адаптеров) +Method m = clazz.getMethod("createRfcommSocket", paramTypes); +Object[] params = new Object[]{1}; // channel 1 +sockFallback = (BluetoothSocket) m.invoke(device, params); +``` + +**Ключевой вывод:** Есть fallback на reflection-based RFCOMM channel 1 — для дешёвых китайских клонов! + +--- + +## 2. OBD2AI (catsmoker) — Kotlin Android + +### 2.1. BluetoothHelper + +```kotlin +val sppUuid: UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB") + +suspend fun connectToDevice(deviceAddress: String): Pair { + val device = bluetoothAdapter?.getRemoteDevice(deviceAddress) + bluetoothSocket = device.createRfcommSocketToServiceRecord(sppUuid).apply { + bluetoothAdapter.cancelDiscovery() + connect() + } + return Pair(socket.inputStream, socket.outputStream) +} +``` + +### 2.2. ObdHelper — инициализация и команды + +```kotlin +// Инициализация: фиксированные задержки, БЕЗ ожидания '>' +suspend fun initializeObd() = withContext(Dispatchers.IO) { + suspend fun sendRawCommand(command: String) { + out.write((command + "\r").toByteArray()) + out.flush() + delay(400) // ← 400мс после КАЖДОЙ команды + } + + sendRawCommand("ATZ") // сброс + sendRawCommand("ATE0") // эхо выкл + sendRawCommand("ATL0") // line feeds выкл + sendRawCommand("ATSP0") // авто-протокол + + delay(1000) // дополнительная пауза после инита + // Очистка буфера + if (`in`.available() > 0) { + val buffer = ByteArray(`in`.available()) + `in`.read(buffer) + } +} +``` + +**Используется библиотека `kotlin-obd` (eltonvs):** +```kotlin +// Для стандартных команд — библиотека +obdConnection = ObdDeviceConnection(inputStream, outputStream) +val result = connection.run(TroubleCodesCommand()) + +// Для нестандартных — ручной парсинг +class MyRPMCommand : ObdCommand() { + override val pid = "0C" + override val handler = { it: ObdRawResponse -> + val rawValue = it.processedValue + val identifier = "410C" + val aHex = rawValue.substring(index + 4, index + 6) + val bHex = rawValue.substring(index + 6, index + 8) + ((a * 256) + b) / 4 // формула RPM + } +} +``` + +### 2.3. Live Data Monitoring + +```kotlin +suspend fun startLiveDataMonitoring() = withContext(Dispatchers.IO) { + var errorCount = 0 + while (isMonitoring.get()) { + try { + val speed = runCommand(MySpeedCommand()) + val rpm = runCommand(MyRPMCommand()) + val temp = runCommand(MyCoolantTempCommand()) + errorCount = 0 + delay(800) // 800мс между циклами + } catch (e: Exception) { + errorCount++ + if (errorCount >= 3) break // 3 ошибки подряд = стоп + delay(1000) + } + } +} +``` + +--- + +## 3. Automotive-AI (Eloquent-Algorithmics) — Python Desktop + +### 3.1. ELM327 через pyserial + +```python +# config.py +SERIAL_PORT = "/dev/ttyUSB0" # или COM3 на Windows +BAUD_RATE = 38400 + +# Подключение +ser = serial.Serial(port=SERIAL_PORT, baudrate=BAUD_RATE, timeout=1) + +# Отправка команды +def send_command(ser, command): + ser.write((command + "\r\n").encode()) # CRLF терминатор + response = ser.readline().decode().strip() + response = response.replace("\r", "").replace(">", "") + return response +``` + +**Ключевые отличия от OBD-Droid:** +- `readline()` вместо побайтового чтения — ПРОЩЕ, но менее надёжно +- `\r\n` вместо просто `\r` +- `timeout=1` — ждёт 1 секунду на readline +- Убирает `>` из ответа (не использует как разделитель) + +### 3.2. Парсинг ответов + +```python +# RPM: 010C → 41 0C HH LL +if cmd == "010C": + value = (int(response.split()[2], 16) * 256 + + int(response.split()[3], 16)) / 4 + +# Coolant: 0105 → 41 05 XX +if cmd == "0105": + value = int(response.split()[2], 16) - 40 # -40 offset + +# VIN: 0902 +vin_response = parse_vin_response(response) +vehicle_data = decode_vin(vin_response) +``` + +--- + +## 4. obd2-mcp-server (petrpatek) — Python Claude MCP ⭐ САМЫЙ СВЕЖИЙ + +### 4.1. BLE + Serial подключение + +``` +Поддерживает: +- BLE (vLinker FD, STN чип) — асинхронный, asyncio.Lock +- Serial (classic Bluetooth SPP) — синхронный, pyserial + +Baud rate auto-retry: [500k, 115.2k, 38.4k, 9.6k] +BLE: 30-секундный keepalive heartbeat (без него адаптер засыпает через ~120с) +``` + +### 4.2. Инициализация (САМАЯ ПОЛНАЯ) + +```python +ATZ # сброс +ATE0 # эхо выкл +ATL0 # line feeds выкл +ATS0 # пробелы выкл +ATH1 # заголовки CAN ВКЛ (для обнаружения ЭБУ) +ATCAF1 # CAN auto-formatting ON +ATAT1 # adaptive timing ON +ATST64 # timeout = 64*4ms = 256ms +ATSP0 # авто-протокол + +# Для STN адаптеров (OBDlink): +ATPP 0E SV 00 # отключить сон +ATPP 0E ON # включить +``` + +### 4.3. Ретраи и таймауты + +```python +MAX_RETRIES = 3 +RETRY_BACKOFF = [2, 5, 10] # секунды +CONNECT_TIMEOUT = 20 # секунд +PROTOCOL_TIMEOUT = 12 # секунд +CONFIG_TIMEOUT = 2 # секунды +``` + +### 4.4. Очистка ответа + +```python +def _clean_elm_response(raw: str) -> str: + # Убирает: промпт ">", эхо команд, "SEARCHING...", пустые строки + ... +``` + +### 4.5. DTC база данных + +``` +- 1937 Ford-специфичных кодов +- Generic OBD-II коды (P, B, C, U) +- Ленивая загрузка по бренду +- Скрапинг с troublecodes.net +``` + +--- + +## 5. Vehicle-Diagnostic-Assistant (castlebbs) — Embedded C + Python + +### 5.1. Аппаратная архитектура + +``` +W600 MCU ←UART1 38400 8N1→ ELM327 чип → CAN → Авто + ↕ HTTP/MCP +LangChain Agent (Python) → DeepSeek / Claude +``` + +### 5.2. ELM327 Driver (C) + +```c +// elm327.c +int elm327_send_command(const char* cmd, char* resp, int len, int timeout) { + // Пишет команду + \r в UART1 + // Ждёт ответ через FreeRTOS semaphore (прерывание по приёму) + // Таймаут по умолчанию: 2000мс + // Макс. длина ответа: 512 байт +} + +// Hybrid simulation mode: +// AT команды → реальный ELM327 +// OBD команды → симуляция (если включена) +``` + +### 5.3. Поддерживаемые режимы OBD + +``` +Mode 01: live data (30+ PID) +Mode 03: stored DTC (формат 43 XX XX XX XX) +Mode 04: clear DTC (44) +Mode 07: pending DTC (47) +Mode 09: vehicle info (VIN, calibration ID) +``` + +### 5.4. PID формулы (Mode 01) + +| PID | Формула | Пример | +|-----|---------|--------| +| 0C (RPM) | `(A*256+B)/4` | 0x1AF8 → 1726 | +| 0D (Speed) | `A` (km/h) | 0x00 → 0 | +| 05 (ECT) | `A-40` (°C) | 0x5A → 50 | +| 04 (Load) | `(A*100)/255` (%) | 0x40 → 25.1 | +| 10 (MAF) | `((A*256)+B)/100` (g/s) | — | +| 2F (Fuel) | `(A*100)/255` (%) | — | + +### 5.5. Safe formula evaluation + +```python +def calculate_obd_value(raw_response, formula): + # LLM вызывает этот tool для расчёта значений + # safe_eval() — ограниченный eval (только +-*/ и переменные A,B,C,D) + hex_bytes = raw_response.replace("41 XX ", "").split() + A, B, C, D = [int(x, 16) for x in hex_bytes] + return safe_eval(formula, {"A": A, "B": B, "C": C, "D": D}) +``` + +--- + +## СРАВНИТЕЛЬНЫЙ АНАЛИЗ: Что взять для Elmer + +### Инициализация ELM327 + +| Проект | Последовательность | Задержки | +|--------|-------------------|----------| +| OBD-Droid | ATD→ATE0→ATL0→ATS0→ATH1→ATDP→ATSPA1→ATAT1→ATST | Стейт-машина, нет фикс. задержек | +| OBD2AI | ATZ→ATE0→ATL0→ATSP0 | 400мс после каждой | +| obd2-mcp | ATZ→ATE0→ATL0→ATS0→ATH1→ATCAF1→ATAT1→ATST64→ATSP0 | async, по ответам | +| Automotive-AI | Нет явной инициализации | — | + +**Рекомендация для Elmer:** взять последовательность obd2-mcp-server (самая полная) + задержки OBD2AI (400мс) + ATH0 вместо ATH1 (для чистых ответов без CAN-заголовков). + +### Чтение ответов + +| Проект | Метод | Плюсы | Минусы | +|--------|-------|-------|--------| +| OBD-Droid | Побайтово, 1мс sleep | Макс. контроль | Сложный код | +| OBD2AI | kotlin-obd lib | Готовое решение | Зависимость от библиотеки | +| Automotive-AI | `ser.readline()` | Простой код | Менее надёжно | + +**Рекомендация для Elmer:** для Android — побайтовое чтение как у OBD-Droid (уже есть в TestService). Для Python-мока/сервера — `readline()` достаточно для тестов. + +### Обработка ошибок + +| Ошибка | OBD-Droid | OBD2AI | obd2-mcp | +|--------|-----------|--------|----------| +| SEARCHING | Статус CONNECTING | — | Пропустить, ждать | +| NO DATA | Увеличить timeout | — | Вернуть пусто | +| BUS ERROR | DISCONNECTED + retry | — | — | +| UNABLE | DISCONNECTED + retry | — | — | +| ERROR | WARMSTART (ATWS) | — | — | +| RX ERROR | WARMSTART | 3 strikes → stop | — | + +**Рекомендация для Elmer:** SEARCHING = ждать + увеличить таймаут. NO DATA = пропустить PID. BUS ERROR/UNABLE = одна попытка reconnect + retry. ERROR = WARMSTART. + +### Тайминги + +| Проект | Между командами | Инит | Таймаут ответа | +|--------|-----------------|------|----------------| +| OBD-Droid | Нет (стейт-машина) | Стейт-машина | 5000мс адаптивный | +| OBD2AI | 400мс fix | 1000мс после всех | ? (внутри lib) | +| Automotive-AI | Нет | Нет | 1000мс (readline) | +| obd2-mcp | По ответам | По ответам | 2000-20000мс | +| castlebbs | По семафору | — | 2000мс | + +**Рекомендация для Elmer:** 400мс между командами (как OBD2AI) + адаптивный таймаут от 2000мс с возможностью увеличения (как OBD-Droid). + +### BT подключение (Android) + +| Проект | Метод | Fallback | +|--------|-------|----------| +| OBD-Droid | `createRfcommSocketToServiceRecord` secure + insecure | Reflection RFCOMM channel 1 | +| OBD2AI | `createRfcommSocketToServiceRecord` | Нет | + +**Рекомендация для Elmer:** взять fallback на reflection channel 1 из OBD-Droid — критично для дешёвых клонов. + +--- + +## ИТОГ: Что реализовать в elmer-android + +### Приоритет 1 (обязательно) +- [ ] Побайтовое чтение с паузой 1мс (StreamHandler.java) +- [ ] `>` = разделитель строк, НЕ спецсигнал +- [ ] Fallback RFCOMM channel 1 (BluetoothCommService.java) +- [ ] Фиксированные задержки 400мс между командами (OBD2AI) +- [ ] Очистка буфера после инициализации + +### Приоритет 2 (важно) +- [ ] Обработка SEARCHING, NO DATA, BUS ERROR +- [ ] 3-strike retry для live monitoring +- [ ] Адаптивный таймаут (базовый 5000мс) + +### Приоритет 3 (для production) +- [ ] WARMSTART при ERROR/DATAERROR +- [ ] Мульти-фрейм ISO-TP +- [ ] DTC база (можно с obd2-mcp-server) +- [ ] Экспоненциальный backoff для ретраев diff --git a/elmer/elm_proto.py b/elmer/elm_proto.py new file mode 100644 index 0000000..9c0c0e8 --- /dev/null +++ b/elmer/elm_proto.py @@ -0,0 +1,251 @@ +""" +ELM327 Protocol Layer — низкоуровневый обмен с ELM327. + +Паттерны списаны с OBD-Droid (Wal33D): +- Побайтовое чтение с паузой 1 мс (не sleep(250)!) +- Детекция промпта '>' — только после него слать следующую команду +- Адаптивный таймаут +- Обработка: SEARCHING, NO DATA, UNABLE TO CONNECT, CAN ERROR, ERROR +- Мульти-фрейм ISO-TP (строки с префиксом ':') +- flush() после каждой команды + +Использование: + proto = ELMProtocol(port="/dev/rfcomm0") + proto.init() # ATZ → ATE0 → ATL0 → ATSP0 → ATH0 + resp = proto.send_and_read("03") # отправил, дождался '>', вернул ответ + proto.close() +""" + +import logging +import time +from enum import Enum, auto + +logger = logging.getLogger("elmer.proto") + + +class ELMError(Exception): + """Ошибка протокола ELM327.""" + + +class ELMTimeout(ELMError): + """Таймаут ожидания ответа от ELM327.""" + + +class ELMBusError(ELMError): + """Ошибка на шине CAN (UNABLE TO CONNECT, CAN ERROR и т.п.).""" + + +class ELMNoData(ELMError): + """NO DATA — запрошенный PID/режим не поддерживается.""" + + +class ELMSearching(ELMError): + """SEARCHING — ELM327 ищет протокол.""" + + +# ── Низкоуровневый протокол ────────────────────────────────── + +class ELMProtocol: + """Побайтовый обмен с ELM327 по паттернам OBD-Droid. + + Ключевые принципы: + 1. Ждём '>' перед отправкой следующей команды + 2. Читаем побайтово, пауза 1мс между проверками + 3. Обрабатываем SEARCHING / NO DATA / UNABLE / CAN ERROR + """ + + SPP_UUID = "00001101-0000-1000-8000-00805F9B34FB" + + # Таймауты (мс) + DEFAULT_TIMEOUT = 5000 # базовый для обычных команд + LONG_TIMEOUT = 10000 # для ATZ, ATSP0 (инициализация) + SEARCH_EXTRA = 5000 # дополнительное время при SEARCHING + + # Максимум ретраев + MAX_RETRIES = 3 + + def __init__(self, port: str, baudrate: int = 38400): + """port — путь к последовательному порту (или BT RFCOMM).""" + self.port = port + self.baudrate = baudrate + self._ser = None + self._timeout_ms = self.DEFAULT_TIMEOUT + + # ── подключение ─────────────────────────────────────── + + 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, + ) + logger.info(f"ELM: connected to {self.port} @ {self.baudrate}") + + def close(self): + """Закрывает порт.""" + if self._ser and self._ser.is_open: + self._ser.close() + logger.info("ELM: disconnected") + + def is_connected(self) -> bool: + return self._ser is not None and self._ser.is_open + + # ── инициализация ───────────────────────────────────── + + def init(self) -> bool: + """Полная инициализация ELM327. Возвращает True если ОК.""" + + # Фаза 1 — сброс (ATZ, долгий таймаут) + resp = self.send_and_read("ATZ", timeout_ms=self.LONG_TIMEOUT) + if "ELM" not in resp: + logger.error("ELM: ATZ failed, no ELM signature") + return False + logger.info(f"ELM: {resp.split(chr(10))[0].strip()}") + + # Фаза 2 — настройка + self.send_and_read("ATE0") # эхо выкл + self.send_and_read("ATL0") # перевод строки выкл + self.send_and_read("ATS0") # пробелы выкл (чище ответы) + + self.send_and_read("ATSP0", timeout_ms=self.LONG_TIMEOUT) # авто-протокол + self.send_and_read("ATH0") # заголовки CAN выкл + + logger.info("ELM: initialized") + return True + + # ── отправка + чтение ───────────────────────────────── + + def send_and_read(self, cmd: str, timeout_ms: int | None = None) -> str: + """Отправляет команду, ждёт '>', возвращает ответ БЕЗ '>'. + + Поднимает: + - ELMTimeout — если не дождались '>' за timeout_ms + - ELMNoData — если ответ содержит NO DATA + - ELMBusError — если UNABLE TO CONNECT / CAN ERROR + """ + timeout = timeout_ms if timeout_ms is not None else self._timeout_ms + + for attempt in range(1, self.MAX_RETRIES + 1): + self._write(cmd) + try: + return self._read_until_prompt(timeout) + except ELMSearching: + # SEARCHING — даём ещё время + logger.debug(f"ELM: SEARCHING (attempt {attempt}), waiting...") + timeout += self.SEARCH_EXTRA + continue + except ELMBusError: + # BUS BUSY / UNABLE — ретрай через 500мс + if attempt < self.MAX_RETRIES: + logger.warning(f"ELM: bus error, retry {attempt}/{self.MAX_RETRIES}") + time.sleep(0.5) + continue + raise + except ELMTimeout: + if attempt < self.MAX_RETRIES: + logger.warning(f"ELM: timeout, retry {attempt}/{self.MAX_RETRIES}") + continue + raise + + raise ELMTimeout(f"ELM: no response after {self.MAX_RETRIES} attempts") + + # ── внутренние ──────────────────────────────────────── + + def _write(self, cmd: str): + """Пишет команду + CR в порт, flush.""" + if not self._ser or not self._ser.is_open: + raise ELMError("ELM: not connected") + + self._ser.reset_input_buffer() + raw = (cmd + "\r").encode() + self._ser.write(raw) + self._ser.flush() + logger.debug(f"ELM → {cmd}") + + def _read_until_prompt(self, timeout_ms: int) -> str: + """Читает побайтово пока не получит '>' (или таймаут). + + Паттерн OBD-Droid: while True: if in.available() > 0: read() + else: sleep(1ms) + """ + buf: list[str] = [] + line: list[str] = [] + deadline = time.monotonic() + timeout_ms / 1000.0 + + while time.monotonic() < deadline: + # Проверяем есть ли байты + if self._ser.in_waiting > 0: + ch = self._ser.read(1).decode("utf-8", errors="ignore") + if not ch: + continue + + cp = ord(ch) + + # ── обработка символов ── + if cp == ord(">"): + # Промпт — конец ответа + self._flush_line(line, buf) + result = "\n".join(buf).strip() + self._check_response(result) + logger.debug(f"ELM ← {result[:80]}") + return result + + elif cp == 13: # CR + self._flush_line(line, buf) + + elif cp == 10: # LF + pass # игнорируем (у нас ATS0, должны быть пробелы) + + elif cp == 32: # space + pass # игнорируем пробелы (у нас ATS0) + + else: + line.append(ch) + + else: + time.sleep(0.001) # 1 мс — как в OBD-Droid + + # Таймаут — собрали что есть + self._flush_line(line, buf) + result = "\n".join(buf).strip() + if not result: + raise ELMTimeout(f"ELM: no '>' prompt within {timeout_ms}ms") + logger.warning(f"ELM: partial response (no prompt): {result[:80]}") + return result + + @staticmethod + def _flush_line(line: list[str], buf: list[str]): + """Добавляет накопленную строку в буфер.""" + if line: + buf.append("".join(line)) + line.clear() + + @staticmethod + def _check_response(raw: str): + """Проверяет ответ на ошибки протокола.""" + upper = raw.upper() + + if "SEARCHING" in upper: + raise ELMSearching() + if "NO DATA" in upper or "NODATA" in upper: + raise ELMNoData(raw) + if "UNABLE" in upper or "BUS BUSY" in upper or "BUS ERROR" in upper: + raise ELMBusError(raw) + if upper.startswith("CAN ERROR"): + raise ELMBusError(raw) + if "STOPPED" in upper: + raise ELMBusError(raw) + + +# ── Удобная фабрика ──────────────────────────────────────── + +def create_protocol(port: str) -> ELMProtocol: + """Создаёт и подключает ELMProtocol.""" + proto = ELMProtocol(port) + proto.connect() + return proto diff --git a/tools/mock_elm327_v2.py b/tools/mock_elm327_v2.py new file mode 100644 index 0000000..3a8a405 --- /dev/null +++ b/tools/mock_elm327_v2.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""mock_elm327_v2.py — эмулятор ELM327 с '>' промптами и симуляцией ошибок. + +Соответствует паттернам OBD-Droid: +- Каждый ответ заканчивается '>' (промпт) +- SEARCHING... перед первым ответом после ATSP0 +- Случайные ошибки: BUS BUSY, UNABLE TO CONNECT +- Побайтовая отправка с реалистичными задержками +- Многострочные ответы для DTC (как мульти-ЭБУ) + +Запуск: python tools/mock_elm327_v2.py +""" + +import argparse +import random +import socketserver +import time + + +FAKE_VIN = "WVWZZZ1KZAW123456" + +# Многострочные DTC — имитация нескольких ЭБУ +FAKE_DTC_MULTI = [ + "43011300000000", # P0301 (ЭБУ 1) + "43013300000000", # P0303 (ЭБУ 1) + "47500047000000", # P0047 (ЭБУ 2, pending) +] + +FAKE_NO_DTC = "43000000000000" + +FAKE_PIDS = { + "05": "4105 5A", + "0C": "410C 1A F8", + "0D": "410D 00", + "11": "4111 80", + "0B": "410B 64", + "0F": "410F 46", + "1F": "411F 02 58", + "04": "4104 40", + "06": "4106 80", + "07": "4107 80", +} + + +class ElmHandler(socketserver.StreamRequestHandler): + + def handle(self): + print(f"\n🔌 {self.client_address}") + buf = b"" + while True: + try: + ch = self.rfile.read(1) + if not ch: + break + if ch in (b"\n", b"\r"): + if buf: + cmd = buf.decode("utf-8", errors="ignore").strip().upper() + buf = b"" + if cmd: + self._respond(cmd) + else: + buf += ch + except (ConnectionResetError, BrokenPipeError, OSError): + break + + print(f"🔌 Откл: {self.client_address}") + + def _respond(self, cmd: str): + """Обрабатывает команду и отправляет ответ побайтово.""" + print(f" 📥 {cmd}") + + # Случайная симуляция ошибок (3%) + if random.random() < 0.03 and cmd not in ("ATZ", "ATE0", "ATL0", "ATH0"): + error = random.choice([ + b"BUS BUSY\r\n>", + b"BUS BUSY\r\n>", + b"UNABLE TO CONNECT\r\n>", + ]) + self._send_bytes(error) + print(f" 📤 (ERROR) {error.decode().strip()}") + return + + # ── AT команды ─────────────────────── + if cmd == "ATZ": + self._sleep(2.5) + self._send_bytes(b"\r\nELM327 v1.5\r\n>") + + elif cmd == "ATSP0": + self._send_bytes(b"SEARCHING...\r\n") + self._sleep(1.8) + self._send_bytes(b"AUTO, ISO 15765-4 (CAN 11/500)\r\n>") + + elif cmd in ("ATE0", "ATL0", "ATS0", "ATH0", "ATH1"): + self._send_bytes(b"OK\r\n>") + + elif cmd == "ATI": + self._send_bytes(b"ELM327 v1.5\r\n>") + + # ── OBD запросы ───────────────────── + elif cmd == "0902": + self._sleep(0.5) + resp = self._vin_bytes() + self._send_bytes(resp) + + elif cmd == "03": + self._sleep(0.3) + # Многострочный ответ — как от нескольких ЭБУ + for line in FAKE_DTC_MULTI: + self._send_bytes(line.encode() + b"\r\n") + time.sleep(0.05) + self._send_bytes(b">") + + elif cmd == "07": + self._sleep(0.3) + self._send_bytes(FAKE_NO_DTC.encode() + b"\r\n>") + + elif cmd.startswith("01"): + pid = cmd[2:4] + self._sleep(0.2) + if pid in FAKE_PIDS: + self._send_bytes(FAKE_PIDS[pid].encode() + b"\r\n>") + else: + self._send_bytes(b"NO DATA\r\n>") + + else: + self._send_bytes(b"?\r\n>") + + def _send_bytes(self, data: bytes): + """Отправляет данные побайтово с микро-задержкой (как реальный ELM).""" + for byte in data: + self.wfile.write(bytes([byte])) + self.wfile.flush() + time.sleep(0.0005) # 0.5 мс между байтами + + def _sleep(self, seconds: float): + """Пауза ±30%, как у реального железа.""" + time.sleep(seconds * random.uniform(0.7, 1.3)) + + def _vin_bytes(self) -> bytes: + vin_hex = "".join(f"{ord(c):02X}" for c in FAKE_VIN) + return f"014\r\n0:49 02 01 {vin_hex}\r\n>".encode() + + +def main(): + parser = argparse.ArgumentParser(description="Mock ELM327 v2 (OBD-Droid совместимый)") + parser.add_argument("--port", type=int, default=35000) + parser.add_argument("--host", default="0.0.0.0") + args = parser.parse_args() + + class ReuseTCPServer(socketserver.ThreadingTCPServer): + allow_reuse_address = True + + server = ReuseTCPServer((args.host, args.port), ElmHandler) + print(f"🚗 Mock ELM327 v2 на {args.host}:{args.port}") + print(f" Промпт '>' после каждого ответа") + print(f" Случайные ошибки: 3%") + print(f" Ctrl+C = стоп") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\n⏹️ Стоп") + server.shutdown() + + +if __name__ == "__main__": + main()