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, IoSuspended, 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
110impl From<IoSuspended> for HostSwitchError {
111    fn from(error: IoSuspended) -> Self {
112        Self::Hid(error.into())
113    }
114}
115
116/// Capture host switch keys until a press, shutdown, or channel retirement.
117///
118/// Returns any requested host together with the restoration outcome. The caller
119/// must retain pending restoration and finish it before switching hosts or
120/// starting a successor session.
121pub 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        // A press does not retire the channel: teardown may write through it
195        // for as long as inventory still publishes it.
196        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/// Why monitoring an armed host-switch session stopped.
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222enum HostSwitchStop {
223    /// The keyboard asked for this zero-based host.
224    Pressed(u8),
225    /// Teardown was requested while inventory still published the channel
226    /// that armed the session.
227    Shutdown,
228    /// The channel that armed the session must not be written through again:
229    /// inventory removed or replaced it, or the owner reported the keyboard
230    /// lost.
231    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    /// A stop that did not itself retire the channel. Re-checking inventory
243    /// keeps a simultaneously ready replacement from being written underneath.
244    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
297/// Move reachable targets to `host`, then move the keyboard last.
298///
299/// Returns whether the keyboard actually changed hosts.
300pub 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    // Validate the keyboard's own move before touching anything: preparation is
310    // read-only, but it is the step that rejects an unpaired host slot, and
311    // discovering that *after* the mice have moved would strand them on a host
312    // the keyboard never reaches. Applying it still happens last, because once
313    // the keyboard leaves this host its channel can no longer command a mouse
314    // sharing the same receiver.
315    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            // Record the rollback before issuing the write: a transport timeout
371            // can mean that the device applied the request but its response was
372            // lost, so the failing control must be restored as well.
373            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
541/// Whether the device explicitly reports `host` as an empty slot.
542///
543/// `ChangeHost`'s `host_count` counts the device's RF channels, not the ones
544/// that have a pairing. Switching to an empty slot is not refused by the
545/// device: `setCurrentHost` is fire-and-forget and a successful switch usually
546/// resets the device, so it simply drops off this host and does not come back
547/// until the user pairs that slot or presses the device's own host button. A
548/// keyboard with three host keys paired to two machines is enough to hit this.
549///
550/// `HostsInfo` (`0x1815`) is the only feature that reports per-slot pairing
551/// status, and asking is advisory: a device that does not implement it, times
552/// out, returns a feature error, or answers with a status byte outside the
553/// spec has not said the slot is empty, and must still be allowed to switch.
554/// Only an explicit `Empty` refuses, so this returns a plain `bool` — an
555/// unreadable status can never abort the transition it was meant to protect.
556///
557/// Some devices (confirmed on an MX Vertical, see #1709) answer `status ==
558/// Empty` for every slot, including the one they are currently connected on
559/// and others that are genuinely paired and carry a real friendly name — the
560/// status byte is simply unimplemented/stuck at 0 on these units. A nonzero
561/// `name_len` is independent evidence the slot has a stored pairing, so it
562/// overrides an `Empty` status; only a slot with both `Empty` and no name is
563/// treated as truly empty.
564async 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;