Three defects on the path between MainActivity and the scan loop, all of which presented as "no trainer found". initBtleplug ran after super.onCreate, which is a race rather than a clean ordering bug: the super chain dispatches Rust.create(), and tao's ndk_glue spawns a thread to run `run()` on. That thread builds the AppState and starts the scan loop concurrently. Reaching btleplug first hits droidplug's global_adapter(), which is an `expect` — the scan task panics and scanning is dead for the process, silently and only on some phones. Initialising before super.onCreate means the race cannot be lost. The same panic was reachable without any race, because init failure was logged and shrugged off while every later call still went through to `expect`. Failing soft is right; it just needed READY, so the call sites can produce an ordinary "no adapter" instead of taking the task down (NFR-4). MainActivity retries the init on resume, which is idempotent, so a rider who launched with Bluetooth off recovers by going to Settings. Neither of those covers a radio the rider switches off, which btleplug does not model at all: getDefaultAdapter() returns a disabled adapter whose scans just find nothing. MainActivity now watches ACTION_STATE_CHANGED — the quick-settings shade never fires onResume — and pushes the state to Rust, with requestBluetoothEnable coming back the other way so the connection screen can offer the system dialog rather than describing an empty room. Tri-state on purpose: unknown is not off, or a rider with a working radio gets told to switch it on at launch. Also: the adapter hint told Android riders to check BlueZ. Verified on debug and release APKs for aarch64. Release matters separately here — every one of these classes is reached only by name over JNI, so R8 would strip or rename the lot and the failure would appear only in a shipped build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
690 lines
24 KiB
Rust
690 lines
24 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 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<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: String) -> Cmd<String> {
|
||
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<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())
|
||
}
|
||
}
|
||
|
||
/// 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. 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(())
|
||
}
|