diff --git a/src/vocalinux/ui/keyboard_backends/evdev_backend.py b/src/vocalinux/ui/keyboard_backends/evdev_backend.py index 251072a..cb6b118 100644 --- a/src/vocalinux/ui/keyboard_backends/evdev_backend.py +++ b/src/vocalinux/ui/keyboard_backends/evdev_backend.py @@ -290,12 +290,29 @@ def find_keyboard_devices() -> list[str]: # which showed up as periodic cursor stutter (#900). _POINTER_AXIS_BITS = 0x3 +# Key codes only real keyboards report: the modifiers plus KEY_A — the +# same set the evdev fallback requires. Button-only devices (power/sleep +# buttons, the ACPI video bus, vendor hotkey blocks) never have one, so +# they stop being grabbed and cloned too (#915). +_KEYBOARD_KEY_CODES = frozenset( + { + KEY_LEFTCTRL, + KEY_RIGHTCTRL, + KEY_LEFTALT, + KEY_RIGHTALT, + KEY_LEFTSHIFT, + KEY_RIGHTSHIFT, + KEY_LEFTMETA, + KEY_RIGHTMETA, + 30, # KEY_A + } +) + # 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 @@ -338,6 +355,11 @@ def _bitmap_has_any_bit(hex_bitmap: str) -> bool: return _bitmap_to_int(hex_bitmap) != 0 +def _bitmap_has_code(hex_bitmap: str, code: int) -> bool: + """True when bit ``code`` is set in a /proc capability bitmap.""" + return bool(_bitmap_to_int(hex_bitmap) & (1 << code)) + + def _bitmap_low_bits(hex_bitmap: str) -> int: """Bits 0-63 of a /proc capability bitmap, or 0. @@ -357,13 +379,25 @@ def _proc_block_has_pointer_axes(device: dict[str, Any]) -> bool: ) +def _proc_block_has_keyboard_keys(device: dict[str, Any]) -> bool: + """True when the block's KEY bitmap has a real keyboard key. + + Any nonzero KEY bit used to qualify a block as a keyboard — but + touchpad buttons, power/sleep keys and ACPI hotkey blocks all report + EV_KEY codes too, so every one of them was grabbed and cloned (#915). + """ + key_bitmap = device.get("key", "") + return any(_bitmap_has_code(key_bitmap, code) for code in _KEYBOARD_KEY_CODES) + + 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, 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. + Only blocks carrying a real keyboard key (a modifier or KEY_A, the + same rule the evdev fallback applies) qualify. 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 @@ -372,18 +406,23 @@ def _parse_keyboard_devices_from_proc(proc_file: TextIO) -> list[str]: nonlocal device if device is None: return - if _bitmap_has_any_bit(device.get("key", "")): - if _proc_block_has_pointer_axes(device): + if not _proc_block_has_keyboard_keys(device): + if _bitmap_has_any_bit(device.get("key", "")): logger.debug( - "Skipping pointer device during keyboard discovery: " + "Skipping non-keyboard 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) + elif _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: diff --git a/tests/test_evdev_backend_ext.py b/tests/test_evdev_backend_ext.py index 07d9e01..1f8bf16 100644 --- a/tests/test_evdev_backend_ext.py +++ b/tests/test_evdev_backend_ext.py @@ -54,7 +54,7 @@ H: Handlers= 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 +B: KEY=402000000 3803078f800d001 feffffdfffefffff fffffffffffffffe """ with patch("builtins.open", mock_open(read_data=mock_proc_content)): with patch("os.path.exists", return_value=True): @@ -66,7 +66,7 @@ B: KEY=10000 7ff 202100 3953b001 68ffe0 1 20000 2000000000000 0 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 +B: KEY=402000000 3803078f800d001 feffffdfffefffff fffffffffffffffe I: Bus=0018 Vendor=04f3 Product=0033 Version=0500 N: Name="Elan Touchpad" H: Handlers=mouse0 event1 @@ -126,7 +126,7 @@ B: KEY=0 mock_proc_content = """I: Bus=0011 Vendor=0001 Product=0001 Version=ab83 N: Name="Device with no event" H: Handlers=kbd -B: KEY=10000 7ff 202100 3953b001 68ffe0 1 20000 2000000000000 0 +B: KEY=402000000 3803078f800d001 feffffdfffefffff fffffffffffffffe """ with patch("builtins.open", mock_open(read_data=mock_proc_content)): result = find_keyboard_devices() @@ -138,7 +138,7 @@ B: KEY=10000 7ff 202100 3953b001 68ffe0 1 20000 2000000000000 0 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 +B: KEY=402000000 3803078f800d001 feffffdfffefffff fffffffffffffffe """ with patch("builtins.open", mock_open(read_data=mock_proc_content)): with patch("os.path.exists", return_value=False): @@ -150,14 +150,16 @@ B: KEY=10000 7ff 202100 3953b001 68ffe0 1 20000 2000000000000 0 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 +B: KEY=402000000 3803078f800d001 feffffdfffefffff fffffffffffffffe 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: KEY=1f0000 0 0 0 38000000 B: REL=143 B: MSC=10 """ + # KEY carries real keyboard keys (bits 29-31) so only the pointer + # check can drop this device. with patch("builtins.open", mock_open(read_data=mock_proc_content)): with patch("os.path.exists", return_value=True): result = find_keyboard_devices() @@ -168,9 +170,11 @@ B: MSC=10 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: KEY=ff000000000000 0 0 38000000 B: ABS=273000000000003 """ + # KEY carries real keyboard keys (bits 29-31) so only the pointer + # check can drop this device. with patch("builtins.open", mock_open(read_data=mock_proc_content)): with patch("os.path.exists", return_value=True): result = find_keyboard_devices() @@ -181,7 +185,7 @@ B: ABS=273000000000003 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: KEY=402000000 3803078f800d001 feffffdfffefffff fffffffffffffffe B: REL=100 """ with patch("builtins.open", mock_open(read_data=mock_proc_content)): @@ -194,14 +198,71 @@ B: REL=100 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: KEY=ffff0000 0 0 0 38000000 B: REL=0 0 143 """ + # KEY carries real keyboard keys (bits 29-31) so only the pointer + # check can drop this device. 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_button_only_devices(self) -> None: + """Devices whose KEY bitmap has no real keyboard key are not keyboards (#915).""" + mock_proc_content = """I: Bus=0019 Vendor=0000 Product=0001 Version=0000 +N: Name="Power Button" +H: Handlers=kbd event4 +B: KEY=1000000000000 0 +I: Bus=0019 Vendor=0000 Product=0003 Version=0000 +N: Name="Sleep Button" +H: Handlers=kbd event5 +B: KEY=4000 0 0 +I: Bus=0019 Vendor=0000 Product=0006 Version=0000 +N: Name="Video Bus" +H: Handlers=kbd event6 +B: KEY=3f000300000000 0 0 0 0 +I: Bus=0019 Vendor=17aa Product=5054 Version=4101 +N: Name="ThinkPad Extra Buttons" +H: Handlers=kbd event7 rfkill +B: KEY=40040002000000 c00017 21004000 300600 400028000000 0 +I: Bus=0011 Vendor=0001 Product=0001 Version=ab83 +N: Name="AT Translated Set 2 keyboard" +H: Handlers=sysrq kbd event0 +B: KEY=402000000 3803078f800d001 feffffdfffefffff fffffffffffffffe +I: Bus=0003 Vendor=046d Product=c08b Version=0110 +N: Name="Logitech G502 HERO Gaming Mouse Keyboard" +H: Handlers=sysrq kbd event8 +B: KEY=ff 0 0 0 38000000 +I: Bus=0006 Vendor=0000 Product=0000 Version=0001 +N: Name="ydotoold virtual device" +H: Handlers=kbd mouse0 event9 +B: KEY=ffff0000 0 0 38000000 +B: REL=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 == ["/dev/input/event0", "/dev/input/event8"] + + def test_find_keyboard_devices_32bit_words(self) -> None: + """On a 32-bit kernel each bitmap word holds 32 bits, not 64. + + KEY_RIGHTCTRL (code 97) sits in the fourth 32-bit word — the first + word printed. A 64-bit reading would look for it in word 1 and + miss the keyboard entirely. + """ + mock_proc_content = """I: Bus=0019 Vendor=0000 Product=0001 Version=0000 +N: Name="GPIO keyboard" +H: Handlers=kbd event2 +B: KEY=2 0 0 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 == ["/dev/input/event2"] + 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 @@ -209,7 +270,7 @@ N: Name="GXTP5100:00 27C6:01E9" H: Handlers=mouse0 event2 B: PROP=85 B: EV=1b -B: KEY=10000 0 0 0 0 +B: KEY=10000 0 0 0 38000000 B: ABS=60000000000000 B: MSC=10 """ @@ -223,7 +284,7 @@ B: MSC=10 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: KEY=10000 0 0 0 38000000 B: ABS=60000000000003 """ with patch("builtins.open", mock_open(read_data=mock_proc_content)): @@ -236,7 +297,7 @@ B: ABS=60000000000003 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: KEY=10000 0 0 0 38000000 B: ABS=600000 0 """ with patch("vocalinux.ui.keyboard_backends.evdev_backend._WORD_BITS", 32):