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 - or no display at all. The 10.3" IT8951 and 12.48" panels use different controller stacks and still need a Pi.
  • The backend, to build the firmware and design scenes. Ready-made generic images are also published with every release if you'd rather provision by hand.

Supported boards

FrameOS ships hardware presets - pick one in the backend and the panel, GPIO map, buttons, battery divider, SD slot and flash size are all filled in for you:

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
Seeed reTerminal E1002S37.3" Spectra 63 front buttons, 32 MB flash
Seeed reTerminal E1004S313.3" Spectra 6, portrait8 MB PSRAM, 32 MB flash, TF card, battery
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

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. The backend builds a firmware image, baking in the WiFi details, backend address, frame API key, panel and pin map.

  4. Connect the board over USB and click Flash from browser. 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. Or download the image and flash it by hand:

    esptool.py --chip esp32s3 --port /dev/tty.usbmodem* --baud 460800 \
      --flash_size 8MB write_flash 0x0 frameos-esp32-s3.bin

The device boots fully provisioned: it joins your WiFi, registers with the backend, pulls its scenes, and renders.

Skip the build: flash the release image

The firmware card also offers Flash latest release. Instead of compiling an image with your values baked in, the backend writes the published generic image for your chip and then configures the board over the USB console - set hardware for the preset first, then panel, pins, WiFi, backend address and API key. Nothing needs ESP-IDF on the backend's host, and a first flash takes a minute instead of however long a cold firmware build takes. Every panel driver is in the generic image already, so the result is the same frame.

Four settings exist only as compile-time defaults and can't be set this way: the hostname, the HTTP response size limit, the device admin login (all reported as warnings on the button), and a frame that terminates TLS with its own certificate, which is refused outright.

Backend toolchain

Firmware images are built by the backend, which needs ESP-IDF v5.5.x on its host. If you run the FrameOS backend with Docker, you are already set - the image ships ESP-IDF and Nim, so browser flashing and firmware builds work with nothing installed on your machine.

For non-Docker installs, install ESP-IDF manually and keep Nim 2.2+ on the backend's PATH:

mkdir -p ~/esp && cd ~/esp
git clone --depth 1 --branch v5.5.4 --recursive --shallow-submodules \
  https://github.com/espressif/esp-idf.git
cd esp-idf && ./install.sh esp32s3

The backend finds it via IDF_PATH, falling back to ~/esp/esp-idf. Without Nim, the firmware still builds - as a thin client, without the on-device renderer.

A cold build is ~1300 objects, because every panel driver goes into every image - on a small box that is tens of minutes. Progress ([N/M]) shows in the frame's log while it runs, the browser flasher waits on the build's heartbeat rather than a fixed timeout, and ccache keeps the second build fast. Set FRAMEOS_EMBEDDED_BUILD_ROOT to keep the build directory somewhere persistent (the Docker image and the Home Assistant add-on already do).

Provisioning by hand

You can also flash the generic image published with every release (frameos-*-esp32-s3-generic.bin) and configure the device afterwards. Every supported panel driver is compiled into that one image - you pick the panel at runtime, no rebuild:

  • Captive portal: an unprovisioned device raises a WiFi hotspot called FrameOS-XXXX. Join it and any web page redirects to the setup form (WiFi, backend URL, frame ID and API key, panel, GPIO pins). Until then the panel shows the FrameOS status screen with the hotspot's instructions; once provisioned but without scenes, it shows the frame's name, panel, IP and who manages it.

  • Serial console (115200 baud) - always available, and quickest for tinkering:

    frameos> status
    frameos> set hardware waveshare_esp32_s3_photopainter   # apply a board preset
    frameos> wifi MySSID MyPassword          # saves and reboots
    frameos> set time_zone Europe/Brussels   # IANA name; the tz data slice is fetched once online
    frameos> set panel EPD_7in5_V2
    frameos> set pins rst=5,dc=4,cs=3,cs2=-1,busy=6,sck=7,mosi=9,pwr=-1
    frameos> render                          # render right now
    frameos> ota                             # check for a firmware update
    frameos> factory-reset

