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.
This commit is contained in:
4aiman 2026-10-20 23:49:17 +03:00
commit abc4e3cef0
6 changed files with 174 additions and 4 deletions

View file

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

View file

@ -477,6 +477,32 @@ pub fn ble_read_hr_log(app: tauri::AppHandle) -> Result<String, String> {
{ 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<String, String> {
#[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,

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