From 07c5f649422171f336f99f26f829fd5480c7ad30 Mon Sep 17 00:00:00 2001 From: Carl Ryden Date: Sun, 12 Apr 2026 15:48:41 -0400 Subject: [PATCH 1/5] research: add webhid adapter poc --- research/README.md | 1 + research/webhid-poc/README.md | 57 +++++++ research/webhid-poc/app.js | 295 +++++++++++++++++++++++++++++++++ research/webhid-poc/index.html | 126 ++++++++++++++ 4 files changed, 479 insertions(+) create mode 100644 research/webhid-poc/README.md create mode 100644 research/webhid-poc/app.js create mode 100644 research/webhid-poc/index.html diff --git a/research/README.md b/research/README.md index 7c02bb7..feade6f 100644 --- a/research/README.md +++ b/research/README.md @@ -18,6 +18,7 @@ they're archaeology. | [`sudoers/`](sudoers/) | The narrow NOPASSWD `sudoers` rule and the `install_sudoers.sh` / `uninstall_sudoers.sh` scripts | Only useful for the legacy libusb scripts in `python-tests/`. The production CLI uses node-hid and does not need any of this. | | [`etw-capture/`](etw-capture/) | The Windows batch script that drives `logman` to set up a USB ETW trace from inside the Parallels VM | Run this from inside Windows to produce a fresh `axon-usb.etl` if you need a new trace. | | [`session-notes/`](session-notes/) | The original session diary: `REVERSE_ENGINEERING_GUIDE.md` (the early planning doc) and `SESSION_2026-04-09_VM_PROBE.md` (notes from the Parallels/ETW investigation) | Sources for the polished blog post in [`docs/the-adventure.md`](../docs/the-adventure.md). | +| [`webhid-poc/`](webhid-poc/) | A minimal browser proof-of-concept for talking to the adapter over WebHID on `localhost` | Quick feasibility check for a future browser/WebHID path before any serious app architecture work. | ## How to read the story diff --git a/research/webhid-poc/README.md b/research/webhid-poc/README.md new file mode 100644 index 0000000..037daac --- /dev/null +++ b/research/webhid-poc/README.md @@ -0,0 +1,57 @@ +# 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`? + +## 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 +python3 -m http.server 8765 --directory research/webhid-poc +``` + +Then open: + +```text +http://localhost:8765 +``` + +## Browser constraints + +- Use a Chromium-based browser such as Chrome, Edge, or Brave. +- 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. + +## Protocol note + +The production CLI writes 64-byte HID reports that include report id +`0x04` as byte 0. WebHID passes the report id separately, so this PoC +uses: + +- `reportId = 0x04` +- a 63-byte payload for the rest of the report + +Likewise, the input report event exposes the reply payload without the +report id byte, so the protocol offsets are shifted by `-1` relative to +the `node-hid` code. diff --git a/research/webhid-poc/app.js b/research/webhid-poc/app.js new file mode 100644 index 0000000..16d2b7b --- /dev/null +++ b/research/webhid-poc/app.js @@ -0,0 +1,295 @@ +const VID = 0x0471; +const PID = 0x13aa; +const REPORT_ID = 0x04; +const REPORT_SIZE = 64; +const PAYLOAD_SIZE = REPORT_SIZE - 1; + +const CMD_IDENTIFY = 0x8a; +const CMD_READ = 0xcd; +const MAX_CHUNK = 0x3b; +const CONFIG_BLOCK_SIZE = 95; +const CHUNK_SLEEP_MS = 25; +const REPLY_TIMEOUT_MS = 1000; + +const requestButton = document.querySelector("#requestDevice"); +const reconnectButton = document.querySelector("#reconnect"); +const openButton = document.querySelector("#openDevice"); +const identifyButton = document.querySelector("#identifyDevice"); +const readConfigButton = document.querySelector("#readConfig"); +const closeButton = document.querySelector("#closeDevice"); + +const environmentEl = document.querySelector("#environment"); +const deviceInfoEl = document.querySelector("#deviceInfo"); +const latestReplyEl = document.querySelector("#latestReply"); +const logEl = document.querySelector("#log"); + +let device = null; + +function log(message) { + const timestamp = new Date().toLocaleTimeString(); + logEl.textContent += `[${timestamp}] ${message}\n`; + logEl.scrollTop = logEl.scrollHeight; +} + +function hex(value, width = 2) { + return `0x${value.toString(16).padStart(width, "0")}`; +} + +function hexDump(bytes) { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(" "); +} + +function summarizeCollections(selectedDevice) { + return selectedDevice.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), + })); +} + +function renderEnvironment() { + const hasHid = "hid" in navigator; + const lines = [ + `Secure context: ${window.isSecureContext}`, + `WebHID available: ${hasHid}`, + `User agent: ${navigator.userAgent}`, + ]; + environmentEl.textContent = lines.join("\n"); +} + +function renderDevice() { + if (!device) { + deviceInfoEl.textContent = "No device selected."; + return; + } + + const info = { + productName: device.productName, + vendorId: hex(device.vendorId, 4), + productId: hex(device.productId, 4), + opened: device.opened, + collections: summarizeCollections(device), + }; + deviceInfoEl.textContent = JSON.stringify(info, null, 2); +} + +function setButtons() { + const hasDevice = Boolean(device); + const isOpen = Boolean(device?.opened); + openButton.disabled = !hasDevice || isOpen; + identifyButton.disabled = !isOpen; + readConfigButton.disabled = !isOpen; + closeButton.disabled = !isOpen; +} + +function sleep(ms) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +function buildPayload(command, address = 0x0000, length = 0x00, body = new Uint8Array()) { + if (body.length > MAX_CHUNK) { + throw new Error(`payload body too large: ${body.length} > ${MAX_CHUNK}`); + } + + const payload = new Uint8Array(PAYLOAD_SIZE); + payload[0] = command; + payload[1] = (address >> 8) & 0xff; + payload[2] = address & 0xff; + payload[3] = length & 0xff; + payload.set(body, 4); + return payload; +} + +function waitForInputReport(timeoutMs = REPLY_TIMEOUT_MS) { + if (!device) { + throw new Error("No device selected."); + } + + return new Promise((resolve, reject) => { + const timer = window.setTimeout(() => { + device?.removeEventListener("inputreport", onInputReport); + reject(new Error(`Timed out waiting for input report after ${timeoutMs} ms`)); + }, timeoutMs); + + function onInputReport(event) { + if (event.device !== device) { + return; + } + + window.clearTimeout(timer); + device?.removeEventListener("inputreport", onInputReport); + + const bytes = new Uint8Array( + event.data.buffer.slice(event.data.byteOffset, event.data.byteOffset + event.data.byteLength), + ); + resolve({ + reportId: event.reportId, + data: bytes, + }); + } + + device.addEventListener("inputreport", onInputReport); + }); +} + +async function transfer(payload, timeoutMs = REPLY_TIMEOUT_MS) { + if (!device?.opened) { + throw new Error("Device is not open."); + } + + const replyPromise = waitForInputReport(timeoutMs); + await device.sendReport(REPORT_ID, payload); + return replyPromise; +} + +function modeLabel(modeByte) { + if (modeByte === 0x03) return "servo_mode"; + if (modeByte === 0x04) return "cr_mode"; + return "unknown"; +} + +async function identify() { + const reply = await transfer(buildPayload(CMD_IDENTIFY, 0x0000, 0x04)); + const statusHi = reply.data[0] ?? 0xff; + const statusLo = reply.data[1] ?? 0xff; + const modeByte = reply.data[4] ?? null; + + const parsed = { + reportId: hex(reply.reportId), + statusHi: hex(statusHi), + statusLo: hex(statusLo), + present: statusLo !== 0xfa, + modeByte: modeByte === null ? null : hex(modeByte), + mode: modeByte === null ? "unknown" : modeLabel(modeByte), + rawData: hexDump(reply.data), + }; + + latestReplyEl.textContent = JSON.stringify(parsed, null, 2); + log(`identify reply: statusHi=${parsed.statusHi} statusLo=${parsed.statusLo} mode=${parsed.mode}`); + return parsed; +} + +async function readChunk(address, length) { + await sleep(CHUNK_SLEEP_MS); + const reply = await transfer(buildPayload(CMD_READ, address, length)); + const statusHi = reply.data[0] ?? 0xff; + const statusLo = reply.data[1] ?? 0xff; + + if (statusHi !== 0x01 || statusLo !== 0x00) { + throw new Error( + `read chunk nack at ${hex(address, 4)}: statusHi=${hex(statusHi)} statusLo=${hex(statusLo)}`, + ); + } + + return reply.data.slice(4, 4 + length); +} + +function modelIdFromConfig(config) { + let out = ""; + for (let offset = 0x40; offset < 0x48; offset += 1) { + const value = config[offset]; + if (value === undefined || value === 0x00) { + break; + } + out += String.fromCharCode(value); + } + return out; +} + +async function readFullConfig() { + const chunk0 = await readChunk(0x0000, MAX_CHUNK); + const chunk1 = await readChunk(MAX_CHUNK, CONFIG_BLOCK_SIZE - MAX_CHUNK); + const config = new Uint8Array(CONFIG_BLOCK_SIZE); + config.set(chunk0, 0); + config.set(chunk1, MAX_CHUNK); + + const parsed = { + length: config.length, + modelId: modelIdFromConfig(config), + firstChunk: hexDump(chunk0), + secondChunk: hexDump(chunk1), + }; + + latestReplyEl.textContent = JSON.stringify(parsed, null, 2); + log(`read config: ${config.length} bytes, modelId=${parsed.modelId || "(empty)"}`); + return parsed; +} + +async function pickAuthorizedDevice() { + const devices = await navigator.hid.getDevices(); + return devices.find((candidate) => candidate.vendorId === VID && candidate.productId === PID) ?? null; +} + +async function requestDevice() { + const devices = await navigator.hid.requestDevice({ + filters: [{ vendorId: VID, productId: PID }], + }); + device = devices[0] ?? null; + renderDevice(); + setButtons(); + log(device ? `selected ${device.productName}` : "requestDevice returned no selection"); +} + +async function reconnectAuthorized() { + device = await pickAuthorizedDevice(); + renderDevice(); + setButtons(); + log(device ? `reused authorized device ${device.productName}` : "no authorized Axon device found"); +} + +async function openSelectedDevice() { + if (!device) { + throw new Error("No device selected."); + } + await device.open(); + renderDevice(); + setButtons(); + log("device opened"); +} + +async function closeSelectedDevice() { + if (device?.opened) { + await device.close(); + log("device closed"); + } + renderDevice(); + setButtons(); +} + +async function runAction(fn) { + try { + await fn(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log(`error: ${message}`); + } +} + +requestButton.addEventListener("click", () => runAction(requestDevice)); +reconnectButton.addEventListener("click", () => runAction(reconnectAuthorized)); +openButton.addEventListener("click", () => runAction(openSelectedDevice)); +identifyButton.addEventListener("click", () => runAction(identify)); +readConfigButton.addEventListener("click", () => runAction(readFullConfig)); +closeButton.addEventListener("click", () => runAction(closeSelectedDevice)); + +if ("hid" in navigator) { + navigator.hid.addEventListener("disconnect", (event) => { + if (device && event.device === device) { + log("selected device disconnected"); + device = null; + renderDevice(); + setButtons(); + } else { + log(`other HID device disconnected: ${event.device.productName}`); + } + }); +} else { + log("WebHID is not available in this browser."); +} + +renderEnvironment(); +renderDevice(); +setButtons(); +log("ready"); diff --git a/research/webhid-poc/index.html b/research/webhid-poc/index.html new file mode 100644 index 0000000..ea5f152 --- /dev/null +++ b/research/webhid-poc/index.html @@ -0,0 +1,126 @@ + + + + + + Axon WebHID PoC + + + +
+

Axon WebHID PoC

+

+ Minimal browser probe for the Axon V1.3 HID adapter. This page requests the device, + opens it over WebHID, sends the identify command, and can read the full 95-byte config + block. +

+ + + +
+ + + + + + +
+ +
+
+

Environment

+

+        
+
+

Selected Device

+
No device selected.
+
+
+

Latest Reply

+
No traffic yet.
+
+
+ +
+

Log

+

