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