();
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" ? (
+ {faulted && (
+
+ Motor is off: a fault is latched. Switch torque off and on to clear it.
+
+ )}
{problem !== undefined && {problem}
}
);
diff --git a/tests/e2e/connection.spec.ts b/tests/e2e/connection.spec.ts
index 5ed51f1..396ce0d 100644
--- a/tests/e2e/connection.spec.ts
+++ b/tests/e2e/connection.spec.ts
@@ -58,3 +58,31 @@ test("without ?sim the popover opens on boot offering to connect", async ({ page
await expect(popover.getByText("osc-adapter")).toBeVisible();
await expect(popover.getByRole("button", { name: "Connect adapter" })).toBeVisible();
});
+
+test("an unplugged adapter ends the session and Reconnect brings the fleet back", async ({
+ page,
+}) => {
+ await gotoSim(page, [1, 2], { debug: true });
+ const exchanges = page
+ .getByRole("region", { name: "Bus statistics" })
+ .locator('dt:text-is("exchanges") + dd');
+ await page.evaluate(() => {
+ (window as unknown as { __osc: { sever: () => void } }).__osc.sever();
+ });
+ const lost = page.getByRole("button", { name: "Connection lost" });
+ await expect(lost).toBeVisible();
+ await lost.click();
+ const popover = page.getByRole("dialog");
+ await expect(popover.getByText("The adapter was unplugged.")).toBeVisible();
+ const reconnect = popover.getByRole("button", { name: "Reconnect" });
+ await expect(reconnect).toBeVisible();
+ // The readout publishes twice a second, so let the last exchanges reach it.
+ await page.waitForTimeout(1000);
+ const stopped = (await exchanges.textContent()) ?? "";
+ expect(stopped).not.toBe("0");
+ await page.waitForTimeout(1000);
+ await expect(exchanges).toHaveText(stopped);
+ await reconnect.click();
+ await expect(page.getByRole("button", { name: "ID 1" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "ID 2" })).toBeVisible();
+});
diff --git a/tests/e2e/control.spec.ts b/tests/e2e/control.spec.ts
index f2c9e26..9b9e643 100644
--- a/tests/e2e/control.spec.ts
+++ b/tests/e2e/control.spec.ts
@@ -26,11 +26,24 @@ async function setGoal(page: Page, value: string): Promise {
await input.press("Enter");
}
-test("the simulated servo boots in open loop, with the warning and the duty readouts", async ({
+test("the app's default mode is position and the servo is switched to it on its own", async ({
page,
}) => {
await openLive(page);
- await expect(page.getByRole("combobox", { name: "Mode" })).toHaveText("Open loop");
+ await expect(page.getByRole("combobox", { name: "Mode" })).toHaveText("Position");
+ // The goal control reads the servo's own `mode` register, so degrees here
+ // means the servo took the write, with nothing asked of the user.
+ await expect(page.getByLabel("Goal readout")).toHaveText(/ deg$/);
+ await expect(page.getByRole("alert")).toHaveCount(0);
+ await expect(page.getByLabel("Commanded duty value")).toHaveCount(0);
+});
+
+test("open loop, once picked, shows the warning and the duty readouts", async ({ page }) => {
+ await openLive(page);
+ // Degrees first: the servo has taken the default mode, so the pick below is
+ // the last mode write on the wire.
+ await expect(page.getByLabel("Goal readout")).toHaveText(/ deg$/);
+ await pickMode(page, "Open loop");
await expect(page.getByRole("alert")).toContainText("Open loop can strip the gears");
await expect(page.getByLabel("Goal readout")).toHaveText(/%$/);
await expect(page.getByLabel("Commanded duty value")).toHaveText(/^-?\d+\.\d %$/);
@@ -40,6 +53,7 @@ test("the simulated servo boots in open loop, with the warning and the duty read
test("each mode shows its own goal control and the warning is open loop only", async ({ page }) => {
await openLive(page);
+ await expect(page.getByLabel("Goal readout")).toHaveText(/ deg$/);
await pickMode(page, "Current");
await expect(page.getByLabel("Goal readout")).toHaveText(/ mA$/);
await expect(page.getByRole("alert")).toHaveCount(0);