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}