openlogi-hidpp 0.8.16

OpenLogi's hard fork of the `hidpp` crate (Logitech HID++ protocol).
Documentation
use num_enum::{IntoPrimitive, TryFromPrimitive};

use crate::{feature::DecodeEvent, nibble::U4, protocol::v20};

use super::{CidReportingChangeEcho, ControlId};

/// `setCidReporting`, the function whose response [`ReprogControlsEvent::ReportingChangedElsewhere`]
/// carries.
const SET_CID_REPORTING: u8 = 3;

fn i16_from_be_payload(bytes: &[u8]) -> i16 {
    i16::from_be_bytes([bytes[0], bytes[1]])
}

/// One analytics key event entry.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct AnalyticsKeyEvent {
    /// Control ID associated with the analytics event.
    pub cid: ControlId,
    /// Device-defined analytics event code.
    pub event: u8,
}

impl AnalyticsKeyEvent {
    fn from_payload(bytes: &[u8]) -> Self {
        Self {
            cid: ControlId::from_payload(&bytes[0..=1]),
            event: bytes[2],
        }
    }
}

/// Raw wheel movement resolution.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[repr(u8)]
pub enum RawWheelResolution {
    /// Low-resolution wheel movement.
    Low = 0,
    /// High-resolution wheel movement.
    High = 1,
}

/// Event emitted by `0x1b04`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum ReprogControlsEvent {
    /// Up to four currently pressed diverted controls.
    DivertedButtons([ControlId; 4]),
    /// Raw pointer movement reported while a diverted control is held.
    DivertedRawMouseXy {
        /// Horizontal delta.
        dx: i16,
        /// Vertical delta.
        dy: i16,
    },
    /// Batch of analytics key event entries.
    AnalyticsKeyEvents([AnalyticsKeyEvent; 5]),
    /// Raw wheel movement reported while a diverted control is held.
    DivertedRawWheel {
        /// Wheel movement resolution.
        resolution: RawWheelResolution,
        /// Number of wheel periods encoded in the event header.
        periods: U4,
        /// Vertical wheel delta.
        delta_vertical: i16,
    },
    /// Another client changed how one control reports: the device's response
    /// to a `setCidReporting` (function `3`) request that this channel did
    /// not send. Another application (Logi Options+, for example) or another
    /// OpenLogi channel to the same HID node sent it.
    ///
    /// The echo carries only the settings that request changed; a `None`
    /// field kept its value. The feature defines no notification for such a
    /// change, so this response, which reaches every open handle of the node,
    /// is the only report of it. Logi Options+ 2.8 handles the same
    /// response as a change to the control's diversion (reverse-engineered:
    /// static analysis of its `0x1b04` unexpected-response handling).
    ///
    /// Only [`super::ReprogControlsFeature`]'s event stream publishes this
    /// variant: it needs the channel's own software ids, which
    /// [`decode_event`] does not have.
    ReportingChangedElsewhere(CidReportingChangeEcho),
}

impl ReprogControlsEvent {
    /// Whether `cid` is currently reported as pressed in a diverted-buttons event.
    #[must_use]
    pub fn is_pressed(self, cid: ControlId) -> bool {
        matches!(self, Self::DivertedButtons(cids) if cids.contains(&cid))
    }
}

/// Decode an unsolicited `0x1b04` HID++ message.
///
/// Only broadcasts (software id `0`) decode here. A response never does, so
/// this never returns [`ReprogControlsEvent::ReportingChangedElsewhere`].
#[must_use]
pub fn decode_event(
    msg: &v20::Message,
    device_index: u8,
    feature_index: u8,
) -> Option<ReprogControlsEvent> {
    let header = msg.header();
    if header.device_index != device_index
        || header.feature_index != feature_index
        || header.software_id.to_lo() != 0
    {
        return None;
    }
    decode_event_payload(header.function_id.to_lo(), &msg.extend_payload())
}

pub(super) fn decode_event_payload(
    function_id: u8,
    payload: &[u8; 16],
) -> Option<ReprogControlsEvent> {
    match function_id {
        0 => Some(ReprogControlsEvent::DivertedButtons([
            ControlId::from_payload(&payload[0..=1]),
            ControlId::from_payload(&payload[2..=3]),
            ControlId::from_payload(&payload[4..=5]),
            ControlId::from_payload(&payload[6..=7]),
        ])),
        1 => Some(ReprogControlsEvent::DivertedRawMouseXy {
            dx: i16_from_be_payload(&payload[0..=1]),
            dy: i16_from_be_payload(&payload[2..=3]),
        }),
        2 => Some(ReprogControlsEvent::AnalyticsKeyEvents([
            AnalyticsKeyEvent::from_payload(&payload[0..=2]),
            AnalyticsKeyEvent::from_payload(&payload[3..=5]),
            AnalyticsKeyEvent::from_payload(&payload[6..=8]),
            AnalyticsKeyEvent::from_payload(&payload[9..=11]),
            AnalyticsKeyEvent::from_payload(&payload[12..=14]),
        ])),
        4 => Some(ReprogControlsEvent::DivertedRawWheel {
            resolution: RawWheelResolution::try_from((payload[0] >> 4) & 1).ok()?,
            periods: U4::from_lo(payload[0]),
            delta_vertical: i16_from_be_payload(&payload[1..=2]),
        }),
        _ => None,
    }
}

impl DecodeEvent for ReprogControlsEvent {
    fn decode(sub_id: u8, payload: &[u8; 16]) -> Option<Self> {
        decode_event_payload(sub_id, payload)
    }

    /// Decodes another client's `setCidReporting` response. Every other
    /// response is dropped, and so is a short one: the echo is a long report,
    /// and a short one would lack its remap and second flags byte.
    fn decode_foreign_response(response: &v20::Message) -> Option<Self> {
        match response {
            v20::Message::Long(header, payload)
                if header.function_id.to_lo() == SET_CID_REPORTING =>
            {
                Some(Self::ReportingChangedElsewhere(
                    CidReportingChangeEcho::from_payload(*payload),
                ))
            }
            _ => None,
        }
    }
}