`RiderConfig` lived in `RideInputs` and nowhere else, and nothing in the UI ever called `set_rider_config`. So every ride was ridden as the struct's own default — a 105 kg rider on an 8 kg bike — with no way to say otherwise short of editing the source. Mass is not a preference: it sets the speed a given power produces, the ETA that follows from it, the calorie estimate, and how a 6% ramp feels. FR-7.4 is a Must, and a command the UI never calls does not satisfy it. - settings.rs persists rider config, safety limits and display preferences to app_data_dir()/settings.json, written atomically and read back before the first tick, so no snapshot is ever computed against the default. Advisory like known.rs: an unreadable file costs the rider their setup, never their ride. - A stored file is refused *whole* if it fails the same checks the commands apply. It may predate a tightened bound or have been edited by hand, and a zero mass reaching the engine divides by itself on the next tick. - The commands validate with instructions rather than codes — "CdA must be between 0.1 and 1.5 m² — a road position is about 0.32" — because this is now a form a rider fills in, not a struct only I ever touched. - Preferences (FTP, maximum heart rate, units) are Tauri-side, not in `RiderConfig`. None of it reaches the physics, and crates/core is the frozen contract the engine and the FIT writer share. Zero is a real answer for both references and means "no zones", not "unset and guessed at". - SettingsScreen commits on field-exit and reseats every input from what Rust returned, so a rejected value can never sit on screen looking accepted. Weight, FTP and units are on top; the eight settings with a defensible default are folded away. - Reachable on `,` from any screen, returning to whichever screen opened it. Setup swallows the ride controls while it is up — a stray arrow key while reading the form must not trim the gradient of a ride happening behind it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
198 lines
8.2 KiB
Rust
198 lines
8.2 KiB
Rust
//! BikeControl desktop shell.
|
|
//!
|
|
//! This crate is *only* wiring: it owns the ride loop, exposes intents as Tauri
|
|
//! commands, and pushes state to the webview as events. The ride logic proper
|
|
//! lives in `bikecontrol-core`, and device I/O in `bikecontrol-ble` — the
|
|
//! webview reaches neither directly (§4.3).
|
|
|
|
#[cfg(target_os = "android")]
|
|
pub mod android;
|
|
pub mod backend;
|
|
pub mod commands;
|
|
pub mod controller;
|
|
pub mod derive;
|
|
pub mod devices;
|
|
pub mod events;
|
|
pub mod heart_rate;
|
|
pub mod known;
|
|
pub mod settings;
|
|
pub mod profile_view;
|
|
pub mod recording;
|
|
pub mod samples;
|
|
pub mod session_backend;
|
|
pub mod state;
|
|
pub mod trainer;
|
|
pub mod wakelock;
|
|
|
|
use tauri::{Manager, RunEvent, WindowEvent};
|
|
|
|
use crate::state::AppState;
|
|
|
|
/// Set up `tracing` for whatever this platform calls "somewhere I can read it".
|
|
fn init_tracing() {
|
|
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
|
// `bikecontrol_ble` belongs here as much as the shell does: it owns every
|
|
// BLE conversation, so leaving it at `info` silences exactly the frames
|
|
// NFR-8 says must be loggable — subscribe failures, bad button frames,
|
|
// the lot. That is survivable on desktop, where RUST_LOG can override
|
|
// it, and not on Android, where there is no environment to set.
|
|
.unwrap_or_else(|_| "info,bikecontrol_app_lib=debug,bikecontrol_ble=debug".into());
|
|
|
|
// Android has no stdout: the default writer would drop every line. See
|
|
// `android::Logcat`.
|
|
#[cfg(target_os = "android")]
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(filter)
|
|
.with_ansi(false)
|
|
.with_writer(android::Logcat)
|
|
.init();
|
|
|
|
#[cfg(not(target_os = "android"))]
|
|
tracing_subscriber::fmt().with_env_filter(filter).init();
|
|
}
|
|
|
|
/// The entry point, on every platform.
|
|
///
|
|
/// `main.rs` calls this on desktop. On Android there is no `main`: the
|
|
/// attribute below generates the `start_app` symbol that Tauri's generated
|
|
/// Kotlin invokes, which is why the whole app lives in a lib crate.
|
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
|
pub fn run() {
|
|
init_tracing();
|
|
|
|
// Before anything spawns: every BLE call is made from a Tauri task, and on
|
|
// Android a task running on a thread the JVM has never seen cannot reach
|
|
// droidplug at all. See `android::install_async_runtime`.
|
|
#[cfg(target_os = "android")]
|
|
android::install_async_runtime();
|
|
|
|
tauri::Builder::default()
|
|
.plugin(tauri_plugin_dialog::init())
|
|
.plugin(tauri_plugin_fs::init())
|
|
.manage(AppState::new())
|
|
.invoke_handler(tauri::generate_handler![
|
|
// ride lifecycle
|
|
commands::ride_state,
|
|
commands::start_ride,
|
|
commands::pause_ride,
|
|
commands::resume_ride,
|
|
commands::toggle_pause,
|
|
commands::stop_ride,
|
|
commands::reset_ride,
|
|
// recording and export
|
|
commands::ride_summary,
|
|
commands::save_fit,
|
|
commands::recovered_rides,
|
|
// control modes and targets
|
|
commands::set_control_mode,
|
|
commands::cycle_control_mode,
|
|
commands::shift_gear,
|
|
commands::set_gear,
|
|
commands::nudge_gradient,
|
|
commands::set_gradient,
|
|
commands::reset_gradient,
|
|
commands::set_target_resistance,
|
|
commands::set_target_power,
|
|
commands::mark_lap,
|
|
// configuration
|
|
commands::rider_config,
|
|
commands::set_rider_config,
|
|
commands::safety_limits,
|
|
commands::set_safety_limits,
|
|
commands::preferences,
|
|
commands::set_preferences,
|
|
// profiles
|
|
commands::load_profile_from_path,
|
|
commands::load_profile_from_text,
|
|
commands::preview_profile_yaml,
|
|
commands::clear_profile,
|
|
commands::sample_profiles,
|
|
// devices
|
|
commands::device_list,
|
|
commands::start_scan,
|
|
commands::stop_scan,
|
|
commands::connect_device,
|
|
commands::disconnect_device,
|
|
commands::forget_device,
|
|
commands::trainer_controllable,
|
|
commands::request_bluetooth_enable,
|
|
// controller
|
|
commands::controller_status,
|
|
commands::connect_controller,
|
|
commands::disconnect_controller,
|
|
commands::swap_controller_pods,
|
|
])
|
|
.setup(|app| {
|
|
let handle = app.handle().clone();
|
|
// FR-1.5: the hardware the rider paired with last time, before the
|
|
// first scan pass so the very first thing the scanner sees can be
|
|
// reconnected rather than merely listed. A missing data directory
|
|
// costs auto-connect and nothing else, so it is a warning, not a
|
|
// failed launch.
|
|
match known::store_path(&handle) {
|
|
Ok(path) => handle.state::<AppState>().lock().devices.attach_store(path),
|
|
Err(e) => tracing::warn!(error = %e, "remembered devices unavailable"),
|
|
}
|
|
// FR-7.4: the rider's mass, bike and drag, before the first tick
|
|
// reads them. Restored ahead of the ride loop starting so no
|
|
// snapshot is ever computed against the 105 kg default the struct
|
|
// falls back to.
|
|
match settings::store_path(&handle) {
|
|
Ok(path) => {
|
|
let state = handle.state::<AppState>();
|
|
let mut inner = state.lock();
|
|
let inner = &mut *inner;
|
|
let (rider, limits) = (&mut inner.inputs.rider, &mut inner.inputs.limits);
|
|
inner.settings.attach(path, rider, limits);
|
|
}
|
|
Err(e) => tracing::warn!(error = %e, "rider settings unavailable"),
|
|
}
|
|
// NFR-7: scanning starts immediately, not on a user click.
|
|
handle.state::<AppState>().lock().devices.start_scan();
|
|
state::spawn_ride_loop(handle.clone());
|
|
state::spawn_device_loop(handle.clone());
|
|
state::spawn_controller_loop(handle.clone());
|
|
// FR-8.4: a journal with no activity beside it is a ride the app
|
|
// died during. Rebuilding it is the same code path a clean stop
|
|
// uses, so the rider gets the same file they would have had.
|
|
//
|
|
// The result is stashed rather than emitted: nothing is listening
|
|
// on the event channel yet, and a recovered ride is exactly the
|
|
// thing that must not be announced to an empty room. The webview
|
|
// collects it via `recovered_rides` when it starts.
|
|
let recovered = recording::recover_orphans(&handle);
|
|
handle.state::<AppState>().lock().recovered = recovered;
|
|
recording::prune(&handle, commands::KEEP_RECORDINGS);
|
|
state::emit_devices(&handle);
|
|
state::emit_ride_state(&handle);
|
|
Ok(())
|
|
})
|
|
.build(tauri::generate_context!())
|
|
.expect("failed to start BikeControl")
|
|
.run(|app, event| {
|
|
// SAF-2 / SAF-9 — on any exit path, hand the trainer back at zero
|
|
// load and close every link the app owns. `shutdown_devices` blocks
|
|
// until the reset sequence has actually been written; a
|
|
// fire-and-forget send would race the process teardown and leave the
|
|
// rider on a loaded trainer. It is idempotent, which matters because
|
|
// one quit delivers several of these events.
|
|
match &event {
|
|
RunEvent::ExitRequested { .. } | RunEvent::Exit => {
|
|
// NFR-11: hand the screensaver back too. The inhibitor would
|
|
// lapse with the process anyway, but not before a slow
|
|
// shutdown, and a released lock is one fewer thing to explain.
|
|
crate::wakelock::set(false);
|
|
state::shutdown_devices(app)
|
|
}
|
|
RunEvent::WindowEvent {
|
|
event: WindowEvent::Destroyed,
|
|
..
|
|
} => {
|
|
crate::wakelock::set(false);
|
|
state::shutdown_devices(app)
|
|
}
|
|
_ => {}
|
|
}
|
|
});
|
|
}
|