diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bf1e879..f5b0806 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", @@ -3199,6 +3200,7 @@ dependencies = [ "tauri-plugin-opener", "thiserror 1.0.69", "tokio", + "tokio-stream", "uuid", ] @@ -3709,6 +3711,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..3de69f7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,10 +23,13 @@ tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" btleplug = "0.11.8" -tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync"] } +tokio-stream = "0.1" 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..44227a3 100644 --- a/src-tauri/src/ble/error.rs +++ b/src-tauri/src/ble/error.rs @@ -1,5 +1,7 @@ use thiserror::Error; +use crate::commands::ControlResult; + /// Ошибки BLE-слоя. В отличие от прежней заглушки сохраняют источник /// (`btleplug::Error`), чтобы при отладке было видно, что именно пошло не так. #[derive(Debug, Error)] @@ -11,4 +13,40 @@ 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), + + /// Не удалось прочитать характеристику. + #[error("не удалось прочитать характеристику: {0}")] + ReadFailed(btleplug::Error), + + /// Control Point Response вернулся с ошибкой (не Success). + #[error("станок отклонил управление: {0:?}")] + ControlRejected(ControlResult), + + /// Не дождались подтверждения управления за таймаут. + #[error("таймаут подтверждения управления станком")] + ControlTimeout, } diff --git a/src-tauri/src/ble/mock.rs b/src-tauri/src/ble/mock.rs new file mode 100644 index 0000000..685b087 --- /dev/null +++ b/src-tauri/src/ble/mock.rs @@ -0,0 +1,140 @@ +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(()) + } + + async fn request_control(&self, _device: &Device) -> Result<(), BleError> { + Ok(()) + } + + async fn battery(&self, device: &Device) -> Result { + let percent = match device.id.as_str() { + "mock-hr" => 15, + "mock-cad" => 60, + "mock-trainer" => 85, + _ => 100, + }; + Ok(percent) + } +} + +#[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..a86b613 --- /dev/null +++ b/src-tauri/src/ble/source.rs @@ -0,0 +1,282 @@ +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +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::platform::{Adapter, Peripheral}; +use futures::{Stream, StreamExt}; +use tokio::sync::oneshot; +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}; + +#[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>; + + /// Запросить контроль у FTMS-станка и дождаться подтверждения (0x80). + async fn request_control(&self, device: &Device) -> Result<(), BleError>; + + /// Прочитать уровень заряда батареи (0..=100). Best-effort. + async fn battery(&self, device: &Device) -> Result; +} + +const HR_MEASUREMENT: Uuid = uuid_from_u16(0x2A37); +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); + +// How long to wait for the trainer to confirm control (Control Point Response 0x80). +const CONTROL_CONFIRM_TIMEOUT: Duration = Duration::from_secs(3); +const CONTROL_POLL_INTERVAL: Duration = Duration::from_millis(50); + +/// Реальный источник данных через btleplug. +pub(crate) struct BleSource { + adapter: Adapter, + /// Current cycle's control-result slot. Each `stream()` swaps in a fresh slot so a + /// still-winding-down router from the previous cycle writes to a discarded slot, + /// never the one the new cycle's `request_control` polls. + control: Mutex>>>, +} + +impl BleSource { + pub(crate) fn new(adapter: Adapter) -> Self { + Self { + adapter, + control: Mutex::new(Arc::new(Mutex::new(None))), + } + } + + 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())) + } +} + +/// 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, + cancel: Option>, +} + +impl Stream for CancellableStream { + type Item = Telemetry; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_next(cx) + } +} + +impl Drop for CancellableStream { + fn drop(&mut self) { + if let Some(cancel) = self.cancel.take() { + let _ = cancel.send(()); + } + } +} + +#[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, + INDOOR_BIKE_DATA, + FITNESS_MACHINE_STATUS, + ]; + 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)?; + } + } + + let notifications = peripheral + .notifications() + .await + .map_err(BleError::NotificationsFailed)?; + + // Route notifications on a background task: telemetry -> mpsc, control result -> shared state. + // This keeps the control-result flow concurrent with the telemetry consumer so that + // `request_control` can await confirmation while telemetry is still being consumed. + let (tx, rx) = tokio::sync::mpsc::unbounded_channel::(); + // Swap in a fresh control slot for this cycle; the previous cycle's router holds + // the old slot and can only write there, so it cannot race this cycle's poll. + let control = Arc::new(Mutex::new(None)); + *self.control.lock().unwrap() = control.clone(); + let (cancel_tx, cancel_rx) = oneshot::channel::<()>(); + + tokio::spawn(async move { + let mut notifications = notifications; + let mut acc = CscAccumulator::new(); + let mut cancel_rx = cancel_rx; + loop { + tokio::select! { + n = notifications.next() => { + match n { + Some(n) => { + for t in parse_notification(n, &mut acc, &control) { + let _ = tx.send(t); + } + } + None => break, + } + } + _ = &mut cancel_rx => break, + } + } + }); + + let stream: Pin + Send>> = Box::pin(CancellableStream { + inner: tokio_stream::wrappers::UnboundedReceiverStream::new(rx), + cancel: Some(cancel_tx), + }); + Ok(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 request_control(&self, device: &Device) -> Result<(), BleError> { + let peripheral = self.find_peripheral(device).await?; + ensure_connected(&peripheral).await?; + + let cp_uuid = uuid_from_u16(ControlPoint::Ftms.short_uuid()); + let ch = peripheral + .characteristics() + .into_iter() + .find(|c| c.uuid == cp_uuid) + .ok_or_else(|| BleError::DeviceNotFound(cp_uuid.to_string()))?; + + // Reset and send Request Control (opcode 0x00). + 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)?; + + // Wait for the Control Point Response (routed by the stream's notification task). + let result = tokio::time::timeout(CONTROL_CONFIRM_TIMEOUT, async move { + loop { + let current = *slot.lock().unwrap(); + if let Some(r) = current { + return r; + } + tokio::time::sleep(CONTROL_POLL_INTERVAL).await; + } + }) + .await + .map_err(|_| BleError::ControlTimeout)?; + + match result { + ControlResult::Success => Ok(()), + other => Err(BleError::ControlRejected(other)), + } + } + + async fn battery(&self, device: &Device) -> Result { + let peripheral = self.find_peripheral(device).await?; + ensure_connected(&peripheral).await?; + + let batt_uuid = uuid_from_u16(0x2A19); + let ch = peripheral + .characteristics() + .into_iter() + .find(|c| c.uuid == batt_uuid) + .ok_or_else(|| BleError::DeviceNotFound("0x2A19".into()))?; + + let value = peripheral.read(&ch).await.map_err(BleError::ReadFailed)?; + value + .first() + .copied() + .filter(|p| *p <= 100) + .ok_or_else(|| BleError::DeviceNotFound("уровень батареи".into())) + } +} + +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, + control: &Arc>>, +) -> Vec { + if n.uuid == HR_MEASUREMENT { + parse_hr(&n.value).into_iter().collect() + } else if n.uuid == CSC_MEASUREMENT { + parse_csc(&n.value) + .and_then(|raw| acc.push(raw)) + .map(|rpm| Telemetry::Cadence { rpm }) + .into_iter() + .collect() + } else if n.uuid == POWER_MEASUREMENT { + 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 { + if let Some(result) = parse_control_result(&n.value) { + *control.lock().unwrap() = Some(result); + } + Vec::new() + } else { + Vec::new() + } +} diff --git a/src-tauri/src/ble/utils.rs b/src-tauri/src/ble/utils.rs index 13ba48f..35ed7a6 100644 --- a/src-tauri/src/ble/utils.rs +++ b/src-tauri/src/ble/utils.rs @@ -7,3 +7,36 @@ pub fn normalize_uuid(u: &str) -> String { } s } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_strips_standard_base_suffix() { + assert_eq!(normalize_uuid("00001816-0000-1000-8000-00805f9b34fb"), "1816"); + } + + #[test] + fn test_short_uuid_is_unchanged() { + assert_eq!(normalize_uuid("1816"), "1816"); + } + + #[test] + fn test_trims_whitespace() { + assert_eq!(normalize_uuid(" 1816 "), "1816"); + } + + #[test] + fn test_lowercases_input() { + assert_eq!(normalize_uuid("181D"), "181d"); + } + + #[test] + fn test_custom_128bit_uuid_is_left_untouched() { + assert_eq!( + normalize_uuid("f0001816-0451-4000-b000-000000000000"), + "f0001816-0451-4000-b000-000000000000" + ); + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs new file mode 100644 index 0000000..220e03d --- /dev/null +++ b/src-tauri/src/commands.rs @@ -0,0 +1,182 @@ +#[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 + FtmsSetTargetCadence(u16), // opcode 0x0B (в единицах 0.5 rpm) + 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::FtmsSetTargetCadence(half_rpm) => { + let mut p = vec![0x0B]; + p.extend_from_slice(&half_rpm.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) + } + } +} + +/// Result of a Control Point Response (Fitness Machine Status opcode 0x80). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ControlResult { + Success, + OpCodeNotSupported, + InvalidParameter, + OperationFailed, + ControlNotPermitted, +} + +/// Parses a Fitness Machine Status (0x2ADA) notification for a Control Point +/// Response to Request Control (opcode 0x00). +/// Layout: [opcode=0x80][request opcode=0x00][result code]. +pub(crate) fn parse_control_result(bytes: &[u8]) -> Option { + if bytes.first() != Some(&0x80) { + return None; + } + // Only accept responses to Request Control; ignore responses to other commands. + if bytes.get(1) != Some(&0x00) { + return None; + } + match bytes.get(2) { + Some(0x01) => Some(ControlResult::Success), + Some(0x02) => Some(ControlResult::OpCodeNotSupported), + Some(0x03) => Some(ControlResult::InvalidParameter), + Some(0x04) => Some(ControlResult::OperationFailed), + Some(0x05) => Some(ControlResult::ControlNotPermitted), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_control_result_success() { + assert_eq!(parse_control_result(&[0x80, 0x00, 0x01]), Some(ControlResult::Success)); + } + + #[test] + fn test_parse_control_result_rejected() { + assert_eq!( + parse_control_result(&[0x80, 0x00, 0x05]), + Some(ControlResult::ControlNotPermitted) + ); + } + + #[test] + fn test_parse_control_result_not_a_response() { + // opcode != 0x80 (e.g. Fitness Machine Stopped) + assert_eq!(parse_control_result(&[0x02]), None); + assert_eq!(parse_control_result(&[]), None); + } + + #[test] + fn test_parse_control_result_ignores_other_request_opcodes() { + // opcode 0x80 but request opcode != 0x00 (e.g. response to Set Target Power 0x05) + assert_eq!(parse_control_result(&[0x80, 0x05, 0x01]), None); + } + + #[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_cadence() { + // 90 rpm = 180 (в 0.5-rpm) = 0x00B4 → LE [0xB4, 0x00] + assert_eq!( + encode_command(Command::FtmsSetTargetCadence(180)), + (ControlPoint::Ftms, vec![0x0B, 0xB4, 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/device/mod.rs b/src-tauri/src/device/mod.rs index 9277d58..a3e6903 100644 --- a/src-tauri/src/device/mod.rs +++ b/src-tauri/src/device/mod.rs @@ -93,6 +93,23 @@ pub fn get_device_types(device: &Device) -> Vec { mod tests { use super::*; + /// Test helper: builds a Device from service UUIDs and characteristics, + /// with all other fields stubbed. Tests only exercise classification. + fn dev(services: &[&str], characteristics: &[&str]) -> Device { + Device { + id: "test-device".into(), + address: "00:00:00:00:00:00".into(), + display_name: None, + rssi: None, + service_uuids: services.iter().map(|s| s.to_string()).collect(), + characteristics: characteristics.iter().map(|s| s.to_string()).collect(), + manufacturer_data: None, + metadata: None, + } + } + + // Real-device fixture: Magene speed/cadence sensor advertising the CSC + // service (0x1816) as a full 128-bit UUID. #[test] fn test_service_types_for_magene_speedcadence() { let magene = Device { @@ -108,4 +125,101 @@ mod tests { assert_eq!(get_device_types(&magene), [DeviceType::CadSensor]); } + + // Heart rate + #[test] + fn test_hr_sensor_by_service() { + assert_eq!(get_device_types(&dev(&["180d"], &[])), [DeviceType::HrSensor]); + } + + #[test] + fn test_hr_sensor_by_characteristic() { + assert_eq!(get_device_types(&dev(&[], &["2a37"])), [DeviceType::HrSensor]); + } + + #[test] + fn test_hr_sensor_by_full_128bit_uuid() { + assert_eq!( + get_device_types(&dev(&["0000180d-0000-1000-8000-00805f9b34fb"], &[])), + [DeviceType::HrSensor] + ); + } + + // Cadence / speed + #[test] + fn test_cadence_by_service() { + assert_eq!(get_device_types(&dev(&["1816"], &[])), [DeviceType::CadSensor]); + } + + #[test] + fn test_cadence_by_characteristic() { + assert_eq!(get_device_types(&dev(&[], &["2a5b"])), [DeviceType::CadSensor]); + } + + // Trainer / cycling power / FTMS + #[test] + fn test_trainer_by_ftms_service() { + assert_eq!(get_device_types(&dev(&["1826"], &[])), [DeviceType::Trainer]); + } + + #[test] + fn test_trainer_by_power_service() { + assert_eq!(get_device_types(&dev(&["1818"], &[])), [DeviceType::Trainer]); + } + + #[test] + fn test_trainer_by_power_characteristic() { + assert_eq!(get_device_types(&dev(&[], &["2a63"])), [DeviceType::Trainer]); + } + + #[test] + fn test_trainer_by_fitness_machine_characteristic() { + assert_eq!(get_device_types(&dev(&[], &["2acc"])), [DeviceType::Trainer]); + } + + // Indoor bike data (2ADx): a trainer characteristic that also implies cadence. + #[test] + fn test_indoor_bike_data_is_trainer_and_cadence() { + // 2ad2 is both a trainer characteristic and an indoor-bike marker. + assert_eq!( + get_device_types(&dev(&[], &["2ad2"])), + [DeviceType::Trainer, DeviceType::CadSensor] + ); + } + + #[test] + fn test_indoor_bike_variant_is_cadence_only() { + // 2ad5 is an indoor-bike marker but not in the trainer list. + assert_eq!(get_device_types(&dev(&[], &["2ad5"])), [DeviceType::CadSensor]); + } + + // "service::characteristic" strings produced by the scanner. + #[test] + fn test_characteristic_split_on_double_colon() { + assert_eq!(get_device_types(&dev(&[], &["1816::2a5b"])), [DeviceType::CadSensor]); + } + + // Combined and negative cases + #[test] + fn test_hr_and_cadence_combo() { + assert_eq!( + get_device_types(&dev(&["180d", "1816"], &[])), + [DeviceType::HrSensor, DeviceType::CadSensor] + ); + } + + #[test] + fn test_battery_service_is_ignored() { + assert!(get_device_types(&dev(&["180f"], &[])).is_empty()); + } + + #[test] + fn test_empty_device_has_no_types() { + assert!(get_device_types(&dev(&[], &[])).is_empty()); + } + + #[test] + fn test_uuid_case_and_whitespace_are_normalized() { + assert_eq!(get_device_types(&dev(&[" 180D "], &[])), [DeviceType::HrSensor]); + } } diff --git a/src-tauri/src/device_repo/repo.rs b/src-tauri/src/device_repo/repo.rs index 096bd34..94aa018 100644 --- a/src-tauri/src/device_repo/repo.rs +++ b/src-tauri/src/device_repo/repo.rs @@ -60,6 +60,20 @@ pub fn repo_from_devices(devices: Vec) -> Repo { 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 { @@ -79,4 +93,42 @@ mod tests { 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 42cee58..2c76843 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,32 +2,69 @@ mod device; mod ble; mod device_repo; mod state; +mod telemetry; +mod commands; -use std::sync::Arc; +use std::pin::Pin; +use std::sync::{Arc, Mutex, RwLock}; +use std::time::Duration; + +use futures::{Stream, StreamExt}; +use serde::Serialize; use tauri::{AppHandle, Emitter, Manager, async_runtime}; -use crate::{ble::scan_devices, device_repo::{repo_from_devices, Repo}}; -use crate::state::State; +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::state::{State, TrainerState}; +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 { + device_id: String, + telemetry: Telemetry, +} + +#[derive(Clone, Serialize)] +struct BatteryEvent { + device_id: String, + percent: u8, +} + +// Watchdog: how long without telemetry before the connection is considered dead. +const WATCHDOG_STALE_TIMEOUT: Duration = Duration::from_secs(5); +// How many consecutive failed/silent cycles before giving up on reconnect. +const RECONNECT_MAX_ATTEMPTS: u32 = 5; +// Base backoff between reconnect attempts. +const RECONNECT_BACKOFF: Duration = Duration::from_secs(2); + +#[derive(Clone, Serialize)] +struct ConnectionEvent { + device_id: String, + category: String, + status: &'static str, // "reconnecting" | "reconnected" | "gave_up" +} + #[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?; + *state.devices.write().unwrap() = devices.clone(); Ok(repo_from_devices(devices)) } @@ -48,21 +85,351 @@ fn present_repo(app: &AppHandle, repo: Repo) -> Result<(), String> { Ok(()) } +#[tauri::command] +async fn connect(app: AppHandle, device_id: String, category: String) -> Result<(), String> { + let state = app.state::(); + + let device = { + let devices = state.devices.read().unwrap(); + devices + .iter() + .find(|d| d.id == device_id) + .cloned() + .ok_or_else(|| "устройство не найдено".to_string())? + }; + + if let Some(old) = state.streams.lock().unwrap().remove(&category) { + old.abort(); + } + + let source = state.source.clone(); + + // Battery first, before the watchdog task touches the peripheral (avoids a + // connect/discover race on the same peripheral). + if let Ok(percent) = source.battery(&device).await { + let _ = app.emit( + "battery", + BatteryEvent { + device_id: device.id.clone(), + percent, + }, + ); + } + + let app_t = app.clone(); + let device_t = device.clone(); + let category_t = category.clone(); + let handle = async_runtime::spawn(async move { + run_watchdog(app_t, source, device_t, category_t).await; + }); + + state + .streams + .lock() + .unwrap() + .insert(category.clone(), handle); + + // Trainer: remember the device id and reset the previous trainer's targets. + // Control is requested (with confirmation) inside run_watchdog, after the + // notification subscription is live. + if category == "trainer" { + *state.trainer.lock().unwrap() = Some(device.id.clone()); + *state.trainer_state.lock().unwrap() = TrainerState::default(); + } + + Ok(()) +} + +/// Задать целевую мощность станку (ERG). +#[tauri::command] +async fn set_target_power(app: AppHandle, watts: i16) -> Result<(), String> { + let state = app.state::(); + if watts < 0 { + return Err("мощность не может быть отрицательной".to_string()); + } + let device = trainer_device(&state)?; + state + .source + .command(&device, Command::FtmsSetTargetPower(watts)) + .await + .map_err(|e| e.to_string())?; + state.trainer_state.lock().unwrap().power = Some(watts); + Ok(()) +} + +/// Задать целевой каденс станку (rpm; внутри переводится в 0.5-rpm единицы FTMS). +#[tauri::command] +async fn set_target_cadence(app: AppHandle, rpm: u16) -> Result<(), String> { + let state = app.state::(); + let half_rpm = rpm.checked_mul(2).ok_or("каденс слишком большой")?; + let device = trainer_device(&state)?; + state + .source + .command(&device, Command::FtmsSetTargetCadence(half_rpm)) + .await + .map_err(|e| e.to_string())?; + state.trainer_state.lock().unwrap().cadence = Some(rpm); + Ok(()) +} + +/// Запустить станок (FTMS Start or Resume, opcode 0x07). +#[tauri::command] +async fn trainer_start(app: AppHandle) -> Result<(), String> { + let state = app.state::(); + let device = trainer_device(&state)?; + state + .source + .command(&device, Command::FtmsStart) + .await + .map_err(|e| e.to_string())?; + state.trainer_state.lock().unwrap().running = true; + Ok(()) +} + +/// Остановить станок (FTMS Stop or Pause, opcode 0x08). +#[tauri::command] +async fn trainer_stop(app: AppHandle) -> Result<(), String> { + let state = app.state::(); + let device = trainer_device(&state)?; + state + .source + .command(&device, Command::FtmsStop) + .await + .map_err(|e| e.to_string())?; + state.trainer_state.lock().unwrap().running = false; + Ok(()) +} + +/// Возвращает Device подключённого станка. +fn trainer_device(state: &State) -> Result { + let trainer_id = state + .trainer + .lock() + .unwrap() + .clone() + .ok_or("станок не подключен")?; + let devices = state.devices.read().unwrap(); + devices + .iter() + .find(|d| d.id == trainer_id) + .cloned() + .ok_or_else(|| "станок не найден".to_string()) +} + +/// Why the watchdog loop stopped consuming a stream. +enum StreamOutcome { + /// The stream ended (peripheral disconnected / notifications closed). + Ended, + /// No item arrived within `timeout` (stale connection). + Stale, +} + +/// Consumes a telemetry stream, invoking `on_item` per item, until it ends or goes +/// silent for `timeout`. Returns the outcome and whether at least one item was seen. +async fn consume_with_watchdog( + stream: &mut Pin + Send>>, + timeout: Duration, + mut on_item: F, +) -> (StreamOutcome, bool) +where + F: FnMut(Telemetry), +{ + let mut saw = false; + loop { + match time::timeout(timeout, stream.next()).await { + Ok(Some(t)) => { + saw = true; + on_item(t); + } + Ok(None) => return (StreamOutcome::Ended, saw), + Err(_) => return (StreamOutcome::Stale, saw), + } + } +} + +async fn run_watchdog( + app: AppHandle, + source: Arc, + device: Device, + category: String, +) { + let device_id = device.id.clone(); + let mut silent_cycles: u32 = 0; + + loop { + // stream() does ensure_connected() internally, so a fresh call = reconnect. + let mut stream = match source.stream(&device).await { + Ok(s) => { + emit_connection(&app, &device_id, &category, "reconnected"); + s + } + Err(_) => { + silent_cycles += 1; + emit_connection(&app, &device_id, &category, "reconnecting"); + if silent_cycles >= RECONNECT_MAX_ATTEMPTS { + emit_connection(&app, &device_id, &category, "gave_up"); + app.state::().streams.lock().unwrap().remove(&category); + return; + } + time::sleep(RECONNECT_BACKOFF * silent_cycles).await; + continue; + } + }; + + // Trainer: request control (with confirmation) and restore targets/start. + if category == "trainer" { + match source.request_control(&device).await { + Ok(()) => reapply_trainer_state(&app, &source, &device).await, + Err(e) => eprintln!("request_control failed: {e}"), + } + } + + let app_emit = app.clone(); + let dev_id = device_id.clone(); + let (outcome, saw_data) = consume_with_watchdog( + &mut stream, + WATCHDOG_STALE_TIMEOUT, + move |t| { + let _ = app_emit.emit( + "telemetry", + TelemetryEvent { + device_id: dev_id.clone(), + telemetry: t, + }, + ); + }, + ) + .await; + + let _ = outcome; + // A healthy cycle (received data) resets the counter; a silent one does not. + if saw_data { + silent_cycles = 0; + } else { + silent_cycles += 1; + } + emit_connection(&app, &device_id, &category, "reconnecting"); + if silent_cycles >= RECONNECT_MAX_ATTEMPTS { + emit_connection(&app, &device_id, &category, "gave_up"); + app.state::().streams.lock().unwrap().remove(&category); + return; + } + time::sleep(RECONNECT_BACKOFF * silent_cycles.max(1)).await; + } +} + +fn emit_connection(app: &AppHandle, device_id: &str, category: &str, status: &'static str) { + let _ = app.emit( + "connection", + ConnectionEvent { + device_id: device_id.to_string(), + category: category.to_string(), + status, + }, + ); +} + +async fn reapply_trainer_state( + app: &AppHandle, + source: &Arc, + device: &Device, +) { + let state = app.state::(); + let (power, cadence, running) = { + let t = state.trainer_state.lock().unwrap(); + (t.power, t.cadence, t.running) + }; + if let Some(watts) = power { + let _ = source.command(device, Command::FtmsSetTargetPower(watts)).await; + } + if let Some(rpm) = cadence { + if let Some(half_rpm) = rpm.checked_mul(2) { + let _ = source.command(device, Command::FtmsSetTargetCadence(half_rpm)).await; + } + } + if running { + let _ = source.command(device, Command::FtmsStart).await; + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] 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, + devices: RwLock::new(Vec::new()), + trainer: Mutex::new(None), + streams: Mutex::new(std::collections::HashMap::new()), + trainer_state: Mutex::new(TrainerState::default()), + }); Ok::<(), Box>(()) }) }) - .invoke_handler(tauri::generate_handler![init]) + .invoke_handler(tauri::generate_handler![ + init, + connect, + set_target_power, + set_target_cadence, + trainer_start, + trainer_stop + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } + +#[cfg(test)] +mod tests { + use super::*; + use futures::stream; + use std::time::Duration; + + fn pin(s: impl Stream + Send + 'static) -> Pin + Send>> { + Box::pin(s) + } + + #[tokio::test] + async fn ends_when_stream_is_done() { + let items = vec![Telemetry::HeartRate { bpm: 120 }]; + let mut s = pin(stream::iter(items)); + let mut seen = Vec::new(); + let (outcome, saw) = consume_with_watchdog(&mut s, Duration::from_secs(5), |t| seen.push(t)).await; + assert!(matches!(outcome, StreamOutcome::Ended)); + assert!(saw); + assert_eq!(seen, vec![Telemetry::HeartRate { bpm: 120 }]); + } + + #[tokio::test] + async fn stale_when_silent_past_timeout() { + let mut s = pin(stream::pending::()); + let mut seen = Vec::new(); + let (outcome, saw) = consume_with_watchdog(&mut s, Duration::from_millis(50), |t| seen.push(t)).await; + assert!(matches!(outcome, StreamOutcome::Stale)); + assert!(!saw); + assert!(seen.is_empty()); + } + + #[tokio::test] + async fn forwards_every_item_via_callback() { + let items = vec![ + Telemetry::Power { watts: 200 }, + Telemetry::Cadence { rpm: 90.0 }, + ]; + let mut s = pin(stream::iter(items)); + let mut seen = Vec::new(); + let (_outcome, saw) = consume_with_watchdog(&mut s, Duration::from_secs(5), |t| seen.push(t)).await; + assert!(saw); + assert_eq!(seen.len(), 2); + } +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 026bb9f..7871228 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,6 +1,27 @@ -use btleplug::platform::Adapter; -use std::sync::Arc; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, RwLock}; + +use tauri::async_runtime::JoinHandle; + +use crate::ble::SensorSource; +use crate::device::Device; + +/// Trainer control state, restored after a reconnect. +#[derive(Default)] +pub struct TrainerState { + pub power: Option, // watts + pub cadence: Option, // rpm (raw, as entered by user) + pub running: bool, +} pub struct State { - pub adapter: Arc + pub source: Arc, + /// Все найденные при скане устройства (для поиска по id при подключении). + pub devices: RwLock>, + /// id подключённого станка (тренера). + pub trainer: Mutex>, + /// Потоки телеметрии по категориям ("hr"/"cadence"/"trainer") + pub streams: Mutex>>, + /// Trainer control state, restored after a reconnect. + pub trainer_state: Mutex, } diff --git a/src-tauri/src/telemetry.rs b/src-tauri/src/telemetry.rs new file mode 100644 index 0000000..bcab3e4 --- /dev/null +++ b/src-tauri/src/telemetry.rs @@ -0,0 +1,237 @@ +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 }) +} + +/// 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 { + if bytes.len() < 2 { + return Vec::new(); + } + let flags = u16::from_le_bytes([bytes[0], bytes[1]]); + let mut out = Vec::new(); + + // Byte offset of instantaneous cadence (after the 2-byte flags). + let cadence_off = 2 + + if flags & 0x0100 != 0 { 2 } else { 0 } // instantaneous speed + + if flags & 0x0002 != 0 { 2 } else { 0 }; // average speed + + // Byte offset of instantaneous power. + let power_off = cadence_off + + if flags & 0x0008 != 0 { 2 } else { 0 } // instantaneous cadence + + if flags & 0x0010 != 0 { 2 } else { 0 } // average cadence + + if flags & 0x0004 != 0 { 3 } else { 0 } // total distance (uint24) + + if flags & 0x0080 != 0 { 2 } else { 0 }; // resistance level + + if flags & 0x0008 != 0 && cadence_off + 2 <= bytes.len() { + let raw = u16::from_le_bytes([bytes[cadence_off], bytes[cadence_off + 1]]); + out.push(Telemetry::Cadence { rpm: raw as f32 / 2.0 }); + } + if flags & 0x0020 != 0 && power_off + 2 <= bytes.len() { + let raw = u16::from_le_bytes([bytes[power_off], bytes[power_off + 1]]); + out.push(Telemetry::Power { watts: raw as i16 }); + } + + out +} + +/// 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_indoor_bike_cadence_and_power() { + // flags: bit 3 (cadence) + bit 5 (power) = 0x0028 + // cadence uint16 = 180 (0.5 rpm) => 90 rpm; power sint16 = 250 W + let bytes = [0x28, 0x00, 0xB4, 0x00, 0xFA, 0x00]; + assert_eq!( + parse_indoor_bike_data(&bytes), + vec![ + Telemetry::Cadence { rpm: 90.0 }, + Telemetry::Power { watts: 250 }, + ] + ); + } + + #[test] + fn test_indoor_bike_too_short() { + assert!(parse_indoor_bike_data(&[0x28]).is_empty()); + assert!(parse_indoor_bike_data(&[]).is_empty()); + } + + #[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); + } +} diff --git a/src/colors.css b/src/colors.css deleted file mode 100644 index ef7cad9..0000000 --- a/src/colors.css +++ /dev/null @@ -1,8 +0,0 @@ -:root { - --jet: hsla(0, 0%, 21%, 1); - --gunmetal: hsla(216, 28%, 20%, 1); - --cadet-gray: hsla(212, 8%, 58%, 1); - --nyanza: hsla(114, 100%, 93%, 1); - --rojo: hsla(357, 73%, 50%, 1); - --basewhite: #f6f6f6; -} diff --git a/src/fonts/InterVariable.woff2 b/src/fonts/InterVariable.woff2 new file mode 100644 index 0000000..5a8d3e7 Binary files /dev/null and b/src/fonts/InterVariable.woff2 differ diff --git a/src/index.html b/src/index.html index cb08f48..60329cb 100644 --- a/src/index.html +++ b/src/index.html @@ -1,20 +1,75 @@ - + - - Tauri App - + + ridein + -
-

