Firmware build check / build-check (push) Successful in 2m44s
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.
375 lines
19 KiB
Markdown
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.)
|