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
+73 -20
View File
@@ -6,6 +6,7 @@
import HelpOverlay from './components/HelpOverlay.svelte';
import ProfileDrawer from './components/ProfileDrawer.svelte';
import RideScreen from './components/RideScreen.svelte';
import SummaryScreen from './components/SummaryScreen.svelte';
import Toasts from './components/Toasts.svelte';
let ready = $state(false);
@@ -38,6 +39,36 @@
app.run(fn);
};
// The summary screen has its own two keys, and swallows the ride controls:
// nudging the gradient of a ride that has ended is meaningless, and space
// would silently start a new one out from under the summary.
if (app.screen === 'summary') {
switch (e.key) {
case 's':
case 'S':
e.preventDefault();
app.saveFit();
return;
case 'n':
case 'N':
e.preventDefault();
app.newRide();
return;
case 'd':
case 'D':
app.screen = 'connect';
return;
case '?':
app.showHelp = !app.showHelp;
return;
case 'Escape':
app.showHelp = false;
return;
default:
return;
}
}
switch (e.key) {
case 'ArrowUp':
return run(() => api.nudgeGradient(step));
@@ -53,6 +84,15 @@
case 'l':
case 'L':
return run(() => api.markLap());
// Shifting is the primary control, so it gets the obvious keys and the
// paddles. `[` / `]` keep the mode's own target for the modes that have
// one — a gear is not a substitute for an ERG wattage.
case '+':
case '=':
return run(() => api.shiftGear(1));
case '-':
case '_':
return run(() => api.shiftGear(-1));
case ']':
return run(() => bumpTarget(1));
case '[':
@@ -68,7 +108,7 @@
return;
case 'r':
case 'R':
app.screen = 'ride';
app.goToRide();
return;
case '?':
app.showHelp = !app.showHelp;
@@ -90,12 +130,20 @@
}
/**
* Zwift Click input. The paddles shift "gears" and the D-pad drives the UI.
* Zwift Click input. The paddles shift the virtual gear and the D-pad drives
* the UI.
*
* Every button routes to an intent the keyboard already has, rather than to a
* second implementation — that is the whole point of doing this here instead
* of in Rust. If a shortcut changes, the controller follows it for free.
*
* The one exception is the paddles, and it is deliberate: shifting is applied
* in Rust (`spawn_controller_loop`) so it keeps working with the window
* unfocused or minimised. They are therefore absent from the switch below.
* Handling them here as well would shift twice per press — and the version of
* this file that also nudged the gradient from them is exactly why virtual
* shifting appeared not to work.
*
* Only press edges act. The Rust side already filters the pod's ~10 Hz repeat
* while a button is held, so acting on releases too would double every shift.
*/
@@ -103,12 +151,27 @@
if (!input.pressed) return;
const run = (fn: () => Promise<unknown>) => app.run(fn);
// As with the keyboard: on the summary the ride controls are inert, and the
// face buttons carry that screen's own two actions instead. Leaving `a` on
// toggle-pause here would restart the ride the rider just finished.
if (app.screen === 'summary') {
switch (input.button) {
case 'a':
app.saveFit();
return;
case 'b':
app.newRide();
return;
case 'left':
app.screen = 'connect';
return;
default:
return;
}
}
switch (input.button) {
// Paddles: a gear is ±10 W of load (or the closest thing the mode has).
case 'plus':
return run(() => shiftGear(1));
case 'minus':
return run(() => shiftGear(-1));
// 'plus' / 'minus' are handled in Rust — see above.
// D-pad: gradient on the vertical axis, screens on the horizontal.
case 'up':
@@ -119,7 +182,7 @@
app.screen = 'connect';
return;
case 'right':
app.screen = 'ride';
app.goToRide();
return;
// Face buttons mirror the existing single-key shortcuts.
@@ -135,18 +198,6 @@
}
}
/**
* One "gear" of load. Power modes move in 10 W steps; resistance mode has no
* watt unit, so it moves one level, and gradient modes fall back to the
* existing nudge so the paddles are never dead.
*/
async function shiftGear(dir: number): Promise<unknown> {
const ride = app.ride;
if (!ride) return;
if (ride.mode === 'Erg') return api.setPower(ride.powerTargetW + dir * 10);
if (ride.mode === 'Resistance') return api.setResistance(ride.resistanceLevel + dir);
return api.nudgeGradient(dir * 0.5);
}
</script>
<svelte:window on:keydown={onKey} />
@@ -159,6 +210,8 @@
</div>
{:else if !ready}
<div class="boot"><span class="label">Starting…</span></div>
{:else if app.screen === 'summary'}
<SummaryScreen />
{:else if app.screen === 'ride'}
<RideScreen />
{:else}
+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>
+35 -49
View File
@@ -12,17 +12,18 @@
import { api } from '../lib/bridge';
import { connectionText, rssiBars } from '../lib/format';
import type { DeviceInfo, DeviceKind } from '../lib/types';
import ClickPanel from './ClickPanel.svelte';
const devices = $derived(app.devices.devices);
const scanning = $derived(app.devices.scanning);
const trainerReady = $derived(
devices.some((d) => d.kind === 'trainer' && d.controlAcquired),
);
const trainerReady = $derived(app.trainerReady);
const KIND_LABEL: Record<DeviceKind, string> = {
trainer: 'Smart trainer · FTMS',
clickLeft: 'Zwift Click · left pod',
clickRight: 'Zwift Click · right pod',
// Named for the shift paddle, which is printed on the pod — unlike left and
// right, which nothing in the advertisement actually tells us (§2.3.1).
clickMinus: 'Zwift Click · pod',
clickPlus: 'Zwift Click · + pod',
heartRate: 'Heart rate monitor',
unknown: 'Unidentified',
};
@@ -54,48 +55,33 @@
{:else}
<button class="btn ghost" onclick={() => app.run(() => api.startScan())}>Scan</button>
{/if}
<button class="btn primary" onclick={() => (app.screen = 'ride')}>
{trainerReady ? 'Go to ride' : 'Ride without a trainer'}
<!-- Disabled until control is real: there is no ride without a trainer,
and offering one would be offering a session that records nothing. -->
<button
class="btn primary"
disabled={!trainerReady}
title={trainerReady ? '' : 'Connect a trainer and acquire FTMS control first'}
onclick={() => app.goToRide()}
>
Go to ride
</button>
</div>
</header>
<!--
The Zwift Click is deliberately not in the device list below: that list is
FTMS trainers, and a controller is a different kind of thing with a
different failure mode (it sleeps in seconds and must be woken by hand).
The Click gets a panel of its own above the device list rather than two more
rows in it. It is a different kind of thing with a different failure mode —
two pods that sleep in seconds and must be woken by hand — and the one thing
the old single line could not do was say *which* pod was missing (FR-1.4).
-->
<div class="gate">
<span class="dot {app.controller?.connected ? 'tone-ok' : 'tone-warn'}"></span>
<span>
{#if app.controller?.connected}
Zwift Click connected{app.controller.batteryPercent != null
? ` — battery ${app.controller.batteryPercent}%`
: ''}. Paddles shift; the D-pad drives the UI.
{:else if app.controller?.error}
Controller: {app.controller.error}
{:else}
No controller. <strong>Press a button on the Click first</strong> — it only advertises
while awake.
{/if}
</span>
{#if app.controller?.connected}
<button class="btn ghost" onclick={() => app.run(() => api.disconnectController())}>
Disconnect
</button>
{:else}
<button class="btn ghost" onclick={() => app.run(() => api.connectController())}>
Connect Click
</button>
{/if}
</div>
<ClickPanel />
{#if !trainerReady}
<div class="gate">
<span class="dot tone-warn"></span>
<span
>No trainer under control yet. The ride screen will show <strong>simulated</strong>
telemetry until an FTMS trainer accepts the control point.</span
>No trainer under control yet. The ride screen stays <strong>locked</strong> until an FTMS
trainer accepts the control point.</span
>
</div>
{/if}
@@ -138,20 +124,20 @@
<span class="dot"></span>{device.controlAcquired ? 'Acquired' : 'Not acquired'}
</span>
</span>
{:else if device.kind === 'clickLeft' || device.kind === 'clickRight'}
{:else if device.kind === 'clickMinus' || device.kind === 'clickPlus'}
<!--
Which pod, and what the panel above says about its link. The
unlock countdown that used to sit here was fiction: nothing
reports how much of the ~24 h unlock is left, so it rendered
"expired" against a pod that was working perfectly.
-->
{@const pod = device.kind === 'clickPlus' ? app.controller?.plus : app.controller?.minus}
<span class="state">
<span class="label">Zwift unlock</span>
<span
class="value"
class:tone-ok={(device.unlockExpiresInS ?? 0) > 0}
class:tone-bad={(device.unlockExpiresInS ?? 0) <= 0}
>
<span class="dot"></span>
{#if (device.unlockExpiresInS ?? 0) > 0}
{Math.round((device.unlockExpiresInS ?? 0) / 3600)} h left
{:else}
Expired — re-unlock in Zwift
{/if}
<span class="label">Click pod</span>
<span class="value" class:tone-ok={pod?.state === 'connected'} class:tone-idle={pod?.state !== 'connected'}>
<span class="dot"></span>{pod?.symbol ?? '?'} pod{pod?.state === 'connected'
? ' · linked'
: ''}
</span>
</span>
{:else if device.batteryPct != null}
+46 -12
View File
@@ -9,16 +9,17 @@
/** Kept beside the keyboard list so the two cannot drift apart on screen —
* they already share one implementation in `App.svelte`. */
const CONTROLLER: [string, string][] = [
['+ / ', 'Shift a gear: ±10 W, or one resistance level'],
['+ / ', 'Shift up / down a virtual gear'],
['D-pad ↑ / ↓', 'Gradient +0.5% / 0.5%'],
['D-pad ← / →', 'Device screen / ride screen'],
['A', 'Pause / resume'],
['B', 'Insert lap marker'],
['A', 'Pause / resume — save the FIT on the summary'],
['B', 'Insert lap marker — start a new ride on the summary'],
['Y', 'Cycle control mode'],
['Z', 'Profiles and routes'],
];
const BINDINGS: [string, string][] = [
['+ / ', 'Shift up / down a virtual gear'],
['↑ / ↓', 'Gradient +0.5% / 0.5%'],
['Shift + ↑ / ↓', 'Gradient ±2% (coarse)'],
['0', 'Reset gradient trim to zero'],
@@ -29,6 +30,8 @@
['D', 'Device / connection screen'],
['R', 'Ride screen'],
['[ / ]', 'Target down / up (resistance or ERG power)'],
['S', 'Save the FIT file (summary screen)'],
['N', 'Start a new ride (summary screen)'],
['?', 'This list'],
];
</script>
@@ -60,16 +63,31 @@
</div>
{/each}
</dl>
{#if app.controller?.connected}
<!-- Per pod, because a Click v2 is two of them and losing one loses half the
buttons — the D-pad and shift-down live on the pod. -->
<ul class="pods">
{#each [app.controller?.minus, app.controller?.plus] as pod}
{#if pod}
<li class:tone-ok={pod.state === 'connected'} class:tone-idle={pod.state !== 'connected'}>
<span class="dot"></span>
{pod.symbol} pod —
{#if pod.state === 'connected'}
connected{pod.batteryPercent != null ? `, battery ${pod.batteryPercent}%` : ''}
{:else if pod.state === 'searching'}
searching…
{:else if pod.state === 'reconnecting'}
reconnecting…
{:else}
not connected
{/if}
</li>
{/if}
{/each}
</ul>
{#if !app.controller || app.controller.minus.state !== 'connected' || app.controller.plus.state !== 'connected'}
<p class="note">
Controller connected{app.controller.batteryPercent != null
? ` — battery ${app.controller.batteryPercent}%`
: ''}.
</p>
{:else}
<p class="note">
No controller connected. A Click only advertises after a button press, so wake it and
connect from the device screen.
A Click only advertises just after a button press, so wake the missing pod and connect it
from the device screen. Every action below works from the keyboard meanwhile.
</p>
{/if}
<p class="note">Every one of these has an on-screen equivalent in the control bar.</p>
@@ -127,6 +145,22 @@
font-size: 0.92rem;
}
.pods {
display: flex;
gap: 1.2rem;
margin: 0.9rem 0 0;
padding: 0;
list-style: none;
font-size: 0.86rem;
font-weight: 600;
}
.pods li {
display: inline-flex;
align-items: center;
gap: 0.4em;
}
.note {
margin: 1.2rem 0 0;
padding-top: 0.9rem;
+96 -50
View File
@@ -35,9 +35,6 @@
* zeros without being told why, so every non-controlling state gets words.
*/
const trainerChip = $derived.by(() => {
if (ride?.source === 'mock') {
return { tone: 'tone-warn', label: 'Simulated — no trainer' };
}
const t = ride?.trainer;
if (!t) return null;
const state = t.state;
@@ -64,6 +61,71 @@
}
});
/**
* The Click pods, mid-ride (FR-1.4).
*
* Silent when both are connected — the chip row is for things that need
* attention, and two working pods do not. A pod that has dropped or was
* never connected is named individually, because they do different jobs:
* lose the `` pod and the D-pad and shift-down go with it.
*/
const podChip = $derived.by(() => {
const c = app.controller;
if (!c) return null;
const missing = [c.minus, c.plus].filter((p) => p.state !== 'connected');
if (missing.length === 0) return null;
const names = missing.map((p) => `${p.symbol} pod`).join(' and ');
const searching = missing.some((p) => p.state === 'searching' || p.state === 'reconnecting');
return {
tone: searching ? 'tone-warn' : 'tone-idle',
label: searching ? `Looking for the ${names}…` : `No ${names}`,
// The keyboard is always the fallback, which is what keeps a missing pod
// an annoyance rather than the end of the ride.
title: 'Open Devices to connect, or use the keyboard — press ? for the list',
};
});
/**
* The selected gear (FR-4.1).
*
* Read from the snapshot rather than from `ride`, so what is shown is the
* gear the engine actually rode this tick, not the intent the shell recorded.
* Development is the subtitle because "8 of 12" alone says nothing about how
* hard the pedals will be, whereas 6.5 m per crank turn does.
*/
const gear = $derived.by(() => {
const count = snap?.gear_count ?? 0;
if (!snap || count === 0) return { value: '—', sub: null, dim: true };
return {
value: `${snap.gear}`,
// Development says how far the gear carries you; pedal force says what it
// costs to turn it. The second is the one that tells you, without
// pedalling, whether the gear you just selected is rideable.
sub: `of ${count} · ${num(snap.development_m, 1)} m · ${num(snap.pedal_force_n, 0)} N`,
dim: false,
};
});
/**
* A speed the engine could not compute is not a slow ride, it is a broken
* one, and saying "0.0 km/h" without saying why would be the readout lying by
* omission. Cadence arrives on the trainer's Zwift channel, not over FTMS.
*/
const speedFault = $derived(
snap?.speed_source === 'NoCadence' ? 'No cadence from the trainer — speed unavailable' : null,
);
/**
* Cadence, with what the selected gear is asking for. The difference is the
* whole feedback the rider gets on whether they are in the right gear:
* turning well above it is spinning out, well below it is grinding.
*/
const cadenceSub = $derived.by(() => {
const target = snap?.target_cadence_rpm ?? 0;
if (!target || target < 20) return null;
return `gear wants ${num(target, 0)}`;
});
const gradient = $derived(snap?.gradient_pct ?? 0);
const gradeColour = $derived(
gradient > 0.4 ? 'var(--climb)' : gradient < -0.4 ? 'var(--route)' : 'var(--ink)',
@@ -118,9 +180,11 @@
* mismatch instead of silently absent.
*/
const speedSub = $derived.by(() => {
if (speedFault) return speedFault;
const now = `now ${num(snap?.virtual_speed_kph ?? 0, 1)}`;
const trainer = snap?.telemetry.speed_kph;
return trainer != null ? `${now} · trainer ${num(trainer, 1)}` : now;
const coasting = snap?.speed_source === 'Coasting' ? ' · coasting' : '';
return (trainer != null ? `${now} · trainer ${num(trainer, 1)}` : now) + coasting;
});
const statusChip = $derived.by(() => {
@@ -143,15 +207,6 @@
*/
const preRide = $derived(ride?.status !== 'running' && ride?.status !== 'paused');
/**
* The rider is looking at invented data. This is never inferred from a
* missing trainer — the app shows zeros for that — it is only ever true
* because someone asked for it with `BIKECONTROL_DEMO`/`BIKECONTROL_MOCK`.
* It still gets a banner, because a session you cannot tell from a real one
* is worse than no session at all.
*/
const simulated = $derived(ride?.source === 'mock');
/** Route length and climbing, said plainly, so "what is loaded" is obvious. */
const routeSummary = $derived.by(() => {
if (!profile) return null;
@@ -166,16 +221,7 @@
const openRoutes = () => (app.showProfiles = true);
</script>
<div class="ride" class:simulated>
{#if simulated}
<!-- Not a chip, not a toast: a rider must not be able to finish a session
and only then find out none of it was real. -->
<div class="sim-banner">
<strong>Simulated ride</strong>
<span>Power, speed and distance are fabricated. No trainer is being read.</span>
</div>
{/if}
<div class="ride">
<!-- Header: what is loaded, what mode, what target (FR-9.8). -->
<header>
<div class="who">
@@ -192,6 +238,16 @@
<span class="chip {trainerChip.tone}"><span class="dot"></span>{trainerChip.label}</span>
{/if}
<span class="chip {statusChip.tone}"><span class="dot"></span>{statusChip.label}</span>
<!--
Both pods, mid-ride, in one chip. A Click that has quietly dropped is
indistinguishable from a Click nobody has touched, and the first press
that does nothing is a bad moment to find out (FR-1.4, FR-9.2).
-->
{#if podChip}
<span class="chip {podChip.tone}" title={podChip.title}>
<span class="dot"></span>{podChip.label}
</span>
{/if}
<span class="chip mode">{MODE_LABEL[ride?.mode ?? 'ManualGrade']}</span>
<span class="chip target">Target {targetText(ride?.target ?? null)}</span>
<button class="btn" onclick={openRoutes}>Route <span class="kbd">P</span></button>
@@ -251,7 +307,7 @@
/>
<Readout
label="Speed"
value={num(d?.smoothedSpeedKph ?? 0, 1)}
value={num(d?.displaySpeedKph ?? 0, 1)}
unit="km/h"
size="big"
sub={speedSub}
@@ -264,6 +320,14 @@
colour={gradeColour}
sub={ride?.gradientOffsetPct ? `trim ${signed(ride.gradientOffsetPct, 1)}%` : null}
/>
<Readout
label="Gear"
value={gear.value}
size="big"
colour="var(--power)"
sub={gear.sub}
dim={gear.dim}
/>
</section>
<!-- Route detail. -->
@@ -294,7 +358,13 @@
colour="var(--power)"
sub={`now ${num(snap?.telemetry.power_w ?? 0, 0)} W`}
/>
<Readout label="Cadence" value={num(snap?.telemetry.cadence_rpm ?? 0, 0)} unit="rpm" size="mid" />
<Readout
label="Cadence"
value={num(snap?.telemetry.cadence_rpm ?? 0, 0)}
unit="rpm"
size="mid"
sub={cadenceSub}
/>
<Readout
label="Heart rate"
value={snap?.telemetry.heart_rate_bpm != null ? num(snap.telemetry.heart_rate_bpm, 0) : '—'}
@@ -368,30 +438,6 @@
flex: none;
}
.sim-banner {
display: flex;
align-items: baseline;
gap: 0.7rem;
flex-wrap: wrap;
padding: 0.55rem var(--edge);
background: var(--warn);
color: #1a1400;
font-size: 0.92rem;
}
.sim-banner strong {
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
font-size: 0.8rem;
}
/* A hairline of the same warning colour all the way round the ride, so the
state is legible from the corner of the eye at any scroll position. */
.ride.simulated {
box-shadow: inset 0 0 0 2px var(--warn);
}
header {
display: flex;
align-items: flex-start;
@@ -518,7 +564,7 @@
.primary {
display: grid;
grid-template-columns: 1.15fr 1fr 1fr 1fr;
grid-template-columns: 1.15fr 1fr 1fr 1fr 0.8fr;
gap: var(--gap);
padding: 1.1rem var(--edge) 0.9rem;
align-items: end;
+266
View File
@@ -0,0 +1,266 @@
<script lang="ts">
/**
* The ride, after the ride (FR-9.13, FR-9.14).
*
* The activity is already written by the time this appears — `stop_ride`
* saves it before emitting the summary. So the primary action here is "save a
* copy where I want it", not "save or lose it", and nothing on this screen is
* urgent. It says where the file already is, whatever the rider does next.
*/
import { app } from '../lib/app.svelte';
import { clock, km, num } from '../lib/format';
import Readout from './Readout.svelte';
const s = $derived(app.summary);
/** Time not spent riding. Only worth showing when it is not zero. */
const pausedS = $derived(s ? Math.max(0, s.durationS - s.movingS) : 0);
/**
* Anything the rider should know about the *file* rather than the ride.
* Silence here means the recording was clean, so these only ever appear when
* there is genuinely something to say.
*/
const caveats = $derived.by(() => {
if (!s) return [];
const out: string[] = [];
if (s.recoveredFromCrash) {
out.push('This ride was rebuilt from its journal after an interruption.');
}
if (s.gaps > 0) {
out.push(
`${s.gaps} telemetry dropout${s.gaps === 1 ? '' : 's'} — the trainer stopped reporting and those stretches are gaps in the file.`,
);
}
if (s.skippedLogLines > 0) {
out.push(
`${s.skippedLogLines} journal line${s.skippedLogLines === 1 ? '' : 's'} could not be read and are missing from the activity.`,
);
}
return out;
});
</script>
<div class="summary">
{#if !s}
<section class="empty">
<h1>No finished ride</h1>
<p>End a ride and its summary appears here.</p>
<button class="btn primary" onclick={() => (app.screen = 'ride')}>Back to the ride</button>
</section>
{:else}
<header>
<div class="who">
<h1>Ride complete</h1>
<span class="sub">
{s.records} sample{s.records === 1 ? '' : 's'} · {s.laps} lap{s.laps === 1 ? '' : 's'}
</span>
</div>
<span class="chip tone-ok"><span class="dot"></span>Saved</span>
</header>
<!-- FR-9.13: duration, distance, elevation, avg/max power, avg cadence. -->
<section class="primary">
<Readout label="Duration" value={clock(s.durationS)} size="hero" colour="var(--route)" />
<Readout
label="Distance"
value={km(s.distanceM, 2)}
unit="km"
size="big"
colour="var(--route)"
/>
<Readout
label="Climbing"
value={num(s.ascentM, 0)}
unit="m"
size="big"
colour="var(--climb)"
/>
</section>
<section class="detail">
<Readout
label="Avg power"
value={s.avgPowerW != null ? num(s.avgPowerW, 0) : '—'}
unit={s.avgPowerW != null ? 'W' : ''}
colour="var(--power)"
dim={s.avgPowerW == null}
/>
<Readout
label="Max power"
value={s.maxPowerW != null ? num(s.maxPowerW, 0) : '—'}
unit={s.maxPowerW != null ? 'W' : ''}
colour="var(--power)"
dim={s.maxPowerW == null}
/>
<Readout
label="Avg cadence"
value={s.avgCadenceRpm != null ? num(s.avgCadenceRpm, 0) : '—'}
unit={s.avgCadenceRpm != null ? 'rpm' : ''}
dim={s.avgCadenceRpm == null}
/>
<Readout
label="Moving"
value={clock(s.movingS)}
sub={pausedS >= 1 ? `${clock(pausedS)} paused` : null}
/>
<Readout
label="Calories"
value={s.calories != null ? num(s.calories, 0) : '—'}
unit={s.calories != null ? 'kcal' : ''}
dim={s.calories == null}
/>
</section>
{#if caveats.length > 0}
<section class="caveats">
{#each caveats as caveat (caveat)}
<p>{caveat}</p>
{/each}
</section>
{/if}
<!--
The path is stated whether or not the rider saves a copy. A summary that
only said "saved" would leave someone who closes the window with no idea
where their ride went.
-->
<section class="where">
<span class="label">Activity file</span>
<code class="path">{s.savedPath ?? s.fitPath}</code>
{#if s.savedPath}
<span class="also">Automatic copy kept at {s.fitPath}</span>
{/if}
</section>
<footer>
<button class="btn primary" disabled={app.saving} onclick={() => app.saveFit()}>
{app.saving ? 'Saving…' : s.savedPath ? 'Save another copy' : 'Save FIT…'}
<span class="kbd">S</span>
</button>
<button class="btn" onclick={() => app.newRide()}>
New ride
<span class="kbd">N</span>
</button>
<button class="btn ghost" onclick={() => (app.screen = 'connect')}>
Devices
<span class="kbd">D</span>
</button>
</footer>
{/if}
</div>
<style>
.summary {
display: flex;
flex-direction: column;
gap: var(--gap);
height: 100%;
min-height: 0;
overflow-y: auto;
padding: 1.2rem var(--edge) 1.6rem;
}
header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--gap);
}
.who {
min-width: 0;
}
h1 {
margin: 0;
font-size: clamp(1.05rem, 1.6vw, 1.5rem);
font-weight: 600;
letter-spacing: -0.015em;
}
.sub {
font-size: 0.85rem;
color: var(--ink-dim);
}
.primary {
display: flex;
flex-wrap: wrap;
gap: var(--gap) calc(var(--gap) * 2);
align-items: flex-end;
}
.detail {
display: flex;
flex-wrap: wrap;
gap: var(--gap) calc(var(--gap) * 1.6);
padding-top: var(--gap);
border-top: 1px solid var(--hairline);
}
/* Warnings about the file, not the ride. Deliberately not a toast: these
outlive the four seconds a toast gets, and they are the reason someone
would go looking at the journal. */
.caveats {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.75rem 0.9rem;
border-radius: 0.5rem;
background: var(--bg-lift);
border-left: 3px solid var(--warn);
}
.caveats p {
margin: 0;
font-size: 0.88rem;
color: var(--ink-soft);
}
.where {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 0;
}
.path {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85rem;
color: var(--ink-soft);
overflow-wrap: anywhere;
/* Selectable: the whole point of showing it is that it can be copied. */
user-select: text;
-webkit-user-select: text;
}
.also {
font-size: 0.78rem;
color: var(--ink-dim);
overflow-wrap: anywhere;
}
footer {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: auto;
padding-top: var(--gap);
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.8rem;
height: 100%;
text-align: center;
}
.empty p {
margin: 0;
color: var(--ink-dim);
}
</style>
+100 -3
View File
@@ -2,6 +2,7 @@
* Client-side view state. Everything here is either received from Rust or is
* purely presentational (which screen is showing, which toast is up).
*/
import { save } from '@tauri-apps/plugin-dialog';
import { api, subscribe, type ControllerInput, type ControllerStatus } from './bridge';
import { History } from './history';
import type {
@@ -11,10 +12,11 @@ import type {
Notice,
RideFrame,
RideState,
RideSummary,
SampleProfile,
} from './types';
export type Screen = 'connect' | 'ride';
export type Screen = 'connect' | 'ride' | 'summary';
let toastSeq = 0;
@@ -27,9 +29,14 @@ class AppStore {
toasts = $state<(Notice & { id: number })[]>([]);
lastAck = $state<(InputAck & { at: number }) | null>(null);
lastLap = $state<LapSummary | null>(null);
/** The finished ride behind the summary screen (FR-9.13). */
summary = $state<RideSummary | null>(null);
/** True while a save dialog is open, so the button cannot be double-fired. */
saving = $state(false);
showHelp = $state(false);
showProfiles = $state(false);
/** Zwift Click link, so the UI can show battery and say when it dropped. */
/** Both Click pods, tracked separately: each has its own link, its own
* battery and its own way of going missing (FR-1.4). */
controller = $state<ControllerStatus | null>(null);
/** Bumped on every snapshot so charts know to redraw without deep tracking. */
revision = $state(0);
@@ -41,26 +48,80 @@ class AppStore {
private lastElapsed = -1;
/** An FTMS trainer that is connected *and* has accepted the control point. */
get trainerReady(): boolean {
return this.devices.devices.some((d) => d.kind === 'trainer' && d.controlAcquired);
}
/**
* Enter the ride screen.
*
* There is no trainer-less ride: without a trainer under control the screen
* would show zeros and record a session that never happened. The gate lives
* here rather than on the button that opens the screen, so the keyboard and
* the Click cannot walk around it.
*
* A ride already under way is always let back in a trainer that drops
* mid-effort must not lock the rider out of their own ride.
*/
goToRide(): boolean {
const riding = this.ride?.status === 'running' || this.ride?.status === 'paused';
if (!this.trainerReady && !riding) {
this.toast({
level: 'warn',
message: 'Connect a trainer and acquire FTMS control before riding.',
});
return false;
}
this.screen = 'ride';
return true;
}
/**
* Controller input is routed by the caller, not here: `App.svelte` owns the
* keyboard map, and the Click must land on the *same* intents rather than a
* parallel set that can drift.
*/
async init(hooks: { onControllerInput?: (i: ControllerInput) => void } = {}): Promise<void> {
const [ride, devices, samples] = await Promise.all([
const [ride, devices, samples, summary, recovered, controller] = await Promise.all([
api.rideState(),
api.deviceList(),
api.sampleProfiles(),
api.rideSummary(),
api.recoveredRides(),
// Asked for rather than waited for: the status event only fires on a
// *change*, so a webview reload with both pods already connected would
// otherwise show two empty slots.
api.controllerStatus(),
]);
this.ride = ride;
this.devices = devices;
this.samples = samples;
this.summary = summary;
this.controller = controller;
// A ride already in progress (a reload, or an autostart) belongs on screen
// immediately — nobody wants to click past a device list mid-effort.
if (ride.status === 'running' || ride.status === 'paused') this.screen = 'ride';
// A reload during the summary lands back on it rather than on the device
// list, where an unsaved ride would look like no ride at all.
else if (ride.status === 'finished' && summary) this.screen = 'summary';
// FR-8.4. Reported here rather than from Rust because the setup hook runs
// before this webview is listening, and a recovered ride announced to
// nobody is the same as one silently discarded.
for (const r of recovered) {
this.toast({
level: 'warn',
message: `Recovered an interrupted ride — saved to ${r.fitPath}`,
});
}
await subscribe({
onFrame: (f) => this.onFrame(f),
onSummary: (s) => {
this.summary = s;
this.screen = 'summary';
},
onRideState: (s) => {
this.ride = s;
},
@@ -116,6 +177,42 @@ class AppStore {
this.toasts = this.toasts.filter((t) => t.id !== id);
}
/**
* Ask where to put the finished activity, and put it there (FR-9.14).
*
* The ride is already on disk before this runs, so cancelling the dialog
* costs nothing which is why this can be a plain "Save a copy" rather than
* a save the rider must not get wrong.
*/
async saveFit(): Promise<void> {
const summary = this.summary;
if (!summary || this.saving) return;
this.saving = true;
try {
const suggested = summary.fitPath.split(/[/\\]/).pop() ?? 'ride.fit';
const dest = await save({
defaultPath: suggested,
filters: [{ name: 'FIT activity', extensions: ['fit'] }],
});
if (dest === null) return; // cancelled — the automatic copy still stands
const written = await api.saveFit(dest);
// Rust confirms the path it actually wrote; trust that over `dest`.
this.summary = { ...summary, savedPath: written };
} catch (e) {
this.toast({ level: 'error', message: String(e) });
} finally {
this.saving = false;
}
}
/** Leave the summary and set up for another ride. */
async newRide(): Promise<void> {
await this.run(() => api.reset());
this.summary = null;
this.clearHistory();
this.screen = 'ride';
}
/** Run a command and surface any rejection as a toast rather than silently. */
async run<T>(fn: () => Promise<T>): Promise<T | undefined> {
try {
+57 -4
View File
@@ -14,8 +14,10 @@ import type {
LapSummary,
Notice,
ProfileView,
Recovered,
RideFrame,
RideState,
RideSummary,
RiderConfig,
SafetyLimits,
SampleProfile,
@@ -25,6 +27,7 @@ export const EVENTS = {
snapshot: 'ride://snapshot',
rideState: 'ride://state',
lap: 'ride://lap',
summary: 'ride://summary',
devices: 'devices://updated',
connection: 'devices://connection',
notice: 'app://notice',
@@ -46,19 +49,53 @@ export type ControllerButton =
| 'minus'
| 'plus';
/**
* Which Click pod, named for the shift paddle it carries.
*
* Not left and right: nothing the pod advertises says which end of the bar it
* is clamped to, so those labels would be a guess that a rider who mounts them
* the other way round makes wrong. The paddle is printed on the pod.
*/
export type Pod = 'minus' | 'plus';
/** A press or release edge from a Zwift Click. Repeats while held are already
* filtered out in Rust, so every event here is a real edge. */
export type ControllerInput = {
button: ControllerButton;
pressed: boolean;
/** Which pod sent it. Not used for routing a `+` press means the same
* thing whichever pod it came from but it proves a pod is alive. */
pod: Pod;
};
export type ControllerStatus = {
connected: boolean;
/** Where one pod's link has got to. `idle` and `gaveUp` are both "not
* connected", but they call for different things from the rider. */
export type PodState = 'idle' | 'searching' | 'connected' | 'reconnecting' | 'gaveUp';
export type PodStatus = {
pod: Pod;
/** `+` or ``, ready to render. */
symbol: string;
state: PodState;
address: string | null;
name: string | null;
batteryPercent: number | null;
error: string | null;
lastButton: ControllerButton | null;
buttonsSeen: number;
/** This pod has sent its own paddle, so the label is proven, not assumed. */
confirmed: boolean;
/** It sent the *other* pod's paddle: the pair may be filed the wrong way
* round, or this pod reports for both. */
contradicted: boolean;
};
/** A Click v2 is two peripherals, and each has its own link (FR-1.4). */
export type ControllerStatus = {
minus: PodStatus;
plus: PodStatus;
/** The +/ assignment has been flipped by the rider. */
swapped: boolean;
};
/** True when running inside the Tauri shell rather than a bare browser. */
@@ -78,9 +115,18 @@ export const api = {
stop: () => call<RideState>('stop_ride'),
reset: () => call<RideState>('reset_ride'),
// recording and export
rideSummary: () => call<RideSummary | null>('ride_summary'),
/** Copy the finished activity to `path`; resolves with the path written. */
saveFit: (path: string) => call<string>('save_fit', { path }),
/** Rides rebuilt from an interrupted session. Drains — call once at start. */
recoveredRides: () => call<Recovered[]>('recovered_rides'),
// control modes and targets
setMode: (mode: ControlMode) => call<RideState>('set_control_mode', { mode }),
cycleMode: () => call<RideState>('cycle_control_mode'),
shiftGear: (delta: number) => call<RideState>('shift_gear', { delta }),
setGear: (gear: number) => call<RideState>('set_gear', { gear }),
nudgeGradient: (deltaPct: number) => call<RideState>('nudge_gradient', { deltaPct }),
setGradient: (percent: number) => call<RideState>('set_gradient', { percent }),
resetGradient: () => call<RideState>('reset_gradient'),
@@ -113,14 +159,20 @@ export const api = {
// controller (Zwift Click)
controllerStatus: () => call<ControllerStatus>('controller_status'),
connectController: (deviceId?: string) => call<void>('connect_controller', { deviceId }),
disconnectController: () => call<void>('disconnect_controller'),
/** One pod, or both when `pod` is omitted. `deviceId` names a specific pod
* the scanner has already listed, and needs `pod` alongside it. */
connectController: (pod?: Pod, deviceId?: string) =>
call<void>('connect_controller', { pod, deviceId }),
disconnectController: (pod?: Pod) => call<void>('disconnect_controller', { pod }),
/** Exchange + and , for when the pods answer to the other name. */
swapControllerPods: () => call<void>('swap_controller_pods'),
};
type Handlers = {
onFrame?: (f: RideFrame) => void;
onRideState?: (s: RideState) => void;
onLap?: (l: LapSummary) => void;
onSummary?: (s: RideSummary) => void;
onDevices?: (d: DeviceList) => void;
onNotice?: (n: Notice) => void;
onInputAck?: (a: InputAck) => void;
@@ -138,6 +190,7 @@ export async function subscribe(h: Handlers): Promise<UnlistenFn> {
await add(EVENTS.snapshot, h.onFrame);
await add(EVENTS.rideState, h.onRideState);
await add(EVENTS.lap, h.onLap);
await add(EVENTS.summary, h.onSummary);
await add(EVENTS.devices, h.onDevices);
await add(EVENTS.notice, h.onNotice);
await add(EVENTS.inputAck, h.onInputAck);
+59 -4
View File
@@ -51,8 +51,20 @@ export interface RideSnapshot {
};
virtual_speed_kph: number;
virtual_distance_m: number;
/** The gradient of the *road*. What the trainer was asked for is `target`. */
gradient_pct: number;
elevation_gain_m: number;
/** Selected virtual gear, one-based (FR-4.1). */
gear: number;
gear_count: number;
/** Metres travelled per crank revolution in the selected gear. */
development_m: number;
/** Cadence the selected gear implies at the current speed. */
target_cadence_rpm: number;
/** Which rule set the speed. `NoCadence` is a fault, not a mode. */
speed_source: 'Drivetrain' | 'Coasting' | 'NoCadence' | 'Stopped';
/** Force at the pedal, newtons — the commanded load as the legs feel it. */
pedal_force_n: number;
mode: ControlMode;
target: ControlTarget | null;
profile_progress: number | null;
@@ -66,6 +78,11 @@ export interface RiderConfig {
drivetrain_efficiency: number;
air_density: number;
wheel_circumference_m: number;
/** Crank length (pedal circle radius), metres. Road bikes are 0.1700.175. */
crank_length_m: number;
descent_load_floor_pct: number;
/** Development of the real gear through the Zwift Cog, m per crank rev. */
physical_development_m: number;
}
export interface SafetyLimits {
@@ -93,7 +110,10 @@ export interface Derived {
axisUnit: XUnit;
axisTotal: number;
loopIndex: number | null;
/** 45 s mean, for the ETA. Too slow to show a gear change — see below. */
smoothedSpeedKph: number;
/** 3 s mean: what the speedometer shows, so a shift is visible at once. */
displaySpeedKph: number;
rollingPowerW: number;
rollingPowerWindowS: number;
avgPowerW: number;
@@ -155,6 +175,39 @@ export interface LapSummary {
avgPowerW: number;
}
// --- src-tauri/src/recording.rs ----------------------------------------------
/** A finished ride, as the summary screen shows it (FR-9.13, FR-9.14). */
export interface RideSummary {
durationS: number;
/** Excludes time spent paused. */
movingS: number;
distanceM: number;
ascentM: number;
avgPowerW: number | null;
maxPowerW: number | null;
avgCadenceRpm: number | null;
calories: number | null;
records: number;
laps: number;
/** BLE dropouts spanned (FR-8.5). */
gaps: number;
/** True when the activity was rebuilt from a journal with no end marker. */
recoveredFromCrash: boolean;
/** Journal lines that could not be parsed. Non-zero means data was lost. */
skippedLogLines: number;
/** The automatic copy, in the app's data directory. */
fitPath: string;
/** Where the rider chose to save it, once they have (FR-9.14). */
savedPath: string | null;
}
/** A ride rebuilt from an interrupted session at startup (FR-8.4). */
export interface Recovered {
fitPath: string;
summary: RideSummary;
}
/** Trainer link state, mirrored from `src-tauri/src/trainer.rs`. */
export interface TrainerStatus {
state: ConnectionState;
@@ -175,15 +228,18 @@ export interface RideState {
manualGradientPct: number;
resistanceLevel: number;
powerTargetW: number;
/** Selected virtual gear, one-based, and how many there are (FR-4.1). */
gear: number;
gearCount: number;
lap: number;
laps: LapSummary[];
profile: ProfileView | null;
/** `'ftms'` for the real trainer, `'mock'` for the synthetic rider. */
source: string;
trainer: TrainerStatus;
}
export type DeviceKind = 'trainer' | 'clickLeft' | 'clickRight' | 'heartRate' | 'unknown';
/** Click pods are named for the shift paddle each carries see `Pod` in
* `bridge.ts` for why that is not left and right. */
export type DeviceKind = 'trainer' | 'clickMinus' | 'clickPlus' | 'heartRate' | 'unknown';
export interface DeviceInfo {
id: string;
@@ -196,7 +252,6 @@ export interface DeviceInfo {
services: string[];
remembered: boolean;
batteryPct: number | null;
unlockExpiresInS: number | null;
error: string | null;
}