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
+65 -36
View File
@@ -1,24 +1,31 @@
<script lang="ts">
/**
* The Zwift Click, as two pods (FR-1.4, FR-9.19.2).
* The Zwift Click (FR-1.4, FR-9.19.2).
*
* A Click v2 is **two peripherals**, and until now the app showed one line
* for both: connect, and you got whichever pod answered first, with no way to
* tell which one that was or that the other was missing entirely. Each pod
* now has a card of its own — its own state, battery, address and proof that
* its buttons arrive.
* A Click v2 is two peripherals, but it is **one controller**: connected on
* its own, the `` pod delivers all ten buttons — its own paddle and D-pad,
* *and* the `+` paddle and face buttons relayed from its twin (§2.3.1,
* confirmed on this hardware 2026-08-21). So one link is the whole thing, and
* this panel says so rather than presenting two halves that both look
* required. Pairing both is not merely redundant: it is the configuration in
* which the `` pod stops reporting its own paddle.
*
* The `+` pod keeps a card, because it is the fallback that matters when the
* `` pod is flat or left in the garage — a rider with one working pod should
* still get a working controller.
*
* They are named for the shift paddle each carries, not for the side of the
* bar. Nothing a pod advertises says which end of the handlebar it is
* clamped to, so left and right would be a guess; the paddle is printed on
* the pod, and pressing it settles the question on screen (`confirmed`).
*
* The second job of this panel is to say what to *do* when a pod is missing.
* A Click sleeps within seconds and only advertises while awake (A-4), which
* no rider can guess from the words "not connected" — and which is also why
* connecting is not a button they have to win a race with: the running scan
* picks a pod up the moment it wakes and connects it (FR-1.5). The buttons
* here are for overriding that, not for driving it.
* The second job of this panel is to say what to *do* when nothing is
* connected. A Click sleeps within seconds and only advertises while awake
* (A-4), which no rider can guess from the words "not connected" — and which
* is also why connecting is not a button they have to win a race with: the
* running scan picks a pod up the moment it wakes and connects it (FR-1.5),
* and once paired it goes back to the pod it knows. The buttons here are for
* overriding that, not for driving it.
*/
import { app } from '../lib/app.svelte';
import { api, type Pod, type PodState, type PodStatus } from '../lib/bridge';
@@ -29,7 +36,11 @@
* 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'));
const bothConnected = $derived(pods.length === 2 && pods.every((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');
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. */
@@ -53,8 +64,8 @@
/** What each pod is for, so a rider who has lost one knows what they lost. */
const PURPOSE: Record<Pod, string> = {
minus: 'Shift down · D-pad',
plus: 'Shift up · A B Y Z',
minus: 'All ten buttons · relays the + pod',
plus: 'Fallback · shift up, A B Y Z',
};
function connect(pod: Pod) {
@@ -69,13 +80,13 @@
<section class="click">
<header>
<h2>Zwift Click</h2>
<span class="summary" class:tone-ok={bothConnected} class:tone-warn={!bothConnected}>
{#if bothConnected}
Both pods connected
{:else if anyConnected}
One pod of two
<span class="summary" class:tone-ok={anyConnected} class:tone-warn={!anyConnected}>
{#if minusLive}
Connected · all ten buttons
{:else if plusOnly}
+ pod only
{:else}
No pods connected
Not connected
{/if}
</span>
<div class="actions">
@@ -83,14 +94,16 @@
<!-- Nothing can be picked up automatically while the scan is off, so
the way to fix that sits here rather than only in the header. -->
<button class="btn" onclick={() => app.run(() => api.startScan())}>Start scan</button>
{:else if !bothConnected}
{:else if !minusLive}
<!-- The pod, not both: it is the one that carries the whole
controller. The + pod has its own button on its own card. -->
<button class="btn" disabled={busy} onclick={() => app.run(() => api.connectController())}>
{busy ? 'Searching…' : 'Connect now'}
{busy ? 'Searching…' : 'Look for the pod'}
</button>
{/if}
{#if anyConnected}
<button class="btn ghost" onclick={() => app.run(() => api.disconnectController())}>
Disconnect both
Disconnect
</button>
{/if}
</div>
@@ -99,11 +112,16 @@
<p class="lede">
{#if !scanning}
<strong>The scan is off</strong>, so pods will not be picked up. Start it and press a button
on each pod.
{:else if bothConnected}
Both pods are connected and will reconnect on their own if one drops.
on a pod.
{:else if minusLive}
The <strong> pod is connected</strong>, and it relays its twin: all ten buttons arrive over
this one link. There is nothing to pair the + pod for. It reconnects on its own if it drops,
and on the next launch.
{:else if plusOnly}
Running on the <strong>+ pod alone</strong> — its paddle, face buttons and shift-down on
<span class="kbd">Y</span>. Press a button on the pod to get the D-pad back.
{:else}
<strong>Press any button on a missing pod.</strong> It only advertises while awake, and the
<strong>Press any button on the pod.</strong> It only advertises while awake, and the
running scan connects it as soon as it does — no need to press anything here.
{/if}
</p>
@@ -142,7 +160,13 @@
</div>
</dl>
{#if pod.confirmed}
{#if pod.pod === 'plus' && minusLive && pod.state !== 'connected'}
<!-- Not a fault, and the panel must not let it read as one: this pod
is idle because the pod is already sending its buttons. -->
<p class="note tone-ok">
Not needed — the pod is relaying this pod's paddle and face buttons.
</p>
{:else if pod.confirmed}
<p class="note tone-ok">Confirmed — this pod sent its own {pod.symbol} paddle.</p>
{:else if pod.state === 'connected'}
<p class="note">
@@ -168,7 +192,7 @@
<button class="btn ghost" onclick={() => disconnect(pod.pod)}>Stop searching</button>
{:else}
<button class="btn ghost" onclick={() => connect(pod.pod)}>
Look for it now
{pod.pod === 'plus' && minusLive ? 'Connect anyway' : 'Look for it now'}
</button>
{/if}
</div>
@@ -195,15 +219,14 @@
</div>
{/if}
{#if !bothConnected}
{#if !minusLive}
<!--
FR-1.8 / FR-3.10. "Not connected" on its own reads as a broken app. Both
real causes — a sleeping pod and a lapsed unlock — are things only the
rider can fix, so they are spelled out here rather than left to be
guessed at.
FR-1.8. "Not connected" on its own reads as a broken app, and the real
cause — a pod that is simply asleep — is something only the rider can fix,
so it is spelled out here rather than left to be guessed at.
-->
<details class="help" open={!anyConnected}>
<summary>A pod will not connect — what to try</summary>
<summary>The pod will not connect — what to try</summary>
<ol>
<li>
<strong>Press any button on the pod.</strong> This is almost always the whole answer.
@@ -224,6 +247,12 @@
altogether, and after about thirty failed attempts the app stops chasing it and says
so on the card.
</li>
<li>
<strong>Use the + pod instead.</strong> If the pod is flat or not with you, the app
falls back to the + pod on its own after about a minute — or press
<em>Look for it now</em> on its card. You lose the D-pad; <span class="kbd">Y</span>
still shifts down.
</li>
</ol>
<p class="fallback">
Meanwhile the keyboard mirrors every Click action — <span class="kbd">+</span>
+27 -1
View File
@@ -92,7 +92,17 @@
{@const bars = rssiBars(device.rssi)}
<article class="row" class:live={isConnected(device)}>
<div class="identity">
<span class="name">{device.name}</span>
<span class="name">
{device.name}
<!-- FR-1.5. Paired before, so it comes back on its own the next
time it advertises — this launch or any other. Worth a badge:
it is the difference between a device the rider has to fetch
and one that simply turns up, and Forget below is how they
take it back. -->
{#if device.remembered}
<span class="badge" title="Paired before — reconnects on its own">Remembered</span>
{/if}
</span>
<span class="meta">{KIND_LABEL[device.kind]} · {device.address}</span>
{#if device.error}
<span class="error">{device.error}</span>
@@ -324,6 +334,10 @@
}
.name {
display: flex;
align-items: baseline;
gap: 0.45rem;
min-width: 0;
font-size: 1.12rem;
font-weight: 600;
letter-spacing: -0.01em;
@@ -332,6 +346,18 @@
text-overflow: ellipsis;
}
.badge {
flex: none;
padding: 0.1rem 0.4rem;
border: 1px solid var(--hairline);
border-radius: 0.3rem;
color: var(--ink-dim);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.meta {
font-size: 0.8rem;
color: var(--ink-dim);