Files
espresso_frame/firmware/README.md
T
tfaour d5de882b1e Fix stale documentation found by a doc-accuracy audit
firmware/README.md's HTTP vs HTTPS section still described the
public-CA-bundle trust approach that was tried and abandoned in favor
of pinning one specific certificate -- rewritten to match what's
actually there. docs/architecture.md was missing the back-photo button
entirely (sequence diagram and boot-flow bullets only covered next) and
still said the system talks "over plain HTTP" despite HTTPS support.
docker-compose.yml.example's MANAGEMENT_TOKEN comment understated its
scope (said "the web UI", omitting that every /frame/* endpoint is
gated too).
2026-07-19 15:20:12 -04:00

230 lines
11 KiB
Markdown

# 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 committed [`sdkconfig.defaults`](sdkconfig.defaults) pins an 8MB flash
size and a custom [`partitions.csv`](partitions.csv) (2MB app partition --
the default "single app" ~1MB partition runs out of room once the HTTP
client, TLS, and vendored fonts/QR library are linked in). If your board
has less flash, you'll need to shrink the app partition and drop features
to fit.
## 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 |
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.
## 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). Saving reboots the device, which then
connects to your home network and starts its normal fetch/sleep cycle.
## 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 does **not** use ESP-IDF's general public CA bundle --
it pins one specific certificate, embedded at build time from
[`main/certs/tools_server_ca.pem`](main/certs/tools_server_ca.pem) and
trusted directly via `cert_pem` on every request. (The public bundle
was tried first and rejected: it does an exact byte-level match against
its compiled-in table, and a real-world root that's been re-issued
under a new serial/signature but the same name and key -- as Google did
for GTS Root R4 -- doesn't match it, confirmed on hardware.)
This means a normal publicly-trusted certificate (Let's Encrypt, a
Cloudflare-proxied hostname, etc.) does **not** automatically work --
only whichever certificate is actually embedded in
`certs/tools_server_ca.pem` is trusted. To point the device at a
different reverse proxy, extract that proxy's actual certificate and
replace the file's contents:
```
openssl s_client -connect <host>:443 -showcerts </dev/null
```
Pick whichever certificate in the printed chain you want as the trust
anchor (typically the root) and rebuild. A private/self-signed cert
works exactly the same way -- there's no requirement that it chain to
a public CA at all, since the device trusts this one file directly.
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.
## Access token
If the server has `MANAGEMENT_TOKEN` set (see
[`server/README.md`](../server/README.md)), it requires that same value
on every request -- the web UI *and* every device-facing request the
frame itself makes. Paste it into the captive portal's "Access Token"
field and the device sends it (`?token=...`) on every request
automatically, and bakes it into the manage-menu/share QR codes so
scanning them just works too. Leave it blank if the server has no
`MANAGEMENT_TOKEN` configured -- the default, unauthenticated-on-a-
trusted-LAN behavior from before.
## 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.
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.
## 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** wakes the device 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
either 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 ~3 seconds, then releasing** soft-resets the device --
`esp_restart()`, keeping the stored WiFi/server config. Useful for
recovering a hung device without losing setup.
**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_SOFT_RESET_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.)