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
20mod liveness;
21
22use std::sync::{Arc, Mutex, PoisonError};
23
24use hidpp::{
25 channel::HidppChannel,
26 device::Device,
27 feature::{
28 CreatableFeature, EmittingFeature,
29 root::RootFeature,
30 wireless_device_status::{WirelessDeviceStatusEvent, WirelessDeviceStatusFeature},
31 },
32 protocol::v20,
33};
34use openlogi_core::binding::{ButtonId, GestureDirection, SwipeAccumulator};
35use tokio::sync::{mpsc, oneshot};
36use tracing::{debug, info, warn};
37
38use crate::backend::{BackendError, HidBackend};
39use crate::channel::route::{DeviceRoute, open_route_channel};
40use crate::{ChannelRegistry, DeviceIoGate, SharedChannel};
41
42use liveness::{CaptureLiveness, ChannelActivity, LivenessDecision, PingOutcome};
43
44#[cfg(test)]
45use super::capture_restore::undivert_change;
46use super::capture_restore::{
47 ArmedReporting, CaptureStop, ReprogRestore, divert_change, drop_listener_after,
48 restore_after_stop, rollback_capture_start, stop_for_current_publication,
49 wait_for_channel_change,
50};
51pub use super::capture_restore::{
52 CaptureChannel, CaptureSessionFailure, CaptureSessionOutcome, GestureError,
53 PendingCaptureRestore,
54};
55use crate::reprog_controls::{self, RawControlEvent, ReprogControlsV4};
56use crate::thumbwheel::{self, Thumbwheel, ThumbwheelInfo, WheelDirection, WheelResolution};
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. Positive is always
72 /// physical forward/up: arming normalises the model-specific polarity
73 /// reported by `0x2150 default_dir`.
74 increments: i16,
75 /// What one revolution measures in each mode, so the dispatcher can
76 /// scale those increments back to the wheel's native scroll amount
77 /// instead of scrolling by however finely this wheel happens to
78 /// report.
79 resolution: WheelResolution,
80 },
81 /// The un-inverted polarity learned while arming a thumb wheel. This is a
82 /// one-time session fact rather than user input; the agent records it for
83 /// native horizontal-wheel events that the Windows hook cannot attribute
84 /// to a device.
85 ThumbwheelDirection {
86 /// Whether a positive native delta is physical forward/up.
87 positive_is_forward: bool,
88 },
89 /// A diverted button's physical up edge.
90 ButtonUp(ButtonId),
91 /// An instantaneous firmware-reported tap with no observable hold
92 /// duration, such as the thumb-wheel touch sensor.
93 ButtonPulse(ButtonId),
94}
95
96/// The hold that owns raw-XY motion, or the absence of one. Raw-XY reports
97/// carry no source attribution, so the first held source owns the accumulated
98/// motion until it is released (first hold wins); the per-hold qualifiers live
99/// inside the variant so none can outlive the hold it belongs to.
100#[derive(Default)]
101enum HoldState {
102 /// No armed gesture source is held: raw-XY reports are stray and dropped.
103 #[default]
104 Idle,
105 /// `cid` began the current hold; its events dispatch as `button`. When the
106 /// holder releases, a still-held source takes the hold over.
107 Holding {
108 /// The `0x1b04` control that owns this hold.
109 cid: u16,
110 /// The [`ButtonId`] the hold's gestures dispatch as.
111 button: ButtonId,
112 /// Mid-swipe travel accumulated over this hold.
113 swipe: SwipeAccumulator,
114 /// A second armed source is held alongside the holder. Overlap motion
115 /// could belong to either control — dropped until the overlap ends.
116 overlap: bool,
117 /// The hold's next raw-XY sample must be dropped: the haptic panel's
118 /// first sample after contact is an absolute position jump, not a
119 /// delta (see [`reprog_controls::HAPTIC_PANEL_CID`]).
120 skip_first_raw_xy: bool,
121 },
122}
123
124/// Begin a hold for `cid`, its swipe accumulator started fresh.
125fn begin_hold(cid: u16, button: ButtonId, overlap: bool, skip_first_raw_xy: bool) -> HoldState {
126 let mut swipe = SwipeAccumulator::default();
127 swipe.begin();
128 HoldState::Holding {
129 cid,
130 button,
131 swipe,
132 overlap,
133 skip_first_raw_xy,
134 }
135}
136
137/// Movement + button state accumulated across messages. Lives behind a `Mutex`
138/// because the channel's read thread invokes the listener by shared reference.
139#[derive(Default)]
140struct CaptureAccum {
141 /// The hold owning raw-XY motion, if any (see [`HoldState`]).
142 hold: HoldState,
143 /// The armed gesture sources held in the last event, for edge detection:
144 /// a source not previously held that becomes the holder is a fresh touch
145 /// (the haptic panel's first sample is then a contact jump to discard).
146 gestures_down: Vec<u16>,
147 /// Whether any DPI/ModeShift control was held in the last event — for
148 /// rising-edge press detection.
149 dpi_down: bool,
150 /// Diverted standard-button CIDs held in the last event.
151 buttons_down: Vec<u16>,
152}
153
154#[cfg(test)]
155impl CaptureAccum {
156 /// Test-only seam mirroring [`SwipeAccumulator::backdate_hold_for_test`]
157 /// for the current hold. A no-op while idle.
158 fn backdate_hold_for_test(&mut self) {
159 if let HoldState::Holding { swipe, .. } = &mut self.hold {
160 swipe.backdate_hold_for_test();
161 }
162 }
163}
164
165/// HID++-divertable standard buttons: the `0x1b04` control ID and the
166/// [`ButtonId`] its press dispatches as. A button is diverted per device only
167/// when its binding leaves the default, so an unbound button keeps its native
168/// HID behavior (no re-synthesis needed). The Haptic Sense Panel is a gesture
169/// source ([`GESTURE_SOURCE_BUTTONS`]), not a member of this table.
170///
171/// The two wheel-tilt CIDs are the classic "Left/Right Scroll" controls that
172/// MX-line mice with a tilting main wheel (MX Anywhere 2S and friends) expose
173/// as divertable — the same mechanism Options+ uses to rebind a tilt. Arming
174/// only ever diverts what a device's own `getCtrlIdInfo` reports, so listing
175/// them here is inert on a mouse whose wheel does not tilt.
176pub const DIVERTABLE_STANDARD_BUTTONS: [(u16, ButtonId); 9] = [
177 (0x0052, ButtonId::MiddleClick),
178 (0x0053, ButtonId::Back),
179 (0x00BD, ButtonId::Back),
180 (0x00CE, ButtonId::Back),
181 (0x00DB, ButtonId::Back),
182 (0x0056, ButtonId::Forward),
183 (0x00CF, ButtonId::Forward),
184 (0x005b, ButtonId::WheelTiltLeft),
185 (0x005d, ButtonId::WheelTiltRight),
186];
187
188/// HID++ gesture sources: the `0x1b04` control ID and the [`ButtonId`] it
189/// delivers — the dedicated gesture button on most MX mice, and the Haptic
190/// Sense Panel on MX Master 4 (two distinct physical controls). Each source in
191/// gesture mode is diverted with raw-XY; one with a non-default single binding
192/// instead is plain-diverted like a standard button.
193pub const GESTURE_SOURCE_BUTTONS: [(u16, ButtonId); 2] = [
194 (reprog_controls::GESTURE_BUTTON_CID, ButtonId::GestureButton),
195 (reprog_controls::HAPTIC_PANEL_CID, ButtonId::HapticPanel),
196];
197
198/// Which of one device's controls a capture session should divert.
199#[derive(Debug, Clone, Default, PartialEq, Eq)]
200pub struct CaptureSpec {
201 /// Divert the thumb wheel over `0x2150` (rotation rebind / sensitivity /
202 /// click bound).
203 pub capture_thumbwheel: bool,
204 /// Gesture-source CIDs ([`GESTURE_SOURCE_BUTTONS`] members) to divert
205 /// with raw-XY — one per source in gesture mode; empty when no HID++
206 /// control gestures.
207 pub divert_gesture_sources: Vec<u16>,
208 /// Standard-button CIDs requested as raw-XY gesture sources. A control is
209 /// armed only when its HID++ capability flags advertise raw-XY support.
210 pub divert_gesture_buttons: Vec<(u16, ButtonId)>,
211 /// Buttons to divert as plain presses (no raw-XY): the
212 /// [`DIVERTABLE_STANDARD_BUTTONS`] and non-gesturing
213 /// [`GESTURE_SOURCE_BUTTONS`] whose binding leaves the default.
214 pub divert_buttons: Vec<(u16, ButtonId)>,
215}
216
217/// Capture the controls selected by `spec` on `route` until `shutdown`
218/// resolves, forwarding each event to `sink`.
219///
220/// Each gesture source in `spec.divert_gesture_sources` is diverted with
221/// raw-XY. A source not in gesture mode keeps its native behavior — unless a
222/// non-default single binding puts it in `spec.divert_buttons`, in which case
223/// it is diverted as a plain button (the OS hook never sees a gesture-source
224/// CID, so this is the binding's only delivery path). The DPI/ModeShift
225/// capture and the channel-reuse slot are independent of this.
226///
227/// Opens and holds one HID++ channel, diverts whichever of those controls the
228/// device exposes, and listens. Returns once `shutdown` fires (or its sender is
229/// dropped). A normal stop restores every diverted control before returning;
230/// transport replacement or loss may return
231/// [`CaptureSessionOutcome::RestorePending`] for the caller to retry on the
232/// current inventory channel.
233pub async fn run_capture_session(
234 backend: &dyn HidBackend,
235 route: DeviceRoute,
236 spec: CaptureSpec,
237 sink: mpsc::UnboundedSender<CapturedInput>,
238 shutdown: oneshot::Receiver<()>,
239 channel_slot: CaptureChannel,
240 device_io: DeviceIoGate,
241) -> Result<CaptureSessionOutcome, CaptureSessionFailure> {
242 if !device_io.allows_io() {
243 return Err(GestureError::Hid(BackendError::Backend(
244 "host device I/O is suspended".into(),
245 ))
246 .into());
247 }
248 let chan = open_route_channel(backend, &route)
249 .await
250 .map_err(GestureError::from)?
251 .ok_or(GestureError::DeviceNotFound)?;
252 let shared = SharedChannel::new(chan, route.clone());
253 run_capture_session_on(shared, spec, sink, shutdown, channel_slot, None, device_io).await
254}
255
256/// Capture through the inventory-owned channel currently published for
257/// `route`. Sharing that connection avoids splitting HID++ replies and input
258/// reports across two readers; a registry miss is retried by the caller after
259/// a later inventory publication.
260pub async fn run_capture_session_with_registry_spec(
261 route: DeviceRoute,
262 spec: CaptureSpec,
263 sink: mpsc::UnboundedSender<CapturedInput>,
264 shutdown: oneshot::Receiver<()>,
265 channel_slot: CaptureChannel,
266 registry: &ChannelRegistry,
267 device_io: DeviceIoGate,
268) -> Result<CaptureSessionOutcome, CaptureSessionFailure> {
269 let shared = registry
270 .lookup(&route)
271 .ok_or(GestureError::DeviceNotFound)?;
272 run_capture_session_on(
273 shared,
274 spec,
275 sink,
276 shutdown,
277 channel_slot,
278 Some(registry),
279 device_io,
280 )
281 .await
282}
283
284async fn run_capture_session_on(
285 shared: SharedChannel,
286 spec: CaptureSpec,
287 sink: mpsc::UnboundedSender<CapturedInput>,
288 shutdown: oneshot::Receiver<()>,
289 channel_slot: CaptureChannel,
290 registry: Option<&ChannelRegistry>,
291 device_io: DeviceIoGate,
292) -> Result<CaptureSessionOutcome, CaptureSessionFailure> {
293 if !device_io.allows_io() {
294 return Err(GestureError::Hid(BackendError::Backend(
295 "host device I/O is suspended".into(),
296 ))
297 .into());
298 }
299 let chan = Arc::clone(shared.channel());
300 let device_index = shared.device_index();
301 let armed = arm_controls(&chan, device_index, &spec, &shared, registry).await?;
302
303 if let Some(direction) = armed.thumbwheel_direction() {
304 let _ = sink.send(direction);
305 }
306
307 // Publish this device's open channel so DPI/SmartShift writes reuse it
308 // instead of opening their own. Cleared on the way out.
309 if let Ok(mut slot) = channel_slot.write() {
310 *slot = Some(shared.clone());
311 }
312
313 let accum = Arc::new(Mutex::new(CaptureAccum::default()));
314 let reprog_index = armed.reprog.as_ref().map(ReprogControlsV4::feature_index);
315 let gesture_cids = armed.gesture_cids.clone();
316 let gesture_button_set = armed.gesture_button_cids.clone();
317 let thumb_index = armed
318 .thumb
319 .as_ref()
320 .map(|thumb| thumb.wheel.feature_index());
321 let thumb_resolution = armed
322 .thumb
323 .as_ref()
324 .map_or(WheelResolution::UNKNOWN, ArmedThumbwheel::resolution);
325 let dpi_set = armed.dpi_cids.clone();
326 let button_set = armed.button_cids.clone();
327 let activity = Arc::new(ChannelActivity::default());
328 let listener = chan.add_msg_listener_guarded({
329 let accum = Arc::clone(&accum);
330 let activity = Arc::clone(&activity);
331 let sink = sink.clone();
332 move |raw, matched| {
333 // Every parsed inbound HID++ report proves this channel's read
334 // path is alive, including responses matched to another request.
335 activity.record();
336 if matched {
337 return;
338 }
339 let msg = v20::Message::from(raw);
340 if let Some(idx) = reprog_index
341 && let Some(event) = reprog_controls::decode_event(&msg, device_index, idx)
342 {
343 // Recover the guard even if a prior holder panicked — the
344 // critical section is panic-free, so the data is consistent.
345 let mut acc = accum.lock().unwrap_or_else(PoisonError::into_inner);
346 handle_reprog_with_gesture_buttons(
347 &mut acc,
348 event,
349 &gesture_cids,
350 &dpi_set,
351 &gesture_button_set,
352 &button_set,
353 &sink,
354 );
355 return;
356 }
357 if let Some(idx) = thumb_index
358 && let Some(event) = thumbwheel::decode_event(&msg, device_index, idx)
359 && let Some(input) = thumbwheel_input(event, thumb_resolution)
360 {
361 let _ = sink.send(input);
362 }
363 }
364 });
365
366 // Liveness watchdog: this session's channel is the sole delivery path for
367 // every diverted control, and a channel whose input-report delivery dies
368 // (observed on macOS with concurrent opens of one node: writes accepted,
369 // replies and events silently routed elsewhere) turns every captured
370 // button to dead air with nothing to notice. Ping the device through this
371 // channel; consecutive all-silent pings mean the channel — not the device
372 // — is gone (a sleeping/unreachable device can still send an HID++ error
373 // reply, which proves delivery and resets the count). A transport/setup
374 // error proves neither delivery nor silence, so it restarts immediately.
375 // Exiting lets the manager re-arm on a fresh channel.
376 let root = RootFeature::new(Arc::clone(&chan), device_index, 0);
377 let wireless = root
378 .get_feature(WirelessDeviceStatusFeature::ID)
379 .await
380 .ok()
381 .flatten()
382 .map(|info| WirelessDeviceStatusFeature::new(Arc::clone(&chan), device_index, info.index));
383 log_capture_active(device_index, &armed, wireless.is_some());
384 let stop = monitor_capture(
385 CaptureMonitor {
386 root: &root,
387 armed: &armed,
388 accum: &accum,
389 device_index,
390 registry,
391 shared: &shared,
392 activity: &activity,
393 },
394 wireless,
395 shutdown,
396 device_io,
397 )
398 .await;
399
400 // The slot is one last-writer-wins cell shared by every session, so a
401 // sibling may have published its own channel after ours. Clear it only
402 // while it still holds *this* session's channel — evicting the sibling's
403 // would silently demote its DPI/SmartShift writes to the fresh-open slow
404 // path.
405 if let Ok(mut slot) = channel_slot.write()
406 && slot
407 .as_ref()
408 .is_some_and(|shared| Arc::ptr_eq(shared.channel(), &chan))
409 {
410 *slot = None;
411 }
412 let outcome = finish_capture(listener, stop, armed, shared, registry).await;
413 debug!(index = device_index, "control capture stopped");
414 Ok(outcome)
415}
416
417/// Restore or hand off one stopped session while its listener still owns every
418/// diverted input report.
419async fn finish_capture<T>(
420 listener: T,
421 stop: CaptureStop,
422 armed: ArmedControls,
423 retired: SharedChannel,
424 registry: Option<&ChannelRegistry>,
425) -> CaptureSessionOutcome {
426 let pending = armed.into_pending(&retired);
427 drop_listener_after(
428 listener,
429 restore_after_stop(stop, pending, &retired, registry),
430 )
431 .await
432}
433
434/// The single input one diverted thumb-wheel report stands for, if any.
435///
436/// A report is a roll *or* a tap, never both, and `0x2150` says which: the
437/// wheel's touch sensor sets `single_tap` for the finger that turned the
438/// wheel, so every report from `Start` through `Stop` carries a tap bit that
439/// belongs to the roll rather than to the user. `Stop` is the one that needs
440/// the status field — it is the release, so it reports no rotation of its own
441/// and is otherwise indistinguishable from a tap on a settled wheel.
442///
443/// A report's own rotation is checked alongside the status rather than
444/// through it: both are direct statements that this report is part of a roll,
445/// and taking either keeps the roll recognised on a wheel whose firmware
446/// leaves byte 4 at zero.
447fn thumbwheel_input(
448 event: thumbwheel::ThumbwheelEvent,
449 resolution: WheelResolution,
450) -> Option<CapturedInput> {
451 if event.rotation != 0 {
452 return Some(CapturedInput::Scroll {
453 increments: event.rotation,
454 resolution,
455 });
456 }
457 if event.rotation_status.is_rolling() {
458 return None;
459 }
460 event
461 .single_tap
462 .then_some(CapturedInput::ButtonPulse(ButtonId::Thumbwheel))
463}
464
465/// The set of controls a session has diverted, kept so they can be handed back
466/// to the firmware on teardown.
467#[derive(Default)]
468struct ArmedControls {
469 /// `0x1b04` accessor, present when the device exposes it.
470 reprog: Option<ReprogControlsV4>,
471 /// The gesture-source CIDs diverted with raw-XY reporting: the
472 /// `spec.divert_gesture_sources` members the device exposes.
473 gesture_cids: Vec<u16>,
474 /// Raw-XY-capable standard-button CIDs diverted as gesture sources.
475 gesture_button_cids: Vec<(u16, ButtonId)>,
476 /// DPI/ModeShift CIDs diverted as plain buttons.
477 dpi_cids: Vec<u16>,
478 /// Standard-button CIDs diverted per the session's [`CaptureSpec`], with
479 /// the [`ButtonId`] each dispatches as.
480 button_cids: Vec<(u16, ButtonId)>,
481 /// Original reporting state for every diverted `0x1b04` control.
482 reporting: Vec<ArmedReporting>,
483 /// `0x2150` accessor and the information read while diverting it, present
484 /// when the thumb wheel is diverted.
485 thumb: Option<ArmedThumbwheel>,
486}
487
488struct ArmedThumbwheel {
489 wheel: Thumbwheel,
490 info: Option<ThumbwheelInfo>,
491}
492
493impl ArmedThumbwheel {
494 fn resolution(&self) -> WheelResolution {
495 self.info
496 .map_or(WheelResolution::UNKNOWN, |info| info.resolution)
497 }
498
499 fn direction(&self) -> WheelDirection {
500 if self.info.is_some_and(|info| !info.positive_is_forward()) {
501 WheelDirection::Inverted
502 } else {
503 WheelDirection::Default
504 }
505 }
506}
507
508impl ArmedControls {
509 /// Build the one-time polarity fact learned while arming the thumb wheel.
510 fn thumbwheel_direction(&self) -> Option<CapturedInput> {
511 let positive_is_forward = self
512 .thumb
513 .as_ref()?
514 .info
515 .map(ThumbwheelInfo::positive_is_forward)?;
516 Some(CapturedInput::ThumbwheelDirection {
517 positive_is_forward,
518 })
519 }
520
521 /// Convert all armed firmware state into the one capability that can
522 /// release it. Consuming `self` prevents a session and a restore retry from
523 /// both claiming ownership at once.
524 fn into_pending(self, retired: &SharedChannel) -> Option<PendingCaptureRestore> {
525 let Self {
526 reprog,
527 reporting,
528 thumb,
529 ..
530 } = self;
531 let reprog =
532 reprog.and_then(|controls| ReprogRestore::new(controls.feature_index(), reporting));
533 PendingCaptureRestore::new(
534 retired,
535 reprog,
536 thumb.as_ref().map(|thumb| thumb.wheel.feature_index()),
537 )
538 }
539
540 /// Reapply volatile diversion after a wireless reconnect broadcast. The
541 /// broadcast can precede the device accepting feature writes, so allow a
542 /// short settling window like the keyboard capture path does.
543 async fn rearm(&self, device_io: &DeviceIoGate) {
544 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
545 if !device_io.allows_io() {
546 return;
547 }
548 if let Some(rc) = self.reprog.as_ref() {
549 for &reporting in &self.reporting {
550 let raw_xy = self.gesture_cids.contains(&reporting.cid)
551 || self
552 .gesture_button_cids
553 .iter()
554 .any(|&(cid, _)| cid == reporting.cid);
555 let change = divert_change(reporting.original, raw_xy);
556 if let Err(error) = rc.set_cid_reporting_full(reporting.cid, change).await {
557 warn!(
558 cid = format_args!("{:#06x}", reporting.cid),
559 ?error,
560 "re-divert after wake failed"
561 );
562 }
563 }
564 }
565 if let Some(thumb) = self.thumb.as_ref()
566 && let Err(error) = thumb.wheel.divert(thumb.direction()).await
567 {
568 warn!(?error, "thumb-wheel re-divert after wake failed");
569 }
570 }
571}
572
573fn log_capture_active(device_index: u8, armed: &ArmedControls, wake_rearm: bool) {
574 info!(
575 index = device_index,
576 gesture_sources = armed.gesture_cids.len(),
577 gesture_buttons = armed.gesture_button_cids.len(),
578 dpi_buttons = armed.dpi_cids.len(),
579 buttons = armed.button_cids.len(),
580 thumbwheel = armed.thumb.is_some(),
581 wake_rearm,
582 "control capture active"
583 );
584}
585
586/// Borrowed state used while monitoring one armed capture session.
587struct CaptureMonitor<'a> {
588 root: &'a RootFeature,
589 armed: &'a ArmedControls,
590 accum: &'a Arc<Mutex<CaptureAccum>>,
591 device_index: u8,
592 registry: Option<&'a ChannelRegistry>,
593 shared: &'a SharedChannel,
594 activity: &'a ChannelActivity,
595}
596
597/// Keep a capture session alive and reapply its volatile diversions whenever
598/// the device announces a reconnect. Returns only the typed reason capture
599/// stopped; restoration performs a fresh registry lookup after monitoring.
600async fn monitor_capture(
601 context: CaptureMonitor<'_>,
602 wireless: Option<WirelessDeviceStatusFeature>,
603 shutdown: oneshot::Receiver<()>,
604 mut device_io: DeviceIoGate,
605) -> CaptureStop {
606 let mut wake_events = wireless.as_ref().map(EmittingFeature::listen);
607 let mut shutdown = std::pin::pin!(shutdown);
608 let mut liveness =
609 CaptureLiveness::new(tokio::time::Instant::now(), context.activity.generation());
610 loop {
611 if !device_io.allows_io() {
612 if !device_io.wait_until_allowed().await {
613 return stop_for_current_publication(context.registry, context.shared);
614 }
615 // Time asleep is not channel idleness. Give the transport a full
616 // quiet interval after visible resume and clear any pre-sleep
617 // strike before considering a liveness ping.
618 liveness.record_activity(tokio::time::Instant::now(), context.activity.generation());
619 }
620 let activity_generation = liveness.activity_generation();
621 let idle_deadline = liveness.idle_deadline();
622 tokio::select! {
623 biased;
624
625 allowed = device_io.changed() => {
626 match allowed {
627 Some(true) => liveness.record_activity(
628 tokio::time::Instant::now(),
629 context.activity.generation(),
630 ),
631 Some(false) => {}
632 None => return stop_for_current_publication(context.registry, context.shared),
633 }
634 }
635 transition = wait_for_channel_change(
636 context.registry,
637 context.shared,
638 ) => {
639 info!(index = context.device_index, "inventory replaced or removed capture channel — restarting session");
640 return transition;
641 }
642 _ = &mut shutdown => {
643 // Shutdown and inventory replacement can become ready on the
644 // same turn. Prefer the typed channel transition so teardown
645 // never blindly writes through a transport already known to
646 // be obsolete.
647 return stop_for_current_publication(context.registry, context.shared);
648 }
649 event = async {
650 match wake_events.as_ref() {
651 Some(events) => events.recv().await.ok(),
652 None => std::future::pending().await,
653 }
654 } => {
655 let Some(WirelessDeviceStatusEvent::StatusBroadcast(broadcast)) = event else {
656 wake_events = None;
657 continue;
658 };
659 info!(?broadcast, "device reconnected — re-arming control capture");
660 *context.accum.lock().unwrap_or_else(PoisonError::into_inner) =
661 CaptureAccum::default();
662 context.armed.rearm(&device_io).await;
663 }
664 generation = context.activity.changed_after(activity_generation) => {
665 liveness.record_activity(tokio::time::Instant::now(), generation);
666 }
667 () = tokio::time::sleep_until(idle_deadline) => {
668 if !liveness.ping_due(
669 tokio::time::Instant::now(),
670 context.activity.generation(),
671 ) {
672 continue;
673 }
674 let outcome = match context.root.ping(0x5a).await {
675 Err(v20::Hidpp20Error::Channel(
676 hidpp::channel::ChannelError::Timeout
677 | hidpp::channel::ChannelError::NoResponse,
678 )) => PingOutcome::AllSilent,
679 // A pong, feature error, or unsupported response all prove
680 // that this channel still receives device replies.
681 Ok(_)
682 | Err(
683 v20::Hidpp20Error::Feature(_)
684 | v20::Hidpp20Error::UnsupportedResponse,
685 ) => PingOutcome::Delivered,
686 Err(_) => PingOutcome::ChannelFailed,
687 };
688 if liveness.finish_ping(
689 tokio::time::Instant::now(),
690 context.activity.generation(),
691 outcome,
692 ) == LivenessDecision::Restart {
693 warn!(index = context.device_index, "capture channel stopped delivering — restarting session on a fresh channel");
694 return stop_for_current_publication(context.registry, context.shared);
695 }
696 }
697 }
698 }
699}
700
701/// Resolve features off the device's root and divert the controls `spec`
702/// selects: the gesture sources (raw-XY), DPI/ModeShift buttons and rebindable
703/// standard buttons over `0x1b04`, and the thumb wheel over `0x2150`. The
704/// root-feature lookup mirrors `write::open_feature`,
705/// since hidpp 0.2's registry doesn't carry the features OpenLogi reimplements.
706///
707/// A failure mid-way tries to hand every possibly-diverted control back to the
708/// firmware. If compensation is incomplete, the returned failure carries an
709/// opaque restore capability for the manager to retain and retry.
710async fn arm_controls(
711 chan: &Arc<HidppChannel>,
712 slot: u8,
713 spec: &CaptureSpec,
714 shared: &SharedChannel,
715 registry: Option<&ChannelRegistry>,
716) -> Result<ArmedControls, CaptureSessionFailure> {
717 let device = Device::new(Arc::clone(chan), slot)
718 .await
719 .map_err(|_| GestureError::DeviceUnreachable(slot))?;
720 let mut armed = ArmedControls::default();
721 if let Err(error) = arm_controls_into(&device, chan, slot, spec, &mut armed).await {
722 let pending = armed.into_pending(shared);
723 return Err(rollback_capture_start(error, pending, shared, registry).await);
724 }
725 if armed.gesture_cids.is_empty()
726 && armed.gesture_button_cids.is_empty()
727 && armed.dpi_cids.is_empty()
728 && armed.button_cids.is_empty()
729 && armed.thumb.is_none()
730 {
731 debug!(slot, "no capturable controls — idle session");
732 }
733 Ok(armed)
734}
735
736/// The fallible arming steps of [`arm_controls`], recording ownership before
737/// each write. A transport failure cannot prove whether firmware applied that
738/// write, so rollback deliberately includes the uncertain current control.
739async fn arm_controls_into(
740 device: &Device,
741 chan: &Arc<HidppChannel>,
742 slot: u8,
743 spec: &CaptureSpec,
744 armed: &mut ArmedControls,
745) -> Result<(), GestureError> {
746 if let Some(info) = device
747 .root()
748 .get_feature(reprog_controls::FEATURE_ID)
749 .await
750 .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?
751 {
752 let rc = ReprogControlsV4::new(Arc::clone(chan), slot, info.index);
753 let controls = enumerate_controls(&rc).await?;
754 // Register an accessor before the first divert, so a failure on any
755 // divert (including the first) can become a restore capability.
756 armed.reprog = Some(rc.clone());
757
758 // Divert each gesture-mode source; a source not listed stays native
759 // (an idle HID++ control must not be captured-and-dropped).
760 for &cid in &spec.divert_gesture_sources {
761 if controls.iter().any(|c| c.cid == cid && c.supports_raw_xy()) {
762 arm_reprog_control(&rc, cid, true, &mut armed.reporting).await?;
763 armed.gesture_cids.push(cid);
764 }
765 }
766 for &(cid, button) in &spec.divert_gesture_buttons {
767 if let Some(control) = controls.iter().find(|c| c.cid == cid)
768 && control.is_divertable()
769 && control.supports_raw_xy()
770 {
771 arm_reprog_control(&rc, cid, true, &mut armed.reporting).await?;
772 armed.gesture_button_cids.push((cid, button));
773 }
774 }
775 for &cid in &reprog_controls::DPI_MODE_SHIFT_CIDS {
776 if controls.iter().any(|c| c.cid == cid && c.is_divertable()) {
777 arm_reprog_control(&rc, cid, false, &mut armed.reporting).await?;
778 armed.dpi_cids.push(cid);
779 }
780 }
781 for &(cid, button) in &spec.divert_buttons {
782 // The plan never lists a raw-XY-diverted gesture source, but
783 // guard anyway: a plain (divert, no raw-XY) write here would strip
784 // the raw-XY reporting armed above.
785 if armed.gesture_cids.contains(&cid)
786 || armed
787 .gesture_button_cids
788 .iter()
789 .any(|&(gesture_cid, _)| gesture_cid == cid)
790 {
791 continue;
792 }
793 if controls.iter().any(|c| c.cid == cid && c.is_divertable()) {
794 arm_reprog_control(&rc, cid, false, &mut armed.reporting).await?;
795 armed.button_cids.push((cid, button));
796 }
797 }
798 }
799
800 if spec.capture_thumbwheel
801 && let Some(info) = device
802 .root()
803 .get_feature(thumbwheel::FEATURE_ID)
804 .await
805 .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?
806 {
807 let tw = Thumbwheel::new(Arc::clone(chan), slot, info.index);
808 // Consume the getInfo error here, before the next await: Hidpp20Error
809 // isn't Send, so holding it across an await would make this future
810 // (spawned on tokio) non-Send.
811 let wheel_info = match tw.get_info().await {
812 Ok(twinfo) => Some(twinfo),
813 Err(e) => {
814 warn!(error = ?e, "thumb wheel getInfo failed");
815 None
816 }
817 };
818 // Divert whenever capture was requested: rotation rebinds and the
819 // sensitivity multiplier need the diverted event stream even on wheels
820 // that report no single-tap capability (e.g. MX Master 4) — lacking the
821 // tap only means a bound click can never fire.
822 if wheel_info.is_some_and(|info| !info.supports_single_tap) {
823 debug!("thumb wheel reports no single tap — click not capturable");
824 }
825 // Store ownership before the write: a transport error cannot prove
826 // whether firmware applied diversion, so rollback must cover it too.
827 armed.thumb = Some(ArmedThumbwheel {
828 wheel: tw,
829 info: wheel_info,
830 });
831 if let Some(thumb) = armed.thumb.as_ref()
832 && let Err(error) = thumb.wheel.divert(thumb.direction()).await
833 {
834 return Err(GestureError::Hidpp(format!("{error:?}")));
835 }
836 }
837 Ok(())
838}
839
840async fn arm_reprog_control(
841 rc: &ReprogControlsV4,
842 cid: u16,
843 raw_xy: bool,
844 reporting: &mut Vec<ArmedReporting>,
845) -> Result<(), GestureError> {
846 let original = rc
847 .get_cid_reporting(cid)
848 .await
849 .map_err(|error| GestureError::Hidpp(format!("{error:?}")))?;
850 if original.diverted {
851 // Left over from a session that never tore down (agent killed, or
852 // another Logitech app). Worth a line: it is the state that used to be
853 // replayed on restore, leaving the button dead.
854 debug!(cid, "control was already diverted before arming");
855 }
856 let change = divert_change(original, raw_xy);
857 // Record ownership before the write: a transport error does not prove the
858 // firmware rejected the command, so rollback must cover this CID too.
859 reporting.push(ArmedReporting { cid, original });
860 rc.set_cid_reporting_full(cid, change)
861 .await
862 .map_err(|error| GestureError::Hidpp(format!("{error:?}")))?;
863 Ok(())
864}
865
866/// The [`ButtonId`] a gesture-source CID dispatches as, per
867/// [`GESTURE_SOURCE_BUTTONS`]; `None` for a CID that is not a gesture source.
868/// A spec listing an unknown CID therefore never begins a hold — the press is
869/// dropped rather than misattributed.
870fn gesture_source_button(cid: u16) -> Option<ButtonId> {
871 GESTURE_SOURCE_BUTTONS
872 .into_iter()
873 .find(|&(c, _)| c == cid)
874 .map(|(_, button)| button)
875}
876
877fn captured_gesture_button(cid: u16, gesture_button_cids: &[(u16, ButtonId)]) -> Option<ButtonId> {
878 gesture_source_button(cid).or_else(|| {
879 gesture_button_cids
880 .iter()
881 .find(|&&(candidate, _)| candidate == cid)
882 .map(|&(_, button)| button)
883 })
884}
885
886/// Read the device's full reprogrammable-control table in one pass, so we can
887/// test several CIDs without rescanning per control.
888pub(crate) async fn enumerate_controls(
889 rc: &ReprogControlsV4,
890) -> Result<Vec<reprog_controls::CtrlIdInfo>, GestureError> {
891 let count = rc
892 .get_count()
893 .await
894 .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?;
895 let mut controls = Vec::with_capacity(usize::from(count));
896 for index in 0..count {
897 controls.push(
898 rc.get_ctrl_id_info(index)
899 .await
900 .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?,
901 );
902 }
903 Ok(controls)
904}
905
906/// Update `acc` and emit on a decoded `0x1b04` event: preserve physical button
907/// edges, and commit a gesture swipe the instant it crosses the threshold
908/// (mid-swipe, like Options+) rather than on release.
909fn handle_reprog_with_gesture_buttons(
910 acc: &mut CaptureAccum,
911 event: RawControlEvent,
912 gesture_cids: &[u16],
913 dpi_cids: &[u16],
914 gesture_button_cids: &[(u16, ButtonId)],
915 button_cids: &[(u16, ButtonId)],
916 sink: &mpsc::UnboundedSender<CapturedInput>,
917) {
918 match event {
919 RawControlEvent::DivertedButtons(cids) => {
920 // The swipe accumulator belongs to the raw-XY gesture diverts.
921 // When a gesture-source control is instead diverted as a plain
922 // button (a single binding, not gesture mode), its press must flow
923 // through the `button_cids` loop only — not also emit a click.
924 let held: Vec<(u16, ButtonId)> = gesture_cids
925 .iter()
926 .filter(|cid| cids.contains(cid))
927 .filter_map(|&cid| gesture_source_button(cid).map(|b| (cid, b)))
928 .chain(
929 gesture_button_cids
930 .iter()
931 .copied()
932 .filter(|(cid, _)| cids.contains(cid)),
933 )
934 .collect();
935 acc.hold = match std::mem::take(&mut acc.hold) {
936 // The holder is still down. While a second armed source is
937 // held alongside it, unattributed raw-XY motion is dropped
938 // (see [`HoldState::Holding::overlap`]).
939 HoldState::Holding {
940 cid,
941 button,
942 swipe,
943 skip_first_raw_xy,
944 ..
945 } if cids.contains(&cid) => HoldState::Holding {
946 cid,
947 button,
948 swipe,
949 overlap: held.len() > 1,
950 skip_first_raw_xy,
951 },
952 previous => {
953 // No holder, or the holder released: a released hold that
954 // never committed a direction is a plain click...
955 if let HoldState::Holding {
956 button, mut swipe, ..
957 } = previous
958 && swipe.end()
959 {
960 debug!(%button, "gesture click");
961 let _ = sink.send(CapturedInput::Gesture(button, GestureDirection::Click));
962 }
963 // ...and the first still-held source begins (or takes
964 // over) the hold. A source not down in the previous event
965 // is a fresh touch, so the panel's contact-jump discard
966 // applies; one that was already held has had its jump
967 // dropped during the overlap.
968 match held.first() {
969 Some(&(cid, button)) => begin_hold(
970 cid,
971 button,
972 held.len() > 1,
973 cid == reprog_controls::HAPTIC_PANEL_CID
974 && !acc.gestures_down.contains(&cid),
975 ),
976 None => HoldState::Idle,
977 }
978 }
979 };
980 // Gesture semantics stay separate from the physical lifecycle:
981 // click/swipe remains one completed action, while every armed
982 // source also contributes one rising and one falling edge to the
983 // shared button runtime.
984 for &cid in &acc.gestures_down {
985 if !held.iter().any(|(held_cid, _)| *held_cid == cid)
986 && let Some(button) = captured_gesture_button(cid, gesture_button_cids)
987 {
988 let _ = sink.send(CapturedInput::ButtonUp(button));
989 }
990 }
991 for &(cid, button) in &held {
992 if !acc.gestures_down.contains(&cid) {
993 let _ = sink.send(CapturedInput::ButtonDown(button));
994 }
995 }
996 acc.gestures_down = held.into_iter().map(|(cid, _)| cid).collect();
997
998 let dpi_down = dpi_cids.iter().any(|cid| cids.contains(cid));
999 if dpi_down && !acc.dpi_down {
1000 let _ = sink.send(CapturedInput::ButtonDown(ButtonId::DpiToggle));
1001 } else if !dpi_down && acc.dpi_down {
1002 let _ = sink.send(CapturedInput::ButtonUp(ButtonId::DpiToggle));
1003 }
1004 acc.dpi_down = dpi_down;
1005
1006 for &(cid, button) in button_cids {
1007 let down = cids.contains(&cid);
1008 let was_down = acc.buttons_down.contains(&cid);
1009 if down && !was_down {
1010 let _ = sink.send(CapturedInput::ButtonDown(button));
1011 acc.buttons_down.push(cid);
1012 } else if !down && was_down {
1013 let _ = sink.send(CapturedInput::ButtonUp(button));
1014 acc.buttons_down.retain(|&c| c != cid);
1015 }
1016 }
1017 }
1018 RawControlEvent::RawXy { dx, dy } => {
1019 handle_raw_xy(acc, dx, dy, sink);
1020 }
1021 }
1022}
1023
1024fn handle_raw_xy(
1025 acc: &mut CaptureAccum,
1026 dx: i16,
1027 dy: i16,
1028 sink: &mpsc::UnboundedSender<CapturedInput>,
1029) {
1030 // Motion is attributed to the holding source; outside a hold the report
1031 // is stray and dropped.
1032 let HoldState::Holding {
1033 button,
1034 swipe,
1035 overlap,
1036 skip_first_raw_xy,
1037 ..
1038 } = &mut acc.hold
1039 else {
1040 return;
1041 };
1042 // While two armed sources are held the report could belong to either
1043 // control — drop it rather than miscommit a swipe through the holder's map.
1044 if *overlap {
1045 return;
1046 }
1047 // The haptic panel's first sample after contact is a position jump;
1048 // summing it would commit a bogus direction instantly.
1049 if *skip_first_raw_xy {
1050 *skip_first_raw_xy = false;
1051 return;
1052 }
1053 // Commit the instant a clean direction emerges (mid-swipe, once per hold);
1054 // the accumulator gates on hold duration internally and drops travel that
1055 // arrives outside a hold.
1056 if let Some(direction) = swipe.accumulate(i32::from(dx), i32::from(dy)) {
1057 debug!(?direction, %button, "gesture committed");
1058 let _ = sink.send(CapturedInput::Gesture(*button, direction));
1059 }
1060}
1061
1062/// Test seam for the pre-existing raw-XY/plain-button cases, none of which
1063/// carries a standard-button gesture hold.
1064#[cfg(test)]
1065fn handle_reprog(
1066 acc: &mut CaptureAccum,
1067 event: RawControlEvent,
1068 gesture_cids: &[u16],
1069 dpi_cids: &[u16],
1070 button_cids: &[(u16, ButtonId)],
1071 sink: &mpsc::UnboundedSender<CapturedInput>,
1072) {
1073 handle_reprog_with_gesture_buttons(acc, event, gesture_cids, dpi_cids, &[], button_cids, sink);
1074}
1075#[cfg(test)]
1076mod tests;