Stream heart rate from a strap or watch into the ride and the FIT file
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:
@@ -30,6 +30,7 @@ use tokio::sync::watch;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::controller::{ControllerHandle, PodState};
|
||||
use crate::heart_rate::{HeartRateHandle, HeartRateStatus};
|
||||
use crate::trainer::{TrainerHandle, TrainerStatus};
|
||||
|
||||
/// 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).
|
||||
pub remembered: bool,
|
||||
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
|
||||
// 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).
|
||||
@@ -117,6 +121,9 @@ pub struct PollResult {
|
||||
/// Trainer status, when it changed since the last poll. The caller turns
|
||||
/// this into user-facing notices (FR-1.8, FR-9.4).
|
||||
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.
|
||||
@@ -135,6 +142,8 @@ pub struct DeviceRegistry {
|
||||
/// 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.
|
||||
controller: ControllerHandle,
|
||||
/// The heart rate supervisor, for the same reason.
|
||||
hr: HeartRateHandle,
|
||||
scan_rx: watch::Receiver<ScanSnapshot>,
|
||||
scan_on: watch::Sender<bool>,
|
||||
forgotten: HashSet<String>,
|
||||
@@ -142,6 +151,7 @@ pub struct DeviceRegistry {
|
||||
/// The list published last tick, for change detection.
|
||||
published: Vec<DeviceInfo>,
|
||||
last_trainer: TrainerStatus,
|
||||
last_hr: HeartRateStatus,
|
||||
pub scanning: bool,
|
||||
/// 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).
|
||||
@@ -151,14 +161,16 @@ pub struct 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_tx, scan_rx) = watch::channel(ScanSnapshot::default());
|
||||
tauri::async_runtime::spawn(scan_loop(scan_on_rx, scan_tx));
|
||||
Self {
|
||||
last_trainer: trainer.status(),
|
||||
last_hr: hr.status(),
|
||||
trainer,
|
||||
controller,
|
||||
hr,
|
||||
scan_rx,
|
||||
scan_on,
|
||||
forgotten: HashSet::new(),
|
||||
@@ -193,6 +205,14 @@ impl DeviceRegistry {
|
||||
let trainer_changed = (trainer != self.last_trainer).then(|| 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
|
||||
// `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
|
||||
@@ -213,6 +233,7 @@ impl DeviceRegistry {
|
||||
changed,
|
||||
transitions,
|
||||
trainer_changed,
|
||||
hr_changed,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +274,7 @@ impl DeviceRegistry {
|
||||
services: d.services.iter().map(|u| describe_service(*u)).collect(),
|
||||
remembered: self.remembered.contains(&id),
|
||||
battery_pct: None,
|
||||
heart_rate_bpm: None,
|
||||
error: None,
|
||||
id,
|
||||
});
|
||||
@@ -276,6 +298,7 @@ impl DeviceRegistry {
|
||||
services: vec![describe_service(uuids::FITNESS_MACHINE_SERVICE)],
|
||||
remembered: true,
|
||||
battery_pct: None,
|
||||
heart_rate_bpm: None,
|
||||
error: None,
|
||||
});
|
||||
out.len() - 1
|
||||
@@ -327,6 +350,7 @@ impl DeviceRegistry {
|
||||
services: vec![describe_service(ZWIFT_SERVICE)],
|
||||
remembered: true,
|
||||
battery_pct: None,
|
||||
heart_rate_bpm: None,
|
||||
error: None,
|
||||
});
|
||||
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
|
||||
// looking for should not be below an unnamed peripheral.
|
||||
out.sort_by(|a, b| {
|
||||
@@ -388,9 +459,24 @@ impl DeviceRegistry {
|
||||
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 {
|
||||
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
|
||||
));
|
||||
}
|
||||
@@ -446,6 +532,9 @@ impl DeviceRegistry {
|
||||
if let Some(pod) = device.kind.pod_id() {
|
||||
self.controller.disconnect(Some(pod));
|
||||
}
|
||||
if device.kind == DeviceKind::HeartRate {
|
||||
self.hr.disconnect();
|
||||
}
|
||||
device.state = ConnectionState::Idle;
|
||||
device.control_acquired = false;
|
||||
Ok(device)
|
||||
@@ -458,6 +547,9 @@ impl DeviceRegistry {
|
||||
if device.kind == DeviceKind::Trainer && device.control_acquired {
|
||||
self.trainer.disconnect();
|
||||
}
|
||||
if device.kind == DeviceKind::HeartRate {
|
||||
self.hr.disconnect();
|
||||
}
|
||||
self.remembered.remove(id);
|
||||
self.forgotten.insert(id.to_string());
|
||||
self.published.retain(|d| d.id != id);
|
||||
@@ -638,6 +730,7 @@ mod tests {
|
||||
services: Vec::new(),
|
||||
remembered: false,
|
||||
battery_pct: None,
|
||||
heart_rate_bpm: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ pub mod controller;
|
||||
pub mod derive;
|
||||
pub mod devices;
|
||||
pub mod events;
|
||||
pub mod heart_rate;
|
||||
pub mod profile_view;
|
||||
pub mod recording;
|
||||
pub mod samples;
|
||||
|
||||
@@ -27,6 +27,12 @@ pub struct SessionBackend {
|
||||
session: RideSession,
|
||||
/// Latest decoded Indoor Bike Data, published by [`crate::trainer`].
|
||||
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>,
|
||||
/// 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.
|
||||
@@ -36,12 +42,17 @@ pub struct 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);
|
||||
session.gearing = Gearing::new(inputs.cassette.clone());
|
||||
Self {
|
||||
session,
|
||||
telemetry,
|
||||
heart_rate,
|
||||
last_snapshot: None,
|
||||
loaded_profile: None,
|
||||
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 snapshot = None;
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -157,7 +177,7 @@ mod tests {
|
||||
fn real_power_drives_the_ride_forward() {
|
||||
let (tx, rx) = watch::channel(Telemetry::default());
|
||||
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.
|
||||
for _ in 0..8 {
|
||||
@@ -190,7 +210,7 @@ mod tests {
|
||||
..Telemetry::default()
|
||||
});
|
||||
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 {
|
||||
backend.tick(0.25, &inputs);
|
||||
}
|
||||
@@ -218,7 +238,7 @@ mod tests {
|
||||
// same load in watts and is covered in the engine's own tests.
|
||||
inputs.rider.load_channel = bikecontrol_core::types::LoadChannel::Gradient;
|
||||
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);
|
||||
// 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
|
||||
@@ -240,7 +260,7 @@ mod tests {
|
||||
let mut inputs = running(ControlMode::ManualGrade);
|
||||
inputs.manual_gradient_pct = 3.0;
|
||||
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 {
|
||||
backend.tick(0.25, &inputs);
|
||||
}
|
||||
@@ -253,7 +273,7 @@ mod tests {
|
||||
let (_tx, rx) = watch::channel(Telemetry::default());
|
||||
let mut inputs = running(ControlMode::Erg);
|
||||
inputs.power_target_w = 275;
|
||||
let mut backend = SessionBackend::new(&inputs, rx);
|
||||
let mut backend = SessionBackend::new(&inputs, rx, no_hr());
|
||||
assert_eq!(
|
||||
backend.tick(0.25, &inputs).command,
|
||||
Some(ControlTarget::Power { watts: 275 })
|
||||
@@ -274,7 +294,7 @@ mod tests {
|
||||
let mut inputs = running(ControlMode::ManualGrade);
|
||||
inputs.rider.load_channel = bikecontrol_core::types::LoadChannel::Gradient;
|
||||
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);
|
||||
assert_eq!(
|
||||
tick.command,
|
||||
@@ -292,7 +312,7 @@ mod tests {
|
||||
..Telemetry::default()
|
||||
});
|
||||
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);
|
||||
inputs.status = RideStatus::Paused;
|
||||
inputs.manual_gradient_pct = 9.0;
|
||||
@@ -305,7 +325,7 @@ mod tests {
|
||||
// last value, so a stationary rider was shown 38 km/h indefinitely.
|
||||
let (tx, rx) = watch::channel(Telemetry::default());
|
||||
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 {
|
||||
power_w: Some(250),
|
||||
cadence_rpm: Some(85.0),
|
||||
@@ -334,11 +354,43 @@ mod tests {
|
||||
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]
|
||||
fn reset_returns_to_the_start_line() {
|
||||
let (tx, rx) = watch::channel(Telemetry::default());
|
||||
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 {
|
||||
power_w: Some(300),
|
||||
cadence_rpm: Some(90.0),
|
||||
@@ -392,7 +444,7 @@ mod drag_race_tests {
|
||||
..RideInputs::default()
|
||||
};
|
||||
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.
|
||||
let _ = tx.send(d100(180, 22.0));
|
||||
@@ -420,7 +472,7 @@ mod drag_race_tests {
|
||||
mode: ControlMode::Profile,
|
||||
..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));
|
||||
for _ in 0..40 {
|
||||
backend.tick(0.25, &inputs);
|
||||
@@ -442,7 +494,7 @@ mod drag_race_tests {
|
||||
..RideInputs::default()
|
||||
};
|
||||
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));
|
||||
for _ in 0..60 {
|
||||
backend.tick(0.25, &inputs);
|
||||
|
||||
+63
-7
@@ -19,6 +19,7 @@ use crate::events;
|
||||
use crate::events::{
|
||||
ConnectionEvent, DeviceList, InputAck, LapSummary, Notice, RideState, RideStatus,
|
||||
};
|
||||
use crate::heart_rate::{HeartRateHandle, HeartRateStatus};
|
||||
use crate::profile_view::{ProfileGeometry, ProfileView};
|
||||
use crate::recording::{RecorderHandle, Recovered, RideSummary};
|
||||
use crate::session_backend::SessionBackend;
|
||||
@@ -37,6 +38,7 @@ pub struct Inner {
|
||||
pub devices: DeviceRegistry,
|
||||
pub trainer: TrainerHandle,
|
||||
pub controller: crate::controller::ControllerHandle,
|
||||
pub heart_rate: HeartRateHandle,
|
||||
pub profile_view: Option<ProfileView>,
|
||||
/// Precomputed route geometry, kept Rust-side so the per-tick elevation and
|
||||
/// 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
|
||||
/// fall back to — a session a rider could finish and only then discover none of
|
||||
/// 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");
|
||||
Box::new(SessionBackend::new(inputs, trainer.telemetry()))
|
||||
Box::new(SessionBackend::new(
|
||||
inputs,
|
||||
trainer.telemetry(),
|
||||
heart_rate.bpm(),
|
||||
))
|
||||
}
|
||||
|
||||
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();
|
||||
Self {
|
||||
backend: build_backend(&inputs, &trainer),
|
||||
devices: DeviceRegistry::new(trainer.clone(), controller.clone()),
|
||||
backend: build_backend(&inputs, &trainer, &heart_rate),
|
||||
devices: DeviceRegistry::new(trainer.clone(), controller.clone(), heart_rate.clone()),
|
||||
inputs,
|
||||
trainer,
|
||||
controller,
|
||||
heart_rate,
|
||||
profile_view: None,
|
||||
geometry: None,
|
||||
deriver: Deriver::default(),
|
||||
@@ -211,8 +226,9 @@ impl AppState {
|
||||
let limits = RideInputs::default().limits;
|
||||
let trainer = TrainerHandle::spawn(crate::trainer::app_config(limits));
|
||||
let controller = crate::controller::ControllerHandle::spawn();
|
||||
let heart_rate = HeartRateHandle::spawn();
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(Inner::new(trainer, controller))),
|
||||
inner: Arc::new(Mutex::new(Inner::new(trainer, controller, heart_rate))),
|
||||
recorder: RecorderHandle::default(),
|
||||
}
|
||||
}
|
||||
@@ -233,6 +249,11 @@ impl AppState {
|
||||
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
|
||||
/// the ride loop already died — surface it rather than hide it.
|
||||
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 {
|
||||
return;
|
||||
};
|
||||
let (trainer, controller) = (state.trainer(), state.controller());
|
||||
let (trainer, controller, heart_rate) =
|
||||
(state.trainer(), state.controller(), state.heart_rate());
|
||||
trainer.shutdown_blocking();
|
||||
controller.shutdown_blocking();
|
||||
heart_rate.shutdown_blocking();
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
if let Some(status) = result.hr_changed {
|
||||
if let Some(notice) = hr_notice(&status) {
|
||||
notify(&app, notice);
|
||||
}
|
||||
}
|
||||
if result.changed {
|
||||
emit_devices(&app);
|
||||
}
|
||||
@@ -590,3 +618,31 @@ fn trainer_notice(status: &TrainerStatus) -> Option<Notice> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user