//! Application state and the two background loops that drive the UI. //! //! §4.3: the control loop lives here, in Rust. The webview never computes //! anything — it receives `RideSnapshot`s on a timer and sends intents back as //! commands. use std::sync::{Arc, Mutex}; use std::time::Duration; use bikecontrol_core::profile::Profile; 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::profile_view::{ProfileGeometry, ProfileView}; use crate::recording::{RecorderHandle, Recovered, RideSummary}; use crate::session_backend::SessionBackend; use crate::trainer::{TrainerHandle, TrainerStatus}; /// Snapshot push rate. FTMS notifies at 1–4 Hz (NFR-2); we publish at the top /// of that range and the frontend interpolates nothing. pub const TICK_HZ: u64 = 4; const TICK_MS: u64 = 1000 / TICK_HZ; /// Device list refresh, deliberately slower than the ride loop. const SCAN_TICK_MS: u64 = 500; pub struct Inner { pub inputs: RideInputs, pub backend: Box, pub devices: DeviceRegistry, pub trainer: TrainerHandle, pub controller: crate::controller::ControllerHandle, pub profile_view: Option, /// Precomputed route geometry, kept Rust-side so the per-tick elevation and /// ascent-remaining lookups are a binary search rather than a scan. pub geometry: Option, pub deriver: Deriver, pub last_snapshot: Option, pub last_derived: Option, pub lap_index: u32, pub laps: Vec, /// 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, /// Rides rebuilt from an orphaned journal at startup (FR-8.4), held until /// the webview asks for them. pub recovered: Vec, lap_start_ms: u64, lap_start_m: f64, lap_power_sum: f64, lap_power_n: u64, } /// Build the ride's data source. /// /// **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 { tracing::info!("ride data source is the trainer; with no trainer attached the ride reads zero"); Box::new(SessionBackend::new(inputs, trainer.telemetry())) } impl Inner { fn new(trainer: TrainerHandle, controller: crate::controller::ControllerHandle) -> Self { let inputs = RideInputs::default(); Self { backend: build_backend(&inputs, &trainer), devices: DeviceRegistry::new(trainer.clone(), controller.clone()), inputs, trainer, controller, profile_view: None, geometry: None, deriver: Deriver::default(), last_snapshot: None, 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, lap_power_n: 0, } } pub fn ride_state(&self) -> RideState { RideState { status: self.inputs.status, mode: self.inputs.mode, target: self.last_snapshot.and_then(|s| s.target), gradient_offset_pct: self.inputs.gradient_offset_pct, 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(), trainer: self.trainer.status(), } } pub fn set_profile(&mut self, profile: Profile, view: ProfileView, geom: ProfileGeometry) { self.inputs.profile = Some(Arc::new(profile)); self.profile_view = Some(view); self.geometry = Some(geom); self.inputs.mode = bikecontrol_core::types::ControlMode::Profile; } pub fn clear_profile(&mut self) { self.inputs.profile = None; self.profile_view = None; self.geometry = None; if self.inputs.mode == bikecontrol_core::types::ControlMode::Profile { self.inputs.mode = bikecontrol_core::types::ControlMode::ManualGrade; } } /// Close the current lap and open the next (FR-3.19, FR-8.7). pub fn mark_lap(&mut self) -> LapSummary { let snapshot = self.last_snapshot; let elapsed_ms = snapshot.map(|s| s.elapsed_ms).unwrap_or(0); let distance_m = snapshot.map(|s| s.virtual_distance_m).unwrap_or(0.0); let lap = LapSummary { index: self.lap_index, elapsed_ms: elapsed_ms.saturating_sub(self.lap_start_ms), distance_m: distance_m - self.lap_start_m, avg_power_w: if self.lap_power_n == 0 { 0.0 } else { (self.lap_power_sum / self.lap_power_n as f64) as f32 }, }; self.laps.push(lap); self.lap_index += 1; self.lap_start_ms = elapsed_ms; self.lap_start_m = distance_m; self.lap_power_sum = 0.0; self.lap_power_n = 0; lap } pub fn reset_ride(&mut self) { self.backend.reset(); self.deriver.reset(); self.last_derived = None; 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; self.lap_start_m = 0.0; self.lap_power_sum = 0.0; self.lap_power_n = 0; } /// Fold a fresh snapshot into the lap accumulators and the rolling windows. fn absorb(&mut self, snapshot: &RideSnapshot) -> Derived { let running = self.inputs.status == RideStatus::Running; if running { if let Some(p) = snapshot.telemetry.power_w { self.lap_power_sum += p as f64; self.lap_power_n += 1; } } let profile = self.inputs.profile.clone(); let derived = self.deriver.update( snapshot, running, self.inputs.rider.rider_kg, profile.as_deref(), self.geometry.as_ref(), ); self.last_derived = Some(derived); derived } } #[derive(Clone)] pub struct AppState { inner: Arc>, /// 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 { Self::new() } } impl AppState { pub fn new() -> Self { let limits = RideInputs::default().limits; let trainer = TrainerHandle::spawn(crate::trainer::app_config(limits)); let controller = crate::controller::ControllerHandle::spawn(); 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 /// exit must not hold the mutex while it waits on the radio). pub fn trainer(&self) -> TrainerHandle { self.lock().trainer.clone() } /// The controller supervisor handle. pub fn controller(&self) -> crate::controller::ControllerHandle { self.lock().controller.clone() } /// Panics are impossible to recover from here, and a poisoned lock means /// the ride loop already died — surface it rather than hide it. pub fn lock(&self) -> std::sync::MutexGuard<'_, Inner> { self.inner.lock().unwrap_or_else(|e| e.into_inner()) } } /// Emit the low-frequency ride state. Call after anything that changes mode, /// target, status, laps or the loaded profile. pub fn emit_ride_state(app: &AppHandle) { let state = app.state::(); let payload = state.lock().ride_state(); let _ = app.emit(events::RIDE_STATE, payload); } pub fn emit_devices(app: &AppHandle) { let state = app.state::(); let (scanning, devices) = { let inner = state.lock(); (inner.devices.scanning, inner.devices.list()) }; let _ = app.emit(events::DEVICES_UPDATED, DeviceList { scanning, devices }); } pub fn notify(app: &AppHandle, notice: Notice) { let _ = app.emit(events::APP_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) { let _ = app.emit( events::INPUT_ACK, InputAck { action: action.into(), detail, }, ); } /// Forward controller button edges and link status to the webview. /// /// The backend deliberately does **not** decide what a button means. It reports /// "`plus` was pressed"; the webview routes that to the same intent as the /// matching keypress. One input map, not two that can drift (§4.3). pub fn spawn_controller_loop(app: AppHandle) { tauri::async_runtime::spawn(async move { let controller = app.state::().controller(); let mut inputs = controller.inputs(); let mut status = controller.status_watch(); let mut previous = controller.status(); loop { tokio::select! { input = inputs.recv() => match input { Ok(input) => { // The paddles shift the virtual gear (FR-4.1, OQ-1). // Done here rather than in the webview so gearing keeps // working with the window unfocused or minimised. // // 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, "minus" => -1, _ => 0, }; if delta != 0 { let state = app.state::(); state.lock().inputs.shift_gear(delta); emit_ride_state(&app); } } let _ = app.emit(events::CONTROLLER_INPUT, input); } Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { // Dropping a *release* edge would latch a button on, so // this is worth saying out loud rather than swallowing. tracing::warn!("controller: webview missed {n} button edge(s)"); } Err(tokio::sync::broadcast::error::RecvError::Closed) => return, }, changed = status.changed() => { if changed.is_err() { return; } let payload = status.borrow_and_update().clone(); // 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); } } } }); } /// 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 { 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) { tauri::async_runtime::spawn(async move { let mut interval = tokio::time::interval(Duration::from_millis(TICK_MS)); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); let dt_s = TICK_MS as f32 / 1000.0; let mut n: u64 = 0; loop { interval.tick().await; let (frame, command, altitude_m, status) = { let state = app.state::(); 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); // 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::() .recorder() .record(&frame.snapshot, altitude_m); } n += 1; if n.is_multiple_of(TICK_HZ) { let s = &frame.snapshot; tracing::debug!( elapsed_ms = s.elapsed_ms, speed_kph = s.virtual_speed_kph, distance_m = s.virtual_distance_m, gradient = s.gradient_pct, power = ?s.telemetry.power_w, // The trainer's OWN speed, straight from Indoor Bike Data, // alongside the speed our physics computed. Divergence // between the two is the fastest way to tell a physics // problem from a telemetry problem. trainer_kph = ?s.telemetry.speed_kph, cadence = ?s.telemetry.cadence_rpm, // 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" ); } let _ = app.emit(events::RIDE_SNAPSHOT, frame); if let Some(target) = command { transmit(&app, target); } } }); } /// Push a target to the trainer's FTMS control point. /// /// Every target has already passed `SafetyLimits::clamp` in the ride engine, /// and `bikecontrol_ble` clamps again against the trainer's own reported ranges /// at the point of transmission (SAF-3, FR-2.6). Non-blocking: the BLE layer /// rate-limits to 4 Hz and coalesces, so the ride loop never waits on a radio. fn transmit(app: &AppHandle, target: ControlTarget) { let state = app.state::(); let trainer = state.lock().trainer.clone(); trainer.set_target(target); } /// SAF-2 at the end of a ride: zero gradient / minimum resistance, link kept so /// the next ride does not have to reconnect. pub fn release_trainer(app: &AppHandle) { let state = app.state::(); let (trainer, limits) = { let inner = state.lock(); (inner.trainer.clone(), inner.inputs.limits) }; tracing::info!("releasing trainer to zero load (SAF-2)"); trainer.release(limits); } /// Close every BLE link the app owns, on the way out. /// /// The trainer first and with the full SAF-2 reset — zero gradient, minimum /// resistance, `Reset`, `Stop` — then the controller, which needs no reset but /// does need disconnecting (SAF-9): a link the process merely abandons can /// leave the peripheral held and unreachable on the next launch. /// /// Blocks. That is the point: a fire-and-forget send races the process exit and /// leaves the trainer loaded, which is exactly the failure SAF-2 exists to /// prevent. Both supervisors abandon whatever connect or reconnect is in flight /// rather than finish it, so the wait is bounded by their own budgets and not by /// the radio (FR-1.10, NFR-9). The lock is released before waiting so the ride /// loop can finish. pub fn shutdown_devices(app: &AppHandle) { let Some(state) = app.try_state::() else { return; }; let (trainer, controller) = (state.trainer(), state.controller()); trainer.shutdown_blocking(); controller.shutdown_blocking(); } /// The scan loop: refreshes the device list from the radio and pushes it when /// it changes, and turns trainer state changes into notices the rider can act /// on (FR-1.7, FR-1.8, FR-9.2). pub fn spawn_device_loop(app: AppHandle) { tauri::async_runtime::spawn(async move { let mut interval = tokio::time::interval(Duration::from_millis(SCAN_TICK_MS)); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); loop { interval.tick().await; let result = { let state = app.state::(); let mut inner = state.lock(); inner.devices.poll() }; for device in &result.transitions { let _ = app.emit( events::DEVICE_CONNECTION, ConnectionEvent { device_id: device.id.clone(), state: device.state.clone(), control_acquired: device.control_acquired, error: device.error.clone(), }, ); } 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::(); 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); } emit_ride_state(&app); } if result.changed { emit_devices(&app); } } }); } /// 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 { 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 /// control. fn trainer_notice(status: &TrainerStatus) -> Option { let name = status.name.clone().unwrap_or_else(|| "Trainer".into()); match &status.state { ConnectionState::Connecting => Some(Notice::info(format!("Connecting to {name}…"))), ConnectionState::Connected => Some(Notice::warn(format!( "{name} connected but control is not acquired — it will not respond to targets yet" ))), ConnectionState::Controlling if status.stale => Some(Notice::warn(format!( "{name} is connected but sending no data — turn the cranks to wake it" ))), ConnectionState::Controlling => match &status.error { Some(error) => Some(Notice::error(format!("{name}: {error}"))), None => Some(Notice::info(format!("{name} connected — control acquired"))), }, ConnectionState::Reconnecting => Some(Notice::warn(format!( "Lost {name} — reconnecting. The ride continues; pedal to wake the trainer." ))), ConnectionState::Lost { reason } => Some(Notice::error( status .error .clone() .unwrap_or_else(|| format!("{name} unavailable: {reason}")), )), ConnectionState::Idle | ConnectionState::Scanning => None, } }