diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c1beef..bcb714e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,17 +44,21 @@ jobs: run: sudo apt-get update && sudo apt-get install -y libudev-dev libusb-1.0-0-dev - name: Install dependencies - working-directory: axon run: bun install --frozen-lockfile + - name: Install Playwright browsers (Linux) + if: runner.os == 'Linux' + run: bun x playwright install --with-deps chromium + + - name: Install Playwright browsers (macOS/Windows) + if: runner.os != 'Linux' + run: bun x playwright install chromium + - name: Biome check - working-directory: axon run: bun run check - name: Typecheck - working-directory: axon run: bun run typecheck - name: Test - working-directory: axon - run: bun test + run: bun run test 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/.github/workflows/release.yml b/.github/workflows/release.yml index eee61b5..eded282 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,7 @@ name: Release # Bun's cross-target build because GitHub no longer guarantees a # macOS 13 Intel runner on every plan. # -# To cut a release, bump axon/package.json version, tag, and push: +# To cut a release, bump apps/cli/package.json version, tag, and push: # git tag v1.0.0 && git push origin v1.0.0 # # The workflow_dispatch trigger lets you manually re-run a build @@ -95,15 +95,15 @@ jobs: run: | ./scripts/build-release.sh \ --target ${{ matrix.bun_target }} \ - --outfile axon/dist/${{ matrix.artifact }}${{ matrix.ext }} + --outfile apps/cli/dist/${{ matrix.artifact }}${{ matrix.ext }} - name: Upload artifact uses: actions/upload-artifact@v4 with: name: ${{ matrix.artifact }} path: | - axon/dist/${{ matrix.artifact }}${{ matrix.ext }} - axon/dist/${{ matrix.artifact }}${{ matrix.ext }}.sha256 + apps/cli/dist/${{ matrix.artifact }}${{ matrix.ext }} + apps/cli/dist/${{ matrix.artifact }}${{ matrix.ext }}.sha256 if-no-files-found: error retention-days: 30 diff --git a/.gitignore b/.gitignore index 96a1c19..e51e036 100644 --- a/.gitignore +++ b/.gitignore @@ -30,9 +30,14 @@ ENV/ # Node / Bun node_modules/ +.turbo/ *.log -axon/bin/ -axon/dist/ +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/README.md b/README.md index 7d5ea09..a6fd715 100644 --- a/README.md +++ b/README.md @@ -76,9 +76,10 @@ Full command surface: [docs/CLI_DESIGN.md](docs/CLI_DESIGN.md). curl -fsSL https://bun.sh/install | bash # 2. Clone, install dependencies, run from source -git clone https://github.com/caryden/servo-programmer -cd servo-programmer/axon +git clone https://github.com/caryden/servo-programmer.git +cd servo-programmer bun install +cd apps/cli bun run src/cli.ts status ``` @@ -87,6 +88,7 @@ prints a compact status bar plus the model docs link. For scripts and agents, use JSON: ```bash +cd apps/cli bun run src/cli.ts --json status ``` @@ -106,12 +108,24 @@ No `sudo` required on any platform. **Got an Axon Max, another Micro capture, or an unknown model?** Please help fill out the catalog. Plug it in, run -`bun run src/cli.ts read --svo > my-model.svo`, and +`cd apps/cli && bun run src/cli.ts read --svo > my-model.svo`, and [open an issue](https://github.com/caryden/servo-programmer/issues/new) with the file attached and the model ID string from `axon status`. I'll add the model/defaults to [`data/servo_catalog.json`](data/servo_catalog.json). +## Repo layout + +The repo is now organized as a Bun workspace: + +- [`apps/cli/`](apps/cli/) — production `axon` CLI +- [`apps/web/`](apps/web/) — browser WebHID PoC +- [`apps/desktop/`](apps/desktop/) — Electrobun desktop PoC +- [`packages/core/`](packages/core/) — shared transport-agnostic protocol/catalog logic +- [`packages/ui/`](packages/ui/) — shared probe UI used by the browser and desktop PoCs +- [`packages/transport-nodehid/`](packages/transport-nodehid/) — Node/Bun HID transport +- [`packages/transport-webhid/`](packages/transport-webhid/) — browser WebHID transport + ## Firmware files This repo does not redistribute Axon `.sfw` firmware files. Download diff --git a/axon/package.json b/apps/cli/package.json similarity index 90% rename from axon/package.json rename to apps/cli/package.json index 8806983..3b8ba31 100644 --- a/axon/package.json +++ b/apps/cli/package.json @@ -17,7 +17,8 @@ "test": "bun test" }, "dependencies": { - "node-hid": "^3.3.0" + "@axon/core": "workspace:*", + "@axon/transport-nodehid": "workspace:*" }, "devDependencies": { "@biomejs/biome": "^2.4.11", diff --git a/apps/cli/src/catalog.ts b/apps/cli/src/catalog.ts new file mode 100644 index 0000000..e7f8300 --- /dev/null +++ b/apps/cli/src/catalog.ts @@ -0,0 +1 @@ +export * from "@axon/core/catalog"; diff --git a/axon/src/cli.ts b/apps/cli/src/cli.ts similarity index 95% rename from axon/src/cli.ts rename to apps/cli/src/cli.ts index b6d330b..e6a21d9 100644 --- a/axon/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/axon/src/commands/doctor.ts b/apps/cli/src/commands/doctor.ts similarity index 99% rename from axon/src/commands/doctor.ts rename to apps/cli/src/commands/doctor.ts index 867ee0d..14e10a1 100644 --- a/axon/src/commands/doctor.ts +++ b/apps/cli/src/commands/doctor.ts @@ -19,6 +19,7 @@ import { } from "../driver/protocol.ts"; import type { DongleHandle } from "../driver/transport.ts"; import { AxonError, ExitCode } from "../errors.ts"; +import { cloneBuffer, toUint8Array } from "../util/bytes.ts"; type CheckStatus = "pass" | "warn" | "fail"; @@ -116,7 +117,7 @@ class TracingDongle implements DongleHandle { async read(timeoutMs?: number): Promise { const rx = await this.inner.read(timeoutMs); - this.reads.push(Buffer.from(rx)); + this.reads.push(cloneBuffer(rx)); return rx; } @@ -407,7 +408,7 @@ export async function runDoctor( } debug = debugReport(local.debug, traced, identifyReply, config); - const modelId = modelIdFromConfig(config); + const modelId = modelIdFromConfig(toUint8Array(config)); const model = findModel(catalog, modelId); const servo = { present: true, diff --git a/axon/src/commands/get.ts b/apps/cli/src/commands/get.ts similarity index 99% rename from axon/src/commands/get.ts rename to apps/cli/src/commands/get.ts index 1e6410e..086909f 100644 --- a/axon/src/commands/get.ts +++ b/apps/cli/src/commands/get.ts @@ -26,6 +26,7 @@ import { type ParameterSpec, type ParameterValue, } from "../parameters.ts"; +import { toUint8Array } from "../util/bytes.ts"; import { renderStatusBar } from "../util/tui.ts"; export interface GetFlags { @@ -67,7 +68,7 @@ export async function runGetWithHandle( // 2. read config → get model id const config = await readFullConfig(handle); - const modelId = modelIdFromConfig(config); + const modelId = modelIdFromConfig(toUint8Array(config)); const catalog = loadCatalog(); const model = findModel(catalog, modelId); if (!model) { diff --git a/axon/src/commands/mode.ts b/apps/cli/src/commands/mode.ts similarity index 90% rename from axon/src/commands/mode.ts rename to apps/cli/src/commands/mode.ts index 7fb2d11..22155fd 100644 --- a/axon/src/commands/mode.ts +++ b/apps/cli/src/commands/mode.ts @@ -57,6 +57,7 @@ import { resolveCatalogFirmware, } from "../firmware-store.ts"; import { type DecryptedSfw, decryptSfw, sfwHashHex } from "../sfw.ts"; +import { toUint8Array } from "../util/bytes.ts"; import { readLineFromStdin } from "../util/prompt.ts"; import { renderProgressBar } from "../util/tui.ts"; @@ -118,7 +119,7 @@ async function runModeList(handle: DongleHandle, global: GlobalFlags): Promise {}; 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 +654,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/axon/src/commands/monitor.ts b/apps/cli/src/commands/monitor.ts similarity index 97% rename from axon/src/commands/monitor.ts rename to apps/cli/src/commands/monitor.ts index 3ba7d2f..1e0fd31 100644 --- a/axon/src/commands/monitor.ts +++ b/apps/cli/src/commands/monitor.ts @@ -11,6 +11,7 @@ import { listDongles, openDongle } from "../driver/hid.ts"; import { identify, modelIdFromConfig, readFullConfig, type ServoMode } from "../driver/protocol.ts"; import type { DongleHandle } from "../driver/transport.ts"; import { AxonError, ExitCode } from "../errors.ts"; +import { toUint8Array } from "../util/bytes.ts"; import { CLEAR_LINE, HIDE_CURSOR, renderStatusBar, SHOW_CURSOR } from "../util/tui.ts"; const POLL_INTERVAL_MS = 300; @@ -70,7 +71,7 @@ export async function runMonitor(flags: GlobalFlags): Promise { if (cachedModelName === null) { try { const config = await readFullConfig(handle); - const modelId = modelIdFromConfig(config); + const modelId = modelIdFromConfig(toUint8Array(config)); const catalog = loadCatalog(); const model = findModel(catalog, modelId); cachedModelName = model?.name ?? modelId; diff --git a/apps/cli/src/commands/read.ts b/apps/cli/src/commands/read.ts new file mode 100644 index 0000000..f77986f --- /dev/null +++ b/apps/cli/src/commands/read.ts @@ -0,0 +1,197 @@ +/** + * `axon read` — read the full 95-byte config block and emit it. + * + * Output modes: + * (default) human-readable summary + * --json full model as JSON + * --svo raw 95 bytes to stdout (vendor-compatible) + * --hex annotated hex dump (debug) + */ + +import { findModel, loadCatalog, loadNotYetMapped } from "../catalog.ts"; +import type { GlobalFlags } from "../cli.ts"; +import { openDongle } from "../driver/hid.ts"; +import { identify, modelIdFromConfig, readFullConfig, type ServoMode } from "../driver/protocol.ts"; +import { ExitCode } from "../errors.ts"; +import { listParameters, type ParameterValue } from "../parameters.ts"; +import { toUint8Array } from "../util/bytes.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); + } finally { + await handle.release(); + } + + if (local.format === "svo") { + // Raw bytes to stdout — identical to a vendor .svo file + process.stdout.write(new Uint8Array(config)); + return ExitCode.Ok; + } + if (local.format === "hex") { + process.stdout.write(`${hexDump(config)}\n`); + return ExitCode.Ok; + } + if (local.format === "json" || global.json) { + emitJson(config, identifyReply.mode, local.debug); + return ExitCode.Ok; + } + emitHuman(config, identifyReply.mode, global, local.debug); + return ExitCode.Ok; +} + +function friendlyModeLabel(mode: ServoMode): string { + if (mode === "servo_mode") return "Servo Mode"; + if (mode === "cr_mode") return "CR Mode"; + return "Unknown Mode"; +} + +function formatPhysical(value: ParameterValue): string | number | boolean | undefined { + return value.physical ?? undefined; +} + +function decodedParameters(config: Buffer, modelId: string, mode: ServoMode) { + const rows = listParameters() + .filter((spec) => 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(toUint8Array(config)); + const catalog = loadCatalog(); + const model = findModel(catalog, modelId); + 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, + }, + 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 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(toUint8Array(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]`); + process.stdout.write("\n"); + if (model?.docs_url) { + process.stdout.write(`docs ${model.docs_url}\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(`${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 { + const lines: string[] = []; + for (let off = 0; off < buf.length; off += 16) { + const chunk = buf.subarray(off, off + 16); + const hex = Array.from(chunk) + .map((b) => b.toString(16).padStart(2, "0")) + .join(" ") + .padEnd(47); + const ascii = Array.from(chunk) + .map((b) => (b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : ".")) + .join(""); + lines.push(` 0x${off.toString(16).padStart(2, "0")} ${hex} ${ascii}`); + } + return lines.join("\n"); +} diff --git a/axon/src/commands/set.ts b/apps/cli/src/commands/set.ts similarity index 97% rename from axon/src/commands/set.ts rename to apps/cli/src/commands/set.ts index 2d21965..c699858 100644 --- a/axon/src/commands/set.ts +++ b/apps/cli/src/commands/set.ts @@ -33,6 +33,7 @@ import { notYetMappedReason, type ParameterSpec, } from "../parameters.ts"; +import { cloneBuffer, toUint8Array } from "../util/bytes.ts"; import { confirm } from "../util/prompt.ts"; export interface SetFlags { @@ -83,7 +84,7 @@ export async function runSetWithHandle( // 2. read current config const currentConfig = await readFullConfig(handle); - const modelId = modelIdFromConfig(currentConfig); + const modelId = modelIdFromConfig(toUint8Array(currentConfig)); const catalog = loadCatalog(); const model = findModel(catalog, modelId); if (!model) { @@ -106,7 +107,7 @@ export async function runSetWithHandle( // axon set default [--backup ] if (local.backup) { try { - writeFileSync(local.backup, currentConfig); + writeFileSync(local.backup, toUint8Array(currentConfig)); } catch (e) { throw AxonError.validation( `failed to write backup file ${local.backup}: ${(e as Error).message}`, @@ -229,7 +230,7 @@ export async function runSetWithHandle( } // 5. write - await writeFullConfig(handle, newConfig); + await writeFullConfig(handle, toUint8Array(newConfig)); // 6. read back + verify const verify = await readFullConfig(handle); @@ -330,7 +331,7 @@ function applyAllDefaults( modelId: string, mode: ServoMode, ): { config: Buffer; changes: ParamChange[] } { - let config: Buffer = Buffer.from(current); + let config: Buffer = cloneBuffer(current); const changes: ParamChange[] = []; for (const spec of listParameters()) { if (!spec.modes.includes(mode)) continue; @@ -354,7 +355,7 @@ function applyAllDefaults( beforeText: formatForDiff(spec, before.physical), afterText: formatForDiff(spec, after.physical), }); - config = Buffer.from(next); + config = cloneBuffer(next); } return { config, changes }; } diff --git a/axon/src/commands/status.ts b/apps/cli/src/commands/status.ts similarity index 98% rename from axon/src/commands/status.ts rename to apps/cli/src/commands/status.ts index 9a7da64..8ec0b9d 100644 --- a/axon/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -36,6 +36,7 @@ import { } from "../driver/protocol.ts"; import type { DongleHandle } from "../driver/transport.ts"; import { AxonError, ExitCode } from "../errors.ts"; +import { toUint8Array } from "../util/bytes.ts"; import { renderStatusBar } from "../util/tui.ts"; type StatusCategory = @@ -188,7 +189,7 @@ export async function runStatus(flags: GlobalFlags): Promise { throw e; } - const modelId = modelIdFromConfig(config); + const modelId = modelIdFromConfig(toUint8Array(config)); const catalog = loadCatalog(); const model = findModel(catalog, modelId); diff --git a/axon/src/commands/write.ts b/apps/cli/src/commands/write.ts similarity index 95% rename from axon/src/commands/write.ts rename to apps/cli/src/commands/write.ts index d1ecd5e..8fa76e5 100644 --- a/axon/src/commands/write.ts +++ b/apps/cli/src/commands/write.ts @@ -27,6 +27,7 @@ import { } from "../driver/protocol.ts"; import type { DongleHandle } from "../driver/transport.ts"; import { AxonError, ExitCode } from "../errors.ts"; +import { toUint8Array } from "../util/bytes.ts"; import { confirm } from "../util/prompt.ts"; export interface WriteFlags { @@ -88,8 +89,8 @@ async function runWriteBytesWithHandle( const currentBytes = await readFullConfig(handle); // 3. Model-id sanity check before building or showing a diff. - const currentModel = modelIdFromConfig(currentBytes); - const newModel = modelIdFromConfig(newBytes); + const currentModel = modelIdFromConfig(toUint8Array(currentBytes)); + const newModel = modelIdFromConfig(toUint8Array(newBytes)); if (currentModel.length === 0) { throw AxonError.validation("Connected servo reported an empty model id. Refusing to write."); } @@ -148,7 +149,7 @@ async function runWriteBytesWithHandle( } // 7. Write - await writeFullConfig(handle, newBytes); + await writeFullConfig(handle, toUint8Array(newBytes)); // 8. Read back and verify const verify = await readFullConfig(handle); diff --git a/apps/cli/src/driver/flash.ts b/apps/cli/src/driver/flash.ts new file mode 100644 index 0000000..b35b89e --- /dev/null +++ b/apps/cli/src/driver/flash.ts @@ -0,0 +1 @@ +export * from "@axon/core/driver/flash"; diff --git a/apps/cli/src/driver/hid.ts b/apps/cli/src/driver/hid.ts new file mode 100644 index 0000000..e14a9b5 --- /dev/null +++ b/apps/cli/src/driver/hid.ts @@ -0,0 +1 @@ +export * from "@axon/transport-nodehid"; diff --git a/apps/cli/src/driver/nodehid.ts b/apps/cli/src/driver/nodehid.ts new file mode 100644 index 0000000..d5a16ac --- /dev/null +++ b/apps/cli/src/driver/nodehid.ts @@ -0,0 +1 @@ +export * from "@axon/transport-nodehid/nodehid"; diff --git a/apps/cli/src/driver/protocol.ts b/apps/cli/src/driver/protocol.ts new file mode 100644 index 0000000..fb8855b --- /dev/null +++ b/apps/cli/src/driver/protocol.ts @@ -0,0 +1 @@ +export * from "@axon/core/driver/protocol"; diff --git a/apps/cli/src/driver/transport.ts b/apps/cli/src/driver/transport.ts new file mode 100644 index 0000000..c81768e --- /dev/null +++ b/apps/cli/src/driver/transport.ts @@ -0,0 +1 @@ +export * from "@axon/core/driver/transport"; diff --git a/apps/cli/src/errors.ts b/apps/cli/src/errors.ts new file mode 100644 index 0000000..3130358 --- /dev/null +++ b/apps/cli/src/errors.ts @@ -0,0 +1 @@ +export * from "@axon/core/errors"; diff --git a/axon/src/firmware-store.ts b/apps/cli/src/firmware-store.ts similarity index 96% rename from axon/src/firmware-store.ts rename to apps/cli/src/firmware-store.ts index 774398e..84d357e 100644 --- a/axon/src/firmware-store.ts +++ b/apps/cli/src/firmware-store.ts @@ -13,6 +13,7 @@ import { homedir } from "node:os"; import { delimiter, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { BundledFirmware } from "./catalog.ts"; +import { toUint8Array } from "./util/bytes.ts"; export const FIRMWARE_DOWNLOAD_URL = "https://docs.axon-robotics.com/archive/programmer"; @@ -56,8 +57,8 @@ interface FirmwareSearchOptions { includeDevDownloads?: boolean; } -function sha256Hex(bytes: Buffer): string { - return createHash("sha256").update(bytes).digest("hex"); +function sha256Hex(bytes: ArrayLike): string { + return createHash("sha256").update(toUint8Array(bytes)).digest("hex"); } function pushUnique( diff --git a/axon/src/parameters.ts b/apps/cli/src/parameters.ts similarity index 99% rename from axon/src/parameters.ts rename to apps/cli/src/parameters.ts index 01183eb..dc06d69 100644 --- a/axon/src/parameters.ts +++ b/apps/cli/src/parameters.ts @@ -27,6 +27,7 @@ import { type ModelDefaultValue, } from "./catalog.ts"; import { AxonError } from "./errors.ts"; +import { cloneBuffer } from "./util/bytes.ts"; // --------------------------------------------------------------------- // Public types @@ -94,7 +95,7 @@ function maxRangeDeg(modelId: string): number { } function cloneConfig(config: Buffer): Buffer { - return Buffer.from(config); + return cloneBuffer(config); } function requireCatalogEntry(name: string): CatalogParameterSpec { diff --git a/apps/cli/src/sfw.ts b/apps/cli/src/sfw.ts new file mode 100644 index 0000000..2a034c6 --- /dev/null +++ b/apps/cli/src/sfw.ts @@ -0,0 +1,15 @@ +import { createHash } from "node:crypto"; +import { toUint8Array } from "./util/bytes.ts"; + +export * from "@axon/core/sfw"; + +/** Compute the SHA-256 hash of a .sfw file as a lowercase hex string. */ +export function sfwHashHex(ciphertext: ArrayLike): string { + return createHash("sha256").update(toUint8Array(ciphertext)).digest("hex"); +} + +/** Compare a .sfw file's SHA-256 against a known-good hex digest. */ +export function verifySfwHash(ciphertext: ArrayLike, expectedSha256: string): boolean { + const actual = sfwHashHex(ciphertext); + return actual.toLowerCase() === expectedSha256.toLowerCase(); +} diff --git a/apps/cli/src/util/bytes.ts b/apps/cli/src/util/bytes.ts new file mode 100644 index 0000000..ba8d5a5 --- /dev/null +++ b/apps/cli/src/util/bytes.ts @@ -0,0 +1,9 @@ +import { Buffer } from "node:buffer"; + +export function toUint8Array(bytes: ArrayLike): Uint8Array { + return Uint8Array.from(bytes); +} + +export function cloneBuffer(bytes: ArrayLike): Buffer { + return Buffer.from(toUint8Array(bytes)); +} diff --git a/axon/src/util/prompt.ts b/apps/cli/src/util/prompt.ts similarity index 69% rename from axon/src/util/prompt.ts rename to apps/cli/src/util/prompt.ts index 6b0573b..2c491d2 100644 --- a/axon/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/axon/src/util/tui.ts b/apps/cli/src/util/tui.ts similarity index 100% rename from axon/src/util/tui.ts rename to apps/cli/src/util/tui.ts diff --git a/axon/test/.gitkeep b/apps/cli/test/.gitkeep similarity index 100% rename from axon/test/.gitkeep rename to apps/cli/test/.gitkeep diff --git a/axon/test/catalog.test.ts b/apps/cli/test/catalog.test.ts similarity index 100% rename from axon/test/catalog.test.ts rename to apps/cli/test/catalog.test.ts diff --git a/axon/test/cli.test.ts b/apps/cli/test/cli.test.ts similarity index 100% rename from axon/test/cli.test.ts rename to apps/cli/test/cli.test.ts diff --git a/axon/test/cli.unexpected-error.test.ts b/apps/cli/test/cli.unexpected-error.test.ts similarity index 100% rename from axon/test/cli.unexpected-error.test.ts rename to apps/cli/test/cli.unexpected-error.test.ts diff --git a/axon/test/commands/doctor.test.ts b/apps/cli/test/commands/doctor.test.ts similarity index 100% rename from axon/test/commands/doctor.test.ts rename to apps/cli/test/commands/doctor.test.ts diff --git a/axon/test/commands/get.test.ts b/apps/cli/test/commands/get.test.ts similarity index 100% rename from axon/test/commands/get.test.ts rename to apps/cli/test/commands/get.test.ts diff --git a/axon/test/commands/mode.security.test.ts b/apps/cli/test/commands/mode.security.test.ts similarity index 100% rename from axon/test/commands/mode.security.test.ts rename to apps/cli/test/commands/mode.security.test.ts diff --git a/axon/test/commands/mode.test.ts b/apps/cli/test/commands/mode.test.ts similarity index 99% rename from axon/test/commands/mode.test.ts rename to apps/cli/test/commands/mode.test.ts index b705956..96e59e1 100644 --- a/axon/test/commands/mode.test.ts +++ b/apps/cli/test/commands/mode.test.ts @@ -42,7 +42,7 @@ import { captureIO } from "../helpers/capture-io.ts"; import { loadFixtureConfig, MockDongle } from "../mocks/mock-dongle.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = join(__dirname, "../../.."); +const REPO_ROOT = join(__dirname, "../../../.."); const DOWNLOADS_DIR = join(REPO_ROOT, "downloads"); const MINI_SERVO_SFW = join(DOWNLOADS_DIR, "Axon_Mini_Servo_Mode.sfw"); const MINI_CR_SFW = MINI_SERVO_SFW.replace("Servo_Mode", "Modified_CR_Mode"); diff --git a/axon/test/commands/monitor.test.ts b/apps/cli/test/commands/monitor.test.ts similarity index 100% rename from axon/test/commands/monitor.test.ts rename to apps/cli/test/commands/monitor.test.ts diff --git a/axon/test/commands/read.test.ts b/apps/cli/test/commands/read.test.ts similarity index 65% rename from axon/test/commands/read.test.ts rename to apps/cli/test/commands/read.test.ts index bf2a93c..d4454ee 100644 --- a/axon/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/axon/test/commands/set.test.ts b/apps/cli/test/commands/set.test.ts similarity index 100% rename from axon/test/commands/set.test.ts rename to apps/cli/test/commands/set.test.ts diff --git a/axon/test/commands/status.test.ts b/apps/cli/test/commands/status.test.ts similarity index 100% rename from axon/test/commands/status.test.ts rename to apps/cli/test/commands/status.test.ts diff --git a/axon/test/commands/write.test.ts b/apps/cli/test/commands/write.test.ts similarity index 100% rename from axon/test/commands/write.test.ts rename to apps/cli/test/commands/write.test.ts diff --git a/axon/test/driver/flash.test.ts b/apps/cli/test/driver/flash.test.ts similarity index 100% rename from axon/test/driver/flash.test.ts rename to apps/cli/test/driver/flash.test.ts diff --git a/axon/test/driver/hid.test.ts b/apps/cli/test/driver/hid.test.ts similarity index 73% rename from axon/test/driver/hid.test.ts rename to apps/cli/test/driver/hid.test.ts index a075b9f..dab7a7b 100644 --- a/axon/test/driver/hid.test.ts +++ b/apps/cli/test/driver/hid.test.ts @@ -1,8 +1,12 @@ /** - * Tests for the production HID transport wrapper in `src/driver/hid.ts`. + * Tests for the production node-hid transport in + * `packages/transport-nodehid/src/hid.ts`. * - * These use a mocked local node-hid binding wrapper so we can exercise - * the adapter wrapping logic without requiring a physical programmer. + * These bypass the CLI re-export layer on purpose. Other CLI command tests + * mock `src/driver/hid.ts`, and importing that wrapper here can race with + * those mocks on Windows. By importing the transport package source directly, + * this file exercises the real HID wrapper without sharing a module identity + * with the higher-level CLI tests. */ import { afterEach, describe, expect, mock, test } from "bun:test"; @@ -43,7 +47,7 @@ describe("hid transport", () => { close(): void {} } - await mock.module("../../src/driver/nodehid.ts", () => ({ + await mock.module("../../../../packages/transport-nodehid/src/nodehid.ts", () => ({ getNodeHidBinding: () => ({ devices: () => [device], HID: FakeHID, @@ -51,7 +55,7 @@ describe("hid transport", () => { })); const { listDongles, openDongle, REPORT_SIZE } = await import( - "../../src/driver/hid.ts?hid-transport-test" + "../../../../packages/transport-nodehid/src/hid.ts?hid-transport-test" ); expect(listDongles()).toHaveLength(1); diff --git a/axon/test/driver/protocol.test.ts b/apps/cli/test/driver/protocol.test.ts similarity index 100% rename from axon/test/driver/protocol.test.ts rename to apps/cli/test/driver/protocol.test.ts diff --git a/axon/test/firmware-store.test.ts b/apps/cli/test/firmware-store.test.ts similarity index 100% rename from axon/test/firmware-store.test.ts rename to apps/cli/test/firmware-store.test.ts diff --git a/axon/test/fixtures/cold_state_replies.hex b/apps/cli/test/fixtures/cold_state_replies.hex similarity index 100% rename from axon/test/fixtures/cold_state_replies.hex rename to apps/cli/test/fixtures/cold_state_replies.hex diff --git a/axon/test/fixtures/dual_test7_config.svo b/apps/cli/test/fixtures/dual_test7_config.svo similarity index 100% rename from axon/test/fixtures/dual_test7_config.svo rename to apps/cli/test/fixtures/dual_test7_config.svo diff --git a/axon/test/fixtures/identify_reply.hex b/apps/cli/test/fixtures/identify_reply.hex similarity index 100% rename from axon/test/fixtures/identify_reply.hex rename to apps/cli/test/fixtures/identify_reply.hex diff --git a/axon/test/helpers/capture-io.ts b/apps/cli/test/helpers/capture-io.ts similarity index 100% rename from axon/test/helpers/capture-io.ts rename to apps/cli/test/helpers/capture-io.ts diff --git a/axon/test/mocks/mock-dongle.ts b/apps/cli/test/mocks/mock-dongle.ts similarity index 100% rename from axon/test/mocks/mock-dongle.ts rename to apps/cli/test/mocks/mock-dongle.ts diff --git a/axon/test/parameters.test.ts b/apps/cli/test/parameters.test.ts similarity index 100% rename from axon/test/parameters.test.ts rename to apps/cli/test/parameters.test.ts diff --git a/axon/test/prompt.test.ts b/apps/cli/test/prompt.test.ts similarity index 100% rename from axon/test/prompt.test.ts rename to apps/cli/test/prompt.test.ts diff --git a/axon/test/sanity.test.ts b/apps/cli/test/sanity.test.ts similarity index 100% rename from axon/test/sanity.test.ts rename to apps/cli/test/sanity.test.ts diff --git a/axon/test/sfw.test.ts b/apps/cli/test/sfw.test.ts similarity index 99% rename from axon/test/sfw.test.ts rename to apps/cli/test/sfw.test.ts index 870f262..c9012f9 100644 --- a/axon/test/sfw.test.ts +++ b/apps/cli/test/sfw.test.ts @@ -21,7 +21,7 @@ import { } from "../src/sfw.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = join(__dirname, "../.."); +const REPO_ROOT = join(__dirname, "../../.."); const DOWNLOADS = join(REPO_ROOT, "downloads"); const GROUND_TRUTH = join(REPO_ROOT, "research/decrypted-firmware"); diff --git a/axon/tsconfig.json b/apps/cli/tsconfig.json similarity index 100% rename from axon/tsconfig.json rename to apps/cli/tsconfig.json diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/apps/desktop/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/apps/desktop/README.md b/apps/desktop/README.md new file mode 100644 index 0000000..40579b8 --- /dev/null +++ b/apps/desktop/README.md @@ -0,0 +1,56 @@ +# Electrobun App + +This is the desktop Axon Servo Programmer built with Electrobun. + +## Why this shape + +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 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 the desktop app reuses the shared UI, but routes all hardware work +through Electrobun RPC into Bun. + +## Architecture + +- [`../../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, + flash, catalog, and `.sfw` handling + +## What it does + +- shows runtime and transport info +- 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: + +```bash +cd /Users/caryden/github/servo-programmer +bun install +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 new file mode 100644 index 0000000..03b9319 --- /dev/null +++ b/apps/desktop/electrobun.config.ts @@ -0,0 +1,32 @@ +import type { ElectrobunConfig } from "electrobun"; + +export default { + app: { + name: "Axon Servo Programmer", + identifier: "com.caryden.axon.servo-programmer", + version: "0.0.1", + }, + build: { + views: { + mainview: { + entrypoint: "src/mainview/index.ts", + }, + }, + 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: { + bundleCEF: false, + }, + }, +} satisfies ElectrobunConfig; 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/package.json b/apps/desktop/package.json new file mode 100644 index 0000000..18771cf --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,24 @@ +{ + "name": "@axon/desktop-poc", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "check": "bun x @biomejs/biome check src package.json electrobun.config.ts", + "start": "electrobun dev", + "dev": "electrobun dev --watch", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@axon/core": "workspace:*", + "@axon/transport-nodehid": "workspace:*", + "@axon/ui": "workspace:*", + "electrobun": "1.16.0" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.11", + "@types/bun": "latest", + "@types/three": "^0.180.0", + "typescript": "^5.9.3" + } +} diff --git a/apps/desktop/src/bun/index.ts b/apps/desktop/src/bun/index.ts new file mode 100644 index 0000000..be2900a --- /dev/null +++ b/apps/desktop/src/bun/index.ts @@ -0,0 +1,725 @@ +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, + 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 = 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; + return `0x${value.toString(16).padStart(width, "0")}`; +} + +function toUint8Array(bytes: ArrayLike): Uint8Array { + return Uint8Array.from(bytes); +} + +function hexDump(bytes: ArrayLike): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(" "); +} + +function serializeDescriptor(descriptor: DongleDescriptor): ProbeDeviceInfo { + return { + id: descriptor.path ?? null, + vendorId: hex(descriptor.vendorId, 4), + productId: hex(descriptor.productId, 4), + product: descriptor.product ?? null, + manufacturer: descriptor.manufacturer ?? null, + serialNumber: descriptor.serialNumber ?? null, + interface: descriptor.interface ?? null, + usagePage: hex(descriptor.usagePage, 4), + usage: hex(descriptor.usage, 4), + opened: descriptor.path === activeDescriptor?.path, + }; +} + +function inventoryFromDescriptors(descriptors: DongleDescriptor[]): ProbeInventory { + return { + 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: ArrayLike): string { + return createHash("sha256").update(toUint8Array(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 { + message: error.message, + code: error.code, + category: error.category, + hint: error.hint, + }; + } + + if (error instanceof Error) { + return { message: error.message, category: "internal" }; + } + + 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() }; + } catch (error) { + return { ok: false, error: serializeError(error) }; + } +} + +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(); + activeHandle = null; + 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."); + } + return activeHandle; +} + +function toIdentifyInfo(reply: IdentifyReply): ProbeIdentifyInfo { + return { + present: reply.present, + statusHi: hex(reply.statusHi) ?? "0x00", + statusLo: hex(reply.statusLo) ?? "0x00", + modeByte: hex(reply.modeByte ?? undefined), + mode: reply.mode, + rawRx: hexDump(reply.rawRx), + }; +} + +function runtimeInfo(): RuntimeInfo { + return { + transport: "Electrobun RPC -> Bun main process -> node-hid", + platform: process.platform, + arch: process.arch, + bunVersion: Bun.version, + renderer: "native system webview", + }; +} + +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(() => refreshInventoryState(true)), + reconnectAdapter: () => + withResult(async () => { + return await withHardwareSession("reconnectAdapter", async () => { + return await resetConnectionState("reconnectAdapter"); + }); + }), + openAdapter: (params) => + withResult(async () => { + 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."); + } + + activeHandle = await openDongle(selected); + activeDescriptor = selected; + return await refreshInventoryState(true); + }); + }), + closeAdapter: () => + withResult(async () => { + return await withHardwareSession("closeAdapter", async () => { + await releaseActiveHandle(); + return await refreshInventoryState(true); + }); + }), + identifyServo: () => + withResult(async () => { + 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 () => { + return await withHardwareSession("readFullConfig", async () => { + const handle = requireOpenHandle(); + try { + const config = await withTransientProbeRetry("readFullConfig", async () => + readFullConfig(handle), + ); + const modelId = modelIdFromConfig(toUint8Array(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(toUint8Array(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 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, toUint8Array(params.bytes)); + return { path: targetPath } satisfies ProbeSavedFile; + }), + }, + messages: {}, + }, +}); + +const mainWindow = new BrowserWindow({ + title: APP_NAME, + url: "views://mainview/index.html", + rpc, + frame: { + width: WINDOW_WIDTH, + height: WINDOW_HEIGHT, + x: 160, + y: 120, + }, + 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(`${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.css b/apps/desktop/src/mainview/index.css new file mode 100644 index 0000000..00e399b --- /dev/null +++ b/apps/desktop/src/mainview/index.css @@ -0,0 +1,69 @@ +:root { + color-scheme: light dark; + font-family: + ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +body { + margin: 0; + padding: 24px; + line-height: 1.45; +} + +main { + max-width: 960px; +} + +h1, +h2, +p, +ul { + margin: 0 0 16px; +} + +.row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 16px; +} + +button { + border: 1px solid currentColor; + border-radius: 6px; + background: transparent; + padding: 8px 12px; + cursor: pointer; + font: inherit; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + margin: 0 0 16px; +} + +.panel { + border: 1px solid color-mix(in srgb, currentColor 22%, transparent); + border-radius: 6px; + padding: 12px; +} + +pre { + overflow: auto; + padding: 12px; + border: 1px solid color-mix(in srgb, currentColor 22%, transparent); + border-radius: 6px; + margin: 0; +} + +code { + font-family: + ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, "Liberation Mono", monospace; +} diff --git a/apps/desktop/src/mainview/index.html b/apps/desktop/src/mainview/index.html new file mode 100644 index 0000000..4f3e722 --- /dev/null +++ b/apps/desktop/src/mainview/index.html @@ -0,0 +1,12 @@ + + + + + + Axon Servo Programmer + + +
+ + + diff --git a/apps/desktop/src/mainview/index.ts b/apps/desktop/src/mainview/index.ts new file mode 100644 index 0000000..d4c9542 --- /dev/null +++ b/apps/desktop/src/mainview/index.ts @@ -0,0 +1,254 @@ +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: { + 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))); + }, + }, + }, +}); + +new Electroview({ rpc }); + +function describeError(error: SerializedAxonError): string { + const out = [`${error.category ?? "internal"}: ${error.message}`]; + if (error.hint) { + out.push(`hint: ${error.hint}`); + } + return out.join("\n"); +} + +async function unwrapResult(promise: Promise>): Promise { + const result = await promise; + if (!result.ok) { + throw new Error(describeError(result.error)); + } + 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"); +} + +mountProbeApp({ + root, + 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: + "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.", + "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: "Find Adapter", + run: async (): Promise => unwrapResult(rpc.request.refreshAdapters()), + }, + openDevice: { + label: "Connect", + run: async (): Promise => unwrapResult(rpc.request.openAdapter()), + }, + closeDevice: { + label: "Disconnect", + run: async (): Promise => unwrapResult(rpc.request.closeAdapter()), + }, + identifyServo: { + label: "Find Servo", + run: async (): Promise => unwrapResult(rpc.request.identifyServo()), + }, + readFullConfig: { + 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 new file mode 100644 index 0000000..b7b82a6 --- /dev/null +++ b/apps/desktop/src/shared/api.ts @@ -0,0 +1,83 @@ +import type { + ProbeConfigInfo, + ProbeFirmwareFile, + ProbeFlashProgressEvent, + ProbeIdentifyInfo, + ProbeInventory, + ProbeLoadedFile, + ProbeSavedFile, +} from "@axon/ui"; + +type EmptySchema = Record; + +export interface SerializedAxonError { + message: string; + code?: number; + category?: string; + hint?: string; +} + +export interface RpcOk { + ok: true; + data: T; +} + +export interface RpcErr { + ok: false; + error: SerializedAxonError; +} + +export type RpcResult = RpcOk | RpcErr; + +export interface RuntimeInfo { + transport: string; + platform: string; + arch: string; + bunVersion: string; + 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: { + inventory: ProbeInventory; + transportLog: string; + flashProgress: ProbeFlashProgressEvent; + flashResult: FlashJobResult; + }; + }; +}; diff --git a/apps/desktop/src/types/three.d.ts b/apps/desktop/src/types/three.d.ts new file mode 100644 index 0000000..44db19a --- /dev/null +++ b/apps/desktop/src/types/three.d.ts @@ -0,0 +1 @@ +declare module "three"; diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json new file mode 100644 index 0000000..94bf23f --- /dev/null +++ b/apps/desktop/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": ["bun"] + }, + "include": ["src/**/*"] +} diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..a4f1e22 --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,54 @@ +# WebHID PoC + +This is a minimal browser proof-of-concept for talking to the Axon V1.3 +programmer adapter over WebHID. + +It answers one narrow question: + +> Can a Chromium-based browser on `localhost` request the adapter, open +> it over WebHID, and exchange the same identify/read reports that the +> CLI sends over `node-hid`? + +The current implementation now uses: + +- [`../../packages/ui/`](../../packages/ui/) for the shared probe UI +- [`../../packages/transport-webhid/`](../../packages/transport-webhid/) for browser HID access +- [`../../packages/core/`](../../packages/core/) for shared protocol and catalog logic + +## What it does + +- filters for the Axon adapter (`VID 0x0471`, `PID 0x13aa`) +- opens the selected HID device +- sends the identify command (`0x8A`) +- can read 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. + +## Run it + +Serve this directory from `localhost`. WebHID requires a secure context, +and `http://localhost` qualifies. + +From the repo root: + +```bash +cd apps/web +bun run start +``` + +Then open: + +```text +http://localhost:8765 +``` + +The `start` script bundles [`src/index.ts`](./src/index.ts) to +[`dist/index.js`](./dist/index.js) and then serves the app. + +## Browser constraints + +- Use a Chromium-based browser such as Chrome, Edge, Brave, or Arc. +- Safari and Firefox do not currently give you a useful path here. +- If the adapter is captured by a VM such as Parallels, the browser + will not be able to open it from the host. 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 new file mode 100644 index 0000000..289e860 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,14 @@ + + + + + + Axon WebHID PoC + + + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..d269bad --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,26 @@ +{ + "name": "@axon/web-poc", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "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", + "live": "bun run build && bun run ./server.ts" + }, + "dependencies": { + "@axon/core": "workspace:*", + "@axon/transport-webhid": "workspace:*", + "@axon/ui": "workspace:*" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.11", + "@types/bun": "latest", + "typescript": "^5" + } +} 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..04cf7c7 --- /dev/null +++ b/apps/web/scripts/sync-downloads.ts @@ -0,0 +1,28 @@ +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(targetDir)) { + rmSync(targetDir, { recursive: true, force: true }); +} +mkdirSync(targetDir, { recursive: true }); + +if (!existsSync(sourceDir) || !statSync(sourceDir).isDirectory()) { + console.warn(`downloads directory not found: ${sourceDir}; continuing with empty web downloads/`); + process.exit(0); +} + +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 new file mode 100644 index 0000000..f8d7991 --- /dev/null +++ b/apps/web/src/index.ts @@ -0,0 +1,760 @@ +import { Buffer } from "node:buffer"; +import { findModel, loadCatalog } from "@axon/core/catalog"; +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, + openDongle, + requestAxonDevices, + summarizeDevice, + webHidSupported, +} from "@axon/transport-webhid"; +import { + mountProbeApp, + type ProbeConfigInfo, + type ProbeFirmwareFile, + type ProbeIdentifyInfo, + type ProbeInventory, +} from "@axon/ui"; + +function hex(value: number, width = 2): string { + return `0x${value.toString(16).padStart(width, "0")}`; +} + +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 { + devices: selectedDevice ? [summarizeDevice(selectedDevice)] : [], + openedId: selectedDevice && activeHandle ? deviceId(selectedDevice) : null, + }; +} + +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; + } + 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."); + } + return activeHandle; +} + +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; +} + +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, + }, + 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(); + }, + }, + reconnectDevice: { + label: "Reconnect", + run: async () => inventory(), + }, + openDevice: { + label: "Connect", + run: async () => { + connected = true; + return inventory(); + }, + }, + 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); + }; + }, + 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 new file mode 100644 index 0000000..6c274b8 --- /dev/null +++ b/apps/web/src/webhid.d.ts @@ -0,0 +1,53 @@ +interface HIDReportInfo { + reportId: number; +} + +interface HIDCollectionInfo { + usagePage: number; + usage: number; + inputReports: HIDReportInfo[]; + outputReports: HIDReportInfo[]; + featureReports: HIDReportInfo[]; +} + +interface HIDConnectionEvent extends Event { + device: HIDDevice; +} + +interface HIDInputReportEvent extends Event { + device: HIDDevice; + reportId: number; + data: DataView; +} + +interface HIDDevice extends EventTarget { + vendorId: number; + productId: number; + productName: string; + opened: boolean; + collections: HIDCollectionInfo[]; + open(): Promise; + close(): Promise; + sendReport(reportId: number, data: BufferSource): Promise; +} + +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, + options?: boolean | AddEventListenerOptions, + ): void; +} + +interface Navigator { + hid: HID; +} 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 new file mode 100644 index 0000000..af44644 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": ["bun"] + }, + "include": ["src/**/*", "scripts/**/*"] +} diff --git a/axon/bun.lock b/axon/bun.lock deleted file mode 100644 index 948cab1..0000000 --- a/axon/bun.lock +++ /dev/null @@ -1,84 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "axon", - "dependencies": { - "node-hid": "^3.3.0", - }, - "devDependencies": { - "@biomejs/biome": "^2.4.11", - "@types/bun": "latest", - "typescript": "^5", - }, - }, - }, - "packages": { - "@biomejs/biome": ["@biomejs/biome@2.4.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.11", "@biomejs/cli-darwin-x64": "2.4.11", "@biomejs/cli-linux-arm64": "2.4.11", "@biomejs/cli-linux-arm64-musl": "2.4.11", "@biomejs/cli-linux-x64": "2.4.11", "@biomejs/cli-linux-x64-musl": "2.4.11", "@biomejs/cli-win32-arm64": "2.4.11", "@biomejs/cli-win32-x64": "2.4.11" }, "bin": { "biome": "bin/biome" } }, "sha512-nWxHX8tf3Opb/qRgZpBbsTOqOodkbrkJ7S+JxJAruxOReaDPPmPuLBAGQ8vigyUgo0QBB+oQltNEAvalLcjggA=="], - - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wOt+ed+L2dgZanWyL6i29qlXMc088N11optzpo10peayObBaAshbTcxKUchzEMp9QSY8rh5h6VfAFE3WTS1rqg=="], - - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-gZ6zR8XmZlExfi/Pz/PffmdpWOQ8Qhy7oBztgkR8/ylSRyLwfRPSadmiVCV8WQ8PoJ2MWUy2fgID9zmtgUUJmw=="], - - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-avdJaEElXrKceK0va9FkJ4P5ci3N01TGkc6ni3P8l3BElqbOz42Wg2IyX3gbh0ZLEd4HVKEIrmuVu/AMuSeFFA=="], - - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-+Sbo1OAmlegtdwqFE8iOxFIWLh1B3OEgsuZfBpyyN/kWuqZ8dx9ZEes6zVnDMo+zRHF2wLynRVhoQmV7ohxl2Q=="], - - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.11", "", { "os": "linux", "cpu": "x64" }, "sha512-TagWV0iomp5LnEnxWFg4nQO+e52Fow349vaX0Q/PIcX6Zhk4GGBgp3qqZ8PVkpC+cuehRctMf3+6+FgQ8jCEFQ=="], - - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.11", "", { "os": "linux", "cpu": "x64" }, "sha512-bexd2IklK7ZgPhrz6jXzpIL6dEAH9MlJU1xGTrypx+FICxrXUp4CqtwfiuoDKse+UlgAlWtzML3jrMqeEAHEhA=="], - - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-RJhaTnY8byzxDt4bDVb7AFPHkPcjOPK3xBip4ZRTrN3TEfyhjLRm3r3mqknqydgVTB74XG8l4jMLwEACEeihVg=="], - - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.11", "", { "os": "win32", "cpu": "x64" }, "sha512-A8D3JM/00C2KQgUV3oj8Ba15EHEYwebAGCy5Sf9GAjr5Y3+kJIYOiESoqRDeuRZueuMdCsbLZIUqmPhpYXJE9A=="], - - "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], - - "@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="], - - "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], - - "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=="], - - "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - - "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], - - "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - - "node-addon-api": ["node-addon-api@3.2.1", "", {}, "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A=="], - - "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=="], - - "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=="], - - "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], - - "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - - "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - - "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=="], - - "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], - - "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], - - "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - } -} diff --git a/axon/src/commands/read.ts b/axon/src/commands/read.ts deleted file mode 100644 index 89a7051..0000000 --- a/axon/src/commands/read.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * `axon read` — read the full 95-byte config block and emit it. - * - * Output modes: - * (default) human-readable summary - * --json full model as JSON - * --svo raw 95 bytes to stdout (vendor-compatible) - * --hex annotated hex dump (debug) - */ - -import { findModel, loadCatalog } from "../catalog.ts"; -import type { GlobalFlags } from "../cli.ts"; -import { openDongle } from "../driver/hid.ts"; -import { modelIdFromConfig, readFullConfig } from "../driver/protocol.ts"; -import { ExitCode } from "../errors.ts"; - -export interface ReadFlags { - format: "human" | "json" | "svo" | "hex"; -} - -export async function runRead(global: GlobalFlags, local: ReadFlags): Promise { - const handle = await openDongle(); - let config: Buffer; - try { - config = await readFullConfig(handle); - } finally { - await handle.release(); - } - - if (local.format === "svo") { - // Raw bytes to stdout — identical to a vendor .svo file - process.stdout.write(new Uint8Array(config)); - return ExitCode.Ok; - } - if (local.format === "hex") { - process.stdout.write(`${hexDump(config)}\n`); - return ExitCode.Ok; - } - if (local.format === "json" || global.json) { - emitJson(config); - return ExitCode.Ok; - } - emitHuman(config, global); - return ExitCode.Ok; -} - -function emitJson(config: Buffer): void { - const modelId = modelIdFromConfig(config); - const catalog = loadCatalog(); - const model = findModel(catalog, modelId); - const result = { - 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.", - }, - }; - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); -} - -function emitHuman(config: Buffer, _global: GlobalFlags): void { - const modelId = modelIdFromConfig(config); - const catalog = loadCatalog(); - const model = findModel(catalog, modelId); - - process.stdout.write(`model ${modelId}`); - if (model?.name) process.stdout.write(` (${model.name})`); - if (!model) process.stdout.write(` [unknown to catalog]`); - process.stdout.write("\n"); - 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`, - ); - 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", - ); -} - -function hexDump(buf: Buffer): string { - const lines: string[] = []; - for (let off = 0; off < buf.length; off += 16) { - const chunk = buf.subarray(off, off + 16); - const hex = Array.from(chunk) - .map((b) => b.toString(16).padStart(2, "0")) - .join(" ") - .padEnd(47); - const ascii = Array.from(chunk) - .map((b) => (b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : ".")) - .join(""); - lines.push(` 0x${off.toString(16).padStart(2, "0")} ${hex} ${ascii}`); - } - return lines.join("\n"); -} diff --git a/axon/src/driver/transport.ts b/axon/src/driver/transport.ts deleted file mode 100644 index e85b4e9..0000000 --- a/axon/src/driver/transport.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Transport-layer abstraction for talking to the Axon dongle. - * - * Everything above this file (the protocol layer in `protocol.ts` and - * the command implementations in `commands/`) only depends on this - * interface — they never see the underlying USB/HID library. That - * gives us three things: - * - * 1. Test injection: `test/mocks/mock-dongle.ts` implements - * `DongleHandle` with an in-memory state machine, so the unit - * tests don't need real hardware. - * 2. WebHID port: a future browser-based version of the CLI can - * implement `DongleHandle` on top of `navigator.hid` without - * touching the protocol layer at all. - * 3. No accidental coupling: nothing in `protocol.ts` can - * grab the `node-hid` `HID.HID` object and call something - * transport-specific on it (like `dev.reset()`, which would - * put the dongle into cold state — see - * `docs/FINDINGS.md`). - */ - -export interface DongleHandle { - /** - * Send a single 64-byte HID output report. The data buffer is - * zero-padded up to the report size if shorter; `data[0]` must be - * the HID report id (`0x04` for the Axon dongle). - */ - write(data: Buffer, timeoutMs?: number): Promise; - - /** - * Read a single HID input report from the dongle, with a blocking - * timeout. Resolves with a Buffer of the full report bytes - * (typically 64). The first byte is the report id. - */ - read(timeoutMs?: number): Promise; - - /** - * Close the underlying device handle. Idempotent — safe to call - * more than once. After release, write/read MUST NOT be called. - */ - release(): Promise; -} diff --git a/biome.json b/biome.json index 6f6c419..8306fa8 100644 --- a/biome.json +++ b/biome.json @@ -7,23 +7,46 @@ }, "files": { "includes": [ - "axon/src/**/*.ts", - "axon/src/**/*.tsx", - "axon/src/**/*.js", - "axon/src/**/*.jsx", - "axon/src/**/*.json", - "axon/test/**/*.ts", - "axon/test/**/*.tsx", - "axon/test/**/*.js", - "axon/test/**/*.jsx", - "axon/test/**/*.json", + "apps/cli/src/**/*.ts", + "apps/cli/src/**/*.tsx", + "apps/cli/src/**/*.js", + "apps/cli/src/**/*.jsx", + "apps/cli/src/**/*.json", + "apps/cli/test/**/*.ts", + "apps/cli/test/**/*.tsx", + "apps/cli/test/**/*.js", + "apps/cli/test/**/*.jsx", + "apps/cli/test/**/*.json", + "apps/web/src/**/*.ts", + "apps/web/package.json", + "apps/web/index.html", + "apps/desktop/src/**/*.ts", + "apps/desktop/src/**/*.tsx", + "apps/desktop/package.json", + "apps/desktop/electrobun.config.ts", + "packages/core/src/**/*.ts", + "packages/core/package.json", + "packages/ui/src/**/*.ts", + "packages/ui/package.json", + "packages/transport-nodehid/src/**/*.ts", + "packages/transport-nodehid/package.json", + "packages/transport-webhid/src/**/*.ts", + "packages/transport-webhid/package.json", + "package.json", + "turbo.json", "!**/node_modules", "!research", "!vendor", "!data", "!docs", - "!axon/bin", - "!axon/node_modules" + "!apps/cli/bin", + "!apps/cli/node_modules", + "!apps/desktop/build", + "!apps/web/dist", + "!packages/core/node_modules", + "!packages/ui/node_modules", + "!packages/transport-nodehid/node_modules", + "!packages/transport-webhid/node_modules" ] }, "formatter": { diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..4622378 --- /dev/null +++ b/bun.lock @@ -0,0 +1,406 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "servo-programmer", + "devDependencies": { + "@playwright/test": "^1.59.1", + "http-server": "^14.1.1", + "turbo": "^2", + }, + }, + "apps/cli": { + "name": "axon", + "version": "1.0.0", + "bin": { + "axon": "./src/cli.ts", + }, + "dependencies": { + "@axon/core": "workspace:*", + "@axon/transport-nodehid": "workspace:*", + }, + "devDependencies": { + "@biomejs/biome": "^2.4.11", + "@types/bun": "latest", + "typescript": "^5", + }, + }, + "apps/desktop": { + "name": "@axon/desktop-poc", + "version": "0.0.1", + "dependencies": { + "@axon/core": "workspace:*", + "@axon/transport-nodehid": "workspace:*", + "@axon/ui": "workspace:*", + "electrobun": "1.16.0", + }, + "devDependencies": { + "@biomejs/biome": "^2.4.11", + "@types/bun": "latest", + "@types/three": "^0.180.0", + "typescript": "^5.9.3", + }, + }, + "apps/web": { + "name": "@axon/web-poc", + "version": "0.0.1", + "dependencies": { + "@axon/core": "workspace:*", + "@axon/transport-webhid": "workspace:*", + "@axon/ui": "workspace:*", + }, + "devDependencies": { + "@biomejs/biome": "^2.4.11", + "@types/bun": "latest", + "typescript": "^5", + }, + }, + "packages/core": { + "name": "@axon/core", + "version": "0.0.1", + "dependencies": { + "aes-js": "^3.1.2", + }, + "devDependencies": { + "@biomejs/biome": "^2.4.11", + "@types/bun": "latest", + "typescript": "^5", + }, + }, + "packages/transport-nodehid": { + "name": "@axon/transport-nodehid", + "version": "0.0.1", + "dependencies": { + "@axon/core": "workspace:*", + "node-hid": "^3.3.0", + }, + "devDependencies": { + "@biomejs/biome": "^2.4.11", + "@types/bun": "latest", + "typescript": "^5", + }, + }, + "packages/transport-webhid": { + "name": "@axon/transport-webhid", + "version": "0.0.1", + "dependencies": { + "@axon/core": "workspace:*", + "@axon/ui": "workspace:*", + }, + "devDependencies": { + "@biomejs/biome": "^2.4.11", + "@types/bun": "latest", + "typescript": "^5", + }, + }, + "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", + "typescript": "^5", + }, + }, + }, + "packages": { + "@axon/core": ["@axon/core@workspace:packages/core"], + + "@axon/desktop-poc": ["@axon/desktop-poc@workspace:apps/desktop"], + + "@axon/transport-nodehid": ["@axon/transport-nodehid@workspace:packages/transport-nodehid"], + + "@axon/transport-webhid": ["@axon/transport-webhid@workspace:packages/transport-webhid"], + + "@axon/ui": ["@axon/ui@workspace:packages/ui"], + + "@axon/web-poc": ["@axon/web-poc@workspace:apps/web"], + + "@babylonjs/core": ["@babylonjs/core@7.54.3", "", {}, "sha512-P5ncXVd8GEUJLhwloP9V0oVwQYIrvZztguVeLlvd5Rx+9aQnenKjpV8auJ6SRsUlAmNZU4pFTKzwF6o2EUfhAw=="], + + "@biomejs/biome": ["@biomejs/biome@2.4.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.11", "@biomejs/cli-darwin-x64": "2.4.11", "@biomejs/cli-linux-arm64": "2.4.11", "@biomejs/cli-linux-arm64-musl": "2.4.11", "@biomejs/cli-linux-x64": "2.4.11", "@biomejs/cli-linux-x64-musl": "2.4.11", "@biomejs/cli-win32-arm64": "2.4.11", "@biomejs/cli-win32-x64": "2.4.11" }, "bin": { "biome": "bin/biome" } }, "sha512-nWxHX8tf3Opb/qRgZpBbsTOqOodkbrkJ7S+JxJAruxOReaDPPmPuLBAGQ8vigyUgo0QBB+oQltNEAvalLcjggA=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wOt+ed+L2dgZanWyL6i29qlXMc088N11optzpo10peayObBaAshbTcxKUchzEMp9QSY8rh5h6VfAFE3WTS1rqg=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-gZ6zR8XmZlExfi/Pz/PffmdpWOQ8Qhy7oBztgkR8/ylSRyLwfRPSadmiVCV8WQ8PoJ2MWUy2fgID9zmtgUUJmw=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-avdJaEElXrKceK0va9FkJ4P5ci3N01TGkc6ni3P8l3BElqbOz42Wg2IyX3gbh0ZLEd4HVKEIrmuVu/AMuSeFFA=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-+Sbo1OAmlegtdwqFE8iOxFIWLh1B3OEgsuZfBpyyN/kWuqZ8dx9ZEes6zVnDMo+zRHF2wLynRVhoQmV7ohxl2Q=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.11", "", { "os": "linux", "cpu": "x64" }, "sha512-TagWV0iomp5LnEnxWFg4nQO+e52Fow349vaX0Q/PIcX6Zhk4GGBgp3qqZ8PVkpC+cuehRctMf3+6+FgQ8jCEFQ=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.11", "", { "os": "linux", "cpu": "x64" }, "sha512-bexd2IklK7ZgPhrz6jXzpIL6dEAH9MlJU1xGTrypx+FICxrXUp4CqtwfiuoDKse+UlgAlWtzML3jrMqeEAHEhA=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-RJhaTnY8byzxDt4bDVb7AFPHkPcjOPK3xBip4ZRTrN3TEfyhjLRm3r3mqknqydgVTB74XG8l4jMLwEACEeihVg=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.11", "", { "os": "win32", "cpu": "x64" }, "sha512-A8D3JM/00C2KQgUV3oj8Ba15EHEYwebAGCy5Sf9GAjr5Y3+kJIYOiESoqRDeuRZueuMdCsbLZIUqmPhpYXJE9A=="], + + "@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=="], + + "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aalBeSl4agT/QtYGDyf/XLajedWzUC9Vg/pm/YO6QQ93vkQ91Vz5uK1ta5RbVRDozQSz4njxUNqRNmOXDzW+qw=="], + + "@turbo/linux-64": ["@turbo/linux-64@2.9.6", "", { "os": "linux", "cpu": "x64" }, "sha512-YKi05jnNHaD7vevgYwahpzGwbsNNTwzU2c7VZdmdFm7+cGDP4oREUWSsainiMfRqjRuolQxBwRn8wf1jmu+YZA=="], + + "@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-02o/ZS69cOYEDczXvOB2xmyrtzjQ2hVFtWZK1iqxXUfzMmTjZK4UumrfNnjckSg+gqeBfnPRHa0NstA173Ik3g=="], + + "@turbo/windows-64": ["@turbo/windows-64@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-wVdQjvnBI15wB6JrA+43CtUtagjIMmX6XYO758oZHAsCNSxqRlJtdyujih0D8OCnwCRWiGWGI63zAxR0hO6s9g=="], + + "@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-1XUUyWW0W6FTSqGEhU8RHVqb2wP1SPkr7hIvBlMEwH9jr+sJQK5kqeosLJ/QaUv4ecSAd1ZhIrLoW7qslAzT4A=="], + + "@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="], + + "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + + "@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="], + + "@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="], + + "@types/three": ["@types/three@0.180.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": "*", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~0.22.0" } }, "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg=="], + + "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="], + + "@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=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "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=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "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=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "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=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "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=="], + + "netmask": ["netmask@2.1.1", "", {}, "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA=="], + + "node-addon-api": ["node-addon-api@3.2.1", "", {}, "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A=="], + + "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=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "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=="], + + "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "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=="], + + "turbo": ["turbo@2.9.6", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.6", "@turbo/darwin-arm64": "2.9.6", "@turbo/linux-64": "2.9.6", "@turbo/linux-arm64": "2.9.6", "@turbo/windows-64": "2.9.6", "@turbo/windows-arm64": "2.9.6" }, "bin": { "turbo": "bin/turbo" } }, "sha512-+v2QJey7ZUeUiuigkU+uFfklvNUyPI2VO2vBpMYJA+a1hKFLFiKtUYlRHdb3P9CrAvMzi0upbjI4WT+zKtqkBg=="], + + "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=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + } +} diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 9925e53..e54c29e 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -156,8 +156,9 @@ itself, clone and build: ```bash git clone https://github.com/caryden/servo-programmer.git -cd servo-programmer/axon +cd servo-programmer bun install +cd apps/cli bun run build ./bin/axon --version ``` diff --git a/docs/README.md b/docs/README.md index 129cc45..e26b2ef 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,7 +29,13 @@ narrative version of the journey is in [the-adventure.md](the-adventure.md). | Path | What's there | |---|---| -| [../axon/](../axon/) | The axon CLI source code. | +| [../apps/cli/](../apps/cli/) | The axon CLI source code. | +| [../apps/web/](../apps/web/) | The browser WebHID PoC. | +| [../apps/desktop/](../apps/desktop/) | The Electrobun desktop PoC. | +| [../packages/core/](../packages/core/) | Shared transport-agnostic catalog/protocol/error logic. | +| [../packages/ui/](../packages/ui/) | Shared probe UI for the browser and desktop PoCs. | +| [../packages/transport-nodehid/](../packages/transport-nodehid/) | Node/Bun HID transport package. | +| [../packages/transport-webhid/](../packages/transport-webhid/) | Browser WebHID transport package. | | [../data/servo_catalog.json](../data/servo_catalog.json) | Bundled servo metadata catalog. | | [../research/](../research/) | All the artifacts used during reverse engineering: captures, decompiled exe, Python test scripts. | | [../vendor/](../vendor/) | Vendor-supplied files (firmware, sample configs). Not our code. | 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/docs/licenses.md b/docs/licenses.md index debdb33..6205d45 100644 --- a/docs/licenses.md +++ b/docs/licenses.md @@ -5,7 +5,7 @@ in the `servo-programmer` distribution and confirms they are compatible with the project's MIT license. The audit was generated on 2026-04-09 from the dependency tree of -`axon/package.json` at commit `4963ab1`, using `bun pm ls --all`. +`apps/cli/package.json` at commit `4963ab1`, using `bun pm ls --all`. ## Summary @@ -32,7 +32,7 @@ library `hidapi` that ships inside the `node-hid` tarball. `hidapi` is tri-licensed: the user may choose GPL-3.0, a BSD-style license, or the original permissive HIDAPI license. See -`axon/node_modules/node-hid/hidapi/LICENSE.txt`. We use it under the +`node_modules/node-hid/hidapi/LICENSE.txt`. We use it under the **BSD-3-Clause** option, which is MIT-compatible. The BSD-3-Clause attribution clause requires reproducing the copyright notice in binary distributions; the notice will be carried in the third-party notices diff --git a/docs/the-adventure.md b/docs/the-adventure.md index 047ce50..f2f469a 100644 --- a/docs/the-adventure.md +++ b/docs/the-adventure.md @@ -383,7 +383,7 @@ tiny, sudo-free, cross-platform transport. ## 11. Building the CLI -The production client is in [`axon/`](../axon/). Stack: +The production client is in [`apps/cli/`](../apps/cli/). Stack: - **Bun** — fast TypeScript runtime with built-in bundler, produces single-file standalone binaries for Mac, Linux, and Windows. @@ -394,13 +394,13 @@ The production client is in [`axon/`](../axon/). Stack: - **Vitest** — unit tests. The architecture is deliberately boring. A `DongleHandle` interface in -[`axon/src/driver/transport.ts`](../axon/src/driver/transport.ts) has +[`packages/core/src/driver/transport.ts`](../packages/core/src/driver/transport.ts) has three methods: `write`, `read`, `release`. The protocol layer in -[`axon/src/driver/protocol.ts`](../axon/src/driver/protocol.ts) only +[`packages/core/src/driver/protocol.ts`](../packages/core/src/driver/protocol.ts) only sees that interface, with no compile-time dependency on node-hid — so tests inject a `MockDongle` and validate the protocol without touching hardware. The real transport is in -[`axon/src/driver/hid.ts`](../axon/src/driver/hid.ts) and does nothing +[`apps/cli/src/driver/hid.ts`](../apps/cli/src/driver/hid.ts) and does nothing clever: open by VID/PID, write 64-byte output reports, read 64-byte input reports. No `dev.reset()` anywhere — node-hid doesn't even expose a reset primitive, so the footgun is impossible by construction. diff --git a/package.json b/package.json new file mode 100644 index 0000000..36a7f6b --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "servo-programmer", + "private": true, + "type": "module", + "packageManager": "bun@1.3.9", + "workspaces": [ + "apps/*", + "packages/*" + ], + "scripts": { + "build": "turbo run build", + "check": "turbo run check", + "typecheck": "turbo run typecheck", + "test": "turbo run test", + "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/README.md b/packages/core/README.md new file mode 100644 index 0000000..9c064c7 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,17 @@ +# @axon/core + +Shared transport-agnostic Axon logic. + +This package is the first extraction step toward the eventual monorepo +layout. It holds code that should be reusable across: + +- the CLI +- the desktop app +- a future browser/WebHID app + +Current contents: + +- catalog/model helpers +- shared typed errors +- transport abstraction +- transport-agnostic wire/HID protocol helpers diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..5a24958 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,30 @@ +{ + "name": "@axon/core", + "version": "0.0.1", + "private": true, + "type": "module", + "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" + }, + "scripts": { + "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", + "typescript": "^5" + } +} diff --git a/axon/src/catalog.ts b/packages/core/src/catalog.ts similarity index 78% rename from axon/src/catalog.ts rename to packages/core/src/catalog.ts index b49479f..3c66bd7 100644 --- a/axon/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -1,18 +1,12 @@ /** - * Loads the bundled servo_catalog.json (embedded at build time by Bun's - * JSON import support) and exposes helpers to look up a model from the - * raw 8-byte model id bytes at config offset 0x40..0x47. + * Loads the bundled servo_catalog.json and exposes helpers to look up a + * model from the raw 8-byte model id bytes at config offset 0x40..0x47. * - * Schema version 2 introduces an audience-separated structure: every - * parameter has user-facing top-level fields (vendor_label, description, - * unit, modes, min, max, values, docs_url) and a separate - * `implementation` nested object. Loaders here preserve both layers; - * CLI rendering MUST only read the user-facing fields. + * This module is transport-agnostic and should be shared across CLI, + * desktop, and future browser surfaces. */ -// Bun embeds this JSON into the compiled binary via `bun build --compile`. -// At dev time it's just a relative import. -import catalogJson from "../../data/servo_catalog.json" with { type: "json" }; +import catalogJson from "../../../data/servo_catalog.json" with { type: "json" }; export interface ParameterImplementation { offset?: string; @@ -75,11 +69,6 @@ export interface BundledFirmware { description: string; } -/** - * Per-model default for a parameter. Can be a primitive (e.g. "normal" - * for the inversion enum) or a structured `{raw, …}` object whose - * user-facing fields live alongside a nested `implementation`. - */ export type ModelDefaultValue = | string | number @@ -111,10 +100,6 @@ export interface Catalog { version: string; source_docs: string; models: Map; - /** - * Raw parameter entries straight out of the JSON. Prefer - * `loadParameters()` for typed access. - */ parameters: Record; } @@ -132,28 +117,18 @@ let cachedParameters: CatalogParameterSpec[] | undefined; let cachedServoModes: ServoModeSpec[] | undefined; let cachedNotYetMapped: Record | undefined; -/** - * Parse the raw 8-byte model id from config offset 0x40..0x47 into a - * clean ASCII string. Trailing '*' bytes are padding in the vendor - * format; we preserve them in the model id so that SA33*** matches - * exactly what the vendor exe stores. Null bytes are stripped. - */ export function parseModelId(bytes: Uint8Array): string { if (bytes.length !== 8) { throw new Error(`model id must be exactly 8 bytes, got ${bytes.length}`); } let s = ""; for (const b of bytes) { - if (b === 0x00) break; // null-terminated padding + if (b === 0x00) break; s += String.fromCharCode(b); } return s; } -/** - * Build the in-memory catalog from the bundled JSON. Placeholder entries - * in the JSON (keys starting with "_") are filtered out. - */ export function loadCatalog(): Catalog { if (cachedCatalog) return cachedCatalog; const models = new Map(); @@ -192,15 +167,6 @@ export function findModel(catalog: Catalog, modelId: string): ServoModel | undef return catalog.models.get(modelId); } -// --------------------------------------------------------------------- -// Schema v2 helpers: parameters, servo modes, _not_yet_mapped -// --------------------------------------------------------------------- - -/** - * Return the set of schema-v2 parameter entries (user-facing fields + - * the nested implementation object). Entries with "_"-prefixed keys - * are filtered out. - */ export function loadParameters(): CatalogParameterSpec[] { if (cachedParameters) return cachedParameters; const out: CatalogParameterSpec[] = []; 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/axon/src/driver/flash.ts b/packages/core/src/driver/flash.ts similarity index 92% rename from axon/src/driver/flash.ts rename to packages/core/src/driver/flash.ts index 8fccff3..992c5d4 100644 --- a/axon/src/driver/flash.ts +++ b/packages/core/src/driver/flash.ts @@ -78,12 +78,25 @@ * 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 { 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. */ @@ -110,6 +123,8 @@ 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; @@ -206,21 +221,20 @@ async function exchange( 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(); - if (process.env.AXON_DEBUG === "1") { + 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(" "); - process.stderr.write( - ` [wire] cmd=0x${cmd.toString(16)} tx[0..7]: ${txHex} rx[0..15]: ${rxHex}\n`, - ); + onWireDebug(`cmd=0x${cmd.toString(16)} tx[0..7]: ${txHex} rx[0..15]: ${rxHex}`); } return parseFlashRx(rx, expectLength); } @@ -231,14 +245,14 @@ async function exchange( * 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 { +function fillRandom(buf: Uint8Array, fromIndex: number): void { if (fromIndex >= buf.length) return; - const rnd = randomBytes(buf.length - fromIndex); - rnd.copy(buf, fromIndex); + 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: Buffer, key: Uint8Array): void { +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})`); } @@ -248,8 +262,8 @@ function xorInPlace(buf: Buffer, key: Uint8Array): void { // ---- 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); +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; @@ -266,7 +280,7 @@ export function buildSectorEraseBuf(sectorBytes: [number, number]): Buffer { * the raw byte the dongle returns. */ export function buildHexRecordBuf(record: IntelHexRecord): { - buf: Buffer; + buf: Uint8Array; expectedReply: number; } { // count + 2 addr + 1 type + count data + 1 checksum = count + 5 bytes @@ -278,7 +292,7 @@ export function buildHexRecordBuf(record: IntelHexRecord): { `flash: hex record has count=${record.count}, max is ${MAX_HEX_RECORD_DATA_BYTES} bytes`, ); } - const buf = Buffer.alloc(FLASH_PAYLOAD_SIZE); + const buf = new Uint8Array(FLASH_PAYLOAD_SIZE); buf[0] = DATA_PREFIX_HEX_RECORD; buf[1] = record.count & 0xff; buf[2] = (record.address >> 8) & 0xff; @@ -297,6 +311,8 @@ export function buildHexRecordBuf(record: IntelHexRecord): { 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 @@ -388,6 +404,7 @@ export async function flashFirmware( 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; @@ -440,12 +457,12 @@ export async function flashFirmware( 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 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(" "); - process.stderr.write(` [wire] 0x80 boot rx[0..15]: ${hex}\n`); + wireDebug(`0x80 boot rx[0..15]: ${hex}`); } const bootReply = parseFlashRx(bootRx, 6); if (bootReply[0] !== 0x56) { @@ -490,9 +507,16 @@ export async function flashFirmware( // 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); + 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); } @@ -514,7 +538,7 @@ export async function flashFirmware( 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); + const reply = await exchange(handle, CMD_DATA_WRITE, buf, 1, sleepMs, wireDebug); if (reply[0] !== 0x55) { const replyByte = reply[0] ?? 0; throw AxonError.servoIo( @@ -536,7 +560,7 @@ export async function flashFirmware( 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); + 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 @@ -563,12 +587,12 @@ export async function flashFirmware( // 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); + 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); + 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. diff --git a/packages/core/src/driver/index.ts b/packages/core/src/driver/index.ts new file mode 100644 index 0000000..5a6e779 --- /dev/null +++ b/packages/core/src/driver/index.ts @@ -0,0 +1,3 @@ +export * from "./flash.ts"; +export * from "./protocol.ts"; +export * from "./transport.ts"; diff --git a/axon/src/driver/protocol.ts b/packages/core/src/driver/protocol.ts similarity index 62% rename from axon/src/driver/protocol.ts rename to packages/core/src/driver/protocol.ts index dfd701d..96a42a6 100644 --- a/axon/src/driver/protocol.ts +++ b/packages/core/src/driver/protocol.ts @@ -1,50 +1,33 @@ /** - * Axon dongle HID protocol — identify, read config, write config. + * Shared Axon identify/read/write protocol helpers. * - * The dongle is a transparent proxy to a Dynamixel-v1-like wire - * protocol at 9600 baud. HID command bytes (0x8A identify, 0xCD - * read, 0xCB write) are the same bytes that appear on the wire as - * the INSTR field. The HID reply contains the raw bytes from the - * servo's wire reply at rx[5..5+N], with rx[0]=0x04 (report id), - * rx[1]=status-hi (0x01=OK), rx[2]=status-lo (0x00=OK, 0xFA=no - * servo, 0x02=nack), rx[3]=addr echo, rx[4]=length echo. - * - * See docs/wire-protocol.md for the full specification (the - * "Wire format" and "HID reply format" sections cover the - * derivation), and - * research/static-analysis/ghidra_out/param_helper_READ_004047d0_FUN_004047d0.c - * for the vendor exe's equivalent function. Notable constants taken - * from that decomp: - * - * - max chunk size = 59 bytes (0x3B) - * - minimum inter-chunk sleep = 25 ms (Sleep(0x19)) - * - read success gate: rx[1] != 0 AND rx[2] == 0 - * - write success gate: rx[1] != 0 (rx[2] is NOT checked on write) + * This layer is intentionally transport-agnostic. The only dependency is + * the `DongleHandle` abstraction. */ import { AxonError } from "../errors.ts"; -import { REPORT_SIZE } from "./hid.ts"; import type { DongleHandle } from "./transport.ts"; +export const REPORT_SIZE = 64; export const CMD_IDENTIFY = 0x8a; export const CMD_READ = 0xcd; export const CMD_WRITE = 0xcb; export const REPORT_ID = 0x04; export const CONFIG_BLOCK_SIZE = 95; -export const MAX_CHUNK = 0x3b; // 59 +export const MAX_CHUNK = 0x3b; export const CHUNK_SLEEP_MS = 25; -export const WIRE_REPLY_SETTLE_MS = 80; // empirically adequate for 9600 baud +export const WIRE_REPLY_SETTLE_MS = 80; export type ServoMode = "servo_mode" | "cr_mode" | "unknown"; export interface IdentifyReply { present: boolean; rawRx: Buffer; - statusHi: number; // rx[1]: 0x01 ok, other values are command/NACK echoes - statusLo: number; // rx[2]: 0x00 ok, 0xFA no servo, etc. - modeByte: number | null; // rx[5] when present - mode: ServoMode; // from rx[5]: 0x03 → servo_mode, 0x04 → cr_mode + statusHi: number; + statusLo: number; + modeByte: number | null; + mode: ServoMode; } function buildTx(cmd: number, addr: number, length: number): Buffer { @@ -77,11 +60,6 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -/** - * Send one identify (`0x8A`) and classify the reply. Does NOT throw - * when the servo reports absent — returns `present: false` so - * callers like the monitor can handle transitions themselves. - */ export async function identify(handle: DongleHandle): Promise { const tx = buildTx(CMD_IDENTIFY, 0x00, 0x04); await handle.write(tx); @@ -93,9 +71,6 @@ export async function identify(handle: DongleHandle): Promise { const statusHi = rx[1] ?? 0x00; const statusLo = rx[2] ?? 0xff; - // rx[2]=0xFA is the canonical "no servo" response. Do not - // collapse every malformed reply into this state; callers need to - // distinguish an absent servo from transport/protocol weirdness. if (statusLo === 0xfa) { return { present: false, @@ -128,12 +103,6 @@ function hexPrefix(buf: Buffer, count: number): string { .join(" "); } -/** - * Read up to MAX_CHUNK bytes from the given config-block address. - * Throws `AxonError.notPrimed()` if the dongle is in cold state - * (the most common recoverable failure) or `AxonError.servoIo()` - * for any other unexpected failure. - */ export async function readChunk( handle: DongleHandle, addr: number, @@ -152,8 +121,6 @@ export async function readChunk( throw AxonError.servoIo(`read reply is ${rx.length} bytes, need at least ${5 + length}`); } if (rx[1] !== 0x01 || rx[2] !== 0x00) { - // Distinguish "no servo" from other failures so the user gets a - // useful recovery hint. if (rx[2] === 0xfa) throw AxonError.notPrimed(); const statusHi = rx[1] ?? 0; const statusLo = rx[2] ?? 0; @@ -164,25 +131,15 @@ export async function readChunk( return rx.subarray(5, 5 + length); } -/** - * Read the full 95-byte config block, transparently handling the - * two-chunk (59 + 36) split the dongle requires. - */ export async function readFullConfig(handle: DongleHandle): Promise { const chunk0 = await readChunk(handle, 0x00, MAX_CHUNK); const chunk1 = await readChunk(handle, MAX_CHUNK, CONFIG_BLOCK_SIZE - MAX_CHUNK); const full = Buffer.alloc(CONFIG_BLOCK_SIZE); - chunk0.copy(full, 0); - chunk1.copy(full, MAX_CHUNK); + full.set(chunk0, 0); + full.set(chunk1, MAX_CHUNK); return full; } -/** - * Write up to MAX_CHUNK bytes to the given config-block address. - * Write success gate per the vendor exe decomp is `rx[1] != 0` - * only — rx[2] is not checked on writes, consistent with our wire - * observation that writes are fire-and-forget on the 1-wire link. - */ export async function writeChunk( handle: DongleHandle, addr: number, @@ -205,10 +162,6 @@ export async function writeChunk( } } -/** - * Write the full 95-byte config block in two chunks matching the - * vendor exe's pattern. - */ export async function writeFullConfig(handle: DongleHandle, config: Uint8Array): Promise { if (config.length !== CONFIG_BLOCK_SIZE) { throw new Error(`writeFullConfig: expected ${CONFIG_BLOCK_SIZE} bytes, got ${config.length}`); @@ -217,10 +170,6 @@ export async function writeFullConfig(handle: DongleHandle, config: Uint8Array): await writeChunk(handle, MAX_CHUNK, config.subarray(MAX_CHUNK, CONFIG_BLOCK_SIZE)); } -/** - * Parse the model id from a full 95-byte config block. Model id - * lives at offset 0x40..0x47 as ASCII with '*' padding. - */ export function modelIdFromConfig(config: Uint8Array): string { let s = ""; for (let i = 0x40; i < 0x48; i++) { diff --git a/packages/core/src/driver/transport.ts b/packages/core/src/driver/transport.ts new file mode 100644 index 0000000..e168296 --- /dev/null +++ b/packages/core/src/driver/transport.ts @@ -0,0 +1,11 @@ +/** + * Transport abstraction for talking to the Axon dongle. + * + * This should be implementable by node-hid, WebHID, or test doubles. + */ + +export interface DongleHandle { + write(data: Buffer, timeoutMs?: number): Promise; + read(timeoutMs?: number): Promise; + release(): Promise; +} diff --git a/axon/src/errors.ts b/packages/core/src/errors.ts similarity index 54% rename from axon/src/errors.ts rename to packages/core/src/errors.ts index d55cdfc..9eefbc9 100644 --- a/axon/src/errors.ts +++ b/packages/core/src/errors.ts @@ -1,7 +1,8 @@ /** - * Exit codes and typed errors for the Axon CLI. + * Shared exit codes and typed errors. * - * See docs/CLI_DESIGN.md "Exit codes" section for the contract. + * The CLI consumes the exit codes directly. Other surfaces can still use + * the stable categories and human-facing recovery hints. */ export const ExitCode = { @@ -17,26 +18,16 @@ export const ExitCode = { export type ExitCodeName = keyof typeof ExitCode; export type ExitCodeValue = (typeof ExitCode)[ExitCodeName]; -/** - * Stable machine-readable category tag for errors that come back - * through the CLI's --json mode. Coding agents (and scripts) should - * branch on this field rather than parsing the human-readable - * message or hint. - * - * The category is a specific *state observation*, not a free-form - * error code. Each value corresponds to exactly one recoverable - * situation and exactly one suggested recovery. - */ export type ErrorCategory = - | "no_adapter" // adapter not on USB - | "adapter_busy" // enumerates but the HID open failed (positive observation) - | "adapter_io" // HID write or read on an open handle failed (observation, not cause) - | "no_servo" // adapter OK, identify returns rx[2]=0xFA - | "servo_io" // mid-transaction servo I/O error (lost during read/write after identify) - | "unknown_model" // servo present but its model id isn't in the catalog - | "validation" // user input out of range, bad file, etc. - | "usage" // CLI argument problem - | "internal"; // bug in our code — report with the stack on AXON_DEBUG=1 + | "no_adapter" + | "adapter_busy" + | "adapter_io" + | "no_servo" + | "servo_io" + | "unknown_model" + | "validation" + | "usage" + | "internal"; export class AxonError extends Error { public readonly category: ErrorCategory; @@ -52,11 +43,6 @@ export class AxonError extends Error { this.category = category; } - // -- USB / HID transport layer --------------------------------------------- - - /** - * Nothing matching VID 0471 PID 13AA on USB. - */ static noAdapter(detail: string): AxonError { return new AxonError( ExitCode.DongleNotFound, @@ -66,11 +52,6 @@ export class AxonError extends Error { ); } - /** - * The adapter enumerated on USB but `new HID.HID(path)` threw. - * Surface the raw OS error verbatim so the actual cause (EACCES, - * device in use, etc.) is visible rather than papered over. - */ static adapterBusy(detail: string): AxonError { return new AxonError( ExitCode.DongleNotFound, @@ -80,12 +61,6 @@ export class AxonError extends Error { ); } - /** - * HID write or read on an already-open handle failed. This is a - * pure observation — we do not speculate about the cause (stale - * handle, hot-swap, OS-level weirdness, etc.). The raw OS error is - * included verbatim so the user has something concrete to chase. - */ static adapterIo(detail: string): AxonError { return new AxonError( ExitCode.ServoIoError, @@ -95,11 +70,6 @@ export class AxonError extends Error { ); } - /** - * Adapter is connected, HID transport is fine, and identify - * returned a well-formed reply saying no servo is attached - * (rx[2]=0xFA). - */ static noServo(detail = "Adapter connected, but no servo detected."): AxonError { return new AxonError( ExitCode.NotPrimed, @@ -109,10 +79,6 @@ export class AxonError extends Error { ); } - /** - * Mid-transaction I/O failure: identify said present but a - * subsequent read/write got an unexpected reply. - */ static servoIo(detail: string): AxonError { return new AxonError( ExitCode.ServoIoError, @@ -122,8 +88,6 @@ export class AxonError extends Error { ); } - // -- Catalog / model layer ------------------------------------------------- - static unknownModel(modelId: string): AxonError { return new AxonError( ExitCode.UnknownModel, @@ -133,8 +97,6 @@ export class AxonError extends Error { ); } - // -- User / CLI layer ------------------------------------------------------ - static validation(detail: string): AxonError { return new AxonError(ExitCode.ValidationError, detail, undefined, "validation"); } @@ -143,8 +105,6 @@ export class AxonError extends Error { return new AxonError(ExitCode.UsageError, detail, undefined, "usage"); } - // -- Compatibility aliases (kept for source compat with existing callers) -- - static dongleNotFound(detail: string): AxonError { return AxonError.noAdapter(detail); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..c033fb6 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +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/axon/src/sfw.ts b/packages/core/src/sfw.ts similarity index 90% rename from axon/src/sfw.ts rename to packages/core/src/sfw.ts index a4df9b4..d779834 100644 --- a/axon/src/sfw.ts +++ b/packages/core/src/sfw.ts @@ -23,11 +23,14 @@ * tolerate both line endings on parse. */ -import { createDecipheriv, createHash } from "node:crypto"; +/// + +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 = Buffer.from("TTTTTTTTTTTTTTTT", "ascii"); -const IV_ZERO = Buffer.alloc(16, 0); +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. */ @@ -94,15 +97,13 @@ function ensureAligned(ciphertext: Buffer): void { } function decryptEcb16(block: Buffer): Buffer { - const decipher = createDecipheriv("aes-128-ecb", KEY, null); - decipher.setAutoPadding(false); - return Buffer.concat([decipher.update(block), decipher.final()]); + const mode = new aesjs.ModeOfOperation.ecb(KEY); + return Buffer.from(mode.decrypt(Uint8Array.from(block))); } 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()]); + const mode = new aesjs.ModeOfOperation.cbc(KEY, IV_ZERO); + return Buffer.from(mode.decrypt(Uint8Array.from(body))); } /** @@ -194,7 +195,7 @@ export function decryptSfw(ciphertext: Buffer): DecryptedSfw { const header = decryptEcb16(ciphertext.subarray(0, 16)); const declaredLength = header.readUInt32LE(0); const magic = header.subarray(4, 16); - if (!magic.equals(MAGIC)) { + if (magic.toString("hex") !== MAGIC.toString("hex")) { throw new Error( `sfw: header magic is ${magic.toString("hex")}, expected ${MAGIC.toString("hex")}`, ); @@ -278,14 +279,3 @@ export function decryptSfw(ciphertext: Buffer): DecryptedSfw { hexRecords, }; } - -/** Compute the SHA-256 hash of a .sfw file as a lowercase hex string. */ -export function sfwHashHex(ciphertext: Buffer): string { - return createHash("sha256").update(ciphertext).digest("hex"); -} - -/** Compare a .sfw file's SHA-256 against a known-good hex digest. */ -export function verifySfwHash(ciphertext: Buffer, expectedSha256: string): boolean { - const actual = sfwHashHex(ciphertext); - return actual.toLowerCase() === expectedSha256.toLowerCase(); -} 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/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..5e20d9b --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": ["bun"] + }, + "include": ["src/**/*"] +} diff --git a/packages/transport-nodehid/package.json b/packages/transport-nodehid/package.json new file mode 100644 index 0000000..b1ce855 --- /dev/null +++ b/packages/transport-nodehid/package.json @@ -0,0 +1,23 @@ +{ + "name": "@axon/transport-nodehid", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + ".": "./src/hid.ts", + "./nodehid": "./src/nodehid.ts" + }, + "scripts": { + "check": "bun x @biomejs/biome check src package.json", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@axon/core": "workspace:*", + "node-hid": "^3.3.0" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.11", + "@types/bun": "latest", + "typescript": "^5" + } +} diff --git a/axon/src/driver/hid.ts b/packages/transport-nodehid/src/hid.ts similarity index 97% rename from axon/src/driver/hid.ts rename to packages/transport-nodehid/src/hid.ts index c577211..c83b38c 100644 --- a/axon/src/driver/hid.ts +++ b/packages/transport-nodehid/src/hid.ts @@ -18,10 +18,10 @@ * was a libusb-era footgun. */ -import { AxonError } from "../errors.ts"; +import type { DongleHandle } from "@axon/core/driver/transport"; +import { AxonError } from "@axon/core/errors"; import type { NodeHidDevice, NodeHidHandle } from "./nodehid.ts"; import { getNodeHidBinding } from "./nodehid.ts"; -import type { DongleHandle } from "./transport.ts"; export const VID = 0x0471; export const PID = 0x13aa; @@ -99,7 +99,7 @@ class NodeHidDongle implements DongleHandle { throw hidWriteError(`buffer is ${data.length} bytes, max is ${REPORT_SIZE}`); } const padded = Buffer.alloc(REPORT_SIZE); - data.copy(padded, 0); + padded.set(data, 0); try { const n = this.device.write(Array.from(padded)); if (n <= 0) { diff --git a/axon/src/driver/nodehid.ts b/packages/transport-nodehid/src/nodehid.ts similarity index 77% rename from axon/src/driver/nodehid.ts rename to packages/transport-nodehid/src/nodehid.ts index 643710c..c821c60 100644 --- a/axon/src/driver/nodehid.ts +++ b/packages/transport-nodehid/src/nodehid.ts @@ -41,15 +41,15 @@ let cachedBinding: NodeHidBinding | undefined; function loadBindingForCurrentPlatform(): NodeHidBinding { switch (`${process.platform}/${process.arch}`) { case "darwin/arm64": - return require("../../node_modules/node-hid/prebuilds/HID-darwin-arm64/node-napi-v4.node"); + return require("../node_modules/node-hid/prebuilds/HID-darwin-arm64/node-napi-v4.node"); case "darwin/x64": - return require("../../node_modules/node-hid/prebuilds/HID-darwin-x64/node-napi-v4.node"); + return require("../node_modules/node-hid/prebuilds/HID-darwin-x64/node-napi-v4.node"); case "linux/arm64": - return require("../../node_modules/node-hid/prebuilds/HID_hidraw-linux-arm64/node-napi-v4.node"); + return require("../node_modules/node-hid/prebuilds/HID_hidraw-linux-arm64/node-napi-v4.node"); case "linux/x64": - return require("../../node_modules/node-hid/prebuilds/HID_hidraw-linux-x64/node-napi-v4.node"); + return require("../node_modules/node-hid/prebuilds/HID_hidraw-linux-x64/node-napi-v4.node"); case "win32/x64": - return require("../../node_modules/node-hid/prebuilds/HID-win32-x64/node-napi-v4.node"); + return require("../node_modules/node-hid/prebuilds/HID-win32-x64/node-napi-v4.node"); default: throw new Error( `Unsupported platform for embedded node-hid prebuild: ${process.platform}/${process.arch}`, diff --git a/packages/transport-nodehid/tsconfig.json b/packages/transport-nodehid/tsconfig.json new file mode 100644 index 0000000..5e20d9b --- /dev/null +++ b/packages/transport-nodehid/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": ["bun"] + }, + "include": ["src/**/*"] +} diff --git a/packages/transport-webhid/package.json b/packages/transport-webhid/package.json new file mode 100644 index 0000000..57fccf6 --- /dev/null +++ b/packages/transport-webhid/package.json @@ -0,0 +1,22 @@ +{ + "name": "@axon/transport-webhid", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "check": "bun x @biomejs/biome check src package.json", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@axon/core": "workspace:*", + "@axon/ui": "workspace:*" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.11", + "@types/bun": "latest", + "typescript": "^5" + } +} diff --git a/packages/transport-webhid/src/index.ts b/packages/transport-webhid/src/index.ts new file mode 100644 index 0000000..0fbbec6 --- /dev/null +++ b/packages/transport-webhid/src/index.ts @@ -0,0 +1,179 @@ +import type { DongleHandle } from "@axon/core/driver/transport"; +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")}`; +} + +function summarizeCollections(device: HIDDevice): ProbeCollectionInfo[] { + return device.collections.map((collection) => ({ + usagePage: hex(collection.usagePage, 4), + usage: hex(collection.usage, 4), + inputReports: collection.inputReports.map((report) => report.reportId), + outputReports: collection.outputReports.map((report) => report.reportId), + featureReports: collection.featureReports.map((report) => report.reportId), + })); +} + +export function webHidSupported(): boolean { + return typeof navigator !== "undefined" && "hid" in navigator; +} + +export function deviceId(device: HIDDevice): string { + return `${hex(device.vendorId, 4)}:${hex(device.productId, 4)}:${device.productName}`; +} + +export function summarizeDevice(device: HIDDevice): ProbeDeviceInfo { + return { + id: deviceId(device), + vendorId: hex(device.vendorId, 4), + productId: hex(device.productId, 4), + product: device.productName, + manufacturer: null, + serialNumber: null, + interface: null, + usagePage: null, + usage: null, + opened: device.opened, + collections: summarizeCollections(device), + }; +} + +function axonFilter(device: HIDDevice): boolean { + return device.vendorId === VID && device.productId === PID; +} + +export async function listAuthorizedAxonDevices(): Promise { + if (!webHidSupported()) { + throw new Error("WebHID is not available in this browser."); + } + const devices = await navigator.hid.getDevices(); + return devices.filter(axonFilter); +} + +export async function requestAxonDevices(): Promise { + if (!webHidSupported()) { + throw new Error("WebHID is not available in this browser."); + } + return navigator.hid.requestDevice({ + filters: [{ vendorId: VID, productId: PID }], + }); +} + +export class WebHidDongle implements DongleHandle { + private readonly replies: InputReportReply[] = []; + private pendingRead: PendingRead | null = null; + private readonly onInputReport: EventListener; + + constructor(private readonly device: HIDDevice) { + this.onInputReport = (event) => { + const hidEvent = event as HIDInputReportEvent; + + if (hidEvent.device !== this.device) { + return; + } + + const reply = { + reportId: hidEvent.reportId, + data: new Uint8Array( + hidEvent.data.buffer.slice( + hidEvent.data.byteOffset, + hidEvent.data.byteOffset + hidEvent.data.byteLength, + ), + ), + }; + + // Some browser/WebHID stacks surface our own outbound report payload back + // through `inputreport`. Those frames start with the command byte because + // the report ID is delivered separately. Ignore them so protocol reads only + // see device replies. + const echoedCommand = reply.data[0]; + if (echoedCommand !== undefined && ECHO_COMMANDS.has(echoedCommand)) { + return; + } + + if (this.pendingRead) { + const pending = this.pendingRead; + this.pendingRead = null; + window.clearTimeout(pending.timer); + pending.resolve(reply); + return; + } + + this.replies.push(reply); + }; + + this.device.addEventListener("inputreport", this.onInputReport); + } + + async write(data: Buffer): Promise { + if (!this.device.opened) { + throw new Error("Selected HID device is not open."); + } + + const reportId = data[0] ?? 0x00; + const payload = new Uint8Array(data.subarray(1, REPORT_SIZE)); + await this.device.sendReport(reportId, payload); + } + + async read(timeoutMs = 1000): Promise { + if (!this.device.opened) { + throw new Error("Selected HID device is not open."); + } + + 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 { + if (!device.opened) { + await device.open(); + } + + return new WebHidDongle(device); +} diff --git a/packages/transport-webhid/src/webhid.d.ts b/packages/transport-webhid/src/webhid.d.ts new file mode 100644 index 0000000..1930df2 --- /dev/null +++ b/packages/transport-webhid/src/webhid.d.ts @@ -0,0 +1,58 @@ +interface HIDReportInfo { + reportId: number; +} + +interface HIDCollectionInfo { + usagePage: number; + usage: number; + inputReports: HIDReportInfo[]; + outputReports: HIDReportInfo[]; + featureReports: HIDReportInfo[]; +} + +interface HIDConnectionEvent extends Event { + device: HIDDevice; +} + +interface HIDInputReportEvent extends Event { + device: HIDDevice; + reportId: number; + data: DataView; +} + +interface HIDDevice extends EventTarget { + vendorId: number; + productId: number; + productName: string; + opened: boolean; + collections: HIDCollectionInfo[]; + open(): Promise; + close(): Promise; + sendReport(reportId: number, data: BufferSource): Promise; + addEventListener( + type: "inputreport", + listener: (event: HIDInputReportEvent) => void, + options?: boolean | AddEventListenerOptions, + ): void; + removeEventListener( + type: "inputreport", + listener: (event: HIDInputReportEvent) => void, + options?: boolean | EventListenerOptions, + ): void; +} + +interface HID { + getDevices(): Promise; + requestDevice(options: { + filters: Array<{ vendorId?: number; productId?: number }>; + }): Promise; + addEventListener( + type: "disconnect", + listener: (event: HIDConnectionEvent) => void, + options?: boolean | AddEventListenerOptions, + ): void; +} + +interface Navigator { + hid: HID; +} diff --git a/packages/transport-webhid/tsconfig.json b/packages/transport-webhid/tsconfig.json new file mode 100644 index 0000000..94bf23f --- /dev/null +++ b/packages/transport-webhid/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": ["bun"] + }, + "include": ["src/**/*"] +} diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000..383ad30 --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,22 @@ +{ + "name": "@axon/ui", + "version": "0.0.1", + "private": true, + "type": "module", + "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" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.11", + "@types/bun": "latest", + "typescript": "^5" + } +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts new file mode 100644 index 0000000..d55e376 --- /dev/null +++ b/packages/ui/src/index.ts @@ -0,0 +1 @@ +export * from "./probe.ts"; diff --git a/packages/ui/src/probe.ts b/packages/ui/src/probe.ts new file mode 100644 index 0000000..12ec738 --- /dev/null +++ b/packages/ui/src/probe.ts @@ -0,0 +1,5412 @@ +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"; + +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; + vendorId: string | null; + productId: string | null; + product: string | null; + manufacturer: string | null; + serialNumber: string | null; + interface: number | null; + usagePage: string | null; + usage: string | null; + 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; +} + +export interface ProbeIdentifyInfo { + present: boolean; + statusHi: string; + statusLo: string; + modeByte: string | null; + 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; +} + +interface ProbeAction { + label: string; + run: () => Promise; +} + +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; + loadEnvironment: () => Promise; + loadInventory: () => Promise; + 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 + >; +} + +interface LogEntry { + timestamp: string; + message: string; + level: "status" | "debug"; +} + +type ServoMode = "servo_mode" | "cr_mode"; +type LossBehavior = "release" | "hold" | "neutral"; +type Direction = "cw" | "ccw"; +type SensitivityLabel = "Ultra High" | "High" | "Medium" | "Low" | "Very Low"; + +interface OverloadLevel { + pct: number; + sec: number; +} + +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; +} + +interface SimState { + pwmUs: number; + voltage: number; + loadKgcm: number; + sweepEnabled: boolean; + playbackRunning: boolean; + servoSweepPhase: number; + crAngleDeg: number; +} + +interface ConfigCheckpoint { + id: string; + createdAt: string; + label: string; + modelId: string | null; + modelName: string | null; + draft: DraftConfig; +} + +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; +} + +type EmptyStateKind = "adapter" | "servo" | null; + +interface CurvePoint { + torqueKgcm: number; + speedRpm: number; + currentA: number; + mechanicalPowerW: number; + electricalPowerW: number; + heatW: number; + efficiency: number; +} + +interface OperatingPoint extends CurvePoint { + pwmUs: number; + hornAngleDeg: number; + minAngleDeg: number; + maxAngleDeg: number; + effectiveVoltage: number; + commandMagnitude: number; + commandSigned: number; +} + +interface ChartPoint { + x: number; + y: number; +} + +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)); +} + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + +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 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 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"); + } + 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 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 { + 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 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"], + }; +} + +function draftKey(draft: DraftConfig | null): string | null { + if (!draft) return null; + return JSON.stringify(draft); +} + +function isDirty(baseline: DraftConfig | null, draft: DraftConfig): boolean { + return draftKey(baseline) !== null && draftKey(baseline) !== draftKey(draft); +} + +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 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 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 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; + + 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); + } + + 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}.`); + } + + 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; + } + } + + 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; + } + } + } + + 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; +} + +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, + }); + } + + 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, + }, + }; +} + +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`, + }, + ]; + + 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: "—", + }, + ); + } + + 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: "—", + }, + ); + } + + return { rows }; +} + +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)), + })); +} + +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(); +} diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json new file mode 100644 index 0000000..94bf23f --- /dev/null +++ b/packages/ui/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": ["bun"] + }, + "include": ["src/**/*"] +} diff --git a/research/README.md b/research/README.md index 7c02bb7..3418e82 100644 --- a/research/README.md +++ b/research/README.md @@ -3,7 +3,7 @@ This directory holds **everything we did to figure out how the Axon servo programmer works**, kept here for reproducibility and so the [blog post](../docs/the-adventure.md) has receipts. None of these -files are imported by the production CLI in [`../axon/`](../axon/) — +files are imported by the production CLI in [`../apps/cli/`](../apps/cli/) — they're archaeology. ## What's in each subdirectory @@ -48,3 +48,9 @@ The chronological path is: The full first-person narrative — with the dead ends, the "aha" moments, and the reflections on doing this with an agent — is in [`docs/the-adventure.md`](../docs/the-adventure.md). + +Two artifacts that began as research have now graduated into active app +packages: + +- [`../apps/web/`](../apps/web/) — the browser WebHID PoC +- [`../apps/desktop/`](../apps/desktop/) — the Electrobun desktop PoC diff --git a/research/python-tests/README.md b/research/python-tests/README.md index 2e3f307..5cac7e6 100644 --- a/research/python-tests/README.md +++ b/research/python-tests/README.md @@ -2,7 +2,7 @@ Every Python script we wrote during the reverse-engineering session, in roughly the order we wrote them. None are imported by the -production CLI in `../axon/` — these are diagnostic / experiment +production CLI in `../apps/cli/` — these are diagnostic / experiment scripts kept for reference and reproducibility. ## What each script proved (or disproved) diff --git a/research/sudoers/uninstall_sudoers.sh b/research/sudoers/uninstall_sudoers.sh index d7a1960..fed583b 100755 --- a/research/sudoers/uninstall_sudoers.sh +++ b/research/sudoers/uninstall_sudoers.sh @@ -33,6 +33,6 @@ fi echo " $DEST is gone." echo echo "Sanity check — the axon CLI should now work without sudo:" -echo " cd axon && bun run src/cli.ts status" +echo " cd apps/cli && bun run src/cli.ts status" echo echo "If that works, the libusb-era sudoers setup is fully gone." diff --git a/scripts/build-release.sh b/scripts/build-release.sh index e9bb2ed..1f09a6c 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -12,12 +12,12 @@ workflow matrix to build all platforms. Examples: scripts/build-release.sh scripts/build-release.sh --target bun-darwin-arm64 - scripts/build-release.sh --target bun-linux-x64 --outfile axon/dist/axon-linux-x64 + scripts/build-release.sh --target bun-linux-x64 --outfile apps/cli/dist/axon-linux-x64 EOF } repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -axon_dir="${repo_root}/axon" +cli_dir="${repo_root}/apps/cli" if ! command -v bun >/dev/null 2>&1; then echo "error: bun is required to build release binaries" >&2 @@ -27,7 +27,7 @@ fi skip_checks=0 target="" outfile="" -outdir="${axon_dir}/dist" +outdir="${cli_dir}/dist" allow_cross_target="${AXON_ALLOW_CROSS_TARGET:-0}" while [[ $# -gt 0 ]]; do @@ -130,21 +130,19 @@ else fi ( - cd "${axon_dir}" + cd "${repo_root}" bun install --frozen-lockfile ) if [[ "${skip_checks}" -eq 0 ]]; then ( - cd "${axon_dir}" - bun run check - bun run typecheck - bun test + cd "${repo_root}" + bun run ci ) fi ( - cd "${axon_dir}" + cd "${cli_dir}" bun build \ --compile \ --target="${target}" \ diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index 7bc8a22..7534520 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -2,16 +2,14 @@ set -eu ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" -cd "$ROOT/axon" +cd "$ROOT" if ! command -v bun >/dev/null 2>&1; then echo "error: Bun is required. Install it from https://bun.sh/" >&2 exit 127 fi -# Match the required CI checks as closely as a local platform can: -# dependencies, Biome check, TypeScript, and the Bun test suite. +# Match the required CI checks as closely as a local platform can at the +# workspace root. bun install --frozen-lockfile -bun run check -bun run typecheck -bun test +bun run ci diff --git a/turbo.json b/turbo.json new file mode 100644 index 0000000..6ac1732 --- /dev/null +++ b/turbo.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["bin/**", "dist/**", "build/**"] + }, + "check": { + "dependsOn": ["^check"], + "outputs": [] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [] + }, + "test": { + "dependsOn": ["^test"], + "outputs": [] + } + } +}