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 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:

PresetChipPanelNotes
Waveshare ESP32-S3 PhotoPainterS37.3" Spectra 68 MB PSRAM, TF card slot, battery header, 2 buttons
Waveshare ESP32-S3 ePaper 13.3E6S313.3" Spectra 616 MB PSRAM, 32 MB flash, TF card, battery sensing
Seeed reTerminal E1001S37.5" black/white3 front buttons, 32 MB flash, battery sensing
Seeed reTerminal E1002S37.3" Spectra 63 front buttons, 32 MB flash, battery sensing
Seeed reTerminal E1004S313.3" Spectra 6, portrait8 MB PSRAM, 32 MB flash, TF card, 3 buttons, 5000 mAh battery - sleeps on battery by default
Seeed reTerminal StickyS33.97" black/whiteSmall desk/fridge frame
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
TRMNL OGC37.5" black/whiteThin client - backend renders
TRMNL BWRYC37.5" 4-colourThin client - backend renders
XTEINK X4C34.26" 4-greyThin 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.

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

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

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 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. (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, 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. 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:

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, 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. 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:

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.

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):

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 (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 /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, 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 PiESP32-S3
Displays120+ drivers: e-ink, HDMI, LCDWaveshare SPI e-paper, every driver in one image
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