From 66be855e562b37fb789190a0ce472efac0936bc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 00:22:35 +0000 Subject: [PATCH] feat(hid): add synchronous Transport, MockTransport, and HID backend First M1 slice: the transport foundation, built test-first. - `Transport` is synchronous and policy-free. `ReadOutcome` makes an elapsed timeout a distinct outcome so a poll loop cannot confuse "nothing happened" with "the device went away". - `DeviceMatch`/`select_index` match on VID, PID, and usage page, then the backend opens the enumerated path of the interface that matched (PLAN-REVIEW #8). VID/PID alone would open the wrong collection of a composite device. - `MockTransport` replays scripted reports, timeouts, and disconnects and records every write, so layers above this crate need no hardware. An exhausted script reports a disconnect, which keeps a reconnect loop from spinning against an empty mock. - `HidTransport` wraps `hidapi` over the stock OS HID stack (ADR-0001) and flattens backend errors so nothing above this crate names `hidapi` types. - `bosunctl device list` renders enumerated interfaces with the path used to open them, filtered by criteria given on the command line. No product identifiers are compiled in. - Hardware tests are `#[ignore]`d and gated on `BOSUN_HW=1`. One of them is risk R1: open an interface and prove input reports reach a second reader while LGS holds its own handle. Scope held to M1: no Tokio, no engine, no adapters, no profiles, no descriptor loading yet. Descriptor loading and report decoding are the next slice. Verification: 27 unit tests pass and clippy is clean at `-D warnings`, but they were run by compiling the sources directly with rustc, because this sandbox's egress policy blocks crate downloads from static.crates.io. The hidapi, thiserror, and clap layers therefore compile for the first time in CI, not locally. `rustfmt --check` is clean on every file. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0157Fe2eqqST8yYonUyYbBqk --- apps/bosunctl/src/listing.rs | 106 +++++++++++ apps/bosunctl/src/main.rs | 179 +++++++++++++++++- crates/bosun-hid/src/device.rs | 190 +++++++++++++++++++ crates/bosun-hid/src/error.rs | 29 +++ crates/bosun-hid/src/hid.rs | 125 ++++++++++++ crates/bosun-hid/src/lib.rs | 45 ++++- crates/bosun-hid/src/mock.rs | 292 +++++++++++++++++++++++++++++ crates/bosun-hid/src/transport.rs | 94 ++++++++++ crates/bosun-hid/tests/hardware.rs | 91 +++++++++ 9 files changed, 1147 insertions(+), 4 deletions(-) create mode 100644 apps/bosunctl/src/listing.rs create mode 100644 crates/bosun-hid/src/device.rs create mode 100644 crates/bosun-hid/src/error.rs create mode 100644 crates/bosun-hid/src/hid.rs create mode 100644 crates/bosun-hid/src/mock.rs create mode 100644 crates/bosun-hid/src/transport.rs create mode 100644 crates/bosun-hid/tests/hardware.rs diff --git a/apps/bosunctl/src/listing.rs b/apps/bosunctl/src/listing.rs new file mode 100644 index 0000000..5d4ec2f --- /dev/null +++ b/apps/bosunctl/src/listing.rs @@ -0,0 +1,106 @@ +//! Argument parsing and rendering for `bosunctl device list`. +//! +//! Kept free of `clap` and of any HID backend so it can be unit-tested on its +//! own. + +use bosun_hid::DeviceInfo; + +/// Parse a 16-bit identifier written in decimal or `0x`-prefixed hex. +pub fn parse_u16(raw: &str) -> Result { + let text = raw.trim(); + let parsed = match text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) { + Some(hex) => u16::from_str_radix(hex, 16), + None => text.parse::(), + }; + + parsed.map_err(|_| format!("expected a decimal or 0x-prefixed hex 16-bit value, got `{raw}`")) +} + +/// True when an unset filter accepts everything and a set one must match. +pub fn accepts(filter: Option, actual: u16) -> bool { + match filter { + Some(expected) => expected == actual, + None => true, + } +} + +/// One device rendered as two lines: identity, then the path used to open it. +pub fn format_device(info: &DeviceInfo) -> String { + let manufacturer = info.manufacturer.as_deref().unwrap_or(""); + let product = info.product.as_deref().unwrap_or(""); + + format!( + "{:04x}:{:04x} usage_page={:04x} usage={:04x} iface={} {manufacturer} {product}\n path={}", + info.vendor_id, info.product_id, info.usage_page, info.usage, info.interface_number, info.path + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identifiers_parse_in_hex_and_decimal() { + assert_eq!(parse_u16("0x046D"), Ok(0x046D)); + assert_eq!(parse_u16("0X046d"), Ok(0x046D)); + assert_eq!(parse_u16("1133"), Ok(0x046D)); + assert_eq!(parse_u16(" 0xff00 "), Ok(0xFF00)); + assert_eq!(parse_u16("0"), Ok(0)); + assert_eq!(parse_u16("65535"), Ok(0xFFFF)); + } + + #[test] + fn out_of_range_and_malformed_identifiers_are_rejected() { + // 0x10000 and 65536 do not fit in a u16; silently truncating either + // would match the wrong device. + assert!(parse_u16("0x10000").is_err()); + assert!(parse_u16("65536").is_err()); + assert!(parse_u16("").is_err()); + assert!(parse_u16("nonsense").is_err()); + assert!(parse_u16("-1").is_err()); + assert!( + parse_u16("046D").is_err(), + "bare hex must not parse as decimal" + ); + } + + #[test] + fn an_unset_filter_accepts_every_value() { + assert!(accepts(None, 0x046D)); + assert!(accepts(None, 0)); + } + + #[test] + fn a_set_filter_accepts_only_its_own_value() { + assert!(accepts(Some(0x046D), 0x046D)); + assert!(!accepts(Some(0x046D), 0x046E)); + } + + #[test] + fn a_device_renders_with_its_open_path_and_padded_identifiers() { + let info = DeviceInfo { + path: "/dev/hidraw3".to_owned(), + vendor_id: 0x046D, + product_id: 0xC21C, + usage_page: 0xFF00, + usage: 0x0001, + interface_number: 0, + manufacturer: Some("Logitech".to_owned()), + product: Some("G13".to_owned()), + serial_number: None, + }; + + assert_eq!( + format_device(&info), + "046d:c21c usage_page=ff00 usage=0001 iface=0 Logitech G13\n path=/dev/hidraw3" + ); + } + + #[test] + fn missing_strings_render_as_placeholders_rather_than_blanks() { + let rendered = format_device(&DeviceInfo::default()); + + assert!(rendered.contains(" "), "{rendered}"); + assert!(rendered.contains("0000:0000"), "{rendered}"); + } +} diff --git a/apps/bosunctl/src/main.rs b/apps/bosunctl/src/main.rs index 04436bd..c46f0b7 100644 --- a/apps/bosunctl/src/main.rs +++ b/apps/bosunctl/src/main.rs @@ -1,3 +1,178 @@ -fn main() { - println!("bosunctl: M1 implementation has not started; see docs/PLAN-REVIEW.md"); +//! `bosunctl` — the Bosun control CLI. +//! +//! M1 scope. The CLI names devices by match criteria on the command line; no +//! product identifiers are compiled in. + +mod listing; + +use anyhow::{Context, Result}; +use bosun_hid::{DeviceInfo, HidTransport}; +use clap::{Args, Parser, Subcommand}; +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() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + ) + .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()); + } } diff --git a/crates/bosun-hid/src/device.rs b/crates/bosun-hid/src/device.rs new file mode 100644 index 0000000..80299f8 --- /dev/null +++ b/crates/bosun-hid/src/device.rs @@ -0,0 +1,190 @@ +//! Device identity, enumeration metadata, and selection criteria. +//! +//! This module is policy-free: it knows nothing about any particular product. +//! Callers supply match criteria; the G13 lives in device data, not here. + +use std::fmt; + +/// Metadata for one enumerated HID interface. +/// +/// Windows enumerates a separate entry per top-level collection, so a single +/// physical device can appear several times under one VID/PID with different +/// usage pages. `hidraw` and `IOHIDManager` do the same for composite devices. +/// Selection therefore has to consider the usage page, never VID/PID alone. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DeviceInfo { + /// Backend-specific path that opens this exact interface. + pub path: String, + pub vendor_id: u16, + pub product_id: u16, + pub usage_page: u16, + pub usage: u16, + pub interface_number: i32, + pub manufacturer: Option, + pub product: Option, + pub serial_number: Option, +} + +/// Criteria that identify one HID interface. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DeviceMatch { + pub vendor_id: u16, + pub product_id: u16, + pub usage_page: u16, +} + +impl DeviceMatch { + pub const fn new(vendor_id: u16, product_id: u16, usage_page: u16) -> Self { + Self { + vendor_id, + product_id, + usage_page, + } + } + + /// True when `info` satisfies every criterion. + pub fn matches(&self, info: &DeviceInfo) -> bool { + self.vendor_id == info.vendor_id + && self.product_id == info.product_id + && self.usage_page == info.usage_page + } +} + +impl fmt::Display for DeviceMatch { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{:04x}:{:04x} usage_page={:04x}", + self.vendor_id, self.product_id, self.usage_page + ) + } +} + +/// Index of the first interface in enumeration order satisfying `criteria`. +/// +/// Backends hand back their own richer handle alongside this metadata, so +/// callers that need to reach it match on the index rather than on the path. +pub fn select_index(devices: &[DeviceInfo], criteria: &DeviceMatch) -> Option { + devices.iter().position(|info| criteria.matches(info)) +} + +/// The first interface in enumeration order that satisfies `criteria`. +/// +/// Enumeration order is the backend's own, so this is stable for a given +/// machine and cabling. Open the returned [`DeviceInfo::path`] rather than +/// re-deriving a path from VID/PID. +pub fn select<'a>(devices: &'a [DeviceInfo], criteria: &DeviceMatch) -> Option<&'a DeviceInfo> { + select_index(devices, criteria).map(|index| &devices[index]) +} + +/// Every interface satisfying `criteria`, in enumeration order. +pub fn select_all<'a>(devices: &'a [DeviceInfo], criteria: &DeviceMatch) -> Vec<&'a DeviceInfo> { + devices + .iter() + .filter(|info| criteria.matches(info)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Criteria for the interface M1 targets, used only as test data. + const TARGET: DeviceMatch = DeviceMatch::new(0x046D, 0xC21C, 0xFF00); + + fn interface(path: &str, vendor_id: u16, product_id: u16, usage_page: u16) -> DeviceInfo { + DeviceInfo { + path: path.to_owned(), + vendor_id, + product_id, + usage_page, + ..DeviceInfo::default() + } + } + + #[test] + fn matches_requires_all_three_fields() { + let target = interface("a", 0x046D, 0xC21C, 0xFF00); + assert!(TARGET.matches(&target)); + + assert!(!TARGET.matches(&interface("a", 0x046E, 0xC21C, 0xFF00))); + assert!(!TARGET.matches(&interface("a", 0x046D, 0xC21D, 0xFF00))); + } + + #[test] + fn matching_vid_pid_with_a_different_usage_page_is_rejected() { + // The keyboard collection of the same physical device. Opening it + // yields no vendor reports, so VID/PID alone is not a valid match. + let keyboard_collection = interface("kbd", 0x046D, 0xC21C, 0x0001); + + assert!(!TARGET.matches(&keyboard_collection)); + } + + #[test] + fn select_picks_the_vendor_collection_from_a_composite_device() { + let devices = vec![ + interface("kbd", 0x046D, 0xC21C, 0x0001), + interface("consumer", 0x046D, 0xC21C, 0x000C), + interface("vendor", 0x046D, 0xC21C, 0xFF00), + ]; + + let chosen = select(&devices, &TARGET).expect("vendor collection is present"); + + assert_eq!(chosen.path, "vendor"); + } + + #[test] + fn select_returns_none_when_nothing_matches() { + let devices = vec![interface("kbd", 0x046D, 0xC21C, 0x0001)]; + + assert!(select(&devices, &TARGET).is_none()); + assert!(select(&[], &TARGET).is_none()); + } + + #[test] + fn select_is_deterministic_when_several_interfaces_match() { + let devices = vec![ + interface("first", 0x046D, 0xC21C, 0xFF00), + interface("second", 0x046D, 0xC21C, 0xFF00), + ]; + + let chosen = select(&devices, &TARGET).expect("a match is present"); + + assert_eq!(chosen.path, "first"); + } + + #[test] + fn select_index_points_at_the_matched_interface() { + let devices = vec![ + interface("kbd", 0x046D, 0xC21C, 0x0001), + interface("consumer", 0x046D, 0xC21C, 0x000C), + interface("vendor", 0x046D, 0xC21C, 0xFF00), + ]; + + // The index is what lets a backend recover its own handle for the + // interface this crate chose. + assert_eq!(select_index(&devices, &TARGET), Some(2)); + assert_eq!(select_index(&[], &TARGET), None); + } + + #[test] + fn select_all_returns_every_match_in_enumeration_order() { + let devices = vec![ + interface("first", 0x046D, 0xC21C, 0xFF00), + interface("kbd", 0x046D, 0xC21C, 0x0001), + interface("second", 0x046D, 0xC21C, 0xFF00), + ]; + + let paths: Vec<&str> = select_all(&devices, &TARGET) + .iter() + .map(|info| info.path.as_str()) + .collect(); + + assert_eq!(paths, ["first", "second"]); + } + + #[test] + fn display_is_stable_for_error_messages() { + assert_eq!(TARGET.to_string(), "046d:c21c usage_page=ff00"); + } +} diff --git a/crates/bosun-hid/src/error.rs b/crates/bosun-hid/src/error.rs new file mode 100644 index 0000000..0114830 --- /dev/null +++ b/crates/bosun-hid/src/error.rs @@ -0,0 +1,29 @@ +//! Backend-agnostic errors. +//! +//! Nothing above `bosun-hid` should have to know which HID backend produced a +//! failure, so backend errors are flattened into [`HidError::Backend`]. + +use crate::device::DeviceMatch; + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum HidError { + /// Enumeration completed but no interface satisfied the criteria. + #[error("no HID interface matched {0}")] + NotFound(DeviceMatch), + + /// The device went away. Callers that want hot-plug recovery re-enumerate + /// and re-open rather than retrying on the dead handle. + #[error("the device is no longer connected")] + Disconnected, + + /// The caller's buffer cannot hold the report. + #[error("buffer holds {actual} bytes but the report needs {expected}")] + BufferTooSmall { expected: usize, actual: usize }, + + /// Any failure reported by the underlying HID backend. + #[error("HID backend failure: {0}")] + Backend(String), +} diff --git a/crates/bosun-hid/src/hid.rs b/crates/bosun-hid/src/hid.rs new file mode 100644 index 0000000..736ac61 --- /dev/null +++ b/crates/bosun-hid/src/hid.rs @@ -0,0 +1,125 @@ +//! The `hidapi` transport over the stock OS HID stack. +//! +//! Per ADR-0001 this is the only backend: Windows HID API, macOS +//! `IOHIDManager`, and Linux `hidraw`. No libusb, WinUSB, kext, or kernel +//! module is used or supported. + +use std::ffi::CString; +use std::time::Duration; + +use hidapi::{DeviceInfo as HidDeviceInfo, HidApi, HidDevice}; + +use crate::device::{self, DeviceInfo, DeviceMatch}; +use crate::error::{HidError, Result}; +use crate::transport::{timeout_millis, ReadOutcome, Transport}; + +/// A blocking [`Transport`] bound to one opened HID interface. +pub struct HidTransport { + device: HidDevice, + info: DeviceInfo, +} + +impl HidTransport { + /// Create the backend handle used for enumeration and opening. + /// + /// `hidapi` permits only one live [`HidApi`] per process, so hold this for + /// the lifetime of the process rather than creating one per operation. + /// Re-enumerate after a hot-plug with [`HidApi::refresh_devices`]. + pub fn api() -> Result { + HidApi::new().map_err(backend_error) + } + + /// Every HID interface the OS currently exposes, in enumeration order. + pub fn enumerate(api: &HidApi) -> Vec { + api.device_list().map(convert).collect() + } + + /// Match on VID, PID, and usage page, then open the enumerated path of the + /// interface that matched. + /// + /// Matching on VID/PID alone would open whichever collection the OS + /// happened to list first, which for a composite device is usually the + /// wrong one. + pub fn open(api: &HidApi, criteria: &DeviceMatch) -> Result { + let entries: Vec<&HidDeviceInfo> = api.device_list().collect(); + let mut infos: Vec = entries.iter().copied().map(convert).collect(); + + let index = match device::select_index(&infos, criteria) { + Some(index) => index, + None => return Err(HidError::NotFound(*criteria)), + }; + let device = api + .open_path(entries[index].path()) + .map_err(backend_error)?; + + Ok(Self { + info: infos.swap_remove(index), + device, + }) + } + + /// Open one specific enumerated path. + pub fn open_path(api: &HidApi, path: &str) -> Result { + let info = Self::enumerate(api) + .into_iter() + .find(|info| info.path == path) + .ok_or_else(|| HidError::Backend(format!("no enumerated HID interface at {path}")))?; + let c_path = CString::new(path) + .map_err(|_| HidError::Backend(format!("HID path contains an interior NUL: {path}")))?; + let device = api.open_path(&c_path).map_err(backend_error)?; + + Ok(Self { device, info }) + } +} + +impl Transport for HidTransport { + fn info(&self) -> &DeviceInfo { + &self.info + } + + /// Read one input report. + /// + /// The backend cannot distinguish an unplug from other failures portably, + /// so a caller wanting hot-plug recovery treats any error here as a signal + /// to re-enumerate and re-open rather than to retry this handle. + fn read(&mut self, buf: &mut [u8], timeout: Duration) -> Result { + match self.device.read_timeout(buf, timeout_millis(timeout)) { + Ok(0) => Ok(ReadOutcome::Timeout), + Ok(len) => Ok(ReadOutcome::Report(len)), + Err(error) => Err(backend_error(error)), + } + } + + fn write(&mut self, data: &[u8]) -> Result { + self.device.write(data).map_err(backend_error) + } + + fn send_feature_report(&mut self, data: &[u8]) -> Result<()> { + self.device.send_feature_report(data).map_err(backend_error) + } + + fn get_feature_report(&mut self, buf: &mut [u8]) -> Result { + self.device.get_feature_report(buf).map_err(backend_error) + } +} + +/// Flatten a backend error so callers never depend on the `hidapi` types. +fn backend_error(error: hidapi::HidError) -> HidError { + HidError::Backend(error.to_string()) +} + +fn convert(entry: &HidDeviceInfo) -> DeviceInfo { + DeviceInfo { + // Paths are ASCII device paths on every supported platform; the lossy + // conversion exists so the type above this crate stays a plain String. + path: entry.path().to_string_lossy().into_owned(), + vendor_id: entry.vendor_id(), + product_id: entry.product_id(), + usage_page: entry.usage_page(), + usage: entry.usage(), + interface_number: entry.interface_number(), + manufacturer: entry.manufacturer_string().map(str::to_owned), + product: entry.product_string().map(str::to_owned), + serial_number: entry.serial_number().map(str::to_owned), + } +} diff --git a/crates/bosun-hid/src/lib.rs b/crates/bosun-hid/src/lib.rs index 6a6216d..5de75d5 100644 --- a/crates/bosun-hid/src/lib.rs +++ b/crates/bosun-hid/src/lib.rs @@ -1,3 +1,44 @@ -//! Synchronous, policy-free HID transport and device codecs for Bosun. +//! Synchronous, policy-free HID transport for Bosun. //! -//! M1 implementation begins test-first. See the repository `AGENTS.md`. +//! This crate moves HID reports and nothing else. It is deliberately blocking +//! (async bridging belongs to the M4 daemon) and holds no product knowledge: +//! a device is a set of match criteria plus, above this layer, a descriptor. +//! +//! The only backend is the stock OS HID stack via `hidapi`, per ADR-0001. +//! +//! # Testing without hardware +//! +//! [`MockTransport`] replays a scripted sequence of reports, timeouts, and +//! disconnects, so every layer above this crate is testable with nothing +//! plugged in. +//! +//! ``` +//! use std::time::Duration; +//! use bosun_hid::{DeviceInfo, MockTransport, ReadOutcome, Transport}; +//! +//! let mut transport = MockTransport::new(DeviceInfo::default()) +//! .push_timeout() +//! .push_report(&[0x01, 0x80, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00]); +//! +//! let mut buf = [0u8; 8]; +//! assert_eq!( +//! transport.read(&mut buf, Duration::from_millis(10)).unwrap(), +//! ReadOutcome::Timeout +//! ); +//! assert_eq!( +//! transport.read(&mut buf, Duration::from_millis(10)).unwrap(), +//! ReadOutcome::Report(8) +//! ); +//! ``` + +pub mod device; +pub mod error; +pub mod hid; +pub mod mock; +pub mod transport; + +pub use device::{select, select_all, select_index, DeviceInfo, DeviceMatch}; +pub use error::{HidError, Result}; +pub use hid::HidTransport; +pub use mock::{MockTransport, ScriptedRead}; +pub use transport::{ReadOutcome, Transport}; diff --git a/crates/bosun-hid/src/mock.rs b/crates/bosun-hid/src/mock.rs new file mode 100644 index 0000000..9d0edc8 --- /dev/null +++ b/crates/bosun-hid/src/mock.rs @@ -0,0 +1,292 @@ +//! A scripted in-memory [`Transport`] so everything above this crate is +//! testable with no hardware attached. + +use std::collections::VecDeque; +use std::time::Duration; + +use crate::device::DeviceInfo; +use crate::error::{HidError, Result}; +use crate::transport::{ReadOutcome, Transport}; + +/// One scripted answer to a [`Transport::read`] call. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ScriptedRead { + /// Deliver this report to the caller. + Report(Vec), + /// Report that the timeout elapsed. + Timeout, + /// Report that the device went away. + Disconnect, +} + +/// A [`Transport`] that replays a script and records everything written to it. +/// +/// Once the read script is exhausted every further read reports +/// [`HidError::Disconnected`], which models an unplug and keeps a reconnect +/// loop from spinning forever against an empty mock. +#[derive(Clone, Debug)] +pub struct MockTransport { + info: DeviceInfo, + reads: VecDeque, + feature_reads: VecDeque>, + writes: Vec>, + feature_writes: Vec>, +} + +impl MockTransport { + pub fn new(info: DeviceInfo) -> Self { + Self { + info, + reads: VecDeque::new(), + feature_reads: VecDeque::new(), + writes: Vec::new(), + feature_writes: Vec::new(), + } + } + + /// Queue an input report. + #[must_use] + pub fn push_report(mut self, report: &[u8]) -> Self { + self.reads.push_back(ScriptedRead::Report(report.to_vec())); + self + } + + /// Queue an elapsed timeout. + #[must_use] + pub fn push_timeout(mut self) -> Self { + self.reads.push_back(ScriptedRead::Timeout); + self + } + + /// Queue a disconnect. + #[must_use] + pub fn push_disconnect(mut self) -> Self { + self.reads.push_back(ScriptedRead::Disconnect); + self + } + + /// Queue the payload a [`Transport::get_feature_report`] call will return. + #[must_use] + pub fn push_feature_report(mut self, report: &[u8]) -> Self { + self.feature_reads.push_back(report.to_vec()); + self + } + + /// Output reports written so far, in order. + pub fn writes(&self) -> &[Vec] { + &self.writes + } + + /// Feature reports sent so far, in order. + pub fn feature_writes(&self) -> &[Vec] { + &self.feature_writes + } + + /// Scripted reads not yet consumed. + pub fn reads_remaining(&self) -> usize { + self.reads.len() + } +} + +impl Transport for MockTransport { + fn info(&self) -> &DeviceInfo { + &self.info + } + + fn read(&mut self, buf: &mut [u8], _timeout: Duration) -> Result { + match self.reads.pop_front() { + None | Some(ScriptedRead::Disconnect) => Err(HidError::Disconnected), + Some(ScriptedRead::Timeout) => Ok(ReadOutcome::Timeout), + Some(ScriptedRead::Report(report)) => { + copy_report(&report, buf).map(ReadOutcome::Report) + } + } + } + + fn write(&mut self, data: &[u8]) -> Result { + self.writes.push(data.to_vec()); + Ok(data.len()) + } + + fn send_feature_report(&mut self, data: &[u8]) -> Result<()> { + self.feature_writes.push(data.to_vec()); + Ok(()) + } + + fn get_feature_report(&mut self, buf: &mut [u8]) -> Result { + let report = self + .feature_reads + .pop_front() + .ok_or(HidError::Disconnected)?; + copy_report(&report, buf) + } +} + +fn copy_report(report: &[u8], buf: &mut [u8]) -> Result { + if buf.len() < report.len() { + return Err(HidError::BufferTooSmall { + expected: report.len(), + actual: buf.len(), + }); + } + + buf[..report.len()].copy_from_slice(report); + Ok(report.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const TIMEOUT: Duration = Duration::from_millis(10); + + fn mock() -> MockTransport { + MockTransport::new(DeviceInfo { + path: "mock".to_owned(), + vendor_id: 0x046D, + product_id: 0xC21C, + usage_page: 0xFF00, + ..DeviceInfo::default() + }) + } + + #[test] + fn scripted_reports_arrive_in_order() { + let mut transport = mock() + .push_report(&[0x01, 0x80, 0x80, 0, 0, 0, 0, 0]) + .push_report(&[0x01, 0x80, 0x80, 1, 0, 0, 0, 0]); + let mut buf = [0u8; 8]; + + assert_eq!( + transport.read(&mut buf, TIMEOUT).unwrap(), + ReadOutcome::Report(8) + ); + assert_eq!(buf, [0x01, 0x80, 0x80, 0, 0, 0, 0, 0]); + + assert_eq!( + transport.read(&mut buf, TIMEOUT).unwrap(), + ReadOutcome::Report(8) + ); + assert_eq!(buf, [0x01, 0x80, 0x80, 1, 0, 0, 0, 0]); + } + + #[test] + fn a_timeout_consumes_only_its_own_script_entry() { + let mut transport = mock().push_timeout().push_report(&[0x01, 0xAA]); + let mut buf = [0u8; 8]; + + assert_eq!( + transport.read(&mut buf, TIMEOUT).unwrap(), + ReadOutcome::Timeout + ); + assert_eq!( + transport.read(&mut buf, TIMEOUT).unwrap(), + ReadOutcome::Report(2) + ); + assert_eq!(&buf[..2], &[0x01, 0xAA]); + } + + #[test] + fn an_exhausted_script_reports_a_disconnect() { + let mut transport = mock().push_report(&[0x01, 0xAA]); + let mut buf = [0u8; 8]; + + transport.read(&mut buf, TIMEOUT).unwrap(); + + assert!(matches!( + transport.read(&mut buf, TIMEOUT), + Err(HidError::Disconnected) + )); + // Still disconnected on a retry: reconnect means re-opening, not + // retrying the dead handle. + assert!(matches!( + transport.read(&mut buf, TIMEOUT), + Err(HidError::Disconnected) + )); + } + + #[test] + fn a_scripted_disconnect_interrupts_a_pending_script() { + let mut transport = mock().push_disconnect().push_report(&[0x01, 0xAA]); + let mut buf = [0u8; 8]; + + assert!(matches!( + transport.read(&mut buf, TIMEOUT), + Err(HidError::Disconnected) + )); + } + + #[test] + fn a_short_buffer_is_refused_rather_than_truncating_a_report() { + let mut transport = mock().push_report(&[0x01, 0x02, 0x03, 0x04]); + let mut buf = [0u8; 2]; + + assert!(matches!( + transport.read(&mut buf, TIMEOUT), + Err(HidError::BufferTooSmall { + expected: 4, + actual: 2 + }) + )); + } + + #[test] + fn a_longer_buffer_reports_only_the_bytes_received() { + let mut transport = mock().push_report(&[0x01, 0x02]); + let mut buf = [0xFFu8; 8]; + + assert_eq!( + transport.read(&mut buf, TIMEOUT).unwrap(), + ReadOutcome::Report(2) + ); + assert_eq!(&buf[..2], &[0x01, 0x02]); + // Bytes past the report are the caller's to interpret; the length is + // the only claim the transport makes. + assert_eq!(&buf[2..], &[0xFF; 6]); + } + + #[test] + fn writes_are_recorded_verbatim_and_separately_from_feature_reports() { + let mut transport = mock(); + + assert_eq!(transport.write(&[0x03, 0x00, 0x01]).unwrap(), 3); + transport + .send_feature_report(&[0x07, 0xFF, 0x00, 0x00]) + .unwrap(); + + assert_eq!(transport.writes(), [vec![0x03, 0x00, 0x01]]); + assert_eq!(transport.feature_writes(), [vec![0x07, 0xFF, 0x00, 0x00]]); + } + + #[test] + fn feature_reads_are_queued_and_run_out_independently_of_input_reports() { + let mut transport = mock() + .push_report(&[0x01, 0xAA]) + .push_feature_report(&[0x07, 0x11, 0x22, 0x33]); + let mut buf = [0u8; 8]; + + assert_eq!(transport.get_feature_report(&mut buf).unwrap(), 4); + assert_eq!(&buf[..4], &[0x07, 0x11, 0x22, 0x33]); + assert!(matches!( + transport.get_feature_report(&mut buf), + Err(HidError::Disconnected) + )); + + // The input script is untouched by feature traffic. + assert_eq!(transport.reads_remaining(), 1); + } + + #[test] + fn the_transport_is_usable_as_a_trait_object() { + // Higher layers hold `Box`, so object safety is part of + // the contract, not an implementation detail. + let mut transport: Box = Box::new(mock().push_report(&[0x01, 0xAA])); + let mut buf = [0u8; 8]; + + assert_eq!(transport.info().path, "mock"); + assert_eq!( + transport.read(&mut buf, TIMEOUT).unwrap(), + ReadOutcome::Report(2) + ); + } +} diff --git a/crates/bosun-hid/src/transport.rs b/crates/bosun-hid/src/transport.rs new file mode 100644 index 0000000..8d312fa --- /dev/null +++ b/crates/bosun-hid/src/transport.rs @@ -0,0 +1,94 @@ +//! The synchronous transport abstraction. +//! +//! `Transport` is deliberately blocking and free of policy: it moves reports, +//! and nothing else. Async bridging is a daemon concern (M4), not this crate's. + +use std::time::Duration; + +use crate::device::DeviceInfo; +use crate::error::Result; + +/// The result of one read attempt. +/// +/// Timeouts are a distinct outcome rather than a zero-length report, so a +/// caller polling for input cannot silently confuse "nothing happened" with +/// "the device went away". +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReadOutcome { + /// A report was written into the caller's buffer, this many bytes long. + Report(usize), + /// The timeout elapsed with no report available. + Timeout, +} + +/// A synchronous, policy-free HID channel to one opened interface. +pub trait Transport { + /// Metadata for the interface this transport is bound to. + fn info(&self) -> &DeviceInfo; + + /// Read one input report, waiting at most `timeout`. + fn read(&mut self, buf: &mut [u8], timeout: Duration) -> Result; + + /// Write one output report. `data[0]` is the report ID. + fn write(&mut self, data: &[u8]) -> Result; + + /// Send one feature report. `data[0]` is the report ID. + fn send_feature_report(&mut self, data: &[u8]) -> Result<()>; + + /// Read one feature report. `buf[0]` must hold the requested report ID. + fn get_feature_report(&mut self, buf: &mut [u8]) -> Result; +} + +/// Clamp a timeout to the non-negative millisecond range the HID backend takes. +/// +/// A negative value means "block forever" to `hidapi`, which would turn a long +/// timeout into a hang, so saturate instead of wrapping. +pub(crate) fn timeout_millis(timeout: Duration) -> i32 { + i32::try_from(timeout.as_millis()).unwrap_or(i32::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zero_timeout_polls_without_blocking() { + assert_eq!(timeout_millis(Duration::ZERO), 0); + } + + #[test] + fn ordinary_timeouts_convert_exactly() { + assert_eq!(timeout_millis(Duration::from_millis(1)), 1); + assert_eq!(timeout_millis(Duration::from_secs(1)), 1_000); + assert_eq!(timeout_millis(Duration::from_secs(2)), 2_000); + } + + #[test] + fn oversized_timeouts_saturate_instead_of_going_negative() { + // The failure this guards against: a wrapped conversion yielding a + // negative value, which `hidapi` reads as "block forever". + assert_eq!(timeout_millis(Duration::MAX), i32::MAX); + assert_eq!( + timeout_millis(Duration::from_millis(i32::MAX as u64 + 1)), + i32::MAX + ); + } + + #[test] + fn no_duration_ever_converts_to_a_blocking_read() { + let durations = [ + Duration::ZERO, + Duration::from_millis(1), + Duration::from_secs(2), + Duration::from_secs(u32::MAX as u64), + Duration::MAX, + ]; + + for duration in durations { + assert!( + timeout_millis(duration) >= 0, + "{duration:?} produced a blocking timeout" + ); + } + } +} diff --git a/crates/bosun-hid/tests/hardware.rs b/crates/bosun-hid/tests/hardware.rs new file mode 100644 index 0000000..b18014b --- /dev/null +++ b/crates/bosun-hid/tests/hardware.rs @@ -0,0 +1,91 @@ +//! Hardware-in-the-loop tests. +//! +//! Ignored by default and additionally gated on `BOSUN_HW=1`, per +//! `CONTRIBUTING.md`. With the device attached: +//! +//! ```text +//! BOSUN_HW=1 cargo test -p bosun-hid --test hardware -- --ignored --nocapture +//! ``` +//! +//! [`r1_shared_input_reports_reach_a_second_reader`] is risk R1 from +//! `docs/BOSUN-PLAN.md`: it answers whether a second reader receives input +//! reports while Logitech Gaming Software holds its own handle. Run it once +//! with LGS running and, if no reports arrive, again with LGS closed. + +use std::time::{Duration, Instant}; + +use bosun_hid::{HidTransport, ReadOutcome, Transport}; + +/// Both gates must be open: `#[ignore]` keeps this out of normal runs, and +/// `BOSUN_HW=1` keeps `--ignored` runs on machines without hardware honest. +fn hardware_enabled() -> bool { + match std::env::var("BOSUN_HW") { + Ok(value) if value == "1" => true, + _ => { + eprintln!("skipped: set BOSUN_HW=1 with the device attached"); + false + } + } +} + +#[test] +#[ignore = "requires attached hardware; run with BOSUN_HW=1"] +fn the_backend_enumerates_hid_interfaces() { + if !hardware_enabled() { + return; + } + + let api = HidTransport::api().expect("the HID backend initialises"); + let devices = HidTransport::enumerate(&api); + + for info in &devices { + eprintln!( + "{:04x}:{:04x} usage_page={:04x} path={}", + info.vendor_id, info.product_id, info.usage_page, info.path + ); + } + + assert!( + !devices.is_empty(), + "no HID interfaces enumerated; on Linux check the udev rule" + ); +} + +#[test] +#[ignore = "requires attached hardware; run with BOSUN_HW=1 and BOSUN_HW_PATH"] +fn r1_shared_input_reports_reach_a_second_reader() { + if !hardware_enabled() { + return; + } + + let Ok(path) = std::env::var("BOSUN_HW_PATH") else { + eprintln!("skipped: set BOSUN_HW_PATH to a path from `bosunctl device list`"); + return; + }; + + let api = HidTransport::api().expect("the HID backend initialises"); + let mut transport = HidTransport::open_path(&api, &path).expect("the interface opens"); + + eprintln!("press keys on the device for the next 15 seconds"); + + let deadline = Instant::now() + Duration::from_secs(15); + let mut buf = [0u8; 64]; + let mut reports = 0usize; + + while Instant::now() < deadline { + match transport.read(&mut buf, Duration::from_millis(500)) { + Ok(ReadOutcome::Report(len)) => { + reports += 1; + eprintln!("report {reports}: {:02x?}", &buf[..len]); + } + Ok(ReadOutcome::Timeout) => {} + Err(error) => panic!("read failed after {reports} reports: {error}"), + } + } + + assert!( + reports > 0, + "no input reports arrived in 15 s. If LGS is running, close it and \ + re-run before concluding that shared reads do not work (risk R1)." + ); +}