diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index bd1c9eb..05448a4 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -154,6 +154,7 @@ function servosEmpty(status: Status, onConnect: () => void): ReactNode { return "No servos found"; case "disconnected": case "error": + case "lost": return ( <> - {error !== undefined &&

{error}

} + {status === "lost" ? ( +

{lostReason(error)}

+ ) : ( + error !== undefined &&

{error}

+ )} )} diff --git a/src/components/health-card.tsx b/src/components/health-card.tsx index a24405e..cca7007 100644 --- a/src/components/health-card.tsx +++ b/src/components/health-card.tsx @@ -7,6 +7,7 @@ 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 icons = { fault: CircleAlert, warn: TriangleAlert, ok: CircleCheck }; const tone: Record = { @@ -17,15 +18,40 @@ const tone: Record = { export function HealthCard({ id }: { id: number }) { const bus = useBus(); + const { servos, descriptorFor } = useSession(); const snapshot = useRegisters(id, HEALTH_REGISTERS, "slow"); const [error, setError] = useState(); const [clearing, setClearing] = useState(false); + const [acking, setAcking] = useState(false); // 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); + const servo = servos.find((s) => s.id === id); + const descriptor = servo === undefined ? undefined : descriptorFor(servo); + const faulted = health !== undefined && health.faultFlags !== 0; + + /** The ack is the torque_enable 0->1 edge; one turn, so nothing interleaves. */ + async function ack() { + const field = descriptor?.fields().find((f) => f.name === "torque_enable"); + if (descriptor === undefined || field === undefined) return; + const off = descriptor.encode("torque_enable", { kind: "bool", value: false }); + const on = descriptor.encode("torque_enable", { kind: "bool", value: true }); + setAcking(true); + try { + await bus.command(async (c) => { + await c.write(id, field.addr, off); + await c.write(id, field.addr, on); + }); + setError(undefined); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setAcking(false); + } + } async function clear() { setClearing(true); @@ -66,6 +92,17 @@ export function HealthCard({ id }: { id: number }) { ); })} + {faulted && ( + + )}
{trimLine(health)} diff --git a/src/lib/backend.ts b/src/lib/backend.ts index 0c19291..e23e8f6 100644 --- a/src/lib/backend.ts +++ b/src/lib/backend.ts @@ -30,6 +30,11 @@ export function permittedAdapter d.vendorId === vendor && d.productId === product); } +/** A device the browser reports is the adapter; `vid`/`pid` need the wasm loaded. */ +export function isAdapter(device: { vendorId: number; productId: number }): boolean { + return device.vendorId === vid() && device.productId === pid(); +} + let wasmReady: Promise | undefined; export async function openClient(): Promise { diff --git a/src/lib/bus/manager.test.ts b/src/lib/bus/manager.test.ts index d94dcbe..94978fc 100644 --- a/src/lib/bus/manager.test.ts +++ b/src/lib/bus/manager.test.ts @@ -664,3 +664,34 @@ test("a subscription before its layout loads is planned on `layoutChanged`", asy expect(client.reads()).toEqual([[1, 10, 2]]); expect(seen.seen).toHaveLength(1); }); + +test("a disconnect-class failure reports lost once and no probe follows the session's detach", async () => { + const clock = new FakeClock(); + const client = new FakeClient(clock); + const layout = makeLayout(); + const manager = new BusManager(clock); + const lost: string[] = []; + manager.attach( + client as unknown as OscClient & BusClient, + () => layout, + (error) => { + lost.push(error); + manager.detach(error); + }, + ); + const seen = collect(); + manager.subscribe({ id: 1, registers: ["pos"], rate: "fast" }, seen.listener); + manager.subscribe({ id: 2, registers: ["pos"], rate: "fast" }, () => undefined); + client.failures.set( + "read:1", + "pipe: NotFoundError: Failed to execute 'transferOut' on 'USBDevice': The device was disconnected.", + ); + await clock.advance(0); + expect(lost).toHaveLength(1); + expect(seen.seen.at(-1)?.stale).toBe(true); + const reads = client.reads().length; + await clock.advance(5000); + expect(client.reads()).toHaveLength(reads); + expect(lost).toHaveLength(1); + expect(manager.stats().perServo.get(1)?.probing).toBe(false); +}); diff --git a/src/lib/bus/manager.ts b/src/lib/bus/manager.ts index 68dd359..fd225fb 100644 --- a/src/lib/bus/manager.ts +++ b/src/lib/bus/manager.ts @@ -15,6 +15,7 @@ import { } from "./spans"; import { classify, + isDisconnect, StatsRecorder, type BusStats, type Exchange, @@ -187,6 +188,7 @@ export class BusManager { private client: (OscClient & BusClient) | undefined; private layoutOf: ((id: number) => Layout | undefined) | undefined; + private onLost: ((error: string) => void) | undefined; private rosterIds: ReadonlySet | undefined; private readonly subs = new Set(); @@ -219,9 +221,15 @@ export class BusManager { // Connection - attach(client: OscClient & BusClient, layout: (id: number) => Layout | undefined): void { + /** `onLost` reports the one failure class no retry survives: the adapter is gone. */ + attach( + client: OscClient & BusClient, + layout: (id: number) => Layout | undefined, + onLost?: (error: string) => void, + ): void { this.client = client; this.layoutOf = layout; + this.onLost = onLost; const now = this.nowMs(); for (const id of this.servos.keys()) { const state = this.state(id); @@ -239,6 +247,7 @@ export class BusManager { detach(reason: string): void { this.client = undefined; this.layoutOf = undefined; + this.onLost = undefined; this.arm(undefined); this.stopProbe?.(); this.stopProbe = undefined; @@ -627,6 +636,19 @@ export class BusManager { }; } + /** + * Every client failure is read here, so the session hears about a gone + * adapter once however the lane that met it reports the error onwards. + */ + private outcome(error: unknown): Outcome { + if (this.client !== undefined && isDisconnect(error)) { + const report = this.onLost; + this.onLost = undefined; + report?.(message(error)); + } + return classify(error); + } + private async execute(job: Job): Promise { const client = this.client; if (client === undefined) { @@ -652,7 +674,7 @@ export class BusManager { try { job.item.settle.resolve(await job.item.fn(client)); } catch (e) { - outcome = classify(e); + outcome = this.outcome(e); job.item.settle.reject(e); } this.restart(); @@ -664,7 +686,7 @@ export class BusManager { try { job.item.settle.resolve(await job.item.fn(client)); } catch (e) { - outcome = classify(e); + outcome = this.outcome(e); job.item.settle.reject(e); } break; @@ -691,7 +713,7 @@ export class BusManager { for (const s of item.settle) s.resolve(); this.markDirty(item.id, f); } catch (e) { - outcome = classify(e); + outcome = this.outcome(e); this.rejectControl(item, e); } break; @@ -755,7 +777,7 @@ export class BusManager { } catch (e) { for (const target of read.targets) this.fail(target.id, read, seq, t, message(e), read.done); - return classify(e); + return this.outcome(e); } let outcome: Outcome = "ok"; read.targets.forEach((target, i) => { @@ -777,7 +799,7 @@ export class BusManager { return "ok"; } catch (e) { this.fail(target.id, read, seq, t, message(e), read.done); - return classify(e); + return this.outcome(e); } } diff --git a/src/lib/bus/stats.test.ts b/src/lib/bus/stats.test.ts new file mode 100644 index 0000000..1bb9516 --- /dev/null +++ b/src/lib/bus/stats.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "vitest"; +import { classify, isDisconnect } from "./stats"; + +test("the messages a gone adapter produces classify as a disconnect", () => { + for (const text of [ + "pipe: NotFoundError: Failed to execute 'transferOut' on 'USBDevice': The device was disconnected.", + "pipe: NetworkError: The device is disconnected", + "The device has been disconnected", + "pipe gone", + ]) { + expect(isDisconnect(new Error(text))).toBe(true); + } +}); + +test("a servo failure, a stall and the detach reason are not disconnects", () => { + for (const text of [ + "not connected", + "disconnected", + "read timeout", + "pipe stalled past the guard window", + "NotFoundError: No device selected.", + ]) { + expect(isDisconnect(new Error(text))).toBe(false); + } +}); + +test("a disconnect still counts as a plain error in the readout", () => { + expect(classify(new Error("pipe: the device was disconnected"))).toBe("error"); +}); diff --git a/src/lib/bus/stats.ts b/src/lib/bus/stats.ts index 1ef6766..197f852 100644 --- a/src/lib/bus/stats.ts +++ b/src/lib/bus/stats.ts @@ -169,3 +169,15 @@ export function classify(error: unknown): Exclude { } return "error"; } + +/** What WebUSB rejects a transfer with once the adapter is gone. */ +export const DISCONNECTED = "The device was disconnected."; + +// WebUSB words it "The device was disconnected."; the pipe wraps that text and +// the sim reuses it, so the class is read off the message either way. +const GONE = /device (?:was |is |has been )?disconnected|pipe (?:is )?gone/; + +/** The adapter itself is gone: the session ends, no retry can reach it. */ +export function isDisconnect(error: unknown): boolean { + return GONE.test((error instanceof Error ? error.message : String(error)).toLowerCase()); +} diff --git a/src/lib/health.test.ts b/src/lib/health.test.ts index abe2b67..62128e6 100644 --- a/src/lib/health.test.ts +++ b/src/lib/health.test.ts @@ -30,7 +30,17 @@ test("several raised flags state one sentence each, lowest bit first", () => { }); test("an undefined flag still states its bit", () => { - expect(statements({ ...clean, faultFlags: 1 << 7 })[0]?.text).toBe("Unknown fault, bit 7."); + expect(statements({ ...clean, faultFlags: 1 << 7 })[0]?.text).toContain("Unknown fault, bit 7."); +}); + +test("every fault statement says how to clear it: torque off and on", () => { + for (let bit = 0; bit < 8; bit++) { + const s = statements({ ...clean, faultFlags: 1 << bit }); + expect(s[0]?.text).toContain("The motor stays off until torque is switched off and on again."); + } + expect(statements({ ...clean, faultFlags: 1 << 2 })[0]?.text).toBe( + "Stalled: holding current with no movement. The motor stays off until torque is switched off and on again.", + ); }); test("unsaved changes rank under the faults and above the all-clear", () => { diff --git a/src/lib/health.ts b/src/lib/health.ts index 4e0103e..73d0897 100644 --- a/src/lib/health.ts +++ b/src/lib/health.ts @@ -17,6 +17,12 @@ const FAULTS: readonly string[] = [ "Under voltage: the bus rail sagged below the limit.", ]; +/** + * Every latched fault clears the same way: the torque_enable 0->1 edge is the + * only ack, and until it comes the motor stays off (firmware kernel/faults.rs). + */ +const CLEAR = "The motor stays off until torque is switched off and on again."; + function plural(n: number, word: string): string { return `${n} ${word}${n === 1 ? "" : "s"}`; } @@ -26,7 +32,7 @@ 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}.` }); + out.push({ level: "fault", text: `${FAULTS[bit] ?? `Unknown fault, bit ${bit}.`} ${CLEAR}` }); } if (h.configDirty) { out.push({ level: "warn", text: "Unsaved changes: settings differ from the saved ones." }); diff --git a/src/lib/prefs.test.ts b/src/lib/prefs.test.ts index c0bedd9..735d7a8 100644 --- a/src/lib/prefs.test.ts +++ b/src/lib/prefs.test.ts @@ -1,12 +1,15 @@ import { expect, test } from "vitest"; import { applyTheme, + MODE_KEY, PANE_KEY, readPane, readTheme, + readMode, readUnits, THEME_KEY, UNITS_KEY, + writeMode, writePane, writeTheme, writeUnits, @@ -74,3 +77,16 @@ test("writeUnits round-trips through the storage", () => { expect(s.map.get(UNITS_KEY)).toBe("raw"); expect(readUnits(s)).toBe("raw"); }); + +test("the mode preference defaults to Position and rejects unknown values", () => { + expect(readMode(stub())).toBe("Position"); + expect(readMode(stub({ [MODE_KEY]: "Velocity" }))).toBe("Velocity"); + expect(readMode(stub({ [MODE_KEY]: "Spin" }))).toBe("Position"); +}); + +test("writeMode round-trips through the storage", () => { + const s = stub(); + writeMode(s, "OpenLoop"); + expect(s.map.get(MODE_KEY)).toBe("OpenLoop"); + expect(readMode(s)).toBe("OpenLoop"); +}); diff --git a/src/lib/prefs.ts b/src/lib/prefs.ts index 57a1190..fddb4c2 100644 --- a/src/lib/prefs.ts +++ b/src/lib/prefs.ts @@ -1,3 +1,5 @@ +import { isMode, type ModeName } from "./control"; + export type Theme = "light" | "dark" | "system"; export type Pane = "open" | "collapsed"; export type Units = "real" | "raw"; @@ -5,6 +7,7 @@ export type Units = "real" | "raw"; export const THEME_KEY = "osc-theme"; export const PANE_KEY = "osc-pane"; export const UNITS_KEY = "osc-units"; +export const MODE_KEY = "osc-mode"; export interface StorageLike { getItem(key: string): string | null; @@ -55,6 +58,16 @@ export function writeUnits(storage: StorageLike, value: Units): void { storage.setItem(UNITS_KEY, value); } +/** The control mode the app intends; it is written to the servo, never read from it. */ +export function readMode(storage: StorageLike): ModeName { + const value = storage.getItem(MODE_KEY); + return value !== null && isMode(value) ? value : "Position"; +} + +export function writeMode(storage: StorageLike, value: ModeName): void { + storage.setItem(MODE_KEY, value); +} + export function applyTheme(root: ThemeRoot, theme: Theme): void { if (theme === "system") root.removeAttribute("data-theme"); else root.setAttribute("data-theme", theme); diff --git a/src/lib/session-state.test.ts b/src/lib/session-state.test.ts index a423934..dfc15ab 100644 --- a/src/lib/session-state.test.ts +++ b/src/lib/session-state.test.ts @@ -101,8 +101,41 @@ test("connect leaves error and clears the message", () => { expect(retry.error).toBeUndefined(); }); +test("a lost adapter enters lost from connecting, scanning and ready", () => { + const connecting = reduce(idle, { type: "connect" }); + const scanning = reduce(connecting, { type: "scan" }); + for (const from of [connecting, scanning, reduce(ready, { type: "select", id: 1 })]) { + const gone = reduce(from, { type: "lost", error: "pipe: the device was disconnected" }); + expect(gone.status).toBe("lost"); + expect(gone.error).toBe("pipe: the device was disconnected"); + expect(gone.servos).toEqual([]); + expect(gone.selected).toBeUndefined(); + expect(gone.baud).toBeUndefined(); + } +}); + +test("once lost, a late failure or a second loss changes nothing", () => { + const gone = reduce(ready, { type: "lost", error: "unplugged" }); + expect(reduce(gone, { type: "fail", error: "not connected" })).toBe(gone); + expect(reduce(gone, { type: "lost", error: "again" })).toBe(gone); + expect(reduce(gone, { type: "scan" })).toBe(gone); + expect(reduce(idle, { type: "lost", error: "late" })).toBe(idle); +}); + +test("connect retries from lost and clears the reason", () => { + const gone = reduce(ready, { type: "lost", error: "unplugged" }); + const retry = reduce(gone, { type: "connect" }); + expect(retry.status).toBe("connecting"); + expect(retry.error).toBeUndefined(); +}); + test("disconnect returns to idle from anywhere", () => { - for (const from of [ready, reduce(ready, { type: "scan" }), reduce(idle, { type: "connect" })]) { + for (const from of [ + ready, + reduce(ready, { type: "scan" }), + reduce(idle, { type: "connect" }), + reduce(ready, { type: "lost", error: "unplugged" }), + ]) { expect(reduce(from, { type: "disconnect" })).toBe(idle); } }); diff --git a/src/lib/session-state.ts b/src/lib/session-state.ts index 3e12f0d..8859981 100644 --- a/src/lib/session-state.ts +++ b/src/lib/session-state.ts @@ -9,11 +9,11 @@ export interface Servo extends Found { calibrated?: boolean; } -export type Status = "disconnected" | "connecting" | "scanning" | "ready" | "error"; +export type Status = "disconnected" | "connecting" | "scanning" | "ready" | "error" | "lost"; export interface SessionState { status: Status; - /** The last failure; set only while `status` is "error". */ + /** The last failure; set only while `status` is "error" or "lost". */ error: string | undefined; baud: BaudRate | undefined; rails: Rails | undefined; @@ -30,6 +30,7 @@ export type SessionEvent = | { type: "found"; servos: Servo[]; baud: BaudRate | undefined; rails: Rails } | { type: "rails"; rails: Rails } | { type: "fail"; error: string } + | { type: "lost"; error: string } | { type: "select"; id: number | undefined } | { type: "disconnect" }; @@ -43,14 +44,18 @@ export const idle: SessionState = { missing: [], }; +/** No client is open, so a failure arriving late changes nothing. */ +function ended(state: SessionState): boolean { + const { status } = state; + return status === "disconnected" || status === "error" || status === "lost"; +} + // Events that do not apply in the current status leave it unchanged, so a // command that resolves after a disconnect cannot revive the session. export function reduce(state: SessionState, event: SessionEvent): SessionState { switch (event.type) { case "connect": - return state.status === "disconnected" || state.status === "error" - ? { ...idle, status: "connecting" } - : state; + return ended(state) ? { ...idle, status: "connecting" } : state; case "scan": return state.status === "connecting" || state.status === "ready" ? { ...state, status: "scanning", missing: [] } @@ -75,9 +80,9 @@ export function reduce(state: SessionState, event: SessionEvent): SessionState { case "rails": return state.status === "ready" ? { ...state, rails: event.rails } : state; case "fail": - return state.status === "disconnected" || state.status === "error" - ? state - : { ...idle, status: "error", error: event.error }; + return ended(state) ? state : { ...idle, status: "error", error: event.error }; + case "lost": + return ended(state) ? state : { ...idle, status: "lost", error: event.error }; case "select": return state.status === "ready" ? { ...state, selected: event.id } : state; case "disconnect": diff --git a/src/lib/session.tsx b/src/lib/session.tsx index 261f5f5..50d515f 100644 --- a/src/lib/session.tsx +++ b/src/lib/session.tsx @@ -17,7 +17,7 @@ import { useSyncExternalStore, type ReactNode, } from "react"; -import { openClient, simRequested } from "./backend"; +import { isAdapter, openClient, simRequested } from "./backend"; import { BusContext, type BusHost } from "./bus/hooks"; import { BusManager, systemClock, type Snapshot } from "./bus/manager"; import { @@ -31,6 +31,7 @@ import { type CardValues, type Layout, } from "./bus/spans"; +import { DISCONNECTED, isDisconnect } from "./bus/stats"; import { animationFrame, BusStore } from "./bus/store"; import { fetchDescriptor } from "./descriptor"; import { @@ -225,7 +226,7 @@ class Controller { async connect(): Promise { const { status } = this.snap.state; - if (status !== "disconnected" && status !== "error") return; + if (status !== "disconnected" && status !== "error" && status !== "lost") return; this.dispatch({ type: "connect" }); let client: OscClient; try { @@ -235,7 +236,7 @@ class Controller { return; } this.set({ client, linkInfo: client.linkInfo(), simulated: simRequested() }); - this.bus.attach(client, this.layoutById); + this.bus.attach(client, this.layoutById, this.lost); await this.scan(client); } @@ -274,7 +275,11 @@ class Controller { if (this.snap.client !== client) return; this.set({ client: undefined, linkInfo: undefined, simulated: false }); this.clearCards(); - this.dispatch({ type: "fail", error: message(failure) }); + this.dispatch( + isDisconnect(failure) + ? { type: "lost", error: message(failure) } + : { type: "fail", error: message(failure) }, + ); await release(client); } @@ -385,6 +390,34 @@ class Controller { if (this.snap.client === client) this.dispatch({ type: "rails", rails }); } + /** The adapter is gone: stop the lanes, drop the client, keep the reason. */ + private readonly lost = (error: string): void => { + const { client } = this.snap; + if (client === undefined) return; + this.set({ client: undefined, linkInfo: undefined, simulated: false }); + this.clearCards(); + // Safe from inside the manager's own dispatch: the call in flight has + // already rejected and nothing new is picked while the client is unset. + this.bus.detach(error); + this.dispatch({ type: "lost", error }); + // The device is already gone, so closing it is best effort. + release(client).catch(() => undefined); + }; + + /** The browser says a device left; only the open adapter ends this session. */ + deviceGone(device: { vendorId: number; productId: number }): void { + if (this.snap.client === undefined || this.snap.simulated) return; + if (!isAdapter(device)) return; + this.lost(DISCONNECTED); + } + + /** Test hook: the simulated adapter fails with the text a real unplug produces. */ + sever(): void { + this.lost( + `pipe: NotFoundError: Failed to execute 'transferOut' on 'USBDevice': ${DISCONNECTED}`, + ); + } + async disconnect(): Promise { const { client } = this.snap; if (client === undefined) return; @@ -420,6 +453,11 @@ function withHealth(servo: Servo, values: CardValues | undefined): Servo { }; } +/** `?sim` only: the handle a browser spec severs the fake adapter through. */ +interface SimWindow extends Window { + __osc?: { sever: () => void }; +} + export function SessionProvider({ children }: { children: ReactNode }) { const [ctl] = useState(() => new Controller()); const snap = useSyncExternalStore(ctl.subscribe, ctl.snapshot, ctl.snapshot); @@ -429,6 +467,31 @@ export function SessionProvider({ children }: { children: ReactNode }) { if (simRequested()) void ctl.connect(); }, [ctl]); + // An unplug on an idle bus has no exchange to reject, so the browser's own + // event is what ends the session; the sim is severed by a test instead. + useEffect(() => { + if (simRequested()) { + const w = window as SimWindow; + w.__osc = { + sever: () => { + ctl.sever(); + }, + }; + return () => { + delete w.__osc; + }; + } + const usb = (navigator as { usb?: USB }).usb; + if (usb === undefined) return; + const gone = (e: USBConnectionEvent) => { + ctl.deviceGone(e.device); + }; + usb.addEventListener("disconnect", gone); + return () => { + usb.removeEventListener("disconnect", gone); + }; + }, [ctl]); + const selected = snap.state.servos.find((s) => s.id === snap.state.selected); const value: Session = { status: snap.state.status, diff --git a/src/lib/use-pref.ts b/src/lib/use-pref.ts index 0bb0daa..f8d4387 100644 --- a/src/lib/use-pref.ts +++ b/src/lib/use-pref.ts @@ -1,8 +1,11 @@ import { useSyncExternalStore } from "react"; +import type { ModeName } from "./control"; import { + readMode, readPane, readTheme, readUnits, + writeMode, writePane, writeTheme, writeUnits, @@ -51,3 +54,7 @@ export function usePanePref(): [Pane, (pane: Pane) => void] { export function useUnitsPref(): [Units, (units: Units) => void] { return usePref(readUnits, writeUnits, "real"); } + +export function useModePref(): [ModeName, (mode: ModeName) => void] { + return usePref(readMode, writeMode, "Position"); +} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index fd2ee35..726b0b4 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -29,7 +29,7 @@ function Dashboard() { return ( <>

Dashboard

- {status === "disconnected" || status === "error" ? ( + {status === "disconnected" || status === "error" || status === "lost" ? (