diff --git a/README.xteink.md b/README.xteink.md index f8737f2b90..44d1e2e85e 100644 --- a/README.xteink.md +++ b/README.xteink.md @@ -26,6 +26,24 @@ nix develop ninja -C build qemu-system-riscv32 ``` +Or build the native xteink target directly: + +```sh +nix build .#qemu-native-xteink +``` + +### Agent emulator + +```sh +nix run .#xteink-emu -- start --firmware firmware.bin --sdcard sdcard.img +nix run .#xteink-emu -- wait --timeout 30 --match 'ready' +nix run .#xteink-emu -- button bottom-2 --hold-ms 100 +nix run .#xteink-emu -- screenshot --output screen.png +nix run .#xteink-emu -- stop +``` + +State defaults to `/tmp/xteink-emu-$UID`; pass `--state-dir` to any subcommand for parallel instances. `wait` advances a persistent log cursor after each match. + ### WebAssembly Driven by the parent repo, not here: diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000000..076045e580 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1784796856, + "narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e2587caef70cea85dd97d7daab492899902dbf5d", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix index edb63b4ab2..26127ae566 100644 --- a/flake.nix +++ b/flake.nix @@ -7,13 +7,102 @@ }; outputs = - { self, nixpkgs, flake-utils }: + { self + , nixpkgs + , flake-utils + , + }: flake-utils.lib.eachDefaultSystem ( system: let pkgs = nixpkgs.legacyPackages.${system}; + keycodemapdb = pkgs.fetchFromGitLab { + owner = "qemu-project"; + repo = "keycodemapdb"; + rev = "f5772a62ec52591ff6870b7e8ef32482371f22c6"; + hash = "sha256-GbZ5mrUYLXMi0IX4IZzles0Oyc095ij2xAsiLNJwfKQ="; + }; + qemu-native-xteink = pkgs.stdenv.mkDerivation { + pname = "qemu-native-xteink"; + version = pkgs.lib.strings.trim (builtins.readFile ./VERSION); + src = pkgs.lib.cleanSource ./.; + + nativeBuildInputs = with pkgs; [ + bison + flex + meson + ninja + perl + pkg-config + python3 + ]; + buildInputs = with pkgs; [ + dtc + glib + libgcrypt + libpng + pixman + zlib + ]; + + dontUseMesonConfigure = true; + + postPatch = '' + cp -r ${keycodemapdb} subprojects/keycodemapdb + chmod -R u+w subprojects/keycodemapdb + sed -i "/subdir('fp')/d" tests/meson.build + ''; + + configurePhase = '' + runHook preConfigure + ./configure \ + --prefix=$out \ + --target-list=riscv32-softmmu \ + --with-devices-riscv32=xteink \ + --with-coroutine=ucontext \ + --without-default-features \ + --enable-system \ + --enable-gcrypt \ + --enable-pixman \ + --enable-png \ + --disable-werror \ + --disable-docs \ + --disable-tools + runHook postConfigure + ''; + + buildPhase = '' + runHook preBuild + ninja -C build qemu-system-riscv32 + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + ninja -C build install + runHook postInstall + ''; + }; + xteink-emu = pkgs.writeShellApplication { + name = "xteink-emu"; + runtimeInputs = [ pkgs.uv ]; + text = '' + export XTEINK_QEMU=${qemu-native-xteink}/bin/qemu-system-riscv32 + export XTEINK_BIOS=${qemu-native-xteink}/share/qemu + export XTEINK_BOOTLOADER=${./scripts/assets/esp32c3-bootloader.bin} + export XTEINK_PARTITIONS=${./scripts/assets/crosspoint-partitions.bin} + exec uv run --script ${./scripts/xteink-emu.py} "$@" + ''; + }; in { + packages.qemu-native-xteink = qemu-native-xteink; + packages.xteink-emu = xteink-emu; + apps.xteink-emu = { + type = "app"; + program = "${xteink-emu}/bin/xteink-emu"; + }; + # Native build/test shell. The wasm target is built by the parent # xteink-web-emulator repo via its emsdk Docker toolchain, not here. # @@ -23,16 +112,17 @@ devShells.default = pkgs.mkShell { inputsFrom = [ pkgs.qemu ]; packages = with pkgs; [ - gnumake + dosfstools git - ninja - meson - pkg-config + gnumake libgcrypt libslirp - pixman - dosfstools + meson mtools + ninja + pixman + pkg-config + uv ]; }; } diff --git a/scripts/assets/crosspoint-partitions.bin b/scripts/assets/crosspoint-partitions.bin new file mode 100644 index 0000000000..71dc012d0b Binary files /dev/null and b/scripts/assets/crosspoint-partitions.bin differ diff --git a/scripts/assets/esp32c3-bootloader.bin b/scripts/assets/esp32c3-bootloader.bin new file mode 100644 index 0000000000..b4fc27da8f Binary files /dev/null and b/scripts/assets/esp32c3-bootloader.bin differ diff --git a/scripts/xteink-emu.py b/scripts/xteink-emu.py new file mode 100755 index 0000000000..768aada9c9 --- /dev/null +++ b/scripts/xteink-emu.py @@ -0,0 +1,301 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.11" +# dependencies = [] +# /// + +import argparse +import json +import os +import re +import signal +import socket +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +FLASH_SIZE = 16 * 1024 * 1024 +APP_OFFSET = 0x10000 +PARTITIONS_OFFSET = 0x8000 +RELEASED_ADC = 4095 +BUTTONS = { + "left": ("/machine/adc", "adci[2]", 2242, RELEASED_ADC), + "right": ("/machine/adc", "adci[2]", 5, RELEASED_ADC), + "bottom-1": ("/machine/adc", "adci[1]", 3512, RELEASED_ADC), + "bottom-2": ("/machine/adc", "adci[1]", 2694, RELEASED_ADC), + "bottom-3": ("/machine/adc", "adci[1]", 1493, RELEASED_ADC), + "bottom-4": ("/machine/adc", "adci[1]", 5, RELEASED_ADC), + "power": ("/machine/gpio", "input-level[3]", False, True), +} + + +def default_state_dir(): + return Path(tempfile.gettempdir()) / f"xteink-emu-{os.getuid()}" + + +def state_dir(args): + return Path(args.state_dir).expanduser().resolve() + + +def require_env(name): + value = os.environ.get(name) + if not value: + raise RuntimeError(f"{name} is not set; run xteink-emu through its Nix app") + return Path(value) + + +def pid_from(state): + try: + return int((state / "pid").read_text().strip()) + except (FileNotFoundError, ValueError): + return None + + +def process_alive(pid): + if pid is None: + return False + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + except PermissionError: + return True + + +def qmp_command(state, execute, arguments=None, timeout=5): + with socket.socket(socket.AF_UNIX) as sock: + sock.settimeout(timeout) + sock.connect(str(state / "qmp.sock")) + stream = sock.makefile("rwb") + json.loads(stream.readline()) + + def send(command): + stream.write(json.dumps(command).encode() + b"\n") + stream.flush() + while True: + response = json.loads(stream.readline()) + if "event" in response: + continue + if "error" in response: + raise RuntimeError(response["error"]["desc"]) + return response.get("return") + + send({"execute": "qmp_capabilities"}) + command = {"execute": execute} + if arguments is not None: + command["arguments"] = arguments + return send(command) + + +def require_running(state): + pid = pid_from(state) + if not process_alive(pid): + raise RuntimeError(f"xteink-emu is not running in {state}") + return pid + + +def build_flash(firmware, output): + app = firmware.read_bytes() + if len(app) == FLASH_SIZE: + output.write_bytes(app) + return + if not app or app[0] != 0xE9: + raise RuntimeError(f"{firmware} is not an ESP32 app image") + if APP_OFFSET + len(app) > FLASH_SIZE: + raise RuntimeError(f"{firmware} does not fit in a 16 MiB flash image") + + flash = bytearray(b"\xff" * FLASH_SIZE) + bootloader = require_env("XTEINK_BOOTLOADER").read_bytes() + partitions = require_env("XTEINK_PARTITIONS").read_bytes() + flash[: len(bootloader)] = bootloader + flash[PARTITIONS_OFFSET : PARTITIONS_OFFSET + len(partitions)] = partitions + flash[APP_OFFSET : APP_OFFSET + len(app)] = app + output.write_bytes(flash) + + +def start(args): + state = state_dir(args) + old_pid = pid_from(state) + if process_alive(old_pid): + raise RuntimeError(f"xteink-emu is already running as PID {old_pid} in {state}") + + firmware = Path(args.firmware).expanduser().resolve() + sdcard = Path(args.sdcard).expanduser().resolve() + if not firmware.is_file(): + raise RuntimeError(f"firmware not found: {firmware}") + if not sdcard.is_file(): + raise RuntimeError(f"SD card image not found: {sdcard}") + + state.mkdir(parents=True, exist_ok=True, mode=0o700) + for name in ("pid", "qmp.sock", "serial.log", "qemu.log", "wait.offset"): + (state / name).unlink(missing_ok=True) + (state / "serial.log").touch() + (state / "wait.offset").write_text("0\n") + flash = state / "flash.bin" + build_flash(firmware, flash) + + command = [ + str(require_env("XTEINK_QEMU")), + "-machine", "xteink,variant=x3", + "-accel", "tcg", + "-L", str(require_env("XTEINK_BIOS")), + "-display", "none", + "-serial", f"file:{state / 'serial.log'}", + "-monitor", "none", + "-qmp", f"unix:{state / 'qmp.sock'},server=on,wait=off", + "-nic", "none", + "-drive", f"file={flash},if=mtd,format=raw", + "-drive", f"file={sdcard},if=sd,format=raw", + ] + qemu_log = (state / "qemu.log").open("ab") + process = subprocess.Popen( + command, + stdin=subprocess.DEVNULL, + stdout=qemu_log, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + qemu_log.close() + (state / "pid").write_text(f"{process.pid}\n") + + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + if process.poll() is not None: + message = (state / "qemu.log").read_text(errors="replace") + raise RuntimeError(f"QEMU exited during startup\n{message}") + try: + qmp_command(state, "query-status") + print(f"started PID {process.pid}") + print(f"state: {state}") + print(f"serial log: {state / 'serial.log'}") + return + except (FileNotFoundError, ConnectionRefusedError, socket.timeout): + time.sleep(0.05) + process.terminate() + raise RuntimeError("QEMU did not open its QMP socket") + + +def stop(args): + state = state_dir(args) + pid = require_running(state) + try: + qmp_command(state, "quit") + except (ConnectionError, FileNotFoundError): + os.kill(pid, signal.SIGTERM) + + deadline = time.monotonic() + 5 + while process_alive(pid) and time.monotonic() < deadline: + time.sleep(0.05) + if process_alive(pid): + os.kill(pid, signal.SIGKILL) + (state / "pid").unlink(missing_ok=True) + (state / "qmp.sock").unlink(missing_ok=True) + print(f"stopped PID {pid}") + + +def wait_for_log(args): + state = state_dir(args) + require_running(state) + log_path = state / "serial.log" + offset_path = state / "wait.offset" + try: + pattern = re.compile(args.match) + except re.error as error: + raise RuntimeError(f"invalid regex: {error}") from error + try: + offset = int(offset_path.read_text().strip()) + except (FileNotFoundError, ValueError): + offset = 0 + + deadline = time.monotonic() + args.timeout + with log_path.open("r", errors="replace") as log: + log.seek(offset) + while True: + line = log.readline() + if line: + if pattern.search(line): + offset_path.write_text(f"{log.tell()}\n") + print(line, end="" if line.endswith("\n") else "\n") + return + continue + if time.monotonic() >= deadline: + raise RuntimeError(f"timed out after {args.timeout:g}s waiting for /{args.match}/") + if not process_alive(pid_from(state)): + raise RuntimeError("QEMU exited while waiting for serial output") + time.sleep(0.05) + + +def button(args): + state = state_dir(args) + require_running(state) + path, prop, pressed, released = BUTTONS[args.name] + qmp_command(state, "qom-set", {"path": path, "property": prop, "value": pressed}) + try: + time.sleep(args.hold_ms / 1000) + finally: + qmp_command(state, "qom-set", {"path": path, "property": prop, "value": released}) + + +def screenshot(args): + state = state_dir(args) + require_running(state) + output = Path(args.output).expanduser().resolve() + output.parent.mkdir(parents=True, exist_ok=True) + qmp_command(state, "screendump", {"filename": str(output), "format": "png"}, timeout=30) + print(output) + + +def parser(): + root = argparse.ArgumentParser(prog="xteink-emu") + commands = root.add_subparsers(dest="command", required=True) + + def add_state(command): + command.add_argument("--state-dir", default=default_state_dir()) + + command = commands.add_parser("start") + add_state(command) + command.add_argument("--firmware", required=True) + command.add_argument("--sdcard", required=True) + command.set_defaults(handler=start) + + command = commands.add_parser("stop") + add_state(command) + command.set_defaults(handler=stop) + + command = commands.add_parser("wait") + add_state(command) + command.add_argument("--timeout", type=float, required=True, help="seconds") + command.add_argument("--match", required=True, help="regular expression") + command.set_defaults(handler=wait_for_log) + + command = commands.add_parser("button") + add_state(command) + command.add_argument("name", choices=BUTTONS) + command.add_argument("--hold-ms", type=int, default=100) + command.set_defaults(handler=button) + + command = commands.add_parser("screenshot") + add_state(command) + command.add_argument("--output", required=True) + command.set_defaults(handler=screenshot) + return root + + +def main(): + args = parser().parse_args() + if getattr(args, "timeout", 0) < 0: + raise RuntimeError("--timeout must not be negative") + if getattr(args, "hold_ms", 0) < 0: + raise RuntimeError("--hold-ms must not be negative") + args.handler(args) + + +if __name__ == "__main__": + try: + main() + except (RuntimeError, OSError) as error: + print(f"xteink-emu: {error}", file=sys.stderr) + sys.exit(1)