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