Skip to main content

openlogi_hid/
smartshift.rs

1//! HID++ `SmartShift Enhanced` (feature `0x2111`) — wheel ratchet ↔
2//! free-spin control with sensitivity threshold.
3//!
4//! `hidpp 0.2` ships a typed wrapper for the original `0x2110 SmartShift`
5//! at function IDs `0` / `1`. The "Enhanced" variant `0x2111` (MX Master
6//! 3 / 3S / 4 and most current MX-line devices) shifts the call table by
7//! one slot — `0` is a capability query, `1` is the status read, `2` is
8//! the status write. Using `0x2110`'s function IDs against a `0x2111`
9//! device hits the wrong functions and the device silently keeps its
10//! previous state.
11//!
12//! Mode encoding (consistent across 0x2110 / 0x2111):
13//! - `wheelMode` `1` = free-spin (no ratchet, infinite scroll), `2` =
14//!   ratchet (clicky).
15//! - `autoDisengage` `0x01`–`0xFE` = the wheel speed (in 0.25 turn/s steps)
16//!   past which a ratchet-mode wheel releases into free-spin — i.e. the
17//!   "SmartShift" threshold. `0xFF` keeps the ratchet engaged permanently
18//!   (never auto-switches). See [`AUTO_DISENGAGE_PERMANENT`].
19
20use std::sync::Arc;
21
22use hidpp::{
23    channel::HidppChannel,
24    feature::{CreatableFeature, Feature},
25    nibble::U4,
26    protocol::v20::{self, Hidpp20Error},
27};
28use num_enum::{IntoPrimitive, TryFromPrimitive};
29use serde::{Deserialize, Serialize};
30
31/// SmartShift mode values understood by the firmware. `Free` = free-spin,
32/// `Ratchet` = clicky / smartshift-off. The discriminant is the wire byte;
33/// reserved values (`0` / `3` / future) fail [`TryFrom`] and callers fall back
34/// to whatever they consider sane.
35///
36/// Also crosses the agent↔GUI IPC — where serde encodes the variant *index*
37/// (Free=0, Ratchet=1), not the `#[repr(u8)]` firmware discriminant — so
38/// variant order is wire format and changes require a `PROTOCOL_VERSION` bump
39/// (guarded by `openlogi-agent-core/tests/wire_format.rs`).
40#[derive(
41    Debug, Clone, Copy, PartialEq, Eq, IntoPrimitive, TryFromPrimitive, Serialize, Deserialize,
42)]
43#[repr(u8)]
44pub enum SmartShiftMode {
45    Free = 1,
46    Ratchet = 2,
47}
48
49impl SmartShiftMode {
50    /// The opposite mode — used by [`crate::write::toggle_smartshift`].
51    #[must_use]
52    pub fn flipped(self) -> Self {
53        match self {
54            Self::Free => Self::Ratchet,
55            Self::Ratchet => Self::Free,
56        }
57    }
58}
59
60// The config file persists the wheel mode in its own representation
61// (`openlogi_core::config::WheelMode`, kept IPC-free); these conversions are
62// the single mapping between the persisted and the wire/firmware form, used by
63// the GUI when committing and by the agent when re-applying after a reconnect.
64impl From<openlogi_core::config::WheelMode> for SmartShiftMode {
65    fn from(mode: openlogi_core::config::WheelMode) -> Self {
66        match mode {
67            openlogi_core::config::WheelMode::Free => Self::Free,
68            openlogi_core::config::WheelMode::Ratchet => Self::Ratchet,
69        }
70    }
71}
72
73impl From<SmartShiftMode> for openlogi_core::config::WheelMode {
74    fn from(mode: SmartShiftMode) -> Self {
75        match mode {
76            SmartShiftMode::Free => Self::Free,
77            SmartShiftMode::Ratchet => Self::Ratchet,
78        }
79    }
80}
81
82/// `autoDisengage` value that keeps the ratchet engaged permanently — the
83/// wheel never auto-releases into free-spin, regardless of speed. Any other
84/// value (`0x01`–`0xFE`) is a SmartShift speed threshold.
85pub const AUTO_DISENGAGE_PERMANENT: u8 = 0xff;
86
87/// Snapshot returned from [`SmartShiftFeatureV0::get_status`].
88///
89/// Crosses the agent↔GUI IPC (`read_smartshift`), so field order is wire
90/// format — changes require a `PROTOCOL_VERSION` bump (guarded by
91/// `openlogi-agent-core/tests/wire_format.rs`).
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
93pub struct SmartShiftStatus {
94    pub mode: SmartShiftMode,
95    /// SmartShift speed threshold: `0x01`–`0xFE` in 0.25 turn/s steps (higher
96    /// = harder to flip into free-spin while scrolling; Logitech defaults to
97    /// ~16 on the MX line), or [`AUTO_DISENGAGE_PERMANENT`] for a permanently
98    /// engaged ratchet.
99    pub auto_disengage: u8,
100    /// Tunable-torque force as a percentage (`1`–`100`) of the device's max
101    /// force, or `0` when the device doesn't support tunable torque. Read back
102    /// and re-sent unchanged so adjusting the mode or threshold doesn't
103    /// disturb the wheel's resistance.
104    pub tunable_torque: u8,
105}
106
107/// `SmartShift` / `0x2111` feature, version 0+.
108#[derive(Clone)]
109pub struct SmartShiftFeatureV0 {
110    chan: Arc<HidppChannel>,
111    device_index: u8,
112    feature_index: u8,
113}
114
115impl CreatableFeature for SmartShiftFeatureV0 {
116    const ID: u16 = 0x2111;
117    const STARTING_VERSION: u8 = 0;
118
119    fn new(chan: Arc<HidppChannel>, device_index: u8, feature_index: u8) -> Self {
120        Self {
121            chan,
122            device_index,
123            feature_index,
124        }
125    }
126}
127
128impl Feature for SmartShiftFeatureV0 {}
129
130/// `0x2111` function ID for `getStatus` — returns mode + current
131/// sensitivity + default sensitivity. Different from `0x2110` which uses
132/// function `0` for the same purpose.
133const FUNCTION_GET_STATUS: u8 = 1;
134/// `0x2111` function ID for `setStatus` — accepts mode + sensitivity +
135/// defaultSensitivity. `0x2110` uses function `1` here.
136const FUNCTION_SET_STATUS: u8 = 2;
137
138impl SmartShiftFeatureV0 {
139    /// Read the current `wheelMode` + `autoDisengage` + `currentTunableTorque`.
140    /// Reserved mode bytes fall back to [`SmartShiftMode::Ratchet`] because
141    /// that's the "safe" / clicky behaviour most users expect.
142    pub async fn get_status(&self) -> Result<SmartShiftStatus, Hidpp20Error> {
143        let response = self
144            .chan
145            .send_v20(v20::Message::Short(
146                v20::MessageHeader {
147                    device_index: self.device_index,
148                    feature_index: self.feature_index,
149                    function_id: U4::from_lo(FUNCTION_GET_STATUS),
150                    software_id: self.chan.get_sw_id(),
151                },
152                [0x00, 0x00, 0x00],
153            ))
154            .await?;
155        let payload = response.extend_payload();
156        let mode = SmartShiftMode::try_from(payload[0]).unwrap_or(SmartShiftMode::Ratchet);
157        Ok(SmartShiftStatus {
158            mode,
159            auto_disengage: payload[1],
160            tunable_torque: payload.get(2).copied().unwrap_or(0),
161        })
162    }
163
164    /// Write a new `wheelMode` + `autoDisengage` + `currentTunableTorque`. The
165    /// firmware stores all three persistently in the device's NVM, so callers
166    /// should read the current `tunable_torque` (and any field they don't mean
167    /// to change) via [`Self::get_status`] and re-send it here.
168    pub async fn set_status(
169        &self,
170        mode: SmartShiftMode,
171        auto_disengage: u8,
172        tunable_torque: u8,
173    ) -> Result<(), Hidpp20Error> {
174        let _ = self
175            .chan
176            .send_v20(v20::Message::Short(
177                v20::MessageHeader {
178                    device_index: self.device_index,
179                    feature_index: self.feature_index,
180                    function_id: U4::from_lo(FUNCTION_SET_STATUS),
181                    software_id: self.chan.get_sw_id(),
182                },
183                [u8::from(mode), auto_disengage, tunable_torque],
184            ))
185            .await?;
186        Ok(())
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn flipped_is_an_involution() {
196        assert_eq!(SmartShiftMode::Free.flipped(), SmartShiftMode::Ratchet);
197        assert_eq!(SmartShiftMode::Ratchet.flipped(), SmartShiftMode::Free);
198        assert_eq!(
199            SmartShiftMode::Free.flipped().flipped(),
200            SmartShiftMode::Free
201        );
202    }
203}