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)." + ); +}