From ee101c869880536685a52fb2e1784af7017b4168 Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Fri, 24 Jul 2026 14:11:15 -0400 Subject: [PATCH] feat(xteink): add native emulator harness --- README.xteink.md | 18 ++ flake.lock | 61 +++++ flake.nix | 104 +++++++- scripts/assets/crosspoint-partitions.bin | Bin 0 -> 3072 bytes scripts/assets/esp32c3-bootloader.bin | Bin 0 -> 18672 bytes scripts/xteink-emu.py | 301 +++++++++++++++++++++++ 6 files changed, 477 insertions(+), 7 deletions(-) create mode 100644 flake.lock create mode 100644 scripts/assets/crosspoint-partitions.bin create mode 100644 scripts/assets/esp32c3-bootloader.bin create mode 100755 scripts/xteink-emu.py 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 0000000000000000000000000000000000000000..71dc012d0b72b43cdfa0eb09b362891c7c07d011 GIT binary patch literal 3072 zcmZ1#z{tcffq{V`fPo>etQg2Z1*-xW85kY_#S|DA@=Fp^5=#cs$ocB zNGvEYK#>G;fbxP245=`ALri(bCI*I+Kw&e6;)2YyG&Cmw?Pq3S_z#q4WJu00N=+%v zEkHKo_3QsAV6*Po9oAwc>($G8CT;xDm4qTW${7uT(GVC7fzc2c4S~@R7!85Z5Eu=C H5gq~nRy;-y literal 0 HcmV?d00001 diff --git a/scripts/assets/esp32c3-bootloader.bin b/scripts/assets/esp32c3-bootloader.bin new file mode 100644 index 0000000000000000000000000000000000000000..b4fc27da8f61f218314b13482d1e1088a5853386 GIT binary patch literal 18672 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 literal 0 HcmV?d00001 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)