Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ function servosEmpty(status: Status, onConnect: () => void): ReactNode {
return "No servos found";
case "disconnected":
case "error":
case "lost":
return (
<>
<button type="button" className="underline underline-offset-4" onClick={onConnect}>
Expand Down Expand Up @@ -275,6 +276,8 @@ function connectionText(status: Status, baud: BaudRate | undefined): string {
case "disconnected":
case "error":
return "Not connected";
case "lost":
return "Connection lost";
case "connecting":
return "Connecting ..";
case "scanning":
Expand Down
17 changes: 15 additions & 2 deletions src/components/connection-popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { isDisconnect } from "@/lib/bus/stats";
import { formatBaud } from "@/lib/format";
import { useSession, type Status } from "@/lib/session";

Expand All @@ -29,9 +30,17 @@ function chip(status: Status): { text: string; className: string } {
case "disconnected":
case "error":
return { text: "not connected", className: "bg-surface-3 text-text-2" };
case "lost":
return { text: "connection lost", className: "bg-warning-soft text-warning" };
}
}

/** What ended the session, in words a hobbyist can act on. */
function lostReason(error: string | undefined): string | undefined {
if (error === undefined) return undefined;
return isDisconnect(error) ? "The adapter was unplugged." : error;
}

function servos(n: number): string {
return n === 1 ? "1 servo" : `${n} servos`;
}
Expand Down Expand Up @@ -126,9 +135,13 @@ export function ConnectionPopover({
}}
>
<Plug />
Connect adapter
{status === "lost" ? "Reconnect" : "Connect adapter"}
</Button>
{error !== undefined && <p className="text-danger">{error}</p>}
{status === "lost" ? (
<p className="text-text-2">{lostReason(error)}</p>
) : (
error !== undefined && <p className="text-danger">{error}</p>
)}
</>
)}

Expand Down
37 changes: 37 additions & 0 deletions src/components/health-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Level, string> = {
Expand All @@ -17,15 +18,40 @@ const tone: Record<Level, string> = {

export function HealthCard({ id }: { id: number }) {
const bus = useBus();
const { servos, descriptorFor } = useSession();
const snapshot = useRegisters(id, HEALTH_REGISTERS, "slow");
const [error, setError] = useState<string>();
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);
Expand Down Expand Up @@ -66,6 +92,17 @@ export function HealthCard({ id }: { id: number }) {
</div>
);
})}
{faulted && (
<Button
variant="outline"
size="sm"
className="self-start"
disabled={acking}
onClick={() => void ack()}
>
Clear fault
</Button>
)}
<Separator />
<div className="flex items-baseline justify-between gap-4 text-text-3">
<span>{trimLine(health)}</span>
Expand Down
5 changes: 5 additions & 0 deletions src/lib/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ export function permittedAdapter<T extends { vendorId: number; productId: number
return devices.find((d) => 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<unknown> | undefined;

export async function openClient(): Promise<OscClient> {
Expand Down
31 changes: 31 additions & 0 deletions src/lib/bus/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
34 changes: 28 additions & 6 deletions src/lib/bus/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from "./spans";
import {
classify,
isDisconnect,
StatsRecorder,
type BusStats,
type Exchange,
Expand Down Expand Up @@ -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<number> | undefined;

private readonly subs = new Set<Sub>();
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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<void> {
const client = this.client;
if (client === undefined) {
Expand All @@ -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();
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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) => {
Expand All @@ -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);
}
}

Expand Down
29 changes: 29 additions & 0 deletions src/lib/bus/stats.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
12 changes: 12 additions & 0 deletions src/lib/bus/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,15 @@ export function classify(error: unknown): Exclude<Outcome, "ok"> {
}
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());
}
12 changes: 11 additions & 1 deletion src/lib/health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
8 changes: 7 additions & 1 deletion src/lib/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"}`;
}
Expand All @@ -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." });
Expand Down
16 changes: 16 additions & 0 deletions src/lib/prefs.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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");
});
Loading
Loading