"""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") # ESP32-C3 RAM windows. dram and iram alias the same internal SRAM, so their numbers overlap. MEMORY_REGIONS = { "dram": (0x3FC80000, 0x60000), "iram": (0x4037C000, 0x60000 + 16 * 1024), "rtcram": (0x50000000, 0x2000), } 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 dump_memory(state, region, output): base, size = MEMORY_REGIONS[region] output = Path(output).expanduser().resolve() output.parent.mkdir(parents=True, exist_ok=True) qmp_command(state, "pmemsave", {"val": base, "size": size, "filename": str(output)}, timeout=30) return output def memory_usage(state): """Bytes the guest has written per RAM region. QEMU zeroes RAM at reset, so a non-zero byte is one the firmware touched. That makes this a high-water mark rather than live heap usage: freed blocks keep their old contents. """ scratch = state / "memory.bin" rows = [] try: for region, (base, size) in MEMORY_REGIONS.items(): data = dump_memory(state, region, scratch).read_bytes() rows.append({"region": region, "base": base, "size": size, "touched": len(data) - data.count(0)}) finally: scratch.unlink(missing_ok=True) return rows 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