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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 13:34:27 +02:00
co-authored by Claude Opus 5
parent 3e106de2c5
commit 7c17ca6158
61 changed files with 20933 additions and 55 deletions
+423
View File
@@ -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.19.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()
}