diff --git a/README.xteink.md b/README.xteink.md index 8b2dab7aa7..4c1568938d 100644 --- a/README.xteink.md +++ b/README.xteink.md @@ -42,8 +42,11 @@ nix run .#xteink-emu -- wait --timeout 30 --match 'ready' nix run .#xteink-emu -- button bottom-2 --hold-ms 100 nix run .#xteink-emu -- screenshot --output screen.png 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. + 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. ### WebAssembly diff --git a/flake.nix b/flake.nix index 5620939ed8..c5d463902c 100644 --- a/flake.nix +++ b/flake.nix @@ -97,7 +97,7 @@ export XTEINK_BIOS=${qemu-native-xteink}/share/qemu export XTEINK_BOOTLOADER=${./scripts/assets/esp32c3-bootloader.bin} export XTEINK_PARTITIONS=${./scripts/assets/xteink-partitions.bin} - exec uv run --script ${./scripts/xteink-emu.py} "$@" + exec uv run --script ${./scripts}/xteink-emu.py "$@" ''; }; in diff --git a/scripts/xteink-emu.py b/scripts/xteink-emu.py new file mode 100755 index 0000000000..f40d8baf71 --- /dev/null +++ b/scripts/xteink-emu.py @@ -0,0 +1,15 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.11" +# dependencies = [] +# /// + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from xteink.cli import run_cli + +if __name__ == "__main__": + run_cli() diff --git a/scripts/xteink/__init__.py b/scripts/xteink/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/scripts/xteink/cli.py b/scripts/xteink/cli.py new file mode 100644 index 0000000000..27f84fa0ff --- /dev/null +++ b/scripts/xteink/cli.py @@ -0,0 +1,166 @@ +"""Command line front end for the xteink emulator.""" + +import argparse +import sys +from pathlib import Path + +from . import emu +from .web import serve + + +def state_dir(args): + return Path(args.state_dir).expanduser().resolve() + + +def run(args, interactive): + state = state_dir(args) + process = emu.launch( + state, + args.firmware, + args.sdcard, + variant=args.variant, + power_hold_ms=args.power_hold_ms, + interactive=interactive, + ) + print(f"started PID {process.pid}") + print(f"state: {state}") + if not interactive: + print(f"serial log: {state / 'serial.log'}") + return + + print("keys: Left/Right, 1-4 bottom buttons, P power; close the window to stop") + try: + returncode = process.wait() + except KeyboardInterrupt: + if process.poll() is None: + try: + emu.qmp_command(state, "quit") + except OSError: + process.terminate() + returncode = process.wait() + finally: + (state / "pid").unlink(missing_ok=True) + (state / "qmp.sock").unlink(missing_ok=True) + if returncode: + raise RuntimeError(f"QEMU exited with status {returncode}") + + +def start(args): + run(args, interactive=False) + + +def interactive(args): + run(args, interactive=True) + + +def stop(args): + print(f"stopped PID {emu.shutdown(state_dir(args))}") + + +def wait(args): + line = emu.wait_for_log(state_dir(args), args.match, args.timeout) + print(line, end="" if line.endswith("\n") else "\n") + + +def button(args): + state = state_dir(args) + emu.require_running(state) + if (args.down or args.up) and len(args.name) != 1: + raise RuntimeError("--down/--up accept exactly one button") + + for name in args.name: + if args.down or args.up: + emu.set_button(state, name, down=args.down) + return + emu.press(state, name, args.samples, args.timeout, args.hold_ms) + + +def screenshot(args): + state = state_dir(args) + emu.require_running(state) + print(emu.screendump(state, args.output, args.settle_ms)) + + +def web(args): + serve(state_dir(args), args.host, args.port, args.log_bytes) + + +def parser(): + root = argparse.ArgumentParser(prog="xteink-emu") + commands = root.add_subparsers(dest="command", required=True) + + def add_state(command): + command.add_argument("--state-dir", default=emu.default_state_dir()) + + def add_run_arguments(command): + add_state(command) + command.add_argument("--firmware", required=True) + command.add_argument("--sdcard", required=True) + command.add_argument("--variant", choices=emu.VARIANTS, default="x3") + command.add_argument("--power-hold-ms", type=int, default=2000) + + command = commands.add_parser("start") + add_run_arguments(command) + command.set_defaults(handler=start) + + command = commands.add_parser("interactive") + add_run_arguments(command) + command.set_defaults(handler=interactive) + + command = commands.add_parser("stop") + add_state(command) + command.set_defaults(handler=stop) + + command = commands.add_parser("wait") + add_state(command) + command.add_argument("--timeout", type=float, required=True, help="seconds") + command.add_argument("--match", required=True, help="regular expression") + command.set_defaults(handler=wait) + + command = commands.add_parser("button") + add_state(command) + command.add_argument("name", nargs="+", choices=emu.BUTTONS, help="one or more buttons pressed in order") + command.add_argument("--hold-ms", type=int, default=500, help="hold time for the power button") + command.add_argument("--samples", type=int, default=4, help="guest ADC reads required per press and release") + command.add_argument("--timeout", type=float, default=90, help="seconds to wait for guest ADC sampling") + button_mode = command.add_mutually_exclusive_group() + button_mode.add_argument("--down", action="store_true", help="press and hold") + button_mode.add_argument("--up", action="store_true", help="release") + command.set_defaults(handler=button) + + command = commands.add_parser("screenshot") + add_state(command) + command.add_argument("--output", required=True) + command.add_argument("--settle-ms", type=int, default=1500, help="wait for the panel to refresh before capturing") + command.set_defaults(handler=screenshot) + + command = commands.add_parser("web") + add_state(command) + command.add_argument("--host", default="127.0.0.1") + command.add_argument("--port", type=int, default=8080) + command.add_argument("--log-bytes", type=int, default=200_000, help="tail size of the serial log") + command.set_defaults(handler=web) + return root + + +def main(): + args = parser().parse_args() + if getattr(args, "timeout", 0) < 0: + raise RuntimeError("--timeout must not be negative") + if getattr(args, "hold_ms", 0) < 0: + raise RuntimeError("--hold-ms must not be negative") + if getattr(args, "samples", 1) < 1: + raise RuntimeError("--samples must be positive") + if getattr(args, "settle_ms", 0) < 0: + raise RuntimeError("--settle-ms must not be negative") + if getattr(args, "power_hold_ms", 0) < 0: + raise RuntimeError("--power-hold-ms must not be negative") + args.handler(args) + + +def run_cli(): + try: + main() + except (RuntimeError, OSError) as error: + print(f"xteink-emu: {error}", file=sys.stderr) + sys.exit(1) diff --git a/scripts/xteink/emu.py b/scripts/xteink/emu.py index 668a19c2f9..0ce07bb7d9 100755 --- a/scripts/xteink/emu.py +++ b/scripts/xteink/emu.py @@ -1,17 +1,11 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.11" -# dependencies = [] -# /// +"""Core emulator control shared by the CLI and the web UI.""" -import argparse import json import os import re import signal import socket import subprocess -import sys import tempfile import time from pathlib import Path @@ -20,7 +14,7 @@ FLASH_SIZE = 16 * 1024 * 1024 APP_OFFSET = 0x10000 PARTITIONS_OFFSET = 0x8000 RELEASED_ADC = 4095 -PROJECT_ROOT = Path(__file__).resolve().parent.parent +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", @@ -36,16 +30,13 @@ BUTTONS = { "bottom-4": ("/machine/adc", "adci[1]", 5, RELEASED_ADC), "power": ("/machine/gpio", "input-level[3]", False, True), } +VARIANTS = ("x3", "x4") def default_state_dir(): return Path(tempfile.gettempdir()) / f"xteink-emu-{os.getuid()}" -def state_dir(args): - return Path(args.state_dir).expanduser().resolve() - - def require_env(name): path = Path(os.environ.get(name, DEFAULT_PATHS[name])) if not path.exists(): @@ -72,6 +63,17 @@ def process_alive(pid): 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) @@ -97,13 +99,6 @@ def qmp_command(state, execute, arguments=None, timeout=5): return send(command) -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 build_flash(firmware, output): app = firmware.read_bytes() if len(app) == FLASH_SIZE: @@ -123,14 +118,25 @@ def build_flash(firmware, output): output.write_bytes(flash) -def run(args, interactive): - state = state_dir(args) +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(args.firmware).expanduser().resolve() - sdcard = Path(args.sdcard).expanduser().resolve() + 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(): @@ -150,7 +156,7 @@ def run(args, interactive): command = [ str(require_env("XTEINK_QEMU")), - "-machine", "xteink,variant=x3", + "-machine", f"xteink,variant={variant}", "-accel", "tcg", "-L", str(require_env("XTEINK_BIOS")), "-display", "gtk" if interactive else "none", @@ -183,53 +189,16 @@ def run(args, interactive): raise RuntimeError(f"QEMU exited during startup\n{message}") try: qmp_command(state, "query-status") - if args.power_hold_ms: - path, prop, pressed, released = BUTTONS["power"] - qmp_command(state, "qom-set", {"path": path, "property": prop, "value": pressed}) - try: - time.sleep(args.power_hold_ms / 1000) - finally: - qmp_command(state, "qom-set", {"path": path, "property": prop, "value": released}) - break + if power_hold_ms: + hold_power(state, power_hold_ms) + return process except (FileNotFoundError, ConnectionRefusedError, socket.timeout): time.sleep(0.05) - else: - process.terminate() - raise RuntimeError("QEMU did not open its QMP socket") - - print(f"started PID {process.pid}") - print(f"state: {state}") - if not interactive: - print(f"serial log: {state / 'serial.log'}") - return - - print("keys: Left/Right, 1-4 bottom buttons, P power; close the window to stop") - try: - returncode = process.wait() - except KeyboardInterrupt: - if process.poll() is None: - try: - qmp_command(state, "quit") - except OSError: - process.terminate() - returncode = process.wait() - finally: - (state / "pid").unlink(missing_ok=True) - (state / "qmp.sock").unlink(missing_ok=True) - if returncode: - raise RuntimeError(f"QEMU exited with status {returncode}") + process.terminate() + raise RuntimeError("QEMU did not open its QMP socket") -def start(args): - run(args, interactive=False) - - -def interactive(args): - run(args, interactive=True) - - -def stop(args): - state = state_dir(args) +def shutdown(state): pid = require_running(state) try: qmp_command(state, "quit") @@ -243,16 +212,15 @@ def stop(args): os.kill(pid, signal.SIGKILL) (state / "pid").unlink(missing_ok=True) (state / "qmp.sock").unlink(missing_ok=True) - print(f"stopped PID {pid}") + return pid -def wait_for_log(args): - state = state_dir(args) +def wait_for_log(state, match, timeout): + """Scan the serial log from the persistent cursor and return the first matching line.""" require_running(state) - log_path = state / "serial.log" offset_path = state / "wait.offset" try: - pattern = re.compile(args.match) + pattern = re.compile(match) except re.error as error: raise RuntimeError(f"invalid regex: {error}") from error try: @@ -260,111 +228,85 @@ def wait_for_log(args): except (FileNotFoundError, ValueError): offset = 0 - deadline = time.monotonic() + args.timeout - with log_path.open("r", errors="replace") as log: + 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") - print(line, end="" if line.endswith("\n") else "\n") - return + return line continue if time.monotonic() >= deadline: - raise RuntimeError(f"timed out after {args.timeout:g}s waiting for /{args.match}/") - if not process_alive(pid_from(state)): + 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 button(args): - state = state_dir(args) - require_running(state) - path, prop, pressed, released = BUTTONS[args.name] - if args.down: - qmp_command(state, "qom-set", {"path": path, "property": prop, "value": pressed}) - return - if args.up: - qmp_command(state, "qom-set", {"path": path, "property": prop, "value": released}) - return - qmp_command(state, "qom-set", {"path": path, "property": prop, "value": pressed}) +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: - time.sleep(args.hold_ms / 1000) + if adc: + await_samples(state, prop, samples, timeout) + else: + time.sleep(hold_ms / 1000) finally: - qmp_command(state, "qom-set", {"path": path, "property": prop, "value": released}) + set_button(state, name, down=False) + if adc: + await_samples(state, prop, samples, timeout) -def screenshot(args): - state = state_dir(args) - require_running(state) - output = Path(args.output).expanduser().resolve() +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) - print(output) - - -def parser(): - root = argparse.ArgumentParser(prog="xteink-emu") - commands = root.add_subparsers(dest="command", required=True) - - def add_state(command): - command.add_argument("--state-dir", default=default_state_dir()) - - def add_run_arguments(command): - add_state(command) - command.add_argument("--firmware", required=True) - command.add_argument("--sdcard", required=True) - command.add_argument("--power-hold-ms", type=int, default=2000) - - command = commands.add_parser("start") - add_run_arguments(command) - command.set_defaults(handler=start) - - command = commands.add_parser("interactive") - add_run_arguments(command) - command.set_defaults(handler=interactive) - - command = commands.add_parser("stop") - add_state(command) - command.set_defaults(handler=stop) - - command = commands.add_parser("wait") - add_state(command) - command.add_argument("--timeout", type=float, required=True, help="seconds") - command.add_argument("--match", required=True, help="regular expression") - command.set_defaults(handler=wait_for_log) - - command = commands.add_parser("button") - add_state(command) - command.add_argument("name", choices=BUTTONS) - command.add_argument("--hold-ms", type=int, default=100) - button_mode = command.add_mutually_exclusive_group() - button_mode.add_argument("--down", action="store_true", help="press and hold") - button_mode.add_argument("--up", action="store_true", help="release") - command.set_defaults(handler=button) - - command = commands.add_parser("screenshot") - add_state(command) - command.add_argument("--output", required=True) - command.set_defaults(handler=screenshot) - return root - - -def main(): - args = parser().parse_args() - if getattr(args, "timeout", 0) < 0: - raise RuntimeError("--timeout must not be negative") - if getattr(args, "hold_ms", 0) < 0: - raise RuntimeError("--hold-ms must not be negative") - if getattr(args, "power_hold_ms", 0) < 0: - raise RuntimeError("--power-hold-ms must not be negative") - args.handler(args) - - -if __name__ == "__main__": - try: - main() - except (RuntimeError, OSError) as error: - print(f"xteink-emu: {error}", file=sys.stderr) - sys.exit(1) + return output diff --git a/scripts/xteink/web/__init__.py b/scripts/xteink/web/__init__.py new file mode 100644 index 0000000000..9fb5093520 --- /dev/null +++ b/scripts/xteink/web/__init__.py @@ -0,0 +1,183 @@ +"""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 + +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 == "/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"] + sdcard = state / UPLOADS["sdcard"] + if not firmware.is_file(): + raise RuntimeError("upload a firmware image first") + if not sdcard.is_file(): + blank_sdcard(sdcard, 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) + 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 == "/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 diff --git a/scripts/xteink/web/index.html b/scripts/xteink/web/index.html new file mode 100644 index 0000000000..dbdf71b53c --- /dev/null +++ b/scripts/xteink/web/index.html @@ -0,0 +1,228 @@ + + + + + +xteink emulator + + + +
+
+

ESP32-C3 emulator

+

xteink X3 / X4

+

Upload a firmware .bin (release app image or a full 16 MB flash) and boot it in the emulator running on this host. The SD card image persists between boots.

+
+ +
+ + + + + +
+ +

Loading.

+ +
+
+
+ + +
E-ink display
+ +
+ + + + +
+
+
+ +
+
+ Serial log + +
+

+    
+
+
+ + + +