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:
@@ -24,6 +24,7 @@ tauri-plugin-dialog = "2"
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml_ng = { workspace = true }
|
||||
roxmltree = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 361 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,67 @@
|
||||
//! The seam between the Tauri shell and whatever is actually riding.
|
||||
//!
|
||||
//! Today that is [`crate::mock::MockBackend`], a synthetic rider. Tomorrow it
|
||||
//! is `bikecontrol_core::RideSession` fed by FTMS telemetry from
|
||||
//! `bikecontrol_ble`. 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).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bikecontrol_core::profile::Profile;
|
||||
use bikecontrol_core::types::{
|
||||
ControlMode, ControlTarget, RideSnapshot, RiderConfig, SafetyLimits,
|
||||
};
|
||||
|
||||
use crate::events::RideStatus;
|
||||
|
||||
/// Rider intent, owned by the Tauri layer and read by the backend each tick.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RideInputs {
|
||||
pub status: RideStatus,
|
||||
pub mode: ControlMode,
|
||||
/// Base gradient in `ManualGrade` mode.
|
||||
pub manual_gradient_pct: f32,
|
||||
/// Trim applied on top of whatever the base gradient is (FR-4.2).
|
||||
pub gradient_offset_pct: f32,
|
||||
pub resistance_level: i16,
|
||||
pub power_target_w: u16,
|
||||
pub profile: Option<Arc<Profile>>,
|
||||
pub rider: RiderConfig,
|
||||
pub limits: SafetyLimits,
|
||||
}
|
||||
|
||||
impl Default for RideInputs {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
status: RideStatus::Idle,
|
||||
mode: ControlMode::ManualGrade,
|
||||
manual_gradient_pct: 0.0,
|
||||
gradient_offset_pct: 0.0,
|
||||
resistance_level: 20,
|
||||
power_target_w: 200,
|
||||
profile: None,
|
||||
rider: RiderConfig::default(),
|
||||
limits: SafetyLimits::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a tick produced.
|
||||
pub struct Tick {
|
||||
pub snapshot: RideSnapshot,
|
||||
/// Post-clamp target to transmit (SAF-3). `None` when the ride is not
|
||||
/// running, so a paused ride never pushes a new load.
|
||||
pub command: Option<ControlTarget>,
|
||||
}
|
||||
|
||||
pub trait RideBackend: Send + 'static {
|
||||
/// Advance the ride by `dt_s`.
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
//! Every intent the rider can express, as a Tauri command.
|
||||
//!
|
||||
//! Commands are *intents*, not state changes the frontend has already made:
|
||||
//! 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 bikecontrol_core::gpx::{self, SmoothingConfig};
|
||||
use bikecontrol_core::profile::Profile;
|
||||
use bikecontrol_core::types::{ControlMode, RiderConfig, SafetyLimits};
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use crate::devices::DeviceInfo;
|
||||
use crate::events::{DeviceList, LapSummary, Notice, RideState, RideStatus};
|
||||
use crate::profile_view::{self, ProfileView};
|
||||
use crate::state::{ack, emit_devices, emit_ride_state, notify, AppState};
|
||||
|
||||
type Cmd<T> = Result<T, String>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ride state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ride_state(state: State<'_, AppState>) -> RideState {
|
||||
state.lock().ride_state()
|
||||
}
|
||||
|
||||
#[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;
|
||||
}
|
||||
ack(&app, "start", None);
|
||||
emit_ride_state(&app);
|
||||
Ok(state.lock().ride_state())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn pause_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
|
||||
state.lock().inputs.status = RideStatus::Paused;
|
||||
ack(&app, "pause", None);
|
||||
emit_ride_state(&app);
|
||||
Ok(state.lock().ride_state())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn resume_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
|
||||
state.lock().inputs.status = RideStatus::Running;
|
||||
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.
|
||||
#[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
|
||||
};
|
||||
ack(&app, "toggle-pause", Some(format!("{status:?}")));
|
||||
emit_ride_state(&app);
|
||||
Ok(state.lock().ride_state())
|
||||
}
|
||||
|
||||
/// End the ride. SAF-2: the trainer is returned to 0% / minimum resistance
|
||||
/// before the session closes.
|
||||
#[tauri::command]
|
||||
pub fn stop_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
|
||||
state.lock().inputs.status = RideStatus::Finished;
|
||||
crate::state::release_trainer(&app);
|
||||
ack(&app, "stop", None);
|
||||
emit_ride_state(&app);
|
||||
notify(&app, Notice::info("Ride ended — trainer released to 0%"));
|
||||
Ok(state.lock().ride_state())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn reset_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
|
||||
state.lock().reset_ride();
|
||||
emit_ride_state(&app);
|
||||
Ok(state.lock().ride_state())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Control modes and targets (§5.4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Mode cycle order, matching the on-screen control and Click face button A.
|
||||
const MODE_CYCLE: [ControlMode; 4] = [
|
||||
ControlMode::ManualGrade,
|
||||
ControlMode::Profile,
|
||||
ControlMode::Resistance,
|
||||
ControlMode::Erg,
|
||||
];
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_control_mode(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
mode: ControlMode,
|
||||
) -> Cmd<RideState> {
|
||||
{
|
||||
let mut inner = state.lock();
|
||||
if mode == ControlMode::Profile && inner.inputs.profile.is_none() {
|
||||
return Err("No profile loaded — load a GPX or YAML profile first".into());
|
||||
}
|
||||
inner.inputs.mode = mode;
|
||||
}
|
||||
ack(&app, "mode", Some(format!("{mode:?}")));
|
||||
emit_ride_state(&app);
|
||||
Ok(state.lock().ride_state())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn cycle_control_mode(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
|
||||
let mode = {
|
||||
let mut inner = state.lock();
|
||||
let has_profile = inner.inputs.profile.is_some();
|
||||
let current = inner.inputs.mode;
|
||||
let start = MODE_CYCLE.iter().position(|m| *m == current).unwrap_or(0);
|
||||
let mut chosen = current;
|
||||
for step in 1..=MODE_CYCLE.len() {
|
||||
let candidate = MODE_CYCLE[(start + step) % MODE_CYCLE.len()];
|
||||
if candidate == ControlMode::Profile && !has_profile {
|
||||
continue;
|
||||
}
|
||||
chosen = candidate;
|
||||
break;
|
||||
}
|
||||
inner.inputs.mode = chosen;
|
||||
chosen
|
||||
};
|
||||
ack(&app, "mode", Some(format!("{mode:?}")));
|
||||
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(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
delta_pct: f32,
|
||||
) -> Cmd<RideState> {
|
||||
let step = delta_pct.clamp(-2.0, 2.0);
|
||||
{
|
||||
let mut inner = state.lock();
|
||||
match inner.inputs.mode {
|
||||
ControlMode::ManualGrade => inner.inputs.manual_gradient_pct += step,
|
||||
// In profile mode the nudge trims on top of the profile's gradient.
|
||||
_ => inner.inputs.gradient_offset_pct += step,
|
||||
}
|
||||
let limits = inner.inputs.limits;
|
||||
inner.inputs.manual_gradient_pct = inner
|
||||
.inputs
|
||||
.manual_gradient_pct
|
||||
.clamp(limits.min_gradient_pct, limits.max_gradient_pct);
|
||||
inner.inputs.gradient_offset_pct = inner.inputs.gradient_offset_pct.clamp(-10.0, 10.0);
|
||||
}
|
||||
ack(&app, "gradient", Some(format!("{step:+.1}%")));
|
||||
emit_ride_state(&app);
|
||||
Ok(state.lock().ride_state())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_gradient(app: AppHandle, state: State<'_, AppState>, percent: f32) -> Cmd<RideState> {
|
||||
{
|
||||
let mut inner = state.lock();
|
||||
let limits = inner.inputs.limits;
|
||||
inner.inputs.manual_gradient_pct =
|
||||
percent.clamp(limits.min_gradient_pct, limits.max_gradient_pct);
|
||||
}
|
||||
ack(&app, "gradient", Some(format!("{percent:.1}%")));
|
||||
emit_ride_state(&app);
|
||||
Ok(state.lock().ride_state())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn reset_gradient(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
|
||||
{
|
||||
let mut inner = state.lock();
|
||||
inner.inputs.gradient_offset_pct = 0.0;
|
||||
inner.inputs.manual_gradient_pct = 0.0;
|
||||
}
|
||||
ack(&app, "gradient-reset", None);
|
||||
emit_ride_state(&app);
|
||||
Ok(state.lock().ride_state())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_target_resistance(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
level: i16,
|
||||
) -> Cmd<RideState> {
|
||||
{
|
||||
let mut inner = state.lock();
|
||||
let limits = inner.inputs.limits;
|
||||
inner.inputs.resistance_level = level.clamp(limits.min_resistance, limits.max_resistance);
|
||||
}
|
||||
ack(&app, "resistance", Some(format!("{level}")));
|
||||
emit_ride_state(&app);
|
||||
Ok(state.lock().ride_state())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_target_power(app: AppHandle, state: State<'_, AppState>, watts: u16) -> Cmd<RideState> {
|
||||
{
|
||||
let mut inner = state.lock();
|
||||
let limits = inner.inputs.limits;
|
||||
inner.inputs.power_target_w = watts.clamp(limits.min_power_w, limits.max_power_w);
|
||||
}
|
||||
ack(&app, "power", Some(format!("{watts} W")));
|
||||
emit_ride_state(&app);
|
||||
Ok(state.lock().ride_state())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn mark_lap(app: AppHandle, state: State<'_, AppState>) -> Cmd<LapSummary> {
|
||||
let lap = state.lock().mark_lap();
|
||||
let _ = tauri::Emitter::emit(&app, crate::events::RIDE_LAP, lap);
|
||||
ack(&app, "lap", Some(format!("Lap {}", lap.index)));
|
||||
emit_ride_state(&app);
|
||||
Ok(lap)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rider and safety configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub fn rider_config(state: State<'_, AppState>) -> RiderConfig {
|
||||
state.lock().inputs.rider
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_rider_config(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
config: RiderConfig,
|
||||
) -> Cmd<RiderConfig> {
|
||||
if config.rider_kg <= 20.0 || config.bike_kg <= 0.0 {
|
||||
return Err("Rider and bike mass must be positive and realistic".into());
|
||||
}
|
||||
state.lock().inputs.rider = config;
|
||||
emit_ride_state(&app);
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn safety_limits(state: State<'_, AppState>) -> SafetyLimits {
|
||||
state.lock().inputs.limits
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_safety_limits(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
limits: SafetyLimits,
|
||||
) -> Cmd<SafetyLimits> {
|
||||
if limits.min_gradient_pct >= limits.max_gradient_pct {
|
||||
return Err("Gradient limits are inverted".into());
|
||||
}
|
||||
state.lock().inputs.limits = limits;
|
||||
emit_ride_state(&app);
|
||||
Ok(limits)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profiles (§5.5, §5.6)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn parse_profile(text: &str, name: &str, is_gpx: bool) -> Result<Profile, String> {
|
||||
if is_gpx {
|
||||
// FR-5.2/5.3: core smooths the elevation before differentiating and
|
||||
// clamps the result. The defaults are the spec's defaults.
|
||||
gpx::import(text, name, &SmoothingConfig::default()).map_err(|e| e.to_string())
|
||||
} else {
|
||||
Profile::from_yaml(text).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a profile from a path on disk. GPX is detected by extension, everything
|
||||
/// else is treated as the YAML profile format.
|
||||
#[tauri::command]
|
||||
pub fn load_profile_from_path(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
path: String,
|
||||
) -> Cmd<ProfileView> {
|
||||
let text = std::fs::read_to_string(&path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let stem = std::path::Path::new(&path)
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "Profile".into());
|
||||
let is_gpx = path.to_ascii_lowercase().ends_with(".gpx");
|
||||
let profile = parse_profile(&text, &stem, is_gpx)?;
|
||||
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)));
|
||||
Ok(view)
|
||||
}
|
||||
|
||||
/// Load from text the frontend already has — used by the drop target, the
|
||||
/// built-in samples and the profile editor.
|
||||
#[tauri::command]
|
||||
pub fn load_profile_from_text(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
name: String,
|
||||
text: String,
|
||||
is_gpx: bool,
|
||||
) -> Cmd<ProfileView> {
|
||||
let profile = parse_profile(&text, &name, is_gpx)?;
|
||||
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)));
|
||||
Ok(view)
|
||||
}
|
||||
|
||||
/// Parse and preview without loading — the editor calls this on every keystroke
|
||||
/// so errors surface as you type rather than when you press Ride.
|
||||
#[tauri::command]
|
||||
pub fn preview_profile_yaml(yaml: String) -> Cmd<ProfileView> {
|
||||
let profile = Profile::from_yaml(&yaml).map_err(|e| e.to_string())?;
|
||||
Ok(profile_view::build(&profile, "editor").0)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn clear_profile(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
|
||||
state.lock().clear_profile();
|
||||
emit_ride_state(&app);
|
||||
Ok(state.lock().ride_state())
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SampleProfile {
|
||||
pub name: String,
|
||||
pub summary: String,
|
||||
/// YAML profile source, or GPX XML when `is_gpx`.
|
||||
pub text: String,
|
||||
pub is_gpx: bool,
|
||||
}
|
||||
|
||||
/// Profiles shipped with the app, so there is always something to ride.
|
||||
#[tauri::command]
|
||||
pub fn sample_profiles() -> Vec<SampleProfile> {
|
||||
crate::samples::all()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Devices (FR-1, FR-9.1–9.3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub fn device_list(state: State<'_, AppState>) -> DeviceList {
|
||||
let inner = state.lock();
|
||||
DeviceList { scanning: inner.devices.scanning, devices: inner.devices.list() }
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn start_scan(app: AppHandle, state: State<'_, AppState>) -> Cmd<()> {
|
||||
state.lock().devices.start_scan();
|
||||
emit_devices(&app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn stop_scan(app: AppHandle, state: State<'_, AppState>) -> Cmd<()> {
|
||||
state.lock().devices.stop_scan();
|
||||
emit_devices(&app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn connect_device(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
device_id: String,
|
||||
) -> Cmd<DeviceInfo> {
|
||||
let info = state.lock().devices.connect(&device_id)?;
|
||||
emit_devices(&app);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn disconnect_device(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
device_id: String,
|
||||
) -> Cmd<DeviceInfo> {
|
||||
let info = state.lock().devices.disconnect(&device_id)?;
|
||||
emit_devices(&app);
|
||||
notify(&app, Notice::info(format!("Disconnected {}", info.name)));
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn forget_device(app: AppHandle, state: State<'_, AppState>, device_id: String) -> Cmd<()> {
|
||||
state.lock().devices.forget(&device_id)?;
|
||||
emit_devices(&app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// True once a trainer has FTMS control. The ride screen uses this to warn that
|
||||
/// it is showing simulated data (FR-9.3).
|
||||
#[tauri::command]
|
||||
pub fn trainer_controllable(state: State<'_, AppState>) -> bool {
|
||||
state.lock().devices.trainer_controllable()
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//! Derived ride figures: ETA, distance remaining, rolling averages.
|
||||
//!
|
||||
//! All of this is computed in Rust, not the frontend (§4.3). `RideSnapshot` is
|
||||
//! a frozen contract in `crates/core` and does not carry any of it, so it rides
|
||||
//! alongside the snapshot in a [`RideFrame`].
|
||||
//!
|
||||
//! **ETA (FR-9.15).** The rule that matters: never derive it from
|
||||
//! instantaneous speed. Trainer speed swings several km/h between samples and
|
||||
//! an ETA computed from it flickers uselessly. Three cases:
|
||||
//!
|
||||
//! * **Time-based profile** — remaining time is *known*. No estimation.
|
||||
//! * **Distance-based profile** — remaining distance over a 45-second rolling
|
||||
//! mean speed. When the rider stops, the last good ETA is *held* rather than
|
||||
//! diverging to infinity, and flagged as held so the UI can dim it.
|
||||
//! * **Looping profile** — no finish exists. Report lap position instead of a
|
||||
//! number that would be a lie.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use bikecontrol_core::profile::Profile;
|
||||
use bikecontrol_core::types::RideSnapshot;
|
||||
use serde::Serialize;
|
||||
|
||||
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 displayed power (FR-9.11).
|
||||
pub const POWER_WINDOW_S: f64 = 10.0;
|
||||
/// Window for the normalised-power rolling mean (§12 glossary).
|
||||
const NP_WINDOW_S: f64 = 30.0;
|
||||
/// Below this the rider is not really moving; hold the last ETA.
|
||||
const MIN_ETA_SPEED_KPH: f32 = 2.0;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum EtaKind {
|
||||
/// Time-based profile: the remaining time is exact, not estimated.
|
||||
Exact,
|
||||
/// Distance-based: remaining distance over smoothed speed.
|
||||
Estimated,
|
||||
/// Rider has stopped; showing the last good estimate.
|
||||
Held,
|
||||
/// Looping profile — there is no finish.
|
||||
Looping,
|
||||
/// No profile, or one with no finite extent.
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// Everything the ride screen shows that is not in the frozen `RideSnapshot`.
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Derived {
|
||||
// --- route (the primary readouts) ---------------------------------------
|
||||
pub eta_kind: EtaKind,
|
||||
/// Seconds to the finish. `None` when unavailable or looping.
|
||||
pub time_remaining_s: Option<f64>,
|
||||
pub distance_total_m: Option<f64>,
|
||||
pub distance_remaining_m: Option<f64>,
|
||||
/// Current altitude on the route, metres.
|
||||
pub elevation_m: Option<f32>,
|
||||
pub ascent_remaining_m: Option<f32>,
|
||||
/// Position on the profile's own axis (seconds or metres).
|
||||
pub position_x: f64,
|
||||
pub axis_unit: XUnit,
|
||||
pub axis_total: f64,
|
||||
/// Which lap of a looping profile, 1-based.
|
||||
pub loop_index: Option<u32>,
|
||||
|
||||
// --- motion --------------------------------------------------------------
|
||||
/// 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,
|
||||
|
||||
// --- effort (secondary) ---------------------------------------------------
|
||||
/// Rolling mean power over [`POWER_WINDOW_S`] (FR-9.11).
|
||||
pub rolling_power_w: f32,
|
||||
pub rolling_power_window_s: f64,
|
||||
pub avg_power_w: f32,
|
||||
pub max_power_w: i16,
|
||||
pub normalised_power_w: Option<f32>,
|
||||
pub avg_cadence_rpm: f32,
|
||||
pub energy_kj: f32,
|
||||
}
|
||||
|
||||
/// Rolling windows. One instance lives in the app state for the whole ride.
|
||||
pub struct Deriver {
|
||||
speed: VecDeque<(f64, f32)>,
|
||||
power: VecDeque<(f64, f32)>,
|
||||
np: VecDeque<(f64, f32)>,
|
||||
np_fourth_sum: f64,
|
||||
np_n: u64,
|
||||
power_sum: f64,
|
||||
power_n: u64,
|
||||
cadence_sum: f64,
|
||||
cadence_n: u64,
|
||||
max_power_w: i16,
|
||||
energy_kj: f32,
|
||||
last_elapsed_s: f64,
|
||||
/// Last ETA that was computed from real movement (FR-9.15, hold-on-stop).
|
||||
last_eta_s: Option<f64>,
|
||||
}
|
||||
|
||||
impl Default for Deriver {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
speed: VecDeque::new(),
|
||||
power: VecDeque::new(),
|
||||
np: VecDeque::new(),
|
||||
np_fourth_sum: 0.0,
|
||||
np_n: 0,
|
||||
power_sum: 0.0,
|
||||
power_n: 0,
|
||||
cadence_sum: 0.0,
|
||||
cadence_n: 0,
|
||||
max_power_w: 0,
|
||||
energy_kj: 0.0,
|
||||
last_elapsed_s: 0.0,
|
||||
last_eta_s: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn push_window(window: &mut VecDeque<(f64, f32)>, t: f64, v: f32, span: f64) {
|
||||
window.push_back((t, v));
|
||||
while let Some((t0, _)) = window.front() {
|
||||
if t - t0 > span {
|
||||
window.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mean(window: &VecDeque<(f64, f32)>) -> f32 {
|
||||
if window.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
window.iter().map(|(_, v)| *v as f64).sum::<f64>() as f32 / window.len() as f32
|
||||
}
|
||||
|
||||
impl Deriver {
|
||||
pub fn reset(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
|
||||
/// Fold one snapshot in and produce the derived figures.
|
||||
pub fn update(
|
||||
&mut self,
|
||||
snapshot: &RideSnapshot,
|
||||
running: bool,
|
||||
profile: Option<&Profile>,
|
||||
geom: Option<&ProfileGeometry>,
|
||||
) -> Derived {
|
||||
let t = snapshot.elapsed_ms as f64 / 1000.0;
|
||||
let dt = (t - self.last_elapsed_s).max(0.0);
|
||||
self.last_elapsed_s = t;
|
||||
|
||||
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);
|
||||
|
||||
if running {
|
||||
self.power_sum += power as f64;
|
||||
self.power_n += 1;
|
||||
self.max_power_w = self.max_power_w.max(power as i16);
|
||||
if cadence > 1.0 {
|
||||
self.cadence_sum += cadence as f64;
|
||||
self.cadence_n += 1;
|
||||
}
|
||||
self.energy_kj += power * dt as f32 / 1000.0;
|
||||
// Normalised power: 30 s rolling mean, raised to the fourth,
|
||||
// averaged, fourth root.
|
||||
let rolling = mean(&self.np) as f64;
|
||||
self.np_fourth_sum += rolling.powi(4);
|
||||
self.np_n += 1;
|
||||
}
|
||||
|
||||
let smoothed_speed_kph = mean(&self.speed);
|
||||
|
||||
// ---- route position and ETA ----------------------------------------
|
||||
let mut eta_kind = EtaKind::Unavailable;
|
||||
let mut time_remaining_s = None;
|
||||
let mut distance_total_m = None;
|
||||
let mut distance_remaining_m = None;
|
||||
let mut elevation_m = None;
|
||||
let mut ascent_remaining_m = None;
|
||||
let mut loop_index = None;
|
||||
let mut position_x = 0.0;
|
||||
let mut axis_unit = XUnit::Seconds;
|
||||
let mut axis_total = 0.0;
|
||||
|
||||
if let (Some(profile), Some(geom)) = (profile, geom) {
|
||||
let elapsed_s = t;
|
||||
let distance_m = snapshot.virtual_distance_m;
|
||||
position_x = profile_view::position_x(geom, elapsed_s, distance_m);
|
||||
axis_unit = geom.x_unit;
|
||||
axis_total = geom.total_x;
|
||||
elevation_m = geom.elevation_at(position_x);
|
||||
ascent_remaining_m = geom.ascent_remaining(position_x);
|
||||
distance_total_m = geom.total_metres;
|
||||
|
||||
if profile.looping {
|
||||
eta_kind = EtaKind::Looping;
|
||||
if geom.total_x > 0.0 {
|
||||
let laps = match geom.x_unit {
|
||||
XUnit::Metres => distance_m / geom.total_x,
|
||||
XUnit::Seconds => elapsed_s / geom.total_x,
|
||||
};
|
||||
loop_index = Some(laps.floor() as u32 + 1);
|
||||
}
|
||||
if let Some(total) = geom.total_metres {
|
||||
distance_remaining_m = Some((total - position_x).max(0.0));
|
||||
}
|
||||
} else {
|
||||
match (geom.total_seconds, geom.total_metres) {
|
||||
// Time-based: remaining time is known exactly.
|
||||
(Some(total_s), None) => {
|
||||
eta_kind = EtaKind::Exact;
|
||||
time_remaining_s = Some((total_s - elapsed_s).max(0.0));
|
||||
}
|
||||
// Distance-based (or mixed): estimate from smoothed speed.
|
||||
(_, Some(total_m)) => {
|
||||
let remaining = (total_m - distance_m).max(0.0);
|
||||
distance_remaining_m = Some(remaining);
|
||||
if smoothed_speed_kph >= MIN_ETA_SPEED_KPH {
|
||||
let eta = remaining / (smoothed_speed_kph as f64 * 1000.0 / 3600.0);
|
||||
self.last_eta_s = Some(eta);
|
||||
eta_kind = EtaKind::Estimated;
|
||||
time_remaining_s = Some(eta);
|
||||
} else {
|
||||
eta_kind = EtaKind::Held;
|
||||
time_remaining_s = self.last_eta_s;
|
||||
}
|
||||
// A mixed profile also has a hard time limit; take
|
||||
// whichever finishes first.
|
||||
if let Some(total_s) = geom.total_seconds {
|
||||
let by_time = (total_s - elapsed_s).max(0.0);
|
||||
time_remaining_s =
|
||||
Some(time_remaining_s.map_or(by_time, |e: f64| e.min(by_time)));
|
||||
}
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Derived {
|
||||
eta_kind,
|
||||
time_remaining_s,
|
||||
distance_total_m,
|
||||
distance_remaining_m,
|
||||
elevation_m,
|
||||
ascent_remaining_m,
|
||||
position_x,
|
||||
axis_unit,
|
||||
axis_total,
|
||||
loop_index,
|
||||
smoothed_speed_kph,
|
||||
rolling_power_w: mean(&self.power),
|
||||
rolling_power_window_s: POWER_WINDOW_S,
|
||||
avg_power_w: if self.power_n == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(self.power_sum / self.power_n as f64) as f32
|
||||
},
|
||||
max_power_w: self.max_power_w,
|
||||
normalised_power_w: (self.np_n > 30)
|
||||
.then(|| (self.np_fourth_sum / self.np_n as f64).powf(0.25) as f32),
|
||||
avg_cadence_rpm: if self.cadence_n == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(self.cadence_sum / self.cadence_n as f64) as f32
|
||||
},
|
||||
energy_kj: self.energy_kj,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What lands on the `ride://snapshot` channel: the frozen core snapshot plus
|
||||
/// the derived view data that cannot live in it.
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RideFrame {
|
||||
pub snapshot: RideSnapshot,
|
||||
pub derived: Derived,
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
//! Device discovery and connection state (FR-1, FR-9.1–9.3).
|
||||
//!
|
||||
//! `crates/ble` is not written yet, so this is a **mock scanner**: a scripted
|
||||
//! set of peripherals that appear over a few seconds, with RSSI that drifts and
|
||||
//! connection state machines that take realistic time to settle. It exists so
|
||||
//! the connection screen can be built and judged today.
|
||||
//!
|
||||
//! The important behaviour it models — and the reason it is not just a static
|
||||
//! list — is that **BLE connection and FTMS control acquisition are separate
|
||||
//! steps** (FR-9.3). A trainer goes `Connecting → Connected → Controlling`, and
|
||||
//! it can sit at `Connected` indefinitely if the control point is refused.
|
||||
//!
|
||||
//! Swapping in the real scanner means replacing [`DeviceRegistry::poll`] and
|
||||
//! the two request methods with `btleplug` calls; the `DeviceInfo` the UI
|
||||
//! renders does not change.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use bikecontrol_core::types::ConnectionState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// What we think a peripheral is, from its advertised services and
|
||||
/// manufacturer data (FR-1.2).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DeviceKind {
|
||||
/// Advertises FTMS (`0x1826`).
|
||||
Trainer,
|
||||
/// Zwift custom service, manufacturer type byte identifying the left pod.
|
||||
ClickLeft,
|
||||
ClickRight,
|
||||
HeartRate,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeviceInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub address: String,
|
||||
/// dBm. Roughly −40 (touching) to −95 (barely there).
|
||||
pub rssi: i16,
|
||||
pub kind: DeviceKind,
|
||||
pub state: ConnectionState,
|
||||
/// FTMS control point acquired (FR-2.1). **Connected ≠ controllable**
|
||||
/// (FR-9.3) — this is deliberately a separate field, not a state.
|
||||
pub control_acquired: bool,
|
||||
pub services: Vec<String>,
|
||||
/// 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>,
|
||||
/// Human-readable failure, shown verbatim in the UI (FR-9.2).
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Outcome of one registry tick.
|
||||
pub struct PollResult {
|
||||
pub changed: bool,
|
||||
/// Devices whose connection state settled this tick.
|
||||
pub transitions: Vec<DeviceInfo>,
|
||||
}
|
||||
|
||||
/// A scripted peripheral in the mock environment.
|
||||
struct Simulated {
|
||||
info: DeviceInfo,
|
||||
/// Ticks after scan start before it shows up. Models A-4: the trainer only
|
||||
/// advertises once you pedal, the Click once you press a button.
|
||||
appears_after: u32,
|
||||
/// Ticks remaining in the current transition, and where it lands.
|
||||
pending: Option<(u32, ConnectionState, bool)>,
|
||||
visible: bool,
|
||||
}
|
||||
|
||||
pub struct DeviceRegistry {
|
||||
devices: Vec<Simulated>,
|
||||
forgotten: HashSet<String>,
|
||||
pub scanning: bool,
|
||||
ticks: u32,
|
||||
rng: u64,
|
||||
}
|
||||
|
||||
/// How long each mock transition takes, in registry ticks (2 Hz).
|
||||
const CONNECT_TICKS: u32 = 3;
|
||||
const CONTROL_TICKS: u32 = 3;
|
||||
|
||||
impl Default for DeviceRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
devices: catalogue(),
|
||||
forgotten: HashSet::new(),
|
||||
scanning: false,
|
||||
ticks: 0,
|
||||
rng: 0xDEAD_BEEF_CAFE_F00D,
|
||||
}
|
||||
}
|
||||
|
||||
fn rand(&mut self) -> f32 {
|
||||
let mut x = self.rng;
|
||||
x ^= x >> 12;
|
||||
x ^= x << 25;
|
||||
x ^= x >> 27;
|
||||
self.rng = x;
|
||||
((x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 40) as f32) / (1 << 24) as f32
|
||||
}
|
||||
|
||||
pub fn start_scan(&mut self) {
|
||||
self.scanning = true;
|
||||
self.ticks = 0;
|
||||
for d in &mut self.devices {
|
||||
if !matches!(d.info.state, ConnectionState::Connected | ConnectionState::Controlling) {
|
||||
d.info.state = ConnectionState::Scanning;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop_scan(&mut self) {
|
||||
self.scanning = false;
|
||||
for d in &mut self.devices {
|
||||
if d.info.state == ConnectionState::Scanning {
|
||||
d.info.state = ConnectionState::Idle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance the mock. Reports whether the list changed at all, and which
|
||||
/// devices crossed a connection-state boundary this tick.
|
||||
pub fn poll(&mut self) -> PollResult {
|
||||
let mut changed = false;
|
||||
let mut transitions = Vec::new();
|
||||
if self.scanning {
|
||||
self.ticks += 1;
|
||||
for i in 0..self.devices.len() {
|
||||
let appears = self.devices[i].appears_after;
|
||||
if !self.devices[i].visible && self.ticks >= appears {
|
||||
self.devices[i].visible = true;
|
||||
changed = true;
|
||||
}
|
||||
if self.devices[i].visible {
|
||||
let jitter = (self.rand() * 6.0) as i16 - 3;
|
||||
let base = self.devices[i].info.rssi;
|
||||
let next = (base + jitter).clamp(-95, -38);
|
||||
if next != base {
|
||||
self.devices[i].info.rssi = next;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for d in &mut self.devices {
|
||||
if let Some((remaining, target, control)) = d.pending.take() {
|
||||
if remaining <= 1 {
|
||||
d.info.state = target.clone();
|
||||
d.info.control_acquired = control;
|
||||
if target == ConnectionState::Connected && d.info.kind == DeviceKind::Trainer {
|
||||
// Connected, now go after the FTMS control point.
|
||||
d.pending =
|
||||
Some((CONTROL_TICKS, ConnectionState::Controlling, true));
|
||||
}
|
||||
transitions.push(d.info.clone());
|
||||
changed = true;
|
||||
} else {
|
||||
d.pending = Some((remaining - 1, target, control));
|
||||
}
|
||||
}
|
||||
}
|
||||
PollResult { changed, transitions }
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Vec<DeviceInfo> {
|
||||
self.devices
|
||||
.iter()
|
||||
.filter(|d| d.visible && !self.forgotten.contains(&d.info.id))
|
||||
.map(|d| d.info.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<DeviceInfo> {
|
||||
self.devices.iter().find(|d| d.info.id == id).map(|d| d.info.clone())
|
||||
}
|
||||
|
||||
pub fn connect(&mut self, id: &str) -> Result<DeviceInfo, String> {
|
||||
let device = self
|
||||
.devices
|
||||
.iter_mut()
|
||||
.find(|d| d.info.id == id)
|
||||
.ok_or_else(|| format!("no such device: {id}"))?;
|
||||
if device.info.state == ConnectionState::Controlling {
|
||||
return Err(format!("{} is already connected", device.info.name));
|
||||
}
|
||||
device.info.error = None;
|
||||
device.info.state = ConnectionState::Connecting;
|
||||
device.info.remembered = true;
|
||||
device.pending = Some((CONNECT_TICKS, ConnectionState::Connected, false));
|
||||
Ok(device.info.clone())
|
||||
}
|
||||
|
||||
pub fn disconnect(&mut self, id: &str) -> Result<DeviceInfo, String> {
|
||||
let device = self
|
||||
.devices
|
||||
.iter_mut()
|
||||
.find(|d| d.info.id == id)
|
||||
.ok_or_else(|| format!("no such device: {id}"))?;
|
||||
device.pending = None;
|
||||
device.info.control_acquired = false;
|
||||
device.info.state = if self.scanning { ConnectionState::Scanning } else { ConnectionState::Idle };
|
||||
Ok(device.info.clone())
|
||||
}
|
||||
|
||||
pub fn forget(&mut self, id: &str) -> Result<(), String> {
|
||||
let device = self
|
||||
.devices
|
||||
.iter_mut()
|
||||
.find(|d| d.info.id == id)
|
||||
.ok_or_else(|| format!("no such device: {id}"))?;
|
||||
device.pending = None;
|
||||
device.info.remembered = false;
|
||||
device.info.control_acquired = false;
|
||||
device.info.state = ConnectionState::Idle;
|
||||
device.visible = false;
|
||||
self.forgotten.insert(id.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// True once a trainer is connected *and* controllable — the precondition
|
||||
/// for a real ride (FR-2.1).
|
||||
pub fn trainer_controllable(&self) -> bool {
|
||||
self.devices
|
||||
.iter()
|
||||
.any(|d| d.info.kind == DeviceKind::Trainer && d.info.control_acquired)
|
||||
}
|
||||
}
|
||||
|
||||
fn device(
|
||||
id: &str,
|
||||
name: &str,
|
||||
address: &str,
|
||||
rssi: i16,
|
||||
kind: DeviceKind,
|
||||
services: &[&str],
|
||||
appears_after: u32,
|
||||
) -> Simulated {
|
||||
Simulated {
|
||||
info: DeviceInfo {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
address: address.into(),
|
||||
rssi,
|
||||
kind,
|
||||
state: ConnectionState::Idle,
|
||||
control_acquired: false,
|
||||
services: services.iter().map(|s| s.to_string()).collect(),
|
||||
remembered: false,
|
||||
battery_pct: match kind {
|
||||
DeviceKind::ClickLeft => Some(78),
|
||||
DeviceKind::ClickRight => Some(64),
|
||||
DeviceKind::HeartRate => Some(91),
|
||||
_ => None,
|
||||
},
|
||||
unlock_expires_in_s: match kind {
|
||||
DeviceKind::ClickLeft => Some(0),
|
||||
DeviceKind::ClickRight => Some(41_400),
|
||||
_ => None,
|
||||
},
|
||||
error: None,
|
||||
},
|
||||
appears_after,
|
||||
pending: None,
|
||||
visible: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The mock environment. Timings are in registry ticks (2 Hz), so the trainer
|
||||
/// takes ~2 s to appear and the pods ~4–6 s — long enough that the "wake it by
|
||||
/// pedalling" prompt (FR-1.8) is actually visible.
|
||||
fn catalogue() -> Vec<Simulated> {
|
||||
vec![
|
||||
device(
|
||||
"d100-1",
|
||||
"Van Rysel D100",
|
||||
"E4:2B:11:9A:03:7C",
|
||||
-54,
|
||||
DeviceKind::Trainer,
|
||||
&["0x1826 Fitness Machine", "0x180A Device Information"],
|
||||
4,
|
||||
),
|
||||
device(
|
||||
"click-l",
|
||||
"Zwift Click (left)",
|
||||
"C0:1A:77:12:4E:01",
|
||||
-63,
|
||||
DeviceKind::ClickLeft,
|
||||
&["00000001-19CA-4651-86E5-FA29DCDD09D1"],
|
||||
9,
|
||||
),
|
||||
device(
|
||||
"click-r",
|
||||
"Zwift Click (right)",
|
||||
"C0:1A:77:12:4E:02",
|
||||
-61,
|
||||
DeviceKind::ClickRight,
|
||||
&["00000001-19CA-4651-86E5-FA29DCDD09D1"],
|
||||
11,
|
||||
),
|
||||
device(
|
||||
"hrm-1",
|
||||
"Wahoo TICKR",
|
||||
"D9:44:0B:31:88:2A",
|
||||
-71,
|
||||
DeviceKind::HeartRate,
|
||||
&["0x180D Heart Rate"],
|
||||
14,
|
||||
),
|
||||
device(
|
||||
"unknown-1",
|
||||
"(unnamed peripheral)",
|
||||
"7F:22:C4:08:19:E3",
|
||||
-88,
|
||||
DeviceKind::Unknown,
|
||||
&[],
|
||||
17,
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Event channel from Rust to the webview.
|
||||
//!
|
||||
//! The frontend is a *view* (§4.3): it never computes ride state, it renders
|
||||
//! what arrives here. Every event name is declared once, in this module, and
|
||||
//! mirrored in `ui/src/lib/events.ts`.
|
||||
|
||||
use bikecontrol_core::types::{ConnectionState, ControlMode, ControlTarget};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::devices::DeviceInfo;
|
||||
use crate::profile_view::ProfileView;
|
||||
|
||||
/// `RideSnapshot`, pushed at [`crate::engine::TICK_HZ`].
|
||||
pub const RIDE_SNAPSHOT: &str = "ride://snapshot";
|
||||
/// Low-frequency ride state: status, mode, targets, laps, loaded profile.
|
||||
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 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).
|
||||
pub const DEVICE_CONNECTION: &str = "devices://connection";
|
||||
/// User-facing message: confirmation, warning or error (FR-9.2).
|
||||
pub const APP_NOTICE: &str = "app://notice";
|
||||
/// Acknowledgement that an input registered, so the UI can flash (FR-9.9).
|
||||
pub const INPUT_ACK: &str = "app://input-ack";
|
||||
|
||||
/// Ride lifecycle, mirroring `bikecontrol_core::session::RideStatus` but
|
||||
/// serialisable across the IPC boundary.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RideStatus {
|
||||
Idle,
|
||||
Running,
|
||||
Paused,
|
||||
Finished,
|
||||
}
|
||||
|
||||
/// Everything the ride screen needs that is *not* in a `RideSnapshot`.
|
||||
/// Emitted on change, not on a timer.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RideState {
|
||||
pub status: RideStatus,
|
||||
pub mode: ControlMode,
|
||||
pub target: Option<ControlTarget>,
|
||||
/// Manual gradient trim on top of the profile's base gradient (FR-4.2).
|
||||
pub gradient_offset_pct: f32,
|
||||
pub manual_gradient_pct: f32,
|
||||
pub resistance_level: i16,
|
||||
pub power_target_w: u16,
|
||||
pub lap: u32,
|
||||
pub laps: Vec<LapSummary>,
|
||||
pub profile: Option<ProfileView>,
|
||||
/// Which backend is driving the ride — `"mock"` until `crates/ble` lands.
|
||||
pub source: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LapSummary {
|
||||
pub index: u32,
|
||||
pub elapsed_ms: u64,
|
||||
pub distance_m: f64,
|
||||
pub avg_power_w: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConnectionEvent {
|
||||
pub device_id: String,
|
||||
pub state: ConnectionState,
|
||||
/// FTMS control point acquired. Connected is *not* controllable (FR-9.3).
|
||||
pub control_acquired: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NoticeLevel {
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Notice {
|
||||
pub level: NoticeLevel,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl Notice {
|
||||
pub fn info(message: impl Into<String>) -> Self {
|
||||
Self { level: NoticeLevel::Info, message: message.into() }
|
||||
}
|
||||
pub fn warn(message: impl Into<String>) -> Self {
|
||||
Self { level: NoticeLevel::Warn, message: message.into() }
|
||||
}
|
||||
pub fn error(message: impl Into<String>) -> Self {
|
||||
Self { level: NoticeLevel::Error, message: message.into() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirms an intent was accepted, so the UI can flash the control (FR-9.9).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InputAck {
|
||||
pub action: String,
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
/// The full device list.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeviceList {
|
||||
pub scanning: bool,
|
||||
pub devices: Vec<DeviceInfo>,
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! BikeControl desktop shell.
|
||||
//!
|
||||
//! This crate is *only* wiring: it owns the ride loop, exposes intents as Tauri
|
||||
//! commands, and pushes state to the webview as events. The ride logic proper
|
||||
//! lives in `bikecontrol-core`, and device I/O in `bikecontrol-ble` — the
|
||||
//! webview reaches neither directly (§4.3).
|
||||
|
||||
pub mod backend;
|
||||
pub mod commands;
|
||||
pub mod derive;
|
||||
pub mod devices;
|
||||
pub mod events;
|
||||
pub mod mock;
|
||||
pub mod profile_view;
|
||||
pub mod samples;
|
||||
pub mod session_backend;
|
||||
pub mod state;
|
||||
|
||||
use tauri::{Manager, RunEvent, WindowEvent};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn run() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info,bikecontrol_app_lib=debug".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(AppState::new())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// ride lifecycle
|
||||
commands::ride_state,
|
||||
commands::start_ride,
|
||||
commands::pause_ride,
|
||||
commands::resume_ride,
|
||||
commands::toggle_pause,
|
||||
commands::stop_ride,
|
||||
commands::reset_ride,
|
||||
// control modes and targets
|
||||
commands::set_control_mode,
|
||||
commands::cycle_control_mode,
|
||||
commands::nudge_gradient,
|
||||
commands::set_gradient,
|
||||
commands::reset_gradient,
|
||||
commands::set_target_resistance,
|
||||
commands::set_target_power,
|
||||
commands::mark_lap,
|
||||
// configuration
|
||||
commands::rider_config,
|
||||
commands::set_rider_config,
|
||||
commands::safety_limits,
|
||||
commands::set_safety_limits,
|
||||
// profiles
|
||||
commands::load_profile_from_path,
|
||||
commands::load_profile_from_text,
|
||||
commands::preview_profile_yaml,
|
||||
commands::clear_profile,
|
||||
commands::sample_profiles,
|
||||
// devices
|
||||
commands::device_list,
|
||||
commands::start_scan,
|
||||
commands::stop_scan,
|
||||
commands::connect_device,
|
||||
commands::disconnect_device,
|
||||
commands::forget_device,
|
||||
commands::trainer_controllable,
|
||||
])
|
||||
.setup(|app| {
|
||||
let handle = app.handle().clone();
|
||||
// NFR-7: scanning starts immediately, not on a user click.
|
||||
handle.state::<AppState>().lock().devices.start_scan();
|
||||
state::spawn_ride_loop(handle.clone());
|
||||
state::spawn_device_loop(handle.clone());
|
||||
state::emit_devices(&handle);
|
||||
state::emit_ride_state(&handle);
|
||||
Ok(())
|
||||
})
|
||||
.build(tauri::generate_context!())
|
||||
.expect("failed to start BikeControl")
|
||||
.run(|app, event| {
|
||||
// SAF-2 — on any exit path, hand the trainer back at zero load.
|
||||
if let RunEvent::ExitRequested { .. } = &event {
|
||||
state::release_trainer(app);
|
||||
}
|
||||
if let RunEvent::WindowEvent { event: WindowEvent::Destroyed, .. } = &event {
|
||||
state::release_trainer(app);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Hide the console window on Windows release builds.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
bikecontrol_app_lib::run();
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
//! A synthetic rider, so the UI can be built and judged before `crates/ble`
|
||||
//! and `crates/core` are finished.
|
||||
//!
|
||||
//! 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,
|
||||
virtual_speed_kph: self.speed_ms * 3.6,
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
//! The route, as the ride screen needs to draw it.
|
||||
//!
|
||||
//! `crates/core` owns profile *semantics* — `Profile::sample`,
|
||||
//! `Profile::preview`, `Profile::total_extent`. This module owns the *view
|
||||
//! model*: the elevation trace, the block breakdown, and the geometry needed to
|
||||
//! place the current-position marker and answer "how much climbing is left".
|
||||
//!
|
||||
//! 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 serde::Serialize;
|
||||
|
||||
/// Which axis the profile is drawn against.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum XUnit {
|
||||
#[default]
|
||||
Seconds,
|
||||
Metres,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BlockSummary {
|
||||
pub index: usize,
|
||||
/// `constant` | `ramp` | `wave` | `segments` | `terrain`
|
||||
pub kind: &'static str,
|
||||
pub channel: Channel,
|
||||
pub label: String,
|
||||
pub start_x: f64,
|
||||
pub end_x: f64,
|
||||
pub unit: XUnit,
|
||||
}
|
||||
|
||||
/// Everything the UI needs to draw a profile (FR-6.7, FR-9.7).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileView {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub looping: bool,
|
||||
/// Where it came from: a file path, a sample name, or `"editor"`.
|
||||
pub source: String,
|
||||
/// The channel the value series plots.
|
||||
pub channel: Channel,
|
||||
pub x_unit: XUnit,
|
||||
pub total_x: f64,
|
||||
/// Total ride duration, if the profile is measured in time.
|
||||
pub total_seconds: Option<f64>,
|
||||
/// Total ride distance, if the profile is measured in distance.
|
||||
pub total_metres: Option<f64>,
|
||||
/// `[x, value]` along the axis — gradient %, watts or resistance level.
|
||||
pub series: Vec<[f64; 2]>,
|
||||
/// `[distance_m, elevation_m]`. Real elevation for GPX-derived terrain,
|
||||
/// integrated from gradient otherwise. This is the hero chart.
|
||||
pub elevation: Option<Vec<[f64; 2]>>,
|
||||
pub elevation_min_m: Option<f32>,
|
||||
pub elevation_max_m: Option<f32>,
|
||||
pub total_ascent_m: Option<f32>,
|
||||
pub blocks: Vec<BlockSummary>,
|
||||
/// The profile as YAML, for the in-app editor.
|
||||
pub yaml: String,
|
||||
}
|
||||
|
||||
/// Precomputed geometry kept Rust-side so per-tick lookups are cheap. Never
|
||||
/// serialised — the frontend gets answers, not arrays to search.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ProfileGeometry {
|
||||
pub xs: Vec<f64>,
|
||||
pub elevation: Vec<f32>,
|
||||
/// Cumulative ascent at each sample, so "climbing remaining" is a
|
||||
/// subtraction rather than a scan.
|
||||
pub cum_ascent: Vec<f32>,
|
||||
pub total_x: f64,
|
||||
pub x_unit: XUnit,
|
||||
pub looping: bool,
|
||||
pub total_seconds: Option<f64>,
|
||||
pub total_metres: Option<f64>,
|
||||
}
|
||||
|
||||
impl ProfileGeometry {
|
||||
/// Elevation at a position on the axis, linearly interpolated.
|
||||
pub fn elevation_at(&self, x: f64) -> Option<f32> {
|
||||
interp(&self.xs, &self.elevation, x)
|
||||
}
|
||||
|
||||
/// Metres of climbing still to come from `x` to the end.
|
||||
pub fn ascent_remaining(&self, x: f64) -> Option<f32> {
|
||||
let total = *self.cum_ascent.last()?;
|
||||
let done = interp(&self.xs, &self.cum_ascent, x)?;
|
||||
Some((total - done).max(0.0))
|
||||
}
|
||||
|
||||
pub fn total_ascent(&self) -> Option<f32> {
|
||||
self.cum_ascent.last().copied()
|
||||
}
|
||||
}
|
||||
|
||||
fn interp(xs: &[f64], ys: &[f32], x: f64) -> Option<f32> {
|
||||
if xs.is_empty() || xs.len() != ys.len() {
|
||||
return None;
|
||||
}
|
||||
if x <= xs[0] {
|
||||
return Some(ys[0]);
|
||||
}
|
||||
let last = xs.len() - 1;
|
||||
if x >= xs[last] {
|
||||
return Some(ys[last]);
|
||||
}
|
||||
let i = xs.partition_point(|v| *v <= x).clamp(1, last);
|
||||
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 })
|
||||
}
|
||||
|
||||
const PREVIEW_SAMPLES: usize = 1400;
|
||||
|
||||
fn extent_parts(extent: Extent) -> (f64, XUnit) {
|
||||
match extent {
|
||||
Extent::Seconds(s) => (s.max(0.0), XUnit::Seconds),
|
||||
Extent::Metres(m) => (m.max(0.0), XUnit::Metres),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the view model and the geometry that goes with it.
|
||||
pub fn build(profile: &Profile, source: impl Into<String>) -> (ProfileView, ProfileGeometry) {
|
||||
let extent = profile.total_extent();
|
||||
let x_unit = match (extent.metres, extent.seconds) {
|
||||
(Some(m), Some(s)) => {
|
||||
if m >= s {
|
||||
XUnit::Metres
|
||||
} else {
|
||||
XUnit::Seconds
|
||||
}
|
||||
}
|
||||
(Some(_), None) => XUnit::Metres,
|
||||
_ => XUnit::Seconds,
|
||||
};
|
||||
|
||||
let preview = profile.preview(PREVIEW_SAMPLES);
|
||||
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);
|
||||
|
||||
// Elevation. Prefer the real thing: a GPX import lands as a `Terrain`
|
||||
// block that already carries surveyed elevation. Otherwise integrate the
|
||||
// gradient, which is what a hand-authored segment profile implies anyway.
|
||||
let mut geom = ProfileGeometry {
|
||||
total_x,
|
||||
x_unit,
|
||||
looping: profile.looping,
|
||||
total_seconds: extent.seconds,
|
||||
total_metres: extent.metres,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let elevation: Option<Vec<[f64; 2]>> = if channel == Channel::Gradient {
|
||||
let surveyed = surveyed_elevation(profile);
|
||||
let pairs = match surveyed {
|
||||
Some(points) => points,
|
||||
None if x_unit == XUnit::Metres => integrate_gradient(&series),
|
||||
None => Vec::new(),
|
||||
};
|
||||
if pairs.len() < 2 {
|
||||
None
|
||||
} else {
|
||||
geom.xs = pairs.iter().map(|p| p[0]).collect();
|
||||
geom.elevation = pairs.iter().map(|p| p[1] as f32).collect();
|
||||
let mut cum = Vec::with_capacity(geom.elevation.len());
|
||||
let mut acc = 0.0f32;
|
||||
let mut prev = geom.elevation[0];
|
||||
for e in &geom.elevation {
|
||||
acc += (e - prev).max(0.0);
|
||||
prev = *e;
|
||||
cum.push(acc);
|
||||
}
|
||||
geom.cum_ascent = cum;
|
||||
Some(pairs)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (elevation_min_m, elevation_max_m) = match &geom.elevation {
|
||||
e if e.is_empty() => (None, None),
|
||||
e => (
|
||||
Some(e.iter().copied().fold(f32::INFINITY, f32::min)),
|
||||
Some(e.iter().copied().fold(f32::NEG_INFINITY, f32::max)),
|
||||
),
|
||||
};
|
||||
|
||||
let mut blocks = Vec::with_capacity(profile.blocks.len());
|
||||
let mut cursor = 0.0f64;
|
||||
for (index, block) in profile.blocks.iter().enumerate() {
|
||||
let (span, unit) = extent_parts(block.extent());
|
||||
blocks.push(BlockSummary {
|
||||
index,
|
||||
kind: block_kind(block),
|
||||
channel: block.channel(),
|
||||
label: block_label(block),
|
||||
start_x: cursor,
|
||||
end_x: cursor + span,
|
||||
unit,
|
||||
});
|
||||
cursor += span;
|
||||
}
|
||||
|
||||
let view = ProfileView {
|
||||
name: profile.name.clone(),
|
||||
description: profile.description.clone(),
|
||||
looping: profile.looping,
|
||||
source: source.into(),
|
||||
channel,
|
||||
x_unit,
|
||||
total_x,
|
||||
total_seconds: extent.seconds,
|
||||
total_metres: extent.metres,
|
||||
series,
|
||||
elevation,
|
||||
elevation_min_m,
|
||||
elevation_max_m,
|
||||
total_ascent_m: geom.total_ascent(),
|
||||
blocks,
|
||||
yaml: serde_yaml_ng::to_string(profile).unwrap_or_default(),
|
||||
};
|
||||
(view, geom)
|
||||
}
|
||||
|
||||
/// Elevation straight out of `Terrain` blocks, offset so consecutive blocks
|
||||
/// join up rather than each restarting at zero distance.
|
||||
fn surveyed_elevation(profile: &Profile) -> Option<Vec<[f64; 2]>> {
|
||||
let mut out: Vec<[f64; 2]> = Vec::new();
|
||||
let mut offset = 0.0f64;
|
||||
let mut any = false;
|
||||
for block in &profile.blocks {
|
||||
let (span, _) = extent_parts(block.extent());
|
||||
if let Block::Terrain { points } = block {
|
||||
any = true;
|
||||
let base = points.first().map(|p| p.distance_m).unwrap_or(0.0);
|
||||
for p in points {
|
||||
out.push([offset + (p.distance_m - base), p.elevation_m as f64]);
|
||||
}
|
||||
}
|
||||
offset += span;
|
||||
}
|
||||
any.then_some(out)
|
||||
}
|
||||
|
||||
/// Integrate gradient over distance to get a relative elevation trace.
|
||||
fn integrate_gradient(series: &[[f64; 2]]) -> Vec<[f64; 2]> {
|
||||
let mut elev = 0.0f64;
|
||||
let mut prev_x = series.first().map(|p| p[0]).unwrap_or(0.0);
|
||||
series
|
||||
.iter()
|
||||
.map(|[x, grade]| {
|
||||
elev += (x - prev_x).max(0.0) * (grade / 100.0);
|
||||
prev_x = *x;
|
||||
[*x, elev]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Where the rider is on the preview axis right now.
|
||||
pub fn position_x(geom: &ProfileGeometry, elapsed_s: f64, distance_m: f64) -> f64 {
|
||||
let raw = match geom.x_unit {
|
||||
XUnit::Seconds => elapsed_s,
|
||||
XUnit::Metres => distance_m,
|
||||
};
|
||||
if geom.looping && geom.total_x > 0.0 {
|
||||
raw.rem_euclid(geom.total_x)
|
||||
} else {
|
||||
raw.clamp(0.0, geom.total_x.max(0.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 }
|
||||
}
|
||||
|
||||
fn block_kind(block: &Block) -> &'static str {
|
||||
match block {
|
||||
Block::Constant { .. } => "constant",
|
||||
Block::Ramp { .. } => "ramp",
|
||||
Block::Wave { .. } => "wave",
|
||||
Block::Segments { .. } => "segments",
|
||||
Block::Terrain { .. } => "terrain",
|
||||
}
|
||||
}
|
||||
|
||||
fn unit_suffix(channel: Channel) -> &'static str {
|
||||
match channel {
|
||||
Channel::Gradient => "%",
|
||||
Channel::Resistance => "",
|
||||
Channel::Power => " W",
|
||||
}
|
||||
}
|
||||
|
||||
fn block_label(block: &Block) -> String {
|
||||
let u = unit_suffix(block.channel());
|
||||
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!(
|
||||
"{} {:.0}{u} ±{:.0}{u} ×{:.0}",
|
||||
match shape {
|
||||
Waveform::Sine => "sine",
|
||||
Waveform::Square => "square",
|
||||
Waveform::Triangle => "triangle",
|
||||
Waveform::Sawtooth => "sawtooth",
|
||||
},
|
||||
midpoint,
|
||||
amplitude,
|
||||
repeats
|
||||
),
|
||||
Block::Segments { segments } => {
|
||||
let d: f64 = segments.iter().map(|s| s.distance_m).sum();
|
||||
format!("{} segments · {:.1} km", segments.len(), d / 1000.0)
|
||||
}
|
||||
Block::Terrain { points } => {
|
||||
let d = points.last().map(|p| p.distance_m).unwrap_or(0.0);
|
||||
format!("terrain · {:.1} km", d / 1000.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Profiles shipped with the app, so there is always something to ride and the
|
||||
//! YAML schema (`crates/core/src/profile.rs`) has worked examples.
|
||||
|
||||
use crate::commands::SampleProfile;
|
||||
|
||||
const OVER_UNDERS: &str = r#"name: Over-unders
|
||||
description: Ten minutes up to threshold, then eight over-under cycles, then easy.
|
||||
looping: false
|
||||
blocks:
|
||||
- type: ramp
|
||||
channel: power
|
||||
from: 110
|
||||
to: 210
|
||||
extent: { seconds: 600 }
|
||||
- type: wave
|
||||
channel: power
|
||||
shape: sine
|
||||
midpoint: 245
|
||||
amplitude: 45
|
||||
period: { seconds: 120 }
|
||||
repeats: 8
|
||||
- type: constant
|
||||
channel: power
|
||||
value: 120
|
||||
extent: { seconds: 300 }
|
||||
"#;
|
||||
|
||||
const HILL_REPEATS: &str = r#"name: Hill repeats
|
||||
description: Four kilometres of rolling terrain, looped. Gradient by distance.
|
||||
looping: true
|
||||
blocks:
|
||||
- type: segments
|
||||
segments:
|
||||
- { distance_m: 600, gradient_pct: 1.0 }
|
||||
- { distance_m: 900, gradient_pct: 6.5 }
|
||||
- { distance_m: 300, gradient_pct: 9.0 }
|
||||
- { distance_m: 500, gradient_pct: -3.0 }
|
||||
- { distance_m: 700, gradient_pct: 4.0 }
|
||||
- { distance_m: 1000, gradient_pct: -2.0 }
|
||||
"#;
|
||||
|
||||
const SAWTOOTH_GRADE: &str = r#"name: Sawtooth grade
|
||||
description: A gradient sawtooth for shakedown testing — every 400 m ramps 0 to 8%.
|
||||
looping: true
|
||||
blocks:
|
||||
- type: wave
|
||||
channel: gradient
|
||||
shape: sawtooth
|
||||
midpoint: 4.0
|
||||
amplitude: 4.0
|
||||
period: { metres: 400 }
|
||||
repeats: 20
|
||||
"#;
|
||||
|
||||
const STEADY_ENDURANCE: &str = r#"name: Steady endurance
|
||||
description: Ninety minutes at a fixed grade, with a gentle triangular trim.
|
||||
looping: false
|
||||
blocks:
|
||||
- type: constant
|
||||
channel: gradient
|
||||
value: 2.0
|
||||
extent: { seconds: 900 }
|
||||
- type: wave
|
||||
channel: gradient
|
||||
shape: triangle
|
||||
midpoint: 3.0
|
||||
amplitude: 2.5
|
||||
period: { seconds: 600 }
|
||||
repeats: 7
|
||||
- type: constant
|
||||
channel: gradient
|
||||
value: 0.0
|
||||
extent: { seconds: 600 }
|
||||
"#;
|
||||
|
||||
/// 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();
|
||||
out.insert(
|
||||
0,
|
||||
SampleProfile {
|
||||
name: "Sample climb".into(),
|
||||
summary: "3 km GPX with real GPS elevation noise — smoothed on import.".into(),
|
||||
text: SAMPLE_CLIMB_GPX.to_string(),
|
||||
is_gpx: true,
|
||||
},
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
fn header(yaml: &str) -> (String, String) {
|
||||
let mut name = String::from("Profile");
|
||||
let mut summary = String::new();
|
||||
for line in yaml.lines() {
|
||||
if let Some(rest) = line.strip_prefix("name: ") {
|
||||
name = rest.trim().to_string();
|
||||
} else if let Some(rest) = line.strip_prefix("description: ") {
|
||||
summary = rest.trim().to_string();
|
||||
}
|
||||
}
|
||||
(name, summary)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! The real backend: `bikecontrol_core::RideSession` driven by trainer
|
||||
//! telemetry.
|
||||
//!
|
||||
//! Compiled only under `--features real-session`, because
|
||||
//! `RideSession::tick`/`snapshot` are still `todo!()` and would panic on the
|
||||
//! first tick. Enabling the feature (and disabling `mock-ride`) is the whole
|
||||
//! swap — nothing above [`crate::backend::RideBackend`] changes, and the
|
||||
//! frontend does not change at all.
|
||||
#![cfg(feature = "real-session")]
|
||||
|
||||
use bikecontrol_core::session::{RideSession, SessionEvent};
|
||||
use bikecontrol_core::types::{ControlTarget, RideSnapshot, Telemetry};
|
||||
use tokio::sync::watch;
|
||||
|
||||
use crate::backend::{RideBackend, RideInputs, Tick};
|
||||
use crate::events::RideStatus;
|
||||
|
||||
pub struct SessionBackend {
|
||||
session: RideSession,
|
||||
/// Latest decoded Indoor Bike Data, published by `bikecontrol_ble`.
|
||||
telemetry: watch::Receiver<Telemetry>,
|
||||
last_snapshot: Option<RideSnapshot>,
|
||||
}
|
||||
|
||||
impl SessionBackend {
|
||||
pub fn new(inputs: &RideInputs, telemetry: watch::Receiver<Telemetry>) -> Self {
|
||||
Self {
|
||||
session: RideSession::new(inputs.rider, inputs.limits),
|
||||
telemetry,
|
||||
last_snapshot: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RideBackend for SessionBackend {
|
||||
fn source(&self) -> &'static str {
|
||||
"ftms"
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.session = RideSession::new(self.session.config, self.session.limits);
|
||||
}
|
||||
|
||||
fn tick(&mut self, dt_s: f32, inputs: &RideInputs) -> Tick {
|
||||
self.session.mode = inputs.mode;
|
||||
if let Some(profile) = inputs.profile.as_deref() {
|
||||
if self.session.profile().is_none() {
|
||||
self.session.load_profile(profile.clone());
|
||||
}
|
||||
}
|
||||
match inputs.status {
|
||||
RideStatus::Running => self.session.start(),
|
||||
RideStatus::Paused => self.session.pause(),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let telemetry = *self.telemetry.borrow();
|
||||
let mut command = None;
|
||||
let mut snapshot = None;
|
||||
for event in self.session.tick(telemetry, dt_s) {
|
||||
match event {
|
||||
SessionEvent::Command(target) => command = Some(target),
|
||||
SessionEvent::Snapshot(s) => snapshot = Some(s),
|
||||
SessionEvent::ProfileFinished | SessionEvent::Lap { .. } => {}
|
||||
}
|
||||
}
|
||||
let snapshot = snapshot
|
||||
.or(self.last_snapshot)
|
||||
.unwrap_or_else(|| self.session.snapshot(telemetry));
|
||||
self.last_snapshot = Some(snapshot);
|
||||
|
||||
let _: Option<ControlTarget> = command;
|
||||
Tick { snapshot, command }
|
||||
}
|
||||
}
|
||||
@@ -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 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<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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user