← Commit history
Cargo.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"+  ]+}\ No newline at end of file
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"+}\ No newline at end of file
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"+      }+    }   }-}\ No newline at end of file+}
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",