Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0fefc19f1 | ||
|
|
454c03586e | ||
|
|
fe5a2df074 | ||
|
|
474b92a282 | ||
|
|
1d39e439ff | ||
|
|
2868087467 | ||
|
|
09119e775f | ||
|
|
f209880fd0 | ||
|
|
455020cb1f | ||
|
|
466efdb873 | ||
|
|
e363db0e4e | ||
|
|
5f4f8f2ea7 | ||
|
|
e331f5e5a1 | ||
|
|
c6dad191fb | ||
|
|
8ea1c53ec3 | ||
|
|
d34eb1bf45 | ||
|
|
bcea090e73 | ||
|
|
37d57a1f88 | ||
|
|
d974e872ba | ||
|
|
dfe9d71971 | ||
|
|
05b417a29b | ||
|
|
a48c84ed4a | ||
|
|
d1f1968317 | ||
|
|
83994aab7b | ||
|
|
5866c2f040 | ||
|
|
dd038f8e46 | ||
|
|
3fdda096a9 | ||
|
|
575b3cfa61 | ||
|
|
aa4a382c1b | ||
|
|
684225422c | ||
|
|
08960c9eec | ||
|
|
f0c21af220 | ||
|
|
8602ee3add | ||
|
|
7d34eca5d7 | ||
|
|
fcf3aec4c0 | ||
|
|
9911151d8d | ||
|
|
4e8c6e534b | ||
|
|
b15747a604 | ||
|
|
eb7127718b | ||
|
|
90a014d161 | ||
|
|
efb0f2e22d | ||
|
|
270979949f | ||
|
|
52ebafab78 | ||
|
|
6118705c37 | ||
|
|
5247f5e512 | ||
|
|
8bcc574f99 | ||
|
|
c323402895 | ||
|
|
6fa3e2c2d2 | ||
|
|
af513c1b5a | ||
|
|
c7b164e6cd | ||
|
|
5dfa6c8197 | ||
|
|
e362519261 | ||
|
|
c1c657c0a9 | ||
|
|
edbd90745b | ||
|
|
3735c5bfa7 | ||
|
|
f1fda9bdee | ||
|
|
b2f63601c0 | ||
|
|
35e80c6d1c | ||
|
|
4a2b1f3795 | ||
|
|
14c47aa2a0 | ||
|
|
b5c52004c8 | ||
|
|
9f3f4b6f62 | ||
|
|
0e35735a2a | ||
|
|
20c7620393 | ||
|
|
173d82a238 | ||
|
|
914eaed71c | ||
|
|
289d308b57 | ||
|
|
8b9f636cce | ||
|
|
9c8a87e90d | ||
|
|
82f60ed428 | ||
|
|
569bf733e9 | ||
|
|
cb11ffdd2d | ||
|
|
a33a3a71e4 | ||
|
|
63751a79ad | ||
|
|
86b94a9e64 | ||
|
|
77fe78d874 | ||
|
|
5d4bb53b8a | ||
|
|
99069ba5fe | ||
|
|
37bd657299 | ||
|
|
f48daa71c8 | ||
|
|
8bc0749b42 | ||
|
|
1c67dd20d7 | ||
|
|
1100580c2c | ||
|
|
31adc34a19 | ||
|
|
b4ca795003 | ||
|
|
8556221b08 | ||
|
|
dadd9ec164 | ||
|
|
c171047adf | ||
|
|
afbe9db409 | ||
|
|
49794b4973 | ||
|
|
1f62653118 | ||
|
|
8ae09f238b | ||
|
|
644fdefa66 | ||
|
|
14cf212a60 | ||
|
|
db9a6f1875 | ||
|
|
ce8525bee8 | ||
|
|
01b9e9f1d0 | ||
|
|
33af5408fd | ||
|
|
67d99dd6c0 | ||
|
|
7fc262f9c3 | ||
|
|
27cd6b3703 | ||
|
|
ffce798754 | ||
|
|
acdb929a99 | ||
|
|
95d69a5512 | ||
|
|
3a0007118c | ||
|
|
aa194be09a | ||
|
|
716776c3f2 | ||
|
|
0f98d96d25 | ||
|
|
c007acde75 | ||
|
|
15e37c77cd | ||
|
|
1fa1c68478 | ||
|
|
fc65b19cf2 | ||
|
|
e1bca5a81a | ||
|
|
845e4f9509 | ||
|
|
02934b1d10 | ||
|
|
f24c3b9c8e | ||
|
|
38944a1287 | ||
|
|
5b4fdbe330 | ||
|
|
dcbc71e683 | ||
|
|
f4d2a23e8a | ||
|
|
55b53d5bb2 | ||
|
|
60fcfca4a0 | ||
|
|
996e06e2bc | ||
|
|
d324bc4a57 | ||
|
|
ac4b57e611 | ||
|
|
462b558bef | ||
|
|
9f9ad34a40 | ||
|
|
e48ac50ea1 | ||
|
|
83c59af1dd | ||
|
|
49bc9f9ec9 | ||
|
|
e802882fc1 | ||
|
|
5b11f2accb | ||
|
|
c1c803b497 | ||
|
|
8e10ca540e |
@@ -0,0 +1,132 @@
|
||||
---
|
||||
name: build-firmware
|
||||
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 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
|
||||
`cap_sys_admin` (and blocks the bare `unshare` syscall) from the
|
||||
capability set regardless of uid, which container image-layer
|
||||
extraction and namespace setup both require. Confirmed by hand:
|
||||
`docker run hello-world` fails to extract even the tiny hello-world
|
||||
layer ("failed to extract layer... operation not permitted" with the
|
||||
overlayfs snapshotter; "unshare: operation not permitted" even with
|
||||
the vfs storage driver instead). This is a hard restriction of the
|
||||
sandbox itself, not a permissions/setup problem -- don't spend time
|
||||
re-trying `--privileged`-equivalent flags or alternate storage drivers,
|
||||
none of it routes around a missing `cap_sys_admin`.
|
||||
|
||||
The workaround: skip containers entirely and install ESP-IDF the same
|
||||
way a developer would set it up on their own machine (`git clone` +
|
||||
ESP-IDF's own `install.sh`) -- that path needs nothing this sandbox
|
||||
disallows, just normal file/process operations.
|
||||
|
||||
## Setup (once per fresh container)
|
||||
|
||||
```bash
|
||||
bash .claude/skills/build-firmware/setup.sh
|
||||
```
|
||||
|
||||
Installs (via real `apt-get` -- this container actually has root and a
|
||||
working package manager, unlike run-server's Chromium bootstrap which
|
||||
had neither):
|
||||
- OS build deps: `python3`/`venv`/`pip`, `cmake`, `ninja-build`,
|
||||
`flex`/`bison`/`gperf`, `build-essential`, `libusb-1.0-0`.
|
||||
- ESP-IDF itself: a shallow, single-branch, recursive-submodule clone
|
||||
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+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
|
||||
install`) -- if this container ever runs as non-root, this setup
|
||||
doesn't apply as-is (would need the same non-root apt-download +
|
||||
`dpkg-deb -x` extraction dance `run-server`'s `setup.sh` uses for
|
||||
Chromium).
|
||||
|
||||
Disk: budget ~4GB free before starting (esp-idf checkout + toolchain +
|
||||
Python env land around 3.4GB in `~/.espressif`, plus the ~700MB
|
||||
checkout itself). Confirmed working with as little as ~7GB free.
|
||||
|
||||
## Build
|
||||
|
||||
```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 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 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
|
||||
```
|
||||
|
||||
`flash`/`monitor` need an actual attached device and serial port --
|
||||
this sandbox has neither, so those only work when this skill runs
|
||||
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 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),
|
||||
`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
|
||||
|
||||
Both board variants (`devkit` set-target esp32c6 + build, `xiao`
|
||||
set-target esp32c6 + build) built successfully end-to-end using this
|
||||
exact setup.sh/build.sh pair, producing real
|
||||
`espresso_frame.bin` images with normal free-space margins (41%/36%
|
||||
of their respective app partitions) and no errors -- only one
|
||||
pre-existing, unrelated warning (`battery.c`'s unused `TAG` when that
|
||||
file's logging is compiled out). This is a real compile check, not
|
||||
just a syntax read -- if a future change breaks the build, this skill
|
||||
will actually catch it.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`docker: ... unshare: operation not permitted` / `failed to
|
||||
extract layer ... operation not permitted`**: expected in this
|
||||
sandbox, see the top of this file. Don't debug it further -- use this
|
||||
skill's native install instead.
|
||||
- **`ESP-IDF not found at ... -- run setup.sh first`**: `build.sh`'s
|
||||
own check for a missing `$IDF_DIR/export.sh` -- run `setup.sh` (see
|
||||
above) before the first build.
|
||||
- **`idf.py: command not found` if you try to run it directly**: same
|
||||
gotcha `firmware/build_for_board.sh` already documents -- `idf.py` is
|
||||
normally a shell *function* from ESP-IDF's `export.sh`, not on PATH
|
||||
as a real executable, so it isn't inherited into a script's own
|
||||
subshell even after sourcing `export.sh` in your interactive shell
|
||||
first. Use `build.sh` (or `build_for_board.sh`, which calls
|
||||
`python "$IDF_PATH/tools/idf.py"` directly) instead of typing
|
||||
`idf.py` in a fresh script/subshell.
|
||||
- **Disk pressure during `install.sh`**: this environment runs close to
|
||||
full (single-digit GB free is normal, not a sign of a leak) --
|
||||
`df -h /` before running `setup.sh` if a build mysteriously fails
|
||||
partway with a "no space left on device"-shaped error.
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# 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 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 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
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
firmware_dir="$(git -C "$script_dir" rev-parse --show-toplevel)/firmware"
|
||||
|
||||
IDF_DIR="$HOME/.espressif-idf/esp-idf"
|
||||
if [ ! -f "$IDF_DIR/export.sh" ]; then
|
||||
echo "ESP-IDF not found at $IDF_DIR -- run setup.sh first" >&2
|
||||
exit 1
|
||||
fi
|
||||
# export.sh is chatty and assumes an interactive shell prompt in spots;
|
||||
# redirect its own stdout, not ours, so build.sh's actual output (and a
|
||||
# real failure's stderr) stays visible.
|
||||
source "$IDF_DIR/export.sh" > /dev/null
|
||||
|
||||
cd "$firmware_dir"
|
||||
|
||||
build_one() {
|
||||
local board="$1"
|
||||
shift
|
||||
local sdkconfig target
|
||||
case "$board" in
|
||||
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 $target"
|
||||
./build_for_board.sh "$board" set-target "$target"
|
||||
fi
|
||||
|
||||
local args=("$@")
|
||||
if [ ${#args[@]} -eq 0 ]; then
|
||||
args=(build)
|
||||
fi
|
||||
./build_for_board.sh "$board" "${args[@]}"
|
||||
}
|
||||
|
||||
board="${1:-devkit}"
|
||||
shift || true
|
||||
|
||||
case "$board" in
|
||||
both)
|
||||
build_one devkit "$@"
|
||||
build_one xiao "$@"
|
||||
;;
|
||||
all)
|
||||
build_one devkit "$@"
|
||||
build_one xiao "$@"
|
||||
build_one ee02 "$@"
|
||||
;;
|
||||
*)
|
||||
build_one "$board" "$@"
|
||||
;;
|
||||
esac
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-time (idempotent) environment bootstrap for compiling the
|
||||
# espresso_frame firmware (firmware/) without Docker -- see this
|
||||
# skill's SKILL.md for why not Docker, even though that's what CI uses.
|
||||
# Re-run any time; every step is safe/fast to repeat once already done.
|
||||
set -euo pipefail
|
||||
|
||||
IDF_ROOT="$HOME/.espressif-idf"
|
||||
IDF_DIR="$IDF_ROOT/esp-idf"
|
||||
# Matches the espressif/idf:release-v6.0 image CI's
|
||||
# firmware-build-check.yml/firmware-release-build.yml use -- keep this
|
||||
# in sync with those workflow files if the project's pinned IDF version
|
||||
# ever changes.
|
||||
IDF_BRANCH="release/v6.0"
|
||||
|
||||
# 1. OS packages ESP-IDF's own install.sh needs (python3 + venv/pip,
|
||||
# cmake, ninja, a C toolchain for the odd host-side code generator, git
|
||||
# for the clone below, flex/bison/gperf for mbedtls/etc.'s generated
|
||||
# parsers, libusb for esptool's USB/JTAG bits even though this skill
|
||||
# doesn't flash real hardware). Installed via apt with real root --
|
||||
# unlike run-server's Chromium bootstrap, this container actually has
|
||||
# root and a working apt, so no non-root extraction dance is needed
|
||||
# here.
|
||||
PKGS="git python3 python3-venv python3-pip cmake ninja-build ccache libusb-1.0-0 wget flex bison gperf build-essential"
|
||||
missing=()
|
||||
for pkg in $PKGS; do
|
||||
dpkg -s "$pkg" >/dev/null 2>&1 || missing+=("$pkg")
|
||||
done
|
||||
if [ ${#missing[@]} -gt 0 ]; then
|
||||
echo "installing OS packages: ${missing[*]}"
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y "${missing[@]}"
|
||||
fi
|
||||
|
||||
# 2. ESP-IDF checkout -- shallow, single branch, recursive submodules
|
||||
# also shallow (~700MB total, vs. several GB for a full clone). Only
|
||||
# clones once; re-running this script never re-clones or resets it, so
|
||||
# any local changes you made for debugging survive a re-run.
|
||||
if [ ! -d "$IDF_DIR/.git" ]; then
|
||||
echo "cloning esp-idf $IDF_BRANCH into $IDF_DIR ..."
|
||||
mkdir -p "$IDF_ROOT"
|
||||
git clone --branch "$IDF_BRANCH" --depth 1 --shallow-submodules --recursive \
|
||||
https://github.com/espressif/esp-idf.git "$IDF_DIR"
|
||||
else
|
||||
echo "esp-idf already cloned at $IDF_DIR"
|
||||
fi
|
||||
|
||||
# 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,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)"
|
||||
@@ -0,0 +1,195 @@
|
||||
---
|
||||
name: make-widget
|
||||
description: Scaffold a new widget type for the espresso_frame server (the ~14-file checklist a widget type touches -- config table, grid footprint, render module, registry, migration, config-save + endpoints, dialog template + JS, script tag, WIDGET_LABELS, docs, saved-layout config allowlist, tests). Use when asked to add a new widget type to a frame's panel (e.g. "add a text widget", "add an RSS widget", "add a weather-only widget").
|
||||
---
|
||||
|
||||
Adding a widget type is a very consistent, repeated pattern in this
|
||||
codebase (`photos`/`calendar`/`whiteboard`/`tasks`/`static`) -- see
|
||||
`docs/widgets.md` for the system's actual data model/rendering/dialog
|
||||
architecture (read that first if you haven't). This skill is the
|
||||
checklist of every file that pattern touches, so nothing gets silently
|
||||
dropped (the static-image widget shipped without a `docs/widgets.md`
|
||||
update; this skill exists so that doesn't keep happening).
|
||||
|
||||
**For a complete worked example touching every item below**, `git show
|
||||
35e80c6 --stat` (the static-image widget's commit) in this repo.
|
||||
|
||||
All paths below are relative to `server/`.
|
||||
|
||||
## Before writing any code: shape decisions
|
||||
|
||||
Answer these first -- they determine which existing widget type is the
|
||||
closest template to copy from:
|
||||
|
||||
- **Live upstream to poll, or self-contained/user-authored?** Calendar/
|
||||
whiteboard/tasks fetch from somewhere external on a throttle
|
||||
(`checked_at` + `get_or_refresh_*` in `routers/common.py`). Photos'
|
||||
queue and the static image widget don't -- their content is set once
|
||||
via the dialog (an upload, a pick) and just sits there until changed.
|
||||
A text widget is almost certainly this second shape.
|
||||
- **Single source, or multi-source merge?** Calendar/tasks merge
|
||||
several *people's* data (`FrameCalendar`/`FrameTaskList`, owner-adds/
|
||||
anyone-mutes). Only reach for that shape if the new type genuinely
|
||||
needs to combine several linked users' own data -- most new widget
|
||||
types are single-owner/single-config and don't need it.
|
||||
- **Any button actions**, or is `ACTIONS = {}` correct (nothing to
|
||||
advance/back/force)? Tasks and static image are both `{}`.
|
||||
- **Minimum sane grid footprint** -- how small can this widget be
|
||||
before its content is illegible/pointless?
|
||||
|
||||
Pick your template accordingly:
|
||||
|
||||
| New widget shape | Copy from |
|
||||
|---|---|
|
||||
| Self-contained, user-authored/uploaded, no fetch, no actions | `app/widgets/static_image.py` |
|
||||
| Single external source, throttled fetch, one "check_now" action | `app/widgets/whiteboard.py` |
|
||||
| Multi-source merge, owner-adds/anyone-mutes permissions | `app/widgets/tasks.py` (simpler) or `calendar.py` (also has size-tier rendering) |
|
||||
| Stateful queue/rotation with advance/back | `app/widgets/photos.py` |
|
||||
|
||||
## The checklist
|
||||
|
||||
1. **`app/models.py`** -- new `<Type>WidgetConfig` table, `widget_id`
|
||||
`Mapped[int]` primary key `ForeignKey("widgets.id", ondelete="CASCADE")`,
|
||||
plus whatever fields the type needs. Add it to the `WIDGET_CONFIG_MODELS`
|
||||
dict at the bottom of the file.
|
||||
2. **`app/grid.py`** -- add an entry to `MIN_FOOTPRINT`.
|
||||
3. **`app/widgets/<type>.py`** -- new module exposing:
|
||||
- `render(db, frame, widget, target_w, target_h, is_normal_wake=True) -> Image.Image`
|
||||
-- RGB, exactly `target_w x target_h`, **never raises** for a
|
||||
foreseeable failure (missing config, fetch error) -- fall back to
|
||||
`._shared.placeholder_image(target_w, target_h, [lines])` instead.
|
||||
- `ACTIONS: dict[str, Callable[[Session, Frame, Widget], None]]`
|
||||
- `ACTION_LABELS: dict[str, str]`
|
||||
4. **`app/widgets/__init__.py`** -- import the new module, add it to
|
||||
`WIDGET_TYPES`.
|
||||
5. **`app/migration.py`** -- new `_migration_N`. A brand-new table with
|
||||
no legacy data to carry forward is just
|
||||
`Base.metadata.create_all(bind=conn)` (see `_migration_20`) -- it
|
||||
only creates the one new table, existing ones are untouched. Register
|
||||
`(N, _migration_N)` as the new last entry in `MIGRATIONS`.
|
||||
6. **`app/routers/api_widgets.py`**:
|
||||
- Add any new `Form(...)` fields to `api_widget_config_save`'s
|
||||
signature, and a new `elif widget.widget_type == "<type>":` branch
|
||||
inside its body. Reuse an existing field name (e.g. `display_mode`)
|
||||
where the semantics genuinely match -- fields are namespaced by
|
||||
which widget type actually reads them, not by name collision, so
|
||||
this is safe (see the comment above `display_mode` in that
|
||||
function).
|
||||
- Add type-specific endpoints as needed (upload/source-select/etc.).
|
||||
Use `require_widget_control` for widget-wide settings a dialog Save
|
||||
button changes; use `require_widget_view` (not control) for the
|
||||
owner-adds/anyone-mutes multi-source pattern, matching
|
||||
`api_widget_calendar_select`/`api_widget_task_list_select`.
|
||||
- Add a `GET .../preview/<type>` endpoint mirroring the others --
|
||||
`render_preview_png` (the full palette/dither pipeline) for
|
||||
image-like content, or a dedicated `render_<type>_preview_png` in a
|
||||
rendering module for text/graphics content (see
|
||||
`calendar_render.render_tasks_preview_png`).
|
||||
7. **`app/routers/frame_pages.py`** -- import the new config model, add
|
||||
an `if widget.widget_type == "<type>":` branch in `widget_dialog()`
|
||||
returning `templates.TemplateResponse("_widget_dialog_<type>.html", {...})`.
|
||||
8. **`app/templates/_widget_dialog_<type>.html`** -- the dialog
|
||||
fragment: settings card(s) + `<img class="preview-img"
|
||||
id="<type>-preview">` + a refresh button, using the existing
|
||||
`.card`/`.card-title`/`.sub`/`.checkbox-row` classes from
|
||||
`theme.css` rather than inventing new ones.
|
||||
9. **`app/static/widget_dialog_<type>.js`** -- an `init<Type>Dialog()`/
|
||||
`close<Type>Dialog()` pair (not a page-load script -- see any
|
||||
existing `widget_dialog_*.js`'s header comment for the contract).
|
||||
`window.fetch` already CSRF-injects (see `common.js`), so POSTs don't
|
||||
need a manual header. **Never build user-supplied text into the DOM
|
||||
via `innerHTML` string interpolation** -- use `textContent`/
|
||||
`createElement` (a filename, a task summary, anything another linked
|
||||
user's account could have set is a stored-XSS vector otherwise).
|
||||
10. **`app/templates/frame_layout.html`** -- add
|
||||
`<script src="/static/widget_dialog_<type>.js"></script>` next to
|
||||
the other widget dialog scripts.
|
||||
11. **`app/static/frame_layout.js`** -- add the type to both
|
||||
`DIALOG_INIT` and `DIALOG_CLOSE`.
|
||||
12. **`app/static/common.js`** -- add a `WIDGET_LABELS` entry (the
|
||||
human label shown in the add-widget button, the widget box, and the
|
||||
button-assignment picker in `frame_config.js`).
|
||||
13. **`docs/widgets.md`** -- update every place that enumerates widget
|
||||
types: the intro sentence, the `widget_type` column-value list, the
|
||||
`MIN_FOOTPRINT` prose line, the `app/widgets/` module list. This is
|
||||
the project's own "start here" doc per `CLAUDE.md` -- don't ship a
|
||||
widget without it staying accurate.
|
||||
14. **`app/routers/api_layouts.py`** -- add a `"<type>": (...)` entry to
|
||||
`LAYOUT_CONFIG_FIELDS` listing the config columns that are an
|
||||
authored *setting* (as opposed to runtime/cache state like a fetch
|
||||
cache or queue position, which a saved layout deliberately leaves
|
||||
out -- see the dict's own comment). Skipping this doesn't error or
|
||||
warn anywhere: the widget just silently saves/applies with an empty
|
||||
`{}` config forever, resetting to defaults on every layout apply or
|
||||
hold-to-cycle. This actually shipped missing for the weather widget
|
||||
-- caught only because a user noticed layout-cycling kept resetting
|
||||
its city/mode.
|
||||
|
||||
## Tests (`server/tests/`)
|
||||
|
||||
- `test_widgets_<type>.py` -- unit-level `render()` tests, no HTTP:
|
||||
correct size/mode with no config, with config, at
|
||||
`grid.MIN_FOOTPRINT`'s smallest box, `ACTIONS == {}` if passive.
|
||||
Mirror `test_widgets_static.py` (self-contained) or
|
||||
`test_widgets_tasks.py` (fetch-backed, monkeypatches the fetch call).
|
||||
- `test_widget_config_and_queue_endpoints.py` -- add a
|
||||
`_add_<type>_widget` helper plus an HTTP-level
|
||||
`test_config_save_updates_a_<type>_widget` test, and tests for any new
|
||||
endpoints (upload/select/preview: 400 before configured, 200 after,
|
||||
400 for the wrong widget type via `_require_widget_type`).
|
||||
- `test_migrations.py` -- add the new table to
|
||||
`test_expected_columns_exist_on_current_schema`'s spot-check
|
||||
(`inspector.get_table_names()` or `inspector.get_columns(...)`).
|
||||
- Owner-adds/anyone-mutes multi-source table? Add cases to
|
||||
`test_permission_boundaries.py` following its existing
|
||||
calendar-select/task-list-select pattern (owner can add, non-owner
|
||||
can mute but not add, 404 for an unrelated widget id, 400 for the
|
||||
wrong widget type).
|
||||
- Any pure-logic helper module (decoding, parsing -- like
|
||||
`app/image_upload.py`) gets its own `test_<module>.py`: no HTTP, no
|
||||
DB, just the function.
|
||||
- `test_saved_layouts.py` -- a `test_save_and_apply_round_trip_<type>_settings`
|
||||
test: set every field the new `LAYOUT_CONFIG_FIELDS` entry lists,
|
||||
save a layout, assert the `SavedLayoutWidget.config` snapshot has them
|
||||
all, delete the frame's widgets, apply the layout back, assert the
|
||||
new widget's config matches -- and that any runtime/cache field
|
||||
(`checked_at`, a fetch cache, a queue) was *not* carried over. See
|
||||
`test_save_and_apply_round_trip_weather_settings` for the pattern.
|
||||
|
||||
Run the full suite before calling it done:
|
||||
|
||||
```bash
|
||||
cd server && .venv/bin/pytest -q
|
||||
```
|
||||
|
||||
Comfortably under 30s for the whole suite (~200+ tests) -- there's no
|
||||
reason to skip this or run a subset.
|
||||
|
||||
## Browser verification (required, not optional)
|
||||
|
||||
Per `CLAUDE.md`, reading the JS is not enough -- this project has
|
||||
shipped UI bugs (mobile viewport CSS collapse, a dialog's status message
|
||||
landing behind its own backdrop, a JSON/form body mismatch) that only
|
||||
showed up live. Use the `run-server` skill:
|
||||
|
||||
- Clear existing widgets and add one of the new type
|
||||
(`POST /api/frames/1/widgets`), resize it (`PATCH`), open its dialog
|
||||
(`click .widget-box-settings`), exercise its actual settings/upload
|
||||
flow through the real UI controls (not just a raw `fetch` in `eval` --
|
||||
that only proves the endpoint works, not that the button is wired to
|
||||
it), and check `console-errors` for anything beyond the expected
|
||||
favicon 404.
|
||||
- Check the full composited panel preview
|
||||
(`#frame-preview-thumb` on `/frames/{id}/config`) actually shows the
|
||||
new widget's content -- not just its own dialog's `preview/<type>`
|
||||
image, which only proves the render function works in isolation.
|
||||
- Screenshot **both** desktop (`viewport 1280 900`) and mobile
|
||||
(the driver's default) widths -- the layout genuinely forks at the
|
||||
860px breakpoint in `theme.css`.
|
||||
|
||||
## Commit
|
||||
|
||||
One commit for the whole widget (models + migration + render + router +
|
||||
UI + tests + docs) -- this project's convention is one feature per
|
||||
commit, not split by layer. No `Co-Authored-By: Claude` trailer (see
|
||||
root `CLAUDE.md`).
|
||||
@@ -0,0 +1,2 @@
|
||||
# Generated by setup.sh -- bakes in this host's /tmp paths, not portable.
|
||||
env.sh
|
||||
@@ -0,0 +1,234 @@
|
||||
---
|
||||
name: run-server
|
||||
description: Build, run, and drive the espresso_frame FastAPI server (server/) -- start it against a scratch DB, browser-test its UI, run its pytest suite. Use when asked to start the server, take a screenshot of a frame page, click through the web UI, or verify a server/UI change actually works.
|
||||
---
|
||||
|
||||
The espresso_frame server is a FastAPI app (`app.main:app`) with a
|
||||
server-rendered Jinja UI. For agent/automated use it's driven by a
|
||||
Playwright REPL at `.claude/skills/run-server/driver.py`, run under
|
||||
tmux -- `chromium-cli` isn't available in this container, so this
|
||||
driver replaces it (same command vocabulary: nav/wait-for/click/fill/
|
||||
screenshot/eval/console-errors).
|
||||
|
||||
This container ships with **no Python, Node, Docker, or browser, and
|
||||
no sudo**. `setup.sh` bootstraps everything non-root; it's the bulk of
|
||||
what makes this skill non-obvious. Run it once per fresh container.
|
||||
|
||||
Commands below (`setup.sh`, `start-server.sh`, `stop-server.sh`,
|
||||
`env.sh`, `driver.py`) are invoked from the **repo root** via their
|
||||
`.claude/skills/run-server/` path -- the scripts `cd` into `server/`
|
||||
themselves. Anything under `.venv/` (the venv itself, `pytest`,
|
||||
`uvicorn`) lives inside `server/`, so those commands need `server/`
|
||||
prefixed or `cd server` first.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
None to install manually -- `setup.sh` does it all without root, using
|
||||
only `curl`/`apt-get download`/`dpkg-deb -x` (never `apt-get install`,
|
||||
which needs root). It downloads ~450MB total (Python, Chromium, tmux,
|
||||
shared libs) on first run.
|
||||
|
||||
```bash
|
||||
bash .claude/skills/run-server/setup.sh
|
||||
```
|
||||
|
||||
Re-running is safe and fast -- every step checks whether it already
|
||||
happened (venv exists? chromium downloaded? libs extracted? tmux
|
||||
present?) before doing any work.
|
||||
|
||||
This creates:
|
||||
- `.venv/` -- Python 3.12 + `requirements.txt` + `playwright`, via `uv`
|
||||
(a static Rust binary that fetches its own Python -- no compiler
|
||||
needed, install via `curl -LsSf https://astral.sh/uv/install.sh | sh`)
|
||||
- `~/.cache/ms-playwright/` -- Chromium (full `chrome` + headless-shell)
|
||||
- `/tmp/run-server-chromium-deps/` -- Chromium's + tmux's shared libs
|
||||
and fonts, extracted (not installed) from `.deb` files
|
||||
- `.claude/skills/run-server/env.sh` -- the `PATH`/`LD_LIBRARY_PATH`/
|
||||
`FONTCONFIG_PATH`/`RUN_SERVER_CHROME_BIN` exports the driver needs
|
||||
(gitignored -- host-specific `/tmp` paths, regenerated by setup.sh)
|
||||
|
||||
## Run (agent path)
|
||||
|
||||
**1. Start the server** against a scratch DB (never the real
|
||||
deployment's data -- see `CLAUDE.md`):
|
||||
|
||||
```bash
|
||||
bash .claude/skills/run-server/start-server.sh
|
||||
# -> server PID <pid> up on http://127.0.0.1:8420 (log: /tmp/run-server-scratch/server.log)
|
||||
```
|
||||
|
||||
Optional args: `start-server.sh [scratch-dir] [port]` (defaults
|
||||
`/tmp/run-server-scratch` / `8420`).
|
||||
|
||||
**2. Drive it**, wrapped in tmux so you can send one command at a time
|
||||
and read the response before sending the next. `tmux` itself was
|
||||
extracted the same non-root way as Chromium's libs (see Prerequisites)
|
||||
and needs `LD_LIBRARY_PATH` set in *this* shell too, not just inside
|
||||
the pane -- source `env.sh` before the first `tmux` call:
|
||||
|
||||
```bash
|
||||
source .claude/skills/run-server/env.sh
|
||||
tmux new-session -d -s runserver -x 200 -y 50
|
||||
tmux send-keys -t runserver \
|
||||
'source .claude/skills/run-server/env.sh && server/.venv/bin/python .claude/skills/run-server/driver.py' Enter
|
||||
timeout 20 bash -c 'until tmux capture-pane -t runserver -p | grep -q "driver>"; do sleep 0.3; done'
|
||||
|
||||
# Every fresh scratch DB starts with no users -- bootstrap-admin
|
||||
# completes first-run /setup and logs in (see Commands table):
|
||||
tmux send-keys -t runserver 'bootstrap-admin' Enter
|
||||
timeout 15 bash -c 'until tmux capture-pane -t runserver -p | grep -q "bootstrapped admin"; do sleep 0.3; done'
|
||||
|
||||
tmux send-keys -t runserver 'nav /frames/1/config' Enter
|
||||
tmux send-keys -t runserver 'wait-for #frame-preview-thumb' Enter
|
||||
tmux send-keys -t runserver 'screenshot before' Enter
|
||||
tmux capture-pane -t runserver -p
|
||||
```
|
||||
|
||||
Poll for a specific marker between `send-keys` and `capture-pane`
|
||||
(`driver>`, `bootstrapped admin`, `screenshot:`, ...) rather than a
|
||||
fixed `sleep` -- it's faster and fails loudly instead of capturing a
|
||||
half-rendered screen. Give each poll its own `timeout` (~15-20s); don't
|
||||
chain many polling loops inside one shell invocation -- see Gotchas.
|
||||
|
||||
Screenshots land in `/tmp/run-server-shots/` (override:
|
||||
`SCREENSHOT_DIR`). **Actually look at them** -- a blank or error-page
|
||||
screenshot is a failure to launch, not success.
|
||||
|
||||
**Test every UI change at both a desktop and a mobile viewport.** The
|
||||
driver defaults to a desktop size (1280x900); switch with `viewport`.
|
||||
The app's mobile breakpoint is 860px (`theme.css`) -- below that the
|
||||
sidebar goes off-canvas behind a hamburger (`.mobile-bar`). A page that
|
||||
looks right at 1280px can still overflow, overlap the mobile bar, or
|
||||
mis-center a `<dialog>` at phone widths -- screenshot both:
|
||||
|
||||
```bash
|
||||
tmux send-keys -t runserver 'viewport 1280 900' Enter # desktop (also the default)
|
||||
tmux send-keys -t runserver 'screenshot desktop-x' Enter
|
||||
tmux send-keys -t runserver 'viewport 390 844' Enter # iPhone-ish mobile width
|
||||
tmux send-keys -t runserver 'screenshot mobile-x' Enter
|
||||
```
|
||||
|
||||
### Driver commands
|
||||
|
||||
| command | what it does |
|
||||
|---|---|
|
||||
| `nav <path-or-url>` | navigate (relative paths resolve against `http://127.0.0.1:8420`, override with `RUN_SERVER_BASE_URL`) |
|
||||
| `wait-for <selector>` | wait up to 10s for a selector (plain CSS -- see Gotchas for attribute selectors) |
|
||||
| `click <selector>` | click an element |
|
||||
| `fill <selector> <value>` | fill an input |
|
||||
| `press <key>` | keyboard press (e.g. `Enter`) |
|
||||
| `screenshot [name]` | → `/tmp/run-server-shots/<name>.png` |
|
||||
| `eval <js>` | evaluate JS in the page, prints JSON |
|
||||
| `console-errors` | prints all captured console/page errors as a JSON array |
|
||||
| `viewport [w] [h]` | resize the viewport, default `390 844` -- use `1280 900` for desktop (see Gotchas re: real touch input) |
|
||||
| `is-open <dialog-selector>` | prints `true`/`false` for a `<dialog>` element's `.open` -- use this instead of `wait-for sel[open]` |
|
||||
| `bootstrap-admin [user] [pass]` | completes first-run `/setup` (defaults `admin`/`testpassword123`); links frame #1 and logs in |
|
||||
| `quit` | closes the browser, exits the driver |
|
||||
|
||||
**3. Stop the server** when done:
|
||||
|
||||
```bash
|
||||
bash .claude/skills/run-server/stop-server.sh
|
||||
tmux kill-session -t runserver
|
||||
```
|
||||
|
||||
## Run (human path)
|
||||
|
||||
```bash
|
||||
cd server
|
||||
DATABASE_URL="sqlite:////tmp/dev.db" CONFIG_PATH="/tmp/dev-config.json" \
|
||||
.venv/bin/uvicorn app.main:app --reload --port 8420
|
||||
```
|
||||
|
||||
Open `http://localhost:8420` in a real browser. `start.sh` (the Docker
|
||||
entrypoint) is not this -- it also launches the Node whiteboard
|
||||
render-service sidecar, which needs Node (not installed here) and
|
||||
isn't needed for most UI testing.
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
cd server && .venv/bin/pytest
|
||||
```
|
||||
|
||||
Uses its own tempfile SQLite per run (`tests/conftest.py`) -- no setup
|
||||
needed beyond the venv.
|
||||
|
||||
---
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`viewport` only resizes the window -- it does not emulate touch
|
||||
input.** `click` still dispatches a mouse click, not a tap; there's
|
||||
no touch-delay, no `:hover`-stickiness-after-tap, no `hasTouch`
|
||||
context. It catches real bugs (layout overflow, off-canvas sidebar,
|
||||
a `<dialog>` mis-centering at phone widths) but won't catch anything
|
||||
that's specifically a touch-vs-mouse event difference. Good enough
|
||||
for CSS/layout verification; not a substitute for testing on an
|
||||
actual phone if a change touches touch-specific interaction.
|
||||
|
||||
- **`chrome-headless-shell` (Playwright's default headless target)
|
||||
crashes on basic calls in this container**, e.g. `page.set_content()`
|
||||
returns `TargetClosedError`, even after every `ldd`-reported missing
|
||||
library is resolved. The full `chrome` binary (`chromium-*/chrome-linux64/chrome`)
|
||||
+ `--no-sandbox` is stable; `driver.py` and `setup.sh` both use it,
|
||||
not the headless-shell default.
|
||||
|
||||
- **Missing fonts silently break `fill()`, not just rendering.** Before
|
||||
`fontconfig`/`libfontconfig1` were extracted and `fonts.conf` pointed
|
||||
at the extracted font dir, `page.fill()` ran with no error but left
|
||||
inputs empty (`input_value()` returned `""`), and all text rendered
|
||||
invisible in screenshots. It looks like a scripting bug, not a
|
||||
missing-lib problem -- if `fill` silently no-ops, suspect fonts
|
||||
before suspecting the selector or a race.
|
||||
|
||||
- **No `apt-get install` / `playwright install-deps` (no root) and no
|
||||
`apt-get update` into the real `/var/lib/apt/lists` (root-owned).**
|
||||
Worked around by redirecting apt's state dirs to a scratch,
|
||||
user-writable path (`-o Dir::State::Lists=... -o Dir::Cache=...`),
|
||||
which makes plain `update` and `install --download-only --print-uris`
|
||||
work as a non-root user; then `dpkg-deb -x <deb> <root>` (extract,
|
||||
not install) needs no root either. `setup.sh` does this for
|
||||
Chromium's deps *and* for `tmux` itself, which also isn't
|
||||
preinstalled.
|
||||
|
||||
- **`wait-for` with an attribute selector like `#frame-preview-dialog[open]`
|
||||
is unreliable through `tmux send-keys`** -- shell/tmux escaping of
|
||||
`[`/`]` easily mangles it (seen: a real 10s Playwright timeout from a
|
||||
garbled selector, not a fast failure). Use the app-specific `is-open
|
||||
<selector>` command instead of `wait-for sel[open]` to check a
|
||||
`<dialog>`'s open state.
|
||||
|
||||
- **Don't chain many `tmux send-keys` + polling-`timeout` loops inside
|
||||
one shell invocation.** Each poll can legitimately take up to its own
|
||||
timeout (e.g. 15s) if a selector is wrong; five or six chained in one
|
||||
command can add up past this tool's own command timeout even though
|
||||
each individual step is fine. Send one or two commands per shell
|
||||
call and check the pane before continuing.
|
||||
|
||||
- **A crashed `chrome-headless-shell` process can leave a large core
|
||||
dump file** (`server/core`, ~170MB, from the crash described above)
|
||||
if core dumps are enabled. It's not part of the app -- delete it, and
|
||||
use full `chrome` (as `driver.py` does) to avoid triggering it again.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`error while loading shared libraries: libglib-2.0.so.0` (or similar)
|
||||
when launching Chromium directly**: `LD_LIBRARY_PATH` isn't set --
|
||||
source `.claude/skills/run-server/env.sh` first, or run through
|
||||
`driver.py`, which reads `RUN_SERVER_CHROME_BIN` from it.
|
||||
- **`fc-list` prints nothing after extracting fonts**: `fonts.conf`'s
|
||||
`<dir>` entries still point at the real (unpopulated) `/usr/share/fonts`.
|
||||
`setup.sh` patches this with `sed`; if you extracted packages by hand,
|
||||
do the same.
|
||||
- **`E: Could not open lock file ... Permission denied` from `apt-get`**:
|
||||
you're missing the `-o Dir::State::Lists=... -o Dir::Cache=...`
|
||||
overrides -- plain `apt-get update`/`install` always needs root here.
|
||||
- **`tmux: command not found`**: not preinstalled and no sudo; run
|
||||
`setup.sh`, which fetches it the same non-root way as Chromium's libs.
|
||||
- **`tmux: error while loading shared libraries: libutempter.so.0`**:
|
||||
you sourced `env.sh` inside the driver's tmux pane but not in the
|
||||
shell that *invokes* `tmux` itself -- `tmux` was extracted from the
|
||||
same non-root `.deb` set as Chromium and needs `LD_LIBRARY_PATH` too.
|
||||
`source .claude/skills/run-server/env.sh` before the first `tmux`
|
||||
command, not just inside `send-keys`.
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""REPL driver for the espresso_frame server's web UI.
|
||||
|
||||
Playwright-based since chromium-cli isn't available in this container.
|
||||
Reads one command per line from stdin, prints a result line -- built
|
||||
for tmux send-keys/capture-pane use by an agent. Vocabulary mirrors
|
||||
chromium-cli where it overlaps (nav/wait-for/click/fill/screenshot/
|
||||
eval/console-errors).
|
||||
|
||||
Requires setup.sh to have run first (Python venv + Playwright Chromium
|
||||
+ the non-root shared-lib/font extraction). Run via:
|
||||
|
||||
.claude/skills/run-server/env.sh sourced, then
|
||||
server/.venv/bin/python .claude/skills/run-server/driver.py
|
||||
|
||||
See SKILL.md for the full agent-path invocation (tmux wrapping etc).
|
||||
"""
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
SHOT_DIR = os.environ.get("SCREENSHOT_DIR", "/tmp/run-server-shots")
|
||||
os.makedirs(SHOT_DIR, exist_ok=True)
|
||||
BASE = os.environ.get("RUN_SERVER_BASE_URL", "http://127.0.0.1:8420")
|
||||
|
||||
|
||||
def find_chrome() -> str:
|
||||
override = os.environ.get("RUN_SERVER_CHROME_BIN")
|
||||
if override and os.path.exists(override):
|
||||
return override
|
||||
matches = glob.glob(os.path.expanduser("~/.cache/ms-playwright/chromium-*/chrome-linux64/chrome"))
|
||||
if not matches:
|
||||
sys.exit("chrome binary not found -- run setup.sh first")
|
||||
return matches[0]
|
||||
|
||||
|
||||
pw = sync_playwright().start()
|
||||
# The FULL `chrome` binary, not chrome-headless-shell (Playwright's
|
||||
# default headless target): chrome-headless-shell crashed on basic
|
||||
# calls like set_content() in this container even once every
|
||||
# ldd-reported missing lib was resolved. Full chrome + --no-sandbox is
|
||||
# stable here.
|
||||
browser = pw.chromium.launch(executable_path=find_chrome(), args=["--no-sandbox"])
|
||||
page = browser.new_page(viewport={"width": 1280, "height": 900})
|
||||
console_errors: list[str] = []
|
||||
page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None)
|
||||
page.on("pageerror", lambda exc: console_errors.append(str(exc)))
|
||||
# Playwright auto-DISMISSES native confirm()/alert() dialogs by default
|
||||
# (returns false) -- several destructive actions in this app (remove
|
||||
# widget, clear all widgets) gate on `confirm()`, so without this a
|
||||
# `click` on one of those buttons would silently no-op. Auto-accept
|
||||
# instead, since a driver testing a "yes, do the destructive thing"
|
||||
# flow needs the confirm to actually go through.
|
||||
page.on("dialog", lambda dialog: dialog.accept())
|
||||
|
||||
|
||||
def cmd_nav(arg):
|
||||
url = arg if arg.startswith("http") else BASE + arg
|
||||
page.goto(url)
|
||||
print("nav ->", page.url)
|
||||
|
||||
|
||||
def cmd_wait_for(arg):
|
||||
page.wait_for_selector(arg, timeout=10000)
|
||||
print("found:", arg)
|
||||
|
||||
|
||||
def cmd_click(arg):
|
||||
page.click(arg)
|
||||
print("clicked:", arg)
|
||||
|
||||
|
||||
def cmd_fill(arg):
|
||||
sel, _, value = arg.partition(" ")
|
||||
page.fill(sel, value)
|
||||
print("filled:", sel, "=", value)
|
||||
|
||||
|
||||
def cmd_press(arg):
|
||||
page.keyboard.press(arg)
|
||||
print("pressed:", arg)
|
||||
|
||||
|
||||
def cmd_screenshot(arg):
|
||||
name = arg or f"ss-{len(os.listdir(SHOT_DIR))}"
|
||||
path = os.path.join(SHOT_DIR, name + ".png")
|
||||
page.screenshot(path=path)
|
||||
print("screenshot:", path)
|
||||
|
||||
|
||||
def cmd_eval(arg):
|
||||
try:
|
||||
print(json.dumps(page.evaluate(arg)))
|
||||
except Exception as e:
|
||||
print("ERROR:", e)
|
||||
|
||||
|
||||
def cmd_console_errors(_arg):
|
||||
print(json.dumps(console_errors))
|
||||
|
||||
|
||||
def cmd_viewport(arg):
|
||||
"""Resize the viewport. No args -> 390x844 (iPhone-ish mobile
|
||||
width); the page itself starts at 1280x900 (desktop) on launch, so
|
||||
`viewport 1280 900` gets back to that. The app's mobile breakpoint
|
||||
is 860px (see theme.css) -- anything under that exercises the
|
||||
off-canvas sidebar/mobile-bar layout."""
|
||||
parts = arg.split()
|
||||
width = int(parts[0]) if len(parts) > 0 else 390
|
||||
height = int(parts[1]) if len(parts) > 1 else 844
|
||||
page.set_viewport_size({"width": width, "height": height})
|
||||
print("viewport:", width, "x", height)
|
||||
|
||||
|
||||
def cmd_is_open(arg):
|
||||
"""App-specific: print whether a <dialog> element is open (true/false)."""
|
||||
print(json.dumps(page.eval_on_selector(arg, "el => el.open")))
|
||||
|
||||
|
||||
def cmd_bootstrap_admin(arg):
|
||||
"""App-specific: complete first-run /setup (username/password args,
|
||||
default admin/testpassword123). Every scratch DB starts with no
|
||||
users, and /setup is the only way in -- it also auto-links the
|
||||
pre-existing frame #1 (created by migrations) to the new admin, so
|
||||
/frames/1/... is reachable right after this."""
|
||||
parts = arg.split()
|
||||
username = parts[0] if len(parts) > 0 else "admin"
|
||||
password = parts[1] if len(parts) > 1 else "testpassword123"
|
||||
page.goto(BASE + "/setup")
|
||||
page.fill("input[name=username]", username)
|
||||
page.fill("input[name=password]", password)
|
||||
page.click("button[type=submit]")
|
||||
page.wait_for_load_state("networkidle")
|
||||
print("bootstrapped admin, now at:", page.url)
|
||||
|
||||
|
||||
def cmd_quit(_arg):
|
||||
browser.close()
|
||||
pw.stop()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
COMMANDS = {
|
||||
"nav": cmd_nav,
|
||||
"wait-for": cmd_wait_for,
|
||||
"click": cmd_click,
|
||||
"fill": cmd_fill,
|
||||
"press": cmd_press,
|
||||
"screenshot": cmd_screenshot,
|
||||
"eval": cmd_eval,
|
||||
"console-errors": cmd_console_errors,
|
||||
"viewport": cmd_viewport,
|
||||
"is-open": cmd_is_open,
|
||||
"bootstrap-admin": cmd_bootstrap_admin,
|
||||
"quit": cmd_quit,
|
||||
}
|
||||
|
||||
print("run-server driver -- commands:", ", ".join(COMMANDS), flush=True)
|
||||
print("driver>", end=" ", flush=True)
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
print("driver>", end=" ", flush=True)
|
||||
continue
|
||||
cmd, _, rest = line.partition(" ")
|
||||
fn = COMMANDS.get(cmd)
|
||||
if fn is None:
|
||||
print("unknown command:", cmd, "-- try one of:", ", ".join(COMMANDS))
|
||||
else:
|
||||
try:
|
||||
fn(rest)
|
||||
except Exception as e:
|
||||
print("ERROR:", e)
|
||||
print("driver>", end=" ", flush=True)
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-time (idempotent) environment bootstrap for running the
|
||||
# espresso_frame FastAPI server and browser-driving its UI, in a
|
||||
# container that ships with NO Python/Node/Docker/browser and NO sudo.
|
||||
# Re-run any time; every step checks whether it already happened.
|
||||
set -euo pipefail
|
||||
cd "$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)/server"
|
||||
|
||||
UV_BIN="$HOME/.local/bin/uv"
|
||||
DEPS_ROOT="/tmp/run-server-chromium-deps"
|
||||
APT_WORK="/tmp/apt-work-run-server"
|
||||
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENV_FILE="$SKILL_DIR/env.sh"
|
||||
|
||||
# 1. uv: a static Rust binary that can fetch its own Python build with
|
||||
# no C compiler needed (this container has none).
|
||||
if [ ! -x "$UV_BIN" ]; then
|
||||
echo "installing uv..."
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
fi
|
||||
|
||||
# 2. Python 3.12 + venv + server deps
|
||||
if [ ! -x .venv/bin/uvicorn ]; then
|
||||
echo "creating venv + installing server deps..."
|
||||
"$UV_BIN" python install 3.12
|
||||
"$UV_BIN" venv --python 3.12 .venv
|
||||
"$UV_BIN" pip install -r requirements.txt
|
||||
fi
|
||||
|
||||
# 3. Playwright (Python) + its Chromium download (~280MB: full chrome +
|
||||
# chrome-headless-shell + ffmpeg)
|
||||
if ! .venv/bin/python -c "import playwright" 2>/dev/null; then
|
||||
echo "installing playwright..."
|
||||
"$UV_BIN" pip install playwright
|
||||
fi
|
||||
if ! ls "$HOME"/.cache/ms-playwright/chromium-*/chrome-linux64/chrome >/dev/null 2>&1; then
|
||||
echo "downloading chromium..."
|
||||
.venv/bin/playwright install chromium
|
||||
fi
|
||||
|
||||
# 4. Chromium's shared libs + fonts. `playwright install-deps` and
|
||||
# `apt-get install` both need root; neither is available. Instead:
|
||||
# download the .deb files directly (apt-get download works read-only
|
||||
# without root once given a user-writable state dir) and extract
|
||||
# (not install) them with dpkg-deb -x, which needs no root either.
|
||||
if [ ! -f "$DEPS_ROOT/usr/lib/x86_64-linux-gnu/libglib-2.0.so.0" ]; then
|
||||
echo "fetching chromium's shared libs + fonts (non-root)..."
|
||||
mkdir -p "$APT_WORK/lists" "$APT_WORK/cache/archives/partial" "$APT_WORK/debs" "$DEPS_ROOT"
|
||||
|
||||
apt-get -o Dir::State::Lists="$APT_WORK/lists" -o Dir::Cache="$APT_WORK/cache" \
|
||||
-o Dir::Etc::SourceParts=/dev/null update
|
||||
|
||||
PKGS="libglib2.0-0t64 libnspr4 libnss3 libatk1.0-0t64 libatk-bridge2.0-0t64
|
||||
libdbus-1-3 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1
|
||||
libxkbcommon0 libasound2t64 libatspi2.0-0t64 libcups2t64 libcairo2
|
||||
libpango-1.0-0 libpangocairo-1.0-0 libx11-6 libxcb1 libxext6
|
||||
fonts-liberation fontconfig libfontconfig1"
|
||||
# ^ first row: what chrome-headless-shell's ldd reported missing.
|
||||
# second row: what the FULL chrome binary additionally needed (we use
|
||||
# full chrome, not headless-shell -- see Gotchas in SKILL.md).
|
||||
|
||||
apt-get -o Dir::State::Lists="$APT_WORK/lists" -o Dir::Cache="$APT_WORK/cache" \
|
||||
-o Dir::Etc::SourceParts=/dev/null install --download-only --reinstall -y \
|
||||
--print-uris $PKGS | grep -oP "^'[^']+'" | tr -d "'" > "$APT_WORK/urls.txt"
|
||||
|
||||
(cd "$APT_WORK/debs" && xargs -n1 -P8 curl -sS -O --max-time 30) < "$APT_WORK/urls.txt"
|
||||
for f in "$APT_WORK"/debs/*.deb; do dpkg-deb -x "$f" "$DEPS_ROOT"; done
|
||||
|
||||
# fonts.conf as shipped points at the real /usr/share/fonts, which is
|
||||
# root-owned and has nothing extracted into it. Point it at our
|
||||
# extracted copy instead, and give it a writable cache dir.
|
||||
mkdir -p /tmp/run-server-fontcache
|
||||
sed -i "s#<dir>/usr/share/fonts</dir>#<dir>$DEPS_ROOT/usr/share/fonts</dir>#" \
|
||||
"$DEPS_ROOT/etc/fonts/fonts.conf"
|
||||
sed -i "s#<cachedir>.*</cachedir>#<cachedir>/tmp/run-server-fontcache</cachedir>#" \
|
||||
"$DEPS_ROOT/etc/fonts/fonts.conf"
|
||||
|
||||
PATH="$DEPS_ROOT/usr/bin:$PATH" \
|
||||
LD_LIBRARY_PATH="$DEPS_ROOT/usr/lib/x86_64-linux-gnu:$DEPS_ROOT/lib/x86_64-linux-gnu" \
|
||||
FONTCONFIG_PATH="$DEPS_ROOT/etc/fonts" \
|
||||
"$DEPS_ROOT/usr/bin/fc-cache" -f
|
||||
fi
|
||||
|
||||
# 5. tmux -- also missing, also no apt/sudo. Same non-root download +
|
||||
# dpkg-deb -x trick, into the same extracted root (so its `usr/bin` is
|
||||
# already on PATH via env.sh).
|
||||
if [ ! -f "$DEPS_ROOT/usr/bin/tmux" ]; then
|
||||
echo "fetching tmux (non-root)..."
|
||||
mkdir -p "$APT_WORK/lists" "$APT_WORK/cache/archives/partial" "$APT_WORK/debs" "$DEPS_ROOT"
|
||||
apt-get -o Dir::State::Lists="$APT_WORK/lists" -o Dir::Cache="$APT_WORK/cache" \
|
||||
-o Dir::Etc::SourceParts=/dev/null install --download-only --reinstall -y \
|
||||
--print-uris tmux | grep -oP "^'[^']+'" | tr -d "'" > "$APT_WORK/tmux_urls.txt"
|
||||
(cd "$APT_WORK/debs" && xargs -n1 -P3 curl -sS -O --max-time 30) < "$APT_WORK/tmux_urls.txt"
|
||||
for f in $(sed -E 's#.*/##' "$APT_WORK/tmux_urls.txt"); do dpkg-deb -x "$APT_WORK/debs/$f" "$DEPS_ROOT"; done
|
||||
fi
|
||||
|
||||
CHROME_BIN="$(ls "$HOME"/.cache/ms-playwright/chromium-*/chrome-linux64/chrome | head -1)"
|
||||
|
||||
cat > "$ENV_FILE" <<EOF
|
||||
# Generated by setup.sh. Source this before running driver.py (it
|
||||
# needs LD_LIBRARY_PATH/FONTCONFIG_PATH set before the Chromium
|
||||
# subprocess launches -- driver.py does not source it for you).
|
||||
# start-server.sh does NOT need this file -- uvicorn has no such deps.
|
||||
export PATH="\$HOME/.local/bin:$DEPS_ROOT/usr/bin:\$PATH"
|
||||
export LD_LIBRARY_PATH="$DEPS_ROOT/usr/lib/x86_64-linux-gnu:$DEPS_ROOT/lib/x86_64-linux-gnu"
|
||||
export FONTCONFIG_PATH="$DEPS_ROOT/etc/fonts"
|
||||
export RUN_SERVER_CHROME_BIN="$CHROME_BIN"
|
||||
EOF
|
||||
|
||||
echo "setup complete -> $ENV_FILE"
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# Background-launch the server against a scratch DB/config -- never the
|
||||
# real deployment's data (see CLAUDE.md). Waits for readiness, prints
|
||||
# the PID and log path. Usage: ./start-server.sh [scratch-dir] [port]
|
||||
set -euo pipefail
|
||||
cd "$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)/server"
|
||||
|
||||
SCRATCH="${1:-/tmp/run-server-scratch}"
|
||||
PORT="${2:-8420}"
|
||||
mkdir -p "$SCRATCH"
|
||||
|
||||
DATABASE_URL="sqlite:///$SCRATCH/test.db" \
|
||||
CONFIG_PATH="$SCRATCH/config.json" \
|
||||
LOG_PATH="$SCRATCH/app.log" \
|
||||
.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port "$PORT" \
|
||||
> "$SCRATCH/server.log" 2>&1 &
|
||||
PID=$!
|
||||
echo "$PID" > "$SCRATCH/server.pid"
|
||||
|
||||
for _ in $(seq 1 30); do
|
||||
curl -sf -o /dev/null "http://127.0.0.1:$PORT/" && break
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
if ! curl -sf -o /dev/null "http://127.0.0.1:$PORT/"; then
|
||||
echo "server did not become ready -- check $SCRATCH/server.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "server PID $PID up on http://127.0.0.1:$PORT (log: $SCRATCH/server.log, db: $SCRATCH/test.db)"
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage: ./stop-server.sh [scratch-dir]
|
||||
SCRATCH="${1:-/tmp/run-server-scratch}"
|
||||
if [ -f "$SCRATCH/server.pid" ]; then
|
||||
kill "$(cat "$SCRATCH/server.pid")" 2>/dev/null || true
|
||||
rm -f "$SCRATCH/server.pid"
|
||||
echo "stopped"
|
||||
else
|
||||
echo "no $SCRATCH/server.pid -- nothing to stop"
|
||||
fi
|
||||
@@ -0,0 +1,76 @@
|
||||
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 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]
|
||||
paths:
|
||||
- "firmware/**"
|
||||
- ".gitea/workflows/firmware-build-check.yml"
|
||||
|
||||
jobs:
|
||||
build-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Same docker create/cp/start pattern as firmware-release-build.yml
|
||||
# (see that file's own comment for why -- the runner's job
|
||||
# workspace lives in a named Docker volume, not a real host path,
|
||||
# so a nested `docker run -v "$PWD:..."` bind-mounts nothing
|
||||
# useful). No release/artifact step here -- this only needs to
|
||||
# prove `idf.py build` still succeeds for each board.
|
||||
- name: Build (devkit -- ESP32-C6-DevKitC-1)
|
||||
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 devkit set-target esp32c6 &&
|
||||
./build_for_board.sh devkit build
|
||||
')
|
||||
docker cp "$PWD/." "$cid:/workspace"
|
||||
docker start -a "$cid"
|
||||
docker rm "$cid"
|
||||
|
||||
- name: Build (xiao -- Seeed XIAO ESP32-C6)
|
||||
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 xiao set-target esp32c6 &&
|
||||
./build_for_board.sh xiao build
|
||||
')
|
||||
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"
|
||||
@@ -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})")
|
||||
|
||||
@@ -8,7 +8,27 @@ on:
|
||||
- ".gitea/workflows/server-docker-build.yml"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: server
|
||||
run: pip install -r requirements-dev.txt
|
||||
|
||||
- name: Run tests
|
||||
working-directory: server
|
||||
run: pytest
|
||||
|
||||
build-and-push:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -32,3 +52,41 @@ jobs:
|
||||
tags: |
|
||||
git.thumeit.com/tfaour/espresso-frame-server:latest
|
||||
git.thumeit.com/tfaour/espresso-frame-server:${{ gitea.sha }}
|
||||
|
||||
deploy:
|
||||
needs: build-and-push
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy over SSH
|
||||
env:
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT || '22' }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh-keyscan -p "$DEPLOY_PORT" "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
ssh -i ~/.ssh/deploy_key -p "$DEPLOY_PORT" -o StrictHostKeyChecking=yes \
|
||||
espressoframe_deployer@"$DEPLOY_HOST" bash -s <<'REMOTE'
|
||||
set -e
|
||||
cd ~/espresso-frame
|
||||
docker compose down --remove-orphans
|
||||
docker compose pull
|
||||
# "down" returning doesn't guarantee the OS/docker-proxy has
|
||||
# actually released port 8420 yet -- an immediate "up -d" right
|
||||
# after (especially with "pull" a no-op because the image was
|
||||
# already cached) can lose that race and fail with "port is
|
||||
# already allocated", even though the exact same "up -d" run a
|
||||
# few seconds later succeeds every time. Retry instead of
|
||||
# guessing at a fixed sleep long enough to always cover it.
|
||||
for i in $(seq 1 10); do
|
||||
if docker compose up -d; then
|
||||
exit 0
|
||||
fi
|
||||
echo "docker compose up -d failed (attempt $i/10) -- retrying in 3s"
|
||||
sleep 3
|
||||
done
|
||||
echo "docker compose up -d did not succeed after 10 attempts"
|
||||
exit 1
|
||||
REMOTE
|
||||
|
||||
+15
@@ -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__/
|
||||
@@ -17,6 +20,13 @@ server/**/__pycache__/
|
||||
server/.venv/
|
||||
server/*.egg-info/
|
||||
server/data/
|
||||
server/.pytest_cache/
|
||||
# render-service/ (whiteboard mode's Node sidecar) -- installed fresh
|
||||
# inside the Docker image, never committed. No package-lock.json exists
|
||||
# yet either (no Node/npm available in this project's dev environment to
|
||||
# generate one -- see render-service/README.md); if one's added later, do
|
||||
# NOT ignore it, lockfiles belong in git.
|
||||
server/render-service/node_modules/
|
||||
# Real deploy config, copied from docker-compose.yml.example -- holds the
|
||||
# Immich API key, must never be committed.
|
||||
server/docker-compose.yml
|
||||
@@ -27,3 +37,8 @@ server/docker-compose.yml
|
||||
.idea/
|
||||
*.swp
|
||||
.DS_Store
|
||||
.claude/*
|
||||
# ...except Claude Code skills (e.g. agent-run instructions for this
|
||||
# app) -- those are project tooling worth sharing, not personal/local
|
||||
# state like settings.local.json or worktrees/.
|
||||
!.claude/skills/
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# espresso_frame
|
||||
|
||||
A DIY e-ink photo frame: an ESP32-C6 (`firmware/`, ESP-IDF) driving a
|
||||
Waveshare 7.3" E Ink Spectra 6 panel (800x480, 6-color, SPI), paired with a
|
||||
self-hosted FastAPI server (`server/`) that pulls from Immich, does all
|
||||
image processing (crop/dither/quantize/pack), and serves a placeable
|
||||
photos/calendar/whiteboard/weather widget system to the device.
|
||||
|
||||
CURRENT TODO
|
||||
-sharing layouts with linked users
|
||||
-a "coming up this week" widget
|
||||
-on reset dismiss the menu.
|
||||
|
||||
Start here, don't re-derive from scratch:
|
||||
- [`docs/architecture.md`](docs/architecture.md) -- how firmware and
|
||||
server talk (sequence diagram, boot flow).
|
||||
- [`docs/widgets.md`](docs/widgets.md) -- the server-side widget system
|
||||
(data model, grid placement, compositor, button-action dispatch).
|
||||
- [`docs/hardware.md`](docs/hardware.md) -- wiring.
|
||||
- [`server/README.md`](server/README.md), [`firmware/README.md`](firmware/README.md)
|
||||
-- per-component setup, config, and a lot of accumulated gotchas
|
||||
(Immich API shape, TLS trust-anchor details, button GPIO wakeup
|
||||
quirks, etc.) -- check these before assuming something is a new bug.
|
||||
|
||||
## Conventions specific to this repo
|
||||
|
||||
- **No `Co-Authored-By: Claude` trailers in commits.** Attribution lives
|
||||
in the root [`README.md`](README.md) instead (see its last line) --
|
||||
the maintainer's explicit preference, not the default.
|
||||
- **Copyleft dependencies need an explicit flag, not a silent decision.**
|
||||
Before adding anything LGPL/GPL/AGPL (or unclear), verify the actual
|
||||
license via `pip show`/package metadata -- including transitive deps,
|
||||
not just the top-level package -- and present the finding and tradeoff
|
||||
in plain text rather than picking an approach unilaterally (hand-rolling
|
||||
an alternative, swapping packages, silently accepting it). This project
|
||||
has knowingly accepted AGPL-3.0-or-later exposure once already
|
||||
(`icalendar-searcher`, a transitive dep of `caldav`) as a deliberate,
|
||||
explicit call -- not a precedent for skipping the check next time.
|
||||
- **Scope new auth/access-control broadly, not just to the literal
|
||||
endpoint named.** When a request changes the trust model (e.g. adding
|
||||
public-internet exposure), apply the new gate to every endpoint serving
|
||||
real data or performing a real action, and call out anything you're
|
||||
tempted to exclude and why. This repo shipped a token gate once that
|
||||
covered `/api/*` but left `/frame/image` -- the actual photo bytes --
|
||||
open; caught immediately in production.
|
||||
- **Commit and push once a task is verified working, without waiting to
|
||||
be asked separately.** Once tests pass (and, for UI changes, the
|
||||
browser check has been done), stage the relevant files, write a normal
|
||||
commit message, and push to the current branch -- the maintainer's
|
||||
standing authorization for the commit/push step itself. This doesn't
|
||||
relax anything else: still run `git status`/review the diff before
|
||||
staging, still never force-push/amend a pushed commit/skip hooks, and
|
||||
still surface anything that looks like it needs a real decision (e.g.
|
||||
a change that would trigger `main`'s deploy workflow, see below)
|
||||
instead of pushing through it silently.
|
||||
|
||||
## Working in this repo
|
||||
|
||||
- **Server tests**: `cd server && pytest` (SQLite, fixtures wipe/reseed
|
||||
between tests -- see `tests/conftest.py`). Migration changes need a
|
||||
matching test in `tests/test_migrations.py`; anything touching
|
||||
`require_frame_view`/`require_frame_control` boundaries needs a
|
||||
same-shape permission test (see `tests/test_permission_boundaries.py`
|
||||
and `tests/test_button_actions.py` for the pattern: owner, linked user,
|
||||
unrelated user, logged out).
|
||||
- **UI changes**: verify in a real browser (Playwright), not just by
|
||||
reading the JS -- this project has hit multiple bugs that only showed up
|
||||
live (mobile viewport CSS collapse, a dialog's status message landing
|
||||
behind its own backdrop, a JSON/form-urlencoded body mismatch). Spin up
|
||||
`uvicorn app.main:app` against a scratch `DATABASE_URL`/`CONFIG_PATH`
|
||||
sqlite file, don't touch the real deployment's data. `.claude/skills/run-server/`
|
||||
(`/run-server`) has a driver for exactly this.
|
||||
- **New/changed UI must work at both desktop and mobile widths --
|
||||
screenshot both, don't assume one implies the other.** The layout
|
||||
genuinely forks at the 860px breakpoint (`theme.css`): the sidebar
|
||||
goes off-canvas behind a hamburger below it. A dialog, header
|
||||
control, or new widget that looks right at a wide viewport can
|
||||
overflow, overlap the mobile bar, or mis-center at phone widths.
|
||||
`run-server`'s driver has a `viewport` command for exactly this
|
||||
(defaults to a phone size; switch to `1280 900` for desktop).
|
||||
- **Deploy**: Gitea Actions at `git.thumeit.com/tfaour/espresso_frame`
|
||||
(`.gitea/workflows/server-docker-build.yml`: `test` -> `build-and-push`
|
||||
-> `deploy` on any push to `main` touching `server/**`; `deploy` SSHes
|
||||
into the host as `espressoframe_deployer` and runs `docker compose pull
|
||||
&& docker compose up -d`). A separate workflow
|
||||
(`firmware-release-build.yml`) builds+publishes firmware binaries as
|
||||
Gitea release assets when `firmware/version.txt` changes. Poll CI status
|
||||
with `curl https://git.thumeit.com/api/v1/repos/tfaour/espresso_frame/actions/tasks`
|
||||
rather than asking the user to check.
|
||||
- **Device-facing paths are frozen.** `/frame/image`, `/frame/advance`,
|
||||
`/frame/back`, `/frame/config`, `/frame/battery`, `/frame/firmware` and
|
||||
their exact JSON key names (`refresh_interval_s`, `firmware_version`,
|
||||
etc.) are baked into deployed firmware -- never rename or restructure
|
||||
these without a firmware-side migration story to match.
|
||||
@@ -1,9 +1,12 @@
|
||||
# ESPresso Frame
|
||||
|
||||
A DIY e-ink photo frame: an ESP32-C6 pulls photos from your
|
||||
[Immich](https://immich.app) library and displays them on a 7.3" full-color
|
||||
A DIY e-ink photo frame: an ESP32 board pulls photos from your
|
||||
[Immich](https://immich.app) library and displays them on a full-color
|
||||
e-paper panel, waking on a timer to refresh and spending the rest of its
|
||||
time in deep sleep.
|
||||
time in deep sleep. The original build is a 7.3" panel on an ESP32-C6;
|
||||
a larger 13.3" panel on Seeed's EE02 (ESP32-S3) is supported
|
||||
server-side, but its firmware driver isn't working yet -- see
|
||||
[`docs/hardware.md`](docs/hardware.md).
|
||||
|
||||
- **No cables to a computer, no SD card shuffling.** Provisioning is a
|
||||
captive portal with a QR code drawn on the panel itself -- scan, join,
|
||||
@@ -12,7 +15,9 @@ time in deep sleep.
|
||||
all the work (pulling from Immich, cropping, dithering, packing into
|
||||
the panel's exact pixel format) and hands the device a stream it can
|
||||
write straight to SPI. The ESP32-C6 has no PSRAM and not much SRAM to
|
||||
spare -- keeping it a dumb display client is what makes that workable.
|
||||
spare -- keeping it a dumb display client is what makes that workable
|
||||
(the same design carries over to the ESP32-S3 board even though it
|
||||
does have PSRAM, for consistency).
|
||||
- **Crops toward faces, not just the center**, using face bounding boxes
|
||||
Immich already computed for its own People feature -- no bundled face
|
||||
detector.
|
||||
@@ -21,26 +26,32 @@ time in deep sleep.
|
||||
|
||||
## Hardware
|
||||
|
||||
- ESP32-C6 dev board (8MB flash)
|
||||
- [Waveshare 7.3" E Ink Spectra 6 (E6)](https://www.waveshare.com/7.3inch-e-paper-hat-e.htm) panel -- 800x480, 6-color, SPI
|
||||
- ESP32-C6 dev board (8MB flash), or Seeed's XIAO ESP32-C6 (production
|
||||
board) -- both drive the panel below.
|
||||
- [Waveshare 7.3" E Ink Spectra 6 (E6)](https://www.waveshare.com/7.3inch-e-paper-hat-e.htm) panel -- 800x480, 6-color, SPI.
|
||||
- Experimental, not yet working: [Waveshare 13.3" E Ink Spectra 6](https://www.waveshare.com/13.3inch-e-paper-hat-plus-e.htm)
|
||||
(1600x1200) on [Seeed's EE02](https://www.seeedstudio.com/XIAO-ePaper-DIY-Kit-EE02-for-13-3-Spectratm-6-E-Ink.html)
|
||||
(ESP32-S3) -- server-side support exists, but the firmware driver's
|
||||
panel init sequence isn't ported from vendor code yet.
|
||||
|
||||
See [`docs/hardware.md`](docs/hardware.md) for wiring and
|
||||
See [`docs/hardware.md`](docs/hardware.md) for wiring,
|
||||
[`docs/architecture.md`](docs/architecture.md) for how the two halves talk
|
||||
to each other.
|
||||
to each other, and [`docs/widgets.md`](docs/widgets.md) for the server's
|
||||
placeable photos/calendar/whiteboard widget system.
|
||||
|
||||
## Getting started
|
||||
|
||||
1. **[`server/`](server/)** -- run the FastAPI server first (Docker
|
||||
Compose, points at your Immich instance). See
|
||||
[`server/README.md`](server/README.md).
|
||||
2. **[`firmware/`](firmware/)** -- build and flash the ESP32-C6, then
|
||||
2. **[`firmware/`](firmware/)** -- build and flash the board, then
|
||||
scan the QR codes it draws on first boot to provision it. See
|
||||
[`firmware/README.md`](firmware/README.md).
|
||||
|
||||
## Repo layout
|
||||
|
||||
```
|
||||
firmware/ ESP-IDF project for the ESP32-C6
|
||||
firmware/ ESP-IDF project (ESP32-C6 devkit/xiao boards, ESP32-S3 ee02)
|
||||
server/ FastAPI server: Immich -> crop/dither/pack -> the frame
|
||||
docs/ Wiring and architecture notes
|
||||
```
|
||||
|
||||
+30
-15
@@ -3,14 +3,15 @@
|
||||
Two independent pieces talk over HTTP or HTTPS (the server itself always
|
||||
speaks plain HTTP; HTTPS means a reverse proxy in front of it, see
|
||||
[`firmware/README.md`](../firmware/README.md#http-vs-https)) on the local
|
||||
network: the ESP32-C6 firmware, and a small FastAPI server that sits
|
||||
between it and Immich.
|
||||
network: the ESP32 firmware (ESP32-C6 for the devkit/xiao boards,
|
||||
ESP32-S3 for ee02 -- see [`docs/hardware.md`](hardware.md)), and a small
|
||||
FastAPI server that sits between it and Immich.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Immich
|
||||
participant Server as ESPresso Frame Server
|
||||
participant Frame as ESP32-C6 Frame
|
||||
participant Frame as ESP32 Frame
|
||||
|
||||
Note over Frame: First boot / never provisioned
|
||||
Frame->>Frame: Generate AP SSID/password, draw QR + config QR on panel
|
||||
@@ -22,18 +23,18 @@ sequenceDiagram
|
||||
Frame->>Frame: Connect to home WiFi
|
||||
alt next-photo button pressed
|
||||
Frame->>Server: POST /frame/advance
|
||||
Server->>Server: Force-advance to next queued photo, reset interval clock
|
||||
Server->>Server: Run every action assigned to NEXT, in order<br/>(may span several widgets -- see docs/widgets.md)
|
||||
else back-photo button pressed
|
||||
Frame->>Server: POST /frame/back
|
||||
Server->>Server: Return to previously-current photo (bounded history),<br/>reset interval clock
|
||||
Server->>Server: Run every action assigned to BACK, in order
|
||||
else normal wake
|
||||
Frame->>Server: GET /frame/image
|
||||
Server->>Server: Advance only if refresh_interval_s has elapsed<br/>since the current photo was set -- otherwise a no-op
|
||||
Server->>Server: Render every widget on the panel into its own region<br/>(each independently idempotent -- a photo widget only<br/>actually advances once its own refresh_interval_s has elapsed)
|
||||
end
|
||||
Server->>Immich: List album assets / download preview / faces
|
||||
Server->>Immich: List album assets / download preview / faces<br/>(once per photo widget on the panel)
|
||||
Immich-->>Server: JPEG + face bounding boxes
|
||||
Server->>Server: Crop (face-aware) + quantize (dither) + pack 4bpp
|
||||
Server-->>Frame: 192,000 raw bytes, streamed
|
||||
Server->>Server: Composite every widget's region onto one canvas,<br/>then enhance/overlay/quantize (dither)/pack 4bpp once
|
||||
Server-->>Frame: packed 4bpp bytes, streamed<br/>(192,000 for the 7.3" panel; sized to whichever<br/>panel this frame's device reports, see Frame.panel_type)
|
||||
Frame->>Frame: Write to panel SPI buffer, compute CRC32
|
||||
alt CRC unchanged since last physical refresh
|
||||
Frame->>Frame: Skip refresh (nothing visually changed)
|
||||
@@ -45,6 +46,17 @@ sequenceDiagram
|
||||
Frame->>Frame: Deep sleep (server-configured interval, or a short<br/>retry interval on any failure)
|
||||
```
|
||||
|
||||
The device-facing endpoints above (`/frame/image`, `/frame/advance`,
|
||||
`/frame/back`, `/frame/config`) are frozen -- baked into deployed firmware
|
||||
-- and unchanged by any of this. What *does* change server-side: a frame's
|
||||
panel isn't a single fixed "mode" anymore, it holds an arbitrary
|
||||
arrangement of independently placed/sized widgets (photos/calendar/
|
||||
whiteboard, including several of the same type), each rendered into its
|
||||
own region and composited together, with NEXT/BACK each mapped to their
|
||||
own ordered list of per-widget actions rather than one fixed meaning. See
|
||||
[`docs/widgets.md`](widgets.md) for the widget system's data model,
|
||||
placement grid, and button-action dispatch.
|
||||
|
||||
## Firmware boot flow
|
||||
|
||||
1. **No stored config** (first boot, or NVS erased): bring up the display,
|
||||
@@ -72,8 +84,9 @@ sequenceDiagram
|
||||
once).
|
||||
- Fetch the frame and write it into the panel's SPI buffer
|
||||
(`epd_write_frame()`), computing a CRC32 as it streams -- never
|
||||
buffering the full ~192KB frame in RAM. The panel driver refuses to
|
||||
write a short/wrong-size response into the buffer at all, so a
|
||||
buffering the full packed frame in RAM (~192KB for the 7.3" panel;
|
||||
proportionally more for the 13.3" panel). The panel driver refuses
|
||||
to write a short/wrong-size response into the buffer at all, so a
|
||||
truncated fetch can't corrupt what's already there.
|
||||
- Compare the new CRC32 against the last one that was actually
|
||||
refreshed onto the panel (persisted in NVS). If it matches -- the
|
||||
@@ -91,9 +104,9 @@ sequenceDiagram
|
||||
- Deep sleep for the server-configured interval on success, or a
|
||||
shorter retry interval on any failure.
|
||||
|
||||
The menu/reset button's soft-reset and factory-reset tiers (held ~3s
|
||||
or ~15s) are handled earlier, before any of this, and never return --
|
||||
see [`firmware/README.md`](../firmware/README.md#managing-the-queue-soft-resetting-and-factory-resetting).
|
||||
The menu/reset button's soft-reset (quick press) and factory-reset
|
||||
(held ~15s) tiers are handled earlier, before any of this, and never
|
||||
return -- see [`firmware/README.md`](../firmware/README.md#managing-the-queue-soft-resetting-and-factory-resetting).
|
||||
|
||||
See [`docs/hardware.md`](hardware.md) for wiring and
|
||||
[`server/README.md`](../server/README.md) for the server side.
|
||||
@@ -106,7 +119,9 @@ git history). Decoding a JPEG, then resizing/dithering/quantizing it to
|
||||
the panel's 6-color palette, would be expensive on-device in both memory
|
||||
and battery. Instead, the server does all of that with Pillow and hands
|
||||
the frame a pre-packed, ready-to-stream buffer -- the device never
|
||||
decodes an image at all.
|
||||
decodes an image at all. The ee02 board's ESP32-S3 does have PSRAM, but
|
||||
the same server-side design applies there too, for consistency and
|
||||
battery reasons rather than because the C6's memory limit forces it.
|
||||
|
||||
## Why face detection isn't run on-device (or even on the server)
|
||||
|
||||
|
||||
+124
-4
@@ -40,10 +40,10 @@ pressed):
|
||||
photo; see
|
||||
[`firmware/README.md`](../firmware/README.md#going-back-to-the-previous-photo).
|
||||
- **Menu / reset (GPIO1)**: one button, three actions by hold duration --
|
||||
a quick press overlays a "scan to manage" QR code on the current photo
|
||||
for 30 seconds; holding ~3s then releasing soft-resets the device
|
||||
(config kept); holding ~15s factory-resets it (clears WiFi/server
|
||||
config, reprovisions); see
|
||||
a quick press soft-resets the device (config kept); holding ~3s then
|
||||
releasing overlays a "scan to manage" QR code on the current photo for
|
||||
30 seconds; holding ~15s factory-resets it (clears WiFi/server config,
|
||||
reprovisions); see
|
||||
[`firmware/README.md`](../firmware/README.md#managing-the-queue-soft-resetting-and-factory-resetting).
|
||||
|
||||
All three pins were picked because they're within GPIO 0-7 -- the only
|
||||
@@ -152,3 +152,123 @@ 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 (panel driver ported, `ee02` builds end-to-end; unverified on real hardware)
|
||||
|
||||
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`), and **the panel driver itself is now real and
|
||||
compiles clean** -- `firmware/components/epd13in3e`'s init/LUT/refresh
|
||||
register sequence is a line-for-line port of Waveshare's own reference
|
||||
drivers for this exact panel+controller, confirmed identically across
|
||||
three independent vendor sources (Waveshare's RaspberryPi/c and ESP32
|
||||
drivers for this panel, plus Waveshare's own ESP-IDF example for their
|
||||
ESP32-S3-ePaper-13.3E6 driver board -- a different carrier than EE02,
|
||||
but the same panel/controller, hence the same command bytes). See that
|
||||
component's own top comment for details, and
|
||||
`server/app/image_pipeline.py`'s `PANEL_WIRE_TRANSPOSE` for a load-bearing
|
||||
correction that came with it: the panel's SPI wire raster is a *native
|
||||
1200x1600 (portrait)* raster, rotated 90 degrees from the panel's
|
||||
1600x1200 landscape mount/marketing size -- getting that backwards
|
||||
doesn't just rotate the image, it shreds it (1600x1200 and 1200x1600
|
||||
don't share a row stride).
|
||||
|
||||
**A full `ee02` build now succeeds** (verified locally with a native,
|
||||
non-Docker ESP-IDF v6.0 install -- see
|
||||
`.claude/skills/build-firmware/SKILL.md`); `firmware/main/{back,next,combo}_button.c`
|
||||
used to call `esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown()`, an
|
||||
ESP32-C6-only deep-sleep GPIO-wakeup API (gated by
|
||||
`SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP`, which ESP32-S3's
|
||||
`soc_caps.h` doesn't define) with no ESP32-S3 fallback path. Each of the
|
||||
three button files now branches on that same capability macro: the
|
||||
ESP32-C6 path (devkit/xiao) is untouched, and a new ESP32-S3 path uses
|
||||
`esp_sleep_enable_ext1_wakeup_io()` (not the non-`_io()`
|
||||
`esp_sleep_enable_ext1_wakeup()`, which resets any previously-registered
|
||||
mask -- the `_io()` variant is additive, confirmed by reading
|
||||
`esp_hw_support/sleep_modes.c`, so the three button files can each keep
|
||||
registering their own GPIO independently, no combined-mask coordination
|
||||
needed) plus `esp_sleep_get_ext1_wakeup_status()` for the wake-cause
|
||||
check. The original C6 EXT1 attempt was rejected on hardware because
|
||||
its pull resistor didn't hold across the RTC_PERIPH power-down (see
|
||||
`firmware/main/next_button.c`'s `next_button_init()` comment) -- tracing
|
||||
the same code path for ESP32-S3 shows `gpio_config()`'s `pull_up_en`
|
||||
(already used by all three button files) delegates to
|
||||
`rtc_gpio_pullup_en()` for RTC-capable pins on every non-original-ESP32
|
||||
target (confirmed in `esp_driver_gpio/gpio.c`: `GPIO_RTCIO_ARE_INDEPENDENT`
|
||||
is 1 for both C6 and S3, meaning the digital and RTC pull registers are
|
||||
independent hardware and `gpio_config()` already sets the RTC one), so
|
||||
the pull-up should already survive the same power-down on ESP32-S3
|
||||
without any extra `rtc_gpio_*` calls. That reasoning is verified against
|
||||
IDF source, **not against real EE02 hardware** -- a clean compile
|
||||
confirms the code builds and links, not that it's actually
|
||||
spurious-wakeup-free on a real board. CI's
|
||||
`firmware-build-check.yml`/`firmware-release-build.yml`
|
||||
`continue-on-error` on this board's step is intentionally still in place
|
||||
until that hardware verification happens.
|
||||
|
||||
Confirmed so far:
|
||||
|
||||
- Panel: [Waveshare 13.3" e-Paper (E) Spectra 6](https://www.waveshare.com/13.3inch-e-paper-hat-plus-e.htm) --
|
||||
1600x1200 mount size, 270.40x202.80mm, same 6-ink Spectra family as
|
||||
the 7.3" panel (and, now vendor-confirmed, the identical 4-bit nibble
|
||||
color codes). Full refresh ~19s. SPI wire raster is 1200x1600 (see
|
||||
above).
|
||||
- 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; Waveshare's
|
||||
own ESP32-S3-ePaper-13.3E6 example uses different GPIO numbers, but
|
||||
that's for Waveshare's own driver board, a different carrier than
|
||||
EE02, so it doesn't apply here). 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 -- now confirmed by the real driver code
|
||||
too (master = left half, slave = right half of each row).
|
||||
|
||||
| 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) still isn't confirmed. The
|
||||
firmware's button Kconfig options (`FRAME_NEXT_BUTTON_GPIO` etc.,
|
||||
`firmware/main/Kconfig.projbuild`) now range to GPIO -1 to 21 under
|
||||
`IDF_TARGET_ESP32S3` (the ESP32-S3's own ext1-wakeup-capable RTC-IO
|
||||
range) instead of the ESP32-C6-shaped -1 to 7, so GPIO 2/3/5 fit
|
||||
regardless -- but `firmware/sdkconfig.ee02` still deliberately doesn't
|
||||
override the defaults inherited from the C6 boards (GPIO 2/0/1) until
|
||||
the role mapping above is confirmed.
|
||||
|
||||
SPI clock is reportedly reliable only up to 2MHz on this
|
||||
panel/board per the community ESPHome integration (vs. epd7in3e's 4MHz
|
||||
default) -- see `firmware/sdkconfig.ee02`. Waveshare's own
|
||||
ESP32-S3-ePaper-13.3E6 example defaults to 10MHz, but that's a
|
||||
different carrier board, so it's a data point to try once real EE02
|
||||
hardware exists, not a reason to bump the current conservative default
|
||||
blind.
|
||||
|
||||
Remaining unknowns before trusting this on real hardware: whether the
|
||||
ESP32-S3 button-wakeup path above actually avoids a spurious-instant-wakeup
|
||||
on a real board (not just compiles), the button-to-role mapping, the
|
||||
wiring table (community-sourced, not official), and
|
||||
`PANEL_WIRE_TRANSPOSE`'s rotation *direction* (`ROTATE_90` vs
|
||||
`ROTATE_270` -- a physical-assembly fact no vendor driver encodes, see
|
||||
that dict's own comment in `image_pipeline.py`).
|
||||
|
||||
+584
@@ -0,0 +1,584 @@
|
||||
# Widget system
|
||||
|
||||
A frame's panel isn't one fixed "mode" anymore -- it holds N independently
|
||||
placed/sized widgets (photos/calendar/whiteboard/tasks/static image/text/
|
||||
weather/battery), like arranging icons on an Android home screen. A frame
|
||||
can hold several widgets of the same type (e.g. two photo widgets pointed
|
||||
at different Immich albums side by side).
|
||||
This replaced an earlier design where `Frame.mode` picked exactly one
|
||||
full-panel renderer; that column and the other per-mode `Frame` columns
|
||||
it left behind (`album_id`, `calendar_*`, `whiteboard_*`, etc.) were
|
||||
dropped in migration 41, once every phase of the rollout had shipped
|
||||
(see "Known gaps" below for what's still open).
|
||||
|
||||
The device-facing contract is unchanged by any of this: `GET /frame/image`,
|
||||
`POST /frame/advance`, `POST /frame/back` are the same frozen paths
|
||||
firmware has always called (see `docs/architecture.md`) -- what changed is
|
||||
entirely server-side, in how those endpoints decide what to render and what
|
||||
a button press does.
|
||||
|
||||
## Data model
|
||||
|
||||
- `Widget` (`server/app/models.py`): `id`, `frame_id`, `widget_type`
|
||||
(`"photos"` | `"calendar"` | `"whiteboard"` | `"tasks"` | `"static"` |
|
||||
`"text"` | `"weather"` | `"battery"`), `x`/`y`/`w`/`h`
|
||||
(grid cells), `sort_order`. Widgets never overlap (enforced server-side in
|
||||
`routers/api_widgets.py`, re-validated regardless of what the client
|
||||
already checked) -- that's what keeps compositing simple: no z-order,
|
||||
no blending, just N independent regions pasted onto one shared canvas.
|
||||
Also carries an optional per-widget border (`border_style` -- `"none"`
|
||||
| `"solid"` | `"dashed"` | `"dotted"` | `"fancy"`, `border_thickness`,
|
||||
`border_color_index`, an index into the frame's palette so a border
|
||||
always renders as one of the panel's exact 6 ink colors) directly on
|
||||
`Widget` itself rather than a per-type config table, since every
|
||||
widget type can have one regardless of `widget_type`. Drawn by
|
||||
`image_pipeline.draw_widget_border` onto each widget's own region in
|
||||
`routers/device.py`'s `_render_widgets`, before that region is pasted
|
||||
onto the shared canvas -- one central integration point instead of
|
||||
every `app/widgets/*.py` module needing to know about it. Set via the
|
||||
gear-icon dialog's shared "Border" card (`_widget_border_fields.html`,
|
||||
included by every `_widget_dialog_*.html` template) and
|
||||
`POST .../widgets/{id}/border`, its own endpoint (not folded into
|
||||
`api_widget_config_save`) since that endpoint's per-type dispatch is
|
||||
keyed on a config row via `widget_locked`, and border fields live on
|
||||
`Widget` itself, not any per-type config table.
|
||||
Also carries `font_scale` (one of `panel_style.FONT_SCALE_CHOICES` --
|
||||
`1.0`/`1.25`/`1.5`, labeled Normal/Large/X-Large), a per-widget
|
||||
legibility control: calendar and tasks widgets pack in the most body
|
||||
text at the smallest default sizes, so their gear-icon dialogs get a
|
||||
"Text size" card (`_widget_font_scale_fields.html`) the other types
|
||||
don't. Same Widget-level-property-not-config-field reasoning as
|
||||
border, and its own `POST .../widgets/{id}/font-scale` endpoint for
|
||||
the same reason. `panel_style.scaled_size(value, font_scale)` is the
|
||||
one shared multiply-and-round point every classic (`calendar_render.py`)
|
||||
and modern (`html_render.py`/`calendar_html_render.py`) size calc
|
||||
routes through immediately after its own tier lookup/floor, so row
|
||||
heights and per-view row caps (already derived from the font size, not
|
||||
a fixed constant) automatically re-fit around the bigger text instead
|
||||
of overflowing their box.
|
||||
- Per-type 1:1 extension tables -- `PhotoWidgetConfig`,
|
||||
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`,
|
||||
`StaticWidgetConfig`, `TextWidgetConfig`, `WeatherWidgetConfig`,
|
||||
`BatteryWidgetConfig`, each keyed by `widget_id` with
|
||||
`ondelete="CASCADE"` -- rather than one wide table with every type's
|
||||
mostly-irrelevant columns. `TextWidgetConfig.content` is parsed rich
|
||||
text (paragraphs of styled runs), never raw HTML -- see
|
||||
`server/app/text_content.py`'s module docstring for why that parse
|
||||
step is the widget's actual stored-XSS sanitization boundary.
|
||||
`PhotoWidgetConfig`
|
||||
mirrors `app/photo_queue.py`'s attribute names exactly, so that module's
|
||||
advance/back/queue logic ports across widget instances unchanged.
|
||||
`PhotoWidgetConfig.locked` (migration 27) freezes `current_asset_id`
|
||||
against both the timer-elapsed auto-advance
|
||||
(`photo_queue.get_current`) and the advance/back button actions
|
||||
(`app/widgets/photos.py`'s `ACTIONS`) until unlocked -- toggled via a
|
||||
"Lock this photo" button in the widget's own dialog
|
||||
(`POST .../widgets/{id}/lock`), shown as a lock badge on the widget's
|
||||
box on the Layout tab canvas.
|
||||
`TaskWidgetConfig` used to be a handful of `tasks_*` columns bolted onto
|
||||
`CalendarWidgetConfig` (a week-view-only, single-list task list); split
|
||||
into its own widget type (migration 17) so a task list can be placed
|
||||
and sized independent of any calendar's view/footprint, then (migration
|
||||
18) given the same multi-source shape a calendar widget already has.
|
||||
`WeatherWidgetConfig` similarly lifts `CalendarWidgetConfig`'s embedded
|
||||
weather strip (still present and unchanged, `weather_*` columns) out
|
||||
into its own placeable widget type (migration 24) -- see "Weather
|
||||
widget" below. `BatteryWidgetConfig` (migration 25) is the odd one out
|
||||
-- its actual content (`Frame.battery_percent`/`battery_as_of`) isn't
|
||||
in this table at all, already existing frame-level state set by
|
||||
`routers/device.py`'s `frame_battery` regardless of whether a battery
|
||||
widget is even placed; the config row only holds a display-mode
|
||||
setting (`"compact"` | `"detailed"`).
|
||||
- `FrameCalendar`/`FrameTaskList` are keyed by `widget_id` (not
|
||||
`frame_id`) since a frame can now have more than one independent
|
||||
calendar/tasks widget, each with its own included set. Identical
|
||||
shape and permission model (owner-added, anyone-linked-can-mute, see
|
||||
"Per-widget config UI" below) -- `FrameTaskList` just has no `"ics"`
|
||||
calendar_key variant, since a plain ICS subscription has no VTODO
|
||||
(task) collection.
|
||||
- `FrameButtonAction` (`id`, `frame_id`, `button` [`"next"`|`"back"`],
|
||||
`widget_id`, `action`, `sort_order`) -- see "Button actions" below.
|
||||
|
||||
## Placement: a grid, not freeform pixels
|
||||
|
||||
`app/grid.py` is pure grid math, no I/O. The grid is `GRID_LONG=8` x
|
||||
`GRID_SHORT=5` cells, defined relative to the panel's long/short axis
|
||||
(not "landscape" specifically) so it stays valid across
|
||||
`image_pipeline.logical_render_size(orientation)`'s genuine width/height
|
||||
swap for portrait -- landscape orientations are 8 cols x 5 rows, portrait
|
||||
are 5 cols x 8 rows, same cell size either way. **Changing a frame's
|
||||
orientation invalidates its existing layout** (an 8x5 arrangement isn't
|
||||
valid on a 5x8 grid) -- the server resets to one full-panel widget on an
|
||||
orientation change rather than trying to remap coordinates.
|
||||
|
||||
Each widget type has a minimum grid footprint (`grid.MIN_FOOTPRINT`):
|
||||
photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of
|
||||
size-tier scaling), whiteboard 2x2, tasks 2x2, static image 1x1, text 2x1,
|
||||
weather 2x2 (its hourly/daily strips need the room; current/multi_city
|
||||
modes would tolerate smaller, but every mode shares one footprint value),
|
||||
battery 1x1 (just an icon + a percent, legible even at a single cell,
|
||||
like photos/static -- though see `MIN_FOOTPRINT`'s own comment in
|
||||
`grid.py` on a mobile-width gear-icon click-target gap at that size,
|
||||
already pre-existing for photos/static too). Enforced both client-side
|
||||
(UX, in the Layout tab's drag/resize canvas -- `static/frame_layout.js`)
|
||||
and server-side (`routers/api_widgets.py`) -- the client is never trusted
|
||||
alone.
|
||||
|
||||
## Rendering: one shared compositor
|
||||
|
||||
`app/widgets/` is the render/action registry -- one module per
|
||||
`widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`, `tasks.py`,
|
||||
`static_image.py`, `text.py`, `weather.py`, `battery.py`), each exposing:
|
||||
|
||||
- `render(db, frame, widget, target_w, target_h, is_normal_wake) -> Image`:
|
||||
an unquantized RGB image exactly `target_w x target_h`, the widget's
|
||||
content composed into its own region. Never raises for a foreseeable
|
||||
failure (an Immich hiccup, an unconfigured widget) -- falls back to a
|
||||
small placeholder within its own region instead, so one widget having a
|
||||
bad moment doesn't blank the whole panel.
|
||||
- `ACTIONS: dict[str, Callable]` -- named button actions this type
|
||||
supports (`"advance"`/`"back"` for photos and calendar, `"check_now"`
|
||||
for whiteboard and weather -- both throttled external fetches with a
|
||||
forced-refetch action). Empty for tasks, static image, text, and
|
||||
battery -- nothing to advance/back/force for a passive checklist, a
|
||||
fixed uploaded image, a fixed block of authored text, or a number the
|
||||
device itself pushes on every wake.
|
||||
- `ACTION_LABELS: dict[str, str]` -- human labels for the button-
|
||||
assignment UI.
|
||||
|
||||
`routers/device.py`'s `_render_widgets` loads every `Widget` row for the
|
||||
frame, maps each one's grid rect to pixels (`grid.cell_to_pixels`), calls
|
||||
its module's `render()`, and hands the whole list of `(rect, image)`
|
||||
regions to `image_pipeline.render_panel` -- which pastes every region onto
|
||||
one shared canvas, then runs enhance/manage-overlay/quantize/dither/pack
|
||||
**once** over the composited result. Quantizing the whole canvas together
|
||||
(not each region separately before pasting) is what keeps the 6-color
|
||||
e-ink dithering pattern consistent across a widget boundary instead of a
|
||||
visible seam at the edge.
|
||||
|
||||
Calendar widgets pick from discrete size tiers (`calendar_render.py`'s
|
||||
`_SIZE_TIERS`) for font size/margins/row heights based on their actual
|
||||
grid footprint, rather than continuously scaling constants tuned for a
|
||||
full ~800x480 canvas -- falls back to agenda view if a widget is too small
|
||||
for month view to stay legible. These tiers are pixel-size constants
|
||||
tuned against the 7.3" panel specifically; they aren't re-tuned or
|
||||
verified yet for the 13.3" panel's larger native resolution (see
|
||||
`docs/hardware.md`'s EE02 section) -- a widget's *grid footprint* (cell
|
||||
count) works the same on either panel, but its rendered legibility at
|
||||
that footprint's actual pixel size hasn't been checked on the bigger
|
||||
panel.
|
||||
|
||||
### "Modern" render style (experimental)
|
||||
|
||||
Every widget type except photos has a `render_style` column (`"classic"`
|
||||
default | `"modern"`) that swaps its hand-drawn PIL primitives for an
|
||||
HTML/CSS render: a Jinja2 template (`app/templates/widget_html/`) drawn
|
||||
through a persistent headless-Chromium browser (`app/html_render.py`,
|
||||
Playwright) instead of `ImageDraw` -- gradients, shadows, and soft icon
|
||||
shading PIL can't easily do. Calendar's own modern-style builders (all
|
||||
four view modes) live in `app/calendar_html_render.py` rather than
|
||||
`html_render.py` itself, mirroring `calendar_render.py`'s own separation
|
||||
from the simpler widget types.
|
||||
|
||||
Every modern-style builder runs its own `ordered_dither` (Bayer/ordered,
|
||||
not Floyd-Steinberg) before returning, committing the widget to exact
|
||||
palette colors *before* compositing -- safe to mix with photo/other
|
||||
classic-rendered widgets on the same frame without a Floyd-Steinberg
|
||||
seam at the boundary, because ordered dithering has no cross-pixel error
|
||||
term the way Floyd-Steinberg's diffusion does (see `html_render.py`'s
|
||||
module docstring). No `Frame`-level dithering setting was needed to make
|
||||
this work.
|
||||
|
||||
Not offered for the **photos** widget -- a real photograph isn't a
|
||||
synthesized dashboard card, and photos has a different concern instead:
|
||||
its own independent palette/dithering strength (`Frame.photo_palette_rgb`
|
||||
/ `photo_dither_strength`, a second "Photos configuration" card in
|
||||
Advanced Configuration, separate from the main `palette_rgb`/
|
||||
`dither_strength` every other widget uses). `widgets/photos.py`'s
|
||||
`render()` quantizes itself against these before returning, so a frame
|
||||
can tune the rest of its widgets' look (e.g. a calibrated palette for
|
||||
modern-style dashboard widgets) independently of what actually looks
|
||||
best for real photographs, with no `render_panel` changes needed --
|
||||
see that module's own docstring for the one small, accepted edge case
|
||||
(a border on a photos widget whose palette genuinely diverges from the
|
||||
frame's main one).
|
||||
|
||||
Playwright/Chromium is a real, heavyweight runtime dependency imported
|
||||
lazily only when a widget actually uses modern style. Its browser binary
|
||||
is fetched by `start.sh` at container startup rather than baked into the
|
||||
image (see `server/Dockerfile`'s own comment) -- a single ~181MB
|
||||
`chrome-headless-shell` binary can't be split across Docker layers the
|
||||
way this project's pip/npm installs were, and confirmed-failed to push
|
||||
to the registry as a build-time layer; cached on the `/data` volume
|
||||
(`PLAYWRIGHT_BROWSERS_PATH`) so only the very first boot on a fresh
|
||||
volume actually downloads it. Still real-panel-unverified -- treat every
|
||||
"modern" style as experimental regardless of deploy status.
|
||||
|
||||
Per-widget-type notes:
|
||||
|
||||
- **weather**: `current`/`daily` modes only -- `hourly`/`multi_city`
|
||||
always render classic regardless of this setting (see the Weather
|
||||
widget section below).
|
||||
- **calendar**: all four view modes (agenda/today_tomorrow/week/month)
|
||||
have a modern builder -- the only widget type with full modern-style
|
||||
coverage from the start, rather than a partial rollout like weather's.
|
||||
Month view's "falls back to agenda below a size threshold" behavior
|
||||
(`_month_view_fits`) is honored identically in both styles.
|
||||
- **battery/text/tasks**: full coverage (both battery modes; text reuses
|
||||
its own `_fit()` shrink-to-fit sizing logic, only the drawing differs).
|
||||
- **static image/whiteboard**: modern style is the *first* visual chrome
|
||||
either widget type has ever had (classic draws the image with zero
|
||||
frame/card at all) -- a rounded-corner, shadowed card
|
||||
(`framed_image.html.jinja`, shared between the two) wrapping the
|
||||
already-composed image. Left alone by the "bold minimal" pass below --
|
||||
it never had the reskinned-classic problem the other widgets did.
|
||||
|
||||
### "Bold minimal": a real redesign, not just a reskin
|
||||
|
||||
The initial modern-style rollout (above) mostly translated each widget's
|
||||
*existing* classic layout into HTML/CSS -- same gradient header banner,
|
||||
same rounded-shadowed white card, prettier chrome around an unchanged
|
||||
composition. A second pass reworked weather (`current`/`daily`),
|
||||
calendar (all four views), tasks, and battery into an actual different
|
||||
visual language, picked from several divergent directions rendered
|
||||
through the real pipeline and reviewed with the maintainer (not chosen
|
||||
unilaterally -- see the "Reverted e-ink quantization attempt"-style
|
||||
caution about visual changes needing more than one look). Text and
|
||||
static/whiteboard were deliberately left as they were (see their notes
|
||||
just above) -- text already had zero chrome and its styling is
|
||||
user-authored content, not this system's to redesign; the framed-image
|
||||
card was already minimal.
|
||||
|
||||
What changed, as a consistent language across every redesigned widget:
|
||||
|
||||
- **No card.** No rounded-corner white box, no drop shadow, no outer
|
||||
border -- content sits directly on the shared white canvas. `theme
|
||||
["radius"]`/`theme["shadow"]` are now unused by every redesigned
|
||||
widget's builder (still resolved, for signature uniformity with
|
||||
`resolve_theme`, but nothing reads them) -- a theme's radius/shadow
|
||||
fields now only affect the *un*-redesigned modern widgets (static
|
||||
image/whiteboard's `framed_image.html.jinja`).
|
||||
- **A slim accent rule instead of a gradient banner.** Every widget that
|
||||
used to have a colored header bar with white text on it (weather's
|
||||
`build_daily`, tasks, calendar's four views) now has a thin (~4-8px)
|
||||
accent-colored rounded rule, with the header text as plain ink below
|
||||
it instead of white text on top of it -- only that thin rule dithers
|
||||
at the theme's richer `accent_amplitude` via `ordered_dither_regions`
|
||||
now, not the header text sitting on it, which reads as a legibility
|
||||
improvement, not just a visual one (see "Rich accent hues" below).
|
||||
- **A dominant hero value, not a centered icon+number of equal weight.**
|
||||
Weather's `build_current` and battery's icon+percent used to be drawn
|
||||
at roughly the same size, centered as a unit; both now put the numeric
|
||||
value (temperature / battery percent) at a clearly dominant size, with
|
||||
the icon small and secondary above it -- closer to a phone home-screen
|
||||
widget than a dashboard tile.
|
||||
- **Padding/type sizes as a proportion of widget size, clamped to a
|
||||
floor/ceiling, not a fixed pixel value.** So a 1-2 grid-cell widget
|
||||
doesn't get comically large padding relative to its content, and a
|
||||
near-full-panel widget doesn't get comically small padding either --
|
||||
see `html_render._clamp` and every redesigned `build_*`'s own
|
||||
`pad`/size calculations (`base = min(target_w, target_h)`, then a
|
||||
fraction of `base` clamped to tuned floor/ceiling values).
|
||||
|
||||
**A hairline color this palette can't actually render.** Auditing the
|
||||
month view's grid during this pass turned up a real, pre-existing bug
|
||||
carried forward unnoticed since the very first modern-style rollout:
|
||||
`.day-cell`/`.day-section`/`.col` divider borders used a pale gray
|
||||
(`#e2e6ec`) -- but `DEFAULT_PALETTE_RGB` has no gray in it at all (black/
|
||||
white/yellow/red/blue/green only), so a color that close to white always
|
||||
nearest-matches to pure white regardless of Bayer bias, at any amplitude
|
||||
-- confirmed by sampling actual rendered pixels, not just eyeballing a
|
||||
screenshot. The month grid's week-row dividers now use real solid black
|
||||
(`RULE`-equivalent, matching how the *classic* PIL renderer always drew
|
||||
them -- see `calendar_render.RULE`); the day-section/week-column dividers
|
||||
were simply dropped instead, since the accent rule + spacing at the
|
||||
start of the next section/column already read as a clear boundary
|
||||
without a line at all once you could actually render one.
|
||||
|
||||
### Themes for modern-style widgets
|
||||
|
||||
`Frame.theme` (String, default `"classic"`, one Advanced Configuration
|
||||
`<select>`) picks a curated visual preset for every modern-style widget
|
||||
on that frame -- font family, corner radius, drop shadow, and an accent
|
||||
hue for widgets with a header/accent region. Presets live in
|
||||
`app/theme_tokens.py`'s `THEMES` dict; `resolve_theme(theme_name,
|
||||
widget_kind, palette_rgb)` turns one into concrete, ready-to-render
|
||||
values (`accent_hex`/`accent_hex_dark`, resolved `font_regular`/
|
||||
`font_bold` file paths, `radius`, `shadow`, `accent_amplitude`). Inspired
|
||||
by [Tesserae](https://github.com/dmellok/tesserae)'s (AGPL-3.0) own
|
||||
three-layer CSS custom-property theme system -- this is an original
|
||||
reimplementation of that *architecture*, not a copy of its token file
|
||||
(see this repo's `CLAUDE.md` on copyleft dependencies).
|
||||
|
||||
**What a theme actually changes, in practice**: the accent color (now a
|
||||
slim rule rather than a full header band -- see "Bold minimal" above) is
|
||||
still the most visible change on widgets that have one, but `font_family`
|
||||
applies to *every* text element in the widget, not just the header title
|
||||
-- day labels, temperatures, task rows, event times, day numbers all
|
||||
switch fonts too (e.g. "Moss" is serif, "Ochre" a slab serif), often
|
||||
more noticeable than the accent color on text-heavy widgets. `radius`/
|
||||
`shadow` only affect static image/whiteboard's card now (every other
|
||||
modern-style widget dropped its card in the "bold minimal" pass); text
|
||||
never used them (no card from the start) and weather/battery/tasks/
|
||||
calendar no longer have a card for them to apply to either.
|
||||
|
||||
**A theme is purely stylistic, never functional color-coding.** Battery's
|
||||
charge-level red/yellow/green, calendar/tasks' per-owner event color
|
||||
chips, and text's user-authored inline run colors are status/identity
|
||||
signals, not style choices -- no theme may recolor them, and every
|
||||
`build_*`/`resolve_theme` call site that touches those stays on its own
|
||||
existing logic untouched. Text's own per-widget `font_family` setting
|
||||
(a user's explicit content-level choice, same carve-out reasoning) is
|
||||
similarly never overridden by a theme -- `build_text` accepts a
|
||||
`theme_name` param for signature uniformity with every other modern-
|
||||
style builder but deliberately ignores it.
|
||||
|
||||
**Rich accent hues, not just the 6 exact panel inks.** A theme's
|
||||
`accent_hex` can be any arbitrary color (e.g. terracotta, moss, slate) --
|
||||
`html_render.ordered_dither_regions(rendered, palette_rgb,
|
||||
base_amplitude, accent_regions=[(rect, amplitude), ...])` dithers the
|
||||
whole widget at the existing safe default (`ordered_dither`'s tuned 48,
|
||||
unchanged, still icon/text-legible) and then *separately* re-dithers
|
||||
just the accent rectangle (a header bar's already-computed pixel rect)
|
||||
at a theme's higher `accent_amplitude` (~130) and pastes it back. Safe
|
||||
to do per-region for the same reason `ordered_dither` itself is safe
|
||||
per-widget: ordered (Bayer) dithering has no cross-pixel error term, so
|
||||
a region's result depends only on its own pixels. A single higher
|
||||
amplitude applied to the *whole* widget instead was tried and rejected --
|
||||
it washes out pale content (a weather icon's white cloud body nearly
|
||||
vanished in testing); confining the higher amplitude to just the accent
|
||||
rect avoids that while still letting the rect approximate a rich hue via
|
||||
denser stippling instead of flatly snapping to one nearest ink (what
|
||||
happens to a rich hue at the base amplitude).
|
||||
|
||||
**"classic" is a deliberately no-visual-change default.** Its
|
||||
`accent_hex` is `None`, meaning "keep this widget kind's own pre-theme
|
||||
look exactly": weather's header was always a fixed blue gradient (now
|
||||
`theme_tokens._CLASSIC_WEATHER_GRADIENT`, byte-identical to the old
|
||||
module-level `ACCENT_START`/`ACCENT_END` constants this system
|
||||
replaced); tasks/calendar's header was always a flat single ink resolved
|
||||
through `panel_style.THEME` (still is, just via `resolve_theme` now).
|
||||
Widget kinds with no ink of their own (battery/text/static/whiteboard)
|
||||
fall back to black, though none of their templates currently have an
|
||||
accent-colored surface for it to visibly affect.
|
||||
|
||||
Which widgets get the richer accent-region treatment: weather's
|
||||
`build_daily` (the slim rule, when `city_label` is set), tasks, and
|
||||
calendar's four view builders -- each computes its own small accent-rule
|
||||
pixel rect (a fixed-height band, not the old full header_h) and passes
|
||||
just that to `ordered_dither_regions`. Weather's `build_current` and
|
||||
battery have no accent surface at all (no header of any kind -- see
|
||||
"Bold minimal" above) and static/whiteboard's shared `build_framed_image`
|
||||
is unchanged from the original rollout; all three call plain
|
||||
`ordered_dither` with no accent region.
|
||||
|
||||
## Button actions
|
||||
|
||||
Each physical button (NEXT/BACK) runs the `(widget, action)` binding of
|
||||
every widget on the frame that has one -- **at most one binding per
|
||||
widget per button** (a widget can't be bound to two different actions on
|
||||
the same button). On a press, `routers/device.py`'s `_run_button_actions`
|
||||
runs every widget's assigned action for that button (each in its own
|
||||
`widget_locked` span -- never nested, since the underlying per-frame lock
|
||||
isn't reentrant), catching and logging any single action's failure
|
||||
without blocking the rest, then re-renders and returns the whole composed
|
||||
panel once at the end regardless of which actions succeeded. Which
|
||||
widget's action runs first never matters -- each only touches its own
|
||||
state, and the shared re-render happens once, after all of them finish.
|
||||
|
||||
The UI for this lives in each widget's own gear-icon config dialog (the
|
||||
"Button actions" card, `templates/_widget_button_fields.html` +
|
||||
`static/widget_dialog_button_actions.js`, `POST
|
||||
/api/frames/{id}/widgets/{widget_id}/button-actions`) -- not a frame-level
|
||||
tab, since assigning a widget's next/back behavior is naturally part of
|
||||
configuring that widget. The card only renders for widget types with a
|
||||
non-empty `ACTIONS` (photos, calendar, whiteboard, weather); tasks/
|
||||
static/text/battery have nothing to bind so the card is omitted for
|
||||
them. An empty selection ("(none)") clears that button's binding for the
|
||||
widget.
|
||||
|
||||
A newly-created widget (including the one auto-migrated from a frame's
|
||||
old `mode` on upgrade) gets a sensible default binding reproducing its
|
||||
old button behavior -- see `widgets.default_button_actions` (called from
|
||||
both `migration.py`'s backfill and `api_widgets.py`'s
|
||||
`api_widget_create`), so a widget is never left with nothing bound until
|
||||
someone deliberately reassigns it.
|
||||
|
||||
### Hold-for-global-action
|
||||
|
||||
Holding NEXT or BACK past a configurable duration (`Frame.hold_duration_ms`,
|
||||
minimum 3000ms, set on the Configuration tab) triggers a **global**
|
||||
action instead of the per-widget one -- not scoped to any widget, e.g.
|
||||
cycling through the user's saved layouts. See `app/global_actions.py`'s
|
||||
`GLOBAL_ACTIONS`/`GLOBAL_ACTION_LABELS` registry and
|
||||
`routers/device.py`'s `/frame/global-next`/`/frame/global-back` (the
|
||||
device calls these instead of `/frame/advance`/`/frame/back` once it
|
||||
detects a long press -- see `firmware/main/next_button.c`/`back_button.c`).
|
||||
`Frame.next_hold_action`/`back_hold_action` pick which registry entry (if
|
||||
any) each button's hold triggers; unset is a silent no-op, same
|
||||
convention as an unbound short-press button.
|
||||
|
||||
## Per-widget config UI
|
||||
|
||||
Each widget has a gear-icon button on the Layout canvas that opens a
|
||||
`<dialog>` with that widget's own settings (album, calendar/task-list
|
||||
inclusion, whiteboard source, etc.) -- not a per-frame tab, since a
|
||||
frame can now have several widgets of the same type with independent
|
||||
settings. The
|
||||
dialog HTML is injected server-rendered (`routers/frame_pages.py`'s
|
||||
`widget_dialog`, dispatching on `widget.widget_type`); its JS is a
|
||||
top-level, always-loaded file (`static/widget_dialog_*.js`) exposing
|
||||
`init<Type>Dialog()`/`close<Type>Dialog()`, since dynamically-injected
|
||||
HTML can't carry executable `<script>` tags. While a dialog is open,
|
||||
`window.FRAME_API` is repointed at that widget's own API base
|
||||
(`/api/frames/{id}/widgets/{widget_id}`) and restored on close;
|
||||
`window.FRAME_BASE_API` stays pointed at the frame-level base throughout
|
||||
for the always-present header/status-bar JS.
|
||||
|
||||
## Saved layouts
|
||||
|
||||
A user can snapshot a frame's whole widget arrangement -- every widget's
|
||||
type/placement/settings, calendar/task sources, and button-action
|
||||
bindings -- under a name (`SavedLayout` + `SavedLayoutWidget` +
|
||||
`SavedLayoutSource` + `SavedLayoutButtonAction`, `server/app/models.py`),
|
||||
then switch back to it later, or apply it to a *different* frame. Saved
|
||||
layouts are owned by the **user**, not any one frame -- the same set
|
||||
shows up (with a per-frame `compatible` flag) on every frame that user
|
||||
controls whose grid matches (`grid.grid_dims(orientation)`'s cols/rows,
|
||||
landscape-class 8x5 vs. portrait-class 5x8), not just the frame it was
|
||||
captured from.
|
||||
|
||||
Saving only captures an authored *setting*, never runtime/cache state --
|
||||
a photo widget's current queue position, a calendar's fetch cache, a
|
||||
whiteboard's rendered-image cache, etc. are deliberately left out (see
|
||||
`routers/api_layouts.py`'s `LAYOUT_CONFIG_FIELDS` allowlist per
|
||||
`widget_type`), so applying a layout feels like a fresh widget of that
|
||||
type with its settings pre-filled, not a resurrection of stale state
|
||||
from whenever it was saved. A static-image widget's uploaded bytes are
|
||||
the one exception carried through verbatim (`SavedLayoutWidget.image`).
|
||||
Saving again under a name the user already has overwrites that layout's
|
||||
snapshot in place rather than erroring or creating a duplicate --
|
||||
`SavedLayout`'s own docstring.
|
||||
|
||||
Applying a layout to a frame (`api_layout_apply`, `require_frame_control`)
|
||||
deletes every widget currently on that frame and recreates the saved
|
||||
arrangement from scratch, remapping calendar/task sources and button
|
||||
bindings onto the newly-created widget ids -- same "act unconditionally
|
||||
on the server, confirm on the client" posture as the Layout tab's own
|
||||
"Clear all". A source whose owning user account no longer exists is
|
||||
silently dropped rather than left dangling (config is JSON, not
|
||||
FK-checked, so nothing else would catch that).
|
||||
|
||||
The web UI lives in the Layout tab's "Saved layouts" card
|
||||
(`static/saved_layouts.js`, `GET`/`POST /api/frames/{id}/layouts`,
|
||||
`PATCH`/`DELETE /api/layouts/{id}`, `POST
|
||||
/api/frames/{id}/layouts/{id}/apply`) -- name + Save, then a list of
|
||||
saved layouts each with Apply/rename/delete, incompatible ones shown
|
||||
greyed-out with a "different orientation" badge rather than hidden.
|
||||
|
||||
## Weather widget
|
||||
|
||||
A standalone widget type (`models.WeatherWidgetConfig`, `app/widgets/
|
||||
weather.py`) -- distinct from, and unrelated in code to,
|
||||
`CalendarWidgetConfig`'s own embedded weather strip (still present,
|
||||
still Open-Meteo-only, still working exactly as before). Four display
|
||||
modes (`WeatherWidgetConfig.mode`, switchable in the widget's dialog like
|
||||
`calendar_view`):
|
||||
|
||||
- `current` -- one city's current temp + a condition icon.
|
||||
- `hourly` -- one city, a row of ticks across the day at a configurable
|
||||
interval (`hourly_interval_hours`: 3/4/6/12).
|
||||
- `daily` -- one city, a multi-day strip (`daily_days`, 1-14).
|
||||
- `multi_city` -- several cities' current-day high/low/icon side by
|
||||
side -- the calendar widget's embedded strip, as a standalone
|
||||
widget's whole content instead of a strip above an agenda day.
|
||||
|
||||
**Render style** (`WeatherWidgetConfig.render_style`, `"classic"` default
|
||||
| `"modern"`, experimental) -- see "Modern render style" above; weather's
|
||||
own modern coverage is `current`/`daily` only, `hourly`/`multi_city`
|
||||
always render classic regardless of this setting.
|
||||
|
||||
`current`/`hourly`/`daily` share one configured location
|
||||
(`city_label`/`city_latitude`/`city_longitude`, set via `POST .../
|
||||
weather-location`, geocoded through `weather.geocode_city`); `multi_city`
|
||||
has its own list (`cities`, add/remove via `POST .../weather-widget-
|
||||
cities/add`|`remove` -- named to avoid colliding with the calendar
|
||||
widget's own, differently-scoped `weather-cities/add`|`remove` routes,
|
||||
which share the same `{widget_id}`-parameterized path shape).
|
||||
|
||||
**Providers** (`app/weather/`, a dispatch registry over pluggable
|
||||
implementations mirroring `app/widgets/` itself): `WeatherWidgetConfig.
|
||||
provider` selects which of `app/weather.PROVIDERS` actually fetches --
|
||||
`"open_meteo"` (worldwide, no API key), `"nws"` (api.weather.gov, US
|
||||
only, no API key, approximates "current" with the first hourly forecast
|
||||
period rather than a real station observation), or `"ec"` (Environment
|
||||
Canada, api.weather.gc.ca's MSC GeoMet OGC API, Canada only, no API key).
|
||||
Every provider function returns already-normalized `{"category": ...}`
|
||||
entries (one of `clear`/`partly_cloudy`/`cloudy`/`fog`/`rain`/`snow`/
|
||||
`thunderstorm`) so `app/weather_render.py`'s drawing code never needs to
|
||||
know which provider supplied an entry. `geocode_city` (name -> lat/lon)
|
||||
always goes through Open-Meteo's free geocoder regardless of which
|
||||
provider is chosen to fetch with the result.
|
||||
|
||||
EC's `citypageweather-realtime` collection is only queryable by bounding
|
||||
box (OGC API - Features), not a direct by-coordinate endpoint -- unlike
|
||||
Open-Meteo/NWS's simple lat/lon REST, `app/weather/ec.py`'s
|
||||
`_nearest_site` widens the box progressively and picks the closest site
|
||||
by straight-line distance, rejecting anything beyond 300 km (calibrated
|
||||
against a real bug caught in development: an unconditional "nearest
|
||||
site, however far" matched a Miami, FL query to a site in Ontario,
|
||||
1824 km away, once the box widened enough to cover the whole country).
|
||||
|
||||
`app/weather_render.py` holds every weather-related drawing primitive:
|
||||
`draw_weather_icon`/`draw_weather_row` (extracted out of
|
||||
`calendar_render.py`, which still imports `draw_weather_row` for its own
|
||||
embedded strip, unchanged) plus this widget's own `build_current`/
|
||||
`build_hourly`/`build_daily`/`build_multi_city`, dispatched by `build()`
|
||||
-- the weather analogue of `calendar_render.py`'s own `_build_tasks`/
|
||||
`render_tasks_preview_png` relationship. Icons are hand-drawn (no custom
|
||||
font/icon asset), styled after Environment Canada's own icon set
|
||||
(pointed sun rays, a puffy cloud, teardrop rain, dendrite snowflakes, a
|
||||
zigzag bolt) but filled with the panel's *exact* ink RGB values rather
|
||||
than an arbitrary bitmap's anti-aliased colors -- a flat fill that's
|
||||
already a palette color quantizes with zero dithering error to diffuse,
|
||||
where a fetched/vendored icon's colors (almost never an exact match)
|
||||
dither into a visible speckle at these small on-panel sizes (confirmed
|
||||
by actually running one through the real quantize pass during
|
||||
development). Used for every provider's rendering, not just when EC is
|
||||
selected as the provider.
|
||||
|
||||
## Battery widget
|
||||
|
||||
The simplest widget type (`models.BatteryWidgetConfig`, `app/widgets/
|
||||
battery.py`): shows this frame's own last-reported battery level. Unlike
|
||||
every other widget type, there's no live upstream to poll and nothing to
|
||||
cache -- the content is `Frame.battery_percent`/`battery_as_of`, set by
|
||||
`routers/device.py`'s `frame_battery` on every device wake-on-battery
|
||||
report, which already existed for the Device panel's own history chart
|
||||
regardless of whether a battery widget is placed anywhere. The widget's
|
||||
own config is just a display mode: `"compact"` (icon + percent) or
|
||||
`"detailed"` (default, adds `routers/common.py`'s existing
|
||||
`battery_estimate_s` time-remaining estimate and the last report's age).
|
||||
`render()` falls back to a "No reports yet" placeholder for a frame that
|
||||
has never reported (never run on battery, or not yet claimed by a
|
||||
device) rather than showing a stale or fabricated number. The battery
|
||||
icon fill color (red/yellow/green by percent) uses the same exact-panel-
|
||||
ink-RGB approach as the weather icons above and `manage_overlay.py`'s own
|
||||
battery glyph on the "scan to manage" overlay -- a separate, unrelated
|
||||
piece of code with its own fixed small size, not shared with this
|
||||
widget, but drawing from the same thresholds/colors so a battery glyph
|
||||
reads the same wherever one shows up on a panel.
|
||||
|
||||
## Known gaps
|
||||
|
||||
The original 8-phase rollout plan's last phase is done: migration 41
|
||||
dropped the legacy per-mode `Frame` columns (`mode`, `album_id`,
|
||||
`current_asset_id`, all `calendar_*`, all `whiteboard_*`, `queue`, etc.)
|
||||
-- see its own docstring in `app/migration.py` for the raw-SQL backfill
|
||||
safety net that ran first, and `server/README.md` no longer describes
|
||||
photos/calendar/whiteboard as per-frame "modes".
|
||||
|
||||
Still open:
|
||||
|
||||
- Whiteboard rendering is tagged **(alpha)** in the UI -- not fully
|
||||
reliable yet, treat it as experimental if extending it.
|
||||
+86
-29
@@ -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-ported-ee02-builds-end-to-end-unverified-on-real-hardware)
|
||||
below; it builds end-to-end now, but is still unverified on real EE02
|
||||
hardware). 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,40 @@ 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 ported; `ee02` builds end-to-end, unverified on real hardware)
|
||||
|
||||
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 now compiles and links clean end-to-end**, verified locally with
|
||||
a native, non-Docker ESP-IDF v6.0 install (see
|
||||
`.claude/skills/build-firmware/SKILL.md`).
|
||||
`firmware/components/epd13in3e`'s panel init/LUT/refresh register
|
||||
sequence is a real, vendor-confirmed port (see that component's own top
|
||||
comment and
|
||||
[`docs/hardware.md`](../docs/hardware.md#133-spectra-6-panel-on-seeeds-ee02-board-panel-driver-ported-ee02-builds-end-to-end-unverified-on-real-hardware)
|
||||
for the vendor sources and the load-bearing native-raster-orientation
|
||||
correction that came with it). `main/{back,next,combo}_button.c` used to
|
||||
call an ESP32-C6-only deep-sleep GPIO-wakeup API with no ESP32-S3
|
||||
fallback; each now branches on `SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP`
|
||||
to keep the ESP32-C6 path (devkit/xiao) untouched while using
|
||||
`esp_sleep_enable_ext1_wakeup_io()` on ESP32-S3 -- see `docs/hardware.md`'s
|
||||
same section for why the additive `_io()` variant needs no combined-mask
|
||||
coordination across the three button files, and why the pull-resistor
|
||||
concern that ruled out EXT1 wakeup on ESP32-C6 doesn't apply the same
|
||||
way here. That reasoning is confirmed against ESP-IDF source, **not
|
||||
against real EE02 hardware** -- CI's
|
||||
(`.gitea/workflows/firmware-build-check.yml`/`firmware-release-build.yml`)
|
||||
`continue-on-error` on this board's step is intentionally still in place
|
||||
until it is.
|
||||
|
||||
## Configuration (`idf.py menuconfig`)
|
||||
|
||||
Under **ESPresso Frame Configuration**:
|
||||
@@ -66,8 +104,9 @@ Under **ESPresso Frame Configuration**:
|
||||
| `FRAME_NEXT_BUTTON_GPIO` | 2 | Next-photo button GPIO (-1 to disable). Must be 0-7 (ESP32-C6's deep-sleep-wakeup-capable pins) |
|
||||
| `FRAME_BACK_BUTTON_GPIO` | 0 | Back-photo button GPIO (-1 to disable). Must be 0-7 |
|
||||
| `FRAME_COMBO_BUTTON_GPIO` | 1 | Menu/reset button GPIO (-1 to disable). Must be 0-7 |
|
||||
| `FRAME_COMBO_SOFT_RESET_HOLD_MS` | 3000 | How long the combo button must be held (then released) to soft-reset |
|
||||
| `FRAME_COMBO_MENU_HOLD_MS` | 3000 | How long the combo button must be held (then released) to show the management menu |
|
||||
| `FRAME_COMBO_FACTORY_RESET_HOLD_MS` | 15000 | How long the combo button must be held to factory-reset |
|
||||
| `FRAME_HOLD_ACTION_MS` | 3000 | **Fallback only** -- how long NEXT/BACK must be held to trigger a global action instead of a short press; see below |
|
||||
| `FRAME_BATTERY_ADC_GPIO` | -1 (disabled) | Battery voltage-divider ADC GPIO; see the Battery section below |
|
||||
| `FRAME_VBUS_SENSE_GPIO` | -1 (disabled) | USB-power sense GPIO for hiding the battery indicator on mains |
|
||||
|
||||
@@ -84,6 +123,34 @@ reflashing. The Kconfig value only applies before the device has ever
|
||||
successfully reached a configured server, or if the response doesn't
|
||||
include a valid interval.
|
||||
|
||||
### Holding NEXT/BACK for a global action
|
||||
|
||||
Past `FRAME_HOLD_ACTION_MS`, holding NEXT or BACK stops meaning "advance/
|
||||
back this widget" and instead triggers whatever frame-wide action (if
|
||||
any) is configured for that button's hold on the server's Configuration
|
||||
tab -- e.g. cycling through saved layouts (see
|
||||
`server/app/global_actions.py`). Fires immediately at the threshold,
|
||||
without waiting for release -- same convention as the combo button's
|
||||
factory-reset tier below.
|
||||
|
||||
Same "fallback only" caveat as `FRAME_SLEEP_INTERVAL_S` above, but with
|
||||
one more wrinkle: the server's actual `hold_duration_ms` (set on the
|
||||
Configuration tab, `GET /frame/config`'s response) can't be used for
|
||||
*this* wake's button decision -- that decision happens in `main.c`
|
||||
before WiFi even connects, but `/frame/config` isn't fetched until near
|
||||
the end of the wake cycle (after the image fetch, deliberately -- see
|
||||
`frame_client_run`'s own comment on why). So the device always acts on
|
||||
whatever value the *previous* wake fetched (persisted in NVS via
|
||||
`frame_config_set_hold_duration_ms`), falling back to
|
||||
`FRAME_HOLD_ACTION_MS` only before it's ever successfully fetched one.
|
||||
In practice this means changing the duration on the Configuration tab
|
||||
takes effect starting with the wake *after* the next one, not
|
||||
immediately.
|
||||
|
||||
Holding a button through the poll loop keeps the device awake and
|
||||
connected longer than a normal short-press wake -- the same tradeoff
|
||||
already accepted for the combo button's menu/reset holds below.
|
||||
|
||||
### WiFi fast-connect
|
||||
|
||||
After a successful home-WiFi connection, the device caches the AP's
|
||||
@@ -118,10 +185,9 @@ two-step setup screen:
|
||||
portal's config page (`http://192.168.4.1/` by default), for a
|
||||
one-scan shortcut once you've joined the AP.
|
||||
|
||||
The config page asks for your home WiFi SSID/password, the "Tools
|
||||
The config page asks for your home WiFi SSID/password and the "Tools
|
||||
Server" address (`host:port` of the [server](../server/) -- **not** your
|
||||
Immich server; see below for the `https://` form), and an optional
|
||||
"Access Token" (see below -- usually blank). Saving hands your browser
|
||||
Immich server; see below for the `https://` form). Saving hands your browser
|
||||
off to the server's claim page (after ~7 seconds, giving your phone
|
||||
time to rejoin its normal WiFi while the device reboots) so the frame
|
||||
gets linked to your account; the device meanwhile connects to your home
|
||||
@@ -183,19 +249,6 @@ certificate was actually issued for -- a bare LAN IP address
|
||||
(`https://192.168.1.50`) will fail the handshake even against a
|
||||
perfectly valid cert for a different name.
|
||||
|
||||
## Access token
|
||||
|
||||
Usually blank. Current servers issue each frame its own private token
|
||||
automatically on first contact (delivered via `GET /frame/config`,
|
||||
persisted in NVS, preferred by `build_url()` from then on -- and baked
|
||||
into the manage-menu/share QR codes so scanning them just works). The
|
||||
captive portal's "Access Token" field only matters when pointing this
|
||||
firmware at an *older* (pre-multi-frame) server whose `MANAGEMENT_TOKEN`
|
||||
is set: paste that shared value and the device sends it (`?token=...`)
|
||||
until a newer server replaces it with a per-frame one. Re-provisioning
|
||||
clears any stored per-frame token -- a fresh identity handshake with
|
||||
whatever server you point it at next.
|
||||
|
||||
## Skipping to the next photo
|
||||
|
||||
Wire a momentary push button between GPIO2 and GND (internal pull-up,
|
||||
@@ -204,6 +257,8 @@ device (if asleep) and tells the server to advance to the next photo
|
||||
right away, regardless of the configured refresh interval -- no long hold
|
||||
needed, since advancing is easily reversible by pressing again. See
|
||||
`FRAME_NEXT_BUTTON_GPIO` above to change the pin or disable the feature.
|
||||
Holding it past `FRAME_HOLD_ACTION_MS` instead means something else
|
||||
entirely -- see "Holding NEXT/BACK for a global action" above.
|
||||
|
||||
Normal wakes and reboots never advance the photo on their own -- the
|
||||
server decides when to advance based on its own clock (see
|
||||
@@ -223,7 +278,8 @@ disable the feature.
|
||||
|
||||
If there's nothing to go back to yet (freshly provisioned, or you've
|
||||
already gone back as far as there is history), it's a no-op -- the
|
||||
current photo stays exactly as it was, no flash on the panel.
|
||||
current photo stays exactly as it was, no flash on the panel. Same
|
||||
long-hold caveat as the next-photo button above.
|
||||
|
||||
## Battery (XIAO ESP32-C6)
|
||||
|
||||
@@ -262,9 +318,13 @@ One more button, wired between GPIO1 and GND (same wiring style as the
|
||||
other buttons), covers three actions -- disambiguated purely by how
|
||||
long it's held:
|
||||
|
||||
**A quick press** wakes the device and overlays several corners of
|
||||
whatever photo is currently showing, leaving the middle of the photo
|
||||
visible and unchanged:
|
||||
**A quick press** soft-resets the device -- `esp_restart()`, keeping the
|
||||
stored WiFi/server config. Useful for recovering a hung device without
|
||||
losing setup.
|
||||
|
||||
**Holding it ~3 seconds, then releasing** wakes the device (if asleep)
|
||||
and overlays several corners of whatever photo is currently showing,
|
||||
leaving the middle of the photo visible and unchanged:
|
||||
|
||||
- **Top-right**: a QR code -- "SCAN TO MANAGE" -- linking to the
|
||||
server's config page.
|
||||
@@ -283,8 +343,9 @@ for faces Immich hasn't been told a name for; no face detection happens
|
||||
on the device or the server, this is entirely Immich's own People
|
||||
feature). A third press exits immediately rather than waiting out the
|
||||
30-second timer. Holding the button during this stage doesn't trigger
|
||||
either reset tier below -- the hold-duration read only ever happens
|
||||
once, right when the device first wakes, before any menu is shown.
|
||||
the factory-reset tier below -- the hold-duration read only ever
|
||||
happens once, right when the device first wakes, before any menu is
|
||||
shown.
|
||||
|
||||
The device stays awake for the whole menu interaction (up to three
|
||||
physical refreshes: the base overlay, the escalated one, and
|
||||
@@ -292,17 +353,13 @@ reverting), so this costs meaningfully more power than a normal wake --
|
||||
expected for a deliberate, occasional action, same tradeoff as the
|
||||
other buttons.
|
||||
|
||||
**Holding it ~3 seconds, then releasing** soft-resets the device --
|
||||
`esp_restart()`, keeping the stored WiFi/server config. Useful for
|
||||
recovering a hung device without losing setup.
|
||||
|
||||
**Holding it ~15 seconds** (whether or not you're still holding it --
|
||||
this fires immediately, it doesn't wait for release) clears the stored
|
||||
WiFi/server config and restarts into provisioning. From either power-on
|
||||
or while the device is deep-asleep, since this GPIO is armed as a
|
||||
wakeup source.
|
||||
|
||||
See `FRAME_COMBO_BUTTON_GPIO`, `FRAME_COMBO_SOFT_RESET_HOLD_MS`, and
|
||||
See `FRAME_COMBO_BUTTON_GPIO`, `FRAME_COMBO_MENU_HOLD_MS`, and
|
||||
`FRAME_COMBO_FACTORY_RESET_HOLD_MS` above to change the pin or hold
|
||||
durations, or disable all three actions.
|
||||
|
||||
|
||||
+33
-14
@@ -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 <devkit|xiao> [idf.py args...]" >&2
|
||||
echo "Usage: $0 <devkit|xiao|ee02> [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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,438 @@
|
||||
#include <string.h>
|
||||
|
||||
#include "driver/gpio.h"
|
||||
#include "driver/spi_master.h"
|
||||
#include "esp_heap_caps.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"
|
||||
|
||||
/* Command bytes/register values below are a line-for-line transcription of
|
||||
* Waveshare's official reference drivers for this exact panel+controller --
|
||||
* confirmed identical across three independent sources (RaspberryPi/c,
|
||||
* ESP32, and the ESP32-S3-ePaper-13.3E6 ESP-IDF example; see this
|
||||
* component's header for repo paths). Same "don't clean these up" rule as
|
||||
* epd7in3e.c: this class of panel controller has no public datasheet, so
|
||||
* the vendor driver is the source of truth for every byte.
|
||||
*
|
||||
* Unlike epd7in3e's single chip-select, this panel is driven as two
|
||||
* independent controllers sharing one CLK/MOSI/DC/RST/BUSY bus but with
|
||||
* separate chip-selects (EPD_PIN_CS_MASTER/EPD_PIN_CS_SLAVE) -- most init
|
||||
* commands broadcast to both (CS_ALL), a handful of power/boost commands
|
||||
* go only to the master (which owns the shared analog rails), and actual
|
||||
* frame data is split per-row into a left half (master) and right half
|
||||
* (slave), 300 bytes each out of each 600-byte row. That split is why
|
||||
* epd_write_frame below buffers the whole frame in PSRAM before sending
|
||||
* anything (every row needs slicing in half before either half can go out),
|
||||
* unlike epd7in3e.c's straight single-CS passthrough streaming.
|
||||
*
|
||||
* Pin numbers themselves are NOT from this vendor code -- Waveshare's
|
||||
* ESP32-S3-ePaper-13.3E6 example targets Waveshare's own driver board, a
|
||||
* different carrier than Seeed's EE02 this project actually uses, so its
|
||||
* GPIO numbers don't apply here. EE02's pins remain sourced from a
|
||||
* community-verified ESPHome integration (see this component's Kconfig),
|
||||
* not an official reference. */
|
||||
|
||||
#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)
|
||||
|
||||
/* --- panel command opcodes --- */
|
||||
#define PSR 0x00
|
||||
#define PWR 0x01
|
||||
#define POF 0x02
|
||||
#define PON 0x04
|
||||
#define BTST_N 0x05
|
||||
#define BTST_P 0x06
|
||||
#define DTM 0x10 /* data transfer (frame data) */
|
||||
#define DRF 0x12 /* display refresh */
|
||||
#define CDI 0x50
|
||||
#define TCON 0x60
|
||||
#define TRES 0x61
|
||||
#define AN_TM 0x74
|
||||
#define AGID 0x86
|
||||
#define BUCK_BOOST_VDDN 0xB0
|
||||
#define TFT_VCOM_POWER 0xB1
|
||||
#define EN_BUF 0xB6
|
||||
#define BOOST_VDDP_EN 0xB7
|
||||
#define CCSET 0xE0
|
||||
#define PWS 0xE3
|
||||
#define CMD66 0xF0
|
||||
#define DEEP_SLEEP 0x07
|
||||
|
||||
/* --- canned init parameter blobs (do NOT edit -- see top comment) --- */
|
||||
static const uint8_t PSR_V[] = {0xDF, 0x69};
|
||||
static const uint8_t PWR_V[] = {0x0F, 0x00, 0x28, 0x2C, 0x28, 0x38};
|
||||
static const uint8_t POF_V[] = {0x00};
|
||||
static const uint8_t DRF_V[] = {0x00};
|
||||
static const uint8_t CDI_V[] = {0xF7};
|
||||
static const uint8_t TCON_V[] = {0x03, 0x03};
|
||||
static const uint8_t TRES_V[] = {0x04, 0xB0, 0x03, 0x20};
|
||||
static const uint8_t CMD66_V[] = {0x49, 0x55, 0x13, 0x5D, 0x05, 0x10};
|
||||
static const uint8_t EN_BUF_V[] = {0x07};
|
||||
static const uint8_t CCSET_V[] = {0x01};
|
||||
static const uint8_t PWS_V[] = {0x22};
|
||||
static const uint8_t AN_TM_V[] = {0xC0, 0x1C, 0x1C, 0xCC, 0xCC, 0xCC, 0x15, 0x15, 0x55};
|
||||
static const uint8_t AGID_V[] = {0x10};
|
||||
static const uint8_t BTST_P_V[] = {0xE8, 0x28};
|
||||
static const uint8_t BOOST_VDDP_EN_V[] = {0x01};
|
||||
static const uint8_t BTST_N_V[] = {0xE8, 0x28};
|
||||
static const uint8_t BUCK_BOOST_VDDN_V[] = {0x01};
|
||||
static const uint8_t TFT_VCOM_POWER_V[] = {0x02};
|
||||
|
||||
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/poll-interval reasoning
|
||||
* as epd7in3e.c's identical comment (a tight 1ms-rounds-to-0-ticks poll
|
||||
* starves the idle task badly enough to trip the watchdog). */
|
||||
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;
|
||||
}
|
||||
|
||||
/* Unlike epd7in3e.c's send_command/send_data, these do NOT touch CS --
|
||||
* this panel's two independent chip-selects (and the "broadcast to both"
|
||||
* vs "master only" split the init sequence needs) mean CS bracketing has
|
||||
* to be the caller's decision, not baked into the byte-send primitive. */
|
||||
static esp_err_t epd_send_command(uint8_t cmd)
|
||||
{
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_DC, 0);
|
||||
return epd_spi_write(&cmd, 1);
|
||||
}
|
||||
|
||||
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);
|
||||
return epd_spi_write(data, len);
|
||||
}
|
||||
|
||||
static esp_err_t epd_send_data_byte(uint8_t data)
|
||||
{
|
||||
return epd_send_data(&data, 1);
|
||||
}
|
||||
|
||||
static void epd_cs_both(int level)
|
||||
{
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, level);
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_SLAVE, level);
|
||||
}
|
||||
|
||||
/* Sends `cmd` + its data blob to both controllers at once (most of the
|
||||
* init sequence -- shared display-timing/power registers). */
|
||||
static esp_err_t epd_cmd_both(uint8_t cmd, const uint8_t *data, size_t len)
|
||||
{
|
||||
epd_cs_both(0);
|
||||
esp_err_t err = epd_send_command(cmd);
|
||||
if (err == ESP_OK && data != NULL) {
|
||||
err = epd_send_data(data, len);
|
||||
}
|
||||
epd_cs_both(1);
|
||||
return err;
|
||||
}
|
||||
|
||||
/* Sends `cmd` + its data blob to the master controller only -- the boost/
|
||||
* VCOM power registers the master alone owns. */
|
||||
static esp_err_t epd_cmd_master(uint8_t cmd, const uint8_t *data, size_t len)
|
||||
{
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 0);
|
||||
esp_err_t err = epd_send_command(cmd);
|
||||
if (err == ESP_OK && data != NULL) {
|
||||
err = epd_send_data(data, len);
|
||||
}
|
||||
epd_cs_both(1);
|
||||
return err;
|
||||
}
|
||||
|
||||
/* 5-edge reset sequence (30ms each) -- per-vendor-source exact, more edges
|
||||
* than epd7in3e.c's 3-edge/20ms reset. */
|
||||
static void epd_reset(void)
|
||||
{
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1);
|
||||
epd_delay_ms(30);
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 0);
|
||||
epd_delay_ms(30);
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1);
|
||||
epd_delay_ms(30);
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 0);
|
||||
epd_delay_ms(30);
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1);
|
||||
epd_delay_ms(30);
|
||||
}
|
||||
|
||||
/* Power on, refresh, power off -- mirrors EPD_TurnOnDisplay()/
|
||||
* EPD_13IN3E_TurnOnDisplay() in the reference drivers. */
|
||||
esp_err_t epd_turn_on_display(void)
|
||||
{
|
||||
EPD_CHECK(epd_cmd_both(PON, NULL, 0));
|
||||
epd_wait_busy();
|
||||
|
||||
epd_delay_ms(50);
|
||||
EPD_CHECK(epd_cmd_both(DRF, DRF_V, sizeof(DRF_V)));
|
||||
epd_wait_busy();
|
||||
|
||||
epd_delay_ms(50);
|
||||
EPD_CHECK(epd_cmd_both(POF, POF_V, sizeof(POF_V)));
|
||||
/* No busy-wait after POF -- matches every reference driver. */
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t epd_init(void)
|
||||
{
|
||||
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_RST, 1);
|
||||
epd_cs_both(1);
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_POWER_EN, 0);
|
||||
|
||||
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, /* both chip-selects are bit-banged by hand above */
|
||||
.queue_size = 1,
|
||||
};
|
||||
EPD_CHECK(spi_bus_add_device(EPD_SPI_HOST, &dev_cfg, &s_spi));
|
||||
|
||||
/* Panel power-enable rail (no equivalent on epd7in3e's board -- EE02
|
||||
* gates it separately from the ESP32-S3 module's own supply). */
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_POWER_EN, 1);
|
||||
epd_delay_ms(10);
|
||||
|
||||
epd_reset();
|
||||
epd_wait_busy();
|
||||
|
||||
/* Master-only: shared analog-timing register. */
|
||||
EPD_CHECK(epd_cmd_master(AN_TM, AN_TM_V, sizeof(AN_TM_V)));
|
||||
|
||||
/* Broadcast: display-timing/power-sequencing registers both
|
||||
* controllers need identically. */
|
||||
EPD_CHECK(epd_cmd_both(CMD66, CMD66_V, sizeof(CMD66_V)));
|
||||
EPD_CHECK(epd_cmd_both(PSR, PSR_V, sizeof(PSR_V)));
|
||||
EPD_CHECK(epd_cmd_both(CDI, CDI_V, sizeof(CDI_V)));
|
||||
EPD_CHECK(epd_cmd_both(TCON, TCON_V, sizeof(TCON_V)));
|
||||
EPD_CHECK(epd_cmd_both(AGID, AGID_V, sizeof(AGID_V)));
|
||||
EPD_CHECK(epd_cmd_both(PWS, PWS_V, sizeof(PWS_V)));
|
||||
EPD_CHECK(epd_cmd_both(CCSET, CCSET_V, sizeof(CCSET_V)));
|
||||
EPD_CHECK(epd_cmd_both(TRES, TRES_V, sizeof(TRES_V)));
|
||||
|
||||
/* Master-only: boost/VCOM power programming. */
|
||||
EPD_CHECK(epd_cmd_master(PWR, PWR_V, sizeof(PWR_V)));
|
||||
EPD_CHECK(epd_cmd_master(EN_BUF, EN_BUF_V, sizeof(EN_BUF_V)));
|
||||
EPD_CHECK(epd_cmd_master(BTST_P, BTST_P_V, sizeof(BTST_P_V)));
|
||||
EPD_CHECK(epd_cmd_master(BOOST_VDDP_EN, BOOST_VDDP_EN_V, sizeof(BOOST_VDDP_EN_V)));
|
||||
EPD_CHECK(epd_cmd_master(BTST_N, BTST_N_V, sizeof(BTST_N_V)));
|
||||
EPD_CHECK(epd_cmd_master(BUCK_BOOST_VDDN, BUCK_BOOST_VDDN_V, sizeof(BUCK_BOOST_VDDN_V)));
|
||||
EPD_CHECK(epd_cmd_master(TFT_VCOM_POWER, TFT_VCOM_POWER_V, sizeof(TFT_VCOM_POWER_V)));
|
||||
|
||||
ESP_LOGI(TAG, "EPD initialized (CLK=%d MOSI=%d CS_M=%d CS_S=%d DC=%d RST=%d BUSY=%d PWR_EN=%d)",
|
||||
CONFIG_EPD_PIN_CLK, CONFIG_EPD_PIN_MOSI, CONFIG_EPD_PIN_CS_MASTER,
|
||||
CONFIG_EPD_PIN_CS_SLAVE, CONFIG_EPD_PIN_DC, CONFIG_EPD_PIN_RST,
|
||||
CONFIG_EPD_PIN_BUSY, CONFIG_EPD_PIN_POWER_EN);
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
/* Every row has to be sliced into a left (master) and right (slave)
|
||||
* half before either half can go out over SPI, so -- unlike
|
||||
* epd7in3e.c's single-CS passthrough -- bytes can't be forwarded to
|
||||
* the wire as they arrive. Buffer the whole ~938KB frame in PSRAM
|
||||
* first (EE02's XIAO ESP32-S3 Plus has 8MB of it). */
|
||||
uint8_t *frame = heap_caps_malloc(EPD_FRAME_BYTES, MALLOC_CAP_SPIRAM);
|
||||
if (frame == NULL) {
|
||||
ESP_LOGE(TAG, "OOM allocating %u-byte frame buffer", (unsigned)EPD_FRAME_BYTES);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
size_t total = 0;
|
||||
uint32_t crc = 0;
|
||||
size_t n;
|
||||
while (total < EPD_FRAME_BYTES &&
|
||||
(n = read_fn(frame + total, EPD_FRAME_BYTES - total, ctx)) > 0) {
|
||||
crc = esp_rom_crc32_le(crc, frame + total, n);
|
||||
total += n;
|
||||
}
|
||||
|
||||
if (total != EPD_FRAME_BYTES) {
|
||||
/* Same invariant as epd7in3e.c: never touch the panel on a
|
||||
* short/wrong-size stream -- the visible screen is left exactly
|
||||
* as it was. */
|
||||
ESP_LOGE(TAG, "Stream supplied %u bytes, expected %u -- aborting refresh",
|
||||
(unsigned)total, (unsigned)EPD_FRAME_BYTES);
|
||||
free(frame);
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
/* De-interleave into one half-buffer at a time and DMA it out as a
|
||||
* single contiguous transfer (chunked internally by epd_spi_write) --
|
||||
* far fewer, far larger SPI transactions than sending 1600 separate
|
||||
* 300-byte rows per side. */
|
||||
const size_t HALF_ROW = EPD_BYTES_PER_ROW / 2; /* 300 */
|
||||
const size_t HALF_BUF = HALF_ROW * EPD_HEIGHT; /* 480000 */
|
||||
uint8_t *half = heap_caps_malloc(HALF_BUF, MALLOC_CAP_SPIRAM);
|
||||
if (half == NULL) {
|
||||
ESP_LOGE(TAG, "OOM allocating %u-byte half-frame scratch buffer", (unsigned)HALF_BUF);
|
||||
free(frame);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
for (size_t r = 0; r < EPD_HEIGHT; r++) {
|
||||
memcpy(half + r * HALF_ROW, frame + r * EPD_BYTES_PER_ROW, HALF_ROW);
|
||||
}
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 0);
|
||||
esp_err_t err = epd_send_command(DTM);
|
||||
if (err == ESP_OK) {
|
||||
err = epd_send_data(half, HALF_BUF);
|
||||
}
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 1);
|
||||
|
||||
if (err == ESP_OK) {
|
||||
for (size_t r = 0; r < EPD_HEIGHT; r++) {
|
||||
memcpy(half + r * HALF_ROW, frame + r * EPD_BYTES_PER_ROW + HALF_ROW, HALF_ROW);
|
||||
}
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_SLAVE, 0);
|
||||
err = epd_send_command(DTM);
|
||||
if (err == ESP_OK) {
|
||||
err = epd_send_data(half, HALF_BUF);
|
||||
}
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_SLAVE, 1);
|
||||
}
|
||||
|
||||
free(half);
|
||||
free(frame);
|
||||
EPD_CHECK(err);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
esp_err_t epd_sleep(void)
|
||||
{
|
||||
epd_cs_both(0);
|
||||
EPD_CHECK(epd_send_command(DEEP_SLEEP));
|
||||
EPD_CHECK(epd_send_data_byte(0xA5)); /* magic deep-sleep arg per every reference driver */
|
||||
epd_cs_both(1);
|
||||
|
||||
epd_delay_ms(100);
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_POWER_EN, 0);
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 0);
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include "esp_err.h"
|
||||
|
||||
/* Waveshare 13.3" e-Paper (E) Spectra 6 panel, driven by Seeed's EE02
|
||||
* board (XIAO ESP32-S3 Plus). The panel is marketed/mounted as a
|
||||
* 1600x1200 landscape rectangle (270.40x202.80mm), but its SPI
|
||||
* controller addresses a native raster of 1200 columns x 1600 rows --
|
||||
* i.e. the wire format is portrait, rotated 90 degrees from how the
|
||||
* panel physically hangs. Confirmed identically across three independent
|
||||
* vendor sources: Waveshare's RaspberryPi/c and ESP32 reference drivers
|
||||
* for this exact panel (E-paper_Separate_Program/13.3inch_e-Paper_E in
|
||||
* waveshare/e-Paper), and Waveshare's own ESP-IDF example for their
|
||||
* ESP32-S3-ePaper-13.3E6 driver board (a *different* carrier board than
|
||||
* Seeed's EE02, but the same panel+controller, hence the same command
|
||||
* bytes/geometry -- only the GPIO numbers differ, and those come from
|
||||
* EE02-specific sources, see this component's Kconfig). All three define
|
||||
* EPD_WIDTH=1200/EPD_HEIGHT=1600 and split each row into two 600-byte
|
||||
* (300px) halves sent to independent chip-selects: EPD_PIN_CS_MASTER
|
||||
* gets the left half, EPD_PIN_CS_SLAVE the right -- see epd13in3e.c.
|
||||
*
|
||||
* Getting this backwards (assuming the wire raster matches the
|
||||
* 1600x1200 mount/marketing size) doesn't just rotate the image -- 1600
|
||||
* and 1200 don't share a row stride with 1200 and 1600 the other way
|
||||
* (800 bytes/row x 1200 rows vs 600 bytes/row x 1600 rows), so a mismatch
|
||||
* here slices real image rows at the wrong byte offsets and shreds the
|
||||
* picture into a repeating diagonal garble, not a clean rotation.
|
||||
* server/app/image_pipeline.py's PANEL_WIRE_TRANSPOSE handles the
|
||||
* corresponding rotation server-side before packing bytes for this
|
||||
* panel_type -- this header and that dict must agree on which axis is
|
||||
* native. */
|
||||
#define EPD_WIDTH 1200
|
||||
#define EPD_HEIGHT 1600
|
||||
#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, and (now confirmed by the
|
||||
* same three vendor sources as the geometry above) the same 4-bit nibble
|
||||
* codes as epd7in3e.h's epd_color_t -- matches
|
||||
* server/app/image_pipeline.py's PANEL_CODES unconditionally, no
|
||||
* panel-specific table needed there. */
|
||||
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);
|
||||
@@ -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)
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
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 manage_qr_overlay.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
|
||||
# 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 epd13in3e qrcode epaper_fonts esp_driver_gpio esp_adc esp_https_ota app_update esp_app_format
|
||||
EMBED_FILES root.html)
|
||||
|
||||
@@ -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-<name>.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-<name>.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"
|
||||
@@ -119,6 +142,7 @@ menu "ESPresso Frame Configuration"
|
||||
config FRAME_NEXT_BUTTON_GPIO
|
||||
int "Next-photo button GPIO (-1 to disable)"
|
||||
default 2
|
||||
range -1 21 if IDF_TARGET_ESP32S3
|
||||
range -1 7
|
||||
help
|
||||
Button wired between this GPIO and GND (active-low, internal
|
||||
@@ -126,14 +150,17 @@ menu "ESPresso Frame Configuration"
|
||||
Pressing it wakes the device (if asleep), forces the server to
|
||||
advance to the next photo immediately (POST /frame/advance)
|
||||
regardless of the configured refresh interval, and displays
|
||||
it. Must be GPIO 0-7 -- the only pins the ESP32-C6 can use as
|
||||
a deep-sleep GPIO wakeup source, which is what lets a press
|
||||
wake the device promptly instead of only being noticed during
|
||||
its brief awake windows. Set to -1 to disable the feature.
|
||||
it. Must be a deep-sleep-wakeup-capable GPIO: 0-7 on the
|
||||
ESP32-C6, 0-21 on the ESP32-S3 (RTC-IO pins reachable by
|
||||
esp_sleep_enable_ext1_wakeup_io()) -- required so a press
|
||||
wakes the device promptly instead of only being noticed
|
||||
during its brief awake windows. Set to -1 to disable the
|
||||
feature.
|
||||
|
||||
config FRAME_BACK_BUTTON_GPIO
|
||||
int "Back-photo button GPIO (-1 to disable)"
|
||||
default 0
|
||||
range -1 21 if IDF_TARGET_ESP32S3
|
||||
range -1 7
|
||||
help
|
||||
Button wired between this GPIO and GND (active-low, internal
|
||||
@@ -142,40 +169,40 @@ menu "ESPresso Frame Configuration"
|
||||
to return to the previously-current photo immediately
|
||||
(POST /frame/back), and displays it. Pressing next
|
||||
afterwards returns to where you were before pressing back.
|
||||
Must be GPIO 0-7 for the same deep-sleep-wakeup reason as
|
||||
Must be a deep-sleep-wakeup-capable GPIO, same range as
|
||||
FRAME_NEXT_BUTTON_GPIO above; defaults to a different pin
|
||||
than the other buttons. Set to -1 to disable the feature.
|
||||
|
||||
config FRAME_COMBO_BUTTON_GPIO
|
||||
int "Menu/reset button GPIO (-1 to disable)"
|
||||
default 1
|
||||
range -1 21 if IDF_TARGET_ESP32S3
|
||||
range -1 7
|
||||
help
|
||||
Button wired between this GPIO and GND (active-low, internal
|
||||
pull-up enabled in firmware -- no external resistor needed).
|
||||
One pin, three actions depending on how long it's held:
|
||||
a quick press shows the management menu (same as before);
|
||||
holding it FRAME_COMBO_SOFT_RESET_HOLD_MS then releasing
|
||||
soft-resets the device (reboots, keeps the stored WiFi/
|
||||
server config); holding it all the way to
|
||||
FRAME_COMBO_FACTORY_RESET_HOLD_MS clears the stored config
|
||||
and restarts into provisioning, regardless of whether it's
|
||||
released yet. Must be GPIO 0-7 for the same deep-sleep-
|
||||
wakeup reason as FRAME_NEXT_BUTTON_GPIO above; defaults to
|
||||
a quick press soft-resets the device (reboots, keeps the
|
||||
stored WiFi/server config); holding it FRAME_COMBO_MENU_HOLD_MS
|
||||
then releasing shows the management menu; holding it all the
|
||||
way to FRAME_COMBO_FACTORY_RESET_HOLD_MS clears the stored
|
||||
config and restarts into provisioning, regardless of whether
|
||||
it's released yet. Must be a deep-sleep-wakeup-capable GPIO,
|
||||
same range as FRAME_NEXT_BUTTON_GPIO above; defaults to
|
||||
a different pin than the other buttons. Set to -1 to
|
||||
disable the feature entirely (also disables the management
|
||||
menu, both reset tiers, and factory-reset-via-button --
|
||||
reconfiguring then only works by erasing NVS over USB, see
|
||||
firmware/README.md).
|
||||
|
||||
config FRAME_COMBO_SOFT_RESET_HOLD_MS
|
||||
int "Soft-reset hold duration (ms)"
|
||||
config FRAME_COMBO_MENU_HOLD_MS
|
||||
int "Management-menu hold duration (ms)"
|
||||
default 3000
|
||||
depends on FRAME_COMBO_BUTTON_GPIO >= 0
|
||||
help
|
||||
How long the menu/reset button must be held before releasing
|
||||
it triggers a soft reset (reboot, config kept). Long enough
|
||||
to be clearly distinct from a quick menu-opening press.
|
||||
it shows the management menu instead of soft-resetting. Long
|
||||
enough to be clearly distinct from a quick reset tap.
|
||||
|
||||
config FRAME_COMBO_FACTORY_RESET_HOLD_MS
|
||||
int "Factory-reset hold duration (ms)"
|
||||
@@ -185,8 +212,25 @@ menu "ESPresso Frame Configuration"
|
||||
How long the menu/reset button must be held continuously
|
||||
before the device clears its stored config and reboots into
|
||||
provisioning, regardless of release. Comfortably longer than
|
||||
FRAME_COMBO_SOFT_RESET_HOLD_MS so the two tiers can't be
|
||||
confused for each other.
|
||||
FRAME_COMBO_MENU_HOLD_MS so the two tiers can't be confused
|
||||
for each other.
|
||||
|
||||
config FRAME_HOLD_ACTION_MS
|
||||
int "Next/back hold-for-global-action duration (ms)"
|
||||
default 3000
|
||||
range 3000 10000
|
||||
help
|
||||
How long the NEXT or BACK button must be held before it
|
||||
triggers a frame-wide action (see server/app/global_actions.py
|
||||
-- e.g. cycling saved layouts) instead of that button's normal
|
||||
short-press behavior. Only a first-boot/never-connected
|
||||
fallback: once the device has fetched GET /frame/config at
|
||||
least once, the server's own Frame.hold_duration_ms (set on
|
||||
the Configuration tab) overrides this on every later boot --
|
||||
see wifi_provisioning.h's frame_config_get_hold_duration_ms.
|
||||
Floor matches the server's own minimum, so a long-held button
|
||||
never means something different depending on which value
|
||||
happened to apply.
|
||||
|
||||
config FRAME_BATTERY_ADC_GPIO
|
||||
int "Battery voltage-divider ADC GPIO (-1 to disable)"
|
||||
|
||||
+52
-18
@@ -1,10 +1,15 @@
|
||||
#include <stdint.h>
|
||||
|
||||
#include "driver/gpio.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_sleep.h"
|
||||
#include "soc/soc_caps.h"
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#include "wifi_provisioning.h"
|
||||
|
||||
#include "back_button.h"
|
||||
|
||||
static const char *TAG = "back_button";
|
||||
@@ -14,6 +19,7 @@ static const char *TAG = "back_button";
|
||||
#define BACK_BUTTON_GPIO ((gpio_num_t)CONFIG_FRAME_BACK_BUTTON_GPIO)
|
||||
#define BACK_BUTTON_DEBOUNCE_MS 20
|
||||
#define BACK_BUTTON_DEBOUNCE_CHECKS 3
|
||||
#define BACK_BUTTON_POLL_MS 100
|
||||
|
||||
void back_button_init(void)
|
||||
{
|
||||
@@ -30,13 +36,20 @@ void back_button_init(void)
|
||||
};
|
||||
gpio_config(&io_conf);
|
||||
|
||||
#if SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP
|
||||
/* See next_button.c for why this API (not ext1) -- it manages the
|
||||
* pull resistor across the sleep transition itself, so the pin
|
||||
* doesn't float and wake the device spuriously. */
|
||||
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << BACK_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
|
||||
#else
|
||||
/* See next_button.c for why ext1 is safe here on targets without the
|
||||
* API above (e.g. ESP32-S3), and why _io() needs no cross-file mask
|
||||
* coordination. */
|
||||
ESP_ERROR_CHECK(esp_sleep_enable_ext1_wakeup_io(1ULL << BACK_BUTTON_GPIO, ESP_EXT1_WAKEUP_ANY_LOW));
|
||||
#endif
|
||||
}
|
||||
|
||||
bool back_button_check(void)
|
||||
back_button_result_t back_button_check(void)
|
||||
{
|
||||
/* A quick tap can easily release before this runs (~0.4-0.5s into
|
||||
* boot, confirmed on hardware -- a live gpio_get_level() check here
|
||||
@@ -44,32 +57,53 @@ bool back_button_check(void)
|
||||
* status register is latched at the moment of waking and isn't
|
||||
* cleared until the next sleep entry, so it reliably reflects a tap
|
||||
* regardless of how quickly it was released. */
|
||||
if (esp_sleep_get_gpio_wakeup_status() & (1ULL << BACK_BUTTON_GPIO)) {
|
||||
ESP_LOGI(TAG, "Back-photo button caused this wake, going back");
|
||||
return true;
|
||||
}
|
||||
#if SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP
|
||||
bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << BACK_BUTTON_GPIO);
|
||||
#else
|
||||
bool caused_wake = esp_sleep_get_ext1_wakeup_status() & (1ULL << BACK_BUTTON_GPIO);
|
||||
#endif
|
||||
|
||||
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a fresh
|
||||
* power-on/reflash) -- fall back to a live, debounced level check so
|
||||
* holding the button down while powering on also works. */
|
||||
if (gpio_get_level(BACK_BUTTON_GPIO) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < BACK_BUTTON_DEBOUNCE_CHECKS; i++) {
|
||||
vTaskDelay(pdMS_TO_TICKS(BACK_BUTTON_DEBOUNCE_MS));
|
||||
if (!caused_wake) {
|
||||
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a
|
||||
* fresh power-on/reflash) -- fall back to a live, debounced level
|
||||
* check so holding the button down while powering on also
|
||||
* works. */
|
||||
if (gpio_get_level(BACK_BUTTON_GPIO) != 0) {
|
||||
return false; /* noise, not a real press */
|
||||
return BACK_BUTTON_NOT_PRESSED;
|
||||
}
|
||||
for (int i = 0; i < BACK_BUTTON_DEBOUNCE_CHECKS; i++) {
|
||||
vTaskDelay(pdMS_TO_TICKS(BACK_BUTTON_DEBOUNCE_MS));
|
||||
if (gpio_get_level(BACK_BUTTON_GPIO) != 0) {
|
||||
return BACK_BUTTON_NOT_PRESSED; /* noise, not a real press */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Back-photo button held during power-on, going back");
|
||||
return true;
|
||||
/* Confirmed pressed -- measure how long, same reasoning/pattern as
|
||||
* next_button_check(). */
|
||||
uint32_t hold_threshold_ms;
|
||||
if (frame_config_get_hold_duration_ms(&hold_threshold_ms) != ESP_OK) {
|
||||
hold_threshold_ms = CONFIG_FRAME_HOLD_ACTION_MS;
|
||||
}
|
||||
|
||||
uint32_t elapsed_ms = 0;
|
||||
while (gpio_get_level(BACK_BUTTON_GPIO) == 0) {
|
||||
if (elapsed_ms >= hold_threshold_ms) {
|
||||
ESP_LOGI(TAG, "Back button held past %ums, triggering global hold action",
|
||||
(unsigned)hold_threshold_ms);
|
||||
return BACK_BUTTON_HOLD;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(BACK_BUTTON_POLL_MS));
|
||||
elapsed_ms += BACK_BUTTON_POLL_MS;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Back-photo button short press (%ums), going back", (unsigned)elapsed_ms);
|
||||
return BACK_BUTTON_SHORT_PRESS;
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
void back_button_init(void) {}
|
||||
bool back_button_check(void) { return false; }
|
||||
back_button_result_t back_button_check(void) { return BACK_BUTTON_NOT_PRESSED; }
|
||||
|
||||
#endif
|
||||
|
||||
@@ -12,9 +12,23 @@
|
||||
*/
|
||||
void back_button_init(void);
|
||||
|
||||
typedef enum {
|
||||
BACK_BUTTON_NOT_PRESSED,
|
||||
/** A short press -- same immediate-response reasoning as the
|
||||
* next-photo button. */
|
||||
BACK_BUTTON_SHORT_PRESS,
|
||||
/** Held past the configured hold duration (see
|
||||
* wifi_provisioning.h's frame_config_get_hold_duration_ms) --
|
||||
* triggers a frame-wide action instead (see
|
||||
* server/app/global_actions.py, frame_client.h's FETCH_GLOBAL_BACK).
|
||||
* Fires immediately at the threshold, without waiting for release. */
|
||||
BACK_BUTTON_HOLD,
|
||||
} back_button_result_t;
|
||||
|
||||
/**
|
||||
* Returns whether the back-photo button is currently held, debounced with
|
||||
* a couple of short re-checks to reject noise. Same immediate-response
|
||||
* reasoning as the next-photo button -- no long hold-to-confirm gate.
|
||||
* Checks the back-photo button and, if it's pressed at all, blocks
|
||||
* polling its level until either it's released (BACK_BUTTON_SHORT_PRESS)
|
||||
* or the hold duration elapses (BACK_BUTTON_HOLD) -- same pattern as
|
||||
* next_button_check(). Evaluated once per wake.
|
||||
*/
|
||||
bool back_button_check(void);
|
||||
back_button_result_t back_button_check(void);
|
||||
|
||||
+30
-7
@@ -1,3 +1,5 @@
|
||||
#include <stdlib.h>
|
||||
|
||||
#include "driver/gpio.h"
|
||||
#include "esp_adc/adc_cali_scheme.h"
|
||||
#include "esp_adc/adc_oneshot.h"
|
||||
@@ -11,7 +13,13 @@ static const char *TAG = "battery";
|
||||
#if CONFIG_FRAME_BATTERY_ADC_GPIO >= 0
|
||||
|
||||
#define BATTERY_ADC_GPIO CONFIG_FRAME_BATTERY_ADC_GPIO
|
||||
#define BATTERY_SAMPLES 8
|
||||
#define BATTERY_SAMPLES 16
|
||||
/* Trimmed mean: the extreme BATTERY_TRIM samples on each end (regulator/
|
||||
* RF transients, not the true resting voltage) are dropped before
|
||||
* averaging the rest -- a plain average lets even one or two of those
|
||||
* skew the result enough to read as a real percent change downstream
|
||||
* (see the recharge-jump handling in routers/device.py). */
|
||||
#define BATTERY_TRIM 3
|
||||
/* The external divider halves the battery voltage (2x200k, per the
|
||||
* Seeed-documented XIAO wiring) so a full 4.2V cell reads ~2.1V at the
|
||||
* pin, inside the 12dB-attenuation ADC range. */
|
||||
@@ -34,6 +42,11 @@ static const struct {
|
||||
{ 3300, 5 }, { 3000, 0 },
|
||||
};
|
||||
|
||||
static int int_cmp(const void *a, const void *b)
|
||||
{
|
||||
return *(const int *)a - *(const int *)b;
|
||||
}
|
||||
|
||||
static int mv_to_percent(int mv)
|
||||
{
|
||||
int n = sizeof(LIPO_CURVE) / sizeof(LIPO_CURVE[0]);
|
||||
@@ -139,19 +152,17 @@ int battery_read_percent(void)
|
||||
ESP_LOGW(TAG, "ADC calibration unavailable, using nominal scaling");
|
||||
}
|
||||
|
||||
int mv_sum = 0;
|
||||
int mv_samples[BATTERY_SAMPLES];
|
||||
int samples = 0;
|
||||
for (int i = 0; i < BATTERY_SAMPLES; i++) {
|
||||
int value;
|
||||
if (calibrated) {
|
||||
if (adc_oneshot_get_calibrated_result(adc, cali, channel, &value) == ESP_OK) {
|
||||
mv_sum += value;
|
||||
samples++;
|
||||
mv_samples[samples++] = value;
|
||||
}
|
||||
} else {
|
||||
if (adc_oneshot_read(adc, channel, &value) == ESP_OK) {
|
||||
mv_sum += value * 3300 / 4095; /* nominal 12-bit full scale at 12dB */
|
||||
samples++;
|
||||
mv_samples[samples++] = value * 3300 / 4095; /* nominal 12-bit full scale at 12dB */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,7 +178,19 @@ int battery_read_percent(void)
|
||||
return -1;
|
||||
}
|
||||
|
||||
int battery_mv = (mv_sum / samples) * BATTERY_DIVIDER_RATIO;
|
||||
/* Only trim if there's enough left afterward to still be a
|
||||
* meaningful average -- falls back to a plain average of whatever
|
||||
* came in on a wake where most reads failed. */
|
||||
qsort(mv_samples, samples, sizeof(int), int_cmp);
|
||||
int trim = (samples > 2 * BATTERY_TRIM) ? BATTERY_TRIM : 0;
|
||||
int mv_sum = 0;
|
||||
int kept = 0;
|
||||
for (int i = trim; i < samples - trim; i++) {
|
||||
mv_sum += mv_samples[i];
|
||||
kept++;
|
||||
}
|
||||
|
||||
int battery_mv = (mv_sum / kept) * BATTERY_DIVIDER_RATIO;
|
||||
if (battery_mv < BATTERY_MV_MIN || battery_mv > BATTERY_MV_MAX) {
|
||||
ESP_LOGI(TAG, "Reading %dmV outside plausible battery range, ignoring", battery_mv);
|
||||
return -1;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "driver/gpio.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_sleep.h"
|
||||
#include "soc/soc_caps.h"
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
@@ -31,10 +32,17 @@ void combo_button_init(void)
|
||||
};
|
||||
gpio_config(&io_conf);
|
||||
|
||||
#if SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP
|
||||
/* See next_button.c for why this API (not ext1) -- it manages the
|
||||
* pull resistor across the sleep transition itself, so the pin
|
||||
* doesn't float and wake the device spuriously. */
|
||||
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << COMBO_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
|
||||
#else
|
||||
/* See next_button.c for why ext1 is safe here on targets without the
|
||||
* API above (e.g. ESP32-S3), and why _io() needs no cross-file mask
|
||||
* coordination. */
|
||||
ESP_ERROR_CHECK(esp_sleep_enable_ext1_wakeup_io(1ULL << COMBO_BUTTON_GPIO, ESP_EXT1_WAKEUP_ANY_LOW));
|
||||
#endif
|
||||
}
|
||||
|
||||
bool combo_button_check(void)
|
||||
@@ -48,13 +56,17 @@ bool combo_button_check(void)
|
||||
* caused the wake even if it's since been released -- in which case
|
||||
* the poll loop below simply measures 0ms held, correctly resolving
|
||||
* to a quick press rather than "not pressed at all." */
|
||||
#if SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP
|
||||
bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << COMBO_BUTTON_GPIO);
|
||||
#else
|
||||
bool caused_wake = esp_sleep_get_ext1_wakeup_status() & (1ULL << COMBO_BUTTON_GPIO);
|
||||
#endif
|
||||
if (!caused_wake && gpio_get_level(COMBO_BUTTON_GPIO) != 0) {
|
||||
return false; /* not pressed, and didn't cause this wake either */
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Combo button held -- quick press for menu, %dms for soft reset, %dms for factory reset",
|
||||
CONFIG_FRAME_COMBO_SOFT_RESET_HOLD_MS, CONFIG_FRAME_COMBO_FACTORY_RESET_HOLD_MS);
|
||||
ESP_LOGI(TAG, "Combo button held -- quick press for soft reset, %dms for menu, %dms for factory reset",
|
||||
CONFIG_FRAME_COMBO_MENU_HOLD_MS, CONFIG_FRAME_COMBO_FACTORY_RESET_HOLD_MS);
|
||||
|
||||
int elapsed_ms = 0;
|
||||
while (gpio_get_level(COMBO_BUTTON_GPIO) == 0) {
|
||||
@@ -70,13 +82,13 @@ bool combo_button_check(void)
|
||||
}
|
||||
}
|
||||
|
||||
if (elapsed_ms >= CONFIG_FRAME_COMBO_SOFT_RESET_HOLD_MS) {
|
||||
ESP_LOGW(TAG, "Held %dms and released, soft-restarting (config kept)", elapsed_ms);
|
||||
esp_restart();
|
||||
if (elapsed_ms >= CONFIG_FRAME_COMBO_MENU_HOLD_MS) {
|
||||
ESP_LOGI(TAG, "Held %dms and released, showing management menu", elapsed_ms);
|
||||
return true;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Quick press (%dms), showing management menu", elapsed_ms);
|
||||
return true;
|
||||
ESP_LOGW(TAG, "Quick press (%dms), soft-restarting (config kept)", elapsed_ms);
|
||||
esp_restart();
|
||||
}
|
||||
|
||||
bool combo_button_is_pressed(void)
|
||||
|
||||
@@ -16,11 +16,11 @@ void combo_button_init(void);
|
||||
* Checks the combined menu/reset button and acts on how long it was
|
||||
* held, evaluated once per wake:
|
||||
* - Not pressed: returns false immediately.
|
||||
* - Released before CONFIG_FRAME_COMBO_SOFT_RESET_HOLD_MS (a quick
|
||||
* press): returns true -- caller should show the management menu.
|
||||
* - Released between the soft-reset and factory-reset thresholds: a
|
||||
* soft reset (esp_restart(), stored WiFi/server config kept) --
|
||||
* - Released before CONFIG_FRAME_COMBO_MENU_HOLD_MS (a quick press):
|
||||
* a soft reset (esp_restart(), stored WiFi/server config kept) --
|
||||
* never returns.
|
||||
* - Released between the menu and factory-reset thresholds: returns
|
||||
* true -- caller should show the management menu.
|
||||
* - Held through CONFIG_FRAME_COMBO_FACTORY_RESET_HOLD_MS: a factory
|
||||
* reset (frame_config_clear() + esp_restart(), fires immediately
|
||||
* without waiting for release) -- never returns.
|
||||
|
||||
@@ -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
|
||||
@@ -3,10 +3,10 @@
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#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);
|
||||
|
||||
/**
|
||||
|
||||
+103
-335
@@ -14,12 +14,12 @@
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/event_groups.h"
|
||||
|
||||
#include "epd7in3e.h"
|
||||
#include "epd_board.h"
|
||||
#include "status_screen.h"
|
||||
#include "manage_qr_overlay.h"
|
||||
#include "combo_button.h"
|
||||
#include "ota_update.h"
|
||||
#include "board_antenna.h"
|
||||
#include "battery.h"
|
||||
|
||||
#include "frame_client.h"
|
||||
|
||||
@@ -95,18 +95,15 @@ static void save_wifi_cache(esp_netif_t *netif)
|
||||
}
|
||||
|
||||
/* Builds a full URL from cfg->toolsserver + a path (no leading slash),
|
||||
* appending cfg->access_token as ?token= if one's set. toolsserver is
|
||||
* normally a bare "host:port", defaulting to plain http; it may instead
|
||||
* carry an explicit "http://" or "https://" prefix to pick the scheme,
|
||||
* e.g. "https://frame.example.com" if a reverse proxy is terminating
|
||||
* TLS in front of the tools server. Every URL carries ?id= (the device's
|
||||
* MAC-derived identity -- how a multi-frame server tells frames apart
|
||||
* and how an unknown frame self-registers) plus &token=: the server-
|
||||
* issued per-frame device token once one has been delivered via
|
||||
* /frame/config, else the provisioned access token (the legacy shared
|
||||
* secret, also what a pre-multi-frame server still expects). This is
|
||||
* the one chokepoint all requests go through, so every caller gets both
|
||||
* for free instead of needing to remember to add them. */
|
||||
* appending cfg->device_token as &token= once one's been delivered.
|
||||
* toolsserver is normally a bare "host:port", defaulting to plain http;
|
||||
* it may instead carry an explicit "http://" or "https://" prefix to
|
||||
* pick the scheme, e.g. "https://frame.example.com" if a reverse proxy
|
||||
* is terminating TLS in front of the tools server. Every URL carries
|
||||
* ?id= (the device's MAC-derived identity -- how a multi-frame server
|
||||
* tells frames apart and how an unknown frame self-registers) plus
|
||||
* &token=. This is the one chokepoint all requests go through, so every
|
||||
* caller gets both for free instead of needing to remember to add them. */
|
||||
static void build_url(char *out, size_t out_size, const frame_config_t *cfg, const char *path)
|
||||
{
|
||||
const char *toolsserver = cfg->toolsserver;
|
||||
@@ -123,9 +120,8 @@ static void build_url(char *out, size_t out_size, const frame_config_t *cfg, con
|
||||
len += (size_t)snprintf(out + len, out_size - len, "?id=%s", device_id);
|
||||
}
|
||||
|
||||
const char *token = cfg->device_token[0] != '\0' ? cfg->device_token : cfg->access_token;
|
||||
if (token[0] != '\0' && len < out_size) {
|
||||
snprintf(out + len, out_size - len, "&token=%s", token);
|
||||
if (cfg->device_token[0] != '\0' && len < out_size) {
|
||||
snprintf(out + len, out_size - len, "&token=%s", cfg->device_token);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,6 +272,14 @@ esp_err_t frame_wifi_connect_sta(const frame_config_t *cfg)
|
||||
typedef struct {
|
||||
bool reachable;
|
||||
uint32_t refresh_interval_s; /* CONFIG_FRAME_SLEEP_INTERVAL_S if absent/unparseable */
|
||||
/* How long NEXT/BACK must be held to trigger a global action instead
|
||||
* of a short press (see next_button.h/back_button.h) --
|
||||
* CONFIG_FRAME_HOLD_ACTION_MS if absent/unparseable (older server) or
|
||||
* unreachable. Persisted via frame_config_set_hold_duration_ms() for
|
||||
* the *next* boot's button-hold decision -- this fetch happens too
|
||||
* late in the cycle for its own boot's decision, see that function's
|
||||
* own doc comment. */
|
||||
uint32_t hold_duration_ms;
|
||||
char firmware_version[32]; /* server's uploaded OTA image version; empty if none/unreachable */
|
||||
/* Per-frame token the server pushes until this device has
|
||||
* authenticated with it once; empty when absent. Persisted via
|
||||
@@ -370,6 +374,7 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
|
||||
frame_server_config_t result = {
|
||||
.reachable = false,
|
||||
.refresh_interval_s = CONFIG_FRAME_SLEEP_INTERVAL_S,
|
||||
.hold_duration_ms = CONFIG_FRAME_HOLD_ACTION_MS,
|
||||
};
|
||||
result.firmware_version[0] = '\0';
|
||||
result.device_token[0] = '\0';
|
||||
@@ -419,252 +424,66 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
|
||||
ESP_LOGW(TAG, "'%s' response missing refresh_interval_s, using fallback %ds", url,
|
||||
(int)result.refresh_interval_s);
|
||||
}
|
||||
uint32_t hold_ms;
|
||||
if (json_extract_uint(body, "hold_duration_ms", &hold_ms)) {
|
||||
result.hold_duration_ms = hold_ms;
|
||||
}
|
||||
json_extract_string(body, "firmware_version", result.firmware_version, sizeof(result.firmware_version));
|
||||
json_extract_string(body, "device_token", result.device_token, sizeof(result.device_token));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/* GETs the server's /frame/photo-info for the manage-button overlay:
|
||||
* location/taken_at text (left empty if the server didn't have them --
|
||||
* e.g. no GPS EXIF to geocode, or no capture date) and a share_url built
|
||||
* from the returned asset_id, same construction pattern as
|
||||
* run_fetch_cycle()'s management_url. Any failure (unreachable, no
|
||||
* current photo, etc.) just leaves all outputs empty -- the caller
|
||||
* treats that as "skip these optional overlay regions", not a hard
|
||||
* error, since the base "scan to manage" QR should still show. */
|
||||
static void fetch_photo_info(const frame_config_t *cfg, char *location_line1, size_t location_line1_size,
|
||||
char *location_line2, size_t location_line2_size, char *taken_at, size_t taken_at_size,
|
||||
char *share_url, size_t share_url_size)
|
||||
{
|
||||
location_line1[0] = '\0';
|
||||
location_line2[0] = '\0';
|
||||
taken_at[0] = '\0';
|
||||
share_url[0] = '\0';
|
||||
|
||||
char url[256];
|
||||
build_url(url, sizeof(url), cfg, "frame/photo-info");
|
||||
|
||||
/* CONFIG_FRAME_FETCH_TIMEOUT_MS, not the shorter SERVER_CHECK one:
|
||||
* unlike fetch_frame_config() (always called after the image fetch
|
||||
* has already warmed the connection, see frame_client_run()), this
|
||||
* is the *first* network call of the wake cycle whenever the manage
|
||||
* menu is opened -- same cold-connection latency spike that made
|
||||
* the short timeout unreliable for /frame/config before, now worse
|
||||
* with a real TLS handshake on top. Confirmed on hardware: this
|
||||
* timed out under CONFIG_FRAME_SERVER_CHECK_TIMEOUT_MS while the
|
||||
* rest of the cycle (a fresh connection, but not the *first* one)
|
||||
* succeeded fine. */
|
||||
esp_http_client_config_t config = {
|
||||
.url = url,
|
||||
.method = HTTP_METHOD_GET,
|
||||
.timeout_ms = CONFIG_FRAME_FETCH_TIMEOUT_MS,
|
||||
.crt_bundle_attach = esp_crt_bundle_attach,
|
||||
};
|
||||
esp_http_client_handle_t client = esp_http_client_init(&config);
|
||||
|
||||
esp_err_t err = esp_http_client_open(client, 0);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "'%s' not reachable: %s", url, esp_err_to_name(err));
|
||||
esp_http_client_cleanup(client);
|
||||
return;
|
||||
}
|
||||
|
||||
int status = esp_http_client_fetch_headers(client) >= 0 ? esp_http_client_get_status_code(client) : -1;
|
||||
if (status != 200) {
|
||||
ESP_LOGW(TAG, "'%s' returned HTTP %d", url, status);
|
||||
esp_http_client_close(client);
|
||||
esp_http_client_cleanup(client);
|
||||
return;
|
||||
}
|
||||
|
||||
char body[384];
|
||||
int total = 0;
|
||||
int n;
|
||||
while (total < (int)sizeof(body) - 1 &&
|
||||
(n = esp_http_client_read(client, body + total, sizeof(body) - 1 - total)) > 0) {
|
||||
total += n;
|
||||
}
|
||||
body[total] = '\0';
|
||||
|
||||
esp_http_client_close(client);
|
||||
esp_http_client_cleanup(client);
|
||||
|
||||
json_extract_string(body, "location_line1", location_line1, location_line1_size);
|
||||
json_extract_string(body, "location_line2", location_line2, location_line2_size);
|
||||
json_extract_string(body, "taken_at", taken_at, taken_at_size);
|
||||
|
||||
char asset_id[48];
|
||||
if (json_extract_string(body, "asset_id", asset_id, sizeof(asset_id))) {
|
||||
char path[80];
|
||||
snprintf(path, sizeof(path), "frame/share/%s", asset_id);
|
||||
build_url(share_url, share_url_size, cfg, path);
|
||||
}
|
||||
}
|
||||
|
||||
/* GETs the server's /frame/face-labels for the manage-button's escalated
|
||||
* "level 2" menu -- named-face positions, if Immich has any for the
|
||||
* current photo. Response is a flattened, fixed-slot shape ("count",
|
||||
* then name_0/x_0/y_0, name_1/x_1/y_1, ...) rather than a real JSON
|
||||
* array, read with the same flat-scalar helpers as everywhere else in
|
||||
* this file instead of needing an actual array parser. Any failure
|
||||
* (unreachable, malformed response, etc.) just returns 0 -- named faces
|
||||
* are a "nice to have" addition to the menu, not worth failing it over. */
|
||||
static int fetch_face_labels(const frame_config_t *cfg, manage_face_label_t *out, int max_labels)
|
||||
{
|
||||
char url[256];
|
||||
build_url(url, sizeof(url), cfg, "frame/face-labels");
|
||||
|
||||
/* Same reasoning as fetch_photo_info() -- this is a manage-menu
|
||||
* request too, not a warmed-connection reachability check. */
|
||||
esp_http_client_config_t config = {
|
||||
.url = url,
|
||||
.method = HTTP_METHOD_GET,
|
||||
.timeout_ms = CONFIG_FRAME_FETCH_TIMEOUT_MS,
|
||||
.crt_bundle_attach = esp_crt_bundle_attach,
|
||||
};
|
||||
esp_http_client_handle_t client = esp_http_client_init(&config);
|
||||
|
||||
esp_err_t err = esp_http_client_open(client, 0);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "'%s' not reachable: %s", url, esp_err_to_name(err));
|
||||
esp_http_client_cleanup(client);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int status = esp_http_client_fetch_headers(client) >= 0 ? esp_http_client_get_status_code(client) : -1;
|
||||
if (status != 200) {
|
||||
ESP_LOGW(TAG, "'%s' returned HTTP %d", url, status);
|
||||
esp_http_client_close(client);
|
||||
esp_http_client_cleanup(client);
|
||||
return 0;
|
||||
}
|
||||
|
||||
char body[768];
|
||||
int total = 0;
|
||||
int n;
|
||||
while (total < (int)sizeof(body) - 1 &&
|
||||
(n = esp_http_client_read(client, body + total, sizeof(body) - 1 - total)) > 0) {
|
||||
total += n;
|
||||
}
|
||||
body[total] = '\0';
|
||||
|
||||
esp_http_client_close(client);
|
||||
esp_http_client_cleanup(client);
|
||||
|
||||
uint32_t count = 0;
|
||||
json_extract_uint(body, "count", &count);
|
||||
if ((int)count > max_labels) {
|
||||
count = (uint32_t)max_labels;
|
||||
}
|
||||
|
||||
int found = 0;
|
||||
for (uint32_t i = 0; i < count; i++) {
|
||||
char key[16];
|
||||
snprintf(key, sizeof(key), "name_%u", (unsigned)i);
|
||||
if (!json_extract_string(body, key, out[found].name, sizeof(out[found].name))) {
|
||||
continue;
|
||||
}
|
||||
snprintf(key, sizeof(key), "x_%u", (unsigned)i);
|
||||
uint32_t x;
|
||||
if (!json_extract_uint(body, key, &x)) {
|
||||
continue;
|
||||
}
|
||||
snprintf(key, sizeof(key), "y_%u", (unsigned)i);
|
||||
uint32_t y;
|
||||
if (!json_extract_uint(body, key, &y)) {
|
||||
continue;
|
||||
}
|
||||
out[found].x = (int)x;
|
||||
out[found].y = (int)y;
|
||||
found++;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
esp_http_client_handle_t client;
|
||||
size_t stream_pos; /* running absolute offset into the frame, for overlay splicing */
|
||||
const manage_overlay_set_t *overlay; /* NULL = no overlay this fetch */
|
||||
} http_read_ctx_t;
|
||||
|
||||
/* Splices one overlay region's pixels over the real photo bytes in chunk
|
||||
* wherever chunk's absolute byte range [chunk_start, chunk_start+chunk_len)
|
||||
* within the full frame intersects that region's rectangle. Rows/chunks
|
||||
* outside the region's footprint are left completely untouched.
|
||||
* region->x0 is always even (see manage_qr_overlay.h), so byte_x0 below
|
||||
* is exact. */
|
||||
static void splice_overlay_region(uint8_t *chunk, size_t chunk_len, size_t chunk_start,
|
||||
const manage_overlay_region_t *region)
|
||||
{
|
||||
int byte_x0 = region->x0 / 2;
|
||||
int byte_w = region->w / 2;
|
||||
size_t chunk_end = chunk_start + chunk_len;
|
||||
|
||||
for (int row = region->y0; row < region->y0 + region->h; row++) {
|
||||
size_t row_start = (size_t)row * EPD_BYTES_PER_ROW + (size_t)byte_x0;
|
||||
size_t row_end = row_start + (size_t)byte_w;
|
||||
|
||||
size_t lo = row_start > chunk_start ? row_start : chunk_start;
|
||||
size_t hi = row_end < chunk_end ? row_end : chunk_end;
|
||||
if (lo >= hi) {
|
||||
continue;
|
||||
}
|
||||
|
||||
size_t region_row_offset = (size_t)(row - region->y0) * (size_t)byte_w + (lo - row_start);
|
||||
memcpy(chunk + (lo - chunk_start), region->buf + region_row_offset, hi - lo);
|
||||
}
|
||||
}
|
||||
|
||||
static void splice_overlay(uint8_t *chunk, size_t chunk_len, size_t chunk_start, const manage_overlay_set_t *overlay)
|
||||
{
|
||||
for (int i = 0; i < overlay->count; i++) {
|
||||
splice_overlay_region(chunk, chunk_len, chunk_start, &overlay->regions[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/* Pulls the next chunk straight out of the in-progress HTTP response --
|
||||
* epd_write_frame() calls this to feed the panel without ever holding
|
||||
* the full ~192KB frame in RAM. Splices in ctx->overlay's regions (if
|
||||
* set) as chunks pass through, so the panel driver never needs to know
|
||||
* an overlay exists at all. */
|
||||
* the full ~192KB frame in RAM. Just a plain relay: the manage overlay
|
||||
* (scan-to-manage QR, battery, location/date, share-QR, named face
|
||||
* labels) is composited server-side now (see server/app/manage_overlay.py),
|
||||
* baked into the same image bytes as any other render -- this function,
|
||||
* like the rest of this file, has no idea an overlay exists. */
|
||||
static size_t http_read_fn(uint8_t *chunk, size_t chunk_size, void *ctx_)
|
||||
{
|
||||
http_read_ctx_t *ctx = (http_read_ctx_t *)ctx_;
|
||||
int n = esp_http_client_read(ctx->client, (char *)chunk, (int)chunk_size);
|
||||
if (n <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (ctx->overlay != NULL) {
|
||||
splice_overlay(chunk, (size_t)n, ctx->stream_pos, ctx->overlay);
|
||||
}
|
||||
ctx->stream_pos += (size_t)n;
|
||||
|
||||
return (size_t)n;
|
||||
return n > 0 ? (size_t)n : 0;
|
||||
}
|
||||
|
||||
/* GETs /frame/image (FETCH_NORMAL), or POSTs /frame/advance or
|
||||
* /frame/back to force a move in either direction (FETCH_ADVANCE /
|
||||
* FETCH_BACK -- the next-photo / back-photo buttons), and streams the
|
||||
* response directly into the panel, splicing in overlay's pixels (if
|
||||
* non-NULL) as it streams. 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. */
|
||||
static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t action,
|
||||
const manage_overlay_set_t *overlay)
|
||||
/* GETs /frame/image (FETCH_NORMAL), or POSTs /frame/advance, /frame/back,
|
||||
* /frame/global-next, or /frame/global-back to force a move/action
|
||||
* (FETCH_ADVANCE / FETCH_BACK -- a short press; FETCH_GLOBAL_NEXT /
|
||||
* FETCH_GLOBAL_BACK -- a held press, see next_button.h/back_button.h).
|
||||
* manage=true (the manage button) appends &manage=1, telling the server
|
||||
* 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 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";
|
||||
if (action == FETCH_ADVANCE) {
|
||||
path = "frame/advance";
|
||||
} else if (action == FETCH_BACK) {
|
||||
path = "frame/back";
|
||||
} else if (action == FETCH_GLOBAL_NEXT) {
|
||||
path = "frame/global-next";
|
||||
} else if (action == FETCH_GLOBAL_BACK) {
|
||||
path = "frame/global-back";
|
||||
}
|
||||
|
||||
char url[256];
|
||||
build_url(url, sizeof(url), cfg, path);
|
||||
if (manage) {
|
||||
size_t len = strlen(url);
|
||||
if (len + strlen("&manage=1") < sizeof(url)) {
|
||||
strcpy(url + len, "&manage=1");
|
||||
}
|
||||
}
|
||||
|
||||
esp_http_client_config_t config = {
|
||||
.url = url,
|
||||
@@ -691,7 +510,7 @@ static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t act
|
||||
}
|
||||
ESP_LOGI(TAG, "Fetching frame (%d bytes) from '%s'", content_length, url);
|
||||
|
||||
http_read_ctx_t ctx = { .client = client, .overlay = overlay };
|
||||
http_read_ctx_t ctx = { .client = client };
|
||||
uint32_t crc = 0;
|
||||
err = epd_write_frame(http_read_fn, &ctx, &crc);
|
||||
|
||||
@@ -719,8 +538,7 @@ static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t act
|
||||
return err;
|
||||
}
|
||||
|
||||
#define MANAGE_MENU_MAX_LEVEL 2
|
||||
#define MANAGE_MENU_LEVEL_TIMEOUT_MS 30000
|
||||
#define MANAGE_MENU_TIMEOUT_MS 30000
|
||||
#define MANAGE_MENU_POLL_MS 150
|
||||
#define MANAGE_MENU_DEBOUNCE_MS 30
|
||||
|
||||
@@ -752,110 +570,42 @@ static bool wait_for_button_press(uint32_t timeout_ms)
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Builds and shows one level of the manage menu: level 1 is the base
|
||||
* overlay (management QR + location/date/share-QR wherever the server
|
||||
* had that data); level 2 adds named-face labels on top. action only
|
||||
* applies at level 1 -- escalating to level 2 redisplays the same
|
||||
* photo, so it never re-advances/-backs. */
|
||||
static esp_err_t show_menu_level(const frame_config_t *cfg, fetch_action_t action, int level,
|
||||
int battery_percent)
|
||||
/* Runs the manage-button view: fetches once with manage=1 (the server
|
||||
* bakes its whole overlay -- scan-to-manage QR, battery, location/date/
|
||||
* share-QR, every named face label, no more RAM-driven cap on how many --
|
||||
* into the response), shows it, then waits up to 30s for either another
|
||||
* press or the timeout before reverting to a plain fetch. Device stays
|
||||
* awake throughout (doesn't sleep the panel or the chip). Returns
|
||||
* non-ESP_OK only if the manage fetch itself failed; a revert failure
|
||||
* after that is logged but doesn't count as an overall failure --
|
||||
* something was already shown successfully, which was the point of the
|
||||
* button. */
|
||||
static esp_err_t run_management_menu(const frame_config_t *cfg, fetch_action_t action)
|
||||
{
|
||||
char management_url[256];
|
||||
build_url(management_url, sizeof(management_url), cfg, "");
|
||||
|
||||
char location_line1[32];
|
||||
char location_line2[32];
|
||||
char taken_at[32];
|
||||
/* Wider than the other URL buffers in this file: unlike a fixed path,
|
||||
* this one stacks toolsserver (up to 128) + "/frame/share/" + an
|
||||
* asset_id (up to 47) + "?token=" + an access_token (up to 64) --
|
||||
* worst case ~266 bytes, which a 256-byte buffer could silently
|
||||
* truncate the token off of (build_url()'s bounds check avoids an
|
||||
* overflow, but a truncated/dropped token still means the resulting
|
||||
* request just 401s with no obvious cause). */
|
||||
char share_url[320];
|
||||
fetch_photo_info(cfg, location_line1, sizeof(location_line1), location_line2,
|
||||
sizeof(location_line2), taken_at, sizeof(taken_at), share_url, sizeof(share_url));
|
||||
|
||||
manage_face_label_t face_labels[MANAGE_FACE_LABELS_MAX];
|
||||
int face_label_count = 0;
|
||||
if (level >= 2) {
|
||||
face_label_count = fetch_face_labels(cfg, face_labels, MANAGE_FACE_LABELS_MAX);
|
||||
}
|
||||
|
||||
manage_overlay_content_t content = {
|
||||
.management_url = management_url,
|
||||
.location_line1 = location_line1[0] != '\0' ? location_line1 : NULL,
|
||||
.location_line2 = location_line2[0] != '\0' ? location_line2 : NULL,
|
||||
.taken_at = taken_at[0] != '\0' ? taken_at : NULL,
|
||||
.share_url = share_url[0] != '\0' ? share_url : NULL,
|
||||
.face_labels = face_labels,
|
||||
.face_label_count = face_label_count,
|
||||
.battery_percent = battery_percent,
|
||||
};
|
||||
|
||||
manage_overlay_set_t overlay;
|
||||
esp_err_t err = manage_overlay_render(&content, &overlay);
|
||||
esp_err_t err = fetch_and_display(cfg, action, true);
|
||||
if (err != ESP_OK) {
|
||||
manage_overlay_free(&overlay);
|
||||
return err;
|
||||
ESP_LOGW(TAG, "Could not fetch manage view (%s), showing photo normally", esp_err_to_name(err));
|
||||
return fetch_and_display(cfg, action, false);
|
||||
}
|
||||
|
||||
err = fetch_and_display(cfg, action, &overlay);
|
||||
manage_overlay_free(&overlay);
|
||||
return err;
|
||||
}
|
||||
ESP_LOGI(TAG, "Showing manage view, waiting up to 30s");
|
||||
wait_for_button_press(MANAGE_MENU_TIMEOUT_MS);
|
||||
|
||||
/* Runs the manage-button menu: level 1 (the base overlay) shows first;
|
||||
* from there, each further press within 30s escalates one level (up to
|
||||
* MANAGE_MENU_MAX_LEVEL, which adds named-face labels), and a press once
|
||||
* already at the max level exits immediately instead of escalating
|
||||
* further. A 30s timeout at any level also exits. Device stays awake
|
||||
* throughout (doesn't sleep the panel or the chip). Returns non-ESP_OK
|
||||
* only if the very first (level 1) render/fetch failed; failures after
|
||||
* that (escalating, or the final revert) are logged but don't count as
|
||||
* an overall failure -- something was already shown successfully, which
|
||||
* was the point of the button. */
|
||||
static esp_err_t run_management_menu(const frame_config_t *cfg, fetch_action_t action, int battery_percent)
|
||||
{
|
||||
int level = 1;
|
||||
esp_err_t err = show_menu_level(cfg, action, level, battery_percent);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Could not render management overlay (%s), showing photo normally", esp_err_to_name(err));
|
||||
return fetch_and_display(cfg, action, NULL);
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
ESP_LOGI(TAG, "Showing management menu level %d, waiting up to 30s", level);
|
||||
bool pressed = wait_for_button_press(MANAGE_MENU_LEVEL_TIMEOUT_MS);
|
||||
if (!pressed || level >= MANAGE_MENU_MAX_LEVEL) {
|
||||
break; /* timeout at any level, or a press while already maxed out -- exit */
|
||||
}
|
||||
level++;
|
||||
esp_err_t level_err = show_menu_level(cfg, FETCH_NORMAL, level, battery_percent);
|
||||
if (level_err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Could not render menu level %d (%s), reverting", level, esp_err_to_name(level_err));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t revert_err = fetch_and_display(cfg, FETCH_NORMAL, NULL);
|
||||
esp_err_t revert_err = fetch_and_display(cfg, FETCH_NORMAL, false);
|
||||
if (revert_err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Failed to revert management overlay (%s)", esp_err_to_name(revert_err));
|
||||
ESP_LOGW(TAG, "Failed to revert manage view (%s)", esp_err_to_name(revert_err));
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* Runs the appropriate fetch for this cycle: a plain fetch, or -- if
|
||||
* show_management_qr -- the escalating manage menu (see
|
||||
* run_management_menu()). */
|
||||
static esp_err_t run_fetch_cycle(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr,
|
||||
int battery_percent)
|
||||
* show_management_qr -- the manage view (see run_management_menu()). */
|
||||
static esp_err_t run_fetch_cycle(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr)
|
||||
{
|
||||
if (!show_management_qr) {
|
||||
return fetch_and_display(cfg, action, NULL);
|
||||
return fetch_and_display(cfg, action, false);
|
||||
}
|
||||
return run_management_menu(cfg, action, battery_percent);
|
||||
return run_management_menu(cfg, action);
|
||||
}
|
||||
|
||||
/* Reports the battery percent to the server (POST /frame/battery).
|
||||
@@ -899,8 +649,7 @@ static void report_battery(const frame_config_t *cfg, int percent)
|
||||
esp_http_client_cleanup(client);
|
||||
}
|
||||
|
||||
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr,
|
||||
int battery_percent)
|
||||
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr)
|
||||
{
|
||||
esp_err_t epd_err = epd_init();
|
||||
bool have_display = (epd_err == ESP_OK);
|
||||
@@ -934,14 +683,15 @@ void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool sho
|
||||
* worth it to stop false-failing on the common case. */
|
||||
bool image_ok = true;
|
||||
if (have_display) {
|
||||
esp_err_t fetch_err = run_fetch_cycle(cfg, action, show_management_qr, battery_percent);
|
||||
esp_err_t fetch_err = run_fetch_cycle(cfg, action, show_management_qr);
|
||||
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
|
||||
@@ -969,9 +719,27 @@ void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool sho
|
||||
* normal boot, not just the one right after an update). */
|
||||
esp_ota_mark_app_valid_cancel_rollback();
|
||||
|
||||
/* Read now, not at boot: the photo (and, if shown, the manage
|
||||
* overlay -- entirely server-composited now, using the server's
|
||||
* own last-known battery value, not a local reading, see
|
||||
* server/app/manage_overlay.py) is already on the panel, so
|
||||
* there's no display deadline to beat.
|
||||
* Reading here instead of right after waking sidesteps taking the
|
||||
* ADC sample while the rail's still settling from whatever the
|
||||
* boot/reset just did, with no need to guess a settle delay --
|
||||
* the fetch/display work already done this cycle is the delay.
|
||||
* Still safe re: the battery/button pin sharing (battery.h) --
|
||||
* every button check main.c does happens well before this, at
|
||||
* the very start of boot. */
|
||||
int battery_percent = battery_read_percent();
|
||||
report_battery(cfg, battery_percent);
|
||||
frame_server_config_t server_cfg = fetch_frame_config(cfg);
|
||||
sleep_seconds = server_cfg.reachable ? server_cfg.refresh_interval_s : CONFIG_FRAME_RETRY_INTERVAL_S;
|
||||
if (server_cfg.reachable) {
|
||||
/* For next boot's button-hold decision, not this one -- see
|
||||
* frame_config_get_hold_duration_ms()'s own doc comment. */
|
||||
frame_config_set_hold_duration_ms(server_cfg.hold_duration_ms);
|
||||
}
|
||||
|
||||
/* One-time identity handshake: the server pushes this frame's
|
||||
* own token until we've authenticated with it once. Persist it
|
||||
|
||||
@@ -9,14 +9,19 @@
|
||||
* Which photo-fetch behavior this wake cycle should use -- normally the
|
||||
* idempotent GET /frame/image (the server decides on its own whether to
|
||||
* advance, based on its configured refresh interval, so a plain
|
||||
* wake/reboot never skips a photo just by asking), or POST
|
||||
* /frame/advance / POST /frame/back to force a move in either direction
|
||||
* (the next-photo / back-photo buttons).
|
||||
* wake/reboot never skips a photo just by asking), POST /frame/advance /
|
||||
* POST /frame/back to force a move in either direction (a short press of
|
||||
* the next-photo / back-photo buttons), or POST /frame/global-next /
|
||||
* POST /frame/global-back to run whatever frame-wide action (if any) is
|
||||
* configured for a held press (see next_button.h/back_button.h's
|
||||
* *_HOLD result and app/global_actions.py server-side).
|
||||
*/
|
||||
typedef enum {
|
||||
FETCH_NORMAL,
|
||||
FETCH_ADVANCE,
|
||||
FETCH_BACK,
|
||||
FETCH_GLOBAL_NEXT,
|
||||
FETCH_GLOBAL_BACK,
|
||||
} fetch_action_t;
|
||||
|
||||
/**
|
||||
@@ -33,14 +38,17 @@ esp_err_t frame_wifi_connect_sta(const frame_config_t *cfg);
|
||||
* deep-sleep until the next refresh.
|
||||
*
|
||||
* If show_management_qr is true (the manage button was held), the
|
||||
* displayed photo gets a small "scan to manage" QR overlay in the
|
||||
* top-right corner linking to the server's config page, held for 30
|
||||
* seconds (the device stays awake), then reverted back to the plain
|
||||
* photo before proceeding to the normal sleep-interval logic.
|
||||
* request for that cycle carries &manage=1, and the server bakes its
|
||||
* whole manage overlay (scan-to-manage QR, battery, location/date,
|
||||
* share-QR, named face labels) directly into the image it returns --
|
||||
* see server/app/manage_overlay.py; this device is otherwise unaware
|
||||
* any of that exists, it just displays whatever comes back. Held for 30
|
||||
* seconds (the device stays awake), then reverted back to a plain fetch
|
||||
* before proceeding to the normal sleep-interval logic.
|
||||
*
|
||||
* battery_percent (0-100, or -1 for "no reading" -- see
|
||||
* battery_read_percent()) is shown on the management menu overlay and
|
||||
* reported to the server after a successful fetch; -1 skips both.
|
||||
* Reads the battery (see battery_read_percent()) itself, once, after the
|
||||
* photo is already on the panel, and reports it to the server on a
|
||||
* successful fetch; a -1 reading ("no reading" -- on mains, disabled, or
|
||||
* implausible) skips the report.
|
||||
*/
|
||||
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr,
|
||||
int battery_percent);
|
||||
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr);
|
||||
|
||||
+14
-12
@@ -10,7 +10,6 @@
|
||||
#include "next_button.h"
|
||||
#include "back_button.h"
|
||||
#include "combo_button.h"
|
||||
#include "battery.h"
|
||||
|
||||
static const char *TAG = "main";
|
||||
|
||||
@@ -41,29 +40,32 @@ void app_main(void)
|
||||
back_button_init();
|
||||
combo_button_init();
|
||||
|
||||
bool next_pressed = next_button_check();
|
||||
bool back_pressed = back_button_check();
|
||||
next_button_result_t next_result = next_button_check();
|
||||
back_button_result_t back_result = back_button_check();
|
||||
/* Next takes priority over back if somehow both read pressed at once
|
||||
* (e.g. both held through a power-on) -- an arbitrary but
|
||||
* deterministic tie-break, not expected to matter in practice. */
|
||||
fetch_action_t action = next_pressed ? FETCH_ADVANCE : back_pressed ? FETCH_BACK : FETCH_NORMAL;
|
||||
* deterministic tie-break, not expected to matter in practice. Same
|
||||
* priority applies whether the winning button resolved to a short
|
||||
* press or a hold. */
|
||||
fetch_action_t action;
|
||||
if (next_result != NEXT_BUTTON_NOT_PRESSED) {
|
||||
action = (next_result == NEXT_BUTTON_HOLD) ? FETCH_GLOBAL_NEXT : FETCH_ADVANCE;
|
||||
} else if (back_result != BACK_BUTTON_NOT_PRESSED) {
|
||||
action = (back_result == BACK_BUTTON_HOLD) ? FETCH_GLOBAL_BACK : FETCH_BACK;
|
||||
} else {
|
||||
action = FETCH_NORMAL;
|
||||
}
|
||||
/* Soft-resets or clears config + restarts internally for a medium/
|
||||
* long hold and never returns in those cases -- only returns here
|
||||
* for "not pressed" (false) or "quick press" (true, show the menu). */
|
||||
bool show_management_qr = combo_button_check();
|
||||
|
||||
/* Must come after the button checks: the battery pin is (by design,
|
||||
* on the XIAO board) shared with a button, and the ADC read briefly
|
||||
* takes the pin over -- see battery.h. -1 = no reading (disabled,
|
||||
* on mains, or implausible). */
|
||||
int battery_percent = battery_read_percent();
|
||||
|
||||
frame_config_t cfg;
|
||||
esp_err_t cfg_err = frame_config_load(&cfg);
|
||||
if (cfg_err == ESP_OK) {
|
||||
ESP_LOGI(TAG, "Found stored config for '%s', connecting to home WiFi", cfg.sta_ssid);
|
||||
if (frame_wifi_connect_sta(&cfg) == ESP_OK) {
|
||||
frame_client_run(&cfg, action, show_management_qr, battery_percent);
|
||||
frame_client_run(&cfg, action, show_management_qr);
|
||||
return; /* frame_client_run currently never returns */
|
||||
}
|
||||
ESP_LOGW(TAG, "Could not connect to stored WiFi after %d attempts, falling back to provisioning",
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "esp_check.h"
|
||||
|
||||
#include "epd7in3e.h"
|
||||
#include "epd_draw.h"
|
||||
#include "fonts.h"
|
||||
#include "qrcodegen.h"
|
||||
|
||||
#include "manage_qr_overlay.h"
|
||||
|
||||
static const char *TAG = "manage_qr_overlay";
|
||||
|
||||
#define QR_MAX_VERSION 10
|
||||
#define QR_BUFFER_LEN qrcodegen_BUFFER_LEN_FOR_VERSION(QR_MAX_VERSION)
|
||||
/* Smaller than qr_onboarding.c's QR_MODULE_PX (8) -- these are compact
|
||||
* corner popups, not a full-screen setup step. */
|
||||
#define QR_MODULE_PX 4
|
||||
#define PADDING 16
|
||||
#define QR_TEXT_GAP 8
|
||||
#define LINE_GAP 4
|
||||
/* Distance from the panel's edges to each overlay box. Combined with
|
||||
* EPD_WIDTH/EPD_HEIGHT and each region's forced-even width below, this
|
||||
* guarantees x0 is always even -- required so a region's columns land on
|
||||
* frame byte boundaries (2px/byte) when spliced into the fetch stream. */
|
||||
#define PANEL_MARGIN 20
|
||||
|
||||
typedef enum {
|
||||
CORNER_TOP_LEFT,
|
||||
CORNER_TOP_RIGHT,
|
||||
CORNER_BOTTOM_LEFT,
|
||||
CORNER_BOTTOM_RIGHT,
|
||||
} overlay_corner_t;
|
||||
|
||||
static void position_region(manage_overlay_region_t *region, overlay_corner_t corner)
|
||||
{
|
||||
switch (corner) {
|
||||
case CORNER_TOP_LEFT:
|
||||
region->x0 = PANEL_MARGIN;
|
||||
region->y0 = PANEL_MARGIN;
|
||||
break;
|
||||
case CORNER_TOP_RIGHT:
|
||||
region->x0 = EPD_WIDTH - PANEL_MARGIN - region->w;
|
||||
region->y0 = PANEL_MARGIN;
|
||||
break;
|
||||
case CORNER_BOTTOM_LEFT:
|
||||
region->x0 = PANEL_MARGIN;
|
||||
region->y0 = EPD_HEIGHT - PANEL_MARGIN - region->h;
|
||||
break;
|
||||
case CORNER_BOTTOM_RIGHT:
|
||||
region->x0 = EPD_WIDTH - PANEL_MARGIN - region->w;
|
||||
region->y0 = EPD_HEIGHT - PANEL_MARGIN - region->h;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static void draw_qr(uint8_t *buf, int stride, int width, int height, const uint8_t *qrcode, int origin_x,
|
||||
int origin_y)
|
||||
{
|
||||
int size = qrcodegen_getSize(qrcode);
|
||||
for (int y = 0; y < size; y++) {
|
||||
for (int x = 0; x < size; x++) {
|
||||
epd_color_t color = qrcodegen_getModule(qrcode, x, y) ? EPD_COLOR_BLACK : EPD_COLOR_WHITE;
|
||||
for (int dy = 0; dy < QR_MODULE_PX; dy++) {
|
||||
for (int dx = 0; dx < QR_MODULE_PX; dx++) {
|
||||
epd_draw_pixel_ex(buf, stride, width, height, origin_x + x * QR_MODULE_PX + dx,
|
||||
origin_y + y * QR_MODULE_PX + dy, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* White-padded box with a QR code encoding payload, plus zero, one, or
|
||||
* two centered caption lines beneath it (either may be NULL). Used for
|
||||
* both the top-right "scan to manage" box (two lines) and the
|
||||
* bottom-left share-link box (no lines). */
|
||||
static esp_err_t render_qr_region(const char *payload, const char *line1, const char *line2, overlay_corner_t corner,
|
||||
manage_overlay_region_t *out)
|
||||
{
|
||||
uint8_t temp_buffer[QR_BUFFER_LEN];
|
||||
uint8_t qrcode[QR_BUFFER_LEN];
|
||||
bool ok = qrcodegen_encodeText(payload, temp_buffer, qrcode, qrcodegen_Ecc_MEDIUM, qrcodegen_VERSION_MIN,
|
||||
QR_MAX_VERSION, qrcodegen_Mask_AUTO, true);
|
||||
ESP_RETURN_ON_FALSE(ok, ESP_FAIL, TAG, "QR encoding failed for '%s' (too long for max version)", payload);
|
||||
|
||||
int qr_size = qrcodegen_getSize(qrcode);
|
||||
int qr_px = qr_size * QR_MODULE_PX;
|
||||
|
||||
/* Font24 (32x41px uppercase glyphs) is the only font vendored into
|
||||
* this project -- see components/epaper_fonts. "SCAN TO MANAGE" on
|
||||
* one line would be 448px wide, too wide for a compact corner box,
|
||||
* so it's passed in pre-wrapped across two lines instead. */
|
||||
int text_w = 0;
|
||||
int text_h = 0;
|
||||
if (line1 != NULL) {
|
||||
int w1 = (int)strlen(line1) * Font24.Width;
|
||||
int w2 = line2 != NULL ? (int)strlen(line2) * Font24.Width : 0;
|
||||
text_w = w1 > w2 ? w1 : w2;
|
||||
text_h = QR_TEXT_GAP + Font24.Height + (line2 != NULL ? LINE_GAP + Font24.Height : 0);
|
||||
}
|
||||
|
||||
int content_w = qr_px > text_w ? qr_px : text_w;
|
||||
int content_h = qr_px + text_h;
|
||||
|
||||
int w = content_w + PADDING * 2;
|
||||
int h = content_h + PADDING * 2;
|
||||
w += w % 2; /* keep byte-aligned (2px/byte) */
|
||||
|
||||
int stride = w / 2;
|
||||
uint8_t *buf = malloc((size_t)stride * h);
|
||||
ESP_RETURN_ON_FALSE(buf != NULL, ESP_ERR_NO_MEM, TAG, "Failed to allocate overlay region");
|
||||
memset(buf, (EPD_COLOR_WHITE << 4) | EPD_COLOR_WHITE, (size_t)stride * h);
|
||||
|
||||
int center_x = w / 2;
|
||||
int y = PADDING;
|
||||
draw_qr(buf, stride, w, h, qrcode, center_x - qr_px / 2, y);
|
||||
y += qr_px;
|
||||
if (line1 != NULL) {
|
||||
y += QR_TEXT_GAP;
|
||||
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line1, center_x, y);
|
||||
y += Font24.Height;
|
||||
}
|
||||
if (line2 != NULL) {
|
||||
y += LINE_GAP;
|
||||
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line2, center_x, y);
|
||||
}
|
||||
|
||||
out->buf = buf;
|
||||
out->w = w;
|
||||
out->h = h;
|
||||
position_region(out, corner);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* White-padded box with one or two centered lines of text (line2 may be
|
||||
* NULL) -- used for the top-left location (city + state/country, two
|
||||
* lines rather than cramming both onto one to keep the box from
|
||||
* threatening to overlap the top-right QR box) and the bottom-right
|
||||
* date-taken label (one line). */
|
||||
static esp_err_t render_text_region(const char *line1, const char *line2, overlay_corner_t corner,
|
||||
manage_overlay_region_t *out)
|
||||
{
|
||||
int w1 = (int)strlen(line1) * Font24.Width;
|
||||
int w2 = line2 != NULL ? (int)strlen(line2) * Font24.Width : 0;
|
||||
int text_w = w1 > w2 ? w1 : w2;
|
||||
int text_h = Font24.Height + (line2 != NULL ? LINE_GAP + Font24.Height : 0);
|
||||
|
||||
int w = text_w + PADDING * 2;
|
||||
int h = text_h + PADDING * 2;
|
||||
w += w % 2;
|
||||
|
||||
int stride = w / 2;
|
||||
uint8_t *buf = malloc((size_t)stride * h);
|
||||
ESP_RETURN_ON_FALSE(buf != NULL, ESP_ERR_NO_MEM, TAG, "Failed to allocate overlay region");
|
||||
memset(buf, (EPD_COLOR_WHITE << 4) | EPD_COLOR_WHITE, (size_t)stride * h);
|
||||
|
||||
int center_x = w / 2;
|
||||
int y = PADDING;
|
||||
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line1, center_x, y);
|
||||
if (line2 != NULL) {
|
||||
y += Font24.Height + LINE_GAP;
|
||||
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line2, center_x, y);
|
||||
}
|
||||
|
||||
out->buf = buf;
|
||||
out->w = w;
|
||||
out->h = h;
|
||||
position_region(out, corner);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* Deliberately tighter than PADDING (used for the fixed QR/text corner
|
||||
* boxes) -- these labels sit right next to a face rather than needing
|
||||
* generous QR-scanning margin, and there can be several of them
|
||||
* simultaneously (see MANAGE_FACE_LABELS_MAX's memory-budget note in
|
||||
* the header). */
|
||||
#define FACE_LABEL_PADDING 8
|
||||
#define FACE_LABEL_GAP 4 /* distance from the face's anchor point to the label box */
|
||||
|
||||
/* White-padded single-line name label positioned near an arbitrary
|
||||
* (anchor_x, anchor_y) face position, rather than a fixed corner --
|
||||
* unlike the four corner regions (always in-bounds by construction),
|
||||
* this needs real clamping since a face can be anywhere, including near
|
||||
* an edge. Centered horizontally on the face, placed just below it by
|
||||
* default, flipped above if there's no room below. */
|
||||
static esp_err_t render_face_label_region(const char *name, int anchor_x, int anchor_y, manage_overlay_region_t *out)
|
||||
{
|
||||
int text_w = (int)strlen(name) * Font24.Width;
|
||||
int w = text_w + FACE_LABEL_PADDING * 2;
|
||||
int h = Font24.Height + FACE_LABEL_PADDING * 2;
|
||||
w += w % 2;
|
||||
|
||||
int stride = w / 2;
|
||||
uint8_t *buf = malloc((size_t)stride * h);
|
||||
ESP_RETURN_ON_FALSE(buf != NULL, ESP_ERR_NO_MEM, TAG, "Failed to allocate overlay region");
|
||||
memset(buf, (EPD_COLOR_WHITE << 4) | EPD_COLOR_WHITE, (size_t)stride * h);
|
||||
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, name, w / 2, FACE_LABEL_PADDING);
|
||||
|
||||
int x0 = anchor_x - w / 2;
|
||||
int y0 = anchor_y + FACE_LABEL_GAP;
|
||||
if (y0 + h > EPD_HEIGHT) {
|
||||
y0 = anchor_y - FACE_LABEL_GAP - h; /* no room below -- place above the face instead */
|
||||
}
|
||||
if (x0 < 0) {
|
||||
x0 = 0;
|
||||
} else if (x0 + w > EPD_WIDTH) {
|
||||
x0 = EPD_WIDTH - w;
|
||||
}
|
||||
if (y0 < 0) {
|
||||
y0 = 0;
|
||||
} else if (y0 + h > EPD_HEIGHT) {
|
||||
y0 = EPD_HEIGHT - h;
|
||||
}
|
||||
x0 -= x0 % 2; /* keep byte-aligned (2px/byte) */
|
||||
|
||||
out->buf = buf;
|
||||
out->w = w;
|
||||
out->h = h;
|
||||
out->x0 = x0;
|
||||
out->y0 = y0;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* Battery glyph dimensions -- a static outline (body rectangle + small
|
||||
* terminal nub on the right), deliberately NOT a fill-level graphic. */
|
||||
#define BATTERY_ICON_W 44
|
||||
#define BATTERY_ICON_H 24
|
||||
#define BATTERY_ICON_STROKE 2
|
||||
#define BATTERY_NUB_W 6
|
||||
#define BATTERY_NUB_H 12
|
||||
#define BATTERY_ICON_TEXT_GAP 8
|
||||
#define BATTERY_REGION_GAP 8 /* vertical gap below the manage QR box */
|
||||
|
||||
static void draw_battery_icon(uint8_t *buf, int stride, int width, int height, int x0, int y0)
|
||||
{
|
||||
for (int y = 0; y < BATTERY_ICON_H; y++) {
|
||||
for (int x = 0; x < BATTERY_ICON_W; x++) {
|
||||
bool edge = x < BATTERY_ICON_STROKE || x >= BATTERY_ICON_W - BATTERY_ICON_STROKE ||
|
||||
y < BATTERY_ICON_STROKE || y >= BATTERY_ICON_H - BATTERY_ICON_STROKE;
|
||||
if (edge) {
|
||||
epd_draw_pixel_ex(buf, stride, width, height, x0 + x, y0 + y, EPD_COLOR_BLACK);
|
||||
}
|
||||
}
|
||||
}
|
||||
int nub_y = y0 + (BATTERY_ICON_H - BATTERY_NUB_H) / 2;
|
||||
for (int y = 0; y < BATTERY_NUB_H; y++) {
|
||||
for (int x = 0; x < BATTERY_NUB_W; x++) {
|
||||
epd_draw_pixel_ex(buf, stride, width, height, x0 + BATTERY_ICON_W + x, nub_y + y, EPD_COLOR_BLACK);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* White-padded box with the battery glyph and "NN%" beside it, placed
|
||||
* directly below an already-positioned anchor region (the top-right
|
||||
* manage QR box), right-aligned to the anchor's right edge. */
|
||||
static esp_err_t render_battery_region(int percent, const manage_overlay_region_t *anchor,
|
||||
manage_overlay_region_t *out)
|
||||
{
|
||||
char text[8];
|
||||
snprintf(text, sizeof(text), "%d%%", percent);
|
||||
|
||||
int icon_total_w = BATTERY_ICON_W + BATTERY_NUB_W;
|
||||
int text_w = (int)strlen(text) * Font24.Width;
|
||||
int content_w = icon_total_w + BATTERY_ICON_TEXT_GAP + text_w;
|
||||
int content_h = Font24.Height > BATTERY_ICON_H ? Font24.Height : BATTERY_ICON_H;
|
||||
|
||||
int w = content_w + PADDING * 2;
|
||||
int h = content_h + PADDING * 2;
|
||||
w += w % 2;
|
||||
|
||||
int stride = w / 2;
|
||||
uint8_t *buf = malloc((size_t)stride * h);
|
||||
ESP_RETURN_ON_FALSE(buf != NULL, ESP_ERR_NO_MEM, TAG, "Failed to allocate overlay region");
|
||||
memset(buf, (EPD_COLOR_WHITE << 4) | EPD_COLOR_WHITE, (size_t)stride * h);
|
||||
|
||||
draw_battery_icon(buf, stride, w, h, PADDING, PADDING + (content_h - BATTERY_ICON_H) / 2);
|
||||
epd_draw_text_ex(buf, stride, w, h, &Font24, text, PADDING + icon_total_w + BATTERY_ICON_TEXT_GAP,
|
||||
PADDING + (content_h - Font24.Height) / 2);
|
||||
|
||||
out->buf = buf;
|
||||
out->w = w;
|
||||
out->h = h;
|
||||
out->x0 = anchor->x0 + anchor->w - w;
|
||||
out->x0 -= out->x0 % 2; /* keep byte-aligned (2px/byte) */
|
||||
out->y0 = anchor->y0 + anchor->h + BATTERY_REGION_GAP;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t manage_overlay_render(const manage_overlay_content_t *content, manage_overlay_set_t *out)
|
||||
{
|
||||
out->count = 0;
|
||||
|
||||
esp_err_t err = render_qr_region(content->management_url, "SCAN TO", "MANAGE", CORNER_TOP_RIGHT,
|
||||
&out->regions[out->count]);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
out->count++;
|
||||
|
||||
if (content->battery_percent >= 0 && content->battery_percent <= 100) {
|
||||
/* Anchored below the manage QR box just rendered (regions[0]). */
|
||||
if (render_battery_region(content->battery_percent, &out->regions[0], &out->regions[out->count]) ==
|
||||
ESP_OK) {
|
||||
out->count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (content->location_line1 != NULL && content->location_line1[0] != '\0') {
|
||||
const char *line2 =
|
||||
(content->location_line2 != NULL && content->location_line2[0] != '\0') ? content->location_line2 : NULL;
|
||||
if (render_text_region(content->location_line1, line2, CORNER_TOP_LEFT, &out->regions[out->count]) ==
|
||||
ESP_OK) {
|
||||
out->count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (content->taken_at != NULL && content->taken_at[0] != '\0') {
|
||||
if (render_text_region(content->taken_at, NULL, CORNER_BOTTOM_RIGHT, &out->regions[out->count]) == ESP_OK) {
|
||||
out->count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (content->share_url != NULL && content->share_url[0] != '\0') {
|
||||
if (render_qr_region(content->share_url, "SCAN TO", "DOWNLOAD", CORNER_BOTTOM_LEFT,
|
||||
&out->regions[out->count]) == ESP_OK) {
|
||||
out->count++;
|
||||
}
|
||||
}
|
||||
|
||||
int face_count = content->face_label_count;
|
||||
if (face_count > MANAGE_FACE_LABELS_MAX) {
|
||||
face_count = MANAGE_FACE_LABELS_MAX;
|
||||
}
|
||||
for (int i = 0; content->face_labels != NULL && i < face_count; i++) {
|
||||
const manage_face_label_t *label = &content->face_labels[i];
|
||||
if (label->name[0] == '\0') {
|
||||
continue;
|
||||
}
|
||||
if (render_face_label_region(label->name, label->x, label->y, &out->regions[out->count]) == ESP_OK) {
|
||||
out->count++;
|
||||
}
|
||||
}
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void manage_overlay_free(manage_overlay_set_t *overlay)
|
||||
{
|
||||
for (int i = 0; i < overlay->count; i++) {
|
||||
free(overlay->regions[i].buf);
|
||||
overlay->regions[i].buf = NULL;
|
||||
}
|
||||
overlay->count = 0;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
/* 5 fixed regions (manage QR, battery indicator, location, date, share
|
||||
* QR) plus up to MANAGE_FACE_LABELS_MAX arbitrary-position named-face
|
||||
* labels (see manage_face_label_t below). MANAGE_FACE_LABELS_MAX is
|
||||
* capped small deliberately, not arbitrarily -- each label is its own
|
||||
* malloc'd buffer, and the fixed regions alone already use a meaningful
|
||||
* chunk of the ESP32-C6's limited RAM; this keeps worst-case overlay
|
||||
* memory well clear of what the WiFi/HTTP stack needs alongside it. */
|
||||
#define MANAGE_FACE_LABELS_MAX 4
|
||||
#define MANAGE_OVERLAY_MAX_REGIONS (5 + MANAGE_FACE_LABELS_MAX)
|
||||
|
||||
typedef struct {
|
||||
uint8_t *buf; /* malloc'd (w/2)*h bytes, packed 2px/byte; owned by the region */
|
||||
int x0, y0; /* top-left corner, panel pixel coordinates (x0 is always even) */
|
||||
int w, h; /* pixel dimensions (w is always even) */
|
||||
} manage_overlay_region_t;
|
||||
|
||||
typedef struct {
|
||||
manage_overlay_region_t regions[MANAGE_OVERLAY_MAX_REGIONS];
|
||||
int count;
|
||||
} manage_overlay_set_t;
|
||||
|
||||
typedef struct {
|
||||
char name[16];
|
||||
int x, y; /* anchor point (bottom-center of the face), panel pixel coordinates */
|
||||
} manage_face_label_t;
|
||||
|
||||
typedef struct {
|
||||
const char *management_url; /* top-right QR + "SCAN TO"/"MANAGE" caption -- always shown */
|
||||
const char *location_line1; /* top-left text, line 1 (city); NULL/empty skips this region */
|
||||
const char *location_line2; /* top-left text, line 2 (state/country); NULL/empty is fine if line1 is set */
|
||||
const char *taken_at; /* bottom-right text; NULL/empty skips this region */
|
||||
const char *share_url; /* bottom-left QR + "SCAN TO"/"DOWNLOAD" caption; NULL/empty skips this region */
|
||||
const manage_face_label_t *face_labels; /* named-face labels ("level 2" menu); NULL/empty count skips these */
|
||||
int face_label_count; /* clamped to MANAGE_FACE_LABELS_MAX internally */
|
||||
int battery_percent; /* 0-100 shows an icon + percent below the manage QR; -1 skips it */
|
||||
} manage_overlay_content_t;
|
||||
|
||||
/**
|
||||
* Renders the manage-button overlay: always a "scan to manage" QR in the
|
||||
* top-right corner, plus whichever of location_line1/taken_at/share_url
|
||||
* are non-NULL/non-empty in their own corners (top-left, bottom-right,
|
||||
* bottom-left respectively), plus one region per entry in face_labels
|
||||
* (positioned near that face rather than a fixed corner -- see
|
||||
* render_face_label_region() in the .c file for the clamping logic).
|
||||
* Each region is its own separately malloc'd small buffer (not a full
|
||||
* EPD_FRAME_BYTES frame). A failure rendering the top-right region fails
|
||||
* the whole call; a failure rendering any other region just skips that
|
||||
* region and keeps going. Caller must call manage_overlay_free() on out
|
||||
* regardless of the return value (out->count reflects however many
|
||||
* regions were actually populated).
|
||||
*/
|
||||
esp_err_t manage_overlay_render(const manage_overlay_content_t *content, manage_overlay_set_t *out);
|
||||
|
||||
/** Frees every populated region's buffer in overlay. */
|
||||
void manage_overlay_free(manage_overlay_set_t *overlay);
|
||||
+87
-24
@@ -1,10 +1,15 @@
|
||||
#include <stdint.h>
|
||||
|
||||
#include "driver/gpio.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_sleep.h"
|
||||
#include "soc/soc_caps.h"
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#include "wifi_provisioning.h"
|
||||
|
||||
#include "next_button.h"
|
||||
|
||||
static const char *TAG = "next_button";
|
||||
@@ -14,6 +19,7 @@ static const char *TAG = "next_button";
|
||||
#define NEXT_BUTTON_GPIO ((gpio_num_t)CONFIG_FRAME_NEXT_BUTTON_GPIO)
|
||||
#define NEXT_BUTTON_DEBOUNCE_MS 20
|
||||
#define NEXT_BUTTON_DEBOUNCE_CHECKS 3
|
||||
#define NEXT_BUTTON_POLL_MS 100
|
||||
|
||||
void next_button_init(void)
|
||||
{
|
||||
@@ -31,16 +37,47 @@ void next_button_init(void)
|
||||
};
|
||||
gpio_config(&io_conf);
|
||||
|
||||
/* Not esp_sleep_enable_ext1_wakeup_io(): its internal pull resistors
|
||||
* don't hold once the RTC_PERIPH domain powers down for deep sleep, so
|
||||
* the pin floats and reads spuriously low, waking the device instantly
|
||||
* on every sleep entry (confirmed on hardware). This GPIO-wakeup
|
||||
* variant manages the pull resistor itself across the sleep
|
||||
* transition. */
|
||||
#if SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP
|
||||
/* Not esp_sleep_enable_ext1_wakeup_io() on its own: on a target
|
||||
* without RTC-independent digital pull registers, ext1's internal
|
||||
* pull resistors don't hold once the RTC_PERIPH domain powers down
|
||||
* for deep sleep, so the pin floats and reads spuriously low, waking
|
||||
* the device instantly on every sleep entry (confirmed on hardware,
|
||||
* ESP32-C6). This GPIO-wakeup variant manages the pull resistor
|
||||
* itself across the sleep transition, sidestepping the issue
|
||||
* entirely -- but it only exists on chips with this capability
|
||||
* (currently just ESP32-C6; see the #else below for other targets,
|
||||
* e.g. ESP32-S3). */
|
||||
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << NEXT_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
|
||||
#else
|
||||
/* No HP-periph-powerdown wakeup API here (e.g. ESP32-S3) -- fall
|
||||
* back to ext1, but NOT naively: on every non-original-ESP32 target
|
||||
* (ESP32-S3 included), gpio_pullup_en() -- which the gpio_config()
|
||||
* call above invokes via pull_up_en -- delegates to
|
||||
* rtc_gpio_pullup_en() for RTC-capable pins (confirmed in
|
||||
* esp_driver_gpio's gpio.c: GPIO_RTCIO_ARE_INDEPENDENT is 1 for
|
||||
* every target except the original ESP32, meaning digital and RTC
|
||||
* pull registers are independent hardware and gpio_config() already
|
||||
* routes the pull-up through the RTC pad's own register for these
|
||||
* pins, not just the digital one). That's exactly what was missing
|
||||
* in the ext1 attempt that failed on hardware above -- so on this
|
||||
* target the pull-up already survives the RTC_PERIPH power-down
|
||||
* ext1 wakeup requires, without needing a separate rtc_gpio_*_en()
|
||||
* call here. _io() (not the bare esp_sleep_enable_ext1_wakeup(),
|
||||
* which resets any previously-configured mask) is additive across
|
||||
* this file's, back_button.c's, and combo_button.c's independent
|
||||
* init calls -- confirmed in esp_hw_support's sleep_modes.c -- so no
|
||||
* shared-mask coordination between the three button files is
|
||||
* needed. Still unconfirmed on real EE02 hardware: this avoids the
|
||||
* *documented* failure mode of the earlier ext1 attempt, but that
|
||||
* attempt was never root-caused beyond "confirmed spurious wakeup on
|
||||
* hardware" -- treat this as untested until it's actually run on an
|
||||
* EE02 board. */
|
||||
ESP_ERROR_CHECK(esp_sleep_enable_ext1_wakeup_io(1ULL << NEXT_BUTTON_GPIO, ESP_EXT1_WAKEUP_ANY_LOW));
|
||||
#endif
|
||||
}
|
||||
|
||||
bool next_button_check(void)
|
||||
next_button_result_t next_button_check(void)
|
||||
{
|
||||
/* A quick tap can easily release before this runs (~0.4-0.5s into
|
||||
* boot, confirmed on hardware -- a live gpio_get_level() check here
|
||||
@@ -48,32 +85,58 @@ bool next_button_check(void)
|
||||
* status register is latched at the moment of waking and isn't
|
||||
* cleared until the next sleep entry, so it reliably reflects a tap
|
||||
* regardless of how quickly it was released. */
|
||||
if (esp_sleep_get_gpio_wakeup_status() & (1ULL << NEXT_BUTTON_GPIO)) {
|
||||
ESP_LOGI(TAG, "Next-photo button caused this wake, forcing advance");
|
||||
return true;
|
||||
}
|
||||
#if SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP
|
||||
bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << NEXT_BUTTON_GPIO);
|
||||
#else
|
||||
bool caused_wake = esp_sleep_get_ext1_wakeup_status() & (1ULL << NEXT_BUTTON_GPIO);
|
||||
#endif
|
||||
|
||||
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a fresh
|
||||
* power-on/reflash) -- fall back to a live, debounced level check so
|
||||
* holding the button down while powering on also works. */
|
||||
if (gpio_get_level(NEXT_BUTTON_GPIO) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < NEXT_BUTTON_DEBOUNCE_CHECKS; i++) {
|
||||
vTaskDelay(pdMS_TO_TICKS(NEXT_BUTTON_DEBOUNCE_MS));
|
||||
if (!caused_wake) {
|
||||
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a
|
||||
* fresh power-on/reflash) -- fall back to a live, debounced level
|
||||
* check so holding the button down while powering on also
|
||||
* works. */
|
||||
if (gpio_get_level(NEXT_BUTTON_GPIO) != 0) {
|
||||
return false; /* noise, not a real press */
|
||||
return NEXT_BUTTON_NOT_PRESSED;
|
||||
}
|
||||
for (int i = 0; i < NEXT_BUTTON_DEBOUNCE_CHECKS; i++) {
|
||||
vTaskDelay(pdMS_TO_TICKS(NEXT_BUTTON_DEBOUNCE_MS));
|
||||
if (gpio_get_level(NEXT_BUTTON_GPIO) != 0) {
|
||||
return NEXT_BUTTON_NOT_PRESSED; /* noise, not a real press */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Next-photo button held during power-on, forcing advance");
|
||||
return true;
|
||||
/* Confirmed pressed (either the wake cause, or debounced during
|
||||
* power-on) -- measure how long, same polling pattern as
|
||||
* combo_button.c's own hold-tier detection. Reads the last hold
|
||||
* duration the server reported (persisted from a previous cycle,
|
||||
* see frame_config_get_hold_duration_ms's own doc comment), falling
|
||||
* back to the Kconfig default before the device has ever fetched
|
||||
* one. */
|
||||
uint32_t hold_threshold_ms;
|
||||
if (frame_config_get_hold_duration_ms(&hold_threshold_ms) != ESP_OK) {
|
||||
hold_threshold_ms = CONFIG_FRAME_HOLD_ACTION_MS;
|
||||
}
|
||||
|
||||
uint32_t elapsed_ms = 0;
|
||||
while (gpio_get_level(NEXT_BUTTON_GPIO) == 0) {
|
||||
if (elapsed_ms >= hold_threshold_ms) {
|
||||
ESP_LOGI(TAG, "Next button held past %ums, triggering global hold action",
|
||||
(unsigned)hold_threshold_ms);
|
||||
return NEXT_BUTTON_HOLD;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(NEXT_BUTTON_POLL_MS));
|
||||
elapsed_ms += NEXT_BUTTON_POLL_MS;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Next-photo button short press (%ums), forcing advance", (unsigned)elapsed_ms);
|
||||
return NEXT_BUTTON_SHORT_PRESS;
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
void next_button_init(void) {}
|
||||
bool next_button_check(void) { return false; }
|
||||
next_button_result_t next_button_check(void) { return NEXT_BUTTON_NOT_PRESSED; }
|
||||
|
||||
#endif
|
||||
|
||||
@@ -12,10 +12,27 @@
|
||||
*/
|
||||
void next_button_init(void);
|
||||
|
||||
typedef enum {
|
||||
NEXT_BUTTON_NOT_PRESSED,
|
||||
/** A short press -- advancing a photo is low-stakes and should feel
|
||||
* immediate, so this fires the moment the button releases (or right
|
||||
* away for a wake-triggered press, once it's confirmed not a hold). */
|
||||
NEXT_BUTTON_SHORT_PRESS,
|
||||
/** Held past the configured hold duration (see
|
||||
* wifi_provisioning.h's frame_config_get_hold_duration_ms) --
|
||||
* triggers a frame-wide action instead (see
|
||||
* server/app/global_actions.py, frame_client.h's FETCH_GLOBAL_NEXT).
|
||||
* Fires immediately at the threshold, without waiting for release --
|
||||
* same convention as combo_button.c's factory-reset tier. */
|
||||
NEXT_BUTTON_HOLD,
|
||||
} next_button_result_t;
|
||||
|
||||
/**
|
||||
* Returns whether the next-photo button is currently held, debounced with
|
||||
* a couple of short re-checks to reject noise. No long hold-to-confirm
|
||||
* gate -- advancing a photo is low-stakes and should feel immediate, so
|
||||
* this returns right away either way.
|
||||
* Checks the next-photo button and, if it's pressed at all (either what
|
||||
* caused this wake, per the latched wakeup-status register, or held
|
||||
* through a debounced power-on check), blocks polling its level until
|
||||
* either it's released (NEXT_BUTTON_SHORT_PRESS) or the hold duration
|
||||
* elapses (NEXT_BUTTON_HOLD, returned immediately, not waiting for
|
||||
* release). Evaluated once per wake.
|
||||
*/
|
||||
bool next_button_check(void);
|
||||
next_button_result_t next_button_check(void);
|
||||
|
||||
@@ -36,9 +36,8 @@ static void build_ota_url(char *out, size_t out_size, const frame_config_t *cfg)
|
||||
len += (size_t)snprintf(out + len, out_size - len, "?id=%s", device_id);
|
||||
}
|
||||
|
||||
const char *token = cfg->device_token[0] != '\0' ? cfg->device_token : cfg->access_token;
|
||||
if (token[0] != '\0' && len < out_size) {
|
||||
snprintf(out + len, out_size - len, "&token=%s", token);
|
||||
if (cfg->device_token[0] != '\0' && len < out_size) {
|
||||
snprintf(out + len, out_size - len, "&token=%s", cfg->device_token);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -97,11 +97,6 @@
|
||||
<input type="text" id="toolsserver" name="toolsserver" placeholder="e.g. 192.168.1.50:8080 or https://frame.example.com" maxlength="128" required>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="access_token">Access Token (optional — only for older servers)</label>
|
||||
<input type="text" id="access_token" name="access_token" placeholder="usually blank; current servers issue one automatically" maxlength="64">
|
||||
</div>
|
||||
|
||||
<p style="font-size: 13px; color: #555;">After saving, this page will
|
||||
take you to the server to claim your frame — reconnect to
|
||||
your normal WiFi if it doesn't happen automatically.</p>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -73,20 +73,10 @@ esp_err_t frame_config_load(frame_config_t *out)
|
||||
return pass_err;
|
||||
}
|
||||
|
||||
/* Also optional -- most deployments won't set a server-side
|
||||
* MANAGEMENT_TOKEN at all, in which case this stays empty and the
|
||||
* manage-menu QR just links to the page with no ?token=. */
|
||||
len = sizeof(out->access_token);
|
||||
esp_err_t token_err = nvs_get_str(handle, "access_token", out->access_token, &len);
|
||||
if (token_err != ESP_OK && token_err != ESP_ERR_NVS_NOT_FOUND) {
|
||||
nvs_close(handle);
|
||||
return token_err;
|
||||
}
|
||||
|
||||
/* Optional: absent until the server has pushed a per-frame token
|
||||
* (see frame_config_set_device_token). */
|
||||
len = sizeof(out->device_token);
|
||||
token_err = nvs_get_str(handle, "device_token", out->device_token, &len);
|
||||
esp_err_t token_err = nvs_get_str(handle, "device_token", out->device_token, &len);
|
||||
if (token_err != ESP_OK && token_err != ESP_ERR_NVS_NOT_FOUND) {
|
||||
nvs_close(handle);
|
||||
return token_err;
|
||||
@@ -131,9 +121,6 @@ esp_err_t frame_config_save(const frame_config_t *cfg)
|
||||
if (err == ESP_OK) {
|
||||
err = nvs_set_str(handle, "toolsserver", cfg->toolsserver);
|
||||
}
|
||||
if (err == ESP_OK) {
|
||||
err = nvs_set_str(handle, "access_token", cfg->access_token);
|
||||
}
|
||||
if (err == ESP_OK) {
|
||||
/* Re-provisioning restarts the identity handshake: the server
|
||||
* (possibly a different one now) re-issues a device token when
|
||||
@@ -191,7 +178,6 @@ void frame_config_clear(void)
|
||||
nvs_erase_key(handle, "sta_ssid");
|
||||
nvs_erase_key(handle, "sta_pass");
|
||||
nvs_erase_key(handle, "toolsserver");
|
||||
nvs_erase_key(handle, "access_token");
|
||||
nvs_erase_key(handle, "device_token");
|
||||
nvs_erase_key(handle, "connected_once");
|
||||
nvs_commit(handle);
|
||||
@@ -234,6 +220,29 @@ void frame_config_invalidate_last_display_crc32(void)
|
||||
nvs_close(handle);
|
||||
}
|
||||
|
||||
esp_err_t frame_config_get_hold_duration_ms(uint32_t *out)
|
||||
{
|
||||
nvs_handle_t handle;
|
||||
esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READONLY, &handle);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
err = nvs_get_u32(handle, "hold_ms", out);
|
||||
nvs_close(handle);
|
||||
return err;
|
||||
}
|
||||
|
||||
void frame_config_set_hold_duration_ms(uint32_t ms)
|
||||
{
|
||||
nvs_handle_t handle;
|
||||
if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &handle) != ESP_OK) {
|
||||
return;
|
||||
}
|
||||
nvs_set_u32(handle, "hold_ms", ms);
|
||||
nvs_commit(handle);
|
||||
nvs_close(handle);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------
|
||||
* WiFi fast-connect cache
|
||||
* ---------------------------------------------------------------------- */
|
||||
@@ -429,7 +438,6 @@ static esp_err_t save_config_post_handler(httpd_req_t *req)
|
||||
extract_form_value(body, "ssid", cfg.sta_ssid, sizeof(cfg.sta_ssid));
|
||||
extract_form_value(body, "password", cfg.sta_password, sizeof(cfg.sta_password));
|
||||
extract_form_value(body, "toolsserver", cfg.toolsserver, sizeof(cfg.toolsserver));
|
||||
extract_form_value(body, "access_token", cfg.access_token, sizeof(cfg.access_token));
|
||||
|
||||
if (strlen(cfg.sta_ssid) == 0 || strlen(cfg.toolsserver) == 0) {
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "SSID and Tools Server are required");
|
||||
@@ -443,16 +451,21 @@ static esp_err_t save_config_post_handler(httpd_req_t *req)
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Saved config: ssid='%s' toolsserver='%s' access_token=%s", cfg.sta_ssid, cfg.toolsserver,
|
||||
strlen(cfg.access_token) ? "set" : "none");
|
||||
ESP_LOGI(TAG, "Saved config: ssid='%s' toolsserver='%s'", cfg.sta_ssid, cfg.toolsserver);
|
||||
|
||||
/* The success page hands the browser off to the server's claim page,
|
||||
* carrying this device's id -- how a frame gets linked to a user
|
||||
* account. The ~7s delay covers the phone dropping this softAP (the
|
||||
* device reboots right after this response) and rejoining its normal
|
||||
* WiFi before the redirect fires; the visible link is the fallback
|
||||
* if the phone loses that race. Scheme handling matches
|
||||
* frame_client.c's build_url(): a bare host gets http://. */
|
||||
* account. This page is entirely self-contained (no external
|
||||
* resources) so it renders fully from what we send now, before the
|
||||
* softAP goes away -- a phone mid-load of a remote asset would just
|
||||
* time out once the AP drops. The visible countdown ticks down for
|
||||
* PROVISIONING_COUNTDOWN_S seconds and then redirects; the AP is kept
|
||||
* alive for one second longer than that (see the vTaskDelay below) so
|
||||
* the countdown always finishes, and the phone has that whole window
|
||||
* to rejoin its normal WiFi and let the redirect land on the real
|
||||
* server. "Redirect now" covers a phone that's already reconnected.
|
||||
* Scheme handling matches frame_client.c's build_url(): a bare host
|
||||
* gets http://. */
|
||||
char device_id[FRAME_DEVICE_ID_LEN + 1];
|
||||
frame_device_id_get(device_id, sizeof(device_id));
|
||||
|
||||
@@ -463,25 +476,40 @@ static esp_err_t save_config_post_handler(httpd_req_t *req)
|
||||
}
|
||||
snprintf(claim_url, sizeof(claim_url), "%s%s/claim?device_id=%s", scheme, cfg.toolsserver, device_id);
|
||||
|
||||
char resp[1024];
|
||||
#define PROVISIONING_COUNTDOWN_S 10
|
||||
|
||||
char resp[1536];
|
||||
snprintf(resp, sizeof(resp),
|
||||
"<!doctype html><html><head>"
|
||||
"<meta http-equiv=\"refresh\" content=\"7;url=%s\">"
|
||||
"<style>body{font-family:sans-serif;text-align:center;padding:2em}</style></head>"
|
||||
"<meta http-equiv=\"refresh\" content=\"%d;url=%s\">"
|
||||
"<style>body{font-family:sans-serif;text-align:center;padding:2em}"
|
||||
"#now{display:inline-block;margin-top:1em;padding:.6em 1.2em;"
|
||||
"background:#2563eb;color:#fff;text-decoration:none;border-radius:8px}</style></head>"
|
||||
"<body><h3>Saved — the frame is restarting</h3>"
|
||||
"<p>Reconnect to your normal WiFi. You'll be taken to the claim page "
|
||||
"in a few seconds…</p>"
|
||||
"<p><a href=\"%s\">Continue to claim your frame</a></p>"
|
||||
"<script>setTimeout(function(){location.href=%c%s%c},7000)</script>"
|
||||
"<p>Reconnect to your normal WiFi if it doesn't happen automatically.</p>"
|
||||
"<p>Redirecting you in <span id=\"n\">%d</span> seconds…</p>"
|
||||
"<p><a id=\"now\" href=\"%s\">Redirect now</a></p>"
|
||||
"<script>"
|
||||
"var n=%d,e=document.getElementById('n');"
|
||||
"var t=setInterval(function(){"
|
||||
"n--;if(e)e.textContent=n;"
|
||||
"if(n<=0){clearInterval(t);location.href='%s';}"
|
||||
"},1000);"
|
||||
"</script>"
|
||||
"</body></html>",
|
||||
claim_url, claim_url, '"', claim_url, '"');
|
||||
PROVISIONING_COUNTDOWN_S, claim_url, PROVISIONING_COUNTDOWN_S, claim_url,
|
||||
PROVISIONING_COUNTDOWN_S, claim_url);
|
||||
httpd_resp_set_type(req, "text/html");
|
||||
httpd_resp_send(req, resp, HTTPD_RESP_USE_STRLEN);
|
||||
|
||||
/* Let the response flush to the client before rebooting into STA mode. */
|
||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||
/* Keep the softAP up for the full visible countdown (plus a 1s margin
|
||||
* for the response to flush and the JS timer to fire) before tearing
|
||||
* it down -- see the comment above for why. */
|
||||
vTaskDelay(pdMS_TO_TICKS((PROVISIONING_COUNTDOWN_S + 1) * 1000));
|
||||
esp_restart();
|
||||
|
||||
#undef PROVISIONING_COUNTDOWN_S
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,11 +17,10 @@ typedef struct {
|
||||
char sta_ssid[FRAME_CFG_SSID_MAX_LEN + 1];
|
||||
char sta_password[FRAME_CFG_PASSWORD_MAX_LEN + 1];
|
||||
char toolsserver[FRAME_CFG_SERVER_MAX_LEN + 1];
|
||||
char access_token[FRAME_CFG_TOKEN_MAX_LEN + 1]; /* optional; legacy shared MANAGEMENT_TOKEN */
|
||||
/* Per-frame token issued by the server via GET /frame/config after
|
||||
* this device first introduces itself by id -- preferred over
|
||||
* access_token once present (see frame_client.c's build_url). Not
|
||||
* set at the captive portal; empty until the server pushes one. */
|
||||
* this device first introduces itself by id (see frame_client.c's
|
||||
* build_url). Not set at the captive portal; empty until the server
|
||||
* pushes one. */
|
||||
char device_token[FRAME_CFG_TOKEN_MAX_LEN + 1];
|
||||
} frame_config_t;
|
||||
|
||||
@@ -94,6 +93,27 @@ void frame_config_set_last_display_crc32(uint32_t crc32);
|
||||
*/
|
||||
void frame_config_invalidate_last_display_crc32(void);
|
||||
|
||||
/**
|
||||
* Returns the hold_duration_ms the server most recently reported via GET
|
||||
* /frame/config (see frame_client.c's fetch_frame_config/frame_client_run)
|
||||
* -- how long NEXT/BACK must be held before next_button_check()/
|
||||
* back_button_check() treat it as a hold-for-global-action instead of a
|
||||
* short press. Returns ESP_ERR_NVS_NOT_FOUND if the device has never
|
||||
* fetched one yet (fresh install/factory reset); caller should fall back
|
||||
* to CONFIG_FRAME_HOLD_ACTION_MS in that case.
|
||||
*
|
||||
* Deliberately a *previous* cycle's value: this cycle's own button
|
||||
* decision happens in main.c before WiFi even connects, but
|
||||
* /frame/config isn't fetched until near the end of frame_client_run
|
||||
* (after the image fetch, for connection-warmth/timeout reasons -- see
|
||||
* its own comment) -- so there's no same-cycle fresh value to use yet.
|
||||
*/
|
||||
esp_err_t frame_config_get_hold_duration_ms(uint32_t *out);
|
||||
|
||||
/** Persists the hold duration reported by the server, for the *next*
|
||||
* boot's button-hold decision to use. */
|
||||
void frame_config_set_hold_duration_ms(uint32_t ms);
|
||||
|
||||
/**
|
||||
* Returns this device's provisioning AP identity: a fixed SSID (from
|
||||
* Kconfig) and a password that's generated once on first use and persisted
|
||||
|
||||
@@ -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,
|
||||
|
@@ -0,0 +1,50 @@
|
||||
# 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: which physical button maps to
|
||||
# which logical role (next/back/combo) still isn't confirmed. The
|
||||
# button GPIOs' `range -1 7` constraint (main/Kconfig.projbuild) -- which
|
||||
# used to be hardcoded to the ESP32-C6's deep-sleep-wakeup-capable GPIO
|
||||
# set -- now widens to `range -1 21` under IDF_TARGET_ESP32S3 (the
|
||||
# ESP32-S3's own ext1-wakeup-capable RTC-IO range), so GPIO2/3/5 fit
|
||||
# either way and nothing here needs adjusting on that front. What's
|
||||
# still unconfirmed: (1) the button-to-role mapping above, and (2)
|
||||
# whether the S3 button-wakeup path itself (ext1 + RTC pull-up, see
|
||||
# main/next_button.c) actually avoids the spurious-instant-wakeup bug
|
||||
# that ruled out ext1 on the ESP32-C6 -- that needs real EE02 hardware,
|
||||
# not just a clean compile. FRAME_BATTERY_ADC_GPIO's `range -1 6` is a
|
||||
# separate, still-unwidened concern -- it's the ESP32-C6's ADC-capable
|
||||
# pin set, not a deep-sleep-wakeup range, and out of scope here.
|
||||
@@ -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.
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.2.0
|
||||
1.4.2
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
.venv/
|
||||
*.egg-info/
|
||||
data/
|
||||
render-service/node_modules/
|
||||
.git/
|
||||
+106
-6
@@ -2,18 +2,118 @@ FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# tzdata: python:3.12-slim doesn't include it by default, so the zoneinfo
|
||||
# database backing the web UI's "Timezone" setting (used by "Quiet hours")
|
||||
# would have no named zones to resolve without this -- ZoneInfo() would
|
||||
# raise for anything other than "UTC".
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends tzdata \
|
||||
# tzdata/fonts/Node.js all from Debian's own repo in one layer -- no
|
||||
# external curl/gnupg dance needed (see below for why that changed).
|
||||
# tzdata: python:3.12-slim doesn't include it by default, so the
|
||||
# zoneinfo database backing the web UI's "Timezone" setting (used by
|
||||
# "Quiet hours") would have no named zones to resolve without this --
|
||||
# ZoneInfo() would raise for anything other than "UTC".
|
||||
# fontconfig/fonts-dejavu-core: whiteboard mode's render-service/ (own
|
||||
# README there) needs something to render whiteboard text with.
|
||||
#
|
||||
# Node.js: whiteboard frame mode's render-service/ runs as a second
|
||||
# process in this same container rather than a separate compose service
|
||||
# -- it's a lightweight, stateless, localhost-only sidecar with nothing
|
||||
# worth independently scaling or restarting. Used to be installed via
|
||||
# NodeSource's setup script (Debian's own nodejs package was too old for
|
||||
# jsdom's minimum back when this base image tracked Debian bookworm) --
|
||||
# switched to Debian's own `nodejs`/`npm` packages after NodeSource's
|
||||
# deb.nodesource.com started intermittently 403ing on both its setup_*.x
|
||||
# scripts *and* its GPG key (a live NodeSource-side S3/CDN issue,
|
||||
# confirmed 2026-07-27 by hitting deb.nodesource.com directly -- some
|
||||
# setup_NN.x paths 403, others 200, no consistent pattern, so no
|
||||
# NodeSource-hosted install path could be trusted not to silently break
|
||||
# again). This base image now tracks Debian trixie, whose own `nodejs`
|
||||
# package is 20.19.2 -- inside jsdom 29's stated engines range
|
||||
# (`^20.19.0 || ^22.13.0 || >=24.0.0`) and well above express/resvg-js's
|
||||
# much lower floors -- so there's no longer a version gap to route
|
||||
# around NodeSource for. One less external dependency, and no more
|
||||
# curl-piped-into-bash (that pattern is also what let the NodeSource
|
||||
# failure go undetected here in the first place: `curl -f ... | bash -`
|
||||
# on a 403 hands bash an empty, "successful" script instead of failing
|
||||
# the RUN outright).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
tzdata fontconfig fonts-dejavu-core nodejs npm \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# EXPERIMENTAL. System libs a headless Chromium needs (app/html_render.py, the weather widget's
|
||||
# opt-in "modern" render style), trimmed from Playwright's own full
|
||||
# `install-deps chromium` list to just what a headless (no Xvfb),
|
||||
# Latin-text-plus-emoji use case needs: dropped xvfb (only needed for a
|
||||
# *headed* browser) and the CJK/Cyrillic/Thai locale font packages
|
||||
# (fonts-ipafont-gothic, fonts-wqy-zenhei, fonts-tlwg-loma-otf,
|
||||
# xfonts-cyrillic, xfonts-scalable, fonts-freefont-ttf, fonts-unifont) --
|
||||
# fonts-noto-color-emoji is the one that actually matters here (real
|
||||
# color emoji in the weather icons, vs. WeasyPrint/Pango's monochrome
|
||||
# fallback glyphs in this feature's original spike).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libasound2t64 libatk-bridge2.0-0t64 libatk1.0-0t64 libatspi2.0-0t64 \
|
||||
libcairo2 libcups2t64 libdbus-1-3 libdrm2 libgbm1 libglib2.0-0t64 \
|
||||
libnspr4 libnss3 libpango-1.0-0 libx11-6 libxcb1 libxcomposite1 \
|
||||
libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 \
|
||||
fonts-noto-color-emoji libfontconfig1 libfreetype6 fonts-liberation \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
# Split across several layers rather than one `pip install -r
|
||||
# requirements.txt` -- same Cloudflare single-blob/layer payload-size
|
||||
# limit as render-service's npm installs below. The single combined
|
||||
# layer was measured at ~113MB unpacked, over the limit on its own.
|
||||
# Isolating the largest packages gets every layer's unpacked size well
|
||||
# clear of 100MB (sqlalchemy ~15MB, pillow ~19MB, pypdfium2 ~8MB, the
|
||||
# remaining `-r requirements.txt` layer ~71MB). Each package version here
|
||||
# still comes from requirements.txt (`pip install -r` for everything that
|
||||
# doesn't need its own layer skips these, since pip sees them already
|
||||
# satisfied); the explicit versions below just control *when* each
|
||||
# installs -- same "single source of truth, just splitting *when* it
|
||||
# installs" tradeoff as the npm section's --no-save comment below.
|
||||
RUN pip install --no-cache-dir sqlalchemy==2.0.51
|
||||
RUN pip install --no-cache-dir pillow==12.3.0
|
||||
RUN pip install --no-cache-dir pypdfium2==5.12.1
|
||||
RUN pip install --no-cache-dir playwright==1.61.0
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# The headless Chromium binary itself is deliberately NOT installed
|
||||
# here at build time. `playwright install chromium-headless-shell`
|
||||
# unpacks to ~262MB, and its single `chrome-headless-shell` binary alone
|
||||
# (measured: 181MB) is one file -- unlike the pip/npm splits above
|
||||
# (independently-installable smaller packages moved into their own
|
||||
# layers), a single 181MB file can't be divided across multiple <100MB
|
||||
# Docker layers by any ordinary COPY/RUN restructuring; the whole file
|
||||
# lands in whichever layer's diff contains it, which confirmed-failed
|
||||
# to push to this project's registry (the same Cloudflare single-blob/
|
||||
# layer limit that forced the pip/npm splits elsewhere in this file --
|
||||
# see their comments). Fix: start.sh downloads it at container startup
|
||||
# instead, cached on the /data volume (PLAYWRIGHT_BROWSERS_PATH below)
|
||||
# so it survives restarts/redeploys and only ever downloads once per
|
||||
# volume, not once per image layer. Trade-off: first boot on a fresh
|
||||
# volume needs network access to Playwright's CDN -- true of Immich/
|
||||
# weather API access too, so not a new requirement for this server.
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/data/.playwright-browsers
|
||||
|
||||
# render-service/'s dependencies installed as several separate layers
|
||||
# rather than one `npm install` covering all of them -- a from-scratch
|
||||
# push of this image once hit Cloudflare's payload-size limit on a
|
||||
# single blob/layer upload (the registry sits behind it), and splitting
|
||||
# a big layer into several smaller ones is the direct fix for exactly
|
||||
# that failure mode, independent of anything about the registry itself.
|
||||
# --no-save: package.json already fully declares these (with the exact
|
||||
# same version pins used here) as the single source of truth for what
|
||||
# this service depends on -- these calls are just about *when* each one
|
||||
# gets installed for layer-size reasons, not re-deciding what's needed.
|
||||
COPY render-service/package.json ./render-service/package.json
|
||||
WORKDIR /app/render-service
|
||||
RUN npm install --omit=dev --no-save express@^5.2.1 && npm cache clean --force
|
||||
RUN npm install --omit=dev --no-save jsdom@^29.1.1 && npm cache clean --force
|
||||
RUN npm install --omit=dev --no-save @excalidraw/[email protected] && npm cache clean --force
|
||||
RUN npm install --omit=dev --no-save @resvg/[email protected] && npm cache clean --force
|
||||
WORKDIR /app
|
||||
|
||||
COPY render-service/server.js ./render-service/server.js
|
||||
COPY app ./app
|
||||
COPY start.sh .
|
||||
RUN chmod +x start.sh
|
||||
|
||||
EXPOSE 8420
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8420"]
|
||||
CMD ["./start.sh"]
|
||||
|
||||
+165
-44
@@ -37,26 +37,27 @@ algorithm itself -- it just streams the response straight to the panel.
|
||||
instead (see `firmware/README.md`'s HTTPS section).
|
||||
5. **Each frame gets its own device token automatically** -- the server
|
||||
issues it on the frame's first check-in, so there's nothing to
|
||||
configure. The captive portal's **Access Token** field only matters
|
||||
when pointing new firmware at an old (pre-multi-frame) server.
|
||||
`MANAGEMENT_TOKEN` in `docker-compose.yml` is likewise now only the
|
||||
*migration* credential: a frame flashed with pre-multi-frame
|
||||
firmware authenticates with it until it's updated and bound (the
|
||||
Admin page shows the migration state per frame and a "Close legacy
|
||||
window" button for when it's done).
|
||||
configure. `MANAGEMENT_TOKEN` in `docker-compose.yml` is optional and
|
||||
only matters pre-setup: if set, it's the credential that gates who
|
||||
gets to be the one to run first-run setup on a freshly deployed
|
||||
server, before any admin account exists.
|
||||
6. **Optional: auto-update firmware from Gitea releases.** If you're
|
||||
pushing this repo to a Gitea instance, `.gitea/workflows/firmware-release-build.yml`
|
||||
builds both supported boards and publishes them as release assets
|
||||
(`firmware-xiao.bin`/`firmware-devkit.bin`) whenever `firmware/version.txt`
|
||||
changes on `main`. In a frame's **Configuration** tab, set the
|
||||
**Gitea repo URL**; if the repo is private, also set
|
||||
`GITEA_FIRMWARE_TOKEN` (a read-only PAT) in `docker-compose.yml`.
|
||||
Which board's build to fetch is learned from the frame itself (its
|
||||
`X-Frame-Board` header) -- nothing to pick by hand. The server then
|
||||
periodically checks for a newer release and either shows an "Update
|
||||
frame" button or, with **Automatically apply updates** checked,
|
||||
stages it itself -- either way the frame only actually updates on
|
||||
its own next wake.
|
||||
builds every supported board and publishes them as release assets
|
||||
(`firmware-devkit_esp32c6.bin`/`firmware-xiao_esp32c6.bin`/
|
||||
`firmware-ee02.bin`, plus `firmware-devkit.bin`/`firmware-xiao.bin`
|
||||
duplicates for devices still on pre-rename firmware) whenever
|
||||
`firmware/version.txt` changes on `main`. In a frame's
|
||||
**Configuration** tab, set the **Gitea repo URL**; if the repo is
|
||||
private, also set `GITEA_FIRMWARE_TOKEN` (a read-only PAT) in
|
||||
`docker-compose.yml`. Which board's build to fetch is learned from the
|
||||
frame itself (its `X-Frame-Board` header) -- nothing to pick by hand.
|
||||
The same header also determines which EPD panel the frame renders for
|
||||
(`Frame.panel_type`, see `docs/hardware.md`'s board identifiers
|
||||
section) -- also never a manual setting. The server then periodically
|
||||
checks for a newer release and either shows an "Update frame" button
|
||||
or, with **Automatically apply updates** checked, stages it itself --
|
||||
either way the frame only actually updates on its own next wake.
|
||||
|
||||
## Users, frames, and control
|
||||
|
||||
@@ -79,19 +80,42 @@ algorithm itself -- it just streams the response straight to the panel.
|
||||
view current + upcoming, "show next", advance, back -- nothing else.
|
||||
The share QR stays public (it creates a 30-minute Immich share link
|
||||
for exactly the photo shown).
|
||||
- **Email (optional).** An admin sets an SMTP server once (`/admin` --
|
||||
server, port, username/password, from address, STARTTLS on/off; a
|
||||
"send test email to myself" button, delivered to the admin's own
|
||||
email); each user sets their own email in Settings. Once both are in
|
||||
place: **"Forgot password?"** on the login page emails a one-hour
|
||||
reset link (a generic "check your email" response either way, so the
|
||||
endpoint can't be used to enumerate accounts), and a frame's
|
||||
Configuration tab can set a **battery-alert threshold** -- an email to
|
||||
the frame's owner the first time a report drops to or below it, not
|
||||
again until a recharge is detected and it crosses again. No SMTP
|
||||
configured, or no email on the relevant account, and both features
|
||||
silently no-op rather than erroring.
|
||||
- **Server logs.** `/admin/logs` shows the tail of the process's own
|
||||
log file (`LOG_PATH` env var, default `/data/server.log` -- the same
|
||||
`/data` volume as the database and legacy config, so it survives
|
||||
container restarts/redeploys; `LOG_LEVEL` env var, default `INFO`).
|
||||
Rotates at ~2MB x 3 backups; the page only reads the current file,
|
||||
"Download full log" streams it raw. There's no log shipping/
|
||||
aggregation beyond this -- it's a single-container deployment, so
|
||||
the file *is* the log.
|
||||
|
||||
## Endpoints
|
||||
|
||||
Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
||||
`/admin`, `/frames/{id}` (Photos), `/frames/{id}/config`,
|
||||
`/admin`, `/admin/logs`, `/frames/{id}` (Photos), `/frames/{id}/config`,
|
||||
`/frames/{id}/stats`, `/m/{manage_token}`.
|
||||
|
||||
### Device protocol (`/frame/*` -- paths frozen; auth = `?id=` + `?token=`)
|
||||
|
||||
- `GET /frame/image` -- the frame's current image, pre-processed into
|
||||
the panel's raw 800x480, 4-bit-per-pixel, 2-pixels-per-byte format
|
||||
(`application/octet-stream`, exactly 192,000 bytes). **Side-effect-free**
|
||||
by default: it only actually advances once `refresh_interval_s` has
|
||||
the panel's raw 4-bit-per-pixel, 2-pixels-per-byte format
|
||||
(`application/octet-stream`) -- 800x480/exactly 192,000 bytes for the
|
||||
original 7.3" panel, 1600x1200/exactly 960,000 bytes for the 13.3"
|
||||
panel (see `Frame.panel_type`/`image_pipeline.PANEL_SPECS`; a given
|
||||
device's byte count is fixed by which firmware/panel it actually is).
|
||||
**Side-effect-free** by default: it only actually advances once `refresh_interval_s` has
|
||||
elapsed since the current photo was set, so an unexpected reboot just
|
||||
redisplays the same photo. An unclaimed or not-yet-configured frame
|
||||
gets a rendered instruction placeholder (with a claim QR) instead of
|
||||
@@ -109,15 +133,20 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
||||
this response may grow.
|
||||
- `GET /frame/photo-info` -- location/date overlay text for the manage
|
||||
menu (city + abbreviated US/CAN region or country, `MM/DD/YY`).
|
||||
- `GET /frame/share/{asset_id}` -- creates a 30-minute public Immich
|
||||
share link and 302s to it; scoped to the photo currently showing or
|
||||
queued on *this* frame only.
|
||||
- `GET /frame/face-labels` -- up to 4 named faces with 800x480
|
||||
positions, flattened (`name_0`/`x_0`/`y_0`, ...) for the device's
|
||||
- `GET /frame/share/{manage_token}` -- creates a 30-minute public Immich
|
||||
share link covering every photo widget's currently-showing photo on
|
||||
*this* frame and 302s to it. Authenticated by the frame's own
|
||||
`manage_token` (see the manage QR below), not device credentials -- a
|
||||
phone scanning the QR has no way to supply `?id=`/`?token=`.
|
||||
- `GET /frame/face-labels` -- up to 4 named faces with positions in the
|
||||
frame's own panel space (800x480 for the 7.3" panel, 1600x1200 for the
|
||||
13.3"), flattened (`name_0`/`x_0`/`y_0`, ...) for the device's
|
||||
flat-scalar parser.
|
||||
- `POST /frame/battery` -- `{"percent": 0-100}`; per-discharge-cycle
|
||||
history (feeds the runtime estimate) plus a permanent per-frame
|
||||
battery log (the Stats chart). Only sent on battery power.
|
||||
battery log (the Stats chart). Only sent on battery power. Also where
|
||||
the battery-alert threshold (below) is checked and, at most once per
|
||||
discharge cycle, emailed to the owner.
|
||||
- `GET /frame/firmware` -- streams the frame's staged OTA image.
|
||||
|
||||
### Web API (`/api/frames/{id}/...` -- session auth; *view* for reads, *control* for writes)
|
||||
@@ -133,12 +162,26 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
||||
- `GET .../albums` -- the owner's Immich albums.
|
||||
- `POST .../config` -- **partial** update: only provided fields change
|
||||
(`name`, `album_id` -- resets queue/history on change --, `order`,
|
||||
`refresh_interval_s`, `smart_crop_faces`, `queue_target_len`,
|
||||
`orientation` (composed logically then rotated server-side; the
|
||||
on-device manage overlay still renders native, a known limitation),
|
||||
`quiet_hours_*` + `timezone` (a pure server-side decision shaping
|
||||
what `refresh_interval_s` gets handed to the device),
|
||||
`firmware_update_repo_url`, `firmware_auto_update`).
|
||||
`refresh_interval_s`, `display_mode` (`crop_fill`/`crop_faces`/
|
||||
`stretch_fill`/`letterbox`, see `image_pipeline.DISPLAY_MODES`),
|
||||
`queue_target_len`, `orientation` (composed logically then rotated
|
||||
server-side; the on-device manage overlay still renders native, a
|
||||
known limitation), `quiet_hours_*` + `timezone` (a pure server-side
|
||||
decision shaping what `refresh_interval_s` gets handed to the
|
||||
device), `firmware_update_repo_url`, `firmware_auto_update`,
|
||||
`battery_alert_threshold_pct` -- percent, or `-1`/blank to disable --,
|
||||
`palette` -- exactly 6 `#rrggbb` values in black/white/yellow/red/
|
||||
blue/green order --, `palette_reset` -- `true` clears back to the
|
||||
default palette --, `color_boost`/`contrast_boost` -- PIL
|
||||
`ImageEnhance` factors, 0-2, 1 = unchanged --, `dither_strength` --
|
||||
0-1, blends toward a flat/undithered quantization before running
|
||||
Floyd-Steinberg, so 0 = no dithering texture and 1 = full strength).
|
||||
- `GET .../preview/original`, `GET .../preview/rendered` -- the
|
||||
before/after comparison on the Configuration tab: the current
|
||||
photo's Immich preview untouched (JPEG), and that same photo run
|
||||
through this frame's actual saved rendering pipeline (PNG, upright
|
||||
logical orientation, not packed device bytes) -- reflects saved
|
||||
settings, not unsaved slider positions.
|
||||
- `POST .../take-control` -- always succeeds for a linked user.
|
||||
- `GET .../stats`, `GET .../battery-log`, `GET .../thumbnail/{asset_id}`.
|
||||
- `POST .../firmware` (manual .bin upload, esp_app_desc_t-validated),
|
||||
@@ -172,16 +215,78 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
||||
in the web UI (or using "Show next") only rearranges what's already in
|
||||
that lookahead; it doesn't add or remove photos from the album.
|
||||
- Auth in one breath: browsers use sessions (+CSRF), devices use
|
||||
per-frame tokens (`?id=` + `?token=`), the manage QR uses its own
|
||||
limited token, and `MANAGEMENT_TOKEN` survives only as the migration
|
||||
credential for pre-multi-frame firmware. `/frame/share` stays scoped
|
||||
to photos this frame is actually showing or has queued, not any
|
||||
Immich asset ID someone might guess -- a second layer a leaked device
|
||||
token alone wouldn't bypass.
|
||||
- The 6-color palette RGB values in `app/image_pipeline.py` are
|
||||
approximations, not measured values (Waveshare doesn't publish exact
|
||||
color primaries for this panel) -- tune them once you can compare a
|
||||
rendered test image against the real panel.
|
||||
per-frame tokens (`?id=` + `?token=`), the manage QR and the
|
||||
scan-to-download QR both use the frame's own `manage_token` (device
|
||||
tokens don't work for either -- neither is ever called by firmware,
|
||||
both are opened by a phone that has no way to supply `?id=`/`?token=`),
|
||||
and `MANAGEMENT_TOKEN` is only ever the pre-setup claim gate (see
|
||||
step 5 above).
|
||||
- The calendar widget (`app/calendar_feed.py`) expands recurring events
|
||||
(RRULE/EXDATE/DST) via [`recurring-ical-events`](https://pypi.org/project/recurring-ical-events/),
|
||||
which is LGPL-3.0-or-later -- the only non-permissively-licensed
|
||||
dependency here. It's used as an ordinary `pip install` runtime import,
|
||||
never vendored or modified, so this project's own code stays under its
|
||||
own license; LGPL's copyleft terms apply to that library itself, not
|
||||
to code that merely links against it dynamically.
|
||||
- CalDAV account support (`app/caldav_client.py`, alongside the plain ICS
|
||||
subscription) wraps the `caldav` PyPI package. `caldav` itself is
|
||||
dual-licensed GPL-3.0-or-later/Apache-2.0, but it hard-depends on
|
||||
`icalendar-searcher`, which is **AGPL-3.0-or-later** -- the strongest
|
||||
copyleft in this project's dependency tree, and the one whose
|
||||
network-use clause is written specifically for server applications
|
||||
like this one (not just "don't vendor/modify it," which was enough
|
||||
reasoning for the LGPL dependency above). Taking this on was an
|
||||
explicit, informed call by the project owner, not a default -- anyone
|
||||
redistributing this project (vs. just self-hosting it) should
|
||||
re-evaluate that tradeoff for their own situation before doing so.
|
||||
- The whiteboard widget (`app/webdav_client.py`, `app/whiteboard.py`)
|
||||
fetches a Nextcloud Whiteboard (or any WebDAV server's) `.whiteboard`
|
||||
file -- which turns out to be Excalidraw scene JSON (elements/appState/
|
||||
files), not an image -- and renders it via `render-service/`, a small
|
||||
Node.js sidecar using Excalidraw's own real export code
|
||||
(`@excalidraw/utils`'s `exportToSvg`) plus `@resvg/resvg-js` (a native
|
||||
Rust SVG rasterizer, no headless browser) to turn that into a PNG. That
|
||||
sidecar runs as a **second process inside this same container**
|
||||
(`Dockerfile` installs Node, `start.sh` launches it in the background
|
||||
before `exec`-ing uvicorn), reachable only at `127.0.0.1:3001` from the
|
||||
Python process -- not a second docker-compose service, since it's
|
||||
lightweight, stateless, and has nothing worth independently scaling or
|
||||
restarting. License check (after getting burned once already in this
|
||||
same file, on the CalDAV dependency below, into checking transitive
|
||||
deps and not just top-level ones): Excalidraw, `@excalidraw/utils`,
|
||||
every one of its own runtime dependencies, `@resvg/resvg-js`
|
||||
(MPL-2.0 -- weak/file-level copyleft, doesn't extend to code that just
|
||||
calls into it), `jsdom`, and `express` are all MIT/Apache-2.0/Zlib/
|
||||
MPL-2.0 -- no repeat of the AGPL surprise. **Not runtime-tested against
|
||||
a real `npm install`/`docker build`** -- this project's dev environment
|
||||
has no Node.js/npm, only network access to the npm registry API (used
|
||||
to verify the above and pick real, current dependency versions). See
|
||||
`render-service/README.md` for exactly what is and isn't verified.
|
||||
- Calendar event titles can contain emoji, which `ImageFont.load_default()`
|
||||
(used for every other bit of text this project renders) has no glyphs
|
||||
for -- PIL/FreeType substitute a visible ".notdef" tofu box rather than
|
||||
skipping the codepoint. `app/calendar_render.py` draws emoji runs with
|
||||
a vendored font instead (Google's Noto Emoji, OFL-1.1 -- license text
|
||||
at `app/fonts/OFL.txt`), the one deliberate exception to this project's
|
||||
usual "no new font/icon assets" default elsewhere in calendar_render.py
|
||||
-- there's no way to hand-draw arbitrary emoji with primitives the way
|
||||
the weather icons are. Full color (`app/fonts/NotoColorEmoji.ttf`,
|
||||
embedded CBDT bitmap glyphs) is tried first and confirmed to hold up
|
||||
fine through the panel's own Floyd-Steinberg dithering; a deployment
|
||||
whose Pillow/FreeType wasn't built with embedded color bitmap support
|
||||
falls back to a monochrome outline font (`app/fonts/NotoEmoji.ttf`)
|
||||
instead of crashing or rendering nothing. Color glyphs are only stored
|
||||
at one embedded bitmap size (109px), so they're rasterized once at
|
||||
that size and scaled down to the target row height rather than drawn
|
||||
directly like normal vector text.
|
||||
- The 6-color palette RGB values in `app/image_pipeline.py`
|
||||
(`DEFAULT_PALETTE_RGB`) are approximations, not measured values
|
||||
(Waveshare doesn't publish exact color primaries for this panel).
|
||||
Each frame's Configuration tab has an **Advanced configuration**
|
||||
section (collapsed by default) with a color picker per ink color --
|
||||
tune them once you can compare a rendered photo against the real
|
||||
panel, and "Reset to defaults" to go back. Different panel units can
|
||||
vary enough to be worth calibrating per frame.
|
||||
|
||||
## Deploying a pre-built image
|
||||
|
||||
@@ -213,3 +318,19 @@ CONFIG_PATH=./data/config.json uvicorn app.main:app --reload --host 0.0.0.0 --po
|
||||
`--host 0.0.0.0` matters here: without it, uvicorn defaults to
|
||||
`127.0.0.1` (localhost-only), which the ESP32 can't reach over the LAN.
|
||||
The Docker image already binds `0.0.0.0` by default.
|
||||
|
||||
## Running tests
|
||||
|
||||
```
|
||||
pip install -r requirements-dev.txt
|
||||
pytest
|
||||
```
|
||||
|
||||
Runs against a fresh temp SQLite database (`tests/conftest.py` sets
|
||||
`DATABASE_URL` before anything imports `app.db`), with every table wiped
|
||||
and reseeded (frame #1 + server settings, same as a real fresh install)
|
||||
between tests -- no Docker, Node, or a real Immich/CalDAV/WebDAV server
|
||||
needed; a few tests spin up small local HTTP servers as fixtures to
|
||||
stand in for those. Also runs as its own job in
|
||||
`.gitea/workflows/server-docker-build.yml`, gating the image build/push
|
||||
-- a failing test suite blocks the push, not just decorates it.
|
||||
|
||||
+81
-77
@@ -1,14 +1,18 @@
|
||||
"""Authentication: password hashing, user sessions + CSRF, the legacy
|
||||
shared-token gate, and device resolution.
|
||||
"""Authentication: password hashing, user sessions + CSRF, the pre-setup
|
||||
claim gate, and device resolution.
|
||||
|
||||
Three independent credential classes:
|
||||
- User sessions (cookie "session", server-side sessions table, per-
|
||||
session CSRF token required on mutating requests) -- humans.
|
||||
- The legacy shared MANAGEMENT_TOKEN (env-only). Still accepted on
|
||||
browser routes so the deployed frame's on-panel manage QR (which
|
||||
embeds ?token=) keeps working until Phase C replaces it with the
|
||||
limited /m/ page; CSRF doesn't apply to it (it's explicit per-request
|
||||
credential, not an ambient cookie a cross-site request could ride).
|
||||
- MANAGEMENT_TOKEN (env-only, optional). Only meaningful before any user
|
||||
account exists yet (fresh install, or freshly migrated, before
|
||||
/setup has been run): if set, it gates who gets to be the one to run
|
||||
/setup and claim the first admin account; once a user exists, sessions
|
||||
are the only way in. Not a standing bearer credential -- the on-panel
|
||||
manage QR now embeds a frame's own per-frame manage_token (/m/, see
|
||||
routers/manage.py) rather than this shared one; CSRF doesn't apply to
|
||||
it either way (it's an explicit per-request credential, not an ambient
|
||||
cookie a cross-site request could ride).
|
||||
- Device credentials (?id= + ?token=, see require_device below).
|
||||
"""
|
||||
|
||||
@@ -27,7 +31,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from .db import get_db
|
||||
from .migration import new_device_token, new_manage_token
|
||||
from .models import Frame, PendingClaim, User, UserFrame, UserSession
|
||||
from .models import Frame, PasswordResetToken, PendingClaim, ServerSettings, User, UserFrame, UserSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -35,6 +39,7 @@ MANAGEMENT_TOKEN_COOKIE = "mgmt_token"
|
||||
SESSION_COOKIE = "session"
|
||||
SESSION_LIFETIME_S = 30 * 86400
|
||||
SESSION_REFRESH_BELOW_S = 15 * 86400 # rolling expiry: extend when under this much left
|
||||
PASSWORD_RESET_TOKEN_LIFETIME_S = 3600
|
||||
|
||||
# stdlib scrypt instead of a passlib/argon2 dependency: zero new deps,
|
||||
# and the parameters are baked into each stored hash so they can be
|
||||
@@ -128,6 +133,41 @@ def users_exist(db: Session) -> bool:
|
||||
return db.scalars(select(User).limit(1)).first() is not None
|
||||
|
||||
|
||||
def get_server_settings(db: Session) -> ServerSettings:
|
||||
"""The SMTP config singleton -- migration.py guarantees row id=1
|
||||
exists (created at startup if missing), so this is never None."""
|
||||
settings = db.get(ServerSettings, 1)
|
||||
assert settings is not None
|
||||
return settings
|
||||
|
||||
|
||||
def create_password_reset_token(db: Session, user: User) -> str:
|
||||
token = secrets.token_urlsafe(32)
|
||||
now = time.time()
|
||||
# Opportunistic prune, same pattern as sessions/pending claims.
|
||||
for stale in db.scalars(select(PasswordResetToken).where(PasswordResetToken.expires_at < now)):
|
||||
db.delete(stale)
|
||||
db.add(PasswordResetToken(
|
||||
token=token, user_id=user.id, created_at=now,
|
||||
expires_at=now + PASSWORD_RESET_TOKEN_LIFETIME_S,
|
||||
))
|
||||
db.commit()
|
||||
return token
|
||||
|
||||
|
||||
def consume_password_reset_token(db: Session, token: str) -> User | None:
|
||||
"""Looks up the token and, if valid, deletes it (single-use) and
|
||||
returns the user it was issued for. None for an unknown/expired
|
||||
token -- callers show a generic error either way."""
|
||||
row = db.get(PasswordResetToken, token)
|
||||
if row is None or row.expires_at < time.time():
|
||||
return None
|
||||
user = db.get(User, row.user_id)
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def _csrf_ok(request: Request, session: UserSession) -> bool:
|
||||
supplied = request.headers.get("X-CSRF-Token") or ""
|
||||
return hmac.compare_digest(supplied, session.csrf_token)
|
||||
@@ -214,17 +254,16 @@ def require_frame_control(
|
||||
|
||||
|
||||
def management_token() -> str:
|
||||
"""The legacy shared secret. Env-only, never stored -- same as the old
|
||||
server, where the env var overrode anything on disk on every load."""
|
||||
"""The pre-setup claim-gate secret. Env-only, never stored -- same as
|
||||
the old server, where the env var overrode anything on disk on every
|
||||
load."""
|
||||
return os.environ.get("MANAGEMENT_TOKEN", "")
|
||||
|
||||
|
||||
def browser_token_valid(request: Request) -> bool:
|
||||
"""The legacy shared-token check. No MANAGEMENT_TOKEN configured means
|
||||
token-holders don't exist -- but unlike Phase A this no longer means
|
||||
"open": once users exist, sessions are the primary gate and this is
|
||||
only the compatibility path for the deployed frame's manage QR
|
||||
(?token=) until Phase C. Empty token => not valid (sessions rule)."""
|
||||
"""Whether the request carries the current MANAGEMENT_TOKEN, via
|
||||
query param or cookie. Only meaningful pre-setup (see require_browser
|
||||
below) -- empty configured token => not valid (nothing to match)."""
|
||||
token = management_token()
|
||||
if not token:
|
||||
return False
|
||||
@@ -234,12 +273,12 @@ def browser_token_valid(request: Request) -> bool:
|
||||
|
||||
def require_browser(request: Request, db: Session = Depends(get_db)) -> User | None:
|
||||
"""Dependency for the web UI's /api/* routes: a real user session
|
||||
(CSRF-checked on mutations, returns the User), or the legacy shared
|
||||
token (returns None -- token bearers act as an anonymous operator,
|
||||
exactly the pre-user model). While NO users exist yet (fresh install
|
||||
or freshly migrated, before /setup has been run) the API stays open
|
||||
if no MANAGEMENT_TOKEN is set -- the Phase A/legacy behavior --
|
||||
since there's nobody to log in as yet."""
|
||||
(CSRF-checked on mutations, returns the User). While NO users exist
|
||||
yet (fresh install, or freshly migrated, before /setup has been run)
|
||||
the API instead stays open if no MANAGEMENT_TOKEN is set, or opens
|
||||
to whoever supplies it if one is -- there's nobody to log in as yet,
|
||||
so this is purely the claim gate for who gets to run /setup. Once a
|
||||
user exists, only a session gets in."""
|
||||
session = current_session(request, db)
|
||||
if session is not None:
|
||||
if request.method not in ("GET", "HEAD", "OPTIONS") and not _csrf_ok(request, session):
|
||||
@@ -247,10 +286,9 @@ def require_browser(request: Request, db: Session = Depends(get_db)) -> User | N
|
||||
user = db.get(User, session.user_id)
|
||||
if user is not None:
|
||||
return user
|
||||
if browser_token_valid(request):
|
||||
return None
|
||||
if not users_exist(db) and not management_token():
|
||||
return None
|
||||
if not users_exist(db):
|
||||
if not management_token() or browser_token_valid(request):
|
||||
return None
|
||||
raise HTTPException(401, "Not logged in")
|
||||
|
||||
|
||||
@@ -290,63 +328,29 @@ def _register_frame(db: Session, device_id: str) -> Frame:
|
||||
|
||||
def require_device(request: Request, db: Session = Depends(get_db)) -> Frame:
|
||||
"""Resolves and authenticates the frame behind a /frame/* request.
|
||||
|
||||
New firmware sends ?id=<12-hex-mac>&token=<per-frame device token>.
|
||||
Deployed legacy firmware sends only ?token=<shared MANAGEMENT_TOKEN>
|
||||
(or nothing, on an open server) -- those requests resolve to the
|
||||
unique legacy_token_enabled frame for as long as that migration
|
||||
window stays open. The first id-bearing request arriving with legacy
|
||||
credentials while the legacy frame has no device_id yet BINDS that id
|
||||
to it -- that's the moment the deployed frame comes back up on new
|
||||
firmware after its OTA, and it must not register as a second frame.
|
||||
"""
|
||||
Firmware sends ?id=<12-hex-mac>&token=<per-frame device token>."""
|
||||
device_id = request.query_params.get("id", "").strip().lower()
|
||||
token = request.query_params.get("token", "")
|
||||
legacy = management_token()
|
||||
legacy_ok = not legacy or token == legacy
|
||||
|
||||
if device_id:
|
||||
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
|
||||
if frame is None:
|
||||
legacy_frame = db.scalars(
|
||||
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712
|
||||
).first()
|
||||
if legacy_frame is not None and legacy_frame.device_id is None and legacy_ok:
|
||||
legacy_frame.device_id = device_id
|
||||
frame = legacy_frame
|
||||
logger.info("Bound device id %s to legacy frame #%d", device_id, frame.id)
|
||||
else:
|
||||
frame = _register_frame(db, device_id)
|
||||
else:
|
||||
token_ok = bool(token) and token == frame.device_token
|
||||
if token_ok and not frame.device_token_ack:
|
||||
frame.device_token_ack = True
|
||||
logger.info("Frame #%d acknowledged its device token", frame.id)
|
||||
if not token_ok:
|
||||
if frame.legacy_token_enabled and legacy_ok:
|
||||
pass
|
||||
elif not frame.device_token_ack:
|
||||
# Handshake window: the device registered but hasn't
|
||||
# received its token yet (the wake cycle fetches the
|
||||
# image BEFORE polling /frame/config, where the token
|
||||
# is delivered) -- the id stays the credential, same
|
||||
# trust level as the open registration that created
|
||||
# the row. Closes permanently on the first
|
||||
# authenticated request.
|
||||
pass
|
||||
else:
|
||||
raise HTTPException(401, "Missing or invalid access token")
|
||||
if not device_id:
|
||||
raise HTTPException(401, "Missing device id")
|
||||
|
||||
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
|
||||
if frame is None:
|
||||
frame = _register_frame(db, device_id)
|
||||
else:
|
||||
if not legacy_ok:
|
||||
token_ok = bool(token) and token == frame.device_token
|
||||
if token_ok and not frame.device_token_ack:
|
||||
frame.device_token_ack = True
|
||||
logger.info("Frame #%d acknowledged its device token", frame.id)
|
||||
elif not token_ok and frame.device_token_ack:
|
||||
raise HTTPException(401, "Missing or invalid access token")
|
||||
frame = db.scalars(
|
||||
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712
|
||||
).first()
|
||||
if frame is None:
|
||||
# Nothing to resolve a no-id request to. migration.py always
|
||||
# creates frame #1 at startup, so this only happens if it was
|
||||
# deleted -- treat like an unknown device.
|
||||
raise HTTPException(401, "No frame accepts legacy credentials")
|
||||
# else: handshake window -- the device registered but hasn't
|
||||
# received its token yet (the wake cycle fetches the image
|
||||
# BEFORE polling /frame/config, where the token is delivered) --
|
||||
# the id stays the credential, same trust level as the open
|
||||
# registration that created the row. Closes permanently on the
|
||||
# first authenticated request.
|
||||
|
||||
frame.last_seen = time.time()
|
||||
db.commit()
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""CalDAV account support: discovering which calendars an account exposes,
|
||||
and fetching one calendar's events or tasks -- the second way (alongside
|
||||
calendar_feed.py's single-file ICS subscription) a user can link a
|
||||
calendar for calendar frame mode (Nextcloud, Fastmail, iCloud, Radicale,
|
||||
Baikal, ...). Task lists (VTODO collections) are CalDAV-only -- a plain
|
||||
ICS subscription doesn't meaningfully have one -- see fetch_tasks.
|
||||
|
||||
Thin wrapper around the `caldav` PyPI package (RFC 4791 client). NOTE ON
|
||||
LICENSING: `caldav` itself is dual-licensed GPL-3.0-or-later / Apache-2.0,
|
||||
but it hard-depends on `icalendar-searcher`, which is AGPL-3.0-or-later --
|
||||
the strongest copyleft license in this project's dependency tree, and the
|
||||
one whose network-use clause is specifically written for server
|
||||
applications like this one. This was an explicit, informed call by the
|
||||
project owner to accept that exposure rather than hand-roll a CalDAV
|
||||
client -- see the server README's Notes section. Anyone redistributing
|
||||
this project (as opposed to just self-hosting it) should reread that
|
||||
tradeoff for their own situation.
|
||||
|
||||
Pure functions -- no ORM, no FastAPI Depends -- same testability
|
||||
philosophy as calendar_feed.py. Event parsing/expansion reuses
|
||||
icalendar + recurring_ical_events directly (rather than trusting each
|
||||
CalDAV server's own possibly-inconsistent RRULE expansion) so a CalDAV
|
||||
calendar and an ICS subscription behave identically once fetched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
|
||||
import caldav
|
||||
import icalendar
|
||||
import recurring_ical_events
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HTTP_TIMEOUT_S = 15
|
||||
|
||||
|
||||
class CalDavError(Exception):
|
||||
"""Discovery or fetch failed -- network, auth, or an unexpected
|
||||
server response. Raised loudly; callers (Settings' discover
|
||||
endpoint, calendar_feed.merge_events) decide what to do. Wraps
|
||||
whatever the caldav package/its transport raised, since that
|
||||
exception hierarchy isn't something call sites should need to know
|
||||
about directly."""
|
||||
|
||||
|
||||
def discover_calendars(base_url: str, username: str, password: str) -> list[dict]:
|
||||
"""[{"href": absolute_calendar_url, "display_name": str}, ...] for
|
||||
every calendar in this account. base_url is the server's CalDAV
|
||||
entry point (e.g. "https://cloud.example.com/remote.php/dav/" for
|
||||
Nextcloud) -- the caller supplies it directly, same idiom as the
|
||||
plain ICS subscription URL."""
|
||||
try:
|
||||
client = caldav.DAVClient(url=base_url, username=username, password=password, timeout=HTTP_TIMEOUT_S)
|
||||
calendars = client.principal().calendars()
|
||||
except Exception as e:
|
||||
raise CalDavError(str(e)) from e
|
||||
|
||||
result = []
|
||||
for cal in calendars:
|
||||
try:
|
||||
display_name = cal.get_display_name() or cal.name
|
||||
except Exception:
|
||||
display_name = None
|
||||
result.append({"href": str(cal.url), "display_name": display_name or str(cal.url)})
|
||||
return result
|
||||
|
||||
|
||||
def fetch_calendar_events(calendar_url: str, username: str, password: str,
|
||||
window_start: date, window_end: date) -> list[dict]:
|
||||
"""One CalDAV calendar's events in [window_start, window_end] -- same
|
||||
event dict shape as calendar_feed.fetch_source_events (no
|
||||
"owner_display_name"; the caller adds that).
|
||||
|
||||
Deliberately does NOT use the calendar-query REPORT's server-side
|
||||
time-range filter (caldav.Calendar.date_search) -- RFC 4791 leaves
|
||||
that corner case underspecified and real servers disagree on it
|
||||
(the caldav package's own docs warn "servers often behave
|
||||
differently when presented with a search request"; confirmed here
|
||||
too, once against a real server, as a calendar whose events just
|
||||
silently never came back despite discovery/auth both working
|
||||
fine). Instead this fetches every event in the calendar unfiltered
|
||||
(get_events() is a plain "list VEVENTs" REPORT with no time-range
|
||||
element -- the much more universally-supported case) and does 100%
|
||||
of the date-window filtering/recurrence-expansion client-side via
|
||||
icalendar + recurring_ical_events, exactly like calendar_feed.py
|
||||
already does for plain ICS feeds. Heavier per-fetch (the whole
|
||||
calendar, not just the window) but far more reliable."""
|
||||
try:
|
||||
client = caldav.DAVClient(url=calendar_url, username=username, password=password, timeout=HTTP_TIMEOUT_S)
|
||||
calendar = caldav.Calendar(client=client, url=calendar_url)
|
||||
objects = calendar.get_events()
|
||||
except Exception as e:
|
||||
raise CalDavError(str(e)) from e
|
||||
|
||||
events: list[dict] = []
|
||||
for obj in objects:
|
||||
try:
|
||||
ical = icalendar.Calendar.from_ical(obj.data)
|
||||
occurrences = recurring_ical_events.of(ical).between(window_start, window_end)
|
||||
except Exception as e: # one malformed resource shouldn't blank the whole calendar
|
||||
logger.warning("Could not parse a CalDAV event from %s: %s", calendar_url, e)
|
||||
continue
|
||||
for occ in occurrences:
|
||||
dtstart = occ.get("DTSTART")
|
||||
dtend = occ.get("DTEND")
|
||||
if dtstart is None:
|
||||
continue
|
||||
start_dt = dtstart.dt
|
||||
end_dt = dtend.dt if dtend is not None else start_dt
|
||||
all_day = not isinstance(start_dt, datetime)
|
||||
events.append({
|
||||
"summary": str(occ.get("SUMMARY") or "(untitled)"),
|
||||
"start": start_dt.isoformat(),
|
||||
"end": end_dt.isoformat(),
|
||||
"all_day": all_day,
|
||||
})
|
||||
return events
|
||||
|
||||
|
||||
def fetch_tasks(calendar_url: str, username: str, password: str,
|
||||
completed_since: datetime | None = None) -> list[dict]:
|
||||
"""Outstanding VTODOs from one CalDAV task list, plus -- when
|
||||
completed_since is given -- ones completed at or after that cutoff
|
||||
(see routers/common.py's get_or_refresh_tasks_for_widget, which
|
||||
passes "now - 24h" when TaskWidgetConfig.show_completed is on;
|
||||
None, the default, means completed tasks are dropped entirely, the
|
||||
original behavior). {"summary", "due" (ISO date/datetime string or
|
||||
None), "completed_at" (ISO datetime string, or None for an
|
||||
outstanding task)}, ... . Outstanding tasks sort first (by due date,
|
||||
no-due-date last), any included completed ones after (most recently
|
||||
completed first).
|
||||
|
||||
Fetches every task including completed ones and filters/sorts
|
||||
client-side rather than trusting get_todos()'s own
|
||||
include_completed/sort_keys server-side filtering, same reasoning as
|
||||
fetch_calendar_events not trusting the time-range REPORT filter --
|
||||
a simpler filter than a time range, but not worth re-litigating
|
||||
which server-side filters are reliable one at a time."""
|
||||
try:
|
||||
client = caldav.DAVClient(url=calendar_url, username=username, password=password, timeout=HTTP_TIMEOUT_S)
|
||||
calendar = caldav.Calendar(client=client, url=calendar_url)
|
||||
objects = calendar.get_todos(include_completed=True)
|
||||
except Exception as e:
|
||||
raise CalDavError(str(e)) from e
|
||||
|
||||
outstanding: list[dict] = []
|
||||
completed: list[dict] = []
|
||||
for obj in objects:
|
||||
try:
|
||||
ical = icalendar.Calendar.from_ical(obj.data)
|
||||
except Exception as e: # one malformed resource shouldn't blank the whole list
|
||||
logger.warning("Could not parse a CalDAV task from %s: %s", calendar_url, e)
|
||||
continue
|
||||
for component in ical.walk("VTODO"):
|
||||
status = str(component.get("STATUS") or "NEEDS-ACTION").upper()
|
||||
summary = str(component.get("SUMMARY") or "(untitled)")
|
||||
if status == "COMPLETED":
|
||||
completed_prop = component.get("COMPLETED")
|
||||
completed_dt = completed_prop.dt if completed_prop is not None else None
|
||||
if completed_since is None or completed_dt is None or completed_dt < completed_since:
|
||||
continue
|
||||
completed.append({"summary": summary, "due": None, "completed_at": completed_dt.isoformat()})
|
||||
else:
|
||||
due = component.get("DUE")
|
||||
outstanding.append({
|
||||
"summary": summary,
|
||||
"due": due.dt.isoformat() if due is not None else None,
|
||||
"completed_at": None,
|
||||
})
|
||||
outstanding.sort(key=lambda t: (t["due"] is None, t["due"] or ""))
|
||||
completed.sort(key=lambda t: t["completed_at"], reverse=True)
|
||||
return outstanding + completed
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskSource:
|
||||
"""One task list to merge in -- CalDAV only, no ICS variant (a plain
|
||||
ICS subscription has no VTODO collection to speak of).
|
||||
owner_display_name tags every task pulled from this source so a
|
||||
merged checklist can show whose task is whose; color_index (2-5,
|
||||
into image_pipeline.DEFAULT_PALETTE_RGB) is this list's manually
|
||||
pinned color, or None for calendar_render.py's auto-cycle-by-owner-
|
||||
name fallback -- see models.FrameTaskList."""
|
||||
|
||||
owner_display_name: str
|
||||
url: str
|
||||
username: str
|
||||
password: str
|
||||
color_index: int | None = None
|
||||
|
||||
|
||||
def merge_tasks(sources: list[TaskSource], completed_since: datetime | None = None) -> tuple[list[dict], str]:
|
||||
"""Fetches each source independently -- one broken list never blanks
|
||||
another's tasks. Returns (merged_tasks, fetch_summary); fetch_summary
|
||||
is "" when every source succeeded, else "N of M task lists
|
||||
unavailable" (same no-naming-names posture as calendar_feed.
|
||||
merge_events). No cross-list duplicate collapsing (unlike
|
||||
merge_events) -- a task synced to two lists at once is rare enough,
|
||||
and lower-stakes than a duplicated calendar event, not to be worth
|
||||
the same de-dup machinery."""
|
||||
merged: list[dict] = []
|
||||
failures = 0
|
||||
for source in sources:
|
||||
try:
|
||||
tasks = fetch_tasks(source.url, source.username, source.password, completed_since=completed_since)
|
||||
except CalDavError:
|
||||
failures += 1
|
||||
continue
|
||||
for task in tasks:
|
||||
merged.append({
|
||||
**task,
|
||||
"owner_display_name": source.owner_display_name,
|
||||
"color_index": source.color_index,
|
||||
})
|
||||
|
||||
outstanding = [t for t in merged if t["completed_at"] is None]
|
||||
completed = [t for t in merged if t["completed_at"] is not None]
|
||||
outstanding.sort(key=lambda t: (t["due"] is None, t["due"] or ""))
|
||||
completed.sort(key=lambda t: t["completed_at"], reverse=True)
|
||||
summary = f"{failures} of {len(sources)} task lists unavailable" if failures else ""
|
||||
return outstanding + completed, summary
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Fetch, parse, and merge per-user calendar feeds -- ICS subscriptions
|
||||
and (via caldav_client.py) CalDAV collections -- for calendar frame mode
|
||||
(see routers/device.py's RENDERERS["calendar"] and calendar_render.py).
|
||||
|
||||
Pure functions -- no ORM, no FastAPI Depends. Callers (routers/common.py's
|
||||
get_or_refresh_calendar_events) supply plain CalendarSource values, not
|
||||
ORM objects, so this module stays testable against fixture .ics text with
|
||||
no database or app involved.
|
||||
|
||||
Recurring events (RRULE/EXDATE/RDATE, DST-aware) are expanded via
|
||||
recurring-ical-events rather than hand-rolled -- that's genuinely fiddly
|
||||
to get right (see its own docs), not worth reinventing. It's LGPL-3.0 (an
|
||||
ordinary runtime pip dependency, never vendored/modified -- see the
|
||||
server README's Notes section for why that doesn't put this project's own
|
||||
code under LGPL terms).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
|
||||
import httpx
|
||||
import icalendar
|
||||
import recurring_ical_events
|
||||
|
||||
from . import caldav_client
|
||||
|
||||
HTTP_TIMEOUT_S = 15.0
|
||||
FETCH_MAX_BYTES = 10 * 1024 * 1024 # sanity cap -- a real feed is KB, not MB
|
||||
|
||||
CHECK_INTERVAL_S = 20 * 60 # don't refetch/reparse any feed more often than this
|
||||
|
||||
# How far back/forward each merge-fetch expands recurring events. Households
|
||||
# look back far less than they plan ahead, hence the asymmetry. Browsing
|
||||
# outside this window (calendar_browse_offset) just yields an empty view,
|
||||
# not an error -- self-heals on the next normal wake regardless.
|
||||
EXPAND_WINDOW_PAST_DAYS = 30
|
||||
EXPAND_WINDOW_FUTURE_DAYS = 200
|
||||
|
||||
|
||||
class CalendarFetchError(Exception):
|
||||
"""One feed was unreachable, not valid ICS, or too large. Raised by
|
||||
fetch_source_events(); merge_events() is what catches this per-source
|
||||
so one broken feed can't blank out another's events."""
|
||||
|
||||
|
||||
def fetch_source_events(url: str, window_start: date, window_end: date) -> list[dict]:
|
||||
"""One feed: download, parse, expand recurrences within
|
||||
[window_start, window_end]. Raises CalendarFetchError on any problem
|
||||
-- network, malformed ICS, or an oversized response."""
|
||||
try:
|
||||
with httpx.stream("GET", url, timeout=HTTP_TIMEOUT_S, follow_redirects=True) as resp:
|
||||
resp.raise_for_status()
|
||||
chunks = []
|
||||
total = 0
|
||||
for chunk in resp.iter_bytes():
|
||||
total += len(chunk)
|
||||
if total > FETCH_MAX_BYTES:
|
||||
raise CalendarFetchError(f"Feed exceeds {FETCH_MAX_BYTES} bytes")
|
||||
chunks.append(chunk)
|
||||
body = b"".join(chunks)
|
||||
except httpx.HTTPError as e:
|
||||
raise CalendarFetchError(str(e)) from e
|
||||
|
||||
try:
|
||||
cal = icalendar.Calendar.from_ical(body)
|
||||
occurrences = recurring_ical_events.of(cal).between(window_start, window_end)
|
||||
except Exception as e: # icalendar/recurring_ical_events raise a mix of ValueError-family exceptions
|
||||
raise CalendarFetchError(f"Could not parse ICS feed: {e}") from e
|
||||
|
||||
events = []
|
||||
for occ in occurrences:
|
||||
dtstart = occ.get("DTSTART")
|
||||
dtend = occ.get("DTEND")
|
||||
if dtstart is None:
|
||||
continue
|
||||
start_dt = dtstart.dt
|
||||
end_dt = dtend.dt if dtend is not None else start_dt
|
||||
all_day = not isinstance(start_dt, datetime) # date, not datetime -- VALUE=DATE
|
||||
events.append({
|
||||
"summary": str(occ.get("SUMMARY") or "(untitled)"),
|
||||
"start": start_dt.isoformat(),
|
||||
"end": end_dt.isoformat(),
|
||||
"all_day": all_day,
|
||||
})
|
||||
return events
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CalendarSource:
|
||||
"""One calendar to merge in: either a plain ICS subscription (kind
|
||||
"ics", url is the feed itself) or one CalDAV collection (kind
|
||||
"caldav", url is the calendar's own URL, username/password its
|
||||
account credentials) -- see caldav_client.py. owner_display_name
|
||||
tags every event pulled from this source so a merged agenda can show
|
||||
whose event is whose. color_index (2-5, into
|
||||
image_pipeline.DEFAULT_PALETTE_RGB) is this calendar's manually
|
||||
pinned color, or None to fall back on calendar_render.py's old
|
||||
auto-cycle-by-owner-name behavior -- see models.FrameCalendar."""
|
||||
|
||||
owner_display_name: str
|
||||
kind: str
|
||||
url: str
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
color_index: int | None = None
|
||||
|
||||
|
||||
def merge_events(
|
||||
sources: list[CalendarSource], window_start: date, window_end: date
|
||||
) -> tuple[list[dict], str]:
|
||||
"""Fetches each source independently -- one broken feed never blanks
|
||||
another's events. Returns (merged_time_sorted_events, fetch_summary);
|
||||
fetch_summary is "" when every source succeeded, else "N of M
|
||||
calendars unavailable" (never *which* source -- naming whose feed is
|
||||
down to everyone who looks at a shared household display is a bigger
|
||||
overshare than the outage itself).
|
||||
|
||||
Events sharing the exact same (summary, start, end, all_day) across
|
||||
different calendars -- e.g. a shared family event synced onto more
|
||||
than one person's calendar -- collapse into one entry rather than
|
||||
showing as duplicate rows. Every merged event carries a "sources"
|
||||
list ([{"owner_display_name", "color_index"}, ...], length 1 for an
|
||||
ordinary non-duplicated event) that calendar_render.py draws a
|
||||
color indicator per entry of, so a collapsed event still visibly
|
||||
shows every calendar it came from."""
|
||||
merged: list[dict] = []
|
||||
by_key: dict[tuple, dict] = {}
|
||||
failures = 0
|
||||
for source in sources:
|
||||
try:
|
||||
if source.kind == "caldav":
|
||||
events = caldav_client.fetch_calendar_events(
|
||||
source.url, source.username, source.password, window_start, window_end
|
||||
)
|
||||
else:
|
||||
events = fetch_source_events(source.url, window_start, window_end)
|
||||
except (CalendarFetchError, caldav_client.CalDavError):
|
||||
failures += 1
|
||||
continue
|
||||
for event in events:
|
||||
source_entry = {"owner_display_name": source.owner_display_name, "color_index": source.color_index}
|
||||
key = (event["summary"], event["start"], event["end"], event["all_day"])
|
||||
existing = by_key.get(key)
|
||||
if existing is None:
|
||||
event["sources"] = [source_entry]
|
||||
by_key[key] = event
|
||||
merged.append(event)
|
||||
else:
|
||||
existing["sources"].append(source_entry)
|
||||
|
||||
merged.sort(key=lambda e: e["start"])
|
||||
summary = f"{failures} of {len(sources)} calendars unavailable" if failures else ""
|
||||
return merged, summary
|
||||
@@ -0,0 +1,347 @@
|
||||
"""Calendar widget's "modern" render style -- all four view modes
|
||||
(agenda/today_tomorrow/week/month), mirroring calendar_render.py's own
|
||||
_build dispatch shape exactly so app/widgets/calendar.py and the
|
||||
calendar preview endpoint can call either module identically. Kept in
|
||||
its own module rather than joining app/html_render.py's other build_*
|
||||
functions, mirroring calendar_render.py's own separation from the
|
||||
simpler widgets (calendar is the one case where html_render.py growing
|
||||
a 5th unrelated builder starts to hurt readability).
|
||||
|
||||
Reuses calendar_render's own private helpers (_events_on_day/_event_
|
||||
colors/_event_start/_fmt_time/_weather_for_day/_month_view_fits/
|
||||
_add_months) so a modern-style view's event list/colors/times/weather/
|
||||
month-grid math match the classic renderer's data exactly -- only the
|
||||
drawing differs, same relationship weather's build_current/build_daily
|
||||
have with weather_render.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar as calendar_module
|
||||
from datetime import date, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from . import html_render, panel_style, theme_tokens
|
||||
from .calendar_render import (
|
||||
MARGIN,
|
||||
WEEKDAY_NAMES,
|
||||
_add_months,
|
||||
_event_colors,
|
||||
_event_start,
|
||||
_events_on_day,
|
||||
_fmt_time,
|
||||
_month_view_fits,
|
||||
_weather_for_day,
|
||||
)
|
||||
|
||||
|
||||
def _weather_row(weather_cities, day, units) -> list[dict]:
|
||||
entries = _weather_for_day(weather_cities, day)
|
||||
return [
|
||||
{"emoji": html_render.CATEGORY_EMOJI.get(e["category"], ""), "high": round(e["high"]), "low": round(e["low"])}
|
||||
for e in entries
|
||||
]
|
||||
|
||||
|
||||
def _day_section_data(day: date, events: list[dict], tz: ZoneInfo, palette_rgb, weather_cities,
|
||||
weather_units: str, owners_seen: list[str], rows_avail_h: int, row_h: int) -> dict:
|
||||
"""One day's {header, weather_entries, rows, more_count} -- shared by
|
||||
build_agenda/build_today_tomorrow/build_week's vertical layout, same
|
||||
reuse relationship calendar_render._draw_agenda_day has with
|
||||
_build_agenda/_build_today_tomorrow. `rows_avail_h` is the *rows*
|
||||
area's own pixel budget only -- the caller has already reserved a
|
||||
separate, uniform header_h (which is where weather actually renders,
|
||||
see the day-header macro) for every section, so this function
|
||||
doesn't need to account for weather space itself."""
|
||||
header = day.strftime("%A, %B ") + str(day.day)
|
||||
weather_entries = _weather_row(weather_cities, day, weather_units)
|
||||
max_rows = max(0, rows_avail_h // row_h)
|
||||
|
||||
day_events = _events_on_day(events, day, tz)
|
||||
rows = []
|
||||
for event in day_events[:max_rows]:
|
||||
colors = _event_colors(event, owners_seen, palette_rgb)
|
||||
time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz))
|
||||
rows.append({"colors": [html_render._rgb_to_hex(c) for c in colors], "time": time_str,
|
||||
"summary": event["summary"]})
|
||||
return {"header": header, "weather_entries": weather_entries, "rows": rows,
|
||||
"more_count": max(0, len(day_events) - max_rows)}
|
||||
|
||||
|
||||
def build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
|
||||
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
|
||||
weather_units: str = "fahrenheit", theme_name: str | None = None,
|
||||
font_scale: float = 1.0) -> Image.Image:
|
||||
"""HTML/CSS-rendered analogue of calendar_render._build_agenda.
|
||||
|
||||
Bold-minimal: no card/border/shadow (theme["radius"]/theme["shadow"]
|
||||
are unused, same carve-out as weather's build_current/build_daily --
|
||||
see docs/widgets.md). The day header is plain ink text under a slim
|
||||
accent-colored rule instead of white text on a full gradient band --
|
||||
only that thin rule dithers at the theme's richer accent_amplitude
|
||||
now, not the header text sitting on top of it, which is a legibility
|
||||
improvement over the old design, not just a visual one."""
|
||||
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
|
||||
day = datetime.now(tz).date() + timedelta(days=browse_offset)
|
||||
title_size = panel_style.scaled_size(max(14, min(target_w, target_h) // 12), font_scale)
|
||||
body_size = panel_style.scaled_size(max(11, min(target_w, target_h) // 20), font_scale)
|
||||
weather_size = max(10, body_size - 2)
|
||||
row_h = body_size + 14
|
||||
unit_suffix = "F" if weather_units == "fahrenheit" else "C"
|
||||
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.025, 4, 8))
|
||||
|
||||
# Single day -- no cross-section alignment concern, so header_h can
|
||||
# simply reflect whether THIS day actually has weather (unlike
|
||||
# build_today_tomorrow/build_week's vertical layout, which must
|
||||
# reserve the same header_h for every stacked section regardless).
|
||||
has_weather = bool(_weather_row(weather_cities, day, weather_units))
|
||||
header_h = accent_h + 10 + title_size + ((weather_size + 10) if has_weather else 0)
|
||||
|
||||
owners_seen: list[str] = []
|
||||
data = _day_section_data(day, events, tz, palette_rgb, weather_cities, weather_units, owners_seen,
|
||||
target_h - header_h - MARGIN, row_h)
|
||||
|
||||
template = html_render._jinja_env.get_template("calendar_agenda.html.jinja")
|
||||
html = template.render(
|
||||
w=target_w, h=target_h, gutter=panel_style.GUTTER,
|
||||
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
|
||||
header=data["header"], title_size=title_size, header_h=header_h, accent_h=accent_h,
|
||||
accent_start=theme["accent_hex"], weather_entries=data["weather_entries"],
|
||||
weather_size=weather_size, unit_suffix=unit_suffix, rows=data["rows"],
|
||||
more_count=data["more_count"], row_h=row_h, body_size=body_size,
|
||||
)
|
||||
rendered = html_render.render_html_to_image(html, target_w, target_h)
|
||||
gutter = panel_style.GUTTER
|
||||
accent_rect = (gutter, gutter, target_w - gutter, gutter + accent_h)
|
||||
return html_render.ordered_dither_regions(
|
||||
rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])]
|
||||
)
|
||||
|
||||
|
||||
def build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
|
||||
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
|
||||
weather_units: str = "fahrenheit", theme_name: str | None = None,
|
||||
font_scale: float = 1.0) -> Image.Image:
|
||||
"""HTML/CSS-rendered analogue of calendar_render._build_today_tomorrow
|
||||
-- two day-sections stacked (see _day_section_data). Bold-minimal, no
|
||||
card (see build_agenda's docstring) -- each section's own slim accent
|
||||
rule dithers richer via ordered_dither_regions, not its header text."""
|
||||
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
|
||||
start_day = datetime.now(tz).date() + timedelta(days=browse_offset)
|
||||
section_h = target_h // 2
|
||||
title_size = panel_style.scaled_size(max(13, section_h // 8), font_scale)
|
||||
body_size = panel_style.scaled_size(max(10, min(target_w, target_h) // 26), font_scale)
|
||||
weather_size = max(9, body_size - 2)
|
||||
row_h = body_size + 12
|
||||
unit_suffix = "F" if weather_units == "fahrenheit" else "C"
|
||||
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.02, 3, 6))
|
||||
|
||||
day_dates = [start_day + timedelta(days=i) for i in range(2)]
|
||||
# Uniform across both stacked sections regardless of which day(s)
|
||||
# actually have weather -- see _day_section_data's own docstring for
|
||||
# why a per-day header height misaligns where rows start.
|
||||
any_weather = any(_weather_row(weather_cities, d, weather_units) for d in day_dates)
|
||||
header_h = accent_h + 8 + title_size + ((weather_size + 8) if any_weather else 0)
|
||||
|
||||
owners_seen: list[str] = []
|
||||
days = [
|
||||
_day_section_data(d, events, tz, palette_rgb, weather_cities, weather_units, owners_seen,
|
||||
section_h - header_h, row_h)
|
||||
for d in day_dates
|
||||
]
|
||||
|
||||
template = html_render._jinja_env.get_template("calendar_today_tomorrow.html.jinja")
|
||||
html = template.render(
|
||||
w=target_w, h=target_h, gutter=panel_style.GUTTER,
|
||||
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
|
||||
days=days, title_size=title_size, header_h=header_h, accent_h=accent_h,
|
||||
accent_start=theme["accent_hex"], weather_size=weather_size,
|
||||
unit_suffix=unit_suffix, row_h=row_h, body_size=body_size,
|
||||
)
|
||||
rendered = html_render.render_html_to_image(html, target_w, target_h)
|
||||
gutter = panel_style.GUTTER
|
||||
accent_regions = [
|
||||
((gutter, gutter + i * section_h, target_w - gutter, gutter + i * section_h + accent_h),
|
||||
theme["accent_amplitude"])
|
||||
for i in range(len(days))
|
||||
]
|
||||
return html_render.ordered_dither_regions(rendered, palette_rgb, accent_regions=accent_regions)
|
||||
|
||||
|
||||
def build_week(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
|
||||
week_start: int, palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
|
||||
weather_units: str = "fahrenheit", days: int = 7, layout: str = "horizontal",
|
||||
start_offset: int = 0, theme_name: str | None = None, font_scale: float = 1.0) -> Image.Image:
|
||||
"""HTML/CSS-rendered analogue of calendar_render._build_week -- both
|
||||
the vertical (stacked day-sections, reusing build_today_tomorrow's
|
||||
template with an arbitrary day count) and horizontal (side-by-side
|
||||
columns) layouts. Each header (per-section or per-column) dithers
|
||||
richer via ordered_dither_regions."""
|
||||
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
|
||||
gutter = panel_style.GUTTER
|
||||
today = datetime.now(tz).date()
|
||||
if days == 7:
|
||||
days_since_start = (today.weekday() - week_start) % 7
|
||||
week_first_day = today - timedelta(days=days_since_start) + timedelta(days=days * browse_offset)
|
||||
else:
|
||||
week_first_day = today + timedelta(days=start_offset) + timedelta(days=days * browse_offset)
|
||||
unit_suffix = "F" if weather_units == "fahrenheit" else "C"
|
||||
owners_seen: list[str] = []
|
||||
|
||||
if layout == "vertical":
|
||||
section_h = target_h // days
|
||||
title_size = panel_style.scaled_size(max(11, min(20, section_h // 6)), font_scale)
|
||||
body_size = panel_style.scaled_size(max(9, min(target_w, target_h) // (18 + days)), font_scale)
|
||||
weather_size = max(8, body_size - 2)
|
||||
row_h = body_size + 10
|
||||
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.018, 3, 5))
|
||||
day_dates = [week_first_day + timedelta(days=i) for i in range(days)]
|
||||
# Uniform across all `days` stacked sections -- see
|
||||
# _day_section_data's own docstring for why a per-day header
|
||||
# height misaligns where rows start.
|
||||
any_weather = any(_weather_row(weather_cities, d, weather_units) for d in day_dates)
|
||||
header_h = accent_h + 6 + title_size + ((weather_size + 6) if any_weather else 0)
|
||||
day_sections = [
|
||||
_day_section_data(d, events, tz, palette_rgb, weather_cities, weather_units, owners_seen,
|
||||
section_h - header_h, row_h)
|
||||
for d in day_dates
|
||||
]
|
||||
template = html_render._jinja_env.get_template("calendar_today_tomorrow.html.jinja")
|
||||
html = template.render(
|
||||
w=target_w, h=target_h, gutter=gutter,
|
||||
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
|
||||
days=day_sections, title_size=title_size, header_h=header_h, accent_h=accent_h,
|
||||
accent_start=theme["accent_hex"], weather_size=weather_size,
|
||||
unit_suffix=unit_suffix, row_h=row_h, body_size=body_size,
|
||||
)
|
||||
rendered = html_render.render_html_to_image(html, target_w, target_h)
|
||||
accent_regions = [
|
||||
((gutter, gutter + i * section_h, target_w - gutter, gutter + i * section_h + accent_h),
|
||||
theme["accent_amplitude"])
|
||||
for i in range(len(day_sections))
|
||||
]
|
||||
return html_render.ordered_dither_regions(rendered, palette_rgb, accent_regions=accent_regions)
|
||||
|
||||
header_size = panel_style.scaled_size(max(10, min(16, (target_w // days) // 6)), font_scale)
|
||||
chip_size = max(9, header_size - 3)
|
||||
weather_size = max(8, chip_size - 1)
|
||||
col_w = max(1, (target_w - panel_style.GUTTER * 2) // days)
|
||||
row_h = chip_size + 8
|
||||
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.02, 3, 6))
|
||||
# Reserve weather-line room in every column's header uniformly
|
||||
# (whether or not THIS specific day has a cached forecast) -- a
|
||||
# per-column height that depends on that day's own data would
|
||||
# misalign where each column's event rows start across the week
|
||||
# grid the moment any single day lacks a forecast entry.
|
||||
header_h = header_size + 8 + (weather_size + 4 if weather_cities else 0)
|
||||
max_rows = max(0, (target_h - panel_style.GUTTER * 2 - accent_h - 6 - header_h) // row_h)
|
||||
|
||||
cols = []
|
||||
for i in range(days):
|
||||
day = week_first_day + timedelta(days=i)
|
||||
label = day.strftime("%a %-d") if day != today else f"★ {day.strftime('%a %-d')}"
|
||||
weather_entries = _weather_row(weather_cities, day, weather_units)
|
||||
day_events = _events_on_day(events, day, tz)
|
||||
rows = []
|
||||
for event in day_events[:max_rows]:
|
||||
color = html_render._rgb_to_hex(_event_colors(event, owners_seen, palette_rgb)[0])
|
||||
summary = event["summary"] if event["all_day"] else f"{_fmt_time(_event_start(event, tz))[:-3]} {event['summary']}"
|
||||
rows.append({"color": color, "summary": summary})
|
||||
cols.append({
|
||||
"label": label, "weather": weather_entries[0] if weather_entries else None,
|
||||
"rows": rows, "more_count": max(0, len(day_events) - max_rows),
|
||||
})
|
||||
|
||||
template = html_render._jinja_env.get_template("calendar_week_horizontal.html.jinja")
|
||||
html = template.render(
|
||||
w=target_w, h=target_h, gutter=gutter,
|
||||
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
|
||||
cols=cols, header_size=header_size, chip_size=chip_size,
|
||||
header_h=header_h, accent_h=accent_h, weather_size=weather_size, unit_suffix=unit_suffix,
|
||||
accent_start=theme["accent_hex"],
|
||||
)
|
||||
rendered = html_render.render_html_to_image(html, target_w, target_h)
|
||||
accent_rect = (gutter, gutter, target_w - gutter, gutter + accent_h)
|
||||
return html_render.ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
|
||||
|
||||
|
||||
def build_month(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
|
||||
week_start: int, palette_rgb: list | None = None, theme_name: str | None = None,
|
||||
font_scale: float = 1.0) -> Image.Image:
|
||||
"""HTML/CSS-rendered analogue of calendar_render._build_month --
|
||||
density dots per day, not literal event text, same reasoning as the
|
||||
classic renderer (real text at typical month-cell size is close to
|
||||
unreadable on a 6-color dithered e-ink panel). The per-owner event
|
||||
dots are identity-coding (like every other calendar view's chips) and
|
||||
are never touched by a theme.
|
||||
|
||||
Bold-minimal: no card (see build_agenda's docstring); the old flat
|
||||
accent-colored weekday-name band is now a slim accent rule above
|
||||
plain bold weekday labels, matching every other calendar view's
|
||||
header treatment -- only that rule dithers at the theme's richer
|
||||
accent_amplitude via ordered_dither_regions. "Today" is still called
|
||||
out with a small accent-filled pill around its day number (a
|
||||
genuinely small accent surface, not a band, so it was left alone)."""
|
||||
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
|
||||
gutter = panel_style.GUTTER
|
||||
today = datetime.now(tz).date()
|
||||
target_month = _add_months(date(today.year, today.month, 1), browse_offset)
|
||||
weeks_dates = list(
|
||||
calendar_module.Calendar(firstweekday=week_start).monthdatescalendar(target_month.year, target_month.month)
|
||||
)
|
||||
day_names = [n[:3] for n in (WEEKDAY_NAMES[week_start:] + WEEKDAY_NAMES[:week_start])]
|
||||
|
||||
header_size = panel_style.scaled_size(max(11, min(16, target_h // 30)), font_scale)
|
||||
day_size = panel_style.scaled_size(max(10, min(15, target_w // 55)), font_scale)
|
||||
dot_size = max(4, day_size // 2)
|
||||
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.02, 3, 6))
|
||||
|
||||
owners_seen: list[str] = []
|
||||
weeks = []
|
||||
for week in weeks_dates:
|
||||
row = []
|
||||
for day in week:
|
||||
day_events = _events_on_day(events, day, tz)
|
||||
dots = [html_render._rgb_to_hex(_event_colors(e, owners_seen, palette_rgb)[0]) for e in day_events[:4]]
|
||||
row.append({
|
||||
"day_num": day.day, "in_month": day.month == target_month.month,
|
||||
"is_today": day == today, "dots": dots, "more_count": max(0, len(day_events) - 4),
|
||||
})
|
||||
weeks.append(row)
|
||||
|
||||
template = html_render._jinja_env.get_template("calendar_month.html.jinja")
|
||||
html = template.render(
|
||||
w=target_w, h=target_h, gutter=gutter,
|
||||
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
|
||||
day_names=day_names, weeks=weeks, accent_h=accent_h,
|
||||
header_size=header_size, day_size=day_size, dot_size=dot_size, accent_start=theme["accent_hex"],
|
||||
)
|
||||
rendered = html_render.render_html_to_image(html, target_w, target_h)
|
||||
accent_rect = (gutter, gutter, target_w - gutter, gutter + accent_h)
|
||||
return html_render.ordered_dither_regions(rendered, palette_rgb,
|
||||
accent_regions=[(accent_rect, theme["accent_amplitude"])])
|
||||
|
||||
|
||||
def build(events: list[dict], view: str, browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
|
||||
week_start: int, palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
|
||||
weather_units: str = "fahrenheit", week_days: int = 7, week_layout: str = "horizontal",
|
||||
week_start_offset: int = 0, theme_name: str | None = None, font_scale: float = 1.0) -> Image.Image:
|
||||
"""Dispatches to the right build_* -- mirrors calendar_render._build's
|
||||
exact "month falls back to agenda when it doesn't fit" resolution, so
|
||||
a narrow month-mode widget set to modern style still gets a sensible
|
||||
modern view instead of erroring or silently reverting to classic."""
|
||||
effective_view = view
|
||||
if view == "month" and not _month_view_fits(target_w, target_h):
|
||||
effective_view = "agenda"
|
||||
|
||||
if effective_view == "agenda":
|
||||
return build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities,
|
||||
weather_units, theme_name, font_scale)
|
||||
if effective_view == "today_tomorrow":
|
||||
return build_today_tomorrow(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities,
|
||||
weather_units, theme_name, font_scale)
|
||||
if effective_view == "week":
|
||||
return build_week(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, weather_cities,
|
||||
weather_units, week_days, week_layout, week_start_offset, theme_name, font_scale)
|
||||
return build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, theme_name, font_scale)
|
||||
@@ -0,0 +1,888 @@
|
||||
"""Renders calendar frame mode's three views (agenda/week/month), and the
|
||||
separate standalone tasks widget (see models.TaskWidgetConfig -- a task
|
||||
list used to be a calendar-widget-only week-view slot, split out into
|
||||
its own widget type so it isn't tied to a calendar's view/footprint),
|
||||
into the panel's packed format, following image_pipeline.
|
||||
render_placeholder's own precedent: build an RGB canvas with
|
||||
ImageDraw/ImageFont, then the same _quantize/_transpose_and_pack every
|
||||
other renderer ends on.
|
||||
|
||||
Event dicts here are calendar_feed.py's shape: {"summary", "start", "end"
|
||||
(ISO 8601 strings), "all_day", "sources": [{"owner_display_name",
|
||||
"color_index"}, ...]} -- more than one entry in "sources" means
|
||||
merge_events collapsed several calendars' identical (same title/time)
|
||||
events into one, see _event_colors/panel_style.draw_color_chip below.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar as calendar_module
|
||||
import io
|
||||
import re
|
||||
from datetime import date, datetime, timedelta
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
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,
|
||||
draw_text,
|
||||
logical_render_size,
|
||||
)
|
||||
from .weather import weather_category
|
||||
from .weather_render import draw_weather_row
|
||||
|
||||
CALENDAR_VIEWS = ["agenda", "today_tomorrow", "week", "month"]
|
||||
CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "today_tomorrow": "Agenda (today & tomorrow)",
|
||||
"week": "Week", "month": "Month"}
|
||||
WEEKDAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||||
|
||||
# MARGIN carries panel_style.CONTENT_MARGIN's value unchanged (not
|
||||
# re-tuned). BG/FG are this module's own plain black/white -- checkbox
|
||||
# outlines, month-view grid hairlines -- not a text-emphasis concern (no
|
||||
# MUTED gray here anymore -- see panel_style's module docstring for why:
|
||||
# a mid-gray fill has no close palette match and dithers into speckle
|
||||
# once the whole canvas is quantized. Secondary text now reads through
|
||||
# size/weight alone, always exact black).
|
||||
MARGIN = panel_style.CONTENT_MARGIN
|
||||
BG = (255, 255, 255)
|
||||
FG = (0, 0, 0)
|
||||
# Structural dividers/grid lines (between stacked day sections, week
|
||||
# columns, month cells) stay a plain black rule -- gray dithers away to
|
||||
# near-invisible once quantized to the 6-color e-ink palette. Headers
|
||||
# no longer use this: see panel_style.draw_header_bar/theme_color.
|
||||
RULE = (0, 0, 0)
|
||||
|
||||
# Fallback for any event whose calendar has no manually pinned color
|
||||
# (event["color_index"] is None): cycled per distinct owner_display_name
|
||||
# so a merged multi-person calendar can still visually tell whose event
|
||||
# is whose -- the panel's own non-black/white ink colors, skipping
|
||||
# black/white (index 0/1 in DEFAULT_PALETTE_RGB) since those are already
|
||||
# the page's text/background.
|
||||
OWNER_COLORS = DEFAULT_PALETTE_RGB[2:]
|
||||
|
||||
|
||||
def _event_colors(event: dict, owners_seen: list[str], palette_rgb: list | None) -> list[tuple[int, int, int]]:
|
||||
"""One color per contributing calendar (event["sources"] -- see
|
||||
calendar_feed.merge_events, which collapses events sharing the exact
|
||||
same title/time across different calendars into one entry with
|
||||
several sources, e.g. a shared family event synced onto more than
|
||||
one person's calendar). Usually just one color; more than one is
|
||||
what tells the "same event, more than one calendar" case apart from
|
||||
an ordinary single-calendar event at render time -- see
|
||||
panel_style.draw_color_chip. Each source's own manually pinned color
|
||||
(FrameCalendar.color_index -- see routers/api_widgets.py's
|
||||
api_widget_calendar_color) resolves against whichever palette this frame
|
||||
actually renders with, so a pinned "Blue" stays this frame's actual
|
||||
blue; a source with no color pinned falls back to the old
|
||||
auto-cycle-by-owner-name behavior. owners_seen is shared across every
|
||||
event/source in a render so that cycle stays consistent view-wide."""
|
||||
sources = event.get("sources") or [
|
||||
{"owner_display_name": event.get("owner_display_name"), "color_index": event.get("color_index")}
|
||||
]
|
||||
colors = []
|
||||
for source in sources:
|
||||
color_index = source.get("color_index")
|
||||
if color_index is not None:
|
||||
palette = palette_rgb or DEFAULT_PALETTE_RGB
|
||||
colors.append(tuple(palette[color_index]))
|
||||
continue
|
||||
owner_display_name = source.get("owner_display_name")
|
||||
if owner_display_name not in owners_seen:
|
||||
owners_seen.append(owner_display_name)
|
||||
colors.append(OWNER_COLORS[owners_seen.index(owner_display_name) % len(OWNER_COLORS)])
|
||||
return colors
|
||||
|
||||
|
||||
def _event_start(event: dict, tz: ZoneInfo) -> datetime | date:
|
||||
"""Parses event["start"] and, for timed events, converts to `tz` --
|
||||
calendar_feed.py stores whatever timezone each source event carried
|
||||
(often UTC), but display/bucketing needs to happen in the frame's own
|
||||
timezone."""
|
||||
dt = datetime.fromisoformat(event["start"])
|
||||
if event["all_day"]:
|
||||
return dt if isinstance(dt, date) and not isinstance(dt, datetime) else dt.date()
|
||||
return dt.astimezone(tz)
|
||||
|
||||
|
||||
def _events_on_day(events: list[dict], day: date, tz: ZoneInfo) -> list[dict]:
|
||||
on_day = [e for e in events if _local_date(e, tz) == day]
|
||||
on_day.sort(key=lambda e: (not e["all_day"], e["start"]))
|
||||
return on_day
|
||||
|
||||
|
||||
def _local_date(event: dict, tz: ZoneInfo) -> date:
|
||||
start = _event_start(event, tz)
|
||||
return start if isinstance(start, date) and not isinstance(start, datetime) else start.date()
|
||||
|
||||
|
||||
def _add_months(d: date, months: int) -> date:
|
||||
total = d.month - 1 + months
|
||||
year = d.year + total // 12
|
||||
month = total % 12 + 1
|
||||
day = min(d.day, calendar_module.monthrange(year, month)[1])
|
||||
return date(year, month, day)
|
||||
|
||||
|
||||
def _fmt_time(dt: datetime) -> str:
|
||||
text = dt.strftime("%I:%M %p").lstrip("0")
|
||||
return text if text else "12:00 AM"
|
||||
|
||||
|
||||
def _fmt_task_due(due: str | None) -> str:
|
||||
""""2026-07-25" or "2026-07-25T14:00:00+00:00" -> "Jul 25" -- tasks
|
||||
only need a compact reminder of when they're due, not the precision
|
||||
an event's own start/end time gets."""
|
||||
if not due:
|
||||
return ""
|
||||
try:
|
||||
dt = datetime.fromisoformat(due)
|
||||
except ValueError:
|
||||
return ""
|
||||
d = dt.date() if isinstance(dt, datetime) else dt
|
||||
return d.strftime("%b %-d")
|
||||
|
||||
|
||||
# Neither Inter (panel_style.font_bold/font_regular, this module's own
|
||||
# body/title font -- see MARGIN/BG/FG comment above) nor PIL's bundled
|
||||
# default font has emoji glyphs, and PIL/FreeType don't skip an
|
||||
# unsupported codepoint, they substitute a ".notdef" tofu box (a visible
|
||||
# filled rectangle) -- reads as a rendering glitch, not "emoji not
|
||||
# supported". So event titles get drawn with two fonts: the normal
|
||||
# text font for everything else, and one of these for actual emoji runs
|
||||
# (see _split_emoji_runs/_draw_mixed_line) -- both Noto Emoji, OFL-1.1,
|
||||
# vendored at app/fonts/ (license alongside at app/fonts/OFL.txt).
|
||||
#
|
||||
# Color (NotoColorEmoji.ttf) is tried first: full-color CBDT bitmap
|
||||
# glyphs, which the panel's own Floyd-Steinberg dithering turns into a
|
||||
# recognizable (if slightly speckled) color rendering rather than a flat
|
||||
# monochrome shape -- confirmed by actually rendering a test agenda row
|
||||
# through the real quantizer, not just theorizing about it. Its one
|
||||
# real quirk: CBDT stores glyphs at a single embedded bitmap size
|
||||
# (_COLOR_EMOJI_NATIVE_SIZE), so every glyph is rasterized once at that
|
||||
# size and scaled down to the target row height, unlike normal vector
|
||||
# text which draws directly at whatever size is asked for.
|
||||
#
|
||||
# NotoEmoji.ttf (monochrome, vector) is the fallback for a deployment
|
||||
# whose Pillow/FreeType wasn't built with embedded color bitmap support
|
||||
# -- confirmed working locally, but that's a build-time detail this
|
||||
# project doesn't control everywhere it might run, so a rendering
|
||||
# failure falls back instead of showing nothing/crashing.
|
||||
_COLOR_EMOJI_FONT_PATH = Path(__file__).parent / "fonts" / "NotoColorEmoji.ttf"
|
||||
_MONO_EMOJI_FONT_PATH = Path(__file__).parent / "fonts" / "NotoEmoji.ttf"
|
||||
_COLOR_EMOJI_NATIVE_SIZE = 109
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _color_emoji_font() -> ImageFont.FreeTypeFont | None:
|
||||
try:
|
||||
return ImageFont.truetype(str(_COLOR_EMOJI_FONT_PATH), _COLOR_EMOJI_NATIVE_SIZE)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _mono_emoji_font(size: int) -> ImageFont.FreeTypeFont:
|
||||
return ImageFont.truetype(str(_MONO_EMOJI_FONT_PATH), size)
|
||||
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
def _emoji_glyph(run_text: str, target_h: int) -> Image.Image:
|
||||
"""One emoji run (consecutive emoji collapse into a single run, see
|
||||
_split_emoji_runs) as an RGBA image target_h tall, ready to
|
||||
alpha-composite onto the canvas. Tries color first, falls back to
|
||||
monochrome (rendered directly at target_h, since that font is
|
||||
vector) if the color font failed to load or this Pillow/FreeType
|
||||
build can't decode its embedded bitmaps. Cached -- the same emoji
|
||||
recurs across a household's events, and rasterizing+scaling isn't
|
||||
free."""
|
||||
color_font = _color_emoji_font()
|
||||
if color_font is not None:
|
||||
try:
|
||||
probe = ImageDraw.Draw(Image.new("RGBA", (1, 1)))
|
||||
raw_w = max(1, round(probe.textlength(run_text, font=color_font)))
|
||||
tmp = Image.new("RGBA", (raw_w, _COLOR_EMOJI_NATIVE_SIZE), (255, 255, 255, 0))
|
||||
ImageDraw.Draw(tmp).text((0, 0), run_text, font=color_font, embedded_color=True)
|
||||
scale = target_h / _COLOR_EMOJI_NATIVE_SIZE
|
||||
return tmp.resize((max(1, round(raw_w * scale)), target_h), Image.LANCZOS)
|
||||
except Exception:
|
||||
pass # this deployment's Pillow can't render embedded color bitmaps -- fall back
|
||||
|
||||
mono_font = _mono_emoji_font(target_h)
|
||||
bbox = mono_font.getbbox(run_text)
|
||||
w, h = max(1, bbox[2] - bbox[0]), max(1, bbox[3] - bbox[1])
|
||||
mask = Image.new("L", (w, h), 0)
|
||||
ImageDraw.Draw(mask).text((-bbox[0], -bbox[1]), run_text, fill=255, font=mono_font)
|
||||
glyph = Image.new("RGBA", (w, h), (255, 255, 255, 0))
|
||||
glyph.paste((0, 0, 0, 255), (0, 0), mask)
|
||||
return glyph
|
||||
|
||||
|
||||
# Matches runs of actual emoji base characters (the standard Unicode
|
||||
# emoji blocks -- stable ranges even as new individual emoji get added
|
||||
# within them, so this doesn't need updating as emoji sets grow).
|
||||
_EMOJI_PATTERN = re.compile(
|
||||
"["
|
||||
"\U0001F1E6-\U0001F1FF" # regional indicator symbols (flag emoji)
|
||||
"\U0001F300-\U0001F5FF" # misc symbols & pictographs
|
||||
"\U0001F600-\U0001F64F" # emoticons
|
||||
"\U0001F680-\U0001F6FF" # transport & map symbols
|
||||
"\U0001F900-\U0001F9FF" # supplemental symbols & pictographs
|
||||
"\U0001FA70-\U0001FAFF" # symbols & pictographs extended-A
|
||||
"\U00002600-\U000026FF" # misc symbols (☀☂☕ etc.)
|
||||
"\U00002700-\U000027BF" # dingbats (✂✈✉ etc.)
|
||||
"]+"
|
||||
)
|
||||
_EMOJI_SPLIT_PATTERN = re.compile(f"({_EMOJI_PATTERN.pattern})")
|
||||
# Codepoints with no meaningful standalone glyph once color/ligature
|
||||
# context is dropped: skin-tone modifiers (this is monochrome -- no
|
||||
# color to modify), the variation selector that just requests emoji
|
||||
# presentation, and the zero-width joiner used to fuse multiple emoji
|
||||
# into one combined glyph. That fusion (e.g. the "family" emoji from
|
||||
# four base emoji + 3 ZWJs) needs OpenType ligature substitution
|
||||
# (raqm/harfbuzz), which Pillow only does with a specific, non-default
|
||||
# build -- not something to depend on. Stripping the ZWJ instead means a
|
||||
# ZWJ sequence just draws as its individual base glyphs side by side
|
||||
# (four separate people instead of one family glyph) -- a real fallback,
|
||||
# not a crash or tofu.
|
||||
_EMOJI_MODIFIER_PATTERN = re.compile("[\U0001F3FB-\U0001F3FF\U0000FE0F\U0000200D]")
|
||||
|
||||
|
||||
def _split_emoji_runs(text: str) -> list[tuple[str, bool]]:
|
||||
"""text -> [(run, is_emoji), ...], modifier/joiner codepoints
|
||||
dropped first (see _EMOJI_MODIFIER_PATTERN). Consecutive emoji
|
||||
collapse into one run (_EMOJI_PATTERN's own "+"), consecutive
|
||||
plain-text characters into the other."""
|
||||
cleaned = _EMOJI_MODIFIER_PATTERN.sub("", text)
|
||||
parts = [p for p in _EMOJI_SPLIT_PATTERN.split(cleaned) if p]
|
||||
return [(p, bool(_EMOJI_PATTERN.fullmatch(p))) for p in parts]
|
||||
|
||||
|
||||
def _draw_mixed_line(img: Image.Image, draw: ImageDraw.ImageDraw, xy: tuple[int, int], text: str,
|
||||
text_font: ImageFont.ImageFont, max_width: int, fill: tuple[int, int, int] = FG) -> None:
|
||||
"""Draws `text` left-to-right, switching between text_font (normal
|
||||
characters) and an emoji glyph image (actual emoji runs, per
|
||||
_split_emoji_runs/_emoji_glyph) so emoji visibly render instead of a
|
||||
tofu box. Truncates with "..." once max_width is exceeded -- unlike
|
||||
_truncate_to_width this can't binary-search a single font's metrics
|
||||
across mixed fonts/images, so it works run-by-run instead (and can't
|
||||
partially truncate an emoji run the way it can a text run -- one
|
||||
that doesn't fit just isn't drawn). Fine for the short single-line
|
||||
strings this draws (event/task titles), not meant as a general
|
||||
rich-text layout engine. `fill` only affects text runs -- emoji
|
||||
glyphs are already their own color."""
|
||||
x, y = xy
|
||||
cursor = x
|
||||
# A little taller than text_font's own size so glyphs don't look
|
||||
# cramped next to it; the -2 paste offset below roughly centers that
|
||||
# against the surrounding text's row -- tuned by eye against a real
|
||||
# rendered agenda row, not derived from font metrics.
|
||||
emoji_h = text_font.size + 6
|
||||
for run_text, is_emoji in _split_emoji_runs(text):
|
||||
remaining = max_width - (cursor - x)
|
||||
if remaining <= 0:
|
||||
break
|
||||
if is_emoji:
|
||||
glyph = _emoji_glyph(run_text, emoji_h)
|
||||
if glyph.width <= remaining:
|
||||
img.paste(glyph, (round(cursor), y - 2), glyph)
|
||||
cursor += glyph.width
|
||||
else:
|
||||
break
|
||||
else:
|
||||
run_w = draw.textlength(run_text, font=text_font)
|
||||
if run_w <= remaining:
|
||||
draw_text(img, (round(cursor), y), run_text, text_font, fill)
|
||||
cursor += run_w
|
||||
else:
|
||||
draw_text(img, (round(cursor), y), _truncate_to_width(draw, run_text, text_font, remaining),
|
||||
text_font, fill)
|
||||
break
|
||||
|
||||
|
||||
def _truncate_to_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont, max_width: int) -> str:
|
||||
"""Pixel-width-aware truncation (unlike device.py's char-count
|
||||
_truncate, tuned for a fixed firmware font at a fixed size) -- this
|
||||
module draws at several different sizes, so truncation has to
|
||||
measure the actual font/size in play. Still uses `draw.textlength`
|
||||
for measurement (identical metrics to draw_text's own bbox), just
|
||||
doesn't paint anything."""
|
||||
if draw.textlength(text, font=font) <= max_width:
|
||||
return text
|
||||
ellipsis = "..."
|
||||
lo, hi = 0, len(text)
|
||||
while lo < hi:
|
||||
mid = (lo + hi + 1) // 2
|
||||
if draw.textlength(text[:mid] + ellipsis, font=font) <= max_width:
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid - 1
|
||||
return text[:lo] + ellipsis if lo else ellipsis
|
||||
|
||||
|
||||
# --- Size tiers ---------------------------------------------------------
|
||||
#
|
||||
# A calendar widget can now be placed at any grid footprint (see
|
||||
# app/grid.py), not just the full panel -- these three discrete tiers
|
||||
# (chosen by nearest-fit against the target box's pixel area) drive font
|
||||
# sizes/margins instead of continuously scaling a layout that was tuned
|
||||
# by eye for the full ~800x480 panel, which would risk ugly proportions
|
||||
# at odd in-between sizes. Area-based (not width/height-based) so the
|
||||
# same footprint tiers the same regardless of landscape/portrait target
|
||||
# box shape.
|
||||
_TIER_LARGE_AREA = 280_000 # near/at a full 800x480 panel (384,000px^2)
|
||||
_TIER_MEDIUM_AREA = 120_000 # roughly a half-panel split
|
||||
|
||||
|
||||
def _size_tier(target_w: int, target_h: int) -> str:
|
||||
area = target_w * target_h
|
||||
if area >= _TIER_LARGE_AREA:
|
||||
return "large"
|
||||
if area >= _TIER_MEDIUM_AREA:
|
||||
return "medium"
|
||||
return "small"
|
||||
|
||||
|
||||
def _month_view_fits(target_w: int, target_h: int) -> bool:
|
||||
"""Month view needs real width to keep 7 columns' day numbers and
|
||||
density dots legible -- below the "small" size tier that stops being
|
||||
true, so _build falls back to agenda view instead of drawing an
|
||||
unreadable grid."""
|
||||
return _size_tier(target_w, target_h) != "small"
|
||||
|
||||
|
||||
# --- Weather strip, agenda/today & tomorrow/week views only (never
|
||||
# month -- see _BUILDERS/_build) --------------------------------------
|
||||
|
||||
def _weather_for_day(weather_cities: list[dict] | None, day: date) -> list[dict]:
|
||||
"""[{"label", "code", "high", "low", "category"}, ...] for every
|
||||
configured city that has a cached forecast for this specific date --
|
||||
weather_cities is routers/common.py's get_or_refresh_weather() cache
|
||||
shape, [{"label", "days": {"YYYY-MM-DD": {"code","high","low"}}}]."""
|
||||
if not weather_cities:
|
||||
return []
|
||||
key = day.isoformat()
|
||||
entries = []
|
||||
for city in weather_cities:
|
||||
d = (city.get("days") or {}).get(key)
|
||||
if d is None:
|
||||
continue
|
||||
# Just the city name on-panel ("Portland", not the full
|
||||
# disambiguated "Portland, Oregon, United States") -- that fuller
|
||||
# form matters for telling apart geocoder candidates when adding
|
||||
# a city (see weather.geocode_city), not for a compact display row.
|
||||
entries.append({"label": city["label"].split(",")[0].strip(), "high": d["high"], "low": d["low"],
|
||||
"category": weather_category(d["code"])})
|
||||
return entries
|
||||
|
||||
|
||||
def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, events: list[dict], tz: ZoneInfo,
|
||||
region: tuple[int, int, int, int], title_font: ImageFont.ImageFont,
|
||||
body_font: ImageFont.ImageFont, owners_seen: list[str], palette_rgb: list | None = None,
|
||||
weather_cities: list[dict] | None = None, weather_font: ImageFont.ImageFont | None = None,
|
||||
weather_units: str = "fahrenheit") -> None:
|
||||
"""Draws one day's header + weather strip (if any) + event rows
|
||||
within `region` (x0, y0, w, h) -- factored out of _build_agenda so
|
||||
the today-and-tomorrow view (_build_today_tomorrow) can stack two of
|
||||
these vertically without duplicating the row-layout/truncation
|
||||
logic. Weather is drawn above the event list -- eating into the same
|
||||
row budget the event count is truncated against, exactly like the
|
||||
header bar above it already does."""
|
||||
x0, y0, w, h = region
|
||||
header_h = title_font.size + 20
|
||||
panel_style.draw_header_bar(draw, (x0, y0, w, header_h), header_h,
|
||||
panel_style.theme_color("calendar", palette_rgb))
|
||||
text_x0 = x0 + MARGIN
|
||||
text_w = w - MARGIN * 2
|
||||
header = day.strftime("%A, %B ") + str(day.day)
|
||||
draw_text(img, (text_x0, y0 + (header_h - title_font.size) // 2),
|
||||
_truncate_to_width(draw, header, title_font, text_w), title_font, BG)
|
||||
y = y0 + header_h + 12
|
||||
|
||||
weather_entries = _weather_for_day(weather_cities, day)
|
||||
if weather_entries:
|
||||
y += draw_weather_row(img, draw, text_x0, y, text_w, weather_entries,
|
||||
icon_r=title_font.size // 2, font=weather_font or body_font, units=weather_units,
|
||||
palette_rgb=palette_rgb)
|
||||
|
||||
day_events = _events_on_day(events, day, tz)
|
||||
row_h = body_font.size + 14
|
||||
max_rows = max(0, (y0 + h - MARGIN - y) // row_h)
|
||||
|
||||
if not day_events:
|
||||
draw_text(img, (text_x0, y), "Nothing scheduled", body_font)
|
||||
for i, event in enumerate(day_events):
|
||||
if i >= max_rows:
|
||||
draw_text(img, (text_x0, y), f"+{len(day_events) - max_rows} more", body_font)
|
||||
break
|
||||
colors = _event_colors(event, owners_seen, palette_rgb)
|
||||
panel_style.draw_color_chip(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors)
|
||||
time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz))
|
||||
prefix = f"{time_str} "
|
||||
draw_text(img, (text_x0 + 18, y), prefix, body_font)
|
||||
prefix_w = draw.textlength(prefix, font=body_font)
|
||||
_draw_mixed_line(img, draw, (text_x0 + 18 + prefix_w, y), event["summary"],
|
||||
body_font, text_w - 18 - prefix_w)
|
||||
y += row_h
|
||||
|
||||
|
||||
def _draw_tasks(img: Image.Image, draw: ImageDraw.ImageDraw, region: tuple[int, int, int, int],
|
||||
tasks: list[dict], title_font: ImageFont.ImageFont, body_font: ImageFont.ImageFont,
|
||||
palette_rgb: list | None = None, title: str = "Tasks") -> None:
|
||||
"""A simple checklist filling `region` (x0, y0, w, h) -- a header
|
||||
(`title`, truncated to fit -- TaskWidgetConfig.name or the "Tasks"
|
||||
default; the only widget type with its own on-panel title, since
|
||||
it's the only one where "which list is this" isn't obvious from its
|
||||
content the way a calendar/photo/whiteboard's is), then a color chip
|
||||
(reusing _event_colors/panel_style.draw_color_chip as-is: a task
|
||||
dict's top-level owner_display_name/color_index is exactly
|
||||
_event_colors' single-source fallback shape, since caldav_client.
|
||||
merge_tasks doesn't cross-list-dedup tasks into a "sources" list the
|
||||
way merge_events dedups events) + checkbox glyph + due date (if any)
|
||||
+ summary per task, same header/row-cap/truncation shape as
|
||||
_draw_agenda_day's event list so the standalone tasks widget (see
|
||||
_build_tasks) reads as the same consistent design as everything
|
||||
else on-panel, not a bolted-together look. Reuses _draw_mixed_line
|
||||
so a task summary with emoji in it renders the same way an event
|
||||
title's does.
|
||||
|
||||
Outstanding tasks get an empty checkbox; completed ones (only ever
|
||||
present when TaskWidgetConfig.show_completed is on -- see
|
||||
caldav_client.fetch_tasks' completed_since) get a filled checkbox in
|
||||
this widget's own Green accent (see panel_style.THEME) -- that fill
|
||||
is the "done" signal, no due-date prefix (irrelevant once done) and
|
||||
no separate muted text treatment (see module-level MUTED removal
|
||||
note above _event_colors)."""
|
||||
x0, y0, w, h = region
|
||||
header_h = title_font.size + 20
|
||||
panel_style.draw_header_bar(draw, (x0, y0, w, header_h), header_h,
|
||||
panel_style.theme_color("tasks", palette_rgb))
|
||||
text_x0 = x0 + MARGIN
|
||||
text_w = w - MARGIN * 2
|
||||
draw_text(img, (text_x0, y0 + (header_h - title_font.size) // 2),
|
||||
_truncate_to_width(draw, title or "Tasks", title_font, text_w), title_font, BG)
|
||||
y = y0 + header_h + 12
|
||||
|
||||
row_h = body_font.size + 14
|
||||
max_rows = max(0, (y0 + h - MARGIN - y) // row_h)
|
||||
|
||||
if not tasks:
|
||||
draw_text(img, (text_x0, y), "Nothing outstanding", body_font)
|
||||
return
|
||||
owners_seen: list[str] = []
|
||||
checkbox_fill = panel_style.theme_color("tasks", palette_rgb)
|
||||
for i, task in enumerate(tasks):
|
||||
if i >= max_rows:
|
||||
draw_text(img, (text_x0, y), f"+{len(tasks) - max_rows} more", body_font)
|
||||
break
|
||||
done = task.get("completed_at") is not None
|
||||
colors = _event_colors(task, owners_seen, palette_rgb)
|
||||
panel_style.draw_color_chip(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors)
|
||||
box = body_font.size - 6
|
||||
box_x = text_x0 + 18
|
||||
box_y = y + (row_h - box) // 2 - 5
|
||||
box_r = min(panel_style.CHIP_RADIUS, box // 2)
|
||||
if done:
|
||||
draw.rounded_rectangle([box_x, box_y, box_x + box, box_y + box], radius=box_r, fill=checkbox_fill)
|
||||
else:
|
||||
draw.rounded_rectangle([box_x, box_y, box_x + box, box_y + box], radius=box_r, outline=FG, width=2)
|
||||
text_x = box_x + box + 10
|
||||
due_str = None if done else _fmt_task_due(task.get("due"))
|
||||
prefix = f"{due_str} " if due_str else ""
|
||||
if prefix:
|
||||
draw_text(img, (text_x, y), prefix, body_font)
|
||||
prefix_w = draw.textlength(prefix, font=body_font) if prefix else 0
|
||||
_draw_mixed_line(img, draw, (round(text_x + prefix_w), y), task["summary"],
|
||||
body_font, text_w - (text_x - text_x0) - prefix_w)
|
||||
y += row_h
|
||||
|
||||
|
||||
# Per-tier (title, body, weather) font sizes -- "Wednesday, July 22" at
|
||||
# full size doesn't fit a narrow column, and a narrower box is exactly
|
||||
# when a smaller font (rather than truncating to "Wednesday...") keeps
|
||||
# the header actually informative.
|
||||
_AGENDA_FONTS = {"large": (34, 22, 20), "medium": (24, 22, 16), "small": (18, 16, 13)}
|
||||
|
||||
|
||||
def _build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
|
||||
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
|
||||
weather_units: str = "fahrenheit", font_scale: float = 1.0) -> Image.Image:
|
||||
img, draw, region = panel_style.card_canvas(target_w, target_h)
|
||||
|
||||
title_size, body_size, weather_size = (
|
||||
panel_style.scaled_size(v, font_scale) for v in _AGENDA_FONTS[_size_tier(target_w, target_h)]
|
||||
)
|
||||
title_font = panel_style.font_bold(title_size)
|
||||
body_font = panel_style.font_regular(body_size)
|
||||
weather_font = panel_style.font_regular(weather_size)
|
||||
|
||||
day = datetime.now(tz).date() + timedelta(days=browse_offset)
|
||||
owners_seen: list[str] = []
|
||||
_draw_agenda_day(img, draw, day, events, tz, region, title_font, body_font, owners_seen,
|
||||
palette_rgb, weather_cities, weather_font, weather_units)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
_TODAY_TOMORROW_FONTS = {"large": (26, 18, 16), "medium": (20, 15, 13), "small": (15, 12, 10)}
|
||||
|
||||
|
||||
def _build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
|
||||
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
|
||||
weather_units: str = "fahrenheit", font_scale: float = 1.0) -> Image.Image:
|
||||
"""Two _draw_agenda_day sections stacked vertically (below each other
|
||||
rather than side-by-side -- narrower than tall doesn't leave enough
|
||||
width per day for the event-row text at smaller sizes). browse_offset
|
||||
shifts the whole two-day window together, same "days" unit
|
||||
_build_agenda already uses, so NEXT/BACK behaves identically across
|
||||
both views."""
|
||||
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
|
||||
|
||||
title_size, body_size, weather_size = (
|
||||
panel_style.scaled_size(v, font_scale) for v in _TODAY_TOMORROW_FONTS[_size_tier(target_w, target_h)]
|
||||
)
|
||||
title_font = panel_style.font_bold(title_size)
|
||||
body_font = panel_style.font_regular(body_size)
|
||||
weather_font = panel_style.font_regular(weather_size)
|
||||
|
||||
start_day = datetime.now(tz).date() + timedelta(days=browse_offset)
|
||||
section_h = ch // 2
|
||||
owners_seen: list[str] = []
|
||||
for i in range(2):
|
||||
section_y0 = cy0 + i * section_h
|
||||
if i > 0:
|
||||
draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - MARGIN, section_y0)], fill=RULE)
|
||||
_draw_agenda_day(img, draw, start_day + timedelta(days=i), events, tz,
|
||||
(cx0, section_y0, cw, section_h), title_font, body_font, owners_seen,
|
||||
palette_rgb, weather_cities, weather_font, weather_units)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
# Vertical layout's base (title, body, weather) sizes, before the
|
||||
# per-day-count reduction below -- same three tiers as every other view.
|
||||
_WEEK_VERTICAL_FONTS = {"large": (26, 18, 16), "medium": (20, 15, 13), "small": (16, 12, 10)}
|
||||
# Horizontal layout's (header, chip, weather) sizes.
|
||||
_WEEK_HORIZONTAL_FONTS = {"large": (18, 14, 12), "medium": (14, 12, 10), "small": (11, 10, 8)}
|
||||
|
||||
|
||||
def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
|
||||
week_start: int, palette_rgb: list | None = None,
|
||||
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
||||
days: int = 7, layout: str = "horizontal",
|
||||
start_offset: int = 0, font_scale: float = 1.0) -> Image.Image:
|
||||
"""`days` (2-10, see routers/api_widgets.py's clamp) side-by-side
|
||||
columns (layout="horizontal", the original fixed-at-7 behavior
|
||||
generalized) or stacked bands (layout="vertical", reusing
|
||||
_draw_agenda_day the same way _build_today_tomorrow does, just for
|
||||
an arbitrary day count instead of a hardcoded 2).
|
||||
|
||||
At the default 7 days, the view anchors to week_start (a fixed
|
||||
weekday, "start on the most recent Monday") exactly like before --
|
||||
otherwise "start of the week" doesn't mean much for an arbitrary day
|
||||
count, so it instead starts `start_offset` days from today (0 =
|
||||
today, see routers/api_widgets.py's api_widget_config_save)."""
|
||||
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
|
||||
tier = _size_tier(target_w, target_h)
|
||||
|
||||
today = datetime.now(tz).date()
|
||||
if days == 7:
|
||||
days_since_start = (today.weekday() - week_start) % 7
|
||||
week_first_day = today - timedelta(days=days_since_start) + timedelta(days=days * browse_offset)
|
||||
else:
|
||||
week_first_day = today + timedelta(days=start_offset) + timedelta(days=days * browse_offset)
|
||||
owners_seen: list[str] = []
|
||||
|
||||
if layout == "vertical":
|
||||
title_base, body_base, weather_base = _WEEK_VERTICAL_FONTS[tier]
|
||||
title_font = panel_style.font_bold(panel_style.scaled_size(max(14, title_base - days), font_scale))
|
||||
body_font = panel_style.font_regular(panel_style.scaled_size(max(11, body_base - days), font_scale))
|
||||
weather_font = panel_style.font_regular(panel_style.scaled_size(max(9, weather_base - days), font_scale))
|
||||
section_h = ch // days
|
||||
for i in range(days):
|
||||
section_y0 = cy0 + i * section_h
|
||||
if i > 0:
|
||||
draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - MARGIN, section_y0)], fill=RULE)
|
||||
day = week_first_day + timedelta(days=i)
|
||||
_draw_agenda_day(img, draw, day, events, tz, (cx0, section_y0, cw, section_h),
|
||||
title_font, body_font, owners_seen, palette_rgb,
|
||||
weather_cities, weather_font, weather_units)
|
||||
return img
|
||||
|
||||
header_size, chip_size, weather_size = (
|
||||
panel_style.scaled_size(v, font_scale) for v in _WEEK_HORIZONTAL_FONTS[tier]
|
||||
)
|
||||
header_font = panel_style.font_bold(header_size)
|
||||
chip_font = panel_style.font_regular(chip_size)
|
||||
weather_font = panel_style.font_regular(weather_size)
|
||||
col_w = (cw - MARGIN * 2) // days
|
||||
header_h = 44
|
||||
|
||||
for col in range(days):
|
||||
day = week_first_day + timedelta(days=col)
|
||||
x0 = cx0 + MARGIN + col * col_w
|
||||
if col > 0:
|
||||
draw.line([(x0, cy0 + MARGIN), (x0, cy0 + ch - MARGIN)], fill=RULE)
|
||||
label = day.strftime("%a %-d") if day != today else f"* {day.strftime('%a %-d')}"
|
||||
draw_text(img, (x0 + 6, cy0 + MARGIN), _truncate_to_width(draw, label, header_font, col_w - 10), header_font)
|
||||
|
||||
y = cy0 + MARGIN + header_h
|
||||
# Columns are narrow, so only what actually fits gets drawn (see
|
||||
# weather_render.draw_weather_row) -- typically one city, no label
|
||||
# (the column itself makes which day it's for obvious; a city name
|
||||
# wouldn't fit anyway). Never more than that -- this is already
|
||||
# the tight view.
|
||||
weather_entries = _weather_for_day(weather_cities, day)
|
||||
if weather_entries:
|
||||
y += draw_weather_row(img, draw, x0 + 4, y, col_w - 8, weather_entries,
|
||||
icon_r=8, font=weather_font, units=weather_units, show_labels=False,
|
||||
palette_rgb=palette_rgb)
|
||||
row_h = chip_font.size + 10
|
||||
max_rows = max(0, (cy0 + ch - MARGIN - y) // row_h)
|
||||
day_events = _events_on_day(events, day, tz)
|
||||
for i, event in enumerate(day_events):
|
||||
if i >= max_rows:
|
||||
draw_text(img, (x0 + 6, y), f"+{len(day_events) - max_rows}", chip_font)
|
||||
break
|
||||
colors = _event_colors(event, owners_seen, palette_rgb)
|
||||
panel_style.draw_color_chip(draw, x0 + 4, y + 1, x0 + 11, y + row_h - 5, colors, radius=2)
|
||||
if event["all_day"]:
|
||||
_draw_mixed_line(img, draw, (x0 + 16, y), event["summary"], chip_font, col_w - 20)
|
||||
else:
|
||||
prefix = f"{_fmt_time(_event_start(event, tz))[:-3]} "
|
||||
draw_text(img, (x0 + 16, y), prefix, chip_font)
|
||||
prefix_w = draw.textlength(prefix, font=chip_font)
|
||||
_draw_mixed_line(img, draw, (x0 + 16 + prefix_w, y), event["summary"],
|
||||
chip_font, col_w - 20 - prefix_w)
|
||||
y += row_h
|
||||
|
||||
return img
|
||||
|
||||
|
||||
# Only "large"/"medium" in practice -- _build falls back to agenda view
|
||||
# below the "small" tier (see _month_view_fits) -- but keyed defensively
|
||||
# by tier rather than a bare bool so a future tier addition can't
|
||||
# silently fall through to a KeyError here.
|
||||
_MONTH_FONTS = {"large": (16, 18), "medium": (12, 13), "small": (12, 13)}
|
||||
|
||||
|
||||
def _build_month(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
|
||||
week_start: int, palette_rgb: list | None = None, font_scale: float = 1.0) -> Image.Image:
|
||||
"""Density dots per day, not literal event text -- real text at
|
||||
typical month-cell size (~100x70px) is close to unreadable on a
|
||||
6-color dithered e-ink panel. Capped at 4 visible dots, "+N" beyond.
|
||||
"Not in this month" day numbers used to be a muted gray -- now
|
||||
de-emphasized by weight instead (Regular vs. Bold), same reasoning
|
||||
as everywhere else this module dropped MUTED -- see module-level
|
||||
comment above MARGIN/BG/FG."""
|
||||
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
|
||||
|
||||
header_size, day_size = (
|
||||
panel_style.scaled_size(v, font_scale) for v in _MONTH_FONTS[_size_tier(target_w, target_h)]
|
||||
)
|
||||
header_font = panel_style.font_bold(header_size)
|
||||
day_font_in_month = panel_style.font_bold(day_size)
|
||||
day_font_out_of_month = panel_style.font_regular(day_size)
|
||||
|
||||
today = datetime.now(tz).date()
|
||||
target_month = _add_months(date(today.year, today.month, 1), browse_offset)
|
||||
weeks = list(calendar_module.Calendar(firstweekday=week_start).monthdatescalendar(target_month.year, target_month.month))
|
||||
|
||||
col_w = (cw - MARGIN * 2) // 7
|
||||
header_h = 28
|
||||
grid_top = cy0 + MARGIN + header_h
|
||||
row_h = (cy0 + ch - MARGIN - grid_top) // len(weeks)
|
||||
today_accent = panel_style.theme_color("calendar", palette_rgb)
|
||||
today_badge_r = min(panel_style.CHIP_RADIUS, 9)
|
||||
|
||||
day_names = WEEKDAY_NAMES[week_start:] + WEEKDAY_NAMES[:week_start]
|
||||
for col, name in enumerate(day_names):
|
||||
draw_text(img, (cx0 + MARGIN + col * col_w + 6, cy0 + MARGIN), name[:3], header_font)
|
||||
|
||||
owners_seen: list[str] = []
|
||||
dot_r = 6
|
||||
for row, week in enumerate(weeks):
|
||||
for col, day in enumerate(week):
|
||||
x0 = cx0 + MARGIN + col * col_w
|
||||
y0 = grid_top + row * row_h
|
||||
draw.rectangle([x0, y0, x0 + col_w, y0 + row_h], outline=RULE)
|
||||
in_month = day.month == target_month.month
|
||||
if day == today:
|
||||
# A filled accent badge (this widget's own theme color,
|
||||
# see panel_style.THEME) instead of the old bare outline
|
||||
# -- an actual "today" indicator, not just an outline
|
||||
# easy to miss at ~24px. Sized around the actual digit
|
||||
# bbox (not a fixed pixel box) so a bold 2-digit day
|
||||
# number ("30") fits as comfortably as a single digit
|
||||
# ("3") at every size tier.
|
||||
day_str = str(day.day)
|
||||
text_x, text_y = x0 + 6, y0 + 4
|
||||
dbbox = draw.textbbox((text_x, text_y), day_str, font=day_font_in_month)
|
||||
pad = 3
|
||||
badge_rect = [dbbox[0] - pad, dbbox[1] - pad, dbbox[2] + pad, dbbox[3] + pad]
|
||||
badge_r = min(today_badge_r, (badge_rect[3] - badge_rect[1]) // 2)
|
||||
draw.rounded_rectangle(badge_rect, radius=badge_r, fill=today_accent)
|
||||
draw_text(img, (text_x, text_y), day_str, day_font_in_month, BG)
|
||||
else:
|
||||
day_font = day_font_in_month if in_month else day_font_out_of_month
|
||||
draw_text(img, (x0 + 6, y0 + 4), str(day.day), day_font)
|
||||
|
||||
day_events = _events_on_day(events, day, tz)
|
||||
dot_x = x0 + 8
|
||||
dot_y = y0 + row_h - dot_r * 2 - 6
|
||||
for i, event in enumerate(day_events[:4]):
|
||||
# First contributing calendar's color only, even for a
|
||||
# deduplicated shared event -- month view is density
|
||||
# dots, not a place to also show which calendars a
|
||||
# shared event came from (see _event_colors).
|
||||
event_color = _event_colors(event, owners_seen, palette_rgb)[0]
|
||||
draw.ellipse([dot_x, dot_y, dot_x + dot_r * 2, dot_y + dot_r * 2], fill=event_color)
|
||||
dot_x += dot_r * 2 + 5
|
||||
if len(day_events) > 4:
|
||||
draw_text(img, (dot_x, dot_y - 2), f"+{len(day_events) - 4}", header_font)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
_BUILDERS = {"agenda": _build_agenda, "today_tomorrow": _build_today_tomorrow, "week": _build_week,
|
||||
"month": _build_month}
|
||||
|
||||
|
||||
def _build(events: list[dict], view: str, browse_offset: int, target_w: int, target_h: int, timezone: str,
|
||||
fetch_summary: str, week_start: int, palette_rgb: list | None = None,
|
||||
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) -> Image.Image:
|
||||
tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC")
|
||||
effective_view = view
|
||||
if view == "month" and not _month_view_fits(target_w, target_h):
|
||||
effective_view = "agenda"
|
||||
|
||||
if effective_view == "agenda":
|
||||
img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb,
|
||||
weather_cities, weather_units, font_scale)
|
||||
elif effective_view == "today_tomorrow":
|
||||
img = _build_today_tomorrow(events, browse_offset, target_w, target_h, tz, palette_rgb,
|
||||
weather_cities, weather_units, font_scale)
|
||||
elif effective_view == "week":
|
||||
img = _build_week(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb,
|
||||
weather_cities, weather_units, week_days, week_layout, week_start_offset, font_scale)
|
||||
elif effective_view == "month":
|
||||
# Never given weather -- no room for it at typical month-cell
|
||||
# size, same reasoning that already keeps this view to density
|
||||
# dots instead of literal event text (see _build_month's own
|
||||
# docstring). Colors are still passed through, though -- that's
|
||||
# a different concern (legibility of individual events) than
|
||||
# needing a whole extra strip of content.
|
||||
img = _build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, font_scale)
|
||||
else:
|
||||
img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb,
|
||||
weather_cities, weather_units, font_scale)
|
||||
|
||||
if fetch_summary:
|
||||
# Drawn as a final overlay onto the already-composited img (not
|
||||
# inside any one _build_* branch above), so it offsets by
|
||||
# panel_style.GUTTER itself to land inside the same visible
|
||||
# margin every builder's own content already respects.
|
||||
font = panel_style.font_regular(14 if _size_tier(target_w, target_h) != "small" else 11)
|
||||
draw_text(img, (panel_style.GUTTER + MARGIN, target_h - panel_style.GUTTER - MARGIN - font.size),
|
||||
fetch_summary, font)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def render_calendar(events: list[dict], view: str, browse_offset: int, orientation: str,
|
||||
palette_rgb: list | None, timezone: str,
|
||||
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, panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> bytes:
|
||||
"""Renders one of CALENDAR_VIEWS full-panel to the panel's packed
|
||||
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)
|
||||
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||
return _transpose_and_pack(quantized, orientation)
|
||||
|
||||
|
||||
def render_calendar_preview_png(events: list[dict], view: str, browse_offset: int, orientation: str,
|
||||
palette_rgb: list | None, timezone: str,
|
||||
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,
|
||||
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, 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)
|
||||
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||
buf = io.BytesIO()
|
||||
quantized.convert("RGB").save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# --- Standalone tasks widget (split out of the old calendar-widget-only
|
||||
# week-view task list -- see models.TaskWidgetConfig) -----------------
|
||||
|
||||
_TASKS_FONTS = {"large": (24, 18), "medium": (20, 16), "small": (16, 13)}
|
||||
|
||||
|
||||
def _build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None,
|
||||
title: str = "Tasks", font_scale: float = 1.0) -> Image.Image:
|
||||
"""A tasks widget's entire region is the checklist -- unlike the old
|
||||
week-view slot, there's no day columns/header to share space with,
|
||||
so this is just _draw_tasks over the whole box."""
|
||||
img, draw, region = panel_style.card_canvas(target_w, target_h)
|
||||
title_size, body_size = (
|
||||
panel_style.scaled_size(v, font_scale) for v in _TASKS_FONTS[_size_tier(target_w, target_h)]
|
||||
)
|
||||
title_font = panel_style.font_bold(title_size)
|
||||
body_font = panel_style.font_regular(body_size)
|
||||
_draw_tasks(img, draw, region, tasks, title_font, body_font, palette_rgb, title)
|
||||
return img
|
||||
|
||||
|
||||
def render_tasks(tasks: list[dict], orientation: str, palette_rgb: list | None,
|
||||
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.
|
||||
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)
|
||||
return _transpose_and_pack(quantized, orientation)
|
||||
|
||||
|
||||
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,
|
||||
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, 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)
|
||||
buf = io.BytesIO()
|
||||
quantized.convert("RGB").save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
+31
-1
@@ -16,7 +16,7 @@ from typing import Iterator
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from .models import Frame
|
||||
from .models import WIDGET_CONFIG_MODELS, Frame, Widget
|
||||
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:////data/espresso.db")
|
||||
|
||||
@@ -86,3 +86,33 @@ def frame_locked(db: Session, frame_id: int) -> Iterator[Frame]:
|
||||
db.refresh(frame)
|
||||
yield frame
|
||||
db.commit()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def widget_locked(db: Session, frame_id: int, widget_id: int) -> Iterator[tuple[Frame, Widget, object]]:
|
||||
"""Same lock/refresh/commit dance as frame_locked, additionally
|
||||
resolving and refreshing the widget's own per-type config row
|
||||
(PhotoWidgetConfig/CalendarWidgetConfig/WhiteboardWidgetConfig/
|
||||
TaskWidgetConfig, see models.WIDGET_CONFIG_MODELS). Deliberately
|
||||
still locks at *frame* granularity -- the exact same per-frame
|
||||
threading.Lock frame_locked
|
||||
uses, not a separate per-widget lock -- simplest, avoids a new class
|
||||
of multi-lock deadlock bugs, and this project's actual concurrency
|
||||
needs are tiny (a handful of users per household frame).
|
||||
|
||||
threading.Lock is not reentrant: a caller executing several widget
|
||||
actions in one pass (e.g. a button press assigned multiple
|
||||
(widget, action) pairs, see routers/device.py) MUST call this once
|
||||
per action, sequentially, never nested inside an outer
|
||||
frame_locked/widget_locked span for the same frame -- nesting would
|
||||
deadlock instantly, not just misbehave."""
|
||||
with frame_locked(db, frame_id) as frame:
|
||||
widget = db.get(Widget, widget_id)
|
||||
if widget is None or widget.frame_id != frame_id:
|
||||
raise LookupError(f"Widget {widget_id} does not belong to frame {frame_id}")
|
||||
config_model = WIDGET_CONFIG_MODELS[widget.widget_type]
|
||||
config = db.get(config_model, widget_id)
|
||||
if config is None:
|
||||
raise LookupError(f"Widget {widget_id} has no {widget.widget_type} config row")
|
||||
db.refresh(config)
|
||||
yield frame, widget, config
|
||||
|
||||
+56
-46
@@ -1,6 +1,6 @@
|
||||
"""Maps named faces (from Immich's own face recognition/People feature)
|
||||
onto their position in the final rendered 800x480 frame, for the
|
||||
manage-button overlay's escalated "who's in this photo" menu level.
|
||||
onto their position in the final rendered frame, for the manage-button
|
||||
overlay's named-face labels (see manage_overlay.py, which draws them).
|
||||
|
||||
No face detection or recognition happens here or anywhere else in this
|
||||
project -- Immich's GET /api/faces?id={assetId} already returns each
|
||||
@@ -15,71 +15,81 @@ import io
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
from .image_pipeline import (
|
||||
_face_aware_crop_box,
|
||||
_plain_center_crop_box,
|
||||
logical_render_size,
|
||||
logical_to_native,
|
||||
)
|
||||
from .image_pipeline import EPD_HEIGHT, EPD_WIDTH, _has_bounding_box, _placement_transform, logical_render_size
|
||||
|
||||
# Small caps, not arbitrary: each label is its own malloc'd overlay
|
||||
# buffer on the device (see firmware/main/manage_qr_overlay.c), and the
|
||||
# four existing fixed corner regions already use a meaningful chunk of
|
||||
# the ESP32-C6's limited RAM. Capping at 4 short names keeps the total
|
||||
# overlay memory budget well clear of the WiFi/HTTP stack's own needs.
|
||||
MAX_LABELED_FACES = 4
|
||||
NAME_MAX_LEN = 10
|
||||
# Not a memory constraint anymore (the overlay renders server-side now,
|
||||
# not malloc'd per-label on the device) -- purely a legibility cap. A
|
||||
# photo with a dozen named people would just be visual clutter regardless
|
||||
# of what's rendering it.
|
||||
MAX_LABELED_FACES = 6
|
||||
|
||||
|
||||
def compute_face_labels(preview_bytes: bytes, faces: list[dict], smart_crop_faces: bool,
|
||||
orientation: str = "landscape") -> list[dict]:
|
||||
"""Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in native
|
||||
800x480 panel pixel space at each named face's bottom-center point.
|
||||
def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: str,
|
||||
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
|
||||
logical-space image before it's rotated into native panel space, so
|
||||
no rotation happens here (contrast with the old firmware-side
|
||||
version, which drew post-rotation and needed logical_to_native).
|
||||
Faces without an Immich-identified person name are skipped entirely.
|
||||
preview_bytes must be the same preview image render_frame() used for
|
||||
the currently-displayed frame, and smart_crop_faces/orientation must
|
||||
match the settings that were active then -- otherwise the crop box and
|
||||
rotation computed here won't match what's actually on screen.
|
||||
the currently-displayed frame, and display_mode/orientation must
|
||||
match the settings that were active then -- otherwise the placement
|
||||
computed here won't match what's actually on screen.
|
||||
|
||||
The crop math runs in logical (pre-rotation) space, matching
|
||||
render_frame()'s composition step; each anchor is then rotated into
|
||||
native panel coordinates via logical_to_native(), since the firmware
|
||||
draws labels in native space.
|
||||
`region` is (x0, y0, w, h): where in the logical canvas the photo
|
||||
actually landed, if not the whole thing -- e.g. a photo widget placed
|
||||
in one corner of the panel rather than full-screen (see
|
||||
routers/common.py's build_manage_content, which passes each photo
|
||||
widget's own placement rect) -- without this a label would be placed
|
||||
as if the photo filled the entire canvas, landing well off where the
|
||||
widget actually is. None (the default) means the photo fills the
|
||||
whole logical canvas.
|
||||
|
||||
The placement math matches render_frame()'s own composition step
|
||||
exactly (see image_pipeline._placement_transform, shared so the two
|
||||
can't drift apart).
|
||||
"""
|
||||
named = [face for face in faces if (face.get("person") or {}).get("name")]
|
||||
if not named:
|
||||
return []
|
||||
|
||||
logical_w, logical_h = logical_render_size(orientation)
|
||||
if region is None:
|
||||
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
|
||||
|
||||
fitted = ImageOps.exif_transpose(Image.open(io.BytesIO(preview_bytes)).convert("RGB"))
|
||||
|
||||
if smart_crop_faces and faces:
|
||||
left, top, right, bottom = _face_aware_crop_box(fitted.width, fitted.height, logical_w, logical_h, faces)
|
||||
crop_w, crop_h = right - left, bottom - top
|
||||
else:
|
||||
left, top, crop_w, crop_h = _plain_center_crop_box(fitted.width, fitted.height, logical_w, logical_h)
|
||||
scale_x, scale_y, offset_x, offset_y = _placement_transform(
|
||||
fitted.width, fitted.height, target_w, target_h, display_mode, faces
|
||||
)
|
||||
|
||||
labels = []
|
||||
for face in named[:MAX_LABELED_FACES]:
|
||||
if not _has_bounding_box(face):
|
||||
continue
|
||||
face_w = face.get("imageWidth") or fitted.width
|
||||
face_h = face.get("imageHeight") or fitted.height
|
||||
scale_x = fitted.width / face_w
|
||||
scale_y = fitted.height / face_h
|
||||
img_scale_x = fitted.width / face_w
|
||||
img_scale_y = fitted.height / face_h
|
||||
|
||||
center_x = (face["boundingBoxX1"] + face["boundingBoxX2"]) / 2 * scale_x
|
||||
bottom_y = face["boundingBoxY2"] * scale_y
|
||||
center_x = (face["boundingBoxX1"] + face["boundingBoxX2"]) / 2 * img_scale_x
|
||||
bottom_y = face["boundingBoxY2"] * img_scale_y
|
||||
|
||||
frame_x = (center_x - left) * (logical_w / crop_w)
|
||||
frame_y = (bottom_y - top) * (logical_h / crop_h)
|
||||
# Relative to the region's own origin first (matches
|
||||
# _placement_transform's target_w/target_h space), then shifted
|
||||
# into full-canvas coordinates.
|
||||
region_x = center_x * scale_x + offset_x
|
||||
region_y = bottom_y * scale_y + offset_y
|
||||
|
||||
if not (0 <= frame_x <= logical_w and 0 <= frame_y <= logical_h):
|
||||
continue # this face got cropped out of the final frame entirely
|
||||
if not (0 <= region_x <= target_w and 0 <= region_y <= target_h):
|
||||
continue # this face got cropped out of the region entirely
|
||||
|
||||
name = face["person"]["name"]
|
||||
if len(name) > NAME_MAX_LEN:
|
||||
name = name[: NAME_MAX_LEN - 3] + "..."
|
||||
|
||||
native_x, native_y = logical_to_native(frame_x, frame_y, orientation)
|
||||
labels.append({"name": name, "x": native_x, "y": native_y})
|
||||
labels.append({"name": face["person"]["name"],
|
||||
"x": int(region_x + region_x0), "y": int(region_y + region_y0)})
|
||||
|
||||
return labels
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright (c) 2010-2013, Anton Koovit ([email protected]), with Reserved Font Name 'Arvo'
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2010 The Crimson Text Project Authors (https://github.com/googlefonts/Crimson)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,92 @@
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2010-2020 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2013 Google LLC
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,115 @@
|
||||
"""Frame-wide actions triggered by holding NEXT/BACK past
|
||||
Frame.hold_duration_ms, instead of the per-widget action a short press
|
||||
runs (see models.FrameButtonAction, app/widgets/*.py's ACTIONS). Not
|
||||
scoped to any one widget -- e.g. cycling through the owner's saved
|
||||
layouts -- so this is its own registry rather than living in a widget
|
||||
module.
|
||||
|
||||
Each function's signature is (db, frame) -> None, the frame-level
|
||||
analogue of a widget ACTIONS entry's (db, frame, widget) -> None, and
|
||||
each is responsible for its own locking/commit internally (frame_locked/
|
||||
widget_locked), same convention as app/widgets/*.py. routers/device.py's
|
||||
/frame/global-next and /frame/global-back look up which (if any) of
|
||||
these Frame.next_hold_action/back_hold_action points to and call it,
|
||||
same "unset/unknown -> silent no-op" posture as an unbound short-press
|
||||
button."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from . import grid
|
||||
from .db import frame_locked
|
||||
from .models import Frame, PhotoWidgetConfig, SavedLayout, Widget
|
||||
from .routers.api_layouts import apply_layout_to_frame
|
||||
from .widgets import WIDGET_TYPES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def cycle_layout(db: Session, frame: Frame) -> None:
|
||||
"""Applies the owner's next saved layout compatible with this
|
||||
frame's current grid size, in a stable order (by id), wrapping back
|
||||
to the first past the last one. A silent no-op if the frame is
|
||||
unclaimed or its owner has no compatible saved layouts -- same
|
||||
posture as every other action here when there's nothing to do."""
|
||||
if frame.owner_user_id is None:
|
||||
return
|
||||
cols, rows = grid.grid_dims(frame.orientation)
|
||||
candidates = db.scalars(
|
||||
select(SavedLayout)
|
||||
.where(SavedLayout.user_id == frame.owner_user_id, SavedLayout.cols == cols, SavedLayout.rows == rows)
|
||||
.order_by(SavedLayout.id)
|
||||
).all()
|
||||
if not candidates:
|
||||
return
|
||||
|
||||
next_layout = candidates[0]
|
||||
if frame.last_cycled_layout_id is not None:
|
||||
for i, layout in enumerate(candidates):
|
||||
if layout.id == frame.last_cycled_layout_id:
|
||||
next_layout = candidates[(i + 1) % len(candidates)]
|
||||
break
|
||||
|
||||
apply_layout_to_frame(db, frame, next_layout)
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
locked.last_cycled_layout_id = next_layout.id
|
||||
|
||||
|
||||
def refresh_all_widgets(db: Session, frame: Frame) -> None:
|
||||
"""Runs every widget's own check_now (calendar/weather/whiteboard),
|
||||
regardless of which button it's normally bound to -- a manual "sync
|
||||
everything now" global action. One widget's failure doesn't block
|
||||
the rest, same posture as routers/device.py's _run_button_actions."""
|
||||
widgets = db.scalars(select(Widget).where(Widget.frame_id == frame.id)).all()
|
||||
for widget in widgets:
|
||||
module = WIDGET_TYPES.get(widget.widget_type)
|
||||
check_now = module.ACTIONS.get("check_now") if module else None
|
||||
if check_now is None:
|
||||
continue
|
||||
try:
|
||||
check_now(db, frame, widget)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"refresh_all_widgets failed for widget %d (frame %d)", widget.id, frame.id
|
||||
)
|
||||
|
||||
|
||||
def toggle_all_photo_locks(db: Session, frame: Frame) -> None:
|
||||
"""Flips PhotoWidgetConfig.locked for every photo widget on the frame
|
||||
at once. Target state is the opposite of "everything's already
|
||||
locked" -- one hold freezes every photo widget unless they're all
|
||||
already frozen, in which case it unfreezes all of them. A no-op if
|
||||
the frame has no photo widgets."""
|
||||
widget_ids = [
|
||||
w.id for w in db.scalars(
|
||||
select(Widget).where(Widget.frame_id == frame.id, Widget.widget_type == "photos")
|
||||
)
|
||||
]
|
||||
if not widget_ids:
|
||||
return
|
||||
configs = db.scalars(
|
||||
select(PhotoWidgetConfig).where(PhotoWidgetConfig.widget_id.in_(widget_ids))
|
||||
).all()
|
||||
if not configs:
|
||||
return
|
||||
target = not all(c.locked for c in configs)
|
||||
with frame_locked(db, frame.id):
|
||||
for config in configs:
|
||||
config.locked = target
|
||||
|
||||
|
||||
GLOBAL_ACTIONS = {
|
||||
"cycle_layout": cycle_layout,
|
||||
"refresh_all_widgets": refresh_all_widgets,
|
||||
"toggle_all_photo_locks": toggle_all_photo_locks,
|
||||
}
|
||||
|
||||
GLOBAL_ACTION_LABELS = {
|
||||
"cycle_layout": "Cycle saved layouts",
|
||||
"refresh_all_widgets": "Refresh all widgets now",
|
||||
"toggle_all_photo_locks": "Freeze/unfreeze all photo widgets",
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Snap-to-grid placement math for widgets (see models.Widget) -- pure,
|
||||
no I/O, no ORM.
|
||||
|
||||
The grid is defined relative to the panel's long/short axis, not
|
||||
landscape/portrait specifically, so it stays valid across
|
||||
image_pipeline.logical_render_size(orientation)'s genuine width/height
|
||||
swap for portrait (not just a rotation applied at the very end) --
|
||||
landscape orientations are GRID_LONG columns x GRID_SHORT rows, portrait
|
||||
orientations are GRID_SHORT columns x GRID_LONG rows, same cell size
|
||||
either way. Changing a frame's orientation therefore invalidates any
|
||||
existing widget layout (an 8x5 arrangement isn't valid on a 5x8 grid) --
|
||||
callers are expected to reset to one full-panel widget on an orientation
|
||||
change, not try to remap coordinates.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
GRID_LONG = 8
|
||||
GRID_SHORT = 5
|
||||
|
||||
# Per-widget-type minimum grid footprint (cols, rows) -- enforced both in
|
||||
# the placement UI and server-side (routers/api_widgets.py). A calendar
|
||||
# widget crammed into 1x1 would be illegible regardless of size-tier
|
||||
# scaling (see calendar_render.py); whiteboard needs enough room to be
|
||||
# worth looking at; photos can go as small as a single cell; tasks needs
|
||||
# enough width for a due-date prefix plus a couple words of summary
|
||||
# without truncating on every row; weather needs enough room for its
|
||||
# hourly/daily strips to stay legible (its current/multi_city modes
|
||||
# would tolerate smaller, but every mode shares one footprint value).
|
||||
# battery is just an icon + a percent (+ two optional small lines in
|
||||
# "detailed" mode) -- legible even at a single cell, like photos/static.
|
||||
# NOTE: a 1x1 widget-box on a narrow mobile canvas can clip its own
|
||||
# gear/remove buttons behind theme.css's overflow: hidden (their fixed
|
||||
# pixel offsets overflow the box's clipped width) -- a pre-existing
|
||||
# layout gap that already affects photos/static at 1x1 too, not fixed
|
||||
# here; see the finding called out where this was discovered.
|
||||
MIN_FOOTPRINT: dict[str, tuple[int, int]] = {
|
||||
"photos": (1, 1),
|
||||
"calendar": (3, 2),
|
||||
"whiteboard": (2, 2),
|
||||
"tasks": (2, 2),
|
||||
"static": (1, 1),
|
||||
"text": (2, 1),
|
||||
"weather": (2, 2),
|
||||
"battery": (1, 1),
|
||||
}
|
||||
|
||||
Rect = tuple[int, int, int, int] # (x, y, w, h)
|
||||
|
||||
|
||||
def grid_dims(orientation: str) -> tuple[int, int]:
|
||||
"""(cols, rows) for this orientation."""
|
||||
if orientation in ("portrait", "portrait_flipped"):
|
||||
return GRID_SHORT, GRID_LONG
|
||||
return GRID_LONG, GRID_SHORT
|
||||
|
||||
|
||||
def full_panel_rect(orientation: str) -> Rect:
|
||||
"""The single full-panel widget rect for this orientation -- what a
|
||||
frame gets reset to whenever its layout can't carry over (initial
|
||||
migration backfill, an orientation change)."""
|
||||
cols, rows = grid_dims(orientation)
|
||||
return (0, 0, cols, rows)
|
||||
|
||||
|
||||
def in_bounds(orientation: str, rect: Rect) -> bool:
|
||||
cols, rows = grid_dims(orientation)
|
||||
x, y, w, h = rect
|
||||
return x >= 0 and y >= 0 and w > 0 and h > 0 and x + w <= cols and y + h <= rows
|
||||
|
||||
|
||||
def meets_minimum(widget_type: str, rect: Rect) -> bool:
|
||||
min_w, min_h = MIN_FOOTPRINT.get(widget_type, (1, 1))
|
||||
_, _, w, h = rect
|
||||
return w >= min_w and h >= min_h
|
||||
|
||||
|
||||
def overlaps(a: Rect, b: Rect) -> bool:
|
||||
ax, ay, aw, ah = a
|
||||
bx, by, bw, bh = b
|
||||
return ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah
|
||||
|
||||
|
||||
def find_open_rect(orientation: str, existing: list[Rect], w: int, h: int) -> Rect | None:
|
||||
"""First w x h rect that's in-bounds and doesn't overlap any of
|
||||
`existing`, scanning row-major (top-left first) -- used when creating
|
||||
a widget without an explicit placement (see routers/api_widgets.py),
|
||||
so adding one from a type picker doesn't require the caller to find
|
||||
empty space itself first. None if no such rect fits anywhere."""
|
||||
cols, rows = grid_dims(orientation)
|
||||
for y in range(rows - h + 1):
|
||||
for x in range(cols - w + 1):
|
||||
candidate = (x, y, w, h)
|
||||
if not any(overlaps(candidate, other) for other in existing):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def cell_to_pixels(orientation: str, panel_w: int, panel_h: int, rect: Rect) -> tuple[int, int, int, int]:
|
||||
"""Grid rect -> pixel rect in logical (pre-rotation) canvas space --
|
||||
against image_pipeline.logical_render_size(orientation)'s own
|
||||
(panel_w, panel_h), the same space every renderer already composes
|
||||
in before the final orientation transpose."""
|
||||
cols, rows = grid_dims(orientation)
|
||||
cell_w = panel_w / cols
|
||||
cell_h = panel_h / rows
|
||||
x, y, w, h = rect
|
||||
px, py = round(x * cell_w), round(y * cell_h)
|
||||
# Snap the far edge to the next cell boundary rather than compounding
|
||||
# per-cell rounding error across w/h -- keeps adjacent widgets'
|
||||
# shared edge pixel-exact instead of leaving a stray gap/overlap.
|
||||
px2, py2 = round((x + w) * cell_w), round((y + h) * cell_h)
|
||||
return (px, py, px2 - px, py2 - py)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user