diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..ef4a6d5 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,54 @@ +name: Pages + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build Pages bundle + working-directory: apps/web + run: bun run build:pages + + - name: Configure Pages + uses: actions/configure-pages@v5 + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: apps/web/.pages + + deploy: + if: github.ref == 'refs/heads/main' + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index a64b5c0..e51e036 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,9 @@ node_modules/ apps/cli/bin/ apps/cli/dist/ apps/web/dist/ +apps/web/.pages/ +playwright-report/ +test-results/ # Capture files (large binary data) captures/*.sal diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index b6d330b..e6a21d9 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -33,6 +33,7 @@ export interface GlobalFlags { json: boolean; quiet: boolean; yes: boolean; + debug: boolean; } export interface ParsedArgs { @@ -60,7 +61,7 @@ function isNegativeNumberToken(arg: string): boolean { } export function parseArgs(argv: string[]): ParsedArgs { - const global: GlobalFlags = { json: false, quiet: false, yes: false }; + const global: GlobalFlags = { json: false, quiet: false, yes: false, debug: false }; const flags: Record = {}; const positional: string[] = []; let command: string | null = null; @@ -100,6 +101,7 @@ export function parseArgs(argv: string[]): ParsedArgs { if (name === "json") global.json = true; else if (name === "quiet") global.quiet = true; else if (name === "yes") global.yes = true; + else if (name === "debug") global.debug = true; else flags[name] = value; } else if (arg.startsWith("-") && arg.length > 1) { // short flags: -y, -h, etc @@ -196,7 +198,8 @@ export async function main(argv: string[]): Promise { if (parsed.flags.svo) format = "svo"; else if (parsed.flags.hex) format = "hex"; else if (parsed.flags.json || parsed.global.json) format = "json"; - return await runRead(parsed.global, { format }); + const debug = parsed.flags.debug === true; + return await runRead(parsed.global, { format, debug }); } case "write": { @@ -338,11 +341,14 @@ function printCommandHelp(command: string): void { process.stdout.write( `axon read — read the current config from the servo\n\n` + `FLAGS:\n` + - ` (default) Human-readable summary (named parameters not yet shown)\n` + - ` --json Machine-readable JSON with raw bytes\n` + + ` (default) Human-readable summary of decoded parameters\n` + + ` --json Machine-readable JSON with decoded parameters\n` + ` --svo Raw 95-byte dump to stdout (vendor .svo compatible)\n` + - ` --hex Annotated hex dump (for debugging)\n\n` + - `Example:\n` + + ` --hex Annotated hex dump (for debugging)\n` + + ` --debug Include raw block metadata in normal / --json output\n\n` + + `Examples:\n` + + ` axon read\n` + + ` axon read --json\n` + ` axon read --svo > backup.svo\n`, ); break; @@ -361,7 +367,7 @@ function printCommandHelp(command: string): void { ); break; case "get": - printGetHelp({ json: false, quiet: false, yes: false }); + printGetHelp({ json: false, quiet: false, yes: false, debug: false }); break; case "set": process.stdout.write( diff --git a/apps/cli/src/commands/mode.ts b/apps/cli/src/commands/mode.ts index 7fb2d11..2562eef 100644 --- a/apps/cli/src/commands/mode.ts +++ b/apps/cli/src/commands/mode.ts @@ -441,6 +441,7 @@ async function runModeSet( await flashFirmware(handle, decrypted, { expectedModelId: modelId ?? undefined, onProgress: makeProgressSink(global), + onWireDebug: makeWireDebugSink(global), cmdSleepMs: Number.isFinite(cmdSleepOverride) ? cmdSleepOverride : undefined, }); @@ -589,18 +590,55 @@ function friendlyModeName(mode: ServoMode): string { } /** Returns true when AXON_DEBUG=1 is set. */ -function isDebug(): boolean { - return process.env.AXON_DEBUG === "1"; +function isDebug(global?: GlobalFlags): boolean { + return global?.debug === true || process.env.AXON_DEBUG === "1"; +} + +function timestampLabel(date: Date): string { + const core = date.toLocaleTimeString([], { + hour: "numeric", + minute: "2-digit", + second: "2-digit", + }); + return `${core}.${date.getMilliseconds().toString().padStart(3, "0")}`; +} + +function elapsedLabel(ms: number): string { + const seconds = (ms / 1000).toFixed(3); + return `+${seconds}s`; +} + +function formatProgressDebugLine(event: FlashProgressEvent): string { + const parts = [`Flash ${event.phase}`]; + if (typeof event.recordsSent === "number" && typeof event.recordsTotal === "number") { + parts.push(`${event.recordsSent}/${event.recordsTotal} records`); + } + if (typeof event.bytesSent === "number" && typeof event.bytesTotal === "number") { + parts.push(`${event.bytesSent}/${event.bytesTotal} bytes`); + } + if (event.message) { + parts.push(event.message); + } + return parts.join(" - "); } function makeProgressSink(global: GlobalFlags): FlashProgressFn { if (global.json || global.quiet) return () => {}; const isTTY = process.stderr.isTTY === true; + const debug = isDebug(global); + let firstEventAt: number | null = null; let started = false; return (e: FlashProgressEvent) => { - if (isDebug()) { - process.stderr.write(` [debug] ${e.phase}: ${e.message ?? ""}\n`); + if (debug) { + const now = new Date(); + const nowMs = now.getTime(); + if (firstEventAt === null) { + firstEventAt = nowMs; + } + process.stderr.write( + `[${timestampLabel(now)} ${elapsedLabel(nowMs - firstEventAt)}] ${formatProgressDebugLine(e)}\n`, + ); return; } if (e.bytesSent !== undefined && e.bytesTotal !== undefined && e.bytesTotal > 0) { @@ -615,5 +653,21 @@ function makeProgressSink(global: GlobalFlags): FlashProgressFn { }; } +function makeWireDebugSink(global: GlobalFlags): ((message: string) => void) | undefined { + if (!isDebug(global)) return undefined; + + let firstEventAt: number | null = null; + return (message: string) => { + const now = new Date(); + const nowMs = now.getTime(); + if (firstEventAt === null) { + firstEventAt = nowMs; + } + process.stderr.write( + `[${timestampLabel(now)} ${elapsedLabel(nowMs - firstEventAt)}] [wire] ${message}\n`, + ); + }; +} + export type { IdentifyReply, ServoModel }; export { loadCatalog, loadServoModes, parseModelId }; diff --git a/apps/cli/src/commands/read.ts b/apps/cli/src/commands/read.ts index 89a7051..74beb41 100644 --- a/apps/cli/src/commands/read.ts +++ b/apps/cli/src/commands/read.ts @@ -8,18 +8,22 @@ * --hex annotated hex dump (debug) */ -import { findModel, loadCatalog } from "../catalog.ts"; +import { findModel, loadCatalog, loadNotYetMapped } from "../catalog.ts"; import type { GlobalFlags } from "../cli.ts"; import { openDongle } from "../driver/hid.ts"; -import { modelIdFromConfig, readFullConfig } from "../driver/protocol.ts"; +import { identify, modelIdFromConfig, readFullConfig, type ServoMode } from "../driver/protocol.ts"; import { ExitCode } from "../errors.ts"; +import { listParameters, type ParameterValue } from "../parameters.ts"; +import { renderParamTable, renderStatusBar } from "../util/tui.ts"; export interface ReadFlags { format: "human" | "json" | "svo" | "hex"; + debug: boolean; } export async function runRead(global: GlobalFlags, local: ReadFlags): Promise { const handle = await openDongle(); + const identifyReply = await identify(handle); let config: Buffer; try { config = await readFullConfig(handle); @@ -37,38 +41,122 @@ export async function runRead(global: GlobalFlags, local: ReadFlags): Promise spec.modes.includes(mode)) + .map((spec) => { + const value = spec.read(config, modelId); + return { + name: spec.name, + vendor_label: spec.vendorLabel, + unit: value.unit ?? spec.unit, + value: formatPhysical(value), + raw: value.raw, + ...(value.notes ? { notes: value.notes } : {}), + }; + }); + + const mapped: Record = {}; + for (const row of rows) { + mapped[row.name] = { + vendor_label: row.vendor_label, + unit: row.unit, + value: row.value, + raw: row.raw, + ...(row.notes ? { notes: row.notes } : {}), + }; + } + + const notYetMapped = Object.values(loadNotYetMapped()) + .filter((entry) => entry.modes.includes(mode)) + .map((entry) => ({ + name: entry.name, + vendor_label: entry.vendor_label, + reason_blocked: entry.reason_blocked, + })); + + return { mapped, notYetMapped, rows }; +} + +function emitJson(config: Buffer, mode: ServoMode, debug: boolean): void { const modelId = modelIdFromConfig(config); const catalog = loadCatalog(); const model = findModel(catalog, modelId); - const result = { + const { mapped, notYetMapped } = decodedParameters(config, modelId, mode); + const result: Record = { model: { id: modelId, name: model?.name ?? null, known: !!model, docs_url: model?.docs_url ?? null, }, - raw_bytes_hex: config.toString("hex"), - byte_count: config.length, - parameters: { - _note: "Named parameters not yet implemented in v1 scaffold. See docs/BYTE_MAPPING.md.", - }, + mode, + mode_label: friendlyModeLabel(mode), + parameters: mapped, + not_yet_mapped: notYetMapped, }; + if (debug) { + result.raw_bytes_hex = config.toString("hex"); + result.byte_count = config.length; + } process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); } -function emitHuman(config: Buffer, _global: GlobalFlags): void { +function formatHumanValue(value: ParameterValue): string { + if (value.physical === undefined || value.physical === null) { + if (Array.isArray(value.raw)) return `raw ${value.raw.join(" ")}`; + return `raw ${value.raw}`; + } + const unit = value.unit ?? ""; + if (unit === "deg") return `${value.physical}°`; + if (unit === "us") { + const n = value.physical as number; + return `${n > 0 ? "+" : ""}${n} µs`; + } + if (unit === "percent") return `${value.physical}%`; + if (unit === "step") return `step ${value.physical}`; + return String(value.physical); +} + +function emitHuman(config: Buffer, mode: ServoMode, _global: GlobalFlags, debug: boolean): void { const modelId = modelIdFromConfig(config); const catalog = loadCatalog(); const model = findModel(catalog, modelId); + const { rows, notYetMapped } = decodedParameters(config, modelId, mode); + const humanRows = rows.map((row) => ({ + name: row.name, + value: formatHumanValue({ + raw: row.raw as number | number[], + physical: row.value as string | number | boolean | undefined, + unit: row.unit, + }), + unit: row.unit, + })); + process.stdout.write( + `${renderStatusBar({ + adapter: true, + servoName: model?.name ?? modelId, + modeName: friendlyModeLabel(mode), + })}\n`, + ); process.stdout.write(`model ${modelId}`); if (model?.name) process.stdout.write(` (${model.name})`); if (!model) process.stdout.write(` [unknown to catalog]`); @@ -76,15 +164,19 @@ function emitHuman(config: Buffer, _global: GlobalFlags): void { if (model?.docs_url) { process.stdout.write(`docs ${model.docs_url}\n`); } - process.stdout.write( - `block ${config.length} bytes (magic ${config.subarray(0, 4).toString("hex")})\n`, - ); + if (debug) { + process.stdout.write( + `block ${config.length} bytes (magic ${config.subarray(0, 4).toString("hex")})\n`, + ); + } process.stdout.write("\n"); - process.stdout.write( - "Named parameters not yet shown in v1 scaffold.\n" + - "Use `axon read --svo > cfg.svo` to save the block, or\n" + - "`axon read --hex` to see the raw byte layout.\n", - ); + process.stdout.write(`${renderParamTable(humanRows)}\n`); + if (notYetMapped.length > 0) { + process.stdout.write("\nNot yet mapped:\n"); + for (const entry of notYetMapped) { + process.stdout.write(` ${entry.name} (${entry.vendor_label})\n`); + } + } } function hexDump(buf: Buffer): string { diff --git a/apps/cli/src/driver/flash.ts b/apps/cli/src/driver/flash.ts index 8fccff3..b35b89e 100644 --- a/apps/cli/src/driver/flash.ts +++ b/apps/cli/src/driver/flash.ts @@ -1,582 +1 @@ -/** - * Axon dongle firmware-flash wire protocol. - * - * This module implements the HID-side command sequence the vendor - * exe uses to flash a new .sfw firmware to the connected servo. - * It is NOT the same command family as the config read/write - * protocol in `protocol.ts` — the flash commands live in a separate - * group (0x80..0x83, 0x90) and use a different HID OUT layout: - * - * tx[0] = 0x04 // report id - * tx[1] = // 0x80 / 0x81 / 0x82 / 0x83 / 0x90 - * tx[2] = // payload byte count (never >22 here) - * tx[3..3+length] = - * - * Reverse engineered from - * `research/static-analysis/ghidra_out/firmware_handler.c` - * (FUN_004065b0 — the top-level "flash firmware" handler; ~800 lines - * of Ghidra decomp). The corresponding wire transmission sub-helpers - * are FUN_00408220 (write) and FUN_004082f0 (read). Both send the - * full 64-byte HID report every time; the dongle fills untransmitted - * bytes with zero. - * - * State machine (per firmware_handler.c): - * - * 1. (vendor optional) write 0x90 with param[2]=0 -- "enter flash - * mode" lock. The CLI intentionally omits this command because - * hardware testing showed it can leave the dongle wedged until - * physically replugged. - * 2. write 0x80 payload = { 0x01, 0x02, 0x03, 0x04 } - * read reply (6 bytes); reply[0] must be 0x56 ('V'). reply[1,3] - * encode the boot version (`V0x2y`); we reject `V02` as too old. - * 3. decrypt .sfw -> plaintext (see `sfw.ts`). - * 4. generate 22 random bytes `challenge[0..21]`. - * write 0x81 payload = challenge. - * read 22 bytes `response`. - * compute `key[i] = challenge[i] ^ response[i]` for i=0..21. - * This XOR key is mixed into every subsequent 0x82 payload. - * 5. verify `sfw.header.typeBytes` match the boot-query reply bytes - * (reply[4],reply[5]). - * The vendor exe shows "Error 1030" if they don't match. - * 6. verify `sfw.header.modelId` equals the servo's current model id - * (from the `@0801SA33` line vs the config-block model id). The - * vendor exe shows "Error 1031" if they don't match. - * 7. for each sector-erase line in order: - * buf[0] = 0x0A - * buf[1] = sector.bytes[0] - * buf[2] = sector.bytes[1] - * buf[3..21] = random - * buf[i] ^= key[i] for i in 0..21 - * write 0x82 + 22-byte buf - * read 1 byte; must equal 0x55 or the erase failed. - * 8. for each Intel HEX record in order: - * buf[0] = 0x3A (the `:` char) - * buf[1] = record.count - * buf[2] = (record.address >> 8) & 0xff - * buf[3] = record.address & 0xff - * buf[4] = record.type - * buf[5..5+count-1] = record.data - * buf[5+count] = record.checksum - * buf[(5+count+1)..21] = random - * capture `expectedReply = buf[5+count]` BEFORE XOR (i.e. the - * record's checksum byte) - * buf[i] ^= key[i] for i in 0..21 - * write 0x82 + 22-byte buf - * read 1 byte; must equal `expectedReply`. - * 9. after the `:00000001FF` EOF record has been sent successfully: - * write 0x83 payload = { 0xFF, 0x55, 0xAA, <19 bytes> } again - * read 1 byte (discarded) -- "flash complete; re-enter normal - * operation". - * 10. (vendor optional) write 0x90 with param[2]=1 -- "exit flash - * mode". The CLI omits this for the same reason as step 1. - * - * Chunk transfer rate: the parameter-write helper at FUN_00404900 - * caps chunks at 59 bytes with a 25 ms inter-chunk sleep. The flash - * commands all fit in a single 22-byte payload, so this module only - * needs the 25 ms inter-command sleep to avoid overrunning the - * dongle's HID input queue. That matches the `Sleep(0x19)` at the - * top of FUN_00404900. - */ - -import { randomBytes } from "node:crypto"; -import { AxonError } from "../errors.ts"; -import type { DecryptedSfw, IntelHexRecord } from "../sfw.ts"; -import { REPORT_SIZE } from "./hid.ts"; -import type { DongleHandle } from "./transport.ts"; - -// ---- flash command bytes -------------------------------------------------- - -/** "Enter/exit flash mode" — documented for the vendor path but not sent by the CLI. */ -export const CMD_MODE_LOCK = 0x90; -/** Query bootloader version. */ -export const CMD_BOOT_QUERY = 0x80; -/** Challenge / key exchange: host sends 22-byte challenge, dongle - * returns 22-byte response; XOR is the per-session key. */ -export const CMD_KEY_EXCHANGE = 0x81; -/** Sector erase / hex record write — payload is XOR-encrypted. */ -export const CMD_DATA_WRITE = 0x82; -/** Flash session start / stop marker (`FF 55 AA ...`). */ -export const CMD_FLASH_MARKER = 0x83; - -/** Payload prefix for a sector-erase 0x82 command. */ -export const DATA_PREFIX_SECTOR_ERASE = 0x0a; -/** Payload prefix for a hex-record 0x82 command: ASCII `:` char. */ -export const DATA_PREFIX_HEX_RECORD = 0x3a; - -/** Size of every 0x82 payload (and the 0x81 challenge). */ -export const FLASH_PAYLOAD_SIZE = 22; // 0x16 -/** Max Intel HEX data bytes that fit in one 0x82 payload. */ -export const MAX_HEX_RECORD_DATA_BYTES = FLASH_PAYLOAD_SIZE - 6; - -/** Inter-command sleep. Matches Sleep(0x19) = 25 ms in the decomp. */ -export const FLASH_CMD_SLEEP_MS = 25; - -/** Report id used for all dongle HID transfers. */ -const REPORT_ID = 0x04; - -// ---- progress callback ---------------------------------------------------- - -export type FlashPhase = - | "prepare" - | "boot_query" - | "cancel" - | "key_exchange" - | "verify_model" - | "erase" - | "write" - | "finalize" - | "done"; - -export interface FlashProgressEvent { - phase: FlashPhase; - /** Bytes of the decrypted firmware flashed so far (erase + write). */ - bytesSent?: number; - /** Total bytes of the decrypted firmware that will be flashed. */ - bytesTotal?: number; - /** Number of records processed so far (sector erases + hex records). */ - recordsSent?: number; - /** Total number of records that will be processed. */ - recordsTotal?: number; - /** Human-readable status line. */ - message?: string; -} - -export type FlashProgressFn = (event: FlashProgressEvent) => void; - -// ---- low-level helpers ---------------------------------------------------- - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** - * Build the 64-byte HID OUT report used by the flash command family. - * Layout: `04 ` with zero padding to - * REPORT_SIZE. The flash-family layout is different from the config - * read/write command family in `protocol.ts`: here there are no - * address bytes. - */ -export function buildFlashTx(cmd: number, payload: Uint8Array): Buffer { - if (payload.length > REPORT_SIZE - 3) { - throw new Error(`flash: payload is ${payload.length} bytes, max ${REPORT_SIZE - 3}`); - } - const tx = Buffer.alloc(REPORT_SIZE); - tx[0] = REPORT_ID; - tx[1] = cmd; - tx[2] = payload.length; - for (let i = 0; i < payload.length; i++) { - tx[3 + i] = payload[i] ?? 0; - } - return tx; -} - -/** - * Parse a flash-family HID IN report. The dongle echoes the tx - * layout: `04 `. The vendor exe's read - * helper (FUN_004082f0) gates on `status != 0` AND `length == 0` - * before pulling data out of `rx[5..]`. (The "length" byte here is - * NOT a payload length — it's an error/flag byte, and on a - * successful read it is zero; the actual data lives at rx[5..5+N] - * where N is the count requested by the caller.) - */ -export function parseFlashRx(rx: Buffer, expectLength: number): Buffer { - if (rx.length < 5 + expectLength) { - throw AxonError.servoIo(`flash: rx is ${rx.length} bytes, need >= ${5 + expectLength}`); - } - if (rx[1] === 0 || rx[2] !== 0) { - // Dump the first 16 bytes so we can see exactly what the servo - // returned — critical for diagnosing protocol mismatches. - const hex = Array.from(rx.subarray(0, 16)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(" "); - throw AxonError.servoIo(`flash: unexpected reply. rx[0..15]: ${hex}`); - } - return rx.subarray(5, 5 + expectLength); -} - -/** - * Send one flash-family command and read the reply. Expects a - * reply of `expectLength` bytes (up to 59); 0 is valid for - * fire-and-forget commands that still require a drain-read to keep - * the HID input queue in sync. - */ -async function exchange( - handle: DongleHandle, - cmd: number, - payload: Uint8Array, - expectLength: number, - sleepMs: number, -): Promise { - if (sleepMs > 0) await sleep(sleepMs); - const tx = buildFlashTx(cmd, payload); - await handle.write(tx); - const rx = await handle.read(); - if (process.env.AXON_DEBUG === "1") { - const txHex = Array.from(tx.subarray(0, 8)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(" "); - const rxHex = Array.from(rx.subarray(0, 16)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(" "); - process.stderr.write( - ` [wire] cmd=0x${cmd.toString(16)} tx[0..7]: ${txHex} rx[0..15]: ${rxHex}\n`, - ); - } - return parseFlashRx(rx, expectLength); -} - -/** - * Fill the tail of a payload buffer with random bytes, starting at - * offset `fromIndex`. Matches the FUN_00408164 call in the decomp, - * where the vendor exe fills unused slots with random noise so the - * on-wire traffic doesn't leak patterns. - */ -function fillRandom(buf: Buffer, fromIndex: number): void { - if (fromIndex >= buf.length) return; - const rnd = randomBytes(buf.length - fromIndex); - rnd.copy(buf, fromIndex); -} - -/** XOR-encrypt a 22-byte payload in place against the session key. */ -function xorInPlace(buf: Buffer, key: Uint8Array): void { - if (buf.length !== key.length) { - throw new Error(`flash: xor buffer/key length mismatch (${buf.length} vs ${key.length})`); - } - for (let i = 0; i < buf.length; i++) buf[i] = (buf[i] ?? 0) ^ (key[i] ?? 0); -} - -// ---- the three payload shapes --------------------------------------------- - -/** Build the (plaintext) 22-byte sector-erase command buffer. */ -export function buildSectorEraseBuf(sectorBytes: [number, number]): Buffer { - const buf = Buffer.alloc(FLASH_PAYLOAD_SIZE); - buf[0] = DATA_PREFIX_SECTOR_ERASE; - buf[1] = sectorBytes[0] & 0xff; - buf[2] = sectorBytes[1] & 0xff; - fillRandom(buf, 3); - return buf; -} - -/** - * Build the (plaintext) 22-byte hex-record command buffer. Returns - * both the buffer and the "expected reply" byte (the record's Intel - * HEX checksum, which the dongle echoes back after a successful - * write). The caller XOR-encrypts before writing; the expected reply - * is captured BEFORE the XOR so the caller can compare it against - * the raw byte the dongle returns. - */ -export function buildHexRecordBuf(record: IntelHexRecord): { - buf: Buffer; - expectedReply: number; -} { - // count + 2 addr + 1 type + count data + 1 checksum = count + 5 bytes - // after the leading ':' prefix. All of this must fit in 22 bytes - // including the prefix, so count + 5 + 1 <= 22 → count <= 16. - const total = record.count + 5 + 1; // +1 for the ':' prefix byte - if (total > FLASH_PAYLOAD_SIZE) { - throw AxonError.validation( - `flash: hex record has count=${record.count}, max is ${MAX_HEX_RECORD_DATA_BYTES} bytes`, - ); - } - const buf = Buffer.alloc(FLASH_PAYLOAD_SIZE); - buf[0] = DATA_PREFIX_HEX_RECORD; - buf[1] = record.count & 0xff; - buf[2] = (record.address >> 8) & 0xff; - buf[3] = record.address & 0xff; - buf[4] = record.type & 0xff; - for (let i = 0; i < record.count; i++) { - buf[5 + i] = record.data[i] ?? 0; - } - buf[5 + record.count] = record.checksum & 0xff; - fillRandom(buf, 5 + record.count + 1); - return { buf, expectedReply: record.checksum & 0xff }; -} - -// ---- top-level flasher ---------------------------------------------------- - -export interface FlashOptions { - /** Optional per-record progress callback. */ - onProgress?: FlashProgressFn; - /** - * Connected servo's model id (from the config block at 0x40..0x47). - * Cross-checked against `sfw.header.modelId`. The vendor exe shows - * "Error 1031 Firmware is incorrect" if these don't match. - */ - expectedModelId?: string; - /** - * If set, skip the boot-query → @0801 header cross-check even when - * the bytes don't match. Only used by tests that fake the boot - * reply; production callers should leave this false. - */ - skipTypeCheck?: boolean; - /** - * Override the per-command inter-chunk sleep (ms). Defaults to - * `FLASH_CMD_SLEEP_MS` = 25 ms to match the vendor exe. Tests - * override this to 0 for fast execution. - */ - cmdSleepMs?: number; -} - -/** - * Validate everything about the decrypted firmware that can be known - * before the first HID write. This keeps malformed custom .sfw files - * from failing after sector erases or partial record writes. - */ -export function validateFlashFirmware(firmware: DecryptedSfw): void { - if (firmware.header.modelId.trim().length === 0) { - throw AxonError.validation("flash: firmware header has an empty model id."); - } - if (firmware.sectorErases.length === 0) { - throw AxonError.validation("flash: firmware contains no sector-erase records."); - } - if (firmware.hexRecords.length === 0) { - throw AxonError.validation("flash: firmware contains no Intel HEX records."); - } - - let eofCount = 0; - for (const [index, rec] of firmware.hexRecords.entries()) { - if (rec.count !== rec.data.length) { - throw AxonError.validation( - `flash: hex record addr=0x${rec.address.toString(16).padStart(4, "0")} ` + - `declares count=${rec.count} but has ${rec.data.length} data bytes`, - ); - } - if (rec.type !== 0x00 && rec.type !== 0x01) { - throw AxonError.validation( - `flash: hex record addr=0x${rec.address.toString(16).padStart(4, "0")} ` + - `has unsupported type=0x${rec.type.toString(16).padStart(2, "0")}`, - ); - } - if (rec.count > MAX_HEX_RECORD_DATA_BYTES) { - throw AxonError.validation( - `flash: hex record addr=0x${rec.address.toString(16).padStart(4, "0")} ` + - `has count=${rec.count}, max is ${MAX_HEX_RECORD_DATA_BYTES} bytes`, - ); - } - if (rec.type === 0x01) { - eofCount += 1; - if (index !== firmware.hexRecords.length - 1) { - throw AxonError.validation( - "flash: firmware Intel HEX EOF record must be the final record.", - ); - } - } - } - - if (eofCount !== 1) { - throw AxonError.validation( - `flash: firmware Intel HEX stream must contain exactly one EOF record; found ${eofCount}.`, - ); - } -} - -/** - * Flash the given decrypted firmware to the dongle/servo. The - * happy-path return is `void`; on any failure an `AxonError` is - * thrown describing the phase that failed. Progress events are - * emitted via `options.onProgress` if supplied. - * - * IMPORTANT: this function assumes the caller has already obtained - * exclusive ownership of the dongle handle. It performs no - * additional locking or re-identification — callers should run - * `identify()` before and after this function to observe the mode - * transition. - */ -export async function flashFirmware( - handle: DongleHandle, - firmware: DecryptedSfw, - options: FlashOptions = {}, -): Promise { - const progress = options.onProgress ?? (() => {}); - validateFlashFirmware(firmware); - - // Phase 1: model-id pre-check. Purely a client-side sanity check; - // the dongle's own check happens in phase 6. - if (options.expectedModelId !== undefined) { - const rawModel = firmware.header.modelId; - const cleanFirmware = rawModel.replace(/[*\0\s]+$/, ""); - const cleanExpected = options.expectedModelId.replace(/[*\0\s]+$/, ""); - if (cleanFirmware !== cleanExpected) { - throw AxonError.validation( - `firmware is for model "${rawModel}" but connected servo is "${options.expectedModelId}".`, - ); - } - } - const sleepMs = options.cmdSleepMs ?? FLASH_CMD_SLEEP_MS; - - progress({ phase: "prepare", message: "Preparing flash session..." }); - - // Phase 1b: enter flash mode (0x90 param=0). The vendor exe sends - // this when the HID product name matches its whitelist — which our - // dongle ("USBBootloader V1.3") does. Without this command the - // servo stays in normal mode and rejects the key exchange (0x81). - // - // IMPORTANT: 0x90 has a DIFFERENT wire layout than the other flash - // commands. The vendor writes directly to the HID buffer - // (firmware_handler.c:164-168), NOT through FUN_00408220. The - // param goes at tx[2] — there is no length field: - // - // tx[0] = 0x04 (report ID) - // tx[1] = 0x90 (command) - // tx[2] = 0x00 (param: 0=enter, 1=exit) - // tx[3..63] = 0x00 - // - // Do NOT use buildFlashTx here — it puts a length byte at tx[2]. - // NOTE: 0x90 "enter flash mode" is intentionally NOT sent here. - // The vendor exe only sends 0x90 when the HID product name matches - // a specific whitelist (firmware_handler.c:153-174). Sending it to - // a dongle that doesn't expect it puts the dongle into a state that - // survives handle.close() and blocks all subsequent commands (the - // dongle returns 0x02 to everything until physically unplugged for - // several seconds). The flash protocol works without 0x90 — the - // servo's MCU handles the boot query / key exchange / data write - // sequence directly. - - // Phase 2: boot version query (0x80 + 4-byte payload 01 02 03 04). - progress({ phase: "boot_query", message: "Querying bootloader version..." }); - { - const bootTx = buildFlashTx(CMD_BOOT_QUERY, Uint8Array.from([0x01, 0x02, 0x03, 0x04])); - if (sleepMs > 0) await sleep(sleepMs); - await handle.write(bootTx); - await sleep(5); // vendor exe: Sleep(5) between write and read - } - const bootRx = await handle.read(); - if (process.env.AXON_DEBUG === "1") { - const hex = Array.from(bootRx.subarray(0, 16)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(" "); - process.stderr.write(` [wire] 0x80 boot rx[0..15]: ${hex}\n`); - } - const bootReply = parseFlashRx(bootRx, 6); - if (bootReply[0] !== 0x56) { - const replyByte = bootReply[0] ?? 0; - throw AxonError.servoIo( - `flash: boot query reply[0]=0x${replyByte.toString(16)} (expected 0x56 'V')`, - ); - } - // The vendor exe refuses when (reply[3]=='2' && reply[1]=='0'): ASCII - // "V02" which is exactly the "Boot Version too old" guard. In - // practice every servo we've seen ships with a newer bootloader, - // but mirror the guard so an unexpected old device is rejected. - if (bootReply[1] === 0x30 && bootReply[3] === 0x32) { - throw AxonError.servoIo("flash: bootloader reports version V02, which is too old to flash."); - } - - // Cross-check the .sfw header's type bytes against the boot-query - // reply. The vendor exe stashes `bootReply[4]` and `bootReply[5]` - // into _DAT_007c51e9 and _DAT_007c51ea at firmware_handler.c lines - // 193-194, then compares them against the two hex-byte literals in - // the @0801XY header line after decrypting. This is the "Error - // 1030 Firmware is incorrect" guard at lines 403-404, and it's the - // ONLY wire-level "is this firmware for this servo family" check. - const [fa, fb] = firmware.header.typeBytes; - const ra = bootReply[4] ?? 0; - const rb = bootReply[5] ?? 0; - if (!options.skipTypeCheck && (fa !== ra || fb !== rb)) { - throw AxonError.validation( - `flash: firmware type bytes are [0x${fa.toString(16).padStart(2, "0")}, ` + - `0x${fb.toString(16).padStart(2, "0")}] but boot reply reported ` + - `[0x${ra.toString(16).padStart(2, "0")}, 0x${rb.toString(16).padStart(2, "0")}]. ` + - `Wrong family of firmware for this servo.`, - ); - } - - // NOTE: 0x83 "cancel" is NOT sent here. In the vendor decomp - // (firmware_handler.c:227-238), the 0x83 cancel only appears in the - // ERROR path (cVar2 == 0 → "Error Update please check connection"). - // The SUCCESS path goes straight from boot query → AES decrypt → - // key exchange. Sending 0x83 before 0x81 tells the servo "abort - // the flash session" and then the key exchange is rejected. - - // Phase 3: key exchange (0x81 + 22 random bytes). - progress({ phase: "key_exchange", message: "Negotiating flash session key..." }); - const challenge = randomBytes(FLASH_PAYLOAD_SIZE); - const response = await exchange(handle, CMD_KEY_EXCHANGE, challenge, FLASH_PAYLOAD_SIZE, sleepMs); - const key = Buffer.alloc(FLASH_PAYLOAD_SIZE); - for (let i = 0; i < FLASH_PAYLOAD_SIZE; i++) { - key[i] = (challenge[i] ?? 0) ^ (response[i] ?? 0); - } - - progress({ phase: "verify_model", message: "Verifying firmware matches servo..." }); - // The vendor exe runs the @0801 header check here as a - // post-key-exchange gate. Our decrypt already surfaced the header, - // and we've cross-checked the caller-supplied expected id in - // phase 1, so there's nothing else to do here besides emitting the - // progress event. - - // Compute totals so the caller can render a progress bar. - const totalRecords = firmware.sectorErases.length + firmware.hexRecords.length; - const totalBytes = firmware.hexRecords.reduce((acc, r) => acc + r.count, 0); - let doneRecords = 0; - let doneBytes = 0; - - // Phase 5: sector erases. - for (const sector of firmware.sectorErases) { - const buf = buildSectorEraseBuf(sector.bytes); - xorInPlace(buf, key); - const reply = await exchange(handle, CMD_DATA_WRITE, buf, 1, sleepMs); - if (reply[0] !== 0x55) { - const replyByte = reply[0] ?? 0; - throw AxonError.servoIo( - `flash: sector erase $${sector.raw} failed: reply byte 0x${replyByte.toString(16)} (expected 0x55)`, - ); - } - doneRecords += 1; - progress({ - phase: "erase", - recordsSent: doneRecords, - recordsTotal: totalRecords, - bytesSent: doneBytes, - bytesTotal: totalBytes, - message: `Erased sector $${sector.raw}`, - }); - } - - // Phase 6: hex records (including the EOF record). - for (const rec of firmware.hexRecords) { - const { buf, expectedReply } = buildHexRecordBuf(rec); - xorInPlace(buf, key); - const reply = await exchange(handle, CMD_DATA_WRITE, buf, 1, sleepMs); - // The EOF record (type=0x01) is the last thing sent. After - // receiving it the servo's MCU finishes the flash and reboots — - // the HID reply we read back is from the rebooted firmware, not - // a flash acknowledgment. Don't verify the reply for EOF. - if (rec.type !== 0x01 && reply[0] !== expectedReply) { - const replyByte = reply[0] ?? 0; - throw AxonError.servoIo( - `flash: hex record addr=0x${rec.address.toString(16).padStart(4, "0")} ` + - `type=0x${rec.type.toString(16).padStart(2, "0")} write failed: ` + - `reply 0x${replyByte.toString(16)} != expected 0x${expectedReply.toString(16)}`, - ); - } - doneRecords += 1; - doneBytes += rec.count; - progress({ - phase: "write", - recordsSent: doneRecords, - recordsTotal: totalRecords, - bytesSent: doneBytes, - bytesTotal: totalBytes, - message: `Wrote ${rec.count} bytes @ 0x${rec.address.toString(16).padStart(4, "0")}`, - }); - } - - // Phase 7: finalize — same FF 55 AA marker as the "cancel" above. - progress({ phase: "finalize", message: "Finalizing flash session..." }); - const finishBuf = Buffer.alloc(FLASH_PAYLOAD_SIZE); - finishBuf[0] = 0xff; - finishBuf[1] = 0x55; - finishBuf[2] = 0xaa; - fillRandom(finishBuf, 3); - await exchange(handle, CMD_FLASH_MARKER, finishBuf, 1, sleepMs); - - // NOTE: 0x90 "exit flash mode" is NOT sent — see the comment at - // the top of this function about why 0x90 is omitted. - - progress({ - phase: "done", - bytesSent: doneBytes, - bytesTotal: totalBytes, - message: "Flash complete.", - }); -} +export * from "@axon/core/driver/flash"; diff --git a/apps/cli/src/sfw.ts b/apps/cli/src/sfw.ts index a4df9b4..ca584b9 100644 --- a/apps/cli/src/sfw.ts +++ b/apps/cli/src/sfw.ts @@ -1,283 +1,6 @@ -/** - * .sfw firmware container — decrypt + Intel HEX parse. - * - * TypeScript port of `research/static-analysis/static_analyze.py`'s - * `cmd_decrypt`, cross-checked against - * `research/decrypted-firmware/Axon_Mini_Servo_Mode.plain.bin` as - * ground truth. - * - * File layout (reverse engineered from the vendor exe): - * - * bytes[0:16] AES-128-ECB(key="TTTTTTTTTTTTTTTT") - * -> [uint32_le declared_length][12 x 'x' magic] - * bytes[16:] AES-128-CBC(key, iv=0) - * -> plaintext[0:declared_length] is ASCII text: - * line 1 `@0801<8-char model id>` - * N lines `$<4-hex-char sector addr>` (sector erase) - * M lines `:` - * last `:00000001FF` (Intel HEX EOF) - * The remaining bytes are AES block padding — not - * part of the firmware. - * - * The lines are CRLF-terminated in the ground-truth files, but we - * tolerate both line endings on parse. - */ +import { createHash } from "node:crypto"; -import { createDecipheriv, createHash } from "node:crypto"; - -/** AES-128 key used for every .sfw file: sixteen ASCII 'T' bytes. */ -const KEY = Buffer.from("TTTTTTTTTTTTTTTT", "ascii"); -const IV_ZERO = Buffer.alloc(16, 0); -const MIN_SFW_BYTES = 32; // 16-byte ECB header + at least one AES-CBC body block - -/** Expected magic run in the 16-byte ECB header: twelve ASCII 'x' bytes. */ -const MAGIC = Buffer.from("xxxxxxxxxxxx", "ascii"); - -export interface IntelHexRecord { - /** Byte count field (COUNT in `:CCAAAATT...`). */ - count: number; - /** 16-bit address field. */ - address: number; - /** Record type: 0x00 = data, 0x01 = EOF. */ - type: number; - /** Decoded data payload (`count` bytes). */ - data: Buffer; - /** Stored checksum byte (last byte of the record). */ - checksum: number; - /** The original ASCII line (without the leading `:` or trailing CRLF). */ - raw: string; -} - -export interface SectorEraseRecord { - /** Raw line text without leading `$` or trailing CRLF. */ - raw: string; - /** Two sector bytes parsed from positions 1..2 and 3..4 of the line. */ - bytes: [number, number]; -} - -export interface SfwHeaderRecord { - /** Raw line text without leading `@` or trailing CRLF. */ - raw: string; - /** - * Two servo-type bytes parsed from positions 1..2 and 3..4 of the - * line (`0801` → [0x08, 0x01]). The vendor exe compares these - * against the identify reply's model bytes before flashing. - */ - typeBytes: [number, number]; - /** Model id string starting at position 5 (e.g. "SA33****"). */ - modelId: string; -} - -export interface DecryptedSfw { - /** Length declared by the 16-byte header. */ - declaredLength: number; - /** - * The full decrypted plaintext bytes (trimmed to declaredLength). - * Byte-for-byte comparable with the ground-truth - * `research/decrypted-firmware/*.plain.bin` files. - */ - plaintext: Buffer; - /** The single `@0801` handshake line. */ - header: SfwHeaderRecord; - /** All `$HHHH` sector-erase lines, in file order. */ - sectorErases: SectorEraseRecord[]; - /** All `:...` Intel HEX records, in file order, terminated by a 0x01 EOF record. */ - hexRecords: IntelHexRecord[]; -} - -function ensureAligned(ciphertext: Buffer): void { - if (ciphertext.length < MIN_SFW_BYTES || ciphertext.length % 16 !== 0) { - throw new Error( - `sfw: ciphertext is ${ciphertext.length} bytes; expected >=${MIN_SFW_BYTES} and a multiple of 16`, - ); - } -} - -function decryptEcb16(block: Buffer): Buffer { - const decipher = createDecipheriv("aes-128-ecb", KEY, null); - decipher.setAutoPadding(false); - return Buffer.concat([decipher.update(block), decipher.final()]); -} - -function decryptCbc(body: Buffer): Buffer { - const decipher = createDecipheriv("aes-128-cbc", KEY, IV_ZERO); - decipher.setAutoPadding(false); - return Buffer.concat([decipher.update(body), decipher.final()]); -} - -/** - * Parse a single hex byte pair (e.g. "A3") into a number 0..255. - * Throws on non-hex input. - */ -function hexByte(s: string, where: string): number { - if (s.length !== 2) { - throw new Error(`sfw: ${where}: expected 2 hex chars, got "${s}"`); - } - const v = Number.parseInt(s, 16); - if (Number.isNaN(v) || v < 0 || v > 0xff) { - throw new Error(`sfw: ${where}: not a hex byte: "${s}"`); - } - return v; -} - -/** - * Parse a single Intel HEX record. The input is the line body WITHOUT - * the leading `:` and WITHOUT the trailing CR/LF. Validates the - * checksum against `(0x100 - (sum of all bytes)) & 0xFF`. - */ -export function parseIntelHexRecord(body: string): IntelHexRecord { - if (body.length < 10 || body.length % 2 !== 0) { - throw new Error(`sfw: hex record "${body}" has invalid length ${body.length}`); - } - const count = hexByte(body.slice(0, 2), "hex record count"); - const addrHi = hexByte(body.slice(2, 4), "hex record addr-hi"); - const addrLo = hexByte(body.slice(4, 6), "hex record addr-lo"); - const type = hexByte(body.slice(6, 8), "hex record type"); - // Expected total hex pairs = 1 count + 2 addr + 1 type + count data + 1 checksum - const expectedPairs = 1 + 2 + 1 + count + 1; - if (body.length !== expectedPairs * 2) { - throw new Error( - `sfw: hex record "${body}" has count=${count} but ${body.length / 2} bytes (expected ${expectedPairs})`, - ); - } - const data = Buffer.alloc(count); - for (let i = 0; i < count; i++) { - data[i] = hexByte(body.slice(8 + i * 2, 10 + i * 2), `hex record data[${i}]`); - } - const checksum = hexByte(body.slice(8 + count * 2, 10 + count * 2), "hex record checksum"); - // Verify. - let sum = count + addrHi + addrLo + type; - for (let i = 0; i < count; i++) sum += data[i] ?? 0; - const expected = (0x100 - (sum & 0xff)) & 0xff; - if (expected !== checksum) { - throw new Error( - `sfw: hex record "${body}" checksum mismatch: stored 0x${checksum.toString(16)} != computed 0x${expected.toString(16)}`, - ); - } - return { - count, - address: (addrHi << 8) | addrLo, - type, - data, - checksum, - raw: body, - }; -} - -/** Compute the Intel HEX checksum byte for a record body (excluding `:`). */ -export function intelHexChecksum(bytes: readonly number[] | Buffer): number { - let sum = 0; - for (const b of bytes) sum = (sum + (b & 0xff)) & 0xff; - return (0x100 - sum) & 0xff; -} - -/** - * Split the plaintext into lines, tolerant of CRLF / LF. Empty - * trailing lines (common after the last CRLF) are dropped. - */ -function splitLines(text: string): string[] { - const lines = text.split(/\r?\n/); - while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); - return lines; -} - -/** - * Decrypt a .sfw blob and parse the resulting plaintext into - * structured records. Throws on anything abnormal (wrong magic, bad - * checksum, unexpected line prefix, missing EOF record, or non-empty - * data after EOF). Deliberately strict — a silent-failure decrypter - * would put us in a bad spot the first time we flash real hardware. - */ -export function decryptSfw(ciphertext: Buffer): DecryptedSfw { - ensureAligned(ciphertext); - - const header = decryptEcb16(ciphertext.subarray(0, 16)); - const declaredLength = header.readUInt32LE(0); - const magic = header.subarray(4, 16); - if (!magic.equals(MAGIC)) { - throw new Error( - `sfw: header magic is ${magic.toString("hex")}, expected ${MAGIC.toString("hex")}`, - ); - } - if (declaredLength <= 0 || declaredLength > ciphertext.length) { - throw new Error( - `sfw: declared length ${declaredLength} is out of range (file is ${ciphertext.length} bytes)`, - ); - } - - const body = ciphertext.subarray(16); - const rawPlaintext = decryptCbc(body); - const plaintext = rawPlaintext.subarray(0, declaredLength); - - // Intel HEX + sector directives are ASCII; the rest is padding. - const text = plaintext.toString("ascii"); - const lines = splitLines(text); - if (lines.length === 0) { - throw new Error("sfw: plaintext had no lines"); - } - - // Line 1: @0801 - const first = lines[0]; - if (first === undefined) { - throw new Error("sfw: plaintext had no lines"); - } - if (!first.startsWith("@")) { - throw new Error(`sfw: expected first line to start with '@', got "${first.slice(0, 16)}"`); - } - if (first.length < 5) { - throw new Error(`sfw: header line "${first}" is too short`); - } - const headerRec: SfwHeaderRecord = { - raw: first.slice(1), - typeBytes: [ - hexByte(first.slice(1, 3), "sfw header typeByte[0]"), - hexByte(first.slice(3, 5), "sfw header typeByte[1]"), - ], - modelId: first.slice(5), - }; - - const sectorErases: SectorEraseRecord[] = []; - const hexRecords: IntelHexRecord[] = []; - let sawEof = false; - for (let i = 1; i < lines.length; i++) { - const line = lines[i] ?? ""; - if (line.length === 0) continue; - if (sawEof) { - throw new Error(`sfw: non-empty line after Intel HEX EOF at line ${i + 1}`); - } - if (line.startsWith("$")) { - if (line.length < 5) { - throw new Error(`sfw: sector line "${line}" is too short`); - } - sectorErases.push({ - raw: line.slice(1), - bytes: [ - hexByte(line.slice(1, 3), "sector byte[0]"), - hexByte(line.slice(3, 5), "sector byte[1]"), - ], - }); - } else if (line.startsWith(":")) { - const rec = parseIntelHexRecord(line.slice(1)); - hexRecords.push(rec); - if (rec.type === 0x01) sawEof = true; - } else { - throw new Error( - `sfw: unknown line prefix at line ${i + 1}: "${line.slice(0, 32)}" (expected '$' or ':')`, - ); - } - } - if (!sawEof) { - throw new Error("sfw: plaintext has no Intel HEX EOF record (:00000001FF)"); - } - - return { - declaredLength, - plaintext, - header: headerRec, - sectorErases, - hexRecords, - }; -} +export * from "@axon/core/sfw"; /** Compute the SHA-256 hash of a .sfw file as a lowercase hex string. */ export function sfwHashHex(ciphertext: Buffer): string { diff --git a/apps/cli/src/util/prompt.ts b/apps/cli/src/util/prompt.ts index 6b0573b..2c491d2 100644 --- a/apps/cli/src/util/prompt.ts +++ b/apps/cli/src/util/prompt.ts @@ -2,6 +2,20 @@ * Minimal interactive prompt helpers. Kept dependency-free. */ +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +function concatBytes(chunks: readonly Uint8Array[]): Uint8Array { + const totalLength = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); + const combined = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + combined.set(chunk, offset); + offset += chunk.byteLength; + } + return combined; +} + /** * Ask a yes/no question on stderr, read answer from stdin. Default is * "no" — an empty line or anything not starting with y/Y returns false. @@ -21,7 +35,7 @@ export async function confirm(question: string): Promise { /** Read a single line from stdin without consuming the async iterator. */ export function readLineFromStdin(): Promise { return new Promise((resolve) => { - const chunks: Buffer[] = []; + const chunks: Uint8Array[] = []; const cleanup = () => { process.stdin.removeListener("data", onData); @@ -36,15 +50,15 @@ export function readLineFromStdin(): Promise { }; const currentLine = (): string => { - const combined = Buffer.concat(chunks).toString("utf8"); + const combined = textDecoder.decode(concatBytes(chunks)); const newlineIndex = combined.indexOf("\n"); const line = newlineIndex >= 0 ? combined.slice(0, newlineIndex) : combined; return line.replace(/\r$/, ""); }; - const onData = (chunk: Buffer | string) => { - chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : Buffer.from(chunk)); - const combined = Buffer.concat(chunks).toString("utf8"); + const onData = (chunk: Uint8Array | string) => { + chunks.push(typeof chunk === "string" ? textEncoder.encode(chunk) : Uint8Array.from(chunk)); + const combined = textDecoder.decode(concatBytes(chunks)); if (combined.includes("\n")) { finish(currentLine()); } diff --git a/apps/cli/test/commands/read.test.ts b/apps/cli/test/commands/read.test.ts index bf2a93c..d4454ee 100644 --- a/apps/cli/test/commands/read.test.ts +++ b/apps/cli/test/commands/read.test.ts @@ -37,16 +37,19 @@ describe("axon read", () => { })); const { runRead } = await import("../../src/commands/read.ts"); - const code = await runRead(GLOBAL_FLAGS, { format: "human" }); + const code = await runRead(GLOBAL_FLAGS, { format: "human", debug: false }); expect(code).toBe(ExitCode.Ok); expect(io.stdout).toContain("model SA33****"); expect(io.stdout).toContain("Axon Mini"); - expect(io.stdout).toContain("block 95 bytes"); + expect(io.stdout).toContain("servo_angle"); + expect(io.stdout).toContain("servo_neutral"); + expect(io.stdout).toContain("pwm_power"); + expect(io.stdout).not.toContain("block 95 bytes"); expect(released).toBe(true); }); - test("json output serializes the model metadata", async () => { + test("json output serializes decoded parameters", async () => { const mockDongle = new MockDongle(); await mock.module("../../src/driver/hid.ts", () => ({ listDongles: () => [], @@ -58,13 +61,39 @@ describe("axon read", () => { })); const { runRead } = await import("../../src/commands/read.ts"); - const code = await runRead(JSON_FLAGS, { format: "json" }); + const code = await runRead(JSON_FLAGS, { format: "json", debug: false }); expect(code).toBe(ExitCode.Ok); const parsed = JSON.parse(io.stdout); expect(parsed.model.id).toBe("SA33****"); expect(parsed.model.name).toBe("Axon Mini"); expect(parsed.model.known).toBe(true); + expect(parsed.mode).toBe("servo_mode"); + expect(parsed.parameters.servo_angle.value).toBe(130); + expect(parsed.parameters.servo_neutral.value).toBe(0); + expect(parsed.parameters.pwm_power.value).toBe(86); + expect(parsed.parameters.sensitivity.value).toBe(0); + expect(parsed.raw_bytes_hex).toBeUndefined(); + expect(parsed.byte_count).toBeUndefined(); + }); + + test("debug json includes raw block metadata", async () => { + const mockDongle = new MockDongle(); + await mock.module("../../src/driver/hid.ts", () => ({ + listDongles: () => [], + isDonglePresent: () => false, + openDongle: async () => mockDongle, + VID: 0x0471, + PID: 0x13aa, + REPORT_SIZE: 64, + })); + const { runRead } = await import("../../src/commands/read.ts"); + + const code = await runRead(JSON_FLAGS, { format: "json", debug: true }); + + expect(code).toBe(ExitCode.Ok); + const parsed = JSON.parse(io.stdout); + expect(parsed.raw_bytes_hex).toBeString(); expect(parsed.byte_count).toBe(95); }); @@ -80,7 +109,7 @@ describe("axon read", () => { })); const { runRead } = await import("../../src/commands/read.ts"); - const code = await runRead(GLOBAL_FLAGS, { format: "svo" }); + const code = await runRead(GLOBAL_FLAGS, { format: "svo", debug: false }); expect(code).toBe(ExitCode.Ok); expect(io.stdout.length).toBeGreaterThan(0); @@ -99,7 +128,7 @@ describe("axon read", () => { })); const { runRead } = await import("../../src/commands/read.ts"); - const code = await runRead(GLOBAL_FLAGS, { format: "hex" }); + const code = await runRead(GLOBAL_FLAGS, { format: "hex", debug: false }); expect(code).toBe(ExitCode.Ok); expect(io.stdout).toContain("0x00"); diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 0cae19a..40579b8 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -1,57 +1,56 @@ -# Electrobun PoC +# Electrobun App -This is a minimal desktop proof-of-concept for talking to the Axon V1.3 -programmer adapter from an Electrobun app. - -It answers one narrow question: - -> Can a desktop UI built with Electrobun keep the frontend in a WebView, -> keep HID in the Bun main process, and reuse the existing Axon -> `node-hid` and protocol code to enumerate, identify, and read config? +This is the desktop Axon Servo Programmer built with Electrobun. ## Why this shape -The browser PoC in [`../web/`](../web/) proved that a -Chromium browser can talk to the adapter through WebHID. That is not a -good desktop abstraction, though: +The browser app in [`../web/`](../web/) proved that Chromium can talk +to the adapter through WebHID. That is not the right desktop boundary: -- Safari/WebKit does not give us the same WebHID path. -- Electrobun uses the platform's native webview by default. -- The desktop app should therefore treat HID as a Bun-side transport, - not a renderer-side browser feature. +- Safari/WebKit does not expose the same WebHID path. +- Electrobun uses the platform native webview by default. +- The desktop app should keep HID and firmware flashing in the Bun main + process, not in the renderer. -So this PoC keeps the UI browser-like but routes all hardware access -through Electrobun RPC into the Bun process. +So the desktop app reuses the shared UI, but routes all hardware work +through Electrobun RPC into Bun. -The current implementation now uses: +## Architecture -- [`../../packages/ui/`](../../packages/ui/) for the shared probe UI +- [`../../packages/ui/`](../../packages/ui/) for the shared programmer UI - [`../../packages/transport-nodehid/`](../../packages/transport-nodehid/) for desktop HID access -- [`../../packages/core/`](../../packages/core/) for shared protocol and catalog logic +- [`../../packages/core/`](../../packages/core/) for shared protocol, + flash, catalog, and `.sfw` handling ## What it does - shows runtime and transport info -- lists visible Axon adapters via `node-hid` -- opens the first visible adapter -- sends the identify command (`0x8A`) -- reads the full 95-byte config block via the same two-chunk `0xCD` - flow used by the CLI - -It does **not** attempt to write config or flash firmware. +- watches visible Axon adapters via `node-hid` +- auto-connects to the adapter +- identifies the attached servo and reads the current setup +- applies same-mode config changes with read-back verification +- switches between Servo and CR by flashing bundled firmware, + reconnecting, writing setup, and verifying +- supports native desktop file actions for: + - load `.axon` and `.svo` + - save `.axon` + - export `.svo` ## Run it -From the repo root, make sure the CLI dependencies are present: +From the repo root: ```bash cd /Users/caryden/github/servo-programmer bun install -``` - -Then start the Electrobun app: - -```bash cd /Users/caryden/github/servo-programmer/apps/desktop bun run start ``` + +## Notes + +- Keep the adapter on the host OS, not inside a VM such as Parallels. +- HID and flashing stay in the Bun main process; the renderer only owns + UI state. +- Bundled firmware files are copied into the desktop build under + `app/downloads/`. diff --git a/apps/desktop/assets/icon-source.html b/apps/desktop/assets/icon-source.html new file mode 100644 index 0000000..e52d2a6 --- /dev/null +++ b/apps/desktop/assets/icon-source.html @@ -0,0 +1,146 @@ + + + + + + Axon Servo Programmer Icon + + + +
+
+
+
+
+
+
+
+
+
+
AXON
+
+ + diff --git a/apps/desktop/assets/icon.png b/apps/desktop/assets/icon.png new file mode 100644 index 0000000..6bf903c Binary files /dev/null and b/apps/desktop/assets/icon.png differ diff --git a/apps/desktop/electrobun.config.ts b/apps/desktop/electrobun.config.ts index 7d7f1b7..03b9319 100644 --- a/apps/desktop/electrobun.config.ts +++ b/apps/desktop/electrobun.config.ts @@ -2,8 +2,8 @@ import type { ElectrobunConfig } from "electrobun"; export default { app: { - name: "axon-electrobun-poc", - identifier: "com.caryden.axon.electrobun-poc", + name: "Axon Servo Programmer", + identifier: "com.caryden.axon.servo-programmer", version: "0.0.1", }, build: { @@ -15,11 +15,14 @@ export default { copy: { "src/mainview/index.html": "views/mainview/index.html", "src/mainview/index.css": "views/mainview/index.css", + "../../downloads": "downloads", }, mac: { + icons: "icon.iconset", bundleCEF: false, }, linux: { + icon: "assets/icon.png", bundleCEF: false, }, win: { diff --git a/apps/desktop/icon.iconset/icon_128x128.png b/apps/desktop/icon.iconset/icon_128x128.png new file mode 100644 index 0000000..9724857 Binary files /dev/null and b/apps/desktop/icon.iconset/icon_128x128.png differ diff --git a/apps/desktop/icon.iconset/icon_128x128@2x.png b/apps/desktop/icon.iconset/icon_128x128@2x.png new file mode 100644 index 0000000..507188c Binary files /dev/null and b/apps/desktop/icon.iconset/icon_128x128@2x.png differ diff --git a/apps/desktop/icon.iconset/icon_16x16.png b/apps/desktop/icon.iconset/icon_16x16.png new file mode 100644 index 0000000..4b1d983 Binary files /dev/null and b/apps/desktop/icon.iconset/icon_16x16.png differ diff --git a/apps/desktop/icon.iconset/icon_16x16@2x.png b/apps/desktop/icon.iconset/icon_16x16@2x.png new file mode 100644 index 0000000..59c614f Binary files /dev/null and b/apps/desktop/icon.iconset/icon_16x16@2x.png differ diff --git a/apps/desktop/icon.iconset/icon_256x256.png b/apps/desktop/icon.iconset/icon_256x256.png new file mode 100644 index 0000000..507188c Binary files /dev/null and b/apps/desktop/icon.iconset/icon_256x256.png differ diff --git a/apps/desktop/icon.iconset/icon_256x256@2x.png b/apps/desktop/icon.iconset/icon_256x256@2x.png new file mode 100644 index 0000000..b33c14e Binary files /dev/null and b/apps/desktop/icon.iconset/icon_256x256@2x.png differ diff --git a/apps/desktop/icon.iconset/icon_32x32.png b/apps/desktop/icon.iconset/icon_32x32.png new file mode 100644 index 0000000..59c614f Binary files /dev/null and b/apps/desktop/icon.iconset/icon_32x32.png differ diff --git a/apps/desktop/icon.iconset/icon_32x32@2x.png b/apps/desktop/icon.iconset/icon_32x32@2x.png new file mode 100644 index 0000000..e8945df Binary files /dev/null and b/apps/desktop/icon.iconset/icon_32x32@2x.png differ diff --git a/apps/desktop/icon.iconset/icon_512x512.png b/apps/desktop/icon.iconset/icon_512x512.png new file mode 100644 index 0000000..b33c14e Binary files /dev/null and b/apps/desktop/icon.iconset/icon_512x512.png differ diff --git a/apps/desktop/icon.iconset/icon_512x512@2x.png b/apps/desktop/icon.iconset/icon_512x512@2x.png new file mode 100644 index 0000000..6bf903c Binary files /dev/null and b/apps/desktop/icon.iconset/icon_512x512@2x.png differ diff --git a/apps/desktop/src/bun/index.ts b/apps/desktop/src/bun/index.ts index 73d3ff3..94f6bc0 100644 --- a/apps/desktop/src/bun/index.ts +++ b/apps/desktop/src/bun/index.ts @@ -1,26 +1,55 @@ +import { Buffer } from "node:buffer"; +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; import { findModel, loadCatalog } from "@axon/core/catalog"; +import { servoModeLabel, summarizeConfig } from "@axon/core/config-summary"; +import { flashFirmware } from "@axon/core/driver/flash"; import { type IdentifyReply, identify, modelIdFromConfig, readFullConfig, + writeFullConfig, } from "@axon/core/driver/protocol"; import { AxonError } from "@axon/core/errors"; +import { decryptSfw } from "@axon/core/sfw"; import { type DongleDescriptor, listDongles, openDongle } from "@axon/transport-nodehid"; -import type { ProbeConfigInfo, ProbeDeviceInfo, ProbeIdentifyInfo, ProbeInventory } from "@axon/ui"; -import { BrowserView, BrowserWindow } from "electrobun/bun"; +import type { + ProbeConfigInfo, + ProbeDeviceInfo, + ProbeFirmwareFile, + ProbeFlashProgressEvent, + ProbeIdentifyInfo, + ProbeInventory, + ProbeLoadedFile, + ProbeSavedFile, +} from "@axon/ui"; +import { ApplicationMenu, BrowserView, BrowserWindow, Utils } from "electrobun/bun"; import type { DesktopPocSchema, + FlashJobResult, RpcResult, RuntimeInfo, SerializedAxonError, } from "../shared/api.ts"; -const WINDOW_WIDTH = 960; -const WINDOW_HEIGHT = 860; +const WINDOW_WIDTH = 980; +const WINDOW_HEIGHT = 980; +const APP_NAME = "Axon Servo Programmer"; +const APP_DOCS_URL = "https://docs.axon-robotics.com/archive/programmer"; +const PROGRAMMER_MK2_URL = "https://docs.axon-robotics.com/servos/programmer"; +const INVENTORY_POLL_MS = 1000; +const POST_FLASH_PAUSE_MS = 400; let activeDescriptor: DongleDescriptor | null = null; let activeHandle: Awaited> | null = null; +let hardwareSessionDepth = 0; +let lastInventorySnapshot = ""; +let lastDialogFolder = join(homedir(), "Downloads"); +let lastIdentifyMode: ProbeIdentifyInfo["mode"] = "unknown"; +let activeFlashJob: Promise | null = null; function hex(value: number | undefined, width = 2): string | null { if (value === undefined) return null; @@ -46,13 +75,47 @@ function serializeDescriptor(descriptor: DongleDescriptor): ProbeDeviceInfo { }; } -function currentInventory(): ProbeInventory { +function inventoryFromDescriptors(descriptors: DongleDescriptor[]): ProbeInventory { return { - devices: listDongles().map(serializeDescriptor), + devices: descriptors.map(serializeDescriptor), openedId: activeDescriptor?.path ?? null, }; } +function repoRoot(): string { + return resolve(import.meta.dir, "../../.."); +} + +function downloadsCandidatePaths(filename: string): string[] { + return [ + join(import.meta.dir, "../downloads", filename), + join(repoRoot(), "downloads", filename), + join(process.cwd(), "downloads", filename), + join(process.cwd(), "..", "downloads", filename), + join(import.meta.dir, "../../../downloads", filename), + join(import.meta.dir, "../../downloads", filename), + ]; +} + +function firstExistingPath(paths: string[]): string | null { + for (const candidate of paths) { + if (existsSync(candidate)) return candidate; + } + return null; +} + +function sha256Hex(bytes: Buffer): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +function catalogFirmwareKey(mode: "servo_mode" | "cr_mode"): "standard" | "continuous" { + return mode === "servo_mode" ? "standard" : "continuous"; +} + +function sleep(ms: number): Promise { + return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); +} + function serializeError(error: unknown): SerializedAxonError { if (error instanceof AxonError) { return { @@ -70,6 +133,27 @@ function serializeError(error: unknown): SerializedAxonError { return { message: String(error), category: "internal" }; } +function isTransientProbeError(error: unknown): boolean { + if (error instanceof AxonError) { + if (error.category === "servo_io") return true; + if (error.category === "no_servo") return true; + } + if (error instanceof Error) { + return /read nack|timed out|no servo/i.test(error.message); + } + return false; +} + +function shouldResetHandleOnProbeFailure(error: unknown): boolean { + if (error instanceof AxonError) { + return error.category === "servo_io" || error.category === "adapter_io"; + } + if (error instanceof Error) { + return /read nack|timed out|rpc request timed out|servo i\/o/i.test(error.message); + } + return false; +} + async function withResult(fn: () => Promise | T): Promise> { try { return { ok: true, data: await fn() }; @@ -78,6 +162,45 @@ async function withResult(fn: () => Promise | T): Promise> { } } +async function withTransientProbeRetry( + label: string, + run: () => Promise, + attempts = 4, + delayMs = 250, +): Promise { + let lastError: unknown = null; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + return await run(); + } catch (error) { + lastError = error; + if (attempt === attempts - 1 || !isTransientProbeError(error)) { + throw error; + } + emitTransportLog(`${label} retry`, { + attempt: attempt + 1, + error: serializeError(error), + }); + await sleep(delayMs); + } + } + throw lastError instanceof Error ? lastError : new Error(`${label} failed`); +} + +async function resetHandleAfterProbeFailure(label: string, error: unknown): Promise { + if (!shouldResetHandleOnProbeFailure(error)) return; + emitTransportLog(`${label} reset handle`, { error: serializeError(error) }); + try { + await releaseActiveHandle(); + } finally { + await refreshInventoryState(true); + // Force the next inventory poll to emit the same visible-adapter state again. + // The renderer ignores the forced emission while a connect/read cycle is still busy. + // Emitting it once more on the next idle tick lets auto-connect retry cleanly. + lastInventorySnapshot = ""; + } +} + async function releaseActiveHandle(): Promise { if (!activeHandle) return; await activeHandle.release(); @@ -85,6 +208,59 @@ async function releaseActiveHandle(): Promise { activeDescriptor = null; } +async function resetConnectionState(label: string): Promise { + emitTransportLog(`${label} reset connection`); + lastIdentifyMode = "unknown"; + await releaseActiveHandle(); + const inventory = await refreshInventoryState(true); + // Force one more visible inventory emission on the next idle cycle so the + // renderer-side reconnect logic does not get stuck on an unchanged snapshot. + lastInventorySnapshot = ""; + return inventory; +} + +function emitTransportLog(message: string, extra?: unknown): void { + try { + const payload = + extra === undefined + ? `[transport] ${message}` + : `[transport] ${message} ${JSON.stringify(extra)}`; + rpc.send.transportLog(payload); + } catch {} +} + +function emitInventory(inventory: ProbeInventory, force = false): void { + const snapshot = JSON.stringify(inventory); + if (!force && snapshot === lastInventorySnapshot) return; + lastInventorySnapshot = snapshot; + try { + rpc.send.inventory(inventory); + } catch {} +} + +function emitFlashProgress(event: ProbeFlashProgressEvent): void { + try { + rpc.send.flashProgress(event); + } catch {} +} + +function emitFlashResult(result: FlashJobResult): void { + try { + rpc.send.flashResult(result); + } catch {} +} + +async function withHardwareSession(label: string, run: () => Promise): Promise { + hardwareSessionDepth += 1; + emitTransportLog(`hardware session start: ${label}`); + try { + return await run(); + } finally { + hardwareSessionDepth = Math.max(0, hardwareSessionDepth - 1); + emitTransportLog(`hardware session end: ${label}`); + } +} + function requireOpenHandle() { if (!activeHandle) { throw AxonError.validation("Open the Axon adapter first."); @@ -113,61 +289,316 @@ function runtimeInfo(): RuntimeInfo { }; } +async function refreshInventoryState(force = false): Promise { + const descriptors = listDongles(); + if ( + activeDescriptor?.path && + !descriptors.some((descriptor) => descriptor.path === activeDescriptor?.path) + ) { + await releaseActiveHandle(); + } + const inventory = inventoryFromDescriptors(descriptors); + emitTransportLog("refreshInventoryState()", { + devices: inventory.devices.length, + openedId: inventory.openedId, + }); + emitInventory(inventory, force); + return inventory; +} + +function normalizeSelectedPath(result: string[] | undefined): string | null { + const first = result?.[0]?.trim(); + if (!first) return null; + const nextFolder = existsSync(first) && statSync(first).isDirectory() ? first : dirname(first); + lastDialogFolder = nextFolder; + return first; +} + +function sanitizeSuggestedName(name: string, extension: ".axon" | ".svo"): string { + const base = basename(name).replace(/[\\/]/g, "").trim() || `config${extension}`; + return base.toLowerCase().endsWith(extension) ? base : `${base}${extension}`; +} + +async function promptDirectoryForSave( + suggestedName: string, + extension: ".axon" | ".svo", +): Promise { + const directory = normalizeSelectedPath( + await Utils.openFileDialog({ + startingFolder: lastDialogFolder, + allowedFileTypes: "*", + canChooseFiles: false, + canChooseDirectory: true, + allowsMultipleSelection: false, + }), + ); + if (!directory) { + return null; + } + const targetPath = join(directory, sanitizeSuggestedName(suggestedName, extension)); + if (existsSync(targetPath)) { + const { response } = await Utils.showMessageBox({ + type: "question", + title: APP_NAME, + message: `Replace ${basename(targetPath)}?`, + detail: "A file with that name already exists in the selected folder.", + buttons: ["Replace", "Cancel"], + defaultId: 1, + cancelId: 1, + }); + if (response !== 0) { + return null; + } + } + return targetPath; +} + +function loadBundledFirmware( + modelId: string, + mode: "servo_mode" | "cr_mode", +): { bytes: Buffer; file: string } { + const model = findModel(loadCatalog(), modelId); + if (!model) { + throw new Error(`Unknown model ${modelId}.`); + } + const entry = model.bundled_firmware[catalogFirmwareKey(mode)]; + if (!entry) { + throw new Error(`No bundled firmware is cataloged for ${model.name} ${servoModeLabel(mode)}.`); + } + const firmwarePath = firstExistingPath(downloadsCandidatePaths(entry.file)); + if (!firmwarePath) { + throw new Error(`Could not locate bundled firmware ${entry.file}.`); + } + const bytes = readFileSync(firmwarePath); + const actual = sha256Hex(bytes); + if (actual.toLowerCase() !== entry.sha256.toLowerCase()) { + throw new Error(`Firmware SHA-256 mismatch for ${entry.file}.`); + } + return { bytes, file: entry.file }; +} + const rpc = BrowserView.defineRPC({ + maxRequestTime: 120_000, handlers: { requests: { getRuntime: () => withResult(() => runtimeInfo()), - refreshAdapters: () => withResult(() => currentInventory()), + refreshAdapters: () => withResult(() => refreshInventoryState(true)), + reconnectAdapter: () => + withResult(async () => { + return await withHardwareSession("reconnectAdapter", async () => { + return await resetConnectionState("reconnectAdapter"); + }); + }), openAdapter: (params) => withResult(async () => { - await releaseActiveHandle(); - const descriptors = listDongles(); - const selected = - params?.path === undefined - ? descriptors[0] - : descriptors.find((descriptor) => descriptor.path === params.path); - - if (!selected) { - if (params?.path) { - throw AxonError.validation(`Adapter path not found: ${params.path}`); + return await withHardwareSession("openAdapter", async () => { + await releaseActiveHandle(); + const descriptors = listDongles(); + const selected = + params?.path === undefined + ? descriptors[0] + : descriptors.find((descriptor) => descriptor.path === params.path); + + if (!selected) { + if (params?.path) { + throw AxonError.validation(`Adapter path not found: ${params.path}`); + } + throw AxonError.noAdapter("Axon dongle not found on USB."); } - throw AxonError.noAdapter("Axon dongle not found on USB."); - } - activeHandle = await openDongle(selected); - activeDescriptor = selected; - return currentInventory(); + activeHandle = await openDongle(selected); + activeDescriptor = selected; + return await refreshInventoryState(true); + }); }), closeAdapter: () => withResult(async () => { - await releaseActiveHandle(); - return currentInventory(); + return await withHardwareSession("closeAdapter", async () => { + await releaseActiveHandle(); + return await refreshInventoryState(true); + }); }), identifyServo: () => withResult(async () => { - const handle = requireOpenHandle(); - return toIdentifyInfo(await identify(handle)); + return await withHardwareSession("identifyServo", async () => { + const handle = requireOpenHandle(); + try { + const info = toIdentifyInfo(await identify(handle)); + lastIdentifyMode = info.mode; + return info; + } catch (error) { + await resetHandleAfterProbeFailure("identifyServo", error); + throw error; + } + }); }), readFullConfig: () => withResult(async () => { - const handle = requireOpenHandle(); - const config = await readFullConfig(handle); - const modelId = modelIdFromConfig(config); - const catalog = loadCatalog(); - const model = findModel(catalog, modelId); - const chunkSplit = 59; - - const info: ProbeConfigInfo = { - length: config.length, - modelId, - known: Boolean(model), - modelName: model?.name ?? null, - docsUrl: model?.docs_url ?? null, - rawHex: hexDump(config), - firstChunk: hexDump(config.subarray(0, chunkSplit)), - secondChunk: hexDump(config.subarray(chunkSplit)), + return await withHardwareSession("readFullConfig", async () => { + const handle = requireOpenHandle(); + try { + const config = await withTransientProbeRetry("readFullConfig", async () => + readFullConfig(handle), + ); + const modelId = modelIdFromConfig(config); + const catalog = loadCatalog(); + const model = findModel(catalog, modelId); + const chunkSplit = 59; + + const info: ProbeConfigInfo = { + length: config.length, + modelId, + known: Boolean(model), + modelName: model?.name ?? null, + docsUrl: model?.docs_url ?? null, + mode: lastIdentifyMode, + modeLabel: servoModeLabel(lastIdentifyMode), + setup: summarizeConfig(config, modelId), + rawHex: hexDump(config), + firstChunk: hexDump(config.subarray(0, chunkSplit)), + secondChunk: hexDump(config.subarray(chunkSplit)), + rawBytes: Array.from(config), + }; + return info; + } catch (error) { + await resetHandleAfterProbeFailure("readFullConfig", error); + throw error; + } + }); + }), + writeFullConfig: (params) => + withResult(async () => { + await withHardwareSession("writeFullConfig", async () => { + const handle = requireOpenHandle(); + await writeFullConfig(handle, Uint8Array.from(params.bytes)); + }); + }), + startFlashModeChange: (params) => + withResult(async () => { + if (activeFlashJob) { + throw AxonError.validation("A mode change is already in progress."); + } + activeFlashJob = (async () => { + try { + await withHardwareSession("flashModeChange", async () => { + const handle = requireOpenHandle(); + const { bytes } = loadBundledFirmware(params.modelId, params.targetMode); + const decrypted = decryptSfw(bytes); + await flashFirmware(handle, decrypted, { + expectedModelId: params.modelId, + onProgress: (event) => { + emitFlashProgress(event); + }, + onWireDebug: (message) => emitTransportLog(`[wire] ${message}`), + }); + await sleep(POST_FLASH_PAUSE_MS); + await resetConnectionState("flashModeChange"); + }); + emitFlashResult({ ok: true }); + } catch (error) { + emitFlashResult({ ok: false, error: serializeError(error) }); + } finally { + activeFlashJob = null; + } + })(); + }), + startFlashFirmwareFile: (params) => + withResult(async () => { + if (activeFlashJob) { + throw AxonError.validation("A mode change is already in progress."); + } + activeFlashJob = (async () => { + try { + await withHardwareSession("flashFirmwareFile", async () => { + const handle = requireOpenHandle(); + const decrypted = decryptSfw(Buffer.from(params.bytes)); + await flashFirmware(handle, decrypted, { + expectedModelId: params.expectedModelId ?? decrypted.header.modelId, + onProgress: (event) => { + emitFlashProgress(event); + }, + onWireDebug: (message) => emitTransportLog(`[wire] ${message}`), + }); + await sleep(POST_FLASH_PAUSE_MS); + await resetConnectionState("flashFirmwareFile"); + }); + emitFlashResult({ ok: true }); + } catch (error) { + emitFlashResult({ ok: false, error: serializeError(error) }); + } finally { + activeFlashJob = null; + } + })(); + }), + loadConfigFile: () => + withResult(async () => { + const selectedPath = normalizeSelectedPath( + await Utils.openFileDialog({ + startingFolder: lastDialogFolder, + allowedFileTypes: "axon,svo", + canChooseFiles: true, + canChooseDirectory: false, + allowsMultipleSelection: false, + }), + ); + if (!selectedPath) { + return null; + } + const lower = selectedPath.toLowerCase(); + if (lower.endsWith(".svo")) { + const bytes = readFileSync(selectedPath); + const result: ProbeLoadedFile = { + name: basename(selectedPath), + format: "svo", + bytes: Array.from(bytes), + }; + return result; + } + const text = readFileSync(selectedPath, "utf8"); + const result: ProbeLoadedFile = { + name: basename(selectedPath), + format: "axon", + text, + }; + return result; + }), + loadFirmwareFile: () => + withResult(async () => { + const selectedPath = normalizeSelectedPath( + await Utils.openFileDialog({ + startingFolder: lastDialogFolder, + allowedFileTypes: "sfw", + canChooseFiles: true, + canChooseDirectory: false, + allowsMultipleSelection: false, + }), + ); + if (!selectedPath) { + return null; + } + const result: ProbeFirmwareFile = { + name: basename(selectedPath), + bytes: Array.from(readFileSync(selectedPath)), }; - return info; + return result; + }), + saveAxonFile: (params) => + withResult(async () => { + const targetPath = await promptDirectoryForSave(params.suggestedName, ".axon"); + if (!targetPath) { + return { path: null } satisfies ProbeSavedFile; + } + writeFileSync(targetPath, params.text, "utf8"); + return { path: targetPath } satisfies ProbeSavedFile; + }), + exportSvoFile: (params) => + withResult(async () => { + const targetPath = await promptDirectoryForSave(params.suggestedName, ".svo"); + if (!targetPath) { + return { path: null } satisfies ProbeSavedFile; + } + writeFileSync(targetPath, Buffer.from(params.bytes)); + return { path: targetPath } satisfies ProbeSavedFile; }), }, messages: {}, @@ -175,7 +606,7 @@ const rpc = BrowserView.defineRPC({ }); const mainWindow = new BrowserWindow({ - title: "Axon Electrobun PoC", + title: APP_NAME, url: "views://mainview/index.html", rpc, frame: { @@ -187,8 +618,104 @@ const mainWindow = new BrowserWindow({ renderer: "native", }); +ApplicationMenu.setApplicationMenu([ + { + label: APP_NAME, + submenu: [ + { role: "about" }, + { type: "divider" }, + { role: "hide" }, + { role: "hideOthers" }, + { role: "showAll" }, + { type: "divider" }, + { + label: `Quit ${APP_NAME}`, + action: "app-quit", + accelerator: "CommandOrControl+Q", + }, + ], + }, + { + label: "File", + submenu: [{ role: "close" }], + }, + { + label: "Edit", + submenu: [ + { role: "undo" }, + { role: "redo" }, + { type: "divider" }, + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + { role: "selectAll" }, + ], + }, + { + label: "View", + submenu: [ + { + label: "Reload", + action: "view-reload", + accelerator: "CommandOrControl+R", + }, + { + label: "Toggle Developer Tools", + action: "view-toggle-devtools", + accelerator: "Alt+CommandOrControl+I", + }, + ], + }, + { + label: "Window", + submenu: [{ role: "minimize" }, { role: "zoom" }, { role: "bringAllToFront" }], + }, + { + label: "Help", + submenu: [ + { label: "Legacy Programmer Guide", action: "help-legacy-programmer" }, + { label: "Programmer MK2 Guide", action: "help-programmer-mk2" }, + ], + }, +]); + +ApplicationMenu.on("application-menu-clicked", (event) => { + const action = (event as { data?: { action?: string } })?.data?.action; + + switch (action) { + case "app-quit": + void releaseActiveHandle().finally(() => { + Utils.quit(); + }); + break; + case "view-reload": + mainWindow.webview.executeJavascript("window.location.reload()"); + break; + case "view-toggle-devtools": + mainWindow.webview.toggleDevTools(); + break; + case "help-legacy-programmer": + Utils.openExternal(APP_DOCS_URL); + break; + case "help-programmer-mk2": + Utils.openExternal(PROGRAMMER_MK2_URL); + break; + default: + break; + } +}); + process.on("beforeExit", async () => { await releaseActiveHandle(); }); -console.log(`Axon Electrobun PoC started (window ${mainWindow.id})`); +console.log(`${APP_NAME} started (window ${mainWindow.id})`); + +const inventoryTimer = setInterval(() => { + if (hardwareSessionDepth > 0) return; + void refreshInventoryState(); +}, INVENTORY_POLL_MS); + +process.on("beforeExit", () => { + clearInterval(inventoryTimer); +}); diff --git a/apps/desktop/src/mainview/index.html b/apps/desktop/src/mainview/index.html index dfe4334..4f3e722 100644 --- a/apps/desktop/src/mainview/index.html +++ b/apps/desktop/src/mainview/index.html @@ -3,7 +3,7 @@ - Axon Electrobun PoC + Axon Servo Programmer
diff --git a/apps/desktop/src/mainview/index.ts b/apps/desktop/src/mainview/index.ts index 5cb117b..d4c9542 100644 --- a/apps/desktop/src/mainview/index.ts +++ b/apps/desktop/src/mainview/index.ts @@ -1,21 +1,64 @@ import { mountProbeApp, type ProbeConfigInfo, + type ProbeFlashProgressEvent, type ProbeIdentifyInfo, type ProbeInventory, } from "@axon/ui"; import { Electroview } from "electrobun/view"; import type { DesktopPocSchema, + FlashJobResult, RpcResult, RuntimeInfo, SerializedAxonError, } from "../shared/api.ts"; +const transportLogListeners = new Set<(message: string) => void>(); +const statusLogListeners = new Set<(message: string) => void>(); +const inventoryWatchers = new Set<(inventory: ProbeInventory) => void>(); +const flashProgressListeners = new Set<(event: ProbeFlashProgressEvent) => void>(); +const debugEnabled = new URL(window.location.href).searchParams.has("debug"); +let pendingFlashJob: { + resolve: () => void; + reject: (error: Error) => void; +} | null = null; + const rpc = Electroview.defineRPC({ + maxRequestTime: 120_000, handlers: { requests: {}, - messages: {}, + messages: { + transportLog: (message) => { + for (const listener of transportLogListeners) { + listener(message); + } + }, + inventory: (inventory) => { + for (const watcher of inventoryWatchers) { + watcher(inventory); + } + }, + flashProgress: (event) => { + const line = formatFlashProgress(event, debugEnabled); + for (const listener of statusLogListeners) { + listener(line); + } + for (const listener of flashProgressListeners) { + listener(event); + } + }, + flashResult: (result: FlashJobResult) => { + if (!pendingFlashJob) return; + const pending = pendingFlashJob; + pendingFlashJob = null; + if (result.ok) { + pending.resolve(); + return; + } + pending.reject(new Error(describeError(result.error))); + }, + }, }, }); @@ -37,6 +80,20 @@ async function unwrapResult(promise: Promise>): Promise { return result.data; } +function formatFlashProgress(event: ProbeFlashProgressEvent, verbose: boolean): string { + const parts = [`Flash ${event.phase}`]; + if (verbose && typeof event.recordsSent === "number" && typeof event.recordsTotal === "number") { + parts.push(`${event.recordsSent}/${event.recordsTotal} records`); + } + if (verbose && typeof event.bytesSent === "number" && typeof event.bytesTotal === "number") { + parts.push(`${event.bytesSent}/${event.bytesTotal} bytes`); + } + if (event.message) { + parts.push(event.message); + } + return parts.join(" - "); +} + const root = document.getElementById("app"); if (!(root instanceof HTMLElement)) { throw new Error("Electrobun PoC DOM did not initialize correctly for #app"); @@ -44,36 +101,154 @@ if (!(root instanceof HTMLElement)) { mountProbeApp({ root, - title: "Axon Electrobun PoC", + autoConnectOnLoad: true, + debugEnabled, + initialAuthorizedAccess: true, + livePollIntervalMs: 1000, + subscribeTransportLog: (push) => { + transportLogListeners.add(push); + return () => { + transportLogListeners.delete(push); + }; + }, + subscribeStatusLog: (push) => { + statusLogListeners.add(push); + return () => { + statusLogListeners.delete(push); + }; + }, + watchInventory: (onInventory) => { + inventoryWatchers.add(onInventory); + return () => { + inventoryWatchers.delete(onInventory); + }; + }, + eyebrow: "Electrobun", + title: "Axon Servo Programmer", description: - "Minimal desktop probe for the Axon V1.3 HID adapter. The UI is shared with the WebHID PoC, while HID traffic stays in the Bun main process through the node-hid transport.", + "A clean desktop workspace for Axon V1.3 setup. Detect the adapter, identify the attached servo, and apply mode or configuration changes with native HID access in Bun.", bullets: [ "Use this on the host OS with the adapter released from any VM such as Parallels.", - "This PoC mirrors the WebHID flow, but it does not depend on browser WebHID support.", - "It only enumerates, identifies, and reads config. It does not write or flash firmware.", + "The shared UI stays clean while HID and firmware flashing stay in the Bun main process.", + "File load/save/export use native desktop dialogs.", ], devicePanelTitle: "Visible Adapters", emptyDeviceText: "No adapter scan yet.", loadEnvironment: async (): Promise => unwrapResult(rpc.request.getRuntime()), loadInventory: async (): Promise => unwrapResult(rpc.request.refreshAdapters()), + reconnectDevice: { + label: "Reconnect", + run: async (): Promise => unwrapResult(rpc.request.reconnectAdapter()), + }, refreshInventory: { - label: "Refresh Visible Adapters", + label: "Find Adapter", run: async (): Promise => unwrapResult(rpc.request.refreshAdapters()), }, openDevice: { - label: "Open First Visible Adapter", + label: "Connect", run: async (): Promise => unwrapResult(rpc.request.openAdapter()), }, closeDevice: { - label: "Close Adapter", + label: "Disconnect", run: async (): Promise => unwrapResult(rpc.request.closeAdapter()), }, identifyServo: { - label: "Identify", + label: "Find Servo", run: async (): Promise => unwrapResult(rpc.request.identifyServo()), }, readFullConfig: { - label: "Read Full Config", + label: "Show Current Setup", run: async (): Promise => unwrapResult(rpc.request.readFullConfig()), }, + writeFullConfig: { + label: "Apply", + run: async (bytes: Uint8Array): Promise => + unwrapResult(rpc.request.writeFullConfig({ bytes: Array.from(bytes) })), + }, + flashModeChange: { + label: "Flash Mode", + run: async ({ targetMode, modelId, onProgress }): Promise => { + if (pendingFlashJob) { + throw new Error("A flash is already in progress."); + } + const listener = onProgress + ? (event: ProbeFlashProgressEvent) => { + onProgress(event); + } + : null; + if (listener) { + flashProgressListeners.add(listener); + } + try { + const completion = new Promise((resolve, reject) => { + pendingFlashJob = { resolve, reject }; + }); + try { + await unwrapResult(rpc.request.startFlashModeChange({ targetMode, modelId })); + } catch (error) { + pendingFlashJob = null; + throw error; + } + await completion; + } finally { + pendingFlashJob = null; + if (listener) { + flashProgressListeners.delete(listener); + } + } + }, + }, + flashFirmwareFile: { + label: "Flash Firmware File", + run: async ({ bytes, expectedModelId, onProgress }): Promise => { + if (pendingFlashJob) { + throw new Error("A flash is already in progress."); + } + const listener = onProgress + ? (event: ProbeFlashProgressEvent) => { + onProgress(event); + } + : null; + if (listener) { + flashProgressListeners.add(listener); + } + try { + const completion = new Promise((resolve, reject) => { + pendingFlashJob = { resolve, reject }; + }); + try { + await unwrapResult( + rpc.request.startFlashFirmwareFile({ bytes: Array.from(bytes), expectedModelId }), + ); + } catch (error) { + pendingFlashJob = null; + throw error; + } + await completion; + } finally { + pendingFlashJob = null; + if (listener) { + flashProgressListeners.delete(listener); + } + } + }, + }, + loadConfigFile: { + label: "Load config", + run: async () => unwrapResult(rpc.request.loadConfigFile()), + }, + loadFirmwareFile: { + label: "Load firmware file", + run: async () => unwrapResult(rpc.request.loadFirmwareFile()), + }, + saveAxonFile: { + label: "Save .axon config", + run: async ({ suggestedName, text }) => + unwrapResult(rpc.request.saveAxonFile({ suggestedName, text })), + }, + exportSvoFile: { + label: "Export .svo vendor file", + run: async ({ suggestedName, bytes }) => + unwrapResult(rpc.request.exportSvoFile({ suggestedName, bytes })), + }, }); diff --git a/apps/desktop/src/shared/api.ts b/apps/desktop/src/shared/api.ts index 8341132..b7b82a6 100644 --- a/apps/desktop/src/shared/api.ts +++ b/apps/desktop/src/shared/api.ts @@ -1,4 +1,12 @@ -import type { ProbeConfigInfo, ProbeIdentifyInfo, ProbeInventory } from "@axon/ui"; +import type { + ProbeConfigInfo, + ProbeFirmwareFile, + ProbeFlashProgressEvent, + ProbeIdentifyInfo, + ProbeInventory, + ProbeLoadedFile, + ProbeSavedFile, +} from "@axon/ui"; type EmptySchema = Record; @@ -29,20 +37,47 @@ export interface RuntimeInfo { renderer: string; } +export type FlashJobResult = { ok: true } | { ok: false; error: SerializedAxonError }; + export type DesktopPocSchema = { bun: { requests: { getRuntime: { params: undefined; response: RpcResult }; refreshAdapters: { params: undefined; response: RpcResult }; + reconnectAdapter: { params: undefined; response: RpcResult }; openAdapter: { params: { path?: string } | undefined; response: RpcResult }; closeAdapter: { params: undefined; response: RpcResult }; identifyServo: { params: undefined; response: RpcResult }; readFullConfig: { params: undefined; response: RpcResult }; + writeFullConfig: { params: { bytes: number[] }; response: RpcResult }; + startFlashModeChange: { + params: { targetMode: "servo_mode" | "cr_mode"; modelId: string }; + response: RpcResult; + }; + startFlashFirmwareFile: { + params: { bytes: number[]; expectedModelId?: string }; + response: RpcResult; + }; + loadConfigFile: { params: undefined; response: RpcResult }; + loadFirmwareFile: { params: undefined; response: RpcResult }; + saveAxonFile: { + params: { suggestedName: string; text: string }; + response: RpcResult; + }; + exportSvoFile: { + params: { suggestedName: string; bytes: number[] }; + response: RpcResult; + }; }; messages: EmptySchema; }; webview: { requests: EmptySchema; - messages: EmptySchema; + messages: { + inventory: ProbeInventory; + transportLog: string; + flashProgress: ProbeFlashProgressEvent; + flashResult: FlashJobResult; + }; }; }; diff --git a/apps/web/favicon.ico b/apps/web/favicon.ico new file mode 100644 index 0000000..f9ef78f Binary files /dev/null and b/apps/web/favicon.ico differ diff --git a/apps/web/favicon.png b/apps/web/favicon.png new file mode 100644 index 0000000..40d7998 Binary files /dev/null and b/apps/web/favicon.png differ diff --git a/apps/web/index.html b/apps/web/index.html index 7b7a6f4..289e860 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -4,6 +4,8 @@ Axon WebHID PoC + +
diff --git a/apps/web/package.json b/apps/web/package.json index 5bfb68a..d269bad 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,11 +4,14 @@ "private": true, "type": "module", "scripts": { - "build": "bun build ./src/index.ts --target browser --format esm --outdir ./dist", - "check": "bun x @biomejs/biome check src package.json index.html", + "build": "bun run ./scripts/sync-downloads.ts && bun build ./src/index.ts --target browser --format esm --outdir ./dist", + "build:pages": "bun run build && bun run ./scripts/build-pages.ts", + "check": "bun x @biomejs/biome check src scripts package.json index.html", + "test": "playwright test", "typecheck": "tsc --noEmit -p tsconfig.json", "dev": "bun run build && python3 -m http.server 8765", - "start": "bun run build && python3 -m http.server 8765" + "start": "bun run build && python3 -m http.server 8765", + "live": "bun run build && bun run ./server.ts" }, "dependencies": { "@axon/core": "workspace:*", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 0000000..fcaa9ec --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,20 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "@playwright/test"; + +const configDir = fileURLToPath(new URL(".", import.meta.url)); + +export default defineConfig({ + testDir: "./tests", + timeout: 30_000, + fullyParallel: true, + use: { + baseURL: "http://127.0.0.1:8789", + headless: true, + }, + webServer: { + command: "bun run build && bun x http-server . -p 8789 -c-1", + cwd: configDir, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/apps/web/scripts/build-pages.ts b/apps/web/scripts/build-pages.ts new file mode 100644 index 0000000..6165fcd --- /dev/null +++ b/apps/web/scripts/build-pages.ts @@ -0,0 +1,23 @@ +import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const webRoot = resolve(scriptDir, ".."); +const siteRoot = join(webRoot, ".pages"); + +rmSync(siteRoot, { recursive: true, force: true }); +mkdirSync(siteRoot, { recursive: true }); + +const requiredPaths = ["index.html", "favicon.ico", "favicon.png", "dist", "downloads"]; +for (const relativePath of requiredPaths) { + const sourcePath = join(webRoot, relativePath); + if (!existsSync(sourcePath)) { + throw new Error(`Required Pages asset is missing: ${sourcePath}`); + } + cpSync(sourcePath, join(siteRoot, relativePath), { recursive: true }); +} + +writeFileSync(join(siteRoot, ".nojekyll"), ""); + +console.log(`Prepared GitHub Pages bundle -> ${siteRoot}`); diff --git a/apps/web/scripts/sync-downloads.ts b/apps/web/scripts/sync-downloads.ts new file mode 100644 index 0000000..a9d2606 --- /dev/null +++ b/apps/web/scripts/sync-downloads.ts @@ -0,0 +1,27 @@ +import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const webRoot = resolve(scriptDir, ".."); +const repoRoot = resolve(webRoot, "..", ".."); +const sourceDir = join(repoRoot, "downloads"); +const targetDir = join(webRoot, "downloads"); + +if (!existsSync(sourceDir) || !statSync(sourceDir).isDirectory()) { + throw new Error(`downloads directory not found: ${sourceDir}`); +} + +if (existsSync(targetDir)) { + rmSync(targetDir, { recursive: true, force: true }); +} +mkdirSync(targetDir, { recursive: true }); + +for (const entry of readdirSync(sourceDir)) { + if (!entry.toLowerCase().endsWith(".sfw")) { + continue; + } + cpSync(join(sourceDir, entry), join(targetDir, entry), { recursive: true }); +} + +console.log(`Synced downloads -> ${targetDir}`); diff --git a/apps/web/server.ts b/apps/web/server.ts new file mode 100644 index 0000000..c874712 --- /dev/null +++ b/apps/web/server.ts @@ -0,0 +1,84 @@ +import { stat } from "node:fs/promises"; +import { extname, join, normalize } from "node:path"; +import { fileURLToPath } from "node:url"; + +const webRoot = fileURLToPath(new URL(".", import.meta.url)); +const repoRoot = normalize(join(webRoot, "..", "..")); +const port = Number(process.env.PORT ?? "8787"); + +const mimeTypes: Record = { + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".ico": "image/x-icon", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".sfw": "application/octet-stream", + ".svg": "image/svg+xml; charset=utf-8", +}; + +function contentType(path: string): string { + return mimeTypes[extname(path).toLowerCase()] ?? "application/octet-stream"; +} + +function safeJoin(root: string, relativePath: string): string | null { + const candidate = normalize(join(root, relativePath)); + if (!candidate.startsWith(root)) { + return null; + } + return candidate; +} + +async function fileResponse(path: string): Promise { + try { + const info = await stat(path); + if (!info.isFile()) { + return new Response("Not Found", { status: 404 }); + } + } catch { + return new Response("Not Found", { status: 404 }); + } + + return new Response(Bun.file(path), { + headers: { + "cache-control": "no-store", + "content-type": contentType(path), + }, + }); +} + +function resolvePath(url: URL): string | null { + if (url.pathname === "/" || url.pathname === "/index.html") { + return join(webRoot, "index.html"); + } + if (url.pathname === "/favicon.ico") { + return join(webRoot, "favicon.ico"); + } + if (url.pathname === "/favicon.png") { + return join(webRoot, "favicon.png"); + } + if (url.pathname.startsWith("/dist/")) { + return safeJoin(join(webRoot, "dist"), decodeURIComponent(url.pathname.slice("/dist/".length))); + } + if (url.pathname.startsWith("/downloads/")) { + return safeJoin( + join(repoRoot, "downloads"), + decodeURIComponent(url.pathname.slice("/downloads/".length)), + ); + } + return null; +} + +const server = Bun.serve({ + port, + async fetch(request) { + const url = new URL(request.url); + const path = resolvePath(url); + if (!path) { + return new Response("Not Found", { status: 404 }); + } + return fileResponse(path); + }, +}); + +console.log(`Axon web app listening on http://127.0.0.1:${server.port}`); diff --git a/apps/web/src/index.ts b/apps/web/src/index.ts index 6bf10c6..f8d7991 100644 --- a/apps/web/src/index.ts +++ b/apps/web/src/index.ts @@ -1,5 +1,13 @@ +import { Buffer } from "node:buffer"; import { findModel, loadCatalog } from "@axon/core/catalog"; -import { identify, modelIdFromConfig, readFullConfig } from "@axon/core/driver/protocol"; +import { servoModeLabel, summarizeConfig } from "@axon/core/config-summary"; +import { + identify, + modelIdFromConfig, + readFullConfig, + writeFullConfig, +} from "@axon/core/driver/protocol"; +import { decryptSfw } from "@axon/core/sfw"; import { deviceId, listAuthorizedAxonDevices, @@ -11,6 +19,7 @@ import { import { mountProbeApp, type ProbeConfigInfo, + type ProbeFirmwareFile, type ProbeIdentifyInfo, type ProbeInventory, } from "@axon/ui"; @@ -23,13 +32,99 @@ function hexDump(bytes: Uint8Array): string { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(" "); } +function parseHexBytes(rawHex: string): number[] { + return rawHex.match(/../g)?.map((pair) => Number.parseInt(pair, 16)) ?? []; +} + const root = document.getElementById("app"); if (!(root instanceof HTMLElement)) { throw new Error("Web probe root #app was not found."); } +const appRoot: HTMLElement = root; +const url = new URL(window.location.href); +const debugEnabled = url.searchParams.has("debug"); +const demoEnabled = url.searchParams.has("demo"); + +if (!("Buffer" in globalThis)) { + Object.assign(globalThis, { Buffer }); +} let selectedDevice: HIDDevice | null = null; let activeHandle: Awaited> | null = null; +let lastIdentifyMode: ProbeIdentifyInfo["mode"] = "unknown"; +const PERMISSION_HINT_KEY = "axon-webhid-permission-granted"; +const transportLogListeners = new Set<(message: string) => void>(); +const statusLogListeners = new Set<(message: string) => void>(); +let hardwareSessionDepth = 0; + +function debugLog(message: string, extra?: unknown): void { + const uiMessage = + extra === undefined + ? `[transport] ${message}` + : `[transport] ${message} ${JSON.stringify(extra)}`; + for (const listener of transportLogListeners) { + listener(uiMessage); + } +} + +function statusLog(message: string): void { + for (const listener of statusLogListeners) { + listener(message); + } +} + +function hardwareSessionActive(): boolean { + return hardwareSessionDepth > 0; +} + +async function withHardwareSession(label: string, run: () => Promise): Promise { + hardwareSessionDepth += 1; + try { + debugLog(`hardware session start: ${label}`); + return await run(); + } finally { + hardwareSessionDepth = Math.max(0, hardwareSessionDepth - 1); + debugLog(`hardware session end: ${label}`); + } +} + +function formatFlashProgress( + event: { + phase: string; + bytesSent?: number; + bytesTotal?: number; + recordsSent?: number; + recordsTotal?: number; + message?: string; + }, + verbose = false, +): string { + const parts = [`Flash ${event.phase}`]; + if (verbose && typeof event.recordsSent === "number" && typeof event.recordsTotal === "number") { + parts.push(`${event.recordsSent}/${event.recordsTotal} records`); + } + if (verbose && typeof event.bytesSent === "number" && typeof event.bytesTotal === "number") { + parts.push(`${event.bytesSent}/${event.bytesTotal} bytes`); + } + if (event.message) { + parts.push(event.message); + } + return parts.join(" - "); +} + +function getPermissionHint(): boolean { + try { + return window.localStorage.getItem(PERMISSION_HINT_KEY) === "true"; + } catch { + return false; + } +} + +function setPermissionHint(): void { + try { + window.localStorage.setItem(PERMISSION_HINT_KEY, "true"); + } catch {} +} function currentInventory(): ProbeInventory { return { @@ -38,14 +133,182 @@ function currentInventory(): ProbeInventory { }; } +function configInfoFromBytes( + configBytes: Uint8Array, + mode: ProbeIdentifyInfo["mode"], +): ProbeConfigInfo { + const modelId = modelIdFromConfig(configBytes); + if (modelId.trim().length === 0) { + throw new Error("Invalid config read: missing servo model id."); + } + const model = findModel(loadCatalog(), modelId); + const chunkSplit = 59; + + return { + length: configBytes.length, + modelId, + known: Boolean(model), + modelName: model?.name ?? null, + docsUrl: model?.docs_url ?? null, + mode, + modeLabel: servoModeLabel(mode), + setup: summarizeConfig(configBytes, modelId), + rawHex: hexDump(configBytes), + firstChunk: hexDump(configBytes.subarray(0, chunkSplit)), + secondChunk: hexDump(configBytes.subarray(chunkSplit)), + rawBytes: Array.from(configBytes), + }; +} + +function catalogFirmwareKey(mode: "servo_mode" | "cr_mode"): "standard" | "continuous" { + return mode === "servo_mode" ? "standard" : "continuous"; +} + +async function sha256Hex(bytes: Uint8Array): Promise { + const digestBytes = Uint8Array.from(bytes); + const digest = await crypto.subtle.digest("SHA-256", digestBytes.buffer as ArrayBuffer); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function loadBundledFirmware( + modelId: string, + mode: "servo_mode" | "cr_mode", +): Promise { + const model = findModel(loadCatalog(), modelId); + if (!model) { + throw new Error(`Unknown model ${modelId}.`); + } + const entry = model.bundled_firmware[catalogFirmwareKey(mode)]; + if (!entry) { + throw new Error(`No bundled firmware is cataloged for ${model.name} ${servoModeLabel(mode)}.`); + } + + const candidates = [ + `/downloads/${encodeURIComponent(entry.file)}`, + `./downloads/${encodeURIComponent(entry.file)}`, + ]; + + let response: Response | null = null; + for (const candidate of candidates) { + const maybeResponse = await fetch(candidate, { cache: "no-store" }).catch(() => null); + if (maybeResponse?.ok) { + response = maybeResponse; + break; + } + } + if (!response) { + throw new Error( + `Could not fetch ${entry.file} from local downloads. Ensure the web server exposes /downloads or serves apps/web/downloads.`, + ); + } + const bytes = new Uint8Array(await response.arrayBuffer()); + const actual = await sha256Hex(bytes); + if (actual.toLowerCase() !== entry.sha256.toLowerCase()) { + throw new Error(`Firmware SHA-256 mismatch for ${entry.file}.`); + } + return Buffer.from(bytes); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +async function pickFirmwareFile(): Promise { + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".sfw"; + input.style.position = "fixed"; + input.style.left = "-9999px"; + document.body.appendChild(input); + try { + const file = await new Promise((resolve) => { + input.addEventListener( + "change", + () => { + resolve(input.files?.[0] ?? null); + }, + { once: true }, + ); + input.click(); + }); + if (!file) { + return null; + } + const bytes = new Uint8Array(await file.arrayBuffer()); + return { + name: file.name, + bytes: Array.from(bytes), + }; + } finally { + input.remove(); + } +} + async function releaseHandle(): Promise { if (!activeHandle) { return; } - await activeHandle.release(); + try { + await activeHandle.release(); + } catch {} activeHandle = null; } +function isRecoverableTransportError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + return ( + error.message.includes("Failed to write the report.") || + error.message.includes("Selected HID device is not open.") || + error.message.includes("The device must be opened first") + ); +} + +async function reopenHandle(): Promise { + if (!selectedDevice) { + throw new Error("No device selected."); + } + await releaseHandle(); + activeHandle = await openDongle(selectedDevice); + await sleep(120); +} + +async function withRecoveredHandle(label: string, run: () => Promise): Promise { + try { + return await run(); + } catch (error) { + if (!isRecoverableTransportError(error) || !selectedDevice) { + throw error; + } + debugLog(`${label} transport retry`, error); + await reopenHandle(); + return await run(); + } +} + +async function refreshAuthorizedInventory(force = false): Promise { + if (!webHidSupported()) { + return currentInventory(); + } + if (!force && hardwareSessionActive()) { + debugLog("getDevices() skipped", { reason: "hardware session active" }); + return currentInventory(); + } + const devices = await listAuthorizedAxonDevices(); + debugLog("getDevices()", { + count: devices.length, + products: devices.map((device) => device.productName), + }); + if (devices.length > 0) { + setPermissionHint(); + } + const selectedId = selectedDevice ? deviceId(selectedDevice) : null; + selectedDevice = + devices.find((device) => selectedId !== null && deviceId(device) === selectedId) ?? + devices[0] ?? + null; + return currentInventory(); +} + function requireHandle() { if (!activeHandle) { throw new Error("Open the Axon adapter first."); @@ -53,104 +316,445 @@ function requireHandle() { return activeHandle; } -if (webHidSupported()) { - navigator.hid.addEventListener("disconnect", (event) => { - if (selectedDevice && event.device === selectedDevice) { - void releaseHandle(); - selectedDevice = null; - } - }); +async function syncAuthorizedDevices(force = false): Promise { + const previousId = selectedDevice ? deviceId(selectedDevice) : null; + const inventory = await refreshAuthorizedInventory(force); + const nextId = selectedDevice ? deviceId(selectedDevice) : null; + debugLog("syncAuthorizedDevices()", { previousId, nextId, opened: Boolean(activeHandle) }); + if (activeHandle && previousId && previousId !== nextId) { + await releaseHandle(); + } + return inventory; } -mountProbeApp({ - root, - title: "Axon WebHID PoC", - description: - "Minimal browser probe for the Axon V1.3 HID adapter. The UI is shared with the desktop PoC, while device access comes from a dedicated WebHID transport.", - bullets: [ - "Use a Chromium-based browser on https:// or http://localhost.", - "Make sure the adapter is owned by the host OS, not a VM such as Parallels.", - "This PoC enumerates, identifies, and reads config. It does not write or flash firmware.", - ], - devicePanelTitle: "Selected Device", - emptyDeviceText: "No device selected.", - loadEnvironment: async () => ({ - transport: "WebHID", - secureContext: window.isSecureContext, - webHidAvailable: webHidSupported(), - userAgent: navigator.userAgent, - }), - loadInventory: async () => currentInventory(), - requestDevice: { - label: "Request Axon Adapter", - run: async () => { - await releaseHandle(); - const devices = await requestAxonDevices(); - selectedDevice = devices[0] ?? null; - return currentInventory(); +function demoConfig(): ProbeConfigInfo { + const rawBytes = parseHexBytes( + "3bd00bf6dcdc8003002b0050230000dc02f0f0f0271f1b1712002b002b002b000000000001e1c0005000500050000000140a17000096327850506423e3000000534132304248532a0000000000000000000000000000000000000000000001", + ); + return { + length: 95, + modelId: "SA20BHS*", + known: true, + modelName: "Axon Micro", + docsUrl: "https://docs.axon-robotics.com/servos/micro", + mode: "servo_mode", + modeLabel: "Servo Mode", + setup: { + rangeDegrees: 178, + rangePercent: 50, + neutralUs: 0, + neutralPercent: 50, + pwmLossBehavior: "hold", + inversion: "normal", + softStart: true, + sensitivityStep: 4, + sensitivityLabel: "High", + dampeningFactor: 166, + overloadProtectionEnabled: true, + overloadLevels: [ + { pct: 55, sec: 4.0 }, + { pct: 75, sec: 2.0 }, + { pct: 100, sec: 0.8 }, + ], + pwmPowerPercent: 85, + proptlSeconds: 25.5, }, - }, - reconnectDevice: { - label: "Reuse Authorized Device", - run: async () => { - await releaseHandle(); - const devices = await listAuthorizedAxonDevices(); - selectedDevice = devices[0] ?? null; - return currentInventory(); + rawHex: "", + firstChunk: "", + secondChunk: "", + rawBytes, + }; +} + +function mountDemoApp(): void { + let connected = true; + const servoPresent = true; + let currentConfig = demoConfig(); + + const inventory = (): ProbeInventory => ({ + devices: connected + ? [ + { + id: "demo-axon-adapter", + vendorId: "0x0471", + productId: "0x13aa", + product: "USB Bootloader V1.3", + manufacturer: "Stone Laboratories inc.", + serialNumber: null, + interface: null, + usagePage: null, + usage: null, + opened: connected, + }, + ] + : [], + openedId: connected ? "demo-axon-adapter" : null, + }); + + mountProbeApp({ + root: appRoot, + autoConnectOnLoad: true, + debugEnabled, + initialAuthorizedAccess: true, + livePollIntervalMs: 0, + eyebrow: "Web Demo", + title: "Axon Servo Programmer", + description: "Deterministic browser demo for UI validation.", + bullets: ["No hardware required.", "Use this mode for Playwright and visual iteration."], + loadEnvironment: async () => ({ + transport: "demo", + secureContext: window.isSecureContext, + webHidAvailable: true, + userAgent: navigator.userAgent, + }), + loadInventory: async () => inventory(), + requestDevice: { + label: "Grant Permission", + run: async () => { + connected = true; + return inventory(); + }, }, - }, - openDevice: { - label: "Open Device", - run: async () => { - if (!selectedDevice) { - throw new Error("No device selected."); - } - await releaseHandle(); - activeHandle = await openDongle(selectedDevice); - return currentInventory(); + reconnectDevice: { + label: "Reconnect", + run: async () => inventory(), }, - }, - closeDevice: { - label: "Close Device", - run: async () => { - await releaseHandle(); - return currentInventory(); + openDevice: { + label: "Connect", + run: async () => { + connected = true; + return inventory(); + }, }, - }, - identifyServo: { - label: "Identify", - run: async (): Promise => { - const reply = await identify(requireHandle()); - const replyBytes = new Uint8Array(reply.rawRx); - return { - present: reply.present, - statusHi: hex(reply.statusHi), - statusLo: hex(reply.statusLo), - modeByte: reply.modeByte === null ? null : hex(reply.modeByte), - mode: reply.mode, - rawRx: hexDump(replyBytes), + closeDevice: { + label: "Disconnect", + run: async () => { + connected = false; + return inventory(); + }, + }, + identifyServo: { + label: "Identify", + run: async () => ({ + present: connected && servoPresent, + statusHi: "0x01", + statusLo: connected && servoPresent ? "0x00" : "0xfa", + modeByte: + connected && servoPresent ? (currentConfig.mode === "cr_mode" ? "0x04" : "0x03") : null, + mode: connected && servoPresent ? currentConfig.mode : "unknown", + rawRx: "", + }), + }, + readFullConfig: { + label: "Read", + run: async () => { + if (!connected || !servoPresent) { + throw new Error("Servo not detected."); + } + return currentConfig; + }, + }, + writeFullConfig: { + label: "Write", + run: async (bytes) => { + if (!connected || !servoPresent) { + throw new Error("Servo not detected."); + } + await sleep(40); + currentConfig = configInfoFromBytes(bytes, currentConfig.mode); + }, + }, + flashModeChange: { + label: "Flash Mode", + run: async ({ targetMode, onProgress }) => { + if (!connected || !servoPresent) { + throw new Error("Servo not detected."); + } + onProgress?.({ phase: "prepare", message: "Preparing flash session..." }); + await sleep(40); + onProgress?.({ phase: "write", bytesSent: 1, bytesTotal: 1, message: "Writing firmware" }); + await sleep(40); + onProgress?.({ phase: "done", bytesSent: 1, bytesTotal: 1, message: "Flash complete." }); + currentConfig = { + ...currentConfig, + mode: targetMode, + modeLabel: servoModeLabel(targetMode), + }; + }, + }, + flashFirmwareFile: { + label: "Flash Firmware File", + run: async ({ bytes, expectedModelId, onProgress }) => { + if (!connected || !servoPresent) { + throw new Error("Servo not detected."); + } + const decrypted = decryptSfw(Buffer.from(bytes)); + onProgress?.({ phase: "prepare", message: "Preparing flash session..." }); + await sleep(40); + onProgress?.({ phase: "write", bytesSent: 1, bytesTotal: 1, message: "Writing firmware" }); + await sleep(40); + onProgress?.({ phase: "done", bytesSent: 1, bytesTotal: 1, message: "Flash complete." }); + currentConfig = { + ...currentConfig, + modelId: expectedModelId ?? decrypted.header.modelId, + modelName: + findModel(loadCatalog(), expectedModelId ?? decrypted.header.modelId)?.name ?? + currentConfig.modelName, + mode: currentConfig.mode, + }; + }, + }, + loadFirmwareFile: { + label: "Load firmware file", + run: async () => pickFirmwareFile(), + }, + }); +} + +if (demoEnabled) { + mountDemoApp(); +} else { + mountProbeApp({ + root: appRoot, + autoConnectOnLoad: true, + debugEnabled, + initialAuthorizedAccess: getPermissionHint(), + livePollIntervalMs: 1000, + subscribeTransportLog: (push) => { + transportLogListeners.add(push); + return () => { + transportLogListeners.delete(push); }; }, - }, - readFullConfig: { - label: "Read Full Config", - run: async (): Promise => { - const config = await readFullConfig(requireHandle()); - const configBytes = new Uint8Array(config); - const modelId = modelIdFromConfig(configBytes); - const model = findModel(loadCatalog(), modelId); - const chunkSplit = 59; - - return { - length: configBytes.length, - modelId, - known: Boolean(model), - modelName: model?.name ?? null, - docsUrl: model?.docs_url ?? null, - rawHex: hexDump(configBytes), - firstChunk: hexDump(configBytes.subarray(0, chunkSplit)), - secondChunk: hexDump(configBytes.subarray(chunkSplit)), + subscribeStatusLog: (push) => { + statusLogListeners.add(push); + return () => { + statusLogListeners.delete(push); }; }, - }, -}); + watchInventory: (onInventory) => { + if (!webHidSupported()) { + return; + } + + let stopped = false; + let syncing = false; + + const refresh = async () => { + if (stopped || syncing || hardwareSessionActive()) return; + syncing = true; + try { + onInventory(await syncAuthorizedDevices()); + } catch (error) { + debugLog("watch refresh failed", error); + } finally { + syncing = false; + } + }; + + const onConnect = () => { + debugLog("navigator.hid connect event"); + void refresh(); + }; + + const onDisconnect = () => { + debugLog("navigator.hid disconnect event"); + void refresh(); + }; + + navigator.hid.addEventListener("connect", onConnect); + navigator.hid.addEventListener("disconnect", onDisconnect); + const timer = window.setInterval(() => { + void refresh(); + }, 1000); + + return () => { + stopped = true; + window.clearInterval(timer); + navigator.hid.removeEventListener("connect", onConnect); + navigator.hid.removeEventListener("disconnect", onDisconnect); + }; + }, + eyebrow: "WebHID Lab", + title: "Axon Servo Programmer", + description: + "A clean browser workspace for Axon V1.3 setup. Detect the adapter, identify the attached servo, and load the current profile before making mode or configuration changes.", + bullets: [ + "Use a Chromium-based browser on https:// or http://localhost.", + "Keep the adapter on the host OS, not inside a VM such as Parallels.", + "Diagnostics stay tucked away until you open them.", + ], + devicePanelTitle: "Selected Device", + emptyDeviceText: "No device selected.", + loadEnvironment: async () => ({ + transport: "WebHID", + secureContext: window.isSecureContext, + webHidAvailable: webHidSupported(), + userAgent: navigator.userAgent, + }), + loadInventory: async () => refreshAuthorizedInventory(), + requestDevice: { + label: "Find Adapter", + run: async () => { + return withHardwareSession("requestDevice", async () => { + debugLog("requestDevice()"); + const devices = await requestAxonDevices(); + debugLog("requestDevice() resolved", { + count: devices.length, + products: devices.map((device) => device.productName), + }); + setPermissionHint(); + await releaseHandle(); + selectedDevice = devices[0] ?? null; + return currentInventory(); + }); + }, + }, + reconnectDevice: { + label: "Use Remembered Adapter", + run: async () => { + return withHardwareSession("reconnectDevice", async () => { + debugLog("reconnectDevice()"); + await releaseHandle(); + return refreshAuthorizedInventory(true); + }); + }, + }, + openDevice: { + label: "Connect", + run: async () => { + return withHardwareSession("openDevice", async () => { + if (!selectedDevice) { + throw new Error("No device selected."); + } + debugLog("openDevice()", { + id: deviceId(selectedDevice), + product: selectedDevice.productName, + }); + await reopenHandle(); + debugLog("openDevice() success", { id: deviceId(selectedDevice) }); + return currentInventory(); + }); + }, + }, + closeDevice: { + label: "Disconnect", + run: async () => { + return withHardwareSession("closeDevice", async () => { + debugLog("closeDevice()"); + await releaseHandle(); + return currentInventory(); + }); + }, + }, + identifyServo: { + label: "Find Servo", + run: async (): Promise => { + return withHardwareSession("identifyServo", async () => { + const reply = await withRecoveredHandle("identify", async () => + identify(requireHandle()), + ); + lastIdentifyMode = reply.mode; + const replyBytes = new Uint8Array(reply.rawRx); + return { + present: reply.present, + statusHi: hex(reply.statusHi), + statusLo: hex(reply.statusLo), + modeByte: reply.modeByte === null ? null : hex(reply.modeByte), + mode: reply.mode, + rawRx: hexDump(replyBytes), + }; + }); + }, + }, + readFullConfig: { + label: "Show Current Setup", + run: async (): Promise => { + return withHardwareSession("readFullConfig", async () => { + const config = await withRecoveredHandle("readFullConfig", async () => { + const bytes = await readFullConfig(requireHandle()); + return configInfoFromBytes(new Uint8Array(bytes), lastIdentifyMode); + }); + return config; + }); + }, + }, + writeFullConfig: { + label: "Apply", + run: async (bytes): Promise => { + await withHardwareSession("writeFullConfig", async () => { + await withRecoveredHandle("writeFullConfig", async () => + writeFullConfig(requireHandle(), Uint8Array.from(bytes)), + ); + }); + }, + }, + flashModeChange: { + label: "Flash Mode", + run: async ({ targetMode, modelId, onProgress }): Promise => { + await withHardwareSession("flashModeChange", async () => { + const [{ flashFirmware }] = await Promise.all([import("@axon/core/driver/flash")]); + const maybeQueueHandle = activeHandle as + | (typeof activeHandle & { + clearInputQueue?: () => void; + }) + | null; + maybeQueueHandle?.clearInputQueue?.(); + const firmwareBytes = await loadBundledFirmware(modelId, targetMode); + const decrypted = decryptSfw(firmwareBytes); + await flashFirmware(requireHandle(), decrypted, { + expectedModelId: modelId, + onProgress: (event) => { + onProgress?.(event); + statusLog(formatFlashProgress(event, debugEnabled)); + debugLog(`flash ${event.phase}`, { + bytesSent: event.bytesSent ?? null, + bytesTotal: event.bytesTotal ?? null, + recordsSent: event.recordsSent ?? null, + recordsTotal: event.recordsTotal ?? null, + message: event.message, + }); + }, + onWireDebug: debugEnabled ? (message) => debugLog(`[wire] ${message}`) : undefined, + }); + await sleep(400); + }); + }, + }, + flashFirmwareFile: { + label: "Flash Firmware File", + run: async ({ bytes, expectedModelId, onProgress }): Promise => { + await withHardwareSession("flashFirmwareFile", async () => { + const [{ flashFirmware }] = await Promise.all([import("@axon/core/driver/flash")]); + const maybeQueueHandle = activeHandle as + | (typeof activeHandle & { + clearInputQueue?: () => void; + }) + | null; + maybeQueueHandle?.clearInputQueue?.(); + const decrypted = decryptSfw(Buffer.from(bytes)); + await flashFirmware(requireHandle(), decrypted, { + expectedModelId: expectedModelId ?? decrypted.header.modelId, + onProgress: (event) => { + onProgress?.(event); + statusLog(formatFlashProgress(event, debugEnabled)); + debugLog(`flash ${event.phase}`, { + bytesSent: event.bytesSent ?? null, + bytesTotal: event.bytesTotal ?? null, + recordsSent: event.recordsSent ?? null, + recordsTotal: event.recordsTotal ?? null, + message: event.message, + }); + }, + onWireDebug: debugEnabled ? (message) => debugLog(`[wire] ${message}`) : undefined, + }); + await sleep(400); + }); + }, + }, + loadFirmwareFile: { + label: "Load firmware file", + run: async () => pickFirmwareFile(), + }, + }); +} diff --git a/apps/web/src/webhid.d.ts b/apps/web/src/webhid.d.ts index 8ea9f7e..6c274b8 100644 --- a/apps/web/src/webhid.d.ts +++ b/apps/web/src/webhid.d.ts @@ -31,11 +31,16 @@ interface HIDDevice extends EventTarget { sendReport(reportId: number, data: BufferSource): Promise; } -interface HID { +interface HID extends EventTarget { getDevices(): Promise; requestDevice(options: { filters: Array<{ vendorId?: number; productId?: number }>; }): Promise; + addEventListener( + type: "connect", + listener: (event: HIDConnectionEvent) => void, + options?: boolean | AddEventListenerOptions, + ): void; addEventListener( type: "disconnect", listener: (event: HIDConnectionEvent) => void, diff --git a/apps/web/tests/demo.spec.ts b/apps/web/tests/demo.spec.ts new file mode 100644 index 0000000..af66d80 --- /dev/null +++ b/apps/web/tests/demo.spec.ts @@ -0,0 +1,453 @@ +import { expect, test, type Page } from "@playwright/test"; + +async function pointSnapshot(page: Page) { + const locator = page.locator("[data-operating-point]"); + return { + torque: await locator.getAttribute("data-torque-kgcm"), + speed: await locator.getAttribute("data-speed-rpm"), + current: await locator.getAttribute("data-current-a"), + mechPower: await locator.getAttribute("data-mechanical-power-w"), + elecPower: await locator.getAttribute("data-electrical-power-w"), + heat: await locator.getAttribute("data-heat-w"), + efficiency: await locator.getAttribute("data-efficiency"), + effectiveVoltage: await locator.getAttribute("data-effective-voltage"), + }; +} + +test.describe("web demo", () => { + test("sim pane stays side-by-side at narrower widths", async ({ page }) => { + await page.setViewportSize({ width: 900, height: 980 }); + await page.goto("/?demo"); + + const configPane = page.locator(".config-pane"); + const simPane = page.locator(".sim-pane"); + const simLeft = page.locator(".sim-left"); + const simRight = page.locator(".sim-right"); + + const configBox = await configPane.boundingBox(); + const simBox = await simPane.boundingBox(); + const simLeftBox = await simLeft.boundingBox(); + const simRightBox = await simRight.boundingBox(); + + expect(configBox).not.toBeNull(); + expect(simBox).not.toBeNull(); + expect(simLeftBox).not.toBeNull(); + expect(simRightBox).not.toBeNull(); + + expect(simBox!.x).toBeGreaterThan(configBox!.x + configBox!.width - 20); + expect(simRightBox!.x).toBeGreaterThan(simLeftBox!.x + simLeftBox!.width - 20); + expect(Math.abs(simRightBox!.y - simLeftBox!.y)).toBeLessThan(4); + }); + + test("config pane stays capped instead of expanding across the whole viewport", async ({ page }) => { + await page.setViewportSize({ width: 1520, height: 980 }); + await page.goto("/?demo"); + + const configPane = page.locator(".config-pane"); + const configBox = await configPane.boundingBox(); + + expect(configBox).not.toBeNull(); + expect(configBox!.width).toBeGreaterThan(290); + expect(configBox!.width).toBeLessThan(330); + }); + + test("workspace keeps a horizontal overflow path instead of restacking panes", async ({ page }) => { + await page.setViewportSize({ width: 900, height: 980 }); + await page.goto("/?demo"); + + const dimensions = await page.evaluate(() => { + const frame = document.querySelector(".frame"); + const shell = document.querySelector(".shell"); + if (!(frame instanceof HTMLElement) || !(shell instanceof HTMLElement)) return null; + return { + viewport: window.innerWidth, + frameWidth: frame.getBoundingClientRect().width, + shellScrollWidth: shell.scrollWidth, + }; + }); + + expect(dimensions).not.toBeNull(); + expect(dimensions!.frameWidth).toBeGreaterThan(dimensions!.viewport); + expect(dimensions!.shellScrollWidth).toBeGreaterThan(dimensions!.viewport); + }); + + test("shared frame layout stays fixed when switching between Servo and CR", async ({ page }) => { + await page.goto("/?demo"); + + const topline = page.locator(".topline"); + const workspace = page.locator(".workspace"); + const configPane = page.locator(".config-pane"); + const simPane = page.locator(".sim-pane"); + + const beforeTopline = await topline.boundingBox(); + const beforeWorkspace = await workspace.boundingBox(); + const beforeConfig = await configPane.boundingBox(); + const beforeSim = await simPane.boundingBox(); + + await page.locator('[data-mode="cr_mode"]').click(); + + const afterTopline = await topline.boundingBox(); + const afterWorkspace = await workspace.boundingBox(); + const afterConfig = await configPane.boundingBox(); + const afterSim = await simPane.boundingBox(); + + expect(beforeTopline).not.toBeNull(); + expect(beforeWorkspace).not.toBeNull(); + expect(beforeConfig).not.toBeNull(); + expect(beforeSim).not.toBeNull(); + expect(afterTopline).not.toBeNull(); + expect(afterWorkspace).not.toBeNull(); + expect(afterConfig).not.toBeNull(); + expect(afterSim).not.toBeNull(); + + expect(afterTopline?.x).toBeCloseTo(beforeTopline!.x, 1); + expect(afterWorkspace?.x).toBeCloseTo(beforeWorkspace!.x, 1); + expect(afterConfig?.x).toBeCloseTo(beforeConfig!.x, 1); + expect(afterSim?.x).toBeCloseTo(beforeSim!.x, 1); + expect(afterTopline?.width).toBeCloseTo(beforeTopline!.width, 1); + expect(afterWorkspace?.width).toBeCloseTo(beforeWorkspace!.width, 1); + expect(afterConfig?.width).toBeCloseTo(beforeConfig!.width, 1); + expect(afterSim?.width).toBeCloseTo(beforeSim!.width, 1); + }); + + test("loads the ready demo state", async ({ page }) => { + await page.goto("/?demo"); + + await expect(page.locator('[title="Servo state"] .state-value')).toHaveText("Micro"); + await expect(page.locator('[title="Show status log"] .state-value')).toHaveText("Ready"); + await expect(page.locator("[data-command='toggle-sweep']")).toBeVisible(); + await expect(page.locator("[data-setting='sensitivity']")).toBeVisible(); + await expect(page.locator("[data-setting='dampening']")).toBeVisible(); + await expect(page.locator("[data-setting='overload-protection']")).toBeVisible(); + await expect(page.locator("[data-setting='overload-level1']")).toBeVisible(); + await expect(page.locator("[data-setting='overload-level2']")).toBeVisible(); + await expect(page.locator("[data-setting='overload-level3']")).toBeVisible(); + await expect(page.locator("[data-setting='power-limit']")).toBeVisible(); + }); + + test("config controls stay in the intended priority order", async ({ page }) => { + await page.goto("/?demo"); + + const servoOrder = await page + .locator(".config-pane > [data-setting]") + .evaluateAll((elements) => elements.map((element) => element.getAttribute("data-setting"))); + expect(servoOrder).toEqual([ + "range", + "center", + "direction", + "power-limit", + "sensitivity", + "dampening", + "signal-loss", + "overload-protection", + "ramp", + ]); + + await page.locator('[data-mode="cr_mode"]').click(); + const crOrder = await page + .locator(".config-pane > [data-setting]") + .evaluateAll((elements) => elements.map((element) => element.getAttribute("data-setting"))); + expect(crOrder).toEqual(["stop-trim", "direction", "power-limit", "proptl"]); + }); + + test("servo-mode playback and kinematics do not move the operating point", async ({ page }) => { + await page.goto("/?demo"); + + const sweepButton = page.locator("[data-command='toggle-sweep']"); + const pwmValue = page.locator("[data-live-pwm-value]"); + const before = await pointSnapshot(page); + + await expect(sweepButton).toHaveAttribute("aria-label", "Pause sweep"); + await sweepButton.click({ position: { x: 20, y: 20 } }); + await expect(sweepButton).toHaveAttribute("aria-label", "Play sweep"); + const pausedValue = await pwmValue.textContent(); + await page.waitForTimeout(300); + await expect(pwmValue).toHaveText(String(pausedValue)); + await expect(await pointSnapshot(page)).toEqual(before); + + const rangeSlider = page.locator("[data-slider='range']"); + await rangeSlider.focus(); + await page.keyboard.press("ArrowRight"); + await expect(page.locator("[data-range-value]")).toHaveText("181 deg"); + await expect(await pointSnapshot(page)).toEqual(before); + + const pwmSlider = page.locator('[data-slider="pwm"]'); + await pwmSlider.focus(); + await page.keyboard.press("ArrowRight"); + await expect(await pointSnapshot(page)).toEqual(before); + expect(await pwmValue.textContent()).not.toBe(pausedValue); + + await sweepButton.click(); + await expect(sweepButton).toHaveAttribute("aria-label", "Pause sweep"); + const resumedValue = await pwmValue.textContent(); + await page.waitForTimeout(300); + expect(await pwmValue.textContent()).not.toBe(resumedValue); + }); + + test("status log can be opened and closed", async ({ page }) => { + await page.goto("/?demo"); + + await page.locator("[data-command='toggle-status-log']").click(); + await expect(page.getByText("Status Log")).toBeVisible(); + await expect(page.locator("[data-command='copy-status-log']")).toBeVisible(); + + await page.locator("[aria-label='Close status log']").click(); + await expect(page.getByText("Status Log")).toHaveCount(0); + }); + + test("diagnostics is hidden in the normal UI", async ({ page }) => { + await page.goto("/?demo"); + + await expect(page.locator("details.debug")).toHaveCount(0); + }); + + test("diagnostics is available behind debug mode", async ({ page }) => { + await page.goto("/?demo&debug"); + + const diagnosticsToggle = page.locator("details.debug > summary .debug-toggle"); + await expect(diagnosticsToggle).toHaveText("Diagnostics"); + await expect(page.locator("details.debug > summary .help")).toHaveCount(0); + }); + + test("help tooltip uses the app font stack", async ({ page }) => { + await page.goto("/?demo"); + + const helpButton = page.locator('[data-setting="range"] .help'); + await helpButton.hover(); + + const tooltip = page.locator(".help-tooltip"); + await expect(tooltip).toBeVisible(); + + const styles = await page.evaluate(() => { + const body = getComputedStyle(document.body); + const tooltipEl = document.querySelector(".help-tooltip"); + if (!(tooltipEl instanceof HTMLElement)) return null; + const tooltip = getComputedStyle(tooltipEl); + return { + bodyFontFamily: body.fontFamily, + tooltipFontFamily: tooltip.fontFamily, + bodyFontSize: body.fontSize, + tooltipFontSize: tooltip.fontSize, + }; + }); + + expect(styles).not.toBeNull(); + expect(styles?.tooltipFontFamily).toBe(styles?.bodyFontFamily); + expect(styles?.tooltipFontSize).toBe("13px"); + }); + + test("CR mode swaps in ProPTL and drive changes the curve family", async ({ page }) => { + await page.goto("/?demo"); + + await page.locator('[data-mode="cr_mode"]').click(); + await expect(page.locator('[data-setting="range"]')).toHaveCount(0); + await expect(page.locator('[data-setting="sensitivity"]')).toHaveCount(0); + await expect(page.locator('[data-setting="dampening"]')).toHaveCount(0); + await expect(page.locator('[data-setting="overload-protection"]')).toHaveCount(0); + await expect(page.locator('[data-setting="proptl"]')).toBeVisible(); + await expect(page.getByText("On signal loss")).toHaveCount(0); + await expect(page.locator("[data-command='toggle-sweep']")).toBeVisible(); + await expect(page.locator("[data-command='toggle-sweep']")).toHaveAttribute( + "aria-label", + "Pause animation", + ); + await expect(page.locator("[data-load-value]")).toHaveText("0.0 kgf*cm"); + await expect(page.locator(".servo-svg")).toHaveAttribute("data-load-state", "no load"); + + const before = await pointSnapshot(page); + await expect(page.locator('[data-live-pwm-value]')).toHaveText("2000 us"); + + const driveSlider = page.locator('[data-slider="pwm"]'); + await driveSlider.focus(); + await page.keyboard.press("ArrowLeft"); + await expect(page.locator("[data-command='toggle-sweep']")).toHaveAttribute( + "aria-label", + "Pause animation", + ); + const lowerDrive = await pointSnapshot(page); + expect(lowerDrive.effectiveVoltage).not.toBe(before.effectiveVoltage); + + await driveSlider.focus(); + await page.keyboard.press("End"); + await expect(page.locator('[data-live-pwm-value]')).toHaveText("2500 us"); + + const loadSlider = page.locator('[data-slider="load"]'); + await loadSlider.evaluate((element) => { + const input = element as HTMLInputElement; + input.value = input.max; + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + const stalled = await pointSnapshot(page); + expect(stalled.speed).toBe("0.000"); + await expect(page.locator(".servo-svg")).toHaveAttribute("data-load-state", "stalled"); + + await loadSlider.evaluate((element) => { + const input = element as HTMLInputElement; + input.value = String(Number(input.max) * 0.9); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await expect(page.locator(".servo-svg")).toHaveAttribute("data-load-state", "near stall"); + + await loadSlider.evaluate((element) => { + const input = element as HTMLInputElement; + input.value = String(Number(input.max) * 0.4); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await expect(page.locator(".servo-svg")).toHaveAttribute("data-load-state", "loaded"); + }); + + test("stacked chart plot areas stay horizontally aligned", async ({ page }) => { + await page.goto("/?demo"); + await page.locator('[data-mode="cr_mode"]').click(); + + const geometry = await page.evaluate(() => { + const read = (name: string) => { + const canvas = document.querySelector(`[data-chart='${name}']`); + if (!canvas) return null; + return { + left: Number(canvas.dataset.plotLeft ?? "NaN"), + right: Number(canvas.dataset.plotRight ?? "NaN"), + top: Number(canvas.dataset.plotTop ?? "NaN"), + bottom: Number(canvas.dataset.plotBottom ?? "NaN"), + }; + }; + return { + speedCurrent: read("speed-current"), + power: read("power"), + efficiency: read("efficiency"), + }; + }); + + expect(geometry.speedCurrent).not.toBeNull(); + expect(geometry.power).not.toBeNull(); + expect(geometry.efficiency).not.toBeNull(); + + expect(geometry.power?.left).toBeCloseTo(geometry.speedCurrent?.left ?? 0, 0); + expect(geometry.efficiency?.left).toBeCloseTo(geometry.speedCurrent?.left ?? 0, 0); + expect(geometry.power?.right).toBeCloseTo(geometry.speedCurrent?.right ?? 0, 0); + expect(geometry.efficiency?.right).toBeCloseTo(geometry.speedCurrent?.right ?? 0, 0); + }); + + test("charts use the voltage-aware x-axis title instead of a chart-head row", async ({ page }) => { + await page.goto("/?demo"); + + await expect(page.locator(".chart-head")).toHaveCount(0); + + const xTitles = await page.evaluate(() => { + const names = ["speed-current", "power", "efficiency"]; + return names.map((name) => { + const canvas = document.querySelector(`[data-chart='${name}']`); + return canvas?.dataset.xTitle ?? ""; + }); + }); + + expect(xTitles).toEqual([ + "torque @ 6.0 V (kgf*cm)", + "torque @ 6.0 V (kgf*cm)", + "torque @ 6.0 V (kgf*cm)", + ]); + }); + + test("discard reloads the live servo state", async ({ page }) => { + await page.addInitScript(() => { + window.localStorage.setItem( + "axon-config-checkpoints-v1", + JSON.stringify([ + { + id: "checkpoint-cr", + createdAt: "2026-04-13T18:45:00.000Z", + label: "micro-cr-checkpoint", + modelId: "SA20BHS*", + modelName: "Axon Micro", + draft: { + mode: "cr_mode", + rangePercent: 50, + neutralUs: 12, + direction: "ccw", + pwmLossBehavior: "hold", + softStart: false, + sensitivityStep: 4, + dampeningFactor: 166, + overloadProtectionEnabled: true, + overloadLevels: [ + { pct: 55, sec: 4.0 }, + { pct: 75, sec: 2.0 }, + { pct: 100, sec: 0.8 }, + ], + pwmPowerPercent: 60, + proptlSeconds: 7.5, + }, + }, + ]), + ); + }); + + await page.goto("/?demo"); + + await page.locator("[data-command='toggle-file-menu']").click(); + await page.locator('[data-command="load-checkpoint"][data-checkpoint-id="checkpoint-cr"]').click(); + + await expect(page.locator('[data-mode="cr_mode"]')).toHaveAttribute("data-selected", "true"); + await expect(page.locator("[data-command='discard']")).toBeEnabled(); + await expect(page.locator("[data-command='apply']")).toBeEnabled(); + await expect(page.locator("[data-setting='proptl']")).toBeVisible(); + await expect(page.locator("[data-proptl-value]")).toHaveText("7.5 s"); + + const proptlSlider = page.locator('[data-slider="proptl"]'); + await proptlSlider.focus(); + await page.keyboard.press("ArrowRight"); + + await expect(page.locator("[data-command='discard']")).toBeEnabled(); + await expect(page.locator("[data-proptl-value]")).not.toHaveText("7.5 s"); + + await page.locator("[data-command='discard']").click(); + await expect(page.locator('[data-mode="servo_mode"]')).toHaveAttribute( + "data-selected", + "true", + ); + await expect(page.locator("[data-range-value]")).toHaveText("178 deg"); + await expect(page.locator("[data-power-limit-value]")).toHaveText("85 %"); + await expect(page.locator("[data-command='discard']")).toBeDisabled(); + }); + + test("apply writes same-mode changes and clears dirty state", async ({ page }) => { + await page.goto("/?demo"); + + const powerLimitSlider = page.locator('[data-slider="power-limit"]'); + await powerLimitSlider.focus(); + await page.keyboard.press("ArrowRight"); + + await expect(page.locator("[data-command='apply']")).toBeEnabled(); + await expect(page.locator("[data-command='discard']")).toBeEnabled(); + await expect(page.locator("[data-power-limit-value]")).toHaveText("86 %"); + + await page.locator("[data-command='apply']").click(); + + await expect(page.locator('[title="Show status log"] .state-value')).toHaveText("Ready"); + await expect(page.locator("[data-command='apply']")).toBeDisabled(); + await expect(page.locator("[data-command='discard']")).toBeDisabled(); + await expect(page.locator("[data-power-limit-value]")).toHaveText("86 %"); + }); + + test("apply writes mode changes and clears dirty state", async ({ page }) => { + await page.goto("/?demo"); + + await page.locator('[data-mode="cr_mode"]').click(); + + await expect(page.locator("[data-command='apply']")).toBeEnabled(); + await expect(page.locator("[data-command='discard']")).toBeEnabled(); + await expect(page.locator('[data-setting="proptl"]')).toBeVisible(); + + await page.locator("[data-command='apply']").click(); + await expect(page.getByRole("dialog", { name: "Applying Changes" })).toBeVisible(); + await expect(page.getByText("The attached servo is being updated to match the current settings.")).toBeVisible(); + + await expect(page.locator('[title="Show status log"] .state-value')).toHaveText("Ready"); + await expect(page.locator('[data-mode="cr_mode"]')).toHaveAttribute("data-selected", "true"); + await expect(page.locator('[data-setting="proptl"]')).toBeVisible(); + await expect(page.locator('[data-setting="range"]')).toHaveCount(0); + await expect(page.locator("[data-command='apply']")).toBeDisabled(); + await expect(page.locator("[data-command='discard']")).toBeDisabled(); + }); +}); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 94bf23f..af44644 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -12,5 +12,5 @@ "skipLibCheck": true, "types": ["bun"] }, - "include": ["src/**/*"] + "include": ["src/**/*", "scripts/**/*"] } diff --git a/bun.lock b/bun.lock index e226336..4622378 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,8 @@ "": { "name": "servo-programmer", "devDependencies": { + "@playwright/test": "^1.59.1", + "http-server": "^14.1.1", "turbo": "^2", }, }, @@ -57,6 +59,9 @@ "packages/core": { "name": "@axon/core", "version": "0.0.1", + "dependencies": { + "aes-js": "^3.1.2", + }, "devDependencies": { "@biomejs/biome": "^2.4.11", "@types/bun": "latest", @@ -92,6 +97,10 @@ "packages/ui": { "name": "@axon/ui", "version": "0.0.1", + "dependencies": { + "@axon/core": "workspace:*", + "chart.js": "^4.5.1", + }, "devDependencies": { "@biomejs/biome": "^2.4.11", "@types/bun": "latest", @@ -134,8 +143,12 @@ "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="], + "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], + "@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@1.1.1", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ=="], + "@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="], + "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], "@turbo/darwin-64": ["@turbo/darwin-64@2.9.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-X/56SnVXIQZBLKwniGTwEQTGmtE5brSACnKMBWpY3YafuxVYefrC2acamfjgxP7BG5w3I+6jf0UrLoSzgPcSJg=="], @@ -164,6 +177,8 @@ "@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="], + "aes-js": ["aes-js@3.1.2", "", {}, "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -172,18 +187,32 @@ "ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + "axon": ["axon@workspace:apps/cli"], + "basic-auth": ["basic-auth@2.0.1", "", { "dependencies": { "safe-buffer": "5.1.2" } }, "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg=="], + "basic-ftp": ["basic-ftp@5.2.2", "", {}, "sha512-1tDrzKsdCg70WGvbFss/ulVAxupNauGnOlgpyjKzeQxzyllBLS0CGLV7tjIXTK3ZQA9/FBEm9qyFFN1bciA6pw=="], "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "corser": ["corser@2.0.1", "", {}, "sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "cross-spawn-windows-exe": ["cross-spawn-windows-exe@1.2.0", "", { "dependencies": { "@malept/cross-spawn-promise": "^1.1.0", "is-wsl": "^2.2.0", "which": "^2.0.2" } }, "sha512-mkLtJJcYbDCxEG7Js6eUnUNndWjyUZwJ3H7bErmmtOYU/Zb99DyUkpamuIZE0b3bhmJyZ7D90uS6f+CGxRRjOw=="], @@ -194,10 +223,18 @@ "degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "electrobun": ["electrobun@1.16.0", "", { "dependencies": { "@babylonjs/core": "^7.45.0", "@types/bun": "^1.3.8", "png-to-ico": "^2.1.8", "proxy-agent": "^6.5.0", "rcedit": "^4.0.1", "three": "^0.165.0" }, "bin": { "electrobun": "bin/electrobun.cjs" } }, "sha512-KO/GQO6vpWACJXizqD8F551xtRAgg83Dcfzmjo+6qqCseobttu9x+HL40n+CBnYTe+kBvFXzwso2ZrPuec78zg=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="], @@ -208,16 +245,46 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], + + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], + + "html-encoding-sniffer": ["html-encoding-sniffer@3.0.0", "", { "dependencies": { "whatwg-encoding": "^2.0.0" } }, "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA=="], + + "http-proxy": ["http-proxy@1.18.1", "", { "dependencies": { "eventemitter3": "^4.0.0", "follow-redirects": "^1.0.0", "requires-port": "^1.0.0" } }, "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ=="], + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + "http-server": ["http-server@14.1.1", "", { "dependencies": { "basic-auth": "^2.0.1", "chalk": "^4.1.2", "corser": "^2.0.1", "he": "^1.2.0", "html-encoding-sniffer": "^3.0.0", "http-proxy": "^1.18.1", "mime": "^1.6.0", "minimist": "^1.2.6", "opener": "^1.5.1", "portfinder": "^1.0.28", "secure-compare": "3.0.1", "union": "~0.5.0", "url-join": "^4.0.1" }, "bin": { "http-server": "bin/http-server" } }, "sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], @@ -230,8 +297,12 @@ "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "meshoptimizer": ["meshoptimizer@0.22.0", "", {}, "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg=="], + "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -242,6 +313,10 @@ "node-hid": ["node-hid@3.3.0", "", { "dependencies": { "node-addon-api": "^3.2.1", "pkg-prebuilds": "^1.0.0" }, "bin": { "hid-showdevices": "src/show-devices.js" } }, "sha512-j+dFgJLRAE0nufQKXk3IfS6T6YuHhCgMvz4TrG0sgtb6DSCdYpfJ1etcdmeCmPQjUgO+yo32ktVrRliNs/+fmg=="], + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "opener": ["opener@1.5.2", "", { "bin": { "opener": "bin/opener-bin.js" } }, "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A=="], + "pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="], "pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="], @@ -250,22 +325,46 @@ "pkg-prebuilds": ["pkg-prebuilds@1.0.0", "", { "dependencies": { "yargs": "^17.7.2" }, "bin": { "pkg-prebuilds-copy": "bin/copy.mjs", "pkg-prebuilds-verify": "bin/verify.mjs" } }, "sha512-D9wlkXZCmjxj2kBHTw3fGSyjoahr33breGBoJcoezpi7ouYS59DJVOHMZ+dgqacSrZiJo4qtkXxLQTE+BqXJmQ=="], + "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], + + "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], + "png-to-ico": ["png-to-ico@2.1.8", "", { "dependencies": { "@types/node": "^17.0.36", "minimist": "^1.2.6", "pngjs": "^6.0.0" }, "bin": { "png-to-ico": "bin/cli.js" } }, "sha512-Nf+IIn/cZ/DIZVdGveJp86NG5uNib1ZXMiDd/8x32HCTeKSvgpyg6D/6tUBn1QO/zybzoMK0/mc3QRgAyXdv9w=="], "pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], + "portfinder": ["portfinder@1.0.38", "", { "dependencies": { "async": "^3.2.6", "debug": "^4.3.6" } }, "sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg=="], + "proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="], "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], + "rcedit": ["rcedit@4.0.1", "", { "dependencies": { "cross-spawn-windows-exe": "^1.1.0" } }, "sha512-bZdaQi34krFWhrDn+O53ccBDw0MkAT2Vhu75SqhtvhQu4OPyFM4RoVheyYiVQYdjhUi6EJMVWQ0tR6bCIYVkUg=="], "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="], + + "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "secure-compare": ["secure-compare@3.0.1", "", {}, "sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], @@ -278,6 +377,8 @@ "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "three": ["three@0.165.0", "", {}, "sha512-cc96IlVYGydeceu0e5xq70H8/yoVT/tXBxV/W8A/U6uOq7DXc4/s1Mkmnu6SqoYGhSRWWYFOhVwvq6V0VtbplA=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -286,6 +387,12 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "union": ["union@0.5.0", "", { "dependencies": { "qs": "^6.4.0" } }, "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA=="], + + "url-join": ["url-join@4.0.1", "", {}, "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA=="], + + "whatwg-encoding": ["whatwg-encoding@2.0.0", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], diff --git a/docs/UI_MODE_AUDIT.md b/docs/UI_MODE_AUDIT.md new file mode 100644 index 0000000..e30960b --- /dev/null +++ b/docs/UI_MODE_AUDIT.md @@ -0,0 +1,39 @@ +# UI Mode Audit + +Current browser/desktop UI coverage versus the CLI parameter surface. + +## Servo Mode + +Implemented in UI: +- `servo_angle` as `Range` +- `servo_neutral` as `Center` +- `inversion` as `Direction` +- `soft_start` as `Ramp` +- `sensitivity` as `Sensitivity` +- `dampening_factor` as `Dampening` +- `loose_pwm_protection` as `On signal loss` +- `overload_protection` as `Overload protection` +- `overload_level1` +- `overload_level2` +- `overload_level3` +- `pwm_power` as `Power limit` + +Still missing from UI: +- none from the current mapped Servo CLI parameter surface + +## Continuous Rotation Mode + +Implemented in UI: +- `servo_neutral` as `Stop trim` +- `inversion` as `Direction` +- `proptl` as `ProPTL` +- `pwm_power` as `Power limit` + +Still missing from UI: +- none from the current mapped CR CLI parameter surface + +## Notes + +- Sim-pane controls are ephemeral. They are not part of saved config. +- `Load` / `Save` now use config-only `.axon` files by default and also support `.svo`. +- `Apply` is still pending; this audit is about UI surface coverage, not write/flash wiring. diff --git a/package.json b/package.json index 3ecbd17..36a7f6b 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "ci": "bun run check && bun run typecheck && bun run test" }, "devDependencies": { + "@playwright/test": "^1.59.1", + "http-server": "^14.1.1", "turbo": "^2" } } diff --git a/packages/core/package.json b/packages/core/package.json index 429b4b6..5a24958 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -6,8 +6,12 @@ "exports": { ".": "./src/index.ts", "./catalog": "./src/catalog.ts", + "./config-summary": "./src/config-summary.ts", "./errors": "./src/errors.ts", + "./sfw": "./src/sfw.ts", + "./servo-specs": "./src/servo-specs.ts", "./driver": "./src/driver/index.ts", + "./driver/flash": "./src/driver/flash.ts", "./driver/protocol": "./src/driver/protocol.ts", "./driver/transport": "./src/driver/transport.ts" }, @@ -15,6 +19,9 @@ "check": "bun x @biomejs/biome check src package.json", "typecheck": "tsc --noEmit -p tsconfig.json" }, + "dependencies": { + "aes-js": "^3.1.2" + }, "devDependencies": { "@biomejs/biome": "^2.4.11", "@types/bun": "latest", diff --git a/packages/core/src/config-summary.ts b/packages/core/src/config-summary.ts new file mode 100644 index 0000000..28c535d --- /dev/null +++ b/packages/core/src/config-summary.ts @@ -0,0 +1,98 @@ +import { findModel, loadCatalog } from "./catalog.ts"; + +export type PwmLossBehavior = "release" | "hold" | "neutral"; +export type InversionMode = "normal" | "reversed"; + +export interface OverloadLevelSummary { + pct: number; + sec: number; +} + +export interface ConfigSetupSummary { + rangeDegrees: number | null; + rangePercent: number | null; + neutralUs: number | null; + neutralPercent: number | null; + pwmLossBehavior: PwmLossBehavior | null; + inversion: InversionMode | null; + softStart: boolean | null; + sensitivityStep: number | null; + sensitivityLabel: string | null; + dampeningFactor: number | null; + overloadProtectionEnabled: boolean | null; + overloadLevels: OverloadLevelSummary[]; + pwmPowerPercent: number | null; + proptlSeconds: number | null; +} + +function clamp(n: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, n)); +} + +function sensitivityLabel(step: number): string { + if (step <= 1) return "Ultra High"; + if (step <= 4) return "High"; + if (step <= 8) return "Medium"; + if (step <= 11) return "Low"; + return "Very Low"; +} + +function readBeU16(config: Uint8Array, offset: number): number { + return ((config[offset] ?? 0) << 8) | (config[offset + 1] ?? 0); +} + +function overloadLevels(config: Uint8Array): OverloadLevelSummary[] { + return Array.from({ length: 3 }, (_, index) => { + const powerRaw = config[0x35 + index * 2] ?? 0; + const timeRaw = config[0x36 + index * 2] ?? 0; + return { + pct: Number(((powerRaw * 100) / 255).toFixed(1)), + sec: Number((timeRaw * 0.1).toFixed(1)), + }; + }); +} + +export function summarizeConfig(config: Uint8Array, modelId: string): ConfigSetupSummary { + const model = findModel(loadCatalog(), modelId); + const maxRangeDeg = model?.max_range_deg ?? 355; + + const rangeRaw = config[0x04] ?? 0; + const neutralRaw = config[0x06] ?? 0x80; + const dampeningFactorRaw = readBeU16(config, 0x0a); + const sensitivityRaw = config[0x0c] ?? 0x10; + const flags = config[0x25] ?? 0x00; + const pwmPowerRaw = config[0x11] ?? 0x00; + const proptlRaw = config[0x36] ?? 0x00; + + const rangeDegrees = Math.round((rangeRaw * maxRangeDeg) / 255); + const neutralUs = neutralRaw - 128; + const sensitivityStep = clamp(Math.floor(sensitivityRaw / 16) - 1, 0, 14); + const pwmLossBits = flags & 0x60; + + let pwmLossBehavior: PwmLossBehavior = "release"; + if (pwmLossBits === 0x40) pwmLossBehavior = "hold"; + if (pwmLossBits === 0x60) pwmLossBehavior = "neutral"; + + return { + rangeDegrees, + rangePercent: Math.round((rangeRaw * 100) / 255), + neutralUs, + neutralPercent: Math.round((neutralRaw * 100) / 255), + pwmLossBehavior, + inversion: (flags & 0x02) !== 0 ? "reversed" : "normal", + softStart: (flags & 0x10) !== 0, + sensitivityStep, + sensitivityLabel: sensitivityLabel(sensitivityStep), + dampeningFactor: dampeningFactorRaw, + overloadProtectionEnabled: (flags & 0x80) !== 0, + overloadLevels: overloadLevels(config), + pwmPowerPercent: Math.round((pwmPowerRaw * 100) / 255), + proptlSeconds: Number((proptlRaw * 0.1).toFixed(1)), + }; +} + +export function servoModeLabel(mode: "servo_mode" | "cr_mode" | "unknown"): string { + if (mode === "servo_mode") return "Servo"; + if (mode === "cr_mode") return "Continuous Rotation"; + return "Unknown"; +} diff --git a/packages/core/src/driver/flash.ts b/packages/core/src/driver/flash.ts new file mode 100644 index 0000000..992c5d4 --- /dev/null +++ b/packages/core/src/driver/flash.ts @@ -0,0 +1,606 @@ +/** + * Axon dongle firmware-flash wire protocol. + * + * This module implements the HID-side command sequence the vendor + * exe uses to flash a new .sfw firmware to the connected servo. + * It is NOT the same command family as the config read/write + * protocol in `protocol.ts` — the flash commands live in a separate + * group (0x80..0x83, 0x90) and use a different HID OUT layout: + * + * tx[0] = 0x04 // report id + * tx[1] = // 0x80 / 0x81 / 0x82 / 0x83 / 0x90 + * tx[2] = // payload byte count (never >22 here) + * tx[3..3+length] = + * + * Reverse engineered from + * `research/static-analysis/ghidra_out/firmware_handler.c` + * (FUN_004065b0 — the top-level "flash firmware" handler; ~800 lines + * of Ghidra decomp). The corresponding wire transmission sub-helpers + * are FUN_00408220 (write) and FUN_004082f0 (read). Both send the + * full 64-byte HID report every time; the dongle fills untransmitted + * bytes with zero. + * + * State machine (per firmware_handler.c): + * + * 1. (vendor optional) write 0x90 with param[2]=0 -- "enter flash + * mode" lock. The CLI intentionally omits this command because + * hardware testing showed it can leave the dongle wedged until + * physically replugged. + * 2. write 0x80 payload = { 0x01, 0x02, 0x03, 0x04 } + * read reply (6 bytes); reply[0] must be 0x56 ('V'). reply[1,3] + * encode the boot version (`V0x2y`); we reject `V02` as too old. + * 3. decrypt .sfw -> plaintext (see `sfw.ts`). + * 4. generate 22 random bytes `challenge[0..21]`. + * write 0x81 payload = challenge. + * read 22 bytes `response`. + * compute `key[i] = challenge[i] ^ response[i]` for i=0..21. + * This XOR key is mixed into every subsequent 0x82 payload. + * 5. verify `sfw.header.typeBytes` match the boot-query reply bytes + * (reply[4],reply[5]). + * The vendor exe shows "Error 1030" if they don't match. + * 6. verify `sfw.header.modelId` equals the servo's current model id + * (from the `@0801SA33` line vs the config-block model id). The + * vendor exe shows "Error 1031" if they don't match. + * 7. for each sector-erase line in order: + * buf[0] = 0x0A + * buf[1] = sector.bytes[0] + * buf[2] = sector.bytes[1] + * buf[3..21] = random + * buf[i] ^= key[i] for i in 0..21 + * write 0x82 + 22-byte buf + * read 1 byte; must equal 0x55 or the erase failed. + * 8. for each Intel HEX record in order: + * buf[0] = 0x3A (the `:` char) + * buf[1] = record.count + * buf[2] = (record.address >> 8) & 0xff + * buf[3] = record.address & 0xff + * buf[4] = record.type + * buf[5..5+count-1] = record.data + * buf[5+count] = record.checksum + * buf[(5+count+1)..21] = random + * capture `expectedReply = buf[5+count]` BEFORE XOR (i.e. the + * record's checksum byte) + * buf[i] ^= key[i] for i in 0..21 + * write 0x82 + 22-byte buf + * read 1 byte; must equal `expectedReply`. + * 9. after the `:00000001FF` EOF record has been sent successfully: + * write 0x83 payload = { 0xFF, 0x55, 0xAA, <19 bytes> } again + * read 1 byte (discarded) -- "flash complete; re-enter normal + * operation". + * 10. (vendor optional) write 0x90 with param[2]=1 -- "exit flash + * mode". The CLI omits this for the same reason as step 1. + * + * Chunk transfer rate: the parameter-write helper at FUN_00404900 + * caps chunks at 59 bytes with a 25 ms inter-chunk sleep. The flash + * commands all fit in a single 22-byte payload, so this module only + * needs the 25 ms inter-command sleep to avoid overrunning the + * dongle's HID input queue. That matches the `Sleep(0x19)` at the + * top of FUN_00404900. + */ + +import { AxonError } from "../errors.ts"; +import type { DecryptedSfw, IntelHexRecord } from "../sfw.ts"; +import { REPORT_SIZE } from "./protocol.ts"; +import type { DongleHandle } from "./transport.ts"; + +type RandomSource = { + getRandomValues?: (array: Uint8Array) => Uint8Array; +}; + +function randomBytesCompat(length: number): Uint8Array { + const out = new Uint8Array(length); + const cryptoApi = (globalThis as { crypto?: RandomSource }).crypto; + if (typeof cryptoApi?.getRandomValues === "function") { + cryptoApi.getRandomValues(out); + return out; + } + throw new Error("flash: no secure random source is available in this environment."); +} + +// ---- flash command bytes -------------------------------------------------- + +/** "Enter/exit flash mode" — documented for the vendor path but not sent by the CLI. */ +export const CMD_MODE_LOCK = 0x90; +/** Query bootloader version. */ +export const CMD_BOOT_QUERY = 0x80; +/** Challenge / key exchange: host sends 22-byte challenge, dongle + * returns 22-byte response; XOR is the per-session key. */ +export const CMD_KEY_EXCHANGE = 0x81; +/** Sector erase / hex record write — payload is XOR-encrypted. */ +export const CMD_DATA_WRITE = 0x82; +/** Flash session start / stop marker (`FF 55 AA ...`). */ +export const CMD_FLASH_MARKER = 0x83; + +/** Payload prefix for a sector-erase 0x82 command. */ +export const DATA_PREFIX_SECTOR_ERASE = 0x0a; +/** Payload prefix for a hex-record 0x82 command: ASCII `:` char. */ +export const DATA_PREFIX_HEX_RECORD = 0x3a; + +/** Size of every 0x82 payload (and the 0x81 challenge). */ +export const FLASH_PAYLOAD_SIZE = 22; // 0x16 +/** Max Intel HEX data bytes that fit in one 0x82 payload. */ +export const MAX_HEX_RECORD_DATA_BYTES = FLASH_PAYLOAD_SIZE - 6; + +/** Inter-command sleep. Matches Sleep(0x19) = 25 ms in the decomp. */ +export const FLASH_CMD_SLEEP_MS = 25; +/** Flash reads need more headroom than normal config I/O, especially in WebHID. */ +export const FLASH_READ_TIMEOUT_MS = 5_000; + +/** Report id used for all dongle HID transfers. */ +const REPORT_ID = 0x04; + +// ---- progress callback ---------------------------------------------------- + +export type FlashPhase = + | "prepare" + | "boot_query" + | "cancel" + | "key_exchange" + | "verify_model" + | "erase" + | "write" + | "finalize" + | "done"; + +export interface FlashProgressEvent { + phase: FlashPhase; + /** Bytes of the decrypted firmware flashed so far (erase + write). */ + bytesSent?: number; + /** Total bytes of the decrypted firmware that will be flashed. */ + bytesTotal?: number; + /** Number of records processed so far (sector erases + hex records). */ + recordsSent?: number; + /** Total number of records that will be processed. */ + recordsTotal?: number; + /** Human-readable status line. */ + message?: string; +} + +export type FlashProgressFn = (event: FlashProgressEvent) => void; + +// ---- low-level helpers ---------------------------------------------------- + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Build the 64-byte HID OUT report used by the flash command family. + * Layout: `04 ` with zero padding to + * REPORT_SIZE. The flash-family layout is different from the config + * read/write command family in `protocol.ts`: here there are no + * address bytes. + */ +export function buildFlashTx(cmd: number, payload: Uint8Array): Buffer { + if (payload.length > REPORT_SIZE - 3) { + throw new Error(`flash: payload is ${payload.length} bytes, max ${REPORT_SIZE - 3}`); + } + const tx = Buffer.alloc(REPORT_SIZE); + tx[0] = REPORT_ID; + tx[1] = cmd; + tx[2] = payload.length; + for (let i = 0; i < payload.length; i++) { + tx[3 + i] = payload[i] ?? 0; + } + return tx; +} + +/** + * Parse a flash-family HID IN report. The dongle echoes the tx + * layout: `04 `. The vendor exe's read + * helper (FUN_004082f0) gates on `status != 0` AND `length == 0` + * before pulling data out of `rx[5..]`. (The "length" byte here is + * NOT a payload length — it's an error/flag byte, and on a + * successful read it is zero; the actual data lives at rx[5..5+N] + * where N is the count requested by the caller.) + */ +export function parseFlashRx(rx: Buffer, expectLength: number): Buffer { + if (rx.length < 5 + expectLength) { + throw AxonError.servoIo(`flash: rx is ${rx.length} bytes, need >= ${5 + expectLength}`); + } + if (rx[1] === 0 || rx[2] !== 0) { + // Dump the first 16 bytes so we can see exactly what the servo + // returned — critical for diagnosing protocol mismatches. + const hex = Array.from(rx.subarray(0, 16)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(" "); + throw AxonError.servoIo(`flash: unexpected reply. rx[0..15]: ${hex}`); + } + return rx.subarray(5, 5 + expectLength); +} + +/** + * Send one flash-family command and read the reply. Expects a + * reply of `expectLength` bytes (up to 59); 0 is valid for + * fire-and-forget commands that still require a drain-read to keep + * the HID input queue in sync. + */ +async function exchange( + handle: DongleHandle, + cmd: number, + payload: Uint8Array, + expectLength: number, + sleepMs: number, + onWireDebug?: (message: string) => void, +): Promise { + if (sleepMs > 0) await sleep(sleepMs); + const tx = buildFlashTx(cmd, payload); + await handle.write(tx); + const rx = await handle.read(FLASH_READ_TIMEOUT_MS); + if (onWireDebug !== undefined) { + const txHex = Array.from(tx.subarray(0, 8)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(" "); + const rxHex = Array.from(rx.subarray(0, 16)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(" "); + onWireDebug(`cmd=0x${cmd.toString(16)} tx[0..7]: ${txHex} rx[0..15]: ${rxHex}`); + } + return parseFlashRx(rx, expectLength); +} + +/** + * Fill the tail of a payload buffer with random bytes, starting at + * offset `fromIndex`. Matches the FUN_00408164 call in the decomp, + * where the vendor exe fills unused slots with random noise so the + * on-wire traffic doesn't leak patterns. + */ +function fillRandom(buf: Uint8Array, fromIndex: number): void { + if (fromIndex >= buf.length) return; + const rnd = randomBytesCompat(buf.length - fromIndex); + buf.set(rnd, fromIndex); +} + +/** XOR-encrypt a 22-byte payload in place against the session key. */ +function xorInPlace(buf: Uint8Array, key: Uint8Array): void { + if (buf.length !== key.length) { + throw new Error(`flash: xor buffer/key length mismatch (${buf.length} vs ${key.length})`); + } + for (let i = 0; i < buf.length; i++) buf[i] = (buf[i] ?? 0) ^ (key[i] ?? 0); +} + +// ---- the three payload shapes --------------------------------------------- + +/** Build the (plaintext) 22-byte sector-erase command buffer. */ +export function buildSectorEraseBuf(sectorBytes: [number, number]): Uint8Array { + const buf = new Uint8Array(FLASH_PAYLOAD_SIZE); + buf[0] = DATA_PREFIX_SECTOR_ERASE; + buf[1] = sectorBytes[0] & 0xff; + buf[2] = sectorBytes[1] & 0xff; + fillRandom(buf, 3); + return buf; +} + +/** + * Build the (plaintext) 22-byte hex-record command buffer. Returns + * both the buffer and the "expected reply" byte (the record's Intel + * HEX checksum, which the dongle echoes back after a successful + * write). The caller XOR-encrypts before writing; the expected reply + * is captured BEFORE the XOR so the caller can compare it against + * the raw byte the dongle returns. + */ +export function buildHexRecordBuf(record: IntelHexRecord): { + buf: Uint8Array; + expectedReply: number; +} { + // count + 2 addr + 1 type + count data + 1 checksum = count + 5 bytes + // after the leading ':' prefix. All of this must fit in 22 bytes + // including the prefix, so count + 5 + 1 <= 22 → count <= 16. + const total = record.count + 5 + 1; // +1 for the ':' prefix byte + if (total > FLASH_PAYLOAD_SIZE) { + throw AxonError.validation( + `flash: hex record has count=${record.count}, max is ${MAX_HEX_RECORD_DATA_BYTES} bytes`, + ); + } + const buf = new Uint8Array(FLASH_PAYLOAD_SIZE); + buf[0] = DATA_PREFIX_HEX_RECORD; + buf[1] = record.count & 0xff; + buf[2] = (record.address >> 8) & 0xff; + buf[3] = record.address & 0xff; + buf[4] = record.type & 0xff; + for (let i = 0; i < record.count; i++) { + buf[5 + i] = record.data[i] ?? 0; + } + buf[5 + record.count] = record.checksum & 0xff; + fillRandom(buf, 5 + record.count + 1); + return { buf, expectedReply: record.checksum & 0xff }; +} + +// ---- top-level flasher ---------------------------------------------------- + +export interface FlashOptions { + /** Optional per-record progress callback. */ + onProgress?: FlashProgressFn; + /** Optional wire-level debug sink for environment-specific logging. */ + onWireDebug?: (message: string) => void; + /** + * Connected servo's model id (from the config block at 0x40..0x47). + * Cross-checked against `sfw.header.modelId`. The vendor exe shows + * "Error 1031 Firmware is incorrect" if these don't match. + */ + expectedModelId?: string; + /** + * If set, skip the boot-query → @0801 header cross-check even when + * the bytes don't match. Only used by tests that fake the boot + * reply; production callers should leave this false. + */ + skipTypeCheck?: boolean; + /** + * Override the per-command inter-chunk sleep (ms). Defaults to + * `FLASH_CMD_SLEEP_MS` = 25 ms to match the vendor exe. Tests + * override this to 0 for fast execution. + */ + cmdSleepMs?: number; +} + +/** + * Validate everything about the decrypted firmware that can be known + * before the first HID write. This keeps malformed custom .sfw files + * from failing after sector erases or partial record writes. + */ +export function validateFlashFirmware(firmware: DecryptedSfw): void { + if (firmware.header.modelId.trim().length === 0) { + throw AxonError.validation("flash: firmware header has an empty model id."); + } + if (firmware.sectorErases.length === 0) { + throw AxonError.validation("flash: firmware contains no sector-erase records."); + } + if (firmware.hexRecords.length === 0) { + throw AxonError.validation("flash: firmware contains no Intel HEX records."); + } + + let eofCount = 0; + for (const [index, rec] of firmware.hexRecords.entries()) { + if (rec.count !== rec.data.length) { + throw AxonError.validation( + `flash: hex record addr=0x${rec.address.toString(16).padStart(4, "0")} ` + + `declares count=${rec.count} but has ${rec.data.length} data bytes`, + ); + } + if (rec.type !== 0x00 && rec.type !== 0x01) { + throw AxonError.validation( + `flash: hex record addr=0x${rec.address.toString(16).padStart(4, "0")} ` + + `has unsupported type=0x${rec.type.toString(16).padStart(2, "0")}`, + ); + } + if (rec.count > MAX_HEX_RECORD_DATA_BYTES) { + throw AxonError.validation( + `flash: hex record addr=0x${rec.address.toString(16).padStart(4, "0")} ` + + `has count=${rec.count}, max is ${MAX_HEX_RECORD_DATA_BYTES} bytes`, + ); + } + if (rec.type === 0x01) { + eofCount += 1; + if (index !== firmware.hexRecords.length - 1) { + throw AxonError.validation( + "flash: firmware Intel HEX EOF record must be the final record.", + ); + } + } + } + + if (eofCount !== 1) { + throw AxonError.validation( + `flash: firmware Intel HEX stream must contain exactly one EOF record; found ${eofCount}.`, + ); + } +} + +/** + * Flash the given decrypted firmware to the dongle/servo. The + * happy-path return is `void`; on any failure an `AxonError` is + * thrown describing the phase that failed. Progress events are + * emitted via `options.onProgress` if supplied. + * + * IMPORTANT: this function assumes the caller has already obtained + * exclusive ownership of the dongle handle. It performs no + * additional locking or re-identification — callers should run + * `identify()` before and after this function to observe the mode + * transition. + */ +export async function flashFirmware( + handle: DongleHandle, + firmware: DecryptedSfw, + options: FlashOptions = {}, +): Promise { + const progress = options.onProgress ?? (() => {}); + const wireDebug = options.onWireDebug; + validateFlashFirmware(firmware); + + // Phase 1: model-id pre-check. Purely a client-side sanity check; + // the dongle's own check happens in phase 6. + if (options.expectedModelId !== undefined) { + const rawModel = firmware.header.modelId; + const cleanFirmware = rawModel.replace(/[*\0\s]+$/, ""); + const cleanExpected = options.expectedModelId.replace(/[*\0\s]+$/, ""); + if (cleanFirmware !== cleanExpected) { + throw AxonError.validation( + `firmware is for model "${rawModel}" but connected servo is "${options.expectedModelId}".`, + ); + } + } + const sleepMs = options.cmdSleepMs ?? FLASH_CMD_SLEEP_MS; + + progress({ phase: "prepare", message: "Preparing flash session..." }); + + // Phase 1b: enter flash mode (0x90 param=0). The vendor exe sends + // this when the HID product name matches its whitelist — which our + // dongle ("USBBootloader V1.3") does. Without this command the + // servo stays in normal mode and rejects the key exchange (0x81). + // + // IMPORTANT: 0x90 has a DIFFERENT wire layout than the other flash + // commands. The vendor writes directly to the HID buffer + // (firmware_handler.c:164-168), NOT through FUN_00408220. The + // param goes at tx[2] — there is no length field: + // + // tx[0] = 0x04 (report ID) + // tx[1] = 0x90 (command) + // tx[2] = 0x00 (param: 0=enter, 1=exit) + // tx[3..63] = 0x00 + // + // Do NOT use buildFlashTx here — it puts a length byte at tx[2]. + // NOTE: 0x90 "enter flash mode" is intentionally NOT sent here. + // The vendor exe only sends 0x90 when the HID product name matches + // a specific whitelist (firmware_handler.c:153-174). Sending it to + // a dongle that doesn't expect it puts the dongle into a state that + // survives handle.close() and blocks all subsequent commands (the + // dongle returns 0x02 to everything until physically unplugged for + // several seconds). The flash protocol works without 0x90 — the + // servo's MCU handles the boot query / key exchange / data write + // sequence directly. + + // Phase 2: boot version query (0x80 + 4-byte payload 01 02 03 04). + progress({ phase: "boot_query", message: "Querying bootloader version..." }); + { + const bootTx = buildFlashTx(CMD_BOOT_QUERY, Uint8Array.from([0x01, 0x02, 0x03, 0x04])); + if (sleepMs > 0) await sleep(sleepMs); + await handle.write(bootTx); + await sleep(5); // vendor exe: Sleep(5) between write and read + } + const bootRx = await handle.read(FLASH_READ_TIMEOUT_MS); + if (wireDebug !== undefined) { + const hex = Array.from(bootRx.subarray(0, 16)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(" "); + wireDebug(`0x80 boot rx[0..15]: ${hex}`); + } + const bootReply = parseFlashRx(bootRx, 6); + if (bootReply[0] !== 0x56) { + const replyByte = bootReply[0] ?? 0; + throw AxonError.servoIo( + `flash: boot query reply[0]=0x${replyByte.toString(16)} (expected 0x56 'V')`, + ); + } + // The vendor exe refuses when (reply[3]=='2' && reply[1]=='0'): ASCII + // "V02" which is exactly the "Boot Version too old" guard. In + // practice every servo we've seen ships with a newer bootloader, + // but mirror the guard so an unexpected old device is rejected. + if (bootReply[1] === 0x30 && bootReply[3] === 0x32) { + throw AxonError.servoIo("flash: bootloader reports version V02, which is too old to flash."); + } + + // Cross-check the .sfw header's type bytes against the boot-query + // reply. The vendor exe stashes `bootReply[4]` and `bootReply[5]` + // into _DAT_007c51e9 and _DAT_007c51ea at firmware_handler.c lines + // 193-194, then compares them against the two hex-byte literals in + // the @0801XY header line after decrypting. This is the "Error + // 1030 Firmware is incorrect" guard at lines 403-404, and it's the + // ONLY wire-level "is this firmware for this servo family" check. + const [fa, fb] = firmware.header.typeBytes; + const ra = bootReply[4] ?? 0; + const rb = bootReply[5] ?? 0; + if (!options.skipTypeCheck && (fa !== ra || fb !== rb)) { + throw AxonError.validation( + `flash: firmware type bytes are [0x${fa.toString(16).padStart(2, "0")}, ` + + `0x${fb.toString(16).padStart(2, "0")}] but boot reply reported ` + + `[0x${ra.toString(16).padStart(2, "0")}, 0x${rb.toString(16).padStart(2, "0")}]. ` + + `Wrong family of firmware for this servo.`, + ); + } + + // NOTE: 0x83 "cancel" is NOT sent here. In the vendor decomp + // (firmware_handler.c:227-238), the 0x83 cancel only appears in the + // ERROR path (cVar2 == 0 → "Error Update please check connection"). + // The SUCCESS path goes straight from boot query → AES decrypt → + // key exchange. Sending 0x83 before 0x81 tells the servo "abort + // the flash session" and then the key exchange is rejected. + + // Phase 3: key exchange (0x81 + 22 random bytes). + progress({ phase: "key_exchange", message: "Negotiating flash session key..." }); + const challenge = randomBytesCompat(FLASH_PAYLOAD_SIZE); + const response = await exchange( + handle, + CMD_KEY_EXCHANGE, + challenge, + FLASH_PAYLOAD_SIZE, + sleepMs, + wireDebug, + ); + const key = new Uint8Array(FLASH_PAYLOAD_SIZE); + for (let i = 0; i < FLASH_PAYLOAD_SIZE; i++) { + key[i] = (challenge[i] ?? 0) ^ (response[i] ?? 0); + } + + progress({ phase: "verify_model", message: "Verifying firmware matches servo..." }); + // The vendor exe runs the @0801 header check here as a + // post-key-exchange gate. Our decrypt already surfaced the header, + // and we've cross-checked the caller-supplied expected id in + // phase 1, so there's nothing else to do here besides emitting the + // progress event. + + // Compute totals so the caller can render a progress bar. + const totalRecords = firmware.sectorErases.length + firmware.hexRecords.length; + const totalBytes = firmware.hexRecords.reduce((acc, r) => acc + r.count, 0); + let doneRecords = 0; + let doneBytes = 0; + + // Phase 5: sector erases. + for (const sector of firmware.sectorErases) { + const buf = buildSectorEraseBuf(sector.bytes); + xorInPlace(buf, key); + const reply = await exchange(handle, CMD_DATA_WRITE, buf, 1, sleepMs, wireDebug); + if (reply[0] !== 0x55) { + const replyByte = reply[0] ?? 0; + throw AxonError.servoIo( + `flash: sector erase $${sector.raw} failed: reply byte 0x${replyByte.toString(16)} (expected 0x55)`, + ); + } + doneRecords += 1; + progress({ + phase: "erase", + recordsSent: doneRecords, + recordsTotal: totalRecords, + bytesSent: doneBytes, + bytesTotal: totalBytes, + message: `Erased sector $${sector.raw}`, + }); + } + + // Phase 6: hex records (including the EOF record). + for (const rec of firmware.hexRecords) { + const { buf, expectedReply } = buildHexRecordBuf(rec); + xorInPlace(buf, key); + const reply = await exchange(handle, CMD_DATA_WRITE, buf, 1, sleepMs, wireDebug); + // The EOF record (type=0x01) is the last thing sent. After + // receiving it the servo's MCU finishes the flash and reboots — + // the HID reply we read back is from the rebooted firmware, not + // a flash acknowledgment. Don't verify the reply for EOF. + if (rec.type !== 0x01 && reply[0] !== expectedReply) { + const replyByte = reply[0] ?? 0; + throw AxonError.servoIo( + `flash: hex record addr=0x${rec.address.toString(16).padStart(4, "0")} ` + + `type=0x${rec.type.toString(16).padStart(2, "0")} write failed: ` + + `reply 0x${replyByte.toString(16)} != expected 0x${expectedReply.toString(16)}`, + ); + } + doneRecords += 1; + doneBytes += rec.count; + progress({ + phase: "write", + recordsSent: doneRecords, + recordsTotal: totalRecords, + bytesSent: doneBytes, + bytesTotal: totalBytes, + message: `Wrote ${rec.count} bytes @ 0x${rec.address.toString(16).padStart(4, "0")}`, + }); + } + + // Phase 7: finalize — same FF 55 AA marker as the "cancel" above. + progress({ phase: "finalize", message: "Finalizing flash session..." }); + const finishBuf = new Uint8Array(FLASH_PAYLOAD_SIZE); + finishBuf[0] = 0xff; + finishBuf[1] = 0x55; + finishBuf[2] = 0xaa; + fillRandom(finishBuf, 3); + await exchange(handle, CMD_FLASH_MARKER, finishBuf, 1, sleepMs, wireDebug); + + // NOTE: 0x90 "exit flash mode" is NOT sent — see the comment at + // the top of this function about why 0x90 is omitted. + + progress({ + phase: "done", + bytesSent: doneBytes, + bytesTotal: totalBytes, + message: "Flash complete.", + }); +} diff --git a/packages/core/src/driver/index.ts b/packages/core/src/driver/index.ts index fbd941a..5a6e779 100644 --- a/packages/core/src/driver/index.ts +++ b/packages/core/src/driver/index.ts @@ -1,2 +1,3 @@ +export * from "./flash.ts"; export * from "./protocol.ts"; export * from "./transport.ts"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 050f1f6..c033fb6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,2 +1,5 @@ export * from "./catalog.ts"; +export * from "./config-summary.ts"; export * from "./errors.ts"; +export * from "./servo-specs.ts"; +export * from "./sfw.ts"; diff --git a/packages/core/src/servo-specs.ts b/packages/core/src/servo-specs.ts new file mode 100644 index 0000000..0884ec0 --- /dev/null +++ b/packages/core/src/servo-specs.ts @@ -0,0 +1,76 @@ +export interface VoltagePoint { + voltage: number; + torqueKgcm: number; + speedSec60: number; + idleA: number; + stallA: number; +} + +export interface ServoSpec { + bodyWidth: number; + bodyHeight: number; + axisInsetY: number; + maxRangeDeg: number; + points: VoltagePoint[]; +} + +// Axon legacy v1.3 servo operating points used by the browser/desktop simulator. +// Units match the published servo datasheets so the UI can present familiar FTC values. +export const AXON_MINI_SPEC: ServoSpec = { + bodyWidth: 130, + bodyHeight: 236, + axisInsetY: 44, + maxRangeDeg: 355, + points: [ + { voltage: 4.8, torqueKgcm: 13, speedSec60: 0.16, idleA: 0.14, stallA: 2.2 }, + { voltage: 6.0, torqueKgcm: 16, speedSec60: 0.13, idleA: 0.17, stallA: 2.6 }, + { voltage: 7.4, torqueKgcm: 19, speedSec60: 0.11, idleA: 0.19, stallA: 3.0 }, + { voltage: 8.4, torqueKgcm: 21, speedSec60: 0.1, idleA: 0.21, stallA: 3.2 }, + ], +}; + +export const AXON_MICRO_SPEC: ServoSpec = { + bodyWidth: 112, + bodyHeight: 214, + axisInsetY: 40, + maxRangeDeg: 355, + points: [ + { voltage: 4.8, torqueKgcm: 4.2, speedSec60: 0.16, idleA: 0.1, stallA: 1.0 }, + { voltage: 6.0, torqueKgcm: 5.2, speedSec60: 0.13, idleA: 0.12, stallA: 1.3 }, + { voltage: 7.4, torqueKgcm: 6.5, speedSec60: 0.11, idleA: 0.14, stallA: 1.6 }, + { voltage: 8.4, torqueKgcm: 7.1, speedSec60: 0.1, idleA: 0.16, stallA: 1.8 }, + ], +}; + +export const AXON_MAX_SPEC: ServoSpec = { + bodyWidth: 144, + bodyHeight: 246, + axisInsetY: 46, + maxRangeDeg: 355, + points: [ + { voltage: 4.8, torqueKgcm: 28, speedSec60: 0.14, idleA: 0.28, stallA: 3.6 }, + { voltage: 6.0, torqueKgcm: 34, speedSec60: 0.115, idleA: 0.32, stallA: 4.0 }, + { voltage: 7.2, torqueKgcm: 39, speedSec60: 0.1, idleA: 0.36, stallA: 4.3 }, + { voltage: 8.4, torqueKgcm: 45, speedSec60: 0.085, idleA: 0.4, stallA: 4.6 }, + ], +}; + +export function lookupServoSpec( + modelId: string | null, + modelName: string | null, + fallbackRangeDeg?: number | null, +): ServoSpec { + if ((modelId ?? "").startsWith("SA20") || (modelName ?? "").toLowerCase().includes("micro")) { + return AXON_MICRO_SPEC; + } + if ((modelId ?? "").startsWith("SA81") || (modelName ?? "").toLowerCase().includes("max")) { + return AXON_MAX_SPEC; + } + if ((fallbackRangeDeg ?? 0) > AXON_MINI_SPEC.maxRangeDeg) { + return { + ...AXON_MINI_SPEC, + maxRangeDeg: fallbackRangeDeg ?? AXON_MINI_SPEC.maxRangeDeg, + }; + } + return AXON_MINI_SPEC; +} diff --git a/packages/core/src/sfw.ts b/packages/core/src/sfw.ts new file mode 100644 index 0000000..d779834 --- /dev/null +++ b/packages/core/src/sfw.ts @@ -0,0 +1,281 @@ +/** + * .sfw firmware container — decrypt + Intel HEX parse. + * + * TypeScript port of `research/static-analysis/static_analyze.py`'s + * `cmd_decrypt`, cross-checked against + * `research/decrypted-firmware/Axon_Mini_Servo_Mode.plain.bin` as + * ground truth. + * + * File layout (reverse engineered from the vendor exe): + * + * bytes[0:16] AES-128-ECB(key="TTTTTTTTTTTTTTTT") + * -> [uint32_le declared_length][12 x 'x' magic] + * bytes[16:] AES-128-CBC(key, iv=0) + * -> plaintext[0:declared_length] is ASCII text: + * line 1 `@0801<8-char model id>` + * N lines `$<4-hex-char sector addr>` (sector erase) + * M lines `:` + * last `:00000001FF` (Intel HEX EOF) + * The remaining bytes are AES block padding — not + * part of the firmware. + * + * The lines are CRLF-terminated in the ground-truth files, but we + * tolerate both line endings on parse. + */ + +/// + +import { Buffer } from "node:buffer"; +import * as aesjs from "aes-js"; + +/** AES-128 key used for every .sfw file: sixteen ASCII 'T' bytes. */ +const KEY = aesjs.utils.utf8.toBytes("TTTTTTTTTTTTTTTT"); +const IV_ZERO = new Uint8Array(16); +const MIN_SFW_BYTES = 32; // 16-byte ECB header + at least one AES-CBC body block + +/** Expected magic run in the 16-byte ECB header: twelve ASCII 'x' bytes. */ +const MAGIC = Buffer.from("xxxxxxxxxxxx", "ascii"); + +export interface IntelHexRecord { + /** Byte count field (COUNT in `:CCAAAATT...`). */ + count: number; + /** 16-bit address field. */ + address: number; + /** Record type: 0x00 = data, 0x01 = EOF. */ + type: number; + /** Decoded data payload (`count` bytes). */ + data: Buffer; + /** Stored checksum byte (last byte of the record). */ + checksum: number; + /** The original ASCII line (without the leading `:` or trailing CRLF). */ + raw: string; +} + +export interface SectorEraseRecord { + /** Raw line text without leading `$` or trailing CRLF. */ + raw: string; + /** Two sector bytes parsed from positions 1..2 and 3..4 of the line. */ + bytes: [number, number]; +} + +export interface SfwHeaderRecord { + /** Raw line text without leading `@` or trailing CRLF. */ + raw: string; + /** + * Two servo-type bytes parsed from positions 1..2 and 3..4 of the + * line (`0801` → [0x08, 0x01]). The vendor exe compares these + * against the identify reply's model bytes before flashing. + */ + typeBytes: [number, number]; + /** Model id string starting at position 5 (e.g. "SA33****"). */ + modelId: string; +} + +export interface DecryptedSfw { + /** Length declared by the 16-byte header. */ + declaredLength: number; + /** + * The full decrypted plaintext bytes (trimmed to declaredLength). + * Byte-for-byte comparable with the ground-truth + * `research/decrypted-firmware/*.plain.bin` files. + */ + plaintext: Buffer; + /** The single `@0801` handshake line. */ + header: SfwHeaderRecord; + /** All `$HHHH` sector-erase lines, in file order. */ + sectorErases: SectorEraseRecord[]; + /** All `:...` Intel HEX records, in file order, terminated by a 0x01 EOF record. */ + hexRecords: IntelHexRecord[]; +} + +function ensureAligned(ciphertext: Buffer): void { + if (ciphertext.length < MIN_SFW_BYTES || ciphertext.length % 16 !== 0) { + throw new Error( + `sfw: ciphertext is ${ciphertext.length} bytes; expected >=${MIN_SFW_BYTES} and a multiple of 16`, + ); + } +} + +function decryptEcb16(block: Buffer): Buffer { + const mode = new aesjs.ModeOfOperation.ecb(KEY); + return Buffer.from(mode.decrypt(Uint8Array.from(block))); +} + +function decryptCbc(body: Buffer): Buffer { + const mode = new aesjs.ModeOfOperation.cbc(KEY, IV_ZERO); + return Buffer.from(mode.decrypt(Uint8Array.from(body))); +} + +/** + * Parse a single hex byte pair (e.g. "A3") into a number 0..255. + * Throws on non-hex input. + */ +function hexByte(s: string, where: string): number { + if (s.length !== 2) { + throw new Error(`sfw: ${where}: expected 2 hex chars, got "${s}"`); + } + const v = Number.parseInt(s, 16); + if (Number.isNaN(v) || v < 0 || v > 0xff) { + throw new Error(`sfw: ${where}: not a hex byte: "${s}"`); + } + return v; +} + +/** + * Parse a single Intel HEX record. The input is the line body WITHOUT + * the leading `:` and WITHOUT the trailing CR/LF. Validates the + * checksum against `(0x100 - (sum of all bytes)) & 0xFF`. + */ +export function parseIntelHexRecord(body: string): IntelHexRecord { + if (body.length < 10 || body.length % 2 !== 0) { + throw new Error(`sfw: hex record "${body}" has invalid length ${body.length}`); + } + const count = hexByte(body.slice(0, 2), "hex record count"); + const addrHi = hexByte(body.slice(2, 4), "hex record addr-hi"); + const addrLo = hexByte(body.slice(4, 6), "hex record addr-lo"); + const type = hexByte(body.slice(6, 8), "hex record type"); + // Expected total hex pairs = 1 count + 2 addr + 1 type + count data + 1 checksum + const expectedPairs = 1 + 2 + 1 + count + 1; + if (body.length !== expectedPairs * 2) { + throw new Error( + `sfw: hex record "${body}" has count=${count} but ${body.length / 2} bytes (expected ${expectedPairs})`, + ); + } + const data = Buffer.alloc(count); + for (let i = 0; i < count; i++) { + data[i] = hexByte(body.slice(8 + i * 2, 10 + i * 2), `hex record data[${i}]`); + } + const checksum = hexByte(body.slice(8 + count * 2, 10 + count * 2), "hex record checksum"); + // Verify. + let sum = count + addrHi + addrLo + type; + for (let i = 0; i < count; i++) sum += data[i] ?? 0; + const expected = (0x100 - (sum & 0xff)) & 0xff; + if (expected !== checksum) { + throw new Error( + `sfw: hex record "${body}" checksum mismatch: stored 0x${checksum.toString(16)} != computed 0x${expected.toString(16)}`, + ); + } + return { + count, + address: (addrHi << 8) | addrLo, + type, + data, + checksum, + raw: body, + }; +} + +/** Compute the Intel HEX checksum byte for a record body (excluding `:`). */ +export function intelHexChecksum(bytes: readonly number[] | Buffer): number { + let sum = 0; + for (const b of bytes) sum = (sum + (b & 0xff)) & 0xff; + return (0x100 - sum) & 0xff; +} + +/** + * Split the plaintext into lines, tolerant of CRLF / LF. Empty + * trailing lines (common after the last CRLF) are dropped. + */ +function splitLines(text: string): string[] { + const lines = text.split(/\r?\n/); + while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); + return lines; +} + +/** + * Decrypt a .sfw blob and parse the resulting plaintext into + * structured records. Throws on anything abnormal (wrong magic, bad + * checksum, unexpected line prefix, missing EOF record, or non-empty + * data after EOF). Deliberately strict — a silent-failure decrypter + * would put us in a bad spot the first time we flash real hardware. + */ +export function decryptSfw(ciphertext: Buffer): DecryptedSfw { + ensureAligned(ciphertext); + + const header = decryptEcb16(ciphertext.subarray(0, 16)); + const declaredLength = header.readUInt32LE(0); + const magic = header.subarray(4, 16); + if (magic.toString("hex") !== MAGIC.toString("hex")) { + throw new Error( + `sfw: header magic is ${magic.toString("hex")}, expected ${MAGIC.toString("hex")}`, + ); + } + if (declaredLength <= 0 || declaredLength > ciphertext.length) { + throw new Error( + `sfw: declared length ${declaredLength} is out of range (file is ${ciphertext.length} bytes)`, + ); + } + + const body = ciphertext.subarray(16); + const rawPlaintext = decryptCbc(body); + const plaintext = rawPlaintext.subarray(0, declaredLength); + + // Intel HEX + sector directives are ASCII; the rest is padding. + const text = plaintext.toString("ascii"); + const lines = splitLines(text); + if (lines.length === 0) { + throw new Error("sfw: plaintext had no lines"); + } + + // Line 1: @0801 + const first = lines[0]; + if (first === undefined) { + throw new Error("sfw: plaintext had no lines"); + } + if (!first.startsWith("@")) { + throw new Error(`sfw: expected first line to start with '@', got "${first.slice(0, 16)}"`); + } + if (first.length < 5) { + throw new Error(`sfw: header line "${first}" is too short`); + } + const headerRec: SfwHeaderRecord = { + raw: first.slice(1), + typeBytes: [ + hexByte(first.slice(1, 3), "sfw header typeByte[0]"), + hexByte(first.slice(3, 5), "sfw header typeByte[1]"), + ], + modelId: first.slice(5), + }; + + const sectorErases: SectorEraseRecord[] = []; + const hexRecords: IntelHexRecord[] = []; + let sawEof = false; + for (let i = 1; i < lines.length; i++) { + const line = lines[i] ?? ""; + if (line.length === 0) continue; + if (sawEof) { + throw new Error(`sfw: non-empty line after Intel HEX EOF at line ${i + 1}`); + } + if (line.startsWith("$")) { + if (line.length < 5) { + throw new Error(`sfw: sector line "${line}" is too short`); + } + sectorErases.push({ + raw: line.slice(1), + bytes: [ + hexByte(line.slice(1, 3), "sector byte[0]"), + hexByte(line.slice(3, 5), "sector byte[1]"), + ], + }); + } else if (line.startsWith(":")) { + const rec = parseIntelHexRecord(line.slice(1)); + hexRecords.push(rec); + if (rec.type === 0x01) sawEof = true; + } else { + throw new Error( + `sfw: unknown line prefix at line ${i + 1}: "${line.slice(0, 32)}" (expected '$' or ':')`, + ); + } + } + if (!sawEof) { + throw new Error("sfw: plaintext has no Intel HEX EOF record (:00000001FF)"); + } + + return { + declaredLength, + plaintext, + header: headerRec, + sectorErases, + hexRecords, + }; +} diff --git a/packages/core/src/types/aes-js.d.ts b/packages/core/src/types/aes-js.d.ts new file mode 100644 index 0000000..ce02a7f --- /dev/null +++ b/packages/core/src/types/aes-js.d.ts @@ -0,0 +1 @@ +declare module "aes-js"; diff --git a/packages/transport-webhid/src/index.ts b/packages/transport-webhid/src/index.ts index 10a74d1..0fbbec6 100644 --- a/packages/transport-webhid/src/index.ts +++ b/packages/transport-webhid/src/index.ts @@ -4,12 +4,19 @@ import type { ProbeCollectionInfo, ProbeDeviceInfo } from "@axon/ui"; export const VID = 0x0471; export const PID = 0x13aa; export const REPORT_SIZE = 64; +const ECHO_COMMANDS = new Set([0x8a, 0xcd, 0xcb]); interface InputReportReply { reportId: number; data: Uint8Array; } +interface PendingRead { + resolve: (reply: InputReportReply) => void; + reject: (error: Error) => void; + timer: number; +} + function hex(value: number, width = 2): string { return `0x${value.toString(16).padStart(width, "0")}`; } @@ -69,24 +76,20 @@ export async function requestAxonDevices(): Promise { }); } -function waitForInputReport(device: HIDDevice, timeoutMs: number): Promise { - return new Promise((resolve, reject) => { - const timer = window.setTimeout(() => { - device.removeEventListener("inputreport", onInputReport); - reject(new Error(`Timed out waiting for input report after ${timeoutMs} ms`)); - }, timeoutMs); +export class WebHidDongle implements DongleHandle { + private readonly replies: InputReportReply[] = []; + private pendingRead: PendingRead | null = null; + private readonly onInputReport: EventListener; - const onInputReport: EventListener = (event) => { + constructor(private readonly device: HIDDevice) { + this.onInputReport = (event) => { const hidEvent = event as HIDInputReportEvent; - if (hidEvent.device !== device) { + if (hidEvent.device !== this.device) { return; } - window.clearTimeout(timer); - device.removeEventListener("inputreport", onInputReport); - - resolve({ + const reply = { reportId: hidEvent.reportId, data: new Uint8Array( hidEvent.data.buffer.slice( @@ -94,15 +97,30 @@ function waitForInputReport(device: HIDDevice, timeoutMs: number): Promise { if (!this.device.opened) { @@ -119,15 +137,37 @@ export class WebHidDongle implements DongleHandle { throw new Error("Selected HID device is not open."); } - const reply = await waitForInputReport(this.device, timeoutMs); + const queued = this.replies.shift(); + if (queued) { + return Buffer.from([queued.reportId, ...queued.data]); + } + + const reply = await new Promise((resolve, reject) => { + const timer = window.setTimeout(() => { + this.pendingRead = null; + reject(new Error(`Timed out waiting for input report after ${timeoutMs} ms`)); + }, timeoutMs); + + this.pendingRead = { resolve, reject, timer }; + }); return Buffer.from([reply.reportId, ...reply.data]); } async release(): Promise { + if (this.pendingRead) { + window.clearTimeout(this.pendingRead.timer); + this.pendingRead.reject(new Error("Selected HID device was closed.")); + this.pendingRead = null; + } + this.device.removeEventListener("inputreport", this.onInputReport); if (this.device.opened) { await this.device.close(); } } + + clearInputQueue(): void { + this.replies.length = 0; + } } export async function openDongle(device: HIDDevice): Promise { diff --git a/packages/ui/package.json b/packages/ui/package.json index 76e6fc4..383ad30 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -6,6 +6,10 @@ "exports": { ".": "./src/index.ts" }, + "dependencies": { + "@axon/core": "workspace:*", + "chart.js": "^4.5.1" + }, "scripts": { "check": "bun x @biomejs/biome check src package.json", "typecheck": "tsc --noEmit -p tsconfig.json" diff --git a/packages/ui/src/probe.ts b/packages/ui/src/probe.ts index 564ad81..12ec738 100644 --- a/packages/ui/src/probe.ts +++ b/packages/ui/src/probe.ts @@ -1,12 +1,83 @@ -const STYLE_ID = "axon-probe-ui-style"; +import { Buffer } from "node:buffer"; +import { findModel, loadCatalog } from "@axon/core/catalog"; +import { servoModeLabel, summarizeConfig } from "@axon/core/config-summary"; +import { + CONFIG_BLOCK_SIZE, + type ServoMode as CoreServoMode, + modelIdFromConfig, +} from "@axon/core/driver/protocol"; +import { + AXON_MINI_SPEC, + lookupServoSpec, + type ServoSpec, + type VoltagePoint, +} from "@axon/core/servo-specs"; +import { decryptSfw } from "@axon/core/sfw"; +import { + Chart, + type ChartDataset, + type ChartOptions, + Filler, + Legend, + LinearScale, + LineController, + LineElement, + type Plugin, + PointElement, + Tooltip, + type TooltipModel, +} from "chart.js"; -export interface ProbeCollectionInfo { - usagePage: string; - usage: string; - inputReports: number[]; - outputReports: number[]; - featureReports: number[]; -} +const operatingPointMarkerPlugin: Plugin<"line"> = { + id: "operatingPointMarker", + afterDatasetsDraw(chart) { + const datasets = chart.data.datasets as Array< + ChartDataset<"line", ChartPoint[]> & { + operatingMarker?: boolean; + markerFill?: string; + markerStroke?: string; + markerRadius?: number; + } + >; + const xScale = chart.scales.x; + if (!xScale) return; + + const ctx = chart.ctx; + for (const dataset of datasets) { + if (!dataset.operatingMarker) continue; + const point = dataset.data?.[0]; + if (!point || typeof point.x !== "number" || typeof point.y !== "number") continue; + const yAxisId = dataset.yAxisID ?? "y"; + const yScale = chart.scales[yAxisId]; + if (!yScale) continue; + + const x = xScale.getPixelForValue(point.x); + const y = yScale.getPixelForValue(point.y); + const radius = dataset.markerRadius ?? 6; + + ctx.save(); + ctx.beginPath(); + ctx.arc(x, y, radius, 0, Math.PI * 2); + ctx.fillStyle = dataset.markerFill ?? "#8fc2f8"; + ctx.fill(); + ctx.lineWidth = 2; + ctx.strokeStyle = dataset.markerStroke ?? "#2d78d0"; + ctx.stroke(); + ctx.restore(); + } + }, +}; + +Chart.register( + LineController, + LineElement, + PointElement, + LinearScale, + Tooltip, + Legend, + Filler, + operatingPointMarkerPlugin, +); export interface ProbeDeviceInfo { id: string | null; @@ -18,10 +89,18 @@ export interface ProbeDeviceInfo { interface: number | null; usagePage: string | null; usage: string | null; - opened?: boolean; + opened: boolean; collections?: ProbeCollectionInfo[]; } +export interface ProbeCollectionInfo { + usagePage: string | null; + usage: string | null; + inputReports: number[]; + outputReports: number[]; + featureReports: number[]; +} + export interface ProbeInventory { devices: ProbeDeviceInfo[]; openedId: string | null; @@ -32,347 +111,5302 @@ export interface ProbeIdentifyInfo { statusHi: string; statusLo: string; modeByte: string | null; - mode: string; + mode: "servo_mode" | "cr_mode" | "unknown"; rawRx: string; } +export interface ProbeSetupInfo { + rangeDegrees: number | null; + rangePercent: number | null; + neutralUs: number | null; + neutralPercent: number | null; + pwmLossBehavior: string | null; + inversion: string | null; + softStart: boolean | null; + sensitivityStep: number | null; + sensitivityLabel: string | null; + dampeningFactor: number | null; + overloadProtectionEnabled: boolean | null; + overloadLevels: Array<{ pct: number; sec: number }>; + pwmPowerPercent: number | null; + proptlSeconds: number | null; +} + export interface ProbeConfigInfo { length: number; modelId: string; known: boolean; modelName: string | null; docsUrl: string | null; + mode: "servo_mode" | "cr_mode" | "unknown"; + modeLabel: string; + setup: ProbeSetupInfo | null; rawHex: string; firstChunk: string; secondChunk: string; + rawBytes: number[] | null; } -export interface ProbeUiAction { +interface ProbeAction { label: string; run: () => Promise; } -export interface ProbeUiOptions { +interface ProbeValueAction { + label: string; + run: (arg: TArg) => Promise; +} + +export interface ProbeLoadedFile { + name: string; + format: "axon" | "svo"; + text?: string; + bytes?: number[]; +} + +export interface ProbeSavedFile { + path: string | null; +} + +export interface ProbeFirmwareFile { + name: string; + bytes: number[]; +} + +export interface ProbeFlashProgressEvent { + phase: string; + bytesSent?: number; + bytesTotal?: number; + recordsSent?: number; + recordsTotal?: number; + message?: string; +} + +export interface MountProbeAppOptions { root: HTMLElement; + autoConnectOnLoad?: boolean; + debugEnabled?: boolean; + initialAuthorizedAccess?: boolean; + livePollIntervalMs?: number; + subscribeTransportLog?: (push: (message: string) => void) => undefined | (() => void); + subscribeStatusLog?: (push: (message: string) => void) => undefined | (() => void); + watchInventory?: (onInventory: (inventory: ProbeInventory) => void) => undefined | (() => void); + eyebrow?: string; title: string; - description: string; - bullets: string[]; - devicePanelTitle: string; - emptyDeviceText: string; + description?: string; + bullets?: string[]; + devicePanelTitle?: string; + emptyDeviceText?: string; loadEnvironment: () => Promise; loadInventory: () => Promise; - refreshInventory?: ProbeUiAction; - requestDevice?: ProbeUiAction; - reconnectDevice?: ProbeUiAction; - openDevice: ProbeUiAction; - closeDevice: ProbeUiAction; - identifyServo: ProbeUiAction; - readFullConfig: ProbeUiAction; + requestDevice?: ProbeAction; + reconnectDevice?: ProbeAction; + refreshInventory?: ProbeAction; + openDevice?: ProbeAction; + closeDevice?: ProbeAction; + identifyServo?: ProbeAction; + readFullConfig?: ProbeAction; + writeFullConfig?: ProbeValueAction; + loadConfigFile?: ProbeAction; + loadFirmwareFile?: ProbeAction; + saveAxonFile?: ProbeValueAction< + { suggestedName: string; text: string }, + ProbeSavedFile | undefined + >; + exportSvoFile?: ProbeValueAction< + { suggestedName: string; bytes: number[] }, + ProbeSavedFile | undefined + >; + flashModeChange?: ProbeValueAction< + { + targetMode: ServoMode; + modelId: string; + onProgress?: (event: ProbeFlashProgressEvent) => void; + }, + void + >; + flashFirmwareFile?: ProbeValueAction< + { + bytes: Uint8Array; + expectedModelId?: string; + onProgress?: (event: ProbeFlashProgressEvent) => void; + }, + void + >; } -function installStyles(document: Document) { - if (document.getElementById(STYLE_ID)) { - return; - } +interface LogEntry { + timestamp: string; + message: string; + level: "status" | "debug"; +} - const style = document.createElement("style"); - style.id = STYLE_ID; - style.textContent = ` - :root { - color-scheme: light dark; - font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - } +type ServoMode = "servo_mode" | "cr_mode"; +type LossBehavior = "release" | "hold" | "neutral"; +type Direction = "cw" | "ccw"; +type SensitivityLabel = "Ultra High" | "High" | "Medium" | "Low" | "Very Low"; - body { - margin: 0; - padding: 24px; - line-height: 1.45; - } +interface OverloadLevel { + pct: number; + sec: number; +} - main { - max-width: 960px; - } +interface DraftConfig { + mode: ServoMode; + rangePercent: number; + neutralUs: number; + direction: Direction; + pwmLossBehavior: LossBehavior; + softStart: boolean; + sensitivityStep: number; + dampeningFactor: number; + overloadProtectionEnabled: boolean; + overloadLevels: [OverloadLevel, OverloadLevel, OverloadLevel]; + pwmPowerPercent: number; + proptlSeconds: number; +} - h1, h2, p, ul { - margin: 0 0 16px; - } +interface SimState { + pwmUs: number; + voltage: number; + loadKgcm: number; + sweepEnabled: boolean; + playbackRunning: boolean; + servoSweepPhase: number; + crAngleDeg: number; +} - .axon-probe-row { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin: 0 0 16px; - } +interface ConfigCheckpoint { + id: string; + createdAt: string; + label: string; + modelId: string | null; + modelName: string | null; + draft: DraftConfig; +} - .axon-probe-row button { - border: 1px solid currentColor; - border-radius: 6px; - background: transparent; - padding: 8px 12px; - cursor: pointer; - font: inherit; - } +interface ProbeState { + environment: unknown | null; + inventory: ProbeInventory; + identify: ProbeIdentifyInfo | null; + config: ProbeConfigInfo | null; + lastKnownModelId: string | null; + lastKnownModelName: string | null; + draft: DraftConfig; + baselineRawBytes: number[] | null; + liveSnapshot: DraftConfig | null; + checkpoints: ConfigCheckpoint[]; + sim: SimState; + busyAction: string | null; + applyProgress: { + title: string; + detail: string; + percent: number; + } | null; + error: string | null; + logs: LogEntry[]; + statusMessage: string; + statusOpen: boolean; + loadPanelOpen: boolean; + recoveryConfirmOpen: boolean; + recoverySource: "bundled" | "file"; + recoveryBundledModelId: string; + recoveryBundledMode: ServoMode; + recoveryFile: ProbeFirmwareFile | null; + recoveryFileModelId: string | null; + recoveryFileModelName: string | null; + hasAuthorizedAccess: boolean; + diagnosticsOpen: boolean; +} - .axon-probe-row button:disabled { - cursor: not-allowed; - opacity: 0.5; - } +type EmptyStateKind = "adapter" | "servo" | null; - .axon-probe-grid { - display: grid; - gap: 16px; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - margin: 0 0 16px; - } +interface CurvePoint { + torqueKgcm: number; + speedRpm: number; + currentA: number; + mechanicalPowerW: number; + electricalPowerW: number; + heatW: number; + efficiency: number; +} - .axon-probe-panel { - border: 1px solid color-mix(in srgb, currentColor 22%, transparent); - border-radius: 6px; - padding: 12px; - } +interface OperatingPoint extends CurvePoint { + pwmUs: number; + hornAngleDeg: number; + minAngleDeg: number; + maxAngleDeg: number; + effectiveVoltage: number; + commandMagnitude: number; + commandSigned: number; +} - .axon-probe-panel pre { - overflow: auto; - padding: 12px; - border: 1px solid color-mix(in srgb, currentColor 22%, transparent); - border-radius: 6px; - margin: 0; - } +interface ChartPoint { + x: number; + y: number; +} - code { - font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, "Liberation Mono", monospace; - } - `; +interface TooltipRow { + label: string; + si: string; + imp: string; + servo: string; +} + +interface TooltipTableData { + rows: TooltipRow[]; +} + +interface ProbeCharts { + speedCurrent: Chart<"line", ChartPoint[]>; + power: Chart<"line", ChartPoint[]>; + efficiency: Chart<"line", ChartPoint[]>; +} + +const CHECKPOINT_STORAGE_KEY = "axon-config-checkpoints-v1"; +const MAX_CHECKPOINTS = 12; +const RESIZE_POLL_QUIET_MS = 600; +const FIXED_LEFT_AXIS_WIDTH = 86; +const FIXED_RIGHT_AXIS_WIDTH = 74; +const RECOVERY_MODELS = Array.from(loadCatalog().models.values()) + .filter((model) => model.bundled_firmware.standard || model.bundled_firmware.continuous) + .sort((left, right) => left.name.localeCompare(right.name)); +const HELP_TEXT = { + range: + "Sets the servo travel limit. 100% uses the model's full programmed range; lower values narrow the green-to-red sweep.", + sensitivity: + "PWM dead-band around neutral. Lower step means smaller dead-band and higher sensitivity. Step 0 is the most sensitive setting.", + center: + "Offsets the neutral position in microseconds from the nominal 1500 us center pulse. Use this to trim the mechanical center.", + stopTrim: + "Offsets the stop point in microseconds from the nominal 1500 us pulse in CR mode. Use this to stop rotation cleanly.", + direction: + "Reverses the commanded direction. Vendor default is CCW for normal; reversed flips the response.", + ramp: "Limits acceleration on startup to reduce sudden motion.", + dampening: + "Internal PID D coefficient in Servo mode. Higher values add more braking near the target and can reduce overshoot.", + signalLoss: + "What the servo should do if PWM signal is lost while power remains. Release removes drive, Hold keeps the last commanded position, Neutral moves to center.", + overload: + "Three-stage stall protection in Servo mode. When the servo stalls, it reduces available power to each stage's percentage after that stage's delay.", + proptl: + "Continuous-rotation overheat timeout in seconds. After continuous operation for this long, the servo cuts output to protect the motor.", + powerLimit: + "Caps maximum output power as a percentage of full drive. Useful for limiting current draw or softening the servo.", + pwmServo: + "Simulated command pulse width in Servo mode. Sweep/manual motion changes the horn position, but not the Servo-mode operating-point physics.", + pwmCr: + "Simulated CR drive command. 1500 us is stop; farther from center increases drive in the selected direction.", + voltage: + "Supply voltage used for the operating curves. Detents match the published servo spec voltages, but you can set values between them.", + load: "Mechanical output load in kgf*cm. Higher load moves the operating point toward stall torque.", + diagnostics: "Show decoded state, transport details, and debug logs.", +} as const; + +function clamp(value: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, value)); +} - document.head.appendChild(style); +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; } -function mustQuery(root: ParentNode, selector: string): T { - const element = root.querySelector(selector); - if (!element) { - throw new Error(`Axon probe UI missing required selector ${selector}`); +function interpolatePoint(points: VoltagePoint[], voltage: number): VoltagePoint { + const sorted = [...points].sort((a, b) => a.voltage - b.voltage); + if (voltage <= sorted[0].voltage) return sorted[0]; + if (voltage >= sorted[sorted.length - 1].voltage) return sorted[sorted.length - 1]; + + for (let index = 0; index < sorted.length - 1; index++) { + const left = sorted[index]; + const right = sorted[index + 1]; + if (voltage >= left.voltage && voltage <= right.voltage) { + const t = (voltage - left.voltage) / (right.voltage - left.voltage); + return { + voltage, + torqueKgcm: lerp(left.torqueKgcm, right.torqueKgcm, t), + speedSec60: lerp(left.speedSec60, right.speedSec60, t), + idleA: lerp(left.idleA, right.idleA, t), + stallA: lerp(left.stallA, right.stallA, t), + }; + } } - return element; + + return sorted[0]; +} + +function rpmFromSec60(speedSec60: number): number { + if (speedSec60 <= 0) return 0; + return 10 / speedSec60; +} + +function kgcmToNm(value: number): number { + return value * 0.0980665; +} + +function kgcmToOzIn(value: number): number { + return value * 13.887; +} + +function rpmToRadPerSec(value: number): number { + return (value * 2 * Math.PI) / 60; +} + +function rpmToSecPer60Deg(value: number): number { + if (value <= 0.001) return Number.POSITIVE_INFINITY; + return 10 / value; +} + +function escapeHtml(value: unknown): string { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); } -function toJson(value: unknown): string { - if (typeof value === "string") { - return value; +function nowLabel(): string { + const now = new Date(); + const core = now.toLocaleTimeString([], { + hour: "numeric", + minute: "2-digit", + second: "2-digit", + }); + return `${core}.${now.getMilliseconds().toString().padStart(3, "0")}`; +} + +function formatRelativeTime(iso: string): string { + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return "Unknown"; + const deltaSec = Math.round((then - Date.now()) / 1000); + const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); + if (Math.abs(deltaSec) >= 86400) { + return rtf.format(Math.round(deltaSec / 86400), "day"); } - return JSON.stringify(value, null, 2); + if (Math.abs(deltaSec) >= 3600) { + return rtf.format(Math.round(deltaSec / 3600), "hour"); + } + if (Math.abs(deltaSec) >= 60) { + return rtf.format(Math.round(deltaSec / 60), "minute"); + } + return rtf.format(deltaSec, "second"); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +function lossLabel(loss: LossBehavior): string { + if (loss === "release") return "Release"; + if (loss === "hold") return "Hold"; + return "Neutral"; +} + +function directionLabel(direction: Direction): string { + return direction === "cw" ? "CW" : "CCW"; +} + +function sensitivityLabel(step: number): SensitivityLabel { + if (step <= 1) return "Ultra High"; + if (step <= 4) return "High"; + if (step <= 8) return "Medium"; + if (step <= 11) return "Low"; + return "Very Low"; +} + +function shortModelLabel(value: string | null): string { + if (!value || value.length === 0) return "--"; + return value.replace(/^Axon\s+/i, ""); } -function summarizeInventory(inventory: ProbeInventory): object { +function servoIdentityLabel(config: ProbeConfigInfo | null, servoPresent: boolean): string { + const modelName = shortModelLabel(config?.modelName ?? null); + if (modelName !== "--") return modelName; + + const modelId = (config?.modelId ?? "").trim(); + if (modelId.length > 0) return modelId; + + return servoPresent ? "Detected" : "Not Found"; +} + +function baseDraft(): DraftConfig { return { - openedId: inventory.openedId, - visibleCount: inventory.devices.length, - devices: inventory.devices, + mode: "servo_mode", + rangePercent: 50, + neutralUs: 0, + direction: "cw", + pwmLossBehavior: "hold", + softStart: true, + sensitivityStep: 4, + dampeningFactor: 166, + overloadProtectionEnabled: true, + overloadLevels: [ + { pct: 55, sec: 4.0 }, + { pct: 75, sec: 2.0 }, + { pct: 100, sec: 0.8 }, + ], + pwmPowerPercent: 85, + proptlSeconds: 25.5, }; } -function formatError(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - return String(error); -} - -export function mountProbeApp(options: ProbeUiOptions): void { - const document = options.root.ownerDocument; - installStyles(document); - - options.root.innerHTML = ` -
-

${options.title}

-

${options.description}

-
    ${options.bullets.map((bullet) => `
  • ${bullet}
  • `).join("")}
-
-
-
-

Environment

-
Loading runtime info...
-
-
-

${options.devicePanelTitle}

-
${options.emptyDeviceText}
-
-
-

Latest Reply

-
No traffic yet.
-
-
-
-

Log

-

-      
-
- `; +function baseSim(config: DraftConfig = baseDraft()): SimState { + return { + pwmUs: config.mode === "cr_mode" ? 2000 : 1500, + voltage: 6, + loadKgcm: + config.mode === "cr_mode" + ? 0 + : peakEfficiencyLoadKgcm(AXON_MINI_SPEC, 6, config.pwmPowerPercent), + sweepEnabled: config.mode === "servo_mode", + playbackRunning: true, + servoSweepPhase: 0, + crAngleDeg: 0, + }; +} + +function cloneDraftConfig(draft: DraftConfig): DraftConfig { + return { + ...draft, + overloadLevels: draft.overloadLevels.map((level) => ({ + ...level, + })) as DraftConfig["overloadLevels"], + }; +} - const actionsEl = mustQuery(options.root, '[data-role="actions"]'); - const environmentEl = mustQuery(options.root, '[data-role="environment"]'); - const devicesEl = mustQuery(options.root, '[data-role="devices"]'); - const latestEl = mustQuery(options.root, '[data-role="latest"]'); - const logEl = mustQuery(options.root, '[data-role="log"]'); +function draftKey(draft: DraftConfig | null): string | null { + if (!draft) return null; + return JSON.stringify(draft); +} - const buttons = new Map(); - let inventory: ProbeInventory = { devices: [], openedId: null }; +function isDirty(baseline: DraftConfig | null, draft: DraftConfig): boolean { + return draftKey(baseline) !== null && draftKey(baseline) !== draftKey(draft); +} - function log(message: string) { - const timestamp = new Date().toLocaleTimeString(); - logEl.textContent += `[${timestamp}] ${message}\n`; - logEl.scrollTop = logEl.scrollHeight; +function sameBytes(left: Uint8Array, right: Uint8Array): boolean { + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index++) { + if (left[index] !== right[index]) return false; } + return true; +} - function renderInventory(next: ProbeInventory) { - inventory = next; - devicesEl.textContent = - next.devices.length === 0 ? options.emptyDeviceText : toJson(summarizeInventory(next)); - updateButtons(); +function readStoredCheckpoints(): ConfigCheckpoint[] { + try { + const raw = window.localStorage.getItem(CHECKPOINT_STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed + .filter((value) => value && typeof value === "object" && typeof value.id === "string") + .map((value) => ({ + id: String(value.id), + createdAt: String(value.createdAt ?? ""), + label: String(value.label ?? "checkpoint"), + modelId: typeof value.modelId === "string" ? value.modelId : null, + modelName: typeof value.modelName === "string" ? value.modelName : null, + draft: cloneDraftConfig(value.draft as DraftConfig), + })) + .slice(0, MAX_CHECKPOINTS); + } catch { + return []; } +} + +function writeStoredCheckpoints(checkpoints: ConfigCheckpoint[]): void { + try { + window.localStorage.setItem(CHECKPOINT_STORAGE_KEY, JSON.stringify(checkpoints)); + } catch {} +} + +function checkpointLabel(config: ProbeConfigInfo, createdAt: string): string { + const model = shortModelLabel(config.modelName ?? config.modelId ?? "servo").toLowerCase(); + return `${model}-${createdAt.replaceAll(":", "-")}`; +} + +function parseRecoveryFirmwareFile(file: ProbeFirmwareFile): { + modelId: string; + modelName: string | null; +} { + const decrypted = decryptSfw(Buffer.from(file.bytes)); + const modelId = decrypted.header.modelId; + const model = findModel(loadCatalog(), modelId); + return { + modelId, + modelName: model?.name ?? null, + }; +} - function renderLatest(value: unknown) { - latestEl.textContent = toJson(value); +function recoveryTargetFromState( + state: ProbeState, +): { modelId: string; modelName: string | null } | null { + if (state.lastKnownModelId) { + return { + modelId: state.lastKnownModelId, + modelName: state.lastKnownModelName, + }; } + const checkpoint = state.checkpoints.find((entry) => Boolean(entry.modelId)); + if (!checkpoint?.modelId) return null; + return { + modelId: checkpoint.modelId, + modelName: checkpoint.modelName, + }; +} + +function modeFromConfig(config: ProbeConfigInfo | null): ServoMode { + return config?.mode === "cr_mode" ? "cr_mode" : "servo_mode"; +} - function updateButtons() { - const hasDevice = inventory.devices.length > 0; - const isOpen = inventory.openedId !== null; +function normalizeOverloadLevel( + value: Partial | undefined, + fallback: OverloadLevel, +): OverloadLevel { + return { + pct: clamp(Number(value?.pct ?? fallback.pct), 10, 100), + sec: clamp(Number(value?.sec ?? fallback.sec), 0, 25.5), + }; +} + +function normalizeOverloadLevels( + values: Array> | undefined, + fallback: DraftConfig["overloadLevels"], +): DraftConfig["overloadLevels"] { + return [ + normalizeOverloadLevel(values?.[0], fallback[0]), + normalizeOverloadLevel(values?.[1], fallback[1]), + normalizeOverloadLevel(values?.[2], fallback[2]), + ]; +} + +function draftFromConfig(config: ProbeConfigInfo | null, current: DraftConfig): DraftConfig { + if (!config?.setup) return current; + return { + ...current, + mode: modeFromConfig(config), + rangePercent: clamp(config.setup.rangePercent ?? current.rangePercent, 10, 100), + neutralUs: clamp(config.setup.neutralUs ?? current.neutralUs, -127, 127), + direction: config.setup.inversion === "reversed" ? "ccw" : "cw", + pwmLossBehavior: + config.setup.pwmLossBehavior === "hold" || config.setup.pwmLossBehavior === "neutral" + ? config.setup.pwmLossBehavior + : "release", + softStart: config.setup.softStart ?? current.softStart, + sensitivityStep: clamp(config.setup.sensitivityStep ?? current.sensitivityStep, 0, 14), + dampeningFactor: clamp(config.setup.dampeningFactor ?? current.dampeningFactor, 0, 65535), + overloadProtectionEnabled: + config.setup.overloadProtectionEnabled ?? current.overloadProtectionEnabled, + overloadLevels: normalizeOverloadLevels(config.setup.overloadLevels, current.overloadLevels), + pwmPowerPercent: clamp(config.setup.pwmPowerPercent ?? current.pwmPowerPercent, 0, 100), + proptlSeconds: clamp(config.setup.proptlSeconds ?? current.proptlSeconds, 0, 25.5), + }; +} + +function draftFromPartial(configDraft: Partial, current: DraftConfig): DraftConfig { + return { + ...current, + ...configDraft, + mode: configDraft.mode === "cr_mode" ? "cr_mode" : "servo_mode", + direction: configDraft.direction === "ccw" ? "ccw" : "cw", + pwmLossBehavior: + configDraft.pwmLossBehavior === "hold" || configDraft.pwmLossBehavior === "neutral" + ? configDraft.pwmLossBehavior + : "release", + rangePercent: clamp(Number(configDraft.rangePercent ?? current.rangePercent), 10, 100), + neutralUs: clamp(Number(configDraft.neutralUs ?? current.neutralUs), -127, 127), + sensitivityStep: clamp(Number(configDraft.sensitivityStep ?? current.sensitivityStep), 0, 14), + dampeningFactor: clamp( + Number(configDraft.dampeningFactor ?? current.dampeningFactor), + 0, + 65535, + ), + overloadProtectionEnabled: Boolean( + configDraft.overloadProtectionEnabled ?? current.overloadProtectionEnabled, + ), + overloadLevels: normalizeOverloadLevels( + Array.isArray(configDraft.overloadLevels) ? configDraft.overloadLevels : undefined, + current.overloadLevels, + ), + pwmPowerPercent: clamp(Number(configDraft.pwmPowerPercent ?? current.pwmPowerPercent), 0, 100), + proptlSeconds: clamp(Number(configDraft.proptlSeconds ?? current.proptlSeconds), 0, 25.5), + }; +} + +function encodeRangeRaw(percent: number): number { + return clamp(Math.round((clamp(percent, 10, 100) * 255) / 100), 0, 255); +} + +function encodeNeutralRaw(us: number): number { + return clamp(Math.round(clamp(us, -127, 127) + 128), 1, 255); +} + +function encodeSensitivityRaw(step: number): number { + return (clamp(Math.round(step), 0, 14) + 1) * 16; +} + +function encodePercentRaw(percent: number): number { + return clamp(Math.round((clamp(percent, 0, 100) * 255) / 100), 0, 255); +} + +function encodeSecondsTenthRaw(seconds: number): number { + return clamp(Math.round(clamp(seconds, 0, 25.5) / 0.1), 0, 255); +} + +function setBeU16(bytes: Uint8Array, offset: number, value: number): void { + const clamped = clamp(Math.round(value), 0, 65535); + bytes[offset] = (clamped >> 8) & 0xff; + bytes[offset + 1] = clamped & 0xff; +} + +function encodeDraftOntoRawConfig(source: Uint8Array, draft: DraftConfig): Uint8Array { + const bytes = Uint8Array.from(source); + const rangeRaw = encodeRangeRaw(draft.rangePercent); + bytes[0x04] = rangeRaw; + bytes[0x05] = rangeRaw; + bytes[0x06] = encodeNeutralRaw(draft.neutralUs); + bytes[0x0c] = encodeSensitivityRaw(draft.sensitivityStep); + + setBeU16(bytes, 0x0a, draft.dampeningFactor); + setBeU16(bytes, 0x27, draft.dampeningFactor); + setBeU16(bytes, 0x29, draft.dampeningFactor); + setBeU16(bytes, 0x2b, draft.dampeningFactor); + + const flags = bytes[0x25] ?? 0; + const directionBits = draft.direction === "ccw" ? 0x02 : 0x00; + const pwmLossBits = + draft.pwmLossBehavior === "hold" ? 0x40 : draft.pwmLossBehavior === "neutral" ? 0x60 : 0x00; + const softStartBits = draft.softStart ? 0x10 : 0x00; + const overloadBits = draft.overloadProtectionEnabled ? 0x80 : 0x00; + bytes[0x25] = (flags & ~0xf2) | directionBits | pwmLossBits | softStartBits | overloadBits; - buttons.get("open")?.toggleAttribute("disabled", !hasDevice || isOpen); - buttons.get("identify")?.toggleAttribute("disabled", !isOpen); - buttons.get("read")?.toggleAttribute("disabled", !isOpen); - buttons.get("close")?.toggleAttribute("disabled", !isOpen); + draft.overloadLevels.forEach((level, index) => { + bytes[0x35 + index * 2] = encodePercentRaw(level.pct); + bytes[0x36 + index * 2] = encodeSecondsTenthRaw(level.sec); + }); + + const pwmPowerPrimary = encodePercentRaw(draft.pwmPowerPercent); + bytes[0x11] = pwmPowerPrimary; + bytes[0x12] = pwmPowerPrimary; + bytes[0x13] = pwmPowerPrimary; + bytes[0x0f] = Math.max(0, pwmPowerPrimary - 20); + + if (draft.mode === "cr_mode") { + bytes[0x36] = encodeSecondsTenthRaw(draft.proptlSeconds); } - function addButton(key: string, label: string, onClick: () => Promise) { - const button = document.createElement("button"); - button.type = "button"; - button.textContent = label; - button.addEventListener("click", () => { - void onClick(); - }); - actionsEl.appendChild(button); - buttons.set(key, button); + return bytes; +} + +function configInfoFromRawBytes(rawBytes: Uint8Array, mode: CoreServoMode): ProbeConfigInfo { + if (rawBytes.length !== CONFIG_BLOCK_SIZE) { + throw new Error(`Expected ${CONFIG_BLOCK_SIZE} bytes, got ${rawBytes.length}.`); } - async function runAction( - action: ProbeUiAction, - onSuccess: (data: T) => void, - successMessage: string, - ) { - try { - const data = await action.run(); - onSuccess(data); - log(successMessage); - } catch (error) { - renderLatest({ error: formatError(error) }); - log(`error: ${formatError(error)}`); + const modelId = modelIdFromConfig(rawBytes); + const model = findModel(loadCatalog(), modelId); + const chunkSplit = 59; + + const hexDump = (bytes: Uint8Array): string => + Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(" "); + + return { + length: rawBytes.length, + modelId, + known: Boolean(model), + modelName: model?.name ?? null, + docsUrl: model?.docs_url ?? null, + mode, + modeLabel: servoModeLabel(mode), + setup: summarizeConfig(rawBytes, modelId), + rawHex: hexDump(rawBytes), + firstChunk: hexDump(rawBytes.subarray(0, chunkSplit)), + secondChunk: hexDump(rawBytes.subarray(chunkSplit)), + rawBytes: Array.from(rawBytes), + }; +} + +function lookupSpec( + modelId: string | null, + modelName: string | null, + config: ProbeConfigInfo | null, +): ServoSpec { + return lookupServoSpec(modelId, modelName, config?.setup?.rangeDegrees ?? null); +} + +function peakEfficiencyLoadKgcm(spec: ServoSpec, voltage: number, pwmPowerPercent = 85): number { + const base = interpolatePoint(spec.points, voltage); + const torqueMax = Math.max(0.1, base.torqueKgcm * clamp(pwmPowerPercent / 100, 0.2, 1)); + const noLoadRpm = rpmFromSec60(base.speedSec60); + let bestTorque = 0; + let bestEfficiency = -1; + + for (let index = 0; index <= 48; index++) { + const sampleTorque = (torqueMax * index) / 48; + const sampleRatio = clamp(sampleTorque / torqueMax, 0, 1); + const sampleCurrentA = base.idleA + (base.stallA - base.idleA) * sampleRatio; + const sampleSpeedRpm = Math.max(0, noLoadRpm * (1 - sampleRatio)); + const sampleMechanicalPowerW = kgcmToNm(sampleTorque) * ((sampleSpeedRpm * 2 * Math.PI) / 60); + const sampleElectricalPowerW = voltage * sampleCurrentA; + const sampleEfficiency = + sampleElectricalPowerW > 0 ? sampleMechanicalPowerW / sampleElectricalPowerW : 0; + + if (sampleEfficiency > bestEfficiency) { + bestEfficiency = sampleEfficiency; + bestTorque = sampleTorque; } } - if (options.requestDevice) { - const requestDevice = options.requestDevice; - addButton("request", options.requestDevice.label, async () => { - await runAction( - requestDevice, - (data) => { - renderInventory(data); - renderLatest(summarizeInventory(data)); - }, - "device request completed", - ); - }); + return Number(bestTorque.toFixed(1)); +} + +function maxAvailableTorqueKgcm(spec: ServoSpec, voltage: number, pwmPowerPercent = 85): number { + const base = interpolatePoint(spec.points, voltage); + return Math.max(0, base.torqueKgcm * clamp(pwmPowerPercent / 100, 0, 1)); +} + +function syncSimToDraft( + draft: DraftConfig, + sim: SimState, + spec: ServoSpec, + previousMode: ServoMode | null, +): SimState { + const next = { + ...sim, + loadKgcm: clamp( + sim.loadKgcm, + 0, + maxAvailableTorqueKgcm(spec, sim.voltage, draft.pwmPowerPercent), + ), + }; + + if (draft.mode === "servo_mode") { + if (previousMode !== "servo_mode") { + next.sweepEnabled = true; + next.playbackRunning = true; + next.servoSweepPhase = 0; + next.crAngleDeg = 0; + if (Math.abs(next.pwmUs - (1500 + draft.neutralUs)) < 50) { + next.pwmUs = 1500; + } + } + } else { + if (previousMode !== "cr_mode") { + next.sweepEnabled = false; + next.playbackRunning = true; + next.servoSweepPhase = 0; + next.crAngleDeg = 0; + next.loadKgcm = 0; + if (Math.abs(next.pwmUs - (1500 + draft.neutralUs)) < 50) { + next.pwmUs = 2000; + } + } } - if (options.reconnectDevice) { - const reconnectDevice = options.reconnectDevice; - addButton("reconnect", options.reconnectDevice.label, async () => { - await runAction( - reconnectDevice, - (data) => { - renderInventory(data); - renderLatest(summarizeInventory(data)); - }, - "authorized device lookup completed", - ); - }); + return next; +} + +function toPrettyJson(value: unknown): string { + return JSON.stringify(value, null, 2); +} + +function help(title: string): string { + return ``; +} + +function formatSignedUs(value: number): string { + if (value === 0) return "0"; + return `${value > 0 ? "+" : ""}${value}`; +} + +function triangleWave(phase: number): number { + const wrapped = phase % 2; + return wrapped <= 1 ? wrapped : 2 - wrapped; +} + +function scaleVoltagePoint(point: VoltagePoint, factor: number): VoltagePoint { + return { + voltage: point.voltage * factor, + torqueKgcm: point.torqueKgcm * factor, + speedSec60: factor <= 0.0001 ? Number.POSITIVE_INFINITY : point.speedSec60 / factor, + idleA: point.idleA * factor, + stallA: point.stallA * factor, + }; +} + +function effectiveServoPwm(draft: DraftConfig, sim: SimState): number { + if (draft.mode === "servo_mode" && sim.sweepEnabled) { + return Math.round(500 + 2000 * triangleWave(sim.servoSweepPhase)); } + return sim.pwmUs; +} - if (options.refreshInventory) { - const refreshInventory = options.refreshInventory; - addButton("refresh", options.refreshInventory.label, async () => { - await runAction( - refreshInventory, - (data) => { - renderInventory(data); - renderLatest(summarizeInventory(data)); - }, - "adapter inventory refreshed", - ); +function commandRatio(draft: DraftConfig, pwmUs: number): number { + const centeredPwm = 1500 + draft.neutralUs; + return clamp((pwmUs - centeredPwm) / 1000, -1, 1); +} + +function buildOperatingPoint( + config: ProbeConfigInfo | null, + draft: DraftConfig, + sim: SimState, +): { + spec: ServoSpec; + base: VoltagePoint; + effective: VoltagePoint; + point: OperatingPoint; + curves: CurvePoint[]; + torqueMax: number; + noLoadRpm: number; +} { + const spec = lookupSpec(config?.modelId ?? null, config?.modelName ?? null, config); + const base = interpolatePoint(spec.points, sim.voltage); + const currentPwmUs = effectiveServoPwm(draft, sim); + const servoCommand = commandRatio(draft, currentPwmUs); + const powerFactor = clamp(draft.pwmPowerPercent / 100, 0, 1); + const crDrive = commandRatio(draft, sim.pwmUs); + const driveFactor = draft.mode === "cr_mode" ? Math.abs(crDrive) : 1; + const signedCommand = + (draft.mode === "cr_mode" ? crDrive : servoCommand) * (draft.direction === "cw" ? 1 : -1); + const effective = scaleVoltagePoint(base, powerFactor * driveFactor); + const noLoadRpm = rpmFromSec60(effective.speedSec60); + const torqueMax = Math.max(0, effective.torqueKgcm); + const rangeDeg = (spec.maxRangeDeg * draft.rangePercent) / 100; + const minAngleDeg = -rangeDeg / 2; + const maxAngleDeg = rangeDeg / 2; + const commandMagnitude = Math.abs(signedCommand); + const torqueKgcm = clamp(sim.loadKgcm, 0, Math.max(0.001, torqueMax)); + const torqueRatio = torqueMax <= 0.0001 ? 0 : clamp(torqueKgcm / torqueMax, 0, 1); + const currentA = + torqueMax <= 0.0001 ? 0 : effective.idleA + (effective.stallA - effective.idleA) * torqueRatio; + const currentRpm = torqueMax <= 0.0001 ? 0 : Math.max(0, noLoadRpm * (1 - torqueRatio)); + const hornAngleDeg = + draft.mode === "servo_mode" + ? clamp(signedCommand * (rangeDeg / 2), minAngleDeg, maxAngleDeg) + : commandMagnitude > 0.01 + ? sim.crAngleDeg + : 0; + const mechanicalPowerW = kgcmToNm(torqueKgcm) * ((currentRpm * 2 * Math.PI) / 60); + const electricalPowerW = effective.voltage * currentA; + const heatW = Math.max(0, electricalPowerW - mechanicalPowerW); + const efficiency = electricalPowerW > 0 ? mechanicalPowerW / electricalPowerW : 0; + + const curves: CurvePoint[] = []; + for (let index = 0; index <= 48; index++) { + const sampleTorque = (torqueMax * index) / 48; + const sampleRatio = torqueMax <= 0.0001 ? 0 : clamp(sampleTorque / torqueMax, 0, 1); + const sampleCurrentA = + torqueMax <= 0.0001 + ? 0 + : effective.idleA + (effective.stallA - effective.idleA) * sampleRatio; + const sampleSpeedRpm = torqueMax <= 0.0001 ? 0 : Math.max(0, noLoadRpm * (1 - sampleRatio)); + const sampleMechanicalPowerW = kgcmToNm(sampleTorque) * ((sampleSpeedRpm * 2 * Math.PI) / 60); + const sampleElectricalPowerW = effective.voltage * sampleCurrentA; + const sampleHeatW = Math.max(0, sampleElectricalPowerW - sampleMechanicalPowerW); + const sampleEfficiency = + sampleElectricalPowerW > 0 ? sampleMechanicalPowerW / sampleElectricalPowerW : 0; + + curves.push({ + torqueKgcm: sampleTorque, + speedRpm: sampleSpeedRpm, + currentA: sampleCurrentA, + mechanicalPowerW: sampleMechanicalPowerW, + electricalPowerW: sampleElectricalPowerW, + heatW: sampleHeatW, + efficiency: sampleEfficiency, }); } - addButton("open", options.openDevice.label, async () => { - await runAction( - options.openDevice, - (data) => { - renderInventory(data); - renderLatest(summarizeInventory(data)); - }, - "adapter opened", - ); - }); + return { + spec, + base, + effective, + torqueMax, + noLoadRpm, + curves, + point: { + torqueKgcm, + speedRpm: currentRpm, + currentA, + mechanicalPowerW, + electricalPowerW, + heatW, + efficiency, + pwmUs: currentPwmUs, + hornAngleDeg, + minAngleDeg, + maxAngleDeg, + effectiveVoltage: effective.voltage, + commandMagnitude, + commandSigned: signedCommand, + }, + }; +} - addButton("identify", options.identifyServo.label, async () => { - await runAction( - options.identifyServo, - (data) => { - renderLatest(data); - }, - "identify completed", - ); - }); +function formatOperatingTooltip( + point: OperatingPoint, + kind: "speed" | "current" | "power" | "efficiency", +): TooltipTableData { + const rows: TooltipRow[] = [ + { + label: "Torque", + si: `${kgcmToNm(point.torqueKgcm).toFixed(2)} N*m`, + imp: `${kgcmToOzIn(point.torqueKgcm).toFixed(1)} oz*in`, + servo: `${point.torqueKgcm.toFixed(1)} kgf*cm`, + }, + { + label: "Speed", + si: `${rpmToRadPerSec(point.speedRpm).toFixed(1)} rad/s`, + imp: `${(point.speedRpm * 6).toFixed(0)} deg/s`, + servo: + point.speedRpm <= 0.001 + ? "stopped" + : `${rpmToSecPer60Deg(point.speedRpm).toFixed(2)} s/60deg`, + }, + ]; - addButton("read", options.readFullConfig.label, async () => { - await runAction( - options.readFullConfig, - (data) => { - renderLatest(data); + if (kind === "speed" || kind === "current") { + rows.push( + { + label: "Current", + si: `${point.currentA.toFixed(2)} A`, + imp: "—", + servo: "—", + }, + { + label: "Elec. Power", + si: `${point.electricalPowerW.toFixed(1)} W`, + imp: "—", + servo: "—", }, - "full config read completed", ); - }); + } - addButton("close", options.closeDevice.label, async () => { - await runAction( - options.closeDevice, - (data) => { - renderInventory(data); - renderLatest(summarizeInventory(data)); + if (kind === "power" || kind === "efficiency") { + rows.push( + { + label: "Mech. Power", + si: `${point.mechanicalPowerW.toFixed(1)} W`, + imp: "—", + servo: "—", + }, + { + label: "Elec. Power", + si: `${point.electricalPowerW.toFixed(1)} W`, + imp: "—", + servo: "—", + }, + { + label: "Heat", + si: `${point.heatW.toFixed(1)} W`, + imp: "—", + servo: "—", + }, + { + label: "Eff.", + si: `${(point.efficiency * 100).toFixed(0)} %`, + imp: "—", + servo: "—", }, - "adapter closed", ); - }); + } - updateButtons(); + return { rows }; +} - void (async () => { - try { - environmentEl.textContent = toJson(await options.loadEnvironment()); - log("runtime info loaded"); - } catch (error) { - environmentEl.textContent = toJson({ error: formatError(error) }); - log(`error: ${formatError(error)}`); - } - })(); +function curveData(values: CurvePoint[], select: (value: CurvePoint) => number): ChartPoint[] { + return values.map((value) => ({ + x: Number(value.torqueKgcm.toFixed(3)), + y: Number(select(value).toFixed(6)), + })); +} - void (async () => { - try { - renderInventory(await options.loadInventory()); - log("inventory loaded"); - } catch (error) { - devicesEl.textContent = toJson({ error: formatError(error) }); - log(`error: ${formatError(error)}`); - } - })(); +function fullSpecChartBounds( + spec: ServoSpec, + voltage: number, +): { + torqueMax: number; + speedMax: number; + currentMax: number; + powerMax: number; + efficiencyMax: number; +} { + const sample = interpolatePoint(spec.points, voltage); + const torqueMax = Math.max(0.1, sample.torqueKgcm); + const speedMax = Math.max(1, rpmFromSec60(sample.speedSec60)); + const currentMax = Math.max(0.1, sample.stallA); + const samplePowerMax = + (kgcmToNm(sample.torqueKgcm / 2) * ((rpmFromSec60(sample.speedSec60) / 2) * 2 * Math.PI)) / 60; + const sampleElectricalMax = sample.voltage * sample.stallA; + const powerMax = Math.max(0.1, samplePowerMax, sampleElectricalMax); + + return { + torqueMax, + speedMax, + currentMax, + powerMax, + efficiencyMax: 1, + }; +} + +function formatAxisTick( + value: string | number, + kind: "torque" | "speed" | "current" | "power" | "efficiency", +): string { + const numeric = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(numeric)) return ""; + if (Math.abs(numeric) < 0.000001) return "0"; + switch (kind) { + case "speed": + return String(Math.round(numeric)); + case "torque": + return Number(numeric.toFixed(1)).toFixed(1).replace(/\.0$/, ""); + case "current": + case "power": + case "efficiency": + return numeric.toFixed(1); + } +} + +function createLineDataset( + label: string, + color: string, + yAxisID: string, + data: ChartPoint[], + overrides: Partial> = {}, +): ChartDataset<"line", ChartPoint[]> { + return { + label, + data, + yAxisID, + parsing: false, + borderColor: color, + backgroundColor: color, + borderWidth: 2.5, + tension: 0.22, + pointRadius: 0, + pointHoverRadius: 0, + ...overrides, + }; +} + +function createPointDataset( + label: string, + color: string, + yAxisID: string, + point: ChartPoint, + tooltipData: TooltipTableData, +): ChartDataset<"line", ChartPoint[]> { + return { + label, + data: [point], + yAxisID, + parsing: false, + borderColor: color, + backgroundColor: "#8fc2f8", + pointBorderColor: "#2d78d0", + pointBackgroundColor: "#8fc2f8", + pointBorderWidth: 0, + pointRadius: 0, + pointHoverRadius: 0, + pointHitRadius: 12, + pointStyle: "circle", + showLine: false, + clip: false, + order: 99, + operatingMarker: true, + markerFill: "#8fc2f8", + markerStroke: "#2d78d0", + markerRadius: 6, + tooltipData, + } as ChartDataset<"line", ChartPoint[]>; +} + +function ensureTooltipElement(_chart: Chart<"line", ChartPoint[]>): HTMLDivElement { + let tooltipEl = document.body.querySelector(".chart-tooltip"); + if (!tooltipEl) { + tooltipEl = document.createElement("div"); + tooltipEl.className = "chart-tooltip"; + document.body.appendChild(tooltipEl); + } + + return tooltipEl; +} + +function ensureHelpTooltipElement(): HTMLDivElement { + let tooltipEl = document.body.querySelector(".help-tooltip"); + if (!tooltipEl) { + tooltipEl = document.createElement("div"); + tooltipEl.className = "help-tooltip"; + document.body.appendChild(tooltipEl); + } + return tooltipEl; +} + +function hideHelpTooltip(): void { + const tooltipEl = document.body.querySelector(".help-tooltip"); + if (!tooltipEl) return; + tooltipEl.style.opacity = "0"; + tooltipEl.style.pointerEvents = "none"; +} + +function showHelpTooltip(target: HTMLElement): void { + const text = target.dataset.help?.trim(); + if (!text) return; + const tooltipEl = ensureHelpTooltipElement(); + tooltipEl.textContent = text; + tooltipEl.style.opacity = "1"; + tooltipEl.style.pointerEvents = "none"; + tooltipEl.style.left = "0px"; + tooltipEl.style.top = "0px"; + + const targetRect = target.getBoundingClientRect(); + const tooltipRect = tooltipEl.getBoundingClientRect(); + const margin = 12; + const preferredLeft = targetRect.left + targetRect.width / 2 - tooltipRect.width / 2; + const preferredTop = targetRect.top - tooltipRect.height - 10; + const maxLeft = window.innerWidth - tooltipRect.width - margin; + const maxTop = window.innerHeight - tooltipRect.height - margin; + + const left = clamp(preferredLeft, margin, Math.max(margin, maxLeft)); + const top = clamp(preferredTop, margin, Math.max(margin, maxTop)); + + tooltipEl.style.left = `${left}px`; + tooltipEl.style.top = `${top}px`; +} + +function renderTooltipTable(data: TooltipTableData): string { + return ` + + + + + + + + + + + ${data.rows + .map( + (row) => ` + + + + + + + `, + ) + .join("")} + +
SIImpServo
${escapeHtml(row.label)}${escapeHtml(row.si)}${escapeHtml(row.imp)}${escapeHtml(row.servo)}
+ `; +} + +function externalTooltipHandler(context: { + chart: Chart<"line", ChartPoint[]>; + tooltip: TooltipModel<"line">; +}): void { + const { chart, tooltip } = context; + const tooltipEl = ensureTooltipElement(chart); + + if (tooltip.opacity === 0 || tooltip.dataPoints.length === 0) { + tooltipEl.style.opacity = "0"; + tooltipEl.style.pointerEvents = "none"; + return; + } + + const dataset = tooltip.dataPoints[0].dataset as ChartDataset<"line", ChartPoint[]> & { + tooltipData?: TooltipTableData; + }; + + if (!dataset.tooltipData) { + tooltipEl.style.opacity = "0"; + tooltipEl.style.pointerEvents = "none"; + return; + } + + tooltipEl.innerHTML = renderTooltipTable(dataset.tooltipData); + tooltipEl.style.opacity = "1"; + tooltipEl.style.pointerEvents = "none"; + tooltipEl.style.left = "0px"; + tooltipEl.style.top = "0px"; + + const canvasRect = chart.canvas.getBoundingClientRect(); + const margin = 12; + const tooltipRect = tooltipEl.getBoundingClientRect(); + const preferredLeft = canvasRect.left + tooltip.caretX - tooltipRect.width / 2; + const preferredTop = canvasRect.top + tooltip.caretY - tooltipRect.height - 12; + const maxLeft = window.innerWidth - tooltipRect.width - margin; + const maxTop = window.innerHeight - tooltipRect.height - margin; + + const left = clamp(preferredLeft, margin, Math.max(margin, maxLeft)); + const top = clamp(preferredTop, margin, Math.max(margin, maxTop)); + + tooltipEl.style.left = `${left}px`; + tooltipEl.style.top = `${top}px`; +} + +function baseChartOptions( + xMax: number, + xTitle: string, + yMax: number, + yTitle: string, + yKind: "speed" | "power" | "efficiency", + y1Max?: number, + y1Title?: string, + hideSecondaryAxis = false, +): ChartOptions<"line"> { + return { + animation: false, + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: "nearest", + intersect: true, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + external: externalTooltipHandler, + }, + }, + scales: { + x: { + type: "linear", + min: 0, + max: xMax, + grid: { + color: "#e8edf3", + }, + ticks: { + color: "#9aa3af", + maxTicksLimit: 5, + callback(value) { + return formatAxisTick(value, "torque"); + }, + }, + title: { + display: true, + text: xTitle, + color: "#9aa3af", + }, + }, + y: { + type: "linear", + min: 0, + max: yMax, + afterFit(scale) { + scale.width = FIXED_LEFT_AXIS_WIDTH; + }, + grid: { + color: "#edf1f5", + }, + ticks: { + color: "#9aa3af", + maxTicksLimit: 4, + callback(value) { + return formatAxisTick(value, yKind); + }, + }, + title: { + display: true, + text: yTitle, + color: "#9aa3af", + }, + }, + ...(y1Max && y1Title + ? { + y1: { + type: "linear", + position: "right", + min: 0, + max: y1Max, + afterFit(scale) { + scale.width = FIXED_RIGHT_AXIS_WIDTH; + }, + grid: { + drawOnChartArea: false, + }, + ticks: { + color: hideSecondaryAxis ? "rgba(0,0,0,0)" : "#9aa3af", + maxTicksLimit: 4, + callback(value) { + return formatAxisTick(value, "current"); + }, + }, + title: { + display: true, + text: y1Title, + color: hideSecondaryAxis ? "rgba(0,0,0,0)" : "#9aa3af", + }, + }, + } + : {}), + }, + }; +} + +function syncChartGeometry(canvas: HTMLCanvasElement, chart: Chart<"line">): void { + const area = chart.chartArea; + if (!area) { + return; + } + const xTitle = chart.options.scales?.x?.title?.text; + canvas.dataset.xTitle = typeof xTitle === "string" ? xTitle : ""; + canvas.dataset.plotLeft = area.left.toFixed(1); + canvas.dataset.plotRight = area.right.toFixed(1); + canvas.dataset.plotTop = area.top.toFixed(1); + canvas.dataset.plotBottom = area.bottom.toFixed(1); +} + +function simChartsMarkup(): string { + return ` +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ `; +} + +function sweepControlIcon(enabled: boolean): string { + return enabled + ? ` + + ` + : ` + + `; +} + +function servoSketch( + spec: ServoSpec, + point: OperatingPoint, + draft: DraftConfig, + torqueMax: number, +): string { + const width = 430; + const height = 390; + const bodyX = 168; + const bodyY = 118; + const axisX = bodyX + spec.bodyWidth / 2; + const axisY = bodyY + spec.axisInsetY; + const armLength = Math.round(spec.bodyWidth * 0.78); + const sweepRadius = armLength + 14; + const arrowRadius = sweepRadius + 34; + const arcStartDeg = -74; + const arcEndDeg = 74; + const pointAt = (degrees: number, radius: number): { x: number; y: number } => ({ + x: axisX + Math.sin((degrees * Math.PI) / 180) * radius, + y: axisY - Math.cos((degrees * Math.PI) / 180) * radius, + }); + const arrowStart = pointAt(draft.direction === "cw" ? arcStartDeg : arcEndDeg, arrowRadius); + const arrowEnd = pointAt(draft.direction === "cw" ? arcEndDeg : arcStartDeg, arrowRadius); + const directionArc = `M ${arrowStart.x.toFixed(1)} ${arrowStart.y.toFixed(1)} A ${arrowRadius} ${arrowRadius} 0 0 ${draft.direction === "cw" ? "1" : "0"} ${arrowEnd.x.toFixed(1)} ${arrowEnd.y.toFixed(1)}`; + const markerRadius = arrowRadius; + const minLineEndX = axisX + Math.sin((point.minAngleDeg * Math.PI) / 180) * markerRadius; + const minLineEndY = axisY - Math.cos((point.minAngleDeg * Math.PI) / 180) * markerRadius; + const maxLineEndX = axisX + Math.sin((point.maxAngleDeg * Math.PI) / 180) * markerRadius; + const maxLineEndY = axisY - Math.cos((point.maxAngleDeg * Math.PI) / 180) * markerRadius; + const hornEndX = axisX + Math.sin((point.hornAngleDeg * Math.PI) / 180) * armLength; + const hornEndY = axisY - Math.cos((point.hornAngleDeg * Math.PI) / 180) * armLength; + const readout = + draft.mode === "servo_mode" + ? `${point.hornAngleDeg.toFixed(0)} deg` + : `${point.speedRpm.toFixed(0)} rpm`; + const rpmBadge = + draft.mode === "cr_mode" + ? { + label: readout, + fill: "#e4f0ff", + stroke: "#2d78d0", + text: "#1f5fa8", + } + : null; + const loadRatio = torqueMax <= 0.0001 ? 0 : clamp(point.torqueKgcm / torqueMax, 0, 1); + const loadBadge = + draft.mode === "cr_mode" + ? loadRatio >= 0.95 + ? { + label: "stalled", + fill: "#fbe4e6", + stroke: "#c81e1e", + text: "#a61b1b", + } + : loadRatio >= 0.85 + ? { + label: "near stall", + fill: "#fff6d8", + stroke: "#d4a514", + text: "#8f6a00", + } + : loadRatio <= 0.01 + ? { + label: "no load", + fill: "#e4f0ff", + stroke: "#2d78d0", + text: "#1f5fa8", + } + : { + label: "loaded", + fill: "#e4f0ff", + stroke: "#2d78d0", + text: "#1f5fa8", + } + : null; + const bodyBottomY = bodyY + spec.bodyHeight; + const badgeGap = 10; + const badgeHeight = 24; + const rpmBadgeWidth = rpmBadge ? Math.max(86, 18 + rpmBadge.label.length * 7.4) : 0; + const loadBadgeWidth = loadBadge ? Math.max(72, 18 + loadBadge.label.length * 7.2) : 0; + const loadBadgeX = axisX - loadBadgeWidth / 2; + const rpmBadgeX = axisX - rpmBadgeWidth / 2; + const loadBadgeY = bodyBottomY - badgeGap - badgeHeight; + const rpmBadgeY = + loadBadge && rpmBadge + ? loadBadgeY - badgeGap - badgeHeight + : bodyBottomY - badgeGap - badgeHeight; + const centerColor = + draft.mode === "cr_mode" + ? point.commandMagnitude > 0.01 + ? point.commandSigned >= 0 + ? "#25a244" + : "#ff3a38" + : "#6b7280" + : draft.direction === "cw" + ? "#25a244" + : "#ff3a38"; + + return ` + + + + + + + + + + ${ + draft.mode === "servo_mode" + ? ` + + + ` + : "" + } + + + ${ + draft.mode === "servo_mode" + ? `${readout}` + : "" + } + ${ + rpmBadge + ? ` + + ${rpmBadge.label} + ` + : "" + } + ${ + loadBadge + ? ` + + ${loadBadge.label} + ` + : "" + } + + `; +} + +export function mountProbeApp(options: MountProbeAppOptions): void { + const initialDraft = baseDraft(); + const state: ProbeState = { + environment: null, + inventory: { devices: [], openedId: null }, + identify: null, + config: null, + lastKnownModelId: null, + lastKnownModelName: null, + draft: initialDraft, + baselineRawBytes: null, + liveSnapshot: null, + checkpoints: readStoredCheckpoints(), + sim: baseSim(initialDraft), + busyAction: null, + applyProgress: null, + error: null, + logs: [], + statusMessage: "Starting", + statusOpen: false, + loadPanelOpen: false, + recoveryConfirmOpen: false, + recoverySource: "bundled", + recoveryBundledModelId: "", + recoveryBundledMode: "servo_mode", + recoveryFile: null, + recoveryFileModelId: null, + recoveryFileModelName: null, + hasAuthorizedAccess: options.initialAuthorizedAccess ?? false, + diagnosticsOpen: false, + }; + + let sweepFrame: number | null = null; + let lastSweepFrameTs: number | null = null; + let livePollTimer: ReturnType | null = null; + let _transportLogCleanup: undefined | (() => void); + let _statusLogCleanup: undefined | (() => void); + let liveProbeInFlight = false; + let missingServoStreak = 0; + let probeFailureStreak = 0; + let lastSilentError: string | null = null; + let charts: ProbeCharts | null = null; + let resizeQuietUntil = 0; + let resizeEndTimer: ReturnType | null = null; + let statusLogScrollTop = 0; + let statusLogPinnedToBottom = true; + const logger = { + push(level: "status" | "debug", message: string): void { + state.logs.push({ timestamp: nowLabel(), message, level }); + if (state.logs.length > 64) { + state.logs = state.logs.slice(-64); + } + }, + status(message: string): void { + this.push("status", message); + }, + debug(message: string): void { + if (!options.debugEnabled) return; + this.push("debug", message); + }, + snapshot(): string { + return [...state.logs] + .map( + (entry) => + `[${entry.timestamp}]${entry.level === "debug" ? "[debug]" : ""} ${entry.message}`, + ) + .join("\n"); + }, + }; + + function setStatus(message: string, shouldLog = true): void { + if (state.statusMessage === message) return; + state.statusMessage = message; + if (shouldLog) { + logger.status(message); + } + } + + function idleStatus(): string { + if (state.inventory.openedId === null) { + return state.hasAuthorizedAccess ? "Waiting for adapter" : "Request USB permission"; + } + if (!(state.identify?.present ?? Boolean(state.config))) { + return "Checking for servo"; + } + return "Ready"; + } + + function setIdleStatus(shouldLog = true): void { + setStatus(idleStatus(), shouldLog); + } + + function reportError(error: unknown, statusMessage = "Error"): string { + const message = error instanceof Error ? error.message : String(error); + state.error = message; + logger.status(`Error: ${message}`); + state.statusMessage = statusMessage; + return message; + } + + function setApplyProgress(title: string, detail: string, percent: number): void { + state.applyProgress = { + title, + detail, + percent: clamp(percent, 0, 100), + }; + } + + function clearApplyProgress(): void { + state.applyProgress = null; + } + + function flashPhaseProgress( + targetMode: ServoMode, + event: ProbeFlashProgressEvent, + ): { title: string; detail: string; percent: number } { + const modeLabelText = targetMode === "cr_mode" ? "CR" : "Servo"; + switch (event.phase) { + case "prepare": + return { + title: "Applying Changes...", + detail: `Switching to ${modeLabelText}: preparing flash session`, + percent: 10, + }; + case "boot_query": + return { + title: "Applying Changes...", + detail: `Switching to ${modeLabelText}: checking bootloader`, + percent: 18, + }; + case "key_exchange": + return { + title: "Applying Changes...", + detail: `Switching to ${modeLabelText}: negotiating flash session`, + percent: 26, + }; + case "verify_model": + return { + title: "Applying Changes...", + detail: `Switching to ${modeLabelText}: verifying firmware`, + percent: 32, + }; + case "erase": + return { + title: "Applying Changes...", + detail: `Switching to ${modeLabelText}: erasing firmware sectors`, + percent: 40, + }; + case "write": { + const ratio = + typeof event.bytesSent === "number" && + typeof event.bytesTotal === "number" && + event.bytesTotal > 0 + ? event.bytesSent / event.bytesTotal + : 0; + return { + title: "Applying Changes...", + detail: `Switching to ${modeLabelText}: writing firmware`, + percent: 45 + ratio * 35, + }; + } + case "finalize": + return { + title: "Applying Changes...", + detail: `Switching to ${modeLabelText}: finalizing firmware`, + percent: 82, + }; + case "done": + return { + title: "Applying Changes...", + detail: `Switching to ${modeLabelText}: reconnecting servo`, + percent: 86, + }; + default: + return { + title: "Applying Changes...", + detail: `Switching to ${modeLabelText}`, + percent: 25, + }; + } + } + + function recoveryPhaseProgress( + modelLabel: string, + event: ProbeFlashProgressEvent, + ): { title: string; detail: string; percent: number } { + switch (event.phase) { + case "prepare": + return { + title: "Recovering Servo...", + detail: `Recovering ${modelLabel}: preparing flash session`, + percent: 10, + }; + case "boot_query": + return { + title: "Recovering Servo...", + detail: `Recovering ${modelLabel}: checking bootloader`, + percent: 18, + }; + case "key_exchange": + return { + title: "Recovering Servo...", + detail: `Recovering ${modelLabel}: negotiating flash session`, + percent: 26, + }; + case "verify_model": + return { + title: "Recovering Servo...", + detail: `Recovering ${modelLabel}: verifying firmware`, + percent: 32, + }; + case "erase": + return { + title: "Recovering Servo...", + detail: `Recovering ${modelLabel}: erasing firmware sectors`, + percent: 40, + }; + case "write": { + const ratio = + typeof event.bytesSent === "number" && + typeof event.bytesTotal === "number" && + event.bytesTotal > 0 + ? event.bytesSent / event.bytesTotal + : 0; + return { + title: "Recovering Servo...", + detail: `Recovering ${modelLabel}: writing firmware`, + percent: 45 + ratio * 35, + }; + } + case "finalize": + return { + title: "Recovering Servo...", + detail: `Recovering ${modelLabel}: finalizing firmware`, + percent: 82, + }; + case "done": + return { + title: "Recovering Servo...", + detail: `Recovering ${modelLabel}: reconnecting servo`, + percent: 86, + }; + default: + return { + title: "Recovering Servo...", + detail: `Recovering ${modelLabel}`, + percent: 25, + }; + } + } + + function inventorySignature(inventory: ProbeInventory): string { + return JSON.stringify({ + openedId: inventory.openedId, + devices: inventory.devices.map((device) => ({ + id: device.id, + opened: device.opened, + })), + }); + } + + function updateInventory(inventory: ProbeInventory): boolean { + const previousDevices = state.inventory.devices.length; + const previousOpenedId = state.inventory.openedId; + const changed = inventorySignature(state.inventory) !== inventorySignature(inventory); + let clearedLiveState = false; + state.inventory = inventory; + if (inventory.devices.length > 0 || inventory.openedId !== null) { + state.hasAuthorizedAccess = true; + } + if (!inventory.openedId && state.busyAction !== "Apply" && state.busyAction !== "Recover") { + clearedLiveState = state.identify !== null || state.config !== null; + state.identify = null; + state.config = null; + state.liveSnapshot = null; + missingServoStreak = 0; + probeFailureStreak = 0; + lastSilentError = null; + } + if (changed) { + if (previousDevices === 0 && inventory.devices.length > 0) { + logger.debug("Adapter visible"); + } + if (previousDevices > 0 && inventory.devices.length === 0) { + logger.debug("Adapter not visible"); + } + if (previousOpenedId === null && inventory.openedId !== null) { + logger.debug("Adapter connected"); + } + if (previousOpenedId !== null && inventory.openedId === null) { + logger.debug("Adapter disconnected"); + } + } + updateSweepTimer(); + if (!state.busyAction) { + setIdleStatus(changed || clearedLiveState); + } + return changed || clearedLiveState; + } + + function rememberCheckpoint(config: ProbeConfigInfo, draft: DraftConfig): void { + const createdAt = new Date().toISOString(); + const checkpoint: ConfigCheckpoint = { + id: checkpointLabel(config, createdAt), + createdAt, + label: checkpointLabel(config, createdAt), + modelId: config.modelId, + modelName: config.modelName, + draft: cloneDraftConfig(draft), + }; + if ( + state.checkpoints[0] && + draftKey(state.checkpoints[0].draft) === draftKey(checkpoint.draft) + ) { + return; + } + state.checkpoints = [checkpoint, ...state.checkpoints].slice(0, MAX_CHECKPOINTS); + writeStoredCheckpoints(state.checkpoints); + } + + function adoptDraft(nextDraft: DraftConfig, previousMode: ServoMode | null): void { + state.draft = cloneDraftConfig(nextDraft); + state.sim = syncSimToDraft( + state.draft, + state.sim, + lookupSpec(state.config?.modelId ?? null, state.config?.modelName ?? null, state.config), + previousMode, + ); + updateSweepTimer(); + } + + function applyConfig(config: ProbeConfigInfo): void { + const previousMode = state.draft.mode; + state.config = config; + state.lastKnownModelId = config.modelId; + state.lastKnownModelName = config.modelName; + state.identify = { + present: true, + statusHi: "0x01", + statusLo: "0x00", + modeByte: null, + mode: config.mode, + rawRx: state.identify?.rawRx ?? "", + }; + const nextDraft = draftFromConfig(config, state.draft); + state.liveSnapshot = cloneDraftConfig(nextDraft); + state.baselineRawBytes = config.rawBytes ? [...config.rawBytes] : null; + rememberCheckpoint(config, nextDraft); + adoptDraft(nextDraft, previousMode); + } + + function openRecoveryWizard(): void { + const preferred = recoveryTargetFromState(state); + state.recoverySource = "bundled"; + state.recoveryBundledModelId = + preferred?.modelId || state.recoveryBundledModelId || RECOVERY_MODELS[0]?.id || ""; + state.recoveryBundledMode = "servo_mode"; + state.recoveryConfirmOpen = true; + } + + async function promptRecoveryFirmware(): Promise { + if (!options.loadFirmwareFile) { + return; + } + try { + const file = await options.loadFirmwareFile.run(); + if (!file) { + return; + } + const parsed = parseRecoveryFirmwareFile(file); + state.recoverySource = "file"; + state.recoveryFile = file; + state.recoveryFileModelId = parsed.modelId; + state.recoveryFileModelName = parsed.modelName; + state.error = null; + render(); + } catch (error) { + reportError(error); + render(); + } + } + + async function reconnectAndReadServoAfterFlash( + expectedMode: ServoMode | null, + progressTitle: string, + ): Promise { + if (!options.readFullConfig) { + throw new Error("Read setup is not available after flash."); + } + + let flashedConfig: ProbeConfigInfo | null = null; + let lastError: unknown = null; + + for (let attempt = 0; attempt < 8; attempt++) { + await sleep(1000); + try { + if (options.reconnectDevice) { + updateInventory(await options.reconnectDevice.run()); + } + if (!state.inventory.openedId && options.openDevice) { + updateInventory(await options.openDevice.run()); + } + if (options.identifyServo) { + setApplyProgress(progressTitle, "Waiting for servo to reconnect", 88); + setStatus("Checking for servo", attempt === 0); + const identifyReply = await options.identifyServo.run(); + state.identify = identifyReply; + if (!identifyReply.present) { + continue; + } + } + setApplyProgress(progressTitle, "Reading reconnected servo setup", 92); + setStatus("Reading setup", false); + const candidate = await options.readFullConfig.run(); + if (expectedMode !== null && modeFromConfig(candidate) !== expectedMode) { + continue; + } + flashedConfig = candidate; + break; + } catch (error) { + lastError = error; + } + } + + if (!flashedConfig) { + if (lastError instanceof Error) throw lastError; + throw new Error("Servo did not reconnect in the requested mode after flash."); + } + return flashedConfig; + } + + async function discardToServo(): Promise { + if (state.busyAction || !options.readFullConfig || !state.config) return; + + state.busyAction = "Discard"; + state.error = null; + state.loadPanelOpen = false; + setStatus("Reloading setup"); + render(); + + try { + if (options.identifyServo) { + setStatus("Checking for servo", false); + const identifyReply = await options.identifyServo.run(); + state.identify = identifyReply; + if (!identifyReply.present) { + throw new Error("Servo not detected."); + } + } + + setStatus("Reading setup", false); + const config = await options.readFullConfig.run(); + applyConfig(config); + setStatus("Discarded edits"); + setIdleStatus(); + } catch (error) { + reportError(error); + } finally { + state.busyAction = null; + render(); + } + } + + async function applyToServo(): Promise { + if ( + state.busyAction || + !state.config || + !state.liveSnapshot || + !options.readFullConfig || + !options.writeFullConfig + ) { + return; + } + + state.busyAction = "Apply"; + state.error = null; + setApplyProgress("Applying Changes...", "Reading current servo setup", 10); + setStatus("Reading setup"); + render(); + + try { + if (options.identifyServo) { + const identifyReply = await options.identifyServo.run(); + state.identify = identifyReply; + if (!identifyReply.present) { + throw new Error("Servo not detected."); + } + } + + const liveConfig = await options.readFullConfig.run(); + let workingConfig = liveConfig; + let workingMode = modeFromConfig(workingConfig); + + if (workingMode !== state.draft.mode) { + if (!options.flashModeChange) { + throw new Error("Apply for mode changes is not wired here yet."); + } + setApplyProgress( + "Applying Changes...", + `Switching to ${state.draft.mode === "cr_mode" ? "CR" : "Servo"}`, + 8, + ); + setStatus("Flashing mode"); + await options.flashModeChange.run({ + targetMode: state.draft.mode, + modelId: workingConfig.modelId, + onProgress: (event) => { + const progress = flashPhaseProgress(state.draft.mode, event); + setApplyProgress(progress.title, progress.detail, progress.percent); + render(); + }, + }); + + workingConfig = await reconnectAndReadServoAfterFlash( + state.draft.mode, + "Applying Changes...", + ); + workingMode = modeFromConfig(workingConfig); + } + + if (workingMode !== state.draft.mode) { + throw new Error("Servo mode did not match the requested draft after reconnect."); + } + if (!workingConfig.rawBytes) { + throw new Error("No raw config block is available for apply."); + } + + const targetBytes = encodeDraftOntoRawConfig( + Uint8Array.from(workingConfig.rawBytes), + state.draft, + ); + const workingBytes = Uint8Array.from(workingConfig.rawBytes); + if (sameBytes(targetBytes, workingBytes)) { + applyConfig(workingConfig); + clearApplyProgress(); + setStatus("No changes to apply"); + setIdleStatus(); + return; + } + + setApplyProgress( + "Applying Changes...", + "Writing setup", + workingMode === state.draft.mode && modeFromConfig(liveConfig) === state.draft.mode + ? 70 + : 95, + ); + setStatus("Writing setup"); + await options.writeFullConfig.run(targetBytes); + + setApplyProgress( + "Applying Changes...", + "Verifying applied setup", + workingMode === state.draft.mode && modeFromConfig(liveConfig) === state.draft.mode + ? 90 + : 98, + ); + setStatus("Verifying"); + const verifiedConfig = await options.readFullConfig.run(); + if (!verifiedConfig.rawBytes) { + throw new Error("Verification read did not return a raw config block."); + } + const verifiedBytes = Uint8Array.from(verifiedConfig.rawBytes); + if (!sameBytes(targetBytes, verifiedBytes)) { + throw new Error("Write verification failed. The servo read-back does not match the draft."); + } + + applyConfig(verifiedConfig); + setApplyProgress("Applying Changes...", "Finishing", 100); + setStatus("Applied"); + setIdleStatus(); + } catch (error) { + reportError(error); + try { + if (options.readFullConfig && state.inventory.openedId !== null) { + const refreshed = await options.readFullConfig.run(); + applyConfig(refreshed); + } + } catch { + // keep the current draft and surface the original error + } + } finally { + clearApplyProgress(); + state.busyAction = null; + render(); + } + } + + async function recoverServo(): Promise { + if ( + state.busyAction || + !options.readFullConfig || + (state.recoverySource === "bundled" && !options.flashModeChange) || + (state.recoverySource === "file" && !options.flashFirmwareFile) + ) { + return; + } + + const bundledModel = + state.recoverySource === "bundled" + ? findModel(loadCatalog(), state.recoveryBundledModelId) + : undefined; + const bundledTarget = + state.recoverySource === "bundled" && bundledModel + ? { + modelId: bundledModel.id, + modelName: bundledModel.name, + mode: state.recoveryBundledMode, + } + : null; + const fileTarget = + state.recoverySource === "file" && state.recoveryFile + ? { + file: state.recoveryFile, + modelId: state.recoveryFileModelId, + modelName: state.recoveryFileModelName, + } + : null; + + if (!bundledTarget && !fileTarget) { + return; + } + + const recoveryLabel = shortModelLabel( + bundledTarget?.modelName ?? fileTarget?.modelName ?? fileTarget?.modelId ?? null, + ); + const expectedMode = bundledTarget?.mode ?? null; + state.busyAction = "Recover"; + state.recoveryConfirmOpen = false; + state.error = null; + setApplyProgress( + "Recovering Servo...", + `Recovering ${recoveryLabel}: preparing flash session`, + 8, + ); + setStatus("Flashing recovery firmware"); + render(); + + try { + if (bundledTarget) { + if (!options.flashModeChange) { + throw new Error("Bundled recovery flashing is not available here."); + } + await options.flashModeChange.run({ + targetMode: bundledTarget.mode, + modelId: bundledTarget.modelId, + onProgress: (event) => { + const progress = recoveryPhaseProgress(recoveryLabel, event); + setApplyProgress(progress.title, progress.detail, progress.percent); + render(); + }, + }); + } else if (fileTarget) { + if (!options.flashFirmwareFile) { + throw new Error("Custom firmware recovery flashing is not available here."); + } + await options.flashFirmwareFile.run({ + bytes: Uint8Array.from(fileTarget.file.bytes), + expectedModelId: fileTarget.modelId ?? undefined, + onProgress: (event) => { + const progress = recoveryPhaseProgress(recoveryLabel, event); + setApplyProgress(progress.title, progress.detail, progress.percent); + render(); + }, + }); + } + + const recoveredConfig = await reconnectAndReadServoAfterFlash( + expectedMode, + "Recovering Servo...", + ); + applyConfig(recoveredConfig); + setApplyProgress("Recovering Servo...", "Finishing", 100); + setStatus("Recovered"); + setIdleStatus(); + } catch (error) { + reportError(error); + try { + if (options.readFullConfig && state.inventory.openedId !== null) { + const refreshed = await options.readFullConfig.run(); + applyConfig(refreshed); + } + } catch { + // keep the current state and surface the original error + } + } finally { + clearApplyProgress(); + state.busyAction = null; + render(); + } + } + + function loadCheckpoint(checkpointId: string): void { + const checkpoint = state.checkpoints.find((entry) => entry.id === checkpointId); + if (!checkpoint) return; + const previousMode = state.draft.mode; + adoptDraft(cloneDraftConfig(checkpoint.draft), previousMode); + state.error = null; + state.loadPanelOpen = false; + setStatus(`Loaded ${checkpoint.label}`); + setIdleStatus(); + render(); + } + + function destroyCharts(): void { + charts?.speedCurrent.destroy(); + charts?.power.destroy(); + charts?.efficiency.destroy(); + charts = null; + } + + function ensureCharts(operating: ReturnType): void { + const speedCanvas = options.root.querySelector( + "[data-chart='speed-current']", + ); + const powerCanvas = options.root.querySelector("[data-chart='power']"); + const efficiencyCanvas = options.root.querySelector( + "[data-chart='efficiency']", + ); + + if (!speedCanvas || !powerCanvas || !efficiencyCanvas) { + destroyCharts(); + return; + } + + const bounds = fullSpecChartBounds(operating.spec, state.sim.voltage); + const xMax = Math.max( + 0.1, + ...operating.curves.map((curve) => curve.torqueKgcm), + operating.point.torqueKgcm, + ); + const speedMax = bounds.speedMax; + const currentMax = bounds.currentMax; + const powerMax = bounds.powerMax; + const efficiencyMax = bounds.efficiencyMax; + + const speedData = curveData(operating.curves, (curve) => curve.speedRpm); + const currentData = curveData(operating.curves, (curve) => curve.currentA); + const mechanicalPowerData = curveData(operating.curves, (curve) => curve.mechanicalPowerW); + const totalPowerData = curveData(operating.curves, (curve) => curve.electricalPowerW); + const efficiencyData = curveData(operating.curves, (curve) => curve.efficiency); + + const speedPoint = { + x: Number(operating.point.torqueKgcm.toFixed(3)), + y: Number(operating.point.speedRpm.toFixed(6)), + }; + const currentPoint = { + x: Number(operating.point.torqueKgcm.toFixed(3)), + y: Number(operating.point.currentA.toFixed(6)), + }; + const powerPoint = { + x: Number(operating.point.torqueKgcm.toFixed(3)), + y: Number(operating.point.mechanicalPowerW.toFixed(6)), + }; + const efficiencyPoint = { + x: Number(operating.point.torqueKgcm.toFixed(3)), + y: Number(operating.point.efficiency.toFixed(6)), + }; + const xAxisTitle = `torque @ ${state.sim.voltage.toFixed(1)} V (kgf*cm)`; + + if (!charts) { + charts = { + speedCurrent: new Chart(speedCanvas, { + type: "line", + data: { + datasets: [ + createLineDataset("Speed", "#111111", "y", speedData), + createLineDataset("Current", "#ff3a38", "y1", currentData), + createPointDataset( + "Operating speed", + "#2d78d0", + "y", + speedPoint, + formatOperatingTooltip(operating.point, "speed"), + ), + createPointDataset( + "Operating current", + "#2d78d0", + "y1", + currentPoint, + formatOperatingTooltip(operating.point, "current"), + ), + ], + }, + options: baseChartOptions( + xMax, + xAxisTitle, + speedMax, + "speed (rpm)", + "speed", + currentMax, + "current (A)", + ), + }), + power: new Chart(powerCanvas, { + type: "line", + data: { + datasets: [ + createLineDataset("Mechanical power", "#8fc2f8", "y", mechanicalPowerData, { + fill: "origin", + backgroundColor: "rgba(143,194,248,0.35)", + }), + createLineDataset("Total input power", "#f0c57a", "y", totalPowerData, { + fill: "-1", + backgroundColor: "rgba(240,197,122,0.32)", + borderColor: "#f0c57a", + borderWidth: 2.2, + }), + createPointDataset( + "Operating power", + "#2d78d0", + "y", + powerPoint, + formatOperatingTooltip(operating.point, "power"), + ), + ], + }, + options: baseChartOptions( + xMax, + xAxisTitle, + powerMax, + "power (W)", + "power", + currentMax, + "current (A)", + true, + ), + }), + efficiency: new Chart(efficiencyCanvas, { + type: "line", + data: { + datasets: [ + createLineDataset("Efficiency", "#f0c57a", "y", efficiencyData), + createPointDataset( + "Operating efficiency", + "#2d78d0", + "y", + efficiencyPoint, + formatOperatingTooltip(operating.point, "efficiency"), + ), + ], + }, + options: baseChartOptions( + xMax, + xAxisTitle, + efficiencyMax, + "efficiency", + "efficiency", + currentMax, + "current (A)", + true, + ), + }), + }; + } + + charts.speedCurrent.data.datasets[0].data = speedData; + charts.speedCurrent.data.datasets[1].data = currentData; + charts.speedCurrent.data.datasets[2].data = [speedPoint]; + charts.speedCurrent.data.datasets[3].data = [currentPoint]; + ( + charts.speedCurrent.data.datasets[2] as ChartDataset<"line", ChartPoint[]> & { + tooltipData?: TooltipTableData; + } + ).tooltipData = formatOperatingTooltip(operating.point, "speed"); + ( + charts.speedCurrent.data.datasets[3] as ChartDataset<"line", ChartPoint[]> & { + tooltipData?: TooltipTableData; + } + ).tooltipData = formatOperatingTooltip(operating.point, "current"); + charts.speedCurrent.options.scales = { + ...(charts.speedCurrent.options.scales ?? {}), + x: { + ...(charts.speedCurrent.options.scales?.x ?? {}), + min: 0, + max: xMax, + title: { + ...(charts.speedCurrent.options.scales?.x?.title ?? {}), + text: xAxisTitle, + }, + }, + y: { ...(charts.speedCurrent.options.scales?.y ?? {}), min: 0, max: speedMax }, + y1: { ...(charts.speedCurrent.options.scales?.y1 ?? {}), min: 0, max: currentMax }, + }; + charts.speedCurrent.update("none"); + syncChartGeometry(speedCanvas, charts.speedCurrent); + + charts.power.data.datasets[0].data = mechanicalPowerData; + charts.power.data.datasets[1].data = totalPowerData; + charts.power.data.datasets[2].data = [powerPoint]; + ( + charts.power.data.datasets[2] as ChartDataset<"line", ChartPoint[]> & { + tooltipData?: TooltipTableData; + } + ).tooltipData = formatOperatingTooltip(operating.point, "power"); + charts.power.options.scales = { + ...(charts.power.options.scales ?? {}), + x: { + ...(charts.power.options.scales?.x ?? {}), + min: 0, + max: xMax, + title: { + ...(charts.power.options.scales?.x?.title ?? {}), + text: xAxisTitle, + }, + }, + y: { ...(charts.power.options.scales?.y ?? {}), min: 0, max: powerMax }, + y1: { ...(charts.power.options.scales?.y1 ?? {}), min: 0, max: currentMax }, + }; + charts.power.update("none"); + syncChartGeometry(powerCanvas, charts.power); + + charts.efficiency.data.datasets[0].data = efficiencyData; + charts.efficiency.data.datasets[1].data = [efficiencyPoint]; + ( + charts.efficiency.data.datasets[1] as ChartDataset<"line", ChartPoint[]> & { + tooltipData?: TooltipTableData; + } + ).tooltipData = formatOperatingTooltip(operating.point, "efficiency"); + charts.efficiency.options.scales = { + ...(charts.efficiency.options.scales ?? {}), + x: { + ...(charts.efficiency.options.scales?.x ?? {}), + min: 0, + max: xMax, + title: { + ...(charts.efficiency.options.scales?.x?.title ?? {}), + text: xAxisTitle, + }, + }, + y: { ...(charts.efficiency.options.scales?.y ?? {}), min: 0, max: efficiencyMax }, + y1: { ...(charts.efficiency.options.scales?.y1 ?? {}), min: 0, max: currentMax }, + }; + charts.efficiency.update("none"); + syncChartGeometry(efficiencyCanvas, charts.efficiency); + } + + function renderSweepFrame(updateCharts = false): void { + const servoHost = options.root.querySelector("[data-sim-servo]"); + if (!servoHost) { + return; + } + + const operating = buildOperatingPoint(state.config, state.draft, state.sim); + const rangeValue = options.root.querySelector("[data-range-value]"); + const sensitivityValue = options.root.querySelector("[data-sensitivity-value]"); + const neutralValue = options.root.querySelector("[data-neutral-value]"); + const dampeningValue = options.root.querySelector("[data-dampening-value]"); + const proptlValue = options.root.querySelector("[data-proptl-value]"); + const powerLimitValue = options.root.querySelector("[data-power-limit-value]"); + const pwmValue = options.root.querySelector("[data-live-pwm-value]"); + const pwmInput = options.root.querySelector("[data-slider='pwm']"); + const voltageValue = options.root.querySelector("[data-voltage-value]"); + const voltageMarks = options.root.querySelector("[data-voltage-detents]"); + const loadValue = options.root.querySelector("[data-load-value]"); + const loadInput = options.root.querySelector("[data-slider='load']"); + const loadMax = options.root.querySelector("[data-load-max]"); + const discardButton = options.root.querySelector("[data-command='discard']"); + const applyButton = options.root.querySelector("[data-command='apply']"); + const sweepButton = options.root.querySelector( + "[data-command='toggle-sweep']", + ); + const loadSliderMax = maxAvailableTorqueKgcm( + operating.spec, + state.sim.voltage, + state.draft.pwmPowerPercent, + ); + + servoHost.innerHTML = servoSketch( + operating.spec, + operating.point, + state.draft, + operating.torqueMax, + ); + + if (rangeValue) { + rangeValue.textContent = `${Math.round((operating.spec.maxRangeDeg * state.draft.rangePercent) / 100)} deg`; + } + if (sensitivityValue) { + sensitivityValue.textContent = `${state.draft.sensitivityStep} · ${sensitivityLabel(state.draft.sensitivityStep)}`; + } + if (neutralValue) { + neutralValue.textContent = `${formatSignedUs(state.draft.neutralUs)} us`; + } + if (dampeningValue) { + dampeningValue.textContent = String(state.draft.dampeningFactor); + } + if (proptlValue) { + proptlValue.textContent = `${state.draft.proptlSeconds.toFixed(1)} s`; + } + if (powerLimitValue) { + powerLimitValue.textContent = `${state.draft.pwmPowerPercent.toFixed(0)} %`; + } + if (pwmValue) { + pwmValue.textContent = `${operating.point.pwmUs} us`; + } + if ( + pwmInput && + state.draft.mode === "servo_mode" && + state.sim.sweepEnabled && + document.activeElement !== pwmInput + ) { + pwmInput.value = String(operating.point.pwmUs); + } + if (voltageValue) { + voltageValue.textContent = `${state.sim.voltage.toFixed(1)} V`; + } + if (voltageMarks) { + voltageMarks.innerHTML = Array.from( + new Set(operating.spec.points.map((sample) => sample.voltage.toFixed(1))), + ) + .map((value) => `${escapeHtml(value)}`) + .join(""); + } + if (loadValue) { + loadValue.textContent = `${state.sim.loadKgcm.toFixed(1)} kgf*cm`; + } + if (loadInput) { + loadInput.max = loadSliderMax.toFixed(2); + if (document.activeElement !== loadInput) { + loadInput.value = state.sim.loadKgcm.toFixed(2); + } + } + if (loadMax) { + loadMax.textContent = loadSliderMax.toFixed(1); + } + const liveDirty = isDirty(state.liveSnapshot, state.draft); + if (discardButton) { + discardButton.disabled = !(liveDirty && Boolean(state.config) && !state.busyAction); + } + if (applyButton) { + applyButton.disabled = !( + liveDirty && + state.config && + options.writeFullConfig && + !state.busyAction + ); + } + if (sweepButton) { + sweepButton.innerHTML = sweepControlIcon(state.sim.playbackRunning); + sweepButton.title = + state.draft.mode === "servo_mode" + ? state.sim.sweepEnabled && state.sim.playbackRunning + ? "Pause sweep" + : "Play sweep" + : state.sim.playbackRunning + ? "Pause animation" + : "Play animation"; + sweepButton.setAttribute( + "aria-label", + state.draft.mode === "servo_mode" + ? state.sim.sweepEnabled && state.sim.playbackRunning + ? "Pause sweep" + : "Play sweep" + : state.sim.playbackRunning + ? "Pause animation" + : "Play animation", + ); + } + const pointSnapshot = options.root.querySelector("[data-operating-point]"); + if (pointSnapshot) { + pointSnapshot.dataset.torqueKgcm = operating.point.torqueKgcm.toFixed(3); + pointSnapshot.dataset.speedRpm = operating.point.speedRpm.toFixed(3); + pointSnapshot.dataset.currentA = operating.point.currentA.toFixed(3); + pointSnapshot.dataset.mechanicalPowerW = operating.point.mechanicalPowerW.toFixed(3); + pointSnapshot.dataset.electricalPowerW = operating.point.electricalPowerW.toFixed(3); + pointSnapshot.dataset.heatW = operating.point.heatW.toFixed(3); + pointSnapshot.dataset.efficiency = operating.point.efficiency.toFixed(4); + pointSnapshot.dataset.effectiveVoltage = operating.point.effectiveVoltage.toFixed(3); + } + if (updateCharts) { + ensureCharts(operating); + } + } + + function refreshInteractiveUi(): void { + renderSweepFrame(true); + } + + function updateSweepTimer(): void { + const liveServoPresent = + state.inventory.openedId !== null && (state.identify?.present ?? Boolean(state.config)); + const shouldRun = + state.busyAction !== "Apply" && + liveServoPresent && + ((state.draft.mode === "servo_mode" && state.sim.sweepEnabled && state.sim.playbackRunning) || + (state.draft.mode === "cr_mode" && + state.sim.playbackRunning && + Math.abs(commandRatio(state.draft, state.sim.pwmUs)) > 0.02)); + if (shouldRun && !sweepFrame) { + const tick = (timestamp: number) => { + if (!sweepFrame) { + return; + } + if (lastSweepFrameTs === null) { + lastSweepFrameTs = timestamp; + } + const deltaMs = timestamp - lastSweepFrameTs; + lastSweepFrameTs = timestamp; + if (state.draft.mode === "servo_mode") { + state.sim.servoSweepPhase = (state.sim.servoSweepPhase + (deltaMs * 0.04) / 90) % 2; + } else { + const operating = buildOperatingPoint(state.config, state.draft, state.sim); + const degreesPerMs = + ((operating.point.speedRpm * 360) / 60000) * + (operating.point.commandSigned >= 0 ? 1 : -1); + state.sim.crAngleDeg = + (((state.sim.crAngleDeg + deltaMs * degreesPerMs) % 360) + 360) % 360; + } + renderSweepFrame(); + sweepFrame = window.requestAnimationFrame(tick); + }; + sweepFrame = window.requestAnimationFrame(tick); + } + if (!shouldRun && sweepFrame) { + window.cancelAnimationFrame(sweepFrame); + sweepFrame = null; + lastSweepFrameTs = null; + } + } + + function updateLivePollTimer(): void { + const shouldRun = (options.livePollIntervalMs ?? 0) > 0; + if (shouldRun && !livePollTimer) { + livePollTimer = setInterval(() => { + const liveDirty = isDirty(state.liveSnapshot, state.draft); + if ( + state.inventory.openedId !== null && + !state.busyAction && + !liveDirty && + Date.now() >= resizeQuietUntil + ) { + void probeLiveStateSilently(); + } + }, options.livePollIntervalMs); + } + if (!shouldRun && livePollTimer) { + clearInterval(livePollTimer); + livePollTimer = null; + } + } + + async function probeLiveStateSilently(): Promise { + if ( + liveProbeInFlight || + state.busyAction || + state.inventory.openedId === null || + Date.now() < resizeQuietUntil + ) { + return; + } + + liveProbeInFlight = true; + let shouldRender = false; + + try { + if (!options.identifyServo) { + return; + } + + const previousPresent = state.identify?.present === true || state.config !== null; + const previousMode = state.identify?.mode ?? "unknown"; + if (!previousPresent) { + setStatus("Checking for servo"); + } + const identifyReply = await options.identifyServo.run(); + + if (!identifyReply.present) { + missingServoStreak += 1; + if (missingServoStreak >= 2) { + state.identify = identifyReply; + if (state.config !== null || previousPresent || state.error !== null) { + state.config = null; + state.error = null; + updateSweepTimer(); + shouldRender = true; + } + } + return; + } + + missingServoStreak = 0; + probeFailureStreak = 0; + state.identify = identifyReply; + if (state.error !== null) { + state.error = null; + shouldRender = true; + } + + const readFullConfigAction = options.readFullConfig; + const shouldLoadConfig = + readFullConfigAction !== undefined && + (state.config === null || + previousPresent === false || + (previousMode !== "unknown" && previousMode !== identifyReply.mode)); + + if (!shouldLoadConfig) { + if (previousPresent === false || previousMode !== identifyReply.mode) { + shouldRender = true; + } + lastSilentError = null; + return; + } + + try { + const previousModelId = state.config?.modelId ?? null; + if (state.config === null) { + setStatus("Reading setup"); + } + const config = await readFullConfigAction.run(); + applyConfig(config); + lastSilentError = null; + shouldRender = true; + + const identityLabel = servoIdentityLabel(config, true); + if (previousModelId !== config.modelId && identityLabel !== "Detected") { + setStatus(`Servo detected: ${identityLabel}`); + } + setIdleStatus(previousModelId === config.modelId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + probeFailureStreak += 1; + if (lastSilentError !== message) { + logger.debug(`Probe retry: ${message}`); + lastSilentError = message; + } + if (probeFailureStreak >= 2 && (state.config !== null || previousPresent)) { + state.identify = null; + state.config = null; + updateSweepTimer(); + setStatus("Checking for servo", false); + shouldRender = true; + } else if (previousPresent === false) { + shouldRender = true; + } + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + probeFailureStreak += 1; + if (lastSilentError !== message) { + logger.debug(`Probe retry: ${message}`); + lastSilentError = message; + } + if (probeFailureStreak >= 2 && (state.config !== null || state.identify?.present === true)) { + state.identify = null; + state.config = null; + updateSweepTimer(); + setStatus("Checking for servo", false); + shouldRender = true; + } + } finally { + liveProbeInFlight = false; + if (shouldRender) { + render(); + } + } + } + + function handleResize(): void { + if (state.busyAction === "Apply") { + return; + } + resizeQuietUntil = Date.now() + RESIZE_POLL_QUIET_MS; + if (resizeEndTimer) { + clearTimeout(resizeEndTimer); + } + resizeEndTimer = setTimeout(() => { + resizeEndTimer = null; + refreshInteractiveUi(); + }, RESIZE_POLL_QUIET_MS); + } + + async function doAuthorizeAndConnect(): Promise { + if (state.busyAction || !options.requestDevice) return; + + state.busyAction = "Connect"; + state.error = null; + setStatus("Requesting USB permission"); + render(); + + try { + updateInventory(await options.requestDevice.run()); + await doConnectAndSync(false, true); + return; + } catch (error) { + reportError(error); + } finally { + if (state.busyAction === "Connect") { + state.busyAction = null; + render(); + } + } + } + + async function doConnectAndSync(allowRequest = false, alreadyBusy = false): Promise { + if (state.busyAction && !alreadyBusy) return; + if (!alreadyBusy) { + state.busyAction = "Connect"; + state.error = null; + setStatus(allowRequest ? "Requesting USB permission" : "Opening adapter"); + render(); + } + try { + if (!state.inventory.openedId) { + if (!state.inventory.devices.length && allowRequest && options.requestDevice) { + updateInventory(await options.requestDevice.run()); + } + if (!state.inventory.devices.length && options.reconnectDevice) { + updateInventory(await options.reconnectDevice.run()); + } + if (!state.inventory.devices.length && options.refreshInventory) { + updateInventory(await options.refreshInventory.run()); + } + if (!state.inventory.devices.length) { + throw new Error( + "Adapter not detected. Please ensure it is plugged in, then unplug and replug it.", + ); + } + if (options.openDevice) { + setStatus("Opening adapter", false); + updateInventory(await options.openDevice.run()); + } + } + + if (!state.inventory.openedId) { + throw new Error("Adapter not connected. Please unplug and replug it, then try again."); + } + + if (options.identifyServo) { + setStatus("Checking for servo"); + const identifyReply = await options.identifyServo.run(); + state.identify = identifyReply; + if (!identifyReply.present) { + state.config = null; + updateSweepTimer(); + setStatus("Checking for servo", false); + return; + } + } + + if (options.readFullConfig) { + setStatus("Reading setup"); + const config = await options.readFullConfig.run(); + applyConfig(config); + } + + setIdleStatus(); + } catch (error) { + reportError(error); + } finally { + if (!alreadyBusy) { + state.busyAction = null; + render(); + } + } + } + + function savePayload(): Record { + return { + kind: "axon-config-draft", + version: 2, + savedAt: new Date().toISOString(), + config: state.draft, + modelId: state.config?.modelId ?? null, + mode: state.draft.mode, + }; + } + + function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); + } + + async function triggerSave(format: "axon" | "svo"): Promise { + const baseName = `axon-${(state.config?.modelId ?? "draft").toLowerCase()}`; + const payload = savePayload(); + const axonBlob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); + const svoBytes = state.baselineRawBytes + ? encodeDraftOntoRawConfig(Uint8Array.from(state.baselineRawBytes), state.draft) + : null; + const suggestedName = `${baseName}.${format}`; + + if (format === "axon" && options.saveAxonFile) { + try { + const result = await options.saveAxonFile.run({ + suggestedName, + text: JSON.stringify(payload, null, 2), + }); + if (result && "path" in result && result.path === null) { + setIdleStatus(); + return; + } + setStatus("Saved .axon"); + setIdleStatus(); + } catch (error) { + reportError(error); + render(); + } + return; + } + + if (format === "svo" && options.exportSvoFile) { + try { + if (!svoBytes) { + throw new Error("No raw config block is available for .svo export yet."); + } + const result = await options.exportSvoFile.run({ + suggestedName, + bytes: Array.from(svoBytes), + }); + if (result && "path" in result && result.path === null) { + setIdleStatus(); + return; + } + setStatus("Saved .svo"); + setIdleStatus(); + } catch (error) { + reportError(error); + render(); + } + return; + } + + type SavePickerWindow = Window & { + showSaveFilePicker?: (options: { + suggestedName?: string; + types?: Array<{ description: string; accept: Record }>; + }) => Promise<{ + name?: string; + createWritable: () => Promise<{ + write: (data: Blob | BufferSource | string) => Promise; + close: () => Promise; + }>; + }>; + }; + + const saveWindow = window as SavePickerWindow; + if (typeof saveWindow.showSaveFilePicker === "function") { + try { + const handle = await saveWindow.showSaveFilePicker({ + suggestedName, + types: + format === "axon" + ? [ + { + description: "Axon config (.axon)", + accept: { "application/json": [".axon"] }, + }, + ] + : [ + { + description: "Vendor programmer config (.svo)", + accept: { "application/octet-stream": [".svo"] }, + }, + ], + }); + if (format === "svo") { + if (!svoBytes) { + throw new Error("No raw config block is available for .svo export yet."); + } + const binary = new Uint8Array(Array.from(svoBytes)); + const writable = await handle.createWritable(); + await writable.write(new Blob([binary], { type: "application/octet-stream" })); + await writable.close(); + setStatus("Saved .svo"); + } else { + const writable = await handle.createWritable(); + await writable.write(JSON.stringify(payload, null, 2)); + await writable.close(); + setStatus("Saved .axon"); + } + setIdleStatus(); + return; + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + setIdleStatus(); + return; + } + reportError(error); + render(); + return; + } + } + + if (format === "svo") { + if (!svoBytes) { + reportError("No raw config block is available for .svo export yet."); + render(); + return; + } + const binary = new Uint8Array(Array.from(svoBytes)); + downloadBlob(new Blob([binary], { type: "application/octet-stream" }), `${baseName}.svo`); + setStatus("Saved .svo"); + } else { + downloadBlob(axonBlob, `${baseName}.axon`); + setStatus("Saved .axon"); + } + setIdleStatus(); + } + + function applyLoadedDraft(value: unknown): void { + if (!value || typeof value !== "object") { + throw new Error("Unsupported file format."); + } + const payload = value as { + config?: Partial; + draft?: Partial; + }; + const configDraft = payload.config ?? payload.draft; + if (!configDraft) { + throw new Error("Missing draft payload."); + } + + const previousMode = state.draft.mode; + const nextDraft = draftFromPartial(configDraft, state.draft); + adoptDraft(nextDraft, previousMode); + state.loadPanelOpen = false; + state.error = null; + render(); + setStatus("Loaded config"); + setIdleStatus(); + } + + function handleSvoFile(file: File): void { + const reader = new FileReader(); + reader.onerror = () => { + reportError("Could not read file."); + render(); + }; + reader.onload = () => { + try { + const bytes = new Uint8Array(reader.result as ArrayBuffer); + const mode: CoreServoMode = + state.identify?.mode && state.identify.mode !== "unknown" + ? state.identify.mode + : state.draft.mode; + const config = configInfoFromRawBytes(bytes, mode); + const nextDraft = draftFromConfig(config, state.draft); + adoptDraft(nextDraft, state.draft.mode); + state.loadPanelOpen = false; + state.error = null; + render(); + setStatus(`Loaded ${file.name}`); + setIdleStatus(); + } catch (error) { + reportError(error); + render(); + } + }; + reader.readAsArrayBuffer(file); + } + + function handleFile(file: File): void { + const lower = file.name.toLowerCase(); + if (lower.endsWith(".svo")) { + handleSvoFile(file); + return; + } + const reader = new FileReader(); + reader.onerror = () => { + reportError("Could not read file."); + render(); + }; + reader.onload = () => { + try { + const parsed = JSON.parse(String(reader.result ?? "")); + applyLoadedDraft(parsed); + } catch (error) { + reportError(error); + render(); + } + }; + reader.readAsText(file); + } + + function handleLoadedFile(file: ProbeLoadedFile): void { + if (file.format === "svo") { + try { + if (!Array.isArray(file.bytes)) { + throw new Error("Selected .svo file did not provide binary bytes."); + } + const bytes = Uint8Array.from(file.bytes); + const mode: CoreServoMode = + state.identify?.mode && state.identify.mode !== "unknown" + ? state.identify.mode + : state.draft.mode; + const config = configInfoFromRawBytes(bytes, mode); + const nextDraft = draftFromConfig(config, state.draft); + adoptDraft(nextDraft, state.draft.mode); + state.loadPanelOpen = false; + state.error = null; + render(); + setStatus(`Loaded ${file.name}`); + setIdleStatus(); + } catch (error) { + reportError(error); + render(); + } + return; + } + + try { + if (typeof file.text !== "string") { + throw new Error("Selected .axon file did not provide text."); + } + const parsed = JSON.parse(file.text); + applyLoadedDraft(parsed); + setStatus(`Loaded ${file.name}`); + setIdleStatus(); + } catch (error) { + reportError(error); + render(); + } + } + + async function promptLoadFile(fileInput: HTMLInputElement | null): Promise { + if (options.loadConfigFile) { + try { + const loaded = await options.loadConfigFile.run(); + if (loaded) { + handleLoadedFile(loaded); + } + } catch (error) { + reportError(error); + render(); + } + return; + } + + type OpenPickerWindow = Window & { + showOpenFilePicker?: (options: { + multiple?: boolean; + excludeAcceptAllOption?: boolean; + types?: Array<{ description: string; accept: Record }>; + }) => Promise Promise }>>; + }; + + const openWindow = window as OpenPickerWindow; + if (typeof openWindow.showOpenFilePicker === "function") { + try { + const handles = await openWindow.showOpenFilePicker({ + multiple: false, + excludeAcceptAllOption: false, + types: [ + { + description: "Axon config files", + accept: { + "application/json": [".axon"], + "application/octet-stream": [".svo"], + }, + }, + ], + }); + const file = await handles[0]?.getFile(); + if (file) { + handleFile(file); + } + return; + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + return; + } + } + } + + fileInput?.click(); + } + + function render(): void { + const previousStatusLog = options.root.querySelector(".status-log-list"); + if (previousStatusLog) { + statusLogScrollTop = previousStatusLog.scrollTop; + statusLogPinnedToBottom = + previousStatusLog.scrollTop + previousStatusLog.clientHeight >= + previousStatusLog.scrollHeight - 8; + } + destroyCharts(); + const connected = Boolean(state.inventory.openedId); + const visible = state.inventory.devices.length > 0; + const servoPresent = state.identify?.present ?? Boolean(state.config); + const showStableLiveState = + state.busyAction === "Apply" || + state.busyAction === "Recover" || + state.applyProgress !== null; + const connectedUi = connected || showStableLiveState; + const servoPresentUi = servoPresent || (showStableLiveState && Boolean(state.config)); + const activeModelId = servoPresent ? (state.config?.modelId ?? null) : null; + const activeModelName = servoPresent + ? shortModelLabel(state.config?.modelName ?? activeModelId) + : null; + const fileActionsEnabled = connected && servoPresent && Boolean(state.config); + const visibleCheckpoints = + activeModelId === null + ? [] + : state.checkpoints + .filter((checkpoint) => checkpoint.modelId === activeModelId) + .slice(0, 3); + const liveDirty = isDirty(state.liveSnapshot, state.draft); + const servoLabel = servoIdentityLabel(state.config, servoPresentUi); + const selectedRecoveryModel = state.recoveryBundledModelId + ? findModel(loadCatalog(), state.recoveryBundledModelId) + : undefined; + const canRecover = Boolean(options.flashModeChange || options.flashFirmwareFile); + const recoverySelectionValid = + state.recoverySource === "bundled" + ? Boolean(selectedRecoveryModel && options.flashModeChange) + : Boolean(state.recoveryFile && options.flashFirmwareFile); + const recoverySummaryLabel = + state.recoverySource === "bundled" + ? shortModelLabel(selectedRecoveryModel?.name ?? null) + : shortModelLabel(state.recoveryFileModelName ?? state.recoveryFileModelId); + const canDiscard = liveDirty && Boolean(state.config) && !state.busyAction; + const canApply = + liveDirty && Boolean(state.config) && Boolean(options.writeFullConfig) && !state.busyAction; + const emptyState: EmptyStateKind = !connectedUi ? "adapter" : !servoPresentUi ? "servo" : null; + const showRange = state.draft.mode === "servo_mode"; + const centerLabel = state.draft.mode === "servo_mode" ? "Center" : "Stop trim"; + const showSoftStart = state.draft.mode === "servo_mode"; + const showSensitivity = state.draft.mode === "servo_mode"; + const showDampening = state.draft.mode === "servo_mode"; + const showOverload = state.draft.mode === "servo_mode"; + const showSignalLoss = state.draft.mode === "servo_mode"; + const showProptl = state.draft.mode === "cr_mode"; + const operating = buildOperatingPoint(state.config, state.draft, state.sim); + const voltageDetents = Array.from( + new Set(operating.spec.points.map((sample) => sample.voltage.toFixed(1))), + ); + const loadSliderMax = maxAvailableTorqueKgcm( + operating.spec, + state.sim.voltage, + state.draft.pwmPowerPercent, + ); + const point = operating.point; + + const diagnostics = { + environment: state.environment, + inventory: state.inventory, + identify: state.identify, + config: state.config, + draft: state.draft, + sim: state.sim, + point, + logs: state.logs, + }; + + options.root.innerHTML = ` + +
+
+
+
+ + ${ + state.loadPanelOpen + ? ` +
+
+ + + +
+ ${ + activeModelName + ? ` +
${escapeHtml(activeModelName)} Checkpoints
+
+ ${ + visibleCheckpoints.length > 0 + ? visibleCheckpoints + .map( + (checkpoint) => ` + + `, + ) + .join("") + : `
No ${escapeHtml(activeModelName)} checkpoints yet.
` + } +
+ ` + : "" + } +
+ ` + : "" + } +
+
+
+ Adapter + ${connectedUi ? "Connected" : "Not Connected"} +
+
+ Servo + ${escapeHtml(servoLabel)} +
+ +
+
+ + +
+
+ + ${ + state.statusOpen + ? ` +
+
+
Status Log
+
+ + +
+
+
+ ${state.logs + .map( + (entry) => ` +
+ [${escapeHtml(entry.timestamp)}]${entry.level === "debug" ? '[debug]' : ""}${escapeHtml(entry.message)} +
+ `, + ) + .join("")} +
+
+ ` + : "" + } + + ${state.error && !showStableLiveState ? `
${escapeHtml(state.error)}
` : ""} + + ${ + (state.busyAction === "Apply" || state.busyAction === "Recover") && state.applyProgress + ? ` + + ` + : "" + } + + ${ + state.recoveryConfirmOpen + ? ` + + ` + : "" + } + + ${ + emptyState === "adapter" + ? ` +
+
+

${state.hasAuthorizedAccess ? "Adapter not connected" : "Grant browser permission"}

+

${ + state.hasAuthorizedAccess + ? "Please ensure the adapter is properly plugged in and available to this app. Try unplugging and replugging it." + : "Click Grant Permission. When the browser chooser opens, select USB Bootloader v1.3. After that, this page will poll for the adapter and servo automatically." + }

+ ${ + options.requestDevice + ? ` + +

${ + state.busyAction === "Connect" + ? "The browser chooser can take a few seconds to appear. When it opens, select USB Bootloader v1.3." + : state.hasAuthorizedAccess + ? "If the adapter does not return automatically, click Grant Permission Again and reselect USB Bootloader v1.3." + : "When the browser chooser opens, select USB Bootloader v1.3." + }

+ ` + : "" + } +
+
+ ` + : emptyState === "servo" + ? ` +
+
+

Servo not detected

+

Please ensure the servo is plugged into the adapter and powered correctly. Try unplugging and replugging it.

+ ${ + canRecover + ? ` +

+ If it still does not respond, you can + . +

+ ` + : "" + } +
+
+ ` + : ` +
+
+
+ + +
+ + ${ + showRange + ? ` +
+
+ Range ${help(HELP_TEXT.range)} + ${Math.round((operating.spec.maxRangeDeg * state.draft.rangePercent) / 100)} deg +
+ +
+ ` + : "" + } + +
+
+ ${centerLabel} ${help(state.draft.mode === "servo_mode" ? HELP_TEXT.center : HELP_TEXT.stopTrim)} + ${escapeHtml(formatSignedUs(state.draft.neutralUs))} us +
+ +
+ +
+
+ Direction ${help(HELP_TEXT.direction)} + ${escapeHtml(directionLabel(state.draft.direction))} +
+
+ + +
+
+ +
+
+ Power limit ${help(HELP_TEXT.powerLimit)} + ${state.draft.pwmPowerPercent.toFixed(0)} % +
+ +
+ + ${ + showSensitivity + ? ` +
+
+ Sensitivity ${help(HELP_TEXT.sensitivity)} + ${state.draft.sensitivityStep} · ${escapeHtml(sensitivityLabel(state.draft.sensitivityStep))} +
+ +
+ ` + : "" + } + + ${ + showDampening + ? ` +
+
+ Dampening ${help(HELP_TEXT.dampening)} + ${state.draft.dampeningFactor} +
+ +
+ ` + : "" + } + + ${ + showSignalLoss + ? ` +
+
+ On signal loss ${help(HELP_TEXT.signalLoss)} +
+
+ ${(["hold", "release", "neutral"] as const) + .map( + (loss) => ` + + `, + ) + .join("")} +
+
+ ` + : "" + } + + ${ + showOverload + ? ` +
+
+ Load protection ${help(HELP_TEXT.overload)} +
+
+ + +
+
+ ${state.draft.overloadLevels + .map( + (level, index) => ` +
+
L${index + 1}
+
+
+ + % +
+
+ + sec +
+
+
+ `, + ) + .join("")} +
+
+ ` + : "" + } + + ${ + showSoftStart + ? ` +
+
+ Ramp ${help(HELP_TEXT.ramp)} +
+
+ + +
+
+ ` + : "" + } + + ${ + showProptl + ? ` +
+
+ ProPTL ${help(HELP_TEXT.proptl)} + ${state.draft.proptlSeconds.toFixed(1)} s +
+ +
+ ` + : "" + } + +
+ +
+
+
+
+
+
+ ${servoSketch(operating.spec, point, state.draft, operating.torqueMax)} +
+
+
+ +
+
+ +
+
+
${state.draft.mode === "servo_mode" ? "PWM" : "Drive"} ${help(state.draft.mode === "servo_mode" ? HELP_TEXT.pwmServo : HELP_TEXT.pwmCr)}
+
+
${point.pwmUs} us
+ +
50015002500
+
+
+ +
+
Voltage ${help(HELP_TEXT.voltage)}
+
+
${state.sim.voltage.toFixed(1)} V
+ + + ${voltageDetents.map((value) => ``).join("")} + +
${voltageDetents.map((value) => `${value}`).join("")}
+
+
+ +
+
Load ${help(HELP_TEXT.load)}
+
+
${state.sim.loadKgcm.toFixed(1)} kgf*cm
+ +
0.0${loadSliderMax.toFixed(1)}
+
+
+
+
+ +
+ + ${simChartsMarkup()} +
+
+
+
+ ` + } + + ${ + options.debugEnabled + ? ` +
+ Diagnostics +
+
${escapeHtml(toPrettyJson(diagnostics))}
+
+
+ ` + : "" + } +
+
+ `; + + options.root.querySelectorAll("[data-mode]").forEach((button) => { + button.addEventListener("click", () => { + const previousMode = state.draft.mode; + state.draft.mode = button.dataset.mode === "cr_mode" ? "cr_mode" : "servo_mode"; + state.sim = syncSimToDraft( + state.draft, + state.sim, + lookupSpec(state.config?.modelId ?? null, state.config?.modelName ?? null, state.config), + previousMode, + ); + updateSweepTimer(); + render(); + }); + }); + + options.root.querySelectorAll("[data-direction]").forEach((button) => { + button.addEventListener("click", () => { + state.draft.direction = button.dataset.direction === "ccw" ? "ccw" : "cw"; + render(); + }); + }); + + options.root.querySelectorAll("[data-loss]").forEach((button) => { + button.addEventListener("click", () => { + state.draft.pwmLossBehavior = + button.dataset.loss === "hold" || button.dataset.loss === "neutral" + ? button.dataset.loss + : "release"; + render(); + }); + }); + + options.root.querySelectorAll("[data-slider]").forEach((input) => { + input.addEventListener("input", () => { + switch (input.dataset.slider) { + case "range": + state.draft.rangePercent = clamp(Number(input.value), 10, 100); + break; + case "sensitivity": + state.draft.sensitivityStep = clamp(Math.round(Number(input.value)), 0, 14); + break; + case "neutral": + state.draft.neutralUs = clamp(Number(input.value), -127, 127); + break; + case "proptl": + state.draft.proptlSeconds = clamp(Number(input.value), 0, 25.5); + break; + case "power-limit": + state.draft.pwmPowerPercent = clamp(Number(input.value), 0, 100); + state.sim.loadKgcm = clamp( + state.sim.loadKgcm, + 0, + maxAvailableTorqueKgcm( + lookupSpec( + state.config?.modelId ?? null, + state.config?.modelName ?? null, + state.config, + ), + state.sim.voltage, + state.draft.pwmPowerPercent, + ), + ); + break; + case "pwm": + state.sim.pwmUs = clamp(Number(input.value), 500, 2500); + if (state.draft.mode === "servo_mode") { + state.sim.sweepEnabled = false; + state.sim.playbackRunning = false; + } + break; + case "voltage": + state.sim.voltage = clamp(Number(input.value), 4.8, 8.4); + state.sim.loadKgcm = clamp( + state.sim.loadKgcm, + 0, + maxAvailableTorqueKgcm( + lookupSpec( + state.config?.modelId ?? null, + state.config?.modelName ?? null, + state.config, + ), + state.sim.voltage, + state.draft.pwmPowerPercent, + ), + ); + break; + case "load": + state.sim.loadKgcm = clamp( + Number(input.value), + 0, + maxAvailableTorqueKgcm( + lookupSpec( + state.config?.modelId ?? null, + state.config?.modelName ?? null, + state.config, + ), + state.sim.voltage, + state.draft.pwmPowerPercent, + ), + ); + break; + default: + break; + } + updateSweepTimer(); + refreshInteractiveUi(); + }); + }); + + options.root.querySelectorAll("[data-input]").forEach((input) => { + const syncInput = () => { + const key = input.dataset.input ?? ""; + if (key === "dampening") { + state.draft.dampeningFactor = clamp(Math.round(Number(input.value) || 0), 0, 65535); + } + if (key.startsWith("overload-pct-")) { + const index = Number(key.slice("overload-pct-".length)); + if (Number.isInteger(index) && state.draft.overloadLevels[index]) { + state.draft.overloadLevels[index] = { + ...state.draft.overloadLevels[index], + pct: clamp(Number(input.value) || 10, 10, 100), + }; + } + } + if (key.startsWith("overload-sec-")) { + const index = Number(key.slice("overload-sec-".length)); + if (Number.isInteger(index) && state.draft.overloadLevels[index]) { + state.draft.overloadLevels[index] = { + ...state.draft.overloadLevels[index], + sec: clamp(Number(input.value) || 0, 0, 25.5), + }; + } + } + refreshInteractiveUi(); + }; + + input.addEventListener("input", syncInput); + input.addEventListener("change", syncInput); + }); + + options.root.querySelectorAll("[data-toggle-kind]").forEach((button) => { + button.addEventListener("click", () => { + const nextValue = button.dataset.toggleValue === "true"; + if (button.dataset.toggleKind === "softStart") { + state.draft.softStart = nextValue; + } + if (button.dataset.toggleKind === "overloadProtection") { + state.draft.overloadProtectionEnabled = nextValue; + } + updateSweepTimer(); + render(); + }); + }); + + const fileInput = options.root.querySelector("[data-file-input]"); + if (fileInput) { + fileInput.addEventListener("change", () => { + const file = fileInput.files?.[0]; + if (file) { + handleFile(file); + } + fileInput.value = ""; + }); + } + + const dropzone = options.root.querySelector("[data-dropzone='config']"); + if (dropzone) { + dropzone.addEventListener("dragover", (event) => { + event.preventDefault(); + dropzone.classList.add("dragging"); + }); + dropzone.addEventListener("dragleave", () => { + dropzone.classList.remove("dragging"); + }); + dropzone.addEventListener("drop", (event) => { + event.preventDefault(); + dropzone.classList.remove("dragging"); + const file = event.dataTransfer?.files?.[0]; + if (file) { + handleFile(file); + } + }); + } + + const details = options.root.querySelector("details.debug"); + if (details) { + details.addEventListener("toggle", () => { + state.diagnosticsOpen = details.open; + }); + } + + options.root.querySelectorAll("[data-help]").forEach((element) => { + element.addEventListener("mouseenter", () => { + showHelpTooltip(element); + }); + element.addEventListener("mouseleave", () => { + hideHelpTooltip(); + }); + element.addEventListener("focus", () => { + showHelpTooltip(element); + }); + element.addEventListener("blur", () => { + hideHelpTooltip(); + }); + element.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + }); + }); + + options.root.querySelectorAll("[data-command]").forEach((button) => { + button.addEventListener("click", () => { + const command = button.dataset.command; + if (command === "authorize-adapter") void doAuthorizeAndConnect(); + if (command === "toggle-sweep") { + if (state.draft.mode === "servo_mode") { + if (!state.sim.sweepEnabled) { + state.sim.sweepEnabled = true; + state.sim.playbackRunning = true; + state.sim.servoSweepPhase = 0; + } else { + state.sim.playbackRunning = !state.sim.playbackRunning; + } + } else { + state.sim.playbackRunning = !state.sim.playbackRunning; + } + updateSweepTimer(); + refreshInteractiveUi(); + } + if (command === "toggle-status-log") { + state.statusOpen = !state.statusOpen; + render(); + } + if (command === "toggle-file-menu") { + state.loadPanelOpen = !state.loadPanelOpen; + render(); + } + if (command === "copy-status-log") { + const text = logger.snapshot(); + void navigator.clipboard.writeText(text).then( + () => { + setStatus("Log copied"); + if (state.statusOpen) render(); + }, + () => { + reportError("Could not copy log."); + render(); + }, + ); + } + if (command === "close-load-panel") { + state.loadPanelOpen = false; + render(); + } + if (command === "load-file") { + state.loadPanelOpen = false; + void promptLoadFile(fileInput); + } + if (command === "load-checkpoint") { + const checkpointId = button.dataset.checkpointId; + if (checkpointId) { + loadCheckpoint(checkpointId); + } + } + if (command === "save-axon") { + state.loadPanelOpen = false; + void triggerSave("axon"); + } + if (command === "save-svo") { + state.loadPanelOpen = false; + void triggerSave("svo"); + } + if (command === "open-recover-servo") { + openRecoveryWizard(); + render(); + } + if (command === "set-recovery-source") { + state.recoverySource = button.dataset.recoverySource === "file" ? "file" : "bundled"; + render(); + } + if (command === "set-recovery-mode") { + state.recoveryBundledMode = + button.dataset.recoveryMode === "cr_mode" ? "cr_mode" : "servo_mode"; + render(); + } + if (command === "pick-recovery-file") { + void promptRecoveryFirmware(); + } + if (command === "cancel-recover-servo") { + state.recoveryConfirmOpen = false; + render(); + } + if (command === "confirm-recover-servo") { + void recoverServo(); + } + if (command === "discard") { + void discardToServo(); + } + if (command === "apply") { + void applyToServo(); + } + }); + }); + + const recoveryModelSelect = + options.root.querySelector("[data-recovery-model]"); + if (recoveryModelSelect) { + recoveryModelSelect.addEventListener("change", () => { + state.recoveryBundledModelId = recoveryModelSelect.value; + render(); + }); + } + + if (state.loadPanelOpen) { + const onPointerDown = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Node)) return; + const menu = options.root.querySelector(".top-menu"); + if (menu && !menu.contains(target)) { + state.loadPanelOpen = false; + render(); + } + }; + window.setTimeout(() => { + window.addEventListener("pointerdown", onPointerDown, { once: true }); + }, 0); + } + + const statusLogList = options.root.querySelector(".status-log-list"); + if (statusLogList) { + if (statusLogPinnedToBottom) { + statusLogList.scrollTop = statusLogList.scrollHeight; + } else { + statusLogList.scrollTop = statusLogScrollTop; + } + statusLogList.addEventListener("scroll", () => { + statusLogScrollTop = statusLogList.scrollTop; + statusLogPinnedToBottom = + statusLogList.scrollTop + statusLogList.clientHeight >= statusLogList.scrollHeight - 8; + }); + } + + renderSweepFrame(true); + } + + async function bootstrap(): Promise { + setStatus("Starting"); + render(); + try { + const [environment, inventory] = await Promise.all([ + options.loadEnvironment(), + options.loadInventory(), + ]); + state.environment = environment; + updateInventory(inventory); + if (options.autoConnectOnLoad && inventory.devices.length > 0) { + await doConnectAndSync(false); + } else { + setIdleStatus(); + } + } catch (error) { + reportError(error); + } + if (options.watchInventory) { + options.watchInventory((inventory) => { + const changed = updateInventory(inventory); + if ( + options.autoConnectOnLoad && + inventory.devices.length > 0 && + !inventory.openedId && + !state.busyAction + ) { + void doConnectAndSync(false); + return; + } + if (changed) { + render(); + } + }); + } + if (options.subscribeTransportLog) { + _transportLogCleanup = options.subscribeTransportLog((message) => { + logger.debug(message); + if (state.statusOpen) { + render(); + } + }); + } + if (options.subscribeStatusLog) { + _statusLogCleanup = options.subscribeStatusLog((message) => { + logger.status(message); + if (state.statusOpen) { + render(); + } + }); + } + updateLivePollTimer(); + updateSweepTimer(); + window.addEventListener("resize", handleResize); + render(); + } + + void bootstrap(); }