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
+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';