Skip to main content

openlogi_device/session/
capture_restore.rs

1//! Firmware ownership and channel-lifecycle primitives shared by mouse and
2//! keyboard capture without coupling their manager loops or input semantics.
3
4use std::fmt;
5use std::future::Future;
6use std::sync::{Arc, RwLock, Weak};
7
8use hidpp::channel::HidppChannel;
9use thiserror::Error;
10
11use crate::backend::BackendError;
12use crate::reprog_controls::{self, ReprogControlsV4};
13use crate::thumbwheel::Thumbwheel;
14use crate::{ChannelRegistry, DeviceRoute, SharedChannel};
15
16/// Shared slot holding the active capture session's open channel, so bounded
17/// hardware writes can reuse it instead of opening a second connection.
18pub type CaptureChannel = Arc<RwLock<Option<SharedChannel>>>;
19
20/// Why a capture session could not start (or had to stop).
21#[derive(Debug, Error)]
22pub enum GestureError {
23    /// HID transport-level failure while enumerating or opening the device.
24    #[error("HID transport error")]
25    Hid(#[from] BackendError),
26    /// No connected device matched the capture route.
27    #[error("no connected device matched the capture route")]
28    DeviceNotFound,
29    /// The device at the target index did not answer HID++.
30    #[error("device at index {0:#04x} did not respond to HID++")]
31    DeviceUnreachable(u8),
32    /// A HID++ feature call returned an error; inner string carries context.
33    #[error("HID++ protocol error: {0}")]
34    Hidpp(String),
35}
36
37/// One `0x1b04` control whose original reporting state can restore a failed
38/// or completed capture transaction.
39#[derive(Clone, Copy)]
40pub(crate) struct ArmedReporting {
41    pub(crate) cid: u16,
42    pub(crate) original: reprog_controls::CidReporting,
43}
44
45/// A non-empty set of `0x1b04` controls owned through one feature index.
46pub(crate) struct ReprogRestore {
47    feature_index: u8,
48    controls: Vec<ArmedReporting>,
49}
50
51impl ReprogRestore {
52    pub(crate) fn new(feature_index: u8, controls: Vec<ArmedReporting>) -> Option<Self> {
53        (!controls.is_empty()).then_some(Self {
54            feature_index,
55            controls,
56        })
57    }
58}
59
60#[derive(Clone, Copy)]
61pub(crate) enum CaptureStop {
62    /// The owner deliberately requested teardown.
63    Shutdown,
64    /// Inventory removed or replaced the channel that armed capture.
65    ChannelChanged,
66}
67
68/// Whether a retry may use the transport on which capture originally ran.
69#[derive(Clone, Copy)]
70enum RetiredChannelPolicy {
71    /// Inventory declared the transport obsolete; wait for another current
72    /// publication instead of writing underneath its replacement.
73    ReplacementOnly,
74    /// Capture stopped normally but a restore write failed, so retrying the
75    /// still-current original transport is safe.
76    CurrentAllowed,
77}
78
79/// How a capture session completed its firmware teardown.
80#[must_use = "a pending restore must be retained until firmware ownership is released"]
81pub enum CaptureSessionOutcome {
82    /// Every diverted control was restored before the session returned.
83    Restored,
84    /// Firmware restoration is incomplete. The caller must retain this token
85    /// and retry it before arming a successor for the same physical device.
86    RestorePending(PendingCaptureRestore),
87}
88
89/// A capture setup failure plus any firmware ownership its rollback could not
90/// release.
91#[derive(Debug, Error)]
92#[error("{error}")]
93pub struct CaptureSessionFailure {
94    #[source]
95    error: GestureError,
96    pending_restore: Option<PendingCaptureRestore>,
97}
98
99impl CaptureSessionFailure {
100    pub(crate) fn clean(error: GestureError) -> Self {
101        Self {
102            error,
103            pending_restore: None,
104        }
105    }
106
107    pub(crate) fn with_pending(error: GestureError, pending: PendingCaptureRestore) -> Self {
108        Self {
109            error,
110            pending_restore: Some(pending),
111        }
112    }
113
114    /// Split the setup error from firmware ownership the caller must retain.
115    #[must_use]
116    pub fn into_parts(self) -> (GestureError, Option<PendingCaptureRestore>) {
117        (self.error, self.pending_restore)
118    }
119}
120
121impl From<GestureError> for CaptureSessionFailure {
122    fn from(error: GestureError) -> Self {
123        Self::clean(error)
124    }
125}
126
127/// Opaque firmware ownership that survives the transport which armed it.
128///
129/// The token owns its original route and is consumed by every retry. A failed
130/// retry returns the token through [`CaptureSessionOutcome::RestorePending`],
131/// so callers cannot accidentally treat a borrowed `false` as completion.
132pub struct PendingCaptureRestore {
133    route: DeviceRoute,
134    retired_channel: Weak<HidppChannel>,
135    retired_policy: RetiredChannelPolicy,
136    reprog: Option<ReprogRestore>,
137    thumb_index: Option<u8>,
138}
139
140impl fmt::Debug for PendingCaptureRestore {
141    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142        f.debug_struct("PendingCaptureRestore")
143            .field("route", &self.route)
144            .field(
145                "reporting_count",
146                &self
147                    .reprog
148                    .as_ref()
149                    .map_or(0, |reprog| reprog.controls.len()),
150            )
151            .field("has_thumbwheel", &self.thumb_index.is_some())
152            .finish_non_exhaustive()
153    }
154}
155
156impl PendingCaptureRestore {
157    pub(crate) fn new(
158        retired: &SharedChannel,
159        reprog: Option<ReprogRestore>,
160        thumb_index: Option<u8>,
161    ) -> Option<Self> {
162        if reprog.is_none() && thumb_index.is_none() {
163            return None;
164        }
165        Some(Self {
166            route: retired.route().clone(),
167            retired_channel: Arc::downgrade(retired.channel()),
168            retired_policy: RetiredChannelPolicy::ReplacementOnly,
169            reprog,
170            thumb_index,
171        })
172    }
173
174    /// Retry through the exact-route channel currently published by inventory.
175    ///
176    /// Success is accepted only if the same publication remains current after
177    /// every awaited restore write. A concurrent replacement returns this
178    /// token as pending so the new winner is restored on the next attempt.
179    pub async fn retry(self, registry: &ChannelRegistry) -> CaptureSessionOutcome {
180        let Some(current) = registry.lookup(&self.route) else {
181            return CaptureSessionOutcome::RestorePending(self);
182        };
183        if matches!(self.retired_policy, RetiredChannelPolicy::ReplacementOnly)
184            && self
185                .retired_channel
186                .upgrade()
187                .is_some_and(|retired| Arc::ptr_eq(current.channel(), &retired))
188        {
189            return CaptureSessionOutcome::RestorePending(self);
190        }
191        let restored = self.restore_on(&current).await;
192        if restored && registry.is_current(&current) {
193            CaptureSessionOutcome::Restored
194        } else {
195            CaptureSessionOutcome::RestorePending(self)
196        }
197    }
198
199    /// Restore on the original standalone channel, where no registry
200    /// publication can supersede the session.
201    pub(crate) async fn restore_standalone(self, retired: &SharedChannel) -> CaptureSessionOutcome {
202        if self.restore_on(retired).await {
203            CaptureSessionOutcome::Restored
204        } else {
205            CaptureSessionOutcome::RestorePending(self)
206        }
207    }
208
209    /// Permit a retry on the original channel after a normal teardown write
210    /// failed while that publication was still current.
211    pub(crate) fn allow_current_channel(mut self) -> Self {
212        self.retired_policy = RetiredChannelPolicy::CurrentAllowed;
213        self
214    }
215
216    async fn restore_on(&self, current: &SharedChannel) -> bool {
217        let channel = Arc::clone(current.channel());
218        let device_index = current.device_index();
219        let mut restored = true;
220        if let Some(reprog) = &self.reprog {
221            let controls =
222                ReprogControlsV4::new(channel.clone(), device_index, reprog.feature_index);
223            for &reporting in &reprog.controls {
224                restored &= restore_reporting(&controls, reporting, "captured control").await;
225            }
226        }
227        if let Some(feature_index) = self.thumb_index {
228            let thumbwheel = Thumbwheel::new(channel, device_index, feature_index);
229            restored &= restore_result(thumbwheel.undivert().await, "thumb wheel");
230        }
231        restored
232    }
233}
234
235/// Roll back a partially armed session without losing firmware ownership when
236/// any compensating write fails.
237pub(crate) async fn rollback_capture_start(
238    error: GestureError,
239    pending: Option<PendingCaptureRestore>,
240    retired: &SharedChannel,
241    registry: Option<&ChannelRegistry>,
242) -> CaptureSessionFailure {
243    let Some(pending) = pending else {
244        return CaptureSessionFailure::clean(error);
245    };
246    let pending = pending.allow_current_channel();
247    let outcome = match registry {
248        Some(registry) => pending.retry(registry).await,
249        None => pending.restore_standalone(retired).await,
250    };
251    match outcome {
252        CaptureSessionOutcome::Restored => CaptureSessionFailure::clean(error),
253        CaptureSessionOutcome::RestorePending(pending) => {
254            CaptureSessionFailure::with_pending(error, pending)
255        }
256    }
257}
258
259/// Release firmware ownership after an active session stops.
260pub(crate) async fn restore_after_stop(
261    stop: CaptureStop,
262    pending: Option<PendingCaptureRestore>,
263    retired: &SharedChannel,
264    registry: Option<&ChannelRegistry>,
265) -> CaptureSessionOutcome {
266    let Some(pending) = pending else {
267        return CaptureSessionOutcome::Restored;
268    };
269    match stop {
270        CaptureStop::Shutdown => {
271            let pending = pending.allow_current_channel();
272            match registry {
273                Some(registry) => pending.retry(registry).await,
274                None => pending.restore_standalone(retired).await,
275            }
276        }
277        CaptureStop::ChannelChanged => match registry {
278            Some(registry) => pending.retry(registry).await,
279            None => CaptureSessionOutcome::RestorePending(pending),
280        },
281    }
282}
283
284/// Re-check inventory at shutdown so a simultaneously ready stop request does
285/// not win over publication replacement and write through a retired channel.
286pub(crate) fn stop_for_current_publication(
287    registry: Option<&ChannelRegistry>,
288    retired: &SharedChannel,
289) -> CaptureStop {
290    if registry.is_some_and(|registry| !registry.is_current(retired)) {
291        CaptureStop::ChannelChanged
292    } else {
293        // A standalone session has no publication that can supersede it.
294        CaptureStop::Shutdown
295    }
296}
297
298/// Wait until inventory removes or replaces the channel on which capture was
299/// armed. The returned reason never carries a cached replacement across an
300/// await; restoration performs a fresh registry lookup instead.
301pub(crate) async fn wait_for_channel_change(
302    registry: Option<&ChannelRegistry>,
303    retired: &SharedChannel,
304) -> CaptureStop {
305    let Some(registry) = registry else {
306        return std::future::pending().await;
307    };
308    let mut changes = registry.subscribe();
309    loop {
310        if !registry.is_current(retired) {
311            return CaptureStop::ChannelChanged;
312        }
313        if changes.changed().await.is_err() {
314            // The borrowed registry owns the sender, so this is unreachable;
315            // preserve standalone-like pending semantics if that invariant is
316            // ever changed rather than inventing a channel transition.
317            return std::future::pending().await;
318        }
319    }
320}
321
322/// Keep accepting diverted reports until firmware teardown has completed.
323pub(crate) async fn drop_listener_after<T, R>(listener: T, teardown: impl Future<Output = R>) -> R {
324    let result = teardown.await;
325    drop(listener);
326    result
327}
328
329/// Divert a control in the requested mode while preserving its remap target.
330pub(crate) fn divert_change(
331    reporting: reprog_controls::CidReporting,
332    raw_xy: bool,
333) -> reprog_controls::CidReportingChange {
334    reprog_controls::CidReportingChange {
335        diverted: Some(true),
336        raw_xy: Some(raw_xy),
337        remap: reporting.remap,
338        ..Default::default()
339    }
340}
341
342/// Restore one captured reporting record, preserving its remap target and
343/// touching only the diversion bits capture owns.
344pub(crate) async fn restore_reporting(
345    controls: &ReprogControlsV4,
346    reporting: ArmedReporting,
347    what: &str,
348) -> bool {
349    let result = controls
350        .set_cid_reporting_full(reporting.cid, undivert_change(reporting.original))
351        .await
352        .map(|_| ());
353    restore_result(result, what)
354}
355
356pub(crate) fn restore_result<E: fmt::Display>(result: Result<(), E>, what: &str) -> bool {
357    if let Err(error) = result {
358        tracing::warn!(%error, control = what, "failed to restore control mapping");
359        false
360    } else {
361        true
362    }
363}
364
365/// Clear diversion while preserving the control's original remap target.
366pub(crate) fn undivert_change(
367    reporting: reprog_controls::CidReporting,
368) -> reprog_controls::CidReportingChange {
369    reprog_controls::CidReportingChange {
370        diverted: Some(false),
371        raw_xy: Some(false),
372        remap: reporting.remap,
373        ..Default::default()
374    }
375}