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 functions
13//! OpenLogi needs: `getThumbwheelInfo` (capabilities — notably whether the wheel
14//! reports a single tap), `getThumbwheelStatus` (the reporting state to hand
15//! back), `setThumbwheelReporting` (enter/leave diverted mode), and decode the
16//! unsolicited `thumbwheelEvent`. Wire format from `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/// `getThumbwheelStatus` function ID.
33const FN_GET_STATUS: u8 = 1;
34/// `setThumbwheelReporting` function ID.
35const FN_SET_REPORTING: u8 = 2;
36
37/// Reporting-mode value: native HID scroll.
38const MODE_NATIVE: u8 = 0;
39/// Reporting-mode value: diverted to HID++ events.
40const MODE_DIVERTED: u8 = 1;
41
42/// `c_single_tap` capability bit in `getThumbwheelInfo` byte 5.
43const CAP_SINGLE_TAP: u8 = 0x08;
44/// `invert` bit in `getThumbwheelStatus` byte 1.
45const STATUS_INVERTED: u8 = 0x01;
46/// `single_tap` bit in `thumbwheelEvent` byte 5.
47const EV_SINGLE_TAP: u8 = 0x08;
48/// `proxy` bit in `thumbwheelEvent` byte 5.
49const EV_PROXY: u8 = 0x04;
50/// `touch` bit in `thumbwheelEvent` byte 5.
51const EV_TOUCH: u8 = 0x02;
52
53/// Where a `thumbwheelEvent` sits in the life cycle of one roll
54/// (`thumbwheelEvent` byte 4).
55///
56/// This is what separates a deliberate tap from the tap the wheel's touch
57/// sensor flags for the finger that rolled it: every report from `Start`
58/// through `Stop` belongs to a roll, so a tap bit inside that span is an
59/// artifact of the same contact. Only [`RotationStatus::Inactive`] means the
60/// wheel is at rest and a tap is the user's own.
61///
62/// [`RotationStatus::Stop`] is why this field is read rather than inferred
63/// from the report's own rotation. Observed on an MX Master 4 (Bolt) with
64/// `examples/thumbwheel_trace`, one nudge of the wheel:
65///
66/// ```text
67/// rot= -1 byte4=0x02 byte5=0x02 touch=true → Active
68/// rot= 0 byte4=0x03 byte5=0x00 touch=false → Stop
69/// ```
70///
71/// The release reports no rotation at all, so rotation alone cannot tell it
72/// from a tap on a settled wheel — and on a wheel that does support
73/// `single_tap` that release is exactly where the artifact lands.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum RotationStatus {
76 /// No rotation — the wheel is at rest.
77 Inactive,
78 /// The first rotation report of a roll.
79 Start,
80 /// A subsequent rotation report.
81 Active,
82 /// The roll ended: released, no touch.
83 Stop,
84}
85
86impl RotationStatus {
87 /// Decode `thumbwheelEvent` byte 4. Values outside the four the spec
88 /// defines decode as [`RotationStatus::Inactive`] — a firmware that
89 /// reports something else has said nothing about a roll, and the caller
90 /// still has the report's own `rotation` to go on. Claiming a roll here
91 /// instead would let one unrecognised value make the tap permanently
92 /// undeliverable. Logi Options+ maps an unrecognised value to the same
93 /// state as `Inactive` too (`devio_feature_2150::convert`).
94 #[must_use]
95 fn from_byte(byte: u8) -> Self {
96 match byte {
97 1 => Self::Start,
98 2 => Self::Active,
99 3 => Self::Stop,
100 _ => Self::Inactive,
101 }
102 }
103
104 /// Whether this report belongs to a roll — including its `Stop`, which
105 /// carries no rotation of its own but is still the roll's own contact.
106 #[must_use]
107 pub fn is_rolling(self) -> bool {
108 !matches!(self, Self::Inactive)
109 }
110
111 /// Whether the roll this report belongs to is still under way after it:
112 /// the roll began or continued here, and its `Stop` is still to come.
113 #[must_use]
114 pub fn leaves_roll_open(self) -> bool {
115 matches!(self, Self::Start | Self::Active)
116 }
117}
118
119/// What one revolution of the wheel measures in each reporting mode.
120///
121/// The two are not the same unit: an MX Master 4 reports 20 ratchets per
122/// revolution natively and 120 increments per revolution diverted. Anything
123/// re-synthesising scroll from diverted increments has to scale by the ratio,
124/// or the same physical motion scrolls six times as far as it did before the
125/// wheel was diverted.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
127pub struct WheelResolution {
128 /// Ratchets per revolution in native (HID) mode.
129 pub native_res: u16,
130 /// Rotation increments per revolution in diverted (HID++) mode.
131 pub diverted_res: u16,
132}
133
134impl WheelResolution {
135 /// Resolutions a wheel did not report, scaling increments through
136 /// unchanged.
137 pub const UNKNOWN: Self = Self {
138 native_res: 0,
139 diverted_res: 0,
140 };
141
142 /// Native scroll units one diverted increment is worth.
143 ///
144 /// `1.0` when either resolution is missing — a wheel that did not answer
145 /// `getThumbwheelInfo` keeps the raw increment-per-unit behavior rather
146 /// than having its scroll silently scaled by a guess.
147 #[must_use]
148 pub fn native_per_increment(self) -> f64 {
149 if self.native_res == 0 || self.diverted_res == 0 {
150 return 1.0;
151 }
152 f64::from(self.native_res) / f64::from(self.diverted_res)
153 }
154}
155
156/// Characteristics + capabilities returned by `getThumbwheelInfo`.
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub struct ThumbwheelInfo {
159 /// What one revolution measures in each reporting mode.
160 pub resolution: WheelResolution,
161 /// Original (un-inverted) positive rotation direction: `0` = positive toward
162 /// the left/back of the device, `1` = positive toward the right/front.
163 pub default_dir: u8,
164 /// Whether the wheel reports a single-tap gesture — required to bind a click.
165 pub supports_single_tap: bool,
166}
167
168impl ThumbwheelInfo {
169 /// Whether an un-inverted positive rotation is toward the front of the
170 /// device — the physical direction represented by
171 /// [`ButtonId::ThumbwheelScrollUp`](openlogi_core::binding::ButtonId::ThumbwheelScrollUp).
172 ///
173 /// HID++ `0x2150` defines `default_dir = 0` as positive toward left/back
174 /// and `1` as positive toward right/front. The value varies by model, so
175 /// captured input must consult it instead of assigning meaning to the raw
176 /// sign globally.
177 #[must_use]
178 pub fn positive_is_forward(self) -> bool {
179 self.default_dir == 1
180 }
181}
182
183/// Where the wheel's rotation reports go (`getThumbwheelStatus` byte 0).
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub enum ReportingMode {
186 /// Native HID horizontal scroll.
187 Native,
188 /// HID++ `thumbwheelEvent` reports.
189 Diverted,
190}
191
192/// The reporting state returned by `getThumbwheelStatus`.
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub struct ThumbwheelStatus {
195 /// Current reporting mode.
196 pub mode: ReportingMode,
197 /// Rotation sign applied in that mode.
198 pub direction: WheelDirection,
199}
200
201impl ThumbwheelStatus {
202 fn from_payload(p: &[u8; 16]) -> Result<Self, Hidpp20Error> {
203 let mode = match p[0] {
204 MODE_NATIVE => ReportingMode::Native,
205 MODE_DIVERTED => ReportingMode::Diverted,
206 _ => return Err(Hidpp20Error::UnsupportedResponse),
207 };
208 Ok(Self {
209 mode,
210 direction: WheelDirection::from_inverted(p[1] & STATUS_INVERTED != 0),
211 })
212 }
213}
214
215/// The native rotation sign to hand back when undiverting a wheel whose
216/// status before diversion was `original`.
217///
218/// A diverter owns only the reporting mode, so it keeps the native inversion
219/// found before diverting. A wheel already diverted then is left over from an
220/// owner that never tore down: its sign is that owner's diverted
221/// normalisation, not the user's native preference. With no readable status
222/// there is no evidence of native inversion either; both get the default sign.
223#[must_use]
224pub fn native_direction_to_restore(original: Option<ThumbwheelStatus>) -> WheelDirection {
225 match original {
226 Some(ThumbwheelStatus {
227 mode: ReportingMode::Native,
228 direction,
229 }) => direction,
230 Some(ThumbwheelStatus {
231 mode: ReportingMode::Diverted,
232 ..
233 })
234 | None => WheelDirection::Default,
235 }
236}
237
238/// A decoded `thumbwheelEvent`.
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240pub struct ThumbwheelEvent {
241 /// Relative wheel rotation since the last report (signed, in `diverted_res`
242 /// increments). `+` follows `default_dir` unless inverted at divert time.
243 pub rotation: i16,
244 /// Where this report sits in the life cycle of a roll.
245 pub rotation_status: RotationStatus,
246 /// A single-tap gesture fired with this report.
247 pub single_tap: bool,
248 /// The user is touching the wheel.
249 pub touch: bool,
250 /// The user is in proximity of the wheel.
251 pub proxy: bool,
252}
253
254/// Decode a channel message into a [`ThumbwheelEvent`] when it is the
255/// unsolicited `0x2150` `thumbwheelEvent` (function `0`) for
256/// `(device_index, feature_index)`.
257///
258/// Returns `None` for request responses (`software_id != 0`) and messages from
259/// a different device or feature.
260#[must_use]
261pub fn decode_event(
262 msg: &v20::Message,
263 device_index: u8,
264 feature_index: u8,
265) -> Option<ThumbwheelEvent> {
266 let header = msg.header();
267 if header.device_index != device_index
268 || header.feature_index != feature_index
269 || header.software_id.to_lo() != 0
270 || header.function_id.to_lo() != 0
271 {
272 return None;
273 }
274 let p = msg.extend_payload();
275 Some(ThumbwheelEvent {
276 rotation: i16::from_be_bytes([p[0], p[1]]),
277 rotation_status: RotationStatus::from_byte(p[4]),
278 single_tap: p[5] & EV_SINGLE_TAP != 0,
279 touch: p[5] & EV_TOUCH != 0,
280 proxy: p[5] & EV_PROXY != 0,
281 })
282}
283
284/// `Thumbwheel` accessor bound to one device + resolved feature index.
285///
286/// Construct with the feature index from the device's root feature
287/// (`get_feature(`[`FEATURE_ID`]`)`). Cheap to clone (an `Arc` plus two indices).
288#[derive(Clone)]
289pub struct Thumbwheel {
290 chan: Arc<HidppChannel>,
291 device_index: u8,
292 feature_index: u8,
293}
294
295impl Thumbwheel {
296 /// Bind the feature to `(device_index, feature_index)` on `chan`.
297 #[must_use]
298 pub fn new(chan: Arc<HidppChannel>, device_index: u8, feature_index: u8) -> Self {
299 Self {
300 chan,
301 device_index,
302 feature_index,
303 }
304 }
305
306 /// The feature index this accessor talks to — used to match unsolicited
307 /// events in [`decode_event`].
308 #[must_use]
309 pub fn feature_index(&self) -> u8 {
310 self.feature_index
311 }
312
313 /// Send a feature function call carrying a full long-message payload.
314 async fn call(&self, function_id: u8, params: [u8; 16]) -> Result<[u8; 16], Hidpp20Error> {
315 let response = self
316 .chan
317 .send_v20(v20::Message::Long(
318 v20::MessageHeader {
319 device_index: self.device_index,
320 feature_index: self.feature_index,
321 function_id: U4::from_lo(function_id),
322 software_id: self.chan.get_sw_id(),
323 },
324 params,
325 ))
326 .await?;
327 Ok(response.extend_payload())
328 }
329
330 /// Read the wheel's resolution and capabilities.
331 pub async fn get_info(&self) -> Result<ThumbwheelInfo, Hidpp20Error> {
332 let p = self.call(FN_GET_INFO, [0; 16]).await?;
333 Ok(ThumbwheelInfo {
334 resolution: WheelResolution {
335 native_res: u16::from_be_bytes([p[0], p[1]]),
336 diverted_res: u16::from_be_bytes([p[2], p[3]]),
337 },
338 default_dir: p[4] & 0x01,
339 supports_single_tap: p[5] & CAP_SINGLE_TAP != 0,
340 })
341 }
342
343 /// Read the wheel's current reporting mode and rotation sign.
344 pub async fn get_status(&self) -> Result<ThumbwheelStatus, Hidpp20Error> {
345 ThumbwheelStatus::from_payload(&self.call(FN_GET_STATUS, [0; 16]).await?)
346 }
347
348 /// Enter diverted reporting; wheel events then arrive on this feature
349 /// index instead of moving the native scroll.
350 pub async fn divert(&self, direction: WheelDirection) -> Result<(), Hidpp20Error> {
351 self.set_reporting(MODE_DIVERTED, direction).await
352 }
353
354 /// Hand native scrolling back to the firmware with `direction` as the
355 /// native rotation sign. `setThumbwheelReporting` always writes both, so
356 /// the caller supplies the native inversion it means to keep.
357 pub async fn undivert(&self, direction: WheelDirection) -> Result<(), Hidpp20Error> {
358 self.set_reporting(MODE_NATIVE, direction).await
359 }
360
361 async fn set_reporting(&self, mode: u8, direction: WheelDirection) -> Result<(), Hidpp20Error> {
362 let mut params = [0u8; 16];
363 params[0] = mode;
364 params[1] = u8::from(direction == WheelDirection::Inverted);
365 self.call(FN_SET_REPORTING, params).await?;
366 Ok(())
367 }
368}
369
370/// Rotation sign for diverted wheel reports, relative to the wheel's
371/// `default_dir`.
372#[derive(Clone, Copy, Debug, PartialEq, Eq)]
373pub enum WheelDirection {
374 /// Report rotation with the wheel's default sign.
375 Default,
376 /// Invert the rotation sign.
377 Inverted,
378}
379
380impl WheelDirection {
381 fn from_inverted(inverted: bool) -> Self {
382 if inverted {
383 Self::Inverted
384 } else {
385 Self::Default
386 }
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393
394 fn event(function_id: u8, software_id: u8, payload: [u8; 16]) -> v20::Message {
395 v20::Message::Long(
396 v20::MessageHeader {
397 device_index: 2,
398 feature_index: 6,
399 function_id: U4::from_lo(function_id),
400 software_id: U4::from_lo(software_id),
401 },
402 payload,
403 )
404 }
405
406 #[test]
407 fn decodes_rotation_and_tap() {
408 let mut p = [0u8; 16];
409 p[0..2].copy_from_slice(&(-7i16).to_be_bytes());
410 p[4] = 2;
411 p[5] = EV_SINGLE_TAP | EV_TOUCH;
412 assert_eq!(
413 decode_event(&event(0, 0, p), 2, 6),
414 Some(ThumbwheelEvent {
415 rotation: -7,
416 rotation_status: RotationStatus::Active,
417 single_tap: true,
418 touch: true,
419 proxy: false,
420 })
421 );
422 }
423
424 #[test]
425 fn decodes_every_rotation_status() {
426 let status = |byte| {
427 let mut p = [0u8; 16];
428 p[4] = byte;
429 decode_event(&event(0, 0, p), 2, 6)
430 .expect("event")
431 .rotation_status
432 };
433 assert_eq!(status(0), RotationStatus::Inactive);
434 assert_eq!(status(1), RotationStatus::Start);
435 assert_eq!(status(2), RotationStatus::Active);
436 assert_eq!(status(3), RotationStatus::Stop);
437 assert_eq!(
438 status(0xff),
439 RotationStatus::Inactive,
440 "an unrecognised value must not make the tap permanently undeliverable"
441 );
442 }
443
444 #[test]
445 fn positive_direction_follows_the_device_report() {
446 let info = |default_dir| ThumbwheelInfo {
447 resolution: WheelResolution::UNKNOWN,
448 default_dir,
449 supports_single_tap: false,
450 };
451 assert!(!info(0).positive_is_forward());
452 assert!(info(1).positive_is_forward());
453 }
454
455 /// The roll's own `Stop` reports no rotation — it is the release — so
456 /// rotation alone cannot tell a settled wheel from one that just stopped.
457 #[test]
458 fn a_stop_report_is_still_part_of_the_roll() {
459 assert!(RotationStatus::Stop.is_rolling());
460 assert!(RotationStatus::Start.is_rolling());
461 assert!(RotationStatus::Active.is_rolling());
462 assert!(!RotationStatus::Inactive.is_rolling());
463 }
464
465 #[test]
466 fn ignores_responses_and_foreign_messages() {
467 let p = [0u8; 16];
468 // software_id != 0 marks a request response, not an event.
469 assert_eq!(decode_event(&event(0, 5, p), 2, 6), None);
470 // Wrong feature index.
471 assert_eq!(decode_event(&event(0, 0, p), 2, 9), None);
472 }
473
474 #[test]
475 fn decodes_reporting_status_and_rejects_unknown_modes() {
476 let status = |mode, flags| {
477 let mut p = [0u8; 16];
478 p[0] = mode;
479 p[1] = flags;
480 ThumbwheelStatus::from_payload(&p)
481 };
482 assert_eq!(
483 status(0, 0b001).unwrap(),
484 ThumbwheelStatus {
485 mode: ReportingMode::Native,
486 direction: WheelDirection::Inverted,
487 }
488 );
489 // touch (bit 1) and proxy (bit 2) do not read as inversion.
490 assert_eq!(
491 status(1, 0b110).unwrap(),
492 ThumbwheelStatus {
493 mode: ReportingMode::Diverted,
494 direction: WheelDirection::Default,
495 }
496 );
497 assert!(matches!(
498 status(2, 0),
499 Err(Hidpp20Error::UnsupportedResponse)
500 ));
501 }
502}