подключение датчиков

-

чсс

-

каденс

-

станок

- -
+ +
+
+

ridein

+

датчики

+
+ +
+
+

чсс

+
    +
    +
    +

    каденс

    +
      +
      +
      +

      станок

      +
        +
        +
        + + +
        + + +
        +
        +
        00:00
        + +
        + +
        +
        + мощность + — + W +
        +
        + каденс + — + rpm +
        +
        + чсс + — + bpm +
        +
        + +
        + + + +
        +
        diff --git a/src/main.js b/src/main.js index 4acf346..7eb5121 100644 --- a/src/main.js +++ b/src/main.js @@ -1,7 +1,243 @@ -const { once } = window.__TAURI__.event; +const { once, listen } = window.__TAURI__.event; +const { invoke } = window.__TAURI__.core; -once("devices-loaded", (devices) => { - console.log("Devices are loaded", devices); +const connectScreen = document.querySelector("#connect"); +const workoutScreen = document.querySelector("#workout"); +const startBtn = document.querySelector("#start-btn"); + +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" }, +}; + +const deviceCards = new Map(); // device_id -> li +const selected = {}; // category -> { btn, card } подключённого датчика +const pendingStatus = {}; // category -> status (пришёл до установки selected) + +function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text != null) node.textContent = text; + return node; +} + +// --- Батарея --- +function batteryIcon(percent) { + const low = percent < 20; + const wrap = el("span", "battery" + (low ? " battery--low" : "")); + wrap.title = `${percent}%`; + + const ns = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(ns, "svg"); + svg.setAttribute("viewBox", "0 0 24 12"); + svg.classList.add("battery__icon"); + + const rect = (x, y, w, h, cls) => { + const r = document.createElementNS(ns, "rect"); + r.setAttribute("x", x); + r.setAttribute("y", y); + r.setAttribute("width", w); + r.setAttribute("height", h); + if (cls) r.classList.add(cls); + return r; + }; + + svg.append( + rect(0, 0, 20, 12, "battery__body"), + rect(21, 3, 2, 6, "battery__terminal"), + rect(1, 1, low ? 0 : (18 * percent) / 100, 10, "battery__fill"), + ); + + if (low) { + const warn = document.createElementNS(ns, "text"); + warn.setAttribute("x", "10"); + warn.setAttribute("y", "10"); + warn.setAttribute("text-anchor", "middle"); + warn.classList.add("battery__warn"); + warn.textContent = "!"; + svg.append(warn); + } + + wrap.append(svg); + return wrap; +} + +// --- Датчики --- +function renderDevice(device, category) { + const li = el("li", "device"); + deviceCards.set(device.id, li); + + const head = el("div", "device__head"); + head.append(el("span", "device__name", device.display_name || "без имени")); + head.append(el("span", "device__rssi", device.rssi != null ? `${device.rssi} dBm` : "")); + + const connectBtn = el("button", "device__connect", "подключить"); + connectBtn.addEventListener("click", () => connect(device.id, connectBtn, category, li)); + + li.append(head, el("span", "device__meta", device.address), connectBtn); + return li; +} + +function renderGroup(group, devices) { + group.list.replaceChildren(); + if (!devices.length) { + group.list.appendChild(el("li", "device device--empty", "нет")); + return; + } + for (const d of devices) group.list.appendChild(renderDevice(d, group.category)); +} + +// --- Подключение (режим радио-кнопки: один на категорию) --- +function resetSelected(category) { + const prev = selected[category]; + if (!prev) return; + prev.btn.textContent = "подключить"; + prev.btn.classList.remove("device__connect--done"); + prev.btn.disabled = false; + const battery = prev.card.querySelector(".battery"); + if (battery) battery.remove(); + selected[category] = null; +} + +async function connect(deviceId, btn, category, card) { + btn.disabled = true; + btn.textContent = "…"; + try { + await invoke("connect", { deviceId, category }); + resetSelected(category); + btn.textContent = "✓"; + btn.classList.add("device__connect--done"); + selected[category] = { btn, card }; + if (pendingStatus[category]) { + applyStatus(selected[category], category, pendingStatus[category]); + delete pendingStatus[category]; + } + if (category === "trainer") startBtn.disabled = false; + } catch (e) { + console.error(e); + btn.disabled = false; + btn.textContent = "ошибка"; + } +} + +// --- Переход на экран упражнения --- +startBtn.addEventListener("click", () => { + connectScreen.hidden = true; + workoutScreen.hidden = false; }); -console.log("hello from main.js"); +// --- Телеметрия --- +function setMetric(id, value) { + document.querySelector(`#${id}`).textContent = value; +} + +once("devices-loaded", (event) => { + const repo = event.payload; + renderGroup(groups.hr_sensors, repo.hr_sensors ?? []); + renderGroup(groups.cad_sensors, repo.cad_sensors ?? []); + renderGroup(groups.trainers, repo.trainers ?? []); +}); + +listen("battery", (event) => { + const { device_id, percent } = event.payload; + const card = deviceCards.get(device_id); + if (!card) return; + const head = card.querySelector(".device__head"); + head.querySelector(".battery")?.remove(); + head.append(batteryIcon(percent)); +}); + +listen("telemetry", (event) => { + const t = event.payload.telemetry; + if (t.Power) setMetric("power-value", t.Power.watts); + else if (t.Cadence) setMetric("cadence-value", Math.round(t.Cadence.rpm)); + else if (t.HeartRate) setMetric("hr-value", t.HeartRate.bpm); +}); + +function applyStatus(sel, category, status) { + const { btn } = sel; + if (status === "reconnecting") { + btn.textContent = "переподключение…"; + btn.disabled = true; + } else if (status === "reconnected") { + btn.textContent = "✓"; + btn.classList.add("device__connect--done"); + btn.disabled = true; + } else if (status === "gave_up") { + btn.textContent = "подключить"; + btn.classList.remove("device__connect--done"); + btn.disabled = false; + selected[category] = null; + } +} + +listen("connection", (event) => { + const { category, status } = event.payload; + const sel = selected[category]; + if (!sel) { + pendingStatus[category] = status; + return; + } + applyStatus(sel, category, status); +}); + +// --- Таймер --- +const timerEl = document.querySelector("#timer"); +const timerBtn = document.querySelector("#timer-btn"); +let running = false; +let startTs = 0; +let elapsed = 0; +let raf = 0; + +function formatTime(ms) { + const sec = Math.floor(ms / 1000); + const m = Math.floor(sec / 60); + const s = sec % 60; + return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; +} + +function tick() { + elapsed = performance.now() - startTs; + timerEl.textContent = formatTime(elapsed); + if (running) raf = requestAnimationFrame(tick); +} + +timerBtn.addEventListener("click", async () => { + running = !running; + if (running) { + startTs = performance.now() - elapsed; + timerBtn.textContent = "стоп"; + tick(); + try { + await invoke("trainer_start"); + } catch (e) { + console.error(e); + } + } else { + cancelAnimationFrame(raf); + timerBtn.textContent = "старт"; + try { + await invoke("trainer_stop"); + } catch (e) { + console.error(e); + } + } +}); + +// --- Целевая мощность/каденс --- +document.querySelector("#apply-btn").addEventListener("click", async () => { + const powerRaw = document.querySelector("#power-input").value.trim(); + const cadenceRaw = document.querySelector("#cadence-input").value.trim(); + if (powerRaw === "" || cadenceRaw === "") return; + const watts = Number(powerRaw); + const rpm = Number(cadenceRaw); + if (!Number.isFinite(watts) || !Number.isFinite(rpm)) return; + if (watts < 0 || rpm < 0 || !Number.isInteger(watts) || !Number.isInteger(rpm)) return; + try { + await invoke("set_target_power", { watts }); + await invoke("set_target_cadence", { rpm }); + } catch (e) { + console.error(e); + } +}); diff --git a/src/reset.css b/src/reset.css index 7d4619c..23c8352 100644 --- a/src/reset.css +++ b/src/reset.css @@ -1,3 +1,9 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, diff --git a/src/splashscreen.html b/src/splashscreen.html index 1410473..e2f7472 100644 --- a/src/splashscreen.html +++ b/src/splashscreen.html @@ -1,14 +1,14 @@ - - - - - - Tauri App - - + + + + + + ridein + + - -

        поиск перефирии

        - + +

        поиск датчиков

        + diff --git a/src/styles.css b/src/styles.css index cf10645..ddbee3d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,58 +1,369 @@ @import "./reset.css"; -@import "./colors.css"; +@import "./tokens.css"; -:root { - font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; - font-size: 16px; - line-height: 24px; - font-weight: 400; +/* ============================================================ + База + ============================================================ */ - color: var(--basewhite); - background-color: var(--jet); +html, +body { + height: 100%; +} - font-synthesis: none; - text-rendering: optimizeLegibility; +body { + font-family: var(--font-sans); + font-size: var(--text-base); + line-height: var(--leading-normal); + font-weight: var(--fw-regular); + color: var(--c-text); + background-color: var(--c-bg); + overflow: hidden; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - -webkit-text-size-adjust: 100%; + text-rendering: optimizeLegibility; } -body, -screen { - overflow: hidden; +/* атрибут hidden должен перебивать display: grid у .screen */ +[hidden] { + display: none !important; } +/* ============================================================ + Экраны + ============================================================ */ + .screen { - align-content: center; + min-height: 100vh; display: grid; - height: 100vh; - justify-content: center; - width: 100vw; } +/* --- Сплэш --- */ + +.splashscreen { + place-items: center; +} + +.splash-label { + color: var(--c-text-muted); + font-size: var(--text-sm); + letter-spacing: var(--tracking-wide); + text-transform: uppercase; +} + +/* --- Подключение датчиков --- */ + #connect { - gap: 2em; - grid-template-columns: repeat(3, 1fr); - grid-template-rows: repeat(3, 1fr); - padding: 2em; + grid-template-rows: auto 1fr auto; + gap: var(--space-4); + padding: var(--space-4); + width: 100%; + max-width: var(--container-max); + margin-inline: auto; +} + +.screen-header { + display: grid; + gap: var(--space-1); +} + +.title { + font-size: var(--text-2xl); + font-weight: var(--fw-semibold); + line-height: var(--leading-tight); + letter-spacing: var(--tracking-tight); +} + +.subtitle { + color: var(--c-text-muted); + font-size: var(--text-sm); +} + +/* --- Сетка групп датчиков --- */ + +.devices { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + gap: var(--space-4); + align-content: start; +} + +.device-group { + display: grid; + gap: var(--space-3); + align-content: start; + padding: var(--space-4); + background: var(--c-surface); + border: 1px solid var(--c-border); + border-radius: var(--radius-md); +} + +.device-group__title { + font-size: var(--text-xs); + font-weight: var(--fw-medium); + letter-spacing: var(--tracking-wide); + text-transform: uppercase; + color: var(--c-text-muted); +} + +.device-list { + display: grid; + gap: var(--space-2); +} + +/* --- Карточка датчика --- */ + +.device { + display: grid; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + background: var(--c-surface-2); + border-radius: var(--radius-sm); +} + +.device__head { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.device__name { + font-size: var(--text-sm); + font-weight: var(--fw-medium); +} + +.device__rssi { + margin-left: auto; + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--c-text-muted); +} + +.device__meta { + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--c-text-faint); +} + +.device__connect { + padding: var(--space-1) var(--space-3); + border: 1px solid var(--c-border-strong); + border-radius: var(--radius-sm); + font-size: var(--text-xs); + letter-spacing: var(--tracking-wide); + text-transform: uppercase; + color: var(--c-text); + cursor: pointer; + transition: background var(--duration-fast) var(--ease); } -#connect h1 { - grid-column: 1/-1; +.device__connect:hover { + background: var(--c-surface); +} + +.device__connect:disabled { + cursor: default; + opacity: 0.6; +} + +.device__connect--done { + border-color: var(--c-text); +} + +.device--empty { + justify-items: center; + padding: var(--space-3); + color: var(--c-text-faint); + font-size: var(--text-sm); + background: transparent; + border: 1px dashed var(--c-border); +} + +/* --- Батарея --- */ + +.battery { + display: inline-flex; + align-items: center; +} + +.battery__icon { + width: 22px; + height: 11px; + display: block; +} + +.battery__body { + fill: none; + stroke: var(--c-text); + stroke-width: 1; +} + +.battery__terminal { + fill: var(--c-text); +} + +.battery__fill { + fill: var(--c-text); +} + +.battery--low .battery__fill { + fill: none; +} + +.battery__warn { + fill: var(--c-text); + font-size: 8px; + font-weight: var(--fw-bold); +} + +/* --- Кнопки --- */ + +.btn { + font-family: var(--font-sans); + font-size: var(--text-base); + font-weight: var(--fw-medium); + letter-spacing: var(--tracking-wide); + text-transform: uppercase; + padding: var(--space-3) var(--space-6); + border-radius: var(--radius-full); + cursor: pointer; + transition: opacity var(--duration-fast) var(--ease); +} + +.btn:disabled { + cursor: default; + opacity: 0.35; +} + +.btn--primary { + color: var(--c-accent-contrast); + background: var(--c-accent); +} + +.btn--primary:hover:not(:disabled) { + opacity: 0.85; +} + +.btn--ghost { + color: var(--c-text); + border: 1px solid var(--c-border-strong); +} + +.btn--ghost:hover { + background: var(--c-surface); +} + +#connect .btn { justify-self: center; align-self: end; } -#connect button { - grid-column: 1/-1; - justify-self: center; - align-self: start; +/* ============================================================ + Экран упражнения + ============================================================ */ + +#workout { + grid-template-rows: auto 1fr auto; + gap: var(--space-4); + padding: var(--space-4); + width: 100%; + max-width: var(--container-max); + margin-inline: auto; +} + +.workout-header { + display: flex; + align-items: center; + justify-content: space-between; } -.device-selection { - border: 1px solid var(--basewhite); - border-radius: 2%; +.timer { + font-size: var(--text-3xl); + font-weight: var(--fw-semibold); + line-height: 1; + letter-spacing: var(--tracking-tight); + font-variant-numeric: tabular-nums; +} + +/* --- Метрики --- */ + +.metrics { display: grid; - justify-content: center; - padding: 0.5em; + grid-template-columns: 1fr 1fr; + gap: var(--space-4); + align-content: center; +} + +.metric { + display: grid; + justify-items: center; + align-content: center; + gap: var(--space-1); + padding: var(--space-3); + background: var(--c-surface); + border: 1px solid var(--c-border); + border-radius: var(--radius-lg); +} + +.metric--power { + grid-column: 1 / -1; +} + +.metric__label { + font-size: var(--text-xs); + letter-spacing: var(--tracking-wide); + text-transform: uppercase; + color: var(--c-text-muted); +} + +.metric__value { + font-size: var(--text-3xl); + font-weight: var(--fw-semibold); + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.metric--cadence .metric__value, +.metric--hr .metric__value { + font-size: var(--text-xl); +} + +.metric__unit { + font-size: var(--text-sm); + color: var(--c-text-muted); +} + +/* --- Управление --- */ + +.controls { + display: grid; + grid-template-columns: 1fr 1fr auto; + gap: var(--space-4); + align-items: end; +} + +.field { + display: grid; + gap: var(--space-2); +} + +.field__label { + font-size: var(--text-xs); + letter-spacing: var(--tracking-wide); + text-transform: uppercase; + color: var(--c-text-muted); +} + +.field__input { + font-family: var(--font-sans); + font-size: var(--text-base); + padding: var(--space-2) var(--space-3); + color: var(--c-text); + background: var(--c-surface); + border: 1px solid var(--c-border-strong); + border-radius: var(--radius-sm); + font-variant-numeric: tabular-nums; +} + +.field__input:focus { + border-color: var(--c-text); } diff --git a/src/tokens.css b/src/tokens.css new file mode 100644 index 0000000..e4e66ef --- /dev/null +++ b/src/tokens.css @@ -0,0 +1,73 @@ +/* ============================================================ + ridein — дизайн-токены (монохромная система) + Только функции, поддерживаемые WebKitGTK (webview Tauri): + HSL-цвета, CSS-переменные, grid/flex, clamp/rem. Без oklch, + color-mix(), :has() и прочего слишком свежего. + ============================================================ */ + +@font-face { + font-family: "Inter"; + src: url("./fonts/InterVariable.woff2") format("woff2"); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} + +:root { + /* --- Цвет: монохромная шкала (нулевая насыщенность) --- */ + --c-bg: hsl(0 0% 4%); /* фон страницы */ + --c-surface: hsl(0 0% 8%); /* карточка/панель */ + --c-surface-2: hsl(0 0% 12%); /* приподнятый элемент */ + --c-border: hsl(0 0% 18%); /* тонкая линия */ + --c-border-strong: hsl(0 0% 30%); + --c-text: hsl(0 0% 93%); /* основной текст */ + --c-text-muted: hsl(0 0% 58%); /* вторичный */ + --c-text-faint: hsl(0 0% 40%); /* едва заметный */ + --c-accent: hsl(0 0% 93%); /* акцент (инверсия) */ + --c-accent-contrast: hsl(0 0% 4%); + + /* --- Отступы (база 4px) --- */ + --space-1: 0.25rem; /* 4px */ + --space-2: 0.5rem; /* 8px */ + --space-3: 0.75rem; /* 12px */ + --space-4: 1rem; /* 16px */ + --space-5: 1.5rem; /* 24px */ + --space-6: 2rem; /* 32px */ + --space-8: 3rem; /* 48px */ + --space-10: 4rem; /* 64px */ + + /* --- Типографика --- */ + --font-sans: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif; + --font-mono: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", monospace; + + --text-xs: 0.75rem; /* 12px */ + --text-sm: 0.875rem; /* 14px */ + --text-base: 1rem; /* 16px */ + --text-lg: 1.25rem; /* 20px */ + --text-xl: 1.5rem; /* 24px */ + --text-2xl: 2rem; /* 32px */ + --text-3xl: 2.5rem; /* 40px */ + + --leading-tight: 1.1; + --leading-normal: 1.5; + + --fw-regular: 400; + --fw-medium: 500; + --fw-semibold: 600; + --fw-bold: 700; + + --tracking-tight: -0.02em; + --tracking-wide: 0.08em; + + /* --- Радиусы --- */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-full: 9999px; + + /* --- Разное --- */ + --container-max: 72rem; + --ease: cubic-bezier(0.4, 0, 0.2, 1); + --duration-fast: 120ms; + --duration-base: 200ms; +}