diff --git a/src/components/about-card.tsx b/src/components/about-card.tsx index f578b7d..3e224be 100644 --- a/src/components/about-card.tsx +++ b/src/components/about-card.tsx @@ -1,24 +1,13 @@ -import { unpackVersion, type Identity } from "@openservocore/client"; +import { unpackVersion } from "@openservocore/client"; import { Cog } from "lucide-react"; -import { useEffect, useState, type ReactNode } from "react"; +import 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 { useReadOnce } from "@/lib/bus/hooks"; 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"]; - -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(", "); -} +import { features, identityFrom, IDENTITY_REGISTERS } from "@/lib/identity"; function Row({ label, children }: { label: string; children: ReactNode }) { return ( @@ -30,24 +19,8 @@ function Row({ label, children }: { label: string; children: ReactNode }) { } export function AboutCard({ id, uid }: { id: number; uid: string }) { - const { run } = useSession(); - const [identity, setIdentity] = useState(); - const [error, setError] = useState(); - - useEffect(() => { - let live = true; - void (async () => { - try { - const read = await run((c) => c.identity(id)); - if (live) setIdentity(read); - } catch (e) { - if (live) setError(e instanceof Error ? e.message : String(e)); - } - })(); - return () => { - live = false; - }; - }, [run, id]); + const { snapshot, error } = useReadOnce(id, IDENTITY_REGISTERS); + const identity = snapshot === undefined ? undefined : identityFrom(snapshot.read); const name = identity === undefined ? undefined : modelName(identity.model); return ( diff --git a/src/components/calibration-card.tsx b/src/components/calibration-card.tsx index 215f006..2bafe0b 100644 --- a/src/components/calibration-card.tsx +++ b/src/components/calibration-card.tsx @@ -1,6 +1,6 @@ -import type { Field, OscClient } from "@openservocore/client"; +import type { Field } from "@openservocore/client"; import { Check, CircleQuestionMark, Ruler } from "lucide-react"; -import { useId, useState } from "react"; +import { useId } from "react"; import { ValueEditor } from "@/components/value-editor"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -8,39 +8,22 @@ import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/componen import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useBus } from "@/lib/bus/hooks"; import { CALIBRATION_REGISTERS, editReason, type CalibrationRegister } from "@/lib/calibration"; -import { decodeSpan, span } from "@/lib/bus/spans"; 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)); -} +import { calibrationStatus, type Calibration } from "@/lib/units"; 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 { descriptor, descriptorError, values, refreshConstants } = useSession(); + const bus = useBus(); + const cal = 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); + await bus.write(id, field.name, { kind: field.kind === "int" ? "int" : "uint", value: raw }); + // The write dirties the CALIB span; the session re-reads the constants + // every card on the page shows. refreshConstants(uid); } diff --git a/src/components/connection-popover.tsx b/src/components/connection-popover.tsx index 0db8c3d..b84dc9e 100644 --- a/src/components/connection-popover.tsx +++ b/src/components/connection-popover.tsx @@ -46,12 +46,12 @@ export function ConnectionPopover({ children: ReactNode; }) { const session = useSession(); - const { status, client, simulated, baud, rails, missing, error } = session; + const { status, linkInfo, simulated, baud, rails, missing, error } = session; const [confirming, setConfirming] = useState(false); const [choice, setChoice] = useState(); const [railsError, setRailsError] = useState(); const rate = choice ?? baud ?? "b1000000"; - const connected = client !== undefined; + const connected = linkInfo !== undefined; const ready = status === "ready"; const badge = chip(status); @@ -92,8 +92,8 @@ export function ConnectionPopover({ {simulated ? "Simulated adapter" : "osc-adapter"} {badge.text} - {connected && ( - Firmware {client.linkInfo().version} + {linkInfo !== undefined && ( + Firmware {linkInfo.version} )} diff --git a/src/components/health-card.tsx b/src/components/health-card.tsx index 0d05de6..a24405e 100644 --- a/src/components/health-card.tsx +++ b/src/components/health-card.tsx @@ -1,14 +1,12 @@ -import type { Health } from "@openservocore/client"; import { CircleAlert, CircleCheck, HeartPulse, TriangleAlert } from "lucide-react"; -import { useEffect, useState } from "react"; +import { 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 { useBus, useRegisters } from "@/lib/bus/hooks"; +import { healthFrom, HEALTH_REGISTERS } from "@/lib/bus/spans"; import { countersLine, statements, trimLine, type Level } from "@/lib/health"; -import { useSession } from "@/lib/session"; - -const POLL_MS = 1000; const icons = { fault: CircleAlert, warn: TriangleAlert, ok: CircleCheck }; const tone: Record = { @@ -18,44 +16,22 @@ const tone: Record = { }; export function HealthCard({ id }: { id: number }) { - const { run } = useSession(); - const [health, setHealth] = useState(); + const bus = useBus(); + const snapshot = useRegisters(id, HEALTH_REGISTERS, "slow"); 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 run((c) => c.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); - }; - }, [run, id]); + // A stale snapshot carries what the cache still holds, which may be nothing. + const complete = + snapshot !== undefined && HEALTH_REGISTERS.every((name) => snapshot.values.has(name)); + const health = complete ? healthFrom(snapshot.read) : undefined; + const problem = error ?? (snapshot?.stale === true ? snapshot.error : undefined); async function clear() { setClearing(true); try { - await run((c) => c.clearCounters(id)); - setHealth(await run((c) => c.health(id))); + // The counters come back on the subscription's next read. + await bus.command((c) => c.clearCounters(id)); setError(undefined); } catch (e) { setError(e instanceof Error ? e.message : String(e)); @@ -73,7 +49,7 @@ export function HealthCard({ id }: { id: number }) { - {error !== undefined &&

