//! 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 = Result; // --------------------------------------------------------------------------- // 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 { { 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 { 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 { 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 { 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 { 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 { 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()) } /// 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(); 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. 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() }