Skip to main content

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}