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