The ESP32
Run FrameOS on an ESP32-S3 microcontroller - flash from the browser, render full scenes on-device, sleep on a battery, update over the air.
FrameOS runs on ESP32-S3 microcontrollers - no Linux, no SD card, no SSH. The backend (or FrameOS Cloud) flashes a firmware image over USB straight from the browser, and from then on the device fetches scenes over WiFi, renders them itself, drives the e-paper panel, and updates its own firmware over the air.
This is not a cut-down mode. The same scene interpreter that runs on a Raspberry Pi runs on the S3, with the same apps, the same JavaScript runtime, the same image pipeline, and the same assets, logs, metrics and scheduler the backend shows for a Pi frame. What a $6 board cannot do is a short, specific list - it's at the bottom of this page.
Self-hosted backend or FrameOS Cloud
An ESP32-S3 can be set up and managed from either a self-hosted backend or FrameOS Cloud. The cloud flashes the generic release firmware from your browser and pushes interpreted scenes. The thin-client boards below need the self-hosted backend, as Cloud does not support virtual frames. There is no standalone mode: an ESP32 frame has no admin page of its own.
Two kinds of microcontroller frame
An ESP32-S3 with PSRAM renders scenes on the device itself. A PSRAM-less board (ESP32-C3, and the Pico W boards) can't hold a renderer, so it runs as a thin client: the backend renders and dithers the scene and the device streams the finished panel bytes. Both are set up the same way and look identical in the backend.
What you need
- An ESP32-S3 board with PSRAM. Rendering happens in PSRAM, so don't skip it. 8 MB is enough for every supported panel, including the 13.3" 1600×1200 six-colour ones - the render canvas is 16-bit and a 13.3" scene fits in 3.7 MB (details). The reference bare board is the Seeed XIAO ESP32-S3 (8 MB flash, 8 MB octal PSRAM), but most people start from an all-in-one board that already has a panel attached.
- A panel: any Waveshare-family SPI e-paper panel, wired over SPI - or no display at all. The 10.3" IT8951 and 12.48" panels use different controller stacks and still need a Pi.
- The backend, to build the firmware and design scenes. Ready-made generic images are also published with every release if you'd rather provision by hand.
Supported boards
FrameOS ships hardware presets - pick one in the backend and the panel, GPIO map, buttons, battery divider, SD slot and flash size are all filled in for you:
| Preset | Chip | Panel | Notes |
|---|---|---|---|
| Waveshare ESP32-S3 PhotoPainter | S3 | 7.3" Spectra 6 | 8 MB PSRAM, TF card slot, battery header, 2 buttons |
| Waveshare ESP32-S3 ePaper 13.3E6 | S3 | 13.3" Spectra 6 | 16 MB PSRAM, 32 MB flash, TF card, battery sensing |
| Seeed reTerminal E1001 | S3 | 7.5" black/white | 3 front buttons, 32 MB flash, battery sensing |
| Seeed reTerminal E1002 | S3 | 7.3" Spectra 6 | 3 front buttons, 32 MB flash, battery sensing |
| Seeed reTerminal E1004 | S3 | 13.3" Spectra 6, portrait | 8 MB PSRAM, 32 MB flash, TF card, 3 buttons, 5000 mAh battery - sleeps on battery by default |
| Seeed reTerminal Sticky | S3 | 3.97" black/white | Small desk/fridge frame |
| Elecrow CrowPanel 5.79" | S3 | 5.79" 4-grey | 5 buttons, dual SSD1683 |
| TRMNL 7.5" DIY Kit | S3 | 7.5" black/white | XIAO ePaper driver board |
| TRMNL 4.26" DIY Kit | S3 | 4.26" 4-grey | XIAO ePaper driver board |
| TRMNL OG | C3 | 7.5" black/white | Thin client - backend renders |
| TRMNL BWRY | C3 | 7.5" 4-colour | Thin client - backend renders |
| XTEINK X4 | C3 | 4.26" 4-grey | Thin client - backend renders |
Anything else is a Custom ESP32 board: pick the panel, set the pins, done. The Pimoroni Inky Frame boards carry a Pico instead of an ESP32 and have their own page.
Flash the device
-
Click New frame in the backend and choose Flash embedded device.
-
Pick the platform (ESP32-S3 or ESP32-C3), your hardware preset or panel, and enter your WiFi credentials.
-
The backend builds a firmware image, baking in the WiFi details, backend address, frame API key, panel and pin map.
-
Connect the board over USB and click Flash from browser. This uses Web Serial, so it works in Chrome and Edge, on
https://orlocalhost- a backend served over plainhttp://from another machine (the Home Assistant add-on behind ingress, for instance) has no Web Serial; the Logs panel tells you when that's the case. Either USB port a board offers works: the chip's own USB JTAG/serial debug unit, or a USB Single Serial UART bridge like the CH340 on the Seeed reTerminals (firmware 2026.8.38+; older firmware only answered on the JTAG port, which those boards don't wire up). Or download the image and flash it by hand:esptool.py --chip esp32s3 --port /dev/tty.usbmodem* --baud 460800 \ --flash_size 8MB write_flash 0x0 frameos-esp32-s3.bin
The device boots fully provisioned: it joins your WiFi, registers with the backend, pulls its scenes, and renders.
Skip the build: flash the release image
The firmware card also offers Flash latest release. Instead of compiling an
image with your values baked in, the backend writes the published generic image for your chip and
then configures the board over the USB console - set hardware for the preset first, then panel,
pins, WiFi, backend address and API key. Nothing needs ESP-IDF on the backend's host, and a first
flash takes a minute instead of however long a cold firmware build takes. Every panel driver is in
the generic image already, so the result is the same frame.
Four settings exist only as compile-time defaults and can't be set this way: the hostname, the HTTP response size limit, the device admin login (all reported as warnings on the button), and a frame that terminates TLS with its own certificate, which is refused outright.
Backend toolchain
Firmware images are built by the backend, which needs ESP-IDF v5.5.x on its host. If you run the FrameOS backend with Docker, you are already set - the image ships ESP-IDF and Nim, so browser flashing and firmware builds work with nothing installed on your machine.
For non-Docker installs, install ESP-IDF manually and keep Nim 2.2+ on the backend's PATH:
mkdir -p ~/esp && cd ~/esp
git clone --depth 1 --branch v5.5.4 --recursive --shallow-submodules \
https://github.com/espressif/esp-idf.git
cd esp-idf && ./install.sh esp32s3The backend finds it via IDF_PATH, falling back to ~/esp/esp-idf. Without Nim, the firmware
still builds - as a thin client, without the on-device renderer.
A cold build is ~1300 objects, because every panel driver goes into every image - on a small box
that is tens of minutes. Progress ([N/M]) shows in the frame's log while it runs, the browser
flasher waits on the build's heartbeat rather than a fixed timeout, and ccache keeps the second
build fast. Set FRAMEOS_EMBEDDED_BUILD_ROOT to keep the build directory somewhere persistent (the
Docker image and the Home Assistant add-on already do).
Provisioning by hand
You can also flash the generic image published with every
release
(frameos-*-esp32-s3-generic.bin) and configure the device afterwards. Every supported panel
driver is compiled into that one image - you pick the panel at runtime, no rebuild:
-
Captive portal: an unprovisioned device raises a WiFi hotspot called
FrameOS-XXXX. Join it and any web page redirects to the setup form (WiFi, backend URL, frame ID and API key, panel, GPIO pins). Until then the panel shows the FrameOS status screen with the hotspot's instructions; once provisioned but without scenes, it shows the frame's name, panel, IP and who manages it. -
Serial console (115200 baud) - always available, and quickest for tinkering. It answers on both of the ports a board can bring out, the S3's built-in USB-Serial/JTAG device and UART0 behind a USB-UART bridge:
frameos> status # chip, panel, WiFi, memory, power policy frameos> set hardware waveshare_esp32_s3_photopainter # apply a board preset: panel, pins, buttons, battery wiring frameos> wifi MySSID MyPassword # saves and reboots frameos> set time_zone Europe/Brussels # IANA name; the tz data slice is fetched once online frameos> set panel EPD_7in5_V2 frameos> set pins rst=5,dc=4,cs=3,cs2=-1,busy=6,sck=7,mosi=9,pwr=-1 frameos> render # render right now frameos> ota # check for a firmware update frameos> factory-reset
Scenes on a microcontroller
Install scenes exactly like on any other frame. The device syncs them from the backend as JSON and runs them on-device: the same interpreter as Linux frames, with the standard apps compiled into the firmware and code nodes, expressions and JavaScript apps running on an embedded QuickJS. Scene changes arrive over WiFi in seconds - no reflashing, no compiler.
Scenes are stored one file per scene, so a frame can hold many of them (up to 32, or 512 KB of scene JSON) while keeping only the active one parsed in memory. Switching scenes - from the backend, an event, a button or the schedule - loads the new one and drops the old.
Every built-in app works except two that need a real operating system: Chromium screenshot and RTSP camera snapshot. Calendars, weather, Home Assistant sensors, OpenAI text and images, Immich, Google Photos, Unsplash, Wikimedia Commons, QR codes, charts, SVG, gradients, splits, zoom/pan - all of it runs on the microcontroller, over TLS, straight from the device.
Any-size images, natively
This is the part people don't expect from a microcontroller: you can point an ESP32 frame at a 4000×3000 photo and it just renders it. No proxy, no resize server, no "please host a smaller version".
A naive decode of that JPEG needs 48 MB of pixels - roughly six times the PSRAM on the board. FrameOS never materialises it. The decoder scales while it decodes, straight into the panel-sized canvas: a rolling window of compressed bytes goes in, correctly box-filtered target rows come out, and the peak cost is the canvas plus a few hundred kilobytes. Since 2026.8.39 the bytes go from the socket into the decoder directly - the file is never held in PSRAM or written to flash at all. It works for baseline JPEG, PNG, WebP, BMP and PPM, and it applies to the whole chain
- a rotation or a resize downstream tells the decoder how few pixels it actually needs before a single byte is fetched. Formats that can't stream (progressive JPEG, GIF) are buffered instead, spilling to the SD card when they don't fit.
There's a whole page on how this works and why it matters: Big images on small devices.
Assets and the SD card
Boards with a TF/SD slot mount it at /srv/assets, and it behaves like the assets folder on a
Pi:
- Downloaded images are cached there instead of being re-fetched (and re-paid for) every render.
- Browse, upload, rename and delete files from the frame's Assets tab in the backend.
- The Local image app plays files or folders straight off the card.
- Downloads that have to be held whole - a progressive JPEG, a GIF, anything that isn't an
image - spool to the card's
.cachefolder when they don't fit in memory, and the frame streams them from there. The only ceiling is the frame's HTTP response size setting (4 MiB by default; raise it for big sources). Without a card, whatever is free in the frame's internal storage stands in - about half a megabyte on a typical 8 MB board, more on 16 or 32 MB flash, nothing on 4 MB. Baseline JPEGs and PNGs don't need any of this: they decode straight off the socket. - A
.ttfdropped intofonts/on the card shows up in the editor's font picker for that frame; Sync fonts copies the backend's font store over in one go. See Fonts.
Boards without a card slot work fine - the cache and the spool degrade to memory, and nothing starts failing that used to work.
Fonts
The firmware carries one typeface (Ubuntu Regular). It can also use fonts from the SD card: the frame lists fonts/*.ttf on the card through GET /api/fonts, the editor's font
picker offers them, and the renderer loads the named face off the card - one parsed face at a time,
files over 1 MB refused unparsed, falling back to the built-in face rather than losing the
drawing. Upload them through the Assets tab or use Sync fonts, which skips files the card
already has. A frame without a card, or one that's short of render memory, renders in the built-in
face.
Two things stay Pi-only: SVG <text> always uses the built-in face on a microcontroller
(a second parsed font would come out of the heap the renderer needs), and there is no emoji
fallback - NotoColorEmoji.ttf is 10 MB of bitmaps with no outlines to draw.
Updates, control and telemetry
- Over-the-air updates: A/B partitions with automatic rollback - if a new build fails to come up, the next reset boots the previous one. The device checks the backend daily, or on demand, and verifies the image's SHA-256 against the manifest before installing it. A sleeping frame stays awake for the download, logs its progress, and gives up on a release after three failed attempts rather than re-downloading it on every wake. (The 4 MB flash profile has no OTA slot; those boards update over USB.)
- A real HTTP API on the device:
GET /status,/image,/state,/states, andPOST /event/<name>to dispatch any event into the running scene, plus logs, metrics and asset routes under/api/frames/<id>/- much the same surface the Pi's local API exposes. Native HTTPS is available when the frame is built with certificates. - Logs and metrics stream to the backend, and the last 128 lines are kept in a ring buffer on the device, readable over the serial console when the network is down.
- An on-device scheduler: per-scene render intervals plus minute/hour/weekday schedules that switch scenes, fire events, or restart/reboot the device (a Maintenance entry in the Schedule panel), evaluated on the device in the frame's time zone (firmware 2026.8.34+; earlier firmware runs on UTC).
- Buttons: GPIO buttons (the board presets know where they are) dispatch scene events, so a press can change a scene, re-render, or run anything you wire up in the editor.
Battery and deep sleep
This is where a microcontroller frame beats a Pi. Configure it under the frame's Power settings, or on the console:
| Setting | What it does |
|---|---|
deep_sleep | Power the chip down between refreshes and cold-boot for the next one |
deep_sleep_on_battery | Only sleep while running on a battery; stay connected on USB power |
wake_schedule | Align wakes to wall-clock boundaries, so a clock or calendar updates on the hour |
wake_check | Wake at least this often to pick up queued commands, without refreshing the panel |
battery_pin / battery_divider | Read cell voltage on an ADC1 pin; percentage shows in the backend |
battery_enable_pin | The GPIO that switches the divider on for a reading, on boards that gate it (-1 = always on) |
Without wake_schedule the time already spent awake - boot, WiFi, render - is subtracted from
the interval, so the cadence doesn't drift by however long the render took. Below 3% battery the
frame skips the refresh and sleeps for six hours rather than cycling a flat cell into damage - and
it takes two readings a moment apart and two passes in a row to believe it, so one noisy ADC
sample can't park a healthy cell in the six-hour sleep.
Buttons wake the frame. Every GPIO button on a wake-capable pin is armed as a wake source next to the timer (2026.8.42+). A press brings the frame up early, replays as an ordinary button event to the scene, and that pass renders - so "next photo" works on a sleeping frame. While asleep the WiFi radio is in modem sleep, the clock is kept by the RTC (no time-sync wait on wake), and the log queue and the battery reading are flushed to the backend or cloud before the chip halts, so a battery frame's history is complete.
The backend and the cloud both know when a frame is sleeping: right before it halts, the frame announces when it will be back, and the frame list shows asleep · wakes in 5 min (or overdue when the wake never came) instead of a stale "last seen". Battery frames show their charge in place of the status dot. Anything you deploy meanwhile is queued and lands on the next wake. On the 13.3" E1004 a heavy scene keeps the frame awake for 80-90 s per cycle: ~23 s to render, ~22 s to dither and pack, ~30 s for the Spectra 6 refresh.
The board presets carry the battery wiring - the PhotoPainter and 13.3E6 headers, the Seeed
reTerminals' switched divider, the XIAO ePaper driver board, the TRMNL and the XTEINK X4 (only the
E1004's is verified on hardware so far) - and the reTerminal E1004
preset also switches on deep_sleep_on_battery with a 15-minute wake_check, so it sleeps on its
5000 mAh cell and stays connected on USB out of the box. A frame provisioned before its preset
knew the wiring keeps battery_pin -1 until you re-run set hardware on the console or fill in the
three values under Power.
How fast is it?
Slower than a Pi, and mostly irrelevant - the panel is the bottleneck either way. Measured on the 7.3" Spectra 6 PhotoPainter board (8 MB PSRAM), rendering Weather, the heaviest sample scene:
| Step | Time |
|---|---|
| Cold-boot scene load + JS transpile | ~3.3 s (once, at boot) |
| SVG rasterisation | 7-8 s |
| Dither + pack to the Spectra 6 palette | ~3.2 s |
| Panel refresh (the panel's own hardware) | ~29 s |
Simpler scenes are much quicker; a black/white panel dithers and refreshes in a fraction of that. A frame that updates a few times an hour, or a few times a day on battery, never notices.
Memory on the same board: 7.1 MB PSRAM free with a scene resident, 4.2 MB still free at the worst point of a render. If a panel is too large for a board's PSRAM, the backend says so when you build the firmware rather than shipping you an image that can't render.
The scene canvas is claimed once from PSRAM before WiFi starts and reused for every render. Its format is decided per board: full colour when a canvas fits in half the module's PSRAM, 16-bit RGB565 otherwise. An 800×480 panel on 8 MB and a 1200×1600 panel on 16 MB render in full colour; a 1200×1600 panel on an 8 MB module - the reTerminal E1004 - is the one that takes the 16-bit canvas (3.7 MB instead of 7.3 MB), which is what lets it render on-device at all. The 16-bit canvas dithers its stores so gradients stay smooth, and since every panel the firmware drives is dithered e-paper, a photo dithered to Spectra 6 through either canvas lands the same palette within 0.05% per colour. The Pi, HDMI and the browser preview keep a full RGBA canvas.
Wiring
Default pins target the XIAO ESP32-S3 (a board preset overrides all of these):
| Signal | GPIO | XIAO label |
|---|---|---|
| CS | 3 | D2 |
| DC | 4 | D3 |
| RST | 5 | D4 |
| BUSY | 6 | D5 |
| SCK | 7 | D8 |
| MOSI | 9 | D10 |
Remap at runtime with set pins rst=5,dc=4,cs=3,busy=6,sck=7,mosi=9,pwr=-1 on the serial
console, in the captive portal, or per-frame in the backend. The 13.3" Spectra 6 panel has two
controllers and needs cs2 as well.
What it cannot do
Straight, so you can decide before you buy:
- Panels: Waveshare-family SPI e-paper only. No HDMI, no LCD, no Pimoroni Inky HATs, no Python-backed drivers. The 10.3" IT8951 and the 12.48" multi-controller panels need a Pi.
- Two apps are missing: Chromium screenshot and RTSP camera snapshot. Both need to launch a real program on a real OS.
- No Nim app editing. Scenes run interpreted, like everywhere else. You can write and fork apps in JavaScript/TypeScript and use JS code nodes, but editing an app's Nim source means compiling, which means a Pi. The backend warns you when a scene contains compiled-only content.
- Fonts are limited. One built-in typeface, plus TTFs from the SD card loaded one at a time
(Fonts). No emoji fallback, and SVG
<text>always renders in the built-in face. - No on-device admin web app. The frame answers the HTTP API and serves a setup page, but the
full
/adminpanel and the control page with the QR code are Pi-side. Manage ESP32 frames from the backend. (GET /statusdoes report the chip, the hardware preset, and internal heap and largest-block figures - useful when a thin client runs out of memory.) - It's slow. Seconds per render, not milliseconds. Fine for e-ink, useless for anything that needs to move.
- PSRAM is the ceiling. No PSRAM means thin-client mode, where the backend has to be reachable at render time.
Raspberry Pi or ESP32?
| Raspberry Pi | ESP32-S3 | |
|---|---|---|
| Displays | 120+ drivers: e-ink, HDMI, LCD | Waveshare SPI e-paper, every driver in one image |
| Custom code | JavaScript, or Nim in a compiled scene | JavaScript/TypeScript |
| Apps | All of them | All but Chromium screenshots and RTSP |
| Fonts | Any TTF you upload, emoji fallback | Built-in typeface, plus TTFs on the SD card |
| Power | ~1 W, wall-powered | Deep sleep between refreshes - battery is viable |
| Updates | SSH / Remote deploys | Signed OTA with A/B rollback |
| Price | ~$15-20 (Zero 2 W) | ~$6-10 (bare S3), or an all-in-one board |
In short: pick the Pi for the broadest hardware and the deepest customisation, and the ESP32 for a battery-powered, self-contained frame - or because you already own one of the all-in-one boards. Want to help port a panel? See the firmware README and say hi on Discord.