diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bf1e879..6e65e62 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -178,13 +178,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 3.0.9", ] [[package]] @@ -1114,9 +1114,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -1129,9 +1129,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -1139,15 +1139,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -1156,9 +1156,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -1175,32 +1175,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 3.0.9", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -1210,7 +1210,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -3190,7 +3189,9 @@ dependencies = [ name = "rideinn" version = "0.1.0" dependencies = [ + "async-trait", "btleplug", + "futures", "hex", "serde", "serde_json", @@ -3709,6 +3710,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fed8d08..1769f0d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,6 +27,8 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } hex = "0.4" uuid = "1.16.0" thiserror = "1" +async-trait = "0.1.92" +futures = "0.3.34" [profile.dev] incremental = true # Compile your binary in smaller steps. diff --git a/src-tauri/src/ble/error.rs b/src-tauri/src/ble/error.rs index d7be963..87e7898 100644 --- a/src-tauri/src/ble/error.rs +++ b/src-tauri/src/ble/error.rs @@ -11,4 +11,28 @@ pub enum BleError { /// Не удалось получить список найденных устройств. #[error("не удалось получить список BLE-устройств: {0}")] PeripheralsFailed(btleplug::Error), + + /// Устройство не найдено по id. + #[error("устройство не найдено: {0}")] + DeviceNotFound(String), + + /// Не удалось подключиться к устройству. + #[error("не удалось подключиться к устройству: {0}")] + ConnectFailed(btleplug::Error), + + /// Не удалось обнаружить сервисы/характеристики. + #[error("не удалось обнаружить сервисы: {0}")] + DiscoverFailed(btleplug::Error), + + /// Не удалось подписаться на уведомления. + #[error("не удалось подписаться на уведомления: {0}")] + SubscribeFailed(btleplug::Error), + + /// Не удалось получить поток уведомлений. + #[error("не удалось получить поток уведомлений: {0}")] + NotificationsFailed(btleplug::Error), + + /// Не удалось записать команду. + #[error("не удалось записать команду: {0}")] + WriteFailed(btleplug::Error), } diff --git a/src-tauri/src/ble/mock.rs b/src-tauri/src/ble/mock.rs new file mode 100644 index 0000000..d1851f4 --- /dev/null +++ b/src-tauri/src/ble/mock.rs @@ -0,0 +1,126 @@ +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use async_trait::async_trait; +use futures::stream::{self, Stream}; +use futures::StreamExt; + +use crate::ble::source::SensorSource; +use crate::ble::BleError; +use crate::commands::Command; +use crate::device::{get_device_types, Device, DeviceType}; +use crate::telemetry::Telemetry; + +/// Виртуальный источник: scripted-поток, без реального BLE. +pub(crate) struct MockSource { + devices: Vec, + commands: Arc>>, +} + +impl MockSource { + pub(crate) fn new() -> Self { + let devices = vec![ + Device { + id: "mock-hr".into(), + address: "00:00:00:00:00:01".into(), + display_name: Some("Mock HR".into()), + rssi: Some(-45), + service_uuids: vec!["0000180d-0000-1000-8000-00805f9b34fb".into()], + characteristics: vec!["180d::2a37".into()], + manufacturer_data: None, + metadata: None, + }, + Device { + id: "mock-cad".into(), + address: "00:00:00:00:00:02".into(), + display_name: Some("Mock Cadence".into()), + rssi: Some(-50), + service_uuids: vec!["00001816-0000-1000-8000-00805f9b34fb".into()], + characteristics: vec!["1816::2a5b".into()], + manufacturer_data: None, + metadata: None, + }, + Device { + id: "mock-trainer".into(), + address: "00:00:00:00:00:03".into(), + display_name: Some("Mock Trainer".into()), + rssi: Some(-55), + service_uuids: vec!["00001826-0000-1000-8000-00805f9b34fb".into()], + characteristics: vec!["1826::2ad2".into()], + manufacturer_data: None, + metadata: None, + }, + ]; + Self { + devices, + commands: Arc::new(Mutex::new(Vec::new())), + } + } +} + +#[async_trait] +impl SensorSource for MockSource { + async fn scan(&self, _seconds: u64) -> Result, BleError> { + Ok(self.devices.clone()) + } + + async fn stream(&self, device: &Device) + -> Result + Send>>, BleError> + { + let types = get_device_types(device); + let s: Pin + Send>> = if types.contains(&DeviceType::Trainer) { + // мощность рампой 150..300 Вт + stream::unfold(0u16, |i| async move { + tokio::time::sleep(Duration::from_millis(500)).await; + let watts = 150 + ((i % 16) * 10) as i16; + Some((Telemetry::Power { watts }, i.wrapping_add(1))) + }) + .boxed() + } else if types.contains(&DeviceType::CadSensor) { + // каденс 85..95 rpm + stream::unfold(0u16, |i| async move { + tokio::time::sleep(Duration::from_millis(500)).await; + let rpm = 85.0 + (i % 10) as f32; + Some((Telemetry::Cadence { rpm }, i.wrapping_add(1))) + }) + .boxed() + } else { + // пульс 120..169 + stream::unfold(0u16, |i| async move { + tokio::time::sleep(Duration::from_millis(1000)).await; + let bpm = 120 + (i % 50); + Some((Telemetry::HeartRate { bpm }, i.wrapping_add(1))) + }) + .boxed() + }; + Ok(s) + } + + async fn command(&self, _device: &Device, cmd: Command) -> Result<(), BleError> { + self.commands.lock().unwrap().push(cmd); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_mock_scan_returns_three_devices() { + let src = MockSource::new(); + let devices = src.scan(0).await.unwrap(); + 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 hr = devices.iter().find(|d| d.id == "mock-hr").unwrap(); + let mut stream = src.stream(hr).await.unwrap(); + let first = stream.next().await; + assert!(matches!(first, Some(Telemetry::HeartRate { .. }))); + } +} diff --git a/src-tauri/src/ble/mod.rs b/src-tauri/src/ble/mod.rs index ad8f3fa..d2470b2 100644 --- a/src-tauri/src/ble/mod.rs +++ b/src-tauri/src/ble/mod.rs @@ -1,7 +1,11 @@ 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/source.rs b/src-tauri/src/ble/source.rs new file mode 100644 index 0000000..959e5e6 --- /dev/null +++ b/src-tauri/src/ble/source.rs @@ -0,0 +1,124 @@ +use std::pin::Pin; + +use async_trait::async_trait; +use btleplug::api::{bleuuid::uuid_from_u16, Central, Peripheral as _, ValueNotification, WriteType}; +use btleplug::platform::{Adapter, Peripheral}; +use futures::{future, Stream, StreamExt}; +use uuid::Uuid; + +use crate::ble::BleError; +use crate::commands::{encode_command, Command}; +use crate::device::Device; +use crate::telemetry::{parse_csc, parse_hr, parse_power, CscAccumulator, Telemetry}; + +#[async_trait] +pub(crate) trait SensorSource: Send + Sync { + async fn scan(&self, seconds: u64) -> Result, BleError>; + + /// Подключиться и вернуть поток распарсенных измерений. + async fn stream(&self, device: &Device) + -> Result + Send>>, BleError>; + + /// Отправить команду на управляющую точку устройства (ERG и т.п.). + async fn command(&self, device: &Device, cmd: Command) -> Result<(), BleError>; +} + +const HR_MEASUREMENT: Uuid = uuid_from_u16(0x2A37); +const CSC_MEASUREMENT: Uuid = uuid_from_u16(0x2A5B); +const POWER_MEASUREMENT: Uuid = uuid_from_u16(0x2A63); + +/// Реальный источник данных через btleplug. +pub(crate) struct BleSource { + adapter: Adapter, +} + +impl BleSource { + pub(crate) fn new(adapter: Adapter) -> Self { + Self { adapter } + } + + async fn find_peripheral(&self, device: &Device) -> Result { + let peripherals = self + .adapter + .peripherals() + .await + .map_err(BleError::PeripheralsFailed)?; + peripherals + .into_iter() + .find(|p| p.id().to_string() == device.id) + .ok_or_else(|| BleError::DeviceNotFound(device.id.clone())) + } +} + +#[async_trait] +impl SensorSource for BleSource { + async fn scan(&self, seconds: u64) -> Result, BleError> { + crate::ble::scan_devices(&self.adapter, seconds).await + } + + async fn stream(&self, device: &Device) + -> Result + Send>>, BleError> + { + let peripheral = self.find_peripheral(device).await?; + ensure_connected(&peripheral).await?; + + let targets = [HR_MEASUREMENT, CSC_MEASUREMENT, POWER_MEASUREMENT]; + for ch in peripheral.characteristics().iter() { + if targets.contains(&ch.uuid) { + peripheral.subscribe(ch).await.map_err(BleError::SubscribeFailed)?; + } + } + + let notifications = peripheral + .notifications() + .await + .map_err(BleError::NotificationsFailed)?; + + let mut acc = CscAccumulator::new(); + let stream = notifications.filter_map(move |n| future::ready(parse_notification(n, &mut acc))); + Ok(Box::pin(stream)) + } + + async fn command(&self, device: &Device, cmd: Command) -> Result<(), BleError> { + let peripheral = self.find_peripheral(device).await?; + ensure_connected(&peripheral).await?; + + let (control_point, payload) = encode_command(cmd); + let cp_uuid = uuid_from_u16(control_point.short_uuid()); + let ch = peripheral + .characteristics() + .into_iter() + .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) + } +} + +async fn ensure_connected(peripheral: &Peripheral) -> Result<(), BleError> { + if !peripheral.is_connected().await.unwrap_or(false) { + peripheral.connect().await.map_err(BleError::ConnectFailed)?; + } + peripheral + .discover_services() + .await + .map_err(BleError::DiscoverFailed)?; + Ok(()) +} + +fn parse_notification(n: ValueNotification, acc: &mut CscAccumulator) -> Option { + if n.uuid == HR_MEASUREMENT { + parse_hr(&n.value) + } else if n.uuid == CSC_MEASUREMENT { + parse_csc(&n.value) + .and_then(|raw| acc.push(raw)) + .map(|rpm| Telemetry::Cadence { rpm }) + } else if n.uuid == POWER_MEASUREMENT { + parse_power(&n.value) + } else { + None + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs new file mode 100644 index 0000000..6f48432 --- /dev/null +++ b/src-tauri/src/commands.rs @@ -0,0 +1,110 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Command { + // FTMS Fitness Machine Control Point (0x2AD9) + FtmsRequestControl, // opcode 0x00 + FtmsReset, // opcode 0x01 + FtmsSetTargetResistance(u8), // opcode 0x04 + FtmsSetTargetPower(i16), // opcode 0x05 ERG + FtmsStart, // opcode 0x07 + FtmsStop, // opcode 0x08 (0x01=stop, 0x02=pause) + + // CSC SC Control Point (0x2A55) + CscSetCumulativeValue(u32), // opcode 0x01 + CscSetWheelCircumference(u16), // opcode 0x10 +} + +/// Управляющая точка, на которую пишется команда. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ControlPoint { + Ftms, + Csc, +} + +impl ControlPoint { + /// Короткий UUID характеристики (16-бит). + pub(crate) fn short_uuid(self) -> u16 { + match self { + ControlPoint::Ftms => 0x2AD9, + ControlPoint::Csc => 0x2A55, + } + } +} + +/// Чистая функция: команда → (точка, байты payload). +pub(crate) fn encode_command(cmd: Command) -> (ControlPoint, Vec) { + match cmd { + Command::FtmsRequestControl => (ControlPoint::Ftms, vec![0x00]), + Command::FtmsReset => (ControlPoint::Ftms, vec![0x01]), + Command::FtmsSetTargetResistance(level) => (ControlPoint::Ftms, vec![0x04, level]), + Command::FtmsSetTargetPower(watts) => { + let mut p = vec![0x05]; + p.extend_from_slice(&watts.to_le_bytes()); + (ControlPoint::Ftms, p) + } + Command::FtmsStart => (ControlPoint::Ftms, vec![0x07]), + Command::FtmsStop => (ControlPoint::Ftms, vec![0x08, 0x01]), + Command::CscSetCumulativeValue(val) => { + let mut p = vec![0x01]; + p.extend_from_slice(&val.to_le_bytes()); + (ControlPoint::Csc, p) + } + Command::CscSetWheelCircumference(mm) => { + let mut p = vec![0x10]; + p.extend_from_slice(&mm.to_le_bytes()); + (ControlPoint::Csc, p) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encode_request_control() { + assert_eq!( + encode_command(Command::FtmsRequestControl), + (ControlPoint::Ftms, vec![0x00]) + ); + } + + #[test] + fn test_encode_set_target_power() { + // 200 Вт = 0x00C8 → LE [0xC8, 0x00] + assert_eq!( + encode_command(Command::FtmsSetTargetPower(200)), + (ControlPoint::Ftms, vec![0x05, 0xC8, 0x00]) + ); + } + + #[test] + fn test_encode_set_target_resistance() { + assert_eq!( + encode_command(Command::FtmsSetTargetResistance(3)), + (ControlPoint::Ftms, vec![0x04, 0x03]) + ); + } + + #[test] + fn test_encode_stop() { + assert_eq!( + encode_command(Command::FtmsStop), + (ControlPoint::Ftms, vec![0x08, 0x01]) + ); + } + + #[test] + fn test_encode_csc_cumulative_value() { + // 1000 = 0x000003E8 → LE [0xE8, 0x03, 0x00, 0x00] + assert_eq!( + encode_command(Command::CscSetCumulativeValue(1000)), + (ControlPoint::Csc, vec![0x01, 0xE8, 0x03, 0x00, 0x00]) + ); + } + + #[test] + fn test_control_point_uuids() { + assert_eq!(ControlPoint::Ftms.short_uuid(), 0x2AD9); + assert_eq!(ControlPoint::Csc.short_uuid(), 0x2A55); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 42cee58..e4b8e32 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,10 +2,15 @@ mod device; mod ble; mod device_repo; mod state; +mod telemetry; +mod commands; use std::sync::Arc; + use tauri::{AppHandle, Emitter, Manager, async_runtime}; -use crate::{ble::scan_devices, device_repo::{repo_from_devices, Repo}}; + +use crate::ble::{BleSource, MockSource, SensorSource}; +use crate::device_repo::{repo_from_devices, Repo}; use crate::state::State; use btleplug::api::Manager as ApiManager; use btleplug::platform::Manager as BleManager; @@ -15,19 +20,16 @@ const SCAN_SECONDS: u64 = 5; #[tauri::command] async fn init(app: AppHandle) -> Result<(), String> { - println!("init rideinn app"); - let repo = scan_and_classify(&app).await.map_err(|e| e.to_string())?; present_repo(&app, repo).map_err(|e| e.to_string())?; - Ok(()) } /// Этап 1: скан BLE-устройств и раскладка по типам датчиков. async fn scan_and_classify(app: &AppHandle) -> Result { let state = app.state::(); - let adapter = state.adapter.clone(); - let devices = scan_devices(&adapter, SCAN_SECONDS).await?; + let source = state.source.clone(); + let devices = source.scan(SCAN_SECONDS).await?; Ok(repo_from_devices(devices)) } @@ -53,12 +55,17 @@ pub fn run() { tauri::Builder::default() .setup(|app| { async_runtime::block_on(async move { - let manager = BleManager::new().await?; - let adapters = manager.adapters().await?; - let adapter = adapters.into_iter().next() - .ok_or_else(|| Box::::from("No BLE adapter found"))?; + let source: Arc = if std::env::var("RIDEIN_MOCK").is_ok() { + Arc::new(MockSource::new()) + } else { + let manager = BleManager::new().await?; + let adapters = manager.adapters().await?; + let adapter = adapters.into_iter().next() + .ok_or_else(|| Box::::from("No BLE adapter found"))?; + Arc::new(BleSource::new(adapter)) + }; - app.manage(State { adapter: Arc::new(adapter) }); + app.manage(State { source }); Ok::<(), Box>(()) }) }) diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 026bb9f..9a00fdc 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,6 +1,7 @@ -use btleplug::platform::Adapter; use std::sync::Arc; +use crate::ble::SensorSource; + pub struct State { - pub adapter: Arc + pub source: Arc, } diff --git a/src-tauri/src/telemetry.rs b/src-tauri/src/telemetry.rs new file mode 100644 index 0000000..faefd09 --- /dev/null +++ b/src-tauri/src/telemetry.rs @@ -0,0 +1,184 @@ +use serde::Serialize; + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(crate) enum Telemetry { + HeartRate { bpm: u16 }, + Cadence { rpm: f32 }, + Power { watts: i16 }, + Battery { percent: u8 }, +} + +/// Сырые данные CSC: кумулятивные счётчики шатуна. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct CscRaw { + pub crank_revs: u16, + pub crank_time: u16, +} + +/// Heart Rate Measurement (0x2A37): флаг + значение (uint8 или uint16). +pub(crate) fn parse_hr(bytes: &[u8]) -> Option { + let flags = *bytes.first()?; + if flags & 0x01 != 0 { + let bpm = u16::from_le_bytes([*bytes.get(1)?, *bytes.get(2)?]); + Some(Telemetry::HeartRate { bpm }) + } else { + Some(Telemetry::HeartRate { bpm: *bytes.get(1)? as u16 }) + } +} + +/// Cycling Power Measurement (0x2A63): мгновенная мощность sint16 (LE). +pub(crate) fn parse_power(bytes: &[u8]) -> Option { + if bytes.len() < 4 { + return None; + } + let watts = i16::from_le_bytes([bytes[2], bytes[3]]); + Some(Telemetry::Power { watts }) +} + +/// Battery Level (0x2A19): один байт 0..=100. +pub(crate) fn parse_battery(bytes: &[u8]) -> Option { + let percent = *bytes.first()?; + (percent <= 100).then_some(Telemetry::Battery { percent }) +} + +/// CSC Measurement (0x2A5B): извлекаем кумулятивные счётчики шатуна. +pub(crate) fn parse_csc(bytes: &[u8]) -> Option { + let flags = *bytes.first()?; + if flags & 0x02 == 0 { + return None; // нет данных шатуна + } + let mut idx = 1; + if flags & 0x01 != 0 { + idx += 6; // пропускаем данные колеса (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 }) +} + +/// Превращает кумулятивные счётчики CSC в обороты в минуту. +pub(crate) struct CscAccumulator { + last_revs: Option, + last_time: Option, +} + +impl CscAccumulator { + pub(crate) fn new() -> Self { + Self { last_revs: None, last_time: None } + } + + /// Возвращает rpm или None (нужно минимум два измерения). + pub(crate) fn push(&mut self, raw: CscRaw) -> Option { + let (Some(prev_revs), Some(prev_time)) = (self.last_revs, self.last_time) else { + self.last_revs = Some(raw.crank_revs); + self.last_time = Some(raw.crank_time); + return None; + }; + self.last_revs = Some(raw.crank_revs); + self.last_time = Some(raw.crank_time); + + let d_revs = raw.crank_revs.wrapping_sub(prev_revs) as f32; + let d_time = raw.crank_time.wrapping_sub(prev_time) as f32; // 1/1024 с + if d_time == 0.0 { + return None; + } + Some(d_revs * 60.0 * 1024.0 / d_time) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_hr_8bit() { + // flags=0x00 (8-бит), значение 120 + assert_eq!(parse_hr(&[0x00, 0x78]), Some(Telemetry::HeartRate { bpm: 120 })); + } + + #[test] + fn test_parse_hr_16bit() { + // flags=0x01 (16-бит), значение 180 = 0x00B4 + assert_eq!(parse_hr(&[0x01, 0xB4, 0x00]), Some(Telemetry::HeartRate { bpm: 180 })); + } + + #[test] + fn test_parse_hr_empty() { + assert_eq!(parse_hr(&[]), None); + } + + #[test] + fn test_parse_hr_missing_value() { + assert_eq!(parse_hr(&[0x00]), None); // 8-бит, но значения нет + } + + #[test] + fn test_parse_power() { + // flags=0x0000, мощность 200 = 0x00C8 (LE) + assert_eq!(parse_power(&[0x00, 0x00, 0xC8, 0x00]), Some(Telemetry::Power { watts: 200 })); + } + + #[test] + fn test_parse_power_negative() { + // -200 = 0xFF38 (sint16 LE) + assert_eq!(parse_power(&[0x00, 0x00, 0x38, 0xFF]), Some(Telemetry::Power { watts: -200 })); + } + + #[test] + fn test_parse_power_too_short() { + assert_eq!(parse_power(&[0x00, 0x00, 0xC8]), None); + } + + #[test] + fn test_parse_battery() { + assert_eq!(parse_battery(&[100]), Some(Telemetry::Battery { percent: 100 })); + assert_eq!(parse_battery(&[0]), Some(Telemetry::Battery { percent: 0 })); + assert_eq!(parse_battery(&[101]), None); // вне диапазона + assert_eq!(parse_battery(&[]), None); + } + + #[test] + fn test_parse_csc_crank_only() { + // flags=0x02 (только шатун), revs=5, time=256 + let bytes = [0x02, 0x05, 0x00, 0x00, 0x01]; + assert_eq!(parse_csc(&bytes), Some(CscRaw { crank_revs: 5, crank_time: 256 })); + } + + #[test] + fn test_parse_csc_with_wheel() { + // flags=0x03 (колесо+шатун), колесо 6 байт, затем revs=5, time=256 + let bytes = [0x03, 0, 0, 0, 0, 0, 0, 0x05, 0x00, 0x00, 0x01]; + assert_eq!(parse_csc(&bytes), Some(CscRaw { crank_revs: 5, crank_time: 256 })); + } + + #[test] + fn test_parse_csc_no_crank() { + // flags=0x01 (только колесо) — данных шатуна нет + let bytes = [0x01, 0, 0, 0, 0, 0, 0]; + assert_eq!(parse_csc(&bytes), None); + } + + #[test] + fn test_accumulator_needs_two_samples() { + let mut acc = CscAccumulator::new(); + assert_eq!(acc.push(CscRaw { crank_revs: 0, crank_time: 0 }), None); + } + + #[test] + fn test_accumulator_computes_rpm() { + let mut acc = CscAccumulator::new(); + acc.push(CscRaw { crank_revs: 0, crank_time: 0 }); + // 1 оборот за 1024 тика (1 с) = 60 rpm + assert_eq!(acc.push(CscRaw { crank_revs: 1, crank_time: 1024 }), Some(60.0)); + } + + #[test] + fn test_accumulator_zero_time_returns_none() { + let mut acc = CscAccumulator::new(); + acc.push(CscRaw { crank_revs: 0, crank_time: 0 }); + assert_eq!(acc.push(CscRaw { crank_revs: 5, crank_time: 0 }), None); + } +}