Compare commits

12 Commits

Author SHA1 Message Date
evan 9946dda059 fix(emu-cli): stop emulator after scripts 2026-08-02 17:10:11 -04:00
evan bfdbe91515 feat(emu-cli): add an interpolated touch drag
Back-to-back touch-hold commands collapse into a tap: the guest polls the
touch controller between redraws and samples only the last position. drag
holds, interpolates and releases, with --steps and --step-ms for a guest
that repaints slower than the default cadence.
2026-08-02 17:09:01 -04:00
evan d45d630223 refactor(emu-cli)!: replace per-board commands with esp-emu --board
The repo now carries two board models, so the xteink-first naming had become
misleading. Rename the tool to esp-emu-cli (package esp_emu) and collapse the
xteink-emu/e32r40t-emu entry points into a single esp-emu with a root --board
flag; state directories become /tmp/esp-emu-<board>-$UID.

Flow scripts select the board in the shebang, which now requires env -S:

BREAKING CHANGE: xteink-emu and e32r40t-emu are gone; reinstall with
`uv tool install -e tools/esp-emu-cli` and update existing flow shebangs.
2026-08-01 22:07:00 -04:00
evan 0275620620 fix(emu-cli): preserve board in command scripts 2026-08-01 21:43:21 -04:00
evan 7b9b73c440 feat(wifi): add protected test network 2026-08-01 21:43:21 -04:00
evan c8654eda59 fix(e32r40t): match physical panel touch mapping 2026-08-01 20:31:33 -04:00
evan c45800cdf8 feat(web): drive the browser UI from the board, with touch for e32r40t
The page and server assumed xteink, so /boot ignored the board entirely and an
e32r40t session would have started the wrong machine. Status now carries the
board's buttons, variants, panel and flash-part needs, and the page renders from
that: button chrome for xteink, a click-and-drag panel for e32r40t.

Touch points are clamped server side, a press is held past the guest's poll
interval, and the SD card is created on first edit rather than only at boot.
2026-08-01 08:09:35 -04:00
evan f15a7e85d3 feat(esp32): emulate classic ESP32 WiFi with the shared qemu hotspot
The e32r40t board reached the network through openeth, so firmware calling the
Arduino WiFi API found no radio. Adds a classic Xtensa MAC/DMA front end beside
the C3 one, reusing the synthetic AP, packet layer and user-mode NAT, plus the
ANA/PHYA/FE and PHY register windows the proprietary PHY init touches.

