From abc4e3cef0281b792c2a2f013c99c60601b450d5 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Tue, 20 Oct 2026 23:49:17 +0300 Subject: [PATCH] Strap usage tracking: accrue real HR-delivering time + battery steps natively The strap's battery % is coarse/laggy (reads 100% for >24h), so to judge real battery life we accumulate the time it actually delivered HR plus every battery step into strap_uptime.json in the foreground service (persistent across sessions). ble_read_strap_uptime / ble_reset_strap_uptime expose and reset it; Settings shows hours-since-charge + steps with a Charged reset button. --- PROJECT_MAP.md | 6 +- .../2026-10-20_strap-uptime-and-unmerge.md | 33 ++++++++++ frontend/js/main.js | 64 +++++++++++++++++++ .../main/java/dev/baldav/app/BleHrService.kt | 47 ++++++++++++++ src-tauri/src/ble.rs | 26 ++++++++ src-tauri/src/lib.rs | 2 +- 6 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 docs/session-logs/2026-10-20_strap-uptime-and-unmerge.md diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 05670d4..b7dc486 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -22,7 +22,7 @@ | Файл | Строк | Назначение | |------|-------|------------| | `js/main.js` | ~5900 | Экраны, навигация, модалки, графики, нутриенты, HR UI, компас (EMA + gimbal lock), ландшафт (3 режима), дороги. `appConfirm`/`appPrompt` (in-app диалоги вместо нативных, в стеке окон). Страница «Сохранённые маршруты», оверлей маршрута на записи и просмотре (`whenMapReady` — await готовности карты, не таймаут). Панель де-джиттера на детали трека (ползунки окно/итерации, живое превью, сохранить/откат, `reopenDetail` — переоткрытие детали без наложения оверлеев). `showRouteDetail` — предпросмотр маршрута (карта+профиль высоты+тап-высоты+фуллскрин), `showRouteEditor` — создание/правка маршрута (тап карты=точка в конец, тап линии=вставка в сегмент, тащить=двигать, тап точки=удалить; `makeMarker` возвращает маркер для push/splice). `appConfirm`/`appPrompt`/`appAlert` — in-app диалоги (нативных `alert/prompt/confirm` не осталось; ` +
+
+
Ремень ЧСС: наработка
+
—
+
+ +
@@ -7834,6 +7871,33 @@ async function renderSettings(container) { let dontAskGps = container.querySelector("#s-dontask-gps"); if (dontAskGps) dontAskGps.onchange = () => { try { localStorage.setItem("baldav_dontask_gps", dontAskGps.checked ? "1" : "0"); } catch {} }; + // Strap usage readout: total HR-delivering time since last "charged" reset + battery steps, + // so we can judge real battery life against the claimed 300h (the % reading is coarse/laggy). + let strapDesc = container.querySelector("#s-strap-usage-desc"); + let fillStrapUsage = async () => { + if (!strapDesc) return; + try { + let raw = await window.__TAURI__.core.invoke("ble_read_strap_uptime"); + let u = JSON.parse(raw || "{}"); + let sec = Math.round(u.active_sec || 0); + let h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60); + let bat = (u.bat != null) ? `${u.bat}%` : "?"; + let steps = Array.isArray(u.steps) ? u.steps : []; + let since = u.since ? new Date(u.since * 1000).toLocaleDateString("ru-RU") : "—"; + let stepStr = steps.length + ? " · шаги: " + steps.map(s => `${s.bat}%`).join("->") + : " · шагов заряда нет (ещё 100%?)"; + strapDesc.textContent = `заряд ${bat} · в работе ${h}ч ${m}м с ${since}${stepStr}`; + } catch (e) { strapDesc.textContent = "нет данных"; } + }; + fillStrapUsage(); + let strapReset = container.querySelector("#s-strap-reset"); + if (strapReset) strapReset.onclick = async () => { + if (!(await appConfirm("Сбросить счётчик наработки ремня (отметить как заряженный)?", "Сбросить"))) return; + try { await window.__TAURI__.core.invoke("ble_reset_strap_uptime"); } catch {} + fillStrapUsage(); + }; + let bleScanBtn = container.querySelector("#s-ble-scan"); let bleResults = container.querySelector("#s-ble-results"); if (bleScanBtn) { 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 8ded889..401e8e2 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 @@ -20,6 +20,8 @@ import android.os.ParcelUuid import android.util.Log import java.io.File import java.util.UUID +import org.json.JSONArray +import org.json.JSONObject class BleHrService : Service() { @@ -65,6 +67,13 @@ class BleHrService : Service() { private var stopping = false // user / BT-off asked to stop — never auto-reconnect private var reconnectTries = 0 private val maxReconnect = 3 // ride out a brief drop, then give up (don't hammer an absent strap) + // Strap usage/battery accounting — persistent across sessions (strap_uptime.json), so we can + // compute real battery life ourselves and see the battery-level steps over time. + private var uptimeFile: File? = null + private var lastReadingMs = 0L // for accruing only time the strap was actually delivering HR + private var pendingActiveMs = 0L + private var lastUptimeFlushMs = 0L + private var lastBatLogged = -1 private var lastBattery: Int = -1 // strap charge %, -1 = unknown private var scanResultsFile: File? = null private var fitproWriteChar: BluetoothGattCharacteristic? = null @@ -124,6 +133,7 @@ class BleHrService : Service() { val appDataDir = filesDir.parentFile ?: filesDir dataFile = File(appDataDir, "ble_hr_data.json") logFile = File(appDataDir, "ble_hr_log.jsonl") + uptimeFile = File(appDataDir, "strap_uptime.json") // fresh user-initiated connect: arm reconnect only after it actually connects lastAddress = address; stopping = false; connectedOnce = false; reconnectTries = 0 connectDevice(address) @@ -528,11 +538,48 @@ class BleHrService : Service() { // 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) } + accrueUptime() } } + // Accrue strap "actually delivering HR" time + battery-level steps into strap_uptime.json + // (persistent — NOT cleared per session). Flushed every ~20s (read-modify-write, small file). + private fun accrueUptime() { + val now = System.currentTimeMillis() + val dt = now - lastReadingMs + if (lastReadingMs > 0L && dt in 1..10000) pendingActiveMs += dt // only count gap-free stretches + lastReadingMs = now + if (now - lastUptimeFlushMs < 20000) return + writeUptimeFile() + } + + // Persist accrued active time + battery step to strap_uptime.json (read-modify-write). + private fun writeUptimeFile() { + val now = System.currentTimeMillis() + lastUptimeFlushMs = now + val f = uptimeFile ?: return + try { + val obj = if (f.exists() && f.length() > 0) JSONObject(f.readText()) else JSONObject() + obj.put("active_sec", obj.optDouble("active_sec", 0.0) + pendingActiveMs / 1000.0) + if (!obj.has("since")) obj.put("since", now / 1000) + val bat = lastBattery + if (bat in 0..100) { + obj.put("bat", bat) + if (bat != lastBatLogged) { + val steps = obj.optJSONArray("steps") ?: JSONArray() + steps.put(JSONObject().put("ts", now / 1000).put("bat", bat)) + obj.put("steps", steps) + lastBatLogged = bat + } + } + f.writeText(obj.toString()) + pendingActiveMs = 0L + } catch (e: Exception) { Log.e(TAG, "uptime flush: ${e.message}") } + } + private fun disconnect() { isRunning = false + try { writeUptimeFile() } catch (e: Exception) {} // persist the last accrued strap time handler.removeCallbacksAndMessages(null) // no stray callback may touch GATT after this try { gatt?.disconnect() } catch (e: Exception) { Log.e(TAG, "gatt.disconnect: ${e.message}") } try { gatt?.close() } catch (e: Exception) { Log.e(TAG, "gatt.close: ${e.message}") } diff --git a/src-tauri/src/ble.rs b/src-tauri/src/ble.rs index 24ed2de..1e33525 100644 --- a/src-tauri/src/ble.rs +++ b/src-tauri/src/ble.rs @@ -477,6 +477,32 @@ pub fn ble_read_hr_log(app: tauri::AppHandle) -> Result { { Ok(android::read_file(&app, "ble_hr_log.jsonl").unwrap_or_default()) } } +// Strap usage accounting (strap_uptime.json): total time the strap actually delivered HR plus +// battery-level steps, accumulated by the foreground service across sessions (persistent — so we +// can compute real battery life ourselves). Returns "{}" if none yet. +#[tauri::command] +pub fn ble_read_strap_uptime(app: tauri::AppHandle) -> Result { + #[cfg(not(target_os = "android"))] + { let _ = &app; Ok("{}".to_string()) } + #[cfg(target_os = "android")] + { Ok(android::read_file(&app, "strap_uptime.json").unwrap_or_else(|| "{}".to_string())) } +} + +// Reset the usage counter — call when the strap is put on charge / newly charged. +#[tauri::command] +pub fn ble_reset_strap_uptime(app: tauri::AppHandle) -> Result<(), String> { + use tauri::Manager; + let data_dir = app.path().app_data_dir().map_err(|e: tauri::Error| e.to_string())?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs(); + let fresh = format!("{{\"active_sec\":0,\"since\":{},\"steps\":[]}}", now); + let _ = std::fs::write(data_dir.join("strap_uptime.json"), &fresh); + if let Some(root) = data_dir.parent().and_then(|p| p.parent()) { + let _ = std::fs::write(root.join("strap_uptime.json"), &fresh); + } + Ok(()) +} + #[tauri::command] pub fn ble_is_connected( app: tauri::AppHandle, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 242647f..e2145bd 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_read_hr_log, 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_read_strap_uptime, ble::ble_reset_strap_uptime, ble::ble_request_enable, read_state_file]) .run(tauri::generate_context!()) .expect("error while running tauri application"); }