Files
espresso_frame/firmware/README.md
T
tfaour 08960c9eec
Firmware build check / build-check (push) Successful in 2m45s
Build and release firmware / build-and-release (push) Successful in 2m45s
Swap combo button tiers: quick press resets, ~3s hold shows menu
Quick reset is now the fast/default action; summoning the management
menu takes a deliberate hold. Factory reset at ~15s is unchanged.
Renamed FRAME_COMBO_SOFT_RESET_HOLD_MS -> FRAME_COMBO_MENU_HOLD_MS to
match its new meaning. Bumps firmware to 1.4.1.
2026-07-27 22:40:44 +00:00

17 KiB

ESPresso Frame Firmware

ESP-IDF firmware for the ESP32-C6. On first boot it provisions itself over a WiFi captive portal; after that it wakes on a timer, fetches an already-processed frame from the server, streams it straight to the panel over SPI, and goes back to deep sleep.

See docs/architecture.md for the full boot/fetch cycle and docs/hardware.md for wiring.

Build and flash

Requires ESP-IDF (developed against v5.x/v6.x) with the environment sourced (. $IDF_PATH/export.sh or your distro's equivalent).

idf.py set-target esp32c6
idf.py build
idf.py -p PORT flash monitor

(Ctrl-] exits the monitor.)

The commands above target the dev board this project is built against (ESP32-C6-DevKitC-1, 8MB flash) -- the committed sdkconfig.defaults pins that flash size and a custom partitions.csv with dual OTA app partitions (2MB each; see OTA updates below), since the default "single app" ~1MB partition runs out of room once the HTTP client, TLS, and vendored fonts/QR library are linked in.

Building for the Seeed XIAO ESP32-C6 (production board)

The XIAO has only 4MB of flash, which doesn't fit the dev board's two 2MB OTA slots -- it needs its own partition table (partitions_xiao.csv, 1.875MB slots) and flash-size setting. Rather than hand-editing sdkconfig back and forth between boards, use build_for_board.sh, which builds each board into its own directory with its own generated config, so switching back and forth never clobbers the other:

./build_for_board.sh xiao build
./build_for_board.sh xiao flash monitor -p PORT

(./build_for_board.sh devkit ... does the same for the dev board -- equivalent to a plain idf.py, just consistent with the XIAO invocation.)

Configuration (idf.py menuconfig)

Under ESPresso Frame Configuration:

