feat(xteink): manage the emulator SD card from the web UI

Browse, edit, upload, and delete files on the FAT32 image with mtools,
mirroring the wasm emulator's Files tab. Mutations stop the emulator
first because QEMU caches the card, so host writes to a live image would
be lost.
This commit is contained in:
2026-07-26 11:36:37 -04:00
parent 0feece9036
commit 1174ef0e5d
5 changed files with 319 additions and 12 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ nix run .#xteink-emu -- stop
nix run .#xteink-emu -- web --host 127.0.0.1 --port 8080
```
The code lives in `scripts/xteink/` (`emu.py` drives QEMU, `cli.py` is the command line, `web/` is the browser UI); `scripts/xteink-emu.py` is the entry point. `web` serves a page that uploads firmware and an SD image, boots them, drives the buttons, and streams the panel and serial log. The screen redraws off the panel's `frame-generation` counter, so it updates on each real e-ink refresh rather than on a timer.
The code lives in `scripts/xteink/` (`emu.py` drives QEMU, `cli.py` is the command line, `web/` is the browser UI); `scripts/xteink-emu.py` is the entry point. `web` serves a page that uploads firmware and an SD image, boots them, drives the buttons, streams the panel and serial log, and browses or edits the SD card through mtools. Card edits stop the emulator first, since QEMU caches the image; use *Apply & Reboot* to bring it back. The screen redraws off the panel's `frame-generation` counter, so it updates on each real e-ink refresh rather than on a timer.
State defaults to `/tmp/xteink-emu-$UID`; pass `--state-dir` to any subcommand for parallel instances. `interactive` opens the display and maps Left/Right, 14, and P to the matching device buttons. Hold P for a long power press and close the window to stop. `wait` advances a persistent log cursor after each match. The emulator exposes the open Wi-Fi network `qemu`; guest traffic uses QEMU's user-mode NAT.
+1 -1
View File
@@ -91,7 +91,7 @@
};
xteink-emu = pkgs.writeShellApplication {
name = "xteink-emu";
runtimeInputs = [ pkgs.uv ];
runtimeInputs = [ pkgs.uv pkgs.mtools pkgs.dosfstools ];
text = ''
export XTEINK_QEMU=${qemu-native-xteink}/bin/qemu-system-riscv32
export XTEINK_BIOS=${qemu-native-xteink}/share/qemu
+36 -4
View File
@@ -8,6 +8,7 @@ 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
@@ -106,6 +107,17 @@ def serve(state, host, port, log_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 == "/log":
# Tail only; a long-lived emulator log outgrows what the page can hold.
return self.reply((state / "serial.log").read_bytes()[-log_bytes:])
@@ -139,15 +151,15 @@ def serve(state, host, port, log_bytes):
variant = query.get("variant", ["x3"])[0]
hold = int(query.get("power-hold-ms", ["2000"])[0])
firmware = state / UPLOADS["firmware"]
sdcard = state / UPLOADS["sdcard"]
card = state / UPLOADS["sdcard"]
if not firmware.is_file():
raise RuntimeError("upload a firmware image first")
if not sdcard.is_file():
blank_sdcard(sdcard, 64)
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, sdcard, variant=variant, power_hold_ms=hold)
emu.launch(state, firmware, card, variant=variant, power_hold_ms=hold)
save_settings(state, {"variant": variant})
return self.reply_json(self.status_payload())
@@ -156,6 +168,26 @@ def serve(state, host, port, log_bytes):
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:
+202 -6
View File
@@ -38,7 +38,38 @@ img { display: block; width: 100%; height: auto; aspect-ratio: 528 / 792; backgr
.btn-right { grid-column: 3; }
.btn-bottom { grid-column: 2; grid-row: 3; display: grid; grid-template-columns: repeat(4, 1fr); gap: .6rem; }
.btn-bottom button { height: 1.8rem; }
.inspector { min-width: 0; overflow: hidden; padding: 1rem; border: 1px solid #c8c2b6; border-radius: .75rem; background: #f7f4ed; }
.inspector { min-width: 0; overflow: hidden; border: 1px solid #c8c2b6; border-radius: .75rem; background: #f7f4ed; }
.tabs { display: grid; grid-template-columns: repeat(2, 1fr); border-bottom: 1px solid #c8c2b6; }
.tabs button { border: 0; border-right: 1px solid #c8c2b6; border-radius: 0; color: #56534d; background: transparent; }
.tabs button:last-child { border-right: 0; }
.tabs button[aria-selected="true"] { color: #fff; background: #343532; }
.tab-panel { padding: 1rem; }
.file-toolbar { display: flex; gap: .4rem; align-items: center; flex-wrap: wrap; }
.file-actions { display: flex; justify-content: flex-end; gap: .4rem; margin-top: .6rem; }
.file-button { display: inline-flex; min-height: 1.9rem; align-items: center; padding: .3rem .6rem; border: 1px solid #55534e; border-radius: .4rem; color: #fff; background: #343532; font-size: .8rem; font-weight: 650; cursor: pointer; }
.file-button:hover:not(:disabled) { background: #11120f; }
.file-clear { border-color: #b94a3b; color: #a52d20; background: transparent; }
.file-clear:hover:not(:disabled) { color: #fff; background: #a52d20; }
.file-reboot { margin-left: auto; border-color: #b66020; background: #d2752b; }
.file-reboot:hover:not(:disabled) { background: #b66020; }
.file-location { display: flex; align-items: center; gap: .5rem; margin-bottom: .5rem; color: #625e55; }
.file-location button { min-height: 1.9rem; padding: .2rem .55rem; font-size: .8rem; }
.file-location code { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-list { width: 100%; margin-bottom: .75rem; border: 1px solid #c8c2b6; border-collapse: collapse; border-radius: .6rem; background: #fff; font-size: .82rem; }
.file-list td { padding: .28rem .5rem; border-bottom: 1px solid #eee9df; }
.file-list tr:last-child td { border-bottom: 0; }
.file-list tbody tr:hover { background: #f1ede4; }
.file-list tr.entry { cursor: pointer; }
.file-list td:first-child { width: 1.4rem; text-align: center; }
.file-list tr.directory td:nth-child(2) { font-weight: 700; }
.file-list td:nth-child(3) { text-align: right; color: #8a8479; white-space: nowrap; }
.file-list td:last-child { width: 2rem; padding: 0; text-align: center; }
.file-empty { padding: 1.25rem !important; color: #777269; text-align: center; }
.file-del { min-height: 0; padding: .1rem .35rem; border: 0; border-radius: .3rem; color: #a52d20; background: transparent; font-size: .85rem; line-height: 1; }
.file-del:hover { color: #fff; background: #a52d20; }
.file-preview { width: 100%; min-height: 14rem; max-height: 28rem; overflow: auto; padding: .85rem 1rem; border: 1px solid #c8c2b6; border-radius: .6rem; color: #343532; background: #fff; font-family: ui-monospace, monospace; line-height: 1.45; resize: vertical; }
.file-preview:focus { outline: 3px solid #d2752b; outline-offset: 1px; }
.file-preview:read-only { color: #777269; background: #f1ede4; }
.log-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .5rem; font-size: .85rem; color: #625e55; }
.log-header label { display: flex; gap: .3rem; align-items: center; font-weight: 400; }
.log { margin: 0; height: 38rem; overflow: auto; padding: .75rem 1rem; border-radius: .6rem; background: #1c1d1a; color: #d6d0c2; font: .78rem/1.4 ui-monospace, monospace; white-space: pre-wrap; word-break: break-word; }
@@ -85,12 +116,45 @@ img { display: block; width: 100%; height: auto; aspect-ratio: 528 / 792; backgr
</div>
</section>
<section class="inspector" aria-label="Serial log">
<div class="log-header">
<span>Serial log</span>
<label><input id="follow" type="checkbox" checked> follow</label>
<section class="inspector" aria-label="Emulator inspector">
<div class="tabs" role="tablist">
<button type="button" role="tab" aria-selected="true" data-tab="logs">Logs</button>
<button type="button" role="tab" aria-selected="false" data-tab="files">Files</button>
</div>
<div id="panel-logs" class="tab-panel">
<div class="log-header">
<span>Serial log</span>
<label><input id="follow" type="checkbox" checked> follow</label>
</div>
<pre id="log" class="log" role="log" aria-live="polite"></pre>
</div>
<div id="panel-files" class="tab-panel" hidden>
<div id="files-browse">
<div class="file-location">
<button id="file-back" type="button" aria-label="Go up one folder" disabled></button>
<code id="file-path">/</code>
</div>
<table class="file-list"><tbody id="file-list"></tbody></table>
<div class="file-toolbar">
<label class="file-button">Upload<input id="file-add" type="file" multiple hidden></label>
<button id="file-new" type="button" class="file-button">New File</button>
<button id="file-new-folder" type="button" class="file-button">New Folder</button>
<button id="file-reset" type="button" class="file-button file-clear">Clear</button>
<button id="file-reboot" type="button" class="file-button file-reboot">Apply &amp; Reboot</button>
</div>
</div>
<div id="files-edit" hidden>
<div class="file-location">
<button id="file-close" type="button" aria-label="Back to file list"></button>
<code id="file-edit-name"></code>
<button id="file-delete" type="button" class="file-button file-clear">Delete</button>
</div>
<textarea id="file-editor" class="file-preview" spellcheck="false"></textarea>
<div class="file-actions"><button id="file-save" type="button" class="file-button">Save</button></div>
</div>
</div>
<pre id="log" class="log" role="log" aria-live="polite"></pre>
</section>
</div>
</main>
@@ -219,6 +283,138 @@ async function refreshLog() {
}
}
for (const tab of document.querySelectorAll("[data-tab]")) {
tab.onclick = () => {
for (const other of document.querySelectorAll("[data-tab]")) {
other.setAttribute("aria-selected", String(other === tab));
$("panel-" + other.dataset.tab).hidden = other !== tab;
}
if (tab.dataset.tab === "files") refreshFiles();
};
}
let directory = "/";
let editing = null;
function join(base, name) {
return base === "/" ? "/" + name : base + "/" + name;
}
function showBrowser(show) {
$("files-browse").hidden = !show;
$("files-edit").hidden = show;
}
async function refreshFiles() {
showBrowser(true);
$("file-path").textContent = directory;
$("file-back").disabled = directory === "/";
const list = $("file-list");
list.replaceChildren();
let entries;
try {
entries = JSON.parse(await call("files?path=" + encodeURIComponent(directory)));
} catch (error) {
report(String(error), true);
return;
}
if (!entries.length) {
list.innerHTML = '<tr><td colspan="4" class="file-empty">Empty folder</td></tr>';
return;
}
for (const entry of entries) {
const row = list.insertRow();
row.className = entry.directory ? "entry directory" : "entry";
row.insertCell().textContent = entry.directory ? "\u{1F4C1}" : "\u{1F4C4}";
row.insertCell().textContent = entry.name;
row.insertCell().textContent = entry.directory ? "" : `${entry.size} B`;
const remove = document.createElement("button");
remove.className = "file-del";
remove.textContent = "\u2715";
remove.onclick = async (event) => {
event.stopPropagation();
await mutate(`files/delete?path=${encodeURIComponent(join(directory, entry.name))}&directory=${entry.directory}`);
refreshFiles();
};
row.insertCell().append(remove);
row.onclick = () => {
if (entry.directory) {
directory = join(directory, entry.name);
refreshFiles();
} else {
openFile(join(directory, entry.name));
}
};
}
}
async function mutate(path, body) {
try {
applyStatus(JSON.parse(await call(path, { method: "POST", body })));
return true;
} catch (error) {
report(String(error), true);
return false;
}
}
async function openFile(path) {
try {
const file = JSON.parse(await call("files/read?path=" + encodeURIComponent(path)));
editing = path;
$("file-edit-name").textContent = path;
$("file-editor").value = file.binary ? `(binary file, ${file.size} bytes)` : file.text;
$("file-editor").readOnly = file.binary;
$("file-save").hidden = file.binary;
showBrowser(false);
} catch (error) {
report(String(error), true);
}
}
$("file-back").onclick = () => {
directory = directory.replace(/\/[^/]+$/, "") || "/";
refreshFiles();
};
$("file-close").onclick = refreshFiles;
$("file-save").onclick = async () => {
await mutate("files/write?path=" + encodeURIComponent(editing), $("file-editor").value);
refreshFiles();
};
$("file-delete").onclick = async () => {
await mutate(`files/delete?path=${encodeURIComponent(editing)}&directory=false`);
refreshFiles();
};
$("file-add").onchange = async (event) => {
for (const file of event.target.files) {
if (!await mutate("files/write?path=" + encodeURIComponent(join(directory, file.name)), await file.arrayBuffer())) break;
}
event.target.value = "";
refreshFiles();
};
$("file-new").onclick = async () => {
const name = prompt("File name");
if (!name) return;
await mutate("files/write?path=" + encodeURIComponent(join(directory, name)), "");
refreshFiles();
};
$("file-new-folder").onclick = async () => {
const name = prompt("Folder name");
if (!name) return;
await mutate("files/mkdir?path=" + encodeURIComponent(join(directory, name)));
refreshFiles();
};
$("file-reset").onclick = async () => {
if (!confirm("Erase the SD card image?")) return;
directory = "/";
await mutate("files/clear");
refreshFiles();
};
$("file-reboot").onclick = async () => {
report("Booting...", false);
await mutate(`boot?variant=${$("variant").value}`);
};
setInterval(refreshLog, 1000);
setInterval(refreshStatus, 5000);
refreshStatus();
+79
View File
@@ -0,0 +1,79 @@
"""FAT32 card contents, read and written through mtools.
Mutations require a stopped emulator: QEMU caches the image, so host writes to a live card
would be lost or corrupt the filesystem.
"""
import re
import shutil
import subprocess
LISTING = re.compile(r"^(?P<short>.{12})\s+(?P<size><DIR>|[\d ]+)\s+\S+\s+\S+\s?(?P<long>.*)$")
def mtool(name):
path = shutil.which(name)
if not path:
raise RuntimeError(f"{name} not found; install mtools to manage the SD card")
return path
def run(name, image, *arguments, stdin=None):
result = subprocess.run(
[mtool(name), "-i", str(image), *arguments],
input=stdin,
capture_output=True,
)
if result.returncode:
message = (result.stderr or result.stdout).decode(errors="replace").strip()
raise RuntimeError(message or f"{name} failed")
return result.stdout
def clean(path):
"""Normalise a card path, rejecting anything that could escape the image."""
parts = [part for part in str(path).replace("\\", "/").split("/") if part not in ("", ".")]
if any(part == ".." or ":" in part for part in parts):
raise RuntimeError(f"invalid path: {path}")
return "/" + "/".join(parts)
def drive(path):
return f"::{clean(path)}"
def listing(image, path):
entries = []
for line in run("mdir", image, drive(path)).decode(errors="replace").splitlines():
match = LISTING.match(line)
if not match:
continue
short, size, long_name = match.group("short"), match.group("size"), match.group("long")
name = long_name.strip() or short[:8].strip() + ("." + short[8:].strip() if short[8:].strip() else "")
# The free-space summary line also matches the entry shape, but carries no name.
if not name or name in (".", ".."):
continue
directory = size == "<DIR>"
entries.append({
"name": name,
"directory": directory,
"size": None if directory else int(size.replace(" ", "")),
})
entries.sort(key=lambda entry: (not entry["directory"], entry["name"].lower()))
return entries
def read(image, path):
return run("mtype", image, drive(path))
def write(image, path, data):
run("mcopy", image, "-o", "-", drive(path), stdin=data)
def mkdir(image, path):
run("mmd", image, drive(path))
def delete(image, path, directory):
run("mdeltree" if directory else "mdel", image, drive(path))