openlogi_device/session/gesture.rs
1//! Live control capture for one device: divert the device's gesture sources
2//! (DPI/ModeShift, the MX dedicated gesture button and/or the MX Master 4
3//! haptic panel), and the thumb wheel over HID++ and turn their events
4//! into [`CapturedInput`] the GUI can dispatch.
5//!
6//! [`run_capture_session`] runs on the HID++ channel inventory already holds
7//! open for one device, enables diversion on whichever of those controls it
8//! exposes, registers one message listener, and restores every control's
9//! default mapping on shutdown. Using that one channel matters: a second
10//! channel to the same device would split its input-report stream, so all
11//! captured controls share this session. The channel lifecycle itself is
12//! `session::capture`'s, shared with the keyboard session; this module owns
13//! what is armed (`arm`) and what its reports mean (`accum`).
14//!
15//! The session is transport-only — it has no opinion on what an input *does*.
16//! The GUI maps each [`CapturedInput`] to the user's bound action and dispatches
17//! it, mirroring how the CGEventTap hook handles the side buttons. The thumb
18//! wheel is special: diverting it stops native horizontal scroll, so the GUI
19//! re-synthesises scroll from the [`CapturedInput::Scroll`] deltas — the wheel
20//! is therefore only diverted when the user's thumbwheel config leaves its
21//! defaults (click bound, rotation rebound, or sensitivity changed).
22
23mod accum;
24mod arm;
25
26use std::sync::{Arc, Mutex, PoisonError};
27
28use hidpp::protocol::v20;
29use openlogi_core::binding::{ButtonId, GestureDirection};
30use tokio::sync::mpsc;
31use tracing::info;
32
33use crate::SharedChannel;
34use crate::channel::route::DeviceRoute;
35
36pub use super::capture::CaptureHost;
37use super::capture::{ArmedCapture, Liveness, run_capture};
38use accum::CaptureAccum;
39pub(crate) use arm::enumerate_controls;
40use arm::{ArmedControls, ArmedThumbwheel, arm_controls};
41
42pub use super::capture_restore::{
43 CaptureChannelSlot, CaptureError, CaptureSessionFailure, CaptureSessionOutcome,
44 CaptureSessionStop, PendingCaptureRestore,
45};
46use crate::reprog_controls::{self, ReprogControlsV4};
47use crate::thumbwheel::{self, RotationStatus, WheelResolution};
48
49/// One input captured from the active device.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum CapturedInput {
52 /// A completed swipe (or tap click) from a diverted gesture source,
53 /// tagged with the source control so dispatch resolves it against that
54 /// button's own direction map.
55 Gesture(ButtonId, GestureDirection),
56 /// A diverted button's physical down edge.
57 ButtonDown(ButtonId),
58 /// Thumb-wheel rotation to re-synthesise on the configured scroll axis.
59 /// Emitted while the wheel is diverted (click bound, rotation rebound, or
60 /// sensitivity changed).
61 Scroll {
62 /// Rotation in the wheel's diverted increments. Positive is always
63 /// physical forward/up: arming normalises the model-specific polarity
64 /// reported by `0x2150 default_dir`.
65 increments: i16,
66 /// What one revolution measures in each mode, so the dispatcher can
67 /// scale those increments back to the wheel's native scroll amount
68 /// instead of scrolling by however finely this wheel happens to
69 /// report.
70 resolution: WheelResolution,
71 },
72 /// The un-inverted polarity learned while arming a thumb wheel. This is a
73 /// one-time session fact rather than user input; the agent records it for
74 /// native horizontal-wheel events that the Windows hook cannot attribute
75 /// to a device.
76 ThumbwheelDirection {
77 /// Whether a positive native delta is physical forward/up.
78 positive_is_forward: bool,
79 },
80 /// A diverted button's physical up edge.
81 ButtonUp(ButtonId),
82 /// An instantaneous firmware-reported tap with no observable hold
83 /// duration, such as the thumb-wheel touch sensor.
84 ButtonPulse(ButtonId),
85}
86
87/// HID++-divertable standard buttons: the `0x1b04` control ID and the
88/// [`ButtonId`] its press dispatches as. A button is diverted per device only
89/// when its binding leaves the default, so an unbound button keeps its native
90/// HID behavior (no re-synthesis needed). The Haptic Sense Panel is a gesture
91/// source ([`GESTURE_SOURCE_BUTTONS`]), not a member of this table.
92///
93/// The two wheel-tilt CIDs are the classic "Left/Right Scroll" controls that
94/// MX-line mice with a tilting main wheel (MX Anywhere 2S and friends) expose
95/// as divertable — the same mechanism Options+ uses to rebind a tilt. Arming
96/// only ever diverts what a device's own `getCtrlIdInfo` reports, so listing
97/// them here is inert on a mouse whose wheel does not tilt.
98pub const DIVERTABLE_STANDARD_BUTTONS: [(u16, ButtonId); 9] = {
99 // Destructured rather than indexed: a family that gains a CID stops
100 // compiling here instead of silently staying out of the table.
101 let [
102 back,
103 back_multiplatform,
104 back_multiplatform_alt,
105 back_generic,
106 ] = reprog_controls::BACK_CIDS;
107 let [forward, forward_multiplatform] = reprog_controls::FORWARD_CIDS;
108 [
109 (0x0052, ButtonId::MiddleClick),
110 (back, ButtonId::Back),
111 (back_multiplatform, ButtonId::Back),
112 (back_multiplatform_alt, ButtonId::Back),
113 (back_generic, ButtonId::Back),
114 (forward, ButtonId::Forward),
115 (forward_multiplatform, ButtonId::Forward),
116 (0x005b, ButtonId::WheelTiltLeft),
117 (0x005d, ButtonId::WheelTiltRight),
118 ]
119};
120
121/// HID++ gesture sources: the `0x1b04` control ID and the [`ButtonId`] it
122/// delivers — the dedicated gesture button, its multiplatform variant, and
123/// the haptic panel. Each source in
124/// gesture mode is diverted with raw-XY; one with a non-default single binding
125/// instead is plain-diverted like a standard button.
126pub const GESTURE_SOURCE_BUTTONS: [(u16, ButtonId); 3] = [
127 (reprog_controls::GESTURE_BUTTON_CID, ButtonId::GestureButton),
128 (
129 hidpp::feature::reprog_controls::control_ids::MULTIPLATFORM_GESTURE_BUTTON.0,
130 ButtonId::GestureButton,
131 ),
132 (reprog_controls::HAPTIC_PANEL_CID, ButtonId::HapticPanel),
133];
134
135/// Which of one device's controls a capture session should divert.
136#[derive(Debug, Clone, Default, PartialEq, Eq)]
137pub struct CaptureSpec {
138 /// Divert the thumb wheel over `0x2150` (rotation rebind / sensitivity /
139 /// click bound).
140 pub capture_thumbwheel: bool,
141 /// Gesture-source CIDs ([`GESTURE_SOURCE_BUTTONS`] members) to divert
142 /// with raw-XY — one per source in gesture mode; empty when no HID++
143 /// control gestures.
144 pub divert_gesture_sources: Vec<u16>,
145 /// Standard-button CIDs requested as raw-XY gesture sources. A control is
146 /// armed only when its HID++ capability flags advertise raw-XY support.
147 pub divert_gesture_buttons: Vec<(u16, ButtonId)>,
148 /// Buttons to divert as plain presses (no raw-XY): the
149 /// [`DIVERTABLE_STANDARD_BUTTONS`] and non-gesturing
150 /// [`GESTURE_SOURCE_BUTTONS`] whose binding leaves the default.
151 pub divert_buttons: Vec<(u16, ButtonId)>,
152}
153
154/// Capture the controls selected by `spec` on `route` until `host.shutdown`
155/// resolves, forwarding each event to `host.sink`.
156///
157/// Each gesture source in `spec.divert_gesture_sources` is diverted with
158/// raw-XY. A source not in gesture mode keeps its native behavior — unless a
159/// non-default single binding puts it in `spec.divert_buttons`, in which case
160/// it is diverted as a plain button (the OS hook never sees a gesture-source
161/// CID, so this is the binding's only delivery path). The DPI/ModeShift
162/// capture and the channel-reuse slot are independent of this.
163///
164/// Runs on the inventory-owned channel `host.registry` currently publishes
165/// for `route`: sharing that connection avoids splitting HID++ replies and
166/// input reports across two readers, and a registry miss
167/// ([`CaptureError::DeviceNotFound`]) is retried by the caller after a later
168/// inventory publication. Diverts whichever of those controls the device
169/// exposes, and listens. Returns once `host.shutdown` fires (or its sender is
170/// dropped). A normal stop restores every diverted control before returning;
171/// transport replacement or loss may return
172/// [`CaptureSessionOutcome::RestorePending`] for the caller to retry on the
173/// current inventory channel.
174pub async fn run_capture_session(
175 route: DeviceRoute,
176 spec: CaptureSpec,
177 host: CaptureHost<'_>,
178) -> Result<CaptureSessionOutcome, CaptureSessionFailure> {
179 let shared = host.channel_for(&route)?;
180 let armed = arm_controls(&shared, &spec, host.registry).await?;
181 if let Some(direction) = armed.thumbwheel_direction() {
182 let _ = host.sink.send(direction);
183 }
184 Ok(run_capture(shared, GestureCapture::new(armed), host).await)
185}
186
187/// Gesture capture as [`run_capture`] drives it: the armed controls, and the
188/// accumulator their reports feed.
189struct GestureCapture {
190 armed: ArmedControls,
191 /// Behind a `Mutex` because the channel's read thread invokes the report
192 /// handler by shared reference.
193 accum: Arc<Mutex<CaptureAccum>>,
194 /// The status of the last thumb-wheel report, for [`thumbwheel_input`].
195 /// Behind a `Mutex` for the same reason as `accum`.
196 thumb_status: Arc<Mutex<RotationStatus>>,
197}
198
199impl GestureCapture {
200 fn new(armed: ArmedControls) -> Self {
201 Self {
202 armed,
203 accum: Arc::default(),
204 thumb_status: Arc::new(Mutex::new(RotationStatus::Inactive)),
205 }
206 }
207}
208
209impl ArmedCapture for GestureCapture {
210 const NAME: &'static str = "control";
211 const LIVENESS: Liveness = Liveness::Watched;
212
213 fn log_active(&self, device_index: u8, wake_rearm: bool) {
214 let armed = &self.armed;
215 info!(
216 index = device_index,
217 gesture_sources = armed.gesture_cids.len(),
218 gesture_buttons = armed.gesture_button_cids.len(),
219 dpi_buttons = armed.dpi_cids.len(),
220 buttons = armed.button_cids.len(),
221 thumbwheel = armed.thumb.is_some(),
222 wake_rearm,
223 "control capture active"
224 );
225 }
226
227 fn report_handler(
228 &self,
229 device_index: u8,
230 sink: mpsc::UnboundedSender<CapturedInput>,
231 ) -> impl Fn(&v20::Message) + Send + Sync + 'static {
232 let armed = &self.armed;
233 let accum = Arc::clone(&self.accum);
234 let thumb_status = Arc::clone(&self.thumb_status);
235 let reprog_index = armed.reprog.as_ref().map(ReprogControlsV4::feature_index);
236 let gesture_cids = armed.gesture_cids.clone();
237 let gesture_button_set = armed.gesture_button_cids.clone();
238 let thumb_index = armed
239 .thumb
240 .as_ref()
241 .map(|thumb| thumb.wheel.feature_index());
242 let thumb_resolution = armed
243 .thumb
244 .as_ref()
245 .map_or(WheelResolution::UNKNOWN, ArmedThumbwheel::resolution);
246 let dpi_set = armed.dpi_cids.clone();
247 let button_set = armed.button_cids.clone();
248 move |msg| {
249 if let Some(idx) = reprog_index
250 && let Some(event) = reprog_controls::decode_event(msg, device_index, idx)
251 {
252 // Recover the guard even if a prior holder panicked — the
253 // critical section is panic-free, so the data is consistent.
254 let mut acc = accum.lock().unwrap_or_else(PoisonError::into_inner);
255 acc.on_event(
256 event,
257 &gesture_cids,
258 &dpi_set,
259 &gesture_button_set,
260 &button_set,
261 &sink,
262 );
263 return;
264 }
265 if let Some(idx) = thumb_index
266 && let Some(event) = thumbwheel::decode_event(msg, device_index, idx)
267 {
268 let previous = std::mem::replace(
269 &mut *thumb_status.lock().unwrap_or_else(PoisonError::into_inner),
270 event.rotation_status,
271 );
272 if let Some(input) = thumbwheel_input(event, previous, thumb_resolution) {
273 let _ = sink.send(input);
274 }
275 }
276 }
277 }
278
279 fn reset_input_state(&self) {
280 *self.accum.lock().unwrap_or_else(PoisonError::into_inner) = CaptureAccum::default();
281 // A power cycle ends any roll in progress; carrying it over would
282 // swallow the first tap after the reconnect.
283 *self
284 .thumb_status
285 .lock()
286 .unwrap_or_else(PoisonError::into_inner) = RotationStatus::Inactive;
287 }
288
289 async fn rearm(&self) {
290 self.armed.rearm().await;
291 }
292
293 fn into_pending(self, retired: &SharedChannel) -> Option<PendingCaptureRestore> {
294 self.armed.into_pending(retired)
295 }
296}
297
298/// The single input one diverted thumb-wheel report stands for, if any.
299///
300/// A report is a roll *or* a tap, never both, and `0x2150` says which: the
301/// wheel's touch sensor sets `single_tap` for the finger that turned the
302/// wheel, so every report from `Start` through `Stop` carries a tap bit that
303/// belongs to the roll rather than to the user. `Stop` is the one that needs
304/// the status field — it is the release, so it reports no rotation of its own
305/// and is otherwise indistinguishable from a tap on a settled wheel.
306///
307/// A report's own rotation is checked alongside the status rather than
308/// through it: both are direct statements that this report is part of a roll,
309/// and taking either keeps the roll recognised on a wheel whose firmware
310/// leaves byte 4 at zero.
311///
312/// `previous` is the status of the report before this one. A report with no
313/// rotation that follows a roll still open (`Start` or `Active`) ends that
314/// roll even when its own status says `Inactive`: the firmware did not report
315/// the roll's `Stop`, and this report is the release in its place, tap bit
316/// included. Logi Options+ reads the same sequence as a `Stop`
317/// (`devio_feature_2150::convert`, "Inconsistent transition from ACTIVE to
318/// INACTIVE. Consider this a STOP.").
319fn thumbwheel_input(
320 event: thumbwheel::ThumbwheelEvent,
321 previous: RotationStatus,
322 resolution: WheelResolution,
323) -> Option<CapturedInput> {
324 if event.rotation != 0 {
325 return Some(CapturedInput::Scroll {
326 increments: event.rotation,
327 resolution,
328 });
329 }
330 if event.rotation_status.is_rolling() || previous.leaves_roll_open() {
331 return None;
332 }
333 event
334 .single_tap
335 .then_some(CapturedInput::ButtonPulse(ButtonId::Thumbwheel))
336}
337
338#[cfg(test)]
339mod tests;