"""Keyboard and pointer injection into the focused window, driven from Home Assistant. The point of this module is the Home Assistant mobile app: type a search term into the kiosk's Firefox, or nudge and click the pointer, without opening a VNC viewer. ydotool rather than xdotool: xdotool talks XTEST to an X server, which does not exist here — this is a wlroots Wayland session. ydotool goes in the other direction, writing to /dev/uinput as a virtual input device, so the compositor sees ordinary hardware events and no compositor-specific protocol is involved. SECURITY: the text entity's payload is typed verbatim, and that is the entire point of it — the user is asking for those characters to appear in the focused field. It is passed as a single argv element to subprocess with no shell, so it can be any string without becoming a command. Every *other* action here is a fixed enumerated constant: the movement buttons resolve to one of four hard-coded deltas and the click buttons to one of two hard-coded button codes. Nothing inbound ever becomes an argv element except that one string. move_relative() takes an arbitrary integer delta rather than an enumerated direction, which is why it is worth being explicit that it is NOT part of that inbound surface: mqtt_discovery.py never wires it to a topic. Its only caller is configs/gesture-control/gesture_pointer.py, which runs locally on this machine with no network input of any kind. The deltas are clamped and coerced to int here anyway, so the argv elements stay numeric whatever a caller passes. """ from __future__ import annotations import logging import shutil import subprocess log = logging.getLogger(__name__) MOVE_STEP = 20 DIRECTIONS = { "UP": (0, -MOVE_STEP), "DOWN": (0, MOVE_STEP), "LEFT": (-MOVE_STEP, 0), "RIGHT": (MOVE_STEP, 0), } # ydotool 1.x takes a hex mask where 0x40 is "left button" and 0xC0 is # "left button, press and release". ydotool 0.1.x (what Debian bookworm ships) takes a # plain index instead: 0 left, 1 right, 2 middle. Which dialect is installed is decided # in _uses_daemon_dialect() below. CLICK_CODES = { "LEFT": {"modern": "0xC0", "legacy": "0"}, "RIGHT": {"modern": "0xC1", "legacy": "1"}, } MAX_TYPE_LENGTH = 255 # Ceiling on a single move_relative() step. A camera gesture frame that lands badly # should nudge the pointer wrongly, not fling it off the far edge of the display. MAX_RELATIVE_STEP = 200 class InputControl: def __init__(self, env_provider): self._env_provider = env_provider # ydotool 1.x split the tool into a client plus a ydotoold daemon that owns # /dev/uinput; 0.1.x has no daemon and opens the device itself. The presence of # the daemon binary is therefore also a reliable marker of which command-line # dialect this image got. See 1000-ydotool.hook.chroot. self._dialect = "modern" if shutil.which("ydotoold") else "legacy" self.available = shutil.which("ydotool") is not None if not self.available: log.warning("ydotool is not installed; the HA input-control entities will do nothing") else: log.info("ydotool present, using the %s command dialect", self._dialect) def _ydotool(self, *args: str) -> bool: if not self.available: return False try: result = subprocess.run( ["ydotool", *args], env=self._env_provider(), capture_output=True, text=True, timeout=15, check=False, ) except (OSError, subprocess.TimeoutExpired) as exc: log.warning("ydotool %s failed: %s", args[0], exc) return False if result.returncode != 0: log.warning("ydotool %s: %s", args[0], result.stderr.strip()) return False return True def type_text(self, payload: str) -> None: text = payload.rstrip("\n") if not text: return if len(text) > MAX_TYPE_LENGTH: # Not a security limit — the payload is safe at any length. It is a guard # against a stuck automation holding the keyboard for minutes on a machine # whose display is shared with a room full of people. log.warning("truncating typed text from %d to %d characters", len(text), MAX_TYPE_LENGTH) text = text[:MAX_TYPE_LENGTH] log.info("typing %d characters into the focused window", len(text)) # `--` so text beginning with a dash is typed rather than parsed as options. # VERIFY: accepted by ydotool 1.x; if the installed 0.1.x build rejects it, drop # it here — the only consequence is that leading-dash text is misread as flags. self._ydotool("type", "--", text) def move(self, direction: str) -> None: delta = DIRECTIONS.get(direction.strip().upper()) if delta is None: log.warning("ignoring unknown pointer direction %r", direction) return self.move_relative(*delta) def move_relative(self, dx: int, dy: int) -> None: dx = max(-MAX_RELATIVE_STEP, min(MAX_RELATIVE_STEP, int(dx))) dy = max(-MAX_RELATIVE_STEP, min(MAX_RELATIVE_STEP, int(dy))) if dx == 0 and dy == 0: return if self._dialect == "modern": self._ydotool("mousemove", "-x", str(dx), "-y", str(dy)) else: self._ydotool("mousemove", str(dx), str(dy)) def click(self, button: str) -> None: code = CLICK_CODES.get(button.strip().upper()) if code is None: log.warning("ignoring unknown mouse button %r", button) return self._ydotool("click", code[self._dialect])