Reconnect to remembered hardware instead of pairing every launch

`remembered` was a HashSet inside DeviceRegistry, so it lasted exactly as
long as the process. Every launch started from nothing: find the trainer,
press Connect, find the strap, press Connect, and only then ride. FR-1.5
has been a Should since the beginning and was never actually true.

It is a file now — devices.json in the app data directory, written when a
link actually comes up rather than when Connect is pressed. A Connect the
hardware then refuses is not a pairing, and writing one down would mean a
trainer the rider gave up on getting chased on every launch afterwards.
Forgetting is recorded too, in its own list: absent means never seen,
forgotten means the rider looked at this device and said no, and
auto-connect has to keep honouring that on the next launch as well. The
file is advisory — a corrupt one costs auto-connect, never a ride.

Auto-connect is driven by the scan rather than fired once at startup. The
hardware is asleep at startup — a trainer wakes when the cranks turn, a
strap when it is put on (A-4) — so a remembered device is reconnected the
moment it advertises, through the same path the rider's own click takes,
scan suspension included. Bounded by AUTO_ATTEMPTS on an AUTO_RETRY
cooldown and cleared when the link comes up or the rider connects by
hand: an app that never stops trying can never honestly say it has
stopped (FR-1.11). A device disconnected by hand is left alone for the
rest of the session, since a disconnect that undoes itself two ticks
later is not a disconnect.

Pods now prefer the pod we know. Every Click advertises the same name and
the same type byte, so before this a rider whose partner was warming up
in the next room got whichever pod woke first. With nothing of that kind
remembered anything still goes, or there could never be a first pairing.

And the pair is one pod, not two. Confirmed on this hardware 2026-08-21:
pairing the − pod alone delivers all ten buttons, its twin's included —
which §2.3.1 had established for the frames but not for the pairing. So
take_plus_pod holds the + pod back while a known − pod may merely be
asleep, and connect_controller with no pod named means the − pod rather
than both. The wait is bounded by PLUS_GRACE, because a flat − pod should
cost the rider a D-pad and not a controller, and Buttons is untouched: it
is what makes the handover between the two configurations invisible.

Not yet tested against real hardware — nothing was advertising here. The
store, the retry budget and the pod-preference rules have unit tests, and
a seeded devices.json was confirmed to load and seed the − pod at launch,
but the connect path itself waits for a ride.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 19:36:34 +02:00
co-authored by Claude Opus 5
parent 5dff2500e2
commit 7497a5d602
8 changed files with 877 additions and 85 deletions
+9 -6
View File
@@ -728,7 +728,14 @@ pub fn controller_status(state: State<'_, AppState>) -> ControllerStatus {
state.controller().status()
}
/// Connect a Click pod, or both when `pod` is omitted (FR-1.4).
/// Connect a Click pod, or the one that matters when `pod` is omitted (FR-1.4).
///
/// **Omitting `pod` means the `` pod, not both.** One link is the whole
/// controller: the `` pod relays its twin's paddle and face buttons, so all
/// ten buttons arrive over it alone (§2.3.1, confirmed in the field
/// 2026-08-21). Connecting the pair adds nothing and breaks one thing — the
/// `` pod stops reporting its own paddle. The `+` pod keeps its own button on
/// the connection screen for the case where the `` pod is flat or absent.
///
/// `device_id` is an address, for a specific pod the scanner has already
/// listed. Without one the supervisor looks the pod up by the type byte in its
@@ -753,12 +760,8 @@ pub fn connect_controller(
if address.is_some() {
return Err("An address names one pod, so say which pod it is".into());
}
// Both, each on its own schedule: a pod that is awake connects now
// rather than queueing behind its sleeping twin.
let known = state.lock().devices.click_pod_addresses();
for id in PodId::BOTH {
controller.connect(id, known.get(&id).cloned());
}
controller.connect(PodId::Minus, known.get(&PodId::Minus).cloned());
}
}
Ok(())