OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units
OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download
BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue,
ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s
Kotlin BleHrService, btleplug for desktop, file IPC for Android
Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen
GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical),
configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc
BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725),
fixed 7-day trend using dailyBase instead of raw bmr
Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed
mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns
UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient
reserves strip, GPS sessions in today view, pan on charts, showToast(),
HR norms in settings, HR on recording screen, HR chart in track details
Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing)
2026-06-07 01:36:38 +03:00
|
|
|
// 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,
|
2026-10-17 00:58:31 +03:00
|
|
|
#[serde(alias = "bat", default)]
|
|
|
|
|
pub battery: Option<u8>,
|
OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units
OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download
BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue,
ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s
Kotlin BleHrService, btleplug for desktop, file IPC for Android
Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen
GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical),
configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc
BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725),
fixed 7-day trend using dailyBase instead of raw bmr
Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed
mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns
UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient
reserves strip, GPS sessions in today view, pan on charts, showToast(),
HR norms in settings, HR on recording screen, HR chart in track details
Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing)
2026-06-07 01:36:38 +03:00
|
|
|
}
|
|
|
|
|
|
ble: read DIY barometer (BaroBalDav) over BLE + firmware
Self-made altitude sensor: ESP32-C3 SuperMini + BMP280 broadcasting pressure and
temperature over standard BLE Environmental Sensing. Read into baldav the same way as
the heart-rate strap (Kotlin BLE service -> file -> Rust -> JS), new device_type 'baro'.
- BleHrService: 'baro' type subscribes to ESS 0x181A (pressure 0x2A6D uint32 0.1 Pa,
temperature 0x2A6E int16 0.01 C), writes ble_baro_data.json + ble_baro_log.jsonl.
Every baro branch is gated by deviceType=='baro', so HR/GPS paths are untouched.
- ble.rs: BaroReading, ESS detection, ble_get_baro command (Android reads the file,
desktop returns None). Registered in lib.rs.
- frontend/js/baro.js: passive window.BARO - hypsometric pressure->altitude using the
sensor temperature, anchored to GPS altitude at ride start. Nothing runs until
BARO.start(), so with no barometer the app is unchanged.
- tools/baro-sensor: firmware (PlatformIO main.cpp + Arduino sketch) that reads BMP280
at max oversampling and advertises the ESS service.
Prep only: no hardware yet, verified by compile (build 0.1.538). Live wiring and
ride-track integration pending the board.
2026-10-30 00:38:12 +03:00
|
|
|
// 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,
|
|
|
|
|
}
|
|
|
|
|
|
OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units
OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download
BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue,
ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s
Kotlin BleHrService, btleplug for desktop, file IPC for Android
Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen
GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical),
configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc
BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725),
fixed 7-day trend using dailyBase instead of raw bmr
Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed
mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns
UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient
reserves strip, GPS sessions in today view, pan on charts, showToast(),
HR norms in settings, HR on recording screen, HR chart in track details
Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing)
2026-06-07 01:36:38 +03:00
|
|
|
// 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";
|
|
|
|
|
|
ble: read DIY barometer (BaroBalDav) over BLE + firmware
Self-made altitude sensor: ESP32-C3 SuperMini + BMP280 broadcasting pressure and
temperature over standard BLE Environmental Sensing. Read into baldav the same way as
the heart-rate strap (Kotlin BLE service -> file -> Rust -> JS), new device_type 'baro'.
- BleHrService: 'baro' type subscribes to ESS 0x181A (pressure 0x2A6D uint32 0.1 Pa,
temperature 0x2A6E int16 0.01 C), writes ble_baro_data.json + ble_baro_log.jsonl.
Every baro branch is gated by deviceType=='baro', so HR/GPS paths are untouched.
- ble.rs: BaroReading, ESS detection, ble_get_baro command (Android reads the file,
desktop returns None). Registered in lib.rs.
- frontend/js/baro.js: passive window.BARO - hypsometric pressure->altitude using the
sensor temperature, anchored to GPS altitude at ride start. Nothing runs until
BARO.start(), so with no barometer the app is unchanged.
- tools/baro-sensor: firmware (PlatformIO main.cpp + Arduino sketch) that reads BMP280
at max oversampling and advertises the ESS service.
Prep only: no hardware yet, verified by compile (build 0.1.538). Live wiring and
ride-track integration pending the board.
2026-10-30 00:38:12 +03:00
|
|
|
// Standard Environmental Sensing — our DIY barometer "BaroBalDav"
|
|
|
|
|
const ESS_SERVICE: &str = "0000181a-0000-1000-8000-00805f9b34fb";
|
|
|
|
|
|
OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units
OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download
BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue,
ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s
Kotlin BleHrService, btleplug for desktop, file IPC for Android
Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen
GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical),
configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc
BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725),
fixed 7-day trend using dailyBase instead of raw bmr
Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed
mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns
UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient
reserves strip, GPS sessions in today view, pan on charts, showToast(),
HR norms in settings, HR on recording screen, HR chart in track details
Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing)
2026-06-07 01:36:38 +03:00
|
|
|
// 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 {
|
ble: read DIY barometer (BaroBalDav) over BLE + firmware
Self-made altitude sensor: ESP32-C3 SuperMini + BMP280 broadcasting pressure and
temperature over standard BLE Environmental Sensing. Read into baldav the same way as
the heart-rate strap (Kotlin BLE service -> file -> Rust -> JS), new device_type 'baro'.
- BleHrService: 'baro' type subscribes to ESS 0x181A (pressure 0x2A6D uint32 0.1 Pa,
temperature 0x2A6E int16 0.01 C), writes ble_baro_data.json + ble_baro_log.jsonl.
Every baro branch is gated by deviceType=='baro', so HR/GPS paths are untouched.
- ble.rs: BaroReading, ESS detection, ble_get_baro command (Android reads the file,
desktop returns None). Registered in lib.rs.
- frontend/js/baro.js: passive window.BARO - hypsometric pressure->altitude using the
sensor temperature, anchored to GPS altitude at ride start. Nothing runs until
BARO.start(), so with no barometer the app is unchanged.
- tools/baro-sensor: firmware (PlatformIO main.cpp + Arduino sketch) that reads BMP280
at max oversampling and advertises the ESS service.
Prep only: no hardware yet, verified by compile (build 0.1.538). Live wiring and
ride-track integration pending the board.
2026-10-30 00:38:12 +03:00
|
|
|
// Our DIY barometer advertises standard Environmental Sensing
|
|
|
|
|
if services.contains(&uuid(ESS_SERVICE)) {
|
|
|
|
|
return "baro".to_string();
|
|
|
|
|
}
|
OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units
OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download
BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue,
ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s
Kotlin BleHrService, btleplug for desktop, file IPC for Android
Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen
GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical),
configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc
BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725),
fixed 7-day trend using dailyBase instead of raw bmr
Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed
mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns
UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient
reserves strip, GPS sessions in today view, pan on charts, showToast(),
HR norms in settings, HR on recording screen, HR chart in track details
Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing)
2026-06-07 01:36:38 +03:00
|
|
|
// 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 = ¬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<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)
|
|
|
|
|
}
|
ble: read DIY barometer (BaroBalDav) over BLE + firmware
Self-made altitude sensor: ESP32-C3 SuperMini + BMP280 broadcasting pressure and
temperature over standard BLE Environmental Sensing. Read into baldav the same way as
the heart-rate strap (Kotlin BLE service -> file -> Rust -> JS), new device_type 'baro'.
- BleHrService: 'baro' type subscribes to ESS 0x181A (pressure 0x2A6D uint32 0.1 Pa,
temperature 0x2A6E int16 0.01 C), writes ble_baro_data.json + ble_baro_log.jsonl.
Every baro branch is gated by deviceType=='baro', so HR/GPS paths are untouched.
- ble.rs: BaroReading, ESS detection, ble_get_baro command (Android reads the file,
desktop returns None). Registered in lib.rs.
- frontend/js/baro.js: passive window.BARO - hypsometric pressure->altitude using the
sensor temperature, anchored to GPS altitude at ride start. Nothing runs until
BARO.start(), so with no barometer the app is unchanged.
- tools/baro-sensor: firmware (PlatformIO main.cpp + Arduino sketch) that reads BMP280
at max oversampling and advertises the ESS service.
Prep only: no hardware yet, verified by compile (build 0.1.538). Live wiring and
ride-track integration pending the board.
2026-10-30 00:38:12 +03:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units
OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download
BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue,
ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s
Kotlin BleHrService, btleplug for desktop, file IPC for Android
Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen
GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical),
configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc
BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725),
fixed 7-day trend using dailyBase instead of raw bmr
Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed
mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns
UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient
reserves strip, GPS sessions in today view, pan on charts, showToast(),
HR norms in settings, HR on recording screen, HR chart in track details
Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing)
2026-06-07 01:36:38 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- 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())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-02 14:03:16 +03:00
|
|
|
// 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())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units
OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download
BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue,
ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s
Kotlin BleHrService, btleplug for desktop, file IPC for Android
Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen
GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical),
configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc
BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725),
fixed 7-day trend using dailyBase instead of raw bmr
Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed
mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns
UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient
reserves strip, GPS sessions in today view, pan on charts, showToast(),
HR norms in settings, HR on recording screen, HR chart in track details
Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing)
2026-06-07 01:36:38 +03:00
|
|
|
#[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) }
|
|
|
|
|
}
|
|
|
|
|
|
ble: read DIY barometer (BaroBalDav) over BLE + firmware
Self-made altitude sensor: ESP32-C3 SuperMini + BMP280 broadcasting pressure and
temperature over standard BLE Environmental Sensing. Read into baldav the same way as
the heart-rate strap (Kotlin BLE service -> file -> Rust -> JS), new device_type 'baro'.
- BleHrService: 'baro' type subscribes to ESS 0x181A (pressure 0x2A6D uint32 0.1 Pa,
temperature 0x2A6E int16 0.01 C), writes ble_baro_data.json + ble_baro_log.jsonl.
Every baro branch is gated by deviceType=='baro', so HR/GPS paths are untouched.
- ble.rs: BaroReading, ESS detection, ble_get_baro command (Android reads the file,
desktop returns None). Registered in lib.rs.
- frontend/js/baro.js: passive window.BARO - hypsometric pressure->altitude using the
sensor temperature, anchored to GPS altitude at ride start. Nothing runs until
BARO.start(), so with no barometer the app is unchanged.
- tools/baro-sensor: firmware (PlatformIO main.cpp + Arduino sketch) that reads BMP280
at max oversampling and advertises the ESS service.
Prep only: no hardware yet, verified by compile (build 0.1.538). Live wiring and
ride-track integration pending the board.
2026-10-30 00:38:12 +03:00
|
|
|
// 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) }
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 23:09:50 +03:00
|
|
|
// 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()) }
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 23:49:17 +03:00
|
|
|
// 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(())
|
|
|
|
|
}
|
|
|
|
|
|
OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units
OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download
BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue,
ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s
Kotlin BleHrService, btleplug for desktop, file IPC for Android
Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen
GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical),
configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc
BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725),
fixed 7-day trend using dailyBase instead of raw bmr
Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed
mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns
UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient
reserves strip, GPS sessions in today view, pan on charts, showToast(),
HR norms in settings, HR on recording screen, HR chart in track details
Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing)
2026-06-07 01:36:38 +03:00
|
|
|
#[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())?;
|
2026-10-20 23:09:50 +03:00
|
|
|
// 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"), "{}");
|
2026-10-20 23:09:50 +03:00
|
|
|
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"), "{}");
|
2026-10-20 23:09:50 +03:00
|
|
|
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(())
|
|
|
|
|
}
|
|
|
|
|
|
OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units
OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download
BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue,
ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s
Kotlin BleHrService, btleplug for desktop, file IPC for Android
Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen
GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical),
configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc
BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725),
fixed 7-day trend using dailyBase instead of raw bmr
Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed
mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns
UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient
reserves strip, GPS sessions in today view, pan on charts, showToast(),
HR norms in settings, HR on recording screen, HR chart in track details
Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing)
2026-06-07 01:36:38 +03:00
|
|
|
#[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(())
|
|
|
|
|
}
|
|
|
|
|
|
ble: barometer on its own connection - simultaneous HR+baro, auto-connect, UI
Separate BleBaroService so the HR strap and the barometer each hold their own GATT link and
work at the same time (one adapter, several LE connections). Verified on device against a PC
BlueZ emulator of the sensor: BleHR and BleBaro deliver in parallel, no cross-talk.
- BleBaroService: dedicated baro connection (scan-before-connect + direct-connect fallback,
ESS subscribe, writes ble_baro_data.json). MainActivity routes baro connects to it and
auto-connects both HR (ble_device.json) and baro (baro_device.json) at start.
- Rust: ble_baro_disconnect, ble_save_baro_device.
- Settings: a 'Датчики (BLE)' section - heart-rate and barometer as separate rows with their
own slots, bind/connect/unbind, and a live pressure/temperature/altitude readout.
- Tracking: altitude_baro_m stored per point, preferred in elevation stats (null without baro).
- Record screen: 'Высота' from the barometer (1 Hz, dot marker), temperature from the sensor.
- Fixes: direct-connect on scan timeout; Locale.US JSON (RU decimal comma broke the parse);
parsing routed by characteristic (baro no longer written into the pulse); VR/HR stop returns
to the tracks list instead of into details.
- tools/baro-sensor/emulate_baro.py: PC BlueZ peripheral that emulates the sensor for testing.
Emulator-verified only; real board still pending.
2026-10-30 22:46:26 +03:00
|
|
|
// 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(())
|
|
|
|
|
}
|
|
|
|
|
|
OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units
OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download
BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue,
ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s
Kotlin BleHrService, btleplug for desktop, file IPC for Android
Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen
GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical),
configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc
BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725),
fixed 7-day trend using dailyBase instead of raw bmr
Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed
mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns
UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient
reserves strip, GPS sessions in today view, pan on charts, showToast(),
HR norms in settings, HR on recording screen, HR chart in track details
Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing)
2026-06-07 01:36:38 +03:00
|
|
|
#[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())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|