Files
BikeControl/src-tauri/src/trainer.rs
T
dtourolleandClaude Opus 5 7b511db3dc Ride the drivetrain, command the load in watts
Speed now comes from the drivetrain and the load from the road, which is
the way round a bike actually works.

Speed is cadence x development, filtered lightly. Power, not cadence,
decides whether the rider is driving it: on a direct-drive trainer the
flywheel keeps the cranks turning after they stop, so cadence alone reads
a healthy 80 rpm for someone doing nothing. Below 15 W the speed runs
down to whatever the gradient sustains on no power - zero uphill, a real
freewheeling speed on a descent. Stopping on a 3.5% climb used to settle
at 22 km/h and stay there, because the model wanted to decelerate and a
blend toward the flywheel speed outvoted it; that blend is gone.

The D100 sends no cadence over FTMS - it is a rebadged Magene T110 with
cadence disabled in firmware (qdomyos-zwift#3282) - so it is inferred
from wheel speed, which one sprocket and no freewheel make exact. Its
Zwift channel does carry cadence, and is now greeted with RideOn and
subscribed on every notifying characteristic, so a measured value is used
where one arrives.

The load is commanded as power, not gradient. The trainer declares
50-600 W in 1 W steps against 0-6% inclination in 0.1% steps refusing
negatives, and whether it acts on 0x11 at all is still unconfirmed. Its
power target is a ceiling rather than a setpoint, which is very nearly
what a road is: exceed it and the surplus becomes speed. Gravity travels
on the same channel as watts, so nothing is lost by leaving 0x11 alone.
LoadChannel keeps the gradient path selectable and tested.

Virtual shifting reaches the trainer for the first time. The physics
load model was written but never called, and a paddle press both shifted
a gear in Rust and nudged the gradient in the webview - the shift
silently, the tilt visibly, so the paddles looked like a gradient trim.

Also: a fixed 12 W drivetrain loss, held as a power because that is how
it presents; crank length, so a gear can be reported as the force it puts
under the foot; gear and pedal force on the ride screen; a drag-race
profile for testing gearing on the flat.

Two readout bugs fixed on the way. The rolling windows were trimmed by
timestamp but fed on a fixed timer, so every second spent on the ride
screen before starting pushed samples at t=0 that could never expire -
speed read a fraction of the truth for the first 45 s. And the headline
speed was a 45 s mean, which took most of a minute to show a gear change.

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

889 lines
37 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The trainer supervisor: the app's single owner of an [`FtmsClient`].
//!
//! Everything BLE-shaped happens in one background task. The Tauri commands and
//! the ride loop are synchronous and hold a `Mutex`, so they may never `.await`
//! a radio; they talk to this task over a channel instead, and read its results
//! from two `watch` channels:
//!
//! ```text
//! commands ──connect/target/release──► [supervisor task] ──► FtmsClient
//! ride loop ◄──watch<Telemetry>──────── │
//! device loop ◄──watch<TrainerStatus>────────┘
//! ```
//!
//! Three properties are load-bearing:
//!
//! * **SAF-2** — [`TrainerHandle::shutdown_blocking`] is callable from Tauri's
//! synchronous `RunEvent` handler, and waits for `FtmsClient::shutdown` to
//! actually finish. A fire-and-forget send would race the process exit and
//! leave the trainer loaded.
//! * **FR-9.3** — `control_acquired` is tracked separately from the connection
//! state. Connected is not controllable.
//! * **Never freeze on stale data** — if the trainer goes quiet while
//! nominally connected, the published telemetry is *zeroed* rather than held,
//! so the ride engine coasts to a stop and the UI visibly reacts instead of
//! showing a plausible-looking lie (FR-1.8).
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, ControlOutcome, FtmsClient, FtmsConfig, FtmsError, FtmsEvent, TrainerSelector,
};
use bikecontrol_core::types::{ConnectionState, ControlTarget, SafetyLimits, Telemetry};
use serde::Serialize;
use tokio::sync::{broadcast, mpsc, watch};
/// How long the trainer may stay silent before its telemetry is treated as
/// stale. The D100 notifies at 4 Hz, so three seconds is ~12 missed frames.
const STALE_AFTER: Duration = Duration::from_secs(3);
/// Housekeeping tick — staleness only, so it can be lazy.
const HOUSEKEEPING: Duration = Duration::from_millis(500);
/// Upper bound on the SAF-2 sequence at app exit (NFR-9). Longer than the BLE
/// layer's own per-write timeouts, short enough not to hang a window close.
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(8);
/// Upper bound on the client's own reset sequence, kept under
/// [`SHUTDOWN_TIMEOUT`] so the supervisor always answers before the caller
/// stops listening. A caller that times out learns nothing and leaves.
const SAFETY_SEQUENCE_TIMEOUT: Duration = Duration::from_secs(7);
/// How long auto-reconnect keeps trying before it gives up and says so.
///
/// FR-1.11. Retrying forever sounds kinder than giving up, but it is not: the
/// link sits in `Reconnecting` indefinitely, the screen keeps implying the
/// trainer is on its way back, and the rider is never told to go and look at
/// it. Twenty attempts against the default backoff is about seven minutes.
pub(crate) const RECONNECT_ATTEMPTS: u32 = 20;
/// What the UI needs to know about the trainer link (FR-1.7, FR-9.3).
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TrainerStatus {
pub state: ConnectionState,
/// FTMS control point acquired. Deliberately *not* folded into `state`:
/// connected is not controllable (FR-9.3).
pub control_acquired: bool,
pub address: Option<String>,
pub name: Option<String>,
/// Human-readable failure, rendered verbatim (FR-9.2).
pub error: Option<String>,
/// Connected, but no Indoor Bike Data for [`STALE_AFTER`]. The rider needs
/// to know the numbers stopped being real (FR-1.8).
pub stale: bool,
}
impl Default for TrainerStatus {
fn default() -> Self {
Self {
state: ConnectionState::Idle,
control_acquired: false,
address: None,
name: None,
error: None,
stale: false,
}
}
}
impl TrainerStatus {
/// True once a ride would actually reach the trainer (FR-2.1).
pub fn controllable(&self) -> bool {
self.control_acquired && self.state == ConnectionState::Controlling
}
pub fn is_attached(&self) -> bool {
!matches!(
self.state,
ConnectionState::Idle | ConnectionState::Lost { .. }
)
}
}
enum Cmd {
Connect(TrainerSelector),
Disconnect,
Target(ControlTarget),
/// SAF-2 without dropping the link: used at the end of a ride, so the next
/// ride does not have to reconnect.
Release {
limits: SafetyLimits,
},
/// Full SAF-2 sequence plus disconnect. Used on app exit.
Shutdown {
reply: SyncSender<()>,
},
/// A spawned control write finished. Only reported when it failed.
WriteFailed(String),
}
/// Cheap, cloneable handle to the supervisor.
#[derive(Clone)]
pub struct TrainerHandle {
cmd_tx: mpsc::Sender<Cmd>,
status_rx: watch::Receiver<TrainerStatus>,
telemetry_rx: watch::Receiver<Telemetry>,
/// Shared, so every clone of the handle sees that SAF-2 has already run.
shut_down: Arc<AtomicBool>,
}
impl TrainerHandle {
/// Start the supervisor task. Must be called with a Tokio runtime available
/// — `tauri::async_runtime` provides one before the app is built.
pub fn spawn(config: FtmsConfig) -> Self {
let (cmd_tx, cmd_rx) = mpsc::channel(32);
let (status_tx, status_rx) = watch::channel(TrainerStatus::default());
let (telemetry_tx, telemetry_rx) = watch::channel(Telemetry::default());
let handle = Self {
cmd_tx: cmd_tx.clone(),
status_rx,
telemetry_rx,
shut_down: Arc::new(AtomicBool::new(false)),
};
tauri::async_runtime::spawn(run(cmd_rx, cmd_tx, config, status_tx, telemetry_tx));
handle
}
pub fn status(&self) -> TrainerStatus {
self.status_rx.borrow().clone()
}
/// Latest telemetry, for the ride engine. Zeroed while disconnected or
/// stale, never a held-over sample.
pub fn telemetry(&self) -> watch::Receiver<Telemetry> {
self.telemetry_rx.clone()
}
pub fn connect(&self, selector: TrainerSelector) {
self.send(Cmd::Connect(selector));
}
pub fn disconnect(&self) {
self.send(Cmd::Disconnect);
}
/// Push a control target. Fire-and-forget by design: the ride loop must not
/// block on the radio, and [`FtmsClient`] already coalesces so the newest
/// target wins (FR-2.8).
pub fn set_target(&self, target: ControlTarget) {
self.send(Cmd::Target(target));
}
/// SAF-2 at the end of a ride: zero gradient / minimum resistance, link kept.
pub fn release(&self, limits: SafetyLimits) {
self.send(Cmd::Release { limits });
}
/// SAF-2 at app exit: full reset sequence, then disconnect. Blocks the
/// calling (non-async) thread until it is done or [`SHUTDOWN_TIMEOUT`]
/// elapses.
/// Idempotent: Tauri delivers `ExitRequested`, `Exit` and window
/// `Destroyed` on one quit, and the second and third calls must be quiet
/// no-ops rather than warnings about a supervisor that has already done its
/// job.
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);
// Not `try_send` and give up: the one command that must not be dropped
// is this one, and a queue that is briefly full — the ride loop pushes a
// target every 250 ms — is not a reason to skip SAF-2. This is already a
// blocking call, so spending a little of its budget on getting the
// command in is free.
let mut cmd = Cmd::Shutdown { reply };
loop {
match self.cmd_tx.try_send(cmd) {
Ok(()) => break,
Err(mpsc::error::TrySendError::Closed(_)) => {
tracing::warn!("trainer supervisor already stopped; nothing to release");
return;
}
Err(mpsc::error::TrySendError::Full(returned)) => {
if Instant::now() >= deadline {
tracing::error!("could not reach the trainer supervisor to run SAF-2");
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!("trainer released (SAF-2)"),
Err(e) => tracing::error!(error = %e, "SAF-2 shutdown did not complete in time"),
}
}
fn send(&self, cmd: Cmd) {
if self.cmd_tx.try_send(cmd).is_err() {
// Capacity 32 against a 4 Hz producer: a full queue means the
// supervisor is wedged, which the status channel already reports.
tracing::warn!("trainer command dropped — supervisor queue full or closed");
}
}
}
// ---------------------------------------------------------------------------
// Supervisor
// ---------------------------------------------------------------------------
/// The client's three output streams are kept as separate locals rather than
/// one struct, so each `select!` arm borrows a distinct binding.
async fn run(
mut cmd_rx: mpsc::Receiver<Cmd>,
cmd_tx: mpsc::Sender<Cmd>,
config: FtmsConfig,
status_tx: watch::Sender<TrainerStatus>,
telemetry_tx: watch::Sender<Telemetry>,
) {
let mut client: Option<Arc<FtmsClient>> = None;
let mut telemetry_rx: Option<broadcast::Receiver<Telemetry>> = None;
let mut events_rx: Option<broadcast::Receiver<FtmsEvent>> = None;
let mut state_rx: Option<watch::Receiver<ConnectionState>> = None;
let mut last_sample: Option<Instant> = None;
let mut status = TrainerStatus::default();
loop {
tokio::select! {
// Telemetry first (NFR-2): control writes are rate-limited anyway.
biased;
sample = next_telemetry(&mut telemetry_rx) => match sample {
Some(sample) => {
last_sample = Some(Instant::now());
if status.stale {
status.stale = false;
publish(&status_tx, &status);
}
let _ = telemetry_tx.send(sample);
}
None => {
// The client actor stopped; the state watcher reports why.
telemetry_rx = None;
}
},
event = next_event(&mut events_rx) => match event {
Some(FtmsEvent::ControlFault { reason }) => {
tracing::error!(reason, "trainer control fault (SAF-4)");
status.control_acquired = false;
status.error = Some(reason);
publish(&status_tx, &status);
}
Some(_) => {}
// NFR-10. A closed stream is *ready* forever, and this select is
// biased: left in place it wins every poll and the command arm
// below is never reached again — including for the shutdown
// command. Drop it and let the state arm explain what happened.
None => events_rx = None,
},
state = next_state(&mut state_rx) => match state {
Some(state) => {
status.control_acquired = state == ConnectionState::Controlling;
if let ConnectionState::Lost { reason } = &state {
status.error = Some(reason.clone());
// A lost link must not leave the last sample standing.
let _ = telemetry_tx.send(Telemetry::default());
last_sample = None;
}
status.state = state;
publish(&status_tx, &status);
}
// The client actor stopped without being asked to — it spent its
// reconnect budget (FR-1.11). Everything we hold is now a zombie:
// the handle would fail one write at a time with nothing to
// explain it, and the closed streams would spin the loop.
None => {
telemetry_rx = None;
events_rx = None;
state_rx = None;
client = None;
last_sample = None;
let _ = telemetry_tx.send(Telemetry::default());
let reason = status.error.clone().unwrap_or_else(|| {
"the trainer link ended and could not be re-established".to_string()
});
tracing::warn!(reason, "trainer supervisor lost its client");
status.control_acquired = false;
status.stale = false;
// Address and name stay: the rider needs a row to click on
// to try again (FR-1.12).
status.state = ConnectionState::Lost { reason };
publish(&status_tx, &status);
}
},
cmd = cmd_rx.recv() => match cmd {
None => break,
Some(Cmd::Shutdown { reply }) => {
let subs = (&mut telemetry_rx, &mut events_rx, &mut state_rx);
shutdown(client.take(), subs, &telemetry_tx).await;
status = TrainerStatus::default();
publish(&status_tx, &status);
let _ = reply.send(());
break;
}
Some(Cmd::Disconnect) => {
let subs = (&mut telemetry_rx, &mut events_rx, &mut state_rx);
shutdown(client.take(), subs, &telemetry_tx).await;
last_sample = None;
// Keep who it was. A trainer stops advertising the moment it
// is in use, so a scan will not necessarily put it back —
// wiping the address here drops it out of the device list
// altogether and leaves the rider nothing to click on to
// reconnect (FR-1.12).
let (address, name) = (status.address.take(), status.name.take());
status = TrainerStatus { address, name, ..TrainerStatus::default() };
publish(&status_tx, &status);
}
Some(Cmd::Connect(selector)) => {
// Authoritative guard against a duplicate click racing the
// status watch: reconnecting a live session would drop the
// rider's trainer mid-ride for no reason.
if client.is_some() && status.is_attached() && already_selected(&status, &selector) {
tracing::debug!(selector = selector.describe(), "already connected; ignoring");
continue;
}
let subs = (&mut telemetry_rx, &mut events_rx, &mut state_rx);
shutdown(client.take(), subs, &telemetry_tx).await;
last_sample = None;
status = TrainerStatus {
state: ConnectionState::Connecting,
..TrainerStatus::default()
};
publish(&status_tx, &status);
// FR-1.10 / SAF-8. A connect runs for up to `scan_timeout`
// plus connect, discovery and handshake — far longer than
// the shutdown budget. Awaiting it here without a way out is
// what makes a quit-while-connecting skip SAF-2 entirely and
// leave the rider on a loaded trainer, so the attempt is
// abandoned instead of finished.
let mut abort: Option<Abort> = None;
let outcome = {
let cancel = async {
abort = Some(abort_signal(&mut cmd_rx, &selector).await);
};
FtmsClient::connect_cancellable(
selector.clone(),
config.clone(),
cancel,
)
.await
};
match outcome {
Ok(Some(c)) => {
tracing::info!(
address = c.address(),
name = c.name().unwrap_or("(no name)"),
caps = ?c.capabilities(),
"trainer connected and controllable"
);
telemetry_rx = Some(c.telemetry());
events_rx = Some(c.events());
state_rx = Some(c.state_stream());
status = TrainerStatus {
state: c.state(),
control_acquired: c.state() == ConnectionState::Controlling,
address: Some(c.address().to_string()),
name: c.name().map(str::to_string),
error: None,
stale: false,
};
client = Some(Arc::new(c));
last_sample = Some(Instant::now());
}
Ok(None) => {
tracing::info!(
selector = selector.describe(),
"connect abandoned; the link it had opened is closed"
);
status = TrainerStatus::default();
}
Err(e) => {
tracing::warn!(error = %e, selector = selector.describe(), "trainer connect failed");
status = TrainerStatus {
state: ConnectionState::Lost { reason: e.to_string() },
error: Some(connect_hint(&e)),
..TrainerStatus::default()
};
}
}
publish(&status_tx, &status);
// Whatever interrupted the connect still has to be honoured.
match abort {
None => {}
Some(Abort::Disconnect) => {
let subs = (&mut telemetry_rx, &mut events_rx, &mut state_rx);
shutdown(client.take(), subs, &telemetry_tx).await;
last_sample = None;
let (address, name) = (status.address.take(), status.name.take());
status = TrainerStatus { address, name, ..TrainerStatus::default() };
publish(&status_tx, &status);
}
Some(Abort::Connect(next)) => {
// Back of the queue rather than a recursive call:
// the loop picks it up on its next turn, once this
// attempt has been fully unwound.
let subs = (&mut telemetry_rx, &mut events_rx, &mut state_rx);
shutdown(client.take(), subs, &telemetry_tx).await;
last_sample = None;
if cmd_tx.try_send(Cmd::Connect(next)).is_err() {
tracing::warn!("could not re-queue the trainer the rider picked");
}
}
Some(Abort::Shutdown(reply)) => {
let subs = (&mut telemetry_rx, &mut events_rx, &mut state_rx);
shutdown(client.take(), subs, &telemetry_tx).await;
status = TrainerStatus::default();
publish(&status_tx, &status);
let _ = reply.send(());
break;
}
Some(Abort::Closed) => break,
}
}
Some(Cmd::Target(target)) => {
if let Some(c) = client.clone() {
let back = cmd_tx.clone();
// Spawned, not awaited: an unacknowledged write takes up
// to `ack_timeout`, and the telemetry pump must keep
// running through it.
tauri::async_runtime::spawn(async move {
match c.set_target(target).await {
Ok(ControlOutcome::Acknowledged { sent }) => {
tracing::debug!(?sent, "trainer accepted target")
}
Ok(ControlOutcome::Superseded) => {}
Err(e) => {
tracing::warn!(?target, error = %e, "control write failed");
let _ = back.try_send(Cmd::WriteFailed(e.to_string()));
}
}
});
}
}
Some(Cmd::Release { limits }) => {
if let Some(c) = client.clone() {
let safe = limits.clamp(ControlTarget::Gradient { percent: 0.0 });
tauri::async_runtime::spawn(async move {
if let Err(e) = c.set_target(safe).await {
tracing::warn!(error = %e, "SAF-2 release write failed");
}
});
}
}
Some(Cmd::WriteFailed(reason)) => {
if status.error.as_deref() != Some(reason.as_str()) {
status.error = Some(reason);
publish(&status_tx, &status);
}
}
},
_ = tokio::time::sleep(HOUSEKEEPING) => {
let quiet = last_sample.is_some_and(|t| t.elapsed() >= STALE_AFTER);
if quiet && !status.stale && status.is_attached() {
tracing::warn!("no Indoor Bike Data for {STALE_AFTER:?} — zeroing telemetry");
status.stale = true;
publish(&status_tx, &status);
// Zero rather than hold: a frozen readout is worse than an
// obviously dead one.
let _ = telemetry_tx.send(Telemetry::default());
}
},
}
}
tracing::info!("trainer supervisor stopped");
}
/// A command that arrived while a connect was in flight and means "stop"
/// (FR-1.10). It is carried out of the attempt rather than acted on there,
/// because the attempt has to be unwound first.
enum Abort {
Disconnect,
Shutdown(SyncSender<()>),
/// The rider picked a different trainer while this one was still connecting.
Connect(TrainerSelector),
/// Every handle dropped.
Closed,
}
/// Watch for a reason to abandon a connect that is currently running.
///
/// Resolves only on a command that must interrupt the attempt. Targets and
/// releases arriving mid-connect are consumed and dropped rather than left to
/// pile up: there is no link to write them to, and the ride loop re-sends its
/// target every tick. Draining is half the point — a command channel nobody
/// reads for fifteen seconds is a command channel that fills.
async fn abort_signal(cmd_rx: &mut mpsc::Receiver<Cmd>, connecting_to: &TrainerSelector) -> Abort {
loop {
match cmd_rx.recv().await {
None => return Abort::Closed,
Some(Cmd::Shutdown { reply }) => return Abort::Shutdown(reply),
Some(Cmd::Disconnect) => return Abort::Disconnect,
// Picking a *different* trainer means "not that one, this one".
// Finishing the first attempt before starting the second would make
// the rider wait out a scan timeout for a choice they have already
// changed. The same trainer clicked twice is only impatience.
Some(Cmd::Connect(selector)) if selector != *connecting_to => {
return Abort::Connect(selector)
}
Some(_) => tracing::trace!("command dropped: nothing to send it to yet"),
}
}
}
/// Run SAF-2 and drop the client.
#[allow(clippy::type_complexity)]
async fn shutdown(
client: Option<Arc<FtmsClient>>,
subs: (
&mut Option<broadcast::Receiver<Telemetry>>,
&mut Option<broadcast::Receiver<FtmsEvent>>,
&mut Option<watch::Receiver<ConnectionState>>,
),
telemetry_tx: &watch::Sender<Telemetry>,
) {
*subs.0 = None;
*subs.1 = None;
*subs.2 = None;
let _ = telemetry_tx.send(Telemetry::default());
let Some(client) = client else { return };
// Bounded, so the supervisor always answers `shutdown_blocking` before that
// caller stops listening. A caller that times out reports a failure it
// cannot do anything about and then lets the process exit anyway.
match tokio::time::timeout(SAFETY_SEQUENCE_TIMEOUT, client.shutdown_ref()).await {
Ok(Ok(())) => {}
Ok(Err(e)) => tracing::warn!(error = %e, "SAF-2 shutdown reported an error"),
Err(_) => tracing::error!("SAF-2 shutdown did not finish inside its budget"),
}
}
/// Does `selector` name the trainer we are already attached to?
fn already_selected(status: &TrainerStatus, selector: &TrainerSelector) -> bool {
match selector {
TrainerSelector::Any => true,
TrainerSelector::Address(a) => status
.address
.as_deref()
.is_some_and(|current| current.eq_ignore_ascii_case(a)),
TrainerSelector::NameContains(n) => status
.name
.as_deref()
.is_some_and(|current| current.to_lowercase().contains(&n.to_lowercase())),
}
}
fn publish(tx: &watch::Sender<TrainerStatus>, status: &TrainerStatus) {
let _ = tx.send(status.clone());
}
/// FR-1.8: an empty scan means "nothing was advertising", and the fix is almost
/// always to pedal. Say so rather than reporting a bare error.
fn connect_hint(e: &FtmsError) -> String {
match e {
FtmsError::NotFound(_) => {
"Trainer not found. It only advertises once awake — turn the cranks for a few \
seconds and try again."
.to_string()
}
FtmsError::NoAdapter => {
"No Bluetooth adapter. Check the radio is on and BlueZ is running.".to_string()
}
// A-3: the D100 accepts one BLE host. A second one gets the link torn
// down mid-handshake, which surfaces from BlueZ as a bare "Not
// connected" — the least informative possible description of the most
// common real-world failure.
FtmsError::Bluetooth(_) | FtmsError::MissingCharacteristic(_) => format!(
"Trainer is busy: {e}. It accepts one connection at a time — close any other app \
(or `probe`) that is holding it, then try again."
),
FtmsError::Rejected { .. } | FtmsError::Unacknowledged { .. } => format!(
"{e}. Another app may already hold control of the trainer — only one may at a time."
),
other => other.to_string(),
}
}
// -- select! helpers ---------------------------------------------------------
//
// Each parks forever when there is no client, so the other arms drive the loop.
// The one thing none of them may do is return `Ready` on every poll: the select
// they feed is `biased`, so a permanently-ready arm starves every arm below it
// (NFR-10). That is why a closed stream is reported once and the caller then
// clears the receiver.
async fn next_telemetry(rx: &mut Option<broadcast::Receiver<Telemetry>>) -> Option<Telemetry> {
match rx {
None => std::future::pending().await,
Some(rx) => loop {
match rx.recv().await {
Ok(t) => return Some(t),
// NFR-2: a lagged consumer skips ahead, it does not stall.
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::debug!(skipped = n, "telemetry consumer lagged")
}
Err(broadcast::error::RecvError::Closed) => return None,
}
},
}
}
async fn next_event(rx: &mut Option<broadcast::Receiver<FtmsEvent>>) -> Option<FtmsEvent> {
match rx {
None => std::future::pending().await,
Some(rx) => loop {
match rx.recv().await {
Ok(e) => return Some(e),
Err(broadcast::error::RecvError::Lagged(_)) => {}
Err(broadcast::error::RecvError::Closed) => return None,
}
},
}
}
async fn next_state(rx: &mut Option<watch::Receiver<ConnectionState>>) -> Option<ConnectionState> {
match rx {
None => std::future::pending().await,
Some(rx) => match rx.changed().await {
Ok(()) => Some(rx.borrow_and_update().clone()),
Err(_) => None,
},
}
}
/// The FTMS configuration this app rides with.
///
/// `use_simulation_mode` is **true**, unlike the crate default: the D100
/// advertises `SetIndoorBikeSimulationParameters` (`0x11`, target feature bit
/// 13) and accepts it, while it does *not* advertise `SetTargetInclination`
/// (`0x03`) and its inclination range reports 06 % with no negatives — useless
/// for descents. Measured against firmware 0.108; see README.md.
pub fn app_config(limits: SafetyLimits) -> FtmsConfig {
FtmsConfig {
limits,
use_simulation_mode: true,
backoff: Backoff {
max_attempts: Some(RECONNECT_ATTEMPTS),
..Backoff::default()
},
..FtmsConfig::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn control_is_tracked_separately_from_connection() {
// FR-9.3: connected is not controllable.
let connected = TrainerStatus {
state: ConnectionState::Connected,
control_acquired: false,
..TrainerStatus::default()
};
assert!(connected.is_attached());
assert!(!connected.controllable());
let controlling = TrainerStatus {
state: ConnectionState::Controlling,
control_acquired: true,
..TrainerStatus::default()
};
assert!(controlling.controllable());
}
#[test]
fn a_lost_link_is_not_attached() {
let lost = TrainerStatus {
state: ConnectionState::Lost {
reason: "gone".into(),
},
..TrainerStatus::default()
};
assert!(!lost.is_attached());
assert!(!lost.controllable());
assert!(!TrainerStatus::default().is_attached());
}
#[test]
fn the_app_drives_gradient_through_simulation_mode() {
// The D100 rejects 0x03 and accepts 0x11 — measured, see README.
let cfg = app_config(SafetyLimits::default());
assert!(cfg.use_simulation_mode);
assert!(
!cfg.ignore_advertised_features,
"FR-2.6 is not for the app to bypass"
);
assert!(cfg.start_on_connect);
assert!(
cfg.min_write_interval >= Duration::from_millis(250),
"FR-2.8"
);
}
#[test]
fn safety_limits_reach_the_ble_layer() {
let limits = SafetyLimits {
max_gradient_pct: 8.0,
..SafetyLimits::default()
};
assert_eq!(app_config(limits).limits.max_gradient_pct, 8.0);
}
#[test]
fn a_missing_trainer_is_explained_not_just_reported() {
let hint = connect_hint(&FtmsError::NotFound("any FTMS trainer".into()));
assert!(hint.to_lowercase().contains("crank"), "FR-1.8: {hint}");
let hint = connect_hint(&FtmsError::NoAdapter);
assert!(hint.to_lowercase().contains("bluetooth"));
}
#[test]
fn a_second_connect_to_the_same_trainer_is_recognised() {
// A duplicate click must not tear down a live session.
let status = TrainerStatus {
state: ConnectionState::Controlling,
control_acquired: true,
address: Some("94:05:bb:04:76:28".into()),
name: Some("VANRYSEL-HT-2876".into()),
..TrainerStatus::default()
};
assert!(already_selected(
&status,
&TrainerSelector::Address("94:05:BB:04:76:28".into())
));
assert!(already_selected(
&status,
&TrainerSelector::NameContains("vanrysel".into())
));
assert!(already_selected(&status, &TrainerSelector::Any));
// A different trainer is a genuine reconnect.
assert!(!already_selected(
&status,
&TrainerSelector::Address("aa:bb:cc:dd:ee:ff".into())
));
assert!(!already_selected(
&TrainerStatus::default(),
&TrainerSelector::Address("94:05:bb:04:76:28".into())
));
}
#[tokio::test]
async fn a_shutdown_interrupts_a_connect_instead_of_queueing_behind_it() {
// FR-1.10 / SAF-8. A connect runs for up to `scan_timeout` plus the
// handshake; reading the shutdown command only after it finished is what
// made a quit-while-connecting blow its budget and skip SAF-2 entirely.
let (tx, mut rx) = mpsc::channel(8);
let (reply, done) = sync_channel(1);
// Commands that are not a reason to stop are drained, not left to fill
// the queue while nobody is reading it.
tx.send(Cmd::Target(ControlTarget::Gradient { percent: 3.0 }))
.await
.unwrap();
tx.send(Cmd::Release {
limits: SafetyLimits::default(),
})
.await
.unwrap();
tx.send(Cmd::Shutdown { reply }).await.unwrap();
match abort_signal(&mut rx, &TrainerSelector::Any).await {
Abort::Shutdown(reply) => {
// The caller is blocking on this; it has to be answerable.
let _ = reply.send(());
assert!(done.recv().is_ok());
}
_ => panic!("the shutdown did not interrupt the connect"),
}
}
#[tokio::test]
async fn a_disconnect_interrupts_a_connect_too() {
// Otherwise the rider's Disconnect click does nothing for fifteen
// seconds and then tears down the link it just finished building.
let (tx, mut rx) = mpsc::channel(8);
tx.send(Cmd::Disconnect).await.unwrap();
assert!(matches!(
abort_signal(&mut rx, &TrainerSelector::Any).await,
Abort::Disconnect
));
}
#[tokio::test]
async fn picking_a_different_trainer_mid_connect_takes_over() {
// Otherwise changing your mind costs a whole scan timeout, and the
// trainer you clicked second is connected to only after the one you
// gave up on has finished failing.
let connecting_to = TrainerSelector::Address("94:05:bb:04:76:28".into());
let (tx, mut rx) = mpsc::channel(8);
// The same trainer clicked again is impatience, not a new intent — it
// must not restart the attempt already running.
tx.send(Cmd::Connect(connecting_to.clone())).await.unwrap();
tx.send(Cmd::Connect(TrainerSelector::Address(
"aa:bb:cc:dd:ee:ff".into(),
)))
.await
.unwrap();
match abort_signal(&mut rx, &connecting_to).await {
Abort::Connect(TrainerSelector::Address(a)) => assert_eq!(a, "aa:bb:cc:dd:ee:ff"),
_ => panic!("the second trainer did not take over"),
}
}
#[tokio::test]
async fn dropping_every_handle_ends_a_connect() {
let (tx, mut rx) = mpsc::channel::<Cmd>(1);
drop(tx);
assert!(matches!(
abort_signal(&mut rx, &TrainerSelector::Any).await,
Abort::Closed
));
}
#[test]
fn reconnect_is_bounded_so_the_rider_is_eventually_told() {
// FR-1.11. Retrying forever sounds kinder than giving up, but it leaves
// the link in `Reconnecting` indefinitely and never says the trainer is
// gone — so the rider is never prompted to go and look at it.
let cfg = app_config(SafetyLimits::default());
assert_eq!(cfg.backoff.max_attempts, Some(RECONNECT_ATTEMPTS));
assert!(cfg.backoff.exhausted(RECONNECT_ATTEMPTS));
assert!(!cfg.backoff.exhausted(RECONNECT_ATTEMPTS - 1));
}
#[test]
fn the_supervisor_answers_before_its_caller_stops_listening() {
// NFR-9. If the inner budget outlasted the outer one, every quit would
// report a timeout for a sequence that was about to succeed — and then
// let the process exit on top of it anyway.
assert!(SAFETY_SEQUENCE_TIMEOUT < SHUTDOWN_TIMEOUT);
}
#[test]
fn a_trainer_held_by_another_app_says_so() {
// A-3: one BLE host. BlueZ reports the second one's torn-down link as a
// bare "Not connected", which explains nothing on its own.
let hint = connect_hint(&FtmsError::MissingCharacteristic(
"Indoor Bike Data (0x2AD2)",
));
assert!(hint.contains("one connection at a time"), "{hint}");
assert!(hint.to_lowercase().contains("busy"), "{hint}");
}
}