+      
+
+ + + + From c19ca6c1760c37fd606604e830d6d8b4a4c8bb88 Mon Sep 17 00:00:00 2001 From: Carl Ryden Date: Sun, 12 Apr 2026 16:17:15 -0400 Subject: [PATCH 2/5] research: add electrobun desktop poc --- research/README.md | 1 + research/electrobun-poc/.gitignore | 1 + research/electrobun-poc/README.md | 59 +++++ research/electrobun-poc/bun.lock | 134 ++++++++++++ research/electrobun-poc/electrobun.config.ts | 29 +++ research/electrobun-poc/package.json | 19 ++ research/electrobun-poc/src/bun/index.ts | 202 ++++++++++++++++++ .../electrobun-poc/src/mainview/index.css | 69 ++++++ .../electrobun-poc/src/mainview/index.html | 54 +++++ research/electrobun-poc/src/mainview/index.ts | 190 ++++++++++++++++ research/electrobun-poc/src/shared/api.ts | 81 +++++++ research/electrobun-poc/src/types/three.d.ts | 1 + research/electrobun-poc/tsconfig.json | 16 ++ 13 files changed, 856 insertions(+) create mode 100644 research/electrobun-poc/.gitignore create mode 100644 research/electrobun-poc/README.md create mode 100644 research/electrobun-poc/bun.lock create mode 100644 research/electrobun-poc/electrobun.config.ts create mode 100644 research/electrobun-poc/package.json create mode 100644 research/electrobun-poc/src/bun/index.ts create mode 100644 research/electrobun-poc/src/mainview/index.css create mode 100644 research/electrobun-poc/src/mainview/index.html create mode 100644 research/electrobun-poc/src/mainview/index.ts create mode 100644 research/electrobun-poc/src/shared/api.ts create mode 100644 research/electrobun-poc/src/types/three.d.ts create mode 100644 research/electrobun-poc/tsconfig.json diff --git a/research/README.md b/research/README.md index feade6f..ee5ebe1 100644 --- a/research/README.md +++ b/research/README.md @@ -19,6 +19,7 @@ they're archaeology. | [`etw-capture/`](etw-capture/) | The Windows batch script that drives `logman` to set up a USB ETW trace from inside the Parallels VM | Run this from inside Windows to produce a fresh `axon-usb.etl` if you need a new trace. | | [`session-notes/`](session-notes/) | The original session diary: `REVERSE_ENGINEERING_GUIDE.md` (the early planning doc) and `SESSION_2026-04-09_VM_PROBE.md` (notes from the Parallels/ETW investigation) | Sources for the polished blog post in [`docs/the-adventure.md`](../docs/the-adventure.md). | | [`webhid-poc/`](webhid-poc/) | A minimal browser proof-of-concept for talking to the adapter over WebHID on `localhost` | Quick feasibility check for a future browser/WebHID path before any serious app architecture work. | +| [`electrobun-poc/`](electrobun-poc/) | A minimal Electrobun desktop proof-of-concept that keeps HID in the Bun main process and talks to it over Electrobun RPC | Feasibility check for a desktop GUI path that can share frontend structure with the browser PoC without depending on WebHID support in the desktop renderer. | ## How to read the story diff --git a/research/electrobun-poc/.gitignore b/research/electrobun-poc/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/research/electrobun-poc/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/research/electrobun-poc/README.md b/research/electrobun-poc/README.md new file mode 100644 index 0000000..29bf9b0 --- /dev/null +++ b/research/electrobun-poc/README.md @@ -0,0 +1,59 @@ +# Electrobun PoC + +This is a minimal desktop proof-of-concept for talking to the Axon V1.3 +programmer adapter from an Electrobun app. + +It answers one narrow question: + +> Can a desktop UI built with Electrobun keep the frontend in a WebView, +> keep HID in the Bun main process, and reuse the existing Axon +> `node-hid` and protocol code to enumerate, identify, and read config? + +## Why this shape + +The browser PoC in [`../webhid-poc/`](../webhid-poc/) proved that a +Chromium browser can talk to the adapter through WebHID. That is not a +good desktop abstraction, though: + +- Safari/WebKit does not give us the same WebHID path. +- Electrobun uses the platform's native webview by default. +- The desktop app should therefore treat HID as a Bun-side transport, + not a renderer-side browser feature. + +So this PoC keeps the UI browser-like but routes all hardware access +through Electrobun RPC into the Bun process. + +## What it does + +- shows runtime and transport info +- lists visible Axon adapters via `node-hid` +- opens the first visible adapter +- sends the identify command (`0x8A`) +- reads the full 95-byte config block via the same two-chunk `0xCD` + flow used by the CLI + +It does **not** attempt to write config or flash firmware. + +## Run it + +From the repo root, make sure the CLI dependencies are present: + +```bash +cd axon +bun install +``` + +Then start the Electrobun app: + +```bash +cd /Users/caryden/github/servo-programmer/research/electrobun-poc +bun install +bun run start +``` + +## Current implementation tradeoff + +This PoC imports the existing Axon modules directly from `../../axon/` +instead of moving code into shared packages first. That is intentional. +The purpose here is to prove the transport and UI boundary before doing +any Turborepo or monorepo restructuring. diff --git a/research/electrobun-poc/bun.lock b/research/electrobun-poc/bun.lock new file mode 100644 index 0000000..983ce58 --- /dev/null +++ b/research/electrobun-poc/bun.lock @@ -0,0 +1,134 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "axon-electrobun-poc", + "dependencies": { + "electrobun": "1.16.0", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/three": "^0.180.0", + "typescript": "^5.9.3", + }, + }, + }, + "packages": { + "@babylonjs/core": ["@babylonjs/core@7.54.3", "", {}, "sha512-P5ncXVd8GEUJLhwloP9V0oVwQYIrvZztguVeLlvd5Rx+9aQnenKjpV8auJ6SRsUlAmNZU4pFTKzwF6o2EUfhAw=="], + + "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="], + + "@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@1.1.1", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ=="], + + "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], + + "@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@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + + "@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=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], + + "basic-ftp": ["basic-ftp@5.2.2", "", {}, "sha512-1tDrzKsdCg70WGvbFss/ulVAxupNauGnOlgpyjKzeQxzyllBLS0CGLV7tjIXTK3ZQA9/FBEm9qyFFN1bciA6pw=="], + + "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + + "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=="], + + "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=="], + + "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=="], + + "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + + "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=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "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-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=="], + + "meshoptimizer": ["meshoptimizer@0.22.0", "", {}, "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg=="], + + "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=="], + + "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=="], + + "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=="], + + "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=="], + + "rcedit": ["rcedit@4.0.1", "", { "dependencies": { "cross-spawn-windows-exe": "^1.1.0" } }, "sha512-bZdaQi34krFWhrDn+O53ccBDw0MkAT2Vhu75SqhtvhQu4OPyFM4RoVheyYiVQYdjhUi6EJMVWQ0tR6bCIYVkUg=="], + + "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=="], + + "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=="], + + "three": ["three@0.165.0", "", {}, "sha512-cc96IlVYGydeceu0e5xq70H8/yoVT/tXBxV/W8A/U6uOq7DXc4/s1Mkmnu6SqoYGhSRWWYFOhVwvq6V0VtbplA=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "png-to-ico/@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="], + } +} diff --git a/research/electrobun-poc/electrobun.config.ts b/research/electrobun-poc/electrobun.config.ts new file mode 100644 index 0000000..7d7f1b7 --- /dev/null +++ b/research/electrobun-poc/electrobun.config.ts @@ -0,0 +1,29 @@ +import type { ElectrobunConfig } from "electrobun"; + +export default { + app: { + name: "axon-electrobun-poc", + identifier: "com.caryden.axon.electrobun-poc", + 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", + }, + mac: { + bundleCEF: false, + }, + linux: { + bundleCEF: false, + }, + win: { + bundleCEF: false, + }, + }, +} satisfies ElectrobunConfig; diff --git a/research/electrobun-poc/package.json b/research/electrobun-poc/package.json new file mode 100644 index 0000000..d23f118 --- /dev/null +++ b/research/electrobun-poc/package.json @@ -0,0 +1,19 @@ +{ + "name": "axon-electrobun-poc", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "start": "electrobun dev", + "dev": "electrobun dev --watch", + "typecheck": "bunx tsc --noEmit" + }, + "dependencies": { + "electrobun": "1.16.0" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/three": "^0.180.0", + "typescript": "^5.9.3" + } +} diff --git a/research/electrobun-poc/src/bun/index.ts b/research/electrobun-poc/src/bun/index.ts new file mode 100644 index 0000000..d0f5bae --- /dev/null +++ b/research/electrobun-poc/src/bun/index.ts @@ -0,0 +1,202 @@ +import { BrowserView, BrowserWindow } from "electrobun/bun"; +import { AxonError } from "../../../../axon/src/errors.ts"; +import { findModel, loadCatalog } from "../../../../axon/src/catalog.ts"; +import { + listDongles, + openDongle, + type DongleDescriptor, +} from "../../../../axon/src/driver/hid.ts"; +import { + identify, + modelIdFromConfig, + readFullConfig, + type IdentifyReply, +} from "../../../../axon/src/driver/protocol.ts"; +import type { + AdapterInfo, + AdapterInventory, + ConfigInfo, + DesktopPocSchema, + IdentifyInfo, + RpcResult, + RuntimeInfo, + SerializedAxonError, +} from "../shared/api.ts"; + +const WINDOW_WIDTH = 960; +const WINDOW_HEIGHT = 860; + +let activeDescriptor: DongleDescriptor | null = null; +let activeHandle: + | Awaited> + | 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 hexDump(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(" "); +} + +function serializeDescriptor(descriptor: DongleDescriptor): AdapterInfo { + return { + path: 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), + }; +} + +function currentInventory(): AdapterInventory { + return { + adapters: listDongles().map(serializeDescriptor), + openedPath: activeDescriptor?.path ?? null, + }; +} + +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" }; +} + +async function withResult(fn: () => Promise | T): Promise> { + try { + return { ok: true, data: await fn() }; + } catch (error) { + return { ok: false, error: serializeError(error) }; + } +} + +async function releaseActiveHandle(): Promise { + if (!activeHandle) return; + await activeHandle.release(); + activeHandle = null; + activeDescriptor = null; +} + +function requireOpenHandle() { + if (!activeHandle) { + throw AxonError.validation("Open the Axon adapter first."); + } + return activeHandle; +} + +function toIdentifyInfo(reply: IdentifyReply): IdentifyInfo { + 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", + }; +} + +const rpc = BrowserView.defineRPC({ + handlers: { + requests: { + getRuntime: () => withResult(() => runtimeInfo()), + refreshAdapters: () => withResult(() => currentInventory()), + openAdapter: (params) => + withResult(async () => { + await releaseActiveHandle(); + const descriptors = listDongles(); + const selected = + params?.path === undefined + ? descriptors[0] + : descriptors.find((descriptor) => descriptor.path === params.path); + + if (!selected) { + if (params?.path) { + throw AxonError.validation(`Adapter path not found: ${params.path}`); + } + throw AxonError.noAdapter("Axon dongle not found on USB."); + } + + activeHandle = await openDongle(selected); + activeDescriptor = selected; + return currentInventory(); + }), + closeAdapter: () => + withResult(async () => { + await releaseActiveHandle(); + return currentInventory(); + }), + identifyServo: () => + withResult(async () => { + const handle = requireOpenHandle(); + return toIdentifyInfo(await identify(handle)); + }), + readFullConfig: () => + withResult(async () => { + const handle = requireOpenHandle(); + const config = await readFullConfig(handle); + const modelId = modelIdFromConfig(config); + const catalog = loadCatalog(); + const model = findModel(catalog, modelId); + const chunkSplit = 59; + + const info: ConfigInfo = { + length: config.length, + modelId, + known: Boolean(model), + modelName: model?.name ?? null, + docsUrl: model?.docs_url ?? null, + rawHex: hexDump(config), + firstChunk: hexDump(config.subarray(0, chunkSplit)), + secondChunk: hexDump(config.subarray(chunkSplit)), + }; + return info; + }), + }, + messages: {}, + }, +}); + +const mainWindow = new BrowserWindow({ + title: "Axon Electrobun PoC", + url: "views://mainview/index.html", + rpc, + frame: { + width: WINDOW_WIDTH, + height: WINDOW_HEIGHT, + x: 160, + y: 120, + }, + renderer: "native", +}); + +process.on("beforeExit", async () => { + await releaseActiveHandle(); +}); + +console.log(`Axon Electrobun PoC started (window ${mainWindow.id})`); diff --git a/research/electrobun-poc/src/mainview/index.css b/research/electrobun-poc/src/mainview/index.css new file mode 100644 index 0000000..00e399b --- /dev/null +++ b/research/electrobun-poc/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/research/electrobun-poc/src/mainview/index.html b/research/electrobun-poc/src/mainview/index.html new file mode 100644 index 0000000..4b99b3c --- /dev/null +++ b/research/electrobun-poc/src/mainview/index.html @@ -0,0 +1,54 @@ + + + + + + Axon Electrobun PoC + + + +
+

Axon Electrobun PoC

+

+ Minimal desktop probe for the Axon V1.3 HID adapter. The UI runs in the Electrobun + WebView, but HID traffic stays in the Bun main process through the existing + node-hid transport and Axon protocol code. +

+ +
    +
  • Use this on the host OS with the adapter released from any VM such as Parallels.
  • +
  • This PoC mirrors the WebHID flow, but it does not depend on browser WebHID support.
  • +
  • It only enumerates, identifies, and reads config. It does not write or flash firmware.
  • +
+ +
+ + + + + +
+ +
+
+

Environment

+
Loading runtime info...
+
+
+

Visible Adapters

+
No adapter scan yet.
+
+
+

Latest Reply

+
No traffic yet.
+
+
+ +
+

Log

+

