//! The controller supervisor: the app's single owner of the Click pods. //! //! The same shape as [`crate::trainer`], and for the same reason — the Tauri //! commands hold a `Mutex` and may never `.await` a radio, so all BLE work //! happens in one background task that they reach over a channel. //! //! ```text //! commands ──connect/disconnect──► [supervisor task] ──► ClickClient (+ pod) //! └────────► ClickClient (− pod) //! webview ◄──controller://input──── button edges ◄──────────────┘ //! ``` //! //! **A Click v2 is two peripherals, not one** (§2.3.1, FR-1.4). They advertise //! the same local name, so the old single-slot supervisor asked for "a Zwift //! Click" and got whichever pod answered first — a coin toss, with the loser //! invisible and no way to tell which one you had. Each pod now has its own //! slot, its own link and its own line on screen, keyed by the shift paddle it //! carries rather than by which end of the bar it is clamped to (see //! [`PodId`]). //! //! Connect attempts run in child tasks rather than inline, so a twenty-second //! search for a sleeping pod cannot hold up the other pod's buttons (A-4). //! Each attempt carries a generation, and a result from a superseded attempt is //! discarded — with its link closed, never merely dropped (SAF-9). //! //! The difference from the trainer is that a controller has **no safety //! story**: it never commands load, so there is no SAF-2 sequence to run and //! nothing to reset on exit. It still has to be *disconnected* on the way out //! though (SAF-9) — a link the process merely abandons can leave the pod held //! by BlueZ and unreachable on the next launch — and that disconnect has to be //! waited for, which is what [`ControllerHandle::shutdown_blocking`] is. //! //! Button *edges* arrive already de-duplicated by `ButtonTracker` in the BLE //! layer — the pod repeats a held button at ~10 Hz, and acting on repeats would //! shift ten gears per second. 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::click::{ClickClient, ClickConfig, ClickEvent}; use bikecontrol_ble::zwift::{Button, PodId}; use bikecontrol_ble::{Backoff, FtmsError, PodSelector}; use serde::{Deserialize, Serialize}; use tokio::sync::{mpsc, oneshot, watch}; /// How long a connected pod may stay silent before the screen says so. /// /// This used to be 20 s, on the stated grounds that "the pod sends a battery /// heartbeat every ~5 s even when idle". **Nothing confirms that** — §2.3.1 /// establishes the battery frame's *format*, never its cadence, and a two-minute /// capture with both pods connected recorded six frames from one pod and none at /// all from the other. A Click's whole character is to be quiet. /// /// So the old window was shorter than an ordinary gap between shifts, and a pod /// that was working perfectly was declared stale for the crime of not being /// pressed. Three minutes is longer than any silence a working link plausibly /// explains, and the silence is logged when it fires so the next ride's log can /// replace this guess with the pods' real cadence. const STALE_AFTER: Duration = Duration::from_secs(180); /// How long a *live* link may go without ever carrying a button before we stop /// believing in it. /// /// The §7.1 case: frames arriving steadily, battery every five seconds, and not /// one press in all that time. Recycling costs a few seconds against a pod that /// is otherwise useless for the rest of the ride. /// /// It is measured from the last press rather than from the connect, because a /// link can wedge *after* working — sixty presses and then nothing, tablet, /// 2026-08-27 — and an exemption earned by the first button was an exemption /// for the whole ride. /// /// The cost of being wrong is a rider who genuinely has not shifted for this /// long losing shifting for the few seconds a reconnect takes, so the window is /// deliberately longer than any climb's worth of steady pedalling. const NO_INPUT_AFTER: Duration = Duration::from_secs(150); /// Upper bound on closing the controller links at exit. Shorter than the /// trainer's: there is no reset sequence here, only an unsubscribe and a /// disconnect, and this budget is spent on the same window close (NFR-9). const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3); /// How long a known `−` pod is waited for before a lone `+` pod will do. /// /// One link is the whole controller: the `−` pod relays its twin's paddle and /// face buttons (§2.3.1), so with a `−` pod in the house there is nothing for a /// second link to add and one specific thing for it to break — connected as a /// pair, the `−` pod stops reporting its *own* paddle. Confirmed in the field /// 2026-08-21: pairing the `−` pod alone gives all ten buttons. /// /// But holding out forever would mean a flat or lost `−` pod costs the rider /// their `+` paddle as well, which is a worse trade than a redundant link. So /// the wait is bounded: long enough for a rider to wake both pods in whatever /// order they like, short enough that half a controller beats none. const PLUS_GRACE: Duration = Duration::from_secs(45); /// How long auto-reconnect keeps chasing a pod before it gives up and says so. /// /// FR-1.11. More generous than the trainer's, because a Click genuinely does /// sleep between efforts and only advertises after a button press (A-4), so a /// quiet pod is normal where a quiet trainer is not. It is still bounded: /// a flat pod will not come back, and scanning for it every thirty seconds /// forever competes with the trainer — and with the other pod — for the one /// adapter. const RECONNECT_ATTEMPTS: u32 = 30; const _: () = assert!( RECONNECT_ATTEMPTS > crate::trainer::RECONNECT_ATTEMPTS, "a pod that is merely asleep must not be given up on sooner than a trainer" ); /// The controller configuration this app rides with. pub fn controller_config() -> ClickConfig { ClickConfig { backoff: Backoff { max_attempts: Some(RECONNECT_ATTEMPTS), ..Backoff::default() }, ..ClickConfig::default() } } /// Which pod, as the webview names it. Mirrors [`PodId`], which lives in the /// BLE crate and carries no serde derives for the app's benefit. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Pod { Minus, Plus, } impl From for Pod { fn from(id: PodId) -> Self { match id { PodId::Minus => Pod::Minus, PodId::Plus => Pod::Plus, } } } impl From for PodId { fn from(pod: Pod) -> Self { match pod { Pod::Minus => PodId::Minus, Pod::Plus => PodId::Plus, } } } /// Where one pod's link has got to. /// /// Deliberately more than a bool. "Not connected" covers a pod that is asleep, /// one that is being searched for right now, and one the app has stopped /// chasing — three situations with three different things for the rider to do, /// and a single flag could not tell them apart (FR-9.2). #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub enum PodState { /// Never asked for, or disconnected on purpose. #[default] Idle, /// A connect is in flight. A pod only advertises after a button press, so /// this can legitimately sit here for the whole scan timeout. Searching, /// Connected and talking. Connected, /// The link dropped and the BLE layer is chasing it. The ride carries on. Reconnecting, /// The app has stopped chasing (FR-1.11). Terminal until the rider acts. GaveUp, } /// What the UI needs to know about one pod. #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct PodStatus { pub pod: Pod, /// `+` or `−`, so the webview never has to map an enum to a glyph. pub symbol: &'static str, pub state: PodState, pub address: Option, pub name: Option, pub battery_percent: Option, /// Rendered verbatim (FR-9.2). pub error: Option, /// The last button this pod sent, as [`button_name`] spells it. The point /// is diagnostic: a pod that is connected but has never sent anything looks /// exactly like a working one until you press something. pub last_button: Option<&'static str>, pub buttons_seen: u32, /// This pod has sent its own paddle, so the label above is no longer a /// reading of the type byte — it is what the rider pressed. pub confirmed: bool, /// This pod has sent the *other* pod's paddle. Either the pair is filed the /// wrong way round (see [`ControllerHandle::swap`]), or this pod reports /// for both. pub contradicted: bool, } impl PodStatus { fn new(pod: PodId) -> Self { Self { pod: pod.into(), symbol: pod.symbol(), state: PodState::Idle, address: None, name: None, battery_percent: None, error: None, last_button: None, buttons_seen: 0, confirmed: false, contradicted: false, } } pub fn connected(&self) -> bool { self.state == PodState::Connected } /// Forget everything about the link, keeping what we know about the pod /// itself. The address survives on purpose: it is how the next connect /// finds this pod rather than its identically-named twin. fn reset_link(&mut self, state: PodState) { self.state = state; self.battery_percent = None; self.error = None; } } /// What the UI needs to know about the controller as a whole. #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct ControllerStatus { pub minus: PodStatus, pub plus: PodStatus, /// The type-byte reading has been flipped by the rider. §2.3.1 leaves which /// byte is which pod unproven, so the default is a documented guess with an /// escape hatch rather than a fact. pub swapped: bool, } impl Default for ControllerStatus { fn default() -> Self { Self { minus: PodStatus::new(PodId::Minus), plus: PodStatus::new(PodId::Plus), swapped: false, } } } impl ControllerStatus { pub fn get(&self, pod: PodId) -> &PodStatus { match pod { PodId::Minus => &self.minus, PodId::Plus => &self.plus, } } fn get_mut(&mut self, pod: PodId) -> &mut PodStatus { match pod { PodId::Minus => &mut self.minus, PodId::Plus => &mut self.plus, } } /// Any pod talking at all. Shifting keeps working with one pod, which is /// why this is not `both`. pub fn any_connected(&self) -> bool { self.minus.connected() || self.plus.connected() } pub fn both_connected(&self) -> bool { self.minus.connected() && self.plus.connected() } } /// One physical press, however many pods report it. /// /// The pair does **not** divide the buttons between them. §2.3.1 records every /// one of the ten arriving over the `0x0B` pod alone and leaves open what the /// other contributes; the answer is that the `+` paddle arrives over *both* — /// once from the pod it is printed on and once relayed by its twin. Forwarding /// each pod's edges as they came therefore shifted twice for one press of `+`, /// while `−`, which only its own pod reports, shifted once. That is the `+2` /// upshift against a `−1` downshift, and it is visible in a ride log as a gear /// going 7 → 9 → 11 on three presses. /// /// So the two pods are merged into one controller: a button is held when /// *either* pod says it is, and only the transitions of that aggregate reach /// the app. Releases carry as much weight as presses here — the aggregate /// falling back to "nobody holds it" is what re-arms the next press, which is /// why every path that can lose a release ([`forget`]) has to say so. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] struct Buttons { /// One mask per pod, bit per [`Button::bit`]. The highest is 12, so a `u16` /// holds the lot. minus: u16, plus: u16, } impl Buttons { fn mask_mut(&mut self, pod: PodId) -> &mut u16 { match pod { PodId::Minus => &mut self.minus, PodId::Plus => &mut self.plus, } } /// Does any pod believe this button is held? fn held(&self, button: Button) -> bool { (self.minus | self.plus) & (1u16 << button.bit()) != 0 } /// Fold in one pod's edge. `Some(pressed)` when the controller as a whole /// changed state — that is, when the edge is worth forwarding. `None` when /// the other pod had already said the same thing. fn edge(&mut self, pod: PodId, button: Button, pressed: bool) -> Option { let before = self.held(button); let bit = 1u16 << button.bit(); let mask = self.mask_mut(pod); if pressed { *mask |= bit; } else { *mask &= !bit; } let after = self.held(button); (after != before).then_some(after) } /// Drop everything this pod claimed to be holding, and report which buttons /// nobody is holding any more. /// /// Called wherever a release edge could have been missed — a dropped link, a /// lagged channel. A release that never arrives would leave the aggregate /// latched, and a latched paddle does not shift at all, which is a worse /// failure than the one this type exists to fix. fn forget(&mut self, pod: PodId) -> Vec