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, /// Last control result (set by the notification router, awaited by `request_control`). control: Arc>>, } impl BleSource { pub(crate) fn new(adapter: Adapter) -> Self { Self { adapter, control: 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::(); let control = self.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). *self.control.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 { loop { let current = *self.control.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() } }