diff --git a/README.xteink.md b/README.xteink.md index 4c1568938d..cecf6ec3ea 100644 --- a/README.xteink.md +++ b/README.xteink.md @@ -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, 1–4, 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. diff --git a/flake.nix b/flake.nix index c5d463902c..ffd6488630 100644 --- a/flake.nix +++ b/flake.nix @@ -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 diff --git a/scripts/xteink/web/__init__.py b/scripts/xteink/web/__init__.py index 9fb5093520..83aef1a8d4 100644 --- a/scripts/xteink/web/__init__.py +++ b/scripts/xteink/web/__init__.py @@ -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: diff --git a/scripts/xteink/web/index.html b/scripts/xteink/web/index.html index dbdf71b53c..5c2e2a6694 100644 --- a/scripts/xteink/web/index.html +++ b/scripts/xteink/web/index.html @@ -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 -
-
- Serial log - +
+
+ + +
+ +
+
+ Serial log + +
+

+      
+ + -

     
@@ -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 = 'Empty folder'; + 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(); diff --git a/scripts/xteink/web/sdcard.py b/scripts/xteink/web/sdcard.py new file mode 100644 index 0000000000..76d207f9de --- /dev/null +++ b/scripts/xteink/web/sdcard.py @@ -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.{12})\s+(?P|[\d ]+)\s+\S+\s+\S+\s?(?P.*)$") + + +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 == "" + 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))