diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 282944f..45ce42e 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -42,7 +42,7 @@ | Файл | Строк | Назначение | |------|-------|------------| | `src/lib.rs` | ~790 | Миграции v1, IPC команды, seed import, update check, tile import, road map rebuild. `native_root()` — выбор корня приложения по наличию `gps_live_points.jsonl`/`gps_points_*` (на этом Tauri `app_data_dir()` = корень напрямую; старый `.parent().parent()` читал мимо, отсюда потеря фоновых точек). `http_request` (GET/POST, тело) и `chzn_http` (браузерный User-Agent + возврат `{status,body}` для 204/401/427, `http_status_as_error(false)`) — прокси HTTP мимо CORS WebView | -| `src/ble.rs` | ~470 | BLE HR: btleplug (desktop), file IPC (Android), FitPro + Standard HR | +| `src/ble.rs` | ~480 | BLE HR: btleplug (desktop), file IPC (Android), FitPro + Standard HR. `ble_read_hr_log` — читает нативный лог ЧСС `ble_hr_log.jsonl` (пишет foreground-сервис, переживает гаснущий экран); `ble_clear_hr` чистит и его | | `src/main.rs` | 5 | Точка входа | | `Cargo.toml` | — | tauri, tauri-plugin-sql, rusqlite, serde, btleplug, tokio, uuid | | `tauri.conf.json` | — | Конфиг приложения, плагины, ресурсы | @@ -53,7 +53,7 @@ |------|-------|------------| | `MainActivity.kt` | ~300 | FileObserver IPC, seed DB, safe insets, SAF, BLE commands, OTA check | | `GpsRecorderService.kt` | ~530 | Foreground service GPS, WakeLock, JSONL (один файл на сессию до Стоп, идемпотентный onStartCommand при START_STICKY-перезапуске ОС; `bindSessionIfPending()` дописывает id сессии в имя файла `_s` по `gps_session.txt`), 50Hz акселерометр/гироскоп, dead reckoning, raw sensor files | -| `BleHrService.kt` | ~390 | Foreground service BLE HR, FitPro protocol, GATT queue, scan/connect. START_NOT_STICKY; скан не нашёл адрес -> stopSelf (не держит радио). Подключение инициируется ТОЛЬКО вручную (кнопка в настройках), не при старте записи. Выключение BT в любой момент не роняет app: единый `handler` (все postDelayed, снимаются разом), `BroadcastReceiver` на `ACTION_STATE_CHANGED` -> на STATE_OFF чистая остановка (disconnect+stopSelf), все нативные BLE-вызовы (scan/stopScan/connectGatt/writeCharacteristic/discoverServices) в try/catch | +| `BleHrService.kt` | ~390 | Foreground service BLE HR, FitPro protocol, GATT queue, scan/connect. START_NOT_STICKY; скан не нашёл адрес -> stopSelf (не держит радио). Подключение инициируется ТОЛЬКО вручную (кнопка в настройках), не при старте записи. Выключение BT в любой момент не роняет app: единый `handler` (все postDelayed, снимаются разом), `BroadcastReceiver` на `ACTION_STATE_CHANGED` -> на STATE_OFF чистая остановка (disconnect+stopSelf), все нативные BLE-вызовы (scan/stopScan/connectGatt/writeCharacteristic/discoverServices) в try/catch. Каждый замер ЧСС дописывается в `ble_hr_log.jsonl` (append) прямо в сервисе — запись истории переживает гаснущий экран (JS-поллинг в фоне заморожен Android); воркаут при остановке пересобирает сэмплы из этого лога (`GPS._readHrLogSamples`), а не из JS-массива | | `generated/*.kt` | ~1100 | Tauri-сгенерированные: WebView, IPC, permissions | ### Инструменты (`tools/`) diff --git a/docs/session-logs/2026-10-20_native-hr-log.md b/docs/session-logs/2026-10-20_native-hr-log.md new file mode 100644 index 0000000..f884f68 --- /dev/null +++ b/docs/session-logs/2026-10-20_native-hr-log.md @@ -0,0 +1,36 @@ +# Сессия 2026-10-20 - нативный лог ЧСС (запись воркаута переживает гаснущий экран) + +## Причина (из разбора 21ч-прогона эксперимента) +Сессия-воркаут 1792436740262 (19.10 22:05 -> 20.10 17:27) в сохранёнке имела дырки 11.6ч, +НО пользователь видел живые цифры засыпая/просыпаясь/весь день. Проверка: шаг внутри +покрытия ровно 2.0с (это JS-поллинг), границы дыр = сон/утро/обед (экран гас). Вывод: ремень +и дисплей работали; молчал ПЕРСИСТ - историю сэмплов воркаута писал JS setInterval(2с), +который Android замораживает в фоне/при гаснущем экране. Нативная часть клала только ТЕКУЩЕЕ +значение (ble_hr_data.json, overwrite), истории не было. + +## Сделано +Нативный лог истории ЧСС (как GPS-точки пишутся foreground-сервисом): +- Kotlin BleHrService: поле `logFile` = `ble_hr_log.jsonl`; в `writeData` каждая строка с + `"hr":` дописывается (appendText) в лог. Пишет сам foreground-сервис -> переживает + гаснущий экран. +- Rust ble.rs: команда `ble_read_hr_log` (читает лог), `ble_clear_hr` теперь чистит и лог. + Зарегистрировано в lib.rs invoke_handler. +- JS gps.js: `GPS._readHrLogSamples()` - парсит лог в [[relSec,bpm]] относительно startTime + (фильтр hr>0, rel>=0, дедуп по секунде, сортировка). На остановке воркаута сэмплы берутся + из лога, если он полнее JS-массива (fallback на JS-массив, если лог пуст). Чтение - ДО + `_stopHrMonitor` (который чистит лог). Лог чистится при старте воркаута (ble_clear_hr, 1453). + +## Не делал (осознанно, по риску разовой ночной записи) +Авто-реконнект ремня при обрыве BLE - НЕ добавлял сейчас. Причина дыр была в заморозке JS, +а не в обрыве BLE (днём была длинная непрерывная связь). Ночью пользователь запишет сон - +это проверит, рвётся ли BLE на самом деле. Если нативный лог покажет обрыв среди ночи - +тогда добавим реконнект. Иначе он не нужен. + +## Также отложено +Разделение склеенного велотрека 1792506450721 (3 поездки по orig_session_id: 17:27-17:57, +18:00-18:32, 18:44-18:49) - одобрено, но отложено ради срочного ночного фикса. + +## Проверка +Синтаксис JS ок. Rust/Kotlin компиляция - в сборке. На телефоне: старт воркаута "зарядка +(пульс)", экран гаснет на ночь, утром стоп -> сэмплы должны быть сплошные (из нативного лога), +включая сон. Живую проверку делает пользователь ночью. diff --git a/frontend/js/gps.js b/frontend/js/gps.js index 1e6e8db..6480192 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -881,6 +881,33 @@ GPS._stopHrMonitor = function() { try { window.__TAURI__.core.invoke("ble_clear_hr"); } catch {} }; +// Rebuild the workout's HR series from the NATIVE log (ble_hr_log.jsonl), which the +// foreground service appends to even with the screen off — unlike the JS poll, which +// Android freezes in the background (so a sleep-long workout kept only the on-screen bits). +// Returns [[relSec, bpm], …] relative to the recording start, sorted and de-duplicated, +// or [] if the log is empty/unavailable. +GPS._readHrLogSamples = async function() { + if (!GPS.recording.startTime) return []; + let raw = ""; + try { raw = await window.__TAURI__.core.invoke("ble_read_hr_log"); } catch { return []; } + if (!raw) return []; + const startSec = GPS.recording.startTime.getTime() / 1000; + const out = []; + let lastRel = -1; + for (const line of raw.split("\n")) { + const s = line.trim(); + if (!s) continue; + let o; try { o = JSON.parse(s); } catch { continue; } // skip a torn last line + if (!o || !(o.hr > 0) || o.ts == null) continue; + const rel = Math.round(o.ts - startSec); + if (rel < 0 || rel === lastRel) continue; // before start, or same-second dup + out.push([rel, o.hr]); + lastRel = rel; + } + out.sort((a, b) => a[0] - b[0]); + return out; +}; + // Turn HR on/off during a recording (the toggle on the record screen). ON connects to the // paired strap (Kotlin scans ~5s and stops cleanly if it's absent — so no constant scanning / // drain when the strap isn't there) and polls; OFF stops the poll and disconnects. @@ -1550,7 +1577,13 @@ GPS.stopRecording = async function() { // HR-only workout: no GPS to stop. Compute net kcal from HR, save samples, finalise. if (GPS.recording.activityType === "workout") { - const samples = GPS.recording.hrSamples || []; + // Prefer the native HR log (complete through screen-off) over the JS poll's in-memory + // array (frozen in the background). Fall back to the poll array if the log is empty. + let samples = GPS.recording.hrSamples || []; + try { + const native = await GPS._readHrLogSamples(); + if (native.length > samples.length) samples = native; + } catch (e) { console.error("[workout] native hr log read", e); } const kcal = GPS.hrKcalNet(samples, GPS.recording.hrProfile); const durationSec = GPS.recording.startTime ? Math.round((Date.now() - GPS.recording.startTime.getTime()) / 1000) : 0; const hrs = samples.map(s => s[1]); diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt index fb9ec27..dd2c09b 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/BleHrService.kt @@ -59,6 +59,7 @@ class BleHrService : Service() { private var gatt: BluetoothGatt? = null private var deviceType = "hr_standard" private var dataFile: File? = null + private var logFile: File? = null // append-only HR history (ble_hr_log.jsonl); survives screen-off private var lastBattery: Int = -1 // strap charge %, -1 = unknown private var scanResultsFile: File? = null private var fitproWriteChar: BluetoothGattCharacteristic? = null @@ -115,6 +116,7 @@ class BleHrService : Service() { isRunning = true val appDataDir = filesDir.parentFile ?: filesDir dataFile = File(appDataDir, "ble_hr_data.json") + logFile = File(appDataDir, "ble_hr_log.jsonl") connectDevice(address) } return START_NOT_STICKY // connect is user-initiated; no sticky restart @@ -498,6 +500,12 @@ class BleHrService : Service() { } catch (e: Exception) { Log.e(TAG, "Write data failed", e) } + // Append real HR readings (not status lines) to the history log. This runs in the + // foreground service, so it keeps recording with the screen off — the workout + // recorder rebuilds its samples from here (ble_read_hr_log) instead of the JS poll. + if (json.contains("\"hr\":")) { + try { logFile?.appendText(json + "\n") } catch (e: Exception) { Log.e(TAG, "Append hr log failed", e) } + } } private fun disconnect() { diff --git a/src-tauri/src/ble.rs b/src-tauri/src/ble.rs index d20d531..24ed2de 100644 --- a/src-tauri/src/ble.rs +++ b/src-tauri/src/ble.rs @@ -465,6 +465,18 @@ pub fn ble_get_hr( { android::read_hr_data(&app) } } +// Full HR history for the current workout. The Kotlin foreground service appends every +// reading to ble_hr_log.jsonl (one {"hr","ts"[,"bat"]} per line) — that keeps logging with +// the screen off, unlike the JS poll which Android freezes in the background. The workout +// recorder reads this at finalize to rebuild a gap-free sample series. Returns "" if none. +#[tauri::command] +pub fn ble_read_hr_log(app: tauri::AppHandle) -> Result { + #[cfg(not(target_os = "android"))] + { let _ = &app; Ok(String::new()) } + #[cfg(target_os = "android")] + { Ok(android::read_file(&app, "ble_hr_log.jsonl").unwrap_or_default()) } +} + #[tauri::command] pub fn ble_is_connected( app: tauri::AppHandle, @@ -488,10 +500,14 @@ pub fn ble_is_connected( pub fn ble_clear_hr(app: tauri::AppHandle) -> Result<(), String> { use tauri::Manager; let data_dir = app.path().app_data_dir().map_err(|e: tauri::Error| e.to_string())?; - // Clear HR data file so stale values don't show on next session + // Clear HR data file so stale values don't show on next session, and truncate the + // native HR history log (the foreground service appends every reading there — see + // ble_read_hr_log) so a new workout starts from an empty log. let _ = std::fs::write(data_dir.join("ble_hr_data.json"), "{}"); + let _ = std::fs::write(data_dir.join("ble_hr_log.jsonl"), ""); if let Some(root) = data_dir.parent().and_then(|p| p.parent()) { let _ = std::fs::write(root.join("ble_hr_data.json"), "{}"); + let _ = std::fs::write(root.join("ble_hr_log.jsonl"), ""); } #[cfg(not(target_os = "android"))] { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7a8be8c..242647f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1162,7 +1162,7 @@ pub fn run() { .add_migrations("sqlite:baldav.db", migrations) .build(), ) - .invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_ride_graph, get_app_info, gps_service_command, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, export_image, read_file_base64, export_db, import_db, restart_app, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr, ble::ble_request_enable, read_state_file]) + .invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_ride_graph, get_app_info, gps_service_command, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, export_image, read_file_base64, export_db, import_db, restart_app, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr, ble::ble_read_hr_log, ble::ble_request_enable, read_state_file]) .run(tauri::generate_context!()) .expect("error while running tauri application"); }