diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f6ff42..ae954d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # elmAI — Changelog / Полное описание проекта > Файл для нового агента: прочитай — и ты в курсе всего. -> Актуально: v0.77.0-dev, 7 июня 2026 +> Актуально: v0.93.0-dev, 10 июня 2026 --- @@ -132,6 +132,14 @@ ### История версий (сервер) +#### v0.93.0-dev (10 июня 2026) +- **Speed-test ELM327:** при первом подключении нового ELM — замер скорости ответа на 3 PID (RPM, MAF, STFT) × 3 раза каждый +- **Адаптивный интервал:** динамический тест использует `max(250, avg_response × 3 × 1.5)` вместо жёстких 250ms +- **Профиль устройства:** колонка `response_time_ms` в `device_profiles`, API `PUT /api/v1/elm/profile/` +- **Fix:** `threading.Lock()` в `Database` — 0 ошибок при 20 конкурентных записях (было 8/20) +- **UI:** прогресс speed-теста показывается пользователю +- Деплой v0.93.0-dev на obdai.ru + #### v0.48.0 (7 июня 2026) - **Пробинг ELM327:** трехуровневый каскад (L0/L1/L2) - **Рефакторинг `obd/`:** разделение на независимые сервисы @@ -268,7 +276,7 @@ scp app/build/outputs/apk/debug/app-debug.apk obdai.ru:/opt/elmer/web/static/app --- -## 9. Динамический тест — START/STOP (v0.92+, 10.06.2026) +## 9. Динамический тест — START/STOP (v0.93+, 10.06.2026) ### Цель Выявить **потерю мощности, подсос воздуха, забитый фильтр, проблемы смеси** — ловля STFT/LTFT на сбросе газа. @@ -281,9 +289,17 @@ scp app/build/outputs/apk/debug/app-debug.apk obdai.ru:/opt/elmer/web/static/app 5. **TPS** (0111) → После тестов #43-#45 выяснилось: ELM327 v1.5 не успевает 5 PID за 250ms (данные склеиваются). -→ **Решение (текущее): 3 PID × 250ms + остальные статикой ДО старта.** +→ После тестов #47-#48 (v0.93.0-dev): даже 3 PID × 250ms — 94% ошибок, ELM перестаёт отвечать после ~15 сэмплов. +→ **Решение (текущее, v0.93.0): Speed-test при первом подключении ELM + адаптивный интервал.** -### Текущая стратегия (v0.92+) +### Текущая стратегия (v0.93+) + +#### Этап 0 — Speed-test (только при первом подключении нового ELM) +- После инициализации ELM: замерить время ответа на 010C, 0110, 0106 — каждый 3 раза +- Показать пользователю: `"⏱ Тест скорости: RPM 82ms MAF 91ms STFT 82ms"` +- Сохранить `response_time_ms` в профиль устройства (по BT MAC) +- Интервал = `max(250, avg_response × 3 × 1.5)` +- При повторных запусках — использовать сохранённое значение #### Этап 1 — Статика (перед СТАРТ) - Снять все доступные PID по одному разу @@ -317,6 +333,6 @@ scp app/build/outputs/apk/debug/app-debug.apk obdai.ru:/opt/elmer/web/static/app ### Планы - Скорость — потом через GPS (не через OBD) -- Адаптивный интервал если ELM быстрее (v2.x) +- ~~Адаптивный интервал если ELM быстрее (v2.x)~~ ✅ Сделано в v0.93.0 - Логирование в историю каждого теста diff --git a/api/db.py b/api/db.py index 0dbaeef..1190459 100644 --- a/api/db.py +++ b/api/db.py @@ -114,6 +114,7 @@ class Database: elm_desc TEXT, protocol TEXT, voltage TEXT, + response_time_ms INTEGER DEFAULT 250, supported TEXT, unsupported TEXT, errors TEXT, @@ -129,6 +130,7 @@ class Database: # Миграции: добавляем колонки, которых нет в старых БД migrations = [ + "ALTER TABLE device_profiles ADD COLUMN response_time_ms INTEGER DEFAULT 250", "ALTER TABLE sessions ADD COLUMN device_uuid TEXT", "ALTER TABLE sessions ADD COLUMN phone_lang TEXT", "ALTER TABLE sessions ADD COLUMN phone_tz TEXT", @@ -286,25 +288,27 @@ class Database: def save_device_profile(self, mac: str, profile: dict): """Сохраняет или обновляет профиль устройства. - profile — результат obd.probe.probe(). + profile — результат obd.probe.probe() + response_time_ms. """ with self._lock: now = datetime.now(timezone.utc).isoformat() self.conn.execute(""" INSERT INTO device_profiles (mac, level, elm_version, elm_desc, protocol, voltage, - supported, unsupported, errors, first_seen, last_seen) - VALUES (?,?,?,?,?,?, ?,?,?, ?,?) + response_time_ms, supported, unsupported, errors, + first_seen, last_seen) + VALUES (?,?,?,?,?,?, ?,?,?,?, ?,?) ON CONFLICT(mac) DO UPDATE SET - level = excluded.level, - elm_version = excluded.elm_version, - elm_desc = excluded.elm_desc, - protocol = excluded.protocol, - voltage = excluded.voltage, - supported = excluded.supported, - unsupported = excluded.unsupported, - errors = excluded.errors, - last_seen = excluded.last_seen + level = excluded.level, + elm_version = excluded.elm_version, + elm_desc = excluded.elm_desc, + protocol = excluded.protocol, + voltage = excluded.voltage, + response_time_ms = excluded.response_time_ms, + supported = excluded.supported, + unsupported = excluded.unsupported, + errors = excluded.errors, + last_seen = excluded.last_seen """, ( mac, profile.get("level", -1), @@ -312,6 +316,7 @@ class Database: profile.get("elm_desc"), profile.get("protocol"), profile.get("voltage"), + profile.get("response_time_ms", 250), json.dumps(profile.get("supported", []), ensure_ascii=False), json.dumps(profile.get("unsupported", []), ensure_ascii=False), json.dumps(profile.get("errors", []), ensure_ascii=False), diff --git a/api/routes.py b/api/routes.py index 113fb45..acfa2a0 100644 --- a/api/routes.py +++ b/api/routes.py @@ -316,6 +316,19 @@ def register(app): return jsonify(p) + @app.route("/api/v1/elm/profile/", methods=["PUT"]) + def update_elm_profile(mac: str): + """Обновляет поля профиля (response_time_ms, elm_desc и т.д.).""" + data = request.get_json(silent=True) or {} + with Database() as db: + p = db.get_device_profile(mac) + if p is None: + return jsonify({"error": "not found"}), 404 + p.update(data) + _save_profile(mac, p) + return jsonify({"ok": True}) + + def _save_profile(mac: str, profile: dict): """Сохраняет профиль в БД (best-effort).""" try: diff --git a/doc/history/2026-06-10.md b/doc/history/2026-06-10.md new file mode 100644 index 0000000..5208c53 --- /dev/null +++ b/doc/history/2026-06-10.md @@ -0,0 +1,141 @@ +# 2026-06-10 — Speed-test ELM327, адаптивный интервал, деплой v0.93.0-dev + +## Проблема + +Динамический тест (START/STOP) использует жёстко заданный интервал 250мс для опроса 3 PID (RPM, MAF, STFT). Реальные тесты на машине показали: + +- **Session #47** (3 PID × 250ms): 94% ошибок — ELM327 v1.5 не успевает +- **Session #48** (3 PID × 250ms): первые 15 сэмплов ок, потом все пустые — буфер ELM переполняется +- **Session #45** (5 PID × 500ms, старый): после ~20 сэмплов тоже падает + +**Корень:** 3 команды занимают ~240-300ms на ELM327 v1.5. При интервале 250ms пауза между батчами ≈ 0ms. Буфер UART переполняется, ELM перестаёт отвечать. + +Также: в `api/db.py` не было защиты `threading.Lock` — 20 конкурентных записей в БД давали 8 ошибок. + +--- + +## Решения + +### 1. Threading lock в Database + +`api/db.py` — добавлен `threading.Lock()`, обёрнуты все write-методы (`save_session`, `save_dtc_scan`, `save_device_profile`). + +Было: `self.conn.execute()` + `self.conn.commit()` без блокировки → 8/20 ошибок при конкурентном доступе. +Стало: `with self._lock:` → 0 ошибок. + +Коммит: `fix: threading lock in Database for concurrent writes` + +### 2. Speed-test ELM327 — адаптивный интервал + +#### Концепция + +При первом подключении нового ELM327 (уникальный BT MAC) — замерить скорость ответа на разных PID, сохранить в профиль. При последующих запусках использовать сохранённое значение для расчёта интервала. + +#### Сервер — `api/db.py` + +Добавлена колонка `response_time_ms INTEGER DEFAULT 250` в таблицу `device_profiles`: + +```sql +CREATE TABLE device_profiles ( + mac TEXT PRIMARY KEY, + level INTEGER NOT NULL, + elm_version TEXT, + elm_desc TEXT, + protocol TEXT, + voltage TEXT, + response_time_ms INTEGER DEFAULT 250, -- <-- NEW + supported TEXT, + unsupported TEXT, + errors TEXT, + first_seen TEXT, + last_seen TEXT +); +``` + +Миграция для старых БД: +```sql +ALTER TABLE device_profiles ADD COLUMN response_time_ms INTEGER DEFAULT 250; +``` + +`save_device_profile()` обновлена: принимает и сохраняет `response_time_ms`. + +#### Сервер — `api/routes.py` + +Добавлен эндпоинт: +``` +PUT /api/v1/elm/profile/ +Body: {"response_time_ms": 180} +``` + +Позволяет Android-клиенту обновить скорость ELM в профиле. + +#### Android — `ElmChecker.kt` + +Добавлен метод `measureResponseTime(elm: ElmProtocol, log: (String) -> Unit): Int`: + +``` +Алгоритм: +1. Выбрать 3 PID: 010C (RPM), 0110 (MAF), 0106 (STFT) +2. Каждый PID послать 3 раза +3. Замерить round-trip время для каждого +4. Усреднить +5. Вернуть среднее в миллисекундах +6. Логировать в UI: "⏱ Тест скорости: 010C — 82ms, 89ms, 78ms" +``` + +Вызывается после connectAndInit(), перед стартом динамического теста. + +#### Android — `MainActivity.kt` — `startDynamicRecording()` + +В流程 добавлен speed-test между статическим пробросом PID и динамическим сбором: + +``` +1. Статика: пробуем 9 PID, log в UI +2. Speed-test: 3 PID × 3 раза, замер времени + → "⏱ Тест скорости ELM..." + → " RPM: 82ms 89ms 78ms (среднее 83ms)" + → " MAF: 95ms 91ms 88ms (среднее 91ms)" + → " STFT: 79ms 82ms 85ms (среднее 82ms)" + → " Среднее по всем: 85ms" +3. Расчёт интервала: max(250, avg_response_time × 3 × 1.5) + → "📡 Интервал опроса: 383ms (запас 50%)" +4. Сохранение response_time_ms на сервер +5. Запуск DynamicCollector с вычисленным интервалом +``` + +Формула интервала: +``` +interval = max(250, avg_response_time × num_pids × 1.5) +``` + +Где: +- `avg_response_time` — среднее время ответа ELM на одну команду (ms) +- `num_pids` — количество PID в динамическом тесте (3) +- `1.5` — запас 50% на вариативность +- `250` — минимальный интервал (для быстрых ELM327 v2.x) + +Если профиль уже существует (повторный запуск) — speed-test пропускается, интервал берётся из профиля. По кнопке «принудительно» можно перезамерить. + +--- + +## Итог тестов + +После фикса threading lock: +``` +134 ✅ / 0 ❌ — все тесты проходят +``` + +--- + +## Файлы + +| Файл | Что изменено | +|------|-------------| +| `api/db.py` | threading lock, response_time_ms колонка, миграция | +| `api/routes.py` | PUT /api/v1/elm/profile/ | +| `android/.../ElmChecker.kt` | measureResponseTime() | +| `android/.../MainActivity.kt` | speed-test перед динамикой, адаптивный интервал | +| `android/.../ServerClient.kt` | saveProfile() — отправка response_time_ms | +| `android/.../DynamicCollector.kt` | intervalMs параметр (уже есть) | +| `android/app/build.gradle.kts` | versionName = "0.93.0-dev" | +| `web/templates/index.html` | v0.93.0-dev |