Skip to main content

openlogi_device/session/host_switch/
restore.rs

1//! What a host-switch session owes the firmware, and how its teardown reports
2//! it. The token that carries an unfinished restore is `session::restore`'s.
3
4use std::{fmt, sync::Arc};
5
6use super::{ArmedControl, HostSwitchError, restore_host_controls};
7use crate::session::restore::{
8    PendingRestore, RestoreOutcome, RestorePlan, SessionFailure, rollback_start,
9};
10use crate::{ChannelRegistry, DeviceIoGate, SharedChannel, reprog_controls::ReprogControlsV4};
11
12/// How a host-switch session released its temporary firmware reporting state.
13#[must_use = "pending firmware restoration must be retained by the session manager"]
14pub enum HostSwitchSessionOutcome {
15    /// Every host control was restored before the session returned.
16    Restored {
17        /// Host requested by the keyboard, if the session ended on a key press.
18        requested_host: Option<u8>,
19    },
20    /// Restoration is incomplete and must precede any successor session.
21    RestorePending {
22        /// Host requested before teardown began, if any.
23        requested_host: Option<u8>,
24        /// Owned capability for retrying restoration on a current publication.
25        restore: PendingHostSwitchRestore,
26    },
27}
28
29impl HostSwitchSessionOutcome {
30    /// Split the transition intent from any retained firmware ownership.
31    #[must_use]
32    pub fn into_parts(self) -> (Option<u8>, Option<PendingHostSwitchRestore>) {
33        match self {
34            Self::Restored { requested_host } => (requested_host, None),
35            Self::RestorePending {
36                requested_host,
37                restore,
38            } => (requested_host, Some(restore)),
39        }
40    }
41}
42
43/// What a host-switch session writes to hand the keyboard's host controls
44/// back: the exact feature index, reporting mode, and original reporting bits
45/// of every control it armed.
46///
47/// Each write is bounded and tried twice (`restore_host_controls`); a control
48/// that still fails leaves the whole plan owed.
49pub struct HostSwitchRestorePlan {
50    feature_index: u8,
51    controls: Vec<ArmedControl>,
52}
53
54impl fmt::Debug for HostSwitchRestorePlan {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        f.debug_struct("HostSwitchRestorePlan")
57            .field("reporting_count", &self.controls.len())
58            .finish_non_exhaustive()
59    }
60}
61
62impl RestorePlan for HostSwitchRestorePlan {
63    async fn restore_on(&self, current: &SharedChannel) -> bool {
64        let controls = ReprogControlsV4::new(
65            Arc::clone(current.channel()),
66            current.device_index(),
67            self.feature_index,
68        );
69        restore_host_controls(&controls, &self.controls).await
70    }
71}
72
73/// Host-control restoration state that outlives the channel it was armed on.
74pub type PendingHostSwitchRestore = PendingRestore<HostSwitchRestorePlan>;
75
76/// Result of one bounded pending-restoration attempt.
77pub type HostSwitchRestoreOutcome = RestoreOutcome<HostSwitchRestorePlan>;
78
79/// A host-switch setup failure plus any rollback state still owned by OpenLogi.
80pub type HostSwitchSessionFailure = SessionFailure<HostSwitchError, HostSwitchRestorePlan>;
81
82impl PendingHostSwitchRestore {
83    /// The restore owed for `controls`, or `None` when nothing was armed.
84    pub(super) fn new(
85        retired: &SharedChannel,
86        feature_index: u8,
87        controls: Vec<ArmedControl>,
88    ) -> Option<Self> {
89        (!controls.is_empty()).then(|| {
90            Self::owing(
91                retired,
92                HostSwitchRestorePlan {
93                    feature_index,
94                    controls,
95                },
96            )
97        })
98    }
99}
100
101/// Roll back a partially armed session. Unlike capture's rollback this writes
102/// nothing while host device I/O is suspended: the ownership is returned for
103/// the manager to retry once the gate reopens.
104pub(super) async fn rollback_host_switch_start(
105    error: HostSwitchError,
106    pending: Option<PendingHostSwitchRestore>,
107    registry: &ChannelRegistry,
108    device_io: &DeviceIoGate,
109) -> HostSwitchSessionFailure {
110    if device_io.allows_io() {
111        return rollback_start(error, pending, registry).await;
112    }
113    match pending {
114        Some(pending) => {
115            HostSwitchSessionFailure::with_pending(error, pending.allow_current_channel())
116        }
117        None => HostSwitchSessionFailure::clean(error),
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::super::{
124        HostSwitchStop, HostSwitchStopReason, monitor_host_switch, run_host_switch_session,
125    };
126    use super::*;
127    use crate::DeviceRoute;
128    use crate::backend::NodeId;
129    use crate::channel::scripted::{ScriptedRawHidChannel, feature_error, scripted_channel};
130    use crate::reprog_controls::{CidReporting, ControlId};
131    use crate::session::host_switch::{ArmedControl, ReportingMode};
132
133    fn capture_keyboard(request: &[u8], fail_second_control: bool) -> Option<Vec<u8>> {
134        let mut response = vec![0; 20];
135        response[0] = 0x11;
136        response[1..4].copy_from_slice(&request[1..4]);
137        match (request[2], request[3] >> 4) {
138            (0, 1) => response[4] = 4,
139            (0, 0) => response[4] = 0x22,
140            (0x22, 0) => response[4] = if fail_second_control { 2 } else { 1 },
141            (0x22, 1) => {
142                if request[4] == 1 {
143                    return Some(feature_error(request, 0x08));
144                }
145                // Host key 1, divertable. Unrelated task/group fields are zero.
146                response[4..9].copy_from_slice(&[0, 0xd1, 0, 0, 0x20]);
147            }
148            (0x22, 2) => {
149                response[4..6].copy_from_slice(&request[4..6]);
150                // Original raw XY, persistent diversion, and force raw XY set.
151                response[6] = 0x54;
152            }
153            (0x22, 3) => return Some(request.to_vec()),
154            _ => return None,
155        }
156        Some(response)
157    }
158
159    #[tokio::test]
160    async fn failed_session_and_partial_arm_release_old_channels_and_restore_on_reconnect() {
161        for partial_arm in [false, true] {
162            let route = DeviceRoute::Direct {
163                vendor_id: 0x046d,
164                product_id: 0xb35b,
165            };
166            let node = NodeId::from("host-keyboard".to_owned());
167            let registry = ChannelRegistry::default();
168            let (raw, writes) = ScriptedRawHidChannel::with_dynamic_responder(move |request| {
169                if request[2] == 0x22 && request[3] >> 4 == 3 && request[6] & 1 == 0 {
170                    Some(feature_error(request, 0x08))
171                } else {
172                    capture_keyboard(request, partial_arm)
173                }
174            });
175            let channel = scripted_channel(raw).await;
176            let retired = Arc::downgrade(&channel);
177            registry.replace_node(node.clone(), [route.clone()], channel);
178            let (stop, stopped) = tokio::sync::oneshot::channel();
179            stop.send(HostSwitchStopReason::Graceful).unwrap();
180            let (_signal, gate) = crate::device_io_channel();
181
182            let outcome = tokio::time::timeout(
183                std::time::Duration::from_secs(2),
184                run_host_switch_session(route.clone(), stopped, &registry, gate),
185            )
186            .await
187            .expect("failed restoration must return ownership instead of looping");
188            let pending = match outcome {
189                Ok(HostSwitchSessionOutcome::RestorePending {
190                    requested_host,
191                    restore,
192                }) => {
193                    assert!(!partial_arm);
194                    assert_eq!(requested_host, None);
195                    restore
196                }
197                Err(failure) => {
198                    assert!(partial_arm);
199                    failure
200                        .into_parts()
201                        .1
202                        .expect("partial arm rollback must retain firmware ownership")
203                }
204                Ok(HostSwitchSessionOutcome::Restored { .. }) => {
205                    panic!("failed writes cannot be clean")
206                }
207            };
208            let reporting: Vec<_> = writes
209                .written_reports()
210                .into_iter()
211                .filter(|request| request[2] == 0x22 && request[3] >> 4 == 3)
212                .collect();
213            assert_eq!(
214                reporting.len(),
215                3,
216                "one arm, then two failed bounded restores"
217            );
218            assert_eq!(&reporting[0][4..7], &[0, 0xd1, 0x23]);
219            assert_eq!(&reporting[1][4..7], &[0, 0xd1, 0x32]);
220
221            registry.remove_node(&node);
222            assert!(
223                retired.upgrade().is_none(),
224                "pending ownership must release the dead channel"
225            );
226            let pending = match pending.retry(&registry).await {
227                HostSwitchRestoreOutcome::RestorePending(pending) => pending,
228                HostSwitchRestoreOutcome::Restored => panic!("absent route cannot be restored"),
229            };
230            let (raw, fresh) =
231                ScriptedRawHidChannel::with_responder(|request| Some(request.to_vec()));
232            registry.replace_node(node, [route], scripted_channel(raw).await);
233            assert!(matches!(
234                pending.retry(&registry).await,
235                HostSwitchRestoreOutcome::Restored
236            ));
237            let reporting = fresh.written_reports();
238            assert_eq!(reporting.len(), 1);
239            assert_eq!(&reporting[0][4..10], &[0, 0xd1, 0x32, 0, 0, 0]);
240        }
241    }
242
243    #[tokio::test]
244    async fn retirement_before_listener_subscription_is_observed_without_another_event() {
245        let route = DeviceRoute::Direct {
246            vendor_id: 0x046d,
247            product_id: 0xb35b,
248        };
249        let registry = ChannelRegistry::default();
250        let (raw, _) = ScriptedRawHidChannel::with_responder(|_| None);
251        let retired = SharedChannel::new(scripted_channel(raw).await, route);
252        let (_stop, stopped) = tokio::sync::oneshot::channel();
253        let (_presses, mut presses) = tokio::sync::mpsc::unbounded_channel();
254        let (_signal, gate) = crate::device_io_channel();
255        let result = tokio::time::timeout(
256            std::time::Duration::from_millis(100),
257            monitor_host_switch(stopped, &mut presses, &registry, &retired, gate),
258        )
259        .await
260        .expect("a retired publication needs no further notification");
261        assert_eq!(result, HostSwitchStop::ChannelChanged);
262    }
263
264    #[tokio::test]
265    async fn monitor_names_why_it_stopped_on_a_current_channel() {
266        let route = DeviceRoute::Direct {
267            vendor_id: 0x046d,
268            product_id: 0xb35b,
269        };
270        let registry = ChannelRegistry::default();
271        let (raw, _) = ScriptedRawHidChannel::with_responder(|_| None);
272        registry.replace_node(
273            NodeId::from("keyboard-node".to_owned()),
274            [route.clone()],
275            scripted_channel(raw).await,
276        );
277        let current = registry
278            .lookup(&route)
279            .expect("the keyboard channel should be published");
280        let monitored = |stopped, mut presses| {
281            let (registry, current) = (registry.clone(), current.clone());
282            async move {
283                let (_signal, gate) = crate::device_io_channel();
284                tokio::time::timeout(
285                    std::time::Duration::from_millis(100),
286                    monitor_host_switch(stopped, &mut presses, &registry, &current, gate),
287                )
288                .await
289                .expect("a ready stop source needs no further event")
290            }
291        };
292
293        for (reason, expected) in [
294            (
295                Some(HostSwitchStopReason::Graceful),
296                HostSwitchStop::Shutdown,
297            ),
298            (
299                Some(HostSwitchStopReason::DeviceLost),
300                HostSwitchStop::ChannelChanged,
301            ),
302            // A dropped stop sender is a lost owner, not a graceful request.
303            (None, HostSwitchStop::ChannelChanged),
304        ] {
305            let (stop, stopped) = tokio::sync::oneshot::channel();
306            match reason {
307                Some(reason) => stop.send(reason).unwrap(),
308                None => drop(stop),
309            }
310            let (_press, presses) = tokio::sync::mpsc::unbounded_channel();
311            assert_eq!(monitored(stopped, presses).await, expected, "{reason:?}");
312        }
313
314        let (_stop, stopped) = tokio::sync::oneshot::channel();
315        let (press, presses) = tokio::sync::mpsc::unbounded_channel();
316        press.send(2).unwrap();
317        assert_eq!(
318            monitored(stopped, presses).await,
319            HostSwitchStop::Pressed(2)
320        );
321    }
322
323    #[tokio::test]
324    async fn replaced_publication_during_successful_write_remains_pending() {
325        let route = DeviceRoute::Direct {
326            vendor_id: 0x046d,
327            product_id: 0xb35b,
328        };
329        let node = NodeId::from("keyboard-node".to_owned());
330        let registry = ChannelRegistry::default();
331        let (retired_raw, _) = ScriptedRawHidChannel::with_responder(|_| None);
332        let retired = SharedChannel::new(scripted_channel(retired_raw).await, route.clone());
333        let control = ArmedControl {
334            cid: 0x00d3,
335            host: 0,
336            mode: ReportingMode::Diverted,
337            original: CidReporting {
338                cid: ControlId(0x00d3),
339                diverted: false,
340                persistently_diverted: true,
341                force_raw_xy: true,
342                raw_xy: false,
343                remap: Some(ControlId(0x1234)),
344                analytics_key_events: false,
345                raw_wheel: true,
346            },
347        };
348        let pending = PendingHostSwitchRestore::new(&retired, 0x22, vec![control])
349            .expect("one armed control must require restoration");
350        let (winner_raw, winner_handle) =
351            ScriptedRawHidChannel::with_responder(|request| Some(request.to_vec()));
352        let winner = scripted_channel(winner_raw).await;
353        let replacement_registry = registry.clone();
354        let replacement_node = node.clone();
355        let replacement_route = route.clone();
356        let (superseded_raw, superseded_handle) =
357            ScriptedRawHidChannel::with_dynamic_responder(move |request| {
358                replacement_registry.replace_node(
359                    replacement_node.clone(),
360                    [replacement_route.clone()],
361                    winner.clone(),
362                );
363                Some(request.to_vec())
364            });
365        registry.replace_node(node, [route], scripted_channel(superseded_raw).await);
366
367        let pending = match pending.retry(&registry).await {
368            HostSwitchRestoreOutcome::RestorePending(pending) => pending,
369            HostSwitchRestoreOutcome::Restored => panic!("superseded write counted as final"),
370        };
371        assert_eq!(superseded_handle.written_reports().len(), 1);
372        assert!(matches!(
373            pending.retry(&registry).await,
374            HostSwitchRestoreOutcome::Restored
375        ));
376        assert_eq!(winner_handle.written_reports().len(), 1);
377    }
378}