Skip to main content

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