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:
parent
85d5e47972
commit
ee47af4479
6 changed files with 98 additions and 5 deletions
|
|
@ -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/`)
|
||||
|
|
|
|||
36
docs/session-logs/2026-10-20_native-hr-log.md
Normal file
36
docs/session-logs/2026-10-20_native-hr-log.md
Normal 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 компиляция - в сборке. На телефоне: старт воркаута "зарядка
|
||||
(пульс)", экран гаснет на ночь, утром стоп -> сэмплы должны быть сплошные (из нативного лога),
|
||||
включая сон. Живую проверку делает пользователь ночью.
|
||||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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"))]
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue