Stream heart rate from a strap or watch into the ride and the FIT file
🚴 Build and Test BikeControl / Workspace tests (push) Failing after 0s
🚴 Build and Test BikeControl / Android compile check (push) Skipped

A new HRM client in the BLE crate follows the crate's split: the 0x2A37
decoder is a pure function over bytes (u8/u16 formats, the three-state
sensor-contact field, straps that append energy/RR data), and only the
actor touches the radio. Anything exposing the standard Heart Rate
Service works — a chest strap, or a Garmin watch with Broadcast Heart
Rate on.

A single-slot supervisor in the app owns the link, shaped like the
trainer's and the controller's. It publishes bpm on a watch channel the
session backend stamps onto each tick's telemetry — never over a heart
rate FTMS itself reported, on the same authority rule as the Zwift
cadence merge — and it clears the reading after eight silent seconds,
so a strap taken off records nothing rather than a flatline of the
last real value. From there the existing pipeline does the rest: ride
screen tile, FIT records, avg/max in lap and session.

The device list routes heart-rate rows to the supervisor, keeps the row
alive while connected (a connected monitor stops advertising), and
shows the live bpm as proof data is flowing — a connected-but-silent
monitor otherwise looks exactly like a working one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 19:27:04 +02:00
co-authored by Claude Fable 5
parent be046f341c
commit 3274928a5f
10 changed files with 1467 additions and 24 deletions
+675
View File
@@ -0,0 +1,675 @@
//! 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) {
let unsubscribe = session.peripheral.unsubscribe(&session.measurement);
let _ = tokio::time::timeout(DISCONNECT_TIMEOUT, unsubscribe).await;
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"),
}
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-....)"));
}
}
+6
View File
@@ -13,6 +13,7 @@
//! | [`control_point`] | `0x2AD9` encoders and response decoding | no | //! | [`control_point`] | `0x2AD9` encoders and response decoding | no |
//! | [`capabilities`] | `0x2ACC`/`0x2AD5`/`0x2AD6`/`0x2AD8` decoding, and the safety gate | no | //! | [`capabilities`] | `0x2ACC`/`0x2AD5`/`0x2AD6`/`0x2AD8` decoding, and the safety gate | no |
//! | [`zwift`] | Zwift's proprietary protocol (§2.3.1) | no | //! | [`zwift`] | Zwift's proprietary protocol (§2.3.1) | no |
//! | [`heart_rate`] | `0x2A37` decoder (pure) and the HRM connection actor | decoder no, actor yes |
//! | [`scan`] | discovery | yes | //! | [`scan`] | discovery | yes |
//! | [`client`] | the connection actor | yes | //! | [`client`] | the connection actor | yes |
//! //!
@@ -52,12 +53,17 @@ pub mod click;
pub mod client; pub mod client;
pub mod control_point; pub mod control_point;
pub mod error; pub mod error;
pub mod heart_rate;
pub mod indoor_bike_data; pub mod indoor_bike_data;
pub mod scan; pub mod scan;
pub mod uuids; pub mod uuids;
pub mod zwift; pub mod zwift;
pub use click::{ClickClient, ClickConfig, ClickEvent, PodSelector}; pub use click::{ClickClient, ClickConfig, ClickEvent, PodSelector};
pub use heart_rate::{
decode_heart_rate, HeartRateClient, HeartRateConfig, HeartRateEvent, HeartRateMeasurement,
HrSelector,
};
pub use capabilities::{ pub use capabilities::{
FitnessMachineFeature, InclinationRange, PowerRange, ResistanceLevelRange, TrainerCapabilities, FitnessMachineFeature, InclinationRange, PowerRange, ResistanceLevelRange, TrainerCapabilities,
UnsupportedTarget, UnsupportedTarget,
+12
View File
@@ -55,6 +55,16 @@ pub const DEVICE_INFORMATION_SERVICE: Uuid = uuid16(0x180A);
/// Battery Service — `0x180F`. /// Battery Service — `0x180F`.
pub const BATTERY_SERVICE: Uuid = uuid16(0x180F); pub const BATTERY_SERVICE: Uuid = uuid16(0x180F);
/// Battery Level — `0x2A19`, read.
pub const BATTERY_LEVEL: Uuid = uuid16(0x2A19);
/// Heart Rate Service — `0x180D`. What a strap — or a watch in broadcast
/// mode — advertises.
pub const HEART_RATE_SERVICE: Uuid = uuid16(0x180D);
/// Heart Rate Measurement — `0x2A37`, notify.
pub const HEART_RATE_MEASUREMENT: Uuid = uuid16(0x2A37);
/// Human-readable name for a well-known UUID, for logging and the probe CLI. /// Human-readable name for a well-known UUID, for logging and the probe CLI.
/// Returns `None` for anything not recognised. /// Returns `None` for anything not recognised.
pub fn well_known_name(uuid: Uuid) -> Option<&'static str> { pub fn well_known_name(uuid: Uuid) -> Option<&'static str> {
@@ -79,6 +89,8 @@ pub fn well_known_name(uuid: Uuid) -> Option<&'static str> {
0x1816 => "Cycling Speed and Cadence", 0x1816 => "Cycling Speed and Cadence",
0x2A00 => "Device Name", 0x2A00 => "Device Name",
0x2A19 => "Battery Level", 0x2A19 => "Battery Level",
0x2A37 => "Heart Rate Measurement",
0x2A38 => "Body Sensor Location",
0x2A24 => "Model Number String", 0x2A24 => "Model Number String",
0x2A25 => "Serial Number String", 0x2A25 => "Serial Number String",
0x2A26 => "Firmware Revision String", 0x2A26 => "Firmware Revision String",
+95 -2
View File
@@ -30,6 +30,7 @@ use tokio::sync::watch;
use uuid::Uuid; use uuid::Uuid;
use crate::controller::{ControllerHandle, PodState}; use crate::controller::{ControllerHandle, PodState};
use crate::heart_rate::{HeartRateHandle, HeartRateStatus};
use crate::trainer::{TrainerHandle, TrainerStatus}; use crate::trainer::{TrainerHandle, TrainerStatus};
/// One pass of the scanner. Long enough for a trainer to advertise, short /// One pass of the scanner. Long enough for a trainer to advertise, short
@@ -100,6 +101,9 @@ pub struct DeviceInfo {
/// Previously paired, so it would auto-connect on launch (FR-1.5). /// Previously paired, so it would auto-connect on launch (FR-1.5).
pub remembered: bool, pub remembered: bool,
pub battery_pct: Option<u8>, pub battery_pct: Option<u8>,
/// Live reading from a connected heart rate monitor — the row's proof that
/// data is actually flowing, the way a pod's battery is.
pub heart_rate_bpm: Option<u8>,
// No unlock countdown. FR-3.9 assumed the Click v2 needed its Zwift session // No unlock countdown. FR-3.9 assumed the Click v2 needed its Zwift session
// refreshing daily; TASK-0 disproved it on this hardware — the pods answer // refreshing daily; TASK-0 disproved it on this hardware — the pods answer
// `RideOn 00 09` unencrypted, with no key exchange and no expiry (§2.3.1). // `RideOn 00 09` unencrypted, with no key exchange and no expiry (§2.3.1).
@@ -117,6 +121,9 @@ pub struct PollResult {
/// Trainer status, when it changed since the last poll. The caller turns /// Trainer status, when it changed since the last poll. The caller turns
/// this into user-facing notices (FR-1.8, FR-9.4). /// this into user-facing notices (FR-1.8, FR-9.4).
pub trainer_changed: Option<TrainerStatus>, pub trainer_changed: Option<TrainerStatus>,
/// Heart rate link status, when it changed since the last poll — same
/// contract as `trainer_changed`.
pub hr_changed: Option<HeartRateStatus>,
} }
/// What the scan task publishes. /// What the scan task publishes.
@@ -135,6 +142,8 @@ pub struct DeviceRegistry {
/// Held so a Click row in the device list connects the same way its card on /// Held so a Click row in the device list connects the same way its card on
/// the connection screen does — one path, not two that can disagree. /// the connection screen does — one path, not two that can disagree.
controller: ControllerHandle, controller: ControllerHandle,
/// The heart rate supervisor, for the same reason.
hr: HeartRateHandle,
scan_rx: watch::Receiver<ScanSnapshot>, scan_rx: watch::Receiver<ScanSnapshot>,
scan_on: watch::Sender<bool>, scan_on: watch::Sender<bool>,
forgotten: HashSet<String>, forgotten: HashSet<String>,
@@ -142,6 +151,7 @@ pub struct DeviceRegistry {
/// The list published last tick, for change detection. /// The list published last tick, for change detection.
published: Vec<DeviceInfo>, published: Vec<DeviceInfo>,
last_trainer: TrainerStatus, last_trainer: TrainerStatus,
last_hr: HeartRateStatus,
pub scanning: bool, pub scanning: bool,
/// Scanning was switched off by *us*, to get out of the way of a connect — /// Scanning was switched off by *us*, to get out of the way of a connect —
/// not by the rider. Only a suspension is resumed automatically (FR-1.12). /// not by the rider. Only a suspension is resumed automatically (FR-1.12).
@@ -151,14 +161,16 @@ pub struct DeviceRegistry {
} }
impl DeviceRegistry { impl DeviceRegistry {
pub fn new(trainer: TrainerHandle, controller: ControllerHandle) -> Self { pub fn new(trainer: TrainerHandle, controller: ControllerHandle, hr: HeartRateHandle) -> Self {
let (scan_on, scan_on_rx) = watch::channel(false); let (scan_on, scan_on_rx) = watch::channel(false);
let (scan_tx, scan_rx) = watch::channel(ScanSnapshot::default()); let (scan_tx, scan_rx) = watch::channel(ScanSnapshot::default());
tauri::async_runtime::spawn(scan_loop(scan_on_rx, scan_tx)); tauri::async_runtime::spawn(scan_loop(scan_on_rx, scan_tx));
Self { Self {
last_trainer: trainer.status(), last_trainer: trainer.status(),
last_hr: hr.status(),
trainer, trainer,
controller, controller,
hr,
scan_rx, scan_rx,
scan_on, scan_on,
forgotten: HashSet::new(), forgotten: HashSet::new(),
@@ -193,6 +205,14 @@ impl DeviceRegistry {
let trainer_changed = (trainer != self.last_trainer).then(|| trainer.clone()); let trainer_changed = (trainer != self.last_trainer).then(|| trainer.clone());
self.last_trainer = trainer.clone(); self.last_trainer = trainer.clone();
let hr = self.hr.status();
// Only *link* changes are reported upward: a bpm arriving once a second
// would otherwise raise a notice per heartbeat. The row still updates —
// `build` reads the full status every tick.
let hr_changed = (hr.state != self.last_hr.state || hr.error != self.last_hr.error)
.then(|| hr.clone());
self.last_hr = hr;
// The scan is switched off for the duration of a connect so that it and // The scan is switched off for the duration of a connect so that it and
// `find_peripheral` do not fight over the one adapter — and nothing else // `find_peripheral` do not fight over the one adapter — and nothing else
// ever turns it back on. A disconnect, or a connect that failed, would // ever turns it back on. A disconnect, or a connect that failed, would
@@ -213,6 +233,7 @@ impl DeviceRegistry {
changed, changed,
transitions, transitions,
trainer_changed, trainer_changed,
hr_changed,
} }
} }
@@ -253,6 +274,7 @@ impl DeviceRegistry {
services: d.services.iter().map(|u| describe_service(*u)).collect(), services: d.services.iter().map(|u| describe_service(*u)).collect(),
remembered: self.remembered.contains(&id), remembered: self.remembered.contains(&id),
battery_pct: None, battery_pct: None,
heart_rate_bpm: None,
error: None, error: None,
id, id,
}); });
@@ -276,6 +298,7 @@ impl DeviceRegistry {
services: vec![describe_service(uuids::FITNESS_MACHINE_SERVICE)], services: vec![describe_service(uuids::FITNESS_MACHINE_SERVICE)],
remembered: true, remembered: true,
battery_pct: None, battery_pct: None,
heart_rate_bpm: None,
error: None, error: None,
}); });
out.len() - 1 out.len() - 1
@@ -327,6 +350,7 @@ impl DeviceRegistry {
services: vec![describe_service(ZWIFT_SERVICE)], services: vec![describe_service(ZWIFT_SERVICE)],
remembered: true, remembered: true,
battery_pct: None, battery_pct: None,
heart_rate_bpm: None,
error: None, error: None,
}); });
out.len() - 1 out.len() - 1
@@ -350,6 +374,53 @@ impl DeviceRegistry {
}; };
} }
// And the heart rate monitor, for the same reason again: a connected
// monitor stops advertising, and its row must not vanish the moment it
// starts working. A view of what the supervisor owns, never a second
// link.
let hr = self.hr.status();
if let Some(address) = hr.address.clone() {
if !self.forgotten.contains(&address) {
let idx = match out.iter().position(|d| d.id == address) {
Some(i) => i,
None => {
out.push(DeviceInfo {
id: address.clone(),
name: hr
.name
.clone()
.unwrap_or_else(|| "Heart rate monitor".into()),
address,
rssi: 0,
kind: DeviceKind::HeartRate,
state: ConnectionState::Idle,
control_acquired: false,
services: vec![describe_service(HEART_RATE_SERVICE)],
remembered: true,
battery_pct: None,
heart_rate_bpm: None,
error: None,
});
out.len() - 1
}
};
let device = &mut out[idx];
device.kind = DeviceKind::HeartRate;
device.battery_pct = hr.battery_percent;
device.heart_rate_bpm = hr.bpm;
device.error = hr.error.clone();
device.remembered = true;
if let Some(name) = &hr.name {
device.name = name.clone();
}
// Idle after a deliberate disconnect keeps whatever the scan
// says about the row, exactly like an idle pod.
if hr.state != ConnectionState::Idle {
device.state = hr.state.clone();
}
}
}
// Trainers first, then by signal strength: the thing the rider is // Trainers first, then by signal strength: the thing the rider is
// looking for should not be below an unnamed peripheral. // looking for should not be below an unnamed peripheral.
out.sort_by(|a, b| { out.sort_by(|a, b| {
@@ -388,9 +459,24 @@ impl DeviceRegistry {
return Ok(info); return Ok(info);
} }
// A heart rate row connects the monitor it is, by address — routed to
// the heart rate supervisor so this list and whatever else drives the
// link stay one path.
if device.kind == DeviceKind::HeartRate {
self.remembered.insert(id.to_string());
self.forgotten.remove(id);
self.hr.connect(Some(device.address.clone()));
let mut info = device;
info.state = ConnectionState::Connecting;
info.error = None;
info.remembered = true;
return Ok(info);
}
if device.kind != DeviceKind::Trainer { if device.kind != DeviceKind::Trainer {
return Err(format!( return Err(format!(
"{} is neither a trainer nor a Click pod — there is nothing to connect to.", "{} is not a trainer, Click pod or heart rate monitor — there is nothing to \
connect to.",
device.name device.name
)); ));
} }
@@ -446,6 +532,9 @@ impl DeviceRegistry {
if let Some(pod) = device.kind.pod_id() { if let Some(pod) = device.kind.pod_id() {
self.controller.disconnect(Some(pod)); self.controller.disconnect(Some(pod));
} }
if device.kind == DeviceKind::HeartRate {
self.hr.disconnect();
}
device.state = ConnectionState::Idle; device.state = ConnectionState::Idle;
device.control_acquired = false; device.control_acquired = false;
Ok(device) Ok(device)
@@ -458,6 +547,9 @@ impl DeviceRegistry {
if device.kind == DeviceKind::Trainer && device.control_acquired { if device.kind == DeviceKind::Trainer && device.control_acquired {
self.trainer.disconnect(); self.trainer.disconnect();
} }
if device.kind == DeviceKind::HeartRate {
self.hr.disconnect();
}
self.remembered.remove(id); self.remembered.remove(id);
self.forgotten.insert(id.to_string()); self.forgotten.insert(id.to_string());
self.published.retain(|d| d.id != id); self.published.retain(|d| d.id != id);
@@ -638,6 +730,7 @@ mod tests {
services: Vec::new(), services: Vec::new(),
remembered: false, remembered: false,
battery_pct: None, battery_pct: None,
heart_rate_bpm: None,
error: None, error: None,
} }
} }
+521
View File
@@ -0,0 +1,521 @@
//! The heart rate supervisor: the app's single owner of a [`HeartRateClient`].
//!
//! The same shape as [`crate::controller`], with one slot instead of two — the
//! Tauri commands hold a `Mutex` and may never `.await` a radio, so all BLE
//! work happens in one background task they reach over a channel.
//!
//! ```text
//! commands ──connect/disconnect──► [supervisor task] ──► HeartRateClient
//! ride loop ◄──watch<Option<u8>>──────────┘ (bpm)
//! device loop ◄──watch<HeartRateStatus>───┘
//! ```
//!
//! The bpm channel follows the trainer's "never freeze on stale data" rule:
//! a monitor that goes quiet — strap off, watch out of broadcast mode — has
//! its published reading cleared rather than held, so the ride records *no*
//! heart rate rather than a plausible-looking flatline of the last real value.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{sync_channel, SyncSender};
use std::sync::Arc;
use std::time::{Duration, Instant};
use bikecontrol_ble::{
Backoff, FtmsError, HeartRateClient, HeartRateConfig, HeartRateEvent, HrSelector,
};
use bikecontrol_core::types::ConnectionState;
use serde::Serialize;
use tokio::sync::{broadcast, mpsc, oneshot, watch};
/// How long the monitor may stay silent before the published bpm is cleared.
///
/// A strap notifies at about 1 Hz, so eight seconds is ~8 missed frames —
/// enough slack for a lossy link, short enough that a strap taken off does not
/// keep writing its last reading into the FIT file.
const STALE_AFTER: Duration = Duration::from_secs(8);
/// Housekeeping tick — staleness only, so it can be lazy.
const HOUSEKEEPING: Duration = Duration::from_secs(2);
/// Upper bound on closing the link at exit. There is no reset sequence, only
/// an unsubscribe and a disconnect (SAF-9, NFR-9).
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3);
/// How long auto-reconnect keeps chasing the monitor before giving up
/// (FR-1.11). The trainer's budget, not the Click's: a broadcasting device
/// advertises continuously, so "not found" here means it is genuinely gone.
const RECONNECT_ATTEMPTS: u32 = 20;
/// The heart rate configuration this app rides with.
fn hr_config() -> HeartRateConfig {
HeartRateConfig {
backoff: Backoff {
max_attempts: Some(RECONNECT_ATTEMPTS),
..Backoff::default()
},
..HeartRateConfig::default()
}
}
/// What the UI needs to know about the heart rate link (FR-1.7, FR-9.2).
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HeartRateStatus {
pub state: ConnectionState,
pub address: Option<String>,
pub name: Option<String>,
/// The live reading, also published on the bpm watch channel. Carried here
/// too so the connection screen can show proof the data is flowing — a
/// monitor that is connected but silent looks exactly like a working one
/// otherwise.
pub bpm: Option<u8>,
pub battery_percent: Option<u8>,
/// Human-readable failure, rendered verbatim (FR-9.2).
pub error: Option<String>,
}
impl Default for HeartRateStatus {
fn default() -> Self {
Self {
state: ConnectionState::Idle,
address: None,
name: None,
bpm: None,
battery_percent: None,
error: None,
}
}
}
impl HeartRateStatus {
pub fn is_attached(&self) -> bool {
!matches!(
self.state,
ConnectionState::Idle | ConnectionState::Lost { .. }
)
}
}
enum Cmd {
/// Connect to a monitor. `address` when the scanner has already seen it,
/// which is both faster and unambiguous.
Connect { address: Option<String> },
Disconnect,
/// Close the link and stop, answering only once it is actually closed.
Shutdown { reply: SyncSender<()> },
}
/// The outcome of one connect attempt, back from its child task.
struct Attempt {
/// Which attempt this was, so a result superseded by a newer request (or a
/// disconnect) can be told apart from a live one.
generation: u64,
outcome: Result<Option<HeartRateClient>, FtmsError>,
}
/// Cheap, cloneable handle to the supervisor.
#[derive(Clone)]
pub struct HeartRateHandle {
cmd_tx: mpsc::Sender<Cmd>,
status_rx: watch::Receiver<HeartRateStatus>,
bpm_rx: watch::Receiver<Option<u8>>,
/// Shared, so every clone of the handle sees the link is already shut.
shut_down: Arc<AtomicBool>,
}
impl HeartRateHandle {
/// Start the supervisor task. Needs a Tokio runtime, which
/// `tauri::async_runtime` provides before the app is built.
pub fn spawn() -> Self {
let (cmd_tx, cmd_rx) = mpsc::channel(16);
let (status_tx, status_rx) = watch::channel(HeartRateStatus::default());
let (bpm_tx, bpm_rx) = watch::channel(None);
let handle = Self {
cmd_tx,
status_rx,
bpm_rx,
shut_down: Arc::new(AtomicBool::new(false)),
};
tauri::async_runtime::spawn(run(cmd_rx, status_tx, bpm_tx));
handle
}
pub fn status(&self) -> HeartRateStatus {
self.status_rx.borrow().clone()
}
/// The live reading, for the ride engine. Cleared while disconnected or
/// stale, never a held-over value.
pub fn bpm(&self) -> watch::Receiver<Option<u8>> {
self.bpm_rx.clone()
}
pub fn connect(&self, address: Option<String>) {
let _ = self.cmd_tx.try_send(Cmd::Connect { address });
}
pub fn disconnect(&self) {
let _ = self.cmd_tx.try_send(Cmd::Disconnect);
}
/// Close the link at app exit, waiting for it to be closed (SAF-9).
/// Blocks the calling (non-async) thread until done or [`SHUTDOWN_TIMEOUT`]
/// elapses. Idempotent: one quit delivers several exit events, and the
/// repeats must be silent no-ops.
pub fn shutdown_blocking(&self) {
if self.shut_down.swap(true, Ordering::SeqCst) {
return;
}
let deadline = Instant::now() + SHUTDOWN_TIMEOUT;
let (reply, done) = sync_channel(1);
let mut cmd = Cmd::Shutdown { reply };
loop {
match self.cmd_tx.try_send(cmd) {
Ok(()) => break,
Err(mpsc::error::TrySendError::Closed(_)) => return,
Err(mpsc::error::TrySendError::Full(returned)) => {
if Instant::now() >= deadline {
tracing::warn!("hr supervisor unreachable; link left to the OS");
return;
}
cmd = returned;
std::thread::sleep(Duration::from_millis(20));
}
}
}
let remaining = deadline.saturating_duration_since(Instant::now());
match done.recv_timeout(remaining) {
Ok(()) => tracing::info!("heart rate monitor disconnected"),
Err(e) => tracing::warn!(error = %e, "heart rate monitor did not disconnect in time"),
}
}
}
// ---------------------------------------------------------------------------
// Supervisor
// ---------------------------------------------------------------------------
async fn run(
mut cmd_rx: mpsc::Receiver<Cmd>,
status_tx: watch::Sender<HeartRateStatus>,
bpm_tx: watch::Sender<Option<u8>>,
) {
let mut client: Option<HeartRateClient> = None;
let mut events: Option<broadcast::Receiver<HeartRateEvent>> = None;
// Held while a connect is in flight; sending on it — or dropping it —
// abandons the attempt (FR-1.10).
let mut cancel: Option<oneshot::Sender<()>> = None;
let mut generation: u64 = 0;
let mut last_measurement: Option<Instant> = None;
let (attempt_tx, mut attempt_rx) = mpsc::channel::<Attempt>(2);
let mut housekeeping = tokio::time::interval(HOUSEKEEPING);
loop {
tokio::select! {
cmd = cmd_rx.recv() => {
let Some(cmd) = cmd else {
// Every handle dropped: nobody can ask for anything again.
shutdown(client.take(), &bpm_tx).await;
return;
};
match cmd {
Cmd::Connect { address } => {
// A second click while a search is running means
// "connect", which is what is already happening — unless
// it names a different monitor, which takes over.
let current = status_tx.borrow().address.clone();
let same = address.is_none()
|| address.as_deref().is_some_and(|a| {
current.as_deref().is_some_and(|c| c.eq_ignore_ascii_case(a))
});
if same && (cancel.is_some() || client.is_some()) {
tracing::debug!("hr: already connected or connecting");
continue;
}
// Switching monitors: close the old link first (SAF-9).
if let Some(c) = cancel.take() {
let _ = c.send(());
}
generation += 1;
events = None;
last_measurement = None;
if let Some(c) = client.take() {
c.shutdown().await;
}
let _ = bpm_tx.send(None);
let selector = match address.clone().or(current) {
Some(a) if !a.trim().is_empty() => HrSelector::Address(a),
_ => HrSelector::Any,
};
tracing::info!(selector = %selector.describe(), "hr: connecting");
cancel = Some(start_attempt(generation, selector, &attempt_tx));
let _ = status_tx.send(HeartRateStatus {
state: ConnectionState::Connecting,
address,
..HeartRateStatus::default()
});
}
Cmd::Disconnect => {
if let Some(c) = cancel.take() {
let _ = c.send(());
}
generation += 1;
events = None;
last_measurement = None;
if let Some(c) = client.take() {
c.shutdown().await;
}
let _ = bpm_tx.send(None);
// Keep who it was: a connected monitor stops appearing
// in scans, so wiping the address would leave the rider
// nothing to click on to reconnect (FR-1.12).
let address = status_tx.borrow().address.clone();
let name = status_tx.borrow().name.clone();
let _ = status_tx.send(HeartRateStatus {
address,
name,
..HeartRateStatus::default()
});
}
Cmd::Shutdown { reply } => {
if let Some(c) = cancel.take() {
let _ = c.send(());
}
shutdown(client.take(), &bpm_tx).await;
let _ = status_tx.send(HeartRateStatus::default());
// Answered only once the link is genuinely closed, so a
// caller blocking the app's exit on this knows what it
// waited for (SAF-9).
let _ = reply.send(());
return;
}
}
}
Some(attempt) = attempt_rx.recv() => {
if attempt.generation != generation {
// Superseded — by a disconnect or a newer request. A client
// that arrived anyway owns a live GATT link, and dropping it
// would leave the monitor held (SAF-9).
if let Ok(Some(c)) = attempt.outcome {
tracing::debug!("hr: stale connect; closing");
tokio::spawn(async move { c.shutdown().await });
}
continue;
}
cancel = None;
match attempt.outcome {
Ok(Some(c)) => {
tracing::info!(
address = c.address(),
name = c.name().unwrap_or("(no name)"),
"hr: connected"
);
events = Some(c.events());
last_measurement = Some(Instant::now());
let _ = status_tx.send(HeartRateStatus {
state: ConnectionState::Connected,
address: Some(c.address().to_string()),
name: c.name().map(str::to_owned),
battery_percent: c.battery_percent(),
..HeartRateStatus::default()
});
client = Some(c);
}
Ok(None) => {
tracing::info!("hr: connect abandoned");
status_tx.send_modify(|s| s.state = ConnectionState::Idle);
}
Err(e) => {
tracing::warn!(error = %e, "hr: connect failed");
status_tx.send_modify(|s| {
s.state = ConnectionState::Idle;
s.error = Some(connect_hint(&e));
});
}
}
}
// Only polled while there is a stream; `recv` on a `None` receiver
// would busy-loop, so the branch is disabled instead.
event = async { events.as_mut().unwrap().recv().await }, if events.is_some() => {
match event {
Ok(HeartRateEvent::Measurement { bpm, sensor_contact }) => {
last_measurement = Some(Instant::now());
// `Some(false)` is the strap saying it has fallen off;
// its reading is noise and recording it would be worse
// than recording nothing.
let reading = (sensor_contact != Some(false))
.then_some(bpm.min(u8::MAX as u16) as u8);
let _ = bpm_tx.send(reading);
status_tx.send_modify(|s| {
// The first frame after a dropout is the only notice
// that the link is back.
if s.state == ConnectionState::Reconnecting {
s.state = ConnectionState::Connected;
s.error = None;
}
if s.bpm != reading {
s.bpm = reading;
}
});
}
Ok(HeartRateEvent::Connected { address, name, battery_percent }) => {
// A reconnect inside the client actor.
last_measurement = Some(Instant::now());
status_tx.send_modify(|s| {
s.state = ConnectionState::Connected;
s.error = None;
if !address.is_empty() {
s.address = Some(address.clone());
}
s.name = name.clone();
s.battery_percent = battery_percent;
});
}
Ok(HeartRateEvent::Disconnected) => {
let _ = bpm_tx.send(None);
status_tx.send_modify(|s| {
// Only a live link "reconnects"; a teardown on the
// way to Idle must not flip the state back.
if s.state == ConnectionState::Connected {
s.state = ConnectionState::Reconnecting;
}
s.bpm = None;
});
}
Ok(HeartRateEvent::GaveUp { attempts }) => {
// Terminal: the actor has stopped (FR-1.11).
tracing::warn!(attempts, "hr: reconnect gave up");
events = None;
client = None;
last_measurement = None;
let _ = bpm_tx.send(None);
status_tx.send_modify(|s| {
s.state = ConnectionState::Lost { reason: "stopped answering".into() };
s.bpm = None;
s.error = Some(format!(
"Gave up reaching the heart rate monitor after {attempts} \
attempts. Check it is on a watch must have Broadcast Heart \
Rate enabled then connect again."
));
});
}
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::debug!(skipped = n, "hr: event consumer lagged");
}
Err(broadcast::error::RecvError::Closed) => {
// The actor is gone without a GaveUp — should not
// happen, but a closed stream must not spin the select.
events = None;
}
}
}
_ = housekeeping.tick() => {
let quiet = client.is_some()
&& last_measurement.is_some_and(|t| t.elapsed() >= STALE_AFTER);
if quiet && bpm_tx.borrow().is_some() {
// Clear rather than hold: a strap taken off must not keep
// writing its last reading into the FIT file (FR-1.8).
tracing::info!("hr: no measurement for {STALE_AFTER:?} — clearing the reading");
let _ = bpm_tx.send(None);
status_tx.send_modify(|s| s.bpm = None);
}
}
}
}
}
/// Spawn the connect in its own task, so a fifteen-second search cannot hold
/// up a disconnect or a quit (FR-1.10).
fn start_attempt(
generation: u64,
selector: HrSelector,
results: &mpsc::Sender<Attempt>,
) -> oneshot::Sender<()> {
let (cancel_tx, cancel_rx) = oneshot::channel();
let results = results.clone();
tauri::async_runtime::spawn(async move {
let outcome = HeartRateClient::connect_cancellable(selector, hr_config(), async {
// Resolves on an explicit cancel *and* on the sender being dropped.
let _ = cancel_rx.await;
})
.await;
let _ = results.send(Attempt { generation, outcome }).await;
});
cancel_tx
}
/// Close the link and clear the published reading.
async fn shutdown(client: Option<HeartRateClient>, bpm_tx: &watch::Sender<Option<u8>>) {
let _ = bpm_tx.send(None);
if let Some(c) = client {
c.shutdown().await;
}
}
/// Turn a connect failure into something the rider can act on (FR-9.2).
fn connect_hint(e: &FtmsError) -> String {
match e {
FtmsError::NotFound(_) => "Could not find the heart rate monitor. A strap needs to be \
worn to advertise; a watch needs Broadcast Heart Rate \
switched on. Then try again."
.to_string(),
FtmsError::NoAdapter => {
"No Bluetooth adapter. Check the radio is on and BlueZ is running.".to_string()
}
// A watch in broadcast mode accepts one central; a second connect gets
// torn down mid-handshake and surfaces as a bare BLE error.
FtmsError::Bluetooth(_) | FtmsError::MissingCharacteristic(_) => format!(
"Heart rate monitor is busy: {e}. It accepts one connection at a time — close any \
other app that is holding it, then try again."
),
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_status_is_idle_and_empty() {
let s = HeartRateStatus::default();
assert_eq!(s.state, ConnectionState::Idle);
assert!(!s.is_attached());
assert_eq!(s.bpm, None);
}
#[test]
fn attached_covers_every_live_state() {
for state in [
ConnectionState::Connecting,
ConnectionState::Connected,
ConnectionState::Reconnecting,
] {
let s = HeartRateStatus { state: state.clone(), ..HeartRateStatus::default() };
assert!(s.is_attached(), "{state:?}");
}
let lost = HeartRateStatus {
state: ConnectionState::Lost { reason: "gone".into() },
..HeartRateStatus::default()
};
assert!(!lost.is_attached());
}
#[test]
fn reconnect_is_bounded_so_the_rider_is_eventually_told() {
// FR-1.11 — same reasoning as the trainer's budget.
let cfg = hr_config();
assert_eq!(cfg.backoff.max_attempts, Some(RECONNECT_ATTEMPTS));
}
#[test]
fn a_missing_monitor_is_explained_not_just_reported() {
let hint = connect_hint(&FtmsError::NotFound("any heart rate monitor".into()));
assert!(hint.to_lowercase().contains("broadcast"), "FR-1.8: {hint}");
}
}
+1
View File
@@ -13,6 +13,7 @@ pub mod controller;
pub mod derive; pub mod derive;
pub mod devices; pub mod devices;
pub mod events; pub mod events;
pub mod heart_rate;
pub mod profile_view; pub mod profile_view;
pub mod recording; pub mod recording;
pub mod samples; pub mod samples;
+66 -14
View File
@@ -27,6 +27,12 @@ pub struct SessionBackend {
session: RideSession, session: RideSession,
/// Latest decoded Indoor Bike Data, published by [`crate::trainer`]. /// Latest decoded Indoor Bike Data, published by [`crate::trainer`].
telemetry: watch::Receiver<Telemetry>, telemetry: watch::Receiver<Telemetry>,
/// Latest reading from a dedicated heart rate monitor, published by
/// [`crate::heart_rate`]. Stamped onto the trainer's telemetry each tick —
/// but never over a heart rate FTMS itself reported, on the same principle
/// as the Zwift cadence merge: a trainer that declares a field is the
/// better authority on it.
heart_rate: watch::Receiver<Option<u8>>,
last_snapshot: Option<RideSnapshot>, last_snapshot: Option<RideSnapshot>,
/// Set once a profile has been handed to the session, so a profile swap is /// Set once a profile has been handed to the session, so a profile swap is
/// noticed but the same profile is not reloaded every tick. /// noticed but the same profile is not reloaded every tick.
@@ -36,12 +42,17 @@ pub struct SessionBackend {
} }
impl SessionBackend { impl SessionBackend {
pub fn new(inputs: &RideInputs, telemetry: watch::Receiver<Telemetry>) -> Self { pub fn new(
inputs: &RideInputs,
telemetry: watch::Receiver<Telemetry>,
heart_rate: watch::Receiver<Option<u8>>,
) -> Self {
let mut session = RideSession::new(inputs.rider, inputs.limits); let mut session = RideSession::new(inputs.rider, inputs.limits);
session.gearing = Gearing::new(inputs.cassette.clone()); session.gearing = Gearing::new(inputs.cassette.clone());
Self { Self {
session, session,
telemetry, telemetry,
heart_rate,
last_snapshot: None, last_snapshot: None,
loaded_profile: None, loaded_profile: None,
loaded_cassette: inputs.cassette.clone(), loaded_cassette: inputs.cassette.clone(),
@@ -111,7 +122,10 @@ impl RideBackend for SessionBackend {
_ => {} _ => {}
} }
let telemetry = *self.telemetry.borrow(); let mut telemetry = *self.telemetry.borrow();
if telemetry.heart_rate_bpm.is_none() {
telemetry.heart_rate_bpm = *self.heart_rate.borrow();
}
let mut command = None; let mut command = None;
let mut snapshot = None; let mut snapshot = None;
for event in self.session.tick(telemetry, dt_s) { for event in self.session.tick(telemetry, dt_s) {
@@ -140,6 +154,12 @@ impl RideBackend for SessionBackend {
} }
} }
/// A heart rate channel with nothing on it, for tests about everything else.
#[cfg(test)]
fn no_hr() -> watch::Receiver<Option<u8>> {
watch::channel(None).1
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -157,7 +177,7 @@ mod tests {
fn real_power_drives_the_ride_forward() { fn real_power_drives_the_ride_forward() {
let (tx, rx) = watch::channel(Telemetry::default()); let (tx, rx) = watch::channel(Telemetry::default());
let inputs = running(ControlMode::ManualGrade); let inputs = running(ControlMode::ManualGrade);
let mut backend = SessionBackend::new(&inputs, rx); let mut backend = SessionBackend::new(&inputs, rx, no_hr());
// No power: nothing moves. // No power: nothing moves.
for _ in 0..8 { for _ in 0..8 {
@@ -190,7 +210,7 @@ mod tests {
..Telemetry::default() ..Telemetry::default()
}); });
let inputs = running(ControlMode::ManualGrade); let inputs = running(ControlMode::ManualGrade);
let mut backend = SessionBackend::new(&inputs, rx); let mut backend = SessionBackend::new(&inputs, rx, no_hr());
for _ in 0..60 { for _ in 0..60 {
backend.tick(0.25, &inputs); backend.tick(0.25, &inputs);
} }
@@ -218,7 +238,7 @@ mod tests {
// same load in watts and is covered in the engine's own tests. // same load in watts and is covered in the engine's own tests.
inputs.rider.load_channel = bikecontrol_core::types::LoadChannel::Gradient; inputs.rider.load_channel = bikecontrol_core::types::LoadChannel::Gradient;
inputs.manual_gradient_pct = 5.0; inputs.manual_gradient_pct = 5.0;
let mut backend = SessionBackend::new(&inputs, rx); let mut backend = SessionBackend::new(&inputs, rx, no_hr());
let tick = backend.tick(0.25, &inputs); let tick = backend.tick(0.25, &inputs);
// The road is the rider's setting exactly; what the trainer is asked // The road is the rider's setting exactly; what the trainer is asked
// for is the load that road implies through the selected gear, which is // for is the load that road implies through the selected gear, which is
@@ -240,7 +260,7 @@ mod tests {
let mut inputs = running(ControlMode::ManualGrade); let mut inputs = running(ControlMode::ManualGrade);
inputs.manual_gradient_pct = 3.0; inputs.manual_gradient_pct = 3.0;
inputs.gradient_offset_pct = 1.5; inputs.gradient_offset_pct = 1.5;
let mut backend = SessionBackend::new(&inputs, rx); let mut backend = SessionBackend::new(&inputs, rx, no_hr());
for _ in 0..10 { for _ in 0..10 {
backend.tick(0.25, &inputs); backend.tick(0.25, &inputs);
} }
@@ -253,7 +273,7 @@ mod tests {
let (_tx, rx) = watch::channel(Telemetry::default()); let (_tx, rx) = watch::channel(Telemetry::default());
let mut inputs = running(ControlMode::Erg); let mut inputs = running(ControlMode::Erg);
inputs.power_target_w = 275; inputs.power_target_w = 275;
let mut backend = SessionBackend::new(&inputs, rx); let mut backend = SessionBackend::new(&inputs, rx, no_hr());
assert_eq!( assert_eq!(
backend.tick(0.25, &inputs).command, backend.tick(0.25, &inputs).command,
Some(ControlTarget::Power { watts: 275 }) Some(ControlTarget::Power { watts: 275 })
@@ -274,7 +294,7 @@ mod tests {
let mut inputs = running(ControlMode::ManualGrade); let mut inputs = running(ControlMode::ManualGrade);
inputs.rider.load_channel = bikecontrol_core::types::LoadChannel::Gradient; inputs.rider.load_channel = bikecontrol_core::types::LoadChannel::Gradient;
inputs.manual_gradient_pct = 400.0; inputs.manual_gradient_pct = 400.0;
let mut backend = SessionBackend::new(&inputs, rx); let mut backend = SessionBackend::new(&inputs, rx, no_hr());
let tick = backend.tick(0.25, &inputs); let tick = backend.tick(0.25, &inputs);
assert_eq!( assert_eq!(
tick.command, tick.command,
@@ -292,7 +312,7 @@ mod tests {
..Telemetry::default() ..Telemetry::default()
}); });
let mut inputs = running(ControlMode::ManualGrade); let mut inputs = running(ControlMode::ManualGrade);
let mut backend = SessionBackend::new(&inputs, rx); let mut backend = SessionBackend::new(&inputs, rx, no_hr());
backend.tick(0.25, &inputs); backend.tick(0.25, &inputs);
inputs.status = RideStatus::Paused; inputs.status = RideStatus::Paused;
inputs.manual_gradient_pct = 9.0; inputs.manual_gradient_pct = 9.0;
@@ -305,7 +325,7 @@ mod tests {
// last value, so a stationary rider was shown 38 km/h indefinitely. // last value, so a stationary rider was shown 38 km/h indefinitely.
let (tx, rx) = watch::channel(Telemetry::default()); let (tx, rx) = watch::channel(Telemetry::default());
let mut inputs = running(ControlMode::ManualGrade); let mut inputs = running(ControlMode::ManualGrade);
let mut backend = SessionBackend::new(&inputs, rx); let mut backend = SessionBackend::new(&inputs, rx, no_hr());
let _ = tx.send(Telemetry { let _ = tx.send(Telemetry {
power_w: Some(250), power_w: Some(250),
cadence_rpm: Some(85.0), cadence_rpm: Some(85.0),
@@ -334,11 +354,43 @@ mod tests {
assert!(resumed.virtual_speed_kph > 10.0, "{resumed:?}"); assert!(resumed.virtual_speed_kph > 10.0, "{resumed:?}");
} }
#[test]
fn a_monitors_heart_rate_reaches_the_snapshot_but_never_overrides_ftms() {
let (telemetry_tx, telemetry_rx) = watch::channel(Telemetry::default());
let (hr_tx, hr_rx) = watch::channel(None);
let inputs = running(ControlMode::ManualGrade);
let mut backend = SessionBackend::new(&inputs, telemetry_rx, hr_rx);
// No monitor, no trainer HR: the field stays absent, so a ride without
// a strap carries no heart rate rather than a zero (see fit::builder).
let snap = backend.tick(0.25, &inputs).snapshot;
assert_eq!(snap.telemetry.heart_rate_bpm, None);
// The monitor's reading fills the gap FTMS leaves.
let _ = hr_tx.send(Some(147));
let snap = backend.tick(0.25, &inputs).snapshot;
assert_eq!(snap.telemetry.heart_rate_bpm, Some(147));
// A trainer that reports its own heart rate is the better authority.
let _ = telemetry_tx.send(Telemetry {
heart_rate_bpm: Some(151),
..Telemetry::default()
});
let snap = backend.tick(0.25, &inputs).snapshot;
assert_eq!(snap.telemetry.heart_rate_bpm, Some(151));
// The monitor going quiet clears the reading, not freezes it.
let _ = telemetry_tx.send(Telemetry::default());
let _ = hr_tx.send(None);
let snap = backend.tick(0.25, &inputs).snapshot;
assert_eq!(snap.telemetry.heart_rate_bpm, None);
}
#[test] #[test]
fn reset_returns_to_the_start_line() { fn reset_returns_to_the_start_line() {
let (tx, rx) = watch::channel(Telemetry::default()); let (tx, rx) = watch::channel(Telemetry::default());
let inputs = running(ControlMode::ManualGrade); let inputs = running(ControlMode::ManualGrade);
let mut backend = SessionBackend::new(&inputs, rx); let mut backend = SessionBackend::new(&inputs, rx, no_hr());
let _ = tx.send(Telemetry { let _ = tx.send(Telemetry {
power_w: Some(300), power_w: Some(300),
cadence_rpm: Some(90.0), cadence_rpm: Some(90.0),
@@ -392,7 +444,7 @@ mod drag_race_tests {
..RideInputs::default() ..RideInputs::default()
}; };
inputs.set_gear(3); inputs.set_gear(3);
let mut backend = SessionBackend::new(&inputs, rx); let mut backend = SessionBackend::new(&inputs, rx, no_hr());
// Rolling: 180 W at 22 km/h on the flywheel. // Rolling: 180 W at 22 km/h on the flywheel.
let _ = tx.send(d100(180, 22.0)); let _ = tx.send(d100(180, 22.0));
@@ -420,7 +472,7 @@ mod drag_race_tests {
mode: ControlMode::Profile, mode: ControlMode::Profile,
..RideInputs::default() ..RideInputs::default()
}; };
let mut backend = SessionBackend::new(&inputs, rx); let mut backend = SessionBackend::new(&inputs, rx, no_hr());
let _ = tx.send(d100(180, 22.0)); let _ = tx.send(d100(180, 22.0));
for _ in 0..40 { for _ in 0..40 {
backend.tick(0.25, &inputs); backend.tick(0.25, &inputs);
@@ -442,7 +494,7 @@ mod drag_race_tests {
..RideInputs::default() ..RideInputs::default()
}; };
inputs.set_gear(gear); inputs.set_gear(gear);
let mut backend = SessionBackend::new(&inputs, rx); let mut backend = SessionBackend::new(&inputs, rx, no_hr());
let _ = tx.send(d100(180, 22.0)); let _ = tx.send(d100(180, 22.0));
for _ in 0..60 { for _ in 0..60 {
backend.tick(0.25, &inputs); backend.tick(0.25, &inputs);
+63 -7
View File
@@ -19,6 +19,7 @@ use crate::events;
use crate::events::{ use crate::events::{
ConnectionEvent, DeviceList, InputAck, LapSummary, Notice, RideState, RideStatus, ConnectionEvent, DeviceList, InputAck, LapSummary, Notice, RideState, RideStatus,
}; };
use crate::heart_rate::{HeartRateHandle, HeartRateStatus};
use crate::profile_view::{ProfileGeometry, ProfileView}; use crate::profile_view::{ProfileGeometry, ProfileView};
use crate::recording::{RecorderHandle, Recovered, RideSummary}; use crate::recording::{RecorderHandle, Recovered, RideSummary};
use crate::session_backend::SessionBackend; use crate::session_backend::SessionBackend;
@@ -37,6 +38,7 @@ pub struct Inner {
pub devices: DeviceRegistry, pub devices: DeviceRegistry,
pub trainer: TrainerHandle, pub trainer: TrainerHandle,
pub controller: crate::controller::ControllerHandle, pub controller: crate::controller::ControllerHandle,
pub heart_rate: HeartRateHandle,
pub profile_view: Option<ProfileView>, pub profile_view: Option<ProfileView>,
/// Precomputed route geometry, kept Rust-side so the per-tick elevation and /// Precomputed route geometry, kept Rust-side so the per-tick elevation and
/// ascent-remaining lookups are a binary search rather than a scan. /// ascent-remaining lookups are a binary search rather than a scan.
@@ -66,20 +68,33 @@ pub struct Inner {
/// pedalling, which is the truth. There is deliberately no synthetic rider to /// pedalling, which is the truth. There is deliberately no synthetic rider to
/// fall back to — a session a rider could finish and only then discover none of /// fall back to — a session a rider could finish and only then discover none of
/// it happened is worse than no session at all. /// it happened is worse than no session at all.
fn build_backend(inputs: &RideInputs, trainer: &TrainerHandle) -> Box<dyn RideBackend> { fn build_backend(
inputs: &RideInputs,
trainer: &TrainerHandle,
heart_rate: &HeartRateHandle,
) -> Box<dyn RideBackend> {
tracing::info!("ride data source is the trainer; with no trainer attached the ride reads zero"); tracing::info!("ride data source is the trainer; with no trainer attached the ride reads zero");
Box::new(SessionBackend::new(inputs, trainer.telemetry())) Box::new(SessionBackend::new(
inputs,
trainer.telemetry(),
heart_rate.bpm(),
))
} }
impl Inner { impl Inner {
fn new(trainer: TrainerHandle, controller: crate::controller::ControllerHandle) -> Self { fn new(
trainer: TrainerHandle,
controller: crate::controller::ControllerHandle,
heart_rate: HeartRateHandle,
) -> Self {
let inputs = RideInputs::default(); let inputs = RideInputs::default();
Self { Self {
backend: build_backend(&inputs, &trainer), backend: build_backend(&inputs, &trainer, &heart_rate),
devices: DeviceRegistry::new(trainer.clone(), controller.clone()), devices: DeviceRegistry::new(trainer.clone(), controller.clone(), heart_rate.clone()),
inputs, inputs,
trainer, trainer,
controller, controller,
heart_rate,
profile_view: None, profile_view: None,
geometry: None, geometry: None,
deriver: Deriver::default(), deriver: Deriver::default(),
@@ -211,8 +226,9 @@ impl AppState {
let limits = RideInputs::default().limits; let limits = RideInputs::default().limits;
let trainer = TrainerHandle::spawn(crate::trainer::app_config(limits)); let trainer = TrainerHandle::spawn(crate::trainer::app_config(limits));
let controller = crate::controller::ControllerHandle::spawn(); let controller = crate::controller::ControllerHandle::spawn();
let heart_rate = HeartRateHandle::spawn();
Self { Self {
inner: Arc::new(Mutex::new(Inner::new(trainer, controller))), inner: Arc::new(Mutex::new(Inner::new(trainer, controller, heart_rate))),
recorder: RecorderHandle::default(), recorder: RecorderHandle::default(),
} }
} }
@@ -233,6 +249,11 @@ impl AppState {
self.lock().controller.clone() self.lock().controller.clone()
} }
/// The heart rate supervisor handle.
pub fn heart_rate(&self) -> HeartRateHandle {
self.lock().heart_rate.clone()
}
/// Panics are impossible to recover from here, and a poisoned lock means /// Panics are impossible to recover from here, and a poisoned lock means
/// the ride loop already died — surface it rather than hide it. /// the ride loop already died — surface it rather than hide it.
pub fn lock(&self) -> std::sync::MutexGuard<'_, Inner> { pub fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
@@ -499,9 +520,11 @@ pub fn shutdown_devices(app: &AppHandle) {
let Some(state) = app.try_state::<AppState>() else { let Some(state) = app.try_state::<AppState>() else {
return; return;
}; };
let (trainer, controller) = (state.trainer(), state.controller()); let (trainer, controller, heart_rate) =
(state.trainer(), state.controller(), state.heart_rate());
trainer.shutdown_blocking(); trainer.shutdown_blocking();
controller.shutdown_blocking(); controller.shutdown_blocking();
heart_rate.shutdown_blocking();
} }
/// The scan loop: refreshes the device list from the radio and pushes it when /// The scan loop: refreshes the device list from the radio and pushes it when
@@ -543,6 +566,11 @@ pub fn spawn_device_loop(app: AppHandle) {
} }
emit_ride_state(&app); emit_ride_state(&app);
} }
if let Some(status) = result.hr_changed {
if let Some(notice) = hr_notice(&status) {
notify(&app, notice);
}
}
if result.changed { if result.changed {
emit_devices(&app); emit_devices(&app);
} }
@@ -590,3 +618,31 @@ fn trainer_notice(status: &TrainerStatus) -> Option<Notice> {
ConnectionState::Idle | ConnectionState::Scanning => None, ConnectionState::Idle | ConnectionState::Scanning => None,
} }
} }
/// The same, for the heart rate link (FR-1.8, FR-9.4). Quieter than the
/// trainer's: heart rate is garnish on the ride, not the ride, so only the
/// transitions the rider should act on speak.
fn hr_notice(status: &HeartRateStatus) -> Option<Notice> {
let name = status
.name
.clone()
.unwrap_or_else(|| "Heart rate monitor".into());
match &status.state {
ConnectionState::Connected => Some(Notice::info(format!("{name} connected"))),
ConnectionState::Reconnecting => Some(Notice::warn(format!(
"Lost {name} — reconnecting. The ride continues; heart rate pauses until it is back."
))),
ConnectionState::Lost { reason } => Some(Notice::error(
status
.error
.clone()
.unwrap_or_else(|| format!("{name} unavailable: {reason}")),
)),
// Idle can also carry a failed connect's advice (the supervisor parks
// there rather than in Lost, so the row stays clickable).
ConnectionState::Idle => status.error.clone().map(Notice::warn),
ConnectionState::Connecting
| ConnectionState::Scanning
| ConnectionState::Controlling => None,
}
}
+26 -1
View File
@@ -140,6 +140,28 @@
: ''} : ''}
</span> </span>
</span> </span>
{:else if device.kind === 'heartRate'}
<!-- The live reading is the row's proof that data is flowing: a
monitor that is connected but silent looks exactly like a
working one otherwise. -->
<span class="state">
<span class="label">Heart rate</span>
<span
class="value"
class:tone-ok={device.heartRateBpm != null}
class:tone-idle={device.heartRateBpm == null}
>
<span class="dot"></span>{device.heartRateBpm != null
? `${device.heartRateBpm} bpm`
: '—'}
</span>
</span>
{#if device.batteryPct != null}
<span class="state">
<span class="label">Battery</span>
<span class="value tone-idle"><span class="dot"></span>{device.batteryPct}%</span>
</span>
{/if}
{:else if device.batteryPct != null} {:else if device.batteryPct != null}
<span class="state"> <span class="state">
<span class="label">Battery</span> <span class="label">Battery</span>
@@ -173,7 +195,10 @@
<ul> <ul>
<li><strong>Trainer</strong> — turn the pedals for a few seconds.</li> <li><strong>Trainer</strong> — turn the pedals for a few seconds.</li>
<li><strong>Zwift Click</strong> — press any button on the pod.</li> <li><strong>Zwift Click</strong> — press any button on the pod.</li>
<li><strong>Heart rate strap</strong> — wet the contacts and put it on.</li> <li>
<strong>Heart rate</strong> — wet a strap's contacts and put it on; on a sports watch,
switch on <em>Broadcast Heart Rate</em>.
</li>
</ul> </ul>
<p class="quiet"> <p class="quiet">
They will appear here as soon as they advertise. Scanning continues in the background. They will appear here as soon as they advertise. Scanning continues in the background.
+2
View File
@@ -252,6 +252,8 @@ export interface DeviceInfo {
services: string[]; services: string[];
remembered: boolean; remembered: boolean;
batteryPct: number | null; batteryPct: number | null;
/** Live reading from a connected heart rate monitor — proof data is flowing. */
heartRateBpm: number | null;
error: string | null; error: string | null;
} }