Option Default What it does
ESP_AP_SSID ESPRESSO Provisioning softAP SSID prefix (device appends _XXXXXX from its MAC)
ESP_MAX_STA_CONN 4 Max clients on the provisioning softAP
ESP_ENABLE_DHCP_CAPTIVEPORTAL on DHCP Option 114 captive portal detection
FRAME_STA_CONNECT_MAX_RETRIES 3 Home WiFi connect attempts before falling back to provisioning
FRAME_STA_CONNECT_TIMEOUT_MS 15000 Per-attempt WiFi connect timeout
FRAME_SERVER_CHECK_TIMEOUT_MS 3000 Timeout for GET /frame/config (reachability check + refresh interval)
FRAME_FETCH_TIMEOUT_MS 15000 Timeout for GET /frame/image
FRAME_SLEEP_INTERVAL_S 3600 Fallback only -- the refresh interval is normally set server-side; see below
FRAME_RETRY_INTERVAL_S 300 Sleep duration after a failed cycle, before retrying
FRAME_NEXT_BUTTON_GPIO 2 Next-photo button GPIO (-1 to disable). Must be 0-7 (ESP32-C6's deep-sleep-wakeup-capable pins)
FRAME_BACK_BUTTON_GPIO 0 Back-photo button GPIO (-1 to disable). Must be 0-7
FRAME_COMBO_BUTTON_GPIO 1 Menu/reset button GPIO (-1 to disable). Must be 0-7
FRAME_COMBO_MENU_HOLD_MS 3000 How long the combo button must be held (then released) to show the management menu
FRAME_COMBO_FACTORY_RESET_HOLD_MS 15000 How long the combo button must be held to factory-reset
FRAME_HOLD_ACTION_MS 3000 Fallback only -- how long NEXT/BACK must be held to trigger a global action instead of a short press; see below
FRAME_BATTERY_ADC_GPIO -1 (disabled) Battery voltage-divider ADC GPIO; see the Battery section below
FRAME_VBUS_SENSE_GPIO -1 (disabled) USB-power sense GPIO for hiding the battery indicator on mains

Under E-Paper Display (epd7in3e) Configuration: SPI/GPIO pin assignments and SPI clock speed -- see docs/hardware.md for the wiring these correspond to.

Why FRAME_SLEEP_INTERVAL_S says "fallback"

The actual refresh interval is set from the server's web UI (see server/README.md) and delivered to the device on every wake via GET /frame/config, so it can be changed without reflashing. The Kconfig value only applies before the device has ever successfully reached a configured server, or if the response doesn't include a valid interval.

Holding NEXT/BACK for a global action

Past FRAME_HOLD_ACTION_MS, holding NEXT or BACK stops meaning "advance/ back this widget" and instead triggers whatever frame-wide action (if any) is configured for that button's hold on the server's Configuration tab -- e.g. cycling through saved layouts (see server/app/global_actions.py). Fires immediately at the threshold, without waiting for release -- same convention as the combo button's factory-reset tier below.

Same "fallback only" caveat as FRAME_SLEEP_INTERVAL_S above, but with one more wrinkle: the server's actual hold_duration_ms (set on the Configuration tab, GET /frame/config's response) can't be used for this wake's button decision -- that decision happens in main.c before WiFi even connects, but /frame/config isn't fetched until near the end of the wake cycle (after the image fetch, deliberately -- see frame_client_run's own comment on why). So the device always acts on whatever value the previous wake fetched (persisted in NVS via frame_config_set_hold_duration_ms), falling back to FRAME_HOLD_ACTION_MS only before it's ever successfully fetched one. In practice this means changing the duration on the Configuration tab takes effect starting with the wake after the next one, not immediately.

Holding a button through the poll loop keeps the device awake and connected longer than a normal short-press wake -- the same tradeoff already accepted for the combo button's menu/reset holds below.

WiFi fast-connect

After a successful home-WiFi connection, the device caches the AP's BSSID/channel and its own IP/netmask/gateway/DNS in NVS. The next wake's first connect attempt uses that cache to skip the all-channel scan (wifi_config.sta.bssid_set + a channel hint) and DHCP (a static IP set directly) -- typically a couple of seconds less radio-on time per wake, free every wake since it's already-known information, not a fresh negotiation.

If that cached attempt fails outright, or it "succeeds" at the WiFi layer but the server turns out to be unreachable (a stale cached IP, DNS entry, or gateway), the cache is cleared and that wake falls back to a normal scan + DHCP -- and the next wake tries the fast path again from a fresh cache. A (re)provisioning event or factory reset also clears it, since a new network shouldn't try to reuse the old one's cached BSSID. No user-facing config for this -- it's purely an internal optimization, invisible unless you're watching the serial log ("fast path: cached BSSID/channel + static IP" vs "attempt N/M").

First boot

With no stored WiFi config (a fresh device, or after erasing NVS -- see below), the device brings up the display before anything else and shows a two-step setup screen:

  1. Connect to WiFi -- a QR code encoding WIFI:T:WPA;S:...;P:...;; for the device's own ESPRESSO_XXXXXX softAP, with the SSID and password also printed underneath for anyone provisioning from a desktop/laptop that can't scan a QR code.
  2. Configure device -- a QR code linking straight to the captive portal's config page (http://192.168.4.1/ by default), for a one-scan shortcut once you've joined the AP.

