diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d47187..f53e8cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,9 +24,26 @@ jobs: with: components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 + - name: Assert Windows MSVC host + if: runner.os == 'Windows' + shell: pwsh + run: | + $info = rustc -vV | Out-String + if ($info -notmatch 'host: x86_64-pc-windows-msvc') { + throw "expected host x86_64-pc-windows-msvc, got:`n$info" + } - run: cargo fmt --all -- --check - run: cargo clippy --workspace --all-targets --all-features -- -D warnings - run: cargo test --workspace --all-features + - name: bosun-hid has no Tokio + if: runner.os == 'Linux' + run: | + if cargo tree -p bosun-hid | grep -qi tokio; then + echo "tokio must not appear in bosun-hid" >&2 + exit 1 + fi + - name: Hardware tests skip without BOSUN_HW + run: cargo test -p bosun-hid --test hardware -- --ignored --nocapture supply-chain: runs-on: ubuntu-latest diff --git a/.scratch/m1-software/issues/01-descriptor-load.md b/.scratch/m1-software/issues/01-descriptor-load.md new file mode 100644 index 0000000..dc06ec0 --- /dev/null +++ b/.scratch/m1-software/issues/01-descriptor-load.md @@ -0,0 +1,11 @@ +# 01: Load and reject G13 descriptors + +**What to build:** The committed G13 TOML loads as a descriptor with 34 literal key names, a match block, axes, screen, rgb, leds, and ignored key bits. Missing fields, wrong types, and range syntax return `Err` and never panic. + +**Blocked by:** None (can start immediately) + +**Status:** ready-for-agent + +- [ ] Valid `devices/logitech-g13.toml` loads with 34 keys and the required blocks (AC-5) +- [ ] Missing field, wrong type, and `G1..=G22`-style range syntax each return `Err` +- [ ] G13 VID/PID literals are not compiled into non-test `bosun-hid` policy diff --git a/.scratch/m1-software/issues/02-codec-and-goldens.md b/.scratch/m1-software/issues/02-codec-and-goldens.md new file mode 100644 index 0000000..df37845 --- /dev/null +++ b/.scratch/m1-software/issues/02-codec-and-goldens.md @@ -0,0 +1,13 @@ +# 02: Decode edges from golden reports + +**What to build:** An 8-byte report ID `0x01` decodes to named key downs/ups and 0–255 axis values. Bits 22, 23, and 36–39 emit no key event. Committed goldens cover every named control plus stick center, cardinals, corners, and diagonals. Physical LEFT/DOWN/TOP mapping stays pending. + +**Blocked by:** 01 + +**Status:** ready-for-agent + +- [ ] Bits `0..=21` and `24..=35` map to the 34 descriptor names; pressed = 1 (AC-6) +- [ ] Events are edges against the previous report +- [ ] Flipping each ignored bit yields an empty event stream +- [ ] Fixture files exist and drive the decode tests (AC-10) +- [ ] `docs/hardware-notes.md` still says LEFT/DOWN/TOP are pending diff --git a/.scratch/m1-software/issues/03-cli-info-and-outputs.md b/.scratch/m1-software/issues/03-cli-info-and-outputs.md new file mode 100644 index 0000000..57cdf47 --- /dev/null +++ b/.scratch/m1-software/issues/03-cli-info-and-outputs.md @@ -0,0 +1,13 @@ +# 03: info, rgb, leds, and lcd test over mock + +**What to build:** `bosunctl device info` prints the loaded descriptor. `rgb`, `leds`, and `lcd test` send the measured reports over a transport. Mock tests assert exact bytes. No command writes feature report 6. + +**Blocked by:** 01, 02 + +**Status:** ready-for-agent + +- [ ] `info` prints id, match, 34 keys, axes, screen, rgb, and leds (AC-7 software path) +- [ ] `rgb R G B` sends feature report 7 and reads it back (AC-8) +- [ ] `leds MASK` sends feature report 5 (AC-8) +- [ ] `lcd test` sends one 992-byte report 3: byte 0 = `0x03`, bytes 1–31 zero, rows 43–47 zero (AC-8) +- [ ] Across the CLI surface, no feature write has report ID 6 diff --git a/.scratch/m1-software/issues/04-watch-record-reconnect.md b/.scratch/m1-software/issues/04-watch-record-reconnect.md new file mode 100644 index 0000000..672a4d0 --- /dev/null +++ b/.scratch/m1-software/issues/04-watch-record-reconnect.md @@ -0,0 +1,12 @@ +# 04: watch, record, and mock reconnect + +**What to build:** `watch` prints decoded named key and axis events over `MockTransport`. `record` writes raw 8-byte reports that decode back to the same events. After a scripted disconnect, watch prints one reconnect line and resumes within 2 s of mock time. + +**Blocked by:** 02, 03 + +**Status:** ready-for-agent + +- [ ] `watch` over mock prints the expected named events (AC-7) +- [ ] `record` output round-trips through the decoder (AC-7) +- [ ] Scripted disconnect then reports: one reconnect line, events resume, elapsed ≤ 2 s (AC-9) +- [ ] Existing `device list` filters still pass (AC-2) diff --git a/.scratch/m1-software/issues/05-gates-and-hygiene.md b/.scratch/m1-software/issues/05-gates-and-hygiene.md new file mode 100644 index 0000000..71a6227 --- /dev/null +++ b/.scratch/m1-software/issues/05-gates-and-hygiene.md @@ -0,0 +1,15 @@ +# 05: CI gates, license hygiene, Windows MSVC + +**What to build:** Format, clippy with warnings denied, workspace tests, and cargo-deny are green without `BOSUN_HW=1`. Hardware tests stay ignored and skip unless that gate is set. Windows CI asserts the MSVC host triple. No GPL identifiers. + +**Blocked by:** 01, 02, 03, 04 + +**Status:** ready-for-agent + +- [ ] `cargo fmt --all -- --check` (AC-11) +- [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` (AC-11) +- [ ] `cargo test --workspace --all-features` (AC-11) +- [ ] `cargo deny check` (AC-12) +- [ ] Ignored hardware tests skip with a clear message unless `BOSUN_HW=1` +- [ ] Windows MSVC expectation documented; `windows-latest` asserts `x86_64-pc-windows-msvc` (AC-13) +- [ ] `cargo tree -p bosun-hid` has no Tokio (AC-3) diff --git a/.scratch/m1-software/plan.md b/.scratch/m1-software/plan.md new file mode 100644 index 0000000..3d391f3 --- /dev/null +++ b/.scratch/m1-software/plan.md @@ -0,0 +1,49 @@ +# Plan — M1 remaining SOFTWARE ACs (mockable) + +Scope is locked to mockable paths. This plan does **not** unpark AC-R1, engine, adapters, Tauri, or live finger-on-key work. + +## Files / modules + +### Add + +- `bosun-hid` descriptor loader (generic TOML types + validation) +- `bosun-hid` codec (input edge events, LCD/RGB/LED report builders) +- `crates/bosun-hid/tests/fixtures/g13/` golden 8-byte reports +- `bosunctl` library surface so commands run over `impl Transport` +- CLI subcommands: `info`, `watch`, `record`, `rgb`, `leds`, `lcd test` +- CONTRIBUTING + CI notes for Windows MSVC (AC-13) + +### Do not rewrite + +- `Transport`, `HidTransport`, `MockTransport` +- device match/select +- `g13_probe` +- `bosunctl device list` filters +- `crates/bosun-hid/tests/hardware.rs` (keep `#[ignore]` + `BOSUN_HW=1`) + +## Interfaces + +- `load_descriptor(path) -> Result` +- `DeviceDescriptor` exposes match, literal keys, axes, screen, rgb, leds, ignored key bits +- `Decoder::from_descriptor` + `decode(&[u8]) -> Result>` +- `rgb_feature_report` / `leds_feature_report` / `lcd_test_report` +- CLI commands accept an opener `FnMut() -> Result` so tests inject `MockTransport` +- `watch` reprints after `Disconnected` by re-opening; one `reconnect` line; no 2 s sleep in mock + +## Test list + +- AC-5: valid G13 file; missing field; wrong type; range syntax rejected; no panic +- AC-6: golden replay of 34 keys + stick envelope; ignored bits 22, 23, 36–39 emit no key events +- AC-7: `watch` prints named events over mock; `record` file round-trips through decoder +- AC-8: mock exact bytes for RGB (report 7), LEDs (report 5), LCD (992-byte report 3); no report-ID-6 write +- AC-9: scripted disconnect then reports → one reconnect line, events resume, elapsed ≤ 2 s +- AC-10: fixture files present and drive AC-6; hardware-notes LEFT/DOWN/TOP stay pending +- AC-11/12/13: fmt, clippy `-D warnings`, workspace tests, cargo-deny; ignored HW tests skip without `BOSUN_HW=1`; Windows MSVC documented/CI-asserted + +## Protocol source + +`docs/BOSUN-PLAN.md` §2.4 and `AGENTS.md` Measured G13 protocol only. + +## Stop condition + +If a ticket would require live hardware, physical LEFT/DOWN/TOP mapping, or any PARKED crate — stop. diff --git a/.scratch/m1-software/spec.md b/.scratch/m1-software/spec.md new file mode 100644 index 0000000..25e4e41 --- /dev/null +++ b/.scratch/m1-software/spec.md @@ -0,0 +1,83 @@ +# Spec: M1 remaining SOFTWARE acceptance criteria + +**Status:** ready-for-agent +**Parent:** Heavy Lift brief — Bosun M1 remaining SOFTWARE ACs (mockable paths) +**Authoritative ACs:** `docs/PRD.md` §5, software paths only + +## Problem Statement + +M1 already has a synchronous HID transport, a scripted mock, device match, and `bosunctl device list`. What is still missing is everything that turns those reports into named device behavior on CI: loading the G13 descriptor, decoding kept key bits into edge events, driving `info|watch|record|rgb|leds|lcd test` over `MockTransport`, recovering from a scripted disconnect, and committing golden fixtures — without touching live hardware or PARKED milestones. + +## Solution + +A generic descriptor loader and codec live in `bosun-hid` as data-driven types. `bosunctl` becomes a thin library so each device command can be exercised with `MockTransport`. Golden 8-byte reports, derived from the measured bitmap in `docs/BOSUN-PLAN.md` §2.4, drive decode tests. Physical LEFT/DOWN/TOP mapping and AC-R1 stay pending. + +## User Stories + +1. As a CI runner, I want descriptor load tests to pass without a G13 attached, so that AC-5 is checkable on every OS. +2. As a CI runner, I want a malformed descriptor to return `Err` rather than panic, so that bad device data cannot crash the process. +3. As a CI runner, I want range-syntax key lists rejected, so that only literal key arrays ship. +4. As an operator, I want the committed G13 descriptor to name 34 keys plus match, axes, screen, rgb, and leds, so that the device is data rather than compiled policy. +5. As a decoder caller, I want bits 0..=21 and 24..=35 mapped to those 34 names, so that a pressed bit becomes a named key. +6. As a decoder caller, I want down/up events from a diff against the previous report, so that watchers see edges rather than raw bitmaps. +7. As a decoder caller, I want toggling bits 22, 23, 36, 37, 38, and 39 to emit no key event, so that device-state flags stay silent. +8. As a decoder caller, I want stick bytes 1 and 2 as 0–255 axes, so that joystick motion is named and numeric. +9. As a CI runner, I want golden fixtures to replay through the decoder, so that AC-6 does not depend on a finger on a key. +10. As an operator, I want `bosunctl device info` to print the loaded descriptor, so that I can confirm match and capabilities before opening a handle. +11. As an operator, I want `bosunctl device watch` to print named key and axis events, so that I can see decoded input. +12. As a test, I want watch to run over `MockTransport`, so that AC-7 is mockable. +13. As an operator, I want `bosunctl device record` to write raw 8-byte reports to a file, so that later hardware captures have a stable format. +14. As a test, I want a recorded file to decode back to the same events, so that record round-trips. +15. As an operator, I want `bosunctl device rgb R G B` to send feature report 7 and read it back, so that backlight writes are exercisable. +16. As an operator, I want `bosunctl device leds MASK` to send feature report 5, so that M-key LEDs are exercisable. +17. As an operator, I want `bosunctl device lcd test` to send one 992-byte report 3 with the measured header and hidden rows zero, so that the LCD path is proven without widgets. +18. As a reviewer, I want no CLI command to write feature report 6, so that the LGS identity channel stays untouched. +19. As a test, I want mock writes compared to exact expected bytes, so that AC-8 does not need a glowing keypad. +20. As an operator, I want watch to print one reconnect line after a disconnect and then resume events, so that hot-plug recovery is visible. +21. As a test, I want that recovery to finish within 2 s on a scripted mock without sleeping, so that AC-9 is mockable. +22. As a reviewer, I want one raw fixture per named control plus stick center, cardinals, corners, and diagonals, so that AC-10 has committed goldens. +23. As Kyle, I want `docs/hardware-notes.md` to keep LEFT/DOWN/TOP as pending until I measure them, so that agents do not invent physical mapping. +24. As a CI runner, I want fmt, clippy `-D warnings`, workspace tests, and cargo-deny green without `BOSUN_HW=1`, so that AC-11 and AC-12 hold. +25. As a Windows operator, I want the MSVC host triple documented and asserted on `windows-latest`, so that AC-13 is a software path. +26. As a contributor, I want hardware tests to stay ignored and to skip unless `BOSUN_HW=1`, so that `--ignored` on a bare machine stays honest. +27. As a license reviewer, I want no GPL identifiers or dependencies, so that AC-12 holds. +28. As a maintainer, I want existing device-list and transport tests unchanged in behavior, so that AC-2, AC-3, and AC-4 do not regress. + +## Implementation Decisions + +- Descriptor and codec live in `bosun-hid`. Types are generic. G13 VID/PID literals stay in descriptor data, fixtures, and tests — not crate policy. +- The G13 TOML gains an `ignored_key_bits` list so the ignore set is data. Keys remain a literal array in listed order; the codec maps kept bits in ascending index onto that array. +- `keys.len() + ignored_key_bits.len()` must equal 40 (bits 0..=39). Duplicate names, empty names, and any key token containing `..` are rejected. +- `InputEvent` is `KeyDown`, `KeyUp`, or `Axis { name, value }`. The first report diffs against "no previous keys / no previous axes": pressed keys emit down; current axis values emit. +- LCD, RGB, and LED builders live next to the codec. RGB is `[0x07, r, g, b, 0x00]`. LEDs are `[0x05, mask, 0, 0, 0]`. LCD test is 992 bytes, byte 0 = `0x03`, bytes 1..=31 zero, a fixed visible-row pattern, rows 43..=47 zero. Packing uses `offset = col + (row >> 3) * 160` and `bit = 1 << (row & 7)`. +- `bosunctl` exposes a library API. Commands take a writer and, where I/O is needed, an opener that returns a `Transport`. Production `main` opens `HidTransport` from the descriptor match (or an explicit path). Tests inject `MockTransport`. +- `watch` treats `Disconnected` as "drop the handle and open again". It prints exactly one `reconnect` line per successful reopen after a drop. Reopen is synchronous; tests assert elapsed time ≤ 2 s with no sleep. +- `record` writes one report per line as eight space-separated lowercase hex bytes. That file is the round-trip fixture format. +- `info` prints the descriptor (id, match, keys, axes, screen, rgb, leds) without requiring a live handle. +- `--descriptor` defaults to `devices/logitech-g13.toml`. +- Feature report 6 has no builder and no CLI path. +- Golden fixtures are synthetic protocol reports under `crates/bosun-hid/tests/fixtures/g13/`. They do not claim physical thumb-button identity or a measured stick envelope. +- Hardware tests stay `#[ignore]` and keep the `BOSUN_HW=1` gate. CI on Windows asserts `x86_64-pc-windows-msvc`. CONTRIBUTING documents the rustup-vs-Chocolatey PATH rule. +- No new ADR: D11 and ADR-0001 already lock descriptor-driven HID. No Tokio. No engine, adapters, Tauri, or empty crates. + +## Testing Decisions + +- Test behavior at public seams: `load_descriptor` / `parse_descriptor`, `Decoder::decode`, CLI command functions, and `MockTransport` write logs. +- Expected bytes and event names come from `docs/BOSUN-PLAN.md` §2.4 and committed fixture files — not from re-encoding the implementation. +- Prefer existing test style: table-driven unit tests next to the module, CLI parse tests like the current `device list` tests. +- One dedicated test flips each ignored bit after a rest report and expects an empty event stream. +- One CLI-surface test inspects every command's mock feature writes and asserts none begin with `0x06`. +- Do not add a live-hardware pass/fail to `docs/hardware-notes.md`. + +## Out of Scope + +- AC-R1 live shared-input while LGS runs +- Physical LEFT/DOWN/TOP mapping and measured joystick envelope +- Engine, adapters, JSON-RPC, HTTP/WS, `bosund`, Tauri, profiles, LCD widgets +- libusb, WinUSB, Zadig, kexts, `EVIOCGRAB`, exclusive opens +- Reading or translating GPL G13 sources +- Merging the PR + +## Further Notes + +Wayfinder is not required: the hop is one session of mockable software. Existing transport and list command stay asserted, not rewritten. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..2625fdc --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,29 @@ +# Bosun + +Userspace HID ownership of a descriptor-driven command pad, starting with the Logitech G13. M1 is foundational transport, descriptor, codec, and CLI — not agent UX. + +## Language + +**Descriptor**: +A TOML document that names a device's match criteria, keys, axes, screen, RGB, and LEDs. The G13 is data in `devices/logitech-g13.toml`, not compiled crate policy. +_Avoid_: profile, driver, device definition + +**Match**: +The VID, PID, and usage page that identify one HID interface. Selection then opens the enumerated path. +_Avoid_: VID/PID-only identity, product name lookup + +**Codec**: +The mapping from an 8-byte input report onto named key and axis events, and the packing of LCD, RGB, and M-LED output reports. +_Avoid_: parser, translator, driver logic + +**Edge event**: +A key down, key up, or axis change produced by diffing the current report against the previous one. +_Avoid_: poll state, held-key stream, raw bitmap + +**Ignored bit**: +A bit in the 40-bit key field that is device state, not a user control. Toggling it emits no key event. +_Avoid_: mask (the incomplete Appendix B mask), filter byte + +**Transport**: +A synchronous, policy-free HID channel that moves reports. `MockTransport` scripts those reports for tests with no hardware. +_Avoid_: async HID, exclusive open, libusb backend diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7bd01e3..f91219c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,17 @@ cargo clippy --workspace --all-targets --all-features -- -D warnings cargo test --workspace --all-features ``` -New behavior follows RED-GREEN-REFACTOR. Hardware-only tests must be ignored by default and gated by `BOSUN_HW=1`. +New behavior follows RED-GREEN-REFACTOR. Hardware-only tests must be ignored by default and gated by `BOSUN_HW=1`. Running `cargo test -- --ignored` without that variable must skip with a clear message and pass. + +## Windows MSVC toolchain (AC-13) + +Hardware runs and `windows-latest` CI use the MSVC rustup toolchain. Before any live G13 session, check: + +```text +rustc -vV +``` + +The host line must be `x86_64-pc-windows-msvc`. On Kyle's workstation, `C:\Users\Kyle\.cargo\bin` must precede Chocolatey's Rust shims. If a GNU `cargo` wins PATH, use `rustup run stable cargo ...` instead. CI's `windows-latest` job asserts this host triple. ## Licensing and clean-room protocol diff --git a/README.md b/README.md index fa61bee..fd8bceb 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Bosun turns the Logitech G13 into a cross-platform command console for AI coding ## Status -Phase 0 is complete on live Windows hardware. The repository is prepared for **M1 only**: a synchronous HID transport, descriptor-driven G13 codec, mock transport, and `bosunctl device` commands. +Phase 0 is complete on live Windows hardware. M1 software paths are mockable: a synchronous HID transport, a G13 TOML descriptor and codec, `MockTransport`, and `bosunctl device list|info|watch|record|rgb|leds|lcd test`. Live shared-input (AC-R1) is still pending a finger-on-key run. ## Non-negotiable boundaries @@ -14,3 +14,17 @@ Phase 0 is complete on live Windows hardware. The repository is prepared for **M - Logitech Gaming Software may overwrite LCD/RGB output. The first M1 hardware test must determine whether shared input reads work while LGS is running. See `AGENTS.md`, `docs/PLAN-REVIEW.md`, and `docs/BOSUN-PLAN.md` before implementing. The product requirements, M1 acceptance criteria, and open owner decisions are in `docs/PRD.md`. + +## CLI + +Descriptor path defaults to `devices/logitech-g13.toml`. + +```text +bosunctl device list --vid 0x046D --pid 0xC21C --usage-page 0xFF00 +bosunctl device info +bosunctl device watch +bosunctl device record --output capture.hex +bosunctl device rgb 255 0 0 +bosunctl device leds 0x0F +bosunctl device lcd test +``` diff --git a/apps/bosunctl/src/lib.rs b/apps/bosunctl/src/lib.rs new file mode 100644 index 0000000..4760369 --- /dev/null +++ b/apps/bosunctl/src/lib.rs @@ -0,0 +1,777 @@ +//! `bosunctl` library surface so device commands can run over any [`Transport`]. + +mod listing; + +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{bail, Context, Result}; +use bosun_hid::{ + format_recorded_report, hidden_lcd_rows_are_zero, lcd_test_report, leds_feature_report, + load_descriptor, rgb_feature_report, Decoder, DeviceDescriptor, DeviceInfo, HidError, + HidTransport, InputEvent, ReadOutcome, Transport, LCD_REPORT_LEN, RGB_REPORT_ID, +}; +use clap::{Args, Parser, Subcommand}; + +use crate::listing::{accepts, format_device, parse_u16}; + +/// Default descriptor path relative to the process working directory. +pub const DEFAULT_DESCRIPTOR: &str = "devices/logitech-g13.toml"; + +#[derive(Debug, Parser)] +#[command(name = "bosunctl", version, about = "Bosun control CLI")] +pub struct Cli { + #[command(subcommand)] + pub command: Command, +} + +#[derive(Debug, Subcommand)] +pub enum Command { + /// Inspect and exercise HID devices. + Device { + #[command(subcommand)] + command: DeviceCommand, + }, +} + +#[derive(Debug, Subcommand)] +pub enum DeviceCommand { + /// List enumerated HID interfaces, optionally filtered. + List(ListArgs), + /// Print a loaded device descriptor. + Info(DescriptorArgs), + /// Print decoded key and axis edge events. + Watch(WatchArgs), + /// Write raw 8-byte input reports to a fixture file. + Record(RecordArgs), + /// Set the global RGB backlight and read it back. + Rgb(RgbArgs), + /// Set the M-key LED mask. + Leds(LedsArgs), + /// Send one LCD test frame. + Lcd { + #[command(subcommand)] + command: LcdCommand, + }, +} + +#[derive(Debug, Subcommand)] +pub enum LcdCommand { + /// Write a fixed visible-border frame (no widgets). + Test(DeviceIoArgs), +} + +#[derive(Debug, Args)] +pub struct ListArgs { + /// Vendor ID, decimal or 0x-prefixed hex. + #[arg(long, value_parser = parse_u16)] + pub vid: Option, + + /// Product ID, decimal or 0x-prefixed hex. + #[arg(long, value_parser = parse_u16)] + pub pid: Option, + + /// HID usage page, decimal or 0x-prefixed hex. + #[arg(long = "usage-page", value_parser = parse_u16)] + pub usage_page: Option, +} + +impl ListArgs { + fn selects(&self, info: &DeviceInfo) -> bool { + accepts(self.vid, info.vendor_id) + && accepts(self.pid, info.product_id) + && accepts(self.usage_page, info.usage_page) + } +} + +#[derive(Debug, Args)] +pub struct DescriptorArgs { + /// Path to a device TOML descriptor. + #[arg(long, default_value = DEFAULT_DESCRIPTOR)] + pub descriptor: PathBuf, +} + +#[derive(Debug, Args)] +pub struct DeviceIoArgs { + /// Path to a device TOML descriptor. + #[arg(long, default_value = DEFAULT_DESCRIPTOR)] + pub descriptor: PathBuf, + + /// Open this enumerated path instead of matching the descriptor. + #[arg(long)] + pub path: Option, +} + +#[derive(Debug, Args)] +pub struct WatchArgs { + #[command(flatten)] + pub io: DeviceIoArgs, + + /// Stop after this many decoded events (tests and finite captures). + #[arg(long, hide = true)] + pub max_events: Option, +} + +#[derive(Debug, Args)] +pub struct RecordArgs { + #[command(flatten)] + pub io: DeviceIoArgs, + + /// Fixture file to write. + #[arg(long, short)] + pub output: PathBuf, + + /// Stop after this many reports. + #[arg(long, hide = true)] + pub max_reports: Option, +} + +#[derive(Debug, Args)] +pub struct RgbArgs { + #[command(flatten)] + pub io: DeviceIoArgs, + pub red: u8, + pub green: u8, + pub blue: u8, +} + +#[derive(Debug, Args)] +pub struct LedsArgs { + #[command(flatten)] + pub io: DeviceIoArgs, + /// LED mask, decimal or 0x-prefixed hex. + #[arg(value_parser = parse_u8)] + pub mask: u8, +} + +/// Parse an 8-bit identifier written in decimal or `0x`-prefixed hex. +pub fn parse_u8(raw: &str) -> std::result::Result { + let text = raw.trim(); + let parsed = match text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) { + Some(hex) => u8::from_str_radix(hex, 16), + None => text.parse::(), + }; + parsed.map_err(|_| format!("expected a decimal or 0x-prefixed hex 8-bit value, got `{raw}`")) +} + +/// Limits for [`watch_events`] so tests do not block. +#[derive(Clone, Debug)] +pub struct WatchLimits { + pub read_timeout: Duration, + pub max_events: Option, + pub exit_if_reopen_fails: bool, +} + +impl Default for WatchLimits { + fn default() -> Self { + Self { + read_timeout: Duration::from_millis(100), + max_events: None, + exit_if_reopen_fails: false, + } + } +} + +/// Run the parsed CLI against the live HID backend. +pub fn run(cli: Cli) -> Result<()> { + match cli.command { + Command::Device { command } => match command { + DeviceCommand::List(args) => device_list(&args), + DeviceCommand::Info(args) => { + let descriptor = load_required_descriptor(&args.descriptor)?; + info(&descriptor, &mut io::stdout()) + } + DeviceCommand::Watch(args) => { + let descriptor = load_required_descriptor(&args.io.descriptor)?; + let mut decoder = Decoder::from_descriptor(&descriptor)?; + let limits = WatchLimits { + max_events: args.max_events, + ..WatchLimits::default() + }; + watch_events( + || open_live(&descriptor, args.io.path.as_deref()), + &mut decoder, + &mut io::stdout(), + &limits, + ) + } + DeviceCommand::Record(args) => { + let descriptor = load_required_descriptor(&args.io.descriptor)?; + let mut file = std::fs::File::create(&args.output) + .with_context(|| format!("could not create {}", args.output.display()))?; + record_reports( + || open_live(&descriptor, args.io.path.as_deref()), + &mut file, + args.max_reports, + Duration::from_millis(100), + false, + )?; + Ok(()) + } + DeviceCommand::Rgb(args) => { + let descriptor = load_required_descriptor(&args.io.descriptor)?; + let mut transport = open_live(&descriptor, args.io.path.as_deref())?; + rgb( + &mut transport, + args.red, + args.green, + args.blue, + &mut io::stdout(), + ) + } + DeviceCommand::Leds(args) => { + let descriptor = load_required_descriptor(&args.io.descriptor)?; + let mut transport = open_live(&descriptor, args.io.path.as_deref())?; + leds(&mut transport, args.mask, &mut io::stdout()) + } + DeviceCommand::Lcd { + command: LcdCommand::Test(args), + } => { + let descriptor = load_required_descriptor(&args.descriptor)?; + let mut transport = open_live(&descriptor, args.path.as_deref())?; + lcd_test(&mut transport, &mut io::stdout()) + } + }, + } +} + +fn load_required_descriptor(path: &Path) -> Result { + load_descriptor(path) + .with_context(|| format!("could not load device descriptor {}", path.display())) +} + +fn open_live(descriptor: &DeviceDescriptor, path: Option<&str>) -> bosun_hid::Result { + let api = HidTransport::api()?; + match path { + Some(path) => HidTransport::open_path(&api, path), + None => HidTransport::open(&api, &descriptor.match_criteria.device_match()), + } +} + +fn device_list(args: &ListArgs) -> Result<()> { + let api = HidTransport::api().context("could not initialise the HID backend")?; + let devices = HidTransport::enumerate(&api); + let matched: Vec<&DeviceInfo> = devices.iter().filter(|info| args.selects(info)).collect(); + + if matched.is_empty() { + println!("No HID interface matched. {} enumerated.", devices.len()); + return Ok(()); + } + + for info in matched { + println!("{}", format_device(info)); + } + + Ok(()) +} + +/// Print descriptor identity and capabilities. +pub fn info(descriptor: &DeviceDescriptor, out: &mut impl Write) -> Result<()> { + writeln!(out, "id={}", descriptor.id)?; + writeln!(out, "class={}", descriptor.class)?; + writeln!( + out, + "match={:04x}:{:04x} usage_page={:04x}", + descriptor.match_criteria.vid, + descriptor.match_criteria.pid, + descriptor.match_criteria.usage_page + )?; + writeln!(out, "keys={}", descriptor.caps.keys.len())?; + writeln!(out, " {}", descriptor.caps.keys.join(" "))?; + for axis in &descriptor.caps.axes { + writeln!( + out, + "axis {} byte={} bits={}", + axis.id, axis.byte, axis.bits + )?; + } + writeln!( + out, + "screen {}x{} storage={} report={} padding={}", + descriptor.caps.screen.width, + descriptor.caps.screen.height, + descriptor.caps.screen.storage_height, + descriptor.caps.screen.report_id, + descriptor.caps.screen.padding + )?; + writeln!(out, "rgb report={}", descriptor.caps.rgb.report_id)?; + writeln!( + out, + "leds {} report={}", + descriptor.caps.leds.names.join(","), + descriptor.caps.leds.report_id + )?; + Ok(()) +} + +/// Format one decoded event for `watch`. +pub fn format_event(event: &InputEvent) -> String { + match event { + InputEvent::KeyDown { name } => format!("key {name} down"), + InputEvent::KeyUp { name } => format!("key {name} up"), + InputEvent::Axis { name, value } => format!("axis {name} {value}"), + } +} + +/// Read reports, decode them, and print events. Re-opens after disconnect. +pub fn watch_events( + mut opener: F, + decoder: &mut Decoder, + out: &mut W, + limits: &WatchLimits, +) -> Result<()> +where + T: Transport, + F: FnMut() -> bosun_hid::Result, + W: Write, +{ + let mut transport = opener().context("could not open the device")?; + let mut events_seen = 0usize; + + loop { + if limits.max_events.is_some_and(|max| events_seen >= max) { + return Ok(()); + } + + let mut buf = [0u8; 8]; + match transport.read(&mut buf, limits.read_timeout) { + Ok(ReadOutcome::Timeout) => {} + Ok(ReadOutcome::Report(len)) => { + let Ok(events) = decoder.decode(&buf[..len]) else { + continue; + }; + for event in events { + writeln!(out, "{}", format_event(&event))?; + events_seen += 1; + if limits.max_events.is_some_and(|max| events_seen >= max) { + return Ok(()); + } + } + } + Err(HidError::Disconnected) => match opener() { + Ok(next) => { + transport = next; + writeln!(out, "reconnect")?; + } + Err(_) if limits.exit_if_reopen_fails => return Ok(()), + Err(_) => { + // Live unplug: retry immediately so a replug stays under 2s. + } + }, + Err(error) => return Err(error.into()), + } + } +} + +/// Write raw 8-byte reports as hex lines. +pub fn record_reports( + mut opener: F, + out: &mut W, + max_reports: Option, + read_timeout: Duration, + exit_if_reopen_fails: bool, +) -> Result +where + T: Transport, + F: FnMut() -> bosun_hid::Result, + W: Write, +{ + let mut transport = opener().context("could not open the device")?; + let mut written = 0usize; + + loop { + if max_reports.is_some_and(|max| written >= max) { + return Ok(written); + } + + let mut buf = [0u8; 8]; + match transport.read(&mut buf, read_timeout) { + Ok(ReadOutcome::Timeout) => { + if max_reports.is_some() { + continue; + } + } + Ok(ReadOutcome::Report(len)) => { + if len != 8 { + bail!("record expects 8-byte reports, got {len}"); + } + let mut report = [0u8; 8]; + report.copy_from_slice(&buf[..8]); + writeln!(out, "{}", format_recorded_report(&report))?; + written += 1; + } + Err(HidError::Disconnected) => { + if exit_if_reopen_fails { + return Ok(written); + } + transport = opener().context("could not reopen the device for record")?; + } + Err(error) => return Err(error.into()), + } + } +} + +/// Send feature report 7 and print the readback. +pub fn rgb( + transport: &mut T, + red: u8, + green: u8, + blue: u8, + out: &mut impl Write, +) -> Result<()> { + let report = rgb_feature_report(red, green, blue); + transport + .send_feature_report(&report) + .context("could not send RGB feature report")?; + let mut readback = [0u8; 5]; + readback[0] = RGB_REPORT_ID; + let len = transport + .get_feature_report(&mut readback) + .context("could not read RGB feature report")?; + writeln!( + out, + "rgb sent {:02x?} readback {:02x?}", + report, + &readback[..len] + )?; + Ok(()) +} + +/// Send feature report 5. +pub fn leds(transport: &mut T, mask: u8, out: &mut impl Write) -> Result<()> { + let report = leds_feature_report(mask); + transport + .send_feature_report(&report) + .context("could not send LED feature report")?; + writeln!(out, "leds sent {report:02x?}")?; + Ok(()) +} + +/// Send one 992-byte LCD test report. +pub fn lcd_test(transport: &mut T, out: &mut impl Write) -> Result<()> { + let report = lcd_test_report(); + debug_assert_eq!(report.len(), LCD_REPORT_LEN); + debug_assert_eq!(report[0], 0x03); + debug_assert!(report[1..=31].iter().all(|byte| *byte == 0)); + debug_assert!(hidden_lcd_rows_are_zero(&report)); + let written = transport + .write(&report) + .context("could not write LCD test report")?; + writeln!(out, "lcd test wrote {written} bytes report={}", report[0])?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + use bosun_hid::{load_descriptor, parse_recorded_reports, DeviceInfo, MockTransport}; + use clap::CommandFactory; + use std::io::Cursor; + use std::time::Instant; + + fn g13() -> DeviceDescriptor { + let path = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../devices/logitech-g13.toml"); + load_descriptor(path).unwrap() + } + + fn mock() -> MockTransport { + MockTransport::new(DeviceInfo { + path: "mock".into(), + vendor_id: 0x1234, + product_id: 0x5678, + usage_page: 0xFF00, + ..DeviceInfo::default() + }) + } + + fn g1_report() -> [u8; 8] { + [0x01, 0x7F, 0x7F, 0x01, 0, 0, 0, 0] + } + + fn list_args(argv: [&str; N]) -> ListArgs { + match Cli::parse_from(argv).command { + Command::Device { + command: DeviceCommand::List(args), + } => args, + other => panic!("{other:?}"), + } + } + + fn g13_vendor_collection() -> DeviceInfo { + DeviceInfo { + path: "vendor".to_owned(), + vendor_id: 0x046D, + product_id: 0xC21C, + usage_page: 0xFF00, + ..DeviceInfo::default() + } + } + + fn no_report_six(writes: &[Vec]) { + assert!( + writes.iter().all(|write| write.first() != Some(&0x06)), + "{writes:?}" + ); + } + + #[test] + fn the_cli_definition_is_valid() { + Cli::command().debug_assert(); + } + + #[test] + fn identifiers_accept_hex_on_the_command_line() { + let args = list_args([ + "bosunctl", + "device", + "list", + "--vid", + "0x046D", + "--pid", + "0xC21C", + "--usage-page", + "0xFF00", + ]); + assert_eq!(args.vid, Some(0x046D)); + assert_eq!(args.pid, Some(0xC21C)); + assert_eq!(args.usage_page, Some(0xFF00)); + } + + #[test] + fn an_unfiltered_list_selects_everything() { + let args = list_args(["bosunctl", "device", "list"]); + assert!(args.selects(&g13_vendor_collection())); + assert!(args.selects(&DeviceInfo::default())); + } + + #[test] + fn new_device_commands_parse() { + assert!(matches!( + Cli::parse_from(["bosunctl", "device", "info"]).command, + Command::Device { + command: DeviceCommand::Info(_) + } + )); + assert!(matches!( + Cli::parse_from(["bosunctl", "device", "watch"]).command, + Command::Device { + command: DeviceCommand::Watch(_) + } + )); + assert!(matches!( + Cli::parse_from(["bosunctl", "device", "record", "--output", "out.hex"]).command, + Command::Device { + command: DeviceCommand::Record(_) + } + )); + assert!(matches!( + Cli::parse_from(["bosunctl", "device", "rgb", "1", "2", "3"]).command, + Command::Device { + command: DeviceCommand::Rgb(_) + } + )); + assert!(matches!( + Cli::parse_from(["bosunctl", "device", "leds", "0x0F"]).command, + Command::Device { + command: DeviceCommand::Leds(_) + } + )); + assert!(matches!( + Cli::parse_from(["bosunctl", "device", "lcd", "test"]).command, + Command::Device { + command: DeviceCommand::Lcd { + command: LcdCommand::Test(_) + } + } + )); + } + + #[test] + fn a_usage_page_filter_excludes_the_other_collections_of_one_device() { + let args = ListArgs { + vid: Some(0x046D), + pid: Some(0xC21C), + usage_page: Some(0xFF00), + }; + let vendor = DeviceInfo { + vendor_id: 0x046D, + product_id: 0xC21C, + usage_page: 0xFF00, + ..DeviceInfo::default() + }; + assert!(args.selects(&vendor)); + assert!(!args.selects(&DeviceInfo { + usage_page: 0x0001, + ..vendor + })); + } + + #[test] + fn a_malformed_identifier_is_rejected_rather_than_ignored() { + assert!(Cli::try_parse_from(["bosunctl", "device", "list", "--vid", "zzz"]).is_err()); + assert!(Cli::try_parse_from(["bosunctl", "device", "list", "--vid", "0x10000"]).is_err()); + } + + #[test] + fn info_prints_the_loaded_descriptor() { + let descriptor = g13(); + let mut out = Vec::new(); + info(&descriptor, &mut out).unwrap(); + let text = String::from_utf8(out).unwrap(); + assert!(text.contains("id=logitech-g13"), "{text}"); + assert!(text.contains("046d:c21c"), "{text}"); + assert!(text.contains("G1"), "{text}"); + assert!(text.contains("LEFT DOWN TOP"), "{text}"); + assert!(text.contains("stick-x"), "{text}"); + assert!(text.contains("rgb report=7"), "{text}"); + assert!(text.contains("leds M1,M2,M3,MR report=5"), "{text}"); + } + + #[test] + fn watch_prints_named_events_over_mock_transport() { + let descriptor = g13(); + let mut decoder = Decoder::from_descriptor(&descriptor).unwrap(); + let mut out = Vec::new(); + let limits = WatchLimits { + read_timeout: Duration::from_millis(1), + max_events: Some(3), + exit_if_reopen_fails: true, + }; + watch_events( + || Ok(mock().push_report(&g1_report())), + &mut decoder, + &mut out, + &limits, + ) + .unwrap(); + let text = String::from_utf8(out).unwrap(); + assert!(text.contains("key G1 down"), "{text}"); + assert!(text.contains("axis stick-x 127"), "{text}"); + assert!(text.contains("axis stick-y 127"), "{text}"); + } + + #[test] + fn record_round_trips_through_the_decoder() { + let descriptor = g13(); + let mut recorded = Cursor::new(Vec::new()); + record_reports( + || Ok(mock().push_report(&g1_report()).push_disconnect()), + &mut recorded, + Some(1), + Duration::from_millis(1), + true, + ) + .unwrap(); + let text = String::from_utf8(recorded.into_inner()).unwrap(); + let reports = parse_recorded_reports(&text).unwrap(); + assert_eq!(reports, [g1_report()]); + let mut decoder = Decoder::from_descriptor(&descriptor).unwrap(); + let events = decoder.decode(&reports[0]).unwrap(); + assert!(events.contains(&InputEvent::KeyDown { name: "G1".into() })); + } + + #[test] + fn rgb_sends_feature_report_7_and_reads_it_back() { + let mut transport = mock().push_feature_report(&[0x07, 0x10, 0x20, 0x30, 0x00]); + let mut out = Vec::new(); + rgb(&mut transport, 0x10, 0x20, 0x30, &mut out).unwrap(); + assert_eq!( + transport.feature_writes(), + [vec![0x07, 0x10, 0x20, 0x30, 0x00]] + ); + let text = String::from_utf8(out).unwrap(); + assert!(text.contains("readback"), "{text}"); + } + + #[test] + fn leds_sends_feature_report_5() { + let mut transport = mock(); + let mut out = Vec::new(); + leds(&mut transport, 0x05, &mut out).unwrap(); + assert_eq!(transport.feature_writes(), [vec![0x05, 0x05, 0, 0, 0]]); + } + + #[test] + fn lcd_test_sends_a_992_byte_report_3() { + let mut transport = mock(); + let mut out = Vec::new(); + lcd_test(&mut transport, &mut out).unwrap(); + assert_eq!(transport.writes().len(), 1); + let report = &transport.writes()[0]; + assert_eq!(report.len(), 992); + assert_eq!(report[0], 0x03); + assert!(report[1..=31].iter().all(|byte| *byte == 0)); + assert!(hidden_lcd_rows_are_zero(report)); + } + + #[test] + fn no_cli_output_command_writes_feature_report_6() { + let mut rgb_transport = mock().push_feature_report(&[0x07, 0, 0, 0, 0]); + rgb(&mut rgb_transport, 1, 2, 3, &mut io::sink()).unwrap(); + let mut led_transport = mock(); + leds(&mut led_transport, 1, &mut io::sink()).unwrap(); + let mut lcd_transport = mock(); + lcd_test(&mut lcd_transport, &mut io::sink()).unwrap(); + + no_report_six(rgb_transport.feature_writes()); + no_report_six(led_transport.feature_writes()); + no_report_six(lcd_transport.feature_writes()); + no_report_six(lcd_transport.writes()); + + let src = include_str!("lib.rs"); + let watch = src.find("pub fn watch_events").expect("watch_events"); + let record = src.find("pub fn record_reports").expect("record_reports"); + let rgb_fn = src.find("pub fn rgb<").expect("rgb"); + assert!( + !src[watch..record].contains("send_feature_report"), + "watch must not send feature reports" + ); + assert!( + !src[record..rgb_fn].contains("send_feature_report"), + "record must not send feature reports" + ); + } + + #[test] + fn watch_resumes_after_a_scripted_disconnect() { + let descriptor = g13(); + let mut decoder = Decoder::from_descriptor(&descriptor).unwrap(); + let mut out = Vec::new(); + let limits = WatchLimits { + read_timeout: Duration::from_millis(1), + max_events: Some(3), + exit_if_reopen_fails: true, + }; + let started = Instant::now(); + let mut opens = 0u8; + watch_events( + || { + opens += 1; + match opens { + 1 => Ok(mock().push_disconnect()), + _ => Ok(mock().push_report(&g1_report())), + } + }, + &mut decoder, + &mut out, + &limits, + ) + .unwrap(); + let elapsed = started.elapsed(); + let text = String::from_utf8(out).unwrap(); + assert!(text.contains("reconnect"), "{text}"); + assert_eq!(text.matches("reconnect").count(), 1, "{text}"); + assert!(text.contains("key G1 down"), "{text}"); + assert!(elapsed <= Duration::from_secs(2), "{elapsed:?}"); + } + + #[test] + fn parse_u8_accepts_hex_masks() { + assert_eq!(parse_u8("0x0F").unwrap(), 0x0F); + assert_eq!(parse_u8("15").unwrap(), 15); + assert!(parse_u8("256").is_err()); + } +} diff --git a/apps/bosunctl/src/main.rs b/apps/bosunctl/src/main.rs index c46f0b7..c8d1b53 100644 --- a/apps/bosunctl/src/main.rs +++ b/apps/bosunctl/src/main.rs @@ -1,62 +1,14 @@ //! `bosunctl` — the Bosun control CLI. //! -//! M1 scope. The CLI names devices by match criteria on the command line; no -//! product identifiers are compiled in. +//! M1 scope. Device identity comes from a TOML descriptor or explicit +//! match flags. The default descriptor path is the committed G13 data file; +//! VID/PID are not compiled into this binary. -mod listing; - -use anyhow::{Context, Result}; -use bosun_hid::{DeviceInfo, HidTransport}; -use clap::{Args, Parser, Subcommand}; +use anyhow::Result; +use bosunctl::{run, Cli}; +use clap::Parser; use tracing_subscriber::EnvFilter; -use crate::listing::{accepts, format_device, parse_u16}; - -#[derive(Debug, Parser)] -#[command(name = "bosunctl", version, about = "Bosun control CLI")] -struct Cli { - #[command(subcommand)] - command: Command, -} - -#[derive(Debug, Subcommand)] -enum Command { - /// Inspect HID devices. - Device { - #[command(subcommand)] - command: DeviceCommand, - }, -} - -#[derive(Debug, Subcommand)] -enum DeviceCommand { - /// List enumerated HID interfaces, optionally filtered. - List(ListArgs), -} - -#[derive(Debug, Args)] -struct ListArgs { - /// Vendor ID, decimal or 0x-prefixed hex. - #[arg(long, value_parser = parse_u16)] - vid: Option, - - /// Product ID, decimal or 0x-prefixed hex. - #[arg(long, value_parser = parse_u16)] - pid: Option, - - /// HID usage page, decimal or 0x-prefixed hex. - #[arg(long = "usage-page", value_parser = parse_u16)] - usage_page: Option, -} - -impl ListArgs { - fn selects(&self, info: &DeviceInfo) -> bool { - accepts(self.vid, info.vendor_id) - && accepts(self.pid, info.product_id) - && accepts(self.usage_page, info.usage_page) - } -} - fn main() -> Result<()> { // Logs go to stderr so stdout stays a clean, pipeable listing. tracing_subscriber::fmt() @@ -66,113 +18,5 @@ fn main() -> Result<()> { .with_writer(std::io::stderr) .init(); - let cli = Cli::parse(); - - match cli.command { - Command::Device { command } => match command { - DeviceCommand::List(args) => device_list(&args), - }, - } -} - -fn device_list(args: &ListArgs) -> Result<()> { - let api = HidTransport::api().context("could not initialise the HID backend")?; - let devices = HidTransport::enumerate(&api); - let matched: Vec<&DeviceInfo> = devices.iter().filter(|info| args.selects(info)).collect(); - - if matched.is_empty() { - println!("No HID interface matched. {} enumerated.", devices.len()); - return Ok(()); - } - - for info in matched { - println!("{}", format_device(info)); - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - use clap::CommandFactory; - - /// Parse an argv into the `device list` arguments it produces. - fn list_args(argv: [&str; N]) -> ListArgs { - match Cli::parse_from(argv).command { - Command::Device { - command: DeviceCommand::List(args), - } => args, - } - } - - fn g13_vendor_collection() -> DeviceInfo { - DeviceInfo { - path: "vendor".to_owned(), - vendor_id: 0x046D, - product_id: 0xC21C, - usage_page: 0xFF00, - ..DeviceInfo::default() - } - } - - #[test] - fn the_cli_definition_is_valid() { - Cli::command().debug_assert(); - } - - #[test] - fn identifiers_accept_hex_on_the_command_line() { - let args = list_args([ - "bosunctl", - "device", - "list", - "--vid", - "0x046D", - "--pid", - "0xC21C", - "--usage-page", - "0xFF00", - ]); - - assert_eq!(args.vid, Some(0x046D)); - assert_eq!(args.pid, Some(0xC21C)); - assert_eq!(args.usage_page, Some(0xFF00)); - } - - #[test] - fn an_unfiltered_list_selects_everything() { - let args = list_args(["bosunctl", "device", "list"]); - - assert!(args.selects(&g13_vendor_collection())); - assert!(args.selects(&DeviceInfo::default())); - } - - #[test] - fn a_usage_page_filter_excludes_the_other_collections_of_one_device() { - let args = list_args([ - "bosunctl", - "device", - "list", - "--vid", - "0x046D", - "--pid", - "0xC21C", - "--usage-page", - "0xFF00", - ]); - - assert!(args.selects(&g13_vendor_collection())); - assert!(!args.selects(&DeviceInfo { - usage_page: 0x0001, - ..g13_vendor_collection() - })); - } - - #[test] - fn a_malformed_identifier_is_rejected_rather_than_ignored() { - assert!(Cli::try_parse_from(["bosunctl", "device", "list", "--vid", "zzz"]).is_err()); - assert!(Cli::try_parse_from(["bosunctl", "device", "list", "--vid", "0x10000"]).is_err()); - } + run(Cli::parse()) } diff --git a/crates/bosun-hid/src/codec.rs b/crates/bosun-hid/src/codec.rs new file mode 100644 index 0000000..8a2e4b8 --- /dev/null +++ b/crates/bosun-hid/src/codec.rs @@ -0,0 +1,470 @@ +//! Descriptor-driven input decode and output-report builders. +//! +//! Protocol numbers come from measured facts recorded in `docs/BOSUN-PLAN.md`. +//! This module does not name a vendor product. + +use crate::descriptor::{DeviceDescriptor, KEY_BIT_COUNT}; +use crate::error::CodecError; + +/// Vendor input report ID. +pub const INPUT_REPORT_ID: u8 = 0x01; +/// Input report length including the report ID. +pub const INPUT_REPORT_LEN: usize = 8; + +/// LCD output report ID. +pub const LCD_REPORT_ID: u8 = 0x03; +/// LCD output report length including the report ID. +pub const LCD_REPORT_LEN: usize = 992; +/// Padding bytes between the report ID and the framebuffer. +pub const LCD_PADDING: usize = 31; +/// Visible LCD rows. Rows at and above this stay zero in the packed buffer. +pub const LCD_VISIBLE_ROWS: usize = 43; +/// Packed storage rows (6 bands of 8). +pub const LCD_STORAGE_ROWS: usize = 48; +/// LCD columns. +pub const LCD_WIDTH: usize = 160; +/// Framebuffer bytes after padding. +pub const LCD_FRAMEBUFFER_LEN: usize = 960; + +/// RGB backlight feature report ID. +pub const RGB_REPORT_ID: u8 = 0x07; +/// M-key LED feature report ID. +pub const LED_REPORT_ID: u8 = 0x05; + +/// One edge produced by diffing the current report against the previous one. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum InputEvent { + KeyDown { name: String }, + KeyUp { name: String }, + Axis { name: String, value: u8 }, +} + +/// Stateful decoder: named keys for kept bits, axes from report bytes. +pub struct Decoder { + key_by_bit: [Option; KEY_BIT_COUNT], + axes: Vec<(String, u8)>, + previous_keys: Option<[bool; KEY_BIT_COUNT]>, + previous_axes: Option>, +} + +impl Decoder { + /// Build a decoder from a validated descriptor. + pub fn from_descriptor(descriptor: &DeviceDescriptor) -> std::result::Result { + let ignored: std::collections::HashSet = + descriptor.caps.ignored_key_bits.iter().copied().collect(); + let mut key_by_bit = std::array::from_fn(|_| None); + let mut next_key = 0usize; + for (bit, slot) in key_by_bit.iter_mut().enumerate() { + if ignored.contains(&(bit as u8)) { + continue; + } + let name = + descriptor.caps.keys.get(next_key).ok_or_else(|| { + CodecError::Descriptor("fewer key names than kept bits".into()) + })?; + *slot = Some(name.clone()); + next_key += 1; + } + if next_key != descriptor.caps.keys.len() { + return Err(CodecError::Descriptor( + "more key names than kept bits".into(), + )); + } + + let axes = descriptor + .caps + .axes + .iter() + .map(|axis| (axis.id.clone(), axis.byte)) + .collect(); + + Ok(Self { + key_by_bit, + axes, + previous_keys: None, + previous_axes: None, + }) + } + + /// Decode one 8-byte report into edge events. + pub fn decode(&mut self, report: &[u8]) -> std::result::Result, CodecError> { + if report.len() != INPUT_REPORT_LEN { + return Err(CodecError::WrongLength(report.len())); + } + if report[0] != INPUT_REPORT_ID { + return Err(CodecError::WrongReportId(report[0])); + } + + let current_keys = key_bits(report); + let mut events = Vec::new(); + + match self.previous_keys { + None => { + for (bit, pressed) in current_keys.iter().enumerate() { + if *pressed { + if let Some(name) = &self.key_by_bit[bit] { + events.push(InputEvent::KeyDown { name: name.clone() }); + } + } + } + } + Some(previous) => { + for (bit, (was, is)) in previous.iter().zip(current_keys.iter()).enumerate() { + if was == is { + continue; + } + let Some(name) = &self.key_by_bit[bit] else { + continue; + }; + if *is { + events.push(InputEvent::KeyDown { name: name.clone() }); + } else { + events.push(InputEvent::KeyUp { name: name.clone() }); + } + } + } + } + self.previous_keys = Some(current_keys); + + let mut current_axes = Vec::with_capacity(self.axes.len()); + for (index, (name, byte)) in self.axes.iter().enumerate() { + let byte = *byte as usize; + if byte >= report.len() { + return Err(CodecError::Descriptor(format!( + "axis {name} points at byte {byte}" + ))); + } + let value = report[byte]; + current_axes.push(value); + let changed = match &self.previous_axes { + None => true, + Some(previous) => previous.get(index).copied() != Some(value), + }; + if changed { + events.push(InputEvent::Axis { + name: name.clone(), + value, + }); + } + } + self.previous_axes = Some(current_axes); + + Ok(events) + } +} + +fn key_bits(report: &[u8]) -> [bool; KEY_BIT_COUNT] { + let mut bits = [false; KEY_BIT_COUNT]; + for (bit, pressed) in bits.iter_mut().enumerate() { + let byte = 3 + bit / 8; + let mask = 1u8 << (bit % 8); + *pressed = report[byte] & mask != 0; + } + bits +} + +/// Feature report 7: global RGB backlight. +pub fn rgb_feature_report(red: u8, green: u8, blue: u8) -> [u8; 5] { + [RGB_REPORT_ID, red, green, blue, 0x00] +} + +/// Feature report 5: M1/M2/M3/MR LED mask. +pub fn leds_feature_report(mask: u8) -> [u8; 5] { + [LED_REPORT_ID, mask, 0, 0, 0] +} + +/// One LCD test frame: report ID 3, 31 zero padding bytes, visible border, hidden rows zero. +pub fn lcd_test_report() -> [u8; LCD_REPORT_LEN] { + let mut report = [0u8; LCD_REPORT_LEN]; + report[0] = LCD_REPORT_ID; + let framebuffer = &mut report[1 + LCD_PADDING..]; + for col in 0..LCD_WIDTH { + set_visible_pixel(framebuffer, col, 0); + set_visible_pixel(framebuffer, col, LCD_VISIBLE_ROWS - 1); + } + for row in 0..LCD_VISIBLE_ROWS { + set_visible_pixel(framebuffer, 0, row); + set_visible_pixel(framebuffer, LCD_WIDTH - 1, row); + } + report +} + +fn set_visible_pixel(framebuffer: &mut [u8], col: usize, row: usize) { + debug_assert!(row < LCD_VISIBLE_ROWS); + debug_assert!(col < LCD_WIDTH); + let offset = col + (row >> 3) * LCD_WIDTH; + let bit = 1u8 << (row & 7); + framebuffer[offset] |= bit; +} + +/// True when every packed bit for rows 43–47 is clear. +pub fn hidden_lcd_rows_are_zero(report: &[u8]) -> bool { + if report.len() != LCD_REPORT_LEN { + return false; + } + let framebuffer = &report[1 + LCD_PADDING..]; + for row in LCD_VISIBLE_ROWS..LCD_STORAGE_ROWS { + for col in 0..LCD_WIDTH { + let offset = col + (row >> 3) * LCD_WIDTH; + let bit = 1u8 << (row & 7); + if framebuffer[offset] & bit != 0 { + return false; + } + } + } + true +} + +/// Format one recorded 8-byte report as lowercase hex. +pub fn format_recorded_report(report: &[u8; INPUT_REPORT_LEN]) -> String { + report + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::>() + .join(" ") +} + +/// Parse one recorded hex line into an 8-byte report. +pub fn parse_recorded_report( + line: &str, +) -> std::result::Result<[u8; INPUT_REPORT_LEN], CodecError> { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + return Err(CodecError::InvalidRecording(line.to_owned())); + } + let parts: Vec<&str> = trimmed.split_whitespace().collect(); + if parts.len() != INPUT_REPORT_LEN { + return Err(CodecError::InvalidRecording(line.to_owned())); + } + let mut report = [0u8; INPUT_REPORT_LEN]; + for (index, part) in parts.iter().enumerate() { + report[index] = u8::from_str_radix(part, 16) + .map_err(|_| CodecError::InvalidRecording(line.to_owned()))?; + } + Ok(report) +} + +/// Parse a recorded fixture file, skipping blanks and `#` comments. +pub fn parse_recorded_reports( + text: &str, +) -> std::result::Result, CodecError> { + let mut reports = Vec::new(); + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + reports.push(parse_recorded_report(trimmed)?); + } + Ok(reports) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::descriptor::parse_descriptor; + use std::fs; + use std::path::PathBuf; + + fn g13_decoder() -> Decoder { + let path = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../devices/logitech-g13.toml"); + let descriptor = crate::descriptor::load_descriptor(path).unwrap(); + Decoder::from_descriptor(&descriptor).unwrap() + } + + fn rest() -> [u8; 8] { + [0x01, 0x7F, 0x7F, 0, 0, 0, 0, 0] + } + + fn fixture_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/g13") + } + + fn load_hex(name: &str) -> [u8; 8] { + let text = fs::read_to_string(fixture_dir().join(name)).unwrap(); + parse_recorded_report(text.lines().next().unwrap()).unwrap() + } + + #[test] + fn a_pressed_kept_bit_emits_a_named_key_down() { + let mut decoder = g13_decoder(); + let events = decoder.decode(&load_hex("G1.hex")).unwrap(); + assert!( + events.contains(&InputEvent::KeyDown { name: "G1".into() }), + "{events:?}" + ); + } + + #[test] + fn releasing_a_key_emits_up_after_a_diff() { + let mut decoder = g13_decoder(); + decoder.decode(&load_hex("G1.hex")).unwrap(); + let events = decoder.decode(&rest()).unwrap(); + assert_eq!( + events + .iter() + .filter(|event| matches!(event, InputEvent::KeyUp { name } if name == "G1")) + .count(), + 1, + "{events:?}" + ); + } + + #[test] + fn ignored_bits_emit_no_key_events() { + let mut decoder = g13_decoder(); + decoder.decode(&rest()).unwrap(); + + let ignored = [ + (22, 5, 1u8 << 6), + (23, 5, 1u8 << 7), + (36, 7, 1u8 << 4), + (37, 7, 1u8 << 5), + (38, 7, 1u8 << 6), + (39, 7, 1u8 << 7), + ]; + + for (bit, byte, mask) in ignored { + let mut report = rest(); + report[byte] |= mask; + let events = decoder.decode(&report).unwrap(); + assert!(events.is_empty(), "bit {bit} produced events: {events:?}"); + decoder.decode(&rest()).unwrap(); + } + } + + #[test] + fn stick_bytes_decode_to_0_255_axes() { + let mut decoder = g13_decoder(); + let events = decoder.decode(&[0x01, 0x00, 0xFF, 0, 0, 0, 0, 0]).unwrap(); + assert!(events.contains(&InputEvent::Axis { + name: "stick-x".into(), + value: 0 + })); + assert!(events.contains(&InputEvent::Axis { + name: "stick-y".into(), + value: 255 + })); + } + + #[test] + fn golden_key_fixtures_each_emit_exactly_that_name() { + let names = [ + "G1", "G2", "G3", "G4", "G5", "G6", "G7", "G8", "G9", "G10", "G11", "G12", "G13", + "G14", "G15", "G16", "G17", "G18", "G19", "G20", "G21", "G22", "BD", "L1", "L2", "L3", + "L4", "M1", "M2", "M3", "MR", "LEFT", "DOWN", "TOP", + ]; + for name in names { + let mut decoder = g13_decoder(); + let events = decoder.decode(&load_hex(&format!("{name}.hex"))).unwrap(); + let downs: Vec<_> = events + .iter() + .filter_map(|event| match event { + InputEvent::KeyDown { name } => Some(name.as_str()), + _ => None, + }) + .collect(); + assert_eq!(downs, [name], "{name} fixture: {events:?}"); + } + } + + #[test] + fn golden_stick_fixtures_decode_their_axis_bytes() { + let cases = [ + ("stick-center.hex", 0x7F, 0x7F), + ("stick-north.hex", 0x7F, 0x00), + ("stick-south.hex", 0x7F, 0xFF), + ("stick-west.hex", 0x00, 0x7F), + ("stick-east.hex", 0xFF, 0x7F), + ("stick-nw.hex", 0x00, 0x00), + ("stick-ne.hex", 0xFF, 0x00), + ("stick-sw.hex", 0x00, 0xFF), + ("stick-se.hex", 0xFF, 0xFF), + ("stick-diag-nw.hex", 0x40, 0x40), + ("stick-diag-ne.hex", 0xC0, 0x40), + ("stick-diag-sw.hex", 0x40, 0xC0), + ("stick-diag-se.hex", 0xC0, 0xC0), + ]; + for (file, x, y) in cases { + let mut decoder = g13_decoder(); + let report = load_hex(file); + assert_eq!(report[1], x, "{file}"); + assert_eq!(report[2], y, "{file}"); + let events = decoder.decode(&report).unwrap(); + assert!( + events.contains(&InputEvent::Axis { + name: "stick-x".into(), + value: x + }), + "{file}: {events:?}" + ); + assert!( + events.contains(&InputEvent::Axis { + name: "stick-y".into(), + value: y + }), + "{file}: {events:?}" + ); + } + } + + #[test] + fn rgb_and_led_builders_match_the_measured_layout() { + assert_eq!( + rgb_feature_report(0xFF, 0x00, 0x11), + [0x07, 0xFF, 0x00, 0x11, 0x00] + ); + assert_eq!(leds_feature_report(0x0A), [0x05, 0x0A, 0, 0, 0]); + } + + #[test] + fn lcd_test_report_has_the_measured_header_and_hidden_rows() { + let report = lcd_test_report(); + assert_eq!(report.len(), 992); + assert_eq!(report[0], 0x03); + assert!(report[1..=31].iter().all(|byte| *byte == 0)); + assert!(hidden_lcd_rows_are_zero(&report)); + assert_ne!(report[32..].iter().filter(|byte| **byte != 0).count(), 0); + } + + #[test] + fn recorded_hex_round_trips() { + let report = [0x01, 0x7F, 0x80, 0x01, 0, 0, 0, 0]; + let line = format_recorded_report(&report); + assert_eq!(parse_recorded_report(&line).unwrap(), report); + } + + #[test] + fn a_document_without_ignored_bits_cannot_build_a_40_bit_codec() { + let text = r#" +id = "short" +class = "A" +[match] +vid = 1 +pid = 2 +usage_page = 3 +[caps] +keys = ["ONLY"] +ignored_key_bits = [] +[[caps.axes]] +id = "x" +byte = 1 +bits = 8 +[caps.screen] +width = 1 +height = 1 +storage_height = 1 +format = "1bpp-bands" +report_id = 3 +padding = 31 +[caps.rgb] +zones = 1 +report_id = 7 +[caps.leds] +names = ["M1"] +report_id = 5 +"#; + assert!(parse_descriptor(text).is_err()); + } +} diff --git a/crates/bosun-hid/src/descriptor.rs b/crates/bosun-hid/src/descriptor.rs new file mode 100644 index 0000000..5a8e8df --- /dev/null +++ b/crates/bosun-hid/src/descriptor.rs @@ -0,0 +1,370 @@ +//! Device descriptors: TOML data, not compiled product policy. +//! +//! A descriptor names match criteria and capabilities. The G13 lives in +//! `devices/logitech-g13.toml`. This module does not mention that product. + +use std::collections::HashSet; +use std::path::Path; + +use serde::Deserialize; + +use crate::device::DeviceMatch; +use crate::error::DescriptorError; + +/// Bits in the 5-byte key field (bytes 3–7 of an 8-byte input report). +pub const KEY_BIT_COUNT: usize = 40; + +/// One loaded device document. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DeviceDescriptor { + pub id: String, + pub class: String, + pub match_criteria: MatchCriteria, + pub caps: Capabilities, +} + +/// VID, PID, and usage page used to select an interface. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +pub struct MatchCriteria { + pub vid: u16, + pub pid: u16, + pub usage_page: u16, +} + +impl MatchCriteria { + /// Criteria in the form [`DeviceMatch`] expects. + pub fn device_match(self) -> DeviceMatch { + DeviceMatch::new(self.vid, self.pid, self.usage_page) + } +} + +/// Controls and output surfaces declared by the descriptor. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Capabilities { + pub keys: Vec, + pub ignored_key_bits: Vec, + pub axes: Vec, + pub screen: Screen, + pub rgb: Rgb, + pub leds: Leds, +} + +/// One analog axis packed into an input-report byte. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +pub struct Axis { + pub id: String, + pub byte: u8, + pub bits: u8, +} + +/// Monochrome LCD packing parameters. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +pub struct Screen { + pub width: u32, + pub height: u32, + pub storage_height: u32, + pub format: String, + pub report_id: u8, + pub padding: u8, +} + +/// Global RGB backlight. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +pub struct Rgb { + pub zones: u8, + pub report_id: u8, +} + +/// Named mode LEDs. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +pub struct Leds { + pub names: Vec, + pub report_id: u8, +} + +#[derive(Deserialize)] +struct RawDescriptor { + id: String, + class: String, + #[serde(rename = "match")] + match_criteria: MatchCriteria, + caps: RawCaps, +} + +#[derive(Deserialize)] +struct RawCaps { + keys: KeysField, + #[serde(default)] + ignored_key_bits: Vec, + axes: Vec, + screen: Screen, + rgb: Rgb, + leds: Leds, +} + +/// Accepts only a literal array. A string (including range syntax) is a type error. +#[derive(Deserialize)] +#[serde(untagged)] +enum KeysField { + Literals(Vec), + RangeSyntax(String), +} + +/// Load and validate a descriptor file. +pub fn load_descriptor( + path: impl AsRef, +) -> std::result::Result { + let path = path.as_ref(); + let text = std::fs::read_to_string(path).map_err(|source| DescriptorError::Io { + path: path.display().to_string(), + source, + })?; + parse_descriptor(&text) +} + +/// Parse and validate a descriptor document. +pub fn parse_descriptor(text: &str) -> std::result::Result { + let raw: RawDescriptor = + toml::from_str(text).map_err(|error| DescriptorError::Malformed(error.to_string()))?; + validate(raw) +} + +fn validate(raw: RawDescriptor) -> std::result::Result { + let keys = match raw.caps.keys { + KeysField::RangeSyntax(syntax) => { + let reason = if syntax.contains("..") { + "range syntax is rejected" + } else { + "caps.keys must be a literal array" + }; + return Err(DescriptorError::Malformed(format!( + "{reason}; use a literal key array, got {syntax:?}" + ))); + } + KeysField::Literals(keys) => keys, + }; + + if raw.id.is_empty() { + return Err(DescriptorError::Malformed("id must not be empty".into())); + } + if keys.is_empty() { + return Err(DescriptorError::Malformed( + "caps.keys must not be empty".into(), + )); + } + + let mut seen = HashSet::new(); + for key in &keys { + if key.is_empty() { + return Err(DescriptorError::Malformed( + "key names must not be empty".into(), + )); + } + if key.contains("..") { + return Err(DescriptorError::Malformed(format!( + "range syntax is rejected; use a literal key array, got {key:?}" + ))); + } + if !seen.insert(key) { + return Err(DescriptorError::Malformed(format!( + "duplicate key name {key}" + ))); + } + } + + let mut ignored_seen = HashSet::new(); + for bit in &raw.caps.ignored_key_bits { + if *bit as usize >= KEY_BIT_COUNT { + return Err(DescriptorError::Malformed(format!( + "ignored key bit {bit} is outside 0..{KEY_BIT_COUNT}" + ))); + } + if !ignored_seen.insert(*bit) { + return Err(DescriptorError::Malformed(format!( + "duplicate ignored key bit {bit}" + ))); + } + } + + if keys.len() + raw.caps.ignored_key_bits.len() != KEY_BIT_COUNT { + return Err(DescriptorError::Malformed(format!( + "caps.keys ({}) plus ignored_key_bits ({}) must cover {KEY_BIT_COUNT} bits", + keys.len(), + raw.caps.ignored_key_bits.len() + ))); + } + + for axis in &raw.caps.axes { + if axis.id.is_empty() { + return Err(DescriptorError::Malformed( + "axis id must not be empty".into(), + )); + } + if axis.bits == 0 { + return Err(DescriptorError::Malformed(format!( + "axis {} must declare a positive bit width", + axis.id + ))); + } + } + + if raw.caps.screen.width == 0 || raw.caps.screen.height == 0 { + return Err(DescriptorError::Malformed( + "screen width and height must be positive".into(), + )); + } + if raw.caps.screen.storage_height < raw.caps.screen.height { + return Err(DescriptorError::Malformed( + "screen storage_height must be at least height".into(), + )); + } + + Ok(DeviceDescriptor { + id: raw.id, + class: raw.class, + match_criteria: raw.match_criteria, + caps: Capabilities { + keys, + ignored_key_bits: raw.caps.ignored_key_bits, + axes: raw.caps.axes, + screen: raw.caps.screen, + rgb: raw.caps.rgb, + leds: raw.caps.leds, + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn committed_g13() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../devices/logitech-g13.toml") + } + + const VALID: &str = r#" +id = "example-pad" +class = "A" + +[match] +vid = 0x1234 +pid = 0x5678 +usage_page = 0xFF00 + +[caps] +keys = [ + "K0", "K1", "K2", "K3", "K4", "K5", "K6", "K7", "K8", "K9", + "K10", "K11", "K12", "K13", "K14", "K15", "K16", "K17", "K18", "K19", + "K20", "K21", "K22", "K23", "K24", "K25", "K26", "K27", "K28", "K29", + "K30", "K31", "K32", "K33" +] +ignored_key_bits = [22, 23, 36, 37, 38, 39] + +[[caps.axes]] +id = "stick-x" +byte = 1 +bits = 8 + +[[caps.axes]] +id = "stick-y" +byte = 2 +bits = 8 + +[caps.screen] +width = 160 +height = 43 +storage_height = 48 +format = "1bpp-bands" +report_id = 3 +padding = 31 + +[caps.rgb] +zones = 1 +report_id = 7 + +[caps.leds] +names = ["M1", "M2", "M3", "MR"] +report_id = 5 +"#; + + #[test] + fn the_committed_g13_descriptor_loads_with_34_literal_keys() { + let descriptor = load_descriptor(committed_g13()).expect("committed descriptor is valid"); + + assert_eq!(descriptor.id, "logitech-g13"); + assert_eq!(descriptor.caps.keys.len(), 34); + assert_eq!( + descriptor.caps.keys, + [ + "G1", "G2", "G3", "G4", "G5", "G6", "G7", "G8", "G9", "G10", "G11", "G12", "G13", + "G14", "G15", "G16", "G17", "G18", "G19", "G20", "G21", "G22", "BD", "L1", "L2", + "L3", "L4", "M1", "M2", "M3", "MR", "LEFT", "DOWN", "TOP" + ] + ); + assert_eq!( + descriptor.match_criteria, + MatchCriteria { + vid: 0x046D, + pid: 0xC21C, + usage_page: 0xFF00, + } + ); + assert_eq!(descriptor.caps.axes.len(), 2); + assert_eq!(descriptor.caps.screen.width, 160); + assert_eq!(descriptor.caps.rgb.report_id, 7); + assert_eq!(descriptor.caps.leds.names, ["M1", "M2", "M3", "MR"]); + assert_eq!(descriptor.caps.ignored_key_bits, [22, 23, 36, 37, 38, 39]); + } + + #[test] + fn a_valid_document_round_trips_through_parse() { + let descriptor = parse_descriptor(VALID).expect("fixture is valid"); + assert_eq!(descriptor.caps.keys.len(), 34); + assert_eq!(descriptor.match_criteria.device_match().vendor_id, 0x1234); + } + + #[test] + fn a_missing_field_is_an_error_not_a_panic() { + let missing_pid = VALID.replace("pid = 0x5678\n", ""); + let error = parse_descriptor(&missing_pid).expect_err("pid is required"); + assert!(matches!(error, DescriptorError::Malformed(_)), "{error:?}"); + } + + #[test] + fn a_wrong_type_is_an_error_not_a_panic() { + let wrong_type = VALID.replace("vid = 0x1234", "vid = \"046D\""); + let error = parse_descriptor(&wrong_type).expect_err("vid must be an integer"); + assert!(matches!(error, DescriptorError::Malformed(_)), "{error:?}"); + } + + #[test] + fn range_syntax_as_a_string_is_rejected() { + let ranged = VALID.replace( + "keys = [\n \"K0\", \"K1\", \"K2\", \"K3\", \"K4\", \"K5\", \"K6\", \"K7\", \"K8\", \"K9\",\n \"K10\", \"K11\", \"K12\", \"K13\", \"K14\", \"K15\", \"K16\", \"K17\", \"K18\", \"K19\",\n \"K20\", \"K21\", \"K22\", \"K23\", \"K24\", \"K25\", \"K26\", \"K27\", \"K28\", \"K29\",\n \"K30\", \"K31\", \"K32\", \"K33\"\n]", + "keys = \"K0..=K33\"", + ); + let error = parse_descriptor(&ranged).expect_err("range syntax is rejected"); + assert!(error.to_string().contains("range syntax"), "{error}"); + } + + #[test] + fn range_syntax_inside_the_array_is_rejected() { + let ranged = VALID.replace("\"K0\"", "\"K0..=K3\""); + let error = parse_descriptor(&ranged).expect_err("range tokens are rejected"); + assert!(error.to_string().contains("range syntax"), "{error}"); + } + + #[test] + fn malformed_descriptors_do_not_panic() { + let out_of_range = VALID.replace( + "ignored_key_bits = [22, 23, 36, 37, 38, 39]", + "ignored_key_bits = [99]", + ); + for document in ["", "id = 1", "keys = \"G1..=G22\"", out_of_range.as_str()] { + let result = std::panic::catch_unwind(|| parse_descriptor(document)); + let parsed = result.expect("parse_descriptor must not panic"); + assert!(parsed.is_err(), "expected Err for {document:?}"); + } + } +} diff --git a/crates/bosun-hid/src/error.rs b/crates/bosun-hid/src/error.rs index 0114830..6a10d18 100644 --- a/crates/bosun-hid/src/error.rs +++ b/crates/bosun-hid/src/error.rs @@ -27,3 +27,41 @@ pub enum HidError { #[error("HID backend failure: {0}")] Backend(String), } + +/// A TOML descriptor that could not be loaded or validated. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum DescriptorError { + /// The file could not be read. + #[error("failed to read device descriptor {path}: {source}")] + Io { + path: String, + #[source] + source: std::io::Error, + }, + + /// The document is not a valid descriptor. Never a panic. + #[error("malformed device descriptor: {0}")] + Malformed(String), +} + +/// An input report or descriptor that the codec cannot decode. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum CodecError { + /// Input reports are 8 bytes including the report ID. + #[error("input report must be 8 bytes, got {0}")] + WrongLength(usize), + + /// Vendor input is report ID `0x01`. + #[error("input report ID must be 0x01, got 0x{0:02x}")] + WrongReportId(u8), + + /// The descriptor cannot drive a 40-bit key field. + #[error("device descriptor cannot drive the codec: {0}")] + Descriptor(String), + + /// A recorded fixture line is not eight hex bytes. + #[error("recorded report is not 8 hex bytes: {0}")] + InvalidRecording(String), +} diff --git a/crates/bosun-hid/src/lib.rs b/crates/bosun-hid/src/lib.rs index 5de75d5..e5d6285 100644 --- a/crates/bosun-hid/src/lib.rs +++ b/crates/bosun-hid/src/lib.rs @@ -31,14 +31,22 @@ //! ); //! ``` +pub mod codec; +pub mod descriptor; pub mod device; pub mod error; pub mod hid; pub mod mock; pub mod transport; +pub use codec::{ + format_recorded_report, hidden_lcd_rows_are_zero, lcd_test_report, leds_feature_report, + parse_recorded_report, parse_recorded_reports, rgb_feature_report, Decoder, InputEvent, + INPUT_REPORT_LEN, LCD_REPORT_ID, LCD_REPORT_LEN, LED_REPORT_ID, RGB_REPORT_ID, +}; +pub use descriptor::{load_descriptor, parse_descriptor, DeviceDescriptor, MatchCriteria}; pub use device::{select, select_all, select_index, DeviceInfo, DeviceMatch}; -pub use error::{HidError, Result}; +pub use error::{CodecError, DescriptorError, HidError, Result}; pub use hid::HidTransport; pub use mock::{MockTransport, ScriptedRead}; pub use transport::{ReadOutcome, Transport}; diff --git a/crates/bosun-hid/tests/fixtures/g13/BD.hex b/crates/bosun-hid/tests/fixtures/g13/BD.hex new file mode 100644 index 0000000..e440225 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/BD.hex @@ -0,0 +1 @@ +01 7f 7f 00 00 00 01 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/DOWN.hex b/crates/bosun-hid/tests/fixtures/g13/DOWN.hex new file mode 100644 index 0000000..9630760 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/DOWN.hex @@ -0,0 +1 @@ +01 7f 7f 00 00 00 00 04 diff --git a/crates/bosun-hid/tests/fixtures/g13/G1.hex b/crates/bosun-hid/tests/fixtures/g13/G1.hex new file mode 100644 index 0000000..7b5f929 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/G1.hex @@ -0,0 +1 @@ +01 7f 7f 01 00 00 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/G10.hex b/crates/bosun-hid/tests/fixtures/g13/G10.hex new file mode 100644 index 0000000..0fabb21 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/G10.hex @@ -0,0 +1 @@ +01 7f 7f 00 02 00 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/G11.hex b/crates/bosun-hid/tests/fixtures/g13/G11.hex new file mode 100644 index 0000000..ae14a05 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/G11.hex @@ -0,0 +1 @@ +01 7f 7f 00 04 00 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/G12.hex b/crates/bosun-hid/tests/fixtures/g13/G12.hex new file mode 100644 index 0000000..25f84b8 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/G12.hex @@ -0,0 +1 @@ +01 7f 7f 00 08 00 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/G13.hex b/crates/bosun-hid/tests/fixtures/g13/G13.hex new file mode 100644 index 0000000..ddaeb6b --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/G13.hex @@ -0,0 +1 @@ +01 7f 7f 00 10 00 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/G14.hex b/crates/bosun-hid/tests/fixtures/g13/G14.hex new file mode 100644 index 0000000..ea8b70f --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/G14.hex @@ -0,0 +1 @@ +01 7f 7f 00 20 00 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/G15.hex b/crates/bosun-hid/tests/fixtures/g13/G15.hex new file mode 100644 index 0000000..f053463 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/G15.hex @@ -0,0 +1 @@ +01 7f 7f 00 40 00 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/G16.hex b/crates/bosun-hid/tests/fixtures/g13/G16.hex new file mode 100644 index 0000000..c439b9d --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/G16.hex @@ -0,0 +1 @@ +01 7f 7f 00 80 00 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/G17.hex b/crates/bosun-hid/tests/fixtures/g13/G17.hex new file mode 100644 index 0000000..6e661d7 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/G17.hex @@ -0,0 +1 @@ +01 7f 7f 00 00 01 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/G18.hex b/crates/bosun-hid/tests/fixtures/g13/G18.hex new file mode 100644 index 0000000..7c74b73 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/G18.hex @@ -0,0 +1 @@ +01 7f 7f 00 00 02 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/G19.hex b/crates/bosun-hid/tests/fixtures/g13/G19.hex new file mode 100644 index 0000000..2539506 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/G19.hex @@ -0,0 +1 @@ +01 7f 7f 00 00 04 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/G2.hex b/crates/bosun-hid/tests/fixtures/g13/G2.hex new file mode 100644 index 0000000..56c4ad9 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/G2.hex @@ -0,0 +1 @@ +01 7f 7f 02 00 00 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/G20.hex b/crates/bosun-hid/tests/fixtures/g13/G20.hex new file mode 100644 index 0000000..a959570 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/G20.hex @@ -0,0 +1 @@ +01 7f 7f 00 00 08 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/G21.hex b/crates/bosun-hid/tests/fixtures/g13/G21.hex new file mode 100644 index 0000000..b0b014d --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/G21.hex @@ -0,0 +1 @@ +01 7f 7f 00 00 10 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/G22.hex b/crates/bosun-hid/tests/fixtures/g13/G22.hex new file mode 100644 index 0000000..ac19279 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/G22.hex @@ -0,0 +1 @@ +01 7f 7f 00 00 20 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/G3.hex b/crates/bosun-hid/tests/fixtures/g13/G3.hex new file mode 100644 index 0000000..16c914c --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/G3.hex @@ -0,0 +1 @@ +01 7f 7f 04 00 00 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/G4.hex b/crates/bosun-hid/tests/fixtures/g13/G4.hex new file mode 100644 index 0000000..66777cc --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/G4.hex @@ -0,0 +1 @@ +01 7f 7f 08 00 00 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/G5.hex b/crates/bosun-hid/tests/fixtures/g13/G5.hex new file mode 100644 index 0000000..62ab673 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/G5.hex @@ -0,0 +1 @@ +01 7f 7f 10 00 00 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/G6.hex b/crates/bosun-hid/tests/fixtures/g13/G6.hex new file mode 100644 index 0000000..4411471 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/G6.hex @@ -0,0 +1 @@ +01 7f 7f 20 00 00 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/G7.hex b/crates/bosun-hid/tests/fixtures/g13/G7.hex new file mode 100644 index 0000000..f749eaa --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/G7.hex @@ -0,0 +1 @@ +01 7f 7f 40 00 00 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/G8.hex b/crates/bosun-hid/tests/fixtures/g13/G8.hex new file mode 100644 index 0000000..b04093d --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/G8.hex @@ -0,0 +1 @@ +01 7f 7f 80 00 00 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/G9.hex b/crates/bosun-hid/tests/fixtures/g13/G9.hex new file mode 100644 index 0000000..bb9dd73 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/G9.hex @@ -0,0 +1 @@ +01 7f 7f 00 01 00 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/L1.hex b/crates/bosun-hid/tests/fixtures/g13/L1.hex new file mode 100644 index 0000000..6e6a016 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/L1.hex @@ -0,0 +1 @@ +01 7f 7f 00 00 00 02 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/L2.hex b/crates/bosun-hid/tests/fixtures/g13/L2.hex new file mode 100644 index 0000000..132908b --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/L2.hex @@ -0,0 +1 @@ +01 7f 7f 00 00 00 04 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/L3.hex b/crates/bosun-hid/tests/fixtures/g13/L3.hex new file mode 100644 index 0000000..97a9325 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/L3.hex @@ -0,0 +1 @@ +01 7f 7f 00 00 00 08 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/L4.hex b/crates/bosun-hid/tests/fixtures/g13/L4.hex new file mode 100644 index 0000000..22f1296 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/L4.hex @@ -0,0 +1 @@ +01 7f 7f 00 00 00 10 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/LEFT.hex b/crates/bosun-hid/tests/fixtures/g13/LEFT.hex new file mode 100644 index 0000000..0d5e64e --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/LEFT.hex @@ -0,0 +1 @@ +01 7f 7f 00 00 00 00 02 diff --git a/crates/bosun-hid/tests/fixtures/g13/M1.hex b/crates/bosun-hid/tests/fixtures/g13/M1.hex new file mode 100644 index 0000000..9ea75cc --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/M1.hex @@ -0,0 +1 @@ +01 7f 7f 00 00 00 20 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/M2.hex b/crates/bosun-hid/tests/fixtures/g13/M2.hex new file mode 100644 index 0000000..3a632dc --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/M2.hex @@ -0,0 +1 @@ +01 7f 7f 00 00 00 40 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/M3.hex b/crates/bosun-hid/tests/fixtures/g13/M3.hex new file mode 100644 index 0000000..7e26838 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/M3.hex @@ -0,0 +1 @@ +01 7f 7f 00 00 00 80 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/MR.hex b/crates/bosun-hid/tests/fixtures/g13/MR.hex new file mode 100644 index 0000000..91469b8 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/MR.hex @@ -0,0 +1 @@ +01 7f 7f 00 00 00 00 01 diff --git a/crates/bosun-hid/tests/fixtures/g13/README.md b/crates/bosun-hid/tests/fixtures/g13/README.md new file mode 100644 index 0000000..1da0c91 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/README.md @@ -0,0 +1,7 @@ +# G13 golden input reports + +Synthetic 8-byte reports derived from the measured bitmap in `docs/BOSUN-PLAN.md` §2.4. + +These files are protocol goldens for CI. They do **not** record physical LEFT/DOWN/TOP +thumb-button identity or a measured joystick envelope. Those remain pending in +`docs/hardware-notes.md` until a live capture. diff --git a/crates/bosun-hid/tests/fixtures/g13/TOP.hex b/crates/bosun-hid/tests/fixtures/g13/TOP.hex new file mode 100644 index 0000000..84ecddb --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/TOP.hex @@ -0,0 +1 @@ +01 7f 7f 00 00 00 00 08 diff --git a/crates/bosun-hid/tests/fixtures/g13/stick-center.hex b/crates/bosun-hid/tests/fixtures/g13/stick-center.hex new file mode 100644 index 0000000..1e12b5e --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/stick-center.hex @@ -0,0 +1 @@ +01 7f 7f 00 00 00 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/stick-diag-ne.hex b/crates/bosun-hid/tests/fixtures/g13/stick-diag-ne.hex new file mode 100644 index 0000000..0dc5836 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/stick-diag-ne.hex @@ -0,0 +1 @@ +01 c0 40 00 00 00 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/stick-diag-nw.hex b/crates/bosun-hid/tests/fixtures/g13/stick-diag-nw.hex new file mode 100644 index 0000000..d67aed2 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/stick-diag-nw.hex @@ -0,0 +1 @@ +01 40 40 00 00 00 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/stick-diag-se.hex b/crates/bosun-hid/tests/fixtures/g13/stick-diag-se.hex new file mode 100644 index 0000000..3762195 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/stick-diag-se.hex @@ -0,0 +1 @@ +01 c0 c0 00 00 00 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/stick-diag-sw.hex b/crates/bosun-hid/tests/fixtures/g13/stick-diag-sw.hex new file mode 100644 index 0000000..deed7e5 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/stick-diag-sw.hex @@ -0,0 +1 @@ +01 40 c0 00 00 00 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/stick-east.hex b/crates/bosun-hid/tests/fixtures/g13/stick-east.hex new file mode 100644 index 0000000..87f78c5 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/stick-east.hex @@ -0,0 +1 @@ +01 ff 7f 00 00 00 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/stick-ne.hex b/crates/bosun-hid/tests/fixtures/g13/stick-ne.hex new file mode 100644 index 0000000..11286bf --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/stick-ne.hex @@ -0,0 +1 @@ +01 ff 00 00 00 00 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/stick-north.hex b/crates/bosun-hid/tests/fixtures/g13/stick-north.hex new file mode 100644 index 0000000..a5cff1b --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/stick-north.hex @@ -0,0 +1 @@ +01 7f 00 00 00 00 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/stick-nw.hex b/crates/bosun-hid/tests/fixtures/g13/stick-nw.hex new file mode 100644 index 0000000..b94afd0 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/stick-nw.hex @@ -0,0 +1 @@ +01 00 00 00 00 00 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/stick-se.hex b/crates/bosun-hid/tests/fixtures/g13/stick-se.hex new file mode 100644 index 0000000..8554ed0 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/stick-se.hex @@ -0,0 +1 @@ +01 ff ff 00 00 00 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/stick-south.hex b/crates/bosun-hid/tests/fixtures/g13/stick-south.hex new file mode 100644 index 0000000..c464efc --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/stick-south.hex @@ -0,0 +1 @@ +01 7f ff 00 00 00 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/stick-sw.hex b/crates/bosun-hid/tests/fixtures/g13/stick-sw.hex new file mode 100644 index 0000000..f2543b9 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/stick-sw.hex @@ -0,0 +1 @@ +01 00 ff 00 00 00 00 00 diff --git a/crates/bosun-hid/tests/fixtures/g13/stick-west.hex b/crates/bosun-hid/tests/fixtures/g13/stick-west.hex new file mode 100644 index 0000000..eb66aa8 --- /dev/null +++ b/crates/bosun-hid/tests/fixtures/g13/stick-west.hex @@ -0,0 +1 @@ +01 00 7f 00 00 00 00 00 diff --git a/devices/logitech-g13.toml b/devices/logitech-g13.toml index 7ca6355..0c64682 100644 --- a/devices/logitech-g13.toml +++ b/devices/logitech-g13.toml @@ -12,6 +12,7 @@ keys = [ "G12", "G13", "G14", "G15", "G16", "G17", "G18", "G19", "G20", "G21", "G22", "BD", "L1", "L2", "L3", "L4", "M1", "M2", "M3", "MR", "LEFT", "DOWN", "TOP" ] +ignored_key_bits = [22, 23, 36, 37, 38, 39] [[caps.axes]] id = "stick-x"