Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news.d/feature/1760.linux.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Refactor Uinput to use pipe instead of select with timeout
114 changes: 81 additions & 33 deletions plover/oslayer/linux/keyboardcontrol_uinput.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from evdev import UInput, ecodes as e, util, InputDevice, list_devices
import threading
from select import select
import os
import selectors

from psutil import process_iter

from plover.output.keyboard import GenericKeyboardEmulation
Expand Down Expand Up @@ -412,21 +414,31 @@ def send_key_combination(self, combo):


class KeyboardCapture(Capture):
_selector: selectors.DefaultSelector
_device_thread: threading.Thread | None
# Pipes to signal `_run` thread to stop
_device_thread_read_pipe: int | None
_device_thread_write_pipe: int | None

def __init__(self):
super().__init__()
# This is based on the example from the python-evdev documentation, using the first of the three alternative methods: https://python-evdev.readthedocs.io/en/latest/tutorial.html#reading-events-from-multiple-devices-using-select
self._devices = self._get_devices()
self._running = False
self._thread = None

self._selector = selectors.DefaultSelector()
self._device_thread = None
self._device_thread_read_pipe = None
self._device_thread_write_pipe = None

self._res = util.find_ecodes_by_regex(r"KEY_.*")
self._ui = UInput(self._res)
self._suppressed_keys = []
self._suppressed_keys = set()
# The keycodes from evdev, e.g. e.KEY_A refers to the *physical* a, which corresponds with the qwerty layout.

def _get_devices(self):
input_devices = [InputDevice(path) for path in list_devices()]
keyboard_devices = [dev for dev in input_devices if self._filter_devices(dev)]
return {dev.fd: dev for dev in keyboard_devices}
return keyboard_devices

def _filter_devices(self, device):
"""
Expand All @@ -451,7 +463,7 @@ def _grab_devices(self):
There is likely a race condition here between checking active keys and
actually grabbing the device, but it appears to work fine.
"""
for device in self._devices.values():
for device in self._devices:
if len(device.active_keys()) > 0:
for _ in device.read_loop():
if len(device.active_keys()) == 0:
Expand All @@ -461,56 +473,92 @@ def _grab_devices(self):

def _ungrab_devices(self):
"""Ungrab all devices. Handles all exceptions when ungrabbing."""
for device in self._devices.values():
for device in self._devices:
try:
device.ungrab()
except:
log.debug("failed to ungrab device", exc_info=True)

def start(self):
# Exception handling note: cancel() will eventually be called when the
# machine reconnect button is pressed or when the machine is changed.
# Therefore, cancel() does not need to be called in the except block.
try:
self._grab_devices()
except Exception as e:
self._device_thread_read_pipe, self._device_thread_write_pipe = os.pipe()
self._selector.register(self._device_thread_read_pipe, selectors.EVENT_READ)
for device in self._devices:
self._selector.register(device, selectors.EVENT_READ)

self._device_thread = threading.Thread(target=self._run)
self._device_thread.start()

self._running = True
except Exception:
self._ungrab_devices()
raise
self._running = True
self._thread = threading.Thread(target=self._run)
self._thread.start()

def cancel(self):
# Write some arbitrary data to the pipe to signal the _run thread to stop
if self._device_thread_write_pipe is not None:
try:
os.write(self._device_thread_write_pipe, b"a")
except Exception:
log.warning("failed to write to device thread pipe", exc_info=True)
if self._device_thread is not None:
try:
self._device_thread.join()
except Exception:
log.warning("failed to join device thread", exc_info=True)
self._device_thread = None
try:
self._ungrab_devices()
except Exception:
log.warning("failed to ungrab devices", exc_info=True)
try:
self._selector.close()
except Exception:
log.warning("failed to close selector", exec_info=True)

if self._device_thread_read_pipe is not None:
try:
os.close(self._device_thread_read_pipe)
except Exception:
log.warning("failed to close device thread read pipe", exc_info=True)
self._device_thread_read_pipe = None
if self._device_thread_write_pipe is not None:
try:
os.close(self._device_thread_write_pipe)
except Exception:
log.warning("failed to close device thread write pipe", exc_info=True)
self._device_thread_write_pipe = None

self._running = False
if self._thread is not None:
self._thread.join()

def suppress(self, suppressed_keys=()):
"""
UInput is not capable of suppressing only specific keys. To get around this, non-suppressed keys
are passed through to a UInput device and emulated, while keys in this list get sent to plover.
It does add a little bit of delay, but that is not noticeable.
"""
self._suppressed_keys = suppressed_keys
self._suppressed_keys = set(suppressed_keys)

def _run(self):
try:
while self._running:
"""
The select() call blocks the loop until it gets an input, which meant that the keyboard
had to be pressed once after executing `cancel()`. Now, there is a 1 second delay instead
FIXME: maybe use one of the other options to avoid the timeout
https://python-evdev.readthedocs.io/en/latest/tutorial.html#reading-events-from-multiple-devices-using-select
"""
r, _, _ = select(self._devices, [], [], 1)
for fd in r:
for event in self._devices[fd].read():
if event.type == e.EV_KEY:
if event.code in KEYCODE_TO_KEY:
key_name = KEYCODE_TO_KEY[event.code]
if key_name in self._suppressed_keys:
pressed = event.value == 1
(self.key_down if pressed else self.key_up)(
key_name
)
continue # Go to the next iteration, skipping the below code:
while True:
for key, events in self._selector.select():
if key.fd == self._device_thread_read_pipe:
# Stop this thread
return
assert isinstance(key.fileobj, InputDevice)
device: InputDevice = key.fileobj
for event in device.read():
if event.code in KEYCODE_TO_KEY:
key_name = KEYCODE_TO_KEY[event.code]
if key_name in self._suppressed_keys:
pressed = event.value == 1
(self.key_down if pressed else self.key_up)(key_name)
continue # Go to the next iteration, skipping the below code:
self._ui.write(e.EV_KEY, event.code, event.value)
self._ui.syn()
except:
Expand Down
Loading