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 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-color 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, the 10.3" IT8951 panel, one tiny I2C OLED, the 4" touch LCD of the SenseCAP Indicator - or no display at all. The 12.48" panels use a different controller stack 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, button actions, battery divider, SD slot, microphone 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, speaker and microphone |
| Waveshare ESP32-S3 ePaper 13.3E6 | S3 | 13.3" Spectra 6 | 16 MB PSRAM, 32 MB flash, TF card, battery sensing, speaker and microphone |
| Seeed reTerminal E1001 | S3 | 7.5" black/white | 3 front buttons, 32 MB flash, TF card, battery and charger, clock, microphone |
| Seeed reTerminal E1002 | S3 | 7.3" Spectra 6 | 3 front buttons, 32 MB flash, TF card, battery and charger, clock, microphone |
| Seeed reTerminal E1003 | S3 | 10.3" 16-level grayscale (IT8951), touch | 3 buttons, 32 MB flash, TF card, battery, microphone |
| Seeed reTerminal E1004 | S3 | 13.3" Spectra 6, portrait | 8 MB PSRAM, 32 MB flash, TF card, 3 buttons, 5000 mAh battery and charger, clock - sleeps on battery by default |
| Seeed reTerminal Sticky | S3 | 3.97" black/white, touch | Small desk/fridge frame: 3 keys, microphone, battery gauge |
| Seeed SenseCAP Indicator | S3 | 4" 480×480 color LCD, touch | 8 MB PSRAM, one button - animates and answers touch (LCD and touch) |
| 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 |
| Seeed XIAO ePaper Display Board EE02 | S3 | 13.3" Spectra 6, portrait | 8 MB PSRAM, 16 MB flash, 3 buttons, battery |
| Seeed XIAO ePaper Display Board EE03 | S3 | 10.3" 16-level grayscale (IT8951) | 8 MB PSRAM, 16 MB flash, 3 buttons, battery, temperature sensor |
| Seeed XIAO ePaper Display Board EE04 / EE05 | S3 | Any 24-pin panel (EE04: also the 7.3" Spectra 6 on 50 pins) | 8 MB PSRAM, 16 MB flash, 3 buttons, battery - pick your panel and the preset stays |
| TRMNL OG | C3 | 7.5" black/white | Thin client - backend renders |
| TRMNL BWRY | C3 | 7.5" 4-color | Thin client - backend renders |
| XTEINK X4 | C3 | 4.26" 4-grey | Thin client - backend renders |
| Seeed XIAO 7.5" ePaper Panel | C3 | 7.5" black/white | 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. The Seeed Studio buying guide compares the reTerminals and Seeed's XIAO boards.
Not supported yet
Boards people ask about that FrameOS can't drive yet. Each has a device page saying what's missing:
- The 10.3" TRMNL X - a fast-refresh 10.3" grayscale panel. The E1003 drives its 10.3" glass through an IT8951 controller; the TRMNL X drives its glass without one.
- Seeed reTerminal D1001 - an ESP32-P4 with an 8" MIPI-DSI LCD. The firmware builds for the S3 and C3.
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. 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. It joins WPA2 and WPA3 networks (mixed WPA/WPA2 ones too); a WPA1-only (TKIP) network doesn't connect. A frame whose WiFi drops keeps reconnecting every 30 seconds, so a router restart doesn't strand it until the next reboot. It always joins on a 20 MHz channel, like a Pi, even where the router offers 40 MHz: a frame needs a few KB/s, and the narrow channel holds up better on a crowded 2.4 GHz band.
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. Requests from inside the hotspot skip the admin login - the passphrase on the panel is the credential - so a forgotten admin password doesn't mean a factory reset to change WiFi. 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> mic 2 # record two seconds and print the microphone's level frameos> i2c # what answers on the board's I2C buses: clock, charger, sensors frameos> ota # check for a firmware update frameos> ota downgrade # let the next update be an older release, once 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. A frame that stays awake keeps its JavaScript apps' interpreters between
renders while memory allows, and lets them go when a big image needs the room; a
deep-sleeping frame starts every render with fresh ones. So a module variable may or may not
survive to the next render - keep values in scene state or frameos.remember()
(JavaScript apps).
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. Each one is added, replaced or removed on its own: the backend and the cloud send only what changed, so a push that changed one scene of twenty writes one, and Push & activate has it on an awake frame's screen in a second or two, plus the scene's own first render. A changed scene is written whole before the old one goes, so a power cut or a full partition leaves the scene that worked. The frame's page shows how much of the scene storage is free, and each scene tile shows its size on the frame. 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.
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. 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. A progressive JPEG is decoded in strips, so it fits an 8 MB board too, even on a 13.3" panel: read from the SD card it streams like the rest, and off the network its body is buffered first. GIFs are buffered whole, spilling to the SD card when they don't fit.
A huge photo is honest work, not a stall: a decode that keeps consuming its download renews the render's time limit as it goes, up to 15 minutes, and logs its progress with an ETA - a 60-megapixel camera JPEG takes about seven minutes on an S3, and then it shows. A short WiFi drop in the middle of a download doesn't fail the picture either: the request waits up to 20 seconds for the link to come back, and an image the network cut short is streamed once more.
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.
On the reTerminal E-series the TF slot shares the panel's SPI bus: the card takes its turns between panel refreshes, and an empty slot is reported as empty. The SenseCAP Indicator's slot belongs to its second chip, out of the ESP32's reach, so it keeps assets in 1 MB of its own flash instead: room for a handful of photos, with file names of 28 characters at most.
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. 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. A dropped connection resumes from the byte it stopped at, every failure says what it was (timeout,closed,http 404…) and at which byte, and the verdict reaches the backend or cloud even though the board restarts right after. An update waits for a render in progress rather than fighting it for WiFi and memory, and the image must carry the version its signature was made for. (The 4 MB flash profile has no OTA slot; those boards update over USB.) - Deploys and update progress: the backend's deploy drawer has three buttons for an ESP32 - Deploy scenes & settings, Deploy new firmware and Deploy both - and both control planes show a running update on the frame's card: updating firmware 42%, rebooting into 2026.10.19, update failed with the device's reason, or already on 2026.10.19, nothing to install.
- 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 (every route). Native HTTPS turns on after the frame's first settings sync, when it has a certificate to serve. After each render the frame also pushes its picture to a self-hosted backend, so the backend has a sleeping frame's image without pulling it. Every route except/pingneeds the frame's API key or its admin login; firmware before 2026.10.21 answered a request without them with Authentication required but still carried it out, so update a frame on an older release. - Settings on the frame itself: on a touch board, the scene settings you swipe down for have a
gear that opens the frame's own page - the Wi-Fi network (with a scan and Other network…) and
its password, the rotation, the scale, the date and time when
it's offline, the battery and the signal, Check for updates and Install update, and
Render again, Restart FrameOS and Reboot. An update installed there gets the same
signature checks as one from the backend or the cloud. A frame you change there tells its
control plane its new rotation and scale. Turn the page off with
Frame settings on the screen;
set settings_unlock 1on the USB console brings it back whatever the control plane says. - 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. When the backend doesn't answer, the frame keeps its lines and tries again once a minute instead of waiting out a timeout at every step of a render.
- 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. The
JavaScript
format()helper follows the frame's time zone too. The wait between renders counts from when the render was due, so a scene that renders every second does so every second, not every other one. - Buttons: GPIO buttons (the board presets know where they are) reach scenes as
buttonevents -press,doublePress,longPress- and rawgpiodown/upedges (details), so a press can change a scene, re-render, or run anything you wire up in the editor. Every edge is timestamped by the button task, so a tap made during a 30-second panel refresh is still a tap, and a 30 ms debounce catches quick double taps. Change the button map in the frame's settings; the board picks it up on its next settings sync and restarts. What a press does whatever scene is showing - the next scene, say - is a button action. - Button beep: boards with a buzzer - the Seeed reTerminals - click when a press registers and
click lower on the release, the second tap of a double press clicks higher, a held key counts
each second out loud, and a press the board had no room for sounds a dull "no". Button beep
GPIO and Button beep tone (Hz) sit under the frame's GPIO buttons; on the console it's
set buzzer_pin,set buzzer_tone_hzandbeepto test. The Waveshare PhotoPainter and 13.3E6 play the same notes on their speaker. - Status LED: the reTerminal E-series' green LED lights with every note the buzzer plays, so a
frame with its buzzer switched off still shows a press, and it stays lit while the
voice chat listens. Status LED GPIO sits next to the
beep settings;
ledon the console tests it. - A cloud frame with a backend set too:
set backendon an enrolled frame's console (a bench setup) hands its content to that backend, so the frame turns down the cloud's scenes and settings.statusand the cloud's frame page say so, and handing it back takes one button over USB.
Button actions
Settings → Button actions says what a press does on this frame, whatever scene is showing: the next or previous scene, a render, a particular scene, the display on or off, the voice chat, the scene switcher or scene settings, or Back and Forward, on both control planes. On a touch screen an edge swipe can be bound the same way. A press bound there never reaches the scene. The board presets come with sensible ones:
| Board | Button actions out of the box |
|---|---|
| reTerminal E1001, E1002, E1003, E1004 | LEFT held = previous scene, RIGHT held = next scene; REFRESH held = voice chat (not on the E1004, which has no microphone). The E1003 adds the edge swipes |
| reTerminal Sticky | UP held = previous scene, DOWN held = next scene, AI held = voice chat |
| SenseCAP Indicator | Button pressed = screen on/off, pressed twice = scene switcher; the edge swipes: up = scene switcher, down = scene settings, in from the left = back, from the right = forward |
| PhotoPainter, 13.3E6 | KEY1 / BOOT held = voice chat |
Change or clear them in Settings. A board keeps the map it was set up with, so one from before a
preset changed gets the new one with set hardware <preset> and restart on its console. A scene
written for other keys - a store scene that listens for
A and B - can be told which of your frame's keys are its A and B with Map buttons in
the installed scene's menu, without touching the scene (details). The
frame's status screen lists every button with the action it's bound to.
Voice chat
Hold the frame's voice key for a second and talk to it: "show the
weather", "put a photo of a lighthouse on", "make the text bigger". The buzzer plays a short
rising sparkle and the status LED lights while the frame listens; let go and it answers. What you
said streams to your backend or FrameOS Cloud while you're still talking, is transcribed there
with OpenAI's gpt-4o-mini-transcribe, and goes to the same AI chat the workspace has, with this
frame in its context and a small set of tools for it: switch scenes, set a field of the scene
showing, redraw, rotate the frame, show a picture, play a photo album, or have the scene AI build
a new scene or change the one showing. The audio isn't kept once the answer is in.
The answer is drawn across the bottom of the panel for 30 seconds - what was heard, in quotes, and the reply - and lands in the frame's chat in the workspace, with a log of what each sentence did. The buzzer sounds the outcome: three rising notes when something was done, one low note when nothing was heard. It's one conversation per frame, so a follow-up like "no, the other one" knows what the last sentence did. A sentence can be up to 30 seconds long.
- Which boards: the reTerminal E1001 and E1002 (hold REFRESH), the E1003 (REFRESH), the reTerminal Sticky (the AI key), and the Waveshare PhotoPainter (KEY1) and 13.3E6 (BOOT) through their codec's microphones. The E1004 has no microphone; a PDM microphone on its J2 header works once Microphone GPIO in Settings names its pins. A Pi frame can't do this yet.
- Which key: a button action set to Voice chat, always on a long press - the frame listens while the key is held and stops when it's let go.
- What it may do: four switches under Settings → Buzzer, LED and microphone → Voice chat may: Change settings, Use installed scenes, Create new scenes and Access photo albums, all on until you switch one off, so "show a picture of a chicken" works out of the box. A picture or scene made by voice lands on the frame as a preview, never on its saved list: the scene list shows it with Install and Discard (preview on frame).
- What it needs: the OpenAI key the AI chat uses - Settings → OpenAI on the backend; your own key or the shared one on the cloud. The Voice chat switch in Settings turns the microphone off altogether, and frames without a microphone don't show any of these controls.
LCD and touch
The SenseCAP Indicator brings a 4" 480×480 color touch LCD to the ESP32. An LCD changes what a microcontroller frame can do:
- Animation. A scene whose render interval is under a second runs back to back: the store's DVD Logo Bounce, written in JSX, moves at about 29 frames a second, and the board's talk with the backend - scenes, settings, commands, logs and the picture - runs on a task of its own meanwhile. The scene draws straight into the LCD's own frame buffer, and only the part of the picture that changed is drawn again.
- Touch. The touch glass of the Indicator, the reTerminal Sticky
and the E1003 reaches scenes as the same
pointerDown,pointerMove,pointerUp,tap,doubleTap,longPressandswipeevents a Pi's touchscreen sends (details). A swipe in from the screen's edge is the frame's: out of the box on the Indicator, the Sticky and the E1003 it's the scene switcher, the scene settings, back and forward, or whichever button action you bind. A touch on a dark screen only turns it on. Build touch interfaces with JSX and Tailwind classes, which have fields with a keyboard on the screen: on the SenseCAP Indicator a tap on a JSX home panel is back on the glass in about 85 ms. A touch board boots straight into its scenes and joins WiFi in the background, so it never opens the setup hotspot by itself. On touch e-paper only the changed part of the panel is refreshed while you tap,hover:andactive:are not drawn (each would cost a refresh), and with Touch beep on, a press that something takes clicks at once, before the picture can answer. - Display on and off. Display on, Display off and Display on/off switch the backlight,
and nothing renders while it's dark. Over HTTP they're the
turnOn,turnOffandtoggleScreenevents. - Slow renders. A scene switch, a tap or a refresh that hasn't put its picture up within 200 ms shows three pulsing dots until it does. Animations never show them.
- Storing scenes. While a push writes scenes to flash, the LCD shows the FrameOS mark and Updating: a flash write stops the chip from refreshing the screen, so the picture would tear. The new scene replaces it when it's stored.
- Boot. The FrameOS mark shows on black while the board starts, with a line saying what it's waiting for (Connecting to Wi-Fi, Updating firmware, Starting), and an LCD renders as soon as WiFi is up rather than waiting for the clock. A frame whose setup hotspot is up shows a QR code your phone joins it with, and the password and address under it.
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 in the frame's time zone, 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. A pass that outlasts
its interval doesn't reboot straight into the next one: a deep-sleeping frame rests at least as
long as the pass ran, so it works at most half the time, and logs render:overrun to show the
scene is too slow for its interval. 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.
Input keeps it awake. After a button press or a tap, a deep-sleeping frame stays up for five minutes (Stay awake after a press or tap under Power), so the next one is answered at once instead of waking it again.
Buttons wake the frame. Every GPIO button on a wake-capable pin is armed as a wake source
next to the timer. A press brings the frame up early and reaches the scene like any
other press - held, it's a longPress; tapped twice, a doublePress - and that
pass renders, so "next photo" works on a sleeping frame. A press made while a battery frame is
still rendering holds the sleep for one more pass instead of being lost. 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", and its image carries a small moon badge. 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: 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. 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.
Nothing is lost to a sleep, on both control planes. What you ask of a sleeping frame - a scene switch, new state values, a render, a preview, a reboot, a firmware update, a deploy - waits in the frame's command queue on the backend or the cloud, and the frame drains it at the start of its next wake, before it renders, so the change shows on that wake. A queued command lives until past the announced wake. Each scene's state is kept in a file on the device, so a value set from the control page survives the deep sleep instead of lasting one render, and a frame awake on USB is nudged to pull at once.
The board presets carry the battery wiring - the PhotoPainter and 13.3E6 headers, the Seeed
reTerminals' switched divider, the XIAO ePaper boards, 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.
The reTerminal E-series also carry a charger and a clock chip, which FrameOS reads. With USB plugged in the charger says so, and the frame never counts as on battery - so it doesn't deep-sleep on your desk - and the metrics show whether the cell is charging. The clock sets the time on a cold boot, before WiFi is up, and every time sync writes it back. The reTerminal Sticky has a fuel gauge instead of a divider, which reports the charge itself, and the PhotoPainter reads its cell and its charging from its power chip.
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. Text with an outline takes under half a second on a 480×480 scene.
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 by the panel first:
- A grey or black-and-white panel draws in one byte per pixel. Grey levels need no color, so an 800×480 black-and-white canvas is 384 KB instead of 1.5 MB. A sixteen-grey panel draws in four bits, in its controller's own layout, so the E1003's 1872×1404 is 1.3 MB and goes to the panel as it is.
- A color e-paper panel draws in full color when two canvases fit in PSRAM, 16-bit RGB565 otherwise. An 800×480 panel on 8 MB and a 1200×1600 panel on 16 MB render in full color; a 1200×1600 panel on an 8 MB module - the reTerminal E1004 - 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 a photo dithered to Spectra 6 through either canvas lands the same palette within 0.05% per color.
- An LCD draws straight into its own 16-bit frame buffer, so there's nothing to copy or pack.
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, the 10.3" IT8951, one small I2C OLED and the SenseCAP Indicator's 4" LCD only. No HDMI, no other LCDs, no Pimoroni Inky HATs, no Python-backed drivers. 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 on e-paper, not milliseconds. On the SenseCAP Indicator's LCD a simple animation runs at about 29 frames a second and a tap answers in under a tenth of a second, but that's still no Pi 5 at 60.
- 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 and one 4" LCD, every driver in one image |
| Voice | - | Hold a key and talk, on boards with a microphone |
| 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.