Skip to main content

openlogi_device/
reprog_controls.rs

1//! HID++ `ReprogControlsV4` (feature `0x1b04`) — temporary control diversion
2//! and raw-XY reporting, the mechanism behind MX-line reprogrammable controls.
3//!
4//! The full protocol wrapper lives in `openlogi-hidpp`; this module keeps the
5//! OpenLogi-facing compatibility API used by gesture/button orchestration:
6//! `getCount` / `getCtrlIdInfo` (locate a control and confirm it can divert raw
7//! XY) and `setCidReporting` (turn diversion on or off). While a control is
8//! diverted with raw-XY reporting, the device emits two unsolicited events,
9//! decoded by [`decode_event`]:
10//!
11//! - function `0` `divertedButtonsEvent` — up to four currently-pressed CIDs.
12//! - function `1` `rawXYEvent` — signed `dx`/`dy` while a raw-XY control is held.
13//!
14//! Wire formats cross-checked against Solaar's `hidpp20.py` and
15//! `notifications.py`.
16
17use std::sync::Arc;
18
19use hidpp::{
20    channel::HidppChannel,
21    feature::{CreatableFeature, reprog_controls as hidpp_reprog},
22    protocol::v20::Hidpp20Error,
23};
24
25mod event;
26
27pub use event::{RawControlEvent, decode_event};
28pub use hidpp_reprog::{
29    AnalyticsKeyEvent, CidFlags, CidInfo, CidReporting, CidReportingChange, CidReportingChangeEcho,
30    ControlId, GroupMask, RawWheelResolution, ReprogControlsCapabilities, ReprogControlsEvent,
31    TaskId, decode_event as decode_full_event,
32};
33pub use hidpp_reprog::{control_ids, task_ids};
34
35/// `ReprogControlsV4` HID++ feature ID.
36pub const FEATURE_ID: u16 = 0x1b04;
37
38/// Control ID of the MX-line dedicated gesture button (`Mouse_Gesture_Button`,
39/// Logitech "App_Switch_Gesture").
40///
41/// MX Master 4 also has a separate Haptic Sense Panel in the thumb area; that
42/// panel is [`HAPTIC_PANEL_CID`], not this CID.
43pub const GESTURE_BUTTON_CID: u16 = 0x00c3;
44
45/// Control ID of the MX Master 4 Haptic Sense Panel — the touch-sensitive
46/// thumb rest that replaces the dedicated gesture button on that model.
47///
48/// Reverse-engineered on real hardware (MX Master 4, Bolt receiver); not in
49/// the published `0x1b04` control-ID lists. Press/release arrive as
50/// `divertedButtonsEvent` with this CID, and while touched the panel streams
51/// relative raw-XY at ~125 Hz exactly like the dedicated gesture button —
52/// except that the first raw-XY sample after contact is a large position jump
53/// that must be discarded before feeding a swipe accumulator.
54///
55/// The typed source of truth is [`control_ids::HAPTIC_PANEL`].
56pub const HAPTIC_PANEL_CID: u16 = control_ids::HAPTIC_PANEL.0;
57
58/// Control IDs of the "DPI / ModeShift" button family. Whichever a device
59/// exposes (and can divert) is captured and mapped to
60/// [`ButtonId::DpiToggle`](openlogi_core::binding::ButtonId::DpiToggle): the MX
61/// wheel-mode "Smart Shift" button, plus the dedicated "DPI Change" / "DPI
62/// Switch" buttons on other models. Values from the `0x1b04` control-ID list,
63/// cross-checked against Solaar `special_keys.py`.
64pub const DPI_MODE_SHIFT_CIDS: [u16; 3] = [0x00c4, 0x00ed, 0x00fd];
65
66/// Control IDs of the Back button family. MX Vertical and similar devices
67/// report Back via HID++ `0x1b04` rather than a standard OS mouse button,
68/// so macOS never translates them into `OtherMouseDown` events. Whichever a
69/// device exposes (and can divert) is captured and mapped to
70/// [`ButtonId::Back`](openlogi_core::binding::ButtonId::Back).
71///
72/// Known CIDs (from the `0x1b04` control-ID list / Solaar `special_keys.py`):
73/// - `0x0053` — Back (classic mouse CID, used by MX Vertical)
74/// - `0x00BD` — MultiPlatform Back
75/// - `0x00CE` — Multiplatform Back (alternate)
76/// - `0x00DB` — Back (generic)
77pub const BACK_CIDS: [u16; 4] = [0x0053, 0x00BD, 0x00CE, 0x00DB];
78
79/// Control IDs of the Forward button family. Counterpart to [`BACK_CIDS`]:
80/// captured and mapped to
81/// [`ButtonId::Forward`](openlogi_core::binding::ButtonId::Forward).
82///
83/// Known CIDs:
84/// - `0x0056` — Forward (classic mouse CID, used by MX Vertical)
85/// - `0x00CF` — Multiplatform Forward
86pub const FORWARD_CIDS: [u16; 2] = [0x0056, 0x00CF];
87
88/// Identity and capabilities of one reprogrammable control, as returned by
89/// `getCtrlIdInfo`.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub struct CtrlIdInfo {
92    /// Control ID — stable across firmware (e.g. [`GESTURE_BUTTON_CID`]).
93    pub cid: u16,
94    /// Task ID — the control's default on-device action.
95    pub task_id: u16,
96    /// `KeyFlag` capability bitfield (response bytes 4 and 8 combined).
97    pub flags: u16,
98}
99
100impl CtrlIdInfo {
101    /// Typed view of the legacy raw [`Self::flags`] field.
102    #[must_use]
103    pub fn typed_flags(self) -> CidFlags {
104        CidFlags::from_bits_retain(self.flags)
105    }
106
107    /// Whether the control can be temporarily diverted to HID++ events.
108    #[must_use]
109    pub fn is_divertable(self) -> bool {
110        self.typed_flags().is_divertable()
111    }
112
113    /// Whether the control can report raw XY movement while held — required to
114    /// decode a swipe into a direction.
115    #[must_use]
116    pub fn supports_raw_xy(self) -> bool {
117        self.typed_flags().supports_raw_xy()
118    }
119
120    /// Whether the control can report force raw-XY data while held.
121    #[must_use]
122    pub fn supports_force_raw_xy(self) -> bool {
123        self.typed_flags().supports_force_raw_xy()
124    }
125
126    /// Whether the control can report analytics key events.
127    #[must_use]
128    pub fn supports_analytics_events(self) -> bool {
129        self.typed_flags().supports_analytics_key_events()
130    }
131
132    /// Whether the control can report raw wheel data.
133    #[must_use]
134    pub fn supports_raw_wheel(self) -> bool {
135        self.typed_flags().supports_raw_wheel()
136    }
137}
138
139impl From<CidInfo> for CtrlIdInfo {
140    fn from(info: CidInfo) -> Self {
141        Self {
142            cid: info.cid.into(),
143            task_id: info.task_id.0,
144            flags: info.flags.raw(),
145        }
146    }
147}
148
149/// `ReprogControlsV4` accessor bound to one device + resolved feature index.
150///
151/// Construct with the feature index obtained from the device's root feature
152/// (`get_feature(`[`FEATURE_ID`]`)`), then call the functions below. Cheap to
153/// clone (an `Arc` plus two indices).
154#[derive(Clone)]
155pub struct ReprogControlsV4 {
156    inner: Arc<hidpp_reprog::ReprogControlsFeature>,
157    device_index: u8,
158    feature_index: u8,
159}
160
161impl ReprogControlsV4 {
162    /// Bind the feature to `(device_index, feature_index)` on `chan`.
163    #[must_use]
164    pub fn new(chan: Arc<HidppChannel>, device_index: u8, feature_index: u8) -> Self {
165        Self {
166            inner: Arc::new(hidpp_reprog::ReprogControlsFeature::new(
167                chan,
168                device_index,
169                feature_index,
170            )),
171            device_index,
172            feature_index,
173        }
174    }
175
176    /// The feature index this accessor talks to — used to match unsolicited
177    /// events in [`decode_event`].
178    #[must_use]
179    pub fn feature_index(&self) -> u8 {
180        self.feature_index
181    }
182
183    /// The device index this accessor talks to.
184    #[must_use]
185    pub fn device_index(&self) -> u8 {
186        self.device_index
187    }
188
189    /// Number of reprogrammable controls the device exposes.
190    pub async fn get_count(&self) -> Result<u8, Hidpp20Error> {
191        self.inner.get_count().await
192    }
193
194    /// Identity + capabilities of the control at `index` (`0..get_count`).
195    pub async fn get_cid_info(&self, index: u8) -> Result<CidInfo, Hidpp20Error> {
196        self.inner.get_cid_info(index).await
197    }
198
199    /// Compatibility projection of [`Self::get_cid_info`].
200    pub async fn get_ctrl_id_info(&self, index: u8) -> Result<CtrlIdInfo, Hidpp20Error> {
201        Ok(self.get_cid_info(index).await?.into())
202    }
203
204    /// Scan the control table for the control with `cid`. `None` if the device
205    /// doesn't expose it.
206    pub async fn find_cid_info(&self, cid: ControlId) -> Result<Option<CidInfo>, Hidpp20Error> {
207        let count = self.get_count().await?;
208        for index in 0..count {
209            let info = self.get_cid_info(index).await?;
210            if info.cid == cid {
211                return Ok(Some(info));
212            }
213        }
214        Ok(None)
215    }
216
217    /// Compatibility projection of [`Self::find_cid_info`].
218    pub async fn find_control(&self, cid: u16) -> Result<Option<CtrlIdInfo>, Hidpp20Error> {
219        Ok(self.find_cid_info(ControlId(cid)).await?.map(Into::into))
220    }
221
222    /// Current reporting/remapping state for `cid`.
223    pub async fn get_cid_reporting(&self, cid: u16) -> Result<CidReporting, Hidpp20Error> {
224        self.inner.get_cid_reporting(ControlId(cid)).await
225    }
226
227    /// Apply the full `setCidReporting` packet.
228    pub async fn set_cid_reporting_full(
229        &self,
230        cid: u16,
231        change: CidReportingChange,
232    ) -> Result<CidReportingChangeEcho, Hidpp20Error> {
233        self.inner.set_cid_reporting(ControlId(cid), change).await
234    }
235
236    /// Feature-level v6 capabilities.
237    pub async fn get_capabilities(&self) -> Result<ReprogControlsCapabilities, Hidpp20Error> {
238        self.inner.get_capabilities().await
239    }
240
241    /// Reset all diverted/remapped control report settings on v6 devices that
242    /// advertise this capability.
243    pub async fn reset_all_cid_report_settings(&self) -> Result<(), Hidpp20Error> {
244        self.inner.reset_all_cid_report_settings().await
245    }
246
247    /// Set (or clear) temporary diversion and raw-XY reporting for `cid`.
248    ///
249    /// `remap` is left at `0` (no persistent remapping). After enabling, the
250    /// device emits [`RawControlEvent`]s on this feature index; clear both flags
251    /// on teardown to hand the control back to the firmware.
252    pub async fn set_cid_reporting(
253        &self,
254        cid: u16,
255        diverted: bool,
256        raw_xy: bool,
257    ) -> Result<(), Hidpp20Error> {
258        self.set_cid_reporting_full(
259            cid,
260            hidpp_reprog::CidReportingChange::temporary_diversion(diverted, raw_xy),
261        )
262        .await?;
263        Ok(())
264    }
265}