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