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}