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 (plus one tiny I2C OLED) - or no display at all. The 10.3" IT8951 and 12.48" panels use different controller stacks and still need a Pi.
- The backend or FrameOS Cloud, to flash the board and design scenes. Nothing is compiled: both write the signed release image for your chip and flash size, then configure the board over USB.
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 |
| ESP32-C3 0.42" OLED board | C3 | 0.42" SSD1306 OLED, 72×40 (I2C) | The tiny HW-675 / 01Space dev board, 4 MB flash, BOOT button - thin client |
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.
-
Connect the board over USB on the Connect over USB card. It reads the board and offers the next step: Flash FrameOS & set up this frame on a blank board, Set up as this frame on one that runs FrameOS but isn't provisioned yet, and Update over USB / Apply frame settings later on.
-
The browser writes the published release image that matches the board's chip and flash size (it reads the flash id, so a 16 MB board gets the 16 MB layout and a 4 MB C3 its own) and then sends this frame's settings over the USB console - preset, panel, pins, WiFi, backend address, API key, hostname, HTTP response limit and admin login. What a console line can't carry (an HTTPS certificate, service keys, the schedule) arrives with the first settings sync over WiFi. Nothing is compiled, so a first flash takes about a minute. 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 from the release and flash it by hand - there is one per chip and flash layout (-genericis 8 MB on an S3 and 4 MB on a C3, next to-4mb,-8mb,-16mband-32mb):esptool.py --chip esp32s3 --port /dev/tty.usbmodem* --baud 460800 \ --flash_size 8MB write_flash 0x0 frameos-<version>-esp32-s3-generic.bin
The device boots fully provisioned: it joins your WiFi, registers with the backend, pulls its scenes, and renders.
Provisioning by hand
A board flashed by hand with a release image can be configured without the backend's flasher. Every supported panel driver is compiled into every image - you pick the panel at runtime:
-
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). The hotspot is WPA2-protected with a 10-character passphrase generated per device - it's on the status screen,configprints it on the serial console, andset ap_pskchanges it (firmware 2026.9.1+; older firmware raised an open network). Requests from inside the hotspot skip the admin login - the passphrase on the panel is the credential - so a forgotten admin password no longer means a factory reset to change WiFi (2026.9.5+). 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, NVS use, hostname, 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> ota downgrade # let the next update be an older release, once (2026.9.10+) frameos> factory-reset # wipes scenes, schedule, cached data and all settings
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 (the quickts fork, which parses TypeScript itself). 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. A scene that ran the board out of memory is skipped at the next boot - the frame starts on the next scene instead of looping (2026.9.9+).
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 signature against the FrameOS release key before switching to it - the
backend and the cloud both relay the published release for the board's own flash layout, never
a build of their own (firmware 2026.9.2+). It refuses a release older than the one it runs
unless you arm
ota downgradeon the console. 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 turns on after the frame's first settings sync, when it has a certificate to serve. - 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). The
JavaScript
format()helper follows the frame's time zone too, from 2026.8.44. - 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. Click it for a popup with charge, voltage, how often the frame wakes, drain per day, a two-week history with a forecast to empty, and a slider that shows the battery life at another wake cadence. A lone reading far below its neighbours is an ADC misread, not a discharge: from firmware 2026.9.0 the frame reads the cell in rounds and reports the value it believes, and the charts leave the rest out and say how many misreads they ignored. 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 before you flash rather than leaving you with a frame 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 (and one small I2C OLED) 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 Nim, and offers Convert to an interpreted scene to port it to JavaScript.
- 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.