diff --git a/.claude/skills/build-firmware/SKILL.md b/.claude/skills/build-firmware/SKILL.md
index 8860640..97a8e66 100644
--- a/.claude/skills/build-firmware/SKILL.md
+++ b/.claude/skills/build-firmware/SKILL.md
@@ -1,10 +1,11 @@
---
name: build-firmware
-description: Compile the espresso_frame ESP32-C6 firmware (firmware/) for both board variants without Docker -- a native, non-container ESP-IDF v6.0 install. Use when asked to build the firmware, verify a firmware/main/*.c change actually compiles, or check both the devkit and xiao board targets.
+description: Compile the espresso_frame firmware (firmware/) for all three board variants (ESP32-C6 devkit/xiao, ESP32-S3 ee02) without Docker -- a native, non-container ESP-IDF v6.0 install. Use when asked to build the firmware, verify a firmware/main/*.c change actually compiles, or check the devkit/xiao/ee02 board targets.
---
-Compiles `firmware/` (ESP-IDF, targeting ESP32-C6) locally, without
-Docker -- CI's `firmware-build-check.yml`/`firmware-release-build.yml`
+Compiles `firmware/` (ESP-IDF, targeting ESP32-C6 for devkit/xiao and
+ESP32-S3 for ee02) locally, without Docker -- CI's
+`firmware-build-check.yml`/`firmware-release-build.yml`
build inside the `espressif/idf:release-v6.0` container image, but
**this sandbox cannot run containers at all**: `docker.io` installs and
`dockerd` starts fine even as root, but the sandbox strips
@@ -39,13 +40,13 @@ had neither):
of `release/v6.0` (~700MB) into `~/.espressif-idf/esp-idf` -- matches
the IDF version CI's Docker image pins. Only clones once; re-running
`setup.sh` never touches an existing checkout.
-- The esp32c6 toolchain + Python venv, via ESP-IDF's own
- `./install.sh esp32c6` -- scoped to just this project's one target
- (see `firmware/README.md`'s board table), not every chip ESP-IDF
- supports, to keep the download/disk footprint down. `install.sh` is
- already idempotent on its own, so `setup.sh` always calls it rather
- than duplicating that check -- a re-run costs a few seconds once
- everything's cached.
+- The esp32c6+esp32s3 toolchains + Python venv, via ESP-IDF's own
+ `./install.sh esp32c6,esp32s3` -- scoped to just this project's two
+ chip targets (see `firmware/README.md`'s board table: devkit/xiao are
+ esp32c6, ee02 is esp32s3), not every chip ESP-IDF supports, to keep
+ the download/disk footprint down. `install.sh` is already idempotent
+ on its own, so `setup.sh` always calls it rather than duplicating
+ that check -- a re-run costs a few seconds once everything's cached.
Takes a few minutes on a cold run (mostly `install.sh`'s own pip/tool
downloads), well under a minute on a re-run. Needs real root (`apt-get
@@ -63,15 +64,17 @@ checkout itself). Confirmed working with as little as ~7GB free.
```bash
bash .claude/skills/build-firmware/build.sh # devkit (default)
bash .claude/skills/build-firmware/build.sh xiao
-bash .claude/skills/build-firmware/build.sh both # both variants
+bash .claude/skills/build-firmware/build.sh ee02
+bash .claude/skills/build-firmware/build.sh both # devkit + xiao
+bash .claude/skills/build-firmware/build.sh all # devkit + xiao + ee02
```
Each board gets its own build directory and generated sdkconfig (see
`firmware/build_for_board.sh`'s own comment) -- building one never
-disturbs the other. `build.sh` auto-runs `set-target esp32c6` the very
-first time a board is built (no generated sdkconfig yet); later builds
-skip straight to `idf.py build`. Extra arguments pass straight through
-to `idf.py`, e.g.:
+disturbs the other. `build.sh` auto-runs `set-target` (esp32c6 for
+devkit/xiao, esp32s3 for ee02) the very first time a board is built (no
+generated sdkconfig yet); later builds skip straight to `idf.py build`.
+Extra arguments pass straight through to `idf.py`, e.g.:
```bash
bash .claude/skills/build-firmware/build.sh xiao flash -p /dev/ttyUSB0
@@ -83,15 +86,16 @@ somewhere hardware is actually plugged in (a real dev machine, or a
differently-configured environment with device passthrough).
A clean build of one board takes ~30s once the target's already been
-configured (~1,000 build steps total split across both boards, most of
+configured (~1,000 build steps total split across the boards, most of
it ESP-IDF's own components -- this project's own `firmware/main/*.c`
and `firmware/components/*` sources are a small fraction of that and
compile in a few seconds). Output lands at
-`firmware/build/espresso_frame.bin` (devkit) or
-`firmware/build_xiao/espresso_frame.bin` (xiao) -- both paths are
-gitignored (`firmware/.gitignore`... actually the repo root
-`.gitignore`'s "ESP-IDF firmware build output" section), so nothing
-here needs cleaning up before a commit.
+`firmware/build/espresso_frame.bin` (devkit),
+`firmware/build_xiao/espresso_frame.bin` (xiao), or
+`firmware/build_ee02/espresso_frame.bin` (ee02, once its driver actually
+compiles -- see the note above) -- all three paths are gitignored (the
+repo root `.gitignore`'s "ESP-IDF firmware build output" section), so
+nothing here needs cleaning up before a commit.
## Verified
diff --git a/.claude/skills/build-firmware/build.sh b/.claude/skills/build-firmware/build.sh
index 856307c..a3a67b8 100755
--- a/.claude/skills/build-firmware/build.sh
+++ b/.claude/skills/build-firmware/build.sh
@@ -2,16 +2,25 @@
# Builds (or flashes/monitors, if a serial port is actually attached)
# the espresso_frame firmware for one board variant, via the project's
# own firmware/build_for_board.sh -- this script just sources the
-# ESP-IDF environment first and auto-runs `set-target esp32c6` on a
-# board's very first build (a fresh clone has no generated sdkconfig
-# yet, same reasoning as CI's own build steps -- see firmware/README.md's
-# "Building for the Seeed XIAO ESP32-C6" section).
+# ESP-IDF environment first and auto-runs `set-target` (esp32c6 for
+# devkit/xiao, esp32s3 for ee02) on a board's very first build (a fresh
+# clone has no generated sdkconfig yet, same reasoning as CI's own build
+# steps -- see firmware/README.md's "Building for the Seeed XIAO
+# ESP32-C6" section).
+#
+# NOTE: ee02 builds will fail to compile -- deliberately -- until
+# firmware/components/epd13in3e's panel driver is ported from vendor
+# demo code (see that component's own top-of-file comment). The build
+# plumbing itself (target selection, partition table, sdkconfig
+# layering) is exercised regardless; only the final compile step fails.
#
# Usage:
# build.sh # build devkit (default)
# build.sh devkit
# build.sh xiao
-# build.sh both # build both board variants
+# build.sh ee02
+# build.sh both # build devkit + xiao (unchanged meaning)
+# build.sh all # build devkit + xiao + ee02
# build.sh xiao flash -p /dev/ttyUSB0 # only meaningful with real hardware attached
set -euo pipefail
@@ -33,16 +42,17 @@ cd "$firmware_dir"
build_one() {
local board="$1"
shift
- local sdkconfig
+ local sdkconfig target
case "$board" in
- devkit) sdkconfig="sdkconfig" ;;
- xiao) sdkconfig="sdkconfig.xiao_local" ;;
- *) echo "Unknown board '$board' -- expected 'devkit' or 'xiao'" >&2; exit 1 ;;
+ devkit) sdkconfig="sdkconfig"; target="esp32c6" ;;
+ xiao) sdkconfig="sdkconfig.xiao_local"; target="esp32c6" ;;
+ ee02) sdkconfig="sdkconfig.ee02_local"; target="esp32s3" ;;
+ *) echo "Unknown board '$board' -- expected 'devkit', 'xiao', or 'ee02'" >&2; exit 1 ;;
esac
if [ ! -f "$sdkconfig" ]; then
- echo "==> $board: no generated sdkconfig yet, setting target esp32c6"
- ./build_for_board.sh "$board" set-target esp32c6
+ echo "==> $board: no generated sdkconfig yet, setting target $target"
+ ./build_for_board.sh "$board" set-target "$target"
fi
local args=("$@")
@@ -55,9 +65,17 @@ build_one() {
board="${1:-devkit}"
shift || true
-if [ "$board" = "both" ]; then
- build_one devkit "$@"
- build_one xiao "$@"
-else
- build_one "$board" "$@"
-fi
+case "$board" in
+ both)
+ build_one devkit "$@"
+ build_one xiao "$@"
+ ;;
+ all)
+ build_one devkit "$@"
+ build_one xiao "$@"
+ build_one ee02 "$@"
+ ;;
+ *)
+ build_one "$board" "$@"
+ ;;
+esac
diff --git a/.claude/skills/build-firmware/setup.sh b/.claude/skills/build-firmware/setup.sh
index 1360777..56b31b6 100755
--- a/.claude/skills/build-firmware/setup.sh
+++ b/.claude/skills/build-firmware/setup.sh
@@ -45,14 +45,15 @@ else
echo "esp-idf already cloned at $IDF_DIR"
fi
-# 3. Toolchain + Python virtualenv, scoped to esp32c6 only -- this
-# project's one target (see firmware/README.md's board table). Scoping
-# avoids downloading toolchains for every chip ESP-IDF supports, which
-# matters given this container's disk headroom. install.sh is already
+# 3. Toolchain + Python virtualenv, scoped to esp32c6+esp32s3 only --
+# this project's two chip targets (see firmware/README.md's board
+# table: devkit/xiao are esp32c6, ee02 is esp32s3). Scoping avoids
+# downloading toolchains for every chip ESP-IDF supports, which matters
+# given this container's disk headroom. install.sh is already
# idempotent on its own (checks what's present and skips it), so this
# always calls it rather than trying to duplicate that check here --
# a re-run only costs a few seconds once everything's cached.
-echo "running esp-idf install.sh esp32c6 (fast if already installed) ..."
-(cd "$IDF_DIR" && ./install.sh esp32c6)
+echo "running esp-idf install.sh esp32c6,esp32s3 (fast if already installed) ..."
+(cd "$IDF_DIR" && ./install.sh esp32c6,esp32s3)
echo "setup complete -> $IDF_DIR/export.sh (build.sh sources this for you)"
diff --git a/.gitea/workflows/firmware-build-check.yml b/.gitea/workflows/firmware-build-check.yml
index 7758912..7a3ea43 100644
--- a/.gitea/workflows/firmware-build-check.yml
+++ b/.gitea/workflows/firmware-build-check.yml
@@ -3,9 +3,11 @@ name: Firmware build check
# Fires on every push touching firmware source, unlike
# firmware-release-build.yml (which only builds+publishes when
# firmware/version.txt itself is bumped -- the "cut a release" signal).
-# This just verifies both board variants still compile; nothing else in
-# CI catches a firmware/** push that breaks the build until someone
-# happens to bump the version next.
+# This just verifies every board variant still compiles (or, for ee02,
+# that everything up to its known/tracked #error still compiles --
+# see that step's own comment); nothing else in CI catches a
+# firmware/** push that breaks the build until someone happens to bump
+# the version next.
on:
push:
branches: [main]
@@ -49,3 +51,26 @@ jobs:
docker cp "$PWD/." "$cid:/workspace"
docker start -a "$cid"
docker rm "$cid"
+
+ # Expected to fail until firmware/components/epd13in3e's panel
+ # driver is ported from vendor demo code (deliberate #error, see
+ # that file's own top comment) -- continue-on-error so this known
+ # gap doesn't block every other firmware/** push. Still worth
+ # running: catches a regression in the surrounding scaffolding
+ # (Kconfig, main/CMakeLists.txt's component selection, sdkconfig
+ # layering) up to the point of that #error, same value a build
+ # check normally provides. Remove continue-on-error once
+ # epd13in3e's driver is real, so a build failure here goes back to
+ # being a genuine regression signal.
+ - name: Build (ee02 -- Seeed EE02, XIAO ESP32-S3 Plus + 13.3in panel)
+ continue-on-error: true
+ run: |
+ cid=$(docker create -w /workspace/firmware espressif/idf:release-v6.0 bash -c '
+ git config --global --add safe.directory /workspace &&
+ . "$IDF_PATH/export.sh" &&
+ ./build_for_board.sh ee02 set-target esp32s3 &&
+ ./build_for_board.sh ee02 build
+ ')
+ docker cp "$PWD/." "$cid:/workspace"
+ docker start -a "$cid"
+ docker rm "$cid"
diff --git a/.gitea/workflows/firmware-release-build.yml b/.gitea/workflows/firmware-release-build.yml
index f42c48d..bc3a1a6 100644
--- a/.gitea/workflows/firmware-release-build.yml
+++ b/.gitea/workflows/firmware-release-build.yml
@@ -18,7 +18,7 @@ jobs:
# actions) is a Node action that gets exec'd *inside* whatever container
# the job specifies, so checkout fails immediately with "node: not
# found" (hit this on the first real run). Checkout instead runs on the
- # plain runner (which has Node), and only the two build steps below
+ # plain runner (which has Node), and only the three build steps below
# spin up the ESP-IDF image themselves (docker create/cp/start, see the
# comment on those steps for why not a plain `docker run -v`) -- the
# runner already bind-mounts the host's docker socket, so docker-in-
@@ -33,11 +33,13 @@ jobs:
id: version
run: echo "version=$(tr -d '[:space:]' < firmware/version.txt)" >> "$GITHUB_OUTPUT"
- # Two board variants, two partition tables/flash sizes (see
- # firmware/README.md's "Building for the Seeed XIAO ESP32-C6"
- # section) -- build_for_board.sh gives each its own build dir/
- # generated sdkconfig so this never fights over shared state.
- # set-target first since a fresh checkout has no cached sdkconfig
+ # Three board variants: devkit/xiao (ESP32-C6, different partition
+ # tables/flash sizes -- see firmware/README.md's "Building for the
+ # Seeed XIAO ESP32-C6" section) and ee02 (ESP32-S3 + 13.3" panel,
+ # a genuinely different chip target, not just a Kconfig variant).
+ # build_for_board.sh gives each its own build dir/generated
+ # sdkconfig so this never fights over shared state. set-target
+ # first since a fresh checkout has no cached sdkconfig
# (firmware/sdkconfig* is gitignored, see firmware/.gitignore).
# safe.directory guards against git's "dubious ownership" check,
# since the container runs as root over content owned by a
@@ -64,8 +66,15 @@ jobs:
')
docker cp "$PWD/." "$cid:/workspace"
docker start -a "$cid"
- docker cp "$cid:/workspace/firmware/build/espresso_frame.bin" /tmp/release-assets/firmware-devkit.bin
+ docker cp "$cid:/workspace/firmware/build/espresso_frame.bin" /tmp/release-assets/firmware-devkit_esp32c6.bin
docker rm "$cid"
+ # Rename bridge: fielded devices flashed before this rename still
+ # report the bare "devkit" board name and look up "firmware-
+ # devkit.bin" for their OTA check -- publish a duplicate under
+ # the old name too so they can update at all. Safe to drop this
+ # duplicate in a later release once no fielded device reports
+ # the bare name anymore.
+ cp /tmp/release-assets/firmware-devkit_esp32c6.bin /tmp/release-assets/firmware-devkit.bin
- name: Build (xiao -- Seeed XIAO ESP32-C6)
run: |
@@ -77,7 +86,35 @@ jobs:
')
docker cp "$PWD/." "$cid:/workspace"
docker start -a "$cid"
- docker cp "$cid:/workspace/firmware/build_xiao/espresso_frame.bin" /tmp/release-assets/firmware-xiao.bin
+ docker cp "$cid:/workspace/firmware/build_xiao/espresso_frame.bin" /tmp/release-assets/firmware-xiao_esp32c6.bin
+ docker rm "$cid"
+ # Same rename-bridge reasoning as the devkit step above.
+ cp /tmp/release-assets/firmware-xiao_esp32c6.bin /tmp/release-assets/firmware-xiao.bin
+
+ # NOTE: this build is expected to FAIL until
+ # firmware/components/epd13in3e's panel driver is ported from
+ # vendor demo code (see that component's own top-of-file comment
+ # -- a deliberate #error, not a bug here). `continue-on-error` so
+ # this known, tracked gap doesn't block publishing the devkit/xiao
+ # release (those boards work today and shouldn't wait on ee02) --
+ # this step's own status still shows failed/red individually in
+ # the run's step list, it just doesn't fail the overall job. Once
+ # epd13in3e's driver is real, a build failure here becomes a
+ # genuine regression again -- remove `continue-on-error` at that
+ # point so it goes back to failing the job like the other two
+ # builds do.
+ - name: Build (ee02 -- Seeed EE02, XIAO ESP32-S3 Plus + 13.3in panel)
+ continue-on-error: true
+ run: |
+ cid=$(docker create -w /workspace/firmware espressif/idf:release-v6.0 bash -c '
+ git config --global --add safe.directory /workspace &&
+ . "$IDF_PATH/export.sh" &&
+ ./build_for_board.sh ee02 set-target esp32s3 &&
+ ./build_for_board.sh ee02 build
+ ')
+ docker cp "$PWD/." "$cid:/workspace"
+ docker start -a "$cid"
+ docker cp "$cid:/workspace/firmware/build_ee02/espresso_frame.bin" /tmp/release-assets/firmware-ee02.bin
docker rm "$cid"
# Plain stdlib urllib rather than `requests` -- not guaranteed to be
@@ -149,10 +186,25 @@ jobs:
existing_assets = {a["name"]: a["id"] for a in release.get("assets", [])}
assets = [
+ ("firmware-devkit_esp32c6.bin", "/tmp/release-assets/firmware-devkit_esp32c6.bin"),
+ ("firmware-xiao_esp32c6.bin", "/tmp/release-assets/firmware-xiao_esp32c6.bin"),
+ ("firmware-ee02.bin", "/tmp/release-assets/firmware-ee02.bin"),
+ # Rename-bridge duplicates for devices still on old firmware
+ # reporting the bare "devkit"/"xiao" board names -- see the
+ # build steps above. Safe to remove once no fielded device
+ # reports the bare name anymore.
("firmware-devkit.bin", "/tmp/release-assets/firmware-devkit.bin"),
("firmware-xiao.bin", "/tmp/release-assets/firmware-xiao.bin"),
]
for name, path in assets:
+ if not os.path.exists(path):
+ # Expected for firmware-ee02.bin while that build is
+ # still allowed to fail (continue-on-error, see the
+ # build step's own comment) -- publish whatever boards
+ # did build rather than crashing the whole release over
+ # a known, tracked gap.
+ print(f"Skipping {name}: build did not produce {path}")
+ continue
if name in existing_assets:
del_status, _ = req("DELETE", f"/releases/{release_id}/assets/{existing_assets[name]}")
print(f"Removed existing asset {name} (status {del_status})")
diff --git a/.gitignore b/.gitignore
index 2f341f5..af354bd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,9 @@ firmware/dependencies.lock
firmware/build_xiao/
firmware/sdkconfig.xiao_local
firmware/sdkconfig.xiao_local.old
+firmware/build_ee02/
+firmware/sdkconfig.ee02_local
+firmware/sdkconfig.ee02_local.old
# Python server
server/__pycache__/
diff --git a/docs/hardware.md b/docs/hardware.md
index 374c6f2..c4d6668 100644
--- a/docs/hardware.md
+++ b/docs/hardware.md
@@ -152,3 +152,61 @@ photo. A full-color refresh on this panel takes 15-30+ seconds and draws
more current than deep sleep by a wide margin -- expect battery life (if
not running from USB power) to be dominated by refresh frequency, not
sleep current.
+
+## Board identifiers
+
+Each board reports a name to the server (`X-Frame-Board`,
+`CONFIG_FRAME_BOARD_NAME`) that's chip-qualified rather than the plain
+`devkit`/`xiao` older firmware used -- `devkit_esp32c6`, `xiao_esp32c6`,
+`ee02` (see below). This changed once a second XIAO-based board (EE02,
+an ESP32-S3) existed and "xiao" alone stopped disambiguating hardware.
+The server keeps accepting the old bare names indefinitely, since
+already-flashed devices can't be retroactively renamed.
+
+## 13.3" Spectra 6 panel on Seeed's EE02 board (driver not yet functional)
+
+A second panel size is supported server-side (the web UI shows a
+read-only "Panel: 13.3\" Spectra 6" once a frame's device reports
+itself as `ee02`) but **the firmware driver for it does not work yet**
+-- `firmware/components/epd13in3e`'s panel init/LUT/refresh register
+sequence hasn't been ported from Waveshare's/Seeed's own reference code
+(which wasn't available at the time this was scaffolded), so a build for
+this board deliberately fails to compile (`#error`) rather than risk
+sending wrong register/LUT/timing values to real hardware. Confirmed so
+far, from Waveshare's/Seeed's public product pages and a community
+ESPHome integration (not an official vendor reference driver):
+
+- Panel: [Waveshare 13.3" e-Paper (E) Spectra 6](https://www.waveshare.com/13.3inch-e-paper-hat-plus-e.htm) --
+ 1600x1200, 270.40x202.80mm, same 6-ink Spectra family as the 7.3"
+ panel. Full refresh ~19s.
+- Board: [Seeed's EE02](https://www.seeedstudio.com/XIAO-ePaper-DIY-Kit-EE02-for-13-3-Spectratm-6-E-Ink.html) --
+ a XIAO ESP32-S3 Plus (16MB flash, 8MB PSRAM) socketed into a dedicated
+ driver PCB, one reset + three user buttons, JST 2.0mm battery
+ connector with built-in charging IC.
+- Wiring (source: [github.com/rkaramandi/esphome-seeed-ee02](https://github.com/rkaramandi/esphome-seeed-ee02), a community integration, not Seeed's own schematic --
+ treat as a starting point, confirm before relying on it): unlike
+ epd7in3e's single chip-select, this panel is driven as two halves
+ sharing one CLK/MOSI/DC/RST/BUSY bus with independent chip-selects.
+
+ | Signal | GPIO | Kconfig option |
+ | --- | --- | --- |
+ | CLK | 7 | `EPD_PIN_CLK` |
+ | MOSI | 9 | `EPD_PIN_MOSI` |
+ | CS (master half) | 44 | `EPD_PIN_CS_MASTER` |
+ | CS (slave half) | 41 | `EPD_PIN_CS_SLAVE` |
+ | DC | 10 | `EPD_PIN_DC` |
+ | RST | 38 | `EPD_PIN_RST` |
+ | BUSY | 4 | `EPD_PIN_BUSY` |
+ | Panel power-enable | 43 | `EPD_PIN_POWER_EN` |
+
+ User buttons are reportedly at GPIO 2/3/5, but which physical button
+ maps to which logical role (next/back/menu) isn't confirmed, and the
+ firmware's button Kconfig options (`FRAME_NEXT_BUTTON_GPIO` etc.,
+ `firmware/main/Kconfig.projbuild`) still range-limit to GPIO 0-7 --
+ the ESP32-C6's deep-sleep-wakeup-capable set, not yet verified against
+ the ESP32-S3's. SPI clock is reportedly reliable only up to 2MHz on
+ this panel/board (vs. epd7in3e's 4MHz default) -- see
+ `firmware/sdkconfig.ee02`.
+
+See `firmware/components/epd13in3e/epd13in3e.c`'s own top comment for
+what's needed to actually make this board work.
diff --git a/firmware/README.md b/firmware/README.md
index f56aff2..acc382d 100644
--- a/firmware/README.md
+++ b/firmware/README.md
@@ -1,6 +1,10 @@
# ESPresso Frame Firmware
-ESP-IDF firmware for the ESP32-C6. On first boot it provisions itself over
+ESP-IDF firmware for the ESP32-C6 (devkit/xiao boards, 7.3" panel) or
+ESP32-S3 (ee02 board, 13.3" panel -- see
+[Building for Seeed's EE02](#building-for-seeeds-ee02-esp32-s3--133-panel-driver-not-yet-functional)
+below; the driver for that panel doesn't work yet). 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.
@@ -48,6 +52,29 @@ never clobbers the other:
(`./build_for_board.sh devkit ...` does the same for the dev board --
equivalent to a plain `idf.py`, just consistent with the XIAO invocation.)
+### Building for Seeed's EE02 (ESP32-S3 + 13.3" panel, driver not yet functional)
+
+EE02 is a different chip (ESP32-S3, not C6), so it needs `set-target
+esp32s3` instead of `esp32c6`, and its own partition table/flash-size
+Kconfig sized for its 16MB flash
+([`partitions_ee02.csv`](partitions_ee02.csv)):
+
+```
+./build_for_board.sh ee02 set-target esp32s3
+./build_for_board.sh ee02 build
+```
+
+**This will fail to compile.** `firmware/components/epd13in3e`'s panel
+init/LUT/refresh register sequence hasn't been ported from vendor demo
+code yet (see that file's own top comment and
+[`docs/hardware.md`](../docs/hardware.md#133-spectra-6-panel-on-seeeds-ee02-board-driver-not-yet-functional))
+-- a deliberate `#error`, not a bug in this build path. Everything
+around it (target selection, Kconfig, partition table, sdkconfig
+layering, `main/CMakeLists.txt`'s component selection) is in place and
+exercised by CI (`.gitea/workflows/firmware-build-check.yml`/
+`firmware-release-build.yml`, both with `continue-on-error` on this
+board's step until the driver is real).
+
## Configuration (`idf.py menuconfig`)
Under **ESPresso Frame Configuration**:
diff --git a/firmware/build_for_board.sh b/firmware/build_for_board.sh
index 900372f..f4465ca 100755
--- a/firmware/build_for_board.sh
+++ b/firmware/build_for_board.sh
@@ -1,28 +1,42 @@
#!/usr/bin/env bash
-# Builds/flashes for a specific board variant. This project targets two:
+# Builds/flashes for a specific board variant. This project targets three:
#
# devkit ESP32-C6-DevKitC-1 (8MB flash) -- the dev board. This is
# also the plain `idf.py` default (sdkconfig/build/), so this
# script's devkit mode is mostly for symmetry -- normal
-# `idf.py build`/`flash` work fine too.
+# `idf.py build`/`flash` work fine too. Reports itself as
+# "devkit_esp32c6" (see main/Kconfig.projbuild).
# xiao Seeed XIAO ESP32-C6 (4MB flash) -- the production board.
+# Reports itself as "xiao_esp32c6".
+# ee02 Seeed EE02 (XIAO ESP32-S3 Plus, 16MB flash) + 13.3" Spectra 6
+# panel -- a genuinely different chip target (esp32s3, not
+# esp32c6), unlike xiao's same-chip Kconfig-only variant.
+# Reports itself as "ee02". NOTE: the epd13in3e driver this
+# board links (firmware/components/epd13in3e) doesn't actually
+# work yet -- its panel init/LUT/refresh register sequence is
+# still unported from vendor demo code (see that component's
+# own top-of-file comment); building for ee02 will fail to
+# compile until that lands, by design (a deliberate #error, not
+# a bug in this script).
#
-# The two need different partition tables (the XIAO's 4MB doesn't fit
-# the dev board's two 2MB OTA app slots -- see partitions_xiao.csv,
-# 1.875MB slots instead) and a different flash-size Kconfig. Rather
-# than hand-editing the shared sdkconfig back and forth (fragile, easy
-# to leave it in the wrong state for whichever board you flash next),
-# each board gets its own build directory and its own generated
-# sdkconfig, seeded from sdkconfig.defaults (shared) with the board's
-# override file layered on top via ESP-IDF's own SDKCONFIG_DEFAULTS
-# mechanism. Switching boards is just switching which one you invoke --
-# neither ever touches the other's config or build output.
+# The three need different partition tables (each flash size needs its
+# own OTA app-slot sizing -- see partitions_xiao.csv/partitions_ee02.csv)
+# and different flash-size Kconfig. Rather than hand-editing the shared
+# sdkconfig back and forth (fragile, easy to leave it in the wrong state
+# for whichever board you flash next), each board gets its own build
+# directory and its own generated sdkconfig, seeded from
+# sdkconfig.defaults (shared) with the board's override file layered on
+# top via ESP-IDF's own SDKCONFIG_DEFAULTS mechanism. Switching boards is
+# just switching which one you invoke -- none ever touches another's
+# config or build output.
#
# Usage:
# ./build_for_board.sh xiao build
# ./build_for_board.sh xiao flash -p /dev/ttyUSB0
# ./build_for_board.sh xiao flash monitor -p /dev/ttyUSB0
# ./build_for_board.sh devkit build
+# ./build_for_board.sh ee02 set-target esp32s3 # first build only, see below
+# ./build_for_board.sh ee02 build
#
# Defaults to "build" if no idf.py subcommand is given.
@@ -32,7 +46,7 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$script_dir"
if [ $# -lt 1 ]; then
- echo "Usage: $0 [idf.py args...]" >&2
+ echo "Usage: $0 [idf.py args...]" >&2
exit 1
fi
board="$1"
@@ -49,8 +63,13 @@ case "$board" in
sdkconfig_path="$script_dir/sdkconfig"
defaults="$script_dir/sdkconfig.defaults"
;;
+ ee02)
+ build_dir="$script_dir/build_ee02"
+ sdkconfig_path="$script_dir/sdkconfig.ee02_local"
+ defaults="$script_dir/sdkconfig.defaults;$script_dir/sdkconfig.ee02"
+ ;;
*)
- echo "Unknown board '$board' -- expected 'devkit' or 'xiao'" >&2
+ echo "Unknown board '$board' -- expected 'devkit', 'xiao', or 'ee02'" >&2
exit 1
;;
esac
diff --git a/firmware/components/epd13in3e/CMakeLists.txt b/firmware/components/epd13in3e/CMakeLists.txt
new file mode 100644
index 0000000..f16eee5
--- /dev/null
+++ b/firmware/components/epd13in3e/CMakeLists.txt
@@ -0,0 +1,13 @@
+# SRCS is conditional on which board's panel this build targets -- see
+# epd7in3e/CMakeLists.txt's identical comment (the two components mirror
+# each other: exactly one contributes actual object files/symbols to any
+# given build, the other is required but empty).
+if(CONFIG_FRAME_PANEL_EE02_13IN3)
+ set(srcs "epd13in3e.c")
+else()
+ set(srcs "")
+endif()
+
+idf_component_register(SRCS ${srcs}
+ INCLUDE_DIRS "include"
+ PRIV_REQUIRES esp_driver_spi esp_driver_gpio)
diff --git a/firmware/components/epd13in3e/Kconfig b/firmware/components/epd13in3e/Kconfig
new file mode 100644
index 0000000..a7a4231
--- /dev/null
+++ b/firmware/components/epd13in3e/Kconfig
@@ -0,0 +1,65 @@
+menu "E-Paper Display (epd13in3e) Configuration"
+
+ config EPD_PIN_CLK
+ int "SPI CLK (SCLK) GPIO"
+ default 7
+ help
+ Defaults sourced from a community-verified ESPHome
+ integration for this exact board
+ (github.com/rkaramandi/esphome-seeed-ee02) -- NOT an
+ official Waveshare/Seeed reference driver (see
+ firmware/components/epd13in3e/epd13in3e.c's top comment,
+ which is about the still-unknown panel init/LUT/refresh
+ register sequence, a separate and larger unknown than this
+ pinout). Override if your own board wiring differs.
+
+ config EPD_PIN_MOSI
+ int "SPI MOSI (DIN) GPIO"
+ default 9
+
+ config EPD_PIN_CS_MASTER
+ int "SPI CS (master half) GPIO"
+ default 44
+ help
+ Unlike epd7in3e's single-CS interface, this panel is driven
+ as two halves over one shared CLK/MOSI/DC/RST/BUSY bus with
+ two independent chip-selects (master/slave) -- confirmed by
+ the same community ESPHome integration, not yet by this
+ component's own driver code (still unimplemented, see
+ epd13in3e.c).
+
+ config EPD_PIN_CS_SLAVE
+ int "SPI CS (slave half) GPIO"
+ default 41
+
+ config EPD_PIN_DC
+ int "Data/Command GPIO"
+ default 10
+
+ config EPD_PIN_RST
+ int "Reset GPIO"
+ default 38
+
+ config EPD_PIN_BUSY
+ int "Busy GPIO"
+ default 4
+
+ config EPD_PIN_POWER_EN
+ int "Panel power-enable GPIO"
+ default 43
+ help
+ No equivalent pin on epd7in3e's board -- the EE02 apparently
+ gates the panel's own power rail separately from the ESP32-S3
+ module's. Source: same community integration as the other
+ pins above.
+
+ config EPD_SPI_CLOCK_HZ
+ int "SPI clock speed (Hz)"
+ default 2000000
+ help
+ 2MHz, not epd7in3e's 4MHz default -- the same community
+ integration notes higher rates were unreliable on this
+ panel/board combo. Revisit once wiring is confirmed on real
+ hardware.
+
+endmenu
diff --git a/firmware/components/epd13in3e/epd13in3e.c b/firmware/components/epd13in3e/epd13in3e.c
new file mode 100644
index 0000000..b51ec7e
--- /dev/null
+++ b/firmware/components/epd13in3e/epd13in3e.c
@@ -0,0 +1,292 @@
+#include
+
+#include "driver/gpio.h"
+#include "driver/spi_master.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+#include "esp_check.h"
+#include "esp_log.h"
+#include "esp_rom_crc.h"
+
+#include "epd13in3e.h"
+
+/* This component only ever gets compiled in when CONFIG_FRAME_PANEL_EE02_
+ * 13IN3=y selects it as main/CMakeLists.txt's linked EPD driver (see
+ * main/epd_board.h) -- i.e. only when someone deliberately builds for the
+ * EE02 board. epd7in3e.c's own top comment explains why its exact
+ * command bytes/register values are trustworthy: they're a line-for-line
+ * transcription of Waveshare's own reference driver, since this class of
+ * panel controller has no public datasheet. No equivalent reference
+ * driver for the 13.3" panel + EE02 exists in this tree yet, and guessing
+ * at register/LUT/timing values is not a safe substitute -- wrong values
+ * can under-refresh (ghosting) or over-drive a real panel. Get Waveshare's
+ * or Seeed's official demo/reference code for this exact panel+board
+ * combo, port its command sequences the same way epd7in3e.c's were
+ * ported, and remove this #error as part of that. The SPI/GPIO plumbing
+ * below (bus init, busy-wait, chunked writes, the streaming/CRC contract)
+ * is NOT panel-specific and should carry over unchanged once that
+ * happens -- only the register sequences inside epd_init()/
+ * epd_turn_on_display()/epd_sleep() need real vendor values.
+ *
+ * One more thing the real driver logic will need to account for, beyond
+ * epd7in3e.c's shape: this panel is driven as two halves sharing one
+ * CLK/MOSI/DC/RST/BUSY bus but with two independent chip-selects
+ * (EPD_PIN_CS_MASTER/EPD_PIN_CS_SLAVE, see Kconfig) -- epd_send_command/
+ * epd_send_data below still only assert CS_MASTER, which is wrong for
+ * whichever commands/data need to go to the slave half instead. Source
+ * for the dual-CS pinout itself (not the command sequence) is a
+ * community-verified ESPHome integration for this exact board
+ * (github.com/rkaramandi/esphome-seeed-ee02), not an official Seeed/
+ * Waveshare reference -- treat it as a reasonable starting point, not
+ * gospel, until confirmed against real hardware. */
+#error "epd13in3e: panel init/LUT/refresh register sequence not yet ported from vendor demo code -- see this file's top comment"
+
+#define EPD_SPI_HOST SPI2_HOST
+#define EPD_SPI_CHUNK_SIZE 4096
+
+static const char *TAG = "epd13in3e";
+
+#define EPD_CHECK(expr) ESP_RETURN_ON_ERROR((expr), TAG, #expr)
+
+static spi_device_handle_t s_spi;
+
+static void epd_delay_ms(uint32_t ms)
+{
+ vTaskDelay(pdMS_TO_TICKS(ms));
+}
+
+/* BUSY: LOW = busy, HIGH = idle -- same polarity convention as epd7in3e.c;
+ * confirm against the vendor demo code once it exists (some EPD
+ * controllers invert this). See epd7in3e.c's own comment for why this
+ * polls in >= 1 FreeRTOS tick increments rather than a tight spin. */
+static void epd_wait_busy(void)
+{
+ while (gpio_get_level((gpio_num_t)CONFIG_EPD_PIN_BUSY) == 0) {
+ epd_delay_ms(20);
+ }
+}
+
+static esp_err_t epd_spi_write(const uint8_t *data, size_t len)
+{
+ while (len > 0) {
+ size_t n = len > EPD_SPI_CHUNK_SIZE ? EPD_SPI_CHUNK_SIZE : len;
+ spi_transaction_t t = {
+ .length = n * 8,
+ .tx_buffer = data,
+ };
+ EPD_CHECK(spi_device_polling_transmit(s_spi, &t));
+ data += n;
+ len -= n;
+ }
+ return ESP_OK;
+}
+
+static esp_err_t epd_send_command(uint8_t cmd)
+{
+ gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_DC, 0);
+ gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 0);
+ esp_err_t err = epd_spi_write(&cmd, 1);
+ gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 1);
+ return err;
+}
+
+static esp_err_t epd_send_data(const uint8_t *data, size_t len)
+{
+ gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_DC, 1);
+ gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 0);
+ esp_err_t err = epd_spi_write(data, len);
+ gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 1);
+ return err;
+}
+
+static esp_err_t epd_send_data_byte(uint8_t data)
+{
+ return epd_send_data(&data, 1);
+}
+
+static void epd_reset(void)
+{
+ gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1);
+ epd_delay_ms(20);
+ gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 0);
+ epd_delay_ms(2);
+ gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1);
+ epd_delay_ms(20);
+}
+
+/* TODO(epd13in3e): power-on/refresh/power-off register sequence -- see
+ * this file's top #error. epd7in3e.c's epd_turn_on_display() is the
+ * shape to mirror once the real command bytes are known. */
+esp_err_t epd_turn_on_display(void)
+{
+ (void)epd_send_command;
+ (void)epd_send_data;
+ (void)epd_send_data_byte;
+ (void)epd_wait_busy;
+ return ESP_ERR_NOT_SUPPORTED;
+}
+
+esp_err_t epd_init(void)
+{
+ /* CS_SLAVE and POWER_EN are configured as outputs here (safe,
+ * mechanical) but not yet driven anywhere below -- the dual-CS
+ * command routing and whatever power-on-vs-reset sequencing
+ * POWER_EN needs are both part of the still-unported vendor
+ * register sequence (see this file's top comment), not something
+ * to guess at. */
+ gpio_config_t out_cfg = {
+ .pin_bit_mask = (1ULL << CONFIG_EPD_PIN_DC) | (1ULL << CONFIG_EPD_PIN_RST) |
+ (1ULL << CONFIG_EPD_PIN_CS_MASTER) | (1ULL << CONFIG_EPD_PIN_CS_SLAVE) |
+ (1ULL << CONFIG_EPD_PIN_POWER_EN),
+ .mode = GPIO_MODE_OUTPUT,
+ };
+ EPD_CHECK(gpio_config(&out_cfg));
+
+ gpio_config_t busy_cfg = {
+ .pin_bit_mask = (1ULL << CONFIG_EPD_PIN_BUSY),
+ .mode = GPIO_MODE_INPUT,
+ };
+ EPD_CHECK(gpio_config(&busy_cfg));
+
+ gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 1);
+ gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_SLAVE, 1);
+
+ spi_bus_config_t bus_cfg = {
+ .mosi_io_num = CONFIG_EPD_PIN_MOSI,
+ .miso_io_num = -1,
+ .sclk_io_num = CONFIG_EPD_PIN_CLK,
+ .quadwp_io_num = -1,
+ .quadhd_io_num = -1,
+ .max_transfer_sz = EPD_SPI_CHUNK_SIZE,
+ };
+ EPD_CHECK(spi_bus_initialize(EPD_SPI_HOST, &bus_cfg, SPI_DMA_CH_AUTO));
+
+ spi_device_interface_config_t dev_cfg = {
+ .clock_speed_hz = CONFIG_EPD_SPI_CLOCK_HZ,
+ .mode = 0,
+ .spics_io_num = -1,
+ .queue_size = 1,
+ };
+ EPD_CHECK(spi_bus_add_device(EPD_SPI_HOST, &dev_cfg, &s_spi));
+
+ epd_reset();
+ epd_wait_busy();
+ epd_delay_ms(30);
+
+ /* TODO(epd13in3e): panel-specific power-on register sequence goes
+ * here, see this file's top #error. */
+ ESP_LOGE(TAG, "epd_init: panel register sequence not yet ported, EPD will not actually work");
+
+ return ESP_ERR_NOT_SUPPORTED;
+}
+
+esp_err_t epd_write_frame(epd_read_fn_t read_fn, void *ctx, uint32_t *out_crc32)
+{
+ ESP_RETURN_ON_FALSE(read_fn != NULL, ESP_ERR_INVALID_ARG, TAG, "read_fn required");
+
+ EPD_CHECK(epd_send_command(0x10));
+
+ gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_DC, 1);
+ gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 0);
+
+ /* Static rather than a stack local -- see epd7in3e.c's identical
+ * comment on why (default main task stack is smaller than this
+ * chunk buffer alone). */
+ static uint8_t chunk[EPD_SPI_CHUNK_SIZE];
+ size_t total = 0;
+ uint32_t crc = 0;
+ size_t n;
+ esp_err_t err = ESP_OK;
+ while ((n = read_fn(chunk, sizeof(chunk), ctx)) > 0) {
+ err = epd_spi_write(chunk, n);
+ if (err != ESP_OK) {
+ break;
+ }
+ crc = esp_rom_crc32_le(crc, chunk, n);
+ total += n;
+ }
+
+ gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 1);
+ EPD_CHECK(err);
+
+ if (total != EPD_FRAME_BYTES) {
+ /* Same invariant as epd7in3e.c: never trigger a refresh on a
+ * short/wrong-size stream. */
+ ESP_LOGE(TAG, "Stream supplied %u bytes, expected %u -- aborting refresh",
+ (unsigned)total, (unsigned)EPD_FRAME_BYTES);
+ return ESP_ERR_INVALID_SIZE;
+ }
+
+ if (out_crc32 != NULL) {
+ *out_crc32 = crc;
+ }
+
+ return ESP_OK;
+}
+
+esp_err_t epd_display_stream(epd_read_fn_t read_fn, void *ctx)
+{
+ esp_err_t err = epd_write_frame(read_fn, ctx, NULL);
+ if (err != ESP_OK) {
+ return err;
+ }
+ return epd_turn_on_display();
+}
+
+typedef struct {
+ const uint8_t *data;
+ size_t len;
+ size_t pos;
+} epd_buf_ctx_t;
+
+static size_t epd_buf_read(uint8_t *chunk, size_t chunk_size, void *ctx_)
+{
+ epd_buf_ctx_t *c = (epd_buf_ctx_t *)ctx_;
+ size_t remaining = c->len - c->pos;
+ size_t n = remaining < chunk_size ? remaining : chunk_size;
+ if (n == 0) {
+ return 0;
+ }
+ memcpy(chunk, c->data + c->pos, n);
+ c->pos += n;
+ return n;
+}
+
+esp_err_t epd_display_buffer(const uint8_t *frame, size_t len)
+{
+ epd_buf_ctx_t buf_ctx = { .data = frame, .len = len, .pos = 0 };
+ return epd_display_stream(epd_buf_read, &buf_ctx);
+}
+
+typedef struct {
+ uint8_t fill_byte;
+ size_t remaining;
+} epd_fill_ctx_t;
+
+static size_t epd_fill_read(uint8_t *chunk, size_t chunk_size, void *ctx_)
+{
+ epd_fill_ctx_t *c = (epd_fill_ctx_t *)ctx_;
+ size_t n = c->remaining < chunk_size ? c->remaining : chunk_size;
+ if (n == 0) {
+ return 0;
+ }
+ memset(chunk, c->fill_byte, n);
+ c->remaining -= n;
+ return n;
+}
+
+esp_err_t epd_clear(epd_color_t color)
+{
+ epd_fill_ctx_t fill_ctx = {
+ .fill_byte = (uint8_t)((color << 4) | color),
+ .remaining = EPD_FRAME_BYTES,
+ };
+ return epd_display_stream(epd_fill_read, &fill_ctx);
+}
+
+/* TODO(epd13in3e): power-off/deep-sleep register sequence -- see this
+ * file's top #error. */
+esp_err_t epd_sleep(void)
+{
+ return ESP_ERR_NOT_SUPPORTED;
+}
diff --git a/firmware/components/epd13in3e/include/epd13in3e.h b/firmware/components/epd13in3e/include/epd13in3e.h
new file mode 100644
index 0000000..309a4f7
--- /dev/null
+++ b/firmware/components/epd13in3e/include/epd13in3e.h
@@ -0,0 +1,89 @@
+#pragma once
+
+#include
+#include
+#include "esp_err.h"
+
+/* Waveshare 13.3" e-Paper (E) Spectra 6 panel, driven by Seeed's EE02
+ * board (XIAO ESP32-S3 Plus): 1600x1200, 4 bits/pixel packed
+ * 2-pixels-per-byte -- same packing convention and public function
+ * shapes as epd7in3e.h, just a different resolution, so main's own
+ * sources don't need to branch on which panel is active beyond
+ * main/epd_board.h's
+ * header selection. Confirmed from Waveshare's/Seeed's public product
+ * pages (270.40x202.80mm, 1600x1200px). NOT yet confirmed against the
+ * vendor's own reference driver code, which doesn't exist in this tree
+ * yet -- see epd13in3e.c's top comment and epd_init()'s stub. The panel
+ * is also driven as two halves over a shared bus with two independent
+ * chip-selects (see Kconfig's EPD_PIN_CS_MASTER/EPD_PIN_CS_SLAVE), unlike
+ * epd7in3e's single-CS interface -- that's an implementation detail of
+ * epd13in3e.c, not something callers of this header need to know about. */
+#define EPD_WIDTH 1600
+#define EPD_HEIGHT 1200
+#define EPD_BYTES_PER_ROW ((EPD_WIDTH + 1) / 2)
+#define EPD_FRAME_BYTES (EPD_BYTES_PER_ROW * EPD_HEIGHT)
+
+/* Same 6-ink Spectra family as the 7.3" panel, so the same 6 named
+ * colors -- but whether this panel's controller uses the SAME nibble
+ * values as epd7in3e.h's epd_color_t is UNCONFIRMED (see this file's own
+ * top comment). Left identical to epd7in3e.h's values as the working
+ * assumption; correct these against the vendor demo code once it exists,
+ * alongside server/app/image_pipeline.py's PANEL_CODES if they turn out
+ * to differ (see that file's own comment on PANEL_CODES). */
+typedef enum {
+ EPD_COLOR_BLACK = 0x0,
+ EPD_COLOR_WHITE = 0x1,
+ EPD_COLOR_YELLOW = 0x2,
+ EPD_COLOR_RED = 0x3,
+ EPD_COLOR_BLUE = 0x5,
+ EPD_COLOR_GREEN = 0x6,
+} epd_color_t;
+
+/** Configures SPI + GPIO and runs the panel's power-on register init sequence. */
+esp_err_t epd_init(void);
+
+/** Fills the whole panel with a single color and refreshes. */
+esp_err_t epd_clear(epd_color_t color);
+
+/**
+ * Called repeatedly by epd_display_stream() to fill up to chunk_size bytes
+ * into chunk. Must return the number of bytes written, or 0 once exhausted.
+ */
+typedef size_t (*epd_read_fn_t)(uint8_t *chunk, size_t chunk_size, void *ctx);
+
+/**
+ * Streams a full frame (EPD_FRAME_BYTES bytes, packed 2 pixels/byte) to the
+ * panel via read_fn and refreshes. Pulling from a caller-supplied source
+ * instead of a single buffer lets callers feed the panel directly from an
+ * HTTP response without holding the whole ~960KB frame in RAM.
+ */
+esp_err_t epd_display_stream(epd_read_fn_t read_fn, void *ctx);
+
+/**
+ * Like epd_display_stream(), but writes the frame into the panel's
+ * internal buffer over SPI WITHOUT triggering the physical refresh (the
+ * visible flash/flicker) -- call epd_turn_on_display() separately to make
+ * it visible. Returns ESP_ERR_INVALID_SIZE if read_fn didn't supply
+ * exactly EPD_FRAME_BYTES, same as epd_display_stream(); either way
+ * nothing is refreshed, so the visible screen is left untouched on
+ * error.
+ *
+ * If out_crc32 is non-NULL, it's set to a CRC32 of the bytes written --
+ * lets a caller compare against the last-displayed frame's CRC and skip
+ * the refresh entirely when nothing actually changed (e.g. redisplaying
+ * the same photo after a reboot).
+ */
+esp_err_t epd_write_frame(epd_read_fn_t read_fn, void *ctx, uint32_t *out_crc32);
+
+/**
+ * Triggers the panel's physical refresh cycle (power on, refresh, power
+ * off) -- the visible flash/flicker sequence. Call after epd_write_frame()
+ * to make the written buffer visible.
+ */
+esp_err_t epd_turn_on_display(void);
+
+/** Convenience wrapper around epd_display_stream() for an in-memory frame buffer. */
+esp_err_t epd_display_buffer(const uint8_t *frame, size_t len);
+
+/** Puts the panel into deep sleep to minimize power draw between refreshes. */
+esp_err_t epd_sleep(void);
diff --git a/firmware/components/epd7in3e/CMakeLists.txt b/firmware/components/epd7in3e/CMakeLists.txt
index 009eeee..6b373cd 100644
--- a/firmware/components/epd7in3e/CMakeLists.txt
+++ b/firmware/components/epd7in3e/CMakeLists.txt
@@ -1,3 +1,15 @@
-idf_component_register(SRCS "epd7in3e.c"
+# SRCS is conditional on which board's panel this build targets (see
+# main/CMakeLists.txt's comment on why REQUIRES/PRIV_REQUIRES itself
+# can't be) -- an ee02 build still always requires this component (so
+# its Kconfig menu/include dir exist), but contributes zero object
+# files/symbols to it, since epd13in3e.c provides the real epd_init()
+# etc. for that board instead.
+if(CONFIG_FRAME_PANEL_EE02_13IN3)
+ set(srcs "")
+else()
+ set(srcs "epd7in3e.c")
+endif()
+
+idf_component_register(SRCS ${srcs}
INCLUDE_DIRS "include"
PRIV_REQUIRES esp_driver_spi esp_driver_gpio)
diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt
index a1c3140..1ac5d8b 100644
--- a/firmware/main/CMakeLists.txt
+++ b/firmware/main/CMakeLists.txt
@@ -1,3 +1,15 @@
+# Both EPD driver components are always REQUIRED (REQUIRES/PRIV_REQUIRES
+# can't itself depend on a Kconfig value -- ESP-IDF resolves the
+# component dependency graph in an early pass that runs BEFORE Kconfig
+# is generated, so a CONFIG_* check here would silently see an empty
+# value every time; confirmed the hard way, see git history if this
+# comment ever seems suspicious). Which one actually compiles anything
+# is decided inside each component's own CMakeLists.txt (conditional
+# SRCS, evaluated in the later, Kconfig-aware pass -- that's fine, it's
+# only REQUIRES itself that has the early-pass restriction), keyed off
+# the same CONFIG_FRAME_PANEL_EE02_13IN3 that main/epd_board.h uses to
+# pick which header every source file sees -- exactly one of the two
+# ever contributes actual object files/symbols to a given build.
idf_component_register(SRCS main.c wifi_provisioning.c frame_client.c qr_onboarding.c status_screen.c epd_draw.c next_button.c back_button.c combo_button.c battery.c ota_update.c board_antenna.c
- PRIV_REQUIRES esp_event nvs_flash esp_wifi esp_netif esp_http_server esp_http_client mbedtls dns_server epd7in3e qrcode epaper_fonts esp_driver_gpio esp_adc esp_https_ota app_update esp_app_format
+ PRIV_REQUIRES esp_event nvs_flash esp_wifi esp_netif esp_http_server esp_http_client mbedtls dns_server epd7in3e epd13in3e qrcode epaper_fonts esp_driver_gpio esp_adc esp_https_ota app_update esp_app_format
EMBED_FILES root.html)
diff --git a/firmware/main/Kconfig.projbuild b/firmware/main/Kconfig.projbuild
index 55ec780..b7ff710 100644
--- a/firmware/main/Kconfig.projbuild
+++ b/firmware/main/Kconfig.projbuild
@@ -2,18 +2,29 @@ menu "ESPresso Frame Configuration"
config FRAME_BOARD_NAME
string "Board variant name, reported to the server"
- default "devkit"
+ default "devkit_esp32c6"
help
Sent as the X-Frame-Board request header on every
GET /frame/config poll, so the server can learn which board
this device is and automatically fetch the right OTA build
- from a configured Gitea repo's releases -- no manual "which
- board" picker in the web UI. Must match one of the asset
- names .gitea/workflows/firmware-release-build.yml publishes
- (firmware-.bin): "devkit" (this default, for the
- plain ESP32-C6-DevKitC-1 build) or "xiao" (set via
- sdkconfig.xiao for the Seeed XIAO ESP32-C6 build -- see
- build_for_board.sh).
+ from a configured Gitea repo's releases, and (see
+ routers/device.py's BOARD_PANEL_MAP) which EPD panel it
+ drives -- no manual "which board/panel" picker in the web
+ UI. Must match one of the asset names
+ .gitea/workflows/firmware-release-build.yml publishes
+ (firmware-.bin): "devkit_esp32c6" (this default, for
+ the plain ESP32-C6-DevKitC-1 build), "xiao_esp32c6" (set via
+ sdkconfig.xiao for the Seeed XIAO ESP32-C6 build), or "ee02"
+ (set via sdkconfig.ee02 for the Seeed EE02/XIAO ESP32-S3
+ Plus + 13.3" panel build) -- see build_for_board.sh.
+
+ Chip-qualified rather than plain "devkit"/"xiao": the EE02
+ board also sockets a XIAO module (an ESP32-S3 one), so
+ "xiao" alone stopped disambiguating hardware once EE02
+ existed. The server keeps accepting the old bare
+ "devkit"/"xiao" names indefinitely too, since already-
+ flashed devices report whatever name their current firmware
+ was built with and can't be retroactively renamed.
config FRAME_XIAO_ANTENNA_INIT
bool "Select onboard antenna on Seeed XIAO ESP32-C6 (RF switch init)"
@@ -33,6 +44,18 @@ menu "ESPresso Frame Configuration"
by default in sdkconfig.xiao; leave off for the DevKitC-1
dev board, which has no such switch.
+ config FRAME_PANEL_EE02_13IN3
+ bool "Build for the EE02 board + 13.3in Spectra 6 panel (ESP32-S3), not the 7.3in panel"
+ default n
+ help
+ Selects the epd13in3e driver component (13.3", 1600x1200)
+ instead of epd7in3e (7.3", 800x480) as main/epd_board.h's
+ target -- see firmware/components/epd13in3e. Firmware only
+ ever links one EPD driver at a time, same as the
+ devkit/xiao split links exactly one board's pin config.
+ Enabled by default in sdkconfig.ee02; leave off for the
+ ESP32-C6 boards (devkit/xiao), which drive the 7.3" panel.
+
config ESP_AP_SSID
string "Provisioning softAP SSID prefix"
default "ESPRESSO"
diff --git a/firmware/main/epd_board.h b/firmware/main/epd_board.h
new file mode 100644
index 0000000..dab34ac
--- /dev/null
+++ b/firmware/main/epd_board.h
@@ -0,0 +1,17 @@
+#pragma once
+
+/* Which EPD driver component this binary is built against -- exactly one,
+ * selected at compile time by CONFIG_FRAME_PANEL_EE02_13IN3 (see
+ * main/Kconfig.projbuild and main/CMakeLists.txt's matching PRIV_REQUIRES
+ * selection). Every file that used to `#include "epd7in3e.h"` directly
+ * includes this instead, so a build for the other board picks up the
+ * right EPD_WIDTH/EPD_HEIGHT/EPD_FRAME_BYTES/epd_color_t/epd_init() etc.
+ * with no other source change -- both driver components expose the same
+ * function/macro names (see epd13in3e.h), just sized for their own
+ * panel. */
+
+#if CONFIG_FRAME_PANEL_EE02_13IN3
+#include "epd13in3e.h"
+#else
+#include "epd7in3e.h"
+#endif
diff --git a/firmware/main/epd_draw.h b/firmware/main/epd_draw.h
index d20bb3e..671c9f6 100644
--- a/firmware/main/epd_draw.h
+++ b/firmware/main/epd_draw.h
@@ -3,10 +3,10 @@
#include
#include
-#include "epd7in3e.h"
+#include "epd_board.h"
#include "fonts.h"
-/** Sets one pixel in a malloc'd EPD_FRAME_BYTES buffer (packed 2px/byte, per epd7in3e.h). */
+/** Sets one pixel in a malloc'd EPD_FRAME_BYTES buffer (packed 2px/byte, per epd_board.h). */
void epd_draw_pixel(uint8_t *frame, int x, int y, epd_color_t color);
/**
diff --git a/firmware/main/frame_client.c b/firmware/main/frame_client.c
index c6d9389..3840d91 100644
--- a/firmware/main/frame_client.c
+++ b/firmware/main/frame_client.c
@@ -14,7 +14,7 @@
#include "freertos/FreeRTOS.h"
#include "freertos/event_groups.h"
-#include "epd7in3e.h"
+#include "epd_board.h"
#include "status_screen.h"
#include "combo_button.h"
#include "ota_update.h"
@@ -460,9 +460,9 @@ static size_t http_read_fn(uint8_t *chunk, size_t chunk_size, void *ctx_)
* to bake its overlay into this same response instead of returning the
* bare content -- see server/app/routers/device.py. Returning non-ESP_OK
* means the panel was never actually refreshed -- epd_display_stream()
- * (see epd7in3e.c) refuses to trigger a physical refresh on a short/
- * wrong-size stream, so a failure here always leaves the visible screen
- * exactly as it was. */
+ * (see the active EPD driver component, main/epd_board.h) refuses to
+ * trigger a physical refresh on a short/wrong-size stream, so a failure
+ * here always leaves the visible screen exactly as it was. */
static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t action, bool manage)
{
const char *path = "frame/image";
@@ -687,10 +687,11 @@ void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool sho
image_ok = (fetch_err == ESP_OK);
if (!image_ok) {
/* epd_display_stream() never triggers a physical refresh on a
- * failed/short/wrong-size stream (see epd7in3e.c), so the
- * visible screen is guaranteed untouched here -- always safe
- * to show what went wrong instead of leaving stale content
- * with no indication anything failed. */
+ * failed/short/wrong-size stream (see the active EPD driver
+ * component, main/epd_board.h), so the visible screen is
+ * guaranteed untouched here -- always safe to show what went
+ * wrong instead of leaving stale content with no indication
+ * anything failed. */
ESP_LOGW(TAG, "Fetch/display failed (%s), retrying sooner", esp_err_to_name(fetch_err));
/* Covers the fast-connect cache's blind spot: WiFi can report
* a successful connection (cached static IP "worked" at the
diff --git a/firmware/main/qr_onboarding.c b/firmware/main/qr_onboarding.c
index 2674adc..d5dca24 100644
--- a/firmware/main/qr_onboarding.c
+++ b/firmware/main/qr_onboarding.c
@@ -4,7 +4,7 @@
#include "esp_check.h"
#include "esp_log.h"
-#include "epd7in3e.h"
+#include "epd_board.h"
#include "epd_draw.h"
#include "fonts.h"
#include "qrcodegen.h"
diff --git a/firmware/main/status_screen.c b/firmware/main/status_screen.c
index 579b1d6..8cf3b18 100644
--- a/firmware/main/status_screen.c
+++ b/firmware/main/status_screen.c
@@ -3,7 +3,7 @@
#include "esp_check.h"
-#include "epd7in3e.h"
+#include "epd_board.h"
#include "epd_draw.h"
#include "fonts.h"
#include "wifi_provisioning.h"
diff --git a/firmware/main/wifi_provisioning.c b/firmware/main/wifi_provisioning.c
index 37b6fb3..3d18d87 100644
--- a/firmware/main/wifi_provisioning.c
+++ b/firmware/main/wifi_provisioning.c
@@ -19,7 +19,7 @@
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
-#include "epd7in3e.h"
+#include "epd_board.h"
#include "qr_onboarding.h"
#include "wifi_provisioning.h"
#include "board_antenna.h"
diff --git a/firmware/partitions_ee02.csv b/firmware/partitions_ee02.csv
new file mode 100644
index 0000000..e418e75
--- /dev/null
+++ b/firmware/partitions_ee02.csv
@@ -0,0 +1,13 @@
+# Name, Type, SubType, Offset, Size, Flags
+# Same OTA layout/offsets as partitions.csv (the 8MB dev-board table) --
+# the XIAO ESP32-S3 Plus's 16MB flash has plenty of room for the same
+# 2MB app slots (current firmware runs ~1.2MB, per partitions_xiao.csv's
+# own sizing note) without needing to trim anything the way the 4MB xiao
+# table did. Leaves ~12MB of the 16MB unused/unpartitioned for now --
+# revisit sizing once a real build's actual footprint and any EE02-
+# specific storage needs (if ever) are known.
+nvs, data, nvs, 0x9000, 0x6000,
+phy_init, data, phy, 0xf000, 0x1000,
+ota_0, app, ota_0, 0x10000, 0x200000,
+otadata, data, ota, 0x210000, 0x2000,
+ota_1, app, ota_1, 0x220000, 0x200000,
diff --git a/firmware/sdkconfig.ee02 b/firmware/sdkconfig.ee02
new file mode 100644
index 0000000..c6d17e1
--- /dev/null
+++ b/firmware/sdkconfig.ee02
@@ -0,0 +1,45 @@
+# Board-specific overrides for Seeed's EE02 (XIAO ESP32-S3 Plus + 13.3"
+# Spectra 6 panel), layered on top of sdkconfig.defaults via
+# SDKCONFIG_DEFAULTS -- see build_for_board.sh, which is the supported
+# way to build with this file. Don't set this via a plain `idf.py
+# menuconfig` on the default build; that writes straight into the shared
+# sdkconfig, not this file.
+#
+# Unlike xiao (a same-chip Kconfig-only variant of the ESP32-C6 dev
+# board), EE02 is a genuinely different chip target (ESP32-S3) --
+# build_for_board.sh runs `set-target esp32s3` for this board before
+# building, same as it runs `set-target esp32c6` for devkit/xiao.
+CONFIG_FRAME_BOARD_NAME="ee02"
+
+# Selects the epd13in3e driver component (13.3", 1600x1200) instead of
+# epd7in3e -- see main/Kconfig.projbuild and main/CMakeLists.txt.
+CONFIG_FRAME_PANEL_EE02_13IN3=y
+
+# XIAO ESP32-S3 Plus: 16MB flash, 8MB PSRAM (vs. the plain XIAO ESP32-S3's
+# 8MB/8MB) -- see partitions_ee02.csv, sized generously against this,
+# not yet trimmed/tuned against a real build's actual footprint.
+CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
+CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_ee02.csv"
+CONFIG_PARTITION_TABLE_FILENAME="partitions_ee02.csv"
+
+# EE02's e-paper interface pin defaults live in
+# firmware/components/epd13in3e/Kconfig instead of being overridden here
+# (mirrors how epd7in3e's Kconfig defaults are devkit-shaped and
+# sdkconfig.xiao only overrides the ones that actually differ) -- EE02's
+# pins are a different Kconfig menu entirely (EPD_PIN_CS_MASTER/CS_SLAVE/
+# POWER_EN don't exist on epd7in3e's board at all), not a same-menu
+# override, so there's nothing to set here beyond selecting the component
+# above.
+#
+# Deliberately NOT overriding FRAME_NEXT_BUTTON_GPIO/FRAME_BACK_BUTTON_
+# GPIO/FRAME_COMBO_BUTTON_GPIO/FRAME_BATTERY_ADC_GPIO here, even though
+# the same community source that gave the epd13in3e pinout also reports
+# EE02 has 3 user buttons at GPIO2/3/5: (1) which physical button maps to
+# which logical role (next/back/combo) isn't confirmed, and (2) more
+# importantly, those Kconfig options' `range -1 7`/`range -1 6`
+# constraints (main/Kconfig.projbuild) are hardcoded to the ESP32-C6's
+# deep-sleep-wakeup-capable GPIO set -- NOT verified against which pins
+# can actually wake the ESP32-S3 from deep sleep, which is a different
+# set on a different chip. Confirm both before wiring this up; widen/
+# adjust those Kconfig ranges if the real wakeup-capable pins for
+# whichever GPIOs EE02's buttons land on fall outside them.
diff --git a/firmware/sdkconfig.xiao b/firmware/sdkconfig.xiao
index a15b4ec..39c5c73 100644
--- a/firmware/sdkconfig.xiao
+++ b/firmware/sdkconfig.xiao
@@ -8,7 +8,7 @@ CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_xiao.csv"
CONFIG_PARTITION_TABLE_FILENAME="partitions_xiao.csv"
-CONFIG_FRAME_BOARD_NAME="xiao"
+CONFIG_FRAME_BOARD_NAME="xiao_esp32c6"
# Powers the XIAO's RF switch and selects its onboard antenna -- without
# this the softAP/STA radio doesn't reliably reach the antenna at all.
diff --git a/server/app/calendar_render.py b/server/app/calendar_render.py
index 1e1c95b..ced6e47 100644
--- a/server/app/calendar_render.py
+++ b/server/app/calendar_render.py
@@ -29,6 +29,8 @@ from PIL import Image, ImageDraw, ImageFont
from . import panel_style
from .image_pipeline import (
DEFAULT_PALETTE_RGB,
+ EPD_HEIGHT,
+ EPD_WIDTH,
_apply_manage_overlay,
_quantize,
_transpose_and_pack,
@@ -802,14 +804,14 @@ def render_calendar(events: list[dict], view: str, browse_offset: int, orientati
fetch_summary: str = "", manage: dict | None = None, week_start: int = 0,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
week_days: int = 7, week_layout: str = "horizontal",
- week_start_offset: int = 0) -> bytes:
+ week_start_offset: int = 0, panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> bytes:
"""Renders one of CALENDAR_VIEWS full-panel to the panel's packed
- format. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same
- invariant every other renderer honors. weather_cities is
- routers/common.py's get_or_refresh_weather() cache, or None/[] to
- omit the weather strip entirely (also always omitted for view ==
- "month")."""
- target_w, target_h = logical_render_size(orientation)
+ format. Returns exactly panel_w*panel_h/2 bytes (see
+ image_pipeline.panel_size), same invariant every other renderer
+ honors. weather_cities is routers/common.py's get_or_refresh_weather()
+ cache, or None/[] to omit the weather strip entirely (also always
+ omitted for view == "month")."""
+ target_w, target_h = logical_render_size(orientation, panel_w, panel_h)
img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start,
palette_rgb, weather_cities, weather_units, week_days, week_layout, week_start_offset)
img = _apply_manage_overlay(img, manage)
@@ -822,11 +824,12 @@ def render_calendar_preview_png(events: list[dict], view: str, browse_offset: in
fetch_summary: str = "", manage: dict | None = None, week_start: int = 0,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
week_days: int = 7, week_layout: str = "horizontal",
- week_start_offset: int = 0, font_scale: float = 1.0) -> bytes:
+ week_start_offset: int = 0, font_scale: float = 1.0,
+ panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> bytes:
"""Same pipeline as render_calendar, but a normal browser-viewable
PNG in logical (upright) orientation -- mirrors
image_pipeline.render_preview_png's relationship to render_frame."""
- target_w, target_h = logical_render_size(orientation)
+ target_w, target_h = logical_render_size(orientation, panel_w, panel_h)
img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start,
palette_rgb, weather_cities, weather_units, week_days, week_layout, week_start_offset, font_scale)
img = _apply_manage_overlay(img, manage)
@@ -858,11 +861,12 @@ def _build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: l
def render_tasks(tasks: list[dict], orientation: str, palette_rgb: list | None,
- manage: dict | None = None, title: str = "Tasks") -> bytes:
+ manage: dict | None = None, title: str = "Tasks",
+ panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> bytes:
"""Renders the tasks widget full-panel to the panel's packed format.
- Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same invariant
- every other renderer honors."""
- target_w, target_h = logical_render_size(orientation)
+ Returns exactly panel_w*panel_h/2 bytes, same invariant every other
+ renderer honors."""
+ target_w, target_h = logical_render_size(orientation, panel_w, panel_h)
img = _build_tasks(tasks, target_w, target_h, palette_rgb, title)
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
@@ -870,11 +874,12 @@ def render_tasks(tasks: list[dict], orientation: str, palette_rgb: list | None,
def render_tasks_preview_png(tasks: list[dict], orientation: str, palette_rgb: list | None,
- manage: dict | None = None, title: str = "Tasks", font_scale: float = 1.0) -> bytes:
+ manage: dict | None = None, title: str = "Tasks", font_scale: float = 1.0,
+ panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> bytes:
"""Same pipeline as render_tasks, but a normal browser-viewable PNG
in logical (upright) orientation -- mirrors render_calendar_preview_
png's relationship to render_calendar."""
- target_w, target_h = logical_render_size(orientation)
+ target_w, target_h = logical_render_size(orientation, panel_w, panel_h)
img = _build_tasks(tasks, target_w, target_h, palette_rgb, title, font_scale=font_scale)
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
diff --git a/server/app/face_labels.py b/server/app/face_labels.py
index c3586e5..6db7e33 100644
--- a/server/app/face_labels.py
+++ b/server/app/face_labels.py
@@ -15,7 +15,7 @@ import io
from PIL import Image, ImageOps
-from .image_pipeline import _has_bounding_box, _placement_transform, logical_render_size
+from .image_pipeline import EPD_HEIGHT, EPD_WIDTH, _has_bounding_box, _placement_transform, logical_render_size
# Not a memory constraint anymore (the overlay renders server-side now,
# not malloc'd per-label on the device) -- purely a legibility cap. A
@@ -25,7 +25,8 @@ MAX_LABELED_FACES = 6
def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: str,
- orientation: str = "landscape", region: tuple[int, int, int, int] | None = None) -> list[dict]:
+ orientation: str = "landscape", region: tuple[int, int, int, int] | None = None,
+ panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> list[dict]:
"""Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in
logical (pre-rotation) frame space at each named face's bottom-center
point -- manage_overlay.compose() draws these directly onto the
@@ -56,7 +57,7 @@ def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: s
return []
if region is None:
- logical_w, logical_h = logical_render_size(orientation)
+ logical_w, logical_h = logical_render_size(orientation, panel_w, panel_h)
region_x0, region_y0, target_w, target_h = 0, 0, logical_w, logical_h
else:
region_x0, region_y0, target_w, target_h = region
diff --git a/server/app/html_render.py b/server/app/html_render.py
index 644c206..2b81bb3 100644
--- a/server/app/html_render.py
+++ b/server/app/html_render.py
@@ -433,15 +433,16 @@ def build(mode: str, data, target_w: int, target_h: int, palette_rgb: list | Non
def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: list | None,
units: str = "fahrenheit", city_label: str = "",
- theme_name: str | None = None) -> bytes:
+ theme_name: str | None = None, panel_w: int | None = None,
+ panel_h: int | None = None) -> bytes:
"""Modern-style analogue of weather_render.render_weather_preview_png
-- same browser-viewable-PNG convention every other widget's preview
endpoint uses. build()'s output is already palette-exact (see
ordered_dither), so the final _quantize pass here is a no-op on it,
same reasoning as the module docstring's compositing story."""
- from .image_pipeline import _quantize, _png_bytes, logical_render_size
+ from .image_pipeline import EPD_HEIGHT, EPD_WIDTH, _quantize, _png_bytes, logical_render_size
- target_w, target_h = logical_render_size(orientation)
+ target_w, target_h = logical_render_size(orientation, panel_w or EPD_WIDTH, panel_h or EPD_HEIGHT)
img = build(mode, data, target_w, target_h, palette_rgb, units, city_label, theme_name)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
return _png_bytes(quantized)
diff --git a/server/app/image_pipeline.py b/server/app/image_pipeline.py
index 60d367a..72216bb 100644
--- a/server/app/image_pipeline.py
+++ b/server/app/image_pipeline.py
@@ -10,6 +10,40 @@ from PIL import Image, ImageDraw, ImageEnhance, ImageFont, ImageOps
EPD_WIDTH = 800
EPD_HEIGHT = 480
+# Registry of every supported panel's native pixel size, keyed by
+# Frame.panel_type. New entries get added here as a new EPD driver
+# component is supported firmware-side (see firmware/components/) --
+# geometry lives in exactly one place rather than as new module-level
+# globals per panel.
+DEFAULT_PANEL_TYPE = "epd7in3e"
+PANEL_SPECS: dict[str, tuple[int, int]] = {
+ "epd7in3e": (EPD_WIDTH, EPD_HEIGHT),
+ # Waveshare's 13.3" e-Paper (E) Spectra 6 panel (270.40x202.80mm,
+ # 1600x1200px, 4:3) driven by Seeed's EE02 board -- confirmed from
+ # Waveshare's/Seeed's public product pages, not yet from real
+ # hardware or vendor demo code (see firmware/components/epd13in3e's
+ # own docstring once it exists -- the init/LUT/refresh sequence and
+ # whether this panel's nibble color codes actually match PANEL_CODES
+ # below are still unconfirmed pending that).
+ "epd13in3e": (1600, 1200),
+}
+
+# Human-readable label per PANEL_SPECS key, for the frame settings page's
+# read-only "Panel" line (see routers/device.py's BOARD_PANEL_MAP for how
+# a frame's panel_type actually gets set -- this is display-only).
+PANEL_LABELS: dict[str, str] = {
+ "epd7in3e": '7.3" Spectra 6',
+ "epd13in3e": '13.3" Spectra 6',
+}
+
+
+def panel_size(panel_type: str) -> tuple[int, int]:
+ """(width, height) native pixel size for a Frame.panel_type key.
+ Unknown/blank panel_type (e.g. a frame created before this field
+ existed) falls back to the original 7.3" panel this project shipped
+ with, never raises."""
+ return PANEL_SPECS.get(panel_type, PANEL_SPECS[DEFAULT_PANEL_TYPE])
+
# PIL's TrueType rendering antialiases by default (graduated gray edge
# pixels). Those survive straight into _quantize's Floyd-Steinberg
# dithering, which -- confirmed visually -- turns them into scattered
@@ -146,21 +180,24 @@ ORIENTATION_TRANSPOSE = {
}
-def logical_render_size(orientation: str) -> tuple[int, int]:
+def logical_render_size(orientation: str, panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> tuple[int, int]:
"""(width, height) the photo is composed/cropped at for this
- orientation, before rotating into native panel space."""
+ orientation, before rotating into native panel space. Defaults to the
+ 7.3" panel's native size; callers with a Frame in scope should pass
+ *panel_size(frame.panel_type) instead."""
if orientation in ("portrait", "portrait_flipped"):
- return EPD_HEIGHT, EPD_WIDTH
- return EPD_WIDTH, EPD_HEIGHT
+ return panel_h, panel_w
+ return panel_w, panel_h
-def logical_to_native(x: float, y: float, orientation: str) -> tuple[int, int]:
+def logical_to_native(x: float, y: float, orientation: str,
+ panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> tuple[int, int]:
"""Maps a point in logical (pre-rotation) frame space to native
- 800x480 panel space, applying the same rotation ORIENTATION_TRANSPOSE
- applies to the pixels -- anything positioned in logical coordinates
- (e.g. face labels) needs this to stay attached to the rotated
- content. PIL's ROTATE_90 is counterclockwise; ROTATE_270 clockwise."""
- logical_w, logical_h = logical_render_size(orientation)
+ panel space, applying the same rotation ORIENTATION_TRANSPOSE applies
+ to the pixels -- anything positioned in logical coordinates (e.g.
+ face labels) needs this to stay attached to the rotated content.
+ PIL's ROTATE_90 is counterclockwise; ROTATE_270 clockwise."""
+ logical_w, logical_h = logical_render_size(orientation, panel_w, panel_h)
if orientation == "landscape_flipped":
return int(logical_w - 1 - x), int(logical_h - 1 - y)
if orientation == "portrait": # ROTATE_90 (CCW)
@@ -208,10 +245,14 @@ CALIBRATED_SPECTRA6_RGB = [
(0x35, 0x56, 0x3A), # GREEN
]
-# The panel's actual 4-bit color codes (see firmware/components/epd7in3e),
-# in the same order as DEFAULT_PALETTE_RGB/PALETTE_LABELS -- fixed by the
-# hardware protocol, never user-configurable. 0x4 is intentionally unused
-# upstream.
+# The 7.3" panel's actual 4-bit color codes (see
+# firmware/components/epd7in3e), in the same order as DEFAULT_PALETTE_RGB/
+# PALETTE_LABELS -- fixed by the hardware protocol, never user-
+# configurable. 0x4 is intentionally unused upstream. Used unconditionally
+# for every panel_type today -- unverified whether the 13.3" panel's
+# driver (once it exists, see PANEL_SPECS["epd13in3e"]) uses the same
+# codes; if not, this needs to become a PANEL_CODE_TABLES dict keyed like
+# PANEL_SPECS, with _transpose_and_pack taking the right one as a param.
PANEL_CODES = [0x0, 0x1, 0x2, 0x3, 0x5, 0x6]
# Per-widget optional border (models.Widget.border_style, see
@@ -428,11 +469,13 @@ def compose_into(source: Image.Image, faces: list[dict] | None, target_w: int, t
return ImageOps.fit(fitted, (target_w, target_h), method=Image.LANCZOS) # crop_fill, or crop_faces w/ no faces
-def _compose(source: Image.Image, faces: list[dict] | None, orientation: str, display_mode: str) -> Image.Image:
+def _compose(source: Image.Image, faces: list[dict] | None, orientation: str, display_mode: str,
+ panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> Image.Image:
"""Crop/resize/letterbox `source` per display_mode -- returns an RGB
- image at logical_render_size(orientation), before enhancement or
- quantization. See render_frame for what each display_mode does."""
- return compose_into(source, faces, *logical_render_size(orientation), display_mode)
+ image at logical_render_size(orientation, panel_w, panel_h), before
+ enhancement or quantization. See render_frame for what each
+ display_mode does."""
+ return compose_into(source, faces, *logical_render_size(orientation, panel_w, panel_h), display_mode)
def _enhance(img: Image.Image, color_boost: float, contrast_boost: float) -> Image.Image:
@@ -464,17 +507,22 @@ def _quantize(img: Image.Image, palette_rgb: list | None, dither_strength: float
def _transpose_and_pack(quantized: Image.Image, orientation: str) -> bytes:
"""Rotates a logical-space quantized image into native panel space
- and packs it 2 pixels/byte the way epd7in3e.c expects. Always
- returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes."""
+ and packs it 2 pixels/byte the way the panel's EPD driver expects
+ (see firmware/components/epd7in3e). Returns exactly width*height/2
+ bytes for whatever native size `quantized` actually is post-rotation
+ -- the canvas was already built at the calling frame's own panel size
+ (see panel_size()), so this derives dimensions from the image itself
+ rather than a fixed global."""
transpose = ORIENTATION_TRANSPOSE.get(orientation)
if transpose is not None:
quantized = quantized.transpose(transpose)
pixels = quantized.load()
+ w, h = quantized.size
- out = bytearray(EPD_WIDTH * EPD_HEIGHT // 2)
+ out = bytearray(w * h // 2)
i = 0
- for y in range(EPD_HEIGHT):
- for x in range(0, EPD_WIDTH, 2):
+ for y in range(h):
+ for x in range(0, w, 2):
left = PANEL_CODES[pixels[x, y]]
right = PANEL_CODES[pixels[x + 1, y]]
out[i] = (left << 4) | right
@@ -501,11 +549,11 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
orientation: str = "landscape", palette_rgb: list | None = None,
display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: float = 1.0,
contrast_boost: float = 1.0, dither_strength: float = 1.0,
- manage: dict | None = None) -> bytes:
+ manage: dict | None = None, panel_type: str = DEFAULT_PANEL_TYPE) -> bytes:
"""Fits `source` to the panel's resolution, applies color/contrast
enhancement, quantizes it to the 6-color palette, and packs 2
- pixels/byte the way epd7in3e.c expects. Always returns exactly
- EPD_WIDTH*EPD_HEIGHT/2 bytes.
+ pixels/byte the way the target panel_type's EPD driver expects.
+ Returns exactly width*height/2 bytes for that panel (see panel_size).
`display_mode` (see DISPLAY_MODES) picks how the photo's aspect ratio
is reconciled with the panel's: crop_fill (center-crop to fill,
@@ -531,8 +579,14 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
which callers pass this straight through from. Applied after
enhancement, before quantization, so the overlay's pure black/white
graphics aren't affected by color/contrast boost.
+
+ `panel_type` (see Frame.panel_type/panel_size) picks which panel's
+ native resolution to render for -- None/unrecognized falls back to
+ the original 7.3" panel.
"""
- fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost)
+ panel_w, panel_h = panel_size(panel_type)
+ fitted = _enhance(_compose(source, faces, orientation, display_mode, panel_w, panel_h),
+ color_boost, contrast_boost)
fitted = _apply_manage_overlay(fitted, manage)
quantized = _quantize(fitted, palette_rgb, dither_strength)
return _transpose_and_pack(quantized, orientation)
@@ -547,7 +601,8 @@ def _png_bytes(img: Image.Image) -> bytes:
def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], orientation: str = "landscape",
palette_rgb: list | None = None, color_boost: float = 1.0, contrast_boost: float = 1.0,
dither_strength: float = 1.0, manage: dict | None = None, as_png: bool = False,
- capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]:
+ capture_snapshot: bool = False,
+ panel_type: str = DEFAULT_PANEL_TYPE) -> bytes | tuple[bytes, bytes]:
"""The widget system's compositor -- generalizes render_frame's tail
(paste, enhance once, overlay once, quantize once, pack once) from
"compose one photo" to "paste N already-rendered regions, then run
@@ -586,8 +641,14 @@ def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], o
from the same already-quantized canvas, so a device-facing render can
also persist a browser-viewable copy (see routers/device.py's
_record_last_displayed) without re-running composition/quantization a
- second time."""
- logical_w, logical_h = logical_render_size(orientation)
+ second time.
+
+ `panel_type` (see Frame.panel_type/panel_size) picks the target
+ panel's native resolution -- callers must have computed `regions`'
+ rects against this same panel's logical_render_size (see
+ routers/device.py's _render_widgets, which always derives both from
+ the same frame.panel_type)."""
+ logical_w, logical_h = logical_render_size(orientation, *panel_size(panel_type))
canvas = Image.new("RGB", (logical_w, logical_h), LETTERBOX_BG)
for (x, y, w, h), region_img in regions:
canvas.paste(region_img.convert("RGB"), (x, y))
@@ -607,13 +668,15 @@ def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
orientation: str = "landscape", palette_rgb: list | None = None,
display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: float = 1.0,
contrast_boost: float = 1.0, dither_strength: float = 1.0,
- manage: dict | None = None) -> bytes:
+ manage: dict | None = None, panel_type: str = DEFAULT_PANEL_TYPE) -> bytes:
"""Identical composition/enhancement/quantization pipeline as
render_frame, but returned as a normal browser-viewable PNG in
logical (upright, as-the-frame-actually-hangs) orientation rather
than packed native-panel bytes and rotation -- what the web UI's
"how it will look on the frame" preview shows."""
- fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost)
+ panel_w, panel_h = panel_size(panel_type)
+ fitted = _enhance(_compose(source, faces, orientation, display_mode, panel_w, panel_h),
+ color_boost, contrast_boost)
fitted = _apply_manage_overlay(fitted, manage)
quantized = _quantize(fitted, palette_rgb, dither_strength)
return _png_bytes(quantized)
@@ -622,7 +685,8 @@ def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
def render_placeholder(lines: list[str], qr_url: str | None = None,
orientation: str = "landscape", palette_rgb: list | None = None,
manage: dict | None = None, as_png: bool = False,
- capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]:
+ capture_snapshot: bool = False,
+ panel_type: str = DEFAULT_PANEL_TYPE) -> bytes | tuple[bytes, bytes]:
"""A readable full-panel message (plus an optional QR code) in the
same packed format as render_frame -- what /frame/image serves for a
frame that isn't claimed or configured yet, so a fresh device shows
@@ -631,9 +695,9 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
`manage`, same as render_frame's -- lets the manage button still work
(at minimum, the scan-to-manage QR) on a frame that isn't configured
yet. `capture_snapshot`, same as render_panel's -- (packed, png)
- instead of just packed."""
+ instead of just packed. `panel_type`, same as render_frame's."""
margin = 24
- logical_w, logical_h = logical_render_size(orientation)
+ logical_w, logical_h = logical_render_size(orientation, *panel_size(panel_type))
img = Image.new("RGB", (logical_w, logical_h), (255, 255, 255))
draw = ImageDraw.Draw(img) # measurement only (textbbox/textlength) -- painting goes through draw_text
diff --git a/server/app/migration.py b/server/app/migration.py
index 11b6f24..44eed4c 100644
--- a/server/app/migration.py
+++ b/server/app/migration.py
@@ -1157,6 +1157,18 @@ def _migration_41(conn) -> None:
conn.execute(text("ALTER TABLE frames_new RENAME TO frames"))
+def _migration_42(conn) -> None:
+ """Which EPD panel a frame renders for (models.Frame.panel_type, see
+ image_pipeline.PANEL_SPECS) -- same guarded-per-column shape as every
+ prior migration. Every existing frame defaults to 'epd7in3e' (the
+ original 7.3" panel), auto-corrected on next check-in if the device
+ actually reports a different board (routers/device.py's
+ BOARD_PANEL_MAP)."""
+ existing = {c["name"] for c in inspect(conn).get_columns("frames")}
+ if "panel_type" not in existing:
+ conn.execute(text("ALTER TABLE frames ADD COLUMN panel_type TEXT NOT NULL DEFAULT 'epd7in3e'"))
+
+
MIGRATIONS = [
(1, _migration_1),
(2, _migration_2),
@@ -1199,6 +1211,7 @@ MIGRATIONS = [
(39, _migration_39),
(40, _migration_40),
(41, _migration_41),
+ (42, _migration_42),
]
diff --git a/server/app/models.py b/server/app/models.py
index 4f2b734..cb9f863 100644
--- a/server/app/models.py
+++ b/server/app/models.py
@@ -151,6 +151,13 @@ class Frame(Base):
quiet_hours_end: Mapped[str] = mapped_column(String, default="07:00")
timezone: Mapped[str] = mapped_column(String, default="UTC")
orientation: Mapped[str] = mapped_column(String, default="landscape")
+ # Which EPD panel this frame renders for (image_pipeline.PANEL_SPECS
+ # key) -- a property of the device's hardware, auto-derived from its
+ # self-reported board (see routers/device.py's BOARD_PANEL_MAP), never
+ # a user-editable setting: a mismatched value would corrupt every
+ # image sent to the device. Defaults to the original 7.3" panel this
+ # project shipped with.
+ panel_type: Mapped[str] = mapped_column(String, default="epd7in3e")
# Advanced configuration: [[r,g,b], ...] x6 (black/white/yellow/red/
# blue/green, matching image_pipeline.PANEL_CODES order) overriding
# DEFAULT_PALETTE_RGB for this frame's actual panel. NULL = use the
diff --git a/server/app/routers/api_widgets.py b/server/app/routers/api_widgets.py
index 11f8e90..e0c0f0d 100644
--- a/server/app/routers/api_widgets.py
+++ b/server/app/routers/api_widgets.py
@@ -40,6 +40,7 @@ from ..image_pipeline import (
MIN_BORDER_THICKNESS,
PALETTE_LABELS,
STATIC_DISPLAY_MODES,
+ panel_size,
render_preview_png,
compose_into,
_enhance,
@@ -783,6 +784,7 @@ def api_widget_preview_rendered(
source, faces=faces, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
display_mode=pcfg.display_mode, color_boost=frame.color_boost,
contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength,
+ panel_type=frame.panel_type,
)
return Response(content=png, media_type="image/png")
@@ -891,7 +893,7 @@ def api_widget_preview_calendar(
events, summary = get_or_refresh_calendar_events_for_widget(db, frame, widget)
weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if ccfg.weather_enabled else None
- target_w, target_h = logical_render_size(frame.orientation)
+ target_w, target_h = logical_render_size(frame.orientation, *panel_size(frame.panel_type))
if ccfg.render_style == "modern":
from zoneinfo import ZoneInfo
@@ -906,6 +908,7 @@ def api_widget_preview_calendar(
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
png = _png_bytes(quantized)
else:
+ native_w, native_h = panel_size(frame.panel_type)
png = calendar_render.render_calendar_preview_png(
events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary,
@@ -913,6 +916,7 @@ def api_widget_preview_calendar(
weather_cities=weather_cities, weather_units=ccfg.weather_units,
week_days=ccfg.week_days, week_layout=ccfg.week_layout,
week_start_offset=ccfg.week_start_offset, font_scale=widget.font_scale,
+ panel_w=native_w, panel_h=native_h,
)
return Response(content=png, media_type="image/png")
@@ -936,15 +940,17 @@ def api_widget_preview_tasks(
if tcfg.render_style == "modern":
from .. import html_render
- target_w, target_h = logical_render_size(frame.orientation)
+ target_w, target_h = logical_render_size(frame.orientation, *panel_size(frame.panel_type))
img = html_render.build_tasks(tasks, target_w, target_h, frame.palette_rgb, title, frame.theme,
widget.font_scale)
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
png = _png_bytes(quantized)
else:
+ native_w, native_h = panel_size(frame.panel_type)
png = calendar_render.render_tasks_preview_png(tasks, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, title=title,
- font_scale=widget.font_scale)
+ font_scale=widget.font_scale,
+ panel_w=native_w, panel_h=native_h)
return Response(content=png, media_type="image/png")
@@ -1196,14 +1202,17 @@ def api_widget_preview_weather(
# Same local-import reasoning as widgets/weather.py's render().
from .. import html_render
+ native_w, native_h = panel_size(frame.panel_type)
png = html_render.render_weather_preview_png(
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units,
- city_label=wcfg.city_label or "", theme_name=frame.theme,
+ city_label=wcfg.city_label or "", theme_name=frame.theme, panel_w=native_w, panel_h=native_h,
)
else:
+ native_w, native_h = panel_size(frame.panel_type)
png = weather_render.render_weather_preview_png(
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units,
city_label=wcfg.city_label or "", interval_hours=wcfg.hourly_interval_hours,
+ panel_w=native_w, panel_h=native_h,
)
return Response(content=png, media_type="image/png")
@@ -1254,7 +1263,7 @@ def api_widget_preview_static(
if scfg.render_style == "modern":
from .. import html_render
- target_w, target_h = logical_render_size(frame.orientation)
+ target_w, target_h = logical_render_size(frame.orientation, *panel_size(frame.panel_type))
composed = compose_into(source, faces=None, target_w=target_w, target_h=target_h,
display_mode=scfg.display_mode)
fitted = _enhance(composed, frame.color_boost, frame.contrast_boost)
@@ -1266,6 +1275,7 @@ def api_widget_preview_static(
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
display_mode=scfg.display_mode, color_boost=frame.color_boost,
contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength,
+ panel_type=frame.panel_type,
)
return Response(content=png, media_type="image/png")
@@ -1285,8 +1295,9 @@ def api_widget_preview_text(
xcfg = db.get(TextWidgetConfig, widget.id)
if not has_text(xcfg.content):
raise HTTPException(400, "No text authored on this widget yet")
+ native_w, native_h = panel_size(frame.panel_type)
png = text_widget.render_preview_png(xcfg, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
- theme_name=frame.theme)
+ theme_name=frame.theme, panel_w=native_w, panel_h=native_h)
return Response(content=png, media_type="image/png")
@@ -1405,7 +1416,7 @@ def api_widget_preview_whiteboard(
if wcfg.render_style == "modern":
from .. import html_render
- target_w, target_h = logical_render_size(frame.orientation)
+ target_w, target_h = logical_render_size(frame.orientation, *panel_size(frame.panel_type))
composed = compose_into(source, faces=None, target_w=target_w, target_h=target_h,
display_mode="letterbox")
img = html_render.build_framed_image(composed, target_w, target_h, frame.palette_rgb, frame.theme,
@@ -1415,6 +1426,6 @@ def api_widget_preview_whiteboard(
else:
png = render_preview_png(
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
- display_mode="letterbox",
+ display_mode="letterbox", panel_type=frame.panel_type,
)
return Response(content=png, media_type="image/png")
diff --git a/server/app/routers/common.py b/server/app/routers/common.py
index b82c203..79f3b41 100644
--- a/server/app/routers/common.py
+++ b/server/app/routers/common.py
@@ -18,7 +18,7 @@ from sqlalchemy.orm import Session
from .. import caldav_client, calendar_feed, grid, quiet_hours, weather, whiteboard
from ..db import widget_locked
-from ..image_pipeline import logical_render_size
+from ..image_pipeline import logical_render_size, panel_size
from ..immich_client import ImmichClient
from ..models import (
BatteryLog,
@@ -514,7 +514,7 @@ def build_manage_content(db: Session, frame: Frame, request) -> dict:
if any(db.get(PhotoWidgetConfig, w.id).current_asset_id for w in photo_widgets):
content["share_url"] = f"{base}/frame/share/{frame.manage_token}"
- panel_w, panel_h = logical_render_size(frame.orientation)
+ panel_w, panel_h = logical_render_size(frame.orientation, *panel_size(frame.panel_type))
face_labels: list[dict] = []
for widget in photo_widgets:
cfg = db.get(PhotoWidgetConfig, widget.id)
diff --git a/server/app/routers/device.py b/server/app/routers/device.py
index ee84283..327d138 100644
--- a/server/app/routers/device.py
+++ b/server/app/routers/device.py
@@ -27,7 +27,14 @@ from ..auth import get_server_settings, require_device
from ..db import SessionLocal, frame_locked, get_db
from ..firmware import firmware_path
from ..global_actions import GLOBAL_ACTIONS
-from ..image_pipeline import draw_widget_border, logical_render_size, render_panel, render_placeholder, resolve_border_color
+from ..image_pipeline import (
+ draw_widget_border,
+ logical_render_size,
+ panel_size,
+ render_panel,
+ render_placeholder,
+ resolve_border_color,
+)
from ..models import BatteryLog, Frame, FrameButtonAction, Widget
from ..widgets import WIDGET_TYPES
from .common import (
@@ -62,6 +69,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
manage=manage,
as_png=as_png,
capture_snapshot=capture_snapshot,
+ panel_type=frame.panel_type,
)
if frame.owner_user_id is None:
return render_placeholder(
@@ -71,6 +79,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
manage=manage,
as_png=as_png,
capture_snapshot=capture_snapshot,
+ panel_type=frame.panel_type,
)
return render_placeholder(
["Almost there!", "Add a widget for this frame at", base],
@@ -80,6 +89,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
manage=manage,
as_png=as_png,
capture_snapshot=capture_snapshot,
+ panel_type=frame.panel_type,
)
@@ -141,7 +151,7 @@ def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wa
all_widgets = db.scalars(
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
).all()
- panel_w, panel_h = logical_render_size(frame.orientation)
+ panel_w, panel_h = logical_render_size(frame.orientation, *panel_size(frame.panel_type))
regions = []
if all_widgets:
with ThreadPoolExecutor(max_workers=min(len(all_widgets), 8)) as pool:
@@ -160,7 +170,7 @@ def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wa
regions, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
dither_strength=frame.dither_strength, manage=manage, as_png=as_png,
- capture_snapshot=capture_snapshot,
+ capture_snapshot=capture_snapshot, panel_type=frame.panel_type,
)
@@ -185,6 +195,7 @@ def _render_frame_content(db: Session, frame: Frame, request: Request | None, ma
return render_placeholder(
["Almost there!"], orientation=frame.orientation, palette_rgb=frame.palette_rgb,
manage=manage, as_png=as_png, capture_snapshot=capture_snapshot,
+ panel_type=frame.panel_type,
)
return _setup_placeholder(frame, request, manage=manage, as_png=as_png, capture_snapshot=capture_snapshot)
@@ -251,6 +262,23 @@ def _run_global_action(db: Session, frame: Frame, button: str) -> None:
logger.exception("Global hold action %r failed for frame %d", action, frame.id)
+# Maps a device's self-reported board (X-Frame-Board, CONFIG_FRAME_BOARD_
+# NAME) to which EPD panel it drives -- the panel type is a property of
+# the board's firmware, not something a person picks in the UI (see
+# Frame.panel_type). Includes both the legacy bare names ("devkit",
+# "xiao") already baked into fielded firmware and the current chip-
+# qualified names ("devkit_esp32c6", "xiao_esp32c6") -- keep both
+# indefinitely, since already-flashed devices can't be retroactively
+# renamed and there's no cost to accepting either.
+BOARD_PANEL_MAP = {
+ "devkit": "epd7in3e",
+ "xiao": "epd7in3e",
+ "devkit_esp32c6": "epd7in3e",
+ "xiao_esp32c6": "epd7in3e",
+ "ee02": "epd13in3e",
+}
+
+
@router.get("/frame/config")
def frame_config(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Device-facing settings, polled by the frame alongside its
@@ -259,7 +287,10 @@ def frame_config(request: Request, frame: Frame = Depends(require_device), db: S
signal. Also captures the device's running firmware version and board
variant (X-Frame-Version/X-Frame-Board headers) and advertises the
available OTA image's version, so the device's update check costs
- zero extra round trips."""
+ zero extra round trips. The reported board also auto-sets
+ Frame.panel_type (see BOARD_PANEL_MAP) -- which EPD panel a frame
+ renders for is derived from what the hardware reports, never a manual
+ setting."""
reported_version = request.headers.get("X-Frame-Version", "")
reported_board = request.headers.get("X-Frame-Board", "")
with frame_locked(db, frame.id) as locked:
@@ -272,6 +303,9 @@ def frame_config(request: Request, frame: Frame = Depends(require_device), db: S
locked.device_firmware_version = reported_version
if reported_board:
locked.device_board_variant = reported_board
+ mapped_panel = BOARD_PANEL_MAP.get(reported_board)
+ if mapped_panel and mapped_panel != locked.panel_type:
+ locked.panel_type = mapped_panel
response = {
"refresh_interval_s": quiet_hours.effective_refresh_interval_s(locked),
diff --git a/server/app/routers/frame_pages.py b/server/app/routers/frame_pages.py
index de03901..aca2184 100644
--- a/server/app/routers/frame_pages.py
+++ b/server/app/routers/frame_pages.py
@@ -31,6 +31,7 @@ from ..image_pipeline import (
MAX_BORDER_THICKNESS,
MIN_BORDER_THICKNESS,
PALETTE_LABELS,
+ PANEL_LABELS,
STATIC_DISPLAY_MODES,
palette_to_hex,
)
@@ -89,6 +90,7 @@ def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get
request, db, frame_id, "frame_config.html", "config",
timezones=ALL_TIMEZONES,
palette_labels=PALETTE_LABELS,
+ panel_labels=PANEL_LABELS,
default_palette_rgb=DEFAULT_PALETTE_RGB,
calibrated_spectra6_hex=palette_to_hex(CALIBRATED_SPECTRA6_RGB),
palette_to_hex=palette_to_hex,
diff --git a/server/app/routers/pages.py b/server/app/routers/pages.py
index 779bcae..c339db0 100644
--- a/server/app/routers/pages.py
+++ b/server/app/routers/pages.py
@@ -562,6 +562,8 @@ def _render_admin(request: Request, db: Session, admin: User, notice: str | None
users_by_id = {u.id: u for u in users}
for link in links:
links_by_frame.setdefault(link.frame_id, []).append(users_by_id[link.user_id])
+ from ..image_pipeline import PANEL_LABELS
+
ctx = shell_context(request, db, admin, active_nav="admin")
ctx.update({
"users": users,
@@ -571,6 +573,7 @@ def _render_admin(request: Request, db: Session, admin: User, notice: str | None
"notice": notice,
"error": error,
"active_admin_tab": "main",
+ "panel_labels": PANEL_LABELS,
})
return templates.TemplateResponse("admin.html", ctx)
diff --git a/server/app/templates/admin.html b/server/app/templates/admin.html
index e036696..e43d06c 100644
--- a/server/app/templates/admin.html
+++ b/server/app/templates/admin.html
@@ -108,6 +108,7 @@
owner: {{ (f.owner.username if f.owner else none) or "UNCLAIMED" }}
· linked: {{ links_by_frame.get(f.id, []) | map(attribute="username") | join(", ") or "nobody" }}
firmware: {{ f.device_firmware_version or "?" }} ({{ f.device_board_variant or "board unknown" }})
+ · panel: {{ panel_labels.get(f.panel_type, f.panel_type) }}
· token ack: {{ "yes" if f.device_token_ack else "no" }}