logiops_core/features/
thumb_wheel.rs

1//! `Thumb Wheel` feature (0x2150) - Horizontal thumb wheel configuration.
2//!
3//! This feature provides control over the thumb wheel found on mice
4//! like the MX Master series. It supports divert mode, inversion,
5//! touch detection, and event reporting.
6
7use bitflags::bitflags;
8use hidpp_transport::HidapiChannel;
9use tracing::{debug, trace};
10
11use crate::error::{HidppErrorCode, ProtocolError, Result};
12use crate::protocol::{build_long_request, get_error_code, is_error_response};
13
14/// Function IDs for the Thumb Wheel feature.
15mod function_id {
16    /// Get thumb wheel information.
17    pub const GET_INFO: u8 = 0x00;
18    /// Get current status.
19    pub const GET_STATUS: u8 = 0x01;
20    /// Set reporting mode.
21    pub const SET_REPORTING: u8 = 0x02;
22}
23
24bitflags! {
25    /// Thumb wheel capability flags.
26    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
27    pub struct ThumbWheelCapabilities: u8 {
28        /// Supports timestamp in events.
29        const TIMESTAMP = 0x01;
30        /// Supports touch detection.
31        const TOUCH = 0x02;
32        /// Supports proximity detection.
33        const PROXY = 0x04;
34        /// Supports single tap detection.
35        const SINGLE_TAP = 0x08;
36    }
37}
38
39/// Rotation status in thumb wheel events.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41#[repr(u8)]
42pub enum RotationStatus {
43    /// No rotation activity.
44    Inactive = 0,
45    /// Rotation just started.
46    Start = 1,
47    /// Rotation in progress.
48    Active = 2,
49    /// Rotation stopped.
50    Stop = 3,
51}
52
53impl TryFrom<u8> for RotationStatus {
54    type Error = ();
55
56    fn try_from(value: u8) -> std::result::Result<Self, Self::Error> {
57        match value {
58            0 => Ok(Self::Inactive),
59            1 => Ok(Self::Start),
60            2 => Ok(Self::Active),
61            3 => Ok(Self::Stop),
62            _ => Err(()),
63        }
64    }
65}
66
67/// Thumb wheel hardware information.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct ThumbWheelInfo {
70    /// Native resolution (counts per rotation).
71    pub native_resolution: u16,
72    /// Diverted resolution (when diverted to software).
73    pub diverted_resolution: u16,
74    /// Default scroll direction (true = natural/inverted).
75    pub default_direction: bool,
76    /// Hardware capabilities.
77    pub capabilities: ThumbWheelCapabilities,
78    /// Time window for events (in ms).
79    pub time_elapsed: u16,
80}
81
82/// Current thumb wheel status.
83#[derive(Debug, Clone, PartialEq, Eq)]
84#[allow(clippy::struct_excessive_bools)] // Reflects hardware state flags
85pub struct ThumbWheelStatus {
86    /// Whether events are diverted to software.
87    pub divert: bool,
88    /// Whether scroll direction is inverted.
89    pub invert: bool,
90    /// Whether touch is currently detected.
91    pub touch: bool,
92    /// Whether proximity is currently detected.
93    pub proxy: bool,
94}
95
96/// Thumb wheel configuration for set operations.
97#[derive(Debug, Clone, PartialEq, Eq, Default)]
98pub struct ThumbWheelConfig {
99    /// Divert thumb wheel events to software.
100    pub divert: bool,
101    /// Invert scroll direction.
102    pub invert: bool,
103}
104
105/// Thumb wheel rotation event.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct ThumbWheelEvent {
108    /// Rotation delta (positive = clockwise from user perspective).
109    pub rotation: i16,
110    /// Event timestamp (if supported).
111    pub timestamp: u16,
112    /// Current rotation status.
113    pub status: RotationStatus,
114    /// Touch detected during event.
115    pub touch: bool,
116    /// Proximity detected during event.
117    pub proxy: bool,
118    /// Single tap detected.
119    pub single_tap: bool,
120}
121
122/// `Thumb Wheel` feature implementation.
123pub struct ThumbWheelFeature {
124    device_index: u8,
125    feature_index: u8,
126}
127
128impl ThumbWheelFeature {
129    /// Creates a new thumb wheel feature accessor.
130    ///
131    /// # Arguments
132    /// * `device_index` - Device index (0xFF for direct)
133    /// * `feature_index` - Feature index from root feature discovery
134    #[must_use]
135    pub fn new(device_index: u8, feature_index: u8) -> Self {
136        Self {
137            device_index,
138            feature_index,
139        }
140    }
141
142    /// Gets thumb wheel hardware information.
143    ///
144    /// Returns resolution, capabilities, and timing information.
145    ///
146    /// # Errors
147    /// Returns an error if HID++ communication fails.
148    pub async fn get_info(&self, channel: &HidapiChannel) -> Result<ThumbWheelInfo> {
149        let request = build_long_request(
150            self.device_index,
151            self.feature_index,
152            function_id::GET_INFO,
153            &[],
154        );
155
156        trace!("getting thumb wheel info");
157        let response = channel.request(&request, 5).await?;
158
159        if is_error_response(&response) {
160            let code = get_error_code(&response).unwrap_or(0);
161            return Err(ProtocolError::HidppError(HidppErrorCode::from_byte(code)));
162        }
163
164        if response.len() < 12 {
165            return Err(ProtocolError::InvalidResponse(
166                "thumb wheel info response too short".to_string(),
167            ));
168        }
169
170        // Response format:
171        // [4-5]: native resolution (big-endian)
172        // [6-7]: diverted resolution (big-endian)
173        // [8]: default direction (0 = normal, 1 = inverted)
174        // [9]: capabilities
175        // [10-11]: time elapsed (big-endian)
176        let native_resolution = u16::from_be_bytes([response[4], response[5]]);
177        let diverted_resolution = u16::from_be_bytes([response[6], response[7]]);
178        let default_direction = response[8] != 0;
179        let capabilities = ThumbWheelCapabilities::from_bits_truncate(response[9]);
180        let time_elapsed = u16::from_be_bytes([response[10], response[11]]);
181
182        let info = ThumbWheelInfo {
183            native_resolution,
184            diverted_resolution,
185            default_direction,
186            capabilities,
187            time_elapsed,
188        };
189
190        debug!(
191            native = native_resolution,
192            diverted = diverted_resolution,
193            caps = ?capabilities,
194            "got thumb wheel info"
195        );
196
197        Ok(info)
198    }
199
200    /// Gets the current thumb wheel status.
201    ///
202    /// # Errors
203    /// Returns an error if HID++ communication fails.
204    pub async fn get_status(&self, channel: &HidapiChannel) -> Result<ThumbWheelStatus> {
205        let request = build_long_request(
206            self.device_index,
207            self.feature_index,
208            function_id::GET_STATUS,
209            &[],
210        );
211
212        trace!("getting thumb wheel status");
213        let response = channel.request(&request, 5).await?;
214
215        if is_error_response(&response) {
216            let code = get_error_code(&response).unwrap_or(0);
217            return Err(ProtocolError::HidppError(HidppErrorCode::from_byte(code)));
218        }
219
220        if response.len() < 5 {
221            return Err(ProtocolError::InvalidResponse(
222                "thumb wheel status response too short".to_string(),
223            ));
224        }
225
226        // Response format: [4] = flags
227        // bit 0: divert
228        // bit 1: invert
229        // bit 2: touch
230        // bit 3: proxy
231        let flags = response[4];
232        let status = ThumbWheelStatus {
233            divert: (flags & 0x01) != 0,
234            invert: (flags & 0x02) != 0,
235            touch: (flags & 0x04) != 0,
236            proxy: (flags & 0x08) != 0,
237        };
238
239        debug!(
240            divert = status.divert,
241            invert = status.invert,
242            touch = status.touch,
243            proxy = status.proxy,
244            "got thumb wheel status"
245        );
246
247        Ok(status)
248    }
249
250    /// Sets the thumb wheel reporting mode.
251    ///
252    /// # Arguments
253    /// * `channel` - HID channel
254    /// * `config` - Configuration to apply
255    ///
256    /// # Errors
257    /// Returns an error if HID++ communication fails.
258    pub async fn set_reporting(
259        &self,
260        channel: &HidapiChannel,
261        config: &ThumbWheelConfig,
262    ) -> Result<()> {
263        let mut flags: u8 = 0;
264        if config.divert {
265            flags |= 0x01;
266        }
267        if config.invert {
268            flags |= 0x02;
269        }
270
271        let request = build_long_request(
272            self.device_index,
273            self.feature_index,
274            function_id::SET_REPORTING,
275            &[flags],
276        );
277
278        trace!(
279            divert = config.divert,
280            invert = config.invert,
281            "setting thumb wheel reporting"
282        );
283        let response = channel.request(&request, 5).await?;
284
285        if is_error_response(&response) {
286            let code = get_error_code(&response).unwrap_or(0);
287            return Err(ProtocolError::HidppError(HidppErrorCode::from_byte(code)));
288        }
289
290        debug!(
291            divert = config.divert,
292            invert = config.invert,
293            "set thumb wheel reporting"
294        );
295
296        Ok(())
297    }
298
299    /// Parses a thumb wheel event from a HID++ report.
300    ///
301    /// This should be called when receiving event reports from the device
302    /// after enabling divert mode.
303    ///
304    /// # Arguments
305    /// * `report` - Raw HID++ report data
306    ///
307    /// # Returns
308    /// `Some(ThumbWheelEvent)` if this is a valid thumb wheel event, `None` otherwise.
309    #[must_use]
310    pub fn parse_event(report: &[u8]) -> Option<ThumbWheelEvent> {
311        // Event format (starting at byte 4):
312        // [4-5]: rotation (signed, big-endian)
313        // [6-7]: timestamp (big-endian)
314        // [8]: rotation status (bits 0-1)
315        // [9]: flags (touch, proxy, single_tap)
316        if report.len() < 10 {
317            return None;
318        }
319
320        let rotation = i16::from_be_bytes([report[4], report[5]]);
321        let timestamp = u16::from_be_bytes([report[6], report[7]]);
322        let status = RotationStatus::try_from(report[8] & 0x03).unwrap_or(RotationStatus::Inactive);
323        let flags = report[9];
324
325        Some(ThumbWheelEvent {
326            rotation,
327            timestamp,
328            status,
329            touch: (flags & 0x01) != 0,
330            proxy: (flags & 0x02) != 0,
331            single_tap: (flags & 0x04) != 0,
332        })
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    #[test]
341    fn test_capability_flags() {
342        let caps = ThumbWheelCapabilities::TOUCH | ThumbWheelCapabilities::SINGLE_TAP;
343        assert!(caps.contains(ThumbWheelCapabilities::TOUCH));
344        assert!(caps.contains(ThumbWheelCapabilities::SINGLE_TAP));
345        assert!(!caps.contains(ThumbWheelCapabilities::TIMESTAMP));
346    }
347
348    #[test]
349    fn test_rotation_status() {
350        assert_eq!(RotationStatus::try_from(0), Ok(RotationStatus::Inactive));
351        assert_eq!(RotationStatus::try_from(1), Ok(RotationStatus::Start));
352        assert_eq!(RotationStatus::try_from(2), Ok(RotationStatus::Active));
353        assert_eq!(RotationStatus::try_from(3), Ok(RotationStatus::Stop));
354        assert!(RotationStatus::try_from(4).is_err());
355    }
356
357    #[test]
358    fn test_parse_event() {
359        // Simulate an event report: rotation=100, timestamp=500, status=Active, touch=true
360        let mut report = vec![0u8; 10];
361        report[4] = 0x00;
362        report[5] = 0x64; // rotation = 100
363        report[6] = 0x01;
364        report[7] = 0xF4; // timestamp = 500
365        report[8] = 0x02; // status = Active
366        report[9] = 0x01; // touch = true
367
368        let event = ThumbWheelFeature::parse_event(&report).unwrap();
369        assert_eq!(event.rotation, 100);
370        assert_eq!(event.timestamp, 500);
371        assert_eq!(event.status, RotationStatus::Active);
372        assert!(event.touch);
373        assert!(!event.proxy);
374    }
375
376    #[test]
377    fn test_config_default() {
378        let config = ThumbWheelConfig::default();
379        assert!(!config.divert);
380        assert!(!config.invert);
381    }
382}