Workout HR: log samples natively so recording survives screen-off

The JS 2s poll that stored workout HR samples is frozen by Android when the
screen is off, so a sleep-long recording kept only the on-screen stretches
(the strap and live display were fine — the persist path was the gap). The
foreground service now appends every reading to ble_hr_log.jsonl; ble_read_hr_log
exposes it; on stop the workout rebuilds its samples from that log (falling back
to the poll array). ble_clear_hr truncates the log at start.
This commit is contained in:
4aiman 2026-10-20 23:09:50 +03:00
commit ee47af4479
6 changed files with 98 additions and 5 deletions

View file

@ -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<id>` по `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/`)

View file

@ -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 компиляция - в сборке. На телефоне: старт воркаута "зарядка
(пульс)", экран гаснет на ночь, утром стоп -> сэмплы должны быть сплошные (из нативного лога),
включая сон. Живую проверку делает пользователь ночью.

View file

@ -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]);

View file

@ -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() {

View file

@ -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<String, String> {
#[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"))]
{

View file

@ -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");
}