Skip to main content

hidpp/feature/
haptic_feedback.rs

1//! Implements the reverse-engineered `HapticFeedback` feature (`0x19b0`).
2//!
3//! The function and payload layouts are cross-checked against Solaar and an MX
4//! Master 4. Logitech has not published this feature in the public HID++ spec,
5//! so additions must be verified against hardware rather than guessed.
6
7use num_enum::{IntoPrimitive, TryFromPrimitive};
8use openlogi_hidpp_derive::Feature;
9
10use crate::{feature::FeatureEndpoint, protocol::v20::Hidpp20Error};
11
12bitflags::bitflags! {
13    /// Waveforms the device reports as playable.
14    ///
15    /// Unknown bits are retained so newer firmware does not silently lose
16    /// capability information.
17    #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
18    #[cfg_attr(feature = "serde", derive(serde::Serialize))]
19    pub struct SupportedWaveforms: u32 {
20        /// A damp state-change pulse, used after activating a ring action.
21        const DAMP_STATE_CHANGE = 1 << 1;
22        /// A subtle collision pulse, used when the highlighted ring slot changes.
23        const SUBTLE_COLLISION = 1 << 4;
24    }
25}
26
27/// A haptic waveform ID accepted by `playWaveform`.
28#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)]
29#[repr(u8)]
30pub enum HapticWaveform {
31    /// Confirmation pulse used when an action runs.
32    DampStateChange = 1,
33    /// Light boundary pulse used for hover transitions.
34    SubtleCollision = 4,
35}
36
37/// Valid device haptic intensity (`0..=100`).
38#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
39pub struct HapticIntensity(u8);
40
41impl HapticIntensity {
42    /// Highest accepted intensity percentage.
43    pub const MAX: u8 = 100;
44
45    /// Validate an intensity percentage.
46    #[must_use]
47    pub const fn new(value: u8) -> Option<Self> {
48        if value <= Self::MAX {
49            Some(Self(value))
50        } else {
51            None
52        }
53    }
54
55    /// Return the percentage sent on the wire.
56    #[must_use]
57    pub const fn get(self) -> u8 {
58        self.0
59    }
60}
61
62/// Device-wide haptic configuration.
63#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
64pub struct HapticConfiguration {
65    /// Whether firmware haptic playback is enabled.
66    pub enabled: bool,
67    /// Current intensity percentage.
68    pub intensity: HapticIntensity,
69    /// Number of discrete levels advertised by the firmware.
70    pub level_count: u8,
71    /// Percentage step between discrete levels.
72    pub level_step: u8,
73}
74
75/// Haptic capabilities reported by the device.
76#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
77pub struct HapticCapabilities {
78    /// Bytes whose meaning has not yet been verified.
79    pub unknown_prefix: [u8; 4],
80    /// Supported waveform mask.
81    pub waveforms: SupportedWaveforms,
82}
83
84/// Implements `HapticFeedback` / `0x19b0`.
85#[derive(Clone, Feature)]
86#[creatable(id = 0x19b0, version = 0)]
87pub struct HapticFeedbackFeature {
88    endpoint: FeatureEndpoint,
89}
90
91impl HapticFeedbackFeature {
92    /// Read the device's supported waveform mask.
93    pub async fn get_capabilities(&self) -> Result<HapticCapabilities, Hidpp20Error> {
94        let payload = self.endpoint.call(0, [0; 3]).await?.extend_payload();
95        Ok(HapticCapabilities {
96            unknown_prefix: payload[0..4]
97                .try_into()
98                .map_err(|_| Hidpp20Error::UnsupportedResponse)?,
99            waveforms: SupportedWaveforms::from_bits_retain(u32::from_be_bytes(
100                payload[4..8]
101                    .try_into()
102                    .map_err(|_| Hidpp20Error::UnsupportedResponse)?,
103            )),
104        })
105    }
106
107    /// Read whether haptics are enabled and their current intensity.
108    pub async fn get_configuration(&self) -> Result<HapticConfiguration, Hidpp20Error> {
109        let payload = self.endpoint.call(1, [0; 3]).await?.extend_payload();
110        let enabled = match payload[0] {
111            0 => false,
112            1 => true,
113            _ => return Err(Hidpp20Error::UnsupportedResponse),
114        };
115        let Some(intensity) = HapticIntensity::new(payload[1]) else {
116            return Err(Hidpp20Error::UnsupportedResponse);
117        };
118        Ok(HapticConfiguration {
119            enabled,
120            intensity,
121            level_step: payload[2] >> 4,
122            level_count: payload[2] & 0x0f,
123        })
124    }
125
126    /// Write the device-wide haptic enabled state and intensity.
127    pub async fn set_configuration(
128        &self,
129        enabled: bool,
130        intensity: HapticIntensity,
131    ) -> Result<(), Hidpp20Error> {
132        self.endpoint
133            .call(2, [u8::from(enabled), intensity.get(), 0])
134            .await?;
135        Ok(())
136    }
137
138    /// Play one typed haptic waveform immediately.
139    pub async fn play(&self, waveform: HapticWaveform) -> Result<(), Hidpp20Error> {
140        self.endpoint.call(4, [waveform.into(), 0, 0]).await?;
141        Ok(())
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn intensity_rejects_values_above_one_hundred() {
151        assert_eq!(
152            HapticIntensity::new(100).map(HapticIntensity::get),
153            Some(100)
154        );
155        assert_eq!(HapticIntensity::new(101), None);
156    }
157
158    #[test]
159    fn waveform_mask_retains_unknown_bits() {
160        let mask = SupportedWaveforms::from_bits_retain((1 << 4) | (1 << 31));
161        assert!(mask.contains(SupportedWaveforms::SUBTLE_COLLISION));
162        assert_eq!(mask.bits(), (1 << 4) | (1 << 31));
163    }
164}