The config page asks for your home WiFi SSID/password, the "Tools Server" address (host:port of the server -- not your Immich server; see below for the https:// form), and an optional "Access Token" (see below -- usually blank). Saving hands your browser off to the server's claim page (after ~7 seconds, giving your phone time to rejoin its normal WiFi while the device reboots) so the frame gets linked to your account; the device meanwhile connects to your home network and starts its normal fetch/sleep cycle. The frame identifies itself to the server by ?id= (derived from its WiFi MAC) on every request, and the server issues it a private per-frame token on first contact -- no manual token handling involved.

HTTP vs HTTPS

The Tools Server field accepts either:

  • host:port (e.g. 192.168.1.50:8420) -- plain HTTP, talks straight to the server, which never speaks TLS itself. This is the default and needs nothing extra.
  • https://host[:port] (e.g. https://frame.example.com) -- HTTPS, for a TLS-terminating reverse proxy (nginx, etc.) sitting in front of the server. Every URL the device builds (image fetch, config check, manage-menu overlay data, the QR codes' own links) uses whichever scheme you enter.

The firmware trusts ESP-IDF's standard public CA bundle (esp_crt_bundle_attach) -- so any reverse proxy with a normal publicly-trusted certificate just works out of the box: Let's Encrypt, a Cloudflare-proxied hostname, or any other public CA.

One specific root is added on top of that bundle at build time, from main/certs/additional_root_ca.pem (wired in via CONFIG_MBEDTLS_CUSTOM_CERTIFICATE_BUNDLE_PATH in sdkconfig.defaults): GlobalSign Root CA R1. This isn't a workaround specific to one deployment -- it's needed because Cloudflare (via Google Trust Services) commonly cross-signs its GTS Root R4 chain with this old GlobalSign root for backward compatibility with older/embedded clients, and ESP-IDF's current bundle snapshot has removed it (deprecated from modern trust stores). Without it, ESP-IDF's chain validation -- which looks up a trusted root by the issuer name of whatever certificate it can't otherwise validate, not by matching the presented certificate itself -- comes up empty and the handshake fails with "No matching trusted root certificate found", confirmed on hardware against a real Cloudflare-fronted deployment. Since this is a common, not deployment-specific, gap, it's likely worth keeping even if you're not using Cloudflare.

If your proxy presents a certificate chain the bundle (plus this one addition) still doesn't validate -- an unusual public CA, or a private/ self-signed cert with no public CA in the chain at all -- diagnose with:

openssl s_client -connect <host>:443 -showcerts </dev/null

and add whichever certificate in the chain is missing to additional_root_ca.pem (or a file of your own, if you'd rather not touch the committed one) alongside the existing entry, then rebuild.

The device does perform normal hostname verification (it's not skipped), so the Tools Server field's hostname has to match what the certificate was actually issued for -- a bare LAN IP address (https://192.168.1.50) will fail the handshake even against a perfectly valid cert for a different name.

Access token

Usually blank. Current servers issue each frame its own private token automatically on first contact (delivered via GET /frame/config, persisted in NVS, preferred by build_url() from then on -- and baked into the manage-menu/share QR codes so scanning them just works). The captive portal's "Access Token" field only matters when pointing this firmware at an older (pre-multi-frame) server whose MANAGEMENT_TOKEN is set: paste that shared value and the device sends it (?token=...) until a newer server replaces it with a per-frame one. Re-provisioning clears any stored per-frame token -- a fresh identity handshake with whatever server you point it at next.

Skipping to the next photo

Wire a momentary push button between GPIO2 and GND (internal pull-up, active-low, same wiring style as the other buttons). A press wakes the device (if asleep) and tells the server to advance to the next photo right away, regardless of the configured refresh interval -- no long hold needed, since advancing is easily reversible by pressing again. See FRAME_NEXT_BUTTON_GPIO above to change the pin or disable the feature. Holding it past FRAME_HOLD_ACTION_MS instead means something else entirely -- see "Holding NEXT/BACK for a global action" above.

Normal wakes and reboots never advance the photo on their own -- the server decides when to advance based on its own clock (see server/README.md), so an unplanned reboot just redisplays whatever was already showing instead of skipping ahead.

Going back to the previous photo

Wire a momentary push button between GPIO0 and GND (same wiring style as the other buttons). A press wakes the device (if asleep) and tells the server to return to whichever photo was showing immediately before the current one, regardless of whether it got there by the normal timer or the next-photo button. Pressing next afterwards returns to where you were before pressing back -- it's a real undo, not a separate "recently shown" list. See FRAME_BACK_BUTTON_GPIO above to change the pin or disable the feature.

If there's nothing to go back to yet (freshly provisioned, or you've already gone back as far as there is history), it's a no-op -- the current photo stays exactly as it was, no flash on the panel. Same long-hold caveat as the next-photo button above.

Battery (XIAO ESP32-C6)

Disabled by default (FRAME_BATTERY_ADC_GPIO = -1) -- correct for a dev board with no battery wired. On the intended production board (Seeed XIAO ESP32-C6) with a 1S LiPo:

Wiring (beyond soldering the battery itself to the BAT+/BAT- pads on the XIAO's underside -- note the polarity markings there, and that JST-PH battery connector polarity is not standardized, so verify with a multimeter before first plug-in):

  • Battery sense: a 2x200k voltage divider from BAT+ to GND, midpoint into an ADC-capable pin (GPIO 0-6; the settled design shares the back button's GPIO0/A0 -- the high-impedance divider coexists fine with the button, and firmware time-shares the pin with a brief ADC read once per wake). Set FRAME_BATTERY_ADC_GPIO to match.
  • Mains detection (optional): a 2x100k divider from the 5V pin (which only carries voltage when USB is plugged in) into any spare GPIO -- plain digital high/low, no ADC needed. The divider is required: raw 5V exceeds the 3.3V pin limit. Set FRAME_VBUS_SENSE_GPIO.

Behavior: once per wake the firmware reads the battery voltage, converts it to a percent via a LiPo discharge curve, shows it (battery icon + "NN%") below the "scan to manage" QR box whenever the management menu is up, and reports it to the server (POST /frame/battery), which displays it in the web UI with an "as of" timestamp. All of that is skipped when on mains power (the charging voltage would read misleadingly full), when the reading is implausible (no battery attached), or when the feature is disabled.

Managing the queue, soft-resetting, and factory-resetting

One more button, wired between GPIO1 and GND (same wiring style as the other buttons), covers three actions -- disambiguated purely by how long it's held:

A quick press soft-resets the device -- esp_restart(), keeping the stored WiFi/server config. Useful for recovering a hung device without losing setup.

Holding it ~3 seconds, then releasing wakes the device (if asleep) and overlays several corners of whatever photo is currently showing, leaving the middle of the photo visible and unchanged:

  • Top-right: a QR code -- "SCAN TO MANAGE" -- linking to the server's config page.
  • Top-left: the photo's location, if Immich reverse-geocoded it from GPS EXIF (skipped entirely if not).
  • Bottom-right: the date the photo was taken, if known.
  • Bottom-left: a QR code linking to a public, view-only Immich share link for that exact photo. The link is only created once someone actually scans it, and expires 30 minutes after that.

After 30 seconds with no further presses it automatically reverts to the plain photo. Pressing the button again while this is up escalates to a second menu level -- everything above, plus the name of anyone Immich has identified labeled right next to their face in the photo (skipped for faces Immich hasn't been told a name for; no face detection happens on the device or the server, this is entirely Immich's own People feature). A third press exits immediately rather than waiting out the 30-second timer. Holding the button during this stage doesn't trigger the factory-reset tier below -- the hold-duration read only ever happens once, right when the device first wakes, before any menu is shown.

The device stays awake for the whole menu interaction (up to three physical refreshes: the base overlay, the escalated one, and reverting), so this costs meaningfully more power than a normal wake -- expected for a deliberate, occasional action, same tradeoff as the other buttons.

Holding it ~15 seconds (whether or not you're still holding it -- this fires immediately, it doesn't wait for release) clears the stored WiFi/server config and restarts into provisioning. From either power-on or while the device is deep-asleep, since this GPIO is armed as a wakeup source.

See FRAME_COMBO_BUTTON_GPIO, FRAME_COMBO_MENU_HOLD_MS, and FRAME_COMBO_FACTORY_RESET_HOLD_MS above to change the pin or hold durations, or disable all three actions.

Without the button wired up (or with FRAME_COMBO_BUTTON_GPIO set to -1), reconfiguring still works by erasing the NVS partition over USB:

python -m esptool --chip esp32c6 -p PORT erase-region 0x9000 0x6000

(Offset/size match the nvs entry in partitions.csv; this only wipes the config, not the app itself.)