openlogi_hid/write/smartshift.rs
1use std::num::NonZeroU8;
2use std::sync::Arc;
3use std::time::Duration;
4
5use hidpp::{
6 channel::HidppChannel,
7 device::Device,
8 feature::{
9 CreatableFeature,
10 smartshift::{SmartShiftFeature, WheelMode},
11 smartshift_enhanced::{SmartShiftEnhancedFeature, SmartShiftEnhancedStatusChange},
12 },
13};
14use tracing::debug;
15
16use crate::route::DeviceRoute;
17use crate::smartshift::{SmartShiftMode, SmartShiftStatus};
18
19use super::{
20 HidppFeatureErrorKind, HidppOperation, WriteError, classify_hidpp_error, open_feature,
21 with_route,
22};
23
24/// Brief pause before re-trying a SmartShift transaction that lost a race with
25/// concurrent HID++ traffic on another open of the same node (#485).
26const TRANSIENT_RETRY_DELAY: Duration = Duration::from_millis(50);
27
28/// Whether a failure to open the `0x2111` Enhanced SmartShift feature should
29/// trigger the `0x2110` legacy fallback. Only a missing-`0x2111` feature
30/// qualifies; transport and protocol errors propagate unchanged so a real
31/// failure is never masked by a second open attempt.
32pub(super) fn is_missing_enhanced(err: &WriteError) -> bool {
33 matches!(
34 err,
35 WriteError::FeatureUnsupported { feature_hex } if *feature_hex == 0x2111
36 )
37}
38
39/// Errors that, on SmartShift, have been observed to clear on a second attempt
40/// with byte-identical parameters after concurrent multi-open traffic settles
41/// (#485). Permanent failures (unsupported feature, bad permanent payload) are
42/// not included.
43pub(super) fn is_transient_smartshift_error(err: &WriteError) -> bool {
44 matches!(
45 err,
46 WriteError::HidppFeature {
47 kind: HidppFeatureErrorKind::InvalidArgument
48 | HidppFeatureErrorKind::Busy
49 | HidppFeatureErrorKind::HwError,
50 ..
51 } | WriteError::UnsupportedResponse { .. }
52 )
53}
54
55/// Whether `current` already satisfies a desired SmartShift write. A zero
56/// `auto_disengage` / `tunable_torque` is the firmware "do not change" sentinel
57/// on the write path, so those fields are only compared when non-zero.
58pub(super) fn status_matches_desired(current: SmartShiftStatus, desired: SmartShiftStatus) -> bool {
59 current.mode == desired.mode
60 && (desired.auto_disengage == 0 || current.auto_disengage == desired.auto_disengage)
61 && (desired.tunable_torque == 0 || current.tunable_torque == desired.tunable_torque)
62}
63
64/// Map the fork's `0x2110` [`WheelMode`] onto OpenLogi's [`SmartShiftMode`].
65/// A future `#[non_exhaustive]` variant maps to [`SmartShiftMode::Ratchet`],
66/// the "safe" clicky default OpenLogi uses elsewhere. (Reserved wire bytes
67/// never reach here — the fork's `get_ratchet_control_mode` rejects them.)
68pub(super) fn wheel_mode_to_smartshift(wheel: WheelMode) -> SmartShiftMode {
69 if matches!(wheel, WheelMode::Freespin) {
70 SmartShiftMode::Free
71 } else {
72 SmartShiftMode::Ratchet
73 }
74}
75
76/// Map OpenLogi's [`SmartShiftMode`] onto the fork's `0x2110` [`WheelMode`] —
77/// the inverse of [`wheel_mode_to_smartshift`], used when writing the legacy
78/// ratchet-control mode.
79pub(super) fn smartshift_to_wheel(mode: SmartShiftMode) -> WheelMode {
80 match mode {
81 SmartShiftMode::Free => WheelMode::Freespin,
82 SmartShiftMode::Ratchet => WheelMode::Ratchet,
83 }
84}
85
86/// Whichever SmartShift feature a device exposes, normalised onto
87/// [`SmartShiftMode`]. Devices ship one or the other: MX Master 3 / 3S use the
88/// `0x2111` Enhanced variant, the MX Master 2S uses the original `0x2110`.
89enum SmartShift {
90 /// `0x2111 SmartShiftWheelEnhanced`.
91 Enhanced(Arc<SmartShiftEnhancedFeature>),
92 /// `0x2110 SmartShiftWheel`.
93 Legacy(Arc<SmartShiftFeature>),
94}
95
96impl SmartShift {
97 /// Open whichever SmartShift feature the device exposes. Tries `0x2111`
98 /// first; on a missing-`0x2111` error (and only that), re-checks once before
99 /// falling back to `0x2110`. A concurrent multi-open of the same HID node
100 /// can mis-deliver a `root.get_feature` response and make a present `0x2111`
101 /// look absent (#485) — the second probe catches that before we write the
102 /// wrong feature. Any other error from either attempt propagates unchanged.
103 async fn open(device: &mut Device) -> Result<Self, WriteError> {
104 match open_feature::<SmartShiftEnhancedFeature>(device).await {
105 Ok(feature) => Ok(Self::Enhanced(feature)),
106 Err(err) if is_missing_enhanced(&err) => {
107 match open_feature::<SmartShiftEnhancedFeature>(device).await {
108 Ok(feature) => Ok(Self::Enhanced(feature)),
109 Err(err) if is_missing_enhanced(&err) => {
110 let feature = open_feature::<SmartShiftFeature>(device).await?;
111 Ok(Self::Legacy(feature))
112 }
113 Err(err) => Err(err),
114 }
115 }
116 Err(err) => Err(err),
117 }
118 }
119
120 /// Read the current mode + auto-disengage threshold. Enhanced (`0x2111`)
121 /// also reports tunable torque; Legacy (`0x2110`) has no such concept, so
122 /// `tunable_torque` is reported as `0` per [`SmartShiftStatus`]'s contract.
123 async fn status(&self) -> Result<SmartShiftStatus, WriteError> {
124 match self {
125 Self::Enhanced(feature) => {
126 let status = feature.get_ratchet_control_mode().await.map_err(|e| {
127 classify_hidpp_error(
128 e,
129 HidppOperation::ReadSmartShift,
130 SmartShiftEnhancedFeature::ID,
131 )
132 })?;
133 Ok(SmartShiftStatus {
134 mode: wheel_mode_to_smartshift(status.wheel_mode),
135 auto_disengage: status.auto_disengage,
136 tunable_torque: status.current_tunable_torque,
137 })
138 }
139 Self::Legacy(feature) => {
140 let rcm = feature.get_ratchet_control_mode().await.map_err(|e| {
141 classify_hidpp_error(e, HidppOperation::ReadSmartShift, SmartShiftFeature::ID)
142 })?;
143 Ok(SmartShiftStatus {
144 mode: wheel_mode_to_smartshift(rcm.wheel_mode),
145 auto_disengage: rcm.auto_disengage,
146 // 0x2110 has no tunable-torque function; report 0 like
147 // `SmartShiftStatus::tunable_torque` documents for devices
148 // that don't support it.
149 tunable_torque: 0,
150 })
151 }
152 }
153 }
154
155 /// Write a full desired status — wheel mode plus the auto-disengage
156 /// threshold and (Enhanced only) tunable torque.
157 ///
158 /// Per the `0x2110` / `0x2111` `setRatchetControlMode` spec, `0` is the
159 /// firmware's "do not change" sentinel for `autoDisengage` and
160 /// `currentTunableTorque` (real values are `0x01..=0xFF`). So a zero field
161 /// is sent as "preserve" rather than rejected — this is the only way to
162 /// write a mode change on a device that reports `tunable_torque == 0`
163 /// (e.g. one without tunable-torque hardware), which otherwise silently
164 /// failed the whole write.
165 async fn set_status(&self, status: SmartShiftStatus) -> Result<(), WriteError> {
166 let SmartShiftStatus {
167 mode,
168 auto_disengage,
169 tunable_torque,
170 } = status;
171 match self {
172 Self::Enhanced(feature) => feature
173 .set_ratchet_control_mode(SmartShiftEnhancedStatusChange {
174 wheel_mode: Some(smartshift_to_wheel(mode)),
175 auto_disengage: NonZeroU8::new(auto_disengage),
176 tunable_torque: NonZeroU8::new(tunable_torque),
177 })
178 .await
179 .map(|_| ())
180 .map_err(|e| {
181 classify_hidpp_error(
182 e,
183 HidppOperation::WriteSmartShift,
184 SmartShiftEnhancedFeature::ID,
185 )
186 }),
187 // `Some(0)` encodes as `0x00` = "do not change" per the x2110 spec
188 // and `SmartShiftFeature::set_ratchet_control_mode`, so this matches
189 // the Enhanced branch's `NonZeroU8::new` preserve-on-zero semantics.
190 Self::Legacy(feature) => feature
191 .set_ratchet_control_mode(
192 Some(smartshift_to_wheel(mode)),
193 Some(auto_disengage),
194 None,
195 )
196 .await
197 .map_err(|e| {
198 classify_hidpp_error(e, HidppOperation::WriteSmartShift, SmartShiftFeature::ID)
199 }),
200 }
201 }
202
203 /// Write a new auto-disengage `sensitivity`, preserving the current mode
204 /// (and, on Enhanced, the tunable torque). Reads the current status first
205 /// so every preserved field is written back explicitly. The [`NonZeroU8`]
206 /// rules out `0`, which the device would treat as "no change" — a silent
207 /// non-write rather than a real sensitivity update.
208 async fn set_sensitivity(&self, value: NonZeroU8) -> Result<(), WriteError> {
209 let current = self.status().await?;
210 match self {
211 Self::Enhanced(feature) => feature
212 .set_ratchet_control_mode(SmartShiftEnhancedStatusChange {
213 wheel_mode: Some(smartshift_to_wheel(current.mode)),
214 auto_disengage: Some(value),
215 // Preserve a reported zero as “do not change”; HID++ uses
216 // zero as the sentinel and cannot write it as a target value.
217 tunable_torque: NonZeroU8::new(current.tunable_torque),
218 })
219 .await
220 .map(|_| ())
221 .map_err(|e| {
222 classify_hidpp_error(
223 e,
224 HidppOperation::WriteSmartShift,
225 SmartShiftEnhancedFeature::ID,
226 )
227 }),
228 Self::Legacy(_) => {
229 self.set_status(SmartShiftStatus {
230 auto_disengage: value.get(),
231 ..current
232 })
233 .await
234 }
235 }
236 }
237}
238
239/// Read the device's current SmartShift mode + sensitivity — companion to
240/// [`toggle_smartshift`].
241pub async fn get_smartshift_status(route: &DeviceRoute) -> Result<SmartShiftStatus, WriteError> {
242 let index = route.device_index();
243 with_route(route, move |channel| async move {
244 get_smartshift_status_on_channel(&channel, index).await
245 })
246 .await
247}
248
249pub(super) async fn get_smartshift_status_on_channel(
250 channel: &Arc<HidppChannel>,
251 index: u8,
252) -> Result<SmartShiftStatus, WriteError> {
253 let mut device = Device::new(Arc::clone(channel), index)
254 .await
255 .map_err(|_| WriteError::DeviceUnreachable { index })?;
256 let smartshift = SmartShift::open(&mut device).await?;
257 smartshift.status().await
258}
259
260/// Set the SmartShift auto-disengage sensitivity on `route`, preserving the
261/// current mode. Returns the read-back status after the write so the caller can
262/// display and verify it.
263///
264/// `value` is written verbatim: `0x01..=0xfe` is the auto-disengage threshold
265/// (smaller = releases sooner / more sensitive) and `0xff` is permanent ratchet.
266/// The [`NonZeroU8`] parameter rules out `0` at the type level — the device
267/// treats a `0` threshold as "no change", so it could never be a real write.
268///
269/// `FeatureUnsupported` when the device exposes neither HID++ `0x2111`
270/// (MX Master 3 / 3S) nor the older `0x2110` (MX Master 2S).
271pub async fn set_smartshift_sensitivity(
272 route: &DeviceRoute,
273 value: NonZeroU8,
274) -> Result<SmartShiftStatus, WriteError> {
275 let index = route.device_index();
276 with_route(route, move |channel| async move {
277 let mut device = Device::new(Arc::clone(&channel), index)
278 .await
279 .map_err(|_| WriteError::DeviceUnreachable { index })?;
280 let smartshift = SmartShift::open(&mut device).await?;
281 smartshift.set_sensitivity(value).await?;
282 smartshift.status().await
283 })
284 .await
285}
286
287/// Toggle SmartShift mode (free ↔ ratchet) on `route`. Reads the current
288/// mode first, then writes the opposite — keeps current sensitivity.
289/// Returns the new mode written.
290///
291/// `FeatureUnsupported` when the device exposes neither HID++ `0x2111`
292/// (MX Master 3 / 3S) nor the older `0x2110` (MX Master 2S) — i.e. it has no
293/// SmartShift wheel.
294pub async fn toggle_smartshift(route: &DeviceRoute) -> Result<SmartShiftMode, WriteError> {
295 let index = route.device_index();
296 with_route(route, move |channel| async move {
297 toggle_smartshift_on_channel(&channel, index).await
298 })
299 .await
300}
301
302/// The SmartShift toggle itself, on an already-open channel at HID++ `index`.
303/// Shared by [`toggle_smartshift`] and [`toggle_smartshift_on`](super::toggle_smartshift_on).
304///
305/// Retries once on the same transient errors as [`set_smartshift_on_channel`] —
306/// a ModeShift binding can race concurrent HID++ traffic the same way (#485).
307pub(super) async fn toggle_smartshift_on_channel(
308 channel: &Arc<HidppChannel>,
309 index: u8,
310) -> Result<SmartShiftMode, WriteError> {
311 let mut device = Device::new(Arc::clone(channel), index)
312 .await
313 .map_err(|_| WriteError::DeviceUnreachable { index })?;
314 match toggle_once(&mut device, index).await {
315 Ok(mode) => Ok(mode),
316 Err(err) if is_transient_smartshift_error(&err) => {
317 debug!(
318 index,
319 error = ?err,
320 "SmartShift toggle hit a transient error; retrying once"
321 );
322 tokio::time::sleep(TRANSIENT_RETRY_DELAY).await;
323 toggle_once(&mut device, index).await
324 }
325 Err(err) => Err(err),
326 }
327}
328
329async fn toggle_once(device: &mut Device, index: u8) -> Result<SmartShiftMode, WriteError> {
330 let smartshift = SmartShift::open(device).await?;
331 let status = smartshift.status().await?;
332 let next = status.mode.flipped();
333 smartshift
334 .set_status(SmartShiftStatus {
335 mode: next,
336 ..status
337 })
338 .await?;
339 debug!(index, ?next, "wrote SmartShift mode");
340 Ok(next)
341}
342
343/// Write a full SmartShift configuration — wheel mode, auto-disengage
344/// threshold, and tunable torque — to `route`. These values are volatile device
345/// state and should be re-applied after reconnect. Callers that mean to change
346/// only one field should read the rest via [`get_smartshift_status`] first and
347/// pass them back unchanged.
348/// A `0` auto-disengage threshold or tunable torque is the firmware's
349/// "do not change" sentinel, not a real value to apply. On a Legacy (`0x2110`)
350/// device the `tunable_torque` field is ignored.
351///
352/// `FeatureUnsupported` when the device exposes neither HID++ `0x2111`
353/// (MX Master 3 / 3S) nor the older `0x2110` (MX Master 2S).
354pub async fn set_smartshift(
355 route: &DeviceRoute,
356 mode: SmartShiftMode,
357 auto_disengage: u8,
358 tunable_torque: u8,
359) -> Result<(), WriteError> {
360 let index = route.device_index();
361 with_route(route, move |channel| async move {
362 set_smartshift_on_channel(&channel, index, mode, auto_disengage, tunable_torque).await
363 })
364 .await
365}
366
367/// The SmartShift write itself, on an already-open channel at HID++ `index`.
368/// Shared by [`set_smartshift`] and [`set_smartshift_on`](super::set_smartshift_on).
369///
370/// Skips the HID++ write when the device already holds the desired config, and
371/// retries once after a short delay on transient device errors — the first
372/// post-start reapply races concurrent opens of the same Bolt/Unifying node and
373/// can return `InvalidArgument` for byte-identical parameters (#485).
374pub(super) async fn set_smartshift_on_channel(
375 channel: &Arc<HidppChannel>,
376 index: u8,
377 mode: SmartShiftMode,
378 auto_disengage: u8,
379 tunable_torque: u8,
380) -> Result<(), WriteError> {
381 let desired = SmartShiftStatus {
382 mode,
383 auto_disengage,
384 tunable_torque,
385 };
386 let mut device = Device::new(Arc::clone(channel), index)
387 .await
388 .map_err(|_| WriteError::DeviceUnreachable { index })?;
389 let smartshift = SmartShift::open(&mut device).await?;
390 if let Ok(current) = smartshift.status().await
391 && status_matches_desired(current, desired)
392 {
393 debug!(
394 index,
395 ?mode,
396 auto_disengage,
397 tunable_torque,
398 "SmartShift already matches config; skipping write"
399 );
400 return Ok(());
401 }
402 match smartshift.set_status(desired).await {
403 Ok(()) => {
404 debug!(
405 index,
406 ?mode,
407 auto_disengage,
408 tunable_torque,
409 "wrote SmartShift config"
410 );
411 Ok(())
412 }
413 Err(err) if is_transient_smartshift_error(&err) => {
414 debug!(
415 index,
416 error = ?err,
417 "SmartShift write hit a transient error; retrying once"
418 );
419 tokio::time::sleep(TRANSIENT_RETRY_DELAY).await;
420 // Re-open: the first attempt may have bound the wrong feature index
421 // after a mis-delivered root.get_feature response.
422 let smartshift = SmartShift::open(&mut device).await?;
423 smartshift.set_status(desired).await?;
424 debug!(
425 index,
426 ?mode,
427 auto_disengage,
428 tunable_torque,
429 "wrote SmartShift config"
430 );
431 Ok(())
432 }
433 Err(err) => Err(err),
434 }
435}