1919//! way regardless.
2020
2121use std:: collections:: BTreeMap ;
22- use std:: sync:: atomic:: { AtomicI32 , Ordering } ;
22+ use std:: sync:: atomic:: { AtomicI32 , AtomicU64 , Ordering } ;
2323use std:: sync:: { Arc , RwLock } ;
2424use std:: thread;
2525use std:: time:: { Duration , Instant } ;
2626
2727use openlogi_core:: binding:: { Action , ButtonId , GestureDirection , default_binding} ;
2828use 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} ;
3030use tokio:: sync:: { mpsc, oneshot} ;
3131use 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) ]
447476mod 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 ) ;
0 commit comments