diff --git a/README.xteink.md b/README.xteink.md index a1e5c9f949..8b2dab7aa7 100644 --- a/README.xteink.md +++ b/README.xteink.md @@ -37,13 +37,14 @@ uv run --script scripts/xteink-emu.py --help ```sh nix run .#xteink-emu -- start --firmware firmware.bin --sdcard sdcard.img +nix run .#xteink-emu -- interactive --firmware firmware.bin --sdcard sdcard.img 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 ``` -State defaults to `/tmp/xteink-emu-$UID`; pass `--state-dir` to any subcommand for parallel instances. `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. +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 317d7b0f9f..5620939ed8 100644 --- a/flake.nix +++ b/flake.nix @@ -39,6 +39,7 @@ buildInputs = with pkgs; [ dtc glib + gtk3 libgcrypt libpng libslirp @@ -66,6 +67,7 @@ --without-default-features \ --enable-system \ --enable-gcrypt \ + --enable-gtk \ --enable-pixman \ --enable-png \ --enable-slirp \ diff --git a/hw/riscv/esp32c3.c b/hw/riscv/esp32c3.c index 0a045ad37a..6f7ad5891b 100644 --- a/hw/riscv/esp32c3.c +++ b/hw/riscv/esp32c3.c @@ -61,6 +61,7 @@ #include "hw/display/xteink_x3_eink.h" #include "hw/sd/sd.h" #include "hw/net/can/esp32c3_twai.h" +#include "ui/console.h" #ifdef __EMSCRIPTEN__ #include @@ -795,6 +796,56 @@ static void esp32c3_machine_init(MachineState *machine) } +static void xteink_key_event(DeviceState *dev, QemuConsole *src, + InputEvent *evt) +{ + Esp32C3MachineState *ms = ESP32C3_MACHINE(qdev_get_machine()); + InputKeyEvent *key = evt->u.key.data; + int qcode = qemu_input_key_value_to_qcode(key->key); + int channel = -1; + uint32_t pressed = 0; + + switch (qcode) { + case Q_KEY_CODE_LEFT: + channel = 2; + pressed = 2242; + break; + case Q_KEY_CODE_RIGHT: + channel = 2; + pressed = 5; + break; + case Q_KEY_CODE_1: + channel = 1; + pressed = 3512; + break; + case Q_KEY_CODE_2: + channel = 1; + pressed = 2694; + break; + case Q_KEY_CODE_3: + channel = 1; + pressed = 1493; + break; + case Q_KEY_CODE_4: + channel = 1; + pressed = 5; + break; + case Q_KEY_CODE_P: + esp32_gpio_set_input_level(&ms->gpio.parent, 3, !key->down); + return; + default: + return; + } + + qatomic_set(&ms->adc.input[channel], key->down ? pressed : 4095); +} + +static const QemuInputHandler xteink_key_handler = { + .name = "xteink buttons", + .mask = INPUT_EVENT_MASK_KEY, + .event = xteink_key_event, +}; + static void xteink_machine_init(MachineState *machine) { Esp32C3MachineState *ms = ESP32C3_MACHINE(machine); @@ -814,6 +865,7 @@ static void xteink_machine_init(MachineState *machine) DeviceState *panel = ssi_create_peripheral(ms->spi2.bus, ms->x4 ? TYPE_XTEINK_X4_EINK : TYPE_XTEINK_X3_EINK); + qemu_input_handler_register(panel, &xteink_key_handler); qdev_connect_gpio_out_named(DEVICE(&ms->gpio), ESP32_GPIO_OUTPUT, 21, qdev_get_gpio_in_named(panel, SSI_GPIO_CS, 0)); qdev_connect_gpio_out_named(DEVICE(&ms->gpio), ESP32_GPIO_OUTPUT, 4, diff --git a/scripts/xteink-emu.py b/scripts/xteink-emu.py index f344d993f5..55ba589be2 100755 --- a/scripts/xteink-emu.py +++ b/scripts/xteink-emu.py @@ -123,7 +123,7 @@ def build_flash(firmware, output): output.write_bytes(flash) -def start(args): +def run(args, interactive): state = state_dir(args) old_pid = pid_from(state) if process_alive(old_pid): @@ -140,6 +140,7 @@ def start(args): for name in ("pid", "qmp.sock", "serial.log", "qemu.log", "wait.offset"): (state / name).unlink(missing_ok=True) (state / "serial.log").touch() + (state / "qemu.log").touch() (state / "wait.offset").write_text("0\n") flash = state / "flash.bin" build_flash(firmware, flash) @@ -153,8 +154,8 @@ def start(args): "-machine", "xteink,variant=x3", "-accel", "tcg", "-L", str(require_env("XTEINK_BIOS")), - "-display", "none", - "-serial", f"file:{state / 'serial.log'}", + "-display", "gtk" if interactive else "none", + "-serial", "stdio" if interactive else f"file:{state / 'serial.log'}", "-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", @@ -163,15 +164,17 @@ def start(args): "-global", "driver=nvram.esp32c3.efuse,property=drive,value=efuse", "-drive", f"file={sdcard},if=sd,format=raw", ] - qemu_log = (state / "qemu.log").open("ab") - process = subprocess.Popen( - command, - stdin=subprocess.DEVNULL, - stdout=qemu_log, - stderr=subprocess.STDOUT, - start_new_session=True, - ) - qemu_log.close() + if interactive: + process = subprocess.Popen(command) + else: + with (state / "qemu.log").open("ab") as qemu_log: + process = subprocess.Popen( + command, + stdin=subprocess.DEVNULL, + stdout=qemu_log, + stderr=subprocess.STDOUT, + start_new_session=True, + ) (state / "pid").write_text(f"{process.pid}\n") deadline = time.monotonic() + 10 @@ -188,14 +191,42 @@ def start(args): time.sleep(args.power_hold_ms / 1000) finally: qmp_command(state, "qom-set", {"path": path, "property": prop, "value": released}) - print(f"started PID {process.pid}") - print(f"state: {state}") - print(f"serial log: {state / 'serial.log'}") - return + break except (FileNotFoundError, ConnectionRefusedError, socket.timeout): time.sleep(0.05) - process.terminate() - raise RuntimeError("QEMU did not open its QMP socket") + 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}") + + +def start(args): + run(args, interactive=False) + + +def interactive(args): + run(args, interactive=True) def stop(args): @@ -281,13 +312,20 @@ def parser(): 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_state(command) - command.add_argument("--firmware", required=True) - command.add_argument("--sdcard", required=True) - command.add_argument("--power-hold-ms", type=int, default=2000) + 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)