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