# 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](../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.) ## 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_SOFT_RESET_HOLD_MS` | 3000 | How long the combo button must be held (then released) to soft-reset | | `FRAME_COMBO_FACTORY_RESET_HOLD_MS` | 15000 | How long the combo button must be held to factory-reset | | `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. ### 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](../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](../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