Skip to main content

hidpp/feature/gestures2/
mod.rs

1//! Legacy HID++ `Gestures2` (feature `0x6501`) discovery used by older MX mice.
2//!
3//! MX Master 2S exposes its horizontal thumb wheel as gesture id 46 under
4//! `0x6501`, not through the newer dedicated `0x2150 Thumbwheel` feature.
5
6use std::sync::Arc;
7
8use crate::{
9    channel::HidppChannel,
10    feature::{CreatableFeature, Feature, FeatureEndpoint},
11    protocol::v20::Hidpp20Error,
12};
13
14/// Gestures2 gesture id for the horizontal thumb wheel.
15pub const THUMBWHEEL_GESTURE_ID: u8 = 46;
16
17/// Maximum descriptor fields accepted before treating a malformed table as an
18/// unsupported response. Real device tables are tiny; the bound prevents a
19/// broken device from causing an unbounded probe loop.
20const MAX_DESCRIPTOR_FIELDS: u16 = 1024;
21
22/// Descriptor metadata needed to divert the legacy thumb wheel.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub struct ThumbwheelGesture {
25    /// Sequential index among gestures that advertise the divertable bit.
26    /// `None` means gesture 46 exists but cannot be diverted.
27    pub diversion_index: Option<u16>,
28}
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31enum DescriptorScan {
32    Thumbwheel(ThumbwheelGesture),
33    End,
34    Continue { next_diversion_index: u16 },
35}
36
37fn scan_descriptor_page(payload: &[u8], mut diversion_index: u16) -> DescriptorScan {
38    for field in payload.chunks_exact(2).take(8) {
39        let high = field[0];
40        let low = field[1];
41        if high == 0x01 {
42            return DescriptorScan::End;
43        }
44        if high & 0x80 == 0 {
45            continue;
46        }
47
48        let divertable = high & 0x02 != 0;
49        if low == THUMBWHEEL_GESTURE_ID {
50            return DescriptorScan::Thumbwheel(ThumbwheelGesture {
51                diversion_index: divertable.then_some(diversion_index),
52            });
53        }
54        if divertable {
55            diversion_index = diversion_index.saturating_add(1);
56        }
57    }
58    DescriptorScan::Continue {
59        next_diversion_index: diversion_index,
60    }
61}
62
63fn diversion_address(index: u16) -> Result<(u8, u8), Hidpp20Error> {
64    let offset = u8::try_from(index >> 3).map_err(|_| Hidpp20Error::UnsupportedResponse)?;
65    let mask = 1u8 << u32::from(index & 7);
66    Ok((offset, mask))
67}
68
69fn diversion_write_payload(index: u16, diverted: bool) -> Result<[u8; 16], Hidpp20Error> {
70    let (offset, mask) = diversion_address(index)?;
71    let mut payload = [0u8; 16];
72    payload[..4].copy_from_slice(&[offset, 0x01, mask, if diverted { mask } else { 0 }]);
73    Ok(payload)
74}
75
76/// Typed accessor for legacy `Gestures2` descriptor discovery.
77#[derive(Clone)]
78pub struct Gestures2Feature {
79    endpoint: FeatureEndpoint,
80}
81
82impl CreatableFeature for Gestures2Feature {
83    const ID: u16 = 0x6501;
84    const STARTING_VERSION: u8 = 0;
85
86    fn new(chan: Arc<HidppChannel>, device_index: u8, feature_index: u8) -> Self {
87        Self {
88            endpoint: FeatureEndpoint::new(chan, device_index, feature_index),
89        }
90    }
91}
92
93impl Feature for Gestures2Feature {}
94
95impl Gestures2Feature {
96    /// Find gesture id 46 (Thumbwheel) and its diversion index. Merely exposing
97    /// `0x6501` is not enough: touchpads and other gesture devices can expose
98    /// the feature without a thumb wheel.
99    pub async fn thumbwheel(&self) -> Result<Option<ThumbwheelGesture>, Hidpp20Error> {
100        let mut index = 0u16;
101        let mut diversion_index = 0u16;
102        while index < MAX_DESCRIPTOR_FIELDS {
103            let [hi, lo] = index.to_be_bytes();
104            let payload = self.endpoint.call(0, [hi, lo, 0]).await?.extend_payload();
105            match scan_descriptor_page(&payload, diversion_index) {
106                DescriptorScan::Thumbwheel(thumbwheel) => return Ok(Some(thumbwheel)),
107                DescriptorScan::End => return Ok(None),
108                DescriptorScan::Continue {
109                    next_diversion_index,
110                } => {
111                    diversion_index = next_diversion_index;
112                    index = index.saturating_add(8);
113                }
114            }
115        }
116        Err(Hidpp20Error::UnsupportedResponse)
117    }
118
119    /// Return whether this device's descriptor table contains gesture id 46.
120    pub async fn has_thumbwheel(&self) -> Result<bool, Hidpp20Error> {
121        Ok(self.thumbwheel().await?.is_some())
122    }
123
124    /// Read the current diversion state for gesture id 46. `None` means the
125    /// thumb wheel is absent or present but not divertable.
126    pub async fn thumbwheel_diverted(&self) -> Result<Option<bool>, Hidpp20Error> {
127        let Some(index) = self.thumbwheel().await?.and_then(|g| g.diversion_index) else {
128            return Ok(None);
129        };
130        let (offset, mask) = diversion_address(index)?;
131        let payload = self
132            .endpoint
133            .call(3, [offset, 0x01, mask])
134            .await?
135            .extend_payload();
136        Ok(Some(payload[0] & mask != 0))
137    }
138
139    /// Divert or restore gesture id 46. Returns `false` when the thumb wheel is
140    /// absent or not divertable. Function 4 is the `0x40` Gestures2 diversion
141    /// write; its four-byte body requires a long HID++ report.
142    pub async fn set_thumbwheel_diverted(&self, diverted: bool) -> Result<bool, Hidpp20Error> {
143        let Some(index) = self.thumbwheel().await?.and_then(|g| g.diversion_index) else {
144            return Ok(false);
145        };
146        self.endpoint
147            .call_long(4, diversion_write_payload(index, diverted)?)
148            .await?;
149        Ok(true)
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn descriptor_page_detects_thumbwheel_and_end_marker() {
159        let mut payload = [0u8; 16];
160        payload[0] = 0x83; // gesture + enabled + divertable
161        payload[1] = THUMBWHEEL_GESTURE_ID;
162        assert_eq!(
163            scan_descriptor_page(&payload, 0),
164            DescriptorScan::Thumbwheel(ThumbwheelGesture {
165                diversion_index: Some(0)
166            })
167        );
168
169        let mut end = [0u8; 16];
170        end[0] = 0x01;
171        assert_eq!(scan_descriptor_page(&end, 0), DescriptorScan::End);
172    }
173
174    #[test]
175    fn descriptor_page_ignores_other_gestures() {
176        let mut payload = [0u8; 16];
177        payload[0] = 0x83;
178        payload[1] = 45; // natural scrolling
179        assert_eq!(
180            scan_descriptor_page(&payload, 0),
181            DescriptorScan::Continue {
182                next_diversion_index: 1
183            }
184        );
185    }
186
187    #[test]
188    fn descriptor_page_counts_divertable_gestures_before_thumbwheel() {
189        let mut payload = [0u8; 16];
190        payload[0] = 0x82; // divertable gesture
191        payload[1] = 40;
192        payload[2] = 0x80; // gesture, not divertable
193        payload[3] = 41;
194        payload[4] = 0x82; // divertable gesture
195        payload[5] = THUMBWHEEL_GESTURE_ID;
196
197        assert_eq!(
198            scan_descriptor_page(&payload, 3),
199            DescriptorScan::Thumbwheel(ThumbwheelGesture {
200                diversion_index: Some(4)
201            })
202        );
203    }
204
205    #[test]
206    fn diversion_write_payload_uses_offset_mask_and_value() {
207        let enabled = diversion_write_payload(9, true).unwrap();
208        assert_eq!(&enabled[..4], &[1, 1, 2, 2]);
209
210        let disabled = diversion_write_payload(9, false).unwrap();
211        assert_eq!(&disabled[..4], &[1, 1, 2, 0]);
212    }
213}