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