7
7
from adafruit_hid .keyboard import Keyboard
8
8
from adafruit_hid .mouse import Mouse
9
9
from evdev import InputDevice , InputEvent , KeyEvent , RelEvent , categorize , list_devices
10
+ import pyudev
10
11
import usb_hid
11
12
from usb_hid import Device
12
13
@@ -45,7 +46,7 @@ def init_usb_gadgets() -> None:
45
46
_logger .debug ("Initializing USB gadgets..." )
46
47
usb_hid .enable (
47
48
[
48
- Device .MOUSE ,
49
+ Device .BOOT_MOUSE ,
49
50
Device .KEYBOARD ,
50
51
Device .CONSUMER_CONTROL ,
51
52
] # type: ignore
@@ -136,14 +137,11 @@ async def async_relay_events_loop(self) -> NoReturn:
136
137
async def _async_relay_event (self , input_event : InputEvent ) -> None :
137
138
event = categorize (input_event )
138
139
_logger .debug (f"Received { event } from { self .input_device .name } " )
139
- func = None
140
+
140
141
if isinstance (event , RelEvent ):
141
- func = _move_mouse
142
+ _move_mouse ( event )
142
143
elif isinstance (event , KeyEvent ):
143
- func = _send_key
144
- if func :
145
- loop = asyncio .get_running_loop ()
146
- await loop .run_in_executor (None , func , event )
144
+ _send_key (event )
147
145
148
146
149
147
def _move_mouse (event : RelEvent ) -> None :
@@ -186,7 +184,8 @@ def _get_output_device(event: KeyEvent) -> ConsumerControl | Keyboard | Mouse |
186
184
187
185
class RelayController :
188
186
"""
189
- This class serves as a HID relay to handle Bluetooth keyboard and mouse events from multiple input devices and translate them to USB.
187
+ Manages the TaskGroup of all active DeviceRelay tasks and handles
188
+ add/remove events from UdevEventMonitor.
190
189
"""
191
190
192
191
def __init__ (
@@ -200,59 +199,116 @@ def __init__(
200
199
self ._device_ids = [DeviceIdentifier (id ) for id in device_identifiers ]
201
200
self ._auto_discover = auto_discover
202
201
self ._grab_devices = grab_devices
202
+ self ._task_group : TaskGroup | None = None
203
+ self ._active_tasks : dict [str , asyncio .Task ] = {}
203
204
self ._cancelled = False
204
205
205
- async def async_relay_devices (self ) -> NoReturn :
206
+ async def async_relay_devices (self ) -> None :
207
+ """
208
+ Main method that opens a TaskGroup and waits forever,
209
+ while device add/remove is handled dynamically.
210
+ """
206
211
try :
207
212
async with TaskGroup () as task_group :
208
- await self ._async_discover_devices (task_group )
209
- _logger .critical ("Event loop closed." )
210
- except* Exception :
211
- _logger .exception ("Error(s) in TaskGroup" )
212
-
213
- async def _async_discover_devices (self , task_group : TaskGroup ) -> NoReturn :
214
- async for device in self ._async_discover_devices_loop ():
215
- if not self ._cancelled :
216
- self ._create_task (device , task_group )
217
-
218
- async def _async_discover_devices_loop (self ) -> AsyncGenerator [InputDevice , None ]:
219
- _logger .info ("Discovering input devices..." )
220
- if self ._auto_discover :
221
- _logger .debug ("Auto-discovery enabled. Relaying all input devices." )
213
+ self ._task_group = task_group
214
+ _logger .debug ("RelayController: TaskGroup started." )
215
+
216
+ for dev in await async_list_input_devices ():
217
+ self .add_device (dev )
218
+
219
+ while not self ._cancelled :
220
+ await asyncio .sleep (0.1 )
221
+ except* Exception as exc_grp :
222
+ _logger .exception ("RelayController: Exception in TaskGroup" , exc_info = exc_grp )
223
+ finally :
224
+ self ._task_group = None
225
+ _logger .info ("RelayController: TaskGroup exited." )
226
+
227
+ def add_device (self , device : InputDevice ) -> None :
228
+ """
229
+ Called when a new device is detected. Schedules a new relay task if
230
+ the device passes the _should_relay() check and isn't already tracked.
231
+ """
232
+ if not self ._should_relay (device ):
233
+ _logger .debug (f"Device { device .path } does not match criteria; ignoring." )
234
+ return
235
+
236
+ if self ._task_group is None :
237
+ _logger .critical (f"No TaskGroup available; ignoring device { device .path } ." )
238
+ return
239
+
240
+ if device .path not in self ._active_tasks :
241
+ task = self ._task_group .create_task (
242
+ self ._async_relay_events (device ),
243
+ name = device .path
244
+ )
245
+ self ._active_tasks [device .path ] = task
246
+ _logger .debug (f"Created task for { device .path } ." )
222
247
else :
223
- all_device_ids = " or " .join (str (id ) for id in self ._device_ids )
224
- _logger .debug (f"Relaying devices with matching { all_device_ids } " )
225
- while True :
226
- for device in await async_list_input_devices ():
227
- if self ._should_relay (device ):
228
- yield device
229
- await asyncio .sleep (0.1 )
230
-
231
- def _should_relay (self , device : InputDevice ) -> bool :
232
- return not self ._has_task (device ) and self ._matches_criteria (device )
233
-
234
- def _has_task (self , device : InputDevice ) -> bool :
235
- return device .path in [task .get_name () for task in asyncio .all_tasks ()]
236
-
237
- def _matches_criteria (self , device : InputDevice ) -> bool :
238
- return self ._auto_discover or self ._matches_any_identifier (device )
239
-
240
- def _matches_any_identifier (self , device : InputDevice ) -> bool :
241
- return any (id .matches (device ) for id in self ._device_ids )
248
+ _logger .debug (f"Device { device .path } is already active." )
249
+
250
+ def remove_device (self , device_path : str ) -> None :
251
+ """
252
+ Called when a device is removed. Cancels the associated relay task if running.
253
+ """
254
+ task = self ._active_tasks .pop (device_path , None )
255
+ if task and not task .done ():
256
+ _logger .info (f"Cancelling relay for { device_path } ." )
257
+ task .cancel ()
258
+ else :
259
+ _logger .debug (f"No active task found for { device_path } to remove." )
242
260
243
- def _create_task (self , device : InputDevice , task_group : TaskGroup ) -> None :
244
- task_group .create_task (self ._async_relay_events (device ), name = device .path )
261
+ async def _async_relay_events (self , device : InputDevice ) -> None :
262
+ """
263
+ Creates a DeviceRelay, then loops forever reading events.
264
+ """
265
+ relay = DeviceRelay (device , self ._grab_devices )
266
+ _logger .info (f"Activated { relay } " )
245
267
246
- async def _async_relay_events (self , device : InputDevice ) -> NoReturn :
247
268
try :
248
- relay = DeviceRelay (device , self ._grab_devices )
249
- _logger .info (f"Activated { relay } " )
250
269
await relay .async_relay_events_loop ()
251
270
except CancelledError :
252
- self . _cancelled = True
253
- _logger . critical ( f" { device . name } was cancelled" )
271
+ _logger . debug ( f"Relay cancelled for device { device . path } ." )
272
+ raise
254
273
except (OSError , FileNotFoundError ) as ex :
255
- _logger .critical (f"Connection to { device .name } lost [{ ex !r} ]" )
274
+ _logger .critical (f"Lost connection to { device .path } [{ ex !r} ]. " )
256
275
except Exception :
257
- _logger .exception (f"{ device .name } failed!" )
258
- await asyncio .sleep (1 )
276
+ _logger .exception (f"Unhandled exception in relay for { device .path } ." )
277
+
278
+ def _should_relay (self , device : InputDevice ) -> bool :
279
+ """Return True if we should relay this device (auto_discover or matches)."""
280
+ return self ._auto_discover or any (id .matches (device ) for id in self ._device_ids )
281
+
282
+
283
+
284
+ class UdevEventMonitor :
285
+ """
286
+ Watches for new/removed /dev/input/event* devices and notifies RelayController.
287
+ """
288
+
289
+ def __init__ (self , relay_controller : RelayController , loop : asyncio .AbstractEventLoop ):
290
+ self .relay_controller = relay_controller
291
+ self .loop = loop
292
+ self .context = pyudev .Context ()
293
+ self .monitor = pyudev .Monitor .from_netlink (self .context )
294
+ self .monitor .filter_by (subsystem = 'input' )
295
+
296
+ # Create an observer that calls _udev_event_callback on add/remove
297
+ self .observer = pyudev .MonitorObserver (self .monitor , self ._udev_event_callback )
298
+ self .observer .start ()
299
+ _logger .debug ("UdevEventMonitor started." )
300
+
301
+ def _udev_event_callback (self , action : str , device : pyudev .Device ) -> None :
302
+ """pyudev callback for device add/remove events."""
303
+ device_node = device .device_node
304
+ if not device_node or not device_node .startswith ("/dev/input/event" ):
305
+ return
306
+
307
+ if action == "add" :
308
+ _logger .debug (f"UdevEventMonitor: Added => { device_node } " )
309
+ device = InputDevice (device_node )
310
+ self .relay_controller .add_device (device )
311
+
312
+ elif action == "remove" :
313
+ _logger .debug (f"UdevEventMonitor: Removed => { device_node } " )
314
+ self .relay_controller .remove_device (device_node )
0 commit comments