use std::{future::Future, sync::Arc, time::Duration};
use hidpp::{
channel::HidppChannel,
device::Device,
feature::{
CreatableFeature,
change_host::ChangeHostFeature,
hosts_info::{HostIndex, HostSlotStatus, HostsInfoFeature},
},
protocol::v20,
};
use thiserror::Error;
use tokio::{
sync::{mpsc, oneshot},
time::timeout,
};
use tracing::{debug, info};
use crate::{
ChannelPool,
reprog_controls::{self, ReprogControlsV4},
route::DeviceRoute,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HostSwitchStopReason {
Graceful,
DeviceLost,
}
const HOST_CONTROL_IDS: [(reprog_controls::ControlId, u8); 3] = [
(reprog_controls::control_ids::HOST_SWITCH_CHANNEL_1, 0),
(reprog_controls::control_ids::HOST_SWITCH_CHANNEL_2, 1),
(reprog_controls::control_ids::HOST_SWITCH_CHANNEL_3, 2),
];
const HOST_TASK_IDS: [(reprog_controls::TaskId, u8); 3] = [
(reprog_controls::task_ids::HOST_SWITCH_CHANNEL_1, 0),
(reprog_controls::task_ids::HOST_SWITCH_CHANNEL_2, 1),
(reprog_controls::task_ids::HOST_SWITCH_CHANNEL_3, 2),
];
const HIDPP_OPERATION_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Clone, Copy)]
enum ReportingMode {
Diverted,
Analytics,
}
#[derive(Clone, Copy)]
struct ArmedControl {
cid: u16,
host: u8,
mode: ReportingMode,
original: reprog_controls::CidReporting,
}
#[derive(Debug, Error)]
pub enum HostSwitchError {
#[error("HID transport error")]
Hid(#[from] async_hid::HidError),
#[error("configured keyboard is not connected")]
KeyboardNotFound,
#[error("configured linked device is not connected")]
TargetNotFound,
#[error("HID++ protocol error: {0}")]
Hidpp(String),
#[error("HID++ operation timed out while {operation}")]
TimedOut {
operation: &'static str,
},
#[error("keyboard exposes no reportable host switch controls")]
UnsupportedKeyboard,
#[error("host {host} is not paired on this device")]
HostSlotEmpty {
host: u8,
},
}
pub async fn run_host_switch_session(
keyboard: DeviceRoute,
shutdown: oneshot::Receiver<HostSwitchStopReason>,
channel_pool: ChannelPool,
) -> Result<Option<u8>, HostSwitchError> {
let channel = open_channel(&channel_pool, &keyboard, "opening keyboard channel")
.await?
.ok_or(HostSwitchError::KeyboardNotFound)?;
let keyboard_index = keyboard.device_index();
let device = timed_hidpp(
"opening keyboard device",
Device::new(Arc::clone(&channel), keyboard_index),
)
.await?;
let feature = timed_hidpp(
"locating host controls",
device.root().get_feature(reprog_controls::FEATURE_ID),
)
.await?
.ok_or(HostSwitchError::UnsupportedKeyboard)?;
let controls = ReprogControlsV4::new(Arc::clone(&channel), keyboard_index, feature.index);
let armed = arm_host_controls(&controls).await?;
if armed.is_empty() {
return Err(HostSwitchError::UnsupportedKeyboard);
}
let (press_tx, mut press_rx) = mpsc::unbounded_channel();
let feature_index = controls.feature_index();
let event_controls = armed.clone();
let listener = channel.add_msg_listener_guarded(move |raw, matched| {
if matched {
return;
}
let message = v20::Message::from(raw);
let Some(event) =
reprog_controls::decode_full_event(&message, keyboard_index, feature_index)
else {
return;
};
if let Some(host) = event_host(&event_controls, event) {
let _ = press_tx.send(host);
}
});
info!(
route = %keyboard,
controls = armed.len(),
"host switch link active"
);
let outcome = tokio::select! {
reason = shutdown => {
let reason = reason.unwrap_or(HostSwitchStopReason::DeviceLost);
(None, reason == HostSwitchStopReason::Graceful)
},
Some(host) = press_rx.recv() => (Some(host), true),
};
drop(listener);
if outcome.1 {
restore_host_controls(&controls, armed).await;
}
Ok(outcome.0)
}
pub async fn switch_linked_hosts(
keyboard: &DeviceRoute,
targets: &[DeviceRoute],
host: u8,
channel_pool: &ChannelPool,
) -> Result<bool, HostSwitchError> {
let channel = open_channel(channel_pool, keyboard, "opening keyboard channel")
.await?
.ok_or(HostSwitchError::KeyboardNotFound)?;
let keyboard_change = prepare_host_change_on(&channel, keyboard.device_index(), host).await?;
for target in targets {
match prepare_host_change(target, host, keyboard, &channel, channel_pool).await {
Ok(change) => {
if let Err(error) = apply_host_change(change).await {
debug!(%error, route = %target, host, "linked device host switch failed");
}
}
Err(error) => {
debug!(%error, route = %target, host, "linked device host switch preparation failed");
}
}
}
let changed = apply_host_change(keyboard_change).await?;
if changed {
debug!(host, route = %keyboard, "keyboard host switched");
}
Ok(changed)
}
async fn arm_host_controls(
controls: &ReprogControlsV4,
) -> Result<Vec<ArmedControl>, HostSwitchError> {
let mut armed = Vec::new();
if let Err(error) = arm_host_controls_inner(controls, &mut armed).await {
restore_host_controls(controls, armed).await;
return Err(error);
}
Ok(armed)
}
async fn arm_host_controls_inner(
controls: &ReprogControlsV4,
armed: &mut Vec<ArmedControl>,
) -> Result<(), HostSwitchError> {
let count = timed_hidpp("reading host control count", controls.get_count()).await?;
for index in 0..count {
let info = timed_hidpp(
"reading host control information",
controls.get_ctrl_id_info(index),
)
.await?;
let Some(host) = host_channel(info) else {
continue;
};
debug!(
cid = format_args!("{:#06x}", info.cid),
task_id = format_args!("{:#06x}", info.task_id),
host,
divertable = info.is_divertable(),
analytics = info.supports_analytics_events(),
"host switch control discovered"
);
let mode = if info.is_divertable() {
Some(ReportingMode::Diverted)
} else if info.supports_analytics_events() {
Some(ReportingMode::Analytics)
} else {
None
};
if let Some(mode) = mode {
let original = timed_hidpp(
"reading host control reporting",
controls.get_cid_reporting(info.cid),
)
.await?;
armed.push(ArmedControl {
cid: info.cid,
host,
mode,
original,
});
match mode {
ReportingMode::Diverted => {
timed_hidpp(
"diverting host control",
controls.set_cid_reporting(info.cid, true, false),
)
.await?;
}
ReportingMode::Analytics => {
timed_hidpp(
"enabling host control analytics",
controls.set_cid_reporting_full(
info.cid,
reprog_controls::CidReportingChange {
analytics_key_events: Some(true),
..reprog_controls::CidReportingChange::default()
},
),
)
.await?;
}
}
}
}
Ok(())
}
async fn restore_host_controls(controls: &ReprogControlsV4, armed: Vec<ArmedControl>) {
for control in armed {
let mut restored = restore_host_control(controls, control).await;
if restored.is_err() {
restored = restore_host_control(controls, control).await;
}
if let Err(error) = restored {
debug!(
?error,
cid = control.cid,
"could not restore host switch control"
);
}
}
}
async fn restore_host_control(
controls: &ReprogControlsV4,
control: ArmedControl,
) -> Result<(), HostSwitchError> {
timed_hidpp(
"restoring host control reporting",
controls.set_cid_reporting_full(control.cid, restoration_change(control)),
)
.await
.map(|_echo| ())
}
fn restoration_change(control: ArmedControl) -> reprog_controls::CidReportingChange {
match control.mode {
ReportingMode::Diverted => reprog_controls::CidReportingChange {
diverted: Some(control.original.diverted),
raw_xy: Some(control.original.raw_xy),
..reprog_controls::CidReportingChange::default()
},
ReportingMode::Analytics => reprog_controls::CidReportingChange {
analytics_key_events: Some(control.original.analytics_key_events),
..reprog_controls::CidReportingChange::default()
},
}
}
struct PreparedHostChange {
feature: Arc<ChangeHostFeature>,
device_index: u8,
host: u8,
required: bool,
}
async fn prepare_host_change(
target: &DeviceRoute,
host: u8,
keyboard: &DeviceRoute,
keyboard_channel: &Arc<HidppChannel>,
channel_pool: &ChannelPool,
) -> Result<PreparedHostChange, HostSwitchError> {
if shares_channel(target, keyboard) {
prepare_host_change_on(keyboard_channel, target.device_index(), host).await
} else {
let channel = open_channel(channel_pool, target, "opening linked device channel")
.await?
.ok_or(HostSwitchError::TargetNotFound)?;
prepare_host_change_on(&channel, target.device_index(), host).await
}
}
async fn prepare_host_change_on(
channel: &Arc<HidppChannel>,
device_index: u8,
host: u8,
) -> Result<PreparedHostChange, HostSwitchError> {
let mut device = timed_hidpp(
"opening host-change device",
Device::new(Arc::clone(channel), device_index),
)
.await?;
let info = timed_hidpp(
"locating host-change feature",
device.root().get_feature(ChangeHostFeature::ID),
)
.await?
.ok_or_else(|| HostSwitchError::Hidpp("ChangeHost is unsupported".into()))?;
let change_host = device.add_feature::<ChangeHostFeature>(info.index);
let state = timed_hidpp("reading current host", change_host.get_host_info()).await?;
let required = host_change_required(state.current_host, state.host_count, host)?;
if required && host_slot_is_empty(&mut device, host).await {
return Err(HostSwitchError::HostSlotEmpty { host });
}
Ok(PreparedHostChange {
feature: change_host,
device_index,
host,
required,
})
}
async fn apply_host_change(change: PreparedHostChange) -> Result<bool, HostSwitchError> {
if !change.required {
let PreparedHostChange {
device_index, host, ..
} = change;
debug!(device_index, host, "device already uses requested host");
return Ok(false);
}
timed_hidpp(
"writing current host",
change.feature.set_current_host(change.host),
)
.await?;
Ok(true)
}
async fn open_channel(
channel_pool: &ChannelPool,
route: &DeviceRoute,
operation: &'static str,
) -> Result<Option<Arc<HidppChannel>>, HostSwitchError> {
timeout(HIDPP_OPERATION_TIMEOUT, channel_pool.open(route))
.await
.map_err(|_| HostSwitchError::TimedOut { operation })?
.map_err(HostSwitchError::Hid)
}
async fn timed_hidpp<T, E>(
operation: &'static str,
future: impl Future<Output = Result<T, E>>,
) -> Result<T, HostSwitchError>
where
E: std::fmt::Debug,
{
timeout(HIDPP_OPERATION_TIMEOUT, future)
.await
.map_err(|_| HostSwitchError::TimedOut { operation })?
.map_err(|error| hidpp_error(operation, error))
}
async fn host_slot_is_empty(device: &mut Device, host: u8) -> bool {
let feature = timed_hidpp(
"locating hosts-info feature",
device.root().get_feature(HostsInfoFeature::ID),
)
.await;
let index = match feature {
Ok(Some(info)) => info.index,
Ok(None) => return false,
Err(error) => {
debug!(host, %error, "hosts-info lookup failed; treating the slot as usable");
return false;
}
};
let hosts_info = device.add_feature::<HostsInfoFeature>(index);
match timed_hidpp(
"reading host slot status",
hosts_info.get_host_info(HostIndex::Slot(host)),
)
.await
{
Ok(slot) => slot.status == HostSlotStatus::Empty,
Err(error) => {
debug!(host, %error, "host slot status is unreadable; treating the slot as usable");
false
}
}
}
fn host_change_required(
current_host: u8,
host_count: u8,
requested_host: u8,
) -> Result<bool, HostSwitchError> {
if requested_host >= host_count {
return Err(HostSwitchError::Hidpp(format!(
"host {requested_host} is outside device host count {host_count}"
)));
}
Ok(current_host != requested_host)
}
fn shares_channel(left: &DeviceRoute, right: &DeviceRoute) -> bool {
left.shares_transport(right)
}
fn hidpp_error(operation: &'static str, error: impl std::fmt::Debug) -> HostSwitchError {
HostSwitchError::Hidpp(format!("{operation}: {error:?}"))
}
fn host_channel(info: reprog_controls::CtrlIdInfo) -> Option<u8> {
HOST_CONTROL_IDS
.iter()
.find_map(|(cid, host)| (info.cid == cid.0).then_some(*host))
.or_else(|| {
HOST_TASK_IDS
.iter()
.find_map(|(task, host)| (info.task_id == task.0).then_some(*host))
})
}
fn event_host(
controls: &[ArmedControl],
event: reprog_controls::ReprogControlsEvent,
) -> Option<u8> {
match event {
reprog_controls::ReprogControlsEvent::DivertedButtons(cids) => controls
.iter()
.find_map(|control| cids.contains(&control.cid.into()).then_some(control.host)),
reprog_controls::ReprogControlsEvent::AnalyticsKeyEvents(events) => {
controls.iter().find_map(|control| {
events
.iter()
.any(|event| event.cid.0 == control.cid)
.then_some(control.host)
})
}
reprog_controls::ReprogControlsEvent::DivertedRawMouseXy { .. }
| reprog_controls::ReprogControlsEvent::DivertedRawWheel { .. } => None,
}
}
#[cfg(test)]
#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
mod tests {
use std::sync::Arc;
use hidpp::channel::HidppChannel;
use super::{
ArmedControl, HostSwitchError, ReportingMode, event_host, host_change_required,
host_channel, prepare_host_change_on, restoration_change, shares_channel,
};
use crate::DeviceRoute;
use crate::reprog_controls::{
AnalyticsKeyEvent, CidReporting, ControlId, CtrlIdInfo, ReprogControlsEvent,
};
use crate::scripted_channel::ScriptedRawHidChannel;
const CHANGE_HOST_INDEX: u8 = 0x04;
const HOSTS_INFO_INDEX: u8 = 0x05;
const BUSY: u8 = 0x08;
#[derive(Clone, Copy, PartialEq, Eq)]
enum SlotStatus {
Reported,
Unimplemented,
LookupErrors,
ReadErrors,
}
fn keyboard_with_an_empty_third_slot(request: &[u8]) -> Option<Vec<u8>> {
scripted_keyboard(request, SlotStatus::Reported)
}
fn keyboard_without_hosts_info(request: &[u8]) -> Option<Vec<u8>> {
scripted_keyboard(request, SlotStatus::Unimplemented)
}
fn keyboard_erroring_on_hosts_info_lookup(request: &[u8]) -> Option<Vec<u8>> {
scripted_keyboard(request, SlotStatus::LookupErrors)
}
fn keyboard_erroring_on_slot_status(request: &[u8]) -> Option<Vec<u8>> {
scripted_keyboard(request, SlotStatus::ReadErrors)
}
fn scripted_keyboard(request: &[u8], slot_status: SlotStatus) -> Option<Vec<u8>> {
if request.len() < 7 || !matches!(request[0], 0x10 | 0x11) {
return None;
}
let mut payload = [0u8; 16];
match (request[2], request[3] >> 4) {
(0x00, 0x01) => payload[0] = 4,
(0x00, 0x00) => {
payload[0] = match u16::from_be_bytes([request[4], request[5]]) {
0x1814 => CHANGE_HOST_INDEX,
0x1815 => match slot_status {
SlotStatus::Unimplemented => 0x00,
SlotStatus::LookupErrors => return Some(feature_error(request, BUSY)),
SlotStatus::Reported | SlotStatus::ReadErrors => HOSTS_INFO_INDEX,
},
_ => 0x00,
};
}
(CHANGE_HOST_INDEX, 0x00) => payload[..2].copy_from_slice(&[3, 0]),
(HOSTS_INFO_INDEX, 0x01) => {
if slot_status == SlotStatus::ReadErrors {
return Some(feature_error(request, BUSY));
}
payload[0] = request[4];
payload[1] = u8::from(request[4] < 2);
}
_ => return None,
}
let mut response = vec![0u8; 7];
response[0] = 0x10;
response[1..4].copy_from_slice(&request[1..4]);
response[4..].copy_from_slice(&payload[..3]);
Some(response)
}
fn feature_error(request: &[u8], error: u8) -> Vec<u8> {
let mut response = vec![0u8; 7];
response[0] = 0x10;
response[1] = request[1];
response[2] = 0xff;
response[3] = request[2];
response[4] = request[3];
response[5] = error;
response
}
async fn scripted_channel(responder: crate::scripted_channel::Responder) -> Arc<HidppChannel> {
let (raw, _handle) = ScriptedRawHidChannel::with_responder(responder);
Arc::new(
HidppChannel::from_raw_channel(raw)
.await
.expect("scripted HID++ channel must open"),
)
}
#[tokio::test]
async fn switching_to_an_unpaired_slot_is_refused() {
let channel = scripted_channel(keyboard_with_an_empty_third_slot).await;
let Err(error) = prepare_host_change_on(&channel, 1, 2).await else {
panic!("an unpaired slot must not be switched to");
};
assert!(
matches!(error, HostSwitchError::HostSlotEmpty { host: 2 }),
"got {error:?}"
);
}
#[tokio::test]
async fn switching_to_a_paired_slot_proceeds() {
let channel = scripted_channel(keyboard_with_an_empty_third_slot).await;
let change = prepare_host_change_on(&channel, 1, 1)
.await
.expect("a paired slot must be switchable");
assert!(change.required, "host 1 differs from the current host 0");
}
#[tokio::test]
async fn a_device_without_hosts_info_is_still_switched() {
let channel = scripted_channel(keyboard_without_hosts_info).await;
let change = prepare_host_change_on(&channel, 1, 2)
.await
.expect("a device that cannot report slot status must still switch");
assert!(change.required);
}
#[tokio::test]
async fn a_failed_hosts_info_lookup_does_not_block_the_switch() {
let channel = scripted_channel(keyboard_erroring_on_hosts_info_lookup).await;
let change = prepare_host_change_on(&channel, 1, 2)
.await
.expect("an errored feature lookup must not abort the switch");
assert!(change.required);
}
#[tokio::test]
async fn an_unreadable_slot_status_does_not_block_the_switch() {
let channel = scripted_channel(keyboard_erroring_on_slot_status).await;
let change = prepare_host_change_on(&channel, 1, 2)
.await
.expect("an errored status read must not abort the switch");
assert!(change.required);
}
#[tokio::test]
async fn a_switch_to_the_current_host_never_consults_slot_status() {
let channel = scripted_channel(keyboard_with_an_empty_third_slot).await;
let change = prepare_host_change_on(&channel, 1, 0)
.await
.expect("staying on the current host is always fine");
assert!(!change.required);
}
fn reporting(diverted: bool, raw_xy: bool, analytics_key_events: bool) -> CidReporting {
CidReporting {
cid: ControlId(0x00d3),
diverted,
persistently_diverted: true,
force_raw_xy: true,
raw_xy,
remap: Some(ControlId(0x1234)),
analytics_key_events,
raw_wheel: true,
}
}
#[test]
fn receiver_slots_share_one_channel() {
let keyboard = DeviceRoute::Bolt {
receiver_uid: "AABB".into(),
slot: 1,
};
let mouse = DeviceRoute::Bolt {
receiver_uid: "aabb".into(),
slot: 2,
};
assert!(shares_channel(&keyboard, &mouse));
}
#[test]
fn direct_devices_do_not_share_channels() {
let route = DeviceRoute::Direct {
vendor_id: 0x046d,
product_id: 0xb025,
};
assert!(!shares_channel(&route, &route));
}
#[test]
fn host_controls_are_recognized_by_task_when_cid_varies() {
let info = CtrlIdInfo {
cid: 0x1234,
task_id: 0x00af,
flags: 0,
};
assert_eq!(host_channel(info), Some(1));
}
#[test]
fn analytics_event_selects_the_matching_host() {
let controls = [ArmedControl {
cid: 0x00d3,
host: 2,
mode: ReportingMode::Analytics,
original: reporting(false, false, false),
}];
let mut events = [AnalyticsKeyEvent::default(); 5];
events[0] = AnalyticsKeyEvent {
cid: ControlId(0x00d3),
event: 1,
};
assert_eq!(
event_host(&controls, ReprogControlsEvent::AnalyticsKeyEvents(events)),
Some(2)
);
}
#[test]
fn current_host_does_not_require_a_change() {
assert!(matches!(host_change_required(1, 3, 1), Ok(false)));
}
#[test]
fn different_valid_host_requires_a_change() {
assert!(matches!(host_change_required(0, 3, 2), Ok(true)));
}
#[test]
fn host_outside_device_range_is_rejected() {
assert!(
host_change_required(0, 2, 2).is_err(),
"host 2 is outside a device that reports 2 hosts and must be rejected"
);
}
#[test]
fn diverted_cleanup_restores_only_the_original_temporary_bits() {
let change = restoration_change(ArmedControl {
cid: 0x00d3,
host: 2,
mode: ReportingMode::Diverted,
original: reporting(true, true, false),
});
assert_eq!(change.diverted, Some(true));
assert_eq!(change.raw_xy, Some(true));
assert_eq!(change.analytics_key_events, None);
assert_eq!(change.persistently_diverted, None);
assert_eq!(change.remap, None);
}
#[test]
fn analytics_cleanup_restores_the_original_analytics_bit() {
let change = restoration_change(ArmedControl {
cid: 0x00d3,
host: 2,
mode: ReportingMode::Analytics,
original: reporting(false, false, true),
});
assert_eq!(change.analytics_key_events, Some(true));
assert_eq!(change.diverted, None);
assert_eq!(change.raw_xy, None);
}
}