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