From e9ff5e35ae6e7ca6d8c239014d9ae84f8873e7c8 Mon Sep 17 00:00:00 2001 From: Luis Date: Tue, 28 Jul 2026 16:03:30 -0600 Subject: [PATCH 1/4] feat(hid): synchronize linked devices on host switch - add keyboard-initiated host-switch session handling - switch configured pointing devices before the keyboard leaves the host - resolve configured host-switch targets through the agent inventory - keep sessions synchronized with device availability changes - add TOML configuration and tests for host-switch targets --- .../openlogi-agent-core/src/orchestrator.rs | 82 +++- .../src/watchers/host_switch.rs | 86 ++++ .../openlogi-agent-core/src/watchers/mod.rs | 1 + crates/openlogi-agent/src/main.rs | 1 + crates/openlogi-agent/src/pairing.rs | 1 + crates/openlogi-core/src/config/device.rs | 32 ++ crates/openlogi-hid/src/host_switch.rs | 382 ++++++++++++++++++ crates/openlogi-hid/src/lib.rs | 2 + crates/openlogi-hid/src/reprog_controls.rs | 1 + 9 files changed, 586 insertions(+), 2 deletions(-) create mode 100644 crates/openlogi-agent-core/src/watchers/host_switch.rs create mode 100644 crates/openlogi-hid/src/host_switch.rs diff --git a/crates/openlogi-agent-core/src/orchestrator.rs b/crates/openlogi-agent-core/src/orchestrator.rs index a3c26fdd..8af4afd0 100644 --- a/crates/openlogi-agent-core/src/orchestrator.rs +++ b/crates/openlogi-agent-core/src/orchestrator.rs @@ -26,6 +26,7 @@ use crate::hook_runtime::{HookMaps, SharedHookMaps}; use crate::ipc::InventoryHealth; use crate::receiver_access::ReceiverAccess; use crate::watchers::gesture::GestureBindings; +use crate::watchers::host_switch::{HostSwitchLink, HostSwitchLinks}; /// The minimal per-device facts the agent needs: the config key (binding / /// preset lookup), the HID++ route (DPI/SmartShift writes + capture target), and @@ -61,6 +62,8 @@ pub struct SharedRuntime { /// Exclusive receiver access shared by HID++ capture and pairing. Capture /// and pairing must never open the same receiver HID node concurrently. pub receiver_access: ReceiverAccess, + /// Keyboard → pointing-device routes resolved from `config.toml`. + pub host_switch_links: HostSwitchLinks, } /// Owns the config + device selection and keeps [`SharedRuntime`] in sync. @@ -112,6 +115,7 @@ impl Orchestrator { )), capture_channel: Arc::new(RwLock::new(None)), receiver_access: ReceiverAccess::default(), + host_switch_links: Arc::new(RwLock::new(Vec::new())), }; let orch = Self { config, @@ -183,6 +187,11 @@ impl Orchestrator { self.config.app_settings.thumbwheel_sensitivity, Ordering::Relaxed, ); + write_value( + &self.shared.host_switch_links, + host_switch_links(&self.config, &self.devices), + "host_switch_links", + ); } /// Apply a fresh inventory snapshot. Always refreshes the snapshot the IPC @@ -222,6 +231,11 @@ impl Orchestrator { // Same set and routes — but keep the fresh `online` flags, or a // device that woke this tick would read as a transition forever. self.devices = devices; + write_value( + &self.shared.host_switch_links, + host_switch_links(&self.config, &self.devices), + "host_switch_links", + ); return; } self.devices = devices; @@ -425,6 +439,31 @@ fn build_devices(inventories: &[DeviceInventory]) -> Vec { devices } +fn host_switch_links(config: &Config, devices: &[AgentDevice]) -> Vec { + config + .devices + .iter() + .filter_map(|(keyboard_key, settings)| { + let keyboard = devices + .iter() + .find(|device| device.config_key == *keyboard_key && device.online)? + .route + .clone()?; + let targets = settings + .host_switch_targets + .iter() + .filter_map(|target_key| { + devices + .iter() + .find(|device| device.config_key == *target_key) + .and_then(|device| device.route.clone()) + }) + .collect::>(); + (!targets.is_empty()).then_some(HostSwitchLink { keyboard, targets }) + }) + .collect() +} + /// The canonical identity of one device: what the GUI carousel orders by, what /// the config key is derived from, and what [`reapply_targets`] matches a device /// against across inventory ticks. @@ -518,8 +557,8 @@ fn write_value(lock: &RwLock, value: T, name: &str) { #[cfg(test)] mod tests { use super::{ - AgentDevice, InventoryHealth, Orchestrator, configured_wheel_mode, plan_reapply, - reapply_targets, + AgentDevice, InventoryHealth, Orchestrator, configured_wheel_mode, host_switch_links, + plan_reapply, reapply_targets, }; use openlogi_core::config::{Config, ScrollResolution}; use openlogi_core::device::Capabilities; @@ -582,6 +621,45 @@ mod tests { assert_eq!(configured_wheel_mode(&config, &device), (None, None)); } + #[test] + fn host_switch_links_keep_sleeping_targets_but_require_online_keyboard() { + let mut config = Config::default(); + config + .devices + .entry("keyboard".into()) + .or_default() + .host_switch_targets = vec!["mouse".into(), "offline".into(), "missing".into()]; + let devices = [ + dev("keyboard", 1, true), + dev("mouse", 2, true), + dev("offline", 3, false), + ]; + + let links = host_switch_links(&config, &devices); + + assert_eq!(links.len(), 1); + assert_eq!( + links[0].keyboard, + DeviceRoute::Bolt { + receiver_uid: "AA00".into(), + slot: 1, + } + ); + assert_eq!( + links[0].targets, + vec![ + DeviceRoute::Bolt { + receiver_uid: "AA00".into(), + slot: 2, + }, + DeviceRoute::Bolt { + receiver_uid: "AA00".into(), + slot: 3, + } + ] + ); + } + #[test] fn reapply_targets_new_arrivals_and_transitions() { // First sighting of an online device → re-apply. diff --git a/crates/openlogi-agent-core/src/watchers/host_switch.rs b/crates/openlogi-agent-core/src/watchers/host_switch.rs new file mode 100644 index 00000000..dfc45202 --- /dev/null +++ b/crates/openlogi-agent-core/src/watchers/host_switch.rs @@ -0,0 +1,86 @@ +//! Keep configured keyboard → pointing-device host-switch links armed. + +use std::sync::{Arc, RwLock}; +use std::thread; +use std::time::Duration; + +use openlogi_hid::{DeviceRoute, run_host_switch_session}; +use tokio::sync::{mpsc, oneshot}; +use tracing::{debug, warn}; + +/// One resolved link. Config keys are converted to live routes by the +/// orchestrator so the transport watcher never needs to understand inventory. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostSwitchLink { + /// Keyboard whose host switch keys initiate the transition. + pub keyboard: DeviceRoute, + /// Pointing devices that follow the keyboard. + pub targets: Vec, +} + +/// Shared resolved links, refreshed with config and inventory. +pub type HostSwitchLinks = Arc>>; + +/// Spawn the host switch session manager. +pub fn spawn(links: HostSwitchLinks) { + thread::spawn(move || { + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(error) => { + warn!(%error, "host switch watcher: could not build tokio runtime"); + return; + } + }; + runtime.block_on(manage(links)); + }); +} + +async fn manage(links: HostSwitchLinks) { + let mut current = Vec::new(); + let mut stops = Vec::new(); + let (done_tx, mut done_rx) = mpsc::unbounded_channel(); + let mut ticker = tokio::time::interval(Duration::from_secs(1)); + + loop { + tokio::select! { + _ = ticker.tick() => { + let wanted = links.read().map_or_else(|_| Vec::new(), |guard| guard.clone()); + if wanted == current { + continue; + } + stop_all(&mut stops); + current.clone_from(&wanted); + for link in wanted { + let (stop_tx, stop_rx) = oneshot::channel(); + stops.push(stop_tx); + let done = done_tx.clone(); + tokio::spawn(async move { + let keyboard = link.keyboard.clone(); + if let Err(error) = + run_host_switch_session(link.keyboard, link.targets, stop_rx).await + { + debug!(%error, route = %keyboard, "host switch session ended"); + } + let _ = done.send(()); + }); + } + } + Some(()) = done_rx.recv() => { + // A host switch intentionally disconnects the keyboard. Clear + // the local snapshot so the next tick attempts to arm it again; + // this also recovers transient setup/read failures. + stop_all(&mut stops); + current.clear(); + } + } + } +} + +fn stop_all(stops: &mut Vec>) { + for stop in stops.drain(..) { + let _ = stop.send(()); + } +} diff --git a/crates/openlogi-agent-core/src/watchers/mod.rs b/crates/openlogi-agent-core/src/watchers/mod.rs index cf2985df..46fb3713 100644 --- a/crates/openlogi-agent-core/src/watchers/mod.rs +++ b/crates/openlogi-agent-core/src/watchers/mod.rs @@ -5,5 +5,6 @@ pub mod accessibility; pub mod foreground_app; pub mod gesture; +pub mod host_switch; pub mod inventory; pub mod pairing; diff --git a/crates/openlogi-agent/src/main.rs b/crates/openlogi-agent/src/main.rs index b5e98729..76f242da 100644 --- a/crates/openlogi-agent/src/main.rs +++ b/crates/openlogi-agent/src/main.rs @@ -163,6 +163,7 @@ async fn run(config: Config) { shared.thumbwheel_sensitivity.clone(), shared.receiver_access.clone(), ); + watchers::host_switch::spawn(shared.host_switch_links.clone()); let mut inventory_rx = watchers::inventory::spawn(Duration::from_secs(2)); let mut app_rx = watchers::foreground_app::spawn(Duration::from_secs(1)); diff --git a/crates/openlogi-agent/src/pairing.rs b/crates/openlogi-agent/src/pairing.rs index ef365590..eb7e510e 100644 --- a/crates/openlogi-agent/src/pairing.rs +++ b/crates/openlogi-agent/src/pairing.rs @@ -271,6 +271,7 @@ mod tests { thumbwheel_sensitivity: Arc::new(0.into()), capture_channel: Arc::new(RwLock::new(None)), receiver_access: ReceiverAccess::default(), + host_switch_links: Arc::new(RwLock::new(Vec::new())), } } diff --git a/crates/openlogi-core/src/config/device.rs b/crates/openlogi-core/src/config/device.rs index 9d73ae37..97a818e1 100644 --- a/crates/openlogi-core/src/config/device.rs +++ b/crates/openlogi-core/src/config/device.rs @@ -113,6 +113,12 @@ pub struct DeviceConfig { /// current resolution unmanaged and omits the field from `config.toml`. #[serde(default, skip_serializing_if = "Option::is_none")] pub scroll_resolution: Option, + /// Physical config keys of pointing devices that follow this keyboard's + /// host switch channel. The relationship is keyboard-initiated: pressing + /// one of this device's host keys switches every listed target first, then + /// lets the keyboard leave the current host. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub host_switch_targets: Vec, } /// `skip_serializing_if` helper for plain `bool` fields whose default is @@ -163,6 +169,8 @@ struct RawDeviceConfig { invert_scroll: bool, #[serde(default)] scroll_resolution: Option, + #[serde(default)] + host_switch_targets: Vec, } impl From for DeviceConfig { @@ -207,6 +215,30 @@ impl From for DeviceConfig { smartshift: raw.smartshift, invert_scroll: raw.invert_scroll, scroll_resolution: raw.scroll_resolution, + host_switch_targets: raw.host_switch_targets, } } } + +#[cfg(test)] +mod tests { + use super::DeviceConfig; + + #[test] + fn host_switch_targets_round_trip_as_physical_keys() -> Result<(), Box> { + let config: DeviceConfig = toml::from_str( + r#"host_switch_targets = [ + "receiver:keyboard:slot:1", + "receiver:mouse:slot:2", +]"#, + )?; + + assert_eq!( + config.host_switch_targets, + ["receiver:keyboard:slot:1", "receiver:mouse:slot:2"] + ); + let serialized = toml::to_string(&config)?; + assert!(serialized.contains("host_switch_targets")); + Ok(()) + } +} diff --git a/crates/openlogi-hid/src/host_switch.rs b/crates/openlogi-hid/src/host_switch.rs new file mode 100644 index 00000000..c1ac4b02 --- /dev/null +++ b/crates/openlogi-hid/src/host_switch.rs @@ -0,0 +1,382 @@ +//! Keyboard-initiated host-switch synchronization. +//! +//! A session temporarily diverts the keyboard's three host controls, observes +//! which channel was pressed, switches the linked pointing devices, and then +//! switches the keyboard itself. Ordering matters: once the keyboard leaves +//! this host its HID++ channel can no longer command a mouse sharing the same +//! receiver. + +use std::sync::Arc; + +use hidpp::{ + channel::HidppChannel, + device::Device, + feature::{CreatableFeature, change_host::ChangeHostFeature}, + protocol::v20, +}; +use thiserror::Error; +use tokio::sync::{mpsc, oneshot}; +use tracing::{debug, info, warn}; + +use crate::{ + reprog_controls::{self, ReprogControlsV4}, + route::{DeviceRoute, open_route_channel}, +}; + +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), +]; + +#[derive(Clone, Copy)] +enum ReportingMode { + Diverted, + Analytics, +} + +#[derive(Clone, Copy)] +struct ArmedControl { + cid: u16, + host: u8, + mode: ReportingMode, +} + +/// Failure while arming or running a host-switch link. +#[derive(Debug, Error)] +pub enum HostSwitchError { + /// HID transport-level failure. + #[error("HID transport error")] + Hid(#[from] async_hid::HidError), + /// The configured keyboard is not currently reachable. + #[error("configured keyboard is not connected")] + KeyboardNotFound, + /// A configured target is not currently reachable. + #[error("configured linked device is not connected")] + TargetNotFound, + /// A required HID++ operation failed. + #[error("HID++ protocol error: {0}")] + Hidpp(String), + /// The keyboard cannot report its host switch controls to software. + #[error("keyboard exposes no reportable host switch controls")] + UnsupportedKeyboard, +} + +/// Capture host switch keys on `keyboard` and make `targets` follow it until +/// `shutdown` resolves. +pub async fn run_host_switch_session( + keyboard: DeviceRoute, + targets: Vec, + shutdown: oneshot::Receiver<()>, +) -> Result<(), HostSwitchError> { + let channel = open_route_channel(&keyboard) + .await? + .ok_or(HostSwitchError::KeyboardNotFound)?; + let keyboard_index = keyboard.device_index(); + let device = Device::new(Arc::clone(&channel), keyboard_index) + .await + .map_err(hidpp_error)?; + let feature = device + .root() + .get_feature(reprog_controls::FEATURE_ID) + .await + .map_err(hidpp_error)? + .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(), + targets = targets.len(), + "host switch link active" + ); + let result = tokio::select! { + _ = shutdown => Ok(()), + Some(host) = press_rx.recv() => { + for target in &targets { + if let Err(error) = set_host(target, host, &keyboard, &channel).await { + warn!(%error, route = %target, host, "linked device host switch failed"); + } + } + // Always switch the keyboard last. This normally severs the channel, + // so the session ends and the manager reconnects if this host becomes + // active again. + let result = set_host_on(&channel, keyboard_index, host).await; + if result.is_ok() { + debug!(host, route = %keyboard, "keyboard host switched"); + } + result + } + }; + + drop(listener); + restore_host_controls(&controls, armed).await; + result +} + +async fn arm_host_controls( + controls: &ReprogControlsV4, +) -> Result, HostSwitchError> { + let count = controls.get_count().await.map_err(hidpp_error)?; + let mut armed = Vec::new(); + for index in 0..count { + let info = controls + .get_ctrl_id_info(index) + .await + .map_err(hidpp_error)?; + 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() { + controls + .set_cid_reporting(info.cid, true, false) + .await + .map_err(hidpp_error)?; + Some(ReportingMode::Diverted) + } else if info.supports_analytics_events() { + controls + .set_cid_reporting_full( + info.cid, + reprog_controls::CidReportingChange { + analytics_key_events: Some(true), + ..reprog_controls::CidReportingChange::default() + }, + ) + .await + .map_err(hidpp_error)?; + Some(ReportingMode::Analytics) + } else { + None + }; + if let Some(mode) = mode { + armed.push(ArmedControl { + cid: info.cid, + host, + mode, + }); + } + } + Ok(armed) +} + +async fn restore_host_controls(controls: &ReprogControlsV4, armed: Vec) { + for control in armed { + let restored = match control.mode { + ReportingMode::Diverted => controls.set_cid_reporting(control.cid, false, false).await, + ReportingMode::Analytics => controls + .set_cid_reporting_full( + control.cid, + reprog_controls::CidReportingChange { + analytics_key_events: Some(false), + ..reprog_controls::CidReportingChange::default() + }, + ) + .await + .map(|_echo| ()), + }; + if let Err(error) = restored { + debug!( + ?error, + cid = control.cid, + "could not restore host switch control" + ); + } + } +} + +async fn set_host( + target: &DeviceRoute, + host: u8, + keyboard: &DeviceRoute, + keyboard_channel: &Arc, +) -> Result<(), HostSwitchError> { + if shares_channel(target, keyboard) { + set_host_on(keyboard_channel, target.device_index(), host).await + } else { + let channel = open_route_channel(target) + .await? + .ok_or(HostSwitchError::TargetNotFound)?; + set_host_on(&channel, target.device_index(), host).await + } +} + +async fn set_host_on( + channel: &Arc, + device_index: u8, + host: u8, +) -> Result<(), HostSwitchError> { + let mut device = Device::new(Arc::clone(channel), device_index) + .await + .map_err(hidpp_error)?; + let info = device + .root() + .get_feature(ChangeHostFeature::ID) + .await + .map_err(hidpp_error)? + .ok_or_else(|| HostSwitchError::Hidpp("ChangeHost is unsupported".into()))?; + let change_host = device.add_feature::(info.index); + let state = change_host.get_host_info().await.map_err(hidpp_error)?; + if host >= state.host_count { + return Err(HostSwitchError::Hidpp(format!( + "host {host} is outside device host count {}", + state.host_count + ))); + } + change_host + .set_current_host(host) + .await + .map_err(hidpp_error) +} + +fn shares_channel(left: &DeviceRoute, right: &DeviceRoute) -> bool { + match (left, right) { + ( + DeviceRoute::Bolt { + receiver_uid: left, .. + }, + DeviceRoute::Bolt { + receiver_uid: right, + .. + }, + ) + | ( + DeviceRoute::Unifying { + receiver_uid: left, .. + }, + DeviceRoute::Unifying { + receiver_uid: right, + .. + }, + ) => left.eq_ignore_ascii_case(right), + _ => false, + } +} + +fn hidpp_error(error: impl std::fmt::Debug) -> HostSwitchError { + HostSwitchError::Hidpp(format!("{error:?}")) +} + +fn host_channel(info: reprog_controls::CtrlIdInfo) -> Option { + 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 { + 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)] +mod tests { + use super::{ArmedControl, ReportingMode, event_host, host_channel, shares_channel}; + use crate::DeviceRoute; + use crate::reprog_controls::{AnalyticsKeyEvent, ControlId, CtrlIdInfo, ReprogControlsEvent}; + + #[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, + }]; + let mut events = [AnalyticsKeyEvent::default(); 5]; + events[0] = AnalyticsKeyEvent { + cid: ControlId(0x00d3), + event: 1, + }; + assert_eq!( + event_host(&controls, ReprogControlsEvent::AnalyticsKeyEvents(events)), + Some(2) + ); + } +} diff --git a/crates/openlogi-hid/src/lib.rs b/crates/openlogi-hid/src/lib.rs index b6cf1547..540460d8 100644 --- a/crates/openlogi-hid/src/lib.rs +++ b/crates/openlogi-hid/src/lib.rs @@ -10,6 +10,7 @@ #![deny(rustdoc::bare_urls)] #![deny(rustdoc::broken_intra_doc_links)] +pub mod host_switch; mod mappings; mod node_ledger; mod route; @@ -35,6 +36,7 @@ pub use hires_wheel::{ get_scroll_wheel_mode_on, set_scroll_inversion, set_scroll_inversion_on, set_scroll_resolution, set_scroll_resolution_on, set_scroll_wheel_mode, set_scroll_wheel_mode_on, }; +pub use host_switch::{HostSwitchError, run_host_switch_session}; pub use hotplug::{HotplugEvent, watch_hotplug}; pub use inventory::{Enumerator, InventoryError, enumerate}; pub use pairing::{ diff --git a/crates/openlogi-hid/src/reprog_controls.rs b/crates/openlogi-hid/src/reprog_controls.rs index c25d4e3b..6ee431a3 100644 --- a/crates/openlogi-hid/src/reprog_controls.rs +++ b/crates/openlogi-hid/src/reprog_controls.rs @@ -30,6 +30,7 @@ pub use hidpp_reprog::{ ControlId, GroupMask, RawWheelResolution, ReprogControlsCapabilities, ReprogControlsEvent, TaskId, decode_event as decode_full_event, }; +pub use hidpp_reprog::{control_ids, task_ids}; /// `ReprogControlsV4` HID++ feature ID. pub const FEATURE_ID: u16 = 0x1b04; From 66fdb0b3363fbcc5dc25300aa4998c32d7b9de50 Mon Sep 17 00:00:00 2001 From: Luis Date: Tue, 28 Jul 2026 17:15:50 -0600 Subject: [PATCH 2/4] feat(agent): resolve host-switch links from device inventory - add shared runtime storage for resolved host-switch links - rebuild links when configuration or device inventory changes - require the initiating keyboard to be online - preserve routes for sleeping linked target devices - ignore missing targets and empty link configurations - add coverage for online keyboard and sleeping target behavior --- .../openlogi-agent-core/src/orchestrator.rs | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/openlogi-agent-core/src/orchestrator.rs b/crates/openlogi-agent-core/src/orchestrator.rs index 8af4afd0..211fed1c 100644 --- a/crates/openlogi-agent-core/src/orchestrator.rs +++ b/crates/openlogi-agent-core/src/orchestrator.rs @@ -144,7 +144,29 @@ impl Orchestrator { } fn current_route(&self) -> Option { - self.devices.get(self.current).and_then(|d| d.route.clone()) + self.devices + .get(self.current) + .filter(|device| device.online) + .and_then(|device| device.route.clone()) + } + + /// Keep the capture/DPI write target aligned with the selected device's + /// live connection state without rebuilding the rest of the DPI cycle. + /// + /// Inventory-only online transitions do not warrant [`Self::rebuild`] + /// (which intentionally resets the cycle index), but they do have to stop + /// and restart HID++ capture. Easy-Switch preserves the receiver route + /// while the device is away, and its volatile control diversion is lost; + /// publishing `None` while offline and the route again on return makes the + /// capture watcher open a fresh session and re-arm those controls. + fn sync_current_route(&self) { + let target = self.current_route(); + match self.shared.dpi_cycle.write() { + Ok(mut state) => state.target = target, + Err(error) => { + warn!(%error, lock = "dpi_cycle", "lock poisoned — keeping stale value"); + } + } } /// Build the OS-hook callback's maps for `key` + foreground `app`. Both hook @@ -231,6 +253,7 @@ impl Orchestrator { // Same set and routes — but keep the fresh `online` flags, or a // device that woke this tick would read as a transition forever. self.devices = devices; + self.sync_current_route(); write_value( &self.shared.host_switch_links, host_switch_links(&self.config, &self.devices), @@ -680,6 +703,31 @@ mod tests { assert!(reapply_targets(&[dev("a", 1, true)], &[dev("a", 1, false)], false).is_empty()); } + #[test] + fn capture_target_tracks_online_state_without_resetting_dpi_cycle() { + let mut orch = Orchestrator::new(Config::default()); + orch.devices = vec![dev("mouse", 1, true)]; + orch.rebuild(); + { + let mut dpi = orch.shared.dpi_cycle.write().unwrap(); + dpi.index = 3; + } + + orch.devices[0].online = false; + orch.sync_current_route(); + { + let dpi = orch.shared.dpi_cycle.read().unwrap(); + assert_eq!(dpi.target, None); + assert_eq!(dpi.index, 3); + } + + orch.devices[0].online = true; + orch.sync_current_route(); + let dpi = orch.shared.dpi_cycle.read().unwrap(); + assert_eq!(dpi.target, orch.devices[0].route); + assert_eq!(dpi.index, 3); + } + #[test] fn reapply_targets_disambiguates_same_model_duplicates() { // Two devices can share a model key but are distinct physical units at From b11c0c8b7916423074f2a78a060389bae8e85011 Mon Sep 17 00:00:00 2001 From: Luis Date: Tue, 28 Jul 2026 20:41:10 -0600 Subject: [PATCH 3/4] CONFIGURATION.md updated to include host switch. --- docs/CONFIGURATION.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 555d169d..ea06be5c 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -32,6 +32,11 @@ MX Master 4): RGB keyboards. - `gesture_owner` — which button owns the gesture role, when chosen explicitly (otherwise inferred). +- `host_switch_targets` — on a compatible keyboard, physical config keys of + mice that should follow its Easy-Switch channel. Both devices must already + be paired on corresponding channels. The keyboard's host controls and every + target must expose the HID++ features needed for host switching. Configure + the link on every computer from which the keyboard may initiate a switch. The app-wide `[app_settings]` block holds `launch_at_login`, `check_for_updates`, and `auto_install_updates` (all off by default); @@ -62,6 +67,12 @@ appearance = "system" [devices.2b042] dpi_presets = [800, 1600, 3200] +# Put this on the keyboard's physical device entry. Values are the physical +# keys of the mice that should follow it; use the exact keys already present +# under [devices] in your generated config. +[devices."receiver:aabbccdd:slot:1"] +host_switch_targets = ["receiver:aabbccdd:slot:2"] + [devices.2b042.bindings] Back = "BrowserBack" Forward = "BrowserForward" From b3694b8b3b4711b9e44d73af6ca527096c201058 Mon Sep 17 00:00:00 2001 From: Luis Date: Tue, 28 Jul 2026 21:49:10 -0600 Subject: [PATCH 4/4] test(agent): avoid unwraps in capture lifecycle test Handle poisoned DPI cycle locks explicitly in the online-state test so the workspace passes Clippy with unwrap_used denied. --- crates/openlogi-agent-core/src/orchestrator.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/openlogi-agent-core/src/orchestrator.rs b/crates/openlogi-agent-core/src/orchestrator.rs index 211fed1c..8a6d7e94 100644 --- a/crates/openlogi-agent-core/src/orchestrator.rs +++ b/crates/openlogi-agent-core/src/orchestrator.rs @@ -709,21 +709,27 @@ mod tests { orch.devices = vec![dev("mouse", 1, true)]; orch.rebuild(); { - let mut dpi = orch.shared.dpi_cycle.write().unwrap(); + let Ok(mut dpi) = orch.shared.dpi_cycle.write() else { + panic!("DPI cycle lock should not be poisoned"); + }; dpi.index = 3; } orch.devices[0].online = false; orch.sync_current_route(); { - let dpi = orch.shared.dpi_cycle.read().unwrap(); + let Ok(dpi) = orch.shared.dpi_cycle.read() else { + panic!("DPI cycle lock should not be poisoned"); + }; assert_eq!(dpi.target, None); assert_eq!(dpi.index, 3); } orch.devices[0].online = true; orch.sync_current_route(); - let dpi = orch.shared.dpi_cycle.read().unwrap(); + let Ok(dpi) = orch.shared.dpi_cycle.read() else { + panic!("DPI cycle lock should not be poisoned"); + }; assert_eq!(dpi.target, orch.devices[0].route); assert_eq!(dpi.index, 3); }