From 8964a0fd74b3b9f52da33817b2db815b05fe8e5c Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Thu, 27 Aug 2026 20:09:26 +0200 Subject: [PATCH] Pair the pod that works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- REQUIREMENTS.md | 20 ++++ src-tauri/src/commands.rs | 5 +- src-tauri/src/controller.rs | 118 ++++++++++++---------- ui/src/components/ClickPanel.svelte | 51 +++++----- ui/src/components/ConnectionScreen.svelte | 9 +- 5 files changed, 121 insertions(+), 82 deletions(-) diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 6d88d83..c0ae2b0 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -200,6 +200,26 @@ button we have found drives them. > so it is evidence of a wrong-way-round pair only while that pod has never sent its own — > otherwise the connection screen asks the rider to swap a pair that is filed correctly. +> **The `+` pod is the controller — measured 2026-08-27, and this reverses the note +> below.** Both pods on the same bench, minutes apart, `probe unlock` with no writes: +> +> | | `−` pod | `+` pod | +> |---|---|---| +> | button reporting stops | **~51 s**, every session | **never** — 133 s, 78 paddle edges | +> | status flag `0 → 1` | yes, at the cliff | **never flipped** | +> | key offers | 2 per session | **none** | +> +> The `−` pod does relay 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. The `+` pod +> alone is a complete shifter: its paddle shifts up and `Y` shifts down, both of which the +> app already maps. Shifting is the thing a ride cannot do without, so the app now pairs the +> `+` pod and the `−` pod is the substitute. `take_minus_pod` is the old rule with the pods +> exchanged; opening both is still the configuration that breaks the `−` pod's own paddle, +> so it is still one link. +> +> This also settles A-2 from the other end. The keep-alive that "removes the daily unlock, +> but only for the right controller" removes nothing — the right controller never needed it. + > **One link is the whole controller — confirmed 2026-08-21.** Pairing the `−` pod *alone* > delivers all ten buttons on this hardware: its own paddle and D-pad, plus the `+` paddle and > face buttons relayed from its twin. There is nothing a second link adds, and there is one diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 22b3053..52876a8 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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(()) diff --git a/src-tauri/src/controller.rs b/src-tauri/src/controller.rs index d8e9408..2919213 100644 --- a/src-tauri/src/controller.rs +++ b/src-tauri/src/controller.rs @@ -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] diff --git a/ui/src/components/ClickPanel.svelte b/ui/src/components/ClickPanel.svelte index d97e2ed..4ec42bc 100644 --- a/ui/src/components/ClickPanel.svelte +++ b/ui/src/components/ClickPanel.svelte @@ -35,11 +35,12 @@ * pod stays missing — and the fix belongs next to the symptom. */ const scanning = $derived(app.devices.scanning); const anyConnected = $derived(pods.some((p) => p.state === 'connected')); - /** The pod that speaks for the pair. Connected, this is the whole controller. */ - const minusLive = $derived(controller?.minus.state === 'connected'); - /** Running on the fallback: the `+` pod alone, with no `−` paddle to shift - * down with beyond its `Y` button. Worth saying out loud. */ - const plusOnly = $derived(!minusLive && controller?.plus.state === 'connected'); + /** The controller. Measured 2026-08-27: the `+` pod keeps reporting where the + * `−` pod goes mute about fifty seconds in (§2.3.3). */ + const plusLive = $derived(controller?.plus.state === 'connected'); + /** Running on the fallback: the `−` pod, which relays everything but stops + * reporting about fifty seconds in unless Zwift has blessed it today. */ + const minusOnly = $derived(!plusLive && controller?.minus.state === 'connected'); const busy = $derived(pods.some((p) => p.state === 'searching')); /** A pod reporting the other's paddle: the pair may be filed the wrong way * round, and the rider is the only one who can say. */ @@ -63,8 +64,8 @@ /** What each pod is for, so a rider who has lost one knows what they lost. */ const PURPOSE: Record = { - minus: 'All ten buttons', - plus: 'Fallback · shift up, A B Y Z', + plus: 'Shift up · A B Y Z', + minus: 'All ten buttons, for ~50 s', }; function connect(pod: Pod) { @@ -84,11 +85,11 @@ - {:else if !minusLive} - + {:else if !plusLive} + {/if} {#if anyConnected} @@ -103,21 +104,23 @@ the badge above has already said it. --> {#if !scanning}

The scan is off — pods will not be picked up.

- {:else if plusOnly} + {:else if minusOnly}

- Running on the + pod alone: shift down with Y. - Press a button on the − pod for the D-pad. + Running on the − pod, which relays all ten buttons but stops reporting + after about a minute unless Zwift has blessed it today. Press a button on the + + pod for shifting that lasts.

- {:else if !minusLive} + {:else if !plusLive}

- Press any button on the − pod. It only advertises while awake; the running - scan connects it as soon as it does. + Press any button on the + pod. It only advertises while awake; the running + scan connects it as soon as it does. Its paddle shifts up and + Y shifts down.

{/if}
{#each pods as pod (pod.pod)} - {@const dormant = pod.pod === 'plus' && minusLive && pod.state !== 'connected'} + {@const dormant = pod.pod === 'minus' && plusLive && pod.state !== 'connected'}
@@ -128,8 +131,8 @@ {#if dormant} - Relayed by the − pod + pod is idle because joining the pair is what breaks it. --> + Held back — the + pod is the controller {:else if pod.confirmed} Confirmed — sent its own {pod.symbol} paddle {:else if pod.state === 'connected'} @@ -193,7 +196,7 @@
{/if} - {#if !minusLive} + {#if !plusLive}