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 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) -> f64 {
137        if self.native_res == 0 || self.diverted_res == 0 {
138            return 1.0;
139        }
140        f64::from(self.native_res) / f64::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
156impl ThumbwheelInfo {
157    /// Whether an un-inverted positive rotation is toward the front of the
158    /// device — the physical direction represented by
159    /// [`ButtonId::ThumbwheelScrollUp`](openlogi_core::binding::ButtonId::ThumbwheelScrollUp).
160    ///
161    /// HID++ `0x2150` defines `default_dir = 0` as positive toward left/back
162    /// and `1` as positive toward right/front. The value varies by model, so
163    /// captured input must consult it instead of assigning meaning to the raw
164    /// sign globally.
165    #[must_use]
166    pub fn positive_is_forward(self) -> bool {
167        self.default_dir == 1
168    }
169}
170
171/// A decoded `thumbwheelEvent`.
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173pub struct ThumbwheelEvent {
174    /// Relative wheel rotation since the last report (signed, in `diverted_res`
175    /// increments). `+` follows `default_dir` unless inverted at divert time.
176    pub rotation: i16,
177    /// Where this report sits in the life cycle of a roll.
178    pub rotation_status: RotationStatus,
179    /// A single-tap gesture fired with this report.
180    pub single_tap: bool,
181    /// The user is touching the wheel.
182    pub touch: bool,
183    /// The user is in proximity of the wheel.
184    pub proxy: bool,
185}
186
187/// Decode a channel message into a [`ThumbwheelEvent`] when it is the
188/// unsolicited `0x2150` `thumbwheelEvent` (function `0`) for
189/// `(device_index, feature_index)`.
190///
191/// Returns `None` for request responses (`software_id != 0`) and messages from
192/// a different device or feature.
193#[must_use]
194pub fn decode_event(
195    msg: &v20::Message,
196    device_index: u8,
197    feature_index: u8,
198) -> Option<ThumbwheelEvent> {
199    let header = msg.header();
200    if header.device_index != device_index
201        || header.feature_index != feature_index
202        || header.software_id.to_lo() != 0
203        || header.function_id.to_lo() != 0
204    {
205        return None;
206    }
207    let p = msg.extend_payload();
208    Some(ThumbwheelEvent {
209        rotation: i16::from_be_bytes([p[0], p[1]]),
210        rotation_status: RotationStatus::from_byte(p[4]),
211        single_tap: p[5] & EV_SINGLE_TAP != 0,
212        touch: p[5] & EV_TOUCH != 0,
213        proxy: p[5] & EV_PROXY != 0,
214    })
215}
216
217/// `Thumbwheel` accessor bound to one device + resolved feature index.
218///
219/// Construct with the feature index from the device's root feature
220/// (`get_feature(`[`FEATURE_ID`]`)`). Cheap to clone (an `Arc` plus two indices).
221#[derive(Clone)]
222pub struct Thumbwheel {
223    chan: Arc<HidppChannel>,
224    device_index: u8,
225    feature_index: u8,
226}
227
228impl Thumbwheel {
229    /// Bind the feature to `(device_index, feature_index)` on `chan`.
230    #[must_use]
231    pub fn new(chan: Arc<HidppChannel>, device_index: u8, feature_index: u8) -> Self {
232        Self {
233            chan,
234            device_index,
235            feature_index,
236        }
237    }
238
239    /// The feature index this accessor talks to — used to match unsolicited
240    /// events in [`decode_event`].
241    #[must_use]
242    pub fn feature_index(&self) -> u8 {
243        self.feature_index
244    }
245
246    /// Send a feature function call carrying a full long-message payload.
247    async fn call(&self, function_id: u8, params: [u8; 16]) -> Result<[u8; 16], Hidpp20Error> {
248        let response = self
249            .chan
250            .send_v20(v20::Message::Long(
251                v20::MessageHeader {
252                    device_index: self.device_index,
253                    feature_index: self.feature_index,
254                    function_id: U4::from_lo(function_id),
255                    software_id: self.chan.get_sw_id(),
256                },
257                params,
258            ))
259            .await?;
260        Ok(response.extend_payload())
261    }
262
263    /// Read the wheel's resolution and capabilities.
264    pub async fn get_info(&self) -> Result<ThumbwheelInfo, Hidpp20Error> {
265        let p = self.call(FN_GET_INFO, [0; 16]).await?;
266        Ok(ThumbwheelInfo {
267            resolution: WheelResolution {
268                native_res: u16::from_be_bytes([p[0], p[1]]),
269                diverted_res: u16::from_be_bytes([p[2], p[3]]),
270            },
271            default_dir: p[4] & 0x01,
272            supports_single_tap: p[5] & CAP_SINGLE_TAP != 0,
273        })
274    }
275
276    /// Enter diverted reporting; wheel events then arrive on this feature
277    /// index instead of moving the native scroll.
278    pub async fn divert(&self, direction: WheelDirection) -> Result<(), Hidpp20Error> {
279        let mut params = [0u8; 16];
280        params[0] = MODE_DIVERTED;
281        params[1] = u8::from(direction == WheelDirection::Inverted);
282        self.call(FN_SET_REPORTING, params).await?;
283        Ok(())
284    }
285
286    /// Hand native scrolling back to the firmware.
287    pub async fn undivert(&self) -> Result<(), Hidpp20Error> {
288        let mut params = [0u8; 16];
289        params[0] = MODE_NATIVE;
290        self.call(FN_SET_REPORTING, params).await?;
291        Ok(())
292    }
293}
294
295/// Rotation sign for diverted wheel reports, relative to the wheel's
296/// `default_dir`.
297#[derive(Clone, Copy, Debug, PartialEq, Eq)]
298pub enum WheelDirection {
299    /// Report rotation with the wheel's default sign.
300    Default,
301    /// Invert the rotation sign.
302    Inverted,
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    fn event(function_id: u8, software_id: u8, payload: [u8; 16]) -> v20::Message {
310        v20::Message::Long(
311            v20::MessageHeader {
312                device_index: 2,
313                feature_index: 6,
314                function_id: U4::from_lo(function_id),
315                software_id: U4::from_lo(software_id),
316            },
317            payload,
318        )
319    }
320
321    #[test]
322    fn decodes_rotation_and_tap() {
323        let mut p = [0u8; 16];
324        p[0..2].copy_from_slice(&(-7i16).to_be_bytes());
325        p[4] = 2;
326        p[5] = EV_SINGLE_TAP | EV_TOUCH;
327        assert_eq!(
328            decode_event(&event(0, 0, p), 2, 6),
329            Some(ThumbwheelEvent {
330                rotation: -7,
331                rotation_status: RotationStatus::Active,
332                single_tap: true,
333                touch: true,
334                proxy: false,
335            })
336        );
337    }
338
339    #[test]
340    fn decodes_every_rotation_status() {
341        let status = |byte| {
342            let mut p = [0u8; 16];
343            p[4] = byte;
344            decode_event(&event(0, 0, p), 2, 6)
345                .expect("event")
346                .rotation_status
347        };
348        assert_eq!(status(0), RotationStatus::Inactive);
349        assert_eq!(status(1), RotationStatus::Start);
350        assert_eq!(status(2), RotationStatus::Active);
351        assert_eq!(status(3), RotationStatus::Stop);
352        assert_eq!(
353            status(0xff),
354            RotationStatus::Inactive,
355            "an unrecognised value must not make the tap permanently undeliverable"
356        );
357    }
358
359    #[test]
360    fn positive_direction_follows_the_device_report() {
361        let info = |default_dir| ThumbwheelInfo {
362            resolution: WheelResolution::UNKNOWN,
363            default_dir,
364            supports_single_tap: false,
365        };
366        assert!(!info(0).positive_is_forward());
367        assert!(info(1).positive_is_forward());
368    }
369
370    /// The roll's own `Stop` reports no rotation — it is the release — so
371    /// rotation alone cannot tell a settled wheel from one that just stopped.
372    #[test]
373    fn a_stop_report_is_still_part_of_the_roll() {
374        assert!(RotationStatus::Stop.is_rolling());
375        assert!(RotationStatus::Start.is_rolling());
376        assert!(RotationStatus::Active.is_rolling());
377        assert!(!RotationStatus::Inactive.is_rolling());
378    }
379
380    #[test]
381    fn ignores_responses_and_foreign_messages() {
382        let p = [0u8; 16];
383        // software_id != 0 marks a request response, not an event.
384        assert_eq!(decode_event(&event(0, 5, p), 2, 6), None);
385        // Wrong feature index.
386        assert_eq!(decode_event(&event(0, 0, p), 2, 9), None);
387    }
388}