From af1029dc606b0c702ca880efde6608c8de9e0c3e Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:10:50 -0700 Subject: [PATCH] fix(evdev): skip pointer-motion devices in keyboard discovery (#902) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(evdev): skip pointer-motion devices in keyboard discovery find_keyboard_devices() treated any device with a nonzero EV_KEY bitmap as a keyboard, which includes Logitech mice (G502, MX Master 3) whose button interfaces report key bits. The hub then grabbed them (EVIOCGRAB) and re-emitted every event, including EV_REL pointer motion, through a paired uinput clone — piping all cursor movement through the reader thread and showing up as periodic cursor stutter. Discovery now excludes devices that report pointer motion (REL_X+REL_Y or ABS_X+ABS_Y) in both the /proc/bus/input/devices parser and the evdev.list_devices() fallback. Wheel-only devices (REL_WHEEL) and real keyboards are unaffected, and a mouse's separate keyboard interface node keeps working for shortcuts. Fixes #900 * test(evdev): annotate new device-discovery tests * chore: drop accidentally committed pr_body.md --------- Co-authored-by: Jatin K Malik --- .../ui/keyboard_backends/evdev_backend.py | 124 +++++++++++++++--- tests/test_evdev_backend_ext.py | 110 ++++++++++++++++ 2 files changed, 214 insertions(+), 20 deletions(-) diff --git a/src/vocalinux/ui/keyboard_backends/evdev_backend.py b/src/vocalinux/ui/keyboard_backends/evdev_backend.py index 53a302c..185ffcf 100644 --- a/src/vocalinux/ui/keyboard_backends/evdev_backend.py +++ b/src/vocalinux/ui/keyboard_backends/evdev_backend.py @@ -25,7 +25,7 @@ import select import threading import time import weakref -from typing import Any, Optional, Sequence, TextIO +from typing import Any, Mapping, Optional, Sequence, TextIO # Try to import evdev try: @@ -253,6 +253,10 @@ def find_keyboard_devices() -> list[str]: """ Find all keyboard input devices. + Devices that emit pointer motion (REL_X/Y or ABS_X/Y) are excluded: + a mouse is not a keyboard, and monitoring one proxies all cursor + movement through the reader thread (cursor stutter, #900). + Prefers /proc/bus/input/devices (host installs). Falls back to ``evdev.list_devices()`` when that path cannot be read (common under snap confinement: raw-input grants /dev/input/event*, while hardware-observe @@ -277,34 +281,107 @@ def find_keyboard_devices() -> list[str]: return fallback +# Bits 0 and 1 of a REL or ABS capability bitmap: REL_X|REL_Y or +# ABS_X|ABS_Y — pointer motion. A keyboard never reports both; devices +# that do are mice, trackpads or tablets, and monitoring one meant +# grabbing it and piping all cursor movement through the reader thread, +# which showed up as periodic cursor stutter (#900). +_POINTER_AXIS_BITS = 0x3 + + +def _bitmap_has_any_bit(hex_bitmap: str) -> bool: + """True when any word of a /proc capability bitmap has a bit set.""" + try: + return any(int(word, 16) for word in hex_bitmap.split()) + except ValueError: + return False + + +def _bitmap_low_bits(hex_bitmap: str) -> int: + """Low word (bits 0-63) of a /proc capability bitmap, or 0. + + Bitmaps print most-significant word first, so axis bits 0..63 live in + the last word on the line. + """ + words = hex_bitmap.split() + if not words: + return 0 + try: + return int(words[-1], 16) + except ValueError: + return 0 + + +def _proc_block_has_pointer_axes(device: dict[str, Any]) -> bool: + """True when a parsed /proc device block reports pointer motion axes.""" + return ( + _bitmap_low_bits(device.get("rel", "")) & _POINTER_AXIS_BITS == _POINTER_AXIS_BITS + or _bitmap_low_bits(device.get("abs", "")) & _POINTER_AXIS_BITS == _POINTER_AXIS_BITS + ) + + def _parse_keyboard_devices_from_proc(proc_file: TextIO) -> list[str]: - """Parse an open /proc/bus/input/devices stream for event KEY devices.""" + """Parse an open /proc/bus/input/devices stream for event KEY devices. + + Devices that emit pointer motion (REL_X/Y or ABS_X/Y) are skipped: + mice and tablets report EV_KEY bits for their buttons, but grabbing + them proxies every cursor movement through this reader. + """ keyboard_devices: list[str] = [] - current_device = None + device: Optional[dict[str, Any]] = None + + def finish_device() -> None: + nonlocal device + if device is None: + return + if _bitmap_has_any_bit(device.get("key", "")): + if _proc_block_has_pointer_axes(device): + logger.debug( + "Skipping pointer device during keyboard discovery: " + f"{device.get('name', '?')} ({device.get('handlers', [])})" + ) + else: + for handler in device.get("handlers", []): + if handler.startswith("event"): + device_path = f"/dev/input/{handler}" + if os.path.exists(device_path): + keyboard_devices.append(device_path) + device = None + with proc_file as f: for line in f: line = line.rstrip("\n") if line.startswith("I: Bus="): - current_device = {"handlers": []} - elif line.startswith("H: Handlers=") and current_device is not None: - handlers = line.split("=", 1)[1].strip() - current_device["handlers"] = handlers.split() - elif line.startswith("B: KEY=") and current_device is not None: - # Check if this device has keyboard keys (bit 0 is set) - key_bits = line.split("=", 1)[1].strip() - # The first hex digit after KEY= contains keyboard capability - # If it's not 0, 1, or ffffffffff, it has keyboard keys - if key_bits and key_bits != "0": - # Check if event handler exists - for handler in current_device.get("handlers", []): - if handler.startswith("event"): - device_path = f"/dev/input/{handler}" - if os.path.exists(device_path): - keyboard_devices.append(device_path) - current_device = None + finish_device() + device = {} + elif device is not None: + if line.startswith("N: Name="): + device["name"] = line.split("=", 1)[1].strip().strip('"') + elif line.startswith("H: Handlers="): + device["handlers"] = line.split("=", 1)[1].strip().split() + elif line.startswith("B: KEY="): + device["key"] = line.split("=", 1)[1].strip() + elif line.startswith("B: REL="): + device["rel"] = line.split("=", 1)[1].strip() + elif line.startswith("B: ABS="): + device["abs"] = line.split("=", 1)[1].strip() + finish_device() return keyboard_devices +def _capabilities_have_pointer_axes(capabilities: Mapping[int, Sequence[int]]) -> bool: + """True when the device reports REL_X+REL_Y or ABS_X+ABS_Y pointer axes.""" + rel_codes = capabilities.get(ecodes.EV_REL, ()) + if ecodes.REL_X in rel_codes and ecodes.REL_Y in rel_codes: + return True + # capabilities() reports EV_ABS entries as (code, AbsInfo) pairs. + abs_codes = { + entry[0] if isinstance(entry, (tuple, list)) else entry + for entry in capabilities.get(ecodes.EV_ABS, ()) + } + return ecodes.ABS_X in abs_codes and ecodes.ABS_Y in abs_codes + + def _find_keyboard_devices_from_evdev() -> list[str]: """Discover keyboards by opening devices from ``evdev.list_devices()``.""" if not EVDEV_AVAILABLE: @@ -322,11 +399,18 @@ def _find_keyboard_devices_from_evdev() -> list[str]: device = InputDevice(path) try: capabilities = device.capabilities() + device_name = str(getattr(device, "name", "") or "") finally: device.close() except (OSError, IOError, TypeError, ValueError): continue + if capabilities and _capabilities_have_pointer_axes(capabilities): + # Mice/tablets also report EV_KEY for their buttons; monitoring + # a pointer pipes all cursor movement through this reader. + logger.debug(f"Skipping pointer device during keyboard discovery: {device_name}") + continue + # Prefer devices that look like keyboards: EV_KEY with a common letter # or a modifier. This includes combo "Keyboard" nodes on mice/remotes. key_caps = capabilities.get(ecodes.EV_KEY, []) if capabilities else [] diff --git a/tests/test_evdev_backend_ext.py b/tests/test_evdev_backend_ext.py index 4ba4f6e..44fea4e 100644 --- a/tests/test_evdev_backend_ext.py +++ b/tests/test_evdev_backend_ext.py @@ -27,6 +27,7 @@ from vocalinux.ui.keyboard_backends.evdev_backend import ( EvdevDeviceHub, EvdevKeyboardBackend, _clone_device_name, + _find_keyboard_devices_from_evdev, device_has_key, device_has_modifier_key, ecodes, @@ -144,6 +145,115 @@ B: KEY=10000 7ff 202100 3953b001 68ffe0 1 20000 2000000000000 0 result = find_keyboard_devices() assert "/dev/input/event0" not in result + def test_find_keyboard_devices_skips_relative_pointer(self) -> None: + """A device emitting REL_X/Y motion is a mouse, not a keyboard (#900).""" + mock_proc_content = """I: Bus=0011 Vendor=0001 Product=0001 Version=ab83 +N: Name="AT Translated Set 2 keyboard" +H: Handlers=sysrq kbd event0 +B: KEY=10000 7ff 202100 3953b001 68ffe0 1 20000 2000000000000 0 +I: Bus=0003 Vendor=046d Product=c08b Version=0110 +N: Name="Logitech G502 HERO Gaming Mouse" +H: Handlers=mouse0 event5 +B: KEY=1f0000 0 0 0 0 +B: REL=143 +B: MSC=10 +""" + with patch("builtins.open", mock_open(read_data=mock_proc_content)): + with patch("os.path.exists", return_value=True): + result = find_keyboard_devices() + assert result == ["/dev/input/event0"] + + def test_find_keyboard_devices_skips_absolute_pointer(self) -> None: + """ABS_X/Y devices (touchpads, tablets) are also pointers.""" + mock_proc_content = """I: Bus=0018 Vendor=04f3 Product=0033 Version=0500 +N: Name="Elan Touchpad" +H: Handlers=mouse0 event1 +B: KEY=ff000000000000 0 0 0 +B: ABS=273000000000003 +""" + with patch("builtins.open", mock_open(read_data=mock_proc_content)): + with patch("os.path.exists", return_value=True): + result = find_keyboard_devices() + assert result == [] + + def test_find_keyboard_devices_keeps_wheel_only_device(self) -> None: + """REL_WHEEL without X/Y motion does not mark a device as a pointer.""" + mock_proc_content = """I: Bus=0005 Vendor=046d Product=b331 Version=0030 +N: Name="Wireless Keyboard" +H: Handlers=kbd event3 +B: KEY=10000 7ff 202100 3953b001 68ffe0 1 20000 2000000000000 0 +B: REL=100 +""" + with patch("builtins.open", mock_open(read_data=mock_proc_content)): + with patch("os.path.exists", return_value=True): + result = find_keyboard_devices() + assert result == ["/dev/input/event3"] + + def test_find_keyboard_devices_pointer_axes_split_bitmap(self) -> None: + """Pointer axes are read from the low word of a multi-word bitmap.""" + mock_proc_content = """I: Bus=0003 Vendor=046d Product=c52b Version=1201 +N: Name="Logitech USB Receiver" +H: Handlers=mouse0 event4 +B: KEY=ffff0000 0 0 0 0 +B: REL=0 0 143 +""" + with patch("builtins.open", mock_open(read_data=mock_proc_content)): + with patch("os.path.exists", return_value=True): + result = find_keyboard_devices() + assert result == [] + + +class TestFindKeyboardDevicesFromEvdev: + """Test _find_keyboard_devices_from_evdev() discovery and filtering.""" + + def _devices(self, caps_by_path: dict[str, dict]) -> dict[str, MagicMock]: + devices = {} + for path, caps in caps_by_path.items(): + device = MagicMock() + device.capabilities.return_value = caps + device.name = f"fake-{path}" + devices[path] = device + return devices + + @patch("vocalinux.ui.keyboard_backends.evdev_backend.InputDevice") + @patch("vocalinux.ui.keyboard_backends.evdev_backend.evdev") + def test_discovers_keyboards(self, mock_evdev: Mock, mock_input_device: Mock) -> None: + """A plain keyboard device is reported.""" + devices = self._devices({"/dev/input/event0": {ecodes.EV_KEY: [ecodes.KEY_A]}}) + mock_evdev.list_devices.return_value = list(devices) + mock_input_device.side_effect = lambda path: devices[path] + assert _find_keyboard_devices_from_evdev() == ["/dev/input/event0"] + + @patch("vocalinux.ui.keyboard_backends.evdev_backend.InputDevice") + @patch("vocalinux.ui.keyboard_backends.evdev_backend.evdev") + def test_skips_pointer_devices(self, mock_evdev: Mock, mock_input_device: Mock) -> None: + """Mice/tablets with EV_KEY buttons are skipped, keyboards kept.""" + caps = { + "/dev/input/event0": {ecodes.EV_KEY: [ecodes.KEY_A]}, + # Mouse-style node: keyboard keys plus pointer motion. + "/dev/input/event1": { + ecodes.EV_KEY: [ecodes.KEY_LEFTCTRL], + ecodes.EV_REL: [ecodes.REL_X, ecodes.REL_Y, ecodes.REL_WHEEL], + }, + # Tablet/touchpad: absolute pointer axes (AbsInfo tuples). + "/dev/input/event2": { + ecodes.EV_KEY: [ecodes.KEY_A], + ecodes.EV_ABS: [(ecodes.ABS_X, MagicMock()), (ecodes.ABS_Y, MagicMock())], + }, + # Keyboard with a wheel: relative axis but no X/Y motion. + "/dev/input/event3": { + ecodes.EV_KEY: [ecodes.KEY_A], + ecodes.EV_REL: [ecodes.REL_WHEEL], + }, + } + devices = self._devices(caps) + mock_evdev.list_devices.return_value = list(caps) + mock_input_device.side_effect = lambda path: devices[path] + assert _find_keyboard_devices_from_evdev() == [ + "/dev/input/event0", + "/dev/input/event3", + ] + class TestDeviceHasModifierKey: """Test device_has_modifier_key() function."""