Skip to main content

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;