// BLE Heart Rate module — desktop (btleplug) + Android (Kotlin service + file IPC). use serde::{Serialize, Deserialize}; #[cfg(not(target_os = "android"))] use std::sync::{Arc, Mutex}; #[derive(Serialize, Deserialize, Clone, Debug)] pub struct BleDevice { pub name: String, pub address: String, pub device_type: String, // "hr_standard", "fitpro", "unknown" pub rssi: i32, } #[derive(Serialize, Deserialize, Clone, Debug, Default)] pub struct HrReading { pub hr: u16, #[serde(default)] pub spo2: Option, #[serde(default)] pub bp_sys: Option, #[serde(default)] pub bp_dia: Option, #[serde(alias = "ts", default)] pub timestamp: u64, #[serde(default)] pub device: String, #[serde(alias = "bat", default)] pub battery: Option, } // DIY barometer reading (BaroBalDav): atmospheric pressure in Pa and optional chip temperature. // JS turns pressure into altitude (hypsometric formula, using temp), anchored to GPS at ride start. #[derive(Serialize, Deserialize, Clone, Debug, Default)] pub struct BaroReading { pub pressure_pa: f64, #[serde(default)] pub temp_c: Option, #[serde(alias = "ts", default)] pub timestamp: u64, } // Shared state for BLE operations #[cfg(not(target_os = "android"))] lazy_static::lazy_static! { static ref BLE_STATE: Arc> = Arc::new(Mutex::new(BleState::default())); } #[cfg(not(target_os = "android"))] #[derive(Default)] struct BleState { scan_results: Vec, connected_device: Option, latest_hr: Option, scanning: bool, connected: bool, } // Standard BLE Heart Rate Service UUID const HR_SERVICE_UUID: &str = "0000180d-0000-1000-8000-00805f9b34fb"; const HR_MEASUREMENT_UUID: &str = "00002a37-0000-1000-8000-00805f9b34fb"; // Standard Environmental Sensing — our DIY barometer "BaroBalDav" const ESS_SERVICE: &str = "0000181a-0000-1000-8000-00805f9b34fb"; // FitPro Nordic UART UUIDs const FITPRO_SERVICE: &str = "6e400001-b5a3-f393-e0a9-e50e24dcca9d"; const FITPRO_WRITE: &str = "6e400002-b5a3-f393-e0a9-e50e24dcca9d"; const FITPRO_NOTIFY: &str = "6e400003-b5a3-f393-e0a9-e50e24dcca9d"; // FitPro protocol packets const FITPRO_PAIR: &[u8] = &[0xCD, 0x00, 0x06, 0x12, 0x01, 0x0A, 0x00, 0x01, 0x02]; const FITPRO_HR_SWITCH_ON: &[u8] = &[0xCD, 0x00, 0x06, 0x12, 0x01, 0x0D, 0x00, 0x01, 0x01]; const FITPRO_MEASURE_START: &[u8] = &[0xCD, 0x00, 0x07, 0x12, 0x01, 0x24, 0x00, 0x02, 0x00, 0x01]; const FITPRO_REALTIME_ON: &[u8] = &[0xCD, 0x00, 0x06, 0x15, 0x01, 0x06, 0x00, 0x01, 0x01]; #[cfg(not(target_os = "android"))] mod desktop { use super::*; use btleplug::api::{ Central, Manager as _, Peripheral as _, ScanFilter, CharPropFlags, WriteType, }; use btleplug::platform::{Manager, Peripheral, Adapter}; use std::time::Duration; use uuid::Uuid; fn uuid(s: &str) -> Uuid { Uuid::parse_str(s).unwrap() } async fn get_adapter() -> Result { let manager = Manager::new().await.map_err(|e| format!("BLE manager: {}", e))?; let adapters = manager.adapters().await.map_err(|e| format!("BLE adapters: {}", e))?; adapters.into_iter().next().ok_or_else(|| "No BLE adapter found".to_string()) } fn detect_device_type(name: &str, services: &[Uuid]) -> String { // Our DIY barometer advertises standard Environmental Sensing if services.contains(&uuid(ESS_SERVICE)) { return "baro".to_string(); } // Check for standard HR service if services.contains(&uuid(HR_SERVICE_UUID)) { return "hr_standard".to_string(); } // Check for FitPro Nordic UART if services.contains(&uuid(FITPRO_SERVICE)) { return "fitpro".to_string(); } // Heuristic by name let lower = name.to_lowercase(); if lower.contains("fitpro") || lower.contains("band") || lower.contains("m7") || lower.contains("m8") { return "fitpro".to_string(); } if lower.contains("coospo") || lower.contains("polar") || lower.contains("garmin") || lower.contains("wahoo") || lower.contains("hr") { return "hr_standard".to_string(); } "unknown".to_string() } pub async fn scan_devices(duration_secs: u64) -> Result, String> { { let mut state = BLE_STATE.lock().unwrap(); state.scanning = true; state.scan_results.clear(); } let adapter = get_adapter().await?; adapter.start_scan(ScanFilter::default()) .await.map_err(|e| format!("Scan start: {}", e))?; tokio::time::sleep(Duration::from_secs(duration_secs)).await; adapter.stop_scan().await.map_err(|e| format!("Scan stop: {}", e))?; let peripherals = adapter.peripherals().await.map_err(|e| format!("Peripherals: {}", e))?; let mut devices = Vec::new(); for p in peripherals { if let Ok(Some(props)) = p.properties().await { let name = props.local_name.unwrap_or_default(); if name.is_empty() { continue; } let services = props.services; let device_type = detect_device_type(&name, &services); let rssi = props.rssi.unwrap_or(0) as i32; let address = p.address().to_string(); devices.push(BleDevice { name, address, device_type, rssi }); } } { let mut state = BLE_STATE.lock().unwrap(); state.scan_results = devices.clone(); state.scanning = false; } Ok(devices) } pub async fn connect_device(address: &str, device_type: &str) -> Result { let adapter = get_adapter().await?; let peripherals = adapter.peripherals().await.map_err(|e| format!("{}", e))?; let peripheral = peripherals.into_iter() .find(|p| p.address().to_string() == address) .ok_or_else(|| format!("Device {} not found. Run scan first.", address))?; peripheral.connect().await.map_err(|e| format!("Connect: {}", e))?; peripheral.discover_services().await.map_err(|e| format!("Discover: {}", e))?; match device_type { "hr_standard" => setup_standard_hr(&peripheral).await?, "fitpro" => setup_fitpro(&peripheral).await?, _ => return Err(format!("Unknown device type: {}", device_type)), } { let mut state = BLE_STATE.lock().unwrap(); state.connected = true; state.connected_device = Some(address.to_string()); } // Spawn background task to read notifications let dev_type = device_type.to_string(); tokio::spawn(async move { read_notifications(peripheral, &dev_type).await; }); Ok(format!("Connected to {}", address)) } async fn setup_standard_hr(peripheral: &Peripheral) -> Result<(), String> { let chars = peripheral.characteristics(); let hr_char = chars.iter() .find(|c| c.uuid == uuid(HR_MEASUREMENT_UUID) && c.properties.contains(CharPropFlags::NOTIFY)) .ok_or("HR measurement characteristic not found")?; peripheral.subscribe(hr_char).await.map_err(|e| format!("Subscribe HR: {}", e))?; Ok(()) } async fn setup_fitpro(peripheral: &Peripheral) -> Result<(), String> { let chars = peripheral.characteristics(); let write_char = chars.iter() .find(|c| c.uuid == uuid(FITPRO_WRITE)) .ok_or("FitPro write characteristic not found")?.clone(); let notify_char = chars.iter() .find(|c| c.uuid == uuid(FITPRO_NOTIFY)) .ok_or("FitPro notify characteristic not found")?; // Subscribe to notifications first peripheral.subscribe(notify_char).await.map_err(|e| format!("Subscribe: {}", e))?; tokio::time::sleep(Duration::from_millis(500)).await; // Send PAIR command peripheral.write(&write_char, FITPRO_PAIR, WriteType::WithoutResponse) .await.map_err(|e| format!("PAIR: {}", e))?; tokio::time::sleep(Duration::from_millis(1000)).await; // Enable HR switch peripheral.write(&write_char, FITPRO_HR_SWITCH_ON, WriteType::WithoutResponse) .await.map_err(|e| format!("HR switch: {}", e))?; tokio::time::sleep(Duration::from_millis(500)).await; // Enable realtime data peripheral.write(&write_char, FITPRO_REALTIME_ON, WriteType::WithoutResponse) .await.map_err(|e| format!("Realtime: {}", e))?; tokio::time::sleep(Duration::from_millis(500)).await; // Start measurement peripheral.write(&write_char, FITPRO_MEASURE_START, WriteType::WithoutResponse) .await.map_err(|e| format!("Measure: {}", e))?; Ok(()) } async fn read_notifications(peripheral: Peripheral, device_type: &str) { use btleplug::api::Peripheral as _; use tokio_stream::StreamExt; let mut stream = match peripheral.notifications().await { Ok(s) => s, Err(e) => { eprintln!("[BLE] notification stream error: {}", e); return; } }; while let Some(notification) = stream.next().await { let data = ¬ification.value; let reading = match device_type { "hr_standard" => parse_standard_hr(data), "fitpro" => parse_fitpro_reading(data), _ => None, }; if let Some(mut r) = reading { r.timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default().as_secs(); r.device = device_type.to_string(); let mut state = BLE_STATE.lock().unwrap(); state.latest_hr = Some(r); } } // Stream ended — device disconnected let mut state = BLE_STATE.lock().unwrap(); state.connected = false; state.connected_device = None; } fn parse_standard_hr(data: &[u8]) -> Option { if data.is_empty() { return None; } let flags = data[0]; let hr = if flags & 0x01 == 0 { *data.get(1)? as u16 } else { if data.len() < 3 { return None; } u16::from_le_bytes([data[1], data[2]]) }; if hr == 0 { return None; } Some(HrReading { hr, ..Default::default() }) } fn parse_fitpro_reading(data: &[u8]) -> Option { if data.len() < 8 || data[0] != 0xCD { return None; } let cmd_id = data[3]; let key_id = data[5]; // Measure Day Data (cmd=21, key=14): [date 2b][?? 2b][ts 4b][HR][BP_dia][BP_sys][SpO2] if cmd_id == 21 && key_id == 14 && data.len() >= 20 { let hr = data[16]; let bp_dia = data[17]; let bp_sys = data[18]; let spo2 = data[19]; if hr > 30 && hr < 220 { return Some(HrReading { hr: hr as u16, bp_dia: if bp_dia > 0 { Some(bp_dia) } else { None }, bp_sys: if bp_sys > 0 { Some(bp_sys) } else { None }, spo2: if spo2 > 0 && spo2 <= 100 { Some(spo2) } else { None }, ..Default::default() }); } } // Realtime data (cmd=21, key=11) if cmd_id == 21 && key_id == 11 && data.len() >= 12 { for &byte in &data[8..] { if byte > 40 && byte < 200 { return Some(HrReading { hr: byte as u16, ..Default::default() }); } } } None } pub async fn disconnect_device() -> Result { let address = { let state = BLE_STATE.lock().unwrap(); state.connected_device.clone() }; if let Some(addr) = address { let adapter = get_adapter().await?; let peripherals = adapter.peripherals().await.map_err(|e| format!("{}", e))?; if let Some(p) = peripherals.into_iter().find(|p| p.address().to_string() == addr) { let _ = p.disconnect().await; } } let mut state = BLE_STATE.lock().unwrap(); state.connected = false; state.connected_device = None; state.latest_hr = None; Ok("Disconnected".to_string()) } pub fn get_latest_hr() -> Option { BLE_STATE.lock().unwrap().latest_hr.clone() } pub fn is_connected() -> bool { BLE_STATE.lock().unwrap().connected } } // --- Tauri commands --- // --- Android: BLE via Kotlin service + file IPC --- #[cfg(target_os = "android")] mod android { use super::*; use tauri::Manager; use std::path::PathBuf; fn get_data_dir(app: &tauri::AppHandle) -> Result { app.path().app_data_dir().map_err(|e: tauri::Error| e.to_string()) } fn app_root(data_dir: &std::path::Path) -> Option { data_dir.parent().and_then(|p| p.parent()).map(|r| r.to_path_buf()) } pub fn send_command(app: &tauri::AppHandle, cmd: &str) -> Result<(), String> { let data_dir = get_data_dir(app)?; // Use gps_command.txt — the only file that FileObserver reliably watches let cmd_path = data_dir.join("gps_command.txt"); std::fs::write(&cmd_path, cmd).map_err(|e| e.to_string())?; if let Some(root) = app_root(&data_dir) { let _ = std::fs::write(root.join("gps_command.txt"), cmd); } Ok(()) } pub fn read_file(app: &tauri::AppHandle, filename: &str) -> Option { let data_dir = get_data_dir(app).ok()?; let paths: Vec = vec![ data_dir.join(filename), app_root(&data_dir).map(|r| r.join(filename)).unwrap_or_default(), ]; for p in &paths { if p.exists() { if let Ok(s) = std::fs::read_to_string(p) { if !s.trim().is_empty() { return Some(s); } } } } None } pub fn read_scan_results(app: &tauri::AppHandle) -> Result, String> { if let Some(s) = read_file(app, "ble_scan_results.json") { if let Ok(devices) = serde_json::from_str::>(&s) { return Ok(devices); } } Ok(vec![]) } pub fn read_hr_data(app: &tauri::AppHandle) -> Result, String> { if let Some(s) = read_file(app, "ble_hr_data.json") { if let Ok(r) = serde_json::from_str::(&s) { if r.hr > 0 { return Ok(Some(r)); } } } Ok(None) } pub fn read_baro_data(app: &tauri::AppHandle) -> Result, String> { if let Some(s) = read_file(app, "ble_baro_data.json") { if let Ok(r) = serde_json::from_str::(&s) { if r.pressure_pa > 0.0 { return Ok(Some(r)); } } } Ok(None) } } // --- Tauri commands --- #[tauri::command] pub async fn ble_scan( app: tauri::AppHandle, duration_secs: Option, ) -> Result, String> { #[cfg(not(target_os = "android"))] { let _ = &app; desktop::scan_devices(duration_secs.unwrap_or(5)).await } #[cfg(target_os = "android")] { let _ = duration_secs; android::send_command(&app, "ble_scan")?; // Return immediately — JS will call ble_scan_results after delay Ok(vec![]) } } #[tauri::command] pub fn ble_scan_results(app: tauri::AppHandle) -> Result, String> { #[cfg(not(target_os = "android"))] { let _ = &app; Ok(vec![]) } // desktop returns results directly from ble_scan #[cfg(target_os = "android")] { android::read_scan_results(&app) } } #[tauri::command] pub async fn ble_connect( app: tauri::AppHandle, address: String, device_type: String, ) -> Result { #[cfg(not(target_os = "android"))] { let _ = &app; desktop::connect_device(&address, &device_type).await } #[cfg(target_os = "android")] { android::send_command(&app, &format!("ble_connect:{}:{}", address, device_type))?; Ok(format!("Connecting to {}", address)) } } #[tauri::command] pub async fn ble_disconnect( app: tauri::AppHandle, ) -> Result { #[cfg(not(target_os = "android"))] { let _ = &app; desktop::disconnect_device().await } #[cfg(target_os = "android")] { android::send_command(&app, "ble_disconnect")?; Ok("Disconnecting".to_string()) } } // Ask the OS to turn Bluetooth on (system ACTION_REQUEST_ENABLE dialog). BLUETOOTH_CONNECT is // already granted, so no extra permission. Called when the user taps "Подключить" with BT off. #[tauri::command] pub async fn ble_request_enable( app: tauri::AppHandle, ) -> Result { #[cfg(not(target_os = "android"))] { let _ = &app; Ok("desktop: no-op".to_string()) } #[cfg(target_os = "android")] { android::send_command(&app, "ble_request_enable")?; Ok("Requesting enable".to_string()) } } #[tauri::command] pub fn ble_get_hr( app: tauri::AppHandle, ) -> Result, String> { #[cfg(not(target_os = "android"))] { let _ = &app; Ok(desktop::get_latest_hr()) } #[cfg(target_os = "android")] { android::read_hr_data(&app) } } // Latest barometer reading (BaroBalDav). Android: read from the file the Kotlin BLE service writes. // Desktop: not wired (btleplug baro path not implemented) — returns None. #[tauri::command] pub fn ble_get_baro( app: tauri::AppHandle, ) -> Result, String> { #[cfg(not(target_os = "android"))] { let _ = &app; Ok(None) } #[cfg(target_os = "android")] { android::read_baro_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()) } } // 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, ) -> Result { #[cfg(not(target_os = "android"))] { let _ = &app; Ok(desktop::is_connected()) } #[cfg(target_os = "android")] { match android::read_hr_data(&app) { Ok(Some(r)) => { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs(); Ok(now - r.timestamp < 30) } _ => Ok(false) } } } #[tauri::command] 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, 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"))] { let mut state = BLE_STATE.lock().unwrap(); state.latest_hr = None; } Ok(()) } #[tauri::command] pub fn ble_save_device(app: tauri::AppHandle, address: String, device_type: String) -> Result<(), String> { use tauri::Manager; let data_dir = app.path().app_data_dir().map_err(|e: tauri::Error| e.to_string())?; let json = format!(r#"{{"addr":"{}","type":"{}"}}"#, address, device_type); // Write to app_data_dir std::fs::write(data_dir.join("ble_device.json"), &json).ok(); // Write to app root (Android: Kotlin reads from here) if let Some(root) = data_dir.parent().and_then(|p| p.parent()) { std::fs::write(root.join("ble_device.json"), &json).ok(); } Ok(()) } // Disconnect ONLY the barometer (its own service), leaving the HR strap connected. #[tauri::command] pub async fn ble_baro_disconnect(app: tauri::AppHandle) -> Result { #[cfg(not(target_os = "android"))] { let _ = &app; Ok("desktop: no-op".to_string()) } #[cfg(target_os = "android")] { android::send_command(&app, "baro_disconnect")?; Ok("Disconnecting baro".to_string()) } } // Persist the paired barometer so the Kotlin side auto-connects it at start (separate from the HR // device file). Empty address clears it. #[tauri::command] pub fn ble_save_baro_device(app: tauri::AppHandle, address: String) -> Result<(), String> { use tauri::Manager; let data_dir = app.path().app_data_dir().map_err(|e: tauri::Error| e.to_string())?; let json = format!(r#"{{"addr":"{}","type":"baro"}}"#, address); std::fs::write(data_dir.join("baro_device.json"), &json).ok(); if let Some(root) = data_dir.parent().and_then(|p| p.parent()) { std::fs::write(root.join("baro_device.json"), &json).ok(); } Ok(()) } #[tauri::command] pub fn ble_check_bluetooth( app: tauri::AppHandle, ) -> Result { #[cfg(not(target_os = "android"))] { let _ = &app; Ok("on".to_string()) } // desktop: assume BT available #[cfg(target_os = "android")] { // Read bt_state.txt written by Kotlin if let Some(s) = android::read_file(&app, "bt_state.txt") { Ok(s.trim().to_string()) } else { Ok("unknown".to_string()) } } }