Skip to main content

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