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