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