feat(xteink): add interactive emulator mode

This commit is contained in:
2026-07-24 18:56:11 -04:00
parent 0da29e8871
commit 5906b67287
4 changed files with 116 additions and 23 deletions
+2 -1
View File
@@ -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, 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.
### WebAssembly
+2
View File
@@ -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 \
+52
View File
@@ -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 <emscripten/emscripten.h>
@@ -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,
+60 -22
View File
@@ -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)