diff --git a/playwright.config.ts b/playwright.config.ts index a4eac35..b5c1d24 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,6 +1,7 @@ import { defineConfig, devices } from "@playwright/test"; -const port = 5179; +// Parallel worktrees each take their own dev server: PW_PORT=5183 npm run test:e2e +const port = Number(process.env.PW_PORT ?? 5179); export default defineConfig({ testDir: "tests/e2e", diff --git a/src/components/uplot.tsx b/src/components/uplot.tsx new file mode 100644 index 0000000..eac042f --- /dev/null +++ b/src/components/uplot.tsx @@ -0,0 +1,42 @@ +import { useEffect, useRef } from "react"; +import uPlot from "uplot"; +import "uplot/dist/uPlot.min.css"; + +export type ChartOptions = Omit; + +/** One uPlot filling its host; recreated when `options` changes, resized with the host. */ +export function Chart({ + options, + data, + className, +}: { + options: ChartOptions; + data: uPlot.AlignedData; + className?: string; +}) { + const host = useRef(null); + const chart = useRef(null); + + useEffect(() => { + const el = host.current; + if (el === null) return; + const blank: uPlot.AlignedData = [[], ...options.series.slice(1).map(() => [])]; + const u = new uPlot({ ...options, width: el.clientWidth, height: el.clientHeight }, blank, el); + chart.current = u; + const observer = new ResizeObserver(() => { + u.setSize({ width: el.clientWidth, height: el.clientHeight }); + }); + observer.observe(el); + return () => { + observer.disconnect(); + u.destroy(); + chart.current = null; + }; + }, [options]); + + useEffect(() => { + chart.current?.setData(data); + }, [data, options]); + + return
; +} diff --git a/src/lib/backend.ts b/src/lib/backend.ts index 0851a7a..0c19291 100644 --- a/src/lib/backend.ts +++ b/src/lib/backend.ts @@ -1,4 +1,4 @@ -import init, { OscClient, pid, requestDevice, vid } from "@openservocore/client"; +import init, { OscClient, pid, requestDevice, vid, type Track } from "@openservocore/client"; const ID_MIN = 1; const ID_MAX = 0xf9; @@ -36,7 +36,30 @@ export async function openClient(): Promise { wasmReady ??= init(); await wasmReady; const ids = parseSimIds(window.location.search); - if (ids !== undefined) return OscClient.fake(ids); + if (ids !== undefined) { + const track = await simTrack(); + return OscClient.fakeWithTracks(ids.map((id) => ({ id, track }))); + } const device = permittedAdapter(await navigator.usb.getDevices(), vid(), pid()); return OscClient.connect(device ?? (await requestDevice())); } + +// Loaded on demand: the recording is for the simulated fleet only and stays +// out of the bundle a real adapter needs. +async function simTrack(): Promise { + const { track } = (await import("../../tests/fixtures/stall-24mhz.json")).default; + return { + pos: Uint16Array.from(track.pos), + current: Int16Array.from(track.current), + currentTrough: Uint16Array.from(track.currentTrough), + dutyQ15: Int16Array.from(track.dutyQ15), + vdiff: Int16Array.from(track.vdiff), + vbus: Uint16Array.from(track.vbus), + currentRaw: Uint16Array.from(track.currentRaw), + vmotorA: Uint16Array.from(track.vmotorA), + vmotorB: Uint16Array.from(track.vmotorB), + vbusRaw: Uint16Array.from(track.vbusRaw), + ntcRaw: Uint16Array.from(track.ntcRaw), + windowValid: Uint8Array.from(track.windowValid), + }; +} diff --git a/src/lib/chart-theme.ts b/src/lib/chart-theme.ts new file mode 100644 index 0000000..8e6460f --- /dev/null +++ b/src/lib/chart-theme.ts @@ -0,0 +1,82 @@ +import { useSyncExternalStore } from "react"; + +export interface ChartTokens { + series1: string; + series2: string; + series3: string; + ctx: string; + grid: string; + axis: string; + label: string; +} + +const TOKENS: Record = { + series1: "--color-series-1", + series2: "--color-series-2", + series3: "--color-series-3", + ctx: "--color-series-ctx", + grid: "--color-grid", + axis: "--color-axis", + label: "--color-text-3", +}; + +export function readChartTokens(style: { getPropertyValue(name: string): string }): ChartTokens { + const out = {} as ChartTokens; + for (const key of Object.keys(TOKENS) as (keyof ChartTokens)[]) { + out[key] = style.getPropertyValue(TOKENS[key]).trim(); + } + return out; +} + +const empty = readChartTokens({ getPropertyValue: () => "" }); +const DARK = "(prefers-color-scheme: dark)"; +const listeners = new Set<() => void>(); +let cached = empty; +let stale = true; +let observer: MutationObserver | undefined; + +function invalidate(): void { + stale = true; + for (const listener of listeners) listener(); +} + +// The theme lands as a data-theme attribute (or the OS setting), so both are +// watched; the tokens are re-read from computed style only after a change. +function subscribe(listener: () => void): () => void { + listeners.add(listener); + if (listeners.size === 1) { + observer = new MutationObserver(invalidate); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + matchMedia(DARK).addEventListener("change", invalidate); + } + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + observer?.disconnect(); + observer = undefined; + matchMedia(DARK).removeEventListener("change", invalidate); + } + }; +} + +function snapshot(): ChartTokens { + if (stale) { + stale = false; + const next = readChartTokens(getComputedStyle(document.documentElement)); + if ( + Object.keys(TOKENS).some( + (k) => next[k as keyof ChartTokens] !== cached[k as keyof ChartTokens], + ) + ) { + cached = next; + } + } + return cached; +} + +export function useChartTokens(): ChartTokens { + return useSyncExternalStore(subscribe, snapshot, () => empty); +} diff --git a/src/lib/in-flight.test.ts b/src/lib/in-flight.test.ts new file mode 100644 index 0000000..c50a23d --- /dev/null +++ b/src/lib/in-flight.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "vitest"; +import { inFlight } from "./in-flight"; + +test("a call while one is pending is skipped, the next one after it runs", async () => { + let resolve: (v: number) => void = () => undefined; + let calls = 0; + const guarded = inFlight( + () => + new Promise((r) => { + calls++; + resolve = r; + }), + ); + const first = guarded(); + const skipped = guarded(); + expect(calls).toBe(1); + await expect(skipped).resolves.toBeUndefined(); + resolve(7); + await expect(first).resolves.toBe(7); + const third = guarded(); + expect(calls).toBe(2); + resolve(9); + await expect(third).resolves.toBe(9); +}); + +test("a rejection releases the flag", async () => { + const guarded = inFlight(() => Promise.reject(new Error("boom"))); + await expect(guarded()).rejects.toThrow("boom"); + await expect(guarded()).rejects.toThrow("boom"); +}); diff --git a/src/lib/in-flight.ts b/src/lib/in-flight.ts new file mode 100644 index 0000000..232c085 --- /dev/null +++ b/src/lib/in-flight.ts @@ -0,0 +1,19 @@ +// Until the session serializes commands itself: the adapter answers "busy" to +// a second in-flight command, so a call made while one is pending is skipped +// (resolves undefined) instead of reaching the adapter. One flag for the whole +// app, so a read the previous selection left pending still counts. +let pending = false; + +export function inFlight( + fn: (...args: A) => Promise, +): (...args: A) => Promise { + return async (...args) => { + if (pending) return undefined; + pending = true; + try { + return await fn(...args); + } finally { + pending = false; + } + }; +} diff --git a/src/lib/prefs.test.ts b/src/lib/prefs.test.ts index a3bd768..c0bedd9 100644 --- a/src/lib/prefs.test.ts +++ b/src/lib/prefs.test.ts @@ -4,9 +4,12 @@ import { PANE_KEY, readPane, readTheme, + readUnits, THEME_KEY, + UNITS_KEY, writePane, writeTheme, + writeUnits, type StorageLike, } from "./prefs"; @@ -58,3 +61,16 @@ test("applyTheme sets data-theme for light and dark and removes it for system", applyTheme(root, "system"); expect(attrs.has("data-theme")).toBe(false); }); + +test("units default to real and reject unknown values", () => { + expect(readUnits(stub())).toBe("real"); + expect(readUnits(stub({ [UNITS_KEY]: "raw" }))).toBe("raw"); + expect(readUnits(stub({ [UNITS_KEY]: "si" }))).toBe("real"); +}); + +test("writeUnits round-trips through the storage", () => { + const s = stub(); + writeUnits(s, "raw"); + expect(s.map.get(UNITS_KEY)).toBe("raw"); + expect(readUnits(s)).toBe("raw"); +}); diff --git a/src/lib/prefs.ts b/src/lib/prefs.ts index 2e60bc8..57a1190 100644 --- a/src/lib/prefs.ts +++ b/src/lib/prefs.ts @@ -1,8 +1,10 @@ export type Theme = "light" | "dark" | "system"; export type Pane = "open" | "collapsed"; +export type Units = "real" | "raw"; export const THEME_KEY = "osc-theme"; export const PANE_KEY = "osc-pane"; +export const UNITS_KEY = "osc-units"; export interface StorageLike { getItem(key: string): string | null; @@ -16,6 +18,7 @@ export interface ThemeRoot { const themes: readonly Theme[] = ["light", "dark", "system"]; const panes: readonly Pane[] = ["open", "collapsed"]; +const units: readonly Units[] = ["real", "raw"]; export function isTheme(value: string): value is Theme { return themes.some((t) => t === value); @@ -39,6 +42,19 @@ export function writePane(storage: StorageLike, pane: Pane): void { storage.setItem(PANE_KEY, pane); } +export function isUnits(value: string): value is Units { + return units.some((u) => u === value); +} + +export function readUnits(storage: StorageLike): Units { + const value = storage.getItem(UNITS_KEY); + return value !== null && isUnits(value) ? value : "real"; +} + +export function writeUnits(storage: StorageLike, value: Units): void { + storage.setItem(UNITS_KEY, value); +} + export function applyTheme(root: ThemeRoot, theme: Theme): void { if (theme === "system") root.removeAttribute("data-theme"); else root.setAttribute("data-theme", theme); diff --git a/src/lib/telemetry-poll.test.ts b/src/lib/telemetry-poll.test.ts new file mode 100644 index 0000000..be3ef05 --- /dev/null +++ b/src/lib/telemetry-poll.test.ts @@ -0,0 +1,261 @@ +import { readFileSync } from "node:fs"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { + BIAS_REGISTERS, + CONFIG_REGISTERS, + decodeSample, + decodeSpan, + READ_MAX, + registersOf, + SAMPLE_REGISTERS, + SampleRing, + spanOver, + startTelemetry, + type FieldInfo, + type Sample, + type TelemetryConfig, +} from "./telemetry-poll"; + +const descriptor = JSON.parse( + readFileSync( + new URL("../../../open-servo-core/descriptors/osc-servo/0.1.json", import.meta.url), + "utf8", + ), +) as { fields: FieldInfo[] }; +const { fields } = descriptor; + +const field = (name: string, addr: number, width: number, kind: FieldInfo["kind"]): FieldInfo => ({ + name, + addr, + width, + kind, +}); + +test("spanOver covers the named fields from the lowest address to the highest end", () => { + const span = spanOver( + [field("a", 10, 2, "uint"), field("b", 4, 4, "int"), field("c", 20, 1, "uint")], + ["a", "c"], + ); + expect(span).toMatchObject({ addr: 10, count: 11 }); + expect(span.fields.map((f) => f.name)).toEqual(["a", "c"]); +}); + +test("spanOver rejects a missing register and a span over one READ", () => { + expect(() => spanOver([field("a", 0, 2, "uint")], ["b"])).toThrow("descriptor has no b"); + expect(() => + spanOver([field("a", 0, 2, "uint"), field("z", READ_MAX, 1, "uint")], ["a", "z"]), + ).toThrow("do not fit one READ"); +}); + +test("the sample span over the 0.1 descriptor is goal_position through ntc_raw", () => { + const span = spanOver(fields, SAMPLE_REGISTERS); + expect(span.addr).toBe(392); + expect(span.count).toBe(208); +}); + +test("the config and bias spans over the 0.1 descriptor fit one READ each", () => { + expect(spanOver(fields, CONFIG_REGISTERS)).toMatchObject({ addr: 128, count: 172 }); + expect(spanOver(fields, BIAS_REGISTERS)).toMatchObject({ addr: 594, count: 8 }); +}); + +test("registersOf lists what a binder reads, in order", () => { + expect(registersOf((read) => [read("x"), read("y")])).toEqual(["x", "y"]); +}); + +test("decodeSpan reads little-endian values by width and sign", () => { + const span = spanOver( + [ + field("u8", 100, 1, "uint"), + field("i8", 101, 1, "int"), + field("u16", 102, 2, "uint"), + field("i16", 104, 2, "int"), + field("u32", 106, 4, "uint"), + field("i32", 110, 4, "int"), + ], + ["u8", "i8", "u16", "i16", "u32", "i32"], + ); + const bytes = new Uint8Array([ + 0xff, 0xff, 0x34, 0x12, 0xfe, 0xff, 0x78, 0x56, 0x34, 0x12, 0xfd, 0xff, 0xff, 0xff, + ]); + const read = decodeSpan(span, bytes); + expect(read("u8")).toBe(255); + expect(read("i8")).toBe(-1); + expect(read("u16")).toBe(0x1234); + expect(read("i16")).toBe(-2); + expect(read("u32")).toBe(0x12345678); + expect(read("i32")).toBe(-3); + expect(() => read("nope")).toThrow("span has no nope"); + expect(() => decodeSpan(span, bytes.subarray(1))).toThrow("expected 14"); +}); + +test("decodeSample scales omega_hat_cps out of Q16 and keeps the rest in counts", () => { + const span = spanOver(fields, SAMPLE_REGISTERS); + const bytes = new Uint8Array(span.count); + const view = new DataView(bytes.buffer); + const at = (name: string) => { + const f = fields.find((x) => x.name === name); + if (f === undefined) throw new Error(name); + return f.addr - span.addr; + }; + view.setInt32(at("goal_position"), 2048, true); + view.setInt32(at("omega_hat_cps"), -3 * 65536, true); + view.setUint16(at("pos"), 1234, true); + view.setUint16(at("current"), 300, true); + view.setUint16(at("vmotor_a"), 800, true); + view.setUint16(at("vmotor_b"), 700, true); + view.setUint16(at("vbus_raw"), 3600, true); + view.setUint16(at("ntc_raw"), 2000, true); + expect(decodeSample(span, bytes, 1.5)).toEqual({ + t: 1.5, + pos: 1234, + goal: 2048, + velocity: -3, + current: 300, + vbus: 3600, + vmotorA: 800, + vmotorB: 700, + ntc: 2000, + }); +}); + +const sampleAt = (t: number): Sample => ({ + t, + pos: 0, + goal: 0, + velocity: 0, + current: 0, + vbus: 0, + vmotorA: 0, + vmotorB: 0, + ntc: 0, +}); + +test("the ring keeps the window's worth of samples, oldest first", () => { + const ring = new SampleRing(30); + for (const t of [0, 10, 20, 30, 31]) ring.push(sampleAt(t)); + expect(ring.samples.map((s) => s.t)).toEqual([10, 20, 30, 31]); + expect(ring.samples).not.toBe(ring.samples); +}); + +beforeEach(() => { + vi.useFakeTimers(); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +/** A fake servo whose registers carry their own address in each byte. */ +function fakeRead(): { + calls: [number, number][]; + read: (addr: number, count: number) => Promise; + pending: Set<(bytes: Uint8Array | undefined) => void>; +} { + const calls: [number, number][] = []; + const pending = new Set<(bytes: Uint8Array | undefined) => void>(); + return { + calls, + pending, + read: (addr, count) => { + calls.push([addr, count]); + return new Promise((resolve) => { + pending.add((bytes) => { + pending.clear(); + resolve(bytes ?? Uint8Array.from({ length: count }, (_, i) => (addr + i) & 0xff)); + }); + }); + }, + }; +} + +function settle(fake: ReturnType, bytes?: Uint8Array): void { + for (const resolve of fake.pending) resolve(bytes); +} + +test("startTelemetry reads config, then biases, then samples on every tick", async () => { + const fake = fakeRead(); + const configs: TelemetryConfig[] = []; + const samples: Sample[] = []; + let now = 0; + const stop = startTelemetry({ + fields, + read: fake.read, + now: () => now, + periodMs: 100, + onConfig: (c) => configs.push(c), + onSample: (s) => samples.push(s), + onError: (e: unknown) => { + throw e; + }, + }); + expect(fake.calls).toEqual([[128, 172]]); + settle(fake); + await vi.advanceTimersByTimeAsync(100); + expect(fake.calls).toEqual([ + [128, 172], + [594, 8], + ]); + settle(fake); + await vi.advanceTimersByTimeAsync(0); + expect(configs).toHaveLength(1); + expect(configs[0]?.sense.shuntMohm).toBe(0xf3f2); + expect(configs[0]?.biases.currentBiasCounts).toBe(0x5352); + now = 7; + await vi.advanceTimersByTimeAsync(100); + expect(fake.calls.at(-1)).toEqual([392, 208]); + settle(fake); + await vi.advanceTimersByTimeAsync(0); + expect(samples).toHaveLength(1); + expect(samples[0]?.t).toBe(7); + expect(samples[0]?.pos).toBe(0x4140); + stop(); + await vi.advanceTimersByTimeAsync(500); + expect(fake.calls).toHaveLength(3); +}); + +test("a tick whose read is still pending is skipped, not queued", async () => { + const fake = fakeRead(); + startTelemetry({ + fields, + read: fake.read, + now: () => 0, + periodMs: 100, + onConfig: () => undefined, + onSample: () => undefined, + onError: () => undefined, + }); + await vi.advanceTimersByTimeAsync(350); + expect(fake.calls).toHaveLength(4); + const samples: Sample[] = []; + const skipping = startTelemetry({ + fields, + read: () => Promise.resolve(undefined), + now: () => 0, + periodMs: 100, + onConfig: () => undefined, + onSample: (s) => samples.push(s), + onError: () => undefined, + }); + await vi.advanceTimersByTimeAsync(1000); + expect(samples).toHaveLength(0); + skipping(); +}); + +test("the first error stops the poll and is reported once", async () => { + const errors: unknown[] = []; + let calls = 0; + startTelemetry({ + fields, + read: () => { + calls++; + return Promise.reject(new Error("busy")); + }, + now: () => 0, + periodMs: 100, + onConfig: () => undefined, + onSample: () => undefined, + onError: (e) => errors.push(e), + }); + await vi.advanceTimersByTimeAsync(500); + expect(calls).toBe(1); + expect(errors).toHaveLength(1); +}); diff --git a/src/lib/telemetry-poll.ts b/src/lib/telemetry-poll.ts new file mode 100644 index 0000000..759468d --- /dev/null +++ b/src/lib/telemetry-poll.ts @@ -0,0 +1,228 @@ +// The Live page's telemetry poll: the conversion registers once, then one READ +// over the sample registers per tick. Everything but `startTelemetry` is pure. + +import type { Kind } from "@openservocore/client"; +import { + biasesFromTable, + calibrationFromTable, + senseFromTable, + type Biases, + type Calibration, + type ReadRegister, + type Sense, +} from "./units"; + +export const POLL_HZ = 10; +export const WINDOW_S = 30; +/** Largest READ reply one frame carries (protocol sec 3.1). */ +export const READ_MAX = 252; +/** omega_hat_cps is csQ16, (counts/s) x 2^16 (firmware regions/telemetry.rs). */ +const Q16 = 2 ** 16; + +/** The descriptor's `Field`, narrowed to what a span needs. */ +export interface FieldInfo { + name: string; + addr: number; + width: number; + kind: Kind; +} + +/** One contiguous READ covering `fields`. */ +export interface Span { + addr: number; + count: number; + fields: FieldInfo[]; +} + +/** One poll in device counts: `velocity` is counts/s, `t` seconds. */ +export interface Sample { + t: number; + pos: number; + goal: number; + velocity: number; + current: number; + vbus: number; + vmotorA: number; + vmotorB: number; + ntc: number; +} + +export interface TelemetryConfig { + sense: Sense; + cal: Calibration; + biases: Biases; +} + +export const SAMPLE_REGISTERS: readonly string[] = [ + "goal_position", + "omega_hat_cps", + "pos", + "current", + "vmotor_a", + "vmotor_b", + "vbus_raw", + "ntc_raw", +]; + +/** The registers a units binder reads, so the list lives in one place. */ +export function registersOf(binder: (read: ReadRegister) => unknown): string[] { + const names: string[] = []; + binder((name) => { + names.push(name); + return 0; + }); + return names; +} + +export const CONFIG_REGISTERS: readonly string[] = [ + ...registersOf(senseFromTable), + ...registersOf(calibrationFromTable), +]; +export const BIAS_REGISTERS: readonly string[] = registersOf(biasesFromTable); + +export function spanOver(fields: readonly FieldInfo[], names: readonly string[]): Span { + const picked = names.map((name) => { + const field = fields.find((f) => f.name === name); + if (field === undefined) throw new Error(`descriptor has no ${name}`); + return field; + }); + const addr = Math.min(...picked.map((f) => f.addr)); + const count = Math.max(...picked.map((f) => f.addr + f.width)) - addr; + if (count > READ_MAX) throw new Error(`${count} bytes do not fit one READ`); + return { addr, count, fields: picked }; +} + +// Decodes from the span's own layout, never the descriptor object: a new +// selection frees that while a read is still in flight. +export function decodeSpan(span: Span, bytes: Uint8Array): ReadRegister { + if (bytes.length !== span.count) { + throw new Error(`read returned ${bytes.length} bytes, expected ${span.count}`); + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return (name) => { + const field = span.fields.find((f) => f.name === name); + if (field === undefined) throw new Error(`span has no ${name}`); + return decodeField(view, field.addr - span.addr, field); + }; +} + +function decodeField(view: DataView, offset: number, { name, width, kind }: FieldInfo): number { + if (kind !== "uint" && kind !== "int") throw new Error(`${name} is ${kind}, not a number`); + const signed = kind === "int"; + switch (width) { + case 1: + return signed ? view.getInt8(offset) : view.getUint8(offset); + case 2: + return signed ? view.getInt16(offset, true) : view.getUint16(offset, true); + case 4: + return signed ? view.getInt32(offset, true) : view.getUint32(offset, true); + default: + throw new Error(`${name} is ${width} bytes wide`); + } +} + +export function decodeSample(span: Span, bytes: Uint8Array, t: number): Sample { + const read = decodeSpan(span, bytes); + return { + t, + pos: read("pos"), + goal: read("goal_position"), + velocity: read("omega_hat_cps") / Q16, + current: read("current"), + vbus: read("vbus_raw"), + vmotorA: read("vmotor_a"), + vmotorB: read("vmotor_b"), + ntc: read("ntc_raw"), + }; +} + +/** The last `windowS` seconds of samples, oldest first. */ +export class SampleRing { + private buf: Sample[] = []; + + constructor(private readonly windowS: number) {} + + push(sample: Sample): void { + this.buf.push(sample); + const cutoff = sample.t - this.windowS; + let drop = 0; + for (const s of this.buf) { + if (s.t >= cutoff) break; + drop++; + } + if (drop > 0) this.buf.splice(0, drop); + } + + /** A fresh array each call, so a render can key on it. */ + get samples(): Sample[] { + return this.buf.slice(); + } +} + +export interface TelemetryOptions { + fields: readonly FieldInfo[]; + /** Resolves undefined when the read was skipped (a command was in flight). */ + read: (addr: number, count: number) => Promise; + /** Seconds. */ + now: () => number; + periodMs: number; + onConfig: (config: TelemetryConfig) => void; + onSample: (sample: Sample) => void; + onError: (error: unknown) => void; +} + +type Phase = { name: "config" } | { name: "biases"; config: ReadRegister } | { name: "samples" }; + +/** + * Ticks every `periodMs`: the sense and calibration span, then the bias span, + * then the sample span until stopped. A tick whose read is skipped retries the + * same phase next tick. The first error stops the poll. + */ +export function startTelemetry(o: TelemetryOptions): () => void { + const spans = { + config: spanOver(o.fields, CONFIG_REGISTERS), + biases: spanOver(o.fields, BIAS_REGISTERS), + samples: spanOver(o.fields, SAMPLE_REGISTERS), + }; + let phase: Phase = { name: "config" }; + let stopped = false; + + async function tick(): Promise { + const span = spans[phase.name]; + const bytes = await o.read(span.addr, span.count); + if (stopped || bytes === undefined) return; + switch (phase.name) { + case "config": + phase = { name: "biases", config: decodeSpan(span, bytes) }; + break; + case "biases": { + const { config } = phase; + o.onConfig({ + sense: senseFromTable(config), + cal: calibrationFromTable(config), + biases: biasesFromTable(decodeSpan(span, bytes)), + }); + phase = { name: "samples" }; + break; + } + case "samples": + o.onSample(decodeSample(span, bytes, o.now())); + break; + } + } + + const stop = (): void => { + stopped = true; + clearInterval(timer); + }; + const guarded = (): void => { + tick().catch((e: unknown) => { + if (stopped) return; + stop(); + o.onError(e); + }); + }; + const timer = setInterval(guarded, o.periodMs); + guarded(); + return stop; +} diff --git a/src/lib/use-pref.ts b/src/lib/use-pref.ts index daf1c90..0bb0daa 100644 --- a/src/lib/use-pref.ts +++ b/src/lib/use-pref.ts @@ -2,11 +2,14 @@ import { useSyncExternalStore } from "react"; import { readPane, readTheme, + readUnits, writePane, writeTheme, + writeUnits, type Pane, type StorageLike, type Theme, + type Units, } from "./prefs"; const listeners = new Set<() => void>(); @@ -44,3 +47,7 @@ export function useThemePref(): [Theme, (theme: Theme) => void] { export function usePanePref(): [Pane, (pane: Pane) => void] { return usePref(readPane, writePane, "open"); } + +export function useUnitsPref(): [Units, (units: Units) => void] { + return usePref(readUnits, writeUnits, "real"); +} diff --git a/src/routes/live.tsx b/src/routes/live.tsx index 8ad2749..e0010a3 100644 --- a/src/routes/live.tsx +++ b/src/routes/live.tsx @@ -1,14 +1,409 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { useSession } from "../lib/session"; +import { createFileRoute, Link } from "@tanstack/react-router"; +import { useEffect, useMemo, useState } from "react"; +import type uPlot from "uplot"; +import { Chart, type ChartOptions } from "@/components/uplot"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Switch } from "@/components/ui/switch"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useChartTokens, type ChartTokens } from "@/lib/chart-theme"; +import { inFlight } from "@/lib/in-flight"; +import { isUnits, type Units } from "@/lib/prefs"; +import { useSession } from "@/lib/session"; +import { + POLL_HZ, + SampleRing, + startTelemetry, + WINDOW_S, + type Sample, + type TelemetryConfig, +} from "@/lib/telemetry-poll"; +import { + busV, + calibrationStatus, + currentMa, + DISPLAY, + positionDeg, + temperatureC, + vdiffV, + velocityDegPerS, + type Display, +} from "@/lib/units"; +import { useUnitsPref } from "@/lib/use-pref"; export const Route = createFileRoute("/live")({ component: LivePage }); +type SeriesKey = + "position" | "goal" | "velocity" | "current" | "busVoltage" | "motorVoltage" | "temperature"; + +interface SeriesDef { + key: SeriesKey; + label: string; + token: keyof ChartTokens; + dash?: number[]; + right?: boolean; +} + +interface PanelDef { + key: string; + title: string; + series: readonly SeriesDef[]; + /** The panel leaves the stack while its series is off. */ + optional?: boolean; +} + +const DASH = [6, 4]; + +const PANELS: readonly PanelDef[] = [ + { + key: "motion", + title: "Motion", + series: [ + { key: "position", label: "Position", token: "series1" }, + { key: "goal", label: "Goal", token: "series1", dash: DASH }, + { key: "velocity", label: "Velocity", token: "series2", right: true }, + ], + }, + { + key: "electrical", + title: "Electrical", + series: [ + { key: "current", label: "Current", token: "series3" }, + { key: "busVoltage", label: "Bus V", token: "ctx", right: true }, + { key: "motorVoltage", label: "Motor V", token: "ctx", dash: DASH, right: true }, + ], + }, + { + key: "temperature", + title: "Temperature", + series: [{ key: "temperature", label: "Temperature", token: "series2" }], + optional: true, + }, +]; + +const OFF_BY_DEFAULT: readonly SeriesKey[] = ["motorVoltage", "temperature"]; +/** Raw mode shows counts for these; the rest stay in real units. */ +const RAW_FAMILY: readonly SeriesKey[] = ["position", "goal", "velocity"]; + +type Shown = Record; +type Row = Record & { t: number }; + +function initialShown(): Shown { + const shown = {} as Shown; + for (const panel of PANELS) { + for (const s of panel.series) shown[s.key] = !OFF_BY_DEFAULT.includes(s.key); + } + return shown; +} + +function convert(s: Sample, { sense, cal, biases }: TelemetryConfig, raw: boolean): Row { + return { + t: s.t, + position: raw ? s.pos : positionDeg(s.pos, cal), + goal: raw ? s.goal : positionDeg(s.goal, cal), + velocity: raw ? s.velocity : velocityDegPerS(s.velocity, cal), + current: currentMa(s.current, biases.currentBiasCounts, sense), + busVoltage: busV(s.vbus, sense), + motorVoltage: vdiffV(s.vmotorA, s.vmotorB, sense), + temperature: temperatureC(s.ntc, sense), + }; +} + +/** Rows with `t` relative to the newest sample, which sits at 0. */ +function toRows(samples: readonly Sample[], config: TelemetryConfig, raw: boolean): Row[] { + const last = samples.at(-1); + if (last === undefined) return []; + return samples.map((s) => ({ ...convert(s, config, raw), t: s.t - last.t })); +} + +function display(key: SeriesKey, raw: boolean): Display { + if (!raw || !RAW_FAMILY.includes(key)) return DISPLAY[key]; + if (key === "velocity") return { unit: `${DISPLAY.raw.unit}/s`, digits: DISPLAY.raw.digits }; + return DISPLAY.raw; +} + +function format(value: number | undefined, d: Display): string { + return value === undefined || !Number.isFinite(value) + ? "-" + : `${value.toFixed(d.digits)} ${d.unit}`; +} + +function panelTitle(panel: PanelDef, raw: boolean): string { + const unitOf = (right: boolean) => { + const s = panel.series.find((x) => (x.right ?? false) === right); + return s === undefined ? undefined : display(s.key, raw).unit; + }; + const right = unitOf(true); + return `${panel.title} ${unitOf(false) ?? ""}${right === undefined ? "" : ` | ${right}`}`; +} + +function panelOptions(panel: PanelDef, shown: Shown, tokens: ChartTokens): ChartOptions { + const axis = { + stroke: tokens.label, + grid: { stroke: tokens.grid, width: 1 }, + ticks: { stroke: tokens.axis, width: 1 }, + }; + // Fixed so the panels' x axes line up whatever their y labels are wide. + const size = 60; + const right = panel.series.some((s) => s.right); + return { + legend: { show: false }, + cursor: { drag: { x: false, y: false } }, + scales: { x: { time: false, range: [-WINDOW_S, 0] }, y: {}, ...(right ? { r: {} } : {}) }, + axes: [ + axis, + { ...axis, scale: "y", size }, + ...(right ? [{ ...axis, scale: "r", side: 1, size, grid: { show: false } }] : []), + ], + series: [ + {}, + ...panel.series.map((s) => ({ + label: s.label, + scale: s.right ? "r" : "y", + stroke: tokens[s.token], + width: 1.5, + dash: s.dash, + show: shown[s.key], + points: { show: false }, + })), + ], + }; +} + function LivePage() { - const { status } = useSession(); + const { status, selected } = useSession(); return ( <>

