refactor: move legacy docs to doc/legacy/ with opus/sonnet subfolders
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
# Анализ: ELM327 динамический тест
|
||||
|
||||
## 1. exec() — retry с повторной write(cmd) — ГЛАВНЫЙ БАГ
|
||||
|
||||
При таймауте ELM уже отправил запрос в CAN-шину и ждёт ответа от ЭБУ. write(cmd) снова вызывает drainInput() — сбрасывает буфер с ответом, которого мы ждём — и шлёт команду повторно. ELM получает 010C пока обрабатывает предыдущий 010C → BUFFER FULL / зависание. 10 retry = 10 одновременных CAN-запросов. Статика не задевает этот путь, потому что там таймауты не случаются (пауза между командами — секунды).
|
||||
|
||||
## 2. Почему v1.9.0 (без drainInput) стало хуже
|
||||
|
||||
drainInput() в write() — единственный механизм синхронизации запрос/ответ. Без него:
|
||||
|
||||
- Ответ на команду N читается как ответ на команду N+1
|
||||
- read() видит > из старого ответа — возвращает мусор, считает успехом
|
||||
- К 3-му PID цикла батча сдвиг накопился: ответы не совпадают с командами
|
||||
- BT-буфер на стороне ELM забивается необработанными данными → ELM перестаёт отвечать
|
||||
|
||||
В v1.3.0 drainInput() маскировал проблему retry: хотя бы буфер чистился перед каждой командой.
|
||||
|
||||
## 3. ATWS — нужен ли, сколько ждать
|
||||
|
||||
Нужен: сбрасывает SEARCHING..., очищает внутренние ошибки CAN-протокола ELM.
|
||||
|
||||
Проблемы текущего использования:
|
||||
- Пауза 800 мс — мало. CAN-шина после warm start поднимается 700–1000 мс, плюс ATSP0 negotiate. Нужно 1200–1500 мс.
|
||||
- После ATWS ELM сбрасывает настройки в дефолт: ATE1 (эхо ON), ATL1 (LF ON), ATS1 (пробелы ON). Код не восстанавливает ATE0/ATL0/ATS0 → read() начинает видеть эхо команды и переносы строк → парсинг ломается.
|
||||
|
||||
## 4. sleep(350) между PID — правильно?
|
||||
|
||||
Для ELM327 v1.5 — приемлемо. Адаптер не успевает переключаться быстрее 100–200 мс между разными PID (CAN frame turnaround). Но 350 мс не решает проблему, потому что рассинхрон возникает раньше — внутри retry в exec(). Пауза между командами маскирует, но не лечит.
|
||||
|
||||
## 5. Почему статика работает, динамика нет
|
||||
|
||||
Статика: пауза между командами — секунды (UI-обработка). ELM успевает ответить. Retry не срабатывает. Накопления сдвига нет.
|
||||
|
||||
Динамика: пауза 350–500 мс. При первом таймауте retry запускает цепочку дублей. Синхронизация батча ломается. Следующий батч начинается на сломанном состоянии.
|
||||
|
||||
## Итог: приоритет причин
|
||||
|
||||
1. exec() повторяет write(cmd) при таймауте — нельзя дублировать OBD-команды в CAN (первична)
|
||||
2. Убрали drainInput() в write() — потеряна синхронизация запрос/ответ
|
||||
3. После ATWS не восстанавливают ATE0/ATL0/ATS0 — парсинг ответов ломается
|
||||
@@ -0,0 +1,43 @@
|
||||
# Анализ причин отказа ELM327 v1.5 при динамическом опросе
|
||||
|
||||
Анализ кода `ElmProtocol.kt`, `DynamicCollector.kt` и `ElmChecker.kt` выявил ряд критических проблем, которые в совокупности приводят к "зависанию" адаптера ELM327 (особенно дешевых клонов v1.5) при переходе к быстрому циклу опроса.
|
||||
|
||||
## Ответы на вопросы
|
||||
|
||||
### 1. Почему ELM327 v1.5 замолкает после первых 2 ответов?
|
||||
Основная причина — **десинхронизация и переполнение буфера**.
|
||||
* **ATWS прямо перед циклом:** Команда `ATWS` (Warm Start) сбрасывает микроконтроллер. Ему требуется время на инициализацию (обычно 500-1000 мс). Код в `MainActivity` ждет всего 300 мс. Первые команды `010C` прилетают, когда ELM еще "просыпается" или находится в неопределенном состоянии.
|
||||
* **Эффект домино в `exec()`:** Если первый PID в цикле (`010C`) не успел ответить вовремя, `exec` возвращает пустую строку, но ELM продолжает обработку. Следующий вызов `sendCommand` через `write()` делает `drainInput()`, удаляя запоздавший ответ, и посылает новую команду. Для клона v1.5 типична ситуация, когда он "захлебывается", если получает новую команду, не закончив передачу предыдущего ответа или символа `>`.
|
||||
|
||||
### 2. Может ли `drainInput()` в `write()` съедать ответ?
|
||||
**Да, и это главная проблема надежности.**
|
||||
Если ELM327 ответил на 50 мс позже таймаута, данные уже лежат в буфере Bluetooth-сокета. Вызов `write()` для следующей команды в цикле безусловно их очищает. В итоге `read()` следующей команды видит пустоту, провоцируя новые таймауты и ретраи. Происходит рассинхрон: приложение ждет ответ на команду B, а ELM (если не завис) шлет ответ на команду A.
|
||||
|
||||
### 3. Критична ли последовательность: static -> speed-test -> ATWS -> dynamic?
|
||||
Последовательность перегружена сбросами.
|
||||
* `ATWS` сбрасывает настройки `ATAT1`, `ATSP`, `ATL0` и т.д., которые были установлены в `init()`.
|
||||
* После `ATWS` протокол может вернуться к `AUTO` (`ATSP0`), что заставляет ELM тратить время на "SEARCHING..." при первом же запросе `010C`. Это гарантированный таймаут в динамическом тесте.
|
||||
|
||||
### 4. Нужно ли переподключать ELM вместо ATWS?
|
||||
Переподключать Bluetooth-сокет не обязательно, но **вместо `ATWS` лучше вызвать серию настроечных команд**, гарантирующих состояние:
|
||||
1. `ATE0` (эхо выкл)
|
||||
2. `ATL0` (переносы строк выкл)
|
||||
3. `ATS0` (пробелы выкл) — крайне важно для скорости и предотвращения переполнения буфера.
|
||||
4. `ATSP X` (принудительная установка протокола, найденного в `checkDevice`), чтобы исключить стадию поиска.
|
||||
|
||||
### 5. Как правильно реализовать динамический опрос для v1.5?
|
||||
Для минимизации ошибок на медленных адаптерах:
|
||||
1. **Убрать `Thread.sleep(350)` внутри цикла `for (step in steps)`.** Пауза должна быть только между *пакетами* (батчами) PID, если нужно ограничить частоту. Внутри батча команды должны идти максимально плотно: послал -> дождался `>` -> сразу следующий.
|
||||
2. **Увеличить таймаут для v1.5.** 500 мс — это предел для v1.5. Первичный запрос (особенно после сброса) может занимать до 1500 мс.
|
||||
3. **Оптимизировать `read()`:** v1.5 очень чувствителен к таймингам. Текущий `Thread.sleep(1)` в `read()` — это хорошо, но логика `drainInput` должна быть перемещена: чистить буфер нужно только один раз *перед стартом всего динамического теста*, а не перед каждой командой.
|
||||
|
||||
## Рекомендации по исправлению
|
||||
|
||||
1. **В `ElmProtocol.kt`:**
|
||||
* Сделать `drainInput()` опциональным параметром в `write()` или убрать его из `sendCommand` по умолчанию.
|
||||
* В `handle()` при получении `NODATA` или `ERROR` не делать `ATWS` мгновенно, так как это убивает сессию опроса.
|
||||
2. **В `DynamicCollector.kt`:**
|
||||
* Удалить `Thread.sleep(350)` в цикле `for`. Вместо этого полагаться на таймауты `ElmProtocol`.
|
||||
3. **В `MainActivity.kt`:**
|
||||
* Убрать `ATWS` перед стартом. Если нужен сброс — использовать `ATZ` и ждать 2 секунды, после чего заново прогнать весь `init()`.
|
||||
* Перед запуском `DynamicCollector` зафиксировать протокол: `elmProto.sendCommand("ATSP" + currentProtocol)`.
|
||||
@@ -0,0 +1,277 @@
|
||||
# Запрос к Claude Sonnet — ТОЛЬКО АНАЛИЗ
|
||||
|
||||
## ⛔ ЗАПРЕЩЕНО МЕНЯТЬ КОД ⛔
|
||||
## ⛔ НЕ ДЕЛАТЬ КОММИТЫ ⛔
|
||||
## ⛔ НЕ ПРАВИТЬ ФАЙЛЫ ⛔
|
||||
## ⛔ ТОЛЬКО АНАЛИЗ — вывод в файл `doc/claude-analysis-elm-v2.md` ⛔
|
||||
|
||||
Ты — эксперт по ELM327 и OBD2. Тебе дан код Android-приложения (Kotlin). НАЙДИ БАГИ, ОБЪЯСНИ, ДАЙ РЕКОМЕНДАЦИИ. Код не менять.
|
||||
|
||||
---
|
||||
|
||||
## Проблема
|
||||
|
||||
Динамический тест: опрос 3 PID (010C RPM, 0110 MAF, 0106 STFT) в цикле. ELM327 v1.5 замолкает.
|
||||
|
||||
### Реальные данные с машины
|
||||
|
||||
**v1.3.0-dev** (drainInput в каждой write, ATWS+300ms):
|
||||
```
|
||||
0106: 1/18 ok 010C: 1/18 ok 0110: 0/18 ok
|
||||
Первые 2 ответа — данные, дальше 16 пустых.
|
||||
```
|
||||
|
||||
**v1.9.0-dev** (drainInput отключён, без ATWS):
|
||||
```
|
||||
0106: 0/15 ok 010C: 0/15 ok 0110: 0/15 ok ← СТАЛО ХУЖЕ
|
||||
ВСЕ 15 пустые.
|
||||
```
|
||||
|
||||
**Статическая диагностика** — одиночные PID — работает идеально.
|
||||
|
||||
---
|
||||
|
||||
## Код
|
||||
|
||||
Все файлы в `android/app/src/main/java/ru/elmer/client/`.
|
||||
|
||||
### 1. ElmProtocol.kt (elm/ElmProtocol.kt) — Стейт-машина AndrOBD
|
||||
|
||||
```kotlin
|
||||
package ru.elmer.client.elm
|
||||
|
||||
import android.util.Log
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
class ElmProtocol(
|
||||
private val input: InputStream,
|
||||
private val output: OutputStream
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "ElmProto"
|
||||
private const val POLL_DELAY = 1L
|
||||
private const val INIT_TIMEOUT = 10000L
|
||||
private const val DEF_TIMEOUT = 500L
|
||||
private const val TIMEOUT_MIN = 50L
|
||||
private const val TIMEOUT_MAX = 2000L
|
||||
private const val TIMEOUT_STEP = 20L
|
||||
private const val TIMEOUT_RES = 4
|
||||
private const val MAX_RETRIES = 10
|
||||
}
|
||||
|
||||
private enum class State { UNDEFINED, INITIALIZING, READY, BUSY, ERROR, DISCONNECTED }
|
||||
private var state = State.UNDEFINED
|
||||
private var timeoutMs = DEF_TIMEOUT
|
||||
private var learnedMin = TIMEOUT_MIN
|
||||
|
||||
fun init() {
|
||||
state = State.INITIALIZING
|
||||
write("ATSP0"); tryRead(4000); drainInput()
|
||||
write("ATAT1"); tryRead(2000); drainInput()
|
||||
updateAtst()
|
||||
write("ATS0"); tryRead(2000); drainInput()
|
||||
write("ATL0"); tryRead(2000); drainInput()
|
||||
write("ATE0"); tryRead(2000); drainInput()
|
||||
state = State.READY
|
||||
}
|
||||
|
||||
fun sendCommand(cmd: String): String {
|
||||
if (state == State.ERROR || state == State.DISCONNECTED) recover()
|
||||
state = State.BUSY
|
||||
val result = exec(cmd, timeoutMs)
|
||||
if (state == State.BUSY) state = State.READY
|
||||
return result
|
||||
}
|
||||
|
||||
private fun exec(cmd: String, timeout: Long): String {
|
||||
write(cmd)
|
||||
var t = timeout
|
||||
for (i in 0 until MAX_RETRIES) {
|
||||
try {
|
||||
return handle(read(t))
|
||||
} catch (_: TimeoutException) {
|
||||
if (state == State.INITIALIZING) t += 1000
|
||||
else { increaseTimeout(); t = timeoutMs }
|
||||
}
|
||||
}
|
||||
state = State.ERROR
|
||||
return ""
|
||||
}
|
||||
|
||||
private fun handle(raw: String): String {
|
||||
val u = raw.uppercase().trim()
|
||||
when {
|
||||
u.startsWith("SEARCHING") -> {}
|
||||
u.startsWith("OK") -> decreaseTimeout()
|
||||
u.startsWith("NODATA") || u.startsWith("NO DATA") -> { increaseTimeout(); updateAtst() }
|
||||
isBusError(u) -> {
|
||||
state = State.DISCONNECTED; resetTimeout(); updateAtst()
|
||||
write("ATPC"); tryRead(3000); write("ATSP0"); tryRead(3000)
|
||||
}
|
||||
u.startsWith("ERROR") && !u.startsWith("DATA ERROR") -> { state = State.ERROR; write("ATWS"); tryRead(3000) }
|
||||
isDataError(u) -> { state = State.ERROR; write("ATWS"); tryRead(3000) }
|
||||
else -> decreaseTimeout()
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
private fun recover() {
|
||||
state = State.INITIALIZING
|
||||
write("ATWS"); tryRead(2000); drainInput()
|
||||
write("ATSP0"); tryRead(2000); drainInput()
|
||||
write("ATE0"); tryRead(2000); drainInput()
|
||||
state = State.READY
|
||||
}
|
||||
|
||||
private fun write(cmd: String) {
|
||||
drainInput()
|
||||
output.write((cmd + "\r").toByteArray())
|
||||
output.flush()
|
||||
}
|
||||
|
||||
private fun drainInput() {
|
||||
while (input.available() > 0) input.read()
|
||||
}
|
||||
|
||||
@Throws(TimeoutException::class)
|
||||
private fun read(timeout: Long): String {
|
||||
val dl = System.currentTimeMillis() + timeout
|
||||
val sb = StringBuilder()
|
||||
val lines = mutableListOf<String>()
|
||||
var gotPrompt = false
|
||||
while (System.currentTimeMillis() < dl) {
|
||||
if (input.available() > 0) {
|
||||
val b = input.read()
|
||||
if (b == -1) break
|
||||
when (b) {
|
||||
62 -> { push(sb, lines); gotPrompt = true; break }
|
||||
13 -> push(sb, lines)
|
||||
10, 32 -> {}
|
||||
else -> sb.append(b.toChar())
|
||||
}
|
||||
} else { Thread.sleep(POLL_DELAY) }
|
||||
}
|
||||
push(sb, lines)
|
||||
if (!gotPrompt) throw TimeoutException("timeout ${timeout}ms")
|
||||
return lines.joinToString("\n")
|
||||
}
|
||||
|
||||
private fun tryRead(timeout: Long) { try { read(timeout) } catch (_: TimeoutException) {} }
|
||||
private fun push(sb: StringBuilder, lines: MutableList<String>) {
|
||||
if (sb.isNotEmpty()) { lines.add(sb.toString()); sb.clear() }
|
||||
}
|
||||
private fun increaseTimeout() { if (timeoutMs + TIMEOUT_STEP < TIMEOUT_MAX) timeoutMs += TIMEOUT_STEP }
|
||||
private fun decreaseTimeout() { if (timeoutMs - TIMEOUT_STEP >= learnedMin) timeoutMs -= TIMEOUT_STEP }
|
||||
private fun resetTimeout() { timeoutMs = DEF_TIMEOUT }
|
||||
fun resetAdaptiveTiming() { timeoutMs = DEF_TIMEOUT }
|
||||
private fun updateAtst() {
|
||||
val v = (timeoutMs / TIMEOUT_RES).toInt().coerceAtLeast(1)
|
||||
write("ATST${v.toString(16).uppercase().padStart(2, '0')}")
|
||||
tryRead(2000); drainInput()
|
||||
}
|
||||
private fun isBusError(s: String) = listOf("UNABLE","BUS BUSY","BUS ERROR","CAN ERROR","BUS INIT","STOPPED").any { s.startsWith(it) }
|
||||
private fun isDataError(s: String) = listOf("DATA ERROR","BUFFER FULL","RX ERROR").any { s.startsWith(it) }
|
||||
}
|
||||
|
||||
class TimeoutException(message: String) : Exception(message)
|
||||
```
|
||||
|
||||
### 2. DynamicCollector.kt (script/DynamicCollector.kt)
|
||||
|
||||
```kotlin
|
||||
package ru.elmer.client.script
|
||||
|
||||
import ru.elmer.client.elm.ElmProtocol
|
||||
import ru.elmer.client.elm.ObdDecoder
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class DynamicCollector(
|
||||
private val elm: ElmProtocol,
|
||||
private val steps: List<ElmStep>,
|
||||
private val intervalMs: Long,
|
||||
private val onSample: (sampleIndex: Int) -> Unit,
|
||||
private val onLog: (msg: String) -> Unit
|
||||
) {
|
||||
data class ElmStep(val id: String, val cmd: String, val desc: String)
|
||||
private val running = AtomicBoolean(false)
|
||||
private val samples = mutableListOf<List<SampleResponse>>()
|
||||
private var threadRef: Thread? = null
|
||||
|
||||
data class SampleResponse(val stepId: String, val cmd: String, val raw: String, val decoded: String, val ts: Long = 0)
|
||||
|
||||
fun start() {
|
||||
running.set(true)
|
||||
val startTs = System.currentTimeMillis()
|
||||
threadRef = thread(name = "DynamicCollector", isDaemon = true) {
|
||||
var idx = 0
|
||||
while (running.get()) {
|
||||
val t0 = System.currentTimeMillis()
|
||||
val batch = mutableListOf<SampleResponse>()
|
||||
for (step in steps) {
|
||||
if (!running.get()) break
|
||||
try {
|
||||
val raw = elm.sendCommand(step.cmd)
|
||||
val dec = ObdDecoder.decode(step.cmd, raw)
|
||||
batch.add(SampleResponse(step.id, step.cmd, raw, dec, System.currentTimeMillis() - startTs))
|
||||
} catch (e: Exception) {
|
||||
batch.add(SampleResponse(step.id, step.cmd, "(err)", e.message ?: "error", System.currentTimeMillis() - startTs))
|
||||
}
|
||||
Thread.sleep(350)
|
||||
}
|
||||
if (batch.isNotEmpty()) { synchronized(samples) { samples.add(batch) }; onSample(idx); idx++ }
|
||||
val elapsed = System.currentTimeMillis() - t0
|
||||
val sleep = intervalMs - elapsed
|
||||
if (sleep > 0 && running.get()) Thread.sleep(sleep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(): List<List<SampleResponse>> {
|
||||
running.set(false)
|
||||
try { threadRef?.join(3000) } catch (_: Exception) {}
|
||||
return synchronized(samples) { samples.toList() }
|
||||
}
|
||||
|
||||
fun isRunning(): Boolean = running.get()
|
||||
}
|
||||
```
|
||||
|
||||
### 3. MainActivity.kt — startDynamicRecording() (фрагмент)
|
||||
|
||||
```kotlin
|
||||
// v1.10.0-dev — текущая версия
|
||||
private fun startDynamicRecording() {
|
||||
thread(name = "DynamicTest", isDaemon = true) {
|
||||
checker.ensureConnected()
|
||||
val elmProto = checker.getElm()!!
|
||||
|
||||
// Статика — 9 PID по одному (работает)
|
||||
for ((pid, desc) in staticCmds) {
|
||||
elmProto.sendCommand("01$pid")
|
||||
}
|
||||
|
||||
// Подготовка к динамике
|
||||
try { elmProto.sendCommand("ATWS") } catch (_: Exception) {}
|
||||
Thread.sleep(800)
|
||||
|
||||
// Динамика: 3 PID, интервал 500ms
|
||||
val dynSteps = listOf("010C" to "RPM", "0110" to "MAF", "0106" to "STFT")
|
||||
.map { ElmStep(it.second, it.first, it.second) }
|
||||
DynamicCollector(elmProto, dynSteps, 500L, ...).start()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Вопросы
|
||||
|
||||
1. **exec()** делает retry с ПОВТОРНОЙ ОТПРАВКОЙ команды — не забивает ли это ELM327?
|
||||
2. **drainInput()** в write() — почему без него (v1.9.0) стало ХУЖЕ?
|
||||
3. **ATWS** — нужен ли? Сколько ждать?
|
||||
4. **sleep(350)** между PID — правильно или избыточно?
|
||||
5. Почему статика работает а динамика нет?
|
||||
|
||||
## ⛔ НАПОМИНАНИЕ: НЕ МЕНЯТЬ КОД, НЕ КОММИТИТЬ. ТОЛЬКО АНАЛИЗ В ФАЙЛ doc/claude-analysis-elm-v2.md ⛔
|
||||
@@ -0,0 +1,499 @@
|
||||
# Запрос анализа ELM327-кода — для Claude Sonnet
|
||||
|
||||
## Контекст
|
||||
|
||||
Android-приложение для OBD2-диагностики автомобиля через ELM327 Bluetooth-адаптер.
|
||||
Стек: Kotlin, minSdk 24, OkHttp 4.12.
|
||||
|
||||
**Проблема:** динамический тест (START/STOP) — опрос 3 PID (RPM, MAF, STFT) в реальном времени —
|
||||
даёт 94-100% пустых ответов. ELM327 v1.5 просто перестаёт отвечать.
|
||||
|
||||
Статическая диагностика (одиночные PID) работает нормально.
|
||||
|
||||
## Важно
|
||||
|
||||
АНАЛИЗИРУЙ ТОЛЬКО ELM-код. Не трогай UI, сервер, БД.
|
||||
Нужен анализ того, почему ELM327 замолкает при динамическом опросе.
|
||||
|
||||
## Файлы
|
||||
|
||||
Ниже полный код всех файлов, связанных с ELM327.
|
||||
|
||||
---
|
||||
|
||||
### 1. ElmProtocol.kt — стейт-машина AndrOBD (1:1 копия ElmProt.java)
|
||||
|
||||
```kotlin
|
||||
package ru.elmer.client.elm
|
||||
|
||||
import android.util.Log
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
class ElmProtocol(
|
||||
private val input: InputStream,
|
||||
private val output: OutputStream
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "ElmProto"
|
||||
private const val POLL_DELAY = 1L
|
||||
private const val INIT_TIMEOUT = 10000L
|
||||
private const val DEF_TIMEOUT = 500L
|
||||
private const val TIMEOUT_MIN = 50L
|
||||
private const val TIMEOUT_MAX = 2000L
|
||||
private const val TIMEOUT_STEP = 20L
|
||||
private const val TIMEOUT_RES = 4
|
||||
private const val MAX_RETRIES = 10
|
||||
}
|
||||
|
||||
private enum class State { UNDEFINED, INITIALIZING, READY, BUSY, ERROR, DISCONNECTED }
|
||||
private var state = State.UNDEFINED
|
||||
private var timeoutMs = DEF_TIMEOUT
|
||||
private var learnedMin = TIMEOUT_MIN
|
||||
|
||||
fun init() {
|
||||
Log.i(TAG, "init start")
|
||||
state = State.INITIALIZING
|
||||
write("ATSP0"); tryRead(4000); drainInput()
|
||||
write("ATAT1"); tryRead(2000); drainInput()
|
||||
updateAtst()
|
||||
write("ATS0"); tryRead(2000); drainInput()
|
||||
write("ATL0"); tryRead(2000); drainInput()
|
||||
write("ATE0"); tryRead(2000); drainInput()
|
||||
state = State.READY
|
||||
Log.i(TAG, "ready")
|
||||
}
|
||||
|
||||
fun sendCommand(cmd: String): String {
|
||||
if (state == State.ERROR || state == State.DISCONNECTED) recover()
|
||||
state = State.BUSY
|
||||
val result = exec(cmd, timeoutMs)
|
||||
if (state == State.BUSY) state = State.READY
|
||||
return result
|
||||
}
|
||||
|
||||
private fun exec(cmd: String, timeout: Long): String {
|
||||
write(cmd)
|
||||
var t = timeout
|
||||
for (i in 0 until MAX_RETRIES) {
|
||||
try {
|
||||
return handle(read(t))
|
||||
} catch (_: TimeoutException) {
|
||||
if (state == State.INITIALIZING) t += 1000
|
||||
else { increaseTimeout(); t = timeoutMs }
|
||||
}
|
||||
}
|
||||
Log.e(TAG, "no response for $cmd")
|
||||
state = State.ERROR
|
||||
return ""
|
||||
}
|
||||
|
||||
private fun handle(raw: String): String {
|
||||
val u = raw.uppercase().trim()
|
||||
when {
|
||||
u.startsWith("SEARCHING") -> {}
|
||||
u.startsWith("OK") -> decreaseTimeout()
|
||||
u.startsWith("NODATA") || u.startsWith("NO DATA") -> {
|
||||
increaseTimeout(); updateAtst()
|
||||
}
|
||||
isBusError(u) -> {
|
||||
Log.w(TAG, "BUS ERROR: ${raw.take(60)}")
|
||||
state = State.DISCONNECTED
|
||||
resetTimeout(); updateAtst()
|
||||
write("ATPC"); tryRead(3000)
|
||||
write("ATSP0"); tryRead(3000)
|
||||
}
|
||||
u.startsWith("ERROR") && !u.startsWith("DATA ERROR") -> {
|
||||
Log.w(TAG, "ERROR — warm start")
|
||||
state = State.ERROR
|
||||
write("ATWS"); tryRead(3000)
|
||||
}
|
||||
isDataError(u) -> {
|
||||
Log.w(TAG, "data error — warm start")
|
||||
state = State.ERROR
|
||||
write("ATWS"); tryRead(3000)
|
||||
}
|
||||
else -> decreaseTimeout()
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
private fun recover() {
|
||||
Log.i(TAG, "recovering...")
|
||||
state = State.INITIALIZING
|
||||
write("ATWS"); tryRead(2000); drainInput()
|
||||
write("ATSP0"); tryRead(2000); drainInput()
|
||||
write("ATE0"); tryRead(2000); drainInput()
|
||||
state = State.READY
|
||||
}
|
||||
|
||||
private fun write(cmd: String) {
|
||||
drainInput()
|
||||
output.write((cmd + "\r").toByteArray())
|
||||
output.flush()
|
||||
Log.d(TAG, "→ $cmd")
|
||||
}
|
||||
|
||||
private fun drainInput() {
|
||||
while (input.available() > 0) input.read()
|
||||
}
|
||||
|
||||
@Throws(TimeoutException::class)
|
||||
private fun read(timeout: Long): String {
|
||||
val dl = System.currentTimeMillis() + timeout
|
||||
val sb = StringBuilder()
|
||||
val lines = mutableListOf<String>()
|
||||
var gotPrompt = false
|
||||
while (System.currentTimeMillis() < dl) {
|
||||
if (input.available() > 0) {
|
||||
val b = input.read()
|
||||
if (b == -1) break
|
||||
when (b) {
|
||||
62 -> { push(sb, lines); gotPrompt = true; break } // '>'
|
||||
13 -> push(sb, lines) // CR
|
||||
10, 32 -> {} // LF, space
|
||||
else -> sb.append(b.toChar())
|
||||
}
|
||||
} else {
|
||||
Thread.sleep(POLL_DELAY)
|
||||
}
|
||||
}
|
||||
push(sb, lines)
|
||||
if (!gotPrompt) throw TimeoutException("timeout ${timeout}ms")
|
||||
return lines.joinToString("\n")
|
||||
}
|
||||
|
||||
private fun tryRead(timeout: Long) {
|
||||
try { read(timeout) } catch (_: TimeoutException) {}
|
||||
}
|
||||
|
||||
private fun push(sb: StringBuilder, lines: MutableList<String>) {
|
||||
if (sb.isNotEmpty()) { lines.add(sb.toString()); sb.clear() }
|
||||
}
|
||||
|
||||
private fun increaseTimeout() {
|
||||
if (timeoutMs + TIMEOUT_STEP < TIMEOUT_MAX) timeoutMs += TIMEOUT_STEP
|
||||
}
|
||||
|
||||
private fun decreaseTimeout() {
|
||||
if (timeoutMs - TIMEOUT_STEP >= learnedMin) timeoutMs -= TIMEOUT_STEP
|
||||
}
|
||||
|
||||
private fun resetTimeout() { timeoutMs = DEF_TIMEOUT }
|
||||
|
||||
fun resetAdaptiveTiming() { timeoutMs = DEF_TIMEOUT }
|
||||
|
||||
private fun updateAtst() {
|
||||
val v = (timeoutMs / TIMEOUT_RES).toInt().coerceAtLeast(1)
|
||||
write("ATST${v.toString(16).uppercase().padStart(2, '0')}")
|
||||
tryRead(2000)
|
||||
drainInput()
|
||||
}
|
||||
|
||||
private fun isBusError(s: String): Boolean {
|
||||
return listOf("UNABLE", "BUS BUSY", "BUS ERROR", "CAN ERROR",
|
||||
"BUS INIT", "STOPPED").any { s.startsWith(it) }
|
||||
}
|
||||
|
||||
private fun isDataError(s: String): Boolean {
|
||||
return listOf("DATA ERROR", "BUFFER FULL", "RX ERROR").any { s.startsWith(it) }
|
||||
}
|
||||
}
|
||||
|
||||
class TimeoutException(message: String) : Exception(message)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. DynamicCollector.kt — сборщик для динамического теста
|
||||
|
||||
```kotlin
|
||||
package ru.elmer.client.script
|
||||
|
||||
import ru.elmer.client.elm.ElmProtocol
|
||||
import ru.elmer.client.elm.ObdDecoder
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class DynamicCollector(
|
||||
private val elm: ElmProtocol,
|
||||
private val steps: List<ElmStep>,
|
||||
private val intervalMs: Long,
|
||||
private val onSample: (sampleIndex: Int) -> Unit,
|
||||
private val onLog: (msg: String) -> Unit
|
||||
) {
|
||||
data class ElmStep(val id: String, val cmd: String, val desc: String)
|
||||
private val running = AtomicBoolean(false)
|
||||
private val samples = mutableListOf<List<SampleResponse>>()
|
||||
private var threadRef: Thread? = null
|
||||
|
||||
data class SampleResponse(
|
||||
val stepId: String, val cmd: String, val raw: String,
|
||||
val decoded: String, val ts: Long = 0
|
||||
)
|
||||
|
||||
fun start() {
|
||||
running.set(true)
|
||||
val startTs = System.currentTimeMillis()
|
||||
threadRef = thread(name = "DynamicCollector", isDaemon = true) {
|
||||
var idx = 0
|
||||
while (running.get()) {
|
||||
val t0 = System.currentTimeMillis()
|
||||
val batch = mutableListOf<SampleResponse>()
|
||||
for (step in steps) {
|
||||
if (!running.get()) break
|
||||
try {
|
||||
val raw = elm.sendCommand(step.cmd)
|
||||
val dec = ObdDecoder.decode(step.cmd, raw)
|
||||
batch.add(SampleResponse(step.id, step.cmd, raw, dec, System.currentTimeMillis() - startTs))
|
||||
} catch (e: Exception) {
|
||||
batch.add(SampleResponse(step.id, step.cmd, "(err)", e.message ?: "error", System.currentTimeMillis() - startTs))
|
||||
}
|
||||
Thread.sleep(350)
|
||||
}
|
||||
if (batch.isNotEmpty()) {
|
||||
synchronized(samples) { samples.add(batch) }
|
||||
onSample(idx); idx++
|
||||
}
|
||||
val elapsed = System.currentTimeMillis() - t0
|
||||
val sleep = intervalMs - elapsed
|
||||
if (sleep > 0 && running.get()) Thread.sleep(sleep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(): List<List<SampleResponse>> {
|
||||
running.set(false)
|
||||
try { threadRef?.join(3000) } catch (_: Exception) {}
|
||||
return synchronized(samples) { samples.toList() }
|
||||
}
|
||||
|
||||
fun isRunning(): Boolean = running.get()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. ElmChecker.kt — проверка устройства + speed-test
|
||||
|
||||
```kotlin
|
||||
package ru.elmer.client.elm
|
||||
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import android.util.Log
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
|
||||
class ElmChecker(
|
||||
private val device: BluetoothDevice,
|
||||
private val adapter: BluetoothAdapter
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "ElmChecker"
|
||||
private val SPP_UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB")
|
||||
}
|
||||
|
||||
data class DeviceInfo(
|
||||
val version: String, val deviceId: String, val protocol: String,
|
||||
val voltage: String, val hasAdaptive: Boolean
|
||||
)
|
||||
data class EcuData(val supportsObd: Boolean, val pidMask: String, val vin: String?)
|
||||
data class Result(val good: Boolean, val device: DeviceInfo, val ecu: EcuData, val log: String)
|
||||
|
||||
private val logLines = mutableListOf<String>()
|
||||
fun getLog(): String = logLines.joinToString("\n")
|
||||
private var socket: BluetoothSocket? = null
|
||||
private var elm: ElmProtocol? = null
|
||||
|
||||
fun checkDevice(): DeviceInfo? {
|
||||
if (!connectAndInit()) return null
|
||||
val ati = send("ATI"); val version = parseVersion(ati)
|
||||
val isV2 = version.contains("v2", ignoreCase = true)
|
||||
val deviceId = if (isV2) cleanAt2(send("AT@2")) else "—"
|
||||
val dp = send("ATDP"); val protocol = if (dp.length > 3 && dp != "OK") dp.take(60) else dp
|
||||
val rv = send("ATRV"); val voltage = if (rv.contains("V", ignoreCase = true) || rv.matches(Regex("[0-9.]+"))) rv else "—"
|
||||
val hasAdaptive = if (isV2) send("ATAT1") == "OK" else false
|
||||
return DeviceInfo(version, deviceId, protocol, voltage, hasAdaptive)
|
||||
}
|
||||
|
||||
fun checkEcu(): EcuData {
|
||||
val pid0100 = send("0100"); val supportsObd = pid0100.startsWith("41")
|
||||
val pidMask = if (supportsObd) pid0100.take(60) else "—"
|
||||
val vinRaw = send("0902"); val vin = parseVin(vinRaw)
|
||||
return EcuData(supportsObd, pidMask, vin)
|
||||
}
|
||||
|
||||
fun scanDtc(): List<String>? {
|
||||
if (!connectAndInit()) { disconnect(); return null }
|
||||
val codes = mutableListOf<String>()
|
||||
codes.addAll(parseDtcCodes(send("03")))
|
||||
codes.addAll(parseDtcCodes(send("07")))
|
||||
return codes.distinct()
|
||||
}
|
||||
|
||||
fun ensureConnected(): Boolean = connectAndInit()
|
||||
fun isConnected(): Boolean = socket?.isConnected == true && elm != null
|
||||
fun getElm(): ElmProtocol? = elm
|
||||
|
||||
fun quickCheck(): Int? {
|
||||
val e = elm ?: return null
|
||||
return try {
|
||||
val t0 = System.currentTimeMillis()
|
||||
val raw = e.sendCommand("010C"); val dt = System.currentTimeMillis() - t0
|
||||
if (raw.isBlank() || raw == "(err)") null else dt.toInt()
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
data class SpeedTestResult(val perPidAvg: List<Int>, val batchTime: Int, val reliable: Boolean, val message: String)
|
||||
|
||||
fun measureResponseTime(onProgress: (String) -> Unit): SpeedTestResult {
|
||||
val testPids = listOf("010C" to "RPM", "0110" to "MAF", "0106" to "STFT")
|
||||
val perPidAvg = mutableListOf<Int>()
|
||||
var hadErrors = false; var reliable = true
|
||||
val reasons = mutableListOf<String>()
|
||||
val e = elm ?: return SpeedTestResult(listOf(250,250,250), 750, false, "❌ ELM не инициализирован")
|
||||
try { e.sendCommand("010C") } catch (_: Exception) {}
|
||||
onProgress("\n⏱ Тест скорости ELM...")
|
||||
for ((pi, pair) in testPids.withIndex()) {
|
||||
val (cmd, name) = pair; val allTimes = mutableListOf<Long>()
|
||||
val count = if (pi == 0) 4 else 3
|
||||
for (i in 0 until count) {
|
||||
val t0 = System.currentTimeMillis()
|
||||
val raw = try { e.sendCommand(cmd) } catch (_: Exception) { "(err)" }
|
||||
val dt = System.currentTimeMillis() - t0; allTimes.add(dt)
|
||||
if (raw == "(err)" || raw.isBlank()) hadErrors = true
|
||||
}
|
||||
val times = if (pi == 0) allTimes.takeLast(2).toMutableList() else allTimes
|
||||
val avg = times.average().toInt(); perPidAvg.add(avg)
|
||||
onProgress("\n $name: ${times.joinToString("ms, ")}ms (среднее ${avg}ms)")
|
||||
for (t in times) {
|
||||
if (t > 0 && avg > 0 && kotlin.math.abs(t - avg).toFloat() / avg > 0.5f) {
|
||||
if (reliable) reliable = false
|
||||
reasons.add("${name} нестабилен: ${t}ms vs среднее ${avg}ms")
|
||||
}
|
||||
}
|
||||
}
|
||||
val batchTime = perPidAvg.sum()
|
||||
val message = if (reliable) "✅ Скорость стабильна: ${perPidAvg.joinToString("+")}=${batchTime}ms"
|
||||
else "⚠️ ${reasons.joinToString("; ")}. Проверьте контакт ELM в OBD-разъёме."
|
||||
onProgress("\n$message")
|
||||
return SpeedTestResult(perPidAvg, batchTime, reliable, message)
|
||||
}
|
||||
|
||||
fun close() { try { socket?.close() } catch (_: Exception) {}; socket = null; elm = null }
|
||||
|
||||
// ... (private connect, send, parseVin, parseDtcCodes, parseVersion, failResult опущены для краткости)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Проблема
|
||||
|
||||
При динамическом тесте:
|
||||
|
||||
```
|
||||
Сессия #54 (v1.3.0-dev): 55 ответов
|
||||
0106 (STFT): 1 ok / 18 попыток → 94% ошибок
|
||||
010C (RPM): 1 ok / 18 попыток → 94% ошибок
|
||||
0110 (MAF): 0 ok / 18 попыток → 100% ошибок
|
||||
|
||||
Первые 2 ответа — нормальные (410680, 41110E), затем 17 пустых.
|
||||
```
|
||||
|
||||
**Поток вызовов перед динамическим тестом:**
|
||||
1. `ElmProtocol.init()` — 5 AT-команд (ATSP0, ATAT1, ATS0, ATL0, ATE0)
|
||||
2. `ElmChecker.checkDevice()` — 5-6 AT-команд (ATI, AT@2, ATDP, ATRV, ATAT1)
|
||||
3. `ElmChecker.checkEcu()` — 2 OBD-команды (0100, 0902)
|
||||
4. В MainActivity: статический проброс 9 PID (0104-011F)
|
||||
5. Speed-test: 1 warmup + 4 RPM + 3 MAF + 3 STFT = 11 OBD-команд
|
||||
6. `ATWS` — сброс ELM
|
||||
7. DynamicCollector: 3 PID в цикле каждые 500ms
|
||||
|
||||
**Ключевое:** `sendCommand()` → `exec()` при таймауте делает RETRY:
|
||||
- `write(cmd)` — посылает команду
|
||||
- `read(timeout)` — ждёт ответ
|
||||
- таймаут → `increaseTimeout()` → `write(cmd)` ОПЯТЬ
|
||||
- до 10 retry на одну команду
|
||||
|
||||
## Вопросы
|
||||
|
||||
1. Почему ELM327 v1.5 замолкает после первых 2 ответов в DynamicCollector?
|
||||
2. Может ли `drainInput()` в `write()` съедать ответ от предыдущей команды?
|
||||
3. Критична ли последовательность: static probe → speed-test → ATWS → dynamic collect?
|
||||
4. Нужно ли переподключать ELM перед динамическим тестом вместо ATWS?
|
||||
5. Как правильно реализовать динамический опрос с учётом медленного ELM327 v1.5 (min ответ 350ms)?
|
||||
|
||||
Ответ сохрани в файл `doc/claude-analysis-elm.md`
|
||||
|
||||
---
|
||||
|
||||
### 4. MainActivity.kt — фрагменты (checkElm, checkEcu, startDynamicRecording)
|
||||
|
||||
```kotlin
|
||||
// Вызывается при клике на светофор ELM
|
||||
private fun checkElm() {
|
||||
// ... поиск Bluetooth-устройства ...
|
||||
elmDevice = dev
|
||||
try {
|
||||
val checker = ElmChecker(dev, btAdapter!!)
|
||||
val r = checker.checkDevice() // AT-команды, инициализация
|
||||
if (r != null) {
|
||||
elmChecker = checker
|
||||
setIndicator(indElm, "🟢")
|
||||
checkEcu() // ← синхронно на главном потоке!
|
||||
// Speed-test в фоновом потоке
|
||||
thread(name = "SpeedTest", isDaemon = true) {
|
||||
val client = ServerClient(...)
|
||||
val saved = client.getProfileResponseTime(elmMac)
|
||||
if (saved != null && saved > 0) {
|
||||
val quick = checker.quickCheck() // 010C × 1
|
||||
// сверка с профилем...
|
||||
return@thread
|
||||
}
|
||||
val result = checker.measureResponseTime { msg -> debugLog(msg) }
|
||||
if (result.reliable) client.saveProfile(elmMac, result.batchTime)
|
||||
}
|
||||
}
|
||||
} catch ...
|
||||
}
|
||||
|
||||
private fun checkEcu() {
|
||||
val checker = elmChecker ?: return
|
||||
try {
|
||||
val raw = checker.getElm()?.sendCommand("03") ?: "" // ← синхронно!
|
||||
val ok = raw.startsWith("43")
|
||||
setIndicator(indEcu, if (ok) "🟢" else "🔴")
|
||||
} catch ...
|
||||
}
|
||||
|
||||
// Вызывается при нажатии СТАРТ
|
||||
private fun startDynamicRecording() {
|
||||
thread(name = "DynamicTest", isDaemon = true) {
|
||||
val checker = elmChecker
|
||||
if (!checker.ensureConnected()) { /* retry */ }
|
||||
val elmProto = checker.getElm()!!
|
||||
|
||||
// ── Статика: пробуем 9 PID ──
|
||||
for ((pid, desc) in staticCmds) {
|
||||
val raw = elmProto.sendCommand("01$pid")
|
||||
// ...
|
||||
}
|
||||
|
||||
// ── ATWS: сброс ELM ──
|
||||
try { elmProto.sendCommand("ATWS") } catch (_: Exception) {}
|
||||
Thread.sleep(300)
|
||||
|
||||
// ── Динамика: 3 PID, интервал 500ms ──
|
||||
val dynSteps = listOf("010C" to "RPM", "0110" to "MAF", "0106" to "STFT")
|
||||
.map { ElmStep(it.second, it.first, it.second) }
|
||||
val collector = DynamicCollector(elmProto, dynSteps, 500L, ...)
|
||||
collector.start()
|
||||
while (state == State.START && collector.isRunning()) Thread.sleep(200)
|
||||
val samples = collector.stop()
|
||||
// ... контроль качества, слияние со статикой ...
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,28 @@
|
||||
# Вопросы для Соннета — почему телефон получает старый APK
|
||||
|
||||
Дата: 2026-06-28
|
||||
|
||||
## Ситуация
|
||||
|
||||
Сервер obdai.ru (Ubuntu 24, nginx, 5.172.178.213).
|
||||
APK собран через `./gradlew :raw:clean :raw:assembleDebug`, 36/36 tasks fresh.
|
||||
Проверено:
|
||||
- `aapt dump badging` → versionCode=6, versionName=0.2.2-dev ✅
|
||||
- `curl -sI https://obdai.ru/static/elm-raw-v022.apk` → 200 OK, 3726961 bytes ✅
|
||||
- Файл переименован: elm-raw-v022.apk (абсолютно новое имя, не могло быть в кеше)
|
||||
- Приложение переименовано: app_name = "ELM Relay v2" (strings.xml)
|
||||
- Страница обновлена: ссылка на /static/elm-raw-v022.apk, текст "Скачать ELM Relay v2"
|
||||
- nginx reload делался, gunicorn рестартовал
|
||||
|
||||
НО: пользователь с Android-телефона скачивает и получает **v0.1.1-dev**.
|
||||
Приложение на рабочем столе называется "ELM Relay v2", но внутри показывает v0.1.1-dev.
|
||||
|
||||
## Вопросы
|
||||
|
||||
1. Может ли Android (Google Play Protect / Verify Apps) подменять APK при установке на старую версию из кеша?
|
||||
2. Может ли nginx кешировать файлы, несмотря на `alias`? Нужен ли `expires -1` или `add_header Cache-Control "no-cache"`?
|
||||
3. Может ли `curl` и браузер телефона видеть РАЗНЫЕ файлы из-за Cloudflare/CDN/прокси провайдера? Как проверить?
|
||||
4. Может ли `wget` или `curl` с сервера через внешний IP показать, что отдаётся НЕ тот файл?
|
||||
5. Может ли Android Download Manager кешировать по имени пакета (ru.elmer.raw), а не по URL?
|
||||
6. Какие ещё механизмы кеширования APK существуют на стороне Android, которые мы не учли?
|
||||
7. Как ПРОВЕРИТЬ на сервере, что по HTTP реально отдаётся правильный файл, а не старый — поMD5 или размеру?
|
||||
@@ -0,0 +1,30 @@
|
||||
# Ответ Соннета — по результатам тестов 28.06.2026
|
||||
|
||||
(Отправлено по ошибке вместо Опуса)
|
||||
|
||||
## 1. Природа залипания
|
||||
Известный баг клонов (особенно v1.5 на CH340/PIC18F). Залипает обычно CAN-контроллер внутри клона — накапливает необработанные фреймы и перестаёт принимать команды через UART. Настоящий ELM327 сбрасывает буфер сам; клоны — нет.
|
||||
|
||||
## 2. Детект залипания
|
||||
Лучший вариант — сравнивать `ATRV` до и после цикла (у живого ELM всегда выдаёт реальное напряжение ~12.x V). Дополнительно: если два последних ответа на разные PID идентичны побайтово — залип. Трёх совпадений достаточно.
|
||||
|
||||
## 3. Паузы
|
||||
2–3 секунды между циклами по 4 PID обычно помогают. Минимум ~1.5 сек. При 6+ PID в цикле лучше 3–5 сек. Чем больше PID, тем больше CAN-трафика, тем быстрее переполняется буфер.
|
||||
|
||||
## 4. Auto-recovery
|
||||
`ATZ` (полный сброс, ~1 сек) — самый надёжный программный способ. `ATWS` (warm start) — мягче, но не всегда помогает клонам. `ATPC` (protocol close) — иногда достаточно. Последовательность: `ATPC` → 500 мс → если не помогло → `ATZ` → переинициализация.
|
||||
|
||||
## 5. Оптимальный размер цикла
|
||||
При паузе 2–3 сек: **4 PID** — хороший баланс. 6 PID уже рискованно на нагруженной шине. 2 PID — безопасно, но медленно.
|
||||
|
||||
## 6. Влияние оборотов
|
||||
Да, напрямую. При высоких оборотах CAN-шина загружена больше (ECU шлёт больше фреймов), буфер клона заполняется быстрее. На холостых — залипает позже.
|
||||
|
||||
## 7. Замена адаптера
|
||||
Приоритет: **OBDLink EX/MX+** (оригинальный чип STN2120, нет этой проблемы) → оригинальный ELM327 v1.4b/v2.1 (реже). Клоны v2.1 — лотерея, та же болезнь. Китайские v1.5 — для продакшена непригодны.
|
||||
|
||||
## 8. Стратегия для продакшена
|
||||
Адаптироваться под клонов, но с явными ограничениями:
|
||||
- Медленный режим (4 PID, пауза 3 сек) как default
|
||||
- Детект залипания + авто-`ATZ`
|
||||
- В UI показывать «адаптер нестабилен, рекомендуем OBDLink/оригинал»
|
||||
Reference in New Issue
Block a user