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 device_mode = read_device_mode(&feature).await?;
172    let current = mode_from_hidpp(device_mode)?;
173    let desired = desired_mode(current, resolution, inverted);
174    if current == desired {
175        debug!(index, ?desired, "wheel mode already set; skipping");
176        return Ok(current);
177    }
178
179    // Edit the mode the device reported rather than building a new one, so the
180    // firmware-owned fields outside resolution/invert/target are written back.
181    let mut request = device_mode;
182    request.target = target_to_hidpp(desired.target);
183    request.resolution = resolution_to_hidpp(desired.resolution);
184    request.inverted = desired.inverted;
185    let written = feature.set_wheel_mode(request).await.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    mode_from_hidpp(read_device_mode(feature).await?)
204}
205
206async fn read_device_mode(feature: &HiResWheelFeature) -> Result<HidppWheelMode, WriteError> {
207    feature.get_wheel_mode().await.map_err(|error| {
208        classify_hidpp_error(error, HidppOperation::ReadWheelMode, HiResWheelFeature::ID)
209    })
210}
211
212fn desired_mode(
213    current: ScrollWheelMode,
214    resolution: Option<ScrollResolution>,
215    inverted: Option<bool>,
216) -> ScrollWheelMode {
217    ScrollWheelMode {
218        resolution: resolution.unwrap_or(current.resolution),
219        inverted: inverted.unwrap_or(current.inverted),
220        // Native inversion has no effect on diverted reports. Enabling it or
221        // explicitly selecting a native resolution therefore takes ownership
222        // of the route; clearing inversion must not steal a route another
223        // host-side consumer is already handling.
224        target: if resolution.is_some() || inverted == Some(true) {
225            ScrollReportingTarget::Native
226        } else {
227            current.target
228        },
229    }
230}
231
232fn validate_applied(actual: ScrollWheelMode, desired: ScrollWheelMode) -> Result<(), WriteError> {
233    if actual == desired {
234        Ok(())
235    } else {
236        Err(WriteError::UnsupportedResponse {
237            operation: HidppOperation::WriteWheelMode,
238            feature_hex: HiResWheelFeature::ID,
239        })
240    }
241}
242
243fn resolution_from_hidpp(resolution: HidppWheelResolution) -> Result<ScrollResolution, WriteError> {
244    Ok(match resolution {
245        HidppWheelResolution::Low => ScrollResolution::Low,
246        HidppWheelResolution::High => ScrollResolution::High,
247        _ => return Err(unsupported_read_response()),
248    })
249}
250
251fn resolution_to_hidpp(resolution: ScrollResolution) -> HidppWheelResolution {
252    match resolution {
253        ScrollResolution::Low => HidppWheelResolution::Low,
254        ScrollResolution::High => HidppWheelResolution::High,
255    }
256}
257
258fn target_from_hidpp(target: WheelEventTarget) -> Result<ScrollReportingTarget, WriteError> {
259    match target {
260        WheelEventTarget::Native => Ok(ScrollReportingTarget::Native),
261        WheelEventTarget::Diverted => Ok(ScrollReportingTarget::Diverted),
262        _ => Err(unsupported_read_response()),
263    }
264}
265
266fn target_to_hidpp(target: ScrollReportingTarget) -> WheelEventTarget {
267    match target {
268        ScrollReportingTarget::Native => WheelEventTarget::Native,
269        ScrollReportingTarget::Diverted => WheelEventTarget::Diverted,
270    }
271}
272
273fn mode_from_hidpp(mode: HidppWheelMode) -> Result<ScrollWheelMode, WriteError> {
274    Ok(ScrollWheelMode {
275        resolution: resolution_from_hidpp(mode.resolution)?,
276        inverted: mode.inverted,
277        target: target_from_hidpp(mode.target)?,
278    })
279}
280
281fn unsupported_read_response() -> WriteError {
282    WriteError::UnsupportedResponse {
283        operation: HidppOperation::ReadWheelMode,
284        feature_hex: HiResWheelFeature::ID,
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use std::sync::Mutex;
291
292    use super::*;
293    use crate::channel::scripted::{ScriptedRawHidChannel, scripted_channel};
294
295    const HIRES_WHEEL_INDEX: u8 = 0x05;
296
297    /// A wheel whose firmware keeps the analytics bit (byte 0 bit 3) and the
298    /// two extension bytes Options+ writes as priority and rate (bytes 1 and 2).
299    /// The responder stores whatever `setWheelMode` sends, like the device does.
300    fn stateful_wheel(
301        initial: [u8; 3],
302    ) -> (ScriptedRawHidChannel, crate::replay::ReplayChannelHandle) {
303        let state = Mutex::new(initial);
304        ScriptedRawHidChannel::with_dynamic_responder(move |request| {
305            if request.len() < 7 || request[0] != 0x10 {
306                return None;
307            }
308            let mut payload = [0u8; 3];
309            match (request[2], request[3] >> 4) {
310                (0x00, 0x01) => payload[0] = 4,
311                (0x00, 0x00) => {
312                    if u16::from_be_bytes([request[4], request[5]]) == HiResWheelFeature::ID {
313                        payload[0] = HIRES_WHEEL_INDEX;
314                    }
315                }
316                // Capabilities: multiplier 8, invert + ratchet switch, 24 ratchets.
317                (HIRES_WHEEL_INDEX, 0x00) => payload = [8, 0b1100, 24],
318                (HIRES_WHEEL_INDEX, 0x01) => payload = *state.lock().expect("wheel state"),
319                (HIRES_WHEEL_INDEX, 0x02) => {
320                    let mut current = state.lock().expect("wheel state");
321                    current.copy_from_slice(&request[4..7]);
322                    payload = *current;
323                }
324                _ => return None,
325            }
326            let mut response = request[..4].to_vec();
327            response.extend_from_slice(&payload);
328            Some(response)
329        })
330    }
331
332    fn mode_writes(handle: &crate::replay::ReplayChannelHandle) -> Vec<[u8; 3]> {
333        handle
334            .written_reports()
335            .into_iter()
336            .filter(|report| {
337                report.len() >= 7 && report[2] == HIRES_WHEEL_INDEX && report[3] >> 4 == 0x02
338            })
339            .map(|report| [report[4], report[5], report[6]])
340            .collect()
341    }
342
343    #[tokio::test]
344    async fn resolution_write_preserves_firmware_mode_extension_fields() -> Result<(), WriteError> {
345        // Low resolution, native, not inverted; analytics on, priority 5, rate 7.
346        let (raw, handle) = stateful_wheel([0b1000, 0x05, 0x07]);
347        let shared = SharedChannel::new(
348            scripted_channel(raw).await,
349            DeviceRoute::Direct {
350                vendor_id: 0x046d,
351                product_id: 0xb035,
352            },
353        );
354
355        let applied = set_scroll_resolution_on(&shared, ScrollResolution::High).await?;
356
357        assert_eq!(applied, native(ScrollResolution::High, false));
358        // Only the resolution bit changes; analytics, priority and rate survive.
359        assert_eq!(mode_writes(&handle), vec![[0b1010, 0x05, 0x07]]);
360        Ok(())
361    }
362
363    #[tokio::test]
364    async fn inversion_write_preserves_firmware_mode_extension_fields() -> Result<(), WriteError> {
365        let (raw, handle) = stateful_wheel([0b1010, 0x02, 0x09]);
366        let shared = SharedChannel::new(
367            scripted_channel(raw).await,
368            DeviceRoute::Direct {
369                vendor_id: 0x046d,
370                product_id: 0xb035,
371            },
372        );
373
374        set_scroll_inversion_on(&shared, true).await?;
375
376        assert_eq!(mode_writes(&handle), vec![[0b1110, 0x02, 0x09]]);
377        Ok(())
378    }
379
380    fn native(resolution: ScrollResolution, inverted: bool) -> ScrollWheelMode {
381        ScrollWheelMode {
382            resolution,
383            inverted,
384            target: ScrollReportingTarget::Native,
385        }
386    }
387
388    #[test]
389    fn mode_value_conversions_preserve_known_wire_values() -> Result<(), WriteError> {
390        assert_eq!(
391            resolution_from_hidpp(HidppWheelResolution::Low)?,
392            ScrollResolution::Low
393        );
394        assert_eq!(
395            resolution_from_hidpp(HidppWheelResolution::High)?,
396            ScrollResolution::High
397        );
398        assert_eq!(
399            target_from_hidpp(WheelEventTarget::Native)?,
400            ScrollReportingTarget::Native
401        );
402        assert_eq!(
403            target_from_hidpp(WheelEventTarget::Diverted)?,
404            ScrollReportingTarget::Diverted
405        );
406        assert_eq!(
407            target_to_hidpp(ScrollReportingTarget::Native),
408            WheelEventTarget::Native
409        );
410        assert_eq!(
411            target_to_hidpp(ScrollReportingTarget::Diverted),
412            WheelEventTarget::Diverted
413        );
414        Ok(())
415    }
416
417    #[test]
418    fn resolution_only_preserves_inversion_and_targets_native() {
419        let current = ScrollWheelMode {
420            resolution: ScrollResolution::High,
421            inverted: true,
422            target: ScrollReportingTarget::Diverted,
423        };
424        assert_eq!(
425            desired_mode(current, Some(ScrollResolution::Low), None),
426            native(ScrollResolution::Low, true)
427        );
428    }
429
430    #[test]
431    fn inversion_only_preserves_resolution_and_targets_native() {
432        let current = ScrollWheelMode {
433            resolution: ScrollResolution::Low,
434            inverted: false,
435            target: ScrollReportingTarget::Diverted,
436        };
437        assert_eq!(
438            desired_mode(current, None, Some(true)),
439            native(ScrollResolution::Low, true)
440        );
441    }
442
443    #[test]
444    fn default_non_inverted_setting_preserves_diverted_reporting() {
445        let current = ScrollWheelMode {
446            resolution: ScrollResolution::High,
447            inverted: false,
448            target: ScrollReportingTarget::Diverted,
449        };
450        assert_eq!(desired_mode(current, None, Some(false)), current);
451    }
452
453    #[test]
454    fn mismatched_set_or_read_back_is_rejected() {
455        let desired = native(ScrollResolution::Low, false);
456        let actual = native(ScrollResolution::High, false);
457        assert!(matches!(
458            validate_applied(actual, desired),
459            Err(WriteError::UnsupportedResponse {
460                operation: HidppOperation::WriteWheelMode,
461                feature_hex: 0x2121,
462            })
463        ));
464    }
465}