Files
fenrir/src/fenrirscreenreader/inputDriver/evdevDriver.py
2025-07-07 00:42:23 -04:00

937 lines
35 KiB
Python

# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
import multiprocessing
import threading
import time
from ctypes import c_bool
from multiprocessing.sharedctypes import Value
from select import select
from fenrirscreenreader.core import debug
from fenrirscreenreader.core import inputData
from fenrirscreenreader.core.eventData import FenrirEventType
from fenrirscreenreader.core.inputDriver import InputDriver as inputDriver
_evdevAvailable = False
_udevAvailable = False
_evdevAvailableError = ""
_udevAvailableError = ""
try:
import evdev
from evdev import InputDevice
from evdev import UInput
from evdev import ecodes as e
_evdevAvailable = True
except Exception as e:
_evdevAvailableError = str(e)
try:
import pyudev
_udevAvailable = True
except Exception as e:
_udevAvailableError = str(e)
class driver(inputDriver):
"""Linux evdev input driver for Fenrir screen reader.
This driver provides access to Linux input devices through the evdev interface,
allowing Fenrir to capture keyboard input, manage device grabbing for exclusive
access, and inject synthetic input events.
Features:
- Automatic device detection and hotplug support via udev
- Device grabbing to prevent input from reaching other applications
- Key event mapping and filtering
- UInput support for synthetic key injection
- Multi-device support with thread-safe access
Attributes:
iDevices (dict): Map of file descriptor to InputDevice objects
iDevicesFD (list): Shared list of file descriptors for multiprocessing
uDevices (dict): Map of file descriptor to UInput devices
gDevices (dict): Map of file descriptor to grab status
iDeviceNo (int): Total number of input devices
UInputinject (UInput): Device for injecting synthetic events
_deviceLock (Lock): Thread lock for device access
"""
def __init__(self):
inputDriver.__init__(self)
self._manager = multiprocessing.Manager()
self.iDevices = {}
self.iDevicesFD = self._manager.list()
self.uDevices = {}
self.gDevices = {}
self.iDeviceNo = 0
self.watch_dog = Value(c_bool, True)
self.UInputinject = UInput()
self._deviceLock = threading.Lock()
def initialize(self, environment):
"""Initialize the evdev input driver.
Sets up device monitoring, starts watchdog threads for device hotplug
detection and input monitoring, and configures the input subsystem.
Args:
environment: Fenrir environment dictionary with runtime managers
Note:
Requires evdev and optionally pyudev libraries. Falls back gracefully
if libraries are not available.
"""
self.env = environment
self.env["runtime"]["InputManager"].set_shortcut_type("KEY")
global _evdevAvailable
global _udevAvailable
global _evdevAvailableError
global _udevAvailableError
if not _udevAvailable:
self.env["runtime"]["DebugManager"].write_debug_out(
"InputDriver:" + _udevAvailableError, debug.DebugLevel.ERROR
)
if not _evdevAvailable:
self.env["runtime"]["DebugManager"].write_debug_out(
"InputDriver:" + _evdevAvailableError, debug.DebugLevel.ERROR
)
return
if _udevAvailable:
self.env["runtime"]["ProcessManager"].add_custom_event_thread(
self.plug_input_device_watchdog_udev
)
self.env["runtime"]["ProcessManager"].add_custom_event_thread(
self.input_watchdog
)
self._initialized = True
def plug_input_device_watchdog_udev(self, active, event_queue):
"""Monitor for input device hotplug events via udev.
Runs in a separate thread to detect when input devices are
plugged/unplugged and generates appropriate events for device
management.
Args:
active: Shared boolean controlling the watchdog loop
event_queue: Queue for sending device events to main process
Events Generated:
FenrirEventType.plug_input_device: When new devices are detected
Note:
Filters out virtual devices and devices from assistive technologies
like BRLTTY to avoid conflicts.
"""
context = pyudev.Context()
monitor = pyudev.Monitor.from_netlink(context)
monitor.filter_by(subsystem="input")
monitor.start()
ignore_plug = False
while active.value:
valid_devices = []
device = monitor.poll(1)
while device:
self.env["runtime"]["DebugManager"].write_debug_out(
"plug_input_device_watchdog_udev:" + str(device),
debug.DebugLevel.INFO,
)
try:
try:
# FIX: Check if attributes exist before accessing them
if (
hasattr(device, "name")
and device.name
and device.name.upper()
in ["", "SPEAKUP", "FENRIR-UINPUT"]
):
ignore_plug = True
if (
hasattr(device, "phys")
and device.phys
and device.phys.upper()
in ["", "SPEAKUP", "FENRIR-UINPUT"]
):
ignore_plug = True
if (
hasattr(device, "name")
and device.name
and "BRLTTY" in device.name.upper()
):
ignore_plug = True
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"plug_input_device_watchdog_udev CHECK NAME CRASH: "
+ str(e),
debug.DebugLevel.ERROR,
)
if not ignore_plug:
virtual = (
"/sys/devices/virtual/input/" in device.sys_path
)
if device.device_node:
valid_devices.append(
{
"device": device.device_node,
"virtual": virtual,
}
)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"plug_input_device_watchdog_udev APPEND CRASH: "
+ str(e),
debug.DebugLevel.ERROR,
)
try:
poll_timeout = 1
device = monitor.poll(poll_timeout)
except Exception:
device = None
ignore_plug = False
if valid_devices:
event_queue.put(
{
"Type": FenrirEventType.plug_input_device,
"data": valid_devices,
}
)
return time.time()
def input_watchdog(self, active, event_queue):
"""Main input monitoring loop.
Monitors all registered input devices for key events using select().
Processes key events, maps them to Fenrir's internal format, and
forwards them to the main application.
Args:
active: Shared boolean controlling the watchdog loop
event_queue: Queue for sending input events to main process
Events Generated:
FenrirEventType.keyboard_input: For key press/release events
Note:
Uses thread-safe device access and handles device disconnection
gracefully. Non-keyboard events are forwarded to UInput devices.
"""
try:
while active.value:
# Get a snapshot of devices for select() to avoid lock
# contention
with self._deviceLock:
devices_snapshot = self.iDevices.copy()
if not devices_snapshot:
time.sleep(0.1)
continue
r, w, x = select(devices_snapshot, [], [], 0.8)
event = None
found_key_in_sequence = False
foreward = False
event_fired = False
for fd in r:
# Check if device still exists before accessing
with self._deviceLock:
if fd not in self.iDevices:
continue
device = self.iDevices[fd]
udevice = self.uDevices.get(fd)
try:
event = device.read_one()
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"evdevDriver handle_input_event: Error reading event: "
+ str(e),
debug.DebugLevel.ERROR,
)
self.remove_device(fd)
continue
while event:
self.env["runtime"]["DebugManager"].write_debug_out(
"input_watchdog: EVENT:" + str(event),
debug.DebugLevel.INFO,
)
self.env["input"]["eventBuffer"].append(
[device, udevice, event]
)
if event.type == evdev.events.EV_KEY:
if not found_key_in_sequence:
found_key_in_sequence = True
if event.code != 0:
curr_map_event = self.map_event(event)
if not curr_map_event:
event = device.read_one()
continue
if not isinstance(
curr_map_event["EventName"], str
):
event = device.read_one()
continue
if curr_map_event["EventState"] in [0, 1, 2]:
event_queue.put(
{
"Type": FenrirEventType.keyboard_input,
"data": curr_map_event.copy(),
}
)
event_fired = True
else:
if event.type in [2, 3]:
foreward = True
event = device.read_one()
if not found_key_in_sequence:
if foreward and not event_fired:
self.write_event_buffer()
self.clear_event_buffer()
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"INPUT WATCHDOG CRASH: " + str(e), debug.DebugLevel.ERROR
)
def write_event_buffer(self):
if not self._initialized:
return
for iDevice, uDevice, event in self.env["input"]["eventBuffer"]:
try:
if uDevice:
if self.gDevices[iDevice.fd]:
self.write_u_input(uDevice, event)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"evdevDriver write_event_buffer: Error writing event: "
+ str(e),
debug.DebugLevel.ERROR,
)
def write_u_input(self, uDevice, event):
if not self._initialized:
return
uDevice.write_event(event)
time.sleep(0.0000002)
uDevice.syn()
def update_input_devices(self, new_devices=None, init=False):
"""Update the list of monitored input devices.
Scans for available input devices and adds suitable ones based on
the configured device mode. Supports filtering by device type and
name matching.
Args:
new_devices (list, optional): Specific devices to add
init (bool): Whether this is initial device setup
Device Modes:
- 'ALL': Monitor all keyboard devices
- 'NOMICE': Monitor keyboards but exclude pointing devices
- Device names: Comma-separated list of specific device names
Note:
Automatically filters out virtual devices, assistive technology
devices, and devices with insufficient key counts.
"""
if init:
self.remove_all_devices()
device_file_list = None
if new_devices and not init:
if not isinstance(new_devices, list):
new_devices = [new_devices]
device_file_list = new_devices
else:
device_file_list = evdev.list_devices()
if len(device_file_list) == self.iDeviceNo:
return
if not device_file_list:
return
mode = (
self.env["runtime"]["SettingsManager"]
.get_setting("keyboard", "device")
.upper()
)
i_devices_files = []
for device in self.iDevices:
i_devices_files.append(self.iDevices[device].path)
event_type = evdev.events
for deviceFile in device_file_list:
try:
if not deviceFile:
continue
if deviceFile == "":
continue
if deviceFile in i_devices_files:
continue
try:
with open(deviceFile) as f:
pass
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"evdevDriver update_input_devices: Error opening device file: "
+ str(e),
debug.DebugLevel.ERROR,
)
continue
# 3 pos absolute
# 2 pos relative
# 1 Keys
try:
curr_device = evdev.InputDevice(deviceFile)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"evdevDriver createDeviceType: Error creating device: "
+ str(e),
debug.DebugLevel.ERROR,
)
continue
try:
# FIX: Check if attributes exist before accessing them
if (
hasattr(curr_device, "name")
and curr_device.name
and curr_device.name.upper()
in ["", "SPEAKUP", "FENRIR-UINPUT"]
):
continue
if (
hasattr(curr_device, "phys")
and curr_device.phys
and curr_device.phys.upper()
in ["", "SPEAKUP", "FENRIR-UINPUT"]
):
continue
if (
hasattr(curr_device, "name")
and curr_device.name
and "BRLTTY" in curr_device.name.upper()
):
continue
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"evdevDriver: Error checking device capabilities: "
+ str(e),
debug.DebugLevel.ERROR,
)
cap = curr_device.capabilities()
if mode in ["ALL", "NOMICE"]:
if event_type.EV_KEY in cap:
if (
116 in cap[event_type.EV_KEY]
and len(cap[event_type.EV_KEY]) < 10
):
self.env["runtime"][
"DebugManager"
].write_debug_out(
"Device Skipped (has 116):" + curr_device.name,
debug.DebugLevel.INFO,
)
continue
if len(cap[event_type.EV_KEY]) < 60:
self.env["runtime"][
"DebugManager"
].write_debug_out(
"Device Skipped (< 60 keys):"
+ curr_device.name,
debug.DebugLevel.INFO,
)
continue
if mode == "ALL":
self.add_device(curr_device)
self.env["runtime"][
"DebugManager"
].write_debug_out(
"Device added (ALL):"
+ self.iDevices[curr_device.fd].name,
debug.DebugLevel.INFO,
)
elif mode == "NOMICE":
if not (
(event_type.EV_REL in cap)
or (event_type.EV_ABS in cap)
):
self.add_device(curr_device)
self.env["runtime"][
"DebugManager"
].write_debug_out(
"Device added (NOMICE):"
+ self.iDevices[curr_device.fd].name,
debug.DebugLevel.INFO,
)
else:
self.env["runtime"][
"DebugManager"
].write_debug_out(
"Device Skipped (NOMICE):"
+ curr_device.name,
debug.DebugLevel.INFO,
)
else:
self.env["runtime"]["DebugManager"].write_debug_out(
"Device Skipped (no EV_KEY):" + curr_device.name,
debug.DebugLevel.INFO,
)
elif curr_device.name.upper() in mode.split(","):
self.add_device(curr_device)
self.env["runtime"]["DebugManager"].write_debug_out(
"Device added (Name):"
+ self.iDevices[curr_device.fd].name,
debug.DebugLevel.INFO,
)
except Exception as e:
try:
device_name = (
curr_device.name
if hasattr(curr_device, "name")
else "unknown"
)
self.env["runtime"]["DebugManager"].write_debug_out(
"Device Skipped (Exception): "
+ deviceFile
+ " "
+ device_name
+ " "
+ str(e),
debug.DebugLevel.INFO,
)
except Exception as ex:
self.env["runtime"]["DebugManager"].write_debug_out(
"Device Skipped (Exception): "
+ deviceFile
+ " "
+ str(ex),
debug.DebugLevel.INFO,
)
self.iDeviceNo = len(evdev.list_devices())
self.update_m_pi_devices_fd()
def update_m_pi_devices_fd(self):
try:
for fd in self.iDevices:
if fd not in self.iDevicesFD:
self.iDevicesFD.append(fd)
for fd in self.iDevicesFD:
if fd not in self.iDevices:
self.iDevicesFD.remove(fd)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"evdevDriver update_m_pi_devices_fd: Error updating device file descriptors: "
+ str(e),
debug.DebugLevel.ERROR,
)
def map_event(self, event):
if not self._initialized:
return None
if not event:
return None
m_event = inputData.input_event
try:
# mute is a list = ['KEY_MIN_INTERESTING', 'KEY_MUTE']
m_event["EventName"] = evdev.ecodes.keys[event.code]
if isinstance(m_event["EventName"], list):
if len(m_event["EventName"]) > 0:
m_event["EventName"] = m_event["EventName"][0]
if isinstance(m_event["EventName"], list):
if len(m_event["EventName"]) > 0:
m_event["EventName"] = m_event["EventName"][0]
m_event["EventValue"] = event.code
m_event["EventSec"] = event.sec
m_event["EventUsec"] = event.usec
m_event["EventState"] = event.value
m_event["EventType"] = event.type
return m_event
except Exception as e:
return None
def get_led_state(self, led=0):
if not self.has_i_devices():
return False
# 0 = Numlock
# 1 = Capslock
# 2 = Rollen
for fd, dev in self.iDevices.items():
if led in dev.leds():
return True
return False
def toggle_led_state(self, led=0):
if not self.has_i_devices():
return False
led_state = self.get_led_state(led)
for i in self.iDevices:
if self.gDevices[i]:
# 17 LEDs
if 17 in self.iDevices[i].capabilities():
if led_state == 1:
self.iDevices[i].set_led(led, 0)
else:
self.iDevices[i].set_led(led, 1)
def grab_all_devices(self):
if not self._initialized:
return True
ok = True
for fd in self.iDevices:
if not self.gDevices[fd]:
ok = ok and self.grab_device(fd)
return ok
def ungrab_all_devices(self):
if not self._initialized:
return True
ok = True
for fd in self.iDevices:
if self.gDevices[fd]:
ok = ok and self.ungrab_device(fd)
return ok
def create_u_input_dev(self, fd):
if not self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"keyboard", "grabDevices"
):
self.uDevices[fd] = None
return
try:
test = self.uDevices[fd]
return
except KeyError:
self.uDevices[fd] = None
if self.uDevices[fd] is not None:
return
try:
self.uDevices[fd] = UInput.from_device(
self.iDevices[fd], name="fenrir-uinput", phys="fenrir-uinput"
)
except Exception as e:
try:
self.env["runtime"]["DebugManager"].write_debug_out(
"InputDriver evdev: compat fallback: " + str(e),
debug.DebugLevel.WARNING,
)
dev = self.iDevices[fd]
cap = dev.capabilities()
del cap[0]
self.uDevices[fd] = UInput(
cap, name="fenrir-uinput", phys="fenrir-uinput"
)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"InputDriver evdev: init Uinput not possible: " + str(e),
debug.DebugLevel.ERROR,
)
return
def add_device(self, newDevice):
"""Add a new input device to the monitoring list.
Creates the necessary data structures for device monitoring,
sets up UInput forwarding if device grabbing is enabled, and
initializes device state.
Args:
newDevice: evdev.InputDevice object to add
Note:
Thread-safe operation. Automatically cleans up on failure.
"""
self.env["runtime"]["DebugManager"].write_debug_out(
"InputDriver evdev: device added: "
+ str(newDevice.fd)
+ " "
+ str(newDevice),
debug.DebugLevel.INFO,
)
with self._deviceLock:
try:
self.iDevices[newDevice.fd] = newDevice
self.create_u_input_dev(newDevice.fd)
self.gDevices[newDevice.fd] = False
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"InputDriver evdev: error adding device: " + str(e),
debug.DebugLevel.ERROR,
)
# if it doesnt work clean up
try:
del self.iDevices[newDevice.fd]
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"evdevDriver remove_device: Error removing iDevice: "
+ str(e),
debug.DebugLevel.ERROR,
)
try:
del self.uDevices[newDevice.fd]
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"evdevDriver remove_device: Error removing uDevice: "
+ str(e),
debug.DebugLevel.ERROR,
)
try:
del self.gDevices[newDevice.fd]
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"evdevDriver add_device: Error cleaning up gDevice: "
+ str(e),
debug.DebugLevel.ERROR,
)
def grab_device(self, fd):
"""Grab exclusive access to an input device.
Takes exclusive control of the device, preventing other applications
from receiving its input. Also resets modifier key states to prevent
stuck keys.
Args:
fd (int): File descriptor of device to grab
Returns:
bool: True if grab successful, False otherwise
Note:
Only effective if grabDevices setting is enabled.
"""
if not self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"keyboard", "grabDevices"
):
return True
# FIX: Handle exception variable scope correctly
grab_error = None
try:
self.iDevices[fd].grab()
self.gDevices[fd] = True
self.env["runtime"]["DebugManager"].write_debug_out(
"InputDriver evdev: grab device ("
+ str(self.iDevices[fd].name)
+ ")",
debug.DebugLevel.INFO,
)
# Reset modifier keys on successful grab
if self.uDevices[fd]:
modifier_keys = [
e.KEY_LEFTCTRL,
e.KEY_RIGHTCTRL,
e.KEY_LEFTALT,
e.KEY_RIGHTALT,
e.KEY_LEFTSHIFT,
e.KEY_RIGHTSHIFT,
]
for key in modifier_keys:
try:
self.uDevices[fd].write(e.EV_KEY, key, 0) # 0 = key up
self.uDevices[fd].syn()
except Exception as mod_error:
self.env["runtime"]["DebugManager"].write_debug_out(
"Failed to reset modifier key: " + str(mod_error),
debug.DebugLevel.WARNING,
)
except IOError:
if not self.gDevices[fd]:
return False
except Exception as ex:
grab_error = ex
if grab_error:
self.env["runtime"]["DebugManager"].write_debug_out(
"InputDriver evdev: grabing not possible: " + str(grab_error),
debug.DebugLevel.ERROR,
)
return False
return True
def ungrab_device(self, fd):
"""Release exclusive access to an input device.
Returns control of the device to the system, allowing other
applications to receive its input.
Args:
fd (int): File descriptor of device to ungrab
Returns:
bool: True if ungrab successful, False otherwise
"""
if not self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"keyboard", "grabDevices"
):
return True
# FIX: Handle exception variable scope correctly
ungrab_error = None
try:
self.iDevices[fd].ungrab()
self.gDevices[fd] = False
self.env["runtime"]["DebugManager"].write_debug_out(
"InputDriver evdev: ungrab device ("
+ str(self.iDevices[fd].name)
+ ")",
debug.DebugLevel.INFO,
)
except IOError:
if self.gDevices[fd]:
return False
except Exception as ex:
ungrab_error = ex
if ungrab_error:
self.env["runtime"]["DebugManager"].write_debug_out(
"InputDriver evdev: ungrabing not possible: "
+ str(ungrab_error),
debug.DebugLevel.ERROR,
)
return False
return True
def remove_device(self, fd):
"""Remove an input device from monitoring.
Cleanly removes a device by ungrabbing it, closing file handles,
and cleaning up all associated data structures.
Args:
fd (int): File descriptor of device to remove
Note:
Thread-safe operation with comprehensive error handling.
"""
with self._deviceLock:
try:
self.env["runtime"]["DebugManager"].write_debug_out(
"InputDriver evdev: device removed: "
+ str(fd)
+ " "
+ str(self.iDevices[fd]),
debug.DebugLevel.INFO,
)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"InputDriver evdev: device removed: "
+ str(fd)
+ " Error: "
+ str(e),
debug.DebugLevel.INFO,
)
self.clear_event_buffer()
try:
self.ungrab_device(fd)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"evdevDriver remove_device: Error ungrabbing device "
+ str(fd)
+ ": "
+ str(e),
debug.DebugLevel.ERROR,
)
try:
self.iDevices[fd].close()
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"evdevDriver remove_device: Error closing iDevice "
+ str(fd)
+ ": "
+ str(e),
debug.DebugLevel.ERROR,
)
try:
self.uDevices[fd].close()
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"evdevDriver remove_device: Error closing uDevice "
+ str(fd)
+ ": "
+ str(e),
debug.DebugLevel.ERROR,
)
try:
del self.iDevices[fd]
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"evdevDriver remove_device: Error deleting iDevice "
+ str(fd)
+ ": "
+ str(e),
debug.DebugLevel.ERROR,
)
try:
del self.uDevices[fd]
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"evdevDriver remove_device: Error deleting uDevice "
+ str(fd)
+ ": "
+ str(e),
debug.DebugLevel.ERROR,
)
try:
del self.gDevices[fd]
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"evdevDriver remove_device: Error deleting gDevice "
+ str(fd)
+ ": "
+ str(e),
debug.DebugLevel.ERROR,
)
self.update_m_pi_devices_fd()
def has_i_devices(self):
if not self._initialized:
return False
if not self.iDevices:
return False
if len(self.iDevices) == 0:
return False
return True
def send_key(self, key, state):
"""Inject a synthetic key event.
Sends a key press or release event using UInput. Used for
features like key forwarding and macro execution.
Args:
key (str): Key name (e.g., 'KEY_A')
state (int): Key state (0=release, 1=press, 2=repeat)
"""
if not self._initialized:
return
try:
self.UInputinject.write(e.EV_KEY, e.ecodes[key], state)
self.UInputinject.syn()
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"evdevDriver send_key: Error sending key "
+ str(key)
+ ": "
+ str(e),
debug.DebugLevel.ERROR,
)
def remove_all_devices(self):
if not self.has_i_devices():
return
devices = self.iDevices.copy()
for fd in devices:
self.remove_device(fd)
self.iDevices.clear()
self.uDevices.clear()
self.gDevices.clear()
self.iDeviceNo = 0