Skip to main content

ESP32-S3

The ESP32-S3 example is the reference Flow A board: a real React reconciler on QuickJS, with the JavaScript heap in PSRAM. Its example project lives at examples/esp32/esp32-s3 and is written to be copied out and adapted.

Board: Waveshare ESP32-S3-Touch-LCD-7. A 7" 800×480 RGB-parallel IPS panel, 8 MB octal PSRAM, 16 MB flash, a GT911 capacitive touch controller on I²C, and a CH422G I²C expander that drives the panel's reset, backlight, and touch-reset lines. Two USB-C ports: "UART" (a CH343 bridge, used for flashing) and "USB" (the chip's native USB, used for hot reload).

How it is put together​

The firmware and your app are two independent artifacts. The firmware brings up the panel, the touch controller, and QuickJS, then loads the app as a config container (app.erpkg) from a dedicated flash partition: memory-mapped, checksum-verified, and stamped with the QuickJS version it was built for. To ship a new UI you write one partition; the firmware is untouched.

main/main.c host: PSRAM JS heap, map + load the config partition, frame loop, touch
main/board.c panel + touch bring-up for this board (CH422G, RGB panel, GT911)
CMakeLists.txt top-level IDF project; fetches QuickJS-ng and, when copied out, embedded-react
partitions.csv factory (firmware) + a 2 MB 'config' data partition for app.erpkg
sdkconfig.defaults ESP32-S3, octal PSRAM, 16 MB flash, a 64 KB main task stack

Dependencies are pulled by CMake FetchContent at configure time, nothing is vendored. Inside the repository the example uses the local engine and bridge; copied out on its own, it fetches them from GitHub at a pinned tag. QuickJS-ng is always fetched, pinned to v0.15.0, and that pin matters: bytecode is specific to the QuickJS version, and the loader rejects a container built for another.

Why FetchContent and not the component registry

Flow A needs QuickJS, which is a plain CMake project rather than an ESP-IDF component, so idf.py add-dependency cannot manage it. FetchContent pulls the engine, the bridge, and QuickJS together. The registry holds the engine and the Flow B runtime, but not the bridge or the display backend; see Installation.

Build and flash the firmware​

You need ESP-IDF v6.x installed and exported (. $IDF_PATH/export.sh); the example is validated on v6.0.1. The first configure downloads the dependencies, so it needs git and a network once.

cd examples/esp32/esp32-s3
idf.py set-target esp32s3
idf.py build flash monitor # picks the port automatically, or pass -p PORT

Flash through the UART port. With no app loaded, the panel shows "No config loaded". That is the firmware working.

Build and flash your app​

From your own project (a create-embedded-react scaffold or anything that depends on embedded-react):

npx embedded-react build # → dist/app.erpkg
parttool.py write_partition --partition-name=config --input dist/app.erpkg

parttool.py ships with ESP-IDF and finds the port itself. The board restarts into your app. From now on, only this step is needed for a UI change.

The log should read roughly:

I (xxx) embedded-react: embedded-react ESP32-S3 host
I (xxx) embedded-react: PSRAM free: 6…… internal free: …
I (xxx) board: RGB panel up: 800x480
I (xxx) embedded-react: display backend active
I (xxx) board: GT911 up: product id '911 '
I (xxx) embedded-react: config partition mapped (2097152 bytes) — loading container
I (xxx) embedded-react: config loaded
I (xxx) js: React mounted at 800x480
I (xxx) embedded-react: alive: 120 frames, …

