Skip to main content

openlogi_device/session/
capture.rs

1//! The channel lifecycle every capture session shares.
2//!
3//! A capture session arms a device-specific set of controls and then does the
4//! same thing whatever those controls are: publish its channel for hardware
5//! writes to reuse, listen for diverted reports, re-arm when the device
6//! reconnects, stop when asked or when inventory replaces the channel, and
7//! hand every control back before it stops listening. [`run_capture`] is that
8//! sequence. [`ArmedCapture`] is what differs between the gesture and the
9//! keyboard session: which controls are armed and what their reports mean.
10
11mod liveness;
12
13use std::sync::Arc;
14use std::time::Duration;
15
16use hidpp::{
17    device::Device,
18    feature::{
19        CreatableFeature, EmittingFeature,
20        root::RootFeature,
21        wireless_device_status::{WirelessDeviceStatusEvent, WirelessDeviceStatusFeature},
22    },
23    protocol::v20,
24};
25use tokio::sync::{mpsc, oneshot};
26use tokio::time::Instant;
27use tracing::{debug, info, warn};
28
29use liveness::{CaptureLiveness, ChannelActivity, LivenessDecision, PingOutcome};
30
31use super::capture_restore::{
32    CaptureChannelSlot, CaptureError, CaptureSessionOutcome, CaptureStop, PendingCaptureRestore,
33    drop_listener_after, restore_after_stop, stop_for_current_publication, wait_for_channel_change,
34};
35use super::gesture::CapturedInput;
36use crate::{ChannelRegistry, DeviceIoGate, DeviceRoute, SharedChannel};
37
38/// How long a reconnected device gets before its diversions are re-issued.
39/// The reconnect broadcast arrives the instant the link is back, occasionally
40/// before the device accepts feature writes again.
41const REARM_SETTLE_DELAY: Duration = Duration::from_millis(200);
42
43/// What the process running a capture session hands it: where inputs go, when
44/// to stop, and the handles that tie the session to the rest of the device
45/// layer.
46pub struct CaptureHost<'a> {
47    /// Receives every [`CapturedInput`] the session decodes.
48    pub sink: mpsc::UnboundedSender<CapturedInput>,
49    /// Resolves, or is dropped, when the session should restore its controls
50    /// and return.
51    pub shutdown: oneshot::Receiver<()>,
52    /// Where the session publishes its open channel so bounded hardware
53    /// writes reuse it instead of opening a second connection.
54    pub channel_slot: CaptureChannelSlot,
55    /// Inventory's channel publications: the session runs on the one current
56    /// for its route and stops when that publication is replaced or removed.
57    pub registry: &'a ChannelRegistry,
58    /// Host device-I/O gate: the session refuses to start while it is closed
59    /// and sends nothing until it reopens.
60    pub device_io: DeviceIoGate,
61}
62
63impl CaptureHost<'_> {
64    /// The channel inventory currently publishes for `route`, once host
65    /// device I/O is allowed. A registry miss is not retried here: the caller
66    /// runs the session again after a later inventory publication.
67    pub(super) fn channel_for(&self, route: &DeviceRoute) -> Result<SharedChannel, CaptureError> {
68        let shared = self
69            .registry
70            .lookup(route)
71            .ok_or(CaptureError::DeviceNotFound)?;
72        self.device_io.ensure_allowed()?;
73        Ok(shared)
74    }
75}
76
77/// Prove the device behind `shared` answers HID++ before arming anything on
78/// it.
79pub(super) async fn open_device(shared: &SharedChannel) -> Result<Device, CaptureError> {
80    let device_index = shared.device_index();
81    Device::new(Arc::clone(shared.channel()), device_index)
82        .await
83        .map_err(|_| CaptureError::DeviceUnreachable(device_index))
84}
85
86/// Whether a session probes a channel that has gone quiet.
87///
88/// A session's channel is the sole delivery path for every control it
89/// diverts, and a channel whose input-report delivery dies (observed on macOS
90/// with concurrent opens of one node: writes accepted, replies and events
91/// silently routed elsewhere) turns every captured control to dead air with
92/// nothing to notice.
93#[derive(Clone, Copy, PartialEq, Eq)]
94pub(super) enum Liveness {
95    /// Ping the device through the session's channel once it has been wholly
96    /// idle. Consecutive all-silent pings mean the channel, not the device, is
97    /// gone: a sleeping or unreachable device can still send an HID++ error
98    /// reply, which proves delivery and resets the count. A transport or
99    /// setup error proves neither delivery nor silence, so it stops the
100    /// session at once. Stopping lets the manager re-arm on a fresh channel.
101    Watched,
102    /// Never probe: only inventory replacing the channel ends the session.
103    Unwatched,
104}
105
106/// The device-specific half of a capture session: the controls one session
107/// armed, what their reports mean, and how they are re-armed and handed back.
108/// [`run_capture`] owns the rest.
109pub(super) trait ArmedCapture {
110    /// What this capture is called in the session's logs.
111    const NAME: &'static str;
112    /// Whether the session probes its channel once it goes quiet.
113    const LIVENESS: Liveness;
114
115    /// Log what was armed, once capture is live.
116    fn log_active(&self, device_index: u8, wake_rearm: bool);
117
118    /// Build the handler for every unsolicited report on the session's
119    /// channel. It runs on the channel's read thread, by shared reference.
120    fn report_handler(
121        &self,
122        device_index: u8,
123        sink: mpsc::UnboundedSender<CapturedInput>,
124    ) -> impl Fn(&v20::Message) + Send + Sync + 'static;
125
126    /// Forget input state that a device power-cycle invalidated.
127    fn reset_input_state(&self) {}
128
129    /// Re-issue every diversion after a wireless reconnect. Failures are
130    /// logged, not propagated: the next reconnection broadcast retries.
131    async fn rearm(&self);
132
133    /// Convert all armed firmware state into the one capability that can
134    /// release it. Consuming `self` prevents a session and a restore retry
135    /// from both claiming ownership at once.
136    fn into_pending(self, retired: &SharedChannel) -> Option<PendingCaptureRestore>;
137}
138
139/// Run one armed capture on `shared` until it is told to stop or inventory
140/// replaces the channel, then hand every diverted control back.
141///
142/// A normal stop restores every control before returning; transport
143/// replacement or loss may return [`CaptureSessionOutcome::RestorePending`]
144/// for the caller to retry on the current inventory channel.
145pub(super) async fn run_capture<A: ArmedCapture>(
146    shared: SharedChannel,
147    armed: A,
148    host: CaptureHost<'_>,
149) -> CaptureSessionOutcome {
150    let CaptureHost {
151        sink,
152        shutdown,
153        channel_slot,
154        registry,
155        device_io,
156    } = host;
157    let chan = Arc::clone(shared.channel());
158    let device_index = shared.device_index();
159
160    // Publish this device's open channel so hardware writes (DPI, SmartShift,
161    // Fn-lock) reuse it instead of opening the same HID node a second time.
162    // Cleared on the way out.
163    if let Ok(mut slot) = channel_slot.write() {
164        *slot = Some(shared.clone());
165    }
166
167    let activity = match A::LIVENESS {
168        Liveness::Watched => Some(Arc::new(ChannelActivity::default())),
169        Liveness::Unwatched => None,
170    };
171    let listener = chan.add_msg_listener_guarded({
172        let activity = activity.clone();
173        let on_report = armed.report_handler(device_index, sink.clone());
174        move |raw, matched| {
175            // Every parsed inbound HID++ report proves this channel's read
176            // path is alive, including responses matched to another request.
177            if let Some(activity) = &activity {
178                activity.record();
179            }
180            if matched {
181                return;
182            }
183            on_report(&v20::Message::from(raw));
184        }
185    });
186
187    // Wireless devices drop their diverted-control state when they
188    // power-cycle (idle sleep, power switch, Easy-Switch host change) — the
189    // reconnection broadcast on `0x1d4b` is the firmware asking the host to
190    // reconfigure. Re-arm on every broadcast, or the captured controls
191    // silently revert to their native functions after the first nap.
192    let root = RootFeature::new(Arc::clone(&chan), device_index, 0);
193    let wireless = root
194        .get_feature(WirelessDeviceStatusFeature::ID)
195        .await
196        .ok()
197        .flatten()
198        .map(|info| WirelessDeviceStatusFeature::new(Arc::clone(&chan), device_index, info.index));
199    armed.log_active(device_index, wireless.is_some());
200    let stop = monitor(
201        CaptureMonitor {
202            armed: &armed,
203            root: &root,
204            device_index,
205            registry,
206            shared: &shared,
207            activity: activity.as_deref(),
208        },
209        wireless,
210        shutdown,
211        device_io,
212    )
213    .await;
214
215    // The slot is one last-writer-wins cell shared by every session, so a
216    // sibling may have published its own channel after ours. Clear it only
217    // while it still holds *this* session's channel — evicting the sibling's
218    // would silently demote its hardware writes to the fresh-open slow path.
219    if let Ok(mut slot) = channel_slot.write()
220        && slot
221            .as_ref()
222            .is_some_and(|published| Arc::ptr_eq(published.channel(), &chan))
223    {
224        *slot = None;
225    }
226    // Keep accepting reports until firmware restoration is complete. The
227    // agent drains this listener's forwarding task before publishing ordered
228    // Done, so this session remains the sole owner of every input captured
229    // while its controls could still be diverted.
230    let pending = armed.into_pending(&shared);
231    let outcome = drop_listener_after(listener, restore_after_stop(stop, pending, registry)).await;
232    debug!(index = device_index, capture = A::NAME, "capture stopped");
233    outcome
234}
235
236/// Borrowed state used while monitoring one armed capture session.
237struct CaptureMonitor<'a, A> {
238    armed: &'a A,
239    root: &'a RootFeature,
240    device_index: u8,
241    registry: &'a ChannelRegistry,
242    shared: &'a SharedChannel,
243    /// What the listener records, on a [`Liveness::Watched`] session.
244    activity: Option<&'a ChannelActivity>,
245}
246
247/// The idle watchdog of one watched session: what its listener records, and
248/// the deadline and strike state derived from it.
249struct Watchdog<'a> {
250    activity: &'a ChannelActivity,
251    liveness: CaptureLiveness,
252}
253
254impl<'a> Watchdog<'a> {
255    fn new(activity: &'a ChannelActivity) -> Self {
256        Self {
257            activity,
258            liveness: CaptureLiveness::new(Instant::now(), activity.seq()),
259        }
260    }
261
262    /// Start a full idle interval over and clear any strike.
263    fn restart(&mut self) {
264        self.liveness
265            .record_activity(Instant::now(), self.activity.seq());
266    }
267}
268
269/// The next report on a watched channel. Never resolves on an unwatched one.
270async fn next_activity(watchdog: Option<&Watchdog<'_>>) -> u64 {
271    match watchdog {
272        Some(watchdog) => {
273            watchdog
274                .activity
275                .changed_after(watchdog.liveness.activity_seq())
276                .await
277        }
278        None => std::future::pending().await,
279    }
280}
281
282/// When a watched channel has been idle for a full interval. Never resolves
283/// on an unwatched one.
284async fn idle_deadline(watchdog: Option<&Watchdog<'_>>) {
285    match watchdog {
286        Some(watchdog) => tokio::time::sleep_until(watchdog.liveness.idle_deadline()).await,
287        None => std::future::pending().await,
288    }
289}
290
291/// Keep a capture session alive and reapply its volatile diversions whenever
292/// the device announces a reconnect. Returns only the typed reason capture
293/// stopped; restoration performs a fresh registry lookup after monitoring.
294async fn monitor<A: ArmedCapture>(
295    context: CaptureMonitor<'_, A>,
296    wireless: Option<WirelessDeviceStatusFeature>,
297    shutdown: oneshot::Receiver<()>,
298    mut device_io: DeviceIoGate,
299) -> CaptureStop {
300    let mut wake_events = wireless.as_ref().map(EmittingFeature::listen);
301    let mut shutdown = std::pin::pin!(shutdown);
302    let mut watchdog = context.activity.map(Watchdog::new);
303    loop {
304        if !device_io.allows_io() {
305            if !device_io.wait_until_allowed().await {
306                return stop_for_current_publication(context.registry, context.shared);
307            }
308            // Time asleep is not channel idleness. Give the transport a full
309            // quiet interval after visible resume and clear any pre-sleep
310            // strike before considering a liveness ping.
311            if let Some(watchdog) = &mut watchdog {
312                watchdog.restart();
313            }
314        }
315        tokio::select! {
316            biased;
317
318            allowed = device_io.changed() => {
319                match allowed {
320                    Some(true) => {
321                        if let Some(watchdog) = &mut watchdog {
322                            watchdog.restart();
323                        }
324                    }
325                    Some(false) => {}
326                    None => return stop_for_current_publication(context.registry, context.shared),
327                }
328            }
329            transition = wait_for_channel_change(
330                context.registry,
331                context.shared,
332            ) => {
333                info!(index = context.device_index, capture = A::NAME, "inventory replaced or removed capture channel — restarting session");
334                return transition;
335            }
336            _ = &mut shutdown => {
337                // Shutdown and inventory replacement can become ready on the
338                // same turn. Prefer the typed channel transition so teardown
339                // never blindly writes through a transport already known to
340                // be obsolete.
341                return stop_for_current_publication(context.registry, context.shared);
342            }
343            event = async {
344                match wake_events.as_ref() {
345                    Some(events) => events.recv().await.ok(),
346                    None => std::future::pending().await,
347                }
348            } => {
349                let Some(WirelessDeviceStatusEvent::StatusBroadcast(broadcast)) = event else {
350                    wake_events = None;
351                    continue;
352                };
353                info!(?broadcast, capture = A::NAME, "device reconnected — re-arming capture");
354                context.armed.reset_input_state();
355                tokio::time::sleep(REARM_SETTLE_DELAY).await;
356                if device_io.allows_io() {
357                    context.armed.rearm().await;
358                }
359            }
360            seq = next_activity(watchdog.as_ref()) => {
361                if let Some(watchdog) = &mut watchdog {
362                    watchdog.liveness.record_activity(Instant::now(), seq);
363                }
364            }
365            () = idle_deadline(watchdog.as_ref()) => {
366                let Some(watchdog) = &mut watchdog else {
367                    continue;
368                };
369                if !watchdog
370                    .liveness
371                    .ping_due(Instant::now(), watchdog.activity.seq())
372                {
373                    continue;
374                }
375                let outcome = match context.root.ping(0x5a).await {
376                    Err(v20::Hidpp20Error::Channel(
377                        hidpp::channel::ChannelError::Timeout
378                        | hidpp::channel::ChannelError::NoResponse,
379                    )) => PingOutcome::AllSilent,
380                    // A pong, feature error, or unsupported response all prove
381                    // that this channel still receives device replies.
382                    Ok(_)
383                    | Err(
384                        v20::Hidpp20Error::Feature(_)
385                        | v20::Hidpp20Error::UnsupportedResponse,
386                    ) => PingOutcome::Delivered,
387                    Err(_) => PingOutcome::ChannelFailed,
388                };
389                if watchdog.liveness.finish_ping(
390                    Instant::now(),
391                    watchdog.activity.seq(),
392                    outcome,
393                ) == LivenessDecision::Restart {
394                    warn!(index = context.device_index, capture = A::NAME, "capture channel stopped delivering — restarting session on a fresh channel");
395                    return stop_for_current_publication(context.registry, context.shared);
396                }
397            }
398        }
399    }
400}