Live

- {status !== "ready" &&

Connect first.

} + {status !== "ready" ? ( +

Connect first.

+ ) : selected === undefined ? ( +

Pick a servo in the left pane.

+ ) : ( + + + Telemetry + Stream + + + + + +

Stream capture comes later.

+
+
+ )} ); } + +function Telemetry({ id }: { id: number }) { + const { client, descriptor, descriptorError } = useSession(); + const [config, setConfig] = useState(); + const [samples, setSamples] = useState([]); + const [error, setError] = useState(); + const [shown, setShown] = useState(initialShown); + const [unitsPref, setUnitsPref] = useUnitsPref(); + const tokens = useChartTokens(); + + useEffect(() => { + if (client === undefined || descriptor === undefined) return; + const ring = new SampleRing(WINDOW_S); + return startTelemetry({ + fields: descriptor.fields(), + // Swap for the session's command queue once it lands: read through it, + // drop inFlight. + read: inFlight((addr, count) => client.read(id, addr, count)), + now: () => performance.now() / 1000, + periodMs: 1000 / POLL_HZ, + onConfig: setConfig, + onSample: (s) => { + ring.push(s); + setSamples(ring.samples); + }, + onError: (e: unknown) => { + setError(e instanceof Error ? e.message : String(e)); + }, + }); + }, [client, descriptor, id]); + + const calibrated = config !== undefined && calibrationStatus(config.cal).valid; + const raw = !calibrated || unitsPref === "raw"; + const rows = useMemo( + () => (config === undefined ? [] : toRows(samples, config, raw)), + [samples, config, raw], + ); + const latest = rows.at(-1); + const problem = error ?? descriptorError; + + return ( +
+ + + Telemetry + + {POLL_HZ} Hz, last {WINDOW_S} s + + + + {problem !== undefined &&

{problem}

} +
+ {PANELS.filter((p) => !p.optional || p.series.some((s) => shown[s.key])).map( + (panel) => ( + + ), + )} + {rows.length === 0 && ( +

+ waiting for data +

+ )} +
+
+
+ +
+ ); +} + +function Panel({ + panel, + rows, + shown, + tokens, + raw, +}: { + panel: PanelDef; + rows: readonly Row[]; + shown: Shown; + tokens: ChartTokens; + raw: boolean; +}) { + const options = useMemo(() => panelOptions(panel, shown, tokens), [panel, shown, tokens]); + const data = useMemo( + () => [ + rows.map((r) => r.t), + ...panel.series.map((s) => rows.map((r) => (Number.isFinite(r[s.key]) ? r[s.key] : null))), + ], + [rows, panel], + ); + const heading = `panel-${panel.key}`; + return ( +
+

+ {panelTitle(panel, raw)} +

+ +
+ ); +} + +function UnitsSegment({ + value, + disabled, + onChange, +}: { + value: Units; + disabled: boolean; + onChange: (units: Units) => void; +}) { + const segment = ( + { + if (isUnits(v)) onChange(v); + }} + > + + + Real + + + Raw + + + + ); + if (!disabled) return segment; + return ( + + +
{segment}
+
+ Calibrate the servo first +
+ ); +} + +function SeriesRow({ + series, + checked, + color, + readout, + onChange, +}: { + series: SeriesDef; + checked: boolean; + color: string; + readout: string; + onChange: (checked: boolean) => void; +}) { + return ( + + ); +} diff --git a/tests/e2e/live.spec.ts b/tests/e2e/live.spec.ts new file mode 100644 index 0000000..d0a0f6f --- /dev/null +++ b/tests/e2e/live.spec.ts @@ -0,0 +1,37 @@ +import { expect, test, type Page } from "@playwright/test"; +import { gotoSim } from "./helpers"; + +const READOUT = /^-?\d+(\.\d+)? \S+$/; + +async function openLive(page: Page): Promise { + await gotoSim(page, [1, 2]); + await page.getByRole("button", { name: "ID 1" }).click(); + await page.getByRole("link", { name: "Live" }).click(); + // A cold dev server compiles the Live route chunk on this first request. + await expect(page.getByRole("region", { name: /^Motion/ })).toBeVisible({ timeout: 15_000 }); +} + +test("the position readout is a number with a unit and moves with the replayed track", async ({ + page, +}) => { + await openLive(page); + const position = page.getByLabel("Position value"); + await expect(position).toHaveText(READOUT); + // The recorded pot sits at its rail most of the loop, so the second reading + // is whichever comes first that differs, not one a fixed delay later. + const first = (await position.textContent()) ?? ""; + await expect(position).not.toHaveText(first, { timeout: 5000 }); + await expect(position).toHaveText(READOUT); +}); + +test("the current readout is in milliamps", async ({ page }) => { + await openLive(page); + await expect(page.getByLabel("Current value")).toHaveText(/ mA$/); +}); + +test("the temperature panel is absent until its series is switched on", async ({ page }) => { + await openLive(page); + await expect(page.getByRole("region", { name: /^Temperature/ })).toHaveCount(0); + await page.getByRole("switch", { name: "Temperature" }).click(); + await expect(page.getByRole("region", { name: /^Temperature/ })).toBeVisible(); +});