//! 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 std::path::PathBuf; use bikecontrol_core::gpx::{self, SmoothingConfig}; use bikecontrol_core::profile::Profile; use bikecontrol_core::types::{ControlMode, RiderConfig, SafetyLimits}; use tauri::{AppHandle, State}; use bikecontrol_ble::PodId; use crate::controller::{ControllerStatus, Pod}; use crate::devices::DeviceInfo; use crate::events::{DeviceList, LapSummary, Notice, RideState, RideStatus}; use crate::profile_view::{self, ProfileView}; use crate::recording::{self, Recovered, RideRecordingSetup, RideSummary}; use crate::state::{ack, emit_devices, emit_ride_state, notify, AppState}; type Cmd = Result; /// Ride time now, for journal entries that need a timestamp. Zero before the /// first tick, which is the correct answer rather than a missing one. fn elapsed_ms(state: &AppState) -> u64 { state.lock().last_snapshot.map_or(0, |s| s.elapsed_ms) } // --------------------------------------------------------------------------- // 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 { begin_ride(&app, &state); ack(&app, "start", None); emit_ride_state(&app); Ok(state.lock().ride_state()) } /// Put the ride into `Running`, opening a journal if this is a fresh start. /// /// Every route into a running ride goes through here — the Start button, the /// space bar via [`toggle_pause`], and Click face button B. Any new path that /// set `Running` on its own would ride with no recorder attached, and the rider /// would not find out until the summary said nothing had been saved. fn begin_ride(app: &AppHandle, state: &AppState) { let setup = { let mut inner = state.lock(); // A fresh ride, as opposed to resuming a paused one. Only a fresh ride // opens a new journal; resuming must keep writing to the current one. let fresh = matches!(inner.inputs.status, RideStatus::Finished | RideStatus::Idle); if fresh { inner.reset_ride(); } inner.inputs.status = RideStatus::Running; fresh.then(|| RideRecordingSetup { stamp: recording::stamp_now(), rider_kg: inner.inputs.rider.rider_kg, has_profile: inner.inputs.profile.is_some(), }) }; // Started outside the lock: creating the journal touches the disk. let Some(setup) = setup else { // Resuming, not starting: the journal is already open and only needs // its timer restarted. state.recorder().resume(elapsed_ms(state)); return; }; let started = recording::rides_dir(app).and_then(|dir| state.recorder().start(&dir, setup)); if let Err(e) = started { // The ride still starts. Refusing to ride because a file could not be // opened would be the wrong trade — but the rider has to be told this // one will not be saved. tracing::error!(%e, "recording did not start"); notify( app, Notice::error(format!("{e} — this ride will not be saved")), ); } } #[tauri::command] pub fn pause_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd { state.lock().inputs.status = RideStatus::Paused; state.recorder().pause(elapsed_ms(&state)); 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 { // Not a bare status assignment: resuming from `Finished` is a *new* ride and // must open a journal rather than run on unrecorded. begin_ride(&app, &state); 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, and it is also how a ride is /// *started* from the launch screen — hence the trip through [`begin_ride`] /// rather than a status assignment. #[tauri::command] pub fn toggle_pause(app: AppHandle, state: State<'_, AppState>) -> Cmd { let running = state.lock().inputs.status == RideStatus::Running; if running { state.lock().inputs.status = RideStatus::Paused; state.recorder().pause(elapsed_ms(&state)); } else { begin_ride(&app, &state); } let status = state.lock().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. /// /// The activity is written automatically, before anything is shown and before /// the rider is asked anything (FR-8.2). Saving to a location they choose is a /// copy made afterwards (FR-9.14, [`save_fit`]) — a rider who cancels that /// dialog, or closes the window, still has their ride. #[tauri::command] pub fn stop_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd { state.lock().inputs.status = RideStatus::Finished; // The trainer comes first. Whatever happens to the file, the rider must not // be left on a loaded trainer while we talk to the disk. crate::state::release_trainer(&app); ack(&app, "stop", None); notify(&app, Notice::info("Ride ended — trainer released to 0%")); match state.recorder().finish() { Ok(Some((summary, fit_path))) => { let summary = RideSummary::new(&summary, &fit_path); state.lock().last_summary = Some(summary.clone()); let _ = tauri::Emitter::emit(&app, crate::events::RIDE_SUMMARY, summary); recording::prune(&app, KEEP_RECORDINGS); } // Nothing was recording — a ride that never started, or a recorder that // failed to open at the start and already said so. Ok(None) => {} Err(e) => { tracing::error!(%e, "could not finalise the activity"); notify(&app, Notice::error(e)); } } emit_ride_state(&app); Ok(state.lock().ride_state()) } /// How many finished rides stay in the app's data directory. /// /// §5.8 puts ride *history* out of scope for v1: the FIT the rider saved is the /// artifact, and this directory is the safety net behind it. Unbounded it would /// grow forever somewhere nobody looks. pub const KEEP_RECORDINGS: usize = 20; /// Rides rebuilt from an interrupted session at startup (FR-8.4). /// /// Draining rather than reading: this is reported to the rider once, and a /// webview reload should not re-announce a recovery they have already seen. #[tauri::command] pub fn recovered_rides(state: State<'_, AppState>) -> Vec { std::mem::take(&mut state.lock().recovered) } /// The most recently finished ride, if the summary screen is reloaded. #[tauri::command] pub fn ride_summary(state: State<'_, AppState>) -> Option { state.lock().last_summary.clone() } /// Save the finished activity where the rider asked (FR-9.14). /// /// Returns the path actually written, so the UI can confirm it rather than /// claiming success against a path it merely proposed. #[tauri::command] pub fn save_fit(app: AppHandle, state: State<'_, AppState>, path: String) -> Cmd { let dest = PathBuf::from(&path); let source = { let inner = state.lock(); let summary = inner .last_summary .as_ref() .ok_or("There is no finished ride to save")?; PathBuf::from(&summary.fit_path) }; recording::save_copy(&source, &dest)?; let written = dest.display().to_string(); if let Some(summary) = state.lock().last_summary.as_mut() { summary.saved_path = Some(written.clone()); } ack(&app, "save-fit", Some(written.clone())); notify(&app, Notice::info(format!("Ride saved to {written}"))); Ok(written) } #[tauri::command] pub fn reset_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd { 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 { { 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 { 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()) } /// Shift the virtual gear by `delta` (FR-4.1). /// /// Clamps at both ends rather than wrapping: going from top gear straight to /// bottom mid-climb would be violent, and a rider holding the paddle down /// expects to arrive at the end of the cassette and stay there. /// /// This is the one place a shift happens. The controller loop and the keyboard /// both route here, so the pod and the keys cannot drift apart, and neither can /// also nudge the gradient on the way past — a shift changes how hard the /// pedals are, not what the road is doing. #[tauri::command] pub fn shift_gear(app: AppHandle, state: State<'_, AppState>, delta: i32) -> Cmd { let (gear, count) = { let mut inner = state.lock(); inner.inputs.shift_gear(delta); (inner.inputs.gear, inner.inputs.gear_count()) }; ack(&app, "gear", Some(format!("{gear}/{count}"))); emit_ride_state(&app); Ok(state.lock().ride_state()) } /// Select a gear directly, one-based (FR-4.1). Out-of-range values clamp. #[tauri::command] pub fn set_gear(app: AppHandle, state: State<'_, AppState>, gear: usize) -> Cmd { let (gear, count) = { let mut inner = state.lock(); inner.inputs.set_gear(gear); (inner.inputs.gear, inner.inputs.gear_count()) }; ack(&app, "gear", Some(format!("{gear}/{count}"))); 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 { 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 { { 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 { { 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 { { 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 { { 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 { let lap = state.lock().mark_lap(); // The journal takes the ride time the lap closed at, not the lap's own // duration — the two differ from the second lap onwards. state.recorder().mark_lap(elapsed_ms(&state), false); 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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. This is what gates the ride screen: /// connected is not controllable, and a ride nothing is driving is not a ride /// (FR-9.3). #[tauri::command] pub fn trainer_controllable(state: State<'_, AppState>) -> bool { state.lock().devices.trainer_controllable() } /// Ask the platform to switch the Bluetooth radio on. /// /// Only Android can answer this: there, a disabled radio is a normal state the /// rider reaches by accident and can fix from inside the app. On desktop the /// remedy is the system's business, so this is a no-op and the connection screen /// keeps showing the adapter error. /// /// Returns nothing on purpose. The rider may decline, and some OEM dialogs claim /// success before the radio is up, so the only trustworthy answer is the one /// that arrives on the device list a moment later (§4.3). #[tauri::command] pub fn request_bluetooth_enable() { #[cfg(target_os = "android")] crate::android::request_bluetooth_enable(); } // --------------------------------------------------------------------------- // Controller (Zwift Click) // --------------------------------------------------------------------------- #[tauri::command] pub fn controller_status(state: State<'_, AppState>) -> ControllerStatus { state.controller().status() } /// Connect a Click pod, or both when `pod` is omitted (FR-1.4). /// /// `device_id` is an address, for a specific pod the scanner has already /// listed. Without one the supervisor looks the pod up by the type byte in its /// advertisement — never by name, because both pods of a pair advertise the /// same one and the app used to get whichever answered first. /// /// Fire-and-forget: the supervisor owns the radio and the result arrives on /// `controller://status`. A Click sleeps within seconds and only advertises /// after a button press (A-4), so this routinely takes a few attempts — which /// is why it must not block the UI thread waiting for one. #[tauri::command] pub fn connect_controller( state: State<'_, AppState>, pod: Option, device_id: Option, ) -> Cmd<()> { let controller = state.controller(); let address = device_id.filter(|id| !id.trim().is_empty()); match pod { Some(pod) => controller.connect(pod.into(), address), None => { if address.is_some() { return Err("An address names one pod, so say which pod it is".into()); } // Both, each on its own schedule: a pod that is awake connects now // rather than queueing behind its sleeping twin. let known = state.lock().devices.click_pod_addresses(); for id in PodId::BOTH { controller.connect(id, known.get(&id).cloned()); } } } Ok(()) } /// Disconnect one pod, or both when `pod` is omitted. #[tauri::command] pub fn disconnect_controller( app: AppHandle, state: State<'_, AppState>, pod: Option, ) -> Cmd<()> { state.controller().disconnect(pod.map(PodId::from)); notify( &app, Notice::info(match pod { Some(Pod::Plus) => "+ pod disconnected", Some(Pod::Minus) => "− pod disconnected", None => "Both Click pods disconnected", }), ); Ok(()) } /// Exchange the two pods, for when they answer to the other name. /// /// §2.3.1 confirms one manufacturer-data type byte per pod but not which byte /// belongs to which, so the app starts from a documented guess. Pressing a /// paddle shows the rider whether the guess was right; this is how they fix it /// if it was not. #[tauri::command] pub fn swap_controller_pods(app: AppHandle, state: State<'_, AppState>) -> Cmd<()> { state.controller().swap(); notify(&app, Notice::info("Swapped the + and − pods")); Ok(()) }