diff --git a/README.md b/README.md index 2789a9cb..35e53042 100644 --- a/README.md +++ b/README.md @@ -108,11 +108,25 @@ cargo xtask ci # fmt, clippy, tests, as Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`. -On Windows, SolidCraft detects a connected 3Dconnexion SpaceNavigator or SpaceMouse through its +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. While editing a sketch, the cap pans and zooms without changing the sketch view. The Navigation page in Preferences has an on/off switch and a speed control for the 3D mouse. +On Linux 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: + +``` +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="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. + In the browser: `cd apps/solvecraft-web && trunk build --release --public-url ./` (WebGPU, or WebGL2 with `?webgl`; `?sample` opens the sample part). diff --git a/apps/solvecraft/Cargo.toml b/apps/solvecraft/Cargo.toml index f7c1d4a3..84e1d0b4 100644 --- a/apps/solvecraft/Cargo.toml +++ b/apps/solvecraft/Cargo.toml @@ -19,9 +19,11 @@ serde_json = { workspace = true } log = { workspace = true } rfd = { workspace = true } +[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies] +quick-xml = { workspace = true } + [target.'cfg(target_os = "windows")'.dependencies] hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] } -quick-xml = { workspace = true } [target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] winit = { version = "0.30", default-features = false, features = ["x11"] } diff --git a/apps/solvecraft/src/main.rs b/apps/solvecraft/src/main.rs index 531f5212..763b5d1e 100644 --- a/apps/solvecraft/src/main.rs +++ b/apps/solvecraft/src/main.rs @@ -15,7 +15,7 @@ #[path = "../../app_icon.rs"] mod app_icon; mod control_server; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "windows", target_os = "linux"))] mod space_mouse; use solvecraft_engine::Session; @@ -23,7 +23,7 @@ use solvecraft_ui_egui::{Services, SolveApp}; struct App { solve: SolveApp, - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", target_os = "linux"))] space_mouse: space_mouse::SpaceMouse, } @@ -36,7 +36,7 @@ impl eframe::App for App { solvecraft_ui_egui::documents::request_quit(&mut self.solve); } self.solve.logic(ctx); - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", target_os = "linux"))] self.space_mouse.tick(&mut self.solve, ctx); if self.solve.quit_requested { ctx.send_viewport_cmd(egui::ViewportCommand::Close); @@ -229,11 +229,11 @@ fn main() -> eframe::Result { let every = if app.autosave_minutes > 0.0 { app.autosave_minutes * 60.0 } else { f64::MAX / 4.0 }; solvecraft_ui_egui::recovery_ui::start(&mut app, &dir, std::time::Duration::from_secs_f64(every.min(1e9))); } - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", target_os = "linux"))] let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx); Ok(Box::new(App { solve: app, - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", target_os = "linux"))] space_mouse, })) }), diff --git a/apps/solvecraft/src/space_mouse.rs b/apps/solvecraft/src/space_mouse.rs index 53c35018..6698453f 100644 --- a/apps/solvecraft/src/space_mouse.rs +++ b/apps/solvecraft/src/space_mouse.rs @@ -1,10 +1,17 @@ -//! Windows 3Dconnexion HID navigation. The device reader never touches the document or UI; -//! it publishes the latest axes, and the egui thread applies them once per frame. +//! 3Dconnexion HID navigation on Windows (through hidapi) and Linux (through hidraw). 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; 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; @@ -208,9 +215,14 @@ impl Reading { } /// 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. +#[cfg(target_os = "windows")] fn is_3d_mouse(info: &DeviceInfo) -> bool { - matches!(info.vendor_id(), 0x046d | 0x256f) && info.usage_page() == 0x01 && info.usage() == 0x08 + is_3d_mouse_vendor(info.vendor_id()) && info.usage_page() == 0x01 && info.usage() == 0x08 } fn three_axes(bytes: &[u8]) -> Option<[i16; 3]> { @@ -220,8 +232,18 @@ fn three_axes(bytes: &[u8]) -> Option<[i16; 3]> { /// 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. -fn decode_report(report: &[u8], reading: &mut Reading, now: Instant) -> bool { +/// 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. +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 + } Some(1) => { let Some(translation) = report.get(1..).and_then(three_axes) else { return false }; reading.axes.translation = translation; @@ -238,6 +260,24 @@ fn decode_report(report: &[u8], reading: &mut Reading, now: Instant) -> bool { } } +/// Publishes one input report from the device to the egui thread. +fn publish(ctx: &Context, shared: &Mutex, layout: Layout, report: &[u8]) { + if let Ok(mut reading) = shared.lock() + && decode_report(report, layout, &mut reading, Instant::now()) + { + ctx.request_repaint(); + } +} + +/// The device is gone: stop reporting its last position. +fn reset(ctx: &Context, shared: &Mutex) { + if let Ok(mut reading) = shared.lock() { + *reading = Reading::new(Instant::now()); + } + ctx.request_repaint(); +} + +#[cfg(target_os = "windows")] fn reader(ctx: Context, shared: Arc>) { let Ok(mut api) = HidApi::new() else { log::warn!("3D mouse: HID initialization failed"); @@ -247,27 +287,72 @@ fn reader(ctx: Context, shared: Arc>) { 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]; loop { match device.read_timeout(&mut report, 100) { Ok(0) => {} - Ok(size) => { - if let Ok(mut reading) = shared.lock() - && decode_report(&report[..size], &mut reading, Instant::now()) - { - ctx.request_repaint(); - } - } + Ok(size) => publish(&ctx, &shared, layout, report.get(..size).unwrap_or_default()), Err(error) => { log::info!("3D mouse disconnected: {error}"); break; } } } - if let Ok(mut reading) = shared.lock() { - *reading = Reading::new(Instant::now()); + reset(&ctx, &shared); + } + std::thread::sleep(RETRY_AFTER); + } +} + +/// How the device packs its axes, from its report descriptor; the usual split layout if the +/// descriptor cannot be read. +#[cfg(target_os = "windows")] +fn device_layout(device: &hidapi::HidDevice) -> Layout { + 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 + } + } +} + +/// Reads `/dev/hidrawN` with plain blocking reads, which end with an error when the device is +/// unplugged. Opening needs permission: most distributions grant it to the logged-in user for +/// devices that carry the `uaccess` udev tag, which a rule for the 3D mouse has to add. +#[cfg(target_os = "linux")] +fn reader(ctx: Context, shared: Arc>) { + use std::io::Read; + let mut denied = None; + loop { + if let Some(device) = hidraw::find() { + 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]; + loop { + match file.read(&mut report) { + Ok(0) => break, + Ok(size) => publish(&ctx, &shared, device.layout, report.get(..size).unwrap_or_default()), + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} + Err(error) => { + log::info!("3D mouse disconnected: {error}"); + break; + } + } + } + reset(&ctx, &shared); + } + // Say so once per device node, not every retry. + Err(error) if denied.as_ref() != Some(&device.path) => { + log::warn!("3D mouse: cannot open {}: {error}. A udev rule granting the logged-in user access is needed.", device.path.display()); + denied = Some(device.path); + } + Err(_) => {} } - ctx.request_repaint(); } std::thread::sleep(RETRY_AFTER); } @@ -371,19 +456,46 @@ mod tests { fn decodes_separate_reports_without_stale_motion_or_padding_interference() { let now = Instant::now(); let mut reading = Reading::new(now); - assert!(!decode_report(&[1, 1, 2], &mut reading, now)); + assert!(!decode_report(&[1, 1, 2], Layout::Split, &mut reading, now)); assert_eq!(reading.current(now), Axes::default()); - assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], &mut reading, now)); + 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], &mut reading, now)); + assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], Layout::Split, &mut reading, now)); assert_eq!(reading.current(now).rotation, [0, 1, -1]); 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], &mut reading, later)); - assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 0, 0, 0, 0, 0, 0], &mut reading, later)); + 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] }); } + #[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 six_axes_move_the_camera_and_sketches_keep_their_orientation() { let mut cam = Camera::default(); diff --git a/apps/solvecraft/src/space_mouse/hid_descriptor.rs b/apps/solvecraft/src/space_mouse/hid_descriptor.rs new file mode 100644 index 00000000..3dfa5fea --- /dev/null +++ b/apps/solvecraft/src/space_mouse/hid_descriptor.rs @@ -0,0 +1,253 @@ +//! 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/solvecraft/src/space_mouse/hidraw.rs b/apps/solvecraft/src/space_mouse/hidraw.rs new file mode 100644 index 00000000..e238052a --- /dev/null +++ b/apps/solvecraft/src/space_mouse/hidraw.rs @@ -0,0 +1,57 @@ +//! 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. + +use std::path::{Path, PathBuf}; + +use super::hid_descriptor::{Descriptor, Layout}; + +const SYSFS: &str = "/sys/class/hidraw"; +const DEV: &str = "/dev"; + +pub struct Device { + pub path: PathBuf, + 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. +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(); + names.into_iter().find_map(|name| probe(&Path::new(SYSFS).join(&name), Path::new(DEV).join(&name))) +} + +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:?}"); + } + } +}