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).
230 lines
11 KiB
Markdown
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.)
|