Recycle a pod link that goes mute, not only one born mute

A link can wedge *after* working. On the tablet, 2026-08-27: the − pod
connected at 15:46:29, carried sixty presses, and at 15:47:19 stopped
sending button frames altogether while still streaming battery every five
seconds. The link was up, the pod was answering, and not one press
arrived for the rest of the ride.

The §7.1 detector could not see it. Its test was
`buttons_this_link == 0` — a link that had ever carried a button was
exempt, on the reasoning that a healthy pod proves itself once and should
never be disturbed again. Exempt for life turned out to mean dead for the
ride.

So the clock runs from the last press rather than from the connect, and
falls back to the connect for a link that never carried one. The cost of
being wrong is unchanged and still real — a rider who genuinely has not
shifted for NO_INPUT_AFTER loses shifting for the few seconds a reconnect
takes — which is why the window stays longer than any climb's worth of
steady pedalling.

Automatic recovery is deliberately slow, because the supervisor cannot
tell a wedged pod from a rider who is not shifting. The rider can, so the
shifter tile's action while connected is now Reconnect: drop the link and
take it again, immediately, instead of waiting out the window.

Not the both-pods failure, which was the first suspicion and would have
been the better story — connecting both is what stops the − pod reporting
its own paddle. The log rules it out: one `pod connected`, for the − pod,
and no redundant link ever closed. Every `plus` in it is the − pod
relaying its twin's paddle over the mesh, which is the pair working as
designed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 17:53:58 +02:00
co-authored by Claude Opus 5
parent bbd11757ee
commit 53e7cd05fc
3 changed files with 51 additions and 15 deletions
+36 -11
View File
@@ -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 /// How long a *live* link may go without ever carrying a button before we stop
/// believing in it. /// believing in it.
/// ///
/// This is not "the rider has not shifted lately" — that is normal, and tearing /// The §7.1 case: frames arriving steadily, battery every five seconds, and not
/// down a working link over it would strand a pod that only advertises while /// one press in all that time. Recycling costs a few seconds against a pod that
/// awake. It is the narrower and much stranger case from §7.1: frames arriving /// is otherwise useless for the rest of the ride.
/// 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 /// It is measured from the last press rather than from the connect, because a
/// touched by this; a wedged one never does, and recycling costs a few seconds /// link can wedge *after* working — sixty presses and then nothing, tablet,
/// against a pod that is otherwise useless for the whole ride. /// 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); const NO_INPUT_AFTER: Duration = Duration::from_secs(150);
/// Upper bound on closing the controller links at exit. Shorter than the /// 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 /// 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. /// frames arriving, and not one press among them.
connected_at: Option<tokio::time::Instant>, connected_at: Option<tokio::time::Instant>,
buttons_this_link: u32, 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? /// May this pod be connected the moment the scan sees it?
/// ///
/// True until the rider disconnects it by hand, because a pod that /// True until the rider disconnects it by hand, because a pod that
@@ -575,6 +583,7 @@ impl Default for Slot {
events: None, events: None,
connected_at: None, connected_at: None,
buttons_this_link: 0, buttons_this_link: 0,
last_button: None,
cancel: None, cancel: None,
generation: 0, generation: 0,
last_seen: None, last_seen: None,
@@ -848,13 +857,27 @@ async fn run(
let slot = slot_mut(&mut minus, &mut plus, id); let slot = slot_mut(&mut minus, &mut plus, id);
let alive = slot.client.is_some() let alive = slot.client.is_some()
&& slot.last_seen.is_some_and(|t| t.elapsed() < STALE_AFTER); && slot.last_seen.is_some_and(|t| t.elapsed() < STALE_AFTER);
let mute = slot.buttons_this_link == 0 // Silence *since the last press*, not "never pressed".
&& slot.connected_at.is_some_and(|t| t.elapsed() > NO_INPUT_AFTER); //
// 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 { if alive && mute && slot.auto {
tracing::warn!( tracing::warn!(
pod = id.as_str(), pod = id.as_str(),
"controller: link is alive but has never carried a button; \ presses = slot.buttons_this_link,
recycling it (see REQUIREMENTS §7.1)" "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.generation += 1;
slot.connected_at = None; slot.connected_at = None;
@@ -989,6 +1012,7 @@ fn apply_attempt(attempt: Attempt, slot: &mut Slot, status_tx: &watch::Sender<Co
// nothing about this one. // nothing about this one.
slot.connected_at = Some(tokio::time::Instant::now()); slot.connected_at = Some(tokio::time::Instant::now());
slot.buttons_this_link = 0; slot.buttons_this_link = 0;
slot.last_button = None;
status_tx.send_modify(|s| { status_tx.send_modify(|s| {
let p = s.get_mut(pod); let p = s.get_mut(pod);
p.state = PodState::Connected; p.state = PodState::Connected;
@@ -1077,6 +1101,7 @@ fn handle_event(
// This link has now proven it carries input, which puts it // This link has now proven it carries input, which puts it
// beyond the no-input watchdog for as long as it lasts. // beyond the no-input watchdog for as long as it lasts.
slot.buttons_this_link = slot.buttons_this_link.saturating_add(1); 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 // 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 // ever arrives — the pod does not announce that it has started
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "BikeControl", "productName": "BikeControl",
"version": "0.2.3", "version": "0.2.4",
"identifier": "paris.tourolle.bikecontrol", "identifier": "paris.tourolle.bikecontrol",
"build": { "build": {
"frontendDist": "../ui/dist", "frontendDist": "../ui/dist",
@@ -43,7 +43,7 @@
], ],
"category": "Utility", "category": "Utility",
"android": { "android": {
"versionCode": 1203 "versionCode": 1204
}, },
"shortDescription": "Indoor cycling trainer control", "shortDescription": "Indoor cycling trainer control",
"longDescription": "Control a smart trainer over BLE, ride gradient profiles and synthetic waveforms, and record the result." "longDescription": "Control a smart trainer over BLE, ride gradient profiles and synthetic waveforms, and record the result."
+13 -2
View File
@@ -19,7 +19,7 @@
* strangers fold away into a collapsed list at the bottom. * strangers fold away into a collapsed list at the bottom.
*/ */
import { app } from '../lib/app.svelte'; import { app } from '../lib/app.svelte';
import { api } from '../lib/bridge'; import { api, type Pod } from '../lib/bridge';
import { connectionText, rssiBars } from '../lib/format'; import { connectionText, rssiBars } from '../lib/format';
import type { DeviceInfo, DeviceKind } from '../lib/types'; import type { DeviceInfo, DeviceKind } from '../lib/types';
import ClickPanel from './ClickPanel.svelte'; import ClickPanel from './ClickPanel.svelte';
@@ -116,6 +116,12 @@
* were a separate button the rider had failed to press would be a lie about * were a separate button the rider had failed to press would be a lie about
* what the protocol does. * what the protocol does.
*/ */
/** Drop a pod's link and take it again — the fix for a wedged session. */
async function reconnectPod(pod: Pod) {
await api.disconnectController(pod);
await api.connectController(pod);
}
async function reacquire(id: string) { async function reacquire(id: string) {
await api.disconnect(id); await api.disconnect(id);
await api.connect(id); await api.connect(id);
@@ -179,8 +185,13 @@
.join(' · ') .join(' · ')
: 'Press a button to wake it — the keyboard works meanwhile', : 'Press a button to wake it — the keyboard works meanwhile',
tone: live ? 'tone-ok' : 'tone-idle', tone: live ? 'tone-ok' : 'tone-idle',
// Connected, the useful action is *rebuild the link*. A Click can wedge
// while looking perfectly healthy — battery arriving every five seconds,
// every press ignored (§7.1) — and the supervisor's own recovery is
// deliberately slow, because it cannot tell a wedged pod from a rider who
// simply is not shifting. The rider can, instantly.
action: live action: live
? null ? { label: 'Reconnect', run: () => app.run(() => reconnectPod(live.pod)) }
: { label: 'Find it', run: () => app.run(() => api.connectController()) }, : { label: 'Find it', run: () => app.run(() => api.connectController()) },
secondary: live secondary: live
? { label: 'Disconnect', run: () => app.run(() => api.disconnectController()) } ? { label: 'Disconnect', run: () => app.run(() => api.disconnectController()) }