diff --git a/src/vocalinux/ui/keyboard_backends/evdev_backend.py b/src/vocalinux/ui/keyboard_backends/evdev_backend.py index 185ffcf..251072a 100644 --- a/src/vocalinux/ui/keyboard_backends/evdev_backend.py +++ b/src/vocalinux/ui/keyboard_backends/evdev_backend.py @@ -20,6 +20,7 @@ to each registered backend for its own consumption decision. import errno import logging import os +import platform import re import select import threading @@ -253,9 +254,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). + Devices that emit pointer motion (REL_X/Y, ABS_X/Y, or multitouch + ABS_MT_POSITION_X/Y) are excluded: a mouse is not a keyboard, and + monitoring one proxies all cursor movement through the reader thread + (cursor stutter, #900; dead touchpad, #914). Prefers /proc/bus/input/devices (host installs). Falls back to ``evdev.list_devices()`` when that path cannot be read (common under snap @@ -288,44 +290,80 @@ def find_keyboard_devices() -> list[str]: # which showed up as periodic cursor stutter (#900). _POINTER_AXIS_BITS = 0x3 +# Bits 53 and 54 of an ABS bitmap: ABS_MT_POSITION_X|ABS_MT_POSITION_Y. +# Multitouch-only touchpads and touchscreens (e.g. the Goodix GXTP5100 in +# #914) can report the MT pair without the single-touch ABS_X/Y pair. +_POINTER_MT_AXIS_BITS = 0x3 << 53 + + +# Machines whose kernels print bitmap words as 32-bit unsigned longs; +# everything else (x86_64, aarch64, ppc64*, riscv64, s390x, ...) is 64-bit. +# On a 32-bit kernel bit N lives in a different word than on 64-bit, which +# shifts where each key code or axis lands in the /proc bitmap. +_32BIT_MACHINES = frozenset( + { + "armv5l", + "armv6l", + "armv7l", + "armv8l", + "i386", + "i486", + "i586", + "i686", + "mips", + "mipsel", + "ppc", + "riscv32", + } +) +_WORD_BITS = 32 if platform.machine() in _32BIT_MACHINES else 64 + + +def _bitmap_to_int(hex_bitmap: str) -> int: + """Integer value of a /proc capability bitmap, or 0 when unparseable. + + Words print most-significant first; each holds ``_WORD_BITS`` bits. + """ + value = 0 + for shift, word in enumerate(reversed(hex_bitmap.split())): + try: + value |= int(word, 16) << (shift * _WORD_BITS) + except ValueError: + return 0 + return value + 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 + """True when the /proc capability bitmap has a bit set.""" + return _bitmap_to_int(hex_bitmap) != 0 def _bitmap_low_bits(hex_bitmap: str) -> int: - """Low word (bits 0-63) of a /proc capability bitmap, or 0. + """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. + On 64-bit kernels that is the last word; on 32-bit kernels it spans + the last two — ``_bitmap_to_int`` handles both. """ - words = hex_bitmap.split() - if not words: - return 0 - try: - return int(words[-1], 16) - except ValueError: - return 0 + return _bitmap_to_int(hex_bitmap) & ((1 << 64) - 1) def _proc_block_has_pointer_axes(device: dict[str, Any]) -> bool: """True when a parsed /proc device block reports pointer motion axes.""" + abs_bits = _bitmap_low_bits(device.get("abs", "")) 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 + or abs_bits & _POINTER_AXIS_BITS == _POINTER_AXIS_BITS + or abs_bits & _POINTER_MT_AXIS_BITS == _POINTER_MT_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. - 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. + Devices that emit pointer motion (REL_X/Y, ABS_X/Y or + ABS_MT_POSITION_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] = [] device: Optional[dict[str, Any]] = None @@ -370,7 +408,12 @@ def _parse_keyboard_devices_from_proc(proc_file: TextIO) -> list[str]: 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.""" + """True when the device reports pointer motion axes. + + Covers REL_X+REL_Y, single-touch ABS_X+ABS_Y, and multitouch-only + ABS_MT_POSITION_X+ABS_MT_POSITION_Y (touchpads/touchscreens that + report no single-touch axes). + """ rel_codes = capabilities.get(ecodes.EV_REL, ()) if ecodes.REL_X in rel_codes and ecodes.REL_Y in rel_codes: return True @@ -379,7 +422,9 @@ def _capabilities_have_pointer_axes(capabilities: Mapping[int, Sequence[int]]) - 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 + if ecodes.ABS_X in abs_codes and ecodes.ABS_Y in abs_codes: + return True + return ecodes.ABS_MT_POSITION_X in abs_codes and ecodes.ABS_MT_POSITION_Y in abs_codes def _find_keyboard_devices_from_evdev() -> list[str]: diff --git a/tests/test_evdev_backend_ext.py b/tests/test_evdev_backend_ext.py index 44fea4e..07d9e01 100644 --- a/tests/test_evdev_backend_ext.py +++ b/tests/test_evdev_backend_ext.py @@ -202,6 +202,49 @@ B: REL=0 0 143 result = find_keyboard_devices() assert result == [] + def test_find_keyboard_devices_skips_multitouch_only_touchpad(self) -> None: + """A touchpad reporting only MT axes (no ABS_X/Y) is a pointer (#914).""" + mock_proc_content = """I: Bus=0018 Vendor=27c6 Product=01e9 Version=0100 +N: Name="GXTP5100:00 27C6:01E9" +H: Handlers=mouse0 event2 +B: PROP=85 +B: EV=1b +B: KEY=10000 0 0 0 0 +B: ABS=60000000000000 +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 == [] + + def test_find_keyboard_devices_skips_mixed_single_and_mt_touchpad(self) -> None: + """A touchpad with both ABS_X/Y and MT axes stays skipped.""" + mock_proc_content = """I: Bus=0018 Vendor=27c6 Product=01e9 Version=0100 +N: Name="GXTP5100:00 27C6:01E9" +H: Handlers=mouse0 event2 +B: KEY=10000 0 0 0 0 +B: ABS=60000000000003 +""" + 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_mt_axes_32bit_words(self) -> None: + """On a 32-bit kernel the same MT axes print as two 32-bit words.""" + mock_proc_content = """I: Bus=0018 Vendor=27c6 Product=01e9 Version=0100 +N: Name="GXTP5100:00 27C6:01E9" +H: Handlers=mouse0 event2 +B: KEY=10000 0 0 0 0 +B: ABS=600000 0 +""" + with patch("vocalinux.ui.keyboard_backends.evdev_backend._WORD_BITS", 32): + 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.""" @@ -245,6 +288,15 @@ class TestFindKeyboardDevicesFromEvdev: ecodes.EV_KEY: [ecodes.KEY_A], ecodes.EV_REL: [ecodes.REL_WHEEL], }, + # Multitouch-only touchpad: MT position axes, no ABS_X/Y (#914). + # Carries a real key so only the MT check can drop it. + "/dev/input/event4": { + ecodes.EV_KEY: [ecodes.KEY_LEFTCTRL, ecodes.BTN_LEFT], + ecodes.EV_ABS: [ + (ecodes.ABS_MT_POSITION_X, MagicMock()), + (ecodes.ABS_MT_POSITION_Y, MagicMock()), + ], + }, } devices = self._devices(caps) mock_evdev.list_devices.return_value = list(caps)