{error}

} + {problem !== undefined &&

{problem}

} {health === undefined ? ( <> diff --git a/src/components/manage-card.tsx b/src/components/manage-card.tsx index 430802e..93b9671 100644 --- a/src/components/manage-card.tsx +++ b/src/components/manage-card.tsx @@ -1,4 +1,4 @@ -import type { Descriptor, OscClient } from "@openservocore/client"; +import type { OscClient } from "@openservocore/client"; import { LoaderCircle, RotateCcw, Save, SlidersHorizontal, TriangleAlert } from "lucide-react"; import { useEffect, useId, useState, type KeyboardEvent } from "react"; import { Button } from "@/components/ui/button"; @@ -7,11 +7,13 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useBus, useRegisters } from "@/lib/bus/hooks"; import { ID_MAX, ID_MIN, idIssue, rescans, selectAfter, type ManageAction } from "@/lib/manage"; import { useSession } from "@/lib/session"; -const POLL_MS = 1000; const NOTE_MS = 4000; +/** Inside the cards' span on every servo, so the gate costs no exchange. */ +const TORQUE_REGISTERS: readonly string[] = ["torque_enable"]; const HELP = { id: "Addressed by serial, so it also fixes two servos sharing an id. Ids 1 to 249.", @@ -31,13 +33,14 @@ interface Note { } export function ManageCard({ id, uid }: { id: number; uid: string }) { - const { servos, descriptor, run, discover, select } = useSession(); + const { servos, discover, select } = useSession(); + const bus = useBus(); const inputId = useId(); const [text, setText] = useState(String(id)); const [busy, setBusy] = useState(); const [note, setNote] = useState(); const [confirming, setConfirming] = useState(false); - const torque = useTorque(id, descriptor); + const torque = useTorque(id); useEffect(() => { if (note === undefined || note.bad) return; @@ -70,7 +73,7 @@ export function ManageCard({ id, uid }: { id: number; uid: string }) { setBusy(action); setNote(undefined); try { - await run(fn); + await bus.command(fn); if (rescans(action)) await discover(); const pick = selectAfter(action, newId); if (pick !== undefined) select(pick); @@ -246,39 +249,11 @@ 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; +/** + * The Save gate: `torque_enable` on the cards' cadence. A stale snapshot still + * carries the last reading; Health reports a servo gone quiet. + */ +function useTorque(id: number): boolean | undefined { + const value = useRegisters(id, TORQUE_REGISTERS, "slow")?.values.get("torque_enable"); + return value?.kind === "bool" ? value.value : undefined; } diff --git a/src/lib/identity.test.ts b/src/lib/identity.test.ts new file mode 100644 index 0000000..9bafcb6 --- /dev/null +++ b/src/lib/identity.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "vitest"; +import { features, identityFrom, IDENTITY_REGISTERS } from "./identity"; + +test("identityFrom reads the four identity registers", () => { + const table = new Map([ + ["model_number", 0x0101], + ["firmware_version", 0x0100], + ["hardware_revision", 3], + ["capability_flags", 1], + ]); + const names: string[] = []; + const read = (name: string): number => { + names.push(name); + const v = table.get(name); + if (v === undefined) throw new Error(`${name} outside the read`); + return v; + }; + expect(identityFrom(read)).toEqual({ model: 0x0101, fw: 0x0100, hw: 3, capabilities: 1 }); + expect(new Set(names)).toEqual(new Set(IDENTITY_REGISTERS)); +}); + +test("features names the known bits and numbers the rest", () => { + expect(features(0)).toBe("None"); + expect(features(1)).toBe("Motor encoder"); + expect(features(0b101)).toBe("Motor encoder, bit 2"); +}); diff --git a/src/lib/identity.ts b/src/lib/identity.ts new file mode 100644 index 0000000..8166340 --- /dev/null +++ b/src/lib/identity.ts @@ -0,0 +1,31 @@ +import type { Identity } from "@openservocore/client"; +import type { ReadRegister } from "./units"; + +/** CONFIG-COMMON front: what a servo says about itself, read once per servo. */ +export const IDENTITY_REGISTERS: readonly string[] = [ + "model_number", + "firmware_version", + "hardware_revision", + "capability_flags", +]; + +export function identityFrom(read: ReadRegister): Identity { + return { + model: read("model_number"), + fw: read("firmware_version"), + hw: read("hardware_revision"), + capabilities: read("capability_flags"), + }; +} + +/** `capability_flags` bit order (protocol sec 5.4). */ +const CAPABILITIES: readonly string[] = ["Motor encoder"]; + +export 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(", "); +} diff --git a/src/lib/session.tsx b/src/lib/session.tsx index 81fa7bb..261f5f5 100644 --- a/src/lib/session.tsx +++ b/src/lib/session.tsx @@ -3,6 +3,7 @@ import { type BaudRate, type Descriptor, type Found, + type LinkInfo, type OscClient, type Ping, type Rails, @@ -48,7 +49,8 @@ export interface Session { error: string | undefined; baud: BaudRate | undefined; rails: Rails | undefined; - client: OscClient | undefined; + /** The open adapter's link info; undefined means no client. */ + linkInfo: LinkInfo | undefined; simulated: boolean; servos: Servo[]; selected: number | undefined; @@ -66,12 +68,6 @@ export interface Session { setRails: (patch: Partial) => Promise; setBaud: (rate: BaudRate) => Promise; select: (id: number | undefined) => void; - /** - * The bus manager's control lane under the name the pages still use: a - * command waits for at most the exchange in flight, and the client's - * "busy" can never surface. - */ - run: (fn: (client: OscClient) => Promise) => Promise; /** Re-reads a servo's CALIB constants, after a calibration write. */ refreshConstants: (uid: string) => void; } @@ -87,6 +83,7 @@ interface Model { interface Snap { state: SessionState; client: OscClient | undefined; + linkInfo: LinkInfo | undefined; simulated: boolean; models: ReadonlyMap; layoutErrors: ReadonlyMap; @@ -96,6 +93,7 @@ interface Snap { const initial: Snap = { state: idle, client: undefined, + linkInfo: undefined, simulated: false, models: new Map(), layoutErrors: new Map(), @@ -168,9 +166,6 @@ class Controller { this.set({ state: reduce(this.snap.state, event) }); } - // Stable so a card's effect can depend on it. - readonly run = (fn: (client: OscClient) => Promise): Promise => this.bus.command(fn); - refreshConstants(uid: string): void { const servo = this.snap.state.servos.find((s) => s.uid === uid); if (servo === undefined) return; @@ -239,7 +234,7 @@ class Controller { this.dispatch({ type: "fail", error: message(e) }); return; } - this.set({ client, simulated: simRequested() }); + this.set({ client, linkInfo: client.linkInfo(), simulated: simRequested() }); this.bus.attach(client, this.layoutById); await this.scan(client); } @@ -277,7 +272,7 @@ class Controller { return; } if (this.snap.client !== client) return; - this.set({ client: undefined, simulated: false }); + this.set({ client: undefined, linkInfo: undefined, simulated: false }); this.clearCards(); this.dispatch({ type: "fail", error: message(failure) }); await release(client); @@ -393,7 +388,7 @@ class Controller { async disconnect(): Promise { const { client } = this.snap; if (client === undefined) return; - this.set({ client: undefined, simulated: false }); + this.set({ client: undefined, linkInfo: undefined, simulated: false }); this.clearCards(); this.dispatch({ type: "disconnect" }); // Detaching from inside an exclusive turn: the exchange in flight has @@ -440,7 +435,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { error: snap.state.error, baud: snap.state.baud, rails: snap.state.rails, - client: snap.client, + linkInfo: snap.linkInfo, simulated: snap.simulated, servos: snap.state.servos.map((s) => withHealth(s, snap.values.get(s.uid))), selected: snap.state.selected, @@ -459,7 +454,6 @@ export function SessionProvider({ children }: { children: ReactNode }) { select: (id) => { ctl.select(id); }, - run: ctl.run, refreshConstants: (uid) => { ctl.refreshConstants(uid); }, diff --git a/src/lib/table-live.test.ts b/src/lib/table-live.test.ts deleted file mode 100644 index b84a1e1..0000000 --- a/src/lib/table-live.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { afterEach, beforeEach, expect, test, vi } from "vitest"; -import { spanHolding, startPoll } from "./table-live"; - -const spans = [ - { addr: 0, count: 9 }, - { addr: 16, count: 4 }, -]; - -test("spanHolding picks the span the field lies inside", () => { - expect(spanHolding(spans, { addr: 2, width: 2 })).toBe(spans[0]); - expect(spanHolding(spans, { addr: 16, width: 4 })).toBe(spans[1]); -}); - -test("spanHolding falls back to the field's own extent when no span holds all of it", () => { - expect(spanHolding(spans, { addr: 8, width: 2 })).toEqual({ addr: 8, count: 2 }); - expect(spanHolding([], { addr: 30, width: 1 })).toEqual({ addr: 30, count: 1 }); -}); - -/** A tick whose completion the test controls. */ -function gate(): { tick: () => Promise; release: () => void; calls: number } { - const resolvers: (() => void)[] = []; - const g = { - calls: 0, - tick: () => { - g.calls++; - return new Promise((resolve) => resolvers.push(resolve)); - }, - release: () => { - for (const r of resolvers.splice(0)) r(); - }, - }; - return g; -} - -beforeEach(() => { - vi.useFakeTimers(); -}); - -afterEach(() => { - vi.useRealTimers(); -}); - -test("ticks at once, then once per period", async () => { - const g = gate(); - const stop = startPoll({ periodMs: 100, tick: g.tick, onError: () => undefined }); - expect(g.calls).toBe(1); - g.release(); - await vi.advanceTimersByTimeAsync(100); - expect(g.calls).toBe(2); - g.release(); - await vi.advanceTimersByTimeAsync(100); - expect(g.calls).toBe(3); - stop(); -}); - -test("a tick still pending is not overlapped; the next period after it resolves ticks", async () => { - const g = gate(); - const stop = startPoll({ periodMs: 100, tick: g.tick, onError: () => undefined }); - await vi.advanceTimersByTimeAsync(350); - expect(g.calls).toBe(1); - g.release(); - await vi.advanceTimersByTimeAsync(100); - expect(g.calls).toBe(2); - stop(); -}); - -test("a hold skips ticks until it lifts", async () => { - const g = gate(); - let held = true; - const stop = startPoll({ - periodMs: 100, - tick: g.tick, - hold: () => held, - onError: () => undefined, - }); - await vi.advanceTimersByTimeAsync(300); - expect(g.calls).toBe(0); - held = false; - await vi.advanceTimersByTimeAsync(100); - expect(g.calls).toBe(1); - stop(); -}); - -test("stop ends the ticks and silences a late error", async () => { - const errors: unknown[] = []; - let reject: (e: Error) => void = () => undefined; - const tick = vi.fn( - () => - new Promise((_, rej) => { - reject = rej; - }), - ); - const stop = startPoll({ periodMs: 100, tick, onError: (e) => errors.push(e) }); - stop(); - reject(new Error("late")); - await vi.advanceTimersByTimeAsync(500); - expect(tick).toHaveBeenCalledTimes(1); - expect(errors).toEqual([]); -}); - -test("an error is reported and polling goes on", async () => { - const errors: unknown[] = []; - let fail = true; - const tick = vi.fn(() => (fail ? Promise.reject(new Error("bus")) : Promise.resolve())); - const stop = startPoll({ periodMs: 100, tick, onError: (e) => errors.push(e) }); - await vi.advanceTimersByTimeAsync(0); - expect(errors).toHaveLength(1); - fail = false; - await vi.advanceTimersByTimeAsync(200); - expect(tick).toHaveBeenCalledTimes(3); - expect(errors).toHaveLength(1); - stop(); -}); diff --git a/src/lib/table-live.ts b/src/lib/table-live.ts deleted file mode 100644 index 0115451..0000000 --- a/src/lib/table-live.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { Field } from "@openservocore/client"; -import type { Span } from "./table-read"; - -export const LIVE_POLL_MS = 1000; -export const FLASH_MS = 800; - -/** The span a re-read after a write covers: the one holding `field`, else the field alone. */ -export function spanHolding(spans: readonly Span[], field: Pick): Span { - const end = field.addr + field.width; - return ( - spans.find((s) => s.addr <= field.addr && end <= s.addr + s.count) ?? { - addr: field.addr, - count: field.width, - } - ); -} - -export interface PollOptions { - periodMs: number; - /** One read pass. */ - tick: () => Promise; - /** While true a tick is skipped: an edit is in flight and the next tick picks up its result. */ - hold?: () => boolean; - onError: (error: unknown) => void; -} - -/** - * Ticks at once and then every `periodMs`, skipping a tick while the previous - * one is still pending or `hold` is set. An error is reported and polling goes - * on; only the returned stop ends it. - */ -export function startPoll(o: PollOptions): () => void { - let pending = false; - let stopped = false; - const tick = (): void => { - if (stopped || pending || o.hold?.() === true) return; - pending = true; - o.tick() - .catch((e: unknown) => { - if (!stopped) o.onError(e); - }) - .finally(() => { - pending = false; - }); - }; - const timer = setInterval(tick, o.periodMs); - tick(); - return () => { - stopped = true; - clearInterval(timer); - }; -} diff --git a/src/lib/table-model.test.ts b/src/lib/table-model.test.ts index c3addce..ada71bd 100644 --- a/src/lib/table-model.test.ts +++ b/src/lib/table-model.test.ts @@ -1,8 +1,9 @@ -import type { Field, Variant } from "@openservocore/client"; +import type { Field, Value, Variant } from "@openservocore/client"; import { readFileSync } from "node:fs"; import { describe, expect, test } from "vitest"; import { buildTable, + editValues, formatRow, labelParts, matchRows, @@ -280,3 +281,23 @@ describe("search", () => { ); }); }); + +test("editValues keeps every decoded register under its own kind", () => { + const blob = new Uint8Array([1, 2]); + const values = editValues( + new Map([ + ["pos", { kind: "uint", value: 2048 }], + ["trim_steps", { kind: "int", value: -3 }], + ["torque_enable", { kind: "bool", value: true }], + ["mode", { kind: "enum", value: 2 }], + ["words", { kind: "bytes", value: blob }], + ]), + ); + expect([...values]).toEqual([ + ["pos", 2048], + ["trim_steps", -3], + ["torque_enable", true], + ["mode", 2], + ["words", blob], + ]); +}); diff --git a/src/lib/table-model.ts b/src/lib/table-model.ts index 2c2e9be..b435b8d 100644 --- a/src/lib/table-model.ts +++ b/src/lib/table-model.ts @@ -1,4 +1,4 @@ -import type { Descriptor, Field } from "@openservocore/client"; +import type { Descriptor, Field, Value } from "@openservocore/client"; import { fieldKind, formatValue, type EditValue, type FieldKind } from "./edit"; import { hexAddr } from "./format"; @@ -214,6 +214,15 @@ export function buildTable(descriptor: Pick): TableModel { return { tabs }; } +export type Values = ReadonlyMap; + +/** A read's decoded registers as the values the rows show and edit. */ +export function editValues(values: ReadonlyMap): Values { + const rows = new Map(); + for (const [name, value] of values) rows.set(name, value.value); + return rows; +} + export function summarizeBlob(bytes: Uint8Array): string { const size = `${bytes.length} byte${bytes.length === 1 ? "" : "s"}`; if (bytes.length === 0) return size; diff --git a/src/lib/table-read.test.ts b/src/lib/table-read.test.ts deleted file mode 100644 index 6fc8fa1..0000000 --- a/src/lib/table-read.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { Field, Value } from "@openservocore/client"; -import { expect, test } from "vitest"; -import descriptor from "../../../open-servo-core/descriptors/osc-servo/0.1.json"; -import { buildTable, type Row } from "./table-model"; -import { decodeSpan, MAX_READ, readRows, readSpans } from "./table-read"; - -const fields = descriptor.fields as Field[]; -const model = buildTable({ fields: () => fields }); - -function tabRows(name: string): Row[] { - const tab = model.tabs.find((t) => t.name === name); - if (tab === undefined) throw new Error(`no tab ${name}`); - return tab.groups.flatMap((g) => g.rows); -} - -function row(name: string, addr: number, width: number, kind: Field["kind"] = "uint"): Row { - const field: Field = { name, addr, width, access: "rw", kind, variants: [] }; - return { field, label: name, kind: { kind: "raw", width }, editable: true, blob: false }; -} - -test("the real descriptor's tabs read in a handful of spans each", () => { - expect(readSpans(tabRows("Settings"))).toEqual([ - { addr: 0, count: 9 }, - { addr: 16, count: 4 }, - { addr: 32, count: 59 }, - { addr: 92, count: 25 }, - ]); - expect(readSpans(tabRows("Calibration"))).toEqual([ - { addr: 128, count: 114 }, - { addr: 282, count: 6 }, - ]); - expect(readSpans(tabRows("Board"))).toEqual([ - { addr: 242, count: 40 }, - { addr: 288, count: 12 }, - ]); - expect(readSpans(tabRows("Live values"))).toHaveLength(9); -}); - -test("contiguous fields merge whatever their order, a gap splits", () => { - const rows = [row("c", 6, 2), row("a", 0, 4), row("b", 4, 2), row("d", 10, 1)]; - expect(readSpans(rows)).toEqual([ - { addr: 0, count: 8 }, - { addr: 10, count: 1 }, - ]); -}); - -test("a span stops at the protocol payload cap", () => { - const rows = [row("a", 0, 200), row("b", 200, 52), row("c", 252, 1)]; - expect(readSpans(rows)).toEqual([ - { addr: 0, count: MAX_READ }, - { addr: 252, count: 1 }, - ]); - expect(readSpans([row("a", 0, 200), row("b", 200, 53)])).toEqual([ - { addr: 0, count: 200 }, - { addr: 200, count: 53 }, - ]); -}); - -/** Records the slice each field got; the value is the slice's first byte. */ -function recorder(): { decode: (name: string, bytes: Uint8Array) => Value; slices: string[] } { - const slices: string[] = []; - return { - slices, - decode: (name, bytes) => { - slices.push(`${name}:${Array.from(bytes).join(",")}`); - return { kind: "uint", value: bytes[0] ?? -1 }; - }, - }; -} - -test("decodeSpan slices each field at its offset and skips rows outside the span", () => { - const rows = [row("a", 10, 1), row("b", 11, 2), row("far", 20, 1)]; - const d = recorder(); - const values = decodeSpan(d, rows, { addr: 10, count: 3 }, new Uint8Array([7, 8, 9])); - expect(d.slices).toEqual(["a:7", "b:8,9"]); - expect([...values]).toEqual([ - ["a", 7], - ["b", 8], - ]); -}); - -test("decodeSpan rejects a short read", () => { - const d = recorder(); - expect(() => decodeSpan(d, [row("a", 4, 2)], { addr: 4, count: 2 }, new Uint8Array(1))).toThrow( - "read at 0x004 returned 1 of 2 bytes", - ); -}); - -test("readRows reads the spans in order, one at a time", async () => { - const rows = [row("a", 0, 1), row("b", 1, 1), row("c", 5, 1)]; - const d = recorder(); - const calls: string[] = []; - let pending = 0; - const values = await readRows(rows, d, async (addr, count) => { - expect(pending).toBe(0); - pending++; - calls.push(`${addr}+${count}`); - await Promise.resolve(); - pending--; - return Uint8Array.from({ length: count }, (_, i) => addr + i); - }); - expect(calls).toEqual(["0+2", "5+1"]); - expect([...values]).toEqual([ - ["a", 0], - ["b", 1], - ["c", 5], - ]); -}); diff --git a/src/lib/table-read.ts b/src/lib/table-read.ts deleted file mode 100644 index 7980f69..0000000 --- a/src/lib/table-read.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { Descriptor } from "@openservocore/client"; -import type { EditValue } from "./edit"; -import { hexAddr } from "./format"; -import type { Row } from "./table-model"; - -/** A READ payload is at most 252 bytes (protocol sec 5.1). */ -export const MAX_READ = 252; - -export interface Span { - addr: number; - count: number; -} - -export type Values = ReadonlyMap; - -/** - * The reads covering `rows`: fields in address order, each joined to the - * span before it when it starts inside or right after that span and keeps - * it within MAX_READ. A gap between fields starts a new span. - */ -export function readSpans(rows: readonly Row[]): Span[] { - const fields = rows.map((r) => r.field).sort((a, b) => a.addr - b.addr); - const spans: Span[] = []; - for (const f of fields) { - const last = spans.at(-1); - const end = f.addr + f.width; - if (last !== undefined && f.addr <= last.addr + last.count && end - last.addr <= MAX_READ) { - last.count = Math.max(last.count, end - last.addr); - } else { - spans.push({ addr: f.addr, count: f.width }); - } - } - return spans; -} - -/** The rows one span's bytes hold, decoded through the descriptor's codec. */ -export function decodeSpan( - descriptor: Pick, - rows: readonly Row[], - span: Span, - bytes: Uint8Array, -): Map { - if (bytes.length !== span.count) { - throw new Error( - `read at ${hexAddr(span.addr)} returned ${bytes.length} of ${span.count} bytes`, - ); - } - const values = new Map(); - for (const { field } of rows) { - const at = field.addr - span.addr; - if (at < 0 || at + field.width > span.count) continue; - values.set( - field.name, - descriptor.decode(field.name, bytes.subarray(at, at + field.width)).value, - ); - } - return values; -} - -/** Every row's value, one span read after another. */ -export async function readRows( - rows: readonly Row[], - descriptor: Pick, - read: (addr: number, count: number) => Promise, -): Promise { - const values = new Map(); - for (const span of readSpans(rows)) { - const bytes = await read(span.addr, span.count); - for (const [name, value] of decodeSpan(descriptor, rows, span, bytes)) values.set(name, value); - } - return values; -} diff --git a/src/routes/servo.tsx b/src/routes/servo.tsx index b7a7fb8..0605c56 100644 --- a/src/routes/servo.tsx +++ b/src/routes/servo.tsx @@ -9,12 +9,12 @@ import { useSession } from "@/lib/session"; export const Route = createFileRoute("/servo")({ component: ServoPage }); function ServoPage() { - const { status, client, servos, selected } = useSession(); + const { status, servos, selected } = useSession(); // 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) { + if (servo === undefined) { return

Pick a servo in the left pane.

; } return ( diff --git a/src/routes/table.tsx b/src/routes/table.tsx index 8e08fd4..4d7ec9a 100644 --- a/src/routes/table.tsx +++ b/src/routes/table.tsx @@ -1,4 +1,3 @@ -import type { Descriptor } from "@openservocore/client"; import { createFileRoute, Link } from "@tanstack/react-router"; import { ChevronRight, CircleHelp, Cog, Download, RefreshCw } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; @@ -9,11 +8,14 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { TableSearch } from "@/components/table-search"; import { ValueEditor } from "@/components/value-editor"; +import { useBus, useReadOnce, useRegisters } from "@/lib/bus/hooks"; +import type { Snapshot } from "@/lib/bus/manager"; import { toValue, type EditValue } from "@/lib/edit"; import { hexAddr } from "@/lib/format"; import { useSession } from "@/lib/session"; import { buildTable, + editValues, formatRow, searchIndex, type Group, @@ -23,6 +25,7 @@ import { type Tab, type TabName, type TableModel, + type Values, } from "@/lib/table-model"; import { expandFor, @@ -32,11 +35,12 @@ import { type OpenGroups, type SearchTarget, } from "@/lib/table-search"; -import { FLASH_MS, LIVE_POLL_MS, spanHolding, startPoll } from "@/lib/table-live"; -import { decodeSpan, readRows, readSpans, type Values } from "@/lib/table-read"; export const Route = createFileRoute("/table")({ component: TablePage }); +/** How long a row stays green after a jump or a write lands. */ +const FLASH_MS = 800; + const HELP: ReadonlyMap = new Map([ [ "Control loops", @@ -118,7 +122,6 @@ function TablePage() { ) : ( void; @@ -186,7 +187,6 @@ function Table({ void; jump: Jump | undefined; +} + +function TabPanel(props: PanelProps) { + return props.tab.name === "Live values" ? : ; +} + +function useRegisterNames(tab: Tab): string[] { + return useMemo(() => tab.groups.flatMap((g) => g.rows).map((r) => r.field.name), [tab]); +} + +function useRowValues(snapshot: Snapshot | undefined): Values | undefined { + return useMemo( + () => (snapshot === undefined ? undefined : editValues(snapshot.values)), + [snapshot], + ); +} + +/** A settled tab: read once on arrival, on Refresh and after an edit. */ +function StaticPanel(props: PanelProps) { + const names = useRegisterNames(props.tab); + const { snapshot, error, reload } = useReadOnce(props.id, names, [props.refresh]); + const values = useRowValues(snapshot); + return ; +} + +/** The Live values tab: a slow subscription, which carries a write back itself. */ +function LivePanel(props: PanelProps) { + const names = useRegisterNames(props.tab); + const snapshot = useRegisters(props.id, names, "slow"); + const values = useRowValues(snapshot); + return ( + + ); +} + +function Panel({ + id, + tab, + open, + onToggle, + jump, + values, + error, + seq, + onWrite, +}: PanelProps & { + values: Values | undefined; + error: string | undefined; + seq: number | undefined; + /** Re-read after a write, for a source that does not carry one back itself. */ + onWrite?: () => void; }) { - const { run } = useSession(); - const [values, setValues] = useState(); - const [error, setError] = useState(); + const bus = useBus(); + const [pending, setPending] = useState(); const [flash, setFlash] = useState(); const body = useRef(null); - const holds = useRef(0); - const rows = useMemo(() => tab.groups.flatMap((g) => g.rows), [tab]); - const spans = useMemo(() => readSpans(rows), [rows]); + const seen = useRef(0); useEffect(() => { - let live = true; - const load = async () => { - const v = await run((c) => - readRows(rows, descriptor, (addr, count) => c.read(id, addr, count)), - ); - if (!live) return; - setValues(v); - setError(undefined); - }; - const fail = (e: unknown) => { - if (live) setError(e instanceof Error ? e.message : String(e)); - }; - const stop = - tab.name === "Live values" - ? startPoll({ - periodMs: LIVE_POLL_MS, - tick: load, - hold: () => holds.current > 0, - onError: fail, - }) - : (load().catch(fail), undefined); - return () => { - live = false; - stop?.(); - }; - }, [run, id, descriptor, tab, rows, refresh]); + if (seq !== undefined) seen.current = seq; + }, [seq]); + if (pending !== undefined && seq !== undefined && seq > pending.after) { + setPending(undefined); + setFlash((f) => ({ name: pending.name, seq: (f?.seq ?? 0) + 1 })); + } // A collapsed group's rows reach the DOM only with the render that expands it. useEffect(() => { @@ -275,20 +305,9 @@ function TabPanel({ }, [flash]); async function apply(row: Row, raw: EditValue): Promise { - const { field } = row; - const bytes = descriptor.encode(field.name, toValue(field, raw)); - const span = spanHolding(spans, field); - holds.current++; - try { - const fresh = await run(async (c) => { - await c.write(id, field.addr, bytes); - return decodeSpan(descriptor, rows, span, await c.read(id, span.addr, span.count)); - }); - setValues((v) => new Map([...(v ?? []), ...fresh])); - setFlash((f) => ({ name: field.name, seq: (f?.seq ?? 0) + 1 })); - } finally { - holds.current--; - } + await bus.write(id, row.field.name, toValue(row.field, raw)); + setPending({ name: row.field.name, after: seen.current }); + onWrite?.(); } return ( diff --git a/tests/e2e/calibration.spec.ts b/tests/e2e/calibration.spec.ts index be8323a..1507a1f 100644 --- a/tests/e2e/calibration.spec.ts +++ b/tests/e2e/calibration.spec.ts @@ -74,3 +74,21 @@ test("an edit that inverts the sensor range is refused until cancelled", async ( await page.getByRole("button", { name: "Cancel" }).click(); await expect(highest.getByRole("button", { name: "4095 counts" })).toBeVisible(); }); + +// Angle lowest -150 then Angle highest -100 maps the whole sensor range onto +// negative degrees, which the seeded 0 to 202 deg map could never show. +test("an edit reaches the dashboard card without a reload", async ({ page }) => { + const card = await openCalibration(page, 1); + await edit(card, "Angle lowest", "0.00 deg", "-150"); + await edit(card, "Angle highest", "202.00 deg", "-100"); + await expect( + card.getByRole("group", { name: "Angle lowest" }).getByRole("button", { name: "-150.00 deg" }), + ).toBeVisible(); + await expect( + card.getByRole("group", { name: "Angle highest" }).getByRole("button", { name: "-100.00 deg" }), + ).toBeVisible(); + + await page.getByRole("link", { name: "Dashboard" }).click(); + const dashboard = page.getByRole("link", { name: /^ID 1\b/ }); + await expect(dashboard.getByText(/^-1\d\d(\.\d)? deg$/)).toBeVisible(); +}); diff --git a/tests/e2e/table-edit.spec.ts b/tests/e2e/table-edit.spec.ts index 07e86b2..592a976 100644 --- a/tests/e2e/table-edit.spec.ts +++ b/tests/e2e/table-edit.spec.ts @@ -55,3 +55,17 @@ test("Live values moves on its own", async ({ page }) => { await expect(cell).not.toHaveText(first, { timeout: 5000 }); await expect(cell).toHaveText(/^\d+$/); }); + +test("a Live values edit flashes its row and shows the written value", async ({ page }) => { + await openTable(page); + await page.getByRole("tab", { name: "Live values" }).click(); + const row = tableRow(page, "stall_permit"); + const cell = row.getByRole("cell"); + await expect(cell).toHaveText(/^(On|Off)$/); + const was = await cell.textContent(); + const editor = await openEditor(page, "stall_permit"); + await editor.getByRole("switch").click(); + await editor.getByRole("button", { name: "Apply" }).click(); + await expect(row).toHaveClass(/bg-success-soft/); + await expect(cell).toHaveText(was === "On" ? "Off" : "On"); +});