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
/// 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<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
@@ -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<Co
// 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;
@@ -1077,6 +1101,7 @@ fn handle_event(
// 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
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "BikeControl",
"version": "0.2.3",
"version": "0.2.4",
"identifier": "paris.tourolle.bikecontrol",
"build": {
"frontendDist": "../ui/dist",
@@ -43,7 +43,7 @@
],
"category": "Utility",
"android": {
"versionCode": 1203
"versionCode": 1204
},
"shortDescription": "Indoor cycling trainer control",
"longDescription": "Control a smart trainer over BLE, ride gradient profiles and synthetic waveforms, and record the result."