Verified with the E32R40T firmware: scans, finds open SSID 'qemu', associates,
and gets DHCP 192.168.4.15.
2026-08-01 06:29:34 -04:00
evan 7f55ce0138 fix(e32r40t): model MADCTL rotation on the physical panel
The framebuffer now holds physical pixels and MADCTL maps the guest address
window onto it, so a landscape guest no longer loses every column past 319.
Rendering and touch stay in the panel's own 320x480 frame, which matches the
glass and keeps a tap in the same place when the guest rotates.
2026-07-31 21:15:07 -04:00
evan dbe79afa73 feat(emu-cli): support the e32r40t board alongside xteink
Board differences move into a boards.py data table so boot, wait-log, capture,
memory and run stay single-sourced, with a second e32r40t-emu entry point and
its own state directory. Adds tap, touch-hold and touch-release, gating
button and touch commands per board. Flash images are assembled from the
PlatformIO build directory, so no esptool dependency is needed.
2026-07-31 20:47:34 -04:00
evan 6a34276230 feat(e32r40t): add LCDWiki E32R40T machine with ST7796 panel and touch
Models the 320x480 ST7796S panel and XPT2046 touch controller sharing SPI2,
plus an SD card on SPI3, as a subclass of the upstream esp32 machine. GPIO
support widens to 40 pins so touch CS on GPIO33 and IRQ on GPIO36 resolve.
The panel console is named "lcd" so QMP can target it, and pointer events
drive the touch controller.
2026-07-31 20:47:24 -04:00
evan d70a23f108 fix(qemu): correct SPI full-duplex, SPI SD state and console lookup
The ESP32 SPI full-duplex loop compared the transmitted byte value to the
buffer length instead of the loop index, discarding every received byte of a
dummy-byte transfer. In SPI mode the SD card sits in transfer state, so CMD9
and CMD10 must accept it rather than standby, and ssi-sd now resyncs on chip
select release so a guest reset mid-transfer does not leave the card parked in
a data phase. Console lookup skips consoles without a device link instead of
aborting on text consoles.
2026-07-31 20:47:18 -04:00
43 changed files with 1911 additions and 404 deletions
@@ -0,0 +1 @@
*
@@ -0,0 +1 @@
/home/evanreichard/Development/qemu-xteink
+15 -15
View File
@@ -9,7 +9,7 @@ description: "Build and interactively test Xteink ESP32-C3 firmware in the nativ
Build firmware for an Xteink device, boot it in the native emulator, and validate behavior through serial logs, physical controls, screenshots, SD state, and network access.
Resolve script paths relative to this skill directory. `scripts/xteink-emu.sh` stores the qemu-xteink checkout location and runs the globally installed `xteink-emu` (or its uv project fallback); the CLI builds native QEMU on first boot when missing. If its location is unset, run the `variable.sh --set` command printed by the wrapper after asking the user for the checkout path.
Resolve script paths relative to this skill directory. `scripts/esp-emu.sh` stores the qemu-esp-boards checkout location and runs the globally installed `esp-emu --board xteink` (or its uv project fallback); the CLI builds native QEMU on first boot when missing. If its location is unset, run the `variable.sh --set` command printed by the wrapper after asking the user for the checkout path.
## Workflow
@@ -24,23 +24,23 @@ Resolve script paths relative to this skill directory. `scripts/xteink-emu.sh` s
2. Boot it. `boot` replaces an emulator already using the state and simulates a two-second cold-boot power hold:
```sh
scripts/xteink-emu.sh --state "$XTEINK_EMU_STATE" boot "$XTEINK_FIRMWARE"
scripts/esp-emu.sh --board xteink --state "$XTEINK_EMU_STATE" boot "$XTEINK_FIRMWARE"
```
Without an SD argument, the emulator creates or reuses `$XTEINK_EMU_STATE/sdcard.img`. An existing image can be used directly. A directory is merged into the state image before every boot; host files overwrite conflicts, firmware-created files remain, and the source directory is unchanged:
```sh
scripts/xteink-emu.sh --state "$XTEINK_EMU_STATE" boot "$XTEINK_FIRMWARE" --sdcard card.img
scripts/xteink-emu.sh --state "$XTEINK_EMU_STATE" boot "$XTEINK_FIRMWARE" --sdcard test-library/
scripts/xteink-emu.sh --state "$XTEINK_EMU_STATE" boot "$XTEINK_FIRMWARE" --sdcard test-library/ --fresh-sd
scripts/esp-emu.sh --board xteink --state "$XTEINK_EMU_STATE" boot "$XTEINK_FIRMWARE" --sdcard card.img
scripts/esp-emu.sh --board xteink --state "$XTEINK_EMU_STATE" boot "$XTEINK_FIRMWARE" --sdcard test-library/
scripts/esp-emu.sh --board xteink --state "$XTEINK_EMU_STATE" boot "$XTEINK_FIRMWARE" --sdcard test-library/ --fresh-sd
```
3. Synchronize on observable firmware behavior rather than sleeping:
```sh
scripts/xteink-emu.sh --state "$XTEINK_EMU_STATE" wait-log 'Entering activity: Home' --timeout 60
scripts/xteink-emu.sh --state "$XTEINK_EMU_STATE" wait-idle 2 --timeout 30
scripts/xteink-emu.sh --state "$XTEINK_EMU_STATE" wait-frame 2 --timeout 30
scripts/esp-emu.sh --board xteink --state "$XTEINK_EMU_STATE" wait-log 'Entering activity: Home' --timeout 60
scripts/esp-emu.sh --board xteink --state "$XTEINK_EMU_STATE" wait-idle 2 --timeout 30
scripts/esp-emu.sh --board xteink --state "$XTEINK_EMU_STATE" wait-frame 2 --timeout 30
tail -f "$XTEINK_EMU_STATE/serial.log"
```
@@ -49,9 +49,9 @@ Resolve script paths relative to this skill directory. `scripts/xteink-emu.sh` s
4. Drive physical controls. `press` waits for reliable debounced press and release sampling, and accepts a sequence:
```sh
scripts/xteink-emu.sh --state "$XTEINK_EMU_STATE" press bottom-4 bottom-4 bottom-2
scripts/xteink-emu.sh --state "$XTEINK_EMU_STATE" hold power
scripts/xteink-emu.sh --state "$XTEINK_EMU_STATE" release power
scripts/esp-emu.sh --board xteink --state "$XTEINK_EMU_STATE" press bottom-4 bottom-4 bottom-2
scripts/esp-emu.sh --board xteink --state "$XTEINK_EMU_STATE" hold power
scripts/esp-emu.sh --board xteink --state "$XTEINK_EMU_STATE" release power
```
Buttons are `left`, `right`, `bottom-1` through `bottom-4`, and `power`. Use labels rendered by the current screen as the authoritative mapping.
@@ -59,14 +59,14 @@ Resolve script paths relative to this skill directory. `scripts/xteink-emu.sh` s
5. Put repeatable flows in a plain command file, or pipe them over stdin. `--state` is inherited by every line:
```sh
scripts/xteink-emu.sh --state "$XTEINK_EMU_STATE" run flow.xteink
printf '%s\n' 'wait-log "ready"' 'press bottom-2' | scripts/xteink-emu.sh --state "$XTEINK_EMU_STATE" run
scripts/esp-emu.sh --board xteink --state "$XTEINK_EMU_STATE" run flow.xteink
printf '%s\n' 'wait-log "ready"' 'press bottom-2' | scripts/esp-emu.sh --board xteink --state "$XTEINK_EMU_STATE" run
```
6. Capture and inspect meaningful states without a static settle delay:
```sh
scripts/xteink-emu.sh --state "$XTEINK_EMU_STATE" capture /tmp/xteink-screen.png
scripts/esp-emu.sh --board xteink --state "$XTEINK_EMU_STATE" capture /tmp/xteink-screen.png
```
7. For network tests, connect firmware to the single open Wi-Fi network named `qemu`, then validate the observable result.
@@ -81,5 +81,5 @@ Resolve script paths relative to this skill directory. `scripts/xteink-emu.sh` s
## Cleanup
```sh
scripts/xteink-emu.sh --state "$XTEINK_EMU_STATE" stop
scripts/esp-emu.sh --board xteink --state "$XTEINK_EMU_STATE" stop
```
@@ -3,15 +3,15 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
QEMU_REPO="$("$SCRIPT_DIR/variable.sh" --get XTEINK_QEMU_REPO)"
TOOL="$QEMU_REPO/tools/xteink-emu-cli"
TOOL="$QEMU_REPO/tools/esp-emu-cli"
if [[ ! -f "$TOOL/pyproject.toml" ]]; then
echo >&2 "$QEMU_REPO is not a qemu-xteink checkout (missing tools/xteink-emu-cli)"
echo >&2 "$QEMU_REPO is not a qemu-esp-boards checkout (missing tools/esp-emu-cli)"
exit 2
fi
export XTEINK_QEMU_REPO="$QEMU_REPO"
if command -v xteink-emu >/dev/null; then
exec xteink-emu "$@"
if command -v esp-emu >/dev/null; then
exec esp-emu "$@"
fi
exec uv run --project "$TOOL" xteink-emu "$@"
exec uv run --project "$TOOL" esp-emu "$@"
+154
View File
@@ -0,0 +1,154 @@
# qemu-esp-boards
Espressif's QEMU fork carrying custom ESP32 machine models — `xteink` (X3/X4,
ESP32-C3, e-ink) and `e32r40t` (ESP32, ST7796 LCD) — plus a wasm32 TCG backend (ported from [ktock/qemu-wasm](https://github.com/ktock/qemu-wasm)),
used by [xteink-web-emulator](../xteink-web-emulator) to run CrossPoint firmware
in the browser.
## Branch
- `main` — the working branch. Base is Espressif's `esp-develop-9.2.2-20260417`
(`40edccac`).
## Remotes
- `origin` — https://ssh.gitea.va.reichard.io/evan/qemu-esp-boards
- `upstream` — https://github.com/espressif/qemu
- `qemu-wasm` — https://github.com/ktock/qemu-wasm (source of the wasm32 backend)
## Building
### Native (parity check — authoritative)
```sh
nix develop
./configure --target-list=riscv32-softmmu --with-devices-riscv32=xteink --enable-gcrypt
ninja -C build qemu-system-riscv32
```
Install the emulator controller as an editable global tool:
```sh
uv tool install -e tools/esp-emu-cli
esp-emu --help
```
`esp-emu --board {xteink,e32r40t}` selects the machine and defaults to `xteink`. Flow scripts
select it in the shebang, which needs `env -S`: `#!/usr/bin/env -S esp-emu --board e32r40t`.
Its first `boot` builds `.#qemu-native-xteink` with Nix when `dist/qemu-native` is missing. Run `make xteink` when you want to build ahead of time.
### Agent emulator
```sh
nix run .#esp-emu -- --board xteink boot firmware.bin
nix run .#esp-emu -- --board xteink boot firmware.bin --sdcard sdcard.img
nix run .#esp-emu -- --board xteink boot firmware.bin --sdcard test-library/ --fresh-sd
nix run .#esp-emu -- --board xteink wait-log 'ready' --timeout 30
nix run .#esp-emu -- --board xteink press bottom-4 bottom-4 bottom-2
nix run .#esp-emu -- --board xteink wait-frame 2
nix run .#esp-emu -- --board xteink capture screen.png
nix run .#esp-emu -- --board xteink memory
nix run .#esp-emu -- --board xteink stop
```
Use one state for a complete flow:
```sh
nix run .#esp-emu -- --board xteink --state /tmp/reader-test run flow.xteink
# Omit flow.xteink to read command lines from stdin.
```
`boot` replaces the emulator already running in that state. Without `--sdcard`, it creates or reuses `$STATE/sdcard.img`. An image path is used directly; a directory is merged into the state image before every boot, with host files winning conflicts and other image files preserved. `--fresh-sd` recreates a state-owned image before importing the directory. The source directory is never modified.
The installable application lives in `tools/esp-emu-cli/`; `uv tool install -e tools/esp-emu-cli` exposes the global `esp-emu`, `esp-emu --board xteink`, and `esp-emu --board e32r40t` commands and reflects checkout edits immediately. `press` waits for debounced guest ADC reads, so multiple presses do not need display waits. `wait-log` advances a persistent cursor and fails on timeout; use `wait-frame` only when visible rendering is itself under test. `capture` records the current panel without a static delay.
`web` serves a browser UI for firmware, SD images, controls, screen, logs, and SD files. It
adapts to the board it was started for: `esp-emu --board xteink web` renders the button chrome, while
`esp-emu --board e32r40t web` renders a touch panel you click and drag directly on the screen, and asks
for the `bootloader.bin` and `partitions.bin` that sit beside a PlatformIO `firmware.bin`.
A click is held for at least 250 ms, because the guest only samples touch between redraws. `memory` reports RAM high-water marks; QEMU zeroes RAM at reset, so firmware heap logs remain authoritative for live usage. State defaults to `/tmp/esp-emu-xteink-$UID`. `interactive` opens a native window and maps Left/Right, 14, and P to device buttons. Both emulated boards expose the open Wi-Fi network `qemu` through QEMU user-mode
NAT (`192.168.4.0/24`). Xteink uses the ESP32-C3 Wi-Fi front end; E32R40T uses the
classic Xtensa ESP32 front end. They share the synthetic AP and network backend,
so DHCP and outbound internet access behave the same.
### WebAssembly
Driven by the parent repo, not here:
```sh
cd ../xteink-web-emulator
make web-dev # incremental build straight from this tree
```
The parent's `scripts/build-qemu-wasm.sh` compiles this checkout inside the
emsdk Docker image defined in `xteink-web-emulator/qemu/wasm/Dockerfile`.
## E32R40T (4.0" ESP32-32E display module)
A separate `e32r40t` machine models the E32R40T board (ESP32-WROOM-32E,
ST7796S 320x480 panel + XPT2046 resistive touch, both on SPI2). It is a
subclass of the upstream `esp32` machine; the panel and touch share one
emulated board state. The GPIO model was widened to 40 pins so the firmware's
GPIO 33 (touch CS) and GPIO 36 (touch IRQ) lines resolve. The classic ESP32 Wi-Fi
MAC/DMA and required PHY register windows are modeled for the same open `qemu`
hotspot and user-mode NAT used by Xteink.
```sh
./configure --target-list=xtensa-softmmu --enable-gcrypt
ninja -C build qemu-system-xtensa
./build/qemu-system-xtensa -machine e32r40t -m 4M \
-global driver=timer.esp32.timg,property=wdt_disable,value=true \
-drive file=flash.bin,if=mtd,format=raw \
-drive file=sdcard.img,if=sd,format=raw \
-display none -serial stdio
```
The `wdt_disable` global is worth keeping for this board: TCG is slow enough that
IDF startup trips the timer-group watchdogs repeatedly, and each reset restarts
SD initialisation. With it, boot is a single reset and mounting is deterministic.
`frame-generation` is exposed on the machine (same as `xteink`) so the CLI
can capture only on a real refresh.
The console stays 320x480 whatever MADCTL says, matching the physical glass:
a rotated guest draws sideways, and touch coordinates are physical, so
rotating the guest never moves where a tap lands.
### esp-emu --board e32r40t
The same controller drives this board under a second entry point, so `boot`,
`wait-log`, `wait-frame`, `capture`, `memory` and `run` behave identically. It
keeps its own state directory, and board-specific commands are gated: `tap` is
rejected on `xteink`, `press`/`hold`/`release` on `e32r40t`.
```sh
nix run .#esp-emu -- --board e32r40t boot .pio/build/esp32-32e/firmware.bin --sdcard sdcard/ --fresh-sd
nix run .#esp-emu -- --board e32r40t tap 60 95 # panel pixels, 320x480
nix run .#esp-emu -- --board e32r40t drag 60 400 60 180 # press, interpolate, release
nix run .#esp-emu -- --board e32r40t touch-hold 60 95 # manual gesture: hold, move, release
nix run .#esp-emu -- --board e32r40t touch-release 200 300
nix run .#esp-emu -- --board e32r40t capture screen.png
nix run .#esp-emu -- --board e32r40t stop
```
Use `drag` for scrolling rather than a run of `touch-hold`s: the guest polls the touch
controller between redraws, so back-to-back positions collapse into a tap at the last one.
A guest that repaints slowly can outrun even the interpolated steps, which looks like a
flick that scrolls a fraction of the distance; `--steps` and `--step-ms` slow it down.
`bootloader.bin` and `partitions.bin` are read from the PlatformIO build
directory next to `firmware.bin`; a full 4 MiB flash image is also accepted.
The watchdog global above is applied automatically.
The panel console is bound to the device id `lcd`, so it can be targeted
directly instead of relying on console ordering:
```
{ "execute": "screendump", "arguments": { "filename": "shot.ppm", "device": "lcd" } }
```
Interactive displays (`-display gtk` / `sdl`) drive the touch panel: pointer
motion sets the XPT2046 position and the left button acts as pen-down.
Touches can also be injected over QMP with `input-send-event` (`abs` x/y in
the 0-0x7fff range, then a `btn` press/release).
-75
View File
@@ -1,75 +0,0 @@
# xteink QEMU fork
Espressif's QEMU fork carrying the xteink X3/X4 (ESP32-C3) machine model and a
wasm32 TCG backend (ported from [ktock/qemu-wasm](https://github.com/ktock/qemu-wasm)),
used by [xteink-web-emulator](../xteink-web-emulator) to run CrossPoint firmware
in the browser.
## Branch
- `main` — the working branch. Base is Espressif's `esp-develop-9.2.2-20260417`
(`40edccac`).
## Remotes
- `origin` — https://ssh.gitea.va.reichard.io/evan/qemu-xteink
- `upstream` — https://github.com/espressif/qemu
- `qemu-wasm` — https://github.com/ktock/qemu-wasm (source of the wasm32 backend)
## Building
### Native (parity check — authoritative)
```sh
nix develop
./configure --target-list=riscv32-softmmu --with-devices-riscv32=xteink --enable-gcrypt
ninja -C build qemu-system-riscv32
```
Install the emulator controller as an editable global tool:
```sh
uv tool install -e tools/xteink-emu-cli
xteink-emu --help
```
Its first `boot` builds `.#qemu-native-xteink` with Nix when `dist/qemu-native` is missing. Run `make xteink` when you want to build ahead of time.
### Agent emulator
```sh
nix run .#xteink-emu -- boot firmware.bin
nix run .#xteink-emu -- boot firmware.bin --sdcard sdcard.img
nix run .#xteink-emu -- boot firmware.bin --sdcard test-library/ --fresh-sd
nix run .#xteink-emu -- wait-log 'ready' --timeout 30
nix run .#xteink-emu -- press bottom-4 bottom-4 bottom-2
nix run .#xteink-emu -- wait-frame 2
nix run .#xteink-emu -- capture screen.png
nix run .#xteink-emu -- memory
nix run .#xteink-emu -- stop
```
Use one state for a complete flow:
```sh
nix run .#xteink-emu -- --state /tmp/reader-test run flow.xteink
# Omit flow.xteink to read command lines from stdin.
```
`boot` replaces the emulator already running in that state. Without `--sdcard`, it creates or reuses `$STATE/sdcard.img`. An image path is used directly; a directory is merged into the state image before every boot, with host files winning conflicts and other image files preserved. `--fresh-sd` recreates a state-owned image before importing the directory. The source directory is never modified.
The installable application lives in `tools/xteink-emu-cli/`; `uv tool install -e tools/xteink-emu-cli` exposes the global `xteink-emu` command and reflects checkout edits immediately. `press` waits for debounced guest ADC reads, so multiple presses do not need display waits. `wait-log` advances a persistent cursor and fails on timeout; use `wait-frame` only when visible rendering is itself under test. `capture` records the current panel without a static delay.
`web` serves a browser UI for firmware, SD images, controls, screen, logs, and SD files. `memory` reports RAM high-water marks; QEMU zeroes RAM at reset, so firmware heap logs remain authoritative for live usage. State defaults to `/tmp/xteink-emu-$UID`. `interactive` opens a native window and maps Left/Right, 14, and P to device buttons. The emulator exposes the open Wi-Fi network `qemu` through QEMU user-mode NAT.
### WebAssembly
Driven by the parent repo, not here:
```sh
cd ../xteink-web-emulator
make web-dev # incremental build straight from this tree
```
The parent's `scripts/build-qemu-wasm.sh` compiles this checkout inside the
emsdk Docker image defined in `xteink-web-emulator/qemu/wasm/Dockerfile`.
+12 -11
View File
@@ -61,7 +61,7 @@
export PKG_CONFIG_PATH=${pkgs.libslirp}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}
./configure \
--prefix=$out \
--target-list=riscv32-softmmu \
--target-list=riscv32-softmmu,xtensa-softmmu \
--with-devices-riscv32=xteink \
--with-coroutine=ucontext \
--without-default-features \
@@ -79,7 +79,7 @@
buildPhase = ''
runHook preBuild
ninja -C build qemu-system-riscv32
ninja -C build qemu-system-riscv32 qemu-system-xtensa
runHook postBuild
'';
@@ -89,31 +89,32 @@
runHook postInstall
'';
};
xteink-emu-cli = pkgs.python3Packages.buildPythonApplication {
pname = "xteink-emu-cli";
esp-emu-cli = pkgs.python3Packages.buildPythonApplication {
pname = "esp-emu-cli";
version = "0.1.0";
pyproject = true;
src = ./tools/xteink-emu-cli;
src = ./tools/esp-emu-cli;
build-system = [ pkgs.python3Packages.hatchling ];
};
xteink-emu = pkgs.writeShellApplication {
name = "xteink-emu";
esp-emu = pkgs.writeShellApplication {
name = "esp-emu";
runtimeInputs = [ pkgs.mtools pkgs.dosfstools ];
text = ''
export XTEINK_QEMU=${qemu-native-xteink}/bin/qemu-system-riscv32
export E32R40T_QEMU=${qemu-native-xteink}/bin/qemu-system-xtensa
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 ${xteink-emu-cli}/bin/xteink-emu "$@"
exec ${esp-emu-cli}/bin/esp-emu "$@"
'';
};
in
{
packages.qemu-native-xteink = qemu-native-xteink;
packages.xteink-emu = xteink-emu;
apps.xteink-emu = {
packages.esp-emu = esp-emu;
apps.esp-emu = {
type = "app";
program = "${xteink-emu}/bin/xteink-emu";
program = "${esp-emu}/bin/esp-emu";
};
# Native build/test shell. The wasm target is built by the parent
+442
View File
@@ -0,0 +1,442 @@
/*
* E32R40T 4.0" display module: ST7796S TFT panel (CS=GPIO15, DC=GPIO2) and
* XPT2046 resistive touch (CS=GPIO33), both on ESP32 SPI2. Two thin SSI
* peripherals share one Esp32LcdBoard state since SSI models one CS each.
*
* SPDX-License-Identifier: GPL-2.0-or-later
*/
#include "qemu/osdep.h"
#include "qemu/module.h"
#include "qemu/timer.h"
#include "hw/irq.h"
#include "hw/qdev-properties.h"
#include "hw/sysbus.h"
#include "hw/ssi/ssi.h"
#include "ui/console.h"
#include "ui/input.h"
#include "hw/display/esp32_lcd_st7796.h"
#define WIDTH 320
#define HEIGHT 480
#define ST7796_CMD_CASET 0x2A
#define ST7796_CMD_RASET 0x2B
#define ST7796_CMD_RAMWR 0x2C
#define ST7796_CMD_MADCTL 0x36
#define MADCTL_MY 0x80
#define MADCTL_MX 0x40
#define MADCTL_MV 0x20
#define XPT2046_CMD_X 0x90
#define XPT2046_CMD_Y 0xD0
#define XPT2046_CMD_Z1 0xB0
#define XPT2046_CMD_Z2 0xC0
/* Show the settled framebuffer after a frame of inactivity instead of
* redrawing the console on every RAMWR pixel */
#define REFRESH_IDLE_NS (20 * SCALE_MS)
static uint32_t lcd_frame_generation;
struct Esp32LcdBoard {
SysBusDevice parent_obj;
QemuConsole *console;
QEMUTimer *refresh_timer;
QemuInputHandlerState *input;
qemu_irq touch_irq;
bool dc;
bool touched;
uint16_t touch_x;
uint16_t touch_y;
uint8_t command;
uint8_t madctl;
uint8_t touch_phase;
uint16_t be16;
uint8_t addr_pos;
uint8_t addr_buf[4];
uint32_t pixel;
uint8_t pixel_byte;
uint16_t col_start, col_end, row_start, row_end;
uint16_t cur_col, cur_row;
bool dirty;
uint16_t fb[WIDTH * HEIGHT];
};
typedef struct Esp32LcdPanelState {
SSIPeripheral parent_obj;
Esp32LcdBoard *board;
} Esp32LcdPanelState;
typedef struct Esp32LcdTouchState {
SSIPeripheral parent_obj;
Esp32LcdBoard *board;
} Esp32LcdTouchState;
OBJECT_DECLARE_SIMPLE_TYPE(Esp32LcdPanelState, ESP32_LCD_ST7796)
OBJECT_DECLARE_SIMPLE_TYPE(Esp32LcdTouchState, ESP32_LCD_XPT2046)
/* MADCTL maps the guest's address window onto the glass: MV swaps the axes so a
* landscape guest addresses 480 columns, MX and MY mirror them. Rotation 0 in
* the vendor init sets MX, so that is the identity baseline here. */
static void lcd_gram_to_panel(Esp32LcdBoard *b, int col, int row, int *px, int *py)
{
if (b->madctl & MADCTL_MV) {
*px = (b->madctl & MADCTL_MY) ? row : WIDTH - 1 - row;
*py = (b->madctl & MADCTL_MX) ? HEIGHT - 1 - col : col;
} else {
*px = (b->madctl & MADCTL_MX) ? col : WIDTH - 1 - col;
*py = (b->madctl & MADCTL_MY) ? HEIGHT - 1 - row : row;
}
}
static void lcd_render(Esp32LcdBoard *b)
{
DisplaySurface *surface = qemu_console_surface(b->console);
if (!surface || !surface_data(surface)) {
return;
}
uint32_t *dest = surface_data(surface);
int stride = surface_stride(surface) / 4;
/* The glass is physically 320x480 whatever MADCTL says, so a rotated guest
* simply draws sideways here, exactly as it looks on the real board. */
for (int y = 0; y < HEIGHT; y++) {
for (int x = 0; x < WIDTH; x++) {
uint16_t color = b->fb[y * WIDTH + x];
dest[y * stride + x] = ((color & 0xf800) << 8) |
((color & 0x07e0) << 5) |
((color & 0x001f) << 3);
}
}
qatomic_inc(&lcd_frame_generation);
dpy_gfx_update(b->console, 0, 0, WIDTH, HEIGHT);
b->dirty = false;
}
static void lcd_refresh_idle(void *opaque)
{
Esp32LcdBoard *b = opaque;
if (b->dirty) {
lcd_render(b);
}
}
static void lcd_reset_board(Esp32LcdBoard *b)
{
b->dc = false;
b->touched = false;
b->command = 0;
b->madctl = 0;
b->touch_phase = 0;
b->addr_pos = 0;
b->dirty = false;
memset(b->fb, 0xff, sizeof(b->fb));
lcd_render(b);
}
static void lcd_set_dc(void *opaque, int n, int level)
{
Esp32LcdPanelState *s = ESP32_LCD_ST7796(opaque);
s->board->dc = level;
}
static void lcd_touch_event(DeviceState *dev, QemuConsole *src,
InputEvent *evt)
{
Esp32LcdPanelState *s = ESP32_LCD_ST7796(dev);
Esp32LcdBoard *b = s->board;
switch (evt->type) {
/* Touch coordinates are physical: the resistive panel knows nothing about
* MADCTL, so rotating the guest must not move where a tap lands. */
case INPUT_EVENT_KIND_ABS:
if (evt->u.abs.data->axis == INPUT_AXIS_X) {
b->touch_x = qemu_input_scale_axis(evt->u.abs.data->value,
INPUT_EVENT_ABS_MIN, INPUT_EVENT_ABS_MAX, 0, WIDTH - 1);
} else if (evt->u.abs.data->axis == INPUT_AXIS_Y) {
b->touch_y = qemu_input_scale_axis(evt->u.abs.data->value,
INPUT_EVENT_ABS_MIN, INPUT_EVENT_ABS_MAX, 0, HEIGHT - 1);
}
break;
case INPUT_EVENT_KIND_BTN:
if (evt->u.btn.data->button == INPUT_BUTTON_LEFT) {
b->touched = evt->u.btn.data->down;
qemu_set_irq(b->touch_irq, b->touched ? 0 : 1);
}
break;
default:
break;
}
}
static void lcd_touch_sync(DeviceState *dev)
{
}
static QemuInputHandler lcd_touch_handler = {
.name = "e32r40t touch",
.mask = INPUT_EVENT_MASK_BTN | INPUT_EVENT_MASK_ABS,
.event = lcd_touch_event,
.sync = lcd_touch_sync,
};
static uint32_t lcd_panel_transfer(SSIPeripheral *peripheral, uint32_t value)
{
Esp32LcdBoard *b = ESP32_LCD_ST7796(peripheral)->board;
uint8_t byte = value;
if (!b->dc) {
b->command = byte;
b->addr_pos = 0;
b->pixel_byte = 0;
return 0xff;
}
switch (b->command) {
case ST7796_CMD_CASET:
case ST7796_CMD_RASET:
if (b->addr_pos < sizeof(b->addr_buf)) {
b->addr_buf[b->addr_pos++] = byte;
}
if (b->addr_pos == sizeof(b->addr_buf)) {
uint16_t start = (b->addr_buf[0] << 8) | b->addr_buf[1];
uint16_t end = (b->addr_buf[2] << 8) | b->addr_buf[3];
if (b->command == ST7796_CMD_CASET) {
b->col_start = start;
b->col_end = end;
b->cur_col = start;
} else {
b->row_start = start;
b->row_end = end;
b->cur_row = start;
}
}
break;
case ST7796_CMD_MADCTL:
b->madctl = byte;
break;
case ST7796_CMD_RAMWR:
if (b->pixel_byte == 0) {
b->pixel = byte << 8;
b->pixel_byte = 1;
} else {
b->pixel |= byte;
b->pixel_byte = 0;
if (b->cur_col <= b->col_end && b->cur_row <= b->row_end) {
int px, py;
lcd_gram_to_panel(b, b->cur_col, b->cur_row, &px, &py);
if (px >= 0 && px < WIDTH && py >= 0 && py < HEIGHT) {
b->fb[py * WIDTH + px] = b->pixel;
}
}
if (++b->cur_col > b->col_end) {
b->cur_col = b->col_start;
if (++b->cur_row > b->row_end) {
b->cur_row = b->row_start;
}
}
b->dirty = true;
timer_mod(b->refresh_timer,
qemu_clock_get_ns(QEMU_CLOCK_VIRTUAL) + REFRESH_IDLE_NS);
}
break;
default:
break;
}
return 0xff;
}
static uint16_t lcd_touch_adc(Esp32LcdBoard *b, uint8_t cmd)
{
switch (cmd & 0xF0) {
/* The controller's axes are swapped relative to the panel, as measured on real
* E32R40T hardware: X reads the long axis inverted, Y reads the short axis. */
case XPT2046_CMD_X:
return 3800 - b->touch_y * 3600 / (HEIGHT - 1);
case XPT2046_CMD_Y:
return 240 + b->touch_x * 3560 / (WIDTH - 1);
case XPT2046_CMD_Z1:
return b->touched ? 500 : 0;
case XPT2046_CMD_Z2:
return b->touched ? 3500 : 4095;
default:
return 0;
}
}
static uint32_t lcd_touch_transfer(SSIPeripheral *peripheral, uint32_t value)
{
Esp32LcdBoard *b = ESP32_LCD_XPT2046(peripheral)->board;
if (b->touch_phase == 0) {
b->be16 = lcd_touch_adc(b, value) << 3;
b->touch_phase = 1;
return 0;
}
if (b->touch_phase == 1) {
b->touch_phase = 2;
return b->be16 >> 8;
}
uint8_t result = b->be16;
b->be16 = lcd_touch_adc(b, value) << 3;
b->touch_phase = 1;
return result;
}
static void lcd_invalidate(void *opaque)
{
Esp32LcdBoard *b = opaque;
lcd_render(b);
}
static void lcd_update(void *opaque)
{
}
static const GraphicHwOps lcd_graphics_ops = {
.invalidate = lcd_invalidate,
.gfx_update = lcd_update,
};
static Esp32LcdBoard *lcd_get_board(DeviceState *dev)
{
return ESP32_LCD_BOARD(
object_property_get_link(OBJECT(dev), "board", &error_abort));
}
static void lcd_panel_init(Object *obj)
{
qdev_init_gpio_in_named(DEVICE(obj), lcd_set_dc, ESP32_LCD_ST7796_DC, 1);
}
static void lcd_panel_realize(SSIPeripheral *peripheral, Error **errp)
{
Esp32LcdPanelState *s = ESP32_LCD_ST7796(peripheral);
s->board = lcd_get_board(DEVICE(peripheral));
s->board->input = qemu_input_handler_register(DEVICE(s),
&lcd_touch_handler);
qemu_input_handler_activate(s->board->input);
}
/* Each CS assertion starts a fresh command; otherwise the 16-bit reads that
* follow drift one byte out of phase for the rest of the session. */
static int lcd_touch_set_cs(SSIPeripheral *peripheral, bool level)
{
ESP32_LCD_XPT2046(peripheral)->board->touch_phase = 0;
return 0;
}
static void lcd_touch_realize(SSIPeripheral *peripheral, Error **errp)
{
Esp32LcdTouchState *s = ESP32_LCD_XPT2046(peripheral);
s->board = lcd_get_board(DEVICE(peripheral));
}
static void lcd_board_reset(DeviceState *dev)
{
lcd_reset_board(ESP32_LCD_BOARD(dev));
}
static void lcd_board_init(Object *obj)
{
Esp32LcdBoard *b = ESP32_LCD_BOARD(obj);
object_property_add_uint32_ptr(obj, "frame-generation",
&lcd_frame_generation, OBJ_PROP_FLAG_READ);
b->refresh_timer = timer_new_ns(QEMU_CLOCK_VIRTUAL, lcd_refresh_idle, b);
qdev_init_gpio_out_named(DEVICE(obj), &b->touch_irq,
ESP32_LCD_XPT2046_IRQ, 1);
qemu_set_irq(b->touch_irq, 1);
}
/* Deferred to realize: an unparented device has no canonical path, so the
* console's "device" link would silently stay NULL and QMP could not find it. */
static void lcd_board_realize(DeviceState *dev, Error **errp)
{
Esp32LcdBoard *b = ESP32_LCD_BOARD(dev);
b->console = graphic_console_init(dev, 0, &lcd_graphics_ops, b);
dpy_gfx_replace_surface(b->console,
qemu_create_displaysurface(WIDTH, HEIGHT));
}
static void lcd_board_class_init(ObjectClass *klass, void *data)
{
DeviceClass *dc = DEVICE_CLASS(klass);
dc->realize = lcd_board_realize;
device_class_set_legacy_reset(dc, lcd_board_reset);
dc->user_creatable = false;
}
static const Property lcd_link_properties[] = {
DEFINE_PROP_LINK("board", Esp32LcdPanelState, board,
TYPE_ESP32_LCD_BOARD, Esp32LcdBoard *),
DEFINE_PROP_END_OF_LIST(),
};
static const Property lcd_touch_link_properties[] = {
DEFINE_PROP_LINK("board", Esp32LcdTouchState, board,
TYPE_ESP32_LCD_BOARD, Esp32LcdBoard *),
DEFINE_PROP_END_OF_LIST(),
};
static void lcd_panel_class_init(ObjectClass *klass, void *data)
{
DeviceClass *dc = DEVICE_CLASS(klass);
SSIPeripheralClass *ssi = SSI_PERIPHERAL_CLASS(klass);
ssi->realize = lcd_panel_realize;
ssi->transfer = lcd_panel_transfer;
ssi->cs_polarity = SSI_CS_LOW;
device_class_set_props(dc, lcd_link_properties);
dc->user_creatable = false;
}
static void lcd_touch_class_init(ObjectClass *klass, void *data)
{
DeviceClass *dc = DEVICE_CLASS(klass);
SSIPeripheralClass *ssi = SSI_PERIPHERAL_CLASS(klass);
ssi->realize = lcd_touch_realize;
ssi->transfer = lcd_touch_transfer;
ssi->set_cs = lcd_touch_set_cs;
ssi->cs_polarity = SSI_CS_LOW;
device_class_set_props(dc, lcd_touch_link_properties);
dc->user_creatable = false;
}
static const TypeInfo lcd_board_info = {
.name = TYPE_ESP32_LCD_BOARD,
.parent = TYPE_SYS_BUS_DEVICE,
.instance_size = sizeof(Esp32LcdBoard),
.instance_init = lcd_board_init,
.class_init = lcd_board_class_init,
};
static const TypeInfo lcd_panel_info = {
.name = TYPE_ESP32_LCD_ST7796,
.parent = TYPE_SSI_PERIPHERAL,
.instance_size = sizeof(Esp32LcdPanelState),
.instance_init = lcd_panel_init,
.class_init = lcd_panel_class_init,
};
static const TypeInfo lcd_touch_info = {
.name = TYPE_ESP32_LCD_XPT2046,
.parent = TYPE_SSI_PERIPHERAL,
.instance_size = sizeof(Esp32LcdTouchState),
.class_init = lcd_touch_class_init,
};
static void lcd_register_types(void)
{
type_register_static(&lcd_board_info);
type_register_static(&lcd_panel_info);
type_register_static(&lcd_touch_info);
}
type_init(lcd_register_types)
+1
View File
@@ -33,6 +33,7 @@ system_ss.add(when: 'CONFIG_MACFB', if_true: files('macfb.c'))
system_ss.add(when: 'CONFIG_NEXTCUBE', if_true: files('next-fb.c'))
system_ss.add(when: 'CONFIG_ESP_RGB', if_true: files('esp_rgb.c'))
system_ss.add(when: 'CONFIG_RISCV_ESP32C3', if_true: files('xteink_x3_eink.c'))
system_ss.add(when: 'CONFIG_XTENSA_ESP32', if_true: files('esp32_lcd_st7796.c'))
system_ss.add(when: 'CONFIG_VGA', if_true: files('vga.c'))
system_ss.add(when: 'CONFIG_VIRTIO', if_true: files('virtio-dmabuf.c'))
+80 -25
View File
@@ -23,14 +23,28 @@
#define GPIO_OUT 0x04
#define GPIO_OUT_W1TS 0x08
#define GPIO_OUT_W1TC 0x0c
/* pins 32-39 */
#define GPIO_OUT1 0x10
#define GPIO_OUT1_W1TS 0x14
#define GPIO_OUT1_W1TC 0x18
#define GPIO_ENABLE 0x20
#define GPIO_ENABLE_W1TS 0x24
#define GPIO_ENABLE_W1TC 0x28
#define GPIO_ENABLE1 0x2c
#define GPIO_ENABLE1_W1TS 0x30
#define GPIO_ENABLE1_W1TC 0x34
#define GPIO_IN 0x3c
#define GPIO_IN1 0x40
#define GPIO_STATUS 0x44
#define GPIO_STATUS_W1TS 0x48
#define GPIO_STATUS_W1TC 0x4c
#define GPIO_STATUS1 0x50
#define GPIO_STATUS1_W1TS 0x54
#define GPIO_STATUS1_W1TC 0x58
#define GPIO_PCPU_INT 0x5c
#define GPIO_PCPU_INT1 0x60
#define HI(x) ((uint32_t)((x) >> 32)) /* pins 32-39 */
#define GPIO_PIN_INT_TYPE_SHIFT 7
#define GPIO_PIN_INT_TYPE_MASK 0x7
@@ -47,21 +61,21 @@
static void esp32_gpio_drive_outputs(Esp32GpioState *s)
{
for (int i = 0; i < ESP32_GPIO_COUNT; i++) {
int level = (s->output_enable & BIT(i))
? !!(s->output_level & BIT(i))
int level = (s->output_enable & (1ULL << i))
? !!(s->output_level & (1ULL << i))
: 1;
qemu_set_irq(s->output_lines[i], level);
}
}
static uint32_t esp32_gpio_enabled_interrupts(Esp32GpioState *s)
static uint64_t esp32_gpio_enabled_interrupts(Esp32GpioState *s)
{
uint32_t enabled = 0;
uint64_t enabled = 0;
for (int pin = 0; pin < ESP32_GPIO_COUNT; pin++) {
uint32_t interrupt_enable = s->pin_config[pin] >> GPIO_PIN_INT_ENA_SHIFT;
if (interrupt_enable & GPIO_PIN_INT_ENA_CPU0) {
enabled |= BIT(pin);
enabled |= 1ULL << pin;
}
}
return enabled;
@@ -75,31 +89,31 @@ static void esp32_gpio_update_irq(Esp32GpioState *s)
static void esp32_gpio_latch_level_interrupts(Esp32GpioState *s)
{
uint32_t levels = qatomic_read(&s->input_level);
uint64_t levels = qatomic_read(&s->input_level);
for (int pin = 0; pin < ESP32_GPIO_COUNT; pin++) {
uint32_t type = extract32(s->pin_config[pin],
GPIO_PIN_INT_TYPE_SHIFT,
3);
bool level = !!(levels & BIT(pin));
bool level = !!(levels & (1ULL << pin));
if ((type == GPIO_INTR_LOW_LEVEL && !level) ||
(type == GPIO_INTR_HIGH_LEVEL && level)) {
s->interrupt_status |= BIT(pin);
s->interrupt_status |= 1ULL << pin;
}
}
}
void esp32_gpio_set_input_level(Esp32GpioState *s, int pin, bool level)
{
uint32_t old_level;
uint32_t new_level;
uint64_t old_level;
uint64_t new_level;
do {
old_level = qatomic_read(&s->input_level);
new_level = deposit32(old_level, pin, 1, level);
new_level = deposit64(old_level, pin, 1, level);
} while (qatomic_cmpxchg(&s->input_level, old_level, new_level) != old_level);
bool old_pin_level = !!(old_level & BIT(pin));
bool old_pin_level = !!(old_level & (1ULL << pin));
uint32_t type = extract32(s->pin_config[pin],
GPIO_PIN_INT_TYPE_SHIFT, 3);
if ((!old_pin_level && level && type == GPIO_INTR_POSEDGE) ||
@@ -107,7 +121,7 @@ void esp32_gpio_set_input_level(Esp32GpioState *s, int pin, bool level)
(old_pin_level != level && type == GPIO_INTR_ANYEDGE) ||
(!level && type == GPIO_INTR_LOW_LEVEL) ||
(level && type == GPIO_INTR_HIGH_LEVEL)) {
s->interrupt_status |= BIT(pin);
s->interrupt_status |= 1ULL << pin;
}
esp32_gpio_update_irq(s);
}
@@ -122,7 +136,7 @@ static void esp32_gpio_get_input(Object *obj, Visitor *v, const char *name,
{
Esp32GpioState *s = ESP32_GPIO(obj);
int pin = GPOINTER_TO_INT(opaque);
bool level = extract32(qatomic_read(&s->input_level), pin, 1);
bool level = extract64(qatomic_read(&s->input_level), pin, 1);
visit_type_bool(v, name, &level, errp);
}
@@ -145,17 +159,27 @@ static uint64_t esp32_gpio_read(void *opaque, hwaddr addr, unsigned int size)
switch (addr) {
case GPIO_OUT:
return s->output_level;
return (uint32_t)s->output_level;
case GPIO_OUT1:
return HI(s->output_level);
case GPIO_ENABLE:
return s->output_enable;
return (uint32_t)s->output_enable;
case GPIO_ENABLE1:
return HI(s->output_enable);
case A_GPIO_STRAP:
return s->strap_mode;
case GPIO_IN:
return qatomic_read(&s->input_level);
return (uint32_t)qatomic_read(&s->input_level);
case GPIO_IN1:
return HI(qatomic_read(&s->input_level));
case GPIO_STATUS:
return s->interrupt_status;
return (uint32_t)s->interrupt_status;
case GPIO_STATUS1:
return HI(s->interrupt_status);
case GPIO_PCPU_INT:
return s->interrupt_status & esp32_gpio_enabled_interrupts(s);
return (uint32_t)(s->interrupt_status & esp32_gpio_enabled_interrupts(s));
case GPIO_PCPU_INT1:
return HI(s->interrupt_status & esp32_gpio_enabled_interrupts(s));
default:
if (addr >= s->pin_config_base &&
addr < s->pin_config_base + sizeof(s->pin_config)) {
@@ -180,25 +204,43 @@ static void esp32_gpio_write(void *opaque, hwaddr addr,
switch (addr) {
case GPIO_OUT:
s->output_level = value;
s->output_level = (s->output_level & ~0xffffffffULL) | value;
break;
case GPIO_OUT_W1TS:
s->output_level |= value;
break;
case GPIO_OUT_W1TC:
s->output_level &= ~value;
s->output_level &= ~(uint64_t)value;
break;
case GPIO_OUT1:
s->output_level = (s->output_level & 0xffffffffULL) | (value << 32);
break;
case GPIO_OUT1_W1TS:
s->output_level |= (uint64_t)value << 32;
break;
case GPIO_OUT1_W1TC:
s->output_level &= ~((uint64_t)value << 32);
break;
case GPIO_ENABLE:
s->output_enable = value;
s->output_enable = (s->output_enable & ~0xffffffffULL) | value;
break;
case GPIO_ENABLE_W1TS:
s->output_enable |= value;
break;
case GPIO_ENABLE_W1TC:
s->output_enable &= ~value;
s->output_enable &= ~(uint64_t)value;
break;
case GPIO_ENABLE1:
s->output_enable = (s->output_enable & 0xffffffffULL) | (value << 32);
break;
case GPIO_ENABLE1_W1TS:
s->output_enable |= (uint64_t)value << 32;
break;
case GPIO_ENABLE1_W1TC:
s->output_enable &= ~((uint64_t)value << 32);
break;
case GPIO_STATUS:
s->interrupt_status = value;
s->interrupt_status = (s->interrupt_status & ~0xffffffffULL) | value;
esp32_gpio_update_irq(s);
return;
case GPIO_STATUS_W1TS:
@@ -206,7 +248,20 @@ static void esp32_gpio_write(void *opaque, hwaddr addr,
esp32_gpio_update_irq(s);
return;
case GPIO_STATUS_W1TC:
s->interrupt_status &= ~value;
s->interrupt_status &= ~(uint64_t)value;
esp32_gpio_latch_level_interrupts(s);
esp32_gpio_update_irq(s);
return;
case GPIO_STATUS1:
s->interrupt_status = (s->interrupt_status & 0xffffffffULL) | (value << 32);
esp32_gpio_update_irq(s);
return;
case GPIO_STATUS1_W1TS:
s->interrupt_status |= (uint64_t)value << 32;
esp32_gpio_update_irq(s);
return;
case GPIO_STATUS1_W1TC:
s->interrupt_status &= ~((uint64_t)value << 32);
esp32_gpio_latch_level_interrupts(s);
esp32_gpio_update_irq(s);
return;
+65
View File
@@ -0,0 +1,65 @@
#include "qemu/osdep.h"
#include "hw/sysbus.h"
#include "hw/misc/esp32_ana.h"
#include "esp32_wlan.h"
int esp32_wifi_channel;
static uint64_t esp32_ana_read(void *opaque, hwaddr addr, unsigned size)
{
Esp32AnaState *s = ESP32_ANA(opaque);
switch (addr) {
case 0x04:
return 0xfdffffff;
case 0x44:
case 0x4c:
case 0xc4:
return 0xffffffff;
default:
return s->mem[addr / 4];
}
}
static void esp32_ana_write(void *opaque, hwaddr addr, uint64_t value,
unsigned size)
{
Esp32AnaState *s = ESP32_ANA(opaque);
if (addr == 0xc4 && (value & 0xff) != 0xff) {
/* The emulator exposes one AP on channel 1; proprietary PHY tuning values
* vary between IDF releases, so no wider channel decoder is needed. */
esp32_wifi_channel = 1;
}
s->mem[addr / 4] = value;
}
static const MemoryRegionOps esp32_ana_ops = {
.read = esp32_ana_read,
.write = esp32_ana_write,
.endianness = DEVICE_LITTLE_ENDIAN,
};
static void esp32_ana_init(Object *obj)
{
Esp32AnaState *s = ESP32_ANA(obj);
SysBusDevice *sbd = SYS_BUS_DEVICE(obj);
memory_region_init_io(&s->iomem, obj, &esp32_ana_ops, s,
TYPE_ESP32_ANA, 0x1000);
sysbus_init_mmio(sbd, &s->iomem);
}
static const TypeInfo esp32_ana_info = {
.name = TYPE_ESP32_ANA,
.parent = TYPE_SYS_BUS_DEVICE,
.instance_size = sizeof(Esp32AnaState),
.instance_init = esp32_ana_init,
};
static void esp32_ana_register_types(void)
{
type_register_static(&esp32_ana_info);
}
type_init(esp32_ana_register_types)
+229
View File
@@ -0,0 +1,229 @@
#include "qemu/osdep.h"
#include "hw/irq.h"
#include "hw/misc/esp32_wifi.h"
#include "hw/qdev-properties.h"
#include "hw/sysbus.h"
#include "exec/address-spaces.h"
#include "esp32_wlan_packet.h"
extern access_point_info access_points[];
extern int nb_aps;
static uint64_t esp32_wifi_read(void *opaque, hwaddr addr, unsigned size)
{
Esp32WifiState *s = ESP32_WIFI(opaque);
switch (addr) {
case A_WIFI_DMA_IN_STATUS:
return 0;
case A_WIFI_DMA_INT_STATUS:
case A_WIFI_DMA_INT_CLR:
return s->raw_interrupt;
case A_WIFI_STATUS:
case A_WIFI_DMA_OUT_STATUS:
return 1;
default:
return s->mem[addr / 4];
}
}
static void esp32_wifi_raise(Esp32WifiState *s, uint32_t bits)
{
s->raw_interrupt |= bits;
qemu_set_irq(s->irq, 1);
}
void Esp32_WLAN_frame_delivered(Esp32WifiState *s)
{
esp32_wifi_raise(s, 0x80);
}
static void esp32_wifi_write(void *opaque, hwaddr addr, uint64_t value,
unsigned size)
{
Esp32WifiState *s = ESP32_WIFI(opaque);
switch (addr) {
case A_WIFI_DMA_INLINK: {
uint32_t offset = value & 0xfffff;
s->dma_inlink_address = offset ? 0x3ff00000 | offset : 0;
break;
}
case A_WIFI_DMA_INT_CLR:
s->raw_interrupt &= ~value;
if (!s->raw_interrupt) {
qemu_set_irq(s->irq, 0);
}
break;
case A_WIFI_DMA_OUTLINK:
if ((value & 0xc0000000) && (value & 0xfffff)) {
mac80211_frame frame;
dma_list_item item;
hwaddr descriptor = 0x3ff00000 | (value & 0xfffff);
address_space_read(&address_space_memory, descriptor,
MEMTXATTRS_UNSPECIFIED, &item, sizeof(item));
if (item.length > sizeof(frame)) {
break;
}
address_space_read(&address_space_memory, item.address,
MEMTXATTRS_UNSPECIFIED, &frame, item.length);
frame.frame_length = item.length;
frame.next_frame = NULL;
Esp32_WLAN_handle_frame(s, &frame);
}
break;
default:
break;
}
s->mem[addr / 4] = value;
}
static bool esp32_wifi_mac_matches(const uint8_t *actual, const uint8_t *filter)
{
return !memcmp(actual, filter, 6) || !memcmp(actual, BROADCAST, 6);
}
void Esp32_sendFrame(Esp32WifiState *s, mac80211_frame *frame, int length,
int signal_strength)
{
int packet_channel = esp32_wifi_channel;
if (!s->dma_inlink_address) {
return;
}
if (frame->frame_control.type == IEEE80211_TYPE_MGT &&
frame->frame_control.sub_type == IEEE80211_TYPE_MGT_SUBTYPE_BEACON) {
int pos = 12;
int data_length = length - IEEE80211_HEADER_SIZE;
while (pos + 2 <= data_length) {
uint8_t tag = frame->data_and_fcs[pos];
uint8_t tag_length = frame->data_and_fcs[pos + 1];
if (tag == IEEE80211_BEACON_PARAM_CHANNEL && tag_length) {
packet_channel = frame->data_and_fcs[pos + 2];
break;
}
pos += 2 + tag_length;
}
}
if (!packet_channel) {
const uint8_t *address = frame->frame_control.type == IEEE80211_TYPE_DATA ?
frame->bssid_address : frame->source_address;
for (int i = 0; i < nb_aps; i++) {
if (!memcmp(access_points[i].mac_address, address, 6)) {
packet_channel = access_points[i].channel;
break;
}
}
}
g_autofree uint8_t *buffer = g_malloc0(sizeof(wifi_pkt_rx_ctrl_t) + length);
wifi_pkt_rx_ctrl_t *pkt = (wifi_pkt_rx_ctrl_t *)buffer;
*pkt = (wifi_pkt_rx_ctrl_t) {
.rssi = signal_strength + (rand() % 10) + 96,
.rate = 11,
.legacy_length = length,
.noise_floor = -97,
.channel = packet_channel,
.timestamp = qemu_clock_get_ns(QEMU_CLOCK_REALTIME) / 1000,
.sig_len = length,
.sig_len_copy = length,
};
if (esp32_wifi_mac_matches(frame->destination_address,
(uint8_t *)s->mem + 0x40)) {
pkt->damatch0 = 1;
}
if (esp32_wifi_mac_matches(frame->destination_address,
(uint8_t *)s->mem + 0x48)) {
pkt->damatch1 = 1;
}
if (esp32_wifi_mac_matches(frame->bssid_address,
(uint8_t *)s->mem + 0x40)) {
pkt->bssidmatch0 = 1;
}
if (esp32_wifi_mac_matches(frame->bssid_address,
(uint8_t *)s->mem + 0x48)) {
pkt->bssidmatch1 = 1;
}
pkt->damatch0 = 1;
pkt->bssidmatch0 = 1;
memcpy(buffer + sizeof(*pkt), frame, length);
length += sizeof(*pkt);
dma_list_item item;
address_space_read(&address_space_memory, s->dma_inlink_address,
MEMTXATTRS_UNSPECIFIED, &item, sizeof(item));
if (length > item.size) {
return;
}
address_space_write(&address_space_memory, item.address,
MEMTXATTRS_UNSPECIFIED, buffer, length);
item.length = length;
item.eof = 1;
item.owner = 0;
address_space_write(&address_space_memory, s->dma_inlink_address,
MEMTXATTRS_UNSPECIFIED, &item, sizeof(uint32_t));
s->dma_inlink_address = item.next;
esp32_wifi_raise(s, 0x1000024);
}
static const MemoryRegionOps esp32_wifi_ops = {
.read = esp32_wifi_read,
.write = esp32_wifi_write,
.endianness = DEVICE_LITTLE_ENDIAN,
};
static void esp32_wifi_reset(DeviceState *dev)
{
Esp32WifiState *s = ESP32_WIFI(dev);
s->dma_inlink_address = 0;
s->raw_interrupt = 0;
qemu_set_irq(s->irq, 0);
memset(s->mem, 0, sizeof(s->mem));
Esp32_WLAN_reset_ap(s);
}
static void esp32_wifi_realize(DeviceState *dev, Error **errp)
{
Esp32WifiState *s = ESP32_WIFI(dev);
SysBusDevice *sbd = SYS_BUS_DEVICE(dev);
memory_region_init_io(&s->iomem, OBJECT(dev), &esp32_wifi_ops, s,
TYPE_ESP32_WIFI, 0x1000);
sysbus_init_mmio(sbd, &s->iomem);
sysbus_init_irq(sbd, &s->irq);
Esp32_WLAN_setup_ap(dev, s);
}
static Property esp32_wifi_properties[] = {
DEFINE_NIC_PROPERTIES(Esp32WifiState, conf),
DEFINE_PROP_END_OF_LIST(),
};
static void esp32_wifi_class_init(ObjectClass *klass, void *data)
{
DeviceClass *dc = DEVICE_CLASS(klass);
dc->realize = esp32_wifi_realize;
device_class_set_legacy_reset(dc, esp32_wifi_reset);
set_bit(DEVICE_CATEGORY_NETWORK, dc->categories);
dc->desc = "ESP32 WiFi";
device_class_set_props(dc, esp32_wifi_properties);
}
static const TypeInfo esp32_wifi_info = {
.name = TYPE_ESP32_WIFI,
.parent = TYPE_SYS_BUS_DEVICE,
.instance_size = sizeof(Esp32WifiState),
.class_init = esp32_wifi_class_init,
};
static void esp32_wifi_register_types(void)
{
type_register_static(&esp32_wifi_info);
}
type_init(esp32_wifi_register_types)
+3 -5
View File
@@ -63,7 +63,8 @@
#define ANSI_FG_HCOLOR(f) printf("\033[1;%dm", (f) + 30)
access_point_info access_points[] = {
{"qemu", 1, -25, {0x10, 0x01, 0x00, 0xc4, 0x0a, 0x51}},
{"qemu", NULL, 1, -25, {0x10, 0x01, 0x00, 0xc4, 0x0a, 0x51}},
{"qemu2", "qemuqemu", 1, -30, {0x10, 0x01, 0x00, 0xc4, 0x0a, 0x52}},
};
int nb_aps=sizeof(access_points)/sizeof(access_point_info);
@@ -75,17 +76,14 @@ static void Esp32_WLAN_beacon_timer(void *opaque)
// only send a beacon if we are an access point
if((ENABLE_BEACON)&&(s->mode == Esp32_Mode_Station)){
if(s->ap_state!=Esp32_WLAN__STATE_STA_ASSOCIATED) {
for(int i=0;i<nb_aps;i++){
int ap = (i + s->beacon_ap)%nb_aps;
for(int ap=0;ap<nb_aps;ap++){
if (access_points[ap].channel==esp32_wifi_channel) {
memcpy(s->ap_macaddr,access_points[ap].mac_address,6);
frame = Esp32_WLAN_create_beacon_frame(&access_points[ap]);
Esp32_WLAN_init_ap_frame(s, frame);
Esp32_WLAN_insert_frame(s, frame);
break;
}
}
s->beacon_ap=(s->beacon_ap+1)%nb_aps;
}
}
timer_mod(s->beacon_timer, qemu_clock_get_ns(QEMU_CLOCK_REALTIME) + BEACON_TIME);
+1
View File
@@ -117,6 +117,7 @@ typedef struct mac80211_frame {
typedef struct access_point_info {
const char *ssid;
const char *password;
int channel;
int sigstrength;
macaddr_t mac_address;
+17 -2
View File
@@ -100,17 +100,31 @@ static void add_ssid(mac80211_frame *frame, const char *ssid) {
add_tag(frame,IEEE80211_BEACON_PARAM_SSID,strlen(ssid),(uint8_t *)ssid);
}
static void add_rsn(mac80211_frame *frame, access_point_info *ap) {
if (!ap->password) {
return;
}
add_tag(frame, 0x30, 20, (uint8_t[]){
0x01, 0x00,
0x00, 0x0f, 0xac, 0x04,
0x01, 0x00, 0x00, 0x0f, 0xac, 0x04,
0x01, 0x00, 0x00, 0x0f, 0xac, 0x02,
0x00, 0x00,
});
}
mac80211_frame *Esp32_WLAN_create_beacon_frame(access_point_info *ap) {
mac80211_frame *frame=new_frame(IEEE80211_TYPE_MGT,IEEE80211_TYPE_MGT_SUBTYPE_BEACON);
frame->signal_strength=ap->sigstrength;
memcpy(frame->destination_address,BROADCAST,6);
frame->beacon_info.timestamp=qemu_clock_get_ns(QEMU_CLOCK_REALTIME)/1000;
frame->beacon_info.interval=1000;
frame->beacon_info.capability=1;
frame->beacon_info.capability=ap->password ? 0x11 : 1;
frame->pos=12;
add_ssid(frame,ap->ssid);
add_rates(frame);
add_tag(frame,IEEE80211_BEACON_PARAM_CHANNEL,1,(uint8_t[]){ap->channel});
add_rsn(frame, ap);
add_tag(frame,IEEE80211_BEACON_PARAM_TIM,4,(uint8_t[]){4,1,3,0,0});
return frame;
}
@@ -200,11 +214,12 @@ mac80211_frame *Esp32_WLAN_create_probe_response(access_point_info *ap) {
mac80211_frame *frame=new_frame(IEEE80211_TYPE_MGT,IEEE80211_TYPE_MGT_SUBTYPE_PROBE_RESP);
frame->beacon_info.timestamp=qemu_clock_get_ns(QEMU_CLOCK_REALTIME)/1000;
frame->beacon_info.interval=1000;
frame->beacon_info.capability=1;
frame->beacon_info.capability=ap->password ? 0x11 : 1;
frame->pos=12;
add_ssid(frame,ap->ssid);
add_rates(frame);
add_tag(frame,IEEE80211_BEACON_PARAM_CHANNEL,1,(uint8_t[]){ap->channel});
add_rsn(frame, ap);
return frame;
}
+6 -6
View File
@@ -21,7 +21,7 @@ extern int nb_aps;
static uint64_t esp32C3_wifi_read(void *opaque, hwaddr addr, unsigned int size)
{
Esp32WifiState *s = ESP32_WIFI(opaque);
Esp32WifiState *s = ESP32C3_WIFI(opaque);
uint32_t r = s->mem[addr/4];
switch(addr) {
@@ -56,7 +56,7 @@ void Esp32_WLAN_frame_delivered(Esp32WifiState *s){
static void esp32C3_wifi_write(void *opaque, hwaddr addr, uint64_t value,
unsigned int size) {
Esp32WifiState *s = ESP32_WIFI(opaque);
Esp32WifiState *s = ESP32C3_WIFI(opaque);
if(DEBUG) printf("esp32C3_wifi_write 0x%04lx= 0x%08lx\n",(unsigned long) addr, (unsigned long) value);
switch (addr) {
@@ -192,7 +192,7 @@ static const MemoryRegionOps esp32C3_wifi_ops = {
static void esp32c3_wifi_reset(DeviceState *dev)
{
Esp32WifiState *s = ESP32_WIFI(dev);
Esp32WifiState *s = ESP32C3_WIFI(dev);
s->dma_inlink_address=0;
memset(s->mem,0,sizeof(s->mem));
@@ -201,12 +201,12 @@ static void esp32c3_wifi_reset(DeviceState *dev)
static void esp32C3_wifi_realize(DeviceState *dev, Error **errp)
{
Esp32WifiState *s = ESP32_WIFI(dev);
Esp32WifiState *s = ESP32C3_WIFI(dev);
SysBusDevice *sbd = SYS_BUS_DEVICE(dev);
s->dma_inlink_address=0;
memory_region_init_io(&s->iomem, OBJECT(dev), &esp32C3_wifi_ops, s,
TYPE_ESP32_WIFI, 0x1000);
TYPE_ESP32C3_WIFI, 0x1000);
sysbus_init_mmio(sbd, &s->iomem);
sysbus_init_irq(sbd, &s->irq);
memset(s->mem,0,sizeof(s->mem));
@@ -231,7 +231,7 @@ static void esp32C3_wifi_class_init(ObjectClass *klass, void *data)
static const TypeInfo esp32C3_wifi_info = {
.name = TYPE_ESP32_WIFI,
.name = TYPE_ESP32C3_WIFI,
.parent = TYPE_SYS_BUS_DEVICE,
.instance_size = sizeof(Esp32WifiState),
.class_init = esp32C3_wifi_class_init,
+6
View File
@@ -145,6 +145,12 @@ system_ss.add(when: 'CONFIG_XTENSA_ESP32', if_true: files(
'esp32_aes.c',
'esp32_ledc.c',
'esp32_flash_enc.c',
'esp32_wifi.c',
'esp32_wifi_ap.c',
'esp32_wlan_packet.c',
'esp32_ana.c',
'esp32_phya.c',
'esp32_fe.c',
'ssi_psram.c'
))
+2 -2
View File
@@ -1577,7 +1577,7 @@ static sd_rsp_type_t emmc_cmd_SEND_EXT_CSD(SDState *sd, SDRequest req)
/* CMD9 */
static sd_rsp_type_t spi_cmd_SEND_CSD(SDState *sd, SDRequest req)
{
if (sd->state != sd_standby_state) {
if (sd->state != sd_transfer_state) {
return sd_invalid_state_for_cmd(sd, req);
}
return sd_cmd_to_sendingdata(sd, req, sd_req_get_address(sd, req),
@@ -1596,7 +1596,7 @@ static sd_rsp_type_t sd_cmd_SEND_CSD(SDState *sd, SDRequest req)
/* CMD10 */
static sd_rsp_type_t spi_cmd_SEND_CID(SDState *sd, SDRequest req)
{
if (sd->state != sd_standby_state) {
if (sd->state != sd_transfer_state) {
return sd_invalid_state_for_cmd(sd, req);
}
return sd_cmd_to_sendingdata(sd, req, sd_req_get_address(sd, req),
+20
View File
@@ -395,6 +395,25 @@ static void ssi_sd_realize(SSIPeripheral *d, Error **errp)
qbus_init(&s->sdbus, sizeof(s->sdbus), TYPE_SD_BUS, DEVICE(d), "sd-bus");
}
/* CS frames a command in SPI mode, so releasing it ends whatever was in
* flight. Without this a guest that resets mid-transfer (an ESP32 watchdog
* reboot, say) leaves the card parked in a data phase, and the next boot's
* CMD0 is eaten as payload. */
static int ssi_sd_set_cs(SSIPeripheral *dev, bool level)
{
ssi_sd_state *s = SSI_SD(dev);
if (level) {
s->mode = SSI_SD_CMD;
s->arglen = 0;
s->response_pos = 0;
s->read_bytes = 0;
s->write_bytes = 0;
s->stopping = 0;
}
return 0;
}
static void ssi_sd_reset(DeviceState *dev)
{
ssi_sd_state *s = SSI_SD(dev);
@@ -419,6 +438,7 @@ static void ssi_sd_class_init(ObjectClass *klass, void *data)
k->realize = ssi_sd_realize;
k->transfer = ssi_sd_transfer;
k->set_cs = ssi_sd_set_cs;
k->cs_polarity = SSI_CS_LOW;
dc->vmsd = &vmstate_ssi_sd;
device_class_set_legacy_reset(dc, ssi_sd_reset);
+4 -3
View File
@@ -157,11 +157,11 @@ static void esp32_spi_txrx_buffer(Esp32SpiState *s, void *buf, int tx_bytes, int
uint8_t *c_buf = (uint8_t*) buf;
for (int i = 0; i < bytes; ++i) {
uint8_t byte = 0;
if (byte < tx_bytes) {
if (i < tx_bytes) {
memcpy(&byte, c_buf + i, 1);
}
uint32_t res = ssi_transfer(s->spi, byte);
if (byte < rx_bytes) {
if (i < rx_bytes) {
memcpy(c_buf + i, &res, 1);
}
}
@@ -275,7 +275,8 @@ static void esp32_spi_do_command(Esp32SpiState* s, uint32_t cmd_reg)
case R_SPI_CMD_USR_MASK:
maybe_encrypt_data(s);
if (FIELD_EX32(s->user_reg, SPI_USER, COMMAND) || FIELD_EX32(s->user2_reg, SPI_USER2, COMMAND_BITLEN)) {
if (FIELD_EX32(s->user_reg, SPI_USER, COMMAND) &&
FIELD_EX32(s->user2_reg, SPI_USER2, COMMAND_BITLEN)) {
t.cmd = FIELD_EX32(s->user2_reg, SPI_USER2, COMMAND_VALUE);
t.cmd_bytes = bitlen_to_bytes(FIELD_EX32(s->user2_reg, SPI_USER2, COMMAND_BITLEN));
} else {
+1
View File
@@ -26,6 +26,7 @@ config XTENSA_ESP32
depends on XTENSA
select SSI
select SSI_M25P80
select SSI_SD
select UNIMP
select OPENCORES_ETH
select DWC_SDMMC
+135 -5
View File
@@ -15,6 +15,7 @@
#include "qapi/error.h"
#include "hw/hw.h"
#include "hw/boards.h"
#include "monitor/qdev.h"
#include "hw/loader.h"
#include "hw/sysbus.h"
#include "hw/i2c/esp32_i2c.h"
@@ -25,7 +26,12 @@
#include "hw/qdev-properties.h"
#include "hw/xtensa/esp32.h"
#include "hw/misc/ssi_psram.h"
#include "hw/misc/esp32_wifi.h"
#include "hw/misc/esp32_ana.h"
#include "hw/misc/esp32_phya.h"
#include "hw/misc/esp32_fe.h"
#include "hw/sd/dwc_sdmmc.h"
#include "hw/sd/sd.h"
#include "core-esp32/core-isa.h"
#include "qemu/datadir.h"
#include "sysemu/sysemu.h"
@@ -487,6 +493,8 @@ static void esp32_soc_realize(DeviceState *dev, Error **errp)
sysbus_connect_irq(SYS_BUS_DEVICE(&s->twai), 0,
qdev_get_gpio_in(intmatrix_dev, ETS_CAN_INTR_SOURCE));
/* WiFi PHY setup touches WDEV control words around the RNG register. */
esp32_soc_add_unimp_device(sys_mem, "esp32.wdev", DR_REG_WDEV_BASE, 0x1000);
qdev_realize(DEVICE(&s->rng), &s->periph_bus, &error_fatal);
esp32_soc_add_periph_device(sys_mem, &s->rng, ESP32_RNG_BASE);
@@ -516,7 +524,11 @@ static void esp32_soc_realize(DeviceState *dev, Error **errp)
esp32_soc_add_periph_device(sys_mem, &s->rgb, DR_REG_FRAMEBUF_BASE);
memory_region_add_subregion_overlap(sys_mem, esp32_memmap[ESP32_MEMREGION_FRAMEBUF].base, &s->rgb.vram, 0);
esp32_soc_add_unimp_device(sys_mem, "esp32.analog", DR_REG_ANA_BASE, 0x1000);
esp32_soc_add_unimp_device(sys_mem, "esp32.fe2", DR_REG_FE2_BASE, 0x1000);
esp32_soc_add_unimp_device(sys_mem, "esp32.bt", DR_REG_BT_BASE, 0x1000);
esp32_soc_add_unimp_device(sys_mem, "esp32.nrx", 0x3ff5c000, 0x1000);
esp32_soc_add_unimp_device(sys_mem, "esp32.bb", DR_REG_BB_BASE, 0x1000);
esp32_soc_add_unimp_device(sys_mem, "esp32.phy", DR_REG_PHY_BASE, 0x1000);
esp32_soc_add_unimp_device(sys_mem, "esp32.rtcio", DR_REG_RTCIO_BASE, 0x400);
esp32_soc_add_unimp_device(sys_mem, "esp32.rtcio", DR_REG_SENS_BASE, 0x400);
esp32_soc_add_unimp_device(sys_mem, "esp32.iomux", DR_REG_IO_MUX_BASE, 0x2000);
@@ -690,11 +702,16 @@ static uint64_t translate_phys_addr(void *opaque, uint64_t addr)
}
#include "hw/display/esp32_lcd_st7796.h"
#include "hw/ssi/ssi.h"
struct Esp32MachineState {
MachineState parent;
Esp32SocState esp32;
DeviceState *flash_dev;
bool esp32_realized;
bool e32r40t;
};
#define TYPE_ESP32_MACHINE MACHINE_TYPE_NAME("esp32")
@@ -754,6 +771,38 @@ static void esp32_machine_init_i2c(Esp32SocState *s)
object_property_set_int(OBJECT(tmp105), "temperature", 25 * 1000, &error_fatal);
}
static void esp32_map_radio_device(const char *type, hwaddr address)
{
DeviceState *device = qdev_new(type);
SysBusDevice *sbd = SYS_BUS_DEVICE(device);
sysbus_realize_and_unref(sbd, &error_fatal);
esp32_soc_add_periph_device(get_system_memory(), device, address);
}
static bool esp32_machine_init_wifi(Esp32SocState *ss)
{
DeviceState *wifi = qemu_create_nic_device(TYPE_ESP32_WIFI, false, NULL);
SysBusDevice *sbd;
if (!wifi) {
return false;
}
esp32_map_radio_device(TYPE_ESP32_ANA, DR_REG_ANA_BASE);
esp32_map_radio_device(TYPE_ESP32_PHYA, DR_REG_PHYA_BASE);
esp32_map_radio_device(TYPE_ESP32_FE, DR_REG_FE_BASE);
ss->eth = wifi;
sbd = SYS_BUS_DEVICE(wifi);
sysbus_realize_and_unref(sbd, &error_fatal);
esp32_soc_add_periph_device(get_system_memory(), wifi, DR_REG_WIFI_BASE);
sysbus_connect_irq(sbd, 0,
qdev_get_gpio_in(DEVICE(&ss->intmatrix),
ETS_WIFI_MAC_INTR_SOURCE));
return true;
}
static void esp32_machine_init_openeth(Esp32SocState *ss)
{
SysBusDevice *sbd;
@@ -803,7 +852,9 @@ static void esp32_machine_init(MachineState *machine)
}
Esp32MachineState *ms = ESP32_MACHINE(machine);
object_initialize_child(OBJECT(ms), "soc", &ms->esp32, TYPE_ESP32_SOC);
if (!ms->esp32_realized) {
object_initialize_child(OBJECT(ms), "soc", &ms->esp32, TYPE_ESP32_SOC);
}
Esp32SocState *ss = ESP32_SOC(&ms->esp32);
if (blk) {
@@ -816,7 +867,10 @@ static void esp32_machine_init(MachineState *machine)
qdev_prop_set_bit(DEVICE(&ss->dport), "has_psram", true);
}
qdev_realize(DEVICE(ss), NULL, &error_fatal);
if (!ms->esp32_realized) {
qdev_realize(DEVICE(ss), NULL, &error_fatal);
ms->esp32_realized = true;
}
if (blk) {
esp32_machine_init_spi_flash(ss, blk);
@@ -828,9 +882,13 @@ static void esp32_machine_init(MachineState *machine)
esp32_machine_init_i2c(ss);
esp32_machine_init_openeth(ss);
if (!esp32_machine_init_wifi(ss)) {
esp32_machine_init_openeth(ss);
}
esp32_machine_init_sd(ss);
if (!ms->e32r40t) {
esp32_machine_init_sd(ss);
}
/* Need MMU initialized prior to ELF loading,
* so that ELF gets loaded into virtual addresses
@@ -940,9 +998,81 @@ static const TypeInfo esp32_info = {
.class_init = esp32_machine_class_init,
};
static void e32r40t_machine_init(MachineState *machine)
{
Esp32MachineState *ms = ESP32_MACHINE(machine);
ms->e32r40t = true;
esp32_machine_init(machine);
Esp32SocState *ss = ESP32_SOC(&ms->esp32);
/* ST7796 panel (CS=GPIO15, DC=GPIO2) and XPT2046 touch (CS=GPIO33)
* share SPI2 and one emulated board state. SSIBus is only defined in
* hw/ssi/ssi.c, hence the cast of the SPI2 child bus. */
DeviceState *board = qdev_new(TYPE_ESP32_LCD_BOARD);
/* Named so QMP screendump/input-send-event can target the LCD console. */
qdev_set_id(board, g_strdup("lcd"), &error_fatal);
sysbus_realize_and_unref(SYS_BUS_DEVICE(board), &error_fatal);
BusState *spi2 = qdev_get_child_bus(DEVICE(&ss->spi[2]), "spi");
DeviceState *panel = qdev_new(TYPE_ESP32_LCD_ST7796);
object_property_set_link(OBJECT(panel), "board", OBJECT(board),
&error_fatal);
ssi_realize_and_unref(panel, (SSIBus *)spi2, &error_fatal);
DeviceState *touch = qdev_new(TYPE_ESP32_LCD_XPT2046);
object_property_set_link(OBJECT(touch), "board", OBJECT(board),
&error_fatal);
qdev_prop_set_uint8(touch, "cs", 1);
ssi_realize_and_unref(touch, (SSIBus *)spi2, &error_fatal);
object_property_add_alias(OBJECT(machine), "frame-generation",
OBJECT(board), "frame-generation");
qdev_connect_gpio_out_named(DEVICE(&ss->gpio), ESP32_GPIO_OUTPUT, 15,
qdev_get_gpio_in_named(panel, SSI_GPIO_CS, 0));
qdev_connect_gpio_out_named(DEVICE(&ss->gpio), ESP32_GPIO_OUTPUT, 33,
qdev_get_gpio_in_named(touch, SSI_GPIO_CS, 0));
qdev_connect_gpio_out_named(DEVICE(&ss->gpio), ESP32_GPIO_OUTPUT, 2,
qdev_get_gpio_in_named(panel, ESP32_LCD_ST7796_DC, 0));
qemu_set_irq(qdev_get_gpio_in_named(panel, SSI_GPIO_CS, 0), 1);
qemu_set_irq(qdev_get_gpio_in_named(touch, SSI_GPIO_CS, 0), 1);
qdev_connect_gpio_out_named(board, ESP32_LCD_XPT2046_IRQ, 0,
qdev_get_gpio_in_named(DEVICE(&ss->gpio), ESP32_GPIO_INPUT, 36));
esp32_gpio_set_input_level(&ss->gpio, 36, true);
DeviceState *sd_adapter = qdev_new("ssi-sd");
qdev_realize_and_unref(sd_adapter,
qdev_get_child_bus(DEVICE(&ss->spi[3]), "spi"), &error_fatal);
qdev_connect_gpio_out_named(DEVICE(&ss->gpio), ESP32_GPIO_OUTPUT, 5,
qdev_get_gpio_in_named(sd_adapter, SSI_GPIO_CS, 0));
qemu_set_irq(qdev_get_gpio_in_named(sd_adapter, SSI_GPIO_CS, 0), 1);
DriveInfo *dinfo = drive_get(IF_SD, 0, 0);
DeviceState *sd_card = qdev_new(TYPE_SD_CARD_SPI);
qdev_prop_set_drive_err(sd_card, "drive",
dinfo ? blk_by_legacy_dinfo(dinfo) : NULL,
&error_fatal);
qdev_realize_and_unref(sd_card,
qdev_get_child_bus(sd_adapter, "sd-bus"),
&error_fatal);
}
static void e32r40t_machine_class_init(ObjectClass *oc, void *data)
{
MachineClass *mc = MACHINE_CLASS(oc);
mc->desc = "E32R40T 4.0\" ESP32-32E display module (ST7796 + XPT2046)";
mc->init = e32r40t_machine_init;
}
static const TypeInfo e32r40t_info = {
.name = MACHINE_TYPE_NAME("e32r40t"),
.parent = TYPE_ESP32_MACHINE,
.class_init = e32r40t_machine_class_init,
};
static void esp32_machine_type_init(void)
{
type_register_static(&esp32_info);
type_register_static(&e32r40t_info);
}
type_init(esp32_machine_type_init);
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#include "qom/object.h"
typedef struct Esp32LcdBoard Esp32LcdBoard;
#define TYPE_ESP32_LCD_BOARD "e32r40t-board"
#define ESP32_LCD_BOARD(obj) \
((Esp32LcdBoard *)object_dynamic_cast(OBJECT(obj), TYPE_ESP32_LCD_BOARD))
#define ESP32_LCD_BOARD_GET_CLASS(obj) ((void)(obj), NULL)
#define ESP32_LCD_BOARD_CLASS(klass) ((void)(klass), NULL)
#define TYPE_ESP32_LCD_ST7796 "esp32-lcd-st7796"
#define TYPE_ESP32_LCD_XPT2046 "esp32-lcd-xpt2046"
#define ESP32_LCD_ST7796_DC "dc"
#define ESP32_LCD_XPT2046_IRQ "irq"
+5 -5
View File
@@ -14,7 +14,7 @@ REG32(GPIO_STRAP, 0x0038)
#define ESP32_STRAP_MODE_FLASH_BOOT 0x12
#define ESP32_STRAP_MODE_UART_BOOT 0x0f
#define ESP32_GPIO_COUNT 32
#define ESP32_GPIO_COUNT 40 /* real ESP32 has GPIO0-39; upper pins use OUT1/IN1 */
#define ESP32_GPIO_INPUT "gpio-in"
#define ESP32_GPIO_OUTPUT "gpio-out"
@@ -25,10 +25,10 @@ typedef struct Esp32GpioState {
qemu_irq irq;
qemu_irq output_lines[ESP32_GPIO_COUNT];
uint32_t strap_mode;
uint32_t input_level;
uint32_t output_level;
uint32_t output_enable;
uint32_t interrupt_status;
uint64_t input_level;
uint64_t output_level;
uint64_t output_enable;
uint64_t interrupt_status;
uint32_t pin_config[ESP32_GPIO_COUNT];
hwaddr pin_config_base;
} Esp32GpioState;
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include "hw/hw.h"
#include "hw/sysbus.h"
#define TYPE_ESP32_ANA "misc.esp32.ana"
#define ESP32_ANA(obj) OBJECT_CHECK(Esp32AnaState, (obj), TYPE_ESP32_ANA)
typedef struct Esp32AnaState {
SysBusDevice parent_obj;
MemoryRegion iomem;
uint32_t mem[1024];
} Esp32AnaState;
+3
View File
@@ -59,6 +59,9 @@
#define DR_REG_UART2_BASE 0x3ff6E000
#define DR_REG_PWM2_BASE 0x3ff6F000
#define DR_REG_PWM3_BASE 0x3ff70000
#define DR_REG_PHY_BASE 0x3ff71000
#define DR_REG_WIFI_BASE 0x3ff73000
#define DR_REG_PHYA_BASE 0x3ff74000
#define DR_REG_WDEV_BASE 0x3ff75000
#define APB_REG_BASE 0x60000000
+1 -1
View File
@@ -7,7 +7,7 @@
#include "sysemu/sysemu.h"
#include "net/net.h"
#define TYPE_ESP32_WIFI "esp32c3_wifi"
#define TYPE_ESP32_WIFI "esp32_wifi"
#define ESP32_WIFI(obj) OBJECT_CHECK(Esp32WifiState, (obj), TYPE_ESP32_WIFI)
typedef struct dma_list_item {
+2 -1
View File
@@ -7,7 +7,8 @@
#include "net/net.h"
#include "esp32_wifi.h"
#define TYPE_ESP32C3_WIFI TYPE_ESP32_WIFI
#define TYPE_ESP32C3_WIFI "esp32c3_wifi"
#define ESP32C3_WIFI(obj) OBJECT_CHECK(Esp32WifiState, (obj), TYPE_ESP32C3_WIFI)
REG32(C3_WIFI_DMA_IN_STATUS, 0x84);
+3 -1
View File
@@ -484,9 +484,11 @@ ucontext_probe = '''
# On Windows the only valid backend is the Windows specific one.
# For POSIX prefer ucontext, but it's not always possible. The fallback
# is sigcontext.
supported_backends = ['fiber']
supported_backends = []
if host_os == 'windows'
supported_backends += ['windows']
elif host_arch == 'wasm32'
supported_backends += ['fiber']
else
if host_os != 'darwin' and cc.links(ucontext_probe)
supported_backends += ['ucontext']
+92
View File
@@ -0,0 +1,92 @@
# esp-emu
Scriptable controller for the native ESP32 board emulators (`xteink`, `e32r40t`).
## Install
From the qemu-esp-boards checkout:
```sh
uv tool install -e tools/esp-emu-cli
```
The editable install exposes `esp-emu` globally and reflects source changes immediately. On first `boot`, it locates the containing qemu-esp-boards checkout and builds `.#qemu-native-xteink` with Nix if the native emulator is missing. Set `XTEINK_QEMU_REPO` to select a different checkout or `XTEINK_QEMU` to use an explicit binary.
The build requires Nix. SD helpers require `mtools` and `dosfstools` (`mcopy` and `mkfs.fat`).
## Basic Use
```sh
esp-emu --board xteink boot firmware.bin
esp-emu --board xteink wait-log 'Entering activity: Home' --timeout 60
esp-emu --board xteink press bottom-4 bottom-4 bottom-2
esp-emu --board xteink capture screen.png
esp-emu --board xteink stop
```
State defaults to `/tmp/esp-emu-xteink-$UID`. Use a separate state for each test:
```sh
esp-emu --board xteink --state /tmp/reader-test boot firmware.bin
```
`boot` replaces an emulator already running in that state.
## SD Cards
```sh
# Create or reuse $STATE/sdcard.img
esp-emu --board xteink boot firmware.bin
# Use an existing image directly
esp-emu --board xteink boot firmware.bin --sdcard card.img
# Merge a directory into the persistent state image before every boot
esp-emu --board xteink boot firmware.bin --sdcard test-library/
# Recreate the state image before merging the directory
esp-emu --board xteink boot firmware.bin --sdcard test-library/ --fresh-sd
```
Directory imports overwrite matching files while preserving other image contents. The source directory is never modified.
## Scripts and Flows
A script is a newline-delimited list of the same commands, with blank lines and `#` comments allowed:
```text
#!/usr/bin/env -S esp-emu --board xteink
# Any board: #!/usr/bin/env -S esp-emu --board e32r40t
boot firmware.bin
wait-log "Entering activity: Home" --timeout 60
press bottom-4 bottom-4 bottom-2
wait-log "Entering activity: Settings"
capture settings.png
```
Make it executable and run it directly:
```sh
chmod +x flow.xteink
./flow.xteink
```
A file argument is treated as an implicit `run`. Explicit file, custom-state, and stdin forms also work:
```sh
esp-emu --board xteink flow.xteink
esp-emu --board xteink --state /tmp/reader-test run flow.xteink
printf '%s\n' 'boot firmware.bin' 'wait-log "ready"' | esp-emu --board xteink run
```
The outer `--state` applies to every command in the script. When the script exits, its emulator is stopped even if a command failed or the script was interrupted.
## Synchronization
- `press` waits for debounced guest press and release sampling; display waits are unnecessary between presses.
- `wait-log REGEX` waits for new serial output and acts as an assertion.
- `wait-frame [COUNT]` waits for actual e-ink refreshes when rendering itself matters.
- `wait-idle SECONDS` waits for serial silence.
Run `esp-emu --board xteink --help` or `esp-emu --board xteink COMMAND --help` for all options.
@@ -3,14 +3,14 @@ requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "xteink-emu-cli"
name = "esp-emu-cli"
version = "0.1.0"
description = "Scriptable Xteink firmware emulator controller"
description = "Scriptable ESP32 board emulator controller"
readme = "README.md"
requires-python = ">=3.11"
[project.scripts]
xteink-emu = "xteink_emu.cli:run_cli"
esp-emu = "esp_emu.cli:run"
[tool.hatch.build.targets.wheel]
packages = ["src/xteink_emu"]
packages = ["src/esp_emu"]
+70
View File
@@ -0,0 +1,70 @@
"""Per-board differences. Everything not listed here is shared by emu.py."""
RELEASED_ADC = 4095
XTEINK_BUTTONS = {
"left": ("/machine/adc", "adci[2]", 2242, RELEASED_ADC),
"right": ("/machine/adc", "adci[2]", 5, RELEASED_ADC),
"bottom-1": ("/machine/adc", "adci[1]", 3512, RELEASED_ADC),
"bottom-2": ("/machine/adc", "adci[1]", 2694, RELEASED_ADC),
"bottom-3": ("/machine/adc", "adci[1]", 1493, RELEASED_ADC),
"bottom-4": ("/machine/adc", "adci[1]", 5, RELEASED_ADC),
"power": ("/machine/gpio", "input-level[3]", False, True),
}
BOARDS = {
"xteink": {
"name": "xteink",
"qemu_env": "XTEINK_QEMU",
"qemu_binary": "qemu-system-riscv32",
"machine": "xteink",
"serial": "stdio",
"variants": ("x3", "x4"),
"memory": None, # the machine sizes its own RAM
"flash_size": 16 * 1024 * 1024,
# ESP32-C3 keeps its second stage bootloader at offset 0; ESP32 at 0x1000.
"bootloader_offset": 0x0,
# Bootloader and partition table ship with the checkout for this board.
"packaged_flash_parts": True,
"buttons": XTEINK_BUTTONS,
"console": None,
"panel": None,
# dram and iram alias the same internal SRAM, so their numbers overlap.
"memory_regions": {
"dram": (0x3FC80000, 0x60000),
"iram": (0x4037C000, 0x60000 + 16 * 1024),
"rtcram": (0x50000000, 0x2000),
},
},
"e32r40t": {
"name": "e32r40t",
"qemu_env": "E32R40T_QEMU",
"qemu_binary": "qemu-system-xtensa",
"machine": "e32r40t",
# Plain stdio drops the guest UART on this machine; the mux form works.
"serial": "mon:stdio",
"variants": (),
"memory": "4M",
"flash_size": 4 * 1024 * 1024,
"bootloader_offset": 0x1000,
"packaged_flash_parts": False,
"buttons": {},
"console": "lcd",
"panel": (320, 480),
"memory_regions": {
"dram": (0x3FFAE000, 0x52000),
"iram": (0x40080000, 0x20000),
"rtcram": (0x50000000, 0x2000),
},
},
}
DEFAULT_BOARD = "xteink"
MEMORY_REGIONS = tuple(BOARDS[DEFAULT_BOARD]["memory_regions"])
def board(name):
try:
return BOARDS[name]
except KeyError:
raise RuntimeError(f"unknown board: {name}") from None
@@ -1,4 +1,4 @@
"""Command line front end for the xteink emulator."""
"""Command line front end for the ESP32 board emulators."""
import argparse
import shlex
@@ -15,7 +15,8 @@ class ArgumentParser(argparse.ArgumentParser):
def state_dir(args):
return Path(args.state).expanduser().resolve()
state = args.state or emu.default_state_dir(args.board)
return Path(state).expanduser().resolve()
def nonnegative(value):
@@ -60,6 +61,7 @@ def launch(args, interactive):
state,
args.firmware,
card,
name=args.board,
variant=args.variant,
power_hold_ms=args.power_hold_ms,
interactive=interactive,
@@ -71,7 +73,10 @@ def launch(args, interactive):
print(f"serial log: {state / 'serial.log'}")
return
print("keys: Left/Right, 1-4 bottom buttons, P power; close the window to stop")
if emu.board(args.board)["buttons"]:
print("keys: Left/Right, 1-4 bottom buttons, P power; close the window to stop")
else:
print("click the panel to touch it; close the window to stop")
try:
returncode = process.wait()
except KeyboardInterrupt:
@@ -119,6 +124,25 @@ def release(args):
emu.set_button(state, args.button, down=False)
def tap(args):
state = state_dir(args)
emu.require_running(state)
emu.tap(state, args.x, args.y, hold_ms=args.hold_ms)
def touch(args):
state = state_dir(args)
emu.require_running(state)
emu.touch_move(state, args.x, args.y)
emu.touch_button(state, down=args.command == "touch-hold")
def drag(args):
state = state_dir(args)
emu.require_running(state)
emu.drag(state, args.x, args.y, args.to_x, args.to_y, steps=args.steps, step_ms=args.step_ms)
def wait_log(args):
line = emu.wait_for_log(state_dir(args), args.pattern, args.timeout)
print(line, end="" if line.endswith("\n") else "\n")
@@ -153,20 +177,25 @@ def memory(args):
def web(args):
serve(state_dir(args), args.host, args.port, args.log_bytes)
serve(state_dir(args), args.host, args.port, args.log_bytes, args.board)
def run_script(args):
if args.file == "-":
execute_lines(args, sys.stdin, "stdin")
return
path = Path(args.file).expanduser()
with path.open() as lines:
execute_lines(args, lines, str(path))
try:
if args.file == "-":
execute_lines(args, sys.stdin, "stdin")
return
path = Path(args.file).expanduser()
with path.open() as lines:
execute_lines(args, lines, str(path))
finally:
state = state_dir(args)
if emu.is_running(state):
emu.shutdown(state)
def execute_lines(args, lines, source):
command_parser = parser()
command_parser = parser(args.board)
for number, raw_line in enumerate(lines, 1):
try:
arguments = shlex.split(raw_line, comments=True)
@@ -181,9 +210,10 @@ def execute_lines(args, lines, source):
raise RuntimeError(f"{source}:{number}: {error}") from error
def parser():
root = ArgumentParser(prog="xteink-emu")
root.add_argument("--state", default=emu.default_state_dir(), help="emulator state directory")
def parser(board=emu.DEFAULT_BOARD):
root = ArgumentParser(prog="esp-emu")
root.add_argument("--board", choices=emu.BOARDS, default=board)
root.add_argument("--state", default=None, help="emulator state directory (default: per board)")
commands = root.add_subparsers(dest="command", required=True, parser_class=ArgumentParser)
def add_boot_arguments(command):
@@ -217,12 +247,33 @@ def parser():
command.add_argument("button", choices=emu.BUTTONS)
command.set_defaults(handler=release)
command = commands.add_parser("tap", help="tap the touch panel at a pixel position")
command.add_argument("x", type=int)
command.add_argument("y", type=int)
command.add_argument("--hold-ms", type=nonnegative, default=250)
command.set_defaults(handler=tap)
command = commands.add_parser("drag", help="press, slide across the panel and release")
command.add_argument("x", type=int)
command.add_argument("y", type=int)
command.add_argument("to_x", type=int)
command.add_argument("to_y", type=int)
command.add_argument("--steps", type=positive_int, default=16)
command.add_argument("--step-ms", type=nonnegative, default=30)
command.set_defaults(handler=drag)
for name, help_text in (("touch-hold", "press and hold the panel"), ("touch-release", "release the panel")):
command = commands.add_parser(name, help=help_text)
command.add_argument("x", type=int)
command.add_argument("y", type=int)
command.set_defaults(handler=touch)
command = commands.add_parser("wait-log", help="wait for a regular expression in new serial output")
command.add_argument("pattern")
command.add_argument("--timeout", type=nonnegative, default=30)
command.set_defaults(handler=wait_log)
command = commands.add_parser("wait-frame", help="wait for e-ink refreshes")
command = commands.add_parser("wait-frame", help="wait for panel refreshes")
command.add_argument("count", type=positive_int, nargs="?", default=1)
command.add_argument("--timeout", type=nonnegative, default=30)
command.set_defaults(handler=wait_frame)
@@ -241,7 +292,7 @@ def parser():
command.set_defaults(handler=run_script)
command = commands.add_parser("memory", help="RAM high-water marks, or dump a region with --dump")
command.add_argument("--region", choices=emu.MEMORY_REGIONS, default="dram")
command.add_argument("--region", choices=emu.MEMORY_REGIONS, default="dram") # same names on every board
command.add_argument("--dump", help="write the raw region to this file")
command.set_defaults(handler=memory)
@@ -250,23 +301,30 @@ def parser():
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)
COMMANDS.update(commands.choices)
return root
COMMANDS = set()
def command_line(arguments):
if len(arguments) == 1 and Path(arguments[0]).is_file():
return ["run", arguments[0]]
"""Shebang support - `#!/usr/bin/env -S esp-emu --board x` passes flags plus a script path and no command."""
if not COMMANDS:
parser()
if arguments and not COMMANDS.intersection(arguments) and Path(arguments[-1]).is_file():
return [*arguments[:-1], "run", arguments[-1]]
return arguments
def main():
args = parser().parse_args(command_line(sys.argv[1:]))
def main(board=emu.DEFAULT_BOARD):
args = parser(board).parse_args(command_line(sys.argv[1:]))
args.handler(args)
def run_cli():
def run():
try:
main()
except (RuntimeError, OSError) as error:
print(f"xteink-emu: {error}", file=sys.stderr)
print(f"esp-emu: {error}", file=sys.stderr)
sys.exit(1)
@@ -11,56 +11,66 @@ import tempfile
import time
from pathlib import Path
FLASH_SIZE = 16 * 1024 * 1024
from .boards import BOARDS, DEFAULT_BOARD, MEMORY_REGIONS, RELEASED_ADC, board # noqa: F401
APP_OFFSET = 0x10000
PARTITIONS_OFFSET = 0x8000
RELEASED_ADC = 4095
def project_root():
configured = os.environ.get("XTEINK_QEMU_REPO")
if configured:
return Path(configured).expanduser().resolve()
for parent in Path(__file__).resolve().parents:
if (parent / "README.xteink.md").is_file() and (parent / "scripts/assets").is_dir():
if (parent / "README.esp-boards.md").is_file() and (parent / "scripts/assets").is_dir():
return parent
raise RuntimeError("cannot locate the qemu-xteink checkout; set XTEINK_QEMU_REPO")
raise RuntimeError("cannot locate the qemu-esp-boards checkout; set XTEINK_QEMU_REPO")
PROJECT_ROOT = project_root()
DEFAULT_PATHS = {
"XTEINK_QEMU": PROJECT_ROOT / "dist/qemu-native/bin/qemu-system-riscv32",
"E32R40T_QEMU": PROJECT_ROOT / "dist/qemu-native/bin/qemu-system-xtensa",
"XTEINK_BIOS": PROJECT_ROOT / "dist/qemu-native/share/qemu",
"XTEINK_BOOTLOADER": PROJECT_ROOT / "scripts/assets/esp32c3-bootloader.bin",
"XTEINK_PARTITIONS": PROJECT_ROOT / "scripts/assets/xteink-partitions.bin",
}
BUTTONS = {
"left": ("/machine/adc", "adci[2]", 2242, RELEASED_ADC),
"right": ("/machine/adc", "adci[2]", 5, RELEASED_ADC),
"bottom-1": ("/machine/adc", "adci[1]", 3512, RELEASED_ADC),
"bottom-2": ("/machine/adc", "adci[1]", 2694, RELEASED_ADC),
"bottom-3": ("/machine/adc", "adci[1]", 1493, RELEASED_ADC),
"bottom-4": ("/machine/adc", "adci[1]", 5, RELEASED_ADC),
"power": ("/machine/gpio", "input-level[3]", False, True),
}
VARIANTS = ("x3", "x4")
# ESP32-C3 RAM windows. dram and iram alias the same internal SRAM, so their numbers overlap.
MEMORY_REGIONS = {
"dram": (0x3FC80000, 0x60000),
"iram": (0x4037C000, 0x60000 + 16 * 1024),
"rtcram": (0x50000000, 0x2000),
}
BUTTONS = BOARDS["xteink"]["buttons"]
VARIANTS = BOARDS["xteink"]["variants"]
def default_state_dir():
return Path(tempfile.gettempdir()) / f"xteink-emu-{os.getuid()}"
def default_state_dir(name=DEFAULT_BOARD):
return Path(tempfile.gettempdir()) / f"esp-emu-{name}-{os.getuid()}"
def ensure_qemu():
def board_of(state):
"""Board recorded at boot, so later commands do not have to repeat --board."""
try:
return board((Path(state) / "board").read_text().strip())
except FileNotFoundError:
return board(DEFAULT_BOARD)
def require_buttons(state, command):
spec = board_of(state)
if not spec["buttons"]:
raise RuntimeError(f"{command}: board {spec['machine']} has no buttons")
return spec
def require_touch(state, command):
spec = board_of(state)
if not spec["panel"]:
raise RuntimeError(f"{command}: board {spec['machine']} has no touch panel")
return spec
def ensure_qemu(spec=None):
"""Build the checkout's native emulator on first use unless an explicit binary was configured."""
if os.environ.get("XTEINK_QEMU") or DEFAULT_PATHS["XTEINK_QEMU"].exists():
env = (spec or BOARDS[DEFAULT_BOARD])["qemu_env"]
if os.environ.get(env) or DEFAULT_PATHS[env].exists():
return
nix = shutil.which("nix")
if not nix:
raise RuntimeError("nix is required to build the native xteink emulator")
raise RuntimeError("nix is required to build the native emulator")
output = PROJECT_ROOT / "dist/qemu-native"
output.parent.mkdir(parents=True, exist_ok=True)
result = subprocess.run([
@@ -73,7 +83,7 @@ def ensure_qemu():
str(output),
])
if result.returncode:
raise RuntimeError("failed to build the native xteink emulator")
raise RuntimeError("failed to build the native emulator")
def require_env(name):
@@ -109,7 +119,7 @@ def is_running(state):
def require_running(state):
pid = pid_from(state)
if not process_alive(pid):
raise RuntimeError(f"xteink-emu is not running in {state}")
raise RuntimeError(f"no emulator is running in {state}")
return pid
@@ -138,20 +148,37 @@ def qmp_command(state, execute, arguments=None, timeout=5):
return send(command)
def build_flash(firmware, output):
def flash_parts(firmware, spec):
"""PlatformIO writes bootloader.bin and partitions.bin beside firmware.bin, so a board
without packaged assets takes them from the app image's own build directory."""
if spec["packaged_flash_parts"]:
return require_env("XTEINK_BOOTLOADER"), require_env("XTEINK_PARTITIONS")
parts = []
for name in ("bootloader.bin", "partitions.bin"):
path = firmware.parent / name
if not path.is_file():
raise RuntimeError(f"{name} not found next to {firmware}")
parts.append(path)
return tuple(parts)
def build_flash(firmware, output, spec):
flash_size = spec["flash_size"]
app = firmware.read_bytes()
if len(app) == FLASH_SIZE:
if len(app) == flash_size:
output.write_bytes(app)
return
if not app or app[0] != 0xE9:
raise RuntimeError(f"{firmware} is not an ESP32 app image")
if APP_OFFSET + len(app) > FLASH_SIZE:
raise RuntimeError(f"{firmware} does not fit in a 16 MiB flash image")
if APP_OFFSET + len(app) > flash_size:
raise RuntimeError(f"{firmware} does not fit in a {flash_size // (1024 * 1024)} MiB flash image")
flash = bytearray(b"\xff" * FLASH_SIZE)
bootloader = require_env("XTEINK_BOOTLOADER").read_bytes()
partitions = require_env("XTEINK_PARTITIONS").read_bytes()
flash[: len(bootloader)] = bootloader
flash = bytearray(b"\xff" * flash_size)
bootloader_path, partitions_path = flash_parts(firmware, spec)
bootloader = bootloader_path.read_bytes()
partitions = partitions_path.read_bytes()
offset = spec["bootloader_offset"]
flash[offset : offset + len(bootloader)] = bootloader
flash[PARTITIONS_OFFSET : PARTITIONS_OFFSET + len(partitions)] = partitions
flash[APP_OFFSET : APP_OFFSET + len(app)] = app
output.write_bytes(flash)
@@ -166,13 +193,14 @@ def hold_power(state, hold_ms):
qmp_command(state, "qom-set", {"path": path, "property": prop, "value": released})
def launch(state, firmware, sdcard, variant="x3", power_hold_ms=2000, interactive=False):
def launch(state, firmware, sdcard, name=DEFAULT_BOARD, variant="x3", power_hold_ms=2000, interactive=False):
"""Boot QEMU in `state`, returning the process once its QMP socket answers."""
ensure_qemu()
spec = board(name)
ensure_qemu(spec)
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"emulator is already running as PID {old_pid} in {state}")
if spec["variants"] and variant not in spec["variants"]:
raise RuntimeError(f"unknown variant: {variant}")
firmware = Path(firmware).expanduser().resolve()
@@ -183,32 +211,46 @@ def launch(state, firmware, sdcard, variant="x3", power_hold_ms=2000, interactiv
raise RuntimeError(f"SD card image not found: {sdcard}")
state.mkdir(parents=True, exist_ok=True, mode=0o700)
for name in ("pid", "qmp.sock", "serial.log", "qemu.log", "wait.offset"):
(state / name).unlink(missing_ok=True)
for scratch in ("pid", "qmp.sock", "serial.log", "qemu.log", "wait.offset"):
(state / scratch).unlink(missing_ok=True)
(state / "serial.log").touch()
(state / "wait.offset").write_text("0\n")
(state / "board").write_text(f"{name}\n")
flash = state / "flash.bin"
build_flash(firmware, flash)
efuse = bytearray(1024)
efuse[0x18:0x1E] = bytes.fromhex("563412005452")
efuse[0x26] = 0x0C
(state / "efuse.bin").write_bytes(efuse)
build_flash(firmware, flash, spec)
machine = f"xteink,variant={variant}" if spec["variants"] else spec["machine"]
command = [
str(require_env("XTEINK_QEMU")),
"-machine", f"xteink,variant={variant}",
str(require_env(spec["qemu_env"])),
"-machine", machine,
"-accel", "tcg",
"-L", str(require_env("XTEINK_BIOS")),
"-display", "gtk" if interactive else "none",
"-serial", "stdio",
"-serial", spec["serial"],
"-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",
"-drive", f"file={flash},if=mtd,format=raw",
"-drive", f"file={state / 'efuse.bin'},if=none,format=raw,id=efuse",
"-global", "driver=nvram.esp32c3.efuse,property=drive,value=efuse",
"-drive", f"file={sdcard},if=sd,format=raw",
]
if spec["memory"]:
command += ["-m", spec["memory"]]
if name == "xteink":
efuse = bytearray(1024)
efuse[0x18:0x1E] = bytes.fromhex("563412005452")
efuse[0x26] = 0x0C
(state / "efuse.bin").write_bytes(efuse)
command += [
"-L", str(require_env("XTEINK_BIOS")),
"-nic", "user,model=esp32c3_wifi,mac=52:54:00:12:34:56,net=192.168.4.0/24",
"-drive", f"file={state / 'efuse.bin'},if=none,format=raw,id=efuse",
"-global", "driver=nvram.esp32c3.efuse,property=drive,value=efuse",
]
else:
# TCG is slow enough that IDF startup trips the timer-group watchdogs, and every
# reset restarts SD initialisation.
command += [
"-nic", "user,model=esp32_wifi,mac=52:54:00:12:34:56,net=192.168.4.0/24",
"-global", "driver=timer.esp32.timg,property=wdt_disable,value=true",
]
if interactive:
process = subprocess.Popen(command)
else:
@@ -229,7 +271,7 @@ def launch(state, firmware, sdcard, variant="x3", power_hold_ms=2000, interactiv
raise RuntimeError(f"QEMU exited during startup\n{message}")
try:
qmp_command(state, "query-status")
if power_hold_ms:
if power_hold_ms and spec["buttons"].get("power"):
hold_power(state, power_hold_ms)
return process
except (FileNotFoundError, ConnectionRefusedError, socket.timeout):
@@ -360,12 +402,12 @@ def await_samples(state, prop, count, timeout):
def set_button(state, name, down):
path, prop, pressed, released = BUTTONS[name]
path, prop, pressed, released = require_buttons(state, name)["buttons"][name]
qmp_command(state, "qom-set", {"path": path, "property": prop, "value": pressed if down else released})
def press(state, name, samples=8, timeout=90, hold_ms=500):
path, prop, _, _ = BUTTONS[name]
path, prop, _, _ = require_buttons(state, name)["buttons"][name]
adc = not path.endswith("gpio")
set_button(state, name, down=True)
try:
@@ -379,8 +421,60 @@ def press(state, name, samples=8, timeout=90, hold_ms=500):
await_samples(state, prop, samples, timeout)
def touch_move(state, x, y):
"""Position the pointer in panel pixels; QEMU's absolute axes are 0..0x7fff."""
width, height = require_touch(state, "touch")["panel"]
if not (0 <= x < width and 0 <= y < height):
raise RuntimeError(f"touch outside the {width}x{height} panel: {x},{y}")
def scale(value, size):
return (value * 0x7FFF + (size - 1) // 2) // (size - 1)
qmp_command(state, "input-send-event", {"events": [
{"type": "abs", "data": {"axis": "x", "value": scale(x, width)}},
{"type": "abs", "data": {"axis": "y", "value": scale(y, height)}},
]})
def touch_button(state, down):
qmp_command(state, "input-send-event", {"events": [
{"type": "btn", "data": {"button": "left", "down": down}},
]})
def tap(state, x, y, hold_ms=250):
"""Press and release at one point.
The hold matters: the guest polls the touch controller between redraws, so an
instantaneous press can fall entirely between two polls.
"""
touch_move(state, x, y)
touch_button(state, down=True)
try:
time.sleep(hold_ms / 1000)
finally:
touch_button(state, down=False)
def drag(state, x1, y1, x2, y2, steps=16, step_ms=30, settle_ms=250):
"""Press at the first point, slide to the second, release.
Interpolated for the same reason tap holds: the guest polls the touch controller
between redraws, and a jump straight to the end point is indistinguishable from a tap
there. The settle repeats tap's hold so the press itself is never missed.
"""
touch_move(state, x1, y1)
touch_button(state, down=True)
try:
time.sleep(settle_ms / 1000)
for step in range(1, steps + 1):
touch_move(state, round(x1 + (x2 - x1) * step / steps), round(y1 + (y2 - y1) * step / steps))
time.sleep(step_ms / 1000)
finally:
touch_button(state, down=False)
def dump_memory(state, region, output):
base, size = MEMORY_REGIONS[region]
base, size = board_of(state)["memory_regions"][region]
output = Path(output).expanduser().resolve()
output.parent.mkdir(parents=True, exist_ok=True)
qmp_command(state, "pmemsave", {"val": base, "size": size, "filename": str(output)}, timeout=30)
@@ -396,7 +490,7 @@ def memory_usage(state):
scratch = state / "memory.bin"
rows = []
try:
for region, (base, size) in MEMORY_REGIONS.items():
for region, (base, size) in board_of(state)["memory_regions"].items():
data = dump_memory(state, region, scratch).read_bytes()
rows.append({"region": region, "base": base, "size": size, "touched": len(data) - data.count(0)})
finally:
@@ -410,5 +504,10 @@ def screendump(state, output, settle_ms=0):
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)
arguments = {"filename": str(output), "format": "png"}
console = board_of(state)["console"]
if console:
# Console 0 is the text console on this board, so the panel must be named.
arguments["device"] = console
qmp_command(state, "screendump", arguments, timeout=30)
return output
@@ -13,7 +13,14 @@ from . import sdcard
PAGE = Path(__file__).resolve().parent / "index.html"
MAX_UPLOAD = 512 * 1024 * 1024
LONG_POLL_SECONDS = 25
UPLOADS = {"firmware": "firmware.bin", "sdcard": "sdcard.img"}
# Boards without packaged flash parts read these from beside the firmware, which is
# exactly where an upload lands, so no extra plumbing is needed to assemble the image.
UPLOADS = {
"firmware": "firmware.bin",
"sdcard": "sdcard.img",
"bootloader": "bootloader.bin",
"partitions": "partitions.bin",
}
def settings_path(state):
@@ -31,6 +38,19 @@ def save_settings(state, values):
settings_path(state).write_text(json.dumps(settings(state) | values))
def panel_point(spec, x, y):
"""Clamp a browser-supplied point into panel pixels.
The page maps clicks through a scaled image, so rounding at the edges can land a
pixel outside the panel; emu.touch_move rejects that outright.
"""
panel = spec["panel"]
if not panel:
raise RuntimeError(f"board {spec['machine']} has no touch panel")
width, height = panel
return max(0, min(int(x), width - 1)), max(0, min(int(y), height - 1))
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")
@@ -41,9 +61,16 @@ def blank_sdcard(path, size_mb):
subprocess.run([mkfs, "-F", "32", str(path)], check=True, capture_output=True)
def serve(state, host, port, log_bytes):
def serve(state, host, port, log_bytes, name=emu.DEFAULT_BOARD):
state.mkdir(parents=True, exist_ok=True, mode=0o700)
capture = state / "web-screen.png"
spec = emu.board(name)
def ensure_sdcard(card, size_mb=64):
if card.is_file():
return
blank_sdcard(card, size_mb)
save_settings(state, {"sdcard": f"blank {size_mb} MiB"})
class Handler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
@@ -76,11 +103,14 @@ def serve(state, host, port, log_bytes):
stored = settings(state)
return {
"running": emu.is_running(state),
"board": spec["machine"],
"variant": stored.get("variant", "x3"),
"firmware": stored.get("firmware"),
"sdcard": stored.get("sdcard"),
"buttons": list(emu.BUTTONS),
"variants": list(emu.VARIANTS),
"buttons": list(spec["buttons"]),
"variants": list(spec["variants"]),
"panel": list(spec["panel"]) if spec["panel"] else None,
"flashParts": not spec["packaged_flash_parts"],
}
def do_GET(self):
@@ -144,15 +174,15 @@ def serve(state, host, port, log_bytes):
query = self.query()
try:
if route == "/upload":
name = query.get("name", [""])[0]
if name not in UPLOADS:
upload = query.get("name", [""])[0]
if upload 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})
raise RuntimeError(f"empty {upload} upload")
(state / UPLOADS[upload]).write_bytes(data)
filename = query.get("filename", [upload])[0]
save_settings(state, {upload: filename})
return self.reply_json(self.status_payload())
if route == "/sdcard/blank":
@@ -168,12 +198,18 @@ def serve(state, host, port, log_bytes):
card = state / UPLOADS["sdcard"]
if not firmware.is_file():
raise RuntimeError("upload a firmware image first")
if not card.is_file():
blank_sdcard(card, 64)
save_settings(state, {"sdcard": "blank 64 MiB"})
if not spec["packaged_flash_parts"] and len(firmware.read_bytes()) != spec["flash_size"]:
missing = [
UPLOADS[part]
for part in ("bootloader", "partitions")
if not (state / UPLOADS[part]).is_file()
]
if missing:
raise RuntimeError(f"this board also needs {' and '.join(missing)}")
ensure_sdcard(card)
if emu.is_running(state):
emu.shutdown(state)
emu.launch(state, firmware, card, variant=variant, power_hold_ms=hold)
emu.launch(state, firmware, card, name=spec["name"], variant=variant, power_hold_ms=hold)
save_settings(state, {"variant": variant})
return self.reply_json(self.status_payload())
@@ -187,6 +223,7 @@ def serve(state, host, port, log_bytes):
if emu.is_running(state):
emu.shutdown(state)
image = state / UPLOADS["sdcard"]
ensure_sdcard(image) # the card can be populated before the first boot
path = query.get("path", [""])[0]
if route == "/files/write":
sdcard.write(image, path, self.body())
@@ -203,15 +240,29 @@ def serve(state, host, port, log_bytes):
return self.reply_json(self.status_payload())
if route == "/button":
name = query.get("name", [""])[0]
if name not in emu.BUTTONS:
button = query.get("name", [""])[0]
if button not in spec["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")
emu.set_button(state, button, down=edge == "down")
else:
emu.press(state, name, hold_ms=int(query.get("hold-ms", ["500"])[0]))
emu.press(state, button, hold_ms=int(query.get("hold-ms", ["500"])[0]))
return self.reply("ok")
if route == "/touch":
if not spec["panel"]:
return self.send_error(404)
emu.require_running(state)
x, y = panel_point(spec, query.get("x", ["0"])[0], query.get("y", ["0"])[0])
edge = query.get("edge", ["tap"])[0]
if edge == "tap":
emu.tap(state, x, y, hold_ms=int(query.get("hold-ms", ["250"])[0]))
else:
emu.touch_move(state, x, y)
if edge in ("down", "up"):
emu.touch_button(state, down=edge == "down")
return self.reply("ok")
except (RuntimeError, OSError, ValueError, subprocess.CalledProcessError) as error:
return self.reply(str(error), status=500)
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>xteink emulator</title>
<title>esp32 emulator</title>
<style>
:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #20211f; background: #e9e5dc; }
* { box-sizing: border-box; }
@@ -31,6 +31,8 @@ button.active { transform: translateY(2px); background: #d2752b; }
.device-frame { display: grid; grid-template-columns: auto minmax(14rem, 30rem) auto; grid-template-rows: auto auto auto; gap: .6rem; }
.screen-shell { grid-column: 2; grid-row: 2; padding: 1.1rem; border-radius: 1.2rem; background: #30312e; box-shadow: 0 1rem 2.5rem #5d584c40; }
img { display: block; width: 100%; height: auto; aspect-ratio: 528 / 792; background: #fff; image-rendering: pixelated; }
.touch img { cursor: crosshair; touch-action: none; }
.no-buttons .btn-power, .no-buttons .btn-side, .no-buttons .btn-bottom { display: none; }
.device-frame button { min-height: 0; border-radius: .6rem; font-size: .75rem; }
.btn-power { grid-column: 2; grid-row: 1; height: 1.8rem; }
.btn-side { grid-row: 2; width: 2rem; writing-mode: vertical-rl; }
@@ -79,19 +81,25 @@ img { display: block; width: 100%; height: auto; aspect-ratio: 528 / 792; backgr
<body>
<main>
<header>
<p class="eyebrow">ESP32-C3 emulator</p>
<h1>xteink X3 / X4</h1>
<p>Upload a firmware <code>.bin</code> (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.</p>
<p class="eyebrow">ESP32 emulator</p>
<h1 id="title">emulator</h1>
<p>Upload a firmware <code>.bin</code> (release app image or a full flash image) and boot it in the emulator running on this host. The SD card image persists between boots.</p>
</header>
<form id="boot-form" class="boot-panel">
<label>Firmware (.bin)
<input id="firmware" type="file" accept=".bin,application/octet-stream">
</label>
<label id="bootloader-field" hidden>Bootloader (.bin)
<input id="bootloader" type="file" accept=".bin,application/octet-stream">
</label>
<label id="partitions-field" hidden>Partitions (.bin)
<input id="partitions" type="file" accept=".bin,application/octet-stream">
</label>
<label>SD card image (optional)
<input id="sdcard" type="file" accept=".img,.bin,application/octet-stream">
</label>
<label>Device
<label id="variant-field">Device
<select id="variant"><option value="x3">X3</option><option value="x4">X4 (display stub)</option></select>
</label>
<button id="boot" type="submit">Boot firmware</button>
@@ -101,11 +109,11 @@ img { display: block; width: 100%; height: auto; aspect-ratio: 528 / 792; backgr
<p id="status" class="status" role="status">Loading.</p>
<div class="workspace">
<section class="device" aria-label="Emulated xteink device">
<div class="device-frame">
<section class="device" aria-label="Emulated device">
<div id="device-frame" class="device-frame">
<button type="button" class="btn-power" data-button="power" data-hold-ms="2000">power</button>
<button type="button" class="btn-side btn-left" data-button="left">left</button>
<div class="screen-shell"><img id="screen" alt="E-ink display"></div>
<div class="screen-shell"><img id="screen" alt="Device display"></div>
<button type="button" class="btn-side btn-right" data-button="right">right</button>
<div class="btn-bottom">
<button type="button" data-button="bottom-1">1</button>
@@ -173,6 +181,7 @@ const $ = (id) => document.getElementById(id);
const status = $("status"), screen = $("screen"), log = $("log"), follow = $("follow");
const deviceButtons = [...document.querySelectorAll("[data-button]")];
let running = false;
let panel = null;
function report(message, error) {
status.textContent = message;
@@ -190,9 +199,18 @@ function applyStatus(payload) {
// A fresh QEMU process restarts its frame counter, so drop the cursor across boots.
if (payload.running !== running) generation = null;
running = payload.running;
panel = payload.panel;
for (const button of deviceButtons) button.disabled = !running;
$("stop").disabled = !running;
$("variant").value = payload.variant;
$("title").textContent = payload.board;
document.title = payload.board + " emulator";
$("variant-field").hidden = !payload.variants.length;
$("bootloader-field").hidden = !payload.flashParts;
$("partitions-field").hidden = !payload.flashParts;
$("device-frame").classList.toggle("no-buttons", !payload.buttons.length);
$("device-frame").classList.toggle("touch", Boolean(panel));
if (panel) screen.style.aspectRatio = `${panel[0]} / ${panel[1]}`;
const parts = [running ? "Running." : "Stopped."];
if (payload.firmware) parts.push(`firmware: ${payload.firmware}`);
if (payload.sdcard) parts.push(`sd: ${payload.sdcard}`);
@@ -249,6 +267,8 @@ $("boot-form").onsubmit = async (event) => {
$("boot").disabled = true;
try {
await upload("firmware", $("firmware"));
await upload("bootloader", $("bootloader"));
await upload("partitions", $("partitions"));
await upload("sdcard", $("sdcard"));
report("Booting...", false);
applyStatus(JSON.parse(await call(`boot?variant=${$("variant").value}`, { method: "POST" })));
@@ -267,6 +287,57 @@ $("stop").onclick = async () => {
}
};
// Touch - The panel takes physical pixels regardless of guest rotation, so the image
// rect maps straight onto it. A press shorter than the guest's poll interval can fall
// between two reads, hence the minimum hold on release.
const MIN_HOLD_MS = 250;
let pressedAt = 0, dragging = false, moveInFlight = false;
function panelPoint(event) {
const rect = screen.getBoundingClientRect();
const x = Math.round((event.clientX - rect.left) / rect.width * (panel[0] - 1));
const y = Math.round((event.clientY - rect.top) / rect.height * (panel[1] - 1));
return `x=${x}&y=${y}`;
}
async function sendTouch(event, edge) {
try {
await call(`touch?${panelPoint(event)}&edge=${edge}`, { method: "POST" });
} catch (error) {
report(String(error), true);
}
}
screen.onpointerdown = async (event) => {
if (!panel || !running) return;
event.preventDefault();
screen.setPointerCapture(event.pointerId);
dragging = true;
pressedAt = Date.now();
await sendTouch(event, "down");
};
screen.onpointermove = async (event) => {
// Drop moves while one is in flight; the guest only samples between redraws anyway.
if (!dragging || moveInFlight) return;
moveInFlight = true;
try {
await sendTouch(event, "move");
} finally {
moveInFlight = false;
}
};
screen.onpointerup = async (event) => {
if (!dragging) return;
dragging = false;
const remaining = MIN_HOLD_MS - (Date.now() - pressedAt);
if (remaining > 0) await new Promise((done) => setTimeout(done, remaining));
await sendTouch(event, "up");
};
screen.onpointercancel = screen.onpointerup;
for (const button of deviceButtons) {
button.onclick = async () => {
button.classList.add("active");
+111
View File
@@ -0,0 +1,111 @@
import shutil
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from esp_emu import emu
from esp_emu.cli import command_line, parser, run_script
from esp_emu.web import panel_point, sdcard
class CliTest(unittest.TestCase):
def test_run_defaults_to_stdin_and_global_state(self):
args = parser("e32r40t").parse_args(["--state", "/tmp/example", "run"])
self.assertEqual(args.state, "/tmp/example")
self.assertEqual(args.file, "-")
self.assertEqual(args.board, "e32r40t")
def test_script_path_is_an_implicit_run(self):
with tempfile.NamedTemporaryFile() as script:
self.assertEqual(command_line([script.name]), ["run", script.name])
def test_shebang_flags_survive_the_implicit_run(self):
with tempfile.NamedTemporaryFile() as script:
arguments = command_line(["--board", "e32r40t", script.name])
self.assertEqual(arguments, ["--board", "e32r40t", "run", script.name])
self.assertEqual(parser().parse_args(arguments).board, "e32r40t")
def test_explicit_command_is_never_rewritten(self):
with tempfile.NamedTemporaryFile() as script:
self.assertEqual(command_line(["capture", script.name]), ["capture", script.name])
def test_script_stops_the_emulator_after_failure(self):
with tempfile.NamedTemporaryFile() as script:
args = parser().parse_args(["run", script.name])
with (
mock.patch("esp_emu.cli.execute_lines", side_effect=RuntimeError("failed")),
mock.patch("esp_emu.cli.emu.is_running", return_value=True),
mock.patch("esp_emu.cli.emu.shutdown") as shutdown,
self.assertRaisesRegex(RuntimeError, "failed"),
):
run_script(args)
shutdown.assert_called_once_with(emu.default_state_dir("xteink").resolve())
def test_each_board_gets_its_own_state_directory(self):
self.assertNotEqual(emu.default_state_dir("xteink"), emu.default_state_dir("e32r40t"))
def test_board_specific_commands_are_gated(self):
with tempfile.TemporaryDirectory() as temporary:
state = Path(temporary)
(state / "board").write_text("e32r40t\n")
with self.assertRaisesRegex(RuntimeError, "no buttons"):
emu.require_buttons(state, "press")
emu.require_touch(state, "tap")
(state / "board").write_text("xteink\n")
with self.assertRaisesRegex(RuntimeError, "no touch panel"):
emu.require_touch(state, "tap")
emu.require_buttons(state, "press")
def test_web_boots_the_board_the_tool_was_launched_for(self):
self.assertEqual(parser("e32r40t").parse_args(["web"]).board, "e32r40t")
self.assertEqual(parser("xteink").parse_args(["web"]).board, "xteink")
def test_touch_points_are_clamped_to_the_panel(self):
spec = emu.board("e32r40t")
self.assertEqual(panel_point(spec, 0, 0), (0, 0))
self.assertEqual(panel_point(spec, 319, 479), (319, 479))
# Rounding through a scaled image can land just past the last pixel.
self.assertEqual(panel_point(spec, 320, 480), (319, 479))
self.assertEqual(panel_point(spec, -3, -1), (0, 0))
with self.assertRaisesRegex(RuntimeError, "no touch panel"):
panel_point(emu.board("xteink"), 1, 1)
def test_flash_layout_matches_the_board(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
(root / "bootloader.bin").write_bytes(b"\xe9boot")
(root / "partitions.bin").write_bytes(b"parts")
firmware = root / "firmware.bin"
firmware.write_bytes(b"\xe9app")
output = root / "flash.bin"
spec = emu.board("e32r40t")
emu.build_flash(firmware, output, spec)
image = output.read_bytes()
self.assertEqual(len(image), spec["flash_size"])
self.assertEqual(image[0x1000:0x1005], b"\xe9boot")
self.assertEqual(image[0x8000:0x8005], b"parts")
self.assertEqual(image[0x10000:0x10004], b"\xe9app")
@unittest.skipUnless(shutil.which("mkfs.fat") and shutil.which("mcopy"), "requires dosfstools and mtools")
def test_directory_merge_overwrites_conflicts_and_preserves_other_files(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "source"
source.mkdir()
(source / "host.txt").write_text("first")
image = sdcard.create_image(root / "card.img")
sdcard.merge_directory(image, source)
sdcard.write(image, "/guest.txt", b"guest")
(source / "host.txt").write_text("second")
sdcard.merge_directory(image, source)
self.assertEqual(sdcard.read(image, "/host.txt"), b"second")
self.assertEqual(sdcard.read(image, "/guest.txt"), b"guest")
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -3,6 +3,6 @@ revision = 3
requires-python = ">=3.11"
[[package]]
name = "xteink-emu-cli"
name = "esp-emu-cli"
version = "0.1.0"
source = { editable = "." }
-91
View File
@@ -1,91 +0,0 @@
# xteink-emu
Scriptable controller for the native Xteink firmware emulator.
## Install
From the qemu-xteink checkout:
```sh
uv tool install -e tools/xteink-emu-cli
```
The editable install exposes `xteink-emu` globally and reflects source changes immediately. On first `boot`, it locates the containing qemu-xteink checkout and builds `.#qemu-native-xteink` with Nix if the native emulator is missing. Set `XTEINK_QEMU_REPO` to select a different checkout or `XTEINK_QEMU` to use an explicit binary.
The build requires Nix. SD helpers require `mtools` and `dosfstools` (`mcopy` and `mkfs.fat`).
## Basic Use
```sh
xteink-emu boot firmware.bin
xteink-emu wait-log 'Entering activity: Home' --timeout 60
xteink-emu press bottom-4 bottom-4 bottom-2
xteink-emu capture screen.png
xteink-emu stop
```
State defaults to `/tmp/xteink-emu-$UID`. Use a separate state for each test:
```sh
xteink-emu --state /tmp/reader-test boot firmware.bin
```
`boot` replaces an emulator already running in that state.
## SD Cards
```sh
# Create or reuse $STATE/sdcard.img
xteink-emu boot firmware.bin
# Use an existing image directly
xteink-emu boot firmware.bin --sdcard card.img
# Merge a directory into the persistent state image before every boot
xteink-emu boot firmware.bin --sdcard test-library/
# Recreate the state image before merging the directory
xteink-emu boot firmware.bin --sdcard test-library/ --fresh-sd
```
Directory imports overwrite matching files while preserving other image contents. The source directory is never modified.
## Scripts and Flows
A script is a newline-delimited list of the same commands, with blank lines and `#` comments allowed:
```text
#!/usr/bin/env xteink-emu
boot firmware.bin
wait-log "Entering activity: Home" --timeout 60
press bottom-4 bottom-4 bottom-2
wait-log "Entering activity: Settings"
capture settings.png
```
Make it executable and run it directly:
```sh
chmod +x flow.xteink
./flow.xteink
```
A file argument is treated as an implicit `run`. Explicit file, custom-state, and stdin forms also work:
```sh
xteink-emu flow.xteink
xteink-emu --state /tmp/reader-test run flow.xteink
printf '%s\n' 'boot firmware.bin' 'wait-log "ready"' | xteink-emu run
```
The outer `--state` applies to every command in the script.
## Synchronization
- `press` waits for debounced guest press and release sampling; display waits are unnecessary between presses.
- `wait-log REGEX` waits for new serial output and acts as an assertion.
- `wait-frame [COUNT]` waits for actual e-ink refreshes when rendering itself matters.
- `wait-idle SECONDS` waits for serial silence.
Run `xteink-emu --help` or `xteink-emu COMMAND --help` for all options.
-39
View File
@@ -1,39 +0,0 @@
import shutil
import tempfile
import unittest
from pathlib import Path
from xteink_emu.cli import command_line, parser
from xteink_emu.web import sdcard
class CliTest(unittest.TestCase):
def test_run_defaults_to_stdin_and_global_state(self):
args = parser().parse_args(["--state", "/tmp/example", "run"])
self.assertEqual(args.state, "/tmp/example")
self.assertEqual(args.file, "-")
def test_script_path_is_an_implicit_run(self):
with tempfile.NamedTemporaryFile() as script:
self.assertEqual(command_line([script.name]), ["run", script.name])
@unittest.skipUnless(shutil.which("mkfs.fat") and shutil.which("mcopy"), "requires dosfstools and mtools")
def test_directory_merge_overwrites_conflicts_and_preserves_other_files(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "source"
source.mkdir()
(source / "host.txt").write_text("first")
image = sdcard.create_image(root / "card.img")
sdcard.merge_directory(image, source)
sdcard.write(image, "/guest.txt", b"guest")
(source / "host.txt").write_text("second")
sdcard.merge_directory(image, source)
self.assertEqual(sdcard.read(image, "/host.txt"), b"second")
self.assertEqual(sdcard.read(image, "/guest.txt"), b"guest")
if __name__ == "__main__":
unittest.main()
+4
View File
@@ -1264,6 +1264,10 @@ QemuConsole *qemu_console_lookup_by_device(DeviceState *dev, uint32_t head)
uint32_t h;
QTAILQ_FOREACH(con, &consoles, next) {
/* Text consoles have no backing device, so skip rather than abort. */
if (!object_property_find(OBJECT(con), "device")) {
continue;
}
obj = object_property_get_link(OBJECT(con),
"device", &error_abort);
if (DEVICE(obj) != dev) {