baldav/src-tauri/src/ble.rs

637 lines
23 KiB
Rust
Raw Normal View History

// 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<u8>,
#[serde(default)]
pub bp_sys: Option<u8>,
#[serde(default)]
pub bp_dia: Option<u8>,
#[serde(alias = "ts", default)]
pub timestamp: u64,
#[serde(default)]
pub device: String,
#[serde(alias = "bat", default)]
pub battery: Option<u8>,
}
// 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<f64>,
#[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<Mutex<BleState>> = Arc::new(Mutex::new(BleState::default()));
}
#[cfg(not(target_os = "android"))]
#[derive(Default)]
struct BleState {
scan_results: Vec<BleDevice>,
connected_device: Option<String>,
latest_hr: Option<HrReading>,
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<Adapter, String> {
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<Vec<BleDevice>, 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<String, String> {
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 = &notification.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<HrReading> {
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<HrReading> {
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<String, String> {
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<HrReading> {
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<PathBuf, String> {
app.path().app_data_dir().map_err(|e: tauri::Error| e.to_string())
}
fn app_root(data_dir: &std::path::Path) -> Option<PathBuf> {
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<String> {
let data_dir = get_data_dir(app).ok()?;
let paths: Vec<PathBuf> = 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<Vec<BleDevice>, String> {
if let Some(s) = read_file(app, "ble_scan_results.json") {
if let Ok(devices) = serde_json::from_str::<Vec<BleDevice>>(&s) {
return Ok(devices);
}
}
Ok(vec![])
}
pub fn read_hr_data(app: &tauri::AppHandle) -> Result<Option<HrReading>, String> {
if let Some(s) = read_file(app, "ble_hr_data.json") {
if let Ok(r) = serde_json::from_str::<HrReading>(&s) {
if r.hr > 0 { return Ok(Some(r)); }
}
}
Ok(None)
}
pub fn read_baro_data(app: &tauri::AppHandle) -> Result<Option<BaroReading>, String> {
if let Some(s) = read_file(app, "ble_baro_data.json") {
if let Ok(r) = serde_json::from_str::<BaroReading>(&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<u64>,
) -> Result<Vec<BleDevice>, 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<Vec<BleDevice>, 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<String, String> {
#[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<String, String> {
#[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<String, String> {
#[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<Option<HrReading>, 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<Option<BaroReading>, 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<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()) }
}
// 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,
) -> Result<bool, String> {
#[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)
}
}
}
Sensors, walk limits, GPS fixes, water UI, manual routes, Gridnev products, OTA router GPS altitude: accel-based glitch filter (accel=0 + speed = GPS drift), altitude jump >100m filter, timestamp sanity (>24h = remove). Per-point Crr from accel vibration (smooth/mixed/gravel). Gap interpolation on map with dashed lines when accel confirms movement. Walk/run separate speed limits (configurable). Adaptive GPS accuracy (auto x2 when signal bad). Dead reckoning points when GPS silent but accel shows movement. Locale.US fix for sensor JSON. Sensors: accelerometer + gyroscope in GpsRecorderService (RMS, max, pitch, yaw). Columns in gps_points. Dynamic addGpsPoint INSERT (safe if columns missing). Manual routes: draw on map by tapping points, elevation from past tracks + open-meteo API, realistic timestamps by activity speed. Manual sessions skip glitch filtering on recalculate. Speed from GPS.config not hardcoded. Water tracking: water_pct field in product add/edit forms, searchProducts returns water_pct, "ml" label for liquids, recipe water from ingredients. BMR: moving average weight (7 days), fixed charts using dailyBase not raw bmr. BLE: ble_clear_hr on stop, recovery toast, serde alias for ts/timestamp. Products: 217 Gridnev products (bakery, desserts, bread, ready meals). Magnit scraper fix (NUXT_DATA name/value pairs, not indices). Supplements addSupplement with all 41 columns. Nutrient fallback RDI includes omega/EPA/DHA. Export/import: custom products + recipes + supplements in single JSON bundle. Export all or single product. Dedup on import by name_lower + source. OTA: router upload (LAN, always), VPS upload (-u flag). Kotlin checks router first (2s timeout), then VPS. Update URL from update_info.json. Session detail: renderToken prevents stale setTimeout callbacks after recalculate. WAL disabled (PRAGMA journal_mode=DELETE).
2026-06-18 22:54:45 +03:00
#[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.
Sensors, walk limits, GPS fixes, water UI, manual routes, Gridnev products, OTA router GPS altitude: accel-based glitch filter (accel=0 + speed = GPS drift), altitude jump >100m filter, timestamp sanity (>24h = remove). Per-point Crr from accel vibration (smooth/mixed/gravel). Gap interpolation on map with dashed lines when accel confirms movement. Walk/run separate speed limits (configurable). Adaptive GPS accuracy (auto x2 when signal bad). Dead reckoning points when GPS silent but accel shows movement. Locale.US fix for sensor JSON. Sensors: accelerometer + gyroscope in GpsRecorderService (RMS, max, pitch, yaw). Columns in gps_points. Dynamic addGpsPoint INSERT (safe if columns missing). Manual routes: draw on map by tapping points, elevation from past tracks + open-meteo API, realistic timestamps by activity speed. Manual sessions skip glitch filtering on recalculate. Speed from GPS.config not hardcoded. Water tracking: water_pct field in product add/edit forms, searchProducts returns water_pct, "ml" label for liquids, recipe water from ingredients. BMR: moving average weight (7 days), fixed charts using dailyBase not raw bmr. BLE: ble_clear_hr on stop, recovery toast, serde alias for ts/timestamp. Products: 217 Gridnev products (bakery, desserts, bread, ready meals). Magnit scraper fix (NUXT_DATA name/value pairs, not indices). Supplements addSupplement with all 41 columns. Nutrient fallback RDI includes omega/EPA/DHA. Export/import: custom products + recipes + supplements in single JSON bundle. Export all or single product. Dedup on import by name_lower + source. OTA: router upload (LAN, always), VPS upload (-u flag). Kotlin checks router first (2s timeout), then VPS. Update URL from update_info.json. Session detail: renderToken prevents stale setTimeout callbacks after recalculate. WAL disabled (PRAGMA journal_mode=DELETE).
2026-06-18 22:54:45 +03:00
let _ = std::fs::write(data_dir.join("ble_hr_data.json"), "{}");
let _ = std::fs::write(data_dir.join("ble_hr_log.jsonl"), "");
Sensors, walk limits, GPS fixes, water UI, manual routes, Gridnev products, OTA router GPS altitude: accel-based glitch filter (accel=0 + speed = GPS drift), altitude jump >100m filter, timestamp sanity (>24h = remove). Per-point Crr from accel vibration (smooth/mixed/gravel). Gap interpolation on map with dashed lines when accel confirms movement. Walk/run separate speed limits (configurable). Adaptive GPS accuracy (auto x2 when signal bad). Dead reckoning points when GPS silent but accel shows movement. Locale.US fix for sensor JSON. Sensors: accelerometer + gyroscope in GpsRecorderService (RMS, max, pitch, yaw). Columns in gps_points. Dynamic addGpsPoint INSERT (safe if columns missing). Manual routes: draw on map by tapping points, elevation from past tracks + open-meteo API, realistic timestamps by activity speed. Manual sessions skip glitch filtering on recalculate. Speed from GPS.config not hardcoded. Water tracking: water_pct field in product add/edit forms, searchProducts returns water_pct, "ml" label for liquids, recipe water from ingredients. BMR: moving average weight (7 days), fixed charts using dailyBase not raw bmr. BLE: ble_clear_hr on stop, recovery toast, serde alias for ts/timestamp. Products: 217 Gridnev products (bakery, desserts, bread, ready meals). Magnit scraper fix (NUXT_DATA name/value pairs, not indices). Supplements addSupplement with all 41 columns. Nutrient fallback RDI includes omega/EPA/DHA. Export/import: custom products + recipes + supplements in single JSON bundle. Export all or single product. Dedup on import by name_lower + source. OTA: router upload (LAN, always), VPS upload (-u flag). Kotlin checks router first (2s timeout), then VPS. Update URL from update_info.json. Session detail: renderToken prevents stale setTimeout callbacks after recalculate. WAL disabled (PRAGMA journal_mode=DELETE).
2026-06-18 22:54:45 +03:00
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"), "");
Sensors, walk limits, GPS fixes, water UI, manual routes, Gridnev products, OTA router GPS altitude: accel-based glitch filter (accel=0 + speed = GPS drift), altitude jump >100m filter, timestamp sanity (>24h = remove). Per-point Crr from accel vibration (smooth/mixed/gravel). Gap interpolation on map with dashed lines when accel confirms movement. Walk/run separate speed limits (configurable). Adaptive GPS accuracy (auto x2 when signal bad). Dead reckoning points when GPS silent but accel shows movement. Locale.US fix for sensor JSON. Sensors: accelerometer + gyroscope in GpsRecorderService (RMS, max, pitch, yaw). Columns in gps_points. Dynamic addGpsPoint INSERT (safe if columns missing). Manual routes: draw on map by tapping points, elevation from past tracks + open-meteo API, realistic timestamps by activity speed. Manual sessions skip glitch filtering on recalculate. Speed from GPS.config not hardcoded. Water tracking: water_pct field in product add/edit forms, searchProducts returns water_pct, "ml" label for liquids, recipe water from ingredients. BMR: moving average weight (7 days), fixed charts using dailyBase not raw bmr. BLE: ble_clear_hr on stop, recovery toast, serde alias for ts/timestamp. Products: 217 Gridnev products (bakery, desserts, bread, ready meals). Magnit scraper fix (NUXT_DATA name/value pairs, not indices). Supplements addSupplement with all 41 columns. Nutrient fallback RDI includes omega/EPA/DHA. Export/import: custom products + recipes + supplements in single JSON bundle. Export all or single product. Dedup on import by name_lower + source. OTA: router upload (LAN, always), VPS upload (-u flag). Kotlin checks router first (2s timeout), then VPS. Update URL from update_info.json. Session detail: renderToken prevents stale setTimeout callbacks after recalculate. WAL disabled (PRAGMA journal_mode=DELETE).
2026-06-18 22:54:45 +03:00
}
#[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<String, String> {
#[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<String, String> {
#[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())
}
}
}