use std::hash::Hash;
use num_enum::{IntoPrimitive, TryFromPrimitive};
use openlogi_hidpp_derive::Feature;
use crate::{
feature::{DecodeEvent, EventSource, FeatureEndpoint},
nibble::U4,
protocol::v20::Hidpp20Error,
};
#[derive(Feature)]
#[creatable(id = 0x2121, version = 0)]
pub struct HiResWheelFeature {
endpoint: FeatureEndpoint,
events: EventSource<HiResWheelEvent>,
}
impl DecodeEvent for HiResWheelEvent {
fn decode(sub_id: u8, payload: &[u8; 16]) -> Option<Self> {
match sub_id {
0 => {
let resolution = WheelResolution::try_from((payload[0] & (1 << 4)) >> 4).ok()?;
Some(HiResWheelEvent::WheelMovement(WheelMovementData {
resolution,
periods: U4::from_lo(payload[0]),
delta_vertical: i16::from_be_bytes([payload[1], payload[2]]),
delta_horizontal: i16::from_be_bytes([payload[3], payload[4]]),
}))
}
1 => {
let state = WheelRatchetState::try_from(payload[0] & 1).ok()?;
Some(HiResWheelEvent::RatchetSwitch(state))
}
_ => None,
}
}
}
impl HiResWheelFeature {
pub async fn get_wheel_capabilities(&self) -> Result<WheelCapabilities, Hidpp20Error> {
let payload = self.endpoint.call(0, [0; 3]).await?.extend_payload();
Ok(WheelCapabilities::from_payload(&payload))
}
pub async fn get_wheel_mode(&self) -> Result<WheelMode, Hidpp20Error> {
let payload = self.endpoint.call(1, [0; 3]).await?.extend_payload();
WheelMode::from_payload(&payload)
}
pub async fn set_wheel_mode(&self, mode: WheelMode) -> Result<WheelMode, Hidpp20Error> {
let payload = self
.endpoint
.call(2, mode.to_request())
.await?
.extend_payload();
WheelMode::from_payload(&payload)
}
pub async fn get_ratchet_switch_state(&self) -> Result<WheelRatchetState, Hidpp20Error> {
let payload = self.endpoint.call(3, [0; 3]).await?.extend_payload();
WheelRatchetState::try_from(payload[0] & 1).map_err(|_| Hidpp20Error::UnsupportedResponse)
}
}
#[expect(
clippy::struct_excessive_bools,
reason = "each field is an independent capability flag from getWheelCapability, not a state machine"
)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub struct WheelCapabilities {
pub multiplier: u8,
pub has_invert: bool,
pub has_switch: bool,
pub has_priority: bool,
pub has_rate: bool,
pub has_analytics_data: bool,
pub ratches_per_rotation: u8,
pub wheel_diameter: u8,
}
impl WheelCapabilities {
fn from_payload(payload: &[u8; 16]) -> Self {
let flags = payload[1];
Self {
multiplier: payload[0],
has_invert: flags & (1 << 3) != 0,
has_switch: flags & (1 << 2) != 0,
has_priority: flags & (1 << 0) != 0,
has_rate: flags & (1 << 1) != 0,
has_analytics_data: flags & (1 << 4) != 0,
ratches_per_rotation: payload[2],
wheel_diameter: payload[3],
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub struct WheelMode {
pub inverted: bool,
pub resolution: WheelResolution,
pub target: WheelEventTarget,
extension: WheelModeExtension,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
struct WheelModeExtension {
flags: u8,
bytes: [u8; 2],
}
impl WheelMode {
const TARGET: u8 = 1;
const RESOLUTION: u8 = 1 << 1;
const INVERTED: u8 = 1 << 2;
const DOCUMENTED: u8 = Self::TARGET | Self::RESOLUTION | Self::INVERTED;
fn from_payload(payload: &[u8; 16]) -> Result<Self, Hidpp20Error> {
Ok(Self {
inverted: payload[0] & Self::INVERTED != 0,
resolution: WheelResolution::try_from((payload[0] & Self::RESOLUTION) >> 1)
.map_err(|_| Hidpp20Error::UnsupportedResponse)?,
target: WheelEventTarget::try_from(payload[0] & Self::TARGET)
.map_err(|_| Hidpp20Error::UnsupportedResponse)?,
extension: WheelModeExtension {
flags: payload[0] & !Self::DOCUMENTED,
bytes: [payload[1], payload[2]],
},
})
}
fn to_request(self) -> [u8; 3] {
let mut flags = self.extension.flags | u8::from(self.target);
flags |= u8::from(self.resolution) << 1;
if self.inverted {
flags |= Self::INVERTED;
}
[flags, self.extension.bytes[0], self.extension.bytes[1]]
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
#[repr(u8)]
pub enum WheelResolution {
Low = 0,
High = 1,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
#[repr(u8)]
pub enum WheelEventTarget {
Native = 0,
Diverted = 1,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
#[repr(u8)]
pub enum WheelRatchetState {
Freespin = 0,
Ratchet = 1,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub enum HiResWheelEvent {
WheelMovement(WheelMovementData),
RatchetSwitch(WheelRatchetState),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub struct WheelMovementData {
pub resolution: WheelResolution,
pub periods: U4,
pub delta_vertical: i16,
pub delta_horizontal: i16,
}
#[cfg(test)]
mod tests {
use super::{
HiResWheelEvent, WheelCapabilities, WheelEventTarget, WheelMode, WheelMovementData,
WheelResolution,
};
use crate::{feature::DecodeEvent, nibble::U4};
fn payload(mode: [u8; 3]) -> [u8; 16] {
let mut payload = [0; 16];
payload[..3].copy_from_slice(&mode);
payload
}
fn capabilities(flags: u8) -> WheelCapabilities {
let mut payload = [0; 16];
payload[..4].copy_from_slice(&[8, flags, 24, 26]);
WheelCapabilities::from_payload(&payload)
}
#[test]
fn decodes_every_capability_flag_to_its_own_field() {
let flags = |c: WheelCapabilities| {
[
c.has_priority,
c.has_rate,
c.has_switch,
c.has_invert,
c.has_analytics_data,
]
};
for bit in 0..5 {
let expected: [bool; 5] = std::array::from_fn(|i| i == bit);
assert_eq!(flags(capabilities(1 << bit)), expected, "flag bit {bit}");
}
assert_eq!(flags(capabilities(0x1f)), [true; 5]);
assert_eq!(flags(capabilities(0xe0)), [false; 5]);
let all = capabilities(0x1f);
assert_eq!(
(all.multiplier, all.ratches_per_rotation, all.wheel_diameter),
(8, 24, 26)
);
}
#[test]
fn movement_carries_both_deltas() {
let mut payload = [0; 16];
payload[0] = (1 << 4) | 3;
payload[1..3].copy_from_slice(&120i16.to_be_bytes());
payload[3..5].copy_from_slice(&(-2i16).to_be_bytes());
assert_eq!(
HiResWheelEvent::decode(0, &payload),
Some(HiResWheelEvent::WheelMovement(WheelMovementData {
resolution: WheelResolution::High,
periods: U4::from_lo(3),
delta_vertical: 120,
delta_horizontal: -2,
}))
);
payload[3..5].copy_from_slice(&300i16.to_be_bytes());
let Some(HiResWheelEvent::WheelMovement(movement)) = HiResWheelEvent::decode(0, &payload)
else {
panic!("sub-id 0 is a movement event");
};
assert_eq!(
(movement.delta_vertical, movement.delta_horizontal),
(120, 300)
);
}
#[test]
fn decodes_documented_bits_and_round_trips_extension_fields() {
let wire = [0b1000_1111, 0x05, 0x07];
let mode = WheelMode::from_payload(&payload(wire)).unwrap();
assert!(mode.inverted);
assert_eq!(mode.resolution, WheelResolution::High);
assert_eq!(mode.target, WheelEventTarget::Diverted);
assert_eq!(mode.to_request(), wire);
}
#[test]
fn editing_documented_fields_leaves_extension_fields_untouched() {
let mut mode = WheelMode::from_payload(&payload([0b0000_1000, 0x05, 0x07])).unwrap();
mode.inverted = true;
mode.resolution = WheelResolution::High;
mode.target = WheelEventTarget::Diverted;
assert_eq!(mode.to_request(), [0b0000_1111, 0x05, 0x07]);
mode.inverted = false;
mode.resolution = WheelResolution::Low;
mode.target = WheelEventTarget::Native;
assert_eq!(mode.to_request(), [0b0000_1000, 0x05, 0x07]);
}
#[test]
fn every_documented_mode_round_trips_with_a_zero_extension() {
for byte in 0..=0b111 {
let mode = WheelMode::from_payload(&payload([byte, 0, 0])).unwrap();
assert_eq!(mode.inverted, byte & 0b100 != 0);
assert_eq!(mode.resolution == WheelResolution::High, byte & 0b010 != 0);
assert_eq!(mode.target == WheelEventTarget::Diverted, byte & 0b001 != 0);
assert_eq!(mode.to_request(), [byte, 0, 0]);
}
}
}