1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum HostSwitchStopReason {
45 Graceful,
47 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#[derive(Debug, Error)]
79pub enum HostSwitchError {
80 #[error("HID transport error")]
82 Hid(#[from] BackendError),
83 #[error("configured keyboard is not connected")]
85 KeyboardNotFound,
86 #[error("configured linked device is not connected")]
88 TargetNotFound,
89 #[error("HID++ protocol error: {0}")]
91 Hidpp(String),
92 #[error("HID++ operation timed out while {operation}")]
94 TimedOut {
95 operation: &'static str,
97 },
98 #[error("keyboard exposes no reportable host switch controls")]
100 UnsupportedKeyboard,
101 #[error("host {host} is not paired on this device")]
104 HostSlotEmpty {
105 host: u8,
107 },
108}
109
110pub 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
252pub 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 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 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
496async 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 const CHANGE_HOST_INDEX: u8 = 0x04;
615 const HOSTS_INFO_INDEX: u8 = 0x05;
617
618 const BUSY: u8 = 0x08;
620
621 #[derive(Clone, Copy, PartialEq, Eq)]
623 enum SlotStatus {
624 Reported,
626 Unimplemented,
628 LookupErrors,
631 ReadErrors,
633 }
634
635 fn keyboard_with_an_empty_third_slot(request: &[u8]) -> Option<Vec<u8>> {
639 scripted_keyboard(request, SlotStatus::Reported)
640 }
641
642 fn keyboard_without_hosts_info(request: &[u8]) -> Option<Vec<u8>> {
644 scripted_keyboard(request, SlotStatus::Unimplemented)
645 }
646
647 fn keyboard_erroring_on_hosts_info_lookup(request: &[u8]) -> Option<Vec<u8>> {
649 scripted_keyboard(request, SlotStatus::LookupErrors)
650 }
651
652 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 (0x00, 0x01) => payload[0] = 4,
665 (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 (CHANGE_HOST_INDEX, 0x00) => payload[..2].copy_from_slice(&[3, 0]),
679 (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 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 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 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 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 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 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(®istry).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(®istry).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(®istry).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 ®istry,
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(®istry)
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}