From 786214693c6540dcaa562b31cce8f81c2cdfba89 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sat, 27 Jun 2026 17:40:24 +0800 Subject: [PATCH 01/21] feat(hidpp): add typed device control features --- .../src/feature/brightness_control/mod.rs | 130 ++++++++++++++ .../src/feature/extended_report_rate/mod.rs | 153 ++++++++++++++++ crates/openlogi-hidpp/src/feature/mod.rs | 7 + .../src/feature/mode_status/mod.rs | 124 +++++++++++++ crates/openlogi-hidpp/src/feature/registry.rs | 26 +-- .../src/feature/report_rate/mod.rs | 74 ++++++++ .../src/feature/sidetone/mod.rs | 77 +++++++++ .../src/feature/smartshift_enhanced/mod.rs | 163 ++++++++++++++++++ .../src/feature/vertical_scrolling/mod.rs | 123 +++++++++++++ 9 files changed, 866 insertions(+), 11 deletions(-) create mode 100644 crates/openlogi-hidpp/src/feature/brightness_control/mod.rs create mode 100644 crates/openlogi-hidpp/src/feature/extended_report_rate/mod.rs create mode 100644 crates/openlogi-hidpp/src/feature/mode_status/mod.rs create mode 100644 crates/openlogi-hidpp/src/feature/report_rate/mod.rs create mode 100644 crates/openlogi-hidpp/src/feature/sidetone/mod.rs create mode 100644 crates/openlogi-hidpp/src/feature/smartshift_enhanced/mod.rs create mode 100644 crates/openlogi-hidpp/src/feature/vertical_scrolling/mod.rs diff --git a/crates/openlogi-hidpp/src/feature/brightness_control/mod.rs b/crates/openlogi-hidpp/src/feature/brightness_control/mod.rs new file mode 100644 index 00000000..c51e198d --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/brightness_control/mod.rs @@ -0,0 +1,130 @@ +//! Implements `BrightnessControl` (feature `0x8040`). + +use std::sync::Arc; + +use crate::{ + channel::HidppChannel, + feature::{CreatableFeature, Feature, FeatureEndpoint}, + protocol::v20::Hidpp20Error, +}; + +bitflags::bitflags! { + /// Capabilities reported by `BrightnessControl`. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct BrightnessCapabilities: u8 { + /// Hardware can change brightness directly. + const HARDWARE_BRIGHTNESS = 1 << 0; + /// The device emits brightness or illumination change events. + const EVENTS = 1 << 1; + /// Illumination can be queried and controlled separately from brightness. + const ILLUMINATION = 1 << 2; + /// Hardware can toggle illumination on and off directly. + const HARDWARE_ON_OFF = 1 << 3; + /// Brightness is transient and not persisted by the device. + const TRANSIENT = 1 << 4; + } +} + +/// Brightness range and capability information. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct BrightnessInfo { + /// Minimum accepted brightness. + pub min_brightness: u16, + /// Maximum accepted brightness. + pub max_brightness: u16, + /// Number of brightness steps advertised by the device. + pub steps: u16, + /// Feature capabilities. + pub capabilities: BrightnessCapabilities, +} + +/// Implements the `BrightnessControl` / `0x8040` feature. +#[derive(Clone)] +pub struct BrightnessControlFeature { + /// The endpoint this feature talks to. + endpoint: FeatureEndpoint, +} + +impl CreatableFeature for BrightnessControlFeature { + const ID: u16 = 0x8040; + const STARTING_VERSION: u8 = 1; + + fn new(chan: Arc, device_index: u8, feature_index: u8) -> Self { + Self { + endpoint: FeatureEndpoint::new(chan, device_index, feature_index), + } + } +} + +impl Feature for BrightnessControlFeature {} + +impl BrightnessControlFeature { + /// Retrieves brightness range and capability information. + pub async fn get_info(&self) -> Result { + let payload = self.endpoint.call(0, [0; 3]).await?.extend_payload(); + Ok(BrightnessInfo::from_payload(payload)) + } + + /// Retrieves the current brightness value. + pub async fn get_brightness(&self) -> Result { + let payload = self.endpoint.call(1, [0; 3]).await?.extend_payload(); + Ok(u16::from_be_bytes([payload[0], payload[1]])) + } + + /// Sets the current brightness value. + pub async fn set_brightness(&self, brightness: u16) -> Result<(), Hidpp20Error> { + let [hi, lo] = brightness.to_be_bytes(); + self.endpoint.call(2, [hi, lo, 0]).await?; + Ok(()) + } + + /// Retrieves whether illumination is currently enabled. + pub async fn get_illumination(&self) -> Result { + Ok(self.endpoint.call(3, [0; 3]).await?.extend_payload()[0] & 1 != 0) + } + + /// Enables or disables illumination. + pub async fn set_illumination(&self, enabled: bool) -> Result<(), Hidpp20Error> { + self.endpoint.call(4, [u8::from(enabled), 0, 0]).await?; + Ok(()) + } +} + +impl BrightnessInfo { + fn from_payload(payload: [u8; 16]) -> Self { + Self { + min_brightness: u16::from_be_bytes([payload[4], payload[5]]), + max_brightness: u16::from_be_bytes([payload[0], payload[1]]), + steps: u16::from_be_bytes([payload[6], payload[2]]), + capabilities: BrightnessCapabilities::from_bits_retain(payload[3]), + } + } +} + +#[cfg(test)] +mod tests { + use super::{BrightnessCapabilities, BrightnessInfo}; + + #[test] + fn parses_split_steps_field() { + let mut payload = [0; 16]; + payload[0..=1].copy_from_slice(&1000u16.to_be_bytes()); + payload[2] = 0x34; + payload[3] = BrightnessCapabilities::ILLUMINATION.bits(); + payload[4..=5].copy_from_slice(&10u16.to_be_bytes()); + payload[6] = 0x12; + + let info = BrightnessInfo::from_payload(payload); + + assert_eq!(info.min_brightness, 10); + assert_eq!(info.max_brightness, 1000); + assert_eq!(info.steps, 0x1234); + assert!( + info.capabilities + .contains(BrightnessCapabilities::ILLUMINATION) + ); + } +} diff --git a/crates/openlogi-hidpp/src/feature/extended_report_rate/mod.rs b/crates/openlogi-hidpp/src/feature/extended_report_rate/mod.rs new file mode 100644 index 00000000..5a45d011 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/extended_report_rate/mod.rs @@ -0,0 +1,153 @@ +//! Implements `ExtendedAdjustableReportRate` (feature `0x8061`). + +use std::sync::Arc; + +use num_enum::{IntoPrimitive, TryFromPrimitive}; + +use crate::{ + channel::HidppChannel, + feature::{CreatableFeature, Feature, FeatureEndpoint}, + protocol::v20::Hidpp20Error, +}; + +bitflags::bitflags! { + /// Report-rate values supported by a `0x8061` device. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct ExtendedReportRateList: u16 { + /// 125 Hz, equivalent to an 8 ms report interval. + const HZ_125 = 1 << 0; + /// 250 Hz, equivalent to a 4 ms report interval. + const HZ_250 = 1 << 1; + /// 500 Hz, equivalent to a 2 ms report interval. + const HZ_500 = 1 << 2; + /// 1000 Hz, equivalent to a 1 ms report interval. + const HZ_1000 = 1 << 3; + /// 2000 Hz, equivalent to a 500 µs report interval. + const HZ_2000 = 1 << 4; + /// 4000 Hz, equivalent to a 250 µs report interval. + const HZ_4000 = 1 << 5; + /// 8000 Hz, equivalent to a 125 µs report interval. + const HZ_8000 = 1 << 6; + } +} + +/// A connection type used by `ExtendedAdjustableReportRate`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum ConnectionType { + /// Wired USB connection. + Wired = 0, + /// Logitech gaming wireless connection. + GamingWireless = 1, +} + +/// A concrete report-rate setting for `0x8061`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum ExtendedReportRate { + /// 125 Hz, equivalent to an 8 ms report interval. + Hz125 = 0, + /// 250 Hz, equivalent to a 4 ms report interval. + Hz250 = 1, + /// 500 Hz, equivalent to a 2 ms report interval. + Hz500 = 2, + /// 1000 Hz, equivalent to a 1 ms report interval. + Hz1000 = 3, + /// 2000 Hz, equivalent to a 500 µs report interval. + Hz2000 = 4, + /// 4000 Hz, equivalent to a 250 µs report interval. + Hz4000 = 5, + /// 8000 Hz, equivalent to a 125 µs report interval. + Hz8000 = 6, +} + +/// Implements the `ExtendedAdjustableReportRate` / `0x8061` feature. +#[derive(Clone)] +pub struct ExtendedReportRateFeature { + /// The endpoint this feature talks to. + endpoint: FeatureEndpoint, +} + +impl CreatableFeature for ExtendedReportRateFeature { + const ID: u16 = 0x8061; + const STARTING_VERSION: u8 = 0; + + fn new(chan: Arc, device_index: u8, feature_index: u8) -> Self { + Self { + endpoint: FeatureEndpoint::new(chan, device_index, feature_index), + } + } +} + +impl Feature for ExtendedReportRateFeature {} + +impl ExtendedReportRateFeature { + /// Retrieves the report rates supported by `connection_type`. + pub async fn get_device_capabilities( + &self, + connection_type: ConnectionType, + ) -> Result { + let payload = self + .endpoint + .call(0, [u8::from(connection_type), 0, 0]) + .await? + .extend_payload(); + Ok(report_rate_list_from_payload(payload)) + } + + /// Retrieves the report rates available for the device's current connection. + pub async fn get_actual_report_rate_list( + &self, + ) -> Result { + let payload = self.endpoint.call(1, [0; 3]).await?.extend_payload(); + Ok(report_rate_list_from_payload(payload)) + } + + /// Retrieves the active report rate for `connection_type`. + pub async fn get_report_rate( + &self, + connection_type: ConnectionType, + ) -> Result { + let payload = self + .endpoint + .call(2, [u8::from(connection_type), 0, 0]) + .await? + .extend_payload(); + ExtendedReportRate::try_from(payload[0]).map_err(|_| Hidpp20Error::UnsupportedResponse) + } + + /// Sets the report rate for the current host-side connection. + pub async fn set_report_rate( + &self, + report_rate: ExtendedReportRate, + ) -> Result<(), Hidpp20Error> { + self.endpoint.call(3, [u8::from(report_rate), 0, 0]).await?; + Ok(()) + } +} + +fn report_rate_list_from_payload(payload: [u8; 16]) -> ExtendedReportRateList { + ExtendedReportRateList::from_bits_retain(u16::from_be_bytes([payload[0], payload[1]])) +} + +#[cfg(test)] +mod tests { + use super::{ExtendedReportRateList, report_rate_list_from_payload}; + + #[test] + fn parses_report_rate_mask() { + let mut payload = [0; 16]; + payload[1] = 0b0100_1001; + + let rates = report_rate_list_from_payload(payload); + + assert!(rates.contains(ExtendedReportRateList::HZ_125)); + assert!(rates.contains(ExtendedReportRateList::HZ_1000)); + assert!(rates.contains(ExtendedReportRateList::HZ_8000)); + } +} diff --git a/crates/openlogi-hidpp/src/feature/mod.rs b/crates/openlogi-hidpp/src/feature/mod.rs index 03056834..b555bac6 100755 --- a/crates/openlogi-hidpp/src/feature/mod.rs +++ b/crates/openlogi-hidpp/src/feature/mod.rs @@ -9,17 +9,24 @@ use crate::{ }; pub mod adjustable_dpi; +pub mod brightness_control; pub mod device_friendly_name; pub mod device_information; pub mod device_type_and_name; +pub mod extended_report_rate; pub mod feature_set; pub mod hires_wheel; +pub mod mode_status; pub mod registry; +pub mod report_rate; pub mod reprog_controls; pub mod root; +pub mod sidetone; pub mod smartshift; +pub mod smartshift_enhanced; pub mod thumbwheel; pub mod unified_battery; +pub mod vertical_scrolling; pub mod wireless_device_status; /// Represents a concrete implementation of a HID++2.0 device feature. diff --git a/crates/openlogi-hidpp/src/feature/mode_status/mod.rs b/crates/openlogi-hidpp/src/feature/mode_status/mod.rs new file mode 100644 index 00000000..7bea823f --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/mode_status/mod.rs @@ -0,0 +1,124 @@ +//! Implements `ModeStatus` (feature `0x8090`). + +use std::sync::Arc; + +use crate::{ + channel::HidppChannel, + feature::{CreatableFeature, Feature, FeatureEndpoint}, + protocol::v20::Hidpp20Error, +}; + +bitflags::bitflags! { + /// The first mode-status byte. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct ModeStatus0: u8 { + /// Performance mode. When unset, the device is in endurance mode. + const PERFORMANCE = 1 << 0; + } +} + +bitflags::bitflags! { + /// Capabilities reported by `ModeStatus`. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct ModeStatusCapabilities: u16 { + /// A hardware switch can change the mode bit. + const HARDWARE_SWITCH = 1 << 0; + /// Software can change the mode bit. + const SOFTWARE_SWITCH = 1 << 1; + } +} + +/// Current mode-status bytes. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct ModeStatus { + /// Primary status bits. + pub status0: ModeStatus0, + /// Secondary status byte, reserved by v1 but preserved for callers. + pub status1: u8, +} + +/// A mode-status update request. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct ModeStatusChange { + /// Desired primary status bits. + pub status0: ModeStatus0, + /// Desired secondary status byte. + pub status1: u8, + /// Primary changed-bit mask. + pub changed_mask0: ModeStatus0, + /// Secondary changed-bit mask. + pub changed_mask1: u8, +} + +/// Implements the `ModeStatus` / `0x8090` feature. +#[derive(Clone)] +pub struct ModeStatusFeature { + /// The endpoint this feature talks to. + endpoint: FeatureEndpoint, +} + +impl CreatableFeature for ModeStatusFeature { + const ID: u16 = 0x8090; + const STARTING_VERSION: u8 = 1; + + fn new(chan: Arc, device_index: u8, feature_index: u8) -> Self { + Self { + endpoint: FeatureEndpoint::new(chan, device_index, feature_index), + } + } +} + +impl Feature for ModeStatusFeature {} + +impl ModeStatusFeature { + /// Retrieves the current mode status. + pub async fn get_mode_status(&self) -> Result { + let payload = self.endpoint.call(0, [0; 3]).await?.extend_payload(); + Ok(ModeStatus { + status0: ModeStatus0::from_bits_retain(payload[0]), + status1: payload[1], + }) + } + + /// Sets selected mode-status bits. + pub async fn set_mode_status(&self, change: ModeStatusChange) -> Result<(), Hidpp20Error> { + let mut args = [0; 16]; + args[0] = change.status0.bits(); + args[1] = change.status1; + args[2] = change.changed_mask0.bits(); + args[3] = change.changed_mask1; + + self.endpoint.call_long(1, args).await?; + Ok(()) + } + + /// Enables or disables performance mode. + pub async fn set_performance_mode(&self, enabled: bool) -> Result<(), Hidpp20Error> { + let status0 = if enabled { + ModeStatus0::PERFORMANCE + } else { + ModeStatus0::empty() + }; + self.set_mode_status(ModeStatusChange { + status0, + status1: 0, + changed_mask0: ModeStatus0::PERFORMANCE, + changed_mask1: 0, + }) + .await + } + + /// Retrieves device capabilities for mode switching. + pub async fn get_device_config(&self) -> Result { + let payload = self.endpoint.call(2, [0; 3]).await?.extend_payload(); + Ok(ModeStatusCapabilities::from_bits_retain( + u16::from_be_bytes([payload[0], payload[1]]), + )) + } +} diff --git a/crates/openlogi-hidpp/src/feature/registry.rs b/crates/openlogi-hidpp/src/feature/registry.rs index 6cbd1b22..f60e6ba9 100755 --- a/crates/openlogi-hidpp/src/feature/registry.rs +++ b/crates/openlogi-hidpp/src/feature/registry.rs @@ -12,12 +12,16 @@ use crate::{ channel::HidppChannel, feature::{ CreatableFeature, adjustable_dpi::AdjustableDpiFeature, + brightness_control::BrightnessControlFeature, device_friendly_name::DeviceFriendlyNameFeature, device_information::DeviceInformationFeature, - device_type_and_name::DeviceTypeAndNameFeature, feature_set::FeatureSetFeature, - hires_wheel::HiResWheelFeature, reprog_controls::ReprogControlsFeature, root::RootFeature, - smartshift::SmartShiftFeature, thumbwheel::ThumbwheelFeature, - unified_battery::UnifiedBatteryFeature, + device_type_and_name::DeviceTypeAndNameFeature, + extended_report_rate::ExtendedReportRateFeature, feature_set::FeatureSetFeature, + hires_wheel::HiResWheelFeature, mode_status::ModeStatusFeature, + report_rate::ReportRateFeature, reprog_controls::ReprogControlsFeature, root::RootFeature, + sidetone::SidetoneFeature, smartshift::SmartShiftFeature, + smartshift_enhanced::SmartShiftEnhancedFeature, thumbwheel::ThumbwheelFeature, + unified_battery::UnifiedBatteryFeature, vertical_scrolling::VerticalScrollingFeature, wireless_device_status::WirelessDeviceStatusFeature, }, }; @@ -154,9 +158,9 @@ static KNOWN_FEATURES: LazyLock> = LazyLock::new(|| { 0x2001 "SwapLeftRightButton", 0x2005 "ButtonSwapCancel", 0x2006 "PointerAxesOrientation", - 0x2100 "VerticalScrolling", + 0x2100 "VerticalScrolling" => VerticalScrollingFeature, 0x2110 "SmartShiftWheel" => SmartShiftFeature, - 0x2111 "SmartShiftWheelEnhanced", + 0x2111 "SmartShiftWheelEnhanced" => SmartShiftEnhancedFeature, 0x2120 "HighResolutionScrolling", 0x2121 "HiResWheel" => HiResWheelFeature, 0x2130 "RatchetWheel", @@ -198,20 +202,20 @@ static KNOWN_FEATURES: LazyLock> = LazyLock::new(|| { 0x8010 "GamingGKeys", 0x8020 "GamingMKeys", 0x8030 "MacroRecord", - 0x8040 "BrightnessControl", - 0x8060 "AdjustableReportRate", - 0x8061 "ExtendedAdjustableReportRate", + 0x8040 "BrightnessControl" => BrightnessControlFeature, + 0x8060 "AdjustableReportRate" => ReportRateFeature, + 0x8061 "ExtendedAdjustableReportRate" => ExtendedReportRateFeature, 0x8070 "ColorLedEffects", 0x8071 "RgbEffects", 0x8080 "PerKeyLighting", 0x8081 "PerKeyLighting2", - 0x8090 "ModeStatus", + 0x8090 "ModeStatus" => ModeStatusFeature, 0x8100 "OnboardProfiles", 0x8110 "MouseButtonFilter", 0x8111 "LatencyMonitoring", 0x8120 "GamingAttachments", 0x8123 "ForceFeedback", - 0x8300 "Sidetone", + 0x8300 "Sidetone" => SidetoneFeature, 0x8310 "Equalizer", 0x8320 "HeadsetOut", } diff --git a/crates/openlogi-hidpp/src/feature/report_rate/mod.rs b/crates/openlogi-hidpp/src/feature/report_rate/mod.rs new file mode 100644 index 00000000..fa27f49f --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/report_rate/mod.rs @@ -0,0 +1,74 @@ +//! Implements the legacy `ReportRate` feature (ID `0x8060`). + +use std::sync::Arc; + +use crate::{ + channel::HidppChannel, + feature::{CreatableFeature, Feature, FeatureEndpoint}, + protocol::v20::Hidpp20Error, +}; + +bitflags::bitflags! { + /// Report-rate values supported by a `0x8060` device, encoded as milliseconds. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct ReportRateList: u8 { + /// 1 ms report interval. + const MS_1 = 1 << 0; + /// 2 ms report interval. + const MS_2 = 1 << 1; + /// 3 ms report interval. + const MS_3 = 1 << 2; + /// 4 ms report interval. + const MS_4 = 1 << 3; + /// 5 ms report interval. + const MS_5 = 1 << 4; + /// 6 ms report interval. + const MS_6 = 1 << 5; + /// 7 ms report interval. + const MS_7 = 1 << 6; + /// 8 ms report interval. + const MS_8 = 1 << 7; + } +} + +/// Implements the `ReportRate` / `0x8060` feature. +#[derive(Clone)] +pub struct ReportRateFeature { + /// The endpoint this feature talks to. + endpoint: FeatureEndpoint, +} + +impl CreatableFeature for ReportRateFeature { + const ID: u16 = 0x8060; + const STARTING_VERSION: u8 = 0; + + fn new(chan: Arc, device_index: u8, feature_index: u8) -> Self { + Self { + endpoint: FeatureEndpoint::new(chan, device_index, feature_index), + } + } +} + +impl Feature for ReportRateFeature {} + +impl ReportRateFeature { + /// Retrieves the supported report intervals in milliseconds. + pub async fn get_report_rate_list(&self) -> Result { + let payload = self.endpoint.call(0, [0; 3]).await?.extend_payload(); + Ok(ReportRateList::from_bits_retain(payload[0])) + } + + /// Retrieves the active report interval in milliseconds. + pub async fn get_report_rate(&self) -> Result { + Ok(self.endpoint.call(1, [0; 3]).await?.extend_payload()[0]) + } + + /// Sets the active report interval in milliseconds. + /// + /// Devices reject unsupported intervals with `InvalidArgument`. + pub async fn set_report_rate(&self, report_rate_ms: u8) -> Result<(), Hidpp20Error> { + self.endpoint.call(2, [report_rate_ms, 0, 0]).await?; + Ok(()) + } +} diff --git a/crates/openlogi-hidpp/src/feature/sidetone/mod.rs b/crates/openlogi-hidpp/src/feature/sidetone/mod.rs new file mode 100644 index 00000000..3a2c35ac --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/sidetone/mod.rs @@ -0,0 +1,77 @@ +//! Implements `Sidetone` (feature `0x8300`) for audio devices. + +use std::sync::Arc; + +use crate::{ + channel::HidppChannel, + feature::{CreatableFeature, Feature, FeatureEndpoint}, + protocol::v20::Hidpp20Error, +}; + +/// Per-channel sidetone mute statuses. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct SidetoneMuteStatus { + /// Raw mute-status bitmask. A set bit means the channel is muted. + pub statuses: u8, +} + +/// Change mask and statuses for sidetone mute settings. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct SidetoneMuteChange { + /// Channels to update. A set bit means the corresponding status bit applies. + pub change_mask: u8, + /// Desired mute statuses. A set bit means the channel should be muted. + pub statuses: u8, +} + +/// Implements the `Sidetone` / `0x8300` feature. +#[derive(Clone)] +pub struct SidetoneFeature { + /// The endpoint this feature talks to. + endpoint: FeatureEndpoint, +} + +impl CreatableFeature for SidetoneFeature { + const ID: u16 = 0x8300; + const STARTING_VERSION: u8 = 1; + + fn new(chan: Arc, device_index: u8, feature_index: u8) -> Self { + Self { + endpoint: FeatureEndpoint::new(chan, device_index, feature_index), + } + } +} + +impl Feature for SidetoneFeature {} + +impl SidetoneFeature { + /// Retrieves the sidetone level, in the documented `0..=100` range. + pub async fn get_sidetone_level(&self) -> Result { + Ok(self.endpoint.call(0, [0; 3]).await?.extend_payload()[0]) + } + + /// Sets the sidetone level. Devices reject values outside `0..=100`. + pub async fn set_sidetone_level(&self, level: u8) -> Result<(), Hidpp20Error> { + self.endpoint.call(1, [level, 0, 0]).await?; + Ok(()) + } + + /// Retrieves sidetone mute statuses. + pub async fn get_sidetone_mute(&self) -> Result { + Ok(SidetoneMuteStatus { + statuses: self.endpoint.call(2, [0; 3]).await?.extend_payload()[0], + }) + } + + /// Updates selected sidetone mute statuses. + pub async fn set_sidetone_mute(&self, change: SidetoneMuteChange) -> Result<(), Hidpp20Error> { + self.endpoint + .call(3, [change.change_mask, change.statuses, 0]) + .await?; + Ok(()) + } +} diff --git a/crates/openlogi-hidpp/src/feature/smartshift_enhanced/mod.rs b/crates/openlogi-hidpp/src/feature/smartshift_enhanced/mod.rs new file mode 100644 index 00000000..5fca8840 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/smartshift_enhanced/mod.rs @@ -0,0 +1,163 @@ +//! Implements `SmartShiftWheelEnhanced` (feature `0x2111`). + +use std::sync::Arc; + +use crate::{ + channel::HidppChannel, + feature::{CreatableFeature, Feature, FeatureEndpoint, smartshift::WheelMode}, + protocol::v20::Hidpp20Error, +}; + +bitflags::bitflags! { + /// Capabilities reported by `SmartShiftWheelEnhanced`. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct SmartShiftEnhancedCapabilities: u8 { + /// The device supports tunable ratchet torque. + const TUNABLE_TORQUE = 1 << 0; + } +} + +/// Capability and default values for enhanced SmartShift. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct SmartShiftEnhancedInfo { + /// Supported capabilities. + pub capabilities: SmartShiftEnhancedCapabilities, + /// Default automatic disengage threshold. + pub auto_disengage_default: u8, + /// Default tunable torque, as a percentage of maximum force. + pub default_tunable_torque: u8, + /// Maximum force in gram-force units. + pub max_force: u8, +} + +/// Current enhanced SmartShift status. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct SmartShiftEnhancedStatus { + /// Current requested wheel mode. + pub wheel_mode: WheelMode, + /// Automatic disengage threshold. + pub auto_disengage: u8, + /// Current tunable torque, as a percentage of maximum force. + pub current_tunable_torque: u8, +} + +/// Enhanced SmartShift status update. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct SmartShiftEnhancedStatusChange { + /// Wheel mode to apply, or `None` to leave unchanged. + pub wheel_mode: Option, + /// Automatic disengage threshold, or `None` to leave unchanged. + pub auto_disengage: Option, + /// Tunable torque, or `None` to leave unchanged. + pub tunable_torque: Option, +} + +/// Implements the `SmartShiftWheelEnhanced` / `0x2111` feature. +#[derive(Clone)] +pub struct SmartShiftEnhancedFeature { + /// The endpoint this feature talks to. + endpoint: FeatureEndpoint, +} + +impl CreatableFeature for SmartShiftEnhancedFeature { + const ID: u16 = 0x2111; + const STARTING_VERSION: u8 = 0; + + fn new(chan: Arc, device_index: u8, feature_index: u8) -> Self { + Self { + endpoint: FeatureEndpoint::new(chan, device_index, feature_index), + } + } +} + +impl Feature for SmartShiftEnhancedFeature {} + +impl SmartShiftEnhancedFeature { + /// Retrieves enhanced SmartShift capabilities and defaults. + pub async fn get_capabilities(&self) -> Result { + let payload = self.endpoint.call(0, [0; 3]).await?.extend_payload(); + Ok(SmartShiftEnhancedInfo { + capabilities: SmartShiftEnhancedCapabilities::from_bits_retain(payload[0]), + auto_disengage_default: payload[1], + default_tunable_torque: payload[2], + max_force: payload[3], + }) + } + + /// Retrieves the current enhanced SmartShift ratchet control mode. + pub async fn get_ratchet_control_mode(&self) -> Result { + let payload = self.endpoint.call(1, [0; 3]).await?.extend_payload(); + SmartShiftEnhancedStatus::from_payload(payload) + } + + /// Sets selected enhanced SmartShift fields and returns the resulting status. + /// + /// A `None` field is encoded as `0`, the documented “do not change” value. + pub async fn set_ratchet_control_mode( + &self, + change: SmartShiftEnhancedStatusChange, + ) -> Result { + let payload = self + .endpoint + .call( + 2, + [ + change.wheel_mode.map_or(0, u8::from), + change.auto_disengage.unwrap_or(0), + change.tunable_torque.unwrap_or(0), + ], + ) + .await? + .extend_payload(); + SmartShiftEnhancedStatus::from_payload(payload) + } +} + +impl SmartShiftEnhancedStatus { + fn from_payload(payload: [u8; 16]) -> Result { + Ok(Self { + wheel_mode: WheelMode::try_from(payload[0]) + .map_err(|_| Hidpp20Error::UnsupportedResponse)?, + auto_disengage: payload[1], + current_tunable_torque: payload[2], + }) + } +} + +#[cfg(test)] +mod tests { + use super::{SmartShiftEnhancedStatus, WheelMode}; + use crate::protocol::v20::Hidpp20Error; + + #[test] + fn parses_status() { + let mut payload = [0; 16]; + payload[0] = 2; + payload[1] = 0xff; + payload[2] = 33; + + let status = SmartShiftEnhancedStatus::from_payload(payload).unwrap(); + + assert_eq!(status.wheel_mode, WheelMode::Ratchet); + assert_eq!(status.auto_disengage, 0xff); + assert_eq!(status.current_tunable_torque, 33); + } + + #[test] + fn rejects_unknown_wheel_mode() { + let mut payload = [0; 16]; + payload[0] = 9; + + assert!(matches!( + SmartShiftEnhancedStatus::from_payload(payload), + Err(Hidpp20Error::UnsupportedResponse) + )); + } +} diff --git a/crates/openlogi-hidpp/src/feature/vertical_scrolling/mod.rs b/crates/openlogi-hidpp/src/feature/vertical_scrolling/mod.rs new file mode 100644 index 00000000..9f8d977b --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/vertical_scrolling/mod.rs @@ -0,0 +1,123 @@ +//! Implements `VerticalScrolling` (feature `0x2100`). + +use std::sync::Arc; + +use num_enum::TryFromPrimitive; + +use crate::{ + channel::HidppChannel, + feature::{CreatableFeature, Feature, FeatureEndpoint}, + protocol::v20::Hidpp20Error, +}; + +/// Roller type reported by `VerticalScrolling`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum RollerType { + /// Standard one- or two-dimensional roller. + Standard = 0x01, + /// 3G roller. + ThreeG = 0x03, + /// Micro-ratchet roller. + MicroRatchet = 0x04, + /// Touchpad scrolling. + Touchpad = 0x05, + /// Touchpad with natural scrolling enabled by default. + TouchpadNaturalDefault = 0x06, +} + +/// Number of lines scrolled for a wheel movement. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub enum ScrollLines { + /// Do not change the host system setting. + SystemDefault, + /// Scroll this many lines per movement. + Lines(u8), + /// Scroll a full page or screen per movement. + Page, +} + +/// Vertical scrolling roller information. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct RollerInfo { + /// Roller type. + pub roller_type: RollerType, + /// Number of ratchets per wheel turn. + pub ratchets_per_turn: u8, + /// Scroll-line behavior. + pub scroll_lines: ScrollLines, +} + +/// Implements the `VerticalScrolling` / `0x2100` feature. +#[derive(Clone)] +pub struct VerticalScrollingFeature { + /// The endpoint this feature talks to. + endpoint: FeatureEndpoint, +} + +impl CreatableFeature for VerticalScrollingFeature { + const ID: u16 = 0x2100; + const STARTING_VERSION: u8 = 0; + + fn new(chan: Arc, device_index: u8, feature_index: u8) -> Self { + Self { + endpoint: FeatureEndpoint::new(chan, device_index, feature_index), + } + } +} + +impl Feature for VerticalScrollingFeature {} + +impl VerticalScrollingFeature { + /// Retrieves roller information. + pub async fn get_roller_info(&self) -> Result { + let payload = self.endpoint.call(0, [0; 3]).await?.extend_payload(); + RollerInfo::from_payload(payload) + } +} + +impl RollerInfo { + fn from_payload(payload: [u8; 16]) -> Result { + Ok(Self { + roller_type: RollerType::try_from(payload[0]) + .map_err(|_| Hidpp20Error::UnsupportedResponse)?, + ratchets_per_turn: payload[1], + scroll_lines: ScrollLines::from(payload[2]), + }) + } +} + +impl From for ScrollLines { + fn from(value: u8) -> Self { + match value { + 0x00 => Self::SystemDefault, + 0xff => Self::Page, + lines => Self::Lines(lines), + } + } +} + +#[cfg(test)] +mod tests { + use super::{RollerInfo, RollerType, ScrollLines}; + + #[test] + fn parses_roller_info() { + let mut payload = [0; 16]; + payload[0] = 0x04; + payload[1] = 24; + payload[2] = 0xff; + + let info = RollerInfo::from_payload(payload).unwrap(); + + assert_eq!(info.roller_type, RollerType::MicroRatchet); + assert_eq!(info.ratchets_per_turn, 24); + assert_eq!(info.scroll_lines, ScrollLines::Page); + } +} From ec132b76b16ddbc67fadb8fff6bfcfa99eff333c Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sat, 27 Jun 2026 17:42:29 +0800 Subject: [PATCH 02/21] refactor(hid): use hidpp smartshift enhanced wrapper --- crates/openlogi-hid/src/smartshift.rs | 102 +----------------- crates/openlogi-hid/src/write/smartshift.rs | 33 ++++-- .../src/feature/mode_status/mod.rs | 1 - .../src/feature/sidetone/mod.rs | 1 - .../src/feature/smartshift_enhanced/mod.rs | 1 - 5 files changed, 28 insertions(+), 110 deletions(-) diff --git a/crates/openlogi-hid/src/smartshift.rs b/crates/openlogi-hid/src/smartshift.rs index dcbbc36a..52a2e78d 100644 --- a/crates/openlogi-hid/src/smartshift.rs +++ b/crates/openlogi-hid/src/smartshift.rs @@ -1,13 +1,8 @@ //! HID++ `SmartShift Enhanced` (feature `0x2111`) — wheel ratchet ↔ //! free-spin control with sensitivity threshold. //! -//! `hidpp 0.2` ships a typed wrapper for the original `0x2110 SmartShift` -//! at function IDs `0` / `1`. The "Enhanced" variant `0x2111` (MX Master -//! 3 / 3S / 4 and most current MX-line devices) shifts the call table by -//! one slot — `0` is a capability query, `1` is the status read, `2` is -//! the status write. Using `0x2110`'s function IDs against a `0x2111` -//! device hits the wrong functions and the device silently keeps its -//! previous state. +//! The protocol-level `0x2111` wrapper lives in `openlogi-hidpp`; this module +//! keeps OpenLogi's IPC/config-facing mode and status types. //! //! Mode encoding (consistent across 0x2110 / 0x2111): //! - `wheelMode` `1` = free-spin (no ratchet, infinite scroll), `2` = @@ -17,14 +12,6 @@ //! "SmartShift" threshold. `0xFF` keeps the ratchet engaged permanently //! (never auto-switches). See [`AUTO_DISENGAGE_PERMANENT`]. -use std::sync::Arc; - -use hidpp::{ - channel::HidppChannel, - feature::{CreatableFeature, Feature}, - nibble::U4, - protocol::v20::{self, Hidpp20Error}, -}; use num_enum::{IntoPrimitive, TryFromPrimitive}; use serde::{Deserialize, Serialize}; @@ -86,7 +73,7 @@ impl From for openlogi_core::config::WheelMode { /// value (`0x01`–`0xFE`) is a SmartShift speed threshold. pub const AUTO_DISENGAGE_PERMANENT: u8 = 0xff; -/// Snapshot returned from [`SmartShiftFeatureV0::get_status`]. +/// Snapshot returned from OpenLogi's SmartShift read helpers. /// /// Crosses the agent↔GUI IPC (`read_smartshift`), so field order is wire /// format — changes require a `PROTOCOL_VERSION` bump (guarded by @@ -107,89 +94,6 @@ pub struct SmartShiftStatus { pub tunable_torque: u8, } -/// `SmartShift` / `0x2111` feature, version 0+. -#[derive(Clone)] -pub struct SmartShiftFeatureV0 { - chan: Arc, - device_index: u8, - feature_index: u8, -} - -impl CreatableFeature for SmartShiftFeatureV0 { - const ID: u16 = 0x2111; - const STARTING_VERSION: u8 = 0; - - fn new(chan: Arc, device_index: u8, feature_index: u8) -> Self { - Self { - chan, - device_index, - feature_index, - } - } -} - -impl Feature for SmartShiftFeatureV0 {} - -/// `0x2111` function ID for `getStatus` — returns mode + current -/// sensitivity + default sensitivity. Different from `0x2110` which uses -/// function `0` for the same purpose. -const FUNCTION_GET_STATUS: u8 = 1; -/// `0x2111` function ID for `setStatus` — accepts mode + sensitivity + -/// defaultSensitivity. `0x2110` uses function `1` here. -const FUNCTION_SET_STATUS: u8 = 2; - -impl SmartShiftFeatureV0 { - /// Read the current `wheelMode` + `autoDisengage` + `currentTunableTorque`. - /// Reserved mode bytes fall back to [`SmartShiftMode::Ratchet`] because - /// that's the "safe" / clicky behaviour most users expect. - pub async fn get_status(&self) -> Result { - let response = self - .chan - .send_v20(v20::Message::Short( - v20::MessageHeader { - device_index: self.device_index, - feature_index: self.feature_index, - function_id: U4::from_lo(FUNCTION_GET_STATUS), - software_id: self.chan.get_sw_id(), - }, - [0x00, 0x00, 0x00], - )) - .await?; - let payload = response.extend_payload(); - let mode = SmartShiftMode::try_from(payload[0]).unwrap_or(SmartShiftMode::Ratchet); - Ok(SmartShiftStatus { - mode, - auto_disengage: payload[1], - tunable_torque: payload.get(2).copied().unwrap_or(0), - }) - } - - /// Write a new `wheelMode` + `autoDisengage` + `currentTunableTorque`. The - /// firmware stores all three persistently in the device's NVM, so callers - /// should read the current `tunable_torque` (and any field they don't mean - /// to change) via [`Self::get_status`] and re-send it here. - pub async fn set_status( - &self, - mode: SmartShiftMode, - auto_disengage: u8, - tunable_torque: u8, - ) -> Result<(), Hidpp20Error> { - let _ = self - .chan - .send_v20(v20::Message::Short( - v20::MessageHeader { - device_index: self.device_index, - feature_index: self.feature_index, - function_id: U4::from_lo(FUNCTION_SET_STATUS), - software_id: self.chan.get_sw_id(), - }, - [u8::from(mode), auto_disengage, tunable_torque], - )) - .await?; - Ok(()) - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/openlogi-hid/src/write/smartshift.rs b/crates/openlogi-hid/src/write/smartshift.rs index 6fcae23d..a6cb0c9b 100644 --- a/crates/openlogi-hid/src/write/smartshift.rs +++ b/crates/openlogi-hid/src/write/smartshift.rs @@ -7,12 +7,13 @@ use hidpp::{ feature::{ CreatableFeature, smartshift::{SmartShiftFeature, WheelMode}, + smartshift_enhanced::{SmartShiftEnhancedFeature, SmartShiftEnhancedStatusChange}, }, }; use tracing::debug; use crate::route::DeviceRoute; -use crate::smartshift::{SmartShiftFeatureV0, SmartShiftMode, SmartShiftStatus}; +use crate::smartshift::{SmartShiftMode, SmartShiftStatus}; use super::{HidppOperation, WriteError, classify_hidpp_error, open_feature, with_route}; @@ -54,7 +55,7 @@ pub(super) fn smartshift_to_wheel(mode: SmartShiftMode) -> WheelMode { /// `0x2111` Enhanced variant, the MX Master 2S uses the original `0x2110`. enum SmartShift { /// `0x2111 SmartShiftWheelEnhanced`. - Enhanced(Arc), + Enhanced(Arc), /// `0x2110 SmartShiftWheel`. Legacy(Arc), } @@ -64,7 +65,7 @@ impl SmartShift { /// first; on a missing-`0x2111` error (and only that), retries with /// `0x2110`. Any other error from the first attempt propagates unchanged. async fn open(device: &mut Device) -> Result { - match open_feature::(device).await { + match open_feature::(device).await { Ok(feature) => Ok(Self::Enhanced(feature)), Err(err) if is_missing_enhanced(&err) => { let feature = open_feature::(device).await?; @@ -79,9 +80,20 @@ impl SmartShift { /// `tunable_torque` is reported as `0` per [`SmartShiftStatus`]'s contract. async fn status(&self) -> Result { match self { - Self::Enhanced(feature) => feature.get_status().await.map_err(|e| { - classify_hidpp_error(e, HidppOperation::ReadSmartShift, SmartShiftFeatureV0::ID) - }), + Self::Enhanced(feature) => { + let status = feature.get_ratchet_control_mode().await.map_err(|e| { + classify_hidpp_error( + e, + HidppOperation::ReadSmartShift, + SmartShiftEnhancedFeature::ID, + ) + })?; + Ok(SmartShiftStatus { + mode: wheel_mode_to_smartshift(status.wheel_mode), + auto_disengage: status.auto_disengage, + tunable_torque: status.current_tunable_torque, + }) + } Self::Legacy(feature) => { let rcm = feature.get_ratchet_control_mode().await.map_err(|e| { classify_hidpp_error(e, HidppOperation::ReadSmartShift, SmartShiftFeature::ID) @@ -111,13 +123,18 @@ impl SmartShift { } = status; match self { Self::Enhanced(feature) => feature - .set_status(mode, auto_disengage, tunable_torque) + .set_ratchet_control_mode(SmartShiftEnhancedStatusChange { + wheel_mode: Some(smartshift_to_wheel(mode)), + auto_disengage: Some(auto_disengage), + tunable_torque: Some(tunable_torque), + }) .await + .map(|_| ()) .map_err(|e| { classify_hidpp_error( e, HidppOperation::WriteSmartShift, - SmartShiftFeatureV0::ID, + SmartShiftEnhancedFeature::ID, ) }), Self::Legacy(feature) => feature diff --git a/crates/openlogi-hidpp/src/feature/mode_status/mod.rs b/crates/openlogi-hidpp/src/feature/mode_status/mod.rs index 7bea823f..cf7ab51f 100644 --- a/crates/openlogi-hidpp/src/feature/mode_status/mod.rs +++ b/crates/openlogi-hidpp/src/feature/mode_status/mod.rs @@ -44,7 +44,6 @@ pub struct ModeStatus { /// A mode-status update request. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] -#[non_exhaustive] pub struct ModeStatusChange { /// Desired primary status bits. pub status0: ModeStatus0, diff --git a/crates/openlogi-hidpp/src/feature/sidetone/mod.rs b/crates/openlogi-hidpp/src/feature/sidetone/mod.rs index 3a2c35ac..91aeef27 100644 --- a/crates/openlogi-hidpp/src/feature/sidetone/mod.rs +++ b/crates/openlogi-hidpp/src/feature/sidetone/mod.rs @@ -20,7 +20,6 @@ pub struct SidetoneMuteStatus { /// Change mask and statuses for sidetone mute settings. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] -#[non_exhaustive] pub struct SidetoneMuteChange { /// Channels to update. A set bit means the corresponding status bit applies. pub change_mask: u8, diff --git a/crates/openlogi-hidpp/src/feature/smartshift_enhanced/mod.rs b/crates/openlogi-hidpp/src/feature/smartshift_enhanced/mod.rs index 5fca8840..8050585c 100644 --- a/crates/openlogi-hidpp/src/feature/smartshift_enhanced/mod.rs +++ b/crates/openlogi-hidpp/src/feature/smartshift_enhanced/mod.rs @@ -49,7 +49,6 @@ pub struct SmartShiftEnhancedStatus { /// Enhanced SmartShift status update. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] -#[non_exhaustive] pub struct SmartShiftEnhancedStatusChange { /// Wheel mode to apply, or `None` to leave unchanged. pub wheel_mode: Option, From 1ca31c0df566c0024282ff0296f2f9e360724a7e Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sat, 27 Jun 2026 17:44:56 +0800 Subject: [PATCH 03/21] feat(hidpp): add multi-host feature wrappers --- .../src/feature/fn_inversion/mod.rs | 139 +++++++++++ .../src/feature/hosts_info/mod.rs | 230 ++++++++++++++++++ crates/openlogi-hidpp/src/feature/mod.rs | 3 + .../src/feature/multi_platform/mod.rs | 204 ++++++++++++++++ crates/openlogi-hidpp/src/feature/registry.rs | 18 +- 5 files changed, 586 insertions(+), 8 deletions(-) create mode 100644 crates/openlogi-hidpp/src/feature/fn_inversion/mod.rs create mode 100644 crates/openlogi-hidpp/src/feature/hosts_info/mod.rs create mode 100644 crates/openlogi-hidpp/src/feature/multi_platform/mod.rs diff --git a/crates/openlogi-hidpp/src/feature/fn_inversion/mod.rs b/crates/openlogi-hidpp/src/feature/fn_inversion/mod.rs new file mode 100644 index 00000000..196a11ab --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/fn_inversion/mod.rs @@ -0,0 +1,139 @@ +//! Implements function-key inversion features. + +use std::sync::Arc; + +use num_enum::{IntoPrimitive, TryFromPrimitive}; + +use crate::{ + channel::HidppChannel, + feature::{CreatableFeature, Feature, FeatureEndpoint, hosts_info::HostIndex}, + protocol::v20::Hidpp20Error, +}; + +bitflags::bitflags! { + /// Function-key inversion capabilities. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct FnInversionCapabilities: u8 { + /// The device supports manual Fn-lock control. + const MANUAL_FN_LOCK = 1 << 0; + } +} + +/// Function-key inversion state. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum FnInversionState { + /// Function-key inversion is disabled. + Off = 0, + /// Function-key inversion is enabled. + On = 1, +} + +impl From for FnInversionState { + fn from(value: bool) -> Self { + if value { Self::On } else { Self::Off } + } +} + +/// Function-key inversion state for a host slot. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct FnInversionInfo { + /// Host slot index returned by the device. + pub host_index: HostIndex, + /// Current inversion state. + pub state: FnInversionState, + /// Default inversion state. + pub default_state: FnInversionState, + /// Inversion capabilities. + pub capabilities: FnInversionCapabilities, +} + +/// Implements `FnInversionForMultiHostDevices` / `0x40a3`. +#[derive(Clone)] +pub struct FnInversionMultiHostFeature { + /// The endpoint this feature talks to. + endpoint: FeatureEndpoint, +} + +impl CreatableFeature for FnInversionMultiHostFeature { + const ID: u16 = 0x40a3; + const STARTING_VERSION: u8 = 0; + + fn new(chan: Arc, device_index: u8, feature_index: u8) -> Self { + Self { + endpoint: FeatureEndpoint::new(chan, device_index, feature_index), + } + } +} + +impl Feature for FnInversionMultiHostFeature {} + +impl FnInversionMultiHostFeature { + /// Retrieves global Fn inversion for `host`. + pub async fn get_global_fn_inversion( + &self, + host: HostIndex, + ) -> Result { + let payload = self + .endpoint + .call(0, [u8::from(host), 0, 0]) + .await? + .extend_payload(); + FnInversionInfo::from_payload(payload) + } + + /// Sets global Fn inversion for `host`. + /// + /// The setting is stored by the device for the selected host slot. + pub async fn set_global_fn_inversion( + &self, + host: HostIndex, + state: FnInversionState, + ) -> Result { + let payload = self + .endpoint + .call(1, [u8::from(state), u8::from(host), 0]) + .await? + .extend_payload(); + FnInversionInfo::from_payload(payload) + } +} + +impl FnInversionInfo { + fn from_payload(payload: [u8; 16]) -> Result { + Ok(Self { + host_index: HostIndex::from(payload[0]), + state: FnInversionState::try_from(payload[1]) + .map_err(|_| Hidpp20Error::UnsupportedResponse)?, + default_state: FnInversionState::try_from(payload[2]) + .map_err(|_| Hidpp20Error::UnsupportedResponse)?, + capabilities: FnInversionCapabilities::from_bits_retain(payload[3]), + }) + } +} + +#[cfg(test)] +mod tests { + use super::{FnInversionInfo, FnInversionState}; + use crate::feature::hosts_info::HostIndex; + + #[test] + fn parses_fn_inversion_info() { + let mut payload = [0; 16]; + payload[0] = 1; + payload[1] = 1; + payload[2] = 0; + payload[3] = 1; + + let info = FnInversionInfo::from_payload(payload).unwrap(); + + assert_eq!(info.host_index, HostIndex::Slot(1)); + assert_eq!(info.state, FnInversionState::On); + assert_eq!(info.default_state, FnInversionState::Off); + } +} diff --git a/crates/openlogi-hidpp/src/feature/hosts_info/mod.rs b/crates/openlogi-hidpp/src/feature/hosts_info/mod.rs new file mode 100644 index 00000000..bad2a8bf --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/hosts_info/mod.rs @@ -0,0 +1,230 @@ +//! Implements `HostsInfo` (feature `0x1815`) for multi-host devices. + +use std::sync::Arc; + +use num_enum::TryFromPrimitive; + +use crate::{ + channel::HidppChannel, + feature::{CreatableFeature, Feature, FeatureEndpoint}, + protocol::v20::Hidpp20Error, +}; + +bitflags::bitflags! { + /// Host-management capabilities. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct HostsInfoCapabilities: u8 { + /// Host names can be read. + const GET_NAME = 1 << 0; + /// Host names can be written. + const SET_NAME = 1 << 1; + /// Host slots can be moved. + const MOVE_HOST = 1 << 2; + /// Host slots can be deleted. + const DELETE_HOST = 1 << 3; + /// Host OS versions can be written. + const SET_OS_VERSION = 1 << 4; + } +} + +bitflags::bitflags! { + /// Supported host descriptor families. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct HostDescriptorCapabilities: u8 { + /// eQuad host descriptors are available. + const EQUAD = 1 << 0; + /// USB host descriptors are available. + const USB = 1 << 1; + /// Bluetooth classic host descriptors are available. + const BT = 1 << 2; + /// Bluetooth Low Energy host descriptors are available. + const BLE = 1 << 3; + } +} + +/// A host slot selector. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub enum HostIndex { + /// The host slot currently selected by the device. + Current, + /// A zero-based host slot index. + Slot(u8), +} + +impl From for u8 { + fn from(value: HostIndex) -> Self { + match value { + HostIndex::Current => 0xff, + HostIndex::Slot(index) => index, + } + } +} + +impl From for HostIndex { + fn from(value: u8) -> Self { + if value == 0xff { + Self::Current + } else { + Self::Slot(value) + } + } +} + +/// Pairing status for a host slot. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum HostSlotStatus { + /// The host slot is empty. + Empty = 0, + /// The host slot is paired. + Paired = 1, +} + +/// Bus type associated with a host slot. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum HostBusType { + /// Undefined or unknown bus type. + Undefined = 0, + /// eQuad wireless. + Equad = 1, + /// USB. + Usb = 2, + /// Bluetooth classic. + Bt = 3, + /// Bluetooth Low Energy. + Ble = 4, + /// BLE Pro / Logi Bolt. + BlePro = 5, +} + +/// Static information about the `HostsInfo` feature. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct HostsInfoFeatureInfo { + /// Host-management capabilities. + pub capabilities: HostsInfoCapabilities, + /// Host descriptor capabilities. + pub descriptor_capabilities: HostDescriptorCapabilities, + /// Number of host slots. + pub host_count: u8, + /// Current host slot index. + pub current_host: HostIndex, +} + +/// Information about one host slot. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct HostInfo { + /// Host slot index returned by the device. + pub host_index: HostIndex, + /// Pairing status. + pub status: HostSlotStatus, + /// Bus type used by this host slot. + pub bus_type: HostBusType, + /// Number of descriptor pages. + pub page_count: u8, + /// Current friendly-name length. + pub name_len: u8, + /// Maximum friendly-name length. + pub name_max_len: u8, +} + +/// Raw host descriptor page. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct HostDescriptorPage { + /// Host slot index returned by the device. + pub host_index: HostIndex, + /// Descriptor bus type, decoded from the page header when known. + pub bus_type: HostBusType, + /// Descriptor page index, decoded from the page header. + pub page_index: u8, + /// Raw descriptor body bytes. + pub body: [u8; 14], +} + +/// Implements the `HostsInfo` / `0x1815` feature. +#[derive(Clone)] +pub struct HostsInfoFeature { + /// The endpoint this feature talks to. + endpoint: FeatureEndpoint, +} + +impl CreatableFeature for HostsInfoFeature { + const ID: u16 = 0x1815; + const STARTING_VERSION: u8 = 2; + + fn new(chan: Arc, device_index: u8, feature_index: u8) -> Self { + Self { + endpoint: FeatureEndpoint::new(chan, device_index, feature_index), + } + } +} + +impl Feature for HostsInfoFeature {} + +impl HostsInfoFeature { + /// Retrieves feature capabilities and host-slot count. + pub async fn get_feature_info(&self) -> Result { + let payload = self.endpoint.call(0, [0; 3]).await?.extend_payload(); + Ok(HostsInfoFeatureInfo { + capabilities: HostsInfoCapabilities::from_bits_retain(payload[0]), + descriptor_capabilities: HostDescriptorCapabilities::from_bits_retain(payload[1]), + host_count: payload[2], + current_host: HostIndex::from(payload[3]), + }) + } + + /// Retrieves information for `host`. + pub async fn get_host_info(&self, host: HostIndex) -> Result { + let payload = self + .endpoint + .call(1, [u8::from(host), 0, 0]) + .await? + .extend_payload(); + Ok(HostInfo { + host_index: HostIndex::from(payload[0]), + status: HostSlotStatus::try_from(payload[1]) + .map_err(|_| Hidpp20Error::UnsupportedResponse)?, + bus_type: HostBusType::try_from(payload[2]) + .map_err(|_| Hidpp20Error::UnsupportedResponse)?, + page_count: payload[3], + name_len: payload[4], + name_max_len: payload[5], + }) + } + + /// Retrieves a raw descriptor `page` for `host`. + pub async fn get_host_descriptor( + &self, + host: HostIndex, + page: u8, + ) -> Result { + let payload = self + .endpoint + .call(2, [u8::from(host), page, 0]) + .await? + .extend_payload(); + let mut body = [0; 14]; + body.copy_from_slice(&payload[2..16]); + Ok(HostDescriptorPage { + host_index: HostIndex::from(payload[0]), + bus_type: HostBusType::try_from(payload[1] >> 4) + .map_err(|_| Hidpp20Error::UnsupportedResponse)?, + page_index: payload[1] & 0x0f, + body, + }) + } +} diff --git a/crates/openlogi-hidpp/src/feature/mod.rs b/crates/openlogi-hidpp/src/feature/mod.rs index b555bac6..89ca61ec 100755 --- a/crates/openlogi-hidpp/src/feature/mod.rs +++ b/crates/openlogi-hidpp/src/feature/mod.rs @@ -15,8 +15,11 @@ pub mod device_information; pub mod device_type_and_name; pub mod extended_report_rate; pub mod feature_set; +pub mod fn_inversion; pub mod hires_wheel; +pub mod hosts_info; pub mod mode_status; +pub mod multi_platform; pub mod registry; pub mod report_rate; pub mod reprog_controls; diff --git a/crates/openlogi-hidpp/src/feature/multi_platform/mod.rs b/crates/openlogi-hidpp/src/feature/multi_platform/mod.rs new file mode 100644 index 00000000..300027e3 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/multi_platform/mod.rs @@ -0,0 +1,204 @@ +//! Implements `MultiPlatform` (feature `0x4531`). + +use std::sync::Arc; + +use num_enum::TryFromPrimitive; + +use crate::{ + channel::HidppChannel, + feature::{CreatableFeature, Feature, FeatureEndpoint, hosts_info::HostIndex}, + protocol::v20::Hidpp20Error, +}; + +bitflags::bitflags! { + /// Capabilities reported by `MultiPlatform`. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct MultiPlatformCapabilities: u16 { + /// The device can detect the host OS automatically. + const OS_DETECTION = 1 << 0; + /// Software can set the host platform. + const SET_HOST_PLATFORM = 1 << 1; + } +} + +bitflags::bitflags! { + /// Operating systems covered by a platform descriptor. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct OsMask: u16 { + /// Microsoft Windows. + const WINDOWS = 1 << 0; + /// Windows Embedded. + const WINDOWS_EMBEDDED = 1 << 1; + /// Linux. + const LINUX = 1 << 2; + /// ChromeOS. + const CHROME = 1 << 3; + /// Android. + const ANDROID = 1 << 4; + /// macOS. + const MACOS = 1 << 5; + /// iOS. + const IOS = 1 << 6; + /// webOS. + const WEBOS = 1 << 7; + /// Tizen. + const TIZEN = 1 << 8; + } +} + +/// Source of a host-platform selection. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum PlatformSource { + /// Device default. + Default = 0, + /// Automatically detected by the device. + Auto = 1, + /// Manually selected on the device. + Manual = 2, + /// Set by host software. + Software = 3, +} + +/// Static `MultiPlatform` feature information. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct MultiPlatformInfo { + /// Feature capabilities. + pub capabilities: MultiPlatformCapabilities, + /// Number of platform IDs. + pub platform_count: u8, + /// Number of platform descriptor rows. + pub descriptor_count: u8, + /// Number of host slots. + pub host_count: u8, + /// Current host slot. + pub current_host: HostIndex, + /// Platform index selected for the current host. + pub current_host_platform: Option, +} + +/// A platform descriptor row. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct PlatformDescriptor { + /// Platform index this descriptor belongs to. + pub platform_index: u8, + /// Descriptor row index. + pub descriptor_index: u8, + /// Covered operating systems. + pub os_mask: OsMask, + /// First supported OS major version. + pub from_version: u8, + /// First supported OS revision. + pub from_revision: u8, + /// Last supported OS major version. + pub to_version: u8, + /// Last supported OS revision. + pub to_revision: u8, +} + +/// Platform selection for a host slot. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct HostPlatform { + /// Host slot index returned by the device. + pub host_index: HostIndex, + /// Raw host status byte. + pub status: u8, + /// Selected platform, or `None` when undefined. + pub platform_index: Option, + /// Source of the platform selection. + pub source: PlatformSource, + /// Automatically detected platform, if available. + pub auto_platform_index: Option, + /// Automatically matched platform descriptor, if available. + pub auto_descriptor_index: Option, +} + +/// Implements the `MultiPlatform` / `0x4531` feature. +#[derive(Clone)] +pub struct MultiPlatformFeature { + /// The endpoint this feature talks to. + endpoint: FeatureEndpoint, +} + +impl CreatableFeature for MultiPlatformFeature { + const ID: u16 = 0x4531; + const STARTING_VERSION: u8 = 1; + + fn new(chan: Arc, device_index: u8, feature_index: u8) -> Self { + Self { + endpoint: FeatureEndpoint::new(chan, device_index, feature_index), + } + } +} + +impl Feature for MultiPlatformFeature {} + +impl MultiPlatformFeature { + /// Retrieves feature capabilities and platform counts. + pub async fn get_feature_infos(&self) -> Result { + let payload = self.endpoint.call(0, [0; 3]).await?.extend_payload(); + Ok(MultiPlatformInfo { + capabilities: MultiPlatformCapabilities::from_bits_retain(u16::from_be_bytes([ + payload[0], payload[1], + ])), + platform_count: payload[2], + descriptor_count: payload[3], + host_count: payload[4], + current_host: HostIndex::from(payload[5]), + current_host_platform: optional_index(payload[6]), + }) + } + + /// Retrieves a platform descriptor row. + pub async fn get_platform_descriptor( + &self, + descriptor_index: u8, + ) -> Result { + let payload = self + .endpoint + .call(1, [descriptor_index, 0, 0]) + .await? + .extend_payload(); + Ok(PlatformDescriptor { + platform_index: payload[0], + descriptor_index: payload[1], + os_mask: OsMask::from_bits_retain(u16::from_be_bytes([payload[2], payload[3]])), + from_version: payload[4], + from_revision: payload[5], + to_version: payload[6], + to_revision: payload[7], + }) + } + + /// Retrieves the platform selected for `host`. + pub async fn get_host_platform(&self, host: HostIndex) -> Result { + let payload = self + .endpoint + .call(2, [u8::from(host), 0, 0]) + .await? + .extend_payload(); + Ok(HostPlatform { + host_index: HostIndex::from(payload[0]), + status: payload[1], + platform_index: optional_index(payload[2]), + source: PlatformSource::try_from(payload[3]) + .map_err(|_| Hidpp20Error::UnsupportedResponse)?, + auto_platform_index: optional_index(payload[4]), + auto_descriptor_index: optional_index(payload[5]), + }) + } +} + +fn optional_index(value: u8) -> Option { + (value != 0xff).then_some(value) +} diff --git a/crates/openlogi-hidpp/src/feature/registry.rs b/crates/openlogi-hidpp/src/feature/registry.rs index f60e6ba9..30378163 100755 --- a/crates/openlogi-hidpp/src/feature/registry.rs +++ b/crates/openlogi-hidpp/src/feature/registry.rs @@ -17,11 +17,13 @@ use crate::{ device_information::DeviceInformationFeature, device_type_and_name::DeviceTypeAndNameFeature, extended_report_rate::ExtendedReportRateFeature, feature_set::FeatureSetFeature, - hires_wheel::HiResWheelFeature, mode_status::ModeStatusFeature, - report_rate::ReportRateFeature, reprog_controls::ReprogControlsFeature, root::RootFeature, - sidetone::SidetoneFeature, smartshift::SmartShiftFeature, - smartshift_enhanced::SmartShiftEnhancedFeature, thumbwheel::ThumbwheelFeature, - unified_battery::UnifiedBatteryFeature, vertical_scrolling::VerticalScrollingFeature, + fn_inversion::FnInversionMultiHostFeature, hires_wheel::HiResWheelFeature, + hosts_info::HostsInfoFeature, mode_status::ModeStatusFeature, + multi_platform::MultiPlatformFeature, report_rate::ReportRateFeature, + reprog_controls::ReprogControlsFeature, root::RootFeature, sidetone::SidetoneFeature, + smartshift::SmartShiftFeature, smartshift_enhanced::SmartShiftEnhancedFeature, + thumbwheel::ThumbwheelFeature, unified_battery::UnifiedBatteryFeature, + vertical_scrolling::VerticalScrollingFeature, wireless_device_status::WirelessDeviceStatusFeature, }, }; @@ -135,7 +137,7 @@ static KNOWN_FEATURES: LazyLock> = LazyLock::new(|| { 0x1805 "OobState", 0x1806 "ConfigDeviceProps", 0x1814 "ChangeHost", - 0x1815 "HostsInfo", + 0x1815 "HostsInfo" => HostsInfoFeature, 0x1981 "Backlight1", 0x1982 "Backlight2", 0x1983 "Backlight3", @@ -176,7 +178,7 @@ static KNOWN_FEATURES: LazyLock> = LazyLock::new(|| { 0x2400 "HybridTrackingEngine", 0x40a0 "FnInversion", 0x40a2 "FnInversionWithDefaultState", - 0x40a3 "FnInversionForMultiHostDevices", + 0x40a3 "FnInversionForMultiHostDevices" => FnInversionMultiHostFeature, 0x4100 "Encryption", 0x4220 "LockKeyState", 0x4301 "SolarKeyboardDashboard", @@ -184,7 +186,7 @@ static KNOWN_FEATURES: LazyLock> = LazyLock::new(|| { 0x4521 "DisableKeys", 0x4522 "DisableKeysByUsage", 0x4530 "DualPlatform", - 0x4531 "MultiPlatform", + 0x4531 "MultiPlatform" => MultiPlatformFeature, 0x4540 "KeyboardInternationalLayouts", 0x4600 "Crown", 0x6010 "TouchpadFwItems", From 5351b0b2730a33239a9e05d4cabc1087e4424206 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sat, 27 Jun 2026 18:11:28 +0800 Subject: [PATCH 04/21] feat(hidpp): add extended dpi and mouse pointer wrappers --- .../src/feature/extended_dpi/event.rs | 80 ++++ .../src/feature/extended_dpi/mod.rs | 308 ++++++++++++++ .../src/feature/extended_dpi/tests.rs | 282 +++++++++++++ .../src/feature/extended_dpi/types.rs | 379 ++++++++++++++++++ crates/openlogi-hidpp/src/feature/mod.rs | 2 + .../src/feature/mouse_pointer/mod.rs | 136 +++++++ crates/openlogi-hidpp/src/feature/registry.rs | 16 +- 7 files changed, 1195 insertions(+), 8 deletions(-) create mode 100644 crates/openlogi-hidpp/src/feature/extended_dpi/event.rs create mode 100644 crates/openlogi-hidpp/src/feature/extended_dpi/mod.rs create mode 100644 crates/openlogi-hidpp/src/feature/extended_dpi/tests.rs create mode 100644 crates/openlogi-hidpp/src/feature/extended_dpi/types.rs create mode 100644 crates/openlogi-hidpp/src/feature/mouse_pointer/mod.rs diff --git a/crates/openlogi-hidpp/src/feature/extended_dpi/event.rs b/crates/openlogi-hidpp/src/feature/extended_dpi/event.rs new file mode 100644 index 00000000..78adf834 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/extended_dpi/event.rs @@ -0,0 +1,80 @@ +//! Events emitted by `ExtendedAdjustableDpi` (`0x2202`). + +use super::types::{DpiDirection, Lod}; + +/// An event emitted by [`ExtendedDpiFeature`](super::ExtendedDpiFeature). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub enum ExtendedDpiEvent { + /// The sensor's DPI parameters changed on the device (e.g. via a DPI + /// button). + ParametersChanged(DpiParametersChanged), + /// A DPI calibration finished or timed out. + CalibrationCompleted(DpiCalibrationCompleted), +} + +/// Payload of [`ExtendedDpiEvent::ParametersChanged`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct DpiParametersChanged { + /// Index of the sensor whose parameters changed. + pub sensor_index: u8, + /// New X-axis DPI. + 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, +} + +/// Payload of [`ExtendedDpiEvent::CalibrationCompleted`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct DpiCalibrationCompleted { + /// Index of the sensor. + pub sensor_index: u8, + /// Axis that was calibrated. + pub direction: DpiDirection, + /// Calibration correction value; [`i16::MIN`] (`0x8000`) signals a + /// sensor-level calibration failure (see [`Self::failed`]). + pub correction: i16, + /// Perpendicular-axis displacement, in pixel counts, that software can use to + /// judge calibration quality. + pub delta: i16, +} + +impl DpiCalibrationCompleted { + /// Whether the calibration failed at the sensor level (`correction` is the + /// `0x8000` "negative zero" sentinel). + #[must_use] + pub fn failed(&self) -> bool { + self.correction == i16::MIN + } +} + +/// Decodes an unsolicited `0x2202` event payload by its sub-id. +/// +/// Returns `None` for sub-ids that do not correspond to a known event or whose +/// payload carries an unsupported enum value. +pub(super) fn decode_event(sub_id: u8, payload: &[u8; 16]) -> Option { + match sub_id { + 0 => Some(ExtendedDpiEvent::ParametersChanged(DpiParametersChanged { + sensor_index: payload[0], + dpi_x: u16::from_be_bytes([payload[1], payload[2]]), + dpi_y: u16::from_be_bytes([payload[3], payload[4]]), + lod: Lod::try_from(payload[5]).ok()?, + })), + 1 => Some(ExtendedDpiEvent::CalibrationCompleted( + DpiCalibrationCompleted { + sensor_index: payload[0], + 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]]), + }, + )), + _ => None, + } +} diff --git a/crates/openlogi-hidpp/src/feature/extended_dpi/mod.rs b/crates/openlogi-hidpp/src/feature/extended_dpi/mod.rs new file mode 100644 index 00000000..7e9ccb50 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/extended_dpi/mod.rs @@ -0,0 +1,308 @@ +//! Implements the `ExtendedAdjustableDpi` feature (ID `0x2202`). +//! +//! This is the modern successor to [`AdjustableDpi`](super::adjustable_dpi) +//! (`0x2201`). On top of a single per-sensor DPI it adds independent X/Y DPI, +//! lift-off distance, DPI-status LED control and a DPI calibration flow, and it +//! describes the supported DPI as fixed values and stepped ranges rather than a +//! flat list. + +pub mod event; +pub mod types; + +#[cfg(test)] +mod tests; + +use std::sync::Arc; + +pub use event::{DpiCalibrationCompleted, DpiParametersChanged, ExtendedDpiEvent}; +pub use types::{ + CalibrationType, DpiCalibrationCorrection, DpiCalibrationInfo, DpiDirection, DpiParameters, + DpiRange, LedHoldType, Lod, SensorCapabilities, SensorCapabilitiesInfo, SetDpiParameters, + ShowDpiStatus, StartDpiCalibration, +}; + +use self::types::{parse_dpi_list, parse_dpi_ranges, parse_lod_list, terminated_word_len}; +use crate::{ + channel::{HidppChannel, MessageListenerGuard}, + event::EventEmitter, + feature::{CreatableFeature, EmittingFeature, Feature, FeatureEndpoint, event_payload}, + protocol::v20::Hidpp20Error, +}; + +/// Upper bound on the number of `getSensorDpiRanges` pages fetched before the +/// device is considered to be returning a malformed, unterminated list. +const MAX_RANGE_PAGES: u8 = 16; + +/// Implements the `ExtendedAdjustableDpi` / `0x2202` feature. +pub struct ExtendedDpiFeature { + /// The endpoint this feature talks to. + endpoint: FeatureEndpoint, + + /// The emitter used to publish decoded events. + emitter: Arc>, + + /// Removes the message listener when the feature is dropped. + _msg_listener: MessageListenerGuard, +} + +impl CreatableFeature for ExtendedDpiFeature { + const ID: u16 = 0x2202; + const STARTING_VERSION: u8 = 0; + + fn new(chan: Arc, device_index: u8, feature_index: u8) -> Self { + let emitter = Arc::new(EventEmitter::new()); + + let listener = chan.add_msg_listener_guarded({ + let emitter = Arc::clone(&emitter); + + move |raw, matched| { + let Some((func, payload)) = + event_payload(raw, matched, device_index, feature_index) + else { + return; + }; + + if let Some(event) = event::decode_event(func.to_lo(), &payload) { + emitter.emit(event); + } + } + }); + + Self { + endpoint: FeatureEndpoint::new(chan, device_index, feature_index), + emitter, + _msg_listener: listener, + } + } +} + +impl Feature for ExtendedDpiFeature {} + +impl EmittingFeature for ExtendedDpiFeature { + fn listen(&self) -> async_channel::Receiver { + self.emitter.create_receiver() + } +} + +impl ExtendedDpiFeature { + /// Retrieves the number of motion sensors the device exposes. + pub async fn get_sensor_count(&self) -> Result { + Ok(self.endpoint.call(0, [0; 3]).await?.extend_payload()[0]) + } + + /// Retrieves the capabilities and DPI-level count of `sensor_index`. + pub async fn get_sensor_capabilities( + &self, + sensor_index: u8, + ) -> Result { + let payload = self + .endpoint + .call(1, [sensor_index, 0, 0]) + .await? + .extend_payload(); + Ok(SensorCapabilitiesInfo { + sensor_index: payload[0], + dpi_level_count: payload[1], + capabilities: SensorCapabilities::from_bits_retain(payload[2]), + }) + } + + /// Retrieves the supported DPI of `sensor_index` along `direction` as a mix + /// of fixed values and stepped ranges. + /// + /// The device may split the description across several pages; this fetches + /// them until the `0x0000` end-of-list terminator is seen, then decodes the + /// accumulated stream. A device that never terminates the list within a + /// bounded number of pages yields [`Hidpp20Error::UnsupportedResponse`]. + pub async fn get_sensor_dpi_ranges( + &self, + sensor_index: u8, + direction: DpiDirection, + ) -> Result, Hidpp20Error> { + let mut stream = Vec::new(); + for page in 0..MAX_RANGE_PAGES { + let payload = self + .endpoint + .call(2, [sensor_index, direction.into(), page]) + .await? + .extend_payload(); + // Validate the echoed addressing (sensor, direction, page) before + // trusting the page body, so a mismatched page cannot corrupt the + // accumulated stream. + if payload[0] != sensor_index || payload[1] != u8::from(direction) || payload[2] != page + { + return Err(Hidpp20Error::UnsupportedResponse); + } + stream.extend_from_slice(&payload[3..16]); + if terminated_word_len(&stream).is_some() { + return parse_dpi_ranges(&stream); + } + } + Err(Hidpp20Error::UnsupportedResponse) + } + + /// Retrieves the current profile's DPI list for `sensor_index` along + /// `direction`. + /// + /// Only meaningful when the sensor supports profiles + /// ([`SensorCapabilities::PROFILE`]); otherwise the device returns an error. + pub async fn get_sensor_dpi_list( + &self, + sensor_index: u8, + direction: DpiDirection, + ) -> Result, Hidpp20Error> { + let payload = self + .endpoint + .call(3, [sensor_index, direction.into(), 0]) + .await? + .extend_payload(); + // Skip the echoed sensor index and direction in bytes 0 and 1. + parse_dpi_list(&payload[2..]) + } + + /// Retrieves the current profile's lift-off-distance list for + /// `sensor_index`. + /// + /// The list length is the sensor's DPI-level count + /// ([`SensorCapabilitiesInfo::dpi_level_count`]), which the caller passes as + /// `dpi_level_count`; the device does not delimit the list. + pub async fn get_sensor_lod_list( + &self, + sensor_index: u8, + dpi_level_count: u8, + ) -> Result, Hidpp20Error> { + let payload = self + .endpoint + .call(4, [sensor_index, 0, 0]) + .await? + .extend_payload(); + // Skip the echoed sensor index in byte 0. + parse_lod_list(&payload[1..], usize::from(dpi_level_count)) + } + + /// Retrieves the current and default DPI parameters of `sensor_index`. + pub async fn get_sensor_dpi_parameters( + &self, + sensor_index: u8, + ) -> Result { + let payload = self + .endpoint + .call(5, [sensor_index, 0, 0]) + .await? + .extend_payload(); + Ok(DpiParameters { + sensor_index: payload[0], + dpi_x: u16::from_be_bytes([payload[1], payload[2]]), + default_dpi_x: u16::from_be_bytes([payload[3], payload[4]]), + dpi_y: u16::from_be_bytes([payload[5], payload[6]]), + default_dpi_y: u16::from_be_bytes([payload[7], payload[8]]), + lod: Lod::try_from(payload[9]).map_err(|_| Hidpp20Error::UnsupportedResponse)?, + }) + } + + /// Sets the DPI and lift-off distance of `sensor_index`. + /// + /// `params.dpi_y` must be `0` when the sensor has no independent Y axis. + pub async fn set_sensor_dpi_parameters( + &self, + sensor_index: u8, + params: SetDpiParameters, + ) -> Result<(), Hidpp20Error> { + let [dpi_x_hi, dpi_x_lo] = params.dpi_x.to_be_bytes(); + let [dpi_y_hi, dpi_y_lo] = params.dpi_y.to_be_bytes(); + let mut args = [0; 16]; + args[..6].copy_from_slice(&[ + sensor_index, + dpi_x_hi, + dpi_x_lo, + dpi_y_hi, + dpi_y_lo, + params.lod.into(), + ]); + self.endpoint.call_long(6, args).await?; + Ok(()) + } + + /// Asks the device to show `params.dpi_level` on its DPI status LED. + /// + /// Valid only while the device is in host mode. + pub async fn show_sensor_dpi_status( + &self, + sensor_index: u8, + params: ShowDpiStatus, + ) -> Result<(), Hidpp20Error> { + let mut args = [0; 16]; + args[..4].copy_from_slice(&[ + sensor_index, + params.dpi_level, + params.led_hold_type.into(), + params.button_num, + ]); + self.endpoint.call_long(7, args).await?; + Ok(()) + } + + /// Retrieves the reference information needed to start a calibration of + /// `sensor_index`. + pub async fn get_dpi_calibration_info( + &self, + sensor_index: u8, + ) -> Result { + let payload = self + .endpoint + .call(8, [sensor_index, 0, 0]) + .await? + .extend_payload(); + Ok(DpiCalibrationInfo { + sensor_index: payload[0], + mouse_width: payload[1], + mouse_length: u16::from_be_bytes([payload[2], payload[3]]), + calib_dpi_x: u16::from_be_bytes([payload[4], payload[5]]), + calib_dpi_y: u16::from_be_bytes([payload[6], payload[7]]), + }) + } + + /// Starts a DPI calibration of `sensor_index`. + /// + /// Requires [`SensorCapabilities::CALIBRATION`]. The device reports the + /// outcome through an [`ExtendedDpiEvent::CalibrationCompleted`] event; for a + /// [`CalibrationType::Software`] calibration the result is then applied with + /// [`Self::set_dpi_calibration`]. + pub async fn start_dpi_calibration( + &self, + sensor_index: u8, + params: StartDpiCalibration, + ) -> Result<(), Hidpp20Error> { + let [count_hi, count_lo] = params.expected_count.to_be_bytes(); + let mut args = [0; 16]; + args[..8].copy_from_slice(&[ + sensor_index, + params.direction.into(), + count_hi, + count_lo, + params.calib_type.into(), + params.start_timeout, + params.hw_process_timeout, + params.sw_process_timeout, + ]); + self.endpoint.call_long(9, args).await?; + Ok(()) + } + + /// Applies a calibration correction to `sensor_index` along `direction`. + /// + /// Allowed only while a calibration started by [`Self::start_dpi_calibration`] + /// is in progress (or to revert, see [`DpiCalibrationCorrection`]). + pub async fn set_dpi_calibration( + &self, + sensor_index: u8, + direction: DpiDirection, + correction: DpiCalibrationCorrection, + ) -> Result<(), Hidpp20Error> { + let [cor_hi, cor_lo] = correction.to_wire().to_be_bytes(); + let mut args = [0; 16]; + args[..4].copy_from_slice(&[sensor_index, direction.into(), cor_hi, cor_lo]); + self.endpoint.call_long(10, args).await?; + Ok(()) + } +} diff --git a/crates/openlogi-hidpp/src/feature/extended_dpi/tests.rs b/crates/openlogi-hidpp/src/feature/extended_dpi/tests.rs new file mode 100644 index 00000000..2f306744 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/extended_dpi/tests.rs @@ -0,0 +1,282 @@ +//! Unit tests for `ExtendedAdjustableDpi` payload parsing and event decoding. + +use super::event::{ExtendedDpiEvent, decode_event}; +use super::types::{ + DpiCalibrationCorrection, DpiDirection, DpiRange, Lod, parse_dpi_list, parse_dpi_ranges, + parse_lod_list, terminated_word_len, +}; +use crate::protocol::v20::Hidpp20Error; + +#[test] +fn parses_fixed_dpi_ranges_pws_example() { + // Spec example: a PWS mouse supporting only 400, 800 and 1200 DPI. + let stream = [0x01, 0x90, 0x03, 0x20, 0x04, 0xb0, 0x00, 0x00]; + + assert_eq!( + parse_dpi_ranges(&stream).unwrap(), + [ + DpiRange::Fixed(400), + DpiRange::Fixed(800), + DpiRange::Fixed(1200), + ] + ); +} + +#[test] +fn parses_stepped_dpi_ranges_gaming_example() { + // Spec example: 100..1000 step 1, then 1000..32000 step 100. + let stream = [ + 0x00, 0x64, 0xe0, 0x01, 0x03, 0xe8, 0xe0, 0x64, 0x7d, 0x00, 0x00, 0x00, + ]; + + assert_eq!( + parse_dpi_ranges(&stream).unwrap(), + [ + DpiRange::Stepped { + from: 100, + to: 1000, + step: 1 + }, + DpiRange::Stepped { + from: 1000, + to: 32000, + step: 100 + }, + ] + ); +} + +#[test] +fn parses_dpi_ranges_split_across_pages() { + // Spec example whose seventh word (0x03e8) straddles the page boundary: the + // MSB ends page 0 and the LSB starts page 1. The accumulated stream parses as + // five chained stepped ranges. + let page0 = [ + 0x00, 0x64, 0xe0, 0x01, 0x00, 0xc8, 0xe0, 0x02, 0x01, 0xf4, 0xe0, 0x05, 0x03, + ]; + let page1 = [ + 0xe8, 0xe0, 0x0a, 0x07, 0xd0, 0xe0, 0x14, 0x13, 0x88, 0x00, 0x00, 0x00, 0x00, + ]; + + // The first page alone has no terminator, forcing a second page fetch. + assert!(terminated_word_len(&page0).is_none()); + + let mut stream = page0.to_vec(); + stream.extend_from_slice(&page1); + assert!(terminated_word_len(&stream).is_some()); + + assert_eq!( + parse_dpi_ranges(&stream).unwrap(), + [ + DpiRange::Stepped { + from: 100, + to: 200, + step: 1 + }, + DpiRange::Stepped { + from: 200, + to: 500, + step: 2 + }, + DpiRange::Stepped { + from: 500, + to: 1000, + step: 5 + }, + DpiRange::Stepped { + from: 1000, + to: 2000, + step: 10 + }, + DpiRange::Stepped { + from: 2000, + to: 5000, + step: 20 + }, + ] + ); +} + +#[test] +fn parses_range_followed_by_fixed_value() { + // A stepped range whose endpoint is not reused by the next entry must not be + // re-emitted as a fixed value. + let stream = [0x00, 0x64, 0xe0, 0x01, 0x00, 0xc8, 0x01, 0x90, 0x00, 0x00]; + + assert_eq!( + parse_dpi_ranges(&stream).unwrap(), + [ + DpiRange::Stepped { + from: 100, + to: 200, + step: 1 + }, + DpiRange::Fixed(400), + ] + ); +} + +#[test] +fn rejects_hyphen_without_preceding_value() { + let stream = [0xe0, 0x01, 0x01, 0x90, 0x00, 0x00]; + + assert!(matches!( + parse_dpi_ranges(&stream), + Err(Hidpp20Error::UnsupportedResponse) + )); +} + +#[test] +fn rejects_hyphen_without_following_value() { + let stream = [0x01, 0x90, 0xe0, 0x01, 0x00, 0x00]; + + assert!(matches!( + parse_dpi_ranges(&stream), + Err(Hidpp20Error::UnsupportedResponse) + )); +} + +#[test] +fn rejects_zero_step_unused_marker() { + // 0xe000 is the documented "unused" marker (step 0); it is not a valid range. + let stream = [0x01, 0x90, 0xe0, 0x00, 0x04, 0xb0, 0x00, 0x00]; + + assert!(matches!( + parse_dpi_ranges(&stream), + Err(Hidpp20Error::UnsupportedResponse) + )); +} + +#[test] +fn rejects_descending_stepped_range() { + let stream = [0x06, 0x40, 0xe0, 0x32, 0x01, 0x90, 0x00, 0x00]; + + assert!(matches!( + parse_dpi_ranges(&stream), + Err(Hidpp20Error::UnsupportedResponse) + )); +} + +#[test] +fn rejects_unterminated_dpi_ranges() { + let stream = [0x01, 0x90, 0x03, 0x20]; + + assert!(matches!( + parse_dpi_ranges(&stream), + Err(Hidpp20Error::UnsupportedResponse) + )); +} + +#[test] +fn parses_dpi_list_with_terminator() { + // Spec example: a profile configured to 400, 800 and 1600 DPI. + let bytes = [0x01, 0x90, 0x03, 0x20, 0x06, 0x40, 0x00, 0x00]; + + assert_eq!(parse_dpi_list(&bytes).unwrap(), [400, 800, 1600]); +} + +#[test] +fn parses_dpi_list_filling_payload() { + // A full list leaves no room for the terminator. + let bytes = [0x01, 0x90, 0x03, 0x20]; + + assert_eq!(parse_dpi_list(&bytes).unwrap(), [400, 800]); +} + +#[test] +fn parses_lod_list() { + let bytes = [1, 2, 3, 0, 0, 0]; + + assert_eq!( + parse_lod_list(&bytes, 3).unwrap(), + [Lod::Low, Lod::Medium, Lod::High] + ); +} + +#[test] +fn rejects_unknown_lod_value() { + let bytes = [9]; + + assert!(matches!( + parse_lod_list(&bytes, 1), + Err(Hidpp20Error::UnsupportedResponse) + )); +} + +#[test] +fn rejects_lod_list_longer_than_payload() { + let bytes = [1, 2]; + + assert!(matches!( + parse_lod_list(&bytes, 3), + Err(Hidpp20Error::UnsupportedResponse) + )); +} + +#[test] +fn decodes_parameters_changed_event() { + let mut payload = [0; 16]; + payload[0] = 1; + payload[1..3].copy_from_slice(&800u16.to_be_bytes()); + payload[3..5].copy_from_slice(&1600u16.to_be_bytes()); + payload[5] = 2; + + let ExtendedDpiEvent::ParametersChanged(event) = decode_event(0, &payload).unwrap() else { + panic!("expected a 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); +} + +#[test] +fn decodes_calibration_completed_event() { + let mut payload = [0; 16]; + payload[1] = 1; + payload[2..4].copy_from_slice(&100i16.to_be_bytes()); + payload[4..6].copy_from_slice(&(-1i16).to_be_bytes()); + + 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.correction, 100); + assert_eq!(event.delta, -1); + assert!(!event.failed()); +} + +#[test] +fn flags_failed_calibration_event() { + let mut payload = [0; 16]; + payload[2..4].copy_from_slice(&i16::MIN.to_be_bytes()); + + let ExtendedDpiEvent::CalibrationCompleted(event) = decode_event(1, &payload).unwrap() else { + panic!("expected a calibration-completed event"); + }; + assert!(event.failed()); +} + +#[test] +fn ignores_unknown_event_sub_id() { + assert!(decode_event(7, &[0; 16]).is_none()); +} + +#[test] +fn ignores_event_with_unknown_lod() { + let mut payload = [0; 16]; + payload[5] = 9; + + assert!(decode_event(0, &payload).is_none()); +} + +#[test] +fn encodes_calibration_correction_sentinels() { + assert_eq!(DpiCalibrationCorrection::Adjust(100).to_wire(), 100); + assert_eq!(DpiCalibrationCorrection::Adjust(-512).to_wire(), -512); + assert_eq!(DpiCalibrationCorrection::RevertToOob.to_wire(), 0); + assert_eq!( + DpiCalibrationCorrection::RevertToProfile.to_wire(), + i16::MIN + ); +} diff --git a/crates/openlogi-hidpp/src/feature/extended_dpi/types.rs b/crates/openlogi-hidpp/src/feature/extended_dpi/types.rs new file mode 100644 index 00000000..746e228c --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/extended_dpi/types.rs @@ -0,0 +1,379 @@ +//! Domain types and payload parsers for `ExtendedAdjustableDpi` (`0x2202`). + +use num_enum::{IntoPrimitive, TryFromPrimitive}; + +use crate::protocol::v20::Hidpp20Error; + +/// The axis a DPI value or calibration applies to. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum DpiDirection { + /// Horizontal (X) axis. + X = 0, + /// Vertical (Y) axis. + Y = 1, +} + +/// A sensor's lift-off distance setting. +/// +/// The lift-off distance is the height above the surface at which the sensor +/// stops tracking motion. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum Lod { + /// Lift-off distance control is not supported. + NotSupported = 0, + /// Low lift-off distance. + Low = 1, + /// Medium lift-off distance. + Medium = 2, + /// High lift-off distance. + High = 3, +} + +/// How the device holds the DPI status LED after a +/// [`ExtendedDpiFeature::show_sensor_dpi_status`] request. +/// +/// [`ExtendedDpiFeature::show_sensor_dpi_status`]: +/// super::ExtendedDpiFeature::show_sensor_dpi_status +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum LedHoldType { + /// Turn the LED off once a device-defined timeout elapses. + TimerBased = 0, + /// Turn the LED off once a device-defined event completes (e.g. releasing a + /// DPI-shift button). + EventBased = 1, + /// Turn the LED on under software control. + SwControlOn = 2, + /// Turn the LED off under software control. + SwControlOff = 3, +} + +/// Where a DPI calibration is computed. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum CalibrationType { + /// Calibration is computed by the sensor firmware / hardware. + Hardware = 0, + /// Calibration is computed by host software. + Software = 1, +} + +bitflags::bitflags! { + /// Per-sensor capabilities reported by + /// [`ExtendedDpiFeature::get_sensor_capabilities`]. + /// + /// [`ExtendedDpiFeature::get_sensor_capabilities`]: + /// super::ExtendedDpiFeature::get_sensor_capabilities + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct SensorCapabilities: u8 { + /// The sensor supports an independent Y-axis DPI. + const DPI_Y = 1 << 0; + /// The sensor supports lift-off distance control. + const LOD = 1 << 1; + /// The sensor supports DPI calibration. + const CALIBRATION = 1 << 2; + /// The sensor supports DPI profiles. + const PROFILE = 1 << 3; + } +} + +/// A sensor's capabilities and DPI-level count. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct SensorCapabilitiesInfo { + /// Index of the sensor the capabilities belong to. + pub sensor_index: u8, + /// Number of selectable DPI levels, or `0` if the device does not manage DPI + /// levels. + pub dpi_level_count: u8, + /// Supported capabilities. + pub capabilities: SensorCapabilities, +} + +/// One entry of a sensor's supported-DPI description. +/// +/// Returned by [`ExtendedDpiFeature::get_sensor_dpi_ranges`], which can mix +/// fixed values and stepped ranges. A stepped range's endpoints are inclusive +/// and adjacent ranges may share an endpoint (the device reports the high value +/// of one range as the low value of the next). +/// +/// [`ExtendedDpiFeature::get_sensor_dpi_ranges`]: +/// super::ExtendedDpiFeature::get_sensor_dpi_ranges +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub enum DpiRange { + /// A single selectable DPI value. + Fixed(u16), + /// A contiguous range of selectable DPI values from `from` to `to` + /// (inclusive) in increments of `step`. + Stepped { + /// Lowest selectable DPI in the range (inclusive). + from: u16, + /// Highest selectable DPI in the range (inclusive). + to: u16, + /// DPI increment between adjacent selectable values. + step: u16, + }, +} + +/// Current and default DPI parameters of a sensor, returned by +/// [`ExtendedDpiFeature::get_sensor_dpi_parameters`]. +/// +/// `dpi_y` and `default_dpi_y` are `0` when the sensor does not support an +/// independent Y axis (see [`SensorCapabilities::DPI_Y`]). +/// +/// [`ExtendedDpiFeature::get_sensor_dpi_parameters`]: +/// super::ExtendedDpiFeature::get_sensor_dpi_parameters +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct DpiParameters { + /// Index of the sensor. + pub sensor_index: u8, + /// Current X-axis DPI. + pub dpi_x: u16, + /// Default X-axis DPI. + pub default_dpi_x: u16, + /// Current Y-axis DPI, or `0` when unsupported. + pub dpi_y: u16, + /// Default Y-axis DPI, or `0` when unsupported. + pub default_dpi_y: u16, + /// Current lift-off distance. + pub lod: Lod, +} + +/// DPI parameters to apply with +/// [`ExtendedDpiFeature::set_sensor_dpi_parameters`]. +/// +/// [`ExtendedDpiFeature::set_sensor_dpi_parameters`]: +/// super::ExtendedDpiFeature::set_sensor_dpi_parameters +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub struct SetDpiParameters { + /// New X-axis DPI (`1..=57343`). + pub dpi_x: u16, + /// New Y-axis DPI (`1..=57343`), or `0` when the sensor has no independent Y + /// axis. + pub dpi_y: u16, + /// New lift-off distance. + pub lod: Lod, +} + +/// Parameters for [`ExtendedDpiFeature::show_sensor_dpi_status`]. +/// +/// [`ExtendedDpiFeature::show_sensor_dpi_status`]: +/// super::ExtendedDpiFeature::show_sensor_dpi_status +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub struct ShowDpiStatus { + /// DPI level to display (`1..=dpi_level_count`). + pub dpi_level: u8, + /// How the device holds the DPI status LED. + pub led_hold_type: LedHoldType, + /// HID button number that initiated the DPI change (starts at `1`). + pub button_num: u8, +} + +/// Calibration reference information returned by +/// [`ExtendedDpiFeature::get_dpi_calibration_info`]. +/// +/// [`ExtendedDpiFeature::get_dpi_calibration_info`]: +/// super::ExtendedDpiFeature::get_dpi_calibration_info +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct DpiCalibrationInfo { + /// Index of the sensor. + pub sensor_index: u8, + /// Device width in millimetres. + pub mouse_width: u8, + /// Device length in millimetres. + pub mouse_length: u16, + /// X-axis DPI configured for calibration. + pub calib_dpi_x: u16, + /// Y-axis DPI configured for calibration, or `0` when unsupported. + pub calib_dpi_y: u16, +} + +/// Parameters for [`ExtendedDpiFeature::start_dpi_calibration`]. +/// +/// [`ExtendedDpiFeature::start_dpi_calibration`]: +/// super::ExtendedDpiFeature::start_dpi_calibration +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub struct StartDpiCalibration { + /// Axis to calibrate. + pub direction: DpiDirection, + /// Expected pixel count for the calibration movement (ignored for + /// [`CalibrationType::Software`]). + pub expected_count: u16, + /// Where the calibration is computed. + pub calib_type: CalibrationType, + /// Timeout in seconds for the calibration to start (`<= 60`). + pub start_timeout: u8, + /// Timeout in seconds for the hardware calibration process (`<= 60`). + pub hw_process_timeout: u8, + /// Timeout in seconds for the software calibration process (`<= 60`). + pub sw_process_timeout: u8, +} + +/// A DPI calibration correction to apply with +/// [`ExtendedDpiFeature::set_dpi_calibration`]. +/// +/// [`ExtendedDpiFeature::set_dpi_calibration`]: +/// super::ExtendedDpiFeature::set_dpi_calibration +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub enum DpiCalibrationCorrection { + /// Scale the sensor resolution by `(1024 + value) / 1024`. Valid values are + /// `-1023..=1023`; `0` reverts to the out-of-box setting, like + /// [`DpiCalibrationCorrection::RevertToOob`]. + Adjust(i16), + /// Revert to the out-of-box (OOB) profile setting (wire value `0x0000`). + RevertToOob, + /// Revert to the setting stored in the current profile (wire value + /// `0x8000`). + RevertToProfile, +} + +impl DpiCalibrationCorrection { + /// The signed 16-bit wire value for this correction. + pub(super) fn to_wire(self) -> i16 { + match self { + // 0x8000 as a signed 16-bit integer. + DpiCalibrationCorrection::RevertToProfile => i16::MIN, + DpiCalibrationCorrection::RevertToOob => 0, + DpiCalibrationCorrection::Adjust(value) => { + // `i16::MIN` is the `0x8000` "revert to profile" sentinel, not a + // correction; an out-of-range adjustment would silently collide + // with it instead of eliciting the device's `INVALID_ARGUMENT`. + debug_assert!( + (-1023..=1023).contains(&value), + "calibration correction {value} out of range -1023..=1023" + ); + value + } + } + } +} + +/// Highest bit pattern that marks a "hyphen" (range step) word; values at or +/// above `0xe000` are not literal DPI values. +const HYPHEN_TAG: u16 = 0b111 << 13; + +/// Reads `stream` as a sequence of big-endian 16-bit words, returning their +/// count up to (but excluding) the first `0x0000` end-of-list terminator. +/// +/// Returns `None` when no terminator is present in the complete words available, +/// signalling that another `getSensorDpiRanges` page is required. +pub(super) fn terminated_word_len(stream: &[u8]) -> Option { + let mut offset = 0; + while offset + 1 < stream.len() { + if u16::from_be_bytes([stream[offset], stream[offset + 1]]) == 0 { + return Some(offset); + } + offset += 2; + } + None +} + +/// Parses an accumulated `getSensorDpiRanges` byte stream into [`DpiRange`]s. +/// +/// `stream` is the concatenation of every page's range bytes. Parsing stops at +/// the first `0x0000` terminator word. Each range is encoded as big-endian +/// 16-bit words where the top three bits select the meaning: `0b000..=0b110` +/// tags a literal DPI value and `0b111` tags a "hyphen" carrying the step of the +/// range whose endpoints are the surrounding literal values. +pub(super) fn parse_dpi_ranges(stream: &[u8]) -> Result, Hidpp20Error> { + let len = terminated_word_len(stream).ok_or(Hidpp20Error::UnsupportedResponse)?; + let word = |offset: usize| u16::from_be_bytes([stream[offset], stream[offset + 1]]); + + let mut ranges = Vec::new(); + // The most recent literal value, and whether it was already emitted as a + // range endpoint (so it is not also emitted as a standalone fixed value). + let mut pending: Option = None; + let mut pending_is_range_end = false; + let mut offset = 0; + + while offset < len { + let value = word(offset); + if value >= HYPHEN_TAG { + // A hyphen carries the step and consumes the following literal as the + // range's high endpoint. + let step = value & !HYPHEN_TAG; + let from = pending.ok_or(Hidpp20Error::UnsupportedResponse)?; + if step == 0 || offset + 3 >= len { + return Err(Hidpp20Error::UnsupportedResponse); + } + let to = word(offset + 2); + if to >= HYPHEN_TAG || to < from { + return Err(Hidpp20Error::UnsupportedResponse); + } + ranges.push(DpiRange::Stepped { from, to, step }); + pending = Some(to); + pending_is_range_end = true; + offset += 4; + } else { + // A literal value: flush the previous standalone literal first. + if let Some(previous) = pending { + if !pending_is_range_end { + ranges.push(DpiRange::Fixed(previous)); + } + } + pending = Some(value); + pending_is_range_end = false; + offset += 2; + } + } + + if let Some(previous) = pending { + if !pending_is_range_end { + ranges.push(DpiRange::Fixed(previous)); + } + } + + if ranges.is_empty() { + return Err(Hidpp20Error::UnsupportedResponse); + } + Ok(ranges) +} + +/// Parses a `getSensorDpiList` payload (after the echoed sensor index and +/// direction) into explicit DPI values, stopping at the `0x0000` terminator. +pub(super) fn parse_dpi_list(bytes: &[u8]) -> Result, Hidpp20Error> { + let mut values = Vec::new(); + let mut offset = 0; + while offset + 1 < bytes.len() { + let value = u16::from_be_bytes([bytes[offset], bytes[offset + 1]]); + if value == 0 { + break; + } + values.push(value); + offset += 2; + } + Ok(values) +} + +/// Parses the first `count` lift-off-distance entries of a `getSensorLodList` +/// payload (after the echoed sensor index). +pub(super) fn parse_lod_list(bytes: &[u8], count: usize) -> Result, Hidpp20Error> { + if count > bytes.len() { + return Err(Hidpp20Error::UnsupportedResponse); + } + bytes[..count] + .iter() + .map(|&raw| Lod::try_from(raw).map_err(|_| Hidpp20Error::UnsupportedResponse)) + .collect() +} diff --git a/crates/openlogi-hidpp/src/feature/mod.rs b/crates/openlogi-hidpp/src/feature/mod.rs index 89ca61ec..b6eb323b 100755 --- a/crates/openlogi-hidpp/src/feature/mod.rs +++ b/crates/openlogi-hidpp/src/feature/mod.rs @@ -13,12 +13,14 @@ pub mod brightness_control; pub mod device_friendly_name; pub mod device_information; pub mod device_type_and_name; +pub mod extended_dpi; pub mod extended_report_rate; pub mod feature_set; pub mod fn_inversion; pub mod hires_wheel; pub mod hosts_info; pub mod mode_status; +pub mod mouse_pointer; pub mod multi_platform; pub mod registry; pub mod report_rate; diff --git a/crates/openlogi-hidpp/src/feature/mouse_pointer/mod.rs b/crates/openlogi-hidpp/src/feature/mouse_pointer/mod.rs new file mode 100644 index 00000000..c13ac595 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/mouse_pointer/mod.rs @@ -0,0 +1,136 @@ +//! Implements the `MousePointer` feature (ID `0x2200`) that reports a mouse's +//! basic optical-sensor properties and pointer-tuning hints. + +use std::sync::Arc; + +use num_enum::{IntoPrimitive, TryFromPrimitive}; + +use crate::{ + channel::HidppChannel, + feature::{CreatableFeature, Feature, FeatureEndpoint}, + protocol::v20::Hidpp20Error, +}; + +/// The pointer-acceleration ("ballistics") curve a device suggests, based on its +/// physical characteristics. +/// +/// A host that provides multiple ballistics curves can pick a default from this +/// hint; a host without its own ballistics ignores it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum PointerAcceleration { + /// No acceleration suggested. + None = 0, + /// A low acceleration curve. + Low = 1, + /// A medium acceleration curve. + Medium = 2, + /// A high acceleration curve. + High = 3, +} + +/// Mouse-pointer information returned by +/// [`MousePointerFeature::get_mouse_pointer_info`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct MousePointerInfo { + /// Typical sensor resolution on a standard surface, in 1-DPI steps. + /// + /// Real-world resolution may differ from this value by up to ±20% depending + /// on the surface. + pub sensor_resolution: u16, + + /// The acceleration curve the device suggests. + pub pointer_acceleration: PointerAcceleration, + + /// Whether the device suggests using the OS-native ballistics. + /// + /// `false` means the host may override the OS ballistics if it can; `true` + /// means the device suggests keeping the OS-native ballistics. + pub suggest_os_ballistics: bool, + + /// Whether the device suggests offering vertical-orientation tuning. + /// + /// `true` for devices such as trackballs, where the host can let the user + /// fine-tune X/Y movement relative to cursor movement. + pub suggest_vertical_tuning: bool, +} + +/// Implements the `MousePointer` / `0x2200` feature. +#[derive(Clone)] +pub struct MousePointerFeature { + /// The endpoint this feature talks to. + endpoint: FeatureEndpoint, +} + +impl CreatableFeature for MousePointerFeature { + const ID: u16 = 0x2200; + const STARTING_VERSION: u8 = 0; + + fn new(chan: Arc, device_index: u8, feature_index: u8) -> Self { + Self { + endpoint: FeatureEndpoint::new(chan, device_index, feature_index), + } + } +} + +impl Feature for MousePointerFeature {} + +impl MousePointerFeature { + /// Retrieves the sensor resolution and pointer-tuning hints of the mouse. + pub async fn get_mouse_pointer_info(&self) -> Result { + let payload = self.endpoint.call(0, [0; 3]).await?.extend_payload(); + MousePointerInfo::from_payload(payload) + } +} + +impl MousePointerInfo { + /// Decodes a `getMousePointerInfo` response payload. + fn from_payload(payload: [u8; 16]) -> Result { + let flags = payload[2]; + Ok(Self { + sensor_resolution: u16::from_be_bytes([payload[0], payload[1]]), + // Acceleration occupies the low two bits; all four values are valid + // so this conversion cannot actually fail. + pointer_acceleration: PointerAcceleration::try_from(flags & 0b11) + .map_err(|_| Hidpp20Error::UnsupportedResponse)?, + suggest_os_ballistics: flags & (1 << 2) != 0, + suggest_vertical_tuning: flags & (1 << 3) != 0, + }) + } +} + +#[cfg(test)] +mod tests { + use super::{MousePointerInfo, PointerAcceleration}; + + #[test] + fn decodes_resolution_and_flags() { + let mut payload = [0; 16]; + payload[0..2].copy_from_slice(&1600u16.to_be_bytes()); + // High acceleration (0b11) + suggest OS ballistics (bit 2). + payload[2] = 0b0000_0111; + + let info = MousePointerInfo::from_payload(payload).unwrap(); + assert_eq!(info.sensor_resolution, 1600); + assert_eq!(info.pointer_acceleration, PointerAcceleration::High); + assert!(info.suggest_os_ballistics); + assert!(!info.suggest_vertical_tuning); + } + + #[test] + fn decodes_trackball_vertical_tuning() { + let mut payload = [0; 16]; + payload[0..2].copy_from_slice(&400u16.to_be_bytes()); + // Suggest vertical tuning (bit 3), acceleration none. + payload[2] = 0b0000_1000; + + let info = MousePointerInfo::from_payload(payload).unwrap(); + assert_eq!(info.pointer_acceleration, PointerAcceleration::None); + assert!(!info.suggest_os_ballistics); + assert!(info.suggest_vertical_tuning); + } +} diff --git a/crates/openlogi-hidpp/src/feature/registry.rs b/crates/openlogi-hidpp/src/feature/registry.rs index 30378163..92bf2d68 100755 --- a/crates/openlogi-hidpp/src/feature/registry.rs +++ b/crates/openlogi-hidpp/src/feature/registry.rs @@ -15,15 +15,15 @@ use crate::{ brightness_control::BrightnessControlFeature, device_friendly_name::DeviceFriendlyNameFeature, device_information::DeviceInformationFeature, - device_type_and_name::DeviceTypeAndNameFeature, + device_type_and_name::DeviceTypeAndNameFeature, extended_dpi::ExtendedDpiFeature, extended_report_rate::ExtendedReportRateFeature, feature_set::FeatureSetFeature, fn_inversion::FnInversionMultiHostFeature, hires_wheel::HiResWheelFeature, hosts_info::HostsInfoFeature, mode_status::ModeStatusFeature, - multi_platform::MultiPlatformFeature, report_rate::ReportRateFeature, - reprog_controls::ReprogControlsFeature, root::RootFeature, sidetone::SidetoneFeature, - smartshift::SmartShiftFeature, smartshift_enhanced::SmartShiftEnhancedFeature, - thumbwheel::ThumbwheelFeature, unified_battery::UnifiedBatteryFeature, - vertical_scrolling::VerticalScrollingFeature, + mouse_pointer::MousePointerFeature, multi_platform::MultiPlatformFeature, + report_rate::ReportRateFeature, reprog_controls::ReprogControlsFeature, root::RootFeature, + sidetone::SidetoneFeature, smartshift::SmartShiftFeature, + smartshift_enhanced::SmartShiftEnhancedFeature, thumbwheel::ThumbwheelFeature, + unified_battery::UnifiedBatteryFeature, vertical_scrolling::VerticalScrollingFeature, wireless_device_status::WirelessDeviceStatusFeature, }, }; @@ -167,9 +167,9 @@ static KNOWN_FEATURES: LazyLock> = LazyLock::new(|| { 0x2121 "HiResWheel" => HiResWheelFeature, 0x2130 "RatchetWheel", 0x2150 "Thumbwheel" => ThumbwheelFeature, - 0x2200 "MousePointer", + 0x2200 "MousePointer" => MousePointerFeature, 0x2201 "AdjustableDpi" => AdjustableDpiFeature, - 0x2202 "ExtendedAdjustableDpi", + 0x2202 "ExtendedAdjustableDpi" => ExtendedDpiFeature, 0x2205 "PointerMotionScaling", 0x2230 "SensorAngleSnapping", 0x2240 "SurfaceTuning", From 64f02bd4213a8281129d84a1fca8c05a582cbba2 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sat, 27 Jun 2026 18:23:11 +0800 Subject: [PATCH 05/21] feat(hidpp): add host switching and keyboard key wrappers --- .../src/feature/change_host/mod.rs | 99 +++++++++++++ .../src/feature/disable_keys/mod.rs | 84 +++++++++++ .../src/feature/disable_keys_by_usage/mod.rs | 128 ++++++++++++++++ .../src/feature/dual_platform/mod.rs | 138 ++++++++++++++++++ .../src/feature/fn_inversion/mod.rs | 81 +++++++++- crates/openlogi-hidpp/src/feature/mod.rs | 17 +++ crates/openlogi-hidpp/src/feature/registry.rs | 45 ++++-- 7 files changed, 576 insertions(+), 16 deletions(-) create mode 100644 crates/openlogi-hidpp/src/feature/change_host/mod.rs create mode 100644 crates/openlogi-hidpp/src/feature/disable_keys/mod.rs create mode 100644 crates/openlogi-hidpp/src/feature/disable_keys_by_usage/mod.rs create mode 100644 crates/openlogi-hidpp/src/feature/dual_platform/mod.rs diff --git a/crates/openlogi-hidpp/src/feature/change_host/mod.rs b/crates/openlogi-hidpp/src/feature/change_host/mod.rs new file mode 100644 index 00000000..853cba0b --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/change_host/mod.rs @@ -0,0 +1,99 @@ +//! Implements the `ChangeHost` feature (ID `0x1814`) that selects which host / +//! RF channel a multi-host device is connected to. + +use std::sync::Arc; + +use crate::{ + channel::HidppChannel, + feature::{CreatableFeature, Feature, FeatureEndpoint}, + protocol::v20::Hidpp20Error, +}; + +bitflags::bitflags! { + /// Host-switching capabilities reported by [`ChangeHostFeature::get_host_info`]. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct ChangeHostCapabilities: u8 { + /// Enhanced host switching is enabled: on a failed connection the device + /// falls back to another host with a non-zero cookie before returning to + /// the original host. + const ENHANCED_HOST_SWITCH = 1 << 0; + } +} + +/// Host configuration returned by [`ChangeHostFeature::get_host_info`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct ChangeHostInfo { + /// Number of hosts / RF channels. + pub host_count: u8, + /// Current host index, in `0..host_count`. + pub current_host: u8, + /// Host-switching capabilities. + pub capabilities: ChangeHostCapabilities, +} + +/// Implements the `ChangeHost` / `0x1814` feature. +#[derive(Clone)] +pub struct ChangeHostFeature { + /// The endpoint this feature talks to. + endpoint: FeatureEndpoint, +} + +impl CreatableFeature for ChangeHostFeature { + const ID: u16 = 0x1814; + const STARTING_VERSION: u8 = 0; + + fn new(chan: Arc, device_index: u8, feature_index: u8) -> Self { + Self { + endpoint: FeatureEndpoint::new(chan, device_index, feature_index), + } + } +} + +impl Feature for ChangeHostFeature {} + +impl ChangeHostFeature { + /// Retrieves the host count, current host and host-switching flags. + pub async fn get_host_info(&self) -> Result { + let payload = self.endpoint.call(0, [0; 3]).await?.extend_payload(); + Ok(ChangeHostInfo { + host_count: payload[0], + current_host: payload[1], + capabilities: ChangeHostCapabilities::from_bits_retain(payload[2]), + }) + } + + /// Selects `host` as the current host. + /// + /// This is sent fire-and-forget: a successful switch usually resets the + /// device, so no response is awaited. The device drops off the current host + /// once it acts on the request. + pub async fn set_current_host(&self, host: u8) -> Result<(), Hidpp20Error> { + self.endpoint.notify(1, [host, 0, 0]).await + } + + /// Retrieves the persistent per-host cookie bytes. + /// + /// `host_count` is the value from [`ChangeHostInfo::host_count`]; the device + /// returns one cookie byte per host and does not delimit the list. + pub async fn get_cookies(&self, host_count: u8) -> Result, Hidpp20Error> { + let count = usize::from(host_count); + let payload = self.endpoint.call(2, [0; 3]).await?.extend_payload(); + if count > payload.len() { + return Err(Hidpp20Error::UnsupportedResponse); + } + Ok(payload[..count].to_vec()) + } + + /// Writes the persistent `cookie` byte for `host`. + /// + /// Cookies are arbitrary software-defined bytes stored in the device's + /// non-volatile memory; the firmware clears a host's cookie when a new host + /// connects to that slot. + pub async fn set_cookie(&self, host: u8, cookie: u8) -> Result<(), Hidpp20Error> { + self.endpoint.call(3, [host, cookie, 0]).await?; + Ok(()) + } +} diff --git a/crates/openlogi-hidpp/src/feature/disable_keys/mod.rs b/crates/openlogi-hidpp/src/feature/disable_keys/mod.rs new file mode 100644 index 00000000..1b994db5 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/disable_keys/mod.rs @@ -0,0 +1,84 @@ +//! Implements the `DisableKeys` feature (ID `0x4521`) that disables a fixed set +//! of lock / system keys. +//! +//! For disabling arbitrary keys by HID usage, see +//! [`DisableKeysByUsage`](super::disable_keys_by_usage) (`0x4522`). + +use std::sync::Arc; + +use crate::{ + channel::HidppChannel, + feature::{CreatableFeature, Feature, FeatureEndpoint}, + protocol::v20::Hidpp20Error, +}; + +bitflags::bitflags! { + /// The set of keys a [`DisableKeysFeature`] device can disable. + /// + /// Used both for the device's capabilities and for the currently disabled + /// keys. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct DisableableKeys: u8 { + /// The Caps Lock key. + const CAPS_LOCK = 1 << 0; + /// The Num Lock key. + const NUM_LOCK = 1 << 1; + /// The Scroll Lock key. + const SCROLL_LOCK = 1 << 2; + /// The Insert key. + const INSERT = 1 << 3; + /// The Windows / Start key. + const WINDOWS = 1 << 4; + } +} + +/// Implements the `DisableKeys` / `0x4521` feature. +#[derive(Clone)] +pub struct DisableKeysFeature { + /// The endpoint this feature talks to. + endpoint: FeatureEndpoint, +} + +impl CreatableFeature for DisableKeysFeature { + const ID: u16 = 0x4521; + const STARTING_VERSION: u8 = 0; + + fn new(chan: Arc, device_index: u8, feature_index: u8) -> Self { + Self { + endpoint: FeatureEndpoint::new(chan, device_index, feature_index), + } + } +} + +impl Feature for DisableKeysFeature {} + +impl DisableKeysFeature { + /// Retrieves the set of keys the device allows software to disable. + pub async fn get_capabilities(&self) -> Result { + let payload = self.endpoint.call(0, [0; 3]).await?.extend_payload(); + Ok(DisableableKeys::from_bits_retain(payload[0])) + } + + /// Retrieves the set of keys currently disabled. + pub async fn get_disabled_keys(&self) -> Result { + let payload = self.endpoint.call(1, [0; 3]).await?.extend_payload(); + Ok(DisableableKeys::from_bits_retain(payload[0])) + } + + /// Replaces the set of disabled keys and returns the device's echo. + /// + /// This replaces the whole set, so passing [`DisableableKeys::empty`] + /// re-enables every key. The device rejects keys it cannot disable. + pub async fn set_disabled_keys( + &self, + keys: DisableableKeys, + ) -> Result { + let payload = self + .endpoint + .call(2, [keys.bits(), 0, 0]) + .await? + .extend_payload(); + Ok(DisableableKeys::from_bits_retain(payload[0])) + } +} diff --git a/crates/openlogi-hidpp/src/feature/disable_keys_by_usage/mod.rs b/crates/openlogi-hidpp/src/feature/disable_keys_by_usage/mod.rs new file mode 100644 index 00000000..f640dc91 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/disable_keys_by_usage/mod.rs @@ -0,0 +1,128 @@ +//! Implements the `DisableKeysByUsage` feature (ID `0x4522`) that disables or +//! enables arbitrary keyboard keys by HID usage. +//! +//! Unlike [`DisableKeys`](super::disable_keys) (`0x4521`), which toggles a fixed +//! set of lock keys, this feature operates on any 8-bit keyboard HID usage. + +use std::sync::Arc; + +use crate::{ + channel::HidppChannel, + feature::{CreatableFeature, Feature, FeatureEndpoint}, + protocol::v20::Hidpp20Error, +}; + +/// Number of usage bytes carried by one long-report request. +const USAGES_PER_PACKET: usize = 16; + +/// Implements the `DisableKeysByUsage` / `0x4522` feature. +#[derive(Clone)] +pub struct DisableKeysByUsageFeature { + /// The endpoint this feature talks to. + endpoint: FeatureEndpoint, +} + +impl CreatableFeature for DisableKeysByUsageFeature { + const ID: u16 = 0x4522; + const STARTING_VERSION: u8 = 0; + + fn new(chan: Arc, device_index: u8, feature_index: u8) -> Self { + Self { + endpoint: FeatureEndpoint::new(chan, device_index, feature_index), + } + } +} + +impl Feature for DisableKeysByUsageFeature {} + +impl DisableKeysByUsageFeature { + /// Retrieves the maximum number of usages that can be disabled at once. + pub async fn get_capabilities(&self) -> Result { + Ok(self.endpoint.call(0, [0; 3]).await?.extend_payload()[0]) + } + + /// Disables the given 8-bit keyboard HID `usages`. + /// + /// Disabling is **cumulative**: these usages are added to the disabled set + /// rather than replacing it. A usage of `0` is the list terminator and cannot + /// itself be disabled. More usages than fit in one request are sent over + /// several requests, which the device still accumulates. + pub async fn disable_keys(&self, usages: &[u8]) -> Result<(), Hidpp20Error> { + for packet in usage_packets(usages) { + self.endpoint.call_long(1, packet).await?; + } + Ok(()) + } + + /// Enables (removes from the disabled set) the given 8-bit keyboard HID + /// `usages`. + /// + /// Enabling a usage that is not disabled is a no-op. A usage of `0` + /// terminates the list. + pub async fn enable_keys(&self, usages: &[u8]) -> Result<(), Hidpp20Error> { + for packet in usage_packets(usages) { + self.endpoint.call_long(2, packet).await?; + } + Ok(()) + } + + /// Re-enables every keyboard key. + pub async fn enable_all_keys(&self) -> Result<(), Hidpp20Error> { + self.endpoint.call(3, [0; 3]).await?; + Ok(()) + } +} + +/// Splits `usages` into long-report packets of up to [`USAGES_PER_PACKET`] +/// bytes. +/// +/// A packet shorter than the full width is zero-padded, which doubles as the +/// `0x00` end-of-list terminator; a packet filling every byte carries no +/// terminator, as the device treats a full packet as exactly that many usages. +fn usage_packets(usages: &[u8]) -> Vec<[u8; USAGES_PER_PACKET]> { + usages + .chunks(USAGES_PER_PACKET) + .map(|chunk| { + let mut packet = [0u8; USAGES_PER_PACKET]; + packet[..chunk.len()].copy_from_slice(chunk); + packet + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::usage_packets; + + #[test] + fn empty_usage_list_sends_no_packets() { + assert!(usage_packets(&[]).is_empty()); + } + + #[test] + fn short_list_is_zero_terminated() { + let packets = usage_packets(&[0x39, 0x3a, 0x3b]); + assert_eq!(packets.len(), 1); + assert_eq!(packets[0][..3], [0x39, 0x3a, 0x3b]); + // The remaining bytes are the 0x00 terminator / padding. + assert!(packets[0][3..].iter().all(|&b| b == 0)); + } + + #[test] + fn full_packet_has_no_terminator() { + let usages: Vec = (1..=16).collect(); + let packets = usage_packets(&usages); + assert_eq!(packets.len(), 1); + assert_eq!(packets[0], usages.as_slice()); + } + + #[test] + fn overflow_splits_into_cumulative_packets() { + let usages: Vec = (1..=18).collect(); + let packets = usage_packets(&usages); + assert_eq!(packets.len(), 2); + assert_eq!(packets[0], (1..=16).collect::>().as_slice()); + assert_eq!(packets[1][..2], [17, 18]); + assert!(packets[1][2..].iter().all(|&b| b == 0)); + } +} diff --git a/crates/openlogi-hidpp/src/feature/dual_platform/mod.rs b/crates/openlogi-hidpp/src/feature/dual_platform/mod.rs new file mode 100644 index 00000000..2fa6dd02 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/dual_platform/mod.rs @@ -0,0 +1,138 @@ +//! Implements the `DualPlatform` feature (ID `0x4530`) that selects which of two +//! OS platforms a device sends HID codes for. +//! +//! This is the predecessor of [`MultiPlatform`](super::multi_platform) +//! (`0x4531`); a device exposing `0x4531` should be driven through that feature +//! instead. + +use std::sync::Arc; + +use num_enum::{IntoPrimitive, TryFromPrimitive}; + +use crate::{ + channel::{HidppChannel, MessageListenerGuard}, + event::EventEmitter, + feature::{CreatableFeature, EmittingFeature, Feature, FeatureEndpoint, event_payload}, + protocol::v20::Hidpp20Error, +}; + +/// The platform a [`DualPlatformFeature`] device is configured for. +/// +/// The selection is persistent and chosen by the user during pairing or by short +/// pressing an OS-selection button; there is no default. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum DualPlatformSelection { + /// iOS or macOS. + IosOrMac = 0, + /// Android or Windows. + AndroidOrWindows = 1, +} + +/// An event emitted by [`DualPlatformFeature`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub enum DualPlatformEvent { + /// The user changed the platform via an OS-selection button. + PlatformChanged(DualPlatformSelection), +} + +/// Implements the `DualPlatform` / `0x4530` feature. +pub struct DualPlatformFeature { + /// The endpoint this feature talks to. + endpoint: FeatureEndpoint, + + /// The emitter used to publish decoded events. + emitter: Arc>, + + /// Removes the message listener when the feature is dropped. + _msg_listener: MessageListenerGuard, +} + +impl CreatableFeature for DualPlatformFeature { + const ID: u16 = 0x4530; + const STARTING_VERSION: u8 = 0; + + fn new(chan: Arc, device_index: u8, feature_index: u8) -> Self { + let emitter = Arc::new(EventEmitter::new()); + + let listener = chan.add_msg_listener_guarded({ + let emitter = Arc::clone(&emitter); + + move |raw, matched| { + let Some((func, payload)) = + event_payload(raw, matched, device_index, feature_index) + else { + return; + }; + // PlatformChange is the only event and carries sub-id 0. + if func.to_lo() != 0 { + return; + } + if let Ok(platform) = DualPlatformSelection::try_from(payload[0]) { + emitter.emit(DualPlatformEvent::PlatformChanged(platform)); + } + } + }); + + Self { + endpoint: FeatureEndpoint::new(chan, device_index, feature_index), + emitter, + _msg_listener: listener, + } + } +} + +impl Feature for DualPlatformFeature {} + +impl EmittingFeature for DualPlatformFeature { + fn listen(&self) -> async_channel::Receiver { + self.emitter.create_receiver() + } +} + +impl DualPlatformFeature { + /// Retrieves the current platform setting. + pub async fn get_platform(&self) -> Result { + // `getPlatform` is function 1 in this feature, not the usual 0. + let payload = self.endpoint.call(1, [0; 3]).await?.extend_payload(); + DualPlatformSelection::try_from(payload[0]).map_err(|_| Hidpp20Error::UnsupportedResponse) + } + + /// Sets the platform and returns the device's echo of the new setting. + /// + /// This does not trigger a [`DualPlatformEvent::PlatformChanged`] event. + pub async fn set_platform( + &self, + platform: DualPlatformSelection, + ) -> Result { + let payload = self + .endpoint + .call(2, [platform.into(), 0, 0]) + .await? + .extend_payload(); + DualPlatformSelection::try_from(payload[0]).map_err(|_| Hidpp20Error::UnsupportedResponse) + } +} + +#[cfg(test)] +mod tests { + use super::DualPlatformSelection; + + #[test] + fn maps_platform_wire_values() { + assert_eq!( + DualPlatformSelection::try_from(0).unwrap(), + DualPlatformSelection::IosOrMac + ); + assert_eq!( + DualPlatformSelection::try_from(1).unwrap(), + DualPlatformSelection::AndroidOrWindows + ); + assert!(DualPlatformSelection::try_from(2).is_err()); + assert_eq!(u8::from(DualPlatformSelection::AndroidOrWindows), 1); + } +} diff --git a/crates/openlogi-hidpp/src/feature/fn_inversion/mod.rs b/crates/openlogi-hidpp/src/feature/fn_inversion/mod.rs index 196a11ab..64bb6656 100644 --- a/crates/openlogi-hidpp/src/feature/fn_inversion/mod.rs +++ b/crates/openlogi-hidpp/src/feature/fn_inversion/mod.rs @@ -117,9 +117,76 @@ impl FnInversionInfo { } } +/// Global function-key inversion state, common to all keys. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct GlobalFnInversion { + /// Current inversion state. + pub state: FnInversionState, + /// Default inversion state. + pub default_state: FnInversionState, +} + +impl GlobalFnInversion { + fn from_payload(payload: [u8; 16]) -> Result { + Ok(Self { + state: FnInversionState::try_from(payload[0]) + .map_err(|_| Hidpp20Error::UnsupportedResponse)?, + default_state: FnInversionState::try_from(payload[1]) + .map_err(|_| Hidpp20Error::UnsupportedResponse)?, + }) + } +} + +/// Implements `FnInversionWithDefaultState` / `0x40a2`. +/// +/// This is the single-host predecessor of +/// [`FnInversionMultiHostFeature`] (`0x40a3`): the inversion state is global +/// rather than per host slot. +#[derive(Clone)] +pub struct FnInversionWithDefaultStateFeature { + /// The endpoint this feature talks to. + endpoint: FeatureEndpoint, +} + +impl CreatableFeature for FnInversionWithDefaultStateFeature { + const ID: u16 = 0x40a2; + const STARTING_VERSION: u8 = 0; + + fn new(chan: Arc, device_index: u8, feature_index: u8) -> Self { + Self { + endpoint: FeatureEndpoint::new(chan, device_index, feature_index), + } + } +} + +impl Feature for FnInversionWithDefaultStateFeature {} + +impl FnInversionWithDefaultStateFeature { + /// Retrieves the global Fn inversion state and its default. + pub async fn get_global_fn_inversion(&self) -> Result { + let payload = self.endpoint.call(0, [0; 3]).await?.extend_payload(); + GlobalFnInversion::from_payload(payload) + } + + /// Sets the global Fn inversion state and returns the resulting state. + pub async fn set_global_fn_inversion( + &self, + state: FnInversionState, + ) -> Result { + let payload = self + .endpoint + .call(1, [u8::from(state), 0, 0]) + .await? + .extend_payload(); + GlobalFnInversion::from_payload(payload) + } +} + #[cfg(test)] mod tests { - use super::{FnInversionInfo, FnInversionState}; + use super::{FnInversionInfo, FnInversionState, GlobalFnInversion}; use crate::feature::hosts_info::HostIndex; #[test] @@ -136,4 +203,16 @@ mod tests { assert_eq!(info.state, FnInversionState::On); assert_eq!(info.default_state, FnInversionState::Off); } + + #[test] + fn parses_global_fn_inversion() { + let mut payload = [0; 16]; + payload[0] = 1; + payload[1] = 0; + + let global = GlobalFnInversion::from_payload(payload).unwrap(); + + assert_eq!(global.state, FnInversionState::On); + assert_eq!(global.default_state, FnInversionState::Off); + } } diff --git a/crates/openlogi-hidpp/src/feature/mod.rs b/crates/openlogi-hidpp/src/feature/mod.rs index b6eb323b..fd96d5bd 100755 --- a/crates/openlogi-hidpp/src/feature/mod.rs +++ b/crates/openlogi-hidpp/src/feature/mod.rs @@ -10,9 +10,13 @@ use crate::{ pub mod adjustable_dpi; pub mod brightness_control; +pub mod change_host; pub mod device_friendly_name; pub mod device_information; pub mod device_type_and_name; +pub mod disable_keys; +pub mod disable_keys_by_usage; +pub mod dual_platform; pub mod extended_dpi; pub mod extended_report_rate; pub mod feature_set; @@ -126,6 +130,19 @@ impl FeatureEndpoint { .send_v20(v20::Message::Long(self.header(function), args)) .await } + + /// Sends `function` with a 3-byte short-report payload without waiting for a + /// response. + /// + /// For functions the device answers normally use [`Self::call`]; this is for + /// the rare function whose side effect (e.g. a host switch that resets the + /// device) prevents a response from ever arriving. + pub(crate) async fn notify(&self, function: u8, args: [u8; 3]) -> Result<(), Hidpp20Error> { + self.chan + .send_and_forget(v20::Message::Short(self.header(function), args).into()) + .await?; + Ok(()) + } } /// Shared prelude for a feature's event listener. diff --git a/crates/openlogi-hidpp/src/feature/registry.rs b/crates/openlogi-hidpp/src/feature/registry.rs index 92bf2d68..bb8935d3 100755 --- a/crates/openlogi-hidpp/src/feature/registry.rs +++ b/crates/openlogi-hidpp/src/feature/registry.rs @@ -11,19 +11,34 @@ use super::Feature; use crate::{ channel::HidppChannel, feature::{ - CreatableFeature, adjustable_dpi::AdjustableDpiFeature, + CreatableFeature, + adjustable_dpi::AdjustableDpiFeature, brightness_control::BrightnessControlFeature, + change_host::ChangeHostFeature, device_friendly_name::DeviceFriendlyNameFeature, device_information::DeviceInformationFeature, - device_type_and_name::DeviceTypeAndNameFeature, extended_dpi::ExtendedDpiFeature, - extended_report_rate::ExtendedReportRateFeature, feature_set::FeatureSetFeature, - fn_inversion::FnInversionMultiHostFeature, hires_wheel::HiResWheelFeature, - hosts_info::HostsInfoFeature, mode_status::ModeStatusFeature, - mouse_pointer::MousePointerFeature, multi_platform::MultiPlatformFeature, - report_rate::ReportRateFeature, reprog_controls::ReprogControlsFeature, root::RootFeature, - sidetone::SidetoneFeature, smartshift::SmartShiftFeature, - smartshift_enhanced::SmartShiftEnhancedFeature, thumbwheel::ThumbwheelFeature, - unified_battery::UnifiedBatteryFeature, vertical_scrolling::VerticalScrollingFeature, + device_type_and_name::DeviceTypeAndNameFeature, + disable_keys::DisableKeysFeature, + disable_keys_by_usage::DisableKeysByUsageFeature, + dual_platform::DualPlatformFeature, + extended_dpi::ExtendedDpiFeature, + extended_report_rate::ExtendedReportRateFeature, + feature_set::FeatureSetFeature, + fn_inversion::{FnInversionMultiHostFeature, FnInversionWithDefaultStateFeature}, + hires_wheel::HiResWheelFeature, + hosts_info::HostsInfoFeature, + mode_status::ModeStatusFeature, + mouse_pointer::MousePointerFeature, + multi_platform::MultiPlatformFeature, + report_rate::ReportRateFeature, + reprog_controls::ReprogControlsFeature, + root::RootFeature, + sidetone::SidetoneFeature, + smartshift::SmartShiftFeature, + smartshift_enhanced::SmartShiftEnhancedFeature, + thumbwheel::ThumbwheelFeature, + unified_battery::UnifiedBatteryFeature, + vertical_scrolling::VerticalScrollingFeature, wireless_device_status::WirelessDeviceStatusFeature, }, }; @@ -136,7 +151,7 @@ static KNOWN_FEATURES: LazyLock> = LazyLock::new(|| { 0x1802 "DeviceReset", 0x1805 "OobState", 0x1806 "ConfigDeviceProps", - 0x1814 "ChangeHost", + 0x1814 "ChangeHost" => ChangeHostFeature, 0x1815 "HostsInfo" => HostsInfoFeature, 0x1981 "Backlight1", 0x1982 "Backlight2", @@ -177,15 +192,15 @@ static KNOWN_FEATURES: LazyLock> = LazyLock::new(|| { 0x2251 "WheelStats", 0x2400 "HybridTrackingEngine", 0x40a0 "FnInversion", - 0x40a2 "FnInversionWithDefaultState", + 0x40a2 "FnInversionWithDefaultState" => FnInversionWithDefaultStateFeature, 0x40a3 "FnInversionForMultiHostDevices" => FnInversionMultiHostFeature, 0x4100 "Encryption", 0x4220 "LockKeyState", 0x4301 "SolarKeyboardDashboard", 0x4520 "KeyboardLayout", - 0x4521 "DisableKeys", - 0x4522 "DisableKeysByUsage", - 0x4530 "DualPlatform", + 0x4521 "DisableKeys" => DisableKeysFeature, + 0x4522 "DisableKeysByUsage" => DisableKeysByUsageFeature, + 0x4530 "DualPlatform" => DualPlatformFeature, 0x4531 "MultiPlatform" => MultiPlatformFeature, 0x4540 "KeyboardInternationalLayouts", 0x4600 "Crown", From cc6d9072db061f098e90b16003a641d789b333eb Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sat, 27 Jun 2026 18:38:30 +0800 Subject: [PATCH 06/21] feat(hidpp): add backlight and illumination wrappers --- .../src/feature/backlight/mod.rs | 424 ++++++++++++++++++ .../src/feature/illumination/event.rs | 47 ++ .../src/feature/illumination/mod.rs | 239 ++++++++++ .../src/feature/illumination/tests.rs | 186 ++++++++ .../src/feature/illumination/types.rs | 210 +++++++++ crates/openlogi-hidpp/src/feature/mod.rs | 2 + crates/openlogi-hidpp/src/feature/registry.rs | 6 +- 7 files changed, 1112 insertions(+), 2 deletions(-) create mode 100644 crates/openlogi-hidpp/src/feature/backlight/mod.rs create mode 100644 crates/openlogi-hidpp/src/feature/illumination/event.rs create mode 100644 crates/openlogi-hidpp/src/feature/illumination/mod.rs create mode 100644 crates/openlogi-hidpp/src/feature/illumination/tests.rs create mode 100644 crates/openlogi-hidpp/src/feature/illumination/types.rs diff --git a/crates/openlogi-hidpp/src/feature/backlight/mod.rs b/crates/openlogi-hidpp/src/feature/backlight/mod.rs new file mode 100644 index 00000000..0c93e85b --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/backlight/mod.rs @@ -0,0 +1,424 @@ +//! Implements the `Backlight` feature (ID `0x1982`, version 3) for keyboards +//! with an adjustable backlight. +//! +//! The feature enables/disables the backlight, selects a backlight mode +//! (automatic via the ambient-light sensor, temporary manual, or permanent +//! manual), chooses a predefined effect, and configures the manual level and +//! fade-out durations. +//! +//! All multi-byte fields in this feature are little-endian. + +use std::sync::Arc; + +use num_enum::{IntoPrimitive, TryFromPrimitive}; + +use crate::{ + channel::{HidppChannel, MessageListenerGuard}, + event::EventEmitter, + feature::{CreatableFeature, EmittingFeature, Feature, FeatureEndpoint, event_payload}, + protocol::v20::Hidpp20Error, +}; + +/// The "do not change" sentinel for the backlight effect in `setBacklightConfig`. +const EFFECT_UNCHANGED: u8 = 0xff; + +/// Bit offset of the 2-bit backlight mode inside the options field. +const MODE_SHIFT: u16 = 3; +/// Mask of the backlight-mode bits inside the options field. +const MODE_MASK: u16 = 0b11 << MODE_SHIFT; + +bitflags::bitflags! { + /// Backlight options and capability bits from `getBacklightConfig`. + /// + /// The low bits are the currently enabled options; the high bits report + /// which options and modes the device supports. The 2-bit backlight mode + /// occupies bits 3..=4 and is exposed separately as [`BacklightMode`]. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct BacklightOptions: u16 { + /// The "wow" power-on effect is enabled. + const WOW = 1 << 0; + /// The "crown" touch effect is enabled. + const CROWN = 1 << 1; + /// Power-save (disable backlight at critical battery) is enabled. + const PWR_SAVE = 1 << 2; + /// The device supports the "wow" effect. + const WOW_SUPPORTED = 1 << 8; + /// The device supports the "crown" effect. + const CROWN_SUPPORTED = 1 << 9; + /// The device supports power-save. + const PWR_SAVE_SUPPORTED = 1 << 10; + /// The device supports automatic (ALS) mode. + const AUTO_MODE_SUPPORTED = 1 << 11; + /// The device supports temporary-manual mode. + const TEMP_MANUAL_SUPPORTED = 1 << 12; + /// The device supports permanent-manual mode. + const PERM_MANUAL_SUPPORTED = 1 << 13; + } +} + +bitflags::bitflags! { + /// The set of predefined effects a device supports, from + /// `getBacklightConfig`. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct BacklightEffectList: u16 { + /// The "static" effect. + const STATIC = 1 << 0; + /// The "none" effect. + const NONE = 1 << 1; + /// The "breathing light" effect. + const BREATHING = 1 << 2; + /// The "contrast" effect. + const CONTRAST = 1 << 3; + /// The "reaction" effect. + const REACTION = 1 << 4; + /// The "random" effect. + const RANDOM = 1 << 5; + /// The "waves" effect. + const WAVES = 1 << 6; + } +} + +/// The backlight level-adjustment mode. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum BacklightMode { + /// No mode selected. + None = 0, + /// Automatic mode: level follows the ambient-light sensor. + Automatic = 1, + /// Temporary manual mode: level adjusted via the backlight keys. This mode + /// cannot be set by software. + TemporaryManual = 2, + /// Permanent manual mode: level adjusted by software. + PermanentManual = 3, +} + +/// A predefined backlight effect. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum BacklightEffect { + /// The "static" effect (default). + Static = 0, + /// The "none" effect. + None = 1, + /// The "breathing light" effect. + Breathing = 2, + /// The "contrast" effect. + Contrast = 3, + /// The "reaction" effect. + Reaction = 4, + /// The "random" effect. + Random = 5, + /// The "waves" effect. + Waves = 6, +} + +/// The current backlight status from `getBacklightInfo`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum BacklightStatus { + /// Disabled by software. + DisabledBySoftware = 0, + /// Disabled because the battery is critically low. + DisabledByCriticalBattery = 1, + /// Automatic (ALS) mode. + AlsAutomatic = 2, + /// Automatic mode, saturated — the backlight is off. + AlsSaturated = 3, + /// Temporary manual mode (set by hardware). + TemporaryManual = 4, + /// Permanent manual mode (set by software). + PermanentManual = 5, +} + +/// The backlight configuration from [`BacklightFeature::get_backlight_config`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct BacklightConfig { + /// Whether the backlight system is enabled. + pub enabled: bool, + /// Enabled options and supported capabilities. + pub options: BacklightOptions, + /// Currently selected backlight mode. + pub mode: BacklightMode, + /// Effects the device supports. + pub effect_list: BacklightEffectList, + /// Current manual brightness level (`0` = off, up to `7`). + pub current_level: u8, + /// Fade-out duration after the last keystroke with no proximity, in 5-second + /// units (`1..=0x05a0`). + pub duration_hands_out: u16, + /// Fade-out duration while hands remain in the detection zone, in 5-second + /// units. + pub duration_hands_in: u16, + /// Fade-out duration while externally powered, in 5-second units. + pub duration_powered: u16, +} + +/// Backlight configuration to write with +/// [`BacklightFeature::set_backlight_config`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub struct SetBacklightConfig { + /// Whether to enable the backlight system. + pub enabled: bool, + /// Options to enable. Only [`BacklightOptions::WOW`], + /// [`BacklightOptions::CROWN`] and [`BacklightOptions::PWR_SAVE`] are + /// writable; the device discards unsupported options. + pub options: BacklightOptions, + /// Mode to select. [`BacklightMode::TemporaryManual`] cannot be set by + /// software. + pub mode: BacklightMode, + /// Effect to apply, or `None` to leave the current effect unchanged. + pub effect: Option, + /// Manual brightness level (`0` = off, up to `7`). + pub current_level: u8, + /// Fade-out duration after the last keystroke with no proximity, in 5-second + /// units. + pub duration_hands_out: u16, + /// Fade-out duration while hands remain in the detection zone, in 5-second + /// units. + pub duration_hands_in: u16, + /// Fade-out duration while externally powered, in 5-second units. + pub duration_powered: u16, +} + +/// Backlight information from [`BacklightFeature::get_backlight_info`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct BacklightInfo { + /// Number of user-selectable intensity levels (`0..nb_levels`). + pub nb_levels: u8, + /// Current intensity level. + pub current_level: u8, + /// Current backlight status. + pub status: BacklightStatus, + /// Currently applied effect. + pub effect: BacklightEffect, + /// Out-of-box fade-out duration with hands out, in 5-second units. + pub oob_duration_hands_out: u16, + /// Out-of-box fade-out duration with hands in, in 5-second units. + pub oob_duration_hands_in: u16, + /// Out-of-box fade-out duration while externally powered, in 5-second units. + pub oob_duration_powered: u16, +} + +/// An event emitted by [`BacklightFeature`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub enum BacklightEvent { + /// The user changed the backlight; carries the latest backlight info. + InfoChanged(BacklightInfoUpdate), +} + +/// Payload of [`BacklightEvent::InfoChanged`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct BacklightInfoUpdate { + /// Number of user-selectable intensity levels. + pub nb_levels: u8, + /// Current intensity level. + pub current_level: u8, + /// Current backlight status. + pub status: BacklightStatus, + /// Currently applied effect. + pub effect: BacklightEffect, +} + +/// Implements the `Backlight` / `0x1982` feature (version 3). +pub struct BacklightFeature { + /// The endpoint this feature talks to. + endpoint: FeatureEndpoint, + + /// The emitter used to publish decoded events. + emitter: Arc>, + + /// Removes the message listener when the feature is dropped. + _msg_listener: MessageListenerGuard, +} + +impl CreatableFeature for BacklightFeature { + const ID: u16 = 0x1982; + const STARTING_VERSION: u8 = 3; + + fn new(chan: Arc, device_index: u8, feature_index: u8) -> Self { + let emitter = Arc::new(EventEmitter::new()); + + let listener = chan.add_msg_listener_guarded({ + let emitter = Arc::clone(&emitter); + + move |raw, matched| { + let Some((func, payload)) = + event_payload(raw, matched, device_index, feature_index) + else { + return; + }; + // backlightInfoEvent is the only event and carries sub-id 0. + if func.to_lo() != 0 { + return; + } + if let Ok(update) = BacklightInfoUpdate::from_payload(&payload) { + emitter.emit(BacklightEvent::InfoChanged(update)); + } + } + }); + + Self { + endpoint: FeatureEndpoint::new(chan, device_index, feature_index), + emitter, + _msg_listener: listener, + } + } +} + +impl Feature for BacklightFeature {} + +impl EmittingFeature for BacklightFeature { + fn listen(&self) -> async_channel::Receiver { + self.emitter.create_receiver() + } +} + +impl BacklightFeature { + /// Retrieves the current backlight configuration. + pub async fn get_backlight_config(&self) -> Result { + let payload = self.endpoint.call(0, [0; 3]).await?.extend_payload(); + let raw_options = u16::from_le_bytes([payload[1], payload[2]]); + Ok(BacklightConfig { + enabled: payload[0] & 1 != 0, + options: BacklightOptions::from_bits_retain(raw_options & !MODE_MASK), + mode: BacklightMode::try_from(((raw_options & MODE_MASK) >> MODE_SHIFT) as u8) + .map_err(|_| Hidpp20Error::UnsupportedResponse)?, + effect_list: BacklightEffectList::from_bits_retain(u16::from_le_bytes([ + payload[3], payload[4], + ])), + current_level: payload[5], + duration_hands_out: u16::from_le_bytes([payload[6], payload[7]]), + duration_hands_in: u16::from_le_bytes([payload[8], payload[9]]), + duration_powered: u16::from_le_bytes([payload[10], payload[11]]), + }) + } + + /// Writes the backlight configuration persistently (to non-volatile memory). + pub async fn set_backlight_config( + &self, + config: SetBacklightConfig, + ) -> Result<(), Hidpp20Error> { + // The request options byte packs the writable option flags (low 3 bits) + // and the 2-bit mode (bits 3..=4). + let options_byte = (config.options.bits() + & (BacklightOptions::WOW | BacklightOptions::CROWN | BacklightOptions::PWR_SAVE).bits()) + as u8 + | (u8::from(config.mode) << MODE_SHIFT); + let [out_lo, out_hi] = config.duration_hands_out.to_le_bytes(); + let [in_lo, in_hi] = config.duration_hands_in.to_le_bytes(); + let [pwr_lo, pwr_hi] = config.duration_powered.to_le_bytes(); + let mut args = [0; 16]; + args[..10].copy_from_slice(&[ + u8::from(config.enabled), + options_byte, + config.effect.map_or(EFFECT_UNCHANGED, u8::from), + config.current_level, + out_lo, + out_hi, + in_lo, + in_hi, + pwr_lo, + pwr_hi, + ]); + self.endpoint.call_long(1, args).await?; + Ok(()) + } + + /// Retrieves general backlight information and out-of-box durations. + pub async fn get_backlight_info(&self) -> Result { + let payload = self.endpoint.call(2, [0; 3]).await?.extend_payload(); + Ok(BacklightInfo { + nb_levels: payload[0], + current_level: payload[1], + status: BacklightStatus::try_from(payload[2]) + .map_err(|_| Hidpp20Error::UnsupportedResponse)?, + effect: BacklightEffect::try_from(payload[3]) + .map_err(|_| Hidpp20Error::UnsupportedResponse)?, + oob_duration_hands_out: u16::from_le_bytes([payload[4], payload[5]]), + oob_duration_hands_in: u16::from_le_bytes([payload[6], payload[7]]), + oob_duration_powered: u16::from_le_bytes([payload[8], payload[9]]), + }) + } + + /// Applies a backlight effect temporarily (stored in RAM, not persisted). + pub async fn set_backlight_effect(&self, effect: BacklightEffect) -> Result<(), Hidpp20Error> { + self.endpoint.call(3, [effect.into(), 0, 0]).await?; + Ok(()) + } +} + +impl BacklightInfoUpdate { + fn from_payload(payload: &[u8; 16]) -> Result { + Ok(Self { + nb_levels: payload[0], + current_level: payload[1], + status: BacklightStatus::try_from(payload[2]) + .map_err(|_| Hidpp20Error::UnsupportedResponse)?, + effect: BacklightEffect::try_from(payload[3]) + .map_err(|_| Hidpp20Error::UnsupportedResponse)?, + }) + } +} + +#[cfg(test)] +mod tests { + use super::{ + BacklightEffect, BacklightInfoUpdate, BacklightMode, BacklightOptions, BacklightStatus, + }; + + #[test] + fn decodes_options_and_mode_split() { + // WOW enabled + permanent-manual mode (0b11 << 3) + auto-mode supported. + let raw = BacklightOptions::WOW.bits() + | (u16::from(u8::from(BacklightMode::PermanentManual)) << 3) + | BacklightOptions::AUTO_MODE_SUPPORTED.bits(); + let mode = BacklightMode::try_from(((raw & (0b11 << 3)) >> 3) as u8).unwrap(); + let options = BacklightOptions::from_bits_retain(raw & !(0b11 << 3)); + + assert_eq!(mode, BacklightMode::PermanentManual); + assert!(options.contains(BacklightOptions::WOW)); + assert!(options.contains(BacklightOptions::AUTO_MODE_SUPPORTED)); + // The mode bits must not leak into the options flags. + assert!(!options.contains(BacklightOptions::PWR_SAVE)); + assert!(!options.contains(BacklightOptions::CROWN)); + } + + #[test] + fn decodes_backlight_info_event() { + let mut payload = [0; 16]; + payload[0] = 8; + payload[1] = 5; + payload[2] = 5; + payload[3] = 2; + + let update = BacklightInfoUpdate::from_payload(&payload).unwrap(); + assert_eq!(update.nb_levels, 8); + assert_eq!(update.current_level, 5); + assert_eq!(update.status, BacklightStatus::PermanentManual); + assert_eq!(update.effect, BacklightEffect::Breathing); + } + + #[test] + fn maps_do_not_change_effect_sentinel() { + assert_eq!(None::.map_or(0xff, u8::from), 0xff); + assert_eq!(Some(BacklightEffect::Waves).map_or(0xff, u8::from), 6); + } +} diff --git a/crates/openlogi-hidpp/src/feature/illumination/event.rs b/crates/openlogi-hidpp/src/feature/illumination/event.rs new file mode 100644 index 00000000..39fd0dbf --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/illumination/event.rs @@ -0,0 +1,47 @@ +//! Events emitted by the `Illumination` feature (`0x1990`). + +use super::types::{BrightnessClampedSource, IlluminationState, be16, illumination_state}; + +/// An event emitted by [`IlluminationFeature`](super::IlluminationFeature). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub enum IlluminationEvent { + /// The on/off illumination state changed. + IlluminationChanged(IlluminationState), + /// The brightness changed, in Lumens. + BrightnessChanged(u16), + /// The color temperature changed, in Kelvin. + ColorTemperatureChanged(u16), + /// The effective maximum brightness changed, in Lumens (`0` = no effective + /// maximum). Requires feature version 1. + BrightnessEffectiveMaxChanged(u16), + /// A brightness request was clamped to the effective maximum. Requires + /// feature version 1. + BrightnessClamped { + /// What triggered the clamp. + source: BrightnessClampedSource, + /// The clamped brightness, equal to the current effective maximum, in + /// Lumens. + brightness: u16, + }, +} + +/// Decodes an unsolicited `0x1990` event payload by its sub-id. +pub(super) fn decode_event(sub_id: u8, payload: &[u8; 16]) -> Option { + match sub_id { + 0 => Some(IlluminationEvent::IlluminationChanged( + illumination_state(payload[0]).ok()?, + )), + 1 => Some(IlluminationEvent::BrightnessChanged(be16(payload, 0))), + 2 => Some(IlluminationEvent::ColorTemperatureChanged(be16(payload, 0))), + 3 => Some(IlluminationEvent::BrightnessEffectiveMaxChanged(be16( + payload, 0, + ))), + 4 => Some(IlluminationEvent::BrightnessClamped { + source: BrightnessClampedSource::try_from(payload[0]).ok()?, + brightness: be16(payload, 1), + }), + _ => None, + } +} diff --git a/crates/openlogi-hidpp/src/feature/illumination/mod.rs b/crates/openlogi-hidpp/src/feature/illumination/mod.rs new file mode 100644 index 00000000..3c55f27d --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/illumination/mod.rs @@ -0,0 +1,239 @@ +//! Implements the `Illumination` feature (ID `0x1990`) for devices with a +//! controllable illumination light (brightness in Lumens and color temperature +//! in Kelvin). +//! +//! Brightness and color temperature share the same control shape — an info +//! query, a value get/set, and a level-list get/set — exposed as two parallel +//! sets of methods. Feature version 1 adds the effective-maximum brightness +//! query and its events. +//! +//! All multi-byte fields in this feature are big-endian. + +pub mod event; +pub mod types; + +#[cfg(test)] +mod tests; + +use std::sync::Arc; + +pub use event::IlluminationEvent; +pub use types::{ + BrightnessClampedSource, ControlCapabilities, ControlInfo, IlluminationState, LevelConfig, + SetLevels, +}; + +use self::types::{be16, illumination_state}; +use crate::{ + channel::{HidppChannel, MessageListenerGuard}, + event::EventEmitter, + feature::{CreatableFeature, EmittingFeature, Feature, FeatureEndpoint, event_payload}, + protocol::v20::Hidpp20Error, +}; + +// Function ids. Color-temperature functions mirror the brightness ones offset by +// five, but they are spelled out for clarity. +const FN_GET_ILLUMINATION: u8 = 0; +const FN_SET_ILLUMINATION: u8 = 1; +const FN_GET_BRIGHTNESS_INFO: u8 = 2; +const FN_GET_BRIGHTNESS: u8 = 3; +const FN_SET_BRIGHTNESS: u8 = 4; +const FN_GET_BRIGHTNESS_LEVELS: u8 = 5; +const FN_SET_BRIGHTNESS_LEVELS: u8 = 6; +const FN_GET_COLOR_TEMPERATURE_INFO: u8 = 7; +const FN_GET_COLOR_TEMPERATURE: u8 = 8; +const FN_SET_COLOR_TEMPERATURE: u8 = 9; +const FN_GET_COLOR_TEMPERATURE_LEVELS: u8 = 10; +const FN_SET_COLOR_TEMPERATURE_LEVELS: u8 = 11; +const FN_GET_BRIGHTNESS_EFFECTIVE_MAX: u8 = 12; + +/// Implements the `Illumination` / `0x1990` feature. +pub struct IlluminationFeature { + /// The endpoint this feature talks to. + endpoint: FeatureEndpoint, + + /// The emitter used to publish decoded events. + emitter: Arc>, + + /// Removes the message listener when the feature is dropped. + _msg_listener: MessageListenerGuard, +} + +impl CreatableFeature for IlluminationFeature { + const ID: u16 = 0x1990; + const STARTING_VERSION: u8 = 0; + + fn new(chan: Arc, device_index: u8, feature_index: u8) -> Self { + let emitter = Arc::new(EventEmitter::new()); + + let listener = chan.add_msg_listener_guarded({ + let emitter = Arc::clone(&emitter); + + move |raw, matched| { + let Some((func, payload)) = + event_payload(raw, matched, device_index, feature_index) + else { + return; + }; + if let Some(event) = event::decode_event(func.to_lo(), &payload) { + emitter.emit(event); + } + } + }); + + Self { + endpoint: FeatureEndpoint::new(chan, device_index, feature_index), + emitter, + _msg_listener: listener, + } + } +} + +impl Feature for IlluminationFeature {} + +impl EmittingFeature for IlluminationFeature { + fn listen(&self) -> async_channel::Receiver { + self.emitter.create_receiver() + } +} + +impl IlluminationFeature { + /// Retrieves whether the illumination is on. + pub async fn get_illumination(&self) -> Result { + let payload = self + .endpoint + .call(FN_GET_ILLUMINATION, [0; 3]) + .await? + .extend_payload(); + illumination_state(payload[0]) + } + + /// Turns the illumination on or off. + pub async fn set_illumination(&self, state: IlluminationState) -> Result<(), Hidpp20Error> { + self.endpoint + .call(FN_SET_ILLUMINATION, [u8::from(state), 0, 0]) + .await?; + Ok(()) + } + + /// Retrieves the brightness capabilities and range (in Lumens). + pub async fn get_brightness_info(&self) -> Result { + self.read_info(FN_GET_BRIGHTNESS_INFO).await + } + + /// Retrieves the current brightness (in Lumens). + pub async fn get_brightness(&self) -> Result { + self.read_value(FN_GET_BRIGHTNESS).await + } + + /// Sets the brightness (in Lumens). + /// + /// The value must be within `[min, max]` and on the resolution grid from + /// [`Self::get_brightness_info`]. On devices with a dynamic maximum a value + /// above the effective maximum is clamped (see + /// [`IlluminationEvent::BrightnessClamped`]). + pub async fn set_brightness(&self, brightness: u16) -> Result<(), Hidpp20Error> { + self.write_value(FN_SET_BRIGHTNESS, brightness).await + } + + /// Retrieves the brightness level configuration starting at `start_index` + /// (ignored for linear levels). + pub async fn get_brightness_levels( + &self, + start_index: u8, + ) -> Result { + self.read_levels(FN_GET_BRIGHTNESS_LEVELS, start_index) + .await + } + + /// Writes the brightness level configuration. + pub async fn set_brightness_levels(&self, levels: &SetLevels) -> Result<(), Hidpp20Error> { + self.write_levels(FN_SET_BRIGHTNESS_LEVELS, levels).await + } + + /// Retrieves the current effective maximum brightness (in Lumens), or `0` + /// when none is in effect. Requires feature version 1. + pub async fn get_brightness_effective_max(&self) -> Result { + self.read_value(FN_GET_BRIGHTNESS_EFFECTIVE_MAX).await + } + + /// Retrieves the color-temperature capabilities and range (in Kelvin). + pub async fn get_color_temperature_info(&self) -> Result { + self.read_info(FN_GET_COLOR_TEMPERATURE_INFO).await + } + + /// Retrieves the current color temperature (in Kelvin). + pub async fn get_color_temperature(&self) -> Result { + self.read_value(FN_GET_COLOR_TEMPERATURE).await + } + + /// Sets the color temperature (in Kelvin). + /// + /// The value must be within `[min, max]` and on the resolution grid from + /// [`Self::get_color_temperature_info`]. + pub async fn set_color_temperature(&self, color_temperature: u16) -> Result<(), Hidpp20Error> { + self.write_value(FN_SET_COLOR_TEMPERATURE, color_temperature) + .await + } + + /// Retrieves the color-temperature level configuration starting at + /// `start_index` (ignored for linear levels). + pub async fn get_color_temperature_levels( + &self, + start_index: u8, + ) -> Result { + self.read_levels(FN_GET_COLOR_TEMPERATURE_LEVELS, start_index) + .await + } + + /// Writes the color-temperature level configuration. + pub async fn set_color_temperature_levels( + &self, + levels: &SetLevels, + ) -> Result<(), Hidpp20Error> { + self.write_levels(FN_SET_COLOR_TEMPERATURE_LEVELS, levels) + .await + } + + /// Shared `getInfo` reader. + async fn read_info(&self, function: u8) -> Result { + let payload = self.endpoint.call(function, [0; 3]).await?.extend_payload(); + Ok(ControlInfo::from_payload(&payload)) + } + + /// Shared `get` / effective-max reader for a big-endian `u16`. + async fn read_value(&self, function: u8) -> Result { + let payload = self.endpoint.call(function, [0; 3]).await?.extend_payload(); + Ok(be16(&payload, 0)) + } + + /// Shared `set` writer for a big-endian `u16`. + async fn write_value(&self, function: u8, value: u16) -> Result<(), Hidpp20Error> { + let [hi, lo] = value.to_be_bytes(); + self.endpoint.call(function, [hi, lo, 0]).await?; + Ok(()) + } + + /// Shared `getLevels` reader. + async fn read_levels( + &self, + function: u8, + start_index: u8, + ) -> Result { + // The request carries the start index in the high nibble of byte 0. + let payload = self + .endpoint + .call(function, [start_index << 4, 0, 0]) + .await? + .extend_payload(); + Ok(LevelConfig::from_payload(&payload)) + } + + /// Shared `setLevels` writer. + async fn write_levels(&self, function: u8, levels: &SetLevels) -> Result<(), Hidpp20Error> { + self.endpoint + .call_long(function, levels.to_payload()) + .await?; + Ok(()) + } +} diff --git a/crates/openlogi-hidpp/src/feature/illumination/tests.rs b/crates/openlogi-hidpp/src/feature/illumination/tests.rs new file mode 100644 index 00000000..6b667882 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/illumination/tests.rs @@ -0,0 +1,186 @@ +//! Unit tests for `Illumination` payload parsing and event decoding. + +use super::event::{IlluminationEvent, decode_event}; +use super::types::{ + BrightnessClampedSource, ControlCapabilities, ControlInfo, IlluminationState, LevelConfig, + SetLevels, +}; + +#[test] +fn parses_control_info() { + let mut payload = [0; 16]; + payload[0] = 0b0000_1011; // events + linear + dynamic max + payload[1..3].copy_from_slice(&100u16.to_be_bytes()); + payload[3..5].copy_from_slice(&1000u16.to_be_bytes()); + payload[5..7].copy_from_slice(&10u16.to_be_bytes()); + payload[7] = 0x05; + + let info = ControlInfo::from_payload(&payload); + assert!(info.capabilities.contains(ControlCapabilities::HAS_EVENTS)); + assert!( + info.capabilities + .contains(ControlCapabilities::HAS_LINEAR_LEVELS) + ); + assert!( + info.capabilities + .contains(ControlCapabilities::HAS_DYNAMIC_MAXIMUM) + ); + assert!( + !info + .capabilities + .contains(ControlCapabilities::HAS_NON_LINEAR_LEVELS) + ); + assert_eq!(info.min, 100); + assert_eq!(info.max, 1000); + assert_eq!(info.resolution, 10); + assert_eq!(info.max_levels, 5); +} + +#[test] +fn parses_linear_levels() { + let mut payload = [0; 16]; + payload[0] = 1; // linear + payload[2..4].copy_from_slice(&100u16.to_be_bytes()); + payload[4..6].copy_from_slice(&500u16.to_be_bytes()); + payload[6..8].copy_from_slice(&50u16.to_be_bytes()); + + assert_eq!( + LevelConfig::from_payload(&payload), + LevelConfig::Linear { + min: 100, + max: 500, + step: 50 + } + ); +} + +#[test] +fn parses_non_linear_levels() { + let mut payload = [0; 16]; + // validCount = 3 (bits 5..7), linear bit clear. + payload[0] = 3 << 5; + // startIndex = 1 (high nibble), levelCount = 6 (low nibble). + payload[1] = (1 << 4) | 6; + payload[2..4].copy_from_slice(&200u16.to_be_bytes()); + payload[4..6].copy_from_slice(&400u16.to_be_bytes()); + payload[6..8].copy_from_slice(&800u16.to_be_bytes()); + + assert_eq!( + LevelConfig::from_payload(&payload), + LevelConfig::NonLinear { + start_index: 1, + level_count: 6, + values: vec![200, 400, 800], + } + ); +} + +#[test] +fn encodes_reset_levels() { + let payload = SetLevels::Reset.to_payload(); + assert_eq!(payload[0], 1 << 1); + assert!(payload[1..].iter().all(|&b| b == 0)); +} + +#[test] +fn encodes_linear_levels() { + let payload = SetLevels::Linear { + min: 100, + max: 500, + step: 50, + } + .to_payload(); + assert_eq!(payload[0], 1); + assert_eq!(u16::from_be_bytes([payload[2], payload[3]]), 100); + assert_eq!(u16::from_be_bytes([payload[4], payload[5]]), 500); + assert_eq!(u16::from_be_bytes([payload[6], payload[7]]), 50); +} + +#[test] +fn encodes_non_linear_levels() { + let payload = SetLevels::NonLinear { + start_index: 2, + level_count: 5, + values: vec![100, 200], + } + .to_payload(); + + assert_eq!(payload[0], 2 << 5); // validCount = 2, linear/reset clear + assert_eq!(payload[1], (2 << 4) | 5); // startIndex 2, levelCount 5 + assert_eq!(u16::from_be_bytes([payload[2], payload[3]]), 100); + assert_eq!(u16::from_be_bytes([payload[4], payload[5]]), 200); +} + +#[test] +fn non_linear_round_trips_through_decoder() { + // A non-linear set payload, read back through the get decoder, must agree on + // the value list (the get response uses the same field layout). + let payload = SetLevels::NonLinear { + start_index: 0, + level_count: 3, + values: vec![50, 150, 300], + } + .to_payload(); + + assert_eq!( + LevelConfig::from_payload(&payload), + LevelConfig::NonLinear { + start_index: 0, + level_count: 3, + values: vec![50, 150, 300], + } + ); +} + +#[test] +fn decodes_state_and_value_events() { + let mut on = [0; 16]; + on[0] = 1; + assert_eq!( + decode_event(0, &on), + Some(IlluminationEvent::IlluminationChanged( + IlluminationState::On + )) + ); + + let mut brightness = [0; 16]; + brightness[0..2].copy_from_slice(&750u16.to_be_bytes()); + assert_eq!( + decode_event(1, &brightness), + Some(IlluminationEvent::BrightnessChanged(750)) + ); + + let mut temp = [0; 16]; + temp[0..2].copy_from_slice(&5000u16.to_be_bytes()); + assert_eq!( + decode_event(2, &temp), + Some(IlluminationEvent::ColorTemperatureChanged(5000)) + ); + + let mut eff = [0; 16]; + eff[0..2].copy_from_slice(&600u16.to_be_bytes()); + assert_eq!( + decode_event(3, &eff), + Some(IlluminationEvent::BrightnessEffectiveMaxChanged(600)) + ); +} + +#[test] +fn decodes_brightness_clamped_event() { + let mut payload = [0; 16]; + payload[0] = 2; // Button + payload[1..3].copy_from_slice(&600u16.to_be_bytes()); + + assert_eq!( + decode_event(4, &payload), + Some(IlluminationEvent::BrightnessClamped { + source: BrightnessClampedSource::Button, + brightness: 600, + }) + ); +} + +#[test] +fn ignores_unknown_event_sub_id() { + assert!(decode_event(9, &[0; 16]).is_none()); +} diff --git a/crates/openlogi-hidpp/src/feature/illumination/types.rs b/crates/openlogi-hidpp/src/feature/illumination/types.rs new file mode 100644 index 00000000..2f805ef0 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/illumination/types.rs @@ -0,0 +1,210 @@ +//! Domain types for the `Illumination` feature (`0x1990`). + +use num_enum::{IntoPrimitive, TryFromPrimitive}; + +use crate::protocol::v20::Hidpp20Error; + +/// Reads a big-endian `u16` at `offset` of a payload. +pub(super) fn be16(payload: &[u8; 16], offset: usize) -> u16 { + u16::from_be_bytes([payload[offset], payload[offset + 1]]) +} + +bitflags::bitflags! { + /// Capabilities of an illumination control (brightness or color + /// temperature), from `getBrightnessInfo` / `getColorTemperatureInfo`. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct ControlCapabilities: u8 { + /// The control emits change events. + const HAS_EVENTS = 1 << 0; + /// The control supports linear (min/max/step) levels. + const HAS_LINEAR_LEVELS = 1 << 1; + /// The control supports an explicit list of non-linear levels. + const HAS_NON_LINEAR_LEVELS = 1 << 2; + /// The control has a dynamic effective maximum (brightness only). + const HAS_DYNAMIC_MAXIMUM = 1 << 3; + } +} + +/// Capabilities and range of an illumination control. +/// +/// Values are in Lumens for brightness and Kelvin for color temperature. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct ControlInfo { + /// Control capabilities. + pub capabilities: ControlCapabilities, + /// Minimum value. When `min == max` only one setting exists and the + /// corresponding setter is unsupported. + pub min: u16, + /// Maximum value. + pub max: u16, + /// Resolution: valid values satisfy `(value - min) % resolution == 0`. + pub resolution: u16, + /// Maximum number of non-linear levels (`0` if non-linear levels are + /// unsupported). + pub max_levels: u8, +} + +impl ControlInfo { + pub(super) fn from_payload(payload: &[u8; 16]) -> Self { + Self { + capabilities: ControlCapabilities::from_bits_retain(payload[0]), + min: be16(payload, 1), + max: be16(payload, 3), + resolution: be16(payload, 5), + max_levels: payload[7] & 0x0f, + } + } +} + +/// The level configuration of an illumination control. +/// +/// A control exposes its selectable levels either as a linear `min/max/step` +/// range or as an explicit list of non-linear values. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub enum LevelConfig { + /// Evenly spaced levels from `min` to `max` (inclusive) in steps of `step`. + Linear { + /// Lowest level value. + min: u16, + /// Highest level value. + max: u16, + /// Spacing between adjacent levels. + step: u16, + }, + /// An explicit list of level values. + NonLinear { + /// Zero-based index of the first returned value within the full list. + start_index: u8, + /// Total number of available levels. + level_count: u8, + /// The values in this page (`1..=7` of them). + values: Vec, + }, +} + +impl LevelConfig { + pub(super) fn from_payload(payload: &[u8; 16]) -> Self { + let flags = payload[0]; + if flags & 1 != 0 { + LevelConfig::Linear { + min: be16(payload, 2), + max: be16(payload, 4), + step: be16(payload, 6), + } + } else { + let valid_count = usize::from((flags >> 5) & 0x07); + let values = (0..valid_count).map(|i| be16(payload, 2 + 2 * i)).collect(); + LevelConfig::NonLinear { + start_index: payload[1] >> 4, + level_count: payload[1] & 0x0f, + values, + } + } + } +} + +/// A level configuration to write with `setBrightnessLevels` / +/// `setColorTemperatureLevels`. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub enum SetLevels { + /// Reset the level configuration to the factory defaults. + Reset, + /// Configure evenly spaced linear levels. + Linear { + /// Lowest level value. + min: u16, + /// Highest level value. + max: u16, + /// Spacing between adjacent levels. + step: u16, + }, + /// Configure an explicit list of non-linear levels. + NonLinear { + /// Zero-based index at which `values` are written. + start_index: u8, + /// Total number of available levels (`0` resets the count to the factory + /// default). + level_count: u8, + /// The monotonically increasing values to write (`1..=7` of them). + values: Vec, + }, +} + +impl SetLevels { + /// Encodes this configuration into a request payload. + pub(super) fn to_payload(&self) -> [u8; 16] { + let mut args = [0u8; 16]; + match self { + SetLevels::Reset => { + // bit1 = reset; every other field is ignored by the device. + args[0] = 1 << 1; + } + SetLevels::Linear { min, max, step } => { + args[0] = 1; // bit0 = linear + args[2..4].copy_from_slice(&min.to_be_bytes()); + args[4..6].copy_from_slice(&max.to_be_bytes()); + args[6..8].copy_from_slice(&step.to_be_bytes()); + } + SetLevels::NonLinear { + start_index, + level_count, + values, + } => { + debug_assert!( + (1..=7).contains(&values.len()), + "non-linear level count {} out of range 1..=7", + values.len() + ); + let valid_count = (values.len() as u8) & 0x07; + args[0] = valid_count << 5; // linear = 0, reset = 0 + args[1] = (start_index << 4) | (level_count & 0x0f); + for (i, value) in values.iter().take(7).enumerate() { + args[2 + 2 * i..4 + 2 * i].copy_from_slice(&value.to_be_bytes()); + } + } + } + args + } +} + +/// On/off state of the illumination. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum IlluminationState { + /// Illumination is off. + Off = 0, + /// Illumination is on. + On = 1, +} + +impl From for IlluminationState { + fn from(value: bool) -> Self { + if value { Self::On } else { Self::Off } + } +} + +/// What caused a [`brightness clamp`](super::event::IlluminationEvent::BrightnessClamped). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum BrightnessClampedSource { + /// The source is unknown. + Unknown = 0, + /// A HID++ `setBrightness` request triggered the clamp. + HidPlusPlus = 1, + /// A hardware button triggered the clamp. + Button = 2, +} + +/// Decodes the on/off state bit shared by `getIllumination` and its event. +pub(super) fn illumination_state(byte: u8) -> Result { + IlluminationState::try_from(byte & 1).map_err(|_| Hidpp20Error::UnsupportedResponse) +} diff --git a/crates/openlogi-hidpp/src/feature/mod.rs b/crates/openlogi-hidpp/src/feature/mod.rs index fd96d5bd..e3cd9f9e 100755 --- a/crates/openlogi-hidpp/src/feature/mod.rs +++ b/crates/openlogi-hidpp/src/feature/mod.rs @@ -9,6 +9,7 @@ use crate::{ }; pub mod adjustable_dpi; +pub mod backlight; pub mod brightness_control; pub mod change_host; pub mod device_friendly_name; @@ -23,6 +24,7 @@ pub mod feature_set; pub mod fn_inversion; pub mod hires_wheel; pub mod hosts_info; +pub mod illumination; pub mod mode_status; pub mod mouse_pointer; pub mod multi_platform; diff --git a/crates/openlogi-hidpp/src/feature/registry.rs b/crates/openlogi-hidpp/src/feature/registry.rs index bb8935d3..e442da66 100755 --- a/crates/openlogi-hidpp/src/feature/registry.rs +++ b/crates/openlogi-hidpp/src/feature/registry.rs @@ -13,6 +13,7 @@ use crate::{ feature::{ CreatableFeature, adjustable_dpi::AdjustableDpiFeature, + backlight::BacklightFeature, brightness_control::BrightnessControlFeature, change_host::ChangeHostFeature, device_friendly_name::DeviceFriendlyNameFeature, @@ -27,6 +28,7 @@ use crate::{ fn_inversion::{FnInversionMultiHostFeature, FnInversionWithDefaultStateFeature}, hires_wheel::HiResWheelFeature, hosts_info::HostsInfoFeature, + illumination::IlluminationFeature, mode_status::ModeStatusFeature, mouse_pointer::MousePointerFeature, multi_platform::MultiPlatformFeature, @@ -154,9 +156,9 @@ static KNOWN_FEATURES: LazyLock> = LazyLock::new(|| { 0x1814 "ChangeHost" => ChangeHostFeature, 0x1815 "HostsInfo" => HostsInfoFeature, 0x1981 "Backlight1", - 0x1982 "Backlight2", + 0x1982 "Backlight2" => BacklightFeature, 0x1983 "Backlight3", - 0x1990 "Illumination", + 0x1990 "Illumination" => IlluminationFeature, 0x19b0 "HapticFeedback", 0x19c0 "ForceSensingButton", 0x1a00 "PresenterControl", From 0ffb5c6d8100f81a0bc439686dd9acfa6b270bc1 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sat, 27 Jun 2026 18:48:19 +0800 Subject: [PATCH 07/21] feat(hidpp): add color led effects wrapper --- .../src/feature/color_led_effects/event.rs | 30 ++ .../src/feature/color_led_effects/mod.rs | 309 +++++++++++++ .../src/feature/color_led_effects/tests.rs | 165 +++++++ .../src/feature/color_led_effects/types.rs | 432 ++++++++++++++++++ crates/openlogi-hidpp/src/feature/mod.rs | 1 + crates/openlogi-hidpp/src/feature/registry.rs | 3 +- 6 files changed, 939 insertions(+), 1 deletion(-) create mode 100644 crates/openlogi-hidpp/src/feature/color_led_effects/event.rs create mode 100644 crates/openlogi-hidpp/src/feature/color_led_effects/mod.rs create mode 100644 crates/openlogi-hidpp/src/feature/color_led_effects/tests.rs create mode 100644 crates/openlogi-hidpp/src/feature/color_led_effects/types.rs diff --git a/crates/openlogi-hidpp/src/feature/color_led_effects/event.rs b/crates/openlogi-hidpp/src/feature/color_led_effects/event.rs new file mode 100644 index 00000000..dcf3e040 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/color_led_effects/event.rs @@ -0,0 +1,30 @@ +//! Events emitted by the `ColorLedEffects` feature (`0x8070`). + +use super::types::be16; + +/// An event emitted by [`ColorLedEffectsFeature`](super::ColorLedEffectsFeature). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub enum ColorLedEffectsEvent { + /// A period effect reached a synchronization point. Emitted once per period + /// while sync events are enabled (see + /// [`setSwControl`](super::ColorLedEffectsFeature::set_sw_control)). + SyncEffect { + /// Zone the event applies to; `0xff` means all zones. + zone_index: u8, + /// Current timing position within the period, in milliseconds. + effect_counter: u16, + }, +} + +/// Decodes an unsolicited `0x8070` event payload by its sub-id. +pub(super) fn decode_event(sub_id: u8, payload: &[u8; 16]) -> Option { + match sub_id { + 0 => Some(ColorLedEffectsEvent::SyncEffect { + zone_index: payload[0], + effect_counter: be16(payload, 1), + }), + _ => None, + } +} diff --git a/crates/openlogi-hidpp/src/feature/color_led_effects/mod.rs b/crates/openlogi-hidpp/src/feature/color_led_effects/mod.rs new file mode 100644 index 00000000..8ec321a9 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/color_led_effects/mod.rs @@ -0,0 +1,309 @@ +//! Implements the `ColorLedEffects` feature (ID `0x8070`, version 7), the +//! per-zone RGB effect engine used by Logitech keyboards and mice. +//! +//! Each device exposes one or more LED *zones*; each zone supports a set of +//! *effects* (fixed color, breathing, color wave, …). An effect is applied with +//! [`set_zone_effect`](ColorLedEffectsFeature::set_zone_effect), whose ten +//! parameter bytes have effect-specific meaning, and can be stored volatilely or +//! in EEPROM via [`Persistence`]. +//! +//! All multi-byte fields in this feature are big-endian. + +pub mod event; +pub mod types; + +#[cfg(test)] +mod tests; + +use std::sync::Arc; + +pub use event::ColorLedEffectsEvent; +pub use types::{ + ColorLedInfo, CyclingDirection, EffectId, EffectSettings, ExtCapabilities, LedBinIndex, + LedBinInfo, LocationEffect, NvCapabilities, NvCapabilityState, NvConfig, Persistence, + PersistenceSource, PersistencyCapabilities, Rgb, SwControl, SwControlState, + ZONE_EFFECT_PARAM_COUNT, ZoneEffect, ZoneEffectInfo, ZoneInfo, +}; + +use self::types::be16; +use crate::{ + channel::{HidppChannel, MessageListenerGuard}, + event::EventEmitter, + feature::{CreatableFeature, EmittingFeature, Feature, FeatureEndpoint, event_payload}, + protocol::v20::Hidpp20Error, +}; + +/// Implements the `ColorLedEffects` / `0x8070` feature. +pub struct ColorLedEffectsFeature { + /// The endpoint this feature talks to. + endpoint: FeatureEndpoint, + + /// The emitter used to publish decoded events. + emitter: Arc>, + + /// Removes the message listener when the feature is dropped. + _msg_listener: MessageListenerGuard, +} + +impl CreatableFeature for ColorLedEffectsFeature { + const ID: u16 = 0x8070; + const STARTING_VERSION: u8 = 0; + + fn new(chan: Arc, device_index: u8, feature_index: u8) -> Self { + let emitter = Arc::new(EventEmitter::new()); + + let listener = chan.add_msg_listener_guarded({ + let emitter = Arc::clone(&emitter); + + move |raw, matched| { + let Some((func, payload)) = + event_payload(raw, matched, device_index, feature_index) + else { + return; + }; + if let Some(event) = event::decode_event(func.to_lo(), &payload) { + emitter.emit(event); + } + } + }); + + Self { + endpoint: FeatureEndpoint::new(chan, device_index, feature_index), + emitter, + _msg_listener: listener, + } + } +} + +impl Feature for ColorLedEffectsFeature {} + +impl EmittingFeature for ColorLedEffectsFeature { + fn listen(&self) -> async_channel::Receiver { + self.emitter.create_receiver() + } +} + +impl ColorLedEffectsFeature { + /// Retrieves the zone count and capability bitmasks. + pub async fn get_info(&self) -> Result { + let payload = self.endpoint.call(0, [0; 3]).await?.extend_payload(); + Ok(ColorLedInfo::from_payload(&payload)) + } + + /// Retrieves information about `zone_index`. + pub async fn get_zone_info(&self, zone_index: u8) -> Result { + let payload = self + .endpoint + .call(1, [zone_index, 0, 0]) + .await? + .extend_payload(); + ZoneInfo::from_payload(&payload) + } + + /// Retrieves information about effect `zone_effect_index` of `zone_index`. + pub async fn get_zone_effect_info( + &self, + zone_index: u8, + zone_effect_index: u8, + ) -> Result { + let payload = self + .endpoint + .call(2, [zone_index, zone_effect_index, 0]) + .await? + .extend_payload(); + ZoneEffectInfo::from_payload(&payload) + } + + /// Applies effect `zone_effect_index` to `zone_index` with effect-specific + /// `params`. + /// + /// The meaning of each parameter byte depends on the effect's + /// [`EffectId`] (discoverable with [`Self::get_zone_effect_info`]). For + /// example, the [`EffectId::FixedColor`] effect uses the first three + /// parameters as red, green and blue. + pub async fn set_zone_effect( + &self, + zone_index: u8, + zone_effect_index: u8, + params: [u8; ZONE_EFFECT_PARAM_COUNT], + persistence: Persistence, + ) -> Result<(), Hidpp20Error> { + let mut args = [0; 16]; + args[0] = zone_index; + args[1] = zone_effect_index; + args[2..2 + ZONE_EFFECT_PARAM_COUNT].copy_from_slice(¶ms); + args[12] = persistence.into(); + self.endpoint.call_long(3, args).await?; + Ok(()) + } + + /// Reads one non-volatile configuration `capability`. + /// + /// Exactly one [`NvCapabilities`] bit must be set. + pub async fn get_nv_config( + &self, + capability: NvCapabilities, + ) -> Result { + let [cap_hi, cap_lo] = capability.bits().to_be_bytes(); + let payload = self + .endpoint + .call(4, [cap_hi, cap_lo, 0]) + .await? + .extend_payload(); + Ok(NvConfig { + capability: NvCapabilities::from_bits_retain(be16(&payload, 0)), + state: NvCapabilityState::try_from(payload[2]) + .map_err(|_| Hidpp20Error::UnsupportedResponse)?, + param1: payload[3], + param2: payload[4], + }) + } + + /// Writes one non-volatile configuration entry (to EEPROM, so use sparingly). + pub async fn set_nv_config( + &self, + capability: NvCapabilities, + state: NvCapabilityState, + param1: u8, + param2: u8, + ) -> Result<(), Hidpp20Error> { + let [cap_hi, cap_lo] = capability.bits().to_be_bytes(); + let mut args = [0; 16]; + args[..5].copy_from_slice(&[cap_hi, cap_lo, state.into(), param1, param2]); + self.endpoint.call_long(5, args).await?; + Ok(()) + } + + /// Reads manufacturing LED bin information. + pub async fn get_led_bin_info( + &self, + zone_index: u8, + led_bin_index: LedBinIndex, + ) -> Result { + let payload = self + .endpoint + .call(6, [zone_index, led_bin_index.into(), 0]) + .await? + .extend_payload(); + LedBinInfo::from_payload(&payload) + } + + /// Retrieves whether firmware or software owns the LEDs. + pub async fn get_sw_control(&self) -> Result { + let payload = self.endpoint.call(7, [0; 3]).await?.extend_payload(); + Ok(SwControlState { + control: SwControl::try_from(payload[0]) + .map_err(|_| Hidpp20Error::UnsupportedResponse)?, + sync_events: payload[1] != 0, + }) + } + + /// Takes or releases software control of the LEDs. + /// + /// `sync_events` enables the [`ColorLedEffectsEvent::SyncEffect`] event. This + /// is not stored in EEPROM. + pub async fn set_sw_control( + &self, + control: SwControl, + sync_events: bool, + ) -> Result<(), Hidpp20Error> { + self.endpoint + .call(8, [control.into(), u8::from(sync_events), 0]) + .await?; + Ok(()) + } + + /// Reads the effect settings of `zone_index`. + /// + /// Not supported when [`ExtCapabilities::NO_GET_EFFECT_SETTINGS`] is set. + pub async fn get_effect_settings( + &self, + zone_index: u8, + source: PersistenceSource, + ) -> Result { + let payload = self + .endpoint + .call(9, [zone_index, source.into(), 0]) + .await? + .extend_payload(); + Ok(EffectSettings::from_payload(&payload)) + } + + /// Clears the effect settings of `zone_index`, reverting it to the default + /// mode. + pub async fn clear_effect_settings(&self, zone_index: u8) -> Result<(), Hidpp20Error> { + self.endpoint.call(10, [zone_index, 0, 0]).await?; + Ok(()) + } + + /// Sets the color-cycling direction. + pub async fn set_cycling_direction( + &self, + direction: CyclingDirection, + ) -> Result<(), Hidpp20Error> { + self.endpoint.call(11, [direction.into(), 0, 0]).await?; + Ok(()) + } + + /// Retrieves the color currently displayed by `zone_index`. + pub async fn get_current_color(&self, zone_index: u8) -> Result { + let payload = self + .endpoint + .call(12, [zone_index, 0, 0]) + .await? + .extend_payload(); + Ok(Rgb { + red: payload[1], + green: payload[2], + blue: payload[3], + }) + } + + /// Synchronizes effect timing across devices by applying a `drift_value` + /// correction (milliseconds). + /// + /// Valid only while sync events are enabled. A `zone_index` of `0xff` targets + /// all zones. + pub async fn synchronize_effect( + &self, + zone_index: u8, + drift_value: i16, + ) -> Result<(), Hidpp20Error> { + let [drift_hi, drift_lo] = drift_value.to_be_bytes(); + let mut args = [0; 16]; + args[..4].copy_from_slice(&[zone_index, 0, drift_hi, drift_lo]); + self.endpoint.call_long(13, args).await?; + Ok(()) + } + + /// Retrieves the currently configured effect of `zone_index`. + /// + /// Requires [`ExtCapabilities::GET_ZONE_EFFECT`]. + pub async fn get_zone_effect( + &self, + zone_index: u8, + source: PersistenceSource, + ) -> Result { + let payload = self + .endpoint + .call(14, [zone_index, source.into(), 0]) + .await? + .extend_payload(); + Ok(ZoneEffect::from_payload(&payload)) + } + + /// Stores manufacturing LED bin information and returns the device's echo. + /// + /// Requires [`ExtCapabilities::SET_LED_BIN_INFO`]. + pub async fn set_led_bin_info(&self, info: &LedBinInfo) -> Result { + let mut args = [0; 16]; + args[0] = info.zone_index; + args[1] = info.led_bin_index.into(); + args[2..4].copy_from_slice(&info.red.to_be_bytes()); + args[4..6].copy_from_slice(&info.green.to_be_bytes()); + args[6..8].copy_from_slice(&info.blue.to_be_bytes()); + args[8..10].copy_from_slice(&info.white.to_be_bytes()); + let payload = self.endpoint.call_long(15, args).await?.extend_payload(); + LedBinInfo::from_payload(&payload) + } +} diff --git a/crates/openlogi-hidpp/src/feature/color_led_effects/tests.rs b/crates/openlogi-hidpp/src/feature/color_led_effects/tests.rs new file mode 100644 index 00000000..89556bf6 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/color_led_effects/tests.rs @@ -0,0 +1,165 @@ +//! Unit tests for `ColorLedEffects` payload parsing and event decoding. + +use super::event::{ColorLedEffectsEvent, decode_event}; +use super::types::{ + ColorLedInfo, EffectId, EffectSettings, ExtCapabilities, LedBinIndex, LedBinInfo, + LocationEffect, NvCapabilities, PersistencyCapabilities, ZoneEffect, ZoneEffectInfo, ZoneInfo, +}; +use crate::protocol::v20::Hidpp20Error; + +#[test] +fn parses_info() { + let mut payload = [0; 16]; + payload[0] = 3; + payload[1..3].copy_from_slice(&0x0005u16.to_be_bytes()); // bootUp + userDemo + payload[3..5].copy_from_slice(&0x0001u16.to_be_bytes()); // getZoneEffect supported + + let info = ColorLedInfo::from_payload(&payload); + assert_eq!(info.zone_count, 3); + assert!( + info.nv_capabilities + .contains(NvCapabilities::BOOT_UP_EFFECT) + ); + assert!( + info.nv_capabilities + .contains(NvCapabilities::USER_DEMO_MODE) + ); + assert!(!info.nv_capabilities.contains(NvCapabilities::DEMO)); + assert!( + info.ext_capabilities + .contains(ExtCapabilities::GET_ZONE_EFFECT) + ); +} + +#[test] +fn parses_zone_info() { + let mut payload = [0; 16]; + payload[0] = 1; + payload[1..3].copy_from_slice(&2u16.to_be_bytes()); // Logo + payload[3] = 4; + payload[4] = 0b101; // always_on + on_then_off + + let zone = ZoneInfo::from_payload(&payload).unwrap(); + assert_eq!(zone.zone_index, 1); + assert_eq!(zone.location, LocationEffect::Logo); + assert_eq!(zone.effects_number, 4); + assert!( + zone.persistency + .contains(PersistencyCapabilities::ALWAYS_ON) + ); + assert!( + zone.persistency + .contains(PersistencyCapabilities::ON_THEN_OFF) + ); + assert!( + !zone + .persistency + .contains(PersistencyCapabilities::ALWAYS_OFF) + ); +} + +#[test] +fn rejects_unknown_zone_location() { + let mut payload = [0; 16]; + payload[1..3].copy_from_slice(&99u16.to_be_bytes()); + + assert!(matches!( + ZoneInfo::from_payload(&payload), + Err(Hidpp20Error::UnsupportedResponse) + )); +} + +#[test] +fn parses_zone_effect_info() { + let mut payload = [0; 16]; + payload[0] = 0; + payload[1] = 2; + payload[2..4].copy_from_slice(&4u16.to_be_bytes()); // ColorWave + payload[4..6].copy_from_slice(&0x0003u16.to_be_bytes()); + payload[6..8].copy_from_slice(&1000u16.to_be_bytes()); + + let info = ZoneEffectInfo::from_payload(&payload).unwrap(); + assert_eq!(info.zone_effect_index, 2); + assert_eq!(info.effect_id, EffectId::ColorWave); + assert_eq!(info.effect_capabilities, 0x0003); + assert_eq!(info.effect_period, 1000); +} + +#[test] +fn parses_effect_settings() { + let mut payload = [0; 16]; + payload[0] = 1; + payload[1..4].copy_from_slice(&[0x11, 0x22, 0x33]); + payload[4..6].copy_from_slice(&2000u16.to_be_bytes()); + payload[6] = 80; + payload[7] = 1; + + let settings = EffectSettings::from_payload(&payload); + assert_eq!(settings.zone_index, 1); + assert_eq!(settings.color.red, 0x11); + assert_eq!(settings.color.green, 0x22); + assert_eq!(settings.color.blue, 0x33); + assert_eq!(settings.period, 2000); + assert_eq!(settings.brightness, 80); + assert_eq!(settings.param, 1); +} + +#[test] +fn parses_zone_effect_params() { + let mut payload = [0; 16]; + payload[0] = 2; + payload[1] = 1; + payload[2..12].copy_from_slice(&[10, 20, 30, 40, 50, 60, 70, 80, 90, 100]); + + let effect = ZoneEffect::from_payload(&payload); + assert_eq!(effect.zone_index, 2); + assert_eq!(effect.zone_effect_index, 1); + assert_eq!(effect.params, [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]); +} + +#[test] +fn parses_led_bin_info() { + let mut payload = [0; 16]; + payload[0] = 0; + payload[1] = 2; // CalibrationFactors + payload[2..4].copy_from_slice(&100u16.to_be_bytes()); + payload[4..6].copy_from_slice(&200u16.to_be_bytes()); + payload[6..8].copy_from_slice(&300u16.to_be_bytes()); + payload[8..10].copy_from_slice(&400u16.to_be_bytes()); + + let bin = LedBinInfo::from_payload(&payload).unwrap(); + assert_eq!(bin.led_bin_index, LedBinIndex::CalibrationFactors); + assert_eq!(bin.red, 100); + assert_eq!(bin.green, 200); + assert_eq!(bin.blue, 300); + assert_eq!(bin.white, 400); +} + +#[test] +fn decodes_sync_effect_event() { + let mut payload = [0; 16]; + payload[0] = 0xff; // all zones + payload[1..3].copy_from_slice(&1234u16.to_be_bytes()); + + assert_eq!( + decode_event(0, &payload), + Some(ColorLedEffectsEvent::SyncEffect { + zone_index: 0xff, + effect_counter: 1234, + }) + ); +} + +#[test] +fn ignores_unknown_event_sub_id() { + assert!(decode_event(5, &[0; 16]).is_none()); +} + +#[test] +fn maps_effect_id_wire_values() { + assert_eq!(EffectId::try_from(0u16).unwrap(), EffectId::Disabled); + assert_eq!(EffectId::try_from(1u16).unwrap(), EffectId::FixedColor); + assert_eq!(EffectId::try_from(11u16).unwrap(), EffectId::Ripple); + assert!(EffectId::try_from(12u16).is_err()); + assert_eq!(u16::from(EffectId::FixedColor), 1); +} diff --git a/crates/openlogi-hidpp/src/feature/color_led_effects/types.rs b/crates/openlogi-hidpp/src/feature/color_led_effects/types.rs new file mode 100644 index 00000000..fb1ad849 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/color_led_effects/types.rs @@ -0,0 +1,432 @@ +//! Domain types for the `ColorLedEffects` feature (`0x8070`). + +use num_enum::{IntoPrimitive, TryFromPrimitive}; + +use crate::protocol::v20::Hidpp20Error; + +/// Number of effect parameters carried by `setZoneEffect` / `getZoneEffect`. +pub const ZONE_EFFECT_PARAM_COUNT: usize = 10; + +/// Reads a big-endian `u16` at `offset` of a payload. +pub(super) fn be16(payload: &[u8; 16], offset: usize) -> u16 { + u16::from_be_bytes([payload[offset], payload[offset + 1]]) +} + +/// An 8-bit-per-channel RGB color. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub struct Rgb { + /// Red channel. + pub red: u8, + /// Green channel. + pub green: u8, + /// Blue channel. + pub blue: u8, +} + +/// Identifies the type of a zone effect. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u16)] +pub enum EffectId { + /// No effect / LEDs off. + Disabled = 0, + /// A fixed single color. + FixedColor = 1, + /// Legacy pulsing/breathing effect. + PulsingBreathingLegacy = 2, + /// Color cycling through the color wheel. + Cycling = 3, + /// A traveling color wave. + ColorWave = 4, + /// Twinkling "starlight" effect. + Starlight = 5, + /// Light up keys on press. + LightOnPress = 6, + /// Audio visualizer (reserved). + AudioVisualizer = 7, + /// Boot-up effect. + BootUp = 8, + /// Demo mode. + DemoMode = 9, + /// Pulsing/breathing with a selectable waveform. + PulsingBreathingWaveform = 10, + /// Ripple effect. + Ripple = 11, +} + +/// The physical location a zone covers. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u16)] +pub enum LocationEffect { + /// The primary zone. + Primary = 1, + /// The logo. + Logo = 2, + /// The left side. + LeftSide = 3, + /// The right side. + RightSide = 4, + /// A combined zone. + Combined = 5, + /// Primary zone 1. + Primary1 = 6, + /// Primary zone 2. + Primary2 = 7, + /// Primary zone 3. + Primary3 = 8, + /// Primary zone 4. + Primary4 = 9, + /// Primary zone 5. + Primary5 = 10, + /// Primary zone 6. + Primary6 = 11, +} + +/// Storage persistence for [`setZoneEffect`](super::ColorLedEffectsFeature::set_zone_effect). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum Persistence { + /// Volatile: applied to RAM only, lost on power cycle. + Volatile = 0, + /// Applied to RAM and stored in EEPROM. + VolatileAndNonVolatile = 1, + /// Stored in EEPROM only. + NonVolatileOnly = 2, +} + +/// Which storage a read function should read from. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum PersistenceSource { + /// The actively playing configuration in RAM. + Ram = 0, + /// The saved configuration in EEPROM. + Eeprom = 1, +} + +/// Whether the firmware or software owns the LEDs. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum SwControl { + /// The firmware owns all LEDs. + Firmware = 0, + /// Software owns all LEDs. + Software = 1, +} + +/// Direction of color cycling. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum CyclingDirection { + /// Clockwise through the color wheel. + Clockwise = 0, + /// Anticlockwise through the color wheel. + Anticlockwise = 1, +} + +/// State of a non-volatile configuration capability. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum NvCapabilityState { + /// The stored value has never been explicitly set (read-only sentinel, + /// enabled assumed). + NoChange = 0, + /// The capability is enabled. + Enabled = 1, + /// The capability is disabled. + Disabled = 2, +} + +/// Selects which LED bin parameter a `getLedBinInfo` / `setLedBinInfo` call +/// addresses. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum LedBinIndex { + /// Bin value: brightness. + BinValueBrightness = 0, + /// Bin value: color. + BinValueColor = 1, + /// Calibration factors. + CalibrationFactors = 2, + /// Brightness. + Brightness = 3, + /// Colorimetric X. + ColorimetricX = 4, + /// Colorimetric Y. + ColorimetricY = 5, +} + +bitflags::bitflags! { + /// Supported non-volatile configuration capabilities, from `getInfo`. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct NvCapabilities: u16 { + /// A boot-up effect can be configured. + const BOOT_UP_EFFECT = 1 << 0; + /// Demo mode is supported. + const DEMO = 1 << 1; + /// User demo mode is supported. + const USER_DEMO_MODE = 1 << 2; + } +} + +bitflags::bitflags! { + /// Extended capabilities, from `getInfo`. + /// + /// Several bits are "NOT supported" flags whose set state *removes* a + /// function — named to reflect that. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct ExtCapabilities: u16 { + /// `getZoneEffect` is supported. + const GET_ZONE_EFFECT = 1 << 0; + /// `getEffectSettings` is *not* supported. + const NO_GET_EFFECT_SETTINGS = 1 << 1; + /// `setLedBinInfo` is supported. + const SET_LED_BIN_INFO = 1 << 2; + /// Only monochrome effects are supported. + const MONOCHROME_ONLY = 1 << 3; + /// `synchronizeEffect` and the sync-effect event are *not* supported. + const NO_SYNCHRONIZE_EFFECT = 1 << 4; + } +} + +bitflags::bitflags! { + /// Persistency capabilities of a zone, from `getZoneInfo`. + /// + /// A value of zero means persistency is not supported. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct PersistencyCapabilities: u8 { + /// The zone can persist an "always on" state. + const ALWAYS_ON = 1 << 0; + /// The zone can persist an "always off" state. + const ALWAYS_OFF = 1 << 1; + /// The zone can persist an "on then off" state. + const ON_THEN_OFF = 1 << 2; + } +} + +/// General feature information from +/// [`getInfo`](super::ColorLedEffectsFeature::get_info). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct ColorLedInfo { + /// Number of LED zones. + pub zone_count: u8, + /// Supported non-volatile capabilities. + pub nv_capabilities: NvCapabilities, + /// Extended capabilities. + pub ext_capabilities: ExtCapabilities, +} + +impl ColorLedInfo { + pub(super) fn from_payload(payload: &[u8; 16]) -> Self { + Self { + zone_count: payload[0], + nv_capabilities: NvCapabilities::from_bits_retain(be16(payload, 1)), + ext_capabilities: ExtCapabilities::from_bits_retain(be16(payload, 3)), + } + } +} + +/// Information about one zone, from +/// [`getZoneInfo`](super::ColorLedEffectsFeature::get_zone_info). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct ZoneInfo { + /// Index of the zone. + pub zone_index: u8, + /// Physical location the zone covers. + pub location: LocationEffect, + /// Number of effects the zone supports (iterate `0..effects_number` with + /// `getZoneEffectInfo`). + pub effects_number: u8, + /// Persistency capabilities of the zone. + pub persistency: PersistencyCapabilities, +} + +impl ZoneInfo { + pub(super) fn from_payload(payload: &[u8; 16]) -> Result { + Ok(Self { + zone_index: payload[0], + location: LocationEffect::try_from(be16(payload, 1)) + .map_err(|_| Hidpp20Error::UnsupportedResponse)?, + effects_number: payload[3], + persistency: PersistencyCapabilities::from_bits_retain(payload[4]), + }) + } +} + +/// Information about one effect of a zone, from +/// [`getZoneEffectInfo`](super::ColorLedEffectsFeature::get_zone_effect_info). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct ZoneEffectInfo { + /// Index of the zone. + pub zone_index: u8, + /// Index of the effect within the zone. + pub zone_effect_index: u8, + /// The effect type. + pub effect_id: EffectId, + /// Effect capability bitmask. The bit meanings depend on `effect_id`; a value + /// of `0` means the Raptor-compatibility defaults apply. + pub effect_capabilities: u16, + /// Effect period in milliseconds, or `0` when not available. + pub effect_period: u16, +} + +impl ZoneEffectInfo { + pub(super) fn from_payload(payload: &[u8; 16]) -> Result { + Ok(Self { + zone_index: payload[0], + zone_effect_index: payload[1], + effect_id: EffectId::try_from(be16(payload, 2)) + .map_err(|_| Hidpp20Error::UnsupportedResponse)?, + effect_capabilities: be16(payload, 4), + effect_period: be16(payload, 6), + }) + } +} + +/// Software-control state, from +/// [`getSwControl`](super::ColorLedEffectsFeature::get_sw_control). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct SwControlState { + /// Whether firmware or software owns the LEDs. + pub control: SwControl, + /// Whether the device emits sync-effect events. + pub sync_events: bool, +} + +/// Effect settings of a zone, from +/// [`getEffectSettings`](super::ColorLedEffectsFeature::get_effect_settings). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct EffectSettings { + /// Index of the zone. + pub zone_index: u8, + /// Effect color. + pub color: Rgb, + /// Effect period in milliseconds. + pub period: u16, + /// Effect brightness. + pub brightness: u8, + /// Effect-specific parameter. + pub param: u8, +} + +impl EffectSettings { + pub(super) fn from_payload(payload: &[u8; 16]) -> Self { + Self { + zone_index: payload[0], + color: Rgb { + red: payload[1], + green: payload[2], + blue: payload[3], + }, + period: be16(payload, 4), + brightness: payload[6], + param: payload[7], + } + } +} + +/// The configured effect of a zone, from +/// [`getZoneEffect`](super::ColorLedEffectsFeature::get_zone_effect). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct ZoneEffect { + /// Index of the zone. + pub zone_index: u8, + /// Index of the configured effect within the zone. + pub zone_effect_index: u8, + /// The effect parameters. Their meaning depends on the effect's + /// [`EffectId`]. + pub params: [u8; ZONE_EFFECT_PARAM_COUNT], +} + +impl ZoneEffect { + pub(super) fn from_payload(payload: &[u8; 16]) -> Self { + let mut params = [0; ZONE_EFFECT_PARAM_COUNT]; + params.copy_from_slice(&payload[2..2 + ZONE_EFFECT_PARAM_COUNT]); + Self { + zone_index: payload[0], + zone_effect_index: payload[1], + params, + } + } +} + +/// A non-volatile configuration entry, from +/// [`getNvConfig`](super::ColorLedEffectsFeature::get_nv_config). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct NvConfig { + /// The single capability bit this entry addresses. + pub capability: NvCapabilities, + /// The capability's state. + pub state: NvCapabilityState, + /// First capability-specific parameter. + pub param1: u8, + /// Second capability-specific parameter. + pub param2: u8, +} + +/// Manufacturing LED bin information, from +/// [`getLedBinInfo`](super::ColorLedEffectsFeature::get_led_bin_info). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct LedBinInfo { + /// Index of the zone. + pub zone_index: u8, + /// Which bin parameter this is. + pub led_bin_index: LedBinIndex, + /// Red bin value. + pub red: u16, + /// Green bin value. + pub green: u16, + /// Blue bin value. + pub blue: u16, + /// White bin value. + pub white: u16, +} + +impl LedBinInfo { + pub(super) fn from_payload(payload: &[u8; 16]) -> Result { + Ok(Self { + zone_index: payload[0], + led_bin_index: LedBinIndex::try_from(payload[1]) + .map_err(|_| Hidpp20Error::UnsupportedResponse)?, + red: be16(payload, 2), + green: be16(payload, 4), + blue: be16(payload, 6), + white: be16(payload, 8), + }) + } +} diff --git a/crates/openlogi-hidpp/src/feature/mod.rs b/crates/openlogi-hidpp/src/feature/mod.rs index e3cd9f9e..ae931198 100755 --- a/crates/openlogi-hidpp/src/feature/mod.rs +++ b/crates/openlogi-hidpp/src/feature/mod.rs @@ -12,6 +12,7 @@ pub mod adjustable_dpi; pub mod backlight; pub mod brightness_control; pub mod change_host; +pub mod color_led_effects; pub mod device_friendly_name; pub mod device_information; pub mod device_type_and_name; diff --git a/crates/openlogi-hidpp/src/feature/registry.rs b/crates/openlogi-hidpp/src/feature/registry.rs index e442da66..792a1b65 100755 --- a/crates/openlogi-hidpp/src/feature/registry.rs +++ b/crates/openlogi-hidpp/src/feature/registry.rs @@ -16,6 +16,7 @@ use crate::{ backlight::BacklightFeature, brightness_control::BrightnessControlFeature, change_host::ChangeHostFeature, + color_led_effects::ColorLedEffectsFeature, device_friendly_name::DeviceFriendlyNameFeature, device_information::DeviceInformationFeature, device_type_and_name::DeviceTypeAndNameFeature, @@ -224,7 +225,7 @@ static KNOWN_FEATURES: LazyLock> = LazyLock::new(|| { 0x8040 "BrightnessControl" => BrightnessControlFeature, 0x8060 "AdjustableReportRate" => ReportRateFeature, 0x8061 "ExtendedAdjustableReportRate" => ExtendedReportRateFeature, - 0x8070 "ColorLedEffects", + 0x8070 "ColorLedEffects" => ColorLedEffectsFeature, 0x8071 "RgbEffects", 0x8080 "PerKeyLighting", 0x8081 "PerKeyLighting2", From a78791acbcb7af1ed6a53da22adc4cc63a491879 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sat, 27 Jun 2026 19:00:22 +0800 Subject: [PATCH 08/21] feat(hidpp): add rgb effects and per-key lighting wrappers --- crates/openlogi-hidpp/src/feature/mod.rs | 2 + .../src/feature/per_key_lighting/mod.rs | 308 +++++++++++++++ .../src/feature/per_key_lighting/tests.rs | 144 +++++++ crates/openlogi-hidpp/src/feature/registry.rs | 6 +- .../src/feature/rgb_effects/event.rs | 70 ++++ .../src/feature/rgb_effects/mod.rs | 373 ++++++++++++++++++ .../src/feature/rgb_effects/tests.rs | 158 ++++++++ .../src/feature/rgb_effects/types.rs | 347 ++++++++++++++++ 8 files changed, 1406 insertions(+), 2 deletions(-) create mode 100644 crates/openlogi-hidpp/src/feature/per_key_lighting/mod.rs create mode 100644 crates/openlogi-hidpp/src/feature/per_key_lighting/tests.rs create mode 100644 crates/openlogi-hidpp/src/feature/rgb_effects/event.rs create mode 100644 crates/openlogi-hidpp/src/feature/rgb_effects/mod.rs create mode 100644 crates/openlogi-hidpp/src/feature/rgb_effects/tests.rs create mode 100644 crates/openlogi-hidpp/src/feature/rgb_effects/types.rs diff --git a/crates/openlogi-hidpp/src/feature/mod.rs b/crates/openlogi-hidpp/src/feature/mod.rs index ae931198..7958f37c 100755 --- a/crates/openlogi-hidpp/src/feature/mod.rs +++ b/crates/openlogi-hidpp/src/feature/mod.rs @@ -29,9 +29,11 @@ pub mod illumination; pub mod mode_status; pub mod mouse_pointer; pub mod multi_platform; +pub mod per_key_lighting; pub mod registry; pub mod report_rate; pub mod reprog_controls; +pub mod rgb_effects; pub mod root; pub mod sidetone; pub mod smartshift; diff --git a/crates/openlogi-hidpp/src/feature/per_key_lighting/mod.rs b/crates/openlogi-hidpp/src/feature/per_key_lighting/mod.rs new file mode 100644 index 00000000..9c223cfd --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/per_key_lighting/mod.rs @@ -0,0 +1,308 @@ +//! Implements the `PerKeyLighting` feature (ID `0x8081`, version 0) that sets +//! individual RGB zones (typically per-key) on a keyboard. +//! +//! Zone updates are staged with the various `set_*_rgb_zones` functions and then +//! committed as a frame with [`frame_end`](PerKeyLightingFeature::frame_end). +//! Several setters trade addressing flexibility for the number of zones updated +//! per request; the delta-compression variants pack the most zones by sending +//! signed per-channel deltas from the previous frame. + +#[cfg(test)] +mod tests; + +use std::sync::Arc; + +use num_enum::{IntoPrimitive, TryFromPrimitive}; + +use crate::{ + channel::HidppChannel, + feature::{CreatableFeature, Feature, FeatureEndpoint}, + protocol::v20::Hidpp20Error, +}; + +/// Length of the zone-presence bitfield page returned by `getInfo`. +pub const ZONE_PRESENCE_PAGE_LEN: usize = 14; +/// Length of the packed payload for the delta-compression setters. +pub const DELTA_PACKED_LEN: usize = 15; +/// `typeOfInfo` value selecting the zone-presence query. +const TYPE_RGB_ZONE_PRESENCE: u8 = 0x00; +/// Maximum zones per `setIndividualRgbZones` request. +const MAX_INDIVIDUAL_ZONES: usize = 4; +/// Number of zones per `setConsecutiveRgbZones` request. +const CONSECUTIVE_ZONES: usize = 5; +/// Maximum ranges per `setRangeRgbZones` request. +const MAX_RANGES: usize = 3; +/// Maximum zones per `setRgbZonesSingleValue` request. +const MAX_SINGLE_VALUE_ZONES: usize = 13; + +/// An 8-bit-per-channel RGB color. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub struct Rgb { + /// Red channel. + pub red: u8, + /// Green channel. + pub green: u8, + /// Blue channel. + pub blue: u8, +} + +/// A single zone and the color to apply to it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub struct RgbZone { + /// Zone identifier (`0` and `255` are reserved end-of-list sentinels). + pub zone_id: u8, + /// Color to apply. + pub color: Rgb, +} + +/// A contiguous range of zones to fill with one color. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub struct RgbZoneRange { + /// First zone identifier in the range (inclusive). + pub first_zone_id: u8, + /// Last zone identifier in the range (inclusive). + pub last_zone_id: u8, + /// Color to apply across the range. + pub color: Rgb, +} + +/// Which page of zone IDs a presence query covers. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum ZonePresencePage { + /// Zone IDs 0..=111. + Zones0To111 = 0, + /// Zone IDs 112..=223. + Zones112To223 = 1, + /// Zone IDs 224..=255. + Zones224To255 = 2, +} + +/// Storage persistence for [`frame_end`](PerKeyLightingFeature::frame_end). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum FramePersistence { + /// Volatile: applied to RAM only. + Volatile = 0, + /// Applied to RAM and stored in EEPROM. + VolatileAndNonVolatile = 1, +} + +/// Implements the `PerKeyLighting` / `0x8081` feature. +#[derive(Clone)] +pub struct PerKeyLightingFeature { + /// The endpoint this feature talks to. + endpoint: FeatureEndpoint, +} + +impl CreatableFeature for PerKeyLightingFeature { + const ID: u16 = 0x8081; + const STARTING_VERSION: u8 = 0; + + fn new(chan: Arc, device_index: u8, feature_index: u8) -> Self { + Self { + endpoint: FeatureEndpoint::new(chan, device_index, feature_index), + } + } +} + +impl Feature for PerKeyLightingFeature {} + +impl PerKeyLightingFeature { + /// Retrieves a page of the RGB zone-presence bitfield. + /// + /// The returned 14 bytes form a 112-bit field; bit `i` (LSB-first within each + /// byte) reports whether the zone at `page` base `+ i` is present. + pub async fn get_rgb_zone_presence( + &self, + page: ZonePresencePage, + ) -> Result<[u8; ZONE_PRESENCE_PAGE_LEN], Hidpp20Error> { + let payload = self + .endpoint + .call(0, [TYPE_RGB_ZONE_PRESENCE, page.into(), 0]) + .await? + .extend_payload(); + let mut bitfield = [0; ZONE_PRESENCE_PAGE_LEN]; + bitfield.copy_from_slice(&payload[2..2 + ZONE_PRESENCE_PAGE_LEN]); + Ok(bitfield) + } + + /// Sets up to four individually addressed zones. + /// + /// At most four zones are sent; extra entries are ignored. + pub async fn set_individual_rgb_zones(&self, zones: &[RgbZone]) -> Result<(), Hidpp20Error> { + self.endpoint + .call_long(1, individual_zones_args(zones)) + .await?; + Ok(()) + } + + /// Sets five consecutive zones starting at `first_zone_id`. + pub async fn set_consecutive_rgb_zones( + &self, + first_zone_id: u8, + colors: [Rgb; CONSECUTIVE_ZONES], + ) -> Result<(), Hidpp20Error> { + self.endpoint + .call_long(2, consecutive_zones_args(first_zone_id, colors)) + .await?; + Ok(()) + } + + /// Sets eight consecutive zones from `first_zone_id` using 5-bit signed + /// per-channel deltas. + /// + /// `packed` carries the 8×3 5-bit deltas packed MSB-first, zone-by-zone then + /// channel-by-channel, exactly as defined by the feature spec; this wrapper + /// transmits it verbatim. + pub async fn set_consecutive_rgb_zones_delta_5bit( + &self, + first_zone_id: u8, + packed: [u8; DELTA_PACKED_LEN], + ) -> Result<(), Hidpp20Error> { + self.send_delta(3, first_zone_id, packed).await + } + + /// Sets ten consecutive zones from `first_zone_id` using 4-bit signed + /// per-channel deltas. + /// + /// `packed` carries the 10×3 4-bit signed deltas, two per byte (high nibble + /// first), as defined by the feature spec; this wrapper transmits it verbatim. + pub async fn set_consecutive_rgb_zones_delta_4bit( + &self, + first_zone_id: u8, + packed: [u8; DELTA_PACKED_LEN], + ) -> Result<(), Hidpp20Error> { + self.send_delta(4, first_zone_id, packed).await + } + + /// Sets up to three independent ranges, each filled with one color. + /// + /// At most three ranges are sent; extra entries are ignored. + pub async fn set_range_rgb_zones(&self, ranges: &[RgbZoneRange]) -> Result<(), Hidpp20Error> { + self.endpoint.call_long(5, range_zones_args(ranges)).await?; + Ok(()) + } + + /// Applies one color to up to thirteen individually addressed zones. + /// + /// At most thirteen zone IDs are sent; extra entries are ignored. + pub async fn set_rgb_zones_single_value( + &self, + color: Rgb, + zone_ids: &[u8], + ) -> Result<(), Hidpp20Error> { + self.endpoint + .call_long(6, single_value_args(color, zone_ids)) + .await?; + Ok(()) + } + + /// Commits all pending zone changes and updates the display. + /// + /// `current_frame` and `frames_till_next_change` drive frame animations; pass + /// `0` for both for a one-shot update. + pub async fn frame_end( + &self, + persistence: FramePersistence, + current_frame: u16, + frames_till_next_change: u16, + ) -> Result<(), Hidpp20Error> { + let args = frame_end_args(persistence, current_frame, frames_till_next_change); + self.endpoint.call_long(7, args).await?; + Ok(()) + } + + /// Shared body of the delta-compression setters. + async fn send_delta( + &self, + function: u8, + first_zone_id: u8, + packed: [u8; DELTA_PACKED_LEN], + ) -> Result<(), Hidpp20Error> { + self.endpoint + .call_long(function, delta_args(first_zone_id, packed)) + .await?; + Ok(()) + } +} + +/// Encodes a `setIndividualRgbZones` request. +fn individual_zones_args(zones: &[RgbZone]) -> [u8; 16] { + let mut args = [0; 16]; + for (slot, zone) in zones.iter().take(MAX_INDIVIDUAL_ZONES).enumerate() { + let base = slot * 4; + args[base] = zone.zone_id; + args[base + 1] = zone.color.red; + args[base + 2] = zone.color.green; + args[base + 3] = zone.color.blue; + } + args +} + +/// Encodes a `setConsecutiveRgbZones` request. +fn consecutive_zones_args(first_zone_id: u8, colors: [Rgb; CONSECUTIVE_ZONES]) -> [u8; 16] { + let mut args = [0; 16]; + args[0] = first_zone_id; + for (i, color) in colors.iter().enumerate() { + let base = 1 + i * 3; + args[base] = color.red; + args[base + 1] = color.green; + args[base + 2] = color.blue; + } + args +} + +/// Encodes a `setRangeRgbZones` request. +fn range_zones_args(ranges: &[RgbZoneRange]) -> [u8; 16] { + let mut args = [0; 16]; + for (slot, range) in ranges.iter().take(MAX_RANGES).enumerate() { + let base = slot * 5; + args[base] = range.first_zone_id; + args[base + 1] = range.last_zone_id; + args[base + 2] = range.color.red; + args[base + 3] = range.color.green; + args[base + 4] = range.color.blue; + } + args +} + +/// Encodes a `setRgbZonesSingleValue` request. +fn single_value_args(color: Rgb, zone_ids: &[u8]) -> [u8; 16] { + let mut args = [0; 16]; + args[0] = color.red; + args[1] = color.green; + args[2] = color.blue; + for (i, &zone_id) in zone_ids.iter().take(MAX_SINGLE_VALUE_ZONES).enumerate() { + args[3 + i] = zone_id; + } + args +} + +/// Encodes a `frameEnd` request. +fn frame_end_args( + persistence: FramePersistence, + current_frame: u16, + frames_till_next_change: u16, +) -> [u8; 16] { + let [frame_hi, frame_lo] = current_frame.to_be_bytes(); + let [next_hi, next_lo] = frames_till_next_change.to_be_bytes(); + let mut args = [0; 16]; + args[..5].copy_from_slice(&[persistence.into(), frame_hi, frame_lo, next_hi, next_lo]); + args +} + +/// Encodes a delta-compression request body (`first_zone_id` + packed deltas). +fn delta_args(first_zone_id: u8, packed: [u8; DELTA_PACKED_LEN]) -> [u8; 16] { + let mut args = [0; 16]; + args[0] = first_zone_id; + args[1..1 + DELTA_PACKED_LEN].copy_from_slice(&packed); + args +} diff --git a/crates/openlogi-hidpp/src/feature/per_key_lighting/tests.rs b/crates/openlogi-hidpp/src/feature/per_key_lighting/tests.rs new file mode 100644 index 00000000..a237ea3b --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/per_key_lighting/tests.rs @@ -0,0 +1,144 @@ +//! Unit tests for `PerKeyLighting` request encoding. + +use super::{ + FramePersistence, Rgb, RgbZone, RgbZoneRange, ZonePresencePage, consecutive_zones_args, + delta_args, frame_end_args, individual_zones_args, range_zones_args, single_value_args, +}; + +const RED: Rgb = Rgb { + red: 0xff, + green: 0, + blue: 0, +}; + +#[test] +fn encodes_individual_zones() { + let args = individual_zones_args(&[ + RgbZone { + zone_id: 5, + color: RED, + }, + RgbZone { + zone_id: 9, + color: Rgb { + red: 1, + green: 2, + blue: 3, + }, + }, + ]); + assert_eq!(args[0..4], [5, 0xff, 0, 0]); + assert_eq!(args[4..8], [9, 1, 2, 3]); + // Unused slots stay zero (the zone-id sentinel). + assert_eq!(args[8..16], [0; 8]); +} + +#[test] +fn individual_zones_caps_at_four() { + let zones = [RgbZone { + zone_id: 1, + color: RED, + }; 6]; + let args = individual_zones_args(&zones); + // Only four slots (16 bytes) are produced; the 5th/6th are dropped. + assert_eq!(args[12..16], [1, 0xff, 0, 0]); +} + +#[test] +fn encodes_consecutive_zones() { + let colors = [ + Rgb { + red: 1, + green: 2, + blue: 3, + }, + Rgb { + red: 4, + green: 5, + blue: 6, + }, + Rgb { + red: 7, + green: 8, + blue: 9, + }, + Rgb { + red: 10, + green: 11, + blue: 12, + }, + Rgb { + red: 13, + green: 14, + blue: 15, + }, + ]; + let args = consecutive_zones_args(20, colors); + assert_eq!(args[0], 20); + assert_eq!(args[1..4], [1, 2, 3]); + assert_eq!(args[13..16], [13, 14, 15]); +} + +#[test] +fn encodes_range_zones() { + let args = range_zones_args(&[ + RgbZoneRange { + first_zone_id: 1, + last_zone_id: 5, + color: RED, + }, + RgbZoneRange { + first_zone_id: 10, + last_zone_id: 12, + color: Rgb { + red: 0, + green: 0xff, + blue: 0, + }, + }, + ]); + assert_eq!(args[0..5], [1, 5, 0xff, 0, 0]); + assert_eq!(args[5..10], [10, 12, 0, 0xff, 0]); +} + +#[test] +fn encodes_single_value_zones() { + let args = single_value_args( + Rgb { + red: 0x10, + green: 0x20, + blue: 0x30, + }, + &[1, 2, 3], + ); + assert_eq!(args[0..3], [0x10, 0x20, 0x30]); + assert_eq!(args[3..6], [1, 2, 3]); + assert_eq!(args[6], 0); +} + +#[test] +fn encodes_frame_end_big_endian() { + let args = frame_end_args(FramePersistence::VolatileAndNonVolatile, 0x0102, 0x0304); + assert_eq!(args[0], 1); + assert_eq!(args[1..3], [0x01, 0x02]); + assert_eq!(args[3..5], [0x03, 0x04]); +} + +#[test] +fn encodes_delta_payload_verbatim() { + let packed = [0xaa; 15]; + let args = delta_args(7, packed); + assert_eq!(args[0], 7); + assert_eq!(args[1..16], [0xaa; 15]); +} + +#[test] +fn maps_enum_wire_values() { + assert_eq!(u8::from(ZonePresencePage::Zones112To223), 1); + assert_eq!( + ZonePresencePage::try_from(2u8).unwrap(), + ZonePresencePage::Zones224To255 + ); + assert!(ZonePresencePage::try_from(3u8).is_err()); + assert_eq!(u8::from(FramePersistence::VolatileAndNonVolatile), 1); +} diff --git a/crates/openlogi-hidpp/src/feature/registry.rs b/crates/openlogi-hidpp/src/feature/registry.rs index 792a1b65..2ec88224 100755 --- a/crates/openlogi-hidpp/src/feature/registry.rs +++ b/crates/openlogi-hidpp/src/feature/registry.rs @@ -33,8 +33,10 @@ use crate::{ mode_status::ModeStatusFeature, mouse_pointer::MousePointerFeature, multi_platform::MultiPlatformFeature, + per_key_lighting::PerKeyLightingFeature, report_rate::ReportRateFeature, reprog_controls::ReprogControlsFeature, + rgb_effects::RgbEffectsFeature, root::RootFeature, sidetone::SidetoneFeature, smartshift::SmartShiftFeature, @@ -226,9 +228,9 @@ static KNOWN_FEATURES: LazyLock> = LazyLock::new(|| { 0x8060 "AdjustableReportRate" => ReportRateFeature, 0x8061 "ExtendedAdjustableReportRate" => ExtendedReportRateFeature, 0x8070 "ColorLedEffects" => ColorLedEffectsFeature, - 0x8071 "RgbEffects", + 0x8071 "RgbEffects" => RgbEffectsFeature, 0x8080 "PerKeyLighting", - 0x8081 "PerKeyLighting2", + 0x8081 "PerKeyLighting2" => PerKeyLightingFeature, 0x8090 "ModeStatus" => ModeStatusFeature, 0x8100 "OnboardProfiles", 0x8110 "MouseButtonFilter", diff --git a/crates/openlogi-hidpp/src/feature/rgb_effects/event.rs b/crates/openlogi-hidpp/src/feature/rgb_effects/event.rs new file mode 100644 index 00000000..184cc882 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/rgb_effects/event.rs @@ -0,0 +1,70 @@ +//! Events emitted by the `RgbEffects` feature (`0x8071`). + +use super::types::{ + ActivityEventType, CLUSTER_EFFECT_PARAM_COUNT, PowerModeTarget, RgbPersistence, be16, +}; + +/// Bit offset of the power-mode target in the cluster-effect flags byte. +const POWER_TARGET_SHIFT: u8 = 2; +/// Mask of the 2-bit fields packed into the cluster-effect flags byte. +const FLAGS_FIELD_MASK: u8 = 0b11; + +/// An event emitted by [`RgbEffectsFeature`](super::RgbEffectsFeature). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub enum RgbEffectsEvent { + /// A period effect reached a synchronization point. + EffectSync { + /// Cluster the event applies to; `0xff` means all clusters. + cluster_index: u8, + /// Current timing position within the period, in milliseconds. + effect_counter: u16, + }, + /// User activity started or its absence timed out. + UserActivity(ActivityEventType), + /// A cluster's effect changed; mirrors a `setRgbClusterEffect` request. + ClusterChanged { + /// Index of the cluster. + cluster_index: u8, + /// Index of the effect within the cluster. + cluster_effect_index: u8, + /// The effect parameters (meaning depends on the effect). + params: [u8; CLUSTER_EFFECT_PARAM_COUNT], + /// Persistence the effect was applied with. + persistence: RgbPersistence, + /// Power-mode target the effect applies to. + power_mode: PowerModeTarget, + }, +} + +/// Decodes an unsolicited `0x8071` event payload by its sub-id. +pub(super) fn decode_event(sub_id: u8, payload: &[u8; 16]) -> Option { + match sub_id { + 0 => Some(RgbEffectsEvent::EffectSync { + cluster_index: payload[0], + effect_counter: be16(payload, 1), + }), + 1 => Some(RgbEffectsEvent::UserActivity( + ActivityEventType::try_from(payload[0]).ok()?, + )), + 2 => { + let mut params = [0; CLUSTER_EFFECT_PARAM_COUNT]; + params.copy_from_slice(&payload[2..2 + CLUSTER_EFFECT_PARAM_COUNT]); + // The flags byte mirrors setRgbClusterEffect: persistence in the low + // two bits, power-mode target in bits 2..=3. + let flags = payload[2 + CLUSTER_EFFECT_PARAM_COUNT]; + Some(RgbEffectsEvent::ClusterChanged { + cluster_index: payload[0], + cluster_effect_index: payload[1], + params, + persistence: RgbPersistence::from_bits_retain(flags & 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/mod.rs b/crates/openlogi-hidpp/src/feature/rgb_effects/mod.rs new file mode 100644 index 00000000..06327158 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/rgb_effects/mod.rs @@ -0,0 +1,373 @@ +//! Implements the `RgbEffects` feature (ID `0x8071`, version 4), the modern +//! per-cluster RGB effect engine (successor to +//! [`ColorLedEffects`](super::color_led_effects), `0x8070`). +//! +//! A device groups its LEDs into *clusters*, each supporting a set of *effects*. +//! [`get_device_info`](RgbEffectsFeature::get_device_info), +//! [`get_cluster_info`](RgbEffectsFeature::get_cluster_info) and +//! [`get_effect_info`](RgbEffectsFeature::get_effect_info) decode the three +//! general-info modes of the polymorphic `getInfo` function; effects are applied +//! with [`set_rgb_cluster_effect`](RgbEffectsFeature::set_rgb_cluster_effect). +//! +//! Software must first take control with +//! [`set_sw_control`](RgbEffectsFeature::set_sw_control) before applying effects +//! or power modes, or those calls return a "not allowed" error. +//! +//! All multi-byte fields in this feature are big-endian. + +pub mod event; +pub mod types; + +#[cfg(test)] +mod tests; + +use std::sync::Arc; + +pub use event::RgbEffectsEvent; +pub use types::{ + ActivityEventType, CLUSTER_EFFECT_PARAM_COUNT, DisplayPersistencyCapabilities, + EventsNotificationFlags, LED_BIN_PARAM_COUNT, LedBinIndex, ONBOARD_INFO_PARAM_COUNT, + PowerModeTarget, RgbClusterInfo, RgbDeviceInfo, RgbEffectInfo, RgbExtCapabilities, + RgbNvCapabilities, RgbNvConfig, RgbPersistence, RgbPowerMode, RgbPowerModeConfig, RgbSwControl, + SlotInfoType, SwControlFlags, +}; + +use self::types::{ALL_CLUSTERS, ALL_EFFECTS, GetOrSet, be16}; +use crate::{ + channel::{HidppChannel, MessageListenerGuard}, + event::EventEmitter, + feature::{CreatableFeature, EmittingFeature, Feature, FeatureEndpoint, event_payload}, + protocol::v20::Hidpp20Error, +}; + +/// `typeOfInfo` value selecting general info in `getInfo`. +const TYPE_GENERAL_INFO: u8 = 0x00; +/// `typeOfInfo` value selecting onboard-stored effect info in `getInfo`. +const TYPE_ONBOARD_EFFECT: u8 = 0x01; +/// `getOrSet` value requesting a backup read in `manageRgbLedBinInfo`. +const GET_BACKUP: u8 = 0x02; +/// Bit offset of the power-mode target in the `setRgbClusterEffect` flags byte. +const POWER_TARGET_SHIFT: u8 = 2; + +/// Implements the `RgbEffects` / `0x8071` feature. +pub struct RgbEffectsFeature { + /// The endpoint this feature talks to. + endpoint: FeatureEndpoint, + + /// The emitter used to publish decoded events. + emitter: Arc>, + + /// Removes the message listener when the feature is dropped. + _msg_listener: MessageListenerGuard, +} + +impl CreatableFeature for RgbEffectsFeature { + const ID: u16 = 0x8071; + const STARTING_VERSION: u8 = 0; + + fn new(chan: Arc, device_index: u8, feature_index: u8) -> Self { + let emitter = Arc::new(EventEmitter::new()); + + let listener = chan.add_msg_listener_guarded({ + let emitter = Arc::clone(&emitter); + + move |raw, matched| { + let Some((func, payload)) = + event_payload(raw, matched, device_index, feature_index) + else { + return; + }; + if let Some(event) = event::decode_event(func.to_lo(), &payload) { + emitter.emit(event); + } + } + }); + + Self { + endpoint: FeatureEndpoint::new(chan, device_index, feature_index), + emitter, + _msg_listener: listener, + } + } +} + +impl Feature for RgbEffectsFeature {} + +impl EmittingFeature for RgbEffectsFeature { + fn listen(&self) -> async_channel::Receiver { + self.emitter.create_receiver() + } +} + +impl RgbEffectsFeature { + /// Retrieves device-level RGB information (`getInfo` device mode). + pub async fn get_device_info(&self) -> Result { + let payload = self + .endpoint + .call(0, [ALL_CLUSTERS, ALL_EFFECTS, TYPE_GENERAL_INFO]) + .await? + .extend_payload(); + Ok(RgbDeviceInfo::from_payload(&payload)) + } + + /// Retrieves cluster-level information for `cluster_index` (`getInfo` cluster + /// mode). + pub async fn get_cluster_info( + &self, + cluster_index: u8, + ) -> Result { + let payload = self + .endpoint + .call(0, [cluster_index, ALL_EFFECTS, TYPE_GENERAL_INFO]) + .await? + .extend_payload(); + Ok(RgbClusterInfo::from_payload(&payload)) + } + + /// Retrieves effect-level information for an effect of a cluster (`getInfo` + /// effect mode). + pub async fn get_effect_info( + &self, + cluster_index: u8, + cluster_effect_index: u8, + ) -> Result { + let payload = self + .endpoint + .call(0, [cluster_index, cluster_effect_index, TYPE_GENERAL_INFO]) + .await? + .extend_payload(); + Ok(RgbEffectInfo::from_payload(&payload)) + } + + /// Retrieves raw information about an onboard-stored effect slot. + /// + /// The returned parameters' meaning depends on `slot_info_type` (see the + /// feature spec): e.g. slot state, defaults, UUID bytes, or effect-name + /// characters. + pub async fn get_onboard_effect_info( + &self, + cluster_index: u8, + cluster_effect_index: u8, + slot: u8, + slot_info_type: SlotInfoType, + ) -> Result<[u8; ONBOARD_INFO_PARAM_COUNT], Hidpp20Error> { + let mut args = [0; 16]; + args[..5].copy_from_slice(&[ + cluster_index, + cluster_effect_index, + TYPE_ONBOARD_EFFECT, + slot, + slot_info_type.into(), + ]); + let payload = self.endpoint.call_long(0, args).await?.extend_payload(); + let mut params = [0; ONBOARD_INFO_PARAM_COUNT]; + params.copy_from_slice(&payload[3..3 + ONBOARD_INFO_PARAM_COUNT]); + Ok(params) + } + + /// Applies effect `cluster_effect_index` to `cluster_index`. + /// + /// `params` are effect-specific (discoverable via [`Self::get_effect_info`]). + /// `persistence` controls volatile/non-volatile storage and `power_mode` + /// selects which power mode the effect applies to. Requires software control + /// (see [`Self::set_sw_control`]). + pub async fn set_rgb_cluster_effect( + &self, + cluster_index: u8, + cluster_effect_index: u8, + params: [u8; CLUSTER_EFFECT_PARAM_COUNT], + persistence: RgbPersistence, + power_mode: PowerModeTarget, + ) -> Result<(), Hidpp20Error> { + let mut args = [0; 16]; + args[0] = cluster_index; + args[1] = cluster_effect_index; + args[2..2 + CLUSTER_EFFECT_PARAM_COUNT].copy_from_slice(¶ms); + args[12] = persistence.bits() | (u8::from(power_mode) << POWER_TARGET_SHIFT); + self.endpoint.call_long(1, args).await?; + Ok(()) + } + + /// Sets the multi-LED pattern of `cluster_index`. + pub async fn set_multi_led_cluster_pattern( + &self, + cluster_index: u8, + pattern: u8, + ) -> Result<(), Hidpp20Error> { + self.endpoint.call(2, [cluster_index, pattern, 0]).await?; + Ok(()) + } + + /// Reads one non-volatile configuration `capability`. + pub async fn get_nv_config( + &self, + capability: RgbNvCapabilities, + ) -> Result { + let [cap_hi, cap_lo] = capability.bits().to_be_bytes(); + let payload = self + .endpoint + .call(3, [GetOrSet::Get.into(), cap_hi, cap_lo]) + .await? + .extend_payload(); + Ok(RgbNvConfig { + capability: RgbNvCapabilities::from_bits_retain(be16(&payload, 1)), + state: payload[3], + param1: payload[4], + param2: payload[5], + }) + } + + /// Writes one non-volatile configuration entry (to EEPROM). + pub async fn set_nv_config( + &self, + capability: RgbNvCapabilities, + state: u8, + param1: u8, + param2: u8, + ) -> Result<(), Hidpp20Error> { + let [cap_hi, cap_lo] = capability.bits().to_be_bytes(); + let mut args = [0; 16]; + args[..6].copy_from_slice(&[GetOrSet::Set.into(), cap_hi, cap_lo, state, param1, param2]); + self.endpoint.call_long(3, args).await?; + Ok(()) + } + + /// Reads raw manufacturing LED bin parameters. + /// + /// `backup` reads the backup copy instead of the active one. + pub async fn get_led_bin_info( + &self, + cluster_index: u8, + led_bin_index: LedBinIndex, + backup: bool, + ) -> Result<[u8; LED_BIN_PARAM_COUNT], Hidpp20Error> { + let get_or_set = if backup { + GET_BACKUP + } else { + GetOrSet::Get.into() + }; + let payload = self + .endpoint + .call(4, [get_or_set, cluster_index, led_bin_index.into()]) + .await? + .extend_payload(); + let mut params = [0; LED_BIN_PARAM_COUNT]; + params.copy_from_slice(&payload[3..3 + LED_BIN_PARAM_COUNT]); + Ok(params) + } + + /// Stores raw manufacturing LED bin parameters. + pub async fn set_led_bin_info( + &self, + cluster_index: u8, + led_bin_index: LedBinIndex, + params: [u8; LED_BIN_PARAM_COUNT], + ) -> Result<(), Hidpp20Error> { + let mut args = [0; 16]; + args[0] = GetOrSet::Set.into(); + args[1] = cluster_index; + args[2] = led_bin_index.into(); + args[3..3 + LED_BIN_PARAM_COUNT].copy_from_slice(¶ms); + self.endpoint.call_long(4, args).await?; + Ok(()) + } + + /// Retrieves the software-control and event-notification flags. + pub async fn get_sw_control(&self) -> Result { + let payload = self + .endpoint + .call(5, [GetOrSet::Get.into(), 0, 0]) + .await? + .extend_payload(); + Ok(RgbSwControl { + control: SwControlFlags::from_bits_retain(payload[1]), + events: EventsNotificationFlags::from_bits_retain(payload[2]), + }) + } + + /// Sets the software-control and event-notification flags. + pub async fn set_sw_control( + &self, + control: SwControlFlags, + events: EventsNotificationFlags, + ) -> Result<(), Hidpp20Error> { + self.endpoint + .call(5, [GetOrSet::Set.into(), control.bits(), events.bits()]) + .await?; + Ok(()) + } + + /// Applies an effect-sync `drift_value` (milliseconds) correction. + /// + /// A `cluster_index` of `0xff` targets all clusters. + pub async fn set_effect_sync_correction( + &self, + cluster_index: u8, + drift_value: i16, + ) -> Result<(), Hidpp20Error> { + let [drift_hi, drift_lo] = drift_value.to_be_bytes(); + let mut args = [0; 16]; + args[..4].copy_from_slice(&[cluster_index, 0, drift_hi, drift_lo]); + self.endpoint.call_long(6, args).await?; + Ok(()) + } + + /// Retrieves the RGB power-mode configuration. + pub async fn get_power_mode_config(&self) -> Result { + let payload = self + .endpoint + .call(7, [GetOrSet::Get.into(), 0, 0]) + .await? + .extend_payload(); + Ok(RgbPowerModeConfig::from_payload(&payload)) + } + + /// Writes the RGB power-mode configuration. + pub async fn set_power_mode_config( + &self, + config: RgbPowerModeConfig, + ) -> Result<(), Hidpp20Error> { + let [flags_hi, flags_lo] = config.flags.to_be_bytes(); + let [psave_hi, psave_lo] = config.no_activity_timeout_to_power_save.to_be_bytes(); + let [off_hi, off_lo] = config.no_activity_timeout_to_off.to_be_bytes(); + let mut args = [0; 16]; + args[..7].copy_from_slice(&[ + GetOrSet::Set.into(), + flags_hi, + flags_lo, + psave_hi, + psave_lo, + off_hi, + off_lo, + ]); + self.endpoint.call_long(7, args).await?; + Ok(()) + } + + /// Retrieves the current RGB power mode. + pub async fn get_power_mode(&self) -> Result { + let payload = self + .endpoint + .call(8, [GetOrSet::Get.into(), 0, 0]) + .await? + .extend_payload(); + RgbPowerMode::try_from(payload[1]).map_err(|_| Hidpp20Error::UnsupportedResponse) + } + + /// Sets the RGB power mode. Requires software control of power modes (see + /// [`Self::set_sw_control`]). + pub async fn set_power_mode(&self, mode: RgbPowerMode) -> Result<(), Hidpp20Error> { + self.endpoint + .call(8, [GetOrSet::Set.into(), mode.into(), 0]) + .await?; + Ok(()) + } + + /// Shuts down the RGB system. + /// + /// Requires [`RgbExtCapabilities::SHUTDOWN`]. + pub async fn shutdown(&self) -> Result<(), Hidpp20Error> { + self.endpoint.call(9, [0; 3]).await?; + Ok(()) + } +} diff --git a/crates/openlogi-hidpp/src/feature/rgb_effects/tests.rs b/crates/openlogi-hidpp/src/feature/rgb_effects/tests.rs new file mode 100644 index 00000000..1f125484 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/rgb_effects/tests.rs @@ -0,0 +1,158 @@ +//! Unit tests for `RgbEffects` payload parsing and event decoding. + +use super::event::{RgbEffectsEvent, decode_event}; +use super::types::{ + ActivityEventType, DisplayPersistencyCapabilities, LedBinIndex, PowerModeTarget, + RgbClusterInfo, RgbDeviceInfo, RgbEffectInfo, RgbExtCapabilities, RgbNvCapabilities, + RgbPersistence, RgbPowerMode, RgbPowerModeConfig, SlotInfoType, +}; + +#[test] +fn parses_device_info() { + let mut payload = [0; 16]; + payload[0] = 0xff; + payload[1] = 0xff; + payload[2] = 2; // cluster count + payload[3..5].copy_from_slice(&0x0001u16.to_be_bytes()); // bootUp + payload[5..7].copy_from_slice(&0x0021u16.to_be_bytes()); // getZoneEffect + shutdown + payload[7] = 3; // multicluster effects + + let info = RgbDeviceInfo::from_payload(&payload); + assert_eq!(info.cluster_count, 2); + assert!( + info.nv_capabilities + .contains(RgbNvCapabilities::BOOT_UP_EFFECT) + ); + assert!( + info.ext_capabilities + .contains(RgbExtCapabilities::GET_ZONE_EFFECT) + ); + assert!(info.ext_capabilities.contains(RgbExtCapabilities::SHUTDOWN)); + assert_eq!(info.multicluster_effect_count, 3); +} + +#[test] +fn parses_cluster_info() { + let mut payload = [0; 16]; + payload[0] = 1; + payload[2..4].copy_from_slice(&2u16.to_be_bytes()); // location = Logo + payload[4] = 5; // effects + payload[5] = 0b101; // always_on + on_then_off + payload[6] = 1; // effect persistency supported + payload[7] = 0; // no multiled pattern + + let info = RgbClusterInfo::from_payload(&payload); + assert_eq!(info.cluster_index, 1); + assert_eq!(info.location, 2); + assert_eq!(info.effects_number, 5); + assert!( + info.display_persistency + .contains(DisplayPersistencyCapabilities::ALWAYS_ON) + ); + assert!( + info.display_persistency + .contains(DisplayPersistencyCapabilities::ON_THEN_OFF) + ); + assert!(info.effect_persistency); + assert!(!info.multiled_pattern); +} + +#[test] +fn parses_effect_info() { + let mut payload = [0; 16]; + payload[0] = 0; + payload[1] = 1; + payload[2..4].copy_from_slice(&4u16.to_be_bytes()); // ColorWave + payload[4..6].copy_from_slice(&0x0007u16.to_be_bytes()); + payload[6..8].copy_from_slice(&500u16.to_be_bytes()); + + let info = RgbEffectInfo::from_payload(&payload); + assert_eq!(info.cluster_effect_index, 1); + assert_eq!(info.effect_id, 4); + assert_eq!(info.effect_capabilities, 0x0007); + assert_eq!(info.effect_period, 500); +} + +#[test] +fn parses_power_mode_config() { + let mut payload = [0; 16]; + payload[0] = 0; // getOrSet echo + payload[1..3].copy_from_slice(&0x0003u16.to_be_bytes()); + payload[3..5].copy_from_slice(&60u16.to_be_bytes()); + payload[5..7].copy_from_slice(&300u16.to_be_bytes()); + + let config = RgbPowerModeConfig::from_payload(&payload); + assert_eq!(config.flags, 0x0003); + assert_eq!(config.no_activity_timeout_to_power_save, 60); + assert_eq!(config.no_activity_timeout_to_off, 300); +} + +#[test] +fn decodes_effect_sync_event() { + let mut payload = [0; 16]; + payload[0] = 0xff; + payload[1..3].copy_from_slice(&2000u16.to_be_bytes()); + + assert_eq!( + decode_event(0, &payload), + Some(RgbEffectsEvent::EffectSync { + cluster_index: 0xff, + effect_counter: 2000, + }) + ); +} + +#[test] +fn decodes_user_activity_event() { + let mut payload = [0; 16]; + payload[0] = 1; + assert_eq!( + decode_event(1, &payload), + Some(RgbEffectsEvent::UserActivity( + ActivityEventType::UserActivityDetected + )) + ); +} + +#[test] +fn decodes_cluster_changed_event() { + let mut payload = [0; 16]; + payload[0] = 1; + payload[1] = 2; + payload[2..12].copy_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + // Flags byte: persistence = VOLATILE (bit0), power-mode target = PowerSave (bit2). + payload[12] = 0b101; + + assert_eq!( + decode_event(2, &payload), + Some(RgbEffectsEvent::ClusterChanged { + cluster_index: 1, + cluster_effect_index: 2, + params: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + persistence: RgbPersistence::VOLATILE, + power_mode: PowerModeTarget::PowerSave, + }) + ); +} + +#[test] +fn ignores_unknown_event_sub_id() { + assert!(decode_event(7, &[0; 16]).is_none()); +} + +#[test] +fn maps_stable_enum_wire_values() { + assert_eq!(RgbPowerMode::try_from(1u8).unwrap(), RgbPowerMode::FullRgb); + assert_eq!(RgbPowerMode::try_from(3u8).unwrap(), RgbPowerMode::PowerOff); + assert!(RgbPowerMode::try_from(0u8).is_err()); + assert_eq!(u8::from(PowerModeTarget::PowerSave), 1); + assert_eq!( + LedBinIndex::try_from(2u8).unwrap(), + LedBinIndex::CalibrationFactors + ); + assert_eq!( + SlotInfoType::try_from(6u8).unwrap(), + SlotInfoType::EffectName21To31 + ); + assert!(SlotInfoType::try_from(7u8).is_err()); +} diff --git a/crates/openlogi-hidpp/src/feature/rgb_effects/types.rs b/crates/openlogi-hidpp/src/feature/rgb_effects/types.rs new file mode 100644 index 00000000..e715558a --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/rgb_effects/types.rs @@ -0,0 +1,347 @@ +//! Domain types for the `RgbEffects` feature (`0x8071`). + +use num_enum::{IntoPrimitive, TryFromPrimitive}; + +/// Number of effect parameters carried by `setRgbClusterEffect`. +pub const CLUSTER_EFFECT_PARAM_COUNT: usize = 10; +/// Number of raw parameters returned for onboard-stored effect info. +pub const ONBOARD_INFO_PARAM_COUNT: usize = 13; +/// Number of raw parameters carried by the LED-bin functions. +pub const LED_BIN_PARAM_COUNT: usize = 8; + +/// `0xFF` cluster index — refers to all clusters / the multi-cluster context. +pub const ALL_CLUSTERS: u8 = 0xff; +/// `0xFF` effect index — queries the cluster or device level in `getInfo`. +pub const ALL_EFFECTS: u8 = 0xff; + +/// Reads a big-endian `u16` at `offset` of a payload. +pub(super) fn be16(payload: &[u8; 16], offset: usize) -> u16 { + u16::from_be_bytes([payload[offset], payload[offset + 1]]) +} + +/// Whether a `manage*` call reads or writes. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive)] +#[repr(u8)] +pub(super) enum GetOrSet { + Get = 0, + Set = 1, +} + +/// The kind of slot information requested for an onboard-stored effect. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum SlotInfoType { + /// Slot state (validity, length). + SlotState = 0, + /// Default playback parameters. + Defaults = 1, + /// UUID bytes 0..=10. + Uuid0To10 = 2, + /// UUID bytes 11..=16. + Uuid11To16 = 3, + /// Effect name characters 0..=10. + EffectName0To10 = 4, + /// Effect name characters 11..=21. + EffectName11To21 = 5, + /// Effect name characters 21..=31. + EffectName21To31 = 6, +} + +/// An overall RGB power mode. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum RgbPowerMode { + /// Full RGB. + FullRgb = 1, + /// Power-save. + PowerSave = 2, + /// Power-off. + PowerOff = 3, +} + +/// The power-mode target an effect applies to, packed into `setRgbClusterEffect`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum PowerModeTarget { + /// Full-power mode. + FullPower = 0, + /// Power-save mode. + PowerSave = 1, +} + +/// Selects which LED bin parameter a LED-bin call addresses. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum LedBinIndex { + /// Bin value: brightness. + BinValueBrightness = 0, + /// Bin value: color. + BinValueColor = 1, + /// Calibration factors. + CalibrationFactors = 2, + /// Brightness. + Brightness = 3, + /// Colorimetric X. + ColorimetricX = 4, + /// Colorimetric Y. + ColorimetricY = 5, +} + +/// The kind of user-activity event. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum ActivityEventType { + /// The no-activity timeout was reached. + NoActivityTimeoutReached = 0, + /// User activity was detected. + UserActivityDetected = 1, +} + +bitflags::bitflags! { + /// Persistence of a cluster effect, packed into the low two bits of the + /// `setRgbClusterEffect` flags byte. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct RgbPersistence: u8 { + /// Apply to volatile RAM. + const VOLATILE = 1 << 0; + /// Store in non-volatile EEPROM. + const NON_VOLATILE = 1 << 1; + } +} + +bitflags::bitflags! { + /// Extended device capabilities from `getInfo` (device mode). + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct RgbExtCapabilities: u16 { + /// `getInfo` for stored effects is supported. + const GET_ZONE_EFFECT = 1 << 0; + /// Setting LED bin info is supported. + const SET_LED_BIN_INFO = 1 << 2; + /// Only monochrome effects are supported. + const MONOCHROME_ONLY = 1 << 3; + /// Effect-sync correction / events are *not* supported. + const NO_EFFECT_SYNC = 1 << 4; + /// The shutdown function is supported. + const SHUTDOWN = 1 << 5; + /// The cluster-changed event is supported. + const CLUSTER_CHANGED_EVENT = 1 << 6; + } +} + +bitflags::bitflags! { + /// Supported non-volatile capabilities from `getInfo` (device mode). + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct RgbNvCapabilities: u16 { + /// Boot-up effect. + const BOOT_UP_EFFECT = 1 << 0; + /// Demo mode. + const DEMO = 1 << 1; + /// User demo mode. + const USER_DEMO_MODE = 1 << 2; + /// Events display. + const EVENTS_DISPLAY = 1 << 3; + /// Active dimming. + const ACTIVE_DIMMING = 1 << 4; + /// Ramp down to off. + const RAMP_DOWN_TO_OFF = 1 << 5; + /// Shutdown effect. + const SHUTDOWN_EFFECT = 1 << 6; + } +} + +bitflags::bitflags! { + /// Software-control flags for `manageSwControl`. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct SwControlFlags: u8 { + /// Software controls all RGB clusters (required before `setRgbClusterEffect`). + const ALL_CLUSTERS = 1 << 0; + /// Software controls power modes (required before `setRgbPowerMode`). + const POWER_MODES = 1 << 1; + } +} + +bitflags::bitflags! { + /// Event-notification flags for `manageSwControl`. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct EventsNotificationFlags: u8 { + /// Emit effect-sync events. + const EFFECTS_SYNC = 1 << 0; + /// Emit user-activity events. + const USER_ACTIVITY = 1 << 1; + /// Emit no-user-activity-timeout events. + const NO_USER_ACTIVITY_TIMEOUT = 1 << 2; + } +} + +bitflags::bitflags! { + /// Display-persistency capabilities of a cluster from `getInfo` (cluster mode). + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct DisplayPersistencyCapabilities: u8 { + /// Can persist an "always on" state. + const ALWAYS_ON = 1 << 0; + /// Can persist an "always off" state. + const ALWAYS_OFF = 1 << 1; + /// Can persist an "on then off" state. + const ON_THEN_OFF = 1 << 2; + } +} + +/// Device-level information from +/// [`get_device_info`](super::RgbEffectsFeature::get_device_info). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct RgbDeviceInfo { + /// Number of RGB clusters. + pub cluster_count: u8, + /// Supported non-volatile capabilities. + pub nv_capabilities: RgbNvCapabilities, + /// Extended capabilities. + pub ext_capabilities: RgbExtCapabilities, + /// Number of multi-cluster effects. + pub multicluster_effect_count: u8, +} + +impl RgbDeviceInfo { + pub(super) fn from_payload(payload: &[u8; 16]) -> Self { + Self { + cluster_count: payload[2], + nv_capabilities: RgbNvCapabilities::from_bits_retain(be16(payload, 3)), + ext_capabilities: RgbExtCapabilities::from_bits_retain(be16(payload, 5)), + multicluster_effect_count: payload[7], + } + } +} + +/// Cluster-level information from +/// [`get_cluster_info`](super::RgbEffectsFeature::get_cluster_info). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct RgbClusterInfo { + /// Index of the cluster. + pub cluster_index: u8, + /// Physical location of the cluster (raw `locationEffect` value). + pub location: u16, + /// Number of effects the cluster supports. + pub effects_number: u8, + /// Display persistency capabilities. + pub display_persistency: DisplayPersistencyCapabilities, + /// Whether effect persistency to EEPROM is supported. + pub effect_persistency: bool, + /// Whether multi-LED patterns are supported. + pub multiled_pattern: bool, +} + +impl RgbClusterInfo { + pub(super) fn from_payload(payload: &[u8; 16]) -> Self { + Self { + cluster_index: payload[0], + location: be16(payload, 2), + effects_number: payload[4], + display_persistency: DisplayPersistencyCapabilities::from_bits_retain(payload[5]), + effect_persistency: payload[6] != 0, + multiled_pattern: payload[7] != 0, + } + } +} + +/// Effect-level information from +/// [`get_effect_info`](super::RgbEffectsFeature::get_effect_info). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct RgbEffectInfo { + /// Index of the cluster. + pub cluster_index: u8, + /// Index of the effect within the cluster. + pub cluster_effect_index: u8, + /// The effect type identifier (raw `effectID`). + pub effect_id: u16, + /// Effect capability bitmask (meaning depends on `effect_id`; `0` means + /// Raptor-compatibility defaults). + pub effect_capabilities: u16, + /// Effect period in milliseconds, or `0` when not available. + pub effect_period: u16, +} + +impl RgbEffectInfo { + pub(super) fn from_payload(payload: &[u8; 16]) -> Self { + Self { + cluster_index: payload[0], + cluster_effect_index: payload[1], + effect_id: be16(payload, 2), + effect_capabilities: be16(payload, 4), + effect_period: be16(payload, 6), + } + } +} + +/// Software-control state from +/// [`get_sw_control`](super::RgbEffectsFeature::get_sw_control). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct RgbSwControl { + /// Software-control flags. + pub control: SwControlFlags, + /// Event-notification flags. + pub events: EventsNotificationFlags, +} + +/// A non-volatile configuration entry from +/// [`get_nv_config`](super::RgbEffectsFeature::get_nv_config). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct RgbNvConfig { + /// The capability this entry addresses. + pub capability: RgbNvCapabilities, + /// The capability state. The meaning varies per capability (commonly + /// `0` = no change, `1` = enabled, `2` = disabled). + pub state: u8, + /// First capability-specific parameter. + pub param1: u8, + /// Second capability-specific parameter. + pub param2: u8, +} + +/// Power-mode configuration from +/// [`get_power_mode_config`](super::RgbEffectsFeature::get_power_mode_config). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct RgbPowerModeConfig { + /// Power-mode flags (raw). + pub flags: u16, + /// No-activity timeout before entering power-save, in seconds. + pub no_activity_timeout_to_power_save: u16, + /// No-activity timeout before turning off, in seconds. + pub no_activity_timeout_to_off: u16, +} + +impl RgbPowerModeConfig { + pub(super) fn from_payload(payload: &[u8; 16]) -> Self { + Self { + flags: be16(payload, 1), + no_activity_timeout_to_power_save: be16(payload, 3), + no_activity_timeout_to_off: be16(payload, 5), + } + } +} From 704e524b177f02ec85368a896b09a1a2725ac0ad Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sat, 27 Jun 2026 19:20:39 +0800 Subject: [PATCH 09/21] refactor(hid): use hidpp color led effects wrapper for lighting --- crates/openlogi-hid/src/write/lighting.rs | 99 ++++++++++++----------- 1 file changed, 53 insertions(+), 46 deletions(-) diff --git a/crates/openlogi-hid/src/write/lighting.rs b/crates/openlogi-hid/src/write/lighting.rs index 1121aa6f..46aa2d87 100644 --- a/crates/openlogi-hid/src/write/lighting.rs +++ b/crates/openlogi-hid/src/write/lighting.rs @@ -1,12 +1,18 @@ use std::time::Duration; use async_hid::AsyncHidWrite; -use hidpp::device::Device; +use hidpp::{ + device::Device, + feature::{ + CreatableFeature, + color_led_effects::{ColorLedEffectsFeature, Persistence, ZONE_EFFECT_PARAM_COUNT}, + }, +}; use tracing::debug; use crate::route::DeviceRoute; -use super::{HidppOperation, WriteError, classify_hidpp_error, with_route}; +use super::{HidppOperation, WriteError, classify_hidpp_error, open_feature, with_route}; /// HID++ `PerKeyLighting` (`0x8080`) — streams each key's colour individually. /// Its feature *index* varies per device, so it's resolved at runtime. @@ -32,19 +38,13 @@ const FN_FRAME_END: u8 = 0x5; const SET_RANGE_MODE: u8 = 0x01; const KEYS_PER_FRAME: u8 = 0x0e; -// 0x8070 `ColorLedEffects`: function 0x3 is `setZoneEffect(zone, effect, …)`. -// Effect 0x01 is the fixed/static single colour. The trailing persistence byte -// is RAM-only (0x00): the effect shows live and overrides the running onboard -// profile without touching flash. Reboot survival comes from the agent -// re-applying the saved colour on device arrival (orchestrator reapply), so -// flashing on every colour pick — which would wear the controller — is avoided. -const FN_SET_ZONE_EFFECT: u8 = 0x3; +// 0x8070 `ColorLedEffects`: zone-effect index 0x01 is the fixed/static single +// colour, applied volatilely (RAM only) so it shows live and overrides the +// running onboard profile without touching flash. Reboot survival comes from the +// agent re-applying the saved colour on device arrival (orchestrator reapply), +// avoiding flash wear on every colour pick. const EFFECT_FIXED: u8 = 0x01; -const PERSIST_RAM_ONLY: u8 = 0x00; -// G-series report a small zone count; writing a few covers every real zone (a -// write to a non-existent zone is a harmless no-op). Paced because the -// controller drops back-to-back reports. -const MAX_LIGHTING_ZONES: u8 = 4; +// Zones are paced apart because the controller can drop closely-spaced reports. const FRAME_GAP: Duration = Duration::from_millis(8); /// Which HID++ lighting path drives a solid keyboard colour. [`Auto`] is what @@ -127,40 +127,47 @@ async fn resolve_feature_index( /// Set a solid colour via `ColorLedEffects` (`0x8070`): a fixed effect per zone, /// stored in RAM only (overrides the running onboard profile without touching /// flash). `FeatureUnsupported` when the device exposes no `0x8070`. +/// +/// Uses the typed [`ColorLedEffectsFeature`] wrapper: the real zone count is read +/// first so only existing zones are driven (a typed `set_zone_effect` awaits the +/// device's reply, so unlike the former raw fire-and-forget path a write to a +/// non-existent zone would surface as an error rather than a silent no-op). async fn set_color_effects(route: &DeviceRoute, r: u8, g: u8, b: u8) -> Result<(), WriteError> { - let device_index = route.device_index(); - let feature_index = resolve_feature_index(route, COLOR_LED_EFFECTS_FEATURE) - .await? - .ok_or(WriteError::FeatureUnsupported { - feature_hex: COLOR_LED_EFFECTS_FEATURE, - })?; - - let Some(mut writer) = crate::transport::open_route_writer(route).await? else { - return Err(WriteError::DeviceNotFound); - }; - for zone in 0..MAX_LIGHTING_ZONES { - let mut rep = vec![0u8; 20]; - rep[0] = REPORT_LONG; - rep[1] = device_index; - rep[2] = feature_index; - rep[3] = (FN_SET_ZONE_EFFECT << 4) | SW_ID; - rep[4] = zone; - rep[5] = EFFECT_FIXED; - rep[6] = r; - rep[7] = g; - rep[8] = b; - rep[16] = PERSIST_RAM_ONLY; - writer - .write_output_report(&rep) + let index = route.device_index(); + with_route(route, move |channel| async move { + let mut device = Device::new(std::sync::Arc::clone(&channel), index) .await - .map_err(WriteError::from)?; - tokio::time::sleep(FRAME_GAP).await; - } - debug!( - device_index, - feature_index, r, g, b, "set keyboard colour via 0x8070" - ); - Ok(()) + .map_err(|_| WriteError::DeviceUnreachable { index })?; + let feature = open_feature::(&mut device).await?; + let zone_count = feature + .get_info() + .await + .map_err(classify_lighting_error)? + .zone_count; + + let mut params = [0u8; ZONE_EFFECT_PARAM_COUNT]; + params[0] = r; + params[1] = g; + params[2] = b; + for zone in 0..zone_count { + feature + .set_zone_effect(zone, EFFECT_FIXED, params, Persistence::Volatile) + .await + .map_err(classify_lighting_error)?; + tokio::time::sleep(FRAME_GAP).await; + } + debug!( + index, + zone_count, r, g, b, "set keyboard colour via typed 0x8070" + ); + Ok(()) + }) + .await +} + +/// Classify a HID++ error from the `ColorLedEffects` functions. +fn classify_lighting_error(error: hidpp::protocol::v20::Hidpp20Error) -> WriteError { + classify_hidpp_error(error, HidppOperation::Lighting, ColorLedEffectsFeature::ID) } /// Set a solid colour via `PerKeyLighting` (`0x8080`): stream every key's colour From a8ad21fb8ac4cb849b2e248a0a0a05c997158101 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sat, 27 Jun 2026 19:21:28 +0800 Subject: [PATCH 10/21] docs(hid): clarify MX gesture control semantics --- README.md | 4 +- crates/openlogi-agent-core/src/bindings.rs | 16 ++++---- .../openlogi-agent-core/src/hook_runtime.rs | 2 +- .../openlogi-agent-core/src/orchestrator.rs | 2 +- .../src/watchers/gesture.rs | 2 +- crates/openlogi-core/src/binding.rs | 6 +-- crates/openlogi-core/src/config.rs | 40 +++++++++---------- crates/openlogi-hid/src/gesture.rs | 15 +++---- crates/openlogi-hid/src/reprog_controls.rs | 13 +++--- .../openlogi-hid/src/reprog_controls/event.rs | 4 +- crates/openlogi-hid/src/write/diagnostics.rs | 7 ++-- 11 files changed, 58 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 1d95178d..7655668d 100644 --- a/README.md +++ b/README.md @@ -56,9 +56,9 @@ Things OpenLogi does that Options+ won't: Linux as a first-class platform: evdev/uinput hook, udev rules, a systemd user unit, and `.deb` / `.rpm` packages. - **Move the Gesture Button.** Pick which physical button owns the gesture - role — thumb pad, middle, back, or forward — with per-direction swipe + role — the dedicated Gesture Button, middle, back, or forward — with per-direction swipe bindings, or turn gestures off entirely. Options+ pins the gesture role to - the dedicated thumb pad. + the dedicated Gesture Button. - **Keep config in plain text.** Everything is one TOML file you can read, diff, version-control, and copy between machines. - **Script it.** A real CLI: device inventory, asset prefetch, and on-device diff --git a/crates/openlogi-agent-core/src/bindings.rs b/crates/openlogi-agent-core/src/bindings.rs index 333e358c..a416709c 100644 --- a/crates/openlogi-agent-core/src/bindings.rs +++ b/crates/openlogi-agent-core/src/bindings.rs @@ -54,11 +54,11 @@ pub fn gesture_bindings_for( config: &Config, config_key: Option<&str>, ) -> BTreeMap { - // The thumb pad (HID++ 0x00c3) only gestures while it is the device's gesture + // The dedicated HID++ gesture button (CID 0x00c3) only gestures while it is the device's gesture // owner. When the user moves the role to an OS-hook button (Middle/Back/ // Forward) or turns gestures off, return an empty map so the gesture watcher // dispatches nothing — otherwise the always-seeded defaults would keep the - // thumb pad firing regardless of the selection. + // HID++ gesture button firing regardless of the selection. let owner = config_key.and_then(|key| config.gesture_owner(key)); if owner != Some(ButtonId::GestureButton) { return BTreeMap::new(); @@ -105,7 +105,7 @@ pub fn oshook_gestures_for( return BTreeMap::new(); }; // Only an OS-hook button (Middle/Back/Forward) as the device's gesture owner - // feeds the OS hook: the thumb pad is captured over HID++, and a non-owner + // feeds the OS hook: the dedicated HID++ gesture button is captured over HID++, and a non-owner // button is dispatched as its single click action. Returning *only* the owner // keeps the runtime in lockstep with `gesture_owner` and the GUI, so a stray // second gesture map (e.g. a hand-edited config) can't make two buttons fire. @@ -223,22 +223,22 @@ mod tests { } #[test] - fn gesture_bindings_silent_when_thumb_pad_is_not_the_owner() { + fn gesture_bindings_silent_when_hidpp_button_is_not_the_owner() { let mut cfg = Config::default(); - // Default device: the thumb pad owns gestures, so its defaults are seeded. + // Default device: the dedicated HID++ gesture button owns gestures, so its defaults are seeded. let defaults = gesture_bindings_for(&cfg, Some("2b042")); assert_eq!( defaults.get(&GestureDirection::Up), Some(&default_gesture_binding(GestureDirection::Up)), - "the default gesture owner is the thumb pad" + "the default gesture owner is the dedicated HID++ gesture button" ); - // Move the gesture role to an OS-hook button: the thumb pad goes silent, + // Move the gesture role to an OS-hook button: the HID++ gesture button goes silent, // so the watcher dispatches nothing for 0x00c3. cfg.set_gesture_owner("2b042", ButtonId::Back); assert!( gesture_bindings_for(&cfg, Some("2b042")).is_empty(), - "thumb pad must dispatch nothing once another button owns gestures" + "HID++ gesture button must dispatch nothing once another button owns gestures" ); } } diff --git a/crates/openlogi-agent-core/src/hook_runtime.rs b/crates/openlogi-agent-core/src/hook_runtime.rs index 07419d3e..5c45584d 100644 --- a/crates/openlogi-agent-core/src/hook_runtime.rs +++ b/crates/openlogi-agent-core/src/hook_runtime.rs @@ -40,7 +40,7 @@ pub type SharedHookMaps = Arc>; /// Tracks which OS-hook button (Middle/Back/Forward) is mid-hold and defers the /// swipe detection itself to a shared [`SwipeAccumulator`], which commits a swipe -/// *mid-motion* like the HID++ thumb-pad path in `openlogi-hid`. This wrapper +/// *mid-motion* like the HID++ gesture-button path in `openlogi-hid`. This wrapper /// adds only the button identity the accumulator doesn't track; a press that /// never commits a direction is a plain click, fired on release. #[derive(Default)] diff --git a/crates/openlogi-agent-core/src/orchestrator.rs b/crates/openlogi-agent-core/src/orchestrator.rs index c70b4a5f..019d08bb 100644 --- a/crates/openlogi-agent-core/src/orchestrator.rs +++ b/crates/openlogi-agent-core/src/orchestrator.rs @@ -326,7 +326,7 @@ impl Orchestrator { } /// Foreground-app change → re-overlay per-app bindings on the hook maps (DPI - /// and the thumb-pad gesture map are not app-scoped, so they're untouched). + /// and the dedicated HID++ gesture map are not app-scoped, so they're untouched). /// Both hook maps are recomputed: a per-app override of the gesture owner /// turns it into a single action for that app, dropping it from the OS-hook /// gesture set — so the gesture map is app-scoped too. diff --git a/crates/openlogi-agent-core/src/watchers/gesture.rs b/crates/openlogi-agent-core/src/watchers/gesture.rs index 8e73ccba..80659682 100644 --- a/crates/openlogi-agent-core/src/watchers/gesture.rs +++ b/crates/openlogi-agent-core/src/watchers/gesture.rs @@ -191,7 +191,7 @@ async fn manage( } else { let target = dpi_cycle.read().ok().and_then(|guard| guard.target.clone()); let sensitivity = thumbwheel_sensitivity.load(Ordering::Relaxed); - // Divert the thumb pad only while it owns the gesture role. The + // Divert the dedicated HID++ gesture button only while it owns the gesture role. The // shared gesture map is non-empty exactly then (gesture_bindings_for // gates on the owner), so it doubles as that signal — no need to // thread the full config in. Re-evaluated each tick, so a diff --git a/crates/openlogi-core/src/binding.rs b/crates/openlogi-core/src/binding.rs index dadde01e..1463492a 100644 --- a/crates/openlogi-core/src/binding.rs +++ b/crates/openlogi-core/src/binding.rs @@ -207,7 +207,7 @@ pub fn detect_swipe(dx: i32, dy: i32) -> Option { } /// The mid-swipe state machine shared by both gesture-capture paths: the HID++ -/// thumb pad (`openlogi-hid`'s `0x1b04` raw-XY divert) and the OS-hook +/// dedicated gesture button (`openlogi-hid`'s `0x1b04` raw-XY divert) and the OS-hook /// Middle/Back/Forward buttons (`openlogi-agent-core`'s CGEventTap). A gesture /// button's hold accumulates travel; the instant the dominant axis commits a /// direction — after the button has been held [`GESTURE_HOLD_FOR_SWIPE`], so a @@ -216,7 +216,7 @@ pub fn detect_swipe(dx: i32, dy: i32) -> Option { /// commits is a plain click, reported by [`Self::end`]. /// /// The two paths differ only in *what identifies the held control* (a -/// [`ButtonId`] for the OS hook, a diverted CID for the thumb pad), so each owns +/// [`ButtonId`] for the OS hook, a diverted CID for the HID++ gesture control), so each owns /// that and embeds this for the shared travel logic. Keeping the logic in one /// place is deliberate: the two copies it replaced had already drifted apart /// (one resolved a swipe only on release), which mis-fired the click. @@ -845,7 +845,7 @@ impl Action { /// /// Thumbwheel / GestureButton defaults match what Logi Options+ ships for /// MX-line devices: thumb wheel click → App Exposé, gesture button → -/// Mission Control. The thumb wheel isn't captured yet; the gesture button is +/// Mission Control. The thumb wheel isn't captured yet; the dedicated gesture button is /// (per-direction, see [`default_gesture_binding`]). The bindings persist /// regardless so the user only configures once. /// diff --git a/crates/openlogi-core/src/config.rs b/crates/openlogi-core/src/config.rs index 2f96f475..43db3f22 100644 --- a/crates/openlogi-core/src/config.rs +++ b/crates/openlogi-core/src/config.rs @@ -338,9 +338,9 @@ pub struct DeviceIdentity { #[serde(from = "RawDeviceConfig")] pub struct DeviceConfig { /// Which button owns the device's single gesture role, once the user has - /// chosen explicitly. Absent means "infer" (the thumb pad owns gestures if - /// present) — see [`Config::gesture_owner`]. Listed first so it serializes - /// as a scalar ahead of the `bindings` sub-table. + /// chosen explicitly. Absent means "infer" (the dedicated HID++ gesture + /// button owns gestures if present) — see [`Config::gesture_owner`]. Listed + /// first so it serializes as a scalar ahead of the `bindings` sub-table. #[serde(default, skip_serializing_if = "Option::is_none")] pub gesture_owner: Option, /// Last-known identity (name / kind / capabilities), captured while the @@ -663,13 +663,13 @@ impl Config { /// /// Resolved from the explicit [`DeviceConfig::gesture_owner`] when present; /// otherwise inferred (see `Self::infer_gesture_owner`) for configs - /// predating the field and freshly-migrated pre-v2 files. The dedicated thumb - /// pad ([`ButtonId::GestureButton`]) owns the role by default. At most one - /// button gestures per device. + /// predating the field and freshly-migrated pre-v2 files. The dedicated + /// HID++ gesture button ([`ButtonId::GestureButton`]) owns the role by + /// default. At most one button gestures per device. #[must_use] pub fn gesture_owner(&self, device_key: &str) -> Option { let Some(device) = self.devices.get(device_key) else { - // No config yet → the thumb pad is the default gesture owner. + // No config yet → the dedicated HID++ gesture button is the default gesture owner. return Some(ButtonId::GestureButton); }; match device.gesture_owner { @@ -691,14 +691,14 @@ impl Config { { return Some(*id); } - // A thumb pad explicitly demoted to a single action means gestures off. + // A dedicated HID++ gesture button explicitly demoted to a single action means gestures off. if matches!( bindings.get(&ButtonId::GestureButton), Some(Binding::Single(_)) ) { return None; } - // Default: the thumb pad owns the gesture role. + // Default: the dedicated HID++ gesture button owns the gesture role. Some(ButtonId::GestureButton) } @@ -1471,12 +1471,12 @@ Back = \"BrowserBack\" } #[test] - fn gesture_owner_defaults_to_thumb_pad_yields_to_oshook_and_can_be_off() { + fn gesture_owner_defaults_to_hidpp_button_yields_to_oshook_and_can_be_off() { let mut cfg = Config::default(); - // Default: the thumb pad owns the gesture role even with no config. + // Default: the dedicated HID++ gesture button owns the gesture role even with no config. assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton)); - // A thumb-pad gesture binding keeps it the owner. + // A dedicated HID++ gesture binding keeps it the owner. cfg.set_gesture_direction( "2b042", ButtonId::GestureButton, @@ -1493,7 +1493,7 @@ Back = \"BrowserBack\" ); assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Forward)); - // Turning gestures off explicitly yields `None` (not the thumb-pad default). + // Turning gestures off explicitly yields `None` (not the HID++ button default). let mut off = Config::default(); off.disable_gestures("2b042"); assert_eq!(off.gesture_owner("2b042"), None); @@ -1502,7 +1502,7 @@ Back = \"BrowserBack\" #[test] fn set_gesture_owner_records_owner_without_destroying_other_maps() { let mut cfg = Config::default(); - // Customize the thumb pad's Up swipe; it is the (inferred) owner. + // Customize the dedicated HID++ gesture button's Up swipe; it is the (inferred) owner. cfg.set_gesture_direction( "2b042", ButtonId::GestureButton, @@ -1511,7 +1511,7 @@ Back = \"BrowserBack\" ); assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton)); - // Promote Back: the owner becomes Back explicitly; the thumb pad keeps + // Promote Back: the owner becomes Back explicitly; the HID++ gesture button keeps // its full gesture map (no destructive demotion). cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack.into()); cfg.set_gesture_owner("2b042", ButtonId::Back); @@ -1534,12 +1534,12 @@ Back = \"BrowserBack\" } other => panic!("expected Back to be a gesture binding, got {other:?}"), } - // The thumb pad's customized map survived the switch intact. + // The HID++ gesture button's customized map survived the switch intact. match bindings.get(&ButtonId::GestureButton) { Some(Binding::Gesture(map)) => { assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy)); } - other => panic!("expected the thumb pad map preserved, got {other:?}"), + other => panic!("expected the HID++ gesture button map preserved, got {other:?}"), } // Switching back restores the user's customization, not defaults @@ -1557,7 +1557,7 @@ Back = \"BrowserBack\" #[test] fn set_gesture_owner_seeds_a_fresh_button_with_full_directions() { let mut cfg = Config::default(); - // The dedicated thumb pad gets the full default direction map. + // The dedicated HID++ gesture button gets the full default direction map. cfg.set_gesture_owner("2b042", ButtonId::GestureButton); match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) { Some(Binding::Gesture(map)) => { @@ -1601,7 +1601,7 @@ Back = \"BrowserBack\" Action::Copy, ); cfg.disable_gestures("2b042"); - // Off, but the thumb pad's customized map is preserved (re-enabling + // Off, but the HID++ gesture button's customized map is preserved (re-enabling // restores it rather than resurrecting a wiped default). assert_eq!(cfg.gesture_owner("2b042"), None); match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) { @@ -1654,7 +1654,7 @@ Back = \"Copy\" cfg.bindings_for("2b042").get(&ButtonId::Back), Some(&Binding::Single(Action::Copy)) ); - // ...and the bad owner degraded to inference (thumb-pad default here). + // ...and the bad owner degraded to inference (HID++ button default here). assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton)); } } diff --git a/crates/openlogi-hid/src/gesture.rs b/crates/openlogi-hid/src/gesture.rs index 5aa05b4b..1783494b 100644 --- a/crates/openlogi-hid/src/gesture.rs +++ b/crates/openlogi-hid/src/gesture.rs @@ -1,4 +1,4 @@ -//! Live control capture for one device: divert the MX thumb gesture button, the +//! Live control capture for one device: divert the MX dedicated gesture button, the //! DPI/ModeShift button, and the thumb wheel over HID++ and turn their events //! into [`CapturedInput`] the GUI can dispatch. //! @@ -70,7 +70,7 @@ pub enum GestureError { /// because the channel's read thread invokes the listener by shared reference. #[derive(Default)] struct CaptureAccum { - /// Mid-swipe state for the diverted thumb-pad gesture button (raw-XY). + /// Mid-swipe state for the diverted dedicated gesture button (raw-XY). swipe: SwipeAccumulator, /// Whether any DPI/ModeShift control was held in the last event — for /// rising-edge press detection. @@ -81,11 +81,12 @@ struct CaptureAccum { /// `capture_thumbwheel`) the thumb wheel on `route` until `shutdown` resolves, /// forwarding each event to `sink`. /// -/// The gesture button (raw-XY) is diverted only when `divert_gesture_button` — +/// The dedicated gesture button (raw-XY) is diverted only when `divert_gesture_button` — /// i.e. it is the device's gesture owner. When the user moves the gesture role -/// to an OS-hook button or turns gestures off, the thumb pad is left undiverted -/// so it keeps its native behavior instead of being captured-and-swallowed. The -/// DPI/ModeShift capture and the channel-reuse slot are independent of this. +/// to an OS-hook button or turns gestures off, the HID++ gesture control is +/// left undiverted so it keeps its native behavior instead of being +/// captured-and-swallowed. The DPI/ModeShift capture and the channel-reuse slot +/// are independent of this. /// /// Opens and holds one HID++ channel, diverts whichever of those controls the /// device exposes, and listens. Returns once `shutdown` fires (or its sender is @@ -231,7 +232,7 @@ async fn arm_controls( let controls = enumerate_controls(&rc).await?; // Only divert the gesture button when it owns the gesture role; otherwise - // leave it native (a non-owner thumb pad must not be captured-and-dropped). + // leave it native (a non-owner HID++ control must not be captured-and-dropped). if divert_gesture_button && controls .iter() diff --git a/crates/openlogi-hid/src/reprog_controls.rs b/crates/openlogi-hid/src/reprog_controls.rs index d4fe8850..c25d4e3b 100644 --- a/crates/openlogi-hid/src/reprog_controls.rs +++ b/crates/openlogi-hid/src/reprog_controls.rs @@ -1,6 +1,5 @@ //! HID++ `ReprogControlsV4` (feature `0x1b04`) — temporary control diversion -//! and raw-XY reporting, the mechanism behind the MX-line thumb "gesture -//! button". +//! and raw-XY reporting, the mechanism behind MX-line reprogrammable controls. //! //! The full protocol wrapper lives in `openlogi-hidpp`; this module keeps the //! OpenLogi-facing compatibility API used by gesture/button orchestration: @@ -35,9 +34,13 @@ pub use hidpp_reprog::{ /// `ReprogControlsV4` HID++ feature ID. pub const FEATURE_ID: u16 = 0x1b04; -/// Control ID of the MX-line thumb gesture button (`Mouse_Gesture_Button`, -/// Logitech "App_Switch_Gesture"). Cross-checked against Solaar -/// `special_keys.py`. +/// Control ID of the MX-line dedicated gesture button (`Mouse_Gesture_Button`, +/// Logitech "App_Switch_Gesture"). +/// +/// MX Master 4 also has a separate Haptic Sense Panel in the thumb area. That +/// panel is not this CID; it must be discovered from the device's `0x1b04` +/// control table and supported explicitly before OpenLogi treats it as a +/// bindable/capturable input. pub const GESTURE_BUTTON_CID: u16 = 0x00c3; /// Control IDs of the "DPI / ModeShift" button family. Whichever a device diff --git a/crates/openlogi-hid/src/reprog_controls/event.rs b/crates/openlogi-hid/src/reprog_controls/event.rs index 5bb2b591..ed0e3970 100644 --- a/crates/openlogi-hid/src/reprog_controls/event.rs +++ b/crates/openlogi-hid/src/reprog_controls/event.rs @@ -46,7 +46,7 @@ impl TryFrom for RawControlEvent { /// `0x1b04` event for `(device_index, feature_index)`. /// /// Returns `None` for request responses (`software_id != 0`), messages from a -/// different device or feature, and events outside OpenLogi's legacy gesture +/// different device or feature, and events outside OpenLogi's gesture-control /// pipeline. #[must_use] pub fn decode_event( @@ -111,7 +111,7 @@ mod tests { let p = [0u8; 16]; // software_id != 0 marks a request response, not an event. assert_eq!(decode_event(&event(0, 5, p), 2, 7), None); - // Right device + feature, but an event outside the legacy gesture path. + // Right device + feature, but an event outside the gesture-control path. assert_eq!(decode_event(&event(2, 0, p), 2, 7), None); // Wrong feature index. assert_eq!(decode_event(&event(0, 0, p), 2, 9), None); diff --git a/crates/openlogi-hid/src/write/diagnostics.rs b/crates/openlogi-hid/src/write/diagnostics.rs index 489337ab..e3a9ee6f 100644 --- a/crates/openlogi-hid/src/write/diagnostics.rs +++ b/crates/openlogi-hid/src/write/diagnostics.rs @@ -82,9 +82,10 @@ pub async fn dump_features(route: &DeviceRoute) -> Result, Wri } /// Enumerate the device's HID++ `0x1b04` reprogrammable controls. This is a -/// diagnostics-only probe used to discover controls for newly released devices -/// (for example MX Master 4's Haptic Sense Panel) before wiring them into the -/// capture/remapping model. +/// diagnostics-only probe used to discover controls for newly released devices. +/// For example, MX Master 4 has both a Gesture Button and a separate Haptic +/// Sense Panel in the thumb area; this probe lets us identify the panel's CID +/// and capabilities before wiring it into the capture/remapping model. pub async fn dump_reprog_controls( route: &DeviceRoute, ) -> Result, WriteError> { From 8a6c1dd7095d45ac2af326846543e395aaad9f0b Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sat, 27 Jun 2026 19:36:11 +0800 Subject: [PATCH 11/21] feat(hidpp): add audio equalizer wrapper --- .../src/feature/equalizer/mod.rs | 233 ++++++++++++++++++ .../src/feature/equalizer/tests.rs | 110 +++++++++ crates/openlogi-hidpp/src/feature/mod.rs | 1 + crates/openlogi-hidpp/src/feature/registry.rs | 3 +- 4 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 crates/openlogi-hidpp/src/feature/equalizer/mod.rs create mode 100644 crates/openlogi-hidpp/src/feature/equalizer/tests.rs diff --git a/crates/openlogi-hidpp/src/feature/equalizer/mod.rs b/crates/openlogi-hidpp/src/feature/equalizer/mod.rs new file mode 100644 index 00000000..c69a0101 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/equalizer/mod.rs @@ -0,0 +1,233 @@ +//! Implements the `Equalizer` feature (ID `0x8310`, version 2) that configures +//! an audio device's equalizer (per-band gains) and microphone noise reduction. +//! +//! The device exposes a single EQ table of `band_count` frequency bands; each +//! band has a fixed frequency (Hz) and an adjustable signed gain (dB). All +//! frequencies are big-endian `u16`; gains are signed `i8`. + +#[cfg(test)] +mod tests; + +use std::sync::Arc; + +use num_enum::{IntoPrimitive, TryFromPrimitive}; + +use crate::{ + channel::HidppChannel, + feature::{CreatableFeature, Feature, FeatureEndpoint}, + protocol::v20::Hidpp20Error, +}; + +/// Maximum number of frequencies a single `getFrequencies` response carries. +const FREQUENCIES_PER_PAGE: u8 = 7; + +bitflags::bitflags! { + /// How a device stores its EQ values, from + /// [`get_eq_info`](EqualizerFeature::get_eq_info). + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct EqCapabilities: u8 { + /// EQ values are stored as gains. + const STORED_AS_GAINS = 1 << 0; + /// EQ values are stored as coefficients. + const STORED_AS_COEFFICIENTS = 1 << 1; + } +} + +/// Where [`get_frequency_gains`](EqualizerFeature::get_frequency_gains) reads from. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum GainLocation { + /// The custom EQ stored in EEPROM (the version-0 default). + Eeprom = 0, + /// The active EQ in RAM. + Ram = 1, +} + +/// How [`set_frequency_gains`](EqualizerFeature::set_frequency_gains) persists +/// the gains. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum GainPersistence { + /// Volatile: applied to RAM only. + Volatile = 0, + /// Applied to RAM and stored in EEPROM. + VolatileAndNonVolatile = 1, + /// Stored in EEPROM only. + NonVolatileOnly = 2, +} + +/// EQ table information from [`get_eq_info`](EqualizerFeature::get_eq_info). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct EqInfo { + /// Number of frequency bands. + pub band_count: u8, + /// Gain range in dB; used as `±db_range` when `db_min`/`db_max` are both `0`. + pub db_range: u8, + /// How EQ values are stored. + pub capabilities: EqCapabilities, + /// Minimum gain in dB, or `0` to imply `-db_range`. + pub db_min: i8, + /// Maximum gain in dB, or `0` to imply `+db_range`. + pub db_max: i8, +} + +impl EqInfo { + fn from_payload(payload: &[u8; 16]) -> Self { + Self { + band_count: payload[0], + db_range: payload[1], + capabilities: EqCapabilities::from_bits_retain(payload[2]), + db_min: payload[3] as i8, + db_max: payload[4] as i8, + } + } + + /// The effective `(min, max)` gain range in dB. + /// + /// Resolves the "both zero implies `±db_range`" rule into concrete bounds. + #[must_use] + pub fn effective_range(&self) -> (i8, i8) { + if self.db_min == 0 && self.db_max == 0 { + let range = i8::try_from(self.db_range).unwrap_or(i8::MAX); + (-range, range) + } else { + (self.db_min, self.db_max) + } + } +} + +/// Implements the `Equalizer` / `0x8310` feature. +#[derive(Clone)] +pub struct EqualizerFeature { + /// The endpoint this feature talks to. + endpoint: FeatureEndpoint, +} + +impl CreatableFeature for EqualizerFeature { + const ID: u16 = 0x8310; + const STARTING_VERSION: u8 = 2; + + fn new(chan: Arc, device_index: u8, feature_index: u8) -> Self { + Self { + endpoint: FeatureEndpoint::new(chan, device_index, feature_index), + } + } +} + +impl Feature for EqualizerFeature {} + +impl EqualizerFeature { + /// Retrieves the EQ table's band count, gain range and storage capabilities. + pub async fn get_eq_info(&self) -> Result { + let payload = self.endpoint.call(0, [0; 3]).await?.extend_payload(); + Ok(EqInfo::from_payload(&payload)) + } + + /// Retrieves the frequency (Hz) of every band. + /// + /// `band_count` is the value from [`EqInfo::band_count`]; the device returns + /// up to seven frequencies per response, so this pages through them until all + /// `band_count` are collected. + pub async fn get_frequencies(&self, band_count: u8) -> Result, Hidpp20Error> { + let mut frequencies = Vec::with_capacity(usize::from(band_count)); + let mut index = 0u8; + while index < band_count { + let payload = self.endpoint.call(1, [index, 0, 0]).await?.extend_payload(); + // The response echoes the requested band index in byte 0. + if payload[0] != index { + return Err(Hidpp20Error::UnsupportedResponse); + } + let page = (band_count - index).min(FREQUENCIES_PER_PAGE); + frequencies.extend(parse_frequency_page(&payload, page)?); + index += page; + } + Ok(frequencies) + } + + /// Retrieves the active gain (dB) of every band from `location`. + /// + /// `band_count` is the value from [`EqInfo::band_count`] (at most 15, the + /// number of gains a single response carries). + pub async fn get_frequency_gains( + &self, + location: GainLocation, + band_count: u8, + ) -> Result, Hidpp20Error> { + let payload = self + .endpoint + .call(2, [location.into(), 0, 0]) + .await? + .extend_payload(); + parse_gains(&payload, 0, band_count) + } + + /// Sets the per-band gains (dB) and returns the device's echo of them. + /// + /// `gains` holds one signed value per band (at most 15). The device rejects + /// out-of-range gains. + pub async fn set_frequency_gains( + &self, + persistence: GainPersistence, + gains: &[i8], + ) -> Result, Hidpp20Error> { + let count = u8::try_from(gains.len()).map_err(|_| Hidpp20Error::UnsupportedResponse)?; + let mut args = [0; 16]; + args[0] = persistence.into(); + // Gains follow the persistence byte; each is a signed value sent as a raw + // byte. + for (i, &gain) in gains.iter().enumerate() { + let slot = 1 + i; + if slot >= args.len() { + return Err(Hidpp20Error::UnsupportedResponse); + } + args[slot] = gain as u8; + } + let payload = self.endpoint.call_long(3, args).await?.extend_payload(); + // The response echoes the request, so the gains start after the echoed + // persistence byte. + parse_gains(&payload, 1, count) + } + + /// Retrieves whether hardware microphone noise reduction is enabled. + pub async fn get_mic_noise_reduction(&self) -> Result { + let payload = self.endpoint.call(4, [0; 3]).await?.extend_payload(); + Ok(payload[0] != 0) + } + + /// Enables or disables hardware microphone noise reduction. + pub async fn set_mic_noise_reduction(&self, enabled: bool) -> Result<(), Hidpp20Error> { + self.endpoint.call(5, [u8::from(enabled), 0, 0]).await?; + Ok(()) + } +} + +/// Parses `count` big-endian `u16` frequencies from a `getFrequencies` response, +/// which carries them starting at byte 1 (after the echoed band index). +fn parse_frequency_page(payload: &[u8; 16], count: u8) -> Result, Hidpp20Error> { + let count = usize::from(count); + if 1 + 2 * count > payload.len() { + return Err(Hidpp20Error::UnsupportedResponse); + } + Ok((0..count) + .map(|i| u16::from_be_bytes([payload[1 + 2 * i], payload[2 + 2 * i]])) + .collect()) +} + +/// Parses `count` signed gains from a payload starting at `offset`. +fn parse_gains(payload: &[u8; 16], offset: usize, count: u8) -> Result, Hidpp20Error> { + let count = usize::from(count); + if offset + count > payload.len() { + return Err(Hidpp20Error::UnsupportedResponse); + } + Ok(payload[offset..offset + count] + .iter() + .map(|&byte| byte as i8) + .collect()) +} diff --git a/crates/openlogi-hidpp/src/feature/equalizer/tests.rs b/crates/openlogi-hidpp/src/feature/equalizer/tests.rs new file mode 100644 index 00000000..49fce47c --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/equalizer/tests.rs @@ -0,0 +1,110 @@ +//! Unit tests for `Equalizer` payload parsing, using the spec's worked example +//! (10 bands at ±12 dB). + +use super::{ + EqCapabilities, EqInfo, GainLocation, GainPersistence, parse_frequency_page, parse_gains, +}; + +#[test] +fn parses_eq_info_with_implied_range() { + // Spec example: bandCount = 10, dbRange = 0x0c, the rest zero. + let mut payload = [0; 16]; + payload[0] = 0x0a; + payload[1] = 0x0c; + + let info = EqInfo::from_payload(&payload); + assert_eq!(info.band_count, 10); + assert_eq!(info.db_range, 12); + assert_eq!(info.capabilities, EqCapabilities::empty()); + assert_eq!(info.db_min, 0); + assert_eq!(info.db_max, 0); + // Both bounds zero ⇒ ±dbRange. + assert_eq!(info.effective_range(), (-12, 12)); +} + +#[test] +fn parses_eq_info_with_explicit_range() { + let mut payload = [0; 16]; + payload[0] = 5; + payload[1] = 0x0c; + payload[2] = EqCapabilities::STORED_AS_GAINS.bits(); + payload[3] = (-6i8) as u8; + payload[4] = 9; + + let info = EqInfo::from_payload(&payload); + assert!(info.capabilities.contains(EqCapabilities::STORED_AS_GAINS)); + assert_eq!(info.db_min, -6); + assert_eq!(info.db_max, 9); + assert_eq!(info.effective_range(), (-6, 9)); +} + +#[test] +fn parses_first_frequency_page() { + // Spec example: getFrequencies(0) → 32, 64, 125, 250, 500, 1000, 2000 Hz. + let mut payload = [0; 16]; + payload[0] = 0; // echoed band index + let freqs = [32u16, 64, 125, 250, 500, 1000, 2000]; + for (i, f) in freqs.iter().enumerate() { + payload[1 + 2 * i..3 + 2 * i].copy_from_slice(&f.to_be_bytes()); + } + + assert_eq!(parse_frequency_page(&payload, 7).unwrap(), freqs); +} + +#[test] +fn parses_partial_frequency_page() { + // Spec example: getFrequencies(7) → 4000, 8000, 16000 Hz. + let mut payload = [0; 16]; + payload[0] = 7; + let freqs = [4000u16, 8000, 16000]; + for (i, f) in freqs.iter().enumerate() { + payload[1 + 2 * i..3 + 2 * i].copy_from_slice(&f.to_be_bytes()); + } + + assert_eq!(parse_frequency_page(&payload, 3).unwrap(), freqs); +} + +#[test] +fn rejects_oversized_frequency_page() { + // A page can hold at most seven u16 values after the index byte. + assert!(parse_frequency_page(&[0; 16], 8).is_err()); +} + +#[test] +fn parses_gains_from_response() { + // Spec example: gains {0, -12, 12, 0, ...}; -12 = 0xF4, +12 = 0x0C. + let mut payload = [0; 16]; + payload[0] = 0x00; + payload[1] = 0xf4; + payload[2] = 0x0c; + + assert_eq!( + parse_gains(&payload, 0, 10).unwrap(), + [0, -12, 12, 0, 0, 0, 0, 0, 0, 0] + ); +} + +#[test] +fn parses_echoed_gains_after_persistence_byte() { + // setFrequencyGains echoes persistence@0 then the gains, so reading from + // offset 1 recovers them. + let mut payload = [0; 16]; + payload[0] = 1; // echoed persistence + payload[1] = (-4i8) as u8; + payload[2] = 4; + + assert_eq!(parse_gains(&payload, 1, 3).unwrap(), [-4, 4, 0]); +} + +#[test] +fn rejects_too_many_gains() { + assert!(parse_gains(&[0; 16], 1, 16).is_err()); +} + +#[test] +fn maps_enum_wire_values() { + assert_eq!(u8::from(GainLocation::Ram), 1); + assert_eq!(GainLocation::try_from(0u8).unwrap(), GainLocation::Eeprom); + assert_eq!(u8::from(GainPersistence::NonVolatileOnly), 2); + assert!(GainPersistence::try_from(3u8).is_err()); +} diff --git a/crates/openlogi-hidpp/src/feature/mod.rs b/crates/openlogi-hidpp/src/feature/mod.rs index 7958f37c..1886185a 100755 --- a/crates/openlogi-hidpp/src/feature/mod.rs +++ b/crates/openlogi-hidpp/src/feature/mod.rs @@ -19,6 +19,7 @@ pub mod device_type_and_name; pub mod disable_keys; pub mod disable_keys_by_usage; pub mod dual_platform; +pub mod equalizer; pub mod extended_dpi; pub mod extended_report_rate; pub mod feature_set; diff --git a/crates/openlogi-hidpp/src/feature/registry.rs b/crates/openlogi-hidpp/src/feature/registry.rs index 2ec88224..6a231722 100755 --- a/crates/openlogi-hidpp/src/feature/registry.rs +++ b/crates/openlogi-hidpp/src/feature/registry.rs @@ -23,6 +23,7 @@ use crate::{ disable_keys::DisableKeysFeature, disable_keys_by_usage::DisableKeysByUsageFeature, dual_platform::DualPlatformFeature, + equalizer::EqualizerFeature, extended_dpi::ExtendedDpiFeature, extended_report_rate::ExtendedReportRateFeature, feature_set::FeatureSetFeature, @@ -238,7 +239,7 @@ static KNOWN_FEATURES: LazyLock> = LazyLock::new(|| { 0x8120 "GamingAttachments", 0x8123 "ForceFeedback", 0x8300 "Sidetone" => SidetoneFeature, - 0x8310 "Equalizer", + 0x8310 "Equalizer" => EqualizerFeature, 0x8320 "HeadsetOut", } }); From 9f5253e1cbdc62dd265a790d81814c81ba4735ba Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sat, 27 Jun 2026 19:41:34 +0800 Subject: [PATCH 12/21] feat(hidpp): add persistent remappable action wrapper --- crates/openlogi-hidpp/src/feature/mod.rs | 1 + .../persistent_remappable_action/mod.rs | 291 ++++++++++++++++++ .../persistent_remappable_action/tests.rs | 79 +++++ crates/openlogi-hidpp/src/feature/registry.rs | 3 +- 4 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 crates/openlogi-hidpp/src/feature/persistent_remappable_action/mod.rs create mode 100644 crates/openlogi-hidpp/src/feature/persistent_remappable_action/tests.rs diff --git a/crates/openlogi-hidpp/src/feature/mod.rs b/crates/openlogi-hidpp/src/feature/mod.rs index 1886185a..1b9e1f07 100755 --- a/crates/openlogi-hidpp/src/feature/mod.rs +++ b/crates/openlogi-hidpp/src/feature/mod.rs @@ -31,6 +31,7 @@ pub mod mode_status; pub mod mouse_pointer; pub mod multi_platform; pub mod per_key_lighting; +pub mod persistent_remappable_action; pub mod registry; pub mod report_rate; pub mod reprog_controls; diff --git a/crates/openlogi-hidpp/src/feature/persistent_remappable_action/mod.rs b/crates/openlogi-hidpp/src/feature/persistent_remappable_action/mod.rs new file mode 100644 index 00000000..8f6f4867 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/persistent_remappable_action/mod.rs @@ -0,0 +1,291 @@ +//! Implements the `PersistentRemappableAction` feature (ID `0x1c00`) that +//! persistently remaps a device control to a different HID action. +//! +//! Controls are identified by the same [`ControlId`]s as +//! [`ReprogControls`](super::reprog_controls) (`0x1b04`); when both features are +//! present and `0x1b04` diverts a control, that takes precedence over a +//! persistent remap here. + +#[cfg(test)] +mod tests; + +use std::sync::Arc; + +use num_enum::{IntoPrimitive, TryFromPrimitive}; + +use crate::{ + channel::HidppChannel, + feature::{ + CreatableFeature, Feature, FeatureEndpoint, hosts_info::HostIndex, + reprog_controls::ControlId, + }, + protocol::v20::Hidpp20Error, +}; + +bitflags::bitflags! { + /// What HID outputs a device's persistent remapping can produce, from + /// [`get_feature_info`](PersistentRemappableActionFeature::get_feature_info). + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct RemappableCapabilities: u16 { + /// Can send keyboard/keypad keys. + const KEYBOARD_REPORT = 1 << 0; + /// Can send mouse buttons. + const MOUSE_BUTTONS = 1 << 1; + /// Can send mouse X displacement. + const X_DISPLACEMENT = 1 << 2; + /// Can send mouse Y displacement. + const Y_DISPLACEMENT = 1 << 3; + /// Can send vertical roller increments. + const VERTICAL_ROLLER = 1 << 4; + /// Can send horizontal roller (AC pan) increments. + const HORIZONTAL_ROLLER = 1 << 5; + /// Can send consumer-control codes. + const CONSUMER_CONTROL = 1 << 6; + /// Can execute internal functions. + const INTERNAL_FUNCTION = 1 << 7; + /// Can send power keys. + const POWER_KEY = 1 << 8; + } +} + +bitflags::bitflags! { + /// Standard keyboard modifier keys for a remapped keyboard action. + /// + /// Modifiers only apply to keyboard reports. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct ModifierMask: u8 { + /// Left Control. + const LEFT_CTRL = 1 << 0; + /// Left Shift. + const LEFT_SHIFT = 1 << 1; + /// Left Alt. + const LEFT_ALT = 1 << 2; + /// Left GUI (Win/Command). + const LEFT_GUI = 1 << 3; + /// Right Control. + const RIGHT_CTRL = 1 << 4; + /// Right Shift. + const RIGHT_SHIFT = 1 << 5; + /// Right Alt. + const RIGHT_ALT = 1 << 6; + /// Right GUI (Win/Command). + const RIGHT_GUI = 1 << 7; + } +} + +bitflags::bitflags! { + /// A set of host slots for + /// [`reset_to_factory_settings`](PersistentRemappableActionFeature::reset_to_factory_settings). + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct HostMask: u8 { + /// Host 1. + const HOST_1 = 1 << 0; + /// Host 2. + const HOST_2 = 1 << 1; + /// Host 3. + const HOST_3 = 1 << 2; + } +} + +/// The action a control performs when triggered. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum ActionId { + /// Send a keyboard/keypad report (HID usage page 7). + SendKeyboard = 0x01, + /// Send a mouse-button report (usage page 9). + SendMouseButton = 0x02, + /// Send mouse X displacement (usage page 1, code 0x30). + SendXDisplacement = 0x03, + /// Send mouse Y displacement (usage page 1, code 0x31). + SendYDisplacement = 0x04, + /// Send vertical roller/wheel displacement (usage page 1, code 0x38). + SendVerticalRoller = 0x05, + /// Send horizontal roller / AC pan displacement (usage page 12, code 0x0238). + SendHorizontalRoller = 0x06, + /// Send a consumer-control report (usage page 12). + SendConsumerControl = 0x07, + /// Execute an internal function (the value is the function index). + ExecuteInternalFunction = 0x08, + /// Send a power-key report (usage page 1). + SendPowerKey = 0x09, +} + +/// Control-table sizing from +/// [`get_count`](PersistentRemappableActionFeature::get_count). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct RemappableInfo { + /// Number of control IDs in the table. + pub count: u8, + /// Number of hosts the device supports. + pub host_count: u8, +} + +/// The action mapped to a control, from +/// [`get_persistent_action`](PersistentRemappableActionFeature::get_persistent_action). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct PersistentAction { + /// The control the action belongs to. + pub cid: ControlId, + /// The host slot the mapping applies to. + pub host: HostIndex, + /// The action performed when triggered. + pub action_id: ActionId, + /// The HID usage code, displacement, or internal-function index sent. + pub value: u16, + /// Keyboard modifiers applied (keyboard actions only). + pub modifier_mask: ModifierMask, + /// Whether the control is remapped away from its default behaviour. + pub remapped: bool, +} + +impl PersistentAction { + fn from_payload(payload: &[u8; 16]) -> Result { + Ok(Self { + cid: ControlId::from(u16::from_be_bytes([payload[0], payload[1]])), + host: HostIndex::from(payload[2]), + action_id: ActionId::try_from(payload[3]) + .map_err(|_| Hidpp20Error::UnsupportedResponse)?, + value: u16::from_be_bytes([payload[4], payload[5]]), + modifier_mask: ModifierMask::from_bits_retain(payload[6]), + remapped: payload[7] & 1 != 0, + }) + } +} + +/// The action to assign with +/// [`set_persistent_action`](PersistentRemappableActionFeature::set_persistent_action). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub struct PersistentActionConfig { + /// The action to perform when triggered. + pub action_id: ActionId, + /// The HID usage code, displacement, or internal-function index to send. + pub value: u16, + /// Keyboard modifiers to apply (keyboard actions only). + pub modifier_mask: ModifierMask, +} + +/// Implements the `PersistentRemappableAction` / `0x1c00` feature. +#[derive(Clone)] +pub struct PersistentRemappableActionFeature { + /// The endpoint this feature talks to. + endpoint: FeatureEndpoint, +} + +impl CreatableFeature for PersistentRemappableActionFeature { + const ID: u16 = 0x1c00; + const STARTING_VERSION: u8 = 0; + + fn new(chan: Arc, device_index: u8, feature_index: u8) -> Self { + Self { + endpoint: FeatureEndpoint::new(chan, device_index, feature_index), + } + } +} + +impl Feature for PersistentRemappableActionFeature {} + +impl PersistentRemappableActionFeature { + /// Retrieves which HID outputs the device's remapping can produce. + pub async fn get_feature_info(&self) -> Result { + let payload = self.endpoint.call(0, [0; 3]).await?.extend_payload(); + Ok(RemappableCapabilities::from_bits_retain( + u16::from_be_bytes([payload[0], payload[1]]), + )) + } + + /// Retrieves the control-ID count and host count. + pub async fn get_count(&self) -> Result { + let payload = self.endpoint.call(1, [0; 3]).await?.extend_payload(); + Ok(RemappableInfo { + count: payload[0], + host_count: payload[1], + }) + } + + /// Retrieves the control ID at table `index` for `host`. + pub async fn get_cid_info( + &self, + index: u8, + host: HostIndex, + ) -> Result { + let payload = self + .endpoint + .call(2, [index, u8::from(host), 0]) + .await? + .extend_payload(); + Ok(ControlId::from(u16::from_be_bytes([ + payload[0], payload[1], + ]))) + } + + /// Retrieves the persistent action mapped to `cid` on `host`. + pub async fn get_persistent_action( + &self, + cid: ControlId, + host: HostIndex, + ) -> Result { + let [cid_hi, cid_lo] = u16::from(cid).to_be_bytes(); + let payload = self + .endpoint + .call(3, [cid_hi, cid_lo, u8::from(host)]) + .await? + .extend_payload(); + PersistentAction::from_payload(&payload) + } + + /// Persistently remaps `cid` on `host` to `config`. + /// + /// This writes to the device's non-volatile memory and changes the control's + /// behaviour until reset (see [`Self::reset_persistent_action`]). + pub async fn set_persistent_action( + &self, + cid: ControlId, + host: HostIndex, + config: PersistentActionConfig, + ) -> Result<(), Hidpp20Error> { + let [cid_hi, cid_lo] = u16::from(cid).to_be_bytes(); + let [value_hi, value_lo] = config.value.to_be_bytes(); + let mut args = [0; 16]; + args[..7].copy_from_slice(&[ + cid_hi, + cid_lo, + u8::from(host), + config.action_id.into(), + value_hi, + value_lo, + config.modifier_mask.bits(), + ]); + self.endpoint.call_long(4, args).await?; + Ok(()) + } + + /// Resets `cid` on `host` to its factory default action. + pub async fn reset_persistent_action( + &self, + cid: ControlId, + host: HostIndex, + ) -> Result<(), Hidpp20Error> { + let [cid_hi, cid_lo] = u16::from(cid).to_be_bytes(); + self.endpoint + .call(5, [cid_hi, cid_lo, u8::from(host)]) + .await?; + Ok(()) + } + + /// Resets every control to its factory default for the hosts in `hosts`. + pub async fn reset_to_factory_settings(&self, hosts: HostMask) -> Result<(), Hidpp20Error> { + self.endpoint.call(6, [hosts.bits(), 0, 0]).await?; + Ok(()) + } +} diff --git a/crates/openlogi-hidpp/src/feature/persistent_remappable_action/tests.rs b/crates/openlogi-hidpp/src/feature/persistent_remappable_action/tests.rs new file mode 100644 index 00000000..81baab97 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/persistent_remappable_action/tests.rs @@ -0,0 +1,79 @@ +//! Unit tests for `PersistentRemappableAction` payload parsing. + +use super::{ActionId, HostMask, ModifierMask, PersistentAction, RemappableCapabilities}; +use crate::feature::{hosts_info::HostIndex, reprog_controls::ControlId}; + +#[test] +fn parses_feature_info_flags() { + // flags MSB (byte0) carries the power-key bit (bit 8); flags LSB (byte1) the + // rest. Here: power key + keyboard + consumer control. + let payload = { + let mut p = [0; 16]; + p[0] = 0x01; // power key (bit 8) + p[1] = 0b0100_0001; // consumer ctrl (bit6) + keyboard (bit0) + p + }; + let caps = + RemappableCapabilities::from_bits_retain(u16::from_be_bytes([payload[0], payload[1]])); + assert!(caps.contains(RemappableCapabilities::POWER_KEY)); + assert!(caps.contains(RemappableCapabilities::KEYBOARD_REPORT)); + assert!(caps.contains(RemappableCapabilities::CONSUMER_CONTROL)); + assert!(!caps.contains(RemappableCapabilities::MOUSE_BUTTONS)); +} + +#[test] +fn parses_persistent_action() { + let mut payload = [0; 16]; + payload[0..2].copy_from_slice(&0x00c3u16.to_be_bytes()); // cid + payload[2] = 0xff; // current host + payload[3] = 0x01; // SendKeyboard + payload[4..6].copy_from_slice(&0x001eu16.to_be_bytes()); // value + payload[6] = ModifierMask::LEFT_CTRL.bits() | ModifierMask::LEFT_GUI.bits(); + payload[7] = 0x01; // remapped + + let action = PersistentAction::from_payload(&payload).unwrap(); + assert_eq!(action.cid, ControlId(0x00c3)); + assert_eq!(action.host, HostIndex::Current); + assert_eq!(action.action_id, ActionId::SendKeyboard); + assert_eq!(action.value, 0x001e); + assert!(action.modifier_mask.contains(ModifierMask::LEFT_CTRL)); + assert!(action.modifier_mask.contains(ModifierMask::LEFT_GUI)); + assert!(!action.modifier_mask.contains(ModifierMask::RIGHT_ALT)); + assert!(action.remapped); +} + +#[test] +fn reports_default_mapping_as_not_remapped() { + let mut payload = [0; 16]; + payload[3] = 0x08; // ExecuteInternalFunction + payload[7] = 0x00; // not remapped + + let action = PersistentAction::from_payload(&payload).unwrap(); + assert_eq!(action.action_id, ActionId::ExecuteInternalFunction); + assert!(!action.remapped); +} + +#[test] +fn rejects_unknown_action_id() { + let mut payload = [0; 16]; + payload[3] = 0x55; + + assert!(matches!( + PersistentAction::from_payload(&payload), + Err(crate::protocol::v20::Hidpp20Error::UnsupportedResponse) + )); +} + +#[test] +fn maps_action_id_wire_values() { + assert_eq!(ActionId::try_from(0x01u8).unwrap(), ActionId::SendKeyboard); + assert_eq!(ActionId::try_from(0x09u8).unwrap(), ActionId::SendPowerKey); + assert!(ActionId::try_from(0x00u8).is_err()); + assert_eq!(u8::from(ActionId::SendConsumerControl), 0x07); +} + +#[test] +fn host_mask_bits() { + let mask = HostMask::HOST_1 | HostMask::HOST_3; + assert_eq!(mask.bits(), 0b101); +} diff --git a/crates/openlogi-hidpp/src/feature/registry.rs b/crates/openlogi-hidpp/src/feature/registry.rs index 6a231722..9c7cb17a 100755 --- a/crates/openlogi-hidpp/src/feature/registry.rs +++ b/crates/openlogi-hidpp/src/feature/registry.rs @@ -35,6 +35,7 @@ use crate::{ mouse_pointer::MousePointerFeature, multi_platform::MultiPlatformFeature, per_key_lighting::PerKeyLightingFeature, + persistent_remappable_action::PersistentRemappableActionFeature, report_rate::ReportRateFeature, reprog_controls::ReprogControlsFeature, rgb_effects::RgbEffectsFeature, @@ -173,7 +174,7 @@ static KNOWN_FEATURES: LazyLock> = LazyLock::new(|| { 0x1b03 "ReprogControls4", 0x1b04 "ReprogControls5" => ReprogControlsFeature, 0x1bc0 "ReportHidUsages", - 0x1c00 "PersistentRemappableAction", + 0x1c00 "PersistentRemappableAction" => PersistentRemappableActionFeature, 0x1d4b "WirelessDeviceStatus" => WirelessDeviceStatusFeature, 0x1df0 "RemainingPairings", 0x1f1f "FirmwareProperties", From 80b5c094582302c247c8d0787e84d7d3588fee68 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sat, 27 Jun 2026 19:45:47 +0800 Subject: [PATCH 13/21] feat(hidpp): add crown wrapper --- .../openlogi-hidpp/src/feature/crown/event.rs | 121 +++++++++ .../openlogi-hidpp/src/feature/crown/mod.rs | 238 ++++++++++++++++++ .../openlogi-hidpp/src/feature/crown/tests.rs | 77 ++++++ crates/openlogi-hidpp/src/feature/mod.rs | 1 + crates/openlogi-hidpp/src/feature/registry.rs | 3 +- 5 files changed, 439 insertions(+), 1 deletion(-) create mode 100644 crates/openlogi-hidpp/src/feature/crown/event.rs create mode 100644 crates/openlogi-hidpp/src/feature/crown/mod.rs create mode 100644 crates/openlogi-hidpp/src/feature/crown/tests.rs diff --git a/crates/openlogi-hidpp/src/feature/crown/event.rs b/crates/openlogi-hidpp/src/feature/crown/event.rs new file mode 100644 index 00000000..59a2b422 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/crown/event.rs @@ -0,0 +1,121 @@ +//! The event emitted by the `Crown` feature (`0x4600`). + +use num_enum::{IntoPrimitive, TryFromPrimitive}; + +/// Rotation phase reported in a [`CrownUpdate`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum RotationState { + /// Not rotating (or not diverted). + Inactive = 0, + /// Rotation started. + Start = 1, + /// Rotation ongoing. + Active = 2, + /// Rotation stopped. + Stop = 3, +} + +/// Proximity or touch activity phase reported in a [`CrownUpdate`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum ActivityState { + /// Inactive. + Inactive = 0, + /// Started. + Start = 1, + /// Ongoing. + Active = 2, + /// Stopped. + Stop = 3, +} + +/// Touch gesture reported in a [`CrownUpdate`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum CrownGesture { + /// No gesture. + None = 0, + /// Single tap. + Tap = 1, + /// Double tap. + DoubleTap = 2, +} + +/// Crown button state reported in a [`CrownUpdate`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum ButtonState { + /// Inactive (or not diverted). + Inactive = 0, + /// Press started. + Press = 1, + /// Short press active. + ShortPressActive = 2, + /// Long press reached the time threshold. + LongPress = 3, + /// Long press active. + LongPressActive = 4, + /// Released. + Release = 5, +} + +/// An event emitted by [`CrownFeature`](super::CrownFeature). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub enum CrownEvent { + /// The crown's rotation, proximity, touch, gesture or button state changed. + /// + /// Only reported while the crown is diverted (see + /// [`set_mode`](super::CrownFeature::set_mode)). + Update(CrownUpdate), +} + +/// Payload of [`CrownEvent::Update`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct CrownUpdate { + /// Current rotation phase. + pub rotation_state: RotationState, + /// Slots rotated since the last event (`-127..=127`). + pub relative_slot_rotation: i8, + /// Ratchets rotated since the last event (`-127..=127`). + pub relative_ratchet_rotation: i8, + /// Proximity-sensor phase. + pub proximity: ActivityState, + /// Touch-sensor phase. + pub touch: ActivityState, + /// Touch gesture detected. + pub gesture: CrownGesture, + /// Button state. + pub button: ButtonState, + /// Crown speed in slots per second (signed). + pub speed: i16, +} + +/// Decodes the `0x4600` event payload by its sub-id. +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()?, + 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()?, + speed: i16::from_be_bytes([payload[14], payload[15]]), + })), + _ => None, + } +} diff --git a/crates/openlogi-hidpp/src/feature/crown/mod.rs b/crates/openlogi-hidpp/src/feature/crown/mod.rs new file mode 100644 index 00000000..f28a5468 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/crown/mod.rs @@ -0,0 +1,238 @@ +//! Implements the `Crown` feature (ID `0x4600`) for the MX Master's rotary +//! crown: reading its capabilities, configuring its mode (HID vs diverted, +//! free vs ratchet, timeouts), and receiving diverted rotation/touch/button +//! events. + +pub mod event; + +#[cfg(test)] +mod tests; + +use std::sync::Arc; + +use num_enum::{IntoPrimitive, TryFromPrimitive}; + +pub use event::{ActivityState, ButtonState, CrownEvent, CrownGesture, CrownUpdate, RotationState}; + +use crate::{ + channel::{HidppChannel, MessageListenerGuard}, + event::EventEmitter, + feature::{CreatableFeature, EmittingFeature, Feature, FeatureEndpoint, event_payload}, + protocol::v20::Hidpp20Error, +}; + +bitflags::bitflags! { + /// Crown control capabilities, from [`get_info`](CrownFeature::get_info). + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct CrownControlCapabilities: u8 { + /// The crown has a button. + const BUTTON = 1 << 0; + /// The button reports long presses. + const BUTTON_LONG_PRESS = 1 << 1; + /// The ratchet is mechanized (no manual control). + const MECHANIZED_RATCHET = 1 << 2; + /// The rotation timeout is configurable. + const ROTATION_TIMEOUT_CONFIGURABLE = 1 << 3; + /// The short-long timeout is configurable. + const SHORT_LONG_TIMEOUT_CONFIGURABLE = 1 << 4; + /// The double-tap speed is configurable. + const DOUBLE_TAP_SPEED_CONFIGURABLE = 1 << 5; + } +} + +bitflags::bitflags! { + /// Crown sensor capabilities, from [`get_info`](CrownFeature::get_info). + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct CrownSensorCapabilities: u8 { + /// The crown has a proximity sensor. + const PROXIMITY = 1 << 0; + /// The crown has a touch sensor. + const TOUCH = 1 << 1; + /// The crown detects tap gestures. + const TAP_GESTURE = 1 << 2; + /// The crown detects double-tap gestures. + const DOUBLE_TAP_GESTURE = 1 << 3; + } +} + +/// How crown events are reported. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum ReportingMode { + /// Leave the setting unchanged (write-only sentinel). + NoChange = 0, + /// Events go to the native HID channel. + Hid = 1, + /// Events are diverted to HID++ (required for [`CrownEvent`]). + Diverted = 2, +} + +/// The crown's ratchet mode. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum RatchetMode { + /// Leave the setting unchanged (write-only sentinel). + NoChange = 0, + /// Free-spinning mode. + Free = 1, + /// Ratchet (detented) mode. + Ratchet = 2, +} + +/// Crown info constants from [`get_info`](CrownFeature::get_info). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct CrownInfo { + /// Control capabilities. + pub controls: CrownControlCapabilities, + /// Sensor capabilities. + pub sensors: CrownSensorCapabilities, + /// Number of slots per revolution. + pub slots: u16, + /// Number of ratchets per revolution. + pub ratchets: u16, +} + +/// The crown's mode, from [`get_mode`](CrownFeature::get_mode) and echoed by +/// [`set_mode`](CrownFeature::set_mode). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct CrownMode { + /// How events are reported. + pub diverting: ReportingMode, + /// Ratchet mode. + pub ratchet_mode: RatchetMode, + /// Rotation timeout, in 10 ms steps. + pub rotation_timeout: u8, + /// Short-long press timeout, in 10 ms steps. + pub short_long_timeout: u8, + /// Double-tap speed, in 10 ms steps. + pub double_tap_speed: u8, +} + +impl CrownMode { + fn from_payload(payload: &[u8; 16]) -> Result { + Ok(Self { + diverting: ReportingMode::try_from(payload[0]) + .map_err(|_| Hidpp20Error::UnsupportedResponse)?, + ratchet_mode: RatchetMode::try_from(payload[1]) + .map_err(|_| Hidpp20Error::UnsupportedResponse)?, + rotation_timeout: payload[2], + short_long_timeout: payload[3], + double_tap_speed: payload[4], + }) + } +} + +/// Mode settings to write with [`set_mode`](CrownFeature::set_mode). +/// +/// Every field uses `0` / [`ReportingMode::NoChange`] / [`RatchetMode::NoChange`] +/// as a "leave unchanged" sentinel. The rotation timeout is clipped to `0x40`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub struct SetCrownMode { + /// How events are reported, or [`ReportingMode::NoChange`]. + pub diverting: ReportingMode, + /// Ratchet mode, or [`RatchetMode::NoChange`]. + pub ratchet_mode: RatchetMode, + /// Rotation timeout in 10 ms steps, or `0` to leave unchanged. + pub rotation_timeout: u8, + /// Short-long timeout in 10 ms steps, or `0` to leave unchanged. + pub short_long_timeout: u8, + /// Double-tap speed in 10 ms steps, or `0` to leave unchanged. + pub double_tap_speed: u8, +} + +/// Implements the `Crown` / `0x4600` feature. +pub struct CrownFeature { + /// The endpoint this feature talks to. + endpoint: FeatureEndpoint, + + /// The emitter used to publish decoded events. + emitter: Arc>, + + /// Removes the message listener when the feature is dropped. + _msg_listener: MessageListenerGuard, +} + +impl CreatableFeature for CrownFeature { + const ID: u16 = 0x4600; + const STARTING_VERSION: u8 = 0; + + fn new(chan: Arc, device_index: u8, feature_index: u8) -> Self { + let emitter = Arc::new(EventEmitter::new()); + + let listener = chan.add_msg_listener_guarded({ + let emitter = Arc::clone(&emitter); + + move |raw, matched| { + let Some((func, payload)) = + event_payload(raw, matched, device_index, feature_index) + else { + return; + }; + if let Some(event) = event::decode_event(func.to_lo(), &payload) { + emitter.emit(event); + } + } + }); + + Self { + endpoint: FeatureEndpoint::new(chan, device_index, feature_index), + emitter, + _msg_listener: listener, + } + } +} + +impl Feature for CrownFeature {} + +impl EmittingFeature for CrownFeature { + fn listen(&self) -> async_channel::Receiver { + self.emitter.create_receiver() + } +} + +impl CrownFeature { + /// Retrieves the crown's capabilities and slot/ratchet counts. + pub async fn get_info(&self) -> Result { + let payload = self.endpoint.call(0, [0; 3]).await?.extend_payload(); + Ok(CrownInfo { + controls: CrownControlCapabilities::from_bits_retain(payload[0]), + sensors: CrownSensorCapabilities::from_bits_retain(payload[1]), + slots: u16::from_be_bytes([payload[2], payload[3]]), + ratchets: u16::from_be_bytes([payload[4], payload[5]]), + }) + } + + /// Retrieves the crown's current mode. + pub async fn get_mode(&self) -> Result { + let payload = self.endpoint.call(1, [0; 3]).await?.extend_payload(); + CrownMode::from_payload(&payload) + } + + /// Sets the crown's mode and returns the resulting mode echoed by the device. + /// + /// Divert the crown ([`ReportingMode::Diverted`]) for [`CrownEvent`]s to be + /// emitted. + pub async fn set_mode(&self, mode: SetCrownMode) -> Result { + let mut args = [0; 16]; + args[..5].copy_from_slice(&[ + mode.diverting.into(), + mode.ratchet_mode.into(), + mode.rotation_timeout, + mode.short_long_timeout, + mode.double_tap_speed, + ]); + let payload = self.endpoint.call_long(2, args).await?.extend_payload(); + CrownMode::from_payload(&payload) + } +} diff --git a/crates/openlogi-hidpp/src/feature/crown/tests.rs b/crates/openlogi-hidpp/src/feature/crown/tests.rs new file mode 100644 index 00000000..22491984 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/crown/tests.rs @@ -0,0 +1,77 @@ +//! Unit tests for `Crown` mode parsing and event decoding. + +use super::event::{ + ActivityState, ButtonState, CrownEvent, CrownGesture, RotationState, decode_event, +}; +use super::{CrownMode, RatchetMode, ReportingMode}; + +#[test] +fn parses_mode() { + let mut payload = [0; 16]; + payload[0] = 2; // Diverted + payload[1] = 2; // Ratchet + payload[2] = 0x10; + payload[3] = 0x20; + payload[4] = 0x05; + + let mode = CrownMode::from_payload(&payload).unwrap(); + assert_eq!(mode.diverting, ReportingMode::Diverted); + assert_eq!(mode.ratchet_mode, RatchetMode::Ratchet); + assert_eq!(mode.rotation_timeout, 0x10); + assert_eq!(mode.short_long_timeout, 0x20); + assert_eq!(mode.double_tap_speed, 0x05); +} + +#[test] +fn rejects_unknown_mode_value() { + let mut payload = [0; 16]; + payload[0] = 9; + + assert!(matches!( + CrownMode::from_payload(&payload), + Err(crate::protocol::v20::Hidpp20Error::UnsupportedResponse) + )); +} + +#[test] +fn decodes_crown_event_with_signed_fields() { + let mut payload = [0; 16]; + payload[0] = 1; // Start + payload[1] = 0xfb; // -5 slots + payload[2] = 0x03; // +3 ratchets + payload[3] = 2; // proximity Active + payload[4] = 1; // touch Start + payload[5] = 1; // Tap + payload[6] = 3; // LongPress + payload[14..16].copy_from_slice(&(-200i16).to_be_bytes()); + + let CrownEvent::Update(update) = decode_event(0, &payload).unwrap(); + assert_eq!(update.rotation_state, RotationState::Start); + assert_eq!(update.relative_slot_rotation, -5); + assert_eq!(update.relative_ratchet_rotation, 3); + assert_eq!(update.proximity, ActivityState::Active); + assert_eq!(update.touch, ActivityState::Start); + assert_eq!(update.gesture, CrownGesture::Tap); + assert_eq!(update.button, ButtonState::LongPress); + assert_eq!(update.speed, -200); +} + +#[test] +fn ignores_unknown_event_sub_id() { + assert!(decode_event(1, &[0; 16]).is_none()); +} + +#[test] +fn ignores_event_with_unknown_enum() { + let mut payload = [0; 16]; + payload[6] = 0x09; // out-of-range button state + assert!(decode_event(0, &payload).is_none()); +} + +#[test] +fn maps_mode_enum_wire_values() { + assert_eq!(u8::from(ReportingMode::Diverted), 2); + assert_eq!(ReportingMode::try_from(1u8).unwrap(), ReportingMode::Hid); + assert_eq!(u8::from(RatchetMode::Free), 1); + assert!(RatchetMode::try_from(3u8).is_err()); +} diff --git a/crates/openlogi-hidpp/src/feature/mod.rs b/crates/openlogi-hidpp/src/feature/mod.rs index 1b9e1f07..12a04ec3 100755 --- a/crates/openlogi-hidpp/src/feature/mod.rs +++ b/crates/openlogi-hidpp/src/feature/mod.rs @@ -13,6 +13,7 @@ pub mod backlight; pub mod brightness_control; pub mod change_host; pub mod color_led_effects; +pub mod crown; pub mod device_friendly_name; pub mod device_information; pub mod device_type_and_name; diff --git a/crates/openlogi-hidpp/src/feature/registry.rs b/crates/openlogi-hidpp/src/feature/registry.rs index 9c7cb17a..213a8b73 100755 --- a/crates/openlogi-hidpp/src/feature/registry.rs +++ b/crates/openlogi-hidpp/src/feature/registry.rs @@ -17,6 +17,7 @@ use crate::{ brightness_control::BrightnessControlFeature, change_host::ChangeHostFeature, color_led_effects::ColorLedEffectsFeature, + crown::CrownFeature, device_friendly_name::DeviceFriendlyNameFeature, device_information::DeviceInformationFeature, device_type_and_name::DeviceTypeAndNameFeature, @@ -210,7 +211,7 @@ static KNOWN_FEATURES: LazyLock> = LazyLock::new(|| { 0x4530 "DualPlatform" => DualPlatformFeature, 0x4531 "MultiPlatform" => MultiPlatformFeature, 0x4540 "KeyboardInternationalLayouts", - 0x4600 "Crown", + 0x4600 "Crown" => CrownFeature, 0x6010 "TouchpadFwItems", 0x6011 "TouchpadSwItems", 0x6012 "TouchpadWin8FwItems", From 4fdf710262f0efdf3dc9b46d542b0ddc33b06664 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sat, 27 Jun 2026 19:52:43 +0800 Subject: [PATCH 14/21] feat(hidpp): add raw touch wrappers --- crates/openlogi-hidpp/src/feature/mod.rs | 2 + crates/openlogi-hidpp/src/feature/registry.rs | 6 +- .../src/feature/touch_mouse_raw/event.rs | 85 +++++++++ .../src/feature/touch_mouse_raw/mod.rs | 162 ++++++++++++++++ .../src/feature/touch_mouse_raw/tests.rs | 84 ++++++++ .../src/feature/touchpad_raw_xy/event.rs | 101 ++++++++++ .../src/feature/touchpad_raw_xy/mod.rs | 180 ++++++++++++++++++ .../src/feature/touchpad_raw_xy/tests.rs | 97 ++++++++++ 8 files changed, 715 insertions(+), 2 deletions(-) create mode 100644 crates/openlogi-hidpp/src/feature/touch_mouse_raw/event.rs create mode 100644 crates/openlogi-hidpp/src/feature/touch_mouse_raw/mod.rs create mode 100644 crates/openlogi-hidpp/src/feature/touch_mouse_raw/tests.rs create mode 100644 crates/openlogi-hidpp/src/feature/touchpad_raw_xy/event.rs create mode 100644 crates/openlogi-hidpp/src/feature/touchpad_raw_xy/mod.rs create mode 100644 crates/openlogi-hidpp/src/feature/touchpad_raw_xy/tests.rs diff --git a/crates/openlogi-hidpp/src/feature/mod.rs b/crates/openlogi-hidpp/src/feature/mod.rs index 12a04ec3..66c86ddd 100755 --- a/crates/openlogi-hidpp/src/feature/mod.rs +++ b/crates/openlogi-hidpp/src/feature/mod.rs @@ -42,6 +42,8 @@ pub mod sidetone; pub mod smartshift; pub mod smartshift_enhanced; pub mod thumbwheel; +pub mod touch_mouse_raw; +pub mod touchpad_raw_xy; pub mod unified_battery; pub mod vertical_scrolling; pub mod wireless_device_status; diff --git a/crates/openlogi-hidpp/src/feature/registry.rs b/crates/openlogi-hidpp/src/feature/registry.rs index 213a8b73..27012fb0 100755 --- a/crates/openlogi-hidpp/src/feature/registry.rs +++ b/crates/openlogi-hidpp/src/feature/registry.rs @@ -45,6 +45,8 @@ use crate::{ smartshift::SmartShiftFeature, smartshift_enhanced::SmartShiftEnhancedFeature, thumbwheel::ThumbwheelFeature, + touch_mouse_raw::TouchMouseRawFeature, + touchpad_raw_xy::TouchpadRawXyFeature, unified_battery::UnifiedBatteryFeature, vertical_scrolling::VerticalScrollingFeature, wireless_device_status::WirelessDeviceStatusFeature, @@ -219,8 +221,8 @@ static KNOWN_FEATURES: LazyLock> = LazyLock::new(|| { 0x6021 "TapEnableExtended", 0x6030 "CursorBallistic", 0x6040 "TouchpadResolutionDivider", - 0x6100 "TouchpadRawXy", - 0x6110 "TouchMouseRawTouchPoints", + 0x6100 "TouchpadRawXy" => TouchpadRawXyFeature, + 0x6110 "TouchMouseRawTouchPoints" => TouchMouseRawFeature, 0x6120 "BtTouchMouseSettings", 0x6500 "Gestures1", 0x6501 "Gestures2", diff --git a/crates/openlogi-hidpp/src/feature/touch_mouse_raw/event.rs b/crates/openlogi-hidpp/src/feature/touch_mouse_raw/event.rs new file mode 100644 index 00000000..038a6be1 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/touch_mouse_raw/event.rs @@ -0,0 +1,85 @@ +//! Events emitted by `TouchMouseRaw` (`0x6110`). + +/// Number of touch points carried by a raw-data report. +pub const TOUCH_POINT_COUNT: usize = 4; +/// Byte value in a coordinate's high byte that marks a lifted (absent) finger. +const LIFTED: u8 = 0xff; + +bitflags::bitflags! { + /// Mouse status flags from [`TouchMouseRawEvent::StatusChanged`]. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct TouchMouseStatus: u8 { + /// The mouse is lifted off the surface. + const MOUSE_LIFTED = 1 << 0; + /// A mouse button is pressed. + const BUTTON_DOWN = 1 << 1; + } +} + +/// A single touch point in a raw-data report. +/// +/// The finger ID is the touch point's position (`0..4`) in the report. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct TouchMousePoint { + /// 12-bit X coordinate. + pub x: u16, + /// 12-bit Y coordinate. + pub y: u16, + /// Contact width along X (4-bit), or Z in the Z-reporting mode. + pub width_x: u8, + /// Contact width along Y (4-bit). + pub width_y: u8, +} + +/// An event emitted by [`TouchMouseRawFeature`](super::TouchMouseRawFeature). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub enum TouchMouseRawEvent { + /// Raw data for up to four touch points; a lifted finger is `None`. + /// + /// Only reported in a raw mode (see + /// [`set_raw_mode`](super::TouchMouseRawFeature::set_raw_mode)). + RawData { + /// Touch points indexed by finger ID (`0..4`). + touches: [Option; TOUCH_POINT_COUNT], + }, + /// A mouse status flag changed. + StatusChanged(TouchMouseStatus), +} + +/// Decodes one touch point's four bytes, or `None` when the finger is lifted. +fn decode_touch(bytes: &[u8]) -> Option { + let [x_high, y_high, low_nibbles, widths] = [bytes[0], bytes[1], bytes[2], bytes[3]]; + if x_high == LIFTED { + return None; + } + Some(TouchMousePoint { + // X takes the high 8 bits from `x_high` and the low 4 from the low nibble. + x: (u16::from(x_high) << 4) | u16::from(low_nibbles & 0x0f), + // Y takes the high 8 bits from `y_high` and the low 4 from the high nibble. + y: (u16::from(y_high) << 4) | u16::from(low_nibbles >> 4), + width_x: widths & 0x0f, + width_y: widths >> 4, + }) +} + +/// Decodes the `0x6110` event payload by its sub-id. +pub(super) fn decode_event(sub_id: u8, payload: &[u8; 16]) -> Option { + match sub_id { + 0 => { + let mut touches = [None; TOUCH_POINT_COUNT]; + for (i, touch) in touches.iter_mut().enumerate() { + *touch = decode_touch(&payload[i * 4..i * 4 + 4]); + } + Some(TouchMouseRawEvent::RawData { touches }) + } + 1 => Some(TouchMouseRawEvent::StatusChanged( + TouchMouseStatus::from_bits_retain(payload[0]), + )), + _ => None, + } +} diff --git a/crates/openlogi-hidpp/src/feature/touch_mouse_raw/mod.rs b/crates/openlogi-hidpp/src/feature/touch_mouse_raw/mod.rs new file mode 100644 index 00000000..2af6815c --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/touch_mouse_raw/mod.rs @@ -0,0 +1,162 @@ +//! Implements the `TouchMouseRaw` feature (ID `0x6110`) that exposes a touch +//! mouse's raw touch points: pad characteristics, the raw-data mode, and the +//! raw-data / status events. + +pub mod event; + +#[cfg(test)] +mod tests; + +use std::sync::Arc; + +use num_enum::{IntoPrimitive, TryFromPrimitive}; + +pub use event::{TouchMousePoint, TouchMouseRawEvent, TouchMouseStatus}; + +use crate::{ + channel::{HidppChannel, MessageListenerGuard}, + event::EventEmitter, + feature::{CreatableFeature, EmittingFeature, Feature, FeatureEndpoint, event_payload}, + protocol::v20::Hidpp20Error, +}; + +/// The position of the touch surface's coordinate origin, viewed from above. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum Origin { + /// Lower-left corner. + LowerLeft = 1, + /// Lower-right corner. + LowerRight = 2, + /// Upper-left corner. + UpperLeft = 3, + /// Upper-right corner. + UpperRight = 4, +} + +/// The raw-reporting mode of a touch mouse. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum RawMode { + /// Native gestures only (out of the box). + NativeGestures = 0, + /// Filtered raw data. + RawFiltered = 1, + /// Unfiltered raw data plus native gestures. + RawUnfilteredAndGestures = 2, + /// Unfiltered raw data, sent even while lifted or with a button active. + RawUnfilteredAlways = 3, + /// Like [`RawUnfilteredAndGestures`](Self::RawUnfilteredAndGestures) but with + /// Z information in place of width. + RawUnfilteredWithZ = 4, +} + +/// Touch-mouse characteristics from +/// [`get_touchpad_info`](TouchMouseRawFeature::get_touchpad_info). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct TouchMouseInfo { + /// Maximum X count in dots. + pub x_max_count: u16, + /// Maximum Y count in dots. + pub y_max_count: u16, + /// Sensor resolution in DPI (assumed equal for X and Y). + pub resolution_dpi: u16, + /// Position of the coordinate origin. + pub origin: Origin, + /// Maximum number of reported fingers. + pub max_finger_count: u8, + /// Maximum value of the touch-point width/height data. + pub width_height_data_range: u8, +} + +impl TouchMouseInfo { + fn from_payload(payload: &[u8; 16]) -> Result { + Ok(Self { + x_max_count: u16::from_be_bytes([payload[0], payload[1]]), + y_max_count: u16::from_be_bytes([payload[2], payload[3]]), + resolution_dpi: u16::from_be_bytes([payload[4], payload[5]]), + origin: Origin::try_from(payload[6]).map_err(|_| Hidpp20Error::UnsupportedResponse)?, + max_finger_count: payload[7], + width_height_data_range: payload[8], + }) + } +} + +/// Implements the `TouchMouseRaw` / `0x6110` feature. +pub struct TouchMouseRawFeature { + /// The endpoint this feature talks to. + endpoint: FeatureEndpoint, + + /// The emitter used to publish decoded events. + emitter: Arc>, + + /// Removes the message listener when the feature is dropped. + _msg_listener: MessageListenerGuard, +} + +impl CreatableFeature for TouchMouseRawFeature { + const ID: u16 = 0x6110; + const STARTING_VERSION: u8 = 0; + + fn new(chan: Arc, device_index: u8, feature_index: u8) -> Self { + let emitter = Arc::new(EventEmitter::new()); + + let listener = chan.add_msg_listener_guarded({ + let emitter = Arc::clone(&emitter); + + move |raw, matched| { + let Some((func, payload)) = + event_payload(raw, matched, device_index, feature_index) + else { + return; + }; + if let Some(event) = event::decode_event(func.to_lo(), &payload) { + emitter.emit(event); + } + } + }); + + Self { + endpoint: FeatureEndpoint::new(chan, device_index, feature_index), + emitter, + _msg_listener: listener, + } + } +} + +impl Feature for TouchMouseRawFeature {} + +impl EmittingFeature for TouchMouseRawFeature { + fn listen(&self) -> async_channel::Receiver { + self.emitter.create_receiver() + } +} + +impl TouchMouseRawFeature { + /// Retrieves the touch mouse's characteristics. + pub async fn get_touchpad_info(&self) -> Result { + let payload = self.endpoint.call(0, [0; 3]).await?.extend_payload(); + TouchMouseInfo::from_payload(&payload) + } + + /// Retrieves the current raw-reporting mode. + pub async fn get_raw_mode(&self) -> Result { + let payload = self.endpoint.call(1, [0; 3]).await?.extend_payload(); + RawMode::try_from(payload[0]).map_err(|_| Hidpp20Error::UnsupportedResponse) + } + + /// Sets the raw-reporting mode. + /// + /// A raw mode must be selected for [`TouchMouseRawEvent::RawData`] events to + /// be emitted. + pub async fn set_raw_mode(&self, mode: RawMode) -> Result<(), Hidpp20Error> { + self.endpoint.call(2, [mode.into(), 0, 0]).await?; + Ok(()) + } +} diff --git a/crates/openlogi-hidpp/src/feature/touch_mouse_raw/tests.rs b/crates/openlogi-hidpp/src/feature/touch_mouse_raw/tests.rs new file mode 100644 index 00000000..3b8de1c2 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/touch_mouse_raw/tests.rs @@ -0,0 +1,84 @@ +//! Unit tests for `TouchMouseRaw` info parsing and raw-event decoding. + +use super::event::{TouchMousePoint, TouchMouseRawEvent, TouchMouseStatus, decode_event}; +use super::{Origin, RawMode, TouchMouseInfo}; + +#[test] +fn parses_touch_mouse_info() { + let mut payload = [0; 16]; + payload[0..2].copy_from_slice(&2048u16.to_be_bytes()); + payload[2..4].copy_from_slice(&1280u16.to_be_bytes()); + payload[4..6].copy_from_slice(&1200u16.to_be_bytes()); + payload[6] = 3; // upper-left + payload[7] = 4; // max fingers + payload[8] = 15; // width/height data range + + let info = TouchMouseInfo::from_payload(&payload).unwrap(); + assert_eq!(info.x_max_count, 2048); + assert_eq!(info.y_max_count, 1280); + assert_eq!(info.resolution_dpi, 1200); + assert_eq!(info.origin, Origin::UpperLeft); + assert_eq!(info.max_finger_count, 4); + assert_eq!(info.width_height_data_range, 15); +} + +#[test] +fn decodes_raw_data_with_lifted_fingers() { + // touch 0 present (X=0x123, Y=0x045, Wx=2, Wy=3); touch 2 present (X=0x200, + // Y=0x100, Wx=1, Wy=1); touches 1 and 3 lifted (0xFF). + let payload = [ + 0x12, 0x04, 0x53, 0x32, // touch 0 + 0xff, 0xff, 0xff, 0xff, // touch 1 lifted + 0x20, 0x10, 0x00, 0x11, // touch 2 + 0xff, 0xff, 0xff, 0xff, // touch 3 lifted + ]; + + let TouchMouseRawEvent::RawData { touches } = decode_event(0, &payload).unwrap() else { + panic!("expected raw data"); + }; + assert_eq!( + touches[0], + Some(TouchMousePoint { + x: 0x123, + y: 0x045, + width_x: 2, + width_y: 3 + }) + ); + assert_eq!(touches[1], None); + assert_eq!( + touches[2], + Some(TouchMousePoint { + x: 0x200, + y: 0x100, + width_x: 1, + width_y: 1 + }) + ); + assert_eq!(touches[3], None); +} + +#[test] +fn decodes_status_event() { + let mut payload = [0; 16]; + payload[0] = 0b11; // mouse lifted + button down + + let TouchMouseRawEvent::StatusChanged(status) = decode_event(1, &payload).unwrap() else { + panic!("expected status event"); + }; + assert!(status.contains(TouchMouseStatus::MOUSE_LIFTED)); + assert!(status.contains(TouchMouseStatus::BUTTON_DOWN)); +} + +#[test] +fn ignores_unknown_event_sub_id() { + assert!(decode_event(2, &[0; 16]).is_none()); +} + +#[test] +fn maps_raw_mode_wire_values() { + assert_eq!(RawMode::try_from(0u8).unwrap(), RawMode::NativeGestures); + assert_eq!(RawMode::try_from(4u8).unwrap(), RawMode::RawUnfilteredWithZ); + assert!(RawMode::try_from(5u8).is_err()); + assert_eq!(u8::from(RawMode::RawFiltered), 1); +} diff --git a/crates/openlogi-hidpp/src/feature/touchpad_raw_xy/event.rs b/crates/openlogi-hidpp/src/feature/touchpad_raw_xy/event.rs new file mode 100644 index 00000000..9ca0cc92 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/touchpad_raw_xy/event.rs @@ -0,0 +1,101 @@ +//! The raw touch-data event emitted by `TouchpadRawXy` (`0x6100`). + +/// One touch point from a [`DualXyData`] frame. +/// +/// `x`/`y` are 14-bit device coordinates. The `z` and `area` bytes are +/// mode-dependent: in the default layout they are the Z distance and touch area, +/// but the active report flags (see +/// [`set_raw_report_state`](super::TouchpadRawXyFeature::set_raw_report_state)) +/// can repurpose them — e.g. a 16-bit force spans both bytes, or `area` carries +/// width/height or major/minor data. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct TouchPoint { + /// Contact type (2-bit): `0` = finger, others reserved. + pub contact_type: u8, + /// Contact status (2-bit): `0` = hover, `1` = touch, others reserved. + pub contact_status: u8, + /// 14-bit X coordinate of the touch centre. + pub x: u16, + /// 14-bit Y coordinate of the touch centre. + pub y: u16, + /// Unique finger ID (4-bit). + pub finger_id: u8, + /// Z distance, or force MSB in 16-bit-force mode (mode-dependent). + pub z: u8, + /// Touch area, or width/height / major-minor / force LSB (mode-dependent). + pub area: u8, +} + +/// A frame of raw touch data, carrying up to two touch points. +/// +/// Frames describing more than two fingers are split across several events with +/// the same `timestamp`; the last sets `end_of_frame`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct DualXyData { + /// Running frame timestamp (unit from + /// [`TouchpadInfo::timestamp_units`](super::TouchpadInfo::timestamp_units)). + pub timestamp: u16, + /// First touch point. + pub touch1: TouchPoint, + /// Second touch point. + pub touch2: TouchPoint, + /// Whether the physical switch under the surface is pressed. + pub button: bool, + /// Whether this is the last event for the frame. + pub end_of_frame: bool, + /// Total number of fingers in the frame. + pub finger_count: u8, +} + +/// An event emitted by [`TouchpadRawXyFeature`](super::TouchpadRawXyFeature). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub enum TouchpadRawEvent { + /// A new frame of raw touch data (up to two touch points). + /// + /// Only reported while raw reporting is enabled (see + /// [`set_raw_report_state`](super::TouchpadRawXyFeature::set_raw_report_state)). + DualXy(DualXyData), +} + +/// Extracts a 14-bit coordinate from a high byte (low 6 bits) and a low byte. +fn coord14(high: u8, low: u8) -> u16 { + (u16::from(high & 0x3f) << 8) | u16::from(low) +} + +/// Decodes the `0x6100` event payload by its sub-id (default report layout). +pub(super) fn decode_event(sub_id: u8, payload: &[u8; 16]) -> Option { + match sub_id { + 0 => Some(TouchpadRawEvent::DualXy(DualXyData { + timestamp: u16::from_be_bytes([payload[0], payload[1]]), + touch1: TouchPoint { + contact_type: payload[2] >> 6, + contact_status: payload[4] >> 6, + x: coord14(payload[2], payload[3]), + y: coord14(payload[4], payload[5]), + finger_id: payload[8] >> 4, + z: payload[6], + area: payload[7], + }, + touch2: TouchPoint { + contact_type: payload[9] >> 6, + contact_status: payload[11] >> 6, + x: coord14(payload[9], payload[10]), + y: coord14(payload[11], payload[12]), + finger_id: payload[15] >> 4, + z: payload[13], + area: payload[14], + }, + // Byte 8 carries frame-level flags alongside touch 1's finger id. + button: payload[8] & (1 << 2) != 0, + end_of_frame: payload[8] & 1 != 0, + finger_count: payload[15] & 0x0f, + })), + _ => None, + } +} diff --git a/crates/openlogi-hidpp/src/feature/touchpad_raw_xy/mod.rs b/crates/openlogi-hidpp/src/feature/touchpad_raw_xy/mod.rs new file mode 100644 index 00000000..69d777aa --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/touchpad_raw_xy/mod.rs @@ -0,0 +1,180 @@ +//! Implements the `TouchpadRawXy` feature (ID `0x6100`) that exposes a +//! touchpad's raw multi-touch data: pad characteristics, the raw-report mode, +//! and a per-frame [`DualXyData`] event. + +pub mod event; + +#[cfg(test)] +mod tests; + +use std::sync::Arc; + +use num_enum::{IntoPrimitive, TryFromPrimitive}; + +pub use event::{DualXyData, TouchPoint, TouchpadRawEvent}; + +use crate::{ + channel::{HidppChannel, MessageListenerGuard}, + event::EventEmitter, + feature::{CreatableFeature, EmittingFeature, Feature, FeatureEndpoint, event_payload}, + protocol::v20::Hidpp20Error, +}; + +bitflags::bitflags! { + /// Raw-report mode flags from + /// [`get_raw_report_state`](TouchpadRawXyFeature::get_raw_report_state). + /// + /// Some combinations are mutually exclusive; common valid bitmaps are `0x00` + /// (off), `0x05`, `0x09`, `0x21` and `0x41` (see the feature spec). + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Serialize))] + pub struct RawReportFlags: u8 { + /// Raw reporting enabled. + const RAW = 1 << 0; + /// Add force data to 16-bit reporting (deprecated). + const FORCE_ADD = 1 << 1; + /// Enhanced reporting enabled. + const ENHANCED = 1 << 2; + /// Report width/height instead of area. + const WIDTH_HEIGHT = 1 << 3; + /// Report native gestures. + const NATIVE_GESTURE = 1 << 4; + /// Report major/minor/orientation. + const MAJOR_MINOR = 1 << 5; + /// Report 8-bit width and height bytes instead of area. + const WIDTH_HEIGHT_8BIT = 1 << 6; + } +} + +/// The position of a touchpad's coordinate origin, viewed from above. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum Origin { + /// Lower-left corner. + LowerLeft = 1, + /// Lower-right corner. + LowerRight = 2, + /// Upper-left corner. + UpperLeft = 3, + /// Upper-right corner. + UpperRight = 4, +} + +/// Touchpad characteristics from +/// [`get_touchpad_info`](TouchpadRawXyFeature::get_touchpad_info). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct TouchpadInfo { + /// Pad width in native coordinate units. + pub x_size: u16, + /// Pad height in native coordinate units. + pub y_size: u16, + /// Z-data range (`0x00` = none, `0x0f` = 16-bit). + pub z_data_range: u8, + /// Area-data range (`0x0f` = 16-bit). + pub area_data_range: u8, + /// Timestamp increment, in units of 0.1 ms. + pub timestamp_units: u8, + /// Maximum number of fingers that can be tracked. + pub max_finger_count: u8, + /// Position of the coordinate origin. + pub origin: Origin, + /// Whether pen input is supported. + pub pen_support: bool, + /// Raw-report mapping version. + pub raw_report_mapping_version: u8, + /// Native sensor DPI. + pub dpi: u16, +} + +impl TouchpadInfo { + fn from_payload(payload: &[u8; 16]) -> Result { + Ok(Self { + x_size: u16::from_be_bytes([payload[0], payload[1]]), + y_size: u16::from_be_bytes([payload[2], payload[3]]), + z_data_range: payload[4], + area_data_range: payload[5], + timestamp_units: payload[6], + max_finger_count: payload[7], + origin: Origin::try_from(payload[8]).map_err(|_| Hidpp20Error::UnsupportedResponse)?, + pen_support: payload[9] != 0, + raw_report_mapping_version: payload[12], + dpi: u16::from_be_bytes([payload[13], payload[14]]), + }) + } +} + +/// Implements the `TouchpadRawXy` / `0x6100` feature. +pub struct TouchpadRawXyFeature { + /// The endpoint this feature talks to. + endpoint: FeatureEndpoint, + + /// The emitter used to publish decoded events. + emitter: Arc>, + + /// Removes the message listener when the feature is dropped. + _msg_listener: MessageListenerGuard, +} + +impl CreatableFeature for TouchpadRawXyFeature { + const ID: u16 = 0x6100; + const STARTING_VERSION: u8 = 0; + + fn new(chan: Arc, device_index: u8, feature_index: u8) -> Self { + let emitter = Arc::new(EventEmitter::new()); + + let listener = chan.add_msg_listener_guarded({ + let emitter = Arc::clone(&emitter); + + move |raw, matched| { + let Some((func, payload)) = + event_payload(raw, matched, device_index, feature_index) + else { + return; + }; + if let Some(event) = event::decode_event(func.to_lo(), &payload) { + emitter.emit(event); + } + } + }); + + Self { + endpoint: FeatureEndpoint::new(chan, device_index, feature_index), + emitter, + _msg_listener: listener, + } + } +} + +impl Feature for TouchpadRawXyFeature {} + +impl EmittingFeature for TouchpadRawXyFeature { + fn listen(&self) -> async_channel::Receiver { + self.emitter.create_receiver() + } +} + +impl TouchpadRawXyFeature { + /// Retrieves the touchpad's characteristics. + pub async fn get_touchpad_info(&self) -> Result { + let payload = self.endpoint.call(0, [0; 3]).await?.extend_payload(); + TouchpadInfo::from_payload(&payload) + } + + /// Retrieves the current raw-report mode. + pub async fn get_raw_report_state(&self) -> Result { + let payload = self.endpoint.call(1, [0; 3]).await?.extend_payload(); + Ok(RawReportFlags::from_bits_retain(payload[0])) + } + + /// Sets the raw-report mode. + /// + /// Enable [`RawReportFlags::RAW`] for [`TouchpadRawEvent`]s to be emitted. + pub async fn set_raw_report_state(&self, flags: RawReportFlags) -> Result<(), Hidpp20Error> { + self.endpoint.call(2, [flags.bits(), 0, 0]).await?; + Ok(()) + } +} diff --git a/crates/openlogi-hidpp/src/feature/touchpad_raw_xy/tests.rs b/crates/openlogi-hidpp/src/feature/touchpad_raw_xy/tests.rs new file mode 100644 index 00000000..0c0fab1f --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/touchpad_raw_xy/tests.rs @@ -0,0 +1,97 @@ +//! Unit tests for `TouchpadRawXy` info parsing and raw-event decoding. + +use super::event::{TouchpadRawEvent, decode_event}; +use super::{Origin, RawReportFlags, TouchpadInfo}; + +#[test] +fn parses_touchpad_info() { + let mut payload = [0; 16]; + payload[0..2].copy_from_slice(&1920u16.to_be_bytes()); + payload[2..4].copy_from_slice(&1080u16.to_be_bytes()); + payload[4] = 0x0f; // z range 16-bit + payload[5] = 0x0f; // area range 16-bit + payload[6] = 1; // 0.1 ms timestamp units + payload[7] = 5; // max fingers + payload[8] = 1; // origin = lower-left + payload[9] = 1; // pen support + payload[12] = 2; // mapping version + payload[13..15].copy_from_slice(&1200u16.to_be_bytes()); + + let info = TouchpadInfo::from_payload(&payload).unwrap(); + assert_eq!(info.x_size, 1920); + assert_eq!(info.y_size, 1080); + assert_eq!(info.z_data_range, 0x0f); + assert_eq!(info.timestamp_units, 1); + assert_eq!(info.max_finger_count, 5); + assert_eq!(info.origin, Origin::LowerLeft); + assert!(info.pen_support); + assert_eq!(info.raw_report_mapping_version, 2); + assert_eq!(info.dpi, 1200); +} + +#[test] +fn rejects_reserved_origin() { + let mut payload = [0; 16]; + payload[8] = 0; // 0x00 is reserved + + assert!(matches!( + TouchpadInfo::from_payload(&payload), + Err(crate::protocol::v20::Hidpp20Error::UnsupportedResponse) + )); +} + +#[test] +fn decodes_dual_xy_event() { + let mut payload = [0; 16]; + payload[0..2].copy_from_slice(&0x1234u16.to_be_bytes()); // timestamp + // touch 1: CPT=0, X=0x0123; CTS=1, Y=0x0045; z=0x10, area=0x20. + payload[2] = 0x01; // CPT1(0) | X1[13:8]=0x01 + payload[3] = 0x23; // X1[7:0] + payload[4] = 0x40; // CTS1(1) | Y1[13:8]=0x00 + payload[5] = 0x45; // Y1[7:0] + payload[6] = 0x10; // z1 + payload[7] = 0x20; // area1 + payload[8] = 0x30 | 0x04; // FID1=3, BTN set (bit2), EOF clear + // touch 2: CPT=2, X=0x0210; CTS=0, Y=0x0305; z=0x11, area=0x22. + payload[9] = 0x80 | 0x02; // CPT2(2) | X2[13:8]=0x02 + payload[10] = 0x10; // X2[7:0] + payload[11] = 0x03; // CTS2(0) | Y2[13:8]=0x03 + payload[12] = 0x05; // Y2[7:0] + payload[13] = 0x11; // z2 + payload[14] = 0x22; // area2 + payload[15] = 0x50 | 0x03; // FID2=5, NUMFING=3 + + let TouchpadRawEvent::DualXy(frame) = decode_event(0, &payload).unwrap(); + assert_eq!(frame.timestamp, 0x1234); + + assert_eq!(frame.touch1.contact_type, 0); + assert_eq!(frame.touch1.x, 0x0123); + assert_eq!(frame.touch1.contact_status, 1); + assert_eq!(frame.touch1.y, 0x0045); + assert_eq!(frame.touch1.z, 0x10); + assert_eq!(frame.touch1.area, 0x20); + assert_eq!(frame.touch1.finger_id, 3); + + assert_eq!(frame.touch2.contact_type, 2); + assert_eq!(frame.touch2.x, 0x0210); + assert_eq!(frame.touch2.contact_status, 0); + assert_eq!(frame.touch2.y, 0x0305); + assert_eq!(frame.touch2.finger_id, 5); + + assert!(frame.button); + assert!(!frame.end_of_frame); + assert_eq!(frame.finger_count, 3); +} + +#[test] +fn ignores_unknown_event_sub_id() { + assert!(decode_event(1, &[0; 16]).is_none()); +} + +#[test] +fn raw_report_flag_bits() { + let flags = RawReportFlags::from_bits_retain(0x05); + assert!(flags.contains(RawReportFlags::RAW)); + assert!(flags.contains(RawReportFlags::ENHANCED)); + assert!(!flags.contains(RawReportFlags::FORCE_ADD)); +} From 4f1c551b2b9776e8a7ef2b6ea85593a19136e9aa Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sat, 27 Jun 2026 19:57:42 +0800 Subject: [PATCH 15/21] feat(hidpp): add solar keyboard dashboard wrapper --- crates/openlogi-hidpp/src/feature/mod.rs | 1 + crates/openlogi-hidpp/src/feature/registry.rs | 3 +- .../src/feature/solar_dashboard/event.rs | 50 ++++++++ .../src/feature/solar_dashboard/mod.rs | 115 ++++++++++++++++++ .../src/feature/solar_dashboard/tests.rs | 64 ++++++++++ 5 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 crates/openlogi-hidpp/src/feature/solar_dashboard/event.rs create mode 100644 crates/openlogi-hidpp/src/feature/solar_dashboard/mod.rs create mode 100644 crates/openlogi-hidpp/src/feature/solar_dashboard/tests.rs diff --git a/crates/openlogi-hidpp/src/feature/mod.rs b/crates/openlogi-hidpp/src/feature/mod.rs index 66c86ddd..ec39e990 100755 --- a/crates/openlogi-hidpp/src/feature/mod.rs +++ b/crates/openlogi-hidpp/src/feature/mod.rs @@ -41,6 +41,7 @@ pub mod root; pub mod sidetone; pub mod smartshift; pub mod smartshift_enhanced; +pub mod solar_dashboard; pub mod thumbwheel; pub mod touch_mouse_raw; pub mod touchpad_raw_xy; diff --git a/crates/openlogi-hidpp/src/feature/registry.rs b/crates/openlogi-hidpp/src/feature/registry.rs index 27012fb0..83897373 100755 --- a/crates/openlogi-hidpp/src/feature/registry.rs +++ b/crates/openlogi-hidpp/src/feature/registry.rs @@ -44,6 +44,7 @@ use crate::{ sidetone::SidetoneFeature, smartshift::SmartShiftFeature, smartshift_enhanced::SmartShiftEnhancedFeature, + solar_dashboard::SolarDashboardFeature, thumbwheel::ThumbwheelFeature, touch_mouse_raw::TouchMouseRawFeature, touchpad_raw_xy::TouchpadRawXyFeature, @@ -206,7 +207,7 @@ static KNOWN_FEATURES: LazyLock> = LazyLock::new(|| { 0x40a3 "FnInversionForMultiHostDevices" => FnInversionMultiHostFeature, 0x4100 "Encryption", 0x4220 "LockKeyState", - 0x4301 "SolarKeyboardDashboard", + 0x4301 "SolarKeyboardDashboard" => SolarDashboardFeature, 0x4520 "KeyboardLayout", 0x4521 "DisableKeys" => DisableKeysFeature, 0x4522 "DisableKeysByUsage" => DisableKeysByUsageFeature, diff --git a/crates/openlogi-hidpp/src/feature/solar_dashboard/event.rs b/crates/openlogi-hidpp/src/feature/solar_dashboard/event.rs new file mode 100644 index 00000000..017bd75d --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/solar_dashboard/event.rs @@ -0,0 +1,50 @@ +//! Broadcast events emitted by `SolarKeyboardDashboard` (`0x4301`). + +/// Battery and light readings shared by every solar-dashboard event. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub struct SolarStatus { + /// Remaining battery capacity, as a percentage. + pub battery_level: u8, + /// Current light measure in lux (`0..=511`). + pub light_level: u16, +} + +impl SolarStatus { + fn from_payload(payload: &[u8; 16]) -> Self { + Self { + battery_level: payload[0], + light_level: u16::from_be_bytes([payload[1], payload[2]]), + } + } +} + +/// An event emitted by [`SolarDashboardFeature`](super::SolarDashboardFeature). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +pub enum SolarEvent { + /// Spontaneous battery report (every ~90 s, at power-up, and on reconnect). + /// + /// The light level is always `0` for this event. + Battery(SolarStatus), + /// Battery and light report sent per the + /// [`set_light_measure`](super::SolarDashboardFeature::set_light_measure) + /// schedule. + LightMeasure(SolarStatus), + /// The CheckLight button was pressed; carries the latest battery and light + /// readings. + CheckLightButton(SolarStatus), +} + +/// Decodes a `0x4301` broadcast event payload by its sub-id. +pub(super) fn decode_event(sub_id: u8, payload: &[u8; 16]) -> Option { + let status = SolarStatus::from_payload(payload); + match sub_id { + 0 => Some(SolarEvent::Battery(status)), + 1 => Some(SolarEvent::LightMeasure(status)), + 2 => Some(SolarEvent::CheckLightButton(status)), + _ => None, + } +} diff --git a/crates/openlogi-hidpp/src/feature/solar_dashboard/mod.rs b/crates/openlogi-hidpp/src/feature/solar_dashboard/mod.rs new file mode 100644 index 00000000..6697a666 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/solar_dashboard/mod.rs @@ -0,0 +1,115 @@ +//! Implements the `SolarKeyboardDashboard` feature (ID `0x4301`) for Logitech's +//! solar keyboards (e.g. the K750): scheduling light-measure reports, overriding +//! the CheckLight LED, and receiving battery / light broadcast events. + +pub mod event; + +#[cfg(test)] +mod tests; + +use std::sync::Arc; + +use num_enum::{IntoPrimitive, TryFromPrimitive}; + +pub use event::{SolarEvent, SolarStatus}; + +use crate::{ + channel::{HidppChannel, MessageListenerGuard}, + event::EventEmitter, + feature::{CreatableFeature, EmittingFeature, Feature, FeatureEndpoint, event_payload}, + protocol::v20::Hidpp20Error, +}; + +/// A CheckLight LED color for [`set_led`](SolarDashboardFeature::set_led). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[non_exhaustive] +#[repr(u8)] +pub enum LedId { + /// All LEDs off. + Off = 0, + /// Red. + Red = 1, + /// Orange. + Orange = 2, + /// Green. + Green = 3, +} + +/// Implements the `SolarKeyboardDashboard` / `0x4301` feature. +pub struct SolarDashboardFeature { + /// The endpoint this feature talks to. + endpoint: FeatureEndpoint, + + /// The emitter used to publish decoded events. + emitter: Arc>, + + /// Removes the message listener when the feature is dropped. + _msg_listener: MessageListenerGuard, +} + +impl CreatableFeature for SolarDashboardFeature { + const ID: u16 = 0x4301; + const STARTING_VERSION: u8 = 0; + + fn new(chan: Arc, device_index: u8, feature_index: u8) -> Self { + let emitter = Arc::new(EventEmitter::new()); + + let listener = chan.add_msg_listener_guarded({ + let emitter = Arc::clone(&emitter); + + move |raw, matched| { + let Some((func, payload)) = + event_payload(raw, matched, device_index, feature_index) + else { + return; + }; + if let Some(event) = event::decode_event(func.to_lo(), &payload) { + emitter.emit(event); + } + } + }); + + Self { + endpoint: FeatureEndpoint::new(chan, device_index, feature_index), + emitter, + _msg_listener: listener, + } + } +} + +impl Feature for SolarDashboardFeature {} + +impl EmittingFeature for SolarDashboardFeature { + fn listen(&self) -> async_channel::Receiver { + self.emitter.create_receiver() + } +} + +impl SolarDashboardFeature { + /// Schedules [`SolarEvent::LightMeasure`] reports. + /// + /// `max_reports` is the number of reports to send and `report_period` their + /// spacing in seconds. Passing `0` for either cancels reporting. + pub async fn set_light_measure( + &self, + max_reports: u8, + report_period: u8, + ) -> Result<(), Hidpp20Error> { + self.endpoint + .call(0, [max_reports, report_period, 0]) + .await?; + Ok(()) + } + + /// Lights the CheckLight LED in the given color for a firmware-defined + /// duration. + /// + /// Intended to override the firmware's own CheckLight display in response to a + /// [`SolarEvent::CheckLightButton`]; the firmware waits 250 ms before showing + /// its own status, so call this within that window. + pub async fn set_led(&self, led: LedId) -> Result<(), Hidpp20Error> { + self.endpoint.call(1, [led.into(), 0, 0]).await?; + Ok(()) + } +} diff --git a/crates/openlogi-hidpp/src/feature/solar_dashboard/tests.rs b/crates/openlogi-hidpp/src/feature/solar_dashboard/tests.rs new file mode 100644 index 00000000..a3041633 --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/solar_dashboard/tests.rs @@ -0,0 +1,64 @@ +//! Unit tests for `SolarKeyboardDashboard` event decoding, using the spec's +//! worked examples (battery 96%, light 0 / 319 lux). + +use super::LedId; +use super::event::{SolarEvent, SolarStatus, decode_event}; + +#[test] +fn decodes_battery_event() { + // Spec example: 11 03 06 00 60 00 00 ... → battery 96%, light 0. + let mut payload = [0; 16]; + payload[0] = 0x60; + + assert_eq!( + decode_event(0, &payload), + Some(SolarEvent::Battery(SolarStatus { + battery_level: 96, + light_level: 0, + })) + ); +} + +#[test] +fn decodes_light_measure_event() { + // Spec example: 11 03 06 10 60 01 3F ... → battery 96%, light 0x013F = 319. + let mut payload = [0; 16]; + payload[0] = 0x60; + payload[1] = 0x01; + payload[2] = 0x3f; + + assert_eq!( + decode_event(1, &payload), + Some(SolarEvent::LightMeasure(SolarStatus { + battery_level: 96, + light_level: 319, + })) + ); +} + +#[test] +fn decodes_check_light_button_event() { + // Spec example: 11 03 06 20 60 00 00 ... → battery 96%, light 0. + let mut payload = [0; 16]; + payload[0] = 0x60; + + assert_eq!( + decode_event(2, &payload), + Some(SolarEvent::CheckLightButton(SolarStatus { + battery_level: 96, + light_level: 0, + })) + ); +} + +#[test] +fn ignores_unknown_event_sub_id() { + assert!(decode_event(3, &[0; 16]).is_none()); +} + +#[test] +fn maps_led_wire_values() { + assert_eq!(u8::from(LedId::Off), 0); + assert_eq!(LedId::try_from(3u8).unwrap(), LedId::Green); + assert!(LedId::try_from(4u8).is_err()); +} From 488bed124fd260f7218b8474480e745b07c1ed77 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sat, 27 Jun 2026 20:12:58 +0800 Subject: [PATCH 16/21] feat(hidpp): add control and task id constants --- .../src/feature/reprog_controls.rs | 14 + .../feature/reprog_controls/control_ids.rs | 517 ++++++++++++++++++ .../src/feature/reprog_controls/task_ids.rs | 344 ++++++++++++ 3 files changed, 875 insertions(+) create mode 100644 crates/openlogi-hidpp/src/feature/reprog_controls/control_ids.rs create mode 100644 crates/openlogi-hidpp/src/feature/reprog_controls/task_ids.rs diff --git a/crates/openlogi-hidpp/src/feature/reprog_controls.rs b/crates/openlogi-hidpp/src/feature/reprog_controls.rs index 8e5491ec..1adeb083 100644 --- a/crates/openlogi-hidpp/src/feature/reprog_controls.rs +++ b/crates/openlogi-hidpp/src/feature/reprog_controls.rs @@ -14,7 +14,9 @@ use crate::{ protocol::v20::Hidpp20Error, }; +pub mod control_ids; mod event; +pub mod task_ids; use event::decode_event_payload; pub use event::{AnalyticsKeyEvent, RawWheelResolution, ReprogControlsEvent, decode_event}; @@ -162,6 +164,18 @@ fn u16_from_be_payload(bytes: &[u8]) -> u16 { #[cfg_attr(feature = "serde", derive(serde::Serialize))] pub struct TaskId(pub u16); +impl From for TaskId { + fn from(value: u16) -> Self { + Self(value) + } +} + +impl From for u16 { + fn from(value: TaskId) -> Self { + value.0 + } +} + /// One `getCidInfo` row. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] diff --git a/crates/openlogi-hidpp/src/feature/reprog_controls/control_ids.rs b/crates/openlogi-hidpp/src/feature/reprog_controls/control_ids.rs new file mode 100644 index 00000000..f350918a --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/reprog_controls/control_ids.rs @@ -0,0 +1,517 @@ +//! Named [`ControlId`] constants from the official `0x1b04` +//! control-id list (`x1b04_control_ids_list`). +//! +//! Covers control IDs `0xB8`..=`0x161`; the classic mouse and keyboard control +//! IDs below `0xB8` are not enumerated in that list. + +use super::ControlId; + +/// `Second L-Click` (`0xB8`). +pub const SECOND_L_CLICK: ControlId = ControlId(0xB8); + +/// `Fn + Second L-Click` (`0xB9`). +pub const FN_SECOND_L_CLICK: ControlId = ControlId(0xB9); + +/// `MultiPlatform App Switch` (`0xBA`). +pub const MULTIPLATFORM_APP_SWITCH: ControlId = ControlId(0xBA); + +/// `MultiPlatform Home` (`0xBB`). +pub const MULTIPLATFORM_HOME: ControlId = ControlId(0xBB); + +/// `MultiPlatform Menu` (`0xBC`). +pub const MULTIPLATFORM_MENU: ControlId = ControlId(0xBC); + +/// `MultiPlatform Back` (`0xBD`). +pub const MULTIPLATFORM_BACK: ControlId = ControlId(0xBD); + +/// `MultiPlatform Insert` (`0xBE`). +pub const MULTIPLATFORM_INSERT: ControlId = ControlId(0xBE); + +/// `Screen Capture / Print Screen` (`0xBF`). +pub const SCREEN_CAPTURE_PRINT_SCREEN: ControlId = ControlId(0xBF); + +/// `Fn + Down` (`0xC0`). +pub const FN_DOWN: ControlId = ControlId(0xC0); + +/// `Fn + Up` (`0xC1`). +pub const FN_UP: ControlId = ControlId(0xC1); + +/// `Multiplatform Lock` (`0xC2`). +pub const MULTIPLATFORM_LOCK: ControlId = ControlId(0xC2); + +/// `App Switch Gesture` (`0xC3`). +pub const APP_SWITCH_GESTURE: ControlId = ControlId(0xC3); + +/// `Smart Shift` (`0xC4`). +pub const SMART_SHIFT: ControlId = ControlId(0xC4); + +/// `Microphone` (`0xC5`). +pub const MICROPHONE: ControlId = ControlId(0xC5); + +/// `Wifi` (`0xC6`). +pub const WIFI: ControlId = ControlId(0xC6); + +/// `Brightness Down` (`0xC7`). +pub const BRIGHTNESS_DOWN: ControlId = ControlId(0xC7); + +/// `Brightness Up` (`0xC8`). +pub const BRIGHTNESS_UP: ControlId = ControlId(0xC8); + +/// `Display out ( project screen )` (`0xC9`). +pub const DISPLAY_OUT: ControlId = ControlId(0xC9); + +/// `View Open Apps` (`0xCA`). +pub const VIEW_OPEN_APPS: ControlId = ControlId(0xCA); + +/// `View all apps` (`0xCB`). +pub const VIEW_ALL_APPS: ControlId = ControlId(0xCB); + +/// `Switch App` (`0xCC`). +pub const SWITCH_APP: ControlId = ControlId(0xCC); + +/// `Fn inversion change` (`0xCD`). +pub const FN_INVERSION_CHANGE: ControlId = ControlId(0xCD); + +/// `MultiPlatform back` (`0xCE`). +pub const MULTIPLATFORM_BACK_206: ControlId = ControlId(0xCE); + +/// `Multiplatform forward` (`0xCF`). +pub const MULTIPLATFORM_FORWARD: ControlId = ControlId(0xCF); + +/// `Multiplatform gesture button` (`0xD0`). +pub const MULTIPLATFORM_GESTURE_BUTTON: ControlId = ControlId(0xD0); + +/// `Host Switch channel 1` (`0xD1`). +pub const HOST_SWITCH_CHANNEL_1: ControlId = ControlId(0xD1); + +/// `Host Switch channel 2` (`0xD2`). +pub const HOST_SWITCH_CHANNEL_2: ControlId = ControlId(0xD2); + +/// `Host Switch channel 3` (`0xD3`). +pub const HOST_SWITCH_CHANNEL_3: ControlId = ControlId(0xD3); + +/// `Multiplatform search` (`0xD4`). +pub const MULTIPLATFORM_SEARCH: ControlId = ControlId(0xD4); + +/// `Multiplatform Home / Mission Control` (`0xD5`). +pub const MULTIPLATFORM_HOME_MISSION_CONTROL: ControlId = ControlId(0xD5); + +/// `Multiplatform Menu / Show/Hide Virtual Keyboard / Launchpad` (`0xD6`). +pub const MULTIPLATFORM_MENU_SHOW_HIDE_VIRTUAL_KEYBOARD_LAUNCHPAD: ControlId = ControlId(0xD6); + +/// `Virtual Gesture Button` (`0xD7`). +pub const VIRTUAL_GESTURE_BUTTON: ControlId = ControlId(0xD7); + +/// `Cursor Button Long press` (`0xD8`). +pub const CURSOR_BUTTON_LONG_PRESS: ControlId = ControlId(0xD8); + +/// `Next Button` (`0xD9`). +pub const NEXT_BUTTON: ControlId = ControlId(0xD9); + +/// `Next Button Longpress` (`0xDA`). +pub const NEXT_BUTTON_LONGPRESS: ControlId = ControlId(0xDA); + +/// `Back` (`0xDB`). +pub const BACK: ControlId = ControlId(0xDB); + +/// `Back Button Longpress` (`0xDC`). +pub const BACK_BUTTON_LONGPRESS: ControlId = ControlId(0xDC); + +/// `Multi Platform Language Switch` (`0xDD`). +pub const MULTI_PLATFORM_LANGUAGE_SWITCH: ControlId = ControlId(0xDD); + +/// `F Lock` (`0xDE`). +pub const F_LOCK: ControlId = ControlId(0xDE); + +/// `Switch Highlight` (`0xDF`). +pub const SWITCH_HIGHLIGHT: ControlId = ControlId(0xDF); + +/// `Mission Control / Task View` (`0xE0`). +pub const MISSION_CONTROL_TASK_VIEW: ControlId = ControlId(0xE0); + +/// `Dashboard (Launchpad) / Action Center` (`0xE1`). +pub const DASHBOARD_LAUNCHPAD_ACTION_CENTER: ControlId = ControlId(0xE1); + +/// `Backlight -` (`0xE2`). +pub const BACKLIGHT_MINUS: ControlId = ControlId(0xE2); + +/// `Backlight +` (`0xE3`). +pub const BACKLIGHT_PLUS: ControlId = ControlId(0xE3); + +/// `Re-programmable Previous Track` (`0xE4`). +pub const RE_PROGRAMMABLE_PREVIOUS_TRACK: ControlId = ControlId(0xE4); + +/// `Re-programmable Play/Pause` (`0xE5`). +pub const RE_PROGRAMMABLE_PLAY_PAUSE: ControlId = ControlId(0xE5); + +/// `Re-programmable Next Track` (`0xE6`). +pub const RE_PROGRAMMABLE_NEXT_TRACK: ControlId = ControlId(0xE6); + +/// `Re-programmable Mute` (`0xE7`). +pub const RE_PROGRAMMABLE_MUTE: ControlId = ControlId(0xE7); + +/// `Re-programmable Volume Down` (`0xE8`). +pub const RE_PROGRAMMABLE_VOLUME_DOWN: ControlId = ControlId(0xE8); + +/// `Re-programmable Volume Up` (`0xE9`). +pub const RE_PROGRAMMABLE_VOLUME_UP: ControlId = ControlId(0xE9); + +/// `App (Contextual Menu) / Right Click` (`0xEA`). +pub const APP_CONTEXTUAL_MENU_RIGHT_CLICK: ControlId = ControlId(0xEA); + +/// `Right Arrow` (`0xEB`). +pub const RIGHT_ARROW: ControlId = ControlId(0xEB); + +/// `Left Arrow` (`0xEC`). +pub const LEFT_ARROW: ControlId = ControlId(0xEC); + +/// `DPI Change` (`0xED`). +pub const DPI_CHANGE: ControlId = ControlId(0xED); + +/// `New Tab` (`0xEE`). +pub const NEW_TAB: ControlId = ControlId(0xEE); + +/// `F2` (`0xEF`). +pub const F2: ControlId = ControlId(0xEF); + +/// `F3` (`0xF0`). +pub const F3: ControlId = ControlId(0xF0); + +/// `F4` (`0xF1`). +pub const F4: ControlId = ControlId(0xF1); + +/// `F5` (`0xF2`). +pub const F5: ControlId = ControlId(0xF2); + +/// `F6` (`0xF3`). +pub const F6: ControlId = ControlId(0xF3); + +/// `F7` (`0xF4`). +pub const F7: ControlId = ControlId(0xF4); + +/// `F8` (`0xF5`). +pub const F8: ControlId = ControlId(0xF5); + +/// `F1` (`0xF6`). +pub const F1: ControlId = ControlId(0xF6); + +/// `Next Color Effect` (`0xF7`). +pub const NEXT_COLOR_EFFECT: ControlId = ControlId(0xF7); + +/// `Increase Color Effect Speed` (`0xF8`). +pub const INCREASE_COLOR_EFFECT_SPEED: ControlId = ControlId(0xF8); + +/// `Decrease Color Effect Speed` (`0xF9`). +pub const DECREASE_COLOR_EFFECT_SPEED: ControlId = ControlId(0xF9); + +/// `Load Lighting Custom Profile` (`0xFA`). +pub const LOAD_LIGHTING_CUSTOM_PROFILE: ControlId = ControlId(0xFA); + +/// `Laser button short press` (`0xFB`). +pub const LASER_BUTTON_SHORT_PRESS: ControlId = ControlId(0xFB); + +/// `Laser button long press` (`0xFC`). +pub const LASER_BUTTON_LONG_PRESS: ControlId = ControlId(0xFC); + +/// `DPI switch` (`0xFD`). +pub const DPI_SWITCH: ControlId = ControlId(0xFD); + +/// `MultiPlatform Home / Show Desktop` (`0xFE`). +pub const MULTIPLATFORM_HOME_SHOW_DESKTOP: ControlId = ControlId(0xFE); + +/// `MultiPlatform App Switch / Show Dashboard` (`0xFF`). +pub const MULTIPLATFORM_APP_SWITCH_SHOW_DASHBOARD: ControlId = ControlId(0xFF); + +/// `MultiPlatform App Switch` (`0x100`). +pub const MULTIPLATFORM_APP_SWITCH_256: ControlId = ControlId(0x100); + +/// `Fn Inversion Hot Key` (`0x101`). +pub const FN_INVERSION_HOT_KEY: ControlId = ControlId(0x101); + +/// `LeftAndRightClick` (`0x102`). +pub const LEFT_AND_RIGHT_CLICK: ControlId = ControlId(0x102); + +/// `Voice Dictation` (`0x103`). +pub const VOICE_DICTATION: ControlId = ControlId(0x103); + +/// `Smiling face with heart shaped eyes` (`0x104`). +pub const SMILING_FACE_WITH_HEART_SHAPED_EYES: ControlId = ControlId(0x104); + +/// `Loudly Crying face` (`0x105`). +pub const LOUDLY_CRYING_FACE: ControlId = ControlId(0x105); + +/// `Emoji Smiley` (`0x106`). +pub const EMOJI_SMILEY: ControlId = ControlId(0x106); + +/// `Emoji smiley with tears` (`0x107`). +pub const EMOJI_SMILEY_WITH_TEARS: ControlId = ControlId(0x107); + +/// `Open emoji panel` (`0x108`). +pub const OPEN_EMOJI_PANEL: ControlId = ControlId(0x108); + +/// `Multiplatform App Switch/Launchpad` (`0x109`). +pub const MULTIPLATFORM_APP_SWITCH_LAUNCHPAD: ControlId = ControlId(0x109); + +/// `Snipping tool` (`0x10A`). +pub const SNIPPING_TOOL: ControlId = ControlId(0x10A); + +/// `Grave accent` (`0x10B`). +pub const GRAVE_ACCENT: ControlId = ControlId(0x10B); + +/// `Tab key` (`0x10C`). +pub const TAB_KEY: ControlId = ControlId(0x10C); + +/// `Caps Lock` (`0x10D`). +pub const CAPS_LOCK: ControlId = ControlId(0x10D); + +/// `Left Shift` (`0x10E`). +pub const LEFT_SHIFT: ControlId = ControlId(0x10E); + +/// `Left Control` (`0x10F`). +pub const LEFT_CONTROL: ControlId = ControlId(0x10F); + +/// `Left Option / Start` (`0x110`). +pub const LEFT_OPTION_START: ControlId = ControlId(0x110); + +/// `Left Command / Alt` (`0x111`). +pub const LEFT_COMMAND_ALT: ControlId = ControlId(0x111); + +/// `Right Command / Alt` (`0x112`). +pub const RIGHT_COMMAND_ALT: ControlId = ControlId(0x112); + +/// `Right Option / Start` (`0x113`). +pub const RIGHT_OPTION_START: ControlId = ControlId(0x113); + +/// `Right Control` (`0x114`). +pub const RIGHT_CONTROL: ControlId = ControlId(0x114); + +/// `Right Shift` (`0x115`). +pub const RIGHT_SHIFT: ControlId = ControlId(0x115); + +/// `Insert` (`0x116`). +pub const INSERT: ControlId = ControlId(0x116); + +/// `Delete` (`0x117`). +pub const DELETE: ControlId = ControlId(0x117); + +/// `Home` (`0x118`). +pub const HOME: ControlId = ControlId(0x118); + +/// `End` (`0x119`). +pub const END: ControlId = ControlId(0x119); + +/// `Page Up` (`0x11A`). +pub const PAGE_UP: ControlId = ControlId(0x11A); + +/// `Page Down` (`0x11B`). +pub const PAGE_DOWN: ControlId = ControlId(0x11B); + +/// `Mute microphone` (`0x11C`). +pub const MUTE_MICROPHONE: ControlId = ControlId(0x11C); + +/// `Do not disturb` (`0x11D`). +pub const DO_NOT_DISTURB: ControlId = ControlId(0x11D); + +/// `Backslash` (`0x11E`). +pub const BACKSLASH: ControlId = ControlId(0x11E); + +/// `Refresh` (`0x11F`). +pub const REFRESH: ControlId = ControlId(0x11F); + +/// `Close Tab` (`0x120`). +pub const CLOSE_TAB: ControlId = ControlId(0x120); + +/// `Lang Switch` (`0x121`). +pub const LANG_SWITCH: ControlId = ControlId(0x121); + +/// `Standard key A` (`0x122`). +pub const STANDARD_KEY_A: ControlId = ControlId(0x122); + +/// `Standard key B` (`0x123`). +pub const STANDARD_KEY_B: ControlId = ControlId(0x123); + +/// `Standard key C` (`0x124`). +pub const STANDARD_KEY_C: ControlId = ControlId(0x124); + +/// `Standard key D` (`0x125`). +pub const STANDARD_KEY_D: ControlId = ControlId(0x125); + +/// `Standard key E` (`0x126`). +pub const STANDARD_KEY_E: ControlId = ControlId(0x126); + +/// `Standard key F` (`0x127`). +pub const STANDARD_KEY_F: ControlId = ControlId(0x127); + +/// `Standard key G` (`0x128`). +pub const STANDARD_KEY_G: ControlId = ControlId(0x128); + +/// `Standard key H` (`0x129`). +pub const STANDARD_KEY_H: ControlId = ControlId(0x129); + +/// `Standard key I` (`0x12A`). +pub const STANDARD_KEY_I: ControlId = ControlId(0x12A); + +/// `Standard key J` (`0x12B`). +pub const STANDARD_KEY_J: ControlId = ControlId(0x12B); + +/// `Standard key K` (`0x12C`). +pub const STANDARD_KEY_K: ControlId = ControlId(0x12C); + +/// `Standard key L` (`0x12D`). +pub const STANDARD_KEY_L: ControlId = ControlId(0x12D); + +/// `Standard key M` (`0x12E`). +pub const STANDARD_KEY_M: ControlId = ControlId(0x12E); + +/// `Standard key N` (`0x12F`). +pub const STANDARD_KEY_N: ControlId = ControlId(0x12F); + +/// `Standard key O` (`0x130`). +pub const STANDARD_KEY_O: ControlId = ControlId(0x130); + +/// `Standard key P` (`0x131`). +pub const STANDARD_KEY_P: ControlId = ControlId(0x131); + +/// `Standard key Q` (`0x132`). +pub const STANDARD_KEY_Q: ControlId = ControlId(0x132); + +/// `Standard key R` (`0x133`). +pub const STANDARD_KEY_R: ControlId = ControlId(0x133); + +/// `Standard key S` (`0x134`). +pub const STANDARD_KEY_S: ControlId = ControlId(0x134); + +/// `Standard key T` (`0x135`). +pub const STANDARD_KEY_T: ControlId = ControlId(0x135); + +/// `Standard key U` (`0x136`). +pub const STANDARD_KEY_U: ControlId = ControlId(0x136); + +/// `Standard key V` (`0x137`). +pub const STANDARD_KEY_V: ControlId = ControlId(0x137); + +/// `Standard key W` (`0x138`). +pub const STANDARD_KEY_W: ControlId = ControlId(0x138); + +/// `Standard key X` (`0x139`). +pub const STANDARD_KEY_X: ControlId = ControlId(0x139); + +/// `Standard key Y` (`0x13A`). +pub const STANDARD_KEY_Y: ControlId = ControlId(0x13A); + +/// `Standard key Z` (`0x13B`). +pub const STANDARD_KEY_Z: ControlId = ControlId(0x13B); + +/// `Right Option / Start` (`0x13C`). +pub const RIGHT_OPTION_START_316: ControlId = ControlId(0x13C); + +/// `Left Option` (`0x13D`). +pub const LEFT_OPTION: ControlId = ControlId(0x13D); + +/// `Right Option` (`0x13E`). +pub const RIGHT_OPTION: ControlId = ControlId(0x13E); + +/// `Left Cmd` (`0x13F`). +pub const LEFT_CMD: ControlId = ControlId(0x13F); + +/// `Right Cmd` (`0x140`). +pub const RIGHT_CMD: ControlId = ControlId(0x140); + +/// `Play/Pause (Double track)` (`0x141`). +pub const PLAY_PAUSE_DOUBLE_TRACK: ControlId = ControlId(0x141); + +/// `Standard 0` (`0x142`). +pub const STANDARD_0: ControlId = ControlId(0x142); + +/// `Standard 1` (`0x143`). +pub const STANDARD_1: ControlId = ControlId(0x143); + +/// `Standard 2` (`0x144`). +pub const STANDARD_2: ControlId = ControlId(0x144); + +/// `Standard 3` (`0x145`). +pub const STANDARD_3: ControlId = ControlId(0x145); + +/// `Standard 4` (`0x146`). +pub const STANDARD_4: ControlId = ControlId(0x146); + +/// `Standard 5` (`0x147`). +pub const STANDARD_5: ControlId = ControlId(0x147); + +/// `Standard 6` (`0x148`). +pub const STANDARD_6: ControlId = ControlId(0x148); + +/// `Standard 7` (`0x149`). +pub const STANDARD_7: ControlId = ControlId(0x149); + +/// `Standard 8` (`0x14A`). +pub const STANDARD_8: ControlId = ControlId(0x14A); + +/// `Standard 9` (`0x14B`). +pub const STANDARD_9: ControlId = ControlId(0x14B); + +/// `Standard Esc` (`0x14C`). +pub const STANDARD_ESC: ControlId = ControlId(0x14C); + +/// `Standard F9` (`0x14D`). +pub const STANDARD_F9: ControlId = ControlId(0x14D); + +/// `Standard F10` (`0x14E`). +pub const STANDARD_F10: ControlId = ControlId(0x14E); + +/// `Standard F11` (`0x14F`). +pub const STANDARD_F11: ControlId = ControlId(0x14F); + +/// `Standard F12` (`0x150`). +pub const STANDARD_F12: ControlId = ControlId(0x150); + +/// `Standard up arrow` (`0x151`). +pub const STANDARD_UP_ARROW: ControlId = ControlId(0x151); + +/// `Standard down arrow` (`0x152`). +pub const STANDARD_DOWN_ARROW: ControlId = ControlId(0x152); + +/// `Standard '/~` (`0x153`). +pub const STANDARD_GRAVE_TILDE: ControlId = ControlId(0x153); + +/// `Fn` (`0x154`). +pub const FN: ControlId = ControlId(0x154); + +/// `Standard Enter` (`0x155`). +pub const STANDARD_ENTER: ControlId = ControlId(0x155); + +/// `Standard backspace` (`0x156`). +pub const STANDARD_BACKSPACE: ControlId = ControlId(0x156); + +/// `Std. = or +` (`0x157`). +pub const STD_EQUALS_OR_PLUS: ControlId = ControlId(0x157); + +/// `std minus` (`0x158`). +pub const STD_MINUS: ControlId = ControlId(0x158); + +/// `bluetooth` (`0x159`). +pub const BLUETOOTH: ControlId = ControlId(0x159); + +/// `Standard [ / {` (`0x15A`). +pub const STANDARD_LBRACKET_LBRACE: ControlId = ControlId(0x15A); + +/// `Standard ] / }` (`0x15B`). +pub const STANDARD_RBRACKET_RBRACE: ControlId = ControlId(0x15B); + +/// `Standard ; / :` (`0x15C`). +pub const STANDARD_SEMICOLON_COLON: ControlId = ControlId(0x15C); + +/// `Standard ' / "` (`0x15D`). +pub const STANDARD_APOSTROPHE_QUOTE: ControlId = ControlId(0x15D); + +/// `Standard / / ?` (`0x15E`). +pub const STANDARD_SLASH_QUESTION: ControlId = ControlId(0x15E); + +/// `Standard . / >` (`0x15F`). +pub const STANDARD_DOT_GT: ControlId = ControlId(0x15F); + +/// `Standard , / <` (`0x160`). +pub const STANDARD_COMMA_LT: ControlId = ControlId(0x160); + +/// `Standard Space` (`0x161`). +pub const STANDARD_SPACE: ControlId = ControlId(0x161); diff --git a/crates/openlogi-hidpp/src/feature/reprog_controls/task_ids.rs b/crates/openlogi-hidpp/src/feature/reprog_controls/task_ids.rs new file mode 100644 index 00000000..36c9d10a --- /dev/null +++ b/crates/openlogi-hidpp/src/feature/reprog_controls/task_ids.rs @@ -0,0 +1,344 @@ +//! Named [`TaskId`] constants from the official `0x1b04` task-id +//! list (`x1b04_tasks_ids_list`). +//! +//! A task ID is the behaviour a control performs; it is assigned to a +//! [`ControlId`](super::ControlId) via the device's remapping features. + +use super::TaskId; + +/// `Switch presentation ( switch screen)` (`0x93`). +pub const SWITCH_PRESENTATION_SWITCH_SCREEN: TaskId = TaskId(0x93); + +/// `Minimize window` (`0x94`). +pub const MINIMIZE_WINDOW: TaskId = TaskId(0x94); + +/// `Maximize window` (`0x95`). +pub const MAXIMIZE_WINDOW: TaskId = TaskId(0x95); + +/// `MultiPlatform App Switch` (`0x96`). +pub const MULTIPLATFORM_APP_SWITCH: TaskId = TaskId(0x96); + +/// `MultiPlatform Home` (`0x97`). +pub const MULTIPLATFORM_HOME: TaskId = TaskId(0x97); + +/// `MultiPlatform Menu` (`0x98`). +pub const MULTIPLATFORM_MENU: TaskId = TaskId(0x98); + +/// `MultiPlatform Back` (`0x99`). +pub const MULTIPLATFORM_BACK: TaskId = TaskId(0x99); + +/// `Mac switch language` (`0x9A`). +pub const MAC_SWITCH_LANGUAGE: TaskId = TaskId(0x9A); + +/// `Mac screen Capture` (`0x9B`). +pub const MAC_SCREEN_CAPTURE: TaskId = TaskId(0x9B); + +/// `Gesture Button` (`0x9C`). +pub const GESTURE_BUTTON: TaskId = TaskId(0x9C); + +/// `Smart Shift` (`0x9D`). +pub const SMART_SHIFT: TaskId = TaskId(0x9D); + +/// `AppExpose` (`0x9E`). +pub const APP_EXPOSE: TaskId = TaskId(0x9E); + +/// `SmartZoom` (`0x9F`). +pub const SMART_ZOOM: TaskId = TaskId(0x9F); + +/// `Lookup` (`0xA0`). +pub const LOOKUP: TaskId = TaskId(0xA0); + +/// `Microphone on/off` (`0xA1`). +pub const MICROPHONE_ON_OFF: TaskId = TaskId(0xA1); + +/// `Wifi on/off` (`0xA2`). +pub const WIFI_ON_OFF: TaskId = TaskId(0xA2); + +/// `Brightness down` (`0xA3`). +pub const BRIGHTNESS_DOWN: TaskId = TaskId(0xA3); + +/// `Brightness up` (`0xA4`). +pub const BRIGHTNESS_UP: TaskId = TaskId(0xA4); + +/// `Display out` (`0xA5`). +pub const DISPLAY_OUT: TaskId = TaskId(0xA5); + +/// `View Open Apps` (`0xA6`). +pub const VIEW_OPEN_APPS: TaskId = TaskId(0xA6); + +/// `View All Open Apps` (`0xA7`). +pub const VIEW_ALL_OPEN_APPS: TaskId = TaskId(0xA7); + +/// `AppSwitch` (`0xA8`). +pub const APP_SWITCH: TaskId = TaskId(0xA8); + +/// `Gesture Button Navigation` (`0xA9`). +pub const GESTURE_BUTTON_NAVIGATION: TaskId = TaskId(0xA9); + +/// `Fn inversion` (`0xAA`). +pub const FN_INVERSION: TaskId = TaskId(0xAA); + +/// `Multiplatform Back` (`0xAB`). +pub const MULTIPLATFORM_BACK_171: TaskId = TaskId(0xAB); + +/// `Multiplatform Forward` (`0xAC`). +pub const MULTIPLATFORM_FORWARD: TaskId = TaskId(0xAC); + +/// `Multiplatform Gesture button` (`0xAD`). +pub const MULTIPLATFORM_GESTURE_BUTTON: TaskId = TaskId(0xAD); + +/// `HostSwitch channel 1` (`0xAE`). +pub const HOST_SWITCH_CHANNEL_1: TaskId = TaskId(0xAE); + +/// `HostSwitch channel 2` (`0xAF`). +pub const HOST_SWITCH_CHANNEL_2: TaskId = TaskId(0xAF); + +/// `HostSwitch channel 3` (`0xB0`). +pub const HOST_SWITCH_CHANNEL_3: TaskId = TaskId(0xB0); + +/// `Multiplatform Search` (`0xB1`). +pub const MULTIPLATFORM_SEARCH: TaskId = TaskId(0xB1); + +/// `Multiplatform Home / Mission Control` (`0xB2`). +pub const MULTIPLATFORM_HOME_MISSION_CONTROL: TaskId = TaskId(0xB2); + +/// `Multiplatform Menu / Launchpad` (`0xB3`). +pub const MULTIPLATFORM_MENU_LAUNCHPAD: TaskId = TaskId(0xB3); + +/// `Virtual Gesture Button` (`0xB4`). +pub const VIRTUAL_GESTURE_BUTTON: TaskId = TaskId(0xB4); + +/// `Cursor` (`0xB5`). +pub const CURSOR: TaskId = TaskId(0xB5); + +/// `keyboard right arrow` (`0xB6`). +pub const KEYBOARD_RIGHT_ARROW: TaskId = TaskId(0xB6); + +/// `SW custom highlight` (`0xB7`). +pub const SW_CUSTOM_HIGHLIGHT: TaskId = TaskId(0xB7); + +/// `keyboard left arrow` (`0xB8`). +pub const KEYBOARD_LEFT_ARROW: TaskId = TaskId(0xB8); + +/// `TBD` (`0xB9`). +pub const TBD: TaskId = TaskId(0xB9); + +/// `Multiplatform Language Switch` (`0xBA`). +pub const MULTIPLATFORM_LANGUAGE_SWITCH: TaskId = TaskId(0xBA); + +/// `SWCustomHighligt2` (`0xBB`). +pub const SW_CUSTOM_HIGHLIGT_2: TaskId = TaskId(0xBB); + +/// `FastForward` (`0xBC`). +pub const FAST_FORWARD: TaskId = TaskId(0xBC); + +/// `FastBackward` (`0xBD`). +pub const FAST_BACKWARD: TaskId = TaskId(0xBD); + +/// `SwitchHighlighting` (`0xBE`). +pub const SWITCH_HIGHLIGHTING: TaskId = TaskId(0xBE); + +/// `Mission Control / Task View` (`0xBF`). +pub const MISSION_CONTROL_TASK_VIEW: TaskId = TaskId(0xBF); + +/// `Dashboard (Launchpad) / Action Center` (`0xC0`). +pub const DASHBOARD_LAUNCHPAD_ACTION_CENTER: TaskId = TaskId(0xC0); + +/// `Backlight - (FW internal function)` (`0xC1`). +pub const BACKLIGHT_MINUS_FW_INTERNAL_FUNCTION: TaskId = TaskId(0xC1); + +/// `Backlight + (FW internal function)` (`0xC2`). +pub const BACKLIGHT_PLUS_FW_INTERNAL_FUNCTION: TaskId = TaskId(0xC2); + +/// `Right Click / App (Contextual Menu)` (`0xC3`). +pub const RIGHT_CLICK_APP_CONTEXTUAL_MENU: TaskId = TaskId(0xC3); + +/// `DPI Change` (`0xC4`). +pub const DPI_CHANGE: TaskId = TaskId(0xC4); + +/// `New Tab` (`0xC5`). +pub const NEW_TAB: TaskId = TaskId(0xC5); + +/// `F2` (`0xC6`). +pub const F2: TaskId = TaskId(0xC6); + +/// `F3` (`0xC7`). +pub const F3: TaskId = TaskId(0xC7); + +/// `F4` (`0xC8`). +pub const F4: TaskId = TaskId(0xC8); + +/// `F5` (`0xC9`). +pub const F5: TaskId = TaskId(0xC9); + +/// `F6` (`0xCA`). +pub const F6: TaskId = TaskId(0xCA); + +/// `F7` (`0xCB`). +pub const F7: TaskId = TaskId(0xCB); + +/// `F8` (`0xCC`). +pub const F8: TaskId = TaskId(0xCC); + +/// `F1` (`0xCD`). +pub const F1: TaskId = TaskId(0xCD); + +/// `laser button` (`0xCE`). +pub const LASER_BUTTON: TaskId = TaskId(0xCE); + +/// `laser button long press` (`0xCF`). +pub const LASER_BUTTON_LONG_PRESS: TaskId = TaskId(0xCF); + +/// `start presentation` (`0xD0`). +pub const START_PRESENTATION: TaskId = TaskId(0xD0); + +/// `blank screen` (`0xD1`). +pub const BLANK_SCREEN: TaskId = TaskId(0xD1); + +/// `DPI switch` (`0xD2`). +pub const DPI_SWITCH: TaskId = TaskId(0xD2); + +/// `MultiPlatform Home / Show Desktop` (`0xD3`). +pub const MULTIPLATFORM_HOME_SHOW_DESKTOP: TaskId = TaskId(0xD3); + +/// `MultiPlatform App Switch / Dashboard` (`0xD4`). +pub const MULTIPLATFORM_APP_SWITCH_DASHBOARD: TaskId = TaskId(0xD4); + +/// `MultiPlatform App Switch` (`0xD5`). +pub const MULTIPLATFORM_APP_SWITCH_213: TaskId = TaskId(0xD5); + +/// `Fn Inversion` (`0xD6`). +pub const FN_INVERSION_214: TaskId = TaskId(0xD6); + +/// `LeftAndRightClick` (`0xD7`). +pub const LEFT_AND_RIGHT_CLICK: TaskId = TaskId(0xD7); + +/// `Voice Dictation` (`0xD8`). +pub const VOICE_DICTATION: TaskId = TaskId(0xD8); + +/// `Emoji - Smiling face with heart shaped eyes` (`0xD9`). +pub const EMOJI_SMILING_FACE_WITH_HEART_SHAPED_EYES: TaskId = TaskId(0xD9); + +/// `Emoji - Loudly Crying face` (`0xDA`). +pub const EMOJI_LOUDLY_CRYING_FACE: TaskId = TaskId(0xDA); + +/// `Emoji - Smiley` (`0xDB`). +pub const EMOJI_SMILEY: TaskId = TaskId(0xDB); + +/// `Emoji - Smiley with tears` (`0xDC`). +pub const EMOJI_SMILEY_WITH_TEARS: TaskId = TaskId(0xDC); + +/// `Open emoji panel` (`0xDD`). +pub const OPEN_EMOJI_PANEL: TaskId = TaskId(0xDD); + +/// `Multiplatform App Switch/Launchpad` (`0xDE`). +pub const MULTIPLATFORM_APP_SWITCH_LAUNCHPAD: TaskId = TaskId(0xDE); + +/// `Snipping tool` (`0xDF`). +pub const SNIPPING_TOOL: TaskId = TaskId(0xDF); + +/// `Grave accent` (`0xE0`). +pub const GRAVE_ACCENT: TaskId = TaskId(0xE0); + +/// `Standard Tab key` (`0xE1`). +pub const STANDARD_TAB_KEY: TaskId = TaskId(0xE1); + +/// `Caps lock` (`0xE2`). +pub const CAPS_LOCK: TaskId = TaskId(0xE2); + +/// `Left Shift` (`0xE3`). +pub const LEFT_SHIFT: TaskId = TaskId(0xE3); + +/// `Left Control` (`0xE4`). +pub const LEFT_CONTROL: TaskId = TaskId(0xE4); + +/// `Left Option /Start` (`0xE5`). +pub const LEFT_OPTION_START: TaskId = TaskId(0xE5); + +/// `Left Command/Alt` (`0xE6`). +pub const LEFT_COMMAND_ALT: TaskId = TaskId(0xE6); + +/// `Right Command/Alt` (`0xE7`). +pub const RIGHT_COMMAND_ALT: TaskId = TaskId(0xE7); + +/// `Right Option/Start` (`0xE8`). +pub const RIGHT_OPTION_START: TaskId = TaskId(0xE8); + +/// `Right Control` (`0xE9`). +pub const RIGHT_CONTROL: TaskId = TaskId(0xE9); + +/// `Right Shift` (`0xEA`). +pub const RIGHT_SHIFT: TaskId = TaskId(0xEA); + +/// `Insert` (`0xEB`). +pub const INSERT: TaskId = TaskId(0xEB); + +/// `Delete` (`0xEC`). +pub const DELETE: TaskId = TaskId(0xEC); + +/// `Home` (`0xED`). +pub const HOME: TaskId = TaskId(0xED); + +/// `End` (`0xEE`). +pub const END: TaskId = TaskId(0xEE); + +/// `Page Up` (`0xEF`). +pub const PAGE_UP: TaskId = TaskId(0xEF); + +/// `Page Down` (`0xF0`). +pub const PAGE_DOWN: TaskId = TaskId(0xF0); + +/// `Mute microphone` (`0xF1`). +pub const MUTE_MICROPHONE: TaskId = TaskId(0xF1); + +/// `Do not disturb` (`0xF2`). +pub const DO_NOT_DISTURB: TaskId = TaskId(0xF2); + +/// `Backslash` (`0xF3`). +pub const BACKSLASH: TaskId = TaskId(0xF3); + +/// `Refresh` (`0xF4`). +pub const REFRESH: TaskId = TaskId(0xF4); + +/// `Close Tab` (`0xF5`). +pub const CLOSE_TAB: TaskId = TaskId(0xF5); + +/// `Lang Switch` (`0xF6`). +pub const LANG_SWITCH: TaskId = TaskId(0xF6); + +/// `Standard alphabetical key` (`0xF7`). +pub const STANDARD_ALPHABETICAL_KEY: TaskId = TaskId(0xF7); + +/// `Right Option / Start` (`0xF8`). +pub const RIGHT_OPTION_START_248: TaskId = TaskId(0xF8); + +/// `Left Option` (`0xF9`). +pub const LEFT_OPTION: TaskId = TaskId(0xF9); + +/// `Right Option` (`0xFA`). +pub const RIGHT_OPTION: TaskId = TaskId(0xFA); + +/// `Left cmd` (`0xFB`). +pub const LEFT_CMD: TaskId = TaskId(0xFB); + +/// `Right cmd` (`0xFC`). +pub const RIGHT_CMD: TaskId = TaskId(0xFC); + +#[cfg(test)] +mod tests { + use super::TaskId; + use crate::feature::reprog_controls::control_ids; + + #[test] + fn task_id_round_trips_through_u16() { + assert_eq!(u16::from(TaskId::from(0x93)), 0x93); + assert_eq!(TaskId::from(0x93), super::SWITCH_PRESENTATION_SWITCH_SCREEN); + } + + #[test] + fn known_constants_have_expected_values() { + assert_eq!(super::RIGHT_CMD, TaskId(0xfc)); + assert_eq!(control_ids::SMART_SHIFT.0, 0xc4); + assert_eq!(control_ids::DPI_SWITCH.0, 0xfd); + } +} From 7b2190fbb619f9e488d74906c3f56fdd4e0ed317 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sat, 27 Jun 2026 20:17:41 +0800 Subject: [PATCH 17/21] docs(hidpp): document feature coverage in crate READMEs --- crates/openlogi-hid/README.md | 6 ++++++ crates/openlogi-hidpp/README.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/crates/openlogi-hid/README.md b/crates/openlogi-hid/README.md index 2e172ead..c3498b89 100644 --- a/crates/openlogi-hid/README.md +++ b/crates/openlogi-hid/README.md @@ -19,4 +19,10 @@ Public entry points include: - `list_pairing_receivers`, `run_pairing`, and `unpair` for receiver pairing. - `get_dpi`, `set_dpi`, SmartShift, high-resolution wheel, thumbwheel, and reprogrammable-control helpers for supported HID++ features. +- `set_keyboard_color` / `set_keyboard_color_with` for solid keyboard RGB — + preferring the typed `ColorLedEffects` (`0x8070`) wrapper and falling back to + the `PerKeyLighting` (`0x8080`) stream. - `dump_features` and `dump_reprog_controls` for diagnostics. + +Protocol-level feature support lives in `openlogi-hidpp`; this crate adds the +discovery, routing, fallback, and error-classification policy on top. diff --git a/crates/openlogi-hidpp/README.md b/crates/openlogi-hidpp/README.md index aa66f6f6..98bee11b 100644 --- a/crates/openlogi-hidpp/README.md +++ b/crates/openlogi-hidpp/README.md @@ -13,3 +13,31 @@ upstream to ease future syncs. The crate is versioned with the OpenLogi workspace (unified versioning), not upstream's `0.3.0` — that number is provenance, recorded above. + +## Feature coverage + +Beyond upstream, this fork adds typed wrappers for a broad set of HID++ 2.0 +features. Each is registered in [`feature::registry`] and obtained via +`device.get_feature::<…>()`; implemented areas include: + +- **Device & power** — Root, FeatureSet, DeviceInformation, DeviceTypeAndName, + DeviceFriendlyName, UnifiedBattery, WirelessDeviceStatus. +- **Hosts & platform** — HostsInfo, ChangeHost, MultiPlatform, DualPlatform. +- **Pointer & wheel** — MousePointer, AdjustableDpi, ExtendedAdjustableDpi, + VerticalScrolling, HiResWheel, Thumbwheel, SmartShift (and enhanced). +- **Controls & remapping** — ReprogControls (`0x1b04`, with named control-id and + task-id constants), PersistentRemappableAction. +- **Keyboard** — Fn inversion (legacy and multi-host), DisableKeys, + DisableKeysByUsage, ModeStatus. +- **Lighting** — Backlight, Illumination, BrightnessControl, ColorLedEffects, + RgbEffects, PerKeyLighting. +- **Audio** — Sidetone, Equalizer. +- **Report rate** — AdjustableReportRate, ExtendedAdjustableReportRate. +- **Touch, crown & misc** — Crown, TouchpadRawXy, TouchMouseRaw, + SolarKeyboardDashboard. + +Each wrapper encodes/decodes the official wire format, models domain values with +enums/bitflags/newtypes, returns `Hidpp20Error::UnsupportedResponse` for unknown +wire values rather than guessing, and is unit-tested against the published spec. + +[`feature::registry`]: ./src/feature/registry.rs From 50d5b5b3e949f9b3bc59bb21d6422dd38b35beb2 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sat, 27 Jun 2026 20:30:28 +0800 Subject: [PATCH 18/21] fix(hidpp): validate typed feature inputs --- .../src/feature/disable_keys_by_usage/mod.rs | 22 +++++++++- .../src/feature/extended_dpi/mod.rs | 2 +- .../src/feature/extended_dpi/tests.rs | 30 ++++++++++--- .../src/feature/extended_dpi/types.rs | 17 ++++---- .../src/feature/illumination/mod.rs | 7 ++- .../src/feature/illumination/tests.rs | 43 +++++++++++++++++-- .../src/feature/illumination/types.rs | 14 +++--- .../src/feature/per_key_lighting/mod.rs | 36 +++++++++++++++- .../src/feature/per_key_lighting/tests.rs | 32 ++++++++++++++ .../feature/reprog_controls/control_ids.rs | 4 +- .../src/feature/reprog_controls/task_ids.rs | 2 +- .../src/feature/smartshift_enhanced/mod.rs | 13 +++--- 12 files changed, 179 insertions(+), 43 deletions(-) diff --git a/crates/openlogi-hidpp/src/feature/disable_keys_by_usage/mod.rs b/crates/openlogi-hidpp/src/feature/disable_keys_by_usage/mod.rs index f640dc91..56eeb19b 100644 --- a/crates/openlogi-hidpp/src/feature/disable_keys_by_usage/mod.rs +++ b/crates/openlogi-hidpp/src/feature/disable_keys_by_usage/mod.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use crate::{ channel::HidppChannel, feature::{CreatableFeature, Feature, FeatureEndpoint}, - protocol::v20::Hidpp20Error, + protocol::v20::{ErrorType, Hidpp20Error}, }; /// Number of usage bytes carried by one long-report request. @@ -48,6 +48,7 @@ impl DisableKeysByUsageFeature { /// itself be disabled. More usages than fit in one request are sent over /// several requests, which the device still accumulates. pub async fn disable_keys(&self, usages: &[u8]) -> Result<(), Hidpp20Error> { + validate_usages(usages)?; for packet in usage_packets(usages) { self.endpoint.call_long(1, packet).await?; } @@ -60,6 +61,7 @@ impl DisableKeysByUsageFeature { /// Enabling a usage that is not disabled is a no-op. A usage of `0` /// terminates the list. pub async fn enable_keys(&self, usages: &[u8]) -> Result<(), Hidpp20Error> { + validate_usages(usages)?; for packet in usage_packets(usages) { self.endpoint.call_long(2, packet).await?; } @@ -73,6 +75,13 @@ impl DisableKeysByUsageFeature { } } +fn validate_usages(usages: &[u8]) -> Result<(), Hidpp20Error> { + if usages.contains(&0) { + return Err(Hidpp20Error::Feature(ErrorType::InvalidArgument)); + } + Ok(()) +} + /// Splits `usages` into long-report packets of up to [`USAGES_PER_PACKET`] /// bytes. /// @@ -92,7 +101,8 @@ fn usage_packets(usages: &[u8]) -> Vec<[u8; USAGES_PER_PACKET]> { #[cfg(test)] mod tests { - use super::usage_packets; + use super::{usage_packets, validate_usages}; + use crate::protocol::v20::{ErrorType, Hidpp20Error}; #[test] fn empty_usage_list_sends_no_packets() { @@ -108,6 +118,14 @@ mod tests { assert!(packets[0][3..].iter().all(|&b| b == 0)); } + #[test] + fn rejects_zero_usage_before_packetizing() { + assert!(matches!( + validate_usages(&[0x39, 0, 0x3a]), + Err(Hidpp20Error::Feature(ErrorType::InvalidArgument)) + )); + } + #[test] fn full_packet_has_no_terminator() { let usages: Vec = (1..=16).collect(); diff --git a/crates/openlogi-hidpp/src/feature/extended_dpi/mod.rs b/crates/openlogi-hidpp/src/feature/extended_dpi/mod.rs index 7e9ccb50..d7c9bd81 100644 --- a/crates/openlogi-hidpp/src/feature/extended_dpi/mod.rs +++ b/crates/openlogi-hidpp/src/feature/extended_dpi/mod.rs @@ -299,7 +299,7 @@ impl ExtendedDpiFeature { direction: DpiDirection, correction: DpiCalibrationCorrection, ) -> Result<(), Hidpp20Error> { - let [cor_hi, cor_lo] = correction.to_wire().to_be_bytes(); + let [cor_hi, cor_lo] = correction.to_wire()?.to_be_bytes(); let mut args = [0; 16]; args[..4].copy_from_slice(&[sensor_index, direction.into(), cor_hi, cor_lo]); self.endpoint.call_long(10, args).await?; diff --git a/crates/openlogi-hidpp/src/feature/extended_dpi/tests.rs b/crates/openlogi-hidpp/src/feature/extended_dpi/tests.rs index 2f306744..651cccb1 100644 --- a/crates/openlogi-hidpp/src/feature/extended_dpi/tests.rs +++ b/crates/openlogi-hidpp/src/feature/extended_dpi/tests.rs @@ -5,7 +5,7 @@ use super::types::{ DpiCalibrationCorrection, DpiDirection, DpiRange, Lod, parse_dpi_list, parse_dpi_ranges, parse_lod_list, terminated_word_len, }; -use crate::protocol::v20::Hidpp20Error; +use crate::protocol::v20::{ErrorType, Hidpp20Error}; #[test] fn parses_fixed_dpi_ranges_pws_example() { @@ -272,11 +272,31 @@ fn ignores_event_with_unknown_lod() { #[test] fn encodes_calibration_correction_sentinels() { - assert_eq!(DpiCalibrationCorrection::Adjust(100).to_wire(), 100); - assert_eq!(DpiCalibrationCorrection::Adjust(-512).to_wire(), -512); - assert_eq!(DpiCalibrationCorrection::RevertToOob.to_wire(), 0); assert_eq!( - DpiCalibrationCorrection::RevertToProfile.to_wire(), + DpiCalibrationCorrection::Adjust(100).to_wire().unwrap(), + 100 + ); + assert_eq!( + DpiCalibrationCorrection::Adjust(-512).to_wire().unwrap(), + -512 + ); + assert_eq!(DpiCalibrationCorrection::RevertToOob.to_wire().unwrap(), 0); + assert_eq!( + DpiCalibrationCorrection::RevertToProfile.to_wire().unwrap(), i16::MIN ); } + +#[test] +fn rejects_out_of_range_calibration_corrections() { + for correction in [ + DpiCalibrationCorrection::Adjust(-1024), + DpiCalibrationCorrection::Adjust(1024), + DpiCalibrationCorrection::Adjust(i16::MIN), + ] { + assert!(matches!( + correction.to_wire(), + Err(Hidpp20Error::Feature(ErrorType::InvalidArgument)) + )); + } +} diff --git a/crates/openlogi-hidpp/src/feature/extended_dpi/types.rs b/crates/openlogi-hidpp/src/feature/extended_dpi/types.rs index 746e228c..c3965958 100644 --- a/crates/openlogi-hidpp/src/feature/extended_dpi/types.rs +++ b/crates/openlogi-hidpp/src/feature/extended_dpi/types.rs @@ -2,7 +2,7 @@ use num_enum::{IntoPrimitive, TryFromPrimitive}; -use crate::protocol::v20::Hidpp20Error; +use crate::protocol::v20::{ErrorType, Hidpp20Error}; /// The axis a DPI value or calibration applies to. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)] @@ -250,20 +250,19 @@ pub enum DpiCalibrationCorrection { impl DpiCalibrationCorrection { /// The signed 16-bit wire value for this correction. - pub(super) fn to_wire(self) -> i16 { + pub(super) fn to_wire(self) -> Result { match self { // 0x8000 as a signed 16-bit integer. - DpiCalibrationCorrection::RevertToProfile => i16::MIN, - DpiCalibrationCorrection::RevertToOob => 0, + DpiCalibrationCorrection::RevertToProfile => Ok(i16::MIN), + DpiCalibrationCorrection::RevertToOob => Ok(0), DpiCalibrationCorrection::Adjust(value) => { // `i16::MIN` is the `0x8000` "revert to profile" sentinel, not a // correction; an out-of-range adjustment would silently collide // with it instead of eliciting the device's `INVALID_ARGUMENT`. - debug_assert!( - (-1023..=1023).contains(&value), - "calibration correction {value} out of range -1023..=1023" - ); - value + if !(-1023..=1023).contains(&value) { + return Err(Hidpp20Error::Feature(ErrorType::InvalidArgument)); + } + Ok(value) } } } diff --git a/crates/openlogi-hidpp/src/feature/illumination/mod.rs b/crates/openlogi-hidpp/src/feature/illumination/mod.rs index 3c55f27d..dee24b56 100644 --- a/crates/openlogi-hidpp/src/feature/illumination/mod.rs +++ b/crates/openlogi-hidpp/src/feature/illumination/mod.rs @@ -28,7 +28,7 @@ use crate::{ channel::{HidppChannel, MessageListenerGuard}, event::EventEmitter, feature::{CreatableFeature, EmittingFeature, Feature, FeatureEndpoint, event_payload}, - protocol::v20::Hidpp20Error, + protocol::v20::{ErrorType, Hidpp20Error}, }; // Function ids. Color-temperature functions mirror the brightness ones offset by @@ -220,6 +220,9 @@ impl IlluminationFeature { function: u8, start_index: u8, ) -> Result { + if start_index > 0x0f { + return Err(Hidpp20Error::Feature(ErrorType::InvalidArgument)); + } // The request carries the start index in the high nibble of byte 0. let payload = self .endpoint @@ -232,7 +235,7 @@ impl IlluminationFeature { /// Shared `setLevels` writer. async fn write_levels(&self, function: u8, levels: &SetLevels) -> Result<(), Hidpp20Error> { self.endpoint - .call_long(function, levels.to_payload()) + .call_long(function, levels.to_payload()?) .await?; Ok(()) } diff --git a/crates/openlogi-hidpp/src/feature/illumination/tests.rs b/crates/openlogi-hidpp/src/feature/illumination/tests.rs index 6b667882..eaa0c52b 100644 --- a/crates/openlogi-hidpp/src/feature/illumination/tests.rs +++ b/crates/openlogi-hidpp/src/feature/illumination/tests.rs @@ -5,6 +5,7 @@ use super::types::{ BrightnessClampedSource, ControlCapabilities, ControlInfo, IlluminationState, LevelConfig, SetLevels, }; +use crate::protocol::v20::{ErrorType, Hidpp20Error}; #[test] fn parses_control_info() { @@ -77,7 +78,7 @@ fn parses_non_linear_levels() { #[test] fn encodes_reset_levels() { - let payload = SetLevels::Reset.to_payload(); + let payload = SetLevels::Reset.to_payload().unwrap(); assert_eq!(payload[0], 1 << 1); assert!(payload[1..].iter().all(|&b| b == 0)); } @@ -89,7 +90,8 @@ fn encodes_linear_levels() { max: 500, step: 50, } - .to_payload(); + .to_payload() + .unwrap(); assert_eq!(payload[0], 1); assert_eq!(u16::from_be_bytes([payload[2], payload[3]]), 100); assert_eq!(u16::from_be_bytes([payload[4], payload[5]]), 500); @@ -103,7 +105,8 @@ fn encodes_non_linear_levels() { level_count: 5, values: vec![100, 200], } - .to_payload(); + .to_payload() + .unwrap(); assert_eq!(payload[0], 2 << 5); // validCount = 2, linear/reset clear assert_eq!(payload[1], (2 << 4) | 5); // startIndex 2, levelCount 5 @@ -120,7 +123,8 @@ fn non_linear_round_trips_through_decoder() { level_count: 3, values: vec![50, 150, 300], } - .to_payload(); + .to_payload() + .unwrap(); assert_eq!( LevelConfig::from_payload(&payload), @@ -132,6 +136,37 @@ fn non_linear_round_trips_through_decoder() { ); } +#[test] +fn rejects_invalid_non_linear_levels() { + for levels in [ + SetLevels::NonLinear { + start_index: 0, + level_count: 1, + values: vec![], + }, + SetLevels::NonLinear { + start_index: 0, + level_count: 8, + values: vec![1, 2, 3, 4, 5, 6, 7, 8], + }, + SetLevels::NonLinear { + start_index: 0x10, + level_count: 1, + values: vec![1], + }, + SetLevels::NonLinear { + start_index: 0, + level_count: 0x10, + values: vec![1], + }, + ] { + assert!(matches!( + levels.to_payload(), + Err(Hidpp20Error::Feature(ErrorType::InvalidArgument)) + )); + } +} + #[test] fn decodes_state_and_value_events() { let mut on = [0; 16]; diff --git a/crates/openlogi-hidpp/src/feature/illumination/types.rs b/crates/openlogi-hidpp/src/feature/illumination/types.rs index 2f805ef0..5e53c7dc 100644 --- a/crates/openlogi-hidpp/src/feature/illumination/types.rs +++ b/crates/openlogi-hidpp/src/feature/illumination/types.rs @@ -2,7 +2,7 @@ use num_enum::{IntoPrimitive, TryFromPrimitive}; -use crate::protocol::v20::Hidpp20Error; +use crate::protocol::v20::{ErrorType, Hidpp20Error}; /// Reads a big-endian `u16` at `offset` of a payload. pub(super) fn be16(payload: &[u8; 16], offset: usize) -> u16 { @@ -137,7 +137,7 @@ pub enum SetLevels { impl SetLevels { /// Encodes this configuration into a request payload. - pub(super) fn to_payload(&self) -> [u8; 16] { + pub(super) fn to_payload(&self) -> Result<[u8; 16], Hidpp20Error> { let mut args = [0u8; 16]; match self { SetLevels::Reset => { @@ -155,11 +155,9 @@ impl SetLevels { level_count, values, } => { - debug_assert!( - (1..=7).contains(&values.len()), - "non-linear level count {} out of range 1..=7", - values.len() - ); + if !(1..=7).contains(&values.len()) || *start_index > 0x0f || *level_count > 0x0f { + return Err(Hidpp20Error::Feature(ErrorType::InvalidArgument)); + } let valid_count = (values.len() as u8) & 0x07; args[0] = valid_count << 5; // linear = 0, reset = 0 args[1] = (start_index << 4) | (level_count & 0x0f); @@ -168,7 +166,7 @@ impl SetLevels { } } } - args + Ok(args) } } diff --git a/crates/openlogi-hidpp/src/feature/per_key_lighting/mod.rs b/crates/openlogi-hidpp/src/feature/per_key_lighting/mod.rs index 9c223cfd..fb03b506 100644 --- a/crates/openlogi-hidpp/src/feature/per_key_lighting/mod.rs +++ b/crates/openlogi-hidpp/src/feature/per_key_lighting/mod.rs @@ -17,7 +17,7 @@ use num_enum::{IntoPrimitive, TryFromPrimitive}; use crate::{ channel::HidppChannel, feature::{CreatableFeature, Feature, FeatureEndpoint}, - protocol::v20::Hidpp20Error, + protocol::v20::{ErrorType, Hidpp20Error}, }; /// Length of the zone-presence bitfield page returned by `getInfo`. @@ -138,6 +138,7 @@ impl PerKeyLightingFeature { /// /// At most four zones are sent; extra entries are ignored. pub async fn set_individual_rgb_zones(&self, zones: &[RgbZone]) -> Result<(), Hidpp20Error> { + validate_individual_zones(zones)?; self.endpoint .call_long(1, individual_zones_args(zones)) .await?; @@ -150,6 +151,7 @@ impl PerKeyLightingFeature { first_zone_id: u8, colors: [Rgb; CONSECUTIVE_ZONES], ) -> Result<(), Hidpp20Error> { + validate_zone_id(first_zone_id)?; self.endpoint .call_long(2, consecutive_zones_args(first_zone_id, colors)) .await?; @@ -187,6 +189,7 @@ impl PerKeyLightingFeature { /// /// At most three ranges are sent; extra entries are ignored. pub async fn set_range_rgb_zones(&self, ranges: &[RgbZoneRange]) -> Result<(), Hidpp20Error> { + validate_ranges(ranges)?; self.endpoint.call_long(5, range_zones_args(ranges)).await?; Ok(()) } @@ -199,6 +202,7 @@ impl PerKeyLightingFeature { color: Rgb, zone_ids: &[u8], ) -> Result<(), Hidpp20Error> { + validate_single_value_zones(zone_ids)?; self.endpoint .call_long(6, single_value_args(color, zone_ids)) .await?; @@ -227,6 +231,7 @@ impl PerKeyLightingFeature { first_zone_id: u8, packed: [u8; DELTA_PACKED_LEN], ) -> Result<(), Hidpp20Error> { + validate_zone_id(first_zone_id)?; self.endpoint .call_long(function, delta_args(first_zone_id, packed)) .await?; @@ -234,6 +239,35 @@ impl PerKeyLightingFeature { } } +fn validate_zone_id(zone_id: u8) -> Result<(), Hidpp20Error> { + if matches!(zone_id, 0 | 0xff) { + return Err(Hidpp20Error::Feature(ErrorType::InvalidArgument)); + } + Ok(()) +} + +fn validate_individual_zones(zones: &[RgbZone]) -> Result<(), Hidpp20Error> { + for zone in zones.iter().take(MAX_INDIVIDUAL_ZONES) { + validate_zone_id(zone.zone_id)?; + } + Ok(()) +} + +fn validate_ranges(ranges: &[RgbZoneRange]) -> Result<(), Hidpp20Error> { + for range in ranges.iter().take(MAX_RANGES) { + validate_zone_id(range.first_zone_id)?; + validate_zone_id(range.last_zone_id)?; + } + Ok(()) +} + +fn validate_single_value_zones(zone_ids: &[u8]) -> Result<(), Hidpp20Error> { + for &zone_id in zone_ids.iter().take(MAX_SINGLE_VALUE_ZONES) { + validate_zone_id(zone_id)?; + } + Ok(()) +} + /// Encodes a `setIndividualRgbZones` request. fn individual_zones_args(zones: &[RgbZone]) -> [u8; 16] { let mut args = [0; 16]; diff --git a/crates/openlogi-hidpp/src/feature/per_key_lighting/tests.rs b/crates/openlogi-hidpp/src/feature/per_key_lighting/tests.rs index a237ea3b..a504b013 100644 --- a/crates/openlogi-hidpp/src/feature/per_key_lighting/tests.rs +++ b/crates/openlogi-hidpp/src/feature/per_key_lighting/tests.rs @@ -3,7 +3,9 @@ use super::{ FramePersistence, Rgb, RgbZone, RgbZoneRange, ZonePresencePage, consecutive_zones_args, delta_args, frame_end_args, individual_zones_args, range_zones_args, single_value_args, + validate_individual_zones, validate_ranges, validate_single_value_zones, validate_zone_id, }; +use crate::protocol::v20::{ErrorType, Hidpp20Error}; const RED: Rgb = Rgb { red: 0xff, @@ -44,6 +46,36 @@ fn individual_zones_caps_at_four() { assert_eq!(args[12..16], [1, 0xff, 0, 0]); } +#[test] +fn rejects_reserved_zone_ids() { + for zone_id in [0, 0xff] { + assert!(matches!( + validate_zone_id(zone_id), + Err(Hidpp20Error::Feature(ErrorType::InvalidArgument)) + )); + } + + assert!(matches!( + validate_individual_zones(&[RgbZone { + zone_id: 0, + color: RED, + }]), + Err(Hidpp20Error::Feature(ErrorType::InvalidArgument)) + )); + assert!(matches!( + validate_ranges(&[RgbZoneRange { + first_zone_id: 1, + last_zone_id: 0xff, + color: RED, + }]), + Err(Hidpp20Error::Feature(ErrorType::InvalidArgument)) + )); + assert!(matches!( + validate_single_value_zones(&[1, 0xff]), + Err(Hidpp20Error::Feature(ErrorType::InvalidArgument)) + )); +} + #[test] fn encodes_consecutive_zones() { let colors = [ diff --git a/crates/openlogi-hidpp/src/feature/reprog_controls/control_ids.rs b/crates/openlogi-hidpp/src/feature/reprog_controls/control_ids.rs index f350918a..a44773a4 100644 --- a/crates/openlogi-hidpp/src/feature/reprog_controls/control_ids.rs +++ b/crates/openlogi-hidpp/src/feature/reprog_controls/control_ids.rs @@ -109,13 +109,13 @@ pub const CURSOR_BUTTON_LONG_PRESS: ControlId = ControlId(0xD8); pub const NEXT_BUTTON: ControlId = ControlId(0xD9); /// `Next Button Longpress` (`0xDA`). -pub const NEXT_BUTTON_LONGPRESS: ControlId = ControlId(0xDA); +pub const NEXT_BUTTON_LONG_PRESS: ControlId = ControlId(0xDA); /// `Back` (`0xDB`). pub const BACK: ControlId = ControlId(0xDB); /// `Back Button Longpress` (`0xDC`). -pub const BACK_BUTTON_LONGPRESS: ControlId = ControlId(0xDC); +pub const BACK_BUTTON_LONG_PRESS: ControlId = ControlId(0xDC); /// `Multi Platform Language Switch` (`0xDD`). pub const MULTI_PLATFORM_LANGUAGE_SWITCH: ControlId = ControlId(0xDD); diff --git a/crates/openlogi-hidpp/src/feature/reprog_controls/task_ids.rs b/crates/openlogi-hidpp/src/feature/reprog_controls/task_ids.rs index 36c9d10a..2f13bbea 100644 --- a/crates/openlogi-hidpp/src/feature/reprog_controls/task_ids.rs +++ b/crates/openlogi-hidpp/src/feature/reprog_controls/task_ids.rs @@ -127,7 +127,7 @@ pub const TBD: TaskId = TaskId(0xB9); pub const MULTIPLATFORM_LANGUAGE_SWITCH: TaskId = TaskId(0xBA); /// `SWCustomHighligt2` (`0xBB`). -pub const SW_CUSTOM_HIGHLIGT_2: TaskId = TaskId(0xBB); +pub const SW_CUSTOM_HIGHLIGHT_2: TaskId = TaskId(0xBB); /// `FastForward` (`0xBC`). pub const FAST_FORWARD: TaskId = TaskId(0xBC); diff --git a/crates/openlogi-hidpp/src/feature/smartshift_enhanced/mod.rs b/crates/openlogi-hidpp/src/feature/smartshift_enhanced/mod.rs index 8050585c..7e04c127 100644 --- a/crates/openlogi-hidpp/src/feature/smartshift_enhanced/mod.rs +++ b/crates/openlogi-hidpp/src/feature/smartshift_enhanced/mod.rs @@ -122,8 +122,7 @@ impl SmartShiftEnhancedFeature { impl SmartShiftEnhancedStatus { fn from_payload(payload: [u8; 16]) -> Result { Ok(Self { - wheel_mode: WheelMode::try_from(payload[0]) - .map_err(|_| Hidpp20Error::UnsupportedResponse)?, + wheel_mode: WheelMode::try_from(payload[0]).unwrap_or(WheelMode::Ratchet), auto_disengage: payload[1], current_tunable_torque: payload[2], }) @@ -133,7 +132,6 @@ impl SmartShiftEnhancedStatus { #[cfg(test)] mod tests { use super::{SmartShiftEnhancedStatus, WheelMode}; - use crate::protocol::v20::Hidpp20Error; #[test] fn parses_status() { @@ -150,13 +148,12 @@ mod tests { } #[test] - fn rejects_unknown_wheel_mode() { + fn falls_back_to_ratchet_for_unknown_wheel_mode() { let mut payload = [0; 16]; payload[0] = 9; - assert!(matches!( - SmartShiftEnhancedStatus::from_payload(payload), - Err(Hidpp20Error::UnsupportedResponse) - )); + let status = SmartShiftEnhancedStatus::from_payload(payload).unwrap(); + + assert_eq!(status.wheel_mode, WheelMode::Ratchet); } } From bdcfa8aad48db514737b96a444f259af754d7e35 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sat, 27 Jun 2026 21:13:32 +0800 Subject: [PATCH 19/21] fix(hidpp): address typed wrapper review notes --- crates/openlogi-hid/src/write/lighting.rs | 26 ++++++- crates/openlogi-hid/src/write/smartshift.rs | 76 ++++++++++++++----- .../src/feature/color_led_effects/mod.rs | 11 ++- .../src/feature/color_led_effects/tests.rs | 16 +++- .../src/feature/smartshift_enhanced/mod.rs | 14 ++-- 5 files changed, 114 insertions(+), 29 deletions(-) diff --git a/crates/openlogi-hid/src/write/lighting.rs b/crates/openlogi-hid/src/write/lighting.rs index 46aa2d87..41e7bad4 100644 --- a/crates/openlogi-hid/src/write/lighting.rs +++ b/crates/openlogi-hid/src/write/lighting.rs @@ -44,6 +44,11 @@ const KEYS_PER_FRAME: u8 = 0x0e; // agent re-applying the saved colour on device arrival (orchestrator reapply), // avoiding flash wear on every colour pick. const EFFECT_FIXED: u8 = 0x01; +// The old raw `0x8070` path intentionally wrote only zones 0..4: enough for the +// keyboards this path targets and bounded by a small, predictable delay budget. +// Keep that cap even though the typed wrapper can query the reported zone count; +// a malformed or unexpectedly large count should not stall a color apply. +const MAX_COLOR_LED_EFFECT_ZONES: u8 = 4; // Zones are paced apart because the controller can drop closely-spaced reports. const FRAME_GAP: Duration = Duration::from_millis(8); @@ -149,7 +154,24 @@ async fn set_color_effects(route: &DeviceRoute, r: u8, g: u8, b: u8) -> Result<( params[0] = r; params[1] = g; params[2] = b; - for zone in 0..zone_count { + let zones_to_write = if zone_count == 0 { + debug!( + index, + "0x8070 reported zero zones; applying legacy 4-zone fallback" + ); + MAX_COLOR_LED_EFFECT_ZONES + } else { + zone_count.min(MAX_COLOR_LED_EFFECT_ZONES) + }; + if zone_count > MAX_COLOR_LED_EFFECT_ZONES { + debug!( + index, + zone_count, + capped_zone_count = MAX_COLOR_LED_EFFECT_ZONES, + "0x8070 zone count capped to legacy write limit" + ); + } + for zone in 0..zones_to_write { feature .set_zone_effect(zone, EFFECT_FIXED, params, Persistence::Volatile) .await @@ -158,7 +180,7 @@ async fn set_color_effects(route: &DeviceRoute, r: u8, g: u8, b: u8) -> Result<( } debug!( index, - zone_count, r, g, b, "set keyboard colour via typed 0x8070" + zone_count, zones_to_write, r, g, b, "set keyboard colour via typed 0x8070" ); Ok(()) }) diff --git a/crates/openlogi-hid/src/write/smartshift.rs b/crates/openlogi-hid/src/write/smartshift.rs index a6cb0c9b..6fdb5be9 100644 --- a/crates/openlogi-hid/src/write/smartshift.rs +++ b/crates/openlogi-hid/src/write/smartshift.rs @@ -9,6 +9,7 @@ use hidpp::{ smartshift::{SmartShiftFeature, WheelMode}, smartshift_enhanced::{SmartShiftEnhancedFeature, SmartShiftEnhancedStatusChange}, }, + protocol::v20::{ErrorType, Hidpp20Error}, }; use tracing::debug; @@ -122,21 +123,25 @@ impl SmartShift { tunable_torque, } = status; match self { - Self::Enhanced(feature) => feature - .set_ratchet_control_mode(SmartShiftEnhancedStatusChange { - wheel_mode: Some(smartshift_to_wheel(mode)), - auto_disengage: Some(auto_disengage), - tunable_torque: Some(tunable_torque), - }) - .await - .map(|_| ()) - .map_err(|e| { - classify_hidpp_error( - e, - HidppOperation::WriteSmartShift, - SmartShiftEnhancedFeature::ID, - ) - }), + Self::Enhanced(feature) => { + let auto_disengage = nonzero_smartshift_value(auto_disengage)?; + let tunable_torque = nonzero_smartshift_value(tunable_torque)?; + feature + .set_ratchet_control_mode(SmartShiftEnhancedStatusChange { + wheel_mode: Some(smartshift_to_wheel(mode)), + auto_disengage: Some(auto_disengage), + tunable_torque: Some(tunable_torque), + }) + .await + .map(|_| ()) + .map_err(|e| { + classify_hidpp_error( + e, + HidppOperation::WriteSmartShift, + SmartShiftEnhancedFeature::ID, + ) + }) + } Self::Legacy(feature) => feature .set_ratchet_control_mode( Some(smartshift_to_wheel(mode)), @@ -157,14 +162,45 @@ impl SmartShift { /// non-write rather than a real sensitivity update. async fn set_sensitivity(&self, value: NonZeroU8) -> Result<(), WriteError> { let current = self.status().await?; - self.set_status(SmartShiftStatus { - auto_disengage: value.get(), - ..current - }) - .await + match self { + Self::Enhanced(feature) => feature + .set_ratchet_control_mode(SmartShiftEnhancedStatusChange { + wheel_mode: Some(smartshift_to_wheel(current.mode)), + auto_disengage: Some(value), + // Preserve a reported zero as “do not change”; HID++ uses + // zero as the sentinel and cannot write it as a target value. + tunable_torque: NonZeroU8::new(current.tunable_torque), + }) + .await + .map(|_| ()) + .map_err(|e| { + classify_hidpp_error( + e, + HidppOperation::WriteSmartShift, + SmartShiftEnhancedFeature::ID, + ) + }), + Self::Legacy(_) => { + self.set_status(SmartShiftStatus { + auto_disengage: value.get(), + ..current + }) + .await + } + } } } +fn nonzero_smartshift_value(value: u8) -> Result { + NonZeroU8::new(value).ok_or_else(|| { + classify_hidpp_error( + Hidpp20Error::Feature(ErrorType::InvalidArgument), + HidppOperation::WriteSmartShift, + SmartShiftEnhancedFeature::ID, + ) + }) +} + /// Read the device's current SmartShift mode + sensitivity — companion to /// [`toggle_smartshift`]. pub async fn get_smartshift_status(route: &DeviceRoute) -> Result { diff --git a/crates/openlogi-hidpp/src/feature/color_led_effects/mod.rs b/crates/openlogi-hidpp/src/feature/color_led_effects/mod.rs index 8ec321a9..f31f73b8 100644 --- a/crates/openlogi-hidpp/src/feature/color_led_effects/mod.rs +++ b/crates/openlogi-hidpp/src/feature/color_led_effects/mod.rs @@ -30,7 +30,7 @@ use crate::{ channel::{HidppChannel, MessageListenerGuard}, event::EventEmitter, feature::{CreatableFeature, EmittingFeature, Feature, FeatureEndpoint, event_payload}, - protocol::v20::Hidpp20Error, + protocol::v20::{ErrorType, Hidpp20Error}, }; /// Implements the `ColorLedEffects` / `0x8070` feature. @@ -144,6 +144,7 @@ impl ColorLedEffectsFeature { &self, capability: NvCapabilities, ) -> Result { + validate_single_nv_capability(capability)?; let [cap_hi, cap_lo] = capability.bits().to_be_bytes(); let payload = self .endpoint @@ -167,6 +168,7 @@ impl ColorLedEffectsFeature { param1: u8, param2: u8, ) -> Result<(), Hidpp20Error> { + validate_single_nv_capability(capability)?; let [cap_hi, cap_lo] = capability.bits().to_be_bytes(); let mut args = [0; 16]; args[..5].copy_from_slice(&[cap_hi, cap_lo, state.into(), param1, param2]); @@ -307,3 +309,10 @@ impl ColorLedEffectsFeature { LedBinInfo::from_payload(&payload) } } + +fn validate_single_nv_capability(capability: NvCapabilities) -> Result<(), Hidpp20Error> { + if capability.bits().count_ones() != 1 { + return Err(Hidpp20Error::Feature(ErrorType::InvalidArgument)); + } + Ok(()) +} diff --git a/crates/openlogi-hidpp/src/feature/color_led_effects/tests.rs b/crates/openlogi-hidpp/src/feature/color_led_effects/tests.rs index 89556bf6..4d073533 100644 --- a/crates/openlogi-hidpp/src/feature/color_led_effects/tests.rs +++ b/crates/openlogi-hidpp/src/feature/color_led_effects/tests.rs @@ -5,7 +5,8 @@ use super::types::{ ColorLedInfo, EffectId, EffectSettings, ExtCapabilities, LedBinIndex, LedBinInfo, LocationEffect, NvCapabilities, PersistencyCapabilities, ZoneEffect, ZoneEffectInfo, ZoneInfo, }; -use crate::protocol::v20::Hidpp20Error; +use super::validate_single_nv_capability; +use crate::protocol::v20::{ErrorType, Hidpp20Error}; #[test] fn parses_info() { @@ -163,3 +164,16 @@ fn maps_effect_id_wire_values() { assert!(EffectId::try_from(12u16).is_err()); assert_eq!(u16::from(EffectId::FixedColor), 1); } + +#[test] +fn validates_single_nv_capability() { + assert!(validate_single_nv_capability(NvCapabilities::BOOT_UP_EFFECT).is_ok()); + assert!(matches!( + validate_single_nv_capability(NvCapabilities::empty()), + Err(Hidpp20Error::Feature(ErrorType::InvalidArgument)) + )); + assert!(matches!( + validate_single_nv_capability(NvCapabilities::BOOT_UP_EFFECT | NvCapabilities::DEMO), + Err(Hidpp20Error::Feature(ErrorType::InvalidArgument)) + )); +} diff --git a/crates/openlogi-hidpp/src/feature/smartshift_enhanced/mod.rs b/crates/openlogi-hidpp/src/feature/smartshift_enhanced/mod.rs index 7e04c127..6d3aaa12 100644 --- a/crates/openlogi-hidpp/src/feature/smartshift_enhanced/mod.rs +++ b/crates/openlogi-hidpp/src/feature/smartshift_enhanced/mod.rs @@ -1,6 +1,6 @@ //! Implements `SmartShiftWheelEnhanced` (feature `0x2111`). -use std::sync::Arc; +use std::{num::NonZeroU8, sync::Arc}; use crate::{ channel::HidppChannel, @@ -53,9 +53,13 @@ pub struct SmartShiftEnhancedStatusChange { /// Wheel mode to apply, or `None` to leave unchanged. pub wheel_mode: Option, /// Automatic disengage threshold, or `None` to leave unchanged. - pub auto_disengage: Option, + /// + /// HID++ encodes `0` as “do not change”, so writable values must be non-zero. + pub auto_disengage: Option, /// Tunable torque, or `None` to leave unchanged. - pub tunable_torque: Option, + /// + /// HID++ encodes `0` as “do not change”, so writable values must be non-zero. + pub tunable_torque: Option, } /// Implements the `SmartShiftWheelEnhanced` / `0x2111` feature. @@ -109,8 +113,8 @@ impl SmartShiftEnhancedFeature { 2, [ change.wheel_mode.map_or(0, u8::from), - change.auto_disengage.unwrap_or(0), - change.tunable_torque.unwrap_or(0), + change.auto_disengage.map_or(0, NonZeroU8::get), + change.tunable_torque.map_or(0, NonZeroU8::get), ], ) .await? From 008376773dae57922ec04c5f7c5dcd3b32ae883a Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sat, 27 Jun 2026 21:24:17 +0800 Subject: [PATCH 20/21] fix(hid): preserve zero SmartShift torque on toggle --- crates/openlogi-hid/src/write/smartshift.rs | 38 +++++++++++++++++---- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/crates/openlogi-hid/src/write/smartshift.rs b/crates/openlogi-hid/src/write/smartshift.rs index 6fdb5be9..4ac41bae 100644 --- a/crates/openlogi-hid/src/write/smartshift.rs +++ b/crates/openlogi-hid/src/write/smartshift.rs @@ -155,6 +155,37 @@ impl SmartShift { } } + /// Write a new wheel `mode`, preserving the fields read from the device. + /// + /// Enhanced SmartShift uses `0` as the “do not change” sentinel, so a zero + /// readback is preserved by sending `None` rather than rejected as a target + /// value. This is for read-modify-write flows like toggle; explicit user + /// writes still go through [`Self::set_status`] and reject zero targets. + async fn set_mode_preserving_status( + &self, + mode: SmartShiftMode, + current: SmartShiftStatus, + ) -> Result<(), WriteError> { + match self { + Self::Enhanced(feature) => feature + .set_ratchet_control_mode(SmartShiftEnhancedStatusChange { + wheel_mode: Some(smartshift_to_wheel(mode)), + auto_disengage: NonZeroU8::new(current.auto_disengage), + tunable_torque: NonZeroU8::new(current.tunable_torque), + }) + .await + .map(|_| ()) + .map_err(|e| { + classify_hidpp_error( + e, + HidppOperation::WriteSmartShift, + SmartShiftEnhancedFeature::ID, + ) + }), + Self::Legacy(_) => self.set_status(SmartShiftStatus { mode, ..current }).await, + } + } + /// Write a new auto-disengage `sensitivity`, preserving the current mode /// (and, on Enhanced, the tunable torque). Reads the current status first /// so every preserved field is written back explicitly. The [`NonZeroU8`] @@ -269,12 +300,7 @@ pub(super) async fn toggle_smartshift_on_channel( let smartshift = SmartShift::open(&mut device).await?; let status = smartshift.status().await?; let next = status.mode.flipped(); - smartshift - .set_status(SmartShiftStatus { - mode: next, - ..status - }) - .await?; + smartshift.set_mode_preserving_status(next, status).await?; debug!(index, ?next, "wrote SmartShift mode"); Ok(next) } From 562fea97bc2e0a2542e3c69c7e7131009554a086 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sat, 27 Jun 2026 21:36:02 +0800 Subject: [PATCH 21/21] fix(hidpp): correct multi-host fn inversion arg order --- .../src/feature/fn_inversion/mod.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/openlogi-hidpp/src/feature/fn_inversion/mod.rs b/crates/openlogi-hidpp/src/feature/fn_inversion/mod.rs index 64bb6656..b432e811 100644 --- a/crates/openlogi-hidpp/src/feature/fn_inversion/mod.rs +++ b/crates/openlogi-hidpp/src/feature/fn_inversion/mod.rs @@ -97,13 +97,17 @@ impl FnInversionMultiHostFeature { ) -> Result { let payload = self .endpoint - .call(1, [u8::from(state), u8::from(host), 0]) + .call(1, set_multi_host_fn_inversion_args(host, state)) .await? .extend_payload(); FnInversionInfo::from_payload(payload) } } +fn set_multi_host_fn_inversion_args(host: HostIndex, state: FnInversionState) -> [u8; 3] { + [u8::from(host), u8::from(state), 0] +} + impl FnInversionInfo { fn from_payload(payload: [u8; 16]) -> Result { Ok(Self { @@ -186,7 +190,9 @@ impl FnInversionWithDefaultStateFeature { #[cfg(test)] mod tests { - use super::{FnInversionInfo, FnInversionState, GlobalFnInversion}; + use super::{ + FnInversionInfo, FnInversionState, GlobalFnInversion, set_multi_host_fn_inversion_args, + }; use crate::feature::hosts_info::HostIndex; #[test] @@ -215,4 +221,12 @@ mod tests { assert_eq!(global.state, FnInversionState::On); assert_eq!(global.default_state, FnInversionState::Off); } + + #[test] + fn encodes_multi_host_set_args_as_host_then_state() { + assert_eq!( + set_multi_host_fn_inversion_args(HostIndex::Slot(2), FnInversionState::On), + [2, 1, 0] + ); + } }