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:
@@ -1,24 +1,31 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The Zwift Click, as two pods (FR-1.4, FR-9.1–9.2).
|
||||
* The Zwift Click (FR-1.4, FR-9.1–9.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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user