React mounted plus a lit panel is the win. If the config is missing or rejected, the panel says why (No config loaded, Couldn't load config: <reason>); if the panel itself fails to initialise, the host falls back to a headless backend so the JavaScript side still runs and logs.

Inside the repository, cd packages/embedded-react && npm run pack packs one of the demos instead (npm run pack -- watch-face picks another), and the container lands in packages/embedded-react/dist/app.erpkg.

Hot reload over USB​

A firmware built with idf.py -DER_HOTRELOAD=1 build flash accepts a fresh app over the board's native USB port on every save, with the running UI staying on screen until the new one is in and component state preserved. Plug in both USB ports, then from your project:

npm i serialport # once: the optional native dependency for device upload
npx embedded-react dev --device # auto-detects the ESP32's USB-Serial-JTAG

It is off by default and meant for development only; a release firmware should be built without it. Hot reload explains how it works and what it costs.

Two board-specific things make the native USB port work, both already wired up here: the console is bound to USB-Serial-JTAG (CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y), which is what installs the driver the receiver reads from, and the CH422G's USB_SEL line is driven low in board.c, because the native USB pins are muxed with a CAN transceiver and the port never enumerates until the firmware selects USB. If you adapt the example to a board with the same expander, keep that line.

The backend​

backends/esp32-lcd does the fills, copies and blends on the CPU (on the S3's SIMD unit for the blend loops) and lets the LCD peripheral DMA the framebuffer out; the host calls er_esp32_lcd_present() after each er_commit(). With an unrotated RGB565 panel that exposes two or three framebuffers it can composite straight into them (ER_LCD_DIRECT); three framebuffers means a frame never waits on the display. This example asks the panel for one framebuffer, so it takes the copy path: the engine draws into a separate canonical framebuffer that is copied to the panel on present. A rotated panel or an ARGB8888 build always takes the copy path.

Adapting the example to another board​

Two pieces are board-specific. main/board.c and board.h bring up this panel and touch controller; the render backend (components/esp32-lcd-backend, which is backends/esp32-lcd) pushes pixels through an esp_lcd panel handle.

  • For another RGB or I80 parallel panel, keep the backend and rewrite board.c for your panel and touch chip.
  • For an SPI panel (ILI9341, ST7789, and the like), swap the backend for backends/esp32-spi-lcd, the banded RGB565 backend the CYD example uses.

Things to know​

  • The host must present. er_commit() paints into the backend's framebuffer and nothing more; main.c calls er_esp32_lcd_present() after each commit to flush the dirty region to the panel. Forget it and the screen stays black with a working panel.
  • Panel timings (porches, pixel clock) and the data-pin to colour-channel map live in board.c. A shifted or rolling image means porch or clock; swapped red and blue means byte order.
  • The CH422G expander drives the backlight, LCD-reset, and touch-reset lines from board.c. If the backlight stays off, the config byte and output mask there are what to check.
  • The GT911 shares the panel's I²C bus; taps in the wrong place usually mean an axis swap.
  • Task stack. QuickJS and the reconciler recurse deeply; the main task stack is 64 KB, and the JavaScript stack limit is set to three quarters of it. A JS "stack overflow" or a FreeRTOS stack panic means raise it.
  • PSRAM and flash speed. The defaults run octal PSRAM and flash at 120 MHz, an experimental IDF feature that dual-core rendering needs. Flash above 80 MHz depends on the flash chip, so on another board drop both to 80 MHz if it fails to boot. A quad-PSRAM board needs CONFIG_SPIRAM_MODE_QUAD.
  • First boot pauses while the bytecode loader reads about 940 KB; that is not a hang.
  • The config partition is found by its label config; keep the label if you resize it, and size it at or above your .erpkg (2 MB by default).
  • The QuickJS pin. The tag the top-level CMakeLists.txt fetches must match the one the bridge builds against and the version stamped into the .erpkg; a mismatch is rejected with a panel. Bump them together.

The Flow B build​

The same board can run Flow B: no QuickJS, a fixed arena, and a compiled package in a flash slot. It has its own sdkconfig layer, and builds main/main_flow_b.c in place of main.c:

cd examples/esp32/esp32-s3
idf.py -B build-flow-b -D SDKCONFIG=sdkconfig.flow-b \
-D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.flow-b" set-target esp32s3
idf.py -B build-flow-b -D SDKCONFIG=sdkconfig.flow-b \
-D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.flow-b" build flash monitor

With no package installed, the first boot shows the error screen, saying so. Then, from your project:

npx embedded-react build --aot # → dist/app.erpkg
npx embedded-react flash --board esp32-s3 --port PORT

partitions.csv has two package slots after the config partition, erb_a and erb_b, 2 MB each, so the Flow A build's layout doesn't move. embedded-react flash writes the one that isn't running, and the last good package stays until a new one passes verification.

The layer also makes a hang reset the board (CONFIG_ESP_TASK_WDT_PANIC). To check the error screen, add -DER_BOARD_CRASH_TEST=1 to the build for a panic 5 seconds after boot, or =2 for a hang. Each shows the error screen after the reset, naming the fault, then the app comes back and it happens again.

The thermostat demo picks its wide layout at 800×480.

WiFi​

The example has an optional WiFi build: a station the app can scan with and join, the network and time zone saved in encrypted NVS, and NTP keeping Date.now() on time. It is a build of its own, layered on the defaults, because the WiFi stack needs internal RAM the board is short of:

idf.py -B build-wifi -D SDKCONFIG=sdkconfig.wifi -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.wifi" build flash

Pass the same arguments to every idf.py command for this build (… -p PORT monitor, … menuconfig); plain idf.py keeps building the default firmware. The NTP server is CONFIG_ER_SNTP_SERVER (menuconfig, under embedded-react: WiFi; default pool.ntp.org), and SNTP resyncs hourly and after every reconnection.

In the thermostat demo the settings sheet gains a WI-FI page (scan, pick a network, type its password on the on-screen keyboard) and a TIME ZONE page, and the header clock sets itself.

How it reaches the app. main/network.c runs the station and SNTP on their own tasks and calls er_runtime_set_wall_clock() on each sync, so Date.now() is UTC. main/network_js.c installs two objects into the JavaScript context through ErRuntimeConfig.install_host_globals: __erWifi (scan, networks, connect, forget, status) and __erClock (utcOffsetMs, timeZone, setTimeZone). Where they are absent (the simulator, other boards) the app leaves the pages out. This is the pattern for any host data a Flow A app needs: install an object, and let the app feature-detect it.

What it costs, measured with the thermostat idle: about 36 KB of internal RAM once WiFi is up, which the WiFi build makes room for by cutting the main task stack from 64 to 36 KB (an app that recurses deeper than the thermostat gets a JavaScript "stack overflow"; raise CONFIG_ESP_MAIN_TASK_STACK_SIZE at the cost of WiFi's margin); about 615 KB of flash; and no measurable change in frame time.

The saved settings are encrypted, password included, with a key in eFuse. The first boot of the WiFi build writes a random key into eFuse BLOCK_KEY5, and that is permanent. It is the block the S3 cannot use for flash encryption, so nothing is lost, and the block is read-protected. If KEY5 already holds a different key, WiFi does not start and the log says why; CONFIG_NVS_SEC_HMAC_EFUSE_KEY_ID picks another block. To keep the settings in plain text and leave the eFuses alone, set CONFIG_NVS_ENCRYPTION=n in sdkconfig.defaults.wifi before the first flash.

Measuring​

The example always draws a metrics panel in the bottom-right corner of the screen with this host's own numbers: FPS, CPU load, free PSRAM, and internal RAM. Build with idf.py -DER_PERF_DETAIL=1 to add the engine's frame instrumentation: last and worst frame times, the split between JavaScript, layout, raster, and present, and the region the worst frame repainted. main.c closes each frame before the pacing delay, so FRM is work time, the same basis as the CPU-load line.

FPS 48 host metrics
CPU 61%
PSRAM 3204K
IRAM 118K
FRM 18.4 PK 2013.1 frame time: last / worst, ms
J6.2 L0.3 R9.1 P2.4 last frame: JS, layout, raster, present
PK J1900 L12 R80 P9 the WORST frame's split: which subsystem to blame
PKDRT 800x40 32k the WORST frame's repainted region (pairs with PK)
VEC 3/8 IMG 5/32 vector and image slots in use

Performance explains how to read it, and Engine internals has the full line set and the region policies a host can choose between.