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
Generated
+5
@@ -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"
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
@@ -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)),
|
||||||
|
|||||||
Reference in new issue
Block a user