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, IoSuspended, 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
110impl From<IoSuspended> for HostSwitchError {
111 fn from(error: IoSuspended) -> Self {
112 Self::Hid(error.into())
113 }
114}
115
116pub async fn run_host_switch_session(
122 keyboard: DeviceRoute,
123 shutdown: oneshot::Receiver<HostSwitchStopReason>,
124 registry: &ChannelRegistry,
125 device_io: DeviceIoGate,
126) -> Result<HostSwitchSessionOutcome, HostSwitchSessionFailure> {
127 device_io.ensure_allowed().map_err(HostSwitchError::from)?;
128 let shared = registry
129 .lookup(&keyboard)
130 .ok_or(HostSwitchError::KeyboardNotFound)?;
131 let channel = Arc::clone(shared.channel());
132 let keyboard_index = shared.device_index();
133 let device = timed_hidpp(
134 "opening keyboard device",
135 Device::new(Arc::clone(&channel), keyboard_index),
136 )
137 .await?;
138 let feature = timed_hidpp(
139 "locating host controls",
140 device.root().get_feature(reprog_controls::FEATURE_ID),
141 )
142 .await?
143 .ok_or(HostSwitchError::UnsupportedKeyboard)?;
144 let controls = ReprogControlsV4::new(Arc::clone(&channel), keyboard_index, feature.index);
145
146 let mut armed = Vec::new();
147 if let Err(error) = arm_host_controls_inner(&controls, &mut armed).await {
148 let pending = PendingHostSwitchRestore::new(&shared, controls.feature_index(), armed);
149 return Err(rollback_host_switch_start(error, pending, registry, &device_io).await);
150 }
151 if armed.is_empty() {
152 return Err(HostSwitchError::UnsupportedKeyboard.into());
153 }
154
155 let (press_tx, mut press_rx) = mpsc::unbounded_channel();
156 let feature_index = controls.feature_index();
157 let event_controls = armed.clone();
158 let listener = channel.add_msg_listener_guarded(move |raw, matched| {
159 if matched {
160 return;
161 }
162 let message = v20::Message::from(raw);
163 let Some(event) =
164 reprog_controls::decode_full_event(&message, keyboard_index, feature_index)
165 else {
166 return;
167 };
168 if let Some(host) = event_host(&event_controls, event) {
169 let _ = press_tx.send(host);
170 }
171 });
172
173 info!(
174 route = %keyboard,
175 controls = armed.len(),
176 "host switch link active"
177 );
178 let stop = monitor_host_switch(
179 shutdown,
180 &mut press_rx,
181 registry,
182 &shared,
183 device_io.clone(),
184 )
185 .await;
186
187 drop(listener);
188 let requested_host = stop.requested_host();
189 let Some(mut pending) = PendingHostSwitchRestore::new(&shared, controls.feature_index(), armed)
190 else {
191 return Ok(HostSwitchSessionOutcome::Restored { requested_host });
192 };
193 let reuse_armed_channel = match stop {
194 HostSwitchStop::Pressed(_) => registry.is_current(&shared),
197 HostSwitchStop::Shutdown => true,
198 HostSwitchStop::ChannelChanged => false,
199 };
200 if reuse_armed_channel {
201 pending = pending.allow_current_channel();
202 }
203 if !device_io.allows_io() {
204 return Ok(HostSwitchSessionOutcome::RestorePending {
205 requested_host,
206 restore: pending,
207 });
208 }
209 Ok(match pending.retry(registry).await {
210 HostSwitchRestoreOutcome::Restored => HostSwitchSessionOutcome::Restored { requested_host },
211 HostSwitchRestoreOutcome::RestorePending(restore) => {
212 HostSwitchSessionOutcome::RestorePending {
213 requested_host,
214 restore,
215 }
216 }
217 })
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222enum HostSwitchStop {
223 Pressed(u8),
225 Shutdown,
228 ChannelChanged,
232}
233
234impl HostSwitchStop {
235 fn requested_host(self) -> Option<u8> {
236 match self {
237 Self::Pressed(host) => Some(host),
238 Self::Shutdown | Self::ChannelChanged => None,
239 }
240 }
241
242 fn for_current_publication(registry: &ChannelRegistry, shared: &SharedChannel) -> Self {
245 if registry.is_current(shared) {
246 Self::Shutdown
247 } else {
248 Self::ChannelChanged
249 }
250 }
251}
252
253async fn monitor_host_switch(
254 mut shutdown: oneshot::Receiver<HostSwitchStopReason>,
255 presses: &mut mpsc::UnboundedReceiver<u8>,
256 registry: &ChannelRegistry,
257 shared: &SharedChannel,
258 mut device_io: DeviceIoGate,
259) -> HostSwitchStop {
260 let mut registry_changes = registry.subscribe();
261 loop {
262 if !registry.is_current(shared) {
263 info!(route = %shared.route(), "inventory replaced or removed host-switch channel");
264 return HostSwitchStop::ChannelChanged;
265 }
266 tokio::select! {
267 biased;
268
269 changed = registry_changes.changed() => {
270 if changed.is_err() {
271 return HostSwitchStop::ChannelChanged;
272 }
273 }
274 reason = &mut shutdown => {
275 return match reason.unwrap_or(HostSwitchStopReason::DeviceLost) {
276 HostSwitchStopReason::Graceful => {
277 HostSwitchStop::for_current_publication(registry, shared)
278 }
279 HostSwitchStopReason::DeviceLost => HostSwitchStop::ChannelChanged,
280 };
281 }
282 host = presses.recv() => {
283 return match host {
284 Some(host) => HostSwitchStop::Pressed(host),
285 None => HostSwitchStop::for_current_publication(registry, shared),
286 };
287 }
288 allowed = device_io.changed() => {
289 if allowed.is_none() {
290 return HostSwitchStop::for_current_publication(registry, shared);
291 }
292 }
293 }
294 }
295}
296
297pub async fn switch_linked_hosts(
301 keyboard: &DeviceRoute,
302 targets: &[DeviceRoute],
303 host: u8,
304 channel_pool: &ChannelPool,
305) -> Result<bool, HostSwitchError> {
306 let channel = open_channel(channel_pool, keyboard, "opening keyboard channel")
307 .await?
308 .ok_or(HostSwitchError::KeyboardNotFound)?;
309 let keyboard_change = prepare_host_change_on(&channel, keyboard.device_index(), host).await?;
316 for target in targets {
317 match prepare_host_change(target, host, keyboard, &channel, channel_pool).await {
318 Ok(change) => {
319 if let Err(error) = apply_host_change(change).await {
320 debug!(%error, route = %target, host, "linked device host switch failed");
321 }
322 }
323 Err(error) => {
324 debug!(%error, route = %target, host, "linked device host switch preparation failed");
325 }
326 }
327 }
328 let changed = apply_host_change(keyboard_change).await?;
329 if changed {
330 debug!(host, route = %keyboard, "keyboard host switched");
331 }
332 Ok(changed)
333}
334
335async fn arm_host_controls_inner(
336 controls: &ReprogControlsV4,
337 armed: &mut Vec<ArmedControl>,
338) -> Result<(), HostSwitchError> {
339 let count = timed_hidpp("reading host control count", controls.get_count()).await?;
340 for index in 0..count {
341 let info = timed_hidpp(
342 "reading host control information",
343 controls.get_ctrl_id_info(index),
344 )
345 .await?;
346 let Some(host) = host_channel(info) else {
347 continue;
348 };
349 debug!(
350 cid = format_args!("{:#06x}", info.cid),
351 task_id = format_args!("{:#06x}", info.task_id),
352 host,
353 divertable = info.is_divertable(),
354 analytics = info.supports_analytics_events(),
355 "host switch control discovered"
356 );
357 let mode = if info.is_divertable() {
358 Some(ReportingMode::Diverted)
359 } else if info.supports_analytics_events() {
360 Some(ReportingMode::Analytics)
361 } else {
362 None
363 };
364 if let Some(mode) = mode {
365 let original = timed_hidpp(
366 "reading host control reporting",
367 controls.get_cid_reporting(info.cid),
368 )
369 .await?;
370 armed.push(ArmedControl {
374 cid: info.cid,
375 host,
376 mode,
377 original,
378 });
379 match mode {
380 ReportingMode::Diverted => {
381 timed_hidpp("diverting host control", controls.divert_cid(info.cid)).await?;
382 }
383 ReportingMode::Analytics => {
384 timed_hidpp(
385 "enabling host control analytics",
386 controls.set_cid_reporting_full(
387 info.cid,
388 reprog_controls::CidReportingChange {
389 analytics_key_events: Some(true),
390 ..reprog_controls::CidReportingChange::default()
391 },
392 ),
393 )
394 .await?;
395 }
396 }
397 }
398 }
399 Ok(())
400}
401
402async fn restore_host_controls(controls: &ReprogControlsV4, armed: &[ArmedControl]) -> bool {
403 let mut complete = true;
404 for &control in armed {
405 let mut restored = restore_host_control(controls, control).await;
406 if restored.is_err() {
407 restored = restore_host_control(controls, control).await;
408 }
409 if let Err(error) = restored {
410 debug!(
411 ?error,
412 cid = control.cid,
413 "could not restore host switch control"
414 );
415 complete = false;
416 }
417 }
418 complete
419}
420
421async fn restore_host_control(
422 controls: &ReprogControlsV4,
423 control: ArmedControl,
424) -> Result<(), HostSwitchError> {
425 timed_hidpp(
426 "restoring host control reporting",
427 controls.set_cid_reporting_full(control.cid, restoration_change(control)),
428 )
429 .await
430 .map(|_echo| ())
431}
432
433fn restoration_change(control: ArmedControl) -> reprog_controls::CidReportingChange {
434 match control.mode {
435 ReportingMode::Diverted => reprog_controls::CidReportingChange {
436 diverted: Some(control.original.diverted),
437 raw_xy: Some(control.original.raw_xy),
438 ..reprog_controls::CidReportingChange::default()
439 },
440 ReportingMode::Analytics => reprog_controls::CidReportingChange {
441 analytics_key_events: Some(control.original.analytics_key_events),
442 ..reprog_controls::CidReportingChange::default()
443 },
444 }
445}
446
447struct PreparedHostChange {
448 feature: Arc<ChangeHostFeature>,
449 device_index: u8,
450 host: u8,
451 required: bool,
452}
453
454async fn prepare_host_change(
455 target: &DeviceRoute,
456 host: u8,
457 keyboard: &DeviceRoute,
458 keyboard_channel: &Arc<HidppChannel>,
459 channel_pool: &ChannelPool,
460) -> Result<PreparedHostChange, HostSwitchError> {
461 if shares_channel(target, keyboard) {
462 prepare_host_change_on(keyboard_channel, target.device_index(), host).await
463 } else {
464 let channel = open_channel(channel_pool, target, "opening linked device channel")
465 .await?
466 .ok_or(HostSwitchError::TargetNotFound)?;
467 prepare_host_change_on(&channel, target.device_index(), host).await
468 }
469}
470
471async fn prepare_host_change_on(
472 channel: &Arc<HidppChannel>,
473 device_index: u8,
474 host: u8,
475) -> Result<PreparedHostChange, HostSwitchError> {
476 let mut device = timed_hidpp(
477 "opening host-change device",
478 Device::new(Arc::clone(channel), device_index),
479 )
480 .await?;
481 let info = timed_hidpp(
482 "locating host-change feature",
483 device.root().get_feature(ChangeHostFeature::ID),
484 )
485 .await?
486 .ok_or_else(|| HostSwitchError::Hidpp("ChangeHost is unsupported".into()))?;
487 let change_host = device.add_feature::<ChangeHostFeature>(info.index);
488 let state = timed_hidpp("reading current host", change_host.get_host_info()).await?;
489 let required = host_change_required(state.current_host, state.host_count, host)?;
490 if required && host_slot_is_empty(&mut device, host).await {
491 return Err(HostSwitchError::HostSlotEmpty { host });
492 }
493 Ok(PreparedHostChange {
494 feature: change_host,
495 device_index,
496 host,
497 required,
498 })
499}
500
501async fn apply_host_change(change: PreparedHostChange) -> Result<bool, HostSwitchError> {
502 if !change.required {
503 let PreparedHostChange {
504 device_index, host, ..
505 } = change;
506 debug!(device_index, host, "device already uses requested host");
507 return Ok(false);
508 }
509 timed_hidpp(
510 "writing current host",
511 change.feature.set_current_host(change.host),
512 )
513 .await?;
514 Ok(true)
515}
516
517async fn open_channel(
518 channel_pool: &ChannelPool,
519 route: &DeviceRoute,
520 operation: &'static str,
521) -> Result<Option<Arc<HidppChannel>>, HostSwitchError> {
522 timeout(HIDPP_OPERATION_TIMEOUT, channel_pool.open(route))
523 .await
524 .map_err(|_| HostSwitchError::TimedOut { operation })?
525 .map_err(HostSwitchError::Hid)
526}
527
528async fn timed_hidpp<T, E>(
529 operation: &'static str,
530 future: impl Future<Output = Result<T, E>>,
531) -> Result<T, HostSwitchError>
532where
533 E: std::fmt::Debug,
534{
535 timeout(HIDPP_OPERATION_TIMEOUT, future)
536 .await
537 .map_err(|_| HostSwitchError::TimedOut { operation })?
538 .map_err(|error| hidpp_error(operation, error))
539}
540
541async fn host_slot_is_empty(device: &mut Device, host: u8) -> bool {
565 let feature = timed_hidpp(
566 "locating hosts-info feature",
567 device.root().get_feature(HostsInfoFeature::ID),
568 )
569 .await;
570 let index = match feature {
571 Ok(Some(info)) => info.index,
572 Ok(None) => return false,
573 Err(error) => {
574 debug!(host, %error, "hosts-info lookup failed; treating the slot as usable");
575 return false;
576 }
577 };
578 let hosts_info = device.add_feature::<HostsInfoFeature>(index);
579 match timed_hidpp(
580 "reading host slot status",
581 hosts_info.get_host_info(HostIndex::Slot(host)),
582 )
583 .await
584 {
585 Ok(slot) => slot.status == HostSlotStatus::Empty && slot.name_len == 0,
586 Err(error) => {
587 debug!(host, %error, "host slot status is unreadable; treating the slot as usable");
588 false
589 }
590 }
591}
592
593fn host_change_required(
594 current_host: u8,
595 host_count: u8,
596 requested_host: u8,
597) -> Result<bool, HostSwitchError> {
598 if requested_host >= host_count {
599 return Err(HostSwitchError::Hidpp(format!(
600 "host {requested_host} is outside device host count {host_count}"
601 )));
602 }
603 Ok(current_host != requested_host)
604}
605
606fn shares_channel(left: &DeviceRoute, right: &DeviceRoute) -> bool {
607 left.shares_transport(right)
608}
609
610fn hidpp_error(operation: &'static str, error: impl std::fmt::Debug) -> HostSwitchError {
611 HostSwitchError::Hidpp(format!("{operation}: {error:?}"))
612}
613
614fn host_channel(info: reprog_controls::CtrlIdInfo) -> Option<u8> {
615 HOST_CONTROL_IDS
616 .iter()
617 .find_map(|(cid, host)| (info.cid == cid.0).then_some(*host))
618 .or_else(|| {
619 HOST_TASK_IDS
620 .iter()
621 .find_map(|(task, host)| (info.task_id == task.0).then_some(*host))
622 })
623}
624
625fn event_host(
626 controls: &[ArmedControl],
627 event: reprog_controls::ReprogControlsEvent,
628) -> Option<u8> {
629 match event {
630 reprog_controls::ReprogControlsEvent::DivertedButtons(cids) => controls
631 .iter()
632 .find_map(|control| cids.contains(&control.cid.into()).then_some(control.host)),
633 reprog_controls::ReprogControlsEvent::AnalyticsKeyEvents(events) => {
634 controls.iter().find_map(|control| {
635 events
636 .iter()
637 .any(|event| event.cid.0 == control.cid)
638 .then_some(control.host)
639 })
640 }
641 reprog_controls::ReprogControlsEvent::DivertedRawMouseXy { .. }
642 | reprog_controls::ReprogControlsEvent::DivertedRawWheel { .. } => None,
643 }
644}
645
646#[cfg(test)]
647mod tests;