Server: Frame.panel_type (new column + migration) is auto-derived from the device's reported board (X-Frame-Board), never user-set -- the panel is a property of the hardware, not a picker in the UI. image_pipeline's packing/render pipeline is parameterized by panel geometry instead of hardcoded 800x480 globals, with the real confirmed 13.3in geometry (1600x1200) registered alongside the original 7.3in panel. Existing 7.3in frames are unaffected (column default + board mapping both resolve to the original panel). Board identifiers are also renamed (devkit/xiao -> devkit_esp32c6/ xiao_esp32c6, plus new "ee02") since the EE02 board also carries a XIAO module -- "xiao" alone stopped disambiguating hardware. The server keeps accepting the legacy bare names indefinitely for already-flashed devices. Firmware: scaffolds a third build target (ee02, ESP32-S3 -- a real chip-target change, not just a same-chip Kconfig variant like xiao) and a new epd13in3e driver component skeleton. The actual panel init/LUT/ refresh register sequence isn't ported from vendor demo code yet (none was available), so that component deliberately fails to compile (#error) rather than risk sending unverified register values to real hardware -- devkit/xiao are unaffected and build identically to before. CI's ee02 build step is continue-on-error for the same reason.
364 lines
18 KiB
Markdown
364 lines
18 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-not-yet-functional)
|
|
below; the driver for that panel doesn't work yet). 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 not yet functional)
|
|
|
|
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 will fail to compile.** `firmware/components/epd13in3e`'s panel
|
|
init/LUT/refresh register sequence hasn't been ported from vendor demo
|
|
code yet (see that file's own top comment and
|
|
[`docs/hardware.md`](../docs/hardware.md#133-spectra-6-panel-on-seeeds-ee02-board-driver-not-yet-functional))
|
|
-- a deliberate `#error`, not a bug in this build path. Everything
|
|
around it (target selection, Kconfig, partition table, sdkconfig
|
|
layering, `main/CMakeLists.txt`'s component selection) is in place and
|
|
exercised by CI (`.gitea/workflows/firmware-build-check.yml`/
|
|
`firmware-release-build.yml`, both with `continue-on-error` on this
|
|
board's step until the driver is real).
|
|
|
|
## 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.)
|