RP2040
The smallest verified target. The RP2040 has 264 KB of SRAM, no FPU, and no external RAM, so this
example uses Flow B: a small fixed runtime in the firmware checks a compiled package and runs it from flash.
The example project is at
examples/rp2040/rp2040-touch-lcd-1.69
and runs the watch-face demo: a digital clock with day, date and battery, heart-rate and live
step-counter cards, and a swipe-in bubble level.
Board: Waveshare RP2040-Touch-LCD-1.69. A 1.69" 240×280 rounded-rectangle ST7789V2 panel (portrait, with a 20-row GRAM offset), a CST816S capacitive touch controller, a QMI8658 IMU, 4 MB flash. The round 1.28" sibling (GC9A01A, 240×240) shares the pins and touch chip; see Tuning.
App.jsx → build --aot → app.erpkg → flash slot → Flow B runtime → C engine → pico-spi-lcd backend → ST7789V2
↑
CST816S touch
Build and flash
You need the Raspberry Pi Pico SDK, the Arm GNU toolchain (arm-none-eabi-gcc), CMake and
Ninja or Make. Point the build at the SDK with PICO_SDK_PATH, or let it fetch its own copy with
-DPICO_SDK_FETCH_FROM_GIT=ON.
1. Build the firmware. You do this once.
cd examples/rp2040/rp2040-touch-lcd-1.69
PICO_SDK_PATH=/path/to/pico-sdk cmake -S . -B build -G Ninja
cmake --build build # → build/embedded-react-watch.uf2
2. Flash it, either way:
- Hold BOOT while plugging in USB; the board mounts as an
RPI-RP2drive. Copy the.uf2onto it. picotool load -f -x build/embedded-react-watch.uf2, which needs no button if the current firmware supports it.
With no package installed, the first boot shows the error screen, saying so.
3. Compile the app:
npx embedded-react build --aot # → dist/app.erpkg
4. Write it to the board, either way:
npx embedded-react flash --board rp2040-touch-lcd-1.69
This needs a picotool with USB support, on your PATH or in PICOTOOL, such as brew install picotool or one built
from source with libusb. The copy a Pico SDK build fetches has none. Or, with no picotool, write a UF2 and copy it onto
the RPI-RP2 drive:
npx embedded-react pack --board rp2040-touch-lcd-1.69 --slot a --sequence 1 # → dist/app.slot-a.uf2
A UF2 can't read the board, so for a later package pick the slot yourself: the USB log says which one is staging,
and the sequence must be higher than the running one's. flash does this for you.
The watch face appears. Walk with the board and STEPS counts up; swipe right-to-left for the bubble level and tilt the board. The boot log over USB serial:
embedded-react RP2040-Touch-LCD-1.69 host — Flow B (no QuickJS)
board: ST7789V2 panel up (240x280, SPI 62.5 MHz)
board: CST816 touch ready (chip id 0xB5; ...)
board: QMI8658 IMU ready (addr 0x6B, accel ±4g @125Hz)
package running from slot a; slot b is staging
How it works
main.cboots the panel, touch, and IMU, registers the render backend, loads the active package once, and runs the frame loop: poll touch, poll the IMU and pedometer,er_commit(), present, tick.erb_tick()advances the app'ssetInterval, which is what moves the clock.- Two package slots sit above the firmware, 512 KB each at
1 MB and 1.5 MB into flash, inside the 2 MB the Pico SDK assumes. The link fails if the firmware reaches them.
embedded-react flashwrites the one that isn't running, so the last good package stays in flash. If the new one fails verification, the board shows the reason for a few seconds, then runs the last good one. - The arena is 4 KB (
ERB_ARENA_BYTESinmain.c), and the watch face needs about 0.5 KB. A package that needs more than the firmware reserves is rejected at boot, with both amounts on the error screen. board.cowns the hardware: the ST7789V2 init sequence and windowing on SPI1, a PWM backlight, the CST816S on I²C1 and the QMI8658 on the same bus. Touch is interrupt-gated: the CST816S sleeps when idle and refuses I²C until a touch, so a GPIO edge on its INT line tells the loop when a report is waiting.- Sensors reach the app through
useHostValue. The demo declaresconst steps = useHostValue(0)(anddotx/dotyfor the level). The compiler lowers each to a state field and a named input.main.clooks them up once witherb_input_find("steps"), and callserb_input_set_int()when the value changes.pedometer.cturns accelerometer peaks into steps; the level page maps a low-passed gravity vector to the dot. This is the general Flow B way to feed host data into a compiled app. - The swipe pager is a
PanResponderover a 480-wide track, lowered onto the engine's C gesture negotiation, so the drag and the flick arrive with no JavaScript involved. - The backend,
backends/pico-spi-lcd, keeps one 240×280 RGB565 framebuffer in SRAM (131 KB) and on present streams only the dirty rectangle to the panel. CMakeLists.txtbuilds the engine with RAM-tunedERUI_*flags (shadows, gradients and the keyboard off; node, animation and vector pools sized to the watch face) so everything fits beside the framebuffer in 264 KB. The link fails if the framebuffer no longer fits. Memory has the sizes.
Tuning for your board
| Symptom | Fix, in board.c unless noted |
|---|---|
| Image shifted vertically, a garbage strip at one edge | LCD_Y_OFFSET: this panel's 280 rows start at GRAM row 20; a round 240×240 GC9A01A needs 0 |
| Photo-negative colours | Toggle the 0x21 inversion command in lcd_init_regs() |
| Red and blue swapped | Flip the RGB/BGR bit in MADCTL (0x36: 0x00 ↔ 0x08) |
| Mirrored or rotated | Adjust the MADCTL MX/MY/MV bits in lcd_init_regs() |
| Touch dead or misplaced | Check the CST816S is detected in the boot log; it reports pixel coordinates, no calibration needed |
| Steps never count, or count too easily | PEDOMETER_HIGH_THRESHOLD, PEDOMETER_LOW_THRESHOLD, PEDOMETER_MIN_STEP_MS in pedometer.c, watching the -DER_BOARD_DEBUG=1 heartbeat while walking |
| Sparkle, tearing, wrong pixels | Drop LCD_SPI_HZ from 62.5 MHz to 31.25 MHz; not every panel likes the faster clock |
| The round 1.28" GC9A01A board | Replace lcd_init_regs() with the GC9A01A sequence, set LCD_Y_OFFSET 0 and BOARD_LCD_HEIGHT 240 |
Bring-up debugging
-DER_BOARD_DEBUG=1 waits briefly for a USB host at boot and prints a 1 Hz heartbeat with uptime,
frame rate, step count, and the deepest the stack has reached (stack=3096/8192; the watch face peaks
near 3.1 KB of the 8 KB). An app that overflows the stack hits a guard, and the watchdog reboots the
board; the next boot logs rebooted by the watchdog. -DER_BOARD_DEBUG=2 also
holds a color-bar test pattern for three seconds at boot: clean red, green, blue, and white bands prove
the panel driver end to end, so anything wrong after that is the engine or the app. The default build
compiles all of it out.
To check the error screen, -DER_BOARD_CRASH_TEST=1 hard faults 5 seconds after boot, and =2 hangs, which the
watchdog resets. Each shows the error screen after the reset, naming the fault, then the app comes back and it
happens again.
Pinout
| Function | RP2040 GPIO |
|---|---|
| LCD SCLK / MOSI | GP10 / GP11 (SPI1) |
| LCD DC / CS / RST | GP8 / GP9 / GP13 |
| LCD backlight (PWM) | GP25 |
| Touch SDA / SCL | GP6 / GP7 (I²C1) |
| Touch INT / RST | GP21 / GP22 |
| IMU (QMI8658) | GP6 / GP7 (I²C1, address 0x6B) |