Files
qemu-xteink/scripts/xteink/emu.py
T
evan 0feece9036 feat(xteink): add a web front end for the emulator
Split the emulator script into an xteink package so the CLI and the new
web UI share one QEMU driver. The web UI uploads firmware and an SD image,
boots them, drives the buttons, and long-polls the panel frame generation
so the screen updates on each e-ink refresh.
2026-07-26 11:18:28 -04:00

313 lines
11 KiB
Python
Executable File

"""Core emulator control shared by the CLI and the web UI."""
import json
import os
import re
import signal
import socket
import subprocess
import tempfile
import time
from pathlib import Path
FLASH_SIZE = 16 * 1024 * 1024
APP_OFFSET = 0x10000
PARTITIONS_OFFSET = 0x8000
RELEASED_ADC = 4095
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
DEFAULT_PATHS = {
"XTEINK_QEMU": PROJECT_ROOT / "dist/qemu-native/bin/qemu-system-riscv32",
"XTEINK_BIOS": PROJECT_ROOT / "dist/qemu-native/share/qemu",
"XTEINK_BOOTLOADER": PROJECT_ROOT / "scripts/assets/esp32c3-bootloader.bin",
"XTEINK_PARTITIONS": PROJECT_ROOT / "scripts/assets/xteink-partitions.bin",
}
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),
}
VARIANTS = ("x3", "x4")
def default_state_dir():
return Path(tempfile.gettempdir()) / f"xteink-emu-{os.getuid()}"
def require_env(name):
path = Path(os.environ.get(name, DEFAULT_PATHS[name]))
if not path.exists():
raise RuntimeError(f"{path} does not exist; run 'make xteink'")
return path
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 is_running(state):
return process_alive(pid_from(state))
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 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 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 hold_power(state, hold_ms):
path, prop, pressed, released = BUTTONS["power"]
qmp_command(state, "qom-set", {"path": path, "property": prop, "value": pressed})
try:
time.sleep(hold_ms / 1000)
finally:
qmp_command(state, "qom-set", {"path": path, "property": prop, "value": released})
def launch(state, firmware, sdcard, variant="x3", power_hold_ms=2000, interactive=False):
"""Boot QEMU in `state`, returning the process once its QMP socket answers."""
old_pid = pid_from(state)
if process_alive(old_pid):
raise RuntimeError(f"xteink-emu is already running as PID {old_pid} in {state}")
if variant not in VARIANTS:
raise RuntimeError(f"unknown variant: {variant}")
firmware = Path(firmware).expanduser().resolve()
sdcard = Path(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)
efuse = bytearray(1024)
efuse[0x18:0x1E] = bytes.fromhex("563412005452")
efuse[0x26] = 0x0C
(state / "efuse.bin").write_bytes(efuse)
command = [
str(require_env("XTEINK_QEMU")),
"-machine", f"xteink,variant={variant}",
"-accel", "tcg",
"-L", str(require_env("XTEINK_BIOS")),
"-display", "gtk" if interactive else "none",
"-serial", "stdio",
"-monitor", "none",
"-qmp", f"unix:{state / 'qmp.sock'},server=on,wait=off",
"-nic", "user,model=esp32c3_wifi,mac=52:54:00:12:34:56,net=192.168.4.0/24",
"-drive", f"file={flash},if=mtd,format=raw",
"-drive", f"file={state / 'efuse.bin'},if=none,format=raw,id=efuse",
"-global", "driver=nvram.esp32c3.efuse,property=drive,value=efuse",
"-drive", f"file={sdcard},if=sd,format=raw",
]
if interactive:
process = subprocess.Popen(command)
else:
with (state / "serial.log").open("ab") as serial_log:
process = subprocess.Popen(
command,
stdin=subprocess.DEVNULL,
stdout=serial_log,
stderr=subprocess.STDOUT,
start_new_session=True,
)
(state / "pid").write_text(f"{process.pid}\n")
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
if process.poll() is not None:
message = (state / "serial.log").read_text(errors="replace")
raise RuntimeError(f"QEMU exited during startup\n{message}")
try:
qmp_command(state, "query-status")
if power_hold_ms:
hold_power(state, power_hold_ms)
return process
except (FileNotFoundError, ConnectionRefusedError, socket.timeout):
time.sleep(0.05)
process.terminate()
raise RuntimeError("QEMU did not open its QMP socket")
def shutdown(state):
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)
return pid
def wait_for_log(state, match, timeout):
"""Scan the serial log from the persistent cursor and return the first matching line."""
require_running(state)
offset_path = state / "wait.offset"
try:
pattern = re.compile(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() + timeout
with (state / "serial.log").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")
return line
continue
if time.monotonic() >= deadline:
raise RuntimeError(f"timed out after {timeout:g}s waiting for /{match}/")
if not is_running(state):
raise RuntimeError("QEMU exited while waiting for serial output")
time.sleep(0.05)
def frame_generation(state):
"""Panel refresh counter; changes only when the e-ink display actually redraws."""
return qmp_command(state, "qom-get", {"path": "/machine", "property": "frame-generation"})
def await_frame(state, since, timeout):
"""Block until the panel redraws, returning the new generation or None on timeout."""
deadline = time.monotonic() + timeout
while True:
current = frame_generation(state)
if current != since:
return current
if time.monotonic() >= deadline:
return None
time.sleep(0.05)
def adc_samples(state, prop):
"""Number of times the guest has read this ADC channel."""
channel = prop[prop.index("[") + 1 : prop.index("]")]
return qmp_command(state, "qom-get", {"path": "/machine/adc", "property": f"adcsamples[{channel}]"})
def await_samples(state, prop, count, timeout):
"""Block until the guest reads the channel again.
Holding a button for a fixed wall-clock time is unreliable: the firmware only polls the
resistor ladder between e-ink refreshes, which can block for seconds.
"""
start = adc_samples(state, prop)
deadline = time.monotonic() + timeout
while adc_samples(state, prop) - start < count:
if time.monotonic() >= deadline:
raise RuntimeError(f"timed out after {timeout:g}s waiting for the guest to sample {prop}")
time.sleep(0.01)
def set_button(state, name, down):
path, prop, pressed, released = BUTTONS[name]
qmp_command(state, "qom-set", {"path": path, "property": prop, "value": pressed if down else released})
def press(state, name, samples=4, timeout=90, hold_ms=500):
path, prop, _, _ = BUTTONS[name]
adc = not path.endswith("gpio")
set_button(state, name, down=True)
try:
if adc:
await_samples(state, prop, samples, timeout)
else:
time.sleep(hold_ms / 1000)
finally:
set_button(state, name, down=False)
if adc:
await_samples(state, prop, samples, timeout)
def screendump(state, output, settle_ms=0):
# The e-ink panel needs a full refresh cycle before a capture shows the latest frame.
if settle_ms:
time.sleep(settle_ms / 1000)
output = Path(output).expanduser().resolve()
output.parent.mkdir(parents=True, exist_ok=True)
qmp_command(state, "screendump", {"filename": str(output), "format": "png"}, timeout=30)
return output