Files
BikeControl/src-tauri/src/controller.rs
T
dtourolleandClaude Opus 5 8964a0fd74 Pair the pod that works
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>
2026-08-27 20:09:26 +02:00

1875 lines
80 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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());
}
}