From 108cadbf094f84dfb3442b90f0a97119f07a4fdb Mon Sep 17 00:00:00 2001 From: Jace Date: Mon, 20 Jul 2026 12:23:44 -0700 Subject: [PATCH 1/2] fix(hidpp): keep events when a field carries an unknown enum value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Event listener closures have no error channel, so a single unrecognised field byte made the whole event decode return None — a crown update with an unknown gesture, for example, lost its valid rotation deltas too. Sweep the remaining event decoders (crown, extended_dpi, illumination, rgb_effects) onto the tolerant pattern #381 established, choosing the idiom per how the enum is used: - Event-read-only enums gain a num_enum(catch_all) Other(u8) variant so an unknown wire value carries its raw byte instead of dropping the event. - Lod is also written back to the device via SetDpiParameters, so it must stay a closed enum to keep invalid lift-off values unrepresentable; its event field becomes Option instead, leaving the write and response paths strict (unknown values there still surface UnsupportedResponse). Unknown event sub-ids still return None: an unknown event kind has no payload shape to decode, unlike an unknown field within a known event. RawWheelResolution and IlluminationState are masked to their full value range at the call site and cannot fail, so they are left unchanged. --- .../openlogi-hidpp/src/feature/crown/event.rs | 32 +++++++++++++------ .../openlogi-hidpp/src/feature/crown/tests.rs | 26 +++++++++++++-- .../src/feature/extended_dpi/event.rs | 9 +++--- .../src/feature/extended_dpi/tests.rs | 32 +++++++++++++++++-- .../src/feature/extended_dpi/types.rs | 7 ++-- .../src/feature/illumination/event.rs | 2 +- .../src/feature/illumination/tests.rs | 20 ++++++++++++ .../src/feature/illumination/types.rs | 7 ++-- .../src/feature/rgb_effects/event.rs | 11 +++---- .../src/feature/rgb_effects/tests.rs | 14 ++++++++ .../src/feature/rgb_effects/types.rs | 12 +++++-- 11 files changed, 138 insertions(+), 34 deletions(-) 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..916e933a 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`]. @@ -65,12 +66,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::from(payload[1]), 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..2630cddf 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] @@ -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/extended_dpi/types.rs b/crates/openlogi-hidpp/src/feature/extended_dpi/types.rs index c2396a9b..70fc35ba 100644 --- a/crates/openlogi-hidpp/src/feature/extended_dpi/types.rs +++ b/crates/openlogi-hidpp/src/feature/extended_dpi/types.rs @@ -1,11 +1,11 @@ //! Domain types and payload parsers for `ExtendedAdjustableDpi` (`0x2202`). -use num_enum::{IntoPrimitive, TryFromPrimitive}; +use num_enum::{FromPrimitive, IntoPrimitive, TryFromPrimitive}; use crate::protocol::v20::{ErrorType, Hidpp20Error}; /// The axis a DPI value or calibration applies to. -#[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)] @@ -14,6 +14,9 @@ pub enum DpiDirection { X = 0, /// Vertical (Y) axis. Y = 1, + /// A direction this crate does not model; carries the raw byte. + #[num_enum(catch_all)] + Other(u8), } /// A sensor's lift-off distance setting. 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..80376cd8 100644 --- a/crates/openlogi-hidpp/src/feature/rgb_effects/event.rs +++ b/crates/openlogi-hidpp/src/feature/rgb_effects/event.rs @@ -45,9 +45,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]); @@ -59,10 +59,7 @@ pub(super) fn decode_event(sub_id: u8, payload: &[u8; 16]) -> Option> POWER_TARGET_SHIFT) & FLAGS_FIELD_MASK, - ) - .ok()?, + power_mode: PowerModeTarget::from((flags >> POWER_TARGET_SHIFT) & FLAGS_FIELD_MASK), }) } _ => 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..cd8e2348 100644 --- a/crates/openlogi-hidpp/src/feature/rgb_effects/tests.rs +++ b/crates/openlogi-hidpp/src/feature/rgb_effects/tests.rs @@ -156,3 +156,17 @@ 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}" + ); + } +} diff --git a/crates/openlogi-hidpp/src/feature/rgb_effects/types.rs b/crates/openlogi-hidpp/src/feature/rgb_effects/types.rs index e715558a..cf41e49a 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; @@ -64,7 +64,7 @@ pub enum RgbPowerMode { } /// The power-mode target an effect applies to, packed into `setRgbClusterEffect`. -#[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)] @@ -73,6 +73,9 @@ pub enum PowerModeTarget { FullPower = 0, /// Power-save mode. PowerSave = 1, + /// A target this crate does not model; carries the raw byte. + #[num_enum(catch_all)] + Other(u8), } /// Selects which LED bin parameter a LED-bin call addresses. @@ -96,7 +99,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 +108,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! { From 0df5078699729101c63be305bb85d282e805bdea Mon Sep 17 00:00:00 2001 From: Jace Date: Mon, 20 Jul 2026 12:38:12 -0700 Subject: [PATCH 2/2] fix(hidpp): keep DpiDirection and PowerModeTarget closed for write safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are also written back to the device — DpiDirection via the DPI request encoders and PowerModeTarget via set_rgb_cluster_effect — so giving them a catch_all Other(u8) variant would let an unknown value round-trip onto the wire, the same hazard that kept Lod closed. Treat them like Lod: the enum stays closed and only the event field becomes Option. Also add a totality sweep for the ClusterChanged power-mode nibble, which the 2-bit field can carry values the enum does not model. --- .../src/feature/extended_dpi/event.rs | 7 ++++--- .../src/feature/extended_dpi/tests.rs | 2 +- .../src/feature/extended_dpi/types.rs | 7 ++----- .../src/feature/rgb_effects/event.rs | 10 +++++++--- .../src/feature/rgb_effects/tests.rs | 20 ++++++++++++++++++- .../src/feature/rgb_effects/types.rs | 5 +---- 6 files changed, 34 insertions(+), 17 deletions(-) diff --git a/crates/openlogi-hidpp/src/feature/extended_dpi/event.rs b/crates/openlogi-hidpp/src/feature/extended_dpi/event.rs index 916e933a..04719f7c 100644 --- a/crates/openlogi-hidpp/src/feature/extended_dpi/event.rs +++ b/crates/openlogi-hidpp/src/feature/extended_dpi/event.rs @@ -37,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, @@ -71,7 +72,7 @@ pub(super) fn decode_event(sub_id: u8, payload: &[u8; 16]) -> Option Some(ExtendedDpiEvent::CalibrationCompleted( DpiCalibrationCompleted { sensor_index: payload[0], - direction: DpiDirection::from(payload[1]), + 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 2630cddf..9bce55d8 100644 --- a/crates/openlogi-hidpp/src/feature/extended_dpi/tests.rs +++ b/crates/openlogi-hidpp/src/feature/extended_dpi/tests.rs @@ -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()); diff --git a/crates/openlogi-hidpp/src/feature/extended_dpi/types.rs b/crates/openlogi-hidpp/src/feature/extended_dpi/types.rs index 70fc35ba..c2396a9b 100644 --- a/crates/openlogi-hidpp/src/feature/extended_dpi/types.rs +++ b/crates/openlogi-hidpp/src/feature/extended_dpi/types.rs @@ -1,11 +1,11 @@ //! Domain types and payload parsers for `ExtendedAdjustableDpi` (`0x2202`). -use num_enum::{FromPrimitive, IntoPrimitive, TryFromPrimitive}; +use num_enum::{IntoPrimitive, TryFromPrimitive}; use crate::protocol::v20::{ErrorType, Hidpp20Error}; /// The axis a DPI value or calibration applies to. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, FromPrimitive)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[non_exhaustive] #[repr(u8)] @@ -14,9 +14,6 @@ pub enum DpiDirection { X = 0, /// Vertical (Y) axis. Y = 1, - /// A direction this crate does not model; carries the raw byte. - #[num_enum(catch_all)] - Other(u8), } /// A sensor's lift-off distance setting. diff --git a/crates/openlogi-hidpp/src/feature/rgb_effects/event.rs b/crates/openlogi-hidpp/src/feature/rgb_effects/event.rs index 80376cd8..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, }, } @@ -59,7 +60,10 @@ pub(super) fn decode_event(sub_id: u8, payload: &[u8; 16]) -> Option> POWER_TARGET_SHIFT) & FLAGS_FIELD_MASK), + power_mode: PowerModeTarget::try_from( + (flags >> POWER_TARGET_SHIFT) & FLAGS_FIELD_MASK, + ) + .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 cd8e2348..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), }) ); } @@ -170,3 +170,21 @@ fn keeps_user_activity_event_with_unknown_type() { ); } } + +/// 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 cf41e49a..8460d4f1 100644 --- a/crates/openlogi-hidpp/src/feature/rgb_effects/types.rs +++ b/crates/openlogi-hidpp/src/feature/rgb_effects/types.rs @@ -64,7 +64,7 @@ pub enum RgbPowerMode { } /// The power-mode target an effect applies to, packed into `setRgbClusterEffect`. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, FromPrimitive)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[non_exhaustive] #[repr(u8)] @@ -73,9 +73,6 @@ pub enum PowerModeTarget { FullPower = 0, /// Power-save mode. PowerSave = 1, - /// A target this crate does not model; carries the raw byte. - #[num_enum(catch_all)] - Other(u8), } /// Selects which LED bin parameter a LED-bin call addresses.