From cd388b2e935710e32e6714cb573ed589df150d5c Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:07:50 -0700 Subject: [PATCH] fix(evdev): strip SW_RFKILL_ALL from uinput clone capabilities (#947) The evdev uinput clone copied EV_SW capabilities verbatim. "ThinkPad Extra Buttons" (thinkpad_acpi) declares SW_RFKILL_ALL, and a fresh uinput device reports every switch as 0. The kernel rfkill-input handler reads a 0 killswitch as radios-off, so every clone creation soft-blocked all radios at startup and again after every resume. Strip SW_RFKILL_ALL from the clone capability list, dropping EV_SW entirely when it was the only switch. Other switches stay declared so real switch events still forward; a forwarded SW_RFKILL_ALL write is dropped by the kernel for the undeclared code. Fixes #946 --- .../ui/keyboard_backends/evdev_backend.py | 15 +++++ tests/test_evdev_backend_ext.py | 63 +++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/src/vocalinux/ui/keyboard_backends/evdev_backend.py b/src/vocalinux/ui/keyboard_backends/evdev_backend.py index 818a4ef..df2d0ea 100644 --- a/src/vocalinux/ui/keyboard_backends/evdev_backend.py +++ b/src/vocalinux/ui/keyboard_backends/evdev_backend.py @@ -1317,6 +1317,14 @@ class EvdevDeviceHub: EV_REP is filtered out so the clone never autorepeats: the physical device's own repeat events are forwarded verbatim, preserving its repeat rate without doubling repeats. + + SW_RFKILL_ALL is stripped from EV_SW: a fresh uinput device reports + every switch as 0, and the kernel's rfkill-input handler reads a 0 + killswitch as radios-off — cloning that bit soft-blocks all radios + the moment the clone appears, on ThinkPads and any other device + declaring it (#946). The clone keeps every other switch so real + switch events still forward; a forwarded SW_RFKILL_ALL write is + dropped by the kernel for the undeclared code. """ try: capabilities: dict[int, Sequence[int]] = { @@ -1324,6 +1332,13 @@ class EvdevDeviceHub: } for event_type in (ecodes.EV_SYN, ecodes.EV_FF, ecodes.EV_REP): capabilities.pop(event_type, None) + switches = [ + code for code in capabilities.get(ecodes.EV_SW, ()) if code != ecodes.SW_RFKILL_ALL + ] + if switches: + capabilities[ecodes.EV_SW] = switches + else: + capabilities.pop(ecodes.EV_SW, None) name = _clone_device_name(device) try: return UInput( diff --git a/tests/test_evdev_backend_ext.py b/tests/test_evdev_backend_ext.py index 021d3e6..c95bdc7 100644 --- a/tests/test_evdev_backend_ext.py +++ b/tests/test_evdev_backend_ext.py @@ -1362,6 +1362,69 @@ class TestEvdevGrabAndForwarding: assert backend._forwarders[10] is forwarder assert device in backend.devices + @patch("vocalinux.ui.keyboard_backends.evdev_backend.UInput") + @patch("vocalinux.ui.keyboard_backends.evdev_backend.InputDevice") + def test_clone_drops_rfkill_switch_keeps_other_switches( + self, mock_input_device: Mock, mock_uinput: Mock + ) -> None: + """The clone must not declare SW_RFKILL_ALL (#946). + + "ThinkPad Extra Buttons" declares SW_RFKILL_ALL. A fresh uinput + device reports every switch as 0, and the kernel's rfkill-input + handler reads a 0 killswitch as radios-off — cloning the bit + soft-blocked all radios on every startup and resume. Other + switches stay declared so real switch events still forward. + """ + device = MagicMock() + device.fileno.return_value = 10 + device.active_keys.return_value = [] + device.capabilities.return_value = { + ecodes.EV_KEY: [30], # KEY_A + ecodes.EV_SW: [ecodes.SW_RFKILL_ALL, ecodes.SW_TABLET_MODE], + } + mock_input_device.return_value = device + forwarder = MagicMock() + forwarder.device.path = "/dev/input/event20" + mock_uinput.return_value = forwarder + + backend = EvdevKeyboardBackend() + opened = backend._open_keyboard_device("/dev/input/event0") + + assert opened is True + events = mock_uinput.call_args.kwargs["events"] + assert ecodes.SW_RFKILL_ALL not in events[ecodes.EV_SW] + assert ecodes.SW_TABLET_MODE in events[ecodes.EV_SW] + + @patch("vocalinux.ui.keyboard_backends.evdev_backend.UInput") + @patch("vocalinux.ui.keyboard_backends.evdev_backend.InputDevice") + def test_clone_drops_ev_sw_when_only_switch_is_rfkill( + self, mock_input_device: Mock, mock_uinput: Mock + ) -> None: + """A device declaring only SW_RFKILL_ALL gets no EV_SW at all (#946). + + ThinkPad Extra Buttons reports SW=8: bit 3 alone. Declaring an + empty EV_SW capability would still register a switch device, so + the type is dropped entirely. + """ + device = MagicMock() + device.fileno.return_value = 10 + device.active_keys.return_value = [] + device.capabilities.return_value = { + ecodes.EV_KEY: [30], # KEY_A + ecodes.EV_SW: [ecodes.SW_RFKILL_ALL], + } + mock_input_device.return_value = device + forwarder = MagicMock() + forwarder.device.path = "/dev/input/event20" + mock_uinput.return_value = forwarder + + backend = EvdevKeyboardBackend() + opened = backend._open_keyboard_device("/dev/input/event0") + + assert opened is True + events = mock_uinput.call_args.kwargs["events"] + assert ecodes.EV_SW not in events + @patch("vocalinux.ui.keyboard_backends.evdev_backend.UInput") @patch("vocalinux.ui.keyboard_backends.evdev_backend.InputDevice") @patch("vocalinux.ui.keyboard_backends.evdev_backend.os.path.exists")