fix(evdev): skip pointer-motion devices in keyboard discovery (#902)
* 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 <jatinkrmalik@gmail.com>
This commit is contained in:
co-authored by
Jatin K Malik
parent
758f640787
commit
af1029dc60
@@ -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 []
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user