Skip to main content

openlogi_device/session/
host_switch.rs

1//! Keyboard-initiated host-switch synchronization.
2//!
3//! A session temporarily diverts the keyboard's three host controls, observes
4//! which channel was pressed, switches the linked pointing devices, and then
5//! switches the keyboard itself. Ordering matters: once the keyboard leaves
6//! this host its HID++ channel can no longer command a mouse sharing the same
7//! receiver.
8
9use std::{future::Future, sync::Arc, time::Duration};
10
11use hidpp::{
12    channel::HidppChannel,
13    device::Device,
14    feature::{
15        CreatableFeature,
16        change_host::ChangeHostFeature,
17        hosts_info::{HostIndex, HostSlotStatus, HostsInfoFeature},
18    },
19    protocol::v20,
20};
21use thiserror::Error;
22use tokio::{
23    sync::{mpsc, oneshot},
24    time::timeout,
25};
26use tracing::{debug, info};
27
28use crate::{
29    ChannelPool, DeviceRoute,
30    backend::BackendError,
31    reprog_controls::{self, ReprogControlsV4},
32};
33
34/// Why an armed host-switch session is being stopped externally.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum HostSwitchStopReason {
37    /// The keyboard remains reachable, so its controls must be restored.
38    Graceful,
39    /// The keyboard disappeared, so only local resources can be released.
40    DeviceLost,
41}
42
43const HOST_CONTROL_IDS: [(reprog_controls::ControlId, u8); 3] = [
44    (reprog_controls::control_ids::HOST_SWITCH_CHANNEL_1, 0),
45    (reprog_controls::control_ids::HOST_SWITCH_CHANNEL_2, 1),
46    (reprog_controls::control_ids::HOST_SWITCH_CHANNEL_3, 2),
47];
48const HOST_TASK_IDS: [(reprog_controls::TaskId, u8); 3] = [
49    (reprog_controls::task_ids::HOST_SWITCH_CHANNEL_1, 0),
50    (reprog_controls::task_ids::HOST_SWITCH_CHANNEL_2, 1),
51    (reprog_controls::task_ids::HOST_SWITCH_CHANNEL_3, 2),
52];
53const HIDPP_OPERATION_TIMEOUT: Duration = Duration::from_secs(5);
54
55#[derive(Clone, Copy)]
56enum ReportingMode {
57    Diverted,
58    Analytics,
59}
60
61#[derive(Clone, Copy)]
62struct ArmedControl {
63    cid: u16,
64    host: u8,
65    mode: ReportingMode,
66    original: reprog_controls::CidReporting,
67}
68
69/// Failure while arming or running a host-switch link.
70#[derive(Debug, Error)]
71pub enum HostSwitchError {
72    /// HID transport-level failure.
73    #[error("HID transport error")]
74    Hid(#[from] BackendError),
75    /// The configured keyboard is not currently reachable.
76    #[error("configured keyboard is not connected")]
77    KeyboardNotFound,
78    /// A configured target is not currently reachable.
79    #[error("configured linked device is not connected")]
80    TargetNotFound,
81    /// A required HID++ operation failed.
82    #[error("HID++ protocol error: {0}")]
83    Hidpp(String),
84    /// A required HID++ operation did not complete within its budget.
85    #[error("HID++ operation timed out while {operation}")]
86    TimedOut {
87        /// Description of the operation that exceeded its budget.
88        operation: &'static str,
89    },
90    /// The keyboard cannot report its host switch controls to software.
91    #[error("keyboard exposes no reportable host switch controls")]
92    UnsupportedKeyboard,
93    /// The device reports the requested host slot as unpaired, so switching to
94    /// it would strand the device.
95    #[error("host {host} is not paired on this device")]
96    HostSlotEmpty {
97        /// The zero-based host slot that has no pairing.
98        host: u8,
99    },
100}
101
102/// Capture host switch keys on `keyboard` until one is pressed or `shutdown`
103/// resolves. Controls are restored before a requested host is returned.
104pub async fn run_host_switch_session(
105    keyboard: DeviceRoute,
106    shutdown: oneshot::Receiver<HostSwitchStopReason>,
107    channel_pool: ChannelPool,
108) -> Result<Option<u8>, HostSwitchError> {
109    let channel = open_channel(&channel_pool, &keyboard, "opening keyboard channel")
110        .await?
111        .ok_or(HostSwitchError::KeyboardNotFound)?;
112    let keyboard_index = keyboard.device_index();
113    let device = timed_hidpp(
114        "opening keyboard device",
115        Device::new(Arc::clone(&channel), keyboard_index),
116    )
117    .await?;
118    let feature = timed_hidpp(
119        "locating host controls",
120        device.root().get_feature(reprog_controls::FEATURE_ID),
121    )
122    .await?
123    .ok_or(HostSwitchError::UnsupportedKeyboard)?;
124    let controls = ReprogControlsV4::new(Arc::clone(&channel), keyboard_index, feature.index);
125
126    let armed = arm_host_controls(&controls).await?;
127    if armed.is_empty() {
128        return Err(HostSwitchError::UnsupportedKeyboard);
129    }
130
131    let (press_tx, mut press_rx) = mpsc::unbounded_channel();
132    let feature_index = controls.feature_index();
133    let event_controls = armed.clone();
134    let listener = channel.add_msg_listener_guarded(move |raw, matched| {
135        if matched {
136            return;
137        }
138        let message = v20::Message::from(raw);
139        let Some(event) =
140            reprog_controls::decode_full_event(&message, keyboard_index, feature_index)
141        else {
142            return;
143        };
144        if let Some(host) = event_host(&event_controls, event) {
145            let _ = press_tx.send(host);
146        }
147    });
148
149    info!(
150        route = %keyboard,
151        controls = armed.len(),
152        "host switch link active"
153    );
154    let outcome = tokio::select! {
155        reason = shutdown => {
156            let reason = reason.unwrap_or(HostSwitchStopReason::DeviceLost);
157            (None, reason == HostSwitchStopReason::Graceful)
158        },
159        Some(host) = press_rx.recv() => (Some(host), true),
160    };
161
162    drop(listener);
163    if outcome.1 {
164        restore_host_controls(&controls, armed).await;
165    }
166    Ok(outcome.0)
167}
168
169/// Move reachable targets to `host`, then move the keyboard last.
170///
171/// Returns whether the keyboard actually changed hosts.
172pub async fn switch_linked_hosts(
173    keyboard: &DeviceRoute,
174    targets: &[DeviceRoute],
175    host: u8,
176    channel_pool: &ChannelPool,
177) -> Result<bool, HostSwitchError> {
178    let channel = open_channel(channel_pool, keyboard, "opening keyboard channel")
179        .await?
180        .ok_or(HostSwitchError::KeyboardNotFound)?;
181    // Validate the keyboard's own move before touching anything: preparation is
182    // read-only, but it is the step that rejects an unpaired host slot, and
183    // discovering that *after* the mice have moved would strand them on a host
184    // the keyboard never reaches. Applying it still happens last, because once
185    // the keyboard leaves this host its channel can no longer command a mouse
186    // sharing the same receiver.
187    let keyboard_change = prepare_host_change_on(&channel, keyboard.device_index(), host).await?;
188    for target in targets {
189        match prepare_host_change(target, host, keyboard, &channel, channel_pool).await {
190            Ok(change) => {
191                if let Err(error) = apply_host_change(change).await {
192                    debug!(%error, route = %target, host, "linked device host switch failed");
193                }
194            }
195            Err(error) => {
196                debug!(%error, route = %target, host, "linked device host switch preparation failed");
197            }
198        }
199    }
200    let changed = apply_host_change(keyboard_change).await?;
201    if changed {
202        debug!(host, route = %keyboard, "keyboard host switched");
203    }
204    Ok(changed)
205}
206
207async fn arm_host_controls(
208    controls: &ReprogControlsV4,
209) -> Result<Vec<ArmedControl>, HostSwitchError> {
210    let mut armed = Vec::new();
211    if let Err(error) = arm_host_controls_inner(controls, &mut armed).await {
212        restore_host_controls(controls, armed).await;
213        return Err(error);
214    }
215    Ok(armed)
216}
217
218async fn arm_host_controls_inner(
219    controls: &ReprogControlsV4,
220    armed: &mut Vec<ArmedControl>,
221) -> Result<(), HostSwitchError> {
222    let count = timed_hidpp("reading host control count", controls.get_count()).await?;
223    for index in 0..count {
224        let info = timed_hidpp(
225            "reading host control information",
226            controls.get_ctrl_id_info(index),
227        )
228        .await?;
229        let Some(host) = host_channel(info) else {
230            continue;
231        };
232        debug!(
233            cid = format_args!("{:#06x}", info.cid),
234            task_id = format_args!("{:#06x}", info.task_id),
235            host,
236            divertable = info.is_divertable(),
237            analytics = info.supports_analytics_events(),
238            "host switch control discovered"
239        );
240        let mode = if info.is_divertable() {
241            Some(ReportingMode::Diverted)
242        } else if info.supports_analytics_events() {
243            Some(ReportingMode::Analytics)
244        } else {
245            None
246        };
247        if let Some(mode) = mode {
248            let original = timed_hidpp(
249                "reading host control reporting",
250                controls.get_cid_reporting(info.cid),
251            )
252            .await?;
253            // Record the rollback before issuing the write: a transport timeout
254            // can mean that the device applied the request but its response was
255            // lost, so the failing control must be restored as well.
256            armed.push(ArmedControl {
257                cid: info.cid,
258                host,
259                mode,
260                original,
261            });
262            match mode {
263                ReportingMode::Diverted => {
264                    timed_hidpp(
265                        "diverting host control",
266                        controls.set_cid_reporting(info.cid, true, false),
267                    )
268                    .await?;
269                }
270                ReportingMode::Analytics => {
271                    timed_hidpp(
272                        "enabling host control analytics",
273                        controls.set_cid_reporting_full(
274                            info.cid,
275                            reprog_controls::CidReportingChange {
276                                analytics_key_events: Some(true),
277                                ..reprog_controls::CidReportingChange::default()
278                            },
279                        ),
280                    )
281                    .await?;
282                }
283            }
284        }
285    }
286    Ok(())
287}
288
289async fn restore_host_controls(controls: &ReprogControlsV4, armed: Vec<ArmedControl>) {
290    for control in armed {
291        let mut restored = restore_host_control(controls, control).await;
292        if restored.is_err() {
293            restored = restore_host_control(controls, control).await;
294        }
295        if let Err(error) = restored {
296            debug!(
297                ?error,
298                cid = control.cid,
299                "could not restore host switch control"
300            );
301        }
302    }
303}
304
305async fn restore_host_control(
306    controls: &ReprogControlsV4,
307    control: ArmedControl,
308) -> Result<(), HostSwitchError> {
309    timed_hidpp(
310        "restoring host control reporting",
311        controls.set_cid_reporting_full(control.cid, restoration_change(control)),
312    )
313    .await
314    .map(|_echo| ())
315}
316
317fn restoration_change(control: ArmedControl) -> reprog_controls::CidReportingChange {
318    match control.mode {
319        ReportingMode::Diverted => reprog_controls::CidReportingChange {
320            diverted: Some(control.original.diverted),
321            raw_xy: Some(control.original.raw_xy),
322            ..reprog_controls::CidReportingChange::default()
323        },
324        ReportingMode::Analytics => reprog_controls::CidReportingChange {
325            analytics_key_events: Some(control.original.analytics_key_events),
326            ..reprog_controls::CidReportingChange::default()
327        },
328    }
329}
330
331struct PreparedHostChange {
332    feature: Arc<ChangeHostFeature>,
333    device_index: u8,
334    host: u8,
335    required: bool,
336}
337
338async fn prepare_host_change(
339    target: &DeviceRoute,
340    host: u8,
341    keyboard: &DeviceRoute,
342    keyboard_channel: &Arc<HidppChannel>,
343    channel_pool: &ChannelPool,
344) -> Result<PreparedHostChange, HostSwitchError> {
345    if shares_channel(target, keyboard) {
346        prepare_host_change_on(keyboard_channel, target.device_index(), host).await
347    } else {
348        let channel = open_channel(channel_pool, target, "opening linked device channel")
349            .await?
350            .ok_or(HostSwitchError::TargetNotFound)?;
351        prepare_host_change_on(&channel, target.device_index(), host).await
352    }
353}
354
355async fn prepare_host_change_on(
356    channel: &Arc<HidppChannel>,
357    device_index: u8,
358    host: u8,
359) -> Result<PreparedHostChange, HostSwitchError> {
360    let mut device = timed_hidpp(
361        "opening host-change device",
362        Device::new(Arc::clone(channel), device_index),
363    )
364    .await?;
365    let info = timed_hidpp(
366        "locating host-change feature",
367        device.root().get_feature(ChangeHostFeature::ID),
368    )
369    .await?
370    .ok_or_else(|| HostSwitchError::Hidpp("ChangeHost is unsupported".into()))?;
371    let change_host = device.add_feature::<ChangeHostFeature>(info.index);
372    let state = timed_hidpp("reading current host", change_host.get_host_info()).await?;
373    let required = host_change_required(state.current_host, state.host_count, host)?;
374    if required && host_slot_is_empty(&mut device, host).await {
375        return Err(HostSwitchError::HostSlotEmpty { host });
376    }
377    Ok(PreparedHostChange {
378        feature: change_host,
379        device_index,
380        host,
381        required,
382    })
383}
384
385async fn apply_host_change(change: PreparedHostChange) -> Result<bool, HostSwitchError> {
386    if !change.required {
387        let PreparedHostChange {
388            device_index, host, ..
389        } = change;
390        debug!(device_index, host, "device already uses requested host");
391        return Ok(false);
392    }
393    timed_hidpp(
394        "writing current host",
395        change.feature.set_current_host(change.host),
396    )
397    .await?;
398    Ok(true)
399}
400
401async fn open_channel(
402    channel_pool: &ChannelPool,
403    route: &DeviceRoute,
404    operation: &'static str,
405) -> Result<Option<Arc<HidppChannel>>, HostSwitchError> {
406    timeout(HIDPP_OPERATION_TIMEOUT, channel_pool.open(route))
407        .await
408        .map_err(|_| HostSwitchError::TimedOut { operation })?
409        .map_err(HostSwitchError::Hid)
410}
411
412async fn timed_hidpp<T, E>(
413    operation: &'static str,
414    future: impl Future<Output = Result<T, E>>,
415) -> Result<T, HostSwitchError>
416where
417    E: std::fmt::Debug,
418{
419    timeout(HIDPP_OPERATION_TIMEOUT, future)
420        .await
421        .map_err(|_| HostSwitchError::TimedOut { operation })?
422        .map_err(|error| hidpp_error(operation, error))
423}
424
425/// Whether the device explicitly reports `host` as an empty slot.
426///
427/// `ChangeHost`'s `host_count` counts the device's RF channels, not the ones
428/// that have a pairing. Switching to an empty slot is not refused by the
429/// device: `setCurrentHost` is fire-and-forget and a successful switch usually
430/// resets the device, so it simply drops off this host and does not come back
431/// until the user pairs that slot or presses the device's own host button. A
432/// keyboard with three host keys paired to two machines is enough to hit this.
433///
434/// `HostsInfo` (`0x1815`) is the only feature that reports per-slot pairing
435/// status, and asking is advisory: a device that does not implement it, times
436/// out, returns a feature error, or answers with a status byte outside the
437/// spec has not said the slot is empty, and must still be allowed to switch.
438/// Only an explicit `Empty` refuses, so this returns a plain `bool` — an
439/// unreadable status can never abort the transition it was meant to protect.
440async fn host_slot_is_empty(device: &mut Device, host: u8) -> bool {
441    let feature = timed_hidpp(
442        "locating hosts-info feature",
443        device.root().get_feature(HostsInfoFeature::ID),
444    )
445    .await;
446    let index = match feature {
447        Ok(Some(info)) => info.index,
448        Ok(None) => return false,
449        Err(error) => {
450            debug!(host, %error, "hosts-info lookup failed; treating the slot as usable");
451            return false;
452        }
453    };
454    let hosts_info = device.add_feature::<HostsInfoFeature>(index);
455    match timed_hidpp(
456        "reading host slot status",
457        hosts_info.get_host_info(HostIndex::Slot(host)),
458    )
459    .await
460    {
461        Ok(slot) => slot.status == HostSlotStatus::Empty,
462        Err(error) => {
463            debug!(host, %error, "host slot status is unreadable; treating the slot as usable");
464            false
465        }
466    }
467}
468
469fn host_change_required(
470    current_host: u8,
471    host_count: u8,
472    requested_host: u8,
473) -> Result<bool, HostSwitchError> {
474    if requested_host >= host_count {
475        return Err(HostSwitchError::Hidpp(format!(
476            "host {requested_host} is outside device host count {host_count}"
477        )));
478    }
479    Ok(current_host != requested_host)
480}
481
482fn shares_channel(left: &DeviceRoute, right: &DeviceRoute) -> bool {
483    left.shares_transport(right)
484}
485
486fn hidpp_error(operation: &'static str, error: impl std::fmt::Debug) -> HostSwitchError {
487    HostSwitchError::Hidpp(format!("{operation}: {error:?}"))
488}
489
490fn host_channel(info: reprog_controls::CtrlIdInfo) -> Option<u8> {
491    HOST_CONTROL_IDS
492        .iter()
493        .find_map(|(cid, host)| (info.cid == cid.0).then_some(*host))
494        .or_else(|| {
495            HOST_TASK_IDS
496                .iter()
497                .find_map(|(task, host)| (info.task_id == task.0).then_some(*host))
498        })
499}
500
501fn event_host(
502    controls: &[ArmedControl],
503    event: reprog_controls::ReprogControlsEvent,
504) -> Option<u8> {
505    match event {
506        reprog_controls::ReprogControlsEvent::DivertedButtons(cids) => controls
507            .iter()
508            .find_map(|control| cids.contains(&control.cid.into()).then_some(control.host)),
509        reprog_controls::ReprogControlsEvent::AnalyticsKeyEvents(events) => {
510            controls.iter().find_map(|control| {
511                events
512                    .iter()
513                    .any(|event| event.cid.0 == control.cid)
514                    .then_some(control.host)
515            })
516        }
517        reprog_controls::ReprogControlsEvent::DivertedRawMouseXy { .. }
518        | reprog_controls::ReprogControlsEvent::DivertedRawWheel { .. } => None,
519    }
520}
521
522#[cfg(test)]
523mod tests {
524    use std::sync::Arc;
525
526    use hidpp::channel::HidppChannel;
527
528    use super::{
529        ArmedControl, HostSwitchError, ReportingMode, event_host, host_change_required,
530        host_channel, prepare_host_change_on, restoration_change, shares_channel,
531    };
532    use crate::DeviceRoute;
533    use crate::channel::scripted::{ScriptedRawHidChannel, feature_error};
534    use crate::reprog_controls::{
535        AnalyticsKeyEvent, CidReporting, ControlId, CtrlIdInfo, ReprogControlsEvent,
536    };
537
538    /// Feature index the scripted keyboard reports for `0x1814 ChangeHost`.
539    const CHANGE_HOST_INDEX: u8 = 0x04;
540    /// Feature index the scripted keyboard reports for `0x1815 HostsInfo`.
541    const HOSTS_INFO_INDEX: u8 = 0x05;
542
543    /// `ErrorType::Busy`, the failure a scripted device answers with.
544    const BUSY: u8 = 0x08;
545
546    /// What the scripted keyboard's firmware does when asked about `0x1815`.
547    #[derive(Clone, Copy, PartialEq, Eq)]
548    enum SlotStatus {
549        /// Answers the query: hosts 0 and 1 paired, host 2 empty.
550        Reported,
551        /// Reports the feature as unimplemented, the usual index-0 lookup miss.
552        Unimplemented,
553        /// Errors on the lookup itself, as firmware that refuses unknown
554        /// feature ids rather than reporting index 0 does.
555        LookupErrors,
556        /// Implements the feature but errors on the status read.
557        ReadErrors,
558    }
559
560    /// A three-channel keyboard currently on host 0, paired on hosts 0 and 1
561    /// but **not** on host 2 — a keyboard with three host keys and only two
562    /// machines paired, which is the shape that used to strand devices.
563    fn keyboard_with_an_empty_third_slot(request: &[u8]) -> Option<Vec<u8>> {
564        scripted_keyboard(request, SlotStatus::Reported)
565    }
566
567    /// The same keyboard without `0x1815`, so its slot pairing is unknowable.
568    fn keyboard_without_hosts_info(request: &[u8]) -> Option<Vec<u8>> {
569        scripted_keyboard(request, SlotStatus::Unimplemented)
570    }
571
572    /// The same keyboard, whose firmware errors when asked for `0x1815`.
573    fn keyboard_erroring_on_hosts_info_lookup(request: &[u8]) -> Option<Vec<u8>> {
574        scripted_keyboard(request, SlotStatus::LookupErrors)
575    }
576
577    /// The same keyboard, whose `0x1815` reads come back an error.
578    fn keyboard_erroring_on_slot_status(request: &[u8]) -> Option<Vec<u8>> {
579        scripted_keyboard(request, SlotStatus::ReadErrors)
580    }
581
582    fn scripted_keyboard(request: &[u8], slot_status: SlotStatus) -> Option<Vec<u8>> {
583        if request.len() < 7 || !matches!(request[0], 0x10 | 0x11) {
584            return None;
585        }
586        let mut payload = [0u8; 16];
587        match (request[2], request[3] >> 4) {
588            // Root ping used by Device::new.
589            (0x00, 0x01) => payload[0] = 4,
590            // Root feature lookup.
591            (0x00, 0x00) => {
592                payload[0] = match u16::from_be_bytes([request[4], request[5]]) {
593                    0x1814 => CHANGE_HOST_INDEX,
594                    0x1815 => match slot_status {
595                        SlotStatus::Unimplemented => 0x00,
596                        SlotStatus::LookupErrors => return Some(feature_error(request, BUSY)),
597                        SlotStatus::Reported | SlotStatus::ReadErrors => HOSTS_INFO_INDEX,
598                    },
599                    _ => 0x00,
600                };
601            }
602            // ChangeHost getHostInfo: three RF channels, currently on host 0.
603            (CHANGE_HOST_INDEX, 0x00) => payload[..2].copy_from_slice(&[3, 0]),
604            // HostsInfo getHostInfo: echo the slot, then its pairing status.
605            (HOSTS_INFO_INDEX, 0x01) => {
606                if slot_status == SlotStatus::ReadErrors {
607                    return Some(feature_error(request, BUSY));
608                }
609                payload[0] = request[4];
610                payload[1] = u8::from(request[4] < 2);
611            }
612            _ => return None,
613        }
614
615        let mut response = vec![0u8; 7];
616        response[0] = 0x10;
617        response[1..4].copy_from_slice(&request[1..4]);
618        response[4..].copy_from_slice(&payload[..3]);
619        Some(response)
620    }
621
622    async fn scripted_channel(responder: crate::channel::scripted::Responder) -> Arc<HidppChannel> {
623        let (raw, _handle) = ScriptedRawHidChannel::with_responder(responder);
624        crate::channel::scripted::scripted_channel(raw).await
625    }
626
627    #[tokio::test]
628    async fn switching_to_an_unpaired_slot_is_refused() {
629        // ChangeHost would allow it: host 2 is within the device's channel
630        // count. But nothing is paired there, and `setCurrentHost` is
631        // fire-and-forget — the device would simply leave and not come back.
632        let channel = scripted_channel(keyboard_with_an_empty_third_slot).await;
633
634        let Err(error) = prepare_host_change_on(&channel, 1, 2).await else {
635            panic!("an unpaired slot must not be switched to");
636        };
637
638        assert!(
639            matches!(error, HostSwitchError::HostSlotEmpty { host: 2 }),
640            "got {error:?}"
641        );
642    }
643
644    #[tokio::test]
645    async fn switching_to_a_paired_slot_proceeds() {
646        let channel = scripted_channel(keyboard_with_an_empty_third_slot).await;
647
648        let change = prepare_host_change_on(&channel, 1, 1)
649            .await
650            .expect("a paired slot must be switchable");
651
652        assert!(change.required, "host 1 differs from the current host 0");
653    }
654
655    #[tokio::test]
656    async fn a_device_without_hosts_info_is_still_switched() {
657        // 0x1815 is the only source of per-slot pairing status. Without it the
658        // guard must not block, or this change would regress every device that
659        // does not implement it.
660        let channel = scripted_channel(keyboard_without_hosts_info).await;
661
662        let change = prepare_host_change_on(&channel, 1, 2)
663            .await
664            .expect("a device that cannot report slot status must still switch");
665
666        assert!(change.required);
667    }
668
669    #[tokio::test]
670    async fn a_failed_hosts_info_lookup_does_not_block_the_switch() {
671        // Firmware that answers an unknown feature id with an error rather than
672        // index 0 must read the same as not implementing 0x1815 at all: the
673        // pairing status is unknowable, which is not a reason to refuse.
674        let channel = scripted_channel(keyboard_erroring_on_hosts_info_lookup).await;
675
676        let change = prepare_host_change_on(&channel, 1, 2)
677            .await
678            .expect("an errored feature lookup must not abort the switch");
679
680        assert!(change.required);
681    }
682
683    #[tokio::test]
684    async fn an_unreadable_slot_status_does_not_block_the_switch() {
685        // The guard is advisory. A device that has 0x1815 but cannot answer for
686        // it right now has not said the slot is empty, so refusing here would
687        // turn a transient read failure into a dead host key.
688        let channel = scripted_channel(keyboard_erroring_on_slot_status).await;
689
690        let change = prepare_host_change_on(&channel, 1, 2)
691            .await
692            .expect("an errored status read must not abort the switch");
693
694        assert!(change.required);
695    }
696
697    #[tokio::test]
698    async fn a_switch_to_the_current_host_never_consults_slot_status() {
699        // Already-there is decided before the pairing check, so a device on an
700        // unpaired-looking slot is not blocked from staying put.
701        let channel = scripted_channel(keyboard_with_an_empty_third_slot).await;
702
703        let change = prepare_host_change_on(&channel, 1, 0)
704            .await
705            .expect("staying on the current host is always fine");
706
707        assert!(!change.required);
708    }
709
710    fn reporting(diverted: bool, raw_xy: bool, analytics_key_events: bool) -> CidReporting {
711        CidReporting {
712            cid: ControlId(0x00d3),
713            diverted,
714            persistently_diverted: true,
715            force_raw_xy: true,
716            raw_xy,
717            remap: Some(ControlId(0x1234)),
718            analytics_key_events,
719            raw_wheel: true,
720        }
721    }
722
723    #[test]
724    fn receiver_slots_share_one_channel() {
725        let keyboard = DeviceRoute::Bolt {
726            receiver_uid: "AABB".into(),
727            slot: 1,
728        };
729        let mouse = DeviceRoute::Bolt {
730            receiver_uid: "aabb".into(),
731            slot: 2,
732        };
733        assert!(shares_channel(&keyboard, &mouse));
734    }
735
736    #[test]
737    fn direct_devices_do_not_share_channels() {
738        let route = DeviceRoute::Direct {
739            vendor_id: 0x046d,
740            product_id: 0xb025,
741        };
742        assert!(!shares_channel(&route, &route));
743    }
744
745    #[test]
746    fn host_controls_are_recognized_by_task_when_cid_varies() {
747        let info = CtrlIdInfo {
748            cid: 0x1234,
749            task_id: 0x00af,
750            flags: 0,
751        };
752        assert_eq!(host_channel(info), Some(1));
753    }
754
755    #[test]
756    fn analytics_event_selects_the_matching_host() {
757        let controls = [ArmedControl {
758            cid: 0x00d3,
759            host: 2,
760            mode: ReportingMode::Analytics,
761            original: reporting(false, false, false),
762        }];
763        let mut events = [AnalyticsKeyEvent::default(); 5];
764        events[0] = AnalyticsKeyEvent {
765            cid: ControlId(0x00d3),
766            event: 1,
767        };
768        assert_eq!(
769            event_host(&controls, ReprogControlsEvent::AnalyticsKeyEvents(events)),
770            Some(2)
771        );
772    }
773
774    #[test]
775    fn current_host_does_not_require_a_change() {
776        assert!(matches!(host_change_required(1, 3, 1), Ok(false)));
777    }
778
779    #[test]
780    fn different_valid_host_requires_a_change() {
781        assert!(matches!(host_change_required(0, 3, 2), Ok(true)));
782    }
783
784    #[test]
785    fn host_outside_device_range_is_rejected() {
786        assert!(
787            host_change_required(0, 2, 2).is_err(),
788            "host 2 is outside a device that reports 2 hosts and must be rejected"
789        );
790    }
791
792    #[test]
793    fn diverted_cleanup_restores_only_the_original_temporary_bits() {
794        let change = restoration_change(ArmedControl {
795            cid: 0x00d3,
796            host: 2,
797            mode: ReportingMode::Diverted,
798            original: reporting(true, true, false),
799        });
800
801        assert_eq!(change.diverted, Some(true));
802        assert_eq!(change.raw_xy, Some(true));
803        assert_eq!(change.analytics_key_events, None);
804        assert_eq!(change.persistently_diverted, None);
805        assert_eq!(change.remap, None);
806    }
807
808    #[test]
809    fn analytics_cleanup_restores_the_original_analytics_bit() {
810        let change = restoration_change(ArmedControl {
811            cid: 0x00d3,
812            host: 2,
813            mode: ReportingMode::Analytics,
814            original: reporting(false, false, true),
815        });
816
817        assert_eq!(change.analytics_key_events, Some(true));
818        assert_eq!(change.diverted, None);
819        assert_eq!(change.raw_xy, None);
820    }
821}