Pair the pod that works

Both pods, same bench, minutes apart, no writes:

  − pod   buttons stop at ~51 s, every session; flag 0 -> 1; 2 key offers
  + pod   133 s, 78 paddle edges, flag never flipped, no key offer at all

So the `−` pod is not the controller. It relays more when it works — all
ten buttons, its twin's included — but "when it works" is under a minute
without a Zwift blessing in the last day, and the `+` pod alone is a
complete shifter: its paddle shifts up, `Y` shifts down, both already
mapped. Shifting is what a ride cannot do without.

`take_plus_pod` becomes `take_minus_pod` — the same rule with the pods
exchanged — and the housekeeping, the connect guard and the
no-pod-named default follow it. Opening both is still what stops the `−`
pod reporting its own paddle, so it is still one link, just the other one.

The UI stops telling riders to press the pod that dies: the panel asks
for the `+` pod, the tile prefers it, and the `−` pod's row says what it
actually offers — all ten buttons, for about fifty seconds.

This settles A-2 from the other end too. The keep-alive that "removes the
daily unlock, but only for the right controller" removes nothing. The
right controller never needed it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 20:09:26 +02:00
co-authored by Claude Opus 5
parent e8d384e6fc
commit 8964a0fd74
5 changed files with 121 additions and 82 deletions
+4 -1
View File
@@ -795,8 +795,11 @@ pub fn connect_controller(
if address.is_some() {
return Err("An address names one pod, so say which pod it is".into());
}
// The `+` pod, since 2026-08-27: it is the one that keeps
// reporting (§2.3.3). Naming no pod means "connect the
// controller", and the controller is the pod that works.
let known = state.lock().devices.click_pod_addresses();
controller.connect(PodId::Minus, known.get(&PodId::Minus).cloned());
controller.connect(PodId::Plus, known.get(&PodId::Plus).cloned());
}
}
Ok(())
+65 -53
View File
@@ -663,16 +663,16 @@ async fn run(
// `auto` is still armed below, so the fallback stands:
// the moment the `−` pod goes away, the `+` pod is
// taken on its next advertisement.
let minus_up = !minus.idle();
let plus_up = !plus.idle();
let slot = slot_mut(&mut minus, &mut plus, pod);
// Asking for it by hand re-arms auto-connect, whatever
// came before.
slot.auto = true;
if pod == PodId::Plus && minus_up {
if pod == PodId::Minus && plus_up {
tracing::info!(
"controller: refusing a + pod link while the − pod is up — \
it relays the pair, and joining both stops it reporting \
its own paddle"
"controller: refusing a − pod link while the + pod is up — \
the + pod is the controller, and joining both stops the \
− pod reporting its own paddle anyway"
);
continue;
}
@@ -689,7 +689,7 @@ 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 {
if pod == PodId::Plus {
plus_gate = tokio::time::Instant::now() + PLUS_GRACE;
}
status_tx.send_modify(|s| s.get_mut(pod).reset_link(PodState::Searching));
@@ -717,10 +717,10 @@ async fn run(
// 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.
if pod == PodId::Plus
&& !take_plus_pod(
minus.idle(),
status_tx.borrow().minus.address.is_some(),
if pod == PodId::Minus
&& !take_minus_pod(
plus.idle(),
status_tx.borrow().plus.address.is_some(),
tokio::time::Instant::now() >= plus_gate,
)
{
@@ -733,8 +733,8 @@ async fn run(
// were reading the log for.
if !plus_declined {
tracing::debug!(
"controller: + pod seen; the − pod speaks for the pair \
(silenced until this changes)"
"controller: − pod seen; the + pod is the controller and \
is up (silenced until this changes)"
);
plus_declined = true;
}
@@ -840,7 +840,7 @@ async fn run(
// 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() {
if !plus.idle() {
plus_gate = tokio::time::Instant::now() + PLUS_GRACE;
} else {
// The − pod is no longer speaking for the pair, so the next
@@ -854,19 +854,20 @@ async fn run(
// and is exactly the configuration that breaks the `−` paddle.
// Dropping it leaves `auto` alone, so if the `−` pod later goes
// away the `+` pod is picked up again on its next advertisement.
if !minus.idle() && plus.client.is_some() {
if !plus.idle() && minus.client.is_some() {
tracing::info!(
"controller: − pod is up and relays the pair; closing the redundant + link"
"controller: + pod is up and is the controller; closing the − link, \
which is the pairing that stops the − pod reporting its own paddle"
);
forget(PodId::Plus, &mut buttons, &input_tx);
plus.generation += 1;
plus.events = None;
plus.last_seen = None;
plus.connected_at = None;
if let Some(client) = plus.client.take() {
forget(PodId::Minus, &mut buttons, &input_tx);
minus.generation += 1;
minus.events = None;
minus.last_seen = None;
minus.connected_at = None;
if let Some(client) = minus.client.take() {
tokio::spawn(async move { client.shutdown().await });
}
status_tx.send_modify(|s| s.get_mut(PodId::Plus).reset_link(PodState::Idle));
status_tx.send_modify(|s| s.get_mut(PodId::Minus).reset_link(PodState::Idle));
}
// A link that is plainly alive and has never carried a button
@@ -944,23 +945,33 @@ async fn run(
}
}
/// May a `+` pod the scan has just seen be connected?
/// 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.
/// **The `+` pod is the controller.** This is the reverse of what this module
/// assumed until 2026-08-27, and the measurement that turned it over is in
/// §2.3.3: the `−` pod stops reporting buttons about fifty seconds into every
/// session — a status flag flips, and the link stays up and healthy and mute —
/// while the `+` pod ran 133 s with 78 paddle edges, no key offer and no flag,
/// on the same bench, minutes apart.
///
/// 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)
/// The `−` pod does deliver more when it works: all ten buttons, its twin's
/// relayed. But "when it works" is under a minute without the daily Zwift
/// blessing, and the `+` pod alone is a complete shifter — its paddle shifts
/// up, `Y` shifts down — which is the thing a ride cannot do without.
///
/// So the `−` pod is now the substitute, and this is the old rule with the
/// pods exchanged. Opening both is still the configuration that breaks the `−`
/// pod's own paddle, so still only one link.
///
/// - `plus_idle` — false when the `+` pod is connected or being connected.
/// Nothing else matters then: it is the controller and it is up.
/// - `plus_known` — we have an address for a `+` pod. With none there is
/// nothing to wait for, and the `−` pod is all there is.
/// - `gate_expired` — the `+` pod has been unreachable for [`PLUS_GRACE`].
/// A flat `+` pod must not cost the rider the fifty working seconds the `−`
/// pod still offers.
fn take_minus_pod(plus_idle: bool, plus_known: bool, gate_expired: bool) -> bool {
plus_idle && (!plus_known || gate_expired)
}
fn slot_mut<'a>(minus: &'a mut Slot, plus: &'a mut Slot, pod: PodId) -> &'a mut Slot {
@@ -1720,28 +1731,29 @@ mod tests {
}
#[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.
fn one_link_is_the_whole_controller_and_it_is_the_plus_pod() {
// Measured 2026-08-27, both pods on the same bench minutes apart: the −
// pod goes mute ~51 s into every session while its link stays up, and
// the + pod ran 133 s with 78 paddle edges and never flipped a flag
// (§2.3.3). The − pod relays more; the + pod keeps working. Shifting is
// what a ride cannot do without, so the + pod is the controller.
// 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));
// The + pod is up, or on its way up. Nothing else matters.
assert!(!take_minus_pod(false, true, true));
assert!(!take_minus_pod(false, false, true));
// We know a − pod exists and it has not been out of reach for long. It
// 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));
assert!(!take_minus_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));
// No + pod has ever been seen or remembered: this rider's − pod is all
// there is, fifty working seconds and all.
assert!(take_minus_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));
// The + pod is known but has stayed out of reach. Flat, or left in the
// garage. Fifty seconds of shifting beats none.
assert!(take_minus_pod(true, true, true));
}
#[test]