Both pods, same bench, minutes apart, no writes: − pod buttons stop at ~51 s, every session; flag 0 -> 1; 2 key offers + pod 133 s, 78 paddle edges, flag never flipped, no key offer at all So the `−` pod is not the controller. It relays more when it works — all ten buttons, its twin's included — but "when it works" is under a minute without a Zwift blessing in the last day, and the `+` pod alone is a complete shifter: its paddle shifts up, `Y` shifts down, both already mapped. Shifting is what a ride cannot do without. `take_plus_pod` becomes `take_minus_pod` — the same rule with the pods exchanged — and the housekeeping, the connect guard and the no-pod-named default follow it. Opening both is still what stops the `−` pod reporting its own paddle, so it is still one link, just the other one. The UI stops telling riders to press the pod that dies: the panel asks for the `+` pod, the tile prefers it, and the `−` pod's row says what it actually offers — all ten buttons, for about fifty seconds. This settles A-2 from the other end too. The keep-alive that "removes the daily unlock, but only for the right controller" removes nothing. The right controller never needed it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1875 lines
80 KiB
Rust
1875 lines
80 KiB
Rust
//! 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<PodId> for Pod {
|
||
fn from(id: PodId) -> Self {
|
||
match id {
|
||
PodId::Minus => Pod::Minus,
|
||
PodId::Plus => Pod::Plus,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<Pod> 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<String>,
|
||
pub name: Option<String>,
|
||
pub battery_percent: Option<u8>,
|
||
/// Rendered verbatim (FR-9.2).
|
||
pub error: Option<String>,
|
||
/// 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<bool> {
|
||
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<Button> {
|
||
let dropped = std::mem::take(self.mask_mut(pod));
|
||
Button::ALL
|
||
.into_iter()
|
||
.filter(|b| dropped & (1u16 << b.bit()) != 0 && !self.held(*b))
|
||
.collect()
|
||
}
|
||
}
|
||
|
||
/// Forget one pod's held buttons, sending the releases that implies.
|
||
///
|
||
/// The webview acts only on presses, but a release it never sees is a release
|
||
/// it cannot un-highlight, and the symmetry is cheap.
|
||
fn forget(
|
||
pod: PodId,
|
||
buttons: &mut Buttons,
|
||
input_tx: &tokio::sync::broadcast::Sender<ControllerInput>,
|
||
) {
|
||
for button in buttons.forget(pod) {
|
||
let _ = input_tx.send(ControllerInput {
|
||
button: button_name(button),
|
||
pressed: false,
|
||
pod: pod.into(),
|
||
});
|
||
}
|
||
}
|
||
|
||
/// A button edge, on its way to the webview.
|
||
#[derive(Debug, Clone, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct ControllerInput {
|
||
/// Stable lowercase name: `left`, `up`, `right`, `down`, `a`, `b`, `y`,
|
||
/// `z`, `minus`, `plus`. The webview switches on this, so it must not drift
|
||
/// from [`button_name`].
|
||
pub button: &'static str,
|
||
pub pressed: bool,
|
||
/// Which pod it came from. The webview does not route on this — a `+` press
|
||
/// means the same thing whichever pod sent it — but it lets the connection
|
||
/// screen show that a particular pod is alive.
|
||
pub pod: Pod,
|
||
}
|
||
|
||
/// The webview-facing name for a button. Deliberately not `Button::label`,
|
||
/// which returns `-` and `+` — awkward to switch on in TypeScript.
|
||
pub fn button_name(button: Button) -> &'static str {
|
||
match button {
|
||
Button::Left => "left",
|
||
Button::Up => "up",
|
||
Button::Right => "right",
|
||
Button::Down => "down",
|
||
Button::A => "a",
|
||
Button::B => "b",
|
||
Button::Y => "y",
|
||
Button::Z => "z",
|
||
Button::Minus => "minus",
|
||
Button::Plus => "plus",
|
||
}
|
||
}
|
||
|
||
enum Cmd {
|
||
/// Connect one pod. `address` is used when the scanner has already seen it,
|
||
/// which is both faster and unambiguous.
|
||
Connect { pod: PodId, address: Option<String> },
|
||
/// This is the pod we paired with last time (FR-1.5).
|
||
///
|
||
/// Sets the slot's address without touching the radio, so a pod that is
|
||
/// still asleep is nonetheless *known* — which is what lets the `+` pod
|
||
/// hold back for a `−` pod that has not woken up yet, and what gives a
|
||
/// manual connect an address to go straight to instead of a type byte to
|
||
/// go hunting with.
|
||
Remember { pod: PodId, address: String },
|
||
/// The scanner has this pod in view *right now* (FR-1.5).
|
||
///
|
||
/// The whole difficulty with a Click is that it advertises for only a few
|
||
/// seconds after a button press, so "press Connect and hope" is a race the
|
||
/// rider loses about half the time. The device scan is already running and
|
||
/// already knows the moment a pod appears — this is that moment, and the
|
||
/// supervisor connects on it.
|
||
Seen { pod: PodId, address: String },
|
||
/// Disconnect one pod, or both when `None`.
|
||
Disconnect { pod: Option<PodId> },
|
||
/// The pair is filed the wrong way round: exchange the two slots.
|
||
Swap,
|
||
/// Close every link and stop, answering only once they are actually closed.
|
||
Shutdown { reply: SyncSender<()> },
|
||
}
|
||
|
||
/// The outcome of one connect attempt, back from its child task.
|
||
struct Attempt {
|
||
pod: PodId,
|
||
/// Which attempt this was, so a result superseded by a newer request (or by
|
||
/// a disconnect) can be told apart from a live one.
|
||
generation: u64,
|
||
outcome: Result<Option<ClickClient>, FtmsError>,
|
||
}
|
||
|
||
/// Cheap, cloneable handle to the supervisor.
|
||
#[derive(Clone)]
|
||
pub struct ControllerHandle {
|
||
cmd_tx: mpsc::Sender<Cmd>,
|
||
status_rx: watch::Receiver<ControllerStatus>,
|
||
/// Button edges, for whoever forwards them to the webview.
|
||
input_tx: Arc<tokio::sync::broadcast::Sender<ControllerInput>>,
|
||
/// Shared, so every clone of the handle sees that the links are already
|
||
/// shut.
|
||
shut_down: Arc<AtomicBool>,
|
||
}
|
||
|
||
impl ControllerHandle {
|
||
/// Start the supervisor task. Needs a Tokio runtime, which
|
||
/// `tauri::async_runtime` provides before the app is built.
|
||
pub fn spawn() -> Self {
|
||
let (cmd_tx, cmd_rx) = mpsc::channel(16);
|
||
let (status_tx, status_rx) = watch::channel(ControllerStatus::default());
|
||
let (input_tx, _) = tokio::sync::broadcast::channel(64);
|
||
let input_tx = Arc::new(input_tx);
|
||
|
||
let handle = Self {
|
||
cmd_tx,
|
||
status_rx,
|
||
input_tx: input_tx.clone(),
|
||
shut_down: Arc::new(AtomicBool::new(false)),
|
||
};
|
||
tauri::async_runtime::spawn(run(cmd_rx, status_tx, input_tx));
|
||
handle
|
||
}
|
||
|
||
pub fn status(&self) -> ControllerStatus {
|
||
self.status_rx.borrow().clone()
|
||
}
|
||
|
||
/// A watch receiver, for a loop that wants to react to link changes rather
|
||
/// than poll them.
|
||
pub fn status_watch(&self) -> watch::Receiver<ControllerStatus> {
|
||
self.status_rx.clone()
|
||
}
|
||
|
||
/// Subscribe to button edges.
|
||
pub fn inputs(&self) -> tokio::sync::broadcast::Receiver<ControllerInput> {
|
||
self.input_tx.subscribe()
|
||
}
|
||
|
||
/// Connect one pod. `address` short-circuits the search when the device
|
||
/// scanner has already seen it.
|
||
pub fn connect(&self, pod: PodId, address: Option<String>) {
|
||
let _ = self.cmd_tx.try_send(Cmd::Connect { pod, address });
|
||
}
|
||
|
||
/// Seed the pod we paired with last time, from the remembered-device store.
|
||
pub fn remember(&self, pod: PodId, address: &str) {
|
||
let _ = self.cmd_tx.try_send(Cmd::Remember {
|
||
pod,
|
||
address: address.to_string(),
|
||
});
|
||
}
|
||
|
||
/// Tell the supervisor a pod is advertising right now (FR-1.5).
|
||
///
|
||
/// Called from the device scan on every pass. Cheap and idempotent: the
|
||
/// supervisor ignores it unless that pod is disconnected and the rider has
|
||
/// not disconnected it on purpose. Dropped rather than queued if the
|
||
/// supervisor is busy — the scan will say so again in half a second.
|
||
pub fn pod_seen(&self, pod: PodId, address: &str) {
|
||
let _ = self.cmd_tx.try_send(Cmd::Seen {
|
||
pod,
|
||
address: address.to_string(),
|
||
});
|
||
}
|
||
|
||
pub fn disconnect(&self, pod: Option<PodId>) {
|
||
let _ = self.cmd_tx.try_send(Cmd::Disconnect { pod });
|
||
}
|
||
|
||
/// Exchange the two slots, for when the pods answer to the other name.
|
||
pub fn swap(&self) {
|
||
let _ = self.cmd_tx.try_send(Cmd::Swap);
|
||
}
|
||
|
||
/// Close both controller links at app exit, waiting for them to be closed
|
||
/// (SAF-9). Blocks the calling (non-async) thread until it is done or
|
||
/// [`SHUTDOWN_TIMEOUT`] elapses.
|
||
///
|
||
/// Idempotent: Tauri delivers `ExitRequested`, `Exit` and window `Destroyed`
|
||
/// for a single quit, and the repeats must be silent no-ops.
|
||
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);
|
||
|
||
let mut cmd = Cmd::Shutdown { reply };
|
||
loop {
|
||
match self.cmd_tx.try_send(cmd) {
|
||
Ok(()) => break,
|
||
Err(mpsc::error::TrySendError::Closed(_)) => return,
|
||
Err(mpsc::error::TrySendError::Full(returned)) => {
|
||
if Instant::now() >= deadline {
|
||
tracing::warn!("controller supervisor unreachable; links left to the OS");
|
||
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!("controller pods disconnected"),
|
||
Err(e) => tracing::warn!(error = %e, "controller did not disconnect in time"),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One slot's live state. There are exactly two, and they never share a
|
||
/// peripheral: each holds its own client and its own event stream.
|
||
struct Slot {
|
||
client: Option<ClickClient>,
|
||
events: Option<tokio::sync::broadcast::Receiver<ClickEvent>>,
|
||
/// Held while a connect is in flight; sending on it — or dropping it —
|
||
/// abandons the attempt (FR-1.10).
|
||
cancel: Option<oneshot::Sender<()>>,
|
||
generation: u64,
|
||
last_seen: Option<tokio::time::Instant>,
|
||
/// When the current link was established, and whether it has ever carried a
|
||
/// button. Together these are the signature of the wedged session in §7.1:
|
||
/// frames arriving, and not one press among them.
|
||
connected_at: Option<tokio::time::Instant>,
|
||
buttons_this_link: u32,
|
||
/// When this link last carried a press. `None` until it carries one, which
|
||
/// is why the silence test below falls back to `connected_at`.
|
||
last_button: Option<tokio::time::Instant>,
|
||
/// May this pod be connected the moment the scan sees it?
|
||
///
|
||
/// True until the rider disconnects it by hand, because a pod that
|
||
/// advertises for three seconds after a button press is not something a
|
||
/// human can reliably click Connect at. False afterwards: "disconnect"
|
||
/// that reconnects itself two seconds later is not a disconnect.
|
||
auto: bool,
|
||
}
|
||
|
||
impl Default for Slot {
|
||
fn default() -> Self {
|
||
Self {
|
||
client: None,
|
||
events: None,
|
||
connected_at: None,
|
||
buttons_this_link: 0,
|
||
last_button: None,
|
||
cancel: None,
|
||
generation: 0,
|
||
last_seen: None,
|
||
auto: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Slot {
|
||
fn connecting(&self) -> bool {
|
||
self.cancel.is_some()
|
||
}
|
||
|
||
/// Is this slot free to start a connect?
|
||
fn idle(&self) -> bool {
|
||
self.client.is_none() && !self.connecting()
|
||
}
|
||
|
||
/// Abandon any in-flight attempt and bump the generation, so its result is
|
||
/// recognised as stale when it arrives.
|
||
fn abandon_attempt(&mut self) {
|
||
if let Some(cancel) = self.cancel.take() {
|
||
let _ = cancel.send(());
|
||
}
|
||
self.generation += 1;
|
||
}
|
||
}
|
||
|
||
async fn run(
|
||
mut cmd_rx: mpsc::Receiver<Cmd>,
|
||
status_tx: watch::Sender<ControllerStatus>,
|
||
input_tx: Arc<tokio::sync::broadcast::Sender<ControllerInput>>,
|
||
) {
|
||
// Two slots, held as separate locals rather than in a collection: the
|
||
// `select!` below polls both event streams at once, which needs two
|
||
// disjoint borrows.
|
||
let mut minus = Slot::default();
|
||
let mut plus = Slot::default();
|
||
let mut swapped = false;
|
||
// One press is one press, whichever pods report it (see `Buttons`).
|
||
let mut buttons = Buttons::default();
|
||
// When a lone `+` pod stops being worse than no controller at all. Pushed
|
||
// back whenever the `−` pod is reachable, so it only ever expires against a
|
||
// `−` pod that is genuinely not coming (see `PLUS_GRACE`).
|
||
let mut plus_gate = tokio::time::Instant::now() + PLUS_GRACE;
|
||
// Whether the "+ pod is redundant" refusal has already been logged for the
|
||
// current state of affairs. See the `Seen` arm.
|
||
let mut plus_declined = false;
|
||
|
||
let (attempt_tx, mut attempt_rx) = mpsc::channel::<Attempt>(4);
|
||
let mut housekeeping = tokio::time::interval(Duration::from_secs(5));
|
||
|
||
loop {
|
||
tokio::select! {
|
||
cmd = cmd_rx.recv() => {
|
||
let Some(cmd) = cmd else {
|
||
// Every handle dropped: nobody can ask for anything again.
|
||
shutdown_all(&mut minus, &mut plus).await;
|
||
return;
|
||
};
|
||
match cmd {
|
||
Cmd::Connect { pod, address } => {
|
||
// The rule `Cmd::Seen` has always enforced, applied
|
||
// here too: with the `−` pod up there is nothing for a
|
||
// `+` link to add and one specific thing for it to
|
||
// break — connected as a pair, the `−` pod stops
|
||
// reporting its own paddle (§2.3.1).
|
||
//
|
||
// Only the scan-driven path was guarded, so an explicit
|
||
// request walked straight past it. Housekeeping then
|
||
// closed the redundant link a second or so later, which
|
||
// reads as the app handling it and is not the same
|
||
// thing at all: on the tablet, 2026-08-27 15:55:39, the
|
||
// pair was joined for 1.4 s and the `−` pod did not
|
||
// report a press again for the rest of the session.
|
||
//
|
||
// `auto` is still armed below, so the fallback stands:
|
||
// the moment the `−` pod goes away, the `+` pod is
|
||
// taken on its next advertisement.
|
||
let plus_up = !plus.idle();
|
||
let slot = slot_mut(&mut minus, &mut plus, pod);
|
||
// Asking for it by hand re-arms auto-connect, whatever
|
||
// came before.
|
||
slot.auto = true;
|
||
if pod == PodId::Minus && plus_up {
|
||
tracing::info!(
|
||
"controller: refusing a − pod link while the + pod is up — \
|
||
the + pod is the controller, and joining both stops the \
|
||
− pod reporting its own paddle anyway"
|
||
);
|
||
continue;
|
||
}
|
||
// A second click while a search is running means
|
||
// "connect", which is what is already happening.
|
||
if slot.connecting() {
|
||
tracing::debug!(pod = pod.as_str(), "controller: already connecting");
|
||
continue;
|
||
}
|
||
if slot.client.is_some() {
|
||
tracing::debug!(pod = pod.as_str(), "controller: already connected");
|
||
continue;
|
||
}
|
||
let selector = selector_for(pod, address, &status_tx.borrow(), swapped);
|
||
tracing::info!(pod = pod.as_str(), selector = %selector.describe(), "controller: connecting");
|
||
start_attempt(slot, pod, selector, &attempt_tx);
|
||
if pod == PodId::Plus {
|
||
plus_gate = tokio::time::Instant::now() + PLUS_GRACE;
|
||
}
|
||
status_tx.send_modify(|s| s.get_mut(pod).reset_link(PodState::Searching));
|
||
}
|
||
Cmd::Remember { pod, address } => {
|
||
// Only fills a gap. A pod we have actually talked to
|
||
// this session knows its own address better than a file
|
||
// written last week does.
|
||
status_tx.send_modify(|s| {
|
||
let p = s.get_mut(pod);
|
||
if p.address.is_none() && !address.trim().is_empty() {
|
||
tracing::info!(pod = pod.as_str(), %address, "controller: remembered pod");
|
||
p.address = Some(address);
|
||
}
|
||
});
|
||
}
|
||
Cmd::Seen { pod, address } => {
|
||
// One link is the whole controller — see
|
||
// `take_plus_pod`, which owns this rule. The short of
|
||
// it: the `−` pod relays its twin, so all ten buttons
|
||
// arrive over it alone (§2.3.1, confirmed in the field
|
||
// 2026-08-21), and a `+` link is a substitute for a `−`
|
||
// pod that is missing rather than the other half of a
|
||
// pair. Connecting both is what the merge in `Buttons`
|
||
// exists to paper over, and it is also the
|
||
// configuration in which the `−` pod stops reporting
|
||
// its own paddle — the failure that cost an evening.
|
||
if pod == PodId::Minus
|
||
&& !take_minus_pod(
|
||
plus.idle(),
|
||
status_tx.borrow().plus.address.is_some(),
|
||
tokio::time::Instant::now() >= plus_gate,
|
||
)
|
||
{
|
||
// Once per run of refusals, not once per sighting.
|
||
// The device list republishes several times a
|
||
// second and every pass re-reports a visible pod,
|
||
// so this logged twice a second for as long as the
|
||
// + pod was in the room — 274 lines in five minutes
|
||
// on the tablet, burying the connect failures we
|
||
// were reading the log for.
|
||
if !plus_declined {
|
||
tracing::debug!(
|
||
"controller: − pod seen; the + pod is the controller and \
|
||
is up (silenced until this changes)"
|
||
);
|
||
plus_declined = true;
|
||
}
|
||
continue;
|
||
}
|
||
let slot = slot_mut(&mut minus, &mut plus, pod);
|
||
if !slot.auto || !slot.idle() {
|
||
continue;
|
||
}
|
||
// Straight to the address: the pod is advertising *now*,
|
||
// and the seconds spent rediscovering what the scan has
|
||
// already found are the seconds it goes back to sleep in.
|
||
tracing::info!(pod = pod.as_str(), %address, "controller: pod seen; connecting");
|
||
start_attempt(slot, pod, PodSelector::Address(address.clone()), &attempt_tx);
|
||
if pod == PodId::Minus {
|
||
plus_gate = tokio::time::Instant::now() + PLUS_GRACE;
|
||
}
|
||
status_tx.send_modify(|s| {
|
||
let p = s.get_mut(pod);
|
||
p.address = Some(address);
|
||
p.reset_link(PodState::Searching);
|
||
});
|
||
}
|
||
Cmd::Disconnect { pod } => {
|
||
let pods: Vec<PodId> = pod.map_or_else(|| PodId::BOTH.to_vec(), |p| vec![p]);
|
||
for id in pods {
|
||
let slot = slot_mut(&mut minus, &mut plus, id);
|
||
slot.abandon_attempt();
|
||
slot.events = None;
|
||
slot.last_seen = None;
|
||
// Stays disconnected. Auto-connect exists so a pod
|
||
// that wakes for three seconds is caught; it must
|
||
// not undo a rider who meant it.
|
||
slot.auto = false;
|
||
if let Some(c) = slot.client.take() {
|
||
c.shutdown().await;
|
||
}
|
||
forget(id, &mut buttons, &input_tx);
|
||
status_tx.send_modify(|s| s.get_mut(id).reset_link(PodState::Idle));
|
||
}
|
||
}
|
||
Cmd::Swap => {
|
||
// Both attempts are abandoned first: an in-flight
|
||
// search is for a pod id that is about to mean the
|
||
// other peripheral.
|
||
minus.abandon_attempt();
|
||
plus.abandon_attempt();
|
||
std::mem::swap(&mut minus, &mut plus);
|
||
// The peripherals move slots with everything else they
|
||
// are holding, so what is held moves with them. The
|
||
// aggregate is unchanged, which is the point: a swap is
|
||
// a relabelling, not a release.
|
||
std::mem::swap(&mut buttons.minus, &mut buttons.plus);
|
||
swapped = !swapped;
|
||
status_tx.send_modify(|s| {
|
||
swap_status(s);
|
||
s.swapped = swapped;
|
||
});
|
||
tracing::info!(swapped, "controller: pods exchanged");
|
||
}
|
||
Cmd::Shutdown { reply } => {
|
||
shutdown_all(&mut minus, &mut plus).await;
|
||
status_tx.send_modify(|s| {
|
||
for id in PodId::BOTH {
|
||
s.get_mut(id).reset_link(PodState::Idle);
|
||
}
|
||
});
|
||
// Answered only once the links are genuinely closed, so
|
||
// a caller blocking the app's exit on this knows what it
|
||
// waited for (SAF-9).
|
||
let _ = reply.send(());
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
Some(attempt) = attempt_rx.recv() => {
|
||
let slot = slot_mut(&mut minus, &mut plus, attempt.pod);
|
||
if attempt.generation != slot.generation {
|
||
// Superseded — by a disconnect, a swap, or a newer request.
|
||
// A client that arrived anyway owns a live GATT link, and
|
||
// dropping it would leave the pod held by BlueZ (SAF-9).
|
||
if let Ok(Some(client)) = attempt.outcome {
|
||
tracing::debug!(pod = attempt.pod.as_str(), "controller: stale connect; closing");
|
||
tokio::spawn(async move { client.shutdown().await });
|
||
}
|
||
continue;
|
||
}
|
||
slot.cancel = None;
|
||
apply_attempt(attempt, slot, &status_tx);
|
||
}
|
||
|
||
// Only polled while that pod has a stream; `recv` on a `None`
|
||
// receiver would busy-loop, so the branch is disabled instead.
|
||
event = async { minus.events.as_mut().unwrap().recv().await }, if minus.events.is_some() => {
|
||
handle_event(PodId::Minus, event, &mut minus, &mut buttons, &status_tx, &input_tx);
|
||
}
|
||
event = async { plus.events.as_mut().unwrap().recv().await }, if plus.events.is_some() => {
|
||
handle_event(PodId::Plus, event, &mut plus, &mut buttons, &status_tx, &input_tx);
|
||
}
|
||
|
||
_ = housekeeping.tick() => {
|
||
// A `−` pod that is up, or on its way up, is a `−` pod worth
|
||
// waiting for. Only a slot that has been idle for the whole
|
||
// grace period lets the `+` pod in.
|
||
if !plus.idle() {
|
||
plus_gate = tokio::time::Instant::now() + PLUS_GRACE;
|
||
} else {
|
||
// The − pod is no longer speaking for the pair, so the next
|
||
// refusal — if there is one — is news again.
|
||
plus_declined = false;
|
||
}
|
||
|
||
// Converge on one link. The `+` pod may have connected first —
|
||
// it is the one the rider happened to wake — and once the `−`
|
||
// pod is up it speaks for both, so the second link is redundant
|
||
// and is exactly the configuration that breaks the `−` paddle.
|
||
// Dropping it leaves `auto` alone, so if the `−` pod later goes
|
||
// away the `+` pod is picked up again on its next advertisement.
|
||
if !plus.idle() && minus.client.is_some() {
|
||
tracing::info!(
|
||
"controller: + pod is up and is the controller; closing the − link, \
|
||
which is the pairing that stops the − pod reporting its own paddle"
|
||
);
|
||
forget(PodId::Minus, &mut buttons, &input_tx);
|
||
minus.generation += 1;
|
||
minus.events = None;
|
||
minus.last_seen = None;
|
||
minus.connected_at = None;
|
||
if let Some(client) = minus.client.take() {
|
||
tokio::spawn(async move { client.shutdown().await });
|
||
}
|
||
status_tx.send_modify(|s| s.get_mut(PodId::Minus).reset_link(PodState::Idle));
|
||
}
|
||
|
||
// A link that is plainly alive and has never carried a button
|
||
// is the wedged session from §7.1, and no amount of waiting
|
||
// fixes it — the pod answers the handshake and streams battery
|
||
// for the rest of the ride while ignoring every press. Recycle
|
||
// it. `connect_pod` now drops any pre-existing link first, so
|
||
// the replacement starts genuinely clean.
|
||
//
|
||
// Deliberately narrow: a link that has delivered even one press
|
||
// is exempt for life, so a rider who simply is not shifting is
|
||
// never disturbed.
|
||
for id in PodId::BOTH {
|
||
let slot = slot_mut(&mut minus, &mut plus, id);
|
||
let alive = slot.client.is_some()
|
||
&& slot.last_seen.is_some_and(|t| t.elapsed() < STALE_AFTER);
|
||
// Silence *since the last press*, not "never pressed".
|
||
//
|
||
// This used to exempt any link that had ever carried a
|
||
// button, on the reasoning that a healthy pod proves itself
|
||
// once and should never be disturbed again. The tablet
|
||
// disproved it on 2026-08-27: a link carried sixty presses,
|
||
// wedged at 15:47:19, and went on streaming battery every
|
||
// five seconds while ignoring every press for the rest of
|
||
// the ride. Exempt for life meant dead for the ride.
|
||
//
|
||
// So the clock starts at the last press instead, and falls
|
||
// back to the connect for a link that never carried one.
|
||
let quiet_since = slot.last_button.or(slot.connected_at);
|
||
let mute = quiet_since.is_some_and(|t| t.elapsed() > NO_INPUT_AFTER);
|
||
if alive && mute && slot.auto {
|
||
tracing::warn!(
|
||
pod = id.as_str(),
|
||
presses = slot.buttons_this_link,
|
||
"controller: link is alive but has carried no button for \
|
||
{}s; recycling it (see REQUIREMENTS §7.1)",
|
||
NO_INPUT_AFTER.as_secs()
|
||
);
|
||
slot.generation += 1;
|
||
slot.connected_at = None;
|
||
if let Some(client) = slot.client.take() {
|
||
slot.events = None;
|
||
tokio::spawn(async move { client.shutdown().await });
|
||
}
|
||
status_tx.send_modify(|s| {
|
||
s.get_mut(id).state = PodState::Reconnecting;
|
||
});
|
||
}
|
||
}
|
||
for (id, slot) in [(PodId::Minus, &minus), (PodId::Plus, &plus)] {
|
||
let silence = slot.last_seen.map(|t| t.elapsed());
|
||
let silent = slot.client.is_some()
|
||
&& silence.is_some_and(|s| s > STALE_AFTER);
|
||
if silent {
|
||
// A guess, and marked as one in the log. The link may be
|
||
// perfectly alive and the rider simply not shifting; the
|
||
// truth about a dropped link comes from the BLE layer,
|
||
// which says `Disconnected` when the stream ends. The
|
||
// first frame to arrive takes this back.
|
||
status_tx.send_modify(|s| {
|
||
let p = s.get_mut(id);
|
||
if p.state == PodState::Connected {
|
||
tracing::info!(
|
||
pod = id.as_str(),
|
||
silent_s = silence.map(|s| s.as_secs()),
|
||
"controller: pod has gone quiet; showing it as reconnecting"
|
||
);
|
||
p.state = PodState::Reconnecting;
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// May a `−` pod the scan has just seen be connected?
|
||
///
|
||
/// **The `+` pod is the controller.** This is the reverse of what this module
|
||
/// assumed until 2026-08-27, and the measurement that turned it over is in
|
||
/// §2.3.3: the `−` pod stops reporting buttons about fifty seconds into every
|
||
/// session — a status flag flips, and the link stays up and healthy and mute —
|
||
/// while the `+` pod ran 133 s with 78 paddle edges, no key offer and no flag,
|
||
/// on the same bench, minutes apart.
|
||
///
|
||
/// The `−` pod does deliver more when it works: all ten buttons, its twin's
|
||
/// relayed. But "when it works" is under a minute without the daily Zwift
|
||
/// blessing, and the `+` pod alone is a complete shifter — its paddle shifts
|
||
/// up, `Y` shifts down — which is the thing a ride cannot do without.
|
||
///
|
||
/// So the `−` pod is now the substitute, and this is the old rule with the
|
||
/// pods exchanged. Opening both is still the configuration that breaks the `−`
|
||
/// pod's own paddle, so still only one link.
|
||
///
|
||
/// - `plus_idle` — false when the `+` pod is connected or being connected.
|
||
/// Nothing else matters then: it is the controller and it is up.
|
||
/// - `plus_known` — we have an address for a `+` pod. With none there is
|
||
/// nothing to wait for, and the `−` pod is all there is.
|
||
/// - `gate_expired` — the `+` pod has been unreachable for [`PLUS_GRACE`].
|
||
/// A flat `+` pod must not cost the rider the fifty working seconds the `−`
|
||
/// pod still offers.
|
||
fn take_minus_pod(plus_idle: bool, plus_known: bool, gate_expired: bool) -> bool {
|
||
plus_idle && (!plus_known || gate_expired)
|
||
}
|
||
|
||
fn slot_mut<'a>(minus: &'a mut Slot, plus: &'a mut Slot, pod: PodId) -> &'a mut Slot {
|
||
match pod {
|
||
PodId::Minus => minus,
|
||
PodId::Plus => plus,
|
||
}
|
||
}
|
||
|
||
/// How to look for one pod: by address when anything has seen it, otherwise by
|
||
/// the type byte its advertisement carries (§2.3.1).
|
||
///
|
||
/// Never by name — both pods answer to `Zwift Click`, which is precisely how
|
||
/// the app used to end up connected to whichever one happened to reply first.
|
||
fn selector_for(
|
||
pod: PodId,
|
||
address: Option<String>,
|
||
status: &ControllerStatus,
|
||
swapped: bool,
|
||
) -> PodSelector {
|
||
let known = address.or_else(|| status.get(pod).address.clone());
|
||
match known {
|
||
Some(address) if !address.trim().is_empty() => PodSelector::Address(address),
|
||
_ => PodSelector::Pod(if swapped { pod.other() } else { pod }),
|
||
}
|
||
}
|
||
|
||
/// Spawn the connect. It runs in its own task so that a twenty-second search
|
||
/// for a sleeping pod does not stall the other pod's buttons, or a quit.
|
||
fn start_attempt(
|
||
slot: &mut Slot,
|
||
pod: PodId,
|
||
selector: PodSelector,
|
||
results: &mpsc::Sender<Attempt>,
|
||
) {
|
||
slot.generation += 1;
|
||
let generation = slot.generation;
|
||
let (cancel_tx, cancel_rx) = oneshot::channel();
|
||
slot.cancel = Some(cancel_tx);
|
||
|
||
let results = results.clone();
|
||
tauri::async_runtime::spawn(async move {
|
||
let outcome = ClickClient::connect_cancellable(selector, controller_config(), async {
|
||
// Resolves on an explicit cancel *and* on the sender being dropped,
|
||
// which is how a supervisor that has gone away stops the search.
|
||
let _ = cancel_rx.await;
|
||
})
|
||
.await;
|
||
let _ = results
|
||
.send(Attempt {
|
||
pod,
|
||
generation,
|
||
outcome,
|
||
})
|
||
.await;
|
||
});
|
||
}
|
||
|
||
/// Fold a finished connect attempt into the slot and its status.
|
||
fn apply_attempt(attempt: Attempt, slot: &mut Slot, status_tx: &watch::Sender<ControllerStatus>) {
|
||
let pod = attempt.pod;
|
||
match attempt.outcome {
|
||
Ok(Some(client)) => {
|
||
// `Ok` from the client means the pod answered the handshake, so
|
||
// this is connected — the identity comes off the handle rather than
|
||
// off the event stream, whose first `Connected` was sent before
|
||
// anyone could be subscribed to hear it.
|
||
let address = client.address().to_string();
|
||
let name = client.name().map(str::to_owned);
|
||
let advertised = client.pod();
|
||
slot.events = Some(client.events());
|
||
slot.client = Some(client);
|
||
slot.last_seen = Some(tokio::time::Instant::now());
|
||
// A fresh link starts over: whatever the last one proved says
|
||
// nothing about this one.
|
||
slot.connected_at = Some(tokio::time::Instant::now());
|
||
slot.buttons_this_link = 0;
|
||
slot.last_button = None;
|
||
status_tx.send_modify(|s| {
|
||
let p = s.get_mut(pod);
|
||
p.state = PodState::Connected;
|
||
p.error = None;
|
||
if !address.is_empty() {
|
||
p.address = Some(address);
|
||
}
|
||
p.name = name;
|
||
});
|
||
tracing::info!(
|
||
pod = pod.as_str(),
|
||
advertised = advertised.map(PodId::as_str),
|
||
"controller: pod connected"
|
||
);
|
||
}
|
||
Ok(None) => {
|
||
tracing::info!(pod = pod.as_str(), "controller: connect abandoned");
|
||
status_tx.send_modify(|s| s.get_mut(pod).state = PodState::Idle);
|
||
}
|
||
Err(e) => {
|
||
tracing::warn!(pod = pod.as_str(), error = %e, "controller: connect failed");
|
||
status_tx.send_modify(|s| {
|
||
let p = s.get_mut(pod);
|
||
p.state = PodState::Idle;
|
||
p.error = Some(connect_advice(pod, &e));
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Turn a connect failure into something the rider can act on (FR-9.2).
|
||
fn connect_advice(pod: PodId, error: &FtmsError) -> String {
|
||
match error {
|
||
// The overwhelmingly common case, and barely an error: the pod is
|
||
// asleep. "Not found" on its own invites the rider to conclude the app
|
||
// is broken (FR-1.8, A-4).
|
||
FtmsError::NotFound(_) => format!(
|
||
"Could not find the {} pod. Press any button on it to wake it — a Click only \
|
||
advertises while awake — then try again.",
|
||
pod.symbol()
|
||
),
|
||
other => other.to_string(),
|
||
}
|
||
}
|
||
|
||
/// Fold one pod's event into its status and, for buttons, out to the webview.
|
||
fn handle_event(
|
||
pod: PodId,
|
||
event: Result<ClickEvent, tokio::sync::broadcast::error::RecvError>,
|
||
slot: &mut Slot,
|
||
buttons: &mut Buttons,
|
||
status_tx: &watch::Sender<ControllerStatus>,
|
||
input_tx: &tokio::sync::broadcast::Sender<ControllerInput>,
|
||
) {
|
||
use tokio::sync::broadcast::error::RecvError;
|
||
|
||
match event {
|
||
// Terminal: the actor has stopped and nothing else will arrive.
|
||
// Everything this slot holds is now a zombie, and unlike a plain
|
||
// `Disconnected` this needs saying out loud — the rider's buttons have
|
||
// stopped working and the app is no longer doing anything about it
|
||
// (FR-1.11).
|
||
Ok(ClickEvent::GaveUp { attempts }) => {
|
||
tracing::warn!(
|
||
pod = pod.as_str(),
|
||
attempts,
|
||
"controller: reconnect gave up"
|
||
);
|
||
slot.events = None;
|
||
slot.client = None;
|
||
slot.last_seen = None;
|
||
forget(pod, buttons, input_tx);
|
||
status_tx.send_modify(|s| {
|
||
let p = s.get_mut(pod);
|
||
p.reset_link(PodState::GaveUp);
|
||
p.error = Some(format!(
|
||
"Gave up reaching the {} pod after {attempts} attempts. Press a button on \
|
||
it, then connect again.",
|
||
pod.symbol()
|
||
));
|
||
});
|
||
}
|
||
Ok(event) => {
|
||
slot.last_seen = Some(tokio::time::Instant::now());
|
||
if matches!(event, ClickEvent::Button { .. }) {
|
||
// This link has now proven it carries input, which puts it
|
||
// beyond the no-input watchdog for as long as it lasts.
|
||
slot.buttons_this_link = slot.buttons_this_link.saturating_add(1);
|
||
slot.last_button = Some(tokio::time::Instant::now());
|
||
}
|
||
// A frame is proof the link is up, and it is the *only* proof that
|
||
// ever arrives — the pod does not announce that it has started
|
||
// talking again. Without this, the staleness sweep below was a one-
|
||
// way door: a pod flipped to `Reconnecting` for going quiet stayed
|
||
// there for the rest of the ride, shifting perfectly while the
|
||
// screen said it was lost. `Disconnected` and `GaveUp` are excluded
|
||
// because they are the link reporting its own end.
|
||
if !matches!(event, ClickEvent::Disconnected | ClickEvent::GaveUp { .. }) {
|
||
status_tx.send_modify(|s| {
|
||
let p = s.get_mut(pod);
|
||
if p.state == PodState::Reconnecting {
|
||
tracing::info!(pod = pod.as_str(), "controller: pod talking again");
|
||
p.state = PodState::Connected;
|
||
p.error = None;
|
||
}
|
||
});
|
||
}
|
||
apply(pod, event, buttons, status_tx, input_tx);
|
||
}
|
||
// Lagged means we fell behind the pod, not that it left. One of the
|
||
// events we skipped may have been a release, though, and a release lost
|
||
// out of the merge latches that button held forever (see `Buttons`) —
|
||
// so assume the worst and let go of everything this pod was holding.
|
||
Err(RecvError::Lagged(n)) => {
|
||
tracing::warn!(pod = pod.as_str(), "controller: dropped {n} event(s)");
|
||
forget(pod, buttons, input_tx);
|
||
}
|
||
Err(RecvError::Closed) => {
|
||
slot.events = None;
|
||
slot.client = None;
|
||
slot.last_seen = None;
|
||
forget(pod, buttons, input_tx);
|
||
status_tx.send_modify(|s| s.get_mut(pod).reset_link(PodState::Idle));
|
||
}
|
||
}
|
||
}
|
||
|
||
fn apply(
|
||
pod: PodId,
|
||
event: ClickEvent,
|
||
buttons: &mut Buttons,
|
||
status_tx: &watch::Sender<ControllerStatus>,
|
||
input_tx: &tokio::sync::broadcast::Sender<ControllerInput>,
|
||
) {
|
||
match event {
|
||
ClickEvent::Connected { address, name, .. } => status_tx.send_modify(|s| {
|
||
let p = s.get_mut(pod);
|
||
p.state = PodState::Connected;
|
||
if !address.is_empty() {
|
||
p.address = Some(address);
|
||
}
|
||
p.name = name;
|
||
p.error = None;
|
||
}),
|
||
ClickEvent::Disconnected => {
|
||
// Loud, because this is the one report of a genuinely dropped link —
|
||
// everything else on this screen is inference. A ride that says it
|
||
// keeps losing a pod is either this line repeating or it is not,
|
||
// and those two want opposite fixes.
|
||
tracing::info!(pod = pod.as_str(), "controller: link dropped; the BLE layer is retrying");
|
||
// The BLE layer releases what it was holding before it says this,
|
||
// so the merge is usually already clear; doing it again costs
|
||
// nothing and covers the edge it cannot (see `Buttons::forget`).
|
||
forget(pod, buttons, input_tx);
|
||
status_tx.send_modify(|s| {
|
||
let p = s.get_mut(pod);
|
||
// The BLE layer is already chasing it: this is not idle, and
|
||
// not a failure (FR-1.6).
|
||
if p.state == PodState::Connected {
|
||
p.state = PodState::Reconnecting;
|
||
}
|
||
p.battery_percent = None;
|
||
})
|
||
}
|
||
ClickEvent::Battery { percent } => {
|
||
// Logged for its *timing*, not its value: how often a pod volunteers
|
||
// one is what `STALE_AFTER` is guessing at, and one ride's log
|
||
// settles it.
|
||
tracing::debug!(pod = pod.as_str(), percent, "controller: battery");
|
||
status_tx.send_modify(|s| s.get_mut(pod).battery_percent = Some(percent))
|
||
}
|
||
ClickEvent::Button { button, pressed } => {
|
||
// The status is a readout of this *pod's* link — what it sent, and
|
||
// how much — so it counts the raw event, before the merge below
|
||
// decides whether the controller as a whole changed.
|
||
status_tx.send_modify(|s| {
|
||
let p = s.get_mut(pod);
|
||
p.last_button = Some(button_name(button));
|
||
if pressed {
|
||
p.buttons_seen = p.buttons_seen.saturating_add(1);
|
||
// A pod that sends its own paddle has proved which one it
|
||
// is. That it *also* relays its twin's is normal (see
|
||
// `Buttons`), so the other paddle is evidence of a
|
||
// wrong-way-round pair only while this pod has never sent
|
||
// its own — otherwise the screen would ask to swap a pair
|
||
// that is filed correctly, and swapping would break it.
|
||
if button == pod.paddle() {
|
||
p.confirmed = true;
|
||
p.contradicted = false;
|
||
} else if button == pod.other().paddle() && !p.confirmed {
|
||
p.contradicted = true;
|
||
}
|
||
}
|
||
});
|
||
// Which pod said what, before the merge collapses it. A pod
|
||
// reporting a button that belongs to the *other* one is invisible
|
||
// downstream — the input carries the button, never its source — and
|
||
// that is exactly the shape of a mis-mapped bit.
|
||
tracing::debug!(
|
||
pod = pod.as_str(),
|
||
button = button_name(button),
|
||
pressed,
|
||
"controller: button"
|
||
);
|
||
|
||
// One press is one press, however many pods reported it.
|
||
let Some(pressed) = buttons.edge(pod, button, pressed) else {
|
||
return;
|
||
};
|
||
// A send failure only means nobody is listening yet.
|
||
let _ = input_tx.send(ControllerInput {
|
||
button: button_name(button),
|
||
pressed,
|
||
pod: pod.into(),
|
||
});
|
||
}
|
||
ClickEvent::Unknown { kind, raw } => {
|
||
// The bytes, not just the type. A frame we cannot read is the one
|
||
// case where the type byte is the least useful part of it — it is
|
||
// very often the *framing* that is wrong rather than the content,
|
||
// and without the payload there is nothing to tell the two apart
|
||
// (NFR-8).
|
||
tracing::debug!(
|
||
pod = pod.as_str(),
|
||
raw = %raw.iter().map(|b| format!("{b:02x}")).collect::<String>(),
|
||
"controller: unhandled frame type 0x{kind:02x}"
|
||
)
|
||
}
|
||
// Terminal, and it has to drop the client as well as change the status,
|
||
// so `handle_event` deals with it before we ever get here.
|
||
ClickEvent::GaveUp { .. } => {}
|
||
}
|
||
}
|
||
|
||
/// Exchange everything the two slots know about their pods, leaving each
|
||
/// status's own identity (`pod`, `symbol`) where it is.
|
||
fn swap_status(s: &mut ControllerStatus) {
|
||
std::mem::swap(&mut s.minus.state, &mut s.plus.state);
|
||
std::mem::swap(&mut s.minus.address, &mut s.plus.address);
|
||
std::mem::swap(&mut s.minus.name, &mut s.plus.name);
|
||
std::mem::swap(&mut s.minus.battery_percent, &mut s.plus.battery_percent);
|
||
std::mem::swap(&mut s.minus.error, &mut s.plus.error);
|
||
std::mem::swap(&mut s.minus.last_button, &mut s.plus.last_button);
|
||
std::mem::swap(&mut s.minus.buttons_seen, &mut s.plus.buttons_seen);
|
||
std::mem::swap(&mut s.minus.confirmed, &mut s.plus.confirmed);
|
||
// A swap made *because* of a contradiction must not carry the complaint
|
||
// across, or the screen would go on asking to be swapped back.
|
||
s.minus.contradicted = false;
|
||
s.plus.contradicted = false;
|
||
}
|
||
|
||
/// Close both links, waiting for each (SAF-9). In-flight attempts are abandoned
|
||
/// rather than finished — a quit must not wait out a twenty-second scan.
|
||
async fn shutdown_all(minus: &mut Slot, plus: &mut Slot) {
|
||
for slot in [minus, plus] {
|
||
slot.abandon_attempt();
|
||
slot.events = None;
|
||
slot.last_seen = None;
|
||
if let Some(client) = slot.client.take() {
|
||
client.shutdown().await;
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn every_button_has_a_distinct_webview_name() {
|
||
let mut seen = std::collections::HashSet::new();
|
||
for b in Button::ALL {
|
||
let name = button_name(b);
|
||
assert!(seen.insert(name), "{name} is used twice");
|
||
// The webview switches on these; a stray `+` would need escaping.
|
||
assert!(
|
||
name.chars().all(|c| c.is_ascii_lowercase()),
|
||
"{name} is not a plain lowercase identifier"
|
||
);
|
||
}
|
||
assert_eq!(seen.len(), 10);
|
||
}
|
||
|
||
#[test]
|
||
fn paddles_are_named_for_typescript_not_for_display() {
|
||
assert_eq!(button_name(Button::Plus), "plus");
|
||
assert_eq!(button_name(Button::Minus), "minus");
|
||
}
|
||
|
||
#[test]
|
||
fn a_fresh_status_has_two_idle_pods_and_blames_neither() {
|
||
let s = ControllerStatus::default();
|
||
assert_eq!(s.minus.state, PodState::Idle);
|
||
assert_eq!(s.plus.state, PodState::Idle);
|
||
assert_eq!(s.minus.symbol, "−");
|
||
assert_eq!(s.plus.symbol, "+");
|
||
assert!(!s.any_connected() && !s.both_connected());
|
||
assert!(s.minus.error.is_none() && s.plus.battery_percent.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn one_pod_connecting_does_not_speak_for_the_other() {
|
||
// The reason this module has two slots: a single status meant a
|
||
// connected pod and a missing one shared one line, and the rider could
|
||
// not tell which of the two had gone (FR-1.4).
|
||
let mut s = ControllerStatus::default();
|
||
s.get_mut(PodId::Plus).state = PodState::Connected;
|
||
assert!(s.any_connected());
|
||
assert!(!s.both_connected());
|
||
assert!(!s.minus.connected());
|
||
assert_eq!(s.get(PodId::Minus).state, PodState::Idle);
|
||
}
|
||
|
||
#[test]
|
||
fn chasing_a_pod_is_bounded_but_patient() {
|
||
// FR-1.11. A Click sleeps between efforts and only advertises after a
|
||
// button press (A-4), so it must be chased for longer than a trainer —
|
||
// but not forever: a flat pod never comes back, and scanning for it
|
||
// every thirty seconds competes with the trainer for the one adapter.
|
||
let cfg = controller_config();
|
||
assert_eq!(cfg.backoff.max_attempts, Some(RECONNECT_ATTEMPTS));
|
||
assert!(cfg.backoff.exhausted(RECONNECT_ATTEMPTS));
|
||
assert!(!cfg.backoff.exhausted(RECONNECT_ATTEMPTS - 1));
|
||
// That it outlasts the trainer's budget is asserted at compile time,
|
||
// next to the constant.
|
||
}
|
||
|
||
#[test]
|
||
fn a_known_address_beats_the_type_byte_and_survives_a_swap() {
|
||
let mut status = ControllerStatus::default();
|
||
// Nothing seen yet: fall back to the advertisement's type byte.
|
||
assert_eq!(
|
||
selector_for(PodId::Minus, None, &status, false),
|
||
PodSelector::Pod(PodId::Minus)
|
||
);
|
||
// Swapped, so the − slot goes looking for the byte we used to read as +.
|
||
assert_eq!(
|
||
selector_for(PodId::Minus, None, &status, true),
|
||
PodSelector::Pod(PodId::Plus)
|
||
);
|
||
|
||
// Once a pod has been seen, its address identifies it exactly — and an
|
||
// address cannot be confused with its identically-named twin.
|
||
status.get_mut(PodId::Minus).address = Some("f4:c4:59:03:a1:8e".into());
|
||
assert_eq!(
|
||
selector_for(PodId::Minus, None, &status, true),
|
||
PodSelector::Address("f4:c4:59:03:a1:8e".into())
|
||
);
|
||
// An address handed in by the caller wins over the remembered one.
|
||
assert_eq!(
|
||
selector_for(
|
||
PodId::Minus,
|
||
Some("c0:4a:0e:f9:a8:78".into()),
|
||
&status,
|
||
false
|
||
),
|
||
PodSelector::Address("c0:4a:0e:f9:a8:78".into())
|
||
);
|
||
// A blank one is not an address.
|
||
assert_eq!(
|
||
selector_for(PodId::Plus, Some(" ".into()), &status, false),
|
||
PodSelector::Pod(PodId::Plus)
|
||
);
|
||
}
|
||
|
||
/// One pod's edge, folded in exactly as the supervisor would.
|
||
fn press(
|
||
pod: PodId,
|
||
button: Button,
|
||
pressed: bool,
|
||
buttons: &mut Buttons,
|
||
status_tx: &watch::Sender<ControllerStatus>,
|
||
input_tx: &tokio::sync::broadcast::Sender<ControllerInput>,
|
||
) {
|
||
apply(
|
||
pod,
|
||
ClickEvent::Button { button, pressed },
|
||
buttons,
|
||
status_tx,
|
||
input_tx,
|
||
);
|
||
}
|
||
|
||
/// Everything the app was told about, in order.
|
||
fn drain(
|
||
inputs: &mut tokio::sync::broadcast::Receiver<ControllerInput>,
|
||
) -> Vec<(&'static str, bool)> {
|
||
let mut out = Vec::new();
|
||
while let Ok(i) = inputs.try_recv() {
|
||
out.push((i.button, i.pressed));
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The bug this merge exists to kill, in the shape it was reported: one
|
||
/// press of `+` shifted two gears, while `−` shifted one. The `+` paddle is
|
||
/// relayed by the twin as well as sent by its own pod (§2.3.1), and the app
|
||
/// acted on both copies.
|
||
#[test]
|
||
fn one_press_of_a_paddle_both_pods_report_is_one_press() {
|
||
let (status_tx, _status_rx) = watch::channel(ControllerStatus::default());
|
||
let (input_tx, mut inputs) = tokio::sync::broadcast::channel(16);
|
||
let mut b = Buttons::default();
|
||
|
||
// Its own pod sends it; the twin relays the same press.
|
||
press(PodId::Plus, Button::Plus, true, &mut b, &status_tx, &input_tx);
|
||
press(PodId::Minus, Button::Plus, true, &mut b, &status_tx, &input_tx);
|
||
assert_eq!(drain(&mut inputs), vec![("plus", true)], "one press, one shift");
|
||
|
||
// And the release only lands once both have let go — the aggregate is
|
||
// what re-arms the next press.
|
||
press(PodId::Plus, Button::Plus, false, &mut b, &status_tx, &input_tx);
|
||
assert!(drain(&mut inputs).is_empty(), "the twin still holds it");
|
||
press(PodId::Minus, Button::Plus, false, &mut b, &status_tx, &input_tx);
|
||
assert_eq!(drain(&mut inputs), vec![("plus", false)]);
|
||
|
||
// The next press is a new press, not a suppressed duplicate.
|
||
press(PodId::Plus, Button::Plus, true, &mut b, &status_tx, &input_tx);
|
||
assert_eq!(drain(&mut inputs), vec![("plus", true)]);
|
||
}
|
||
|
||
#[test]
|
||
fn a_button_only_one_pod_reports_still_arrives() {
|
||
// `−` and the D-pad were never doubled, and must not now be swallowed.
|
||
let (status_tx, _status_rx) = watch::channel(ControllerStatus::default());
|
||
let (input_tx, mut inputs) = tokio::sync::broadcast::channel(16);
|
||
let mut b = Buttons::default();
|
||
|
||
for _ in 0..3 {
|
||
press(PodId::Minus, Button::Minus, true, &mut b, &status_tx, &input_tx);
|
||
press(PodId::Minus, Button::Minus, false, &mut b, &status_tx, &input_tx);
|
||
}
|
||
assert_eq!(
|
||
drain(&mut inputs),
|
||
vec![
|
||
("minus", true),
|
||
("minus", false),
|
||
("minus", true),
|
||
("minus", false),
|
||
("minus", true),
|
||
("minus", false)
|
||
],
|
||
"three presses of − are three downshifts"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn two_buttons_at_once_are_still_two_buttons() {
|
||
// The mask is simultaneous state, so the merge has to be per button and
|
||
// not a single "something is held" flag.
|
||
let (status_tx, _status_rx) = watch::channel(ControllerStatus::default());
|
||
let (input_tx, mut inputs) = tokio::sync::broadcast::channel(16);
|
||
let mut b = Buttons::default();
|
||
|
||
press(PodId::Minus, Button::Up, true, &mut b, &status_tx, &input_tx);
|
||
press(PodId::Minus, Button::Minus, true, &mut b, &status_tx, &input_tx);
|
||
assert_eq!(drain(&mut inputs), vec![("up", true), ("minus", true)]);
|
||
}
|
||
|
||
/// A release that never arrives would latch a paddle held forever, and a
|
||
/// latched paddle does not shift at all — worse than the doubling.
|
||
#[test]
|
||
fn a_pod_that_goes_quiet_does_not_latch_the_button_it_was_holding() {
|
||
let (status_tx, status_rx) = watch::channel(ControllerStatus::default());
|
||
let (input_tx, mut inputs) = tokio::sync::broadcast::channel(16);
|
||
let mut b = Buttons::default();
|
||
let mut slot = Slot::default();
|
||
|
||
press(PodId::Plus, Button::Plus, true, &mut b, &status_tx, &input_tx);
|
||
let _ = drain(&mut inputs);
|
||
|
||
// Falling behind the pod may have cost us the release.
|
||
handle_event(
|
||
PodId::Plus,
|
||
Err(tokio::sync::broadcast::error::RecvError::Lagged(4)),
|
||
&mut slot,
|
||
&mut b,
|
||
&status_tx,
|
||
&input_tx,
|
||
);
|
||
assert_eq!(drain(&mut inputs), vec![("plus", false)], "let go of it");
|
||
|
||
press(PodId::Plus, Button::Plus, true, &mut b, &status_tx, &input_tx);
|
||
assert_eq!(drain(&mut inputs), vec![("plus", true)], "shifting still works");
|
||
assert_eq!(status_rx.borrow().plus.buttons_seen, 2);
|
||
}
|
||
|
||
/// A pod flipped to `Reconnecting` for going quiet must come back the
|
||
/// moment it says anything. Without this the flag was a one-way door: the
|
||
/// card read "Reconnecting…" for the rest of the ride while the paddle
|
||
/// shifted perfectly, which is indistinguishable from losing the pod.
|
||
#[test]
|
||
fn a_quiet_pod_that_speaks_again_is_connected_again() {
|
||
let (status_tx, status_rx) = watch::channel(ControllerStatus::default());
|
||
let (input_tx, _) = tokio::sync::broadcast::channel(8);
|
||
let mut slot = Slot::default();
|
||
let mut buttons = Buttons::default();
|
||
|
||
// What the staleness sweep does to a pod nobody has pressed.
|
||
status_tx.send_modify(|s| {
|
||
let p = s.get_mut(PodId::Minus);
|
||
p.state = PodState::Reconnecting;
|
||
});
|
||
|
||
handle_event(
|
||
PodId::Minus,
|
||
Ok(ClickEvent::Battery { percent: 90 }),
|
||
&mut slot,
|
||
&mut buttons,
|
||
&status_tx,
|
||
&input_tx,
|
||
);
|
||
|
||
assert_eq!(status_rx.borrow().minus.state, PodState::Connected);
|
||
assert_eq!(status_rx.borrow().minus.battery_percent, Some(90));
|
||
}
|
||
|
||
#[test]
|
||
fn the_link_reporting_its_own_end_is_not_taken_as_proof_of_life() {
|
||
// `Disconnected` arrives on the same stream as everything else, so the
|
||
// rule above has to exclude it or a drop would announce itself as a
|
||
// recovery.
|
||
let (status_tx, status_rx) = watch::channel(ControllerStatus::default());
|
||
let (input_tx, _) = tokio::sync::broadcast::channel(8);
|
||
let mut slot = Slot::default();
|
||
let mut buttons = Buttons::default();
|
||
status_tx.send_modify(|s| s.get_mut(PodId::Plus).state = PodState::Connected);
|
||
|
||
handle_event(
|
||
PodId::Plus,
|
||
Ok(ClickEvent::Disconnected),
|
||
&mut slot,
|
||
&mut buttons,
|
||
&status_tx,
|
||
&input_tx,
|
||
);
|
||
assert_eq!(status_rx.borrow().plus.state, PodState::Reconnecting);
|
||
}
|
||
|
||
#[test]
|
||
fn a_pod_is_not_called_stale_before_a_rider_could_plausibly_shift_twice() {
|
||
// The window this replaced was 20 s, which is shorter than an ordinary
|
||
// gap between shifts on a flat road — so a working pod was declared
|
||
// lost for not being pressed.
|
||
assert!(
|
||
STALE_AFTER >= Duration::from_secs(120),
|
||
"a Click is quiet by nature: {STALE_AFTER:?} will fire on a healthy pod"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_swap_relabels_the_pods_without_releasing_what_is_held() {
|
||
let mut b = Buttons::default();
|
||
assert_eq!(b.edge(PodId::Minus, Button::Plus, true), Some(true));
|
||
std::mem::swap(&mut b.minus, &mut b.plus);
|
||
assert!(b.held(Button::Plus), "a swap is a relabelling, not a release");
|
||
// And the release still lands, now arriving over the slot it moved to.
|
||
assert_eq!(b.edge(PodId::Plus, Button::Plus, false), Some(false));
|
||
}
|
||
|
||
#[test]
|
||
fn pressing_a_paddle_proves_which_pod_it_is() {
|
||
let (status_tx, status_rx) = watch::channel(ControllerStatus::default());
|
||
let (input_tx, mut inputs) = tokio::sync::broadcast::channel(8);
|
||
let mut buttons = Buttons::default();
|
||
|
||
apply(
|
||
PodId::Plus,
|
||
ClickEvent::Button {
|
||
button: Button::Plus,
|
||
pressed: true,
|
||
},
|
||
&mut buttons,
|
||
&status_tx,
|
||
&input_tx,
|
||
);
|
||
let plus = status_rx.borrow().plus.clone();
|
||
assert!(plus.confirmed, "its own paddle settles it");
|
||
assert!(!plus.contradicted);
|
||
assert_eq!(plus.last_button, Some("plus"));
|
||
assert_eq!(plus.buttons_seen, 1);
|
||
|
||
// The edge still reaches the webview, tagged with its pod.
|
||
let forwarded = inputs.try_recv().expect("edge forwarded");
|
||
assert_eq!(forwarded.button, "plus");
|
||
assert_eq!(forwarded.pod, Pod::Plus);
|
||
|
||
// The other pod's paddle arriving on a pod that has already sent its own
|
||
// is not evidence of anything: the pair relays each other's paddles
|
||
// (§2.3.1, `Buttons`). Complaining here asked the rider to swap a pair
|
||
// that was filed correctly.
|
||
apply(
|
||
PodId::Plus,
|
||
ClickEvent::Button {
|
||
button: Button::Minus,
|
||
pressed: true,
|
||
},
|
||
&mut buttons,
|
||
&status_tx,
|
||
&input_tx,
|
||
);
|
||
assert!(!status_rx.borrow().plus.contradicted);
|
||
}
|
||
|
||
#[test]
|
||
fn a_pod_that_has_only_ever_sent_the_other_paddle_is_still_worth_querying() {
|
||
// The case the flag is actually for: nothing has confirmed this pod, and
|
||
// the one paddle it has sent belongs to its twin.
|
||
let (status_tx, status_rx) = watch::channel(ControllerStatus::default());
|
||
let (input_tx, _) = tokio::sync::broadcast::channel(8);
|
||
let mut buttons = Buttons::default();
|
||
|
||
apply(
|
||
PodId::Plus,
|
||
ClickEvent::Button {
|
||
button: Button::Minus,
|
||
pressed: true,
|
||
},
|
||
&mut buttons,
|
||
&status_tx,
|
||
&input_tx,
|
||
);
|
||
assert!(status_rx.borrow().plus.contradicted);
|
||
assert!(!status_rx.borrow().plus.confirmed);
|
||
|
||
// Its own paddle settles it and withdraws the complaint.
|
||
apply(
|
||
PodId::Plus,
|
||
ClickEvent::Button {
|
||
button: Button::Plus,
|
||
pressed: true,
|
||
},
|
||
&mut buttons,
|
||
&status_tx,
|
||
&input_tx,
|
||
);
|
||
assert!(status_rx.borrow().plus.confirmed);
|
||
assert!(!status_rx.borrow().plus.contradicted);
|
||
}
|
||
|
||
#[test]
|
||
fn releases_do_not_count_as_evidence() {
|
||
let (status_tx, status_rx) = watch::channel(ControllerStatus::default());
|
||
let (input_tx, _) = tokio::sync::broadcast::channel(8);
|
||
apply(
|
||
PodId::Minus,
|
||
ClickEvent::Button {
|
||
button: Button::Minus,
|
||
pressed: false,
|
||
},
|
||
&mut Buttons::default(),
|
||
&status_tx,
|
||
&input_tx,
|
||
);
|
||
let minus = status_rx.borrow().minus.clone();
|
||
assert_eq!(minus.buttons_seen, 0);
|
||
assert!(!minus.confirmed);
|
||
}
|
||
|
||
#[test]
|
||
fn a_swap_exchanges_what_the_pods_know_and_clears_the_complaint() {
|
||
let mut s = ControllerStatus::default();
|
||
s.minus.address = Some("aa".into());
|
||
s.minus.state = PodState::Connected;
|
||
s.minus.battery_percent = Some(80);
|
||
s.minus.contradicted = true;
|
||
s.plus.state = PodState::GaveUp;
|
||
|
||
swap_status(&mut s);
|
||
|
||
// Identity stays put; everything the link knew moves.
|
||
assert_eq!(s.minus.pod, Pod::Minus);
|
||
assert_eq!(s.minus.symbol, "−");
|
||
assert_eq!(s.plus.address.as_deref(), Some("aa"));
|
||
assert_eq!(s.plus.state, PodState::Connected);
|
||
assert_eq!(s.plus.battery_percent, Some(80));
|
||
assert_eq!(s.minus.state, PodState::GaveUp);
|
||
assert!(!s.plus.contradicted && !s.minus.contradicted);
|
||
}
|
||
|
||
#[test]
|
||
fn one_link_is_the_whole_controller_and_it_is_the_plus_pod() {
|
||
// Measured 2026-08-27, both pods on the same bench minutes apart: the −
|
||
// pod goes mute ~51 s into every session while its link stays up, and
|
||
// the + pod ran 133 s with 78 paddle edges and never flipped a flag
|
||
// (§2.3.3). The − pod relays more; the + pod keeps working. Shifting is
|
||
// what a ride cannot do without, so the + pod is the controller.
|
||
|
||
// The + pod is up, or on its way up. Nothing else matters.
|
||
assert!(!take_minus_pod(false, true, true));
|
||
assert!(!take_minus_pod(false, false, true));
|
||
|
||
// We know a + pod exists and it has not been out of reach for long. It
|
||
// is almost certainly just asleep — a Click only advertises while awake
|
||
// (A-4) — so wait rather than open a link we would only close again.
|
||
assert!(!take_minus_pod(true, true, false));
|
||
|
||
// No + pod has ever been seen or remembered: this rider's − pod is all
|
||
// there is, fifty working seconds and all.
|
||
assert!(take_minus_pod(true, false, false));
|
||
|
||
// The + pod is known but has stayed out of reach. Flat, or left in the
|
||
// garage. Fifty seconds of shifting beats none.
|
||
assert!(take_minus_pod(true, true, true));
|
||
}
|
||
|
||
#[test]
|
||
fn waiting_for_the_minus_pod_is_shorter_than_giving_up_on_it() {
|
||
// The grace period is a pause, not a policy: it must expire long before
|
||
// the reconnect budget does, or a rider whose − pod is flat sits with no
|
||
// controller at all while the app keeps hoping.
|
||
assert!(
|
||
PLUS_GRACE <= Duration::from_secs(60),
|
||
"a rider with a flat − pod waits {PLUS_GRACE:?} for their + paddle"
|
||
);
|
||
assert!(
|
||
PLUS_GRACE >= Duration::from_secs(20),
|
||
"shorter than the time it takes to wake two pods by hand"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_missing_pod_is_reported_as_something_to_do_not_as_not_found() {
|
||
let advice = connect_advice(PodId::Minus, &FtmsError::NotFound("the − Click pod".into()));
|
||
assert!(advice.contains('−'), "{advice}");
|
||
assert!(advice.to_lowercase().contains("press"), "{advice}");
|
||
}
|
||
|
||
#[test]
|
||
fn giving_up_is_terminal_and_forgets_the_battery_but_not_the_pod() {
|
||
// Distinct from `Disconnected`, which means "hold on, retrying". This
|
||
// one means the app has stopped trying, and saying nothing would leave
|
||
// the rider with dead buttons and no explanation (FR-1.11).
|
||
let mut status = ControllerStatus::default();
|
||
let p = status.get_mut(PodId::Plus);
|
||
p.state = PodState::Connected;
|
||
p.battery_percent = Some(80);
|
||
p.address = Some("c0:4a:0e:f9:a8:78".into());
|
||
|
||
let (status_tx, status_rx) = watch::channel(status);
|
||
let (input_tx, _) = tokio::sync::broadcast::channel(4);
|
||
let mut slot = Slot::default();
|
||
handle_event(
|
||
PodId::Plus,
|
||
Ok(ClickEvent::GaveUp { attempts: 30 }),
|
||
&mut slot,
|
||
&mut Buttons::default(),
|
||
&status_tx,
|
||
&input_tx,
|
||
);
|
||
|
||
let plus = status_rx.borrow().plus.clone();
|
||
assert_eq!(plus.state, PodState::GaveUp);
|
||
assert!(
|
||
plus.battery_percent.is_none(),
|
||
"a stale battery reading is a lie"
|
||
);
|
||
assert!(plus.error.as_deref().is_some_and(|e| e.contains("Gave up")));
|
||
// The address is how the next attempt finds this pod rather than its
|
||
// twin, so it outlives the link.
|
||
assert_eq!(plus.address.as_deref(), Some("c0:4a:0e:f9:a8:78"));
|
||
// And the other pod is untouched: one pod dying is not both.
|
||
assert_eq!(status_rx.borrow().minus.state, PodState::Idle);
|
||
}
|
||
|
||
#[test]
|
||
fn a_dropped_link_reads_as_reconnecting_not_as_gone() {
|
||
let (status_tx, status_rx) = watch::channel(ControllerStatus::default());
|
||
let (input_tx, _) = tokio::sync::broadcast::channel(4);
|
||
status_tx.send_modify(|s| s.get_mut(PodId::Minus).state = PodState::Connected);
|
||
|
||
apply(
|
||
PodId::Minus,
|
||
ClickEvent::Disconnected,
|
||
&mut Buttons::default(),
|
||
&status_tx,
|
||
&input_tx,
|
||
);
|
||
assert_eq!(status_rx.borrow().minus.state, PodState::Reconnecting);
|
||
assert!(
|
||
status_rx.borrow().minus.error.is_none(),
|
||
"retrying is not a failure"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_pod_the_scan_sees_is_connected_unless_the_rider_said_otherwise() {
|
||
// A Click advertises for a couple of seconds after a button press, so
|
||
// the scan sighting *is* the connect opportunity — there is no second
|
||
// one to press a button for.
|
||
let mut slot = Slot::default();
|
||
assert!(slot.auto && slot.idle(), "a fresh slot takes what it is offered");
|
||
|
||
// A deliberate disconnect stops that: a disconnect that reconnects
|
||
// itself two seconds later is not a disconnect.
|
||
slot.auto = false;
|
||
assert!(slot.idle());
|
||
assert!(!slot.auto);
|
||
|
||
// Asking again by hand re-arms it — which is what `Cmd::Connect` does.
|
||
slot.auto = true;
|
||
assert!(slot.auto);
|
||
|
||
// A slot mid-search is not idle, so a sighting cannot start a second
|
||
// attempt for the same pod.
|
||
let (cancel, _rx) = oneshot::channel();
|
||
slot.cancel = Some(cancel);
|
||
assert!(!slot.idle());
|
||
}
|
||
|
||
#[test]
|
||
fn a_superseded_attempt_is_recognisable_by_its_generation() {
|
||
// Two requests in flight for one pod would otherwise both land, and the
|
||
// second link would be left open with nothing holding it (SAF-9).
|
||
let mut slot = Slot::default();
|
||
slot.generation += 1;
|
||
let stale = slot.generation;
|
||
slot.abandon_attempt();
|
||
assert_ne!(stale, slot.generation);
|
||
assert!(!slot.connecting());
|
||
}
|
||
}
|