Scenes on a microcontroller

Install scenes exactly like on any other frame. The device syncs them from the backend as JSON and runs them on-device: the same interpreter as Linux frames, with the standard apps compiled into the firmware and code nodes, expressions and JavaScript apps running on an embedded QuickJS. Scene changes arrive over WiFi in seconds - no reflashing, no compiler.

Scenes are stored one file per scene, so a frame can hold many of them (up to 32, or 512 KB of scene JSON) while keeping only the active one parsed in memory. Switching scenes - from the backend, an event, a button or the schedule - loads the new one and drops the old.

Every built-in app works except two that need a real operating system: Chromium screenshot and RTSP camera snapshot. Calendars, weather, Home Assistant sensors, OpenAI text and images, Immich, Google Photos, Unsplash, Wikimedia Commons, QR codes, charts, SVG, gradients, splits, zoom/pan - all of it runs on the microcontroller, over TLS, straight from the device.

Any-size images, natively

This is the part people don't expect from a microcontroller: you can point an ESP32 frame at a 4000×3000 photo and it just renders it. No proxy, no resize server, no "please host a smaller version".

A naive decode of that JPEG needs 48 MB of pixels - roughly six times the PSRAM on the board. FrameOS never materialises it. The decoder scales while it decodes, straight into the panel-sized canvas: a rolling window of compressed bytes goes in, correctly box-filtered target rows come out, and the peak cost is the canvas plus a few hundred kilobytes. Downloads too big to hold in RAM spool to the SD card and stream from there. It works for 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.

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.
  • Images that are too big to decode in memory spool to the card's .cache folder and stream from it - the only ceiling is the frame's HTTP response size setting (4 MiB by default; raise it for big photos). Spilled images must be baseline JPEGs. 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 - so an SD card is what makes big photos practical.
  • 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 SHA-256 against the manifest before installing it. (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 is available when the frame is built with certificates.
  • Logs and metrics stream to the backend, and the last 128 lines are kept in a ring buffer on the device, readable over the serial console when the network is down.
  • An on-device scheduler: per-scene render intervals plus minute/hour/weekday schedules that switch scenes, fire events, or restart/reboot the device (a Maintenance entry in the Schedule panel), evaluated on the device in the frame's time zone (firmware 2026.8.34+; earlier firmware runs on UTC).
  • Buttons: GPIO buttons (the board presets know where they are) dispatch scene events, so a press can change a scene, re-render, or run anything you wire up in the editor.

Battery and deep sleep

This is where a microcontroller frame beats a Pi. Configure it under the frame's Power settings, or on the console:

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

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.

The all-in-one boards with battery headers (PhotoPainter, 13.3E6) come with their battery wiring already in the preset.

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 when you build the firmware rather than shipping you an image that can't render.

The scene canvas is 16-bit RGB565 (2 bytes per pixel) rather than RGBA, claimed once from PSRAM before WiFi starts and reused for every render. A 1200×1600 canvas is 3.7 MB instead of 7.3 MB, which is what lets the 13.3" Spectra 6 panels render on an 8 MB module like the reTerminal E1004. It costs nothing visible: every panel the firmware drives is dithered e-paper, the dither keeps full-precision error rows beside the canvas, and a photo dithered to Spectra 6 through both canvases 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 only. No HDMI, no LCD, no Pimoroni Inky HATs, no Python-backed drivers. The 10.3" IT8951 and the 12.48" multi-controller panels need a Pi.
  • Two apps are missing: Chromium screenshot and RTSP camera snapshot. Both need to launch a real program on a real OS.
  • No Nim app editing. Scenes run interpreted, like everywhere else. You can write and fork apps in JavaScript/TypeScript and use JS code nodes, but editing an app's Nim source means compiling, which means a Pi. The backend warns you when a scene contains compiled-only content.
  • Fonts are limited. One built-in typeface, plus TTFs from the SD card loaded one at a time (Fonts). No emoji fallback, and SVG <text> always renders in the built-in face.
  • No on-device admin web app. The frame answers the HTTP API and serves a setup page, but the full /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