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) -> 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}