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