fix(evdev): skip multitouch-only touchpads in keyboard discovery (#917)

* fix(evdev): skip multitouch-only touchpads in keyboard discovery

Devices reporting only ABS_MT_POSITION_X/Y (no single-touch ABS_X/Y)
evaded the pointer-axis check, so multitouch touchpads like the Goodix
GXTP5100 were grabbed and cloned — breaking pointer input while running.

Fixes #914

* fix(evdev): read /proc bitmaps at the kernel's word width

Capability bitmap words are unsigned longs — 32 bits on 32-bit kernels
(armv7l, i686), not 64. Parse the whole bitmap through _bitmap_to_int()
sized by platform.machine() so MT axis bits and any future high bits
land correctly on both widths. Give the fallback MT-mock a real key so
the MT-axes check is the only thing that can drop it.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
Co-authored-by: Jatin K Malik <jk.malik@yahoo.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot]
2026-10-07 17:55:25 -07:00
committed by GitHub
co-authored by Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Jatin K Malik Jatin K Malik Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 622d0e054d
commit ecd4ae8388
2 changed files with 121 additions and 24 deletions
@@ -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]:
+52
View File
@@ -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)