diff --git a/src-tauri/src/controller.rs b/src-tauri/src/controller.rs index 9f67bb2..b496f1d 100644 --- a/src-tauri/src/controller.rs +++ b/src-tauri/src/controller.rs @@ -62,13 +62,18 @@ 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. /// -/// This is not "the rider has not shifted lately" — that is normal, and tearing -/// down a working link over it would strand a pod that only advertises while -/// awake. It is the narrower and much stranger case from §7.1: frames arriving -/// steadily, battery every five seconds, and not one press since the link came -/// up. A healthy pod proves itself with its first button and is then never -/// touched by this; a wedged one never does, and recycling costs a few seconds -/// against a pod that is otherwise useless for the whole ride. +/// 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 @@ -559,6 +564,9 @@ struct Slot { /// frames arriving, and not one press among them. connected_at: Option, 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, /// May this pod be connected the moment the scan sees it? /// /// True until the rider disconnects it by hand, because a pod that @@ -575,6 +583,7 @@ impl Default for Slot { events: None, connected_at: None, buttons_this_link: 0, + last_button: None, cancel: None, generation: 0, last_seen: None, @@ -848,13 +857,27 @@ async fn run( 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); - let mute = slot.buttons_this_link == 0 - && slot.connected_at.is_some_and(|t| t.elapsed() > NO_INPUT_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(), - "controller: link is alive but has never carried a button; \ - recycling it (see REQUIREMENTS §7.1)" + 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; @@ -989,6 +1012,7 @@ fn apply_attempt(attempt: Attempt, slot: &mut Slot, status_tx: &watch::Sender app.run(() => reconnectPod(live.pod)) } : { label: 'Find it', run: () => app.run(() => api.connectController()) }, secondary: live ? { label: 'Disconnect', run: () => app.run(() => api.disconnectController()) }