1use std::num::NonZeroU8;
11use std::sync::Arc;
12use std::time::Duration;
13
14use async_hid::AsyncHidWrite;
15use hidpp::{
16 channel::HidppChannel,
17 device::Device,
18 feature::CreatableFeature,
19 feature::adjustable_dpi::AdjustableDpiFeature,
20 feature::smartshift::{SmartShiftFeature, WheelMode},
21 protocol::v20::{ErrorType, Hidpp20Error},
22};
23use serde::{Deserialize, Serialize};
24use thiserror::Error;
25use tracing::debug;
26
27use crate::route::{DeviceRoute, open_route_channel};
28use crate::smartshift::{SmartShiftFeatureV0, SmartShiftMode, SmartShiftStatus};
29
30#[derive(Debug, Clone, Error, Serialize, Deserialize)]
38pub enum WriteError {
39 #[error("HID transport error: {0}")]
42 Hid(String),
43 #[error("no connected device matched the route")]
44 DeviceNotFound,
45 #[error("device at index {index:#04x} did not respond to HID++")]
46 DeviceUnreachable { index: u8 },
47 #[error("device does not expose HID++ feature {feature_hex:#06x}")]
48 FeatureUnsupported { feature_hex: u16 },
49 #[error("device returned no supported DPI values")]
50 EmptyDpiList,
51 #[error("HID++ protocol error: {0}")]
52 Hidpp(String),
53}
54
55impl From<async_hid::HidError> for WriteError {
56 fn from(e: async_hid::HidError) -> Self {
57 Self::Hid(e.to_string())
58 }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct DpiCapabilities {
64 values: Vec<u16>,
65}
66
67impl DpiCapabilities {
68 pub fn new(mut values: Vec<u16>) -> Result<Self, WriteError> {
71 values.sort_unstable();
72 values.dedup();
73 if values.is_empty() {
74 return Err(WriteError::EmptyDpiList);
75 }
76 Ok(Self { values })
77 }
78
79 #[must_use]
81 pub fn values(&self) -> &[u16] {
82 &self.values
83 }
84
85 #[must_use]
87 pub fn min(&self) -> u16 {
88 self.values[0]
89 }
90
91 #[must_use]
93 pub fn max(&self) -> u16 {
94 self.values[self.values.len() - 1]
95 }
96
97 #[must_use]
99 pub fn contains(&self, dpi: u16) -> bool {
100 self.values.binary_search(&dpi).is_ok()
101 }
102
103 #[must_use]
105 pub fn nearest(&self, dpi: u32) -> u16 {
106 let mut nearest = self.values[0];
107 let mut best_delta = u32::from(nearest).abs_diff(dpi);
108 for &candidate in &self.values[1..] {
109 let delta = u32::from(candidate).abs_diff(dpi);
110 if delta < best_delta {
111 nearest = candidate;
112 best_delta = delta;
113 }
114 }
115 nearest
116 }
117
118 #[must_use]
122 pub fn snap(&self, dpi: u32) -> u32 {
123 u32::from(self.nearest(dpi))
124 }
125
126 #[must_use]
129 pub fn step_hint(&self) -> u16 {
130 self.values
131 .windows(2)
132 .filter_map(|pair| pair[1].checked_sub(pair[0]))
133 .filter(|step| *step > 0)
134 .min()
135 .unwrap_or(1)
136 }
137
138 #[must_use]
140 pub fn adjacent_test_target(&self, current: u16) -> Option<u16> {
141 if self.values.len() < 2 {
142 return None;
143 }
144 match self.values.binary_search(¤t) {
145 Ok(index) if index + 1 < self.values.len() => Some(self.values[index + 1]),
146 Ok(index) if index > 0 => Some(self.values[index - 1]),
147 Ok(_) => None,
148 Err(index) if index < self.values.len() => Some(self.values[index]),
149 Err(_) => self.values.last().copied(),
150 }
151 .filter(|target| *target != current)
152 }
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct DpiInfo {
162 pub current: u16,
164 pub capabilities: DpiCapabilities,
166}
167
168#[derive(Debug, Clone, Copy)]
171pub struct FeatureEntry {
172 pub id: u16,
173 pub version: u8,
174}
175
176pub async fn dump_features(route: &DeviceRoute) -> Result<Vec<FeatureEntry>, WriteError> {
181 use hidpp::feature::feature_set::FeatureSetFeature;
182 let index = route.device_index();
183 with_route(route, move |channel| async move {
184 let mut device = Device::new(Arc::clone(&channel), index)
185 .await
186 .map_err(|_| WriteError::DeviceUnreachable { index })?;
187 let feature_set_info = device
191 .root()
192 .get_feature(FeatureSetFeature::ID)
193 .await
194 .map_err(|e| WriteError::Hidpp(format!("{e:?}")))?
195 .ok_or(WriteError::FeatureUnsupported {
196 feature_hex: FeatureSetFeature::ID,
197 })?;
198 let feature_set = device.add_feature::<FeatureSetFeature>(feature_set_info.index);
199 let count = feature_set
200 .count()
201 .await
202 .map_err(|e| WriteError::Hidpp(format!("{e:?}")))?;
203 let mut entries = Vec::with_capacity(usize::from(count));
204 for i in 0..=count {
205 let info = feature_set
206 .get_feature(i)
207 .await
208 .map_err(|e| WriteError::Hidpp(format!("{e:?}")))?;
209 entries.push(FeatureEntry {
210 id: info.id,
211 version: info.version,
212 });
213 }
214 Ok(entries)
215 })
216 .await
217}
218
219async fn open_feature<F: CreatableFeature + 'static>(
228 device: &mut Device,
229) -> Result<Arc<F>, WriteError> {
230 let info = device
231 .root()
232 .get_feature(F::ID)
233 .await
234 .map_err(|e| WriteError::Hidpp(format!("{e:?}")))?
235 .ok_or(WriteError::FeatureUnsupported { feature_hex: F::ID })?;
236 Ok(device.add_feature::<F>(info.index))
237}
238
239fn is_missing_enhanced(err: &WriteError) -> bool {
244 matches!(
245 err,
246 WriteError::FeatureUnsupported { feature_hex } if *feature_hex == 0x2111
247 )
248}
249
250fn wheel_mode_to_smartshift(wheel: WheelMode) -> SmartShiftMode {
255 if matches!(wheel, WheelMode::Freespin) {
256 SmartShiftMode::Free
257 } else {
258 SmartShiftMode::Ratchet
259 }
260}
261
262fn smartshift_to_wheel(mode: SmartShiftMode) -> WheelMode {
266 match mode {
267 SmartShiftMode::Free => WheelMode::Freespin,
268 SmartShiftMode::Ratchet => WheelMode::Ratchet,
269 }
270}
271
272enum SmartShift {
276 Enhanced(Arc<SmartShiftFeatureV0>),
278 Legacy(Arc<SmartShiftFeature>),
280}
281
282impl SmartShift {
283 async fn open(device: &mut Device) -> Result<Self, WriteError> {
287 match open_feature::<SmartShiftFeatureV0>(device).await {
288 Ok(feature) => Ok(Self::Enhanced(feature)),
289 Err(err) if is_missing_enhanced(&err) => {
290 let feature = open_feature::<SmartShiftFeature>(device).await?;
291 Ok(Self::Legacy(feature))
292 }
293 Err(err) => Err(err),
294 }
295 }
296
297 async fn status(&self) -> Result<SmartShiftStatus, WriteError> {
301 match self {
302 Self::Enhanced(feature) => feature
303 .get_status()
304 .await
305 .map_err(|e| WriteError::Hidpp(format!("{e:?}"))),
306 Self::Legacy(feature) => {
307 let rcm = feature
308 .get_ratchet_control_mode()
309 .await
310 .map_err(|e| WriteError::Hidpp(format!("{e:?}")))?;
311 Ok(SmartShiftStatus {
312 mode: wheel_mode_to_smartshift(rcm.wheel_mode),
313 auto_disengage: rcm.auto_disengage,
314 tunable_torque: 0,
318 })
319 }
320 }
321 }
322
323 async fn set_status(&self, status: SmartShiftStatus) -> Result<(), WriteError> {
329 let SmartShiftStatus {
330 mode,
331 auto_disengage,
332 tunable_torque,
333 } = status;
334 match self {
335 Self::Enhanced(feature) => feature
336 .set_status(mode, auto_disengage, tunable_torque)
337 .await
338 .map_err(|e| WriteError::Hidpp(format!("{e:?}"))),
339 Self::Legacy(feature) => feature
340 .set_ratchet_control_mode(
341 Some(smartshift_to_wheel(mode)),
342 Some(auto_disengage),
343 None,
344 )
345 .await
346 .map_err(|e| WriteError::Hidpp(format!("{e:?}"))),
347 }
348 }
349
350 async fn set_sensitivity(&self, value: NonZeroU8) -> Result<(), WriteError> {
356 let current = self.status().await?;
357 self.set_status(SmartShiftStatus {
358 auto_disengage: value.get(),
359 ..current
360 })
361 .await
362 }
363}
364
365pub async fn get_dpi(route: &DeviceRoute) -> Result<u16, WriteError> {
369 let index = route.device_index();
370 with_route(route, move |channel| async move {
371 let mut device = Device::new(Arc::clone(&channel), index)
372 .await
373 .map_err(|_| WriteError::DeviceUnreachable { index })?;
374 let feature = open_feature::<AdjustableDpiFeature>(&mut device).await?;
375 feature
376 .get_sensor_dpi(0)
377 .await
378 .map_err(|e| WriteError::Hidpp(format!("{e:?}")))
379 })
380 .await
381}
382
383fn classify_dpi_error(error: Hidpp20Error) -> WriteError {
390 match error {
391 Hidpp20Error::Feature(ErrorType::Unsupported | ErrorType::InvalidFunctionId)
392 | Hidpp20Error::UnsupportedResponse => WriteError::FeatureUnsupported {
393 feature_hex: AdjustableDpiFeature::ID,
394 },
395 other => WriteError::Hidpp(format!("{other:?}")),
396 }
397}
398
399pub async fn get_dpi_info(route: &DeviceRoute) -> Result<DpiInfo, WriteError> {
402 let index = route.device_index();
403 with_route(route, move |channel| async move {
404 let mut device = Device::new(Arc::clone(&channel), index)
405 .await
406 .map_err(|_| WriteError::DeviceUnreachable { index })?;
407 let feature = open_feature::<AdjustableDpiFeature>(&mut device).await?;
408 let sensor_count = feature
409 .get_sensor_count()
410 .await
411 .map_err(classify_dpi_error)?;
412 if sensor_count == 0 {
413 return Err(WriteError::FeatureUnsupported {
416 feature_hex: AdjustableDpiFeature::ID,
417 });
418 }
419 let current = feature
420 .get_sensor_dpi(0)
421 .await
422 .map_err(classify_dpi_error)?;
423 let values = feature
424 .get_sensor_dpi_list(0)
425 .await
426 .map_err(classify_dpi_error)?;
427 Ok(DpiInfo {
428 current,
429 capabilities: DpiCapabilities::new(values)?,
430 })
431 })
432 .await
433}
434
435pub async fn get_smartshift_status(route: &DeviceRoute) -> Result<SmartShiftStatus, WriteError> {
438 let index = route.device_index();
439 with_route(route, move |channel| async move {
440 let mut device = Device::new(Arc::clone(&channel), index)
441 .await
442 .map_err(|_| WriteError::DeviceUnreachable { index })?;
443 let smartshift = SmartShift::open(&mut device).await?;
444 smartshift.status().await
445 })
446 .await
447}
448
449pub async fn set_smartshift_sensitivity(
461 route: &DeviceRoute,
462 value: NonZeroU8,
463) -> Result<SmartShiftStatus, WriteError> {
464 let index = route.device_index();
465 with_route(route, move |channel| async move {
466 let mut device = Device::new(Arc::clone(&channel), index)
467 .await
468 .map_err(|_| WriteError::DeviceUnreachable { index })?;
469 let smartshift = SmartShift::open(&mut device).await?;
470 smartshift.set_sensitivity(value).await?;
471 smartshift.status().await
472 })
473 .await
474}
475
476pub async fn set_dpi(route: &DeviceRoute, dpi: u16) -> Result<(), WriteError> {
477 let index = route.device_index();
478 with_route(route, move |channel| async move {
479 set_dpi_on_channel(&channel, index, dpi).await
480 })
481 .await
482}
483
484const PER_KEY_LIGHTING_FEATURE: u16 = 0x8080;
487const COLOR_LED_EFFECTS_FEATURE: u16 = 0x8070;
492
493const REPORT_SET_KEYS: u8 = 0x12;
497const REPORT_LONG: u8 = 0x11;
498const SW_ID: u8 = 0x0a;
501const FN_SET_KEY_RANGE: u8 = 0x3;
502const FN_FRAME_END: u8 = 0x5;
503const SET_RANGE_MODE: u8 = 0x01;
506const KEYS_PER_FRAME: u8 = 0x0e;
507
508const FN_SET_ZONE_EFFECT: u8 = 0x3;
515const EFFECT_FIXED: u8 = 0x01;
516const PERSIST_RAM_ONLY: u8 = 0x00;
517const MAX_LIGHTING_ZONES: u8 = 4;
521const FRAME_GAP: Duration = Duration::from_millis(8);
522
523#[derive(Debug, Clone, Copy, PartialEq, Eq)]
528pub enum LightingMethod {
529 Auto,
532 Effects,
534 PerKey,
536}
537
538pub async fn set_keyboard_color(
543 route: &DeviceRoute,
544 r: u8,
545 g: u8,
546 b: u8,
547) -> Result<(), WriteError> {
548 set_keyboard_color_with(route, LightingMethod::Auto, r, g, b).await
549}
550
551pub async fn set_keyboard_color_with(
555 route: &DeviceRoute,
556 method: LightingMethod,
557 r: u8,
558 g: u8,
559 b: u8,
560) -> Result<(), WriteError> {
561 match method {
562 LightingMethod::PerKey => set_color_per_key(route, r, g, b).await,
563 LightingMethod::Effects => set_color_effects(route, r, g, b).await,
564 LightingMethod::Auto => match set_color_effects(route, r, g, b).await {
565 Err(WriteError::FeatureUnsupported { feature_hex })
566 if feature_hex == COLOR_LED_EFFECTS_FEATURE =>
567 {
568 debug!("no 0x8070 effect engine — falling back to 0x8080 per-key");
569 set_color_per_key(route, r, g, b).await
570 }
571 other => other,
572 },
573 }
574}
575
576async fn resolve_feature_index(
580 route: &DeviceRoute,
581 feature_id: u16,
582) -> Result<Option<u8>, WriteError> {
583 let device_index = route.device_index();
584 with_route(route, move |channel| async move {
585 let device = Device::new(Arc::clone(&channel), device_index)
586 .await
587 .map_err(|_| WriteError::DeviceUnreachable {
588 index: device_index,
589 })?;
590 let info = device
591 .root()
592 .get_feature(feature_id)
593 .await
594 .map_err(|e| WriteError::Hidpp(format!("{e:?}")))?;
595 Ok(info.map(|i| i.index))
596 })
597 .await
598}
599
600async fn set_color_effects(route: &DeviceRoute, r: u8, g: u8, b: u8) -> Result<(), WriteError> {
604 let device_index = route.device_index();
605 let feature_index = resolve_feature_index(route, COLOR_LED_EFFECTS_FEATURE)
606 .await?
607 .ok_or(WriteError::FeatureUnsupported {
608 feature_hex: COLOR_LED_EFFECTS_FEATURE,
609 })?;
610
611 let Some(mut writer) = crate::transport::open_route_writer(route).await? else {
612 return Err(WriteError::DeviceNotFound);
613 };
614 for zone in 0..MAX_LIGHTING_ZONES {
615 let mut rep = vec![0u8; 20];
616 rep[0] = REPORT_LONG;
617 rep[1] = device_index;
618 rep[2] = feature_index;
619 rep[3] = (FN_SET_ZONE_EFFECT << 4) | SW_ID;
620 rep[4] = zone;
621 rep[5] = EFFECT_FIXED;
622 rep[6] = r;
623 rep[7] = g;
624 rep[8] = b;
625 rep[16] = PERSIST_RAM_ONLY;
626 writer
627 .write_output_report(&rep)
628 .await
629 .map_err(WriteError::from)?;
630 tokio::time::sleep(FRAME_GAP).await;
631 }
632 debug!(
633 device_index,
634 feature_index, r, g, b, "set keyboard colour via 0x8070"
635 );
636 Ok(())
637}
638
639async fn set_color_per_key(route: &DeviceRoute, r: u8, g: u8, b: u8) -> Result<(), WriteError> {
643 let device_index = route.device_index();
644 let feature_index = resolve_feature_index(route, PER_KEY_LIGHTING_FEATURE)
645 .await?
646 .ok_or(WriteError::FeatureUnsupported {
647 feature_hex: PER_KEY_LIGHTING_FEATURE,
648 })?;
649
650 let Some(mut writer) = crate::transport::open_route_writer(route).await? else {
651 return Err(WriteError::DeviceNotFound);
652 };
653 let key_ids: Vec<u8> = (0x00u8..=0xe8).collect();
658 for chunk in key_ids.chunks(KEYS_PER_FRAME as usize) {
659 let mut rep = vec![0u8; 64];
660 rep[0] = REPORT_SET_KEYS;
661 rep[1] = device_index;
662 rep[2] = feature_index;
663 rep[3] = (FN_SET_KEY_RANGE << 4) | SW_ID;
664 rep[5] = SET_RANGE_MODE;
665 rep[7] = KEYS_PER_FRAME;
666 for (i, &key) in chunk.iter().enumerate() {
667 let off = 8 + i * 4;
668 rep[off] = key;
669 rep[off + 1] = r;
670 rep[off + 2] = g;
671 rep[off + 3] = b;
672 }
673 writer
674 .write_output_report(&rep)
675 .await
676 .map_err(WriteError::from)?;
677 }
678 let mut commit = vec![0u8; 20];
679 commit[0] = REPORT_LONG;
680 commit[1] = device_index;
681 commit[2] = feature_index;
682 commit[3] = (FN_FRAME_END << 4) | SW_ID;
683 writer
684 .write_output_report(&commit)
685 .await
686 .map_err(WriteError::from)?;
687 debug!(
688 device_index,
689 feature_index, r, g, b, "set keyboard colour via 0x8080"
690 );
691 Ok(())
692}
693
694async fn set_dpi_on_channel(
698 channel: &Arc<HidppChannel>,
699 index: u8,
700 dpi: u16,
701) -> Result<(), WriteError> {
702 let mut device = Device::new(Arc::clone(channel), index)
703 .await
704 .map_err(|_| WriteError::DeviceUnreachable { index })?;
705 let feature = open_feature::<AdjustableDpiFeature>(&mut device).await?;
706 feature
707 .set_sensor_dpi(0, dpi)
708 .await
709 .map_err(|e| WriteError::Hidpp(format!("{e:?}")))?;
710 if let Ok(actual) = feature.get_sensor_dpi(0).await {
716 if actual == dpi {
717 debug!(index, dpi, "wrote DPI (verified)");
718 } else {
719 tracing::warn!(
720 index,
721 requested = dpi,
722 actual,
723 "DPI write accepted but device reports a different value — \
724 likely out of the device's supported range"
725 );
726 }
727 } else {
728 debug!(index, dpi, "wrote DPI (read-back skipped)");
729 }
730 Ok(())
731}
732
733pub async fn toggle_smartshift(route: &DeviceRoute) -> Result<SmartShiftMode, WriteError> {
741 let index = route.device_index();
742 with_route(route, move |channel| async move {
743 toggle_smartshift_on_channel(&channel, index).await
744 })
745 .await
746}
747
748async fn toggle_smartshift_on_channel(
751 channel: &Arc<HidppChannel>,
752 index: u8,
753) -> Result<SmartShiftMode, WriteError> {
754 let mut device = Device::new(Arc::clone(channel), index)
755 .await
756 .map_err(|_| WriteError::DeviceUnreachable { index })?;
757 let smartshift = SmartShift::open(&mut device).await?;
758 let status = smartshift.status().await?;
759 let next = status.mode.flipped();
760 smartshift
761 .set_status(SmartShiftStatus {
762 mode: next,
763 ..status
764 })
765 .await?;
766 debug!(index, ?next, "wrote SmartShift mode");
767 Ok(next)
768}
769
770pub async fn set_smartshift(
779 route: &DeviceRoute,
780 mode: SmartShiftMode,
781 auto_disengage: u8,
782 tunable_torque: u8,
783) -> Result<(), WriteError> {
784 let index = route.device_index();
785 with_route(route, move |channel| async move {
786 set_smartshift_on_channel(&channel, index, mode, auto_disengage, tunable_torque).await
787 })
788 .await
789}
790
791async fn set_smartshift_on_channel(
794 channel: &Arc<HidppChannel>,
795 index: u8,
796 mode: SmartShiftMode,
797 auto_disengage: u8,
798 tunable_torque: u8,
799) -> Result<(), WriteError> {
800 let mut device = Device::new(Arc::clone(channel), index)
801 .await
802 .map_err(|_| WriteError::DeviceUnreachable { index })?;
803 let smartshift = SmartShift::open(&mut device).await?;
804 smartshift
805 .set_status(SmartShiftStatus {
806 mode,
807 auto_disengage,
808 tunable_torque,
809 })
810 .await?;
811 debug!(
812 index,
813 ?mode,
814 auto_disengage,
815 tunable_torque,
816 "wrote SmartShift config"
817 );
818 Ok(())
819}
820
821#[derive(Clone)]
829pub struct SharedChannel {
830 channel: Arc<HidppChannel>,
831 route: DeviceRoute,
832}
833
834impl SharedChannel {
835 #[must_use]
837 pub(crate) fn new(channel: Arc<HidppChannel>, route: DeviceRoute) -> Self {
838 Self { channel, route }
839 }
840
841 #[must_use]
844 pub fn matches(&self, route: &DeviceRoute) -> bool {
845 self.route == *route
846 }
847}
848
849pub async fn set_dpi_on(shared: &SharedChannel, dpi: u16) -> Result<(), WriteError> {
852 set_dpi_on_channel(&shared.channel, shared.route.device_index(), dpi).await
853}
854
855pub async fn toggle_smartshift_on(shared: &SharedChannel) -> Result<SmartShiftMode, WriteError> {
857 toggle_smartshift_on_channel(&shared.channel, shared.route.device_index()).await
858}
859
860pub async fn set_smartshift_on(
863 shared: &SharedChannel,
864 mode: SmartShiftMode,
865 auto_disengage: u8,
866 tunable_torque: u8,
867) -> Result<(), WriteError> {
868 set_smartshift_on_channel(
869 &shared.channel,
870 shared.route.device_index(),
871 mode,
872 auto_disengage,
873 tunable_torque,
874 )
875 .await
876}
877
878async fn with_route<F, Fut, T>(route: &DeviceRoute, f: F) -> Result<T, WriteError>
881where
882 F: FnOnce(Arc<HidppChannel>) -> Fut,
883 Fut: std::future::Future<Output = Result<T, WriteError>>,
884{
885 match open_route_channel(route).await? {
886 Some(channel) => f(channel).await,
887 None => Err(WriteError::DeviceNotFound),
888 }
889}
890
891#[cfg(test)]
892mod tests {
893 use super::*;
894
895 #[test]
896 fn capabilities_sort_and_deduplicate_values() -> Result<(), WriteError> {
897 let caps = DpiCapabilities::new(vec![1600, 400, 800, 800])?;
898
899 assert_eq!(caps.values(), [400, 800, 1600]);
900 assert_eq!(caps.min(), 400);
901 assert_eq!(caps.max(), 1600);
902 Ok(())
903 }
904
905 #[test]
906 fn capabilities_reject_empty_list() {
907 assert!(matches!(
908 DpiCapabilities::new(Vec::new()),
909 Err(WriteError::EmptyDpiList)
910 ));
911 }
912
913 #[test]
914 fn nearest_returns_closest_supported_value() -> Result<(), WriteError> {
915 let caps = DpiCapabilities::new(vec![400, 800, 1600])?;
916
917 assert_eq!(caps.nearest(390), 400);
918 assert_eq!(caps.nearest(1000), 800);
919 assert_eq!(caps.nearest(2000), 1600);
920 Ok(())
921 }
922
923 #[test]
924 fn step_hint_returns_smallest_positive_gap() -> Result<(), WriteError> {
925 let caps = DpiCapabilities::new(vec![400, 800, 1200, 2000])?;
926
927 assert_eq!(caps.step_hint(), 400);
928 Ok(())
929 }
930
931 #[test]
932 fn adjacent_test_target_prefers_next_then_previous_value() -> Result<(), WriteError> {
933 let caps = DpiCapabilities::new(vec![400, 800, 1600])?;
934
935 assert_eq!(caps.adjacent_test_target(400), Some(800));
936 assert_eq!(caps.adjacent_test_target(800), Some(1600));
937 assert_eq!(caps.adjacent_test_target(1600), Some(800));
938 Ok(())
939 }
940
941 #[test]
942 fn adjacent_test_target_handles_current_outside_list() -> Result<(), WriteError> {
943 let caps = DpiCapabilities::new(vec![400, 800, 1600])?;
944
945 assert_eq!(caps.adjacent_test_target(1000), Some(1600));
946 assert_eq!(caps.adjacent_test_target(2000), Some(1600));
947 Ok(())
948 }
949
950 #[test]
951 fn smartshift_and_wheel_mode_byte_encodings_match() {
952 assert_eq!(
956 u8::from(SmartShiftMode::Free),
957 u8::from(WheelMode::Freespin)
958 );
959 assert_eq!(
960 u8::from(SmartShiftMode::Ratchet),
961 u8::from(WheelMode::Ratchet)
962 );
963 }
964
965 #[test]
966 fn wheel_mode_maps_to_smartshift_mode() {
967 assert_eq!(
968 wheel_mode_to_smartshift(WheelMode::Freespin),
969 SmartShiftMode::Free
970 );
971 assert_eq!(
972 wheel_mode_to_smartshift(WheelMode::Ratchet),
973 SmartShiftMode::Ratchet
974 );
975 }
976
977 #[test]
978 fn smartshift_to_wheel_round_trips() {
979 for mode in [SmartShiftMode::Free, SmartShiftMode::Ratchet] {
981 assert_eq!(wheel_mode_to_smartshift(smartshift_to_wheel(mode)), mode);
982 }
983 }
984
985 #[test]
986 fn missing_enhanced_triggers_fallback() {
987 assert!(is_missing_enhanced(&WriteError::FeatureUnsupported {
988 feature_hex: 0x2111,
989 }));
990 }
991
992 #[test]
993 fn missing_legacy_does_not_trigger_fallback() {
994 assert!(!is_missing_enhanced(&WriteError::FeatureUnsupported {
997 feature_hex: 0x2110,
998 }));
999 }
1000
1001 #[test]
1002 fn transport_errors_do_not_trigger_fallback() {
1003 assert!(!is_missing_enhanced(&WriteError::DeviceUnreachable {
1005 index: 0xff,
1006 }));
1007 assert!(!is_missing_enhanced(&WriteError::Hidpp("boom".into())));
1008 }
1009}