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}