Skip to main content

openlogi_device/write/
hires_wheel.rs

1//! HID++ `0x2121 HiResWheel` mode reads and writes.
2
3use std::sync::Arc;
4
5use hidpp::{
6    channel::HidppChannel,
7    device::Device,
8    feature::CreatableFeature,
9    feature::hires_wheel::{
10        HiResWheelFeature, WheelEventTarget, WheelMode as HidppWheelMode,
11        WheelResolution as HidppWheelResolution,
12    },
13};
14pub use openlogi_core::config::ScrollResolution;
15use tracing::debug;
16
17use super::{HidppOperation, WriteError, classify_hidpp_error, open_feature, with_route};
18use crate::SharedChannel;
19use crate::backend::HidBackend;
20use crate::channel::route::DeviceRoute;
21
22/// Destination for vertical wheel movement reports.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum ScrollReportingTarget {
25    /// Ordinary HID scroll reports delivered to the operating system.
26    Native,
27    /// HID++ notifications consumed by a host-side handler.
28    Diverted,
29}
30
31impl From<ScrollReportingTarget> for WheelEventTarget {
32    fn from(target: ScrollReportingTarget) -> Self {
33        match target {
34            ScrollReportingTarget::Native => Self::Native,
35            ScrollReportingTarget::Diverted => Self::Diverted,
36        }
37    }
38}
39
40impl TryFrom<WheelEventTarget> for ScrollReportingTarget {
41    type Error = WriteError;
42
43    fn try_from(target: WheelEventTarget) -> Result<Self, Self::Error> {
44        match target {
45            WheelEventTarget::Native => Ok(Self::Native),
46            WheelEventTarget::Diverted => Ok(Self::Diverted),
47            _ => Err(unsupported_read_response()),
48        }
49    }
50}
51
52/// Current HID++ `0x2121` wheel reporting mode.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub struct ScrollWheelMode {
55    /// Vertical wheel reporting resolution.
56    pub resolution: ScrollResolution,
57    /// Whether native vertical reports are inverted in firmware.
58    pub inverted: bool,
59    /// Destination for wheel movement reports.
60    pub target: ScrollReportingTarget,
61}
62
63impl TryFrom<HidppWheelMode> for ScrollWheelMode {
64    type Error = WriteError;
65
66    fn try_from(mode: HidppWheelMode) -> Result<Self, Self::Error> {
67        Ok(Self {
68            resolution: resolution_from_hidpp(mode.resolution)?,
69            inverted: mode.inverted,
70            target: mode.target.try_into()?,
71        })
72    }
73}
74
75#[cfg(test)]
76impl ScrollWheelMode {
77    fn native(resolution: ScrollResolution, inverted: bool) -> Self {
78        Self {
79            resolution,
80            inverted,
81            target: ScrollReportingTarget::Native,
82        }
83    }
84}
85
86/// Read the current vertical wheel reporting mode.
87pub async fn get_scroll_wheel_mode(
88    backend: &dyn HidBackend,
89    route: &DeviceRoute,
90) -> Result<ScrollWheelMode, WriteError> {
91    let index = route.device_index();
92    with_route(backend, route, move |channel| async move {
93        get_scroll_wheel_mode_on_channel(&channel, index).await
94    })
95    .await
96}
97
98/// Read the current wheel mode on an already-open [`SharedChannel`].
99pub async fn get_scroll_wheel_mode_on(
100    shared: &SharedChannel,
101) -> Result<ScrollWheelMode, WriteError> {
102    get_scroll_wheel_mode_on_channel(shared.channel(), shared.device_index()).await
103}
104
105async fn get_scroll_wheel_mode_on_channel(
106    channel: &Arc<HidppChannel>,
107    index: u8,
108) -> Result<ScrollWheelMode, WriteError> {
109    let mut device = open_device(channel, index).await?;
110    let feature = open_feature::<HiResWheelFeature>(&mut device).await?;
111    read_mode(&feature).await
112}
113
114/// Set only the wheel resolution while preserving the current inversion flag.
115/// Reporting is always normalized to native HID.
116pub async fn set_scroll_resolution(
117    backend: &dyn HidBackend,
118    route: &DeviceRoute,
119    resolution: ScrollResolution,
120) -> Result<ScrollWheelMode, WriteError> {
121    let index = route.device_index();
122    with_route(backend, route, move |channel| async move {
123        change_wheel_mode_on_channel(&channel, index, Some(resolution), None, false).await
124    })
125    .await
126}
127
128/// Set only the wheel resolution on an already-open [`SharedChannel`].
129pub async fn set_scroll_resolution_on(
130    shared: &SharedChannel,
131    resolution: ScrollResolution,
132) -> Result<ScrollWheelMode, WriteError> {
133    change_wheel_mode_on_channel(
134        shared.channel(),
135        shared.device_index(),
136        Some(resolution),
137        None,
138        false,
139    )
140    .await
141}
142
143/// Set wheel resolution and native inversion together in one HID++ write.
144///
145/// This is the agent re-apply path: reading once and writing the complete mode
146/// avoids briefly exposing a mixed resolution/inversion state after reconnect.
147pub async fn set_scroll_wheel_mode(
148    backend: &dyn HidBackend,
149    route: &DeviceRoute,
150    resolution: ScrollResolution,
151    inverted: bool,
152) -> Result<ScrollWheelMode, WriteError> {
153    let index = route.device_index();
154    with_route(backend, route, move |channel| async move {
155        change_wheel_mode_on_channel(&channel, index, Some(resolution), Some(inverted), true).await
156    })
157    .await
158}
159
160/// Set wheel resolution and inversion on an already-open [`SharedChannel`].
161pub async fn set_scroll_wheel_mode_on(
162    shared: &SharedChannel,
163    resolution: ScrollResolution,
164    inverted: bool,
165) -> Result<ScrollWheelMode, WriteError> {
166    change_wheel_mode_on_channel(
167        shared.channel(),
168        shared.device_index(),
169        Some(resolution),
170        Some(inverted),
171        true,
172    )
173    .await
174}
175
176/// Write the device's native vertical-scroll inversion flag while preserving
177/// its current resolution. Enabling inversion selects native HID reporting;
178/// disabling it preserves the current reporting target so an unrelated
179/// host-side consumer does not lose diverted wheel events.
180///
181/// Returns [`WriteError::FeatureUnsupported`] when the device lacks `0x2121` or
182/// reports that native inversion is not supported.
183pub async fn set_scroll_inversion(
184    backend: &dyn HidBackend,
185    route: &DeviceRoute,
186    inverted: bool,
187) -> Result<(), WriteError> {
188    let index = route.device_index();
189    with_route(backend, route, move |channel| async move {
190        change_wheel_mode_on_channel(&channel, index, None, Some(inverted), true)
191            .await
192            .map(|_| ())
193    })
194    .await
195}
196
197/// Write scroll inversion on an already-open [`SharedChannel`], with the same
198/// reporting-target behavior as [`set_scroll_inversion`].
199pub async fn set_scroll_inversion_on(
200    shared: &SharedChannel,
201    inverted: bool,
202) -> Result<(), WriteError> {
203    change_wheel_mode_on_channel(
204        shared.channel(),
205        shared.device_index(),
206        None,
207        Some(inverted),
208        true,
209    )
210    .await
211    .map(|_| ())
212}
213
214async fn change_wheel_mode_on_channel(
215    channel: &Arc<HidppChannel>,
216    index: u8,
217    resolution: Option<ScrollResolution>,
218    inverted: Option<bool>,
219    require_invert_support: bool,
220) -> Result<ScrollWheelMode, WriteError> {
221    let mut device = open_device(channel, index).await?;
222    let feature = open_feature::<HiResWheelFeature>(&mut device).await?;
223    if require_invert_support {
224        let capabilities = feature.get_wheel_capabilities().await.map_err(|error| {
225            classify_hidpp_error(error, HidppOperation::ReadWheelMode, HiResWheelFeature::ID)
226        })?;
227        if !capabilities.has_invert {
228            return Err(WriteError::FeatureUnsupported {
229                feature_hex: HiResWheelFeature::ID,
230            });
231        }
232    }
233
234    let current = read_mode(&feature).await?;
235    let desired = desired_mode(current, resolution, inverted);
236    if current == desired {
237        debug!(index, ?desired, "wheel mode already set; skipping");
238        return Ok(current);
239    }
240
241    let written = feature
242        .set_wheel_mode(
243            desired.target.into(),
244            resolution_to_hidpp(desired.resolution),
245            desired.inverted,
246        )
247        .await
248        .map_err(|error| {
249            classify_hidpp_error(error, HidppOperation::WriteWheelMode, HiResWheelFeature::ID)
250        })?;
251    validate_applied(written.try_into()?, desired)?;
252
253    let read_back = read_mode(&feature).await?;
254    validate_applied(read_back, desired)?;
255    debug!(index, ?read_back, "wheel mode written and verified");
256    Ok(read_back)
257}
258
259async fn open_device(channel: &Arc<HidppChannel>, index: u8) -> Result<Device, WriteError> {
260    Device::new(Arc::clone(channel), index)
261        .await
262        .map_err(|_| WriteError::DeviceUnreachable { index })
263}
264
265async fn read_mode(feature: &HiResWheelFeature) -> Result<ScrollWheelMode, WriteError> {
266    let mode = feature.get_wheel_mode().await.map_err(|error| {
267        classify_hidpp_error(error, HidppOperation::ReadWheelMode, HiResWheelFeature::ID)
268    })?;
269    mode.try_into()
270}
271
272fn desired_mode(
273    current: ScrollWheelMode,
274    resolution: Option<ScrollResolution>,
275    inverted: Option<bool>,
276) -> ScrollWheelMode {
277    ScrollWheelMode {
278        resolution: resolution.unwrap_or(current.resolution),
279        inverted: inverted.unwrap_or(current.inverted),
280        // Native inversion has no effect on diverted reports. Enabling it or
281        // explicitly selecting a native resolution therefore takes ownership
282        // of the route; clearing inversion must not steal a route another
283        // host-side consumer is already handling.
284        target: if resolution.is_some() || inverted == Some(true) {
285            ScrollReportingTarget::Native
286        } else {
287            current.target
288        },
289    }
290}
291
292fn validate_applied(actual: ScrollWheelMode, desired: ScrollWheelMode) -> Result<(), WriteError> {
293    if actual == desired {
294        Ok(())
295    } else {
296        Err(WriteError::UnsupportedResponse {
297            operation: HidppOperation::WriteWheelMode,
298            feature_hex: HiResWheelFeature::ID,
299        })
300    }
301}
302
303fn resolution_from_hidpp(resolution: HidppWheelResolution) -> Result<ScrollResolution, WriteError> {
304    Ok(match resolution {
305        HidppWheelResolution::Low => ScrollResolution::Low,
306        HidppWheelResolution::High => ScrollResolution::High,
307        _ => return Err(unsupported_read_response()),
308    })
309}
310
311fn resolution_to_hidpp(resolution: ScrollResolution) -> HidppWheelResolution {
312    match resolution {
313        ScrollResolution::Low => HidppWheelResolution::Low,
314        ScrollResolution::High => HidppWheelResolution::High,
315    }
316}
317
318fn unsupported_read_response() -> WriteError {
319    WriteError::UnsupportedResponse {
320        operation: HidppOperation::ReadWheelMode,
321        feature_hex: HiResWheelFeature::ID,
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    #[test]
330    fn mode_value_conversions_preserve_known_wire_values() -> Result<(), WriteError> {
331        assert_eq!(
332            resolution_from_hidpp(HidppWheelResolution::Low)?,
333            ScrollResolution::Low
334        );
335        assert_eq!(
336            resolution_from_hidpp(HidppWheelResolution::High)?,
337            ScrollResolution::High
338        );
339        assert_eq!(
340            ScrollReportingTarget::try_from(WheelEventTarget::Native)?,
341            ScrollReportingTarget::Native
342        );
343        assert_eq!(
344            ScrollReportingTarget::try_from(WheelEventTarget::Diverted)?,
345            ScrollReportingTarget::Diverted
346        );
347        assert_eq!(
348            WheelEventTarget::from(ScrollReportingTarget::Native),
349            WheelEventTarget::Native
350        );
351        assert_eq!(
352            WheelEventTarget::from(ScrollReportingTarget::Diverted),
353            WheelEventTarget::Diverted
354        );
355        Ok(())
356    }
357
358    #[test]
359    fn resolution_only_preserves_inversion_and_targets_native() {
360        let current = ScrollWheelMode {
361            resolution: ScrollResolution::High,
362            inverted: true,
363            target: ScrollReportingTarget::Diverted,
364        };
365        assert_eq!(
366            desired_mode(current, Some(ScrollResolution::Low), None),
367            ScrollWheelMode::native(ScrollResolution::Low, true)
368        );
369    }
370
371    #[test]
372    fn inversion_only_preserves_resolution_and_targets_native() {
373        let current = ScrollWheelMode {
374            resolution: ScrollResolution::Low,
375            inverted: false,
376            target: ScrollReportingTarget::Diverted,
377        };
378        assert_eq!(
379            desired_mode(current, None, Some(true)),
380            ScrollWheelMode::native(ScrollResolution::Low, true)
381        );
382    }
383
384    #[test]
385    fn default_non_inverted_setting_preserves_diverted_reporting() {
386        let current = ScrollWheelMode {
387            resolution: ScrollResolution::High,
388            inverted: false,
389            target: ScrollReportingTarget::Diverted,
390        };
391        assert_eq!(desired_mode(current, None, Some(false)), current);
392    }
393
394    #[test]
395    fn mismatched_set_or_read_back_is_rejected() {
396        let desired = ScrollWheelMode::native(ScrollResolution::Low, false);
397        let actual = ScrollWheelMode::native(ScrollResolution::High, false);
398        assert!(matches!(
399            validate_applied(actual, desired),
400            Err(WriteError::UnsupportedResponse {
401                operation: HidppOperation::WriteWheelMode,
402                feature_hex: 0x2121,
403            })
404        ));
405    }
406}