From 0c1b9e70d730400156da5d66711dfbe3dd87b9c9 Mon Sep 17 00:00:00 2001 From: lambda Date: Wed, 28 Oct 2026 17:18:30 +0300 Subject: [PATCH 1/2] fix: fix connecting sensors + magene cadence fix --- src-tauri/src/ble/mock.rs | 21 ++- src-tauri/src/ble/mod.rs | 2 - src-tauri/src/ble/scanner.rs | 98 -------------- src-tauri/src/ble/source.rs | 177 ++++++++++++++++++++++---- src-tauri/src/device/mod.rs | 18 +++ src-tauri/src/device_repo/mod.rs | 2 +- src-tauri/src/device_repo/repo.rs | 114 +---------------- src-tauri/src/lib.rs | 113 +++++++++++++--- src-tauri/src/logging.rs | 67 ++++++++++ src-tauri/src/state.rs | 2 + src-tauri/src/tcx.rs | 205 ++++++++++++++++++++++++++++++ src-tauri/src/telemetry.rs | 21 +++ src/main.js | 52 +++++++- 13 files changed, 631 insertions(+), 261 deletions(-) delete mode 100644 src-tauri/src/ble/scanner.rs create mode 100644 src-tauri/src/logging.rs create mode 100644 src-tauri/src/tcx.rs diff --git a/src-tauri/src/ble/mock.rs b/src-tauri/src/ble/mock.rs index 823d982..121e9cf 100644 --- a/src-tauri/src/ble/mock.rs +++ b/src-tauri/src/ble/mock.rs @@ -67,8 +67,10 @@ impl MockSource { #[async_trait] impl SensorSource for MockSource { - async fn scan(&self, _seconds: u64) -> Result, BleError> { - Ok(self.devices.clone()) + async fn scan_stream(&self) -> Result + Send>>, BleError> { + // мок сразу отдаёт три устройства и завершает поток + let s: Pin + Send>> = stream::iter(self.devices.clone()).boxed(); + Ok(s) } async fn stream(&self, device: &Device) @@ -133,17 +135,26 @@ impl SensorSource for MockSource { mod tests { use super::*; + async fn mock_devices(src: &MockSource) -> Vec { + let mut stream = src.scan_stream().await.unwrap(); + let mut out = Vec::new(); + while let Some(d) = stream.next().await { + out.push(d); + } + out + } + #[tokio::test] async fn test_mock_scan_returns_three_devices() { let src = MockSource::new(); - let devices = src.scan(0).await.unwrap(); + let devices = mock_devices(&src).await; assert_eq!(devices.len(), 3); } #[tokio::test] async fn test_mock_stream_yields_telemetry() { let src = MockSource::new(); - let devices = src.scan(0).await.unwrap(); + let devices = mock_devices(&src).await; let hr = devices.iter().find(|d| d.id == "mock-hr").unwrap(); let mut stream = src.stream(hr).await.unwrap(); let first = stream.next().await; @@ -153,7 +164,7 @@ mod tests { #[tokio::test] async fn test_mock_trainer_emits_power_and_cadence() { let src = MockSource::new(); - let devices = src.scan(0).await.unwrap(); + let devices = mock_devices(&src).await; let trainer = devices.iter().find(|d| d.id == "mock-trainer").unwrap(); let mut stream = src.stream(trainer).await.unwrap(); let mut saw_power = false; diff --git a/src-tauri/src/ble/mod.rs b/src-tauri/src/ble/mod.rs index d2470b2..24e13d4 100644 --- a/src-tauri/src/ble/mod.rs +++ b/src-tauri/src/ble/mod.rs @@ -1,11 +1,9 @@ mod error; mod utils; -mod scanner; mod source; mod mock; pub use error::BleError; -pub use scanner::scan_devices; pub use utils::normalize_uuid; pub(crate) use source::{BleSource, SensorSource}; pub(crate) use mock::MockSource; diff --git a/src-tauri/src/ble/scanner.rs b/src-tauri/src/ble/scanner.rs deleted file mode 100644 index 1165fc6..0000000 --- a/src-tauri/src/ble/scanner.rs +++ /dev/null @@ -1,98 +0,0 @@ -use btleplug::api::{Central, Peripheral, ScanFilter, bleuuid::uuid_from_u16}; -use btleplug::platform::Adapter; -use std::time::Duration; -use tokio::time; -use crate::ble::error::BleError; -use crate::device::Device; -use uuid::Uuid; - -/// Сервисы, по которым ищем датчики. Только целевые (HR / CSC / FTMS / мощность). -/// Battery (0x180F) и Device Info (0x180A) убраны: их рекламирует почти любое -/// BLE-устройство, из-за чего скан подхватывал много лишнего и подключался к нему. -static TARGET_UUIDS: &[Uuid] = &[ - uuid_from_u16(0x180D), // Heart Rate - uuid_from_u16(0x1816), // Cycling Speed and Cadence - uuid_from_u16(0x1826), // Fitness Machine (FTMS) - uuid_from_u16(0x1818), // Cycling Power -]; - -const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); -const DISCOVER_TIMEOUT: Duration = Duration::from_secs(5); -const DISCONNECT_TIMEOUT: Duration = Duration::from_secs(2); - -pub async fn scan_devices(adapter: &Adapter, scan_seconds: u64) -> Result, BleError> { - adapter - .start_scan(ScanFilter { services: TARGET_UUIDS.to_vec() }) - .await - .map_err(BleError::ScanFailed)?; - - time::sleep(Duration::from_secs(scan_seconds)).await; - - let peripherals = adapter.peripherals().await.map_err(BleError::PeripheralsFailed)?; - - let mut devices = Vec::new(); - for peripheral in peripherals { - // Свойства берём из рекламы; если устройство уже пропало - пропускаем. - let Some(props) = peripheral.properties().await.unwrap_or(None) else { - continue; - }; - - let svc_match = props.services.iter().any(|s| TARGET_UUIDS.contains(s)); - if !svc_match { - continue; - } - - // Для классификации по типу нужны характеристики - подключаемся, читаем, - // отключаемся (best-effort: при сбое устройство всё равно попадёт в список, - // просто без характеристик). - let characteristics = discover_characteristics(&peripheral).await; - - let manuf = props.manufacturer_data.iter().next().map(|(_, v)| hex::encode(v)); - - devices.push(Device { - id: peripheral.id().to_string(), - address: props.address.to_string(), - display_name: props.local_name, - rssi: props.rssi, - service_uuids: props.services.iter().map(|u| u.to_string()).collect(), - manufacturer_data: manuf, - metadata: None, - characteristics, - }); - } - - adapter.stop_scan().await.ok(); - Ok(devices) -} - -/// Подключается к устройству, читает характеристики и отключается. -/// Возвращает список характеристик в формате "service::characteristic". -/// Best-effort: неудачи connect/discover игнорируются. -async fn discover_characteristics(peripheral: &impl Peripheral) -> Vec { - let was_connected = peripheral.is_connected().await.unwrap_or(false); - - if !was_connected { - let _ = time::timeout(CONNECT_TIMEOUT, peripheral.connect()).await; - } - let _ = time::timeout(DISCOVER_TIMEOUT, peripheral.discover_services()).await; - - let mut characteristics = Vec::new(); - let chars = peripheral.characteristics(); - if !chars.is_empty() { - for ch in chars.iter() { - characteristics.push(format!("{}::{}", ch.service_uuid, ch.uuid)); - } - } else { - for svc in peripheral.services().iter() { - for ch in svc.characteristics.iter() { - characteristics.push(format!("{}::{}", svc.uuid, ch.uuid)); - } - } - } - - if !was_connected { - let _ = time::timeout(DISCONNECT_TIMEOUT, peripheral.disconnect()).await; - } - - characteristics -} diff --git a/src-tauri/src/ble/source.rs b/src-tauri/src/ble/source.rs index a86b613..de15cc4 100644 --- a/src-tauri/src/ble/source.rs +++ b/src-tauri/src/ble/source.rs @@ -4,9 +4,10 @@ use std::task::{Context, Poll}; use std::time::Duration; use async_trait::async_trait; -use btleplug::api::{bleuuid::uuid_from_u16, Central, Peripheral as _, ValueNotification, WriteType}; +use btleplug::api::{bleuuid::uuid_from_u16, Central, Characteristic, Peripheral as _, ScanFilter, ValueNotification, WriteType}; use btleplug::platform::{Adapter, Peripheral}; use futures::{Stream, StreamExt}; +use std::collections::HashSet; use tokio::sync::oneshot; use uuid::Uuid; @@ -17,7 +18,10 @@ use crate::telemetry::{parse_csc, parse_hr, parse_indoor_bike_data, parse_power, #[async_trait] pub(crate) trait SensorSource: Send + Sync { - async fn scan(&self, seconds: u64) -> Result, BleError>; + /// Начать непрерывный скан. Возвращает поток найденных устройств (по мере + /// появления, только из рекламы — без подключения и чтения характеристик). + /// Дроп потока останавливает скан. + async fn scan_stream(&self) -> Result + Send>>, BleError>; /// Подключиться и вернуть поток распарсенных измерений. async fn stream(&self, device: &Device) @@ -38,6 +42,18 @@ const CSC_MEASUREMENT: Uuid = uuid_from_u16(0x2A5B); const POWER_MEASUREMENT: Uuid = uuid_from_u16(0x2A63); const INDOOR_BIKE_DATA: Uuid = uuid_from_u16(0x2AD2); const FITNESS_MACHINE_STATUS: Uuid = uuid_from_u16(0x2ADA); +const CONTROL_POINT: Uuid = uuid_from_u16(0x2AD9); + +/// Сервисы, по которым сканируем (только целевые: HR/CSC/FTMS/мощность). +static TARGET_UUIDS: &[Uuid] = &[ + uuid_from_u16(0x180D), // Heart Rate + uuid_from_u16(0x1816), // Cycling Speed and Cadence + uuid_from_u16(0x1826), // Fitness Machine (FTMS) + uuid_from_u16(0x1818), // Cycling Power +]; + +/// Интервал опроса перифериалов при непрерывном скане. +const SCAN_POLL_INTERVAL: Duration = Duration::from_millis(1500); // How long to wait for the trainer to confirm control (Control Point Response 0x80). const CONTROL_CONFIRM_TIMEOUT: Duration = Duration::from_secs(3); @@ -75,20 +91,20 @@ impl BleSource { /// Stream wrapper that cancels the background notification router on drop, so a /// reconnect does not leak the previous cycle's routing task. -struct CancellableStream { - inner: tokio_stream::wrappers::UnboundedReceiverStream, +struct CancellableStream { + inner: tokio_stream::wrappers::UnboundedReceiverStream, cancel: Option>, } -impl Stream for CancellableStream { - type Item = Telemetry; +impl Stream for CancellableStream { + type Item = T; - fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { Pin::new(&mut self.inner).poll_next(cx) } } -impl Drop for CancellableStream { +impl Drop for CancellableStream { fn drop(&mut self) { if let Some(cancel) = self.cancel.take() { let _ = cancel.send(()); @@ -98,14 +114,85 @@ impl Drop for CancellableStream { #[async_trait] impl SensorSource for BleSource { - async fn scan(&self, seconds: u64) -> Result, BleError> { - crate::ble::scan_devices(&self.adapter, seconds).await + async fn scan_stream(&self) -> Result + Send>>, BleError> { + // Сканируем без сервис-фильтра: часть станков не рекламирует сервисы в ADV, + // и фильтр на уровне адаптера их прячет. Фильтруем уже в коде ниже. + self.adapter + .start_scan(ScanFilter { services: Vec::new() }) + .await + .map_err(|e| { + crate::logging::log("scan", &format!("start_scan failed: {e}")); + BleError::ScanFailed(e) + })?; + crate::logging::log("scan", "scan started"); + + let adapter = self.adapter.clone(); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel::(); + let (cancel_tx, cancel_rx) = oneshot::channel::<()>(); + + tokio::spawn(async move { + let mut seen: HashSet = HashSet::new(); + let mut cancel_rx = cancel_rx; + loop { + tokio::select! { + _ = &mut cancel_rx => break, + _ = tokio::time::sleep(SCAN_POLL_INTERVAL) => { + let peripherals = match adapter.peripherals().await { + Ok(p) => p, + Err(e) => { + crate::logging::log("scan", &format!("peripherals() failed: {e}")); + continue; + } + }; + for peripheral in peripherals { + let Some(props) = peripheral.properties().await.unwrap_or(None) else { + continue; + }; + let id = peripheral.id().to_string(); + let is_new = seen.insert(id.clone()); + // логируем каждый новый перифериал (с его сервисами), чтобы + // видеть, что реально видит адаптер — даже до фильтрации. + if is_new { + crate::logging::log("scan", &format!("peripheral: name={:?} services={:?}", props.local_name, props.services)); + } + if !props.services.iter().any(|s| TARGET_UUIDS.contains(s)) { + continue; + } + if !is_new { + continue; + } + let device = Device { + id, + address: props.address.to_string(), + display_name: props.local_name, + rssi: props.rssi, + service_uuids: props.services.iter().map(|u| u.to_string()).collect(), + characteristics: Vec::new(), + manufacturer_data: props.manufacturer_data.iter().next().map(|(_, v)| hex::encode(v)), + metadata: None, + }; + crate::logging::log("scan", &format!("device found: name={:?} id={}", device.display_name, device.id)); + let _ = tx.send(device); + } + } + } + } + crate::logging::log("scan", "scan stopped"); + let _ = adapter.stop_scan().await; + }); + + let stream: Pin + Send>> = Box::pin(CancellableStream { + inner: tokio_stream::wrappers::UnboundedReceiverStream::new(rx), + cancel: Some(cancel_tx), + }); + Ok(stream) } async fn stream(&self, device: &Device) -> Result + Send>>, BleError> { let peripheral = self.find_peripheral(device).await?; + crate::logging::log("ble", &format!("stream for '{}' ({})", device.display_name.as_deref().unwrap_or("?"), device.id)); ensure_connected(&peripheral).await?; let targets = [ @@ -114,12 +201,14 @@ impl SensorSource for BleSource { POWER_MEASUREMENT, INDOOR_BIKE_DATA, FITNESS_MACHINE_STATUS, + CONTROL_POINT, // 0x2AD9 — сюда приходит Control Point Response (opcode 0x80) ]; for ch in peripheral.characteristics().iter() { if targets.contains(&ch.uuid) { // Unsubscribe first (best-effort) so a stale reconnect does not double-subscribe. let _ = peripheral.unsubscribe(ch).await; peripheral.subscribe(ch).await.map_err(BleError::SubscribeFailed)?; + crate::logging::log("ble", &format!("subscribed {}", ch.uuid)); } } @@ -142,12 +231,22 @@ impl SensorSource for BleSource { let mut notifications = notifications; let mut acc = CscAccumulator::new(); let mut cancel_rx = cancel_rx; + // Первые 30 уведомлений логируем с сырыми байтами — чтобы видеть, что + // реально шлёт устройство (какие флаги/поля в 0x2AD2 и т.п.). + let mut logged = 0u32; loop { tokio::select! { n = notifications.next() => { match n { Some(n) => { - for t in parse_notification(n, &mut acc, &control) { + let uuid = n.uuid; + let raw = hex::encode(&n.value); + let parsed = parse_notification(n, &mut acc, &control); + if logged < 30 { + logged += 1; + crate::logging::log("telemetry", &format!("uuid={uuid} raw={raw} parsed={parsed:?}")); + } + for t in parsed { let _ = tx.send(t); } } @@ -170,7 +269,7 @@ impl SensorSource for BleSource { let peripheral = self.find_peripheral(device).await?; ensure_connected(&peripheral).await?; - let (control_point, payload) = encode_command(cmd); + let (control_point, payload) = encode_command(cmd.clone()); let cp_uuid = uuid_from_u16(control_point.short_uuid()); let ch = peripheral .characteristics() @@ -178,10 +277,8 @@ impl SensorSource for BleSource { .find(|c| c.uuid == cp_uuid) .ok_or_else(|| BleError::DeviceNotFound(cp_uuid.to_string()))?; - peripheral - .write(&ch, &payload, WriteType::WithoutResponse) - .await - .map_err(BleError::WriteFailed) + crate::logging::log("control", &format!("command {:?} payload={}", cmd, hex::encode(&payload))); + write_control_point(&peripheral, &ch, &payload).await } async fn request_control(&self, device: &Device) -> Result<(), BleError> { @@ -199,10 +296,8 @@ impl SensorSource for BleSource { let slot = self.control.lock().unwrap().clone(); *slot.lock().unwrap() = None; let (_, payload) = encode_command(Command::FtmsRequestControl); - peripheral - .write(&ch, &payload, WriteType::WithoutResponse) - .await - .map_err(BleError::WriteFailed)?; + crate::logging::log("control", "request_control sent (opcode 0x00)"); + write_control_point(&peripheral, &ch, &payload).await?; // Wait for the Control Point Response (routed by the stream's notification task). let result = tokio::time::timeout(CONTROL_CONFIRM_TIMEOUT, async move { @@ -215,11 +310,17 @@ impl SensorSource for BleSource { } }) .await - .map_err(|_| BleError::ControlTimeout)?; + .map_err(|_| { + crate::logging::log("control", "request_control: no confirmation (timeout)"); + BleError::ControlTimeout + })?; match result { ControlResult::Success => Ok(()), - other => Err(BleError::ControlRejected(other)), + other => { + crate::logging::log("control", &format!("control rejected: {:?}", other)); + Err(BleError::ControlRejected(other)) + } } } @@ -245,7 +346,9 @@ impl SensorSource for BleSource { async fn ensure_connected(peripheral: &Peripheral) -> Result<(), BleError> { if !peripheral.is_connected().await.unwrap_or(false) { + crate::logging::log("ble", &format!("connecting {}", peripheral.id())); peripheral.connect().await.map_err(BleError::ConnectFailed)?; + crate::logging::log("ble", "connected"); } peripheral .discover_services() @@ -271,12 +374,40 @@ fn parse_notification( parse_power(&n.value).into_iter().collect() } else if n.uuid == INDOOR_BIKE_DATA { parse_indoor_bike_data(&n.value) - } else if n.uuid == FITNESS_MACHINE_STATUS { + } else if n.uuid == CONTROL_POINT { + // Control Point Response (opcode 0x80) приходит indication-ом на сам + // Control Point (0x2AD9), а НЕ на Fitness Machine Status (0x2ADA). if let Some(result) = parse_control_result(&n.value) { + crate::logging::log("control", &format!("control response: {:?}", result)); *control.lock().unwrap() = Some(result); } Vec::new() + } else if n.uuid == FITNESS_MACHINE_STATUS { + // статус станка (spin down и т.п.) — для ERG пока не нужен + Vec::new() } else { Vec::new() } } + +/// Пишет payload на Control Point: пробуем write-with-response (часть станков +/// требует именно его), при неудаче — write-without-response. +async fn write_control_point( + peripheral: &Peripheral, + ch: &Characteristic, + payload: &[u8], +) -> Result<(), BleError> { + match peripheral.write(ch, payload, WriteType::WithResponse).await { + Ok(()) => Ok(()), + Err(with_resp) => { + crate::logging::log( + "control", + &format!("with-response failed ({with_resp}), falling back to without-response"), + ); + peripheral + .write(ch, payload, WriteType::WithoutResponse) + .await + .map_err(BleError::WriteFailed) + } + } +} diff --git a/src-tauri/src/device/mod.rs b/src-tauri/src/device/mod.rs index a3e6903..11e5d5c 100644 --- a/src-tauri/src/device/mod.rs +++ b/src-tauri/src/device/mod.rs @@ -25,6 +25,17 @@ pub enum DeviceType { Trainer, } +impl DeviceType { + /// Категория на фронте ("hr"/"cadence"/"trainer") для маршрутизации события. + pub fn category(&self) -> &'static str { + match self { + DeviceType::HrSensor => "hr", + DeviceType::CadSensor => "cadence", + DeviceType::Trainer => "trainer", + } + } +} + pub fn get_device_types(device: &Device) -> Vec { let hr_services = ["180d"]; let hr_characteristics = ["2a37"]; @@ -222,4 +233,11 @@ mod tests { fn test_uuid_case_and_whitespace_are_normalized() { assert_eq!(get_device_types(&dev(&[" 180D "], &[])), [DeviceType::HrSensor]); } + + #[test] + fn test_category_mapping() { + assert_eq!(DeviceType::HrSensor.category(), "hr"); + assert_eq!(DeviceType::CadSensor.category(), "cadence"); + assert_eq!(DeviceType::Trainer.category(), "trainer"); + } } diff --git a/src-tauri/src/device_repo/mod.rs b/src-tauri/src/device_repo/mod.rs index d2a027e..52e4a20 100644 --- a/src-tauri/src/device_repo/mod.rs +++ b/src-tauri/src/device_repo/mod.rs @@ -1,3 +1,3 @@ mod repo; -pub use repo::{repo_from_devices, Repo}; +pub use repo::Repo; diff --git a/src-tauri/src/device_repo/repo.rs b/src-tauri/src/device_repo/repo.rs index 94aa018..31fc824 100644 --- a/src-tauri/src/device_repo/repo.rs +++ b/src-tauri/src/device_repo/repo.rs @@ -1,5 +1,5 @@ use serde::{Deserialize, Serialize}; -use crate::device::{Device, DeviceType, get_device_types}; +use crate::device::Device; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Repo { @@ -20,115 +20,3 @@ impl Repo { } } } - -pub fn repo_from_devices(devices: Vec) -> Repo { - let mut repo = Repo::new(); - - for dev in devices.into_iter() { - let types = get_device_types(&dev); - - for t in types { - match t { - DeviceType::HrSensor => repo.hr_sensors.push(dev.clone()), - DeviceType::CadSensor => repo.cad_sensors.push(dev.clone()), - DeviceType::Trainer => repo.trainers.push(dev.clone()), - } - } - } - - fn sort_by_rssi_desc(v: &mut [Device]) { - v.sort_by(|a, b| { - let ra = a.rssi; - let rb = b.rssi; - match (ra, rb) { - (Some(va), Some(vb)) => vb.cmp(&va), - (Some(_), None) => std::cmp::Ordering::Less, - (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => std::cmp::Ordering::Equal, - } - }); - } - - sort_by_rssi_desc(&mut repo.hr_sensors); - sort_by_rssi_desc(&mut repo.cad_sensors); - sort_by_rssi_desc(&mut repo.trainers); - - repo -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Helper: a cadence device (CSC service 0x1816) with the given RSSI. - fn cadence_device(rssi: Option) -> Device { - Device { - id: format!("cad-{rssi:?}"), - address: "00:00:00:00:00:00".into(), - display_name: None, - rssi, - service_uuids: vec!["1816".into()], - characteristics: vec![], - manufacturer_data: None, - metadata: None, - } - } - - #[test] - fn test_repo_classifies_magene_as_cadence_sensor() { - let magene = Device { - id: "hci0/dev_F1_F2_7C_77_14_BE".into(), - address: "F1:F2:7C:77:14:BE".into(), - display_name: Some("10911-17".into()), - rssi: Some(-42), - service_uuids: vec!["00001816-0000-1000-8000-00805f9b34fb".into()], - characteristics: vec![], - manufacturer_data: Some("6b009f2a110300030072".into()), - metadata: None, - }; - - let repo = repo_from_devices(vec![magene]); - - assert_eq!(repo.cad_sensors.len(), 1); - assert!(repo.hr_sensors.is_empty()); - assert!(repo.trainers.is_empty()); - } - - #[test] - fn test_sorts_devices_by_rssi_descending() { - let devices = vec![ - cadence_device(Some(-80)), - cadence_device(Some(-42)), - cadence_device(Some(-60)), - ]; - let repo = repo_from_devices(devices); - let rssis: Vec<_> = repo.cad_sensors.iter().map(|d| d.rssi).collect(); - assert_eq!(rssis, vec![Some(-42), Some(-60), Some(-80)]); - } - - #[test] - fn test_devices_without_rssi_go_last() { - let devices = vec![cadence_device(None), cadence_device(Some(-50))]; - let repo = repo_from_devices(devices); - assert_eq!(repo.cad_sensors[0].rssi, Some(-50)); - assert_eq!(repo.cad_sensors[1].rssi, None); - } - - #[test] - fn test_indoor_bike_lands_in_trainer_and_cadence_buckets() { - let indoor_bike = Device { - id: "indoor-bike".into(), - address: "00:00:00:00:00:00".into(), - display_name: None, - rssi: Some(-70), - service_uuids: vec![], - characteristics: vec!["2ad2".into()], - manufacturer_data: None, - metadata: None, - }; - let repo = repo_from_devices(vec![indoor_bike]); - assert_eq!(repo.trainers.len(), 1); - assert_eq!(repo.cad_sensors.len(), 1); - assert!(repo.hr_sensors.is_empty()); - } -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d169bc3..5568165 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,8 @@ mod state; mod telemetry; mod commands; mod workout; +mod tcx; +mod logging; use std::pin::Pin; use std::sync::atomic::{AtomicBool, Ordering}; @@ -18,15 +20,13 @@ use tokio::time; use crate::ble::{BleSource, MockSource, SensorSource}; use crate::commands::Command; -use crate::device::Device; -use crate::device_repo::{repo_from_devices, Repo}; +use crate::device::{get_device_types, Device}; +use crate::device_repo::Repo; use crate::state::{State, TrainerState, WorkoutHandle}; use crate::telemetry::Telemetry; use btleplug::api::Manager as ApiManager; use btleplug::platform::Manager as BleManager; -/// Сколько секунд сканируем BLE на старте. -const SCAN_SECONDS: u64 = 5; #[derive(Clone, Serialize)] struct TelemetryEvent { @@ -69,20 +69,43 @@ struct WorkoutFinishedEvent { ok: bool, } +#[derive(Clone, Serialize)] +struct DeviceFoundEvent { + device: Device, + categories: Vec, +} + #[tauri::command] async fn init(app: AppHandle) -> Result<(), String> { - let repo = scan_and_classify(&app).await.map_err(|e| e.to_string())?; - present_repo(&app, repo).map_err(|e| e.to_string())?; + // показать главное окно (пока пустое — датчики подтянутся скан-потоком) + present_repo(&app, Repo::new()).map_err(|e| e.to_string())?; + // запустить непрерывный скан (устройства приходят событием device-found) + start_scan(&app).await.map_err(|e| e.to_string())?; Ok(()) } -/// Этап 1: скан BLE-устройств и раскладка по типам датчиков. -async fn scan_and_classify(app: &AppHandle) -> Result { +/// Запускает непрерывный скан: каждое найденное устройство эмитится событием +/// `device-found` и добавляется в `state.devices` для последующего connect. +async fn start_scan(app: &AppHandle) -> Result<(), ble::BleError> { let state = app.state::(); let source = state.source.clone(); - let devices = source.scan(SCAN_SECONDS).await?; - *state.devices.write().unwrap() = devices.clone(); - Ok(repo_from_devices(devices)) + let mut stream = source.scan_stream().await?; + + let app_t = app.clone(); + let handle = async_runtime::spawn(async move { + while let Some(device) = stream.next().await { + let categories: Vec = get_device_types(&device) + .iter() + .map(|t| t.category().to_string()) + .collect(); + crate::logging::log("scan", &format!("emit device-found: {:?} categories={:?}", device.display_name, categories)); + app_t.state::().devices.write().unwrap().push(device.clone()); + let _ = app_t.emit("device-found", DeviceFoundEvent { device, categories }); + } + }); + + *state.scan.lock().unwrap() = Some(handle); + Ok(()) } /// Этап 2: закрываем сплэш, показываем главное окно, отдаём список датчиков на фронт. @@ -106,6 +129,12 @@ fn present_repo(app: &AppHandle, repo: Repo) -> Result<(), String> { async fn connect(app: AppHandle, device_id: String, category: String) -> Result<(), String> { let state = app.state::(); + // Остановить непрерывный скан перед подключением: адаптер один, и постоянный + // скан во время соединения — источник нестабильности/обрывов. + if let Some(handle) = state.scan.lock().unwrap().take() { + handle.abort(); + } + let device = { let devices = state.devices.read().unwrap(); devices @@ -274,7 +303,7 @@ async fn apply_power( workout::percent_to_watts(workout::target_percent(p, elapsed_secs, seg.duration_secs), ftp) }); if let Some(w) = watts.filter(|w| Some(*w) != last_watts) { - let _ = source.command(device, Command::FtmsSetTargetPower(w)).await; + send_command(source, device, Command::FtmsSetTargetPower(w)).await; } watts } @@ -297,6 +326,14 @@ fn progress_event( } } +/// Шлёт команду на станок; ошибку логирует, но не прерывает воркаут (ERG-команды +/// — best-effort: пропущенная цель не должна ронять всю тренировку). +async fn send_command(source: &Arc, device: &Device, cmd: Command) { + if let Err(e) = source.command(device, cmd.clone()).await { + crate::logging::log("workout", &format!("command {:?} failed: {e}", cmd)); + } +} + /// Ядро исполнения воркаута (без AppHandle) — тестируемо против MockSource. async fn run_workout_engine( source: &Arc, @@ -307,10 +344,11 @@ async fn run_workout_engine( tick_duration: Duration, mut on_progress: impl FnMut(WorkoutProgressEvent) + Send, ) -> bool { - if source.request_control(device).await.is_err() { + if let Err(e) = source.request_control(device).await { + crate::logging::log("workout", &format!("request_control failed: {e}")); return false; } - let _ = source.command(device, Command::FtmsStart).await; + send_command(source, device, Command::FtmsStart).await; let total = workout::total_duration_secs(segments); let mut elapsed_total: u64 = 0; @@ -320,7 +358,7 @@ async fn run_workout_engine( // каденс — константа сегмента, шлём один раз (в 0.5-rpm единицах FTMS). if let Some(half) = seg.cadence_rpm.and_then(|rpm| rpm.checked_mul(2)) { - let _ = source.command(device, Command::FtmsSetTargetCadence(half)).await; + send_command(source, device, Command::FtmsSetTargetCadence(half)).await; } let mut last_watts: Option = None; @@ -346,7 +384,7 @@ async fn run_workout_engine( } } - let _ = source.command(device, Command::FtmsStop).await; + send_command(source, device, Command::FtmsStop).await; true } @@ -386,6 +424,7 @@ fn start_workout(app: AppHandle, path: String) -> Result<(), String> { let state = app.state::(); let ftp = *state.ftp.lock().unwrap(); + crate::logging::log("workout", &format!("start workout '{}': {} segments, ftp {}", workout.name, segments.len(), ftp)); let device = trainer_device(&state)?; let source = state.source.clone(); @@ -420,6 +459,9 @@ async fn stop_workout(app: AppHandle) -> Result<(), String> { h.task.abort(); let device = trainer_device(&state)?; let _ = state.source.command(&device, Command::FtmsStop).await; + // Ручная остановка тоже сохраняет накопленный (частичный) воркаут: + // после abort задача не дойдёт до emit, поэтому шлём событие здесь. + let _ = app.emit("workout-finished", WorkoutFinishedEvent { ok: true }); } Ok(()) } @@ -434,6 +476,34 @@ fn pause_workout(app: AppHandle, paused: bool) -> Result<(), String> { Ok(()) } +/// Сохранить завершённый воркаут в TCX (для ручной загрузки в intervals.icu). +/// Возвращает путь к созданному файлу. +#[tauri::command] +fn save_workout(name: String, samples: Vec) -> Result { + if samples.is_empty() { + return Err("нет данных для сохранения".to_string()); + } + let xml = tcx::serialize_tcx(&name, &samples); + + // Имя файла из валидированного ISO-префикса первой точки: + // "2026-10-28T10:00:00..." -> date "2026-10-28", time "10-00-00". + // Валидация закрывает path traversal (нельзя всунуть ".."), а суффикс + // времени — коллизию двух воркаутов в один день с одним именем. + let start = &samples[0].time; + if !tcx::is_iso_timestamp(start) { + return Err(format!("некорректное время сэмпла: {}", start)); + } + let date = &start[0..10]; + let time_slug = start[11..19].replace(':', "-"); + let filename = format!("{}_{}_{}.tcx", date, time_slug, tcx::slugify(&name)); + let dir = tcx::activities_dir(); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let path = dir.join(&filename); + std::fs::write(&path, xml).map_err(|e| e.to_string())?; + crate::logging::log("save", &format!("saved {}", path.display())); + Ok(path.to_string_lossy().into_owned()) +} + /// Why the watchdog loop stopped consuming a stream. enum StreamOutcome { /// The stream ended (peripheral disconnected / notifications closed). @@ -572,6 +642,7 @@ async fn reapply_trainer_state( #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + crate::logging::init(); tauri::Builder::default() .setup(|app| { async_runtime::block_on(async move { @@ -590,6 +661,7 @@ pub fn run() { devices: RwLock::new(Vec::new()), trainer: Mutex::new(None), streams: Mutex::new(std::collections::HashMap::new()), + scan: Mutex::new(None), trainer_state: Mutex::new(TrainerState::default()), ftp: Mutex::new(state::DEFAULT_FTP), workout: Mutex::new(None), @@ -610,7 +682,8 @@ pub fn run() { get_ftp, start_workout, stop_workout, - pause_workout + pause_workout, + save_workout ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); @@ -664,7 +737,11 @@ mod tests { async fn workout_engine_sends_expected_commands() { let mock = Arc::new(MockSource::new()); let source: Arc = mock.clone(); - let devices = source.scan(0).await.unwrap(); + let mut devices = Vec::new(); + let mut scan_stream = source.scan_stream().await.unwrap(); + while let Some(d) = scan_stream.next().await { + devices.push(d); + } let trainer = devices.iter().find(|d| d.id == "mock-trainer").unwrap().clone(); // короткий воркаут: фикс-сегмент + ramp-сегмент diff --git a/src-tauri/src/logging.rs b/src-tauri/src/logging.rs new file mode 100644 index 0000000..1c6d515 --- /dev/null +++ b/src-tauri/src/logging.rs @@ -0,0 +1,67 @@ +//! Минимальный файловый лог для пост-анализа реального прогона. +//! +//! Логируется «управляющая плоскость»: BLE-события (подключение/подписка), +//! команды на Control Point и их результат, подтверждение контроля, жизненный +//! цикл воркаута и ошибки. Данные телеметрии сюда НЕ пишутся — они уходят в +//! .tcx. Так лог остаётся читаемым и пригодным для разбора после запуска. + +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +static LOG_FILE: Mutex> = Mutex::new(None); + +/// Открывает лог-файл на дозапись (env `RIDEIN_LOG`, иначе `ridein.log` в cwd). +/// Вызывается один раз при старте приложения. +pub(crate) fn init() { + let path = std::env::var("RIDEIN_LOG").unwrap_or_else(|_| "ridein.log".to_string()); + let file = OpenOptions::new().create(true).append(true).open(&path).ok(); + *LOG_FILE.lock().unwrap() = file; + log("logging", &format!("log initialized -> {path}")); +} + +/// Timestamp в секундах.millis с начала эпохи (монотонный, сортируемый). +fn timestamp() -> String { + let d = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default(); + format!("{}.{:03}", d.as_secs(), d.subsec_millis()) +} + +/// Пишет строку в лог. Best-effort: если лог не инициализирован или запись не +/// удалась — молча пропускается (в юнит-тестах лог не инициализируется). +pub(crate) fn log(component: &str, msg: &str) { + let mut guard = LOG_FILE.lock().unwrap(); + if let Some(f) = guard.as_mut() { + let _ = writeln!(f, "{} [{}] {}", timestamp(), component, msg); + let _ = f.flush(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Read; + + #[test] + fn logs_timestamped_line_to_file() { + // сбрасываем глобальное состояние (тесты идут параллельно) + *LOG_FILE.lock().unwrap() = None; + + let path = std::env::temp_dir().join(format!("ridein-log-test-{}.log", std::process::id())); + std::env::set_var("RIDEIN_LOG", &path); + init(); + log("test", "hello world"); + + let mut s = String::new(); + std::fs::File::open(&path).unwrap().read_to_string(&mut s).unwrap(); + assert!(s.contains("[test] hello world"), "log line missing: {s}"); + // timestamp — «seconds.millis» (цифры и одна точка) + let first = s.lines().next().unwrap_or(""); + let head = first.split(' ').next().unwrap_or(""); + assert!(head.chars().all(|c| c.is_ascii_digit() || c == '.'), "bad timestamp: {head}"); + + let _ = std::fs::remove_file(&path); + *LOG_FILE.lock().unwrap() = None; + std::env::remove_var("RIDEIN_LOG"); + } +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index e7a6fcf..0aaadf2 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -32,6 +32,8 @@ pub struct State { pub trainer: Mutex>, /// Потоки телеметрии по категориям ("hr"/"cadence"/"trainer") pub streams: Mutex>>, + /// Задача непрерывного скана (обрывается при подключении устройства). + pub scan: Mutex>>, /// Trainer control state, restored after a reconnect. pub trainer_state: Mutex, /// FTP гонщика (Вт), для перевода %FTP воркаутов в ватты. diff --git a/src-tauri/src/tcx.rs b/src-tauri/src/tcx.rs new file mode 100644 index 0000000..eecf65a --- /dev/null +++ b/src-tauri/src/tcx.rs @@ -0,0 +1,205 @@ +//! Сохранение завершённого воркаута в TCX (Garmin Training Center XML). +//! +//! intervals.icu принимает TCX при ручной загрузке активности и читает из него +//! мощность (через расширение TPX), каденс и пульс. Формат выбран потому, что +//! это открытый XML, который просто генерировать строкой и легко проверить +//! глазами (в отличие от бинарного FIT). + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +/// Одна точка измерения во время воркаута (~1 Гц). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActivitySample { + /// Абсолютное время точки, ISO 8601 UTC (напр. "2026-10-28T10:00:05Z"). + pub time: String, + pub watts: Option, + pub cadence: Option, + pub hr: Option, +} + +/// Экранирует XML-спецсимволы. +fn esc(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +/// Сериализует воркаут в TCX-документ. `samples` должны идти по возрастанию +/// времени; первая точка задаёт `Id` и `Lap StartTime`. +pub fn serialize_tcx(name: &str, samples: &[ActivitySample]) -> String { + let mut out = String::new(); + out.push_str("\n"); + out.push_str( + "\n", + ); + out.push_str(" \n"); + out.push_str(" \n"); + + let start = samples.first().map(|s| s.time.as_str()).unwrap_or(""); + // после дедупликации на фронте первая точка — t=0, остальные 1..N, + // поэтому длительность = число точек минус один. + let total = samples.len().saturating_sub(1) as u64; + + out.push_str(&format!(" {}\n", start)); + out.push_str(&format!(" \n", start)); + out.push_str(&format!(" {}\n", total)); + out.push_str(" 0\n"); + out.push_str(" Active\n"); + out.push_str(" Manual\n"); + out.push_str(" \n"); + + for s in samples { + out.push_str(" \n"); + out.push_str(&format!(" \n", s.time)); + if let Some(hr) = s.hr { + out.push_str(&format!( + " {}\n", + hr + )); + } + if let Some(cad) = s.cadence { + out.push_str(&format!(" {}\n", cad)); + } + if let Some(w) = s.watts { + out.push_str(" \n"); + out.push_str( + " \n", + ); + out.push_str(&format!(" {}\n", w)); + out.push_str(" \n"); + out.push_str(" \n"); + } + out.push_str(" \n"); + } + + out.push_str(" \n"); + out.push_str(" \n"); + out.push_str(&format!(" {}\n", esc(name))); + out.push_str(" \n"); + out.push_str(" \n"); + out.push_str("\n"); + out +} + +/// Каталог сохранённых активностей: env `RIDEIN_ACTIVITIES_DIR`, иначе +/// `activities/` рядом с cwd (как `workouts_dir` для запланированных). +pub fn activities_dir() -> PathBuf { + std::env::var("RIDEIN_ACTIVITIES_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("activities")) +} + +/// Имя → безопасный слаг для имени файла (ASCII alnum, остальное — `_`). +pub fn slugify(name: &str) -> String { + let mut out = String::new(); + let mut prev_underscore = false; + for c in name.chars() { + if c.is_ascii_alphanumeric() { + out.push(c.to_ascii_lowercase()); + prev_underscore = false; + } else if !prev_underscore { + out.push('_'); + prev_underscore = true; + } + } + let trimmed = out.trim_matches('_').to_string(); + if trimmed.is_empty() { + "workout".to_string() + } else { + trimmed + } +} + +/// Проверяет, что строка начинается с валидного ISO-префикса +/// `YYYY-MM-DDTHH:MM:SS` — достаточно для безопасного извлечения даты/времени +/// в имя файла (не даёт протащить `..` или `/`). +pub fn is_iso_timestamp(s: &str) -> bool { + let b = s.as_bytes(); + if b.len() < 19 { + return false; + } + let d = |i: usize| b[i].is_ascii_digit(); + d(0) && d(1) && d(2) && d(3) + && b[4] == b'-' + && d(5) && d(6) + && b[7] == b'-' + && d(8) && d(9) + && b[10] == b'T' + && d(11) && d(12) + && b[13] == b':' + && d(14) && d(15) + && b[16] == b':' + && d(17) && d(18) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample(time: &str, watts: Option, cadence: Option, hr: Option) -> ActivitySample { + ActivitySample { time: time.to_string(), watts, cadence, hr } + } + + #[test] + fn serializes_full_trackpoint() { + let samples = vec![sample("2026-10-28T10:00:00Z", Some(150), Some(85), Some(120))]; + let xml = serialize_tcx("Threshold", &samples); + assert!(xml.contains("")); + assert!(xml.contains("2026-10-28T10:00:00Z")); + assert!(xml.contains("")); + assert!(xml.contains("120")); + assert!(xml.contains("85")); + assert!(xml.contains("150")); + assert!(xml.contains("140")); + assert!(!xml.contains("")); + assert!(!xml.contains("")); + } + + #[test] + fn escapes_name() { + let samples = vec![sample("2026-10-28T10:00:00Z", Some(1), None, None)]; + let xml = serialize_tcx("A & B < C", &samples); + assert!(xml.contains("A & B < C")); + } + + #[test] + fn slugifies_filenames() { + assert_eq!(slugify("Threshold Over-Under 2x20"), "threshold_over_under_2x20"); + assert_eq!(slugify(" !! "), "workout"); + assert_eq!(slugify("VO2max 5x4min"), "vo2max_5x4min"); + } + + #[test] + fn notes_go_after_lap() { + let samples = vec![sample("2026-10-28T10:00:00Z", Some(1), None, None)]; + let xml = serialize_tcx("W", &samples); + let notes = xml.find("").expect("Notes present"); + let lap_end = xml.find("").expect("Lap close present"); + assert!(notes > lap_end, "Notes must come after "); + } + + #[test] + fn validates_iso_timestamp() { + assert!(is_iso_timestamp("2026-10-28T10:00:00.000Z")); + assert!(is_iso_timestamp("2026-10-28T10:00:00")); + assert!(!is_iso_timestamp("../../../../etc")); + assert!(!is_iso_timestamp("2026-10-28")); + assert!(!is_iso_timestamp("")); + } +} diff --git a/src-tauri/src/telemetry.rs b/src-tauri/src/telemetry.rs index bcab3e4..4492c98 100644 --- a/src-tauri/src/telemetry.rs +++ b/src-tauri/src/telemetry.rs @@ -44,6 +44,16 @@ pub(crate) fn parse_indoor_bike_data(bytes: &[u8]) -> Vec { let flags = u16::from_le_bytes([bytes[0], bytes[1]]); let mut out = Vec::new(); + // Magene T110 (и, возможно, другие Magene): шлёт НЕстандартный Indoor Bike Data — + // флаги всегда 0x0040, а раскладка фиксированная: speed(2-3), power(4-5), + // cadence(6-7, 0.1 rpm). Стандартный разбор по флагам тут не срабатывает. + // Единица 0.1 rpm (не стандартные 0.5): проверено по соотношению скорость/каденс. + if flags == 0x0040 && bytes.len() >= 8 { + let raw_cad = u16::from_le_bytes([bytes[6], bytes[7]]); + out.push(Telemetry::Cadence { rpm: raw_cad as f32 / 10.0 }); + return out; + } + // Byte offset of instantaneous cadence (after the 2-byte flags). let cadence_off = 2 + if flags & 0x0100 != 0 { 2 } else { 0 } // instantaneous speed @@ -185,6 +195,17 @@ mod tests { assert!(parse_indoor_bike_data(&[]).is_empty()); } + #[test] + fn test_magene_indoor_bike_cadence() { + // Реальный пакет Magene T110 (ручное вращение): flags=0x0040, speed=0x02f2, + // power=8 (4-5), cadence=0x01fd (6-7) = 509 * 0.1 = 50.9 rpm. + let bytes = [0x40, 0x00, 0xf2, 0x02, 0x08, 0x00, 0xfd, 0x01, 0x09, 0x12, 0x00, 0x00, 0x1c, 0x00, 0xe8, 0x03]; + assert_eq!( + parse_indoor_bike_data(&bytes), + vec![Telemetry::Cadence { rpm: 50.9 }] + ); + } + #[test] fn test_parse_battery() { assert_eq!(parse_battery(&[100]), Some(Telemetry::Battery { percent: 100 })); diff --git a/src/main.js b/src/main.js index 79bb870..7704fc9 100644 --- a/src/main.js +++ b/src/main.js @@ -141,6 +141,18 @@ once("devices-loaded", (event) => { renderGroup(groups.trainers, repo.trainers ?? []); }); +// Непрерывный скан: новые датчики добавляются в список по мере появления. +listen("device-found", (event) => { + const { device, categories } = event.payload; + for (const cat of categories) { + const group = Object.values(groups).find((g) => g.category === cat); + if (!group) continue; + const empty = group.list.querySelector(".device--empty"); + if (empty) empty.remove(); + group.list.appendChild(renderDevice(device, cat)); + } +}); + listen("battery", (event) => { const { device_id, percent } = event.payload; const card = deviceCards.get(device_id); @@ -163,6 +175,7 @@ listen("telemetry", (event) => { setMetric("cadence-value", `${actualCadence} rpm`); updateCadenceDeviation(); } else if (t.HeartRate) { + actualHr = t.HeartRate.bpm; setMetric("hr-value", t.HeartRate.bpm); sampleHr(t.HeartRate.bpm); scheduleDraw(); @@ -222,6 +235,7 @@ let targetWatts = null; let targetCadence = null; let actualWatts = null; let actualCadence = null; +let actualHr = null; function powerTolerance(target) { return Math.max(POWER_TOL_ABS, Math.round(target * POWER_TOL_PCT)); @@ -410,6 +424,10 @@ window.addEventListener("resize", () => { let workouts = []; let workoutPaused = false; +let workoutSamples = []; +let workoutStartMs = 0; +let workoutSaveName = "workout"; +let lastSampleSecs = -1; function formatClock(sec) { const m = Math.floor(sec / 60); @@ -465,6 +483,13 @@ workoutStartBtn.addEventListener("click", async () => { const w = selectedWorkout(); const ftp = Number(ftpInput.value); if (!w || !Number.isFinite(ftp) || ftp <= 0) return; + workoutSamples = []; + workoutStartMs = Date.now(); + workoutSaveName = w.name; + actualWatts = null; + actualCadence = null; + actualHr = null; + lastSampleSecs = -1; try { await invoke("set_ftp", { ftp }); await invoke("start_workout", { path: w.path }); @@ -503,8 +528,33 @@ listen("workout-progress", (event) => { timerEl.textContent = formatClock(p.elapsed_secs); const pct = p.total_secs > 0 ? (p.elapsed_secs / p.total_secs) * 100 : 0; workoutBar.style.width = `${Math.min(100, pct)}%`; + if (p.elapsed_secs > lastSampleSecs) { + lastSampleSecs = p.elapsed_secs; + workoutSamples.push({ + time: new Date(workoutStartMs + p.elapsed_secs * 1000).toISOString(), + watts: actualWatts, + cadence: actualCadence, + hr: actualHr, + }); + } }); -listen("workout-finished", () => { +listen("workout-finished", (event) => { setRunning(false); + if (event.payload?.ok && workoutSamples.length) { + saveWorkout(); + } }); + +async function saveWorkout() { + try { + const path = await invoke("save_workout", { + name: workoutSaveName, + samples: workoutSamples, + }); + console.log("saved workout:", path); + workoutMeta.textContent = `сохранено: ${path}`; + } catch (e) { + console.error(e); + } +} -- 2.36.5 From 39756c9ed4a2c39270fea8c0463ccd8d62cd1372 Mon Sep 17 00:00:00 2001 From: lambda Date: Thu, 29 Oct 2026 15:33:34 +0300 Subject: [PATCH 2/2] fix: using power meter as cadence sensor --- src-tauri/src/ble/source.rs | 26 ++++++--- src-tauri/src/device/mod.rs | 28 +++++++--- src-tauri/src/device_repo/repo.rs | 3 + src-tauri/src/lib.rs | 15 +++-- src-tauri/src/telemetry.rs | 68 +++++++++++++++++------ src/index.html | 11 ++++ src/main.js | 91 ++++++++++++++++++++++++++++--- src/styles.css | 40 +++++++++++++- 8 files changed, 237 insertions(+), 45 deletions(-) diff --git a/src-tauri/src/ble/source.rs b/src-tauri/src/ble/source.rs index de15cc4..0903f82 100644 --- a/src-tauri/src/ble/source.rs +++ b/src-tauri/src/ble/source.rs @@ -14,7 +14,7 @@ use uuid::Uuid; use crate::ble::BleError; use crate::commands::{encode_command, parse_control_result, Command, ControlPoint, ControlResult}; use crate::device::Device; -use crate::telemetry::{parse_csc, parse_hr, parse_indoor_bike_data, parse_power, CscAccumulator, Telemetry}; +use crate::telemetry::{parse_csc, parse_hr, parse_indoor_bike_data, parse_power, parse_power_crank, CscAccumulator, Telemetry}; #[async_trait] pub(crate) trait SensorSource: Send + Sync { @@ -231,9 +231,11 @@ impl SensorSource for BleSource { let mut notifications = notifications; let mut acc = CscAccumulator::new(); let mut cancel_rx = cancel_rx; - // Первые 30 уведомлений логируем с сырыми байтами — чтобы видеть, что - // реально шлёт устройство (какие флаги/поля в 0x2AD2 и т.п.). - let mut logged = 0u32; + // Логируем сырые байты для диагностики. 0x2AD2 (Indoor Bike Data — там + // каденс/скорость/мощность) логируем дольше (до 500), остальные — до 30, + // чтобы «пустые» уведомления до начала вращения не съели лимит. + let mut bike_logged = 0u32; + let mut other_logged = 0u32; loop { tokio::select! { n = notifications.next() => { @@ -242,8 +244,11 @@ impl SensorSource for BleSource { let uuid = n.uuid; let raw = hex::encode(&n.value); let parsed = parse_notification(n, &mut acc, &control); - if logged < 30 { - logged += 1; + let is_bike = uuid.to_string().starts_with("00002ad2"); + let counter = if is_bike { &mut bike_logged } else { &mut other_logged }; + let cap = if is_bike { 500u32 } else { 30u32 }; + if *counter < cap { + *counter += 1; crate::logging::log("telemetry", &format!("uuid={uuid} raw={raw} parsed={parsed:?}")); } for t in parsed { @@ -371,7 +376,14 @@ fn parse_notification( .into_iter() .collect() } else if n.uuid == POWER_MEASUREMENT { - parse_power(&n.value).into_iter().collect() + // мощность + каденс из crank revolution data (пауэрметр Quarq и т.п.) + let mut out: Vec = parse_power(&n.value).into_iter().collect(); + if let Some(raw) = parse_power_crank(&n.value) { + if let Some(rpm) = acc.push(raw) { + out.push(Telemetry::Cadence { rpm }); + } + } + out } else if n.uuid == INDOOR_BIKE_DATA { parse_indoor_bike_data(&n.value) } else if n.uuid == CONTROL_POINT { diff --git a/src-tauri/src/device/mod.rs b/src-tauri/src/device/mod.rs index 11e5d5c..7f39320 100644 --- a/src-tauri/src/device/mod.rs +++ b/src-tauri/src/device/mod.rs @@ -23,15 +23,17 @@ pub enum DeviceType { HrSensor, CadSensor, Trainer, + PowerMeter, } impl DeviceType { - /// Категория на фронте ("hr"/"cadence"/"trainer") для маршрутизации события. + /// Категория на фронте ("hr"/"cadence"/"trainer"/"power") для маршрутизации события. pub fn category(&self) -> &'static str { match self { DeviceType::HrSensor => "hr", DeviceType::CadSensor => "cadence", DeviceType::Trainer => "trainer", + DeviceType::PowerMeter => "power", } } } @@ -43,9 +45,13 @@ pub fn get_device_types(device: &Device) -> Vec { let csc_services = ["1816"]; let csc_characteristics = ["2a5b"]; - // trainer / cycling power / fitness machine / indoor bike - let trainer_services = ["1818", "1826"]; - let trainer_characteristics = ["2a63", "2ad2", "2acc"]; // 2AD2 = Indoor Bike Data, 2ACC = Fitness Machine Characteristics, 2A63 = Cycling Power Measurement + // smart trainer / FTMS (indoor bike, fitness machine) + let trainer_services = ["1826"]; + let trainer_characteristics = ["2ad2", "2acc"]; // 2AD2 = Indoor Bike Data, 2ACC = Fitness Machine Characteristics + + // power meter (Cycling Power без FTMS) — Quarq и т.п. + let power_services = ["1818"]; + let power_characteristics = ["2a63"]; // 2A63 = Cycling Power Measurement // любые 2ADx характеристики (indoor bike related) - учитывать как признак каденса доступного в тренажёре let indoor_bike_chars_prefix = "2ad"; // covers 2ad2,2ad5,2ad6,2ad8,2ad9,2ada etc. @@ -84,10 +90,13 @@ pub fn get_device_types(device: &Device) -> Vec { out.push(DeviceType::CadSensor); } - // Trainer/Cycling power/Indoor bike + // Trainer (FTMS). Приоритет: станок рекламирует и 0x1826, и 0x1818 — он всё равно станок. let is_trainer = contains_any(&trainer_services) || contains_any(&trainer_characteristics); if is_trainer { out.push(DeviceType::Trainer); + } else if contains_any(&power_services) || contains_any(&power_characteristics) { + // Power meter (Cycling Power без FTMS) — Quarq и т.п. + out.push(DeviceType::PowerMeter); } // Доп. логика: если найдены Indoor Bike Data (любые 2ADx characteristics), считаем, что устройство может отдавать cadence - @@ -174,13 +183,13 @@ mod tests { } #[test] - fn test_trainer_by_power_service() { - assert_eq!(get_device_types(&dev(&["1818"], &[])), [DeviceType::Trainer]); + fn test_power_meter_by_service() { + assert_eq!(get_device_types(&dev(&["1818"], &[])), [DeviceType::PowerMeter]); } #[test] - fn test_trainer_by_power_characteristic() { - assert_eq!(get_device_types(&dev(&[], &["2a63"])), [DeviceType::Trainer]); + fn test_power_meter_by_characteristic() { + assert_eq!(get_device_types(&dev(&[], &["2a63"])), [DeviceType::PowerMeter]); } #[test] @@ -239,5 +248,6 @@ mod tests { assert_eq!(DeviceType::HrSensor.category(), "hr"); assert_eq!(DeviceType::CadSensor.category(), "cadence"); assert_eq!(DeviceType::Trainer.category(), "trainer"); + assert_eq!(DeviceType::PowerMeter.category(), "power"); } } diff --git a/src-tauri/src/device_repo/repo.rs b/src-tauri/src/device_repo/repo.rs index 31fc824..fc5e661 100644 --- a/src-tauri/src/device_repo/repo.rs +++ b/src-tauri/src/device_repo/repo.rs @@ -9,6 +9,8 @@ pub struct Repo { cad_sensors: Vec, #[serde(default)] trainers: Vec, + #[serde(default)] + power_meters: Vec, } impl Repo { @@ -17,6 +19,7 @@ impl Repo { hr_sensors: Vec::new(), cad_sensors: Vec::new(), trainers: Vec::new(), + power_meters: Vec::new(), } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5568165..d5adda8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -274,6 +274,13 @@ fn get_workout(path: String) -> Result { workout::read_workout(&path).map_err(|e| e.to_string()) } +/// Развёрнутые линейные сегменты воркаута (для отрисовки профиля). +#[tauri::command] +fn get_segments(path: String) -> Result, String> { + let w = workout::read_workout(&path).map_err(|e| e.to_string())?; + Ok(workout::flatten(&w.steps)) +} + /// Задать FTP гонщика (Вт). Используется для перевода %FTP → ватты. #[tauri::command] fn set_ftp(app: AppHandle, ftp: u16) -> Result<(), String> { @@ -344,10 +351,9 @@ async fn run_workout_engine( tick_duration: Duration, mut on_progress: impl FnMut(WorkoutProgressEvent) + Send, ) -> bool { - if let Err(e) = source.request_control(device).await { - crate::logging::log("workout", &format!("request_control failed: {e}")); - return false; - } + // Контроль станка уже запрошен при подключении (run_watchdog). Повторный + // request_control здесь срывает тренировку на Magene (отказ/таймаут при уже + // выданном контроле) — воркаут мгновенно «останавливается» с ok:false. send_command(source, device, Command::FtmsStart).await; let total = workout::total_duration_secs(segments); @@ -678,6 +684,7 @@ pub fn run() { trainer_stop, list_workouts, get_workout, + get_segments, set_ftp, get_ftp, start_workout, diff --git a/src-tauri/src/telemetry.rs b/src-tauri/src/telemetry.rs index 4492c98..c2a8dbe 100644 --- a/src-tauri/src/telemetry.rs +++ b/src-tauri/src/telemetry.rs @@ -35,6 +35,35 @@ pub(crate) fn parse_power(bytes: &[u8]) -> Option { Some(Telemetry::Power { watts }) } +/// Cycling Power Measurement (0x2A63): crank revolution data (кумулятивные обороты +/// шатуна + время) для вычисления каденса. Поле опциональное (флаг bit 5 = 0x0020), +/// после мгновенной мощности и предшествующих опциональных полей. +pub(crate) fn parse_power_crank(bytes: &[u8]) -> Option { + if bytes.len() < 4 { + return None; + } + let flags = u16::from_le_bytes([bytes[0], bytes[1]]); + if flags & 0x0020 == 0 { + return None; // нет crank revolution data + } + let mut idx = 4; // flags(2) + instantaneous power(2) + if flags & 0x0001 != 0 { + idx += 1; // pedal power balance (uint8) + } + if flags & 0x0004 != 0 { + idx += 2; // accumulated torque (uint16) + } + if flags & 0x0010 != 0 { + idx += 6; // wheel revolution data (uint32 + uint16) + } + if bytes.len() < idx + 4 { + return None; + } + let crank_revs = u16::from_le_bytes([bytes[idx], bytes[idx + 1]]); + let crank_time = u16::from_le_bytes([bytes[idx + 2], bytes[idx + 3]]); + Some(CscRaw { crank_revs, crank_time }) +} + /// Indoor Bike Data (0x2AD2): variable-length, fields driven by flags. /// Extracts instantaneous cadence (bit 3) and instantaneous power (bit 5). pub(crate) fn parse_indoor_bike_data(bytes: &[u8]) -> Vec { @@ -44,15 +73,10 @@ pub(crate) fn parse_indoor_bike_data(bytes: &[u8]) -> Vec { let flags = u16::from_le_bytes([bytes[0], bytes[1]]); let mut out = Vec::new(); - // Magene T110 (и, возможно, другие Magene): шлёт НЕстандартный Indoor Bike Data — - // флаги всегда 0x0040, а раскладка фиксированная: speed(2-3), power(4-5), - // cadence(6-7, 0.1 rpm). Стандартный разбор по флагам тут не срабатывает. - // Единица 0.1 rpm (не стандартные 0.5): проверено по соотношению скорость/каденс. - if flags == 0x0040 && bytes.len() >= 8 { - let raw_cad = u16::from_le_bytes([bytes[6], bytes[7]]); - out.push(Telemetry::Cadence { rpm: raw_cad as f32 / 10.0 }); - return out; - } + // Magene T110: НЕстандартный Indoor Bike Data — флаги всегда 0x0040, раскладка + // speed(2-3, 0.01 km/h), power(4-5, W), flywheel speed(6-7, rpm). Каденса в 0x2AD2 + // НЕТ: станок не отдаёт обороты шатунов по стандартному BLE. Поэтому здесь ничего + // не извлекаем — стандартный разбор ниже вернёт пусто (флаги 0x0040 = avg power). // Byte offset of instantaneous cadence (after the 2-byte flags). let cadence_off = 2 @@ -175,6 +199,21 @@ mod tests { assert_eq!(parse_power(&[0x00, 0x00, 0xC8]), None); } + #[test] + fn test_parse_power_crank_extracts_crank_data() { + // flags=0x0021 (bit0 pedal balance + bit5 crank), power=200, balance=50, + // crank revs=10, crank time=2048 + let bytes = [0x21, 0x00, 0xC8, 0x00, 0x32, 0x0A, 0x00, 0x00, 0x08]; + assert_eq!(parse_power_crank(&bytes), Some(CscRaw { crank_revs: 10, crank_time: 2048 })); + } + + #[test] + fn test_parse_power_crank_no_crank_data() { + // flags=0x0001 (только pedal balance) — crank revolution data нет + let bytes = [0x01, 0x00, 0xC8, 0x00, 0x32]; + assert_eq!(parse_power_crank(&bytes), None); + } + #[test] fn test_indoor_bike_cadence_and_power() { // flags: bit 3 (cadence) + bit 5 (power) = 0x0028 @@ -196,14 +235,11 @@ mod tests { } #[test] - fn test_magene_indoor_bike_cadence() { - // Реальный пакет Magene T110 (ручное вращение): flags=0x0040, speed=0x02f2, - // power=8 (4-5), cadence=0x01fd (6-7) = 509 * 0.1 = 50.9 rpm. + fn test_magene_indoor_bike_has_no_cadence() { + // Magene T110 0x2AD2: flags=0x0040, speed(2-3), power(4-5), flywheel(6-7). + // Каденса нет — стандартный разбор по флагам (0x0040 = avg power) пуст. let bytes = [0x40, 0x00, 0xf2, 0x02, 0x08, 0x00, 0xfd, 0x01, 0x09, 0x12, 0x00, 0x00, 0x1c, 0x00, 0xe8, 0x03]; - assert_eq!( - parse_indoor_bike_data(&bytes), - vec![Telemetry::Cadence { rpm: 50.9 }] - ); + assert!(parse_indoor_bike_data(&bytes).is_empty()); } #[test] diff --git a/src/index.html b/src/index.html index 9d72778..93106aa 100644 --- a/src/index.html +++ b/src/index.html @@ -25,12 +25,21 @@

каденс

    +
    +

    мощность

    +
      +

      станок

        + + @@ -53,6 +62,8 @@

        + +
        мощность diff --git a/src/main.js b/src/main.js index 7704fc9..0b38efe 100644 --- a/src/main.js +++ b/src/main.js @@ -4,11 +4,13 @@ const { invoke } = window.__TAURI__.core; const connectScreen = document.querySelector("#connect"); const workoutScreen = document.querySelector("#workout"); const startBtn = document.querySelector("#start-btn"); +const powerSourceCheck = document.querySelector("#power-source-check"); const groups = { hr_sensors: { list: document.querySelector("#hr-list"), category: "hr" }, cad_sensors: { list: document.querySelector("#cadence-list"), category: "cadence" }, trainers: { list: document.querySelector("#trainer-list"), category: "trainer" }, + power_meters: { list: document.querySelector("#power-list"), category: "power" }, }; const deviceCards = new Map(); // device_id -> li @@ -108,7 +110,7 @@ async function connect(deviceId, btn, category, card) { resetSelected(category); btn.textContent = "✓"; btn.classList.add("device__connect--done"); - selected[category] = { btn, card }; + selected[category] = { btn, card, deviceId }; if (pendingStatus[category]) { applyStatus(selected[category], category, pendingStatus[category]); delete pendingStatus[category]; @@ -139,6 +141,7 @@ once("devices-loaded", (event) => { renderGroup(groups.hr_sensors, repo.hr_sensors ?? []); renderGroup(groups.cad_sensors, repo.cad_sensors ?? []); renderGroup(groups.trainers, repo.trainers ?? []); + renderGroup(groups.power_meters, repo.power_meters ?? []); }); // Непрерывный скан: новые датчики добавляются в список по мере появления. @@ -164,12 +167,19 @@ listen("battery", (event) => { listen("telemetry", (event) => { const t = event.payload.telemetry; + const devId = event.payload.device_id; if (t.Power) { - actualWatts = t.Power.watts; - setMetric("power-value", `${actualWatts} W`); - updatePowerDeviation(); - samplePower(actualWatts); - scheduleDraw(); + // источник мощности: пауэрметр (чекбокс + он подключён) или станок + const pmId = selected.power?.deviceId; + const trainerId = selected.trainer?.deviceId; + const expectedId = powerSourceCheck.checked && pmId ? pmId : trainerId; + if (!expectedId || devId === expectedId) { + actualWatts = t.Power.watts; + setMetric("power-value", `${actualWatts} W`); + updatePowerDeviation(); + samplePower(actualWatts); + scheduleDraw(); + } } else if (t.Cadence) { actualCadence = Math.round(t.Cadence.rpm); setMetric("cadence-value", `${actualCadence} rpm`); @@ -217,6 +227,7 @@ const workoutMeta = document.querySelector("#workout-meta"); const workoutName = document.querySelector("#workout-name"); const workoutStep = document.querySelector("#workout-step"); const workoutBar = document.querySelector("#workout-bar"); +const workoutProfile = document.querySelector("#workout-profile"); const workoutStartBtn = document.querySelector("#workout-start"); const workoutPauseBtn = document.querySelector("#workout-pause"); const workoutStopBtn = document.querySelector("#workout-stop"); @@ -288,8 +299,7 @@ const powerHistory = []; const hrHistory = []; let graphDirty = false; -function powerZone(watts, ftp) { - const pct = (watts / ftp) * 100; +function percentZone(pct) { if (pct < 55) return 0; if (pct < 76) return 1; if (pct < 91) return 2; @@ -299,6 +309,10 @@ function powerZone(watts, ftp) { return 6; } +function powerZone(watts, ftp) { + return percentZone((watts / ftp) * 100); +} + function currentFtp() { const v = Number(ftpInput.value); return Number.isFinite(v) && v > 0 ? v : 250; @@ -429,6 +443,60 @@ let workoutStartMs = 0; let workoutSaveName = "workout"; let lastSampleSecs = -1; +// --- Профиль воркаута (сегменты) --- + +function segmentPowerPct(seg) { + const p = seg.power; + if (!p) return null; + if (p.type === "fixed") return p.percent; + if (p.type === "ramp") return (p.from + p.to) / 2; + if (p.type === "range") return (p.low + p.high) / 2; + return null; +} + +function segmentColor(seg) { + const pct = segmentPowerPct(seg); + return pct == null ? "var(--c-surface-2)" : ZONE_COLORS[percentZone(pct)]; +} + +function segmentLabel(seg) { + const name = seg.text || "без имени"; + const dur = formatClock(seg.duration_secs); + let power = ""; + if (seg.power) { + const p = seg.power; + if (p.type === "fixed") power = `${p.percent}%`; + else if (p.type === "ramp") power = `${p.from}→${p.to}%`; + else if (p.type === "range") power = `${p.low}–${p.high}%`; + } + const cad = seg.cadence_rpm ? ` · ${seg.cadence_rpm} rpm` : ""; + return `${name} · ${dur}${power ? ` · ${power}` : ""}${cad}`; +} + +async function renderWorkoutProfile(path) { + try { + const segments = await invoke("get_segments", { path }); + workoutProfile.replaceChildren(); + if (!segments.length) return; + for (const seg of segments) { + const block = el("div", "workout-profile__seg"); + block.style.flexGrow = String(Math.max(seg.duration_secs, 1)); + block.style.background = segmentColor(seg); + block.title = segmentLabel(seg); + workoutProfile.append(block); + } + } catch (e) { + console.error(e); + } +} + +function highlightSegment(idx) { + const blocks = workoutProfile.children; + for (let i = 0; i < blocks.length; i++) { + blocks[i].classList.toggle("workout-profile__seg--current", i === idx); + } +} + function formatClock(sec) { const m = Math.floor(sec / 60); const s = Math.floor(sec % 60); @@ -461,6 +529,11 @@ workoutSelect.addEventListener("change", () => { const w = selectedWorkout(); workoutName.textContent = w ? w.name : "—"; workoutMeta.textContent = w ? `${w.duration_min} мин · load ${w.load ?? "—"}` : ""; + if (w) { + renderWorkoutProfile(w.path); + } else { + workoutProfile.replaceChildren(); + } }); async function setRunning(active) { @@ -476,6 +549,7 @@ async function setRunning(active) { workoutBar.style.width = "0%"; timerEl.textContent = "00:00"; clearTargets(); + highlightSegment(-1); } } @@ -521,6 +595,7 @@ workoutStopBtn.addEventListener("click", async () => { listen("workout-progress", (event) => { const p = event.payload; workoutStep.textContent = p.step_name || `шаг ${p.step_index + 1}`; + highlightSegment(p.step_index); targetWatts = p.target_watts; targetCadence = p.target_cadence; updatePowerDeviation(); diff --git a/src/styles.css b/src/styles.css index b65f82a..94d31ae 100644 --- a/src/styles.css +++ b/src/styles.css @@ -53,7 +53,7 @@ body { /* --- Подключение датчиков --- */ #connect { - grid-template-rows: auto 1fr auto; + grid-template-rows: auto 1fr auto auto; gap: var(--space-4); padding: var(--space-4); width: 100%; @@ -257,6 +257,20 @@ body { align-self: end; } +.power-source { + display: flex; + align-items: center; + gap: var(--space-2); + justify-self: center; + font-size: var(--text-sm); + color: var(--c-text-muted); + cursor: pointer; +} + +.power-source input { + accent-color: var(--c-accent); +} + /* ============================================================ Экран упражнения ============================================================ */ @@ -455,3 +469,27 @@ body { .controls--workout { grid-template-columns: 1fr 1fr 1fr; } + +/* --- Профиль воркаута (сегменты) --- */ + +.workout-profile { + display: flex; + gap: 2px; + height: 28px; + background: var(--c-surface); + border: 1px solid var(--c-border); + border-radius: var(--radius-sm); + overflow: hidden; +} + +.workout-profile__seg { + flex-basis: 0; + min-width: 2px; + opacity: 0.75; + transition: opacity var(--duration-fast) var(--ease); +} + +.workout-profile__seg--current { + opacity: 1; + box-shadow: inset 0 0 0 2px var(--c-text); +} -- 2.36.5