Skip to main content

openlogi_hid/
write.rs

1//! HID++ writes back to the device — DPI and SmartShift.
2//!
3//! Each entry point takes a [`DeviceRoute`] and resolves it to an open channel
4//! through [`open_route_channel`], so the same call works whether the device is
5//! behind a Bolt receiver or attached directly (USB cable / Bluetooth). Each
6//! call re-enumerates and re-opens — fine at the frequency this is invoked
7//! (once per slider release) — unless a [`SharedChannel`] from the capture
8//! session is reused.
9
10use std::num::NonZeroU8;
11use std::sync::Arc;
12use std::time::Duration;
13
14use async_hid::AsyncHidWrite;
15use hidpp::{
16    channel::HidppChannel,
17    device::Device,
18    feature::CreatableFeature,
19    feature::adjustable_dpi::AdjustableDpiFeature,
20    feature::smartshift::{SmartShiftFeature, WheelMode},
21    protocol::v20::{ErrorType, Hidpp20Error},
22};
23use serde::{Deserialize, Serialize};
24use thiserror::Error;
25use tracing::debug;
26
27use crate::route::{DeviceRoute, open_route_channel};
28use crate::smartshift::{SmartShiftFeatureV0, SmartShiftMode, SmartShiftStatus};
29
30// Serializable + Clone so it can cross the agent↔GUI IPC unchanged: the GUI
31// classifies a device read/write error as permanent (FeatureUnsupported /
32// EmptyDpiList) vs transient, so the discriminating variant must survive the
33// wire — stringifying it would collapse every case to "transient" and a device
34// that genuinely lacks a feature would be re-probed forever. Variant order is
35// therefore wire format: changes require a `PROTOCOL_VERSION` bump (guarded
36// by `openlogi-agent-core/tests/wire_format.rs`).
37#[derive(Debug, Clone, Error, Serialize, Deserialize)]
38pub enum WriteError {
39    // `async_hid::HidError` isn't `Serialize`, so carry its message as text; the
40    // typed error is never matched on (only constructed + displayed).
41    #[error("HID transport error: {0}")]
42    Hid(String),
43    #[error("no connected device matched the route")]
44    DeviceNotFound,
45    #[error("device at index {index:#04x} did not respond to HID++")]
46    DeviceUnreachable { index: u8 },
47    #[error("device does not expose HID++ feature {feature_hex:#06x}")]
48    FeatureUnsupported { feature_hex: u16 },
49    #[error("device returned no supported DPI values")]
50    EmptyDpiList,
51    #[error("HID++ protocol error: {0}")]
52    Hidpp(String),
53}
54
55impl From<async_hid::HidError> for WriteError {
56    fn from(e: async_hid::HidError) -> Self {
57        Self::Hid(e.to_string())
58    }
59}
60
61/// Supported DPI values reported by a device's HID++ AdjustableDpi feature.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct DpiCapabilities {
64    values: Vec<u16>,
65}
66
67impl DpiCapabilities {
68    /// Build capabilities from a device-reported DPI list. Values are sorted
69    /// and deduplicated so callers can rely on stable ordering.
70    pub fn new(mut values: Vec<u16>) -> Result<Self, WriteError> {
71        values.sort_unstable();
72        values.dedup();
73        if values.is_empty() {
74            return Err(WriteError::EmptyDpiList);
75        }
76        Ok(Self { values })
77    }
78
79    /// All supported DPI values, sorted ascending.
80    #[must_use]
81    pub fn values(&self) -> &[u16] {
82        &self.values
83    }
84
85    /// Minimum supported DPI.
86    #[must_use]
87    pub fn min(&self) -> u16 {
88        self.values[0]
89    }
90
91    /// Maximum supported DPI.
92    #[must_use]
93    pub fn max(&self) -> u16 {
94        self.values[self.values.len() - 1]
95    }
96
97    /// Whether `dpi` is exactly supported by the device.
98    #[must_use]
99    pub fn contains(&self, dpi: u16) -> bool {
100        self.values.binary_search(&dpi).is_ok()
101    }
102
103    /// The supported DPI nearest to `dpi`.
104    #[must_use]
105    pub fn nearest(&self, dpi: u32) -> u16 {
106        let mut nearest = self.values[0];
107        let mut best_delta = u32::from(nearest).abs_diff(dpi);
108        for &candidate in &self.values[1..] {
109            let delta = u32::from(candidate).abs_diff(dpi);
110            if delta < best_delta {
111                nearest = candidate;
112                best_delta = delta;
113            }
114        }
115        nearest
116    }
117
118    /// Snap `dpi` to the nearest supported value, widened to `u32` for UI math.
119    /// The single home for "round a DPI onto this device's grid" — callers that
120    /// hold an `Option<DpiCapabilities>` should `map_or(dpi, |c| c.snap(dpi))`.
121    #[must_use]
122    pub fn snap(&self, dpi: u32) -> u32 {
123        u32::from(self.nearest(dpi))
124    }
125
126    /// Best-effort step size for UI widgets that need a single increment.
127    /// Returns the smallest positive gap between adjacent reported values.
128    #[must_use]
129    pub fn step_hint(&self) -> u16 {
130        self.values
131            .windows(2)
132            .filter_map(|pair| pair[1].checked_sub(pair[0]))
133            .filter(|step| *step > 0)
134            .min()
135            .unwrap_or(1)
136    }
137
138    /// A supported value different from `current`, for diagnostic write tests.
139    #[must_use]
140    pub fn adjacent_test_target(&self, current: u16) -> Option<u16> {
141        if self.values.len() < 2 {
142            return None;
143        }
144        match self.values.binary_search(&current) {
145            Ok(index) if index + 1 < self.values.len() => Some(self.values[index + 1]),
146            Ok(index) if index > 0 => Some(self.values[index - 1]),
147            Ok(_) => None,
148            Err(index) if index < self.values.len() => Some(self.values[index]),
149            Err(_) => self.values.last().copied(),
150        }
151        .filter(|target| *target != current)
152    }
153}
154
155/// Current DPI plus the supported values reported by the device.
156///
157/// Crosses the agent↔GUI IPC (`read_dpi`, [`DpiCapabilities`] included), so
158/// field order is wire format — changes require a `PROTOCOL_VERSION` bump
159/// (guarded by `openlogi-agent-core/tests/wire_format.rs`).
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct DpiInfo {
162    /// DPI currently configured on sensor 0.
163    pub current: u16,
164    /// Supported values reported by the device for sensor 0.
165    pub capabilities: DpiCapabilities,
166}
167
168/// Snapshot of one HID++ feature exposed by a device: protocol ID +
169/// version. Returned by [`dump_features`] for diagnostics.
170#[derive(Debug, Clone, Copy)]
171pub struct FeatureEntry {
172    pub id: u16,
173    pub version: u8,
174}
175
176/// Enumerate every HID++ feature the device on `route` reports — used by
177/// `openlogi diag features` to confirm which DPI / SmartShift / etc.
178/// feature IDs a given peripheral actually exposes (e.g. some mice use
179/// `0x2202 ExtendedAdjustableDpi` instead of `0x2201 AdjustableDpi`).
180pub async fn dump_features(route: &DeviceRoute) -> Result<Vec<FeatureEntry>, WriteError> {
181    use hidpp::feature::feature_set::FeatureSetFeature;
182    let index = route.device_index();
183    with_route(route, move |channel| async move {
184        let mut device = Device::new(Arc::clone(&channel), index)
185            .await
186            .map_err(|_| WriteError::DeviceUnreachable { index })?;
187        // The root feature exposes the FeatureSet (0x0001) at a fixed
188        // address; we look it up directly rather than going through
189        // `enumerate_features` so the iteration is observable.
190        let feature_set_info = device
191            .root()
192            .get_feature(FeatureSetFeature::ID)
193            .await
194            .map_err(|e| WriteError::Hidpp(format!("{e:?}")))?
195            .ok_or(WriteError::FeatureUnsupported {
196                feature_hex: FeatureSetFeature::ID,
197            })?;
198        let feature_set = device.add_feature::<FeatureSetFeature>(feature_set_info.index);
199        let count = feature_set
200            .count()
201            .await
202            .map_err(|e| WriteError::Hidpp(format!("{e:?}")))?;
203        let mut entries = Vec::with_capacity(usize::from(count));
204        for i in 0..=count {
205            let info = feature_set
206                .get_feature(i)
207                .await
208                .map_err(|e| WriteError::Hidpp(format!("{e:?}")))?;
209            entries.push(FeatureEntry {
210                id: info.id,
211                version: info.version,
212            });
213        }
214        Ok(entries)
215    })
216    .await
217}
218
219/// Look up `F` on a device by HID++ feature ID, register it with
220/// [`Device::add_feature`], and return the typed wrapper.
221///
222/// The direct lookup via `root().get_feature(id)` returns the assigned index
223/// unconditionally; `add_feature` then attaches our wrapper to that index. This
224/// keeps route-based write/read paths independent from full feature-table
225/// enumeration and also works for feature wrappers that are not in the central
226/// registry yet.
227async fn open_feature<F: CreatableFeature + 'static>(
228    device: &mut Device,
229) -> Result<Arc<F>, WriteError> {
230    let info = device
231        .root()
232        .get_feature(F::ID)
233        .await
234        .map_err(|e| WriteError::Hidpp(format!("{e:?}")))?
235        .ok_or(WriteError::FeatureUnsupported { feature_hex: F::ID })?;
236    Ok(device.add_feature::<F>(info.index))
237}
238
239/// Whether a failure to open the `0x2111` Enhanced SmartShift feature should
240/// trigger the `0x2110` legacy fallback. Only a missing-`0x2111` feature
241/// qualifies; transport and protocol errors propagate unchanged so a real
242/// failure is never masked by a second open attempt.
243fn is_missing_enhanced(err: &WriteError) -> bool {
244    matches!(
245        err,
246        WriteError::FeatureUnsupported { feature_hex } if *feature_hex == 0x2111
247    )
248}
249
250/// Map the fork's `0x2110` [`WheelMode`] onto OpenLogi's [`SmartShiftMode`].
251/// A future `#[non_exhaustive]` variant maps to [`SmartShiftMode::Ratchet`],
252/// the "safe" clicky default OpenLogi uses elsewhere. (Reserved wire bytes
253/// never reach here — the fork's `get_ratchet_control_mode` rejects them.)
254fn wheel_mode_to_smartshift(wheel: WheelMode) -> SmartShiftMode {
255    if matches!(wheel, WheelMode::Freespin) {
256        SmartShiftMode::Free
257    } else {
258        SmartShiftMode::Ratchet
259    }
260}
261
262/// Map OpenLogi's [`SmartShiftMode`] onto the fork's `0x2110` [`WheelMode`] —
263/// the inverse of [`wheel_mode_to_smartshift`], used when writing the legacy
264/// ratchet-control mode.
265fn smartshift_to_wheel(mode: SmartShiftMode) -> WheelMode {
266    match mode {
267        SmartShiftMode::Free => WheelMode::Freespin,
268        SmartShiftMode::Ratchet => WheelMode::Ratchet,
269    }
270}
271
272/// Whichever SmartShift feature a device exposes, normalised onto
273/// [`SmartShiftMode`]. Devices ship one or the other: MX Master 3 / 3S use the
274/// `0x2111` Enhanced variant, the MX Master 2S uses the original `0x2110`.
275enum SmartShift {
276    /// `0x2111 SmartShiftWheelEnhanced`.
277    Enhanced(Arc<SmartShiftFeatureV0>),
278    /// `0x2110 SmartShiftWheel`.
279    Legacy(Arc<SmartShiftFeature>),
280}
281
282impl SmartShift {
283    /// Open whichever SmartShift feature the device exposes. Tries `0x2111`
284    /// first; on a missing-`0x2111` error (and only that), retries with
285    /// `0x2110`. Any other error from the first attempt propagates unchanged.
286    async fn open(device: &mut Device) -> Result<Self, WriteError> {
287        match open_feature::<SmartShiftFeatureV0>(device).await {
288            Ok(feature) => Ok(Self::Enhanced(feature)),
289            Err(err) if is_missing_enhanced(&err) => {
290                let feature = open_feature::<SmartShiftFeature>(device).await?;
291                Ok(Self::Legacy(feature))
292            }
293            Err(err) => Err(err),
294        }
295    }
296
297    /// Read the current mode + auto-disengage threshold. Enhanced (`0x2111`)
298    /// also reports tunable torque; Legacy (`0x2110`) has no such concept, so
299    /// `tunable_torque` is reported as `0` per [`SmartShiftStatus`]'s contract.
300    async fn status(&self) -> Result<SmartShiftStatus, WriteError> {
301        match self {
302            Self::Enhanced(feature) => feature
303                .get_status()
304                .await
305                .map_err(|e| WriteError::Hidpp(format!("{e:?}"))),
306            Self::Legacy(feature) => {
307                let rcm = feature
308                    .get_ratchet_control_mode()
309                    .await
310                    .map_err(|e| WriteError::Hidpp(format!("{e:?}")))?;
311                Ok(SmartShiftStatus {
312                    mode: wheel_mode_to_smartshift(rcm.wheel_mode),
313                    auto_disengage: rcm.auto_disengage,
314                    // 0x2110 has no tunable-torque function; report 0 like
315                    // `SmartShiftStatus::tunable_torque` documents for devices
316                    // that don't support it.
317                    tunable_torque: 0,
318                })
319            }
320        }
321    }
322
323    /// Write a full desired status. Enhanced (`0x2111`) takes mode +
324    /// auto-disengage + tunable torque directly. Legacy (`0x2110`) has no
325    /// tunable-torque function, so that field is ignored; the wheel mode and
326    /// auto-disengage threshold are written explicitly — never relying on the
327    /// device treating a `None`/`0` field as "keep current".
328    async fn set_status(&self, status: SmartShiftStatus) -> Result<(), WriteError> {
329        let SmartShiftStatus {
330            mode,
331            auto_disengage,
332            tunable_torque,
333        } = status;
334        match self {
335            Self::Enhanced(feature) => feature
336                .set_status(mode, auto_disengage, tunable_torque)
337                .await
338                .map_err(|e| WriteError::Hidpp(format!("{e:?}"))),
339            Self::Legacy(feature) => feature
340                .set_ratchet_control_mode(
341                    Some(smartshift_to_wheel(mode)),
342                    Some(auto_disengage),
343                    None,
344                )
345                .await
346                .map_err(|e| WriteError::Hidpp(format!("{e:?}"))),
347        }
348    }
349
350    /// Write a new auto-disengage `sensitivity`, preserving the current mode
351    /// (and, on Enhanced, the tunable torque). Reads the current status first
352    /// so every preserved field is written back explicitly. The [`NonZeroU8`]
353    /// rules out `0`, which the device would treat as "no change" — a silent
354    /// non-write rather than a real sensitivity update.
355    async fn set_sensitivity(&self, value: NonZeroU8) -> Result<(), WriteError> {
356        let current = self.status().await?;
357        self.set_status(SmartShiftStatus {
358            auto_disengage: value.get(),
359            ..current
360        })
361        .await
362    }
363}
364
365/// Read the device's current DPI on sensor 0 — companion to [`set_dpi`].
366/// Used by `openlogi diag dpi` and any future Settings → Diagnostics
367/// surface that wants to display the current value without writing.
368pub async fn get_dpi(route: &DeviceRoute) -> Result<u16, WriteError> {
369    let index = route.device_index();
370    with_route(route, move |channel| async move {
371        let mut device = Device::new(Arc::clone(&channel), index)
372            .await
373            .map_err(|_| WriteError::DeviceUnreachable { index })?;
374        let feature = open_feature::<AdjustableDpiFeature>(&mut device).await?;
375        feature
376            .get_sensor_dpi(0)
377            .await
378            .map_err(|e| WriteError::Hidpp(format!("{e:?}")))
379    })
380    .await
381}
382
383/// Classify a HID++ error from the AdjustableDpi functions. A device that
384/// announces `0x2201` but rejects a function (`Unsupported` /
385/// `InvalidFunctionId`) or returns a structurally invalid DPI list
386/// (`UnsupportedResponse`) will keep doing so, so these map to the permanent
387/// [`WriteError::FeatureUnsupported`]; channel/timeout errors stay transient
388/// [`WriteError::Hidpp`] so callers may retry.
389fn classify_dpi_error(error: Hidpp20Error) -> WriteError {
390    match error {
391        Hidpp20Error::Feature(ErrorType::Unsupported | ErrorType::InvalidFunctionId)
392        | Hidpp20Error::UnsupportedResponse => WriteError::FeatureUnsupported {
393            feature_hex: AdjustableDpiFeature::ID,
394        },
395        other => WriteError::Hidpp(format!("{other:?}")),
396    }
397}
398
399/// Read the current DPI and the supported DPI values for sensor 0 in one
400/// route/channel session.
401pub async fn get_dpi_info(route: &DeviceRoute) -> Result<DpiInfo, WriteError> {
402    let index = route.device_index();
403    with_route(route, move |channel| async move {
404        let mut device = Device::new(Arc::clone(&channel), index)
405            .await
406            .map_err(|_| WriteError::DeviceUnreachable { index })?;
407        let feature = open_feature::<AdjustableDpiFeature>(&mut device).await?;
408        let sensor_count = feature
409            .get_sensor_count()
410            .await
411            .map_err(classify_dpi_error)?;
412        if sensor_count == 0 {
413            // The device claims AdjustableDpi but exposes no sensor — it cannot
414            // report DPI, and that won't change on retry.
415            return Err(WriteError::FeatureUnsupported {
416                feature_hex: AdjustableDpiFeature::ID,
417            });
418        }
419        let current = feature
420            .get_sensor_dpi(0)
421            .await
422            .map_err(classify_dpi_error)?;
423        let values = feature
424            .get_sensor_dpi_list(0)
425            .await
426            .map_err(classify_dpi_error)?;
427        Ok(DpiInfo {
428            current,
429            capabilities: DpiCapabilities::new(values)?,
430        })
431    })
432    .await
433}
434
435/// Read the device's current SmartShift mode + sensitivity — companion to
436/// [`toggle_smartshift`].
437pub async fn get_smartshift_status(route: &DeviceRoute) -> Result<SmartShiftStatus, WriteError> {
438    let index = route.device_index();
439    with_route(route, move |channel| async move {
440        let mut device = Device::new(Arc::clone(&channel), index)
441            .await
442            .map_err(|_| WriteError::DeviceUnreachable { index })?;
443        let smartshift = SmartShift::open(&mut device).await?;
444        smartshift.status().await
445    })
446    .await
447}
448
449/// Set the SmartShift auto-disengage sensitivity on `route`, preserving the
450/// current mode. Returns the read-back status after the write so the caller can
451/// display and verify it.
452///
453/// `value` is written verbatim: `0x01..=0xfe` is the auto-disengage threshold
454/// (smaller = releases sooner / more sensitive) and `0xff` is permanent ratchet.
455/// The [`NonZeroU8`] parameter rules out `0` at the type level — the device
456/// treats a `0` threshold as "no change", so it could never be a real write.
457///
458/// `FeatureUnsupported` when the device exposes neither HID++ `0x2111`
459/// (MX Master 3 / 3S) nor the older `0x2110` (MX Master 2S).
460pub async fn set_smartshift_sensitivity(
461    route: &DeviceRoute,
462    value: NonZeroU8,
463) -> Result<SmartShiftStatus, WriteError> {
464    let index = route.device_index();
465    with_route(route, move |channel| async move {
466        let mut device = Device::new(Arc::clone(&channel), index)
467            .await
468            .map_err(|_| WriteError::DeviceUnreachable { index })?;
469        let smartshift = SmartShift::open(&mut device).await?;
470        smartshift.set_sensitivity(value).await?;
471        smartshift.status().await
472    })
473    .await
474}
475
476pub async fn set_dpi(route: &DeviceRoute, dpi: u16) -> Result<(), WriteError> {
477    let index = route.device_index();
478    with_route(route, move |channel| async move {
479        set_dpi_on_channel(&channel, index, dpi).await
480    })
481    .await
482}
483
484/// HID++ `PerKeyLighting` (`0x8080`) — streams each key's colour individually.
485/// Its feature *index* varies per device, so it's resolved at runtime.
486const PER_KEY_LIGHTING_FEATURE: u16 = 0x8080;
487/// HID++ `ColorLedEffects` (`0x8070`) — the keyboard's effect engine. Writing a
488/// *fixed* effect here replaces a running onboard profile, which a per-key
489/// (`0x8080`) write can't override on G-series keyboards (the firmware keeps
490/// replaying its stored effect). Preferred for a solid colour for that reason.
491const COLOR_LED_EFFECTS_FEATURE: u16 = 0x8070;
492
493// HID++ 2.0 report ids: 0x12 is the 64-byte "very long" report that streams a
494// batch of (keyID, R, G, B) entries; 0x11 is the 20-byte "long" report used both
495// to commit a per-key frame and to carry a single `ColorLedEffects` request.
496const REPORT_SET_KEYS: u8 = 0x12;
497const REPORT_LONG: u8 = 0x11;
498// Function byte = `function_id << 4 | software_id`. Software id 0xa just tags our
499// requests; for 0x8080: function 0x3 streams a key range, 0x5 commits the frame.
500const SW_ID: u8 = 0x0a;
501const FN_SET_KEY_RANGE: u8 = 0x3;
502const FN_FRAME_END: u8 = 0x5;
503// Fixed bytes of the "set key range" payload: a mode flag (byte 5) and the
504// per-frame entry count (byte 7), which is also the chunk size below.
505const SET_RANGE_MODE: u8 = 0x01;
506const KEYS_PER_FRAME: u8 = 0x0e;
507
508// 0x8070 `ColorLedEffects`: function 0x3 is `setZoneEffect(zone, effect, …)`.
509// Effect 0x01 is the fixed/static single colour. The trailing persistence byte
510// is RAM-only (0x00): the effect shows live and overrides the running onboard
511// profile without touching flash. Reboot survival comes from the agent
512// re-applying the saved colour on device arrival (orchestrator reapply), so
513// flashing on every colour pick — which would wear the controller — is avoided.
514const FN_SET_ZONE_EFFECT: u8 = 0x3;
515const EFFECT_FIXED: u8 = 0x01;
516const PERSIST_RAM_ONLY: u8 = 0x00;
517// G-series report a small zone count; writing a few covers every real zone (a
518// write to a non-existent zone is a harmless no-op). Paced because the
519// controller drops back-to-back reports.
520const MAX_LIGHTING_ZONES: u8 = 4;
521const FRAME_GAP: Duration = Duration::from_millis(8);
522
523/// Which HID++ lighting path drives a solid keyboard colour. [`Auto`] is what
524/// the GUI/agent use; the explicit variants exist for the `diag` A/B test.
525///
526/// [`Auto`]: LightingMethod::Auto
527#[derive(Debug, Clone, Copy, PartialEq, Eq)]
528pub enum LightingMethod {
529    /// Prefer `ColorLedEffects` (`0x8070`), falling back to `PerKeyLighting`
530    /// (`0x8080`) when the device exposes no effect engine.
531    Auto,
532    /// Force `ColorLedEffects` (`0x8070`) — the fixed-effect override.
533    Effects,
534    /// Force `PerKeyLighting` (`0x8080`) — the per-key stream.
535    PerKey,
536}
537
538/// Set a keyboard to a solid `(r, g, b)` colour, choosing the HID++ path
539/// automatically: the `0x8070` effect engine (which overrides the onboard
540/// profile) when present, else the `0x8080` per-key stream. `FeatureUnsupported`
541/// when the device exposes neither.
542pub async fn set_keyboard_color(
543    route: &DeviceRoute,
544    r: u8,
545    g: u8,
546    b: u8,
547) -> Result<(), WriteError> {
548    set_keyboard_color_with(route, LightingMethod::Auto, r, g, b).await
549}
550
551/// [`set_keyboard_color`] with an explicit [`LightingMethod`]. `Auto` tries
552/// `0x8070` first and falls back to `0x8080` only when the effect engine is
553/// absent (a missing-`0x8070` `FeatureUnsupported`); any other error propagates.
554pub async fn set_keyboard_color_with(
555    route: &DeviceRoute,
556    method: LightingMethod,
557    r: u8,
558    g: u8,
559    b: u8,
560) -> Result<(), WriteError> {
561    match method {
562        LightingMethod::PerKey => set_color_per_key(route, r, g, b).await,
563        LightingMethod::Effects => set_color_effects(route, r, g, b).await,
564        LightingMethod::Auto => match set_color_effects(route, r, g, b).await {
565            Err(WriteError::FeatureUnsupported { feature_hex })
566                if feature_hex == COLOR_LED_EFFECTS_FEATURE =>
567            {
568                debug!("no 0x8070 effect engine — falling back to 0x8080 per-key");
569                set_color_per_key(route, r, g, b).await
570            }
571            other => other,
572        },
573    }
574}
575
576/// Resolve `route`'s runtime feature *index* for HID++ `feature_id`. `Ok(None)`
577/// when the device doesn't expose it; the index differs per device, so callers
578/// can't hard-code it.
579async fn resolve_feature_index(
580    route: &DeviceRoute,
581    feature_id: u16,
582) -> Result<Option<u8>, WriteError> {
583    let device_index = route.device_index();
584    with_route(route, move |channel| async move {
585        let device = Device::new(Arc::clone(&channel), device_index)
586            .await
587            .map_err(|_| WriteError::DeviceUnreachable {
588                index: device_index,
589            })?;
590        let info = device
591            .root()
592            .get_feature(feature_id)
593            .await
594            .map_err(|e| WriteError::Hidpp(format!("{e:?}")))?;
595        Ok(info.map(|i| i.index))
596    })
597    .await
598}
599
600/// Set a solid colour via `ColorLedEffects` (`0x8070`): a fixed effect per zone,
601/// stored in RAM only (overrides the running onboard profile without touching
602/// flash). `FeatureUnsupported` when the device exposes no `0x8070`.
603async fn set_color_effects(route: &DeviceRoute, r: u8, g: u8, b: u8) -> Result<(), WriteError> {
604    let device_index = route.device_index();
605    let feature_index = resolve_feature_index(route, COLOR_LED_EFFECTS_FEATURE)
606        .await?
607        .ok_or(WriteError::FeatureUnsupported {
608            feature_hex: COLOR_LED_EFFECTS_FEATURE,
609        })?;
610
611    let Some(mut writer) = crate::transport::open_route_writer(route).await? else {
612        return Err(WriteError::DeviceNotFound);
613    };
614    for zone in 0..MAX_LIGHTING_ZONES {
615        let mut rep = vec![0u8; 20];
616        rep[0] = REPORT_LONG;
617        rep[1] = device_index;
618        rep[2] = feature_index;
619        rep[3] = (FN_SET_ZONE_EFFECT << 4) | SW_ID;
620        rep[4] = zone;
621        rep[5] = EFFECT_FIXED;
622        rep[6] = r;
623        rep[7] = g;
624        rep[8] = b;
625        rep[16] = PERSIST_RAM_ONLY;
626        writer
627            .write_output_report(&rep)
628            .await
629            .map_err(WriteError::from)?;
630        tokio::time::sleep(FRAME_GAP).await;
631    }
632    debug!(
633        device_index,
634        feature_index, r, g, b, "set keyboard colour via 0x8070"
635    );
636    Ok(())
637}
638
639/// Set a solid colour via `PerKeyLighting` (`0x8080`): stream every key's colour
640/// in 64-byte `0x12` frames, then commit. `FeatureUnsupported` when the device
641/// exposes no `0x8080`.
642async fn set_color_per_key(route: &DeviceRoute, r: u8, g: u8, b: u8) -> Result<(), WriteError> {
643    let device_index = route.device_index();
644    let feature_index = resolve_feature_index(route, PER_KEY_LIGHTING_FEATURE)
645        .await?
646        .ok_or(WriteError::FeatureUnsupported {
647            feature_hex: PER_KEY_LIGHTING_FEATURE,
648        })?;
649
650    let Some(mut writer) = crate::transport::open_route_writer(route).await? else {
651        return Err(WriteError::DeviceNotFound);
652    };
653    // Each 64-byte `0x12` "set group keys" packet carries up to 14
654    // `(keyID, R, G, B)` entries; keyIDs are HID usage codes. Cover the whole
655    // keyboard usage range (incl. modifiers at `0xe0..`) so every key lights,
656    // then commit the frame.
657    let key_ids: Vec<u8> = (0x00u8..=0xe8).collect();
658    for chunk in key_ids.chunks(KEYS_PER_FRAME as usize) {
659        let mut rep = vec![0u8; 64];
660        rep[0] = REPORT_SET_KEYS;
661        rep[1] = device_index;
662        rep[2] = feature_index;
663        rep[3] = (FN_SET_KEY_RANGE << 4) | SW_ID;
664        rep[5] = SET_RANGE_MODE;
665        rep[7] = KEYS_PER_FRAME;
666        for (i, &key) in chunk.iter().enumerate() {
667            let off = 8 + i * 4;
668            rep[off] = key;
669            rep[off + 1] = r;
670            rep[off + 2] = g;
671            rep[off + 3] = b;
672        }
673        writer
674            .write_output_report(&rep)
675            .await
676            .map_err(WriteError::from)?;
677    }
678    let mut commit = vec![0u8; 20];
679    commit[0] = REPORT_LONG;
680    commit[1] = device_index;
681    commit[2] = feature_index;
682    commit[3] = (FN_FRAME_END << 4) | SW_ID;
683    writer
684        .write_output_report(&commit)
685        .await
686        .map_err(WriteError::from)?;
687    debug!(
688        device_index,
689        feature_index, r, g, b, "set keyboard colour via 0x8080"
690    );
691    Ok(())
692}
693
694/// The DPI write itself, on an already-open channel at HID++ `index`. Shared by
695/// [`set_dpi`] (which opens a fresh channel) and [`set_dpi_on`] (which reuses
696/// one).
697async fn set_dpi_on_channel(
698    channel: &Arc<HidppChannel>,
699    index: u8,
700    dpi: u16,
701) -> Result<(), WriteError> {
702    let mut device = Device::new(Arc::clone(channel), index)
703        .await
704        .map_err(|_| WriteError::DeviceUnreachable { index })?;
705    let feature = open_feature::<AdjustableDpiFeature>(&mut device).await?;
706    feature
707        .set_sensor_dpi(0, dpi)
708        .await
709        .map_err(|e| WriteError::Hidpp(format!("{e:?}")))?;
710    // Read back to confirm the firmware accepted the value. A mismatch is a
711    // silent failure mode that's otherwise invisible — devices in low-power
712    // states or with unsupported DPI ranges can ACK the write yet keep the old
713    // value. We log a warning but still return Ok because the request reached
714    // the device.
715    if let Ok(actual) = feature.get_sensor_dpi(0).await {
716        if actual == dpi {
717            debug!(index, dpi, "wrote DPI (verified)");
718        } else {
719            tracing::warn!(
720                index,
721                requested = dpi,
722                actual,
723                "DPI write accepted but device reports a different value — \
724                 likely out of the device's supported range"
725            );
726        }
727    } else {
728        debug!(index, dpi, "wrote DPI (read-back skipped)");
729    }
730    Ok(())
731}
732
733/// Toggle SmartShift mode (free ↔ ratchet) on `route`. Reads the current
734/// mode first, then writes the opposite — keeps current sensitivity.
735/// Returns the new mode written.
736///
737/// `FeatureUnsupported` when the device exposes neither HID++ `0x2111`
738/// (MX Master 3 / 3S) nor the older `0x2110` (MX Master 2S) — i.e. it has no
739/// SmartShift wheel.
740pub async fn toggle_smartshift(route: &DeviceRoute) -> Result<SmartShiftMode, WriteError> {
741    let index = route.device_index();
742    with_route(route, move |channel| async move {
743        toggle_smartshift_on_channel(&channel, index).await
744    })
745    .await
746}
747
748/// The SmartShift toggle itself, on an already-open channel at HID++ `index`.
749/// Shared by [`toggle_smartshift`] and [`toggle_smartshift_on`].
750async fn toggle_smartshift_on_channel(
751    channel: &Arc<HidppChannel>,
752    index: u8,
753) -> Result<SmartShiftMode, WriteError> {
754    let mut device = Device::new(Arc::clone(channel), index)
755        .await
756        .map_err(|_| WriteError::DeviceUnreachable { index })?;
757    let smartshift = SmartShift::open(&mut device).await?;
758    let status = smartshift.status().await?;
759    let next = status.mode.flipped();
760    smartshift
761        .set_status(SmartShiftStatus {
762            mode: next,
763            ..status
764        })
765        .await?;
766    debug!(index, ?next, "wrote SmartShift mode");
767    Ok(next)
768}
769
770/// Write a full SmartShift configuration — wheel mode, auto-disengage
771/// threshold, and tunable torque — to `route`. The firmware persists all three
772/// to the device's NVM. Callers that mean to change only one field should read
773/// the rest via [`get_smartshift_status`] first and pass them back unchanged.
774/// On a Legacy (`0x2110`) device the `tunable_torque` field is ignored.
775///
776/// `FeatureUnsupported` when the device exposes neither HID++ `0x2111`
777/// (MX Master 3 / 3S) nor the older `0x2110` (MX Master 2S).
778pub async fn set_smartshift(
779    route: &DeviceRoute,
780    mode: SmartShiftMode,
781    auto_disengage: u8,
782    tunable_torque: u8,
783) -> Result<(), WriteError> {
784    let index = route.device_index();
785    with_route(route, move |channel| async move {
786        set_smartshift_on_channel(&channel, index, mode, auto_disengage, tunable_torque).await
787    })
788    .await
789}
790
791/// The SmartShift write itself, on an already-open channel at HID++ `index`.
792/// Shared by [`set_smartshift`] and [`set_smartshift_on`].
793async fn set_smartshift_on_channel(
794    channel: &Arc<HidppChannel>,
795    index: u8,
796    mode: SmartShiftMode,
797    auto_disengage: u8,
798    tunable_torque: u8,
799) -> Result<(), WriteError> {
800    let mut device = Device::new(Arc::clone(channel), index)
801        .await
802        .map_err(|_| WriteError::DeviceUnreachable { index })?;
803    let smartshift = SmartShift::open(&mut device).await?;
804    smartshift
805        .set_status(SmartShiftStatus {
806            mode,
807            auto_disengage,
808            tunable_torque,
809        })
810        .await?;
811    debug!(
812        index,
813        ?mode,
814        auto_disengage,
815        tunable_torque,
816        "wrote SmartShift config"
817    );
818    Ok(())
819}
820
821/// An open HID++ channel to a device, shared so DPI / SmartShift writes can
822/// reuse the capture session's connection instead of re-enumerating and
823/// opening a fresh channel each time (which costs ~100ms+).
824///
825/// Cheap to clone (an `Arc` plus the [`DeviceRoute`] it points at). Built by
826/// the capture session via [`SharedChannel::new`] and stashed in a slot the
827/// GUI's write path consults.
828#[derive(Clone)]
829pub struct SharedChannel {
830    channel: Arc<HidppChannel>,
831    route: DeviceRoute,
832}
833
834impl SharedChannel {
835    /// Wrap an open channel that reaches `route`.
836    #[must_use]
837    pub(crate) fn new(channel: Arc<HidppChannel>, route: DeviceRoute) -> Self {
838        Self { channel, route }
839    }
840
841    /// Whether this channel reaches `route` — so the write path only reuses it
842    /// for the device it actually points at.
843    #[must_use]
844    pub fn matches(&self, route: &DeviceRoute) -> bool {
845        self.route == *route
846    }
847}
848
849/// Write DPI on an already-open [`SharedChannel`] — the fast path that skips
850/// enumeration and channel setup.
851pub async fn set_dpi_on(shared: &SharedChannel, dpi: u16) -> Result<(), WriteError> {
852    set_dpi_on_channel(&shared.channel, shared.route.device_index(), dpi).await
853}
854
855/// Toggle SmartShift on an already-open [`SharedChannel`].
856pub async fn toggle_smartshift_on(shared: &SharedChannel) -> Result<SmartShiftMode, WriteError> {
857    toggle_smartshift_on_channel(&shared.channel, shared.route.device_index()).await
858}
859
860/// Write a full SmartShift configuration on an already-open [`SharedChannel`]
861/// — the fast path that skips enumeration and channel setup.
862pub async fn set_smartshift_on(
863    shared: &SharedChannel,
864    mode: SmartShiftMode,
865    auto_disengage: u8,
866    tunable_torque: u8,
867) -> Result<(), WriteError> {
868    set_smartshift_on_channel(
869        &shared.channel,
870        shared.route.device_index(),
871        mode,
872        auto_disengage,
873        tunable_torque,
874    )
875    .await
876}
877
878/// Boilerplate-eater: open the channel that reaches `route`, then run `f` once
879/// with it. The caller addresses features at [`DeviceRoute::device_index`].
880async fn with_route<F, Fut, T>(route: &DeviceRoute, f: F) -> Result<T, WriteError>
881where
882    F: FnOnce(Arc<HidppChannel>) -> Fut,
883    Fut: std::future::Future<Output = Result<T, WriteError>>,
884{
885    match open_route_channel(route).await? {
886        Some(channel) => f(channel).await,
887        None => Err(WriteError::DeviceNotFound),
888    }
889}
890
891#[cfg(test)]
892mod tests {
893    use super::*;
894
895    #[test]
896    fn capabilities_sort_and_deduplicate_values() -> Result<(), WriteError> {
897        let caps = DpiCapabilities::new(vec![1600, 400, 800, 800])?;
898
899        assert_eq!(caps.values(), [400, 800, 1600]);
900        assert_eq!(caps.min(), 400);
901        assert_eq!(caps.max(), 1600);
902        Ok(())
903    }
904
905    #[test]
906    fn capabilities_reject_empty_list() {
907        assert!(matches!(
908            DpiCapabilities::new(Vec::new()),
909            Err(WriteError::EmptyDpiList)
910        ));
911    }
912
913    #[test]
914    fn nearest_returns_closest_supported_value() -> Result<(), WriteError> {
915        let caps = DpiCapabilities::new(vec![400, 800, 1600])?;
916
917        assert_eq!(caps.nearest(390), 400);
918        assert_eq!(caps.nearest(1000), 800);
919        assert_eq!(caps.nearest(2000), 1600);
920        Ok(())
921    }
922
923    #[test]
924    fn step_hint_returns_smallest_positive_gap() -> Result<(), WriteError> {
925        let caps = DpiCapabilities::new(vec![400, 800, 1200, 2000])?;
926
927        assert_eq!(caps.step_hint(), 400);
928        Ok(())
929    }
930
931    #[test]
932    fn adjacent_test_target_prefers_next_then_previous_value() -> Result<(), WriteError> {
933        let caps = DpiCapabilities::new(vec![400, 800, 1600])?;
934
935        assert_eq!(caps.adjacent_test_target(400), Some(800));
936        assert_eq!(caps.adjacent_test_target(800), Some(1600));
937        assert_eq!(caps.adjacent_test_target(1600), Some(800));
938        Ok(())
939    }
940
941    #[test]
942    fn adjacent_test_target_handles_current_outside_list() -> Result<(), WriteError> {
943        let caps = DpiCapabilities::new(vec![400, 800, 1600])?;
944
945        assert_eq!(caps.adjacent_test_target(1000), Some(1600));
946        assert_eq!(caps.adjacent_test_target(2000), Some(1600));
947        Ok(())
948    }
949
950    #[test]
951    fn smartshift_and_wheel_mode_byte_encodings_match() {
952        // The whole design relies on 0x2110 WheelMode and 0x2111
953        // SmartShiftMode sharing one wire encoding (Free/Freespin = 1,
954        // Ratchet = 2). If the fork ever renumbers WheelMode this fails loudly.
955        assert_eq!(
956            u8::from(SmartShiftMode::Free),
957            u8::from(WheelMode::Freespin)
958        );
959        assert_eq!(
960            u8::from(SmartShiftMode::Ratchet),
961            u8::from(WheelMode::Ratchet)
962        );
963    }
964
965    #[test]
966    fn wheel_mode_maps_to_smartshift_mode() {
967        assert_eq!(
968            wheel_mode_to_smartshift(WheelMode::Freespin),
969            SmartShiftMode::Free
970        );
971        assert_eq!(
972            wheel_mode_to_smartshift(WheelMode::Ratchet),
973            SmartShiftMode::Ratchet
974        );
975    }
976
977    #[test]
978    fn smartshift_to_wheel_round_trips() {
979        // smartshift_to_wheel is the inverse of wheel_mode_to_smartshift.
980        for mode in [SmartShiftMode::Free, SmartShiftMode::Ratchet] {
981            assert_eq!(wheel_mode_to_smartshift(smartshift_to_wheel(mode)), mode);
982        }
983    }
984
985    #[test]
986    fn missing_enhanced_triggers_fallback() {
987        assert!(is_missing_enhanced(&WriteError::FeatureUnsupported {
988            feature_hex: 0x2111,
989        }));
990    }
991
992    #[test]
993    fn missing_legacy_does_not_trigger_fallback() {
994        // A device missing 0x2110 must NOT loop back — it genuinely has no
995        // SmartShift.
996        assert!(!is_missing_enhanced(&WriteError::FeatureUnsupported {
997            feature_hex: 0x2110,
998        }));
999    }
1000
1001    #[test]
1002    fn transport_errors_do_not_trigger_fallback() {
1003        // Real failures must propagate, not be masked by a fallback attempt.
1004        assert!(!is_missing_enhanced(&WriteError::DeviceUnreachable {
1005            index: 0xff,
1006        }));
1007        assert!(!is_missing_enhanced(&WriteError::Hidpp("boom".into())));
1008    }
1009}