diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 05448a4..ce31e6f 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -20,6 +20,7 @@ import { Wrench, } from "lucide-react"; import { ConnectionPopover } from "@/components/connection-popover"; +import { FirmwareItem } from "@/components/firmware-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; @@ -95,6 +96,7 @@ export function AppSidebar() { + diff --git a/src/components/firmware-dialog.tsx b/src/components/firmware-dialog.tsx new file mode 100644 index 0000000..65b8e06 --- /dev/null +++ b/src/components/firmware-dialog.tsx @@ -0,0 +1,633 @@ +import { + parseImage, + unpackVersion, + type BaudRate, + type BootRow, + type Found, + type OscClient, + type Version, +} from "@openservocore/client"; +import { useBlocker } from "@tanstack/react-router"; +import { + CircleCheck, + CircleX, + Cog, + HardDriveUpload, + LoaderCircle, + TriangleAlert, +} from "lucide-react"; +import { useEffect, useId, useRef, useState } from "react"; +import { Alert, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar"; +import { useBus } from "@/lib/bus/hooks"; +import { isDisconnect } from "@/lib/bus/stats"; +import { + canEnter, + COMPAT_UNSET, + deviceLabel, + fileRefusal, + hex32, + installedVersion, + leaveWarning, + locked, + matching, + preselect, + progressText, + restartResult, + shortUid, + stepIndex, + STEPS, + UPDATE_RATE_DEFAULT, + updatingText, + verdictText, + type AppReport, + type LoadedFile, + type Phase, + type RestartResult, +} from "@/lib/firmware-update"; +import { BAUD_RATES, formatBaud, formatVersion } from "@/lib/format"; +import { useSession } from "@/lib/session"; + +interface Device { + row: BootRow; + version: Version | null; + checked: boolean; + file: string | undefined; + progress: [number, number] | undefined; + result: { text: string; ok: boolean } | undefined; +} + +function message(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +export function FirmwareItem() { + const { status, baud, discover } = useSession(); + // The rate is kept from the click: a lost adapter clears the session's. + const [fleetRate, setFleetRate] = useState(); + return ( + + { + setFleetRate(baud); + }} + > + + Update firmware + + {fleetRate !== undefined && ( + { + setFleetRate(undefined); + if (ran) void discover(); + }} + /> + )} + + ); +} + +interface Held { + client: OscClient | undefined; + error: string | undefined; + /** Ends the turn with this failure, so the bus reports a gone adapter to the session. */ + fail: (e: unknown) => void; +} + +/** Holds the bus as one exclusive turn for as long as the dialog is mounted. */ +function useHeldClient(): Held { + const bus = useBus(); + const [client, setClient] = useState(); + const [error, setError] = useState(); + const failure = useRef(undefined); + useEffect(() => { + let live = true; + let release!: () => void; + const released = new Promise((resolve) => { + release = resolve; + }); + bus + .exclusive(async (c) => { + if (live) setClient(c); + await released; + if (failure.current !== undefined) throw failure.current; + }) + .catch((e: unknown) => { + if (live) setError(message(e)); + }); + return () => { + live = false; + release(); + }; + }, [bus]); + const fail = (e: unknown) => { + failure.current = e instanceof Error ? e : new Error(message(e)); + }; + return { client, error, fail }; +} + +function FirmwareDialog({ + fleetRate, + onClosed, +}: { + fleetRate: BaudRate; + onClosed: (ran: boolean) => void; +}) { + const held = useHeldClient(); + const client = held.client; + const { status, servos } = useSession(); + const rateId = useId(); + const filesId = useId(); + const nextKey = useRef(0); + const [phase, setPhase] = useState("prepare"); + const [rate, setRate] = useState(UPDATE_RATE_DEFAULT); + const [files, setFiles] = useState([]); + const [roster, setRoster] = useState([]); + const [devices, setDevices] = useState([]); + const [listed, setListed] = useState(false); + const [installed, setInstalled] = useState>(new Set()); + const [turn, setTurn] = useState<{ index: number; count: number }>(); + const [result, setResult] = useState(); + const [error, setError] = useState(); + const [warned, setWarned] = useState(false); + const [dropped, setDropped] = useState(false); + const lost = dropped || status === "lost"; + const isLocked = locked(phase, lost); + const warning = leaveWarning(phase, lost, warned); + const busy = phase === "entering" || phase === "flashing" || phase === "restarting"; + + useBlocker({ + shouldBlockFn: () => { + setWarned(true); + return true; + }, + enableBeforeUnload: isLocked, + disabled: !isLocked, + }); + + function leave() { + if (isLocked) { + setWarned(true); + return; + } + onClosed(phase === "done"); + } + + /** A call's failure; true when the adapter itself is gone. */ + function failed(e: unknown): boolean { + if (!isDisconnect(e)) return false; + held.fail(e); + setDropped(true); + return true; + } + + function update(uid: string, patch: Partial) { + setDevices((ds) => ds.map((d) => (d.row.uid === uid ? { ...d, ...patch } : d))); + } + + async function load(list: FileList | null) { + if (list === null) return; + const added = await Promise.all( + [...list].map(async (f) => { + const bytes = new Uint8Array(await f.arrayBuffer()); + return { key: String(nextKey.current++), name: f.name, bytes, check: parseImage(bytes) }; + }), + ); + setFiles((fs) => [...fs, ...added]); + } + + async function enter(c: OscClient) { + setPhase("entering"); + setError(undefined); + const apps: AppReport[] = servos.flatMap((s) => + s.ping === undefined ? [] : [{ uid: s.uid, version: unpackVersion(s.ping.fw) }], + ); + try { + setRoster(await c.bootEnter(rate)); + const rows = await c.bootList(rate); + setListed(true); + setDevices( + rows.map((row) => ({ + row, + version: installedVersion(row, apps), + checked: false, + file: preselect(row.compat, files), + progress: undefined, + result: undefined, + })), + ); + } catch (e) { + failed(e); + setError(message(e)); + } + setPhase("devices"); + } + + async function flash(c: OscClient) { + const queue = devices.filter((d) => d.checked && d.file !== undefined); + setPhase("flashing"); + setError(undefined); + for (const [index, d] of queue.entries()) { + setTurn({ index, count: queue.length }); + const uid = d.row.uid; + const image = files.find((f) => f.key === d.file); + if (image === undefined) continue; + update(uid, { progress: [0, image.bytes.length], result: undefined }); + try { + const verdict = await c.bootInstall(uid, image.bytes, (written, total) => { + update(uid, { progress: [written, total] }); + }); + const ok = verdict.kind === "installed"; + update(uid, { + progress: undefined, + result: { text: verdictText(verdict), ok }, + checked: !ok, + }); + if (ok) setInstalled((s) => new Set(s).add(uid)); + } catch (e) { + update(uid, { progress: undefined, result: { text: message(e), ok: false } }); + if (failed(e)) break; + } + } + setTurn(undefined); + setPhase("flashed"); + } + + async function restart(c: OscClient) { + const before = phase; + setPhase("restarting"); + setError(undefined); + try { + const rows = listed ? devices.map((d) => d.row) : await c.bootList(rate); + await c.bootResetAll(fleetRate); + const found = await c.discover(); + const counts = new Map(); + for (const f of found) counts.set(f.id, (counts.get(f.id) ?? 0) + 1); + const back = []; + // An id two nodes answer at would garble the identity read. + for (const f of found.filter((f) => counts.get(f.id) === 1)) { + back.push({ ...f, version: unpackVersion((await c.identity(f.id)).fw) }); + } + setResult(restartResult(back, rows, installed)); + setWarned(false); + setPhase("done"); + } catch (e) { + failed(e); + setError(message(e)); + setPhase(before === "restarting" ? "flashed" : before); + } + } + + const queued = devices.filter((d) => d.checked && d.file !== undefined).length; + const description = + phase === "flashing" && turn !== undefined + ? updatingText(turn.index, turn.count) + : { + prepare: "Load the firmware files, then put every servo in its bootloader.", + entering: "Putting every servo in its bootloader ..", + devices: "Pick the boards to update and the file for each.", + flashing: "Updating ..", + flashed: "Restart all to bring every board back to its app.", + restarting: "Restarting every board ..", + done: "Every board with an app is running it again.", + }[phase]; + + return ( + { + if (!open) leave(); + }} + > + + + + + Update firmware + + {description} + + +
    + {STEPS.map((label, i) => ( +
  1. + {i + 1} + {label} +
  2. + ))} +
+ +
+ {phase === "prepare" ? ( + <> + + + Rest the robot first: every servo goes limp. + +
+ + +

Slower is safer on long or unknown wiring.

+
+
+ + { + void load(e.target.files); + e.target.value = ""; + }} + /> +
    + {files.map((f) => ( + + ))} +
+
+ + ) : phase === "done" && result !== undefined ? ( + + ) : ( + + )} + {held.error !== undefined &&

{held.error}

} + {error !== undefined &&

{error}

} +
+ + + {warning !== undefined && ( +

+ + {warning} +

+ )} + {phase === "prepare" && ( + <> + + + + )} + {isLocked && ( + <> + {(phase === "devices" || phase === "flashed" || phase === "flashing") && ( + + )} + + + )} + {(phase === "done" || (lost && phase !== "prepare")) && ( + + )} +
+
+
+ ); +} + +function FileLine({ file }: { file: LoadedFile }) { + const refusal = fileRefusal(file.check); + return ( +
  • + {file.name} + {file.check.kind === "ok" ? ( + <> + Version {formatVersion(file.check.version)} + + board key {hex32(file.check.compat)} + + + ) : ( + {refusal} + )} +
  • + ); +} + +function rowStatus(d: Device): { text: string; ok: boolean | undefined } | undefined { + if (d.progress !== undefined) return { text: progressText(...d.progress), ok: undefined }; + return d.result; +} + +function DeviceTable({ + devices, + files, + roster, + busy, + onChange, +}: { + devices: Device[]; + files: LoadedFile[]; + roster: Found[]; + busy: boolean; + onChange: (uid: string, patch: Partial) => void; +}) { + if (devices.length === 0) return null; + return ( + + + + + + + + + + + + {devices.map((d) => { + const label = deviceLabel(d.row, roster); + const options = matching(d.row.compat, files); + const status = rowStatus(d); + return ( + + + + + + + + + ); + })} + +
    + DeviceBoard keyInstalledFileStatus
    + { + onChange(d.row.uid, { checked: v === true }); + }} + /> + +
    + + {label} + {shortUid(d.row.uid)} +
    +
    {d.row.uid}
    +
    + {d.row.compat === COMPAT_UNSET ? "none" : hex32(d.row.compat)} + + {d.version === null ? "None" : formatVersion(d.version)} + + {options.length === 0 ? ( + No loaded file is for this board + ) : ( + + )} + + {status !== undefined && ( + + {status.ok === undefined ? ( + + ) : status.ok ? ( + + ) : ( + + )} + {status.text} + + )} +
    + ); +} + +function RestartView({ result, roster }: { result: RestartResult; roster: Found[] }) { + return ( +
    +
    +

    Back on the bus

    +
      + {result.back.map((s) => ( +
    • + + ID {s.id} + {formatVersion(s.version)} + {s.updated && new} +
    • + ))} +
    +
    + {result.waiting.length > 0 && ( +
    +

    Waiting for firmware

    +
      + {result.waiting.map((r) => ( +
    • + + {deviceLabel(r, roster)} + {shortUid(r.uid)} + still in its bootloader +
    • + ))} +
    +
    + )} +
    + ); +} diff --git a/src/lib/backend.test.ts b/src/lib/backend.test.ts index ef0bdb7..4def378 100644 --- a/src/lib/backend.test.ts +++ b/src/lib/backend.test.ts @@ -1,5 +1,12 @@ import { expect, test } from "vitest"; -import { parseSimDeadlines, parseSimIds, parseSimVirgin, permittedAdapter } from "./backend"; +import { + parseSimBoot, + parseSimDeadlines, + parseSimFail, + parseSimIds, + parseSimVirgin, + permittedAdapter, +} from "./backend"; test("parseSimIds is undefined without ?sim", () => { expect(parseSimIds("")).toBeUndefined(); @@ -36,6 +43,21 @@ test("parseSimDeadlines maps ids to the deadline they hold, none by default", () ); }); +test("parseSimBoot names the bootloader-only boards, none by default", () => { + expect(parseSimBoot("?sim=1,2,3")).toEqual([]); + expect(parseSimBoot("?sim=1,2,3&boot=3,x,0")).toEqual([3]); +}); + +test("parseSimFail maps ids to the bootloader command that fails next", () => { + expect(parseSimFail("?sim=1,2")).toEqual(new Map()); + expect(parseSimFail("?fail=1:write,2:erase,3:read,x,0:write")).toEqual( + new Map([ + [1, "write"], + [2, "erase"], + ]), + ); +}); + test("parseSimIds drops duplicates", () => { expect(parseSimIds("?sim=2,1,2,1")).toEqual([2, 1]); }); diff --git a/src/lib/backend.ts b/src/lib/backend.ts index c531237..b8301dd 100644 --- a/src/lib/backend.ts +++ b/src/lib/backend.ts @@ -1,4 +1,11 @@ -import init, { OscClient, pid, requestDevice, vid, type Track } from "@openservocore/client"; +import init, { + OscClient, + pid, + requestDevice, + vid, + type FakeFail, + type Track, +} from "@openservocore/client"; const ID_MIN = 1; const ID_MAX = 0xf9; @@ -41,6 +48,25 @@ export function parseSimDeadlines(search: string): Map { return held; } +/** `&boot=3` boots those ids as boards that carry only their bootloader. */ +export function parseSimBoot(search: string): number[] { + const raw = new URLSearchParams(search).get("boot"); + return raw === null ? [] : parseIds(raw); +} + +/** `&fail=1:write` makes that id's bootloader fail its next erase or write. */ +export function parseSimFail(search: string): Map { + const raw = new URLSearchParams(search).get("fail"); + const fails = new Map(); + for (const part of raw?.split(",") ?? []) { + const m = /^(\d+):(erase|write)$/.exec(part); + if (m === null) continue; + const id = Number(m[1]); + if (id >= ID_MIN && id <= ID_MAX) fails.set(id, m[2] as FakeFail); + } + return fails; +} + export function simRequested(): boolean { return parseSimIds(window.location.search) !== undefined; } @@ -69,12 +95,16 @@ export async function openClient(): Promise { const track = await simTrack(); const virgin = parseSimVirgin(window.location.search); const held = parseSimDeadlines(window.location.search); + const boot = parseSimBoot(window.location.search); + const fail = parseSimFail(window.location.search); return OscClient.fakeWithTracks( ids.map((id) => ({ id, track, virgin: virgin.includes(id), responseDeadlineUs: held.get(id), + bootloader: boot.includes(id), + failNext: fail.get(id), })), ); } diff --git a/src/lib/firmware-update.test.ts b/src/lib/firmware-update.test.ts new file mode 100644 index 0000000..e2dee87 --- /dev/null +++ b/src/lib/firmware-update.test.ts @@ -0,0 +1,170 @@ +import type { BootRow, ImageCheck, Version } from "@openservocore/client"; +import { expect, test } from "vitest"; +import { + canEnter, + COMPAT_UNSET, + deviceLabel, + fileRefusal, + installedVersion, + leaveWarning, + locked, + LOST_WARNING, + matching, + preselect, + progressText, + restartResult, + stepIndex, + STUCK_WARNING, + UPDATE_RATE_DEFAULT, + updatingText, + verdictText, + type LoadedFile, + type Phase, +} from "./firmware-update"; + +const KEY = 0x1234_abcd; + +function file(key: string, check: ImageCheck): LoadedFile { + return { key, name: `${key}.bin`, bytes: new Uint8Array(), check }; +} + +function ok(key: string, compat: number): LoadedFile { + return file(key, { kind: "ok", compat, version: [1, 2, 3], size: 1002 }); +} + +function row(uid: string, compat: number, app = true): BootRow { + return { + uid, + compat, + appVersion: app ? [0, 9, 0] : null, + bootVersion: [0, 6, 2], + capacity: 58_112, + }; +} + +test("the update rate defaults to 0.5 M", () => { + expect(UPDATE_RATE_DEFAULT).toBe("b500000"); +}); + +test("a row's menu lists only the files carrying its key", () => { + const files = [ok("a", KEY), ok("b", KEY ^ 1), file("elf", { kind: "elf" })]; + expect(matching(KEY, files).map((f) => f.key)).toEqual(["a"]); + expect(preselect(KEY, files)).toBe("a"); + expect(matching(0x5555, files)).toEqual([]); + expect(preselect(0x5555, files)).toBeUndefined(); +}); + +test("a keyless bootloader takes every image and preselects none of several", () => { + const files = [ok("a", KEY), ok("b", KEY ^ 1), file("short", { kind: "tooShort", len: 3 })]; + expect(matching(COMPAT_UNSET, files).map((f) => f.key)).toEqual(["a", "b"]); + expect(preselect(COMPAT_UNSET, files)).toBeUndefined(); + expect(preselect(COMPAT_UNSET, [ok("a", KEY)])).toBe("a"); +}); + +test("two files for one board preselect neither", () => { + expect(preselect(KEY, [ok("a", KEY), ok("b", KEY)])).toBeUndefined(); +}); + +test("Enter needs the bus in hand and one usable image", () => { + expect(canEnter([ok("a", KEY)], true)).toBe(true); + expect(canEnter([ok("a", KEY)], false)).toBe(false); + expect(canEnter([file("elf", { kind: "elf" })], true)).toBe(false); + expect(canEnter([], true)).toBe(false); +}); + +test("the dialog locks from Enter until the servos are back", () => { + const phases: Phase[] = [ + "prepare", + "entering", + "devices", + "flashing", + "flashed", + "restarting", + "done", + ]; + expect(phases.map((p) => locked(p, false))).toEqual([false, true, true, true, true, true, false]); + expect(phases.map(stepIndex)).toEqual([0, 1, 2, 3, 4, 4, 5]); +}); + +test("a refused file reads as a plain sentence", () => { + expect(fileRefusal({ kind: "elf" })).toBe("This is an ELF file: pick the .bin"); + expect(fileRefusal({ kind: "tooShort", len: 3 })).toBe("This file is not a firmware image"); + expect(fileRefusal({ kind: "misaligned", len: 1000 })).toBe("This file is not a firmware image"); + expect(fileRefusal({ kind: "ok", compat: KEY, version: [1, 2, 3], size: 1002 })).toBeUndefined(); +}); + +test("each install verdict reads as words", () => { + expect(verdictText({ kind: "installed" })).toBe("Installed"); + expect(verdictText({ kind: "incompatible", image: 1, device: 2 })).toBe( + "This file is for another board", + ); + expect(verdictText({ kind: "tooLarge", size: 61_000, capacity: 58_112 })).toBe( + "Does not fit: 60 KB of 56 KB", + ); + expect(verdictText({ kind: "status", status: "writeError" })).toBe( + "The board could not write its flash", + ); + expect(verdictText({ kind: "crcMismatch", image: 1, device: 2 })).toBe( + "The written image did not verify", + ); + expect(verdictText({ kind: "noDevice" })).toBe("The board did not answer"); +}); + +test("progress and the header count read as words", () => { + expect(progressText(0, 44_000)).toBe("Preparing"); + expect(progressText(12_800, 44_000)).toBe("Writing 12 of 43 KB"); + expect(progressText(44_000, 44_000)).toBe("Verifying"); + expect(updatingText(1, 3)).toBe("Updating 2 of 3"); +}); + +test("a row names the roster servo by uid, else a board with no app", () => { + const roster = [{ id: 4, uid: "00000000AABBCCDDEEFF001122334455" }]; + expect(deviceLabel(row("aabbccddeeff001122334455", KEY), roster)).toBe("ID 4"); + expect(deviceLabel(row("000000000000000000000001", KEY, false), roster)).toBe("No app"); +}); + +test("after the restart the installed servos are marked and the rest wait", () => { + const rows = [ + row("aaaaaaaaaaaaaaaaaaaaaaa1", KEY), + row("aaaaaaaaaaaaaaaaaaaaaaa2", KEY), + row("aaaaaaaaaaaaaaaaaaaaaaa3", KEY, false), + ]; + const found = [ + { + id: 1, + uid: "00000000aaaaaaaaaaaaaaaaaaaaaaa1", + version: [1, 2, 3] as [number, number, number], + }, + { + id: 2, + uid: "00000000aaaaaaaaaaaaaaaaaaaaaaa2", + version: [0, 9, 0] as [number, number, number], + }, + ]; + const result = restartResult(found, rows, new Set(["aaaaaaaaaaaaaaaaaaaaaaa1"])); + expect(result.back).toEqual([ + { id: 1, version: [1, 2, 3], updated: true }, + { id: 2, version: [0, 9, 0], updated: false }, + ]); + expect(result.waiting.map((r) => r.uid)).toEqual(["aaaaaaaaaaaaaaaaaaaaaaa3"]); +}); + +test("a lost adapter unlocks the dialog and always says why", () => { + expect(locked("flashed", true)).toBe(false); + expect(leaveWarning("flashed", true, false)).toBe(LOST_WARNING); + expect(leaveWarning("devices", false, false)).toBeUndefined(); + expect(leaveWarning("devices", false, true)).toBe(STUCK_WARNING); + expect(leaveWarning("prepare", true, true)).toBeUndefined(); + expect(leaveWarning("done", true, true)).toBeUndefined(); +}); + +test("the installed version is the app's own report unless the bootloader's is newer", () => { + const apps = [{ uid: "00000000aaaaaaaaaaaaaaaaaaaaaaa1", version: [0, 9, 0] as Version }]; + const servo = row("aaaaaaaaaaaaaaaaaaaaaaa1", KEY); + expect(installedVersion({ ...servo, appVersion: null }, apps)).toEqual([0, 9, 0]); + expect(installedVersion({ ...servo, appVersion: [0, 8, 9] }, apps)).toEqual([0, 9, 0]); + expect(installedVersion({ ...servo, appVersion: [1, 0, 0] }, apps)).toEqual([1, 0, 0]); + const board = row("aaaaaaaaaaaaaaaaaaaaaaa3", KEY, false); + expect(installedVersion(board, apps)).toBeNull(); + expect(installedVersion({ ...board, appVersion: [0, 1, 0] }, apps)).toEqual([0, 1, 0]); +}); diff --git a/src/lib/firmware-update.ts b/src/lib/firmware-update.ts new file mode 100644 index 0000000..aaeaa56 --- /dev/null +++ b/src/lib/firmware-update.ts @@ -0,0 +1,231 @@ +import type { + BaudRate, + BootRow, + BootStatus, + Found, + ImageCheck, + InstallVerdict, + Version, +} from "@openservocore/client"; + +/** Slower is safer on long or unknown wiring; the user may pick a higher rate. */ +export const UPDATE_RATE_DEFAULT: BaudRate = "b500000"; + +/** A bootloader holding this key skips the check and takes every image. */ +export const COMPAT_UNSET = 0xffff_ffff; + +export interface LoadedFile { + key: string; + name: string; + bytes: Uint8Array; + check: ImageCheck; +} + +export type Phase = + "prepare" | "entering" | "devices" | "flashing" | "flashed" | "restarting" | "done"; + +export const STEPS = ["Prepare", "Enter bootloaders", "Devices", "Flash", "Restart"] as const; + +/** The stepper position; past the last step once the servos are back. */ +export function stepIndex(phase: Phase): number { + switch (phase) { + case "prepare": + return 0; + case "entering": + return 1; + case "devices": + return 2; + case "flashing": + return 3; + case "flashed": + case "restarting": + return 4; + case "done": + return 5; + } +} + +export const STUCK_WARNING = + "1 or more servos are still in bootloader mode. Exiting now will leave them stuck in this mode."; + +export const LOST_WARNING = + "The adapter disconnected. 1 or more servos may still be in bootloader mode: reconnect and restart them from Update firmware."; + +function midway(phase: Phase): boolean { + return phase !== "prepare" && phase !== "done"; +} + +/** + * From Enter until the servos are back, leaving would strand them in their + * bootloaders; with the adapter gone Restart all cannot reach them, so the + * dialog lets go. + */ +export function locked(phase: Phase, lost: boolean): boolean { + return midway(phase) && !lost; +} + +/** The line beside Restart all: the lost adapter always, the stuck servos once a leave was tried. */ +export function leaveWarning(phase: Phase, lost: boolean, warned: boolean): string | undefined { + if (!midway(phase)) return undefined; + if (lost) return LOST_WARNING; + return warned ? STUCK_WARNING : undefined; +} + +export function canEnter(files: readonly LoadedFile[], held: boolean): boolean { + return held && files.some((f) => f.check.kind === "ok"); +} + +export function fileRefusal(check: ImageCheck): string | undefined { + switch (check.kind) { + case "ok": + return undefined; + case "elf": + return "This is an ELF file: pick the .bin"; + case "tooShort": + case "misaligned": + return "This file is not a firmware image"; + } +} + +/** The loaded images a bootloader with `compat` takes. */ +export function matching(compat: number, files: readonly LoadedFile[]): LoadedFile[] { + return files.filter( + (f) => f.check.kind === "ok" && (compat === COMPAT_UNSET || f.check.compat === compat), + ); +} + +export function preselect(compat: number, files: readonly LoadedFile[]): string | undefined { + const m = matching(compat, files); + return m.length === 1 ? m[0]?.key : undefined; +} + +function sameChip(found: Found, row: BootRow): boolean { + return found.uid.toLowerCase().endsWith(row.uid.toLowerCase()); +} + +export function rosterId(row: BootRow, roster: readonly Found[]): number | undefined { + return roster.find((f) => sameChip(f, row))?.id; +} + +export function deviceLabel(row: BootRow, roster: readonly Found[]): string { + const id = rosterId(row, roster); + if (id !== undefined) return `ID ${id}`; + return row.appVersion === null ? "No app" : "Not on the roster"; +} + +/** What a servo's app reported about itself before Enter, by its 32-char uid. */ +export interface AppReport { + uid: string; + version: Version; +} + +function newer(a: Version, b: Version): boolean { + for (let i = 0; i < 3; i++) { + const [x, y] = [a[i] ?? 0, b[i] ?? 0]; + if (x !== y) return x > y; + } + return false; +} + +/** + * The installed app's version. A bootloader reports one only for an image it + * verified itself, so an app flashed by a debugger reads null there; the + * app's own report wins unless the bootloader's is newer. + */ +export function installedVersion(row: BootRow, apps: readonly AppReport[]): Version | null { + const app = apps.find((a) => a.uid.toLowerCase().endsWith(row.uid.toLowerCase()))?.version; + if (app === undefined) return row.appVersion; + return row.appVersion !== null && newer(row.appVersion, app) ? row.appVersion : app; +} + +export function shortUid(uid: string): string { + return uid.slice(-8); +} + +export function hex32(n: number): string { + return `0x${n.toString(16).padStart(8, "0")}`; +} + +function kbUp(bytes: number): number { + return Math.ceil(bytes / 1024); +} + +function kbDown(bytes: number): number { + return Math.floor(bytes / 1024); +} + +export function progressText(written: number, total: number): string { + if (written === 0) return "Preparing"; + if (written >= total) return "Verifying"; + return `Writing ${kbDown(written)} of ${kbUp(total)} KB`; +} + +export function updatingText(index: number, count: number): string { + return `Updating ${index + 1} of ${count}`; +} + +export function bootStatusText(status: BootStatus): string { + switch (status) { + case "writeError": + return "The board could not write its flash"; + case "crcMismatch": + return "The written image did not verify"; + case "addrOutOfBounds": + return "The board refused an address outside its app space"; + case "unsupported": + return "The board does not support this step"; + case "payloadOverflow": + return "The board refused a write that was too long"; + case "incompatible": + return "This file is for another board"; + case "request": + case "ok": + return "The board gave an unexpected reply"; + } +} + +export function verdictText(v: InstallVerdict): string { + switch (v.kind) { + case "installed": + return "Installed"; + case "noDevice": + return "The board did not answer"; + case "incompatible": + return "This file is for another board"; + case "tooLarge": + return `Does not fit: ${kbUp(v.size)} KB of ${kbDown(v.capacity)} KB`; + case "crcMismatch": + return "The written image did not verify"; + case "status": + return bootStatusText(v.status); + } +} + +export interface BackServo { + id: number; + version: Version; + updated: boolean; +} + +export interface RestartResult { + back: BackServo[]; + waiting: BootRow[]; +} + +/** + * The servos the walk after the reset found, with the ones this session + * installed marked, and every listed bootloader that did not come back. + */ +export function restartResult( + found: readonly (Found & { version: Version })[], + rows: readonly BootRow[], + installed: ReadonlySet, +): RestartResult { + const back = found.map((f) => ({ + id: f.id, + version: f.version, + updated: rows.some((r) => installed.has(r.uid) && sameChip(f, r)), + })); + const waiting = rows.filter((r) => !found.some((f) => sameChip(f, r))); + return { back, waiting }; +} diff --git a/tests/e2e/firmware.spec.ts b/tests/e2e/firmware.spec.ts new file mode 100644 index 0000000..f625edf --- /dev/null +++ b/tests/e2e/firmware.spec.ts @@ -0,0 +1,118 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; +import { CORE_VERSION } from "./helpers"; + +// The simulated bootloaders all hold compat_key("osc-dev-v006"): 32-bit FNV-1a. +function compatKey(name: string): number { + let h = 0x811c9dc5; + for (const b of new TextEncoder().encode(name)) h = Math.imul(h ^ b, 0x01000193) >>> 0; + return h; +} + +/** A `.bin` ending in the tinyboot tail `[compat u32, version u16]`, 2 bytes past a word. */ +function image(version: [number, number, number]): Buffer { + const len = 1002; + const bytes = Buffer.alloc(len, 0x5a); + const [major, minor, patch] = version; + bytes.writeUInt32LE(compatKey("osc-dev-v006"), len - 6); + bytes.writeUInt16LE((major << 11) | (minor << 6) | patch, len - 2); + return bytes; +} + +async function openDialog(page: Page, search: string): Promise { + await page.goto(`/?${search}`); + await expect(page.getByRole("button", { name: /^Connected/ })).toBeVisible(); + await page.getByRole("button", { name: "Update firmware" }).click(); + const dialog = page.getByRole("dialog", { name: "Update firmware" }); + await dialog.getByLabel("Firmware files").setInputFiles({ + name: "osc-9.8.7.bin", + mimeType: "application/octet-stream", + buffer: image([9, 8, 7]), + }); + await expect(dialog.getByText("Version 9.8.7")).toBeVisible(); + await dialog.getByRole("button", { name: "Enter bootloaders" }).click(); + return dialog; +} + +function row(dialog: Locator, label: string): Locator { + return dialog.getByRole("row").filter({ hasText: label }); +} + +test("a matching image installs on two boards and they come back on the new version", async ({ + page, +}) => { + const dialog = await openDialog(page, "sim=1,2,3&boot=3"); + await expect(row(dialog, "ID 1")).toBeVisible(); + await expect(row(dialog, "ID 2")).toBeVisible(); + await expect(row(dialog, "No app")).toBeVisible(); + await expect(row(dialog, "ID 1")).toContainText(CORE_VERSION); + + await dialog.getByRole("checkbox", { name: /^Update ID 1 / }).click(); + await dialog.getByRole("checkbox", { name: /^Update No app / }).click(); + await dialog.getByRole("button", { name: "Flash selected" }).click(); + await expect(row(dialog, "ID 1")).toContainText("Installed"); + await expect(row(dialog, "No app")).toContainText("Installed"); + await expect(row(dialog, "ID 2")).not.toContainText("Installed"); + + await dialog.getByRole("button", { name: "Restart all" }).click(); + const back = (id: number) => + dialog + .getByRole("region", { name: "Back on the bus" }) + .getByRole("listitem") + .filter({ hasText: `ID ${id}` }); + await expect(back(1)).toContainText("9.8.7"); + await expect(back(1)).toContainText("new"); + await expect(back(3)).toContainText("9.8.7"); + await expect(back(2)).toContainText(CORE_VERSION); + await expect(back(2)).not.toContainText("new"); + + await dialog.getByRole("button", { name: "Close" }).click(); + await expect(dialog).toBeHidden(); + await expect(page.getByRole("button", { name: "ID 3" })).toBeVisible(); +}); + +test("after Enter the dialog stays open and a failed board still waits after Restart all", async ({ + page, +}) => { + const dialog = await openDialog(page, "sim=1,2&fail=1:write"); + await expect(row(dialog, "ID 1")).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(dialog).toBeVisible(); + await expect( + dialog.getByText( + "1 or more servos are still in bootloader mode. Exiting now will leave them stuck in this mode.", + ), + ).toBeVisible(); + + await dialog.getByRole("checkbox", { name: /^Update ID 1 / }).click(); + await dialog.getByRole("checkbox", { name: /^Update ID 2 / }).click(); + await dialog.getByRole("button", { name: "Flash selected" }).click(); + await expect(row(dialog, "ID 1")).toContainText("The board could not write its flash"); + await expect(row(dialog, "ID 2")).toContainText("Installed"); + + await dialog.getByRole("button", { name: "Restart all" }).click(); + const waiting = dialog.getByRole("region", { name: "Waiting for firmware" }); + await expect(waiting).toContainText("ID 1"); + await expect(dialog.getByRole("region", { name: "Back on the bus" })).toContainText("ID 2"); + await expect(dialog.getByRole("region", { name: "Back on the bus" })).not.toContainText("ID 1"); +}); + +test("a lost adapter unlocks the dialog and hands over to Reconnect", async ({ page }) => { + const dialog = await openDialog(page, "sim=1,2"); + await expect(row(dialog, "ID 1")).toBeVisible(); + + await page.evaluate(() => { + (window as unknown as { __osc: { sever: () => void } }).__osc.sever(); + }); + await expect( + dialog.getByText( + "The adapter disconnected. 1 or more servos may still be in bootloader mode: reconnect and restart them from Update firmware.", + ), + ).toBeVisible(); + await expect(dialog.getByRole("button", { name: "Restart all" })).toBeHidden(); + + await dialog.getByRole("button", { name: "Close" }).click(); + await expect(dialog).toBeHidden(); + await page.getByRole("button", { name: "Connection lost" }).click(); + await expect(page.getByRole("dialog").getByRole("button", { name: "Reconnect" })).toBeVisible(); +});