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>
424 lines
14 KiB
Rust
424 lines
14 KiB
Rust
//! 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()
|
||
}
|