from __future__ import annotations from pathlib import Path from evdev import InputDevice, ecodes, list_devices def is_mouse(dev: InputDevice) -> bool: keys = dev.capabilities().get(ecodes.EV_KEY, []) rels = dev.capabilities().get(ecodes.EV_REL, []) return ecodes.BTN_LEFT in keys and ecodes.REL_X in rels and ecodes.REL_Y in rels def is_keyboard(dev: InputDevice) -> bool: caps = dev.capabilities().get(ecodes.EV_KEY, []) # Gaming mice (e.g. Logitech G502) commonly also advertise KEY_A/ # KEY_SPACE (programmable buttons / media-key emulation) and would # otherwise get opened redundantly as both "keyboard" and "mouse" by # separate listeners, which was observed to make one of the duplicate # opens randomly drop ("Input device disconnected"). return ecodes.KEY_A in caps and ecodes.KEY_SPACE in caps and not is_mouse(dev) def inaccessible_keyboard_names() -> list[str]: """Best-effort list of device names the kernel flags as keyboards (a "kbd" handler in /proc/bus/input/devices) but that this process can't actually open. evdev.list_devices() silently drops any /dev/input/eventN this process fails an os.access(R_OK | W_OK) check on - no exception, no gap in the returned list, so a real keyboard blocked by permissions just vanishes without a trace and whatever else happens to qualify as "keyboard-like" (e.g. a gaming mouse's macro-key HID sub-interface) silently becomes the only thing listened on. Observed on a Bazzite/GNOME machine: the physical keyboard's device node wasn't covered by the desktop session's usual per-session uaccess ACL (likely stripped by a Steam-Input udev rule for recognized gaming-keyboard hardware IDs), leaving it gated by the static 'input' group alone - which only takes effect for the process's *next* login session, not a new terminal in an already-running one. /proc/bus/input/devices is a plain readable text file regardless of any of that, so it's used here purely to produce an actionable diagnostic, never to open anything. """ try: text = Path("/proc/bus/input/devices").read_text() except OSError: return [] accessible = {Path(p).name for p in list_devices()} missing: list[str] = [] name: str | None = None for line in text.splitlines(): if line.startswith("N: Name="): name = line.split("=", 1)[1].strip('"') elif line.startswith("H: Handlers="): handlers = line.split("=", 1)[1].split() if "kbd" not in handlers: continue event_handlers = [h for h in handlers if h.startswith("event")] if event_handlers and event_handlers[0] not in accessible: missing.append(name or event_handlers[0]) return missing