Virtual gearing, trainer-speed blend, and cadence decode

Gears are expressed as an offset to the commanded gradient, leaving the
physics on the route's true gradient so shifting changes effort, not speed.
Neutral gear commands exactly the route gradient, so an un-shifted ride is
unchanged.

Cadence is not in FTMS on this trainer but is on its Zwift channel, decoded
against captured frames. The undeclared FTMS trailing bytes were ruled out:
wheel RPM restated at a fixed 73.8x speed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 15:33:28 +02:00
co-authored by Claude Opus 5
parent 3a2a787b7d
commit 57eb5e809b
48 changed files with 57737 additions and 431 deletions
+61 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { app } from './lib/app.svelte';
import { api, inTauri } from './lib/bridge';
import { api, inTauri, type ControllerInput } from './lib/bridge';
import ConnectionScreen from './components/ConnectionScreen.svelte';
import HelpOverlay from './components/HelpOverlay.svelte';
import ProfileDrawer from './components/ProfileDrawer.svelte';
@@ -17,7 +17,7 @@
return;
}
try {
await app.init();
await app.init({ onControllerInput });
ready = true;
} catch (e) {
fatal = String(e);
@@ -88,6 +88,65 @@
if (ride.mode === 'Resistance') return api.setResistance(ride.resistanceLevel + dir * 2);
return api.nudgeGradient(dir * 0.5);
}
/**
* Zwift Click input. The paddles shift "gears" 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.
*
* 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.
*/
function onControllerInput(input: ControllerInput) {
if (!input.pressed) return;
const run = (fn: () => Promise<unknown>) => app.run(fn);
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));
// D-pad: gradient on the vertical axis, screens on the horizontal.
case 'up':
return run(() => api.nudgeGradient(0.5));
case 'down':
return run(() => api.nudgeGradient(-0.5));
case 'left':
app.screen = 'connect';
return;
case 'right':
app.screen = 'ride';
return;
// Face buttons mirror the existing single-key shortcuts.
case 'a':
return run(() => api.togglePause());
case 'b':
return run(() => api.markLap());
case 'y':
return run(() => api.cycleMode());
case 'z':
app.showProfiles = !app.showProfiles;
return;
}
}
/**
* 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} />
+30
View File
@@ -60,6 +60,36 @@
</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).
-->
<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>
{#if !trainerReady}
<div class="gate">
<span class="dot tone-warn"></span>
+34 -21
View File
@@ -10,6 +10,13 @@
const ride = $derived(app.ride);
const running = $derived(ride?.status === 'running');
/**
* Before a ride starts, the gradient trim, the lap marker and End have
* nothing to act on. The ride screen is showing a large Start button at that
* point, and nine similar-looking buttons beside it only make it harder to
* find.
*/
const started = $derived(ride?.status === 'running' || ride?.status === 'paused');
let flash = $state<string | null>(null);
let flashTimer: ReturnType<typeof setTimeout> | null = null;
@@ -26,35 +33,39 @@
</script>
<div class="bar">
<div class="group">
<button class="btn" class:lit={flash === 'gradient'} onclick={() => grade(-0.5)}>
<span class="glyph"></span> Grade <span class="kbd"></span>
</button>
<button class="btn" class:lit={flash === 'gradient'} onclick={() => grade(0.5)}>
<span class="glyph">+</span> Grade <span class="kbd"></span>
</button>
<button
class="btn ghost"
class:lit={flash === 'gradient-reset'}
onclick={() => app.run(() => api.resetGradient())}
>
Zero <span class="kbd">0</span>
</button>
</div>
{#if started}
<div class="group">
<button class="btn" class:lit={flash === 'gradient'} onclick={() => grade(-0.5)}>
<span class="glyph"></span> Grade <span class="kbd"></span>
</button>
<button class="btn" class:lit={flash === 'gradient'} onclick={() => grade(0.5)}>
<span class="glyph">+</span> Grade <span class="kbd"></span>
</button>
<button
class="btn ghost"
class:lit={flash === 'gradient-reset'}
onclick={() => app.run(() => api.resetGradient())}
>
Zero <span class="kbd">0</span>
</button>
</div>
{/if}
<div class="group">
<button class="btn" class:lit={flash === 'mode'} onclick={() => app.run(() => api.cycleMode())}>
Mode: {MODE_LABEL[ride?.mode ?? 'ManualGrade']} <span class="kbd">M</span>
</button>
<button class="btn ghost" onclick={() => (app.showProfiles = true)}>
Profile <span class="kbd">P</span>
<button class="btn" onclick={() => (app.showProfiles = true)}>
Route <span class="kbd">P</span>
</button>
</div>
<div class="group right">
<button class="btn" class:lit={flash === 'lap'} onclick={() => app.run(() => api.markLap())}>
Lap {ride?.lap ?? 1} <span class="kbd">L</span>
</button>
{#if started}
<button class="btn" class:lit={flash === 'lap'} onclick={() => app.run(() => api.markLap())}>
Lap {ride?.lap ?? 1} <span class="kbd">L</span>
</button>
{/if}
{#if running}
<button class="btn" class:lit={flash === 'toggle-pause'} onclick={() => app.run(() => api.togglePause())}>
Pause <span class="kbd"></span>
@@ -64,7 +75,9 @@
{ride?.status === 'paused' ? 'Resume' : 'Start ride'} <span class="kbd"></span>
</button>
{/if}
<button class="btn danger" onclick={() => app.run(() => api.stop())}>End</button>
{#if started}
<button class="btn danger" onclick={() => app.run(() => api.stop())}>End</button>
{/if}
<button class="btn ghost" onclick={() => (app.showHelp = !app.showHelp)} title="Keyboard shortcuts">
<span class="kbd">?</span>
</button>
+37 -7
View File
@@ -1,11 +1,23 @@
<script lang="ts">
/**
* Keyboard shortcuts (FR-3.19). These exist because there is no physical
* controller in this phase — and because if a Click pod dies mid-ride, the
* keyboard is the only way to keep the session going.
* Keyboard shortcuts (FR-3.19), and the Zwift Click buttons that mirror them.
* The keyboard remains the fallback: if a Click pod dies or its battery goes
* mid-ride, it is the only way to keep the session going.
*/
import { app } from '../lib/app.svelte';
/** 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'],
['D-pad ↑ / ↓', 'Gradient +0.5% / 0.5%'],
['D-pad ← / →', 'Device screen / ride screen'],
['A', 'Pause / resume'],
['B', 'Insert lap marker'],
['Y', 'Cycle control mode'],
['Z', 'Profiles and routes'],
];
const BINDINGS: [string, string][] = [
['↑ / ↓', 'Gradient +0.5% / 0.5%'],
['Shift + ↑ / ↓', 'Gradient ±2% (coarse)'],
@@ -39,10 +51,28 @@
</div>
{/each}
</dl>
<p class="note">
Every one of these has an on-screen equivalent in the control bar, and each will map to a
Zwift Click button once the controller client lands.
</p>
<h2>Zwift Click</h2>
<dl>
{#each CONTROLLER as [key, what]}
<div>
<dt><span class="kbd">{key}</span></dt>
<dd>{what}</dd>
</div>
{/each}
</dl>
{#if app.controller?.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.
</p>
{/if}
<p class="note">Every one of these has an on-screen equivalent in the control bar.</p>
</div>
<style>
+251 -12
View File
@@ -30,6 +30,40 @@
const ride = $derived(app.ride);
const profile = $derived(ride?.profile ?? null);
/**
* The trainer chip (FR-1.8, FR-9.3). The rider must never be left reading
* 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;
if (typeof state === 'object' && 'Lost' in state) {
return { tone: 'tone-bad', label: `Trainer unavailable — ${state.Lost.reason}` };
}
switch (state) {
case 'Idle':
return { tone: 'tone-warn', label: 'No trainer — open Devices to connect' };
case 'Scanning':
return { tone: 'tone-warn', label: 'Scanning for the trainer…' };
case 'Connecting':
return { tone: 'tone-warn', label: 'Connecting to the trainer…' };
case 'Reconnecting':
return { tone: 'tone-warn', label: 'Trainer lost — reconnecting' };
case 'Connected':
return { tone: 'tone-warn', label: 'Connected, control not acquired' };
case 'Controlling':
if (t.stale) return { tone: 'tone-warn', label: 'Trainer silent — pedal to wake it' };
if (t.error) return { tone: 'tone-bad', label: t.error };
return null;
default:
return null;
}
});
const gradient = $derived(snap?.gradient_pct ?? 0);
const gradeColour = $derived(
gradient > 0.4 ? 'var(--climb)' : gradient < -0.4 ? 'var(--route)' : 'var(--ink)',
@@ -77,6 +111,18 @@
return d.distanceRemainingM;
});
/**
* FR-7.5 — the trainer's own speed reading is diagnostic, so it sits beside
* the virtual speed rather than replacing it. A trainer that reports nothing
* (or, as on the D100, in units we have not confirmed) is then visible as a
* mismatch instead of silently absent.
*/
const speedSub = $derived.by(() => {
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 statusChip = $derived.by(() => {
switch (ride?.status) {
case 'running':
@@ -89,27 +135,66 @@
return { label: 'Ready', tone: 'tone-idle' };
}
});
/**
* A ride that has not started yet. Before the first pedal stroke almost
* everything on this screen reads zero, so the screen's job is not to show
* numbers — it is to show the one action that matters.
*/
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;
const bits: string[] = [];
if (profile.totalMetres) bits.push(`${(profile.totalMetres / 1000).toFixed(1)} km`);
if (profile.totalSeconds) bits.push(`${Math.round(profile.totalSeconds / 60)} min`);
if (profile.totalAscentM != null) bits.push(`${profile.totalAscentM.toFixed(0)} m up`);
if (profile.looping) bits.push('loops');
return bits.join(' · ');
});
const openRoutes = () => (app.showProfiles = true);
</script>
<div class="ride">
<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}
<!-- Header: what is loaded, what mode, what target (FR-9.8). -->
<header>
<div class="who">
<h1>{profile?.name ?? 'No route'}</h1>
{#if profile?.description}
<p>{profile.description}</p>
<h1>{profile?.name ?? 'No route loaded'}</h1>
{#if routeSummary}
<p>{routeSummary}{profile?.description ? ` — ${profile.description}` : ''}</p>
{:else}
<p>Manual control — load a profile to ride terrain.</p>
<p>Manual control — choose a route to ride real terrain.</p>
{/if}
</div>
<div class="chips">
{#if ride?.source === 'mock'}
<span class="chip tone-warn"><span class="dot"></span>Simulated — no trainer</span>
{#if trainerChip}
<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>
<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>
<button class="btn ghost" onclick={() => (app.screen = 'connect')}>Devices</button>
</div>
</header>
@@ -119,6 +204,33 @@
<RouteChart {profile} positionX={d?.positionX ?? 0} revision={app.revision} />
</section>
{#if preRide}
<!-- The whole screen before a ride begins: one obvious action. -->
<section class="launch">
{#if profile}
<button class="start" onclick={() => app.run(() => api.start())}>
{ride?.status === 'finished' ? 'Ride again' : 'Start ride'}
<span class="kbd"></span>
</button>
<div class="launch-aside">
<span class="launch-route">{profile.name}</span>
<span class="launch-sub">{routeSummary}</span>
<button class="btn ghost" onclick={openRoutes}>Choose a different route</button>
</div>
{:else}
<button class="start" onclick={openRoutes}>Choose a route</button>
<div class="launch-aside">
<span class="launch-sub">
Pick a bundled route or open your own GPX. Or start now and ride on manual gradient.
</span>
<button class="btn ghost" onclick={() => app.run(() => api.start())}>
Start without a route
</button>
</div>
{/if}
</section>
{/if}
<!-- Primary readouts. -->
<section class="primary">
<Readout
@@ -142,7 +254,7 @@
value={num(d?.smoothedSpeedKph ?? 0, 1)}
unit="km/h"
size="big"
sub={`now ${num(snap?.virtual_speed_kph ?? 0, 1)}`}
sub={speedSub}
/>
<Readout
label="Gradient"
@@ -197,6 +309,8 @@
size="small"
/>
<Readout label="Work" value={num(d?.energyKj ?? 0, 0)} unit="kJ" size="small" />
<!-- An estimate, not a measurement — see `bikecontrol_core::energy`. -->
<Readout label="Burned" value={num(d?.caloriesKcal ?? 0, 0)} unit="kcal" size="small" />
<div class="spacer"></div>
<div class="charts">
<div class="chart">
@@ -226,11 +340,56 @@
</div>
<style>
/*
* A column, not a fixed grid.
*
* This used to be `grid-template-rows` with pixel minimums that together
* exceeded a short window. When they did, the tracks overflowed and the
* chart section was painted straight over the control bar — and because
* uPlot positions its canvas and its `.u-over` overlay, both of which are
* `position: relative/absolute`, they painted *above* the unpositioned
* buttons and swallowed every click on them. The Start ride button was
* visible, looked enabled, and did nothing.
*
* Flex items cannot overlap, the flexible sections absorb the slack, and
* `overflow: hidden` on the chart area means a canvas that has not yet been
* resized cannot escape it either. The control bar keeps its own stacking
* context as a final guarantee.
*/
.ride {
display: grid;
grid-template-rows: auto minmax(150px, 1fr) auto auto minmax(190px, 0.95fr) auto;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
.ride > :global(*) {
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 {
@@ -283,8 +442,78 @@
}
.route {
min-height: 0;
flex: 2 1 0;
min-height: 130px;
padding: 0 var(--edge);
overflow: hidden;
}
/* ---- pre-ride launch panel ---------------------------------------- */
.launch {
display: flex;
align-items: center;
gap: var(--gap);
flex-wrap: wrap;
padding: 1.1rem var(--edge);
margin: 0.9rem var(--edge) 0.2rem;
border-radius: 0.7rem;
background: var(--bg-lift);
}
/* The single most important control on the screen before a ride, and it must
look like it. */
.start {
display: inline-flex;
align-items: center;
gap: 0.7em;
padding: 0.85em 2em;
border-radius: 0.6rem;
background: var(--route);
color: #04121a;
font-size: clamp(1.1rem, 1.5vw, 1.45rem);
font-weight: 700;
letter-spacing: -0.01em;
transition:
background 120ms ease,
transform 90ms ease;
}
.start:hover {
background: #6cdcff;
}
.start:active {
transform: translateY(1px);
}
.start :global(.kbd) {
background: rgba(0, 0, 0, 0.2);
color: #04121a;
}
.launch-aside {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.2rem;
min-width: 0;
}
.launch-route {
font-size: 1.05rem;
font-weight: 600;
}
.launch-sub {
font-size: 0.88rem;
color: var(--ink-dim);
max-width: 34rem;
}
.launch-aside .btn {
margin-top: 0.3rem;
padding-left: 0;
}
.primary {
@@ -304,13 +533,16 @@
}
.effort {
flex: 1 1 0;
display: grid;
grid-template-columns: repeat(6, minmax(0, auto)) 1fr;
grid-template-columns: repeat(7, minmax(0, auto)) 1fr;
grid-template-rows: auto minmax(0, 1fr);
align-items: start;
gap: var(--gap);
padding: 0.9rem var(--edge) 0.4rem;
min-height: 0;
/* Nothing inside may be painted outside: see the note on `.ride`. */
overflow: hidden;
}
.spacer {
@@ -319,10 +551,16 @@
.charts {
grid-column: 1 / -1;
/* `align-items: start` above would otherwise leave this at its content
height — which is whatever size uPlot happened to pick — instead of the
height the track actually has. That is what let the canvas grow past the
bottom of the section. */
align-self: stretch;
display: grid;
grid-template-columns: 1.6fr 1fr;
gap: var(--gap);
min-height: 0;
overflow: hidden;
}
.chart {
@@ -330,6 +568,7 @@
grid-template-rows: auto 1fr;
gap: 0.15rem;
min-height: 0;
overflow: hidden;
}
@media (max-width: 1150px) {
+13 -2
View File
@@ -2,7 +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 { api, subscribe } from './bridge';
import { api, subscribe, type ControllerInput, type ControllerStatus } from './bridge';
import { History } from './history';
import type {
DeviceList,
@@ -29,6 +29,8 @@ class AppStore {
lastLap = $state<LapSummary | null>(null);
showHelp = $state(false);
showProfiles = $state(false);
/** Zwift Click link, so the UI can show battery and say when it dropped. */
controller = $state<ControllerStatus | null>(null);
/** Bumped on every snapshot so charts know to redraw without deep tracking. */
revision = $state(0);
@@ -39,7 +41,12 @@ class AppStore {
private lastElapsed = -1;
async init(): Promise<void> {
/**
* 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([
api.rideState(),
api.deviceList(),
@@ -67,6 +74,10 @@ class AppStore {
onInputAck: (a) => {
this.lastAck = { ...a, at: performance.now() };
},
onControllerInput: hooks.onControllerInput,
onControllerStatus: (s) => {
this.controller = s;
},
});
}
+39
View File
@@ -29,8 +29,38 @@ export const EVENTS = {
connection: 'devices://connection',
notice: 'app://notice',
inputAck: 'app://input-ack',
controllerInput: 'controller://input',
controllerStatus: 'controller://status',
} as const;
/** Button names as sent by `controller::button_name`. */
export type ControllerButton =
| 'left'
| 'up'
| 'right'
| 'down'
| 'a'
| 'b'
| 'y'
| 'z'
| '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;
};
export type ControllerStatus = {
connected: boolean;
address: string | null;
name: string | null;
batteryPercent: number | null;
error: string | null;
};
/** True when running inside the Tauri shell rather than a bare browser. */
export const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
@@ -80,6 +110,11 @@ export const api = {
disconnect: (deviceId: string) => call<DeviceInfo>('disconnect_device', { deviceId }),
forget: (deviceId: string) => call<void>('forget_device', { deviceId }),
trainerControllable: () => call<boolean>('trainer_controllable'),
// controller (Zwift Click)
controllerStatus: () => call<ControllerStatus>('controller_status'),
connectController: (deviceId?: string) => call<void>('connect_controller', { deviceId }),
disconnectController: () => call<void>('disconnect_controller'),
};
type Handlers = {
@@ -89,6 +124,8 @@ type Handlers = {
onDevices?: (d: DeviceList) => void;
onNotice?: (n: Notice) => void;
onInputAck?: (a: InputAck) => void;
onControllerInput?: (i: ControllerInput) => void;
onControllerStatus?: (s: ControllerStatus) => void;
};
/** Subscribe to the whole event channel. Returns a single unsubscribe. */
@@ -104,5 +141,7 @@ export async function subscribe(h: Handlers): Promise<UnlistenFn> {
await add(EVENTS.devices, h.onDevices);
await add(EVENTS.notice, h.onNotice);
await add(EVENTS.inputAck, h.onInputAck);
await add(EVENTS.controllerInput, h.onControllerInput);
await add(EVENTS.controllerStatus, h.onControllerStatus);
return () => offs.forEach((off) => off());
}
+16
View File
@@ -101,6 +101,8 @@ export interface Derived {
normalisedPowerW: number | null;
avgCadenceRpm: number;
energyKj: number;
/** Estimated rider energy expenditure, kcal — not the same as `energyKj`. */
caloriesKcal: number;
}
/** What arrives on `ride://snapshot`. */
@@ -153,6 +155,18 @@ export interface LapSummary {
avgPowerW: number;
}
/** Trainer link state, mirrored from `src-tauri/src/trainer.rs`. */
export interface TrainerStatus {
state: ConnectionState;
/** FTMS control point acquired. Connected is NOT controllable (FR-9.3). */
controlAcquired: boolean;
address: string | null;
name: string | null;
error: string | null;
/** Connected, but no Indoor Bike Data for several seconds (FR-1.8). */
stale: boolean;
}
export interface RideState {
status: RideStatus;
mode: ControlMode;
@@ -164,7 +178,9 @@ export interface RideState {
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';