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