3D mouse: any HID multi-axis controller with motion axes (#1880)

Thanks @nomadsgalaxy.
This commit is contained in:
The Nomad authored and GitHub committed 2026-10-17 06:48:02 -04:00
1 parent fbc00bb2c4
commit e052d19879
11 files changed
+998 -398

No files matched your search

Generated
+5
View File
@@ -4078,6 +4078,7 @@ dependencies = [
"rustls", "rustls",
"serde_json", "serde_json",
"solidcraft-engine", "solidcraft-engine",
"solidcraft-space-mouse",
"solidcraft-ui-egui", "solidcraft-ui-egui",
"ureq", "ureq",
"windows-sys 0.61.2", "windows-sys 0.61.2",
@@ -4236,6 +4237,10 @@ dependencies = [
"ttf-parser", "ttf-parser",
] ]
[[package]]
name = "solidcraft-space-mouse"
version = "0.14.0"
[[package]] [[package]]
name = "solidcraft-ui-egui" name = "solidcraft-ui-egui"
version = "0.14.0" version = "0.14.0"
+1
View File
@@ -23,6 +23,7 @@ solidcraft-doc = { path = "crates/doc" }
solidcraft-io = { path = "crates/io" } solidcraft-io = { path = "crates/io" }
solidcraft-engine = { path = "crates/engine" } solidcraft-engine = { path = "crates/engine" }
solidcraft-ui-egui = { path = "crates/ui-egui" } solidcraft-ui-egui = { path = "crates/ui-egui" }
solidcraft-space-mouse = { path = "crates/space-mouse" }
solidcraft-mcp = { path = "crates/mcp" } solidcraft-mcp = { path = "crates/mcp" }
solidcraft-derive = { path = "crates/derive" } solidcraft-derive = { path = "crates/derive" }
+16 -8
View File
@@ -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`. 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 On Windows and Linux, SolidCraft detects a connected 3D mouse through its HID multi-axis
multi-axis HID interface. Move the cap to pan and zoom, and tilt or twist it to rotate the 3D view. 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 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 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 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` 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` directly, so the logged-in user needs access to the device. A udev rule that adds the `uaccess`
tag does that, for example 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", 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`) Older SpaceNavigators use Logitech's vendor id `046d`, so add a rule with their product id (see
instead of matching every Logitech device. Reload with `sudo udevadm control --reload && sudo udevadm trigger`, then replug the device. The Flatpak `lsusb`) instead of matching every Logitech device. A homemade device needs a rule with its own
sandbox does not expose hidraw devices, so the 3D mouse only works with native builds there for now. vendor and product id. Reload with `sudo udevadm control --reload && sudo udevadm trigger`, then
Devices that send all six axes in HID report 1 (some homemade ones do) are recognised from their replug the device. The Flatpak sandbox does not expose hidraw devices, so the 3D mouse only works
report descriptor, so they work as well. 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 In the browser: `cd apps/solidcraft-web && trunk build --release --public-url ./` (WebGPU, or
WebGL2 with `?webgl`; `?sample` opens the sample part). WebGL2 with `?webgl`; `?sample` opens the sample part).
+1
View File
@@ -23,6 +23,7 @@ neg_cmp_op_on_partial_ord = "allow"
[dependencies] [dependencies]
solidcraft-engine = { workspace = true } solidcraft-engine = { workspace = true }
solidcraft-ui-egui = { workspace = true } solidcraft-ui-egui = { workspace = true }
solidcraft-space-mouse = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] } eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
egui = { workspace = true } egui = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
+126 -102
View File
@@ -1,8 +1,8 @@
//! 3Dconnexion HID navigation on Windows (through hidapi) and Linux (through spacenavd when it //! 3D mouse navigation on Windows (through hidapi) and Linux (through spacenavd when it runs, else
//! runs, else hidraw). The device reader never touches the document or UI; it publishes the //! hidraw). Read directly, any HID multi-axis controller works: 3Dconnexion's and homemade ones.
//! latest axes, and the egui thread applies them once per frame. //! 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")] #[cfg(target_os = "linux")]
mod hidraw; mod hidraw;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -12,17 +12,20 @@ use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use egui::Context; use egui::Context;
use hid_descriptor::Layout;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
use hidapi::{DeviceInfo, HidApi}; use hidapi::{DeviceInfo, HidApi};
use quick_xml::Reader; use quick_xml::Reader;
use quick_xml::events::Event; use quick_xml::events::Event;
use solidcraft_space_mouse::Layout;
use solidcraft_ui_egui::SolveApp; use solidcraft_ui_egui::SolveApp;
use solidcraft_ui_egui::space_mouse::{Axes, AxisRule, DriverMapping, WheelGuard, navigate_frame}; use solidcraft_ui_egui::space_mouse::{Axes, AxisRule, DriverMapping, WheelGuard, navigate_frame};
const STALE_AFTER: Duration = Duration::from_millis(150); const STALE_AFTER: Duration = Duration::from_millis(150);
const RETRY_AFTER: Duration = Duration::from_secs(2); const RETRY_AFTER: Duration = Duration::from_secs(2);
const MAX_PROFILE_BYTES: u64 = 1024 * 1024; 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 /// 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 /// 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)] #[derive(Clone, Copy)]
struct Reading { struct Reading {
axes: Axes, axes: Axes,
translation_at: Instant, /// When each of X, Y, Z, Rx, Ry and Rz last arrived. Each ages on its own: a device may send
rotation_at: Instant, /// 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. /// 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 /// 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. /// the values change, and a steady hold can go seconds without one.
@@ -166,64 +170,42 @@ struct Reading {
impl Reading { impl Reading {
fn new(now: Instant) -> Self { 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 { fn current(self, now: Instant) -> Axes {
if self.held { let mut axes = self.axes;
return self.axes; if !self.held {
} for (value, at) in axes.translation.iter_mut().chain(axes.rotation.iter_mut()).zip(self.at) {
Axes { if now.duration_since(at) >= STALE_AFTER {
translation: if now.duration_since(self.translation_at) < STALE_AFTER { self.axes.translation } else { [0; 3] }, *value = 0;
rotation: if now.duration_since(self.rotation_at) < STALE_AFTER { self.axes.rotation } else { [0; 3] }, }
}
} }
axes
} }
} }
/// The old SpaceNavigator uses Logitech's VID; newer 3Dconnexion devices use their own. /// The Generic Desktop "Multi-Axis Controller" usage names a 3D mouse's motion interface, whoever
fn is_3d_mouse_vendor(vendor_id: u16) -> bool { /// made it, and excludes ordinary mice and the keyboard interfaces of 3D mice.
matches!(vendor_id, 0x046d | 0x256f)
}
/// The HID usage excludes ordinary Logitech mice and the keyboard interfaces of SpaceMice.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn is_3d_mouse(info: &DeviceInfo) -> bool { 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]> { /// Takes the axes an input report carries, as the device's descriptor laid them out, and leaves
let &[x0, x1, y0, y1, z0, z1] = bytes.get(..6)? else { return None }; /// the others as they were: translation and rotation may come in separate reports (3Dconnexion's
Some([i16::from_le_bytes([x0, x1]), i16::from_le_bytes([y0, y1]), i16::from_le_bytes([z0, z1])]) /// wired devices) or one (its wireless ones and most homemade devices).
}
/// 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.
fn decode_report(report: &[u8], layout: Layout, reading: &mut Reading, now: Instant) -> bool { fn decode_report(report: &[u8], layout: Layout, reading: &mut Reading, now: Instant) -> bool {
match report.first().copied() { let Some(decoded) = layout.decode(report) else { return false };
Some(1) if layout == Layout::Combined => { let values = reading.axes.translation.iter_mut().chain(reading.axes.rotation.iter_mut());
let Some(body) = report.get(1..) else { return false }; for ((value, at), new) in values.zip(reading.at.iter_mut()).zip(decoded.axes) {
let (Some(translation), Some(rotation)) = (body.get(..6).and_then(three_axes), body.get(6..).and_then(three_axes)) else { return false }; if let Some(new) = new {
reading.axes = Axes { translation, rotation }; *value = new;
reading.translation_at = now; *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;
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. /// Publishes one input report from the device to the egui thread.
@@ -261,9 +243,12 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
loop { loop {
if let Err(error) = api.refresh_devices() { if let Err(error) = api.refresh_devices() {
log::warn!("3D mouse: device scan failed: {error}"); 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()) { } else if let Some((device, layout)) = api.device_list().filter(|info| is_3d_mouse(info)).find_map(|info| {
let layout = device_layout(&device); let device = info.open_device(&api).ok()?;
let mut report = [0_u8; 64]; let layout = device_layout(&device, info.vendor_id())?;
Some((device, layout))
}) {
let mut report = [0_u8; REPORT_BUFFER_BYTES];
loop { loop {
match device.read_timeout(&mut report, 100) { match device.read_timeout(&mut report, 100) {
Ok(0) => {} 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 /// How the device packs its axes, from its report descriptor, or None when it isn't one to read:
/// descriptor cannot be 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")] #[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]; let mut descriptor = [0_u8; hidapi::MAX_REPORT_DESCRIPTOR_SIZE];
match device.get_report_descriptor(&mut descriptor) { let read = device.get_report_descriptor(&mut descriptor);
Ok(size) => hid_descriptor::Descriptor::parse(descriptor.get(..size).unwrap_or_default()).layout(), if let Ok(size @ 1..) = read {
Err(error) => { return solidcraft_space_mouse::Descriptor::parse(descriptor.get(..size).unwrap_or_default()).layout();
log::info!("3D mouse: no report descriptor, assuming separate translation and rotation reports: {error}"); }
Layout::Split 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) { match std::fs::File::open(&device.path) {
Ok(mut file) => { Ok(mut file) => {
denied = None; denied = None;
log::info!("3D mouse: reading {} ({:?} reports)", device.path.display(), device.layout); log::info!("3D mouse: reading {}", device.path.display());
let mut report = [0_u8; 64]; let mut report = [0_u8; REPORT_BUFFER_BYTES];
loop { loop {
match file.read(&mut report) { match file.read(&mut report) {
Ok(0) => break, Ok(0) => break,
@@ -418,20 +414,75 @@ mod tests {
} }
#[test] #[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 now = Instant::now();
let mut reading = Reading::new(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_eq!(reading.current(now), Axes::default());
assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], Layout::Split, &mut reading, now)); assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], layout, &mut reading, now));
assert_eq!(reading.current(now).translation, [-100, 300, 0]); assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], layout, &mut reading, now));
assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], Layout::Split, &mut reading, now)); assert_eq!(reading.current(now), Axes { translation: [-100, 300, 0], rotation: [0, 1, -1] });
assert_eq!(reading.current(now).rotation, [0, 1, -1]); // Only rotation arrives again: translation goes stale, rotation stays current.
let later = now + STALE_AFTER + Duration::from_millis(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], layout, &mut reading, later));
assert!(decode_report(&[2, 4, 0, 5, 0, 6, 0], Layout::Split, &mut reading, later)); assert_eq!(reading.current(later), Axes { translation: [0; 3], rotation: [4, 5, 6] });
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 0, 0, 0, 0, 0, 0], Layout::Split, &mut reading, later)); // A device sending all six in one report updates both at once.
assert_eq!(reading.current(later), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] }); 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] #[test]
@@ -447,33 +498,6 @@ mod tests {
assert_eq!(hid.current(later), Axes::default()); 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] #[test]
fn applies_3dxware_axis_swaps_and_reversals() { fn applies_3dxware_axis_swaps_and_reversals() {
let profile = br#" 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);
}
}
}
+7 -35
View File
@@ -1,10 +1,10 @@
//! Finding a 3D mouse on Linux. The kernel exposes every HID interface as `/dev/hidrawN`, with //! 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 //! its report descriptor under `/sys/class/hidraw/hidrawN/device/`. Reading it is plain file
//! plain file I/O, so no udev or hidapi library is needed. //! I/O, so no udev or hidapi library is needed.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use super::hid_descriptor::{Descriptor, Layout}; use solidcraft_space_mouse::{Descriptor, Layout};
const SYSFS: &str = "/sys/class/hidraw"; const SYSFS: &str = "/sys/class/hidraw";
const DEV: &str = "/dev"; const DEV: &str = "/dev";
@@ -14,14 +14,7 @@ pub struct Device {
pub layout: Layout, pub layout: Layout,
} }
/// The vendor id from `HID_ID=<bus>:<vendor>:<product>` (all hexadecimal) in a sysfs `uevent`. /// The first hidraw interface that is a multi-axis controller with motion axes, whoever made it.
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.
pub fn find() -> Option<Device> { 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(); let mut names: Vec<_> = std::fs::read_dir(SYSFS).ok()?.filter_map(Result::ok).map(|entry| entry.file_name()).collect();
names.sort(); names.sort();
@@ -29,29 +22,8 @@ pub fn find() -> Option<Device> {
} }
fn probe(sysfs: &Path, path: PathBuf) -> 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()?); let descriptor = Descriptor::parse(&std::fs::read(sysfs.join("device/report_descriptor")).ok()?);
descriptor.is_multi_axis_controller().then(|| Device { path, layout: descriptor.layout() }) // 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 })
#[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:?}");
}
}
} }
+9
View File
@@ -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
+816
View File
@@ -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<i64> {
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<Field>; 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<Field>; 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<i16>; 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<u32>,
}
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<Report> {
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<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, 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<Item>,
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<Field>; AXIS_COUNT],
buttons: [Option<Field>; 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<Globals> = Vec::new();
let mut depth = 0_u32;
// The depth of the open Multi-axis Controller application collection; its fields count.
let mut controller: Option<u32> = 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<u32> = 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<Layout> {
let keep = |field: &Option<Field>| 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<Field> {
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<i16>; 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<u8> {
[&[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<u8> = [0x05, 0x01, 0x09, 0x08, 0xA1, 0x01].into_iter().chain((0..64).map(|_| next())).collect();
let report: Vec<u8> = (0..16).map(|_| next()).collect();
if let Some(layout) = Descriptor::parse(&bytes).layout() {
let _ = layout.decode(&report);
}
}
}
}
+15
View File
@@ -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};
+2
View File
@@ -36,6 +36,8 @@ impl Class {
/// prefix. /// prefix.
pub const TABLE: &[(&str, Class)] = &[ pub const TABLE: &[(&str, Class)] = &[
("geom", Class::Layer(0)), ("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) // the derive macros (proc-macro, no workspace dependencies)
("derive", Class::Standalone), ("derive", Class::Standalone),
("sketch", Class::Layer(1)), ("sketch", Class::Layer(1)),