6e0e65218a
Adds a 'memory' subcommand and matching web tab that show how much of each ESP32-C3 RAM window the guest has written, plus raw region dumps via QMP pmemsave.
344 lines
12 KiB
Python
Executable File
344 lines
12 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")
|
|
# 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
|