Skip to main content

hidpp/feature/
reprog_controls.rs

1//! Implements `SpecialKeysMseButtons` / `ReprogControlsV4` (feature `0x1b04`).
2//!
3//! Logitech's v6 document names this feature `SpecialKeysMseButtons`: it
4//! enumerates physical and virtual controls, lets host software divert or remap
5//! them, and emits notifications for diverted buttons, raw XY, analytics key
6//! events, and raw wheel movement.
7
8use openlogi_hidpp_derive::Feature;
9
10use crate::{
11    feature::{EventSource, FeatureEndpoint},
12    protocol::v20::Hidpp20Error,
13};
14
15pub mod control_ids;
16mod event;
17pub mod task_ids;
18
19pub use event::{AnalyticsKeyEvent, RawWheelResolution, ReprogControlsEvent, decode_event};
20
21/// Implements the `SpecialKeysMseButtons` / `0x1b04` feature.
22#[derive(Feature)]
23#[creatable(id = 0x1b04, version = 0)]
24pub struct ReprogControlsFeature {
25    endpoint: FeatureEndpoint,
26    events: EventSource<ReprogControlsEvent>,
27}
28
29impl ReprogControlsFeature {
30    /// Returns the number of rows in the control ID table.
31    pub async fn get_count(&self) -> Result<u8, Hidpp20Error> {
32        Ok(self.endpoint.call(0, [0; 3]).await?.extend_payload()[0])
33    }
34
35    /// Returns one row from the control ID table.
36    pub async fn get_cid_info(&self, index: u8) -> Result<CidInfo, Hidpp20Error> {
37        let mut params = [0u8; 16];
38        params[0] = index;
39        let payload = self.endpoint.call_long(1, params).await?.extend_payload();
40        Ok(CidInfo::from_payload(payload))
41    }
42
43    /// Returns the current reporting/remapping state for `cid`.
44    pub async fn get_cid_reporting(&self, cid: ControlId) -> Result<CidReporting, Hidpp20Error> {
45        let [cid_hi, cid_lo] = cid.0.to_be_bytes();
46        let payload = self
47            .endpoint
48            .call(2, [cid_hi, cid_lo, 0])
49            .await?
50            .extend_payload();
51        Ok(CidReporting::from_payload(payload))
52    }
53
54    /// Applies reporting/remapping changes for `cid`.
55    ///
56    /// Optional boolean fields in [`CidReportingChange`] map to the corresponding
57    /// `*-valid` bit in Logitech's packet. Fields set to `None` are left
58    /// unchanged by the device. Remapping is carried as a value field rather
59    /// than a valid/value pair; `None` sends the documented `0` value.
60    pub async fn set_cid_reporting(
61        &self,
62        cid: ControlId,
63        change: CidReportingChange,
64    ) -> Result<CidReportingChangeEcho, Hidpp20Error> {
65        let payload = self
66            .endpoint
67            .call_long(3, change.to_payload(cid))
68            .await?
69            .extend_payload();
70        Ok(CidReportingChangeEcho::from_payload(payload))
71    }
72
73    /// Returns feature-level capabilities.
74    ///
75    /// This function exists on v6 devices. Older firmware may return
76    /// `InvalidFunctionId`.
77    pub async fn get_capabilities(&self) -> Result<ReprogControlsCapabilities, Hidpp20Error> {
78        let payload = self.endpoint.call(4, [0; 3]).await?.extend_payload();
79        Ok(ReprogControlsCapabilities {
80            reset_all_cid_report_settings: payload[0] & 1 != 0,
81        })
82    }
83
84    /// Resets all diverted or remapped control settings.
85    ///
86    /// This function exists on v6 devices that report
87    /// [`ReprogControlsCapabilities::reset_all_cid_report_settings`].
88    pub async fn reset_all_cid_report_settings(&self) -> Result<(), Hidpp20Error> {
89        self.endpoint.call(5, [0; 3]).await?;
90        Ok(())
91    }
92}
93
94/// A HID++ control ID.
95#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
96#[cfg_attr(feature = "serde", derive(serde::Serialize))]
97pub struct ControlId(pub u16);
98
99impl ControlId {
100    fn from_payload(bytes: &[u8]) -> Self {
101        Self(u16_from_be_payload(bytes))
102    }
103}
104
105impl From<u16> for ControlId {
106    fn from(value: u16) -> Self {
107        Self(value)
108    }
109}
110
111impl From<ControlId> for u16 {
112    fn from(value: ControlId) -> Self {
113        value.0
114    }
115}
116
117fn u16_from_be_payload(bytes: &[u8]) -> u16 {
118    u16::from_be_bytes([bytes[0], bytes[1]])
119}
120
121/// A HID++ task ID.
122#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
123#[cfg_attr(feature = "serde", derive(serde::Serialize))]
124pub struct TaskId(pub u16);
125
126impl From<u16> for TaskId {
127    fn from(value: u16) -> Self {
128        Self(value)
129    }
130}
131
132impl From<TaskId> for u16 {
133    fn from(value: TaskId) -> Self {
134        value.0
135    }
136}
137
138/// One `getCidInfo` row.
139#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
140#[cfg_attr(feature = "serde", derive(serde::Serialize))]
141pub struct CidInfo {
142    /// Control ID.
143    pub cid: ControlId,
144    /// Default task ID currently associated with the control.
145    pub task_id: TaskId,
146    /// Capability and classification flags.
147    pub flags: CidFlags,
148    /// Physical position value reported by the device.
149    pub position: u8,
150    /// Control group number.
151    pub group: u8,
152    /// Bit mask of groups this control belongs to.
153    pub group_mask: GroupMask,
154}
155
156impl CidInfo {
157    fn from_payload(payload: [u8; 16]) -> Self {
158        Self {
159            cid: ControlId::from_payload(&payload[0..=1]),
160            task_id: TaskId(u16_from_be_payload(&payload[2..=3])),
161            flags: CidFlags::from_bytes(payload[4], payload[8]),
162            position: payload[5],
163            group: payload[6],
164            group_mask: GroupMask(payload[7]),
165        }
166    }
167}
168
169bitflags::bitflags! {
170    /// Capability and classification flags for one control ID.
171    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
172    #[cfg_attr(feature = "serde", derive(serde::Serialize))]
173    pub struct CidFlags: u16 {
174        /// Control belongs to a mouse/pointer device.
175        const MOUSE = 1 << 0;
176        /// Control is a keyboard function key.
177        const FUNCTION_KEY = 1 << 1;
178        /// Control is a hotkey.
179        const HOTKEY = 1 << 2;
180        /// Control toggles Fn behavior.
181        const FN_TOGGLE = 1 << 3;
182        /// Control can be reprogrammed.
183        const REPROGRAMMABLE = 1 << 4;
184        /// Control can be temporarily diverted to HID++ events.
185        const DIVERTABLE = 1 << 5;
186        /// Control can be persistently diverted.
187        const PERSISTENTLY_DIVERTABLE = 1 << 6;
188        /// Control is virtual rather than a physical input.
189        const VIRTUAL_CONTROL = 1 << 7;
190        /// Control supports raw XY reporting.
191        const RAW_XY = 1 << 8;
192        /// Control supports force raw XY reporting.
193        const FORCE_RAW_XY = 1 << 9;
194        /// Control supports analytics key events.
195        const ANALYTICS_KEY_EVENTS = 1 << 10;
196        /// Control supports raw wheel events.
197        const RAW_WHEEL = 1 << 11;
198    }
199}
200
201impl CidFlags {
202    fn from_bytes(primary: u8, additional: u8) -> Self {
203        Self::from_bits_retain(u16::from(primary) | (u16::from(additional) << 8))
204    }
205
206    /// Raw `flags` value used by older OpenLogi diagnostics: primary flags in
207    /// the low byte, additional flags in the high byte.
208    #[must_use]
209    pub fn raw(self) -> u16 {
210        self.bits()
211    }
212
213    /// Whether this is a mouse control.
214    #[must_use]
215    pub fn is_mouse(self) -> bool {
216        self.contains(Self::MOUSE)
217    }
218
219    /// Whether this control can be temporarily diverted to HID++ events.
220    #[must_use]
221    pub fn is_divertable(self) -> bool {
222        self.contains(Self::DIVERTABLE)
223    }
224
225    /// Whether this control can be persistently diverted.
226    #[must_use]
227    pub fn is_persistently_divertable(self) -> bool {
228        self.contains(Self::PERSISTENTLY_DIVERTABLE)
229    }
230
231    /// Whether this is a virtual control.
232    #[must_use]
233    pub fn is_virtual_control(self) -> bool {
234        self.contains(Self::VIRTUAL_CONTROL)
235    }
236
237    /// Whether this control can report raw XY movement while held.
238    #[must_use]
239    pub fn supports_raw_xy(self) -> bool {
240        self.contains(Self::RAW_XY)
241    }
242
243    /// Whether this control can report force raw XY movement while held.
244    #[must_use]
245    pub fn supports_force_raw_xy(self) -> bool {
246        self.contains(Self::FORCE_RAW_XY)
247    }
248
249    /// Whether this control can report analytics key events.
250    #[must_use]
251    pub fn supports_analytics_key_events(self) -> bool {
252        self.contains(Self::ANALYTICS_KEY_EVENTS)
253    }
254
255    /// Whether this control can report raw wheel events.
256    #[must_use]
257    pub fn supports_raw_wheel(self) -> bool {
258        self.contains(Self::RAW_WHEEL)
259    }
260}
261
262/// Group mask `g1..g8` from `getCidInfo`.
263#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
264#[cfg_attr(feature = "serde", derive(serde::Serialize))]
265pub struct GroupMask(pub u8);
266
267/// Current reporting/remapping state returned by `getCidReporting`.
268#[expect(
269    clippy::struct_excessive_bools,
270    reason = "each field is an independent wire flag from getCidReporting, not a state machine"
271)]
272#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
273#[cfg_attr(feature = "serde", derive(serde::Serialize))]
274pub struct CidReporting {
275    /// Control ID whose reporting state was read.
276    pub cid: ControlId,
277    /// Whether temporary diversion is enabled.
278    pub diverted: bool,
279    /// Whether persistent diversion is enabled.
280    pub persistently_diverted: bool,
281    /// Whether force raw XY reporting is enabled.
282    pub force_raw_xy: bool,
283    /// Whether raw XY reporting is enabled.
284    pub raw_xy: bool,
285    /// Optional remapping target control ID.
286    pub remap: Option<ControlId>,
287    /// Whether analytics key events are enabled.
288    pub analytics_key_events: bool,
289    /// Whether raw wheel reporting is enabled.
290    pub raw_wheel: bool,
291}
292
293impl CidReporting {
294    fn from_payload(payload: [u8; 16]) -> Self {
295        let remap = ControlId::from_payload(&payload[3..=4]);
296        Self {
297            cid: ControlId::from_payload(&payload[0..=1]),
298            diverted: payload[2] & (1 << 0) != 0,
299            persistently_diverted: payload[2] & (1 << 2) != 0,
300            raw_xy: payload[2] & (1 << 4) != 0,
301            force_raw_xy: payload[2] & (1 << 6) != 0,
302            remap: (remap.0 != 0).then_some(remap),
303            analytics_key_events: payload[5] & (1 << 0) != 0,
304            raw_wheel: payload[5] & (1 << 2) != 0,
305        }
306    }
307}
308
309/// Changes for `setCidReporting`.
310///
311/// For boolean fields, `None` means "leave unchanged". Remapping is encoded as
312/// the packet's value field and defaults to the documented `0` value.
313#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
314#[cfg_attr(feature = "serde", derive(serde::Serialize))]
315pub struct CidReportingChange {
316    /// New temporary diversion state, or `None` to leave unchanged.
317    pub diverted: Option<bool>,
318    /// New persistent diversion state, or `None` to leave unchanged.
319    pub persistently_diverted: Option<bool>,
320    /// New force raw XY state, or `None` to leave unchanged.
321    pub force_raw_xy: Option<bool>,
322    /// New raw XY state, or `None` to leave unchanged.
323    pub raw_xy: Option<bool>,
324    /// Remaps to another control ID. `None` sends the documented `0` value,
325    /// which represents no persistent remapping.
326    pub remap: Option<ControlId>,
327    /// New analytics key event state, or `None` to leave unchanged.
328    pub analytics_key_events: Option<bool>,
329    /// New raw wheel state, or `None` to leave unchanged.
330    pub raw_wheel: Option<bool>,
331}
332
333impl CidReportingChange {
334    /// Change only the temporary diverted/raw-XY bits.
335    #[must_use]
336    pub fn temporary_diversion(diverted: bool, raw_xy: bool) -> Self {
337        Self {
338            diverted: Some(diverted),
339            raw_xy: Some(raw_xy),
340            ..Self::default()
341        }
342    }
343
344    fn to_payload(self, cid: ControlId) -> [u8; 16] {
345        let mut payload = [0u8; 16];
346        let [cid_hi, cid_lo] = cid.0.to_be_bytes();
347        payload[0] = cid_hi;
348        payload[1] = cid_lo;
349
350        if let Some(value) = self.diverted {
351            payload[2] |= 1 << 1;
352            payload[2] |= u8::from(value);
353        }
354        if let Some(value) = self.persistently_diverted {
355            payload[2] |= 1 << 3;
356            payload[2] |= u8::from(value) << 2;
357        }
358        if let Some(value) = self.raw_xy {
359            payload[2] |= 1 << 5;
360            payload[2] |= u8::from(value) << 4;
361        }
362        if let Some(value) = self.force_raw_xy {
363            payload[2] |= 1 << 7;
364            payload[2] |= u8::from(value) << 6;
365        }
366        if let Some(remap) = self.remap {
367            let [remap_hi, remap_lo] = remap.0.to_be_bytes();
368            payload[3] = remap_hi;
369            payload[4] = remap_lo;
370        }
371        if let Some(value) = self.analytics_key_events {
372            payload[5] |= 1 << 1;
373            payload[5] |= u8::from(value);
374        }
375        if let Some(value) = self.raw_wheel {
376            payload[5] |= 1 << 3;
377            payload[5] |= u8::from(value) << 2;
378        }
379
380        payload
381    }
382}
383
384/// Echo returned by `setCidReporting`.
385#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
386#[cfg_attr(feature = "serde", derive(serde::Serialize))]
387pub struct CidReportingChangeEcho {
388    /// Control ID whose reporting state was changed.
389    pub cid: ControlId,
390    /// Echoed temporary diversion state when changed.
391    pub diverted: Option<bool>,
392    /// Echoed persistent diversion state when changed.
393    pub persistently_diverted: Option<bool>,
394    /// Echoed force raw XY state when changed.
395    pub force_raw_xy: Option<bool>,
396    /// Echoed raw XY state when changed.
397    pub raw_xy: Option<bool>,
398    /// Echoed remapping target when present.
399    pub remap: Option<ControlId>,
400    /// Echoed analytics key event state when changed.
401    pub analytics_key_events: Option<bool>,
402    /// Echoed raw wheel state when changed.
403    pub raw_wheel: Option<bool>,
404}
405
406impl CidReportingChangeEcho {
407    fn from_payload(payload: [u8; 16]) -> Self {
408        let remap = ControlId::from_payload(&payload[3..=4]);
409        Self {
410            cid: ControlId::from_payload(&payload[0..=1]),
411            diverted: (payload[2] & (1 << 1) != 0).then_some(payload[2] & (1 << 0) != 0),
412            persistently_diverted: (payload[2] & (1 << 3) != 0)
413                .then_some(payload[2] & (1 << 2) != 0),
414            raw_xy: (payload[2] & (1 << 5) != 0).then_some(payload[2] & (1 << 4) != 0),
415            force_raw_xy: (payload[2] & (1 << 7) != 0).then_some(payload[2] & (1 << 6) != 0),
416            remap: (remap.0 != 0).then_some(remap),
417            analytics_key_events: (payload[5] & (1 << 1) != 0)
418                .then_some(payload[5] & (1 << 0) != 0),
419            raw_wheel: (payload[5] & (1 << 3) != 0).then_some(payload[5] & (1 << 2) != 0),
420        }
421    }
422}
423
424/// Feature-level capabilities returned by `getCapabilities` on v6 devices.
425#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
426#[cfg_attr(feature = "serde", derive(serde::Serialize))]
427pub struct ReprogControlsCapabilities {
428    /// Whether `resetAllCidReportSettings` is supported.
429    pub reset_all_cid_report_settings: bool,
430}
431
432#[cfg(test)]
433mod tests;