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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
//! 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,
/// "sniffer" | "decoder" | "firmware"
pub kind: String,
pub entrypoint: String,
/// {"class": ["CDC serial","*"], "vid": ["*"], "pid": ["*"]}
pub matches: Value,
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,
}
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(),
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,
})
}
/// 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.
///
/// The wiki's `/discover?triggers=` endpoint is unreliable for hyphenated tags,
/// so we use full-text search (`?q=adom-usb`) and keep the `adom-usb-*` family
/// (the plugin naming convention) minus the app itself and anything installed.
pub fn discover_registry(installed: &[Plugin]) -> Vec<Plugin> {
let url = "https://wiki.adom.inc/api/v1/search?q=adom-usb";
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("pages")).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" || !slug.starts_with("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(),
vendor: "".to_string(),
chips: vec![],
flash_method: "".to_string(),
personas: json!([]),
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 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 == 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<_>>())
.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)
}
/// 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!([]))
}