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" }}

diff --git a/server/app/templates/frame_config.html b/server/app/templates/frame_config.html index 04a83ff..cdfa46b 100644 --- a/server/app/templates/frame_config.html +++ b/server/app/templates/frame_config.html @@ -95,6 +95,10 @@ {% if frame.device_board_variant %}Detected board: {{ frame.device_board_variant }} {% else %}Board not detected yet -- the frame reports it on its next check-in.{% endif %}

+

+ {% if frame.device_board_variant %}Panel: {{ panel_labels.get(frame.panel_type, frame.panel_type) }} + {% else %}Panel not detected yet -- determined automatically from the frame's board.{% endif %} +

{% if frame.firmware_available_version %}Uploaded: v{{ frame.firmware_available_version }} -- the frame updates itself on its next wake if it's running something else.{% else %}No firmware uploaded yet.{% endif %} diff --git a/server/app/weather_render.py b/server/app/weather_render.py index d719e77..5e995a1 100644 --- a/server/app/weather_render.py +++ b/server/app/weather_render.py @@ -34,7 +34,7 @@ from datetime import date, datetime from PIL import Image, ImageDraw, ImageFont from . import panel_style -from .image_pipeline import _apply_manage_overlay, _quantize, draw_text, logical_render_size +from .image_pipeline import EPD_HEIGHT, EPD_WIDTH, _apply_manage_overlay, _quantize, draw_text, logical_render_size # MARGIN carries panel_style.CONTENT_MARGIN's value unchanged (not # re-tuned -- every column-width/icon-size calc below was measured @@ -421,10 +421,11 @@ 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", manage: dict | None = None, - city_label: str = "", interval_hours: int = 4) -> bytes: + city_label: str = "", interval_hours: int = 4, + panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> bytes: """Same pipeline as calendar_render.render_tasks_preview_png -- a normal browser-viewable PNG in logical (upright) orientation.""" - target_w, target_h = logical_render_size(orientation) + target_w, target_h = logical_render_size(orientation, panel_w, panel_h) img = build(mode, data, target_w, target_h, palette_rgb, units, city_label, interval_hours) img = _apply_manage_overlay(img, manage) quantized = _quantize(img, palette_rgb, dither_strength=1.0) diff --git a/server/app/widgets/battery.py b/server/app/widgets/battery.py index c90e8c4..01ec346 100644 --- a/server/app/widgets/battery.py +++ b/server/app/widgets/battery.py @@ -21,7 +21,7 @@ from PIL import Image, ImageDraw from sqlalchemy.orm import Session from .. import panel_style -from ..image_pipeline import _quantize, draw_text, logical_render_size +from ..image_pipeline import _quantize, draw_text, logical_render_size, panel_size from ..models import BatteryWidgetConfig, Frame, Widget from ..routers.common import battery_estimate_s from ._shared import placeholder_image @@ -135,7 +135,7 @@ def render_preview_png(db: Session, frame: Frame, widget: Widget, orientation: s """A normal browser-viewable PNG at full logical panel size -- same "dialog preview always renders at the frame's full size, not the widget's actual grid box" convention as text.py's render_preview_png.""" - target_w, target_h = logical_render_size(orientation) + target_w, target_h = logical_render_size(orientation, *panel_size(frame.panel_type)) img = render(db, frame, widget, target_w, target_h) quantized = _quantize(img, palette_rgb, dither_strength=1.0) buf = io.BytesIO() diff --git a/server/app/widgets/text.py b/server/app/widgets/text.py index 9b912b7..f23b816 100644 --- a/server/app/widgets/text.py +++ b/server/app/widgets/text.py @@ -25,7 +25,7 @@ from PIL import Image, ImageDraw from sqlalchemy.orm import Session from .. import theme_tokens -from ..image_pipeline import _quantize, draw_text, hex_to_rgb, logical_render_size +from ..image_pipeline import EPD_HEIGHT, EPD_WIDTH, _quantize, draw_text, hex_to_rgb, logical_render_size from ..models import Frame, TextWidgetConfig, Widget from ..text_content import has_text from ._shared import placeholder_image @@ -212,7 +212,8 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: list | None, - theme_name: str | None = None) -> bytes: + theme_name: str | None = None, panel_w: int = EPD_WIDTH, + panel_h: int = EPD_HEIGHT) -> bytes: """A normal browser-viewable PNG at full logical panel size -- mirrors calendar_render.render_tasks_preview_png's relationship to render_tasks (the dialog's own preview endpoint always renders at @@ -220,7 +221,7 @@ def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: lis convention every other widget type's preview endpoint follows).""" import io - target_w, target_h = logical_render_size(orientation) + target_w, target_h = logical_render_size(orientation, panel_w, panel_h) img = _render_dispatch(cfg, target_w, target_h, palette_rgb, theme_name) quantized = _quantize(img, palette_rgb, dither_strength=1.0) buf = io.BytesIO() diff --git a/server/tests/test_board_panel_mapping.py b/server/tests/test_board_panel_mapping.py new file mode 100644 index 0000000..39f1e8b --- /dev/null +++ b/server/tests/test_board_panel_mapping.py @@ -0,0 +1,80 @@ +"""GET /frame/config auto-deriving Frame.panel_type from the device's +self-reported board (X-Frame-Board header) -- see routers/device.py's +BOARD_PANEL_MAP. Panel type is a property of the hardware, never a user +setting, so this mapping is the only thing that's allowed to change it.""" + +from __future__ import annotations + +from app.models import Frame + +from .conftest import claim_device + + +def test_new_chip_qualified_board_names_map_to_the_right_panel(client, db_session): + frame = db_session.get(Frame, 1) + creds = claim_device(db_session, frame) + + resp = client.get(f"/frame/config?{creds}", headers={"X-Frame-Board": "ee02"}) + assert resp.status_code == 200 + + db_session.refresh(frame) + assert frame.device_board_variant == "ee02" + assert frame.panel_type == "epd13in3e" + + +def test_legacy_bare_board_names_still_map_correctly(client, db_session): + """Already-flashed devices that haven't been OTA'd past the + devkit/xiao -> devkit_esp32c6/xiao_esp32c6 rename must keep reporting + their old bare name and still get mapped to the right panel -- fielded + firmware can't be retroactively renamed.""" + frame = db_session.get(Frame, 1) + creds = claim_device(db_session, frame) + + resp = client.get(f"/frame/config?{creds}", headers={"X-Frame-Board": "xiao"}) + assert resp.status_code == 200 + + db_session.refresh(frame) + assert frame.device_board_variant == "xiao" + assert frame.panel_type == "epd7in3e" + + +def test_renamed_chip_qualified_board_names_map_correctly(client, db_session): + frame = db_session.get(Frame, 1) + creds = claim_device(db_session, frame) + + resp = client.get(f"/frame/config?{creds}", headers={"X-Frame-Board": "devkit_esp32c6"}) + assert resp.status_code == 200 + + db_session.refresh(frame) + assert frame.device_board_variant == "devkit_esp32c6" + assert frame.panel_type == "epd7in3e" + + +def test_unrecognized_board_name_leaves_panel_type_unchanged(client, db_session): + """An unrecognized board string still gets recorded (same as today's + device_board_variant behavior) but must never blow away whatever + panel_type is already set -- an unknown value is more likely a typo + or a not-yet-supported board than evidence the frame's actual panel + changed.""" + frame = db_session.get(Frame, 1) + frame.panel_type = "epd13in3e" + db_session.commit() + creds = claim_device(db_session, frame) + + resp = client.get(f"/frame/config?{creds}", headers={"X-Frame-Board": "some_future_board"}) + assert resp.status_code == 200 + + db_session.refresh(frame) + assert frame.device_board_variant == "some_future_board" + assert frame.panel_type == "epd13in3e" + + +def test_no_board_header_leaves_panel_type_at_its_default(client, db_session): + frame = db_session.get(Frame, 1) + creds = claim_device(db_session, frame) + + resp = client.get(f"/frame/config?{creds}") + assert resp.status_code == 200 + + db_session.refresh(frame) + assert frame.panel_type == "epd7in3e" diff --git a/server/tests/test_migrations.py b/server/tests/test_migrations.py index c4e47fe..81a8c3d 100644 --- a/server/tests/test_migrations.py +++ b/server/tests/test_migrations.py @@ -458,6 +458,24 @@ def test_migration_40_adds_font_scale_to_an_existing_database(db_session): assert widget.font_scale == 1.0 +def test_migration_42_adds_panel_type_to_an_existing_database(db_session): + """Exercises _migration_42's real guarded ALTER path (frames isn't + dropped/recreated by this replay -- migration 41 already ran -- so + the column must be added defensively, same reasoning as migration + 39/40's own comments).""" + with db_module.engine.begin() as conn: + conn.execute(text("UPDATE schema_version SET version = 41")) + + run_migrations() + + with db_module.engine.connect() as conn: + version = conn.execute(text("SELECT version FROM schema_version")).scalar() + assert version == MIGRATIONS[-1][0] + + frame = db_session.query(Frame).filter(Frame.id == 1).first() + assert frame.panel_type == "epd7in3e" + + def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(db_session): """Exercises _migration_17 and _migration_18's actual data-extraction SQL back to back (the real "existing widget-system database diff --git a/server/tests/test_render_size_invariants.py b/server/tests/test_render_size_invariants.py index f3123ba..ea00a28 100644 --- a/server/tests/test_render_size_invariants.py +++ b/server/tests/test_render_size_invariants.py @@ -161,3 +161,83 @@ def test_render_panel_backfilled_full_panel_widget_matches_grid_full_panel_rect( region = Image.new("RGB", px[2:], (10, 20, 30)) data = render_panel([(px, region)], orientation="landscape") assert len(data) == EXPECTED_BYTES + + +# --- a second, synthetic panel size (proves the packing path is genuinely +# resolution-agnostic, ahead of the real 13.3" panel's numbers existing -- +# see image_pipeline._transpose_and_pack, which derives its output size +# from the quantized image itself rather than a hardcoded EPD_WIDTH/ +# EPD_HEIGHT global) --- + + +@pytest.fixture +def synthetic_panel(monkeypatch): + """Registers a second PANEL_SPECS entry, a different size than the + real 7.3" panel, without needing the real 13.3" panel's confirmed + resolution to exist yet.""" + from app import image_pipeline + + monkeypatch.setitem(image_pipeline.PANEL_SPECS, "test_panel", (600, 400)) + return "test_panel", 600, 400 + + +@pytest.mark.parametrize("orientation", ORIENTATIONS) +def test_render_panel_size_for_a_synthetic_second_panel_type(synthetic_panel, orientation): + from app.image_pipeline import logical_render_size + + panel_type, panel_w, panel_h = synthetic_panel + w, h = logical_render_size(orientation, panel_w, panel_h) + region = Image.new("RGB", (w, h), (200, 0, 0)) + data = render_panel([((0, 0, w, h), region)], orientation=orientation, panel_type=panel_type) + assert len(data) == panel_w * panel_h // 2 + + +@pytest.mark.parametrize("orientation", ORIENTATIONS) +def test_render_placeholder_size_for_a_synthetic_second_panel_type(synthetic_panel, orientation): + panel_type, panel_w, panel_h = synthetic_panel + data = render_placeholder(["Not configured yet"], orientation=orientation, panel_type=panel_type) + assert len(data) == panel_w * panel_h // 2 + + +def test_render_panel_default_panel_type_is_unaffected_by_a_new_registry_entry(synthetic_panel): + """A second PANEL_SPECS entry existing must never change what an + ordinary (no panel_type passed) render produces -- every existing + 7.3" frame's output stays byte-identical regardless of what other + panels get registered.""" + data = render_placeholder(["Not configured yet"], orientation="landscape") + assert len(data) == EXPECTED_BYTES + + +def test_panel_size_falls_back_to_the_original_panel_for_unknown_types(): + from app.image_pipeline import panel_size + + assert panel_size("nonexistent") == (EPD_WIDTH, EPD_HEIGHT) + assert panel_size("") == (EPD_WIDTH, EPD_HEIGHT) + + +# --- the real (not synthetic) 13.3" Spectra 6 / EE02 panel geometry, +# confirmed from Waveshare's/Seeed's public product pages -- the vendor +# init/LUT/refresh sequence firmware-side is still unconfirmed (see +# image_pipeline.PANEL_SPECS's own comment), but the geometry itself is +# real, not a placeholder, so it gets the same coverage as the 7.3" panel +# rather than just the synthetic-panel tests above. --- + + +@pytest.mark.parametrize("orientation", ORIENTATIONS) +def test_render_panel_size_for_the_real_13in3_panel(orientation): + from app.image_pipeline import PANEL_SPECS, logical_render_size + + panel_w, panel_h = PANEL_SPECS["epd13in3e"] + w, h = logical_render_size(orientation, panel_w, panel_h) + region = Image.new("RGB", (w, h), (200, 0, 0)) + data = render_panel([((0, 0, w, h), region)], orientation=orientation, panel_type="epd13in3e") + assert len(data) == panel_w * panel_h // 2 + + +@pytest.mark.parametrize("orientation", ORIENTATIONS) +def test_render_placeholder_size_for_the_real_13in3_panel(orientation): + from app.image_pipeline import PANEL_SPECS + + panel_w, panel_h = PANEL_SPECS["epd13in3e"] + data = render_placeholder(["Not configured yet"], orientation=orientation, panel_type="epd13in3e") + assert len(data) == panel_w * panel_h // 2