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