logiops_core/features/
smartshift.rs

1//! `SmartShift` feature (0x2110) - Scroll wheel mode switching.
2//!
3//! `SmartShift` is Logitech's technology that automatically switches the scroll wheel
4//! between ratchet mode (notched scrolling) and free-spin mode (smooth scrolling)
5//! based on scroll speed.
6
7use hidpp_transport::HidapiChannel;
8use tracing::{debug, trace};
9
10use crate::error::{HidppErrorCode, ProtocolError, Result};
11use crate::protocol::{build_long_request, get_error_code, is_error_response};
12
13/// `SmartShift` feature implementation.
14pub struct SmartShiftFeature {
15    device_index: u8,
16    feature_index: u8,
17}
18
19/// `SmartShift` status and configuration.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct SmartShiftConfig {
22    /// Whether `SmartShift` is active (true) or wheel is in fixed mode.
23    pub active: bool,
24    /// Current wheel mode when `SmartShift` is inactive or as default.
25    pub wheel_mode: WheelMode,
26    /// Auto-disengage threshold (1-255, higher = more resistance before free-spin).
27    /// 0 means use device default.
28    pub auto_disengage: u8,
29    /// Torque setting for ratchet mode (1-255, higher = stronger detents).
30    /// 0 means use device default.
31    pub torque: u8,
32}
33
34impl Default for SmartShiftConfig {
35    fn default() -> Self {
36        Self {
37            active: true,
38            wheel_mode: WheelMode::Ratchet,
39            auto_disengage: 30,
40            torque: 50,
41        }
42    }
43}
44
45/// Scroll wheel operating mode.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
47pub enum WheelMode {
48    /// Ratchet mode - notched scrolling with detents.
49    #[default]
50    Ratchet,
51    /// Free-spin mode - smooth continuous scrolling.
52    FreeSpin,
53}
54
55impl WheelMode {
56    /// Converts from byte value.
57    #[must_use]
58    pub fn from_byte(value: u8) -> Self {
59        if value == 0 {
60            Self::Ratchet
61        } else {
62            Self::FreeSpin
63        }
64    }
65
66    /// Converts to byte value.
67    #[must_use]
68    pub fn to_byte(self) -> u8 {
69        match self {
70            Self::Ratchet => 0,
71            Self::FreeSpin => 1,
72        }
73    }
74}
75
76impl std::fmt::Display for WheelMode {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        match self {
79            Self::Ratchet => write!(f, "Ratchet"),
80            Self::FreeSpin => write!(f, "Free-spin"),
81        }
82    }
83}
84
85impl SmartShiftFeature {
86    /// Creates a new `SmartShift` feature accessor.
87    ///
88    /// # Arguments
89    /// * `device_index` - Device index (0xFF for direct)
90    /// * `feature_index` - Feature index from root feature discovery
91    #[must_use]
92    pub fn new(device_index: u8, feature_index: u8) -> Self {
93        Self {
94            device_index,
95            feature_index,
96        }
97    }
98
99    /// Gets the current `SmartShift` status.
100    ///
101    /// # Errors
102    /// Returns an error if HID++ communication fails.
103    pub async fn get_status(&self, channel: &HidapiChannel) -> Result<SmartShiftConfig> {
104        // getRatchetControlMode: function_id=0
105        let request = build_long_request(self.device_index, self.feature_index, 0x00, &[]);
106
107        trace!("getting SmartShift status");
108        let response = channel.request(&request, 5).await?;
109
110        if is_error_response(&response) {
111            let code = get_error_code(&response).unwrap_or(0);
112            return Err(ProtocolError::HidppError(HidppErrorCode::from_byte(code)));
113        }
114
115        if response.len() < 8 {
116            return Err(ProtocolError::InvalidResponse(
117                "SmartShift status response too short".to_string(),
118            ));
119        }
120
121        // Response format varies by device version
122        // Common: [report_id, device_idx, feature_idx, func_sw_id, wheel_mode, smartshift_on, auto_disengage, torque]
123        let wheel_mode = WheelMode::from_byte(response[4]);
124        let active = response[5] != 0;
125        let auto_disengage = response[6];
126        let torque = response.get(7).copied().unwrap_or(0);
127
128        let config = SmartShiftConfig {
129            active,
130            wheel_mode,
131            auto_disengage,
132            torque,
133        };
134
135        debug!(
136            active = config.active,
137            mode = %config.wheel_mode,
138            threshold = config.auto_disengage,
139            torque = config.torque,
140            "got SmartShift status"
141        );
142
143        Ok(config)
144    }
145
146    /// Sets the `SmartShift` configuration.
147    ///
148    /// # Errors
149    /// Returns an error if HID++ communication fails.
150    pub async fn set_status(
151        &self,
152        channel: &HidapiChannel,
153        config: &SmartShiftConfig,
154    ) -> Result<()> {
155        // setRatchetControlMode: function_id=1
156        // Params: [wheel_mode, smartshift_on, auto_disengage, torque]
157        let params = [
158            config.wheel_mode.to_byte(),
159            u8::from(config.active),
160            config.auto_disengage,
161        ];
162        let request = build_long_request(self.device_index, self.feature_index, 0x01, &params);
163
164        trace!(
165            active = config.active,
166            mode = ?config.wheel_mode,
167            threshold = config.auto_disengage,
168            "setting SmartShift status"
169        );
170
171        let response = channel.request(&request, 5).await?;
172
173        if is_error_response(&response) {
174            let code = get_error_code(&response).unwrap_or(0);
175            return Err(ProtocolError::HidppError(HidppErrorCode::from_byte(code)));
176        }
177
178        debug!(
179            active = config.active,
180            mode = %config.wheel_mode,
181            "set SmartShift status"
182        );
183
184        Ok(())
185    }
186
187    /// Enables or disables `SmartShift`.
188    ///
189    /// # Errors
190    /// Returns an error if HID++ communication fails.
191    pub async fn set_enabled(&self, channel: &HidapiChannel, enabled: bool) -> Result<()> {
192        let mut config = self.get_status(channel).await?;
193        config.active = enabled;
194        self.set_status(channel, &config).await
195    }
196
197    /// Sets the auto-disengage threshold.
198    ///
199    /// Lower values make it easier to trigger free-spin mode.
200    ///
201    /// # Arguments
202    /// * `threshold` - Threshold value (1-255)
203    ///
204    /// # Errors
205    /// Returns an error if HID++ communication fails.
206    pub async fn set_threshold(&self, channel: &HidapiChannel, threshold: u8) -> Result<()> {
207        let mut config = self.get_status(channel).await?;
208        config.auto_disengage = threshold;
209        self.set_status(channel, &config).await
210    }
211
212    /// Sets the wheel mode directly (bypassing `SmartShift`).
213    ///
214    /// # Errors
215    /// Returns an error if HID++ communication fails.
216    pub async fn set_wheel_mode(&self, channel: &HidapiChannel, mode: WheelMode) -> Result<()> {
217        let mut config = self.get_status(channel).await?;
218        config.wheel_mode = mode;
219        self.set_status(channel, &config).await
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn test_wheel_mode() {
229        assert_eq!(WheelMode::from_byte(0), WheelMode::Ratchet);
230        assert_eq!(WheelMode::from_byte(1), WheelMode::FreeSpin);
231        assert_eq!(WheelMode::from_byte(2), WheelMode::FreeSpin);
232
233        assert_eq!(WheelMode::Ratchet.to_byte(), 0);
234        assert_eq!(WheelMode::FreeSpin.to_byte(), 1);
235    }
236
237    #[test]
238    fn test_default_config() {
239        let config = SmartShiftConfig::default();
240        assert!(config.active);
241        assert_eq!(config.wheel_mode, WheelMode::Ratchet);
242        assert_eq!(config.auto_disengage, 30);
243        assert_eq!(config.torque, 50);
244    }
245}