From 86a588848929b65f31fd0dda95307ffda868c6cc Mon Sep 17 00:00:00 2001 From: Aaron Qian Date: Tue, 22 Sep 2026 16:35:38 -0700 Subject: [PATCH 1/3] app: servo page with about and health cards --- src/components/about-card.tsx | 98 +++++++++++++++++++++++++++ src/components/editor-preview.tsx | 81 ---------------------- src/components/health-card.tsx | 107 ++++++++++++++++++++++++++++++ src/lib/command.ts | 8 +++ src/lib/health.test.ts | 58 ++++++++++++++++ src/lib/health.ts | 46 +++++++++++++ src/routes/servo.tsx | 23 +++++-- tests/e2e/editor.spec.ts | 32 --------- tests/e2e/servo.spec.ts | 25 +++++++ 9 files changed, 359 insertions(+), 119 deletions(-) create mode 100644 src/components/about-card.tsx delete mode 100644 src/components/editor-preview.tsx create mode 100644 src/components/health-card.tsx create mode 100644 src/lib/command.ts create mode 100644 src/lib/health.test.ts create mode 100644 src/lib/health.ts delete mode 100644 tests/e2e/editor.spec.ts create mode 100644 tests/e2e/servo.spec.ts diff --git a/src/components/about-card.tsx b/src/components/about-card.tsx new file mode 100644 index 0000000..210ede8 --- /dev/null +++ b/src/components/about-card.tsx @@ -0,0 +1,98 @@ +import { unpackVersion, type Identity, type OscClient } from "@openservocore/client"; +import { Cog } from "lucide-react"; +import { useEffect, useState, type ReactNode } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { queued } from "@/lib/command"; +import { modelName } from "@/lib/descriptor"; +import { formatVersion, hex16 } from "@/lib/format"; + +/** `capability_flags` bit order (protocol sec 5.4). */ +const CAPABILITIES: readonly string[] = ["Motor encoder"]; + +function features(caps: number): string { + const set: string[] = []; + for (let bit = 0; bit < 32; bit++) { + if ((caps & (1 << bit)) === 0) continue; + set.push(CAPABILITIES[bit] ?? `bit ${bit}`); + } + return set.length === 0 ? "None" : set.join(", "); +} + +function Row({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +export function AboutCard({ client, id, uid }: { client: OscClient; id: number; uid: string }) { + const [identity, setIdentity] = useState(); + const [error, setError] = useState(); + + useEffect(() => { + let live = true; + void (async () => { + try { + const read = await queued(() => client.identity(id)); + if (live) setIdentity(read); + } catch (e) { + if (live) setError(e instanceof Error ? e.message : String(e)); + } + })(); + return () => { + live = false; + }; + }, [client, id]); + + const name = identity === undefined ? undefined : modelName(identity.model); + return ( + + + + + About + + + + {error !== undefined &&

{error}

} + {error === undefined && identity === undefined && ( + <> + + + + + )} + {identity !== undefined && ( + <> + + {name === undefined ? ( + {hex16(identity.model)} + ) : ( + + + {name} + + {hex16(identity.model)} + + )} + + + {formatVersion(unpackVersion(identity.fw))} + + + {identity.hw} + + + {uid} + + {features(identity.capabilities)} + + )} +
+
+ ); +} diff --git a/src/components/editor-preview.tsx b/src/components/editor-preview.tsx deleted file mode 100644 index 88e60fc..0000000 --- a/src/components/editor-preview.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import type { Field } from "@openservocore/client"; -import { useState } from "react"; -import { ValueEditor } from "./value-editor"; -import type { EditValue } from "@/lib/edit"; - -// Copied from descriptors/osc-servo/0.1.json so the preview needs no session. -const PREVIEW: { field: Field; initial: EditValue }[] = [ - { - field: { - name: "id", - addr: 16, - width: 1, - access: "rw", - kind: "uint", - min: 1, - max: 249, - variants: [], - }, - initial: 1, - }, - { - field: { - name: "mode", - addr: 388, - width: 1, - access: "rw", - kind: "enum", - variants: [ - { name: "OpenLoop", value: 0 }, - { name: "Current", value: 1 }, - { name: "Velocity", value: 2 }, - { name: "Position", value: 3 }, - ], - }, - initial: 3, - }, - { - field: { name: "torque_enable", addr: 384, width: 1, access: "rw", kind: "bool", variants: [] }, - initial: false, - }, - { - field: { name: "words", addr: 640, width: 8, access: "rw", kind: "bytes", variants: [] }, - initial: new Uint8Array([0x4f, 0x53, 0x43, 0x00, 0x01, 0x02, 0x03, 0x04]), - }, -]; - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function PreviewRow({ field, initial }: { field: Field; initial: EditValue }) { - const [value, setValue] = useState(initial); - return ( - <> -
{field.name}
-
- { - await delay(300); - setValue(raw); - }} - /> -
- - ); -} - -export function EditorPreview() { - return ( -
-

Editor preview

-
- {PREVIEW.map((p) => ( - - ))} -
-
- ); -} diff --git a/src/components/health-card.tsx b/src/components/health-card.tsx new file mode 100644 index 0000000..54b1098 --- /dev/null +++ b/src/components/health-card.tsx @@ -0,0 +1,107 @@ +import type { Health, OscClient } from "@openservocore/client"; +import { CircleAlert, CircleCheck, HeartPulse, TriangleAlert } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { Skeleton } from "@/components/ui/skeleton"; +import { queued } from "@/lib/command"; +import { countersLine, statements, trimLine, type Level } from "@/lib/health"; + +const POLL_MS = 1000; + +const icons = { fault: CircleAlert, warn: TriangleAlert, ok: CircleCheck }; +const tone: Record = { + fault: "text-danger", + warn: "text-warning", + ok: "text-success", +}; + +export function HealthCard({ client, id }: { client: OscClient; id: number }) { + const [health, setHealth] = useState(); + const [error, setError] = useState(); + const [clearing, setClearing] = useState(false); + + useEffect(() => { + let live = true; + // One read in flight at a time: a poll that outlasts its period is skipped + // rather than queued behind itself. + let busy = false; + async function poll() { + if (busy || !live) return; + busy = true; + try { + const read = await queued(() => client.health(id)); + if (live) { + setHealth(read); + setError(undefined); + } + } catch (e) { + if (live) setError(e instanceof Error ? e.message : String(e)); + } finally { + busy = false; + } + } + void poll(); + const timer = window.setInterval(() => void poll(), POLL_MS); + return () => { + live = false; + window.clearInterval(timer); + }; + }, [client, id]); + + async function clear() { + setClearing(true); + try { + await queued(() => client.clearCounters(id)); + setHealth(await queued(() => client.health(id))); + setError(undefined); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setClearing(false); + } + } + + return ( + + + + + Health + + + + {error !== undefined &&

{error}

} + {health === undefined ? ( + <> + + + + ) : ( + <> + {statements(health).map(({ level, text }) => { + const Icon = icons[level]; + return ( +
+ + {text} +
+ ); + })} + +
+ {trimLine(health)} +
+
+ {countersLine(health)} + +
+ + )} +
+
+ ); +} diff --git a/src/lib/command.ts b/src/lib/command.ts new file mode 100644 index 0000000..fd2540c --- /dev/null +++ b/src/lib/command.ts @@ -0,0 +1,8 @@ +let chain: Promise = Promise.resolve(); + +/** The adapter takes one command at a time, so page reads queue behind each other. */ +export function queued(run: () => Promise): Promise { + const next = chain.then(run, run); + chain = next.catch(() => undefined); + return next; +} diff --git a/src/lib/health.test.ts b/src/lib/health.test.ts new file mode 100644 index 0000000..abe2b67 --- /dev/null +++ b/src/lib/health.test.ts @@ -0,0 +1,58 @@ +import type { Health } from "@openservocore/client"; +import { expect, test } from "vitest"; +import { countersLine, statements, trimLine } from "./health"; + +const clean: Health = { + faultFlags: 0, + configDirty: false, + trimSteps: 0, + crcFailCount: 0, + framingDropCount: 0, +}; + +test("a clean servo states the all-clear alone", () => { + expect(statements(clean)).toEqual([{ level: "ok", text: "No faults." }]); +}); + +test("one raised flag states that fault and drops the all-clear", () => { + const s = statements({ ...clean, faultFlags: 1 << 2 }); + expect(s).toHaveLength(1); + expect(s[0]?.level).toBe("fault"); + expect(s[0]?.text).toContain("Stalled"); +}); + +test("several raised flags state one sentence each, lowest bit first", () => { + const s = statements({ ...clean, faultFlags: (1 << 0) | (1 << 1) | (1 << 5) }); + expect(s.map((x) => x.level)).toEqual(["fault", "fault", "fault"]); + expect(s[0]?.text).toContain("Over current"); + expect(s[1]?.text).toContain("Over temperature"); + expect(s[2]?.text).toContain("Under voltage"); +}); + +test("an undefined flag still states its bit", () => { + expect(statements({ ...clean, faultFlags: 1 << 7 })[0]?.text).toBe("Unknown fault, bit 7."); +}); + +test("unsaved changes rank under the faults and above the all-clear", () => { + expect(statements({ ...clean, configDirty: true })).toEqual([ + { level: "warn", text: "Unsaved changes: settings differ from the saved ones." }, + { level: "ok", text: "No faults." }, + ]); + expect(statements({ ...clean, faultFlags: 1, configDirty: true }).map((s) => s.level)).toEqual([ + "fault", + "warn", + ]); +}); + +test("the counter lines carry sign and plurals", () => { + expect(trimLine(clean)).toBe("Clock trim 0 steps"); + expect(trimLine({ ...clean, trimSteps: 3 })).toBe("Clock trim +3 steps"); + expect(trimLine({ ...clean, trimSteps: -1 })).toBe("Clock trim -1 step"); + expect(countersLine(clean)).toBe("0 CRC errors, 0 dropped frames"); + expect(countersLine({ ...clean, crcFailCount: 1, framingDropCount: 1 })).toBe( + "1 CRC error, 1 dropped frame", + ); + expect(countersLine({ ...clean, crcFailCount: 2, framingDropCount: 7 })).toBe( + "2 CRC errors, 7 dropped frames", + ); +}); diff --git a/src/lib/health.ts b/src/lib/health.ts new file mode 100644 index 0000000..4e0103e --- /dev/null +++ b/src/lib/health.ts @@ -0,0 +1,46 @@ +import type { Health } from "@openservocore/client"; + +export type Level = "fault" | "warn" | "ok"; + +export interface Statement { + level: Level; + text: string; +} + +/** `fault_flags` bit order (protocol sec 5.3 alarm register). */ +const FAULTS: readonly string[] = [ + "Over current: the drive pulled more than its current limit.", + "Over temperature: the winding estimate reached the cutoff.", + "Stalled: holding current with no movement.", + "Position error: the servo stayed away from its goal for too long.", + "Sensor fault: the position readings jumped further than a step can.", + "Under voltage: the bus rail sagged below the limit.", +]; + +function plural(n: number, word: string): string { + return `${n} ${word}${n === 1 ? "" : "s"}`; +} + +/** Worst first: faults, then unsaved changes, then the all-clear. */ +export function statements(h: Health): Statement[] { + const out: Statement[] = []; + for (let bit = 0; bit < 8; bit++) { + if ((h.faultFlags & (1 << bit)) === 0) continue; + out.push({ level: "fault", text: FAULTS[bit] ?? `Unknown fault, bit ${bit}.` }); + } + if (h.configDirty) { + out.push({ level: "warn", text: "Unsaved changes: settings differ from the saved ones." }); + } + if (h.faultFlags === 0) out.push({ level: "ok", text: "No faults." }); + return out; +} + +export function trimLine(h: Health): string { + const sign = h.trimSteps > 0 ? "+" : ""; + const unit = Math.abs(h.trimSteps) === 1 ? "step" : "steps"; + return `Clock trim ${sign}${h.trimSteps} ${unit}`; +} + +export function countersLine(h: Health): string { + return `${plural(h.crcFailCount, "CRC error")}, ${plural(h.framingDropCount, "dropped frame")}`; +} diff --git a/src/routes/servo.tsx b/src/routes/servo.tsx index cd0f8e9..c86249e 100644 --- a/src/routes/servo.tsx +++ b/src/routes/servo.tsx @@ -1,16 +1,27 @@ import { createFileRoute } from "@tanstack/react-router"; -import { EditorPreview } from "../components/editor-preview"; -import { useSession } from "../lib/session"; +import { Cog } from "lucide-react"; +import { AboutCard } from "@/components/about-card"; +import { HealthCard } from "@/components/health-card"; +import { useSession } from "@/lib/session"; export const Route = createFileRoute("/servo")({ component: ServoPage }); function ServoPage() { - const { status } = useSession(); + const { status, client, servos, selected } = useSession(); + const servo = status === "ready" ? servos.find((s) => s.id === selected) : undefined; + if (client === undefined || servo === undefined) { + return

Pick a servo in the left pane.

; + } return ( <> -

Servo

- {status !== "ready" &&

Connect first.

} - +

+ + ID {servo.id} +

+
+ + +
); } diff --git a/tests/e2e/editor.spec.ts b/tests/e2e/editor.spec.ts deleted file mode 100644 index 1200149..0000000 --- a/tests/e2e/editor.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { expect, test } from "@playwright/test"; - -test("number editor validates, applies and shows the new value", async ({ page }) => { - await page.goto("/servo"); - await page.getByRole("button", { name: "1", exact: true }).click(); - const input = page.getByRole("textbox"); - await input.fill("250"); - await expect(page.getByText("between 1 and 249")).toBeVisible(); - await expect(page.getByRole("button", { name: "Apply" })).toBeDisabled(); - await input.fill("42"); - await expect(page.getByText("between 1 and 249")).toBeHidden(); - await page.getByRole("button", { name: "Apply" }).click(); - await expect(page.getByRole("button", { name: "42", exact: true })).toBeVisible(); - await expect(page.getByRole("button", { name: "1", exact: true })).toBeHidden(); -}); - -test("bool editor flips the label", async ({ page }) => { - await page.goto("/servo"); - await page.getByRole("button", { name: "Off", exact: true }).click(); - await page.getByRole("switch").click(); - await page.getByRole("button", { name: "Apply" }).click(); - await expect(page.getByRole("button", { name: "On", exact: true })).toBeVisible(); -}); - -test("enum editor applies the picked option", async ({ page }) => { - await page.goto("/servo"); - await page.getByRole("button", { name: "Position", exact: true }).click(); - await page.getByRole("combobox").click(); - await page.getByRole("option", { name: "Velocity" }).click(); - await page.getByRole("button", { name: "Apply" }).click(); - await expect(page.getByRole("button", { name: "Velocity", exact: true })).toBeVisible(); -}); diff --git a/tests/e2e/servo.spec.ts b/tests/e2e/servo.spec.ts new file mode 100644 index 0000000..b6de777 --- /dev/null +++ b/tests/e2e/servo.spec.ts @@ -0,0 +1,25 @@ +import { expect, test } from "@playwright/test"; +import { gotoSim } from "./helpers"; + +const UID_1 = "c94b8419d1092aec87de5fd151ce290f"; + +test("the servo page reports the selected servo's identity and health", async ({ page }) => { + await gotoSim(page, [1, 2]); + await page.getByRole("button", { name: "ID 1" }).click(); + await expect(page.getByRole("heading", { name: "ID 1" })).toBeVisible(); + + const about = page.getByRole("region", { name: "About" }); + await about.getByText("osc-servo").hover(); + await expect(page.getByRole("tooltip")).toContainText("0x0101"); + await expect(about.getByText("0.1.0")).toBeVisible(); + await expect(about.getByText("1", { exact: true })).toBeVisible(); + await expect(about.getByText(UID_1)).toBeVisible(); + + const health = page.getByRole("region", { name: "Health" }); + await expect(health.getByText("No faults.")).toBeVisible(); +}); + +test("the servo page asks for a selection while none is made", async ({ page }) => { + await page.goto("/servo?sim=1,2"); + await expect(page.getByText("Pick a servo in the left pane.")).toBeVisible(); +}); From d7b89fabc39c15f70fbb7058b94060d59ff910be Mon Sep 17 00:00:00 2001 From: Aaron Qian Date: Tue, 22 Sep 2026 16:57:25 -0700 Subject: [PATCH 2/3] app: calibration card with real-unit editing and the derived status --- src/components/about-card.tsx | 11 +- src/components/calibration-card.tsx | 156 ++++++++++++++++++++++++++++ src/components/health-card.tsx | 15 +-- src/components/value-editor.tsx | 42 ++++++-- src/lib/calibration.test.ts | 83 +++++++++++++++ src/lib/calibration.ts | 50 +++++++++ src/lib/command.ts | 8 -- src/lib/edit.ts | 3 + src/lib/session.tsx | 19 +++- src/lib/units.ts | 11 +- src/routes/servo.tsx | 6 +- tests/e2e/calibration.spec.ts | 82 +++++++++++++++ 12 files changed, 445 insertions(+), 41 deletions(-) create mode 100644 src/components/calibration-card.tsx create mode 100644 src/lib/calibration.test.ts create mode 100644 src/lib/calibration.ts delete mode 100644 src/lib/command.ts create mode 100644 tests/e2e/calibration.spec.ts diff --git a/src/components/about-card.tsx b/src/components/about-card.tsx index 210ede8..f578b7d 100644 --- a/src/components/about-card.tsx +++ b/src/components/about-card.tsx @@ -1,12 +1,12 @@ -import { unpackVersion, type Identity, type OscClient } from "@openservocore/client"; +import { unpackVersion, type Identity } from "@openservocore/client"; import { Cog } from "lucide-react"; import { useEffect, useState, type ReactNode } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { queued } from "@/lib/command"; import { modelName } from "@/lib/descriptor"; import { formatVersion, hex16 } from "@/lib/format"; +import { useSession } from "@/lib/session"; /** `capability_flags` bit order (protocol sec 5.4). */ const CAPABILITIES: readonly string[] = ["Motor encoder"]; @@ -29,7 +29,8 @@ function Row({ label, children }: { label: string; children: ReactNode }) { ); } -export function AboutCard({ client, id, uid }: { client: OscClient; id: number; uid: string }) { +export function AboutCard({ id, uid }: { id: number; uid: string }) { + const { run } = useSession(); const [identity, setIdentity] = useState(); const [error, setError] = useState(); @@ -37,7 +38,7 @@ export function AboutCard({ client, id, uid }: { client: OscClient; id: number; let live = true; void (async () => { try { - const read = await queued(() => client.identity(id)); + const read = await run((c) => c.identity(id)); if (live) setIdentity(read); } catch (e) { if (live) setError(e instanceof Error ? e.message : String(e)); @@ -46,7 +47,7 @@ export function AboutCard({ client, id, uid }: { client: OscClient; id: number; return () => { live = false; }; - }, [client, id]); + }, [run, id]); const name = identity === undefined ? undefined : modelName(identity.model); return ( diff --git a/src/components/calibration-card.tsx b/src/components/calibration-card.tsx new file mode 100644 index 0000000..b8b1a47 --- /dev/null +++ b/src/components/calibration-card.tsx @@ -0,0 +1,156 @@ +import type { Field, OscClient } from "@openservocore/client"; +import { Check, CircleQuestionMark, Ruler } from "lucide-react"; +import { useId, useState } from "react"; +import { ValueEditor } from "@/components/value-editor"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { CALIBRATION_REGISTERS, editReason, type CalibrationRegister } from "@/lib/calibration"; +import { decodeSpan, span } from "@/lib/card-poll"; +import { hexAddr } from "@/lib/format"; +import { useSession } from "@/lib/session"; +import { calibrationFromTable, calibrationStatus, type Calibration } from "@/lib/units"; + +const NAMES = CALIBRATION_REGISTERS.map((r) => r.name); + +async function readCalibration( + client: OscClient, + id: number, + fields: readonly Field[], +): Promise { + const block = span(fields, NAMES); + const bytes = await client.read(id, block.addr, block.count); + return calibrationFromTable(decodeSpan(fields, block, bytes)); +} + +export function CalibrationCard({ id, uid }: { id: number; uid: string }) { + const { descriptor, descriptorError, values, run, refreshConstants } = useSession(); + const [own, setOwn] = useState(); + const cal = own ?? values.get(uid)?.constants.calibration; + const fields = descriptor?.fields(); + + async function apply(field: Field, raw: number) { + if (descriptor === undefined || fields === undefined) return; + const kind = field.kind === "int" ? "int" : "uint"; + const bytes = descriptor.encode(field.name, { kind, value: raw }); + const fresh = await run(async (c) => { + await c.write(id, field.addr, bytes); + return readCalibration(c, id, fields); + }); + setOwn(fresh); + refreshConstants(uid); + } + + return ( + + + + + Calibration + + + + + + Five numbers map the sensor's counts to degrees at the output shaft. Click a value to + edit it; degrees appear everywhere once all five make sense. + + + + {cal !== undefined && ( + + + + )} + + + {descriptorError !== undefined &&

{descriptorError}

} + {cal === undefined || fields === undefined ? ( +
+ {CALIBRATION_REGISTERS.map((reg) => ( + + ))} +
+ ) : ( +
+ {CALIBRATION_REGISTERS.map((reg) => ( + + ))} +
+ )} +
+
+ ); +} + +function fieldNamed(fields: readonly Field[], name: string): Field { + const f = fields.find((f) => f.name === name); + if (f === undefined) throw new Error(`descriptor has no ${name}`); + return f; +} + +function StatusChip({ cal }: { cal: Calibration }) { + const status = calibrationStatus(cal); + if (status.valid) { + return ( + + + Calibrated + + ); + } + return ( + + + + Not calibrated + + + {status.reason} + + ); +} + +function Row({ + reg, + field, + cal, + onApply, +}: { + reg: CalibrationRegister; + field: Field; + cal: Calibration; + onApply: (field: Field, raw: number) => Promise; +}) { + const labelId = useId(); + return ( +
+ + {reg.label} + + + (typeof raw === "number" ? editReason(cal, reg.name, raw) : undefined)} + onApply={(raw) => (typeof raw === "number" ? onApply(field, raw) : Promise.resolve())} + /> + + + {field.name} {hexAddr(field.addr)} + +
+ ); +} diff --git a/src/components/health-card.tsx b/src/components/health-card.tsx index 54b1098..0d05de6 100644 --- a/src/components/health-card.tsx +++ b/src/components/health-card.tsx @@ -1,12 +1,12 @@ -import type { Health, OscClient } from "@openservocore/client"; +import type { Health } from "@openservocore/client"; import { CircleAlert, CircleCheck, HeartPulse, TriangleAlert } from "lucide-react"; import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; -import { queued } from "@/lib/command"; import { countersLine, statements, trimLine, type Level } from "@/lib/health"; +import { useSession } from "@/lib/session"; const POLL_MS = 1000; @@ -17,7 +17,8 @@ const tone: Record = { ok: "text-success", }; -export function HealthCard({ client, id }: { client: OscClient; id: number }) { +export function HealthCard({ id }: { id: number }) { + const { run } = useSession(); const [health, setHealth] = useState(); const [error, setError] = useState(); const [clearing, setClearing] = useState(false); @@ -31,7 +32,7 @@ export function HealthCard({ client, id }: { client: OscClient; id: number }) { if (busy || !live) return; busy = true; try { - const read = await queued(() => client.health(id)); + const read = await run((c) => c.health(id)); if (live) { setHealth(read); setError(undefined); @@ -48,13 +49,13 @@ export function HealthCard({ client, id }: { client: OscClient; id: number }) { live = false; window.clearInterval(timer); }; - }, [client, id]); + }, [run, id]); async function clear() { setClearing(true); try { - await queued(() => client.clearCounters(id)); - setHealth(await queued(() => client.health(id))); + await run((c) => c.clearCounters(id)); + setHealth(await run((c) => c.health(id))); setError(undefined); } catch (e) { setError(e instanceof Error ? e.message : String(e)); diff --git a/src/components/value-editor.tsx b/src/components/value-editor.tsx index a1ef8fb..9e918e5 100644 --- a/src/components/value-editor.tsx +++ b/src/components/value-editor.tsx @@ -11,6 +11,7 @@ import { toRaw, type EditValue, type FieldKind, + type NumberDisplay, } from "@/lib/edit"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -30,13 +31,29 @@ interface ValueEditorProps { field: Field; value: EditValue; onApply: (raw: EditValue) => Promise; + display?: NumberDisplay; + /** The page's own rule over a parsed edit; its message keeps Apply disabled. */ + validate?: (raw: EditValue) => string | undefined; disabled?: string; } const valueClass = "rounded-sm px-1 font-mono tabular-nums"; -export function ValueEditor({ field, value, onApply, disabled }: ValueEditorProps) { - const kind = fieldKind(field); +function rawOf(kind: FieldKind, value: EditValue): EditValue { + return kind.kind === "number" && typeof value === "number" ? toRaw(kind, value) : value; +} + +export function ValueEditor({ + field, + value, + onApply, + display, + validate, + disabled, +}: ValueEditorProps) { + const base = fieldKind(field); + const kind: FieldKind = + base.kind === "number" && display !== undefined ? { ...base, ...display } : base; const label = formatValue(kind, value); const inputId = useId(); const [open, setOpen] = useState(false); @@ -58,7 +75,8 @@ export function ValueEditor({ field, value, onApply, disabled }: ValueEditorProp } const parsed = parseInput(kind, text); - const message = parsed.ok ? error : parsed.reason; + const issue = parsed.ok ? validate?.(rawOf(kind, parsed.value)) : parsed.reason; + const message = issue ?? error; function edit(next: string) { setText(next); @@ -74,14 +92,10 @@ export function ValueEditor({ field, value, onApply, disabled }: ValueEditorProp } async function apply() { - if (!parsed.ok || pending) return; + if (!parsed.ok || issue !== undefined || pending) return; setPending(true); try { - const raw = - kind.kind === "number" && typeof parsed.value === "number" - ? toRaw(kind, parsed.value) - : parsed.value; - await onApply(raw); + await onApply(rawOf(kind, parsed.value)); setOpen(false); } catch (e) { setError(e instanceof Error ? e.message : String(e)); @@ -115,7 +129,13 @@ export function ValueEditor({ field, value, onApply, disabled }: ValueEditorProp {field.name} {hexAddr(field.addr)} - + {kind.kind === "number" && rangeHint(kind) !== undefined && (

{rangeHint(kind)}

)} @@ -134,7 +154,7 @@ export function ValueEditor({ field, value, onApply, disabled }: ValueEditorProp + ); + + return ( + + + + + Manage + + + +
+ +
+ { + setText(e.target.value); + }} + /> + +
+ +
+ +
+
+ {torque === true ? ( + // The disabled button drops pointer events, so the wrapper hosts the tooltip. + + +
{saveButton}
+
+ {TORQUE_ON} +
+ ) : ( + saveButton + )} + +
+ +
+ + + +
+ + Danger zone + +
+ +
+ + {confirming && ( +
) => { + if (e.key !== "Escape") return; + e.stopPropagation(); + setConfirming(false); + }} + > +

+ ID {id} loses its settings and calibration, then comes back as ID 1 at 1 M. +

+
+ + +
+
+ )} +
+
+
+ ); +} + +function Hint({ note }: { note: Note }) { + return

{note.text}

; +} + +/** The Save gate: `torque_enable` read off the table on the cards' cadence. */ +function useTorque(id: number, descriptor: Descriptor | undefined): boolean | undefined { + const { run } = useSession(); + const [on, setOn] = useState(); + + useEffect(() => { + if (descriptor === undefined) return; + const table = descriptor; + const field = table.fields().find((f) => f.name === "torque_enable"); + if (field === undefined) return; + const { name, addr, width } = field; + let live = true; + let busy = false; + async function poll() { + if (busy || !live) return; + busy = true; + try { + const bytes = await run((c) => c.read(id, addr, width)); + const value = table.decode(name, bytes); + if (live && value.kind === "bool") setOn(value.value); + } catch { + // The gate holds its last reading; Health reports a servo gone quiet. + } finally { + busy = false; + } + } + void poll(); + const timer = window.setInterval(() => void poll(), POLL_MS); + return () => { + live = false; + window.clearInterval(timer); + }; + }, [run, id, descriptor]); + + return on; +} diff --git a/src/lib/manage.test.ts b/src/lib/manage.test.ts new file mode 100644 index 0000000..f8d42c9 --- /dev/null +++ b/src/lib/manage.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "vitest"; +import { ID_MAX, ID_MIN, idIssue, rescans, selectAfter, type ManageAction } from "./manage"; + +const ACTIONS: ManageAction[] = ["assign", "save", "reboot", "factory"]; + +test("an id inside the range, unused and not the current one, is accepted", () => { + expect(idIssue(7, 2, [1, 2])).toBeUndefined(); + expect(idIssue(ID_MIN, 2, [2])).toBeUndefined(); + expect(idIssue(ID_MAX, 2, [2])).toBeUndefined(); +}); + +test("an id outside 1 to 249 is refused", () => { + expect(idIssue(0, 2, [2])).toBe("Ids are 1 to 249."); + expect(idIssue(250, 2, [2])).toBe("Ids are 1 to 249."); + expect(idIssue(-1, 2, [2])).toBe("Ids are 1 to 249."); +}); + +test("a fractional or unparsed id is refused before the range", () => { + expect(idIssue(NaN, 2, [2])).toBe("Ids are whole numbers."); + expect(idIssue(7.5, 2, [2])).toBe("Ids are whole numbers."); +}); + +test("the servo's own id is refused, and so is one another servo answers at", () => { + expect(idIssue(2, 2, [1, 2])).toBe("This servo is already ID 2."); + expect(idIssue(1, 2, [1, 2])).toBe("ID 1 is taken by another servo."); + expect(idIssue(1, 2, [2])).toBeUndefined(); +}); + +test("every action but Save rescans", () => { + expect(ACTIONS.filter(rescans)).toEqual(["assign", "reboot", "factory"]); +}); + +test("only Assign re-selects, onto the id it handed out", () => { + expect(selectAfter("assign", 7)).toBe(7); + for (const action of ACTIONS.filter((a) => a !== "assign")) { + expect(selectAfter(action, 7)).toBeUndefined(); + } +}); diff --git a/src/lib/manage.ts b/src/lib/manage.ts new file mode 100644 index 0000000..0f01f55 --- /dev/null +++ b/src/lib/manage.ts @@ -0,0 +1,31 @@ +/** The ids a unicast servo may take (protocol sec 9.2). */ +export const ID_MIN = 1; +export const ID_MAX = 249; + +export type ManageAction = "assign" | "save" | "reboot" | "factory"; + +/** Why the bus would refuse `next` as this servo's id; undefined means it takes it. */ +export function idIssue( + next: number, + current: number, + fleet: readonly number[], +): string | undefined { + if (!Number.isInteger(next)) return "Ids are whole numbers."; + if (next < ID_MIN || next > ID_MAX) return `Ids are ${ID_MIN} to ${ID_MAX}.`; + if (next === current) return `This servo is already ID ${current}.`; + if (fleet.includes(next)) return `ID ${next} is taken by another servo.`; + return undefined; +} + +/** Save only touches flash; the other three change who answers, so the roster is re-read. */ +export function rescans(action: ManageAction): boolean { + return action !== "save"; +} + +/** + * Only Assign moves the selection, onto the id it handed out. Reboot and + * Factory leave it where it is: the rescan alone says whether it still answers. + */ +export function selectAfter(action: ManageAction, newId: number): number | undefined { + return action === "assign" ? newId : undefined; +} diff --git a/src/routes/servo.tsx b/src/routes/servo.tsx index a974871..b7a7fb8 100644 --- a/src/routes/servo.tsx +++ b/src/routes/servo.tsx @@ -3,13 +3,17 @@ import { Cog } from "lucide-react"; import { AboutCard } from "@/components/about-card"; import { CalibrationCard } from "@/components/calibration-card"; import { HealthCard } from "@/components/health-card"; +import { ManageCard } from "@/components/manage-card"; import { useSession } from "@/lib/session"; export const Route = createFileRoute("/servo")({ component: ServoPage }); function ServoPage() { const { status, client, servos, selected } = useSession(); - const servo = status === "ready" ? servos.find((s) => s.id === selected) : undefined; + // A rescan keeps the page on its servo: falling back to the placeholder + // would unmount the cards, and with them the feedback an action just landed. + const live = status === "ready" || status === "scanning"; + const servo = live ? servos.find((s) => s.id === selected) : undefined; if (client === undefined || servo === undefined) { return

Pick a servo in the left pane.

; } @@ -23,6 +27,7 @@ function ServoPage() { + ); diff --git a/tests/e2e/manage.spec.ts b/tests/e2e/manage.spec.ts new file mode 100644 index 0000000..65b7777 --- /dev/null +++ b/tests/e2e/manage.spec.ts @@ -0,0 +1,70 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; +import { gotoSim } from "./helpers"; + +// The simulated servos run the production stacks over a bus with no config +// store behind them, so SAVE and FACTORY reach the servo and come back +// `hardware` (firmware dispatch.rs: both need a store to write). REBOOT and +// ASSIGN need no flash and land for real. +const NO_FLASH = "servo answered Hardware"; + +async function openManage(page: Page, id: number): Promise { + await gotoSim(page, [1, 2]); + await page.getByRole("button", { name: `ID ${id}` }).click(); + await expect(page.getByRole("heading", { name: `ID ${id}` })).toBeVisible(); + return page.getByRole("region", { name: "Manage" }); +} + +test("a new id is assigned by serial and the page follows the servo onto it", async ({ page }) => { + const card = await openManage(page, 2); + await card.getByRole("spinbutton", { name: "Servo id" }).fill("7"); + await card.getByRole("button", { name: "Assign" }).click(); + + await expect(page.getByRole("heading", { name: "ID 7" })).toBeVisible(); + await expect(page.getByRole("button", { name: "ID 1" })).toBeVisible(); + await expect(page.getByRole("button", { name: "ID 7" })).toBeVisible(); + await expect(page.getByRole("button", { name: "ID 2" })).toHaveCount(0); +}); + +test("an id the fleet already answers at is refused", async ({ page }) => { + const card = await openManage(page, 2); + await card.getByRole("spinbutton", { name: "Servo id" }).fill("1"); + await expect(card.getByText("ID 1 is taken by another servo.")).toBeVisible(); + await expect(card.getByRole("button", { name: "Assign" })).toBeDisabled(); +}); + +test("Reboot rescans and the servo answers again", async ({ page }) => { + const card = await openManage(page, 2); + await card.getByRole("button", { name: "Reboot" }).click(); + await expect(card.getByText("Rebooting .. back in a moment.")).toBeVisible(); + await expect(page.getByRole("button", { name: "ID 2" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "ID 2" })).toBeVisible(); +}); + +test("Factory reset runs behind the confirm strip", async ({ page }) => { + const card = await openManage(page, 2); + const erase = card.getByRole("button", { name: "Erase" }); + + await card.getByRole("button", { name: "Factory reset" }).click(); + await expect(erase).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(erase).toBeHidden(); + + await card.getByRole("button", { name: "Factory reset" }).click(); + await card.getByRole("button", { name: "Cancel" }).click(); + await expect(erase).toBeHidden(); + + await card.getByRole("button", { name: "Factory reset" }).click(); + await erase.click(); + await expect(erase).toBeHidden(); + await expect(card.getByText(NO_FLASH)).toBeVisible(); + await expect(page.getByRole("button", { name: "ID 2" })).toBeVisible(); +}); + +test("Save settings reaches the servo while torque is off", async ({ page }) => { + const card = await openManage(page, 2); + const save = card.getByRole("button", { name: "Save settings" }); + await expect(save).toBeEnabled(); + await save.click(); + await expect(card.getByText(NO_FLASH)).toBeVisible(); + await expect(page.getByRole("button", { name: "ID 2" })).toBeVisible(); +});