Virtual gearing, trainer-speed blend, and cadence decode

Gears are expressed as an offset to the commanded gradient, leaving the
physics on the route's true gradient so shifting changes effort, not speed.
Neutral gear commands exactly the route gradient, so an un-shifted ride is
unchanged.

Cadence is not in FTMS on this trainer but is on its Zwift channel, decoded
against captured frames. The undeclared FTMS trailing bytes were ruled out:
wheel RPM restated at a fixed 73.8x speed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 15:33:28 +02:00
co-authored by Claude Opus 5
parent 3a2a787b7d
commit 57eb5e809b
48 changed files with 57737 additions and 431 deletions
+10 -5
View File
@@ -17,10 +17,12 @@ tauri-build = { version = "2", features = [] }
[dependencies]
bikecontrol-core = { workspace = true }
bikecontrol-ble = { workspace = true }
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
uuid = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml_ng = { workspace = true }
@@ -33,9 +35,12 @@ tracing-subscriber = { workspace = true }
[features]
default = ["mock-ride"]
# Drive the UI from the synthetic ride simulator in `src/mock.rs`. This is what
# ships today, while `crates/core` and `crates/ble` are still being written.
# Compile the synthetic rider in `src/mock.rs` *as an option*. It is no longer
# the default data source — `bikecontrol_core::RideSession` fed by real FTMS
# telemetry is (see `state::Inner::new`). The feature exists so the mock can be
# selected at runtime with `BIKECONTROL_DEMO=1` or `BIKECONTROL_MOCK=1`, which
# is what makes the GUI developable with no hardware on the desk.
#
# Build with `--no-default-features` for a binary that can only ever show real
# trainer data.
mock-ride = []
# Drive the UI from `bikecontrol_core::RideSession` fed by real FTMS telemetry.
# Swap `default` to this once core's `session`/`physics` are implemented.
real-session = []
+9 -4
View File
@@ -1,9 +1,11 @@
//! The seam between the Tauri shell and whatever is actually riding.
//!
//! Today that is [`crate::mock::MockBackend`], a synthetic rider. Tomorrow it
//! is `bikecontrol_core::RideSession` fed by FTMS telemetry from
//! `bikecontrol_ble`. Both are the same shape: rider intent in, a
//! `RideSnapshot` out, and a `ControlTarget` to push to the trainer.
//! By default that is [`crate::session_backend::SessionBackend`] —
//! `bikecontrol_core::RideSession` fed by real FTMS telemetry from
//! `bikecontrol_ble`. `crate::mock::MockBackend`, a synthetic rider, is the
//! opt-in alternative for working on the GUI with no hardware. Both are the
//! same shape: rider intent in, a `RideSnapshot` out, and a `ControlTarget` to
//! push to the trainer.
//!
//! Nothing above this trait knows which one is running (§4.3 — the control loop
//! lives in Rust; the frontend only ever sees snapshots).
@@ -26,6 +28,8 @@ pub struct RideInputs {
pub manual_gradient_pct: f32,
/// Trim applied on top of whatever the base gradient is (FR-4.2).
pub gradient_offset_pct: f32,
/// Selected virtual gear, one-based (FR-4.1).
pub gear: usize,
pub resistance_level: i16,
pub power_target_w: u16,
pub profile: Option<Arc<Profile>>,
@@ -38,6 +42,7 @@ impl Default for RideInputs {
Self {
status: RideStatus::Idle,
mode: ControlMode::ManualGrade,
gear: bikecontrol_core::Gearing::default().gear(),
manual_gradient_pct: 0.0,
gradient_offset_pct: 0.0,
resistance_level: 20,
+37
View File
@@ -9,6 +9,9 @@ use bikecontrol_core::profile::Profile;
use bikecontrol_core::types::{ControlMode, RiderConfig, SafetyLimits};
use tauri::{AppHandle, State};
use bikecontrol_ble::TrainerSelector;
use crate::controller::ControllerStatus;
use crate::devices::DeviceInfo;
use crate::events::{DeviceList, LapSummary, Notice, RideState, RideStatus};
use crate::profile_view::{self, ProfileView};
@@ -421,3 +424,37 @@ pub fn forget_device(app: AppHandle, state: State<'_, AppState>, device_id: Stri
pub fn trainer_controllable(state: State<'_, AppState>) -> bool {
state.lock().devices.trainer_controllable()
}
// ---------------------------------------------------------------------------
// Controller (Zwift Click)
// ---------------------------------------------------------------------------
#[tauri::command]
pub fn controller_status(state: State<'_, AppState>) -> ControllerStatus {
state.controller().status()
}
/// Connect to a Click. `device_id` is an address; omit it to take the first pod
/// that advertises.
///
/// Fire-and-forget: the supervisor owns the radio and the result arrives on
/// `controller://status`. A Click sleeps within seconds and only advertises
/// after a button press (A-4), so this routinely takes a few attempts — which
/// is why it must not block the UI thread waiting for one.
#[tauri::command]
pub fn connect_controller(state: State<'_, AppState>, device_id: Option<String>) -> Cmd<()> {
let selector = match device_id {
Some(id) if !id.trim().is_empty() => TrainerSelector::Address(id),
// Every pod so far advertises as "Zwift Click".
_ => TrainerSelector::NameContains("Zwift Click".into()),
};
state.controller().connect(selector);
Ok(())
}
#[tauri::command]
pub fn disconnect_controller(app: AppHandle, state: State<'_, AppState>) -> Cmd<()> {
state.controller().disconnect();
notify(&app, Notice::info("Controller disconnected"));
Ok(())
}
+397
View File
@@ -0,0 +1,397 @@
//! The controller supervisor: the app's single owner of a [`ClickClient`].
//!
//! The same shape as [`crate::trainer`], and for the same reason — the Tauri
//! commands hold a `Mutex` and may never `.await` a radio, so all BLE work
//! happens in one background task that they reach over a channel.
//!
//! ```text
//! commands ──connect/disconnect──► [supervisor task] ──► ClickClient
//! webview ◄──controller://input──── button edges ◄─────────┘
//! ```
//!
//! The difference from the trainer is that a controller has **no safety
//! story**: it never commands load, so there is no SAF-2 sequence to run and
//! nothing to reset on exit. It still has to be *disconnected* on the way out
//! though (SAF-9) — a link the process merely abandons can leave the pod held
//! by BlueZ and unreachable on the next launch — and that disconnect has to be
//! waited for, which is what [`ControllerHandle::shutdown_blocking`] is.
//!
//! Button *edges* arrive already de-duplicated by `ButtonTracker` in the BLE
//! layer — the pod repeats a held button at ~10 Hz, and acting on repeats would
//! shift ten gears per second.
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::click::{ClickClient, ClickConfig, ClickEvent};
use bikecontrol_ble::zwift::Button;
use bikecontrol_ble::TrainerSelector;
use serde::Serialize;
use tokio::sync::{mpsc, watch};
/// How long a controller may stay silent before we call it stale. The pod sends
/// a battery heartbeat every ~5 s even when idle, so 20 s is four missed beats.
const STALE_AFTER: Duration = Duration::from_secs(20);
/// Upper bound on closing the controller link at exit. Shorter than the
/// trainer's: there is no reset sequence here, only an unsubscribe and a
/// disconnect, and this budget is spent on the same window close (NFR-9).
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3);
/// What the UI needs to know about the controller link.
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ControllerStatus {
pub connected: bool,
pub address: Option<String>,
pub name: Option<String>,
pub battery_percent: Option<u8>,
/// Rendered verbatim (FR-9.2).
pub error: Option<String>,
}
/// A button edge, on its way to the webview.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ControllerInput {
/// Stable lowercase name: `left`, `up`, `right`, `down`, `a`, `b`, `y`,
/// `z`, `minus`, `plus`. The webview switches on this, so it must not drift
/// from [`button_name`].
pub button: &'static str,
pub pressed: bool,
}
/// The webview-facing name for a button. Deliberately not `Button::label`,
/// which returns `-` and `+` — awkward to switch on in TypeScript.
pub fn button_name(button: Button) -> &'static str {
match button {
Button::Left => "left",
Button::Up => "up",
Button::Right => "right",
Button::Down => "down",
Button::A => "a",
Button::B => "b",
Button::Y => "y",
Button::Z => "z",
Button::Minus => "minus",
Button::Plus => "plus",
}
}
enum Cmd {
Connect(TrainerSelector),
Disconnect,
/// Close the link and stop, answering only once it is actually closed.
Shutdown { reply: SyncSender<()> },
}
/// A command that arrived while a connect was in flight and means "stop"
/// (FR-1.10).
enum Abort {
Disconnect,
Shutdown(SyncSender<()>),
/// Every handle dropped.
Closed,
}
/// Cheap, cloneable handle to the supervisor.
#[derive(Clone)]
pub struct ControllerHandle {
cmd_tx: mpsc::Sender<Cmd>,
status_rx: watch::Receiver<ControllerStatus>,
/// Button edges, for whoever forwards them to the webview.
input_tx: Arc<tokio::sync::broadcast::Sender<ControllerInput>>,
/// Shared, so every clone of the handle sees that the link is already shut.
shut_down: Arc<AtomicBool>,
}
impl ControllerHandle {
/// 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(ControllerStatus::default());
let (input_tx, _) = tokio::sync::broadcast::channel(64);
let input_tx = Arc::new(input_tx);
let handle = Self {
cmd_tx,
status_rx,
input_tx: input_tx.clone(),
shut_down: Arc::new(AtomicBool::new(false)),
};
tauri::async_runtime::spawn(run(cmd_rx, status_tx, input_tx));
handle
}
pub fn status(&self) -> ControllerStatus {
self.status_rx.borrow().clone()
}
/// A watch receiver, for a loop that wants to react to link changes rather
/// than poll them.
pub fn status_watch(&self) -> watch::Receiver<ControllerStatus> {
self.status_rx.clone()
}
/// Subscribe to button edges.
pub fn inputs(&self) -> tokio::sync::broadcast::Receiver<ControllerInput> {
self.input_tx.subscribe()
}
pub fn connect(&self, selector: TrainerSelector) {
let _ = self.cmd_tx.try_send(Cmd::Connect(selector));
}
pub fn disconnect(&self) {
let _ = self.cmd_tx.try_send(Cmd::Disconnect);
}
/// Close the controller link at app exit, waiting for it to be closed
/// (SAF-9). Blocks the calling (non-async) thread until it is done or
/// [`SHUTDOWN_TIMEOUT`] elapses.
///
/// Idempotent: Tauri delivers `ExitRequested`, `Exit` and window `Destroyed`
/// for a single quit, 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!("controller 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!("controller disconnected"),
Err(e) => tracing::warn!(error = %e, "controller did not disconnect in time"),
}
}
}
async fn run(
mut cmd_rx: mpsc::Receiver<Cmd>,
status_tx: watch::Sender<ControllerStatus>,
input_tx: Arc<tokio::sync::broadcast::Sender<ControllerInput>>,
) {
let mut client: Option<ClickClient> = None;
let mut events: Option<tokio::sync::broadcast::Receiver<ClickEvent>> = None;
let mut last_seen = tokio::time::Instant::now();
let mut housekeeping = tokio::time::interval(Duration::from_secs(5));
loop {
tokio::select! {
cmd = cmd_rx.recv() => match cmd {
Some(Cmd::Connect(selector)) => {
if let Some(existing) = client.take() {
existing.shutdown().await;
}
events = None;
status_tx.send_modify(|s| {
*s = ControllerStatus { error: None, ..Default::default() }
});
// A pod only advertises after a button press (A-4), so this
// routinely runs the full scan timeout. Awaiting it without
// a way out would make a quit — or even a Disconnect click —
// wait twenty seconds behind it (FR-1.10).
let mut abort: Option<Abort> = None;
let outcome = {
let cancel = async {
abort = Some(abort_signal(&mut cmd_rx).await);
};
ClickClient::connect_cancellable(
selector,
ClickConfig::default(),
cancel,
)
.await
};
match outcome {
Ok(Some(c)) => {
events = Some(c.events());
client = Some(c);
last_seen = tokio::time::Instant::now();
}
Ok(None) => tracing::info!("controller: connect abandoned"),
Err(e) => {
tracing::warn!(error = %e, "controller: connect failed");
status_tx.send_modify(|s| s.error = Some(e.to_string()));
}
}
// Whatever interrupted the connect still has to be honoured.
match abort {
None => {}
Some(Abort::Disconnect) => {
if let Some(c) = client.take() {
c.shutdown().await;
}
events = None;
status_tx.send_modify(|s| *s = ControllerStatus::default());
}
Some(Abort::Shutdown(reply)) => {
if let Some(c) = client.take() {
c.shutdown().await;
}
status_tx.send_modify(|s| *s = ControllerStatus::default());
let _ = reply.send(());
return;
}
Some(Abort::Closed) => {
if let Some(c) = client.take() {
c.shutdown().await;
}
return;
}
}
}
Some(Cmd::Disconnect) => {
if let Some(c) = client.take() {
c.shutdown().await;
}
events = None;
status_tx.send_modify(|s| *s = ControllerStatus::default());
}
Some(Cmd::Shutdown { reply }) => {
if let Some(c) = client.take() {
// Waits for the pod's link to actually close (SAF-9).
c.shutdown().await;
}
status_tx.send_modify(|s| *s = ControllerStatus::default());
let _ = reply.send(());
return;
}
None => {
if let Some(c) = client.take() {
c.shutdown().await;
}
return;
}
},
// Only polled while a client exists; `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(e) => {
last_seen = tokio::time::Instant::now();
apply(e, &status_tx, &input_tx);
}
// Lagged means we fell behind the pod, not that it left.
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!("controller: dropped {n} event(s)");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
events = None;
client = None;
status_tx.send_modify(|s| *s = ControllerStatus::default());
}
}
},
_ = housekeeping.tick() => {
if client.is_some() && last_seen.elapsed() > STALE_AFTER {
// Not an error: the BLE layer is already retrying. Say so
// rather than showing a connected pod that is not talking.
status_tx.send_modify(|s| s.connected = false);
}
}
}
}
}
/// Watch for a reason to abandon a connect that is currently running.
///
/// Connect requests arriving mid-connect are dropped rather than queued: the
/// rider clicking twice means "connect", which is what is already happening.
async fn abort_signal(cmd_rx: &mut mpsc::Receiver<Cmd>) -> 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,
Some(Cmd::Connect(_)) => tracing::debug!("controller: already connecting"),
}
}
}
fn apply(
event: ClickEvent,
status_tx: &watch::Sender<ControllerStatus>,
input_tx: &tokio::sync::broadcast::Sender<ControllerInput>,
) {
match event {
ClickEvent::Connected { address, name } => status_tx.send_modify(|s| {
s.connected = true;
s.address = Some(address);
s.name = name;
s.error = None;
}),
ClickEvent::Disconnected => status_tx.send_modify(|s| s.connected = false),
ClickEvent::Battery { percent } => {
status_tx.send_modify(|s| s.battery_percent = Some(percent))
}
ClickEvent::Button { button, pressed } => {
// A send failure only means nobody is listening yet.
let _ = input_tx.send(ControllerInput {
button: button_name(button),
pressed,
});
}
ClickEvent::Unknown { kind, .. } => {
tracing::debug!("controller: unhandled frame type 0x{kind:02x}")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_button_has_a_distinct_webview_name() {
let mut seen = std::collections::HashSet::new();
for b in Button::ALL {
let name = button_name(b);
assert!(seen.insert(name), "{name} is used twice");
// The webview switches on these; a stray `+` would need escaping.
assert!(
name.chars().all(|c| c.is_ascii_lowercase()),
"{name} is not a plain lowercase identifier"
);
}
assert_eq!(seen.len(), 10);
}
#[test]
fn paddles_are_named_for_typescript_not_for_display() {
assert_eq!(button_name(Button::Plus), "plus");
assert_eq!(button_name(Button::Minus), "minus");
}
#[test]
fn a_fresh_status_is_disconnected_and_blameless() {
let s = ControllerStatus::default();
assert!(!s.connected);
assert!(s.error.is_none() && s.battery_percent.is_none());
}
}
+102 -13
View File
@@ -17,6 +17,7 @@
use std::collections::VecDeque;
use bikecontrol_core::energy;
use bikecontrol_core::profile::Profile;
use bikecontrol_core::types::RideSnapshot;
use serde::Serialize;
@@ -84,6 +85,10 @@ pub struct Derived {
pub normalised_power_w: Option<f32>,
pub avg_cadence_rpm: f32,
pub energy_kj: f32,
/// Estimated metabolic cost of the ride so far, kcal. See
/// [`bikecontrol_core::energy`] — this is the rider's burn, not the
/// mechanical work in `energy_kj`.
pub calories_kcal: f32,
}
/// Rolling windows. One instance lives in the app state for the whole ride.
@@ -99,6 +104,9 @@ pub struct Deriver {
cadence_n: u64,
max_power_w: i16,
energy_kj: f32,
/// Seconds the ride has actually been running. Drives the resting-burn
/// half of the calorie estimate, so a paused ride does not accrue.
active_s: f64,
last_elapsed_s: f64,
/// Last ETA that was computed from real movement (FR-9.15, hold-on-stop).
last_eta_s: Option<f64>,
@@ -118,6 +126,7 @@ impl Default for Deriver {
cadence_n: 0,
max_power_w: 0,
energy_kj: 0.0,
active_s: 0.0,
last_elapsed_s: 0.0,
last_eta_s: None,
}
@@ -148,10 +157,14 @@ impl Deriver {
}
/// Fold one snapshot in and produce the derived figures.
///
/// `rider_kg` is the configured rider mass; it only feeds the calorie
/// estimate, and zero simply means the resting term is skipped.
pub fn update(
&mut self,
snapshot: &RideSnapshot,
running: bool,
rider_kg: f32,
profile: Option<&Profile>,
geom: Option<&ProfileGeometry>,
) -> Derived {
@@ -175,6 +188,7 @@ impl Deriver {
self.cadence_n += 1;
}
self.energy_kj += power * dt as f32 / 1000.0;
self.active_s += dt;
// Normalised power: 30 s rolling mean, raised to the fourth,
// averaged, fourth root.
let rolling = mean(&self.np) as f64;
@@ -281,6 +295,11 @@ impl Deriver {
(self.cadence_sum / self.cadence_n as f64) as f32
},
energy_kj: self.energy_kj,
calories_kcal: energy::kcal(
f64::from(self.energy_kj) * 1000.0,
rider_kg,
self.active_s,
) as f32,
}
}
}
@@ -302,6 +321,9 @@ mod tests {
use crate::profile_view;
/// The default rider mass, so the calorie term is exercised everywhere.
const RIDER_KG: f32 = 75.0;
fn snapshot(elapsed_s: f64, distance_m: f64, speed_kph: f32) -> RideSnapshot {
RideSnapshot {
elapsed_ms: (elapsed_s * 1000.0) as u64,
@@ -350,7 +372,7 @@ mod tests {
let profile = timed_profile();
let (_, geom) = profile_view::build(&profile, "test");
let mut d = Deriver::default();
let out = d.update(&snapshot(120.0, 0.0, 0.0), true, Some(&profile), Some(&geom));
let out = d.update(&snapshot(120.0, 0.0, 0.0), true, RIDER_KG, Some(&profile), Some(&geom));
assert_eq!(out.eta_kind, EtaKind::Exact);
assert!((out.time_remaining_s.unwrap() - 480.0).abs() < 1e-6);
}
@@ -365,13 +387,25 @@ mod tests {
let mut t = 0.0;
for i in 1..=(SPEED_WINDOW_S / 0.25) as u32 {
t = i as f64 * 0.25;
d.update(&snapshot(t, t * 10.0, 36.0), true, Some(&profile), Some(&geom));
d.update(&snapshot(t, t * 10.0, 36.0), true, RIDER_KG, Some(&profile), Some(&geom));
}
t += 0.25;
let steady = d.update(&snapshot(t, t * 10.0, 36.0), true, Some(&profile), Some(&geom));
let steady = d.update(
&snapshot(t, t * 10.0, 36.0),
true,
RIDER_KG,
Some(&profile),
Some(&geom),
);
t += 0.25;
// One absurd sample: 90 km/h, two and a half times reality.
let spike = d.update(&snapshot(t, t * 10.0, 90.0), true, Some(&profile), Some(&geom));
let spike = d.update(
&snapshot(t, t * 10.0, 90.0),
true,
RIDER_KG,
Some(&profile),
Some(&geom),
);
assert_eq!(spike.eta_kind, EtaKind::Estimated);
let base = steady.time_remaining_s.unwrap();
let drift = (spike.time_remaining_s.unwrap() - base).abs();
@@ -390,9 +424,15 @@ mod tests {
let mut d = Deriver::default();
for i in 1..=200 {
let t = i as f64 * 0.25;
d.update(&snapshot(t, t * 8.0, 28.8), true, Some(&profile), Some(&geom));
d.update(&snapshot(t, t * 8.0, 28.8), true, RIDER_KG, Some(&profile), Some(&geom));
}
let moving = d.update(&snapshot(50.25, 402.0, 28.8), true, Some(&profile), Some(&geom));
let moving = d.update(
&snapshot(50.25, 402.0, 28.8),
true,
RIDER_KG,
Some(&profile),
Some(&geom),
);
assert_eq!(moving.eta_kind, EtaKind::Estimated);
// Now stop dead for long enough to flush the whole speed window.
@@ -401,7 +441,13 @@ mod tests {
for i in 1..=400 {
let t = 50.25 + i as f64 * 0.25;
prev = stopped;
stopped = d.update(&snapshot(t, 402.0, 0.0), true, Some(&profile), Some(&geom));
stopped = d.update(
&snapshot(t, 402.0, 0.0),
true,
RIDER_KG,
Some(&profile),
Some(&geom),
);
}
// The contract: finite, flagged as held, and no longer changing.
assert_eq!(stopped.eta_kind, EtaKind::Held);
@@ -418,9 +464,15 @@ mod tests {
let mut d = Deriver::default();
for i in 1..=200 {
let t = i as f64 * 0.25;
d.update(&snapshot(t, t * 8.0, 28.8), true, Some(&profile), Some(&geom));
d.update(&snapshot(t, t * 8.0, 28.8), true, RIDER_KG, Some(&profile), Some(&geom));
}
let paused = d.update(&snapshot(50.25, 402.0, 28.8), false, Some(&profile), Some(&geom));
let paused = d.update(
&snapshot(50.25, 402.0, 28.8),
false,
RIDER_KG,
Some(&profile),
Some(&geom),
);
assert_eq!(paused.eta_kind, EtaKind::Held);
assert!(paused.time_remaining_s.unwrap().is_finite());
}
@@ -431,7 +483,13 @@ mod tests {
let profile = distance_profile(true);
let (_, geom) = profile_view::build(&profile, "test");
let mut d = Deriver::default();
let out = d.update(&snapshot(300.0, 4500.0, 30.0), true, Some(&profile), Some(&geom));
let out = d.update(
&snapshot(300.0, 4500.0, 30.0),
true,
RIDER_KG,
Some(&profile),
Some(&geom),
);
assert_eq!(out.eta_kind, EtaKind::Looping);
assert_eq!(out.time_remaining_s, None);
assert_eq!(out.loop_index, Some(3));
@@ -443,7 +501,7 @@ mod tests {
#[test]
fn no_profile_means_unavailable() {
let mut d = Deriver::default();
let out = d.update(&snapshot(60.0, 500.0, 30.0), true, None, None);
let out = d.update(&snapshot(60.0, 500.0, 30.0), true, RIDER_KG, None, None);
assert_eq!(out.eta_kind, EtaKind::Unavailable);
assert_eq!(out.time_remaining_s, None);
}
@@ -459,11 +517,42 @@ mod tests {
for i in 1..=40 {
snap.elapsed_ms = (i * 250) as u64;
snap.telemetry.power_w = Some(100);
d.update(&snap, true, Some(&profile), Some(&geom));
d.update(&snap, true, RIDER_KG, Some(&profile), Some(&geom));
}
snap.elapsed_ms = 10_250;
snap.telemetry.power_w = Some(600);
let out = d.update(&snap, true, Some(&profile), Some(&geom));
let out = d.update(&snap, true, RIDER_KG, Some(&profile), Some(&geom));
assert!(out.rolling_power_w < 250.0, "rolling power tracked the spike too closely");
}
/// An hour at 200 W: the work term dominates, the resting term is the
/// smaller correction on top, and the total is in the range a rider would
/// recognise from a head unit.
#[test]
fn calories_track_work_plus_a_resting_correction() {
let mut d = Deriver::default();
let mut out = None;
for i in 1..=3600 {
out = Some(d.update(&snapshot(i as f64, 0.0, 30.0), true, RIDER_KG, None, None));
}
let out = out.unwrap();
assert!((out.energy_kj - 720.0).abs() < 1.0, "work {} kJ", out.energy_kj);
// 720 kJ of work plus 75 kcal of being alive for an hour.
assert!(
(out.calories_kcal - 763.0).abs() < 5.0,
"burn {} kcal",
out.calories_kcal
);
}
/// A paused ride burns nothing this ride can claim: neither pedalling work
/// nor the resting term accrues while the clock is stopped.
#[test]
fn a_paused_ride_accrues_no_calories() {
let mut d = Deriver::default();
let running = d.update(&snapshot(60.0, 0.0, 30.0), true, RIDER_KG, None, None);
let paused = d.update(&snapshot(3600.0, 0.0, 0.0), false, RIDER_KG, None, None);
assert!(running.calories_kcal > 0.0);
assert_eq!(running.calories_kcal, paused.calories_kcal);
}
}
+444 -222
View File
@@ -1,23 +1,42 @@
//! Device discovery and connection state (FR-1, FR-9.19.3).
//!
//! `crates/ble` is not written yet, so this is a **mock scanner**: a scripted
//! set of peripherals that appear over a few seconds, with RSSI that drifts and
//! connection state machines that take realistic time to settle. It exists so
//! the connection screen can be built and judged today.
//! A real BLE scanner. A background task drives `bikecontrol_ble::scan` and
//! publishes its results on a `watch` channel; [`DeviceRegistry`] — which lives
//! inside the app's synchronous `Mutex` and therefore may never `.await` —
//! reads that channel, merges in the trainer supervisor's status, and produces
//! the `DeviceInfo` list the UI renders.
//!
//! The important behaviour it models — and the reason it is not just a static
//! list — is that **BLE connection and FTMS control acquisition are separate
//! steps** (FR-9.3). A trainer goes `Connecting → Connected → Controlling`, and
//! it can sit at `Connected` indefinitely if the control point is refused.
//! ```text
//! scan task ──watch<ScanSnapshot>──┐
//! ├──► DeviceRegistry::poll ──► DeviceInfo[]
//! trainer ──watch<TrainerStatus>─┘
//! ```
//!
//! Swapping in the real scanner means replacing [`DeviceRegistry::poll`] and
//! the two request methods with `btleplug` calls; the `DeviceInfo` the UI
//! renders does not change.
//! The behaviour that matters, and the reason `control_acquired` is a field
//! rather than a state: **BLE connection and FTMS control acquisition are
//! separate steps** (FR-9.3). A trainer goes `Connecting → Connected →
//! Controlling`, and it can sit at `Connected` indefinitely if the control
//! point is refused.
use std::collections::HashSet;
use std::time::Duration;
use bikecontrol_ble::scan::{self, DiscoveredDevice, ScanKind, ZWIFT_SERVICE};
use bikecontrol_ble::uuids;
use bikecontrol_core::types::ConnectionState;
use serde::{Deserialize, Serialize};
use tokio::sync::watch;
use uuid::Uuid;
use crate::trainer::{TrainerHandle, TrainerStatus};
/// One pass of the scanner. Long enough for a trainer to advertise, short
/// enough that the list feels live.
const SCAN_WINDOW: Duration = Duration::from_millis(2500);
/// Poll interval while scanning is switched off.
const IDLE_POLL: Duration = Duration::from_millis(400);
/// Heart Rate Service, so an HRM in the room is labelled rather than "unknown".
const HEART_RATE_SERVICE: Uuid = Uuid::from_u128(0x0000180d_0000_1000_8000_00805f9b34fb);
/// What we think a peripheral is, from its advertised services and
/// manufacturer data (FR-1.2).
@@ -33,13 +52,14 @@ pub enum DeviceKind {
Unknown,
}
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceInfo {
pub id: String,
pub name: String,
pub address: String,
/// dBm. Roughly 40 (touching) to 95 (barely there).
/// dBm. Roughly 40 (touching) to 95 (barely there). 0 when the adapter
/// did not report one.
pub rssi: i16,
pub kind: DeviceKind,
pub state: ConnectionState,
@@ -61,273 +81,475 @@ pub struct PollResult {
pub changed: bool,
/// Devices whose connection state settled this tick.
pub transitions: Vec<DeviceInfo>,
/// 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>,
}
/// A scripted peripheral in the mock environment.
struct Simulated {
info: DeviceInfo,
/// Ticks after scan start before it shows up. Models A-4: the trainer only
/// advertises once you pedal, the Click once you press a button.
appears_after: u32,
/// Ticks remaining in the current transition, and where it lands.
pending: Option<(u32, ConnectionState, bool)>,
visible: bool,
/// What the scan task publishes.
#[derive(Debug, Clone, Default)]
pub struct ScanSnapshot {
pub devices: Vec<DiscoveredDevice>,
/// Adapter-level failure — no radio, BlueZ down. Surfaced verbatim.
pub error: Option<String>,
/// Bumped every completed pass, so `poll` can tell "same devices" from
/// "scanner has not run yet".
pub generation: u64,
}
pub struct DeviceRegistry {
devices: Vec<Simulated>,
trainer: TrainerHandle,
scan_rx: watch::Receiver<ScanSnapshot>,
scan_on: watch::Sender<bool>,
forgotten: HashSet<String>,
remembered: HashSet<String>,
/// The list published last tick, for change detection.
published: Vec<DeviceInfo>,
last_trainer: TrainerStatus,
pub scanning: bool,
ticks: u32,
rng: u64,
}
/// How long each mock transition takes, in registry ticks (2 Hz).
const CONNECT_TICKS: u32 = 3;
const CONTROL_TICKS: u32 = 3;
impl Default for DeviceRegistry {
fn default() -> Self {
Self::new()
}
/// 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).
scan_suspended: bool,
/// The most recent adapter error, so the UI can say why the list is empty.
pub error: Option<String>,
}
impl DeviceRegistry {
pub fn new() -> Self {
pub fn new(trainer: TrainerHandle) -> 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 {
devices: catalogue(),
last_trainer: trainer.status(),
trainer,
scan_rx,
scan_on,
forgotten: HashSet::new(),
remembered: HashSet::new(),
published: Vec::new(),
scanning: false,
ticks: 0,
rng: 0xDEAD_BEEF_CAFE_F00D,
scan_suspended: false,
error: None,
}
}
fn rand(&mut self) -> f32 {
let mut x = self.rng;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.rng = x;
((x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 40) as f32) / (1 << 24) as f32
}
/// Rider-initiated. Cancels any suspension: an explicit request outranks
/// our own bookkeeping in both directions.
pub fn start_scan(&mut self) {
self.scanning = true;
self.ticks = 0;
for d in &mut self.devices {
if !matches!(d.info.state, ConnectionState::Connected | ConnectionState::Controlling) {
d.info.state = ConnectionState::Scanning;
}
}
self.scan_suspended = false;
self.set_scanning(true);
}
pub fn stop_scan(&mut self) {
self.scanning = false;
for d in &mut self.devices {
if d.info.state == ConnectionState::Scanning {
d.info.state = ConnectionState::Idle;
}
}
self.scan_suspended = false;
self.set_scanning(false);
}
/// Advance the mock. Reports whether the list changed at all, and which
/// devices crossed a connection-state boundary this tick.
fn set_scanning(&mut self, on: bool) {
self.scanning = on;
let _ = self.scan_on.send(on);
}
/// Rebuild the device list from the scanner and the trainer supervisor.
pub fn poll(&mut self) -> PollResult {
let mut changed = false;
let mut transitions = Vec::new();
if self.scanning {
self.ticks += 1;
for i in 0..self.devices.len() {
let appears = self.devices[i].appears_after;
if !self.devices[i].visible && self.ticks >= appears {
self.devices[i].visible = true;
changed = true;
}
if self.devices[i].visible {
let jitter = (self.rand() * 6.0) as i16 - 3;
let base = self.devices[i].info.rssi;
let next = (base + jitter).clamp(-95, -38);
if next != base {
self.devices[i].info.rssi = next;
changed = true;
}
let trainer = self.trainer.status();
let trainer_changed = (trainer != self.last_trainer).then(|| trainer.clone());
self.last_trainer = trainer.clone();
// 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
// otherwise leave the list frozen on a snapshot taken before the attempt
// and the rider with no way to find anything but the Scan button
// (FR-1.12, NFR-7).
if should_resume_scan(self.scan_suspended, &trainer) {
self.scan_suspended = false;
self.set_scanning(true);
}
let next = self.build(&trainer);
let transitions = state_transitions(&self.published, &next);
let changed = next != self.published;
self.published = next;
PollResult { changed, transitions, trainer_changed }
}
/// Merge the scan snapshot with the trainer's live status.
fn build(&mut self, trainer: &TrainerStatus) -> Vec<DeviceInfo> {
let snapshot = self.scan_rx.borrow().clone();
self.error = snapshot.error.clone();
let mut out: Vec<DeviceInfo> = Vec::with_capacity(snapshot.devices.len() + 1);
for d in &snapshot.devices {
let id = d.address.clone();
if self.forgotten.contains(&id) {
continue;
}
out.push(DeviceInfo {
kind: classify(d),
name: d.label(),
address: d.address.clone(),
rssi: d.rssi.unwrap_or(0),
state: if self.scanning {
ConnectionState::Scanning
} else {
ConnectionState::Idle
},
control_acquired: false,
services: d.services.iter().map(|u| describe_service(*u)).collect(),
remembered: self.remembered.contains(&id),
battery_pct: None,
unlock_expires_in_s: None,
error: None,
id,
});
}
// A connected peripheral usually stops appearing in scan results, and
// the trainer must not vanish from the list the moment it is in use.
if let Some(address) = trainer.address.clone() {
let existing = out.iter().position(|d| d.id == address);
let idx = match existing {
Some(i) => i,
None => {
out.push(DeviceInfo {
id: address.clone(),
name: trainer.name.clone().unwrap_or_else(|| "Trainer".into()),
address,
rssi: 0,
kind: DeviceKind::Trainer,
state: ConnectionState::Idle,
control_acquired: false,
services: vec![describe_service(uuids::FITNESS_MACHINE_SERVICE)],
remembered: true,
battery_pct: None,
unlock_expires_in_s: None,
error: None,
});
out.len() - 1
}
};
let device = &mut out[idx];
device.kind = DeviceKind::Trainer;
device.state = trainer.state.clone();
device.control_acquired = trainer.control_acquired;
device.remembered = true;
device.error = trainer.error.clone().or_else(|| {
trainer
.stale
.then(|| "Connected but sending no data — pedal to wake it".to_string())
});
if let Some(name) = &trainer.name {
device.name = name.clone();
}
}
for d in &mut self.devices {
if let Some((remaining, target, control)) = d.pending.take() {
if remaining <= 1 {
d.info.state = target.clone();
d.info.control_acquired = control;
if target == ConnectionState::Connected && d.info.kind == DeviceKind::Trainer {
// Connected, now go after the FTMS control point.
d.pending =
Some((CONTROL_TICKS, ConnectionState::Controlling, true));
}
transitions.push(d.info.clone());
changed = true;
} else {
d.pending = Some((remaining - 1, target, control));
}
}
}
PollResult { changed, transitions }
// 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| {
(a.kind != DeviceKind::Trainer)
.cmp(&(b.kind != DeviceKind::Trainer))
.then(b.rssi.cmp(&a.rssi))
.then(a.id.cmp(&b.id))
});
out
}
pub fn list(&self) -> Vec<DeviceInfo> {
self.devices
.iter()
.filter(|d| d.visible && !self.forgotten.contains(&d.info.id))
.map(|d| d.info.clone())
.collect()
self.published.clone()
}
pub fn get(&self, id: &str) -> Option<DeviceInfo> {
self.devices.iter().find(|d| d.info.id == id).map(|d| d.info.clone())
self.published.iter().find(|d| d.id == id).cloned()
}
pub fn connect(&mut self, id: &str) -> Result<DeviceInfo, String> {
let device = self
.devices
.iter_mut()
.find(|d| d.info.id == id)
.get(id)
.ok_or_else(|| format!("no such device: {id}"))?;
if device.info.state == ConnectionState::Controlling {
return Err(format!("{} is already connected", device.info.name));
if device.kind != DeviceKind::Trainer {
return Err(format!(
"{} is not a trainer. Zwift Click support is Phase 3 (REQUIREMENTS.md §5.3).",
device.name
));
}
device.info.error = None;
device.info.state = ConnectionState::Connecting;
device.info.remembered = true;
device.pending = Some((CONNECT_TICKS, ConnectionState::Connected, false));
Ok(device.info.clone())
// A second click on an already-connected trainer must not tear the
// working session down and start over. Connecting takes ~8 s against
// this hardware, which is easily long enough for an impatient rider to
// click again and destroy the connection they were waiting for.
let status = self.trainer.status();
if status.address.as_deref() == Some(device.address.as_str()) {
match status.state {
ConnectionState::Connecting | ConnectionState::Scanning => {
return Err(format!("Already connecting to {}", device.name))
}
ConnectionState::Reconnecting => {
return Err(format!("Reconnecting to {} — hold on.", device.name))
}
ConnectionState::Connected | ConnectionState::Controlling => {
return Err(format!(
"{} is already connected. Disconnect first to start over.",
device.name
))
}
ConnectionState::Idle | ConnectionState::Lost { .. } => {}
}
}
// Our own scan and the client's `find_peripheral` would otherwise fight
// over the one adapter. Suspended, not stopped: `poll` puts it back as
// soon as the trainer is no longer attached (FR-1.12).
self.set_scanning(false);
self.scan_suspended = true;
self.remembered.insert(id.to_string());
self.forgotten.remove(id);
self.trainer
.connect(scan::TrainerSelector::Address(device.address.clone()));
let mut info = device;
info.state = ConnectionState::Connecting;
info.control_acquired = false;
info.error = None;
info.remembered = true;
Ok(info)
}
pub fn disconnect(&mut self, id: &str) -> Result<DeviceInfo, String> {
let device = self
.devices
.iter_mut()
.find(|d| d.info.id == id)
let mut device = self
.get(id)
.ok_or_else(|| format!("no such device: {id}"))?;
device.pending = None;
device.info.control_acquired = false;
device.info.state = if self.scanning { ConnectionState::Scanning } else { ConnectionState::Idle };
Ok(device.info.clone())
if device.kind == DeviceKind::Trainer {
// SAF-2 runs inside the supervisor before the link drops.
self.trainer.disconnect();
}
device.state = ConnectionState::Idle;
device.control_acquired = false;
Ok(device)
}
pub fn forget(&mut self, id: &str) -> Result<(), String> {
let device = self
.devices
.iter_mut()
.find(|d| d.info.id == id)
.ok_or_else(|| format!("no such device: {id}"))?;
device.pending = None;
device.info.remembered = false;
device.info.control_acquired = false;
device.info.state = ConnectionState::Idle;
device.visible = false;
let device = self.get(id).ok_or_else(|| format!("no such device: {id}"))?;
if device.kind == DeviceKind::Trainer && device.control_acquired {
self.trainer.disconnect();
}
self.remembered.remove(id);
self.forgotten.insert(id.to_string());
self.published.retain(|d| d.id != id);
Ok(())
}
/// True once a trainer is connected *and* controllable — the precondition
/// for a real ride (FR-2.1).
pub fn trainer_controllable(&self) -> bool {
self.devices
.iter()
.any(|d| d.info.kind == DeviceKind::Trainer && d.info.control_acquired)
self.trainer.status().controllable()
}
pub fn trainer_status(&self) -> TrainerStatus {
self.trainer.status()
}
}
fn device(
id: &str,
name: &str,
address: &str,
rssi: i16,
kind: DeviceKind,
services: &[&str],
appears_after: u32,
) -> Simulated {
Simulated {
info: DeviceInfo {
/// FR-1.2. FTMS is checked first: the D100 advertises the Zwift custom service
/// too, so "is a Zwift device" is not enough to call something a Click.
fn classify(d: &DiscoveredDevice) -> DeviceKind {
if d.is_fitness_machine() {
return DeviceKind::Trainer;
}
if d.services.contains(&HEART_RATE_SERVICE) {
return DeviceKind::HeartRate;
}
if d.services.contains(&ZWIFT_SERVICE) || d.is_zwift_device() {
// Splitting left pod from right needs the Zwift manufacturer-data type
// byte, which is Phase 3 and unverified against this hardware. Guessing
// would put a wrong label on the connection screen, so it stays Unknown
// and the service UUID is listed instead.
return DeviceKind::Unknown;
}
DeviceKind::Unknown
}
fn describe_service(uuid: Uuid) -> String {
match uuids::well_known_name(uuid) {
Some(name) => format!("{uuid} ({name})"),
None if uuid == ZWIFT_SERVICE => format!("{uuid} (Zwift custom)"),
None => uuid.to_string(),
}
}
/// May a scan we suspended for a connect be switched back on?
///
/// FR-1.12. Split out from [`DeviceRegistry::poll`] so the rule is checkable
/// without a radio. Only a suspension of *ours* is resumed — a rider who
/// pressed Stop scan meant it.
fn should_resume_scan(suspended: bool, trainer: &TrainerStatus) -> bool {
suspended && !trainer.is_attached()
}
/// Devices whose connection state or control acquisition changed (FR-1.7).
fn state_transitions(before: &[DeviceInfo], after: &[DeviceInfo]) -> Vec<DeviceInfo> {
after
.iter()
.filter(|d| match before.iter().find(|p| p.id == d.id) {
None => d.state != ConnectionState::Scanning && d.state != ConnectionState::Idle,
Some(prev) => prev.state != d.state || prev.control_acquired != d.control_acquired,
})
.cloned()
.collect()
}
/// Drive the radio. Runs for the life of the process; a failure to get an
/// adapter is reported through the snapshot rather than killing the task, so
/// plugging a dongle in later recovers on its own (NFR-4).
async fn scan_loop(mut on: watch::Receiver<bool>, tx: watch::Sender<ScanSnapshot>) {
let mut generation = 0u64;
loop {
if !*on.borrow() {
// Wait to be switched on rather than spinning.
if on.changed().await.is_err() {
return;
}
continue;
}
let adapter = match scan::default_adapter().await {
Ok(a) => a,
Err(e) => {
tracing::warn!(error = %e, "no Bluetooth adapter");
generation += 1;
let _ = tx.send(ScanSnapshot {
devices: Vec::new(),
error: Some(format!("{e}. Check the radio is on and BlueZ is running.")),
generation,
});
tokio::time::sleep(Duration::from_secs(2)).await;
continue;
}
};
// FR-1.1 lists every peripheral, not only fitness machines: a trainer
// is not obliged to advertise FTMS, and the rider needs to see what is
// in the room to know the scan is working at all.
let result = scan::scan(&adapter, SCAN_WINDOW, ScanKind::All).await;
generation += 1;
let snapshot = match result {
Ok(devices) => ScanSnapshot { devices, error: None, generation },
Err(e) => {
tracing::warn!(error = %e, "scan failed");
ScanSnapshot {
devices: Vec::new(),
error: Some(e.to_string()),
generation,
}
}
};
let _ = tx.send(snapshot);
tokio::time::sleep(IDLE_POLL).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn info(id: &str, state: ConnectionState, control: bool) -> DeviceInfo {
DeviceInfo {
id: id.into(),
name: name.into(),
address: address.into(),
rssi,
kind,
state: ConnectionState::Idle,
control_acquired: false,
services: services.iter().map(|s| s.to_string()).collect(),
name: id.into(),
address: id.into(),
rssi: -50,
kind: DeviceKind::Trainer,
state,
control_acquired: control,
services: Vec::new(),
remembered: false,
battery_pct: match kind {
DeviceKind::ClickLeft => Some(78),
DeviceKind::ClickRight => Some(64),
DeviceKind::HeartRate => Some(91),
_ => None,
},
unlock_expires_in_s: match kind {
DeviceKind::ClickLeft => Some(0),
DeviceKind::ClickRight => Some(41_400),
_ => None,
},
battery_pct: None,
unlock_expires_in_s: None,
error: None,
},
appears_after,
pending: None,
visible: false,
}
}
#[test]
fn acquiring_control_is_a_transition_even_though_the_state_is_unchanged() {
// FR-9.3: Connected → Controlling *and* Connected-with-control are both
// events the UI must see.
let before = vec![info("t", ConnectionState::Connected, false)];
let after = vec![info("t", ConnectionState::Connected, true)];
let t = state_transitions(&before, &after);
assert_eq!(t.len(), 1);
assert!(t[0].control_acquired);
}
#[test]
fn a_device_merely_appearing_in_a_scan_is_not_a_transition() {
let after = vec![info("t", ConnectionState::Scanning, false)];
assert!(state_transitions(&[], &after).is_empty());
let after = vec![info("t", ConnectionState::Idle, false)];
assert!(state_transitions(&[], &after).is_empty());
}
#[test]
fn a_device_that_appears_already_connected_is_a_transition() {
let after = vec![info("t", ConnectionState::Controlling, true)];
assert_eq!(state_transitions(&[], &after).len(), 1);
}
#[test]
fn a_lost_link_is_reported() {
let before = vec![info("t", ConnectionState::Controlling, true)];
let after = vec![info("t", ConnectionState::Lost { reason: "gone".into() }, false)];
let t = state_transitions(&before, &after);
assert_eq!(t.len(), 1);
assert!(!t[0].control_acquired);
}
#[test]
fn an_unchanged_list_produces_no_transitions() {
let list = vec![info("t", ConnectionState::Controlling, true)];
assert!(state_transitions(&list, &list).is_empty());
}
#[test]
fn a_scan_suspended_for_a_connect_comes_back_when_the_connect_is_over() {
// FR-1.12. Connecting switches the scan off so it does not fight
// `find_peripheral` over the one adapter, and before this nothing ever
// switched it back on: a disconnect or a failed connect left the device
// list frozen on a snapshot taken before the attempt.
let idle = TrainerStatus::default();
let lost = TrainerStatus {
state: ConnectionState::Lost { reason: "gone".into() },
..TrainerStatus::default()
};
assert!(should_resume_scan(true, &idle));
assert!(should_resume_scan(true, &lost));
// Still in progress, or in use: leave the adapter alone.
let connecting = TrainerStatus {
state: ConnectionState::Connecting,
..TrainerStatus::default()
};
let reconnecting = TrainerStatus {
state: ConnectionState::Reconnecting,
..TrainerStatus::default()
};
let riding = TrainerStatus {
state: ConnectionState::Controlling,
control_acquired: true,
..TrainerStatus::default()
};
assert!(!should_resume_scan(true, &connecting));
assert!(!should_resume_scan(true, &reconnecting));
assert!(!should_resume_scan(true, &riding));
// The rider pressed Stop scan. That is not ours to undo.
assert!(!should_resume_scan(false, &idle));
}
#[test]
fn well_known_services_are_named_and_zwift_is_recognised() {
let ftms = describe_service(uuids::FITNESS_MACHINE_SERVICE);
assert!(ftms.contains("Fitness Machine"), "{ftms}");
let zwift = describe_service(ZWIFT_SERVICE);
assert!(zwift.contains("Zwift"), "{zwift}");
}
}
/// The mock environment. Timings are in registry ticks (2 Hz), so the trainer
/// takes ~2 s to appear and the pods ~46 s — long enough that the "wake it by
/// pedalling" prompt (FR-1.8) is actually visible.
fn catalogue() -> Vec<Simulated> {
vec![
device(
"d100-1",
"Van Rysel D100",
"E4:2B:11:9A:03:7C",
-54,
DeviceKind::Trainer,
&["0x1826 Fitness Machine", "0x180A Device Information"],
4,
),
device(
"click-l",
"Zwift Click (left)",
"C0:1A:77:12:4E:01",
-63,
DeviceKind::ClickLeft,
&["00000001-19CA-4651-86E5-FA29DCDD09D1"],
9,
),
device(
"click-r",
"Zwift Click (right)",
"C0:1A:77:12:4E:02",
-61,
DeviceKind::ClickRight,
&["00000001-19CA-4651-86E5-FA29DCDD09D1"],
11,
),
device(
"hrm-1",
"Wahoo TICKR",
"D9:44:0B:31:88:2A",
-71,
DeviceKind::HeartRate,
&["0x180D Heart Rate"],
14,
),
device(
"unknown-1",
"(unnamed peripheral)",
"7F:22:C4:08:19:E3",
-88,
DeviceKind::Unknown,
&[],
17,
),
]
}
+11 -1
View File
@@ -9,6 +9,7 @@ use serde::Serialize;
use crate::devices::DeviceInfo;
use crate::profile_view::ProfileView;
use crate::trainer::TrainerStatus;
/// `RideSnapshot`, pushed at [`crate::engine::TICK_HZ`].
pub const RIDE_SNAPSHOT: &str = "ride://snapshot";
@@ -24,6 +25,11 @@ pub const DEVICE_CONNECTION: &str = "devices://connection";
pub const APP_NOTICE: &str = "app://notice";
/// Acknowledgement that an input registered, so the UI can flash (FR-9.9).
pub const INPUT_ACK: &str = "app://input-ack";
/// A Zwift Click button edge. The webview routes these to the same intents as
/// the equivalent keypress, so controller and keyboard cannot drift apart.
pub const CONTROLLER_INPUT: &str = "controller://input";
/// Controller link state and battery.
pub const CONTROLLER_STATUS: &str = "controller://status";
/// Ride lifecycle, mirroring `bikecontrol_core::session::RideStatus` but
/// serialisable across the IPC boundary.
@@ -52,8 +58,12 @@ pub struct RideState {
pub lap: u32,
pub laps: Vec<LapSummary>,
pub profile: Option<ProfileView>,
/// Which backend is driving the ride `"mock"` until `crates/ble` lands.
/// Which backend is driving the ride: `"ftms"` (the real trainer) or
/// `"mock"` (the synthetic rider, only reachable via `BIKECONTROL_DEMO`).
pub source: &'static str,
/// Trainer link, so the ride screen can say when the numbers stopped being
/// real rather than quietly showing zeros (FR-1.8, FR-9.3).
pub trainer: TrainerStatus,
}
#[derive(Debug, Clone, Copy, Serialize)]
+20 -6
View File
@@ -7,14 +7,17 @@
pub mod backend;
pub mod commands;
pub mod controller;
pub mod derive;
pub mod devices;
pub mod events;
#[cfg(feature = "mock-ride")]
pub mod mock;
pub mod profile_view;
pub mod samples;
pub mod session_backend;
pub mod state;
pub mod trainer;
use tauri::{Manager, RunEvent, WindowEvent};
@@ -68,6 +71,10 @@ pub fn run() {
commands::disconnect_device,
commands::forget_device,
commands::trainer_controllable,
// controller
commands::controller_status,
commands::connect_controller,
commands::disconnect_controller,
])
.setup(|app| {
let handle = app.handle().clone();
@@ -75,6 +82,7 @@ pub fn run() {
handle.state::<AppState>().lock().devices.start_scan();
state::spawn_ride_loop(handle.clone());
state::spawn_device_loop(handle.clone());
state::spawn_controller_loop(handle.clone());
// `BIKECONTROL_DEMO=1` opens straight onto a running ride with the
// bundled GPX loaded. Purely a development convenience — it makes
// the ride screen reviewable without clicking through first.
@@ -88,12 +96,18 @@ pub fn run() {
.build(tauri::generate_context!())
.expect("failed to start BikeControl")
.run(|app, event| {
// SAF-2 — on any exit path, hand the trainer back at zero load.
if let RunEvent::ExitRequested { .. } = &event {
state::release_trainer(app);
}
if let RunEvent::WindowEvent { event: WindowEvent::Destroyed, .. } = &event {
state::release_trainer(app);
// SAF-2 / SAF-9 — on any exit path, hand the trainer back at zero
// load and close every link the app owns. `shutdown_devices` blocks
// until the reset sequence has actually been written; a
// fire-and-forget send would race the process teardown and leave the
// rider on a loaded trainer. It is idempotent, which matters because
// one quit delivers several of these events.
match &event {
RunEvent::ExitRequested { .. } | RunEvent::Exit => state::shutdown_devices(app),
RunEvent::WindowEvent { event: WindowEvent::Destroyed, .. } => {
state::shutdown_devices(app)
}
_ => {}
}
});
}
+9 -3
View File
@@ -1,5 +1,10 @@
//! A synthetic rider, so the UI can be built and judged before `crates/ble`
//! and `crates/core` are finished.
//! A synthetic rider, so the UI can be built and judged with no trainer on the
//! desk.
//!
//! No longer the default: the app rides `RideSession` on real FTMS telemetry
//! unless `BIKECONTROL_DEMO=1` or `BIKECONTROL_MOCK=1` selects this instead.
//! It is compiled only under the `mock-ride` feature, so a build made with
//! `--no-default-features` cannot show fake data at all.
//!
//! It fabricates plausible power and cadence, then runs them through the §5.7
//! physics equations to get virtual speed, distance and elevation gain. The
@@ -197,7 +202,8 @@ impl RideBackend for MockBackend {
let snapshot = RideSnapshot {
elapsed_ms: self.elapsed_ms,
telemetry,
virtual_speed_kph: self.speed_ms * 3.6,
// Not running means not moving, and the readout must agree.
virtual_speed_kph: if running { self.speed_ms * 3.6 } else { 0.0 },
virtual_distance_m: self.distance_m,
gradient_pct,
elevation_gain_m: self.elevation_gain_m,
+236 -13
View File
@@ -1,15 +1,22 @@
//! The real backend: `bikecontrol_core::RideSession` driven by trainer
//! telemetry.
//!
//! Compiled only under `--features real-session`, because
//! `RideSession::tick`/`snapshot` are still `todo!()` and would panic on the
//! first tick. Enabling the feature (and disabling `mock-ride`) is the whole
//! swap — nothing above [`crate::backend::RideBackend`] changes, and the
//! frontend does not change at all.
#![cfg(feature = "real-session")]
//! This is the app's default data source. It holds the latest decoded Indoor
//! Bike Data sample — published by [`crate::trainer`] from `bikecontrol_ble`'s
//! telemetry stream — and feeds it to the ride engine once per tick.
//!
//! The engine, not this module, owns the physics: FR-7.1/7.5 say virtual speed
//! and distance are computed from *power*, not from the trainer's own speed
//! reading. That is what makes the ride behave correctly when the trainer's
//! speed is wrong, or absent, or (as on the D100) reported in units nobody has
//! confirmed.
//!
//! When no trainer is attached the watch channel carries `Telemetry::default()`
//! — zero power — so the ride coasts to a stop instead of freezing on the last
//! real sample.
use bikecontrol_core::session::{RideSession, SessionEvent};
use bikecontrol_core::types::{ControlTarget, RideSnapshot, Telemetry};
use bikecontrol_core::types::{RideSnapshot, Telemetry};
use tokio::sync::watch;
use crate::backend::{RideBackend, RideInputs, Tick};
@@ -17,9 +24,12 @@ use crate::events::RideStatus;
pub struct SessionBackend {
session: RideSession,
/// Latest decoded Indoor Bike Data, published by `bikecontrol_ble`.
/// Latest decoded Indoor Bike Data, published by [`crate::trainer`].
telemetry: watch::Receiver<Telemetry>,
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.
loaded_profile: Option<usize>,
}
impl SessionBackend {
@@ -28,6 +38,7 @@ impl SessionBackend {
session: RideSession::new(inputs.rider, inputs.limits),
telemetry,
last_snapshot: None,
loaded_profile: None,
}
}
}
@@ -39,15 +50,48 @@ impl RideBackend for SessionBackend {
fn reset(&mut self) {
self.session = RideSession::new(self.session.config, self.session.limits);
self.last_snapshot = None;
self.loaded_profile = None;
}
fn tick(&mut self, dt_s: f32, inputs: &RideInputs) -> Tick {
// Rider intent lives in `RideInputs` (the Tauri commands mutate it);
// the session is told about it each tick rather than being driven
// directly, so there is one source of truth for what the rider asked
// for regardless of which backend is running.
self.session.config = inputs.rider;
self.session.limits = inputs.limits;
self.session.mode = inputs.mode;
if let Some(profile) = inputs.profile.as_deref() {
if self.session.profile().is_none() {
self.session.load_profile(profile.clone());
self.session.set_resistance(inputs.resistance_level);
self.session.set_erg_power(inputs.power_target_w);
match inputs.profile.as_deref() {
Some(profile) => {
// `Arc` identity, not contents: reloading resets the session's
// position, which must not happen every tick.
let id = inputs.profile.as_ref().map(|p| std::sync::Arc::as_ptr(p) as usize);
if self.loaded_profile != id {
self.session.load_profile(profile.clone());
self.session.mode = inputs.mode;
self.loaded_profile = id;
}
}
None => self.loaded_profile = None,
}
// FR-4.2: in ManualGrade the rider's absolute setting *is* the gradient;
// elsewhere the nudge is a trim on top of the profile.
let offset = match inputs.mode {
bikecontrol_core::types::ControlMode::ManualGrade => {
inputs.manual_gradient_pct + inputs.gradient_offset_pct
}
_ => inputs.gradient_offset_pct,
};
self.session.reset_gradient_offset();
self.session.nudge_gradient(offset);
self.session.gearing.set_gear(inputs.gear);
match inputs.status {
RideStatus::Running => self.session.start(),
RideStatus::Paused => self.session.pause(),
@@ -64,12 +108,191 @@ impl RideBackend for SessionBackend {
SessionEvent::ProfileFinished | SessionEvent::Lap { .. } => {}
}
}
let snapshot = snapshot
let mut snapshot = snapshot
.or(self.last_snapshot)
.unwrap_or_else(|| self.session.snapshot(telemetry));
// A ride that is not running is a rider who is not moving, and the
// screen must say so. The engine deliberately keeps the physics state
// across a pause so the ride resumes where it stopped — but reporting
// that held velocity reads as "you are doing 38 km/h" to someone
// standing still, which is the one thing a readout must never do.
// Distance and elapsed already freeze; speed has to go to zero.
if inputs.status != RideStatus::Running {
snapshot.virtual_speed_kph = 0.0;
}
self.last_snapshot = Some(snapshot);
let _: Option<ControlTarget> = command;
Tick { snapshot, command }
}
}
#[cfg(test)]
mod tests {
use super::*;
use bikecontrol_core::types::{ControlMode, ControlTarget};
fn running(mode: ControlMode) -> RideInputs {
RideInputs { status: RideStatus::Running, mode, ..RideInputs::default() }
}
#[test]
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);
assert_eq!(backend.source(), "ftms");
// No power: nothing moves.
for _ in 0..8 {
backend.tick(0.25, &inputs);
}
assert_eq!(backend.tick(0.25, &inputs).snapshot.virtual_distance_m, 0.0);
// 200 W from the trainer: the engine accelerates.
let _ = tx.send(Telemetry { power_w: Some(200), ..Telemetry::default() });
for _ in 0..40 {
backend.tick(0.25, &inputs);
}
let snapshot = backend.tick(0.25, &inputs).snapshot;
assert!(snapshot.virtual_speed_kph > 5.0, "{snapshot:?}");
assert!(snapshot.virtual_distance_m > 10.0, "{snapshot:?}");
assert_eq!(snapshot.telemetry.power_w, Some(200));
}
#[test]
fn losing_the_trainer_coasts_to_a_stop_rather_than_freezing() {
let (tx, rx) = watch::channel(Telemetry { power_w: Some(250), ..Telemetry::default() });
let inputs = running(ControlMode::ManualGrade);
let mut backend = SessionBackend::new(&inputs, rx);
for _ in 0..60 {
backend.tick(0.25, &inputs);
}
let moving = backend.tick(0.25, &inputs).snapshot.virtual_speed_kph;
assert!(moving > 5.0);
// The supervisor zeroes telemetry when the link drops.
let _ = tx.send(Telemetry::default());
for _ in 0..400 {
backend.tick(0.25, &inputs);
}
let stopped = backend.tick(0.25, &inputs).snapshot;
assert!(stopped.virtual_speed_kph < moving, "speed must decay, not hold");
assert_eq!(stopped.telemetry.power_w, None);
}
#[test]
fn the_manual_gradient_reaches_the_trainer() {
let (_tx, rx) = watch::channel(Telemetry::default());
let mut inputs = running(ControlMode::ManualGrade);
inputs.manual_gradient_pct = 5.0;
let mut backend = SessionBackend::new(&inputs, rx);
let tick = backend.tick(0.25, &inputs);
assert_eq!(tick.command, Some(ControlTarget::Gradient { percent: 5.0 }));
assert_eq!(tick.snapshot.gradient_pct, 5.0);
}
#[test]
fn a_trim_stacks_on_the_manual_gradient_and_is_not_applied_twice() {
let (_tx, rx) = watch::channel(Telemetry::default());
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);
for _ in 0..10 {
backend.tick(0.25, &inputs);
}
// Would be 3 + 1.5 * 10 if the nudge accumulated across ticks.
assert_eq!(backend.tick(0.25, &inputs).snapshot.gradient_pct, 4.5);
}
#[test]
fn erg_and_resistance_targets_come_from_rider_intent() {
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);
assert_eq!(
backend.tick(0.25, &inputs).command,
Some(ControlTarget::Power { watts: 275 })
);
let mut inputs = running(ControlMode::Resistance);
inputs.resistance_level = 42;
assert_eq!(
backend.tick(0.25, &inputs).command,
Some(ControlTarget::Resistance { level: 42 })
);
}
#[test]
fn targets_are_clamped_before_they_leave() {
// SAF-3 is enforced by the engine; assert the backend does not bypass it.
let (_tx, rx) = watch::channel(Telemetry::default());
let mut inputs = running(ControlMode::ManualGrade);
inputs.manual_gradient_pct = 400.0;
let mut backend = SessionBackend::new(&inputs, rx);
let tick = backend.tick(0.25, &inputs);
assert_eq!(
tick.command,
Some(ControlTarget::Gradient { percent: inputs.limits.max_gradient_pct })
);
}
#[test]
fn a_paused_ride_commands_nothing() {
// SAF-1: the last target stands; a pause must not push a new load.
let (_tx, rx) = watch::channel(Telemetry { power_w: Some(200), ..Telemetry::default() });
let mut inputs = running(ControlMode::ManualGrade);
let mut backend = SessionBackend::new(&inputs, rx);
backend.tick(0.25, &inputs);
inputs.status = RideStatus::Paused;
inputs.manual_gradient_pct = 9.0;
assert_eq!(backend.tick(0.25, &inputs).command, None);
}
#[test]
fn a_ride_that_is_not_running_reports_no_speed() {
// Regression: elapsed and distance froze on pause but speed held its
// 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 _ = tx.send(Telemetry { power_w: Some(250), ..Telemetry::default() });
for _ in 0..60 {
backend.tick(0.25, &inputs);
}
let moving = backend.tick(0.25, &inputs).snapshot;
assert!(moving.virtual_speed_kph > 10.0, "{moving:?}");
for status in [RideStatus::Paused, RideStatus::Finished, RideStatus::Idle] {
inputs.status = status;
let stopped = backend.tick(0.25, &inputs).snapshot;
assert_eq!(stopped.virtual_speed_kph, 0.0, "{status:?} still showed speed");
// Distance must not be thrown away — the ride resumes where it was.
assert!(stopped.virtual_distance_m >= moving.virtual_distance_m);
}
// And resuming picks the ride back up rather than starting from rest.
inputs.status = RideStatus::Running;
let resumed = backend.tick(0.25, &inputs).snapshot;
assert!(resumed.virtual_speed_kph > 10.0, "{resumed:?}");
}
#[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 _ = tx.send(Telemetry { power_w: Some(300), ..Telemetry::default() });
for _ in 0..40 {
backend.tick(0.25, &inputs);
}
assert!(backend.tick(0.25, &inputs).snapshot.virtual_distance_m > 0.0);
backend.reset();
let snapshot = backend.tick(0.25, &inputs).snapshot;
// One tick from standstill covers centimetres, not the metres just ridden.
assert!(snapshot.virtual_distance_m < 1.0, "{snapshot:?}");
assert_eq!(snapshot.elapsed_ms, 250);
}
}
+218 -21
View File
@@ -8,7 +8,7 @@ use std::sync::{Arc, Mutex};
use std::time::Duration;
use bikecontrol_core::profile::Profile;
use bikecontrol_core::types::{ControlTarget, RideSnapshot};
use bikecontrol_core::types::{ConnectionState, ControlTarget, RideSnapshot};
use tauri::{AppHandle, Emitter, Manager};
use crate::backend::{RideBackend, RideInputs};
@@ -18,8 +18,9 @@ use crate::events::{
ConnectionEvent, DeviceList, InputAck, LapSummary, Notice, RideState, RideStatus,
};
use crate::derive::{Derived, Deriver, RideFrame};
use crate::mock::MockBackend;
use crate::profile_view::{ProfileGeometry, ProfileView};
use crate::session_backend::SessionBackend;
use crate::trainer::{TrainerHandle, TrainerStatus};
/// Snapshot push rate. FTMS notifies at 14 Hz (NFR-2); we publish at the top
/// of that range and the frontend interpolates nothing.
@@ -32,6 +33,8 @@ pub struct Inner {
pub inputs: RideInputs,
pub backend: Box<dyn RideBackend>,
pub devices: DeviceRegistry,
pub trainer: TrainerHandle,
pub controller: crate::controller::ControllerHandle,
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.
@@ -47,12 +50,49 @@ pub struct Inner {
lap_power_n: u64,
}
/// Choose the ride's data source.
///
/// **There is no fallback.** A missing, sleeping or uncontrollable trainer
/// yields zeros, not invented numbers: the ride engine reads
/// `Telemetry::default()` and the screen shows a rider who is not pedalling,
/// which is the truth. Substituting a synthetic rider when the hardware is
/// absent would mean a rider could complete a session and only discover
/// afterwards that none of it happened.
///
/// The synthetic rider is therefore opt-in, deliberately, and only from
/// outside the app: `BIKECONTROL_DEMO=1` (which also loads a route and starts
/// riding) or `BIKECONTROL_MOCK=1`. It exists only under the `mock-ride`
/// feature, so a build made with `--no-default-features` is incapable of
/// showing fake data at all. Whenever it is on, `RideState::source` reports
/// `"mock"` and the ride screen carries a banner that cannot be missed.
fn build_backend(inputs: &RideInputs, trainer: &TrainerHandle) -> Box<dyn RideBackend> {
#[cfg(feature = "mock-ride")]
if std::env::var_os("BIKECONTROL_DEMO").is_some()
|| std::env::var_os("BIKECONTROL_MOCK").is_some()
{
tracing::warn!(
source = "mock",
"BIKECONTROL_DEMO/MOCK is set — this ride is a SIMULATION. Power, speed and \
distance are fabricated and nothing is being read from a trainer."
);
return Box::new(crate::mock::MockBackend::default());
}
tracing::info!(
source = "ftms",
"ride data source is the trainer; with no trainer attached the ride reads zero"
);
Box::new(SessionBackend::new(inputs, trainer.telemetry()))
}
impl Inner {
fn new() -> Self {
fn new(trainer: TrainerHandle, controller: crate::controller::ControllerHandle) -> Self {
let inputs = RideInputs::default();
Self {
inputs: RideInputs::default(),
backend: Box::new(MockBackend::default()),
devices: DeviceRegistry::new(),
backend: build_backend(&inputs, &trainer),
devices: DeviceRegistry::new(trainer.clone()),
inputs,
trainer,
controller,
profile_view: None,
geometry: None,
deriver: Deriver::default(),
@@ -80,6 +120,7 @@ impl Inner {
laps: self.laps.clone(),
profile: self.profile_view.clone(),
source: self.backend.source(),
trainer: self.trainer.status(),
}
}
@@ -148,9 +189,13 @@ impl Inner {
}
}
let profile = self.inputs.profile.clone();
let derived =
self.deriver
.update(snapshot, running, profile.as_deref(), self.geometry.as_ref());
let derived = self.deriver.update(
snapshot,
running,
self.inputs.rider.rider_kg,
profile.as_deref(),
self.geometry.as_ref(),
);
self.last_derived = Some(derived);
derived
}
@@ -167,7 +212,21 @@ impl Default for AppState {
impl AppState {
pub fn new() -> Self {
Self(Arc::new(Mutex::new(Inner::new())))
let limits = RideInputs::default().limits;
let trainer = TrainerHandle::spawn(crate::trainer::app_config(limits));
let controller = crate::controller::ControllerHandle::spawn();
Self(Arc::new(Mutex::new(Inner::new(trainer, controller))))
}
/// The trainer supervisor handle, for callers outside the lock (SAF-2 at
/// exit must not hold the mutex while it waits on the radio).
pub fn trainer(&self) -> TrainerHandle {
self.lock().trainer.clone()
}
/// The controller supervisor handle.
pub fn controller(&self) -> crate::controller::ControllerHandle {
self.lock().controller.clone()
}
/// Panics are impossible to recover from here, and a poisoned lock means
@@ -203,6 +262,60 @@ pub fn ack(app: &AppHandle, action: &str, detail: Option<String>) {
let _ = app.emit(events::INPUT_ACK, InputAck { action: action.into(), detail });
}
/// Forward controller button edges and link status to the webview.
///
/// The backend deliberately does **not** decide what a button means. It reports
/// "`plus` was pressed"; the webview routes that to the same intent as the
/// matching keypress. One input map, not two that can drift (§4.3).
pub fn spawn_controller_loop(app: AppHandle) {
tauri::async_runtime::spawn(async move {
let controller = app.state::<AppState>().controller();
let mut inputs = controller.inputs();
let mut status = controller.status_watch();
loop {
tokio::select! {
input = inputs.recv() => match input {
Ok(input) => {
// The paddles shift the virtual gear (FR-4.1, OQ-1).
// Done here rather than in the webview so gearing keeps
// working with the window unfocused or minimised.
if input.pressed {
let delta = match input.button {
"plus" => 1i32,
"minus" => -1,
_ => 0,
};
if delta != 0 {
let state = app.state::<AppState>();
let mut inner = state.lock();
let next = (inner.inputs.gear as i32 + delta).max(1);
inner.inputs.gear = next as usize;
drop(inner);
emit_ride_state(&app);
}
}
let _ = app.emit(events::CONTROLLER_INPUT, input);
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
// Dropping a *release* edge would latch a button on, so
// this is worth saying out loud rather than swallowing.
tracing::warn!("controller: webview missed {n} button edge(s)");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
},
changed = status.changed() => {
if changed.is_err() {
return;
}
let payload = status.borrow_and_update().clone();
let _ = app.emit(events::CONTROLLER_STATUS, payload);
}
}
}
});
}
/// The ride loop. One tick: advance the backend, publish the snapshot, and
/// transmit the (already clamped) target to the trainer.
pub fn spawn_ride_loop(app: AppHandle) {
@@ -210,6 +323,7 @@ pub fn spawn_ride_loop(app: AppHandle) {
let mut interval = tokio::time::interval(Duration::from_millis(TICK_MS));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let dt_s = TICK_MS as f32 / 1000.0;
let mut n: u64 = 0;
loop {
interval.tick().await;
let (frame, command) = {
@@ -221,6 +335,25 @@ pub fn spawn_ride_loop(app: AppHandle) {
let derived = inner.absorb(&tick.snapshot);
(RideFrame { snapshot: tick.snapshot, derived }, tick.command)
};
n += 1;
if n % TICK_HZ == 0 {
let s = &frame.snapshot;
tracing::debug!(
elapsed_ms = s.elapsed_ms,
speed_kph = s.virtual_speed_kph,
distance_m = s.virtual_distance_m,
gradient = s.gradient_pct,
power = ?s.telemetry.power_w,
// The trainer's OWN speed, straight from Indoor Bike Data,
// alongside the speed our physics computed. Divergence
// between the two is the fastest way to tell a physics
// problem from a telemetry problem.
trainer_kph = ?s.telemetry.speed_kph,
cadence = ?s.telemetry.cadence_rpm,
?command,
"ride tick"
);
}
let _ = app.emit(events::RIDE_SNAPSHOT, frame);
if let Some(target) = command {
transmit(&app, target);
@@ -229,11 +362,16 @@ pub fn spawn_ride_loop(app: AppHandle) {
});
}
/// Where the FTMS control-point write will go. Until `crates/ble` exists this
/// only logs — but every target already passed `SafetyLimits::clamp` before it
/// got here (SAF-3), so wiring the real write is a one-line change.
fn transmit(_app: &AppHandle, target: ControlTarget) {
tracing::debug!(?target, "control target (no trainer attached — mock backend)");
/// Push a target to the trainer's FTMS control point.
///
/// Every target has already passed `SafetyLimits::clamp` in the ride engine,
/// and `bikecontrol_ble` clamps again against the trainer's own reported ranges
/// at the point of transmission (SAF-3, FR-2.6). Non-blocking: the BLE layer
/// rate-limits to 4 Hz and coalesces, so the ride loop never waits on a radio.
fn transmit(app: &AppHandle, target: ControlTarget) {
let state = app.state::<AppState>();
let trainer = state.lock().trainer.clone();
trainer.set_target(target);
}
/// Load the bundled GPX and start riding it. Development only — see the
@@ -260,16 +398,41 @@ pub fn start_demo(app: &AppHandle) {
inner.inputs.status = RideStatus::Running;
}
/// SAF-2: never leave the trainer loaded. Called on ride end and on app exit.
/// SAF-2 at the end of a ride: zero gradient / minimum resistance, link kept so
/// the next ride does not have to reconnect.
pub fn release_trainer(app: &AppHandle) {
let state = app.state::<AppState>();
let limits = state.lock().inputs.limits;
let safe = limits.clamp(ControlTarget::Gradient { percent: 0.0 });
tracing::info!(?safe, "releasing trainer (SAF-2)");
transmit(app, safe);
let (trainer, limits) = {
let inner = state.lock();
(inner.trainer.clone(), inner.inputs.limits)
};
tracing::info!("releasing trainer to zero load (SAF-2)");
trainer.release(limits);
}
/// The scan loop: advances the device mock and pushes the list when it changes.
/// Close every BLE link the app owns, on the way out.
///
/// The trainer first and with the full SAF-2 reset — zero gradient, minimum
/// resistance, `Reset`, `Stop` — then the controller, which needs no reset but
/// does need disconnecting (SAF-9): a link the process merely abandons can
/// leave the peripheral held and unreachable on the next launch.
///
/// Blocks. That is the point: a fire-and-forget send races the process exit and
/// leaves the trainer loaded, which is exactly the failure SAF-2 exists to
/// prevent. Both supervisors abandon whatever connect or reconnect is in flight
/// rather than finish it, so the wait is bounded by their own budgets and not by
/// the radio (FR-1.10, NFR-9). The lock is released before waiting so the ride
/// loop can finish.
pub fn shutdown_devices(app: &AppHandle) {
let Some(state) = app.try_state::<AppState>() else { return };
let (trainer, controller) = (state.trainer(), state.controller());
trainer.shutdown_blocking();
controller.shutdown_blocking();
}
/// The scan loop: refreshes the device list from the radio and pushes it when
/// it changes, and turns trainer state changes into notices the rider can act
/// on (FR-1.7, FR-1.8, FR-9.2).
pub fn spawn_device_loop(app: AppHandle) {
tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_millis(SCAN_TICK_MS));
@@ -292,9 +455,43 @@ pub fn spawn_device_loop(app: AppHandle) {
},
);
}
if let Some(status) = result.trainer_changed {
if let Some(notice) = trainer_notice(&status) {
notify(&app, notice);
}
emit_ride_state(&app);
}
if result.changed {
emit_devices(&app);
}
}
});
}
/// Say what is wrong, in words, whenever the trainer link changes (FR-1.8,
/// FR-9.4). Silence is the one thing that is not allowed: a rider staring at
/// zeros must be told whether the trainer is missing, asleep or refusing
/// control.
fn trainer_notice(status: &TrainerStatus) -> Option<Notice> {
let name = status.name.clone().unwrap_or_else(|| "Trainer".into());
match &status.state {
ConnectionState::Connecting => Some(Notice::info(format!("Connecting to {name}"))),
ConnectionState::Connected => Some(Notice::warn(format!(
"{name} connected but control is not acquired — it will not respond to targets yet"
))),
ConnectionState::Controlling if status.stale => Some(Notice::warn(format!(
"{name} is connected but sending no data — turn the cranks to wake it"
))),
ConnectionState::Controlling => match &status.error {
Some(error) => Some(Notice::error(format!("{name}: {error}"))),
None => Some(Notice::info(format!("{name} connected — control acquired"))),
},
ConnectionState::Reconnecting => Some(Notice::warn(format!(
"Lost {name} — reconnecting. The ride continues; pedal to wake the trainer."
))),
ConnectionState::Lost { reason } => Some(Notice::error(
status.error.clone().unwrap_or_else(|| format!("{name} unavailable: {reason}")),
)),
ConnectionState::Idle | ConnectionState::Scanning => None,
}
}
+864
View File
@@ -0,0 +1,864 @@
//! 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.
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}");
}
}