Skip to main content

openlogi_core/hid/
smartshift.rs

1//! HID++ `SmartShift Enhanced` (feature `0x2111`) — wheel ratchet ↔
2//! free-spin control with sensitivity threshold.
3//!
4//! The protocol-level `0x2111` wrapper lives in `openlogi-hidpp`; this module
5//! keeps OpenLogi's IPC/config-facing mode and status types.
6//!
7//! Mode encoding (consistent across 0x2110 / 0x2111):
8//! - `wheelMode` `1` = free-spin (no ratchet, infinite scroll), `2` =
9//!   ratchet (clicky).
10//! - `autoDisengage` `0x01`–`0xFE` = the wheel speed (in 0.25 turn/s steps)
11//!   past which a ratchet-mode wheel releases into free-spin — i.e. the
12//!   "SmartShift" threshold. `0xFF` keeps the ratchet engaged permanently.
13
14use std::{fmt, num::NonZeroU8};
15
16use az::SaturatingAs;
17use num_enum::{IntoPrimitive, TryFromPrimitive};
18use nutype::nutype;
19use serde::{Deserialize, Deserializer, Serialize, Serializer};
20
21/// SmartShift mode values understood by the firmware. `Free` = free-spin,
22/// `Ratchet` = clicky / smartshift-off. The discriminant is the wire byte;
23/// reserved values (`0` / `3` / future) fail [`TryFrom`] and callers fall back
24/// to whatever they consider sane.
25///
26/// Also crosses the agent↔GUI IPC — where serde encodes the variant *index*
27/// (Free=0, Ratchet=1), not the `#[repr(u8)]` firmware discriminant — so
28/// variant order is wire format and changes require a `PROTOCOL_VERSION` bump
29/// (guarded by `openlogi-ipc/tests/wire_format.rs`).
30#[derive(
31    Debug, Clone, Copy, PartialEq, Eq, IntoPrimitive, TryFromPrimitive, Serialize, Deserialize,
32)]
33#[repr(u8)]
34pub enum SmartShiftMode {
35    /// Wheel is in free-spin mode.
36    Free = 1,
37    /// Wheel is in ratchet mode.
38    Ratchet = 2,
39}
40
41impl SmartShiftMode {
42    /// The opposite mode — used when toggling SmartShift between free-spin
43    /// and ratchet in the write path.
44    #[must_use]
45    pub fn flipped(self) -> Self {
46        match self {
47            Self::Free => Self::Ratchet,
48            Self::Ratchet => Self::Free,
49        }
50    }
51}
52
53// The config file persists the wheel mode in its own representation
54// (`crate::config::WheelMode`, kept IPC-free); these conversions are the
55// single mapping between the persisted and the wire/firmware form, used by
56// the GUI when committing and by the agent when re-applying after a reconnect.
57impl From<crate::config::WheelMode> for SmartShiftMode {
58    fn from(mode: crate::config::WheelMode) -> Self {
59        match mode {
60            crate::config::WheelMode::Free => Self::Free,
61            crate::config::WheelMode::Ratchet => Self::Ratchet,
62        }
63    }
64}
65
66impl From<SmartShiftMode> for crate::config::WheelMode {
67    fn from(mode: SmartShiftMode) -> Self {
68        match mode {
69            SmartShiftMode::Free => Self::Free,
70            SmartShiftMode::Ratchet => Self::Ratchet,
71        }
72    }
73}
74
75/// A SmartShift auto-disengage speed threshold in firmware units of 0.25 turn/s.
76///
77/// Zero is HID++'s write-only "preserve" sentinel and `0xFF` means permanent
78/// ratchet, so neither can inhabit this type.
79#[nutype(
80    const_fn,
81    validate(greater_or_equal = 1, less_or_equal = 254),
82    derive(
83        Debug,
84        Clone,
85        Copy,
86        PartialEq,
87        Eq,
88        PartialOrd,
89        Ord,
90        TryFrom,
91        Into,
92        Display,
93        Serialize,
94        Deserialize
95    )
96)]
97pub struct SmartShiftThreshold(u8);
98
99impl SmartShiftThreshold {
100    /// Round and clamp a floating-point control value into the firmware range.
101    #[must_use]
102    pub fn from_rounded(value: f32) -> Self {
103        let value = if value.is_nan() { 1.0 } else { value };
104        let raw = value.clamp(1.0, 254.0).round().saturating_as::<u8>();
105        let Ok(value) = Self::try_new(raw) else {
106            unreachable!("clamped SmartShift threshold is always valid");
107        };
108        value
109    }
110}
111
112impl From<SmartShiftThreshold> for f32 {
113    fn from(threshold: SmartShiftThreshold) -> Self {
114        Self::from(threshold.into_inner())
115    }
116}
117
118/// SmartShift's auto-disengage behavior.
119///
120/// Its serde representation remains the HID++ byte (`1..=254` for a threshold,
121/// `255` for permanent ratchet), preserving the existing IPC and TOML shapes.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum SmartShiftAutoDisengage {
124    /// Auto-release the ratchet when wheel speed crosses this threshold.
125    Threshold(SmartShiftThreshold),
126    /// Keep the ratchet engaged regardless of wheel speed.
127    Permanent,
128}
129
130impl SmartShiftAutoDisengage {
131    /// Whether this setting keeps the ratchet permanently engaged.
132    #[must_use]
133    pub const fn is_permanent(self) -> bool {
134        matches!(self, Self::Permanent)
135    }
136
137    /// The speed threshold, or `None` for permanent ratchet.
138    #[must_use]
139    pub const fn threshold(self) -> Option<SmartShiftThreshold> {
140        match self {
141            Self::Threshold(threshold) => Some(threshold),
142            Self::Permanent => None,
143        }
144    }
145}
146
147impl TryFrom<u8> for SmartShiftAutoDisengage {
148    type Error = SmartShiftThresholdError;
149
150    fn try_from(value: u8) -> Result<Self, Self::Error> {
151        if value == u8::MAX {
152            Ok(Self::Permanent)
153        } else {
154            SmartShiftThreshold::try_from(value).map(Self::Threshold)
155        }
156    }
157}
158
159impl From<SmartShiftAutoDisengage> for u8 {
160    fn from(auto_disengage: SmartShiftAutoDisengage) -> Self {
161        match auto_disengage {
162            SmartShiftAutoDisengage::Threshold(threshold) => threshold.into_inner(),
163            SmartShiftAutoDisengage::Permanent => Self::MAX,
164        }
165    }
166}
167
168impl From<NonZeroU8> for SmartShiftAutoDisengage {
169    fn from(value: NonZeroU8) -> Self {
170        if value == NonZeroU8::MAX {
171            Self::Permanent
172        } else {
173            let Ok(threshold) = SmartShiftThreshold::try_new(value.get()) else {
174                unreachable!("non-zero SmartShift values below 255 are thresholds");
175            };
176            Self::Threshold(threshold)
177        }
178    }
179}
180
181impl From<SmartShiftAutoDisengage> for NonZeroU8 {
182    fn from(auto_disengage: SmartShiftAutoDisengage) -> Self {
183        match auto_disengage {
184            SmartShiftAutoDisengage::Threshold(threshold) => {
185                let Some(value) = Self::new(threshold.into_inner()) else {
186                    unreachable!("SmartShift thresholds are non-zero");
187                };
188                value
189            }
190            SmartShiftAutoDisengage::Permanent => Self::MAX,
191        }
192    }
193}
194
195impl fmt::Display for SmartShiftAutoDisengage {
196    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
197        u8::from(*self).fmt(formatter)
198    }
199}
200
201impl Serialize for SmartShiftAutoDisengage {
202    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
203    where
204        S: Serializer,
205    {
206        serializer.serialize_u8((*self).into())
207    }
208}
209
210impl<'de> Deserialize<'de> for SmartShiftAutoDisengage {
211    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
212    where
213        D: Deserializer<'de>,
214    {
215        Self::try_from(u8::deserialize(deserializer)?).map_err(serde::de::Error::custom)
216    }
217}
218
219/// A non-zero tunable-torque level reported by SmartShift Enhanced.
220///
221/// Devices without tunable-torque hardware represent that absence as zero;
222/// [`SmartShiftStatus`] exposes it as `None` instead.
223#[nutype(
224    const_fn,
225    validate(greater_or_equal = 1),
226    derive(
227        Debug,
228        Clone,
229        Copy,
230        PartialEq,
231        Eq,
232        PartialOrd,
233        Ord,
234        TryFrom,
235        Into,
236        Display,
237        Serialize,
238        Deserialize
239    )
240)]
241pub struct TunableTorque(u8);
242
243impl From<TunableTorque> for NonZeroU8 {
244    fn from(torque: TunableTorque) -> Self {
245        let Some(value) = Self::new(torque.into_inner()) else {
246            unreachable!("tunable torque is non-zero");
247        };
248        value
249    }
250}
251
252pub(crate) mod optional_tunable_torque {
253    use super::TunableTorque;
254    use serde::{Deserialize, Deserializer, Serializer};
255
256    #[expect(
257        clippy::ref_option,
258        clippy::trivially_copy_pass_by_ref,
259        reason = "serde field serializers must receive the field by reference"
260    )]
261    pub(crate) fn serialize<S>(
262        torque: &Option<TunableTorque>,
263        serializer: S,
264    ) -> Result<S::Ok, S::Error>
265    where
266        S: Serializer,
267    {
268        serializer.serialize_u8(torque.map_or(0, TunableTorque::into_inner))
269    }
270
271    pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Option<TunableTorque>, D::Error>
272    where
273        D: Deserializer<'de>,
274    {
275        let value = u8::deserialize(deserializer)?;
276        if value == 0 {
277            Ok(None)
278        } else {
279            TunableTorque::try_from(value)
280                .map(Some)
281                .map_err(serde::de::Error::custom)
282        }
283    }
284}
285
286/// Snapshot returned from OpenLogi's SmartShift read helpers.
287///
288/// Crosses the agent↔GUI IPC (`read_smartshift`), so field order is wire
289/// format — changes require a `PROTOCOL_VERSION` bump (guarded by
290/// `openlogi-ipc/tests/wire_format.rs`).
291#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
292pub struct SmartShiftStatus {
293    /// Current wheel mode.
294    pub mode: SmartShiftMode,
295    /// SmartShift speed threshold or permanent-ratchet behavior.
296    pub auto_disengage: SmartShiftAutoDisengage,
297    /// Tunable-torque level, or `None` when the device doesn't support it.
298    /// Read back and re-sent unchanged so adjusting the mode or threshold
299    /// doesn't disturb the wheel's resistance.
300    #[serde(with = "optional_tunable_torque")]
301    pub tunable_torque: Option<TunableTorque>,
302}
303
304impl From<crate::config::SmartShift> for SmartShiftStatus {
305    fn from(config: crate::config::SmartShift) -> Self {
306        Self {
307            mode: config.mode.into(),
308            auto_disengage: config.auto_disengage,
309            tunable_torque: config.tunable_torque,
310        }
311    }
312}
313
314impl From<SmartShiftStatus> for crate::config::SmartShift {
315    fn from(status: SmartShiftStatus) -> Self {
316        Self {
317            mode: status.mode.into(),
318            auto_disengage: status.auto_disengage,
319            tunable_torque: status.tunable_torque,
320        }
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[test]
329    fn flipped_is_an_involution() {
330        assert_eq!(SmartShiftMode::Free.flipped(), SmartShiftMode::Ratchet);
331        assert_eq!(SmartShiftMode::Ratchet.flipped(), SmartShiftMode::Free);
332        assert_eq!(
333            SmartShiftMode::Free.flipped().flipped(),
334            SmartShiftMode::Free
335        );
336    }
337
338    #[test]
339    fn auto_disengage_reserves_zero_and_models_permanent_ratchet()
340    -> Result<(), SmartShiftThresholdError> {
341        let Err(_) = SmartShiftAutoDisengage::try_from(0) else {
342            panic!("zero is the write-only preserve sentinel");
343        };
344        assert_eq!(
345            SmartShiftAutoDisengage::try_from(16),
346            Ok(SmartShiftAutoDisengage::Threshold(
347                SmartShiftThreshold::try_new(16)?
348            ))
349        );
350        assert_eq!(
351            SmartShiftAutoDisengage::try_from(0xff),
352            Ok(SmartShiftAutoDisengage::Permanent)
353        );
354        Ok(())
355    }
356
357    #[test]
358    fn floating_thresholds_round_and_saturate_into_the_domain() {
359        assert_eq!(u8::from(SmartShiftThreshold::from_rounded(15.6)), 16);
360        assert_eq!(u8::from(SmartShiftThreshold::from_rounded(f32::NAN)), 1);
361        assert_eq!(
362            u8::from(SmartShiftThreshold::from_rounded(f32::NEG_INFINITY)),
363            1
364        );
365        assert_eq!(
366            u8::from(SmartShiftThreshold::from_rounded(f32::INFINITY)),
367            254
368        );
369    }
370}