3D mouse: any HID multi-axis controller with motion axes (#1880)
Thanks @nomadsgalaxy.
This commit is contained in:
1 parent
fbc00bb2c4
commit
e052d19879
11 files changed
+998
-398
No files matched your search
@@ -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 }
|
||||
|
||||
+126
-102
@@ -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<DriverMapping> {
|
||||
#[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<Mutex<Reading>>) {
|
||||
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<Mutex<Reading>>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<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
|
||||
}
|
||||
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<Mutex<Reading>>) {
|
||||
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#"
|
||||
|
||||
@@ -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<Item = Item> + '_ {
|
||||
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<u32> = 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<u8> = (0..64)
|
||||
.map(|_| {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
state.to_le_bytes()[0]
|
||||
})
|
||||
.collect();
|
||||
let _ = Descriptor::parse(&bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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=<bus>:<vendor>:<product>` (all hexadecimal) in a sysfs `uevent`.
|
||||
fn vendor_id(uevent: &str) -> Option<u16> {
|
||||
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<Device> {
|
||||
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<Device> {
|
||||
}
|
||||
|
||||
fn probe(sysfs: &Path, path: PathBuf) -> Option<Device> {
|
||||
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 })
|
||||
}
|
||||
Reference in new issue
Block a user