From e052d198797481aea49a2e799a2bd8d79b5ff15c Mon Sep 17 00:00:00 2001 From: The Nomad Date: Sat, 17 Oct 2026 06:48:02 -0400 Subject: [PATCH] 3D mouse: any HID multi-axis controller with motion axes (#1880) Thanks @nomadsgalaxy. --- Cargo.lock | 5 + Cargo.toml | 1 + README.md | 24 +- apps/solidcraft/Cargo.toml | 1 + apps/solidcraft/src/space_mouse.rs | 228 ++--- .../src/space_mouse/hid_descriptor.rs | 253 ------ apps/solidcraft/src/space_mouse/hidraw.rs | 42 +- crates/space-mouse/Cargo.toml | 9 + crates/space-mouse/src/descriptor.rs | 816 ++++++++++++++++++ crates/space-mouse/src/lib.rs | 15 + xtask/src/layers.rs | 2 + 11 files changed, 998 insertions(+), 398 deletions(-) delete mode 100644 apps/solidcraft/src/space_mouse/hid_descriptor.rs create mode 100644 crates/space-mouse/Cargo.toml create mode 100644 crates/space-mouse/src/descriptor.rs create mode 100644 crates/space-mouse/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 82a4f865..cd011e4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4078,6 +4078,7 @@ dependencies = [ "rustls", "serde_json", "solidcraft-engine", + "solidcraft-space-mouse", "solidcraft-ui-egui", "ureq", "windows-sys 0.61.2", @@ -4236,6 +4237,10 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "solidcraft-space-mouse" +version = "0.14.0" + [[package]] name = "solidcraft-ui-egui" version = "0.14.0" diff --git a/Cargo.toml b/Cargo.toml index ab168710..9f53bb0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ solidcraft-doc = { path = "crates/doc" } solidcraft-io = { path = "crates/io" } solidcraft-engine = { path = "crates/engine" } solidcraft-ui-egui = { path = "crates/ui-egui" } +solidcraft-space-mouse = { path = "crates/space-mouse" } solidcraft-mcp = { path = "crates/mcp" } solidcraft-derive = { path = "crates/derive" } diff --git a/README.md b/README.md index 215caeac..094dc3f3 100644 --- a/README.md +++ b/README.md @@ -218,8 +218,9 @@ unaccepted artwork Open; run `cargo xtask icons` to check the collection. Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`. -On Windows and Linux, SolidCraft detects a connected 3Dconnexion SpaceNavigator or SpaceMouse through its -multi-axis HID interface. Move the cap to pan and zoom, and tilt or twist it to rotate the 3D view. +On Windows and Linux, SolidCraft detects a connected 3D mouse through its HID multi-axis +controller interface: a 3Dconnexion SpaceNavigator or SpaceMouse, or a homemade device that +declares itself one. Move the cap to pan and zoom, and tilt or twist it to rotate the 3D view. While editing a sketch, the cap pans and zooms without changing the sketch view. The Navigation page in Preferences has an on/off switch, a speed control and, for each of the cap's six axes, an invert switch and a speed for the 3D mouse. The web app reads @@ -236,17 +237,24 @@ zones) applies as it does in other programs, and no udev rule is needed. With no the motion is the same as without the daemon. Without `spacenavd`, SolidCraft reads `/dev/hidrawN` directly, so the logged-in user needs access to the device. A udev rule that adds the `uaccess` tag does that, for example -`/etc/udev/rules.d/70-3d-mouse.rules` for devices with 3Dconnexion's vendor id: +`/etc/udev/rules.d/70-3d-mouse.rules` for USB devices with 3Dconnexion's vendor id, and for its +Bluetooth ones (where `idVendor` isn't set, so the HID id is matched instead): ``` SUBSYSTEM=="hidraw", ATTRS{idVendor}=="256f", TAG+="uaccess" +SUBSYSTEM=="hidraw", KERNELS=="0005:256F:*", TAG+="uaccess" ``` -Older SpaceNavigators use Logitech's vendor id `046d`; add a rule with their product id (see `lsusb`) -instead of matching every Logitech device. Reload with `sudo udevadm control --reload && sudo udevadm trigger`, then replug the device. The Flatpak -sandbox does not expose hidraw devices, so the 3D mouse only works with native builds there for now. -Devices that send all six axes in HID report 1 (some homemade ones do) are recognised from their -report descriptor, so they work as well. +Older SpaceNavigators use Logitech's vendor id `046d`, so add a rule with their product id (see +`lsusb`) instead of matching every Logitech device. A homemade device needs a rule with its own +vendor and product id. Reload with `sudo udevadm control --reload && sudo udevadm trigger`, then +replug the device. The Flatpak sandbox does not expose hidraw devices, so the 3D mouse only works +with native builds there for now. + +SolidCraft finds the axes from the device's report descriptor, so it doesn't matter how a device +lays them out: translation and rotation in separate reports (3Dconnexion's wired devices), all six +in one (its wireless ones and most homemade devices), after the buttons, or narrower than 16 bits. +Each axis is scaled from the range the descriptor declares. In the browser: `cd apps/solidcraft-web && trunk build --release --public-url ./` (WebGPU, or WebGL2 with `?webgl`; `?sample` opens the sample part). diff --git a/apps/solidcraft/Cargo.toml b/apps/solidcraft/Cargo.toml index 20e0fa7f..df9e36ed 100644 --- a/apps/solidcraft/Cargo.toml +++ b/apps/solidcraft/Cargo.toml @@ -23,6 +23,7 @@ neg_cmp_op_on_partial_ord = "allow" [dependencies] solidcraft-engine = { workspace = true } solidcraft-ui-egui = { workspace = true } +solidcraft-space-mouse = { workspace = true } eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] } egui = { workspace = true } serde_json = { workspace = true } diff --git a/apps/solidcraft/src/space_mouse.rs b/apps/solidcraft/src/space_mouse.rs index d261fd0e..a6e30195 100644 --- a/apps/solidcraft/src/space_mouse.rs +++ b/apps/solidcraft/src/space_mouse.rs @@ -1,8 +1,8 @@ -//! 3Dconnexion HID navigation on Windows (through hidapi) and Linux (through spacenavd when it -//! runs, else hidraw). The device reader never touches the document or UI; it publishes the -//! latest axes, and the egui thread applies them once per frame. +//! 3D mouse navigation on Windows (through hidapi) and Linux (through spacenavd when it runs, else +//! hidraw). Read directly, any HID multi-axis controller works: 3Dconnexion's and homemade ones. +//! The device reader never touches the document or UI; it publishes the latest axes, and the egui +//! thread applies them once per frame. -mod hid_descriptor; #[cfg(target_os = "linux")] mod hidraw; #[cfg(target_os = "linux")] @@ -12,17 +12,20 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use egui::Context; -use hid_descriptor::Layout; #[cfg(target_os = "windows")] use hidapi::{DeviceInfo, HidApi}; use quick_xml::Reader; use quick_xml::events::Event; +use solidcraft_space_mouse::Layout; use solidcraft_ui_egui::SolveApp; use solidcraft_ui_egui::space_mouse::{Axes, AxisRule, DriverMapping, WheelGuard, navigate_frame}; const STALE_AFTER: Duration = Duration::from_millis(150); const RETRY_AFTER: Duration = Duration::from_secs(2); const MAX_PROFILE_BYTES: u64 = 1024 * 1024; +/// Room for one input report: the 4096 bytes `solidcraft_space_mouse` places fields in, plus the +/// report id. A smaller buffer would cut a longer report short, and a cut report is dropped whole. +const REPORT_BUFFER_BYTES: usize = 4096 + 1; /// The mapping used when no 3DxWare profile is found: what the driver applies by default in /// object mode. On Linux there is no 3DxWare (#1172); on Windows 3DxWare has no profile for @@ -156,8 +159,9 @@ fn load_driver_mapping() -> Option { #[derive(Clone, Copy)] struct Reading { axes: Axes, - translation_at: Instant, - rotation_at: Instant, + /// When each of X, Y, Z, Rx, Ry and Rz last arrived. Each ages on its own: a device may send + /// translation and rotation in separate reports, or spread even one group over several. + at: [Instant; 6], /// The source says when the cap is back at rest, so a reading stays until the next one. /// A HID device repeats its report while the cap is held; spacenavd sends a record only when /// the values change, and a steady hold can go seconds without one. @@ -166,64 +170,42 @@ struct Reading { impl Reading { fn new(now: Instant) -> Self { - Self { axes: Axes::default(), translation_at: now, rotation_at: now, held: false } + Self { axes: Axes::default(), at: [now; 6], held: false } } fn current(self, now: Instant) -> Axes { - if self.held { - return self.axes; - } - Axes { - translation: if now.duration_since(self.translation_at) < STALE_AFTER { self.axes.translation } else { [0; 3] }, - rotation: if now.duration_since(self.rotation_at) < STALE_AFTER { self.axes.rotation } else { [0; 3] }, + let mut axes = self.axes; + if !self.held { + for (value, at) in axes.translation.iter_mut().chain(axes.rotation.iter_mut()).zip(self.at) { + if now.duration_since(at) >= STALE_AFTER { + *value = 0; + } + } } + axes } } -/// The old SpaceNavigator uses Logitech's VID; newer 3Dconnexion devices use their own. -fn is_3d_mouse_vendor(vendor_id: u16) -> bool { - matches!(vendor_id, 0x046d | 0x256f) -} - -/// The HID usage excludes ordinary Logitech mice and the keyboard interfaces of SpaceMice. +/// The Generic Desktop "Multi-Axis Controller" usage names a 3D mouse's motion interface, whoever +/// made it, and excludes ordinary mice and the keyboard interfaces of 3D mice. #[cfg(target_os = "windows")] fn is_3d_mouse(info: &DeviceInfo) -> bool { - is_3d_mouse_vendor(info.vendor_id()) && info.usage_page() == 0x01 && info.usage() == 0x08 + info.usage_page() == 0x01 && info.usage() == 0x08 } -fn three_axes(bytes: &[u8]) -> Option<[i16; 3]> { - let &[x0, x1, y0, y1, z0, z1] = bytes.get(..6)? else { return None }; - Some([i16::from_le_bytes([x0, x1]), i16::from_le_bytes([y0, y1]), i16::from_le_bytes([z0, z1])]) -} - -/// SpaceNavigator reports 1 (translation) and 2 (rotation), each with three signed -/// little-endian 16-bit values. The HID layer may pad either report to its maximum size. -/// Some homemade devices send all six values in report 1 instead (`Layout::Combined`): only the -/// device's report descriptor can tell the two apart, because a padded split report 1 is as long. +/// Takes the axes an input report carries, as the device's descriptor laid them out, and leaves +/// the others as they were: translation and rotation may come in separate reports (3Dconnexion's +/// wired devices) or one (its wireless ones and most homemade devices). fn decode_report(report: &[u8], layout: Layout, reading: &mut Reading, now: Instant) -> bool { - match report.first().copied() { - Some(1) if layout == Layout::Combined => { - let Some(body) = report.get(1..) else { return false }; - let (Some(translation), Some(rotation)) = (body.get(..6).and_then(three_axes), body.get(6..).and_then(three_axes)) else { return false }; - reading.axes = Axes { translation, rotation }; - reading.translation_at = now; - reading.rotation_at = now; - true + let Some(decoded) = layout.decode(report) else { return false }; + let values = reading.axes.translation.iter_mut().chain(reading.axes.rotation.iter_mut()); + for ((value, at), new) in values.zip(reading.at.iter_mut()).zip(decoded.axes) { + if let Some(new) = new { + *value = new; + *at = now; } - Some(1) => { - let Some(translation) = report.get(1..).and_then(three_axes) else { return false }; - reading.axes.translation = translation; - reading.translation_at = now; - true - } - Some(2) => { - let Some(rotation) = report.get(1..).and_then(three_axes) else { return false }; - reading.axes.rotation = rotation; - reading.rotation_at = now; - true - } - _ => false, } + true } /// Publishes one input report from the device to the egui thread. @@ -261,9 +243,12 @@ fn reader(ctx: Context, shared: Arc>) { loop { if let Err(error) = api.refresh_devices() { log::warn!("3D mouse: device scan failed: {error}"); - } else if let Some(device) = api.device_list().find(|info| is_3d_mouse(info)).and_then(|info| info.open_device(&api).ok()) { - let layout = device_layout(&device); - let mut report = [0_u8; 64]; + } else if let Some((device, layout)) = api.device_list().filter(|info| is_3d_mouse(info)).find_map(|info| { + let device = info.open_device(&api).ok()?; + let layout = device_layout(&device, info.vendor_id())?; + Some((device, layout)) + }) { + let mut report = [0_u8; REPORT_BUFFER_BYTES]; loop { match device.read_timeout(&mut report, 100) { Ok(0) => {} @@ -280,17 +265,28 @@ fn reader(ctx: Context, shared: Arc>) { } } -/// How the device packs its axes, from its report descriptor; the usual split layout if the -/// descriptor cannot be read. +/// How the device packs its axes, from its report descriptor, or None when it isn't one to read: +/// a controller whose descriptor names no motion axis. Without a descriptor to go on (it can't be +/// read, or it's empty), a 3Dconnexion device (Logitech's id for the old SpaceNavigator, +/// 3Dconnexion's own for the rest) gets 3Dconnexion's layout, and any other device is left alone +/// rather than guessed at. #[cfg(target_os = "windows")] -fn device_layout(device: &hidapi::HidDevice) -> Layout { +fn device_layout(device: &hidapi::HidDevice, vendor_id: u16) -> Option { let mut descriptor = [0_u8; hidapi::MAX_REPORT_DESCRIPTOR_SIZE]; - match device.get_report_descriptor(&mut descriptor) { - Ok(size) => hid_descriptor::Descriptor::parse(descriptor.get(..size).unwrap_or_default()).layout(), - Err(error) => { - log::info!("3D mouse: no report descriptor, assuming separate translation and rotation reports: {error}"); - Layout::Split - } + let read = device.get_report_descriptor(&mut descriptor); + if let Ok(size @ 1..) = read { + return solidcraft_space_mouse::Descriptor::parse(descriptor.get(..size).unwrap_or_default()).layout(); + } + let why = match read { + Err(error) => error.to_string(), + Ok(_) => "it is empty".to_string(), + }; + if matches!(vendor_id, 0x046d | 0x256f) { + log::info!("3D mouse: no report descriptor ({why}), assuming separate translation and rotation reports"); + Some(Layout::default()) + } else { + log::debug!("3D mouse: skipping a multi-axis controller without a report descriptor ({why})"); + None } } @@ -312,8 +308,8 @@ fn reader(ctx: Context, shared: Arc>) { match std::fs::File::open(&device.path) { Ok(mut file) => { denied = None; - log::info!("3D mouse: reading {} ({:?} reports)", device.path.display(), device.layout); - let mut report = [0_u8; 64]; + log::info!("3D mouse: reading {}", device.path.display()); + let mut report = [0_u8; REPORT_BUFFER_BYTES]; loop { match file.read(&mut report) { Ok(0) => break, @@ -418,20 +414,75 @@ mod tests { } #[test] - fn decodes_separate_reports_without_stale_motion_or_padding_interference() { + fn each_axis_keeps_its_own_age_so_stale_motion_stops() { + // Report layouts are tested in solidcraft-space-mouse; here, what the reader keeps. let now = Instant::now(); let mut reading = Reading::new(now); - assert!(!decode_report(&[1, 1, 2], Layout::Split, &mut reading, now)); + let layout = Layout::default(); + assert!(!decode_report(&[1, 1, 2], layout, &mut reading, now)); assert_eq!(reading.current(now), Axes::default()); - assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], Layout::Split, &mut reading, now)); - assert_eq!(reading.current(now).translation, [-100, 300, 0]); - assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], Layout::Split, &mut reading, now)); - assert_eq!(reading.current(now).rotation, [0, 1, -1]); + assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], layout, &mut reading, now)); + assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], layout, &mut reading, now)); + assert_eq!(reading.current(now), Axes { translation: [-100, 300, 0], rotation: [0, 1, -1] }); + // Only rotation arrives again: translation goes stale, rotation stays current. let later = now + STALE_AFTER + Duration::from_millis(1); - assert_eq!(reading.current(later), Axes::default()); - assert!(decode_report(&[2, 4, 0, 5, 0, 6, 0], Layout::Split, &mut reading, later)); - assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 0, 0, 0, 0, 0, 0], Layout::Split, &mut reading, later)); - assert_eq!(reading.current(later), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] }); + assert!(decode_report(&[2, 4, 0, 5, 0, 6, 0], layout, &mut reading, later)); + assert_eq!(reading.current(later), Axes { translation: [0; 3], rotation: [4, 5, 6] }); + // A device sending all six in one report updates both at once. + let combined = solidcraft_space_mouse::Descriptor::parse(&[ + 0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, 0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, 0x75, 0x10, 0x95, 0x06, 0x19, 0x30, 0x29, 0x35, 0x81, + 0x02, 0xC0, + ]) + .layout() + .expect("six axes declared"); + let mut both = Reading::new(now); + assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0, 6, 0], combined, &mut both, now)); + assert_eq!(both.current(now), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] }); + } + + #[test] + fn a_group_spread_over_reports_keeps_the_axes_it_already_has() { + // X in report 1, Y in report 2 (16-bit, -350..350): X arriving mustn't zero Y, and Y + // arriving mustn't zero X. + let split_x_y = solidcraft_space_mouse::Descriptor::parse(&[ + 0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, 0x75, 0x10, 0x95, 0x01, // 1 x 16 bit, -350..350 + 0x85, 0x01, 0x09, 0x30, 0x81, 0x02, // report 1: X + 0x85, 0x02, 0x09, 0x31, 0x81, 0x02, // report 2: Y + 0xC0, + ]) + .layout() + .expect("X and Y declared"); + let now = Instant::now(); + let mut reading = Reading::new(now); + assert!(decode_report(&[1, 100, 0], split_x_y, &mut reading, now)); + assert!(decode_report(&[2, 200, 0], split_x_y, &mut reading, now)); + assert_eq!(reading.current(now).translation, [100, 200, 0]); + // Then only X keeps coming: Y goes stale on its own. + let later = now + STALE_AFTER + Duration::from_millis(1); + assert!(decode_report(&[1, 50, 0], split_x_y, &mut reading, later)); + assert_eq!(reading.current(later).translation, [50, 0, 0]); + } + + #[test] + fn only_the_axis_itself_keeps_it_alive() { + // 3Dconnexion's split layout, plus buttons in report 3 (one byte of bits). + let mut layout = Layout::default(); + layout.buttons[0] = + Some(solidcraft_space_mouse::Field { report_id: 3, bit_offset: 0, bits: 1, logical_min: 0, logical_max: 1, relative: false }); + let now = Instant::now(); + let mut reading = Reading::new(now); + assert!(decode_report(&[1, 100, 0, 0, 0, 0, 0], layout, &mut reading, now)); + // Just before the timeout X is still there; at exactly the timeout it's stale. + assert_eq!(reading.current(now + STALE_AFTER - Duration::from_millis(1)).translation, [100, 0, 0]); + assert_eq!(reading.current(now + STALE_AFTER).translation, [0; 3]); + // Button reports and reports with an id the layout doesn't know keep coming, but neither + // refreshes X: it goes stale on time all the same. + for step in 1..=10 { + let at = now + STALE_AFTER * step / 10; + assert!(decode_report(&[3, 1], layout, &mut reading, at)); + assert!(!decode_report(&[0x17, 80], layout, &mut reading, at)); + } + assert_eq!(reading.current(now + STALE_AFTER).translation, [0; 3]); } #[test] @@ -447,33 +498,6 @@ mod tests { assert_eq!(hid.current(later), Axes::default()); } - #[test] - fn combined_report_one_carries_translation_and_rotation_together() { - let now = Instant::now(); - let mut reading = Reading::new(now); - // Report id 1, then X Y Z Rx Ry Rz as signed little-endian 16-bit values. - let report = [1, 0x9c, 0xff, 0x2c, 0x01, 0, 0, 1, 0, 0, 0, 0xff, 0xff]; - assert!(decode_report(&report, Layout::Combined, &mut reading, now)); - assert_eq!(reading.current(now), Axes { translation: [-100, 300, 0], rotation: [1, 0, -1] }); - // The same bytes on a split device are only the translation report; its padding is not rotation. - let mut split = Reading::new(now); - assert!(decode_report(&report, Layout::Split, &mut split, now)); - assert_eq!(split.current(now), Axes { translation: [-100, 300, 0], rotation: [0; 3] }); - } - - #[test] - fn combined_layout_ignores_short_and_other_reports() { - let now = Instant::now(); - let mut reading = Reading::new(now); - assert!(!decode_report(&[1, 1, 0, 2, 0, 3, 0], Layout::Combined, &mut reading, now)); - assert!(!decode_report(&[3, 1, 0, 0, 0, 0], Layout::Combined, &mut reading, now)); - assert!(!decode_report(&[], Layout::Combined, &mut reading, now)); - assert_eq!(reading.current(now), Axes::default()); - // Extra padding after the six values is fine. - assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0, 6, 0, 0, 0], Layout::Combined, &mut reading, now)); - assert_eq!(reading.current(now), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] }); - } - #[test] fn applies_3dxware_axis_swaps_and_reversals() { let profile = br#" diff --git a/apps/solidcraft/src/space_mouse/hid_descriptor.rs b/apps/solidcraft/src/space_mouse/hid_descriptor.rs deleted file mode 100644 index 3dfa5fea..00000000 --- a/apps/solidcraft/src/space_mouse/hid_descriptor.rs +++ /dev/null @@ -1,253 +0,0 @@ -//! Just enough of a HID report descriptor reader to recognise a 3D mouse and to see how it packs -//! its motion axes. Written from the public USB HID specification. Malformed input never panics; -//! it simply yields less information. - -/// Generic Desktop usage page, and the usages in it that matter here. -const PAGE_GENERIC_DESKTOP: u32 = 0x01; -const USAGE_MULTI_AXIS_CONTROLLER: u32 = 0x08; -const FIRST_AXIS_USAGE: u32 = 0x30; // X, Y, Z, Rx, Ry, Rz are 0x30..=0x35 -const AXIS_COUNT: u32 = 6; - -/// Motion reports are report id 1 (translation, or everything) and report id 2 (rotation). -const MOTION_REPORT_ID: u8 = 1; - -/// Descriptors are small (hidapi reserves 4 KiB). Anything past this is ignored. -const MAX_DESCRIPTOR_BYTES: usize = 4096; -/// Bound on the usages one main item can declare through a usage range. -const MAX_USAGES_PER_ITEM: u32 = 256; - -/// How a device spreads its six motion axes over input reports. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum Layout { - /// Report 1 carries translation and report 2 carries rotation, three 16-bit values each. - /// This is how 3Dconnexion devices report, and it is the layout assumed when nothing is known. - #[default] - Split, - /// Report 1 carries all six axes: translation, then rotation. Homemade devices do this. - Combined, -} - -#[derive(Clone, Copy)] -struct Item { - /// 0 main, 1 global, 2 local (3 is reserved). - kind: u8, - tag: u8, - value: u32, -} - -/// Walks the short items of a descriptor. Long items are skipped; a truncated tail ends the walk. -fn items(descriptor: &[u8]) -> impl Iterator + '_ { - let descriptor = descriptor.get(..MAX_DESCRIPTOR_BYTES).unwrap_or(descriptor); - let mut pos = 0_usize; - std::iter::from_fn(move || { - loop { - let prefix = *descriptor.get(pos)?; - pos += 1; - if prefix == 0xFE { - // Long item: a data length byte, a tag byte, then the data. - let length = usize::from(*descriptor.get(pos)?); - pos = pos.checked_add(2 + length)?; - continue; - } - let size = match prefix & 0b11 { - 0 => 0, - 1 => 1, - 2 => 2, - _ => 4, - }; - let data = descriptor.get(pos..pos + size)?; - pos += size; - let value = data.iter().enumerate().fold(0_u32, |value, (index, byte)| value | (u32::from(*byte) << (8 * index))); - return Some(Item { kind: (prefix >> 2) & 0b11, tag: prefix >> 4, value }); - } - }) -} - -/// What a descriptor says about a device, as far as 3D mouse support needs to know. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct Descriptor { - multi_axis_controller: bool, - /// Distinct axes (X, Y, Z, Rx, Ry, Rz as bits 0..=5) that input report 1 carries. - motion_report_axes: u8, -} - -impl Descriptor { - pub fn parse(descriptor: &[u8]) -> Self { - let mut summary = Self::default(); - let mut page = 0_u32; - let mut report_id = 0_u8; - let mut depth = 0_u32; - // Local items belong to the next main item only. - let mut usages: Vec = Vec::new(); - let mut usage_min = None; - for item in items(descriptor) { - match (item.kind, item.tag) { - // Global: usage page, report id. - (1, 0) => page = item.value, - (1, 8) => report_id = u8::try_from(item.value).unwrap_or(0), - // Local: usage, usage minimum, usage maximum. - (2, 0) => usages.push(Self::full_usage(page, item)), - (2, 1) => usage_min = Some(Self::full_usage(page, item)), - (2, 2) => { - if let Some(min) = usage_min.take() { - let max = Self::full_usage(page, item); - let count = max.saturating_sub(min).min(MAX_USAGES_PER_ITEM - 1); - usages.extend((0..=count).map(|offset| min.saturating_add(offset))); - } - } - // Main: input, collection, end collection. - (0, 8) => { - if report_id == MOTION_REPORT_ID || (report_id == 0 && depth > 0) { - summary.note_axes(&usages); - } - } - (0, 0xA) => { - // An application collection (1) at the top level names the device. - if depth == 0 && item.value == 1 && usages.first() == Some(&((PAGE_GENERIC_DESKTOP << 16) | USAGE_MULTI_AXIS_CONTROLLER)) { - summary.multi_axis_controller = true; - } - depth += 1; - } - (0, 0xC) => depth = depth.saturating_sub(1), - _ => {} - } - if item.kind == 0 { - usages.clear(); - usage_min = None; - } - } - summary - } - - /// Usage items of up to two bytes name a usage on the current page, four bytes carry the page. - fn full_usage(page: u32, item: Item) -> u32 { - if item.value > 0xFFFF { item.value } else { (page << 16) | item.value } - } - - fn note_axes(&mut self, usages: &[u32]) { - for usage in usages { - let axis = usage.wrapping_sub((PAGE_GENERIC_DESKTOP << 16) | FIRST_AXIS_USAGE); - if axis < AXIS_COUNT { - self.motion_report_axes |= 1 << axis; - } - } - } - - /// True for a top-level Generic Desktop "Multi-Axis Controller" collection. This tells a - /// SpaceMouse's motion interface apart from its keyboard interface and from ordinary mice. - #[cfg_attr(not(target_os = "linux"), allow(dead_code))] - pub fn is_multi_axis_controller(&self) -> bool { - self.multi_axis_controller - } - - /// `Combined` when input report 1 declares all six axes, otherwise `Split`. - pub fn layout(&self) -> Layout { - if self.motion_report_axes.count_ones() == AXIS_COUNT { Layout::Combined } else { Layout::Split } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Report 1 = X Y Z, report 2 = Rx Ry Rz, each as three signed 16-bit values. - const SPLIT: &[u8] = &[ - 0x05, 0x01, // usage page: generic desktop - 0x09, 0x08, // usage: multi-axis controller - 0xA1, 0x01, // collection: application - 0x85, 0x01, // report id 1 - 0x16, 0xA2, 0xFE, // logical minimum -350 - 0x26, 0x5E, 0x01, // logical maximum 350 - 0x75, 0x10, // report size 16 - 0x95, 0x03, // report count 3 - 0x09, 0x30, 0x09, 0x31, 0x09, 0x32, // X Y Z - 0x81, 0x06, // input: data, variable, relative - 0x85, 0x02, // report id 2 - 0x09, 0x33, 0x09, 0x34, 0x09, 0x35, // Rx Ry Rz - 0x81, 0x06, // input - 0xC0, // end collection - ]; - - /// Report 1 = X Y Z Rx Ry Rz. - const COMBINED: &[u8] = &[ - 0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, // multi-axis controller, report id 1 - 0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, 0x75, 0x10, 0x95, 0x06, // 6 x 16 bit, -350..350 - 0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x33, 0x09, 0x34, 0x09, 0x35, // X Y Z Rx Ry Rz - 0x81, 0x06, 0xC0, - ]; - - /// The same six axes declared as a usage range, with report id 1 after the collection opens. - const COMBINED_RANGE: &[u8] = &[ - 0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, 0x75, 0x10, 0x95, 0x06, // - 0x19, 0x30, 0x29, 0x35, // usage minimum X, usage maximum Rz - 0x81, 0x06, 0xC0, - ]; - - /// A boot keyboard-like interface: usage page 7 usages must not count as axes. - const KEYBOARD: &[u8] = - &[0x05, 0x01, 0x09, 0x06, 0xA1, 0x01, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0xC0]; - - /// An ordinary mouse: Generic Desktop X, Y but top-level usage is Mouse (2). - const MOUSE: &[u8] = &[ - 0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x09, 0x01, 0xA1, 0x00, 0x09, 0x30, 0x09, 0x31, 0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x02, 0x81, - 0x06, 0xC0, 0xC0, - ]; - - #[test] - fn split_descriptor_is_a_multi_axis_controller_with_three_axes_in_report_one() { - let descriptor = Descriptor::parse(SPLIT); - assert!(descriptor.is_multi_axis_controller()); - assert_eq!(descriptor.motion_report_axes, 0b000_111); - assert_eq!(descriptor.layout(), Layout::Split); - } - - #[test] - fn six_axes_in_report_one_are_a_combined_layout() { - for bytes in [COMBINED, COMBINED_RANGE] { - let descriptor = Descriptor::parse(bytes); - assert!(descriptor.is_multi_axis_controller()); - assert_eq!(descriptor.layout(), Layout::Combined); - } - } - - #[test] - fn other_interfaces_are_not_3d_mice() { - for bytes in [KEYBOARD, MOUSE, &[][..]] { - let descriptor = Descriptor::parse(bytes); - assert!(!descriptor.is_multi_axis_controller()); - assert_eq!(descriptor.layout(), Layout::Split); - } - } - - #[test] - fn four_byte_usages_carry_their_own_page() { - // Usage (4 bytes) 0x0007_0030 is on page 7, not an axis. - let descriptor = Descriptor::parse(&[0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, 0x0B, 0x30, 0x00, 0x07, 0x00, 0x81, 0x06, 0xC0]); - assert!(descriptor.is_multi_axis_controller()); - assert_eq!(descriptor.motion_report_axes, 0); - } - - #[test] - fn truncated_and_hostile_descriptors_never_panic() { - for end in 0..=COMBINED.len() { - let _ = Descriptor::parse(&COMBINED[..end]); - } - // Long item with a length running past the end, huge usage ranges, deep nesting. - let _ = Descriptor::parse(&[0xFE, 0xFF, 0x00, 0x01]); - let _ = Descriptor::parse(&[0x05, 0x01, 0x1B, 0, 0, 0, 0, 0x2B, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0x06]); - let _ = Descriptor::parse(&[0xC0; 64]); - let _ = Descriptor::parse(&[0xA1; 64]); - let mut state = 0x2545_F491_4F6C_DD1D_u64; - for _ in 0..200 { - let bytes: Vec = (0..64) - .map(|_| { - state ^= state << 13; - state ^= state >> 7; - state ^= state << 17; - state.to_le_bytes()[0] - }) - .collect(); - let _ = Descriptor::parse(&bytes); - } - } -} diff --git a/apps/solidcraft/src/space_mouse/hidraw.rs b/apps/solidcraft/src/space_mouse/hidraw.rs index e238052a..46ab2943 100644 --- a/apps/solidcraft/src/space_mouse/hidraw.rs +++ b/apps/solidcraft/src/space_mouse/hidraw.rs @@ -1,10 +1,10 @@ //! Finding a 3D mouse on Linux. The kernel exposes every HID interface as `/dev/hidrawN`, with -//! its identity and report descriptor under `/sys/class/hidraw/hidrawN/device/`. Reading both is -//! plain file I/O, so no udev or hidapi library is needed. +//! its report descriptor under `/sys/class/hidraw/hidrawN/device/`. Reading it is plain file +//! I/O, so no udev or hidapi library is needed. use std::path::{Path, PathBuf}; -use super::hid_descriptor::{Descriptor, Layout}; +use solidcraft_space_mouse::{Descriptor, Layout}; const SYSFS: &str = "/sys/class/hidraw"; const DEV: &str = "/dev"; @@ -14,14 +14,7 @@ pub struct Device { pub layout: Layout, } -/// The vendor id from `HID_ID=::` (all hexadecimal) in a sysfs `uevent`. -fn vendor_id(uevent: &str) -> Option { - let id = uevent.lines().find_map(|line| line.strip_prefix("HID_ID="))?; - let vendor = id.split(':').nth(1)?; - u32::from_str_radix(vendor.trim(), 16).ok().and_then(|vendor| u16::try_from(vendor).ok()) -} - -/// The first hidraw interface that is a 3D mouse's multi-axis controller. +/// The first hidraw interface that is a multi-axis controller with motion axes, whoever made it. pub fn find() -> Option { let mut names: Vec<_> = std::fs::read_dir(SYSFS).ok()?.filter_map(Result::ok).map(|entry| entry.file_name()).collect(); names.sort(); @@ -29,29 +22,8 @@ pub fn find() -> Option { } fn probe(sysfs: &Path, path: PathBuf) -> Option { - let uevent = std::fs::read_to_string(sysfs.join("device/uevent")).ok()?; - if !super::is_3d_mouse_vendor(vendor_id(&uevent)?) { - return None; - } let descriptor = Descriptor::parse(&std::fs::read(sysfs.join("device/report_descriptor")).ok()?); - descriptor.is_multi_axis_controller().then(|| Device { path, layout: descriptor.layout() }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn reads_the_vendor_from_the_hid_id() { - let uevent = "DRIVER=hid-generic\nHID_ID=0003:0000256F:0000C631\nHID_NAME=Some Device\n"; - assert_eq!(vendor_id(uevent), Some(0x256f)); - assert_eq!(vendor_id("HID_ID=0003:0000046D:0000C077"), Some(0x046d)); - } - - #[test] - fn malformed_ids_are_rejected() { - for uevent in ["", "HID_NAME=x", "HID_ID=", "HID_ID=0003", "HID_ID=0003:zzzz:0001", "HID_ID=0003:FFFFFFFF:0001"] { - assert_eq!(vendor_id(uevent), None, "{uevent:?}"); - } - } + // A controller that declares no motion axis (buttons only, say) isn't read. + let layout = descriptor.is_multi_axis_controller().then(|| descriptor.layout()).flatten()?; + Some(Device { path, layout }) } diff --git a/crates/space-mouse/Cargo.toml b/crates/space-mouse/Cargo.toml new file mode 100644 index 00000000..7ae84345 --- /dev/null +++ b/crates/space-mouse/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "solidcraft-space-mouse" +description = "SolidCraft 3D mouse input: HID report descriptors and reports, independent of the device API and the UI" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true diff --git a/crates/space-mouse/src/descriptor.rs b/crates/space-mouse/src/descriptor.rs new file mode 100644 index 00000000..409f6207 --- /dev/null +++ b/crates/space-mouse/src/descriptor.rs @@ -0,0 +1,816 @@ +//! Just enough of a HID report descriptor reader to recognise a 3D mouse and to find its six +//! motion axes and its buttons in its input reports. Written from the public USB HID +//! specification (Device Class Definition for HID 1.11, section 6.2.2, and the HID Usage Tables). +//! Malformed input never panics; it simply yields less information. + +/// Generic Desktop usage page, and the usages in it that matter here. +const PAGE_GENERIC_DESKTOP: u32 = 0x01; +const USAGE_MULTI_AXIS_CONTROLLER: u32 = 0x08; +const FIRST_AXIS_USAGE: u32 = 0x30; // X, Y, Z, Rx, Ry, Rz are 0x30..=0x35 +const AXIS_COUNT: usize = 6; +/// The Button usage page: usage N is button N, from 1. +const PAGE_BUTTON: u32 = 0x09; +/// Buttons 1 to this are read; [`Report::buttons`] has one bit for each. +pub const MAX_BUTTONS: usize = 32; + +/// Descriptors are small (hidapi reserves 4 KiB). Anything past this is ignored. +const MAX_DESCRIPTOR_BYTES: usize = 4096; +/// Bound on the usages one main item can declare through a usage range, and on its fields. +const MAX_USAGES_PER_ITEM: u32 = 256; +/// Bound on a report's length in bits: hidraw reads at most 4096 bytes. +const MAX_REPORT_BITS: u32 = 4096 * 8; + +/// What decoded axes are scaled to at full deflection: 3Dconnexion's own range, which the rest +/// of the 3D mouse code is tuned for. A device declaring ±350 is passed through unchanged. +pub const FULL_SCALE: i16 = 350; + +/// Where one axis or button sits in the device's input reports. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Field { + /// The report that carries it; 0 when the device doesn't number its reports. + pub report_id: u8, + /// Its first bit in the report, not counting the report id byte. + pub bit_offset: u32, + /// Its width, 1 to 32 bits. A field of any other width reads as missing. + pub bits: u32, + /// The declared range. 64 bits wide so a 32-bit unsigned maximum fits. + pub logical_min: i64, + pub logical_max: i64, + /// The descriptor's Relative flag. 3Dconnexion sets it on some spring-return caps and not on + /// others, so it does not tell a joystick from a trackball. + pub relative: bool, +} + +impl Field { + /// The `index`th signed 16-bit value of a report, ±350: how 3Dconnexion devices report. + const fn sixteen_bit(report_id: u8, index: u32) -> Self { + Self { report_id, bit_offset: index * 16, bits: 16, logical_min: -350, logical_max: 350, relative: true } + } + + /// The field's raw value in `body` (the report without its id), or None if `body` is too short + /// or the field isn't 1 to 32 bits wide. The fields are public, so a hand-built one is checked + /// here too, not only the ones `Descriptor::parse` makes. + fn read(&self, body: &[u8]) -> Option { + if !(1..=32).contains(&self.bits) { + return None; + } + let mut raw = 0_i64; + for bit in 0..self.bits { + let at = self.bit_offset.checked_add(bit)?; + let byte = *body.get(usize::try_from(at / 8).ok()?)?; + raw |= i64::from((byte >> (at % 8)) & 1) << bit; + } + // Signed fields are two's complement, as the logical minimum says. + Some(if self.logical_min < 0 && (raw >> (self.bits - 1)) & 1 == 1 { raw - (1_i64 << self.bits) } else { raw }) + } + + /// `raw` scaled from the field's logical range so its middle is 0 and its ends are + /// ±[`FULL_SCALE`]. A value past the declared range goes past ±FULL_SCALE rather than being cut + /// off, and a field without a usable range is passed through. Either way the result is clamped + /// to ±`i16::MAX`, never `i16::MIN`, so negating an axis can't overflow. + fn scaled(&self, raw: i64) -> i16 { + // i128: a hand-built Field can declare any i64 range, and `max - min` of i64::MIN..i64::MAX + // overflows an i64. + let (raw, min, max) = (i128::from(raw), i128::from(self.logical_min), i128::from(self.logical_max)); + let value = if min < max { (2 * raw - (min + max)) as f64 / (max - min) as f64 * f64::from(FULL_SCALE) } else { raw as f64 }; + value.round().clamp(-f64::from(i16::MAX), f64::from(i16::MAX)) as i16 + } +} + +/// Where a device puts its six axes and its buttons. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Layout { + /// X, Y, Z, Rx, Ry, Rz, for each axis the device has. + pub axes: [Option; AXIS_COUNT], + /// Buttons 1 to [`MAX_BUTTONS`] at indices 0 to 31, for each the device has. Devices number + /// them with gaps (3Dconnexion's do), so the index is the button's own number less one. + pub buttons: [Option; MAX_BUTTONS], + /// The descriptor declares report ids, so every report starts with its id byte. + pub numbered: bool, +} + +impl Default for Layout { + /// 3Dconnexion's: report 1 carries X Y Z and report 2 Rx Ry Rz, three signed 16-bit values + /// each. For a 3Dconnexion device whose descriptor can't be read; a device that isn't one + /// should never be read with it. Where its buttons are differs from model to model, so it has + /// none. + fn default() -> Self { + let translation = |index| Some(Field::sixteen_bit(1, index)); + let rotation = |index| Some(Field::sixteen_bit(2, index)); + Self { + axes: [translation(0), translation(1), translation(2), rotation(0), rotation(1), rotation(2)], + buttons: [None; MAX_BUTTONS], + numbered: true, + } + } +} + +/// What one input report carries: the axes it has, scaled to ±[`FULL_SCALE`], and the buttons. +/// An axis the report doesn't carry is None, not 0, so a device that spreads one group over +/// several reports (X in one, Y in another) doesn't zero the axes it sent a moment ago. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Report { + /// X, Y, Z, Rx, Ry, Rz. + pub axes: [Option; AXIS_COUNT], + /// When the report carries buttons, which are down: bit N for button N + 1. A report with + /// every button up is how a device says they were released, so `Some(0)` matters. + pub buttons: Option, +} + +impl Layout { + /// The axes and buttons in one input report, or None when it carries none of them or is too + /// short for one of its fields. A truncated report is dropped whole, axes included, rather + /// than half read: devices send each report at its full declared size, so a short one is + /// damaged. Padding after the fields is ignored. + pub fn decode(&self, report: &[u8]) -> Option { + let (id, body) = if self.numbered { (*report.first()?, report.get(1..)?) } else { (0, report) }; + let mut decoded = Report::default(); + for (value, field) in decoded.axes.iter_mut().zip(&self.axes) { + if let Some(field) = field.filter(|field| field.report_id == id) { + *value = Some(field.scaled(field.read(body)?)); + } + } + for (bit, field) in self.buttons.iter().enumerate() { + if let Some(field) = field.filter(|field| field.report_id == id) { + let down = field.read(body)? != 0; + decoded.buttons = Some(decoded.buttons.unwrap_or(0) | (u32::from(down) << bit)); + } + } + (decoded != Report::default()).then_some(decoded) + } +} + +#[derive(Clone, Copy)] +struct Item { + /// 0 main, 1 global, 2 local (3 is reserved). + kind: u8, + tag: u8, + /// Data bytes: 0, 1, 2 or 4. + size: u8, + value: u32, +} + +impl Item { + /// The data as a two's complement number of its own size (logical minimum and maximum). + fn signed(self) -> i64 { + match self.size { + 1 => i64::from(self.value as u8 as i8), + 2 => i64::from(self.value as u16 as i16), + _ => i64::from(self.value as i32), + } + } +} + +/// Walks the short items of a descriptor. Long items are skipped; a truncated tail ends the walk. +fn items(descriptor: &[u8]) -> impl Iterator + '_ { + let descriptor = descriptor.get(..MAX_DESCRIPTOR_BYTES).unwrap_or(descriptor); + let mut pos = 0_usize; + std::iter::from_fn(move || { + loop { + let prefix = *descriptor.get(pos)?; + pos += 1; + if prefix == 0xFE { + // Long item: a data length byte, a tag byte, then the data. + let length = usize::from(*descriptor.get(pos)?); + pos = pos.checked_add(2 + length)?; + continue; + } + let size = match prefix & 0b11 { + 0 => 0, + 1 => 1, + 2 => 2, + _ => 4, + }; + let data = descriptor.get(pos..pos + size)?; + pos += size; + let value = data.iter().enumerate().fold(0_u32, |value, (index, byte)| value | (u32::from(*byte) << (8 * index))); + return Some(Item { kind: (prefix >> 2) & 0b11, tag: prefix >> 4, size: size as u8, value }); + } + }) +} + +/// The global items in force (PUSH and POP save and restore them). +#[derive(Clone, Copy, Default)] +struct Globals { + page: u32, + logical_min: i64, + logical_max: Option, + report_size: u32, + report_count: u32, + report_id: u8, +} + +impl Globals { + /// The logical maximum. With a non-negative minimum it is unsigned, so `0x26 0xFF 0xFF` is + /// 65535 and `0x27 0xFF 0xFF 0xFF 0xFF` is 4294967295, rather than -1, as devices mean it. + fn logical_max(&self) -> i64 { + let Some(item) = self.logical_max else { return 0 }; + let signed = item.signed(); + if self.logical_min >= 0 && signed < 0 { i64::from(item.value) } else { signed } + } +} + +/// What a descriptor says about a device, as far as 3D mouse support needs to know. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Descriptor { + multi_axis_controller: bool, + axes: [Option; AXIS_COUNT], + buttons: [Option; MAX_BUTTONS], + numbered: bool, +} + +impl Descriptor { + pub fn parse(descriptor: &[u8]) -> Self { + let mut summary = Self { multi_axis_controller: false, axes: [None; AXIS_COUNT], buttons: [None; MAX_BUTTONS], numbered: false }; + let mut globals = Globals::default(); + // Every PUSH is kept, so every POP restores what its own PUSH saved. The descriptor is cut + // at MAX_DESCRIPTOR_BYTES, which bounds the stack at that many entries. + let mut pushed: Vec = Vec::new(); + let mut depth = 0_u32; + // The depth of the open Multi-axis Controller application collection; its fields count. + let mut controller: Option = None; + // The next free input bit of each report id. + let mut offsets: Vec<(u8, u32)> = Vec::new(); + // Local items belong to the next main item only. + let mut usages: Vec = Vec::new(); + let mut usage_min = None; + for item in items(descriptor) { + match (item.kind, item.tag) { + // Global: usage page, logical extent, report size, id and count, push, pop. + (1, 0) => globals.page = item.value, + (1, 1) => globals.logical_min = item.signed(), + (1, 2) => globals.logical_max = Some(item), + (1, 7) => globals.report_size = item.value, + (1, 8) => { + globals.report_id = u8::try_from(item.value).unwrap_or(0); + summary.numbered = true; + } + (1, 9) => globals.report_count = item.value, + (1, 0xA) => pushed.push(globals), + (1, 0xB) => { + if let Some(saved) = pushed.pop() { + globals = saved; + } + } + // Local: usage, usage minimum, usage maximum. + (2, 0) => usages.push(full_usage(globals.page, item)), + (2, 1) => usage_min = Some(full_usage(globals.page, item)), + (2, 2) => { + if let Some(min) = usage_min.take() { + let max = full_usage(globals.page, item); + let count = max.saturating_sub(min).min(MAX_USAGES_PER_ITEM - 1); + usages.extend((0..=count).map(|offset| min.saturating_add(offset))); + } + } + // Main: input, collection, end collection. + (0, 8) => { + let start = match offsets.iter().position(|(id, _)| *id == globals.report_id) { + Some(index) => offsets.get(index).map_or(0, |(_, bits)| *bits), + None => { + offsets.push((globals.report_id, 0)); + 0 + } + }; + // Data (not constant) and variable (not an array): one value per usage. + if controller.is_some() && item.value & 0b11 == 0b10 { + summary.note_fields(&globals, &usages, start, item.value & 0b100 != 0); + } + let end = start.saturating_add(globals.report_size.saturating_mul(globals.report_count)).min(MAX_REPORT_BITS); + if let Some((_, bits)) = offsets.iter_mut().find(|(id, _)| *id == globals.report_id) { + *bits = end; + } + } + (0, 0xA) => { + // An application collection (1) at the top level names the device. + if depth == 0 && item.value == 1 && usages.first() == Some(&((PAGE_GENERIC_DESKTOP << 16) | USAGE_MULTI_AXIS_CONTROLLER)) { + summary.multi_axis_controller = true; + controller = Some(depth); + } + depth = depth.saturating_add(1); + } + (0, 0xC) => { + depth = depth.saturating_sub(1); + if controller == Some(depth) { + controller = None; + } + } + _ => {} + } + if item.kind == 0 { + usages.clear(); + usage_min = None; + } + } + summary + } + + /// Records the axes and buttons among an input item's fields: the `i`th field takes the `i`th + /// usage, and fields past the last usage take the last one (HID 1.11, 6.2.2.8). The first + /// declaration of an axis or button wins. + fn note_fields(&mut self, globals: &Globals, usages: &[u32], start: u32, relative: bool) { + let bits = globals.report_size; + if !(1..=32).contains(&bits) { + return; + } + for index in 0..globals.report_count.min(MAX_USAGES_PER_ITEM) { + let Some(usage) = usages.get(index as usize).or(usages.last()) else { return }; + let Some(bit_offset) = index.checked_mul(bits).and_then(|offset| offset.checked_add(start)) else { return }; + if bit_offset.saturating_add(bits) > MAX_REPORT_BITS { + return; + } + let axis = usage.wrapping_sub((PAGE_GENERIC_DESKTOP << 16) | FIRST_AXIS_USAGE) as usize; + // Button usages start at 1; usage 0 on the page means "no button". + let button = usage.wrapping_sub((PAGE_BUTTON << 16) | 1) as usize; + let slot = match self.axes.get_mut(axis) { + Some(slot) => Some(slot), + None => self.buttons.get_mut(button), + }; + if let Some(slot) = slot + && slot.is_none() + { + *slot = Some(Field { + report_id: globals.report_id, + bit_offset, + bits, + logical_min: globals.logical_min, + logical_max: globals.logical_max(), + relative, + }); + } + } + } + + /// True for a top-level Generic Desktop "Multi-Axis Controller" collection. This tells a + /// 3D mouse's motion interface apart from its keyboard interface and from ordinary mice, + /// whoever made it. + pub fn is_multi_axis_controller(&self) -> bool { + self.multi_axis_controller + } + + /// Where the device's axes and buttons are, or None when the descriptor names no motion axis: + /// a controller with only buttons isn't something to steer the view with, and guessing a + /// layout for it would read its reports as motion. + /// + /// In a descriptor that numbers its reports, fields declared before the first report id carry + /// id 0, which no report can have, so they're left out. + pub fn layout(&self) -> Option { + let keep = |field: &Option| field.filter(|field| !self.numbered || field.report_id != 0); + let axes = self.axes.each_ref().map(keep); + axes.iter().any(Option::is_some).then(|| Layout { axes, buttons: self.buttons.each_ref().map(keep), numbered: self.numbered }) + } +} + +/// Usage items of up to two bytes name a usage on the current page, four bytes carry the page. +fn full_usage(page: u32, item: Item) -> u32 { + if item.size == 4 { item.value } else { (page << 16) | item.value } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Report 1 = X Y Z, report 2 = Rx Ry Rz, each as three signed 16-bit values (3Dconnexion's + /// wired devices). + const SPLIT: &[u8] = &[ + 0x05, 0x01, // usage page: generic desktop + 0x09, 0x08, // usage: multi-axis controller + 0xA1, 0x01, // collection: application + 0xA1, 0x00, // collection: physical + 0x85, 0x01, // report id 1 + 0x16, 0xA2, 0xFE, // logical minimum -350 + 0x26, 0x5E, 0x01, // logical maximum 350 + 0x09, 0x30, 0x09, 0x31, 0x09, 0x32, // X Y Z + 0x75, 0x10, // report size 16 + 0x95, 0x03, // report count 3 + 0x81, 0x06, // input: data, variable, relative + 0xC0, // end collection + 0xA1, 0x00, // collection: physical + 0x85, 0x02, // report id 2 + 0x09, 0x33, 0x09, 0x34, 0x09, 0x35, // Rx Ry Rz + 0x75, 0x10, 0x95, 0x03, 0x81, 0x06, // 3 x 16 bit, relative + 0xC0, // end collection + 0xA1, 0x02, // collection: logical + 0x85, 0x03, // report id 3 + 0x05, 0x09, 0x19, 0x01, 0x29, 0x0F, // buttons 1 to 15 + 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x0F, 0x81, 0x02, // 15 x 1 bit, absolute + 0x95, 0x01, 0x81, 0x03, // 1 bit of padding + 0xC0, 0xC0, + ]; + + /// Report 1 = X Y Z Rx Ry Rz, absolute (3Dconnexion's wireless devices). + const COMBINED: &[u8] = &[ + 0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0xA1, 0x00, 0x85, 0x01, // multi-axis controller, report id 1 + 0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, // -350..350 + 0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x33, 0x09, 0x34, 0x09, 0x35, // X Y Z Rx Ry Rz + 0x75, 0x10, 0x95, 0x06, 0x81, 0x02, // 6 x 16 bit, absolute + 0xC0, 0xC0, + ]; + + /// The same six axes declared as a usage range, with report id 1 after the collection opens. + const COMBINED_RANGE: &[u8] = &[ + 0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, 0x75, 0x10, 0x95, 0x06, // + 0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, // + 0x19, 0x30, 0x29, 0x35, // usage minimum X, usage maximum Rz + 0x81, 0x06, 0xC0, + ]; + + /// A homemade trackball: report 1 is eight button bits, then six 16-bit relative axes over + /// the full signed range. + const BUTTONS_FIRST: &[u8] = &[ + 0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, // multi-axis controller, report id 1 + 0x05, 0x09, 0x19, 0x01, 0x29, 0x08, 0x15, 0x00, 0x25, 0x01, // buttons 1 to 8, 0..1 + 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, // 8 x 1 bit + 0x05, 0x01, 0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x33, 0x09, 0x34, 0x09, 0x35, // X..Rz + 0x16, 0x00, 0x80, 0x26, 0xFF, 0x7F, // -32768..32767 + 0x75, 0x10, 0x95, 0x06, 0x81, 0x06, // 6 x 16 bit, relative + 0xC0, + ]; + + /// A homemade joystick without report ids: four 8-bit axes from 0 to 255, a byte of constant + /// padding first, and the rotation pushed and popped around a narrower range. + const UNNUMBERED_UNSIGNED: &[u8] = &[ + 0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, // + 0x75, 0x08, 0x95, 0x01, 0x81, 0x03, // one constant byte + 0x15, 0x00, 0x26, 0xFF, 0x00, // 0..255 + 0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x95, 0x03, 0x81, 0x02, // X Y Z + 0xA4, // push + 0x26, 0x7F, 0x00, // 0..127 + 0x09, 0x35, 0x95, 0x01, 0x81, 0x02, // Rz + 0xB4, // pop + 0xC0, + ]; + + /// A boot keyboard-like interface: usage page 7 usages must not count as axes. + const KEYBOARD: &[u8] = + &[0x05, 0x01, 0x09, 0x06, 0xA1, 0x01, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0xC0]; + + /// An ordinary mouse: Generic Desktop X, Y but top-level usage is Mouse (2). + const MOUSE: &[u8] = &[ + 0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x09, 0x01, 0xA1, 0x00, 0x09, 0x30, 0x09, 0x31, 0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x02, 0x81, + 0x06, 0xC0, 0xC0, + ]; + + fn field(report_id: u8, bit_offset: u32, bits: u32, (logical_min, logical_max): (i64, i64), relative: bool) -> Option { + Some(Field { report_id, bit_offset, bits, logical_min, logical_max, relative }) + } + + fn layout(bytes: &[u8]) -> Layout { + Descriptor::parse(bytes).layout().expect("the descriptor declares motion axes") + } + + /// One report's axes, X to Rz, from a list of (axis, value). + fn axes(values: &[(usize, i16)]) -> [Option; AXIS_COUNT] { + let mut axes = [None; AXIS_COUNT]; + for &(axis, value) in values { + if let Some(slot) = axes.get_mut(axis) { + *slot = Some(value); + } + } + axes + } + + /// A Multi-axis Controller application collection around `body`. + fn controller(body: &[u8]) -> Vec { + [&[0x05, 0x01, 0x09, 0x08, 0xA1, 0x01][..], body, &[0xC0]].concat() + } + + #[test] + fn split_reports_put_translation_in_report_one_and_rotation_in_report_two() { + let descriptor = Descriptor::parse(SPLIT); + assert!(descriptor.is_multi_axis_controller()); + let layout = layout(SPLIT); + assert!(layout.numbered); + let r = (-350, 350); + assert_eq!( + layout.axes, + [ + field(1, 0, 16, r, true), + field(1, 16, 16, r, true), + field(1, 32, 16, r, true), + field(2, 0, 16, r, true), + field(2, 16, 16, r, true), + field(2, 32, 16, r, true) + ] + ); + // The same axes as are assumed for a 3Dconnexion device whose descriptor can't be read. + assert_eq!(layout.axes, Layout::default().axes); + // Buttons 1 to 15 are report 3's first 15 bits; the 16th is padding. + assert_eq!(layout.buttons[0], field(3, 0, 1, (0, 1), false)); + assert_eq!(layout.buttons[14], field(3, 14, 1, (0, 1), false)); + assert_eq!(layout.buttons[15], None); + // Buttons 1, 3 and 15 down; then all up, which is a report too. + assert_eq!(layout.decode(&[3, 0b101, 0b0100_0000]), Some(Report { axes: [None; 6], buttons: Some(0b101 | 1 << 14) })); + assert_eq!(layout.decode(&[3, 0, 0]).map(|r| r.buttons), Some(Some(0))); + // A motion report says nothing about the buttons. + assert_eq!(layout.decode(&[1, 1, 0, 2, 0, 3, 0]).map(|r| r.buttons), Some(None)); + } + + #[test] + fn buttons_keep_their_own_numbers_across_gaps() { + // X in report 1; buttons 1 to 3, a constant bit, then buttons 5 and 6 in report 3, as + // 3Dconnexion numbers them. + let layout = layout(&controller(&[ + 0x85, 0x01, 0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, 0x75, 0x10, 0x95, 0x01, 0x09, 0x30, 0x81, 0x02, // report 1: X + 0x85, 0x03, 0x05, 0x09, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, // report 3, 1-bit fields + 0x19, 0x01, 0x29, 0x03, 0x95, 0x03, 0x81, 0x02, // buttons 1 to 3 + 0x95, 0x01, 0x81, 0x03, // a constant bit + 0x19, 0x05, 0x29, 0x06, 0x95, 0x02, 0x81, 0x02, // buttons 5 and 6 + ])); + assert_eq!(layout.buttons[3], None); + assert_eq!(layout.buttons[4], field(3, 4, 1, (0, 1), false)); + assert_eq!(layout.buttons[5], field(3, 5, 1, (0, 1), false)); + // Bits 0 and 5 set: buttons 1 and 6. + assert_eq!(layout.decode(&[3, 0b10_0001]).and_then(|r| r.buttons), Some(0b10_0001)); + } + + #[test] + fn a_controller_without_motion_axes_has_no_layout() { + // Buttons only, in report 1. With a guessed 3Dconnexion layout, its report + // `01 01 00 00 00 00 00` would read as X = 1. It must not be read at all. + let buttons_only = controller(&[ + 0x85, 0x01, 0x05, 0x09, 0x19, 0x01, 0x29, 0x08, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, // + 0x75, 0x08, 0x95, 0x05, 0x81, 0x03, // padding up to 6 bytes + ]); + assert!(Descriptor::parse(&buttons_only).is_multi_axis_controller()); + assert_eq!(Descriptor::parse(&buttons_only).layout(), None); + // Axes wider than 32 bits aren't supported, so they don't count either. + let wide = controller(&[0x75, 0x40, 0x95, 0x01, 0x09, 0x30, 0x81, 0x02]); + assert_eq!(Descriptor::parse(&wide).layout(), None); + // A page-7 usage that happens to be 0x30 isn't an axis. + let four_byte = controller(&[0x85, 0x01, 0x75, 0x10, 0x95, 0x01, 0x0B, 0x30, 0x00, 0x07, 0x00, 0x81, 0x06]); + assert_eq!(Descriptor::parse(&four_byte).layout(), None); + } + + #[test] + fn six_axes_in_report_one_follow_each_other() { + for (bytes, relative) in [(COMBINED, false), (COMBINED_RANGE, true)] { + assert!(Descriptor::parse(bytes).is_multi_axis_controller()); + for (index, axis) in layout(bytes).axes.iter().enumerate() { + assert_eq!(*axis, field(1, 16 * index as u32, 16, (-350, 350), relative), "axis {index}"); + } + } + } + + #[test] + fn axes_after_the_buttons_start_after_the_buttons() { + let layout = layout(BUTTONS_FIRST); + assert_eq!(layout.axes[0], field(1, 8, 16, (-32768, 32767), true)); + assert_eq!(layout.axes[5], field(1, 8 + 5 * 16, 16, (-32768, 32767), true)); + // A report: buttons 0b101, then X = -100, Rz = 32767; the rest 0. + let mut report = vec![1, 0b101]; + report.extend((-100_i16).to_le_bytes()); + report.extend([0; 8]); + report.extend(32767_i16.to_le_bytes()); + let decoded = layout.decode(&report).expect("report 1 carries the axes"); + assert_eq!(decoded.axes, axes(&[(0, -1), (1, 0), (2, 0), (3, 0), (4, 0), (5, 350)])); + // The same report carries buttons 1 to 8: 1 and 3 are down. + assert_eq!(decoded.buttons, Some(0b101)); + } + + #[test] + fn unnumbered_unsigned_axes_after_padding_are_centred() { + let layout = layout(UNNUMBERED_UNSIGNED); + assert!(!layout.numbered); + assert_eq!(layout.axes[0], field(0, 8, 8, (0, 255), false)); + assert_eq!(layout.axes[2], field(0, 24, 8, (0, 255), false)); + assert_eq!(layout.axes[3], None); + // PUSH and POP: Rz has its own range, and the size and count it was declared with. + assert_eq!(layout.axes[5], field(0, 32, 8, (0, 127), false)); + // No id byte: padding, X at its minimum, Y at the top, Z near the middle, Rz at the top. + let decoded = layout.decode(&[0xEE, 0, 255, 128, 127]).expect("one report carries everything"); + assert_eq!(decoded.axes, axes(&[(0, -350), (1, 350), (2, 1), (5, 350)])); + } + + #[test] + fn other_interfaces_are_not_3d_mice() { + for bytes in [KEYBOARD, MOUSE, &[][..]] { + let descriptor = Descriptor::parse(bytes); + assert!(!descriptor.is_multi_axis_controller()); + assert_eq!(descriptor.layout(), None); + } + // A mouse's X and Y aren't axes, even though the generic desktop usages are the same. + assert!(Descriptor::parse(MOUSE).axes.iter().all(Option::is_none)); + } + + #[test] + fn an_end_collection_closes_the_controller() { + // X Y Z in the controller; Rx in a top-level Mouse collection after it isn't the + // controller's. + let bytes = + [ + &controller(&[ + 0x85, 0x01, 0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, 0x75, 0x10, 0x95, 0x03, 0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x81, 0x02, + ])[..], + &[0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x85, 0x02, 0x09, 0x33, 0x75, 0x10, 0x95, 0x01, 0x81, 0x02, 0xC0], + ] + .concat(); + let layout = layout(&bytes); + assert!(layout.axes[0].is_some()); + assert_eq!(layout.axes[3], None); + } + + #[test] + fn decodes_separate_reports_and_ignores_short_ones_and_padding() { + let layout = Layout::default(); + assert_eq!(layout.decode(&[1, 1, 2]), None); + assert_eq!(layout.decode(&[]), None); + assert_eq!(layout.decode(&[3, 1, 0, 0, 0, 0, 0]), None); + assert_eq!(layout.decode(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0]), Some(Report { axes: axes(&[(0, -100), (1, 300), (2, 0)]), buttons: None })); + assert_eq!(layout.decode(&[2, 0, 0, 1, 0, 0xff, 0xff]), Some(Report { axes: axes(&[(3, 0), (4, 1), (5, -1)]), buttons: None })); + // A padded translation report: its padding is not rotation. + assert_eq!(layout.decode(&[1, 1, 0, 2, 0, 3, 0, 9, 9, 9, 9, 9, 9]), Some(Report { axes: axes(&[(0, 1), (1, 2), (2, 3)]), buttons: None })); + } + + #[test] + fn combined_reports_carry_both_groups() { + let layout = layout(COMBINED); + let report = [1, 0x9c, 0xff, 0x2c, 0x01, 0, 0, 1, 0, 0, 0, 0xff, 0xff, 0, 0]; + assert_eq!(layout.decode(&report).map(|r| r.axes), Some(axes(&[(0, -100), (1, 300), (2, 0), (3, 1), (4, 0), (5, -1)]))); + // One byte short of Rz: dropped whole rather than half read. + assert_eq!(layout.decode(&report[..12]), None); + } + + #[test] + fn a_group_spread_over_reports_reports_only_what_each_carries() { + // X in report 1, Y in report 2: each report says nothing about the other axis. + let layout = layout(&controller(&[ + 0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, 0x75, 0x10, 0x95, 0x01, // + 0x85, 0x01, 0x09, 0x30, 0x81, 0x02, // report 1: X + 0x85, 0x02, 0x09, 0x31, 0x81, 0x02, // report 2: Y + ])); + assert_eq!(layout.decode(&[1, 100, 0]).map(|r| r.axes), Some(axes(&[(0, 100)]))); + assert_eq!(layout.decode(&[2, 200, 0]).map(|r| r.axes), Some(axes(&[(1, 200)]))); + } + + #[test] + fn a_short_report_is_dropped_whole_axes_included() { + // Report 1: X (16 bits), then a button. A report one byte short has X but not the button: + // it's damaged, so neither is taken. + let layout = layout(&controller(&[ + 0x85, 0x01, 0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, 0x75, 0x10, 0x95, 0x01, 0x09, 0x30, 0x81, 0x02, // X + 0x05, 0x09, 0x09, 0x01, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x81, 0x02, 0x75, 0x07, 0x81, 0x03, // button 1, padding + ])); + assert_eq!(layout.decode(&[1, 100, 0]), None); + assert_eq!(layout.decode(&[1, 100, 0, 1]), Some(Report { axes: axes(&[(0, 100)]), buttons: Some(1) })); + } + + #[test] + fn fields_declared_before_the_first_report_id_are_left_out() { + // X before any report id, then Y in report 1. In a numbered descriptor X would have to be + // in a report with id 0, which can't exist. + let layout = layout(&controller(&[ + 0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, 0x75, 0x10, 0x95, 0x01, 0x09, 0x30, 0x81, 0x02, // X, no id yet + 0x85, 0x01, 0x09, 0x31, 0x81, 0x02, // report 1: Y + ])); + assert_eq!(layout.axes[0], None); + assert_eq!(layout.axes[1], field(1, 0, 16, (-350, 350), false)); + assert_eq!(layout.decode(&[0, 100, 0]), None); + assert_eq!(layout.decode(&[1, 100, 0]).map(|r| r.axes), Some(axes(&[(1, 100)]))); + } + + #[test] + fn push_and_pop_restore_the_globals_however_deep() { + // PUSH, a 4-bit constant field, POP: X then takes the pushed 8-bit size, so it starts at + // bit 4 and is 8 bits wide. + let pushed = layout(&controller(&[ + 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x01, // 0..255, 8 bits, 1 field + 0xA4, 0x75, 0x04, 0x81, 0x03, 0xB4, // push, 4 constant bits, pop + 0x09, 0x30, 0x81, 0x02, // X + ])); + assert_eq!(pushed.axes[0], field(0, 4, 8, (0, 255), false)); + assert_eq!(pushed.decode(&[0xF0, 0x0F]).map(|r| r.axes), Some(axes(&[(0, 350)]))); + // 20 PUSHes, the Nth saving the size before it and then setting the size to N. Every POP + // restores what its own PUSH saved: the first POP gives back 19, the 20th gives back 8. + let mut deep = vec![0x15, 0x00, 0x25, 0x01, 0x95, 0x01, 0x75, 0x08]; + for depth in 1..=20_u8 { + deep.extend([0xA4, 0x75, depth]); + } + deep.extend([0x81, 0x03]); // 20 constant bits at the deepest size + deep.extend([0xB4, 0x09, 0x31, 0x81, 0x02]); // one POP: Y is 19 bits + deep.extend([0xB4; 19]); + deep.extend([0x09, 0x30, 0x81, 0x02]); // 19 more POPs: X is 8 bits + let deep = layout(&controller(&deep)); + assert_eq!(deep.axes[1].map(|f| (f.bit_offset, f.bits)), Some((20, 19))); + assert_eq!(deep.axes[0].map(|f| (f.bit_offset, f.bits)), Some((39, 8))); + } + + #[test] + fn a_long_item_is_skipped_whole() { + // A long item (prefix 0xFE, 3 data bytes) in front changes nothing. + let with_long_item = [&[0xFE, 0x03, 0x00, 0x01, 0x02, 0x03][..], COMBINED].concat(); + assert_eq!(layout(&with_long_item), layout(COMBINED)); + } + + #[test] + fn negative_and_one_byte_ranges_keep_their_signs() { + // -127..127 in one-byte items: 0x81 is -127, the bottom of the range. + let one_byte = layout(&controller(&[0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x01, 0x09, 0x30, 0x81, 0x02])); + assert_eq!(one_byte.axes[0].map(|f| (f.logical_min, f.logical_max)), Some((-127, 127))); + assert_eq!(one_byte.decode(&[0x81]).map(|r| r.axes), Some(axes(&[(0, -350)]))); + // -128..-1: a negative maximum stays negative, so 0xFF (-1) is the top. + let negative = layout(&controller(&[0x15, 0x80, 0x25, 0xFF, 0x75, 0x08, 0x95, 0x01, 0x09, 0x30, 0x81, 0x02])); + assert_eq!(negative.axes[0].map(|f| f.logical_max), Some(-1)); + assert_eq!(negative.decode(&[0xFF]).map(|r| r.axes), Some(axes(&[(0, 350)]))); + } + + #[test] + fn ranges_scale_to_full_scale() { + let f = |min, max, bits| Field { report_id: 0, bit_offset: 0, bits, logical_min: min, logical_max: max, relative: false }; + assert_eq!(f(-350, 350, 16).scaled(-350), -350); + assert_eq!(f(-350, 350, 16).scaled(123), 123); + assert_eq!(f(-32768, 32767, 16).scaled(32767), 350); + assert_eq!(f(-32768, 32767, 16).scaled(-32768), -350); + assert_eq!(f(0, 1023, 10).scaled(0), -350); + assert_eq!(f(0, 1023, 10).scaled(1023), 350); + // No usable range: passed through, clamped to 16 bits. + assert_eq!(f(0, 0, 32).scaled(40), 40); + assert_eq!(f(5, 5, 32).scaled(1 << 20), i16::MAX); + // Past the declared range: beyond ±350, but never i16::MIN, whose negation overflows. + assert_eq!(f(-350, 350, 32).scaled(700), 700); + assert_eq!(f(-350, 350, 32).scaled(-(1 << 30)), -i16::MAX); + assert_eq!(f(5, 5, 32).scaled(-(1 << 20)), -i16::MAX); + // Unsigned maxima: with a minimum of 0, 0x26 0xFF 0xFF is 65535 and the 4-byte + // 0x27 0xFF 0xFF 0xFF 0xFF is 4294967295, so full deflection of a 32-bit field is 350. + let two_byte = layout(&controller(&[0x15, 0x00, 0x26, 0xFF, 0xFF, 0x75, 0x10, 0x95, 0x01, 0x09, 0x30, 0x81, 0x02])); + assert_eq!(two_byte.axes[0].map(|f| f.logical_max), Some(65535)); + assert_eq!(two_byte.decode(&[0xFF, 0xFF]).map(|r| r.axes), Some(axes(&[(0, 350)]))); + let four_byte = layout(&controller(&[0x15, 0x00, 0x27, 0xFF, 0xFF, 0xFF, 0xFF, 0x75, 0x20, 0x95, 0x01, 0x09, 0x30, 0x81, 0x02])); + assert_eq!(four_byte.axes[0].map(|f| f.logical_max), Some(4_294_967_295)); + assert_eq!(four_byte.decode(&[0xFF; 4]).map(|r| r.axes), Some(axes(&[(0, 350)]))); + } + + #[test] + fn a_field_must_be_1_to_32_bits_wide() { + // Fields are public, so a hand-built one can be any width; reading it mustn't panic. + for bits in [0, 33, 64, 65, u32::MAX] { + let layout = + Layout { axes: [field(0, 0, bits, (-1, 1), false), None, None, None, None, None], buttons: [None; MAX_BUTTONS], numbered: false }; + assert_eq!(layout.decode(&[0; 16]), None, "{bits} bits"); + } + // Nor can any range: the extremes of i64 don't overflow the scaling. + for (min, max) in [(i64::MIN, i64::MAX), (i64::MIN, 0), (0, i64::MAX), (i64::MAX, i64::MIN)] { + for (bits, report) in [(1, [0xFF; 4]), (32, [0xFF; 4]), (32, [0; 4])] { + let layout = Layout { + axes: [field(0, 0, bits, (min, max), false), None, None, None, None, None], + buttons: [None; MAX_BUTTONS], + numbered: false, + }; + assert!(layout.decode(&report).is_some(), "{min}..{max}, {bits} bits"); + } + } + } + + #[test] + fn fields_reach_the_end_of_the_biggest_report_and_no_further() { + // An 8-bit X after 514 bytes of padding is still inside a report. + let far = layout(&controller(&[0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x96, 0x02, 0x02, 0x81, 0x03, 0x95, 0x01, 0x09, 0x30, 0x81, 0x02])); + assert_eq!(far.axes[0].map(|f| f.bit_offset), Some(514 * 8)); + let mut report = vec![0; 515]; + if let Some(last) = report.last_mut() { + *last = 0xFF; + } + assert_eq!(far.decode(&report).map(|r| r.axes), Some(axes(&[(0, 350)]))); + // A 32-bit X that ends exactly at the last bit is kept; one byte later it's outside. + let at_end = |padding: u16| { + let [low, high] = padding.to_le_bytes(); + Descriptor::parse(&controller(&[0x75, 0x08, 0x96, low, high, 0x81, 0x03, 0x75, 0x20, 0x95, 0x01, 0x09, 0x30, 0x81, 0x02])).layout() + }; + assert_eq!(at_end(4092).and_then(|l| l.axes[0]).map(|f| f.bit_offset), Some(4092 * 8)); + assert_eq!(at_end(4093), None); + } + + #[test] + fn truncated_and_hostile_input_never_panics() { + for bytes in [SPLIT, COMBINED, BUTTONS_FIRST, UNNUMBERED_UNSIGNED] { + for end in 0..=bytes.len() { + if let Some(layout) = Descriptor::parse(&bytes[..end]).layout() { + let _ = layout.decode(&[1, 2, 3]); + } + } + } + // Long item with a length running past the end, huge usage ranges and counts, deep nesting, + // pops without pushes, pushes without pops, 32-bit fields at the end of the biggest report. + let _ = Descriptor::parse(&[0xFE, 0xFF, 0x00, 0x01]); + let _ = Descriptor::parse(&[0x05, 0x01, 0x1B, 0, 0, 0, 0, 0x2B, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0x06]); + let _ = Descriptor::parse(&controller(&[0x77, 0xFF, 0xFF, 0xFF, 0xFF, 0x97, 0xFF, 0xFF, 0xFF, 0xFF, 0x09, 0x30, 0x81, 0x02])); + let _ = Descriptor::parse(&controller(&[0x75, 0x20, 0x96, 0xFF, 0x03, 0x09, 0x30, 0x81, 0x02, 0x09, 0x31, 0x81, 0x02])); + let _ = Descriptor::parse(&[0xC0; 64]); + let _ = Descriptor::parse(&[0xA1; 64]); + let _ = Descriptor::parse(&[0xB4; 64]); + let _ = Descriptor::parse(&[0xA4; MAX_DESCRIPTOR_BYTES * 2]); + let mut state = 0x2545_F491_4F6C_DD1D_u64; + let mut next = || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state.to_le_bytes()[0] + }; + for _ in 0..500 { + let bytes: Vec = [0x05, 0x01, 0x09, 0x08, 0xA1, 0x01].into_iter().chain((0..64).map(|_| next())).collect(); + let report: Vec = (0..16).map(|_| next()).collect(); + if let Some(layout) = Descriptor::parse(&bytes).layout() { + let _ = layout.decode(&report); + } + } + } +} diff --git a/crates/space-mouse/src/lib.rs b/crates/space-mouse/src/lib.rs new file mode 100644 index 00000000..6ab8099d --- /dev/null +++ b/crates/space-mouse/src/lib.rs @@ -0,0 +1,15 @@ +//! 3D mouse input that doesn't depend on how the device is read or what the motion drives. The +//! apps open the device their own way (hidraw on Linux, hidapi on Windows), pass its report +//! descriptor to [`Descriptor::parse`] and each input report to [`Layout::decode`], and apply the +//! six axes and the buttons they get back to their own camera and commands. +//! +//! Any device that declares a HID Multi-axis Controller is read the same way: 3Dconnexion's, +//! which send translation and rotation in two reports and their buttons in a third, and homemade +//! ones, which often send all six axes in one report, sometimes after their buttons. The +//! descriptor says where each axis and button is. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +mod descriptor; + +pub use descriptor::{Descriptor, FULL_SCALE, Field, Layout, MAX_BUTTONS, Report}; diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 2118c8e0..7bc55bf9 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -36,6 +36,8 @@ impl Class { /// prefix. pub const TABLE: &[(&str, Class)] = &[ ("geom", Class::Layer(0)), + // 3D mouse descriptors and reports (no workspace dependencies; the apps read the device) + ("space-mouse", Class::Layer(0)), // the derive macros (proc-macro, no workspace dependencies) ("derive", Class::Standalone), ("sketch", Class::Layer(1)),