initial commit

This commit is contained in:
2026-07-19 19:21:43 -04:00
commit f972fbe78b
20 changed files with 2032 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
use flake
+10
View File
@@ -0,0 +1,10 @@
dist/
firmware.bin
firmware.elf
flash16.bin
flash.bin
*.png
*.ppm
result
_scratch/
sd.img
+290
View File
@@ -0,0 +1,290 @@
# Handoff — xteink-web-emulator
Everything below is hard-won context from the session that built the native X3
emulator. If it isn't in this repo or this doc, it's gone. Read this before
touching QEMU internals. Pairs with `ROADMAP.md` (the plan) — this is the
"how it actually works and what will bite you" file.
---
## 0. TL;DR of current state
- The `xteink-x3` QEMU machine boots **unmodified canonical firmware** to the
home screen, mounts a FAT32 SD image, and takes button input. Verified with
QMP `screendump`.
- All Phase-1 device models live in **one patch**:
`qemu/patches/0001-xteink-x3-machine.patch`, applied onto espressif/qemu tag
`esp-develop-9.2.2-20260417` by `scripts/build-qemu.sh`.
- Next: X4 sibling machine → compile to WASM (qemu-wasm) → static browser UI.
---
## 1. Environment / hardware realities (READ FIRST)
- **The dev box is ~15 yr old, 4 GB RAM.** A full QEMU build is ~1660 objects
and takes a long time. **Always `-j2`** (more OOMs the linker) and use
**very long timeouts (I used 7200 s)** for any `ninja`/`pio`/`nix build`.
Do not treat a long-running build as hung.
- **First full QEMU build ≈ many minutes; incremental after a patch edit is
seconds** (only the touched `.c` + relink). Iterate incrementally; never
`rm -rf build`.
- **Nix sandbox capability leak:** running FHS/bwrap-wrapped tools (PlatformIO,
and the prebuilt release QEMU) fails with
`bwrap: Unexpected capabilities but not setuid, old file caps config?`.
Workaround — clear ambient caps:
```sh
SP=$(nix build nixpkgs#util-linux.bin --no-link --print-out-paths)/bin/setpriv
"$SP" --inh-caps=-all --ambient-caps=-all nix develop <flake> --command <cmd>
```
Our **source-built** QEMU is a normal nix-linked ELF and does NOT need this
(that's a bonus reason we build from source, not just to patch).
---
## 2. Building espressif-qemu from source (the fiddly part)
`scripts/build-qemu.sh` encodes all of this, but here's *why*:
- **Reuse nixpkgs' QEMU build env**: `nix develop nixpkgs#qemu --command …`
(or the repo devShell, which has `inputsFrom = [ pkgs.qemu ]`). Getting glib/
pixman/meson/ninja right by hand is painful; borrow qemu's.
- **slirp**: the fork *unconditionally compiles* `net/slirp.c` even with
`--disable-slirp` (build-system bug), so you must **enable** it. nixpkgs
provides libslirp; `--enable-slirp` + libslirp on `PKG_CONFIG_PATH` works, or
it falls back to the bundled subproject.
- **libgcrypt**: the ESP32-C3 crypto devices (`esp32c3_aes.c`, rsa, ds, xts) are
gated on `gcrypt.found()`. QEMU prefers gnutls and **skips gcrypt unless you
pass `--enable-gcrypt`**. Without it the build succeeds but the machine
**fails at runtime**: `unknown type 'misc.esp32c3.aes'`.
- **Meson caches `pkg_config_path`**: exporting `PKG_CONFIG_PATH` after the
first `configure` is ignored on reconfigure. Use
`meson configure build -Dpkg_config_path=…` or pass it to `./configure`.
- Config that works: `--target-list=riscv32-softmmu --enable-gcrypt
--enable-slirp --disable-werror --disable-docs --disable-tools`.
- **`/tmp/qsrc` is where I built it this session and is EPHEMERAL** (lost on
reboot). The patch is the durable artifact; `make qemu` reclones+rebuilds.
---
## 3. How espressif-qemu's esp32c3 machine is structured
- File: `hw/riscv/esp32c3.c`. It hand-builds the SoC (no device tree).
- **Catch-all MMIO**: `esp32c3_io_ops` (`esp32c3_io_read`/`write`) covers the
whole peripheral window from `ESP32C3_IO_START_ADDR` (== `DR_REG_UART_BASE` ==
`0x60000000`) for `0xd1000` bytes. **Any peripheral without a dedicated
device falls here and reads 0 / ignores writes.** That's why unmodeled
peripherals silently misbehave instead of faulting.
- Turn on `#define ESP32C3_IO_WARNING 1` (top of the file) to log every
unsupported access as `[ESP32-C3] Unsupported read/write to $ADDR`. **This is
the single most useful debugging lever.** It's noisy (megabytes/sec), so run
~12 s and bucket by address:
```sh
grep 'Unsupported read' log | sed -E 's/.*\$//' | sort | uniq -c | sort -nr | head
```
The hottest address is almost always the next thing to model. Leave it `0` in
committed code.
- Devices that **already exist** in the fork: UART, GPIO (barebones — we fleshed
it out), SPI1 (flash only), GDMA, AES/SHA/RSA/HMAC/DS/XTS, RTC_CNTL, systimer,
timg, efuse, intmatrix, cache, JTAG, TWAI, `esp_rgb` (a QEMU-specific virtual
framebuffer, NOT real X3 hardware — we disable it on the xteink machine).
- Devices that **did NOT exist** and we added: SAR ADC, SPI2, the UC8253 panel,
and real GPIO register behavior. There is **no I²C controller model** — this
matters for X3/X4 detection (see §7).
---
## 4. Peripheral register facts (discovered, verified against firmware)
Base addresses from ESP-IDF `soc/esp32c3` headers; all confirmed live.
### SAR ADC — `DR_REG_APB_SARADC_BASE = 0x60040000`
- The **original boot blocker**: firmware polls `INT_RAW` (`+0x44`) bit 31
(`ADC1_ONESHOT_DONE`) after starting a conversion; the unmodeled reg returned
0 forever → busy-loop with interrupts off → **interrupt-WDT panic**
(`Guru Meditation … Interrupt wdt timeout`). The panic dump's `S0/FP =
0x60040000` literally pointed at the ADC base.
- Model (`hw/adc/esp32c3_adc.c`): `INT_RAW` always returns done bits 31|30;
`ONETIME_SAMPLE` (`+0x20`) bits [28:25] select the channel; `1_DATA_STATUS`
(`+0x2c`) / `2_DATA_STATUS` (`+0x30`) return the selected channel's 12-bit
value. Per-channel values are QOM props `adci[*]`, default `0xfff`.
- `adc_oneshot_ll_get_raw_result` reads `adc1_data & 0xfff`.
### SPI2 (display + SD) — `DR_REG_SPI2_BASE = 0x60024000`
- **Completely different register map from SPI1** (`hw/ssi/esp32c3_spi.c` is
flash-specific — do NOT reuse it). GP-SPI2 layout: `CMD 0x00` (USR = bit24,
UPDATE = bit23), `MS_DLEN 0x1c` (bits[17:0] = bitlen-1), data buffer
`W0..W15` at `0x98..0xd4`.
- Model (`hw/ssi/esp32c3_spi2.c`): on `CMD` write with USR set, clock
`(MS_DLEN/8)+1` bytes out of W0.. onto an `SSIBus` via `ssi_transfer`, writing
RX back into the same words. **DMA path is intentionally unimplemented** — the
firmware's CPU-buffer path is enough; add DMA only if a future firmware needs
it (it'll show up as SPI2 traffic that the CPU-buffer model can't explain).
### GPIO — `DR_REG_GPIO_BASE = 0x60004000`
- The stock model only returned the strap register. We added real
`OUT 0x04` / `OUT_W1TS 0x08` / `OUT_W1TC 0x0c` / `ENABLE 0x20` (+W1TS/W1TC) /
`IN 0x3c`, with 32 named input + output qemu_irq lines
(`ESP32_GPIO_INPUT`/`ESP32_GPIO_OUTPUT`).
- **CRITICAL SUBTLETY**: an output line must read **HIGH when the pin is
configured as input** (`output_enable` bit clear), not follow the (zero) OUT
latch. Real boards have pull-ups; the X3 display **CS is pulled high** while
SD is being probed. Without this, CS floated LOW during SD init and the panel
greedily consumed the SD command bytes (`FF … 40 00 … 95`) as garbage
commands. This one bit cost real debugging time.
### SD-over-SPI — QEMU's `ssi-sd` + `sd-card-spi`
- These exist upstream; we attach them to SPI2 at CS12. But the 9.2 fork's
`ssi-sd.c` has a **bug**: CMD58 (read OCR) hardcodes the R1 byte to `0x01`
(idle) even after ACMD41 reported ready → SdFat rejects the card → firmware
shows "SD card error". Fix: track `idle` state, clear it when ACMD41 returns
0, return it for CMD58. (Upstream qemu master rewrote this path entirely; we
did the minimal equivalent + a vmstate version bump to 8.)
---
## 5. The e-ink panel model (`hw/display/xteink_x3_eink.c`)
- X3 uses a **UC8253** controller, 792×528, 99 bytes/row, **52 272 bytes/plane**.
- Protocol (from `chip/eink-x3.chip.c`, the old Wokwi model — keep it as the
reference): DC low = command, DC high = data. `0x10`=DTM1 (old plane),
`0x13`=DTM2 (new plane), `0x12`=refresh (render + pulse BUSY), `0x04`/`0x02`=
power on/off (pulse BUSY). Bit 1 = white, 0 = black. Rows arrive
**bottom-to-top** (wire row i → framebuffer row H-1-i).
- BUSY is a GPIO output from the panel back into GPIO6 (active low; we pulse it
low for 5 ms of virtual time via a QEMUTimer, then high).
- **Rendered to a QEMU graphical console** (not a custom file callback) so the
WASM front-end reuses QEMU's display surface. Capture headless with QMP
`screendump` → PPM.
- **Orientation**: the controller framebuffer (792×528 landscape) is **rotated
90° clockwise** to the physical portrait panel (**528×792**). We bake that
into the render loop (`screen_x = H-1-y`, `screen_y = x`) so the console is
already physical-orientation. Verified by eye: text reads upright at 528×792.
- **SSI gotcha**: a `TYPE_SSI_PERIPHERAL` subclass **must set `ssi->realize`**
even if empty — the base class calls it unconditionally and a null pointer
segfaults at machine init. Also set `cs_polarity = SSI_CS_LOW` and
`user_creatable = false`.
---
## 6. Machine wiring (`xteink_x3_machine_init`)
`xteink-x3` is a subclass of the `esp32c3` machine that, after the base init:
- creates the panel on `spi2.bus`, wires GPIO out 21→CS, 4→DC, 5→RST, and panel
BUSY→GPIO in 6;
- creates `ssi-sd` at CS index 1 on `spi2.bus`, wires GPIO out 12→its CS, and a
`sd-card-spi` backed by `drive_get(IF_SD, 0, 0)` (QEMU block layer — the SD
"interface" you asked for; local `-drive` now, browser blockdev later);
- **defaults both CS lines high** at init (`qemu_set_irq(cs, 1)`) so nothing is
selected before the firmware drives GPIO.
Pin facts (X3, from `freeink-sdk` `BoardConfig.h` `XTEINK_X3`): display
SCLK8/MOSI10/CS21/DC4/RST5/BUSY6; SD shares SCLK8/MOSI10, MISO7, CS12; button
ADC ladders on GPIO1 & GPIO2; power button GPIO3; UC8253 @ 16 MHz.
---
## 7. X3 vs X4 detection — the thing that wasted time
- **Both X3 and X4 ship in ONE ESP32-C3 binary.** Which panel/battery profile is
used is decided **at runtime** by an I²C fingerprint on SDA20/SCL0: presence of
BQ27220 gauge (0x55), DS3231 RTC (0x68), QMI8658 IMU (0x6B). ≥2 of 3 on two
passes ⇒ X3; 0 ⇒ X4. Result is cached in NVS (`cphw/dev_det`) and can be
overridden (`cphw/dev_ovr`: 1=X4, 2=X3).
- **QEMU models no I²C**, so detection currently returns X4 by default. The
**crosspoint-reader-lua** firmware never calls `setDisplayX3()` at all → it is
**X4-only in emulation** and drives SSD1677 commands (`0x18/0x0c/0x44/0x45…`),
which our UC8253 panel ignores (→ blank screen). Don't chase that as a bug;
it's the wrong firmware for an X3 machine.
- To test the X3 display this session I built the **canonical** firmware with a
**one-line temporary hack**: `detectDeviceTypeWithFingerprint()` in
`/tmp/crosspoint-reader-main/lib/hal/HalGPIO.cpp` returns `X3` at the top.
**THIS EDIT IS IN /tmp AND WILL BE LOST ON REBOOT.** The merged test image is
`/tmp/crosspoint-main-x3-flash.bin` (also ephemeral).
- **Production plan (no custom firmware):** the `xteink-x3` machine should expose
the three X3 I²C fingerprint chips (needs an esp32c3 I²C controller model +
trivial BQ27220/DS3231/QMI8658 stubs that return plausible WHO_AM_I/values),
OR the emulator seeds the NVS `dev_ovr` key. Either makes stock firmware
auto-select X3. The `xteink-x4` machine simply omits the fingerprint. This is
how launch-time X3/X4 selection stays firmware-agnostic.
---
## 8. Reproducibility model
- **Durable = the patch** (`qemu/patches/0001-xteink-x3-machine.patch`), verified
to `git apply` cleanly onto a pristine checkout of the pinned tag. When you
edit `/tmp/qsrc`, **regenerate it** and re-verify:
```sh
cd <qemu-src> && git add -N <new files> && git diff --binary > <repo>/qemu/patches/0001-xteink-x3-machine.patch
git apply --reverse --check <repo>/qemu/patches/0001-xteink-x3-machine.patch # must succeed
```
(Reverse-check on the dirty tree proves patch == pristine→current diff, which
is equivalent to forward-applying to pristine.)
- Firmware/SD/flash images and `/tmp/qsrc` are all disposable; `make qemu` +
`make firmware sdimage` rebuild them.
---
## 9. Debugging workflow cheat-sheet
- **Headless run + capture** (canonical loop this session):
```sh
qemu-system-riscv32 -machine xteink-x3 -L <src>/pc-bios -display none -serial null \
-qmp unix:/tmp/q.sock,server=on,wait=off \
-drive file=flash.bin,if=mtd,format=raw -drive file=sd.img,if=sd,format=raw &
sleep 200 # this CPU is slow — give boot real time
scripts/qmp-screendump.py /tmp/q.sock out.ppm
```
- **Inspect the screen**: convert with `nix shell nixpkgs#imagemagick -c magick
out.ppm out.png`; the black-pixel count is a cheap "did the screen change"
signal (home ≈ 15914, browse ≈ 11962, boot-white = 962).
- **Inject a button** (proper edge = set then clear; hold-from-boot gives no
press edge): `scripts/qmp-qom.py /tmp/q.sock set /machine/adc "adci[1]" 2760`
then `… 4095` to release. Ladder-1 midpoints: Back 3512 / Confirm 2694 /
Left 1493 / Right ~5; ladder-2: Up 2242 / Down ~5. **>3900 = no button.**
- **SD protocol trace**: flip `//#define DEBUG_SSI_SD 1` in `hw/sd/ssi-sd.c`.
- **Panel trace**: add an `info_report` in `xteink_x3_eink_transfer`
(bounded with a static counter — it's hot).
- **Symbolize a panic PC**: `llvm-addr2line -f -C -e firmware.elf 0xADDR`
(ignore DWARF "premature terminator" warnings).
- **Flash images must be 2/4/8/16 MB.** The partition table spans 16 MB (app is
<4 MB), so always pad to 16 MB (`truncate -s 16M`); a mismatched size errors
with "only 2, 4, 8, and 16MB images are supported".
---
## 10. Next steps (in priority order)
1. **X3 fingerprint or NVS seeding** (§7) so stock firmware auto-selects X3 with
no source edit. Smallest path to a genuinely firmware-agnostic X3 machine.
Needs a minimal esp32c3 I²C controller + 3 tiny I²C target stubs, OR NVS
pre-seed. This is the last thing standing between "works with a hacked build"
and "works with any downloaded binary."
2. **`xteink-x4` machine**: SSD1677 panel (different command set — `0x18` set-RAM,
`0x44/0x45` window, `0x4e/0x4f` cursor, `0x24` write, `0x22/0x20` update;
BUSY active-HIGH), X4 battery via ADC (not I²C gauge), 792×528? confirm X4
geometry from `BoardConfig.h XTEINK_X4`. Sibling machine + a second panel
device; reuse ADC/SPI2/GPIO/SD unchanged.
3. **Compile to WASM**: combine this patched fork with `ktock/qemu-wasm`'s WASM
TCG/TCI backend + Emscripten. Expect toolchain friction; the device models
themselves are portable C and shouldn't need changes. De-risk by first
building an *unpatched* esp32c3 target under qemu-wasm, then apply our patch.
4. **Static browser front-end**: `<canvas>` fed from the display surface; DOM
buttons → the ADC/GPIO QOM injection (same calls as `qmp-qom.py`); file input
→ emulated flash; preloaded/uploaded SD image → block backend; X3/X4 chosen
before boot. Must run fully from static assets (airgapped).
---
## 11. Key paths
| What | Where |
|------|-------|
| Emulator repo | `/home/evanreichard/Development/git/personal/xteink-web-emulator` |
| The patch | `qemu/patches/0001-xteink-x3-machine.patch` |
| QEMU source (ephemeral) | `_scratch/qemu-src` (via `make qemu`); was `/tmp/qsrc` this session |
| Canonical firmware (X3 hack in /tmp, ephemeral) | `/tmp/crosspoint-reader-main` (`lib/hal/HalGPIO.cpp` forced X3) |
| Lua firmware (X4-only in emu) | `/home/evanreichard/Development/git/personal/crosspoint-reader-lua` |
| Upstream firmware refs | `crosspoint-reader/crosspoint-reader` (canonical, cleaner) + its `freeink-sdk` submodule |
| espressif/qemu tag | `esp-develop-9.2.2-20260417` |
| qemu-wasm | `github.com/ktock/qemu-wasm` |
+41
View File
@@ -0,0 +1,41 @@
# PlatformIO artifacts to merge into a 16 MB ESP32-C3 flash image.
# Canonical firmware: https://github.com/crosspoint-reader/crosspoint-reader
FIRMWARE_DIR ?= ../crosspoint-reader/.pio/build/default
SD_SRC ?= ../crosspoint-reader/sdcard
QEMU_SRC ?= _scratch/qemu-src
QEMU_BIN = $(QEMU_SRC)/build/qemu-system-riscv32
.PHONY: firmware sdimage qemu run run-upstream chip clean
firmware:
esptool --chip esp32c3 merge-bin -o flash.bin \
0x0 $(FIRMWARE_DIR)/bootloader.bin \
0x8000 $(FIRMWARE_DIR)/partitions.bin \
0x10000 $(FIRMWARE_DIR)/firmware.bin
truncate -s 16M flash.bin
cp $(FIRMWARE_DIR)/firmware.elf firmware.elf
# Local-disk implementation of the SD block interface.
sdimage:
scripts/mksd.sh $(SD_SRC) sd.img
qemu:
QEMU_SRC=$(QEMU_SRC) scripts/build-qemu.sh
# Patched xteink X3 machine. Add `-display none` for a headless run.
run: qemu
$(QEMU_BIN) -machine xteink-x3 -L $(QEMU_SRC)/pc-bios \
-serial stdio -drive file=flash.bin,if=mtd,format=raw \
$(if $(wildcard sd.img),-drive file=sd.img,if=sd,format=raw)
# Unpatched release binary retained as the fast generic ESP32-C3 baseline.
run-upstream:
nix run .#run-upstream -- flash.bin
# Legacy Wokwi protocol model; retained as a small host-tested reference.
chip:
$(MAKE) -C chip
clean:
$(MAKE) -C chip clean
rm -f flash.bin flash16.bin firmware.elf
+43
View File
@@ -0,0 +1,43 @@
# xteink-web-emulator
An offline, in-browser emulator for the xteink **X3/X4** (ESP32-C3 + e-ink):
upload a firmware `.bin`, run the real machine code client-side, see the screen,
press buttons, mount a virtual SD — no server, no proprietary engine.
**Status:** native peripheral models done for the X3. Unmodified canonical
firmware boots to the home screen, mounts a FAT32 SD image, lists its files, and
responds to button presses under a patched espressif-qemu. The browser (WASM)
port is next. See [ROADMAP.md](./ROADMAP.md) for architecture and plan.
## Quick start (native QEMU)
```sh
nix develop
make qemu # clone espressif/qemu @ pinned tag, apply patch, build (JOBS=2)
make firmware sdimage # 16 MB flash.bin + FAT32 sd.img from a PlatformIO build
make run # boot the xteink-x3 machine (add `-display none` for headless)
```
Point at a firmware build with `make firmware FIRMWARE_DIR=/path/to/.pio/build/gh_release`.
`make qemu` is a long compile on modest hardware — set `JOBS` to taste.
## Contents
- `qemu/patches/0001-xteink-x3-machine.patch` — the `xteink-x3` machine and its
device models (ADC, GPIO, SPI2, UC8253 e-ink, SD-over-SPI fix), applied onto
the pinned espressif/qemu tag by `scripts/build-qemu.sh`.
- `flake.nix` — the espressif-qemu *release* binary as the fast generic baseline
(`nix run .#run-upstream -- flash.bin`), plus a devShell with QEMU's full build
environment. `Makefile``qemu`, `firmware`, `sdimage`, `run`, `chip`.
- `scripts/mksd.sh` — build the FAT32 SD backing image (local-disk impl of the
SD block interface).
- `chip/eink-x3.chip.c` — the reverse-engineered X3 e-ink protocol, first written
as a Wokwi chip and now the reference for the QEMU display device.
`make -C chip test` runs its host self-check.
## Why not Wokwi
Wokwi runs any bin in a browser and we had the e-ink chip working — but its
simulation engine is proprietary and gated behind a license/service, so it can't
be self-hosted or run airgapped. QEMU is the open path; the e-ink protocol work
carried straight over.
+107
View File
@@ -0,0 +1,107 @@
# xteink-web-emulator — architecture & roadmap
**Goal:** an offline, static-asset web page where you upload an xteink X3/X4
firmware `.bin` and it runs *client-side* — real ESP32-C3 machine code, with the
e-ink screen on a canvas, on-screen buttons, and a virtual SD card. No server,
no proprietary engine.
## Why QEMU (and why not the alternatives)
| Approach | Runs any `.bin` | Open source | Offline in browser |
|----------|:---:|:---:|:---:|
| **Wokwi** | ✅ | ❌ (closed engine, licensed) | ❌ |
| **Recompile firmware → WASM** (crosspoint-simulator + Emscripten) | ❌ source only | ✅ | ✅ |
| **QEMU (espressif fork) → WASM** | ✅ | ✅ | ✅ |
Only QEMU satisfies all three. `espressif/qemu` models the ESP32-C3 SoC and
executes real firmware; `ktock/qemu-wasm` shows QEMU compiling to WASM and
running in-browser from static files. The endgame merges those two.
## Status
**Phase 1 (native peripheral models) is done for the X3.** The unmodified
canonical firmware (`crosspoint-reader/crosspoint-reader`, X3-selected) boots to
the home screen, mounts a FAT32 SD image, lists its contents, and responds to
button presses — all under native espressif-qemu with our patch. Reproduce:
```sh
make qemu # clone espressif/qemu @ pinned tag, apply patch, build
make firmware sdimage # 16 MB flash.bin + FAT32 sd.img from a pio build
make run # boot the xteink-x3 machine (add `-display none` for headless)
```
Screenshots (via QMP `screendump`) confirmed: boot → home → Browse Files.
### Device selection (X3 vs X4)
X3 and X4 share one ESP32-C3 binary and pick a profile at runtime via an I²C
fingerprint (BQ27220 gauge + DS3231 RTC + QMI8658 IMU on SDA20/SCL0), with an
NVS override (`cphw/dev_ovr`: 1=X4, 2=X3). **The emulator picks X3 vs X4 at
launch** by starting the matching machine (`xteink-x3`, later `xteink-x4`); the
X3 machine will expose the X3 fingerprint so stock dual firmware auto-detects.
No recompiled/custom firmware — the current test build forces X3 only because
the fingerprint I²C devices aren't modelled yet.
## The interfaces (native now → browser later)
Each peripheral is modelled behind a QEMU-native seam that the WASM front-end
reuses unchanged — no bespoke abstraction layer:
| Peripheral | Native seam | Browser binding (phase 3) |
|------------|-------------|---------------------------|
| e-ink panel | `xteink-x3-eink` SSI device → QEMU graphical console (528×792, physical orientation) | `<canvas>` from the same display surface |
| SD card | `ssi-sd` + `sd-card-spi` backed by QEMU block layer (`-drive if=sd`) | browser-supplied blockdev (File/OPFS) |
| Buttons + battery | `esp32c3.adc` QOM props `adci[1]`/`adci[2]`; power button = GPIO3 input | DOM buttons → `qom-set` equivalent |
| Firmware image | flash via `-drive if=mtd` | uploaded `.bin` written to emulated flash |
## Phase 1 device models (in `qemu/patches/0001-xteink-x3-machine.patch`)
- **`esp32c3.adc`** — SAR ADC: oneshot completes immediately; per-channel value
is settable live via QOM (`adci[*]`). Feeds battery and the two resistor-ladder
button groups. Replaced the original always-done register shim.
- **`esp32_gpio`** — real OUT/ENABLE/IN registers with 32 named input + output
lines. Outputs read high while a pin is input-configured (matches the board's
pull-ups; without this, display CS floated low during SD probing).
- **`ssi.esp32c3.spi2`** — the general-purpose SPI2 controller (CPU-buffer
transactions on an SSI bus). DMA path deliberately unimplemented until needed.
- **`xteink-x3-eink`** — UC8253 panel: decodes DTM1/DTM2 planes (52 272 B each),
drives BUSY, renders to a QEMU console. Protocol from `chip/eink-x3.chip.c`.
- **`ssi-sd` fix** — track card idle state so CMD58 reports ready after ACMD41
(the 9.2 fork hardcoded idle, which SdFat rejects).
- **`xteink-x3` machine** — subclass of `esp32c3` wiring panel CS21/DC4/RST5/
BUSY6 and SD CS12 onto SPI2, and the SD card onto QEMU's block layer.
## Remaining phases
1. **X4 machine** — SSD1677 panel + X4 battery/ADC profile as a sibling machine.
The X3 command trace (`0x18/0x0c/0x44/0x45…`) is already captured.
2. **Compile the patched fork to WASM** — merge Espressif's SoC models with
qemu-wasm's WASM TCG/TCI backend; build `qemu-system-riscv32` with Emscripten.
Output: static `.wasm` + `.js` + packaged BIOS/ROM.
3. **Browser front-end (static)** — upload `.bin` → emulated flash; `<canvas>`
from the display surface; DOM buttons → ADC/GPIO injection; preloaded SD
image; launch-time X3/X4 selection. Fully airgapped.
## Repo layout
| Path | Role |
|------|------|
| `flake.nix` | espressif-qemu release pkg (`nix run .#run-upstream`) + full QEMU build devShell |
| `Makefile` | `qemu` (source build), `firmware`, `sdimage`, `run`, `chip` |
| `scripts/build-qemu.sh` | clone pinned espressif/qemu, apply patch, build riscv32-softmmu |
| `qemu/patches/` | the xteink X3 machine + device models (applied onto the pinned tag) |
| `chip/eink-x3.chip.c` | legacy Wokwi model — the reverse-engineered X3 e-ink protocol reference |
| `web/` | (phase 3) static front-end: upload + canvas + buttons |
## Debugging notes
- Headless capture: run with `-qmp unix:…` and use `scripts/qmp-screendump.py`;
the console is 528×792 (physical portrait), rotate not needed.
- Inject a button: `scripts/qmp-qom.py <sock> set /machine/adc "adci[1]" 2760`
(ladder-1 midpoints: Back 3512 / Confirm 2694 / Left 1493 / Right ~5; ladder-2
Up 2242 / Down ~5). Value > 3900 = no button.
- Symbolize a panic PC: `llvm-addr2line -f -C -e firmware.elf 0x4210d286`.
- Flash must be 16 MB — the partition table spans 16 MB even though the app fits
under 4 MB. Espressif QEMU only accepts 2/4/8/16 MB mtd images.
- Trace SD/panel: flip `DEBUG_SSI_SD` in `hw/sd/ssi-sd.c` or add an
`info_report` in `xteink_x3_eink.c` (both off by default in the patch).
+23
View File
@@ -0,0 +1,23 @@
TARGET = dist/eink-x3.chip.wasm
SOURCES = eink-x3.chip.c
.PHONY: all clean test
all: $(TARGET) dist/eink-x3.chip.json
test: dist
$(CC) -O2 -Wall -o dist/test test/test.c
./dist/test
dist:
mkdir -p dist
$(TARGET): dist $(SOURCES) wokwi-api.h
clang --target=wasm32 -nostdlib -O2 -Wall -Werror -Wno-unused-function \
-Wl,--no-entry -Wl,--import-memory -Wl,--export-table -Wl,--export-dynamic \
-o $(TARGET) $(SOURCES)
dist/eink-x3.chip.json: dist eink-x3.chip.json
cp eink-x3.chip.json dist/
clean:
rm -rf dist
+157
View File
@@ -0,0 +1,157 @@
// xteink X3 e-ink panel model for Wokwi.
//
// The X3 firmware drives a UC8179-style controller (see EInkDisplay.cpp
// setDisplayX3 / displayBuffer). This chip reconstructs the intended image
// from the SPI stream and paints it to a Wokwi framebuffer.
//
// Protocol facts this relies on (from the driver, not a datasheet):
// - SPI mode 0, MSB first. DC low = command byte, DC high = data byte.
// - Pixel planes are streamed after command 0x13 (new frame), 99 bytes/row,
// bottom-to-top (row i on the wire = framebuffer row H-1-i).
// - Bit = 1 -> white, bit = 0 -> black (0xFF = white byte).
// - Full-sync writes the plane inverted and flags it with 0x50 payload 0xA9;
// fast refresh writes it upright with 0x50 payload 0x29.
// - Command 0x12 triggers the refresh; BUSY is active LOW (idle HIGH).
//
// Commands 0x20-0x24 are LUT registers here (not RAM/activation), so they are
// ignored. Only 0x13 (plane), 0x50 (polarity), and 0x12/0x04/0x02 (refresh/
// power -> BUSY pulse) matter for rendering.
#include "wokwi-api.h"
#define WIDTH 792
#define HEIGHT 528
#define WIDTH_BYTES (WIDTH / 8) // 99
#define PLANE_SIZE (WIDTH_BYTES * HEIGHT) // 52272
#define CMD_NEW_PLANE 0x13
#define CMD_POLARITY 0x50
#define CMD_REFRESH 0x12
#define CMD_POWER_ON 0x04
#define CMD_POWER_OFF 0x02
#define BUSY_PULSE_US 5000
#define SPI_CHUNK 4096
typedef struct {
pin_t cs, dc, busy;
spi_dev_t spi;
buffer_t fb;
timer_t busy_timer;
uint8_t spi_buf[SPI_CHUNK];
bool span_is_command; // this CS-low span began with DC low
uint32_t span_pos; // byte index within the current span
uint8_t cmd; // last command byte
uint32_t plane_pos; // write cursor into plane[]
bool invert; // recovered from the 0x50 polarity selector
uint8_t plane[PLANE_SIZE];
} chip_state_t;
static chip_state_t chip;
static void render(void) {
static uint8_t row_rgba[WIDTH * 4];
for (uint32_t i = 0; i < HEIGHT; i++) {
const uint32_t y = HEIGHT - 1 - i; // un-mirror: wire is bottom-to-top
const uint8_t *src = &chip.plane[i * WIDTH_BYTES];
uint32_t o = 0;
for (uint32_t bx = 0; bx < WIDTH_BYTES; bx++) {
uint8_t v = src[bx];
if (chip.invert) v = (uint8_t)~v;
for (int bit = 0; bit < 8; bit++) {
const uint8_t px = (v & (0x80 >> bit)) ? 0xFF : 0x00; // 1=white
row_rgba[o++] = px; // R
row_rgba[o++] = px; // G
row_rgba[o++] = px; // B
row_rgba[o++] = 0xFF; // A
}
}
buffer_write(chip.fb, y * WIDTH * 4, row_rgba, WIDTH * 4);
}
}
static void pulse_busy(void) {
pin_write(chip.busy, LOW);
timer_start(chip.busy_timer, BUSY_PULSE_US, false);
}
static void on_busy_timer(void *user_data) {
(void)user_data;
pin_write(chip.busy, HIGH);
}
static void handle_command(uint8_t b) {
chip.cmd = b;
chip.span_pos = 1;
if (b == CMD_NEW_PLANE) chip.plane_pos = 0;
if (b == CMD_REFRESH) render();
if (b == CMD_REFRESH || b == CMD_POWER_ON || b == CMD_POWER_OFF) pulse_busy();
}
static void handle_data(uint8_t b) {
const uint32_t data_index = chip.span_pos - (chip.span_is_command ? 1 : 0);
if (chip.cmd == CMD_NEW_PLANE) {
if (chip.plane_pos < PLANE_SIZE) chip.plane[chip.plane_pos++] = b;
} else if (chip.cmd == CMD_POLARITY && data_index == 0) {
chip.invert = (b == 0xA9);
}
chip.span_pos++;
}
static void on_spi_done(void *user_data, uint8_t *buffer, uint32_t count) {
(void)user_data;
if (count == 0) return; // spi_stop with no data
if (chip.span_is_command) {
const uint8_t b = buffer[0];
if (chip.span_pos == 0)
handle_command(b);
else
handle_data(b);
if (pin_read(chip.cs) == LOW) spi_start(chip.spi, chip.spi_buf, 1);
} else {
for (uint32_t i = 0; i < count; i++) handle_data(buffer[i]);
if (pin_read(chip.cs) == LOW) spi_start(chip.spi, chip.spi_buf, SPI_CHUNK);
}
}
static void on_cs_change(void *user_data, pin_t pin, uint32_t value) {
(void)user_data;
(void)pin;
if (value == LOW) {
chip.span_is_command = (pin_read(chip.dc) == LOW);
chip.span_pos = 0;
spi_start(chip.spi, chip.spi_buf, chip.span_is_command ? 1 : SPI_CHUNK);
} else {
spi_stop(chip.spi);
}
}
void chip_init(void) {
const pin_t sck = pin_init("SCLK", INPUT);
const pin_t mosi = pin_init("MOSI", INPUT);
chip.cs = pin_init("CS", INPUT_PULLUP);
chip.dc = pin_init("DC", INPUT);
pin_init("RST", INPUT);
chip.busy = pin_init("BUSY", OUTPUT_HIGH);
uint32_t w = 0, h = 0;
chip.fb = framebuffer_init(&w, &h);
const spi_config_t spi_cfg = {
.sck = sck,
.mosi = mosi,
.miso = NO_PIN,
.mode = 0,
.done = on_spi_done,
.user_data = &chip,
};
chip.spi = spi_init(&spi_cfg);
const timer_config_t timer_cfg = {.callback = on_busy_timer, .user_data = &chip};
chip.busy_timer = timer_init(&timer_cfg);
const pin_watch_config_t cs_watch = {.edge = BOTH, .pin_change = on_cs_change, .user_data = &chip};
pin_watch(chip.cs, &cs_watch);
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "xteink X3 e-ink",
"author": "xteink-web-emulator",
"license": "MIT",
"pins": ["SCLK", "MOSI", "CS", "DC", "RST", "BUSY", "VCC", "GND"],
"display": {
"width": 792,
"height": 528
}
}
+77
View File
@@ -0,0 +1,77 @@
// Host self-check for the plane reconstruction (mirror + invert + bit unpack).
// Drives the chip's command/data handlers with a full-sync stream built the way
// EInkDisplay::displayBuffer emits it, then asserts the framebuffer matches the
// source image. Run: make -C chip test
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#define W 792
#define H 528
#define WB (W / 8)
static uint8_t g_fb[W * H * 4];
// Minimal Wokwi API mocks (chip only calls these at runtime).
typedef int32_t pin_t;
typedef uint32_t spi_dev_t;
typedef uint32_t timer_t;
typedef uint32_t buffer_t;
typedef struct { void *user_data; uint32_t edge; void (*pin_change)(void *, pin_t, uint32_t); } pin_watch_config_t;
typedef struct { void *user_data; pin_t sck, mosi, miso; uint32_t mode; void (*done)(void *, uint8_t *, uint32_t); uint32_t reserved[8]; } spi_config_t;
typedef struct { void *user_data; void (*callback)(void *); uint32_t reserved[8]; } timer_config_t;
enum { LOW = 0, HIGH = 1, INPUT = 0, OUTPUT = 1, INPUT_PULLUP = 2, OUTPUT_HIGH = 17, BOTH = 3 };
#define NO_PIN ((pin_t)-1)
static pin_t pin_init(const char *n, uint32_t m) { (void)n; (void)m; return 0; }
static uint32_t pin_read(pin_t p) { (void)p; return HIGH; }
static void pin_write(pin_t p, uint32_t v) { (void)p; (void)v; }
static int pin_watch(pin_t p, const pin_watch_config_t *c) { (void)p; (void)c; return 1; }
static spi_dev_t spi_init(const spi_config_t *c) { (void)c; return 0; }
static void spi_start(spi_dev_t s, uint8_t *b, uint32_t n) { (void)s; (void)b; (void)n; }
static void spi_stop(spi_dev_t s) { (void)s; }
static timer_t timer_init(const timer_config_t *c) { (void)c; return 0; }
static void timer_start(timer_t t, uint32_t us, int r) { (void)t; (void)us; (void)r; }
static buffer_t framebuffer_init(uint32_t *w, uint32_t *h) { *w = W; *h = H; return 1; }
static void buffer_write(buffer_t b, uint32_t off, uint8_t *d, uint32_t n) { (void)b; memcpy(g_fb + off, d, n); }
#define WOKWI_API_H
#include "../eink-x3.chip.c"
static void feed_command(uint8_t c) { chip.span_is_command = true; handle_command(c); }
static void feed_data(uint8_t d) { handle_data(d); }
int main(void) {
chip.fb = framebuffer_init(&(uint32_t){0}, &(uint32_t){0});
// Source image: white, with a few black pixels at known coordinates.
static uint8_t src[WB * H];
memset(src, 0xFF, sizeof(src));
const int bx[] = {16, 0, 791, 400};
const int by[] = {3, 0, 527, 260};
for (int k = 0; k < 4; k++)
src[by[k] * WB + bx[k] / 8] &= (uint8_t)~(0x80 >> (bx[k] % 8));
// Full-sync stream: 0x13 + inverted, bottom-to-top plane; polarity 0xA9; 0x12.
feed_command(0x13);
for (int i = 0; i < H; i++) {
const int srcY = H - 1 - i;
for (int x = 0; x < WB; x++) feed_data((uint8_t)~src[srcY * WB + x]);
}
feed_command(0x50);
feed_data(0xA9);
feed_data(0x07);
feed_command(0x12);
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++) {
const int black = !(src[y * WB + x / 8] & (0x80 >> (x % 8)));
const uint8_t got = g_fb[(y * W + x) * 4];
assert(got == (black ? 0x00 : 0xFF));
}
printf("ok: %dx%d reconstructed, mirror+invert correct\n", W, H);
return 0;
}
+138
View File
@@ -0,0 +1,138 @@
#include <stdint.h>
#include <stdbool.h>
#ifndef WOKWI_API_H
#define WOKWI_API_H
enum pin_value {
LOW = 0,
HIGH = 1
};
enum pin_mode {
INPUT = 0,
OUTPUT = 1,
INPUT_PULLUP = 2,
INPUT_PULLDOWN = 3,
ANALOG = 4,
OUTPUT_LOW = 16,
OUTPUT_HIGH = 17,
};
enum edge {
RISING = 1,
FALLING = 2,
BOTH = 3,
};
int __attribute__((export_name("__wokwi_api_version_1"))) __attribute__((weak)) __wokwi_api_version_1(void) { return 1; }
#ifdef __cplusplus
extern "C" {
#endif
typedef int32_t pin_t;
#define NO_PIN ((pin_t)-1)
typedef struct {
void *user_data;
uint32_t edge;
void (*pin_change)(void *user_data, pin_t pin, uint32_t value);
} pin_watch_config_t;
extern __attribute__((export_name("chipInit"))) void chip_init(void);
extern __attribute__((import_name("pinInit"))) pin_t pin_init(const char *name, uint32_t mode);
extern __attribute__((import_name("pinRead"))) uint32_t pin_read(pin_t pin);
extern __attribute__((import_name("pinWrite"))) void pin_write(pin_t pin, uint32_t value);
extern __attribute__((import_name("pinWatch"))) bool pin_watch(pin_t pin, const pin_watch_config_t *config);
extern __attribute__((import_name("pinWatchStop"))) void pin_watch_stop(pin_t pin);
extern __attribute__((import_name("pinMode"))) void pin_mode(pin_t pin, uint32_t value);
extern __attribute__((import_name("pinADCRead"))) float pin_adc_read(pin_t pin);
extern __attribute__((import_name("pinDACWrite"))) float pin_dac_write(pin_t pin, float voltage);
extern __attribute__((import_name("attrInit"))) uint32_t attr_init(const char *name, uint32_t default_value);
extern __attribute__((import_name("attrInit"))) uint32_t attr_init_float(const char *name, float default_value);
extern __attribute__((import_name("attrRead"))) uint32_t attr_read(uint32_t attr_id);
extern __attribute__((import_name("attrReadFloat"))) float attr_read_float(uint32_t attr_id);
typedef struct {
void *user_data;
uint32_t address;
pin_t scl;
pin_t sda;
bool (*connect)(void *user_data, uint32_t address, bool connect);
uint8_t (*read)(void *user_data);
bool (*write)(void *user_data, uint8_t data);
void (*disconnect)(void *user_data);
uint32_t reserved[8];
} i2c_config_t;
typedef uint32_t i2c_dev_t;
extern __attribute__((import_name("i2cInit"))) i2c_dev_t i2c_init(const i2c_config_t *config);
typedef struct {
void *user_data;
pin_t rx;
pin_t tx;
uint32_t baud_rate;
void (*rx_data)(void *user_data, uint8_t byte);
void (*write_done)(void *user_data);
uint32_t reserved[8];
} uart_config_t;
typedef uint32_t uart_dev_t;
extern __attribute__((import_name("uartInit"))) uart_dev_t uart_init(const uart_config_t *config);
extern __attribute__((import_name("uartWrite"))) bool uart_write(uart_dev_t uart, uint8_t *buffer, uint32_t count);
typedef struct {
void *user_data;
pin_t sck;
pin_t mosi;
pin_t miso;
uint32_t mode;
void (*done)(void *user_data, uint8_t *buffer, uint32_t count);
uint32_t reserved[8];
} spi_config_t;
typedef uint32_t spi_dev_t;
extern __attribute__((import_name("spiInit"))) spi_dev_t spi_init(const spi_config_t *spi_config);
extern __attribute__((import_name("spiStart"))) void spi_start(const spi_dev_t spi, uint8_t *buffer, uint32_t count);
extern __attribute__((import_name("spiStop"))) void spi_stop(const spi_dev_t spi);
typedef struct {
void *user_data;
void (*callback)(void *user_data);
uint32_t reserved[8];
} timer_config_t;
typedef uint32_t timer_t;
extern __attribute__((import_name("timerInit"))) timer_t timer_init(const timer_config_t *config);
extern __attribute__((import_name("timerStart"))) void timer_start(const timer_t timer, uint32_t micros, bool repeat);
extern __attribute__((import_name("timerStartNanos"))) void timer_start_ns_d(const timer_t timer, double nanos, bool repeat);
static void timer_start_ns(const timer_t timer, uint64_t nanos, bool repeat) {
timer_start_ns_d(timer, (double)nanos, repeat);
}
extern __attribute__((import_name("timerStop"))) void timer_stop(const timer_t timer);
extern __attribute__((import_name("getSimNanos"))) double get_sim_nanos_d(void);
static uint64_t get_sim_nanos(void) {
return (uint64_t)get_sim_nanos_d();
}
typedef uint32_t buffer_t;
extern __attribute__((import_name("framebufferInit"))) buffer_t framebuffer_init(uint32_t *pixel_width, uint32_t *pixel_height);
extern __attribute__((import_name("bufferRead"))) void buffer_read(buffer_t buffer, uint32_t offset, uint8_t *data, uint32_t data_len);
extern __attribute__((import_name("bufferWrite"))) void buffer_write(buffer_t buffer, uint32_t offset, uint8_t *data, uint32_t data_len);
#ifdef __cplusplus
}
#endif
#endif /* WOKWI_API_H */
+20
View File
@@ -0,0 +1,20 @@
{
"version": 1,
"author": "xteink-web-emulator",
"editor": "wokwi",
"parts": [
{ "type": "board-esp32-c3-devkitm-1", "id": "esp", "top": 0, "left": 0, "attrs": { "flashSize": "16" } },
{ "type": "chip-eink-x3", "id": "disp", "top": -140, "left": 200, "attrs": {} }
],
"connections": [
["esp:8", "disp:SCLK", "green", []],
["esp:10", "disp:MOSI", "blue", []],
["esp:TX", "disp:CS", "orange", []],
["esp:4", "disp:DC", "purple", []],
["esp:5", "disp:RST", "gray", []],
["esp:6", "disp:BUSY", "yellow", []],
["esp:3V3.1", "disp:VCC", "red", []],
["esp:GND.1", "disp:GND", "black", []]
],
"dependencies": {}
}
Generated
+61
View File
@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1784356753,
"narHash": "sha256-12KrbMiWLcf8m7pCvAtZh1ZrgF85ZXDXvfR/fWTKy84=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "61b7c44c4073f0b827768aff0049561b5110ea5a",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+75
View File
@@ -0,0 +1,75 @@
{
description = "Browser emulator for the xteink X3 (ESP32-C3) QEMU-based, runs any firmware .bin";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs =
{ self
, nixpkgs
, flake-utils
,
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
# Espressif's QEMU fork (ESP32-C3 SoC). Prebuilt generic-linux binary,
# made runnable on NixOS with autoPatchelfHook (no FHS/bwrap needed).
qemu-esp32c3 = pkgs.stdenv.mkDerivation {
pname = "qemu-esp32c3";
version = "esp-develop-9.2.2-20260417";
src = pkgs.fetchurl {
url = "https://github.com/espressif/qemu/releases/download/esp-develop-9.2.2-20260417/qemu-riscv32-softmmu-esp_develop_9.2.2_20260417-x86_64-linux-gnu.tar.xz";
hash = "sha256-VH8D4EcBqSy7aZ9/fQFa3B9bXvk8u5TA3ZtxB+LYTnc=";
};
sourceRoot = "qemu";
nativeBuildInputs = [ pkgs.autoPatchelfHook ];
buildInputs = with pkgs; [ pixman libgcrypt SDL2 zlib libslirp stdenv.cc.cc.lib ];
installPhase = ''
mkdir -p $out
cp -r bin share $out/
'';
};
# `nix run .#run-upstream -- flash.bin [extra qemu args]`
runScript = pkgs.writeShellScriptBin "xteink-qemu-upstream" ''
flash="''${1:?usage: run <flash.bin> [qemu args...]}"; shift || true
exec ${qemu-esp32c3}/bin/qemu-system-riscv32 \
-machine esp32c3 -L ${qemu-esp32c3}/share/qemu \
-nographic -drive file="$flash",if=mtd,format=raw "$@"
'';
in
{
packages.qemu-esp32c3 = qemu-esp32c3;
packages.default = qemu-esp32c3;
apps.run-upstream = {
type = "app";
program = "${runScript}/bin/xteink-qemu-upstream";
};
devShells.default = pkgs.mkShell {
# Reuse nixpkgs' complete QEMU build environment; Espressif's crypto
# devices additionally require libgcrypt.
inputsFrom = [ pkgs.qemu ];
packages = with pkgs; [
qemu-esp32c3
esptool
gnumake
git
libgcrypt
libslirp
dosfstools
mtools
llvmPackages.clang-unwrapped
lld
gcc
];
};
}
);
}
+868
View File
@@ -0,0 +1,868 @@
diff --git i/hw/adc/esp32c3_adc.c w/hw/adc/esp32c3_adc.c
new file mode 100644
index 0000000..8908742
--- /dev/null
+++ w/hw/adc/esp32c3_adc.c
@@ -0,0 +1,101 @@
+/*
+ * ESP32-C3 SAR ADC
+ *
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+#include "qemu/osdep.h"
+#include "qemu/module.h"
+#include "hw/adc/esp32c3_adc.h"
+
+#define ADC_ONETIME_SAMPLE 0x020
+#define ADC_DATA1 0x02c
+#define ADC_DATA2 0x030
+#define ADC_INT_RAW 0x044
+
+#define ADC_CHANNEL_SHIFT 25
+#define ADC_CHANNEL_MASK 0xf
+#define ADC_DONE (BIT(31) | BIT(30))
+#define ADC_MAX 0xfff
+
+static uint64_t esp32c3_adc_read(void *opaque, hwaddr addr, unsigned size)
+{
+ ESP32C3AdcState *s = ESP32C3_ADC(opaque);
+
+ if (addr == ADC_INT_RAW) {
+ return ADC_DONE;
+ }
+ if (addr == ADC_DATA1 || addr == ADC_DATA2) {
+ return s->channel < ESP32C3_ADC_CHANNELS
+ ? MIN(s->input[s->channel], ADC_MAX)
+ : ADC_MAX;
+ }
+ return s->regs[addr / 4];
+}
+
+static void esp32c3_adc_write(void *opaque, hwaddr addr, uint64_t value,
+ unsigned size)
+{
+ ESP32C3AdcState *s = ESP32C3_ADC(opaque);
+ s->regs[addr / 4] = value;
+
+ if (addr == ADC_ONETIME_SAMPLE) {
+ s->channel = (value >> ADC_CHANNEL_SHIFT) & ADC_CHANNEL_MASK;
+ }
+}
+
+static const MemoryRegionOps esp32c3_adc_ops = {
+ .read = esp32c3_adc_read,
+ .write = esp32c3_adc_write,
+ .endianness = DEVICE_LITTLE_ENDIAN,
+ .valid = {
+ .min_access_size = 4,
+ .max_access_size = 4,
+ },
+};
+
+static void esp32c3_adc_reset_hold(Object *obj, ResetType type)
+{
+ ESP32C3AdcState *s = ESP32C3_ADC(obj);
+ memset(s->regs, 0, sizeof(s->regs));
+ for (int i = 0; i < ESP32C3_ADC_CHANNELS; i++) {
+ s->input[i] = ADC_MAX;
+ }
+ s->channel = 0;
+}
+
+static void esp32c3_adc_init(Object *obj)
+{
+ ESP32C3AdcState *s = ESP32C3_ADC(obj);
+ SysBusDevice *sbd = SYS_BUS_DEVICE(obj);
+
+ memory_region_init_io(&s->iomem, obj, &esp32c3_adc_ops, s,
+ TYPE_ESP32C3_ADC, ESP32C3_ADC_IO_SIZE);
+ sysbus_init_mmio(sbd, &s->iomem);
+ for (int i = 0; i < ESP32C3_ADC_CHANNELS; i++) {
+ object_property_add_uint32_ptr(obj, "adci[*]", &s->input[i],
+ OBJ_PROP_FLAG_READWRITE);
+ }
+ esp32c3_adc_reset_hold(obj, RESET_TYPE_COLD);
+}
+
+static void esp32c3_adc_class_init(ObjectClass *klass, void *data)
+{
+ ResettableClass *rc = RESETTABLE_CLASS(klass);
+ rc->phases.hold = esp32c3_adc_reset_hold;
+}
+
+static const TypeInfo esp32c3_adc_info = {
+ .name = TYPE_ESP32C3_ADC,
+ .parent = TYPE_SYS_BUS_DEVICE,
+ .instance_size = sizeof(ESP32C3AdcState),
+ .instance_init = esp32c3_adc_init,
+ .class_init = esp32c3_adc_class_init,
+};
+
+static void esp32c3_adc_register_types(void)
+{
+ type_register_static(&esp32c3_adc_info);
+}
+
+type_init(esp32c3_adc_register_types)
diff --git i/hw/adc/meson.build w/hw/adc/meson.build
index 7f7acc1..541d5d3 100644
--- i/hw/adc/meson.build
+++ w/hw/adc/meson.build
@@ -1,4 +1,5 @@
system_ss.add(when: 'CONFIG_STM32F2XX_ADC', if_true: files('stm32f2xx_adc.c'))
+system_ss.add(when: 'CONFIG_RISCV_ESP32C3', if_true: files('esp32c3_adc.c'))
system_ss.add(when: 'CONFIG_ASPEED_SOC', if_true: files('aspeed_adc.c'))
system_ss.add(when: 'CONFIG_NPCM7XX', if_true: files('npcm7xx_adc.c'))
system_ss.add(when: 'CONFIG_ZYNQ', if_true: files('zynq-xadc.c'))
diff --git i/hw/display/meson.build w/hw/display/meson.build
index 36a9ace..c73f313 100644
--- i/hw/display/meson.build
+++ w/hw/display/meson.build
@@ -32,6 +32,7 @@ system_ss.add(when: 'CONFIG_CG3', if_true: files('cg3.c'))
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_VGA', if_true: files('vga.c'))
system_ss.add(when: 'CONFIG_VIRTIO', if_true: files('virtio-dmabuf.c'))
diff --git i/hw/display/xteink_x3_eink.c w/hw/display/xteink_x3_eink.c
new file mode 100644
index 0000000..bc9a18c
--- /dev/null
+++ w/hw/display/xteink_x3_eink.c
@@ -0,0 +1,165 @@
+/*
+ * xteink X3 UC8253 e-ink panel
+ *
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+#include "qemu/osdep.h"
+#include "qemu/module.h"
+#include "hw/irq.h"
+#include "hw/display/xteink_x3_eink.h"
+
+#define CMD_POWER_OFF 0x02
+#define CMD_POWER_ON 0x04
+#define CMD_DTM1 0x10
+#define CMD_REFRESH 0x12
+#define CMD_DTM2 0x13
+
+#define BUSY_TIME_NS (5 * SCALE_MS)
+
+static void xteink_x3_eink_render(XteinkX3EinkState *s)
+{
+ DisplaySurface *surface = qemu_console_surface(s->console);
+ uint32_t *pixels = (uint32_t *)surface_data(surface);
+
+ for (unsigned y = 0; y < XTEINK_X3_HEIGHT; y++) {
+ const uint8_t *row = &s->new_plane[(XTEINK_X3_HEIGHT - 1 - y) *
+ XTEINK_X3_WIDTH_BYTES];
+ for (unsigned x = 0; x < XTEINK_X3_WIDTH; x++) {
+ unsigned screen_x = XTEINK_X3_HEIGHT - 1 - y;
+ unsigned screen_y = x;
+ pixels[screen_y * XTEINK_X3_HEIGHT + screen_x] =
+ (row[x / 8] & (0x80 >> (x % 8))) ? 0x00ffffff : 0;
+ }
+ }
+
+ dpy_gfx_update(s->console, 0, 0, XTEINK_X3_HEIGHT, XTEINK_X3_WIDTH);
+}
+
+static void xteink_x3_eink_busy_done(void *opaque)
+{
+ XteinkX3EinkState *s = XTEINK_X3_EINK(opaque);
+ qemu_set_irq(s->busy, 1);
+}
+
+static void xteink_x3_eink_pulse_busy(XteinkX3EinkState *s)
+{
+ qemu_set_irq(s->busy, 0);
+ timer_mod(s->busy_timer,
+ qemu_clock_get_ns(QEMU_CLOCK_VIRTUAL) + BUSY_TIME_NS);
+}
+
+static void xteink_x3_eink_reset(DeviceState *dev)
+{
+ XteinkX3EinkState *s = XTEINK_X3_EINK(dev);
+
+ s->command = 0;
+ s->plane_pos = 0;
+ memset(s->old_plane, 0xff, sizeof(s->old_plane));
+ memset(s->new_plane, 0xff, sizeof(s->new_plane));
+ qemu_set_irq(s->busy, 1);
+ xteink_x3_eink_render(s);
+}
+
+static void xteink_x3_eink_set_dc(void *opaque, int n, int level)
+{
+ XteinkX3EinkState *s = XTEINK_X3_EINK(opaque);
+ s->dc = level;
+}
+
+static void xteink_x3_eink_set_reset(void *opaque, int n, int level)
+{
+ if (!level) {
+ xteink_x3_eink_reset(DEVICE(opaque));
+ }
+}
+
+static uint32_t xteink_x3_eink_transfer(SSIPeripheral *peripheral,
+ uint32_t value)
+{
+ XteinkX3EinkState *s = XTEINK_X3_EINK(peripheral);
+ uint8_t byte = value;
+
+ if (!s->dc) {
+ s->command = byte;
+ if (byte == CMD_DTM1 || byte == CMD_DTM2) {
+ s->plane_pos = 0;
+ } else if (byte == CMD_REFRESH) {
+ xteink_x3_eink_render(s);
+ xteink_x3_eink_pulse_busy(s);
+ } else if (byte == CMD_POWER_ON || byte == CMD_POWER_OFF) {
+ xteink_x3_eink_pulse_busy(s);
+ }
+ } else if (s->plane_pos < XTEINK_X3_PLANE_SIZE) {
+ if (s->command == CMD_DTM1) {
+ s->old_plane[s->plane_pos++] = byte;
+ } else if (s->command == CMD_DTM2) {
+ s->new_plane[s->plane_pos++] = byte;
+ }
+ }
+
+ return 0xff;
+}
+
+static void xteink_x3_eink_invalidate(void *opaque)
+{
+ xteink_x3_eink_render(XTEINK_X3_EINK(opaque));
+}
+
+static void xteink_x3_eink_update(void *opaque)
+{
+}
+
+static const GraphicHwOps xteink_x3_eink_graphics_ops = {
+ .invalidate = xteink_x3_eink_invalidate,
+ .gfx_update = xteink_x3_eink_update,
+};
+
+static void xteink_x3_eink_init(Object *obj)
+{
+ XteinkX3EinkState *s = XTEINK_X3_EINK(obj);
+
+ s->console = graphic_console_init(DEVICE(s), 0,
+ &xteink_x3_eink_graphics_ops, s);
+ dpy_gfx_replace_surface(s->console,
+ qemu_create_displaysurface(XTEINK_X3_HEIGHT, XTEINK_X3_WIDTH));
+ s->busy_timer = timer_new_ns(QEMU_CLOCK_VIRTUAL,
+ xteink_x3_eink_busy_done, s);
+ qdev_init_gpio_in_named(DEVICE(s), xteink_x3_eink_set_dc,
+ XTEINK_X3_EINK_DC, 1);
+ qdev_init_gpio_in_named(DEVICE(s), xteink_x3_eink_set_reset,
+ XTEINK_X3_EINK_RESET, 1);
+ qdev_init_gpio_out_named(DEVICE(s), &s->busy,
+ XTEINK_X3_EINK_BUSY, 1);
+}
+
+static void xteink_x3_eink_realize(SSIPeripheral *peripheral, Error **errp)
+{
+}
+
+static void xteink_x3_eink_class_init(ObjectClass *klass, void *data)
+{
+ DeviceClass *dc = DEVICE_CLASS(klass);
+ SSIPeripheralClass *ssi = SSI_PERIPHERAL_CLASS(klass);
+
+ ssi->realize = xteink_x3_eink_realize;
+ ssi->transfer = xteink_x3_eink_transfer;
+ ssi->cs_polarity = SSI_CS_LOW;
+ device_class_set_legacy_reset(dc, xteink_x3_eink_reset);
+ dc->user_creatable = false;
+}
+
+static const TypeInfo xteink_x3_eink_info = {
+ .name = TYPE_XTEINK_X3_EINK,
+ .parent = TYPE_SSI_PERIPHERAL,
+ .instance_size = sizeof(XteinkX3EinkState),
+ .instance_init = xteink_x3_eink_init,
+ .class_init = xteink_x3_eink_class_init,
+};
+
+static void xteink_x3_eink_register_types(void)
+{
+ type_register_static(&xteink_x3_eink_info);
+}
+
+type_init(xteink_x3_eink_register_types)
diff --git i/hw/gpio/esp32_gpio.c w/hw/gpio/esp32_gpio.c
index 4fea1f5..55cac08 100644
--- i/hw/gpio/esp32_gpio.c
+++ w/hw/gpio/esp32_gpio.c
@@ -19,26 +19,77 @@
#include "hw/qdev-properties.h"
#include "hw/gpio/esp32_gpio.h"
+#define GPIO_OUT 0x04
+#define GPIO_OUT_W1TS 0x08
+#define GPIO_OUT_W1TC 0x0c
+#define GPIO_ENABLE 0x20
+#define GPIO_ENABLE_W1TS 0x24
+#define GPIO_ENABLE_W1TC 0x28
+#define GPIO_IN 0x3c
+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))
+ : 1;
+ qemu_set_irq(s->output_lines[i], level);
+ }
+}
+
+static void esp32_gpio_set_input(void *opaque, int pin, int level)
+{
+ Esp32GpioState *s = ESP32_GPIO(opaque);
+ s->input_level = deposit32(s->input_level, pin, 1, !!level);
+}
static uint64_t esp32_gpio_read(void *opaque, hwaddr addr, unsigned int size)
{
Esp32GpioState *s = ESP32_GPIO(opaque);
- uint64_t r = 0;
- switch (addr) {
- case A_GPIO_STRAP:
- r = s->strap_mode;
- break;
+ switch (addr) {
+ case GPIO_OUT:
+ return s->output_level;
+ case GPIO_ENABLE:
+ return s->output_enable;
+ case A_GPIO_STRAP:
+ return s->strap_mode;
+ case GPIO_IN:
+ return s->input_level;
default:
- break;
+ return 0;
}
- return r;
}
static void esp32_gpio_write(void *opaque, hwaddr addr,
- uint64_t value, unsigned int size)
+ uint64_t value, unsigned int size)
{
+ Esp32GpioState *s = ESP32_GPIO(opaque);
+
+ switch (addr) {
+ case GPIO_OUT:
+ s->output_level = value;
+ break;
+ case GPIO_OUT_W1TS:
+ s->output_level |= value;
+ break;
+ case GPIO_OUT_W1TC:
+ s->output_level &= ~value;
+ break;
+ case GPIO_ENABLE:
+ s->output_enable = value;
+ break;
+ case GPIO_ENABLE_W1TS:
+ s->output_enable |= value;
+ break;
+ case GPIO_ENABLE_W1TC:
+ s->output_enable &= ~value;
+ break;
+ default:
+ return;
+ }
+
+ esp32_gpio_drive_outputs(s);
}
static const MemoryRegionOps uart_ops = {
@@ -49,6 +100,11 @@ static const MemoryRegionOps uart_ops = {
static void esp32_gpio_reset_hold(Object *obj, ResetType type)
{
+ Esp32GpioState *s = ESP32_GPIO(obj);
+ s->input_level = UINT32_MAX;
+ s->output_level = 0;
+ s->output_enable = 0;
+ esp32_gpio_drive_outputs(s);
}
static void esp32_gpio_realize(DeviceState *dev, Error **errp)
@@ -67,6 +123,12 @@ static void esp32_gpio_init(Object *obj)
TYPE_ESP32_GPIO, 0x1000);
sysbus_init_mmio(sbd, &s->iomem);
sysbus_init_irq(sbd, &s->irq);
+ qdev_init_gpio_in_named(DEVICE(obj), esp32_gpio_set_input,
+ ESP32_GPIO_INPUT, ESP32_GPIO_COUNT);
+ qdev_init_gpio_out_named(DEVICE(obj), s->output_lines,
+ ESP32_GPIO_OUTPUT, ESP32_GPIO_COUNT);
+
+ esp32_gpio_reset_hold(obj, RESET_TYPE_COLD);
}
static Property esp32_gpio_properties[] = {
diff --git i/hw/riscv/esp32c3.c w/hw/riscv/esp32c3.c
index ed698fb..a49eae6 100644
--- i/hw/riscv/esp32c3.c
+++ w/hw/riscv/esp32c3.c
@@ -22,6 +22,7 @@
#include "hw/riscv/boot.h"
#include "hw/riscv/numa.h"
#include "sysemu/device_tree.h"
+#include "sysemu/blockdev.h"
#include "sysemu/sysemu.h"
#include "sysemu/kvm.h"
#include "sysemu/runstate.h"
@@ -40,6 +41,8 @@
#include "hw/timer/esp32c3_timg.h"
#include "hw/timer/esp32c3_systimer.h"
#include "hw/ssi/esp32c3_spi.h"
+#include "hw/ssi/esp32c3_spi2.h"
+#include "hw/adc/esp32c3_adc.h"
#include "hw/misc/esp32c3_rtc_cntl.h"
#include "hw/misc/esp32c3_aes.h"
#include "hw/misc/esp32c3_rsa.h"
@@ -49,6 +52,8 @@
#include "hw/misc/esp32c3_jtag.h"
#include "hw/dma/esp32c3_gdma.h"
#include "hw/display/esp_rgb.h"
+#include "hw/display/xteink_x3_eink.h"
+#include "hw/sd/sd.h"
#include "hw/net/can/esp32c3_twai.h"
#define ESP32C3_IO_WARNING 0
@@ -66,6 +71,7 @@ struct Esp32C3MachineState {
EspRISCVCPU soc;
BusState periph_bus;
MemoryRegion iomem;
+ bool xteink_x3;
qemu_irq cpu_reset;
@@ -86,6 +92,8 @@ struct Esp32C3MachineState {
ESP32C3TimgState timg[2];
ESP32C3SysTimerState systimer;
ESP32C3SpiState spi1;
+ ESP32C3Spi2State spi2;
+ ESP32C3AdcState adc;
ESP32C3RtcCntlState rtccntl;
ESP32C3UsbJtagState jtag;
ESPRgbState rgb;
@@ -420,9 +428,13 @@ static void esp32c3_machine_init(MachineState *machine)
object_initialize_child(OBJECT(machine), "timg1", &ms->timg[1], TYPE_ESP32C3_TIMG);
object_initialize_child(OBJECT(machine), "systimer", &ms->systimer, TYPE_ESP32C3_SYSTIMER);
object_initialize_child(OBJECT(machine), "spi1", &ms->spi1, TYPE_ESP32C3_SPI);
+ object_initialize_child(OBJECT(machine), "spi2", &ms->spi2, TYPE_ESP32C3_SPI2);
+ object_initialize_child(OBJECT(machine), "adc", &ms->adc, TYPE_ESP32C3_ADC);
object_initialize_child(OBJECT(machine), "rtccntl", &ms->rtccntl, TYPE_ESP32C3_RTC_CNTL);
object_initialize_child(OBJECT(machine), "jtag", &ms->jtag, TYPE_ESP32C3_JTAG);
- object_initialize_child(OBJECT(machine), "rgb", &ms->rgb, TYPE_ESP_RGB);
+ if (!ms->xteink_x3) {
+ object_initialize_child(OBJECT(machine), "rgb", &ms->rgb, TYPE_ESP_RGB);
+ }
object_initialize_child(OBJECT(machine), "twai", &ms->twai, TYPE_ESP32C3_TWAI);
/* Realize all the I/O peripherals we depend on */
@@ -476,6 +488,20 @@ static void esp32c3_machine_init(MachineState *machine)
}
}
+ /* SPI2 controller (display and SD card) */
+ {
+ sysbus_realize(SYS_BUS_DEVICE(&ms->spi2), &error_fatal);
+ MemoryRegion *mr = sysbus_mmio_get_region(SYS_BUS_DEVICE(&ms->spi2), 0);
+ memory_region_add_subregion_overlap(sys_mem, DR_REG_SPI2_BASE, mr, 0);
+ }
+
+ /* SAR ADC (battery and resistor-ladder buttons) */
+ {
+ sysbus_realize(SYS_BUS_DEVICE(&ms->adc), &error_fatal);
+ MemoryRegion *mr = sysbus_mmio_get_region(SYS_BUS_DEVICE(&ms->adc), 0);
+ memory_region_add_subregion_overlap(sys_mem, DR_REG_APB_SARADC_BASE, mr, 0);
+ }
+
for (int i = 0; i < ESP32C3_UART_COUNT; ++i) {
const hwaddr uart_base[] = { DR_REG_UART_BASE, DR_REG_UART1_BASE };
sysbus_realize(SYS_BUS_DEVICE(&ms->uart[i]), &error_fatal);
@@ -641,7 +667,7 @@ static void esp32c3_machine_init(MachineState *machine)
}
/* RGB display realization */
- {
+ if (!ms->xteink_x3) {
/* Give the internal RAM memory region to the display */
ms->rgb.intram = dram;
sysbus_realize(SYS_BUS_DEVICE(&ms->rgb), &error_fatal);
@@ -659,6 +685,42 @@ static void esp32c3_machine_init(MachineState *machine)
}
+static void xteink_x3_machine_init(MachineState *machine)
+{
+ Esp32C3MachineState *ms = ESP32C3_MACHINE(machine);
+ ms->xteink_x3 = true;
+ esp32c3_machine_init(machine);
+
+ DeviceState *panel = ssi_create_peripheral(ms->spi2.bus,
+ TYPE_XTEINK_X3_EINK);
+ qdev_connect_gpio_out_named(DEVICE(&ms->gpio), ESP32_GPIO_OUTPUT, 21,
+ qdev_get_gpio_in_named(panel, SSI_GPIO_CS, 0));
+ qdev_connect_gpio_out_named(DEVICE(&ms->gpio), ESP32_GPIO_OUTPUT, 4,
+ qdev_get_gpio_in_named(panel, XTEINK_X3_EINK_DC, 0));
+ qdev_connect_gpio_out_named(DEVICE(&ms->gpio), ESP32_GPIO_OUTPUT, 5,
+ qdev_get_gpio_in_named(panel, XTEINK_X3_EINK_RESET, 0));
+ qdev_connect_gpio_out_named(panel, XTEINK_X3_EINK_BUSY, 0,
+ qdev_get_gpio_in_named(DEVICE(&ms->gpio), ESP32_GPIO_INPUT, 6));
+ device_cold_reset(panel);
+ qemu_set_irq(qdev_get_gpio_in_named(panel, SSI_GPIO_CS, 0), 1);
+
+ DeviceState *sd_adapter = qdev_new("ssi-sd");
+ qdev_prop_set_uint8(sd_adapter, "cs", 1);
+ qdev_realize_and_unref(sd_adapter, BUS(ms->spi2.bus), &error_fatal);
+ qdev_connect_gpio_out_named(DEVICE(&ms->gpio), ESP32_GPIO_OUTPUT, 12,
+ 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);
+}
+
/* Initialize machine type */
static void esp32c3_machine_class_init(ObjectClass *oc, void *data)
{
@@ -683,9 +745,23 @@ static const TypeInfo esp32c3_info = {
.class_init = esp32c3_machine_class_init,
};
+static void xteink_x3_machine_class_init(ObjectClass *oc, void *data)
+{
+ MachineClass *mc = MACHINE_CLASS(oc);
+ mc->desc = "xteink X3 (ESP32-C3, UC8253 e-ink)";
+ mc->init = xteink_x3_machine_init;
+}
+
+static const TypeInfo xteink_x3_info = {
+ .name = MACHINE_TYPE_NAME("xteink-x3"),
+ .parent = TYPE_ESP32C3_MACHINE,
+ .class_init = xteink_x3_machine_class_init,
+};
+
static void esp32c3_machine_type_init(void)
{
type_register_static(&esp32c3_info);
+ type_register_static(&xteink_x3_info);
}
type_init(esp32c3_machine_type_init);
diff --git i/hw/sd/ssi-sd.c w/hw/sd/ssi-sd.c
index 1594051..552dc2c 100644
--- i/hw/sd/ssi-sd.c
+++ w/hw/sd/ssi-sd.c
@@ -64,6 +64,7 @@ struct ssi_sd_state {
int32_t arglen;
int32_t response_pos;
int32_t stopping;
+ bool idle;
SDBus sdbus;
};
@@ -180,7 +181,7 @@ static uint32_t ssi_sd_transfer(SSIPeripheral *dev, uint32_t val)
/* CMD8/CMD58 returns R3/R7 response */
DPRINTF("Returned R3/R7\n");
s->arglen = 5;
- s->response[0] = 1;
+ s->response[0] = s->cmd == 58 ? s->idle : 1;
memcpy(&s->response[1], longresp, 4);
} else if (s->arglen != 4) {
BADF("Unexpected response to cmd %d\n", s->cmd);
@@ -236,6 +237,11 @@ static uint32_t ssi_sd_transfer(SSIPeripheral *dev, uint32_t val)
status |= SSI_SDR_PARAMETER_ERROR;
s->response[0] = status >> 8;
s->response[1] = status;
+ if (s->cmd == 0) {
+ s->idle = true;
+ } else if (s->cmd == 41 && s->response[0] == 0) {
+ s->idle = false;
+ }
DPRINTF("Card status 0x%02x\n", status);
}
s->mode = SSI_SD_PREP_RESP;
@@ -329,6 +335,9 @@ static int ssi_sd_post_load(void *opaque, int version_id)
{
ssi_sd_state *s = (ssi_sd_state *)opaque;
+ if (version_id < 8) {
+ s->idle = false;
+ }
if (s->mode > SSI_SD_SKIP_CRC16) {
return -EINVAL;
}
@@ -347,7 +356,7 @@ static int ssi_sd_post_load(void *opaque, int version_id)
static const VMStateDescription vmstate_ssi_sd = {
.name = "ssi_sd",
- .version_id = 7,
+ .version_id = 8,
.minimum_version_id = 7,
.post_load = ssi_sd_post_load,
.fields = (const VMStateField []) {
@@ -361,6 +370,7 @@ static const VMStateDescription vmstate_ssi_sd = {
VMSTATE_INT32(arglen, ssi_sd_state),
VMSTATE_INT32(response_pos, ssi_sd_state),
VMSTATE_INT32(stopping, ssi_sd_state),
+ VMSTATE_BOOL_V(idle, ssi_sd_state, 8),
VMSTATE_SSI_PERIPHERAL(ssidev, ssi_sd_state),
VMSTATE_END_OF_LIST()
}
@@ -387,6 +397,7 @@ static void ssi_sd_reset(DeviceState *dev)
s->arglen = 0;
s->response_pos = 0;
s->stopping = 0;
+ s->idle = true;
}
static void ssi_sd_class_init(ObjectClass *klass, void *data)
diff --git i/hw/ssi/esp32c3_spi2.c w/hw/ssi/esp32c3_spi2.c
new file mode 100644
index 0000000..28fcc9d
--- /dev/null
+++ w/hw/ssi/esp32c3_spi2.c
@@ -0,0 +1,101 @@
+/*
+ * ESP32-C3 general-purpose SPI2 controller
+ *
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+#include "qemu/osdep.h"
+#include "qemu/module.h"
+#include "hw/ssi/esp32c3_spi2.h"
+
+#define SPI2_CMD 0x000
+#define SPI2_MS_DLEN 0x01c
+#define SPI2_W0 0x098
+
+#define SPI2_CMD_USR BIT(24)
+#define SPI2_DLEN_MASK 0x3ffff
+#define SPI2_BUFFER_SIZE 64
+
+static void esp32c3_spi2_transfer(ESP32C3Spi2State *s)
+{
+ size_t bytes = (s->regs[SPI2_MS_DLEN / 4] & SPI2_DLEN_MASK) / 8 + 1;
+ bytes = MIN(bytes, SPI2_BUFFER_SIZE);
+
+ for (size_t i = 0; i < bytes; i++) {
+ uint32_t *word = &s->regs[SPI2_W0 / 4 + i / 4];
+ unsigned shift = (i % 4) * 8;
+ uint8_t tx = *word >> shift;
+ uint8_t rx = ssi_transfer(s->bus, tx);
+ *word = (*word & ~(0xffu << shift)) | ((uint32_t)rx << shift);
+ }
+}
+
+static uint64_t esp32c3_spi2_read(void *opaque, hwaddr addr, unsigned size)
+{
+ ESP32C3Spi2State *s = ESP32C3_SPI2(opaque);
+ return s->regs[addr / 4];
+}
+
+static void esp32c3_spi2_write(void *opaque, hwaddr addr, uint64_t value,
+ unsigned size)
+{
+ ESP32C3Spi2State *s = ESP32C3_SPI2(opaque);
+
+ if (addr == SPI2_CMD) {
+ if (value & SPI2_CMD_USR) {
+ esp32c3_spi2_transfer(s);
+ }
+ s->regs[SPI2_CMD / 4] = 0;
+ return;
+ }
+
+ s->regs[addr / 4] = value;
+}
+
+static const MemoryRegionOps esp32c3_spi2_ops = {
+ .read = esp32c3_spi2_read,
+ .write = esp32c3_spi2_write,
+ .endianness = DEVICE_LITTLE_ENDIAN,
+ .valid = {
+ .min_access_size = 4,
+ .max_access_size = 4,
+ },
+};
+
+static void esp32c3_spi2_reset_hold(Object *obj, ResetType type)
+{
+ ESP32C3Spi2State *s = ESP32C3_SPI2(obj);
+ memset(s->regs, 0, sizeof(s->regs));
+}
+
+static void esp32c3_spi2_init(Object *obj)
+{
+ ESP32C3Spi2State *s = ESP32C3_SPI2(obj);
+ SysBusDevice *sbd = SYS_BUS_DEVICE(obj);
+
+ memory_region_init_io(&s->iomem, obj, &esp32c3_spi2_ops, s,
+ TYPE_ESP32C3_SPI2, ESP32C3_SPI2_IO_SIZE);
+ sysbus_init_mmio(sbd, &s->iomem);
+ s->bus = ssi_create_bus(DEVICE(s), "spi");
+}
+
+static void esp32c3_spi2_class_init(ObjectClass *klass, void *data)
+{
+ ResettableClass *rc = RESETTABLE_CLASS(klass);
+ rc->phases.hold = esp32c3_spi2_reset_hold;
+}
+
+static const TypeInfo esp32c3_spi2_info = {
+ .name = TYPE_ESP32C3_SPI2,
+ .parent = TYPE_SYS_BUS_DEVICE,
+ .instance_size = sizeof(ESP32C3Spi2State),
+ .instance_init = esp32c3_spi2_init,
+ .class_init = esp32c3_spi2_class_init,
+};
+
+static void esp32c3_spi2_register_types(void)
+{
+ type_register_static(&esp32c3_spi2_info);
+}
+
+type_init(esp32c3_spi2_register_types)
diff --git i/hw/ssi/meson.build w/hw/ssi/meson.build
index 30f1a87..5524459 100644
--- i/hw/ssi/meson.build
+++ w/hw/ssi/meson.build
@@ -11,7 +11,7 @@ system_ss.add(when: 'CONFIG_XILINX_SPIPS', if_true: files('xilinx_spips.c'))
system_ss.add(when: 'CONFIG_XLNX_VERSAL', if_true: files('xlnx-versal-ospi.c'))
system_ss.add(when: 'CONFIG_IMX', if_true: files('imx_spi.c'))
system_ss.add(when: 'CONFIG_XTENSA_ESP32', if_true: files('esp32_spi.c'))
-system_ss.add(when: 'CONFIG_RISCV_ESP32C3', if_true: files('esp32c3_spi.c'))
+system_ss.add(when: 'CONFIG_RISCV_ESP32C3', if_true: files('esp32c3_spi.c', 'esp32c3_spi2.c'))
system_ss.add(when: 'CONFIG_XTENSA_ESP32S3', if_true: files('esp32s3_spi.c'))
system_ss.add(when: 'CONFIG_IBEX', if_true: files('ibex_spi_host.c'))
system_ss.add(when: 'CONFIG_BCM2835_SPI', if_true: files('bcm2835_spi.c'))
diff --git i/include/hw/adc/esp32c3_adc.h w/include/hw/adc/esp32c3_adc.h
new file mode 100644
index 0000000..1b914e1
--- /dev/null
+++ w/include/hw/adc/esp32c3_adc.h
@@ -0,0 +1,17 @@
+#pragma once
+
+#include "hw/sysbus.h"
+
+#define TYPE_ESP32C3_ADC "esp32c3.adc"
+OBJECT_DECLARE_SIMPLE_TYPE(ESP32C3AdcState, ESP32C3_ADC)
+
+#define ESP32C3_ADC_CHANNELS 10
+#define ESP32C3_ADC_IO_SIZE 0x1000
+
+struct ESP32C3AdcState {
+ SysBusDevice parent_obj;
+ MemoryRegion iomem;
+ uint32_t regs[ESP32C3_ADC_IO_SIZE / sizeof(uint32_t)];
+ uint32_t input[ESP32C3_ADC_CHANNELS];
+ uint8_t channel;
+};
diff --git i/include/hw/display/xteink_x3_eink.h w/include/hw/display/xteink_x3_eink.h
new file mode 100644
index 0000000..64de018
--- /dev/null
+++ w/include/hw/display/xteink_x3_eink.h
@@ -0,0 +1,29 @@
+#pragma once
+
+#include "hw/ssi/ssi.h"
+#include "qemu/timer.h"
+#include "ui/console.h"
+
+#define TYPE_XTEINK_X3_EINK "xteink-x3-eink"
+OBJECT_DECLARE_SIMPLE_TYPE(XteinkX3EinkState, XTEINK_X3_EINK)
+
+#define XTEINK_X3_EINK_DC "dc"
+#define XTEINK_X3_EINK_RESET "reset"
+#define XTEINK_X3_EINK_BUSY "busy"
+
+#define XTEINK_X3_WIDTH 792
+#define XTEINK_X3_HEIGHT 528
+#define XTEINK_X3_WIDTH_BYTES (XTEINK_X3_WIDTH / 8)
+#define XTEINK_X3_PLANE_SIZE (XTEINK_X3_WIDTH_BYTES * XTEINK_X3_HEIGHT)
+
+struct XteinkX3EinkState {
+ SSIPeripheral parent_obj;
+ QemuConsole *console;
+ QEMUTimer *busy_timer;
+ qemu_irq busy;
+ bool dc;
+ uint8_t command;
+ uint32_t plane_pos;
+ uint8_t old_plane[XTEINK_X3_PLANE_SIZE];
+ uint8_t new_plane[XTEINK_X3_PLANE_SIZE];
+};
diff --git i/include/hw/gpio/esp32_gpio.h w/include/hw/gpio/esp32_gpio.h
index 163b41a..e2f52ad 100644
--- i/include/hw/gpio/esp32_gpio.h
+++ w/include/hw/gpio/esp32_gpio.h
@@ -14,12 +14,20 @@ 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_INPUT "gpio-in"
+#define ESP32_GPIO_OUTPUT "gpio-out"
+
typedef struct Esp32GpioState {
SysBusDevice parent_obj;
MemoryRegion iomem;
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;
} Esp32GpioState;
typedef struct Esp32GpioClass {
diff --git i/include/hw/ssi/esp32c3_spi2.h w/include/hw/ssi/esp32c3_spi2.h
new file mode 100644
index 0000000..2155c36
--- /dev/null
+++ w/include/hw/ssi/esp32c3_spi2.h
@@ -0,0 +1,17 @@
+#pragma once
+
+#include "hw/ssi/ssi.h"
+#include "hw/sysbus.h"
+
+#define TYPE_ESP32C3_SPI2 "ssi.esp32c3.spi2"
+OBJECT_DECLARE_SIMPLE_TYPE(ESP32C3Spi2State, ESP32C3_SPI2)
+
+#define ESP32C3_SPI2_IO_SIZE 0x1000
+#define ESP32C3_SPI2_REG_WORDS (ESP32C3_SPI2_IO_SIZE / sizeof(uint32_t))
+
+struct ESP32C3Spi2State {
+ SysBusDevice parent_obj;
+ MemoryRegion iomem;
+ SSIBus *bus;
+ uint32_t regs[ESP32C3_SPI2_REG_WORDS];
+};
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SRC="${QEMU_SRC:-$ROOT/_scratch/qemu-src}"
TAG="esp-develop-9.2.2-20260417"
PATCH="$ROOT/qemu/patches/0001-xteink-x3-machine.patch"
JOBS="${JOBS:-2}"
if [ ! -d "$SRC/.git" ]; then
git clone --depth 1 --branch "$TAG" https://github.com/espressif/qemu "$SRC"
fi
cd "$SRC"
if git apply --check "$PATCH" 2>/dev/null; then
git apply "$PATCH"
elif ! git apply --reverse --check "$PATCH" 2>/dev/null; then
echo "QEMU source has changes that conflict with $PATCH" >&2
exit 1
fi
if [ ! -f build/build.ninja ]; then
./configure \
--target-list=riscv32-softmmu \
--enable-gcrypt \
--enable-slirp \
--disable-werror \
--disable-docs \
--disable-tools
fi
ninja -C build -j"$JOBS" qemu-system-riscv32
printf '%s\n' "$SRC/build/qemu-system-riscv32"
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Build a FAT32 SD-card image from a local directory — the local-disk backing
# for the emulated SD. QEMU's block layer is the interface: this same image is
# swapped for a browser-supplied blockdev (File/OPFS) in the WASM build.
#
# scripts/mksd.sh [src-dir] [out-img] (SIZE_MB env overrides size)
set -euo pipefail
SRC="${1:-../crosspoint-reader-lua/sdcard}"
OUT="${2:-sd.img}"
SIZE_MB="${SIZE_MB:-64}"
[ -d "$SRC" ] || { echo "no such dir: $SRC" >&2; exit 1; }
OUT_ABS="$(realpath -m "$OUT")"
rm -f "$OUT_ABS"
truncate -s "${SIZE_MB}M" "$OUT_ABS"
mkfs.vfat -F 32 -n XTEINK "$OUT_ABS" >/dev/null
# mcopy the tree in (recursive, no per-file prompts).
( cd "$SRC" && for e in *; do [ -e "$e" ] && mcopy -i "$OUT_ABS" -s -Q -o "$e" ::; done )
echo "wrote $OUT (${SIZE_MB}M FAT32) from $SRC"
mdir -i "$OUT_ABS" :: | sed 's/^/ /'
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env python3
# Exercise the button/ADC QOM interface: qmp-qom.py <sock> get|set <path> <prop> [value]
import json
import socket
import sys
sock = socket.socket(socket.AF_UNIX)
sock.connect(sys.argv[1])
f = sock.makefile("rw")
json.loads(f.readline())
def cmd(command):
f.write(json.dumps(command) + "\n")
f.flush()
return json.loads(f.readline())
cmd({"execute": "qmp_capabilities"})
op, path, prop = sys.argv[2], sys.argv[3], sys.argv[4]
if op == "set":
print(cmd({"execute": "qom-set", "arguments": {
"path": path, "property": prop, "value": int(sys.argv[5])}}))
else:
print(cmd({"execute": "qom-get", "arguments": {
"path": path, "property": prop}}))
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env python3
import json
import socket
import sys
sock = socket.socket(socket.AF_UNIX)
sock.connect(sys.argv[1])
f = sock.makefile("rw")
json.loads(f.readline())
for command in (
{"execute": "qmp_capabilities"},
{"execute": "screendump", "arguments": {"filename": sys.argv[2]}},
):
f.write(json.dumps(command) + "\n")
f.flush()
response = json.loads(f.readline())
if "error" in response:
raise RuntimeError(response["error"])
+10
View File
@@ -0,0 +1,10 @@
# Point `firmware`/`elf` at any xteink-X3 build. `make firmware` produces
# flash.bin + firmware.elf from a local crosspoint-reader-lua PlatformIO build.
[wokwi]
version = 1
firmware = 'flash.bin'
elf = 'firmware.elf'
[[chip]]
name = 'eink-x3'
binary = 'chip/dist/eink-x3.chip.wasm'