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}