diff --git a/src/components/stream-tab.tsx b/src/components/stream-tab.tsx new file mode 100644 index 0000000..180a78e --- /dev/null +++ b/src/components/stream-tab.tsx @@ -0,0 +1,383 @@ +import { CircleQuestionMark, Download, Zap } from "lucide-react"; +import { useEffect, useId, useMemo, useState } from "react"; +import type uPlot from "uplot"; +import { Chart, type ChartOptions } from "@/components/uplot"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { useChartTokens, type ChartTokens } from "@/lib/chart-theme"; +import { hex16 } from "@/lib/format"; +import { useSession } from "@/lib/session"; +import { + decodeBurst, + DEFAULT_FIELDS, + familyOf, + FIELDS, + maskIssue, + maskOf, + summarize, + summaryText, + toCsv, + unitsFor, + type FieldKey, + type Row, + type Summary, + type Unit, +} from "@/lib/stream"; +import { + BIAS_REGISTERS, + CONFIG_REGISTERS, + decodeSpan, + spanOver, + type TelemetryConfig, +} from "@/lib/telemetry-poll"; +import { + biasesFromTable, + calibrationFromTable, + calibrationStatus, + senseFromTable, +} from "@/lib/units"; +import { useUnitsPref } from "@/lib/use-pref"; + +const DEFAULT_COUNT = 120; +const DEFAULT_WINDOW_MS = 500; +/** tel_count is a u16 on the wire. */ +const COUNT_MAX = 65535; +/** The burst window is a u32 of microseconds. */ +const WINDOW_MAX_MS = 4_294_967; +const DASH = [6, 4]; +const COLORS: readonly (keyof ChartTokens)[] = ["series1", "series2", "series3", "ctx"]; + +interface StreamConfig extends TelemetryConfig { + tickHz: number; +} + +interface Capture { + mask: number; + rows: Row[]; + summary: Summary; + at: Date; +} + +function message(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +function wholeIn(text: string, max: number): number | undefined { + const n = Number(text); + return /^\d+$/.test(text.trim()) && n >= 1 && n <= max ? n : undefined; +} + +function spanMs(samples: number, tickHz: number): number { + return (samples / tickHz) * 1000; +} + +function chartOptions(units: readonly Unit[], tokens: ChartTokens, tickHz: number): ChartOptions { + const axis = { + stroke: tokens.label, + grid: { stroke: tokens.grid, width: 1 }, + ticks: { stroke: tokens.axis, width: 1 }, + }; + const right = units.some((u) => familyOf(u.key) === "electrical"); + const left = units.some((u) => familyOf(u.key) === "position"); + return { + legend: { show: false }, + cursor: { drag: { x: false, y: false } }, + scales: { x: { time: false }, ...(left ? { y: {} } : {}), ...(right ? { r: {} } : {}) }, + axes: [ + { ...axis, label: tickHz > 0 ? "ms" : "sample" }, + ...(left ? [{ ...axis, scale: "y", size: 60 }] : []), + ...(right ? [{ ...axis, scale: "r", side: 1, size: 60, grid: { show: false } }] : []), + ], + series: [ + {}, + ...units.map((u, i) => ({ + label: u.key, + scale: familyOf(u.key) === "position" ? "y" : "r", + stroke: tokens[COLORS[i % COLORS.length] ?? "ctx"], + width: 1.5, + dash: i >= COLORS.length ? DASH : undefined, + points: { show: false }, + })), + ], + }; +} + +function saveCsv(text: string, name: string): void { + const url = URL.createObjectURL(new Blob([text], { type: "text/csv" })); + const a = document.createElement("a"); + a.href = url; + a.download = name; + a.click(); + setTimeout(() => { + URL.revokeObjectURL(url); + }, 0); +} + +export function StreamTab({ id }: { id: number }) { + const { descriptor, descriptorError, run } = useSession(); + const [config, setConfig] = useState(); + const [fields, setFields] = useState(DEFAULT_FIELDS); + const [count, setCount] = useState(String(DEFAULT_COUNT)); + const [windowMs, setWindowMs] = useState(String(DEFAULT_WINDOW_MS)); + const [pending, setPending] = useState(false); + const [error, setError] = useState(); + const [capture, setCapture] = useState(); + const [unitsPref] = useUnitsPref(); + const tokens = useChartTokens(); + const countId = useId(); + const windowId = useId(); + const fieldsId = useId(); + + useEffect(() => { + if (descriptor === undefined) return; + const all = descriptor.fields(); + const config = spanOver(all, [...CONFIG_REGISTERS, "tick_hz"]); + const biases = spanOver(all, BIAS_REGISTERS); + let live = true; + run(async (c) => { + const read = decodeSpan(config, await c.read(id, config.addr, config.count)); + const bias = decodeSpan(biases, await c.read(id, biases.addr, biases.count)); + return { + sense: senseFromTable(read), + cal: calibrationFromTable(read), + biases: biasesFromTable(bias), + tickHz: read("tick_hz"), + }; + }).then( + (c) => { + if (live) setConfig(c); + }, + (e: unknown) => { + if (live) setError(message(e)); + }, + ); + return () => { + live = false; + }; + }, [descriptor, id, run]); + + const calibrated = config !== undefined && calibrationStatus(config.cal).valid; + const raw = !calibrated || unitsPref === "raw"; + const mask = maskOf(fields); + const samples = wholeIn(count, COUNT_MAX); + const window = wholeIn(windowMs, WINDOW_MAX_MS); + const tickHz = config?.tickHz ?? 0; + const units = useMemo( + () => (capture === undefined ? [] : unitsFor(capture.mask, config, raw)), + [capture, config, raw], + ); + const options = useMemo(() => chartOptions(units, tokens, tickHz), [units, tokens, tickHz]); + const data = useMemo(() => { + const rows = capture?.rows ?? []; + return [ + rows.map((r) => (tickHz > 0 ? spanMs(r.sample, tickHz) : r.sample)), + ...units.map((u) => + rows.map((r) => { + const c = r.values[u.key]; + return c === undefined ? null : u.convert(c); + }), + ), + ]; + }, [capture, units, tickHz]); + + const startCapture = () => { + const issue = maskIssue(mask); + if (descriptor === undefined || samples === undefined || window === undefined) return; + if (issue !== undefined) { + setError(issue); + return; + } + setPending(true); + run((c) => c.telBurst(id, descriptor, mask, samples, window * 1000)) + .then( + (burst) => { + const rows = decodeBurst(burst.frames, mask); + setCapture({ mask, rows, summary: summarize(burst, rows), at: new Date() }); + setError(undefined); + }, + (e: unknown) => { + setError(message(e)); + }, + ) + .finally(() => { + setPending(false); + }); + }; + + const problem = error ?? descriptorError; + const heading = `${fieldsId}-chart`; + const title = (family: "position" | "electrical") => + [...new Set(units.filter((u) => familyOf(u.key) === family).map((u) => u.unit))].join(", "); + + return ( +
+ + + + + Capture + + + + + + A burst records the servo's own control-tick samples for a few milliseconds, far + faster than Telemetry polls. Pick the fields and a sample count, then Capture: the + servo streams the batch over the bus and the line is its own until the last frame. + + + + + +
+ + Fields + +
+ {FIELDS.map((f) => { + const boxId = `${fieldsId}-${f.key}`; + return ( +
+ { + setFields( + on === true + ? FIELDS.filter((x) => x.key === f.key || fields.includes(x.key)).map( + (x) => x.key, + ) + : fields.filter((k) => k !== f.key), + ); + }} + /> + +
+ ); + })} +
+
+
+ + { + setCount(e.target.value); + }} + /> + of {COUNT_MAX} + + { + setWindowMs(e.target.value); + }} + /> + ms +
+
+ +

