feat(xteink): add native emulator harness

This commit is contained in:
2026-07-24 14:11:15 -04:00
parent 598b5e66fd
commit ee101c8698
6 changed files with 477 additions and 7 deletions
+18
View File
@@ -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:
Generated
+61
View File
@@ -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
}
+97 -7
View File
@@ -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
];
};
}
Binary file not shown.
Binary file not shown.
+301
View File
@@ -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)