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:
+131
-15
@@ -74,6 +74,19 @@ const NO_INPUT_AFTER: Duration = Duration::from_secs(150);
|
||||
/// 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
|
||||
@@ -379,6 +392,14 @@ 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
|
||||
@@ -456,6 +477,14 @@ impl ControllerHandle {
|
||||
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
|
||||
@@ -587,6 +616,10 @@ async fn run(
|
||||
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;
|
||||
|
||||
let (attempt_tx, mut attempt_rx) = mpsc::channel::<Attempt>(4);
|
||||
let mut housekeeping = tokio::time::interval(Duration::from_secs(5));
|
||||
@@ -618,29 +651,43 @@ async fn run(
|
||||
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::Minus {
|
||||
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.
|
||||
//
|
||||
// The `−` pod relays its twin: connected on its own it
|
||||
// delivers its own paddle and D-pad *and* the `+`
|
||||
// paddle and face buttons — all ten buttons, measured
|
||||
// over 445 frames on one characteristic (§2.3.1). So
|
||||
// the `+` pod is not connected while the `−` pod is
|
||||
// there to speak for it. It stays a fallback for the
|
||||
// case where the `−` pod is absent or the rider only
|
||||
// owns that half.
|
||||
//
|
||||
// Connecting both is what the pair-merge in `Buttons`
|
||||
// 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.
|
||||
// Not opening the second link removes both.
|
||||
if pod == PodId::Plus && !slot_mut(&mut minus, &mut plus, PodId::Minus).idle()
|
||||
if pod == PodId::Plus
|
||||
&& !take_plus_pod(
|
||||
minus.idle(),
|
||||
status_tx.borrow().minus.address.is_some(),
|
||||
tokio::time::Instant::now() >= plus_gate,
|
||||
)
|
||||
{
|
||||
tracing::debug!(
|
||||
"controller: + pod seen but the − pod is already speaking for it"
|
||||
"controller: + pod seen; the − pod speaks for the pair"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -653,6 +700,9 @@ async fn run(
|
||||
// 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);
|
||||
@@ -738,6 +788,13 @@ async fn run(
|
||||
}
|
||||
|
||||
_ = 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 !minus.idle() {
|
||||
plus_gate = tokio::time::Instant::now() + PLUS_GRACE;
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -820,6 +877,25 @@ async fn run(
|
||||
}
|
||||
}
|
||||
|
||||
/// May a `+` pod the scan has just seen be connected?
|
||||
///
|
||||
/// The `−` pod is the controller (§2.3.1): connected on its own it delivers all
|
||||
/// ten buttons, its twin's included. So a `+` link is only ever a *substitute*,
|
||||
/// and opening one alongside a working `−` link is the configuration in which
|
||||
/// the `−` pod stops reporting its own paddle.
|
||||
///
|
||||
/// Three inputs, in the order they decide:
|
||||
/// - `minus_idle` — false when the `−` pod is connected or being connected.
|
||||
/// Nothing else matters then: it is already speaking for both.
|
||||
/// - `minus_known` — we have an address for a `−` pod, from this session or
|
||||
/// from the remembered-device store. With none, there is no `−` pod to wait
|
||||
/// for and the `+` pod is the whole controller.
|
||||
/// - `gate_expired` — the `−` pod has been unreachable for [`PLUS_GRACE`].
|
||||
/// A flat or lost `−` pod must not cost the rider their `+` paddle too.
|
||||
fn take_plus_pod(minus_idle: bool, minus_known: bool, gate_expired: bool) -> bool {
|
||||
minus_idle && (!minus_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,
|
||||
@@ -1574,6 +1650,46 @@ mod tests {
|
||||
assert!(!s.plus.contradicted && !s.minus.contradicted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_link_is_the_whole_controller() {
|
||||
// Confirmed in the field 2026-08-21: pairing the − pod alone gives all
|
||||
// ten buttons, because it relays its twin (§2.3.1). So the + pod is a
|
||||
// substitute, never a second half.
|
||||
|
||||
// The − pod is up, or on its way up. Nothing else matters.
|
||||
assert!(!take_plus_pod(false, true, true));
|
||||
assert!(!take_plus_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_plus_pod(true, true, false));
|
||||
|
||||
// No − pod has ever been seen or remembered: this rider's + pod *is*
|
||||
// their controller, and making them wait for a pod they do not own
|
||||
// would be waiting forever.
|
||||
assert!(take_plus_pod(true, false, false));
|
||||
|
||||
// The − pod is known but has stayed out of reach. Flat, or left in the
|
||||
// garage. Half a controller beats none.
|
||||
assert!(take_plus_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()));
|
||||
|
||||
Reference in New Issue
Block a user