openlogi_device/thumbwheel.rs
1//! HID++ `Thumbwheel` (feature `0x2150`) — divert the MX-line horizontal thumb
2//! wheel so its rotation and single-tap gesture arrive as HID++ events instead
3//! of native HID scroll.
4//!
5//! The wheel only has two reporting modes — Native (HID scroll) or Diverted
6//! (HID++ events) — there is no "report taps but keep scrolling" mode. So the
7//! capture session diverts the wheel whenever the user's thumbwheel config
8//! leaves its defaults (click bound, rotation rebound, or sensitivity changed),
9//! and re-synthesises horizontal scroll from the rotation deltas to keep
10//! scrolling working.
11//!
12//! `hidpp 0.2` ships no typed wrapper, so we re-implement the three functions
13//! OpenLogi needs: `getThumbwheelInfo` (capabilities — notably whether the wheel
14//! reports a single tap), `setThumbwheelReporting` (enter/leave diverted mode),
15//! and decode the unsolicited `thumbwheelEvent`. Wire format from
16//! `x2150_thumbwheel_v0.pdf`.
17
18use std::sync::Arc;
19
20use hidpp::{
21 channel::HidppChannel,
22 nibble::U4,
23 protocol::v20::{self, Hidpp20Error},
24};
25use serde::{Deserialize, Serialize};
26
27/// `Thumbwheel` HID++ feature ID.
28pub const FEATURE_ID: u16 = 0x2150;
29
30/// `getThumbwheelInfo` function ID.
31const FN_GET_INFO: u8 = 0;
32/// `setThumbwheelReporting` function ID.
33const FN_SET_REPORTING: u8 = 2;
34
35/// Reporting-mode value: native HID scroll.
36const MODE_NATIVE: u8 = 0;
37/// Reporting-mode value: diverted to HID++ events.
38const MODE_DIVERTED: u8 = 1;
39
40/// `c_single_tap` capability bit in `getThumbwheelInfo` byte 5.
41const CAP_SINGLE_TAP: u8 = 0x08;
42/// `single_tap` bit in `thumbwheelEvent` byte 5.
43const EV_SINGLE_TAP: u8 = 0x08;
44/// `proxy` bit in `thumbwheelEvent` byte 5.
45const EV_PROXY: u8 = 0x04;
46/// `touch` bit in `thumbwheelEvent` byte 5.
47const EV_TOUCH: u8 = 0x02;
48
49/// Where a `thumbwheelEvent` sits in the life cycle of one roll
50/// (`thumbwheelEvent` byte 4).
51///
52/// This is what separates a deliberate tap from the tap the wheel's touch
53/// sensor flags for the finger that rolled it: every report from `Start`
54/// through `Stop` belongs to a roll, so a tap bit inside that span is an
55/// artifact of the same contact. Only [`RotationStatus::Inactive`] means the
56/// wheel is at rest and a tap is the user's own.
57///
58/// [`RotationStatus::Stop`] is why this field is read rather than inferred
59/// from the report's own rotation. Observed on an MX Master 4 (Bolt) with
60/// `examples/thumbwheel_trace`, one nudge of the wheel:
61///
62/// ```text
63/// rot= -1 byte4=0x02 byte5=0x02 touch=true → Active
64/// rot= 0 byte4=0x03 byte5=0x00 touch=false → Stop
65/// ```
66///
67/// The release reports no rotation at all, so rotation alone cannot tell it
68/// from a tap on a settled wheel — and on a wheel that does support
69/// `single_tap` that release is exactly where the artifact lands.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum RotationStatus {
72 /// No rotation — the wheel is at rest.
73 Inactive,
74 /// The first rotation report of a roll.
75 Start,
76 /// A subsequent rotation report.
77 Active,
78 /// The roll ended: released, no touch.
79 Stop,
80}
81
82impl RotationStatus {
83 /// Decode `thumbwheelEvent` byte 4. Values outside the four the spec
84 /// defines decode as [`RotationStatus::Inactive`] — a firmware that
85 /// reports something else has said nothing about a roll, and the caller
86 /// still has the report's own `rotation` to go on. Claiming a roll here
87 /// instead would let one unrecognised value make the tap permanently
88 /// undeliverable.
89 #[must_use]
90 fn from_byte(byte: u8) -> Self {
91 match byte {
92 1 => Self::Start,
93 2 => Self::Active,
94 3 => Self::Stop,
95 _ => Self::Inactive,
96 }
97 }
98
99 /// Whether this report belongs to a roll — including its `Stop`, which
100 /// carries no rotation of its own but is still the roll's own contact.
101 #[must_use]
102 pub fn is_rolling(self) -> bool {
103 !matches!(self, Self::Inactive)
104 }
105}
106
107/// What one revolution of the wheel measures in each reporting mode.
108///
109/// The two are not the same unit: an MX Master 4 reports 20 ratchets per
110/// revolution natively and 120 increments per revolution diverted. Anything
111/// re-synthesising scroll from diverted increments has to scale by the ratio,
112/// or the same physical motion scrolls six times as far as it did before the
113/// wheel was diverted.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
115pub struct WheelResolution {
116 /// Ratchets per revolution in native (HID) mode.
117 pub native_res: u16,
118 /// Rotation increments per revolution in diverted (HID++) mode.
119 pub diverted_res: u16,
120}
121
122impl WheelResolution {
123 /// Resolutions a wheel did not report, scaling increments through
124 /// unchanged.
125 pub const UNKNOWN: Self = Self {
126 native_res: 0,
127 diverted_res: 0,
128 };
129
130 /// Native scroll units one diverted increment is worth.
131 ///
132 /// `1.0` when either resolution is missing — a wheel that did not answer
133 /// `getThumbwheelInfo` keeps the raw increment-per-unit behavior rather
134 /// than having its scroll silently scaled by a guess.
135 #[must_use]
136 pub fn native_per_increment(self) -> f32 {
137 if self.native_res == 0 || self.diverted_res == 0 {
138 return 1.0;
139 }
140 f32::from(self.native_res) / f32::from(self.diverted_res)
141 }
142}
143
144/// Characteristics + capabilities returned by `getThumbwheelInfo`.
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub struct ThumbwheelInfo {
147 /// What one revolution measures in each reporting mode.
148 pub resolution: WheelResolution,
149 /// Original (un-inverted) positive rotation direction: `0` = positive toward
150 /// the left/back of the device, `1` = positive toward the right/front.
151 pub default_dir: u8,
152 /// Whether the wheel reports a single-tap gesture — required to bind a click.
153 pub supports_single_tap: bool,
154}
155
156/// A decoded `thumbwheelEvent`.
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub struct ThumbwheelEvent {
159 /// Relative wheel rotation since the last report (signed, in `diverted_res`
160 /// increments). `+` follows `default_dir` unless inverted at divert time.
161 pub rotation: i16,
162 /// Where this report sits in the life cycle of a roll.
163 pub rotation_status: RotationStatus,
164 /// A single-tap gesture fired with this report.
165 pub single_tap: bool,
166 /// The user is touching the wheel.
167 pub touch: bool,
168 /// The user is in proximity of the wheel.
169 pub proxy: bool,
170}
171
172/// Decode a channel message into a [`ThumbwheelEvent`] when it is the
173/// unsolicited `0x2150` `thumbwheelEvent` (function `0`) for
174/// `(device_index, feature_index)`.
175///
176/// Returns `None` for request responses (`software_id != 0`) and messages from
177/// a different device or feature.
178#[must_use]
179pub fn decode_event(
180 msg: &v20::Message,
181 device_index: u8,
182 feature_index: u8,
183) -> Option<ThumbwheelEvent> {
184 let header = msg.header();
185 if header.device_index != device_index
186 || header.feature_index != feature_index
187 || header.software_id.to_lo() != 0
188 || header.function_id.to_lo() != 0
189 {
190 return None;
191 }
192 let p = msg.extend_payload();
193 Some(ThumbwheelEvent {
194 rotation: i16::from_be_bytes([p[0], p[1]]),
195 rotation_status: RotationStatus::from_byte(p[4]),
196 single_tap: p[5] & EV_SINGLE_TAP != 0,
197 touch: p[5] & EV_TOUCH != 0,
198 proxy: p[5] & EV_PROXY != 0,
199 })
200}
201
202/// `Thumbwheel` accessor bound to one device + resolved feature index.
203///
204/// Construct with the feature index from the device's root feature
205/// (`get_feature(`[`FEATURE_ID`]`)`). Cheap to clone (an `Arc` plus two indices).
206#[derive(Clone)]
207pub struct Thumbwheel {
208 chan: Arc<HidppChannel>,
209 device_index: u8,
210 feature_index: u8,
211}
212
213impl Thumbwheel {
214 /// Bind the feature to `(device_index, feature_index)` on `chan`.
215 #[must_use]
216 pub fn new(chan: Arc<HidppChannel>, device_index: u8, feature_index: u8) -> Self {
217 Self {
218 chan,
219 device_index,
220 feature_index,
221 }
222 }
223
224 /// The feature index this accessor talks to — used to match unsolicited
225 /// events in [`decode_event`].
226 #[must_use]
227 pub fn feature_index(&self) -> u8 {
228 self.feature_index
229 }
230
231 /// Send a feature function call carrying a full long-message payload.
232 async fn call(&self, function_id: u8, params: [u8; 16]) -> Result<[u8; 16], Hidpp20Error> {
233 let response = self
234 .chan
235 .send_v20(v20::Message::Long(
236 v20::MessageHeader {
237 device_index: self.device_index,
238 feature_index: self.feature_index,
239 function_id: U4::from_lo(function_id),
240 software_id: self.chan.get_sw_id(),
241 },
242 params,
243 ))
244 .await?;
245 Ok(response.extend_payload())
246 }
247
248 /// Read the wheel's resolution and capabilities.
249 pub async fn get_info(&self) -> Result<ThumbwheelInfo, Hidpp20Error> {
250 let p = self.call(FN_GET_INFO, [0; 16]).await?;
251 Ok(ThumbwheelInfo {
252 resolution: WheelResolution {
253 native_res: u16::from_be_bytes([p[0], p[1]]),
254 diverted_res: u16::from_be_bytes([p[2], p[3]]),
255 },
256 default_dir: p[4] & 0x01,
257 supports_single_tap: p[5] & CAP_SINGLE_TAP != 0,
258 })
259 }
260
261 /// Enter (or leave) diverted reporting. `inv_dir` inverts the rotation sign
262 /// relative to `default_dir`. Set `diverted = false` on teardown to hand
263 /// native scrolling back to the firmware.
264 pub async fn set_reporting(&self, diverted: bool, inv_dir: bool) -> Result<(), Hidpp20Error> {
265 let mut params = [0u8; 16];
266 params[0] = if diverted { MODE_DIVERTED } else { MODE_NATIVE };
267 params[1] = u8::from(inv_dir);
268 self.call(FN_SET_REPORTING, params).await?;
269 Ok(())
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 fn event(function_id: u8, software_id: u8, payload: [u8; 16]) -> v20::Message {
278 v20::Message::Long(
279 v20::MessageHeader {
280 device_index: 2,
281 feature_index: 6,
282 function_id: U4::from_lo(function_id),
283 software_id: U4::from_lo(software_id),
284 },
285 payload,
286 )
287 }
288
289 #[test]
290 fn decodes_rotation_and_tap() {
291 let mut p = [0u8; 16];
292 p[0..2].copy_from_slice(&(-7i16).to_be_bytes());
293 p[4] = 2;
294 p[5] = EV_SINGLE_TAP | EV_TOUCH;
295 assert_eq!(
296 decode_event(&event(0, 0, p), 2, 6),
297 Some(ThumbwheelEvent {
298 rotation: -7,
299 rotation_status: RotationStatus::Active,
300 single_tap: true,
301 touch: true,
302 proxy: false,
303 })
304 );
305 }
306
307 #[test]
308 fn decodes_every_rotation_status() {
309 let status = |byte| {
310 let mut p = [0u8; 16];
311 p[4] = byte;
312 decode_event(&event(0, 0, p), 2, 6)
313 .expect("event")
314 .rotation_status
315 };
316 assert_eq!(status(0), RotationStatus::Inactive);
317 assert_eq!(status(1), RotationStatus::Start);
318 assert_eq!(status(2), RotationStatus::Active);
319 assert_eq!(status(3), RotationStatus::Stop);
320 assert_eq!(
321 status(0xff),
322 RotationStatus::Inactive,
323 "an unrecognised value must not make the tap permanently undeliverable"
324 );
325 }
326
327 /// The roll's own `Stop` reports no rotation — it is the release — so
328 /// rotation alone cannot tell a settled wheel from one that just stopped.
329 #[test]
330 fn a_stop_report_is_still_part_of_the_roll() {
331 assert!(RotationStatus::Stop.is_rolling());
332 assert!(RotationStatus::Start.is_rolling());
333 assert!(RotationStatus::Active.is_rolling());
334 assert!(!RotationStatus::Inactive.is_rolling());
335 }
336
337 #[test]
338 fn ignores_responses_and_foreign_messages() {
339 let p = [0u8; 16];
340 // software_id != 0 marks a request response, not an event.
341 assert_eq!(decode_event(&event(0, 5, p), 2, 6), None);
342 // Wrong feature index.
343 assert_eq!(decode_event(&event(0, 0, p), 2, 9), None);
344 }
345}