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:
+7
-11
@@ -18,11 +18,13 @@ tauri-build = { version = "2", features = [] }
|
||||
[dependencies]
|
||||
bikecontrol-core = { workspace = true }
|
||||
bikecontrol-ble = { workspace = true }
|
||||
bikecontrol-fit = { workspace = true }
|
||||
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "2"
|
||||
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml_ng = { workspace = true }
|
||||
@@ -33,14 +35,8 @@ thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = ["mock-ride"]
|
||||
# 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 = []
|
||||
# Desktop-only: the crate is a `compile_error!` on anything that is not
|
||||
# Windows/Linux/macOS, and the mobile entry points (G-4) have their own
|
||||
# platform APIs for this. `wakelock.rs` degrades to a no-op there.
|
||||
[target.'cfg(any(windows, target_os = "linux", target_os = "macos"))'.dependencies]
|
||||
keepawake = "0.6.0"
|
||||
|
||||
+32
-12
@@ -1,14 +1,11 @@
|
||||
//! The seam between the Tauri shell and whatever is actually riding.
|
||||
//! The seam between the Tauri shell and what is actually riding:
|
||||
//! [`crate::session_backend::SessionBackend`] — `bikecontrol_core::RideSession`
|
||||
//! fed by real FTMS telemetry from `bikecontrol_ble`. 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).
|
||||
//! There is no synthetic alternative: with no trainer attached the ride reads
|
||||
//! zero, which is the truth (§4.3 — the control loop lives in Rust; the
|
||||
//! frontend only ever sees snapshots).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -16,6 +13,7 @@ use bikecontrol_core::profile::Profile;
|
||||
use bikecontrol_core::types::{
|
||||
ControlMode, ControlTarget, RideSnapshot, RiderConfig, SafetyLimits,
|
||||
};
|
||||
use bikecontrol_core::VirtualCassette;
|
||||
|
||||
use crate::events::RideStatus;
|
||||
|
||||
@@ -30,6 +28,11 @@ pub struct RideInputs {
|
||||
pub gradient_offset_pct: f32,
|
||||
/// Selected virtual gear, one-based (FR-4.1).
|
||||
pub gear: usize,
|
||||
/// The ladder `gear` indexes into. Held here rather than left to the
|
||||
/// session's own default so that the gear and the cassette it is counted
|
||||
/// against can never disagree — a "gear 12 of 12" that is really gear 12 of
|
||||
/// 8 is worse than no readout at all.
|
||||
pub cassette: VirtualCassette,
|
||||
pub resistance_level: i16,
|
||||
pub power_target_w: u16,
|
||||
pub profile: Option<Arc<Profile>>,
|
||||
@@ -43,6 +46,7 @@ impl Default for RideInputs {
|
||||
status: RideStatus::Idle,
|
||||
mode: ControlMode::ManualGrade,
|
||||
gear: bikecontrol_core::Gearing::default().gear(),
|
||||
cassette: VirtualCassette::default(),
|
||||
manual_gradient_pct: 0.0,
|
||||
gradient_offset_pct: 0.0,
|
||||
resistance_level: 20,
|
||||
@@ -54,6 +58,24 @@ impl Default for RideInputs {
|
||||
}
|
||||
}
|
||||
|
||||
impl RideInputs {
|
||||
/// How many gears the selected cassette offers.
|
||||
pub fn gear_count(&self) -> usize {
|
||||
self.cassette.len().max(1)
|
||||
}
|
||||
|
||||
/// Select a gear, one-based. Out-of-range values clamp to the ends.
|
||||
pub fn set_gear(&mut self, gear: usize) {
|
||||
self.gear = gear.clamp(1, self.gear_count());
|
||||
}
|
||||
|
||||
/// Shift by `delta` gears, clamping at both ends (FR-4.1.3 — never wraps).
|
||||
pub fn shift_gear(&mut self, delta: i32) {
|
||||
let next = self.gear as i64 + delta as i64;
|
||||
self.set_gear(next.clamp(1, self.gear_count() as i64) as usize);
|
||||
}
|
||||
}
|
||||
|
||||
/// What a tick produced.
|
||||
pub struct Tick {
|
||||
pub snapshot: RideSnapshot,
|
||||
@@ -67,6 +89,4 @@ pub trait RideBackend: Send + 'static {
|
||||
fn tick(&mut self, dt_s: f32, inputs: &RideInputs) -> Tick;
|
||||
/// Return to a fresh ride: zero elapsed, distance and speed.
|
||||
fn reset(&mut self);
|
||||
/// Identifier surfaced to the UI so it is obvious when the data is fake.
|
||||
fn source(&self) -> &'static str;
|
||||
}
|
||||
|
||||
+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(())
|
||||
}
|
||||
|
||||
+1212
-161
File diff suppressed because it is too large
Load Diff
+111
-19
@@ -27,6 +27,15 @@ use crate::profile_view::{self, ProfileGeometry, XUnit};
|
||||
/// Rolling mean window for the speed that feeds ETA. Long enough to survive a
|
||||
/// soft-pedal over a rise, short enough to react to a real change of pace.
|
||||
const SPEED_WINDOW_S: f64 = 45.0;
|
||||
/// Rolling mean window for the speed the rider *reads*.
|
||||
///
|
||||
/// Deliberately far shorter than the ETA's. An ETA wants a pace, and averaging
|
||||
/// three quarters of a minute is right for that. A speedometer wants to answer
|
||||
/// "what did that do?" — and with virtual gearing a shift changes the speed
|
||||
/// immediately, so a 45 s mean takes most of a minute to show a change that
|
||||
/// already happened. Shifting through the whole cassette inside one window
|
||||
/// averages the lot and reads as the gears doing nothing at all.
|
||||
const DISPLAY_SPEED_WINDOW_S: f64 = 3.0;
|
||||
/// Rolling mean window for the displayed power (FR-9.11).
|
||||
pub const POWER_WINDOW_S: f64 = 10.0;
|
||||
/// Window for the normalised-power rolling mean (§12 glossary).
|
||||
@@ -75,6 +84,10 @@ pub struct Derived {
|
||||
/// 45-second rolling mean. This is what drives ETA; it is also the honest
|
||||
/// number to show a rider, because instantaneous speed is noise.
|
||||
pub smoothed_speed_kph: f32,
|
||||
/// The speed to put on screen: lightly smoothed, so a shift is visible at
|
||||
/// once. `smoothed_speed_kph` is the ETA's much longer mean and would take
|
||||
/// most of a minute to show the same change.
|
||||
pub display_speed_kph: f32,
|
||||
|
||||
// --- effort (secondary) ---------------------------------------------------
|
||||
/// Rolling mean power over [`POWER_WINDOW_S`] (FR-9.11).
|
||||
@@ -94,6 +107,8 @@ pub struct Derived {
|
||||
/// Rolling windows. One instance lives in the app state for the whole ride.
|
||||
pub struct Deriver {
|
||||
speed: VecDeque<(f64, f32)>,
|
||||
/// Short window behind the speed on screen; `speed` is the ETA's.
|
||||
display_speed: VecDeque<(f64, f32)>,
|
||||
power: VecDeque<(f64, f32)>,
|
||||
np: VecDeque<(f64, f32)>,
|
||||
np_fourth_sum: f64,
|
||||
@@ -116,6 +131,7 @@ impl Default for Deriver {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
speed: VecDeque::new(),
|
||||
display_speed: VecDeque::new(),
|
||||
power: VecDeque::new(),
|
||||
np: VecDeque::new(),
|
||||
np_fourth_sum: 0.0,
|
||||
@@ -175,9 +191,35 @@ impl Deriver {
|
||||
let power = snapshot.telemetry.power_w.unwrap_or(0) as f32;
|
||||
let cadence = snapshot.telemetry.cadence_rpm.unwrap_or(0.0);
|
||||
|
||||
push_window(&mut self.speed, t, snapshot.virtual_speed_kph, SPEED_WINDOW_S);
|
||||
push_window(&mut self.power, t, power, POWER_WINDOW_S);
|
||||
push_window(&mut self.np, t, power, NP_WINDOW_S);
|
||||
// Only fold a sample in when the ride clock actually moved.
|
||||
//
|
||||
// These windows are trimmed by timestamp, so a sample taken while the
|
||||
// clock is frozen can never expire: `t - t0 > span` is `0 > span`. The
|
||||
// tick loop runs at a fixed 4 Hz whether or not the ride is running,
|
||||
// and `elapsed_ms` only advances while it is — so every second spent
|
||||
// sitting on the ride screen before pressing start used to push four
|
||||
// more zero-speed samples at t = 0 that nothing would ever evict.
|
||||
//
|
||||
// The rider then set off and watched the speed read a fraction of their
|
||||
// real pace, because the mean was still mostly those zeros, and it only
|
||||
// came right 45 seconds in when the frozen samples finally aged out.
|
||||
// Rolling power, normalised power and the ETA all had it too.
|
||||
if dt > 0.0 {
|
||||
push_window(
|
||||
&mut self.speed,
|
||||
t,
|
||||
snapshot.virtual_speed_kph,
|
||||
SPEED_WINDOW_S,
|
||||
);
|
||||
push_window(
|
||||
&mut self.display_speed,
|
||||
t,
|
||||
snapshot.virtual_speed_kph,
|
||||
DISPLAY_SPEED_WINDOW_S,
|
||||
);
|
||||
push_window(&mut self.power, t, power, POWER_WINDOW_S);
|
||||
push_window(&mut self.np, t, power, NP_WINDOW_S);
|
||||
}
|
||||
|
||||
if running {
|
||||
self.power_sum += power as f64;
|
||||
@@ -197,6 +239,7 @@ impl Deriver {
|
||||
}
|
||||
|
||||
let smoothed_speed_kph = mean(&self.speed);
|
||||
let display_speed_kph = mean(&self.display_speed);
|
||||
|
||||
// ---- route position and ETA ----------------------------------------
|
||||
let mut eta_kind = EtaKind::Unavailable;
|
||||
@@ -279,6 +322,7 @@ impl Deriver {
|
||||
axis_total,
|
||||
loop_index,
|
||||
smoothed_speed_kph,
|
||||
display_speed_kph,
|
||||
rolling_power_w: mean(&self.power),
|
||||
rolling_power_window_s: POWER_WINDOW_S,
|
||||
avg_power_w: if self.power_n == 0 {
|
||||
@@ -295,11 +339,8 @@ 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,
|
||||
calories_kcal: energy::kcal(f64::from(self.energy_kj) * 1000.0, rider_kg, self.active_s)
|
||||
as f32,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,11 +368,20 @@ mod tests {
|
||||
fn snapshot(elapsed_s: f64, distance_m: f64, speed_kph: f32) -> RideSnapshot {
|
||||
RideSnapshot {
|
||||
elapsed_ms: (elapsed_s * 1000.0) as u64,
|
||||
telemetry: Telemetry { power_w: Some(200), ..Telemetry::default() },
|
||||
telemetry: Telemetry {
|
||||
power_w: Some(200),
|
||||
..Telemetry::default()
|
||||
},
|
||||
virtual_speed_kph: speed_kph,
|
||||
virtual_distance_m: distance_m,
|
||||
gradient_pct: 0.0,
|
||||
elevation_gain_m: 0.0,
|
||||
gear: 6,
|
||||
gear_count: 12,
|
||||
development_m: 5.7,
|
||||
target_cadence_rpm: 0.0,
|
||||
speed_source: bikecontrol_core::types::SpeedSource::Drivetrain,
|
||||
pedal_force_n: 120.0,
|
||||
mode: bikecontrol_core::types::ControlMode::Profile,
|
||||
target: None,
|
||||
profile_progress: None,
|
||||
@@ -358,8 +408,14 @@ mod tests {
|
||||
looping,
|
||||
blocks: vec![Block::Segments {
|
||||
segments: vec![
|
||||
Segment { distance_m: 1000.0, gradient_pct: 4.0 },
|
||||
Segment { distance_m: 1000.0, gradient_pct: -2.0 },
|
||||
Segment {
|
||||
distance_m: 1000.0,
|
||||
gradient_pct: 4.0,
|
||||
},
|
||||
Segment {
|
||||
distance_m: 1000.0,
|
||||
gradient_pct: -2.0,
|
||||
},
|
||||
],
|
||||
}],
|
||||
}
|
||||
@@ -372,7 +428,13 @@ 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, RIDER_KG, 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);
|
||||
}
|
||||
@@ -387,7 +449,13 @@ 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, RIDER_KG, 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(
|
||||
@@ -424,7 +492,13 @@ 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, RIDER_KG, 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),
|
||||
@@ -451,9 +525,14 @@ mod tests {
|
||||
}
|
||||
// The contract: finite, flagged as held, and no longer changing.
|
||||
assert_eq!(stopped.eta_kind, EtaKind::Held);
|
||||
let eta = stopped.time_remaining_s.expect("held ETA must still be a number");
|
||||
let eta = stopped
|
||||
.time_remaining_s
|
||||
.expect("held ETA must still be a number");
|
||||
assert!(eta.is_finite(), "ETA diverged when the rider stopped");
|
||||
assert_eq!(prev.time_remaining_s, stopped.time_remaining_s, "held ETA still drifting");
|
||||
assert_eq!(
|
||||
prev.time_remaining_s, stopped.time_remaining_s,
|
||||
"held ETA still drifting"
|
||||
);
|
||||
}
|
||||
|
||||
/// Pausing freezes the estimate rather than letting it creep.
|
||||
@@ -464,7 +543,13 @@ 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, RIDER_KG, 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),
|
||||
@@ -522,7 +607,10 @@ mod tests {
|
||||
snap.elapsed_ms = 10_250;
|
||||
snap.telemetry.power_w = Some(600);
|
||||
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");
|
||||
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
|
||||
@@ -536,7 +624,11 @@ mod tests {
|
||||
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);
|
||||
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,
|
||||
|
||||
+165
-23
@@ -18,16 +18,18 @@
|
||||
//! Controlling`, and it can sit at `Connected` indefinitely if the control
|
||||
//! point is refused.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::Duration;
|
||||
|
||||
use bikecontrol_ble::scan::{self, DiscoveredDevice, ScanKind, ZWIFT_SERVICE};
|
||||
use bikecontrol_ble::uuids;
|
||||
use bikecontrol_ble::PodId;
|
||||
use bikecontrol_core::types::ConnectionState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::watch;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::controller::{ControllerHandle, PodState};
|
||||
use crate::trainer::{TrainerHandle, TrainerStatus};
|
||||
|
||||
/// One pass of the scanner. Long enough for a trainer to advertise, short
|
||||
@@ -45,13 +47,25 @@ const HEART_RATE_SERVICE: Uuid = Uuid::from_u128(0x0000180d_0000_1000_8000_00805
|
||||
pub enum DeviceKind {
|
||||
/// Advertises FTMS (`0x1826`).
|
||||
Trainer,
|
||||
/// Zwift custom service, manufacturer type byte identifying the left pod.
|
||||
ClickLeft,
|
||||
ClickRight,
|
||||
/// A Click pod, named for the shift paddle it carries — the type byte in
|
||||
/// its manufacturer data says which (§2.3.1, FR-1.4).
|
||||
ClickMinus,
|
||||
ClickPlus,
|
||||
HeartRate,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl DeviceKind {
|
||||
/// Which Click pod this row is, if it is one at all.
|
||||
pub fn pod_id(self) -> Option<PodId> {
|
||||
match self {
|
||||
DeviceKind::ClickMinus => Some(PodId::Minus),
|
||||
DeviceKind::ClickPlus => Some(PodId::Plus),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeviceInfo {
|
||||
@@ -70,8 +84,11 @@ pub struct DeviceInfo {
|
||||
/// Previously paired, so it would auto-connect on launch (FR-1.5).
|
||||
pub remembered: bool,
|
||||
pub battery_pct: Option<u8>,
|
||||
/// Zwift unlock validity for Click pods (FR-3.9). `None` for other kinds.
|
||||
pub unlock_expires_in_s: Option<u64>,
|
||||
// No unlock countdown. FR-3.9 assumed the Click v2 needed its Zwift session
|
||||
// refreshing daily; TASK-0 disproved it on this hardware — the pods answer
|
||||
// `RideOn 00 09` unencrypted, with no key exchange and no expiry (§2.3.1).
|
||||
// The field was always `None`, which the UI rendered as "expired" against a
|
||||
// pod that was working perfectly.
|
||||
/// Human-readable failure, shown verbatim in the UI (FR-9.2).
|
||||
pub error: Option<String>,
|
||||
}
|
||||
@@ -99,6 +116,9 @@ pub struct ScanSnapshot {
|
||||
|
||||
pub struct DeviceRegistry {
|
||||
trainer: TrainerHandle,
|
||||
/// Held so a Click row in the device list connects the same way its card on
|
||||
/// the connection screen does — one path, not two that can disagree.
|
||||
controller: ControllerHandle,
|
||||
scan_rx: watch::Receiver<ScanSnapshot>,
|
||||
scan_on: watch::Sender<bool>,
|
||||
forgotten: HashSet<String>,
|
||||
@@ -115,13 +135,14 @@ pub struct DeviceRegistry {
|
||||
}
|
||||
|
||||
impl DeviceRegistry {
|
||||
pub fn new(trainer: TrainerHandle) -> Self {
|
||||
pub fn new(trainer: TrainerHandle, controller: ControllerHandle) -> 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 {
|
||||
last_trainer: trainer.status(),
|
||||
trainer,
|
||||
controller,
|
||||
scan_rx,
|
||||
scan_on,
|
||||
forgotten: HashSet::new(),
|
||||
@@ -172,7 +193,11 @@ impl DeviceRegistry {
|
||||
let changed = next != self.published;
|
||||
self.published = next;
|
||||
|
||||
PollResult { changed, transitions, trainer_changed }
|
||||
PollResult {
|
||||
changed,
|
||||
transitions,
|
||||
trainer_changed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge the scan snapshot with the trainer's live status.
|
||||
@@ -180,14 +205,26 @@ impl DeviceRegistry {
|
||||
let snapshot = self.scan_rx.borrow().clone();
|
||||
self.error = snapshot.error.clone();
|
||||
|
||||
let controller = self.controller.status();
|
||||
|
||||
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;
|
||||
}
|
||||
let kind = classify(d);
|
||||
// A Click advertises for a few seconds after a button press and
|
||||
// then goes back to sleep (A-4). Nobody can reliably press Connect
|
||||
// inside that window, and there is nothing to decide anyway — this
|
||||
// is the pod the rider already told us about by pressing a button
|
||||
// on it. So the scan connects it (FR-1.5), unless they disconnected
|
||||
// it on purpose, in which case the supervisor ignores this.
|
||||
if let Some(pod) = kind.pod_id() {
|
||||
self.controller.pod_seen(pod, &id);
|
||||
}
|
||||
out.push(DeviceInfo {
|
||||
kind: classify(d),
|
||||
kind,
|
||||
name: d.label(),
|
||||
address: d.address.clone(),
|
||||
rssi: d.rssi.unwrap_or(0),
|
||||
@@ -200,7 +237,6 @@ impl DeviceRegistry {
|
||||
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,
|
||||
});
|
||||
@@ -224,7 +260,6 @@ impl DeviceRegistry {
|
||||
services: vec![describe_service(uuids::FITNESS_MACHINE_SERVICE)],
|
||||
remembered: true,
|
||||
battery_pct: None,
|
||||
unlock_expires_in_s: None,
|
||||
error: None,
|
||||
});
|
||||
out.len() - 1
|
||||
@@ -245,6 +280,60 @@ impl DeviceRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
// The same for each Click pod, and for the same reason: a connected pod
|
||||
// stops advertising, and a row that disappears the moment the pod works
|
||||
// reads as a pod that has gone (FR-1.4). This is a *view* of what the
|
||||
// controller supervisor owns — the panel above the list and this row
|
||||
// are the same link, never two.
|
||||
for id in PodId::BOTH {
|
||||
let pod = controller.get(id);
|
||||
let Some(address) = pod.address.clone() else {
|
||||
continue;
|
||||
};
|
||||
if self.forgotten.contains(&address) {
|
||||
continue;
|
||||
}
|
||||
let kind = match id {
|
||||
PodId::Minus => DeviceKind::ClickMinus,
|
||||
PodId::Plus => DeviceKind::ClickPlus,
|
||||
};
|
||||
let idx = match out.iter().position(|d| d.id == address) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
out.push(DeviceInfo {
|
||||
id: address.clone(),
|
||||
name: pod.name.clone().unwrap_or_else(|| "Zwift Click".into()),
|
||||
address,
|
||||
rssi: 0,
|
||||
kind,
|
||||
state: ConnectionState::Idle,
|
||||
control_acquired: false,
|
||||
services: vec![describe_service(ZWIFT_SERVICE)],
|
||||
remembered: true,
|
||||
battery_pct: None,
|
||||
error: None,
|
||||
});
|
||||
out.len() - 1
|
||||
}
|
||||
};
|
||||
let device = &mut out[idx];
|
||||
device.kind = kind;
|
||||
device.battery_pct = pod.battery_percent;
|
||||
device.error = pod.error.clone();
|
||||
device.remembered = true;
|
||||
device.state = match pod.state {
|
||||
PodState::Connected => ConnectionState::Connected,
|
||||
PodState::Searching => ConnectionState::Connecting,
|
||||
PodState::Reconnecting => ConnectionState::Reconnecting,
|
||||
PodState::GaveUp => ConnectionState::Lost {
|
||||
reason: "stopped answering".into(),
|
||||
},
|
||||
// Nothing has been asked of this pod, so whatever the scan says
|
||||
// about it stands.
|
||||
PodState::Idle => device.state.clone(),
|
||||
};
|
||||
}
|
||||
|
||||
// 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| {
|
||||
@@ -268,9 +357,24 @@ impl DeviceRegistry {
|
||||
let device = self
|
||||
.get(id)
|
||||
.ok_or_else(|| format!("no such device: {id}"))?;
|
||||
|
||||
// A Click row connects the pod it *is*, by address. Routed to the
|
||||
// controller supervisor rather than handled here, so the device list
|
||||
// and the connection screen drive the same one link per pod (FR-1.4).
|
||||
if let Some(pod) = device.kind.pod_id() {
|
||||
self.remembered.insert(id.to_string());
|
||||
self.forgotten.remove(id);
|
||||
self.controller.connect(pod, Some(device.address.clone()));
|
||||
let mut info = device;
|
||||
info.state = ConnectionState::Connecting;
|
||||
info.error = None;
|
||||
info.remembered = true;
|
||||
return Ok(info);
|
||||
}
|
||||
|
||||
if device.kind != DeviceKind::Trainer {
|
||||
return Err(format!(
|
||||
"{} is not a trainer. Zwift Click support is Phase 3 (REQUIREMENTS.md §5.3).",
|
||||
"{} is neither a trainer nor a Click pod — there is nothing to connect to.",
|
||||
device.name
|
||||
));
|
||||
}
|
||||
@@ -323,13 +427,18 @@ impl DeviceRegistry {
|
||||
// SAF-2 runs inside the supervisor before the link drops.
|
||||
self.trainer.disconnect();
|
||||
}
|
||||
if let Some(pod) = device.kind.pod_id() {
|
||||
self.controller.disconnect(Some(pod));
|
||||
}
|
||||
device.state = ConnectionState::Idle;
|
||||
device.control_acquired = false;
|
||||
Ok(device)
|
||||
}
|
||||
|
||||
pub fn forget(&mut self, id: &str) -> Result<(), String> {
|
||||
let device = self.get(id).ok_or_else(|| format!("no such device: {id}"))?;
|
||||
let device = self
|
||||
.get(id)
|
||||
.ok_or_else(|| format!("no such device: {id}"))?;
|
||||
if device.kind == DeviceKind::Trainer && device.control_acquired {
|
||||
self.trainer.disconnect();
|
||||
}
|
||||
@@ -339,6 +448,23 @@ impl DeviceRegistry {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The address of each Click pod the scanner has seen (FR-1.4).
|
||||
///
|
||||
/// Connecting by address is both faster and unambiguous: the pods share a
|
||||
/// local name, so anything that goes looking for "a Zwift Click" is picking
|
||||
/// one of the two at random. The scan has already done the identifying
|
||||
/// work — this hands it to the controller supervisor rather than making it
|
||||
/// scan again.
|
||||
pub fn click_pod_addresses(&self) -> HashMap<PodId, String> {
|
||||
let mut out = HashMap::new();
|
||||
for device in &self.published {
|
||||
if let Some(pod) = device.kind.pod_id() {
|
||||
out.entry(pod).or_insert_with(|| device.address.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// True once a trainer is connected *and* controllable — the precondition
|
||||
/// for a real ride (FR-2.1).
|
||||
pub fn trainer_controllable(&self) -> bool {
|
||||
@@ -359,12 +485,17 @@ fn classify(d: &DiscoveredDevice) -> DeviceKind {
|
||||
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;
|
||||
// Which pod comes from the manufacturer-data type byte (§2.3.1) — the one
|
||||
// thing that distinguishes the pair, since both advertise the same name.
|
||||
match d.pod_id() {
|
||||
Some(PodId::Minus) => return DeviceKind::ClickMinus,
|
||||
Some(PodId::Plus) => return DeviceKind::ClickPlus,
|
||||
// A Zwift device we cannot place: a v1 Click, or the trainer's own
|
||||
// Zwift service. Labelled by the service rather than guessed at.
|
||||
None if d.services.contains(&ZWIFT_SERVICE) || d.is_zwift_device() => {
|
||||
return DeviceKind::Unknown
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
DeviceKind::Unknown
|
||||
}
|
||||
@@ -433,7 +564,11 @@ async fn scan_loop(mut on: watch::Receiver<bool>, tx: watch::Sender<ScanSnapshot
|
||||
let result = scan::scan(&adapter, SCAN_WINDOW, ScanKind::All).await;
|
||||
generation += 1;
|
||||
let snapshot = match result {
|
||||
Ok(devices) => ScanSnapshot { devices, error: None, generation },
|
||||
Ok(devices) => ScanSnapshot {
|
||||
devices,
|
||||
error: None,
|
||||
generation,
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "scan failed");
|
||||
ScanSnapshot {
|
||||
@@ -464,7 +599,6 @@ mod tests {
|
||||
services: Vec::new(),
|
||||
remembered: false,
|
||||
battery_pct: None,
|
||||
unlock_expires_in_s: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
@@ -497,7 +631,13 @@ mod tests {
|
||||
#[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 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);
|
||||
@@ -517,7 +657,9 @@ mod tests {
|
||||
// list frozen on a snapshot taken before the attempt.
|
||||
let idle = TrainerStatus::default();
|
||||
let lost = TrainerStatus {
|
||||
state: ConnectionState::Lost { reason: "gone".into() },
|
||||
state: ConnectionState::Lost {
|
||||
reason: "gone".into(),
|
||||
},
|
||||
..TrainerStatus::default()
|
||||
};
|
||||
assert!(should_resume_scan(true, &idle));
|
||||
|
||||
+18
-6
@@ -17,6 +17,9 @@ pub const RIDE_SNAPSHOT: &str = "ride://snapshot";
|
||||
pub const RIDE_STATE: &str = "ride://state";
|
||||
/// A lap marker was inserted (FR-3.19 / FR-8.7).
|
||||
pub const RIDE_LAP: &str = "ride://lap";
|
||||
/// The ride ended and its activity was written (FR-9.13). Carries a
|
||||
/// `crate::recording::RideSummary`.
|
||||
pub const RIDE_SUMMARY: &str = "ride://summary";
|
||||
/// The full device list changed (FR-9.1).
|
||||
pub const DEVICES_UPDATED: &str = "devices://updated";
|
||||
/// One device changed connection or control state (FR-1.7, FR-9.3).
|
||||
@@ -55,12 +58,12 @@ pub struct RideState {
|
||||
pub manual_gradient_pct: f32,
|
||||
pub resistance_level: i16,
|
||||
pub power_target_w: u16,
|
||||
/// Selected virtual gear, one-based, and how many there are (FR-4.1).
|
||||
pub gear: usize,
|
||||
pub gear_count: usize,
|
||||
pub lap: u32,
|
||||
pub laps: Vec<LapSummary>,
|
||||
pub profile: Option<ProfileView>,
|
||||
/// 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,
|
||||
@@ -102,13 +105,22 @@ pub struct Notice {
|
||||
|
||||
impl Notice {
|
||||
pub fn info(message: impl Into<String>) -> Self {
|
||||
Self { level: NoticeLevel::Info, message: message.into() }
|
||||
Self {
|
||||
level: NoticeLevel::Info,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
pub fn warn(message: impl Into<String>) -> Self {
|
||||
Self { level: NoticeLevel::Warn, message: message.into() }
|
||||
Self {
|
||||
level: NoticeLevel::Warn,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
pub fn error(message: impl Into<String>) -> Self {
|
||||
Self { level: NoticeLevel::Error, message: message.into() }
|
||||
Self {
|
||||
level: NoticeLevel::Error,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+32
-10
@@ -11,13 +11,13 @@ 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 recording;
|
||||
pub mod samples;
|
||||
pub mod session_backend;
|
||||
pub mod state;
|
||||
pub mod trainer;
|
||||
pub mod wakelock;
|
||||
|
||||
use tauri::{Manager, RunEvent, WindowEvent};
|
||||
|
||||
@@ -43,9 +43,15 @@ pub fn run() {
|
||||
commands::toggle_pause,
|
||||
commands::stop_ride,
|
||||
commands::reset_ride,
|
||||
// recording and export
|
||||
commands::ride_summary,
|
||||
commands::save_fit,
|
||||
commands::recovered_rides,
|
||||
// control modes and targets
|
||||
commands::set_control_mode,
|
||||
commands::cycle_control_mode,
|
||||
commands::shift_gear,
|
||||
commands::set_gear,
|
||||
commands::nudge_gradient,
|
||||
commands::set_gradient,
|
||||
commands::reset_gradient,
|
||||
@@ -75,6 +81,7 @@ pub fn run() {
|
||||
commands::controller_status,
|
||||
commands::connect_controller,
|
||||
commands::disconnect_controller,
|
||||
commands::swap_controller_pods,
|
||||
])
|
||||
.setup(|app| {
|
||||
let handle = app.handle().clone();
|
||||
@@ -83,12 +90,17 @@ pub fn run() {
|
||||
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.
|
||||
if std::env::var("BIKECONTROL_DEMO").is_ok() {
|
||||
state::start_demo(&handle);
|
||||
}
|
||||
// FR-8.4: a journal with no activity beside it is a ride the app
|
||||
// died during. Rebuilding it is the same code path a clean stop
|
||||
// uses, so the rider gets the same file they would have had.
|
||||
//
|
||||
// The result is stashed rather than emitted: nothing is listening
|
||||
// on the event channel yet, and a recovered ride is exactly the
|
||||
// thing that must not be announced to an empty room. The webview
|
||||
// collects it via `recovered_rides` when it starts.
|
||||
let recovered = recording::recover_orphans(&handle);
|
||||
handle.state::<AppState>().lock().recovered = recovered;
|
||||
recording::prune(&handle, commands::KEEP_RECORDINGS);
|
||||
state::emit_devices(&handle);
|
||||
state::emit_ride_state(&handle);
|
||||
Ok(())
|
||||
@@ -103,8 +115,18 @@ pub fn run() {
|
||||
// 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, .. } => {
|
||||
RunEvent::ExitRequested { .. } | RunEvent::Exit => {
|
||||
// NFR-11: hand the screensaver back too. The inhibitor would
|
||||
// lapse with the process anyway, but not before a slow
|
||||
// shutdown, and a released lock is one fewer thing to explain.
|
||||
crate::wakelock::set(false);
|
||||
state::shutdown_devices(app)
|
||||
}
|
||||
RunEvent::WindowEvent {
|
||||
event: WindowEvent::Destroyed,
|
||||
..
|
||||
} => {
|
||||
crate::wakelock::set(false);
|
||||
state::shutdown_devices(app)
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
//! 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
|
||||
//! numbers are fake; their *shape* is not — power is deliberately noisy so the
|
||||
//! rolling average (FR-9.11) has something to smooth, and speed responds to
|
||||
//! gradient with inertia rather than snapping (FR-7.3).
|
||||
//!
|
||||
//! Replaced wholesale by a `RideSession`-backed implementation; see
|
||||
//! [`crate::backend::RideBackend`].
|
||||
|
||||
use bikecontrol_core::profile::Position;
|
||||
use bikecontrol_core::types::{ControlMode, ControlTarget, RideSnapshot, Telemetry};
|
||||
|
||||
use crate::backend::{RideBackend, RideInputs, Tick};
|
||||
use crate::events::RideStatus;
|
||||
|
||||
/// Deterministic, dependency-free noise source.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next_f32(&mut self) -> f32 {
|
||||
// xorshift64*
|
||||
let mut x = self.0;
|
||||
x ^= x >> 12;
|
||||
x ^= x << 25;
|
||||
x ^= x >> 27;
|
||||
self.0 = x;
|
||||
((x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 40) as f32) / (1 << 24) as f32
|
||||
}
|
||||
/// Symmetric noise in `[-1, 1]`.
|
||||
fn bipolar(&mut self) -> f32 {
|
||||
self.next_f32() * 2.0 - 1.0
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MockBackend {
|
||||
rng: Rng,
|
||||
t_s: f64,
|
||||
elapsed_ms: u64,
|
||||
speed_ms: f32,
|
||||
distance_m: f64,
|
||||
elevation_gain_m: f32,
|
||||
energy_kj: f32,
|
||||
power_w: f32,
|
||||
cadence: f32,
|
||||
/// Slow effort wander, so the rider drifts rather than jitters.
|
||||
effort: f32,
|
||||
last_target: Option<ControlTarget>,
|
||||
}
|
||||
|
||||
impl Default for MockBackend {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
rng: Rng(0x9E37_79B9_7F4A_7C15),
|
||||
t_s: 0.0,
|
||||
elapsed_ms: 0,
|
||||
speed_ms: 0.0,
|
||||
distance_m: 0.0,
|
||||
elevation_gain_m: 0.0,
|
||||
energy_kj: 0.0,
|
||||
power_w: 0.0,
|
||||
cadence: 0.0,
|
||||
effort: 1.0,
|
||||
last_target: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MockBackend {
|
||||
/// Base gradient before the rider's manual trim.
|
||||
fn base_gradient(&self, inputs: &RideInputs) -> f32 {
|
||||
match inputs.mode {
|
||||
ControlMode::Profile => inputs
|
||||
.profile
|
||||
.as_deref()
|
||||
.and_then(|p| p.sample(self.position()))
|
||||
.and_then(|t| match t {
|
||||
ControlTarget::Gradient { percent } => Some(percent),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(0.0),
|
||||
_ => inputs.manual_gradient_pct,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the profile wants right now, whatever channel it drives.
|
||||
fn profile_target(&self, inputs: &RideInputs) -> Option<ControlTarget> {
|
||||
inputs.profile.as_deref().and_then(|p| p.sample(self.position()))
|
||||
}
|
||||
|
||||
fn position(&self) -> Position {
|
||||
Position { elapsed_s: self.t_s, distance_m: self.distance_m }
|
||||
}
|
||||
}
|
||||
|
||||
impl RideBackend for MockBackend {
|
||||
fn source(&self) -> &'static str {
|
||||
"mock"
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
let rng = Rng(self.rng.0);
|
||||
*self = Self { rng, ..Self::default() };
|
||||
}
|
||||
|
||||
fn tick(&mut self, dt_s: f32, inputs: &RideInputs) -> Tick {
|
||||
let running = inputs.status == RideStatus::Running;
|
||||
if running {
|
||||
self.t_s += dt_s as f64;
|
||||
self.elapsed_ms += (dt_s * 1000.0).round() as u64;
|
||||
}
|
||||
|
||||
let gradient_pct = self.base_gradient(inputs) + inputs.gradient_offset_pct;
|
||||
|
||||
// ---- what we would send to the trainer -----------------------------
|
||||
let raw_target = match inputs.mode {
|
||||
ControlMode::ManualGrade => ControlTarget::Gradient { percent: gradient_pct },
|
||||
ControlMode::Resistance => ControlTarget::Resistance { level: inputs.resistance_level },
|
||||
ControlMode::Erg => ControlTarget::Power { watts: inputs.power_target_w },
|
||||
ControlMode::Profile => match self.profile_target(inputs) {
|
||||
Some(ControlTarget::Gradient { .. }) | None => {
|
||||
ControlTarget::Gradient { percent: gradient_pct }
|
||||
}
|
||||
Some(other) => other,
|
||||
},
|
||||
};
|
||||
// SAF-3: clamped at the point of transmission, whatever the source.
|
||||
let target = inputs.limits.clamp(raw_target);
|
||||
|
||||
// ---- synthesise a rider --------------------------------------------
|
||||
if running {
|
||||
// Slow wander in effort plus a breathing cycle.
|
||||
self.effort += (self.rng.bipolar() * 0.02 - (self.effort - 1.0) * 0.02) * dt_s;
|
||||
self.effort = self.effort.clamp(0.75, 1.3);
|
||||
let breathing = 1.0 + 0.06 * (self.t_s as f32 / 23.0).sin();
|
||||
|
||||
let demand = match target {
|
||||
ControlTarget::Power { watts } => watts as f32,
|
||||
ControlTarget::Resistance { level } => 90.0 + level as f32 * 3.2,
|
||||
ControlTarget::Gradient { percent } => 165.0 + percent * 13.0,
|
||||
};
|
||||
let wanted = (demand * self.effort * breathing).clamp(0.0, 800.0);
|
||||
// First-order lag: legs do not step.
|
||||
let tau = 2.5;
|
||||
self.power_w += (wanted - self.power_w) * (dt_s / tau).min(1.0);
|
||||
let noisy = (self.power_w + self.rng.bipolar() * 14.0).max(0.0);
|
||||
|
||||
let cadence_wanted = (78.0 + 14.0 * self.effort - gradient_pct * 1.1).clamp(55.0, 105.0);
|
||||
self.cadence += (cadence_wanted - self.cadence) * (dt_s / 1.8).min(1.0);
|
||||
|
||||
self.energy_kj += noisy * dt_s / 1000.0;
|
||||
|
||||
// ---- §5.7 physics ----------------------------------------------
|
||||
let cfg = inputs.rider;
|
||||
let m = cfg.total_mass_kg();
|
||||
let g = 9.80665f32;
|
||||
let theta = (gradient_pct / 100.0).atan();
|
||||
let v = self.speed_ms.max(0.5);
|
||||
let f_prop = (noisy * cfg.drivetrain_efficiency) / v;
|
||||
let f_grav = m * g * theta.sin();
|
||||
let f_roll = m * g * cfg.crr * theta.cos();
|
||||
let f_aero = 0.5 * cfg.air_density * cfg.cda * self.speed_ms * self.speed_ms;
|
||||
let a = (f_prop - f_grav - f_roll - f_aero) / m;
|
||||
self.speed_ms = (self.speed_ms + a * dt_s).max(0.0);
|
||||
|
||||
let step = self.speed_ms as f64 * dt_s as f64;
|
||||
self.distance_m += step;
|
||||
if gradient_pct > 0.0 {
|
||||
self.elevation_gain_m += (step * (gradient_pct as f64 / 100.0)) as f32;
|
||||
}
|
||||
} else {
|
||||
// Coast down when paused so the readouts settle rather than freeze.
|
||||
self.power_w *= 1.0 - (dt_s * 2.0).min(1.0);
|
||||
self.cadence *= 1.0 - (dt_s * 2.0).min(1.0);
|
||||
self.speed_ms *= 1.0 - (dt_s * 0.6).min(1.0);
|
||||
}
|
||||
|
||||
let power_out = if running { (self.power_w + self.rng.bipolar() * 12.0).max(0.0) } else { 0.0 };
|
||||
let telemetry = Telemetry {
|
||||
elapsed_ms: self.elapsed_ms,
|
||||
power_w: Some(power_out.round() as i16),
|
||||
cadence_rpm: Some(if self.cadence < 2.0 { 0.0 } else { self.cadence }),
|
||||
// Trainer-reported speed is deliberately a little off the virtual
|
||||
// speed — it is diagnostic only (FR-7.5).
|
||||
speed_kph: Some(self.speed_ms * 3.6 * 0.98),
|
||||
resistance_level: match target {
|
||||
ControlTarget::Resistance { level } => Some(level),
|
||||
_ => None,
|
||||
},
|
||||
heart_rate_bpm: Some((118.0 + power_out * 0.13).clamp(60.0, 195.0) as u8),
|
||||
total_distance_m: Some(self.distance_m as u32),
|
||||
total_energy_kcal: Some((self.energy_kj / 4.184) as u16),
|
||||
};
|
||||
|
||||
let snapshot = RideSnapshot {
|
||||
elapsed_ms: self.elapsed_ms,
|
||||
telemetry,
|
||||
// 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,
|
||||
mode: inputs.mode,
|
||||
target: Some(target),
|
||||
profile_progress: inputs
|
||||
.profile
|
||||
.as_deref()
|
||||
.and_then(|p| p.total_extent().progress(self.position())),
|
||||
};
|
||||
|
||||
let changed = self.last_target != Some(target);
|
||||
self.last_target = Some(target);
|
||||
|
||||
Tick {
|
||||
snapshot,
|
||||
// SAF-1/SAF-8: only transmit while running, and only on change —
|
||||
// the real backend rate-limits to ≤4 Hz here too (FR-2.8).
|
||||
command: (running && changed).then_some(target),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,7 @@
|
||||
//! The route is the hero element of the ride screen, so this is the payload
|
||||
//! that matters most.
|
||||
|
||||
use bikecontrol_core::profile::{
|
||||
Block, Channel, Extent, Position, Profile, Waveform,
|
||||
};
|
||||
use bikecontrol_core::profile::{Block, Channel, Extent, Position, Profile, Waveform};
|
||||
use serde::Serialize;
|
||||
|
||||
/// Which axis the profile is drawn against.
|
||||
@@ -114,7 +112,11 @@ fn interp(xs: &[f64], ys: &[f32], x: f64) -> Option<f32> {
|
||||
let (x0, x1) = (xs[i - 1], xs[i]);
|
||||
let (y0, y1) = (ys[i - 1], ys[i]);
|
||||
let span = x1 - x0;
|
||||
Some(if span.abs() < f64::EPSILON { y1 } else { y0 + (y1 - y0) * ((x - x0) / span) as f32 })
|
||||
Some(if span.abs() < f64::EPSILON {
|
||||
y1
|
||||
} else {
|
||||
y0 + (y1 - y0) * ((x - x0) / span) as f32
|
||||
})
|
||||
}
|
||||
|
||||
const PREVIEW_SAMPLES: usize = 1400;
|
||||
@@ -145,7 +147,11 @@ pub fn build(profile: &Profile, source: impl Into<String>) -> (ProfileView, Prof
|
||||
let series: Vec<[f64; 2]> = preview.iter().map(|(x, v)| [*x, *v as f64]).collect();
|
||||
let total_x = series.last().map(|p| p[0]).unwrap_or(0.0);
|
||||
|
||||
let channel = profile.blocks.first().map(|b| b.channel()).unwrap_or(Channel::Gradient);
|
||||
let channel = profile
|
||||
.blocks
|
||||
.first()
|
||||
.map(|b| b.channel())
|
||||
.unwrap_or(Channel::Gradient);
|
||||
|
||||
// Elevation. Prefer the real thing: a GPX import lands as a `Terrain`
|
||||
// block that already carries surveyed elevation. Otherwise integrate the
|
||||
@@ -280,7 +286,10 @@ pub fn position_x(geom: &ProfileGeometry, elapsed_s: f64, distance_m: f64) -> f6
|
||||
|
||||
/// Convenience wrapper so callers do not have to build a `Position`.
|
||||
pub fn position(elapsed_s: f64, distance_m: f64) -> Position {
|
||||
Position { elapsed_s, distance_m }
|
||||
Position {
|
||||
elapsed_s,
|
||||
distance_m,
|
||||
}
|
||||
}
|
||||
|
||||
fn block_kind(block: &Block) -> &'static str {
|
||||
@@ -306,7 +315,13 @@ fn block_label(block: &Block) -> String {
|
||||
match block {
|
||||
Block::Constant { value, .. } => format!("hold {value:.0}{u}"),
|
||||
Block::Ramp { from, to, .. } => format!("ramp {from:.0}{u} → {to:.0}{u}"),
|
||||
Block::Wave { shape, midpoint, amplitude, repeats, .. } => format!(
|
||||
Block::Wave {
|
||||
shape,
|
||||
midpoint,
|
||||
amplitude,
|
||||
repeats,
|
||||
..
|
||||
} => format!(
|
||||
"{} {:.0}{u} ±{:.0}{u} ×{:.0}",
|
||||
match shape {
|
||||
Waveform::Sine => "sine",
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
//! Ride recording: the journal, the FIT file, and where both live.
|
||||
//!
|
||||
//! Two files per ride, in the app's data directory:
|
||||
//!
|
||||
//! ```text
|
||||
//! rides/2026-08-05T18-42-10.jsonl the raw journal (FR-8.4)
|
||||
//! rides/2026-08-05T18-42-10.fit the activity, written at stop
|
||||
//! ```
|
||||
//!
|
||||
//! Neither path is chosen by the rider. The journal is crash-safety scaffolding
|
||||
//! and the FIT written next to it is the automatic copy that exists so a ride
|
||||
//! can never be lost to a cancelled dialog — see [`finish`]. Saving *somewhere
|
||||
//! the rider picked* (FR-9.14) is a separate copy, made afterwards, from a file
|
||||
//! that is already safely on disk.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
|
||||
use bikecontrol_core::types::RideSnapshot;
|
||||
use bikecontrol_fit::profile::enums;
|
||||
use bikecontrol_fit::{FitSummary, Recorder, RecorderOptions, Sample};
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
/// The recording of one ride, plus where its output landed.
|
||||
struct Active {
|
||||
recorder: Recorder,
|
||||
fit_path: PathBuf,
|
||||
}
|
||||
|
||||
/// The live recorder, behind its own lock.
|
||||
///
|
||||
/// Deliberately *not* a field of [`crate::state::Inner`]. `RecorderOptions`
|
||||
/// defaults to an `fsync` every ten samples, and holding the ride-state mutex
|
||||
/// across a blocking `sync_data` would stall every command and the device loop
|
||||
/// behind the disk. The ride loop takes this lock only after it has dropped the
|
||||
/// other one.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct RecorderHandle(Arc<Mutex<Option<Active>>>);
|
||||
|
||||
impl RecorderHandle {
|
||||
fn lock(&self) -> MutexGuard<'_, Option<Active>> {
|
||||
self.0.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
pub fn is_recording(&self) -> bool {
|
||||
self.lock().is_some()
|
||||
}
|
||||
|
||||
/// Begin recording. Any recorder still open is abandoned first — its
|
||||
/// journal stays on disk and can be rebuilt, but it is not this ride.
|
||||
///
|
||||
/// Returns the journal path, or the reason recording could not start. A
|
||||
/// failure here is not fatal to the ride: the caller reports it and the
|
||||
/// rider carries on unrecorded, which is worse than recording but far
|
||||
/// better than refusing to ride.
|
||||
/// `dir` is passed in rather than resolved from the `AppHandle` so that the
|
||||
/// whole start/record/finish path can be exercised without a Tauri app.
|
||||
pub fn start(&self, dir: &Path, opts: RideRecordingSetup) -> Result<PathBuf, String> {
|
||||
let stamp = opts.stamp;
|
||||
let log_path = dir.join(format!("{stamp}.jsonl"));
|
||||
let fit_path = dir.join(format!("{stamp}.fit"));
|
||||
|
||||
let mut slot = self.lock();
|
||||
if let Some(prev) = slot.take() {
|
||||
let orphan = prev.recorder.abandon();
|
||||
tracing::warn!(path = %orphan.display(), "a recording was still open; abandoning it");
|
||||
}
|
||||
|
||||
let recorder = Recorder::create(
|
||||
&log_path,
|
||||
RecorderOptions {
|
||||
// A ride following a loaded route is a Virtual Ride; a plain
|
||||
// trainer session with no course is indoor cycling. Getting
|
||||
// this wrong files every ERG workout as a virtual ride.
|
||||
sub_sport: if opts.has_profile {
|
||||
enums::SUB_SPORT_VIRTUAL_ACTIVITY
|
||||
} else {
|
||||
enums::SUB_SPORT_INDOOR_CYCLING
|
||||
},
|
||||
rider_kg: opts.rider_kg,
|
||||
software_version: software_version(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("could not start recording: {e}"))?;
|
||||
|
||||
tracing::info!(journal = %log_path.display(), "recording started");
|
||||
*slot = Some(Active { recorder, fit_path });
|
||||
Ok(log_path)
|
||||
}
|
||||
|
||||
/// Record one tick. Throttled to 1 Hz inside the recorder, so this is safe
|
||||
/// and cheap to call on every engine tick.
|
||||
pub fn record(&self, snapshot: &RideSnapshot, altitude_m: Option<f32>) {
|
||||
let mut slot = self.lock();
|
||||
let Some(active) = slot.as_mut() else { return };
|
||||
// `Sample::from_snapshot` leaves `gear` unset even though the snapshot
|
||||
// carries one, hence `record_sample` rather than `record`.
|
||||
// Gears are one-based and there are a dozen or so; the cast cannot
|
||||
// realistically saturate, but clamping beats a panic in the ride loop.
|
||||
let gear = u8::try_from(snapshot.gear).unwrap_or(u8::MAX);
|
||||
let mut sample = Sample::from_snapshot(snapshot).with_gear(gear);
|
||||
if let Some(altitude) = altitude_m {
|
||||
sample = sample.with_altitude(altitude);
|
||||
}
|
||||
if let Err(e) = active.recorder.record_sample(sample) {
|
||||
tracing::warn!(%e, "dropped a sample");
|
||||
}
|
||||
}
|
||||
|
||||
/// Run something against the live recorder, if there is one. Errors are
|
||||
/// logged rather than propagated: a journal write that fails must not turn
|
||||
/// a lap press or a pause into a failed command.
|
||||
fn with(
|
||||
&self,
|
||||
what: &'static str,
|
||||
f: impl FnOnce(&mut Recorder) -> Result<(), bikecontrol_fit::FitError>,
|
||||
) {
|
||||
let mut slot = self.lock();
|
||||
let Some(active) = slot.as_mut() else { return };
|
||||
if let Err(e) = f(&mut active.recorder) {
|
||||
tracing::warn!(%e, "could not journal {what}");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mark_lap(&self, at_ms: u64, from_controller: bool) {
|
||||
self.with("lap", |r| r.mark_lap(at_ms, from_controller));
|
||||
}
|
||||
|
||||
pub fn pause(&self, at_ms: u64) {
|
||||
self.with("pause", |r| r.pause(at_ms));
|
||||
}
|
||||
|
||||
pub fn resume(&self, at_ms: u64) {
|
||||
self.with("resume", |r| r.resume(at_ms));
|
||||
}
|
||||
|
||||
/// Note a telemetry dropout (FR-8.5). Repeated calls inside one dropout are
|
||||
/// a no-op in the recorder, so the device loop can call this freely.
|
||||
pub fn mark_gap(&self, at_ms: u64, reason: impl Into<String>) {
|
||||
let reason = reason.into();
|
||||
self.with("dropout", move |r| r.mark_gap(at_ms, reason));
|
||||
}
|
||||
|
||||
/// Close the journal and write the FIT beside it.
|
||||
///
|
||||
/// `Ok(None)` means there was nothing recording — stopping a ride that
|
||||
/// never started is not an error.
|
||||
pub fn finish(&self) -> Result<Option<(FitSummary, PathBuf)>, String> {
|
||||
let Some(active) = self.lock().take() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Active { recorder, fit_path } = active;
|
||||
let journal = recorder.log_path().to_path_buf();
|
||||
match recorder.finish(&fit_path) {
|
||||
Ok(summary) => {
|
||||
tracing::info!(
|
||||
path = %fit_path.display(),
|
||||
records = summary.records,
|
||||
"activity written"
|
||||
);
|
||||
Ok(Some((summary, fit_path)))
|
||||
}
|
||||
// The journal survives, so say where it is. "Failed to save" with no
|
||||
// path would leave a recoverable ride looking like a lost one.
|
||||
Err(e) => Err(format!(
|
||||
"could not write the FIT file ({e}) — the ride is still recorded at {}",
|
||||
journal.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`RecorderHandle::start`] needs from the ride state, read under the
|
||||
/// other lock and handed over so the two are never held at once.
|
||||
pub struct RideRecordingSetup {
|
||||
pub stamp: String,
|
||||
pub rider_kg: f32,
|
||||
pub has_profile: bool,
|
||||
}
|
||||
|
||||
/// The finished ride, as the summary screen shows it (FR-9.13, FR-9.14).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RideSummary {
|
||||
pub duration_s: f64,
|
||||
/// Excludes time spent paused.
|
||||
pub moving_s: f64,
|
||||
pub distance_m: f64,
|
||||
pub ascent_m: u16,
|
||||
pub avg_power_w: Option<u16>,
|
||||
pub max_power_w: Option<u16>,
|
||||
pub avg_cadence_rpm: Option<u8>,
|
||||
pub calories: Option<u16>,
|
||||
pub records: usize,
|
||||
pub laps: usize,
|
||||
/// BLE dropouts spanned (FR-8.5).
|
||||
pub gaps: usize,
|
||||
/// True when the activity was rebuilt from a journal with no end marker.
|
||||
pub recovered_from_crash: bool,
|
||||
/// Journal lines that could not be parsed. Non-zero means data was lost.
|
||||
pub skipped_log_lines: usize,
|
||||
/// The automatic copy, in the app's data directory.
|
||||
pub fit_path: String,
|
||||
/// Where the rider chose to save it, once they have (FR-9.14).
|
||||
pub saved_path: Option<String>,
|
||||
}
|
||||
|
||||
impl RideSummary {
|
||||
pub fn new(summary: &FitSummary, fit_path: &Path) -> Self {
|
||||
Self {
|
||||
duration_s: summary.total_elapsed_s,
|
||||
moving_s: summary.total_timer_s,
|
||||
distance_m: summary.total_distance_m,
|
||||
ascent_m: summary.total_ascent_m,
|
||||
avg_power_w: summary.avg_power_w,
|
||||
max_power_w: summary.max_power_w,
|
||||
avg_cadence_rpm: summary.avg_cadence_rpm,
|
||||
calories: summary.total_calories,
|
||||
records: summary.records,
|
||||
laps: summary.laps,
|
||||
gaps: summary.gaps,
|
||||
recovered_from_crash: summary.recovered_from_crash,
|
||||
skipped_log_lines: summary.skipped_log_lines,
|
||||
fit_path: fit_path.display().to_string(),
|
||||
saved_path: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `rides/` inside the app's data directory, created if absent.
|
||||
pub fn rides_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("no app data directory: {e}"))?
|
||||
.join("rides");
|
||||
std::fs::create_dir_all(&dir)
|
||||
.map_err(|e| format!("could not create {}: {e}", dir.display()))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// A filename stem for a ride starting now: local time, sortable, no colons
|
||||
/// (Windows will not have them).
|
||||
pub fn stamp_now() -> String {
|
||||
chrono::Local::now().format("%Y-%m-%dT%H-%M-%S").to_string()
|
||||
}
|
||||
|
||||
/// The app version as FIT wants it: scaled by 100, so 0.1.0 is `10`.
|
||||
fn software_version() -> u16 {
|
||||
let v = env!("CARGO_PKG_VERSION");
|
||||
let mut parts = v.split('.');
|
||||
let major: u16 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
|
||||
let minor: u16 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
|
||||
major.saturating_mul(100).saturating_add(minor)
|
||||
}
|
||||
|
||||
/// Copy the finished activity to a path the rider chose (FR-9.14).
|
||||
///
|
||||
/// A copy, never a move: the automatic file stays where it is so that saving
|
||||
/// twice, or saving to a disk that then fills up, cannot lose the ride.
|
||||
pub fn save_copy(from: &Path, to: &Path) -> Result<(), String> {
|
||||
if !from.exists() {
|
||||
return Err(format!("{} is gone — nothing to save", from.display()));
|
||||
}
|
||||
if let Some(parent) = to.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("could not create {}: {e}", parent.display()))?;
|
||||
}
|
||||
}
|
||||
std::fs::copy(from, to)
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("could not save to {}: {e}", to.display()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Crash recovery (FR-8.4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A journal with no activity beside it: a ride that ended when the process did.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Recovered {
|
||||
pub fit_path: String,
|
||||
pub summary: RideSummary,
|
||||
}
|
||||
|
||||
/// Rebuild an activity for every journal that has no FIT next to it.
|
||||
///
|
||||
/// The encoder guarantees a file rebuilt from a journal is byte-identical to
|
||||
/// one written by a clean shutdown of the same ride, so this is not a degraded
|
||||
/// path — it is the same path, run late.
|
||||
///
|
||||
/// Journals that produce nothing useful (a ride that recorded no samples before
|
||||
/// the crash) are deleted rather than left to be retried on every launch.
|
||||
pub fn recover_orphans(app: &AppHandle) -> Vec<Recovered> {
|
||||
match rides_dir(app) {
|
||||
Ok(dir) => recover_in(&dir),
|
||||
Err(e) => {
|
||||
tracing::warn!(%e, "cannot reach the rides directory; skipping recovery");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`recover_orphans`] against a directory, so it can be tested without a Tauri
|
||||
/// app.
|
||||
pub fn recover_in(dir: &Path) -> Vec<Recovered> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut recovered = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let log_path = entry.path();
|
||||
if log_path.extension().is_none_or(|e| e != "jsonl") {
|
||||
continue;
|
||||
}
|
||||
let fit_path = log_path.with_extension("fit");
|
||||
if fit_path.exists() {
|
||||
continue;
|
||||
}
|
||||
match bikecontrol_fit::build_fit_from_log(&log_path, &fit_path) {
|
||||
Ok(summary) => {
|
||||
tracing::info!(
|
||||
journal = %log_path.display(),
|
||||
records = summary.records,
|
||||
"recovered an interrupted ride"
|
||||
);
|
||||
recovered.push(Recovered {
|
||||
fit_path: fit_path.display().to_string(),
|
||||
summary: RideSummary::new(&summary, &fit_path),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
journal = %log_path.display(),
|
||||
%e,
|
||||
"journal holds no usable ride; removing it"
|
||||
);
|
||||
let _ = std::fs::remove_file(&log_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
recovered
|
||||
}
|
||||
|
||||
/// Delete the oldest rides once there are more than `keep`.
|
||||
///
|
||||
/// §5.8 puts in-app ride history out of scope for v1 — the FIT the rider saved
|
||||
/// is the artifact, and what is left here is a safety net, not a library. Left
|
||||
/// unbounded it would grow forever in a directory nobody opens. A ride is only
|
||||
/// ever removed once its FIT exists, so nothing is deleted before it has been
|
||||
/// through the encoder.
|
||||
pub fn prune(app: &AppHandle, keep: usize) {
|
||||
let Ok(dir) = rides_dir(app) else { return };
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Stems are timestamps, so lexical order is chronological.
|
||||
let mut stems: Vec<String> = entries
|
||||
.flatten()
|
||||
.filter(|e| e.path().extension().is_some_and(|x| x == "fit"))
|
||||
.filter_map(|e| e.path().file_stem()?.to_str().map(str::to_owned))
|
||||
.collect();
|
||||
if stems.len() <= keep {
|
||||
return;
|
||||
}
|
||||
stems.sort();
|
||||
let doomed = stems.len() - keep;
|
||||
for stem in stems.into_iter().take(doomed) {
|
||||
let _ = std::fs::remove_file(dir.join(format!("{stem}.fit")));
|
||||
let _ = std::fs::remove_file(dir.join(format!("{stem}.jsonl")));
|
||||
tracing::info!(ride = %stem, "pruned an old recording");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bikecontrol_core::types::{ControlMode, RideSnapshot, Telemetry};
|
||||
|
||||
fn tmpdir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("bc-rec-{name}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn setup() -> RideRecordingSetup {
|
||||
RideRecordingSetup {
|
||||
stamp: "2026-08-05T18-42-10".into(),
|
||||
rider_kg: 78.0,
|
||||
has_profile: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(elapsed_ms: u64) -> RideSnapshot {
|
||||
RideSnapshot {
|
||||
elapsed_ms,
|
||||
telemetry: Telemetry {
|
||||
elapsed_ms,
|
||||
power_w: Some(230),
|
||||
cadence_rpm: Some(91.0),
|
||||
..Default::default()
|
||||
},
|
||||
virtual_speed_kph: 30.0,
|
||||
virtual_distance_m: elapsed_ms as f64 * 0.008_333,
|
||||
gradient_pct: 2.0,
|
||||
elevation_gain_m: elapsed_ms as f32 * 0.000_15,
|
||||
gear: 7,
|
||||
gear_count: 12,
|
||||
development_m: 6.3,
|
||||
target_cadence_rpm: 90.0,
|
||||
pedal_force_n: 120.0,
|
||||
speed_source: bikecontrol_core::types::SpeedSource::Drivetrain,
|
||||
mode: ControlMode::Profile,
|
||||
target: None,
|
||||
profile_progress: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole app-side recording path, without a Tauri app: start, record a
|
||||
/// ride's worth of ticks at the real 4 Hz rate, mark a lap and a pause, and
|
||||
/// finish. The FIT that comes out must be one an uploader would accept.
|
||||
#[test]
|
||||
fn a_ride_records_end_to_end_and_produces_a_verifiable_fit() {
|
||||
let dir = tmpdir("e2e");
|
||||
let rec = RecorderHandle::default();
|
||||
assert!(!rec.is_recording());
|
||||
|
||||
let journal = rec.start(&dir, setup()).unwrap();
|
||||
assert!(rec.is_recording());
|
||||
assert_eq!(journal, dir.join("2026-08-05T18-42-10.jsonl"));
|
||||
|
||||
// 60 s of ride at the ride loop's 4 Hz. The recorder throttles to 1 Hz
|
||||
// internally, so this also proves the throttle survives the wiring.
|
||||
for tick in 0..240u64 {
|
||||
rec.record(&snapshot(tick * 250), Some(120.0 + tick as f32 * 0.01));
|
||||
}
|
||||
rec.mark_lap(30_000, false);
|
||||
rec.pause(40_000);
|
||||
rec.resume(50_000);
|
||||
rec.mark_gap(55_000, "trainer went quiet");
|
||||
|
||||
let (summary, fit_path) = rec.finish().unwrap().expect("a ride was recording");
|
||||
assert!(!rec.is_recording(), "finish must release the recorder");
|
||||
assert_eq!(fit_path, dir.join("2026-08-05T18-42-10.fit"));
|
||||
|
||||
assert_eq!(summary.records, 60, "60 s at 1 Hz after throttling");
|
||||
assert_eq!(summary.laps, 2, "one marker splits the ride in two");
|
||||
assert!(!summary.recovered_from_crash);
|
||||
assert_eq!(summary.skipped_log_lines, 0);
|
||||
assert_eq!(summary.avg_power_w, Some(230));
|
||||
assert_eq!(summary.avg_cadence_rpm, Some(91), "FR-9.13 needs this");
|
||||
|
||||
// The file itself, not just what the encoder claimed about it.
|
||||
let bytes = std::fs::read(&fit_path).unwrap();
|
||||
assert_eq!(bytes.len(), summary.bytes);
|
||||
bikecontrol_fit::verify(&bytes).expect("a FIT an uploader would reject");
|
||||
|
||||
// And the summary the screen renders agrees with it.
|
||||
let view = RideSummary::new(&summary, &fit_path);
|
||||
assert_eq!(view.avg_cadence_rpm, Some(91));
|
||||
assert_eq!(view.saved_path, None, "not saved anywhere yet");
|
||||
// The ten seconds of pause are elapsed time but not moving time, which
|
||||
// is the distinction the summary screen shows as "N paused".
|
||||
assert!(
|
||||
view.moving_s < view.duration_s,
|
||||
"moving {} vs duration {}",
|
||||
view.moving_s,
|
||||
view.duration_s
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// Stopping a ride that never started is not an error.
|
||||
#[test]
|
||||
fn finishing_without_recording_is_not_a_failure() {
|
||||
assert!(RecorderHandle::default().finish().unwrap().is_none());
|
||||
}
|
||||
|
||||
/// FR-8.4: a journal left behind by a crash becomes an activity on the next
|
||||
/// launch, and one that already has its FIT is left alone.
|
||||
#[test]
|
||||
fn an_orphaned_journal_is_rebuilt_and_a_finished_one_is_not() {
|
||||
let dir = tmpdir("orphan");
|
||||
let rec = RecorderHandle::default();
|
||||
rec.start(&dir, setup()).unwrap();
|
||||
for tick in 0..40u64 {
|
||||
rec.record(&snapshot(tick * 250), None);
|
||||
}
|
||||
// Drop the handle's contents without finishing — the crash case.
|
||||
let orphan = dir.join("2026-08-05T18-42-10.jsonl");
|
||||
drop(rec);
|
||||
assert!(orphan.exists());
|
||||
assert!(!dir.join("2026-08-05T18-42-10.fit").exists());
|
||||
|
||||
let rebuilt = recover_in(&dir);
|
||||
assert_eq!(rebuilt.len(), 1);
|
||||
assert!(rebuilt[0].summary.recovered_from_crash);
|
||||
bikecontrol_fit::verify(&std::fs::read(&rebuilt[0].fit_path).unwrap()).unwrap();
|
||||
|
||||
// Second pass: the FIT now exists, so there is nothing left to recover.
|
||||
assert!(
|
||||
recover_in(&dir).is_empty(),
|
||||
"recovery must not repeat itself"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_software_version_is_scaled_the_way_fit_wants() {
|
||||
// Whatever the crate version is, the encoding must not panic or
|
||||
// overflow; the shape is what matters.
|
||||
let v = software_version();
|
||||
assert!(v < 10_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stamp_is_a_sortable_filename() {
|
||||
let s = stamp_now();
|
||||
assert_eq!(s.len(), 19, "YYYY-MM-DDTHH-MM-SS");
|
||||
assert!(!s.contains(':'), "colons are illegal in Windows filenames");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_a_copy_leaves_the_original() {
|
||||
let dir = std::env::temp_dir().join(format!("bc-save-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let from = dir.join("ride.fit");
|
||||
std::fs::write(&from, b"activity").unwrap();
|
||||
let to = dir.join("nested").join("saved.fit");
|
||||
|
||||
save_copy(&from, &to).unwrap();
|
||||
assert_eq!(std::fs::read(&to).unwrap(), b"activity");
|
||||
assert!(from.exists(), "the automatic copy must survive the save");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_from_a_missing_file_says_so() {
|
||||
let missing = std::env::temp_dir().join("bc-definitely-not-here.fit");
|
||||
let err = save_copy(&missing, &std::env::temp_dir().join("out.fit")).unwrap_err();
|
||||
assert!(err.contains("nothing to save"), "{err}");
|
||||
}
|
||||
}
|
||||
+92
-12
@@ -73,22 +73,51 @@ blocks:
|
||||
extent: { seconds: 600 }
|
||||
"#;
|
||||
|
||||
/// The gearing bench test, shipped rather than kept in a scratch file because
|
||||
/// it is the fastest way to answer "do the gears and the resistance work?" on
|
||||
/// real hardware. Flat on purpose: on a slope, gravity swamps everything and a
|
||||
/// broken gear ratio still feels like a hill.
|
||||
const DRAG_RACE: &str = r#"name: Drag race
|
||||
description: >-
|
||||
A standing-start kilometre on a dead-flat road, for testing that the gears and
|
||||
the resistance actually do something. Start in bottom gear from a stop and
|
||||
wind it up: every shift should land under the pedals at once, and holding one
|
||||
gear should get harder as you speed up, because on the flat drag is the only
|
||||
thing resisting you and it grows with the square of speed. If shifting feels
|
||||
like nothing, the control writes are not reaching the trainer. Loops, so you
|
||||
can go again in a different gear and compare the time.
|
||||
looping: true
|
||||
blocks:
|
||||
- type: segments
|
||||
segments:
|
||||
- distance_m: 1000.0
|
||||
gradient_pct: 0.0
|
||||
"#;
|
||||
|
||||
/// A real GPX, bundled so the route view has something to draw on first run.
|
||||
const SAMPLE_CLIMB_GPX: &str = include_str!("../../testdata/sample-climb.gpx");
|
||||
|
||||
pub fn all() -> Vec<SampleProfile> {
|
||||
let mut out: Vec<SampleProfile> = [OVER_UNDERS, HILL_REPEATS, SAWTOOTH_GRADE, STEADY_ENDURANCE]
|
||||
.iter()
|
||||
.map(|yaml| {
|
||||
let (name, summary) = header(yaml);
|
||||
SampleProfile {
|
||||
name,
|
||||
summary,
|
||||
text: (*yaml).to_string(),
|
||||
is_gpx: false,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// Drag race first among the written profiles: it is the bench test, and the
|
||||
// thing most likely to be wanted in a hurry when the gearing feels wrong.
|
||||
let mut out: Vec<SampleProfile> = [
|
||||
DRAG_RACE,
|
||||
OVER_UNDERS,
|
||||
HILL_REPEATS,
|
||||
SAWTOOTH_GRADE,
|
||||
STEADY_ENDURANCE,
|
||||
]
|
||||
.iter()
|
||||
.map(|yaml| {
|
||||
let (name, summary) = header(yaml);
|
||||
SampleProfile {
|
||||
name,
|
||||
summary,
|
||||
text: (*yaml).to_string(),
|
||||
is_gpx: false,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.insert(
|
||||
0,
|
||||
SampleProfile {
|
||||
@@ -113,3 +142,54 @@ fn header(yaml: &str) -> (String, String) {
|
||||
}
|
||||
(name, summary)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_shipped_profile_parses() {
|
||||
// These are only ever exercised when a rider clicks one, so a typo in a
|
||||
// heredoc ships and stays shipped. Parsing them here is the difference
|
||||
// between finding that at compile time and finding it mid-warm-up.
|
||||
for sample in all() {
|
||||
if sample.is_gpx {
|
||||
continue;
|
||||
}
|
||||
bikecontrol_core::profile::Profile::from_yaml(&sample.text)
|
||||
.unwrap_or_else(|e| panic!("sample profile {:?} does not parse: {e}", sample.name));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_drag_race_is_offered_and_is_the_flat_kilometre_it_claims() {
|
||||
// Flat is the whole point: on a slope gravity swamps the gearing and a
|
||||
// broken ratio still feels like a hill.
|
||||
let sample = all()
|
||||
.into_iter()
|
||||
.find(|s| s.name == "Drag race")
|
||||
.expect("the drag race must reach the picker — it was defined but unlisted once");
|
||||
let profile = bikecontrol_core::profile::Profile::from_yaml(&sample.text).unwrap();
|
||||
assert!(
|
||||
profile.looping,
|
||||
"you must be able to go again without reloading"
|
||||
);
|
||||
|
||||
let extent = profile.total_extent();
|
||||
let metres = extent
|
||||
.metres
|
||||
.expect("a drag race is measured in distance, not time");
|
||||
assert!(
|
||||
(metres - 1000.0).abs() < 1.0,
|
||||
"expected a kilometre, got {metres} m"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_and_summary_are_extracted_for_every_sample() {
|
||||
for sample in all() {
|
||||
assert!(!sample.name.is_empty(), "a nameless entry in the picker");
|
||||
assert_ne!(sample.name, "Profile", "fell back to the placeholder name");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The real backend: `bikecontrol_core::RideSession` driven by trainer
|
||||
//! telemetry.
|
||||
//!
|
||||
//! This is the app's default data source. It holds the latest decoded Indoor
|
||||
//! This is the app's only 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.
|
||||
//!
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
use bikecontrol_core::session::{RideSession, SessionEvent};
|
||||
use bikecontrol_core::types::{RideSnapshot, Telemetry};
|
||||
use bikecontrol_core::{Gearing, VirtualCassette};
|
||||
use tokio::sync::watch;
|
||||
|
||||
use crate::backend::{RideBackend, RideInputs, Tick};
|
||||
@@ -30,26 +31,28 @@ pub struct SessionBackend {
|
||||
/// 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>,
|
||||
/// The cassette currently installed in the session, for the same reason.
|
||||
loaded_cassette: VirtualCassette,
|
||||
}
|
||||
|
||||
impl SessionBackend {
|
||||
pub fn new(inputs: &RideInputs, telemetry: watch::Receiver<Telemetry>) -> Self {
|
||||
let mut session = RideSession::new(inputs.rider, inputs.limits);
|
||||
session.gearing = Gearing::new(inputs.cassette.clone());
|
||||
Self {
|
||||
session: RideSession::new(inputs.rider, inputs.limits),
|
||||
session,
|
||||
telemetry,
|
||||
last_snapshot: None,
|
||||
loaded_profile: None,
|
||||
loaded_cassette: inputs.cassette.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RideBackend for SessionBackend {
|
||||
fn source(&self) -> &'static str {
|
||||
"ftms"
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.session = RideSession::new(self.session.config, self.session.limits);
|
||||
self.session.gearing = Gearing::new(self.loaded_cassette.clone());
|
||||
self.last_snapshot = None;
|
||||
self.loaded_profile = None;
|
||||
}
|
||||
@@ -69,7 +72,10 @@ impl RideBackend for SessionBackend {
|
||||
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);
|
||||
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;
|
||||
@@ -90,6 +96,13 @@ impl RideBackend for SessionBackend {
|
||||
self.session.reset_gradient_offset();
|
||||
self.session.nudge_gradient(offset);
|
||||
|
||||
// The cassette is the rider's, not the session's default. Rebuilding
|
||||
// the gearing is only correct when it actually changed — doing it every
|
||||
// tick would reset the cadence readout to zero forever.
|
||||
if self.loaded_cassette != inputs.cassette {
|
||||
self.session.gearing = Gearing::new(inputs.cassette.clone());
|
||||
self.loaded_cassette = inputs.cassette.clone();
|
||||
}
|
||||
self.session.gearing.set_gear(inputs.gear);
|
||||
|
||||
match inputs.status {
|
||||
@@ -133,7 +146,11 @@ mod tests {
|
||||
use bikecontrol_core::types::{ControlMode, ControlTarget};
|
||||
|
||||
fn running(mode: ControlMode) -> RideInputs {
|
||||
RideInputs { status: RideStatus::Running, mode, ..RideInputs::default() }
|
||||
RideInputs {
|
||||
status: RideStatus::Running,
|
||||
mode,
|
||||
..RideInputs::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -141,7 +158,6 @@ mod tests {
|
||||
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 {
|
||||
@@ -149,8 +165,14 @@ mod tests {
|
||||
}
|
||||
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() });
|
||||
// 200 W and turning the cranks: the engine moves. Cadence is not
|
||||
// garnish — speed is cadence × the selected gear, so a sample with
|
||||
// power and no cadence is a bike going nowhere.
|
||||
let _ = tx.send(Telemetry {
|
||||
power_w: Some(200),
|
||||
cadence_rpm: Some(85.0),
|
||||
..Telemetry::default()
|
||||
});
|
||||
for _ in 0..40 {
|
||||
backend.tick(0.25, &inputs);
|
||||
}
|
||||
@@ -162,7 +184,11 @@ mod tests {
|
||||
|
||||
#[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 (tx, rx) = watch::channel(Telemetry {
|
||||
power_w: Some(250),
|
||||
cadence_rpm: Some(85.0),
|
||||
..Telemetry::default()
|
||||
});
|
||||
let inputs = running(ControlMode::ManualGrade);
|
||||
let mut backend = SessionBackend::new(&inputs, rx);
|
||||
for _ in 0..60 {
|
||||
@@ -177,7 +203,10 @@ mod tests {
|
||||
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!(
|
||||
stopped.virtual_speed_kph < moving,
|
||||
"speed must decay, not hold"
|
||||
);
|
||||
assert_eq!(stopped.telemetry.power_w, None);
|
||||
}
|
||||
|
||||
@@ -185,11 +214,24 @@ mod tests {
|
||||
fn the_manual_gradient_reaches_the_trainer() {
|
||||
let (_tx, rx) = watch::channel(Telemetry::default());
|
||||
let mut inputs = running(ControlMode::ManualGrade);
|
||||
// The gradient channel specifically; the power channel expresses the
|
||||
// same load in watts and is covered in the engine's own tests.
|
||||
inputs.rider.load_channel = bikecontrol_core::types::LoadChannel::Gradient;
|
||||
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 }));
|
||||
// The road is the rider's setting exactly; what the trainer is asked
|
||||
// for is the load that road implies through the selected gear, which is
|
||||
// a different number (see `bikecontrol_core::gearing`).
|
||||
assert_eq!(tick.snapshot.gradient_pct, 5.0);
|
||||
let commanded = match tick.command {
|
||||
Some(ControlTarget::Gradient { percent }) => percent,
|
||||
other => panic!("expected a gradient command, got {other:?}"),
|
||||
};
|
||||
assert!(
|
||||
commanded > 0.0,
|
||||
"a 5% road must put load on the pedals: {commanded}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -230,19 +272,25 @@ mod tests {
|
||||
// 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.rider.load_channel = bikecontrol_core::types::LoadChannel::Gradient;
|
||||
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 })
|
||||
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 (_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);
|
||||
@@ -258,7 +306,11 @@ mod tests {
|
||||
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() });
|
||||
let _ = tx.send(Telemetry {
|
||||
power_w: Some(250),
|
||||
cadence_rpm: Some(85.0),
|
||||
..Telemetry::default()
|
||||
});
|
||||
for _ in 0..60 {
|
||||
backend.tick(0.25, &inputs);
|
||||
}
|
||||
@@ -268,7 +320,10 @@ mod tests {
|
||||
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");
|
||||
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);
|
||||
}
|
||||
@@ -284,7 +339,11 @@ mod tests {
|
||||
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() });
|
||||
let _ = tx.send(Telemetry {
|
||||
power_w: Some(300),
|
||||
cadence_rpm: Some(90.0),
|
||||
..Telemetry::default()
|
||||
});
|
||||
for _ in 0..40 {
|
||||
backend.tick(0.25, &inputs);
|
||||
}
|
||||
@@ -296,3 +355,102 @@ mod tests {
|
||||
assert_eq!(snapshot.elapsed_ms, 250);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod drag_race_tests {
|
||||
use super::*;
|
||||
use bikecontrol_core::types::ControlMode;
|
||||
|
||||
/// Telemetry shaped exactly like the D100's: power and wheel speed, and
|
||||
/// **no cadence** — the firmware does not send it (qdomyos-zwift#3282).
|
||||
fn d100(power_w: i16, speed_kph: f32) -> Telemetry {
|
||||
Telemetry {
|
||||
power_w: Some(power_w),
|
||||
cadence_rpm: None,
|
||||
speed_kph: Some(speed_kph),
|
||||
..Telemetry::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_race_on_d100_telemetry_actually_moves() {
|
||||
// End to end on the real shipped profile: if this passes and the app
|
||||
// still shows zero, the fault is above the engine — in what is being
|
||||
// fed to it, or in the ride never having been started.
|
||||
let yaml = crate::samples::all()
|
||||
.into_iter()
|
||||
.find(|s| s.name == "Drag race")
|
||||
.expect("drag race must be shipped")
|
||||
.text;
|
||||
let profile = bikecontrol_core::profile::Profile::from_yaml(&yaml).unwrap();
|
||||
|
||||
let (tx, rx) = watch::channel(Telemetry::default());
|
||||
let mut inputs = RideInputs {
|
||||
status: RideStatus::Running,
|
||||
mode: ControlMode::Profile,
|
||||
profile: Some(std::sync::Arc::new(profile)),
|
||||
..RideInputs::default()
|
||||
};
|
||||
inputs.set_gear(3);
|
||||
let mut backend = SessionBackend::new(&inputs, rx);
|
||||
|
||||
// Rolling: 180 W at 22 km/h on the flywheel.
|
||||
let _ = tx.send(d100(180, 22.0));
|
||||
for _ in 0..40 {
|
||||
backend.tick(0.25, &inputs);
|
||||
}
|
||||
let snap = backend.tick(0.25, &inputs).snapshot;
|
||||
assert_eq!(
|
||||
snap.speed_source,
|
||||
bikecontrol_core::types::SpeedSource::Drivetrain,
|
||||
"cadence must be inferred from wheel speed: {snap:?}"
|
||||
);
|
||||
assert!(snap.virtual_speed_kph > 5.0, "the bike must move: {snap:?}");
|
||||
assert!(snap.virtual_distance_m > 0.0, "the race must progress: {snap:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_race_that_was_never_started_reports_zero() {
|
||||
// The other explanation for a stationary drag race, and it is not a
|
||||
// bug: loading a profile does not start the ride. Pinned so the two
|
||||
// causes stay distinguishable.
|
||||
let (tx, rx) = watch::channel(Telemetry::default());
|
||||
let inputs = RideInputs {
|
||||
status: RideStatus::Idle,
|
||||
mode: ControlMode::Profile,
|
||||
..RideInputs::default()
|
||||
};
|
||||
let mut backend = SessionBackend::new(&inputs, rx);
|
||||
let _ = tx.send(d100(180, 22.0));
|
||||
for _ in 0..40 {
|
||||
backend.tick(0.25, &inputs);
|
||||
}
|
||||
let snap = backend.tick(0.25, &inputs).snapshot;
|
||||
assert_eq!(snap.virtual_speed_kph, 0.0);
|
||||
assert_eq!(snap.elapsed_ms, 0, "an unstarted ride has no clock");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shifting_up_mid_race_speeds_the_rider_up_for_the_same_flywheel() {
|
||||
// The gear doing its job on inferred cadence: same trainer speed, more
|
||||
// ground covered.
|
||||
let ride = |gear: usize| {
|
||||
let (tx, rx) = watch::channel(Telemetry::default());
|
||||
let mut inputs = RideInputs {
|
||||
status: RideStatus::Running,
|
||||
mode: ControlMode::ManualGrade,
|
||||
..RideInputs::default()
|
||||
};
|
||||
inputs.set_gear(gear);
|
||||
let mut backend = SessionBackend::new(&inputs, rx);
|
||||
let _ = tx.send(d100(180, 22.0));
|
||||
for _ in 0..60 {
|
||||
backend.tick(0.25, &inputs);
|
||||
}
|
||||
backend.tick(0.25, &inputs).snapshot.virtual_speed_kph
|
||||
};
|
||||
let low = ride(2);
|
||||
let high = ride(11);
|
||||
assert!(high > low * 2.0, "a longer gear must cover more ground: {high} vs {low}");
|
||||
}
|
||||
}
|
||||
|
||||
+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,
|
||||
}
|
||||
|
||||
+39
-15
@@ -54,7 +54,7 @@ const SAFETY_SEQUENCE_TIMEOUT: Duration = Duration::from_secs(7);
|
||||
/// 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;
|
||||
pub(crate) const RECONNECT_ATTEMPTS: u32 = 20;
|
||||
|
||||
/// What the UI needs to know about the trainer link (FR-1.7, FR-9.3).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
@@ -93,7 +93,10 @@ impl TrainerStatus {
|
||||
}
|
||||
|
||||
pub fn is_attached(&self) -> bool {
|
||||
!matches!(self.state, ConnectionState::Idle | ConnectionState::Lost { .. })
|
||||
!matches!(
|
||||
self.state,
|
||||
ConnectionState::Idle | ConnectionState::Lost { .. }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,9 +106,13 @@ enum Cmd {
|
||||
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 },
|
||||
Release {
|
||||
limits: SafetyLimits,
|
||||
},
|
||||
/// Full SAF-2 sequence plus disconnect. Used on app exit.
|
||||
Shutdown { reply: SyncSender<()> },
|
||||
Shutdown {
|
||||
reply: SyncSender<()>,
|
||||
},
|
||||
/// A spawned control write finished. Only reported when it failed.
|
||||
WriteFailed(String),
|
||||
}
|
||||
@@ -701,7 +708,9 @@ mod tests {
|
||||
#[test]
|
||||
fn a_lost_link_is_not_attached() {
|
||||
let lost = TrainerStatus {
|
||||
state: ConnectionState::Lost { reason: "gone".into() },
|
||||
state: ConnectionState::Lost {
|
||||
reason: "gone".into(),
|
||||
},
|
||||
..TrainerStatus::default()
|
||||
};
|
||||
assert!(!lost.is_attached());
|
||||
@@ -714,14 +723,23 @@ mod tests {
|
||||
// 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.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");
|
||||
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() };
|
||||
let limits = SafetyLimits {
|
||||
max_gradient_pct: 8.0,
|
||||
..SafetyLimits::default()
|
||||
};
|
||||
assert_eq!(app_config(limits).limits.max_gradient_pct, 8.0);
|
||||
}
|
||||
|
||||
@@ -776,9 +794,11 @@ mod tests {
|
||||
tx.send(Cmd::Target(ControlTarget::Gradient { percent: 3.0 }))
|
||||
.await
|
||||
.unwrap();
|
||||
tx.send(Cmd::Release { limits: SafetyLimits::default() })
|
||||
.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 {
|
||||
@@ -814,9 +834,11 @@ mod tests {
|
||||
// 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();
|
||||
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"),
|
||||
@@ -857,7 +879,9 @@ mod tests {
|
||||
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)"));
|
||||
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}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Keep the display awake while a ride is live (NFR-11).
|
||||
//!
|
||||
//! A ride is an hour with both hands on the bars and no keyboard or mouse
|
||||
//! activity at all, so every desktop's idle timer eventually blanks the screen
|
||||
//! and locks the session — mid-interval, with the numbers the rider is pacing
|
||||
//! against behind a lock screen.
|
||||
//!
|
||||
//! The inhibitor is therefore held for exactly as long as a session is live
|
||||
//! (`Running` **or** `Paused` — pausing for a drink is still a ride) and
|
||||
//! dropped the moment it is not. An app that suppressed the lock screen for
|
||||
//! the whole time it happened to be open would be a worse citizen than the
|
||||
//! screensaver it is fighting.
|
||||
//!
|
||||
//! Failure here is never fatal: no D-Bus, no screensaver service, a sandbox
|
||||
//! that refuses the inhibit — all of it costs the rider a blanked screen, not
|
||||
//! a ride.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::events::RideStatus;
|
||||
|
||||
#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
|
||||
mod imp {
|
||||
/// Mobile (G-4) has its own keep-screen-on API and no `keepawake`.
|
||||
pub struct Guard;
|
||||
pub fn acquire() -> Result<Guard, String> {
|
||||
Err("no keep-awake implementation for this platform".into())
|
||||
}
|
||||
pub fn release(_guard: Guard) {}
|
||||
}
|
||||
|
||||
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
|
||||
mod imp {
|
||||
pub type Guard = keepawake::KeepAwake;
|
||||
|
||||
pub fn acquire() -> Result<Guard, keepawake::Error> {
|
||||
// Two separate inhibits on Linux: `display` is the freedesktop
|
||||
// ScreenSaver one (blank and lock), `idle` the systemd login1 one
|
||||
// (suspend-on-idle). The second needs a system-bus call that some
|
||||
// configurations refuse, and a refusal fails the whole builder — so
|
||||
// fall back to display-only rather than lose both.
|
||||
//
|
||||
// We never ask for `sleep`. A closed lid or a pressed suspend key is
|
||||
// an instruction, not an accident, and blocking it would strand the
|
||||
// machine awake in a bag.
|
||||
let build = |idle: bool| {
|
||||
keepawake::Builder::default()
|
||||
.display(true)
|
||||
.idle(idle)
|
||||
.reason("Ride in progress")
|
||||
.app_name("BikeControl")
|
||||
.app_reverse_domain("paris.tourolle.bikecontrol")
|
||||
.create()
|
||||
};
|
||||
build(true).or_else(|e| {
|
||||
tracing::debug!(error = %e, "idle inhibitor refused; falling back to display only");
|
||||
build(false)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn release(guard: Guard) {
|
||||
// `keepawake`'s `Drop` unwraps its D-Bus un-inhibit call, which fails
|
||||
// if the session bus went away under us. This runs on the ride loop's
|
||||
// task, and that task must not die of a screensaver.
|
||||
let dropped = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || drop(guard)));
|
||||
if dropped.is_err() {
|
||||
tracing::warn!("releasing the display inhibitor failed; it lapses when the app exits");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct State {
|
||||
held: Option<imp::Guard>,
|
||||
/// Set after a failed acquire so a machine without a working screensaver
|
||||
/// service does not mean a fresh attempt — and a fresh warning — four
|
||||
/// times a second for a whole ride. Cleared when the ride ends, so the
|
||||
/// next one tries again.
|
||||
unavailable: bool,
|
||||
}
|
||||
|
||||
static STATE: Mutex<State> = Mutex::new(State {
|
||||
held: None,
|
||||
unavailable: false,
|
||||
});
|
||||
|
||||
/// Hold the inhibitor iff a ride is live. Called every ride tick; cheap and
|
||||
/// idempotent, and only ever talks to the bus on a transition.
|
||||
pub fn sync(status: RideStatus) {
|
||||
set(matches!(status, RideStatus::Running | RideStatus::Paused));
|
||||
}
|
||||
|
||||
/// Acquire or release the inhibitor. Safe to call from any thread, repeatedly.
|
||||
pub fn set(active: bool) {
|
||||
// A poisoned lock here means a previous caller panicked mid-transition;
|
||||
// the guard it left behind is still valid, so carry on rather than take
|
||||
// the whole ride loop down with it.
|
||||
let mut state = STATE.lock().unwrap_or_else(|p| p.into_inner());
|
||||
if active {
|
||||
if state.held.is_some() || state.unavailable {
|
||||
return;
|
||||
}
|
||||
match imp::acquire() {
|
||||
Ok(guard) => {
|
||||
state.held = Some(guard);
|
||||
tracing::info!("display sleep inhibited for the duration of the ride");
|
||||
}
|
||||
Err(e) => {
|
||||
state.unavailable = true;
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"could not inhibit the screensaver — the display may blank mid-ride"
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
state.unavailable = false;
|
||||
if let Some(guard) = state.held.take() {
|
||||
imp::release(guard);
|
||||
tracing::info!("display sleep inhibitor released");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user