diff --git a/crates/openlogi-hidpp/src/feature/crown/event.rs b/crates/openlogi-hidpp/src/feature/crown/event.rs index 59a2b422..d9b20b7d 100644 --- a/crates/openlogi-hidpp/src/feature/crown/event.rs +++ b/crates/openlogi-hidpp/src/feature/crown/event.rs @@ -1,9 +1,9 @@ //! The event emitted by the `Crown` feature (`0x4600`). -use num_enum::{IntoPrimitive, TryFromPrimitive}; +use num_enum::{FromPrimitive, IntoPrimitive}; /// Rotation phase reported in a [`CrownUpdate`]. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, FromPrimitive)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[non_exhaustive] #[repr(u8)] @@ -16,10 +16,13 @@ pub enum RotationState { Active = 2, /// Rotation stopped. Stop = 3, + /// A state this crate does not model; carries the raw byte. + #[num_enum(catch_all)] + Other(u8), } /// Proximity or touch activity phase reported in a [`CrownUpdate`]. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, FromPrimitive)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[non_exhaustive] #[repr(u8)] @@ -32,10 +35,13 @@ pub enum ActivityState { Active = 2, /// Stopped. Stop = 3, + /// A state this crate does not model; carries the raw byte. + #[num_enum(catch_all)] + Other(u8), } /// Touch gesture reported in a [`CrownUpdate`]. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, FromPrimitive)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[non_exhaustive] #[repr(u8)] @@ -46,10 +52,13 @@ pub enum CrownGesture { Tap = 1, /// Double tap. DoubleTap = 2, + /// A gesture this crate does not model; carries the raw byte. + #[num_enum(catch_all)] + Other(u8), } /// Crown button state reported in a [`CrownUpdate`]. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, FromPrimitive)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[non_exhaustive] #[repr(u8)] @@ -66,6 +75,9 @@ pub enum ButtonState { LongPressActive = 4, /// Released. Release = 5, + /// A state this crate does not model; carries the raw byte. + #[num_enum(catch_all)] + Other(u8), } /// An event emitted by [`CrownFeature`](super::CrownFeature). @@ -107,13 +119,13 @@ pub struct CrownUpdate { pub(super) fn decode_event(sub_id: u8, payload: &[u8; 16]) -> Option { match sub_id { 0 => Some(CrownEvent::Update(CrownUpdate { - rotation_state: RotationState::try_from(payload[0]).ok()?, + rotation_state: RotationState::from(payload[0]), relative_slot_rotation: payload[1] as i8, relative_ratchet_rotation: payload[2] as i8, - proximity: ActivityState::try_from(payload[3]).ok()?, - touch: ActivityState::try_from(payload[4]).ok()?, - gesture: CrownGesture::try_from(payload[5]).ok()?, - button: ButtonState::try_from(payload[6]).ok()?, + proximity: ActivityState::from(payload[3]), + touch: ActivityState::from(payload[4]), + gesture: CrownGesture::from(payload[5]), + button: ButtonState::from(payload[6]), speed: i16::from_be_bytes([payload[14], payload[15]]), })), _ => None, diff --git a/crates/openlogi-hidpp/src/feature/crown/tests.rs b/crates/openlogi-hidpp/src/feature/crown/tests.rs index 78b4e9ea..bdb5559f 100644 --- a/crates/openlogi-hidpp/src/feature/crown/tests.rs +++ b/crates/openlogi-hidpp/src/feature/crown/tests.rs @@ -64,10 +64,32 @@ fn ignores_unknown_event_sub_id() { } #[test] -fn ignores_event_with_unknown_enum() { +fn keeps_event_with_unknown_enum_field() { let mut payload = [0; 16]; + payload[1] = 0xfb; // -5 slots — a valid sibling field that must survive payload[6] = 0x09; // out-of-range button state - assert!(decode_event(0, &payload).is_none()); + let CrownEvent::Update(update) = decode_event(0, &payload).expect("event kept"); + assert_eq!(update.button, ButtonState::Other(0x09)); + assert_eq!(update.relative_slot_rotation, -5); +} + +/// Totality: for a known sub-id, no single field byte value may drop the whole +/// event. Sweeps every value of each enum-typed field position. +#[test] +fn known_sub_id_survives_any_field_byte() { + for byte in 0..=u8::MAX { + for pos in [0usize, 3, 4, 5, 6] { + let mut payload = [0; 16]; + payload[1] = 0xfb; // sibling that must always survive + payload[pos] = byte; + let CrownEvent::Update(update) = decode_event(0, &payload) + .unwrap_or_else(|| panic!("dropped event for payload[{pos}]={byte:#04x}")); + assert_eq!( + update.relative_slot_rotation, -5, + "sibling lost for payload[{pos}]={byte:#04x}" + ); + } + } } #[test] diff --git a/crates/openlogi-hidpp/src/feature/extended_dpi/event.rs b/crates/openlogi-hidpp/src/feature/extended_dpi/event.rs index 78adf834..04719f7c 100644 --- a/crates/openlogi-hidpp/src/feature/extended_dpi/event.rs +++ b/crates/openlogi-hidpp/src/feature/extended_dpi/event.rs @@ -25,8 +25,9 @@ pub struct DpiParametersChanged { pub dpi_x: u16, /// New Y-axis DPI, or `0` when the sensor has no independent Y axis. pub dpi_y: u16, - /// New lift-off distance. - pub lod: Lod, + /// New lift-off distance, or `None` when the device reported a value this + /// crate does not model (the rest of the event is still delivered). + pub lod: Option, } /// Payload of [`ExtendedDpiEvent::CalibrationCompleted`]. @@ -36,8 +37,9 @@ pub struct DpiParametersChanged { pub struct DpiCalibrationCompleted { /// Index of the sensor. pub sensor_index: u8, - /// Axis that was calibrated. - pub direction: DpiDirection, + /// Axis that was calibrated, or `None` when the device reported a value + /// this crate does not model (the rest of the event is still delivered). + pub direction: Option, /// Calibration correction value; [`i16::MIN`] (`0x8000`) signals a /// sensor-level calibration failure (see [`Self::failed`]). pub correction: i16, @@ -65,12 +67,12 @@ pub(super) fn decode_event(sub_id: u8, payload: &[u8; 16]) -> Option Some(ExtendedDpiEvent::CalibrationCompleted( DpiCalibrationCompleted { sensor_index: payload[0], - direction: DpiDirection::try_from(payload[1]).ok()?, + direction: DpiDirection::try_from(payload[1]).ok(), correction: i16::from_be_bytes([payload[2], payload[3]]), delta: i16::from_be_bytes([payload[4], payload[5]]), }, diff --git a/crates/openlogi-hidpp/src/feature/extended_dpi/tests.rs b/crates/openlogi-hidpp/src/feature/extended_dpi/tests.rs index 8966f7b3..9bce55d8 100644 --- a/crates/openlogi-hidpp/src/feature/extended_dpi/tests.rs +++ b/crates/openlogi-hidpp/src/feature/extended_dpi/tests.rs @@ -229,7 +229,7 @@ fn decodes_parameters_changed_event() { assert_eq!(event.sensor_index, 1); assert_eq!(event.dpi_x, 800); assert_eq!(event.dpi_y, 1600); - assert_eq!(event.lod, Lod::Medium); + assert_eq!(event.lod, Some(Lod::Medium)); } #[test] @@ -242,7 +242,7 @@ fn decodes_calibration_completed_event() { let ExtendedDpiEvent::CalibrationCompleted(event) = decode_event(1, &payload).unwrap() else { panic!("expected a calibration-completed event"); }; - assert_eq!(event.direction, DpiDirection::Y); + assert_eq!(event.direction, Some(DpiDirection::Y)); assert_eq!(event.correction, 100); assert_eq!(event.delta, -1); assert!(!event.failed()); @@ -265,11 +265,37 @@ fn ignores_unknown_event_sub_id() { } #[test] -fn ignores_event_with_unknown_lod() { +fn keeps_event_with_unknown_lod() { let mut payload = [0; 16]; + payload[0] = 3; // sensor index — a valid sibling that must survive payload[5] = 9; - assert!(decode_event(0, &payload).is_none()); + let ExtendedDpiEvent::ParametersChanged(changed) = + decode_event(0, &payload).expect("event kept") + else { + panic!("expected ParametersChanged"); + }; + // Lod stays a closed, write-safe enum; an unknown lift-off value surfaces + // as None on the event without dropping the DPI change. + assert_eq!(changed.lod, None); + assert_eq!(changed.sensor_index, 3); +} + +/// Totality: a known event sub-id must decode for any enum-field byte value. +#[test] +fn known_sub_id_survives_any_field_byte() { + for byte in 0..=u8::MAX { + // sub 0: Lod at payload[5]; sub 1: DpiDirection at payload[1]. + for (sub_id, pos) in [(0u8, 5usize), (1, 1)] { + let mut payload = [0; 16]; + payload[0] = 3; // sensor index sibling + payload[pos] = byte; + assert!( + decode_event(sub_id, &payload).is_some(), + "dropped sub {sub_id} for payload[{pos}]={byte:#04x}" + ); + } + } } #[test] diff --git a/crates/openlogi-hidpp/src/feature/illumination/event.rs b/crates/openlogi-hidpp/src/feature/illumination/event.rs index 39fd0dbf..3f5aa332 100644 --- a/crates/openlogi-hidpp/src/feature/illumination/event.rs +++ b/crates/openlogi-hidpp/src/feature/illumination/event.rs @@ -39,7 +39,7 @@ pub(super) fn decode_event(sub_id: u8, payload: &[u8; 16]) -> Option Some(IlluminationEvent::BrightnessClamped { - source: BrightnessClampedSource::try_from(payload[0]).ok()?, + source: BrightnessClampedSource::from(payload[0]), brightness: be16(payload, 1), }), _ => None, diff --git a/crates/openlogi-hidpp/src/feature/illumination/tests.rs b/crates/openlogi-hidpp/src/feature/illumination/tests.rs index 9ec7de84..5f4a1779 100644 --- a/crates/openlogi-hidpp/src/feature/illumination/tests.rs +++ b/crates/openlogi-hidpp/src/feature/illumination/tests.rs @@ -221,3 +221,23 @@ fn decodes_brightness_clamped_event() { fn ignores_unknown_event_sub_id() { assert!(decode_event(9, &[0; 16]).is_none()); } + +/// Totality: the clamp-source event must decode for any source byte; an +/// unknown source folds to `Other(_)` and the brightness sibling survives. +#[test] +fn keeps_clamp_event_with_unknown_source() { + for byte in 0..=u8::MAX { + let mut payload = [0; 16]; + payload[0] = byte; // BrightnessClampedSource + payload[1..3].copy_from_slice(&300u16.to_be_bytes()); // brightness sibling + let event = decode_event(4, &payload) + .unwrap_or_else(|| panic!("dropped clamp event for source {byte:#04x}")); + assert_eq!( + event, + IlluminationEvent::BrightnessClamped { + source: BrightnessClampedSource::from(byte), + brightness: 300, + } + ); + } +} diff --git a/crates/openlogi-hidpp/src/feature/illumination/types.rs b/crates/openlogi-hidpp/src/feature/illumination/types.rs index 5e53c7dc..a2333543 100644 --- a/crates/openlogi-hidpp/src/feature/illumination/types.rs +++ b/crates/openlogi-hidpp/src/feature/illumination/types.rs @@ -1,6 +1,6 @@ //! Domain types for the `Illumination` feature (`0x1990`). -use num_enum::{IntoPrimitive, TryFromPrimitive}; +use num_enum::{FromPrimitive, IntoPrimitive, TryFromPrimitive}; use crate::protocol::v20::{ErrorType, Hidpp20Error}; @@ -189,7 +189,7 @@ impl From for IlluminationState { } /// What caused a [`brightness clamp`](super::event::IlluminationEvent::BrightnessClamped). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, FromPrimitive)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[non_exhaustive] #[repr(u8)] @@ -200,6 +200,9 @@ pub enum BrightnessClampedSource { HidPlusPlus = 1, /// A hardware button triggered the clamp. Button = 2, + /// A source this crate does not model; carries the raw byte. + #[num_enum(catch_all)] + Other(u8), } /// Decodes the on/off state bit shared by `getIllumination` and its event. diff --git a/crates/openlogi-hidpp/src/feature/rgb_effects/event.rs b/crates/openlogi-hidpp/src/feature/rgb_effects/event.rs index 184cc882..24b0cd48 100644 --- a/crates/openlogi-hidpp/src/feature/rgb_effects/event.rs +++ b/crates/openlogi-hidpp/src/feature/rgb_effects/event.rs @@ -33,8 +33,9 @@ pub enum RgbEffectsEvent { params: [u8; CLUSTER_EFFECT_PARAM_COUNT], /// Persistence the effect was applied with. persistence: RgbPersistence, - /// Power-mode target the effect applies to. - power_mode: PowerModeTarget, + /// Power-mode target the effect applies to, or `None` when the device + /// reported a value this crate does not model. + power_mode: Option, }, } @@ -45,9 +46,9 @@ pub(super) fn decode_event(sub_id: u8, payload: &[u8; 16]) -> Option Some(RgbEffectsEvent::UserActivity( - ActivityEventType::try_from(payload[0]).ok()?, - )), + 1 => Some(RgbEffectsEvent::UserActivity(ActivityEventType::from( + payload[0], + ))), 2 => { let mut params = [0; CLUSTER_EFFECT_PARAM_COUNT]; params.copy_from_slice(&payload[2..2 + CLUSTER_EFFECT_PARAM_COUNT]); @@ -62,7 +63,7 @@ pub(super) fn decode_event(sub_id: u8, payload: &[u8; 16]) -> Option> POWER_TARGET_SHIFT) & FLAGS_FIELD_MASK, ) - .ok()?, + .ok(), }) } _ => None, diff --git a/crates/openlogi-hidpp/src/feature/rgb_effects/tests.rs b/crates/openlogi-hidpp/src/feature/rgb_effects/tests.rs index 1f125484..3f4b4ebf 100644 --- a/crates/openlogi-hidpp/src/feature/rgb_effects/tests.rs +++ b/crates/openlogi-hidpp/src/feature/rgb_effects/tests.rs @@ -130,7 +130,7 @@ fn decodes_cluster_changed_event() { cluster_effect_index: 2, params: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], persistence: RgbPersistence::VOLATILE, - power_mode: PowerModeTarget::PowerSave, + power_mode: Some(PowerModeTarget::PowerSave), }) ); } @@ -156,3 +156,35 @@ fn maps_stable_enum_wire_values() { ); assert!(SlotInfoType::try_from(7u8).is_err()); } + +/// Totality: the user-activity event must decode for any activity byte. +#[test] +fn keeps_user_activity_event_with_unknown_type() { + for byte in 0..=u8::MAX { + let mut payload = [0; 16]; + payload[0] = byte; + assert_eq!( + decode_event(1, &payload), + Some(RgbEffectsEvent::UserActivity(ActivityEventType::from(byte))), + "dropped user-activity event for byte {byte:#04x}" + ); + } +} + +/// Totality: the cluster-changed event must decode for any flags byte. The +/// 2-bit power-mode nibble can hold values 2 and 3 that the enum does not +/// model; those must surface as `None` without dropping the event. +#[test] +fn keeps_cluster_changed_event_with_unknown_power_mode() { + for flags in 0..=u8::MAX { + let mut payload = [0; 16]; + payload[0] = 1; // cluster_index sibling that must survive + payload[12] = flags; + let event = decode_event(2, &payload) + .unwrap_or_else(|| panic!("dropped cluster-changed event for flags {flags:#04x}")); + let RgbEffectsEvent::ClusterChanged { cluster_index, .. } = event else { + panic!("expected ClusterChanged"); + }; + assert_eq!(cluster_index, 1); + } +} diff --git a/crates/openlogi-hidpp/src/feature/rgb_effects/types.rs b/crates/openlogi-hidpp/src/feature/rgb_effects/types.rs index e715558a..8460d4f1 100644 --- a/crates/openlogi-hidpp/src/feature/rgb_effects/types.rs +++ b/crates/openlogi-hidpp/src/feature/rgb_effects/types.rs @@ -1,6 +1,6 @@ //! Domain types for the `RgbEffects` feature (`0x8071`). -use num_enum::{IntoPrimitive, TryFromPrimitive}; +use num_enum::{FromPrimitive, IntoPrimitive, TryFromPrimitive}; /// Number of effect parameters carried by `setRgbClusterEffect`. pub const CLUSTER_EFFECT_PARAM_COUNT: usize = 10; @@ -96,7 +96,7 @@ pub enum LedBinIndex { } /// The kind of user-activity event. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, FromPrimitive)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[non_exhaustive] #[repr(u8)] @@ -105,6 +105,9 @@ pub enum ActivityEventType { NoActivityTimeoutReached = 0, /// User activity was detected. UserActivityDetected = 1, + /// A type this crate does not model; carries the raw byte. + #[num_enum(catch_all)] + Other(u8), } bitflags::bitflags! {