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}