1use 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
23pub 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
35pub 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
51pub 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
65pub 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
80pub 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
97pub 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
113pub 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
134pub 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 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 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 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 (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 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 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}