Ride the drivetrain, command the load in watts
Speed now comes from the drivetrain and the load from the road, which is the way round a bike actually works. Speed is cadence x development, filtered lightly. Power, not cadence, decides whether the rider is driving it: on a direct-drive trainer the flywheel keeps the cranks turning after they stop, so cadence alone reads a healthy 80 rpm for someone doing nothing. Below 15 W the speed runs down to whatever the gradient sustains on no power - zero uphill, a real freewheeling speed on a descent. Stopping on a 3.5% climb used to settle at 22 km/h and stay there, because the model wanted to decelerate and a blend toward the flywheel speed outvoted it; that blend is gone. The D100 sends no cadence over FTMS - it is a rebadged Magene T110 with cadence disabled in firmware (qdomyos-zwift#3282) - so it is inferred from wheel speed, which one sprocket and no freewheel make exact. Its Zwift channel does carry cadence, and is now greeted with RideOn and subscribed on every notifying characteristic, so a measured value is used where one arrives. The load is commanded as power, not gradient. The trainer declares 50-600 W in 1 W steps against 0-6% inclination in 0.1% steps refusing negatives, and whether it acts on 0x11 at all is still unconfirmed. Its power target is a ceiling rather than a setpoint, which is very nearly what a road is: exceed it and the surplus becomes speed. Gravity travels on the same channel as watts, so nothing is lost by leaving 0x11 alone. LoadChannel keeps the gradient path selectable and tested. Virtual shifting reaches the trainer for the first time. The physics load model was written but never called, and a paddle press both shifted a gear in Rust and nudged the gradient in the webview - the shift silently, the tilt visibly, so the paddles looked like a gradient trim. Also: a fixed 12 W drivetrain loss, held as a power because that is how it presents; crank length, so a gear can be reported as the force it puts under the foot; gear and pedal force on the ride screen; a drag-race profile for testing gearing on the flat. Two readout bugs fixed on the way. The rolling windows were trimmed by timestamp but fed on a fixed timer, so every second spent on the ride screen before starting pushed samples at t=0 that could never expire - speed read a fraction of the truth for the first 45 s. And the headline speed was a 45 s mean, which took most of a minute to show a gear change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+163
-68
@@ -12,13 +12,15 @@ use bikecontrol_core::types::{ConnectionState, ControlTarget, RideSnapshot};
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
use crate::backend::{RideBackend, RideInputs};
|
||||
use crate::controller::{ControllerStatus, PodState};
|
||||
use crate::derive::{Derived, Deriver, RideFrame};
|
||||
use crate::devices::DeviceRegistry;
|
||||
use crate::events;
|
||||
use crate::events::{
|
||||
ConnectionEvent, DeviceList, InputAck, LapSummary, Notice, RideState, RideStatus,
|
||||
};
|
||||
use crate::derive::{Derived, Deriver, RideFrame};
|
||||
use crate::profile_view::{ProfileGeometry, ProfileView};
|
||||
use crate::recording::{RecorderHandle, Recovered, RideSummary};
|
||||
use crate::session_backend::SessionBackend;
|
||||
use crate::trainer::{TrainerHandle, TrainerStatus};
|
||||
|
||||
@@ -44,43 +46,28 @@ pub struct Inner {
|
||||
pub last_derived: Option<Derived>,
|
||||
pub lap_index: u32,
|
||||
pub laps: Vec<LapSummary>,
|
||||
/// The most recently finished ride, kept so the summary screen survives a
|
||||
/// webview reload (FR-9.13). Cleared when the next ride starts.
|
||||
pub last_summary: Option<RideSummary>,
|
||||
/// Rides rebuilt from an orphaned journal at startup (FR-8.4), held until
|
||||
/// the webview asks for them.
|
||||
pub recovered: Vec<Recovered>,
|
||||
lap_start_ms: u64,
|
||||
lap_start_m: f64,
|
||||
lap_power_sum: f64,
|
||||
lap_power_n: u64,
|
||||
}
|
||||
|
||||
/// Choose the ride's data source.
|
||||
/// Build 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.
|
||||
/// **There is one, and it is the trainer.** 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. There is deliberately no synthetic rider to
|
||||
/// fall back to — a session a rider could finish and only then discover none of
|
||||
/// it happened is worse than no session at all.
|
||||
fn build_backend(inputs: &RideInputs, trainer: &TrainerHandle) -> Box<dyn RideBackend> {
|
||||
#[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"
|
||||
);
|
||||
tracing::info!("ride data source is the trainer; with no trainer attached the ride reads zero");
|
||||
Box::new(SessionBackend::new(inputs, trainer.telemetry()))
|
||||
}
|
||||
|
||||
@@ -89,7 +76,7 @@ impl Inner {
|
||||
let inputs = RideInputs::default();
|
||||
Self {
|
||||
backend: build_backend(&inputs, &trainer),
|
||||
devices: DeviceRegistry::new(trainer.clone()),
|
||||
devices: DeviceRegistry::new(trainer.clone(), controller.clone()),
|
||||
inputs,
|
||||
trainer,
|
||||
controller,
|
||||
@@ -100,6 +87,8 @@ impl Inner {
|
||||
last_derived: None,
|
||||
lap_index: 1,
|
||||
laps: Vec::new(),
|
||||
last_summary: None,
|
||||
recovered: Vec::new(),
|
||||
lap_start_ms: 0,
|
||||
lap_start_m: 0.0,
|
||||
lap_power_sum: 0.0,
|
||||
@@ -116,10 +105,11 @@ impl Inner {
|
||||
manual_gradient_pct: self.inputs.manual_gradient_pct,
|
||||
resistance_level: self.inputs.resistance_level,
|
||||
power_target_w: self.inputs.power_target_w,
|
||||
gear: self.inputs.gear,
|
||||
gear_count: self.inputs.gear_count(),
|
||||
lap: self.lap_index,
|
||||
laps: self.laps.clone(),
|
||||
profile: self.profile_view.clone(),
|
||||
source: self.backend.source(),
|
||||
trainer: self.trainer.status(),
|
||||
}
|
||||
}
|
||||
@@ -171,6 +161,7 @@ impl Inner {
|
||||
self.inputs.status = RideStatus::Idle;
|
||||
self.inputs.gradient_offset_pct = 0.0;
|
||||
self.last_snapshot = None;
|
||||
self.last_summary = None;
|
||||
self.lap_index = 1;
|
||||
self.laps.clear();
|
||||
self.lap_start_ms = 0;
|
||||
@@ -202,7 +193,12 @@ impl Inner {
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState(Arc<Mutex<Inner>>);
|
||||
pub struct AppState {
|
||||
inner: Arc<Mutex<Inner>>,
|
||||
/// The live recording, behind its own lock so that journal `fsync`s never
|
||||
/// block a command waiting on `inner` — see [`crate::recording`].
|
||||
recorder: RecorderHandle,
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
fn default() -> Self {
|
||||
@@ -215,7 +211,15 @@ impl AppState {
|
||||
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))))
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(Inner::new(trainer, controller))),
|
||||
recorder: RecorderHandle::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The ride recorder. Never call this while holding [`AppState::lock`].
|
||||
pub fn recorder(&self) -> RecorderHandle {
|
||||
self.recorder.clone()
|
||||
}
|
||||
|
||||
/// The trainer supervisor handle, for callers outside the lock (SAF-2 at
|
||||
@@ -232,7 +236,7 @@ impl AppState {
|
||||
/// Panics are impossible to recover from here, and a poisoned lock means
|
||||
/// the ride loop already died — surface it rather than hide it.
|
||||
pub fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
|
||||
self.0.lock().unwrap_or_else(|e| e.into_inner())
|
||||
self.inner.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +263,13 @@ pub fn notify(app: &AppHandle, notice: Notice) {
|
||||
|
||||
/// Confirm an input registered so the UI can flash the control (FR-9.9).
|
||||
pub fn ack(app: &AppHandle, action: &str, detail: Option<String>) {
|
||||
let _ = app.emit(events::INPUT_ACK, InputAck { action: action.into(), detail });
|
||||
let _ = app.emit(
|
||||
events::INPUT_ACK,
|
||||
InputAck {
|
||||
action: action.into(),
|
||||
detail,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Forward controller button edges and link status to the webview.
|
||||
@@ -272,6 +282,7 @@ pub fn spawn_controller_loop(app: AppHandle) {
|
||||
let controller = app.state::<AppState>().controller();
|
||||
let mut inputs = controller.inputs();
|
||||
let mut status = controller.status_watch();
|
||||
let mut previous = controller.status();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -280,6 +291,13 @@ pub fn spawn_controller_loop(app: AppHandle) {
|
||||
// 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.
|
||||
//
|
||||
// The webview must therefore NOT also act on these two
|
||||
// buttons. It used to, and the result was that one
|
||||
// paddle press both shifted a gear and tilted the road
|
||||
// — the gear silently, the gradient visibly, so the
|
||||
// paddles looked like a gradient trim and virtual
|
||||
// shifting looked broken.
|
||||
if input.pressed {
|
||||
let delta = match input.button {
|
||||
"plus" => 1i32,
|
||||
@@ -288,10 +306,7 @@ pub fn spawn_controller_loop(app: AppHandle) {
|
||||
};
|
||||
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);
|
||||
state.lock().inputs.shift_gear(delta);
|
||||
emit_ride_state(&app);
|
||||
}
|
||||
}
|
||||
@@ -309,6 +324,13 @@ pub fn spawn_controller_loop(app: AppHandle) {
|
||||
return;
|
||||
}
|
||||
let payload = status.borrow_and_update().clone();
|
||||
// Each pod speaks for itself (FR-1.4, FR-9.4): one pod
|
||||
// arriving must not read as "the controller is connected"
|
||||
// when the other is still missing.
|
||||
for notice in pod_notices(&previous, &payload) {
|
||||
notify(&app, notice);
|
||||
}
|
||||
previous = payload.clone();
|
||||
let _ = app.emit(events::CONTROLLER_STATUS, payload);
|
||||
}
|
||||
}
|
||||
@@ -316,6 +338,39 @@ pub fn spawn_controller_loop(app: AppHandle) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Say what changed about each pod, in words (FR-1.8, FR-9.4).
|
||||
///
|
||||
/// Only *state* changes speak. A battery reading or a button count arriving
|
||||
/// would otherwise raise a toast every few seconds, and the one message that
|
||||
/// matters — a pod that has gone — would be buried in them.
|
||||
fn pod_notices(before: &ControllerStatus, after: &ControllerStatus) -> Vec<Notice> {
|
||||
let mut out = Vec::new();
|
||||
for (was, now) in [(&before.minus, &after.minus), (&before.plus, &after.plus)] {
|
||||
if was.state == now.state {
|
||||
continue;
|
||||
}
|
||||
let pod = now.symbol;
|
||||
out.push(match now.state {
|
||||
PodState::Connected => Notice::info(format!("{pod} pod connected")),
|
||||
PodState::Reconnecting => Notice::warn(format!(
|
||||
"Lost the {pod} pod — reconnecting. Press a button on it to wake it."
|
||||
)),
|
||||
PodState::GaveUp => Notice::error(
|
||||
now.error
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("Stopped looking for the {pod} pod")),
|
||||
),
|
||||
// Searching is visible on the card, and Idle after a deliberate
|
||||
// disconnect is already acknowledged by the command that did it.
|
||||
PodState::Searching | PodState::Idle => match &now.error {
|
||||
Some(error) => Notice::warn(error.clone()),
|
||||
None => continue,
|
||||
},
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
@@ -326,15 +381,48 @@ pub fn spawn_ride_loop(app: AppHandle) {
|
||||
let mut n: u64 = 0;
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let (frame, command) = {
|
||||
let (frame, command, altitude_m, status) = {
|
||||
let state = app.state::<AppState>();
|
||||
let mut inner = state.lock();
|
||||
let inputs = inner.inputs.clone();
|
||||
let tick = inner.backend.tick(dt_s, &inputs);
|
||||
inner.last_snapshot = Some(tick.snapshot);
|
||||
let derived = inner.absorb(&tick.snapshot);
|
||||
(RideFrame { snapshot: tick.snapshot, derived }, tick.command)
|
||||
// A loaded route knows the rider's true altitude; without one
|
||||
// the recorder integrates elevation gain instead.
|
||||
let altitude_m = inner.geometry.as_ref().and_then(|g| {
|
||||
let x = crate::profile_view::position_x(
|
||||
g,
|
||||
tick.snapshot.elapsed_ms as f64 / 1000.0,
|
||||
tick.snapshot.virtual_distance_m,
|
||||
);
|
||||
g.elevation_at(x)
|
||||
});
|
||||
let status = inner.inputs.status;
|
||||
(
|
||||
RideFrame {
|
||||
snapshot: tick.snapshot,
|
||||
derived,
|
||||
},
|
||||
tick.command,
|
||||
altitude_m,
|
||||
status,
|
||||
)
|
||||
};
|
||||
// NFR-11 — the screensaver follows the ride, not the window. Done
|
||||
// here rather than in the commands because a ride can also end on
|
||||
// its own (a profile running out), and this is the one place that
|
||||
// sees every status however it changed. Outside the lock: on a
|
||||
// transition it makes a D-Bus round trip.
|
||||
crate::wakelock::sync(status);
|
||||
// Journalling happens outside the ride-state lock, deliberately: the
|
||||
// recorder fsyncs periodically and holding `inner` across the disk
|
||||
// would stall every command behind it.
|
||||
if status == RideStatus::Running {
|
||||
app.state::<AppState>()
|
||||
.recorder()
|
||||
.record(&frame.snapshot, altitude_m);
|
||||
}
|
||||
n += 1;
|
||||
if n % TICK_HZ == 0 {
|
||||
let s = &frame.snapshot;
|
||||
@@ -350,6 +438,14 @@ pub fn spawn_ride_loop(app: AppHandle) {
|
||||
// problem from a telemetry problem.
|
||||
trainer_kph = ?s.telemetry.speed_kph,
|
||||
cadence = ?s.telemetry.cadence_rpm,
|
||||
// Which rule set the speed, the gear it used, and what that
|
||||
// gear is worth. `NoCadence` here means the trainer's Zwift
|
||||
// channel is not delivering and the ride is pinned at a
|
||||
// stop — a fault, and the first thing to check when the
|
||||
// speed looks wrong.
|
||||
speed_source = ?s.speed_source,
|
||||
gear = s.gear,
|
||||
development_m = s.development_m,
|
||||
?command,
|
||||
"ride tick"
|
||||
);
|
||||
@@ -374,30 +470,6 @@ fn transmit(app: &AppHandle, target: ControlTarget) {
|
||||
trainer.set_target(target);
|
||||
}
|
||||
|
||||
/// Load the bundled GPX and start riding it. Development only — see the
|
||||
/// `BIKECONTROL_DEMO` check in `lib.rs`.
|
||||
pub fn start_demo(app: &AppHandle) {
|
||||
let Some(sample) = crate::samples::all().into_iter().find(|s| s.is_gpx) else {
|
||||
return;
|
||||
};
|
||||
let profile = match bikecontrol_core::gpx::import(
|
||||
&sample.text,
|
||||
&sample.name,
|
||||
&bikecontrol_core::gpx::SmoothingConfig::default(),
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::warn!(%e, "demo profile failed to import");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (view, geom) = crate::profile_view::build(&profile, "demo");
|
||||
let state = app.state::<AppState>();
|
||||
let mut inner = state.lock();
|
||||
inner.set_profile(profile, view, geom);
|
||||
inner.inputs.status = RideStatus::Running;
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
@@ -424,7 +496,9 @@ pub fn release_trainer(app: &AppHandle) {
|
||||
/// 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 Some(state) = app.try_state::<AppState>() else {
|
||||
return;
|
||||
};
|
||||
let (trainer, controller) = (state.trainer(), state.controller());
|
||||
trainer.shutdown_blocking();
|
||||
controller.shutdown_blocking();
|
||||
@@ -456,6 +530,14 @@ pub fn spawn_device_loop(app: AppHandle) {
|
||||
);
|
||||
}
|
||||
if let Some(status) = result.trainer_changed {
|
||||
// FR-8.5: a dropout the app *knows* about is journalled with its
|
||||
// reason and its exact moment, rather than left to the
|
||||
// recorder's five-second silence detector to infer.
|
||||
if let Some(reason) = dropout_reason(&status.state) {
|
||||
let state = app.state::<AppState>();
|
||||
let at_ms = state.lock().last_snapshot.map_or(0, |s| s.elapsed_ms);
|
||||
state.recorder().mark_gap(at_ms, reason);
|
||||
}
|
||||
if let Some(notice) = trainer_notice(&status) {
|
||||
notify(&app, notice);
|
||||
}
|
||||
@@ -468,6 +550,16 @@ pub fn spawn_device_loop(app: AppHandle) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether a trainer state change means telemetry has stopped arriving, and if
|
||||
/// so what to write in the journal. `None` for states that still deliver data.
|
||||
fn dropout_reason(state: &ConnectionState) -> Option<String> {
|
||||
match state {
|
||||
ConnectionState::Reconnecting => Some("trainer link lost — reconnecting".into()),
|
||||
ConnectionState::Lost { reason } => Some(format!("trainer unavailable: {reason}")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -490,7 +582,10 @@ fn trainer_notice(status: &TrainerStatus) -> Option<Notice> {
|
||||
"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}")),
|
||||
status
|
||||
.error
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{name} unavailable: {reason}")),
|
||||
)),
|
||||
ConnectionState::Idle | ConnectionState::Scanning => None,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user