+      
+
+ + + diff --git a/research/electrobun-poc/src/mainview/index.ts b/research/electrobun-poc/src/mainview/index.ts new file mode 100644 index 0000000..68cc7c2 --- /dev/null +++ b/research/electrobun-poc/src/mainview/index.ts @@ -0,0 +1,190 @@ +import { Electroview } from "electrobun/view"; +import type { + AdapterInventory, + ConfigInfo, + DesktopPocSchema, + IdentifyInfo, + RpcResult, + RuntimeInfo, + SerializedAxonError, +} from "../shared/api.ts"; + +function mustQuery(selector: string): T { + const element = document.querySelector(selector); + if (!element) { + throw new Error(`Electrobun PoC DOM did not initialize correctly for ${selector}`); + } + return element; +} + +const refreshButton = mustQuery("#refreshAdapters"); +const openButton = mustQuery("#openAdapter"); +const identifyButton = mustQuery("#identifyServo"); +const readButton = mustQuery("#readConfig"); +const closeButton = mustQuery("#closeAdapter"); + +const environmentEl = mustQuery("#environment"); +const deviceInfoEl = mustQuery("#deviceInfo"); +const latestReplyEl = mustQuery("#latestReply"); +const logEl = mustQuery("#log"); + +const rpc = Electroview.defineRPC({ + handlers: { + requests: {}, + messages: {}, + }, +}); + +new Electroview({ rpc }); + +let openedPath: string | null = null; +let lastInventory: AdapterInventory | null = null; + +function log(message: string) { + const timestamp = new Date().toLocaleTimeString(); + logEl.textContent += `[${timestamp}] ${message}\n`; + logEl.scrollTop = logEl.scrollHeight; +} + +function renderButtons() { + const visibleCount = lastInventory?.adapters.length ?? 0; + const isOpen = openedPath !== null; + openButton.disabled = visibleCount === 0 || isOpen; + identifyButton.disabled = !isOpen; + readButton.disabled = !isOpen; + closeButton.disabled = !isOpen; +} + +function renderInventory(inventory: AdapterInventory) { + lastInventory = inventory; + openedPath = inventory.openedPath; + const payload = { + openedPath: inventory.openedPath, + visibleCount: inventory.adapters.length, + adapters: inventory.adapters, + }; + deviceInfoEl.textContent = JSON.stringify(payload, null, 2); + renderButtons(); +} + +function renderEnvironment(info: RuntimeInfo) { + environmentEl.textContent = JSON.stringify(info, null, 2); +} + +function renderLatest(value: unknown) { + latestReplyEl.textContent = JSON.stringify(value, null, 2); +} + +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 handleResult( + promise: Promise>, + onOk: (data: T) => void, + successMessage: string, +) { + const result = await promise; + if (!result.ok) { + renderLatest({ error: result.error }); + log(`error: ${describeError(result.error)}`); + return; + } + onOk(result.data); + log(successMessage); +} + +async function refreshAdapters() { + await handleResult( + rpc.request.refreshAdapters(), + (data) => { + renderInventory(data); + renderLatest({ + visibleCount: data.adapters.length, + openedPath: data.openedPath, + }); + }, + "adapter inventory refreshed", + ); +} + +async function loadRuntime() { + await handleResult( + rpc.request.getRuntime(), + (data) => { + renderEnvironment(data); + }, + "runtime info loaded", + ); +} + +async function openAdapter() { + await handleResult( + rpc.request.openAdapter(), + (data) => { + renderInventory(data); + renderLatest({ + openedPath: data.openedPath, + visibleCount: data.adapters.length, + }); + }, + "adapter opened", + ); +} + +async function closeAdapter() { + await handleResult( + rpc.request.closeAdapter(), + (data) => { + renderInventory(data); + renderLatest({ + openedPath: data.openedPath, + visibleCount: data.adapters.length, + }); + }, + "adapter closed", + ); +} + +async function identifyServo() { + await handleResult( + rpc.request.identifyServo(), + (data: IdentifyInfo) => { + renderLatest(data); + }, + "identify completed", + ); +} + +async function readConfig() { + await handleResult( + rpc.request.readFullConfig(), + (data: ConfigInfo) => { + renderLatest(data); + }, + "full config read completed", + ); +} + +refreshButton.addEventListener("click", () => { + void refreshAdapters(); +}); +openButton.addEventListener("click", () => { + void openAdapter(); +}); +identifyButton.addEventListener("click", () => { + void identifyServo(); +}); +readButton.addEventListener("click", () => { + void readConfig(); +}); +closeButton.addEventListener("click", () => { + void closeAdapter(); +}); + +void loadRuntime(); +void refreshAdapters(); diff --git a/research/electrobun-poc/src/shared/api.ts b/research/electrobun-poc/src/shared/api.ts new file mode 100644 index 0000000..2541c1f --- /dev/null +++ b/research/electrobun-poc/src/shared/api.ts @@ -0,0 +1,81 @@ +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 interface AdapterInfo { + path: 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; +} + +export interface AdapterInventory { + adapters: AdapterInfo[]; + openedPath: string | null; +} + +export interface IdentifyInfo { + present: boolean; + statusHi: string; + statusLo: string; + modeByte: string | null; + mode: string; + rawRx: string; +} + +export interface ConfigInfo { + length: number; + modelId: string; + known: boolean; + modelName: string | null; + docsUrl: string | null; + rawHex: string; + firstChunk: string; + secondChunk: string; +} + +export type DesktopPocSchema = { + bun: { + requests: { + getRuntime: { params: undefined; response: RpcResult }; + refreshAdapters: { 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 }; + }; + messages: {}; + }; + webview: { + requests: {}; + messages: {}; + }; +}; diff --git a/research/electrobun-poc/src/types/three.d.ts b/research/electrobun-poc/src/types/three.d.ts new file mode 100644 index 0000000..44db19a --- /dev/null +++ b/research/electrobun-poc/src/types/three.d.ts @@ -0,0 +1 @@ +declare module "three"; diff --git a/research/electrobun-poc/tsconfig.json b/research/electrobun-poc/tsconfig.json new file mode 100644 index 0000000..94bf23f --- /dev/null +++ b/research/electrobun-poc/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/**/*"] +} From 02d0046344fb1a80109bedf8713a0ec144fa1226 Mon Sep 17 00:00:00 2001 From: Carl Ryden Date: Sun, 12 Apr 2026 16:24:09 -0400 Subject: [PATCH 3/5] refactor: extract shared core package --- axon/src/catalog.ts | 301 +---------------------- axon/src/driver/protocol.ts | 233 +----------------- axon/src/driver/transport.ts | 43 +--- axon/src/errors.ts | 156 +----------- packages/core/README.md | 17 ++ packages/core/package.json | 6 + packages/core/src/catalog.ts | 266 ++++++++++++++++++++ packages/core/src/driver/index.ts | 2 + packages/core/src/driver/protocol.ts | 181 ++++++++++++++ packages/core/src/driver/transport.ts | 11 + packages/core/src/errors.ts | 115 +++++++++ packages/core/src/index.ts | 2 + packages/core/tsconfig.json | 16 ++ research/electrobun-poc/README.md | 12 +- research/electrobun-poc/src/bun/index.ts | 6 +- 15 files changed, 631 insertions(+), 736 deletions(-) create mode 100644 packages/core/README.md create mode 100644 packages/core/package.json create mode 100644 packages/core/src/catalog.ts create mode 100644 packages/core/src/driver/index.ts create mode 100644 packages/core/src/driver/protocol.ts create mode 100644 packages/core/src/driver/transport.ts create mode 100644 packages/core/src/errors.ts create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/tsconfig.json diff --git a/axon/src/catalog.ts b/axon/src/catalog.ts index b49479f..f71171f 100644 --- a/axon/src/catalog.ts +++ b/axon/src/catalog.ts @@ -1,300 +1 @@ -/** - * 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. - * - * 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. - */ - -// 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" }; - -export interface ParameterImplementation { - offset?: string; - mirrors?: string[]; - encoding?: string; - widget?: string; - confidence?: string; - label_confidence?: string; - confidence_note?: string; - bit_mask?: string; - min_raw?: number; - max_raw?: number; - deg_formula?: string; - us_formula?: string; - percent_formula?: string; - user_step_formula?: string; - source?: string; - notes?: string; - value_mapping_confidence?: string; - value_mapping_note?: string; - mirror_0x0F_offset?: number; - [key: string]: unknown; -} - -export interface CatalogParameterSpec { - name: string; - vendor_label: string; - description: string; - unit: string; - modes: string[]; - min?: number | null; - max?: number | null; - max_from_model?: string; - values?: string[]; - docs_url?: string; - implementation: ParameterImplementation; -} - -export interface NotYetMappedEntry { - name: string; - vendor_label: string; - description: string; - modes: string[]; - reason_blocked: string; -} - -export interface ServoModeSpec { - id: string; - id_byte: number; - name: string; - description: string; - sfw_pattern?: string; - available_parameters: string[]; - unavailable_parameters?: Record; -} - -export interface BundledFirmware { - file: string; - sha256: string; - 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 - | boolean - | null - | { - raw?: number; - user_step?: number; - percent_approx?: number; - us?: number; - deg_approx?: number; - mode?: string | null; - implementation?: Record; - [key: string]: unknown; - }; - -export interface ServoModel { - id: string; - name: string; - max_range_deg: number | null; - pulse_range_us: [number, number]; - docs_url?: string; - magic_bytes?: string; - defaults: Record; - bundled_firmware: Record; -} - -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; -} - -const rawCatalog = catalogJson as unknown as { - catalog_version: string; - source_docs: string; - models: Record>; - parameters: Record>; - servo_modes: Record>; - _not_yet_mapped: Record; -}; - -let cachedCatalog: Catalog | undefined; -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 - 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(); - for (const [key, value] of Object.entries(rawCatalog.models)) { - if (key.startsWith("_")) continue; - const v = value as { - name: string; - max_range_deg: number | null; - pulse_range_us: [number, number]; - docs_url?: string; - implementation?: { magic_bytes?: string }; - defaults?: Record; - bundled_firmware?: Record; - }; - models.set(key, { - id: key, - name: v.name, - max_range_deg: v.max_range_deg ?? null, - pulse_range_us: v.pulse_range_us, - docs_url: v.docs_url, - magic_bytes: v.implementation?.magic_bytes, - defaults: v.defaults ?? {}, - bundled_firmware: v.bundled_firmware ?? {}, - }); - } - cachedCatalog = { - version: rawCatalog.catalog_version, - source_docs: rawCatalog.source_docs, - models, - parameters: rawCatalog.parameters ?? {}, - }; - return cachedCatalog; -} - -export function findModel(catalog: Catalog, modelId: string): ServoModel | undefined { - 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[] = []; - for (const [name, raw] of Object.entries(rawCatalog.parameters ?? {})) { - if (name.startsWith("_")) continue; - const v = raw as { - vendor_label: string; - description: string; - unit: string; - modes: string[]; - min?: number | null; - max?: number | null; - max_from_model?: string; - values?: string[]; - docs_url?: string; - implementation?: ParameterImplementation; - }; - out.push({ - name, - vendor_label: v.vendor_label, - description: v.description, - unit: v.unit, - modes: v.modes ?? [], - min: v.min ?? null, - max: v.max ?? null, - max_from_model: v.max_from_model, - values: v.values, - docs_url: v.docs_url, - implementation: v.implementation ?? {}, - }); - } - cachedParameters = out; - return cachedParameters; -} - -export function findParameter(name: string): CatalogParameterSpec | undefined { - const all = loadParameters(); - return all.find((p) => p.name === name); -} - -export function loadServoModes(): ServoModeSpec[] { - if (cachedServoModes) return cachedServoModes; - const out: ServoModeSpec[] = []; - for (const [id, raw] of Object.entries(rawCatalog.servo_modes ?? {})) { - if (id.startsWith("_")) continue; - const v = raw as { - id_byte: number; - name: string; - description: string; - sfw_pattern?: string; - available_parameters: string[]; - unavailable_parameters?: Record; - }; - out.push({ - id, - id_byte: v.id_byte, - name: v.name, - description: v.description, - sfw_pattern: v.sfw_pattern, - available_parameters: v.available_parameters ?? [], - unavailable_parameters: v.unavailable_parameters, - }); - } - cachedServoModes = out; - return cachedServoModes; -} - -export function findServoMode(id_byte: number): ServoModeSpec | undefined { - return loadServoModes().find((m) => m.id_byte === id_byte); -} - -export function loadNotYetMapped(): Record { - if (cachedNotYetMapped) return cachedNotYetMapped; - const out: Record = {}; - for (const [name, raw] of Object.entries(rawCatalog._not_yet_mapped ?? {})) { - if (name.startsWith("_")) continue; - const v = raw as { - vendor_label: string; - description: string; - modes: string[]; - implementation?: { reason_blocked?: string }; - }; - out[name] = { - name, - vendor_label: v.vendor_label, - description: v.description, - modes: v.modes ?? [], - reason_blocked: v.implementation?.reason_blocked ?? "not yet mapped", - }; - } - cachedNotYetMapped = out; - return cachedNotYetMapped; -} - -export function isNotYetMapped(name: string): boolean { - return name in loadNotYetMapped(); -} +export * from "../../packages/core/src/catalog.ts"; diff --git a/axon/src/driver/protocol.ts b/axon/src/driver/protocol.ts index dfd701d..d301f1b 100644 --- a/axon/src/driver/protocol.ts +++ b/axon/src/driver/protocol.ts @@ -1,232 +1 @@ -/** - * Axon dongle HID protocol — identify, read config, write config. - * - * 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) - */ - -import { AxonError } from "../errors.ts"; -import { REPORT_SIZE } from "./hid.ts"; -import type { DongleHandle } from "./transport.ts"; - -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 CHUNK_SLEEP_MS = 25; -export const WIRE_REPLY_SETTLE_MS = 80; // empirically adequate for 9600 baud - -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 -} - -function buildTx(cmd: number, addr: number, length: number): Buffer { - const tx = Buffer.alloc(REPORT_SIZE); - tx[0] = REPORT_ID; - tx[1] = cmd; - tx[2] = (addr >> 8) & 0xff; - tx[3] = addr & 0xff; - tx[4] = length; - return tx; -} - -function buildWriteTx(addr: number, data: Uint8Array): Buffer { - if (data.length > MAX_CHUNK) { - throw new Error(`buildWriteTx: data is ${data.length} bytes, max chunk is ${MAX_CHUNK}`); - } - const tx = Buffer.alloc(REPORT_SIZE); - tx[0] = REPORT_ID; - tx[1] = CMD_WRITE; - tx[2] = (addr >> 8) & 0xff; - tx[3] = addr & 0xff; - tx[4] = data.length; - for (let i = 0; i < data.length; i++) { - tx[5 + i] = data[i] ?? 0; - } - return tx; -} - -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); - const rx = await handle.read(); - - if (rx.length < 3) { - throw AxonError.servoIo(`identify reply is ${rx.length} bytes, need at least 3`); - } - 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, - rawRx: rx, - statusHi, - statusLo, - modeByte: null, - mode: "unknown", - }; - } - - if (statusHi !== 0x01 || statusLo !== 0x00) { - throw AxonError.servoIo(`identify nack: rx[0..15]: ${hexPrefix(rx, 16)}`); - } - if (rx.length < 6) { - throw AxonError.servoIo(`identify success reply is ${rx.length} bytes, need at least 6`); - } - - let mode: ServoMode = "unknown"; - const modeByte = rx[5] ?? null; - if (modeByte === 0x03) mode = "servo_mode"; - else if (modeByte === 0x04) mode = "cr_mode"; - - return { present: true, rawRx: rx, statusHi, statusLo, modeByte, mode }; -} - -function hexPrefix(buf: Buffer, count: number): string { - return Array.from(buf.subarray(0, count)) - .map((b) => b.toString(16).padStart(2, "0")) - .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, - length: number, -): Promise { - if (length === 0) return Buffer.alloc(0); - if (length > MAX_CHUNK) { - throw new Error(`readChunk: length ${length} exceeds max ${MAX_CHUNK}`); - } - await sleep(CHUNK_SLEEP_MS); - const tx = buildTx(CMD_READ, addr, length); - await handle.write(tx); - await sleep(WIRE_REPLY_SETTLE_MS); - const rx = await handle.read(); - if (rx.length < 5 + length) { - 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; - throw AxonError.servoIo( - `read nack: rx[1]=0x${statusHi.toString(16).padStart(2, "0")} rx[2]=0x${statusLo.toString(16).padStart(2, "0")}`, - ); - } - 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); - 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, - data: Uint8Array, -): Promise { - if (data.length === 0) return; - if (data.length > MAX_CHUNK) { - throw new Error(`writeChunk: data is ${data.length} bytes, max chunk is ${MAX_CHUNK}`); - } - await sleep(CHUNK_SLEEP_MS); - const tx = buildWriteTx(addr, data); - await handle.write(tx); - await sleep(WIRE_REPLY_SETTLE_MS); - const rx = await handle.read(); - if (rx[1] === 0) { - const statusLo = rx[2] ?? 0; - throw AxonError.servoIo( - `write nack: rx[1]=0 (expected non-zero). rx[2]=0x${statusLo.toString(16).padStart(2, "0")}`, - ); - } -} - -/** - * 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}`); - } - await writeChunk(handle, 0x00, config.subarray(0, MAX_CHUNK)); - 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++) { - const b = config[i]; - if (b === undefined || b === 0x00) break; - s += String.fromCharCode(b); - } - return s; -} +export * from "../../../packages/core/src/driver/protocol.ts"; diff --git a/axon/src/driver/transport.ts b/axon/src/driver/transport.ts index e85b4e9..db9a6e2 100644 --- a/axon/src/driver/transport.ts +++ b/axon/src/driver/transport.ts @@ -1,42 +1 @@ -/** - * 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; -} +export * from "../../../packages/core/src/driver/transport.ts"; diff --git a/axon/src/errors.ts b/axon/src/errors.ts index d55cdfc..d859997 100644 --- a/axon/src/errors.ts +++ b/axon/src/errors.ts @@ -1,155 +1 @@ -/** - * Exit codes and typed errors for the Axon CLI. - * - * See docs/CLI_DESIGN.md "Exit codes" section for the contract. - */ - -export const ExitCode = { - Ok: 0, - UsageError: 1, - DongleNotFound: 2, - NotPrimed: 3, - ServoIoError: 4, - ValidationError: 5, - UnknownModel: 6, -} as const; - -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 - -export class AxonError extends Error { - public readonly category: ErrorCategory; - - constructor( - public readonly code: ExitCodeValue, - message: string, - public readonly hint?: string, - category: ErrorCategory = "internal", - ) { - super(message); - this.name = "AxonError"; - 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, - detail, - "Plug in the Axon servo programmer adapter. If it is already plugged in, release it from Parallels/Windows or close any app using it, then retry.", - "no_adapter", - ); - } - - /** - * 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, - `Could not open the Axon adapter: ${detail}`, - "Close any app or VM using the adapter, or release it from Parallels/Windows, then retry.", - "adapter_busy", - ); - } - - /** - * 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, - `HID I/O to the Axon adapter failed: ${detail}`, - "Unplug and replug the adapter, then retry.", - "adapter_io", - ); - } - - /** - * 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, - detail, - "Unplug and replug the servo from the adapter (leave the adapter connected).", - "no_servo", - ); - } - - /** - * 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, - `Servo I/O error: ${detail}`, - "Unplug and replug the servo from the adapter (leave the adapter connected).", - "servo_io", - ); - } - - // -- Catalog / model layer ------------------------------------------------- - - static unknownModel(modelId: string): AxonError { - return new AxonError( - ExitCode.UnknownModel, - `Unknown servo model "${modelId}".`, - `Please open an issue at https://github.com/caryden/servo-programmer/issues with the output of 'axon read --svo > unknown.svo' so this model can be added to the catalog.`, - "unknown_model", - ); - } - - // -- User / CLI layer ------------------------------------------------------ - - static validation(detail: string): AxonError { - return new AxonError(ExitCode.ValidationError, detail, undefined, "validation"); - } - - static usage(detail: string): AxonError { - 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); - } - - static notPrimed(): AxonError { - return AxonError.noServo(); - } -} +export * from "../../packages/core/src/errors.ts"; 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..2f89f29 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,6 @@ +{ + "name": "@axon/core", + "version": "0.0.1", + "private": true, + "type": "module" +} diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts new file mode 100644 index 0000000..3c66bd7 --- /dev/null +++ b/packages/core/src/catalog.ts @@ -0,0 +1,266 @@ +/** + * 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. + * + * This module is transport-agnostic and should be shared across CLI, + * desktop, and future browser surfaces. + */ + +import catalogJson from "../../../data/servo_catalog.json" with { type: "json" }; + +export interface ParameterImplementation { + offset?: string; + mirrors?: string[]; + encoding?: string; + widget?: string; + confidence?: string; + label_confidence?: string; + confidence_note?: string; + bit_mask?: string; + min_raw?: number; + max_raw?: number; + deg_formula?: string; + us_formula?: string; + percent_formula?: string; + user_step_formula?: string; + source?: string; + notes?: string; + value_mapping_confidence?: string; + value_mapping_note?: string; + mirror_0x0F_offset?: number; + [key: string]: unknown; +} + +export interface CatalogParameterSpec { + name: string; + vendor_label: string; + description: string; + unit: string; + modes: string[]; + min?: number | null; + max?: number | null; + max_from_model?: string; + values?: string[]; + docs_url?: string; + implementation: ParameterImplementation; +} + +export interface NotYetMappedEntry { + name: string; + vendor_label: string; + description: string; + modes: string[]; + reason_blocked: string; +} + +export interface ServoModeSpec { + id: string; + id_byte: number; + name: string; + description: string; + sfw_pattern?: string; + available_parameters: string[]; + unavailable_parameters?: Record; +} + +export interface BundledFirmware { + file: string; + sha256: string; + description: string; +} + +export type ModelDefaultValue = + | string + | number + | boolean + | null + | { + raw?: number; + user_step?: number; + percent_approx?: number; + us?: number; + deg_approx?: number; + mode?: string | null; + implementation?: Record; + [key: string]: unknown; + }; + +export interface ServoModel { + id: string; + name: string; + max_range_deg: number | null; + pulse_range_us: [number, number]; + docs_url?: string; + magic_bytes?: string; + defaults: Record; + bundled_firmware: Record; +} + +export interface Catalog { + version: string; + source_docs: string; + models: Map; + parameters: Record; +} + +const rawCatalog = catalogJson as unknown as { + catalog_version: string; + source_docs: string; + models: Record>; + parameters: Record>; + servo_modes: Record>; + _not_yet_mapped: Record; +}; + +let cachedCatalog: Catalog | undefined; +let cachedParameters: CatalogParameterSpec[] | undefined; +let cachedServoModes: ServoModeSpec[] | undefined; +let cachedNotYetMapped: Record | undefined; + +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; + s += String.fromCharCode(b); + } + return s; +} + +export function loadCatalog(): Catalog { + if (cachedCatalog) return cachedCatalog; + const models = new Map(); + for (const [key, value] of Object.entries(rawCatalog.models)) { + if (key.startsWith("_")) continue; + const v = value as { + name: string; + max_range_deg: number | null; + pulse_range_us: [number, number]; + docs_url?: string; + implementation?: { magic_bytes?: string }; + defaults?: Record; + bundled_firmware?: Record; + }; + models.set(key, { + id: key, + name: v.name, + max_range_deg: v.max_range_deg ?? null, + pulse_range_us: v.pulse_range_us, + docs_url: v.docs_url, + magic_bytes: v.implementation?.magic_bytes, + defaults: v.defaults ?? {}, + bundled_firmware: v.bundled_firmware ?? {}, + }); + } + cachedCatalog = { + version: rawCatalog.catalog_version, + source_docs: rawCatalog.source_docs, + models, + parameters: rawCatalog.parameters ?? {}, + }; + return cachedCatalog; +} + +export function findModel(catalog: Catalog, modelId: string): ServoModel | undefined { + return catalog.models.get(modelId); +} + +export function loadParameters(): CatalogParameterSpec[] { + if (cachedParameters) return cachedParameters; + const out: CatalogParameterSpec[] = []; + for (const [name, raw] of Object.entries(rawCatalog.parameters ?? {})) { + if (name.startsWith("_")) continue; + const v = raw as { + vendor_label: string; + description: string; + unit: string; + modes: string[]; + min?: number | null; + max?: number | null; + max_from_model?: string; + values?: string[]; + docs_url?: string; + implementation?: ParameterImplementation; + }; + out.push({ + name, + vendor_label: v.vendor_label, + description: v.description, + unit: v.unit, + modes: v.modes ?? [], + min: v.min ?? null, + max: v.max ?? null, + max_from_model: v.max_from_model, + values: v.values, + docs_url: v.docs_url, + implementation: v.implementation ?? {}, + }); + } + cachedParameters = out; + return cachedParameters; +} + +export function findParameter(name: string): CatalogParameterSpec | undefined { + const all = loadParameters(); + return all.find((p) => p.name === name); +} + +export function loadServoModes(): ServoModeSpec[] { + if (cachedServoModes) return cachedServoModes; + const out: ServoModeSpec[] = []; + for (const [id, raw] of Object.entries(rawCatalog.servo_modes ?? {})) { + if (id.startsWith("_")) continue; + const v = raw as { + id_byte: number; + name: string; + description: string; + sfw_pattern?: string; + available_parameters: string[]; + unavailable_parameters?: Record; + }; + out.push({ + id, + id_byte: v.id_byte, + name: v.name, + description: v.description, + sfw_pattern: v.sfw_pattern, + available_parameters: v.available_parameters ?? [], + unavailable_parameters: v.unavailable_parameters, + }); + } + cachedServoModes = out; + return cachedServoModes; +} + +export function findServoMode(id_byte: number): ServoModeSpec | undefined { + return loadServoModes().find((m) => m.id_byte === id_byte); +} + +export function loadNotYetMapped(): Record { + if (cachedNotYetMapped) return cachedNotYetMapped; + const out: Record = {}; + for (const [name, raw] of Object.entries(rawCatalog._not_yet_mapped ?? {})) { + if (name.startsWith("_")) continue; + const v = raw as { + vendor_label: string; + description: string; + modes: string[]; + implementation?: { reason_blocked?: string }; + }; + out[name] = { + name, + vendor_label: v.vendor_label, + description: v.description, + modes: v.modes ?? [], + reason_blocked: v.implementation?.reason_blocked ?? "not yet mapped", + }; + } + cachedNotYetMapped = out; + return cachedNotYetMapped; +} + +export function isNotYetMapped(name: string): boolean { + return name in loadNotYetMapped(); +} diff --git a/packages/core/src/driver/index.ts b/packages/core/src/driver/index.ts new file mode 100644 index 0000000..fbd941a --- /dev/null +++ b/packages/core/src/driver/index.ts @@ -0,0 +1,2 @@ +export * from "./protocol.ts"; +export * from "./transport.ts"; diff --git a/packages/core/src/driver/protocol.ts b/packages/core/src/driver/protocol.ts new file mode 100644 index 0000000..6b2d000 --- /dev/null +++ b/packages/core/src/driver/protocol.ts @@ -0,0 +1,181 @@ +/** + * Shared Axon identify/read/write protocol helpers. + * + * This layer is intentionally transport-agnostic. The only dependency is + * the `DongleHandle` abstraction. + */ + +import { AxonError } from "../errors.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; +export const CHUNK_SLEEP_MS = 25; +export const WIRE_REPLY_SETTLE_MS = 80; + +export type ServoMode = "servo_mode" | "cr_mode" | "unknown"; + +export interface IdentifyReply { + present: boolean; + rawRx: Buffer; + statusHi: number; + statusLo: number; + modeByte: number | null; + mode: ServoMode; +} + +function buildTx(cmd: number, addr: number, length: number): Buffer { + const tx = Buffer.alloc(REPORT_SIZE); + tx[0] = REPORT_ID; + tx[1] = cmd; + tx[2] = (addr >> 8) & 0xff; + tx[3] = addr & 0xff; + tx[4] = length; + return tx; +} + +function buildWriteTx(addr: number, data: Uint8Array): Buffer { + if (data.length > MAX_CHUNK) { + throw new Error(`buildWriteTx: data is ${data.length} bytes, max chunk is ${MAX_CHUNK}`); + } + const tx = Buffer.alloc(REPORT_SIZE); + tx[0] = REPORT_ID; + tx[1] = CMD_WRITE; + tx[2] = (addr >> 8) & 0xff; + tx[3] = addr & 0xff; + tx[4] = data.length; + for (let i = 0; i < data.length; i++) { + tx[5 + i] = data[i] ?? 0; + } + return tx; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function identify(handle: DongleHandle): Promise { + const tx = buildTx(CMD_IDENTIFY, 0x00, 0x04); + await handle.write(tx); + const rx = await handle.read(); + + if (rx.length < 3) { + throw AxonError.servoIo(`identify reply is ${rx.length} bytes, need at least 3`); + } + const statusHi = rx[1] ?? 0x00; + const statusLo = rx[2] ?? 0xff; + + if (statusLo === 0xfa) { + return { + present: false, + rawRx: rx, + statusHi, + statusLo, + modeByte: null, + mode: "unknown", + }; + } + + if (statusHi !== 0x01 || statusLo !== 0x00) { + throw AxonError.servoIo(`identify nack: rx[0..15]: ${hexPrefix(rx, 16)}`); + } + if (rx.length < 6) { + throw AxonError.servoIo(`identify success reply is ${rx.length} bytes, need at least 6`); + } + + let mode: ServoMode = "unknown"; + const modeByte = rx[5] ?? null; + if (modeByte === 0x03) mode = "servo_mode"; + else if (modeByte === 0x04) mode = "cr_mode"; + + return { present: true, rawRx: rx, statusHi, statusLo, modeByte, mode }; +} + +function hexPrefix(buf: Buffer, count: number): string { + return Array.from(buf.subarray(0, count)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(" "); +} + +export async function readChunk( + handle: DongleHandle, + addr: number, + length: number, +): Promise { + if (length === 0) return Buffer.alloc(0); + if (length > MAX_CHUNK) { + throw new Error(`readChunk: length ${length} exceeds max ${MAX_CHUNK}`); + } + await sleep(CHUNK_SLEEP_MS); + const tx = buildTx(CMD_READ, addr, length); + await handle.write(tx); + await sleep(WIRE_REPLY_SETTLE_MS); + const rx = await handle.read(); + if (rx.length < 5 + length) { + throw AxonError.servoIo(`read reply is ${rx.length} bytes, need at least ${5 + length}`); + } + if (rx[1] !== 0x01 || rx[2] !== 0x00) { + if (rx[2] === 0xfa) throw AxonError.notPrimed(); + const statusHi = rx[1] ?? 0; + const statusLo = rx[2] ?? 0; + throw AxonError.servoIo( + `read nack: rx[1]=0x${statusHi.toString(16).padStart(2, "0")} rx[2]=0x${statusLo.toString(16).padStart(2, "0")}`, + ); + } + return rx.subarray(5, 5 + length); +} + +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); + return full; +} + +export async function writeChunk( + handle: DongleHandle, + addr: number, + data: Uint8Array, +): Promise { + if (data.length === 0) return; + if (data.length > MAX_CHUNK) { + throw new Error(`writeChunk: data is ${data.length} bytes, max chunk is ${MAX_CHUNK}`); + } + await sleep(CHUNK_SLEEP_MS); + const tx = buildWriteTx(addr, data); + await handle.write(tx); + await sleep(WIRE_REPLY_SETTLE_MS); + const rx = await handle.read(); + if (rx[1] === 0) { + const statusLo = rx[2] ?? 0; + throw AxonError.servoIo( + `write nack: rx[1]=0 (expected non-zero). rx[2]=0x${statusLo.toString(16).padStart(2, "0")}`, + ); + } +} + +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}`); + } + await writeChunk(handle, 0x00, config.subarray(0, MAX_CHUNK)); + await writeChunk(handle, MAX_CHUNK, config.subarray(MAX_CHUNK, CONFIG_BLOCK_SIZE)); +} + +export function modelIdFromConfig(config: Uint8Array): string { + let s = ""; + for (let i = 0x40; i < 0x48; i++) { + const b = config[i]; + if (b === undefined || b === 0x00) break; + s += String.fromCharCode(b); + } + return s; +} 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/packages/core/src/errors.ts b/packages/core/src/errors.ts new file mode 100644 index 0000000..9eefbc9 --- /dev/null +++ b/packages/core/src/errors.ts @@ -0,0 +1,115 @@ +/** + * Shared exit codes and typed errors. + * + * The CLI consumes the exit codes directly. Other surfaces can still use + * the stable categories and human-facing recovery hints. + */ + +export const ExitCode = { + Ok: 0, + UsageError: 1, + DongleNotFound: 2, + NotPrimed: 3, + ServoIoError: 4, + ValidationError: 5, + UnknownModel: 6, +} as const; + +export type ExitCodeName = keyof typeof ExitCode; +export type ExitCodeValue = (typeof ExitCode)[ExitCodeName]; + +export type ErrorCategory = + | "no_adapter" + | "adapter_busy" + | "adapter_io" + | "no_servo" + | "servo_io" + | "unknown_model" + | "validation" + | "usage" + | "internal"; + +export class AxonError extends Error { + public readonly category: ErrorCategory; + + constructor( + public readonly code: ExitCodeValue, + message: string, + public readonly hint?: string, + category: ErrorCategory = "internal", + ) { + super(message); + this.name = "AxonError"; + this.category = category; + } + + static noAdapter(detail: string): AxonError { + return new AxonError( + ExitCode.DongleNotFound, + detail, + "Plug in the Axon servo programmer adapter. If it is already plugged in, release it from Parallels/Windows or close any app using it, then retry.", + "no_adapter", + ); + } + + static adapterBusy(detail: string): AxonError { + return new AxonError( + ExitCode.DongleNotFound, + `Could not open the Axon adapter: ${detail}`, + "Close any app or VM using the adapter, or release it from Parallels/Windows, then retry.", + "adapter_busy", + ); + } + + static adapterIo(detail: string): AxonError { + return new AxonError( + ExitCode.ServoIoError, + `HID I/O to the Axon adapter failed: ${detail}`, + "Unplug and replug the adapter, then retry.", + "adapter_io", + ); + } + + static noServo(detail = "Adapter connected, but no servo detected."): AxonError { + return new AxonError( + ExitCode.NotPrimed, + detail, + "Unplug and replug the servo from the adapter (leave the adapter connected).", + "no_servo", + ); + } + + static servoIo(detail: string): AxonError { + return new AxonError( + ExitCode.ServoIoError, + `Servo I/O error: ${detail}`, + "Unplug and replug the servo from the adapter (leave the adapter connected).", + "servo_io", + ); + } + + static unknownModel(modelId: string): AxonError { + return new AxonError( + ExitCode.UnknownModel, + `Unknown servo model "${modelId}".`, + `Please open an issue at https://github.com/caryden/servo-programmer/issues with the output of 'axon read --svo > unknown.svo' so this model can be added to the catalog.`, + "unknown_model", + ); + } + + static validation(detail: string): AxonError { + return new AxonError(ExitCode.ValidationError, detail, undefined, "validation"); + } + + static usage(detail: string): AxonError { + return new AxonError(ExitCode.UsageError, detail, undefined, "usage"); + } + + static dongleNotFound(detail: string): AxonError { + return AxonError.noAdapter(detail); + } + + static notPrimed(): AxonError { + return AxonError.noServo(); + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..050f1f6 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,2 @@ +export * from "./catalog.ts"; +export * from "./errors.ts"; 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/research/electrobun-poc/README.md b/research/electrobun-poc/README.md index 29bf9b0..ebe685b 100644 --- a/research/electrobun-poc/README.md +++ b/research/electrobun-poc/README.md @@ -53,7 +53,11 @@ bun run start ## Current implementation tradeoff -This PoC imports the existing Axon modules directly from `../../axon/` -instead of moving code into shared packages first. That is intentional. -The purpose here is to prove the transport and UI boundary before doing -any Turborepo or monorepo restructuring. +This PoC now imports the shared catalog/protocol/error logic from +[`../../packages/core/`](../../packages/core/) while still using the +CLI's `node-hid` transport from [`../../axon/`](../../axon/). That is +an intentional halfway step: + +- shared pure logic moves first +- platform transports stay where they currently live +- bigger repo reshaping can happen after the boundaries are proven diff --git a/research/electrobun-poc/src/bun/index.ts b/research/electrobun-poc/src/bun/index.ts index d0f5bae..74947dc 100644 --- a/research/electrobun-poc/src/bun/index.ts +++ b/research/electrobun-poc/src/bun/index.ts @@ -1,6 +1,6 @@ import { BrowserView, BrowserWindow } from "electrobun/bun"; -import { AxonError } from "../../../../axon/src/errors.ts"; -import { findModel, loadCatalog } from "../../../../axon/src/catalog.ts"; +import { findModel, loadCatalog } from "../../../../packages/core/src/catalog.ts"; +import { AxonError } from "../../../../packages/core/src/errors.ts"; import { listDongles, openDongle, @@ -11,7 +11,7 @@ import { modelIdFromConfig, readFullConfig, type IdentifyReply, -} from "../../../../axon/src/driver/protocol.ts"; +} from "../../../../packages/core/src/driver/protocol.ts"; import type { AdapterInfo, AdapterInventory, From f3c74c2ec96e6d8bace93f95594d8320af7ca4bd Mon Sep 17 00:00:00 2001 From: Carl Ryden Date: Sun, 12 Apr 2026 16:41:42 -0400 Subject: [PATCH 4/5] refactor: move repo into bun workspace layout --- .github/workflows/ci.yml | 6 +- .github/workflows/release.yml | 8 +- .gitignore | 5 +- README.md | 17 ++- {axon => apps/cli}/package.json | 1 + apps/cli/src/catalog.ts | 1 + {axon => apps/cli}/src/cli.ts | 0 {axon => apps/cli}/src/commands/doctor.ts | 0 {axon => apps/cli}/src/commands/get.ts | 0 {axon => apps/cli}/src/commands/mode.ts | 0 {axon => apps/cli}/src/commands/monitor.ts | 0 {axon => apps/cli}/src/commands/read.ts | 0 {axon => apps/cli}/src/commands/set.ts | 0 {axon => apps/cli}/src/commands/status.ts | 0 {axon => apps/cli}/src/commands/write.ts | 0 {axon => apps/cli}/src/driver/flash.ts | 0 {axon => apps/cli}/src/driver/hid.ts | 0 {axon => apps/cli}/src/driver/nodehid.ts | 0 apps/cli/src/driver/protocol.ts | 1 + apps/cli/src/driver/transport.ts | 1 + apps/cli/src/errors.ts | 1 + {axon => apps/cli}/src/firmware-store.ts | 0 {axon => apps/cli}/src/parameters.ts | 0 {axon => apps/cli}/src/sfw.ts | 0 {axon => apps/cli}/src/util/prompt.ts | 0 {axon => apps/cli}/src/util/tui.ts | 0 {axon => apps/cli}/test/.gitkeep | 0 {axon => apps/cli}/test/catalog.test.ts | 0 {axon => apps/cli}/test/cli.test.ts | 0 .../cli}/test/cli.unexpected-error.test.ts | 0 .../cli}/test/commands/doctor.test.ts | 0 {axon => apps/cli}/test/commands/get.test.ts | 0 .../cli}/test/commands/mode.security.test.ts | 0 {axon => apps/cli}/test/commands/mode.test.ts | 2 +- .../cli}/test/commands/monitor.test.ts | 0 {axon => apps/cli}/test/commands/read.test.ts | 0 {axon => apps/cli}/test/commands/set.test.ts | 0 .../cli}/test/commands/status.test.ts | 0 .../cli}/test/commands/write.test.ts | 0 {axon => apps/cli}/test/driver/flash.test.ts | 0 {axon => apps/cli}/test/driver/hid.test.ts | 0 .../cli}/test/driver/protocol.test.ts | 0 .../cli}/test/firmware-store.test.ts | 0 .../cli}/test/fixtures/cold_state_replies.hex | 0 .../cli}/test/fixtures/dual_test7_config.svo | Bin .../cli}/test/fixtures/identify_reply.hex | 0 {axon => apps/cli}/test/helpers/capture-io.ts | 0 {axon => apps/cli}/test/mocks/mock-dongle.ts | 0 {axon => apps/cli}/test/parameters.test.ts | 0 {axon => apps/cli}/test/prompt.test.ts | 0 {axon => apps/cli}/test/sanity.test.ts | 0 {axon => apps/cli}/test/sfw.test.ts | 2 +- {axon => apps/cli}/tsconfig.json | 0 .../desktop}/.gitignore | 0 .../electrobun-poc => apps/desktop}/README.md | 9 +- .../desktop}/electrobun.config.ts | 0 .../desktop}/package.json | 7 +- .../desktop}/src/bun/index.ts | 20 +-- .../desktop}/src/mainview/index.css | 0 .../desktop}/src/mainview/index.html | 0 .../desktop}/src/mainview/index.ts | 0 .../desktop}/src/shared/api.ts | 8 +- .../desktop}/src/types/three.d.ts | 0 .../desktop}/tsconfig.json | 0 {research/webhid-poc => apps/web}/README.md | 3 +- {research/webhid-poc => apps/web}/app.js | 0 {research/webhid-poc => apps/web}/index.html | 0 apps/web/package.json | 10 ++ axon/bun.lock | 84 ------------ axon/src/catalog.ts | 1 - axon/src/driver/protocol.ts | 1 - axon/src/driver/transport.ts | 1 - axon/src/errors.ts | 1 - biome.json | 31 +++-- research/electrobun-poc/bun.lock => bun.lock | 122 +++++++++++++++++- docs/INSTALL.md | 3 +- docs/README.md | 5 +- docs/licenses.md | 4 +- docs/the-adventure.md | 8 +- package.json | 20 +++ packages/core/package.json | 19 ++- packages/core/src/driver/protocol.ts | 4 +- research/README.md | 10 +- research/python-tests/README.md | 2 +- research/sudoers/uninstall_sudoers.sh | 2 +- scripts/build-release.sh | 16 +-- scripts/ci-local.sh | 10 +- turbo.json | 21 +++ 88 files changed, 291 insertions(+), 176 deletions(-) rename {axon => apps/cli}/package.json (95%) create mode 100644 apps/cli/src/catalog.ts rename {axon => apps/cli}/src/cli.ts (100%) rename {axon => apps/cli}/src/commands/doctor.ts (100%) rename {axon => apps/cli}/src/commands/get.ts (100%) rename {axon => apps/cli}/src/commands/mode.ts (100%) rename {axon => apps/cli}/src/commands/monitor.ts (100%) rename {axon => apps/cli}/src/commands/read.ts (100%) rename {axon => apps/cli}/src/commands/set.ts (100%) rename {axon => apps/cli}/src/commands/status.ts (100%) rename {axon => apps/cli}/src/commands/write.ts (100%) rename {axon => apps/cli}/src/driver/flash.ts (100%) rename {axon => apps/cli}/src/driver/hid.ts (100%) rename {axon => apps/cli}/src/driver/nodehid.ts (100%) create mode 100644 apps/cli/src/driver/protocol.ts create mode 100644 apps/cli/src/driver/transport.ts create mode 100644 apps/cli/src/errors.ts rename {axon => apps/cli}/src/firmware-store.ts (100%) rename {axon => apps/cli}/src/parameters.ts (100%) rename {axon => apps/cli}/src/sfw.ts (100%) rename {axon => apps/cli}/src/util/prompt.ts (100%) rename {axon => apps/cli}/src/util/tui.ts (100%) rename {axon => apps/cli}/test/.gitkeep (100%) rename {axon => apps/cli}/test/catalog.test.ts (100%) rename {axon => apps/cli}/test/cli.test.ts (100%) rename {axon => apps/cli}/test/cli.unexpected-error.test.ts (100%) rename {axon => apps/cli}/test/commands/doctor.test.ts (100%) rename {axon => apps/cli}/test/commands/get.test.ts (100%) rename {axon => apps/cli}/test/commands/mode.security.test.ts (100%) rename {axon => apps/cli}/test/commands/mode.test.ts (99%) rename {axon => apps/cli}/test/commands/monitor.test.ts (100%) rename {axon => apps/cli}/test/commands/read.test.ts (100%) rename {axon => apps/cli}/test/commands/set.test.ts (100%) rename {axon => apps/cli}/test/commands/status.test.ts (100%) rename {axon => apps/cli}/test/commands/write.test.ts (100%) rename {axon => apps/cli}/test/driver/flash.test.ts (100%) rename {axon => apps/cli}/test/driver/hid.test.ts (100%) rename {axon => apps/cli}/test/driver/protocol.test.ts (100%) rename {axon => apps/cli}/test/firmware-store.test.ts (100%) rename {axon => apps/cli}/test/fixtures/cold_state_replies.hex (100%) rename {axon => apps/cli}/test/fixtures/dual_test7_config.svo (100%) rename {axon => apps/cli}/test/fixtures/identify_reply.hex (100%) rename {axon => apps/cli}/test/helpers/capture-io.ts (100%) rename {axon => apps/cli}/test/mocks/mock-dongle.ts (100%) rename {axon => apps/cli}/test/parameters.test.ts (100%) rename {axon => apps/cli}/test/prompt.test.ts (100%) rename {axon => apps/cli}/test/sanity.test.ts (100%) rename {axon => apps/cli}/test/sfw.test.ts (99%) rename {axon => apps/cli}/tsconfig.json (100%) rename {research/electrobun-poc => apps/desktop}/.gitignore (100%) rename {research/electrobun-poc => apps/desktop}/README.md (88%) rename {research/electrobun-poc => apps/desktop}/electrobun.config.ts (100%) rename {research/electrobun-poc => apps/desktop}/package.json (60%) rename {research/electrobun-poc => apps/desktop}/src/bun/index.ts (93%) rename {research/electrobun-poc => apps/desktop}/src/mainview/index.css (100%) rename {research/electrobun-poc => apps/desktop}/src/mainview/index.html (100%) rename {research/electrobun-poc => apps/desktop}/src/mainview/index.ts (100%) rename {research/electrobun-poc => apps/desktop}/src/shared/api.ts (93%) rename {research/electrobun-poc => apps/desktop}/src/types/three.d.ts (100%) rename {research/electrobun-poc => apps/desktop}/tsconfig.json (100%) rename {research/webhid-poc => apps/web}/README.md (96%) rename {research/webhid-poc => apps/web}/app.js (100%) rename {research/webhid-poc => apps/web}/index.html (100%) create mode 100644 apps/web/package.json delete mode 100644 axon/bun.lock delete mode 100644 axon/src/catalog.ts delete mode 100644 axon/src/driver/protocol.ts delete mode 100644 axon/src/driver/transport.ts delete mode 100644 axon/src/errors.ts rename research/electrobun-poc/bun.lock => bun.lock (56%) create mode 100644 package.json create mode 100644 turbo.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c1beef..6f7fd13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,17 +44,13 @@ 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: 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/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..449f4f7 100644 --- a/.gitignore +++ b/.gitignore @@ -30,9 +30,10 @@ ENV/ # Node / Bun node_modules/ +.turbo/ *.log -axon/bin/ -axon/dist/ +apps/cli/bin/ +apps/cli/dist/ # Capture files (large binary data) captures/*.sal diff --git a/README.md b/README.md index 7d5ea09..0e828f1 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,21 @@ 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 + ## 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 95% rename from axon/package.json rename to apps/cli/package.json index 8806983..4d466b9 100644 --- a/axon/package.json +++ b/apps/cli/package.json @@ -17,6 +17,7 @@ "test": "bun test" }, "dependencies": { + "@axon/core": "workspace:*", "node-hid": "^3.3.0" }, "devDependencies": { 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 100% rename from axon/src/cli.ts rename to apps/cli/src/cli.ts diff --git a/axon/src/commands/doctor.ts b/apps/cli/src/commands/doctor.ts similarity index 100% rename from axon/src/commands/doctor.ts rename to apps/cli/src/commands/doctor.ts diff --git a/axon/src/commands/get.ts b/apps/cli/src/commands/get.ts similarity index 100% rename from axon/src/commands/get.ts rename to apps/cli/src/commands/get.ts diff --git a/axon/src/commands/mode.ts b/apps/cli/src/commands/mode.ts similarity index 100% rename from axon/src/commands/mode.ts rename to apps/cli/src/commands/mode.ts diff --git a/axon/src/commands/monitor.ts b/apps/cli/src/commands/monitor.ts similarity index 100% rename from axon/src/commands/monitor.ts rename to apps/cli/src/commands/monitor.ts diff --git a/axon/src/commands/read.ts b/apps/cli/src/commands/read.ts similarity index 100% rename from axon/src/commands/read.ts rename to apps/cli/src/commands/read.ts diff --git a/axon/src/commands/set.ts b/apps/cli/src/commands/set.ts similarity index 100% rename from axon/src/commands/set.ts rename to apps/cli/src/commands/set.ts diff --git a/axon/src/commands/status.ts b/apps/cli/src/commands/status.ts similarity index 100% rename from axon/src/commands/status.ts rename to apps/cli/src/commands/status.ts diff --git a/axon/src/commands/write.ts b/apps/cli/src/commands/write.ts similarity index 100% rename from axon/src/commands/write.ts rename to apps/cli/src/commands/write.ts diff --git a/axon/src/driver/flash.ts b/apps/cli/src/driver/flash.ts similarity index 100% rename from axon/src/driver/flash.ts rename to apps/cli/src/driver/flash.ts diff --git a/axon/src/driver/hid.ts b/apps/cli/src/driver/hid.ts similarity index 100% rename from axon/src/driver/hid.ts rename to apps/cli/src/driver/hid.ts diff --git a/axon/src/driver/nodehid.ts b/apps/cli/src/driver/nodehid.ts similarity index 100% rename from axon/src/driver/nodehid.ts rename to apps/cli/src/driver/nodehid.ts 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 100% rename from axon/src/firmware-store.ts rename to apps/cli/src/firmware-store.ts diff --git a/axon/src/parameters.ts b/apps/cli/src/parameters.ts similarity index 100% rename from axon/src/parameters.ts rename to apps/cli/src/parameters.ts diff --git a/axon/src/sfw.ts b/apps/cli/src/sfw.ts similarity index 100% rename from axon/src/sfw.ts rename to apps/cli/src/sfw.ts diff --git a/axon/src/util/prompt.ts b/apps/cli/src/util/prompt.ts similarity index 100% rename from axon/src/util/prompt.ts rename to apps/cli/src/util/prompt.ts 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 100% rename from axon/test/commands/read.test.ts rename to apps/cli/test/commands/read.test.ts 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 100% rename from axon/test/driver/hid.test.ts rename to apps/cli/test/driver/hid.test.ts 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/research/electrobun-poc/.gitignore b/apps/desktop/.gitignore similarity index 100% rename from research/electrobun-poc/.gitignore rename to apps/desktop/.gitignore diff --git a/research/electrobun-poc/README.md b/apps/desktop/README.md similarity index 88% rename from research/electrobun-poc/README.md rename to apps/desktop/README.md index ebe685b..f6513cc 100644 --- a/research/electrobun-poc/README.md +++ b/apps/desktop/README.md @@ -11,7 +11,7 @@ It answers one narrow question: ## Why this shape -The browser PoC in [`../webhid-poc/`](../webhid-poc/) proved that a +The browser PoC in [`../web/`](../web/) proved that a Chromium browser can talk to the adapter through WebHID. That is not a good desktop abstraction, though: @@ -39,15 +39,14 @@ It does **not** attempt to write config or flash firmware. From the repo root, make sure the CLI dependencies are present: ```bash -cd axon +cd /Users/caryden/github/servo-programmer bun install ``` Then start the Electrobun app: ```bash -cd /Users/caryden/github/servo-programmer/research/electrobun-poc -bun install +cd /Users/caryden/github/servo-programmer/apps/desktop bun run start ``` @@ -55,7 +54,7 @@ bun run start This PoC now imports the shared catalog/protocol/error logic from [`../../packages/core/`](../../packages/core/) while still using the -CLI's `node-hid` transport from [`../../axon/`](../../axon/). That is +CLI's `node-hid` transport from [`../cli/`](../cli/). That is an intentional halfway step: - shared pure logic moves first diff --git a/research/electrobun-poc/electrobun.config.ts b/apps/desktop/electrobun.config.ts similarity index 100% rename from research/electrobun-poc/electrobun.config.ts rename to apps/desktop/electrobun.config.ts diff --git a/research/electrobun-poc/package.json b/apps/desktop/package.json similarity index 60% rename from research/electrobun-poc/package.json rename to apps/desktop/package.json index d23f118..5e4cb02 100644 --- a/research/electrobun-poc/package.json +++ b/apps/desktop/package.json @@ -1,17 +1,20 @@ { - "name": "axon-electrobun-poc", + "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": "bunx tsc --noEmit" + "typecheck": "tsc --noEmit" }, "dependencies": { + "@axon/core": "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/research/electrobun-poc/src/bun/index.ts b/apps/desktop/src/bun/index.ts similarity index 93% rename from research/electrobun-poc/src/bun/index.ts rename to apps/desktop/src/bun/index.ts index 74947dc..1afbf80 100644 --- a/research/electrobun-poc/src/bun/index.ts +++ b/apps/desktop/src/bun/index.ts @@ -1,17 +1,13 @@ -import { BrowserView, BrowserWindow } from "electrobun/bun"; -import { findModel, loadCatalog } from "../../../../packages/core/src/catalog.ts"; -import { AxonError } from "../../../../packages/core/src/errors.ts"; -import { - listDongles, - openDongle, - type DongleDescriptor, -} from "../../../../axon/src/driver/hid.ts"; +import { findModel, loadCatalog } from "@axon/core/catalog"; import { + type IdentifyReply, identify, modelIdFromConfig, readFullConfig, - type IdentifyReply, -} from "../../../../packages/core/src/driver/protocol.ts"; +} from "@axon/core/driver/protocol"; +import { AxonError } from "@axon/core/errors"; +import { BrowserView, BrowserWindow } from "electrobun/bun"; +import { type DongleDescriptor, listDongles, openDongle } from "../../../cli/src/driver/hid.ts"; import type { AdapterInfo, AdapterInventory, @@ -27,9 +23,7 @@ const WINDOW_WIDTH = 960; const WINDOW_HEIGHT = 860; let activeDescriptor: DongleDescriptor | null = null; -let activeHandle: - | Awaited> - | null = null; +let activeHandle: Awaited> | null = null; function hex(value: number | undefined, width = 2): string | null { if (value === undefined) return null; diff --git a/research/electrobun-poc/src/mainview/index.css b/apps/desktop/src/mainview/index.css similarity index 100% rename from research/electrobun-poc/src/mainview/index.css rename to apps/desktop/src/mainview/index.css diff --git a/research/electrobun-poc/src/mainview/index.html b/apps/desktop/src/mainview/index.html similarity index 100% rename from research/electrobun-poc/src/mainview/index.html rename to apps/desktop/src/mainview/index.html diff --git a/research/electrobun-poc/src/mainview/index.ts b/apps/desktop/src/mainview/index.ts similarity index 100% rename from research/electrobun-poc/src/mainview/index.ts rename to apps/desktop/src/mainview/index.ts diff --git a/research/electrobun-poc/src/shared/api.ts b/apps/desktop/src/shared/api.ts similarity index 93% rename from research/electrobun-poc/src/shared/api.ts rename to apps/desktop/src/shared/api.ts index 2541c1f..eb8ba93 100644 --- a/research/electrobun-poc/src/shared/api.ts +++ b/apps/desktop/src/shared/api.ts @@ -1,3 +1,5 @@ +type EmptySchema = Record; + export interface SerializedAxonError { message: string; code?: number; @@ -72,10 +74,10 @@ export type DesktopPocSchema = { identifyServo: { params: undefined; response: RpcResult }; readFullConfig: { params: undefined; response: RpcResult }; }; - messages: {}; + messages: EmptySchema; }; webview: { - requests: {}; - messages: {}; + requests: EmptySchema; + messages: EmptySchema; }; }; diff --git a/research/electrobun-poc/src/types/three.d.ts b/apps/desktop/src/types/three.d.ts similarity index 100% rename from research/electrobun-poc/src/types/three.d.ts rename to apps/desktop/src/types/three.d.ts diff --git a/research/electrobun-poc/tsconfig.json b/apps/desktop/tsconfig.json similarity index 100% rename from research/electrobun-poc/tsconfig.json rename to apps/desktop/tsconfig.json diff --git a/research/webhid-poc/README.md b/apps/web/README.md similarity index 96% rename from research/webhid-poc/README.md rename to apps/web/README.md index 037daac..e05274f 100644 --- a/research/webhid-poc/README.md +++ b/apps/web/README.md @@ -27,7 +27,8 @@ and `http://localhost` qualifies. From the repo root: ```bash -python3 -m http.server 8765 --directory research/webhid-poc +cd apps/web +python3 -m http.server 8765 ``` Then open: diff --git a/research/webhid-poc/app.js b/apps/web/app.js similarity index 100% rename from research/webhid-poc/app.js rename to apps/web/app.js diff --git a/research/webhid-poc/index.html b/apps/web/index.html similarity index 100% rename from research/webhid-poc/index.html rename to apps/web/index.html diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..ddcdbdb --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,10 @@ +{ + "name": "@axon/web-poc", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "python3 -m http.server 8765", + "start": "python3 -m http.server 8765" + } +} 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/catalog.ts b/axon/src/catalog.ts deleted file mode 100644 index f71171f..0000000 --- a/axon/src/catalog.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../../packages/core/src/catalog.ts"; diff --git a/axon/src/driver/protocol.ts b/axon/src/driver/protocol.ts deleted file mode 100644 index d301f1b..0000000 --- a/axon/src/driver/protocol.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../../../packages/core/src/driver/protocol.ts"; diff --git a/axon/src/driver/transport.ts b/axon/src/driver/transport.ts deleted file mode 100644 index db9a6e2..0000000 --- a/axon/src/driver/transport.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../../../packages/core/src/driver/transport.ts"; diff --git a/axon/src/errors.ts b/axon/src/errors.ts deleted file mode 100644 index d859997..0000000 --- a/axon/src/errors.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../../packages/core/src/errors.ts"; diff --git a/biome.json b/biome.json index 6f6c419..1dcacf2 100644 --- a/biome.json +++ b/biome.json @@ -7,23 +7,30 @@ }, "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/desktop/src/**/*.ts", + "apps/desktop/src/**/*.tsx", + "apps/desktop/package.json", + "apps/desktop/electrobun.config.ts", + "packages/core/src/**/*.ts", + "packages/core/package.json", "!**/node_modules", "!research", "!vendor", "!data", "!docs", - "!axon/bin", - "!axon/node_modules" + "!apps/cli/bin", + "!apps/cli/node_modules", + "!apps/desktop/build" ] }, "formatter": { diff --git a/research/electrobun-poc/bun.lock b/bun.lock similarity index 56% rename from research/electrobun-poc/bun.lock rename to bun.lock index 983ce58..394c491 100644 --- a/research/electrobun-poc/bun.lock +++ b/bun.lock @@ -3,31 +3,105 @@ "configVersion": 1, "workspaces": { "": { - "name": "axon-electrobun-poc", + "name": "servo-programmer", + "devDependencies": { + "turbo": "^2", + }, + }, + "apps/cli": { + "name": "axon", + "version": "1.0.0", + "bin": { + "axon": "./src/cli.ts", + }, + "dependencies": { + "@axon/core": "workspace:*", + "node-hid": "^3.3.0", + }, + "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:*", "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", + }, + "packages/core": { + "name": "@axon/core", + "version": "0.0.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/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=="], "@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@1.1.1", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ=="], "@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@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + "@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="], "@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="], @@ -39,12 +113,24 @@ "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=="], + "axon": ["axon@workspace:apps/cli"], + "basic-ftp": ["basic-ftp@5.2.2", "", {}, "sha512-1tDrzKsdCg70WGvbFss/ulVAxupNauGnOlgpyjKzeQxzyllBLS0CGLV7tjIXTK3ZQA9/FBEm9qyFFN1bciA6pw=="], "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + "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=="], + "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=="], @@ -57,6 +143,10 @@ "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=="], + + "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=="], @@ -67,6 +157,8 @@ "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "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=="], "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], @@ -77,6 +169,8 @@ "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=="], @@ -91,12 +185,18 @@ "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=="], + "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=="], + "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=="], @@ -107,6 +207,8 @@ "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=="], + "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=="], @@ -119,16 +221,26 @@ "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=="], + "three": ["three@0.165.0", "", {}, "sha512-cc96IlVYGydeceu0e5xq70H8/yoVT/tXBxV/W8A/U6uOq7DXc4/s1Mkmnu6SqoYGhSRWWYFOhVwvq6V0VtbplA=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "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=="], - "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "png-to-ico/@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="], + "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..f804f71 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,7 +29,10 @@ 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. | | [../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/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..3ecbd17 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "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": { + "turbo": "^2" + } +} diff --git a/packages/core/package.json b/packages/core/package.json index 2f89f29..429b4b6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -2,5 +2,22 @@ "name": "@axon/core", "version": "0.0.1", "private": true, - "type": "module" + "type": "module", + "exports": { + ".": "./src/index.ts", + "./catalog": "./src/catalog.ts", + "./errors": "./src/errors.ts", + "./driver": "./src/driver/index.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" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.11", + "@types/bun": "latest", + "typescript": "^5" + } } diff --git a/packages/core/src/driver/protocol.ts b/packages/core/src/driver/protocol.ts index 6b2d000..96a42a6 100644 --- a/packages/core/src/driver/protocol.ts +++ b/packages/core/src/driver/protocol.ts @@ -135,8 +135,8 @@ 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; } diff --git a/research/README.md b/research/README.md index ee5ebe1..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 @@ -18,8 +18,6 @@ they're archaeology. | [`sudoers/`](sudoers/) | The narrow NOPASSWD `sudoers` rule and the `install_sudoers.sh` / `uninstall_sudoers.sh` scripts | Only useful for the legacy libusb scripts in `python-tests/`. The production CLI uses node-hid and does not need any of this. | | [`etw-capture/`](etw-capture/) | The Windows batch script that drives `logman` to set up a USB ETW trace from inside the Parallels VM | Run this from inside Windows to produce a fresh `axon-usb.etl` if you need a new trace. | | [`session-notes/`](session-notes/) | The original session diary: `REVERSE_ENGINEERING_GUIDE.md` (the early planning doc) and `SESSION_2026-04-09_VM_PROBE.md` (notes from the Parallels/ETW investigation) | Sources for the polished blog post in [`docs/the-adventure.md`](../docs/the-adventure.md). | -| [`webhid-poc/`](webhid-poc/) | A minimal browser proof-of-concept for talking to the adapter over WebHID on `localhost` | Quick feasibility check for a future browser/WebHID path before any serious app architecture work. | -| [`electrobun-poc/`](electrobun-poc/) | A minimal Electrobun desktop proof-of-concept that keeps HID in the Bun main process and talks to it over Electrobun RPC | Feasibility check for a desktop GUI path that can share frontend structure with the browser PoC without depending on WebHID support in the desktop renderer. | ## How to read the story @@ -50,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": [] + } + } +} From 2439a6584490a767dbf699298744c606292dd5fc Mon Sep 17 00:00:00 2001 From: Carl Ryden Date: Sun, 12 Apr 2026 17:05:24 -0400 Subject: [PATCH 5/5] refactor: share probe ui and transports --- .gitignore | 1 + README.md | 3 + apps/cli/package.json | 2 +- apps/cli/src/driver/hid.ts | 178 +--------- apps/cli/src/driver/nodehid.ts | 64 +--- apps/cli/test/driver/hid.test.ts | 6 +- apps/desktop/README.md | 17 +- apps/desktop/package.json | 2 + apps/desktop/src/bun/index.ts | 22 +- apps/desktop/src/mainview/index.html | 44 +-- apps/desktop/src/mainview/index.ts | 203 +++--------- apps/desktop/src/shared/api.ts | 49 +-- apps/web/README.md | 26 +- apps/web/app.js | 295 ----------------- apps/web/index.html | 118 +------ apps/web/package.json | 17 +- apps/web/src/index.ts | 156 +++++++++ apps/web/src/webhid.d.ts | 48 +++ apps/web/tsconfig.json | 16 + biome.json | 18 +- bun.lock | 55 +++- docs/README.md | 3 + packages/transport-nodehid/package.json | 23 ++ packages/transport-nodehid/src/hid.ts | 177 ++++++++++ packages/transport-nodehid/src/nodehid.ts | 63 ++++ packages/transport-nodehid/tsconfig.json | 16 + packages/transport-webhid/package.json | 22 ++ packages/transport-webhid/src/index.ts | 139 ++++++++ packages/transport-webhid/src/webhid.d.ts | 58 ++++ packages/transport-webhid/tsconfig.json | 16 + packages/ui/package.json | 18 ++ packages/ui/src/index.ts | 1 + packages/ui/src/probe.ts | 378 ++++++++++++++++++++++ packages/ui/tsconfig.json | 16 + 34 files changed, 1331 insertions(+), 939 deletions(-) delete mode 100644 apps/web/app.js create mode 100644 apps/web/src/index.ts create mode 100644 apps/web/src/webhid.d.ts create mode 100644 apps/web/tsconfig.json create mode 100644 packages/transport-nodehid/package.json create mode 100644 packages/transport-nodehid/src/hid.ts create mode 100644 packages/transport-nodehid/src/nodehid.ts create mode 100644 packages/transport-nodehid/tsconfig.json create mode 100644 packages/transport-webhid/package.json create mode 100644 packages/transport-webhid/src/index.ts create mode 100644 packages/transport-webhid/src/webhid.d.ts create mode 100644 packages/transport-webhid/tsconfig.json create mode 100644 packages/ui/package.json create mode 100644 packages/ui/src/index.ts create mode 100644 packages/ui/src/probe.ts create mode 100644 packages/ui/tsconfig.json diff --git a/.gitignore b/.gitignore index 449f4f7..a64b5c0 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ node_modules/ *.log apps/cli/bin/ apps/cli/dist/ +apps/web/dist/ # Capture files (large binary data) captures/*.sal diff --git a/README.md b/README.md index 0e828f1..a6fd715 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,9 @@ The repo is now organized as a Bun workspace: - [`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 diff --git a/apps/cli/package.json b/apps/cli/package.json index 4d466b9..3b8ba31 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -18,7 +18,7 @@ }, "dependencies": { "@axon/core": "workspace:*", - "node-hid": "^3.3.0" + "@axon/transport-nodehid": "workspace:*" }, "devDependencies": { "@biomejs/biome": "^2.4.11", diff --git a/apps/cli/src/driver/hid.ts b/apps/cli/src/driver/hid.ts index c577211..e14a9b5 100644 --- a/apps/cli/src/driver/hid.ts +++ b/apps/cli/src/driver/hid.ts @@ -1,177 +1 @@ -/** - * Production HID transport for the Axon dongle via node-hid. - * - * Implements the abstract `DongleHandle` interface from - * `transport.ts`. The protocol layer in `protocol.ts` only sees the - * abstract interface — it has no compile-time dependency on - * `node-hid`. Tests inject a `MockDongle` instead. - * - * This module replaces an earlier libusb-based driver that required - * `sudo` on macOS to claim HID-class interfaces. node-hid goes - * through the OS's HID framework (IOKit HID Manager on macOS, - * hidraw on Linux, HID.dll on Windows) and does NOT need elevated - * privileges on any platform. - * - * IMPORTANT: the dongle has a state machine that can be put into a - * "cold" mode by a USB bus reset. node-hid does not expose any - * primitive that can do this — we're safe by construction. The risk - * was a libusb-era footgun. - */ - -import { AxonError } from "../errors.ts"; -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; -export const REPORT_SIZE = 64; - -export type DongleDescriptor = NodeHidDevice; - -/** - * Wrap a raw node-hid failure in an AxonError so the top-level - * catch in cli.ts renders it cleanly (no stack trace). The detail - * string is the raw error message from node-hid — we do NOT - * speculate about the cause in the hint; we just surface what the - * OS told us and ask the user to replug. - * - * Previously this file retried once on failure. The retry was - * removed because it masked the root cause — if node-hid keeps - * failing, we want to see the first error verbatim so we can - * actually fix the bug instead of papering over it. - */ -function hidWriteError(detail: string): AxonError { - return AxonError.adapterIo(detail); -} - -function hidReadError(detail: string): AxonError { - return AxonError.adapterIo(detail); -} - -function hidEnumerateError(detail: string): AxonError { - return AxonError.adapterIo(`HID enumeration failed: ${detail}`); -} - -/** - * Cheap "which dongles are visible through this OS's HID layer?" - * check. Does NOT open the device — safe to call from `axon status` - * and `axon monitor`'s "not-yet-opened" state. - * - * This is deliberately phrased as HID visibility rather than physical - * USB presence: if a VM such as Parallels has captured the adapter, - * macOS may still list the raw USB device while node-hid cannot see - * an openable HID interface. - */ -export function listDongles(): DongleDescriptor[] { - try { - return getNodeHidBinding().devices(VID, PID); - } catch (e) { - throw hidEnumerateError((e as Error).message ?? "unknown failure"); - } -} - -/** - * Back-compat helper for callers that only need a boolean. - */ -export function isDonglePresent(): boolean { - return listDongles().length > 0; -} - -/** - * Concrete `DongleHandle` implementation backed by a node-hid - * `HID.HID` instance. Internal — instances are produced by - * `openDongle()`. - */ -class NodeHidDongle implements DongleHandle { - private device: NodeHidHandle; - private released = false; - - constructor(device: NodeHidHandle) { - this.device = device; - } - - async write(data: Buffer): Promise { - if (this.released) { - throw hidWriteError("handle was released"); - } - if (data.length > REPORT_SIZE) { - throw hidWriteError(`buffer is ${data.length} bytes, max is ${REPORT_SIZE}`); - } - const padded = Buffer.alloc(REPORT_SIZE); - data.copy(padded, 0); - try { - const n = this.device.write(Array.from(padded)); - if (n <= 0) { - throw hidWriteError(`node-hid write returned ${n} (expected ${REPORT_SIZE})`); - } - } catch (e) { - if (e instanceof AxonError) throw e; - throw hidWriteError((e as Error).message ?? "unknown failure"); - } - } - - async read(timeoutMs = 500): Promise { - if (this.released) { - throw hidReadError("handle was released"); - } - try { - const bytes = this.device.readTimeout(timeoutMs); - if (!bytes || bytes.length === 0) { - throw hidReadError(`read timed out after ${timeoutMs} ms (no bytes available)`); - } - return Buffer.from(bytes); - } catch (e) { - throw hidReadError((e as Error).message ?? "unknown failure"); - } - } - - async release(): Promise { - if (this.released) return; - this.released = true; - try { - this.device.close(); - } catch { - // best effort — node-hid occasionally throws on double-close - } - } -} - -/** - * Open the single Axon dongle. Enforces the "exactly one" invariant - * from the CLI design spec, and emits state-specific errors: - * - * - No matches → AxonError.noAdapter (category "no_adapter") - * - >1 matches → AxonError.noAdapter - * - Open fails → AxonError.adapterBusy (category "adapter_busy", - * usually another process is claiming it) - */ -export async function openDongle(descriptor?: DongleDescriptor): Promise { - const matches = descriptor ? [descriptor] : listDongles(); - if (matches.length === 0) { - throw AxonError.noAdapter("Axon dongle not found on USB."); - } - if (matches.length > 1) { - throw AxonError.noAdapter(`Found ${matches.length} Axon dongles on USB — expected exactly 1.`); - } - - const selected = matches[0]; - if (selected === undefined) { - throw AxonError.noAdapter("Axon dongle not found on USB."); - } - const path = selected.path; - if (!path) { - throw AxonError.noAdapter( - "Axon dongle enumerated but no device path (node-hid returned an entry without `path`).", - ); - } - - let device: NodeHidHandle; - try { - device = new (getNodeHidBinding().HID)(path); - } catch (e) { - throw AxonError.adapterBusy((e as Error).message); - } - - return new NodeHidDongle(device); -} +export * from "@axon/transport-nodehid"; diff --git a/apps/cli/src/driver/nodehid.ts b/apps/cli/src/driver/nodehid.ts index 643710c..d5a16ac 100644 --- a/apps/cli/src/driver/nodehid.ts +++ b/apps/cli/src/driver/nodehid.ts @@ -1,63 +1 @@ -/** - * Runtime loader for node-hid's platform-specific N-API prebuilds. - * - * We intentionally bypass node-hid's default pkg-prebuilds loader here. - * Bun standalone executables can embed `.node` addons when they are - * required directly, but node-hid resolves its addon through a dynamic - * `__dirname`-based search that bakes the build-machine path into the - * compiled binary. That works on the build host and fails everywhere else. - * - * By selecting the prebuild ourselves with static require() calls, Bun can - * embed the addon into the executable and the release binary remains - * portable across machines. - */ - -export interface NodeHidDevice { - path?: string; - vendorId?: number; - productId?: number; - product?: string; - manufacturer?: string; - serialNumber?: string; - release?: number; - interface?: number; - usagePage?: number; - usage?: number; -} - -export interface NodeHidHandle { - write(data: number[]): number; - readTimeout(timeoutMs: number): number[] | undefined; - close(): void; -} - -export interface NodeHidBinding { - devices(vendorId?: number, productId?: number): NodeHidDevice[]; - HID: new (path: string) => NodeHidHandle; -} - -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"); - case "darwin/x64": - 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"); - case "linux/x64": - 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"); - default: - throw new Error( - `Unsupported platform for embedded node-hid prebuild: ${process.platform}/${process.arch}`, - ); - } -} - -export function getNodeHidBinding(): NodeHidBinding { - cachedBinding ??= loadBindingForCurrentPlatform(); - return cachedBinding; -} +export * from "@axon/transport-nodehid/nodehid"; diff --git a/apps/cli/test/driver/hid.test.ts b/apps/cli/test/driver/hid.test.ts index a075b9f..4a72a23 100644 --- a/apps/cli/test/driver/hid.test.ts +++ b/apps/cli/test/driver/hid.test.ts @@ -1,8 +1,8 @@ /** * Tests for the production HID transport wrapper in `src/driver/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 use a mocked node-hid binding wrapper from the transport package + * so we can exercise the adapter logic without requiring a programmer. */ import { afterEach, describe, expect, mock, test } from "bun:test"; @@ -43,7 +43,7 @@ describe("hid transport", () => { close(): void {} } - await mock.module("../../src/driver/nodehid.ts", () => ({ + await mock.module("@axon/transport-nodehid/nodehid", () => ({ getNodeHidBinding: () => ({ devices: () => [device], HID: FakeHID, diff --git a/apps/desktop/README.md b/apps/desktop/README.md index f6513cc..0cae19a 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -23,6 +23,12 @@ good desktop abstraction, though: So this PoC keeps the UI browser-like but routes all hardware access through Electrobun RPC into the Bun process. +The current implementation now uses: + +- [`../../packages/ui/`](../../packages/ui/) for the shared probe UI +- [`../../packages/transport-nodehid/`](../../packages/transport-nodehid/) for desktop HID access +- [`../../packages/core/`](../../packages/core/) for shared protocol and catalog logic + ## What it does - shows runtime and transport info @@ -49,14 +55,3 @@ Then start the Electrobun app: cd /Users/caryden/github/servo-programmer/apps/desktop bun run start ``` - -## Current implementation tradeoff - -This PoC now imports the shared catalog/protocol/error logic from -[`../../packages/core/`](../../packages/core/) while still using the -CLI's `node-hid` transport from [`../cli/`](../cli/). That is -an intentional halfway step: - -- shared pure logic moves first -- platform transports stay where they currently live -- bigger repo reshaping can happen after the boundaries are proven diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5e4cb02..18771cf 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -11,6 +11,8 @@ }, "dependencies": { "@axon/core": "workspace:*", + "@axon/transport-nodehid": "workspace:*", + "@axon/ui": "workspace:*", "electrobun": "1.16.0" }, "devDependencies": { diff --git a/apps/desktop/src/bun/index.ts b/apps/desktop/src/bun/index.ts index 1afbf80..73d3ff3 100644 --- a/apps/desktop/src/bun/index.ts +++ b/apps/desktop/src/bun/index.ts @@ -6,14 +6,11 @@ import { readFullConfig, } from "@axon/core/driver/protocol"; import { AxonError } from "@axon/core/errors"; +import { type DongleDescriptor, listDongles, openDongle } from "@axon/transport-nodehid"; +import type { ProbeConfigInfo, ProbeDeviceInfo, ProbeIdentifyInfo, ProbeInventory } from "@axon/ui"; import { BrowserView, BrowserWindow } from "electrobun/bun"; -import { type DongleDescriptor, listDongles, openDongle } from "../../../cli/src/driver/hid.ts"; import type { - AdapterInfo, - AdapterInventory, - ConfigInfo, DesktopPocSchema, - IdentifyInfo, RpcResult, RuntimeInfo, SerializedAxonError, @@ -34,9 +31,9 @@ function hexDump(bytes: Uint8Array): string { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(" "); } -function serializeDescriptor(descriptor: DongleDescriptor): AdapterInfo { +function serializeDescriptor(descriptor: DongleDescriptor): ProbeDeviceInfo { return { - path: descriptor.path ?? null, + id: descriptor.path ?? null, vendorId: hex(descriptor.vendorId, 4), productId: hex(descriptor.productId, 4), product: descriptor.product ?? null, @@ -45,13 +42,14 @@ function serializeDescriptor(descriptor: DongleDescriptor): AdapterInfo { interface: descriptor.interface ?? null, usagePage: hex(descriptor.usagePage, 4), usage: hex(descriptor.usage, 4), + opened: descriptor.path === activeDescriptor?.path, }; } -function currentInventory(): AdapterInventory { +function currentInventory(): ProbeInventory { return { - adapters: listDongles().map(serializeDescriptor), - openedPath: activeDescriptor?.path ?? null, + devices: listDongles().map(serializeDescriptor), + openedId: activeDescriptor?.path ?? null, }; } @@ -94,7 +92,7 @@ function requireOpenHandle() { return activeHandle; } -function toIdentifyInfo(reply: IdentifyReply): IdentifyInfo { +function toIdentifyInfo(reply: IdentifyReply): ProbeIdentifyInfo { return { present: reply.present, statusHi: hex(reply.statusHi) ?? "0x00", @@ -159,7 +157,7 @@ const rpc = BrowserView.defineRPC({ const model = findModel(catalog, modelId); const chunkSplit = 59; - const info: ConfigInfo = { + const info: ProbeConfigInfo = { length: config.length, modelId, known: Boolean(model), diff --git a/apps/desktop/src/mainview/index.html b/apps/desktop/src/mainview/index.html index 4b99b3c..dfe4334 100644 --- a/apps/desktop/src/mainview/index.html +++ b/apps/desktop/src/mainview/index.html @@ -4,51 +4,9 @@ Axon Electrobun PoC - -
-

