diff --git a/configs/qemu.ini b/configs/qemu.ini index f69e15fc..d0e5261a 100644 --- a/configs/qemu.ini +++ b/configs/qemu.ini @@ -71,6 +71,12 @@ size=32 [keyboard] variant=qemu +# --------------------------------------------------------------------------- +# Mouse settings +# --------------------------------------------------------------------------- +[mouse] +variant=qemu + # --------------------------------------------------------------------------- # USB devices # --------------------------------------------------------------------------- diff --git a/mtda/console/qemu.py b/mtda/console/qemu.py index 756a9b94..767b19cd 100644 --- a/mtda/console/qemu.py +++ b/mtda/console/qemu.py @@ -41,8 +41,8 @@ def open(self): result = self.opened if self.opened is False: try: - self.tx = open("/tmp/qemu-serial.in", mode="wb", buffering=0) - self.rx = open("/tmp/qemu-serial.out", mode="rb", buffering=0) + self.tx = open(self.qemu.serial_in, mode="wb", buffering=0) + self.rx = open(self.qemu.serial_out, mode="rb", buffering=0) result = True finally: diff --git a/mtda/keyboard/qemu.py b/mtda/keyboard/qemu.py index 398cccea..fbbfa1f3 100644 --- a/mtda/keyboard/qemu.py +++ b/mtda/keyboard/qemu.py @@ -55,21 +55,22 @@ def press(self, key, repeat=1, ctrl=False, shift=False, alt=False, '\n': 'ret' } - mod = "" + mods = [] if ctrl: - mod = "ctrl-" + mods.append("ctrl") if shift: - mod = f"{mod}shift-" + mods.append("shift") if alt: - mod = f"{mod}alt-" + mods.append("alt") if meta: - mod = f"{mod}meta_l-" + mods.append("meta_l") result = True + key = symbols[key] if key in symbols else key + keys = [{"type": "qcode", "data": k} for k in mods + [key]] while repeat > 0: repeat = repeat - 1 - key = symbols[key] if key in symbols else key - self.qemu.cmd(f"sendkey {mod}{key}") + self.qemu.qmp("send-key", {"keys": keys}) time.sleep(0.1) return result diff --git a/mtda/mouse/qemu.py b/mtda/mouse/qemu.py new file mode 100644 index 00000000..379dd4d4 --- /dev/null +++ b/mtda/mouse/qemu.py @@ -0,0 +1,98 @@ +# --------------------------------------------------------------------------- +# QEMU mouse driver for MTDA +# --------------------------------------------------------------------------- +# +# This software is a part of MTDA. +# Copyright (C) 2026 Siemens AG +# +# --------------------------------------------------------------------------- +# SPDX-License-Identifier: MIT +# --------------------------------------------------------------------------- + +# Local imports +from mtda.mouse.controller import MouseController +import mtda.constants as CONSTS + +# bit0=left, bit1=right, bit2=middle +BUTTONS = ( + (1, "left"), + (2, "right"), + (4, "middle"), +) + + +class QemuController(MouseController): + + def __init__(self, mtda): + self.mtda = mtda + self.qemu = mtda.power + self._index = None + + def configure(self, conf): + self.mtda.debug(3, "mouse.qemu.configure()") + return True + + def probe(self): + self.mtda.debug(3, "mouse.qemu.probe()") + + result = (self.qemu is not None and self.qemu.variant == "qemu") + if result is False: + self.mtda.debug(1, "mouse.qemu.probe(): " + "a qemu power controller is required") + + self.mtda.debug(3, f"mouse.qemu.probe(): {str(result)}") + return result + + def idle(self): + return True + + def _tablet_state(self): + """Return (index, active) for the "-device usb-tablet" device. + The index is cached after the first successful lookup since + the device set is fixed at boot, but "active" is read fresh + every time: it reflects which pointer QEMU currently uses. + """ + mice = self.qemu.qmp("query-mice") + for mouse in (mice if mice is not None else []): + if self._index is None: + if "tablet" in mouse.get("name", "").lower(): + self._index = mouse.get("index") + else: + continue + if mouse.get("index") == self._index: + return self._index, mouse.get("current", False) + + return self._index, False + + def move(self, x, y, buttons=0): + self.mtda.debug(3, f"mouse.qemu.move({x}, {y}, {buttons})") + + index, active = self._tablet_state() + if index is not None: + if active is False: + self.qemu.qmp( + "human-monitor-command", + {"command-line": f"mouse_set {index}"}) + else: + self.mtda.debug(1, "mouse.qemu.move(): " + "usb-tablet not found in 'query-mice'") + + ax = int(x * CONSTS.MOUSE.MAX_X) + ay = int(y * CONSTS.MOUSE.MAX_Y) + self.mtda.debug(3, "mouse.qemu.move(): " + f"ax={ax} ay={ay} buttons={buttons}") + events = [ + {"type": "abs", "data": {"axis": "x", "value": ax}}, + {"type": "abs", "data": {"axis": "y", "value": ay}}, + ] + for bit, button in BUTTONS: + data = {"down": bool(buttons & bit), "button": button} + events.append({"type": "btn", "data": data}) + result = self.qemu.qmp("input-send-event", {"events": events}) + + self.mtda.debug(3, f"mouse.qemu.move(): {result}") + return result + + +def instantiate(mtda): + return QemuController(mtda) diff --git a/mtda/power/qemu.py b/mtda/power/qemu.py index e7c83d18..f19a10e3 100644 --- a/mtda/power/qemu.py +++ b/mtda/power/qemu.py @@ -11,10 +11,11 @@ # System imports import atexit +import json import os import pathlib import psutil -import re +import socket import subprocess import tempfile import threading @@ -27,6 +28,10 @@ from mtda.utils import Size, System +def _runtime_dir(): + return os.environ.get("XDG_RUNTIME_DIR", tempfile.gettempdir()) + + class QemuController(PowerController): def __init__(self, mtda): @@ -37,7 +42,7 @@ def __init__(self, mtda): self.drives = [] self.executable = "kvm" self.hostname = "mtda-kvm" - self.lock = threading.Lock() + self.lock = threading.RLock() self.machine = None self.memory = Size.to_bytes(512, 'MiB') self.mtda = mtda @@ -51,6 +56,17 @@ def __init__(self, mtda): self.uuid = None self.watchdog = None self.websockify = "/usr/bin/websockify" + self._qmp_sock = None + self._qmp_file = None + self._usb_devices = set() + + runtime_dir = _runtime_dir() + self._qmp_socket = os.path.join(runtime_dir, "qemu-mtda.qmp") + self._serial_base = os.path.join(runtime_dir, "qemu-serial") + self.serial_in = self._serial_base + ".in" + self.serial_out = self._serial_base + ".out" + self._swtpm_dir = os.path.join(runtime_dir, "qemu-swtpm") + self._swtpm_sock = os.path.join(self._swtpm_dir, "sock") def configure(self, conf): self.mtda.debug(3, "power.qemu.configure()") @@ -140,30 +156,26 @@ def start(self): if self.pidOfQemu is not None: return True - if os.path.exists("/tmp/qemu-mtda.in"): - os.unlink("/tmp/qemu-mtda.in") - if os.path.exists("/tmp/qemu-mtda.out"): - os.unlink("/tmp/qemu-mtda.out") - if os.path.exists("/tmp/qemu-serial.in"): - os.unlink("/tmp/qemu-serial.in") - if os.path.exists("/tmp/qemu-serial.out"): - os.unlink("/tmp/qemu-serial.out") - os.mkfifo("/tmp/qemu-mtda.in") - os.mkfifo("/tmp/qemu-mtda.out") - os.mkfifo("/tmp/qemu-serial.in") - os.mkfifo("/tmp/qemu-serial.out") + if os.path.exists(self._qmp_socket): + os.unlink(self._qmp_socket) + if os.path.exists(self.serial_in): + os.unlink(self.serial_in) + if os.path.exists(self.serial_out): + os.unlink(self.serial_out) + os.mkfifo(self.serial_in) + os.mkfifo(self.serial_out) atexit.register(self.stop) # base options options = f"-daemonize -S -m {int(self.memory / 1024**2)}" - options += " -chardev pipe,id=monitor,path=/tmp/qemu-mtda" - options += " -monitor chardev:monitor" - options += " -serial pipe:/tmp/qemu-serial" + options += f" -qmp unix:{self._qmp_socket},server=on,wait=off" + options += f" -serial pipe:{self._serial_base}" options += " -device e1000,netdev=net0" options += " -netdev user,id=net0," options += f"hostfwd=tcp::2222-:22,hostname={self.hostname}" options += " -device qemu-xhci" + options += " -device usb-tablet" options += " -vga virtio" options += " -vnc :0,websocket=on" @@ -255,12 +267,12 @@ def start(self): # swtpm options if self.swtpm is not None: with tempfile.NamedTemporaryFile() as pidfile: - os.makedirs("/tmp/qemu-swtpm", exist_ok=True) + os.makedirs(self._swtpm_dir, exist_ok=True) result = os.system( self.swtpm + " socket -d" - + " --tpmstate dir=/tmp/qemu-swtpm" - + " --ctrl type=unixio,path=/tmp/qemu-swtpm/sock" + + f" --tpmstate dir={self._swtpm_dir}" + + f" --ctrl type=unixio,path={self._swtpm_sock}" + f" --pid file={pidfile.name} --tpm2") if result == 0: self.pidOfSwTpm = self.getpid(pidfile.name) @@ -274,7 +286,7 @@ def start(self): return False options += " -chardev socket,id=chrtpm," - options += "path=/tmp/qemu-swtpm/sock" + options += f"path={self._swtpm_sock}" options += " -tpmdev emulator,id=tpm0,chardev=chrtpm" options += " -device tpm-tis,tpmdev=tpm0" @@ -298,6 +310,10 @@ def start(self): self.mtda.debug(2, "power.qemu.start(): " "qemu process started " "[{0}]".format(self.pidOfQemu)) + if self._qmp_connect() is False: + self.mtda.debug(1, "power.qemu.start(): " + "could not connect to QMP socket") + return False return True else: self.mtda.debug(1, "power.qemu.start(): " @@ -311,6 +327,16 @@ def stop(self): self.lock.acquire() result = True + if self._qmp_sock is not None: + try: + self._qmp_file.close() + self._qmp_sock.close() + except OSError: + pass + self._qmp_sock = None + self._qmp_file = None + self._usb_devices.clear() + if self.pidOfQemu is not None: result = System.kill("qemu", self.pidOfQemu) if result: @@ -329,69 +355,78 @@ def stop(self): self.lock.release() return result - def monitor_output_non_blocking(self): - self.mtda.debug(4, "power.qemu.monitor_output_non_blocking()") - - fd = os.open("/tmp/qemu-mtda.out", os.O_RDONLY) - os.set_blocking(fd, False) - try: - output = os.read(fd, 2048).decode('utf-8') - except BlockingIOError: - output = "" - os.close(fd) - return output - - def monitor_command_output(self): - self.mtda.debug(3, "power.qemu.monitor_command_output()") - - output = "" - while output.endswith("(qemu) ") is False: - output += self.monitor_output_non_blocking() - if output.endswith("(qemu) "): - output = output[:-7] - - self.mtda.debug(3, f"power.qemu.monitor_command_output(): {output}") - return output + def _qmp_send(self, msg): + self._qmp_file.write(json.dumps(msg) + "\n") + self._qmp_file.flush() - def _cmd(self, what): - self.mtda.debug(3, "power.qemu._cmd()") - - started = self.start() - if started is False: + def _qmp_recv(self): + while True: + line = self._qmp_file.readline() + if not line: + return None + msg = json.loads(line) + # events may be interleaved with command responses; only a + # message without an "event" key answers the command we sent + if "event" not in msg: + return msg + + def _qmp_connect(self, timeout=30): + self.mtda.debug(3, "power.qemu._qmp_connect()") + + sock = None + while timeout > 0: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + sock.connect(self._qmp_socket) + break + except OSError: + sock.close() + sock = None + time.sleep(1) + timeout -= 1 + if sock is None: + return False + + self._qmp_sock = sock + self._qmp_file = sock.makefile(mode="rw") + self._qmp_recv() # greeting + self._qmp_send({"execute": "qmp_capabilities"}) + self._qmp_recv() + return True + + def qmp(self, command, arguments=None): + self.mtda.debug(3, f"power.qemu.qmp({command})") + + with self.lock: + started = self.start() + if started is False: + return None + msg = {"execute": command} + if arguments: + msg["arguments"] = arguments + self._qmp_send(msg) + response = self._qmp_recv() + + if response is None: + self.mtda.debug(1, f"power.qemu.qmp(): no response to '{command}'") + return None + if "error" in response: + self.mtda.debug(1, "power.qemu.qmp(): " + f"'{command}' failed: {response['error']}") return None - # flush monitor output - self.monitor_output_non_blocking() - - # send requested command to "out" pipe - what += "\n" - with open("/tmp/qemu-mtda.in", "w") as f: - f.write(what) - - # provide response from the monitor - output = self.monitor_command_output() - - self.mtda.debug(3, f"power.qemu._cmd(): {str(output)}") - return output - - def cmd(self, what): - self.mtda.debug(3, "power.qemu.cmd()") - - self.lock.acquire() - result = self._cmd(what) - self.lock.release() - - self.mtda.debug(3, f"power.qemu.cmd(): {str(result)}") + result = response.get("return") + self.mtda.debug(3, f"power.qemu.qmp(): {result}") return result def command(self, args): self.mtda.debug(3, "power.qemu.command()") - result = self.cmd(" ".join(args)) - result = "\n".join(result.splitlines()[1:]) if result is not None else "" + result = self.qmp( + "human-monitor-command", {"command-line": " ".join(args)}) self.mtda.debug(3, f"power.qemu.command(): {str(result)}") - return result + return result if result is not None else "" def on(self): self.mtda.debug(3, "power.qemu.on()") @@ -399,8 +434,8 @@ def on(self): s = self.status() if s == self.POWER_ON: return True - self.cmd("system_reset") - self.cmd("cont") + self.qmp("system_reset") + self.qmp("cont") return self.status() == self.POWER_ON def off(self): @@ -409,103 +444,110 @@ def off(self): s = self.status() if s == self.POWER_OFF: return True - self.cmd("stop") - self.cmd("system_reset") + self.qmp("stop") + self.qmp("system_reset") return self.status() == self.POWER_OFF def status(self): self.mtda.debug(3, "power.qemu.status()") result = self.POWER_UNSURE - status = self.cmd('info status') - if status is not None: - for line in status.splitlines(): - line = line.strip() - if line.startswith("VM status:"): - if 'running' in line: - result = self.POWER_ON - elif 'paused' in line: - result = self.POWER_OFF - break + info = self.qmp("query-status") + if info is not None: + result = self.POWER_ON if info.get("running") else self.POWER_OFF if result == self.POWER_UNSURE: - self.mtda.debug(1, f"unknown power status: {str(status)}") + self.mtda.debug(1, f"unknown power status: {str(info)}") self.mtda.debug(3, f"power.qemu.status(): {str(result)}") return result - def usb_ids(self): - info = self._cmd("info usb") - if info is None: - return [] - lines = info.splitlines() - results = [] - for line in lines: - line = line.strip() - if line.startswith("Device "): - self.mtda.debug(2, f"power.qemu.usb_ids(): {line}") - match = re.findall(r'ID: (\S+)$', line) - if match: - results.append(match[0]) - return results - def usb_add(self, id, file): self.mtda.debug(3, "power.qemu.usb_add()") result = None - self.lock.acquire() - - if id not in self.usb_ids(): - self.mtda.debug(2, "power.qemu." - f"usb_add(): adding '{file}' as '{id}'") - cmdstr = "drive_add 0 if=none,id={0},file={1}" - output = self._cmd(cmdstr.format(id, file)) - added = False - reason = "drive_add failed" - for line in (output.splitlines() if output is not None else []): - line = line.strip() - if line == "OK": - added = True - break - if added is True: - reason = "device_add failed" - cmdstr = "device_add usb-storage,id={0},drive={0},removable=on" - self._cmd(cmdstr.format(id)) - added = (id in self.usb_ids()) - if added is True: - result = id - self.mtda.debug(2, "power.qemu.usb_add(): " - "usb-storage '{0}' connected".format(id)) - else: - self.mtda.debug(1, "power.qemu.usb_add(): " - "usb-storage '{0}' could not be added " - "({1})!".format(id, reason)) + with self.lock: + if id not in self._usb_devices: + self.mtda.debug(2, "power.qemu." + f"usb_add(): adding '{file}' as '{id}'") + info = subprocess.check_output( + ['qemu-img', 'info', '--output=json', file], + encoding="utf-8") + fmt = json.loads(info)['format'] + + reason = "blockdev-add failed" + added = self.qmp("blockdev-add", { + "driver": fmt, + "node-name": id, + "file": {"driver": "file", "filename": file}, + }) is not None + if added is True: + reason = "device_add failed" + added = self.qmp("device_add", { + "driver": "usb-storage", + "id": id, + "drive": id, + "removable": True, + }) is not None + if added is False: + self.qmp("blockdev-del", {"node-name": id}) + if added is True: + result = id + self._usb_devices.add(id) + self.mtda.debug(2, "power.qemu.usb_add(): " + "usb-storage '{0}' connected" + .format(id)) + else: + self.mtda.debug(1, "power.qemu.usb_add(): " + "usb-storage '{0}' could not be added " + "({1})!".format(id, reason)) self.mtda.debug(3, f"power.qemu.usb_add(): {str(result)}") - self.lock.release() return result def usb_rm(self, id): self.mtda.debug(3, "power.qemu.usb_rm()") result = True - self.lock.acquire() - - if id in self.usb_ids(): - self._cmd(f"device_del {id}") - result = (id not in self.usb_ids()) - if result: - self.mtda.debug(2, "power.qemu." - f"usb_rm(): usb-storage '{id}' removed") - else: - self.mtda.debug(1, "power.qemu.usb_rm(): " - "usb-storage '{0}' could not be " - "removed!".format(id)) + with self.lock: + if id in self._usb_devices: + result = self.qmp("device_del", {"id": id}) is not None + if result: + if not self._qmp_wait_device_deleted(id): + self.mtda.debug(1, "power.qemu.usb_rm(): " + f"'{id}' not confirmed removed, " + "trying blockdev-del anyway") + self.qmp("blockdev-del", {"node-name": id}) + self._usb_devices.discard(id) + self.mtda.debug(2, "power.qemu." + f"usb_rm(): usb-storage '{id}' " + "removed") + else: + self.mtda.debug(1, "power.qemu.usb_rm(): " + "usb-storage '{0}' could not be " + "removed!".format(id)) self.mtda.debug(3, f"power.qemu.usb_rm(): {str(result)}") - self.lock.release() return result + def _qmp_wait_device_deleted(self, id, timeout=10): + self.mtda.debug(3, f"power.qemu._qmp_wait_device_deleted({id})") + + self._qmp_sock.settimeout(timeout) + try: + while True: + line = self._qmp_file.readline() + if not line: + return False + msg = json.loads(line) + if (msg.get("event") == "DEVICE_DELETED" + and msg.get("data", {}).get("device") == id): + return True + except socket.timeout: + return False + finally: + self._qmp_sock.settimeout(None) + def instantiate(mtda): return QemuController(mtda) diff --git a/mtda/video/qemu.py b/mtda/video/qemu.py index a5400627..571fe6c9 100644 --- a/mtda/video/qemu.py +++ b/mtda/video/qemu.py @@ -63,7 +63,7 @@ def snapshot(self): try: with tempfile.NamedTemporaryFile(suffix=".ppm") as tmp: - self.qemu.cmd(f"screendump {tmp.name}") + self.qemu.qmp("screendump", {"filename": tmp.name}) tmp.seek(0) img = Image.open(tmp.name) buf = io.BytesIO()