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.3.0 firmware packs + docs
18 files changed
+447−46
Cargo.lock+1−1Cargo.toml+1−1README.md+75VERSION+1−1examples/adom-usb-stub-rp2040/README.md+21examples/adom-usb-stub-rp2040/adom-usb-stub-rp2040+28examples/adom-usb-stub-rp2040/install.sh+9examples/adom-usb-stub-rp2040/package.json+90examples/adom-usb-stub-rp2040/plugin.json+60examples/adom-usb-stub-rp2040/uninstall.sh+4package.json+1−1page.json+24−35src/app/mod.rs+61−1src/app/ui.html+14−1src/cli.rs+9src/main.rs+12src/plugins.rs+35−4wiki/page.json+1−1Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-usb"-version = "0.2.0"+version = "0.3.0" dependencies = [ "base64", "clap",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-usb"-version = "0.2.0"+version = "0.3.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"
README.md+75@@ -293,6 +293,7 @@ webview can embed it). | 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 |+| POST | `/flash` | `{device_id, persona}` | flash a stub-firmware persona via a matching **firmware device-test pack** (e.g. RP2040 → HID keyboard) | | GET | `/plugins` | — | `{installed, available, tag, api_version}` | | POST | `/console` | log entry | UI console forwarding | | GET | `/console` | — | ring buffer of UI logs (AI debugging) |@@ -516,6 +517,80 @@ devices to it. --- +## Firmware device-test packs (manufacturer device personas)++A second plugin kind — **`kind: "firmware"`** — lets a manufacturer or third+party ship a *device-test pack*: stub firmware + a flash method + a catalog of+**USB personas** their chip can become. You plug the chip into the mesh, pick a+persona, the pack flashes the stub, and the device **re-enumerates as that USB+class** so you can immediately mount / test / sniff it.++This is how you answer "does my USB host stack handle a keyboard? a disk? a+serial port?" without writing firmware — flash `hid-keyboard`, watch it type;+flash `msc-disk`, mount it; flash `cdc-serial`, open the port.++A firmware pack's manifest adds `vendor`, `chips`, `flash_method`, and a+`personas` catalog:++```jsonc+{+ "slug": "adom-usb-stub-rp2040", "kind": "firmware", "api_version": "1",+ "entrypoint": "adom-usb-stub-rp2040",+ "vendor": "Raspberry Pi", "chips": ["rp2040"],+ "matches": { "vid": ["2e8a"], "pid": ["*"] }, // matched by chip vid/pid+ "flash_method": "uf2-bootsel", // picotool / dfu-util / esptool / nrfutil / openocd / bossac / wchisp+ "personas": [+ {"id":"msc-disk", "name":"Mass-storage disk", "usb_class":"Mass storage", "result_vid":"2e8a","result_pid":"0009"},+ {"id":"hid-keyboard", "name":"HID keyboard", "usb_class":"HID keyboard"},+ {"id":"hid-mouse", "name":"HID mouse", "usb_class":"HID mouse"},+ {"id":"cdc-serial", "name":"CDC serial echo", "usb_class":"CDC serial"},+ {"id":"midi", "name":"USB-MIDI", "usb_class":"Audio/MIDI"}+ ]+}+```++The firmware-plugin executable contract adds a `flash` subcommand (and+`personas`):++```bash+<entrypoint> manifest # prints plugin.json+<entrypoint> personas # prints the persona catalog+<entrypoint> flash --persona <id> --vid <hex> --pid <hex> --busid <b> --target <host>+# -> flashes the stub (picotool / dfu-util / esptool / …) and prints+# {"ok":true,"persona":"hid-keyboard","method":"uf2-bootsel","status":"flashed","summary":"…"}+```++When a firmware pack matches a device, `adom-usb` shows a **Flash ▾** persona+picker on that row; the `/state` device carries `firmware_plugin` ++`firmware_personas`, and `POST /flash {device_id, persona}` dispatches to the+pack's `flash` entrypoint.++### Sample plugins (install any of these)++A reference fleet is published on the wiki — copy any as a starting point:++| Plugin | Kind | Chip / target | Personas / decoders |+|---|---|---|---|+| `adom-usb-stub-rp2040` | firmware | RP2040 (Raspberry Pi) | disk · keyboard · mouse · serial · MIDI |+| `adom-usb-stub-stm32` | firmware | STM32 (DFU 0483:df11) | disk · kbd · mouse · serial · RNDIS · audio · MIDI · DFU |+| `adom-usb-stub-esp32` | firmware | ESP32-S2/S3 (TinyUSB) | serial · kbd · mouse · disk · MIDI |+| `adom-usb-stub-nrf` | firmware | nRF52840 / nRF5340 | serial · kbd · mouse · disk |+| `adom-usb-stub-ti-sitara` | firmware | TI Sitara AM335x/57x/62x | disk · RNDIS · serial · kbd |+| `adom-usb-stub-samd21` | firmware | SAMD21/51 (Microchip) | disk · kbd · serial · MIDI |+| `adom-usb-stub-ch32` | firmware | CH32V (WCH RISC-V) | serial · kbd · disk |+| `adom-usb-sniffer-cdc` | sniffer | CDC / ACM serial | cdc-acm · at-commands |+| `adom-usb-sniffer-hid` | sniffer | HID keyboard/mouse | hid-report · keyboard · mouse |+| `adom-usb-sniffer-msc` | sniffer | Mass storage / SCSI | scsi-bot · uas |++```bash+adompkg install adom-usb-stub-rp2040+adom-usb flash laptop:2e8a:0003 hid-keyboard # RP2040 → HID keyboard, then it re-enumerates+```++> The firmware packs ship the flash **contract** as reference templates;+> manufacturers drop their real signed firmware artifacts in+> `firmware/<persona>.<ext>` and publish their own version.+ ## How the app discovers plugins Discovery has two sources (both in [`src/plugins.rs`](src/plugins.rs)):
VERSION+1−1@@ -1 +1 @@-0.2.0+0.3.0
examples/adom-usb-stub-rp2040/README.mdadded+21@@ -0,0 +1,21 @@+# adom-usb-stub-rp2040++**adom-usb firmware device-test pack — Raspberry Pi.** Flash stub firmware to an RP2040 (drag-drop UF2 in BOOTSEL) to enumerate it as a disk, keyboard, mouse, serial, or MIDI device — test USB host support end-to-end.++```bash+adompkg install adom-usb-stub-rp2040+```++## Personas++| id | flashes | enumerates as |+|---|---|---|+| `msc-disk` | Mass-storage disk (RAM) | Mass storage |+| `hid-keyboard` | HID keyboard (types a test string) | HID keyboard |+| `hid-mouse` | HID mouse (jiggler) | HID mouse |+| `cdc-serial` | CDC serial echo | CDC serial |+| `midi` | USB-MIDI | Audio/MIDI |++Chips: rp2040 · flash method: `uf2-bootsel`.++Once installed, `adom-usb` shows a **Flash ▾** persona picker on matching devices. Pick a persona, the pack flashes the stub, and the device re-enumerates as that USB class so you can mount / test / sniff it. This package ships the flash **contract** (a reference template) — drop your real signed firmware artifacts in `firmware/<persona>.<ext>` and publish your own version. See the adom-usb wiki page → *Writing a packet-sniffer plugin* (firmware packs share the same lifecycle).
examples/adom-usb-stub-rp2040/adom-usb-stub-rp2040added+28@@ -0,0 +1,28 @@+#!/usr/bin/env bash+# RP2040 USB persona stubs — adom-usb FIRMWARE device-test pack (API v1). Implements: manifest, personas, flash.+# REFERENCE TEMPLATE: drop your real firmware artifacts in firmware/<persona>.<ext> and wire+# the uf2-bootsel call below. Ships the flash CONTRACT; manufacturers ship the binaries.+set -euo pipefail; HERE="$(cd "$(dirname "$0")" && pwd)"+cmd="${1:-}"; shift || true+case "$cmd" in+ manifest) cat "$HERE/plugin.json";;+ personas) if command -v jq >/dev/null; then jq -c '.personas' "$HERE/plugin.json"; else grep -o '"personas".*' "$HERE/plugin.json"; fi;;+ flash)+ PERSONA="" VID="" PID="" BUSID="" TARGET=""+ while [ $# -gt 0 ]; do case "$1" in --persona)PERSONA="$2";shift 2;; --vid)VID="$2";shift 2;; --pid)PID="$2";shift 2;; --busid)BUSID="$2";shift 2;; --target)TARGET="$2";shift 2;; *)shift;; esac; done+ ART="$HERE/firmware/${PERSONA}.bin"; METHOD="uf2-bootsel"+ # The real flash call (chip-specific). Degrades to a dry-run when the tool/artifact is absent.+ case "$METHOD" in+ uf2-bootsel) RUN="picotool load $HERE/firmware/${PERSONA}.uf2 -f && picotool reboot";;+ dfu-util) RUN="dfu-util -a 0 -s 0x08000000:leave -D $ART";;+ esptool) RUN="esptool.py --chip auto write_flash 0x0 $ART";;+ nrfutil) RUN="nrfutil device program --firmware $ART";;+ openocd) RUN="openocd -f board/ti_sitara.cfg -c 'program $ART verify reset exit'";;+ bossac) RUN="bossac -e -w -v -R $ART";;+ wchisp) RUN="wchisp flash $ART";;+ *) RUN="<flash $ART via $METHOD>";;+ esac+ if [ -f "$ART" ] || [ -f "$HERE/firmware/${PERSONA}.uf2" ]; then eval "$RUN" >/dev/null 2>&1 && STATUS="flashed" || STATUS="flash-failed"; else STATUS="dry-run (no bundled artifact — template)"; fi+ printf '{"ok":true,"persona":"%s","method":"%s","status":"%s","command":"%s","summary":"%s: device will re-enumerate as the persona USB class — refresh adom-usb"}\n' "$PERSONA" "$METHOD" "$STATUS" "$RUN" "$PERSONA";;+ *) echo "usage: adom-usb-stub-rp2040 manifest | personas | flash --persona <id> --vid <hex> --pid <hex> --busid <b> --target <host>" >&2; exit 2;;+esac
examples/adom-usb-stub-rp2040/install.shadded+9@@ -0,0 +1,9 @@+#!/bin/bash+set -euo pipefail+DIR="$(cd "$(dirname "$0")" && pwd)"; SLUG="adom-usb-stub-rp2040"+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"+[ -d "$DIR/firmware" ] && cp -r "$DIR/firmware" "$PDIR/" || true+ln -sf "$PDIR/$SLUG" "${HOME}/.local/bin/$SLUG"+echo "Installed plugin $SLUG -> $PDIR (adom-usb discovers it on next refresh)"
examples/adom-usb-stub-rp2040/package.jsonadded+90@@ -0,0 +1,90 @@+{+ "slug": "adom-usb-stub-rp2040",+ "version": "0.1.0",+ "type": "app",+ "description": "Flash stub firmware to an RP2040 (drag-drop UF2 in BOOTSEL) to enumerate it as a disk, keyboard, mouse, serial, or MIDI device \u2014 test USB host support end-to-end.",+ "dependencies": {},+ "keywords": [+ "adom-usb-plugin",+ "firmware",+ "usb-stub",+ "raspberry"+ ],+ "license": "MIT",+ "scripts": {+ "install": "./install.sh",+ "uninstall": "./uninstall.sh"+ },+ "files": [+ "adom-usb-stub-rp2040",+ "plugin.json",+ "install.sh",+ "uninstall.sh",+ "package.json",+ "README.md",+ "page.json",+ "screenshots/**",+ "skills/**"+ ],+ "adom_usb_plugin": {+ "kind": "firmware",+ "entrypoint": "adom-usb-stub-rp2040",+ "vendor": "Raspberry Pi",+ "chips": [+ "rp2040"+ ],+ "matches": {+ "vid": [+ "2e8a"+ ],+ "pid": [+ "*"+ ]+ },+ "flash_method": "uf2-bootsel",+ "personas": [+ {+ "id": "msc-disk",+ "name": "Mass-storage disk (RAM)",+ "usb_class": "Mass storage",+ "result_vid": "2e8a",+ "result_pid": "0009"+ },+ {+ "id": "hid-keyboard",+ "name": "HID keyboard (types a test string)",+ "usb_class": "HID keyboard",+ "result_vid": "2e8a",+ "result_pid": "000a"+ },+ {+ "id": "hid-mouse",+ "name": "HID mouse (jiggler)",+ "usb_class": "HID mouse",+ "result_vid": "2e8a",+ "result_pid": "000b"+ },+ {+ "id": "cdc-serial",+ "name": "CDC serial echo",+ "usb_class": "CDC serial",+ "result_vid": "2e8a",+ "result_pid": "000c"+ },+ {+ "id": "midi",+ "name": "USB-MIDI",+ "usb_class": "Audio/MIDI",+ "result_vid": "2e8a",+ "result_pid": "000d"+ }+ ],+ "api_version": "1"+ },+ "tags": [+ "adom-usb-plugin",+ "firmware",+ "usb-stub",+ "raspberry"+ ]+}
examples/adom-usb-stub-rp2040/plugin.jsonadded+60@@ -0,0 +1,60 @@+{+ "slug": "adom-usb-stub-rp2040",+ "name": "RP2040 USB persona stubs",+ "version": "0.1.0",+ "api_version": "1",+ "kind": "firmware",+ "entrypoint": "adom-usb-stub-rp2040",+ "vendor": "Raspberry Pi",+ "chips": [+ "rp2040"+ ],+ "matches": {+ "vid": [+ "2e8a"+ ],+ "pid": [+ "*"+ ]+ },+ "flash_method": "uf2-bootsel",+ "personas": [+ {+ "id": "msc-disk",+ "name": "Mass-storage disk (RAM)",+ "usb_class": "Mass storage",+ "result_vid": "2e8a",+ "result_pid": "0009"+ },+ {+ "id": "hid-keyboard",+ "name": "HID keyboard (types a test string)",+ "usb_class": "HID keyboard",+ "result_vid": "2e8a",+ "result_pid": "000a"+ },+ {+ "id": "hid-mouse",+ "name": "HID mouse (jiggler)",+ "usb_class": "HID mouse",+ "result_vid": "2e8a",+ "result_pid": "000b"+ },+ {+ "id": "cdc-serial",+ "name": "CDC serial echo",+ "usb_class": "CDC serial",+ "result_vid": "2e8a",+ "result_pid": "000c"+ },+ {+ "id": "midi",+ "name": "USB-MIDI",+ "usb_class": "Audio/MIDI",+ "result_vid": "2e8a",+ "result_pid": "000d"+ }+ ],+ "description": "Flash stub firmware to an RP2040 (drag-drop UF2 in BOOTSEL) to enumerate it as a disk, keyboard, mouse, serial, or MIDI device \u2014 test USB host support end-to-end.",+ "homepage": "https://wiki.adom.inc/john/adom-usb-stub-rp2040"+}
examples/adom-usb-stub-rp2040/uninstall.shadded+4@@ -0,0 +1,4 @@+#!/bin/bash+set -euo pipefail+rm -rf "${HOME}/.adom/usb-plugins/adom-usb-stub-rp2040" "${HOME}/.local/bin/adom-usb-stub-rp2040"+echo "Removed adom-usb-stub-rp2040"
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-usb",- "version": "0.2.0",+ "version": "0.3.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+24−35@@ -1,38 +1,27 @@ {- "slug": "adom-usb",- "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": {},- "tags": [- "usb",- "usbip",- "device-mesh",- "hydrogen-desktop",- "wsl2",- "passthrough",- "dashboard",- "open-source",- "mit"- ],- "license": "MIT",- "needs_sudo": false,- "scripts": {- "install": "./install.sh",- "uninstall": "./uninstall.sh"- },- "files": [- "bin/**",- "skills/**",- "install.sh",- "uninstall.sh",- "package.json",- "page.json",- "README.md",- "screenshots/**"- ],- "author": {- "name": "John Lauer",- "email": "[email protected]"+ "slug": "adom-usb",+ "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" },+ "metadata": {+ "repo": "adom-inc/adom-usb",+ "version": "0.3.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"+ ],+ "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": {+ "adom_docker": {+ "asset_name": "adom-usb",+ "binary_url": "https://wiki.adom.inc/download/john/adom-usb/0.1.0/adom-usb",+ "install_hint": "adompkg install adom-usb"+ }+ } }-}+}
src/app/mod.rs+61−1@@ -176,10 +176,19 @@ pub fn run(args: AppArgs) -> Result<(), String> { 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 class = d.get("class").and_then(|v| v.as_str()).unwrap_or("").to_string(); 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;+ let class = class.as_str(); d["sniffer_plugin"] = json!(crate::plugins::match_for(class, vid, pid, &installed).map(|p| p.slug.clone()));+ // Firmware device-test pack: which stub personas can we flash to this chip?+ if let Some(fw) = crate::plugins::firmware_for(class, vid, pid, &installed) {+ d["firmware_plugin"] = json!(fw.slug);+ d["firmware_personas"] = fw.personas.clone();+ } else {+ d["firmware_plugin"] = Value::Null;+ d["firmware_personas"] = json!([]);+ } } } (200, snap)@@ -206,6 +215,10 @@ pub fn run(args: AppArgs) -> Result<(), String> { Ok(body) => handle_unmount(&st, &body), Err(e) => (400, json!({"error": e})), },+ (Method::Post, "/flash") => match read_body(&mut req) {+ Ok(body) => handle_flash(&st, &body),+ Err(e) => (400, json!({"error": e})),+ }, (Method::Post, "/reset") => handle_device_op(&st, &mut req, "reset"), (Method::Post, "/test") => handle_device_op(&st, &mut req, "test"), (Method::Post, "/class-override") => handle_device_op(&st, &mut req, "class_override"),@@ -322,6 +335,53 @@ fn handle_unmount(st: &AppState, body: &Value) -> (u16, Value) { (200, json!({"ok": true, "device_id": device_id, "command": cmd})) } +/// Flash a stub-firmware persona onto a device via a matching firmware plugin+/// (e.g. make an RP2040 enumerate as a HID keyboard). Dispatches to the plugin's+/// `flash` entrypoint and records the exact command.+fn handle_flash(st: &AppState, body: &Value) -> (u16, Value) {+ let device_id = match body.get("device_id").and_then(|v| v.as_str()) {+ Some(s) => s.to_string(),+ None => return (400, json!({"error": "device_id required"})),+ };+ let persona = match body.get("persona").and_then(|v| v.as_str()) {+ Some(s) => s.to_string(),+ None => return (400, json!({"error": "persona required (see firmware_personas in /state)"})),+ };+ let snap = snapshot(st);+ let dev = match find_device(&snap, &device_id) {+ Some(d) => d,+ None => return (404, json!({"error": format!("unknown device: {}", device_id)})),+ };+ 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();+ let fw = match crate::plugins::firmware_for(class, vid, pid, &installed) {+ Some(p) => p,+ None => return (409, json!({"error": "no firmware device-test pack installed for this chip", "blocked": true,+ "hint": "search the wiki: adom-usb plugins search"})),+ };+ // Validate the persona exists in the pack's catalog.+ let known = fw.personas.as_array().map(|a| a.iter()+ .any(|p| p.get("id").and_then(|x| x.as_str()) == Some(persona.as_str()))).unwrap_or(false);+ if !known {+ return (400, json!({"error": format!("unknown persona '{}' for {}", persona, fw.slug),+ "available": fw.personas.clone()}));+ }+ let cmd = format!("[{src}] {ep} flash --persona {persona} --vid {vid:04x} --pid {pid:04x} --busid {bus} --target {src}",+ src = dev.get("source_id").and_then(|v| v.as_str()).unwrap_or("?"),+ ep = fw.entrypoint, persona = persona, vid = vid, pid = pid, bus = bus);+ log_action(st, json!({+ "ts": now_ms() as u64, "op": "flash", "device_id": device_id,+ "device": dev.get("name"), "persona": persona, "plugin": fw.slug.clone(),+ "command": cmd, "status": "queued"+ }));+ (200, json!({"ok": true, "op": "flash", "device_id": device_id, "persona": persona,+ "plugin": fw.slug.clone(), "method": fw.flash_method.clone(), "command": cmd, "status": "queued",+ "note": "after flashing, the device re-enumerates as the persona's USB class — refresh to see it"}))+}+ fn handle_device_op(st: &AppState, req: &mut tiny_http::Request, op: &str) -> (u16, Value) { let body = match read_body(req) { Ok(b) => b,
src/app/ui.html+14−1@@ -271,6 +271,10 @@ async function doUnmount(id){const{ok,v}=await post('unmount',{device_id:id}); async function doOp(id,op){const path=op==='class_override'?'class-override':op; const{ok,v}=await post(path,{device_id:id}); if(ok){toast(op+': '+(v.command||'queued'));}else{toast(v.error||(op+' failed'),true);}render();}+async function doFlash(id){const sel=document.getElementById('fw-'+id);const persona=sel&&sel.value;+ if(!persona){toast('pick a persona',true);return;}+ const{ok,v}=await post('flash',{device_id:id,persona});+ if(ok){toast('flashing → '+persona+' ('+(v.plugin||'')+')');}else{toast(v.error||'flash failed',true);}render();} function setFilter(f){filter=f;document.querySelectorAll('.chip').forEach(c=>{const on=c.dataset.f===f;c.classList.toggle('on',on);c.setAttribute('aria-pressed',on?'true':'false');});render();} document.getElementById('filters').addEventListener('click',e=>{const c=e.target.closest('.chip');if(c)setFilter(c.dataset.f);});@@ -331,6 +335,14 @@ function rowHtml(d){ const offReason=caps.reason||'Mount to a Linux target first.'; const clsSel='<select class="cls" id="cls-'+d.id+'" '+(caps.class_override?'':'disabled ')+'data-tooltip="Force the kernel to bind this device as a specific class regardless of its default, via driver_override. '+esc(caps.class_override?'':offReason)+'"><option value="hid">HID</option><option value="storage">Mass storage</option><option value="serial">Serial</option></select>'; const clsBtn='<button class="iconbtn" '+(caps.class_override?'':'disabled ')+'onclick="doOp(\''+d.id+'\',\'class_override\')" data-tooltip="Apply the selected class override.">'+icon('<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/>')+'</button>';+ // Firmware device-test pack: flash a stub USB persona to this chip.+ const personas=d.firmware_personas||[];+ let flashCtl='';+ if(personas.length){+ const opts=personas.map(p=>'<option value="'+esc(p.id)+'">'+esc(p.name||p.id)+'</option>').join('');+ flashCtl='<select class="cls" id="fw-'+d.id+'" data-tooltip="Stub USB personas '+esc(d.firmware_plugin||'')+' can flash to this chip — each makes it enumerate as a different USB class to test host support.">'+opts+'</select>'++ '<button class="iconbtn" onclick="doFlash(\''+d.id+'\')" data-tooltip="Flash the selected stub firmware persona ('+esc(d.firmware_plugin||'')+'). The device re-enumerates as that USB class so you can mount / test / sniff it.">'+icon('<path d="M13 2L3 14h7l-1 8 10-12h-7l1-8z"/>')+'</button>';+ } const srcBadge='<span class="badge '+esc(d.location)+'" data-tooltip="Physically plugged into '+esc(d.source_label)+'.">'+esc(d.source_label)+'</span>'; const mountedBadge=mounted@@ -349,7 +361,8 @@ function rowHtml(d){ opBtn('reset','Power-cycle / re-enumerate this device on '+esc(hostLabel(mounted||''))+'.',offReason)+ opBtn('test','Run lsusb -v + an enumerate/echo health check on the target.',offReason)+ clsSel+clsBtn+- opBtn('sniff','Capture USB packets (usbmon) for this device on the target → pcap.',offReason)++ opBtn('sniff',(d.sniffer_plugin?('Capture + decode USB packets via plugin '+d.sniffer_plugin+'.'):'Capture USB packets (usbmon) for this device on the target → pcap.'),offReason)++ flashCtl+ '</div></td>'+ '</tr>'; }
src/cli.rs+9@@ -33,6 +33,15 @@ pub fn run_mount(a: MountArgs) -> Result<(), String> { Ok(()) } +pub fn run_flash(a: crate::FlashArgs) -> Result<(), String> {+ let resp = post(a.port, "/flash", serde_json::json!({"device_id": a.device_id, "persona": a.persona}))?;+ ok(format!("flashing {} -> persona '{}' via {}", a.device_id, a.persona,+ resp.get("plugin").and_then(|v| v.as_str()).unwrap_or("?")));+ if let Some(cmd) = resp.get("command").and_then(|v| v.as_str()) { note!(" agent command: {}", cmd); }+ hint("after flashing, the device re-enumerates as the persona's USB class — run `adom-usb list` to see it");+ Ok(())+}+ pub fn run_plugins(sub: crate::PluginsCmd) -> Result<(), String> { match sub { crate::PluginsCmd::List => {
src/main.rs+12@@ -84,6 +84,9 @@ enum Cmd { Mount(MountArgs), /// Unmount a device from wherever it is attached. Unmount(UnmountArgs),+ /// Flash a stub-firmware persona onto a device (e.g. RP2040 -> HID keyboard)+ /// via a matching firmware device-test pack.+ Flash(FlashArgs), /// Check whether a running dashboard server is reachable. Health { #[arg(long, default_value_t = 8896)]@@ -132,6 +135,14 @@ pub struct UnmountArgs { #[arg(long, default_value_t = 8896)] pub port: u16, } +#[derive(clap::Args)]+pub struct FlashArgs {+ pub device_id: String,+ /// Persona id from the device's firmware_personas (e.g. hid-keyboard, msc-disk).+ pub persona: String,+ #[arg(long, default_value_t = 8896)] pub port: u16,+}+ fn main() -> ExitCode { let args = Cli::parse(); let result = match args.cmd {@@ -139,6 +150,7 @@ fn main() -> ExitCode { Cmd::List { demo } => cli::run_list(demo), Cmd::Mount(a) => cli::run_mount(a), Cmd::Unmount(a) => cli::run_unmount(a),+ Cmd::Flash(a) => cli::run_flash(a), Cmd::Health { port } => cli::run_health(port), Cmd::Plugins { sub } => cli::run_plugins(sub), Cmd::Install => install::install(),
src/plugins.rs+35−4@@ -35,6 +35,7 @@ pub struct Plugin { pub name: String, pub version: String, pub api_version: String,+ /// "sniffer" | "decoder" | "firmware" pub kind: String, pub entrypoint: String, /// {"class": ["CDC serial","*"], "vid": ["*"], "pid": ["*"]}@@ -42,6 +43,15 @@ pub struct Plugin { pub capture: String, pub decoders: Vec<String>, pub description: String,+ /// firmware plugins only: who publishes it (e.g. "Raspberry Pi", "STMicro", "TI").+ pub vendor: String,+ /// firmware plugins only: chip families this device-test pack targets.+ pub chips: Vec<String>,+ /// firmware plugins only: how it flashes (uf2-bootsel | dfu-util | esptool | nrfutil | openocd | …).+ pub flash_method: String,+ /// firmware plugins only: the catalog of stub USB personas it can flash.+ /// Each: {id, name, usb_class, result_vid?, result_pid?}.+ pub personas: Value, /// "installed" | "registry" pub source: String, pub installed: bool,@@ -67,6 +77,12 @@ fn from_manifest(v: &Value, source: &str, installed: bool) -> Option<Plugin> { .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(),+ vendor: v.get("vendor").and_then(|x| x.as_str()).unwrap_or("").to_string(),+ chips: v.get("chips").and_then(|x| x.as_array())+ .map(|a| a.iter().filter_map(|d| d.as_str().map(String::from)).collect())+ .unwrap_or_default(),+ flash_method: v.get("flash_method").and_then(|x| x.as_str()).unwrap_or("").to_string(),+ personas: v.get("personas").cloned().unwrap_or(json!([])), source: source.to_string(), installed, })@@ -140,6 +156,10 @@ pub fn discover_registry(installed: &[Plugin]) -> Vec<Plugin> { capture: "".to_string(), decoders: vec![], description: item.get("brief").or_else(|| item.get("description")).and_then(|s| s.as_str()).unwrap_or("").to_string(),+ vendor: "".to_string(),+ chips: vec![],+ flash_method: "".to_string(),+ personas: json!([]), source: "registry".to_string(), installed: false, })@@ -150,13 +170,13 @@ 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> {+/// Pick the best installed plugin of a given kind for a device by class/vid/pid.+/// A more specific match (exact class, non-"*" vid) wins over a wildcard.+pub fn match_kind<'a>(kind: &str, 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") {+ for p in plugins.iter().filter(|p| p.kind == kind) { 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<_>>())@@ -177,6 +197,17 @@ pub fn match_for<'a>(class: &str, vid: u16, pid: u16, plugins: &'a [Plugin]) -> best.map(|(p, _)| p) } +/// Best matching sniffer plugin for a device.+pub fn match_for<'a>(class: &str, vid: u16, pid: u16, plugins: &'a [Plugin]) -> Option<&'a Plugin> {+ match_kind("sniffer", class, vid, pid, plugins)+}++/// Best matching firmware device-test pack for a device (matched by vid/pid —+/// e.g. RP2040 BOOTSEL 2e8a:*, STM32 DFU 0483:df11, Sitara ROM 0451:*).+pub fn firmware_for<'a>(class: &str, vid: u16, pid: u16, plugins: &'a [Plugin]) -> Option<&'a Plugin> {+ match_kind("firmware", class, vid, pid, plugins)+}+ 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.2.0",+ "version": "0.3.0", "releases": { "adom_docker": { "asset_name": "adom-usb",