//! Heart rate monitor client: decoder and connection actor. //! //! Structurally a sibling of [`crate::click`]: one task owns the peripheral, //! callers hold a cheap handle and receive [`HeartRateEvent`]s on a broadcast //! channel. The wire format lives in this module too, as pure functions over //! bytes, because it is a single characteristic and does not earn a module of //! its own. //! //! ```text //! caller ◄──broadcast── HeartRateEvent ◄── actor ◄─notify── HRM / watch //! ``` //! //! Works with anything that exposes the standard Heart Rate Service (`0x180D`) //! — a chest strap, or a sports watch with *Broadcast Heart Rate* switched on. //! A watch in broadcast mode accepts a single central, so connecting it here //! takes it away from any other app; that is a property of the watch, not of //! this client. //! //! Read-only in every sense: nothing is ever written to the device, so — like //! the Click and unlike the trainer — there is no safety sequence, only the //! SAF-9 obligation to actually close the link on the way out. use std::future::Future; use std::time::Duration; use btleplug::api::{Characteristic, Peripheral as _}; use btleplug::platform::{Adapter, Peripheral}; use futures::{Stream, StreamExt}; use tokio::sync::{broadcast, mpsc, oneshot}; use crate::client::{Backoff, InFlight, DISCONNECT_TIMEOUT}; use crate::error::FtmsError; use crate::indoor_bike_data::hex; use crate::scan::{self, DiscoveredDevice, ScanKind}; use crate::uuids; // --------------------------------------------------------------------------- // Wire format (pure) // --------------------------------------------------------------------------- /// One decoded Heart Rate Measurement (`0x2A37`) notification. /// /// Only what the app uses. The characteristic can also carry energy expended /// and RR intervals; both are skipped, not rejected — a device that sends them /// still decodes. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct HeartRateMeasurement { /// Beats per minute. `u16` because the flags allow it, though anything a /// human produces fits in a `u8`. pub bpm: u16, /// Skin contact, where the device reports the feature at all. `None` means /// "not supported", not "no contact" — the two must not be conflated, or /// every strap without the feature reads as fallen off. pub sensor_contact: Option, } /// Why a Heart Rate Measurement frame would not decode. #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] pub enum HrDecodeError { #[error("empty heart rate measurement")] Empty, #[error("heart rate measurement of {len} bytes is too short for its flags (0x{flags:02x})")] TooShort { flags: u8, len: usize }, } /// Decode a Heart Rate Measurement (`0x2A37`) value. /// /// Layout per the Bluetooth SIG GATT specification: a flags byte, then the /// heart rate as `u8` or `u16` little-endian depending on flags bit 0. Bits /// 1–2 describe sensor contact: bit 2 says the feature is supported, bit 1 /// says contact is detected. pub fn decode_heart_rate(data: &[u8]) -> Result { let (&flags, rest) = data.split_first().ok_or(HrDecodeError::Empty)?; let bpm = if flags & 0x01 == 0 { *rest.first().ok_or(HrDecodeError::TooShort { flags, len: data.len(), })? as u16 } else { match rest { [lo, hi, ..] => u16::from_le_bytes([*lo, *hi]), _ => { return Err(HrDecodeError::TooShort { flags, len: data.len(), }) } } }; let sensor_contact = match flags & 0x06 { 0x06 => Some(true), 0x04 => Some(false), _ => None, }; Ok(HeartRateMeasurement { bpm, sensor_contact, }) } // --------------------------------------------------------------------------- // Selection and configuration // --------------------------------------------------------------------------- /// How to pick a heart rate monitor out of a scan. #[derive(Debug, Clone, PartialEq, Eq)] pub enum HrSelector { /// A specific device, by address — what the app uses, since the scan has /// already identified it. Matched without insisting on the advertisement: /// a device mid-connection may not be advertising its services. Address(String), /// Any peripheral advertising the Heart Rate Service (`0x180D`). Any, } impl HrSelector { pub fn matches(&self, d: &DiscoveredDevice) -> bool { self.matches_parts( &d.address, d.services.contains(&uuids::HEART_RATE_SERVICE), &format!("{:?}", d.id), ) } /// The matching rule, factored out so it can be unit-tested without a /// `PeripheralId` (which only the platform backend can construct). pub(crate) fn matches_parts( &self, address: &str, advertises_hr: bool, id_debug: &str, ) -> bool { match self { HrSelector::Address(a) => { address.eq_ignore_ascii_case(a) || id_debug.to_lowercase().contains(&a.to_lowercase()) } HrSelector::Any => advertises_hr, } } /// Human-readable, for error messages the rider will actually read. pub fn describe(&self) -> String { match self { HrSelector::Address(a) => format!("heart rate monitor at {a}"), HrSelector::Any => "any heart rate monitor".to_string(), } } } /// Tunables for [`HeartRateClient`]. #[derive(Debug, Clone)] pub struct HeartRateConfig { /// How long to look before giving up. A broadcasting device advertises /// continuously — unlike a Click — so this needs no extra patience. pub scan_timeout: Duration, pub backoff: Backoff, pub channel_capacity: usize, } impl Default for HeartRateConfig { fn default() -> Self { Self { scan_timeout: Duration::from_secs(15), backoff: Backoff::default(), channel_capacity: 64, } } } // --------------------------------------------------------------------------- // Events and handle // --------------------------------------------------------------------------- /// Everything the app learns from a heart rate monitor. #[derive(Debug, Clone, PartialEq)] pub enum HeartRateEvent { Connected { address: String, name: Option, /// Read once at connect, where the device exposes `0x2A19` at all. battery_percent: Option, }, /// The link dropped. The actor is retrying — this is not terminal. Disconnected, /// Reconnecting ran out of attempts and the actor has stopped (FR-1.11). /// Terminal: nothing further arrives on this stream. GaveUp { attempts: u32 }, /// A decoded measurement. Measurement { bpm: u16, sensor_contact: Option, }, } enum Cmd { Shutdown { reply: oneshot::Sender<()> }, } /// Cheap, cloneable handle to a heart rate monitor session. #[derive(Clone)] pub struct HeartRateClient { cmd_tx: mpsc::Sender, events_tx: broadcast::Sender, address: String, name: Option, battery_percent: Option, } impl HeartRateClient { /// Carried on the handle rather than left to the event stream, because the /// first `Connected` is sent before the caller has had a chance to /// subscribe — same reasoning as [`crate::click::ClickClient::address`]. pub fn address(&self) -> &str { &self.address } pub fn name(&self) -> Option<&str> { self.name.as_deref() } /// Battery level at connect time, where the device reported one. pub fn battery_percent(&self) -> Option { self.battery_percent } /// Connect to a heart rate monitor and start streaming measurements. /// /// Returns once the measurement characteristic is subscribed, so an `Ok` /// means data is genuinely on its way — not merely that a link exists. pub async fn connect( selector: HrSelector, config: HeartRateConfig, ) -> Result { let adapter = scan::default_adapter().await?; Self::connect_with_adapter(adapter, selector, config).await } /// As [`HeartRateClient::connect`], but abandoned as soon as `cancel` /// resolves. Returns `Ok(None)` when it was cancelled; any link the /// abandoned attempt had opened is closed before this returns (SAF-9, /// FR-1.10). pub async fn connect_cancellable( selector: HrSelector, config: HeartRateConfig, cancel: impl Future, ) -> Result, FtmsError> { let in_flight = InFlight::default(); let outcome = { let attempt = async { let adapter = scan::default_adapter().await?; Self::connect_on(adapter, selector, config, &in_flight).await }; tokio::pin!(attempt, cancel); tokio::select! { result = &mut attempt => Some(result), () = &mut cancel => None, } }; match outcome { Some(result) => result.map(Some), None => { tracing::info!("hr: connect attempt cancelled"); in_flight.abandon().await; Ok(None) } } } /// As [`HeartRateClient::connect`], but on a caller-supplied adapter. pub async fn connect_with_adapter( adapter: Adapter, selector: HrSelector, config: HeartRateConfig, ) -> Result { Self::connect_on(adapter, selector, config, &InFlight::default()).await } async fn connect_on( adapter: Adapter, selector: HrSelector, config: HeartRateConfig, in_flight: &InFlight, ) -> Result { let (events_tx, _) = broadcast::channel(config.channel_capacity); let (cmd_tx, cmd_rx) = mpsc::channel(8); let (session, notifications) = open_session(&adapter, &selector, &config, in_flight).await?; let (address, name, battery_percent) = ( session.address.clone(), session.name.clone(), session.battery_percent, ); // Sent for symmetry with a reconnect; the same facts ride out on the // handle below because nobody is subscribed yet. let _ = events_tx.send(HeartRateEvent::Connected { address: address.clone(), name: name.clone(), battery_percent, }); let actor = Actor { adapter, // Reconnect to *this* device, not to whatever HRM now answers // first — two straps in a household is not exotic. selector: if address.is_empty() { selector } else { HrSelector::Address(address.clone()) }, config, events_tx: events_tx.clone(), }; tokio::spawn(actor.run(cmd_rx, session, Box::pin(notifications))); Ok(Self { cmd_tx, events_tx, address, name, battery_percent, }) } /// Subscribe to events. Late subscribers see only what arrives after they /// subscribe. pub fn events(&self) -> broadcast::Receiver { self.events_tx.subscribe() } /// Close the link, and wait for it to actually be closed (SAF-9). /// Idempotent — shutting down a stopped client is a no-op. pub async fn shutdown(&self) { let (reply, done) = oneshot::channel(); if self.cmd_tx.send(Cmd::Shutdown { reply }).await.is_err() { // The actor is already gone; it tore the link down on its way out. return; } let _ = done.await; } } // --------------------------------------------------------------------------- // Session and actor // --------------------------------------------------------------------------- /// Boxed so the initial connection and every reconnect share one type. type Notifications = std::pin::Pin< Box + Send>, >; struct Session { peripheral: Peripheral, address: String, name: Option, battery_percent: Option, measurement: Characteristic, } /// Find the device, connect and subscribe to Heart Rate Measurement. async fn open_session( adapter: &Adapter, selector: &HrSelector, config: &HeartRateConfig, in_flight: &InFlight, ) -> Result<(Session, Notifications), FtmsError> { // Unfiltered: an address selector must match a device whatever it // advertises, and some backends ignore service filters anyway. let peripheral = scan::find_matching( adapter, ScanKind::All, config.scan_timeout, &selector.describe(), |d| selector.matches(d), ) .await?; // Cancelling past this point would otherwise strand the link (FR-1.10). in_flight.hold(peripheral.clone()); match setup_session(peripheral.clone()).await { Ok(session) => { in_flight.released(); Ok(session) } Err(e) => { // A failure after `connect()` leaves a live GATT link behind, and a // watch in broadcast mode accepts one central — held, it will not // advertise for the retry, so the retry loop would never succeed. tracing::debug!(error = %e, "hr: session setup failed; disconnecting"); let _ = peripheral.disconnect().await; in_flight.released(); Err(e) } } } async fn setup_session(peripheral: Peripheral) -> Result<(Session, Notifications), FtmsError> { if !peripheral.is_connected().await.unwrap_or(false) { peripheral.connect().await?; } peripheral.discover_services().await?; let chars = peripheral.characteristics(); // Prefer the characteristic inside the Heart Rate Service, but take a // bare 0x2A37 anywhere — the same leniency the FTMS client extends. let measurement = chars .iter() .find(|c| { c.uuid == uuids::HEART_RATE_MEASUREMENT && c.service_uuid == uuids::HEART_RATE_SERVICE }) .or_else(|| chars.iter().find(|c| c.uuid == uuids::HEART_RATE_MEASUREMENT)) .cloned() .ok_or(FtmsError::MissingCharacteristic( "Heart Rate Measurement (0x2A37)", ))?; let notifications = peripheral.notifications().await?; peripheral.subscribe(&measurement).await?; // Best effort, once: straps report battery, watches in broadcast mode // usually do not. A failure here must not cost the session. let battery_percent = match chars.iter().find(|c| c.uuid == uuids::BATTERY_LEVEL) { Some(c) => peripheral .read(c) .await .ok() .and_then(|v| v.first().copied()) .filter(|p| *p <= 100), None => None, }; let described = scan::describe(&peripheral).await; Ok(( Session { address: described .as_ref() .map(|d| d.address.clone()) .unwrap_or_default(), name: described.and_then(|d| d.name), battery_percent, peripheral, measurement, }, Box::pin(notifications), )) } struct Actor { adapter: Adapter, selector: HrSelector, config: HeartRateConfig, events_tx: broadcast::Sender, } impl Actor { async fn run( self, mut cmd_rx: mpsc::Receiver, mut current: Session, mut notifications: Notifications, ) { let mut attempt = 0u32; loop { let dropped = tokio::select! { cmd = cmd_rx.recv() => { match cmd { Some(Cmd::Shutdown { reply }) => { self.teardown(¤t).await; // Answered only once the link is genuinely closed // (SAF-9). let _ = reply.send(()); return; } None => { self.teardown(¤t).await; return; } } } frame = notifications.next() => match frame { Some(n) => { self.handle(n.uuid, &n.value); false } // The stream ending is how btleplug reports a dropped link. None => true, }, }; if !dropped { continue; } tracing::warn!( selector = %self.selector.describe(), "hr: notification stream ended — link dropped" ); let _ = self.events_tx.send(HeartRateEvent::Disconnected); // FR-1.6: reconnect with backoff. A watch that left broadcast mode // or a strap taken off will simply not be found, which is normal. loop { if self.config.backoff.exhausted(attempt) { tracing::warn!("hr: giving up after {attempt} reconnect attempts"); // Say so before going away (FR-1.11): a silent exit strands // the supervisor holding a handle whose actor is gone. let _ = self.events_tx.send(HeartRateEvent::GaveUp { attempts: attempt }); return; } let delay = self.config.backoff.delay(attempt); attempt += 1; // Both the backoff and the attempt stay answerable to shutdown // (FR-1.10, SAF-9) — an attempt runs for up to `scan_timeout`. let in_flight = InFlight::default(); let outcome = { let attempting = async { tokio::time::sleep(delay).await; open_session(&self.adapter, &self.selector, &self.config, &in_flight) .await }; tokio::pin!(attempting); tokio::select! { biased; cmd = cmd_rx.recv() => Err(cmd), result = &mut attempting => Ok(result), } }; let result = match outcome { Ok(result) => result, Err(cmd) => { in_flight.abandon().await; if let Some(Cmd::Shutdown { reply }) = cmd { let _ = reply.send(()); } return; } }; match result { Ok((session, stream)) => { let _ = self.events_tx.send(HeartRateEvent::Connected { address: session.address.clone(), name: session.name.clone(), battery_percent: session.battery_percent, }); current = session; notifications = stream; attempt = 0; break; } Err(e) => tracing::debug!(error = %e, "hr: reconnect failed"), } } } } /// Decode one notification into an event. fn handle(&self, uuid: uuid::Uuid, raw: &[u8]) { if uuid != uuids::HEART_RATE_MEASUREMENT { tracing::trace!(%uuid, raw = %hex(raw), "hr: notification on an unexpected characteristic"); return; } match decode_heart_rate(raw) { Ok(m) => { let _ = self.events_tx.send(HeartRateEvent::Measurement { bpm: m.bpm, sensor_contact: m.sensor_contact, }); } // Logged, never fatal (NFR-4). Err(e) => tracing::debug!(raw = %hex(raw), error = %e, "hr: bad measurement frame"), } } /// Close the link. Bounded: this runs on the app's exit path (NFR-9). async fn teardown(&self, session: &Session) { tracing::debug!(measurement = %session.measurement.uuid, "hr: closing the link"); // No unsubscribe first. It is optional — dropping the link clears the // strap's CCCD anyway — and on the exit path it competes for the same // budget as the disconnect that actually matters. The Click actor lost // that race and left links open in BlueZ; this path is one // characteristic rather than five, but the failure is the same shape. match tokio::time::timeout(DISCONNECT_TIMEOUT, session.peripheral.disconnect()).await { Ok(Ok(())) => tracing::info!("hr: disconnected"), Ok(Err(e)) => tracing::debug!(error = %e, "hr: disconnect failed"), Err(_) => tracing::warn!( "hr: disconnect timed out — the link may be left open, and a stale link makes \ the strap look connected while sending nothing" ), } let _ = self.events_tx.send(HeartRateEvent::Disconnected); } } #[cfg(test)] mod tests { use super::*; #[test] fn a_plain_u8_measurement_decodes() { // Flags 0x00: u8 heart rate, no contact feature, nothing else. let m = decode_heart_rate(&[0x00, 142]).unwrap(); assert_eq!(m.bpm, 142); assert_eq!(m.sensor_contact, None); } #[test] fn a_u16_measurement_decodes_little_endian() { // Flags 0x01: u16 heart rate. 0x0121 = 289 — absurd for a human but // the format allows it, and the decoder is a decoder, not a referee. let m = decode_heart_rate(&[0x01, 0x21, 0x01]).unwrap(); assert_eq!(m.bpm, 289); } #[test] fn sensor_contact_is_three_states_not_two() { // Bit 2 = feature supported, bit 1 = contact detected. A strap without // the feature must read `None`, not "fallen off". assert_eq!( decode_heart_rate(&[0x06, 100]).unwrap().sensor_contact, Some(true) ); assert_eq!( decode_heart_rate(&[0x04, 100]).unwrap().sensor_contact, Some(false) ); assert_eq!(decode_heart_rate(&[0x00, 100]).unwrap().sensor_contact, None); // Bit 1 without bit 2 is "feature not supported" per the spec. assert_eq!(decode_heart_rate(&[0x02, 100]).unwrap().sensor_contact, None); } #[test] fn trailing_fields_are_skipped_not_rejected() { // Flags 0x18: energy expended + RR intervals present. Real straps // (Garmin HRM-Dual, Polar H10) send these; the bpm must still decode. let m = decode_heart_rate(&[0x18, 155, 0x10, 0x00, 0x40, 0x03]).unwrap(); assert_eq!(m.bpm, 155); } #[test] fn short_frames_are_errors_not_panics() { // NFR-4: a malformed packet is a value, never a crash. assert_eq!(decode_heart_rate(&[]), Err(HrDecodeError::Empty)); assert!(matches!( decode_heart_rate(&[0x00]), Err(HrDecodeError::TooShort { .. }) )); // u16 flag with only one byte of payload. assert!(matches!( decode_heart_rate(&[0x01, 142]), Err(HrDecodeError::TooShort { .. }) )); } #[test] fn selector_any_requires_the_heart_rate_service() { let s = HrSelector::Any; assert!(s.matches_parts("aa:bb:cc:dd:ee:ff", true, "")); assert!(!s.matches_parts("aa:bb:cc:dd:ee:ff", false, "")); } #[test] fn selector_address_ignores_the_advertisement() { // Reconnect runs against a device that may not be advertising its // services; the address alone identifies it. let s = HrSelector::Address("AA:BB:CC:DD:EE:FF".into()); assert!(s.matches_parts("aa:bb:cc:dd:ee:ff", false, "")); assert!(!s.matches_parts("11:22:33:44:55:66", true, "")); } #[test] fn selector_address_also_matches_an_opaque_platform_id() { // macOS gives UUID-shaped PeripheralIds rather than MACs. let s = HrSelector::Address("1E2F3A4B".into()); assert!(s.matches_parts("", false, "PeripheralId(1e2f3a4b-....)")); } }