Files
BikeControl/crates/ble/src/heart_rate.rs
T
dtourolleandClaude Opus 5 a0190095fb
🚴 Build and Test BikeControl / Workspace tests (push) Failing after 0s
🚴 Build and Test BikeControl / Android compile check (push) Skipped
Close the link before the budget runs out, not after
A pod that was connected, reporting battery every five seconds, and
sending no button frames at all cost most of an evening. The cause was
not in the decode: it was that the previous run never let go of the link.

`Actor::teardown` unsubscribed every notifying characteristic before
disconnecting, each under its own two-second timeout. A Click carries
five of them, so the exit path could spend ten seconds on optional work
against a supervisor budget of three. The supervisor gave up first, the
process exited, and `disconnect` was never reached — leaving BlueZ
holding the pod with no application running. The next connect inherited
that half-dead session, and a half-dead session streams battery and no
buttons, which reads exactly like broken hardware.

Measured directly: both apps stopped, `bluetoothctl devices Connected`
still listing the pod. It also explains the connect failures that came
with it — `le-connection-abort-by-local`, then `service discovery timed
out` — and why the fault came and went, since it depended on whether the
last exit happened to time out.

Nothing is lost by dropping the unsubscribes. The link going down clears
the peripheral's CCCDs anyway, so they were only ever politeness toward a
connection about to be destroyed. The heart rate actor had the same
shape with one characteristic instead of five; the trainer's safety
writes stay, because SAF-2 is not optional.

All three now say what a timed-out disconnect costs. The old message
named the symptom and not the consequence, and the consequence lands on
the *next* run — which is precisely why this hid for so long.

Also logs which pod reported which button. A pod reporting a button that
belongs to the other one is invisible downstream, because the input
carries the button and never its source.

Fixes both platforms at once: this is `crates/ble`, and Android is the
worse case — a leaked link there survives the app being swiped away.

Verified with four connect/disconnect cycles against the `−` pod: every
button registers on its documented bit (D-pad 0-3, paddle 8), and BlueZ
holds nothing afterwards. That satisfies TASK-0(d) and retires RISK-9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 20:21:31 +02:00

683 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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
/// 12 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?;
// 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<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(&current).await;
// Answered only once the link is genuinely closed
// (SAF-9).
let _ = reply.send(());
return;
}
None => {
self.teardown(&current).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-....)"));
}
}