# ESPresso Frame Firmware ESP-IDF firmware for the ESP32-C6 (devkit/xiao boards, 7.3" panel) or ESP32-S3 (ee02 board, 13.3" panel -- see [Building for Seeed's EE02](#building-for-seeeds-ee02-esp32-s3--133-panel-driver-ported-ee02-builds-end-to-end-unverified-on-real-hardware) below; it builds end-to-end now, but is still unverified on real EE02 hardware). 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](../server/), streams it straight to the panel over SPI, and goes back to deep sleep. See [`docs/architecture.md`](../docs/architecture.md) for the full boot/fetch cycle and [`docs/hardware.md`](../docs/hardware.md) for wiring. ## Build and flash Requires [ESP-IDF](https://docs.espressif.com/projects/esp-idf/en/stable/esp32c6/get-started/) (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`](sdkconfig.defaults) pins that flash size and a custom [`partitions.csv`](partitions.csv) with dual OTA app partitions (2MB each; see [OTA updates](#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`](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`](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.) ### Building for Seeed's EE02 (ESP32-S3 + 13.3" panel, driver ported; `ee02` builds end-to-end, unverified on real hardware) EE02 is a different chip (ESP32-S3, not C6), so it needs `set-target esp32s3` instead of `esp32c6`, and its own partition table/flash-size Kconfig sized for its 16MB flash ([`partitions_ee02.csv`](partitions_ee02.csv)): ``` ./build_for_board.sh ee02 set-target esp32s3 ./build_for_board.sh ee02 build ``` **This now compiles and links clean end-to-end**, verified locally with a native, non-Docker ESP-IDF v6.0 install (see `.claude/skills/build-firmware/SKILL.md`). `firmware/components/epd13in3e`'s panel init/LUT/refresh register sequence is a real, vendor-confirmed port (see that component's own top comment and [`docs/hardware.md`](../docs/hardware.md#133-spectra-6-panel-on-seeeds-ee02-board-panel-driver-ported-ee02-builds-end-to-end-unverified-on-real-hardware) for the vendor sources and the load-bearing native-raster-orientation correction that came with it). `main/{back,next,combo}_button.c` used to call an ESP32-C6-only deep-sleep GPIO-wakeup API with no ESP32-S3 fallback; each now branches on `SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP` to keep the ESP32-C6 path (devkit/xiao) untouched while using `esp_sleep_enable_ext1_wakeup_io()` on ESP32-S3 -- see `docs/hardware.md`'s same section for why the additive `_io()` variant needs no combined-mask coordination across the three button files, and why the pull-resistor concern that ruled out EXT1 wakeup on ESP32-C6 doesn't apply the same way here. That reasoning is confirmed against ESP-IDF source, **not against real EE02 hardware** -- CI's (`.gitea/workflows/firmware-build-check.yml`/`firmware-release-build.yml`) `continue-on-error` on this board's step is intentionally still in place until it is. ## 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`](../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`](../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 and the "Tools Server" address (`host:port` of the [server](../server/) -- **not** your Immich server; see below for the `https://` form). 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](../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`](main/certs/additional_root_ca.pem) (wired in via `CONFIG_MBEDTLS_CUSTOM_CERTIFICATE_BUNDLE_PATH` in [`sdkconfig.defaults`](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 :443 -showcerts