docs: эталонный анализ 5 проектов — ELM327/BT паттерны
This commit is contained in:
@@ -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<value> // установить таймаут
|
||||||
|
```
|
||||||
|
|
||||||
|
**Обработка ошибок — детально:**
|
||||||
|
```
|
||||||
|
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<InputStream, OutputStream> {
|
||||||
|
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 для ретраев
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user