openlogi_device/session/gesture.rs
1//! Live control capture for one device: divert the device's gesture sources
2//! (the MX dedicated gesture button and/or the MX Master 4 haptic panel), the
3//! DPI/ModeShift button, and the thumb wheel over HID++ and turn their events
4//! into [`CapturedInput`] the GUI can dispatch.
5//!
6//! [`run_capture_session`] holds a single HID++ channel open for one device,
7//! enables diversion on whichever of those controls it exposes, registers one
8//! message listener, and restores every control's default mapping on shutdown.
9//! Using one channel matters: a second channel to the same device would split
10//! its input-report stream, so all captured controls share this session.
11//!
12//! The session is transport-only — it has no opinion on what an input *does*.
13//! The GUI maps each [`CapturedInput`] to the user's bound action and dispatches
14//! it, mirroring how the CGEventTap hook handles the side buttons. The thumb
15//! wheel is special: diverting it stops native horizontal scroll, so the GUI
16//! re-synthesises scroll from the [`CapturedInput::Scroll`] deltas — the wheel
17//! is therefore only diverted when the user's thumbwheel config leaves its
18//! defaults (click bound, rotation rebound, or sensitivity changed).
19
20use std::sync::{Arc, Mutex, PoisonError, RwLock};
21
22use hidpp::{channel::HidppChannel, device::Device, protocol::v20};
23use openlogi_core::binding::{ButtonId, GestureDirection, SwipeAccumulator};
24use thiserror::Error;
25use tokio::sync::{mpsc, oneshot};
26use tracing::{debug, info, warn};
27
28use crate::SharedChannel;
29use crate::backend::{BackendError, HidBackend};
30use crate::channel::route::{DeviceRoute, open_route_channel};
31
32use crate::reprog_controls::{self, RawControlEvent, ReprogControlsV4};
33use crate::thumbwheel::{self, Thumbwheel, WheelResolution};
34
35/// How often the capture session pings its device to prove the channel still
36/// delivers input reports. Cheap: one HID++ round-trip per interval.
37const LIVENESS_PING_INTERVAL: std::time::Duration = std::time::Duration::from_secs(20);
38
39/// Consecutive all-silent pings after which the capture channel is declared
40/// dead. Two, so one ping lost to transient receiver congestion (which does
41/// happen under pointer load) doesn't churn the session.
42const LIVENESS_PING_STRIKES: u8 = 2;
43
44/// Shared slot holding the active capture session's open channel, so DPI /
45/// SmartShift writes can reuse it instead of opening a fresh one. `None`
46/// whenever no session is connected.
47pub type CaptureChannel = Arc<RwLock<Option<SharedChannel>>>;
48
49/// Why a capture session is shutting down.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum CaptureStop {
52 /// Normal stop — restore diverted controls.
53 Graceful,
54 /// Lease revoked / channel dying — skip restore writes.
55 Revoked,
56}
57
58/// One input captured from the active device.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum CapturedInput {
61 /// A completed swipe (or tap click) from a diverted gesture source,
62 /// tagged with the source control so dispatch resolves it against that
63 /// button's own direction map.
64 Gesture(ButtonId, GestureDirection),
65 /// A diverted button's physical down edge.
66 ButtonDown(ButtonId),
67 /// Thumb-wheel rotation to re-synthesise on the configured scroll axis.
68 /// Emitted while the wheel is diverted (click bound, rotation rebound, or
69 /// sensitivity changed).
70 Scroll {
71 /// Rotation in the wheel's diverted increments.
72 increments: i16,
73 /// What one revolution measures in each mode, so the dispatcher can
74 /// scale those increments back to the wheel's native scroll amount
75 /// instead of scrolling by however finely this wheel happens to
76 /// report.
77 resolution: WheelResolution,
78 },
79 /// A diverted button's physical up edge.
80 ButtonUp(ButtonId),
81 /// An instantaneous firmware-reported tap with no observable hold
82 /// duration, such as the thumb-wheel touch sensor.
83 ButtonPulse(ButtonId),
84}
85
86/// Why a capture session could not start (or had to stop).
87#[derive(Debug, Error)]
88pub enum GestureError {
89 /// HID transport-level failure while enumerating or opening the device.
90 #[error("HID transport error")]
91 Hid(#[from] BackendError),
92 /// No connected device matched the capture route.
93 #[error("no connected device matched the capture route")]
94 DeviceNotFound,
95 /// The device at the target index did not answer HID++.
96 #[error("device at index {0:#04x} did not respond to HID++")]
97 DeviceUnreachable(u8),
98 /// A HID++ feature call returned an error; inner string carries context.
99 #[error("HID++ protocol error: {0}")]
100 Hidpp(String),
101}
102
103/// Movement + button state accumulated across messages. Lives behind a `Mutex`
104/// because the channel's read thread invokes the listener by shared reference.
105#[derive(Default)]
106struct CaptureAccum {
107 /// Mid-swipe state for the currently held gesture source (raw-XY).
108 swipe: SwipeAccumulator,
109 /// The gesture source that began the current hold, with the [`ButtonId`]
110 /// its events dispatch as. Raw-XY reports carry no source attribution, so
111 /// the first held source owns the accumulated motion until it is released
112 /// (first hold wins). While a second source is held alongside it, motion
113 /// is dropped instead of miscommitted (see [`Self::overlap`]); when the
114 /// holder releases, a still-held source takes the hold over.
115 gesture_source: Option<(u16, ButtonId)>,
116 /// Whether a second armed source is held alongside the holder. Raw-XY
117 /// reports are unattributed on the wire, so overlap motion could belong to
118 /// either control — it is dropped until the overlap ends.
119 overlap: bool,
120 /// The armed gesture sources held in the last event, for edge detection:
121 /// a source not previously held that becomes the holder is a fresh touch
122 /// (the haptic panel's first sample is then a contact jump to discard).
123 gestures_down: Vec<u16>,
124 /// Whether the current hold's next raw-XY sample must be dropped: the
125 /// haptic panel's first sample after contact is an absolute position
126 /// jump, not a delta (see [`reprog_controls::HAPTIC_PANEL_CID`]).
127 skip_first_raw_xy: bool,
128 /// Whether any DPI/ModeShift control was held in the last event — for
129 /// rising-edge press detection.
130 dpi_down: bool,
131 /// Diverted standard-button CIDs held in the last event.
132 buttons_down: Vec<u16>,
133}
134
135/// HID++-divertable standard buttons: the `0x1b04` control ID and the
136/// [`ButtonId`] its press dispatches as. A button is diverted per device only
137/// when its binding leaves the default, so an unbound button keeps its native
138/// HID behavior (no re-synthesis needed). The Haptic Sense Panel is a gesture
139/// source ([`GESTURE_SOURCE_BUTTONS`]), not a member of this table.
140///
141/// The two wheel-tilt CIDs are the classic "Left/Right Scroll" controls that
142/// MX-line mice with a tilting main wheel (MX Anywhere 2S and friends) expose
143/// as divertable — the same mechanism Options+ uses to rebind a tilt. Arming
144/// only ever diverts what a device's own `getCtrlIdInfo` reports, so listing
145/// them here is inert on a mouse whose wheel does not tilt.
146pub const DIVERTABLE_STANDARD_BUTTONS: [(u16, ButtonId); 5] = [
147 (0x0052, ButtonId::MiddleClick),
148 (0x0053, ButtonId::Back),
149 (0x0056, ButtonId::Forward),
150 (0x005b, ButtonId::WheelTiltLeft),
151 (0x005d, ButtonId::WheelTiltRight),
152];
153
154/// HID++ gesture sources: the `0x1b04` control ID and the [`ButtonId`] it
155/// delivers — the dedicated gesture button on most MX mice, and the Haptic
156/// Sense Panel on MX Master 4 (two distinct physical controls). Each source in
157/// gesture mode is diverted with raw-XY; one with a non-default single binding
158/// instead is plain-diverted like a standard button.
159pub const GESTURE_SOURCE_BUTTONS: [(u16, ButtonId); 2] = [
160 (reprog_controls::GESTURE_BUTTON_CID, ButtonId::GestureButton),
161 (reprog_controls::HAPTIC_PANEL_CID, ButtonId::HapticPanel),
162];
163
164/// Which of one device's controls a capture session should divert.
165#[derive(Debug, Clone, Default, PartialEq, Eq)]
166pub struct CaptureSpec {
167 /// Divert the thumb wheel over `0x2150` (rotation rebind / sensitivity /
168 /// click bound).
169 pub capture_thumbwheel: bool,
170 /// Gesture-source CIDs ([`GESTURE_SOURCE_BUTTONS`] members) to divert
171 /// with raw-XY — one per source in gesture mode; empty when no HID++
172 /// control gestures.
173 pub divert_gesture_sources: Vec<u16>,
174 /// Buttons to divert as plain presses (no raw-XY): the
175 /// [`DIVERTABLE_STANDARD_BUTTONS`] and non-gesturing
176 /// [`GESTURE_SOURCE_BUTTONS`] whose binding leaves the default.
177 pub divert_buttons: Vec<(u16, ButtonId)>,
178}
179
180/// Capture the controls selected by `spec` on `route` until `shutdown`
181/// resolves, forwarding each event to `sink`.
182///
183/// Each gesture source in `spec.divert_gesture_sources` is diverted with
184/// raw-XY. A source not in gesture mode keeps its native behavior — unless a
185/// non-default single binding puts it in `spec.divert_buttons`, in which case
186/// it is diverted as a plain button (the OS hook never sees a gesture-source
187/// CID, so this is the binding's only delivery path). The DPI/ModeShift
188/// capture and the channel-reuse slot are independent of this.
189///
190/// Opens and holds one HID++ channel, diverts whichever of those controls the
191/// device exposes, and listens. Returns once `shutdown` fires (or its sender is
192/// dropped), after restoring every diverted control. Setup errors are returned;
193/// failures to restore on the way out are logged, not propagated.
194pub async fn run_capture_session(
195 backend: &dyn HidBackend,
196 route: DeviceRoute,
197 spec: CaptureSpec,
198 sink: mpsc::UnboundedSender<CapturedInput>,
199 shutdown: oneshot::Receiver<()>,
200 channel_slot: CaptureChannel,
201) -> Result<(), GestureError> {
202 let chan = open_route_channel(backend, &route)
203 .await?
204 .ok_or(GestureError::DeviceNotFound)?;
205 let device_index = route.device_index();
206 let armed = arm_controls(&chan, device_index, &spec).await?;
207
208 // Publish this device's open channel so DPI/SmartShift writes reuse it
209 // instead of opening their own. Cleared on the way out.
210 if let Ok(mut slot) = channel_slot.write() {
211 *slot = Some(SharedChannel::new(Arc::clone(&chan), route.clone()));
212 }
213
214 let accum = Arc::new(Mutex::new(CaptureAccum::default()));
215 let reprog_index = armed.reprog.as_ref().map(|(_, idx)| *idx);
216 let gesture_cids = armed.gesture_cids.clone();
217 let thumb_index = armed.thumb.as_ref().map(|(_, idx, _)| *idx);
218 let thumb_resolution = armed
219 .thumb
220 .as_ref()
221 .map_or(WheelResolution::UNKNOWN, |(_, _, res)| *res);
222 let dpi_set = armed.dpi_cids.clone();
223 let button_set = armed.button_cids.clone();
224 let listener = chan.add_msg_listener_guarded({
225 let accum = Arc::clone(&accum);
226 let sink = sink.clone();
227 move |raw, matched| {
228 if matched {
229 return;
230 }
231 let msg = v20::Message::from(raw);
232 if let Some(idx) = reprog_index
233 && let Some(event) = reprog_controls::decode_event(&msg, device_index, idx)
234 {
235 // Recover the guard even if a prior holder panicked — the
236 // critical section is panic-free, so the data is consistent.
237 let mut acc = accum.lock().unwrap_or_else(PoisonError::into_inner);
238 handle_reprog(&mut acc, event, &gesture_cids, &dpi_set, &button_set, &sink);
239 return;
240 }
241 if let Some(idx) = thumb_index
242 && let Some(event) = thumbwheel::decode_event(&msg, device_index, idx)
243 && let Some(input) = thumbwheel_input(event, thumb_resolution)
244 {
245 let _ = sink.send(input);
246 }
247 }
248 });
249
250 info!(
251 index = device_index,
252 gesture_sources = armed.gesture_cids.len(),
253 dpi_buttons = armed.dpi_cids.len(),
254 buttons = armed.button_cids.len(),
255 thumbwheel = armed.thumb.is_some(),
256 "control capture active"
257 );
258
259 // Liveness watchdog: this session's channel is the sole delivery path for
260 // every diverted control, and a channel whose input-report delivery dies
261 // (observed on macOS with concurrent opens of one node: writes accepted,
262 // replies and events silently routed elsewhere) turns every captured
263 // button to dead air with nothing to notice. Ping the device through this
264 // channel; consecutive all-silent pings mean the channel — not the device
265 // — is gone (a sleeping/unreachable device still gets us an error *reply*,
266 // which proves delivery and resets the count). Exiting lets the manager
267 // re-arm on a fresh channel.
268 let root = <hidpp::feature::root::RootFeature as hidpp::feature::CreatableFeature>::new(
269 Arc::clone(&chan),
270 device_index,
271 0,
272 );
273 let mut shutdown = std::pin::pin!(shutdown);
274 let mut silent_pings = 0u8;
275 let channel_dead = loop {
276 tokio::select! {
277 _ = &mut shutdown => break false,
278 () = tokio::time::sleep(LIVENESS_PING_INTERVAL) => {
279 match root.ping(0x5a).await {
280 Err(v20::Hidpp20Error::Channel(
281 hidpp::channel::ChannelError::Timeout
282 | hidpp::channel::ChannelError::NoResponse,
283 )) => {
284 silent_pings = silent_pings.saturating_add(1);
285 if silent_pings >= LIVENESS_PING_STRIKES {
286 warn!(
287 index = device_index,
288 "capture channel stopped delivering — restarting session on a fresh channel"
289 );
290 break true;
291 }
292 }
293 // Any reply — pong, feature error, unreachable-device
294 // error — proves the channel still delivers.
295 _ => silent_pings = 0,
296 }
297 }
298 }
299 };
300
301 drop(listener);
302 // The slot is one last-writer-wins cell shared by every session, so a
303 // sibling may have published its own channel after ours. Clear it only
304 // while it still holds *this* session's channel — evicting the sibling's
305 // would silently demote its DPI/SmartShift writes to the fresh-open slow
306 // path.
307 if let Ok(mut slot) = channel_slot.write()
308 && slot
309 .as_ref()
310 .is_some_and(|shared| Arc::ptr_eq(shared.channel(), &chan))
311 {
312 *slot = None;
313 }
314 if channel_dead {
315 // Disarm writes would each burn a timeout on a channel that no longer
316 // answers, and the replacement session re-arms the same diverts
317 // anyway; leave the device state for it.
318 debug!(index = device_index, "skipping disarm on a dead channel");
319 } else {
320 armed.disarm().await;
321 }
322 debug!(index = device_index, "control capture stopped");
323 Ok(())
324}
325
326/// The single input one diverted thumb-wheel report stands for, if any.
327///
328/// A report is a roll *or* a tap, never both, and `0x2150` says which: the
329/// wheel's touch sensor sets `single_tap` for the finger that turned the
330/// wheel, so every report from `Start` through `Stop` carries a tap bit that
331/// belongs to the roll rather than to the user. `Stop` is the one that needs
332/// the status field — it is the release, so it reports no rotation of its own
333/// and is otherwise indistinguishable from a tap on a settled wheel.
334///
335/// A report's own rotation is checked alongside the status rather than
336/// through it: both are direct statements that this report is part of a roll,
337/// and taking either keeps the roll recognised on a wheel whose firmware
338/// leaves byte 4 at zero.
339fn thumbwheel_input(
340 event: thumbwheel::ThumbwheelEvent,
341 resolution: WheelResolution,
342) -> Option<CapturedInput> {
343 if event.rotation != 0 {
344 return Some(CapturedInput::Scroll {
345 increments: event.rotation,
346 resolution,
347 });
348 }
349 if event.rotation_status.is_rolling() {
350 return None;
351 }
352 event
353 .single_tap
354 .then_some(CapturedInput::ButtonPulse(ButtonId::Thumbwheel))
355}
356
357/// Reason-aware capture: maps stop reasons onto a unit oneshot shutdown.
358pub async fn run_capture_session_with_stop_reason(
359 backend: &dyn HidBackend,
360 route: DeviceRoute,
361 capture_thumbwheel: bool,
362 divert_gesture_button: bool,
363 sink: mpsc::UnboundedSender<CapturedInput>,
364 shutdown: oneshot::Receiver<CaptureStop>,
365 channel_slot: CaptureChannel,
366) -> Result<(), GestureError> {
367 let (tx, rx) = oneshot::channel();
368 tokio::spawn(async move {
369 let _ = shutdown.await;
370 let _ = tx.send(());
371 });
372 let spec = CaptureSpec {
373 capture_thumbwheel,
374 // The bool-era API only ever meant the dedicated gesture button; the
375 // haptic panel is reachable through [`CaptureSpec`] itself.
376 divert_gesture_sources: divert_gesture_button
377 .then_some(reprog_controls::GESTURE_BUTTON_CID)
378 .into_iter()
379 .collect(),
380 divert_buttons: Vec::new(),
381 };
382 run_capture_session(backend, route, spec, sink, rx, channel_slot).await
383}
384
385/// The set of controls a session has diverted, kept so they can be handed back
386/// to the firmware on teardown.
387#[derive(Default)]
388struct ArmedControls {
389 /// `0x1b04` accessor + feature index, present when the device exposes it.
390 reprog: Option<(ReprogControlsV4, u8)>,
391 /// The gesture-source CIDs diverted with raw-XY reporting: the
392 /// `spec.divert_gesture_sources` members the device exposes.
393 gesture_cids: Vec<u16>,
394 /// DPI/ModeShift CIDs diverted as plain buttons.
395 dpi_cids: Vec<u16>,
396 /// Standard-button CIDs diverted per the session's [`CaptureSpec`], with
397 /// the [`ButtonId`] each dispatches as.
398 button_cids: Vec<(u16, ButtonId)>,
399 /// Original reporting state for every diverted `0x1b04` control.
400 reporting: Vec<ArmedCid>,
401 /// `0x2150` accessor, feature index, and the wheel's reported resolution,
402 /// present when the thumb wheel is diverted.
403 thumb: Option<(Thumbwheel, u8, WheelResolution)>,
404}
405
406#[derive(Clone, Copy)]
407struct ArmedCid {
408 cid: u16,
409 original: reprog_controls::CidReporting,
410}
411
412impl ArmedControls {
413 /// Restore every diverted control. Failures are logged, not propagated.
414 async fn disarm(&self) {
415 if let Some((rc, _)) = self.reprog.as_ref() {
416 for &reporting in &self.reporting {
417 restore_reporting(rc, reporting, "captured control").await;
418 }
419 }
420 if let Some((tw, _, _)) = self.thumb.as_ref() {
421 restore(tw.set_reporting(false, false).await, "thumb wheel");
422 }
423 }
424}
425
426/// Resolve features off the device's root and divert the controls `spec`
427/// selects: the gesture sources (raw-XY), DPI/ModeShift buttons and rebindable
428/// standard buttons over `0x1b04`, and the thumb wheel over `0x2150`. The
429/// root-feature lookup mirrors `write::open_feature`,
430/// since hidpp 0.2's registry doesn't carry the features OpenLogi reimplements.
431///
432/// A failure mid-way hands every already-diverted control back to the firmware
433/// before returning: with several controls armed one after another, aborting
434/// without disarming would leave the earlier ones diverted with no session
435/// listening — captured-and-dropped until a later respawn succeeds.
436async fn arm_controls(
437 chan: &Arc<HidppChannel>,
438 slot: u8,
439 spec: &CaptureSpec,
440) -> Result<ArmedControls, GestureError> {
441 let device = Device::new(Arc::clone(chan), slot)
442 .await
443 .map_err(|_| GestureError::DeviceUnreachable(slot))?;
444 let mut armed = ArmedControls::default();
445 if let Err(error) = arm_controls_into(&device, chan, slot, spec, &mut armed).await {
446 armed.disarm().await;
447 return Err(error);
448 }
449 if armed.gesture_cids.is_empty()
450 && armed.dpi_cids.is_empty()
451 && armed.button_cids.is_empty()
452 && armed.thumb.is_none()
453 {
454 debug!(slot, "no capturable controls — idle session");
455 }
456 Ok(armed)
457}
458
459/// The fallible arming steps of [`arm_controls`], recording each successful
460/// divert into `armed` as it lands — so the caller can disarm exactly what was
461/// armed when a later step fails.
462async fn arm_controls_into(
463 device: &Device,
464 chan: &Arc<HidppChannel>,
465 slot: u8,
466 spec: &CaptureSpec,
467 armed: &mut ArmedControls,
468) -> Result<(), GestureError> {
469 if let Some(info) = device
470 .root()
471 .get_feature(reprog_controls::FEATURE_ID)
472 .await
473 .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?
474 {
475 let rc = ReprogControlsV4::new(Arc::clone(chan), slot, info.index);
476 let controls = enumerate_controls(&rc).await?;
477 // Register an accessor before the first divert, so a failure on any
478 // divert (including the first) can be handed back via `disarm`.
479 armed.reprog = Some((rc.clone(), info.index));
480
481 // Divert each gesture-mode source; a source not listed stays native
482 // (an idle HID++ control must not be captured-and-dropped).
483 for &cid in &spec.divert_gesture_sources {
484 if controls.iter().any(|c| c.cid == cid && c.supports_raw_xy()) {
485 let reporting = arm_reprog_control(&rc, cid, true).await?;
486 armed.reporting.push(reporting);
487 armed.gesture_cids.push(cid);
488 }
489 }
490 for &cid in &reprog_controls::DPI_MODE_SHIFT_CIDS {
491 if controls.iter().any(|c| c.cid == cid && c.is_divertable()) {
492 let reporting = arm_reprog_control(&rc, cid, false).await?;
493 armed.reporting.push(reporting);
494 armed.dpi_cids.push(cid);
495 }
496 }
497 for &(cid, button) in &spec.divert_buttons {
498 // The plan never lists a raw-XY-diverted gesture source, but
499 // guard anyway: a plain (divert, no raw-XY) write here would strip
500 // the raw-XY reporting armed above.
501 if armed.gesture_cids.contains(&cid) {
502 continue;
503 }
504 if controls.iter().any(|c| c.cid == cid && c.is_divertable()) {
505 let reporting = arm_reprog_control(&rc, cid, false).await?;
506 armed.reporting.push(reporting);
507 armed.button_cids.push((cid, button));
508 }
509 }
510 }
511
512 if spec.capture_thumbwheel
513 && let Some(info) = device
514 .root()
515 .get_feature(thumbwheel::FEATURE_ID)
516 .await
517 .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?
518 {
519 let tw = Thumbwheel::new(Arc::clone(chan), slot, info.index);
520 // Consume the getInfo error here, before the next await: Hidpp20Error
521 // isn't Send, so holding it across an await would make this future
522 // (spawned on tokio) non-Send.
523 let (supports_single_tap, resolution) = match tw.get_info().await {
524 Ok(twinfo) => (twinfo.supports_single_tap, twinfo.resolution),
525 Err(e) => {
526 warn!(error = ?e, "thumb wheel getInfo failed");
527 (false, WheelResolution::UNKNOWN)
528 }
529 };
530 // Divert whenever capture was requested: rotation rebinds and the
531 // sensitivity multiplier need the diverted event stream even on wheels
532 // that report no single-tap capability (e.g. MX Master 4) — lacking the
533 // tap only means a bound click can never fire.
534 if !supports_single_tap {
535 debug!("thumb wheel reports no single tap — click not capturable");
536 }
537 if let Err(error) = tw.set_reporting(true, false).await {
538 let error = GestureError::Hidpp(format!("{error:?}"));
539 restore(
540 tw.set_reporting(false, false).await,
541 "failed thumb wheel diversion",
542 );
543 return Err(error);
544 }
545 armed.thumb = Some((tw, info.index, resolution));
546 }
547 Ok(())
548}
549
550async fn arm_reprog_control(
551 rc: &ReprogControlsV4,
552 cid: u16,
553 raw_xy: bool,
554) -> Result<ArmedCid, GestureError> {
555 let original = rc
556 .get_cid_reporting(cid)
557 .await
558 .map_err(|error| GestureError::Hidpp(format!("{error:?}")))?;
559 if original.diverted {
560 // Left over from a session that never tore down (agent killed, or
561 // another Logitech app). Worth a line: it is the state that used to be
562 // replayed on restore, leaving the button dead.
563 debug!(cid, "control was already diverted before arming");
564 }
565 let mut change = reprog_controls::CidReportingChange::temporary_diversion(true, raw_xy);
566 change.remap = original.remap;
567 if let Err(error) = rc.set_cid_reporting_full(cid, change).await {
568 let error = GestureError::Hidpp(format!("{error:?}"));
569 restore_reporting(rc, ArmedCid { cid, original }, "failed diversion").await;
570 return Err(error);
571 }
572 Ok(ArmedCid { cid, original })
573}
574
575/// The mirror image of arming: clear the diversion this session turned on and
576/// hand the control's remap target back untouched.
577///
578/// Deliberately *not* a verbatim replay of the snapshot. A control can already
579/// be diverted when the session arms it — the agent was killed mid-session, or
580/// Logi Options+ left its own diversion behind — and replaying that snapshot
581/// hands the button back diverted with nothing listening for its HID++ events
582/// and no OS event either: dead until the device sleeps or reconnects, since
583/// diversion is volatile. Arming only ever sets `diverted` / `raw_xy` (plus
584/// re-asserting `remap`), so undoing exactly those fields is the whole job;
585/// every other bit stays `None`, i.e. unchanged.
586fn undivert_change(
587 reporting: reprog_controls::CidReporting,
588) -> reprog_controls::CidReportingChange {
589 let mut change = reprog_controls::CidReportingChange::temporary_diversion(false, false);
590 change.remap = reporting.remap;
591 change
592}
593
594async fn restore_reporting(rc: &ReprogControlsV4, armed: ArmedCid, what: &str) {
595 let result = rc
596 .set_cid_reporting_full(armed.cid, undivert_change(armed.original))
597 .await
598 .map(|_| ());
599 restore(result, what);
600}
601
602/// The [`ButtonId`] a gesture-source CID dispatches as, per
603/// [`GESTURE_SOURCE_BUTTONS`]; `None` for a CID that is not a gesture source.
604/// A spec listing an unknown CID therefore never begins a hold — the press is
605/// dropped rather than misattributed.
606fn gesture_source_button(cid: u16) -> Option<ButtonId> {
607 GESTURE_SOURCE_BUTTONS
608 .into_iter()
609 .find(|&(c, _)| c == cid)
610 .map(|(_, button)| button)
611}
612
613/// Log (don't propagate) a failure to hand a control back to the firmware.
614pub(crate) fn restore<E: std::fmt::Display>(result: Result<(), E>, what: &str) {
615 if let Err(e) = result {
616 warn!(error = %e, control = what, "failed to restore control mapping on shutdown");
617 }
618}
619
620/// Read the device's full reprogrammable-control table in one pass, so we can
621/// test several CIDs without rescanning per control.
622pub(crate) async fn enumerate_controls(
623 rc: &ReprogControlsV4,
624) -> Result<Vec<reprog_controls::CtrlIdInfo>, GestureError> {
625 let count = rc
626 .get_count()
627 .await
628 .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?;
629 let mut controls = Vec::with_capacity(usize::from(count));
630 for index in 0..count {
631 controls.push(
632 rc.get_ctrl_id_info(index)
633 .await
634 .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?,
635 );
636 }
637 Ok(controls)
638}
639
640/// Update `acc` and emit on a decoded `0x1b04` event: preserve physical button
641/// edges, and commit a gesture swipe the instant it crosses the threshold
642/// (mid-swipe, like Options+) rather than on release.
643fn handle_reprog(
644 acc: &mut CaptureAccum,
645 event: RawControlEvent,
646 gesture_cids: &[u16],
647 dpi_cids: &[u16],
648 button_cids: &[(u16, ButtonId)],
649 sink: &mpsc::UnboundedSender<CapturedInput>,
650) {
651 match event {
652 RawControlEvent::DivertedButtons(cids) => {
653 // The swipe accumulator belongs to the raw-XY gesture diverts.
654 // When a gesture-source control is instead diverted as a plain
655 // button (a single binding, not gesture mode), its press must flow
656 // through the `button_cids` loop only — not also emit a click.
657 let held: Vec<(u16, ButtonId)> = gesture_cids
658 .iter()
659 .filter(|cid| cids.contains(cid))
660 .filter_map(|&cid| gesture_source_button(cid).map(|b| (cid, b)))
661 .collect();
662 match acc.gesture_source {
663 Some((cid, _)) if cids.contains(&cid) => {
664 // The holder is still down. While a second armed source is
665 // held alongside it, unattributed raw-XY motion is dropped
666 // (see `CaptureAccum::overlap`).
667 acc.overlap = held.len() > 1;
668 }
669 previous => {
670 // No holder, or the holder released: a released hold that
671 // never committed a direction is a plain click...
672 if let Some((_, button)) = previous {
673 acc.gesture_source = None;
674 acc.overlap = false;
675 if acc.swipe.end() {
676 debug!(%button, "gesture click");
677 let _ =
678 sink.send(CapturedInput::Gesture(button, GestureDirection::Click));
679 }
680 }
681 // ...and the first still-held source begins (or takes
682 // over) the hold. A source not down in the previous event
683 // is a fresh touch, so the panel's contact-jump discard
684 // applies; one that was already held has had its jump
685 // dropped during the overlap.
686 if let Some(&(cid, button)) = held.first() {
687 acc.gesture_source = Some((cid, button));
688 acc.swipe.begin();
689 acc.overlap = held.len() > 1;
690 acc.skip_first_raw_xy = cid == reprog_controls::HAPTIC_PANEL_CID
691 && !acc.gestures_down.contains(&cid);
692 }
693 }
694 }
695 // Gesture semantics stay separate from the physical lifecycle:
696 // click/swipe remains one completed action, while every armed
697 // source also contributes one rising and one falling edge to the
698 // shared button runtime.
699 for &cid in &acc.gestures_down {
700 if !held.iter().any(|(held_cid, _)| *held_cid == cid)
701 && let Some(button) = gesture_source_button(cid)
702 {
703 let _ = sink.send(CapturedInput::ButtonUp(button));
704 }
705 }
706 for &(cid, button) in &held {
707 if !acc.gestures_down.contains(&cid) {
708 let _ = sink.send(CapturedInput::ButtonDown(button));
709 }
710 }
711 acc.gestures_down = held.into_iter().map(|(cid, _)| cid).collect();
712
713 let dpi_down = dpi_cids.iter().any(|cid| cids.contains(cid));
714 if dpi_down && !acc.dpi_down {
715 let _ = sink.send(CapturedInput::ButtonDown(ButtonId::DpiToggle));
716 } else if !dpi_down && acc.dpi_down {
717 let _ = sink.send(CapturedInput::ButtonUp(ButtonId::DpiToggle));
718 }
719 acc.dpi_down = dpi_down;
720
721 for &(cid, button) in button_cids {
722 let down = cids.contains(&cid);
723 let was_down = acc.buttons_down.contains(&cid);
724 if down && !was_down {
725 let _ = sink.send(CapturedInput::ButtonDown(button));
726 acc.buttons_down.push(cid);
727 } else if !down && was_down {
728 let _ = sink.send(CapturedInput::ButtonUp(button));
729 acc.buttons_down.retain(|&c| c != cid);
730 }
731 }
732 }
733 RawControlEvent::RawXy { dx, dy } => {
734 // Motion is attributed to the holding source; outside a hold the
735 // report is stray and dropped.
736 let Some((_, button)) = acc.gesture_source else {
737 return;
738 };
739 // While two armed sources are held the report could belong to
740 // either control — drop it rather than miscommit a swipe through
741 // the holder's map.
742 if acc.overlap {
743 return;
744 }
745 // The haptic panel's first sample after contact is a position
746 // jump; summing it would commit a bogus direction instantly.
747 if acc.skip_first_raw_xy {
748 acc.skip_first_raw_xy = false;
749 return;
750 }
751 // Commit the instant a clean direction emerges (mid-swipe, once per
752 // hold); the accumulator gates on hold duration internally and drops
753 // travel that arrives outside a hold.
754 if let Some(direction) = acc.swipe.accumulate(i32::from(dx), i32::from(dy)) {
755 debug!(?direction, %button, "gesture committed");
756 let _ = sink.send(CapturedInput::Gesture(button, direction));
757 }
758 }
759 }
760}
761#[cfg(test)]
762mod tests;