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..a64b5c0 100644 --- a/.gitignore +++ b/.gitignore @@ -30,9 +30,11 @@ ENV/ # Node / Bun node_modules/ +.turbo/ *.log -axon/bin/ -axon/dist/ +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 7d5ea09..a6fd715 100644 --- a/README.md +++ b/README.md @@ -76,9 +76,10 @@ Full command surface: [docs/CLI_DESIGN.md](docs/CLI_DESIGN.md). curl -fsSL https://bun.sh/install | bash # 2. Clone, install dependencies, run from source -git clone https://github.com/caryden/servo-programmer -cd servo-programmer/axon +git clone https://github.com/caryden/servo-programmer.git +cd servo-programmer bun install +cd apps/cli bun run src/cli.ts status ``` @@ -87,6 +88,7 @@ prints a compact status bar plus the model docs link. For scripts and agents, use JSON: ```bash +cd apps/cli bun run src/cli.ts --json status ``` @@ -106,12 +108,24 @@ No `sudo` required on any platform. **Got an Axon Max, another Micro capture, or an unknown model?** Please help fill out the catalog. Plug it in, run -`bun run src/cli.ts read --svo > my-model.svo`, and +`cd apps/cli && bun run src/cli.ts read --svo > my-model.svo`, and [open an issue](https://github.com/caryden/servo-programmer/issues/new) with the file attached and the model ID string from `axon status`. I'll add the model/defaults to [`data/servo_catalog.json`](data/servo_catalog.json). +## Repo layout + +The repo is now organized as a Bun workspace: + +- [`apps/cli/`](apps/cli/) — production `axon` CLI +- [`apps/web/`](apps/web/) — browser WebHID PoC +- [`apps/desktop/`](apps/desktop/) — Electrobun desktop PoC +- [`packages/core/`](packages/core/) — shared transport-agnostic protocol/catalog logic +- [`packages/ui/`](packages/ui/) — shared probe UI used by the browser and desktop PoCs +- [`packages/transport-nodehid/`](packages/transport-nodehid/) — Node/Bun HID transport +- [`packages/transport-webhid/`](packages/transport-webhid/) — browser WebHID transport + ## Firmware files This repo does not redistribute Axon `.sfw` firmware files. Download diff --git a/axon/package.json b/apps/cli/package.json similarity index 90% rename from axon/package.json rename to apps/cli/package.json index 8806983..3b8ba31 100644 --- a/axon/package.json +++ b/apps/cli/package.json @@ -17,7 +17,8 @@ "test": "bun test" }, "dependencies": { - "node-hid": "^3.3.0" + "@axon/core": "workspace:*", + "@axon/transport-nodehid": "workspace:*" }, "devDependencies": { "@biomejs/biome": "^2.4.11", diff --git a/apps/cli/src/catalog.ts b/apps/cli/src/catalog.ts new file mode 100644 index 0000000..e7f8300 --- /dev/null +++ b/apps/cli/src/catalog.ts @@ -0,0 +1 @@ +export * from "@axon/core/catalog"; diff --git a/axon/src/cli.ts b/apps/cli/src/cli.ts similarity index 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/apps/cli/src/driver/hid.ts b/apps/cli/src/driver/hid.ts new file mode 100644 index 0000000..e14a9b5 --- /dev/null +++ b/apps/cli/src/driver/hid.ts @@ -0,0 +1 @@ +export * from "@axon/transport-nodehid"; diff --git a/apps/cli/src/driver/nodehid.ts b/apps/cli/src/driver/nodehid.ts new file mode 100644 index 0000000..d5a16ac --- /dev/null +++ b/apps/cli/src/driver/nodehid.ts @@ -0,0 +1 @@ +export * from "@axon/transport-nodehid/nodehid"; diff --git a/apps/cli/src/driver/protocol.ts b/apps/cli/src/driver/protocol.ts new file mode 100644 index 0000000..fb8855b --- /dev/null +++ b/apps/cli/src/driver/protocol.ts @@ -0,0 +1 @@ +export * from "@axon/core/driver/protocol"; diff --git a/apps/cli/src/driver/transport.ts b/apps/cli/src/driver/transport.ts new file mode 100644 index 0000000..c81768e --- /dev/null +++ b/apps/cli/src/driver/transport.ts @@ -0,0 +1 @@ +export * from "@axon/core/driver/transport"; diff --git a/apps/cli/src/errors.ts b/apps/cli/src/errors.ts new file mode 100644 index 0000000..3130358 --- /dev/null +++ b/apps/cli/src/errors.ts @@ -0,0 +1 @@ +export * from "@axon/core/errors"; diff --git a/axon/src/firmware-store.ts b/apps/cli/src/firmware-store.ts similarity index 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 89% rename from axon/test/driver/hid.test.ts rename to apps/cli/test/driver/hid.test.ts index a075b9f..4a72a23 100644 --- a/axon/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/axon/test/driver/protocol.test.ts b/apps/cli/test/driver/protocol.test.ts similarity index 100% rename from axon/test/driver/protocol.test.ts rename to apps/cli/test/driver/protocol.test.ts diff --git a/axon/test/firmware-store.test.ts b/apps/cli/test/firmware-store.test.ts similarity index 100% rename from axon/test/firmware-store.test.ts rename to apps/cli/test/firmware-store.test.ts diff --git a/axon/test/fixtures/cold_state_replies.hex b/apps/cli/test/fixtures/cold_state_replies.hex similarity index 100% rename from axon/test/fixtures/cold_state_replies.hex rename to apps/cli/test/fixtures/cold_state_replies.hex diff --git a/axon/test/fixtures/dual_test7_config.svo b/apps/cli/test/fixtures/dual_test7_config.svo similarity index 100% rename from axon/test/fixtures/dual_test7_config.svo rename to apps/cli/test/fixtures/dual_test7_config.svo diff --git a/axon/test/fixtures/identify_reply.hex b/apps/cli/test/fixtures/identify_reply.hex similarity index 100% rename from axon/test/fixtures/identify_reply.hex rename to apps/cli/test/fixtures/identify_reply.hex diff --git a/axon/test/helpers/capture-io.ts b/apps/cli/test/helpers/capture-io.ts similarity index 100% rename from axon/test/helpers/capture-io.ts rename to apps/cli/test/helpers/capture-io.ts diff --git a/axon/test/mocks/mock-dongle.ts b/apps/cli/test/mocks/mock-dongle.ts similarity index 100% rename from axon/test/mocks/mock-dongle.ts rename to apps/cli/test/mocks/mock-dongle.ts diff --git a/axon/test/parameters.test.ts b/apps/cli/test/parameters.test.ts similarity index 100% rename from axon/test/parameters.test.ts rename to apps/cli/test/parameters.test.ts diff --git a/axon/test/prompt.test.ts b/apps/cli/test/prompt.test.ts similarity index 100% rename from axon/test/prompt.test.ts rename to apps/cli/test/prompt.test.ts diff --git a/axon/test/sanity.test.ts b/apps/cli/test/sanity.test.ts similarity index 100% rename from axon/test/sanity.test.ts rename to apps/cli/test/sanity.test.ts diff --git a/axon/test/sfw.test.ts b/apps/cli/test/sfw.test.ts similarity index 99% rename from axon/test/sfw.test.ts rename to apps/cli/test/sfw.test.ts index 870f262..c9012f9 100644 --- a/axon/test/sfw.test.ts +++ b/apps/cli/test/sfw.test.ts @@ -21,7 +21,7 @@ import { } from "../src/sfw.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = join(__dirname, "../.."); +const REPO_ROOT = join(__dirname, "../../.."); const DOWNLOADS = join(REPO_ROOT, "downloads"); const GROUND_TRUTH = join(REPO_ROOT, "research/decrypted-firmware"); diff --git a/axon/tsconfig.json b/apps/cli/tsconfig.json similarity index 100% rename from axon/tsconfig.json rename to apps/cli/tsconfig.json diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/apps/desktop/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/apps/desktop/README.md b/apps/desktop/README.md new file mode 100644 index 0000000..0cae19a --- /dev/null +++ b/apps/desktop/README.md @@ -0,0 +1,57 @@ +# 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 [`../web/`](../web/) 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. + +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 +- 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 /Users/caryden/github/servo-programmer +bun install +``` + +Then start the Electrobun app: + +```bash +cd /Users/caryden/github/servo-programmer/apps/desktop +bun run start +``` diff --git a/apps/desktop/electrobun.config.ts b/apps/desktop/electrobun.config.ts new file mode 100644 index 0000000..7d7f1b7 --- /dev/null +++ b/apps/desktop/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/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 0000000..18771cf --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,24 @@ +{ + "name": "@axon/desktop-poc", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "check": "bun x @biomejs/biome check src package.json electrobun.config.ts", + "start": "electrobun dev", + "dev": "electrobun dev --watch", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@axon/core": "workspace:*", + "@axon/transport-nodehid": "workspace:*", + "@axon/ui": "workspace:*", + "electrobun": "1.16.0" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.11", + "@types/bun": "latest", + "@types/three": "^0.180.0", + "typescript": "^5.9.3" + } +} diff --git a/apps/desktop/src/bun/index.ts b/apps/desktop/src/bun/index.ts new file mode 100644 index 0000000..73d3ff3 --- /dev/null +++ b/apps/desktop/src/bun/index.ts @@ -0,0 +1,194 @@ +import { findModel, loadCatalog } from "@axon/core/catalog"; +import { + type IdentifyReply, + identify, + modelIdFromConfig, + 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 { + DesktopPocSchema, + 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): ProbeDeviceInfo { + return { + id: descriptor.path ?? null, + vendorId: hex(descriptor.vendorId, 4), + productId: hex(descriptor.productId, 4), + product: descriptor.product ?? null, + manufacturer: descriptor.manufacturer ?? null, + serialNumber: descriptor.serialNumber ?? null, + interface: descriptor.interface ?? null, + usagePage: hex(descriptor.usagePage, 4), + usage: hex(descriptor.usage, 4), + opened: descriptor.path === activeDescriptor?.path, + }; +} + +function currentInventory(): ProbeInventory { + return { + devices: listDongles().map(serializeDescriptor), + openedId: 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): ProbeIdentifyInfo { + return { + present: reply.present, + statusHi: hex(reply.statusHi) ?? "0x00", + statusLo: hex(reply.statusLo) ?? "0x00", + modeByte: hex(reply.modeByte ?? undefined), + mode: reply.mode, + rawRx: hexDump(reply.rawRx), + }; +} + +function runtimeInfo(): RuntimeInfo { + return { + transport: "Electrobun RPC -> Bun main process -> node-hid", + platform: process.platform, + arch: process.arch, + bunVersion: Bun.version, + renderer: "native system webview", + }; +} + +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: ProbeConfigInfo = { + length: config.length, + modelId, + known: Boolean(model), + modelName: model?.name ?? null, + docsUrl: model?.docs_url ?? null, + rawHex: hexDump(config), + firstChunk: hexDump(config.subarray(0, chunkSplit)), + secondChunk: hexDump(config.subarray(chunkSplit)), + }; + return 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/apps/desktop/src/mainview/index.css b/apps/desktop/src/mainview/index.css new file mode 100644 index 0000000..00e399b --- /dev/null +++ b/apps/desktop/src/mainview/index.css @@ -0,0 +1,69 @@ +:root { + color-scheme: light dark; + font-family: + ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +body { + margin: 0; + padding: 24px; + line-height: 1.45; +} + +main { + max-width: 960px; +} + +h1, +h2, +p, +ul { + margin: 0 0 16px; +} + +.row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 16px; +} + +button { + border: 1px solid currentColor; + border-radius: 6px; + background: transparent; + padding: 8px 12px; + cursor: pointer; + font: inherit; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + margin: 0 0 16px; +} + +.panel { + border: 1px solid color-mix(in srgb, currentColor 22%, transparent); + border-radius: 6px; + padding: 12px; +} + +pre { + overflow: auto; + padding: 12px; + border: 1px solid color-mix(in srgb, currentColor 22%, transparent); + border-radius: 6px; + margin: 0; +} + +code { + font-family: + ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, "Liberation Mono", monospace; +} diff --git a/apps/desktop/src/mainview/index.html b/apps/desktop/src/mainview/index.html new file mode 100644 index 0000000..dfe4334 --- /dev/null +++ b/apps/desktop/src/mainview/index.html @@ -0,0 +1,12 @@ + + + + + + Axon Electrobun PoC + + +
+ + + diff --git a/apps/desktop/src/mainview/index.ts b/apps/desktop/src/mainview/index.ts new file mode 100644 index 0000000..5cb117b --- /dev/null +++ b/apps/desktop/src/mainview/index.ts @@ -0,0 +1,79 @@ +import { + mountProbeApp, + type ProbeConfigInfo, + type ProbeIdentifyInfo, + type ProbeInventory, +} from "@axon/ui"; +import { Electroview } from "electrobun/view"; +import type { + DesktopPocSchema, + RpcResult, + RuntimeInfo, + SerializedAxonError, +} from "../shared/api.ts"; + +const rpc = Electroview.defineRPC({ + handlers: { + requests: {}, + messages: {}, + }, +}); + +new Electroview({ rpc }); + +function describeError(error: SerializedAxonError): string { + const out = [`${error.category ?? "internal"}: ${error.message}`]; + if (error.hint) { + out.push(`hint: ${error.hint}`); + } + return out.join("\n"); +} + +async function unwrapResult(promise: Promise>): Promise { + const result = await promise; + if (!result.ok) { + throw new Error(describeError(result.error)); + } + return result.data; +} + +const root = document.getElementById("app"); +if (!(root instanceof HTMLElement)) { + throw new Error("Electrobun PoC DOM did not initialize correctly for #app"); +} + +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()), + }, +}); diff --git a/apps/desktop/src/shared/api.ts b/apps/desktop/src/shared/api.ts new file mode 100644 index 0000000..8341132 --- /dev/null +++ b/apps/desktop/src/shared/api.ts @@ -0,0 +1,48 @@ +import type { ProbeConfigInfo, ProbeIdentifyInfo, ProbeInventory } from "@axon/ui"; + +type EmptySchema = Record; + +export interface SerializedAxonError { + message: string; + code?: number; + category?: string; + hint?: string; +} + +export interface RpcOk { + ok: true; + data: T; +} + +export interface RpcErr { + ok: false; + error: SerializedAxonError; +} + +export type RpcResult = RpcOk | RpcErr; + +export interface RuntimeInfo { + transport: string; + platform: string; + arch: string; + bunVersion: string; + renderer: string; +} + +export type 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: EmptySchema; + }; + webview: { + requests: EmptySchema; + messages: EmptySchema; + }; +}; diff --git a/apps/desktop/src/types/three.d.ts b/apps/desktop/src/types/three.d.ts new file mode 100644 index 0000000..44db19a --- /dev/null +++ b/apps/desktop/src/types/three.d.ts @@ -0,0 +1 @@ +declare module "three"; diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json new file mode 100644 index 0000000..94bf23f --- /dev/null +++ b/apps/desktop/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": ["bun"] + }, + "include": ["src/**/*"] +} diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..a4f1e22 --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,54 @@ +# WebHID PoC + +This is a minimal browser proof-of-concept for talking to the Axon V1.3 +programmer adapter over WebHID. + +It answers one narrow question: + +> Can a Chromium-based browser on `localhost` request the adapter, open +> it over WebHID, and exchange the same identify/read reports that the +> CLI sends over `node-hid`? + +The current implementation now uses: + +- [`../../packages/ui/`](../../packages/ui/) for the shared probe UI +- [`../../packages/transport-webhid/`](../../packages/transport-webhid/) for browser HID access +- [`../../packages/core/`](../../packages/core/) for shared protocol and catalog logic + +## What it does + +- filters for the Axon adapter (`VID 0x0471`, `PID 0x13aa`) +- opens the selected HID device +- sends the identify command (`0x8A`) +- can read the full 95-byte config block via the same two-chunk + `0xCD` flow used by the CLI + +It does **not** attempt to write config or flash firmware. + +## Run it + +Serve this directory from `localhost`. WebHID requires a secure context, +and `http://localhost` qualifies. + +From the repo root: + +```bash +cd apps/web +bun run start +``` + +Then open: + +```text +http://localhost:8765 +``` + +The `start` script bundles [`src/index.ts`](./src/index.ts) to +[`dist/index.js`](./dist/index.js) and then serves the app. + +## Browser constraints + +- Use a Chromium-based browser such as Chrome, Edge, Brave, or Arc. +- Safari and Firefox do not currently give you a useful path here. +- If the adapter is captured by a VM such as Parallels, the browser + will not be able to open it from the host. diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..7b7a6f4 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,12 @@ + + + + + + Axon WebHID PoC + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..5bfb68a --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,23 @@ +{ + "name": "@axon/web-poc", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "build": "bun build ./src/index.ts --target browser --format esm --outdir ./dist", + "check": "bun x @biomejs/biome check src package.json index.html", + "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/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/driver/transport.ts b/axon/src/driver/transport.ts deleted file mode 100644 index e85b4e9..0000000 --- a/axon/src/driver/transport.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Transport-layer abstraction for talking to the Axon dongle. - * - * Everything above this file (the protocol layer in `protocol.ts` and - * the command implementations in `commands/`) only depends on this - * interface — they never see the underlying USB/HID library. That - * gives us three things: - * - * 1. Test injection: `test/mocks/mock-dongle.ts` implements - * `DongleHandle` with an in-memory state machine, so the unit - * tests don't need real hardware. - * 2. WebHID port: a future browser-based version of the CLI can - * implement `DongleHandle` on top of `navigator.hid` without - * touching the protocol layer at all. - * 3. No accidental coupling: nothing in `protocol.ts` can - * grab the `node-hid` `HID.HID` object and call something - * transport-specific on it (like `dev.reset()`, which would - * put the dongle into cold state — see - * `docs/FINDINGS.md`). - */ - -export interface DongleHandle { - /** - * Send a single 64-byte HID output report. The data buffer is - * zero-padded up to the report size if shorter; `data[0]` must be - * the HID report id (`0x04` for the Axon dongle). - */ - write(data: Buffer, timeoutMs?: number): Promise; - - /** - * Read a single HID input report from the dongle, with a blocking - * timeout. Resolves with a Buffer of the full report bytes - * (typically 64). The first byte is the report id. - */ - read(timeoutMs?: number): Promise; - - /** - * Close the underlying device handle. Idempotent — safe to call - * more than once. After release, write/read MUST NOT be called. - */ - release(): Promise; -} diff --git a/biome.json b/biome.json index 6f6c419..8306fa8 100644 --- a/biome.json +++ b/biome.json @@ -7,23 +7,46 @@ }, "files": { "includes": [ - "axon/src/**/*.ts", - "axon/src/**/*.tsx", - "axon/src/**/*.js", - "axon/src/**/*.jsx", - "axon/src/**/*.json", - "axon/test/**/*.ts", - "axon/test/**/*.tsx", - "axon/test/**/*.js", - "axon/test/**/*.jsx", - "axon/test/**/*.json", + "apps/cli/src/**/*.ts", + "apps/cli/src/**/*.tsx", + "apps/cli/src/**/*.js", + "apps/cli/src/**/*.jsx", + "apps/cli/src/**/*.json", + "apps/cli/test/**/*.ts", + "apps/cli/test/**/*.tsx", + "apps/cli/test/**/*.js", + "apps/cli/test/**/*.jsx", + "apps/cli/test/**/*.json", + "apps/web/src/**/*.ts", + "apps/web/package.json", + "apps/web/index.html", + "apps/desktop/src/**/*.ts", + "apps/desktop/src/**/*.tsx", + "apps/desktop/package.json", + "apps/desktop/electrobun.config.ts", + "packages/core/src/**/*.ts", + "packages/core/package.json", + "packages/ui/src/**/*.ts", + "packages/ui/package.json", + "packages/transport-nodehid/src/**/*.ts", + "packages/transport-nodehid/package.json", + "packages/transport-webhid/src/**/*.ts", + "packages/transport-webhid/package.json", + "package.json", + "turbo.json", "!**/node_modules", "!research", "!vendor", "!data", "!docs", - "!axon/bin", - "!axon/node_modules" + "!apps/cli/bin", + "!apps/cli/node_modules", + "!apps/desktop/build", + "!apps/web/dist", + "!packages/core/node_modules", + "!packages/ui/node_modules", + "!packages/transport-nodehid/node_modules", + "!packages/transport-webhid/node_modules" ] }, "formatter": { diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..e226336 --- /dev/null +++ b/bun.lock @@ -0,0 +1,299 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "servo-programmer", + "devDependencies": { + "turbo": "^2", + }, + }, + "apps/cli": { + "name": "axon", + "version": "1.0.0", + "bin": { + "axon": "./src/cli.ts", + }, + "dependencies": { + "@axon/core": "workspace:*", + "@axon/transport-nodehid": "workspace:*", + }, + "devDependencies": { + "@biomejs/biome": "^2.4.11", + "@types/bun": "latest", + "typescript": "^5", + }, + }, + "apps/desktop": { + "name": "@axon/desktop-poc", + "version": "0.0.1", + "dependencies": { + "@axon/core": "workspace:*", + "@axon/transport-nodehid": "workspace:*", + "@axon/ui": "workspace:*", + "electrobun": "1.16.0", + }, + "devDependencies": { + "@biomejs/biome": "^2.4.11", + "@types/bun": "latest", + "@types/three": "^0.180.0", + "typescript": "^5.9.3", + }, + }, + "apps/web": { + "name": "@axon/web-poc", + "version": "0.0.1", + "dependencies": { + "@axon/core": "workspace:*", + "@axon/transport-webhid": "workspace:*", + "@axon/ui": "workspace:*", + }, + "devDependencies": { + "@biomejs/biome": "^2.4.11", + "@types/bun": "latest", + "typescript": "^5", + }, + }, + "packages/core": { + "name": "@axon/core", + "version": "0.0.1", + "devDependencies": { + "@biomejs/biome": "^2.4.11", + "@types/bun": "latest", + "typescript": "^5", + }, + }, + "packages/transport-nodehid": { + "name": "@axon/transport-nodehid", + "version": "0.0.1", + "dependencies": { + "@axon/core": "workspace:*", + "node-hid": "^3.3.0", + }, + "devDependencies": { + "@biomejs/biome": "^2.4.11", + "@types/bun": "latest", + "typescript": "^5", + }, + }, + "packages/transport-webhid": { + "name": "@axon/transport-webhid", + "version": "0.0.1", + "dependencies": { + "@axon/core": "workspace:*", + "@axon/ui": "workspace:*", + }, + "devDependencies": { + "@biomejs/biome": "^2.4.11", + "@types/bun": "latest", + "typescript": "^5", + }, + }, + "packages/ui": { + "name": "@axon/ui", + "version": "0.0.1", + "devDependencies": { + "@biomejs/biome": "^2.4.11", + "@types/bun": "latest", + "typescript": "^5", + }, + }, + }, + "packages": { + "@axon/core": ["@axon/core@workspace:packages/core"], + + "@axon/desktop-poc": ["@axon/desktop-poc@workspace:apps/desktop"], + + "@axon/transport-nodehid": ["@axon/transport-nodehid@workspace:packages/transport-nodehid"], + + "@axon/transport-webhid": ["@axon/transport-webhid@workspace:packages/transport-webhid"], + + "@axon/ui": ["@axon/ui@workspace:packages/ui"], + + "@axon/web-poc": ["@axon/web-poc@workspace:apps/web"], + + "@babylonjs/core": ["@babylonjs/core@7.54.3", "", {}, "sha512-P5ncXVd8GEUJLhwloP9V0oVwQYIrvZztguVeLlvd5Rx+9aQnenKjpV8auJ6SRsUlAmNZU4pFTKzwF6o2EUfhAw=="], + + "@biomejs/biome": ["@biomejs/biome@2.4.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.11", "@biomejs/cli-darwin-x64": "2.4.11", "@biomejs/cli-linux-arm64": "2.4.11", "@biomejs/cli-linux-arm64-musl": "2.4.11", "@biomejs/cli-linux-x64": "2.4.11", "@biomejs/cli-linux-x64-musl": "2.4.11", "@biomejs/cli-win32-arm64": "2.4.11", "@biomejs/cli-win32-x64": "2.4.11" }, "bin": { "biome": "bin/biome" } }, "sha512-nWxHX8tf3Opb/qRgZpBbsTOqOodkbrkJ7S+JxJAruxOReaDPPmPuLBAGQ8vigyUgo0QBB+oQltNEAvalLcjggA=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wOt+ed+L2dgZanWyL6i29qlXMc088N11optzpo10peayObBaAshbTcxKUchzEMp9QSY8rh5h6VfAFE3WTS1rqg=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-gZ6zR8XmZlExfi/Pz/PffmdpWOQ8Qhy7oBztgkR8/ylSRyLwfRPSadmiVCV8WQ8PoJ2MWUy2fgID9zmtgUUJmw=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-avdJaEElXrKceK0va9FkJ4P5ci3N01TGkc6ni3P8l3BElqbOz42Wg2IyX3gbh0ZLEd4HVKEIrmuVu/AMuSeFFA=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-+Sbo1OAmlegtdwqFE8iOxFIWLh1B3OEgsuZfBpyyN/kWuqZ8dx9ZEes6zVnDMo+zRHF2wLynRVhoQmV7ohxl2Q=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.11", "", { "os": "linux", "cpu": "x64" }, "sha512-TagWV0iomp5LnEnxWFg4nQO+e52Fow349vaX0Q/PIcX6Zhk4GGBgp3qqZ8PVkpC+cuehRctMf3+6+FgQ8jCEFQ=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.11", "", { "os": "linux", "cpu": "x64" }, "sha512-bexd2IklK7ZgPhrz6jXzpIL6dEAH9MlJU1xGTrypx+FICxrXUp4CqtwfiuoDKse+UlgAlWtzML3jrMqeEAHEhA=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-RJhaTnY8byzxDt4bDVb7AFPHkPcjOPK3xBip4ZRTrN3TEfyhjLRm3r3mqknqydgVTB74XG8l4jMLwEACEeihVg=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.11", "", { "os": "win32", "cpu": "x64" }, "sha512-A8D3JM/00C2KQgUV3oj8Ba15EHEYwebAGCy5Sf9GAjr5Y3+kJIYOiESoqRDeuRZueuMdCsbLZIUqmPhpYXJE9A=="], + + "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="], + + "@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@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="], + + "@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="], + + "@types/three": ["@types/three@0.180.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": "*", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~0.22.0" } }, "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg=="], + + "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="], + + "@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="], + + "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=="], + + "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=="], + + "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=="], + + "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-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=="], + + "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-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + + "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=="], + + "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=="], + + "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=="], + + "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=="], + + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], + + "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "three": ["three@0.165.0", "", {}, "sha512-cc96IlVYGydeceu0e5xq70H8/yoVT/tXBxV/W8A/U6uOq7DXc4/s1Mkmnu6SqoYGhSRWWYFOhVwvq6V0VtbplA=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "turbo": ["turbo@2.9.6", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.6", "@turbo/darwin-arm64": "2.9.6", "@turbo/linux-64": "2.9.6", "@turbo/linux-arm64": "2.9.6", "@turbo/windows-64": "2.9.6", "@turbo/windows-arm64": "2.9.6" }, "bin": { "turbo": "bin/turbo" } }, "sha512-+v2QJey7ZUeUiuigkU+uFfklvNUyPI2VO2vBpMYJA+a1hKFLFiKtUYlRHdb3P9CrAvMzi0upbjI4WT+zKtqkBg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + } +} diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 9925e53..e54c29e 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -156,8 +156,9 @@ itself, clone and build: ```bash git clone https://github.com/caryden/servo-programmer.git -cd servo-programmer/axon +cd servo-programmer bun install +cd apps/cli bun run build ./bin/axon --version ``` diff --git a/docs/README.md b/docs/README.md index 129cc45..e26b2ef 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,7 +29,13 @@ narrative version of the journey is in [the-adventure.md](the-adventure.md). | Path | What's there | |---|---| -| [../axon/](../axon/) | The axon CLI source code. | +| [../apps/cli/](../apps/cli/) | The axon CLI source code. | +| [../apps/web/](../apps/web/) | The browser WebHID PoC. | +| [../apps/desktop/](../apps/desktop/) | The Electrobun desktop PoC. | +| [../packages/core/](../packages/core/) | Shared transport-agnostic catalog/protocol/error logic. | +| [../packages/ui/](../packages/ui/) | Shared probe UI for the browser and desktop PoCs. | +| [../packages/transport-nodehid/](../packages/transport-nodehid/) | Node/Bun HID transport package. | +| [../packages/transport-webhid/](../packages/transport-webhid/) | Browser WebHID transport package. | | [../data/servo_catalog.json](../data/servo_catalog.json) | Bundled servo metadata catalog. | | [../research/](../research/) | All the artifacts used during reverse engineering: captures, decompiled exe, Python test scripts. | | [../vendor/](../vendor/) | Vendor-supplied files (firmware, sample configs). Not our code. | diff --git a/docs/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/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..429b4b6 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,23 @@ +{ + "name": "@axon/core", + "version": "0.0.1", + "private": true, + "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/axon/src/catalog.ts b/packages/core/src/catalog.ts similarity index 78% rename from axon/src/catalog.ts rename to packages/core/src/catalog.ts index b49479f..3c66bd7 100644 --- a/axon/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -1,18 +1,12 @@ /** - * Loads the bundled servo_catalog.json (embedded at build time by Bun's - * JSON import support) and exposes helpers to look up a model from the - * raw 8-byte model id bytes at config offset 0x40..0x47. + * Loads the bundled servo_catalog.json and exposes helpers to look up a + * model from the raw 8-byte model id bytes at config offset 0x40..0x47. * - * Schema version 2 introduces an audience-separated structure: every - * parameter has user-facing top-level fields (vendor_label, description, - * unit, modes, min, max, values, docs_url) and a separate - * `implementation` nested object. Loaders here preserve both layers; - * CLI rendering MUST only read the user-facing fields. + * This module is transport-agnostic and should be shared across CLI, + * desktop, and future browser surfaces. */ -// Bun embeds this JSON into the compiled binary via `bun build --compile`. -// At dev time it's just a relative import. -import catalogJson from "../../data/servo_catalog.json" with { type: "json" }; +import catalogJson from "../../../data/servo_catalog.json" with { type: "json" }; export interface ParameterImplementation { offset?: string; @@ -75,11 +69,6 @@ export interface BundledFirmware { description: string; } -/** - * Per-model default for a parameter. Can be a primitive (e.g. "normal" - * for the inversion enum) or a structured `{raw, …}` object whose - * user-facing fields live alongside a nested `implementation`. - */ export type ModelDefaultValue = | string | number @@ -111,10 +100,6 @@ export interface Catalog { version: string; source_docs: string; models: Map; - /** - * Raw parameter entries straight out of the JSON. Prefer - * `loadParameters()` for typed access. - */ parameters: Record; } @@ -132,28 +117,18 @@ let cachedParameters: CatalogParameterSpec[] | undefined; let cachedServoModes: ServoModeSpec[] | undefined; let cachedNotYetMapped: Record | undefined; -/** - * Parse the raw 8-byte model id from config offset 0x40..0x47 into a - * clean ASCII string. Trailing '*' bytes are padding in the vendor - * format; we preserve them in the model id so that SA33*** matches - * exactly what the vendor exe stores. Null bytes are stripped. - */ export function parseModelId(bytes: Uint8Array): string { if (bytes.length !== 8) { throw new Error(`model id must be exactly 8 bytes, got ${bytes.length}`); } let s = ""; for (const b of bytes) { - if (b === 0x00) break; // null-terminated padding + if (b === 0x00) break; s += String.fromCharCode(b); } return s; } -/** - * Build the in-memory catalog from the bundled JSON. Placeholder entries - * in the JSON (keys starting with "_") are filtered out. - */ export function loadCatalog(): Catalog { if (cachedCatalog) return cachedCatalog; const models = new Map(); @@ -192,15 +167,6 @@ export function findModel(catalog: Catalog, modelId: string): ServoModel | undef return catalog.models.get(modelId); } -// --------------------------------------------------------------------- -// Schema v2 helpers: parameters, servo modes, _not_yet_mapped -// --------------------------------------------------------------------- - -/** - * Return the set of schema-v2 parameter entries (user-facing fields + - * the nested implementation object). Entries with "_"-prefixed keys - * are filtered out. - */ export function loadParameters(): CatalogParameterSpec[] { if (cachedParameters) return cachedParameters; const out: CatalogParameterSpec[] = []; diff --git a/packages/core/src/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/axon/src/driver/protocol.ts b/packages/core/src/driver/protocol.ts similarity index 62% rename from axon/src/driver/protocol.ts rename to packages/core/src/driver/protocol.ts index dfd701d..96a42a6 100644 --- a/axon/src/driver/protocol.ts +++ b/packages/core/src/driver/protocol.ts @@ -1,50 +1,33 @@ /** - * Axon dongle HID protocol — identify, read config, write config. + * Shared Axon identify/read/write protocol helpers. * - * The dongle is a transparent proxy to a Dynamixel-v1-like wire - * protocol at 9600 baud. HID command bytes (0x8A identify, 0xCD - * read, 0xCB write) are the same bytes that appear on the wire as - * the INSTR field. The HID reply contains the raw bytes from the - * servo's wire reply at rx[5..5+N], with rx[0]=0x04 (report id), - * rx[1]=status-hi (0x01=OK), rx[2]=status-lo (0x00=OK, 0xFA=no - * servo, 0x02=nack), rx[3]=addr echo, rx[4]=length echo. - * - * See docs/wire-protocol.md for the full specification (the - * "Wire format" and "HID reply format" sections cover the - * derivation), and - * research/static-analysis/ghidra_out/param_helper_READ_004047d0_FUN_004047d0.c - * for the vendor exe's equivalent function. Notable constants taken - * from that decomp: - * - * - max chunk size = 59 bytes (0x3B) - * - minimum inter-chunk sleep = 25 ms (Sleep(0x19)) - * - read success gate: rx[1] != 0 AND rx[2] == 0 - * - write success gate: rx[1] != 0 (rx[2] is NOT checked on write) + * This layer is intentionally transport-agnostic. The only dependency is + * the `DongleHandle` abstraction. */ import { AxonError } from "../errors.ts"; -import { REPORT_SIZE } from "./hid.ts"; import type { DongleHandle } from "./transport.ts"; +export const REPORT_SIZE = 64; export const CMD_IDENTIFY = 0x8a; export const CMD_READ = 0xcd; export const CMD_WRITE = 0xcb; export const REPORT_ID = 0x04; export const CONFIG_BLOCK_SIZE = 95; -export const MAX_CHUNK = 0x3b; // 59 +export const MAX_CHUNK = 0x3b; export const CHUNK_SLEEP_MS = 25; -export const WIRE_REPLY_SETTLE_MS = 80; // empirically adequate for 9600 baud +export const WIRE_REPLY_SETTLE_MS = 80; export type ServoMode = "servo_mode" | "cr_mode" | "unknown"; export interface IdentifyReply { present: boolean; rawRx: Buffer; - statusHi: number; // rx[1]: 0x01 ok, other values are command/NACK echoes - statusLo: number; // rx[2]: 0x00 ok, 0xFA no servo, etc. - modeByte: number | null; // rx[5] when present - mode: ServoMode; // from rx[5]: 0x03 → servo_mode, 0x04 → cr_mode + statusHi: number; + statusLo: number; + modeByte: number | null; + mode: ServoMode; } function buildTx(cmd: number, addr: number, length: number): Buffer { @@ -77,11 +60,6 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -/** - * Send one identify (`0x8A`) and classify the reply. Does NOT throw - * when the servo reports absent — returns `present: false` so - * callers like the monitor can handle transitions themselves. - */ export async function identify(handle: DongleHandle): Promise { const tx = buildTx(CMD_IDENTIFY, 0x00, 0x04); await handle.write(tx); @@ -93,9 +71,6 @@ export async function identify(handle: DongleHandle): Promise { const statusHi = rx[1] ?? 0x00; const statusLo = rx[2] ?? 0xff; - // rx[2]=0xFA is the canonical "no servo" response. Do not - // collapse every malformed reply into this state; callers need to - // distinguish an absent servo from transport/protocol weirdness. if (statusLo === 0xfa) { return { present: false, @@ -128,12 +103,6 @@ function hexPrefix(buf: Buffer, count: number): string { .join(" "); } -/** - * Read up to MAX_CHUNK bytes from the given config-block address. - * Throws `AxonError.notPrimed()` if the dongle is in cold state - * (the most common recoverable failure) or `AxonError.servoIo()` - * for any other unexpected failure. - */ export async function readChunk( handle: DongleHandle, addr: number, @@ -152,8 +121,6 @@ export async function readChunk( throw AxonError.servoIo(`read reply is ${rx.length} bytes, need at least ${5 + length}`); } if (rx[1] !== 0x01 || rx[2] !== 0x00) { - // Distinguish "no servo" from other failures so the user gets a - // useful recovery hint. if (rx[2] === 0xfa) throw AxonError.notPrimed(); const statusHi = rx[1] ?? 0; const statusLo = rx[2] ?? 0; @@ -164,25 +131,15 @@ export async function readChunk( return rx.subarray(5, 5 + length); } -/** - * Read the full 95-byte config block, transparently handling the - * two-chunk (59 + 36) split the dongle requires. - */ export async function readFullConfig(handle: DongleHandle): Promise { const chunk0 = await readChunk(handle, 0x00, MAX_CHUNK); const chunk1 = await readChunk(handle, MAX_CHUNK, CONFIG_BLOCK_SIZE - MAX_CHUNK); const full = Buffer.alloc(CONFIG_BLOCK_SIZE); - chunk0.copy(full, 0); - chunk1.copy(full, MAX_CHUNK); + full.set(chunk0, 0); + full.set(chunk1, MAX_CHUNK); return full; } -/** - * Write up to MAX_CHUNK bytes to the given config-block address. - * Write success gate per the vendor exe decomp is `rx[1] != 0` - * only — rx[2] is not checked on writes, consistent with our wire - * observation that writes are fire-and-forget on the 1-wire link. - */ export async function writeChunk( handle: DongleHandle, addr: number, @@ -205,10 +162,6 @@ export async function writeChunk( } } -/** - * Write the full 95-byte config block in two chunks matching the - * vendor exe's pattern. - */ export async function writeFullConfig(handle: DongleHandle, config: Uint8Array): Promise { if (config.length !== CONFIG_BLOCK_SIZE) { throw new Error(`writeFullConfig: expected ${CONFIG_BLOCK_SIZE} bytes, got ${config.length}`); @@ -217,10 +170,6 @@ export async function writeFullConfig(handle: DongleHandle, config: Uint8Array): await writeChunk(handle, MAX_CHUNK, config.subarray(MAX_CHUNK, CONFIG_BLOCK_SIZE)); } -/** - * Parse the model id from a full 95-byte config block. Model id - * lives at offset 0x40..0x47 as ASCII with '*' padding. - */ export function modelIdFromConfig(config: Uint8Array): string { let s = ""; for (let i = 0x40; i < 0x48; i++) { diff --git a/packages/core/src/driver/transport.ts b/packages/core/src/driver/transport.ts new file mode 100644 index 0000000..e168296 --- /dev/null +++ b/packages/core/src/driver/transport.ts @@ -0,0 +1,11 @@ +/** + * Transport abstraction for talking to the Axon dongle. + * + * This should be implementable by node-hid, WebHID, or test doubles. + */ + +export interface DongleHandle { + write(data: Buffer, timeoutMs?: number): Promise; + read(timeoutMs?: number): Promise; + release(): Promise; +} diff --git a/axon/src/errors.ts b/packages/core/src/errors.ts similarity index 54% rename from axon/src/errors.ts rename to packages/core/src/errors.ts index d55cdfc..9eefbc9 100644 --- a/axon/src/errors.ts +++ b/packages/core/src/errors.ts @@ -1,7 +1,8 @@ /** - * Exit codes and typed errors for the Axon CLI. + * Shared exit codes and typed errors. * - * See docs/CLI_DESIGN.md "Exit codes" section for the contract. + * The CLI consumes the exit codes directly. Other surfaces can still use + * the stable categories and human-facing recovery hints. */ export const ExitCode = { @@ -17,26 +18,16 @@ export const ExitCode = { export type ExitCodeName = keyof typeof ExitCode; export type ExitCodeValue = (typeof ExitCode)[ExitCodeName]; -/** - * Stable machine-readable category tag for errors that come back - * through the CLI's --json mode. Coding agents (and scripts) should - * branch on this field rather than parsing the human-readable - * message or hint. - * - * The category is a specific *state observation*, not a free-form - * error code. Each value corresponds to exactly one recoverable - * situation and exactly one suggested recovery. - */ export type ErrorCategory = - | "no_adapter" // adapter not on USB - | "adapter_busy" // enumerates but the HID open failed (positive observation) - | "adapter_io" // HID write or read on an open handle failed (observation, not cause) - | "no_servo" // adapter OK, identify returns rx[2]=0xFA - | "servo_io" // mid-transaction servo I/O error (lost during read/write after identify) - | "unknown_model" // servo present but its model id isn't in the catalog - | "validation" // user input out of range, bad file, etc. - | "usage" // CLI argument problem - | "internal"; // bug in our code — report with the stack on AXON_DEBUG=1 + | "no_adapter" + | "adapter_busy" + | "adapter_io" + | "no_servo" + | "servo_io" + | "unknown_model" + | "validation" + | "usage" + | "internal"; export class AxonError extends Error { public readonly category: ErrorCategory; @@ -52,11 +43,6 @@ export class AxonError extends Error { this.category = category; } - // -- USB / HID transport layer --------------------------------------------- - - /** - * Nothing matching VID 0471 PID 13AA on USB. - */ static noAdapter(detail: string): AxonError { return new AxonError( ExitCode.DongleNotFound, @@ -66,11 +52,6 @@ export class AxonError extends Error { ); } - /** - * The adapter enumerated on USB but `new HID.HID(path)` threw. - * Surface the raw OS error verbatim so the actual cause (EACCES, - * device in use, etc.) is visible rather than papered over. - */ static adapterBusy(detail: string): AxonError { return new AxonError( ExitCode.DongleNotFound, @@ -80,12 +61,6 @@ export class AxonError extends Error { ); } - /** - * HID write or read on an already-open handle failed. This is a - * pure observation — we do not speculate about the cause (stale - * handle, hot-swap, OS-level weirdness, etc.). The raw OS error is - * included verbatim so the user has something concrete to chase. - */ static adapterIo(detail: string): AxonError { return new AxonError( ExitCode.ServoIoError, @@ -95,11 +70,6 @@ export class AxonError extends Error { ); } - /** - * Adapter is connected, HID transport is fine, and identify - * returned a well-formed reply saying no servo is attached - * (rx[2]=0xFA). - */ static noServo(detail = "Adapter connected, but no servo detected."): AxonError { return new AxonError( ExitCode.NotPrimed, @@ -109,10 +79,6 @@ export class AxonError extends Error { ); } - /** - * Mid-transaction I/O failure: identify said present but a - * subsequent read/write got an unexpected reply. - */ static servoIo(detail: string): AxonError { return new AxonError( ExitCode.ServoIoError, @@ -122,8 +88,6 @@ export class AxonError extends Error { ); } - // -- Catalog / model layer ------------------------------------------------- - static unknownModel(modelId: string): AxonError { return new AxonError( ExitCode.UnknownModel, @@ -133,8 +97,6 @@ export class AxonError extends Error { ); } - // -- User / CLI layer ------------------------------------------------------ - static validation(detail: string): AxonError { return new AxonError(ExitCode.ValidationError, detail, undefined, "validation"); } @@ -143,8 +105,6 @@ export class AxonError extends Error { return new AxonError(ExitCode.UsageError, detail, undefined, "usage"); } - // -- Compatibility aliases (kept for source compat with existing callers) -- - static dongleNotFound(detail: string): AxonError { return AxonError.noAdapter(detail); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..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/packages/transport-nodehid/package.json b/packages/transport-nodehid/package.json new file mode 100644 index 0000000..b1ce855 --- /dev/null +++ b/packages/transport-nodehid/package.json @@ -0,0 +1,23 @@ +{ + "name": "@axon/transport-nodehid", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + ".": "./src/hid.ts", + "./nodehid": "./src/nodehid.ts" + }, + "scripts": { + "check": "bun x @biomejs/biome check src package.json", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@axon/core": "workspace:*", + "node-hid": "^3.3.0" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.11", + "@types/bun": "latest", + "typescript": "^5" + } +} diff --git a/axon/src/driver/hid.ts b/packages/transport-nodehid/src/hid.ts similarity index 97% rename from axon/src/driver/hid.ts rename to packages/transport-nodehid/src/hid.ts index c577211..c83b38c 100644 --- a/axon/src/driver/hid.ts +++ b/packages/transport-nodehid/src/hid.ts @@ -18,10 +18,10 @@ * was a libusb-era footgun. */ -import { AxonError } from "../errors.ts"; +import type { DongleHandle } from "@axon/core/driver/transport"; +import { AxonError } from "@axon/core/errors"; import type { NodeHidDevice, NodeHidHandle } from "./nodehid.ts"; import { getNodeHidBinding } from "./nodehid.ts"; -import type { DongleHandle } from "./transport.ts"; export const VID = 0x0471; export const PID = 0x13aa; @@ -99,7 +99,7 @@ class NodeHidDongle implements DongleHandle { throw hidWriteError(`buffer is ${data.length} bytes, max is ${REPORT_SIZE}`); } const padded = Buffer.alloc(REPORT_SIZE); - data.copy(padded, 0); + padded.set(data, 0); try { const n = this.device.write(Array.from(padded)); if (n <= 0) { diff --git a/axon/src/driver/nodehid.ts b/packages/transport-nodehid/src/nodehid.ts similarity index 77% rename from axon/src/driver/nodehid.ts rename to packages/transport-nodehid/src/nodehid.ts index 643710c..c821c60 100644 --- a/axon/src/driver/nodehid.ts +++ b/packages/transport-nodehid/src/nodehid.ts @@ -41,15 +41,15 @@ let cachedBinding: NodeHidBinding | undefined; function loadBindingForCurrentPlatform(): NodeHidBinding { switch (`${process.platform}/${process.arch}`) { case "darwin/arm64": - return require("../../node_modules/node-hid/prebuilds/HID-darwin-arm64/node-napi-v4.node"); + return require("../node_modules/node-hid/prebuilds/HID-darwin-arm64/node-napi-v4.node"); case "darwin/x64": - return require("../../node_modules/node-hid/prebuilds/HID-darwin-x64/node-napi-v4.node"); + return require("../node_modules/node-hid/prebuilds/HID-darwin-x64/node-napi-v4.node"); case "linux/arm64": - return require("../../node_modules/node-hid/prebuilds/HID_hidraw-linux-arm64/node-napi-v4.node"); + return require("../node_modules/node-hid/prebuilds/HID_hidraw-linux-arm64/node-napi-v4.node"); case "linux/x64": - return require("../../node_modules/node-hid/prebuilds/HID_hidraw-linux-x64/node-napi-v4.node"); + return require("../node_modules/node-hid/prebuilds/HID_hidraw-linux-x64/node-napi-v4.node"); case "win32/x64": - return require("../../node_modules/node-hid/prebuilds/HID-win32-x64/node-napi-v4.node"); + return require("../node_modules/node-hid/prebuilds/HID-win32-x64/node-napi-v4.node"); default: throw new Error( `Unsupported platform for embedded node-hid prebuild: ${process.platform}/${process.arch}`, diff --git a/packages/transport-nodehid/tsconfig.json b/packages/transport-nodehid/tsconfig.json new file mode 100644 index 0000000..5e20d9b --- /dev/null +++ b/packages/transport-nodehid/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": ["bun"] + }, + "include": ["src/**/*"] +} diff --git a/packages/transport-webhid/package.json b/packages/transport-webhid/package.json new file mode 100644 index 0000000..57fccf6 --- /dev/null +++ b/packages/transport-webhid/package.json @@ -0,0 +1,22 @@ +{ + "name": "@axon/transport-webhid", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "check": "bun x @biomejs/biome check src package.json", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@axon/core": "workspace:*", + "@axon/ui": "workspace:*" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.11", + "@types/bun": "latest", + "typescript": "^5" + } +} diff --git a/packages/transport-webhid/src/index.ts b/packages/transport-webhid/src/index.ts new file mode 100644 index 0000000..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/**/*"] +} diff --git a/research/README.md b/research/README.md index 7c02bb7..3418e82 100644 --- a/research/README.md +++ b/research/README.md @@ -3,7 +3,7 @@ This directory holds **everything we did to figure out how the Axon servo programmer works**, kept here for reproducibility and so the [blog post](../docs/the-adventure.md) has receipts. None of these -files are imported by the production CLI in [`../axon/`](../axon/) — +files are imported by the production CLI in [`../apps/cli/`](../apps/cli/) — they're archaeology. ## What's in each subdirectory @@ -48,3 +48,9 @@ The chronological path is: The full first-person narrative — with the dead ends, the "aha" moments, and the reflections on doing this with an agent — is in [`docs/the-adventure.md`](../docs/the-adventure.md). + +Two artifacts that began as research have now graduated into active app +packages: + +- [`../apps/web/`](../apps/web/) — the browser WebHID PoC +- [`../apps/desktop/`](../apps/desktop/) — the Electrobun desktop PoC diff --git a/research/python-tests/README.md b/research/python-tests/README.md index 2e3f307..5cac7e6 100644 --- a/research/python-tests/README.md +++ b/research/python-tests/README.md @@ -2,7 +2,7 @@ Every Python script we wrote during the reverse-engineering session, in roughly the order we wrote them. None are imported by the -production CLI in `../axon/` — these are diagnostic / experiment +production CLI in `../apps/cli/` — these are diagnostic / experiment scripts kept for reference and reproducibility. ## What each script proved (or disproved) diff --git a/research/sudoers/uninstall_sudoers.sh b/research/sudoers/uninstall_sudoers.sh index d7a1960..fed583b 100755 --- a/research/sudoers/uninstall_sudoers.sh +++ b/research/sudoers/uninstall_sudoers.sh @@ -33,6 +33,6 @@ fi echo " $DEST is gone." echo echo "Sanity check — the axon CLI should now work without sudo:" -echo " cd axon && bun run src/cli.ts status" +echo " cd apps/cli && bun run src/cli.ts status" echo echo "If that works, the libusb-era sudoers setup is fully gone." diff --git a/scripts/build-release.sh b/scripts/build-release.sh index e9bb2ed..1f09a6c 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -12,12 +12,12 @@ workflow matrix to build all platforms. Examples: scripts/build-release.sh scripts/build-release.sh --target bun-darwin-arm64 - scripts/build-release.sh --target bun-linux-x64 --outfile axon/dist/axon-linux-x64 + scripts/build-release.sh --target bun-linux-x64 --outfile apps/cli/dist/axon-linux-x64 EOF } repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -axon_dir="${repo_root}/axon" +cli_dir="${repo_root}/apps/cli" if ! command -v bun >/dev/null 2>&1; then echo "error: bun is required to build release binaries" >&2 @@ -27,7 +27,7 @@ fi skip_checks=0 target="" outfile="" -outdir="${axon_dir}/dist" +outdir="${cli_dir}/dist" allow_cross_target="${AXON_ALLOW_CROSS_TARGET:-0}" while [[ $# -gt 0 ]]; do @@ -130,21 +130,19 @@ else fi ( - cd "${axon_dir}" + cd "${repo_root}" bun install --frozen-lockfile ) if [[ "${skip_checks}" -eq 0 ]]; then ( - cd "${axon_dir}" - bun run check - bun run typecheck - bun test + cd "${repo_root}" + bun run ci ) fi ( - cd "${axon_dir}" + cd "${cli_dir}" bun build \ --compile \ --target="${target}" \ diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index 7bc8a22..7534520 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -2,16 +2,14 @@ set -eu ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" -cd "$ROOT/axon" +cd "$ROOT" if ! command -v bun >/dev/null 2>&1; then echo "error: Bun is required. Install it from https://bun.sh/" >&2 exit 127 fi -# Match the required CI checks as closely as a local platform can: -# dependencies, Biome check, TypeScript, and the Bun test suite. +# Match the required CI checks as closely as a local platform can at the +# workspace root. bun install --frozen-lockfile -bun run check -bun run typecheck -bun test +bun run ci diff --git a/turbo.json b/turbo.json new file mode 100644 index 0000000..6ac1732 --- /dev/null +++ b/turbo.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["bin/**", "dist/**", "build/**"] + }, + "check": { + "dependsOn": ["^check"], + "outputs": [] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [] + }, + "test": { + "dependsOn": ["^test"], + "outputs": [] + } + } +}