Core ride logic, FTMS client, FIT encoder and probe CLI

Adds backing state for Resistance and Erg control modes, which had no
value to hold and so could never satisfy FR-4.3/FR-4.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 13:34:27 +02:00
co-authored by Claude Opus 5
parent 3e106de2c5
commit 7c17ca6158
61 changed files with 20933 additions and 55 deletions
+276
View File
@@ -0,0 +1,276 @@
//! 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::{ControlTarget, RideSnapshot};
use tauri::{AppHandle, Emitter, Manager};
use crate::backend::{RideBackend, RideInputs};
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::mock::MockBackend;
use crate::profile_view::{ProfileGeometry, ProfileView};
/// Snapshot push rate. FTMS notifies at 14 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<dyn RideBackend>,
pub devices: DeviceRegistry,
pub profile_view: Option<ProfileView>,
/// Precomputed route geometry, kept Rust-side so the per-tick elevation and
/// ascent-remaining lookups are a binary search rather than a scan.
pub geometry: Option<ProfileGeometry>,
pub deriver: Deriver,
pub last_snapshot: Option<RideSnapshot>,
pub last_derived: Option<Derived>,
pub lap_index: u32,
pub laps: Vec<LapSummary>,
lap_start_ms: u64,
lap_start_m: f64,
lap_power_sum: f64,
lap_power_n: u64,
}
impl Inner {
fn new() -> Self {
Self {
inputs: RideInputs::default(),
backend: Box::new(MockBackend::default()),
devices: DeviceRegistry::new(),
profile_view: None,
geometry: None,
deriver: Deriver::default(),
last_snapshot: None,
last_derived: None,
lap_index: 1,
laps: 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,
lap: self.lap_index,
laps: self.laps.clone(),
profile: self.profile_view.clone(),
source: self.backend.source(),
}
}
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.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, profile.as_deref(), self.geometry.as_ref());
self.last_derived = Some(derived);
derived
}
}
#[derive(Clone)]
pub struct AppState(Arc<Mutex<Inner>>);
impl Default for AppState {
fn default() -> Self {
Self::new()
}
}
impl AppState {
pub fn new() -> Self {
Self(Arc::new(Mutex::new(Inner::new())))
}
/// 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())
}
}
/// 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::<AppState>();
let payload = state.lock().ride_state();
let _ = app.emit(events::RIDE_STATE, payload);
}
pub fn emit_devices(app: &AppHandle) {
let state = app.state::<AppState>();
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<String>) {
let _ = app.emit(events::INPUT_ACK, InputAck { action: action.into(), detail });
}
/// 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;
loop {
interval.tick().await;
let (frame, command) = {
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)
};
let _ = app.emit(events::RIDE_SNAPSHOT, frame);
if let Some(target) = command {
transmit(&app, target);
}
}
});
}
/// Where the FTMS control-point write will go. Until `crates/ble` exists this
/// only logs — but every target already passed `SafetyLimits::clamp` before it
/// got here (SAF-3), so wiring the real write is a one-line change.
fn transmit(_app: &AppHandle, target: ControlTarget) {
tracing::debug!(?target, "control target (no trainer attached — mock backend)");
}
/// SAF-2: never leave the trainer loaded. Called on ride end and on app exit.
pub fn release_trainer(app: &AppHandle) {
let state = app.state::<AppState>();
let limits = state.lock().inputs.limits;
let safe = limits.clamp(ControlTarget::Gradient { percent: 0.0 });
tracing::info!(?safe, "releasing trainer (SAF-2)");
transmit(app, safe);
}
/// The scan loop: advances the device mock and pushes the list when it changes.
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::<AppState>();
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 result.changed {
emit_devices(&app);
}
}
});
}