Skip to content

Commit b2de5b3

Browse files
committed
fix(agent): rearm control capture after device reconnect
1 parent cd4de32 commit b2de5b3

9 files changed

Lines changed: 402 additions & 52 deletions

File tree

crates/openlogi-agent-core/src/orchestrator.rs

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@
1111
//! (still valid) values — exactly the GUI's "window never opened" behaviour.
1212
1313
use std::collections::{BTreeMap, HashSet};
14-
use std::sync::atomic::{AtomicI32, Ordering};
14+
use std::sync::atomic::{AtomicI32, AtomicU64, Ordering};
1515
use std::sync::{Arc, RwLock};
1616

1717
use openlogi_core::config::{Config, ScrollResolution};
1818
use openlogi_core::device::{Capabilities, DeviceInventory};
1919
use openlogi_hid::{CaptureChannel, DeviceRoute};
20-
use tracing::warn;
20+
use tracing::{debug, warn};
2121

2222
use crate::DpiCycleState;
2323
use crate::bindings::{bindings_for, gesture_bindings_for, oshook_gestures_for};
@@ -58,6 +58,10 @@ pub struct SharedRuntime {
5858
pub dpi_cycle: Arc<RwLock<DpiCycleState>>,
5959
pub thumbwheel_sensitivity: Arc<AtomicI32>,
6060
pub capture_channel: CaptureChannel,
61+
/// Incremented when the selected device reconnects or the system wakes, so
62+
/// the gesture watcher re-arms volatile HID++ control diversion even when
63+
/// the receiver route itself never changed.
64+
pub capture_rearm_generation: Arc<AtomicU64>,
6165
/// Exclusive receiver access shared by HID++ capture and pairing. Capture
6266
/// and pairing must never open the same receiver HID node concurrently.
6367
pub receiver_access: ReceiverAccess,
@@ -111,6 +115,7 @@ impl Orchestrator {
111115
config.app_settings.thumbwheel_sensitivity,
112116
)),
113117
capture_channel: Arc::new(RwLock::new(None)),
118+
capture_rearm_generation: Arc::new(AtomicU64::new(0)),
114119
receiver_access: ReceiverAccess::default(),
115120
};
116121
let orch = Self {
@@ -205,6 +210,9 @@ impl Orchestrator {
205210
// (offline→online), or — via the
206211
// flag — a system wake where none of those are observable.
207212
let reapply_all = std::mem::take(&mut self.reapply_all_next_refresh);
213+
let next_current = pick_current(&devices, self.config.selected_device());
214+
let rearm_capture =
215+
selected_needs_capture_rearm(&self.devices, &devices, next_current, reapply_all);
208216
let followup = std::mem::take(&mut self.reapply_followup);
209217
let (targets, next_followup) =
210218
plan_reapply(&self.devices, &devices, &followup, reapply_all);
@@ -218,15 +226,23 @@ impl Orchestrator {
218226
|| a.route != b.route
219227
|| a.capabilities != b.capabilities
220228
});
221-
if !changed {
229+
if changed {
230+
self.devices = devices;
231+
self.current = next_current;
232+
self.rebuild();
233+
} else {
222234
// Same set and routes — but keep the fresh `online` flags, or a
223235
// device that woke this tick would read as a transition forever.
224236
self.devices = devices;
225-
return;
226237
}
227-
self.devices = devices;
228-
self.current = pick_current(&self.devices, self.config.selected_device());
229-
self.rebuild();
238+
if rearm_capture {
239+
let generation = self
240+
.shared
241+
.capture_rearm_generation
242+
.fetch_add(1, Ordering::Relaxed)
243+
.wrapping_add(1);
244+
debug!(generation, "selected device requires capture re-arm");
245+
}
230246
}
231247

232248
/// Force a volatile-settings re-apply for every online device on the next
@@ -465,6 +481,18 @@ fn reapply_targets(prev: &[AgentDevice], next: &[AgentDevice], reapply_all: bool
465481
.collect()
466482
}
467483

484+
/// Whether this refresh invalidated the selected device's volatile control
485+
/// diversion. Receiver routes stay connected while a paired mouse sleeps, so
486+
/// route equality alone cannot tell the capture watcher to re-arm on wake.
487+
fn selected_needs_capture_rearm(
488+
prev: &[AgentDevice],
489+
next: &[AgentDevice],
490+
selected: usize,
491+
reapply_all: bool,
492+
) -> bool {
493+
reapply_targets(prev, next, reapply_all).contains(&selected)
494+
}
495+
468496
/// Plan this refresh's volatile-settings writes: the [`reapply_targets`] set
469497
/// plus one confirming re-apply for devices first sighted last refresh, and
470498
/// the follow-up keys to confirm next refresh.
@@ -519,7 +547,7 @@ fn write_value<T>(lock: &RwLock<T>, value: T, name: &str) {
519547
mod tests {
520548
use super::{
521549
AgentDevice, InventoryHealth, Orchestrator, configured_wheel_mode, plan_reapply,
522-
reapply_targets,
550+
reapply_targets, selected_needs_capture_rearm,
523551
};
524552
use openlogi_core::config::{Config, ScrollResolution};
525553
use openlogi_core::device::Capabilities;
@@ -633,6 +661,29 @@ mod tests {
633661
assert_eq!(reapply_targets(&prev, &next, true), vec![0]);
634662
}
635663

664+
#[test]
665+
fn selected_receiver_reconnect_requests_capture_rearm() {
666+
let prev = [dev("selected", 1, false), dev("other", 2, true)];
667+
let next = [dev("selected", 1, true), dev("other", 2, true)];
668+
669+
assert!(selected_needs_capture_rearm(&prev, &next, 0, false));
670+
assert!(!selected_needs_capture_rearm(&prev, &next, 1, false));
671+
}
672+
673+
#[test]
674+
fn system_wake_requests_capture_rearm_for_selected_online_device() {
675+
let devices = [dev("selected", 1, true), dev("other", 2, true)];
676+
677+
assert!(selected_needs_capture_rearm(&devices, &devices, 0, true));
678+
}
679+
680+
#[test]
681+
fn steady_inventory_does_not_cycle_capture() {
682+
let devices = [dev("selected", 1, true)];
683+
684+
assert!(!selected_needs_capture_rearm(&devices, &devices, 0, false));
685+
}
686+
636687
#[test]
637688
fn plan_reapply_confirms_a_first_sighting_once() {
638689
use std::collections::HashSet;

crates/openlogi-agent-core/src/watchers/gesture.rs

Lines changed: 96 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,14 @@
1919
//! way regardless.
2020
2121
use std::collections::BTreeMap;
22-
use std::sync::atomic::{AtomicI32, Ordering};
22+
use std::sync::atomic::{AtomicI32, AtomicU64, Ordering};
2323
use std::sync::{Arc, RwLock};
2424
use std::thread;
2525
use std::time::{Duration, Instant};
2626

2727
use openlogi_core::binding::{Action, ButtonId, GestureDirection, default_binding};
2828
use openlogi_core::config::DEFAULT_THUMBWHEEL_SENSITIVITY;
29-
use openlogi_hid::{CaptureChannel, CapturedInput, DeviceRoute, run_capture_session};
29+
use openlogi_hid::{CaptureChannel, CaptureStop, CapturedInput, DeviceRoute, run_capture_session};
3030
use tokio::sync::{mpsc, oneshot};
3131
use tracing::{debug, warn};
3232

@@ -80,6 +80,7 @@ pub fn spawn(
8080
dpi_cycle: Arc<RwLock<DpiCycleState>>,
8181
capture_channel: CaptureChannel,
8282
thumbwheel_sensitivity: ThumbwheelSensitivity,
83+
capture_rearm_generation: Arc<AtomicU64>,
8384
receiver_access: ReceiverAccess,
8485
) {
8586
thread::spawn(move || {
@@ -99,6 +100,7 @@ pub fn spawn(
99100
dpi_cycle,
100101
capture_channel,
101102
thumbwheel_sensitivity,
103+
capture_rearm_generation,
102104
receiver_access,
103105
));
104106
});
@@ -141,6 +143,27 @@ fn should_rearm(done_epoch: u64, live_epoch: u64, has_target: bool) -> bool {
141143
done_epoch == live_epoch && has_target
142144
}
143145

146+
#[derive(Debug, Clone, PartialEq, Eq)]
147+
struct CaptureTarget {
148+
route: DeviceRoute,
149+
capture_thumbwheel: bool,
150+
divert_gesture_button: bool,
151+
rearm_generation: u64,
152+
}
153+
154+
/// A generation-only restart on the same route follows a device reconnect or
155+
/// system wake. Its old firmware state is already gone, so restoring it would
156+
/// only delay (or permanently block) the replacement session.
157+
fn stop_for_transition(current: &CaptureTarget, next: Option<&CaptureTarget>) -> CaptureStop {
158+
if next.is_some_and(|next| {
159+
next.route == current.route && next.rearm_generation != current.rearm_generation
160+
}) {
161+
CaptureStop::Abandon
162+
} else {
163+
CaptureStop::Restore
164+
}
165+
}
166+
144167
/// Keep one capture session alive for the active device, restarting it when the
145168
/// device or the thumb-wheel arming changes, and dispatch incoming inputs. Runs
146169
/// for the lifetime of the process.
@@ -150,12 +173,12 @@ async fn manage(
150173
dpi_cycle: Arc<RwLock<DpiCycleState>>,
151174
capture_channel: CaptureChannel,
152175
thumbwheel_sensitivity: ThumbwheelSensitivity,
176+
capture_rearm_generation: Arc<AtomicU64>,
153177
receiver_access: ReceiverAccess,
154178
) {
155179
let (tx, mut rx) = mpsc::unbounded_channel::<CapturedInput>();
156-
// (route, capture_thumbwheel, divert_gesture_button)
157-
let mut current: Option<(DeviceRoute, bool, bool)> = None;
158-
let mut stop: Option<oneshot::Sender<()>> = None;
180+
let mut current: Option<CaptureTarget> = None;
181+
let mut stop: Option<oneshot::Sender<CaptureStop>> = None;
159182
let mut ticker = tokio::time::interval(TARGET_POLL);
160183
let mut accumulators = WheelAccumulators::default();
161184
// Capture sessions run as detached tasks, so an unexpected exit (a transient
@@ -197,33 +220,39 @@ async fn manage(
197220
// thread the full config in. Re-evaluated each tick, so a
198221
// ReloadConfig owner change restarts the session accordingly.
199222
let divert_gesture = gesture_bindings.read().is_ok_and(|g| !g.is_empty());
200-
target.map(|t| {
201-
(
202-
t,
203-
thumbwheel_armed(&hook_maps, sensitivity),
204-
divert_gesture,
205-
)
223+
let rearm_generation = capture_rearm_generation.load(Ordering::Relaxed);
224+
target.map(|route| CaptureTarget {
225+
route,
226+
capture_thumbwheel: thumbwheel_armed(&hook_maps, sensitivity),
227+
divert_gesture_button: divert_gesture,
228+
rearm_generation,
206229
})
207230
};
208231
if want == current {
209232
continue;
210233
}
234+
debug!(?current, ?want, "capture target state changed");
211235
// Target or thumb-wheel arming changed (or first tick): stop the
212236
// old session and start one for the new state. Sending on the
213237
// oneshot lets the old session restore the diverted controls.
214238
if let Some(stop) = stop.take() {
215-
let _ = stop.send(());
239+
let reason = current
240+
.as_ref()
241+
.map_or(CaptureStop::Restore, |current| {
242+
stop_for_transition(current, want.as_ref())
243+
});
244+
let _ = stop.send(reason);
216245
}
217246
if current.is_some() {
218247
current = None;
219248
continue;
220249
}
221-
if let Some((route, capture_thumbwheel, divert_gesture_button)) = want {
250+
if let Some(target) = want {
222251
let Some(receiver_lease) = receiver_access.try_acquire_for_capture() else {
223252
current = None;
224253
continue;
225254
};
226-
current = Some((route.clone(), capture_thumbwheel, divert_gesture_button));
255+
current = Some(target.clone());
227256
let (stop_tx, stop_rx) = oneshot::channel();
228257
let sink = tx.clone();
229258
let slot = Arc::clone(&capture_channel);
@@ -233,9 +262,9 @@ async fn manage(
233262
tokio::spawn(async move {
234263
let _receiver_lease = receiver_lease;
235264
if let Err(e) = run_capture_session(
236-
route,
237-
capture_thumbwheel,
238-
divert_gesture_button,
265+
target.route,
266+
target.capture_thumbwheel,
267+
target.divert_gesture_button,
239268
sink,
240269
stop_rx,
241270
slot,
@@ -445,8 +474,58 @@ fn advance(
445474

446475
#[cfg(test)]
447476
mod tests {
477+
use openlogi_hid::{CaptureStop, DeviceRoute};
478+
448479
use super::*;
449480

481+
fn capture_target(route: DeviceRoute, generation: u64) -> CaptureTarget {
482+
CaptureTarget {
483+
route,
484+
capture_thumbwheel: false,
485+
divert_gesture_button: true,
486+
rearm_generation: generation,
487+
}
488+
}
489+
490+
#[test]
491+
fn reconnect_restart_abandons_stale_state_on_the_same_route() {
492+
let route = DeviceRoute::Unifying {
493+
receiver_uid: "receiver".to_string(),
494+
slot: 1,
495+
};
496+
let current = capture_target(route.clone(), 3);
497+
let next = capture_target(route, 4);
498+
499+
assert_eq!(
500+
stop_for_transition(&current, Some(&next)),
501+
CaptureStop::Abandon
502+
);
503+
}
504+
505+
#[test]
506+
fn ordinary_target_changes_restore_old_controls() {
507+
let current = capture_target(
508+
DeviceRoute::Unifying {
509+
receiver_uid: "receiver".to_string(),
510+
slot: 1,
511+
},
512+
3,
513+
);
514+
let next = capture_target(
515+
DeviceRoute::Direct {
516+
vendor_id: 0x046d,
517+
product_id: 0xb023,
518+
},
519+
4,
520+
);
521+
522+
assert_eq!(
523+
stop_for_transition(&current, Some(&next)),
524+
CaptureStop::Restore
525+
);
526+
assert_eq!(stop_for_transition(&current, None), CaptureStop::Restore);
527+
}
528+
450529
#[test]
451530
fn multiplier_is_unity_at_default_sensitivity() {
452531
assert!((scroll_multiplier(DEFAULT_THUMBWHEEL_SENSITIVITY) - 1.0).abs() < f32::EPSILON);

crates/openlogi-agent/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ async fn run(config: Config) {
161161
shared.dpi_cycle.clone(),
162162
shared.capture_channel.clone(),
163163
shared.thumbwheel_sensitivity.clone(),
164+
shared.capture_rearm_generation.clone(),
164165
shared.receiver_access.clone(),
165166
);
166167

crates/openlogi-agent/src/pairing.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ mod tests {
270270
dpi_cycle: Arc::new(RwLock::new(DpiCycleState::default())),
271271
thumbwheel_sensitivity: Arc::new(0.into()),
272272
capture_channel: Arc::new(RwLock::new(None)),
273+
capture_rearm_generation: Arc::new(0.into()),
273274
receiver_access: ReceiverAccess::default(),
274275
}
275276
}

0 commit comments

Comments
 (0)