+ {fields.length} fields x {samples ?? "?"} samples + {config !== undefined && samples !== undefined + ? ` = ${spanMs(samples, config.tickHz).toFixed(1)} ms at ${(config.tickHz / 1000).toFixed(0)} kHz` + : ""} +

+

+ tel_mask {hex16(mask)} - tel_count {samples ?? "?"} +

+ {capture !== undefined && ( +

+ {summaryText(capture.summary)} +

+ )} + {problem !== undefined &&

{problem}

} +
+
+
+ + + Last burst + + {capture === undefined + ? "no capture yet" + : `${capture.rows.length} samples${ + tickHz > 0 ? `, ${spanMs(capture.rows.length, tickHz).toFixed(1)} ms` : "" + }, ${capture.at.toLocaleTimeString()}`} + + + + + + +
+ {capture === undefined ? ( +

+ Capture a burst to see it here +

+ ) : ( +
+

+ {[title("position"), title("electrical")].filter((t) => t !== "").join(" | ")} +

+ +
+ )} +
+
+
+
+ ); +} diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..1537ed5 --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +"use client"; + +import * as React from "react"; +import { cn } from "cn"; +import { Checkbox as CheckboxPrimitive } from "radix-ui"; +import { CheckIcon } from "lucide-react"; + +function Checkbox({ className, ...props }: React.ComponentProps) { + return ( + + + + + + ); +} + +export { Checkbox }; diff --git a/src/lib/stream.test.ts b/src/lib/stream.test.ts new file mode 100644 index 0000000..704587b --- /dev/null +++ b/src/lib/stream.test.ts @@ -0,0 +1,189 @@ +import { expect, test } from "vitest"; +import fixture from "../../tests/fixtures/stall-24mhz.json"; +import { + decodeBurst, + DEFAULT_FIELDS, + FIELDS, + fieldsOf, + isLast, + maskIssue, + maskOf, + sampleLen, + summarize, + summaryText, + toCsv, + unitsFor, +} from "./stream"; +import type { TelemetryConfig } from "./telemetry-poll"; +import { calibrationFromTable, senseFromTable } from "./units"; + +/** Mirrors core tel.rs `sample(i)`: every field, window_valid on even i. */ +function sampleBytes(mask: number, i: number): number[] { + const all: [number, number][] = [ + [0, 0x1000 + i], + [1, (-i - 1) & 0xffff], + [2, 0xb000 + i], + [3, 0x2000 + i], + [4, (-300 - i) & 0xffff], + [5, 1800 + i], + [6, 0x0100 + i], + [7, 0x0a00 + i], + [8, 0x0b00 + i], + [9, 0x0c00 + i], + [10, 0x0d00 + i], + ]; + const out: number[] = []; + for (const [bit, v] of all) { + if ((mask & (1 << bit)) !== 0) out.push(v & 0xff, v >> 8); + } + return out; +} + +function frame(mask: number, seq: number, last: boolean, count: number): Uint8Array { + let valid = 0; + const p = [seq, last ? 1 : 0, 0, 0]; + for (let i = 0; i < count; i++) { + if (i % 2 === 0) valid |= 1 << i; + p.push(...sampleBytes(mask, i)); + } + p[2] = valid & 0xff; + p[3] = valid >> 8; + return Uint8Array.from(p); +} + +const MASK_SIX = 0x3f; + +test("the field table is in mask bit order with two bytes per field", () => { + expect(FIELDS.map((f) => f.bit)).toEqual([...FIELDS.keys()]); + expect(maskOf(DEFAULT_FIELDS)).toBe(0x3c1); + expect(fieldsOf(0x3c1).map((f) => f.key)).toEqual(DEFAULT_FIELDS); + expect(sampleLen(0x3c1)).toBe(10); + expect(sampleLen(MASK_SIX)).toBe(12); +}); + +test("mask rules mirror the firmware's mask_valid", () => { + expect(maskIssue(0)).toBe("pick at least one field"); + expect(maskIssue(MASK_SIX)).toBeUndefined(); + expect(maskIssue(1 << 11)).toBe("mask has reserved bits set"); + expect(maskIssue(MASK_SIX | (1 << 6))).toBe("at most 6 fields fit one sample"); +}); + +test("the six-field golden frame decodes to the tel.rs sample vector", () => { + const f = frame(MASK_SIX, 0x42, false, 16); + // The bytes core's encode_golden_six_field_mask_full_batch pins. + expect([...f.subarray(0, 4)]).toEqual([0x42, 0x00, 0x55, 0x55]); + expect([...f.subarray(4, 16)]).toEqual([ + 0x00, 0x10, 0xff, 0xff, 0x00, 0xb0, 0x00, 0x20, 0xd4, 0xfe, 0x08, 0x07, + ]); + const rows = decodeBurst([f], MASK_SIX); + expect(rows).toHaveLength(16); + // A burst arms at seq 0: a first frame at 0x42 is 0x42 missed frames. + expect(rows[0]?.sample).toBe(0x42 * 16); + expect(rows[0]).toMatchObject({ + valid: true, + values: { + pos: 0x1000, + current: -1, + current_trough: 0xb000, + duty_q15: 0x2000, + vdiff: -300, + vbus: 1800, + }, + }); + expect(rows[15]).toMatchObject({ + sample: 0x42 * 16 + 15, + valid: false, + values: { pos: 0x100f, current: -16, vdiff: -315, vbus: 1815 }, + }); + expect(isLast(f)).toBe(false); +}); + +test("a two-field mask packs in bit order and a seq hole leaves 16 missing samples", () => { + const mask = maskOf(["pos", "vmotor_a"]); + const a = frame(mask, 0, false, 16); + const c = frame(mask, 2, true, 3); + expect([...a.subarray(4, 8)]).toEqual([0x00, 0x10, 0x00, 0x0a]); + const rows = decodeBurst([a, c], mask); + expect(rows).toHaveLength(19); + expect(rows.map((r) => r.sample)).toEqual([...Array(16).keys(), 32, 33, 34]); + expect(rows[18]?.values).toEqual({ pos: 0x1002, vmotor_a: 0x0a02 }); + expect(Object.keys(rows[0]?.values ?? {})).toEqual(["pos", "vmotor_a"]); + expect(isLast(c)).toBe(true); +}); + +test("seq unwraps past 255 and ragged payloads are skipped", () => { + const mask = maskOf(["pos"]); + const rows = decodeBurst( + [frame(mask, 255, false, 16), Uint8Array.from([0, 0, 0, 0, 1]), frame(mask, 0, true, 1)], + mask, + ); + expect(rows.map((r) => r.sample)).toEqual( + [...Array(16).keys()].map((i) => 255 * 16 + i).concat(256 * 16), + ); +}); + +test("the summary carries the burst's counters and the decoded sample count", () => { + const mask = maskOf(["pos"]); + const burst = { + frames: [frame(mask, 0, false, 16), frame(mask, 1, true, 4)], + complete: true, + statuses: 3, + garble: 0, + trailing: false, + }; + const s = summarize(burst, decodeBurst(burst.frames, mask)); + expect(s).toEqual({ + frames: 2, + samples: 20, + complete: true, + garble: 0, + trailing: false, + statuses: 3, + }); + expect(summaryText(s)).toBe("2 frames, 20 samples, complete, 0 garble, 3 statuses"); + expect(summaryText({ ...s, complete: false, trailing: true })).toBe( + "2 frames, 20 samples, incomplete, 0 garble, 3 statuses, trailing", + ); +}); + +function readFrom(table: Record) { + return (name: string) => { + const v = table[name]; + if (v === undefined) throw new Error(`no ${name}`); + return v; + }; +} + +const config: TelemetryConfig = { + sense: senseFromTable(readFrom(fixture.meta.sense)), + cal: calibrationFromTable( + readFrom({ + raw_min: 500, + raw_max: 3500, + angle_min_cdeg: 0, + angle_max_cdeg: 18000, + gear_ratio_centi: 100, + }), + ), + biases: { currentBiasCounts: 106, vmotorBiasCounts: 779 }, +}; + +test("the CSV has a header naming the selected fields with their units and one line per row", () => { + const mask = maskOf(["pos", "current_raw"]); + const rows = decodeBurst([frame(mask, 0, true, 2)], mask); + const csv = toCsv(rows, unitsFor(mask, config, false)); + const lines = csv.split("\n"); + expect(lines[0]).toBe("sample,valid,pos (deg),current_raw (mA)"); + expect(lines).toHaveLength(4); + expect(lines[3]).toBe(""); + expect(lines[1]).toMatch(/^0,1,\d+\.\d,-?\d+$/); + expect(lines[2]).toMatch(/^1,0,/); + // Raw keeps the position family in counts and the electrical fields real. + const raw = toCsv(rows, unitsFor(mask, config, true)); + expect(raw.split("\n")[0]).toBe("sample,valid,pos (counts),current_raw (mA)"); + expect(raw.split("\n")[1]?.startsWith("0,1,4096,")).toBe(true); + // Without a config every column is counts. + expect(toCsv(rows, unitsFor(mask, undefined, false)).split("\n")[0]).toBe( + "sample,valid,pos (counts),current_raw (counts)", + ); +}); diff --git a/src/lib/stream.ts b/src/lib/stream.ts new file mode 100644 index 0000000..15fb9f3 --- /dev/null +++ b/src/lib/stream.ts @@ -0,0 +1,257 @@ +// The Stream tab minus React: the TEL burst frame layout (protocol sec 5.6, +// firmware core tel.rs), the mask that picks a sample's fields, the decoder +// that mirrors the ident host's StreamAssembler, and the CSV export. + +import { dutyPercent } from "./control"; +import type { TelemetryConfig } from "./telemetry-poll"; +import { + busV, + currentMa, + DISPLAY, + motorV, + positionDeg, + temperatureC, + vdiffV, + type Display, +} from "./units"; + +/** Payload bytes before the samples: stream_seq, flags, valid bitmap. */ +export const STREAM_HDR = 4; +/** One frame batches up to 16 fast-tick samples; a seq hole is 16 samples. */ +export const SAMPLES_PER_FRAME = 16; +/** A 16-sample batch must clear the wire in its own tick window at 3 M. */ +export const FIELDS_MAX = 6; +const FLAG_LAST = 1; +const MASK_ALL = 0x7ff; +/** Every v1 field is two bytes. */ +const FIELD_BYTES = 2; + +export type Family = "position" | "electrical"; + +/** + * In `tel_mask` bit order, which is also the order fields pack in a sample. + * `key` is the firmware's TelSample name, so a CSV column names the register. + */ +export const FIELDS = [ + { key: "pos", label: "Position", bit: 0, signed: false, family: "position" }, + { key: "current", label: "Current", bit: 1, signed: true, family: "electrical" }, + { key: "current_trough", label: "Current trough", bit: 2, signed: false, family: "electrical" }, + { key: "duty_q15", label: "Duty", bit: 3, signed: true, family: "electrical" }, + { key: "vdiff", label: "Motor V", bit: 4, signed: true, family: "electrical" }, + { key: "vbus", label: "Bus V", bit: 5, signed: false, family: "electrical" }, + { key: "current_raw", label: "Current raw", bit: 6, signed: false, family: "electrical" }, + { key: "vmotor_a", label: "Motor A", bit: 7, signed: false, family: "electrical" }, + { key: "vmotor_b", label: "Motor B", bit: 8, signed: false, family: "electrical" }, + { key: "vbus_raw", label: "Bus raw", bit: 9, signed: false, family: "electrical" }, + { key: "ntc_raw", label: "NTC raw", bit: 10, signed: false, family: "electrical" }, +] as const satisfies readonly { + key: string; + label: string; + bit: number; + signed: boolean; + family: Family; +}[]; + +export type FieldDef = (typeof FIELDS)[number]; +export type FieldKey = FieldDef["key"]; + +/** Position, the raw current window, both terminal taps and the raw bus tap. */ +export const DEFAULT_FIELDS: readonly FieldKey[] = [ + "pos", + "current_raw", + "vmotor_a", + "vmotor_b", + "vbus_raw", +]; + +export function maskOf(keys: Iterable): number { + let mask = 0; + for (const key of keys) { + const field = FIELDS.find((f) => f.key === key); + if (field !== undefined) mask |= 1 << field.bit; + } + return mask; +} + +/** The mask's fields in bit order. */ +export function fieldsOf(mask: number): FieldDef[] { + return FIELDS.filter((f) => (mask & (1 << f.bit)) !== 0); +} + +/** Why the servo would refuse this mask (tel.rs `mask_valid`), or undefined. */ +export function maskIssue(mask: number): string | undefined { + if ((mask & ~MASK_ALL) !== 0) return "mask has reserved bits set"; + const n = fieldsOf(mask).length; + if (n === 0) return "pick at least one field"; + if (n > FIELDS_MAX) return `at most ${FIELDS_MAX} fields fit one sample`; + return undefined; +} + +export function sampleLen(mask: number): number { + return FIELD_BYTES * fieldsOf(mask).length; +} + +export interface Row { + /** Fast-tick index from the arm; a dropped frame leaves 16 missing indices. */ + sample: number; + /** This tick's drive window met the sampling floors (the frame's `valid` bitmap). */ + valid: boolean; + values: Partial>; +} + +/** + * Splits the CRC-clean stream payloads of one burst into rows. Sample `i` of + * the frame with unwrapped index `k` is sample `16k + i`: the u8 stream_seq + * is unwrapped against the previous frame, and a burst arms at seq 0, so a + * nonzero first seq is missed frames too. A payload that cannot be a + * `mask` frame (short header, ragged remainder, over 16 samples) is skipped. + */ +export function decodeBurst(frames: readonly Uint8Array[], mask: number): Row[] { + const fields = fieldsOf(mask); + const len = FIELD_BYTES * fields.length; + const rows: Row[] = []; + let lastSeq: number | undefined; + let frameIdx = 0; + for (const payload of frames) { + if (payload.length < STREAM_HDR) continue; + const seq = payload[0] ?? 0; + const idx = lastSeq === undefined ? seq : frameIdx + ((seq - lastSeq) & 0xff); + lastSeq = seq; + frameIdx = idx; + const body = payload.length - STREAM_HDR; + if (len === 0 || body % len !== 0 || body / len > SAMPLES_PER_FRAME) continue; + const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); + const valid = view.getUint16(2, true); + for (let i = 0; i < body / len; i++) { + const values: Partial> = {}; + let at = STREAM_HDR + i * len; + for (const f of fields) { + values[f.key] = f.signed ? view.getInt16(at, true) : view.getUint16(at, true); + at += FIELD_BYTES; + } + rows.push({ sample: idx * SAMPLES_PER_FRAME + i, valid: (valid & (1 << i)) !== 0, values }); + } + } + return rows; +} + +/** The last frame of a burst carries the LAST flag; the line frees after it. */ +export function isLast(payload: Uint8Array): boolean { + return ((payload[1] ?? 0) & FLAG_LAST) !== 0; +} + +/** What `telBurst` reports besides the payloads. */ +export interface BurstInfo { + frames: readonly Uint8Array[]; + complete: boolean; + statuses: number; + garble: number; + trailing: boolean; +} + +export interface Summary { + frames: number; + samples: number; + complete: boolean; + garble: number; + trailing: boolean; + statuses: number; +} + +export function summarize(burst: BurstInfo, rows: readonly Row[]): Summary { + return { + frames: burst.frames.length, + samples: rows.length, + complete: burst.complete, + garble: burst.garble, + trailing: burst.trailing, + statuses: burst.statuses, + }; +} + +export function summaryText(s: Summary): string { + const parts = [ + `${s.frames} frames`, + `${s.samples} samples`, + s.complete ? "complete" : "incomplete", + `${s.garble} garble`, + `${s.statuses} statuses`, + ]; + if (s.trailing) parts.push("trailing"); + return parts.join(", "); +} + +/** One CSV column and chart series: a field's counts through one converter. */ +export interface Unit extends Display { + key: FieldKey; + convert: (counts: number) => number; +} + +const COUNTS: Display = DISPLAY.raw; +const PERCENT: Display = { unit: "%", digits: 1 }; + +/** + * Real units through the servo's own sense chain and calibration; the raw + * preference reverts the position family to counts, the electrical fields + * stay real. Without a config every field is counts. + */ +export function unitsFor(mask: number, config: TelemetryConfig | undefined, raw: boolean): Unit[] { + return fieldsOf(mask).map((f) => { + const counts: Unit = { key: f.key, ...COUNTS, convert: (c) => c }; + if (config === undefined) return counts; + const { sense, cal, biases } = config; + switch (f.key) { + case "pos": + return raw + ? counts + : { key: f.key, ...DISPLAY.position, convert: (c) => positionDeg(c, cal) }; + // The kernel's own bias-subtracted sample: no bias to take out again. + case "current": + return { key: f.key, ...DISPLAY.current, convert: (c) => currentMa(c, 0, sense) }; + case "current_trough": + case "current_raw": + return { + key: f.key, + ...DISPLAY.current, + convert: (c) => currentMa(c, biases.currentBiasCounts, sense), + }; + case "duty_q15": + return { key: f.key, ...PERCENT, convert: dutyPercent }; + case "vdiff": + return { key: f.key, ...DISPLAY.motorVoltage, convert: (c) => vdiffV(c, 0, sense) }; + case "vbus": + case "vbus_raw": + return { key: f.key, ...DISPLAY.busVoltage, convert: (c) => busV(c, sense) }; + case "vmotor_a": + case "vmotor_b": + return { + key: f.key, + ...DISPLAY.motorVoltage, + convert: (c) => motorV(c, biases.vmotorBiasCounts, sense), + }; + case "ntc_raw": + return { key: f.key, ...DISPLAY.temperature, convert: (c) => temperatureC(c, sense) }; + } + }); +} + +export function familyOf(key: FieldKey): Family { + return FIELDS.find((f) => f.key === key)?.family ?? "electrical"; +} + +export function csvHeader(units: readonly Unit[]): string { + return ["sample", "valid", ...units.map((u) => `${u.key} (${u.unit})`)].join(","); +} + +/** Header, then one line per row; a field the row lacks is an empty cell. */ +export function toCsv(rows: readonly Row[], units: readonly Unit[]): string { + const lines = [csvHeader(units)]; + for (const row of rows) { + const cells = units.map((u) => { + const counts = row.values[u.key]; + return counts === undefined ? "" : u.convert(counts).toFixed(u.digits); + }); + lines.push([row.sample, row.valid ? 1 : 0, ...cells].join(",")); + } + return `${lines.join("\n")}\n`; +} diff --git a/src/routes/live.tsx b/src/routes/live.tsx index 5e498d0..fd98e2b 100644 --- a/src/routes/live.tsx +++ b/src/routes/live.tsx @@ -3,6 +3,7 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { Pause, Play, TriangleAlert } from "lucide-react"; import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from "react"; import type uPlot from "uplot"; +import { StreamTab } from "@/components/stream-tab"; import { Chart, type ChartOptions } from "@/components/uplot"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; @@ -271,7 +272,7 @@ function LivePage() { -

Stream capture comes later.

+
)} diff --git a/tests/e2e/stream.spec.ts b/tests/e2e/stream.spec.ts new file mode 100644 index 0000000..d7882b4 --- /dev/null +++ b/tests/e2e/stream.spec.ts @@ -0,0 +1,75 @@ +import { expect, test, type Page } from "@playwright/test"; +import { readFile } from "node:fs/promises"; +import { gotoSim } from "./helpers"; + +const SUMMARY = /^(\d+) frames, (\d+) samples, (complete|incomplete), \d+ garble, \d+ statuses/; + +async function openStream(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 page.getByRole("tab", { name: "Stream" }).click({ timeout: 15_000 }); + // The tick rate arrives with the conversion registers; the sentence needs both. + await expect(page.getByText(/ samples = [\d.]+ ms at \d+ kHz$/)).toBeVisible(); +} + +/** Runs a capture and returns the summary's frame and sample counts. */ +async function capture(page: Page): Promise<{ frames: number; samples: number }> { + await page.getByRole("button", { name: "Capture", exact: true }).click(); + const summary = page.getByLabel("Capture summary"); + await expect(summary).toHaveText(SUMMARY); + const m = SUMMARY.exec((await summary.textContent()) ?? ""); + return { frames: Number(m?.[1]), samples: Number(m?.[2]) }; +} + +async function downloadCsv(page: Page): Promise { + const [download] = await Promise.all([ + page.waitForEvent("download"), + page.getByRole("button", { name: "Download .csv" }).click(), + ]); + const text = await readFile(await download.path(), "utf8"); + expect(download.suggestedFilename()).toMatch(/^burst-id1-.*\.csv$/); + // The file ends with a newline, so the split leaves one empty string. + const lines = text.split("\n"); + expect(lines.at(-1)).toBe(""); + return lines.slice(0, -1); +} + +// The simulated fleet replays a recorded track at 1 M, where a full-rate burst +// still fits the wire for the default five fields, so the burst comes back +// complete; the assertions only lean on what arrived. +test("a capture with the default fields fills the summary and downloads matching CSV rows", async ({ + page, +}) => { + await openStream(page); + await expect(page.getByText("tel_mask 0x03c1 - tel_count 120")).toBeVisible(); + const { frames, samples } = await capture(page); + expect(frames).toBeGreaterThan(0); + expect(samples).toBeGreaterThan(0); + await expect(page.getByRole("region", { name: "deg | mA, V" })).toBeVisible(); + const lines = await downloadCsv(page); + expect(lines[0]).toBe( + "sample,valid,pos (deg),current_raw (mA),vmotor_a (V),vmotor_b (V),vbus_raw (V)", + ); + expect(lines.length - 1).toBe(samples); + expect(lines[1]).toMatch(/^0,[01],\d+\.\d,-?\d+,-?\d+\.\d\d,-?\d+\.\d\d,\d+\.\d\d$/); +}); + +test("the field selection and sample count drive the mask, the burst and the CSV header", async ({ + page, +}) => { + await openStream(page); + for (const name of ["Current raw", "Motor A", "Motor B", "Bus raw"]) { + await page.getByRole("checkbox", { name, exact: true }).click(); + } + await page.getByRole("checkbox", { name: "Current", exact: true }).click(); + await page.getByRole("spinbutton", { name: "Samples" }).fill("40"); + await expect(page.getByText("tel_mask 0x0003 - tel_count 40")).toBeVisible(); + const { samples } = await capture(page); + expect(samples).toBeGreaterThan(0); + expect(samples).toBeLessThanOrEqual(40); + const lines = await downloadCsv(page); + expect(lines[0]).toBe("sample,valid,pos (deg),current (mA)"); + expect(lines.length - 1).toBe(samples); +});