diff --git a/crates/openlogi-agent-core/src/orchestrator.rs b/crates/openlogi-agent-core/src/orchestrator.rs index 3dad2eec..605ea582 100644 --- a/crates/openlogi-agent-core/src/orchestrator.rs +++ b/crates/openlogi-agent-core/src/orchestrator.rs @@ -11,7 +11,7 @@ //! (still valid) values — exactly the GUI's "window never opened" behaviour. use std::collections::{BTreeMap, HashSet}; -use std::sync::atomic::{AtomicI32, Ordering}; +use std::sync::atomic::{AtomicI32, AtomicU64, Ordering}; use std::sync::{Arc, RwLock}; use openlogi_core::binding::Action; @@ -23,7 +23,7 @@ use openlogi_hid::{ CaptureChannel, ChannelPool, ChannelRegistry, DIRECT_DEVICE_INDEX, DeviceRoute, KEYBOARD_KEY_CIDS, }; -use tracing::{info, warn}; +use tracing::{debug, info, warn}; use crate::DpiCycleState; use crate::bindings::{bindings_for, gesture_bindings_for, oshook_gestures_for}; @@ -84,6 +84,10 @@ pub struct SharedRuntime { /// The keyboard capture session's open channel, reused by Fn-lock writes /// (the mouse-oriented [`Self::capture_channel`] points elsewhere). pub keyboard_channel: CaptureChannel, + /// Incremented when the selected device reconnects or the system wakes, so + /// the gesture watcher re-arms volatile HID++ control diversion even when + /// the receiver route itself never changed. + pub capture_rearm_generation: Arc, /// Receiver access shared by HID++ sessions and pairing. Pairing/host /// transitions are exclusive; capture sessions share under read leases. pub receiver_access: ReceiverAccess, @@ -153,6 +157,7 @@ impl Orchestrator { channel_pool: ChannelPool::default(), keyboard_spec: Arc::new(RwLock::new(None)), keyboard_channel: Arc::new(RwLock::new(None)), + capture_rearm_generation: Arc::new(AtomicU64::new(0)), receiver_access: ReceiverAccess::default(), host_switch_links: Arc::new(RwLock::new(Vec::new())), }; @@ -339,6 +344,9 @@ impl Orchestrator { // (offline→online), or — via the // flag — a system wake where none of those are observable. let reapply_all = std::mem::take(&mut self.reapply_all_next_refresh); + let next_current = pick_current(&devices, self.config.selected_device()); + let rearm_capture = + selected_needs_capture_rearm(&self.devices, &devices, next_current, reapply_all); let followup = std::mem::take(&mut self.reapply_followup); let (targets, next_followup) = plan_reapply(&self.devices, &devices, &followup, reapply_all); @@ -353,7 +361,11 @@ impl Orchestrator { || a.capabilities != b.capabilities || a.light_capabilities != b.light_capabilities }); - if !changed { + if changed { + self.devices = devices; + self.current = next_current; + self.rebuild(); + } else { // 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; @@ -363,11 +375,15 @@ impl Orchestrator { host_switch_links(&self.config, &self.devices), "host_switch_links", ); - return; } - self.devices = devices; - self.current = pick_current(&self.devices, self.config.selected_device()); - self.rebuild(); + if rearm_capture { + let generation = self + .shared + .capture_rearm_generation + .fetch_add(1, Ordering::Relaxed) + .wrapping_add(1); + debug!(generation, "selected device requires capture re-arm"); + } } /// Force a volatile-settings re-apply for every online device on the next @@ -828,6 +844,18 @@ fn reapply_targets(prev: &[AgentDevice], next: &[AgentDevice], reapply_all: bool .collect() } +/// Whether this refresh invalidated the selected device's volatile control +/// diversion. Receiver routes stay connected while a paired mouse sleeps, so +/// route equality alone cannot tell the capture watcher to re-arm on wake. +fn selected_needs_capture_rearm( + prev: &[AgentDevice], + next: &[AgentDevice], + selected: usize, + reapply_all: bool, +) -> bool { + reapply_targets(prev, next, reapply_all).contains(&selected) +} + /// Plan this refresh's volatile-settings writes: the [`reapply_targets`] set /// plus one confirming re-apply for devices first sighted last refresh, and /// the follow-up keys to confirm next refresh. @@ -892,6 +920,7 @@ mod tests { use super::{ AgentDevice, InventoryHealth, Orchestrator, build_devices, configured_wheel_mode, host_switch_links, pick_current, plan_reapply, reapply_targets, + selected_needs_capture_rearm, }; use openlogi_core::config::{Config, LightSettings, ScrollResolution}; use openlogi_core::device::{ @@ -1189,6 +1218,29 @@ mod tests { assert_eq!(reapply_targets(&prev, &next, true), vec![0]); } + #[test] + fn selected_receiver_reconnect_requests_capture_rearm() { + let prev = [dev("selected", 1, false), dev("other", 2, true)]; + let next = [dev("selected", 1, true), dev("other", 2, true)]; + + assert!(selected_needs_capture_rearm(&prev, &next, 0, false)); + assert!(!selected_needs_capture_rearm(&prev, &next, 1, false)); + } + + #[test] + fn system_wake_requests_capture_rearm_for_selected_online_device() { + let devices = [dev("selected", 1, true), dev("other", 2, true)]; + + assert!(selected_needs_capture_rearm(&devices, &devices, 0, true)); + } + + #[test] + fn steady_inventory_does_not_cycle_capture() { + let devices = [dev("selected", 1, true)]; + + assert!(!selected_needs_capture_rearm(&devices, &devices, 0, false)); + } + #[test] fn plan_reapply_confirms_a_first_sighting_once() { use std::collections::HashSet; diff --git a/crates/openlogi-agent-core/src/watchers/gesture.rs b/crates/openlogi-agent-core/src/watchers/gesture.rs index 0515b3c3..4ba014c4 100644 --- a/crates/openlogi-agent-core/src/watchers/gesture.rs +++ b/crates/openlogi-agent-core/src/watchers/gesture.rs @@ -19,7 +19,7 @@ //! way regardless. use std::collections::BTreeMap; -use std::sync::atomic::{AtomicI32, Ordering}; +use std::sync::atomic::{AtomicI32, AtomicU64, Ordering}; use std::sync::{Arc, RwLock}; use std::thread; use std::time::{Duration, Instant}; @@ -83,6 +83,7 @@ pub fn spawn( dpi_cycle: Arc>, capture_channel: CaptureChannel, thumbwheel_sensitivity: ThumbwheelSensitivity, + capture_rearm_generation: Arc, receiver_access: ReceiverAccess, ) { spawn_inner( @@ -91,18 +92,24 @@ pub fn spawn( dpi_cycle, capture_channel, thumbwheel_sensitivity, + capture_rearm_generation, receiver_access, None, ); } /// Spawn capture in Agent mode, reusing only inventory-published channels. +#[expect( + clippy::too_many_arguments, + reason = "agent mode needs maps, dpi, rearm, leases, and registry" +)] pub fn spawn_with_registry( hook_maps: SharedHookMaps, gesture_bindings: GestureBindings, dpi_cycle: Arc>, capture_channel: CaptureChannel, thumbwheel_sensitivity: ThumbwheelSensitivity, + capture_rearm_generation: Arc, receiver_access: ReceiverAccess, registry: ChannelRegistry, ) { @@ -112,17 +119,23 @@ pub fn spawn_with_registry( dpi_cycle, capture_channel, thumbwheel_sensitivity, + capture_rearm_generation, receiver_access, Some(registry), ); } +#[expect( + clippy::too_many_arguments, + reason = "capture manager needs maps, dpi, rearm, leases, and registry" +)] fn spawn_inner( hook_maps: SharedHookMaps, gesture_bindings: GestureBindings, dpi_cycle: Arc>, capture_channel: CaptureChannel, thumbwheel_sensitivity: ThumbwheelSensitivity, + capture_rearm_generation: Arc, receiver_access: ReceiverAccess, registry: Option, ) { @@ -143,6 +156,7 @@ fn spawn_inner( dpi_cycle, capture_channel, thumbwheel_sensitivity, + capture_rearm_generation, receiver_access, registry, )); @@ -186,6 +200,37 @@ pub(crate) fn should_rearm(done_epoch: u64, live_epoch: u64, has_target: bool) - done_epoch == live_epoch && has_target } +#[derive(Debug, Clone, PartialEq, Eq)] +struct CaptureTarget { + route: DeviceRoute, + capture_thumbwheel: bool, + divert_gesture_button: bool, + rearm_generation: u64, +} + +/// Why to stop the current session when the desired target changes. +fn stop_for_target_change( + want: Option<&CaptureTarget>, + current: Option<&CaptureTarget>, + connection_is_current: bool, +) -> Option { + let cur = current?; + if want == Some(cur) { + return (!connection_is_current).then_some(CaptureStop::Revoked); + } + // Same route, new generation (reconnect/wake): skip restore — firmware already reset. + if want.is_some_and(|next| { + next.route == cur.route && next.rearm_generation != cur.rearm_generation + }) { + return Some(CaptureStop::Revoked); + } + Some(CaptureStop::Graceful) +} + +#[cfg_attr( + not(test), + allow(dead_code, reason = "kept for unit tests of stop policy") +)] fn stop_reason( want: Option<&T>, current: Option<&T>, @@ -277,18 +322,22 @@ fn spawn_capture_session(launch: CaptureLaunch) -> oneshot::Sender /// Keep one capture session alive for the active device, restarting it when the /// device or the thumb-wheel arming changes, and dispatch incoming inputs. Runs /// for the lifetime of the process. +#[expect( + clippy::too_many_arguments, + reason = "capture manager needs maps, dpi, rearm, leases, and registry" +)] async fn manage( hook_maps: SharedHookMaps, gesture_bindings: GestureBindings, dpi_cycle: Arc>, capture_channel: CaptureChannel, thumbwheel_sensitivity: ThumbwheelSensitivity, + capture_rearm_generation: Arc, receiver_access: ReceiverAccess, registry: Option, ) { let (tx, mut rx) = mpsc::unbounded_channel::(); - // (route, capture_thumbwheel, divert_gesture_button) - let mut current: Option<(DeviceRoute, bool, bool)> = None; + let mut current: Option = None; let mut stop: Option> = None; let mut stopping_epoch: Option = None; let mut ticker = tokio::time::interval(TARGET_POLL); @@ -336,12 +385,12 @@ async fn manage( // thread the full config in. Re-evaluated each tick, so a // ReloadConfig owner change restarts the session accordingly. let divert_gesture = gesture_bindings.read().is_ok_and(|g| !g.is_empty()); - target.map(|t| { - ( - t, - thumbwheel_armed(&hook_maps, sensitivity), - divert_gesture, - ) + let rearm_generation = capture_rearm_generation.load(Ordering::Relaxed); + target.map(|route| CaptureTarget { + route, + capture_thumbwheel: thumbwheel_armed(&hook_maps, sensitivity), + divert_gesture_button: divert_gesture, + rearm_generation, }) }; if stopping_epoch.is_some() { @@ -350,7 +399,7 @@ async fn manage( let connection_is_current = capture_connection_is_current(registry.as_ref(), &capture_channel); if let Some(reason) = - stop_reason(want.as_ref(), current.as_ref(), connection_is_current) + stop_for_target_change(want.as_ref(), current.as_ref(), connection_is_current) { if let Some(stop) = stop.take() { let _ = stop.send(reason); @@ -362,17 +411,17 @@ async fn manage( if want == current { continue; } - if let Some((route, capture_thumbwheel, divert_gesture_button)) = want { + if let Some(target) = want { let Some(receiver_lease) = receiver_access.try_acquire_for_session() else { current = None; continue; }; - current = Some((route.clone(), capture_thumbwheel, divert_gesture_button)); + current = Some(target.clone()); epoch = epoch.wrapping_add(1); stop = Some(spawn_capture_session(CaptureLaunch { - route, - capture_thumbwheel, - divert_gesture_button, + route: target.route, + capture_thumbwheel: target.capture_thumbwheel, + divert_gesture_button: target.divert_gesture_button, sink: tx.clone(), channel_slot: Arc::clone(&capture_channel), receiver_lease, diff --git a/crates/openlogi-agent/src/main.rs b/crates/openlogi-agent/src/main.rs index 3e70b91e..083f9da3 100644 --- a/crates/openlogi-agent/src/main.rs +++ b/crates/openlogi-agent/src/main.rs @@ -130,6 +130,7 @@ fn spawn_hidpp_watchers(shared: &SharedRuntime) { shared.dpi_cycle.clone(), shared.capture_channel.clone(), shared.thumbwheel_sensitivity.clone(), + shared.capture_rearm_generation.clone(), shared.receiver_access.clone(), shared.channel_registry.clone(), ); diff --git a/crates/openlogi-agent/src/pairing.rs b/crates/openlogi-agent/src/pairing.rs index b0b4b201..385e6a9e 100644 --- a/crates/openlogi-agent/src/pairing.rs +++ b/crates/openlogi-agent/src/pairing.rs @@ -277,6 +277,7 @@ mod tests { channel_pool: openlogi_hid::ChannelPool::default(), keyboard_spec: Arc::new(RwLock::new(None)), keyboard_channel: Arc::new(RwLock::new(None)), + capture_rearm_generation: Arc::new(0.into()), receiver_access: ReceiverAccess::default(), host_switch_links: Arc::new(RwLock::new(Vec::new())), } diff --git a/crates/openlogi-hid/src/gesture.rs b/crates/openlogi-hid/src/gesture.rs index c3c7c455..ab740c06 100644 --- a/crates/openlogi-hid/src/gesture.rs +++ b/crates/openlogi-hid/src/gesture.rs @@ -66,8 +66,9 @@ pub enum CaptureStop { /// The target/configuration changed while the channel is still current, so /// diverted controls must be restored before the session exits. Graceful, - /// Inventory revoked or replaced the underlying connection. Clear local - /// ownership without sending restoration requests through the stale channel. + /// Inventory revoked or replaced the underlying connection, or the + /// transport went down. Clear local ownership without restore writes — + /// the device has already discarded volatile diversion state. Revoked, } @@ -105,6 +106,40 @@ pub enum GestureError { /// A HID++ feature call returned an error; inner string carries context. #[error("HID++ protocol error: {0}")] Hidpp(String), + /// An established HID channel disconnected while capture was active. + #[error("HID channel disconnected")] + ChannelDisconnected, +} + +const CAPTURE_HEALTH_POLL: Duration = Duration::from_secs(1); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CaptureExit { + Stopped(CaptureStop), + Disconnected, +} + +async fn wait_for_capture_exit( + chan: &HidppChannel, + shutdown: Shutdown, + poll_period: Duration, +) -> CaptureExit +where + Shutdown: std::future::Future, +{ + tokio::pin!(shutdown); + loop { + tokio::select! { + stop = &mut shutdown => { + return CaptureExit::Stopped(stop); + } + () = tokio::time::sleep(poll_period) => { + if !chan.is_connected() { + return CaptureExit::Disconnected; + } + } + } + } } /// Movement + button state accumulated across messages. Lives behind a `Mutex` @@ -154,8 +189,11 @@ const BACK_FORWARD_DEBOUNCE: Duration = Duration::from_millis(150); /// /// Opens and holds one HID++ channel, diverts whichever of those controls the /// device exposes, and listens. Returns once `shutdown` fires (or its sender is -/// dropped), after restoring every diverted control. Setup errors are returned; -/// failures to restore on the way out are logged, not propagated. +/// dropped), or when the established channel disconnects. A normal shutdown +/// restores every diverted control; [`CaptureStop::Revoked`] and a disconnect +/// skip restoration because the device has already reset its volatile mappings. +/// Setup errors are returned; failures to restore on the way out are logged, +/// not propagated. pub async fn run_capture_session( route: DeviceRoute, capture_thumbwheel: bool, @@ -325,23 +363,38 @@ where thumbwheel = armed.thumb.is_some(), "control capture active" ); - let stop = shutdown.await; + let exit = wait_for_capture_exit(&chan, shutdown, CAPTURE_HEALTH_POLL).await; - teardown_capture( - || replace_capture_slot(&channel_slot, None), - listener, - stop, - || armed.disarm(), - ) - .await; - debug!(index = device_index, "control capture stopped"); - Ok(()) + drop(listener); + replace_capture_slot(&channel_slot, None); + match exit { + CaptureExit::Stopped(CaptureStop::Graceful) => { + armed.disarm().await; + debug!(index = device_index, "control capture stopped"); + Ok(()) + } + CaptureExit::Stopped(CaptureStop::Revoked) => { + debug!( + index = device_index, + "control capture abandoned after reconnect" + ); + Ok(()) + } + CaptureExit::Disconnected => { + debug!(index = device_index, "control capture channel disconnected"); + Err(GestureError::ChannelDisconnected) + } + } } fn replace_capture_slot(slot: &CaptureChannel, value: Option) { *slot.write().unwrap_or_else(PoisonError::into_inner) = value; } +#[cfg_attr( + not(test), + allow(dead_code, reason = "used by gesture session unit tests") +)] async fn teardown_capture( clear: Clear, listener: Listener, diff --git a/crates/openlogi-hid/src/inventory.rs b/crates/openlogi-hid/src/inventory.rs index bd790c64..bce818c3 100644 --- a/crates/openlogi-hid/src/inventory.rs +++ b/crates/openlogi-hid/src/inventory.rs @@ -308,6 +308,51 @@ const ONESHOT_ATTEMPTS: u8 = 4; /// asleep device, so a short pause lets the next attempt read it cleanly. const ONESHOT_RETRY_DELAY: Duration = Duration::from_millis(300); +/// Nodes that remain valid for this tick: everything the OS enumerated plus +/// cached channels whose open transport still reports a live connection. +fn retained_nodes( + enumerated: &HashSet, + cached_channels: impl IntoIterator, +) -> HashSet +where + K: Clone + Eq + Hash, +{ + let mut retained = enumerated.clone(); + retained.extend( + cached_channels + .into_iter() + .filter_map(|(node, connected)| connected.then_some(node)), + ); + retained +} + +/// Add cached channels omitted by this OS enumeration while their open +/// transport still reports a live connection. +fn append_live_cached_channels( + nodes: &mut HashSet, + channels: &ChannelCache, + active: &mut Vec<(async_hid::DeviceInfo, Arc)>, +) { + let retained = retained_nodes( + nodes, + channels + .active + .iter() + .map(|(node, open)| (node.clone(), open.channel.is_connected())), + ); + for node in retained.difference(nodes) { + if let Some(open) = channels.get(node) { + debug!( + ?node, + name = %open.info.name, + "OS enumeration omitted a live HID node; probing cached channel" + ); + active.push((open.info.clone(), Arc::clone(&open.channel))); + } + } + *nodes = retained; +} + impl Enumerator { /// Build a persistent enumerator that publishes its already-open channels /// into `registry` after each settled inventory tick. @@ -357,6 +402,12 @@ impl Enumerator { } } + // IOHIDManager can temporarily omit a Bluetooth device's vendor HID++ + // collection while its already-open handle and ordinary mouse link are + // still live. Keep probing that cached channel instead of turning one + // incomplete OS snapshot into an offline device and stopping capture. + append_live_cached_channels(&mut seen_nodes, &self.channels, &mut active); + if let Some(registry) = &self.registry { registry.retain_nodes(&seen_nodes); } diff --git a/crates/openlogi-hid/src/inventory/probe.rs b/crates/openlogi-hid/src/inventory/probe.rs index 8e2b717d..439e4cc1 100644 --- a/crates/openlogi-hid/src/inventory/probe.rs +++ b/crates/openlogi-hid/src/inventory/probe.rs @@ -3,6 +3,7 @@ use std::{collections::HashMap, sync::Arc}; use futures_concurrency::future::Join as _; use hidpp::{ channel::HidppChannel, + device::Device, receiver::{ self, Receiver, bolt::{ @@ -575,39 +576,69 @@ async fn probe_unifying_slot( // online or not, and the crate's `event.online` reads the wrong notification // byte (payload[1] bit6, always set here — wire-verified `04 62 69 40`), so // neither tells us if the device is actually reachable on this receiver. - // We therefore always attempt the probe (passing `true`) and treat the - // feature walk succeeding as the real liveness signal below — a device that - // moved to Bluetooth answers `DeviceNotFound` and surfaces as offline. + // A cache hit must therefore still do one live round-trip: otherwise cached + // capabilities keep an absent device "online" forever and its reconnect is + // invisible to the agent's volatile-state re-apply/capture re-arm path. let probe_result = timeout( UNIFYING_SLOT_PROBE, - probe_or_reuse(channel, slot, Some(id.clone()), cached, true, tick), + probe_unifying_features(channel, slot, &id, cached, tick), ) .await; - let (probe, outcome) = if let Ok(r) = probe_result { + let (probe, outcome, online) = if let Ok(r) = probe_result { r } else { debug!(slot, budget = ?UNIFYING_SLOT_PROBE, "Unifying slot probe timed out; using cached data if available"); let probe = cached.map_or_else(ProbedFeatures::default, |c| c.probe.clone()); - (probe, CacheOutcome::Seen(id)) + (probe, CacheOutcome::Seen(id), false) }; - let device = PairedDevice { + let device = assemble_unifying_device(slot, codename, event.wpid, register_kind, probe, online); + Some((device, outcome)) +} + +/// Return cached immutable features together with a fresh reachability result. +/// +/// A successful full probe ([`CacheOutcome::Fresh`]) confirms liveness on a +/// cache miss/stale entry. A fresh cached entry normally refreshes its battery, +/// whose successful response ([`CacheOutcome::Update`]) is the liveness check. +/// A failed battery refresh, or a device without that feature, gets a root ping +/// before being treated as offline. +pub(super) async fn probe_unifying_features( + channel: &Arc, + slot: u8, + id: &CacheKey, + cached: Option<&Cached>, + tick: u64, +) -> (ProbedFeatures, CacheOutcome, bool) { + let (probe, outcome) = + probe_or_reuse(channel, slot, Some(id.clone()), cached, true, tick).await; + let online = if matches!(outcome, CacheOutcome::Fresh(..) | CacheOutcome::Update(..)) { + true + } else { + Device::new(Arc::clone(channel), slot).await.is_ok() + }; + (probe, outcome, online) +} + +pub(super) fn assemble_unifying_device( + slot: u8, + codename: Option, + wpid: u16, + register_kind: DeviceKind, + probe: ProbedFeatures, + online: bool, +) -> PairedDevice { + PairedDevice { slot, codename, - wpid: Some(event.wpid), + wpid: Some(wpid), kind: resolve_device_kind(probe.kind, register_kind), - // Reachable on this receiver iff the feature walk got through this tick. - // Caveat: a GUI cache hit can serve stale capabilities for up to - // REFRESH_TICKS after the device leaves for Bluetooth, briefly showing it - // online; self-heals on the next forced re-probe. Add a per-tick liveness - // ping if that window ever matters. - online: probe.capabilities.is_some(), + online, battery: probe.battery, model_info: probe.model_info, capabilities: probe.capabilities, - }; - Some((device, outcome)) + } } /// Reads a Unifying paired device's name. Unifying stores names at diff --git a/crates/openlogi-hid/src/inventory/tests.rs b/crates/openlogi-hid/src/inventory/tests.rs index e7ef39ff..c6ec05b7 100644 --- a/crates/openlogi-hid/src/inventory/tests.rs +++ b/crates/openlogi-hid/src/inventory/tests.rs @@ -10,8 +10,8 @@ use super::cache::{ }; use super::probe::{NodeProbe, assemble_bolt_probe, parse_codename_unifying}; use super::{ - ChannelCache, Enumerator, ONESHOT_ATTEMPTS, one_shot_should_stop, routes_for_inventories, - settle_unhealthy_node, + ChannelCache, Enumerator, ONESHOT_ATTEMPTS, one_shot_should_stop, retained_nodes, + routes_for_inventories, settle_unhealthy_node, }; use crate::inventory::features::ProbedFeatures; use crate::{DIRECT_DEVICE_INDEX, DeviceRoute}; @@ -510,3 +510,14 @@ fn codename_clamps_overlong_len() { fn codename_rejects_short_response() { assert_eq!(parse_codename_unifying(&[0x40]), None); } + +#[test] +fn live_cached_channel_survives_a_transient_enumeration_gap() { + let enumerated = std::collections::HashSet::from([1_u8]); + let cached_channels = [(1_u8, true), (2_u8, true), (3_u8, false)]; + let retained = retained_nodes(&enumerated, cached_channels); + assert!(retained.contains(&1)); + assert!(retained.contains(&2)); + assert!(!retained.contains(&3)); + assert_eq!(retained, std::collections::HashSet::from([1, 2])); +} diff --git a/crates/openlogi-hid/src/transport.rs b/crates/openlogi-hid/src/transport.rs index 9898237a..afe57815 100644 --- a/crates/openlogi-hid/src/transport.rs +++ b/crates/openlogi-hid/src/transport.rs @@ -10,6 +10,8 @@ #[cfg(not(target_os = "windows"))] use std::error::Error; +#[cfg(not(target_os = "windows"))] +use std::sync::atomic::AtomicBool; use std::sync::atomic::{AtomicU16, Ordering}; use std::sync::{Arc, LazyLock}; @@ -433,6 +435,7 @@ pub(crate) struct AsyncHidChannel { reader: Mutex, writer: Mutex, info: DeviceInfo, + connected: AtomicBool, /// Whether the device exposes only the long HID++ report (a BLE-direct /// peripheral on macOS). Reported via `supports_short_long_hidpp` so the /// `hidpp` channel up-converts outgoing short messages to long. @@ -451,9 +454,16 @@ impl AsyncHidChannel { reader: Mutex::new(reader), writer: Mutex::new(writer), info, + connected: AtomicBool::new(true), long_only, } } + + fn mark_disconnected(&self) { + if self.connected.swap(false, Ordering::AcqRel) { + debug!(name = %self.info.name, "HID channel disconnected"); + } + } } #[cfg(not(target_os = "windows"))] @@ -469,8 +479,15 @@ impl RawHidChannel for AsyncHidChannel { async fn write_report(&self, src: &[u8]) -> Result> { let mut w = self.writer.lock().await; - w.write_output_report(src).await?; - Ok(src.len()) + match w.write_output_report(src).await { + Ok(()) => Ok(src.len()), + Err(e) => { + if matches!(e, async_hid::HidError::Disconnected) { + self.mark_disconnected(); + } + Err(e.into()) + } + } } async fn read_report(&self, buf: &mut [u8]) -> Result> { @@ -487,11 +504,18 @@ impl RawHidChannel for AsyncHidChannel { // until the inventory watcher evicts the channel), so park instead. // The contract guarantees every caller races this future against // the channel's close signal, which tears the read down on drop. - Err(async_hid::HidError::Disconnected) => std::future::pending().await, + Err(async_hid::HidError::Disconnected) => { + self.mark_disconnected(); + std::future::pending().await + } Err(e) => Err(e.into()), } } + fn is_connected(&self) -> bool { + self.connected.load(Ordering::Acquire) + } + fn supports_short_long_hidpp(&self) -> Option<(bool, bool)> { // USB / receiver collections carry both reports; BLE-direct collections // are long-only (no short report on macOS), where the `hidpp` channel diff --git a/crates/openlogi-hidpp/src/channel.rs b/crates/openlogi-hidpp/src/channel.rs index f33b7cdd..8fd22d72 100755 --- a/crates/openlogi-hidpp/src/channel.rs +++ b/crates/openlogi-hidpp/src/channel.rs @@ -100,6 +100,15 @@ pub trait RawHidChannel: Sync + Send + 'static { /// must do the same and must not await `read_report` bare. async fn read_report(&self, buf: &mut [u8]) -> Result>; + /// Whether the underlying device connection is still usable. + /// + /// Implementations that can detect a permanent disconnect should override + /// this. The default preserves the behavior of transports that cannot + /// report connection state. + fn is_connected(&self) -> bool { + true + } + /// If the implementation already knows whether the underlying HID channel /// supports HID++ messages, it should return `Some((supports_short, /// supports_long))` from this method. @@ -418,6 +427,11 @@ impl HidppChannel { }) } + /// Whether the underlying HID transport still reports a live connection. + pub fn is_connected(&self) -> bool { + self.raw_channel.is_connected() + } + /// Sets the software ID that should be returned by the next call to /// [`Self::get_sw_id`]. ///