openlogi_hid/write/smartshift.rs
1use std::num::NonZeroU8;
2use std::sync::Arc;
3
4use hidpp::{
5 channel::HidppChannel,
6 device::Device,
7 feature::{
8 CreatableFeature,
9 smartshift::{SmartShiftFeature, WheelMode},
10 smartshift_enhanced::{SmartShiftEnhancedFeature, SmartShiftEnhancedStatusChange},
11 },
12};
13use tracing::debug;
14
15use crate::route::DeviceRoute;
16use crate::smartshift::{SmartShiftMode, SmartShiftStatus};
17
18use super::{HidppOperation, WriteError, classify_hidpp_error, open_feature, with_route};
19
20/// Whether a failure to open the `0x2111` Enhanced SmartShift feature should
21/// trigger the `0x2110` legacy fallback. Only a missing-`0x2111` feature
22/// qualifies; transport and protocol errors propagate unchanged so a real
23/// failure is never masked by a second open attempt.
24pub(super) fn is_missing_enhanced(err: &WriteError) -> bool {
25 matches!(
26 err,
27 WriteError::FeatureUnsupported { feature_hex } if *feature_hex == 0x2111
28 )
29}
30
31/// Map the fork's `0x2110` [`WheelMode`] onto OpenLogi's [`SmartShiftMode`].
32/// A future `#[non_exhaustive]` variant maps to [`SmartShiftMode::Ratchet`],
33/// the "safe" clicky default OpenLogi uses elsewhere. (Reserved wire bytes
34/// never reach here — the fork's `get_ratchet_control_mode` rejects them.)
35pub(super) fn wheel_mode_to_smartshift(wheel: WheelMode) -> SmartShiftMode {
36 if matches!(wheel, WheelMode::Freespin) {
37 SmartShiftMode::Free
38 } else {
39 SmartShiftMode::Ratchet
40 }
41}
42
43/// Map OpenLogi's [`SmartShiftMode`] onto the fork's `0x2110` [`WheelMode`] —
44/// the inverse of [`wheel_mode_to_smartshift`], used when writing the legacy
45/// ratchet-control mode.
46pub(super) fn smartshift_to_wheel(mode: SmartShiftMode) -> WheelMode {
47 match mode {
48 SmartShiftMode::Free => WheelMode::Freespin,
49 SmartShiftMode::Ratchet => WheelMode::Ratchet,
50 }
51}
52
53/// Whichever SmartShift feature a device exposes, normalised onto
54/// [`SmartShiftMode`]. Devices ship one or the other: MX Master 3 / 3S use the
55/// `0x2111` Enhanced variant, the MX Master 2S uses the original `0x2110`.
56enum SmartShift {
57 /// `0x2111 SmartShiftWheelEnhanced`.
58 Enhanced(Arc<SmartShiftEnhancedFeature>),
59 /// `0x2110 SmartShiftWheel`.
60 Legacy(Arc<SmartShiftFeature>),
61}
62
63impl SmartShift {
64 /// Open whichever SmartShift feature the device exposes. Tries `0x2111`
65 /// first; on a missing-`0x2111` error (and only that), retries with
66 /// `0x2110`. Any other error from the first attempt propagates unchanged.
67 async fn open(device: &mut Device) -> Result<Self, WriteError> {
68 match open_feature::<SmartShiftEnhancedFeature>(device).await {
69 Ok(feature) => Ok(Self::Enhanced(feature)),
70 Err(err) if is_missing_enhanced(&err) => {
71 let feature = open_feature::<SmartShiftFeature>(device).await?;
72 Ok(Self::Legacy(feature))
73 }
74 Err(err) => Err(err),
75 }
76 }
77
78 /// Read the current mode + auto-disengage threshold. Enhanced (`0x2111`)
79 /// also reports tunable torque; Legacy (`0x2110`) has no such concept, so
80 /// `tunable_torque` is reported as `0` per [`SmartShiftStatus`]'s contract.
81 async fn status(&self) -> Result<SmartShiftStatus, WriteError> {
82 match self {
83 Self::Enhanced(feature) => {
84 let status = feature.get_ratchet_control_mode().await.map_err(|e| {
85 classify_hidpp_error(
86 e,
87 HidppOperation::ReadSmartShift,
88 SmartShiftEnhancedFeature::ID,
89 )
90 })?;
91 Ok(SmartShiftStatus {
92 mode: wheel_mode_to_smartshift(status.wheel_mode),
93 auto_disengage: status.auto_disengage,
94 tunable_torque: status.current_tunable_torque,
95 })
96 }
97 Self::Legacy(feature) => {
98 let rcm = feature.get_ratchet_control_mode().await.map_err(|e| {
99 classify_hidpp_error(e, HidppOperation::ReadSmartShift, SmartShiftFeature::ID)
100 })?;
101 Ok(SmartShiftStatus {
102 mode: wheel_mode_to_smartshift(rcm.wheel_mode),
103 auto_disengage: rcm.auto_disengage,
104 // 0x2110 has no tunable-torque function; report 0 like
105 // `SmartShiftStatus::tunable_torque` documents for devices
106 // that don't support it.
107 tunable_torque: 0,
108 })
109 }
110 }
111 }
112
113 /// Write a full desired status — wheel mode plus the auto-disengage
114 /// threshold and (Enhanced only) tunable torque.
115 ///
116 /// Per the `0x2110` / `0x2111` `setRatchetControlMode` spec, `0` is the
117 /// firmware's "do not change" sentinel for `autoDisengage` and
118 /// `currentTunableTorque` (real values are `0x01..=0xFF`). So a zero field
119 /// is sent as "preserve" rather than rejected — this is the only way to
120 /// write a mode change on a device that reports `tunable_torque == 0`
121 /// (e.g. one without tunable-torque hardware), which otherwise silently
122 /// failed the whole write.
123 async fn set_status(&self, status: SmartShiftStatus) -> Result<(), WriteError> {
124 let SmartShiftStatus {
125 mode,
126 auto_disengage,
127 tunable_torque,
128 } = status;
129 match self {
130 Self::Enhanced(feature) => feature
131 .set_ratchet_control_mode(SmartShiftEnhancedStatusChange {
132 wheel_mode: Some(smartshift_to_wheel(mode)),
133 auto_disengage: NonZeroU8::new(auto_disengage),
134 tunable_torque: NonZeroU8::new(tunable_torque),
135 })
136 .await
137 .map(|_| ())
138 .map_err(|e| {
139 classify_hidpp_error(
140 e,
141 HidppOperation::WriteSmartShift,
142 SmartShiftEnhancedFeature::ID,
143 )
144 }),
145 // `Some(0)` encodes as `0x00` = "do not change" per the x2110 spec
146 // and `SmartShiftFeature::set_ratchet_control_mode`, so this matches
147 // the Enhanced branch's `NonZeroU8::new` preserve-on-zero semantics.
148 Self::Legacy(feature) => feature
149 .set_ratchet_control_mode(
150 Some(smartshift_to_wheel(mode)),
151 Some(auto_disengage),
152 None,
153 )
154 .await
155 .map_err(|e| {
156 classify_hidpp_error(e, HidppOperation::WriteSmartShift, SmartShiftFeature::ID)
157 }),
158 }
159 }
160
161 /// Write a new auto-disengage `sensitivity`, preserving the current mode
162 /// (and, on Enhanced, the tunable torque). Reads the current status first
163 /// so every preserved field is written back explicitly. The [`NonZeroU8`]
164 /// rules out `0`, which the device would treat as "no change" — a silent
165 /// non-write rather than a real sensitivity update.
166 async fn set_sensitivity(&self, value: NonZeroU8) -> Result<(), WriteError> {
167 let current = self.status().await?;
168 match self {
169 Self::Enhanced(feature) => feature
170 .set_ratchet_control_mode(SmartShiftEnhancedStatusChange {
171 wheel_mode: Some(smartshift_to_wheel(current.mode)),
172 auto_disengage: Some(value),
173 // Preserve a reported zero as “do not change”; HID++ uses
174 // zero as the sentinel and cannot write it as a target value.
175 tunable_torque: NonZeroU8::new(current.tunable_torque),
176 })
177 .await
178 .map(|_| ())
179 .map_err(|e| {
180 classify_hidpp_error(
181 e,
182 HidppOperation::WriteSmartShift,
183 SmartShiftEnhancedFeature::ID,
184 )
185 }),
186 Self::Legacy(_) => {
187 self.set_status(SmartShiftStatus {
188 auto_disengage: value.get(),
189 ..current
190 })
191 .await
192 }
193 }
194 }
195}
196
197/// Read the device's current SmartShift mode + sensitivity — companion to
198/// [`toggle_smartshift`].
199pub async fn get_smartshift_status(route: &DeviceRoute) -> Result<SmartShiftStatus, WriteError> {
200 let index = route.device_index();
201 with_route(route, move |channel| async move {
202 let mut device = Device::new(Arc::clone(&channel), index)
203 .await
204 .map_err(|_| WriteError::DeviceUnreachable { index })?;
205 let smartshift = SmartShift::open(&mut device).await?;
206 smartshift.status().await
207 })
208 .await
209}
210
211/// Set the SmartShift auto-disengage sensitivity on `route`, preserving the
212/// current mode. Returns the read-back status after the write so the caller can
213/// display and verify it.
214///
215/// `value` is written verbatim: `0x01..=0xfe` is the auto-disengage threshold
216/// (smaller = releases sooner / more sensitive) and `0xff` is permanent ratchet.
217/// The [`NonZeroU8`] parameter rules out `0` at the type level — the device
218/// treats a `0` threshold as "no change", so it could never be a real write.
219///
220/// `FeatureUnsupported` when the device exposes neither HID++ `0x2111`
221/// (MX Master 3 / 3S) nor the older `0x2110` (MX Master 2S).
222pub async fn set_smartshift_sensitivity(
223 route: &DeviceRoute,
224 value: NonZeroU8,
225) -> Result<SmartShiftStatus, WriteError> {
226 let index = route.device_index();
227 with_route(route, move |channel| async move {
228 let mut device = Device::new(Arc::clone(&channel), index)
229 .await
230 .map_err(|_| WriteError::DeviceUnreachable { index })?;
231 let smartshift = SmartShift::open(&mut device).await?;
232 smartshift.set_sensitivity(value).await?;
233 smartshift.status().await
234 })
235 .await
236}
237
238/// Toggle SmartShift mode (free ↔ ratchet) on `route`. Reads the current
239/// mode first, then writes the opposite — keeps current sensitivity.
240/// Returns the new mode written.
241///
242/// `FeatureUnsupported` when the device exposes neither HID++ `0x2111`
243/// (MX Master 3 / 3S) nor the older `0x2110` (MX Master 2S) — i.e. it has no
244/// SmartShift wheel.
245pub async fn toggle_smartshift(route: &DeviceRoute) -> Result<SmartShiftMode, WriteError> {
246 let index = route.device_index();
247 with_route(route, move |channel| async move {
248 toggle_smartshift_on_channel(&channel, index).await
249 })
250 .await
251}
252
253/// The SmartShift toggle itself, on an already-open channel at HID++ `index`.
254/// Shared by [`toggle_smartshift`] and [`toggle_smartshift_on`](super::toggle_smartshift_on).
255pub(super) async fn toggle_smartshift_on_channel(
256 channel: &Arc<HidppChannel>,
257 index: u8,
258) -> Result<SmartShiftMode, WriteError> {
259 let mut device = Device::new(Arc::clone(channel), index)
260 .await
261 .map_err(|_| WriteError::DeviceUnreachable { index })?;
262 let smartshift = SmartShift::open(&mut device).await?;
263 let status = smartshift.status().await?;
264 let next = status.mode.flipped();
265 smartshift
266 .set_status(SmartShiftStatus {
267 mode: next,
268 ..status
269 })
270 .await?;
271 debug!(index, ?next, "wrote SmartShift mode");
272 Ok(next)
273}
274
275/// Write a full SmartShift configuration — wheel mode, auto-disengage
276/// threshold, and tunable torque — to `route`. These values are volatile device
277/// state and should be re-applied after reconnect. Callers that mean to change
278/// only one field should read the rest via [`get_smartshift_status`] first and
279/// pass them back unchanged.
280/// A `0` auto-disengage threshold or tunable torque is the firmware's
281/// "do not change" sentinel, not a real value to apply. On a Legacy (`0x2110`)
282/// device the `tunable_torque` field is ignored.
283///
284/// `FeatureUnsupported` when the device exposes neither HID++ `0x2111`
285/// (MX Master 3 / 3S) nor the older `0x2110` (MX Master 2S).
286pub async fn set_smartshift(
287 route: &DeviceRoute,
288 mode: SmartShiftMode,
289 auto_disengage: u8,
290 tunable_torque: u8,
291) -> Result<(), WriteError> {
292 let index = route.device_index();
293 with_route(route, move |channel| async move {
294 set_smartshift_on_channel(&channel, index, mode, auto_disengage, tunable_torque).await
295 })
296 .await
297}
298
299/// The SmartShift write itself, on an already-open channel at HID++ `index`.
300/// Shared by [`set_smartshift`] and [`set_smartshift_on`](super::set_smartshift_on).
301pub(super) async fn set_smartshift_on_channel(
302 channel: &Arc<HidppChannel>,
303 index: u8,
304 mode: SmartShiftMode,
305 auto_disengage: u8,
306 tunable_torque: u8,
307) -> Result<(), WriteError> {
308 let mut device = Device::new(Arc::clone(channel), index)
309 .await
310 .map_err(|_| WriteError::DeviceUnreachable { index })?;
311 let smartshift = SmartShift::open(&mut device).await?;
312 smartshift
313 .set_status(SmartShiftStatus {
314 mode,
315 auto_disengage,
316 tunable_torque,
317 })
318 .await?;
319 debug!(
320 index,
321 ?mode,
322 auto_disengage,
323 tunable_torque,
324 "wrote SmartShift config"
325 );
326 Ok(())
327}