From fe5a2df0745bace698f728e5240509a2177be549 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Tue, 4 Aug 2026 20:50:29 +0000 Subject: [PATCH] Update remaining docs for the second panel/board (missed in the previous commit) 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. --- README.md | 26 ++++++++++++++++++-------- docs/architecture.md | 18 +++++++++++------- docs/widgets.md | 8 +++++++- server/README.md | 40 ++++++++++++++++++++++++---------------- 4 files changed, 60 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index d628a14..b498d91 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,12 @@ # ESPresso Frame -A DIY e-ink photo frame: an ESP32-C6 pulls photos from your -[Immich](https://immich.app) library and displays them on a 7.3" full-color +A DIY e-ink photo frame: an ESP32 board pulls photos from your +[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 -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 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 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 - 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 Immich already computed for its own People feature -- no bundled face detector. @@ -21,8 +26,13 @@ time in deep sleep. ## Hardware -- ESP32-C6 dev board (8MB flash) -- [Waveshare 7.3" E Ink Spectra 6 (E6)](https://www.waveshare.com/7.3inch-e-paper-hat-e.htm) panel -- 800x480, 6-color, SPI +- ESP32-C6 dev board (8MB flash), or Seeed's XIAO ESP32-C6 (production + 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, [`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 Compose, points at your Immich instance). See [`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 [`firmware/README.md`](firmware/README.md). ## 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 docs/ Wiring and architecture notes ``` diff --git a/docs/architecture.md b/docs/architecture.md index 3eef6c2..4500bc9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,14 +3,15 @@ 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 [`firmware/README.md`](../firmware/README.md#http-vs-https)) on the local -network: the ESP32-C6 firmware, and a small FastAPI server that sits -between it and Immich. +network: the ESP32 firmware (ESP32-C6 for the devkit/xiao boards, +ESP32-S3 for ee02 -- see [`docs/hardware.md`](hardware.md)), and a small +FastAPI server that sits between it and Immich. ```mermaid sequenceDiagram participant Immich participant Server as ESPresso Frame Server - participant Frame as ESP32-C6 Frame + participant Frame as ESP32 Frame Note over Frame: First boot / never provisioned 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
(once per photo widget on the panel) Immich-->>Server: JPEG + face bounding boxes Server->>Server: Composite every widget's region onto one canvas,
then enhance/overlay/quantize (dither)/pack 4bpp once - Server-->>Frame: 192,000 raw bytes, streamed + Server-->>Frame: packed 4bpp bytes, streamed
(192,000 for the 7.3" panel; sized to whichever
panel this frame's device reports, see Frame.panel_type) Frame->>Frame: Write to panel SPI buffer, compute CRC32 alt CRC unchanged since last physical refresh Frame->>Frame: Skip refresh (nothing visually changed) @@ -83,8 +84,9 @@ placement grid, and button-action dispatch. once). - Fetch the frame and write it into the panel's SPI buffer (`epd_write_frame()`), computing a CRC32 as it streams -- never - buffering the full ~192KB frame in RAM. The panel driver refuses to - write a short/wrong-size response into the buffer at all, so a + buffering the full packed frame in RAM (~192KB for the 7.3" panel; + 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. - Compare the new CRC32 against the last one that was actually 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 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 -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) diff --git a/docs/widgets.md b/docs/widgets.md index f1b7f2a..4b94357 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -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 grid footprint, rather than continuously scaling constants tuned for a 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) diff --git a/server/README.md b/server/README.md index f4af2c3..0043c55 100644 --- a/server/README.md +++ b/server/README.md @@ -43,17 +43,21 @@ algorithm itself -- it just streams the response straight to the panel. server, before any admin account exists. 6. **Optional: auto-update firmware from Gitea releases.** If you're pushing this repo to a Gitea instance, `.gitea/workflows/firmware-release-build.yml` - builds both supported boards and publishes them as release assets - (`firmware-xiao.bin`/`firmware-devkit.bin`) whenever `firmware/version.txt` - changes on `main`. In a frame's **Configuration** tab, set the - **Gitea repo URL**; if the repo is private, also set - `GITEA_FIRMWARE_TOKEN` (a read-only PAT) in `docker-compose.yml`. - Which board's build to fetch is learned from the frame itself (its - `X-Frame-Board` header) -- nothing to pick by hand. 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. + builds every supported board and publishes them as release assets + (`firmware-devkit_esp32c6.bin`/`firmware-xiao_esp32c6.bin`/ + `firmware-ee02.bin`, plus `firmware-devkit.bin`/`firmware-xiao.bin` + duplicates for devices still on pre-rename firmware) whenever + `firmware/version.txt` changes on `main`. In a frame's + **Configuration** tab, set the **Gitea repo URL**; if the repo is + private, also set `GITEA_FIRMWARE_TOKEN` (a read-only PAT) in + `docker-compose.yml`. Which board's build to fetch is learned from the + frame itself (its `X-Frame-Board` header) -- nothing to pick by hand. + The same header also determines which EPD panel the frame renders for + (`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 @@ -106,9 +110,12 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`, ### Device protocol (`/frame/*` -- paths frozen; auth = `?id=` + `?token=`) - `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 - (`application/octet-stream`, exactly 192,000 bytes). **Side-effect-free** - by default: it only actually advances once `refresh_interval_s` has + the panel's raw 4-bit-per-pixel, 2-pixels-per-byte format + (`application/octet-stream`) -- 800x480/exactly 192,000 bytes for the + 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 redisplays the same photo. An unclaimed or not-yet-configured frame 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 `manage_token` (see the manage QR below), not device credentials -- a phone scanning the QR has no way to supply `?id=`/`?token=`. -- `GET /frame/face-labels` -- up to 4 named faces with 800x480 - positions, flattened (`name_0`/`x_0`/`y_0`, ...) for the device's +- `GET /frame/face-labels` -- up to 4 named faces with positions in the + 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. - `POST /frame/battery` -- `{"percent": 0-100}`; per-discharge-cycle history (feeds the runtime estimate) plus a permanent per-frame