//! The trainer supervisor: the app's single owner of an [`FtmsClient`]. //! //! Everything BLE-shaped happens in one background task. The Tauri commands and //! the ride loop are synchronous and hold a `Mutex`, so they may never `.await` //! a radio; they talk to this task over a channel instead, and read its results //! from two `watch` channels: //! //! ```text //! commands ──connect/target/release──► [supervisor task] ──► FtmsClient //! ride loop ◄──watch──────── │ //! device loop ◄──watch────────┘ //! ``` //! //! Three properties are load-bearing: //! //! * **SAF-2** — [`TrainerHandle::shutdown_blocking`] is callable from Tauri's //! synchronous `RunEvent` handler, and waits for `FtmsClient::shutdown` to //! actually finish. A fire-and-forget send would race the process exit and //! leave the trainer loaded. //! * **FR-9.3** — `control_acquired` is tracked separately from the connection //! state. Connected is not controllable. //! * **Never freeze on stale data** — if the trainer goes quiet while //! nominally connected, the published telemetry is *zeroed* rather than held, //! so the ride engine coasts to a stop and the UI visibly reacts instead of //! showing a plausible-looking lie (FR-1.8). use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{sync_channel, SyncSender}; use std::sync::Arc; use std::time::{Duration, Instant}; use bikecontrol_ble::{ Backoff, ControlOutcome, FtmsClient, FtmsConfig, FtmsError, FtmsEvent, TrainerSelector, }; use bikecontrol_core::types::{ConnectionState, ControlTarget, SafetyLimits, Telemetry}; use serde::Serialize; use tokio::sync::{broadcast, mpsc, watch}; /// How long the trainer may stay silent before its telemetry is treated as /// stale. The D100 notifies at 4 Hz, so three seconds is ~12 missed frames. const STALE_AFTER: Duration = Duration::from_secs(3); /// Housekeeping tick — staleness only, so it can be lazy. const HOUSEKEEPING: Duration = Duration::from_millis(500); /// Upper bound on the SAF-2 sequence at app exit (NFR-9). Longer than the BLE /// layer's own per-write timeouts, short enough not to hang a window close. const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(8); /// Upper bound on the client's own reset sequence, kept under /// [`SHUTDOWN_TIMEOUT`] so the supervisor always answers before the caller /// stops listening. A caller that times out learns nothing and leaves. const SAFETY_SEQUENCE_TIMEOUT: Duration = Duration::from_secs(7); /// How long auto-reconnect keeps trying before it gives up and says so. /// /// FR-1.11. Retrying forever sounds kinder than giving up, but it is not: the /// link sits in `Reconnecting` indefinitely, the screen keeps implying the /// trainer is on its way back, and the rider is never told to go and look at /// it. Twenty attempts against the default backoff is about seven minutes. pub(crate) const RECONNECT_ATTEMPTS: u32 = 20; /// What the UI needs to know about the trainer link (FR-1.7, FR-9.3). #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct TrainerStatus { pub state: ConnectionState, /// FTMS control point acquired. Deliberately *not* folded into `state`: /// connected is not controllable (FR-9.3). pub control_acquired: bool, pub address: Option, pub name: Option, /// Human-readable failure, rendered verbatim (FR-9.2). pub error: Option, /// Connected, but no Indoor Bike Data for [`STALE_AFTER`]. The rider needs /// to know the numbers stopped being real (FR-1.8). pub stale: bool, } impl Default for TrainerStatus { fn default() -> Self { Self { state: ConnectionState::Idle, control_acquired: false, address: None, name: None, error: None, stale: false, } } } impl TrainerStatus { /// True once a ride would actually reach the trainer (FR-2.1). pub fn controllable(&self) -> bool { self.control_acquired && self.state == ConnectionState::Controlling } pub fn is_attached(&self) -> bool { !matches!( self.state, ConnectionState::Idle | ConnectionState::Lost { .. } ) } } enum Cmd { Connect(TrainerSelector), Disconnect, Target(ControlTarget), /// SAF-2 without dropping the link: used at the end of a ride, so the next /// ride does not have to reconnect. Release { limits: SafetyLimits, }, /// Full SAF-2 sequence plus disconnect. Used on app exit. Shutdown { reply: SyncSender<()>, }, /// A spawned control write finished. Only reported when it failed. WriteFailed(String), } /// Cheap, cloneable handle to the supervisor. #[derive(Clone)] pub struct TrainerHandle { cmd_tx: mpsc::Sender, status_rx: watch::Receiver, telemetry_rx: watch::Receiver, /// Shared, so every clone of the handle sees that SAF-2 has already run. shut_down: Arc, } impl TrainerHandle { /// Start the supervisor task. Must be called with a Tokio runtime available /// — `tauri::async_runtime` provides one before the app is built. pub fn spawn(config: FtmsConfig) -> Self { let (cmd_tx, cmd_rx) = mpsc::channel(32); let (status_tx, status_rx) = watch::channel(TrainerStatus::default()); let (telemetry_tx, telemetry_rx) = watch::channel(Telemetry::default()); let handle = Self { cmd_tx: cmd_tx.clone(), status_rx, telemetry_rx, shut_down: Arc::new(AtomicBool::new(false)), }; tauri::async_runtime::spawn(run(cmd_rx, cmd_tx, config, status_tx, telemetry_tx)); handle } pub fn status(&self) -> TrainerStatus { self.status_rx.borrow().clone() } /// Latest telemetry, for the ride engine. Zeroed while disconnected or /// stale, never a held-over sample. pub fn telemetry(&self) -> watch::Receiver { self.telemetry_rx.clone() } pub fn connect(&self, selector: TrainerSelector) { self.send(Cmd::Connect(selector)); } pub fn disconnect(&self) { self.send(Cmd::Disconnect); } /// Push a control target. Fire-and-forget by design: the ride loop must not /// block on the radio, and [`FtmsClient`] already coalesces so the newest /// target wins (FR-2.8). pub fn set_target(&self, target: ControlTarget) { self.send(Cmd::Target(target)); } /// SAF-2 at the end of a ride: zero gradient / minimum resistance, link kept. pub fn release(&self, limits: SafetyLimits) { self.send(Cmd::Release { limits }); } /// SAF-2 at app exit: full reset sequence, then disconnect. Blocks the /// calling (non-async) thread until it is done or [`SHUTDOWN_TIMEOUT`] /// elapses. /// Idempotent: Tauri delivers `ExitRequested`, `Exit` and window /// `Destroyed` on one quit, and the second and third calls must be quiet /// no-ops rather than warnings about a supervisor that has already done its /// job. pub fn shutdown_blocking(&self) { if self.shut_down.swap(true, Ordering::SeqCst) { return; } let deadline = Instant::now() + SHUTDOWN_TIMEOUT; let (reply, done) = sync_channel(1); // Not `try_send` and give up: the one command that must not be dropped // is this one, and a queue that is briefly full — the ride loop pushes a // target every 250 ms — is not a reason to skip SAF-2. This is already a // blocking call, so spending a little of its budget on getting the // command in is free. let mut cmd = Cmd::Shutdown { reply }; loop { match self.cmd_tx.try_send(cmd) { Ok(()) => break, Err(mpsc::error::TrySendError::Closed(_)) => { tracing::warn!("trainer supervisor already stopped; nothing to release"); return; } Err(mpsc::error::TrySendError::Full(returned)) => { if Instant::now() >= deadline { tracing::error!("could not reach the trainer supervisor to run SAF-2"); return; } cmd = returned; std::thread::sleep(Duration::from_millis(20)); } } } let remaining = deadline.saturating_duration_since(Instant::now()); match done.recv_timeout(remaining) { Ok(()) => tracing::info!("trainer released (SAF-2)"), Err(e) => tracing::error!(error = %e, "SAF-2 shutdown did not complete in time"), } } fn send(&self, cmd: Cmd) { if self.cmd_tx.try_send(cmd).is_err() { // Capacity 32 against a 4 Hz producer: a full queue means the // supervisor is wedged, which the status channel already reports. tracing::warn!("trainer command dropped — supervisor queue full or closed"); } } } // --------------------------------------------------------------------------- // Supervisor // --------------------------------------------------------------------------- /// The client's three output streams are kept as separate locals rather than /// one struct, so each `select!` arm borrows a distinct binding. async fn run( mut cmd_rx: mpsc::Receiver, cmd_tx: mpsc::Sender, config: FtmsConfig, status_tx: watch::Sender, telemetry_tx: watch::Sender, ) { let mut client: Option> = None; let mut telemetry_rx: Option> = None; let mut events_rx: Option> = None; let mut state_rx: Option> = None; let mut last_sample: Option = None; let mut status = TrainerStatus::default(); loop { tokio::select! { // Telemetry first (NFR-2): control writes are rate-limited anyway. biased; sample = next_telemetry(&mut telemetry_rx) => match sample { Some(sample) => { last_sample = Some(Instant::now()); if status.stale { status.stale = false; publish(&status_tx, &status); } let _ = telemetry_tx.send(sample); } None => { // The client actor stopped; the state watcher reports why. telemetry_rx = None; } }, event = next_event(&mut events_rx) => match event { Some(FtmsEvent::ControlFault { reason }) => { tracing::error!(reason, "trainer control fault (SAF-4)"); status.control_acquired = false; status.error = Some(reason); publish(&status_tx, &status); } Some(_) => {} // NFR-10. A closed stream is *ready* forever, and this select is // biased: left in place it wins every poll and the command arm // below is never reached again — including for the shutdown // command. Drop it and let the state arm explain what happened. None => events_rx = None, }, state = next_state(&mut state_rx) => match state { Some(state) => { status.control_acquired = state == ConnectionState::Controlling; if let ConnectionState::Lost { reason } = &state { status.error = Some(reason.clone()); // A lost link must not leave the last sample standing. let _ = telemetry_tx.send(Telemetry::default()); last_sample = None; } status.state = state; publish(&status_tx, &status); } // The client actor stopped without being asked to — it spent its // reconnect budget (FR-1.11). Everything we hold is now a zombie: // the handle would fail one write at a time with nothing to // explain it, and the closed streams would spin the loop. None => { telemetry_rx = None; events_rx = None; state_rx = None; client = None; last_sample = None; let _ = telemetry_tx.send(Telemetry::default()); let reason = status.error.clone().unwrap_or_else(|| { "the trainer link ended and could not be re-established".to_string() }); tracing::warn!(reason, "trainer supervisor lost its client"); status.control_acquired = false; status.stale = false; // Address and name stay: the rider needs a row to click on // to try again (FR-1.12). status.state = ConnectionState::Lost { reason }; publish(&status_tx, &status); } }, cmd = cmd_rx.recv() => match cmd { None => break, Some(Cmd::Shutdown { reply }) => { let subs = (&mut telemetry_rx, &mut events_rx, &mut state_rx); shutdown(client.take(), subs, &telemetry_tx).await; status = TrainerStatus::default(); publish(&status_tx, &status); let _ = reply.send(()); break; } Some(Cmd::Disconnect) => { let subs = (&mut telemetry_rx, &mut events_rx, &mut state_rx); shutdown(client.take(), subs, &telemetry_tx).await; last_sample = None; // Keep who it was. A trainer stops advertising the moment it // is in use, so a scan will not necessarily put it back — // wiping the address here drops it out of the device list // altogether and leaves the rider nothing to click on to // reconnect (FR-1.12). let (address, name) = (status.address.take(), status.name.take()); status = TrainerStatus { address, name, ..TrainerStatus::default() }; publish(&status_tx, &status); } Some(Cmd::Connect(selector)) => { // Authoritative guard against a duplicate click racing the // status watch: reconnecting a live session would drop the // rider's trainer mid-ride for no reason. if client.is_some() && status.is_attached() && already_selected(&status, &selector) { tracing::debug!(selector = selector.describe(), "already connected; ignoring"); continue; } let subs = (&mut telemetry_rx, &mut events_rx, &mut state_rx); shutdown(client.take(), subs, &telemetry_tx).await; last_sample = None; status = TrainerStatus { state: ConnectionState::Connecting, ..TrainerStatus::default() }; publish(&status_tx, &status); // FR-1.10 / SAF-8. A connect runs for up to `scan_timeout` // plus connect, discovery and handshake — far longer than // the shutdown budget. Awaiting it here without a way out is // what makes a quit-while-connecting skip SAF-2 entirely and // leave the rider on a loaded trainer, so the attempt is // abandoned instead of finished. let mut abort: Option = None; let outcome = { let cancel = async { abort = Some(abort_signal(&mut cmd_rx, &selector).await); }; FtmsClient::connect_cancellable( selector.clone(), config.clone(), cancel, ) .await }; match outcome { Ok(Some(c)) => { tracing::info!( address = c.address(), name = c.name().unwrap_or("(no name)"), caps = ?c.capabilities(), "trainer connected and controllable" ); telemetry_rx = Some(c.telemetry()); events_rx = Some(c.events()); state_rx = Some(c.state_stream()); status = TrainerStatus { state: c.state(), control_acquired: c.state() == ConnectionState::Controlling, address: Some(c.address().to_string()), name: c.name().map(str::to_string), error: None, stale: false, }; client = Some(Arc::new(c)); last_sample = Some(Instant::now()); } Ok(None) => { tracing::info!( selector = selector.describe(), "connect abandoned; the link it had opened is closed" ); status = TrainerStatus::default(); } Err(e) => { tracing::warn!(error = %e, selector = selector.describe(), "trainer connect failed"); status = TrainerStatus { state: ConnectionState::Lost { reason: e.to_string() }, error: Some(connect_hint(&e)), ..TrainerStatus::default() }; } } publish(&status_tx, &status); // Whatever interrupted the connect still has to be honoured. match abort { None => {} Some(Abort::Disconnect) => { let subs = (&mut telemetry_rx, &mut events_rx, &mut state_rx); shutdown(client.take(), subs, &telemetry_tx).await; last_sample = None; let (address, name) = (status.address.take(), status.name.take()); status = TrainerStatus { address, name, ..TrainerStatus::default() }; publish(&status_tx, &status); } Some(Abort::Connect(next)) => { // Back of the queue rather than a recursive call: // the loop picks it up on its next turn, once this // attempt has been fully unwound. let subs = (&mut telemetry_rx, &mut events_rx, &mut state_rx); shutdown(client.take(), subs, &telemetry_tx).await; last_sample = None; if cmd_tx.try_send(Cmd::Connect(next)).is_err() { tracing::warn!("could not re-queue the trainer the rider picked"); } } Some(Abort::Shutdown(reply)) => { let subs = (&mut telemetry_rx, &mut events_rx, &mut state_rx); shutdown(client.take(), subs, &telemetry_tx).await; status = TrainerStatus::default(); publish(&status_tx, &status); let _ = reply.send(()); break; } Some(Abort::Closed) => break, } } Some(Cmd::Target(target)) => { if let Some(c) = client.clone() { let back = cmd_tx.clone(); // Spawned, not awaited: an unacknowledged write takes up // to `ack_timeout`, and the telemetry pump must keep // running through it. tauri::async_runtime::spawn(async move { match c.set_target(target).await { Ok(ControlOutcome::Acknowledged { sent }) => { tracing::debug!(?sent, "trainer accepted target") } Ok(ControlOutcome::Superseded) => {} Err(e) => { tracing::warn!(?target, error = %e, "control write failed"); let _ = back.try_send(Cmd::WriteFailed(e.to_string())); } } }); } } Some(Cmd::Release { limits }) => { if let Some(c) = client.clone() { let safe = limits.clamp(ControlTarget::Gradient { percent: 0.0 }); tauri::async_runtime::spawn(async move { if let Err(e) = c.set_target(safe).await { tracing::warn!(error = %e, "SAF-2 release write failed"); } }); } } Some(Cmd::WriteFailed(reason)) => { if status.error.as_deref() != Some(reason.as_str()) { status.error = Some(reason); publish(&status_tx, &status); } } }, _ = tokio::time::sleep(HOUSEKEEPING) => { let quiet = last_sample.is_some_and(|t| t.elapsed() >= STALE_AFTER); if quiet && !status.stale && status.is_attached() { tracing::warn!("no Indoor Bike Data for {STALE_AFTER:?} — zeroing telemetry"); status.stale = true; publish(&status_tx, &status); // Zero rather than hold: a frozen readout is worse than an // obviously dead one. let _ = telemetry_tx.send(Telemetry::default()); } }, } } tracing::info!("trainer supervisor stopped"); } /// A command that arrived while a connect was in flight and means "stop" /// (FR-1.10). It is carried out of the attempt rather than acted on there, /// because the attempt has to be unwound first. enum Abort { Disconnect, Shutdown(SyncSender<()>), /// The rider picked a different trainer while this one was still connecting. Connect(TrainerSelector), /// Every handle dropped. Closed, } /// Watch for a reason to abandon a connect that is currently running. /// /// Resolves only on a command that must interrupt the attempt. Targets and /// releases arriving mid-connect are consumed and dropped rather than left to /// pile up: there is no link to write them to, and the ride loop re-sends its /// target every tick. Draining is half the point — a command channel nobody /// reads for fifteen seconds is a command channel that fills. async fn abort_signal(cmd_rx: &mut mpsc::Receiver, connecting_to: &TrainerSelector) -> Abort { loop { match cmd_rx.recv().await { None => return Abort::Closed, Some(Cmd::Shutdown { reply }) => return Abort::Shutdown(reply), Some(Cmd::Disconnect) => return Abort::Disconnect, // Picking a *different* trainer means "not that one, this one". // Finishing the first attempt before starting the second would make // the rider wait out a scan timeout for a choice they have already // changed. The same trainer clicked twice is only impatience. Some(Cmd::Connect(selector)) if selector != *connecting_to => { return Abort::Connect(selector) } Some(_) => tracing::trace!("command dropped: nothing to send it to yet"), } } } /// Run SAF-2 and drop the client. #[allow(clippy::type_complexity)] async fn shutdown( client: Option>, subs: ( &mut Option>, &mut Option>, &mut Option>, ), telemetry_tx: &watch::Sender, ) { *subs.0 = None; *subs.1 = None; *subs.2 = None; let _ = telemetry_tx.send(Telemetry::default()); let Some(client) = client else { return }; // Bounded, so the supervisor always answers `shutdown_blocking` before that // caller stops listening. A caller that times out reports a failure it // cannot do anything about and then lets the process exit anyway. match tokio::time::timeout(SAFETY_SEQUENCE_TIMEOUT, client.shutdown_ref()).await { Ok(Ok(())) => {} Ok(Err(e)) => tracing::warn!(error = %e, "SAF-2 shutdown reported an error"), Err(_) => tracing::error!("SAF-2 shutdown did not finish inside its budget"), } } /// Does `selector` name the trainer we are already attached to? fn already_selected(status: &TrainerStatus, selector: &TrainerSelector) -> bool { match selector { TrainerSelector::Any => true, TrainerSelector::Address(a) => status .address .as_deref() .is_some_and(|current| current.eq_ignore_ascii_case(a)), TrainerSelector::NameContains(n) => status .name .as_deref() .is_some_and(|current| current.to_lowercase().contains(&n.to_lowercase())), } } fn publish(tx: &watch::Sender, status: &TrainerStatus) { let _ = tx.send(status.clone()); } /// FR-1.8: an empty scan means "nothing was advertising", and the fix is almost /// always to pedal. Say so rather than reporting a bare error. fn connect_hint(e: &FtmsError) -> String { match e { FtmsError::NotFound(_) => { "Trainer not found. It only advertises once awake — turn the cranks for a few \ seconds and try again." .to_string() } FtmsError::NoAdapter => { "No Bluetooth adapter. Check the radio is on and BlueZ is running.".to_string() } // A-3: the D100 accepts one BLE host. A second one gets the link torn // down mid-handshake, which surfaces from BlueZ as a bare "Not // connected" — the least informative possible description of the most // common real-world failure. FtmsError::Bluetooth(_) | FtmsError::MissingCharacteristic(_) => format!( "Trainer is busy: {e}. It accepts one connection at a time — close any other app \ (or `probe`) that is holding it, then try again." ), FtmsError::Rejected { .. } | FtmsError::Unacknowledged { .. } => format!( "{e}. Another app may already hold control of the trainer — only one may at a time." ), other => other.to_string(), } } // -- select! helpers --------------------------------------------------------- // // Each parks forever when there is no client, so the other arms drive the loop. // The one thing none of them may do is return `Ready` on every poll: the select // they feed is `biased`, so a permanently-ready arm starves every arm below it // (NFR-10). That is why a closed stream is reported once and the caller then // clears the receiver. async fn next_telemetry(rx: &mut Option>) -> Option { match rx { None => std::future::pending().await, Some(rx) => loop { match rx.recv().await { Ok(t) => return Some(t), // NFR-2: a lagged consumer skips ahead, it does not stall. Err(broadcast::error::RecvError::Lagged(n)) => { tracing::debug!(skipped = n, "telemetry consumer lagged") } Err(broadcast::error::RecvError::Closed) => return None, } }, } } async fn next_event(rx: &mut Option>) -> Option { match rx { None => std::future::pending().await, Some(rx) => loop { match rx.recv().await { Ok(e) => return Some(e), Err(broadcast::error::RecvError::Lagged(_)) => {} Err(broadcast::error::RecvError::Closed) => return None, } }, } } async fn next_state(rx: &mut Option>) -> Option { match rx { None => std::future::pending().await, Some(rx) => match rx.changed().await { Ok(()) => Some(rx.borrow_and_update().clone()), Err(_) => None, }, } } /// The FTMS configuration this app rides with. /// /// `use_simulation_mode` is **true**, unlike the crate default: the D100 /// advertises `SetIndoorBikeSimulationParameters` (`0x11`, target feature bit /// 13) and accepts it, while it does *not* advertise `SetTargetInclination` /// (`0x03`) and its inclination range reports 0–6 % with no negatives — useless /// for descents. Measured against firmware 0.108; see README.md. pub fn app_config(limits: SafetyLimits) -> FtmsConfig { FtmsConfig { limits, use_simulation_mode: true, backoff: Backoff { max_attempts: Some(RECONNECT_ATTEMPTS), ..Backoff::default() }, ..FtmsConfig::default() } } #[cfg(test)] mod tests { use super::*; #[test] fn control_is_tracked_separately_from_connection() { // FR-9.3: connected is not controllable. let connected = TrainerStatus { state: ConnectionState::Connected, control_acquired: false, ..TrainerStatus::default() }; assert!(connected.is_attached()); assert!(!connected.controllable()); let controlling = TrainerStatus { state: ConnectionState::Controlling, control_acquired: true, ..TrainerStatus::default() }; assert!(controlling.controllable()); } #[test] fn a_lost_link_is_not_attached() { let lost = TrainerStatus { state: ConnectionState::Lost { reason: "gone".into(), }, ..TrainerStatus::default() }; assert!(!lost.is_attached()); assert!(!lost.controllable()); assert!(!TrainerStatus::default().is_attached()); } #[test] fn the_app_drives_gradient_through_simulation_mode() { // The D100 rejects 0x03 and accepts 0x11 — measured, see README. let cfg = app_config(SafetyLimits::default()); assert!(cfg.use_simulation_mode); assert!( !cfg.ignore_advertised_features, "FR-2.6 is not for the app to bypass" ); assert!(cfg.start_on_connect); assert!( cfg.min_write_interval >= Duration::from_millis(250), "FR-2.8" ); } #[test] fn safety_limits_reach_the_ble_layer() { let limits = SafetyLimits { max_gradient_pct: 8.0, ..SafetyLimits::default() }; assert_eq!(app_config(limits).limits.max_gradient_pct, 8.0); } #[test] fn a_missing_trainer_is_explained_not_just_reported() { let hint = connect_hint(&FtmsError::NotFound("any FTMS trainer".into())); assert!(hint.to_lowercase().contains("crank"), "FR-1.8: {hint}"); let hint = connect_hint(&FtmsError::NoAdapter); assert!(hint.to_lowercase().contains("bluetooth")); } #[test] fn a_second_connect_to_the_same_trainer_is_recognised() { // A duplicate click must not tear down a live session. let status = TrainerStatus { state: ConnectionState::Controlling, control_acquired: true, address: Some("94:05:bb:04:76:28".into()), name: Some("VANRYSEL-HT-2876".into()), ..TrainerStatus::default() }; assert!(already_selected( &status, &TrainerSelector::Address("94:05:BB:04:76:28".into()) )); assert!(already_selected( &status, &TrainerSelector::NameContains("vanrysel".into()) )); assert!(already_selected(&status, &TrainerSelector::Any)); // A different trainer is a genuine reconnect. assert!(!already_selected( &status, &TrainerSelector::Address("aa:bb:cc:dd:ee:ff".into()) )); assert!(!already_selected( &TrainerStatus::default(), &TrainerSelector::Address("94:05:bb:04:76:28".into()) )); } #[tokio::test] async fn a_shutdown_interrupts_a_connect_instead_of_queueing_behind_it() { // FR-1.10 / SAF-8. A connect runs for up to `scan_timeout` plus the // handshake; reading the shutdown command only after it finished is what // made a quit-while-connecting blow its budget and skip SAF-2 entirely. let (tx, mut rx) = mpsc::channel(8); let (reply, done) = sync_channel(1); // Commands that are not a reason to stop are drained, not left to fill // the queue while nobody is reading it. tx.send(Cmd::Target(ControlTarget::Gradient { percent: 3.0 })) .await .unwrap(); tx.send(Cmd::Release { limits: SafetyLimits::default(), }) .await .unwrap(); tx.send(Cmd::Shutdown { reply }).await.unwrap(); match abort_signal(&mut rx, &TrainerSelector::Any).await { Abort::Shutdown(reply) => { // The caller is blocking on this; it has to be answerable. let _ = reply.send(()); assert!(done.recv().is_ok()); } _ => panic!("the shutdown did not interrupt the connect"), } } #[tokio::test] async fn a_disconnect_interrupts_a_connect_too() { // Otherwise the rider's Disconnect click does nothing for fifteen // seconds and then tears down the link it just finished building. let (tx, mut rx) = mpsc::channel(8); tx.send(Cmd::Disconnect).await.unwrap(); assert!(matches!( abort_signal(&mut rx, &TrainerSelector::Any).await, Abort::Disconnect )); } #[tokio::test] async fn picking_a_different_trainer_mid_connect_takes_over() { // Otherwise changing your mind costs a whole scan timeout, and the // trainer you clicked second is connected to only after the one you // gave up on has finished failing. let connecting_to = TrainerSelector::Address("94:05:bb:04:76:28".into()); let (tx, mut rx) = mpsc::channel(8); // The same trainer clicked again is impatience, not a new intent — it // must not restart the attempt already running. tx.send(Cmd::Connect(connecting_to.clone())).await.unwrap(); tx.send(Cmd::Connect(TrainerSelector::Address( "aa:bb:cc:dd:ee:ff".into(), ))) .await .unwrap(); match abort_signal(&mut rx, &connecting_to).await { Abort::Connect(TrainerSelector::Address(a)) => assert_eq!(a, "aa:bb:cc:dd:ee:ff"), _ => panic!("the second trainer did not take over"), } } #[tokio::test] async fn dropping_every_handle_ends_a_connect() { let (tx, mut rx) = mpsc::channel::(1); drop(tx); assert!(matches!( abort_signal(&mut rx, &TrainerSelector::Any).await, Abort::Closed )); } #[test] fn reconnect_is_bounded_so_the_rider_is_eventually_told() { // FR-1.11. Retrying forever sounds kinder than giving up, but it leaves // the link in `Reconnecting` indefinitely and never says the trainer is // gone — so the rider is never prompted to go and look at it. let cfg = app_config(SafetyLimits::default()); assert_eq!(cfg.backoff.max_attempts, Some(RECONNECT_ATTEMPTS)); assert!(cfg.backoff.exhausted(RECONNECT_ATTEMPTS)); assert!(!cfg.backoff.exhausted(RECONNECT_ATTEMPTS - 1)); } #[test] fn the_supervisor_answers_before_its_caller_stops_listening() { // NFR-9. If the inner budget outlasted the outer one, every quit would // report a timeout for a sequence that was about to succeed — and then // let the process exit on top of it anyway. assert!(SAFETY_SEQUENCE_TIMEOUT < SHUTDOWN_TIMEOUT); } #[test] fn a_trainer_held_by_another_app_says_so() { // A-3: one BLE host. BlueZ reports the second one's torn-down link as a // bare "Not connected", which explains nothing on its own. let hint = connect_hint(&FtmsError::MissingCharacteristic( "Indoor Bike Data (0x2AD2)", )); assert!(hint.contains("one connection at a time"), "{hint}"); assert!(hint.to_lowercase().contains("busy"), "{hint}"); } }