diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df53990..cfe1e3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,9 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v7 + - name: Install hidapi build deps (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libudev-dev libhidapi-dev - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7e0ac30..5da4211 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,9 @@ jobs: target: x86_64-unknown-linux-gnu steps: - uses: actions/checkout@v7 + - name: Install hidapi build deps (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libudev-dev libhidapi-dev - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md new file mode 100644 index 0000000..8b5b9c2 --- /dev/null +++ b/ACKNOWLEDGMENTS.md @@ -0,0 +1,41 @@ +# Acknowledgments + +## Thanks to OpenAI + +Microbridge exists because the Codex Micro is an *open* piece of hardware, and +that is not an accident — it is a choice OpenAI made. + +OpenAI is a for-profit company, and it would have been easy to lock the Micro to +a single first-party app: a closed protocol, an exclusive USB claim, no way for +anyone else to light a key. They did the opposite. + +- **They shipped the device kit in the open.** The full Work Louder protocol + travels inside the ChatGPT desktop app, which is how a community project like + this one could learn the framing and RPC without a single reverse-engineered + firmware dump. +- **They open the HID interface non-exclusively.** The Micro can be driven by + more than one program at a time, so third-party software can coexist with the + official experience instead of fighting it. Microbridge only works because of + that decision. +- **They keep giving users a choice.** Codex CLI is open source, the models are + reachable over documented APIs, and the tooling favors interoperability over + lock-in. Consumers get options, and options are good for everyone. + +None of that was required of them. We think it is worth saying thank you when a +company consistently chooses to give its users room to build — so: **thank you.** + +Microbridge is an independent community project and is not affiliated with, +sponsored by, or endorsed by OpenAI or Work Louder. This note is simply our +appreciation, offered freely. + +## Thanks to Work Louder + +For designing a genuinely hackable macropad — one that is also configurable +through Work Louder Input / VIA — and for building the hardware the whole +project is aimed at. + +## Thanks to contributors + +And to everyone who writes an adapter, files an issue, or plugs in a device and +tells us what really happens. Adapters are the point of this project; see +[CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/Cargo.lock b/Cargo.lock index aa78162..e25b283 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,16 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cc" +version = "1.2.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -80,6 +90,12 @@ dependencies = [ "libc", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -95,6 +111,19 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hidapi" +version = "2.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c78dadfc12f865bc3fcac3897e64533b930737ceb9ef245c8277de98d0b010e9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "pkg-config", + "windows-sys 0.61.2", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -203,7 +232,10 @@ dependencies = [ name = "mb-device" version = "0.1.0" dependencies = [ + "hidapi", "mb-protocol", + "serde", + "serde_json", "tracing", ] @@ -225,6 +257,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" name = "microbridgectl" version = "0.1.0" dependencies = [ + "mb-device", "mb-protocol", "serde_json", "tokio", @@ -461,6 +494,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -566,6 +605,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" diff --git a/INSTALL.md b/INSTALL.md index 79b6271..5833512 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -54,7 +54,7 @@ Governance / why this path: [docs/governance.md](docs/governance.md). |---|---| | macOS (Homebrew) | Homebrew + **Xcode Command Line Tools** (`xcode-select --install`); Rust + Node pulled in as **build** deps (builds `.app` + daemon) | | From source | Rust stable, Node ≥ 20; macOS also needs Xcode CLT for the `.app` | -| Hardware LEDs | Codex Micro over USB (HID packing still landing — mock works without hardware) | +| Hardware LEDs | Codex Micro over USB (protocol ready; set `MICROBRIDGE_HID_CLAIM=1` to write) | ## From source (developers) @@ -120,8 +120,10 @@ brew services restart microbridge launchctl kickstart -k "gui/$(id -u)/ai.microbridge.daemon" ``` -**LEDs stay dark** — HID packing is still best-effort; ChatGPT desktop may -also own the device. +**LEDs stay dark** — by default Microbridge only probes USB (Detected). To +write Agent Key lighting: pause ChatGPT Desktop ownership, then +`export MICROBRIDGE_HID_CLAIM=1` before starting the daemon. See +[docs/device-hid.md](docs/device-hid.md). **Homebrew can’t fetch (private repo)** — `gh auth login`, or set `HOMEBREW_GITHUB_API_TOKEN` to a PAT with `repo` scope. diff --git a/README.md b/README.md index bc81cbd..3fca2da 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Microbridge is a tiny local daemon that bridges AI coding agents — Codex CLI, Claude Code, Cursor, T3 Code, and anything else with an adapter — to the [Work Louder Codex Micro](https://worklouder.cc/). Per-key RGB mirrors live agent state; the keys drive agent actions (approve, reject, interrupt, switch focus). No vendor desktop app required. -> **Status: early public alpha (`v0.1.x`).** Menu bar UI, local daemon, in-process Codex/Claude watchers, and signed macOS packages are shipping. **Real Micro HID packing lands after hardware arrives (target 2026-07-22)** — until then LEDs/keys use Simulator / Detected. See [ROADMAP.md](ROADMAP.md) and [PRIVACY.md](PRIVACY.md). +> **Status: early public alpha (`v0.1.x`).** Menu bar UI, local daemon, in-process Codex/Claude watchers, and signed macOS packages are shipping. **HID protocol (VID/PID, framing, `v.oai.thstatus`) is implemented from ChatGPT’s Work Louder kit**; live LED writes stay opt-in (`MICROBRIDGE_HID_CLAIM=1`) until hardware validation. See [docs/device-hid.md](docs/device-hid.md). ## Screenshots @@ -71,7 +71,7 @@ Details in [docs/architecture.md](docs/architecture.md). The wire format is spec ``` crates/mb-protocol wire types (serde) — the protocol's source of truth -crates/mb-device device abstraction; mock today, HID packing TBD +crates/mb-device device abstraction; HID framing + opt-in claim crates/mb-adapters first-party Codex CLI + Claude Code watchers crates/microbridged the daemon: socket server, registry, focus, key source crates/microbridgectl support/debug CLI (`status`) @@ -129,6 +129,8 @@ Adapter PRs are explicitly welcome — that is the point of the project. Start w Microbridge is an independent community project. It is not affiliated with or endorsed by Work Louder or OpenAI. Driving the Micro's LEDs outside official software relies on best-effort reverse engineering of the device's HID protocol and may lag firmware updates. +This project is only possible because OpenAI chose to keep the Micro open — an open protocol, a non-exclusive HID interface, and open tooling. That is a real choice, and we're grateful for it: see [ACKNOWLEDGMENTS.md](ACKNOWLEDGMENTS.md). + ## License Dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option. Contributions are accepted under the same terms. diff --git a/ROADMAP.md b/ROADMAP.md index 1cad219..f65249a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,9 +14,11 @@ Homebrew formula skeleton. UI/control protocol (`subscribe` / config) and five key-source modes. ## M2 — Real light out 🚧 -Codex Micro HID driver in `mb-device` (LED frames, key events, encoder), -behind a capability-probed device descriptor. Mock remains the default until -VID/PID + report map are captured — see [docs/device-hid.md](docs/device-hid.md). +Codex Micro HID driver in `mb-device`: VID/PID + HID framing + `v.oai.thstatus` +packing mined from ChatGPT Desktop's Work Louder kit. USB probe shows +**Detected**; live writes are opt-in (`MICROBRIDGE_HID_CLAIM=1`). Hardware +validation (key map, ownership UX) still pending — see +[docs/device-hid.md](docs/device-hid.md). ## M3 — Focus + menu bar 🚧 Tauri companion (`apps/microbridge-ui`) ports the approved MagicPath surfaces diff --git a/crates/mb-device/Cargo.toml b/crates/mb-device/Cargo.toml index 7b53c09..66c0365 100644 --- a/crates/mb-device/Cargo.toml +++ b/crates/mb-device/Cargo.toml @@ -6,6 +6,14 @@ edition.workspace = true license.workspace = true repository.workspace = true +[features] +default = ["hid"] +## Open/write the Work Louder vendor HID interface via `hidapi`. +hid = ["dep:hidapi"] + [dependencies] mb-protocol = { path = "../mb-protocol" } tracing = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +hidapi = { version = "2.6", optional = true } diff --git a/crates/mb-device/src/capture.rs b/crates/mb-device/src/capture.rs new file mode 100644 index 0000000..6b906df --- /dev/null +++ b/crates/mb-device/src/capture.rs @@ -0,0 +1,130 @@ +//! Interactive HID capture for hardware bring-up. +//! +//! Enabled with `--features hid`. Opens the Work Louder vendor interface and +//! streams decoded device→host notifications so the shipping key-string map can +//! be filled in without guessing. This is the tool the +//! [hardware bring-up runbook](../../docs/hardware-bringup.md) drives on day one +//! with a real Codex Micro. +//! +//! Nothing here writes to the device — it is read-only observation. + +#![cfg(feature = "hid")] + +use std::collections::BTreeMap; +use std::time::{Duration, Instant}; + +use crate::claim::open_device; +use crate::rpc::DeviceNotify; + +/// Rolling stats for a distinct `v.oai.hid` key string. +#[derive(Debug, Default)] +struct KeyStat { + count: usize, + last_act: Option, + last_agent: Option, +} + +/// Open the Micro and stream decoded device→host events for `seconds` +/// (`0` = until interrupted). Prints a fill-in-the-blank summary at the end so +/// the observed key strings can be dropped straight into the bring-up runbook. +/// +/// Read-only: this never claims write ownership or drives LEDs. macOS opens the +/// interface non-exclusively, so quit ChatGPT Desktop and pause `microbridged`'s +/// LED claim first if you want a clean stream. +pub fn run_capture(seconds: u64) -> Result<(), String> { + let mut device = open_device(None)?; + let limit = if seconds == 0 { + "∞".to_string() + } else { + format!("{seconds}s") + }; + + println!( + "microbridge hid-capture — {} (0x{:04X})", + device.name, device.product_id + ); + println!("Read-only. Press every Agent Key, then Approve / Reject / Interrupt,"); + println!("rotate the dial, press the dial, and flick the joystick in each direction."); + println!("Every device→host event prints below. Runs for {limit} (Ctrl-C stops early).\n"); + + let start = Instant::now(); + let mut keys: BTreeMap = BTreeMap::new(); + let mut joystick_samples = 0usize; + let mut others: BTreeMap = BTreeMap::new(); + + loop { + for notify in device.poll_notifies() { + let t = start.elapsed().as_secs_f32(); + match notify { + DeviceNotify::Hid { key, act, agent } => { + println!("[{t:>6.1}s] hid k={key:<12} act={act:?} ag={agent:?}"); + let stat = keys.entry(key).or_default(); + stat.count += 1; + stat.last_act = act; + stat.last_agent = agent; + } + DeviceNotify::Joystick { angle, distance } => { + println!("[{t:>6.1}s] joy a={angle:?} d={distance:?}"); + joystick_samples += 1; + } + DeviceNotify::Other { method } => { + println!("[{t:>6.1}s] other method={method}"); + *others.entry(method).or_insert(0) += 1; + } + } + } + + if seconds != 0 && start.elapsed() >= Duration::from_secs(seconds) { + break; + } + // Foreground debug tool: a short sleep keeps CPU near-idle while polling. + std::thread::sleep(Duration::from_millis(5)); + } + + print_summary(&keys, joystick_samples, &others); + Ok(()) +} + +fn print_summary( + keys: &BTreeMap, + joystick_samples: usize, + others: &BTreeMap, +) { + println!("\n──────── capture summary ────────"); + if keys.is_empty() && joystick_samples == 0 && others.is_empty() { + println!("No device→host events observed. Is the interface owned by another app"); + println!("(ChatGPT Desktop / microbridged)? Quit it and re-run."); + return; + } + + if !keys.is_empty() { + println!("\nkey (v.oai.hid) → drop these into docs/hardware-bringup.md:"); + println!(" {:<14} {:>5} last_act last_ag", "k", "hits"); + for (key, stat) in keys { + println!( + " {:<14} {:>5} {:<8} {}", + key, + stat.count, + stat.last_act + .map(|a| a.to_string()) + .unwrap_or_else(|| "-".into()), + stat.last_agent + .map(|a| a.to_string()) + .unwrap_or_else(|| "-".into()), + ); + } + } + + if joystick_samples > 0 { + println!("\njoystick (v.oai.rad): {joystick_samples} samples"); + } + + if !others.is_empty() { + println!("\nother notifications (unmapped methods):"); + for (method, count) in others { + println!(" {method:<20} {count}"); + } + } + println!("\nNext: record the mapping in docs/hardware-bringup.md and update"); + println!("`agent_key_index` in crates/mb-device/src/lib.rs if the real strings differ."); +} diff --git a/crates/mb-device/src/claim.rs b/crates/mb-device/src/claim.rs new file mode 100644 index 0000000..604489d --- /dev/null +++ b/crates/mb-device/src/claim.rs @@ -0,0 +1,136 @@ +//! Optional HID claim via `hidapi`. +//! +//! Enabled with `--features hid`. Claim is still opt-in at runtime via +//! `MICROBRIDGE_HID_CLAIM=1` so ChatGPT Desktop can keep the device by default. + +#![cfg(feature = "hid")] + +use std::sync::Mutex; + +use hidapi::{HidApi, HidDevice as RawHid}; + +use crate::framing::{frame_rpc, parse_report, CHANNEL_RPC, REPORT_ID}; +use crate::ids::{is_supported_pid, product_name, WL_USAGE_PAGE, WL_VID}; +use crate::rpc::{parse_notify, DeviceNotify}; + +/// Open the first matching vendor HID interface (usage page `0xFF00`). +/// +/// On macOS, opens non-exclusively so ChatGPT Desktop can coexist. +pub fn open_device(preferred_pid: Option) -> Result { + let api = HidApi::new().map_err(|e| e.to_string())?; + + #[cfg(target_os = "macos")] + { + // Match ChatGPT Desktop / node-hid `nonExclusive: true`. + api.set_open_exclusive(false); + } + + let mut candidates: Vec<_> = api + .device_list() + .filter(|info| { + info.vendor_id() == WL_VID + && is_supported_pid(info.product_id()) + && info.usage_page() == WL_USAGE_PAGE + }) + .collect(); + + if let Some(pid) = preferred_pid { + candidates.sort_by_key(|info| usize::from(info.product_id() != pid)); + } + + let info = candidates + .first() + .ok_or_else(|| "no Work Louder vendor HID interface found".to_string())?; + + let product_id = info.product_id(); + let name = product_name(product_id).to_string(); + let device = api + .open_path(info.path()) + .map_err(|e| format!("open_path failed: {e}"))?; + let _ = device.set_blocking_mode(false); + + Ok(ClaimedDevice { + device: Mutex::new(device), + product_id, + name, + rpc_id: 1, + rx_buf: String::new(), + pending: Vec::new(), + }) +} + +/// A claimed vendor HID channel that can write RPC and poll notifications. +pub struct ClaimedDevice { + device: Mutex, + pub product_id: u16, + pub name: String, + rpc_id: u32, + rx_buf: String, + pending: Vec, +} + +impl ClaimedDevice { + pub fn next_rpc_id(&mut self) -> u32 { + let id = self.rpc_id; + self.rpc_id = (self.rpc_id + 1) % 999; + if self.rpc_id == 0 { + self.rpc_id = 1; + } + id + } + + /// Write a JSON-RPC request string (already serialized). + pub fn write_rpc(&self, request: &str) -> Result<(), String> { + let reports = frame_rpc(request); + let dev = self.device.lock().map_err(|e| e.to_string())?; + for report in reports { + // hidapi expects the report id in byte 0 for write(). + debug_assert_eq!(report[0], REPORT_ID); + dev.write(&report) + .map_err(|e| format!("hid write failed: {e}"))?; + } + Ok(()) + } + + /// Non-blocking read; accumulates RPC channel text and parses notifies. + pub fn poll_notifies(&mut self) -> Vec { + { + let dev = match self.device.lock() { + Ok(d) => d, + Err(_) => return Vec::new(), + }; + let mut buf = [0u8; 64]; + loop { + match dev.read_timeout(&mut buf, 0) { + Ok(n) if n > 0 => { + if let Some(packet) = parse_report(&buf[..n]) { + if packet.channel == CHANNEL_RPC { + if let Ok(text) = std::str::from_utf8(&packet.payload) { + self.rx_buf.push_str(text); + } + } + } + } + _ => break, + } + } + } + + let mut out = std::mem::take(&mut self.pending); + while let Some(idx) = self.rx_buf.find('\n') { + let line = self.rx_buf[..idx].trim_end_matches('\r').to_string(); + self.rx_buf = self.rx_buf[idx + 1..].to_string(); + if let Some(n) = parse_notify(&line) { + out.push(n); + } + } + // Also try parse if buffer looks like a complete JSON object w/o newline yet. + if self.rx_buf.trim_start().starts_with('{') { + if let Some(n) = parse_notify(&self.rx_buf) { + out.push(n); + self.rx_buf.clear(); + } + } + out + } +} diff --git a/crates/mb-device/src/framing.rs b/crates/mb-device/src/framing.rs new file mode 100644 index 0000000..8ba3b4b --- /dev/null +++ b/crates/mb-device/src/framing.rs @@ -0,0 +1,121 @@ +//! HID report framing for the Work Louder RPC channel. +//! +//! Wire layout (64-byte interrupt report), from `wl-device-kit`: +//! +//! ```text +//! [0] report id = 0x06 +//! [1] channel = 1 (debug) | 2 (RPC) +//! [2] length = payload byte count (0..=61) +//! [3..] UTF-8 payload +//! ``` + +/// HID report identifier used for Work Louder vendor traffic. +pub const REPORT_ID: u8 = 0x06; + +/// Debug / log channel (device → host text). +pub const CHANNEL_DEBUG: u8 = 1; + +/// JSON-RPC channel (bidirectional). +pub const CHANNEL_RPC: u8 = 2; + +/// Maximum UTF-8 payload bytes per 64-byte HID report. +pub const MAX_CHUNK_SIZE: usize = 61; + +/// Full HID report size written to the device (includes report id). +pub const REPORT_SIZE: usize = 64; + +/// One demultiplexed HID packet. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HidPacket { + pub channel: u8, + pub payload: Vec, +} + +/// Frame a UTF-8 message into one or more 64-byte HID reports on `channel`. +pub fn frame_message(channel: u8, message: &[u8]) -> Vec<[u8; REPORT_SIZE]> { + if message.is_empty() { + let mut report = [0u8; REPORT_SIZE]; + report[0] = REPORT_ID; + report[1] = channel; + report[2] = 0; + return vec![report]; + } + + let mut out = Vec::new(); + let mut offset = 0; + while offset < message.len() { + let chunk = (message.len() - offset).min(MAX_CHUNK_SIZE); + let mut report = [0u8; REPORT_SIZE]; + report[0] = REPORT_ID; + report[1] = channel; + report[2] = chunk as u8; + report[3..3 + chunk].copy_from_slice(&message[offset..offset + chunk]); + out.push(report); + offset += chunk; + } + out +} + +/// Convenience: frame a string on the RPC channel. +pub fn frame_rpc(message: &str) -> Vec<[u8; REPORT_SIZE]> { + frame_message(CHANNEL_RPC, message.as_bytes()) +} + +/// Parse a raw HID read buffer into channel + payload. +/// +/// Accepts buffers with or without a leading report id (some backends strip it). +pub fn parse_report(data: &[u8]) -> Option { + if data.len() < 3 { + return None; + } + let (channel, length, payload_start) = if data[0] == REPORT_ID { + if data.len() < 3 { + return None; + } + (data[1], data[2] as usize, 3usize) + } else { + // Report id already stripped — treat byte 0 as channel. + (data[0], data[1] as usize, 2usize) + }; + if payload_start + length > data.len() || length > MAX_CHUNK_SIZE { + return None; + } + Some(HidPacket { + channel, + payload: data[payload_start..payload_start + length].to_vec(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frames_short_rpc_message() { + let msg = r#"{"method":"sys.version","params":null,"id":1}"#; + let reports = frame_rpc(msg); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0][0], REPORT_ID); + assert_eq!(reports[0][1], CHANNEL_RPC); + assert_eq!(reports[0][2] as usize, msg.len()); + assert_eq!(&reports[0][3..3 + msg.len()], msg.as_bytes()); + } + + #[test] + fn splits_long_messages() { + let msg = "x".repeat(130); + let reports = frame_rpc(&msg); + assert_eq!(reports.len(), 3); + assert_eq!(reports[0][2] as usize, 61); + assert_eq!(reports[1][2] as usize, 61); + assert_eq!(reports[2][2] as usize, 8); + } + + #[test] + fn round_trips_report() { + let reports = frame_rpc("hello"); + let packet = parse_report(&reports[0]).expect("parse"); + assert_eq!(packet.channel, CHANNEL_RPC); + assert_eq!(packet.payload, b"hello"); + } +} diff --git a/crates/mb-device/src/ids.rs b/crates/mb-device/src/ids.rs new file mode 100644 index 0000000..b945254 --- /dev/null +++ b/crates/mb-device/src/ids.rs @@ -0,0 +1,33 @@ +//! USB identity for Work Louder / Codex Micro devices. +//! +//! Sourced from ChatGPT Desktop's bundled `@worklouder/wl-device-kit` +//! (`DEVICE_REGISTRY` / `WL_VID`). See `docs/device-hid.md`. + +/// Work Louder / Espressif USB vendor ID (`0x303A`). +pub const WL_VID: u16 = 0x303A; + +/// Codex Micro (firmware name `project_2077`) product ID (`0x8360`). +pub const CODEX_MICRO_PID: u16 = 0x8360; + +/// Creator Micro V2 product IDs (same family; not Codex Micro branding). +pub const CREATOR_MICRO_V2_PIDS: [u16; 2] = [0x8297, 0x8298]; + +/// Vendor-specific HID usage page used for the JSON-RPC channel (`0xFF00`). +pub const WL_USAGE_PAGE: u16 = 0xFF00; + +/// Manufacturer strings reported by Work Louder HID devices. +pub const WL_MANUFACTURERS: [&str; 2] = ["Work Louder", "Work_Louder"]; + +/// Product IDs Microbridge treats as a compatible macropad. +pub fn is_supported_pid(pid: u16) -> bool { + pid == CODEX_MICRO_PID || CREATOR_MICRO_V2_PIDS.contains(&pid) +} + +/// Human label for a supported PID. +pub fn product_name(pid: u16) -> &'static str { + match pid { + CODEX_MICRO_PID => "codex-micro", + 0x8297 | 0x8298 => "creator-micro-v2", + _ => "work-louder", + } +} diff --git a/crates/mb-device/src/lib.rs b/crates/mb-device/src/lib.rs index 1b78a3b..0279354 100644 --- a/crates/mb-device/src/lib.rs +++ b/crates/mb-device/src/lib.rs @@ -2,8 +2,31 @@ //! //! Real Codex Micro HID support lands behind [`HidDevice`] (best-effort). //! Until a device is present the daemon drives [`MockDevice`], which logs the -//! frames a real device would render. All reverse-engineering stays in this -//! crate — see `docs/device-hid.md`. +//! frames a real device would render. Protocol constants and packing live in +//! this crate — see `docs/device-hid.md`. + +mod framing; +mod ids; +mod lighting; +mod probe; +mod rpc; + +#[cfg(feature = "hid")] +mod capture; +#[cfg(feature = "hid")] +mod claim; + +#[cfg(feature = "hid")] +pub use capture::run_capture; + +pub use framing::{frame_rpc, parse_report, CHANNEL_DEBUG, CHANNEL_RPC, REPORT_ID}; +pub use ids::{is_supported_pid, CODEX_MICRO_PID, WL_MANUFACTURERS, WL_USAGE_PAGE, WL_VID}; +pub use lighting::{parse_rgb_hex, threads_lighting_rpc}; +pub use probe::{match_usb_text, probe_usb_micro, ProbeResult}; +pub use rpc::{ + parse_notify, threads_lighting_request, DeviceNotify, LightingEffect, METHOD_RGB_CONFIG, + METHOD_THREADS_LIGHTING, +}; use mb_protocol::{AgentState, AGENT_KEY_COUNT}; @@ -67,6 +90,8 @@ pub enum JoystickDir { #[derive(Debug, Clone, PartialEq, Eq)] pub struct LedFrame { pub keys: [Option; AGENT_KEY_COUNT], + /// Packed RGB (`0xRRGGBB`) per key when the daemon has resolved palette colors. + pub key_colors: [Option; AGENT_KEY_COUNT], pub focus_index: Option, pub brightness: u8, pub paused: bool, @@ -76,6 +101,7 @@ impl Default for LedFrame { fn default() -> Self { Self { keys: [None; AGENT_KEY_COUNT], + key_colors: [None; AGENT_KEY_COUNT], focus_index: None, brightness: 80, paused: false, @@ -131,13 +157,30 @@ impl Device for MockDevice { /// Best-effort USB HID driver for the Codex Micro. /// /// Without a probed device this behaves like [`MockDevice`] and reports -/// `connected: false`. Real report packing is documented in -/// `docs/device-hid.md` and filled in as the HID map is confirmed. -#[derive(Debug)] +/// `connected: false`. Presence (Detected) uses VID/PID from ChatGPT's kit. +/// Live claim requires `--features hid` and `MICROBRIDGE_HID_CLAIM=1`. pub struct HidDevice { inner: MockDevice, + /// True only when the vendor HID interface is claimed for writes. connected: bool, + /// USB present (Detected) even if not claimed. + usb_present: bool, name: String, + product_id: Option, + rpc_seq: u32, + #[cfg(feature = "hid")] + claimed: Option, +} + +impl std::fmt::Debug for HidDevice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HidDevice") + .field("connected", &self.connected) + .field("usb_present", &self.usb_present) + .field("name", &self.name) + .field("product_id", &self.product_id) + .finish_non_exhaustive() + } } impl Default for HidDevice { @@ -145,7 +188,12 @@ impl Default for HidDevice { Self { inner: MockDevice::default(), connected: false, + usb_present: false, name: "codex-micro".into(), + product_id: None, + rpc_seq: 1, + #[cfg(feature = "hid")] + claimed: None, } } } @@ -154,87 +202,85 @@ impl HidDevice { /// Attempt to open the first matching USB device. Falls back to /// disconnected (mock rendering) when none is found or HID is unavailable. /// - /// Until the report map is verified we never claim exclusive access. On - /// macOS we still probe USB presence so the UI can show "Detected". + /// Claim is opt-in (`MICROBRIDGE_HID_CLAIM=1` + `hid` feature) so we do not + /// fight ChatGPT Desktop by default. pub fn open() -> Self { - if usb_micro_present() { - Self { - inner: MockDevice::default(), - connected: false, - name: "codex-micro-usb".into(), - } - } else { - Self::default() + let probe = probe_usb_micro(); + if !probe.present { + return Self::default(); } + + let pid = probe.product_id.unwrap_or(CODEX_MICRO_PID); + let name = format!("{}-usb", ids::product_name(pid)); + let mut device = Self { + inner: MockDevice::default(), + connected: false, + usb_present: true, + name, + product_id: Some(pid), + rpc_seq: 1, + #[cfg(feature = "hid")] + claimed: None, + }; + + if claim_requested() { + device.try_claim(); + } + device } pub fn set_connected_for_tests(&mut self, connected: bool) { self.connected = connected; + self.usb_present = connected || self.usb_present; } -} -/// Best-effort USB presence probe — does not claim the interface. -fn usb_micro_present() -> bool { - #[cfg(target_os = "macos")] - { - match system_profiler_usb_text(std::time::Duration::from_secs(3)) { - Some(text) => usb_text_matches_micro(&text), - None => false, - } - } - #[cfg(not(target_os = "macos"))] - { - false + pub fn usb_present(&self) -> bool { + self.usb_present } -} -#[cfg(target_os = "macos")] -fn system_profiler_usb_text(timeout: std::time::Duration) -> Option { - use std::io::Read; - use std::process::{Command, Stdio}; - use std::thread; - use std::time::Instant; - - let mut child = Command::new("system_profiler") - .args(["SPUSBDataType", "-detailLevel", "mini"]) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .ok()?; - let deadline = Instant::now() + timeout; - loop { - match child.try_wait() { - Ok(Some(status)) => { - if !status.success() { - return None; - } - let mut buf = Vec::new(); - if let Some(mut out) = child.stdout.take() { - let _ = out.read_to_end(&mut buf); - } - return Some(String::from_utf8_lossy(&buf).into_owned()); + #[cfg(feature = "hid")] + fn try_claim(&mut self) { + match claim::open_device(self.product_id) { + Ok(claimed) => { + tracing::info!( + product_id = format_args!("0x{:04X}", claimed.product_id), + name = %claimed.name, + "claimed Work Louder HID interface" + ); + self.name = claimed.name.clone(); + self.product_id = Some(claimed.product_id); + self.connected = true; + self.claimed = Some(claimed); } - Ok(None) if Instant::now() >= deadline => { - let _ = child.kill(); - let _ = child.wait(); - return None; + Err(error) => { + tracing::warn!(%error, "HID claim requested but open failed; staying Detected-only"); } - Ok(None) => thread::sleep(std::time::Duration::from_millis(50)), - Err(_) => return None, } } -} -#[cfg(target_os = "macos")] -fn usb_text_matches_micro(raw: &str) -> bool { - let text = raw.to_ascii_lowercase(); - if text.contains("codex micro") { - return true; + #[cfg(not(feature = "hid"))] + fn try_claim(&mut self) { + tracing::warn!( + "MICROBRIDGE_HID_CLAIM set but mb-device built without `hid` feature — Detected only" + ); + } + + fn next_rpc_id(&mut self) -> u32 { + #[cfg(feature = "hid")] + if let Some(claimed) = self.claimed.as_mut() { + return claimed.next_rpc_id(); + } + let id = self.rpc_seq; + self.rpc_seq = (self.rpc_seq % 998) + 1; + id } - // Require manufacturer + product token in the same USB record block. - text.split("\n\n").any(|block| { - block.contains("work louder") && (block.contains("codex") || block.contains("kbd-1.0")) - }) +} + +fn claim_requested() -> bool { + matches!( + std::env::var("MICROBRIDGE_HID_CLAIM").as_deref(), + Ok("1") | Ok("true") | Ok("TRUE") | Ok("yes") | Ok("YES") + ) } impl Device for HidDevice { @@ -249,21 +295,147 @@ impl Device for HidDevice { } fn set_leds(&mut self, frame: &LedFrame) { + let rpc_id = self.next_rpc_id(); + let request = threads_lighting_rpc(frame, rpc_id); + let reports = frame_rpc(&request); + if self.connected { - tracing::debug!(device = %self.name, keys = ?frame.keys, "hid led frame"); + #[cfg(feature = "hid")] + if let Some(claimed) = self.claimed.as_ref() { + if let Err(error) = claimed.write_rpc(&request) { + tracing::warn!(%error, "failed to write thread lighting RPC"); + } else { + tracing::debug!( + device = %self.name, + bytes = request.len(), + packets = reports.len(), + "hid rpc v.oai.thstatus" + ); + } + } + #[cfg(not(feature = "hid"))] + { + let _ = reports; + tracing::debug!(device = %self.name, %request, "hid led rpc (no claim backend)"); + } + } else if self.usb_present { + tracing::debug!( + device = %self.name, + packets = reports.len(), + request = %request, + "usb present; packed LED RPC (set MICROBRIDGE_HID_CLAIM=1 to write)" + ); } + self.inner.set_leds(frame); } + + fn poll_input(&mut self) -> Option { + #[cfg(feature = "hid")] + if let Some(claimed) = self.claimed.as_mut() { + for notify in claimed.poll_notifies() { + if let Some(input) = notify_to_input(notify) { + return Some(input); + } + } + } + None + } +} + +#[cfg(feature = "hid")] +fn notify_to_input(notify: DeviceNotify) -> Option { + match notify { + DeviceNotify::Hid { key, .. } => { + agent_key_index(&key).map(|index| DeviceInput::AgentKeyPress { index }) + } + DeviceNotify::Joystick { angle, .. } => angle.and_then(joystick_from_angle), + DeviceNotify::Other { .. } => None, + } +} + +fn agent_key_index(key: &str) -> Option { + // Firmware may use agent0..agent5, agent1..agent6, or bare digits — accept common forms. + let digits: String = key.chars().filter(|c| c.is_ascii_digit()).collect(); + if digits.is_empty() { + return None; + } + let n: usize = digits.parse().ok()?; + if (1..=AGENT_KEY_COUNT).contains(&n) { + Some(n - 1) + } else if n < AGENT_KEY_COUNT { + Some(n) + } else { + None + } +} + +fn joystick_from_angle(angle: i64) -> Option { + // Degrees → cardinal flick; exact firmware mapping validated on hardware. + let a = angle.rem_euclid(360); + let direction = match a { + 45..=134 => JoystickDir::Right, + 135..=224 => JoystickDir::Down, + 225..=314 => JoystickDir::Left, + _ => JoystickDir::Up, + }; + Some(DeviceInput::JoystickFlick { direction }) } /// Prefer a claimed HID device; else a detected-but-unclaimed USB Micro; /// else the mock simulator. pub fn open_default_device() -> Box { let hid = HidDevice::open(); - let desc = hid.descriptor(); - if desc.connected || desc.name == "codex-micro-usb" { + if hid.usb_present() || hid.descriptor().connected { Box::new(hid) } else { Box::new(MockDevice::default()) } } + +#[cfg(test)] +mod tests { + use super::*; + + // Characterization tests: these lock in the *current guessed* mapping so a + // regression is visible. The real `v.oai.hid` strings get confirmed with a + // physical unit via `microbridgectl hid-capture` — see + // docs/hardware-bringup.md. Update these alongside the real map. + + #[test] + fn agent_key_index_prefers_one_based_forms() { + // ChatGPT ships `agent1..agent6`; treat 1..=6 as one-based (0..=5). + assert_eq!(agent_key_index("agent1"), Some(0)); + assert_eq!(agent_key_index("agent6"), Some(5)); + assert_eq!(agent_key_index("k3"), Some(2)); + } + + #[test] + fn agent_key_index_accepts_zero_based_zero() { + // A bare 0 can only mean the first key. + assert_eq!(agent_key_index("agent0"), Some(0)); + assert_eq!(agent_key_index("0"), Some(0)); + } + + #[test] + fn agent_key_index_rejects_out_of_range_and_digitless() { + assert_eq!(agent_key_index("agent7"), None); + assert_eq!(agent_key_index("approve"), None); + assert_eq!(agent_key_index(""), None); + } + + #[test] + fn joystick_angle_maps_to_cardinals() { + let dir = |a| match joystick_from_angle(a) { + Some(DeviceInput::JoystickFlick { direction }) => direction, + other => panic!("expected a flick, got {other:?}"), + }; + assert_eq!(dir(0), JoystickDir::Up); + assert_eq!(dir(90), JoystickDir::Right); + assert_eq!(dir(180), JoystickDir::Down); + assert_eq!(dir(270), JoystickDir::Left); + // Wraps: 360 ≡ 0, and negatives normalize via rem_euclid. + assert_eq!(dir(360), JoystickDir::Up); + assert_eq!(dir(-90), JoystickDir::Left); + } +} diff --git a/crates/mb-device/src/lighting.rs b/crates/mb-device/src/lighting.rs new file mode 100644 index 0000000..e1fed48 --- /dev/null +++ b/crates/mb-device/src/lighting.rs @@ -0,0 +1,119 @@ +//! Map Microbridge [`LedFrame`] values onto Work Louder thread-lighting RPC. + +use mb_protocol::AgentState; + +use crate::rpc::{threads_lighting_request, LightingEffect, ThreadLightingParam}; +use crate::LedFrame; + +/// Convert a daemon LED frame into a `v.oai.thstatus` JSON-RPC request. +pub fn threads_lighting_rpc(frame: &LedFrame, rpc_id: u32) -> String { + let threads = frame_to_threads(frame); + threads_lighting_request(&threads, rpc_id) +} + +fn frame_to_threads(frame: &LedFrame) -> Vec { + let brightness = if frame.paused { + 0.0 + } else { + (frame.brightness as f64 / 100.0).clamp(0.0, 1.0) + }; + + frame + .keys + .iter() + .enumerate() + .map(|(id, state)| { + let focused = frame.focus_index == Some(id); + match (frame.paused, state, frame.key_colors[id]) { + (true, _, _) | (_, None, _) => ThreadLightingParam { + id: id as u32, + c: None, + b: Some(0.0), + e: Some(LightingEffect::Off as u8), + s: None, + sk: None, + sa: None, + }, + (_, Some(agent_state), color) => ThreadLightingParam { + id: id as u32, + c: color.or_else(|| Some(fallback_color(*agent_state))), + b: Some(brightness), + e: Some(effect_for(*agent_state) as u8), + s: speed_for(*agent_state), + sk: if focused { Some(1) } else { None }, + sa: if focused { Some(1) } else { None }, + }, + } + }) + .collect() +} + +fn effect_for(state: AgentState) -> LightingEffect { + match state { + AgentState::Idle | AgentState::Done => LightingEffect::Solid, + AgentState::Thinking => LightingEffect::ShallowBreath, + AgentState::Working => LightingEffect::Solid, + AgentState::AwaitingApproval => LightingEffect::Breath, + AgentState::Error => LightingEffect::Solid, + } +} + +fn speed_for(state: AgentState) -> Option { + match state { + AgentState::Thinking | AgentState::AwaitingApproval => Some(0.55), + _ => None, + } +} + +fn fallback_color(state: AgentState) -> u32 { + // Codex preset defaults from `StateColors::codex` when the daemon omits RGB. + match state { + AgentState::Idle => 0xE9E9E6, + AgentState::Thinking | AgentState::Working => 0x3D7EFF, + AgentState::AwaitingApproval => 0xFFB000, + AgentState::Done => 0x30C463, + AgentState::Error => 0xFF453A, + } +} + +/// Parse `#RRGGBB` / `RRGGBB` into a packed RGB integer for the device. +pub fn parse_rgb_hex(s: &str) -> Option { + let hex = s.trim().trim_start_matches('#'); + if hex.len() != 6 { + return None; + } + u32::from_str_radix(hex, 16).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use mb_protocol::AgentState; + use serde_json::Value; + + #[test] + fn packs_six_threads() { + let mut frame = LedFrame { + brightness: 80, + ..LedFrame::default() + }; + frame.keys[0] = Some(AgentState::Working); + frame.keys[1] = Some(AgentState::AwaitingApproval); + frame.key_colors[0] = Some(0x112233); + frame.focus_index = Some(0); + + let req = threads_lighting_rpc(&frame, 7); + let v: Value = serde_json::from_str(&req).unwrap(); + assert_eq!(v["params"].as_array().unwrap().len(), 6); + assert_eq!(v["params"][0]["c"], 0x112233); + assert_eq!(v["params"][0]["sk"], 1); + assert_eq!(v["params"][1]["e"], LightingEffect::Breath as u8); + assert_eq!(v["params"][2]["e"], LightingEffect::Off as u8); + } + + #[test] + fn parse_rgb_hex_works() { + assert_eq!(parse_rgb_hex("#3D7EFF"), Some(0x3D7EFF)); + assert_eq!(parse_rgb_hex("ff453a"), Some(0xFF453A)); + } +} diff --git a/crates/mb-device/src/probe.rs b/crates/mb-device/src/probe.rs new file mode 100644 index 0000000..9ab2cdf --- /dev/null +++ b/crates/mb-device/src/probe.rs @@ -0,0 +1,164 @@ +//! Best-effort USB presence probe — does not claim the HID interface. + +use crate::ids::{is_supported_pid, CODEX_MICRO_PID, WL_VID}; + +/// Result of a non-claiming USB probe. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProbeResult { + pub present: bool, + /// Best-effort product id when parsed from the host USB listing. + pub product_id: Option, +} + +impl ProbeResult { + pub fn absent() -> Self { + Self { + present: false, + product_id: None, + } + } +} + +/// Probe for a supported Work Louder / Codex Micro USB device. +pub fn probe_usb_micro() -> ProbeResult { + #[cfg(target_os = "macos")] + { + match system_profiler_usb_text(std::time::Duration::from_secs(3)) { + Some(text) => match_usb_text(&text), + None => ProbeResult::absent(), + } + } + #[cfg(not(target_os = "macos"))] + { + ProbeResult::absent() + } +} + +/// Match `system_profiler SPUSBDataType` (or similar) text against known IDs. +pub fn match_usb_text(raw: &str) -> ProbeResult { + let lower = raw.to_ascii_lowercase(); + + // Prefer explicit VID/PID pairs in the same USB record block. + for block in lower.split("\n\n") { + if let Some(pid) = block_matching_pid(block) { + return ProbeResult { + present: true, + product_id: Some(pid), + }; + } + } + + // Fallback: product name tokens (pre-VID listings / BT advertising names). + if lower.contains("codex micro") + || lower.split("\n\n").any(|block| { + block.contains("work louder") && (block.contains("codex") || block.contains("kbd-1.0")) + }) + { + return ProbeResult { + present: true, + product_id: Some(CODEX_MICRO_PID), + }; + } + + ProbeResult::absent() +} + +fn block_matching_pid(block: &str) -> Option { + let vid = parse_id_field(block, "vendor id")?; + if vid != WL_VID { + return None; + } + let pid = parse_id_field(block, "product id")?; + is_supported_pid(pid).then_some(pid) +} + +fn parse_id_field(block: &str, label: &str) -> Option { + let prefix = format!("{label}:"); + for line in block.lines() { + let ll = line.trim().to_ascii_lowercase(); + if let Some(rest) = ll.strip_prefix(&prefix) { + return parse_hex_id(rest.trim()); + } + } + None +} + +fn parse_hex_id(raw: &str) -> Option { + // Formats: "0x8360", "0x8360 (codex micro)", "8360" + let token = raw.split_whitespace().next()?.trim(); + let hex = token.strip_prefix("0x").unwrap_or(token); + u16::from_str_radix(hex, 16).ok() +} + +#[cfg(target_os = "macos")] +fn system_profiler_usb_text(timeout: std::time::Duration) -> Option { + use std::io::Read; + use std::process::{Command, Stdio}; + use std::thread; + use std::time::Instant; + + let mut child = Command::new("system_profiler") + .args(["SPUSBDataType", "-detailLevel", "mini"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + let deadline = Instant::now() + timeout; + loop { + match child.try_wait() { + Ok(Some(status)) => { + if !status.success() { + return None; + } + let mut buf = Vec::new(); + if let Some(mut out) = child.stdout.take() { + let _ = out.read_to_end(&mut buf); + } + return Some(String::from_utf8_lossy(&buf).into_owned()); + } + Ok(None) if Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + Ok(None) => thread::sleep(std::time::Duration::from_millis(50)), + Err(_) => return None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matches_vid_pid_block() { + let sample = r#" +Codex Micro: + + Product ID: 0x8360 + Vendor ID: 0x303a + Manufacturer: Work Louder +"#; + let r = match_usb_text(sample); + assert!(r.present); + assert_eq!(r.product_id, Some(CODEX_MICRO_PID)); + } + + #[test] + fn ignores_unrelated_espressif() { + let sample = r#" +ESP Serial: + + Product ID: 0x1001 + Vendor ID: 0x303a +"#; + assert!(!match_usb_text(sample).present); + } + + #[test] + fn matches_name_fallback() { + let r = match_usb_text("Something Codex Micro attached"); + assert!(r.present); + } +} diff --git a/crates/mb-device/src/rpc.rs b/crates/mb-device/src/rpc.rs new file mode 100644 index 0000000..86790ab --- /dev/null +++ b/crates/mb-device/src/rpc.rs @@ -0,0 +1,156 @@ +//! Compact JSON-RPC helpers matching Work Louder / OAI device firmware. +//! +//! Requests are `{ method, params, id }` (no `jsonrpc: "2.0"` field). +//! IDs must stay in `0..999` per firmware limits in `wl-device-kit`. + +use serde::Serialize; +use serde_json::{json, Value}; + +/// Per-thread accent lighting (Agent Key / thread LEDs). +pub const METHOD_THREADS_LIGHTING: &str = "v.oai.thstatus"; + +/// Keys + ambient ring lighting config. +pub const METHOD_RGB_CONFIG: &str = "v.oai.rgbcfg"; + +/// Device → host: custom HID key event. +pub const NOTIFY_HID: &str = "v.oai.hid"; + +/// Device → host: joystick / radial pad. +pub const NOTIFY_JOYSTICK: &str = "v.oai.rad"; + +/// Built-in LED animation effects (`OAILightingEffect`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum LightingEffect { + Off = 0, + Solid = 1, + Snake = 2, + Rainbow = 3, + Breath = 4, + Gradient = 5, + ShallowBreath = 6, +} + +/// Minimized per-thread lighting entry (`sendThreadsLighting`). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct ThreadLightingParam { + pub id: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub c: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub b: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub e: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub s: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sk: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sa: Option, +} + +/// Build a JSON-RPC request body (UTF-8). `id` is clamped to `0..999`. +pub fn build_request(method: &str, params: Value, id: u32) -> String { + let id = id % 999; + serde_json::to_string(&json!({ + "method": method, + "params": params, + "id": id, + })) + .expect("json request serialization") +} + +/// Build a `v.oai.thstatus` request from minimized thread entries. +pub fn threads_lighting_request(threads: &[ThreadLightingParam], id: u32) -> String { + let params = serde_json::to_value(threads).expect("thread params"); + build_request(METHOD_THREADS_LIGHTING, params, id) +} + +/// Parsed device → host notification. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DeviceNotify { + Hid { + key: String, + act: Option, + agent: Option, + }, + Joystick { + angle: Option, + distance: Option, + }, + Other { + method: String, + }, +} + +/// Parse a complete JSON notification / response line from the device. +pub fn parse_notify(line: &str) -> Option { + let value: Value = serde_json::from_str(line.trim()).ok()?; + // Responses have `id`; notifications have `method`/`m` only. + if value.get("id").is_some() || value.get("i").is_some() { + return None; + } + let method = value + .get("method") + .or_else(|| value.get("m")) + .and_then(|v| v.as_str())?; + let params = value.get("params").cloned().unwrap_or(Value::Null); + match method { + NOTIFY_HID => { + let key = params.get("k")?.as_str()?.to_string(); + Some(DeviceNotify::Hid { + key, + act: params.get("act").and_then(|v| v.as_i64()), + agent: params.get("ag").and_then(|v| v.as_i64()), + }) + } + NOTIFY_JOYSTICK => Some(DeviceNotify::Joystick { + angle: params.get("a").and_then(|v| v.as_i64()), + distance: params.get("d").and_then(|v| v.as_i64()), + }), + other => Some(DeviceNotify::Other { + method: other.to_string(), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn threads_request_shape() { + let req = threads_lighting_request( + &[ThreadLightingParam { + id: 0, + c: Some(0x3D7EFF), + b: Some(0.8), + e: Some(LightingEffect::Solid as u8), + s: None, + sk: Some(1), + sa: None, + }], + 42, + ); + let v: Value = serde_json::from_str(&req).unwrap(); + assert_eq!(v["method"], METHOD_THREADS_LIGHTING); + assert_eq!(v["id"], 42); + assert_eq!(v["params"][0]["id"], 0); + assert_eq!(v["params"][0]["c"], 0x3D7EFF); + assert!(v.get("jsonrpc").is_none()); + } + + #[test] + fn parses_hid_notify() { + let n = parse_notify(r#"{"method":"v.oai.hid","params":{"k":"agent1","act":1,"ag":0}}"#) + .unwrap(); + assert_eq!( + n, + DeviceNotify::Hid { + key: "agent1".into(), + act: Some(1), + agent: Some(0), + } + ); + } +} diff --git a/crates/microbridgectl/Cargo.toml b/crates/microbridgectl/Cargo.toml index 35c03e6..189f110 100644 --- a/crates/microbridgectl/Cargo.toml +++ b/crates/microbridgectl/Cargo.toml @@ -10,7 +10,13 @@ repository.workspace = true name = "microbridgectl" path = "src/main.rs" +[features] +default = ["hid"] +## Enables the `hid-capture` command (pulls in `mb-device` + `hidapi`). +hid = ["dep:mb-device", "mb-device/hid"] + [dependencies] mb-protocol = { path = "../mb-protocol" } +mb-device = { path = "../mb-device", optional = true } serde_json = { workspace = true } tokio = { workspace = true } diff --git a/crates/microbridgectl/src/main.rs b/crates/microbridgectl/src/main.rs index f508deb..d26b20a 100644 --- a/crates/microbridgectl/src/main.rs +++ b/crates/microbridgectl/src/main.rs @@ -34,19 +34,61 @@ async fn main() -> ExitCode { ExitCode::FAILURE } }, + "hid-capture" => run_hid_capture(args.next()), "help" | "-h" | "--help" => { - println!("Usage: microbridgectl [status]"); - println!(" status print the live bus snapshot as JSON (default)"); + print_usage(); ExitCode::SUCCESS } other => { eprintln!("unknown command: {other}"); - eprintln!("Usage: microbridgectl [status]"); + print_usage(); ExitCode::FAILURE } } } +fn print_usage() { + println!("Usage: microbridgectl [status | hid-capture [seconds]]"); + println!(" status print the live bus snapshot as JSON (default)"); + println!(" hid-capture [seconds] observe raw Codex Micro key/dial/joystick events"); + println!(" (default 120s; needs the `hid` feature + a device)"); +} + +/// Stream decoded device→host HID events for hardware bring-up. +/// See docs/hardware-bringup.md. +fn run_hid_capture(seconds_arg: Option) -> ExitCode { + let seconds = seconds_arg.as_deref().map(|s| s.parse::()).transpose(); + let seconds = match seconds { + Ok(value) => value.unwrap_or(120), + Err(_) => { + eprintln!( + "microbridgectl hid-capture: seconds must be a whole number (0 = until Ctrl-C)" + ); + return ExitCode::FAILURE; + } + }; + + #[cfg(feature = "hid")] + { + match mb_device::run_capture(seconds) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("microbridgectl hid-capture: {error}"); + eprintln!("no Codex Micro detected? plug it in, or quit the app that owns it."); + ExitCode::FAILURE + } + } + } + #[cfg(not(feature = "hid"))] + { + let _ = seconds; + eprintln!( + "hid-capture needs the `hid` feature: cargo run -p microbridgectl --features hid -- hid-capture" + ); + ExitCode::FAILURE + } +} + async fn fetch_snapshot() -> Result { let path = socket_path(); let stream = UnixStream::connect(&path) diff --git a/crates/microbridged/src/state.rs b/crates/microbridged/src/state.rs index d415136..3375422 100644 --- a/crates/microbridged/src/state.rs +++ b/crates/microbridged/src/state.rs @@ -4,9 +4,10 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; -use mb_device::{Device, LedFrame}; +use mb_device::{parse_rgb_hex, Device, LedFrame}; use mb_protocol::{ - Action, BusEvent, DaemonConfig, ServerMessage, SessionStatus, Snapshot, AGENT_KEY_COUNT, + Action, AgentState, BusEvent, DaemonConfig, ServerMessage, SessionStatus, Snapshot, + AGENT_KEY_COUNT, }; use tokio::sync::{mpsc, Mutex}; use tracing::{info, warn}; @@ -130,15 +131,18 @@ impl DaemonState { pub fn render_leds(&mut self, keys: &[Option; AGENT_KEY_COUNT]) { let mut frame = LedFrame { keys: [None; AGENT_KEY_COUNT], + key_colors: [None; AGENT_KEY_COUNT], focus_index: None, brightness: self.config.brightness, paused: self.config.pause_leds, }; for (i, id) in keys.iter().enumerate() { - frame.keys[i] = id + let state = id .as_ref() .and_then(|sid| self.registry.sessions.get(sid)) .map(|s| s.state); + frame.keys[i] = state; + frame.key_colors[i] = state.and_then(|s| color_for_state(&self.config, s)); if id.as_ref() == self.registry.focused.as_ref() { frame.focus_index = Some(i); } @@ -206,3 +210,15 @@ impl DaemonState { } } } + +fn color_for_state(config: &DaemonConfig, state: AgentState) -> Option { + let hex = match state { + AgentState::Idle => &config.state_colors.idle, + AgentState::Thinking => &config.state_colors.thinking, + AgentState::Working => &config.state_colors.working, + AgentState::AwaitingApproval => &config.state_colors.awaiting_approval, + AgentState::Done => &config.state_colors.done, + AgentState::Error => &config.state_colors.error, + }; + parse_rgb_hex(hex) +} diff --git a/docs/device-hid.md b/docs/device-hid.md index d4d5d06..03b6316 100644 --- a/docs/device-hid.md +++ b/docs/device-hid.md @@ -8,13 +8,106 @@ Firmware changes may invalidate this document — treat it as a living map. | Capability | Status | |---|---| -| USB open / claim | presence probe on macOS (`system_profiler`); claim deferred until VID/PID + report map confirmed | -| LED frames (6 Agent Keys) | mock logs frames; HID packing TBD | -| Key / dial / joystick input | trait defined (`DeviceInput`); no live reports yet | +| USB identity (VID/PID) | **known** — from ChatGPT Desktop `@worklouder/wl-device-kit` | +| USB presence probe | macOS `system_profiler` matches VID `0x303A` + known PIDs (Detected) | +| HID claim / LED write | opt-in: build with `hid` feature (default) + `MICROBRIDGE_HID_CLAIM=1` | +| LED frames (6 Agent Keys) | packed as JSON-RPC `v.oai.thstatus` over framed HID reports | +| Key / dial / joystick input | notify parsers ready (`v.oai.hid` / `v.oai.rad`); key id map TBD on hardware | | Bluetooth | out of scope for M2 (USB-first) | Without a claimed device the daemon uses [`MockDevice`](../crates/mb-device/src/lib.rs) -so CI and headless installs stay green. +so CI and headless installs stay green. Detected-but-unclaimed USB still shows +in the UI as **Detected** (not Connected). + +## Source of truth (no hardware required) + +ChatGPT macOS app ships the protocol stack: + +```text +/Applications/ChatGPT.app/Contents/Resources/app.asar + → node_modules/@worklouder/device-kit-oai + → node_modules/@worklouder/wl-device-kit +``` + +Useful symbols mined from that kit: + +- `WL_VID`, `DEVICE_REGISTRY`, `WL_MANUFACTURER` +- HID framing in `WLDeviceCommImpl.sendDataHID` / `parseHIDReport` +- OAI RPC in `RPCApiOAI` (`v.oai.thstatus`, `v.oai.rgbcfg`, notify keys) + +## USB identity + +| Field | Value | +|---|---| +| Vendor ID | `0x303A` (Espressif / Work Louder) | +| Manufacturer | `Work Louder` / `Work_Louder` | +| Codex Micro PID | `0x8360` (`project_2077`) | +| Creator Micro V2 PIDs | `0x8297`, `0x8298` | +| Vendor HID usage page | `0xFF00` | + +Microbridge treats Codex Micro + Creator Micro V2 PIDs as supported. + +## HID report framing + +64-byte interrupt reports on the vendor usage page: + +| Offset | Field | +|---|---| +| 0 | Report ID `0x06` | +| 1 | Channel: `1` = debug log, `2` = JSON-RPC | +| 2 | Payload length `0..=61` | +| 3… | UTF-8 payload | + +Messages longer than 61 bytes are split across multiple reports. ChatGPT opens +the interface with **non-exclusive** access on macOS; Microbridge does the same +when claiming (`hidapi` `set_open_exclusive(false)`). + +## JSON-RPC (Work Louder compact) + +Requests are **not** JSON-RPC 2.0 envelopes — just: + +```json +{"method":"v.oai.thstatus","params":[...],"id":42} +``` + +- `id` must be in `0..999` (firmware constraint) +- Responses carry `id`; notifications omit `id` and set `method` / `m` + +### OAI methods Microbridge uses + +| Method | Direction | Purpose | +|---|---|---| +| `v.oai.thstatus` | host → device | Per-thread / Agent Key lighting | +| `v.oai.rgbcfg` | host → device | Keys + ambient ring config (reserved) | +| `v.oai.hid` | device → host | Key events (`params.k`, `act`, `ag`) | +| `v.oai.rad` | device → host | Joystick (`params.a` angle, `d` distance) | + +### Thread lighting params (minimized) + +| Field | Meaning | +|---|---| +| `id` | Thread / Agent Key index | +| `c` | Packed RGB integer | +| `b` | Brightness `0.0`–`1.0` | +| `e` | Effect enum (0 off … 6 shallowBreath) | +| `s` | Effect speed `0.0`–`1.0` | +| `sk` / `sa` | Sync keys / ambient to this thread (`1` / `0`) | + +Effects: `off=0`, `solid=1`, `snake=2`, `rainbow=3`, `breath=4`, +`gradient=5`, `shallowBreath=6`. + +## Claiming the device + +Default daemon behavior: **probe only** (Detected). To write LEDs: + +```bash +export MICROBRIDGE_HID_CLAIM=1 +# quit or pause ChatGPT Desktop Agent Key ownership if LEDs fight +microbridged +``` + +Build flag: `mb-device` feature `hid` (on by default). Disable with +`--no-default-features` if you need a hidapi-free binary. ## Hardware (product facts) @@ -23,22 +116,23 @@ so CI and headless installs stay green. - USB-C and BLE; Microbridge M2 targets USB only - Also configurable via Work Louder Input / VIA for non-agent layers -## Probe checklist (when hardware is available) +## Remaining validation (needs hardware) -1. `system_profiler SPUSBDataType` / `lsusb` — record VID/PID/iProduct -2. Capture HID report descriptor (`hidutil` / Wireshark USBPcap / `usbhid-dump`) -3. Observe ChatGPT desktop LED traffic while forcing each `AgentState` -4. Map report IDs → Agent Key RGB slots and command key bitfields -5. Document double-press window (ChatGPT uses ≤350ms) for Agent Keys +Run the [hardware bring-up runbook](hardware-bringup.md) — it drives each of +these with exact commands. `microbridgectl hid-capture` harvests item 2 in one +pass. -Until those captures land, `HidDevice::open()` never claims the interface — -we refuse to guess report layouts that could fight ChatGPT desktop. +1. Confirm live PID/iProduct string on the shipping Codex Micro unit +2. Map `v.oai.hid` key strings → Agent Key indices / Approve / Reject / etc. +3. Confirm double-press window (ChatGPT uses ≤350ms) for Agent Keys +4. Tune effect/speed mapping vs ChatGPT Desktop visuals +5. Ownership UX when ChatGPT Desktop and Microbridge both want LEDs ## Exclusive ownership Only one process should drive Agent Key LEDs. If ChatGPT desktop is open and -owning the Micro, pause Microbridge LEDs (Settings → Pause LEDs) or quit the -desktop bridge. The companion empty state should mention this. +owning the Micro, pause Microbridge LEDs (Settings → Pause LEDs), leave claim +off, or quit the desktop bridge. The companion empty state should mention this. ## Descriptor-driven layout diff --git a/docs/hardware-bringup.md b/docs/hardware-bringup.md new file mode 100644 index 0000000..37c634d --- /dev/null +++ b/docs/hardware-bringup.md @@ -0,0 +1,135 @@ +# Codex Micro hardware bring-up runbook + +Everything that can be done without the device is already done (see +[`device-hid.md`](device-hid.md)). This runbook is the **day-one checklist for +when a physical Codex Micro is in hand** — it turns the "needs hardware" list +into a ~15-minute session that confirms detection, harvests the real input map, +and validates LED output. + +Do the steps in order. Each one has an exact command and a "pass" condition. + +## 0. Prep + +- Quit **ChatGPT Desktop** (or pause its Agent Key ownership) so it isn't + fighting for the interface during capture. +- If `microbridged` is running with a live claim, stop it for the capture step: + it only needs to be running for the LED step (§4). +- Plug the Micro in over **USB-C** (BLE is out of scope for M2). + +## 1. Confirm detection + +```sh +system_profiler SPUSBDataType -detailLevel mini | grep -iA3 "work louder\|codex\|0x303a" +``` + +**Pass:** a record with `Vendor ID: 0x303a` and `Product ID: 0x8360` +(Codex Micro) or `0x8297` / `0x8298` (Creator Micro V2). + +Then confirm the daemon sees it: + +```sh +cargo run -p microbridged # in one shell +cargo run -p microbridgectl status # in another — device should show "Detected" +``` + +**Pass:** the snapshot reports the Micro as **Detected** (not `mock`). +Record the real `iProduct` string and confirmed PID: + +| Field | Documented | Observed | +|---|---|---| +| Product ID | `0x8360` | | +| iProduct string | _(unknown)_ | | +| Manufacturer | `Work Louder` | | + +## 2. Capture the real input map (the important one) + +```sh +cargo run -p microbridgectl --features hid -- hid-capture 120 +``` + +Then, while it runs, **press each control once, slowly**, in this order: + +1. Each of the 6 Agent Keys, left→right, top→bottom +2. Approve, Reject, Interrupt (whatever the deck exposes) +3. New session / cycle focus keys +4. Rotate the dial each way, then press it +5. Flick the joystick up / down / left / right + +The tool prints every `v.oai.hid` and `v.oai.rad` event live and, on exit, +a summary of the distinct key strings it saw. Fill this in from that summary: + +| Physical control | Observed `k` | `act` | `ag` | → Microbridge action | +|---|---|---|---|---| +| Agent Key 1 | | | | `AgentKeyPress { index: 0 }` | +| Agent Key 2 | | | | `AgentKeyPress { index: 1 }` | +| Agent Key 3 | | | | `AgentKeyPress { index: 2 }` | +| Agent Key 4 | | | | `AgentKeyPress { index: 3 }` | +| Agent Key 5 | | | | `AgentKeyPress { index: 4 }` | +| Agent Key 6 | | | | `AgentKeyPress { index: 5 }` | +| Approve | | | | `Approve` | +| Reject | | | | `Reject` | +| Interrupt | | | | `Interrupt` | +| New session | | | | `NewSession` | +| Cycle focus | | | | `CycleFocus` | +| Dial rotate + / − | (`v.oai.rad`?) | | | `DialRotate` | +| Dial press | | | | `DialPress` | +| Joystick up/down/left/right | (`v.oai.rad` angle) | | | `JoystickFlick` | + +Also note the **double-press window** for Agent Keys: press one key twice +quickly and confirm the timing threshold (ChatGPT Desktop uses ≤ 350 ms). + +## 3. Apply the map in code + +With the observed strings in hand, update: + +- `agent_key_index` in [`crates/mb-device/src/lib.rs`](../crates/mb-device/src/lib.rs) + if the real Agent Key strings differ from the current guess. +- `notify_to_input` in the same file to route Approve / Reject / Interrupt / + New session / Cycle focus / dial press from their real `k` strings. +- The characterization tests in that file's `mod tests` — replace the guessed + expectations with the confirmed strings so the map is locked by CI. + +At that point, wire `device.poll_input()` into the daemon loop in +[`crates/microbridged/src/main.rs`](../crates/microbridged/src/main.rs) (a +dedicated blocking-read task feeding an mpsc channel of `DeviceInput`, so idle +CPU stays at zero). This step is intentionally left until the map is real — +routing guessed strings would just ship a broken deck. + +## 4. Validate LED output + +```sh +export MICROBRIDGE_HID_CLAIM=1 +cargo run -p microbridged +# drive a session through its states (or run the reference echo adapter): +node adapters/reference-echo/index.mjs +``` + +**Pass:** the six frosted Agent Keys light in the Codex state palette and change +on state transitions; the focused key is distinguishable. + +Check against ChatGPT Desktop's own visuals and tune if needed: + +- effect / speed mapping (`e`, `s` in `v.oai.thstatus`) +- brightness (`b`) at the daemon's default vs. ChatGPT's +- packed color correctness (`c` = `0xRRGGBB`) + +## 5. Ownership UX vs ChatGPT Desktop + +Confirm the coexistence story with both running: + +- With `MICROBRIDGE_HID_CLAIM=1` **and** ChatGPT Desktop open, do the LEDs + fight? Document the winner and the recovery (Settings → Pause LEDs). +- Confirm non-exclusive open (macOS `set_open_exclusive(false)`) actually lets + both read without erroring. + +## Done criteria + +- [ ] Detection confirmed with real PID + iProduct string (§1) +- [ ] Full input map captured and recorded (§2) +- [ ] Map applied to `agent_key_index` / `notify_to_input` + tests (§3) +- [ ] `poll_input` wired into the daemon loop (§3) +- [ ] LEDs render correct colors on transitions (§4) +- [ ] Ownership vs ChatGPT Desktop documented (§5) + +When these are checked, cut a hardware-capable release (`v0.2.0`) and update the +status table in [`device-hid.md`](device-hid.md).