FrameOS
Guide

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:

PresetChipPanelNotes
Waveshare ESP32-S3 PhotoPainterS37.3" Spectra 68 MB PSRAM, TF card slot, battery header, 2 buttons, speaker and microphone
Waveshare ESP32-S3 ePaper 13.3E6S313.3" Spectra 616 MB PSRAM, 32 MB flash, TF card, battery sensing, speaker and microphone
Seeed reTerminal E1001S37.5" black/white3 front buttons, 32 MB flash, TF card, battery and charger, clock, microphone
Seeed reTerminal E1002S37.3" Spectra 63 front buttons, 32 MB flash, TF card, battery and charger, clock, microphone
Seeed reTerminal E1003S310.3" 16-level grayscale (IT8951), touch3 buttons, 32 MB flash, TF card, battery, microphone
Seeed reTerminal E1004S313.3" Spectra 6, portrait8 MB PSRAM, 32 MB flash, TF card, 3 buttons, 5000 mAh battery and charger, clock - sleeps on battery by default
Seeed reTerminal StickyS33.97" black/white, touchSmall desk/fridge frame: 3 keys, microphone, battery gauge
Seeed SenseCAP IndicatorS34" 480×480 color LCD, touch8 MB PSRAM, one button - animates and answers touch (LCD and touch)
Elecrow CrowPanel 5.79"S35.79" 4-grey5 buttons, dual SSD1683
TRMNL 7.5" DIY KitS37.5" black/whiteXIAO ePaper driver board
TRMNL 4.26" DIY KitS34.26" 4-greyXIAO ePaper driver board
Seeed XIAO ePaper Display Board EE02S313.3" Spectra 6, portrait8 MB PSRAM, 16 MB flash, 3 buttons, battery
Seeed XIAO ePaper Display Board EE03S310.3" 16-level grayscale (IT8951)8 MB PSRAM, 16 MB flash, 3 buttons, battery, temperature sensor
Seeed XIAO ePaper Display Board EE04 / EE05S3Any 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 OGC37.5" black/whiteThin client - backend renders
TRMNL BWRYC37.5" 4-colorThin client - backend renders
XTEINK X4C34.26" 4-greyThin client - backend renders
Seeed XIAO 7.5" ePaper PanelC37.5" black/whiteThin client - backend renders
ESP32-C3 0.42" OLED boardC30.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

  1. Click New frame in the backend and choose Flash embedded device.

  2. Pick the platform (ESP32-S3 or ESP32-C3), your hardware preset or panel, and enter your WiFi credentials.

  3. 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.

  4. 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:// or localhost - a backend served over plain http:// 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 (-generic is 8 MB on an S3 and 4 MB on a C3, next to -4mb, -8mb, -16mb and -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, config prints it on the serial console, and set ap_psk changes 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 .cache folder 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 .ttf dropped into fonts/ 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 downgrade on 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, and POST /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 /ping needs 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 1 on 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 button events - press, doublePress, longPress - and raw gpio down / up edges (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_hz and beep to 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; led on the console tests it.
  • A cloud frame with a backend set too: set backend on 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. status and 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:

BoardButton actions out of the box
reTerminal E1001, E1002, E1003, E1004LEFT 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 StickyUP held = previous scene, DOWN held = next scene, AI held = voice chat
SenseCAP IndicatorButton 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.3E6KEY1 / 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, longPress and swipe events 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: and active: 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, turnOff and toggleScreen events.
  • 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:

SettingWhat it does
deep_sleepPower the chip down between refreshes and cold-boot for the next one
deep_sleep_on_batteryOnly sleep while running on a battery; stay connected on USB power
wake_scheduleAlign wakes to wall-clock boundaries in the frame's time zone, so a clock or calendar updates on the hour
wake_checkWake at least this often to pick up queued commands, without refreshing the panel
battery_pin / battery_dividerRead cell voltage on an ADC1 pin; percentage shows in the backend
battery_enable_pinThe 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:

StepTime
Cold-boot scene load + JS transpile~3.3 s (once, at boot)
SVG rasterisation7-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):

SignalGPIOXIAO label
CS3D2
DC4D3
RST5D4
BUSY6D5
SCK7D8
MOSI9D10

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 /admin panel and the control page with the QR code are Pi-side. Manage ESP32 frames from the backend. (GET /status does 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 PiESP32-S3
Displays120+ drivers: e-ink, HDMI, LCDWaveshare SPI e-paper and one 4" LCD, every driver in one image
Voice-Hold a key and talk, on boards with a microphone
Custom codeJavaScript, or Nim in a compiled sceneJavaScript/TypeScript
AppsAll of themAll but Chromium screenshots and RTSP
FontsAny TTF you upload, emoji fallbackBuilt-in typeface, plus TTFs on the SD card
Power~1 W, wall-poweredDeep sleep between refreshes - battery is viable
UpdatesSSH / Remote deploysSigned 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.

Next step

Deploy your first scene.

On this page