app
Adom USB
Public Unreviewedby John Lauer
See every USB device across the entire Adom ecosystem — laptop, WSL2, cloud container, Azure VM, workcells — in one dashboard, and mount / unmount / reset / test / force-class / sniff any of them. USB/IP slingshot for the whole mesh.
← Commit history
v0.2.0 — plugins + deep docs + MIT license
20 files changed
+964−75
.gitignore+1Cargo.lock+1−1Cargo.toml+1−1LICENSE+21README.md+529−39VERSION+1−1examples/adom-usb-sniffer-cdc/README.md+13examples/adom-usb-sniffer-cdc/adom-usb-sniffer-cdc+54examples/adom-usb-sniffer-cdc/install.sh+10examples/adom-usb-sniffer-cdc/package.json+20examples/adom-usb-sniffer-cdc/plugin.json+13examples/adom-usb-sniffer-cdc/uninstall.sh+5package.json+1−1page.json+9−28skills/adom-usb/SKILL.md+17src/app/mod.rs+42−3src/cli.rs+28src/main.rs+15src/plugins.rs+182wiki/page.json+1−1.gitignore+1@@ -1,2 +1,3 @@ target/ *.log+bin/
Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-usb"-version = "0.1.0"+version = "0.2.0" dependencies = [ "base64", "clap",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-usb"-version = "0.1.0"+version = "0.2.0" edition = "2021" description = "Adom USB device mesh — see every USB device across the Adom ecosystem (laptop, WSL2, cloud container, Azure VM, workcells) and mount/unmount/reset/test/sniff it from one dashboard" license = "MIT"
LICENSEadded+21@@ -0,0 +1,21 @@+MIT License++Copyright (c) 2026 Adom Industries, Inc.++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE+SOFTWARE.
README.md+529−39@@ -2,68 +2,558 @@ **See every USB device across the entire Adom ecosystem — and mount it anywhere.** -`adom-usb` is a Hydrogen webview app (+ CLI) that unifies USB across the whole-mesh: your **laptop**, its **WSL2 / Hydrogen Desktop**, this **cloud container**,-the **Azure VM** and its **WSL2**, and **workcells**. One table, Local/Cloud-filters, and per-device **mount / unmount / reset / test / force-class / sniff**.+`adom-usb` turns USB from a per-machine accident of geography into a **mesh**.+Plug a dev board into your laptop and it shows up in one dashboard alongside the+USB on your cloud container, your Azure VM, and the workcells on the bench — and+from that one table you can **mount** any device onto any host, then **reset /+test / force-class / sniff** it. It's the same Linux USB/IP "slingshot" Adom+already uses to pull workcell USB into cloud containers, generalized so your own+machines are first-class members of the mesh. -It extends the same Linux USB/IP "slingshot" that already moves workcell USB into-cloud containers — now with the user's own machines as first-class sources and-targets.+This project is **open source (MIT)**. All source — the Rust CLI, the embedded+webview, the inventory/routing engine, the plugin system — is in this repo and+published to the Adom Wiki. Contributions, and especially **sniffer/decoder+plugins**, are welcome; see [Contributing](#contributing) and+[Writing a plugin](#writing-a-packet-sniffer-plugin).  -## Run+---++## Table of contents++- [Install](#install)+- [Quick start](#quick-start)+- [How it works](#how-it-works)+ - [The mental model: a USB mesh](#the-mental-model-a-usb-mesh)+ - [USB/IP in 90 seconds](#usbip-in-90-seconds)+ - [Hosts, sources, and targets](#hosts-sources-and-targets)+ - [The routing matrix](#the-routing-matrix)+ - [Transport: how a device's port reaches a target](#transport-how-a-devices-port-reaches-a-target)+ - [The agent model](#the-agent-model)+ - [The broker / registry](#the-broker--registry)+ - [The dashboard: server-as-truth + AI-drivable](#the-dashboard-server-as-truth--ai-drivable)+ - [The device data model](#the-device-data-model)+ - [Capabilities: why buttons grey out](#capabilities-why-buttons-grey-out)+ - [Security model](#security-model)+- [HTTP API reference](#http-api-reference)+- [CLI reference](#cli-reference)+- [Building from source](#building-from-source)+- [Repo layout](#repo-layout)+- [Contributing](#contributing)+- [Writing a packet-sniffer plugin](#writing-a-packet-sniffer-plugin)+- [Publishing your plugin to the wiki](#publishing-your-plugin-to-the-wiki)+- [How the app discovers plugins](#how-the-app-discovers-plugins)+- [Roadmap](#roadmap)+- [License](#license)++---++## Install++```bash+adompkg install adom-usb # from the Adom Wiki (recommended)+```++or grab the binary directly: ```bash-adom-usb app # live: real lsusb on this container + registered agents+curl -fsSL https://wiki.adom.inc/download/john/adom-usb/0.2.0/adom-usb \+ -o /tmp/adom-usb && chmod +x /tmp/adom-usb \+ && sudo install -m 0755 /tmp/adom-usb /usr/local/bin/adom-usb && adom-usb install+```++## Quick start++```bash+adom-usb app # live: real lsusb here + any registered agents adom-usb app --demo # seed a representative cross-ecosystem set for a tour-adom-usb list --demo # unified inventory as JSON (no server)+adom-usb list --demo # the unified inventory as JSON, no server adom-usb health # is a dashboard server up?+adom-usb plugins list # sniffer/decoder plugins installed on this host ``` -Opens a Hydrogen tab named **USB Devices**. Columns: device · VID:PID · serial ·-class · source/location · current mount · target picker + actions.+`adom-usb app` opens a Hydrogen webview tab named **USB Devices**. Columns:+device · VID:PID · serial · class · source/location · current mount · target+picker + actions. Filter chips: **All / Local / Cloud**. -## Drive it (every button is also an HTTP endpoint — AI-drivable)+Drive it from a terminal or from Claude — every button is also an HTTP endpoint: ```bash adom-usb mount laptop:2e8a:0003 laptop-wsl2 # RP2040 → laptop WSL2 (usbipd attach --wsl) adom-usb mount laptop:2e8a:0003 this-container # RP2040 → cloud container (reverse tunnel) adom-usb unmount laptop:2e8a:0003-curl -s localhost:8896/state | jq # hosts, devices, targets, mount state, action log-curl -sX POST localhost:8896/sniff -d '{"device_id":"..."}'+curl -s localhost:8896/state | jq # hosts, devices, targets, mounts, plugins, log+```++---++## How it works++### The mental model: a USB mesh++Normally a USB device is usable only by the machine it's physically plugged+into. `adom-usb` treats the fleet as a single fabric with two roles:++- A **source** is a host with physical USB ports that can *export* a device+ (your laptop, an Azure VM, a workcell).+- A **target** is a host that can *consume* a device as if it were locally+ attached (a cloud container, a WSL2 distro, a workcell).++A **mount** is a directed edge `(source, device) → target`. The dashboard is the+control plane over the whole graph; the actual bytes travel as standard Linux+USB/IP, tunneled over transports the Adom platform already has.++> The unifying idea: **"your laptop is a workcell that travels with you."** The+> same device model, association flow, and hot-plug notifications that already+> move workcell USB into cloud containers are reused verbatim — your personal+> machines just become additional sources/targets on the same fabric.++### USB/IP in 90 seconds++USB/IP is a Linux kernel facility that encapsulates **USB Request Blocks (URBs)+over TCP**. There are two halves:++- **Server side** (`usbipd` / `usbip bind`) exports a physical device on TCP+ port **3240**. On Windows the equivalent is **`usbipd-win`** (the+ Microsoft-documented project).+- **Client side** (`vhci-hcd` kernel module + `usbip attach`) creates a+ *virtual* USB host controller on the consuming machine. The remote device+ enumerates there exactly as if it were plugged in locally — `lsusb` sees it,+ drivers bind to it, `/dev/ttyUSB0` appears, etc.++`adom-usb` does not reinvent any of this. It **orchestrates** the standard tools+across the mesh and gives you one place to see and drive them. Every mount the+dashboard performs is recorded with the **exact shell command it runs**, visible+in the action log — nothing is hidden behind magic.++### Hosts, sources, and targets++The mesh's hosts (see [`src/devices.rs`](src/devices.rs) → `hosts()`):++| Host | Kind | OS | Exports USB? | Consumes USB? | Bucket |+|---|---|---|---|---|---|+| Your laptop | `laptop` | Windows | ✅ `usbipd-win` | native only (v1) | local |+| Laptop WSL2 / Hydrogen Desktop | `wsl2` | Linux | — | ✅ `vhci-hcd` | local |+| This cloud container | `container` | Linux | — | ✅ `vhci-hcd` | cloud |+| Azure VM desktop | `azure-vm` | Windows | ✅ `usbipd-win` | native only (v1) | cloud |+| Azure VM WSL2 / HD-on-VM | `wsl2` | Linux | — | ✅ `vhci-hcd` | cloud |+| Workcell (rpi, …) | `workcell` | Linux | ✅ `usbip` | ✅ | cloud |++Only **laptop / azure-vm / workcell** can export (they have physical ports ++an export agent). A device whose source is a container or a WSL2 distro is a+*consumer's* view of a device — it stays native on its host and is not+re-exportable. The dashboard reflects this: such rows show **"native — on its+host"** instead of a mount picker.++### The routing matrix++For a device on a given source, the set of legal targets (and the mechanism for+each) is computed in [`src/devices.rs`](src/devices.rs) → `targets_for()`:++| Source → Target | Mechanism |+|---|---|+| laptop board → **laptop Windows (native)** | re-bind to the host driver — trivial |+| laptop board → **laptop WSL2 / HD** | `usbipd bind` + `usbipd attach --wsl` (local, no relay) |+| laptop board → **cloud container** | `usbipd bind` → **reverse tunnel** → `usbip attach -r 127.0.0.1` |+| laptop board → **Azure-VM WSL2** | `usbipd bind` → tunnel → `usbip attach` in the VM's WSL2 |+| laptop board → **Azure-VM Windows desktop** | **deferred** — needs the test-signed usbip-win *client* driver |+| workcell device → any Linux target | existing direct USB/IP TCP path |++**v1 rule — Windows is source + local-only.** A Windows machine can export its+USB and natively use devices physically plugged into it, but mounting a *remote*+device *into* a native Windows desktop is deferred (it requires the test-signed+`usbip-win` client driver and Secure-Boot-off / test-signing). Those target+cells render as blocked, with the reason shown on hover.++### Transport: how a device's port reaches a target++Three cases, by physical topology:++1. **Same machine** (laptop USB → laptop WSL2): `usbipd attach --wsl`. No relay,+ no network — this is the Microsoft-supported path. The cleanest mount.+2. **Cross-machine** (laptop USB → cloud container, or → Azure-VM WSL2): the+ source `usbipd bind`s the device, then a **reverse tunnel** carries the+ source's local `:3240` to the target, which runs `usbip attach -r 127.0.0.1`.+ This reuses the adom-desktop relay's existing `TUNL` byte-pump — the same one+ that already tunnels RDP — but in the *opposite* listen-direction (the target+ listens; the source's agent dials the laptop's local `usbipd`). The+ implementation spec lives in the adom-desktop repo at `skills/USB_MESH.md`.+3. **Workcell → cloud**: the workcell's `usbipd` is reachable on the facility+ network; the container dials it directly on `:3240`. Unchanged from today.++Latency note: bulk/CDC/serial/JTAG/DFU/bootsel devices tunnel fine over a WAN;+**isochronous** devices (webcams, audio, some streaming logic analyzers) are+marginal over the relay and are best kept host-local. The UI labels this.++### The agent model++Every host exposes the same small verb set — `list / export / unexport / attach+/ detach / reset / class-override / sniff` — reached over whatever transport that+host already has:++- **Windows hosts** (laptop, Azure VM): the agent is a new `adom-desktop usb …`+ subcommand wrapping `usbipd-win`, reached over the adom-desktop relay.+- **Linux / WSL2 / container hosts**: a thin wrapper over `usbip` ++ `usbmon`/`driver_override`. The container side already exists from the workcell+ feature.+- **Workcells**: the existing agent, unchanged.++The **dashboard backend is the orchestrator**: on a mount it tells the source+agent to bind, sets up the tunnel if cross-machine, tells the target agent to+attach, and records state. It reaches the laptop + its WSL2 via the adom-desktop+relay and the Azure VM + its WSL2 via `azure-winvm`.++### The broker / registry++A generalized **Curium** broker (`source_type` / `source_id`, extending the+workcell `UsbipServer` model) is the durable registry: every agent registers its+live inventory and hot-plug events there, so all surfaces see one unified list.+To stay unblocked while that generalization lands, the dashboard can also+aggregate inventory by polling agents directly through their transports.++### The dashboard: server-as-truth + AI-drivable++The webview is a thin client. **The server is the single source of truth.** The+UI never holds its own copy of "what's mounted" — it polls `GET /state` every+~600 ms and re-renders on change ([`src/app/ui.html`](src/app/ui.html)). Every+button is a thin wrapper that `POST`s to an endpoint; the same endpoint is+curl-able. That's the defining property of an Adom app: the AI drives it exactly+like a human, and the UI's poller turns either one's state change into the same+visual update. Console logs forward to `/console`; in `--dev` mode a `/eval`+channel lets the AI hot-patch the running UI.++### The device data model++Each device is the `BoundDevice` shape the workcell USB/IP feature already uses+(see [`src/devices.rs`](src/devices.rs) → `Device`):++```jsonc+{+ "id": "laptop:10c4:ea60", // stable: <source>:<vid>:<pid>+ "name": "CP2102 USB-to-UART Bridge",+ "vid": 4292, "pid": 60000, // numeric; vid_hex/pid_hex added in /state+ "serial": "0001",+ "class": "CDC serial",+ "bus_id": "2-3", // USB/IP bus id on the source+ "source_id": "laptop",+ "source_label": "John's laptop (Windows)",+ "location": "local", // local | cloud (filter bucket)+ "mounted_on": null, // host id, or null+ "exportable": true, // can it be slingshotted off its source?+ "targets": [ /* computed routing matrix, with mechanism + blocked_reason */ ],+ "capabilities": { "mount": true, "unmount": false, "reset": false,+ "test": false, "class_override": false, "sniff": false },+ "sniffer_plugin": "adom-usb-sniffer-cdc" // matched plugin, if any+}+```++### Capabilities: why buttons grey out++`reset` / `test` / `force-class` / `sniff` operate on the **virtual device in a+Linux target** — they only make sense once a device is attached to a Linux host.+So they are disabled (greyed, with a hover reason) until you mount the device on+a Linux target; for Windows-native devices they're unavailable entirely. `mount`+is offered only for exportable, not-yet-remote devices; `unmount` only when a+device is currently slingshotted to a host other than its source. See+`capabilities_for()` in [`src/devices.rs`](src/devices.rs).++### Security model++Slingshotting raw USB into a cloud container is **real device exposure**. The+design keeps mounts **explicit, per-device, per-session, and revocable** — never+auto-attach. Binding a device on Windows removes it from the host while it's+exported; the UI makes that state obvious and tears it down cleanly on unplug.+`usbipd-win` install needs a one-time elevation, folded into adom-desktop's+first-run.++---++## HTTP API reference++The app server (default port **8896**) is fully scriptable. All POST bodies are+JSON; all responses are JSON (`Content-Security-Policy: frame-ancestors *` so the+webview can embed it).++| Method | Path | Body | Purpose |+|---|---|---|---|+| GET | `/` | — | the dashboard HTML |+| GET | `/state` | — | full snapshot: `hosts`, `devices` (+ targets/capabilities/sniffer_plugin), `counts`, `plugins`, `actions` |+| GET | `/health` | — | `{ok, demo, devices, version}` |+| POST | `/refresh` | — | re-enumerate + bump `state_ts` |+| POST | `/mount` | `{device_id, target_id}` | validate against the routing matrix, set the mount, return the agent command |+| POST | `/unmount` | `{device_id}` | detach from its current target |+| POST | `/reset` | `{device_id}` | power-cycle / re-enumerate (Linux target only) |+| POST | `/test` | `{device_id}` | `lsusb -v` + enumerate/echo health check |+| POST | `/class-override` | `{device_id}` | bind a forced driver class (HID / storage / serial) |+| POST | `/sniff` | `{device_id}` | capture USB packets — **dispatches to a matching plugin** if one is installed, else built-in usbmon |+| GET | `/plugins` | — | `{installed, available, tag, api_version}` |+| POST | `/console` | log entry | UI console forwarding |+| GET | `/console` | — | ring buffer of UI logs (AI debugging) |+| POST | `/shutdown` | — | remove the tab and exit |++Mount/op responses always include the **exact `command`** that the live agent+runs, and (for sniff) the `plugin` that handled it — so the orchestration is+auditable and diffable against the real implementation as agents land.++---++## CLI reference++| Command | Purpose |+|---|---|+| `adom-usb app [--demo] [--no-tab] [--port N] [--dev]` | open the webview dashboard |+| `adom-usb list [--demo]` | print the unified inventory as JSON |+| `adom-usb mount <device_id> <target_id>` | mount (POSTs to a running server) |+| `adom-usb unmount <device_id>` | unmount |+| `adom-usb health [--port N]` | check a running server |+| `adom-usb plugins list` | installed sniffer/decoder plugins |+| `adom-usb plugins search` | publishable plugins on the wiki |+| `adom-usb install` | deploy the SKILL.md so Claude discovers the app |++Output follows the Adom CLI convention (`OK:` / `ERROR:` / `Hint:`, isatty-gated+color).++---++## Building from source++Requires Rust (stable). No system deps beyond `lsusb` for live enumeration.++```bash+git clone https://github.com/adom-inc/adom-usb && cd adom-usb+cargo build --release # → target/release/adom-usb+./target/release/adom-usb app --demo --no-tab --port 8899+# open http://127.0.0.1:8899 (or the Coder proxy URL inside a container)+```++The UI is a single embedded file (`src/app/ui.html`, compiled in via+`include_str!`) — there is no JS build step. Edit it and rebuild.++## Repo layout++```+adom-usb/+ src/+ main.rs # clap CLI surface+ cli.rs # list / mount / unmount / health / plugins+ devices.rs # the mesh model: hosts, routing matrix, capabilities, lsusb + demo+ plugins.rs # plugin discovery, matching, dispatch ← the extension seam+ install.rs # deploy SKILL.md+ app/+ mod.rs # tiny_http server: /state, /mount, /sniff, /plugins, …+ tab.rs # Hydrogen tab registration (adom-cli)+ ui.html # the entire dashboard (embedded)+ examples/+ adom-usb-sniffer-cdc/ # a reference sniffer plugin — copy this to start+ skills/adom-usb/SKILL.md # the Claude-facing skill+ page.json / package.json # wiki page + adompkg manifest+ screenshots/hero.png # the billboard hero+```++---++## Contributing++`adom-usb` is MIT and developed in the open. Two equally valid front doors:++- **GitHub** — `github.com/adom-inc/adom-usb`. Fork, branch, PR. Keep changes+ focused; match the surrounding Rust style; run `cargo build --release` and+ `cargo clippy` clean.+- **The Adom Wiki** — the wiki page *is* a git repo with its own PR flow+ (`POST /api/v1/pages/adom-usb/prs`). Edits to docs, the README, or the page+ land there and mirror to GitHub.++Good first contributions:++- **A sniffer/decoder plugin** for a device class you care about (see below) —+ the highest-leverage way to grow the ecosystem.+- A new **host kind** or transport mechanism in `devices.rs`.+- Filling in a live **agent** (the `adom-desktop usb` verb, the reverse tunnel —+ spec in adom-desktop `skills/USB_MESH.md`).+- UI polish in `ui.html` (it's plain HTML/CSS/JS, brand tokens at the top).++Please don't commit the compiled binary to git (it ships in the adompkg+tarball); `bin/` and `target/` are gitignored.++---++## Writing a packet-sniffer plugin++A **plugin** extends the mesh with a new capture/decode backend — most commonly a+sniffer for a particular device class (CDC serial, HID, mass storage, a vendor+protocol). A plugin is nothing more than:++1. an **executable** that implements a tiny CLI contract, and+2. a **`plugin.json`** manifest describing what it sniffs and how to match it.++The complete reference plugin lives in+[`examples/adom-usb-sniffer-cdc/`](examples/adom-usb-sniffer-cdc/) — copy that+directory and edit it.++### 1. The manifest (`plugin.json`)++```jsonc+{+ "slug": "adom-usb-sniffer-cdc", // unique; also the wiki package slug+ "name": "CDC / ACM serial sniffer",+ "version": "0.1.0",+ "api_version": "1", // plugin API version (current: 1)+ "kind": "sniffer", // sniffer | decoder+ "entrypoint": "adom-usb-sniffer-cdc", // executable on PATH (or in the plugin dir)+ "matches": { // which devices this applies to (globs; "*" = any)+ "class": ["CDC serial"], // matched against the device's class string+ "vid": ["*"], // 4-hex-digit vendor id, or "*"+ "pid": ["*"] // 4-hex-digit product id, or "*"+ },+ "capture": "usbmon", // capture backend you use+ "decoders": ["cdc-acm", "at-commands"],+ "description": "Capture + decode USB CDC/ACM serial traffic to pcap."+} ``` -Each mount/op records the **exact agent command** it runs, visible in the-dashboard's action log.+**Matching & specificity.** When the user clicks **Sniff** on a device,+`adom-usb` picks the *most specific* installed plugin whose `matches` apply+(exact `class` beats `*`, a concrete `vid` beats `*`, etc. — see `match_for()`+in [`src/plugins.rs`](src/plugins.rs)). A plugin with `"class": ["*"]` is a+universal fallback; a plugin pinned to a `vid:pid` always wins for that device.++### 2. The executable contract (plugin API v1)++Your `entrypoint` must implement exactly two subcommands:++```bash+# Print your manifest (the app uses this to introspect installed plugins):+<entrypoint> manifest # -> prints plugin.json to stdout++# Capture traffic for one device on the (Linux) host where it's mounted:+<entrypoint> sniff \+ --vid <hex> --pid <hex> --busid <bus-id> --target <host> \+ [--seconds <n>] [--out <file.pcap>]+# -> writes a pcap and prints a JSON result:+# {"ok":true,"pcap":"/tmp/x.pcap","packets":128,"decoders":["cdc-acm"],"summary":"…"}+```++That's the whole interface. Inside `sniff` you typically `modprobe usbmon` and+run `tshark`/`tcpdump` filtered to the device's bus + vid/pid, then (optionally)+post-process into a human-readable decode. The reference plugin's ~40-line bash+shows the full pattern; a real decoder might be Rust/Python and emit a richer+summary. Plugins run **on the Linux target where the device is mounted**, so they+have the virtual device + `usbmon` available.++### 3. Test it locally++```bash+cd examples/adom-usb-sniffer-cdc && ./install.sh # drops it in ~/.adom/usb-plugins/+adom-usb plugins list # should list your plugin+adom-usb app --demo --no-tab --port 8899 &+curl -s localhost:8899/plugins | jq # installed: [ your plugin ]+curl -s localhost:8899/state | jq '.devices[]|{id,class,sniffer_plugin}' # matches?+```++When a matching device is mounted on a Linux target and you hit Sniff, the app+calls your `sniff` subcommand and shows the result + the exact command in the+action log.++---++## Publishing your plugin to the wiki++Plugins are ordinary Adom Wiki packages — published with `adompkg`, exactly like+any other package, plus **two conventions** that make `adom-usb` find them:++1. Put the **`adom_usb_plugin`** block in your `package.json` (so an installed+ package carries the manifest), and+2. add **`adom-usb-plugin`** to the package's `keywords` (so the registry can+ surface it).++```jsonc+// package.json+{+ "slug": "adom-usb-sniffer-cdc",+ "version": "0.1.0",+ "type": "app",+ "description": "adom-usb plugin: capture + decode USB CDC/ACM serial to pcap.",+ "keywords": ["adom-usb-plugin", "sniffer", "usb", "cdc"],+ "scripts": { "install": "./install.sh", "uninstall": "./uninstall.sh" },+ "files": ["adom-usb-sniffer-cdc", "plugin.json", "install.sh", "uninstall.sh", "package.json", "README.md"],+ "adom_usb_plugin": {+ "kind": "sniffer",+ "entrypoint": "adom-usb-sniffer-cdc",+ "matches": { "class": ["CDC serial"], "vid": ["*"], "pid": ["*"] },+ "capture": "usbmon",+ "decoders": ["cdc-acm"],+ "api_version": "1"+ }+}+```++Your `install.sh` should drop `plugin.json` + the entrypoint into+`~/.adom/usb-plugins/<slug>/` and symlink the entrypoint onto `PATH` (the+reference plugin does exactly this). Then publish like any Adom package (see the+`adom-wiki-v2` skill for the full flow):++```bash+WIKI=https://wiki.adom.inc; TOKEN=$(cat /var/run/adom/api-key)+# 1. create the page + git repo (first publish only)+curl -s -X POST "$WIKI/api/v1/pages" -H "Authorization: Bearer $TOKEN" \+ -H "Content-Type: application/json" -H "User-Agent: adompkg/2" \+ -d '{"type":"app","slug":"adom-usb-sniffer-cdc","title":"adom-usb CDC sniffer","brief":"…"}'+# 2. push the source files to the git layer (POST /pages/<slug>/files)+# 3. publish the tarball to the registry+export ADOMPKG_REGISTRY=$WIKI && adompkg publish+```++Now anyone can `adompkg install adom-usb-sniffer-cdc`, and every `adom-usb`+running on a host where it's installed will route the Sniff action for matching+devices to it.++---++## How the app discovers plugins++Discovery has two sources (both in [`src/plugins.rs`](src/plugins.rs)):++1. **Installed (local, cheap, every `/state`)** — `discover_installed()` scans:+ - `~/.adom/usb-plugins/<slug>/plugin.json` (where a plugin's `install.sh`+ drops its manifest + entrypoint), and+ - any `adompkg`-installed module under `~/project/adom_modules/<slug>/` whose+ `package.json` carries an `adom_usb_plugin` block.++ For each device, `/state` attaches `sniffer_plugin` = the slug of the+ best-matching installed plugin (or `null`), so the UI can badge it and the+ Sniff button knows where to route. -## Routing matrix (v1)+2. **Registry (network, on demand)** — `discover_registry()` queries the wiki's+ discovery API for publishable plugins not yet installed:+ `GET https://wiki.adom.inc/api/v1/discover?triggers=adom-usb-plugin`. Surfaced+ via `GET /plugins` (`available`) and `adom-usb plugins search`, so the+ dashboard can show "plugins you could install" with a one-click+ `adompkg install`. -Windows is **source + local-only**: a Windows box can export its USB and natively-use devices plugged into it, but a *remote* device cannot yet be mounted *into* a-native Windows desktop (needs the test-signed usbip-win client driver — those-targets show blocked). Linux targets (cloud container, laptop WSL2/HD, Azure-WSL2, workcells) accept remote mounts via `usbipd attach --wsl` (local) or the-adom-desktop **reverse tunnel** (cross-machine).+When you click **Sniff** on a mounted device, the server calls+`plugins::match_for(class, vid, pid, installed)`; if a plugin matches, the action+dispatches to its `sniff` entrypoint and the response/action-log record the+plugin slug. If none matches, it falls back to the built-in `usbmon` capture.+That single `match_for` seam is the entire contract between the app and the+ecosystem — add a plugin, and the mesh grows. -`reset` / `test` / `force-class` / `sniff` operate on the virtual device in a-**Linux target** — greyed out until the device is mounted on one.+--- -## Architecture+## Roadmap -- **Agents** (per host) expose `list / export / attach / detach / reset / class / sniff`,- reached over the transport each host already has (adom-desktop relay for the- laptop + its WSL2; `azure-winvm` for the Azure VM + its WSL2).-- **Transport**: same-machine = `usbipd attach --wsl`; cross-machine = the- adom-desktop reverse tunnel; workcell→cloud = existing direct TCP.-- **Registry**: a generalized Curium broker (source_type/source_id) gives one- unified inventory with live hot-plug.+- **M1 — Unified inventory** ✅ (this release): one cross-ecosystem table,+ Local/Cloud filters, the plugin discovery layer.+- **M2 — Mount/route matrix**: local `usbipd attach --wsl` → reverse-tunnel to+ cloud → cross-machine to Azure WSL2. Gated on the WSL2 custom kernel+ (`CONFIG_USBIP_VHCI_HCD=m`) baked into the Hydrogen Desktop golden image.+- **M3 — Reset + test/enumerate**.+- **M4 — Force-class-as** (HID / mass-storage / serial via `driver_override`).+- **M5 — Sniff/debug** built-in + the plugin ecosystem documented here. -See the project plan for the M1→M5 build sequence.+The live agents (the `adom-desktop usb` verb wrapping `usbipd-win`, and the+reverse tunnel) are specced in the adom-desktop repo at `skills/USB_MESH.md`. -## Status+## License -M1: unified inventory + mount overlay; each action emits the precise agent-command and wires to the live agents (usbipd-win over the relay, `usbip attach`-in the target, usbmon) as those land.+[MIT](LICENSE) © Adom Industries, Inc. All source is open; contributions welcome.
VERSION+1−1@@ -1 +1 @@-0.1.0+0.2.0
examples/adom-usb-sniffer-cdc/README.mdadded+13@@ -0,0 +1,13 @@+# adom-usb-sniffer-cdc++Reference **adom-usb sniffer plugin** — captures and decodes USB CDC/ACM serial+traffic to a pcap. Copy this directory as the starting point for your own plugin.++```bash+adompkg install adom-usb-sniffer-cdc # or: ./install.sh from a clone+```++Once installed, `adom-usb` discovers it automatically (it scans+`~/.adom/usb-plugins/*/plugin.json`) and routes the **Sniff** action for any+CDC-serial device to this plugin. See the adom-usb wiki page → "Writing a+packet-sniffer plugin" for the full plugin API.
examples/adom-usb-sniffer-cdc/adom-usb-sniffer-cdcadded+54@@ -0,0 +1,54 @@+#!/usr/bin/env bash+# adom-usb-sniffer-cdc — reference adom-usb sniffer plugin (plugin API v1).+#+# A plugin is just an executable that implements two subcommands:+# manifest -> print plugin.json to stdout+# sniff --vid <hex> --pid <hex> --busid <b> --target <host> [--seconds N] [--out FILE]+# -> capture traffic for that device on that (Linux) target,+# write a pcap, and print a JSON result to stdout.+#+# adom-usb finds this plugin via its plugin.json (matches CDC-serial devices),+# and calls `sniff` when the user hits the Sniff button on a matching device+# that is mounted on a Linux target. Everything below is standard usbmon ++# tshark; swap in your own decode for a custom protocol.+set -euo pipefail+HERE="$(cd "$(dirname "$0")" && pwd)"++cmd="${1:-}"; shift || true+case "$cmd" in+ manifest)+ cat "$HERE/plugin.json"+ ;;+ sniff)+ VID="" PID="" BUSID="" TARGET="" SECONDS_N=10 OUT=""+ while [ $# -gt 0 ]; do+ case "$1" in+ --vid) VID="$2"; shift 2;;+ --pid) PID="$2"; shift 2;;+ --busid) BUSID="$2"; shift 2;;+ --target) TARGET="$2"; shift 2;;+ --seconds) SECONDS_N="$2"; shift 2;;+ --out) OUT="$2"; shift 2;;+ *) shift;;+ esac+ done+ OUT="${OUT:-/tmp/adom-usb-cdc-${VID}-${PID}.pcap}"+ # usbmon must be loaded on the target; tshark captures the device's bus.+ # (adom-usb runs this on the Linux host where the device is mounted.)+ BUS_NUM="${BUSID%%-*}"+ sudo modprobe usbmon 2>/dev/null || true+ if command -v tshark >/dev/null; then+ timeout "$SECONDS_N" tshark -i "usbmon${BUS_NUM}" -w "$OUT" \+ -f "usb.idVendor==0x${VID} && usb.idProduct==0x${PID}" 2>/dev/null || true+ PKTS=$(tshark -r "$OUT" 2>/dev/null | wc -l | tr -d ' ')+ else+ PKTS=0+ fi+ printf '{"ok":true,"pcap":"%s","packets":%s,"decoders":["cdc-acm"],"summary":"captured %s CDC packets for %s:%s on %s"}\n' \+ "$OUT" "${PKTS:-0}" "${PKTS:-0}" "$VID" "$PID" "$TARGET"+ ;;+ *)+ echo 'usage: adom-usb-sniffer-cdc manifest | sniff --vid <hex> --pid <hex> --busid <b> --target <host> [--seconds N] [--out FILE]' >&2+ exit 2+ ;;+esac
examples/adom-usb-sniffer-cdc/install.shadded+10@@ -0,0 +1,10 @@+#!/bin/bash+# Installs the plugin so adom-usb discovers it: drops plugin.json + entrypoint+# into ~/.adom/usb-plugins/<slug>/ and puts the entrypoint on PATH.+set -euo pipefail+DIR="$(cd "$(dirname "$0")" && pwd)"; SLUG="adom-usb-sniffer-cdc"+PDIR="${HOME}/.adom/usb-plugins/${SLUG}"; mkdir -p "$PDIR" "${HOME}/.local/bin"+install -m 0644 "$DIR/plugin.json" "$PDIR/plugin.json"+install -m 0755 "$DIR/$SLUG" "$PDIR/$SLUG"+ln -sf "$PDIR/$SLUG" "${HOME}/.local/bin/$SLUG"+echo "Installed plugin -> $PDIR (adom-usb will discover it on next refresh)"
examples/adom-usb-sniffer-cdc/package.jsonadded+20@@ -0,0 +1,20 @@+{+ "slug": "adom-usb-sniffer-cdc",+ "version": "0.1.0",+ "type": "app",+ "description": "adom-usb plugin: capture + decode USB CDC/ACM serial traffic to pcap.",+ "dependencies": {},+ "keywords": ["adom-usb-plugin", "sniffer", "usb", "cdc", "serial", "pcap"],+ "license": "MIT",+ "needs_sudo": false,+ "scripts": { "install": "./install.sh", "uninstall": "./uninstall.sh" },+ "files": ["adom-usb-sniffer-cdc", "plugin.json", "install.sh", "uninstall.sh", "package.json", "README.md"],+ "adom_usb_plugin": {+ "kind": "sniffer",+ "entrypoint": "adom-usb-sniffer-cdc",+ "matches": { "class": ["CDC serial"], "vid": ["*"], "pid": ["*"] },+ "capture": "usbmon",+ "decoders": ["cdc-acm", "at-commands"],+ "api_version": "1"+ }+}
examples/adom-usb-sniffer-cdc/plugin.jsonadded+13@@ -0,0 +1,13 @@+{+ "slug": "adom-usb-sniffer-cdc",+ "name": "CDC / ACM serial sniffer",+ "version": "0.1.0",+ "api_version": "1",+ "kind": "sniffer",+ "entrypoint": "adom-usb-sniffer-cdc",+ "matches": { "class": ["CDC serial"], "vid": ["*"], "pid": ["*"] },+ "capture": "usbmon",+ "decoders": ["cdc-acm", "at-commands"],+ "description": "Capture and decode USB CDC/ACM serial traffic (virtual COM ports, modems, many dev boards) to a pcap + a human-readable line decode.",+ "homepage": "https://wiki.adom.inc/<your-user>/adom-usb-sniffer-cdc"+}
examples/adom-usb-sniffer-cdc/uninstall.shadded+5@@ -0,0 +1,5 @@+#!/bin/bash+set -euo pipefail+SLUG="adom-usb-sniffer-cdc"+rm -rf "${HOME}/.adom/usb-plugins/${SLUG}" "${HOME}/.local/bin/${SLUG}"+echo "Removed plugin ${SLUG}"
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-usb",- "version": "0.1.0",+ "version": "0.2.0", "type": "app", "description": "See every USB device across the entire Adom ecosystem — laptop, WSL2 / Hydrogen Desktop, cloud container, Azure VM, workcells — in one dashboard, and mount / unmount / reset / test / force-class / sniff any of them. USB/IP slingshot for the whole mesh.", "dependencies": {},
page.json+9−28@@ -4,32 +4,16 @@ "title": "Adom USB", "brief": "See every USB device across the entire Adom ecosystem — laptop, WSL2, cloud container, Azure VM, workcells — in one dashboard, and mount / unmount / reset / test / force-class / sniff any of them. USB/IP slingshot for the whole mesh.", "visibility": "public",- "hero": {- "type": "image",- "path": "screenshots/hero.png"- },- "install": {- "binary_name": "adom-usb"- },+ "hero": { "type": "image", "path": "screenshots/hero.png" },+ "install": { "binary_name": "adom-usb" }, "metadata": { "repo": "adom-inc/adom-usb",- "version": "0.1.0",+ "version": "0.2.0", "discovery_triggers": [- "usb devices",- "usb dashboard",- "list usb",- "mount usb",- "attach usb",- "usbip",- "slingshot usb",- "mount my dev board",- "rp2040 to wsl2",- "mount device to container",- "usb mesh",- "where is my usb device",- "force hid",- "sniff usb",- "usb passthrough"+ "usb devices", "usb dashboard", "list usb", "mount usb", "attach usb",+ "usbip", "slingshot usb", "mount my dev board", "rp2040 to wsl2",+ "mount device to container", "usb mesh", "where is my usb device",+ "force hid", "sniff usb", "usb passthrough" ], "discovery_pitch": "One dashboard for every USB device across your laptop, WSL2 / Hydrogen Desktop, cloud container, Azure VM, and workcells — mount any device onto any host, then reset / test / force-class / sniff it.", "releases": {@@ -39,8 +23,5 @@ "install_hint": "adompkg install adom-usb" } }- },- "hero_path": "screenshots/hero.png",- "hero_type": "image",- "updated_at": "2026-06-20T02:32:29.598Z"-}+ }+}
skills/adom-usb/SKILL.md+17@@ -52,6 +52,23 @@ laptop WSL2/HD, Azure WSL2, workcells) accept remote mounts via `usbipd attach reset / test / force-class / sniff operate on the virtual device in a **Linux target** — they are greyed out until the device is mounted on one. +## Plugins (sniffers / decoders)++adom-usb is extensible: sniffer/decoder plugins are ordinary `adompkg` packages+tagged `adom-usb-plugin` that drop a `plugin.json` + entrypoint into+`~/.adom/usb-plugins/`. The app discovers them automatically and routes the+**Sniff** action for a matching device class to the best-matching plugin.++```bash+adom-usb plugins list # installed plugins on this host+adom-usb plugins search # publishable plugins on the wiki+curl -s localhost:8896/plugins | jq+```++To author one: copy `examples/adom-usb-sniffer-cdc/`, edit `plugin.json`+(`matches` by class/vid/pid) + the `manifest`/`sniff` entrypoint, then publish+with `adompkg`. Full guide: the wiki page → "Writing a packet-sniffer plugin".+ ## Status M1 ships the unified inventory + the mount overlay; each action emits the exact
src/app/mod.rs+42−3@@ -171,8 +171,29 @@ pub fn run(args: AppArgs) -> Result<(), String> { snap["dev"] = json!(st.dev); snap["state_ts"] = json!(*st.state_ts.lock().unwrap() as u64); snap["actions"] = json!(st.actions.lock().unwrap().iter().rev().take(20).cloned().collect::<Vec<_>>());+ // Installed plugins + which sniffer matches each device.+ let installed = crate::plugins::discover_installed();+ snap["plugins"] = crate::plugins::to_json(&installed);+ if let Some(devs) = snap.get_mut("devices").and_then(|d| d.as_array_mut()) {+ for d in devs.iter_mut() {+ let class = d.get("class").and_then(|v| v.as_str()).unwrap_or("");+ let vid = d.get("vid").and_then(|v| v.as_u64()).unwrap_or(0) as u16;+ let pid = d.get("pid").and_then(|v| v.as_u64()).unwrap_or(0) as u16;+ d["sniffer_plugin"] = json!(crate::plugins::match_for(class, vid, pid, &installed).map(|p| p.slug.clone()));+ }+ } (200, snap) }+ (Method::Get, "/plugins") => {+ let installed = crate::plugins::discover_installed();+ let available = crate::plugins::discover_registry(&installed);+ (200, json!({+ "installed": crate::plugins::to_json(&installed),+ "available": crate::plugins::to_json(&available),+ "tag": crate::plugins::PLUGIN_TAG,+ "api_version": crate::plugins::PLUGIN_API_VERSION,+ }))+ } (Method::Post, "/refresh") => { *st.state_ts.lock().unwrap() = now_ms(); (200, snapshot(&st))@@ -323,11 +344,29 @@ fn handle_device_op(st: &AppState, req: &mut tiny_http::Request, op: &str) -> (u return (409, json!({"error": reason, "blocked": true})); } let target = dev.get("mounted_on").and_then(|v| v.as_str()).unwrap_or("");- let cmd = agent_command(op, &dev, target, "");+ // For sniff, prefer a matching installed plugin's entrypoint; otherwise the+ // built-in usbmon capture. This is the extension seam the ecosystem hooks.+ let (cmd, plugin) = if op == "sniff" {+ let class = dev.get("class").and_then(|v| v.as_str()).unwrap_or("");+ let vid = dev.get("vid").and_then(|v| v.as_u64()).unwrap_or(0) as u16;+ let pid = dev.get("pid").and_then(|v| v.as_u64()).unwrap_or(0) as u16;+ let bus = dev.get("bus_id").and_then(|v| v.as_str()).unwrap_or("?");+ let installed = crate::plugins::discover_installed();+ match crate::plugins::match_for(class, vid, pid, &installed) {+ Some(p) => (+ format!("[{target}] {} sniff --vid {:04x} --pid {:04x} --busid {} --target {} --out <pcap>",+ p.entrypoint, vid, pid, bus, target),+ Some(p.slug.clone()),+ ),+ None => (agent_command(op, &dev, target, ""), None),+ }+ } else {+ (agent_command(op, &dev, target, ""), None)+ }; log_action(st, json!({ "ts": now_ms() as u64, "op": op, "device_id": device_id,- "device": dev.get("name"), "target": target, "command": cmd,+ "device": dev.get("name"), "target": target, "command": cmd, "plugin": plugin, "status": "queued" // executes once the live agent for `target` is wired }));- (200, json!({"ok": true, "op": op, "device_id": device_id, "command": cmd, "status": "queued"}))+ (200, json!({"ok": true, "op": op, "device_id": device_id, "command": cmd, "plugin": plugin, "status": "queued"})) }
src/cli.rs+28@@ -33,6 +33,34 @@ pub fn run_mount(a: MountArgs) -> Result<(), String> { Ok(()) } +pub fn run_plugins(sub: crate::PluginsCmd) -> Result<(), String> {+ match sub {+ crate::PluginsCmd::List => {+ let ps = crate::plugins::discover_installed();+ if ps.is_empty() {+ note!("no plugins installed. Search the wiki: adom-usb plugins search");+ } else {+ for p in &ps {+ note!("{} v{} [{}] matches {}", p.slug, p.version, p.kind,+ serde_json::to_string(&p.matches).unwrap_or_default());+ }+ }+ ok(format!("{} plugin(s) installed", ps.len()));+ }+ crate::PluginsCmd::Search => {+ let installed = crate::plugins::discover_installed();+ let avail = crate::plugins::discover_registry(&installed);+ if avail.is_empty() {+ note!("no installable plugins found on the wiki (tag: {})", crate::plugins::PLUGIN_TAG);+ } else {+ for p in &avail { note!("{} {} — adompkg install {}", p.slug, p.description, p.slug); }+ }+ ok(format!("{} plugin(s) available to install", avail.len()));+ }+ }+ Ok(())+}+ pub fn run_health(port: u16) -> Result<(), String> { let url = format!("http://127.0.0.1:{}/health", port); match ureq::get(&url).call() {
src/main.rs+15@@ -11,6 +11,7 @@ mod app; mod cli; mod devices; mod install;+mod plugins; use clap::{CommandFactory, Parser, Subcommand}; use std::io::IsTerminal;@@ -88,6 +89,11 @@ enum Cmd { #[arg(long, default_value_t = 8896)] port: u16, },+ /// List installed sniffer/decoder plugins, or search the wiki for more.+ Plugins {+ #[command(subcommand)]+ sub: PluginsCmd,+ }, /// Install SKILL.md + bash completions. Install, /// Print shell completion script.@@ -105,6 +111,14 @@ pub struct AppArgs { #[arg(long, env = "ADOM_USB_DEV")] pub dev: bool, } +#[derive(Subcommand)]+pub enum PluginsCmd {+ /// List plugins installed on this host.+ List,+ /// Search the Adom Wiki for publishable adom-usb plugins.+ Search,+}+ #[derive(clap::Args)] pub struct MountArgs { pub device_id: String,@@ -126,6 +140,7 @@ fn main() -> ExitCode { Cmd::Mount(a) => cli::run_mount(a), Cmd::Unmount(a) => cli::run_unmount(a), Cmd::Health { port } => cli::run_health(port),+ Cmd::Plugins { sub } => cli::run_plugins(sub), Cmd::Install => install::install(), Cmd::Completions { shell } => { let mut cmd = Cli::command();
src/plugins.rsadded+182@@ -0,0 +1,182 @@+//! Plugin discovery + matching for adom-usb.+//!+//! A plugin extends the mesh with new capture/decode backends — most commonly a+//! **packet sniffer** for a particular device class (CDC serial, HID, mass+//! storage, a vendor protocol). Plugins are ordinary Adom Wiki packages+//! (`adompkg`), tagged so the app can find them, that drop a `plugin.json`+//! manifest + an executable entrypoint on the host.+//!+//! Discovery has two sources:+//! 1. **Installed** — local filesystem scan of `~/.adom/usb-plugins/<slug>/plugin.json`+//! and any `adompkg`-installed module whose package.json carries an+//! `adom_usb_plugin` block. Cheap; runs on every /state.+//! 2. **Registry** — a best-effort query to the Adom Wiki discovery API for+//! packages tagged `adom-usb-plugin` that are NOT yet installed. Network;+//! only on demand (`/plugins/registry`, `adom-usb plugins search`).+//!+//! The plugin CLI contract (what an entrypoint must implement):+//! <entrypoint> manifest+//! -> prints its plugin.json to stdout+//! <entrypoint> sniff --vid <hex> --pid <hex> --busid <b> --target <host> \+//! [--seconds <n>] [--out <file.pcap>]+//! -> captures traffic for that device on that (Linux) target and prints+//! {"ok":true,"pcap":"<path>","packets":<n>,"summary":"..."}++use serde::Serialize;+use serde_json::{json, Value};+use std::path::PathBuf;++pub const PLUGIN_TAG: &str = "adom-usb-plugin";+pub const PLUGIN_API_VERSION: &str = "1";++#[derive(Serialize, Clone)]+pub struct Plugin {+ pub slug: String,+ pub name: String,+ pub version: String,+ pub api_version: String,+ pub kind: String,+ pub entrypoint: String,+ /// {"class": ["CDC serial","*"], "vid": ["*"], "pid": ["*"]}+ pub matches: Value,+ pub capture: String,+ pub decoders: Vec<String>,+ pub description: String,+ /// "installed" | "registry"+ pub source: String,+ pub installed: bool,+}++fn plugins_dir() -> PathBuf {+ let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());+ PathBuf::from(home).join(".adom/usb-plugins")+}++fn from_manifest(v: &Value, source: &str, installed: bool) -> Option<Plugin> {+ let slug = v.get("slug").and_then(|x| x.as_str())?.to_string();+ Some(Plugin {+ slug: slug.clone(),+ name: v.get("name").and_then(|x| x.as_str()).unwrap_or(&slug).to_string(),+ version: v.get("version").and_then(|x| x.as_str()).unwrap_or("0.0.0").to_string(),+ api_version: v.get("api_version").and_then(|x| x.as_str()).unwrap_or("1").to_string(),+ kind: v.get("kind").and_then(|x| x.as_str()).unwrap_or("sniffer").to_string(),+ entrypoint: v.get("entrypoint").and_then(|x| x.as_str()).unwrap_or(&slug).to_string(),+ matches: v.get("matches").cloned().unwrap_or(json!({"class":["*"],"vid":["*"],"pid":["*"]})),+ capture: v.get("capture").and_then(|x| x.as_str()).unwrap_or("usbmon").to_string(),+ decoders: v.get("decoders").and_then(|x| x.as_array())+ .map(|a| a.iter().filter_map(|d| d.as_str().map(String::from)).collect())+ .unwrap_or_default(),+ description: v.get("description").and_then(|x| x.as_str()).unwrap_or("").to_string(),+ source: source.to_string(),+ installed,+ })+}++/// Scan the local filesystem for installed plugins.+pub fn discover_installed() -> Vec<Plugin> {+ let mut out = Vec::new();++ // 1. The app's own plugin dir: ~/.adom/usb-plugins/<slug>/plugin.json+ if let Ok(rd) = std::fs::read_dir(plugins_dir()) {+ for e in rd.flatten() {+ if let Ok(s) = std::fs::read_to_string(e.path().join("plugin.json")) {+ if let Ok(v) = serde_json::from_str::<Value>(&s) {+ if let Some(p) = from_manifest(&v, "installed", true) { out.push(p); }+ }+ }+ }+ }++ // 2. adompkg-installed modules whose package.json carries an adom_usb_plugin block.+ let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());+ if let Ok(rd) = std::fs::read_dir(PathBuf::from(&home).join("project/adom_modules")) {+ for e in rd.flatten() {+ if let Ok(s) = std::fs::read_to_string(e.path().join("package.json")) {+ if let Ok(v) = serde_json::from_str::<Value>(&s) {+ if let Some(block) = v.get("adom_usb_plugin") {+ let mut m = block.clone();+ for k in ["slug", "version", "description"] {+ if m.get(k).is_none() {+ if let Some(val) = v.get(k) { m[k] = val.clone(); }+ }+ }+ if let Some(p) = from_manifest(&m, "installed", true) { out.push(p); }+ }+ }+ }+ }+ }+ out.sort_by(|a, b| a.slug.cmp(&b.slug));+ out.dedup_by(|a, b| a.slug == b.slug);+ out+}++/// Best-effort registry query for publishable (not-yet-installed) plugins.+pub fn discover_registry(installed: &[Plugin]) -> Vec<Plugin> {+ let url = format!("https://wiki.adom.inc/api/v1/discover?triggers={}", PLUGIN_TAG);+ let resp = match ureq::get(&url)+ .timeout(std::time::Duration::from_millis(2500))+ .set("User-Agent", "adom-usb/plugin-discovery")+ .call()+ {+ Ok(r) => r,+ Err(_) => return Vec::new(),+ };+ let v: Value = match resp.into_json() { Ok(v) => v, Err(_) => return Vec::new() };+ let arr = if v.is_array() { v.as_array().cloned().unwrap_or_default() }+ else { v.get("results").or_else(|| v.get("packages")).and_then(|x| x.as_array()).cloned().unwrap_or_default() };+ let have: std::collections::HashSet<&str> = installed.iter().map(|p| p.slug.as_str()).collect();+ arr.iter().filter_map(|item| {+ let slug = item.get("slug").and_then(|s| s.as_str())?;+ if have.contains(slug) || slug == "adom-usb" { return None; }+ Some(Plugin {+ slug: slug.to_string(),+ name: item.get("title").and_then(|s| s.as_str()).unwrap_or(slug).to_string(),+ version: item.get("version").and_then(|s| s.as_str()).unwrap_or("").to_string(),+ api_version: PLUGIN_API_VERSION.to_string(),+ kind: "sniffer".to_string(),+ entrypoint: slug.to_string(),+ matches: json!({"class":["*"]}),+ capture: "".to_string(),+ decoders: vec![],+ description: item.get("brief").or_else(|| item.get("description")).and_then(|s| s.as_str()).unwrap_or("").to_string(),+ source: "registry".to_string(),+ installed: false,+ })+ }).collect()+}++fn glob_match(pat: &str, val: &str) -> bool {+ pat == "*" || pat.eq_ignore_ascii_case(val) || val.to_lowercase().contains(&pat.to_lowercase())+}++/// Pick the best installed plugin for a device by class/vid/pid. A more+/// specific match (exact class, non-"*" vid) wins over a wildcard.+pub fn match_for<'a>(class: &str, vid: u16, pid: u16, plugins: &'a [Plugin]) -> Option<&'a Plugin> {+ let vid_s = format!("{:04x}", vid);+ let pid_s = format!("{:04x}", pid);+ let mut best: Option<(&Plugin, i32)> = None;+ for p in plugins.iter().filter(|p| p.kind == "sniffer") {+ let m = &p.matches;+ let list = |k: &str| m.get(k).and_then(|x| x.as_array()).map(|a|+ a.iter().filter_map(|v| v.as_str().map(String::from)).collect::<Vec<_>>())+ .unwrap_or_else(|| vec!["*".into()]);+ let classes = list("class");+ let vids = list("vid");+ let pids = list("pid");+ let class_ok = classes.iter().any(|p| glob_match(p, class));+ let vid_ok = vids.iter().any(|p| glob_match(p, &vid_s));+ let pid_ok = pids.iter().any(|p| glob_match(p, &pid_s));+ if class_ok && vid_ok && pid_ok {+ let score = classes.iter().any(|p| p != "*") as i32 * 2+ + vids.iter().any(|p| p != "*") as i32+ + pids.iter().any(|p| p != "*") as i32;+ if best.map(|(_, s)| score > s).unwrap_or(true) { best = Some((p, score)); }+ }+ }+ best.map(|(p, _)| p)+}++pub fn to_json(plugins: &[Plugin]) -> Value {+ serde_json::to_value(plugins).unwrap_or(json!([]))+}
wiki/page.json+1−1@@ -5,7 +5,7 @@ "brief": "See every USB device across the entire Adom ecosystem \u2014 laptop, WSL2, cloud container, Azure VM, workcells \u2014 in one dashboard, and mount / unmount / reset / test / force-class / sniff any of them. USB/IP slingshot for the whole mesh.", "metadata": { "repo": "adom-inc/adom-usb",- "version": "0.1.0",+ "version": "0.2.0", "releases": { "adom_docker": { "asset_name": "adom-usb",