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
+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,
}
}