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