Files
BikeControl/src-tauri/src/commands.rs
T
dtourolleandClaude Opus 5 5dff2500e2 Open picked files through the content resolver, not std::fs
Loading a GPX on Android failed for every file in the picker. The dialog
plugin fires ACTION_GET_CONTENT, which returns a `content://` URI, and
`load_profile_from_path` handed that straight to `std::fs::read_to_string`
— "no such file or directory" for a file the rider is looking at. Picking
from Nextcloud makes it plainer: a document provider backed by a server
may have no local file at all until the resolver opens the stream, so
there was never a path to find.

So the command now takes a `FilePath` and reads through tauri-plugin-fs,
which opens a path directly and a URI via the resolver. The plugin is
here for `FsExt` alone; nothing in ui/ calls its commands, so the
capabilities are unchanged.

Two things that were derived from the filename can no longer be:

  - GPX is detected by content. A document id need not contain a name,
    let alone an extension. No YAML profile begins with `<`.
  - The route name falls back to the GPX's own <name>. Providers over
    real storage encode the filename in the last segment, but an opaque
    row id would have made a wretched route name.

save_fit had the same bug on the export side — PathBuf::from on a
save-dialog URI — and now writes down a resolver descriptor when handed
one.

Note for anyone rebuilding locally: gen/android/tauri.settings.gradle is
autogenerated and lists each plugin's Android project, so the new
plugin's Kotlin only reaches the APK after `cargo tauri android init` and
scripts/sync-android-sources.sh. CI already runs both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 19:08:36 +02:00

798 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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::{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 tauri_plugin_fs::{FilePath, FsExt, OpenOptions};
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<T> = Result<T, String>;
/// 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<RideState> {
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<RideState> {
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<RideState> {
// 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<RideState> {
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<RideState> {
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<Recovered> {
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<RideSummary> {
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: FilePath) -> Cmd<String> {
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)
};
match &path {
FilePath::Path(dest) => recording::save_copy(&source, dest)?,
// Android: the save dialog returns a `content://` URI for a document
// the provider has already created. There is no directory to make and
// no path to copy to — the bytes go down a descriptor the resolver
// opens, which is the same reason `read_picked_file` exists.
FilePath::Url(_) => write_through_resolver(&app, &path, &source)?,
}
let written = path.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)
}
/// Copy the activity into a document the rider chose from an Android picker.
///
/// The `std::fs` path in `recording::save_copy` cannot do this: there is no
/// filesystem path on the other end, only a URI the content resolver can turn
/// into a writable descriptor. Still a copy, never a move, for the reason
/// `save_copy` documents — the automatic file in the rides directory has to
/// survive a failed export.
fn write_through_resolver(app: &AppHandle, dest: &FilePath, source: &Path) -> Result<(), String> {
let mut from = std::fs::File::open(source)
.map_err(|e| format!("{} is gone — nothing to save: {e}", source.display()))?;
let mut to = app
.fs()
.open(
dest.clone(),
OpenOptions::new().write(true).truncate(true).clone(),
)
.map_err(|e| format!("could not save to {dest}: {e}"))?;
std::io::copy(&mut from, &mut to)
.map(|_| ())
.map_err(|e| format!("could not save to {dest}: {e}"))
}
#[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())
}
/// 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<RideState> {
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<RideState> {
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<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();
// 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<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())
}
}
/// Read a file the rider picked, wherever it actually lives.
///
/// `std::fs` is not enough, and Android is why. The dialog plugin's picker
/// fires `ACTION_GET_CONTENT`, which hands back a `content://` URI rather than
/// a path; `std::fs::read_to_string` on one of those fails with "no such file
/// or directory" — an error the rider gets for a file they are looking at in
/// the picker. And for a provider backed by a server rather than storage
/// (Nextcloud, Drive) there may be no local file at all until the resolver
/// opens the stream, so no amount of path-guessing could have found one.
///
/// `tauri_plugin_fs` is the piece that knows the difference: a plain path is
/// opened directly, a URI goes through the Android content resolver for a file
/// descriptor. On desktop it is `std::fs` with extra steps.
fn read_picked_file(app: &AppHandle, path: &FilePath) -> Result<String, String> {
app.fs()
.read_to_string(path.clone())
.map_err(|e| format!("{path}: {e}"))
}
/// Whether a picked file is GPX, decided by content rather than by name.
///
/// The extension is not always there to read: a `content://` URI carries a
/// document id, which for some providers contains no filename at all. XML is
/// unmistakable next to the YAML profile format — no YAML document begins with
/// `<` — so the first non-space character is the reliable test and the
/// extension is only a fast path.
fn looks_like_gpx(path: &FilePath, text: &str) -> bool {
path.to_string().to_ascii_lowercase().ends_with(".gpx") || text.trim_start().starts_with('<')
}
/// The filename behind a picked file, if there is one to be had.
///
/// A `FilePath::Path` always has a stem. A URI might: providers over real
/// storage encode the path in the last segment, percent-escaped but with the
/// extension intact (`primary%3ADownload%2Fventoux.gpx`). Others use opaque row
/// ids, which would make a terrible route name — so "does it end in an
/// extension we know" is the test, and anything else gets `None` and falls back
/// to the name inside the GPX.
fn picked_file_stem(path: &FilePath) -> Option<String> {
match path {
FilePath::Path(p) => p.file_stem().map(|s| s.to_string_lossy().to_string()),
FilePath::Url(url) => {
let (stem, ext) = url.path_segments()?.next_back()?.rsplit_once('.')?;
if !matches!(ext.to_ascii_lowercase().as_str(), "gpx" | "yaml" | "yml") {
return None;
}
// The escaped separators are all that stands between the document
// id and the name inside it.
let decoded = stem
.replace("%2F", "/")
.replace("%2f", "/")
.replace("%3A", ":")
.replace("%3a", ":");
let name = decoded.rsplit(['/', ':']).next().unwrap_or(&decoded);
(!name.is_empty()).then(|| name.to_string())
}
}
}
/// The route's own name: `<metadata><name>`, else the first `<trk><name>`.
///
/// The fallback when the picker gave us no filename to use. Matches on local
/// names so a namespaced document (`<g:trk>`) is not silently skipped, for the
/// same reason `core::gpx` does.
fn gpx_name(xml: &str) -> Option<String> {
let doc = roxmltree::Document::parse(xml).ok()?;
let named = |parent: &str| {
doc.descendants()
.find(|n| n.is_element() && n.tag_name().name() == parent)?
.children()
.find(|c| c.is_element() && c.tag_name().name() == "name")?
.text()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
};
named("metadata").or_else(|| named("trk"))
}
/// Load a profile the rider picked: a path on desktop, a `content://` URI on
/// Android. GPX is detected by content, everything else is treated as the YAML
/// profile format.
#[tauri::command]
pub fn load_profile_from_path(
app: AppHandle,
state: State<'_, AppState>,
path: FilePath,
) -> Cmd<ProfileView> {
let text = read_picked_file(&app, &path)?;
let is_gpx = looks_like_gpx(&path, &text);
let name = picked_file_stem(&path)
.or_else(|| gpx_name(&text))
.unwrap_or_else(|| "Profile".into());
let profile = parse_profile(&text, &name, is_gpx)?;
let (view, geom) = profile_view::build(&profile, path.to_string());
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. 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<Pod>,
device_id: Option<String>,
) -> 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<Pod>,
) -> 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(())
}