Two failures on the tablet, one of them mine from an hour ago. The real one first. A trainer that drops mid-ride could not get back: its supervisor reconnects on its own (FR-1.10) and those attempts never pass through `DeviceRegistry::connect`, so nothing suspended the device list for them. On Android a GATT link that is discovering services while a scan is running is killed by the platform, and the log has it exactly — a reconnect at 13:07:18 dying with `Disconnected while discovering services`, inside a list-scan session opened at 13:07:02. My first fix was to suspend the list whenever the trainer was mid-connect. That was drawn around the wrong thing. `Connecting` covers the 15 s *search*, `Reconnecting` covers the backoff between attempts, and against an asleep trainer those alternate for the whole of RECONNECT_ATTEMPTS — so the list scan went off the air for minutes and every other device starved with it. A pod that dropped could never be seen again, which is what "the pods disconnect after 40 seconds" was. The window that matters is narrower than either: connect, then discover services. `scan::gatt_setup` marks it — an RAII guard taken by the trainer, pod and heart rate paths the moment their search returns a peripheral — and the device list yields only for that. Measured on the tablet: 1.2 s of yielding for a heart rate connect, then straight back to one session per 21 s. Also: the "+ pod seen; the − pod speaks for the pair" line is logged once per run of refusals rather than once per sighting. The device list republishes several times a second and every pass re-reported a visible pod — 274 identical lines in five minutes, burying the connect failures the log was being read for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
690 lines
25 KiB
Rust
690 lines
25 KiB
Rust
//! 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<bool>,
|
||
}
|
||
|
||
/// 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<HeartRateMeasurement, HrDecodeError> {
|
||
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<String>,
|
||
/// Read once at connect, where the device exposes `0x2A19` at all.
|
||
battery_percent: Option<u8>,
|
||
},
|
||
/// 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<bool>,
|
||
},
|
||
}
|
||
|
||
enum Cmd {
|
||
Shutdown { reply: oneshot::Sender<()> },
|
||
}
|
||
|
||
/// Cheap, cloneable handle to a heart rate monitor session.
|
||
#[derive(Clone)]
|
||
pub struct HeartRateClient {
|
||
cmd_tx: mpsc::Sender<Cmd>,
|
||
events_tx: broadcast::Sender<HeartRateEvent>,
|
||
address: String,
|
||
name: Option<String>,
|
||
battery_percent: Option<u8>,
|
||
}
|
||
|
||
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<u8> {
|
||
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<Self, FtmsError> {
|
||
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<Output = ()>,
|
||
) -> Result<Option<Self>, 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, FtmsError> {
|
||
Self::connect_on(adapter, selector, config, &InFlight::default()).await
|
||
}
|
||
|
||
async fn connect_on(
|
||
adapter: Adapter,
|
||
selector: HrSelector,
|
||
config: HeartRateConfig,
|
||
in_flight: &InFlight,
|
||
) -> Result<Self, FtmsError> {
|
||
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<HeartRateEvent> {
|
||
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<dyn Stream<Item = btleplug::api::ValueNotification> + Send>,
|
||
>;
|
||
|
||
struct Session {
|
||
peripheral: Peripheral,
|
||
address: String,
|
||
name: Option<String>,
|
||
battery_percent: Option<u8>,
|
||
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?;
|
||
// The GATT window — see `scan::gatt_setup`.
|
||
let _gatt = scan::gatt_setup();
|
||
// 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> {
|
||
// Never adopt a link we did not open. A peripheral found already connected
|
||
// usually means a previous run's shutdown ran out of budget before closing
|
||
// it, and an inherited half-closed session reports some frames and not
|
||
// others rather than failing outright (§7.1, SAF-9).
|
||
if peripheral.is_connected().await.unwrap_or(false) {
|
||
let _ = tokio::time::timeout(DISCONNECT_TIMEOUT, peripheral.disconnect()).await;
|
||
}
|
||
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<HeartRateEvent>,
|
||
}
|
||
|
||
impl Actor {
|
||
async fn run(
|
||
self,
|
||
mut cmd_rx: mpsc::Receiver<Cmd>,
|
||
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-....)"));
|
||
}
|
||
}
|