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:
+250
-37
@@ -4,21 +4,30 @@
|
||||
//! they mutate Rust-side state and the resulting truth comes back on the event
|
||||
//! channel. The UI never assumes a command took effect (§4.3).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use bikecontrol_core::gpx::{self, SmoothingConfig};
|
||||
use bikecontrol_core::profile::Profile;
|
||||
use bikecontrol_core::types::{ControlMode, RiderConfig, SafetyLimits};
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use bikecontrol_ble::TrainerSelector;
|
||||
use bikecontrol_ble::PodId;
|
||||
|
||||
use crate::controller::ControllerStatus;
|
||||
use crate::controller::{ControllerStatus, Pod};
|
||||
use crate::devices::DeviceInfo;
|
||||
use crate::events::{DeviceList, LapSummary, Notice, RideState, RideStatus};
|
||||
use crate::profile_view::{self, ProfileView};
|
||||
use crate::recording::{self, Recovered, RideRecordingSetup, RideSummary};
|
||||
use crate::state::{ack, emit_devices, emit_ride_state, notify, AppState};
|
||||
|
||||
type Cmd<T> = Result<T, String>;
|
||||
|
||||
/// Ride time now, for journal entries that need a timestamp. Zero before the
|
||||
/// first tick, which is the correct answer rather than a missing one.
|
||||
fn elapsed_ms(state: &AppState) -> u64 {
|
||||
state.lock().last_snapshot.map_or(0, |s| s.elapsed_ms)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ride state
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -30,21 +39,58 @@ pub fn ride_state(state: State<'_, AppState>) -> RideState {
|
||||
|
||||
#[tauri::command]
|
||||
pub fn start_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
|
||||
{
|
||||
let mut inner = state.lock();
|
||||
if inner.inputs.status == RideStatus::Finished || inner.inputs.status == RideStatus::Idle {
|
||||
inner.reset_ride();
|
||||
}
|
||||
inner.inputs.status = RideStatus::Running;
|
||||
}
|
||||
begin_ride(&app, &state);
|
||||
ack(&app, "start", None);
|
||||
emit_ride_state(&app);
|
||||
Ok(state.lock().ride_state())
|
||||
}
|
||||
|
||||
/// Put the ride into `Running`, opening a journal if this is a fresh start.
|
||||
///
|
||||
/// Every route into a running ride goes through here — the Start button, the
|
||||
/// space bar via [`toggle_pause`], and Click face button B. Any new path that
|
||||
/// set `Running` on its own would ride with no recorder attached, and the rider
|
||||
/// would not find out until the summary said nothing had been saved.
|
||||
fn begin_ride(app: &AppHandle, state: &AppState) {
|
||||
let setup = {
|
||||
let mut inner = state.lock();
|
||||
// A fresh ride, as opposed to resuming a paused one. Only a fresh ride
|
||||
// opens a new journal; resuming must keep writing to the current one.
|
||||
let fresh = matches!(inner.inputs.status, RideStatus::Finished | RideStatus::Idle);
|
||||
if fresh {
|
||||
inner.reset_ride();
|
||||
}
|
||||
inner.inputs.status = RideStatus::Running;
|
||||
fresh.then(|| RideRecordingSetup {
|
||||
stamp: recording::stamp_now(),
|
||||
rider_kg: inner.inputs.rider.rider_kg,
|
||||
has_profile: inner.inputs.profile.is_some(),
|
||||
})
|
||||
};
|
||||
// Started outside the lock: creating the journal touches the disk.
|
||||
let Some(setup) = setup else {
|
||||
// Resuming, not starting: the journal is already open and only needs
|
||||
// its timer restarted.
|
||||
state.recorder().resume(elapsed_ms(state));
|
||||
return;
|
||||
};
|
||||
let started = recording::rides_dir(app).and_then(|dir| state.recorder().start(&dir, setup));
|
||||
if let Err(e) = started {
|
||||
// The ride still starts. Refusing to ride because a file could not be
|
||||
// opened would be the wrong trade — but the rider has to be told this
|
||||
// one will not be saved.
|
||||
tracing::error!(%e, "recording did not start");
|
||||
notify(
|
||||
app,
|
||||
Notice::error(format!("{e} — this ride will not be saved")),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn pause_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
|
||||
state.lock().inputs.status = RideStatus::Paused;
|
||||
state.recorder().pause(elapsed_ms(&state));
|
||||
ack(&app, "pause", None);
|
||||
emit_ride_state(&app);
|
||||
Ok(state.lock().ride_state())
|
||||
@@ -52,24 +98,28 @@ pub fn pause_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState>
|
||||
|
||||
#[tauri::command]
|
||||
pub fn resume_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
|
||||
state.lock().inputs.status = RideStatus::Running;
|
||||
// Not a bare status assignment: resuming from `Finished` is a *new* ride and
|
||||
// must open a journal rather than run on unrecorded.
|
||||
begin_ride(&app, &state);
|
||||
ack(&app, "resume", None);
|
||||
emit_ride_state(&app);
|
||||
Ok(state.lock().ride_state())
|
||||
}
|
||||
|
||||
/// Pause or resume, whichever is the opposite of now. This is the one bound to
|
||||
/// the space bar and to Click face button B.
|
||||
/// the space bar and to Click face button B, and it is also how a ride is
|
||||
/// *started* from the launch screen — hence the trip through [`begin_ride`]
|
||||
/// rather than a status assignment.
|
||||
#[tauri::command]
|
||||
pub fn toggle_pause(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
|
||||
let status = {
|
||||
let mut inner = state.lock();
|
||||
inner.inputs.status = match inner.inputs.status {
|
||||
RideStatus::Running => RideStatus::Paused,
|
||||
_ => RideStatus::Running,
|
||||
};
|
||||
inner.inputs.status
|
||||
};
|
||||
let running = state.lock().inputs.status == RideStatus::Running;
|
||||
if running {
|
||||
state.lock().inputs.status = RideStatus::Paused;
|
||||
state.recorder().pause(elapsed_ms(&state));
|
||||
} else {
|
||||
begin_ride(&app, &state);
|
||||
}
|
||||
let status = state.lock().inputs.status;
|
||||
ack(&app, "toggle-pause", Some(format!("{status:?}")));
|
||||
emit_ride_state(&app);
|
||||
Ok(state.lock().ride_state())
|
||||
@@ -77,16 +127,88 @@ pub fn toggle_pause(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState
|
||||
|
||||
/// End the ride. SAF-2: the trainer is returned to 0% / minimum resistance
|
||||
/// before the session closes.
|
||||
///
|
||||
/// The activity is written automatically, before anything is shown and before
|
||||
/// the rider is asked anything (FR-8.2). Saving to a location they choose is a
|
||||
/// copy made afterwards (FR-9.14, [`save_fit`]) — a rider who cancels that
|
||||
/// dialog, or closes the window, still has their ride.
|
||||
#[tauri::command]
|
||||
pub fn stop_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
|
||||
state.lock().inputs.status = RideStatus::Finished;
|
||||
// The trainer comes first. Whatever happens to the file, the rider must not
|
||||
// be left on a loaded trainer while we talk to the disk.
|
||||
crate::state::release_trainer(&app);
|
||||
ack(&app, "stop", None);
|
||||
emit_ride_state(&app);
|
||||
notify(&app, Notice::info("Ride ended — trainer released to 0%"));
|
||||
|
||||
match state.recorder().finish() {
|
||||
Ok(Some((summary, fit_path))) => {
|
||||
let summary = RideSummary::new(&summary, &fit_path);
|
||||
state.lock().last_summary = Some(summary.clone());
|
||||
let _ = tauri::Emitter::emit(&app, crate::events::RIDE_SUMMARY, summary);
|
||||
recording::prune(&app, KEEP_RECORDINGS);
|
||||
}
|
||||
// Nothing was recording — a ride that never started, or a recorder that
|
||||
// failed to open at the start and already said so.
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
tracing::error!(%e, "could not finalise the activity");
|
||||
notify(&app, Notice::error(e));
|
||||
}
|
||||
}
|
||||
|
||||
emit_ride_state(&app);
|
||||
Ok(state.lock().ride_state())
|
||||
}
|
||||
|
||||
/// How many finished rides stay in the app's data directory.
|
||||
///
|
||||
/// §5.8 puts ride *history* out of scope for v1: the FIT the rider saved is the
|
||||
/// artifact, and this directory is the safety net behind it. Unbounded it would
|
||||
/// grow forever somewhere nobody looks.
|
||||
pub const KEEP_RECORDINGS: usize = 20;
|
||||
|
||||
/// Rides rebuilt from an interrupted session at startup (FR-8.4).
|
||||
///
|
||||
/// Draining rather than reading: this is reported to the rider once, and a
|
||||
/// webview reload should not re-announce a recovery they have already seen.
|
||||
#[tauri::command]
|
||||
pub fn recovered_rides(state: State<'_, AppState>) -> Vec<Recovered> {
|
||||
std::mem::take(&mut state.lock().recovered)
|
||||
}
|
||||
|
||||
/// The most recently finished ride, if the summary screen is reloaded.
|
||||
#[tauri::command]
|
||||
pub fn ride_summary(state: State<'_, AppState>) -> Option<RideSummary> {
|
||||
state.lock().last_summary.clone()
|
||||
}
|
||||
|
||||
/// Save the finished activity where the rider asked (FR-9.14).
|
||||
///
|
||||
/// Returns the path actually written, so the UI can confirm it rather than
|
||||
/// claiming success against a path it merely proposed.
|
||||
#[tauri::command]
|
||||
pub fn save_fit(app: AppHandle, state: State<'_, AppState>, path: String) -> Cmd<String> {
|
||||
let dest = PathBuf::from(&path);
|
||||
let source = {
|
||||
let inner = state.lock();
|
||||
let summary = inner
|
||||
.last_summary
|
||||
.as_ref()
|
||||
.ok_or("There is no finished ride to save")?;
|
||||
PathBuf::from(&summary.fit_path)
|
||||
};
|
||||
|
||||
recording::save_copy(&source, &dest)?;
|
||||
let written = dest.display().to_string();
|
||||
if let Some(summary) = state.lock().last_summary.as_mut() {
|
||||
summary.saved_path = Some(written.clone());
|
||||
}
|
||||
ack(&app, "save-fit", Some(written.clone()));
|
||||
notify(&app, Notice::info(format!("Ride saved to {written}")));
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn reset_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
|
||||
state.lock().reset_ride();
|
||||
@@ -148,6 +270,41 @@ pub fn cycle_control_mode(app: AppHandle, state: State<'_, AppState>) -> Cmd<Rid
|
||||
Ok(state.lock().ride_state())
|
||||
}
|
||||
|
||||
/// Shift the virtual gear by `delta` (FR-4.1).
|
||||
///
|
||||
/// Clamps at both ends rather than wrapping: going from top gear straight to
|
||||
/// bottom mid-climb would be violent, and a rider holding the paddle down
|
||||
/// expects to arrive at the end of the cassette and stay there.
|
||||
///
|
||||
/// This is the one place a shift happens. The controller loop and the keyboard
|
||||
/// both route here, so the pod and the keys cannot drift apart, and neither can
|
||||
/// also nudge the gradient on the way past — a shift changes how hard the
|
||||
/// pedals are, not what the road is doing.
|
||||
#[tauri::command]
|
||||
pub fn shift_gear(app: AppHandle, state: State<'_, AppState>, delta: i32) -> Cmd<RideState> {
|
||||
let (gear, count) = {
|
||||
let mut inner = state.lock();
|
||||
inner.inputs.shift_gear(delta);
|
||||
(inner.inputs.gear, inner.inputs.gear_count())
|
||||
};
|
||||
ack(&app, "gear", Some(format!("{gear}/{count}")));
|
||||
emit_ride_state(&app);
|
||||
Ok(state.lock().ride_state())
|
||||
}
|
||||
|
||||
/// Select a gear directly, one-based (FR-4.1). Out-of-range values clamp.
|
||||
#[tauri::command]
|
||||
pub fn set_gear(app: AppHandle, state: State<'_, AppState>, gear: usize) -> Cmd<RideState> {
|
||||
let (gear, count) = {
|
||||
let mut inner = state.lock();
|
||||
inner.inputs.set_gear(gear);
|
||||
(inner.inputs.gear, inner.inputs.gear_count())
|
||||
};
|
||||
ack(&app, "gear", Some(format!("{gear}/{count}")));
|
||||
emit_ride_state(&app);
|
||||
Ok(state.lock().ride_state())
|
||||
}
|
||||
|
||||
/// FR-4.2 / SAF-5 — one configured increment per event, never more.
|
||||
#[tauri::command]
|
||||
pub fn nudge_gradient(
|
||||
@@ -231,6 +388,9 @@ pub fn set_target_power(app: AppHandle, state: State<'_, AppState>, watts: u16)
|
||||
#[tauri::command]
|
||||
pub fn mark_lap(app: AppHandle, state: State<'_, AppState>) -> Cmd<LapSummary> {
|
||||
let lap = state.lock().mark_lap();
|
||||
// The journal takes the ride time the lap closed at, not the lap's own
|
||||
// duration — the two differ from the second lap onwards.
|
||||
state.recorder().mark_lap(elapsed_ms(&state), false);
|
||||
let _ = tauri::Emitter::emit(&app, crate::events::RIDE_LAP, lap);
|
||||
ack(&app, "lap", Some(format!("Lap {}", lap.index)));
|
||||
emit_ride_state(&app);
|
||||
@@ -311,7 +471,10 @@ pub fn load_profile_from_path(
|
||||
let (view, geom) = profile_view::build(&profile, path);
|
||||
state.lock().set_profile(profile, view.clone(), geom);
|
||||
emit_ride_state(&app);
|
||||
notify(&app, Notice::info(format!("Loaded profile “{}”", view.name)));
|
||||
notify(
|
||||
&app,
|
||||
Notice::info(format!("Loaded profile “{}”", view.name)),
|
||||
);
|
||||
Ok(view)
|
||||
}
|
||||
|
||||
@@ -329,7 +492,10 @@ pub fn load_profile_from_text(
|
||||
let (view, geom) = profile_view::build(&profile, name);
|
||||
state.lock().set_profile(profile, view.clone(), geom);
|
||||
emit_ride_state(&app);
|
||||
notify(&app, Notice::info(format!("Loaded profile “{}”", view.name)));
|
||||
notify(
|
||||
&app,
|
||||
Notice::info(format!("Loaded profile “{}”", view.name)),
|
||||
);
|
||||
Ok(view)
|
||||
}
|
||||
|
||||
@@ -371,7 +537,10 @@ pub fn sample_profiles() -> Vec<SampleProfile> {
|
||||
#[tauri::command]
|
||||
pub fn device_list(state: State<'_, AppState>) -> DeviceList {
|
||||
let inner = state.lock();
|
||||
DeviceList { scanning: inner.devices.scanning, devices: inner.devices.list() }
|
||||
DeviceList {
|
||||
scanning: inner.devices.scanning,
|
||||
devices: inner.devices.list(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -418,8 +587,9 @@ pub fn forget_device(app: AppHandle, state: State<'_, AppState>, device_id: Stri
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// True once a trainer has FTMS control. The ride screen uses this to warn that
|
||||
/// it is showing simulated data (FR-9.3).
|
||||
/// True once a trainer has FTMS control. This is what gates the ride screen:
|
||||
/// connected is not controllable, and a ride nothing is driving is not a ride
|
||||
/// (FR-9.3).
|
||||
#[tauri::command]
|
||||
pub fn trainer_controllable(state: State<'_, AppState>) -> bool {
|
||||
state.lock().devices.trainer_controllable()
|
||||
@@ -434,27 +604,70 @@ 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.
|
||||
/// Connect a Click pod, or both when `pod` is omitted (FR-1.4).
|
||||
///
|
||||
/// `device_id` is an address, for a specific pod the scanner has already
|
||||
/// listed. Without one the supervisor looks the pod up by the type byte in its
|
||||
/// advertisement — never by name, because both pods of a pair advertise the
|
||||
/// same one and the app used to get whichever answered first.
|
||||
///
|
||||
/// 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);
|
||||
pub fn connect_controller(
|
||||
state: State<'_, AppState>,
|
||||
pod: Option<Pod>,
|
||||
device_id: Option<String>,
|
||||
) -> Cmd<()> {
|
||||
let controller = state.controller();
|
||||
let address = device_id.filter(|id| !id.trim().is_empty());
|
||||
match pod {
|
||||
Some(pod) => controller.connect(pod.into(), address),
|
||||
None => {
|
||||
if address.is_some() {
|
||||
return Err("An address names one pod, so say which pod it is".into());
|
||||
}
|
||||
// Both, each on its own schedule: a pod that is awake connects now
|
||||
// rather than queueing behind its sleeping twin.
|
||||
let known = state.lock().devices.click_pod_addresses();
|
||||
for id in PodId::BOTH {
|
||||
controller.connect(id, known.get(&id).cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Disconnect one pod, or both when `pod` is omitted.
|
||||
#[tauri::command]
|
||||
pub fn disconnect_controller(app: AppHandle, state: State<'_, AppState>) -> Cmd<()> {
|
||||
state.controller().disconnect();
|
||||
notify(&app, Notice::info("Controller disconnected"));
|
||||
pub fn disconnect_controller(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
pod: Option<Pod>,
|
||||
) -> Cmd<()> {
|
||||
state.controller().disconnect(pod.map(PodId::from));
|
||||
notify(
|
||||
&app,
|
||||
Notice::info(match pod {
|
||||
Some(Pod::Plus) => "+ pod disconnected",
|
||||
Some(Pod::Minus) => "− pod disconnected",
|
||||
None => "Both Click pods disconnected",
|
||||
}),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Exchange the two pods, for when they answer to the other name.
|
||||
///
|
||||
/// §2.3.1 confirms one manufacturer-data type byte per pod but not which byte
|
||||
/// belongs to which, so the app starts from a documented guess. Pressing a
|
||||
/// paddle shows the rider whether the guess was right; this is how they fix it
|
||||
/// if it was not.
|
||||
#[tauri::command]
|
||||
pub fn swap_controller_pods(app: AppHandle, state: State<'_, AppState>) -> Cmd<()> {
|
||||
state.controller().swap();
|
||||
notify(&app, Notice::info("Swapped the + and − pods"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user