Files
tfaour c0fefc19f1
Firmware build check / build-check (push) Successful in 2m44s
Fix ee02 button-wakeup build: ext1 fallback for ESP32-S3
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown() only exists on
ESP32-C6 (SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP), so the ee02
(ESP32-S3) build failed with implicit-declaration errors in
{back,next,combo}_button.c once the epd13in3e driver's #error stopped
masking it.

Each button file now branches on that capability macro: the C6 path
(devkit/xiao) is untouched, and ESP32-S3 uses
esp_sleep_enable_ext1_wakeup_io() instead. The earlier ext1 attempt was
rejected on C6 hardware because its pull resistor didn't hold across
RTC_PERIPH power-down -- tracing the same path in ESP-IDF source shows
gpio_config()'s pull_up_en already delegates to rtc_gpio_pullup_en()
for RTC-capable pins on every non-original-ESP32 target, so the pull-up
should already survive the same power-down on S3. The _io() variant is
additive, so the three button files don't need cross-file mask
coordination. Also widens the button GPIO Kconfig range for
IDF_TARGET_ESP32S3 (0-21, matching its RTC-IO set) instead of the
C6-shaped 0-7.

Verified: ee02, devkit, and xiao all build clean end-to-end locally
(native ESP-IDF v6.0, no Docker in this sandbox). NOT verified: whether
this actually avoids the spurious-instant-wakeup bug on real EE02
hardware -- that failure mode was only ever confirmed empirically, not
root-caused in a way a compile can check. continue-on-error stays on
in CI's ee02 build step until that's confirmed.
2026-08-04 22:46:13 +00:00

375 lines
19 KiB
Markdown

# 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 <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.
## 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`](../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`](partitions.csv);
this only wipes the config, not the app itself.)