Core ride logic, FTMS client, FIT encoder and probe CLI
Adds backing state for Resistance and Erg control modes, which had no value to hold and so could never satisfy FR-4.3/FR-4.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 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 { History } from './history';
|
||||
import type {
|
||||
DeviceList,
|
||||
InputAck,
|
||||
LapSummary,
|
||||
Notice,
|
||||
RideFrame,
|
||||
RideState,
|
||||
SampleProfile,
|
||||
} from './types';
|
||||
|
||||
export type Screen = 'connect' | 'ride';
|
||||
|
||||
let toastSeq = 0;
|
||||
|
||||
class AppStore {
|
||||
screen = $state<Screen>('connect');
|
||||
frame = $state<RideFrame | null>(null);
|
||||
ride = $state<RideState | null>(null);
|
||||
devices = $state<DeviceList>({ scanning: false, devices: [] });
|
||||
samples = $state<SampleProfile[]>([]);
|
||||
toasts = $state<(Notice & { id: number })[]>([]);
|
||||
lastAck = $state<(InputAck & { at: number }) | null>(null);
|
||||
lastLap = $state<LapSummary | null>(null);
|
||||
showHelp = $state(false);
|
||||
showProfiles = $state(false);
|
||||
/** Bumped on every snapshot so charts know to redraw without deep tracking. */
|
||||
revision = $state(0);
|
||||
|
||||
/** Bounded chart history — raw power, rolling power. */
|
||||
readonly power = new History(2);
|
||||
/** Bounded chart history — commanded gradient. */
|
||||
readonly grade = new History(1);
|
||||
|
||||
private lastElapsed = -1;
|
||||
|
||||
async init(): Promise<void> {
|
||||
const [ride, devices, samples] = await Promise.all([
|
||||
api.rideState(),
|
||||
api.deviceList(),
|
||||
api.sampleProfiles(),
|
||||
]);
|
||||
this.ride = ride;
|
||||
this.devices = devices;
|
||||
this.samples = samples;
|
||||
|
||||
await subscribe({
|
||||
onFrame: (f) => this.onFrame(f),
|
||||
onRideState: (s) => {
|
||||
this.ride = s;
|
||||
},
|
||||
onDevices: (d) => {
|
||||
this.devices = d;
|
||||
},
|
||||
onLap: (l) => {
|
||||
this.lastLap = l;
|
||||
},
|
||||
onNotice: (n) => this.toast(n),
|
||||
onInputAck: (a) => {
|
||||
this.lastAck = { ...a, at: performance.now() };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private onFrame(f: RideFrame): void {
|
||||
this.frame = f;
|
||||
const t = f.snapshot.elapsed_ms / 1000;
|
||||
// The ride clock only advances while running; a paused ride should not
|
||||
// stack duplicate points onto the charts.
|
||||
if (t > this.lastElapsed) {
|
||||
this.lastElapsed = t;
|
||||
this.power.push(t, [f.snapshot.telemetry.power_w ?? 0, f.derived.rollingPowerW]);
|
||||
this.grade.push(t, [f.snapshot.gradient_pct]);
|
||||
} else if (t < this.lastElapsed) {
|
||||
this.clearHistory();
|
||||
this.lastElapsed = t;
|
||||
}
|
||||
this.revision++;
|
||||
}
|
||||
|
||||
clearHistory(): void {
|
||||
this.power.clear();
|
||||
this.grade.clear();
|
||||
this.lastElapsed = -1;
|
||||
}
|
||||
|
||||
toast(n: Notice): void {
|
||||
const entry = { ...n, id: ++toastSeq };
|
||||
this.toasts = [...this.toasts, entry];
|
||||
const ttl = n.level === 'error' ? 8000 : 4000;
|
||||
setTimeout(() => {
|
||||
this.toasts = this.toasts.filter((t) => t.id !== entry.id);
|
||||
}, ttl);
|
||||
}
|
||||
|
||||
dismiss(id: number): void {
|
||||
this.toasts = this.toasts.filter((t) => t.id !== id);
|
||||
}
|
||||
|
||||
/** Run a command and surface any rejection as a toast rather than silently. */
|
||||
async run<T>(fn: () => Promise<T>): Promise<T | undefined> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
this.toast({ level: 'error', message: String(e) });
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const app = new AppStore();
|
||||
Reference in New Issue
Block a user