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
27impl SupportedWaveforms {
28    /// Whether the device advertises `waveform`: bit *N* of the mask is
29    /// waveform ID *N*, which is how Logi Options+ 2.8 decodes it too.
30    #[must_use]
31    pub fn supports(self, waveform: HapticWaveform) -> bool {
32        1u32.checked_shl(u32::from(u8::from(waveform)))
33            .is_some_and(|bit| self.bits() & bit != 0)
34    }
35}
36
37bitflags::bitflags! {
38    /// The first byte of the haptic configuration.
39    ///
40    /// Unknown bits are retained: a write built from a read hands every bit
41    /// this crate does not name back to the firmware unchanged.
42    #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
43    #[cfg_attr(feature = "serde", derive(serde::Serialize))]
44    pub struct HapticConfigFlags: u8 {
45        /// Firmware haptic playback is enabled.
46        const ENABLED = 1 << 0;
47        /// Battery-saving haptics. Logi Options+ sets this bit from its own
48        /// power-save setting, independently of [`Self::ENABLED`].
49        const POWER_SAVE = 1 << 1;
50    }
51}
52
53/// A haptic waveform ID accepted by `playWaveform`.
54#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)]
55#[repr(u8)]
56pub enum HapticWaveform {
57    /// Confirmation pulse used when an action runs.
58    DampStateChange = 1,
59    /// Light boundary pulse used for hover transitions.
60    SubtleCollision = 4,
61}
62
63/// Valid device haptic intensity (`0..=100`).
64#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
65pub struct HapticIntensity(u8);
66
67impl HapticIntensity {
68    /// Highest accepted intensity percentage.
69    pub const MAX: u8 = 100;
70
71    /// Validate an intensity percentage.
72    #[must_use]
73    pub const fn new(value: u8) -> Option<Self> {
74        if value <= Self::MAX {
75            Some(Self(value))
76        } else {
77            None
78        }
79    }
80
81    /// Return the percentage sent on the wire.
82    #[must_use]
83    pub const fn get(self) -> u8 {
84        self.0
85    }
86}
87
88/// Device-wide haptic configuration.
89#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
90pub struct HapticConfiguration {
91    /// Enable, power-save, and any unnamed bits, as the device reported them.
92    pub flags: HapticConfigFlags,
93    /// Current intensity percentage.
94    pub intensity: HapticIntensity,
95    /// Number of discrete levels advertised by the firmware.
96    pub level_count: u8,
97    /// Percentage step between discrete levels.
98    pub level_step: u8,
99}
100
101impl HapticConfiguration {
102    fn from_payload(payload: &[u8]) -> Result<Self, Hidpp20Error> {
103        let [flags, intensity, levels, ..] = *payload else {
104            return Err(Hidpp20Error::UnsupportedResponse);
105        };
106        Ok(Self {
107            flags: HapticConfigFlags::from_bits_retain(flags),
108            intensity: HapticIntensity::new(intensity).ok_or(Hidpp20Error::UnsupportedResponse)?,
109            level_step: levels >> 4,
110            level_count: levels & 0x0f,
111        })
112    }
113}
114
115/// Haptic capabilities reported by the device.
116#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
117pub struct HapticCapabilities {
118    /// Bytes whose meaning has not yet been verified.
119    pub unknown_prefix: [u8; 4],
120    /// Supported waveform mask.
121    pub waveforms: SupportedWaveforms,
122}
123
124/// The haptic status the device reports through `getHapticInfo`
125/// (function 3).
126///
127/// Logi Options+ plays a waveform only while this status is zero, and names
128/// no other value, so neither does this type: the raw byte is kept so a
129/// refusal can say what the device reported.
130#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
131#[cfg_attr(feature = "serde", derive(serde::Serialize))]
132pub struct HapticStatus(u8);
133
134impl HapticStatus {
135    /// Wrap a raw status byte.
136    #[must_use]
137    pub const fn from_raw(raw: u8) -> Self {
138        Self(raw)
139    }
140
141    /// The raw status byte.
142    #[must_use]
143    pub const fn raw(self) -> u8 {
144        self.0
145    }
146
147    /// Whether the device can play a waveform: Logi Options+ refuses every
148    /// play while the status is non-zero.
149    #[must_use]
150    pub const fn is_ready(self) -> bool {
151        self.0 == 0
152    }
153}
154
155/// Implements `HapticFeedback` / `0x19b0`.
156#[derive(Clone, Feature)]
157#[creatable(id = 0x19b0, version = 0)]
158pub struct HapticFeedbackFeature {
159    endpoint: FeatureEndpoint,
160}
161
162impl HapticFeedbackFeature {
163    /// Read the device's supported waveform mask.
164    ///
165    /// A reply too short to hold the mask reads as advertising nothing:
166    /// Logi Options+ zero-pads it to eight bytes before decoding, too.
167    pub async fn get_capabilities(&self) -> Result<HapticCapabilities, Hidpp20Error> {
168        let payload = self.endpoint.call(0, [0; 3]).await?.extend_payload();
169        Ok(HapticCapabilities {
170            unknown_prefix: payload[0..4]
171                .try_into()
172                .map_err(|_| Hidpp20Error::UnsupportedResponse)?,
173            waveforms: SupportedWaveforms::from_bits_retain(u32::from_be_bytes(
174                payload[4..8]
175                    .try_into()
176                    .map_err(|_| Hidpp20Error::UnsupportedResponse)?,
177            )),
178        })
179    }
180
181    /// Read the haptic flags and current intensity.
182    pub async fn get_configuration(&self) -> Result<HapticConfiguration, Hidpp20Error> {
183        let payload = self.endpoint.call(1, [0; 3]).await?.extend_payload();
184        HapticConfiguration::from_payload(&payload)
185    }
186
187    /// Write the device-wide haptic flags and intensity.
188    ///
189    /// `flags` replaces the whole byte: edit the value from
190    /// [`Self::get_configuration`] so power-save and unnamed bits survive.
191    ///
192    /// Returns the configuration the device reports after the write. The
193    /// reply has the `getHapticConfig` layout and carries the device's
194    /// state, not an echo of the request: firmware may normalize the
195    /// intensity or refuse a flag. Logi Options+ adopts this reply as its
196    /// haptic state (reverse-engineered from its `setHapticConfig`, which
197    /// resolves the same `HapticConfigResponse` as the getter). Callers
198    /// check the fields they rely on.
199    pub async fn set_configuration(
200        &self,
201        flags: HapticConfigFlags,
202        intensity: HapticIntensity,
203    ) -> Result<HapticConfiguration, Hidpp20Error> {
204        let payload = self
205            .endpoint
206            .call(2, [flags.bits(), intensity.get(), 0])
207            .await?
208            .extend_payload();
209        HapticConfiguration::from_payload(&payload)
210    }
211
212    /// Read the haptic status (`getHapticInfo`, function 3).
213    ///
214    /// Reverse-engineered from Logi Options+ 2.8: its `getHapticInfo` sends
215    /// function 3 with an empty request and decodes the status from reply
216    /// byte 0, zero-padding an empty reply; its play path refuses while that
217    /// status is non-zero. The unsolicited haptic-info report it stores into
218    /// the same field carries the status in byte 0 too.
219    pub async fn get_haptic_status(&self) -> Result<HapticStatus, Hidpp20Error> {
220        let payload = self.endpoint.call(3, [0; 3]).await?.extend_payload();
221        Ok(HapticStatus(payload[0]))
222    }
223
224    /// Play one typed haptic waveform immediately.
225    pub async fn play(&self, waveform: HapticWaveform) -> Result<(), Hidpp20Error> {
226        self.endpoint.call(4, [waveform.into(), 0, 0]).await?;
227        Ok(())
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn intensity_rejects_values_above_one_hundred() {
237        assert_eq!(
238            HapticIntensity::new(100).map(HapticIntensity::get),
239            Some(100)
240        );
241        assert_eq!(HapticIntensity::new(101), None);
242    }
243
244    #[test]
245    fn waveform_mask_retains_unknown_bits() {
246        let mask = SupportedWaveforms::from_bits_retain((1 << 4) | (1 << 31));
247        assert!(mask.contains(SupportedWaveforms::SUBTLE_COLLISION));
248        assert_eq!(mask.bits(), (1 << 4) | (1 << 31));
249    }
250
251    #[test]
252    fn a_waveform_is_supported_exactly_when_its_id_bit_is_set() {
253        let only_collision = SupportedWaveforms::from_bits_retain(1 << 4);
254        assert!(only_collision.supports(HapticWaveform::SubtleCollision));
255        assert!(!only_collision.supports(HapticWaveform::DampStateChange));
256
257        let only_damp = SupportedWaveforms::from_bits_retain(1 << 1);
258        assert!(only_damp.supports(HapticWaveform::DampStateChange));
259        assert!(!only_damp.supports(HapticWaveform::SubtleCollision));
260
261        // Bits for waveforms this crate does not name grant nothing named.
262        let others = SupportedWaveforms::from_bits_retain(!((1 << 1) | (1 << 4)));
263        assert!(!others.supports(HapticWaveform::DampStateChange));
264        assert!(!others.supports(HapticWaveform::SubtleCollision));
265        assert!(!SupportedWaveforms::empty().supports(HapticWaveform::SubtleCollision));
266    }
267
268    #[test]
269    fn configuration_keeps_power_save_and_unnamed_flag_bits() {
270        // Power save without enable, as Options+ writes it, plus an unnamed bit.
271        let config = HapticConfiguration::from_payload(&[0x82, 60, 0x34]).unwrap();
272        assert!(!config.flags.contains(HapticConfigFlags::ENABLED));
273        assert!(config.flags.contains(HapticConfigFlags::POWER_SAVE));
274        assert_eq!(config.flags.bits(), 0x82);
275        assert_eq!(
276            (config.flags | HapticConfigFlags::ENABLED).bits(),
277            0x83,
278            "enabling must leave power save and the unnamed bit in place"
279        );
280        assert_eq!(config.intensity.get(), 60);
281        assert_eq!((config.level_step, config.level_count), (3, 4));
282    }
283
284    /// What `set_configuration` returns when the device answers the write
285    /// with `reply`.
286    fn set_with_reply(reply: [u8; 3]) -> Result<HapticConfiguration, Hidpp20Error> {
287        use std::sync::Arc;
288
289        use crate::channel::mock::{MockRawHidChannel, channel_with_reader};
290        use crate::feature::CreatableFeature;
291        use crate::nibble::U4;
292        use crate::protocol::v20::{Message, MessageHeader};
293
294        futures::executor::block_on(async {
295            let (raw, handle) = MockRawHidChannel::new();
296            let channel = Arc::new(channel_with_reader(raw).await);
297            let feature = HapticFeedbackFeature::new(channel, 3, 0x0b);
298            let intensity = HapticIntensity::new(63).unwrap();
299            let mut write = Box::pin(
300                feature.set_configuration(HapticConfigFlags::from_bits_retain(0x83), intensity),
301            );
302            assert!(futures::poll!(write.as_mut()).is_pending());
303            let header = MessageHeader {
304                device_index: 3,
305                feature_index: 0x0b,
306                function_id: U4::from_lo(2),
307                software_id: U4::from_lo(1),
308            };
309            handle
310                .send_incoming(Message::Short(header, reply).into())
311                .await;
312            let result = write.await;
313            let written = handle.written_reports();
314            let request = written.last().expect("setHapticConfig was sent");
315            assert_eq!(&request[4..7], &[0x83, 63, 0]);
316            result
317        })
318    }
319
320    #[test]
321    fn set_configuration_returns_the_state_the_device_reports() {
322        // The firmware kept the engine off and rounded the intensity.
323        let applied = set_with_reply([0x82, 60, 0x34]).unwrap();
324        assert!(!applied.flags.contains(HapticConfigFlags::ENABLED));
325        assert_eq!(applied.flags.bits(), 0x82);
326        assert_eq!(applied.intensity.get(), 60);
327        assert_eq!((applied.level_step, applied.level_count), (3, 4));
328        assert!(matches!(
329            set_with_reply([0x83, 101, 0]),
330            Err(Hidpp20Error::UnsupportedResponse)
331        ));
332    }
333
334    #[test]
335    fn configuration_rejects_out_of_range_intensity() {
336        assert!(matches!(
337            HapticConfiguration::from_payload(&[0x01, 101, 0]),
338            Err(Hidpp20Error::UnsupportedResponse)
339        ));
340    }
341
342    /// What `get_haptic_status` returns when the device answers with `reply`.
343    fn status_with_reply(reply: [u8; 3]) -> HapticStatus {
344        use std::sync::Arc;
345
346        use crate::channel::mock::{MockRawHidChannel, channel_with_reader};
347        use crate::feature::CreatableFeature;
348        use crate::nibble::U4;
349        use crate::protocol::v20::{Message, MessageHeader};
350
351        futures::executor::block_on(async {
352            let (raw, handle) = MockRawHidChannel::new();
353            let channel = Arc::new(channel_with_reader(raw).await);
354            let feature = HapticFeedbackFeature::new(channel, 3, 0x0b);
355            let mut read = Box::pin(feature.get_haptic_status());
356            assert!(futures::poll!(read.as_mut()).is_pending());
357            let header = MessageHeader {
358                device_index: 3,
359                feature_index: 0x0b,
360                function_id: U4::from_lo(3),
361                software_id: U4::from_lo(1),
362            };
363            handle
364                .send_incoming(Message::Short(header, reply).into())
365                .await;
366            let status = read.await.expect("the device answered getHapticInfo");
367            let written = handle.written_reports();
368            let request = written.last().expect("getHapticInfo was sent");
369            assert_eq!(request[3] >> 4, 3, "getHapticInfo is function 3");
370            assert_eq!(&request[4..7], &[0, 0, 0], "with an empty request");
371            status
372        })
373    }
374
375    #[test]
376    fn haptic_status_is_reply_byte_zero_and_only_zero_is_ready() {
377        let ready = status_with_reply([0, 0xff, 0xff]);
378        assert!(ready.is_ready(), "later bytes do not decide readiness");
379        assert_eq!(ready.raw(), 0);
380        for raw in [1, 2, 0x80, 0xff] {
381            let status = status_with_reply([raw, 0, 0]);
382            assert!(!status.is_ready(), "status {raw} refuses play");
383            assert_eq!(status.raw(), raw, "the raw byte is kept");
384        }
385    }
386}