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.
230 lines
9.9 KiB
Python
230 lines
9.9 KiB
Python
"""Browser front end: boot firmware, drive the buttons, watch the screen and serial log."""
|
|
|
|
import http.server
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import urllib.parse
|
|
from pathlib import Path
|
|
|
|
from .. import emu
|
|
from . import sdcard
|
|
|
|
PAGE = Path(__file__).resolve().parent / "index.html"
|
|
MAX_UPLOAD = 512 * 1024 * 1024
|
|
LONG_POLL_SECONDS = 25
|
|
UPLOADS = {"firmware": "firmware.bin", "sdcard": "sdcard.img"}
|
|
|
|
|
|
def settings_path(state):
|
|
return state / "web.json"
|
|
|
|
|
|
def settings(state):
|
|
try:
|
|
return json.loads(settings_path(state).read_text())
|
|
except (FileNotFoundError, ValueError):
|
|
return {}
|
|
|
|
|
|
def save_settings(state, values):
|
|
settings_path(state).write_text(json.dumps(settings(state) | values))
|
|
|
|
|
|
def blank_sdcard(path, size_mb):
|
|
"""Create a FAT32 card so the UI works without the user supplying an image."""
|
|
mkfs = shutil.which("mkfs.fat")
|
|
if not mkfs:
|
|
raise RuntimeError("mkfs.fat not found; upload an SD card image instead")
|
|
with path.open("wb") as image:
|
|
image.truncate(size_mb * 1024 * 1024)
|
|
subprocess.run([mkfs, "-F", "32", str(path)], check=True, capture_output=True)
|
|
|
|
|
|
def serve(state, host, port, log_bytes):
|
|
state.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
capture = state / "web-screen.png"
|
|
|
|
class Handler(http.server.BaseHTTPRequestHandler):
|
|
protocol_version = "HTTP/1.1"
|
|
|
|
def reply(self, body, content_type="text/plain; charset=utf-8", status=200, headers=()):
|
|
if isinstance(body, str):
|
|
body = body.encode()
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", content_type)
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
for name, value in headers:
|
|
self.send_header(name, value)
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def reply_json(self, value, status=200):
|
|
self.reply(json.dumps(value), "application/json", status)
|
|
|
|
def query(self):
|
|
return urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
|
|
|
def body(self):
|
|
length = int(self.headers.get("Content-Length", 0))
|
|
if length > MAX_UPLOAD:
|
|
raise RuntimeError(f"upload larger than {MAX_UPLOAD // 1024 // 1024} MiB")
|
|
return self.rfile.read(length)
|
|
|
|
def status_payload(self):
|
|
stored = settings(state)
|
|
return {
|
|
"running": emu.is_running(state),
|
|
"variant": stored.get("variant", "x3"),
|
|
"firmware": stored.get("firmware"),
|
|
"sdcard": stored.get("sdcard"),
|
|
"buttons": list(emu.BUTTONS),
|
|
"variants": list(emu.VARIANTS),
|
|
}
|
|
|
|
def do_GET(self):
|
|
route = urllib.parse.urlparse(self.path).path
|
|
query = self.query()
|
|
try:
|
|
if route == "/":
|
|
return self.reply(PAGE.read_bytes(), "text/html; charset=utf-8")
|
|
if route == "/status":
|
|
return self.reply_json(self.status_payload())
|
|
if route == "/screen.png":
|
|
emu.require_running(state)
|
|
# Long Poll - The panel only redraws on a real refresh, so block until the
|
|
# guest bumps the frame counter instead of re-capturing on a timer.
|
|
since = query.get("since", [None])[0]
|
|
generation = emu.frame_generation(state)
|
|
if since is not None and str(generation) == since:
|
|
generation = emu.await_frame(state, generation, LONG_POLL_SECONDS)
|
|
if generation is None:
|
|
return self.reply(b"", status=304)
|
|
emu.screendump(state, capture)
|
|
return self.reply(
|
|
capture.read_bytes(),
|
|
"image/png",
|
|
headers=[("X-Frame-Generation", str(generation))],
|
|
)
|
|
if route == "/files":
|
|
path = query.get("path", ["/"])[0]
|
|
return self.reply_json(sdcard.listing(state / UPLOADS["sdcard"], path))
|
|
if route == "/files/read":
|
|
path = query.get("path", [""])[0]
|
|
data = sdcard.read(state / UPLOADS["sdcard"], path)
|
|
try:
|
|
text = data.decode()
|
|
except UnicodeDecodeError:
|
|
return self.reply_json({"binary": True, "size": len(data)})
|
|
return self.reply_json({"binary": False, "text": text})
|
|
if route == "/memory":
|
|
emu.require_running(state)
|
|
return self.reply_json(emu.memory_usage(state))
|
|
if route == "/memory/dump":
|
|
emu.require_running(state)
|
|
region = query.get("region", ["dram"])[0]
|
|
if region not in emu.MEMORY_REGIONS:
|
|
return self.send_error(404)
|
|
dump = emu.dump_memory(state, region, state / "web-memory.bin")
|
|
return self.reply(
|
|
dump.read_bytes(),
|
|
"application/octet-stream",
|
|
headers=[("Content-Disposition", f'attachment; filename="{region}.bin"')],
|
|
)
|
|
if route == "/log":
|
|
# Tail only; a long-lived emulator log outgrows what the page can hold.
|
|
return self.reply((state / "serial.log").read_bytes()[-log_bytes:])
|
|
except (RuntimeError, OSError) as error:
|
|
return self.reply(str(error), status=500)
|
|
self.send_error(404)
|
|
|
|
def do_POST(self):
|
|
route = urllib.parse.urlparse(self.path).path
|
|
query = self.query()
|
|
try:
|
|
if route == "/upload":
|
|
name = query.get("name", [""])[0]
|
|
if name not in UPLOADS:
|
|
return self.send_error(404)
|
|
data = self.body()
|
|
if not data:
|
|
raise RuntimeError(f"empty {name} upload")
|
|
(state / UPLOADS[name]).write_bytes(data)
|
|
filename = query.get("filename", [name])[0]
|
|
save_settings(state, {name: filename})
|
|
return self.reply_json(self.status_payload())
|
|
|
|
if route == "/sdcard/blank":
|
|
size_mb = max(8, min(int(query.get("size-mb", ["64"])[0]), 4096))
|
|
blank_sdcard(state / UPLOADS["sdcard"], size_mb)
|
|
save_settings(state, {"sdcard": f"blank {size_mb} MiB"})
|
|
return self.reply_json(self.status_payload())
|
|
|
|
if route == "/boot":
|
|
variant = query.get("variant", ["x3"])[0]
|
|
hold = int(query.get("power-hold-ms", ["2000"])[0])
|
|
firmware = state / UPLOADS["firmware"]
|
|
card = state / UPLOADS["sdcard"]
|
|
if not firmware.is_file():
|
|
raise RuntimeError("upload a firmware image first")
|
|
if not card.is_file():
|
|
blank_sdcard(card, 64)
|
|
save_settings(state, {"sdcard": "blank 64 MiB"})
|
|
if emu.is_running(state):
|
|
emu.shutdown(state)
|
|
emu.launch(state, firmware, card, variant=variant, power_hold_ms=hold)
|
|
save_settings(state, {"variant": variant})
|
|
return self.reply_json(self.status_payload())
|
|
|
|
if route == "/stop":
|
|
if emu.is_running(state):
|
|
emu.shutdown(state)
|
|
return self.reply_json(self.status_payload())
|
|
|
|
if route.startswith("/files/"):
|
|
# Editing detaches the card from a live guest, mirroring "apply and reboot".
|
|
if emu.is_running(state):
|
|
emu.shutdown(state)
|
|
image = state / UPLOADS["sdcard"]
|
|
path = query.get("path", [""])[0]
|
|
if route == "/files/write":
|
|
sdcard.write(image, path, self.body())
|
|
elif route == "/files/mkdir":
|
|
sdcard.mkdir(image, path)
|
|
elif route == "/files/delete":
|
|
sdcard.delete(image, path, query.get("directory", [""])[0] == "true")
|
|
elif route == "/files/clear":
|
|
size_mb = max(8, image.stat().st_size // 1024 // 1024) if image.is_file() else 64
|
|
blank_sdcard(image, size_mb)
|
|
save_settings(state, {"sdcard": f"blank {size_mb} MiB"})
|
|
else:
|
|
return self.send_error(404)
|
|
return self.reply_json(self.status_payload())
|
|
|
|
if route == "/button":
|
|
name = query.get("name", [""])[0]
|
|
if name not in emu.BUTTONS:
|
|
return self.send_error(404)
|
|
emu.require_running(state)
|
|
edge = query.get("edge", [""])[0]
|
|
if edge in ("down", "up"):
|
|
emu.set_button(state, name, down=edge == "down")
|
|
else:
|
|
emu.press(state, name, hold_ms=int(query.get("hold-ms", ["500"])[0]))
|
|
return self.reply("ok")
|
|
except (RuntimeError, OSError, ValueError, subprocess.CalledProcessError) as error:
|
|
return self.reply(str(error), status=500)
|
|
self.send_error(404)
|
|
|
|
def log_message(self, *_):
|
|
pass
|
|
|
|
server = http.server.ThreadingHTTPServer((host, port), Handler)
|
|
print(f"http://{host}:{server.server_address[1]}")
|
|
print(f"state: {state}")
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
pass
|