Skip to main content

openlogi_device/session/
gesture.rs

1//! Live control capture for one device: divert the device's gesture sources
2//! (DPI/ModeShift, the MX dedicated gesture button and/or the MX Master 4
3//! haptic panel), 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 additional CIDs diverted as gesture sources (macOS side
475    /// buttons and a gesture-mode DPI/ModeShift button).
476    gesture_button_cids: Vec<(u16, ButtonId)>,
477    /// DPI/ModeShift CIDs diverted as plain buttons when gesture mode is off.
478    dpi_cids: Vec<u16>,
479    /// Standard-button CIDs diverted per the session's [`CaptureSpec`], with
480    /// the [`ButtonId`] each dispatches as.
481    button_cids: Vec<(u16, ButtonId)>,
482    /// Original reporting state for every diverted `0x1b04` control.
483    reporting: Vec<ArmedReporting>,
484    /// `0x2150` accessor and the information read while diverting it, present
485    /// when the thumb wheel is diverted.
486    thumb: Option<ArmedThumbwheel>,
487}
488
489struct ArmedThumbwheel {
490    wheel: Thumbwheel,
491    info: Option<ThumbwheelInfo>,
492}
493
494impl ArmedThumbwheel {
495    fn resolution(&self) -> WheelResolution {
496        self.info
497            .map_or(WheelResolution::UNKNOWN, |info| info.resolution)
498    }
499
500    fn direction(&self) -> WheelDirection {
501        if self.info.is_some_and(|info| !info.positive_is_forward()) {
502            WheelDirection::Inverted
503        } else {
504            WheelDirection::Default
505        }
506    }
507}
508
509impl ArmedControls {
510    /// Build the one-time polarity fact learned while arming the thumb wheel.
511    fn thumbwheel_direction(&self) -> Option<CapturedInput> {
512        let positive_is_forward = self
513            .thumb
514            .as_ref()?
515            .info
516            .map(ThumbwheelInfo::positive_is_forward)?;
517        Some(CapturedInput::ThumbwheelDirection {
518            positive_is_forward,
519        })
520    }
521
522    /// Convert all armed firmware state into the one capability that can
523    /// release it. Consuming `self` prevents a session and a restore retry from
524    /// both claiming ownership at once.
525    fn into_pending(self, retired: &SharedChannel) -> Option<PendingCaptureRestore> {
526        let Self {
527            reprog,
528            reporting,
529            thumb,
530            ..
531        } = self;
532        let reprog =
533            reprog.and_then(|controls| ReprogRestore::new(controls.feature_index(), reporting));
534        PendingCaptureRestore::new(
535            retired,
536            reprog,
537            thumb.as_ref().map(|thumb| thumb.wheel.feature_index()),
538        )
539    }
540
541    /// Reapply volatile diversion after a wireless reconnect broadcast. The
542    /// broadcast can precede the device accepting feature writes, so allow a
543    /// short settling window like the keyboard capture path does.
544    async fn rearm(&self, device_io: &DeviceIoGate) {
545        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
546        if !device_io.allows_io() {
547            return;
548        }
549        if let Some(rc) = self.reprog.as_ref() {
550            for &reporting in &self.reporting {
551                let raw_xy = self.gesture_cids.contains(&reporting.cid)
552                    || self
553                        .gesture_button_cids
554                        .iter()
555                        .any(|&(cid, _)| cid == reporting.cid);
556                let change = divert_change(reporting.original, raw_xy);
557                if let Err(error) = rc.set_cid_reporting_full(reporting.cid, change).await {
558                    warn!(
559                        cid = format_args!("{:#06x}", reporting.cid),
560                        ?error,
561                        "re-divert after wake failed"
562                    );
563                }
564            }
565        }
566        if let Some(thumb) = self.thumb.as_ref()
567            && let Err(error) = thumb.wheel.divert(thumb.direction()).await
568        {
569            warn!(?error, "thumb-wheel re-divert after wake failed");
570        }
571    }
572}
573
574fn log_capture_active(device_index: u8, armed: &ArmedControls, wake_rearm: bool) {
575    info!(
576        index = device_index,
577        gesture_sources = armed.gesture_cids.len(),
578        gesture_buttons = armed.gesture_button_cids.len(),
579        dpi_buttons = armed.dpi_cids.len(),
580        buttons = armed.button_cids.len(),
581        thumbwheel = armed.thumb.is_some(),
582        wake_rearm,
583        "control capture active"
584    );
585}
586
587/// Borrowed state used while monitoring one armed capture session.
588struct CaptureMonitor<'a> {
589    root: &'a RootFeature,
590    armed: &'a ArmedControls,
591    accum: &'a Arc<Mutex<CaptureAccum>>,
592    device_index: u8,
593    registry: Option<&'a ChannelRegistry>,
594    shared: &'a SharedChannel,
595    activity: &'a ChannelActivity,
596}
597
598/// Keep a capture session alive and reapply its volatile diversions whenever
599/// the device announces a reconnect. Returns only the typed reason capture
600/// stopped; restoration performs a fresh registry lookup after monitoring.
601async fn monitor_capture(
602    context: CaptureMonitor<'_>,
603    wireless: Option<WirelessDeviceStatusFeature>,
604    shutdown: oneshot::Receiver<()>,
605    mut device_io: DeviceIoGate,
606) -> CaptureStop {
607    let mut wake_events = wireless.as_ref().map(EmittingFeature::listen);
608    let mut shutdown = std::pin::pin!(shutdown);
609    let mut liveness =
610        CaptureLiveness::new(tokio::time::Instant::now(), context.activity.generation());
611    loop {
612        if !device_io.allows_io() {
613            if !device_io.wait_until_allowed().await {
614                return stop_for_current_publication(context.registry, context.shared);
615            }
616            // Time asleep is not channel idleness. Give the transport a full
617            // quiet interval after visible resume and clear any pre-sleep
618            // strike before considering a liveness ping.
619            liveness.record_activity(tokio::time::Instant::now(), context.activity.generation());
620        }
621        let activity_generation = liveness.activity_generation();
622        let idle_deadline = liveness.idle_deadline();
623        tokio::select! {
624            biased;
625
626            allowed = device_io.changed() => {
627                match allowed {
628                    Some(true) => liveness.record_activity(
629                        tokio::time::Instant::now(),
630                        context.activity.generation(),
631                    ),
632                    Some(false) => {}
633                    None => return stop_for_current_publication(context.registry, context.shared),
634                }
635            }
636            transition = wait_for_channel_change(
637                context.registry,
638                context.shared,
639            ) => {
640                info!(index = context.device_index, "inventory replaced or removed capture channel — restarting session");
641                return transition;
642            }
643            _ = &mut shutdown => {
644                // Shutdown and inventory replacement can become ready on the
645                // same turn. Prefer the typed channel transition so teardown
646                // never blindly writes through a transport already known to
647                // be obsolete.
648                return stop_for_current_publication(context.registry, context.shared);
649            }
650            event = async {
651                match wake_events.as_ref() {
652                    Some(events) => events.recv().await.ok(),
653                    None => std::future::pending().await,
654                }
655            } => {
656                let Some(WirelessDeviceStatusEvent::StatusBroadcast(broadcast)) = event else {
657                    wake_events = None;
658                    continue;
659                };
660                info!(?broadcast, "device reconnected — re-arming control capture");
661                *context.accum.lock().unwrap_or_else(PoisonError::into_inner) =
662                    CaptureAccum::default();
663                context.armed.rearm(&device_io).await;
664            }
665            generation = context.activity.changed_after(activity_generation) => {
666                liveness.record_activity(tokio::time::Instant::now(), generation);
667            }
668            () = tokio::time::sleep_until(idle_deadline) => {
669                if !liveness.ping_due(
670                    tokio::time::Instant::now(),
671                    context.activity.generation(),
672                ) {
673                    continue;
674                }
675                let outcome = match context.root.ping(0x5a).await {
676                    Err(v20::Hidpp20Error::Channel(
677                        hidpp::channel::ChannelError::Timeout
678                        | hidpp::channel::ChannelError::NoResponse,
679                    )) => PingOutcome::AllSilent,
680                    // A pong, feature error, or unsupported response all prove
681                    // that this channel still receives device replies.
682                    Ok(_)
683                    | Err(
684                        v20::Hidpp20Error::Feature(_)
685                        | v20::Hidpp20Error::UnsupportedResponse,
686                    ) => PingOutcome::Delivered,
687                    Err(_) => PingOutcome::ChannelFailed,
688                };
689                if liveness.finish_ping(
690                    tokio::time::Instant::now(),
691                    context.activity.generation(),
692                    outcome,
693                ) == LivenessDecision::Restart {
694                    warn!(index = context.device_index, "capture channel stopped delivering — restarting session on a fresh channel");
695                    return stop_for_current_publication(context.registry, context.shared);
696                }
697            }
698        }
699    }
700}
701
702/// Resolve features off the device's root and divert the controls `spec`
703/// selects: the gesture sources (raw-XY), DPI/ModeShift buttons and rebindable
704/// standard buttons over `0x1b04`, and the thumb wheel over `0x2150`. The
705/// root-feature lookup mirrors `write::open_feature`,
706/// since hidpp 0.2's registry doesn't carry the features OpenLogi reimplements.
707///
708/// A failure mid-way tries to hand every possibly-diverted control back to the
709/// firmware. If compensation is incomplete, the returned failure carries an
710/// opaque restore capability for the manager to retain and retry.
711async fn arm_controls(
712    chan: &Arc<HidppChannel>,
713    slot: u8,
714    spec: &CaptureSpec,
715    shared: &SharedChannel,
716    registry: Option<&ChannelRegistry>,
717) -> Result<ArmedControls, CaptureSessionFailure> {
718    let device = Device::new(Arc::clone(chan), slot)
719        .await
720        .map_err(|_| GestureError::DeviceUnreachable(slot))?;
721    let mut armed = ArmedControls::default();
722    if let Err(error) = arm_controls_into(&device, chan, slot, spec, &mut armed).await {
723        let pending = armed.into_pending(shared);
724        return Err(rollback_capture_start(error, pending, shared, registry).await);
725    }
726    if armed.gesture_cids.is_empty()
727        && armed.gesture_button_cids.is_empty()
728        && armed.dpi_cids.is_empty()
729        && armed.button_cids.is_empty()
730        && armed.thumb.is_none()
731    {
732        debug!(slot, "no capturable controls — idle session");
733    }
734    Ok(armed)
735}
736
737/// The fallible arming steps of [`arm_controls`], recording ownership before
738/// each write. A transport failure cannot prove whether firmware applied that
739/// write, so rollback deliberately includes the uncertain current control.
740async fn arm_controls_into(
741    device: &Device,
742    chan: &Arc<HidppChannel>,
743    slot: u8,
744    spec: &CaptureSpec,
745    armed: &mut ArmedControls,
746) -> Result<(), GestureError> {
747    if let Some(info) = device
748        .root()
749        .get_feature(reprog_controls::FEATURE_ID)
750        .await
751        .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?
752    {
753        let rc = ReprogControlsV4::new(Arc::clone(chan), slot, info.index);
754        let controls = enumerate_controls(&rc).await?;
755        // Register an accessor before the first divert, so a failure on any
756        // divert (including the first) can become a restore capability.
757        armed.reprog = Some(rc.clone());
758
759        // Divert each gesture-mode source; a source not listed stays native
760        // (an idle HID++ control must not be captured-and-dropped).
761        for &cid in &spec.divert_gesture_sources {
762            if controls.iter().any(|c| c.cid == cid && c.supports_raw_xy()) {
763                arm_reprog_control(&rc, cid, true, &mut armed.reporting).await?;
764                armed.gesture_cids.push(cid);
765            }
766        }
767        for &(cid, button) in &spec.divert_gesture_buttons {
768            if let Some(control) = controls.iter().find(|c| c.cid == cid)
769                && control.is_divertable()
770                && control.supports_raw_xy()
771            {
772                arm_reprog_control(&rc, cid, true, &mut armed.reporting).await?;
773                armed.gesture_button_cids.push((cid, button));
774            }
775        }
776        for &cid in &reprog_controls::DPI_MODE_SHIFT_CIDS {
777            let gesture_requested = spec
778                .divert_gesture_buttons
779                .iter()
780                .any(|&(gesture_cid, button)| gesture_cid == cid && button == ButtonId::DpiToggle);
781            if gesture_requested {
782                // The raw-XY loop above owns a supported control. An
783                // unsupported one must stay native: plain-diverting it while
784                // dispatch still expects gesture events would swallow both
785                // the configured click and the firmware action.
786                continue;
787            }
788            if controls.iter().any(|c| c.cid == cid && c.is_divertable()) {
789                arm_reprog_control(&rc, cid, false, &mut armed.reporting).await?;
790                armed.dpi_cids.push(cid);
791            }
792        }
793        for &(cid, button) in &spec.divert_buttons {
794            // The plan never lists a raw-XY-diverted gesture source, but
795            // guard anyway: a plain (divert, no raw-XY) write here would strip
796            // the raw-XY reporting armed above.
797            if armed.gesture_cids.contains(&cid)
798                || armed
799                    .gesture_button_cids
800                    .iter()
801                    .any(|&(gesture_cid, _)| gesture_cid == cid)
802            {
803                continue;
804            }
805            if controls.iter().any(|c| c.cid == cid && c.is_divertable()) {
806                arm_reprog_control(&rc, cid, false, &mut armed.reporting).await?;
807                armed.button_cids.push((cid, button));
808            }
809        }
810    }
811
812    if spec.capture_thumbwheel
813        && let Some(info) = device
814            .root()
815            .get_feature(thumbwheel::FEATURE_ID)
816            .await
817            .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?
818    {
819        let tw = Thumbwheel::new(Arc::clone(chan), slot, info.index);
820        // Consume the getInfo error here, before the next await: Hidpp20Error
821        // isn't Send, so holding it across an await would make this future
822        // (spawned on tokio) non-Send.
823        let wheel_info = match tw.get_info().await {
824            Ok(twinfo) => Some(twinfo),
825            Err(e) => {
826                warn!(error = ?e, "thumb wheel getInfo failed");
827                None
828            }
829        };
830        // Divert whenever capture was requested: rotation rebinds and the
831        // sensitivity multiplier need the diverted event stream even on wheels
832        // that report no single-tap capability (e.g. MX Master 4) — lacking the
833        // tap only means a bound click can never fire.
834        if wheel_info.is_some_and(|info| !info.supports_single_tap) {
835            debug!("thumb wheel reports no single tap — click not capturable");
836        }
837        // Store ownership before the write: a transport error cannot prove
838        // whether firmware applied diversion, so rollback must cover it too.
839        armed.thumb = Some(ArmedThumbwheel {
840            wheel: tw,
841            info: wheel_info,
842        });
843        if let Some(thumb) = armed.thumb.as_ref()
844            && let Err(error) = thumb.wheel.divert(thumb.direction()).await
845        {
846            return Err(GestureError::Hidpp(format!("{error:?}")));
847        }
848    }
849    Ok(())
850}
851
852async fn arm_reprog_control(
853    rc: &ReprogControlsV4,
854    cid: u16,
855    raw_xy: bool,
856    reporting: &mut Vec<ArmedReporting>,
857) -> Result<(), GestureError> {
858    let original = rc
859        .get_cid_reporting(cid)
860        .await
861        .map_err(|error| GestureError::Hidpp(format!("{error:?}")))?;
862    if original.diverted {
863        // Left over from a session that never tore down (agent killed, or
864        // another Logitech app). Worth a line: it is the state that used to be
865        // replayed on restore, leaving the button dead.
866        debug!(cid, "control was already diverted before arming");
867    }
868    let change = divert_change(original, raw_xy);
869    // Record ownership before the write: a transport error does not prove the
870    // firmware rejected the command, so rollback must cover this CID too.
871    reporting.push(ArmedReporting { cid, original });
872    rc.set_cid_reporting_full(cid, change)
873        .await
874        .map_err(|error| GestureError::Hidpp(format!("{error:?}")))?;
875    Ok(())
876}
877
878/// The [`ButtonId`] a gesture-source CID dispatches as, per
879/// [`GESTURE_SOURCE_BUTTONS`]; `None` for a CID that is not a gesture source.
880/// A spec listing an unknown CID therefore never begins a hold — the press is
881/// dropped rather than misattributed.
882fn gesture_source_button(cid: u16) -> Option<ButtonId> {
883    GESTURE_SOURCE_BUTTONS
884        .into_iter()
885        .find(|&(c, _)| c == cid)
886        .map(|(_, button)| button)
887}
888
889fn captured_gesture_button(cid: u16, gesture_button_cids: &[(u16, ButtonId)]) -> Option<ButtonId> {
890    gesture_source_button(cid).or_else(|| {
891        gesture_button_cids
892            .iter()
893            .find(|&&(candidate, _)| candidate == cid)
894            .map(|&(_, button)| button)
895    })
896}
897
898/// Read the device's full reprogrammable-control table in one pass, so we can
899/// test several CIDs without rescanning per control.
900pub(crate) async fn enumerate_controls(
901    rc: &ReprogControlsV4,
902) -> Result<Vec<reprog_controls::CtrlIdInfo>, GestureError> {
903    let count = rc
904        .get_count()
905        .await
906        .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?;
907    let mut controls = Vec::with_capacity(usize::from(count));
908    for index in 0..count {
909        controls.push(
910            rc.get_ctrl_id_info(index)
911                .await
912                .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?,
913        );
914    }
915    Ok(controls)
916}
917
918/// Update `acc` and emit on a decoded `0x1b04` event: preserve physical button
919/// edges, and commit a gesture swipe the instant it crosses the threshold
920/// (mid-swipe, like Options+) rather than on release.
921fn handle_reprog_with_gesture_buttons(
922    acc: &mut CaptureAccum,
923    event: RawControlEvent,
924    gesture_cids: &[u16],
925    dpi_cids: &[u16],
926    gesture_button_cids: &[(u16, ButtonId)],
927    button_cids: &[(u16, ButtonId)],
928    sink: &mpsc::UnboundedSender<CapturedInput>,
929) {
930    match event {
931        RawControlEvent::DivertedButtons(cids) => {
932            // The swipe accumulator belongs to the raw-XY gesture diverts.
933            // When a gesture-source control is instead diverted as a plain
934            // button (a single binding, not gesture mode), its press must flow
935            // through the `button_cids` loop only — not also emit a click.
936            let held: Vec<(u16, ButtonId)> = gesture_cids
937                .iter()
938                .filter(|cid| cids.contains(cid))
939                .filter_map(|&cid| gesture_source_button(cid).map(|b| (cid, b)))
940                .chain(
941                    gesture_button_cids
942                        .iter()
943                        .copied()
944                        .filter(|(cid, _)| cids.contains(cid)),
945                )
946                .collect();
947            acc.hold = match std::mem::take(&mut acc.hold) {
948                // The holder is still down. While a second armed source is
949                // held alongside it, unattributed raw-XY motion is dropped
950                // (see [`HoldState::Holding::overlap`]).
951                HoldState::Holding {
952                    cid,
953                    button,
954                    swipe,
955                    skip_first_raw_xy,
956                    ..
957                } if cids.contains(&cid) => HoldState::Holding {
958                    cid,
959                    button,
960                    swipe,
961                    overlap: held.len() > 1,
962                    skip_first_raw_xy,
963                },
964                previous => {
965                    // No holder, or the holder released: a released hold that
966                    // never committed a direction is a plain click...
967                    if let HoldState::Holding {
968                        button, mut swipe, ..
969                    } = previous
970                        && swipe.end()
971                    {
972                        debug!(%button, "gesture click");
973                        let _ = sink.send(CapturedInput::Gesture(button, GestureDirection::Click));
974                    }
975                    // ...and the first still-held source begins (or takes
976                    // over) the hold. A source not down in the previous event
977                    // is a fresh touch, so the panel's contact-jump discard
978                    // applies; one that was already held has had its jump
979                    // dropped during the overlap.
980                    match held.first() {
981                        Some(&(cid, button)) => begin_hold(
982                            cid,
983                            button,
984                            held.len() > 1,
985                            cid == reprog_controls::HAPTIC_PANEL_CID
986                                && !acc.gestures_down.contains(&cid),
987                        ),
988                        None => HoldState::Idle,
989                    }
990                }
991            };
992            // Gesture semantics stay separate from the physical lifecycle:
993            // click/swipe remains one completed action, while every armed
994            // source also contributes one rising and one falling edge to the
995            // shared button runtime.
996            for &cid in &acc.gestures_down {
997                if !held.iter().any(|(held_cid, _)| *held_cid == cid)
998                    && let Some(button) = captured_gesture_button(cid, gesture_button_cids)
999                {
1000                    let _ = sink.send(CapturedInput::ButtonUp(button));
1001                }
1002            }
1003            for &(cid, button) in &held {
1004                if !acc.gestures_down.contains(&cid) {
1005                    let _ = sink.send(CapturedInput::ButtonDown(button));
1006                }
1007            }
1008            acc.gestures_down = held.into_iter().map(|(cid, _)| cid).collect();
1009
1010            let dpi_down = dpi_cids.iter().any(|cid| cids.contains(cid));
1011            if dpi_down && !acc.dpi_down {
1012                let _ = sink.send(CapturedInput::ButtonDown(ButtonId::DpiToggle));
1013            } else if !dpi_down && acc.dpi_down {
1014                let _ = sink.send(CapturedInput::ButtonUp(ButtonId::DpiToggle));
1015            }
1016            acc.dpi_down = dpi_down;
1017
1018            for &(cid, button) in button_cids {
1019                let down = cids.contains(&cid);
1020                let was_down = acc.buttons_down.contains(&cid);
1021                if down && !was_down {
1022                    let _ = sink.send(CapturedInput::ButtonDown(button));
1023                    acc.buttons_down.push(cid);
1024                } else if !down && was_down {
1025                    let _ = sink.send(CapturedInput::ButtonUp(button));
1026                    acc.buttons_down.retain(|&c| c != cid);
1027                }
1028            }
1029        }
1030        RawControlEvent::RawXy { dx, dy } => {
1031            handle_raw_xy(acc, dx, dy, sink);
1032        }
1033    }
1034}
1035
1036fn handle_raw_xy(
1037    acc: &mut CaptureAccum,
1038    dx: i16,
1039    dy: i16,
1040    sink: &mpsc::UnboundedSender<CapturedInput>,
1041) {
1042    // Motion is attributed to the holding source; outside a hold the report
1043    // is stray and dropped.
1044    let HoldState::Holding {
1045        button,
1046        swipe,
1047        overlap,
1048        skip_first_raw_xy,
1049        ..
1050    } = &mut acc.hold
1051    else {
1052        return;
1053    };
1054    // While two armed sources are held the report could belong to either
1055    // control — drop it rather than miscommit a swipe through the holder's map.
1056    if *overlap {
1057        return;
1058    }
1059    // The haptic panel's first sample after contact is a position jump;
1060    // summing it would commit a bogus direction instantly.
1061    if *skip_first_raw_xy {
1062        *skip_first_raw_xy = false;
1063        return;
1064    }
1065    // Commit the instant a clean direction emerges (mid-swipe, once per hold);
1066    // the accumulator gates on hold duration internally and drops travel that
1067    // arrives outside a hold.
1068    if let Some(direction) = swipe.accumulate(i32::from(dx), i32::from(dy)) {
1069        debug!(?direction, %button, "gesture committed");
1070        let _ = sink.send(CapturedInput::Gesture(*button, direction));
1071    }
1072}
1073
1074/// Test seam for the pre-existing raw-XY/plain-button cases, none of which
1075/// carries a standard-button gesture hold.
1076#[cfg(test)]
1077fn handle_reprog(
1078    acc: &mut CaptureAccum,
1079    event: RawControlEvent,
1080    gesture_cids: &[u16],
1081    dpi_cids: &[u16],
1082    button_cids: &[(u16, ButtonId)],
1083    sink: &mpsc::UnboundedSender<CapturedInput>,
1084) {
1085    handle_reprog_with_gesture_buttons(acc, event, gesture_cids, dpi_cids, &[], button_cids, sink);
1086}
1087#[cfg(test)]
1088mod tests;