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