Skip to main content

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}