Axon Electrobun PoC

-

- Minimal desktop probe for the Axon V1.3 HID adapter. The UI runs in the Electrobun - WebView, but HID traffic stays in the Bun main process through the existing - node-hid transport and Axon protocol code. -

- -
    -
  • Use this on the host OS with the adapter released from any VM such as Parallels.
  • -
  • This PoC mirrors the WebHID flow, but it does not depend on browser WebHID support.
  • -
  • It only enumerates, identifies, and reads config. It does not write or flash firmware.
  • -
- -
- - - - - -
- -
-
-

Environment

-
Loading runtime info...
-
-
-

Visible Adapters

-
No adapter scan yet.
-
-
-

Latest Reply

-
No traffic yet.
-
-
- -
-

Log

-

-      
-
+
diff --git a/apps/desktop/src/mainview/index.ts b/apps/desktop/src/mainview/index.ts index 68cc7c2..5cb117b 100644 --- a/apps/desktop/src/mainview/index.ts +++ b/apps/desktop/src/mainview/index.ts @@ -1,33 +1,17 @@ +import { + mountProbeApp, + type ProbeConfigInfo, + type ProbeIdentifyInfo, + type ProbeInventory, +} from "@axon/ui"; import { Electroview } from "electrobun/view"; import type { - AdapterInventory, - ConfigInfo, DesktopPocSchema, - IdentifyInfo, RpcResult, RuntimeInfo, SerializedAxonError, } from "../shared/api.ts"; -function mustQuery(selector: string): T { - const element = document.querySelector(selector); - if (!element) { - throw new Error(`Electrobun PoC DOM did not initialize correctly for ${selector}`); - } - return element; -} - -const refreshButton = mustQuery("#refreshAdapters"); -const openButton = mustQuery("#openAdapter"); -const identifyButton = mustQuery("#identifyServo"); -const readButton = mustQuery("#readConfig"); -const closeButton = mustQuery("#closeAdapter"); - -const environmentEl = mustQuery("#environment"); -const deviceInfoEl = mustQuery("#deviceInfo"); -const latestReplyEl = mustQuery("#latestReply"); -const logEl = mustQuery("#log"); - const rpc = Electroview.defineRPC({ handlers: { requests: {}, @@ -37,44 +21,6 @@ const rpc = Electroview.defineRPC({ new Electroview({ rpc }); -let openedPath: string | null = null; -let lastInventory: AdapterInventory | null = null; - -function log(message: string) { - const timestamp = new Date().toLocaleTimeString(); - logEl.textContent += `[${timestamp}] ${message}\n`; - logEl.scrollTop = logEl.scrollHeight; -} - -function renderButtons() { - const visibleCount = lastInventory?.adapters.length ?? 0; - const isOpen = openedPath !== null; - openButton.disabled = visibleCount === 0 || isOpen; - identifyButton.disabled = !isOpen; - readButton.disabled = !isOpen; - closeButton.disabled = !isOpen; -} - -function renderInventory(inventory: AdapterInventory) { - lastInventory = inventory; - openedPath = inventory.openedPath; - const payload = { - openedPath: inventory.openedPath, - visibleCount: inventory.adapters.length, - adapters: inventory.adapters, - }; - deviceInfoEl.textContent = JSON.stringify(payload, null, 2); - renderButtons(); -} - -function renderEnvironment(info: RuntimeInfo) { - environmentEl.textContent = JSON.stringify(info, null, 2); -} - -function renderLatest(value: unknown) { - latestReplyEl.textContent = JSON.stringify(value, null, 2); -} - function describeError(error: SerializedAxonError): string { const out = [`${error.category ?? "internal"}: ${error.message}`]; if (error.hint) { @@ -83,108 +29,51 @@ function describeError(error: SerializedAxonError): string { return out.join("\n"); } -async function handleResult( - promise: Promise>, - onOk: (data: T) => void, - successMessage: string, -) { +async function unwrapResult(promise: Promise>): Promise { const result = await promise; if (!result.ok) { - renderLatest({ error: result.error }); - log(`error: ${describeError(result.error)}`); - return; + throw new Error(describeError(result.error)); } - onOk(result.data); - log(successMessage); -} - -async function refreshAdapters() { - await handleResult( - rpc.request.refreshAdapters(), - (data) => { - renderInventory(data); - renderLatest({ - visibleCount: data.adapters.length, - openedPath: data.openedPath, - }); - }, - "adapter inventory refreshed", - ); + return result.data; } -async function loadRuntime() { - await handleResult( - rpc.request.getRuntime(), - (data) => { - renderEnvironment(data); - }, - "runtime info loaded", - ); +const root = document.getElementById("app"); +if (!(root instanceof HTMLElement)) { + throw new Error("Electrobun PoC DOM did not initialize correctly for #app"); } -async function openAdapter() { - await handleResult( - rpc.request.openAdapter(), - (data) => { - renderInventory(data); - renderLatest({ - openedPath: data.openedPath, - visibleCount: data.adapters.length, - }); - }, - "adapter opened", - ); -} - -async function closeAdapter() { - await handleResult( - rpc.request.closeAdapter(), - (data) => { - renderInventory(data); - renderLatest({ - openedPath: data.openedPath, - visibleCount: data.adapters.length, - }); - }, - "adapter closed", - ); -} - -async function identifyServo() { - await handleResult( - rpc.request.identifyServo(), - (data: IdentifyInfo) => { - renderLatest(data); - }, - "identify completed", - ); -} - -async function readConfig() { - await handleResult( - rpc.request.readFullConfig(), - (data: ConfigInfo) => { - renderLatest(data); - }, - "full config read completed", - ); -} - -refreshButton.addEventListener("click", () => { - void refreshAdapters(); -}); -openButton.addEventListener("click", () => { - void openAdapter(); -}); -identifyButton.addEventListener("click", () => { - void identifyServo(); -}); -readButton.addEventListener("click", () => { - void readConfig(); -}); -closeButton.addEventListener("click", () => { - void closeAdapter(); +mountProbeApp({ + root, + title: "Axon Electrobun PoC", + description: + "Minimal desktop probe for the Axon V1.3 HID adapter. The UI is shared with the WebHID PoC, while HID traffic stays in the Bun main process through the node-hid transport.", + bullets: [ + "Use this on the host OS with the adapter released from any VM such as Parallels.", + "This PoC mirrors the WebHID flow, but it does not depend on browser WebHID support.", + "It only enumerates, identifies, and reads config. It does not write or flash firmware.", + ], + devicePanelTitle: "Visible Adapters", + emptyDeviceText: "No adapter scan yet.", + loadEnvironment: async (): Promise => unwrapResult(rpc.request.getRuntime()), + loadInventory: async (): Promise => unwrapResult(rpc.request.refreshAdapters()), + refreshInventory: { + label: "Refresh Visible Adapters", + run: async (): Promise => unwrapResult(rpc.request.refreshAdapters()), + }, + openDevice: { + label: "Open First Visible Adapter", + run: async (): Promise => unwrapResult(rpc.request.openAdapter()), + }, + closeDevice: { + label: "Close Adapter", + run: async (): Promise => unwrapResult(rpc.request.closeAdapter()), + }, + identifyServo: { + label: "Identify", + run: async (): Promise => unwrapResult(rpc.request.identifyServo()), + }, + readFullConfig: { + label: "Read Full Config", + run: async (): Promise => unwrapResult(rpc.request.readFullConfig()), + }, }); - -void loadRuntime(); -void refreshAdapters(); diff --git a/apps/desktop/src/shared/api.ts b/apps/desktop/src/shared/api.ts index eb8ba93..8341132 100644 --- a/apps/desktop/src/shared/api.ts +++ b/apps/desktop/src/shared/api.ts @@ -1,3 +1,5 @@ +import type { ProbeConfigInfo, ProbeIdentifyInfo, ProbeInventory } from "@axon/ui"; + type EmptySchema = Record; export interface SerializedAxonError { @@ -27,52 +29,15 @@ export interface RuntimeInfo { renderer: string; } -export interface AdapterInfo { - path: 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; -} - -export interface AdapterInventory { - adapters: AdapterInfo[]; - openedPath: string | null; -} - -export interface IdentifyInfo { - present: boolean; - statusHi: string; - statusLo: string; - modeByte: string | null; - mode: string; - rawRx: string; -} - -export interface ConfigInfo { - length: number; - modelId: string; - known: boolean; - modelName: string | null; - docsUrl: string | null; - rawHex: string; - firstChunk: string; - secondChunk: string; -} - export type DesktopPocSchema = { bun: { requests: { getRuntime: { params: undefined; response: RpcResult }; - refreshAdapters: { 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 }; + refreshAdapters: { 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 }; }; messages: EmptySchema; }; diff --git a/apps/web/README.md b/apps/web/README.md index e05274f..a4f1e22 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -9,6 +9,12 @@ It answers one narrow question: > 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`) @@ -28,7 +34,7 @@ From the repo root: ```bash cd apps/web -python3 -m http.server 8765 +bun run start ``` Then open: @@ -37,22 +43,12 @@ Then open: 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, or Brave. +- 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. - -## Protocol note - -The production CLI writes 64-byte HID reports that include report id -`0x04` as byte 0. WebHID passes the report id separately, so this PoC -uses: - -- `reportId = 0x04` -- a 63-byte payload for the rest of the report - -Likewise, the input report event exposes the reply payload without the -report id byte, so the protocol offsets are shifted by `-1` relative to -the `node-hid` code. diff --git a/apps/web/app.js b/apps/web/app.js deleted file mode 100644 index 16d2b7b..0000000 --- a/apps/web/app.js +++ /dev/null @@ -1,295 +0,0 @@ -const VID = 0x0471; -const PID = 0x13aa; -const REPORT_ID = 0x04; -const REPORT_SIZE = 64; -const PAYLOAD_SIZE = REPORT_SIZE - 1; - -const CMD_IDENTIFY = 0x8a; -const CMD_READ = 0xcd; -const MAX_CHUNK = 0x3b; -const CONFIG_BLOCK_SIZE = 95; -const CHUNK_SLEEP_MS = 25; -const REPLY_TIMEOUT_MS = 1000; - -const requestButton = document.querySelector("#requestDevice"); -const reconnectButton = document.querySelector("#reconnect"); -const openButton = document.querySelector("#openDevice"); -const identifyButton = document.querySelector("#identifyDevice"); -const readConfigButton = document.querySelector("#readConfig"); -const closeButton = document.querySelector("#closeDevice"); - -const environmentEl = document.querySelector("#environment"); -const deviceInfoEl = document.querySelector("#deviceInfo"); -const latestReplyEl = document.querySelector("#latestReply"); -const logEl = document.querySelector("#log"); - -let device = null; - -function log(message) { - const timestamp = new Date().toLocaleTimeString(); - logEl.textContent += `[${timestamp}] ${message}\n`; - logEl.scrollTop = logEl.scrollHeight; -} - -function hex(value, width = 2) { - return `0x${value.toString(16).padStart(width, "0")}`; -} - -function hexDump(bytes) { - return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(" "); -} - -function summarizeCollections(selectedDevice) { - return selectedDevice.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), - })); -} - -function renderEnvironment() { - const hasHid = "hid" in navigator; - const lines = [ - `Secure context: ${window.isSecureContext}`, - `WebHID available: ${hasHid}`, - `User agent: ${navigator.userAgent}`, - ]; - environmentEl.textContent = lines.join("\n"); -} - -function renderDevice() { - if (!device) { - deviceInfoEl.textContent = "No device selected."; - return; - } - - const info = { - productName: device.productName, - vendorId: hex(device.vendorId, 4), - productId: hex(device.productId, 4), - opened: device.opened, - collections: summarizeCollections(device), - }; - deviceInfoEl.textContent = JSON.stringify(info, null, 2); -} - -function setButtons() { - const hasDevice = Boolean(device); - const isOpen = Boolean(device?.opened); - openButton.disabled = !hasDevice || isOpen; - identifyButton.disabled = !isOpen; - readConfigButton.disabled = !isOpen; - closeButton.disabled = !isOpen; -} - -function sleep(ms) { - return new Promise((resolve) => window.setTimeout(resolve, ms)); -} - -function buildPayload(command, address = 0x0000, length = 0x00, body = new Uint8Array()) { - if (body.length > MAX_CHUNK) { - throw new Error(`payload body too large: ${body.length} > ${MAX_CHUNK}`); - } - - const payload = new Uint8Array(PAYLOAD_SIZE); - payload[0] = command; - payload[1] = (address >> 8) & 0xff; - payload[2] = address & 0xff; - payload[3] = length & 0xff; - payload.set(body, 4); - return payload; -} - -function waitForInputReport(timeoutMs = REPLY_TIMEOUT_MS) { - if (!device) { - throw new Error("No device selected."); - } - - return new Promise((resolve, reject) => { - const timer = window.setTimeout(() => { - device?.removeEventListener("inputreport", onInputReport); - reject(new Error(`Timed out waiting for input report after ${timeoutMs} ms`)); - }, timeoutMs); - - function onInputReport(event) { - if (event.device !== device) { - return; - } - - window.clearTimeout(timer); - device?.removeEventListener("inputreport", onInputReport); - - const bytes = new Uint8Array( - event.data.buffer.slice(event.data.byteOffset, event.data.byteOffset + event.data.byteLength), - ); - resolve({ - reportId: event.reportId, - data: bytes, - }); - } - - device.addEventListener("inputreport", onInputReport); - }); -} - -async function transfer(payload, timeoutMs = REPLY_TIMEOUT_MS) { - if (!device?.opened) { - throw new Error("Device is not open."); - } - - const replyPromise = waitForInputReport(timeoutMs); - await device.sendReport(REPORT_ID, payload); - return replyPromise; -} - -function modeLabel(modeByte) { - if (modeByte === 0x03) return "servo_mode"; - if (modeByte === 0x04) return "cr_mode"; - return "unknown"; -} - -async function identify() { - const reply = await transfer(buildPayload(CMD_IDENTIFY, 0x0000, 0x04)); - const statusHi = reply.data[0] ?? 0xff; - const statusLo = reply.data[1] ?? 0xff; - const modeByte = reply.data[4] ?? null; - - const parsed = { - reportId: hex(reply.reportId), - statusHi: hex(statusHi), - statusLo: hex(statusLo), - present: statusLo !== 0xfa, - modeByte: modeByte === null ? null : hex(modeByte), - mode: modeByte === null ? "unknown" : modeLabel(modeByte), - rawData: hexDump(reply.data), - }; - - latestReplyEl.textContent = JSON.stringify(parsed, null, 2); - log(`identify reply: statusHi=${parsed.statusHi} statusLo=${parsed.statusLo} mode=${parsed.mode}`); - return parsed; -} - -async function readChunk(address, length) { - await sleep(CHUNK_SLEEP_MS); - const reply = await transfer(buildPayload(CMD_READ, address, length)); - const statusHi = reply.data[0] ?? 0xff; - const statusLo = reply.data[1] ?? 0xff; - - if (statusHi !== 0x01 || statusLo !== 0x00) { - throw new Error( - `read chunk nack at ${hex(address, 4)}: statusHi=${hex(statusHi)} statusLo=${hex(statusLo)}`, - ); - } - - return reply.data.slice(4, 4 + length); -} - -function modelIdFromConfig(config) { - let out = ""; - for (let offset = 0x40; offset < 0x48; offset += 1) { - const value = config[offset]; - if (value === undefined || value === 0x00) { - break; - } - out += String.fromCharCode(value); - } - return out; -} - -async function readFullConfig() { - const chunk0 = await readChunk(0x0000, MAX_CHUNK); - const chunk1 = await readChunk(MAX_CHUNK, CONFIG_BLOCK_SIZE - MAX_CHUNK); - const config = new Uint8Array(CONFIG_BLOCK_SIZE); - config.set(chunk0, 0); - config.set(chunk1, MAX_CHUNK); - - const parsed = { - length: config.length, - modelId: modelIdFromConfig(config), - firstChunk: hexDump(chunk0), - secondChunk: hexDump(chunk1), - }; - - latestReplyEl.textContent = JSON.stringify(parsed, null, 2); - log(`read config: ${config.length} bytes, modelId=${parsed.modelId || "(empty)"}`); - return parsed; -} - -async function pickAuthorizedDevice() { - const devices = await navigator.hid.getDevices(); - return devices.find((candidate) => candidate.vendorId === VID && candidate.productId === PID) ?? null; -} - -async function requestDevice() { - const devices = await navigator.hid.requestDevice({ - filters: [{ vendorId: VID, productId: PID }], - }); - device = devices[0] ?? null; - renderDevice(); - setButtons(); - log(device ? `selected ${device.productName}` : "requestDevice returned no selection"); -} - -async function reconnectAuthorized() { - device = await pickAuthorizedDevice(); - renderDevice(); - setButtons(); - log(device ? `reused authorized device ${device.productName}` : "no authorized Axon device found"); -} - -async function openSelectedDevice() { - if (!device) { - throw new Error("No device selected."); - } - await device.open(); - renderDevice(); - setButtons(); - log("device opened"); -} - -async function closeSelectedDevice() { - if (device?.opened) { - await device.close(); - log("device closed"); - } - renderDevice(); - setButtons(); -} - -async function runAction(fn) { - try { - await fn(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - log(`error: ${message}`); - } -} - -requestButton.addEventListener("click", () => runAction(requestDevice)); -reconnectButton.addEventListener("click", () => runAction(reconnectAuthorized)); -openButton.addEventListener("click", () => runAction(openSelectedDevice)); -identifyButton.addEventListener("click", () => runAction(identify)); -readConfigButton.addEventListener("click", () => runAction(readFullConfig)); -closeButton.addEventListener("click", () => runAction(closeSelectedDevice)); - -if ("hid" in navigator) { - navigator.hid.addEventListener("disconnect", (event) => { - if (device && event.device === device) { - log("selected device disconnected"); - device = null; - renderDevice(); - setButtons(); - } else { - log(`other HID device disconnected: ${event.device.productName}`); - } - }); -} else { - log("WebHID is not available in this browser."); -} - -renderEnvironment(); -renderDevice(); -setButtons(); -log("ready"); diff --git a/apps/web/index.html b/apps/web/index.html index ea5f152..7b7a6f4 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -4,123 +4,9 @@ Axon WebHID PoC - -
-

Axon WebHID PoC

-

- Minimal browser probe for the Axon V1.3 HID adapter. This page requests the device, - opens it over WebHID, sends the identify command, and can read the full 95-byte config - block. -

- -
    -
  • Use a Chromium-based browser on https:// or http://localhost.
  • -
  • Make sure the adapter is owned by the host OS, not a VM such as Parallels.
  • -
  • The PoC uses report id 0x04 with a 63-byte payload.
  • -
- -
- - - - - - -
- -
-
-

Environment

-

-        
-
-

Selected Device

-
No device selected.
-
-
-

Latest Reply

-
No traffic yet.
-
-
- -
-

Log

-

-      
-
- - +
+ diff --git a/apps/web/package.json b/apps/web/package.json index ddcdbdb..5bfb68a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,7 +4,20 @@ "private": true, "type": "module", "scripts": { - "dev": "python3 -m http.server 8765", - "start": "python3 -m http.server 8765" + "build": "bun build ./src/index.ts --target browser --format esm --outdir ./dist", + "check": "bun x @biomejs/biome check src package.json index.html", + "typecheck": "tsc --noEmit -p tsconfig.json", + "dev": "bun run build && python3 -m http.server 8765", + "start": "bun run build && python3 -m http.server 8765" + }, + "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/src/index.ts b/apps/web/src/index.ts new file mode 100644 index 0000000..6bf10c6 --- /dev/null +++ b/apps/web/src/index.ts @@ -0,0 +1,156 @@ +import { findModel, loadCatalog } from "@axon/core/catalog"; +import { identify, modelIdFromConfig, readFullConfig } from "@axon/core/driver/protocol"; +import { + deviceId, + listAuthorizedAxonDevices, + openDongle, + requestAxonDevices, + summarizeDevice, + webHidSupported, +} from "@axon/transport-webhid"; +import { + mountProbeApp, + type ProbeConfigInfo, + 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(" "); +} + +const root = document.getElementById("app"); +if (!(root instanceof HTMLElement)) { + throw new Error("Web probe root #app was not found."); +} + +let selectedDevice: HIDDevice | null = null; +let activeHandle: Awaited> | null = null; + +function currentInventory(): ProbeInventory { + return { + devices: selectedDevice ? [summarizeDevice(selectedDevice)] : [], + openedId: selectedDevice && activeHandle ? deviceId(selectedDevice) : null, + }; +} + +async function releaseHandle(): Promise { + if (!activeHandle) { + return; + } + await activeHandle.release(); + activeHandle = null; +} + +function requireHandle() { + if (!activeHandle) { + throw new Error("Open the Axon adapter first."); + } + return activeHandle; +} + +if (webHidSupported()) { + navigator.hid.addEventListener("disconnect", (event) => { + if (selectedDevice && event.device === selectedDevice) { + void releaseHandle(); + selectedDevice = null; + } + }); +} + +mountProbeApp({ + root, + title: "Axon WebHID PoC", + description: + "Minimal browser probe for the Axon V1.3 HID adapter. The UI is shared with the desktop PoC, while device access comes from a dedicated WebHID transport.", + bullets: [ + "Use a Chromium-based browser on https:// or http://localhost.", + "Make sure the adapter is owned by the host OS, not a VM such as Parallels.", + "This PoC enumerates, identifies, and reads config. It does not write or flash firmware.", + ], + devicePanelTitle: "Selected Device", + emptyDeviceText: "No device selected.", + loadEnvironment: async () => ({ + transport: "WebHID", + secureContext: window.isSecureContext, + webHidAvailable: webHidSupported(), + userAgent: navigator.userAgent, + }), + loadInventory: async () => currentInventory(), + requestDevice: { + label: "Request Axon Adapter", + run: async () => { + await releaseHandle(); + const devices = await requestAxonDevices(); + selectedDevice = devices[0] ?? null; + return currentInventory(); + }, + }, + reconnectDevice: { + label: "Reuse Authorized Device", + run: async () => { + await releaseHandle(); + const devices = await listAuthorizedAxonDevices(); + selectedDevice = devices[0] ?? null; + return currentInventory(); + }, + }, + openDevice: { + label: "Open Device", + run: async () => { + if (!selectedDevice) { + throw new Error("No device selected."); + } + await releaseHandle(); + activeHandle = await openDongle(selectedDevice); + return currentInventory(); + }, + }, + closeDevice: { + label: "Close Device", + run: async () => { + await releaseHandle(); + return currentInventory(); + }, + }, + identifyServo: { + label: "Identify", + run: async (): Promise => { + const reply = await identify(requireHandle()); + const replyBytes = new Uint8Array(reply.rawRx); + return { + present: reply.present, + statusHi: hex(reply.statusHi), + statusLo: hex(reply.statusLo), + modeByte: reply.modeByte === null ? null : hex(reply.modeByte), + mode: reply.mode, + rawRx: hexDump(replyBytes), + }; + }, + }, + readFullConfig: { + label: "Read Full Config", + run: async (): Promise => { + const config = await readFullConfig(requireHandle()); + const configBytes = new Uint8Array(config); + const modelId = modelIdFromConfig(configBytes); + const model = findModel(loadCatalog(), modelId); + const chunkSplit = 59; + + return { + length: configBytes.length, + modelId, + known: Boolean(model), + modelName: model?.name ?? null, + docsUrl: model?.docs_url ?? null, + rawHex: hexDump(configBytes), + firstChunk: hexDump(configBytes.subarray(0, chunkSplit)), + secondChunk: hexDump(configBytes.subarray(chunkSplit)), + }; + }, + }, +}); diff --git a/apps/web/src/webhid.d.ts b/apps/web/src/webhid.d.ts new file mode 100644 index 0000000..8ea9f7e --- /dev/null +++ b/apps/web/src/webhid.d.ts @@ -0,0 +1,48 @@ +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 { + 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/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..94bf23f --- /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/**/*"] +} diff --git a/biome.json b/biome.json index 1dcacf2..8306fa8 100644 --- a/biome.json +++ b/biome.json @@ -17,12 +17,23 @@ "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", @@ -30,7 +41,12 @@ "!docs", "!apps/cli/bin", "!apps/cli/node_modules", - "!apps/desktop/build" + "!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 index 394c491..e226336 100644 --- a/bun.lock +++ b/bun.lock @@ -16,7 +16,7 @@ }, "dependencies": { "@axon/core": "workspace:*", - "node-hid": "^3.3.0", + "@axon/transport-nodehid": "workspace:*", }, "devDependencies": { "@biomejs/biome": "^2.4.11", @@ -29,6 +29,8 @@ "version": "0.0.1", "dependencies": { "@axon/core": "workspace:*", + "@axon/transport-nodehid": "workspace:*", + "@axon/ui": "workspace:*", "electrobun": "1.16.0", }, "devDependencies": { @@ -41,6 +43,16 @@ "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", @@ -51,12 +63,53 @@ "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", + "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=="], diff --git a/docs/README.md b/docs/README.md index f804f71..e26b2ef 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,6 +33,9 @@ narrative version of the journey is in [the-adventure.md](the-adventure.md). | [../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/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/packages/transport-nodehid/src/hid.ts b/packages/transport-nodehid/src/hid.ts new file mode 100644 index 0000000..c83b38c --- /dev/null +++ b/packages/transport-nodehid/src/hid.ts @@ -0,0 +1,177 @@ +/** + * Production HID transport for the Axon dongle via node-hid. + * + * Implements the abstract `DongleHandle` interface from + * `transport.ts`. The protocol layer in `protocol.ts` only sees the + * abstract interface — it has no compile-time dependency on + * `node-hid`. Tests inject a `MockDongle` instead. + * + * This module replaces an earlier libusb-based driver that required + * `sudo` on macOS to claim HID-class interfaces. node-hid goes + * through the OS's HID framework (IOKit HID Manager on macOS, + * hidraw on Linux, HID.dll on Windows) and does NOT need elevated + * privileges on any platform. + * + * IMPORTANT: the dongle has a state machine that can be put into a + * "cold" mode by a USB bus reset. node-hid does not expose any + * primitive that can do this — we're safe by construction. The risk + * was a libusb-era footgun. + */ + +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"; + +export const VID = 0x0471; +export const PID = 0x13aa; +export const REPORT_SIZE = 64; + +export type DongleDescriptor = NodeHidDevice; + +/** + * Wrap a raw node-hid failure in an AxonError so the top-level + * catch in cli.ts renders it cleanly (no stack trace). The detail + * string is the raw error message from node-hid — we do NOT + * speculate about the cause in the hint; we just surface what the + * OS told us and ask the user to replug. + * + * Previously this file retried once on failure. The retry was + * removed because it masked the root cause — if node-hid keeps + * failing, we want to see the first error verbatim so we can + * actually fix the bug instead of papering over it. + */ +function hidWriteError(detail: string): AxonError { + return AxonError.adapterIo(detail); +} + +function hidReadError(detail: string): AxonError { + return AxonError.adapterIo(detail); +} + +function hidEnumerateError(detail: string): AxonError { + return AxonError.adapterIo(`HID enumeration failed: ${detail}`); +} + +/** + * Cheap "which dongles are visible through this OS's HID layer?" + * check. Does NOT open the device — safe to call from `axon status` + * and `axon monitor`'s "not-yet-opened" state. + * + * This is deliberately phrased as HID visibility rather than physical + * USB presence: if a VM such as Parallels has captured the adapter, + * macOS may still list the raw USB device while node-hid cannot see + * an openable HID interface. + */ +export function listDongles(): DongleDescriptor[] { + try { + return getNodeHidBinding().devices(VID, PID); + } catch (e) { + throw hidEnumerateError((e as Error).message ?? "unknown failure"); + } +} + +/** + * Back-compat helper for callers that only need a boolean. + */ +export function isDonglePresent(): boolean { + return listDongles().length > 0; +} + +/** + * Concrete `DongleHandle` implementation backed by a node-hid + * `HID.HID` instance. Internal — instances are produced by + * `openDongle()`. + */ +class NodeHidDongle implements DongleHandle { + private device: NodeHidHandle; + private released = false; + + constructor(device: NodeHidHandle) { + this.device = device; + } + + async write(data: Buffer): Promise { + if (this.released) { + throw hidWriteError("handle was released"); + } + if (data.length > REPORT_SIZE) { + throw hidWriteError(`buffer is ${data.length} bytes, max is ${REPORT_SIZE}`); + } + const padded = Buffer.alloc(REPORT_SIZE); + padded.set(data, 0); + try { + const n = this.device.write(Array.from(padded)); + if (n <= 0) { + throw hidWriteError(`node-hid write returned ${n} (expected ${REPORT_SIZE})`); + } + } catch (e) { + if (e instanceof AxonError) throw e; + throw hidWriteError((e as Error).message ?? "unknown failure"); + } + } + + async read(timeoutMs = 500): Promise { + if (this.released) { + throw hidReadError("handle was released"); + } + try { + const bytes = this.device.readTimeout(timeoutMs); + if (!bytes || bytes.length === 0) { + throw hidReadError(`read timed out after ${timeoutMs} ms (no bytes available)`); + } + return Buffer.from(bytes); + } catch (e) { + throw hidReadError((e as Error).message ?? "unknown failure"); + } + } + + async release(): Promise { + if (this.released) return; + this.released = true; + try { + this.device.close(); + } catch { + // best effort — node-hid occasionally throws on double-close + } + } +} + +/** + * Open the single Axon dongle. Enforces the "exactly one" invariant + * from the CLI design spec, and emits state-specific errors: + * + * - No matches → AxonError.noAdapter (category "no_adapter") + * - >1 matches → AxonError.noAdapter + * - Open fails → AxonError.adapterBusy (category "adapter_busy", + * usually another process is claiming it) + */ +export async function openDongle(descriptor?: DongleDescriptor): Promise { + const matches = descriptor ? [descriptor] : listDongles(); + if (matches.length === 0) { + throw AxonError.noAdapter("Axon dongle not found on USB."); + } + if (matches.length > 1) { + throw AxonError.noAdapter(`Found ${matches.length} Axon dongles on USB — expected exactly 1.`); + } + + const selected = matches[0]; + if (selected === undefined) { + throw AxonError.noAdapter("Axon dongle not found on USB."); + } + const path = selected.path; + if (!path) { + throw AxonError.noAdapter( + "Axon dongle enumerated but no device path (node-hid returned an entry without `path`).", + ); + } + + let device: NodeHidHandle; + try { + device = new (getNodeHidBinding().HID)(path); + } catch (e) { + throw AxonError.adapterBusy((e as Error).message); + } + + return new NodeHidDongle(device); +} diff --git a/packages/transport-nodehid/src/nodehid.ts b/packages/transport-nodehid/src/nodehid.ts new file mode 100644 index 0000000..c821c60 --- /dev/null +++ b/packages/transport-nodehid/src/nodehid.ts @@ -0,0 +1,63 @@ +/** + * Runtime loader for node-hid's platform-specific N-API prebuilds. + * + * We intentionally bypass node-hid's default pkg-prebuilds loader here. + * Bun standalone executables can embed `.node` addons when they are + * required directly, but node-hid resolves its addon through a dynamic + * `__dirname`-based search that bakes the build-machine path into the + * compiled binary. That works on the build host and fails everywhere else. + * + * By selecting the prebuild ourselves with static require() calls, Bun can + * embed the addon into the executable and the release binary remains + * portable across machines. + */ + +export interface NodeHidDevice { + path?: string; + vendorId?: number; + productId?: number; + product?: string; + manufacturer?: string; + serialNumber?: string; + release?: number; + interface?: number; + usagePage?: number; + usage?: number; +} + +export interface NodeHidHandle { + write(data: number[]): number; + readTimeout(timeoutMs: number): number[] | undefined; + close(): void; +} + +export interface NodeHidBinding { + devices(vendorId?: number, productId?: number): NodeHidDevice[]; + HID: new (path: string) => NodeHidHandle; +} + +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"); + case "darwin/x64": + 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"); + case "linux/x64": + 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"); + default: + throw new Error( + `Unsupported platform for embedded node-hid prebuild: ${process.platform}/${process.arch}`, + ); + } +} + +export function getNodeHidBinding(): NodeHidBinding { + cachedBinding ??= loadBindingForCurrentPlatform(); + return cachedBinding; +} 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..10a74d1 --- /dev/null +++ b/packages/transport-webhid/src/index.ts @@ -0,0 +1,139 @@ +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; + +interface InputReportReply { + reportId: number; + data: Uint8Array; +} + +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 }], + }); +} + +function waitForInputReport(device: HIDDevice, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = window.setTimeout(() => { + device.removeEventListener("inputreport", onInputReport); + reject(new Error(`Timed out waiting for input report after ${timeoutMs} ms`)); + }, timeoutMs); + + const onInputReport: EventListener = (event) => { + const hidEvent = event as HIDInputReportEvent; + + if (hidEvent.device !== device) { + return; + } + + window.clearTimeout(timer); + device.removeEventListener("inputreport", onInputReport); + + resolve({ + reportId: hidEvent.reportId, + data: new Uint8Array( + hidEvent.data.buffer.slice( + hidEvent.data.byteOffset, + hidEvent.data.byteOffset + hidEvent.data.byteLength, + ), + ), + }); + }; + + device.addEventListener("inputreport", onInputReport); + }); +} + +export class WebHidDongle implements DongleHandle { + constructor(private readonly device: HIDDevice) {} + + 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 reply = await waitForInputReport(this.device, timeoutMs); + return Buffer.from([reply.reportId, ...reply.data]); + } + + async release(): Promise { + if (this.device.opened) { + await this.device.close(); + } + } +} + +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..76e6fc4 --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,18 @@ +{ + "name": "@axon/ui", + "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" + }, + "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..564ad81 --- /dev/null +++ b/packages/ui/src/probe.ts @@ -0,0 +1,378 @@ +const STYLE_ID = "axon-probe-ui-style"; + +export interface ProbeCollectionInfo { + usagePage: string; + usage: string; + inputReports: number[]; + outputReports: number[]; + featureReports: number[]; +} + +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 ProbeInventory { + devices: ProbeDeviceInfo[]; + openedId: string | null; +} + +export interface ProbeIdentifyInfo { + present: boolean; + statusHi: string; + statusLo: string; + modeByte: string | null; + mode: string; + rawRx: string; +} + +export interface ProbeConfigInfo { + length: number; + modelId: string; + known: boolean; + modelName: string | null; + docsUrl: string | null; + rawHex: string; + firstChunk: string; + secondChunk: string; +} + +export interface ProbeUiAction { + label: string; + run: () => Promise; +} + +export interface ProbeUiOptions { + root: HTMLElement; + title: string; + description: string; + bullets: string[]; + devicePanelTitle: string; + emptyDeviceText: string; + loadEnvironment: () => Promise; + loadInventory: () => Promise; + refreshInventory?: ProbeUiAction; + requestDevice?: ProbeUiAction; + reconnectDevice?: ProbeUiAction; + openDevice: ProbeUiAction; + closeDevice: ProbeUiAction; + identifyServo: ProbeUiAction; + readFullConfig: ProbeUiAction; +} + +function installStyles(document: Document) { + if (document.getElementById(STYLE_ID)) { + return; + } + + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` + :root { + color-scheme: light dark; + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + } + + body { + margin: 0; + padding: 24px; + line-height: 1.45; + } + + main { + max-width: 960px; + } + + h1, h2, p, ul { + margin: 0 0 16px; + } + + .axon-probe-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 16px; + } + + .axon-probe-row button { + border: 1px solid currentColor; + border-radius: 6px; + background: transparent; + padding: 8px 12px; + cursor: pointer; + font: inherit; + } + + .axon-probe-row button:disabled { + cursor: not-allowed; + opacity: 0.5; + } + + .axon-probe-grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + margin: 0 0 16px; + } + + .axon-probe-panel { + border: 1px solid color-mix(in srgb, currentColor 22%, transparent); + border-radius: 6px; + padding: 12px; + } + + .axon-probe-panel 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; + } + `; + + document.head.appendChild(style); +} + +function mustQuery(root: ParentNode, selector: string): T { + const element = root.querySelector(selector); + if (!element) { + throw new Error(`Axon probe UI missing required selector ${selector}`); + } + return element; +} + +function toJson(value: unknown): string { + if (typeof value === "string") { + return value; + } + return JSON.stringify(value, null, 2); +} + +function summarizeInventory(inventory: ProbeInventory): object { + return { + openedId: inventory.openedId, + visibleCount: inventory.devices.length, + devices: inventory.devices, + }; +} + +function formatError(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +export function mountProbeApp(options: ProbeUiOptions): void { + const document = options.root.ownerDocument; + installStyles(document); + + options.root.innerHTML = ` +
+

${options.title}

+

${options.description}

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

Environment

+
Loading runtime info...
+
+
+

${options.devicePanelTitle}

+
${options.emptyDeviceText}
+
+
+

Latest Reply

+
No traffic yet.
+
+
+
+

Log

+

+      
+
+ `; + + const actionsEl = mustQuery(options.root, '[data-role="actions"]'); + const environmentEl = mustQuery(options.root, '[data-role="environment"]'); + const devicesEl = mustQuery(options.root, '[data-role="devices"]'); + const latestEl = mustQuery(options.root, '[data-role="latest"]'); + const logEl = mustQuery(options.root, '[data-role="log"]'); + + const buttons = new Map(); + let inventory: ProbeInventory = { devices: [], openedId: null }; + + function log(message: string) { + const timestamp = new Date().toLocaleTimeString(); + logEl.textContent += `[${timestamp}] ${message}\n`; + logEl.scrollTop = logEl.scrollHeight; + } + + function renderInventory(next: ProbeInventory) { + inventory = next; + devicesEl.textContent = + next.devices.length === 0 ? options.emptyDeviceText : toJson(summarizeInventory(next)); + updateButtons(); + } + + function renderLatest(value: unknown) { + latestEl.textContent = toJson(value); + } + + function updateButtons() { + const hasDevice = inventory.devices.length > 0; + const isOpen = inventory.openedId !== null; + + buttons.get("open")?.toggleAttribute("disabled", !hasDevice || isOpen); + buttons.get("identify")?.toggleAttribute("disabled", !isOpen); + buttons.get("read")?.toggleAttribute("disabled", !isOpen); + buttons.get("close")?.toggleAttribute("disabled", !isOpen); + } + + function addButton(key: string, label: string, onClick: () => Promise) { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = label; + button.addEventListener("click", () => { + void onClick(); + }); + actionsEl.appendChild(button); + buttons.set(key, button); + } + + async function runAction( + action: ProbeUiAction, + onSuccess: (data: T) => void, + successMessage: string, + ) { + try { + const data = await action.run(); + onSuccess(data); + log(successMessage); + } catch (error) { + renderLatest({ error: formatError(error) }); + log(`error: ${formatError(error)}`); + } + } + + if (options.requestDevice) { + const requestDevice = options.requestDevice; + addButton("request", options.requestDevice.label, async () => { + await runAction( + requestDevice, + (data) => { + renderInventory(data); + renderLatest(summarizeInventory(data)); + }, + "device request completed", + ); + }); + } + + if (options.reconnectDevice) { + const reconnectDevice = options.reconnectDevice; + addButton("reconnect", options.reconnectDevice.label, async () => { + await runAction( + reconnectDevice, + (data) => { + renderInventory(data); + renderLatest(summarizeInventory(data)); + }, + "authorized device lookup completed", + ); + }); + } + + if (options.refreshInventory) { + const refreshInventory = options.refreshInventory; + addButton("refresh", options.refreshInventory.label, async () => { + await runAction( + refreshInventory, + (data) => { + renderInventory(data); + renderLatest(summarizeInventory(data)); + }, + "adapter inventory refreshed", + ); + }); + } + + addButton("open", options.openDevice.label, async () => { + await runAction( + options.openDevice, + (data) => { + renderInventory(data); + renderLatest(summarizeInventory(data)); + }, + "adapter opened", + ); + }); + + addButton("identify", options.identifyServo.label, async () => { + await runAction( + options.identifyServo, + (data) => { + renderLatest(data); + }, + "identify completed", + ); + }); + + addButton("read", options.readFullConfig.label, async () => { + await runAction( + options.readFullConfig, + (data) => { + renderLatest(data); + }, + "full config read completed", + ); + }); + + addButton("close", options.closeDevice.label, async () => { + await runAction( + options.closeDevice, + (data) => { + renderInventory(data); + renderLatest(summarizeInventory(data)); + }, + "adapter closed", + ); + }); + + updateButtons(); + + void (async () => { + try { + environmentEl.textContent = toJson(await options.loadEnvironment()); + log("runtime info loaded"); + } catch (error) { + environmentEl.textContent = toJson({ error: formatError(error) }); + log(`error: ${formatError(error)}`); + } + })(); + + void (async () => { + try { + renderInventory(await options.loadInventory()); + log("inventory loaded"); + } catch (error) { + devicesEl.textContent = toJson({ error: formatError(error) }); + log(`error: ${formatError(error)}`); + } + })(); +} 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/**/*"] +}