Ride the drivetrain, command the load in watts

Speed now comes from the drivetrain and the load from the road, which is
the way round a bike actually works.

Speed is cadence x development, filtered lightly. Power, not cadence,
decides whether the rider is driving it: on a direct-drive trainer the
flywheel keeps the cranks turning after they stop, so cadence alone reads
a healthy 80 rpm for someone doing nothing. Below 15 W the speed runs
down to whatever the gradient sustains on no power - zero uphill, a real
freewheeling speed on a descent. Stopping on a 3.5% climb used to settle
at 22 km/h and stay there, because the model wanted to decelerate and a
blend toward the flywheel speed outvoted it; that blend is gone.

The D100 sends no cadence over FTMS - it is a rebadged Magene T110 with
cadence disabled in firmware (qdomyos-zwift#3282) - so it is inferred
from wheel speed, which one sprocket and no freewheel make exact. Its
Zwift channel does carry cadence, and is now greeted with RideOn and
subscribed on every notifying characteristic, so a measured value is used
where one arrives.

The load is commanded as power, not gradient. The trainer declares
50-600 W in 1 W steps against 0-6% inclination in 0.1% steps refusing
negatives, and whether it acts on 0x11 at all is still unconfirmed. Its
power target is a ceiling rather than a setpoint, which is very nearly
what a road is: exceed it and the surplus becomes speed. Gravity travels
on the same channel as watts, so nothing is lost by leaving 0x11 alone.
LoadChannel keeps the gradient path selectable and tested.

Virtual shifting reaches the trainer for the first time. The physics
load model was written but never called, and a paddle press both shifted
a gear in Rust and nudged the gradient in the webview - the shift
silently, the tilt visibly, so the paddles looked like a gradient trim.

Also: a fixed 12 W drivetrain loss, held as a power because that is how
it presents; crank length, so a gear can be reported as the force it puts
under the foot; gear and pedal force on the ride screen; a drag-race
profile for testing gearing on the flat.

Two readout bugs fixed on the way. The rolling windows were trimmed by
timestamp but fed on a fixed timer, so every second spent on the ride
screen before starting pushed samples at t=0 that could never expire -
speed read a fraction of the truth for the first 45 s. And the headline
speed was a 45 s mean, which took most of a minute to show a gear change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 18:21:08 +02:00
co-authored by Claude Opus 5
parent f2c4cb2120
commit 7b511db3dc
44 changed files with 6636 additions and 950 deletions
+459
View File
@@ -0,0 +1,459 @@
<script lang="ts">
/**
* The Zwift Click, as two pods (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.
*
* 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.
*/
import { app } from '../lib/app.svelte';
import { api, type Pod, type PodState, type PodStatus } from '../lib/bridge';
const controller = $derived(app.controller);
const pods = $derived(controller ? [controller.minus, controller.plus] : []);
/** Auto-connect rides on the device scan, so a stopped scan is a reason a
* 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'));
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. */
const mixedUp = $derived(pods.some((p) => p.contradicted));
const STATE_TEXT: Record<PodState, string> = {
idle: 'Not connected',
searching: 'Searching…',
connected: 'Connected',
reconnecting: 'Reconnecting…',
gaveUp: 'Gave up',
};
const STATE_TONE: Record<PodState, string> = {
idle: 'tone-idle',
searching: 'tone-warn',
connected: 'tone-ok',
reconnecting: 'tone-warn',
gaveUp: 'tone-bad',
};
/** 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',
};
function connect(pod: Pod) {
app.run(() => api.connectController(pod));
}
function disconnect(pod: Pod) {
app.run(() => api.disconnectController(pod));
}
</script>
<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
{:else}
No pods connected
{/if}
</span>
<div class="actions">
{#if !scanning}
<!-- 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}
<button class="btn" disabled={busy} onclick={() => app.run(() => api.connectController())}>
{busy ? 'Searching…' : 'Connect now'}
</button>
{/if}
{#if anyConnected}
<button class="btn ghost" onclick={() => app.run(() => api.disconnectController())}>
Disconnect both
</button>
{/if}
</div>
</header>
<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.
{:else}
<strong>Press any button on a missing 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>
<div class="pods">
{#each pods as pod (pod.pod)}
<article class="pod" class:live={pod.state === 'connected'}>
<div class="title">
<span class="paddle" class:on={pod.state === 'connected'}>{pod.symbol}</span>
<span class="what">
<span class="label">{pod.symbol} pod</span>
<span class="purpose">{PURPOSE[pod.pod]}</span>
</span>
<span class="state {STATE_TONE[pod.state]}">
<span class="dot"></span>{STATE_TEXT[pod.state]}
</span>
</div>
<dl class="facts">
<div>
<dt>Battery</dt>
<dd>{pod.batteryPercent != null ? `${pod.batteryPercent}%` : '—'}</dd>
</div>
<div>
<dt>Buttons seen</dt>
<!-- Connected and silent looks exactly like working until you press
something, so the count is the honest test of the link. -->
<dd>
{pod.buttonsSeen === 0 ? 'none yet' : `${pod.buttonsSeen}`}
{#if pod.lastButton}<span class="last">· {pod.lastButton}</span>{/if}
</dd>
</div>
<div>
<dt>Address</dt>
<dd class="addr">{pod.address ?? '—'}</dd>
</div>
</dl>
{#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">
Press the <strong>{pod.symbol} paddle</strong> on this pod to confirm it is the one.
</p>
{/if}
{#if pod.contradicted}
<p class="note tone-warn">
This pod sent the other paddle. If the pair is the wrong way round, swap them.
</p>
{/if}
{#if pod.error}
<!-- Verbatim (FR-9.2). Rust writes these as instructions, not codes. -->
<p class="note tone-bad">{pod.error}</p>
{/if}
<div class="controls">
{#if pod.state === 'connected' || pod.state === 'reconnecting'}
<button class="btn ghost" onclick={() => disconnect(pod.pod)}>Disconnect</button>
{:else if pod.state === 'searching'}
<button class="btn ghost" onclick={() => disconnect(pod.pod)}>Stop searching</button>
{:else}
<button class="btn ghost" onclick={() => connect(pod.pod)}>
Look for it now
</button>
{/if}
</div>
</article>
{/each}
{#if pods.length === 0}
<p class="note">Waiting for the controller supervisor…</p>
{/if}
</div>
{#if mixedUp || controller?.swapped}
<div class="swap">
<span>
{#if mixedUp}
A pod is sending the other pod's paddle — the pair may be filed the wrong way round.
{:else}
The pods are swapped from what their advertisement claims.
{/if}
</span>
<button class="btn ghost" onclick={() => app.run(() => api.swapControllerPods())}>
Swap + /
</button>
</div>
{/if}
{#if !bothConnected}
<!--
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.
-->
<details class="help" open={!anyConnected}>
<summary>A 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.
A Click sleeps within seconds and only advertises while awake, so a pod that is not
broadcasting is the normal case, not a fault. The scan below is running: press a
button and the pod connects itself, usually within a second or two.
</li>
<li>
<strong>Keep the scan on.</strong> Auto-connect works off the device scan, so a
stopped scan means nothing gets picked up. Restart it above.
</li>
<li>
<strong>Close anything else holding the pod.</strong> One app at a time — Zwift left
running in the background keeps the link, and this app will never see the pod.
</li>
<li>
<strong>Bring it closer, or charge it.</strong> A flat pod stops advertising
altogether, and after about thirty failed attempts the app stops chasing it and says
so on the card.
</li>
</ol>
<p class="fallback">
Meanwhile the keyboard mirrors every Click action — <span class="kbd">+</span>
<span class="kbd"></span> shift, <span class="kbd"></span>
<span class="kbd"></span> trim the gradient. Press
<span class="kbd">?</span> for the full list. A ride never depends on a pod.
</p>
</details>
{/if}
</section>
<style>
.click {
margin: 0 var(--edge) 0.8rem;
padding: 0.9rem 1rem 1rem;
border-radius: 0.7rem;
background: var(--bg-lift);
}
header {
display: flex;
align-items: baseline;
gap: 0.75rem;
flex-wrap: wrap;
}
h2 {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.summary {
font-size: 0.85rem;
font-weight: 600;
}
.lede {
margin: 0.55rem 0 0;
font-size: 0.86rem;
line-height: 1.5;
color: var(--ink-soft);
}
.lede strong {
color: var(--ink);
}
.actions {
display: flex;
gap: 0.4rem;
margin-left: auto;
}
.pods {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr));
gap: 0.6rem;
margin-top: 0.8rem;
}
.pod {
display: flex;
flex-direction: column;
gap: 0.55rem;
padding: 0.8rem 0.9rem;
border-radius: 0.55rem;
border: 1px solid var(--hairline);
}
.pod.live {
border-color: color-mix(in srgb, var(--ok) 35%, transparent);
}
.title {
display: flex;
align-items: center;
gap: 0.65rem;
}
/* The paddle glyph is the pod's identity — big enough to match against the
one printed on the hardware at arm's length. */
.paddle {
display: grid;
place-items: center;
width: 2.1rem;
height: 2.1rem;
border-radius: 0.45rem;
background: var(--hairline);
color: var(--ink-dim);
font-size: 1.3rem;
font-weight: 300;
line-height: 1;
flex: none;
}
.paddle.on {
background: color-mix(in srgb, var(--ok) 18%, transparent);
color: var(--ok);
}
.what {
display: flex;
flex-direction: column;
min-width: 0;
}
.label {
font-size: 1rem;
font-weight: 600;
}
.purpose {
font-size: 0.78rem;
color: var(--ink-dim);
}
.state {
display: inline-flex;
align-items: center;
gap: 0.4em;
margin-left: auto;
font-size: 0.88rem;
font-weight: 600;
white-space: nowrap;
}
.facts {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.4rem;
margin: 0;
}
.facts div {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
}
dt {
font-size: 0.72rem;
letter-spacing: 0.03em;
text-transform: uppercase;
color: var(--ink-dim);
}
dd {
margin: 0;
font-size: 0.9rem;
color: var(--ink-soft);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.last {
color: var(--ink-dim);
}
.addr {
font-size: 0.78rem;
color: var(--ink-dim);
}
.note {
margin: 0;
font-size: 0.84rem;
color: var(--ink-soft);
line-height: 1.45;
}
.note strong {
color: var(--ink);
}
.controls {
display: flex;
gap: 0.4rem;
margin-top: auto;
padding-top: 0.15rem;
}
.swap {
display: flex;
align-items: center;
gap: 0.6rem;
margin-top: 0.6rem;
padding: 0.55rem 0.75rem;
border-radius: 0.5rem;
background: rgba(255, 207, 74, 0.07);
color: var(--ink-soft);
font-size: 0.85rem;
}
.swap button {
margin-left: auto;
}
.help {
margin-top: 0.7rem;
font-size: 0.86rem;
color: var(--ink-soft);
}
summary {
cursor: pointer;
color: var(--ink-soft);
font-weight: 600;
}
.help ol {
margin: 0.5rem 0 0;
padding-left: 1.2rem;
line-height: 1.6;
}
.help li {
margin-bottom: 0.4rem;
}
.help strong {
color: var(--ink);
}
.fallback {
margin: 0.5rem 0 0;
color: var(--ink-dim);
line-height: 1.6;
}
</style>