Skip to main content

openlogi_hid/
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 crate::route::DeviceRoute;
18use crate::write::{
19    HidppOperation, SharedChannel, WriteError, classify_hidpp_error, open_feature, with_route,
20};
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
31/// Current HID++ `0x2121` wheel reporting mode.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct ScrollWheelMode {
34    /// Vertical wheel reporting resolution.
35    pub resolution: ScrollResolution,
36    /// Whether native vertical reports are inverted in firmware.
37    pub inverted: bool,
38    /// Destination for wheel movement reports.
39    pub target: ScrollReportingTarget,
40}
41
42impl ScrollWheelMode {
43    fn native(resolution: ScrollResolution, inverted: bool) -> Self {
44        Self {
45            resolution,
46            inverted,
47            target: ScrollReportingTarget::Native,
48        }
49    }
50}
51
52/// Read the current vertical wheel reporting mode.
53pub async fn get_scroll_wheel_mode(route: &DeviceRoute) -> Result<ScrollWheelMode, WriteError> {
54    let index = route.device_index();
55    with_route(route, move |channel| async move {
56        get_scroll_wheel_mode_on_channel(&channel, index).await
57    })
58    .await
59}
60
61/// Read the current wheel mode on an already-open [`SharedChannel`].
62pub async fn get_scroll_wheel_mode_on(
63    shared: &SharedChannel,
64) -> Result<ScrollWheelMode, WriteError> {
65    get_scroll_wheel_mode_on_channel(shared.channel(), shared.device_index()).await
66}
67
68async fn get_scroll_wheel_mode_on_channel(
69    channel: &Arc<HidppChannel>,
70    index: u8,
71) -> Result<ScrollWheelMode, WriteError> {
72    let mut device = open_device(channel, index).await?;
73    let feature = open_feature::<HiResWheelFeature>(&mut device).await?;
74    read_mode(&feature).await
75}
76
77/// Set only the wheel resolution while preserving the current inversion flag.
78/// Reporting is always normalized to native HID.
79pub async fn set_scroll_resolution(
80    route: &DeviceRoute,
81    resolution: ScrollResolution,
82) -> Result<ScrollWheelMode, WriteError> {
83    let index = route.device_index();
84    with_route(route, move |channel| async move {
85        change_wheel_mode_on_channel(&channel, index, Some(resolution), None, false).await
86    })
87    .await
88}
89
90/// Set only the wheel resolution on an already-open [`SharedChannel`].
91pub async fn set_scroll_resolution_on(
92    shared: &SharedChannel,
93    resolution: ScrollResolution,
94) -> Result<ScrollWheelMode, WriteError> {
95    change_wheel_mode_on_channel(
96        shared.channel(),
97        shared.device_index(),
98        Some(resolution),
99        None,
100        false,
101    )
102    .await
103}
104
105/// Set wheel resolution and native inversion together in one HID++ write.
106///
107/// This is the agent re-apply path: reading once and writing the complete mode
108/// avoids briefly exposing a mixed resolution/inversion state after reconnect.
109pub async fn set_scroll_wheel_mode(
110    route: &DeviceRoute,
111    resolution: ScrollResolution,
112    inverted: bool,
113) -> Result<ScrollWheelMode, WriteError> {
114    let index = route.device_index();
115    with_route(route, move |channel| async move {
116        change_wheel_mode_on_channel(&channel, index, Some(resolution), Some(inverted), true).await
117    })
118    .await
119}
120
121/// Set wheel resolution and inversion on an already-open [`SharedChannel`].
122pub async fn set_scroll_wheel_mode_on(
123    shared: &SharedChannel,
124    resolution: ScrollResolution,
125    inverted: bool,
126) -> Result<ScrollWheelMode, WriteError> {
127    change_wheel_mode_on_channel(
128        shared.channel(),
129        shared.device_index(),
130        Some(resolution),
131        Some(inverted),
132        true,
133    )
134    .await
135}
136
137/// Write the device's native vertical-scroll inversion flag while preserving
138/// its current resolution.
139///
140/// Returns [`WriteError::FeatureUnsupported`] when the device lacks `0x2121` or
141/// reports that native inversion is not supported.
142pub async fn set_scroll_inversion(route: &DeviceRoute, inverted: bool) -> Result<(), WriteError> {
143    let index = route.device_index();
144    with_route(route, move |channel| async move {
145        change_wheel_mode_on_channel(&channel, index, None, Some(inverted), true)
146            .await
147            .map(|_| ())
148    })
149    .await
150}
151
152/// Write native scroll inversion on an already-open [`SharedChannel`].
153pub async fn set_scroll_inversion_on(
154    shared: &SharedChannel,
155    inverted: bool,
156) -> Result<(), WriteError> {
157    change_wheel_mode_on_channel(
158        shared.channel(),
159        shared.device_index(),
160        None,
161        Some(inverted),
162        true,
163    )
164    .await
165    .map(|_| ())
166}
167
168async fn change_wheel_mode_on_channel(
169    channel: &Arc<HidppChannel>,
170    index: u8,
171    resolution: Option<ScrollResolution>,
172    inverted: Option<bool>,
173    require_invert_support: bool,
174) -> Result<ScrollWheelMode, WriteError> {
175    let mut device = open_device(channel, index).await?;
176    let feature = open_feature::<HiResWheelFeature>(&mut device).await?;
177    if require_invert_support {
178        let capabilities = feature.get_wheel_capabilities().await.map_err(|error| {
179            classify_hidpp_error(error, HidppOperation::ReadWheelMode, HiResWheelFeature::ID)
180        })?;
181        if !capabilities.has_invert {
182            return Err(WriteError::FeatureUnsupported {
183                feature_hex: HiResWheelFeature::ID,
184            });
185        }
186    }
187
188    let current = read_mode(&feature).await?;
189    let desired = desired_mode(current, resolution, inverted);
190    if current == desired {
191        debug!(index, ?desired, "native wheel mode already set; skipping");
192        return Ok(current);
193    }
194
195    let written = feature
196        .set_wheel_mode(
197            WheelEventTarget::Native,
198            resolution_to_hidpp(desired.resolution),
199            desired.inverted,
200        )
201        .await
202        .map_err(|error| {
203            classify_hidpp_error(error, HidppOperation::WriteWheelMode, HiResWheelFeature::ID)
204        })?;
205    validate_applied(mode_from_hidpp(written)?, desired)?;
206
207    let read_back = read_mode(&feature).await?;
208    validate_applied(read_back, desired)?;
209    debug!(index, ?read_back, "native wheel mode written and verified");
210    Ok(read_back)
211}
212
213async fn open_device(channel: &Arc<HidppChannel>, index: u8) -> Result<Device, WriteError> {
214    Device::new(Arc::clone(channel), index)
215        .await
216        .map_err(|_| WriteError::DeviceUnreachable { index })
217}
218
219async fn read_mode(feature: &HiResWheelFeature) -> Result<ScrollWheelMode, WriteError> {
220    let mode = feature.get_wheel_mode().await.map_err(|error| {
221        classify_hidpp_error(error, HidppOperation::ReadWheelMode, HiResWheelFeature::ID)
222    })?;
223    mode_from_hidpp(mode)
224}
225
226fn desired_mode(
227    current: ScrollWheelMode,
228    resolution: Option<ScrollResolution>,
229    inverted: Option<bool>,
230) -> ScrollWheelMode {
231    ScrollWheelMode::native(
232        resolution.unwrap_or(current.resolution),
233        inverted.unwrap_or(current.inverted),
234    )
235}
236
237fn validate_applied(actual: ScrollWheelMode, desired: ScrollWheelMode) -> Result<(), WriteError> {
238    if actual == desired {
239        Ok(())
240    } else {
241        Err(WriteError::UnsupportedResponse {
242            operation: HidppOperation::WriteWheelMode,
243            feature_hex: HiResWheelFeature::ID,
244        })
245    }
246}
247
248fn mode_from_hidpp(mode: HidppWheelMode) -> Result<ScrollWheelMode, WriteError> {
249    Ok(ScrollWheelMode {
250        resolution: resolution_from_hidpp(mode.resolution)?,
251        inverted: mode.inverted,
252        target: target_from_hidpp(mode.target)?,
253    })
254}
255
256fn resolution_from_hidpp(resolution: HidppWheelResolution) -> Result<ScrollResolution, WriteError> {
257    Ok(match resolution {
258        HidppWheelResolution::Low => ScrollResolution::Low,
259        HidppWheelResolution::High => ScrollResolution::High,
260        _ => return Err(unsupported_read_response()),
261    })
262}
263
264fn target_from_hidpp(target: WheelEventTarget) -> Result<ScrollReportingTarget, WriteError> {
265    Ok(match target {
266        WheelEventTarget::Native => ScrollReportingTarget::Native,
267        WheelEventTarget::Diverted => ScrollReportingTarget::Diverted,
268        _ => return Err(unsupported_read_response()),
269    })
270}
271
272fn resolution_to_hidpp(resolution: ScrollResolution) -> HidppWheelResolution {
273    match resolution {
274        ScrollResolution::Low => HidppWheelResolution::Low,
275        ScrollResolution::High => HidppWheelResolution::High,
276    }
277}
278
279fn unsupported_read_response() -> WriteError {
280    WriteError::UnsupportedResponse {
281        operation: HidppOperation::ReadWheelMode,
282        feature_hex: HiResWheelFeature::ID,
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn mode_value_conversions_preserve_known_wire_values() -> Result<(), WriteError> {
292        assert_eq!(
293            resolution_from_hidpp(HidppWheelResolution::Low)?,
294            ScrollResolution::Low
295        );
296        assert_eq!(
297            resolution_from_hidpp(HidppWheelResolution::High)?,
298            ScrollResolution::High
299        );
300        assert_eq!(
301            target_from_hidpp(WheelEventTarget::Native)?,
302            ScrollReportingTarget::Native
303        );
304        assert_eq!(
305            target_from_hidpp(WheelEventTarget::Diverted)?,
306            ScrollReportingTarget::Diverted
307        );
308        Ok(())
309    }
310
311    #[test]
312    fn resolution_only_preserves_inversion_and_targets_native() {
313        let current = ScrollWheelMode {
314            resolution: ScrollResolution::High,
315            inverted: true,
316            target: ScrollReportingTarget::Diverted,
317        };
318        assert_eq!(
319            desired_mode(current, Some(ScrollResolution::Low), None),
320            ScrollWheelMode::native(ScrollResolution::Low, true)
321        );
322    }
323
324    #[test]
325    fn inversion_only_preserves_resolution_and_targets_native() {
326        let current = ScrollWheelMode {
327            resolution: ScrollResolution::Low,
328            inverted: false,
329            target: ScrollReportingTarget::Diverted,
330        };
331        assert_eq!(
332            desired_mode(current, None, Some(true)),
333            ScrollWheelMode::native(ScrollResolution::Low, true)
334        );
335    }
336
337    #[test]
338    fn mismatched_set_or_read_back_is_rejected() {
339        let desired = ScrollWheelMode::native(ScrollResolution::Low, false);
340        let actual = ScrollWheelMode::native(ScrollResolution::High, false);
341        assert!(matches!(
342            validate_applied(actual, desired),
343            Err(WriteError::UnsupportedResponse {
344                operation: HidppOperation::WriteWheelMode,
345                feature_hex: 0x2121,
346            })
347        ));
348    }
349}