A simulator for TRMNL devices that runs unmodified compiled firmware: the merged image
you would flash at 0 (e.g. FW1.8.17-trmnl.bin), plus its ELF, the same path with the .elf
extension (FW1.8.17-trmnl.elf). It gives you a window with the e-paper display and an HTTP
control API for automated tests (trmnl-spec),
locally or in GitHub Actions.
You always say which image to run (trmnl-sim <image>). The board is the PlatformIO env the
firmware was built with: the -<env> in the image's file name (FW1.8.17-trmnl.bin), else
--env. Started without an image, the window asks for it (setting the board from its name
where it can), and optionally the MAC, whether to erase the flash, and hot reload. It offers the last
choice again, apart from erasing (kept in trmnl-sim/preferences.json in your config
directory).
The firmware's post-build steps write the image and its ELF; envs without a merge step in
the firmware's platformio.ini (e.g. trmnl_test, local, WAVESHARE_397) have none.
TRMNL devices:
trmnltrmnl_4clrTRMNL_Xtrmnl_gen2trmnl_gen2_4clr
BYOD boards (the firmware's other device_list[] rows):
seeed_xiao_esp32c3TRMNL_7inch5_OG_DIY_KitTRMNL_7inch5_OG_DIY_Kit_3CLRTRMNL_7inch5_OG_DIY_Kit_6CLRTRMNL_4inch26_DIY_Kitseeed_reTerminal_E1001seeed_reTerminal_E1002seeed_reTerminal_E1004seeed_stickyxteink_x4xteink_x3WAVESHARE_397m5_paper_monom5_paper_colorTRMNL_X_PAPERS3TRMNL_X_LILYGO_T5PROtrmnl_steamTRMNL_X_SENSORIAC5
Common to all devices:
| Part | How |
|---|---|
| Boot | The real mask ROM (from Espressif's ROM ELFs) and your 2nd-stage bootloader: partition table, OTA slot selection, SHA-256 check, flash MMU, deep-sleep wake stubs |
| WiFi | A high-level model replaces the binary driver (scans, joins, soft-AP); lwIP, DHCP, DNS, TLS, AsyncTCP and the captive portal are the firmware's own |
| Network | A user-mode router/NAT (vnet) to the internet, or --offline. The host is 10.0.2.2; an NTP server at 10.0.2.123 gives the host's clock (offline, NTP names resolve to it) |
| Sleep | Deep sleep (timer and GPIO wake, RTC memory, correct wake cause) and light sleep (the radio is off: over 36 s of it with no 103 ms awake in between loses the AP, as a long screen wipe does) |
| Persistence | Flash is a file, so credentials, API key and SPIFFS/LittleFS survive restarts; --erase gives a factory-fresh device |
- Rust (stable, 1.85+).
- A PlatformIO build of the firmware (
pio run -e trmnl,-e TRMNL_X, ... or a BYOD board's env). The X build also needs itslittlefs.bin, which its post-build script downloads. - The chips' mask ROM ELFs: the C3 and S3 ones come with PlatformIO's
tool-esp-rom-elfs; the C5's production-silicon ROM (esp32c5_rev100_rom.elf) comes from Espressif's esp-rom-elfs, whichscripts/fetch-rom-elfs.sh(run bybin/setup) puts inrom/.--romorTRMNL_SIM_ROMoverrides the search. - PlatformIO needs Python; the integration tests (trmnl-spec) need Ruby 3.2+ with Bundler.
bin/setup # install Rust (rustup), a C toolchain, the ESP32 ROM ELFs
bin/build # cargo build --release
bin/sim # build, then run; the window asks for the image and the board
bin/sim ../trmnl-firmware/.pio/build/trmnl/FW1.8.17-trmnl.bin # ... or say which
bin/sim ../trmnl-firmware/.pio/build/TRMNL_X/FW1.8.17-TRMNL_X.bin --erase # extra arguments go to trmnl-sim
bin/test # fmt, clippy, unit tests (the integration tests: ../trmnl-spec, rake spec)Or by hand:
cargo build --release
./target/release/trmnl-sim ../trmnl-firmware/.pio/build/trmnl/FW1.8.17-trmnl.bin # TRMNL OG
./target/release/trmnl-sim ../trmnl-firmware/.pio/build/trmnl_4clr/FW1.8.17-trmnl_4clr.bin # TRMNL BWRY
./target/release/trmnl-sim ../trmnl-firmware/.pio/build/TRMNL_X/FW1.8.17-TRMNL_X.bin # TRMNL X
./target/release/trmnl-sim ../trmnl-firmware/.pio/build/seeed_reTerminal_E1002/FW1.8.17-seeed_reTerminal_E1002.bin # reTerminal E1002
./target/release/trmnl-sim ../trmnl-firmware/.pio/build/trmnl_gen2/FW1.8.17-trmnl_gen2.bin # TRMNL OG gen 2 (ESP32-C5)The GUI's left utility panel has tabs for Server (the built-in mock server),
Faults, NVS and BLE (the mock Bluetooth controller's state, the decoded
advertisement, and a manual central for raw ATT exchanges); a tab is marked * while
something in it is live. Click the selected tab again to collapse the panel. NVS
shows the firmware's saved values, grouped by partition and namespace. Search by key, type, or value, copy values, and refresh
manually or automatically. Values are unmasked; blobs appear as hexadecimal bytes.
While the firmware is in deep sleep, you can add, edit, or delete preferences. Changes
are saved immediately without waking the device and take effect on its next wake.
See Preferences for the HTTP API and editing constraints.
A fresh device (--erase) boots into WiFi setup. Its portal is at
http://127.0.0.1:8080/: pick TRMNL-Sim (any password but fail) and a server. Use
--mac D8:3B:DA:12:34:56 to run as your own device.
The control API (POST /faults), --faults JSON and the Ruby client inject
faults:
| Fault | JSON (POST /faults, --faults) |
|
|---|---|---|
| Latency | {"net": {"latency_ms": 300}} |
added to every packet towards the device |
| Packet loss | {"net": {"loss": 0.1}} |
each packet, each direction |
| Bandwidth | {"net": {"bandwidth_bps": 16000}} |
bytes per second, each direction |
| DNS failure | {"net": {"dns": "servfail"}} |
or nxdomain, empty, timeout |
| No internet | {"net": {"no_internet": true}} |
WiFi and DHCP work; everything else times out |
| Internet down | {"net": {"offline": true}} |
only the host is reachable, like --offline |
| Cut connections | {"net": {"tcp_cut": {"after_bytes": 20000, "stall": false, "port": 8080}}} |
new TCP connections get a RST (or stall) after N bytes towards the device |
| Power loss | {"power_loss": {"partition": "nvs", "op": "program", "nth": 3, "cut": "torn"}} |
one-shot power cut at the nth flash op (any, program, erase) in a partition or range; cut: before, torn (half written) or after |
| I2C device absent | {"i2c_absent": [85]} |
NACKs everything (X: 0x55 gauge, 0x44 touch bar, 0x20 expander, 0x68 PMIC) |
| Panel stuck | {"panel_busy_stuck": true} |
OG/BWRY: BUSY held; X: the PMIC never reports power good |
| Modem unresponsive | {"modem_unresponsive": true} |
X: the modem ignores AT commands |
| Modem AT errors | {"modem_at_errors": ["AT+CWMODE"]} |
X: ERROR for AT commands starting with these |
| Chip temperature | {"chip_temp_c": 60} |
the on-chip sensor reads this instead of 25 °C |
| Fuel gauge | {"gauge_reset": true} |
X: the BQ27427 has a power-on reset (once) |
| Touch bar | {"touch_bar": "reset"} |
X: the IQS323 resets (once); "lockup", "ati_error" |
POST /faults merges (null clears one) and DELETE /faults clears all. Faults are the
environment, not device state: save points don't record them. Network faults also apply to
the X's modem requests (but packet loss). HTTP faults (500s, bad JSON, cut or slow bodies) are
set per path on the Ruby mock server: mock.set_fault("/api/display", status: 500).
A power cut shows on the console as [sim] power lost: program #3 at 0xa060 ...;
GET /faults lists the partition table and flash program/erase counts, and
RUST_LOG=flash=trace logs every program and erase.
--mock-server (port 8090; =0 for any) starts a TRMNL server in the simulator, so you can
drive the device's content without a trmnl.app account. The device URL is
http://10.0.2.2:8090; keep the port fixed, since the device remembers the URL.
Images added to it are resized and dithered to what the panel takes (1-bit BMP on the OG,
4-color PNG on the BWRY, 4-bit PNG on the X) with server-style filenames. The control API's
/mock endpoints set the images and playlist, the display response (refresh
rate, special function, registration, friendly ID), fields for the next request only (a
firmware update, a reset; the Server tab's firmware update needs the app image chosen first,
or --ota-firmware), HTTP failures per route in the firmware's scripts/mock_server.py
syntax, and list the recorded requests.
Headless, e.g. for serial output or a screenshot:
trmnl-sim ../trmnl-firmware/.pio/build/trmnl/FW1.8.17-trmnl.bin --headless --seconds 60 --screenshot screen.pngProduction builds don't log, so the simulator mirrors the firmware's Log_* messages to the
console ([sim] log I: src/bl.cpp [701]: …) for tests to wait on.
A factory-fresh TRMNL X (--erase) flashes its modem (about 13 s of virtual time), then
waits in shipment mode until docked (POST /dock) and restarts into setup.
| Option | |
|---|---|
<firmware> |
Merged flash image (FW1.8.17-trmnl.bin); its ELF is the same path with .elf unless --elf says otherwise. Without it the window asks |
--env NAME |
The PlatformIO env the firmware was built with; picks the board. Default: the -<env> in the image's file name |
--flash PATH |
Flash image (default sim-flash.bin next to the firmware); the firmware is written on every start, NVS and SPIFFS are kept |
--erase |
Start from erased flash |
--mac AA:BB:.. |
eFuse MAC, i.e. the device identity on the server |
--headless |
No window; serial output to stdout. Exits with status 2 if the CPU halts |
--control ADDR |
Serve the control API and its MCP server, e.g. 127.0.0.1:7878 (port 0 = pick one) |
--turbo |
Don't pace to wall-clock time (see Time) |
--fast-sleep |
Fast-forward deep sleeps |
--offline |
Only the host (10.0.2.2) and the NTP server are reachable |
--dns NAME=IP |
Answer DNS for NAME locally (repeatable) |
--portal-port N |
Host port for the captive portal (default 8080, 0 = any) |
--mock-server[=PORT] |
Start the built-in mock server (default 8090, 0 = any) |
--ota-firmware PATH |
App image (firmware.bin) the built-in server offers at /firmware.bin for OTA; its ELF (same path, .elf) is loaded too |
--hot-reload |
On each wake from deep sleep, reset or power cycle, load the firmware again if it or its ELF changed, and boot it from power-on (NVS and SPIFFS kept): rebuild and see it on the next wake |
--elf PATH |
The firmware's ELF (default: the firmware's path with .elf) |
--elf-dir DIR |
Also look here for the ELF of a build an OTA update installs (repeatable; see HLE and OTA) |
--seconds S |
Stop after S seconds of virtual time |
--screenshot PNG |
Save the display when the run ends |
--panel-rev HEX |
What the panel's REV command returns (the Panel-Rev header) |
--rom PATH |
ROM ELF for the build's chip (or $TRMNL_SIM_ROM) |
--trace f1,f2 |
Log every call to these firmware functions |
--profile |
Print where the CPU spent its time on exit |
--coverage FILE |
Write an lcov tracefile on exit (see Code coverage) |
--coverage-root DIR |
Source paths relative to DIR, e.g. the firmware checkout (default: absolute) |
--coverage-include P,.. |
Only report files under these prefixes, e.g. src/,lib/ |
--memcheck[=halt] |
Check memory use; =halt stops at the first error |
--memcheck-suppress F,.. |
Ignore violations with these functions in their stacks |
--gdb ADDR |
Serve the GDB remote protocol, e.g. 127.0.0.1:3333 (port 0 = pick one) |
--gdb-wait |
With --gdb: hold the CPU at its first instruction until GDB attaches |
--scale Z |
Initial display zoom (0 = fit) |
--restore FILE |
Start from a save point (its flash and MAC replace --flash/--mac) |
--sensor NAME |
Environment sensor on an SPI-panel board's I2C (repeatable): scd41, aht20 |
--wifi-networks JSON |
Access points in range, replacing the defaults (ssid, password, rssi, channel, open, internet, enterprise) |
--faults JSON |
Inject faults from the start (repeatable, merged) |
The default networks are TRMNL-Sim (any password) and Neighbors WiFi (hunter2hunter2);
TRMNL-Sim-5G (channel 36) is seen only by 5 GHz radios (the C5 and the X's modem, which
then does all HTTP). Networks taking any password reject fail, to test a failed join.
WPA2-Enterprise. "enterprise": {"username": "alice", "password": "s3cret"} makes a
network 802.1X: it scans as WIFI_AUTH_WPA2_ENTERPRISE (ESP-AT ecn 5 on the X's modem),
and its server accepts only those EAP credentials (null or left out: anything but fail;
"enterprise": true accepts any). The firmware's esp_wifi_sta_wpa2_ent_* (IDF 4.4) or
esp_eap_client_* (IDF 5) configuration is checked at the join: the inner username (the
identity if none is set) and password must match, an identity must be set, and the station
must have 802.1X enabled. Rejected credentials disconnect with 802_1X_AUTH_FAILED (23); an
802.1X station and a PSK or open network don't match (NO_AP_FOUND, 201), either way round.
The X's modem can't join one (+CWJAP:4); the firmware uses the S3's own radio for those.
curl -X POST 127.0.0.1:7878/wifi -d '{"networks": [{"ssid": "Corp",
"enterprise": {"username": "alice", "password": "s3cret"}}, {"ssid": "TRMNL-Sim"}]}'The mock backend runs the firmware's real NimBLE host, GATT endpoints and
Espressif Security 1 over a portable simulated HCI controller. It has been
verified with the OG ESP32-C3 trmnl build using ESP-IDF 4.4.7. Other firmware
controller interfaces are not yet verified. Mock Bluetooth has no host radio or
platform framework dependencies.
bin/sim ../firmware/.pio/build/trmnl/FW1.8.17-trmnl.bin --headless --control 127.0.0.1:7878Bluetooth is always mocked, on every host platform. It needs no host Bluetooth
adapter, permissions, or backend configuration and does not connect to physical
phones. /status.bluetooth reports active ("mock" while the guest controller
is initialized, otherwise null), initialized (controller enabled),
advertising, connection, advertisement, and scan_response.
The control API provides one mock central and raw ATT exchanges; discover GATT handles by UUID from guest responses rather than assuming firmware handle numbers:
| POST path | JSON body | Response |
|---|---|---|
/bluetooth/connect |
{} |
connection token |
/bluetooth/att |
{"connection": TOKEN, "data": [10, HANDLE_LOW, HANDLE_HIGH]} |
ATT reply in data |
/bluetooth/receive |
{"connection": TOKEN} |
Next notification/indication in data, or [] |
/bluetooth/disconnect |
{"connection": TOKEN} |
Disconnect completion accepted |
For example, after /status.bluetooth.advertising becomes true, this script
connects, discovers primary GATT services, and disconnects (Python standard library only):
import json
from urllib.request import Request, urlopen
def post(path, body):
request = Request("http://127.0.0.1:7878" + path,
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"})
with urlopen(request, timeout=15) as response:
return json.load(response)
connection = post("/bluetooth/connect", {})["connection"]
try:
reply = post("/bluetooth/att", {
"connection": connection,
"data": list(bytes.fromhex("100100ffff0028")),
})
print(bytes(reply["data"]).hex())
finally:
post("/bluetooth/disconnect", {"connection": connection})ATT errors are returned as ATT bytes, not turned into backend failures. A single request may be pending; its limit is five seconds of virtual time, with an eight second wall-clock bound when paused. Timeout discards the connection. Reset and reconnect invalidate tokens, and pending requests are failed without replay. Notifications are bounded to 64 queued packets; overflow disconnects the central. Indications are acknowledged when queued. Write commands complete on enqueue. The implemented radio is peripheral-only, one link, without SMP/link encryption; application-layer Security 1 still executes in full. Central-role scanning and whitelists are not implemented.
Firmware integration coverage lives in trmnl-spec, with the shared Ruby simulator client and TLS mock. Its OG Bluetooth specs cover discovery, QR-based Security 1, encrypted status, long writes, oversize rejection, reconnect, wrong proof, reset invalidation, and encrypted WiFi/setup-code handoff:
cd ../trmnl-spec
ENVS=trmnl:full FIRMWARE_REPO=../firmware SIM_REPO=../trmnl-sim \
bundle exec rspec spec/core/og_bluetooth_spec.rbSee that repository's setup instructions for the QR decoder and PAIRING_HOST override
when testing firmware built for another setup server. The specs use offline networking
and a local TLS server; they do not contact the configured real server.
GET /preferences returns { "ok": true, "editable": bool, "entries": [...], "warnings": [...] }.
Each entry contains partition, namespace, key, type, and value. Values are
strings: decimal integers, literal text, or space-separated hexadecimal blob bytes.
This preserves the full precision of 64-bit integers. Values are never masked.
PUT /preferences creates or replaces one key; DELETE /preferences deletes one.
Both accept JSON and return the updated snapshot. Partition, namespace and key are
required. PUT also requires type and a string value:
curl http://127.0.0.1:7878/preferences
curl -X PUT http://127.0.0.1:7878/preferences -H 'Content-Type: application/json' \
-d '{"partition":"nvs","namespace":"data","key":"friendly_id","type":"string","value":"TEST123"}'
curl -X DELETE http://127.0.0.1:7878/preferences -H 'Content-Type: application/json' \
-d '{"partition":"nvs","namespace":"data","key":"friendly_id"}'Supported types: string, blob, u8, u16, u32, u64, i8, i16, i32, i64.
Arduino booleans are stored as u8 ("0" or "1"). Names contain 1–15 UTF-8 bytes;
strings allow up to 3999 bytes. Blobs accept hexadecimal pairs with optional spaces.
An empty string or blob is allowed. Integer values must fit their declared type.
Reads work in any state. Mutations are checked on the emulator thread and require
deep sleep: paused active firmware, light sleep, and halted firmware are rejected.
Pausing an already sleeping device freezes its wake timer and still allows edits.
The device remains asleep with its existing wake schedule; use POST /wake to wake
it early. If the simulator process is closed, its HTTP API is unavailable.
Edits repack only the selected NVS partition, preserving namespace IDs and other committed values, and reserve an erased page for firmware garbage collection. The new flash image is atomically saved before live flash is replaced. Unreadable or encrypted partitions, insufficient space, missing delete targets, invalid values, and disallowed power states return HTTP 409 without applying the change. Malformed requests return 400; a missing emulator response returns 504. Host edits bypass simulated flash faults and do not count as guest program/erase operations.
Firmware integration coverage lives in trmnl-spec, using its onboarded-device fixtures
and temporary flash copies:
cd ../trmnl-spec
ENVS=trmnl:full FIRMWARE_REPO=../firmware SIM_REPO=../trmnl-sim \
bundle exec rspec spec/general/tooling/preferences_spec.rbThe specs cover typed edits, validation, multi-page blobs, unchanged unrelated values, active-CPU rejection, firmware reads after wake, and persistence across process restart.
Virtual time comes from executed cycles, paced to wall-clock time by default. --turbo
fast-forwards idle periods, but runs in real time while the host network is waited on and
while the setup portal is up (POST /wifi {"portal_client": false} lifts that), so no
firmware timeout fires early. Deep sleep lasts its real duration unless --fast-sleep; end
it early with POST /wake or the button.
A save point captures the device, e.g. "onboarded, asleep, showing image X", so you can
return to it without redoing setup: POST /savepoint / POST /restore, or --restore FILE.
- Taken in deep sleep, it keeps everything that survives deep sleep (flash, RTC memory and registers, the pending wake, the screen and panel RAM, the I2C chips, the modem image, battery, dock, WiFi) and resumes that sleep with the same time left.
- Taken otherwise, it keeps what survives pulling the battery (flash, screen, modem flash) and powers on from there.
Files are compressed (about 1 MB for an OG, 2.5 MB for an X) and tied to their firmware build; restoring onto another build, or saving mid-refresh, is refused.
--control 127.0.0.1:7878 serves JSON over HTTP.
GET /status |
state, virtual time, display busy and refresh count, WiFi/IP, portal URL, boot count, … |
POST /button {"down": bool} |
hold or release the button |
POST /press {"ms": N} |
press for N virtual ms; returns after release ("count": 2, "gap_ms": 150: a double click) |
POST /touch {"zone": "left"|"center"|"right", "ms": N} |
TRMNL X touch bar tap ("down": true/false: hold / lift a finger) |
POST /gesture {"gesture": "swipe_next"|"swipe_back"|"flick_next"|"flick_back"} |
TRMNL X slide (slide mode only) |
POST /dock {"docked": bool} |
TRMNL X magnetic dock |
POST /reset, /power-cycle, /wake, /quit |
|
POST /wifi {"available": bool} |
network in or out of range; {"networks": [...]} replaces the access points; {"portal_client": false} lets an unattended portal run ahead in turbo |
POST /battery {"mv": N} |
|
POST /turbo {"on": bool}, /pause {"on": bool} |
|
POST /debug |
dump CPU state, backtrace, FreeRTOS task and board diagnostics to the console |
GET /console?since=N |
serial lines with absolute indices |
POST /wait {...} |
block on conditions, timeout_s, settle_ms; 408 on timeout, 409 if the CPU halted |
GET /screenshot[?x=&y=&w=&h=] |
grayscale PNG (0 = ink); RGB on color panels |
POST /screenshot/compare?tolerance=&max_ratio=[&x=&y=&w=&h=] |
PNG body in; {"match", "diff_pixels", "diff_ratio"} |
GET /mock |
built-in mock server state |
POST /mock/start {"port": N}, /mock/stop |
start (returns device_url) or stop it |
POST /mock/images?name=¤t=1&dither=0&fit=contain|cover|stretch&raw=1 |
add an image, converted for the panel (raw=1: served as is) |
GET /mock/images/NAME/expected, DELETE /mock/images/NAME |
the PNG the screen should show; remove it |
POST /mock/display {...} |
image, refresh_rate, special_function, playlist, auto_advance, registered, friendly_id, api_key, extra |
POST /mock/queue {...}, DELETE /mock/queue |
fields for the next /api/display answer only |
POST /mock/faults {"display": [...], "image": [...]}, DELETE /mock/faults[?route=] |
queue HTTP failures per route in mock_server.py syntax ("503:2", "timeout=20", "slow=1024,20") |
POST /mock/files?path=/x.bin |
serve the body at that path |
GET /mock/requests?since=N |
recorded device requests |
POST /savepoint {"path"?: str, "label"?: str} |
take a save point; 409 if refused |
POST /restore {"path": str} or {"id": N} |
restore one; 409 on failure |
GET /savepoints |
the in-memory save points |
POST /coverage {"path": "x.info", "reset": bool} |
with --coverage: write the tracefile now, optionally start over |
GET /preferences |
NVS values, warnings, and whether editing is currently allowed |
PUT /preferences |
create/replace a typed NVS value during deep sleep |
DELETE /preferences |
delete an NVS key during deep sleep |
GET /memcheck |
with --memcheck: violations, suppressed ones, heap statistics, stack marks |
GET /faults |
injected faults, power losses, flash counts, the partition table |
POST /faults {...}, DELETE /faults |
merge faults; clear them all |
POST /mcp |
the MCP server |
The endpoints are listed once, in crates/sim-control/src/endpoints.rs: the HTTP server
answers only those, and the MCP server offers each as a tool.
/mcp on the control address is a Model Context Protocol
server (Streamable HTTP, JSON answers) for AI agents, with one tool per control endpoint
(status, press, wait, screenshot, mock_display, faults_set, …):
bin/sim FW1.8.17-trmnl.bin --control 127.0.0.1:7878
claude mcp add --transport http trmnl-sim http://127.0.0.1:7878/mcpA tool's arguments are the endpoint's body keys and query parameters (path parameters
too, e.g. mock_expected_image {"name"}), and a call runs exactly the HTTP request it
stands for, so validation, defaults and answers are the same. JSON answers come back as
text and structured content; an HTTP error status makes the result an error. PNG answers
(screenshot, mock_expected_image) are image content, also written to save_to if
given. Raw request bodies (screenshot_compare's reference, mock_add_image,
mock_add_file) are given as file, a path on the simulator's machine, or data_base64.
Requests from non-local browser origins are refused.
Adding an endpoint means adding its row to ENDPOINTS (method, path, tool name, description,
parameters); the unit tests fail if a route the routers handle is missing from the table or
a row reaches no handler, and an unlisted route answers 404 over HTTP too.
--gdb 127.0.0.1:3333 lets the toolchain's GDB debug the firmware as it runs, with the
firmware's ELF: breakpoints, single steps, watchpoints, registers, memory and backtraces with
source lines, on every chip.
bin/sim ../trmnl-firmware/.pio/build/trmnl/FW1.8.17-trmnl.bin --gdb 127.0.0.1:3333 --gdb-wait
~/.platformio/packages/tool-riscv32-esp-elf-gdb/bin/riscv32-esp-elf-gdb \
../trmnl-firmware/.pio/build/trmnl/FW1.8.17-trmnl.elf \
-ex 'target remote 127.0.0.1:3333' -ex 'break setup' -ex continueThe S3 boards (TRMNL X, PaperS3, ...) use tool-xtensa-esp-elf-gdb/bin/xtensa-esp32s3-elf-gdb.
In VS Code, the firmware's trmnl.code-workspace has Debug in simulator: it builds
PlatformIO's selected env, runs it here held at reset, and attaches, so breakpoints set in the
editor catch the boot (scripts/debug_sim.sh in the firmware does the build-and-run part).
- All-stop. Attaching stops the whole machine (both S3 cores, virtual time);
continueruns it again, Ctrl-C stops it. GDB threads are the cores: thread 1 is core 0, thread 2 the S3's APP_CPU once it runs. While a step runs, interrupts wait, sonextstays in your code. - Breakpoints cost nothing when unset and survive resets and deep-sleep wakes,
so
break setupcatches every boot. Calls to HLE-replaced functions (WiFi driver, sleep, ADC) complete in one step. - Watchpoints (
watch,rwatch,awatch) see the CPU's loads and stores, stopping after the accessing instruction; not writes the HLE or DMA makes. - Crashes stop in GDB before the firmware's panic handler runs: a fatal CPU exception
(illegal instruction, access fault, misaligned access, division by zero) shows as SIGILL,
SIGSEGV, SIGBUS or SIGFPE at the faulting instruction;
continuelets the panic handler take it. Breakpoint instructions (ebreak,BREAK) stop with SIGTRAP and are stepped over; a simulator halt shows as SIGABRT with its message. monitor reset,monitor power-cycleandmonitor wakepress the buttons; the status showsdebugger(/statusstate,/wait {"state": "debugger"}) while stopped.
Memory reads are RAM, ROM and mapped flash; peripheral registers aren't read (some reads have side effects). The host network keeps going while the target is stopped, so a long stop can make a connection time out.
--coverage FILE records which firmware instructions run (across resets and deep sleeps)
and at exit maps them to source lines through the ELF's DWARF line tables, writing an
lcov tracefile with lines and functions hit
(0/1 per run). Source paths are absolute unless --coverage-root makes them relative to a
directory, e.g. the firmware checkout.
trmnl-sim ../trmnl-firmware/.pio/build/trmnl/FW1.8.17-trmnl.bin --headless --seconds 30 --coverage og.info \
--coverage-root ../trmnl-firmware --coverage-include src/,lib/POST /coverage (Ruby: sim.write_coverage) writes one mid-run; after an OTA both builds
are reported. trmnl-spec records coverage on every run and merges it into one report.
It costs about 5-8% of emulation speed. Not covered: the bootloader and ROM (no line tables) and functions replaced by HLE hooks (left out, not counted as missed). A killed simulator writes no tracefile.
--memcheck catches the firmware's memory bugs as they happen, with symbolized stacks:
- heap-use-after-free, heap-buffer-overflow, accesses to unallocated heap or allocator metadata, and stack-overflow;
- double-free and invalid-free (not passed on, so the heap stays consistent);
- stack high-water marks of every FreeRTOS task, flagged
lowwithin 256 bytes; - heap statistics for internal RAM and PSRAM.
[sim] memcheck: heap-use-after-free: 1-byte read at 0x3fcaad88 (pc dns_gethostbyname_addrtype+0x8, task "tiT", core 0)
[sim] backtrace: dns_gethostbyname_addrtype+0x8 <- dns_gethostbyname+0xa <- sntp_request+0x52 <- ...
[sim] 0x3fcaad88 is the start of a 16-byte block at 0x3fcaad88
[sim] allocated by task "loopTask": _ZN6String12changeBufferEj+0x40 <- ... <- _ZN11Preferences9getStringEPKc6String+0x40
[sim] freed by task "loopTask": _ZN6String10invalidateEv+0x1c <- _ZN6StringD2Ev+0x8 <- _ZN5Clock14setTimeFromNTPEv+0xec <- ...
Each distinct violation is reported once. --memcheck=halt stops at the first (sim.wait
then fails); GET /memcheck (Ruby: sim.memcheck) returns the full report, and a summary
prints at exit. trmnl-spec runs every simulator with --memcheck=halt.
How it works: HLE hooks on the IDF heap's multi_heap_* layer see every block. A shadow
byte per byte of SRAM and PSRAM marks live, freed, header and never-allocated bytes, and
every CPU load and store (but the allocator's own) is checked against it. Freed blocks wait
in a quarantine and realloc always moves, so stale pointers hit poisoned memory. It costs
about 5-12% when on.
Limitations: stacks are exact on the X but heuristic on the OG/BWRY (no frame pointers).
Aligned word loads may run past a block's end unreported (strlen does that), overflows
into another live block aren't seen, and static buffers and non-heap stacks aren't checked.
.github/workflows/ci.yml runs format, clippy and unit tests, then
calls trmnl-spec's workflow with this commit: it builds the firmware and the headless
simulator (--no-default-features, no GUI), runs the suite and uploads logs, screens and
coverage. The firmware repository calls that workflow to test its PRs. Adjust the
usetrmnl/... names if the repositories live elsewhere.
.github/workflows/release.yml runs on a pushed v* tag
that matches Cargo.toml's version (v0.1.0; a suffix like v0.2.0-rc.1 makes a
prerelease). It builds Linux x86_64 (with the GUI, and headless for CI runners) and Windows
x86_64, each archived with the C3, S3 and C5 ROM ELFs in rom/ next to the executable, and
macOS arm64 (Apple silicon) as TRMNL Simulator.app (the ROM ELFs in Contents/Resources/rom;
from a terminal, run TRMNL Simulator.app/Contents/MacOS/trmnl-sim), and publishes them with
SHA256SUMS.txt as a GitHub release. With the repository variable MACOS_SIGNING set to
true and the secrets the workflow lists, the app is signed with a Developer ID, notarized
and stapled; without them it is signed ad hoc. The app's icon,
packaging/macos/AppIcon.icns, is rendered from the window
icon's SVG by scripts/make-icns.sh.
trmnl-sim (bin) CLI, runner (pacing, power states, commands)
├─ arch/ CPU cores behind MemBus + GuestCpu traits (riscv.rs, xtensa.rs)
├─ soc/ one module per chip, implementing the chip-agnostic Machine trait
│ ├─ esp32c3/ memory map, peripherals, crypto/GDMA, boot flow, interrupt routing
│ ├─ esp32s3/ the same for the dual-core S3, plus PSRAM, LCD_CAM, USB console
│ └─ esp32c5/ the C5: CLIC, PCR/LP domain, cache MMU, AHB DMA, ECC/ECDSA, USB console
├─ periph/ IP blocks shared by chips (SYSTIMER, I2C engine, SHA, AES/RSA math, EC math)
├─ board/ what's wired to the pins (spi_epd.rs: OG, BWRY and SPI-panel BYOD boards;
│ trmnl_x.rs; parallel_byod.rs: PaperS3, T5 Pro); Board trait
├─ devices/ e-paper controllers (UC8179/UC81xx, SSD16xx, dual-CS, parallel), SPI NOR flash,
│ ESP-AT modem, I2C chips (TCA9535, TPS65185, IQS323, BQ27427, BQ27220, AXP2101,
│ M5Stack PY32 and M5IOE1)
├─ coverage/ executed-instruction bitmaps, DWARF line mapping, lcov output
├─ hle/ ESP-IDF function replacements by ELF symbol (WiFi driver, sleep,
│ ADC), ISA-neutral; hooks can call back into guest code or wrap it
├─ memcheck.rs heap tracker, shadow memory, stack marks (--memcheck)
└─ firmware.rs build artifacts, ELF symbols, OTA slot and app selection
crates/
├─ sim-api/ the contract between the emulator thread and front-ends
├─ sim-ui/ egui desktop window
├─ sim-control/ HTTP control API
├─ sim-gdb/ GDB remote protocol stub (--gdb); the chip-specific parts are src/debug.rs
│ and arch/gdb.rs (register maps)
├─ sim-bluetooth/ portable H4 controller; guest NimBLE/GATT stays in firmware
├─ mock-trmnl/ built-in mock TRMNL server and image conversion (/mock API)
└─ vnet/ user-mode router/NAT (smoltcp) + soft-AP client
Chips and boards. The image header names the chip (C3, S3 or C5); the board comes from
the PlatformIO env (the image's file name, --env, or picked in the window). The C3 and C5 share the
RISC-V core; the Xtensa windowed ABI is hidden behind a few GuestCpu calls, so the HLE
(WiFi for IDF 4.4 and 5.5, sleep, ADC) is the same code on every chip. A new board is a
Board plus its devices; a new chip is a soc/ module.
HLE and OTA. Hooks are bound to the ELF's addresses. Each boot, the simulator
matches the slot's app descriptor to a known ELF, so an OTA to another build needs its ELF.
--ota-firmware and the Server tab's firmware picker load the one next to the image. For an
update from anywhere else, the descriptor's ELF SHA-256 finds it: any .elf in the
directories of the firmware, its ELF, --ota-firmware or --elf-dir that hashes to it. With
none, the run halts with a clear message. Coverage leaves out an app picked in the window or
found this way.
- ESP32-C3, ESP32-S3 and ESP32-C5 only (no classic ESP32). BYOD boards model what the firmware uses: no SD cards, touch panels or power latches; charging only on the X and gen 2.
- ESP32-C5: no ADC (HLE'd through
analogRead*), LP core or PARLIO RX; the ECDSA accelerator only verifies; no WiFi 6 details or BLE. - WiFi is modelled at the IDF driver API, not 802.11: signal, roaming and power save are canned.
- TRMNL OG: nothing on the I2C bus. No USB data or serial input on any device.
- TRMNL X: no accelerometer; the modem does station mode and HTTP GET only.
- Timing counts instructions (1 per cycle), not cycles; flash operations are instant.
- The e-paper model is exact in pixels but approximate in grays and ghosting; the 4-color refresh is a fixed frame sequence.
- OTA to another build needs its ELF in a searched directory.
- Save points: only deep-sleep ones keep chip state; a powered modem comes back off.
- "CPU exception at …": the message has symbolized registers and a stack scan;
--trace fnlogs calls into a suspect function. - The firmware seems stuck:
--headless --seconds N --profileshows where the CPU spends its time, often polling an unmodelled register. - An unmodelled register:
RUST_LOG=trmnl_sim=tracelogs first accesses to them. - Memory corruption:
--memcheck(see Memory checking) catches the bad access where it happens. - A crash or hang you want to step through:
--gdb(see Debugging with GDB) stops at the faulting instruction before the panic handler, or at any breakpoint. - A hang or crash on the TRMNL X:
POST /debug(Ruby:sim.debug) prints both cores' registers, a backtrace and the running task (SIM_PEEK=addr,addradds memory words);RUST_LOG=modem=debuglogs the AT traffic.