Skip to main content

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}