Update remaining docs for the second panel/board (missed in the previous commit)
Build and push server image / test (push) Successful in 42s
Build and push server image / build-and-push (push) Successful in 3m32s
Build and push server image / deploy (push) Failing after 1m28s

docs/hardware.md and firmware/README.md were updated already; this
catches the root README, docs/architecture.md, docs/widgets.md, and
server/README.md -- all still described the project as single-panel/
single-chip (800x480, ESP32-C6 only) even after image_pipeline.py
stopped hardcoding that.
This commit is contained in:
2026-08-04 20:50:29 +00:00
parent 474b92a282
commit fe5a2df074
4 changed files with 60 additions and 32 deletions
+18 -8
View File
@@ -1,9 +1,12 @@
# ESPresso Frame # ESPresso Frame
A DIY e-ink photo frame: an ESP32-C6 pulls photos from your A DIY e-ink photo frame: an ESP32 board pulls photos from your
[Immich](https://immich.app) library and displays them on a 7.3" full-color [Immich](https://immich.app) library and displays them on a full-color
e-paper panel, waking on a timer to refresh and spending the rest of its e-paper panel, waking on a timer to refresh and spending the rest of its
time in deep sleep. time in deep sleep. The original build is a 7.3" panel on an ESP32-C6;
a larger 13.3" panel on Seeed's EE02 (ESP32-S3) is supported
server-side, but its firmware driver isn't working yet -- see
[`docs/hardware.md`](docs/hardware.md).
- **No cables to a computer, no SD card shuffling.** Provisioning is a - **No cables to a computer, no SD card shuffling.** Provisioning is a
captive portal with a QR code drawn on the panel itself -- scan, join, captive portal with a QR code drawn on the panel itself -- scan, join,
@@ -12,7 +15,9 @@ time in deep sleep.
all the work (pulling from Immich, cropping, dithering, packing into all the work (pulling from Immich, cropping, dithering, packing into
the panel's exact pixel format) and hands the device a stream it can the panel's exact pixel format) and hands the device a stream it can
write straight to SPI. The ESP32-C6 has no PSRAM and not much SRAM to write straight to SPI. The ESP32-C6 has no PSRAM and not much SRAM to
spare -- keeping it a dumb display client is what makes that workable. spare -- keeping it a dumb display client is what makes that workable
(the same design carries over to the ESP32-S3 board even though it
does have PSRAM, for consistency).
- **Crops toward faces, not just the center**, using face bounding boxes - **Crops toward faces, not just the center**, using face bounding boxes
Immich already computed for its own People feature -- no bundled face Immich already computed for its own People feature -- no bundled face
detector. detector.
@@ -21,8 +26,13 @@ time in deep sleep.
## Hardware ## Hardware
- ESP32-C6 dev board (8MB flash) - ESP32-C6 dev board (8MB flash), or Seeed's XIAO ESP32-C6 (production
- [Waveshare 7.3" E Ink Spectra 6 (E6)](https://www.waveshare.com/7.3inch-e-paper-hat-e.htm) panel -- 800x480, 6-color, SPI board) -- both drive the panel below.
- [Waveshare 7.3" E Ink Spectra 6 (E6)](https://www.waveshare.com/7.3inch-e-paper-hat-e.htm) panel -- 800x480, 6-color, SPI.
- Experimental, not yet working: [Waveshare 13.3" E Ink Spectra 6](https://www.waveshare.com/13.3inch-e-paper-hat-plus-e.htm)
(1600x1200) on [Seeed's EE02](https://www.seeedstudio.com/XIAO-ePaper-DIY-Kit-EE02-for-13-3-Spectratm-6-E-Ink.html)
(ESP32-S3) -- server-side support exists, but the firmware driver's
panel init sequence isn't ported from vendor code yet.
See [`docs/hardware.md`](docs/hardware.md) for wiring, See [`docs/hardware.md`](docs/hardware.md) for wiring,
[`docs/architecture.md`](docs/architecture.md) for how the two halves talk [`docs/architecture.md`](docs/architecture.md) for how the two halves talk
@@ -34,14 +44,14 @@ placeable photos/calendar/whiteboard widget system.
1. **[`server/`](server/)** -- run the FastAPI server first (Docker 1. **[`server/`](server/)** -- run the FastAPI server first (Docker
Compose, points at your Immich instance). See Compose, points at your Immich instance). See
[`server/README.md`](server/README.md). [`server/README.md`](server/README.md).
2. **[`firmware/`](firmware/)** -- build and flash the ESP32-C6, then 2. **[`firmware/`](firmware/)** -- build and flash the board, then
scan the QR codes it draws on first boot to provision it. See scan the QR codes it draws on first boot to provision it. See
[`firmware/README.md`](firmware/README.md). [`firmware/README.md`](firmware/README.md).
## Repo layout ## Repo layout
``` ```
firmware/ ESP-IDF project for the ESP32-C6 firmware/ ESP-IDF project (ESP32-C6 devkit/xiao boards, ESP32-S3 ee02)
server/ FastAPI server: Immich -> crop/dither/pack -> the frame server/ FastAPI server: Immich -> crop/dither/pack -> the frame
docs/ Wiring and architecture notes docs/ Wiring and architecture notes
``` ```
+11 -7
View File
@@ -3,14 +3,15 @@
Two independent pieces talk over HTTP or HTTPS (the server itself always Two independent pieces talk over HTTP or HTTPS (the server itself always
speaks plain HTTP; HTTPS means a reverse proxy in front of it, see speaks plain HTTP; HTTPS means a reverse proxy in front of it, see
[`firmware/README.md`](../firmware/README.md#http-vs-https)) on the local [`firmware/README.md`](../firmware/README.md#http-vs-https)) on the local
network: the ESP32-C6 firmware, and a small FastAPI server that sits network: the ESP32 firmware (ESP32-C6 for the devkit/xiao boards,
between it and Immich. ESP32-S3 for ee02 -- see [`docs/hardware.md`](hardware.md)), and a small
FastAPI server that sits between it and Immich.
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant Immich participant Immich
participant Server as ESPresso Frame Server participant Server as ESPresso Frame Server
participant Frame as ESP32-C6 Frame participant Frame as ESP32 Frame
Note over Frame: First boot / never provisioned Note over Frame: First boot / never provisioned
Frame->>Frame: Generate AP SSID/password, draw QR + config QR on panel Frame->>Frame: Generate AP SSID/password, draw QR + config QR on panel
@@ -33,7 +34,7 @@ sequenceDiagram
Server->>Immich: List album assets / download preview / faces<br/>(once per photo widget on the panel) Server->>Immich: List album assets / download preview / faces<br/>(once per photo widget on the panel)
Immich-->>Server: JPEG + face bounding boxes Immich-->>Server: JPEG + face bounding boxes
Server->>Server: Composite every widget's region onto one canvas,<br/>then enhance/overlay/quantize (dither)/pack 4bpp once Server->>Server: Composite every widget's region onto one canvas,<br/>then enhance/overlay/quantize (dither)/pack 4bpp once
Server-->>Frame: 192,000 raw bytes, streamed Server-->>Frame: packed 4bpp bytes, streamed<br/>(192,000 for the 7.3" panel; sized to whichever<br/>panel this frame's device reports, see Frame.panel_type)
Frame->>Frame: Write to panel SPI buffer, compute CRC32 Frame->>Frame: Write to panel SPI buffer, compute CRC32
alt CRC unchanged since last physical refresh alt CRC unchanged since last physical refresh
Frame->>Frame: Skip refresh (nothing visually changed) Frame->>Frame: Skip refresh (nothing visually changed)
@@ -83,8 +84,9 @@ placement grid, and button-action dispatch.
once). once).
- Fetch the frame and write it into the panel's SPI buffer - Fetch the frame and write it into the panel's SPI buffer
(`epd_write_frame()`), computing a CRC32 as it streams -- never (`epd_write_frame()`), computing a CRC32 as it streams -- never
buffering the full ~192KB frame in RAM. The panel driver refuses to buffering the full packed frame in RAM (~192KB for the 7.3" panel;
write a short/wrong-size response into the buffer at all, so a proportionally more for the 13.3" panel). The panel driver refuses
to write a short/wrong-size response into the buffer at all, so a
truncated fetch can't corrupt what's already there. truncated fetch can't corrupt what's already there.
- Compare the new CRC32 against the last one that was actually - Compare the new CRC32 against the last one that was actually
refreshed onto the panel (persisted in NVS). If it matches -- the refreshed onto the panel (persisted in NVS). If it matches -- the
@@ -117,7 +119,9 @@ git history). Decoding a JPEG, then resizing/dithering/quantizing it to
the panel's 6-color palette, would be expensive on-device in both memory the panel's 6-color palette, would be expensive on-device in both memory
and battery. Instead, the server does all of that with Pillow and hands and battery. Instead, the server does all of that with Pillow and hands
the frame a pre-packed, ready-to-stream buffer -- the device never the frame a pre-packed, ready-to-stream buffer -- the device never
decodes an image at all. decodes an image at all. The ee02 board's ESP32-S3 does have PSRAM, but
the same server-side design applies there too, for consistency and
battery reasons rather than because the C6's memory limit forces it.
## Why face detection isn't run on-device (or even on the server) ## Why face detection isn't run on-device (or even on the server)
+7 -1
View File
@@ -160,7 +160,13 @@ Calendar widgets pick from discrete size tiers (`calendar_render.py`'s
`_SIZE_TIERS`) for font size/margins/row heights based on their actual `_SIZE_TIERS`) for font size/margins/row heights based on their actual
grid footprint, rather than continuously scaling constants tuned for a grid footprint, rather than continuously scaling constants tuned for a
full ~800x480 canvas -- falls back to agenda view if a widget is too small full ~800x480 canvas -- falls back to agenda view if a widget is too small
for month view to stay legible. for month view to stay legible. These tiers are pixel-size constants
tuned against the 7.3" panel specifically; they aren't re-tuned or
verified yet for the 13.3" panel's larger native resolution (see
`docs/hardware.md`'s EE02 section) -- a widget's *grid footprint* (cell
count) works the same on either panel, but its rendered legibility at
that footprint's actual pixel size hasn't been checked on the bigger
panel.
### "Modern" render style (experimental) ### "Modern" render style (experimental)
+24 -16
View File
@@ -43,17 +43,21 @@ algorithm itself -- it just streams the response straight to the panel.
server, before any admin account exists. server, before any admin account exists.
6. **Optional: auto-update firmware from Gitea releases.** If you're 6. **Optional: auto-update firmware from Gitea releases.** If you're
pushing this repo to a Gitea instance, `.gitea/workflows/firmware-release-build.yml` pushing this repo to a Gitea instance, `.gitea/workflows/firmware-release-build.yml`
builds both supported boards and publishes them as release assets builds every supported board and publishes them as release assets
(`firmware-xiao.bin`/`firmware-devkit.bin`) whenever `firmware/version.txt` (`firmware-devkit_esp32c6.bin`/`firmware-xiao_esp32c6.bin`/
changes on `main`. In a frame's **Configuration** tab, set the `firmware-ee02.bin`, plus `firmware-devkit.bin`/`firmware-xiao.bin`
**Gitea repo URL**; if the repo is private, also set duplicates for devices still on pre-rename firmware) whenever
`GITEA_FIRMWARE_TOKEN` (a read-only PAT) in `docker-compose.yml`. `firmware/version.txt` changes on `main`. In a frame's
Which board's build to fetch is learned from the frame itself (its **Configuration** tab, set the **Gitea repo URL**; if the repo is
`X-Frame-Board` header) -- nothing to pick by hand. The server then private, also set `GITEA_FIRMWARE_TOKEN` (a read-only PAT) in
periodically checks for a newer release and either shows an "Update `docker-compose.yml`. Which board's build to fetch is learned from the
frame" button or, with **Automatically apply updates** checked, frame itself (its `X-Frame-Board` header) -- nothing to pick by hand.
stages it itself -- either way the frame only actually updates on The same header also determines which EPD panel the frame renders for
its own next wake. (`Frame.panel_type`, see `docs/hardware.md`'s board identifiers
section) -- also never a manual setting. The server then periodically
checks for a newer release and either shows an "Update frame" button
or, with **Automatically apply updates** checked, stages it itself --
either way the frame only actually updates on its own next wake.
## Users, frames, and control ## Users, frames, and control
@@ -106,9 +110,12 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
### Device protocol (`/frame/*` -- paths frozen; auth = `?id=` + `?token=`) ### Device protocol (`/frame/*` -- paths frozen; auth = `?id=` + `?token=`)
- `GET /frame/image` -- the frame's current image, pre-processed into - `GET /frame/image` -- the frame's current image, pre-processed into
the panel's raw 800x480, 4-bit-per-pixel, 2-pixels-per-byte format the panel's raw 4-bit-per-pixel, 2-pixels-per-byte format
(`application/octet-stream`, exactly 192,000 bytes). **Side-effect-free** (`application/octet-stream`) -- 800x480/exactly 192,000 bytes for the
by default: it only actually advances once `refresh_interval_s` has original 7.3" panel, 1600x1200/exactly 960,000 bytes for the 13.3"
panel (see `Frame.panel_type`/`image_pipeline.PANEL_SPECS`; a given
device's byte count is fixed by which firmware/panel it actually is).
**Side-effect-free** by default: it only actually advances once `refresh_interval_s` has
elapsed since the current photo was set, so an unexpected reboot just elapsed since the current photo was set, so an unexpected reboot just
redisplays the same photo. An unclaimed or not-yet-configured frame redisplays the same photo. An unclaimed or not-yet-configured frame
gets a rendered instruction placeholder (with a claim QR) instead of gets a rendered instruction placeholder (with a claim QR) instead of
@@ -131,8 +138,9 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
*this* frame and 302s to it. Authenticated by the frame's own *this* frame and 302s to it. Authenticated by the frame's own
`manage_token` (see the manage QR below), not device credentials -- a `manage_token` (see the manage QR below), not device credentials -- a
phone scanning the QR has no way to supply `?id=`/`?token=`. phone scanning the QR has no way to supply `?id=`/`?token=`.
- `GET /frame/face-labels` -- up to 4 named faces with 800x480 - `GET /frame/face-labels` -- up to 4 named faces with positions in the
positions, flattened (`name_0`/`x_0`/`y_0`, ...) for the device's frame's own panel space (800x480 for the 7.3" panel, 1600x1200 for the
13.3"), flattened (`name_0`/`x_0`/`y_0`, ...) for the device's
flat-scalar parser. flat-scalar parser.
- `POST /frame/battery` -- `{"percent": 0-100}`; per-discharge-cycle - `POST /frame/battery` -- `{"percent": 0-100}`; per-discharge-cycle
history (feeds the runtime estimate) plus a permanent per-frame history (feeds the runtime estimate) plus a permanent per-frame