# 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