The Tools Server hostname turned out to be Cloudflare-proxied, not a
direct connection to nginx -- so the ESP32 (and any browser) sees
Cloudflare's own edge certificate (issued by Google Trust Services),
never the Origin CA cert, which only ever sits on the Cloudflare-to-
origin leg. Confirmed on hardware: ESP_ERR_HTTP_CONNECT.
Tried switching to ESP-IDF's built-in public CA bundle instead
(esp_crt_bundle_attach) as the more general fix, but that also failed
on hardware ("No matching trusted root certificate found") -- the
bundle's copy of the relevant Google root has the same name and public
key as the live one but a different serial/signature (a reissue), and
the bundle does an exact byte-level match, not a semantic one.
Simplest reliable fix: embed the exact certificate the proxy actually
presents (extracted live via openssl s_client, see
firmware/main/certs/tools_server_ca.pem) and trust that directly via
cert_pem, sidestepping bundle-matching semantics entirely. Documented
in firmware/README.md how to re-extract if the proxy's CA ever changes.
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 committed sdkconfig.defaults pins an 8MB flash
size and a custom partitions.csv (2MB app partition --
the default "single app" ~1MB partition runs out of room once the HTTP
client, TLS, and vendored fonts/QR library are linked in). If your board
has less flash, you'll need to shrink the app partition and drop features
to fit.
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_RESET_BUTTON_GPIO |
3 | Factory-reset button GPIO (-1 to disable). Must be 0-7 (ESP32-C6's deep-sleep-wakeup-capable pins) |
FRAME_RESET_BUTTON_HOLD_MS |
10000 | How long the button must be held to trigger a reset |
FRAME_NEXT_BUTTON_GPIO |
2 | Next-photo button GPIO (-1 to disable). Must be 0-7 |
FRAME_MANAGE_BUTTON_GPIO |
1 | "Scan to manage" button GPIO (-1 to disable). Must be 0-7 |
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.
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:
- Connect to WiFi -- a QR code encoding
WIFI:T:WPA;S:...;P:...;;for the device's ownESPRESSO_XXXXXXsoftAP, with the SSID and password also printed underneath for anyone provisioning from a desktop/laptop that can't scan a QR code. - 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). Saving reboots the device, which then
connects to your home network and starts its normal fetch/sleep cycle.
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 the standard public CA bundle ESP-IDF ships
(esp_crt_bundle_attach, the same root store a browser trusts) -- so
any reverse proxy with a normal publicly-trusted certificate just
works: Let's Encrypt, a Cloudflare-proxied hostname (Cloudflare's own
edge certificate, issued by Google Trust Services or similar -- not
Cloudflare's Origin CA cert, which only ever sits on the Cloudflare-to-
origin leg and is never presented to a public client, ESP32 or browser
alike), or any other public CA. If your proxy uses a private/self-signed
cert instead (no public CA in the chain at all), the public bundle won't
trust it -- that's not supported today, would need switching back to
embedding that specific cert.
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
If the server has MANAGEMENT_TOKEN set (see
server/README.md), it requires that same value
on every request -- the web UI and every device-facing request the
frame itself makes. Paste it into the captive portal's "Access Token"
field and the device sends it (?token=...) on every request
automatically, and bakes it into the manage-menu/share QR codes so
scanning them just works too. Leave it blank if the server has no
MANAGEMENT_TOKEN configured -- the default, unauthenticated-on-a-
trusted-LAN behavior from before.
Skipping to the next photo
Wire a momentary push button between GPIO2 and GND (internal pull-up,
active-low, same wiring style as the reset button). 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, unlike the factory-reset button, since advancing is easily
reversible by pressing again. See FRAME_NEXT_BUTTON_GPIO above to
change the pin or disable the feature.
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.
Scanning to manage the queue
Wire a momentary push button between GPIO1 and GND (same wiring style as the other two buttons). A press wakes the device 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. See FRAME_MANAGE_BUTTON_GPIO above to change the pin
or disable the feature.
The device stays awake the whole time (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 two buttons.
Resetting to provisioning mode
Wire a momentary push button between GPIO3 and GND (internal pull-up,
active-low -- no external resistor needed). Hold it for 10 seconds (from
either power-on or while the device is deep-asleep -- GPIO3 is armed as a
wakeup source) and it clears the stored WiFi/server config and restarts
into provisioning. Releasing it early is a no-op; nothing happens until
the full hold duration elapses, so a brief accidental bump won't
reprovision the device. See FRAME_RESET_BUTTON_GPIO/
FRAME_RESET_BUTTON_HOLD_MS above to change the pin or hold duration, or
disable the feature.
Without the button wired up (or with FRAME_RESET_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.)