Files
BikeControl/src-tauri/src/state.rs
T
dtourolleandClaude Opus 5 06b4635470 Build every artifact in a pinned image, not on my laptop
Two builder images, because the jobs genuinely need two distributions:
Ubuntu for the tests, the Linux bundle and the Android APK, and Arch for
the .pkg.tar.zst, since makepkg is Arch-specific. Both are built from
this repo (NFR-12) and pinned by tag in the workflows, so a Dockerfile
change only reaches CI once it has been pushed.

libdbus-1-dev is not incidental in the Ubuntu image: btleplug's Linux
backend is bluez-async over the dbus crate, so without it the workspace
does not build at all.

The per-commit Android job is a cargo check, not an APK. The full signed
build is ~15 minutes and runs only on tags; a one-minute check catches
what actually breaks — the JNI shim, droidplug, and any desktop-only API
that has crept into a shared crate.

Two invariants a compiler cannot see are checked there too, because both
fail silently: the app builds, installs, launches, and finds no trainer.
The JNI symbol in android.rs is matched by the runtime by name, so
renaming the Kotlin package compiles fine and simply never initialises
btleplug; and gen/ must stay untracked or the sync script quietly becomes
optional.

The Android versionCode carries a 1000 floor. `tauri android init` writes
1000 for 0.1.0 today, so anyone holding a locally built APK already has
that number installed, and a bare major/minor/patch code would be 100 —
a downgrade, which Android refuses outright.

The fmt check is advisory for now. The tree predates this workflow and
`cargo fmt --all` currently rewrites ~2000 lines across 28 files; making
it a gate here would mean landing a repo-wide reformat as a side effect
of adding CI. Run fmt in its own commit, then drop the continue-on-error.
The two clippy warnings that stood between the tree and a real
`-D warnings` gate are fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 19:52:45 +02:00

593 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 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 trainer: TrainerHandle,
pub controller: crate::controller::ControllerHandle,
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>,
/// 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,
}
/// 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<dyn RideBackend> {
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<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 {
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::<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,
},
);
}
/// 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::<AppState>().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::<AppState>();
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<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) {
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::<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);
// 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.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::<AppState>();
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::<AppState>();
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::<AppState>() 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::<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 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);
}
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<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
/// control.
fn trainer_notice(status: &TrainerStatus) -> Option<Notice> {
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,
}
}