Skip to content

Commit df89523

Browse files
authored
Fix mouse stuttering and Windows not recognizing mouse (#126)
* Remove executor when relaying events * Use boot mouse gadget * Add udev monitor to register device changes * Adapt logging
1 parent e088a1d commit df89523

File tree

3 files changed

+111
-53
lines changed

3 files changed

+111
-53
lines changed

bluetooth_2_usb.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from src.bluetooth_2_usb.args import parse_args
1111
from src.bluetooth_2_usb.logging import add_file_handler, get_logger
12-
from src.bluetooth_2_usb.relay import RelayController, async_list_input_devices
12+
from src.bluetooth_2_usb.relay import RelayController, UdevEventMonitor, async_list_input_devices
1313

1414

1515
logger = get_logger()
@@ -50,6 +50,7 @@ async def main() -> NoReturn:
5050
logger.info(f"Launching {VERSIONED_NAME}")
5151

5252
controller = RelayController(args.device_ids, args.auto_discover, args.grab_devices)
53+
monitor = UdevEventMonitor(controller, asyncio.get_running_loop())
5354
await controller.async_relay_devices()
5455

5556

requirements.txt

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Adafruit-PureIO==1.1.11
44
evdev==1.6.1
55
pyftdi==0.55.0
66
pyserial==3.5
7+
pyudev==0.24.3
78
pyusb==1.2.1
89
quax-Blinka==8.27.0.post2
910
quax-circuitpython-hid==6.0.2.post1

src/bluetooth_2_usb/relay.py

+108-52
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from adafruit_hid.keyboard import Keyboard
88
from adafruit_hid.mouse import Mouse
99
from evdev import InputDevice, InputEvent, KeyEvent, RelEvent, categorize, list_devices
10+
import pyudev
1011
import usb_hid
1112
from usb_hid import Device
1213

@@ -45,7 +46,7 @@ def init_usb_gadgets() -> None:
4546
_logger.debug("Initializing USB gadgets...")
4647
usb_hid.enable(
4748
[
48-
Device.MOUSE,
49+
Device.BOOT_MOUSE,
4950
Device.KEYBOARD,
5051
Device.CONSUMER_CONTROL,
5152
] # type: ignore
@@ -136,14 +137,11 @@ async def async_relay_events_loop(self) -> NoReturn:
136137
async def _async_relay_event(self, input_event: InputEvent) -> None:
137138
event = categorize(input_event)
138139
_logger.debug(f"Received {event} from {self.input_device.name}")
139-
func = None
140+
140141
if isinstance(event, RelEvent):
141-
func = _move_mouse
142+
_move_mouse(event)
142143
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)
147145

148146

149147
def _move_mouse(event: RelEvent) -> None:
@@ -186,7 +184,8 @@ def _get_output_device(event: KeyEvent) -> ConsumerControl | Keyboard | Mouse |
186184

187185
class RelayController:
188186
"""
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.
190189
"""
191190

192191
def __init__(
@@ -200,59 +199,116 @@ def __init__(
200199
self._device_ids = [DeviceIdentifier(id) for id in device_identifiers]
201200
self._auto_discover = auto_discover
202201
self._grab_devices = grab_devices
202+
self._task_group: TaskGroup | None = None
203+
self._active_tasks: dict[str, asyncio.Task] = {}
203204
self._cancelled = False
204205

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+
"""
206211
try:
207212
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}.")
222247
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.")
242260

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}")
245267

246-
async def _async_relay_events(self, device: InputDevice) -> NoReturn:
247268
try:
248-
relay = DeviceRelay(device, self._grab_devices)
249-
_logger.info(f"Activated {relay}")
250269
await relay.async_relay_events_loop()
251270
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
254273
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}].")
256275
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

Comments
 (0)