Compare commits
43
Commits
6fa3e2c2d2
...
v1.4.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -0,0 +1,128 @@
|
|||||||
|
---
|
||||||
|
name: build-firmware
|
||||||
|
description: Compile the espresso_frame ESP32-C6 firmware (firmware/) for both board variants without Docker -- a native, non-container ESP-IDF v6.0 install. Use when asked to build the firmware, verify a firmware/main/*.c change actually compiles, or check both the devkit and xiao board targets.
|
||||||
|
---
|
||||||
|
|
||||||
|
Compiles `firmware/` (ESP-IDF, targeting ESP32-C6) 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 toolchain + Python venv, via ESP-IDF's own
|
||||||
|
`./install.sh esp32c6` -- scoped to just this project's one target
|
||||||
|
(see `firmware/README.md`'s board table), not every chip ESP-IDF
|
||||||
|
supports, to keep the download/disk footprint down. `install.sh` is
|
||||||
|
already idempotent on its own, so `setup.sh` always calls it rather
|
||||||
|
than duplicating that check -- a re-run costs a few seconds once
|
||||||
|
everything's cached.
|
||||||
|
|
||||||
|
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 both # both variants
|
||||||
|
```
|
||||||
|
|
||||||
|
Each board gets its own build directory and generated sdkconfig (see
|
||||||
|
`firmware/build_for_board.sh`'s own comment) -- building one never
|
||||||
|
disturbs the other. `build.sh` auto-runs `set-target esp32c6` the very
|
||||||
|
first time a board is built (no generated sdkconfig yet); later builds
|
||||||
|
skip straight to `idf.py build`. Extra arguments pass straight through
|
||||||
|
to `idf.py`, e.g.:
|
||||||
|
|
||||||
|
```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 both boards, most of
|
||||||
|
it ESP-IDF's own components -- this project's own `firmware/main/*.c`
|
||||||
|
and `firmware/components/*` sources are a small fraction of that and
|
||||||
|
compile in a few seconds). Output lands at
|
||||||
|
`firmware/build/espresso_frame.bin` (devkit) or
|
||||||
|
`firmware/build_xiao/espresso_frame.bin` (xiao) -- both paths are
|
||||||
|
gitignored (`firmware/.gitignore`... actually the repo root
|
||||||
|
`.gitignore`'s "ESP-IDF firmware build output" section), so nothing
|
||||||
|
here needs cleaning up before a commit.
|
||||||
|
|
||||||
|
## 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
+63
@@ -0,0 +1,63 @@
|
|||||||
|
#!/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` 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).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# build.sh # build devkit (default)
|
||||||
|
# build.sh devkit
|
||||||
|
# build.sh xiao
|
||||||
|
# build.sh both # build both board variants
|
||||||
|
# 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
|
||||||
|
case "$board" in
|
||||||
|
devkit) sdkconfig="sdkconfig" ;;
|
||||||
|
xiao) sdkconfig="sdkconfig.xiao_local" ;;
|
||||||
|
*) echo "Unknown board '$board' -- expected 'devkit' or 'xiao'" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ ! -f "$sdkconfig" ]; then
|
||||||
|
echo "==> $board: no generated sdkconfig yet, setting target esp32c6"
|
||||||
|
./build_for_board.sh "$board" set-target esp32c6
|
||||||
|
fi
|
||||||
|
|
||||||
|
local args=("$@")
|
||||||
|
if [ ${#args[@]} -eq 0 ]; then
|
||||||
|
args=(build)
|
||||||
|
fi
|
||||||
|
./build_for_board.sh "$board" "${args[@]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
board="${1:-devkit}"
|
||||||
|
shift || true
|
||||||
|
|
||||||
|
if [ "$board" = "both" ]; then
|
||||||
|
build_one devkit "$@"
|
||||||
|
build_one xiao "$@"
|
||||||
|
else
|
||||||
|
build_one "$board" "$@"
|
||||||
|
fi
|
||||||
Executable
+58
@@ -0,0 +1,58 @@
|
|||||||
|
#!/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 only -- this
|
||||||
|
# project's one target (see firmware/README.md's board table). Scoping
|
||||||
|
# avoids downloading toolchains for every chip ESP-IDF supports, which
|
||||||
|
# matters given this container's disk headroom. install.sh is already
|
||||||
|
# idempotent on its own (checks what's present and skips it), so this
|
||||||
|
# always calls it rather than trying to duplicate that check here --
|
||||||
|
# a re-run only costs a few seconds once everything's cached.
|
||||||
|
echo "running esp-idf install.sh esp32c6 (fast if already installed) ..."
|
||||||
|
(cd "$IDF_DIR" && ./install.sh esp32c6)
|
||||||
|
|
||||||
|
echo "setup complete -> $IDF_DIR/export.sh (build.sh sources this for you)"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: make-widget
|
name: make-widget
|
||||||
description: Scaffold a new widget type for the espresso_frame server (the ~13-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, 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").
|
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
|
Adding a widget type is a very consistent, repeated pattern in this
|
||||||
@@ -114,6 +114,16 @@ Pick your template accordingly:
|
|||||||
`MIN_FOOTPRINT` prose line, the `app/widgets/` module list. This is
|
`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
|
the project's own "start here" doc per `CLAUDE.md` -- don't ship a
|
||||||
widget without it staying accurate.
|
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/`)
|
## Tests (`server/tests/`)
|
||||||
|
|
||||||
@@ -138,6 +148,13 @@ Pick your template accordingly:
|
|||||||
- Any pure-logic helper module (decoding, parsing -- like
|
- Any pure-logic helper module (decoding, parsing -- like
|
||||||
`app/image_upload.py`) gets its own `test_<module>.py`: no HTTP, no
|
`app/image_upload.py`) gets its own `test_<module>.py`: no HTTP, no
|
||||||
DB, just the function.
|
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:
|
Run the full suite before calling it done:
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ mkdir -p "$SCRATCH"
|
|||||||
|
|
||||||
DATABASE_URL="sqlite:///$SCRATCH/test.db" \
|
DATABASE_URL="sqlite:///$SCRATCH/test.db" \
|
||||||
CONFIG_PATH="$SCRATCH/config.json" \
|
CONFIG_PATH="$SCRATCH/config.json" \
|
||||||
|
LOG_PATH="$SCRATCH/app.log" \
|
||||||
.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port "$PORT" \
|
.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port "$PORT" \
|
||||||
> "$SCRATCH/server.log" 2>&1 &
|
> "$SCRATCH/server.log" 2>&1 &
|
||||||
PID=$!
|
PID=$!
|
||||||
|
|||||||
@@ -68,5 +68,25 @@ jobs:
|
|||||||
chmod 600 ~/.ssh/deploy_key
|
chmod 600 ~/.ssh/deploy_key
|
||||||
ssh-keyscan -p "$DEPLOY_PORT" "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
ssh-keyscan -p "$DEPLOY_PORT" "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
ssh -i ~/.ssh/deploy_key -p "$DEPLOY_PORT" -o StrictHostKeyChecking=yes \
|
ssh -i ~/.ssh/deploy_key -p "$DEPLOY_PORT" -o StrictHostKeyChecking=yes \
|
||||||
espressoframe_deployer@"$DEPLOY_HOST" \
|
espressoframe_deployer@"$DEPLOY_HOST" bash -s <<'REMOTE'
|
||||||
'cd ~/espresso-frame && docker compose pull && docker compose up -d'
|
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
|
||||||
|
|||||||
@@ -4,27 +4,18 @@ 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
|
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
|
self-hosted FastAPI server (`server/`) that pulls from Immich, does all
|
||||||
image processing (crop/dither/quantize/pack), and serves a placeable
|
image processing (crop/dither/quantize/pack), and serves a placeable
|
||||||
photos/calendar/whiteboard widget system to the device.
|
photos/calendar/whiteboard/weather widget system to the device.
|
||||||
|
|
||||||
CURRENT TODO
|
CURRENT TODO
|
||||||
-add more actions for buttons (i.e. change widget/layout)
|
|
||||||
-Fix spurious button assignment stuff (probably but buttons on widget config with sane defaults)
|
|
||||||
-FIX Scan to download
|
|
||||||
-make a weather widget
|
|
||||||
-widget border option
|
|
||||||
-battery life widget
|
|
||||||
-sharing layouts with linked users
|
-sharing layouts with linked users
|
||||||
-when multiple photo widgets on layout, the "scan to download" should create a share with all the photos on
|
|
||||||
-a "coming up this week" widget
|
-a "coming up this week" widget
|
||||||
-scan to download for non-immich photos too?
|
-on reset dismiss the menu.
|
||||||
-switch button reset action? and on reset dismiss the menu.
|
|
||||||
|
|
||||||
Start here, don't re-derive from scratch:
|
Start here, don't re-derive from scratch:
|
||||||
- [`docs/architecture.md`](docs/architecture.md) -- how firmware and
|
- [`docs/architecture.md`](docs/architecture.md) -- how firmware and
|
||||||
server talk (sequence diagram, boot flow).
|
server talk (sequence diagram, boot flow).
|
||||||
- [`docs/widgets.md`](docs/widgets.md) -- the server-side widget system
|
- [`docs/widgets.md`](docs/widgets.md) -- the server-side widget system
|
||||||
(data model, grid placement, compositor, button-action dispatch). Notes
|
(data model, grid placement, compositor, button-action dispatch).
|
||||||
a known gap at the bottom (legacy `Frame` columns not yet dropped).
|
|
||||||
- [`docs/hardware.md`](docs/hardware.md) -- wiring.
|
- [`docs/hardware.md`](docs/hardware.md) -- wiring.
|
||||||
- [`server/README.md`](server/README.md), [`firmware/README.md`](firmware/README.md)
|
- [`server/README.md`](server/README.md), [`firmware/README.md`](firmware/README.md)
|
||||||
-- per-component setup, config, and a lot of accumulated gotchas
|
-- per-component setup, config, and a lot of accumulated gotchas
|
||||||
@@ -41,8 +32,7 @@ Start here, don't re-derive from scratch:
|
|||||||
license via `pip show`/package metadata -- including transitive deps,
|
license via `pip show`/package metadata -- including transitive deps,
|
||||||
not just the top-level package -- and present the finding and tradeoff
|
not just the top-level package -- and present the finding and tradeoff
|
||||||
in plain text rather than picking an approach unilaterally (hand-rolling
|
in plain text rather than picking an approach unilaterally (hand-rolling
|
||||||
an alternative, swapping packages, silently accepting
|
an alternative, swapping packages, silently accepting it). This project
|
||||||
a "coming up this week" widget). This project
|
|
||||||
has knowingly accepted AGPL-3.0-or-later exposure once already
|
has knowingly accepted AGPL-3.0-or-later exposure once already
|
||||||
(`icalendar-searcher`, a transitive dep of `caldav`) as a deliberate,
|
(`icalendar-searcher`, a transitive dep of `caldav`) as a deliberate,
|
||||||
explicit call -- not a precedent for skipping the check next time.
|
explicit call -- not a precedent for skipping the check next time.
|
||||||
@@ -53,6 +43,16 @@ Start here, don't re-derive from scratch:
|
|||||||
tempted to exclude and why. This repo shipped a token gate once that
|
tempted to exclude and why. This repo shipped a token gate once that
|
||||||
covered `/api/*` but left `/frame/image` -- the actual photo bytes --
|
covered `/api/*` but left `/frame/image` -- the actual photo bytes --
|
||||||
open; caught immediately in production.
|
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
|
## Working in this repo
|
||||||
|
|
||||||
@@ -73,8 +73,7 @@ Start here, don't re-derive from scratch:
|
|||||||
- **New/changed UI must work at both desktop and mobile widths --
|
- **New/changed UI must work at both desktop and mobile widths --
|
||||||
screenshot both, don't assume one implies the other.** The layout
|
screenshot both, don't assume one implies the other.** The layout
|
||||||
genuinely forks at the 860px breakpoint (`theme.css`): the sidebar
|
genuinely forks at the 860px breakpoint (`theme.css`): the sidebar
|
||||||
goes off-canvas behind a hamburger below
|
goes off-canvas behind a hamburger below it. A dialog, header
|
||||||
a "coming up this week" widget. A dialog, header
|
|
||||||
control, or new widget that looks right at a wide viewport can
|
control, or new widget that looks right at a wide viewport can
|
||||||
overflow, overlap the mobile bar, or mis-center at phone widths.
|
overflow, overlap the mobile bar, or mis-center at phone widths.
|
||||||
`run-server`'s driver has a `viewport` command for exactly this
|
`run-server`'s driver has a `viewport` command for exactly this
|
||||||
|
|||||||
@@ -102,9 +102,9 @@ placement grid, and button-action dispatch.
|
|||||||
- Deep sleep for the server-configured interval on success, or a
|
- Deep sleep for the server-configured interval on success, or a
|
||||||
shorter retry interval on any failure.
|
shorter retry interval on any failure.
|
||||||
|
|
||||||
The menu/reset button's soft-reset and factory-reset tiers (held ~3s
|
The menu/reset button's soft-reset (quick press) and factory-reset
|
||||||
or ~15s) are handled earlier, before any of this, and never return --
|
(held ~15s) tiers are handled earlier, before any of this, and never
|
||||||
see [`firmware/README.md`](../firmware/README.md#managing-the-queue-soft-resetting-and-factory-resetting).
|
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
|
See [`docs/hardware.md`](hardware.md) for wiring and
|
||||||
[`server/README.md`](../server/README.md) for the server side.
|
[`server/README.md`](../server/README.md) for the server side.
|
||||||
|
|||||||
+4
-4
@@ -40,10 +40,10 @@ pressed):
|
|||||||
photo; see
|
photo; see
|
||||||
[`firmware/README.md`](../firmware/README.md#going-back-to-the-previous-photo).
|
[`firmware/README.md`](../firmware/README.md#going-back-to-the-previous-photo).
|
||||||
- **Menu / reset (GPIO1)**: one button, three actions by hold duration --
|
- **Menu / reset (GPIO1)**: one button, three actions by hold duration --
|
||||||
a quick press overlays a "scan to manage" QR code on the current photo
|
a quick press soft-resets the device (config kept); holding ~3s then
|
||||||
for 30 seconds; holding ~3s then releasing soft-resets the device
|
releasing overlays a "scan to manage" QR code on the current photo for
|
||||||
(config kept); holding ~15s factory-resets it (clears WiFi/server
|
30 seconds; holding ~15s factory-resets it (clears WiFi/server config,
|
||||||
config, reprovisions); see
|
reprovisions); see
|
||||||
[`firmware/README.md`](../firmware/README.md#managing-the-queue-soft-resetting-and-factory-resetting).
|
[`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
|
All three pins were picked because they're within GPIO 0-7 -- the only
|
||||||
|
|||||||
+418
-49
@@ -1,15 +1,15 @@
|
|||||||
# Widget system
|
# Widget system
|
||||||
|
|
||||||
A frame's panel isn't one fixed "mode" anymore -- it holds N independently
|
A frame's panel isn't one fixed "mode" anymore -- it holds N independently
|
||||||
placed/sized widgets (photos/calendar/whiteboard/tasks/static image/text), like
|
placed/sized widgets (photos/calendar/whiteboard/tasks/static image/text/
|
||||||
arranging icons on an Android home screen. A frame can hold several widgets of the
|
weather/battery), like arranging icons on an Android home screen. A frame
|
||||||
same type (e.g. two photo widgets pointed at different Immich albums side
|
can hold several widgets of the same type (e.g. two photo widgets pointed
|
||||||
by side).
|
at different Immich albums side by side).
|
||||||
This replaced an earlier design where `Frame.mode` picked exactly one
|
This replaced an earlier design where `Frame.mode` picked exactly one
|
||||||
full-panel renderer; that column (and the other now-dead per-mode `Frame`
|
full-panel renderer; that column and the other per-mode `Frame` columns
|
||||||
columns it left behind -- `album_id`, `calendar_*`, `whiteboard_*`, etc.)
|
it left behind (`album_id`, `calendar_*`, `whiteboard_*`, etc.) were
|
||||||
is still physically present but unused, pending a final cleanup migration
|
dropped in migration 41, once every phase of the rollout had shipped
|
||||||
(see "Known gaps" below).
|
(see "Known gaps" below for what's still open).
|
||||||
|
|
||||||
The device-facing contract is unchanged by any of this: `GET /frame/image`,
|
The device-facing contract is unchanged by any of this: `GET /frame/image`,
|
||||||
`POST /frame/advance`, `POST /frame/back` are the same frozen paths
|
`POST /frame/advance`, `POST /frame/back` are the same frozen paths
|
||||||
@@ -20,14 +20,46 @@ a button press does.
|
|||||||
## Data model
|
## Data model
|
||||||
|
|
||||||
- `Widget` (`server/app/models.py`): `id`, `frame_id`, `widget_type`
|
- `Widget` (`server/app/models.py`): `id`, `frame_id`, `widget_type`
|
||||||
(`"photos"` | `"calendar"` | `"whiteboard"` | `"tasks"` | `"static"` | `"text"`), `x`/`y`/`w`/`h`
|
(`"photos"` | `"calendar"` | `"whiteboard"` | `"tasks"` | `"static"` |
|
||||||
|
`"text"` | `"weather"` | `"battery"`), `x`/`y`/`w`/`h`
|
||||||
(grid cells), `sort_order`. Widgets never overlap (enforced server-side in
|
(grid cells), `sort_order`. Widgets never overlap (enforced server-side in
|
||||||
`routers/api_widgets.py`, re-validated regardless of what the client
|
`routers/api_widgets.py`, re-validated regardless of what the client
|
||||||
already checked) -- that's what keeps compositing simple: no z-order,
|
already checked) -- that's what keeps compositing simple: no z-order,
|
||||||
no blending, just N independent regions pasted onto one shared canvas.
|
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`,
|
- Per-type 1:1 extension tables -- `PhotoWidgetConfig`,
|
||||||
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`,
|
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`,
|
||||||
`StaticWidgetConfig`, `TextWidgetConfig`, each keyed by `widget_id` with
|
`StaticWidgetConfig`, `TextWidgetConfig`, `WeatherWidgetConfig`,
|
||||||
|
`BatteryWidgetConfig`, each keyed by `widget_id` with
|
||||||
`ondelete="CASCADE"` -- rather than one wide table with every type's
|
`ondelete="CASCADE"` -- rather than one wide table with every type's
|
||||||
mostly-irrelevant columns. `TextWidgetConfig.content` is parsed rich
|
mostly-irrelevant columns. `TextWidgetConfig.content` is parsed rich
|
||||||
text (paragraphs of styled runs), never raw HTML -- see
|
text (paragraphs of styled runs), never raw HTML -- see
|
||||||
@@ -36,11 +68,27 @@ a button press does.
|
|||||||
`PhotoWidgetConfig`
|
`PhotoWidgetConfig`
|
||||||
mirrors `app/photo_queue.py`'s attribute names exactly, so that module's
|
mirrors `app/photo_queue.py`'s attribute names exactly, so that module's
|
||||||
advance/back/queue logic ports across widget instances unchanged.
|
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
|
`TaskWidgetConfig` used to be a handful of `tasks_*` columns bolted onto
|
||||||
`CalendarWidgetConfig` (a week-view-only, single-list task list); split
|
`CalendarWidgetConfig` (a week-view-only, single-list task list); split
|
||||||
into its own widget type (migration 17) so a task list can be placed
|
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
|
and sized independent of any calendar's view/footprint, then (migration
|
||||||
18) given the same multi-source shape a calendar widget already has.
|
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
|
- `FrameCalendar`/`FrameTaskList` are keyed by `widget_id` (not
|
||||||
`frame_id`) since a frame can now have more than one independent
|
`frame_id`) since a frame can now have more than one independent
|
||||||
calendar/tasks widget, each with its own included set. Identical
|
calendar/tasks widget, each with its own included set. Identical
|
||||||
@@ -65,8 +113,13 @@ orientation change rather than trying to remap coordinates.
|
|||||||
|
|
||||||
Each widget type has a minimum grid footprint (`grid.MIN_FOOTPRINT`):
|
Each widget type has a minimum grid footprint (`grid.MIN_FOOTPRINT`):
|
||||||
photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of
|
photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of
|
||||||
size-tier scaling), whiteboard 2x2, tasks 2x2, static image 1x1, text 2x1.
|
size-tier scaling), whiteboard 2x2, tasks 2x2, static image 1x1, text 2x1,
|
||||||
Enforced both client-side
|
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`)
|
(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
|
and server-side (`routers/api_widgets.py`) -- the client is never trusted
|
||||||
alone.
|
alone.
|
||||||
@@ -75,7 +128,7 @@ alone.
|
|||||||
|
|
||||||
`app/widgets/` is the render/action registry -- one module per
|
`app/widgets/` is the render/action registry -- one module per
|
||||||
`widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`, `tasks.py`,
|
`widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`, `tasks.py`,
|
||||||
`static_image.py`, `text.py`), each exposing:
|
`static_image.py`, `text.py`, `weather.py`, `battery.py`), each exposing:
|
||||||
|
|
||||||
- `render(db, frame, widget, target_w, target_h, is_normal_wake) -> Image`:
|
- `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
|
an unquantized RGB image exactly `target_w x target_h`, the widget's
|
||||||
@@ -85,9 +138,11 @@ alone.
|
|||||||
bad moment doesn't blank the whole panel.
|
bad moment doesn't blank the whole panel.
|
||||||
- `ACTIONS: dict[str, Callable]` -- named button actions this type
|
- `ACTIONS: dict[str, Callable]` -- named button actions this type
|
||||||
supports (`"advance"`/`"back"` for photos and calendar, `"check_now"`
|
supports (`"advance"`/`"back"` for photos and calendar, `"check_now"`
|
||||||
for whiteboard). Empty for tasks, static image, and text -- nothing to
|
for whiteboard and weather -- both throttled external fetches with a
|
||||||
advance/back/force for a passive checklist, a fixed uploaded image, or
|
forced-refetch action). Empty for tasks, static image, text, and
|
||||||
a fixed block of authored text.
|
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-
|
- `ACTION_LABELS: dict[str, str]` -- human labels for the button-
|
||||||
assignment UI.
|
assignment UI.
|
||||||
|
|
||||||
@@ -107,30 +162,255 @@ grid footprint, rather than continuously scaling constants tuned for a
|
|||||||
full ~800x480 canvas -- falls back to agenda view if a widget is too small
|
full ~800x480 canvas -- falls back to agenda view if a widget is too small
|
||||||
for month view to stay legible.
|
for month view to stay legible.
|
||||||
|
|
||||||
|
### "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
|
## Button actions
|
||||||
|
|
||||||
Each physical button (NEXT/BACK) maps to an **ordered list** of
|
Each physical button (NEXT/BACK) runs the `(widget, action)` binding of
|
||||||
`(widget, action)` bindings, not a fixed meaning -- e.g. NEXT can be
|
every widget on the frame that has one -- **at most one binding per
|
||||||
"photo widget A: advance" *and* "calendar widget B: advance" together, or
|
widget per button** (a widget can't be bound to two different actions on
|
||||||
even a mismatched combination on purpose. On a press,
|
the same button). On a press, `routers/device.py`'s `_run_button_actions`
|
||||||
`routers/device.py`'s `_run_button_actions` runs every assigned action for
|
runs every widget's assigned action for that button (each in its own
|
||||||
that button in order (each in its own `widget_locked` span -- never nested,
|
`widget_locked` span -- never nested, since the underlying per-frame lock
|
||||||
since the underlying per-frame lock isn't reentrant), catching and
|
isn't reentrant), catching and logging any single action's failure
|
||||||
logging any single action's failure without blocking the rest, then
|
without blocking the rest, then re-renders and returns the whole composed
|
||||||
re-renders and returns the whole composed panel once at the end regardless
|
panel once at the end regardless of which actions succeeded. Which
|
||||||
of which actions succeeded.
|
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 web UI for this is the "Button assignments" card on a frame's
|
The UI for this lives in each widget's own gear-icon config dialog (the
|
||||||
Configuration tab (`static/frame_config.js`, `GET`/`PUT
|
"Button actions" card, `templates/_widget_button_fields.html` +
|
||||||
/api/frames/{id}/buttons`) -- add/remove/reorder, autosaved. Two widgets of
|
`static/widget_dialog_button_actions.js`, `POST
|
||||||
the same type would otherwise both just say "Photos" in the assignment
|
/api/frames/{id}/widgets/{widget_id}/button-actions`) -- not a frame-level
|
||||||
dropdowns; the UI disambiguates using each widget's grid position (e.g.
|
tab, since assigning a widget's next/back behavior is naturally part of
|
||||||
"Photos 1 (left)" / "Photos 2 (right)"), the same way you'd tell them
|
configuring that widget. The card only renders for widget types with a
|
||||||
apart by eye on the Layout canvas.
|
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
|
A newly-created widget (including the one auto-migrated from a frame's
|
||||||
`mode` on upgrade) gets a sensible default binding reproducing its old
|
old `mode` on upgrade) gets a sensible default binding reproducing its
|
||||||
button behavior -- see `migration.py`'s `_default_button_actions`.
|
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
|
## Per-widget config UI
|
||||||
|
|
||||||
@@ -190,20 +470,109 @@ The web UI lives in the Layout tab's "Saved layouts" card
|
|||||||
saved layouts each with Apply/rename/delete, incompatible ones shown
|
saved layouts each with Apply/rename/delete, incompatible ones shown
|
||||||
greyed-out with a "different orientation" badge rather than hidden.
|
greyed-out with a "different orientation" badge rather than hidden.
|
||||||
|
|
||||||
## Known gaps (Phase 6, not yet done)
|
## Weather widget
|
||||||
|
|
||||||
The original 8-phase rollout plan's last phase is still open:
|
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:
|
||||||
|
|
||||||
- Legacy per-mode `Frame` columns (`mode`, `album_id`,
|
|
||||||
`current_asset_id`, all `calendar_*`, all `whiteboard_*`, etc.) are
|
|
||||||
still physically present in the schema but no longer read or written
|
|
||||||
anywhere -- they need a dedicated final migration to drop them. Left in
|
|
||||||
place deliberately through the widget-system rollout (a much larger
|
|
||||||
blast radius cutover than this project's usual same-migration-drop
|
|
||||||
convention) but there's no reason to keep carrying them now that every
|
|
||||||
phase has shipped.
|
|
||||||
- `server/README.md` still describes photos/calendar/whiteboard as
|
|
||||||
per-frame "modes" in several places rather than widgets -- needs a pass
|
|
||||||
once the column drop above is safely deployed.
|
|
||||||
- Whiteboard rendering is tagged **(alpha)** in the UI -- not fully
|
- Whiteboard rendering is tagged **(alpha)** in the UI -- not fully
|
||||||
reliable yet, treat it as experimental if extending it.
|
reliable yet, treat it as experimental if extending it.
|
||||||
|
|||||||
+47
-28
@@ -66,8 +66,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_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_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_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_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_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 |
|
| `FRAME_VBUS_SENSE_GPIO` | -1 (disabled) | USB-power sense GPIO for hiding the battery indicator on mains |
|
||||||
|
|
||||||
@@ -84,6 +85,34 @@ reflashing. The Kconfig value only applies before the device has ever
|
|||||||
successfully reached a configured server, or if the response doesn't
|
successfully reached a configured server, or if the response doesn't
|
||||||
include a valid interval.
|
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
|
### WiFi fast-connect
|
||||||
|
|
||||||
After a successful home-WiFi connection, the device caches the AP's
|
After a successful home-WiFi connection, the device caches the AP's
|
||||||
@@ -118,10 +147,9 @@ two-step setup screen:
|
|||||||
portal's config page (`http://192.168.4.1/` by default), for a
|
portal's config page (`http://192.168.4.1/` by default), for a
|
||||||
one-scan shortcut once you've joined the AP.
|
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
|
Server" address (`host:port` of the [server](../server/) -- **not** your
|
||||||
Immich server; see below for the `https://` form), and an optional
|
Immich server; see below for the `https://` form). Saving hands your browser
|
||||||
"Access Token" (see below -- usually blank). Saving hands your browser
|
|
||||||
off to the server's claim page (after ~7 seconds, giving your phone
|
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
|
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
|
gets linked to your account; the device meanwhile connects to your home
|
||||||
@@ -183,19 +211,6 @@ certificate was actually issued for -- a bare LAN IP address
|
|||||||
(`https://192.168.1.50`) will fail the handshake even against a
|
(`https://192.168.1.50`) will fail the handshake even against a
|
||||||
perfectly valid cert for a different name.
|
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
|
## Skipping to the next photo
|
||||||
|
|
||||||
Wire a momentary push button between GPIO2 and GND (internal pull-up,
|
Wire a momentary push button between GPIO2 and GND (internal pull-up,
|
||||||
@@ -204,6 +219,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
|
right away, regardless of the configured refresh interval -- no long hold
|
||||||
needed, since advancing is easily reversible by pressing again. See
|
needed, since advancing is easily reversible by pressing again. See
|
||||||
`FRAME_NEXT_BUTTON_GPIO` above to change the pin or disable the feature.
|
`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
|
Normal wakes and reboots never advance the photo on their own -- the
|
||||||
server decides when to advance based on its own clock (see
|
server decides when to advance based on its own clock (see
|
||||||
@@ -223,7 +240,8 @@ disable the feature.
|
|||||||
|
|
||||||
If there's nothing to go back to yet (freshly provisioned, or you've
|
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
|
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)
|
## Battery (XIAO ESP32-C6)
|
||||||
|
|
||||||
@@ -262,9 +280,13 @@ One more button, wired between GPIO1 and GND (same wiring style as the
|
|||||||
other buttons), covers three actions -- disambiguated purely by how
|
other buttons), covers three actions -- disambiguated purely by how
|
||||||
long it's held:
|
long it's held:
|
||||||
|
|
||||||
**A quick press** wakes the device and overlays several corners of
|
**A quick press** soft-resets the device -- `esp_restart()`, keeping the
|
||||||
whatever photo is currently showing, leaving the middle of the photo
|
stored WiFi/server config. Useful for recovering a hung device without
|
||||||
visible and unchanged:
|
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
|
- **Top-right**: a QR code -- "SCAN TO MANAGE" -- linking to the
|
||||||
server's config page.
|
server's config page.
|
||||||
@@ -283,8 +305,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
|
on the device or the server, this is entirely Immich's own People
|
||||||
feature). A third press exits immediately rather than waiting out the
|
feature). A third press exits immediately rather than waiting out the
|
||||||
30-second timer. Holding the button during this stage doesn't trigger
|
30-second timer. Holding the button during this stage doesn't trigger
|
||||||
either reset tier below -- the hold-duration read only ever happens
|
the factory-reset tier below -- the hold-duration read only ever
|
||||||
once, right when the device first wakes, before any menu is shown.
|
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
|
The device stays awake for the whole menu interaction (up to three
|
||||||
physical refreshes: the base overlay, the escalated one, and
|
physical refreshes: the base overlay, the escalated one, and
|
||||||
@@ -292,17 +315,13 @@ reverting), so this costs meaningfully more power than a normal wake --
|
|||||||
expected for a deliberate, occasional action, same tradeoff as the
|
expected for a deliberate, occasional action, same tradeoff as the
|
||||||
other buttons.
|
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 --
|
**Holding it ~15 seconds** (whether or not you're still holding it --
|
||||||
this fires immediately, it doesn't wait for release) clears the stored
|
this fires immediately, it doesn't wait for release) clears the stored
|
||||||
WiFi/server config and restarts into provisioning. From either power-on
|
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
|
or while the device is deep-asleep, since this GPIO is armed as a
|
||||||
wakeup source.
|
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
|
`FRAME_COMBO_FACTORY_RESET_HOLD_MS` above to change the pin or hold
|
||||||
durations, or disable all three actions.
|
durations, or disable all three actions.
|
||||||
|
|
||||||
|
|||||||
@@ -154,13 +154,12 @@ menu "ESPresso Frame Configuration"
|
|||||||
Button wired between this GPIO and GND (active-low, internal
|
Button wired between this GPIO and GND (active-low, internal
|
||||||
pull-up enabled in firmware -- no external resistor needed).
|
pull-up enabled in firmware -- no external resistor needed).
|
||||||
One pin, three actions depending on how long it's held:
|
One pin, three actions depending on how long it's held:
|
||||||
a quick press shows the management menu (same as before);
|
a quick press soft-resets the device (reboots, keeps the
|
||||||
holding it FRAME_COMBO_SOFT_RESET_HOLD_MS then releasing
|
stored WiFi/server config); holding it FRAME_COMBO_MENU_HOLD_MS
|
||||||
soft-resets the device (reboots, keeps the stored WiFi/
|
then releasing shows the management menu; holding it all the
|
||||||
server config); holding it all the way to
|
way to FRAME_COMBO_FACTORY_RESET_HOLD_MS clears the stored
|
||||||
FRAME_COMBO_FACTORY_RESET_HOLD_MS clears the stored config
|
config and restarts into provisioning, regardless of whether
|
||||||
and restarts into provisioning, regardless of whether it's
|
it's released yet. Must be GPIO 0-7 for the same deep-sleep-
|
||||||
released yet. Must be GPIO 0-7 for the same deep-sleep-
|
|
||||||
wakeup reason as FRAME_NEXT_BUTTON_GPIO above; defaults to
|
wakeup reason as FRAME_NEXT_BUTTON_GPIO above; defaults to
|
||||||
a different pin than the other buttons. Set to -1 to
|
a different pin than the other buttons. Set to -1 to
|
||||||
disable the feature entirely (also disables the management
|
disable the feature entirely (also disables the management
|
||||||
@@ -168,14 +167,14 @@ menu "ESPresso Frame Configuration"
|
|||||||
reconfiguring then only works by erasing NVS over USB, see
|
reconfiguring then only works by erasing NVS over USB, see
|
||||||
firmware/README.md).
|
firmware/README.md).
|
||||||
|
|
||||||
config FRAME_COMBO_SOFT_RESET_HOLD_MS
|
config FRAME_COMBO_MENU_HOLD_MS
|
||||||
int "Soft-reset hold duration (ms)"
|
int "Management-menu hold duration (ms)"
|
||||||
default 3000
|
default 3000
|
||||||
depends on FRAME_COMBO_BUTTON_GPIO >= 0
|
depends on FRAME_COMBO_BUTTON_GPIO >= 0
|
||||||
help
|
help
|
||||||
How long the menu/reset button must be held before releasing
|
How long the menu/reset button must be held before releasing
|
||||||
it triggers a soft reset (reboot, config kept). Long enough
|
it shows the management menu instead of soft-resetting. Long
|
||||||
to be clearly distinct from a quick menu-opening press.
|
enough to be clearly distinct from a quick reset tap.
|
||||||
|
|
||||||
config FRAME_COMBO_FACTORY_RESET_HOLD_MS
|
config FRAME_COMBO_FACTORY_RESET_HOLD_MS
|
||||||
int "Factory-reset hold duration (ms)"
|
int "Factory-reset hold duration (ms)"
|
||||||
@@ -185,8 +184,25 @@ menu "ESPresso Frame Configuration"
|
|||||||
How long the menu/reset button must be held continuously
|
How long the menu/reset button must be held continuously
|
||||||
before the device clears its stored config and reboots into
|
before the device clears its stored config and reboots into
|
||||||
provisioning, regardless of release. Comfortably longer than
|
provisioning, regardless of release. Comfortably longer than
|
||||||
FRAME_COMBO_SOFT_RESET_HOLD_MS so the two tiers can't be
|
FRAME_COMBO_MENU_HOLD_MS so the two tiers can't be confused
|
||||||
confused for each other.
|
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
|
config FRAME_BATTERY_ADC_GPIO
|
||||||
int "Battery voltage-divider ADC GPIO (-1 to disable)"
|
int "Battery voltage-divider ADC GPIO (-1 to disable)"
|
||||||
|
|||||||
+40
-18
@@ -1,3 +1,5 @@
|
|||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
#include "driver/gpio.h"
|
#include "driver/gpio.h"
|
||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
#include "esp_sleep.h"
|
#include "esp_sleep.h"
|
||||||
@@ -5,6 +7,8 @@
|
|||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
#include "freertos/task.h"
|
#include "freertos/task.h"
|
||||||
|
|
||||||
|
#include "wifi_provisioning.h"
|
||||||
|
|
||||||
#include "back_button.h"
|
#include "back_button.h"
|
||||||
|
|
||||||
static const char *TAG = "back_button";
|
static const char *TAG = "back_button";
|
||||||
@@ -14,6 +18,7 @@ static const char *TAG = "back_button";
|
|||||||
#define BACK_BUTTON_GPIO ((gpio_num_t)CONFIG_FRAME_BACK_BUTTON_GPIO)
|
#define BACK_BUTTON_GPIO ((gpio_num_t)CONFIG_FRAME_BACK_BUTTON_GPIO)
|
||||||
#define BACK_BUTTON_DEBOUNCE_MS 20
|
#define BACK_BUTTON_DEBOUNCE_MS 20
|
||||||
#define BACK_BUTTON_DEBOUNCE_CHECKS 3
|
#define BACK_BUTTON_DEBOUNCE_CHECKS 3
|
||||||
|
#define BACK_BUTTON_POLL_MS 100
|
||||||
|
|
||||||
void back_button_init(void)
|
void back_button_init(void)
|
||||||
{
|
{
|
||||||
@@ -36,7 +41,7 @@ void back_button_init(void)
|
|||||||
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << BACK_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
|
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << BACK_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
/* 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
|
* boot, confirmed on hardware -- a live gpio_get_level() check here
|
||||||
@@ -44,32 +49,49 @@ bool back_button_check(void)
|
|||||||
* status register is latched at the moment of waking and isn't
|
* status register is latched at the moment of waking and isn't
|
||||||
* cleared until the next sleep entry, so it reliably reflects a tap
|
* cleared until the next sleep entry, so it reliably reflects a tap
|
||||||
* regardless of how quickly it was released. */
|
* regardless of how quickly it was released. */
|
||||||
if (esp_sleep_get_gpio_wakeup_status() & (1ULL << BACK_BUTTON_GPIO)) {
|
bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << BACK_BUTTON_GPIO);
|
||||||
ESP_LOGI(TAG, "Back-photo button caused this wake, going back");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a fresh
|
if (!caused_wake) {
|
||||||
* power-on/reflash) -- fall back to a live, debounced level check so
|
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a
|
||||||
* holding the button down while powering on also works. */
|
* fresh power-on/reflash) -- fall back to a live, debounced level
|
||||||
if (gpio_get_level(BACK_BUTTON_GPIO) != 0) {
|
* check so holding the button down while powering on also
|
||||||
return false;
|
* works. */
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
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");
|
/* Confirmed pressed -- measure how long, same reasoning/pattern as
|
||||||
return true;
|
* 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
|
#else
|
||||||
|
|
||||||
void back_button_init(void) {}
|
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
|
#endif
|
||||||
|
|||||||
@@ -12,9 +12,23 @@
|
|||||||
*/
|
*/
|
||||||
void back_button_init(void);
|
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
|
* Checks the back-photo button and, if it's pressed at all, blocks
|
||||||
* a couple of short re-checks to reject noise. Same immediate-response
|
* polling its level until either it's released (BACK_BUTTON_SHORT_PRESS)
|
||||||
* reasoning as the next-photo button -- no long hold-to-confirm gate.
|
* 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);
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ bool combo_button_check(void)
|
|||||||
return false; /* not pressed, and didn't cause this wake either */
|
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",
|
ESP_LOGI(TAG, "Combo button held -- quick press for soft reset, %dms for menu, %dms for factory reset",
|
||||||
CONFIG_FRAME_COMBO_SOFT_RESET_HOLD_MS, CONFIG_FRAME_COMBO_FACTORY_RESET_HOLD_MS);
|
CONFIG_FRAME_COMBO_MENU_HOLD_MS, CONFIG_FRAME_COMBO_FACTORY_RESET_HOLD_MS);
|
||||||
|
|
||||||
int elapsed_ms = 0;
|
int elapsed_ms = 0;
|
||||||
while (gpio_get_level(COMBO_BUTTON_GPIO) == 0) {
|
while (gpio_get_level(COMBO_BUTTON_GPIO) == 0) {
|
||||||
@@ -70,13 +70,13 @@ bool combo_button_check(void)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (elapsed_ms >= CONFIG_FRAME_COMBO_SOFT_RESET_HOLD_MS) {
|
if (elapsed_ms >= CONFIG_FRAME_COMBO_MENU_HOLD_MS) {
|
||||||
ESP_LOGW(TAG, "Held %dms and released, soft-restarting (config kept)", elapsed_ms);
|
ESP_LOGI(TAG, "Held %dms and released, showing management menu", elapsed_ms);
|
||||||
esp_restart();
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Quick press (%dms), showing management menu", elapsed_ms);
|
ESP_LOGW(TAG, "Quick press (%dms), soft-restarting (config kept)", elapsed_ms);
|
||||||
return true;
|
esp_restart();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool combo_button_is_pressed(void)
|
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
|
* Checks the combined menu/reset button and acts on how long it was
|
||||||
* held, evaluated once per wake:
|
* held, evaluated once per wake:
|
||||||
* - Not pressed: returns false immediately.
|
* - Not pressed: returns false immediately.
|
||||||
* - Released before CONFIG_FRAME_COMBO_SOFT_RESET_HOLD_MS (a quick
|
* - Released before CONFIG_FRAME_COMBO_MENU_HOLD_MS (a quick press):
|
||||||
* press): returns true -- caller should show the management menu.
|
* a soft reset (esp_restart(), stored WiFi/server config kept) --
|
||||||
* - Released between the soft-reset and factory-reset thresholds: a
|
|
||||||
* soft reset (esp_restart(), stored WiFi/server config kept) --
|
|
||||||
* never returns.
|
* 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
|
* - Held through CONFIG_FRAME_COMBO_FACTORY_RESET_HOLD_MS: a factory
|
||||||
* reset (frame_config_clear() + esp_restart(), fires immediately
|
* reset (frame_config_clear() + esp_restart(), fires immediately
|
||||||
* without waiting for release) -- never returns.
|
* without waiting for release) -- never returns.
|
||||||
|
|||||||
@@ -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),
|
/* Builds a full URL from cfg->toolsserver + a path (no leading slash),
|
||||||
* appending cfg->access_token as ?token= if one's set. toolsserver is
|
* appending cfg->device_token as &token= once one's been delivered.
|
||||||
* normally a bare "host:port", defaulting to plain http; it may instead
|
* toolsserver is normally a bare "host:port", defaulting to plain http;
|
||||||
* carry an explicit "http://" or "https://" prefix to pick the scheme,
|
* it may instead carry an explicit "http://" or "https://" prefix to
|
||||||
* e.g. "https://frame.example.com" if a reverse proxy is terminating
|
* pick the scheme, e.g. "https://frame.example.com" if a reverse proxy
|
||||||
* TLS in front of the tools server. Every URL carries ?id= (the device's
|
* is terminating TLS in front of the tools server. Every URL carries
|
||||||
* MAC-derived identity -- how a multi-frame server tells frames apart
|
* ?id= (the device's MAC-derived identity -- how a multi-frame server
|
||||||
* and how an unknown frame self-registers) plus &token=: the server-
|
* tells frames apart and how an unknown frame self-registers) plus
|
||||||
* issued per-frame device token once one has been delivered via
|
* &token=. This is the one chokepoint all requests go through, so every
|
||||||
* /frame/config, else the provisioned access token (the legacy shared
|
* caller gets both for free instead of needing to remember to add them. */
|
||||||
* 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. */
|
|
||||||
static void build_url(char *out, size_t out_size, const frame_config_t *cfg, const char *path)
|
static void build_url(char *out, size_t out_size, const frame_config_t *cfg, const char *path)
|
||||||
{
|
{
|
||||||
const char *toolsserver = cfg->toolsserver;
|
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);
|
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 (cfg->device_token[0] != '\0' && len < out_size) {
|
||||||
if (token[0] != '\0' && len < out_size) {
|
snprintf(out + len, out_size - len, "&token=%s", cfg->device_token);
|
||||||
snprintf(out + len, out_size - len, "&token=%s", token);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,6 +272,14 @@ esp_err_t frame_wifi_connect_sta(const frame_config_t *cfg)
|
|||||||
typedef struct {
|
typedef struct {
|
||||||
bool reachable;
|
bool reachable;
|
||||||
uint32_t refresh_interval_s; /* CONFIG_FRAME_SLEEP_INTERVAL_S if absent/unparseable */
|
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 */
|
char firmware_version[32]; /* server's uploaded OTA image version; empty if none/unreachable */
|
||||||
/* Per-frame token the server pushes until this device has
|
/* Per-frame token the server pushes until this device has
|
||||||
* authenticated with it once; empty when absent. Persisted via
|
* 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 = {
|
frame_server_config_t result = {
|
||||||
.reachable = false,
|
.reachable = false,
|
||||||
.refresh_interval_s = CONFIG_FRAME_SLEEP_INTERVAL_S,
|
.refresh_interval_s = CONFIG_FRAME_SLEEP_INTERVAL_S,
|
||||||
|
.hold_duration_ms = CONFIG_FRAME_HOLD_ACTION_MS,
|
||||||
};
|
};
|
||||||
result.firmware_version[0] = '\0';
|
result.firmware_version[0] = '\0';
|
||||||
result.device_token[0] = '\0';
|
result.device_token[0] = '\0';
|
||||||
@@ -419,6 +424,10 @@ 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,
|
ESP_LOGW(TAG, "'%s' response missing refresh_interval_s, using fallback %ds", url,
|
||||||
(int)result.refresh_interval_s);
|
(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, "firmware_version", result.firmware_version, sizeof(result.firmware_version));
|
||||||
json_extract_string(body, "device_token", result.device_token, sizeof(result.device_token));
|
json_extract_string(body, "device_token", result.device_token, sizeof(result.device_token));
|
||||||
|
|
||||||
@@ -443,12 +452,13 @@ static size_t http_read_fn(uint8_t *chunk, size_t chunk_size, void *ctx_)
|
|||||||
return n > 0 ? (size_t)n : 0;
|
return n > 0 ? (size_t)n : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* GETs /frame/image (FETCH_NORMAL), or POSTs /frame/advance or
|
/* GETs /frame/image (FETCH_NORMAL), or POSTs /frame/advance, /frame/back,
|
||||||
* /frame/back to force a move in either direction (FETCH_ADVANCE /
|
* /frame/global-next, or /frame/global-back to force a move/action
|
||||||
* FETCH_BACK -- the next-photo / back-photo buttons). manage=true (the
|
* (FETCH_ADVANCE / FETCH_BACK -- a short press; FETCH_GLOBAL_NEXT /
|
||||||
* manage button) appends &manage=1, telling the server to bake its
|
* FETCH_GLOBAL_BACK -- a held press, see next_button.h/back_button.h).
|
||||||
* overlay into this same response instead of returning the bare
|
* manage=true (the manage button) appends &manage=1, telling the server
|
||||||
* content -- see server/app/routers/device.py. Returning non-ESP_OK
|
* 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()
|
* means the panel was never actually refreshed -- epd_display_stream()
|
||||||
* (see epd7in3e.c) refuses to trigger a physical refresh on a short/
|
* (see epd7in3e.c) refuses to trigger a physical refresh on a short/
|
||||||
* wrong-size stream, so a failure here always leaves the visible screen
|
* wrong-size stream, so a failure here always leaves the visible screen
|
||||||
@@ -460,6 +470,10 @@ static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t act
|
|||||||
path = "frame/advance";
|
path = "frame/advance";
|
||||||
} else if (action == FETCH_BACK) {
|
} else if (action == FETCH_BACK) {
|
||||||
path = "frame/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];
|
char url[256];
|
||||||
@@ -720,6 +734,11 @@ void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool sho
|
|||||||
report_battery(cfg, battery_percent);
|
report_battery(cfg, battery_percent);
|
||||||
frame_server_config_t server_cfg = fetch_frame_config(cfg);
|
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;
|
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
|
/* One-time identity handshake: the server pushes this frame's
|
||||||
* own token until we've authenticated with it once. Persist it
|
* 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
|
* Which photo-fetch behavior this wake cycle should use -- normally the
|
||||||
* idempotent GET /frame/image (the server decides on its own whether to
|
* idempotent GET /frame/image (the server decides on its own whether to
|
||||||
* advance, based on its configured refresh interval, so a plain
|
* advance, based on its configured refresh interval, so a plain
|
||||||
* wake/reboot never skips a photo just by asking), or POST
|
* wake/reboot never skips a photo just by asking), POST /frame/advance /
|
||||||
* /frame/advance / POST /frame/back to force a move in either direction
|
* POST /frame/back to force a move in either direction (a short press of
|
||||||
* (the next-photo / back-photo buttons).
|
* 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 {
|
typedef enum {
|
||||||
FETCH_NORMAL,
|
FETCH_NORMAL,
|
||||||
FETCH_ADVANCE,
|
FETCH_ADVANCE,
|
||||||
FETCH_BACK,
|
FETCH_BACK,
|
||||||
|
FETCH_GLOBAL_NEXT,
|
||||||
|
FETCH_GLOBAL_BACK,
|
||||||
} fetch_action_t;
|
} fetch_action_t;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+13
-4
@@ -40,12 +40,21 @@ void app_main(void)
|
|||||||
back_button_init();
|
back_button_init();
|
||||||
combo_button_init();
|
combo_button_init();
|
||||||
|
|
||||||
bool next_pressed = next_button_check();
|
next_button_result_t next_result = next_button_check();
|
||||||
bool back_pressed = back_button_check();
|
back_button_result_t back_result = back_button_check();
|
||||||
/* Next takes priority over back if somehow both read pressed at once
|
/* Next takes priority over back if somehow both read pressed at once
|
||||||
* (e.g. both held through a power-on) -- an arbitrary but
|
* (e.g. both held through a power-on) -- an arbitrary but
|
||||||
* deterministic tie-break, not expected to matter in practice. */
|
* deterministic tie-break, not expected to matter in practice. Same
|
||||||
fetch_action_t action = next_pressed ? FETCH_ADVANCE : back_pressed ? FETCH_BACK : FETCH_NORMAL;
|
* 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/
|
/* Soft-resets or clears config + restarts internally for a medium/
|
||||||
* long hold and never returns in those cases -- only returns here
|
* long hold and never returns in those cases -- only returns here
|
||||||
* for "not pressed" (false) or "quick press" (true, show the menu). */
|
* for "not pressed" (false) or "quick press" (true, show the menu). */
|
||||||
|
|||||||
+45
-18
@@ -1,3 +1,5 @@
|
|||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
#include "driver/gpio.h"
|
#include "driver/gpio.h"
|
||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
#include "esp_sleep.h"
|
#include "esp_sleep.h"
|
||||||
@@ -5,6 +7,8 @@
|
|||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
#include "freertos/task.h"
|
#include "freertos/task.h"
|
||||||
|
|
||||||
|
#include "wifi_provisioning.h"
|
||||||
|
|
||||||
#include "next_button.h"
|
#include "next_button.h"
|
||||||
|
|
||||||
static const char *TAG = "next_button";
|
static const char *TAG = "next_button";
|
||||||
@@ -14,6 +18,7 @@ static const char *TAG = "next_button";
|
|||||||
#define NEXT_BUTTON_GPIO ((gpio_num_t)CONFIG_FRAME_NEXT_BUTTON_GPIO)
|
#define NEXT_BUTTON_GPIO ((gpio_num_t)CONFIG_FRAME_NEXT_BUTTON_GPIO)
|
||||||
#define NEXT_BUTTON_DEBOUNCE_MS 20
|
#define NEXT_BUTTON_DEBOUNCE_MS 20
|
||||||
#define NEXT_BUTTON_DEBOUNCE_CHECKS 3
|
#define NEXT_BUTTON_DEBOUNCE_CHECKS 3
|
||||||
|
#define NEXT_BUTTON_POLL_MS 100
|
||||||
|
|
||||||
void next_button_init(void)
|
void next_button_init(void)
|
||||||
{
|
{
|
||||||
@@ -40,7 +45,7 @@ void next_button_init(void)
|
|||||||
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << NEXT_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
|
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << NEXT_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
/* 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
|
* boot, confirmed on hardware -- a live gpio_get_level() check here
|
||||||
@@ -48,32 +53,54 @@ bool next_button_check(void)
|
|||||||
* status register is latched at the moment of waking and isn't
|
* status register is latched at the moment of waking and isn't
|
||||||
* cleared until the next sleep entry, so it reliably reflects a tap
|
* cleared until the next sleep entry, so it reliably reflects a tap
|
||||||
* regardless of how quickly it was released. */
|
* regardless of how quickly it was released. */
|
||||||
if (esp_sleep_get_gpio_wakeup_status() & (1ULL << NEXT_BUTTON_GPIO)) {
|
bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << NEXT_BUTTON_GPIO);
|
||||||
ESP_LOGI(TAG, "Next-photo button caused this wake, forcing advance");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a fresh
|
if (!caused_wake) {
|
||||||
* power-on/reflash) -- fall back to a live, debounced level check so
|
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a
|
||||||
* holding the button down while powering on also works. */
|
* fresh power-on/reflash) -- fall back to a live, debounced level
|
||||||
if (gpio_get_level(NEXT_BUTTON_GPIO) != 0) {
|
* check so holding the button down while powering on also
|
||||||
return false;
|
* works. */
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
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");
|
/* Confirmed pressed (either the wake cause, or debounced during
|
||||||
return true;
|
* 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
|
#else
|
||||||
|
|
||||||
void next_button_init(void) {}
|
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
|
#endif
|
||||||
|
|||||||
@@ -12,10 +12,27 @@
|
|||||||
*/
|
*/
|
||||||
void next_button_init(void);
|
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
|
* Checks the next-photo button and, if it's pressed at all (either what
|
||||||
* a couple of short re-checks to reject noise. No long hold-to-confirm
|
* caused this wake, per the latched wakeup-status register, or held
|
||||||
* gate -- advancing a photo is low-stakes and should feel immediate, so
|
* through a debounced power-on check), blocks polling its level until
|
||||||
* this returns right away either way.
|
* 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);
|
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 (cfg->device_token[0] != '\0' && len < out_size) {
|
||||||
if (token[0] != '\0' && len < out_size) {
|
snprintf(out + len, out_size - len, "&token=%s", cfg->device_token);
|
||||||
snprintf(out + len, out_size - len, "&token=%s", token);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
<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>
|
||||||
|
|
||||||
<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
|
<p style="font-size: 13px; color: #555;">After saving, this page will
|
||||||
take you to the server to claim your frame — reconnect to
|
take you to the server to claim your frame — reconnect to
|
||||||
your normal WiFi if it doesn't happen automatically.</p>
|
your normal WiFi if it doesn't happen automatically.</p>
|
||||||
|
|||||||
@@ -73,20 +73,10 @@ esp_err_t frame_config_load(frame_config_t *out)
|
|||||||
return pass_err;
|
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
|
/* Optional: absent until the server has pushed a per-frame token
|
||||||
* (see frame_config_set_device_token). */
|
* (see frame_config_set_device_token). */
|
||||||
len = sizeof(out->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) {
|
if (token_err != ESP_OK && token_err != ESP_ERR_NVS_NOT_FOUND) {
|
||||||
nvs_close(handle);
|
nvs_close(handle);
|
||||||
return token_err;
|
return token_err;
|
||||||
@@ -131,9 +121,6 @@ esp_err_t frame_config_save(const frame_config_t *cfg)
|
|||||||
if (err == ESP_OK) {
|
if (err == ESP_OK) {
|
||||||
err = nvs_set_str(handle, "toolsserver", cfg->toolsserver);
|
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) {
|
if (err == ESP_OK) {
|
||||||
/* Re-provisioning restarts the identity handshake: the server
|
/* Re-provisioning restarts the identity handshake: the server
|
||||||
* (possibly a different one now) re-issues a device token when
|
* (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_ssid");
|
||||||
nvs_erase_key(handle, "sta_pass");
|
nvs_erase_key(handle, "sta_pass");
|
||||||
nvs_erase_key(handle, "toolsserver");
|
nvs_erase_key(handle, "toolsserver");
|
||||||
nvs_erase_key(handle, "access_token");
|
|
||||||
nvs_erase_key(handle, "device_token");
|
nvs_erase_key(handle, "device_token");
|
||||||
nvs_erase_key(handle, "connected_once");
|
nvs_erase_key(handle, "connected_once");
|
||||||
nvs_commit(handle);
|
nvs_commit(handle);
|
||||||
@@ -234,6 +220,29 @@ void frame_config_invalidate_last_display_crc32(void)
|
|||||||
nvs_close(handle);
|
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
|
* 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, "ssid", cfg.sta_ssid, sizeof(cfg.sta_ssid));
|
||||||
extract_form_value(body, "password", cfg.sta_password, sizeof(cfg.sta_password));
|
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, "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) {
|
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");
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "SSID and Tools Server are required");
|
||||||
@@ -443,8 +451,7 @@ static esp_err_t save_config_post_handler(httpd_req_t *req)
|
|||||||
return ESP_FAIL;
|
return ESP_FAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Saved config: ssid='%s' toolsserver='%s' access_token=%s", cfg.sta_ssid, cfg.toolsserver,
|
ESP_LOGI(TAG, "Saved config: ssid='%s' toolsserver='%s'", cfg.sta_ssid, cfg.toolsserver);
|
||||||
strlen(cfg.access_token) ? "set" : "none");
|
|
||||||
|
|
||||||
/* The success page hands the browser off to the server's claim page,
|
/* 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
|
* carrying this device's id -- how a frame gets linked to a user
|
||||||
|
|||||||
@@ -17,11 +17,10 @@ typedef struct {
|
|||||||
char sta_ssid[FRAME_CFG_SSID_MAX_LEN + 1];
|
char sta_ssid[FRAME_CFG_SSID_MAX_LEN + 1];
|
||||||
char sta_password[FRAME_CFG_PASSWORD_MAX_LEN + 1];
|
char sta_password[FRAME_CFG_PASSWORD_MAX_LEN + 1];
|
||||||
char toolsserver[FRAME_CFG_SERVER_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
|
/* Per-frame token issued by the server via GET /frame/config after
|
||||||
* this device first introduces itself by id -- preferred over
|
* this device first introduces itself by id (see frame_client.c's
|
||||||
* access_token once present (see frame_client.c's build_url). Not
|
* build_url). Not set at the captive portal; empty until the server
|
||||||
* set at the captive portal; empty until the server pushes one. */
|
* pushes one. */
|
||||||
char device_token[FRAME_CFG_TOKEN_MAX_LEN + 1];
|
char device_token[FRAME_CFG_TOKEN_MAX_LEN + 1];
|
||||||
} frame_config_t;
|
} 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);
|
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
|
* Returns this device's provisioning AP identity: a fixed SSID (from
|
||||||
* Kconfig) and a password that's generated once on first use and persisted
|
* Kconfig) and a password that's generated once on first use and persisted
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.3.0
|
1.4.2
|
||||||
|
|||||||
+81
-25
@@ -2,39 +2,95 @@ FROM python:3.12-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# tzdata/fonts in their own layer, kept separate from the much larger
|
# tzdata/fonts/Node.js all from Debian's own repo in one layer -- no
|
||||||
# Node.js/npm layers below -- see those layers' own comments for why
|
# external curl/gnupg dance needed (see below for why that changed).
|
||||||
# they're split up the way they are. tzdata: python:3.12-slim doesn't
|
# tzdata: python:3.12-slim doesn't include it by default, so the
|
||||||
# include it by default, so the zoneinfo database backing the web UI's
|
# zoneinfo database backing the web UI's "Timezone" setting (used by
|
||||||
# "Timezone" setting (used by "Quiet hours") would have no named zones
|
# "Quiet hours") would have no named zones to resolve without this --
|
||||||
# to resolve without this -- ZoneInfo() would raise for anything other
|
# ZoneInfo() would raise for anything other than "UTC".
|
||||||
# than "UTC". fontconfig/fonts-dejavu-core: whiteboard mode's
|
# fontconfig/fonts-dejavu-core: whiteboard mode's render-service/ (own
|
||||||
# render-service/ (own README there) needs something to render
|
# README there) needs something to render whiteboard text with.
|
||||||
# whiteboard text with.
|
#
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
tzdata fontconfig fonts-dejavu-core \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Node.js: whiteboard frame mode's render-service/ runs as a second
|
# Node.js: whiteboard frame mode's render-service/ runs as a second
|
||||||
# process in this same container rather than a separate compose service
|
# process in this same container rather than a separate compose service
|
||||||
# -- it's a lightweight, stateless, localhost-only sidecar with nothing
|
# -- it's a lightweight, stateless, localhost-only sidecar with nothing
|
||||||
# worth independently scaling or restarting. NodeSource's setup script is
|
# worth independently scaling or restarting. Used to be installed via
|
||||||
# used instead of Debian bookworm's own apt Node package, which is both
|
# NodeSource's setup script (Debian's own nodejs package was too old for
|
||||||
# older than jsdom's minimum (20.19+) and inconsistently available.
|
# jsdom's minimum back when this base image tracked Debian bookworm) --
|
||||||
# curl/gnupg are only needed to add and fetch NodeSource's repo -- purged
|
# switched to Debian's own `nodejs`/`npm` packages after NodeSource's
|
||||||
# again in this same RUN (not a later one; Docker layers are immutable,
|
# deb.nodesource.com started intermittently 403ing on both its setup_*.x
|
||||||
# so removing them in a *different* instruction wouldn't shrink this
|
# scripts *and* its GPG key (a live NodeSource-side S3/CDN issue,
|
||||||
# one's actual pushed size) so their bytes don't end up in the image at
|
# confirmed 2026-07-27 by hitting deb.nodesource.com directly -- some
|
||||||
# all, only nodejs's.
|
# setup_NN.x paths 403, others 200, no consistent pattern, so no
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates gnupg \
|
# NodeSource-hosted install path could be trusted not to silently break
|
||||||
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
# again). This base image now tracks Debian trixie, whose own `nodejs`
|
||||||
&& apt-get install -y --no-install-recommends nodejs \
|
# package is 20.19.2 -- inside jsdom 29's stated engines range
|
||||||
&& apt-get purge -y --auto-remove curl gnupg \
|
# (`^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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
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
|
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
|
# render-service/'s dependencies installed as several separate layers
|
||||||
# rather than one `npm install` covering all of them -- a from-scratch
|
# 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
|
# push of this image once hit Cloudflare's payload-size limit on a
|
||||||
|
|||||||
+26
-19
@@ -37,13 +37,10 @@ algorithm itself -- it just streams the response straight to the panel.
|
|||||||
instead (see `firmware/README.md`'s HTTPS section).
|
instead (see `firmware/README.md`'s HTTPS section).
|
||||||
5. **Each frame gets its own device token automatically** -- the server
|
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
|
issues it on the frame's first check-in, so there's nothing to
|
||||||
configure. The captive portal's **Access Token** field only matters
|
configure. `MANAGEMENT_TOKEN` in `docker-compose.yml` is optional and
|
||||||
when pointing new firmware at an old (pre-multi-frame) server.
|
only matters pre-setup: if set, it's the credential that gates who
|
||||||
`MANAGEMENT_TOKEN` in `docker-compose.yml` is likewise now only the
|
gets to be the one to run first-run setup on a freshly deployed
|
||||||
*migration* credential: a frame flashed with pre-multi-frame
|
server, before any admin account exists.
|
||||||
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).
|
|
||||||
6. **Optional: auto-update firmware from Gitea releases.** If you're
|
6. **Optional: auto-update firmware from Gitea releases.** If you're
|
||||||
pushing this repo to a Gitea instance, `.gitea/workflows/firmware-release-build.yml`
|
pushing this repo to a Gitea instance, `.gitea/workflows/firmware-release-build.yml`
|
||||||
builds both supported boards and publishes them as release assets
|
builds both supported boards and publishes them as release assets
|
||||||
@@ -91,11 +88,19 @@ algorithm itself -- it just streams the response straight to the panel.
|
|||||||
again until a recharge is detected and it crosses again. No SMTP
|
again until a recharge is detected and it crosses again. No SMTP
|
||||||
configured, or no email on the relevant account, and both features
|
configured, or no email on the relevant account, and both features
|
||||||
silently no-op rather than erroring.
|
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
|
## Endpoints
|
||||||
|
|
||||||
Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
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}`.
|
`/frames/{id}/stats`, `/m/{manage_token}`.
|
||||||
|
|
||||||
### Device protocol (`/frame/*` -- paths frozen; auth = `?id=` + `?token=`)
|
### Device protocol (`/frame/*` -- paths frozen; auth = `?id=` + `?token=`)
|
||||||
@@ -121,9 +126,11 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
|||||||
this response may grow.
|
this response may grow.
|
||||||
- `GET /frame/photo-info` -- location/date overlay text for the manage
|
- `GET /frame/photo-info` -- location/date overlay text for the manage
|
||||||
menu (city + abbreviated US/CAN region or country, `MM/DD/YY`).
|
menu (city + abbreviated US/CAN region or country, `MM/DD/YY`).
|
||||||
- `GET /frame/share/{asset_id}` -- creates a 30-minute public Immich
|
- `GET /frame/share/{manage_token}` -- creates a 30-minute public Immich
|
||||||
share link and 302s to it; scoped to the photo currently showing or
|
share link covering every photo widget's currently-showing photo on
|
||||||
queued on *this* frame only.
|
*this* frame and 302s to it. Authenticated by the frame's own
|
||||||
|
`manage_token` (see the manage QR below), not device credentials -- a
|
||||||
|
phone scanning the QR has no way to supply `?id=`/`?token=`.
|
||||||
- `GET /frame/face-labels` -- up to 4 named faces with 800x480
|
- `GET /frame/face-labels` -- up to 4 named faces with 800x480
|
||||||
positions, flattened (`name_0`/`x_0`/`y_0`, ...) for the device's
|
positions, flattened (`name_0`/`x_0`/`y_0`, ...) for the device's
|
||||||
flat-scalar parser.
|
flat-scalar parser.
|
||||||
@@ -200,13 +207,13 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
|||||||
in the web UI (or using "Show next") only rearranges what's already in
|
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.
|
that lookahead; it doesn't add or remove photos from the album.
|
||||||
- Auth in one breath: browsers use sessions (+CSRF), devices use
|
- Auth in one breath: browsers use sessions (+CSRF), devices use
|
||||||
per-frame tokens (`?id=` + `?token=`), the manage QR uses its own
|
per-frame tokens (`?id=` + `?token=`), the manage QR and the
|
||||||
limited token, and `MANAGEMENT_TOKEN` survives only as the migration
|
scan-to-download QR both use the frame's own `manage_token` (device
|
||||||
credential for pre-multi-frame firmware. `/frame/share` stays scoped
|
tokens don't work for either -- neither is ever called by firmware,
|
||||||
to photos this frame is actually showing or has queued, not any
|
both are opened by a phone that has no way to supply `?id=`/`?token=`),
|
||||||
Immich asset ID someone might guess -- a second layer a leaked device
|
and `MANAGEMENT_TOKEN` is only ever the pre-setup claim gate (see
|
||||||
token alone wouldn't bypass.
|
step 5 above).
|
||||||
- Calendar frame mode (`app/calendar_feed.py`) expands recurring events
|
- The calendar widget (`app/calendar_feed.py`) expands recurring events
|
||||||
(RRULE/EXDATE/DST) via [`recurring-ical-events`](https://pypi.org/project/recurring-ical-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
|
which is LGPL-3.0-or-later -- the only non-permissively-licensed
|
||||||
dependency here. It's used as an ordinary `pip install` runtime import,
|
dependency here. It's used as an ordinary `pip install` runtime import,
|
||||||
@@ -224,7 +231,7 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
|||||||
explicit, informed call by the project owner, not a default -- anyone
|
explicit, informed call by the project owner, not a default -- anyone
|
||||||
redistributing this project (vs. just self-hosting it) should
|
redistributing this project (vs. just self-hosting it) should
|
||||||
re-evaluate that tradeoff for their own situation before doing so.
|
re-evaluate that tradeoff for their own situation before doing so.
|
||||||
- Whiteboard frame mode (`app/webdav_client.py`, `app/whiteboard.py`)
|
- The whiteboard widget (`app/webdav_client.py`, `app/whiteboard.py`)
|
||||||
fetches a Nextcloud Whiteboard (or any WebDAV server's) `.whiteboard`
|
fetches a Nextcloud Whiteboard (or any WebDAV server's) `.whiteboard`
|
||||||
file -- which turns out to be Excalidraw scene JSON (elements/appState/
|
file -- which turns out to be Excalidraw scene JSON (elements/appState/
|
||||||
files), not an image -- and renders it via `render-service/`, a small
|
files), not an image -- and renders it via `render-service/`, a small
|
||||||
|
|||||||
+44
-76
@@ -1,14 +1,18 @@
|
|||||||
"""Authentication: password hashing, user sessions + CSRF, the legacy
|
"""Authentication: password hashing, user sessions + CSRF, the pre-setup
|
||||||
shared-token gate, and device resolution.
|
claim gate, and device resolution.
|
||||||
|
|
||||||
Three independent credential classes:
|
Three independent credential classes:
|
||||||
- User sessions (cookie "session", server-side sessions table, per-
|
- User sessions (cookie "session", server-side sessions table, per-
|
||||||
session CSRF token required on mutating requests) -- humans.
|
session CSRF token required on mutating requests) -- humans.
|
||||||
- The legacy shared MANAGEMENT_TOKEN (env-only). Still accepted on
|
- MANAGEMENT_TOKEN (env-only, optional). Only meaningful before any user
|
||||||
browser routes so the deployed frame's on-panel manage QR (which
|
account exists yet (fresh install, or freshly migrated, before
|
||||||
embeds ?token=) keeps working until Phase C replaces it with the
|
/setup has been run): if set, it gates who gets to be the one to run
|
||||||
limited /m/ page; CSRF doesn't apply to it (it's explicit per-request
|
/setup and claim the first admin account; once a user exists, sessions
|
||||||
credential, not an ambient cookie a cross-site request could ride).
|
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).
|
- Device credentials (?id= + ?token=, see require_device below).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -250,17 +254,16 @@ def require_frame_control(
|
|||||||
|
|
||||||
|
|
||||||
def management_token() -> str:
|
def management_token() -> str:
|
||||||
"""The legacy shared secret. Env-only, never stored -- same as the old
|
"""The pre-setup claim-gate secret. Env-only, never stored -- same as
|
||||||
server, where the env var overrode anything on disk on every load."""
|
the old server, where the env var overrode anything on disk on every
|
||||||
|
load."""
|
||||||
return os.environ.get("MANAGEMENT_TOKEN", "")
|
return os.environ.get("MANAGEMENT_TOKEN", "")
|
||||||
|
|
||||||
|
|
||||||
def browser_token_valid(request: Request) -> bool:
|
def browser_token_valid(request: Request) -> bool:
|
||||||
"""The legacy shared-token check. No MANAGEMENT_TOKEN configured means
|
"""Whether the request carries the current MANAGEMENT_TOKEN, via
|
||||||
token-holders don't exist -- but unlike Phase A this no longer means
|
query param or cookie. Only meaningful pre-setup (see require_browser
|
||||||
"open": once users exist, sessions are the primary gate and this is
|
below) -- empty configured token => not valid (nothing to match)."""
|
||||||
only the compatibility path for the deployed frame's manage QR
|
|
||||||
(?token=) until Phase C. Empty token => not valid (sessions rule)."""
|
|
||||||
token = management_token()
|
token = management_token()
|
||||||
if not token:
|
if not token:
|
||||||
return False
|
return False
|
||||||
@@ -270,12 +273,12 @@ def browser_token_valid(request: Request) -> bool:
|
|||||||
|
|
||||||
def require_browser(request: Request, db: Session = Depends(get_db)) -> User | None:
|
def require_browser(request: Request, db: Session = Depends(get_db)) -> User | None:
|
||||||
"""Dependency for the web UI's /api/* routes: a real user session
|
"""Dependency for the web UI's /api/* routes: a real user session
|
||||||
(CSRF-checked on mutations, returns the User), or the legacy shared
|
(CSRF-checked on mutations, returns the User). While NO users exist
|
||||||
token (returns None -- token bearers act as an anonymous operator,
|
yet (fresh install, or freshly migrated, before /setup has been run)
|
||||||
exactly the pre-user model). While NO users exist yet (fresh install
|
the API instead stays open if no MANAGEMENT_TOKEN is set, or opens
|
||||||
or freshly migrated, before /setup has been run) the API stays open
|
to whoever supplies it if one is -- there's nobody to log in as yet,
|
||||||
if no MANAGEMENT_TOKEN is set -- the Phase A/legacy behavior --
|
so this is purely the claim gate for who gets to run /setup. Once a
|
||||||
since there's nobody to log in as yet."""
|
user exists, only a session gets in."""
|
||||||
session = current_session(request, db)
|
session = current_session(request, db)
|
||||||
if session is not None:
|
if session is not None:
|
||||||
if request.method not in ("GET", "HEAD", "OPTIONS") and not _csrf_ok(request, session):
|
if request.method not in ("GET", "HEAD", "OPTIONS") and not _csrf_ok(request, session):
|
||||||
@@ -283,10 +286,9 @@ def require_browser(request: Request, db: Session = Depends(get_db)) -> User | N
|
|||||||
user = db.get(User, session.user_id)
|
user = db.get(User, session.user_id)
|
||||||
if user is not None:
|
if user is not None:
|
||||||
return user
|
return user
|
||||||
if browser_token_valid(request):
|
if not users_exist(db):
|
||||||
return None
|
if not management_token() or browser_token_valid(request):
|
||||||
if not users_exist(db) and not management_token():
|
return None
|
||||||
return None
|
|
||||||
raise HTTPException(401, "Not logged in")
|
raise HTTPException(401, "Not logged in")
|
||||||
|
|
||||||
|
|
||||||
@@ -326,63 +328,29 @@ def _register_frame(db: Session, device_id: str) -> Frame:
|
|||||||
|
|
||||||
def require_device(request: Request, db: Session = Depends(get_db)) -> Frame:
|
def require_device(request: Request, db: Session = Depends(get_db)) -> Frame:
|
||||||
"""Resolves and authenticates the frame behind a /frame/* request.
|
"""Resolves and authenticates the frame behind a /frame/* request.
|
||||||
|
Firmware sends ?id=<12-hex-mac>&token=<per-frame device token>."""
|
||||||
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.
|
|
||||||
"""
|
|
||||||
device_id = request.query_params.get("id", "").strip().lower()
|
device_id = request.query_params.get("id", "").strip().lower()
|
||||||
token = request.query_params.get("token", "")
|
token = request.query_params.get("token", "")
|
||||||
legacy = management_token()
|
|
||||||
legacy_ok = not legacy or token == legacy
|
|
||||||
|
|
||||||
if device_id:
|
if not device_id:
|
||||||
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
|
raise HTTPException(401, "Missing device id")
|
||||||
if frame is None:
|
|
||||||
legacy_frame = db.scalars(
|
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
|
||||||
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712
|
if frame is None:
|
||||||
).first()
|
frame = _register_frame(db, device_id)
|
||||||
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")
|
|
||||||
else:
|
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")
|
raise HTTPException(401, "Missing or invalid access token")
|
||||||
frame = db.scalars(
|
# else: handshake window -- the device registered but hasn't
|
||||||
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712
|
# received its token yet (the wake cycle fetches the image
|
||||||
).first()
|
# BEFORE polling /frame/config, where the token is delivered) --
|
||||||
if frame is None:
|
# the id stays the credential, same trust level as the open
|
||||||
# Nothing to resolve a no-id request to. migration.py always
|
# registration that created the row. Closes permanently on the
|
||||||
# creates frame #1 at startup, so this only happens if it was
|
# first authenticated request.
|
||||||
# deleted -- treat like an unknown device.
|
|
||||||
raise HTTPException(401, "No frame accepts legacy credentials")
|
|
||||||
|
|
||||||
frame.last_seen = time.time()
|
frame.last_seen = time.time()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -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)
|
||||||
+179
-234
@@ -11,7 +11,7 @@ Event dicts here are calendar_feed.py's shape: {"summary", "start", "end"
|
|||||||
(ISO 8601 strings), "all_day", "sources": [{"owner_display_name",
|
(ISO 8601 strings), "all_day", "sources": [{"owner_display_name",
|
||||||
"color_index"}, ...]} -- more than one entry in "sources" means
|
"color_index"}, ...]} -- more than one entry in "sources" means
|
||||||
merge_events collapsed several calendars' identical (same title/time)
|
merge_events collapsed several calendars' identical (same title/time)
|
||||||
events into one, see _event_colors/_draw_color_bar below.
|
events into one, see _event_colors/panel_style.draw_color_chip below.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -26,6 +26,7 @@ from zoneinfo import ZoneInfo
|
|||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
from . import panel_style
|
||||||
from .image_pipeline import (
|
from .image_pipeline import (
|
||||||
DEFAULT_PALETTE_RGB,
|
DEFAULT_PALETTE_RGB,
|
||||||
_apply_manage_overlay,
|
_apply_manage_overlay,
|
||||||
@@ -35,18 +36,27 @@ from .image_pipeline import (
|
|||||||
logical_render_size,
|
logical_render_size,
|
||||||
)
|
)
|
||||||
from .weather import weather_category
|
from .weather import weather_category
|
||||||
|
from .weather_render import draw_weather_row
|
||||||
|
|
||||||
CALENDAR_VIEWS = ["agenda", "today_tomorrow", "week", "month"]
|
CALENDAR_VIEWS = ["agenda", "today_tomorrow", "week", "month"]
|
||||||
CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "today_tomorrow": "Agenda (today & tomorrow)",
|
CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "today_tomorrow": "Agenda (today & tomorrow)",
|
||||||
"week": "Week", "month": "Month"}
|
"week": "Week", "month": "Month"}
|
||||||
WEEKDAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
WEEKDAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||||||
|
|
||||||
MARGIN = 20
|
# 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)
|
BG = (255, 255, 255)
|
||||||
FG = (0, 0, 0)
|
FG = (0, 0, 0)
|
||||||
MUTED = (110, 110, 110)
|
# Structural dividers/grid lines (between stacked day sections, week
|
||||||
# Was a light gray, but that dithers away to near-invisible once quantized
|
# columns, month cells) stay a plain black rule -- gray dithers away to
|
||||||
# to the 6-color e-ink palette -- black reads as an actual line on-panel.
|
# 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)
|
RULE = (0, 0, 0)
|
||||||
|
|
||||||
# Fallback for any event whose calendar has no manually pinned color
|
# Fallback for any event whose calendar has no manually pinned color
|
||||||
@@ -66,7 +76,7 @@ def _event_colors(event: dict, owners_seen: list[str], palette_rgb: list | None)
|
|||||||
one person's calendar). Usually just one color; more than one is
|
one person's calendar). Usually just one color; more than one is
|
||||||
what tells the "same event, more than one calendar" case apart from
|
what tells the "same event, more than one calendar" case apart from
|
||||||
an ordinary single-calendar event at render time -- see
|
an ordinary single-calendar event at render time -- see
|
||||||
_draw_color_bar. Each source's own manually pinned color
|
panel_style.draw_color_chip. Each source's own manually pinned color
|
||||||
(FrameCalendar.color_index -- see routers/api_widgets.py's
|
(FrameCalendar.color_index -- see routers/api_widgets.py's
|
||||||
api_widget_calendar_color) resolves against whichever palette this frame
|
api_widget_calendar_color) resolves against whichever palette this frame
|
||||||
actually renders with, so a pinned "Blue" stays this frame's actual
|
actually renders with, so a pinned "Blue" stays this frame's actual
|
||||||
@@ -90,24 +100,6 @@ def _event_colors(event: dict, owners_seen: list[str], palette_rgb: list | None)
|
|||||||
return colors
|
return colors
|
||||||
|
|
||||||
|
|
||||||
def _draw_color_bar(draw: ImageDraw.ImageDraw, x0: int, y0: int, x1: int, y1: int,
|
|
||||||
colors: list[tuple[int, int, int]], radius: int) -> None:
|
|
||||||
"""One rounded bar for a single-source event, or that same overall
|
|
||||||
footprint split into equal-width side-by-side segments -- one per
|
|
||||||
contributing calendar -- for a deduplicated shared event (see
|
|
||||||
_event_colors/calendar_feed.merge_events). Splitting rather than
|
|
||||||
e.g. concentric rings keeps every color equally "thick and bold" at
|
|
||||||
a glance, the same design goal a single pinned color already has."""
|
|
||||||
if len(colors) == 1:
|
|
||||||
draw.rounded_rectangle([x0, y0, x1, y1], radius=radius, fill=colors[0])
|
|
||||||
return
|
|
||||||
seg_w = (x1 - x0) / len(colors)
|
|
||||||
for i, color in enumerate(colors):
|
|
||||||
seg_x0 = round(x0 + i * seg_w)
|
|
||||||
seg_x1 = round(x0 + (i + 1) * seg_w) - (2 if i < len(colors) - 1 else 0)
|
|
||||||
draw.rectangle([seg_x0, y0, seg_x1, y1], fill=color)
|
|
||||||
|
|
||||||
|
|
||||||
def _event_start(event: dict, tz: ZoneInfo) -> datetime | date:
|
def _event_start(event: dict, tz: ZoneInfo) -> datetime | date:
|
||||||
"""Parses event["start"] and, for timed events, converts to `tz` --
|
"""Parses event["start"] and, for timed events, converts to `tz` --
|
||||||
calendar_feed.py stores whatever timezone each source event carried
|
calendar_feed.py stores whatever timezone each source event carried
|
||||||
@@ -157,11 +149,12 @@ def _fmt_task_due(due: str | None) -> str:
|
|||||||
return d.strftime("%b %-d")
|
return d.strftime("%b %-d")
|
||||||
|
|
||||||
|
|
||||||
# ImageFont.load_default() (used for everything else in this module --
|
# Neither Inter (panel_style.font_bold/font_regular, this module's own
|
||||||
# see the module docstring) has no emoji glyphs, and PIL/FreeType don't
|
# body/title font -- see MARGIN/BG/FG comment above) nor PIL's bundled
|
||||||
# skip an unsupported codepoint, they substitute a ".notdef" tofu box (a
|
# default font has emoji glyphs, and PIL/FreeType don't skip an
|
||||||
# visible filled rectangle) -- reads as a rendering glitch, not "emoji
|
# unsupported codepoint, they substitute a ".notdef" tofu box (a visible
|
||||||
# not supported". So event titles get drawn with two fonts: the normal
|
# 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
|
# 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,
|
# (see _split_emoji_runs/_draw_mixed_line) -- both Noto Emoji, OFL-1.1,
|
||||||
# vendored at app/fonts/ (license alongside at app/fonts/OFL.txt).
|
# vendored at app/fonts/ (license alongside at app/fonts/OFL.txt).
|
||||||
@@ -389,98 +382,6 @@ def _weather_for_day(weather_cities: list[dict] | None, day: date) -> list[dict]
|
|||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
||||||
def _draw_cloud(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float) -> None:
|
|
||||||
"""A simple puffy-cloud silhouette (three overlapping lobes + a base)
|
|
||||||
with a clean outline -- drawn as one black pass slightly larger than
|
|
||||||
the shapes, then the same shapes again in white on top. Overlapping
|
|
||||||
ellipses each drawn with their own `outline=` would leave visible
|
|
||||||
seams where they cross; this double-draw trick sidesteps that
|
|
||||||
entirely regardless of how the lobes overlap."""
|
|
||||||
stroke = 2
|
|
||||||
lobes = [
|
|
||||||
(cx - r, cy - r * 0.3, cx - r * 0.1, cy + r * 0.6),
|
|
||||||
(cx - r * 0.45, cy - r * 0.8, cx + r * 0.35, cy + r * 0.25),
|
|
||||||
(cx, cy - r * 0.35, cx + r, cy + r * 0.6),
|
|
||||||
]
|
|
||||||
base = (cx - r * 0.8, cy, cx + r * 0.8, cy + r * 0.5)
|
|
||||||
for x0, y0, x1, y1 in lobes:
|
|
||||||
draw.ellipse([x0 - stroke, y0 - stroke, x1 + stroke, y1 + stroke], fill=FG)
|
|
||||||
draw.rectangle([base[0] - stroke, base[1], base[2] + stroke, base[3] + stroke], fill=FG)
|
|
||||||
for x0, y0, x1, y1 in lobes:
|
|
||||||
draw.ellipse([x0, y0, x1, y1], fill=BG)
|
|
||||||
draw.rectangle(base, fill=BG)
|
|
||||||
|
|
||||||
|
|
||||||
def _draw_weather_icon(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, category: str) -> None:
|
|
||||||
"""A small hand-drawn glyph for one weather category -- no custom
|
|
||||||
font/icon asset, same hand-primitives-only approach the rest of this
|
|
||||||
module uses (colored rectangles for owner indicators, density dots
|
|
||||||
for month view)."""
|
|
||||||
if category == "clear":
|
|
||||||
# Kept within a ~1.1r visual radius overall (rays included) to
|
|
||||||
# match _draw_cloud's own footprint -- _draw_weather_row lays
|
|
||||||
# icons out assuming each one stays roughly within icon_r of its
|
|
||||||
# center, and the first entry in a row sits flush against the
|
|
||||||
# region's own left margin, so any icon that draws wider than
|
|
||||||
# that pokes out past it with nothing to visually connect to.
|
|
||||||
draw.ellipse([cx - r * 0.7, cy - r * 0.7, cx + r * 0.7, cy + r * 0.7], fill=FG)
|
|
||||||
for dx, dy in ((0, -1), (0, 1), (-1, 0), (1, 0)):
|
|
||||||
draw.line([(cx + dx * r * 0.65, cy + dy * r * 0.65), (cx + dx * r * 1.0, cy + dy * r * 1.0)],
|
|
||||||
fill=FG, width=3)
|
|
||||||
return
|
|
||||||
|
|
||||||
cloud_cy = cy if category in ("partly_cloudy", "cloudy", "fog") else cy - r * 0.3
|
|
||||||
if category == "partly_cloudy":
|
|
||||||
draw.ellipse([cx - r * 1.3, cy - r * 1.3, cx - r * 0.1, cy - r * 0.1], fill=FG)
|
|
||||||
_draw_cloud(draw, cx, cloud_cy, r)
|
|
||||||
|
|
||||||
if category == "fog":
|
|
||||||
for i in range(3):
|
|
||||||
y = cy + r * 0.5 + i * (r * 0.45)
|
|
||||||
draw.line([(cx - r, y), (cx + r, y)], fill=FG, width=2)
|
|
||||||
elif category == "rain":
|
|
||||||
for dx in (-0.6, 0, 0.6):
|
|
||||||
x = cx + dx * r
|
|
||||||
draw.line([(x, cloud_cy + r * 0.6), (x - r * 0.25, cloud_cy + r * 1.2)], fill=FG, width=2)
|
|
||||||
elif category == "snow":
|
|
||||||
for dx in (-0.6, 0, 0.6):
|
|
||||||
x, y = cx + dx * r, cloud_cy + r * 0.9
|
|
||||||
draw.ellipse([x - 2, y - 2, x + 2, y + 2], fill=FG)
|
|
||||||
elif category == "thunderstorm":
|
|
||||||
x, y = cx, cloud_cy + r * 0.5
|
|
||||||
draw.line([(x, y), (x - r * 0.3, y + r * 0.5), (x + r * 0.1, y + r * 0.5), (x - r * 0.2, y + r * 1.1)],
|
|
||||||
fill=FG, width=2)
|
|
||||||
|
|
||||||
|
|
||||||
def _draw_weather_row(img: Image.Image, draw: ImageDraw.ImageDraw, x0: int, y0: int, max_w: int,
|
|
||||||
entries: list[dict], icon_r: int, font: ImageFont.ImageFont, units: str,
|
|
||||||
show_labels: bool = True) -> int:
|
|
||||||
"""Draws one or more cities' weather side by side starting at
|
|
||||||
(x0, y0), stopping once another entry wouldn't fit within max_w
|
|
||||||
(narrow views like week columns just end up showing fewer cities --
|
|
||||||
same graceful-degradation approach month view takes with density
|
|
||||||
dots). Returns the row height consumed (0 if there was nothing to
|
|
||||||
draw, so callers can skip reserving space entirely)."""
|
|
||||||
if not entries:
|
|
||||||
return 0
|
|
||||||
unit_suffix = "F" if units == "fahrenheit" else "C"
|
|
||||||
row_h = icon_r * 2 + 8
|
|
||||||
x = x0
|
|
||||||
drew_any = False
|
|
||||||
for entry in entries:
|
|
||||||
temps = f"{round(entry['high'])}°/{round(entry['low'])}°{unit_suffix}"
|
|
||||||
label = f"{entry['label']} {temps}" if show_labels else temps
|
|
||||||
entry_w = round(icon_r * 2 + 6 + draw.textlength(label, font=font) + 18)
|
|
||||||
if drew_any and x + entry_w > x0 + max_w:
|
|
||||||
break
|
|
||||||
cx, cy = x + icon_r, y0 + icon_r
|
|
||||||
_draw_weather_icon(draw, cx, cy, icon_r, entry["category"])
|
|
||||||
draw_text(img, (x + icon_r * 2 + 6, y0 + (row_h - font.size) // 2), label, font)
|
|
||||||
x += entry_w
|
|
||||||
drew_any = True
|
|
||||||
return row_h + 6
|
|
||||||
|
|
||||||
|
|
||||||
def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, events: list[dict], tz: ZoneInfo,
|
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,
|
region: tuple[int, int, int, int], title_font: ImageFont.ImageFont,
|
||||||
body_font: ImageFont.ImageFont, owners_seen: list[str], palette_rgb: list | None = None,
|
body_font: ImageFont.ImageFont, owners_seen: list[str], palette_rgb: list | None = None,
|
||||||
@@ -492,33 +393,36 @@ def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, eve
|
|||||||
these vertically without duplicating the row-layout/truncation
|
these vertically without duplicating the row-layout/truncation
|
||||||
logic. Weather is drawn above the event list -- eating into the same
|
logic. Weather is drawn above the event list -- eating into the same
|
||||||
row budget the event count is truncated against, exactly like the
|
row budget the event count is truncated against, exactly like the
|
||||||
header/rule above it already does."""
|
header bar above it already does."""
|
||||||
x0, y0, w, h = region
|
x0, y0, w, h = region
|
||||||
text_x0, text_y0 = x0 + MARGIN, y0 + MARGIN
|
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
|
text_w = w - MARGIN * 2
|
||||||
header = day.strftime("%A, %B ") + str(day.day)
|
header = day.strftime("%A, %B ") + str(day.day)
|
||||||
draw_text(img, (text_x0, text_y0), _truncate_to_width(draw, header, title_font, text_w), title_font)
|
draw_text(img, (text_x0, y0 + (header_h - title_font.size) // 2),
|
||||||
y = text_y0 + title_font.size + 12
|
_truncate_to_width(draw, header, title_font, text_w), title_font, BG)
|
||||||
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
|
y = y0 + header_h + 12
|
||||||
y += 12
|
|
||||||
|
|
||||||
weather_entries = _weather_for_day(weather_cities, day)
|
weather_entries = _weather_for_day(weather_cities, day)
|
||||||
if weather_entries:
|
if weather_entries:
|
||||||
y += _draw_weather_row(img, draw, text_x0, y, text_w, 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)
|
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)
|
day_events = _events_on_day(events, day, tz)
|
||||||
row_h = body_font.size + 14
|
row_h = body_font.size + 14
|
||||||
max_rows = max(0, (y0 + h - MARGIN - y) // row_h)
|
max_rows = max(0, (y0 + h - MARGIN - y) // row_h)
|
||||||
|
|
||||||
if not day_events:
|
if not day_events:
|
||||||
draw_text(img, (text_x0, y), "Nothing scheduled", body_font, MUTED)
|
draw_text(img, (text_x0, y), "Nothing scheduled", body_font)
|
||||||
for i, event in enumerate(day_events):
|
for i, event in enumerate(day_events):
|
||||||
if i >= max_rows:
|
if i >= max_rows:
|
||||||
draw_text(img, (text_x0, y), f"+{len(day_events) - max_rows} more", body_font, MUTED)
|
draw_text(img, (text_x0, y), f"+{len(day_events) - max_rows} more", body_font)
|
||||||
break
|
break
|
||||||
colors = _event_colors(event, owners_seen, palette_rgb)
|
colors = _event_colors(event, owners_seen, palette_rgb)
|
||||||
_draw_color_bar(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors, radius=3)
|
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))
|
time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz))
|
||||||
prefix = f"{time_str} "
|
prefix = f"{time_str} "
|
||||||
draw_text(img, (text_x0 + 18, y), prefix, body_font)
|
draw_text(img, (text_x0 + 18, y), prefix, body_font)
|
||||||
@@ -535,13 +439,13 @@ def _draw_tasks(img: Image.Image, draw: ImageDraw.ImageDraw, region: tuple[int,
|
|||||||
(`title`, truncated to fit -- TaskWidgetConfig.name or the "Tasks"
|
(`title`, truncated to fit -- TaskWidgetConfig.name or the "Tasks"
|
||||||
default; the only widget type with its own on-panel title, since
|
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
|
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 bar
|
content the way a calendar/photo/whiteboard's is), then a color chip
|
||||||
(reusing _event_colors/_draw_color_bar as-is: a task dict's
|
(reusing _event_colors/panel_style.draw_color_chip as-is: a task
|
||||||
top-level owner_display_name/color_index is exactly _event_colors'
|
dict's top-level owner_display_name/color_index is exactly
|
||||||
single-source fallback shape, since caldav_client.merge_tasks
|
_event_colors' single-source fallback shape, since caldav_client.
|
||||||
doesn't cross-list-dedup tasks into a "sources" list the way
|
merge_tasks doesn't cross-list-dedup tasks into a "sources" list the
|
||||||
merge_events dedups events) + checkbox glyph + due date (if any) +
|
way merge_events dedups events) + checkbox glyph + due date (if any)
|
||||||
summary per task, same header/rule/row-cap/truncation shape as
|
+ summary per task, same header/row-cap/truncation shape as
|
||||||
_draw_agenda_day's event list so the standalone tasks widget (see
|
_draw_agenda_day's event list so the standalone tasks widget (see
|
||||||
_build_tasks) reads as the same consistent design as everything
|
_build_tasks) reads as the same consistent design as everything
|
||||||
else on-panel, not a bolted-together look. Reuses _draw_mixed_line
|
else on-panel, not a bolted-together look. Reuses _draw_mixed_line
|
||||||
@@ -550,45 +454,52 @@ def _draw_tasks(img: Image.Image, draw: ImageDraw.ImageDraw, region: tuple[int,
|
|||||||
|
|
||||||
Outstanding tasks get an empty checkbox; completed ones (only ever
|
Outstanding tasks get an empty checkbox; completed ones (only ever
|
||||||
present when TaskWidgetConfig.show_completed is on -- see
|
present when TaskWidgetConfig.show_completed is on -- see
|
||||||
caldav_client.fetch_tasks' completed_since) get a filled one and
|
caldav_client.fetch_tasks' completed_since) get a filled checkbox in
|
||||||
muted text, no due-date prefix (irrelevant once done)."""
|
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
|
x0, y0, w, h = region
|
||||||
text_x0, text_y0 = x0 + MARGIN, y0 + MARGIN
|
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
|
text_w = w - MARGIN * 2
|
||||||
draw_text(img, (text_x0, text_y0), _truncate_to_width(draw, title or "Tasks", title_font, text_w), title_font)
|
draw_text(img, (text_x0, y0 + (header_h - title_font.size) // 2),
|
||||||
y = text_y0 + title_font.size + 12
|
_truncate_to_width(draw, title or "Tasks", title_font, text_w), title_font, BG)
|
||||||
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
|
y = y0 + header_h + 12
|
||||||
y += 12
|
|
||||||
|
|
||||||
row_h = body_font.size + 14
|
row_h = body_font.size + 14
|
||||||
max_rows = max(0, (y0 + h - MARGIN - y) // row_h)
|
max_rows = max(0, (y0 + h - MARGIN - y) // row_h)
|
||||||
|
|
||||||
if not tasks:
|
if not tasks:
|
||||||
draw_text(img, (text_x0, y), "Nothing outstanding", body_font, MUTED)
|
draw_text(img, (text_x0, y), "Nothing outstanding", body_font)
|
||||||
return
|
return
|
||||||
owners_seen: list[str] = []
|
owners_seen: list[str] = []
|
||||||
|
checkbox_fill = panel_style.theme_color("tasks", palette_rgb)
|
||||||
for i, task in enumerate(tasks):
|
for i, task in enumerate(tasks):
|
||||||
if i >= max_rows:
|
if i >= max_rows:
|
||||||
draw_text(img, (text_x0, y), f"+{len(tasks) - max_rows} more", body_font, MUTED)
|
draw_text(img, (text_x0, y), f"+{len(tasks) - max_rows} more", body_font)
|
||||||
break
|
break
|
||||||
done = task.get("completed_at") is not None
|
done = task.get("completed_at") is not None
|
||||||
colors = _event_colors(task, owners_seen, palette_rgb)
|
colors = _event_colors(task, owners_seen, palette_rgb)
|
||||||
_draw_color_bar(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors, radius=3)
|
panel_style.draw_color_chip(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors)
|
||||||
box = body_font.size - 6
|
box = body_font.size - 6
|
||||||
box_x = text_x0 + 18
|
box_x = text_x0 + 18
|
||||||
box_y = y + (row_h - box) // 2 - 5
|
box_y = y + (row_h - box) // 2 - 5
|
||||||
|
box_r = min(panel_style.CHIP_RADIUS, box // 2)
|
||||||
if done:
|
if done:
|
||||||
draw.rectangle([box_x, box_y, box_x + box, box_y + box], fill=FG)
|
draw.rounded_rectangle([box_x, box_y, box_x + box, box_y + box], radius=box_r, fill=checkbox_fill)
|
||||||
else:
|
else:
|
||||||
draw.rectangle([box_x, box_y, box_x + box, box_y + box], outline=FG, width=2)
|
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
|
text_x = box_x + box + 10
|
||||||
due_str = None if done else _fmt_task_due(task.get("due"))
|
due_str = None if done else _fmt_task_due(task.get("due"))
|
||||||
prefix = f"{due_str} " if due_str else ""
|
prefix = f"{due_str} " if due_str else ""
|
||||||
if prefix:
|
if prefix:
|
||||||
draw_text(img, (text_x, y), prefix, body_font, MUTED)
|
draw_text(img, (text_x, y), prefix, body_font)
|
||||||
prefix_w = draw.textlength(prefix, font=body_font) if prefix else 0
|
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"],
|
_draw_mixed_line(img, draw, (round(text_x + prefix_w), y), task["summary"],
|
||||||
body_font, text_w - (text_x - text_x0) - prefix_w, fill=MUTED if done else FG)
|
body_font, text_w - (text_x - text_x0) - prefix_w)
|
||||||
y += row_h
|
y += row_h
|
||||||
|
|
||||||
|
|
||||||
@@ -601,18 +512,19 @@ _AGENDA_FONTS = {"large": (34, 22, 20), "medium": (24, 22, 16), "small": (18, 16
|
|||||||
|
|
||||||
def _build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
|
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,
|
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
|
||||||
weather_units: str = "fahrenheit") -> Image.Image:
|
weather_units: str = "fahrenheit", font_scale: float = 1.0) -> Image.Image:
|
||||||
img = Image.new("RGB", (target_w, target_h), BG)
|
img, draw, region = panel_style.card_canvas(target_w, target_h)
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
|
|
||||||
title_size, body_size, weather_size = _AGENDA_FONTS[_size_tier(target_w, target_h)]
|
title_size, body_size, weather_size = (
|
||||||
title_font = ImageFont.load_default(size=title_size)
|
panel_style.scaled_size(v, font_scale) for v in _AGENDA_FONTS[_size_tier(target_w, target_h)]
|
||||||
body_font = ImageFont.load_default(size=body_size)
|
)
|
||||||
weather_font = ImageFont.load_default(size=weather_size)
|
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)
|
day = datetime.now(tz).date() + timedelta(days=browse_offset)
|
||||||
owners_seen: list[str] = []
|
owners_seen: list[str] = []
|
||||||
_draw_agenda_day(img, draw, day, events, tz, (0, 0, target_w, target_h), title_font, body_font, owners_seen,
|
_draw_agenda_day(img, draw, day, events, tz, region, title_font, body_font, owners_seen,
|
||||||
palette_rgb, weather_cities, weather_font, weather_units)
|
palette_rgb, weather_cities, weather_font, weather_units)
|
||||||
|
|
||||||
return img
|
return img
|
||||||
@@ -623,30 +535,31 @@ _TODAY_TOMORROW_FONTS = {"large": (26, 18, 16), "medium": (20, 15, 13), "small":
|
|||||||
|
|
||||||
def _build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
|
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,
|
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
|
||||||
weather_units: str = "fahrenheit") -> Image.Image:
|
weather_units: str = "fahrenheit", font_scale: float = 1.0) -> Image.Image:
|
||||||
"""Two _draw_agenda_day sections stacked vertically (below each other
|
"""Two _draw_agenda_day sections stacked vertically (below each other
|
||||||
rather than side-by-side -- narrower than tall doesn't leave enough
|
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
|
width per day for the event-row text at smaller sizes). browse_offset
|
||||||
shifts the whole two-day window together, same "days" unit
|
shifts the whole two-day window together, same "days" unit
|
||||||
_build_agenda already uses, so NEXT/BACK behaves identically across
|
_build_agenda already uses, so NEXT/BACK behaves identically across
|
||||||
both views."""
|
both views."""
|
||||||
img = Image.new("RGB", (target_w, target_h), BG)
|
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
|
|
||||||
title_size, body_size, weather_size = _TODAY_TOMORROW_FONTS[_size_tier(target_w, target_h)]
|
title_size, body_size, weather_size = (
|
||||||
title_font = ImageFont.load_default(size=title_size)
|
panel_style.scaled_size(v, font_scale) for v in _TODAY_TOMORROW_FONTS[_size_tier(target_w, target_h)]
|
||||||
body_font = ImageFont.load_default(size=body_size)
|
)
|
||||||
weather_font = ImageFont.load_default(size=weather_size)
|
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)
|
start_day = datetime.now(tz).date() + timedelta(days=browse_offset)
|
||||||
section_h = target_h // 2
|
section_h = ch // 2
|
||||||
owners_seen: list[str] = []
|
owners_seen: list[str] = []
|
||||||
for i in range(2):
|
for i in range(2):
|
||||||
section_y0 = i * section_h
|
section_y0 = cy0 + i * section_h
|
||||||
if i > 0:
|
if i > 0:
|
||||||
draw.line([(MARGIN, section_y0), (target_w - MARGIN, section_y0)], fill=RULE)
|
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,
|
_draw_agenda_day(img, draw, start_day + timedelta(days=i), events, tz,
|
||||||
(0, section_y0, target_w, section_h), title_font, body_font, owners_seen,
|
(cx0, section_y0, cw, section_h), title_font, body_font, owners_seen,
|
||||||
palette_rgb, weather_cities, weather_font, weather_units)
|
palette_rgb, weather_cities, weather_font, weather_units)
|
||||||
|
|
||||||
return img
|
return img
|
||||||
@@ -663,7 +576,7 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
|
|||||||
week_start: int, palette_rgb: list | None = None,
|
week_start: int, palette_rgb: list | None = None,
|
||||||
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
||||||
days: int = 7, layout: str = "horizontal",
|
days: int = 7, layout: str = "horizontal",
|
||||||
start_offset: int = 0) -> Image.Image:
|
start_offset: int = 0, font_scale: float = 1.0) -> Image.Image:
|
||||||
"""`days` (2-10, see routers/api_widgets.py's clamp) side-by-side
|
"""`days` (2-10, see routers/api_widgets.py's clamp) side-by-side
|
||||||
columns (layout="horizontal", the original fixed-at-7 behavior
|
columns (layout="horizontal", the original fixed-at-7 behavior
|
||||||
generalized) or stacked bands (layout="vertical", reusing
|
generalized) or stacked bands (layout="vertical", reusing
|
||||||
@@ -675,8 +588,7 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
|
|||||||
otherwise "start of the week" doesn't mean much for an arbitrary day
|
otherwise "start of the week" doesn't mean much for an arbitrary day
|
||||||
count, so it instead starts `start_offset` days from today (0 =
|
count, so it instead starts `start_offset` days from today (0 =
|
||||||
today, see routers/api_widgets.py's api_widget_config_save)."""
|
today, see routers/api_widgets.py's api_widget_config_save)."""
|
||||||
img = Image.new("RGB", (target_w, target_h), BG)
|
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
tier = _size_tier(target_w, target_h)
|
tier = _size_tier(target_w, target_h)
|
||||||
|
|
||||||
today = datetime.now(tz).date()
|
today = datetime.now(tz).date()
|
||||||
@@ -689,53 +601,57 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
|
|||||||
|
|
||||||
if layout == "vertical":
|
if layout == "vertical":
|
||||||
title_base, body_base, weather_base = _WEEK_VERTICAL_FONTS[tier]
|
title_base, body_base, weather_base = _WEEK_VERTICAL_FONTS[tier]
|
||||||
title_font = ImageFont.load_default(size=max(14, title_base - days))
|
title_font = panel_style.font_bold(panel_style.scaled_size(max(14, title_base - days), font_scale))
|
||||||
body_font = ImageFont.load_default(size=max(11, body_base - days))
|
body_font = panel_style.font_regular(panel_style.scaled_size(max(11, body_base - days), font_scale))
|
||||||
weather_font = ImageFont.load_default(size=max(9, weather_base - days))
|
weather_font = panel_style.font_regular(panel_style.scaled_size(max(9, weather_base - days), font_scale))
|
||||||
section_h = target_h // days
|
section_h = ch // days
|
||||||
for i in range(days):
|
for i in range(days):
|
||||||
section_y0 = i * section_h
|
section_y0 = cy0 + i * section_h
|
||||||
if i > 0:
|
if i > 0:
|
||||||
draw.line([(MARGIN, section_y0), (target_w - MARGIN, section_y0)], fill=RULE)
|
draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - MARGIN, section_y0)], fill=RULE)
|
||||||
day = week_first_day + timedelta(days=i)
|
day = week_first_day + timedelta(days=i)
|
||||||
_draw_agenda_day(img, draw, day, events, tz, (0, section_y0, target_w, section_h),
|
_draw_agenda_day(img, draw, day, events, tz, (cx0, section_y0, cw, section_h),
|
||||||
title_font, body_font, owners_seen, palette_rgb,
|
title_font, body_font, owners_seen, palette_rgb,
|
||||||
weather_cities, weather_font, weather_units)
|
weather_cities, weather_font, weather_units)
|
||||||
return img
|
return img
|
||||||
|
|
||||||
header_size, chip_size, weather_size = _WEEK_HORIZONTAL_FONTS[tier]
|
header_size, chip_size, weather_size = (
|
||||||
header_font = ImageFont.load_default(size=header_size)
|
panel_style.scaled_size(v, font_scale) for v in _WEEK_HORIZONTAL_FONTS[tier]
|
||||||
chip_font = ImageFont.load_default(size=chip_size)
|
)
|
||||||
weather_font = ImageFont.load_default(size=weather_size)
|
header_font = panel_style.font_bold(header_size)
|
||||||
col_w = (target_w - MARGIN * 2) // days
|
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
|
header_h = 44
|
||||||
|
|
||||||
for col in range(days):
|
for col in range(days):
|
||||||
day = week_first_day + timedelta(days=col)
|
day = week_first_day + timedelta(days=col)
|
||||||
x0 = MARGIN + col * col_w
|
x0 = cx0 + MARGIN + col * col_w
|
||||||
if col > 0:
|
if col > 0:
|
||||||
draw.line([(x0, MARGIN), (x0, target_h - MARGIN)], fill=RULE)
|
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')}"
|
label = day.strftime("%a %-d") if day != today else f"* {day.strftime('%a %-d')}"
|
||||||
draw_text(img, (x0 + 6, MARGIN), _truncate_to_width(draw, label, header_font, col_w - 10), header_font)
|
draw_text(img, (x0 + 6, cy0 + MARGIN), _truncate_to_width(draw, label, header_font, col_w - 10), header_font)
|
||||||
|
|
||||||
y = MARGIN + header_h
|
y = cy0 + MARGIN + header_h
|
||||||
# Columns are narrow, so only what actually fits gets drawn (see
|
# Columns are narrow, so only what actually fits gets drawn (see
|
||||||
# _draw_weather_row) -- typically one city, no label (the column
|
# weather_render.draw_weather_row) -- typically one city, no label
|
||||||
# itself makes which day it's for obvious; a city name wouldn't fit
|
# (the column itself makes which day it's for obvious; a city name
|
||||||
# anyway). Never more than that -- this is already the tight view.
|
# wouldn't fit anyway). Never more than that -- this is already
|
||||||
|
# the tight view.
|
||||||
weather_entries = _weather_for_day(weather_cities, day)
|
weather_entries = _weather_for_day(weather_cities, day)
|
||||||
if weather_entries:
|
if weather_entries:
|
||||||
y += _draw_weather_row(img, draw, x0 + 4, y, col_w - 8, 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)
|
icon_r=8, font=weather_font, units=weather_units, show_labels=False,
|
||||||
|
palette_rgb=palette_rgb)
|
||||||
row_h = chip_font.size + 10
|
row_h = chip_font.size + 10
|
||||||
max_rows = max(0, (target_h - MARGIN - y) // row_h)
|
max_rows = max(0, (cy0 + ch - MARGIN - y) // row_h)
|
||||||
day_events = _events_on_day(events, day, tz)
|
day_events = _events_on_day(events, day, tz)
|
||||||
for i, event in enumerate(day_events):
|
for i, event in enumerate(day_events):
|
||||||
if i >= max_rows:
|
if i >= max_rows:
|
||||||
draw_text(img, (x0 + 6, y), f"+{len(day_events) - max_rows}", chip_font, MUTED)
|
draw_text(img, (x0 + 6, y), f"+{len(day_events) - max_rows}", chip_font)
|
||||||
break
|
break
|
||||||
colors = _event_colors(event, owners_seen, palette_rgb)
|
colors = _event_colors(event, owners_seen, palette_rgb)
|
||||||
_draw_color_bar(draw, x0 + 4, y + 1, x0 + 11, y + row_h - 5, colors, radius=2)
|
panel_style.draw_color_chip(draw, x0 + 4, y + 1, x0 + 11, y + row_h - 5, colors, radius=2)
|
||||||
if event["all_day"]:
|
if event["all_day"]:
|
||||||
_draw_mixed_line(img, draw, (x0 + 16, y), event["summary"], chip_font, col_w - 20)
|
_draw_mixed_line(img, draw, (x0 + 16, y), event["summary"], chip_font, col_w - 20)
|
||||||
else:
|
else:
|
||||||
@@ -757,42 +673,65 @@ _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,
|
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) -> Image.Image:
|
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
|
"""Density dots per day, not literal event text -- real text at
|
||||||
typical month-cell size (~100x70px) is close to unreadable on a
|
typical month-cell size (~100x70px) is close to unreadable on a
|
||||||
6-color dithered e-ink panel. Capped at 4 visible dots, "+N" beyond."""
|
6-color dithered e-ink panel. Capped at 4 visible dots, "+N" beyond.
|
||||||
img = Image.new("RGB", (target_w, target_h), BG)
|
"Not in this month" day numbers used to be a muted gray -- now
|
||||||
draw = ImageDraw.Draw(img)
|
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 = _MONTH_FONTS[_size_tier(target_w, target_h)]
|
header_size, day_size = (
|
||||||
header_font = ImageFont.load_default(size=header_size)
|
panel_style.scaled_size(v, font_scale) for v in _MONTH_FONTS[_size_tier(target_w, target_h)]
|
||||||
day_font = ImageFont.load_default(size=day_size)
|
)
|
||||||
|
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()
|
today = datetime.now(tz).date()
|
||||||
target_month = _add_months(date(today.year, today.month, 1), browse_offset)
|
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))
|
weeks = list(calendar_module.Calendar(firstweekday=week_start).monthdatescalendar(target_month.year, target_month.month))
|
||||||
|
|
||||||
col_w = (target_w - MARGIN * 2) // 7
|
col_w = (cw - MARGIN * 2) // 7
|
||||||
header_h = 28
|
header_h = 28
|
||||||
grid_top = MARGIN + header_h
|
grid_top = cy0 + MARGIN + header_h
|
||||||
row_h = (target_h - MARGIN - grid_top) // len(weeks)
|
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]
|
day_names = WEEKDAY_NAMES[week_start:] + WEEKDAY_NAMES[:week_start]
|
||||||
for col, name in enumerate(day_names):
|
for col, name in enumerate(day_names):
|
||||||
draw_text(img, (MARGIN + col * col_w + 6, MARGIN), name[:3], header_font, MUTED)
|
draw_text(img, (cx0 + MARGIN + col * col_w + 6, cy0 + MARGIN), name[:3], header_font)
|
||||||
|
|
||||||
owners_seen: list[str] = []
|
owners_seen: list[str] = []
|
||||||
dot_r = 6
|
dot_r = 6
|
||||||
for row, week in enumerate(weeks):
|
for row, week in enumerate(weeks):
|
||||||
for col, day in enumerate(week):
|
for col, day in enumerate(week):
|
||||||
x0 = MARGIN + col * col_w
|
x0 = cx0 + MARGIN + col * col_w
|
||||||
y0 = grid_top + row * row_h
|
y0 = grid_top + row * row_h
|
||||||
draw.rectangle([x0, y0, x0 + col_w, y0 + row_h], outline=RULE)
|
draw.rectangle([x0, y0, x0 + col_w, y0 + row_h], outline=RULE)
|
||||||
in_month = day.month == target_month.month
|
in_month = day.month == target_month.month
|
||||||
text_color = FG if in_month else MUTED
|
|
||||||
if day == today:
|
if day == today:
|
||||||
draw.rectangle([x0 + 2, y0 + 2, x0 + 24, y0 + 20], outline=FG)
|
# A filled accent badge (this widget's own theme color,
|
||||||
draw_text(img, (x0 + 6, y0 + 4), str(day.day), day_font, text_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)
|
day_events = _events_on_day(events, day, tz)
|
||||||
dot_x = x0 + 8
|
dot_x = x0 + 8
|
||||||
@@ -806,7 +745,7 @@ def _build_month(events: list[dict], browse_offset: int, target_w: int, target_h
|
|||||||
draw.ellipse([dot_x, dot_y, dot_x + dot_r * 2, dot_y + dot_r * 2], fill=event_color)
|
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
|
dot_x += dot_r * 2 + 5
|
||||||
if len(day_events) > 4:
|
if len(day_events) > 4:
|
||||||
draw_text(img, (dot_x, dot_y - 2), f"+{len(day_events) - 4}", header_font, MUTED)
|
draw_text(img, (dot_x, dot_y - 2), f"+{len(day_events) - 4}", header_font)
|
||||||
|
|
||||||
return img
|
return img
|
||||||
|
|
||||||
@@ -819,7 +758,7 @@ def _build(events: list[dict], view: str, browse_offset: int, target_w: int, tar
|
|||||||
fetch_summary: str, week_start: int, palette_rgb: list | None = None,
|
fetch_summary: str, week_start: int, palette_rgb: list | None = None,
|
||||||
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
||||||
week_days: int = 7, week_layout: str = "horizontal",
|
week_days: int = 7, week_layout: str = "horizontal",
|
||||||
week_start_offset: int = 0) -> Image.Image:
|
week_start_offset: int = 0, font_scale: float = 1.0) -> Image.Image:
|
||||||
tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC")
|
tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC")
|
||||||
effective_view = view
|
effective_view = view
|
||||||
if view == "month" and not _month_view_fits(target_w, target_h):
|
if view == "month" and not _month_view_fits(target_w, target_h):
|
||||||
@@ -827,13 +766,13 @@ def _build(events: list[dict], view: str, browse_offset: int, target_w: int, tar
|
|||||||
|
|
||||||
if effective_view == "agenda":
|
if effective_view == "agenda":
|
||||||
img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb,
|
img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb,
|
||||||
weather_cities, weather_units)
|
weather_cities, weather_units, font_scale)
|
||||||
elif effective_view == "today_tomorrow":
|
elif effective_view == "today_tomorrow":
|
||||||
img = _build_today_tomorrow(events, browse_offset, target_w, target_h, tz, palette_rgb,
|
img = _build_today_tomorrow(events, browse_offset, target_w, target_h, tz, palette_rgb,
|
||||||
weather_cities, weather_units)
|
weather_cities, weather_units, font_scale)
|
||||||
elif effective_view == "week":
|
elif effective_view == "week":
|
||||||
img = _build_week(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb,
|
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)
|
weather_cities, weather_units, week_days, week_layout, week_start_offset, font_scale)
|
||||||
elif effective_view == "month":
|
elif effective_view == "month":
|
||||||
# Never given weather -- no room for it at typical month-cell
|
# Never given weather -- no room for it at typical month-cell
|
||||||
# size, same reasoning that already keeps this view to density
|
# size, same reasoning that already keeps this view to density
|
||||||
@@ -841,14 +780,19 @@ def _build(events: list[dict], view: str, browse_offset: int, target_w: int, tar
|
|||||||
# docstring). Colors are still passed through, though -- that's
|
# docstring). Colors are still passed through, though -- that's
|
||||||
# a different concern (legibility of individual events) than
|
# a different concern (legibility of individual events) than
|
||||||
# needing a whole extra strip of content.
|
# needing a whole extra strip of content.
|
||||||
img = _build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb)
|
img = _build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, font_scale)
|
||||||
else:
|
else:
|
||||||
img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb,
|
img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb,
|
||||||
weather_cities, weather_units)
|
weather_cities, weather_units, font_scale)
|
||||||
|
|
||||||
if fetch_summary:
|
if fetch_summary:
|
||||||
font = ImageFont.load_default(size=14 if _size_tier(target_w, target_h) != "small" else 11)
|
# Drawn as a final overlay onto the already-composited img (not
|
||||||
draw_text(img, (MARGIN, target_h - MARGIN - font.size), fetch_summary, font, MUTED)
|
# 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
|
return img
|
||||||
|
|
||||||
@@ -878,13 +822,13 @@ def render_calendar_preview_png(events: list[dict], view: str, browse_offset: in
|
|||||||
fetch_summary: str = "", manage: dict | None = None, week_start: int = 0,
|
fetch_summary: str = "", manage: dict | None = None, week_start: int = 0,
|
||||||
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
||||||
week_days: int = 7, week_layout: str = "horizontal",
|
week_days: int = 7, week_layout: str = "horizontal",
|
||||||
week_start_offset: int = 0) -> bytes:
|
week_start_offset: int = 0, font_scale: float = 1.0) -> bytes:
|
||||||
"""Same pipeline as render_calendar, but a normal browser-viewable
|
"""Same pipeline as render_calendar, but a normal browser-viewable
|
||||||
PNG in logical (upright) orientation -- mirrors
|
PNG in logical (upright) orientation -- mirrors
|
||||||
image_pipeline.render_preview_png's relationship to render_frame."""
|
image_pipeline.render_preview_png's relationship to render_frame."""
|
||||||
target_w, target_h = logical_render_size(orientation)
|
target_w, target_h = logical_render_size(orientation)
|
||||||
img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start,
|
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)
|
palette_rgb, weather_cities, weather_units, week_days, week_layout, week_start_offset, font_scale)
|
||||||
img = _apply_manage_overlay(img, manage)
|
img = _apply_manage_overlay(img, manage)
|
||||||
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
@@ -899,16 +843,17 @@ _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,
|
def _build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None,
|
||||||
title: str = "Tasks") -> Image.Image:
|
title: str = "Tasks", font_scale: float = 1.0) -> Image.Image:
|
||||||
"""A tasks widget's entire region is the checklist -- unlike the old
|
"""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,
|
week-view slot, there's no day columns/header to share space with,
|
||||||
so this is just _draw_tasks over the whole box."""
|
so this is just _draw_tasks over the whole box."""
|
||||||
img = Image.new("RGB", (target_w, target_h), BG)
|
img, draw, region = panel_style.card_canvas(target_w, target_h)
|
||||||
draw = ImageDraw.Draw(img)
|
title_size, body_size = (
|
||||||
title_size, body_size = _TASKS_FONTS[_size_tier(target_w, target_h)]
|
panel_style.scaled_size(v, font_scale) for v in _TASKS_FONTS[_size_tier(target_w, target_h)]
|
||||||
title_font = ImageFont.load_default(size=title_size)
|
)
|
||||||
body_font = ImageFont.load_default(size=body_size)
|
title_font = panel_style.font_bold(title_size)
|
||||||
_draw_tasks(img, draw, (0, 0, target_w, target_h), tasks, title_font, body_font, palette_rgb, title)
|
body_font = panel_style.font_regular(body_size)
|
||||||
|
_draw_tasks(img, draw, region, tasks, title_font, body_font, palette_rgb, title)
|
||||||
return img
|
return img
|
||||||
|
|
||||||
|
|
||||||
@@ -925,12 +870,12 @@ def render_tasks(tasks: list[dict], orientation: str, palette_rgb: list | None,
|
|||||||
|
|
||||||
|
|
||||||
def render_tasks_preview_png(tasks: list[dict], orientation: str, palette_rgb: list | None,
|
def render_tasks_preview_png(tasks: list[dict], orientation: str, palette_rgb: list | None,
|
||||||
manage: dict | None = None, title: str = "Tasks") -> bytes:
|
manage: dict | None = None, title: str = "Tasks", font_scale: float = 1.0) -> bytes:
|
||||||
"""Same pipeline as render_tasks, but a normal browser-viewable PNG
|
"""Same pipeline as render_tasks, but a normal browser-viewable PNG
|
||||||
in logical (upright) orientation -- mirrors render_calendar_preview_
|
in logical (upright) orientation -- mirrors render_calendar_preview_
|
||||||
png's relationship to render_calendar."""
|
png's relationship to render_calendar."""
|
||||||
target_w, target_h = logical_render_size(orientation)
|
target_w, target_h = logical_render_size(orientation)
|
||||||
img = _build_tasks(tasks, target_w, target_h, palette_rgb, title)
|
img = _build_tasks(tasks, target_w, target_h, palette_rgb, title, font_scale=font_scale)
|
||||||
img = _apply_manage_overlay(img, manage)
|
img = _apply_manage_overlay(img, manage)
|
||||||
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
+12
-1
@@ -24,7 +24,16 @@ GRID_SHORT = 5
|
|||||||
# scaling (see calendar_render.py); whiteboard needs enough room to be
|
# 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
|
# 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
|
# enough width for a due-date prefix plus a couple words of summary
|
||||||
# without truncating on every row.
|
# 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]] = {
|
MIN_FOOTPRINT: dict[str, tuple[int, int]] = {
|
||||||
"photos": (1, 1),
|
"photos": (1, 1),
|
||||||
"calendar": (3, 2),
|
"calendar": (3, 2),
|
||||||
@@ -32,6 +41,8 @@ MIN_FOOTPRINT: dict[str, tuple[int, int]] = {
|
|||||||
"tasks": (2, 2),
|
"tasks": (2, 2),
|
||||||
"static": (1, 1),
|
"static": (1, 1),
|
||||||
"text": (2, 1),
|
"text": (2, 1),
|
||||||
|
"weather": (2, 2),
|
||||||
|
"battery": (1, 1),
|
||||||
}
|
}
|
||||||
|
|
||||||
Rect = tuple[int, int, int, int] # (x, y, w, h)
|
Rect = tuple[int, int, int, int] # (x, y, w, h)
|
||||||
|
|||||||
@@ -0,0 +1,666 @@
|
|||||||
|
"""Experimental "modern" render style, offered as an opt-in alternative
|
||||||
|
to several widget types' hand-drawn PIL primitives: Jinja2 + a
|
||||||
|
persistent headless Chromium browser (Playwright) -- see docs/widgets.md
|
||||||
|
for the design rationale (gradients/shadows/soft shading that PIL can't
|
||||||
|
easily do, at the cost of a real browser-process dependency). Everything
|
||||||
|
in this module is shared infrastructure (the persistent browser, ordered
|
||||||
|
dithering) plus one `build_*` function per widget type that has a
|
||||||
|
modern-style builder -- battery/text/tasks/static image/whiteboard live
|
||||||
|
here directly (mirroring how those widget types are themselves "inlined"
|
||||||
|
in their own widget.py rather than getting a dedicated render module);
|
||||||
|
calendar's (all four view modes, see calendar_html_render.py) is the one
|
||||||
|
exception, kept separate the same reason calendar_render.py itself is
|
||||||
|
its own 800+ line file rather than joining battery/text/tasks inline.
|
||||||
|
Not offered for the photos widget -- a real photograph isn't a
|
||||||
|
synthesized dashboard card, and photos has its own separate palette/
|
||||||
|
dithering concern instead (see widgets/photos.py).
|
||||||
|
|
||||||
|
Two things this module owns that nothing else in the codebase needed
|
||||||
|
before:
|
||||||
|
|
||||||
|
1. A **persistent** background browser process. Widget rendering already
|
||||||
|
happens concurrently across a fresh `ThreadPoolExecutor` per frame
|
||||||
|
request (routers/device.py's _render_widgets) -- Playwright's sync
|
||||||
|
API is thread-affine (an object must be used from the thread that
|
||||||
|
created it), so a single browser object can't be handed across those
|
||||||
|
ad-hoc worker threads, and relaunching a full Chromium process on
|
||||||
|
every widget render would be real, avoidable latency. Fix: one
|
||||||
|
background thread runs its own persistent asyncio event loop hosting
|
||||||
|
one long-lived `Browser`, lazily started on first use (see start()) --
|
||||||
|
not eagerly at server startup, so a deployment that never enables the
|
||||||
|
weather widget's "modern" style never launches Chromium at all and
|
||||||
|
never needs Playwright's browser binaries installed. main.py's
|
||||||
|
lifespan only wires up the *shutdown* half (stop()), so a clean
|
||||||
|
server restart doesn't leave an orphaned Chromium process behind if
|
||||||
|
this was ever actually used. render_html_to_image() is a plain sync
|
||||||
|
function any worker thread can call, bridging in via
|
||||||
|
`asyncio.run_coroutine_threadsafe` (the standard safe cross-thread
|
||||||
|
entry point into a *running* loop on another thread).
|
||||||
|
|
||||||
|
2. **Per-region ordered (Bayer) dithering against the palette**, done
|
||||||
|
here rather than in the shared image_pipeline.py pipeline.
|
||||||
|
render_panel's whole-canvas single Floyd-Steinberg pass exists
|
||||||
|
because Floyd-Steinberg's error diffusion can't be split across
|
||||||
|
independently-quantized regions without a visible seam at the
|
||||||
|
boundary -- but that reasoning doesn't apply to ordered dithering,
|
||||||
|
which has no cross-pixel error term (each pixel's dither decision
|
||||||
|
only depends on its own position + color). So this module dithers its
|
||||||
|
own rendered widget to *already-exact* palette colors before
|
||||||
|
returning it; the later shared Floyd-Steinberg pass sees zero
|
||||||
|
quantization error there and leaves it untouched -- the same
|
||||||
|
"pre-commit to exact palette colors" trick image_pipeline.draw_text
|
||||||
|
and the hand-drawn weather icons already rely on, just reached a
|
||||||
|
different way. Floyd-Steinberg keeps working exactly as before for
|
||||||
|
photos and every other (classic-rendered) widget region.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import threading
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from . import panel_style, theme_tokens
|
||||||
|
from .image_pipeline import DEFAULT_PALETTE_RGB, hex_to_rgb
|
||||||
|
|
||||||
|
_TEMPLATE_DIR = Path(__file__).resolve().parent / "templates" / "widget_html"
|
||||||
|
_FONT_DIR = Path(__file__).resolve().parent / "fonts"
|
||||||
|
|
||||||
|
_jinja_env = Environment(
|
||||||
|
loader=FileSystemLoader(str(_TEMPLATE_DIR)),
|
||||||
|
autoescape=select_autoescape(["html", "jinja"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
CATEGORY_EMOJI = {
|
||||||
|
"clear": "☀️",
|
||||||
|
"partly_cloudy": "⛅",
|
||||||
|
"cloudy": "☁️",
|
||||||
|
"fog": "\U0001f32b️",
|
||||||
|
"rain": "\U0001f327️",
|
||||||
|
"snow": "❄️",
|
||||||
|
"thunderstorm": "⛈️",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Spelled-out condition word for the "bold minimal" current-mode layout --
|
||||||
|
# classic's build_current never needed one (icon + temp only), but the
|
||||||
|
# redesigned modern layout has room for a secondary line under the temp.
|
||||||
|
CATEGORY_LABEL = {
|
||||||
|
"clear": "Clear",
|
||||||
|
"partly_cloudy": "Partly cloudy",
|
||||||
|
"cloudy": "Cloudy",
|
||||||
|
"fog": "Fog",
|
||||||
|
"rain": "Rain",
|
||||||
|
"snow": "Snow",
|
||||||
|
"thunderstorm": "Thunderstorm",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _rgb_to_hex(rgb: tuple[int, int, int]) -> str:
|
||||||
|
return "#%02x%02x%02x" % tuple(rgb)
|
||||||
|
|
||||||
|
|
||||||
|
def _darken_hex(rgb: tuple[int, int, int], factor: float = 0.75) -> str:
|
||||||
|
"""A darker shade of `rgb` for a CSS gradient's second stop -- purely
|
||||||
|
decorative (ordered_dither commits everything to exact palette colors
|
||||||
|
regardless of which literal hex a gradient starts from)."""
|
||||||
|
return _rgb_to_hex(tuple(max(0, round(c * factor)) for c in rgb))
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp(value: float, lo: float, hi: float) -> float:
|
||||||
|
"""Keeps a size/spacing value proportional to widget dimensions
|
||||||
|
(`value` is always some fraction of target_w/target_h) while still
|
||||||
|
guaranteeing a floor (stays legible on a 1-2 grid-cell widget) and a
|
||||||
|
ceiling (stops padding/type from just growing forever on a
|
||||||
|
near-full-panel widget -- see build_current's docstring)."""
|
||||||
|
return max(lo, min(hi, value))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Persistent background browser -------------------------------------
|
||||||
|
|
||||||
|
_loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
_loop_thread: threading.Thread | None = None
|
||||||
|
_browser = None
|
||||||
|
_playwright_cm = None
|
||||||
|
_start_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
async def _launch_browser() -> None:
|
||||||
|
global _browser, _playwright_cm
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
|
_playwright_cm = async_playwright()
|
||||||
|
playwright = await _playwright_cm.__aenter__()
|
||||||
|
_browser = await playwright.chromium.launch()
|
||||||
|
|
||||||
|
|
||||||
|
async def _close_browser() -> None:
|
||||||
|
global _browser, _playwright_cm
|
||||||
|
if _browser is not None:
|
||||||
|
await _browser.close()
|
||||||
|
_browser = None
|
||||||
|
if _playwright_cm is not None:
|
||||||
|
await _playwright_cm.__aexit__(None, None, None)
|
||||||
|
_playwright_cm = None
|
||||||
|
|
||||||
|
|
||||||
|
def start() -> None:
|
||||||
|
"""Launches the background event loop + persistent Chromium browser,
|
||||||
|
if not already running. Called lazily by render_html_to_image on
|
||||||
|
first use (not from main.py's lifespan -- see module docstring for
|
||||||
|
why this must stay opt-in) -- exposed directly too, for tests that
|
||||||
|
want to control startup explicitly. Idempotent -- a second call
|
||||||
|
while already started is a no-op."""
|
||||||
|
global _loop, _loop_thread
|
||||||
|
if _loop is not None:
|
||||||
|
return
|
||||||
|
ready = threading.Event()
|
||||||
|
|
||||||
|
def _run() -> None:
|
||||||
|
global _loop
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
_loop = loop
|
||||||
|
ready.set()
|
||||||
|
loop.run_forever()
|
||||||
|
|
||||||
|
_loop_thread = threading.Thread(target=_run, daemon=True, name="html-render-loop")
|
||||||
|
_loop_thread.start()
|
||||||
|
ready.wait()
|
||||||
|
asyncio.run_coroutine_threadsafe(_launch_browser(), _loop).result()
|
||||||
|
|
||||||
|
|
||||||
|
def stop() -> None:
|
||||||
|
"""Closes the browser and stops the background loop -- called from
|
||||||
|
main.py's lifespan shutdown so a server restart never leaves an
|
||||||
|
orphaned Chromium process behind. No-op if start() was never called
|
||||||
|
(the common case: most deployments never enable "modern" style)."""
|
||||||
|
global _loop, _loop_thread
|
||||||
|
if _loop is None:
|
||||||
|
return
|
||||||
|
asyncio.run_coroutine_threadsafe(_close_browser(), _loop).result()
|
||||||
|
_loop.call_soon_threadsafe(_loop.stop)
|
||||||
|
_loop_thread.join(timeout=5)
|
||||||
|
_loop = None
|
||||||
|
_loop_thread = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _screenshot(html: str, target_w: int, target_h: int) -> bytes:
|
||||||
|
page = await _browser.new_page(viewport={"width": target_w, "height": target_h}, device_scale_factor=1)
|
||||||
|
try:
|
||||||
|
await page.set_content(html, wait_until="networkidle")
|
||||||
|
return await page.screenshot()
|
||||||
|
finally:
|
||||||
|
await page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def render_html_to_image(html: str, target_w: int, target_h: int) -> Image.Image:
|
||||||
|
"""Renders `html` (already sized to target_w x target_h via its own
|
||||||
|
<style>) through the persistent headless Chromium browser and
|
||||||
|
returns an RGB image of exactly that size. Safe to call from any
|
||||||
|
thread -- bridges into the dedicated background asyncio loop via
|
||||||
|
run_coroutine_threadsafe. Lazily calls start() on first use (see its
|
||||||
|
docstring) -- the first "modern" style render on a freshly-started
|
||||||
|
server pays Chromium's launch latency; every render after that reuses
|
||||||
|
the same persistent browser."""
|
||||||
|
if _loop is None:
|
||||||
|
with _start_lock:
|
||||||
|
if _loop is None:
|
||||||
|
start()
|
||||||
|
future = asyncio.run_coroutine_threadsafe(_screenshot(html, target_w, target_h), _loop)
|
||||||
|
png_bytes = future.result()
|
||||||
|
return Image.open(io.BytesIO(png_bytes)).convert("RGB")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Ordered (Bayer 8x8) dithering against an arbitrary palette ---------
|
||||||
|
|
||||||
|
_BAYER8 = (
|
||||||
|
np.array(
|
||||||
|
[
|
||||||
|
[0, 32, 8, 40, 2, 34, 10, 42],
|
||||||
|
[48, 16, 56, 24, 50, 18, 58, 26],
|
||||||
|
[12, 44, 4, 36, 14, 46, 6, 38],
|
||||||
|
[60, 28, 52, 20, 62, 30, 54, 22],
|
||||||
|
[3, 35, 11, 43, 1, 33, 9, 41],
|
||||||
|
[51, 19, 59, 27, 49, 17, 57, 25],
|
||||||
|
[15, 47, 7, 39, 13, 45, 5, 37],
|
||||||
|
[63, 31, 55, 23, 61, 29, 53, 21],
|
||||||
|
],
|
||||||
|
dtype=np.float32,
|
||||||
|
)
|
||||||
|
/ 64.0
|
||||||
|
- 0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ordered_dither(img: Image.Image, palette_rgb: list | None, amplitude: float = 48.0) -> Image.Image:
|
||||||
|
"""Bayer-ordered dither of `img` against `palette_rgb` (falls back to
|
||||||
|
DEFAULT_PALETTE_RGB) -- every output pixel is one of the palette's
|
||||||
|
exact colors, spatially patterned rather than error-diffused, so it's
|
||||||
|
safe to run per-region before compositing (see module docstring for
|
||||||
|
why that's not true of Floyd-Steinberg). `amplitude` is the Bayer
|
||||||
|
bias's full swing in 0-255 RGB units before nearest-palette-color
|
||||||
|
matching -- 48 was the value this render style was tuned against in
|
||||||
|
the exploratory spike behind this feature; not exposed as a per-frame
|
||||||
|
setting (unlike dither_strength) since there's only one consumer of
|
||||||
|
it today."""
|
||||||
|
palette = np.array(palette_rgb or DEFAULT_PALETTE_RGB, dtype=np.float32)
|
||||||
|
arr = np.asarray(img.convert("RGB"), dtype=np.float32)
|
||||||
|
h, w, _ = arr.shape
|
||||||
|
tile = np.tile(_BAYER8, (h // 8 + 1, w // 8 + 1))[:h, :w]
|
||||||
|
biased = np.clip(arr + tile[:, :, None] * amplitude, 0, 255)
|
||||||
|
diffs = biased[:, :, None, :] - palette[None, None, :, :]
|
||||||
|
dists = np.einsum("hwkc,hwkc->hwk", diffs, diffs)
|
||||||
|
idx = np.argmin(dists, axis=2)
|
||||||
|
return Image.fromarray(palette[idx].astype(np.uint8), "RGB")
|
||||||
|
|
||||||
|
|
||||||
|
def ordered_dither_regions(rendered: Image.Image, palette_rgb: list | None, base_amplitude: float = 48.0,
|
||||||
|
accent_regions: list[tuple[tuple[int, int, int, int], float]] = ()) -> Image.Image:
|
||||||
|
"""Like `ordered_dither`, but lets specific rectangles (e.g. a themed
|
||||||
|
header bar) dither at a higher amplitude than the rest of the widget.
|
||||||
|
A single higher amplitude applied to a whole widget washes out pale
|
||||||
|
content (a weather icon's white cloud body nearly disappeared in
|
||||||
|
testing); dithering the base image at the safe default and only
|
||||||
|
re-dithering an accent rect on top -- pasted back over the base --
|
||||||
|
lets a header carry a rich, arbitrary accent hue (via denser
|
||||||
|
stippling) without touching icon/text legibility elsewhere. Safe to
|
||||||
|
do per-region for the same reason `ordered_dither` is safe per-widget
|
||||||
|
(see its docstring): no cross-pixel error-diffusion term, so each
|
||||||
|
region's result depends only on its own pixels."""
|
||||||
|
base = ordered_dither(rendered, palette_rgb, amplitude=base_amplitude)
|
||||||
|
for (x0, y0, x1, y1), amplitude in accent_regions:
|
||||||
|
crop = rendered.crop((x0, y0, x1, y1))
|
||||||
|
base.paste(ordered_dither(crop, palette_rgb, amplitude=amplitude), (x0, y0))
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
# --- Weather "modern" style ----------------------------------------------
|
||||||
|
|
||||||
|
def _day_label(day_date: date) -> str:
|
||||||
|
delta = (day_date - date.today()).days
|
||||||
|
if delta == 0:
|
||||||
|
return "Today"
|
||||||
|
if delta == 1:
|
||||||
|
return "Tomorrow"
|
||||||
|
return day_date.strftime("%a")
|
||||||
|
|
||||||
|
|
||||||
|
def _short_city(city_label: str) -> str:
|
||||||
|
"""geocode_city (see docs/widgets.md's Weather widget section) hands
|
||||||
|
back a full "City, Region, Country" string -- fine for classic's
|
||||||
|
build_current (just drawn as one line, however wide) but wrong for
|
||||||
|
the bold-minimal layout's small top-row label, where a 1-2 grid-
|
||||||
|
cell widget has no room for the whole thing. Every phone-homescreen
|
||||||
|
weather widget this style is drawing from shows just the city, so
|
||||||
|
that's what this keeps -- CSS `text-overflow: ellipsis` is still in
|
||||||
|
the template as a safety net for a custom single-segment label
|
||||||
|
that's itself too long, not as the primary truncation strategy."""
|
||||||
|
return city_label.split(",")[0].strip()
|
||||||
|
|
||||||
|
|
||||||
|
def build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb: list | None = None,
|
||||||
|
units: str = "fahrenheit", city_label: str = "", theme_name: str | None = None) -> Image.Image:
|
||||||
|
"""HTML/CSS-rendered analogue of weather_render.build_current --
|
||||||
|
same call signature, so app/widgets/weather.py can dispatch to
|
||||||
|
either interchangeably. Returns an already-palette-exact RGB image
|
||||||
|
(see ordered_dither).
|
||||||
|
|
||||||
|
"Bold minimal" layout: the temperature itself is the graphic --
|
||||||
|
city label + icon in a top row, the temp (dominant) and spelled-out
|
||||||
|
condition anchored to the bottom, no card/border/shadow at all. This
|
||||||
|
is a deliberate departure from every other modern-style widget's
|
||||||
|
card-on-white-canvas chrome (see docs/widgets.md) -- there's nothing
|
||||||
|
for a "card" to visually separate from here, so `theme["radius"]`/
|
||||||
|
`theme["shadow"]` have no effect on this template; still theme-aware
|
||||||
|
for font_family only, same as before. No header/accent region either
|
||||||
|
(see ordered_dither_regions' docstring) -- a themed accent has
|
||||||
|
nothing to attach to in a chrome-free layout.
|
||||||
|
|
||||||
|
Every size below is a fraction of `base` (the widget's shorter side),
|
||||||
|
clamped to a floor/ceiling rather than fixed -- so a 1-grid-cell
|
||||||
|
widget doesn't get comically oversized padding relative to its
|
||||||
|
content, and a near-full-panel widget doesn't get comically large
|
||||||
|
padding relative to *its* content either. Floors/ceilings are tuned
|
||||||
|
by eye against real widget sizes, not derived from anything."""
|
||||||
|
img = Image.new("RGB", (target_w, target_h), (255, 255, 255))
|
||||||
|
if not entry:
|
||||||
|
return img
|
||||||
|
|
||||||
|
theme = theme_tokens.resolve_theme(theme_name, "weather", palette_rgb)
|
||||||
|
unit_suffix = "F" if units == "fahrenheit" else "C"
|
||||||
|
base = min(target_w, target_h)
|
||||||
|
pad = _clamp(base * 0.09, 10, 26)
|
||||||
|
icon_size = _clamp(base * 0.20, 22, 60)
|
||||||
|
temp_size = _clamp(base * 0.46, 30, 150)
|
||||||
|
deg_size = _clamp(temp_size * 0.28, 12, 40)
|
||||||
|
cond_size = _clamp(base * 0.075, 11, 20)
|
||||||
|
city_size = _clamp(base * 0.06, 10, 15)
|
||||||
|
template = _jinja_env.get_template("weather_current.html.jinja")
|
||||||
|
html = template.render(
|
||||||
|
w=target_w, h=target_h, pad=round(pad),
|
||||||
|
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
|
||||||
|
emoji=CATEGORY_EMOJI.get(entry["category"], ""),
|
||||||
|
condition=CATEGORY_LABEL.get(entry["category"], ""),
|
||||||
|
temp=round(entry["temp"]), unit_suffix=unit_suffix, city_label=_short_city(city_label),
|
||||||
|
icon_size=round(icon_size), temp_size=round(temp_size), deg_size=round(deg_size),
|
||||||
|
cond_size=round(cond_size), city_size=round(city_size),
|
||||||
|
)
|
||||||
|
rendered = render_html_to_image(html, target_w, target_h)
|
||||||
|
return ordered_dither(rendered, palette_rgb)
|
||||||
|
|
||||||
|
|
||||||
|
def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rgb: list | None = None,
|
||||||
|
units: str = "fahrenheit", city_label: str = "", theme_name: str | None = None) -> Image.Image:
|
||||||
|
"""HTML/CSS-rendered analogue of weather_render.build_daily -- same
|
||||||
|
call signature. Returns an already-palette-exact RGB image (see
|
||||||
|
ordered_dither).
|
||||||
|
|
||||||
|
"Bold minimal" layout, matching build_current: no card/border/
|
||||||
|
shadow, a row of day columns each carrying its own high (dominant)
|
||||||
|
/ low (muted) temp the same way build_current makes the current
|
||||||
|
temp dominant. The old full-width gradient banner is gone --
|
||||||
|
city_label, when set, is a slim accent-colored rule (not a block)
|
||||||
|
with the city name understated beneath it, so there's still
|
||||||
|
somewhere for a theme's accent hue to show up (dithered richer via
|
||||||
|
ordered_dither_regions, same mechanism as before) without dragging
|
||||||
|
back the "card with a colored header" chrome this redesign is
|
||||||
|
moving away from. `theme["radius"]`/`theme["shadow"]` are unused
|
||||||
|
here for the same reason as build_current -- no card for them to
|
||||||
|
apply to."""
|
||||||
|
img = Image.new("RGB", (target_w, target_h), (255, 255, 255))
|
||||||
|
days = list(daily.items())
|
||||||
|
if not days:
|
||||||
|
return img
|
||||||
|
|
||||||
|
theme = theme_tokens.resolve_theme(theme_name, "weather", palette_rgb)
|
||||||
|
unit_suffix = "F" if units == "fahrenheit" else "C"
|
||||||
|
base = min(target_w, target_h)
|
||||||
|
pad = round(_clamp(base * 0.08, 10, 22))
|
||||||
|
col_w = max(1, (target_w - pad * 2) // len(days))
|
||||||
|
col_gap = round(_clamp(col_w * 0.12, 4, 16))
|
||||||
|
icon_size = round(_clamp(col_w * 0.30, 16, 32))
|
||||||
|
day_label_size = round(_clamp(col_w * 0.15, 10, 14))
|
||||||
|
high_size = round(_clamp(col_w * 0.32, 16, 32))
|
||||||
|
low_size = round(max(9, high_size * 0.55))
|
||||||
|
city_size = round(_clamp(base * 0.055, 10, 14))
|
||||||
|
accent_h = round(_clamp(base * 0.025, 4, 8))
|
||||||
|
day_entries = [
|
||||||
|
{
|
||||||
|
"label": _day_label(date.fromisoformat(day_str)),
|
||||||
|
"emoji": CATEGORY_EMOJI.get(d["category"], ""),
|
||||||
|
"high": round(d["high"]),
|
||||||
|
"low": round(d["low"]),
|
||||||
|
}
|
||||||
|
for day_str, d in days
|
||||||
|
]
|
||||||
|
template = _jinja_env.get_template("weather_daily.html.jinja")
|
||||||
|
html = template.render(
|
||||||
|
w=target_w, h=target_h, pad=pad, col_gap=col_gap,
|
||||||
|
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
|
||||||
|
city_label=_short_city(city_label), city_size=city_size, accent_h=accent_h,
|
||||||
|
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"],
|
||||||
|
days=day_entries, icon_size=icon_size, day_label_size=day_label_size,
|
||||||
|
high_size=high_size, low_size=low_size, unit_suffix=unit_suffix,
|
||||||
|
)
|
||||||
|
rendered = render_html_to_image(html, target_w, target_h)
|
||||||
|
if not city_label:
|
||||||
|
return ordered_dither(rendered, palette_rgb)
|
||||||
|
accent_rect = (pad, pad, target_w - pad, pad + accent_h)
|
||||||
|
return ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
|
||||||
|
|
||||||
|
|
||||||
|
SUPPORTED_MODES = ("current", "daily")
|
||||||
|
|
||||||
|
|
||||||
|
def build(mode: str, data, target_w: int, target_h: int, palette_rgb: list | None = None,
|
||||||
|
units: str = "fahrenheit", city_label: str = "", theme_name: str | None = None) -> Image.Image:
|
||||||
|
"""Dispatches to build_current/build_daily -- mirrors weather_render.
|
||||||
|
build()'s signature (minus interval_hours, which no modern-style mode
|
||||||
|
uses) so app/widgets/weather.py and the weather preview endpoint can
|
||||||
|
call either module identically. Only call this for mode in
|
||||||
|
SUPPORTED_MODES -- callers are expected to have already fallen back to
|
||||||
|
weather_render.build() for hourly/multi_city (see weather.py)."""
|
||||||
|
if mode == "current":
|
||||||
|
return build_current(data, target_w, target_h, palette_rgb, units, city_label, theme_name)
|
||||||
|
return build_daily(data, target_w, target_h, palette_rgb, units, city_label, theme_name)
|
||||||
|
|
||||||
|
|
||||||
|
def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: list | None,
|
||||||
|
units: str = "fahrenheit", city_label: str = "",
|
||||||
|
theme_name: str | None = None) -> bytes:
|
||||||
|
"""Modern-style analogue of weather_render.render_weather_preview_png
|
||||||
|
-- same browser-viewable-PNG convention every other widget's preview
|
||||||
|
endpoint uses. build()'s output is already palette-exact (see
|
||||||
|
ordered_dither), so the final _quantize pass here is a no-op on it,
|
||||||
|
same reasoning as the module docstring's compositing story."""
|
||||||
|
from .image_pipeline import _quantize, _png_bytes, logical_render_size
|
||||||
|
|
||||||
|
target_w, target_h = logical_render_size(orientation)
|
||||||
|
img = build(mode, data, target_w, target_h, palette_rgb, units, city_label, theme_name)
|
||||||
|
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||||
|
return _png_bytes(quantized)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Battery "modern" style ------------------------------------------------
|
||||||
|
|
||||||
|
def _battery_sizes(target_w: int, target_h: int, num_lines: int, scale: float) -> dict:
|
||||||
|
base = min(target_w, target_h)
|
||||||
|
icon_h = max(14, int(base * 0.15 * scale))
|
||||||
|
pct_size = max(20, int(base * 0.42 * scale))
|
||||||
|
line_size = max(9, int(base * 0.085 * scale))
|
||||||
|
gap = max(4, int(base * 0.035 * scale))
|
||||||
|
total = icon_h + gap + pct_size + num_lines * (line_size + gap)
|
||||||
|
return {"icon_h": icon_h, "pct_size": pct_size, "line_size": line_size, "gap": gap, "total": total}
|
||||||
|
|
||||||
|
|
||||||
|
def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
|
||||||
|
palette_rgb: list | None = None, theme_name: str | None = None) -> Image.Image:
|
||||||
|
"""HTML/CSS-rendered analogue of widgets/battery.py's classic PIL
|
||||||
|
drawing -- same icon+percent+caption-lines shape, `lines` already
|
||||||
|
resolved by the caller (widgets/battery.py's _lines_for(), shared
|
||||||
|
with the classic path so the estimate/age formatting only lives in
|
||||||
|
one place). Returns an already-palette-exact RGB image (see
|
||||||
|
ordered_dither). Theme-aware for font only -- the charge-level
|
||||||
|
fill_color below is a functional status signal (not a style choice)
|
||||||
|
and is never touched by a theme, and there's no header/accent region
|
||||||
|
to dither richer via ordered_dither_regions.
|
||||||
|
|
||||||
|
Bold-minimal: no card (theme["radius"] unused, same carve-out as
|
||||||
|
weather's build_current -- see docs/widgets.md); the percent is the
|
||||||
|
hero value anchored toward the bottom, same treatment build_current
|
||||||
|
gives the temperature, with the icon small and secondary above it
|
||||||
|
instead of both competing at the same size like the old centered
|
||||||
|
layout did.
|
||||||
|
|
||||||
|
Shrinks icon/text sizes together (in 0.05 steps down to 0.3x) until
|
||||||
|
the whole stack actually fits the available height -- the classic
|
||||||
|
PIL path solves the same "icon + percent + 0-2 lines in a fixed box"
|
||||||
|
problem by truncating lines that don't fit; scaling down instead
|
||||||
|
keeps every resolved line visible, which reads better for a widget
|
||||||
|
that only ever has at most 2 short caption lines to begin with."""
|
||||||
|
pad = round(_clamp(min(target_w, target_h) * 0.09, 10, 26))
|
||||||
|
avail_h = target_h - pad * 2
|
||||||
|
num_lines = len(lines)
|
||||||
|
scale = 1.0
|
||||||
|
sizes = _battery_sizes(target_w, target_h, num_lines, scale)
|
||||||
|
while sizes["total"] > avail_h and scale > 0.3:
|
||||||
|
scale -= 0.05
|
||||||
|
sizes = _battery_sizes(target_w, target_h, num_lines, scale)
|
||||||
|
# Extreme case (a 1x1-grid-cell-sized widget in "detailed" mode):
|
||||||
|
# scale bottomed out and it still doesn't fit -- drop the least
|
||||||
|
# important line rather than render overlapping text, same
|
||||||
|
# graceful-degradation idiom the classic PIL path's own
|
||||||
|
# `if y + small_font_size > ...: break` truncation already uses.
|
||||||
|
while sizes["total"] > avail_h and lines:
|
||||||
|
lines = lines[:-1]
|
||||||
|
num_lines = len(lines)
|
||||||
|
sizes = _battery_sizes(target_w, target_h, num_lines, scale)
|
||||||
|
|
||||||
|
theme = theme_tokens.resolve_theme(theme_name, "battery", palette_rgb)
|
||||||
|
fill_color = panel_style.battery_fill_color(percent, palette_rgb)
|
||||||
|
icon_h = sizes["icon_h"]
|
||||||
|
icon_w = int(icon_h * 1.8)
|
||||||
|
stroke = max(2, icon_h // 12)
|
||||||
|
nub_w = max(3, icon_w // 10)
|
||||||
|
template = _jinja_env.get_template("battery.html.jinja")
|
||||||
|
html = template.render(
|
||||||
|
w=target_w, h=target_h, pad=pad,
|
||||||
|
font_regular=theme["font_regular"], font_bold=theme["font_bold"], percent=percent, lines=lines,
|
||||||
|
icon_w=icon_w, icon_h=icon_h, icon_radius=icon_h // 6, stroke=stroke,
|
||||||
|
fill_pct=max(0, min(100, percent)), fill_radius=max(0, icon_h // 6 - stroke),
|
||||||
|
fill_color=_rgb_to_hex(fill_color), fill_color_dark=_darken_hex(fill_color),
|
||||||
|
nub_w=nub_w, nub_h=icon_h // 2, nub_radius=max(1, nub_w // 3),
|
||||||
|
pct_size=sizes["pct_size"], line_size=sizes["line_size"], line_gap=sizes["gap"],
|
||||||
|
)
|
||||||
|
rendered = render_html_to_image(html, target_w, target_h)
|
||||||
|
return ordered_dither(rendered, palette_rgb)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Text "modern" style ---------------------------------------------------
|
||||||
|
|
||||||
|
def build_text(cfg, target_w: int, target_h: int, palette_rgb: list | None = None,
|
||||||
|
theme_name: str | None = None) -> Image.Image:
|
||||||
|
"""HTML/CSS-rendered analogue of widgets/text.py's classic PIL
|
||||||
|
drawing. Reuses widgets/text.py's own `_fit()` for the one piece of
|
||||||
|
logic CSS has no native equivalent for (shrink-to-fit sizing) --
|
||||||
|
`_fit` measures against the exact same vendored font files via PIL,
|
||||||
|
so the resolved size is a real fit decision, not a guess -- but lets
|
||||||
|
the browser do its own text wrapping/line-breaking at that size
|
||||||
|
(paragraphs/runs passed through directly as HTML) rather than
|
||||||
|
replicating `_fit`'s own word-wrapped line list; the two wrapping
|
||||||
|
algorithms can disagree on exact break points, an acceptable
|
||||||
|
approximation since this style only needs to look good and fit
|
||||||
|
reasonably, not be pixel-identical to classic. Returns an already-
|
||||||
|
palette-exact RGB image (see ordered_dither).
|
||||||
|
|
||||||
|
theme_name is accepted (every modern-style build_* function takes
|
||||||
|
one, threaded uniformly from frame.theme) but deliberately unused --
|
||||||
|
the text widget's own font_family is a per-widget, user-authored
|
||||||
|
choice (see widgets/text.py's module docstring), same carve-out
|
||||||
|
reasoning as run-level colors; a frame theme overriding it would
|
||||||
|
silently undo an explicit user choice. text.html.jinja also has no
|
||||||
|
card chrome (no radius/shadow) for a theme to touch."""
|
||||||
|
from PIL import ImageDraw
|
||||||
|
|
||||||
|
from . import widgets # local import: heavy-ish, and only "modern" text needs it
|
||||||
|
|
||||||
|
text_widget = widgets.text
|
||||||
|
bg_rgb = (hex_to_rgb(cfg.background_color) if cfg.background_color else None) or (255, 255, 255)
|
||||||
|
family = cfg.font_family if cfg.font_family in text_widget.FONT_FAMILIES else text_widget.DEFAULT_FONT_FAMILY
|
||||||
|
paragraphs = cfg.content or []
|
||||||
|
margin = text_widget.MARGIN
|
||||||
|
max_width = max(10, target_w - 2 * margin)
|
||||||
|
max_height = max(10, target_h - 2 * margin)
|
||||||
|
|
||||||
|
measure_img = Image.new("RGB", (1, 1))
|
||||||
|
draw = ImageDraw.Draw(measure_img)
|
||||||
|
size, _lines = text_widget._fit(draw, paragraphs, family, cfg.font_size, max_width, max_height)
|
||||||
|
|
||||||
|
files = text_widget._FONT_FILES.get(family) or text_widget._FONT_FILES[text_widget.DEFAULT_FONT_FAMILY]
|
||||||
|
align = cfg.align if cfg.align in ("left", "center", "right") else "left"
|
||||||
|
template = _jinja_env.get_template("text.html.jinja")
|
||||||
|
html = template.render(
|
||||||
|
w=target_w, h=target_h, margin=margin, bg_color=_rgb_to_hex(bg_rgb),
|
||||||
|
size=size, line_height=text_widget.LINE_HEIGHT_FACTOR, align=align,
|
||||||
|
font_regular=str(_FONT_DIR / files[(False, False)]), font_bold=str(_FONT_DIR / files[(True, False)]),
|
||||||
|
font_italic=str(_FONT_DIR / files[(False, True)]), font_bold_italic=str(_FONT_DIR / files[(True, True)]),
|
||||||
|
paragraphs=paragraphs,
|
||||||
|
)
|
||||||
|
rendered = render_html_to_image(html, target_w, target_h)
|
||||||
|
return ordered_dither(rendered, palette_rgb)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Tasks "modern" style ---------------------------------------------------
|
||||||
|
|
||||||
|
def build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None,
|
||||||
|
title: str = "Tasks", theme_name: str | None = None, font_scale: float = 1.0) -> Image.Image:
|
||||||
|
"""HTML/CSS-rendered analogue of calendar_render._build_tasks --
|
||||||
|
same header+checklist shape. Reuses calendar_render's own
|
||||||
|
_event_colors/_fmt_task_due (the exact color-dedup/due-date-format
|
||||||
|
logic the classic renderer uses) so a task's color chip/due string
|
||||||
|
matches classic style exactly; only the drawing differs -- and a
|
||||||
|
theme's accent never touches those per-owner chip colors (identity-
|
||||||
|
coding, not style) or the done-checkbox fill (a completion state
|
||||||
|
signal, not a style choice -- it happens to reuse the accent color,
|
||||||
|
but that's incidental, same as before this redesign).
|
||||||
|
|
||||||
|
Bold-minimal: no card (theme["shadow"]/["radius"] unused, same
|
||||||
|
carve-out as calendar's redesigned views -- see docs/widgets.md).
|
||||||
|
The old gradient header banner is now a slim accent rule + plain
|
||||||
|
bold title, matching every calendar view's day-header language --
|
||||||
|
only the rule dithers at the theme's richer accent_amplitude, not
|
||||||
|
the title text sitting on it. Returns an already-palette-exact RGB
|
||||||
|
image (see ordered_dither)."""
|
||||||
|
from .calendar_render import _event_colors, _fmt_task_due
|
||||||
|
|
||||||
|
theme = theme_tokens.resolve_theme(theme_name, "tasks", palette_rgb)
|
||||||
|
base = min(target_w, target_h)
|
||||||
|
accent_h = round(_clamp(base * 0.025, 3, 6))
|
||||||
|
title_size = panel_style.scaled_size(max(14, base // 12), font_scale)
|
||||||
|
body_size = panel_style.scaled_size(max(11, base // 20), font_scale)
|
||||||
|
row_h = body_size + 14
|
||||||
|
box_size = max(10, body_size - 4)
|
||||||
|
header_h = accent_h + 6 + title_size
|
||||||
|
avail_h = target_h - panel_style.GUTTER * 2 - header_h - 8
|
||||||
|
max_rows = max(0, avail_h // row_h)
|
||||||
|
|
||||||
|
owners_seen: list[str] = []
|
||||||
|
rows = []
|
||||||
|
for task in tasks[:max_rows]:
|
||||||
|
colors = _event_colors(task, owners_seen, palette_rgb)
|
||||||
|
done = task.get("completed_at") is not None
|
||||||
|
due = None if done else (_fmt_task_due(task.get("due")) or None)
|
||||||
|
rows.append({
|
||||||
|
"colors": [_rgb_to_hex(c) for c in colors],
|
||||||
|
"done": done,
|
||||||
|
"due": due,
|
||||||
|
"summary": task["summary"],
|
||||||
|
})
|
||||||
|
more_count = max(0, len(tasks) - max_rows)
|
||||||
|
|
||||||
|
template = _jinja_env.get_template("tasks.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"],
|
||||||
|
title=title, header_h=header_h, accent_h=accent_h, title_size=title_size,
|
||||||
|
accent_start=theme["accent_hex"],
|
||||||
|
rows=rows, more_count=more_count, row_h=row_h, box_size=box_size, body_size=body_size,
|
||||||
|
)
|
||||||
|
rendered = render_html_to_image(html, target_w, target_h)
|
||||||
|
gutter = panel_style.GUTTER
|
||||||
|
accent_rect = (gutter, gutter, target_w - gutter, gutter + accent_h)
|
||||||
|
return ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
|
||||||
|
|
||||||
|
|
||||||
|
# --- Static image / whiteboard "modern" style (shared) ---------------------
|
||||||
|
|
||||||
|
def build_framed_image(composed: Image.Image, target_w: int, target_h: int,
|
||||||
|
palette_rgb: list | None = None, theme_name: str | None = None,
|
||||||
|
widget_kind: str = "static") -> Image.Image:
|
||||||
|
"""Wraps an already-composed image (static_image.py/whiteboard.py's
|
||||||
|
own compose_into() output, exactly target_w x target_h, already
|
||||||
|
cropped/fit per that widget's own display_mode) in a rounded-corner,
|
||||||
|
shadowed card -- the first visual chrome either widget type has ever
|
||||||
|
had (both currently draw with zero chrome of their own). Theme-aware
|
||||||
|
for radius/shadow only -- no text/header content to accent or font.
|
||||||
|
Returns an already-palette-exact RGB image (see ordered_dither)."""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
theme = theme_tokens.resolve_theme(theme_name, widget_kind, palette_rgb)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
composed.convert("RGB").save(buf, format="PNG")
|
||||||
|
image_b64 = base64.b64encode(buf.getvalue()).decode("ascii")
|
||||||
|
|
||||||
|
template = _jinja_env.get_template("framed_image.html.jinja")
|
||||||
|
html = template.render(
|
||||||
|
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
|
||||||
|
image_b64=image_b64,
|
||||||
|
)
|
||||||
|
rendered = render_html_to_image(html, target_w, target_h)
|
||||||
|
return ordered_dither(rendered, palette_rgb)
|
||||||
+181
-15
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
import math
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageEnhance, ImageFont, ImageOps
|
from PIL import Image, ImageDraw, ImageEnhance, ImageFont, ImageOps
|
||||||
|
|
||||||
@@ -32,6 +33,104 @@ def draw_text(img: Image.Image, xy: tuple[int, int], text: str, font: ImageFont.
|
|||||||
mask = mask.point(lambda p: 255 if p > _TEXT_MASK_THRESHOLD else 0)
|
mask = mask.point(lambda p: 255 if p > _TEXT_MASK_THRESHOLD else 0)
|
||||||
img.paste(fill, (xy[0] + bbox[0], xy[1] + bbox[1]), mask)
|
img.paste(fill, (xy[0] + bbox[0], xy[1] + bbox[1]), mask)
|
||||||
|
|
||||||
|
|
||||||
|
def _dashed_edge(draw: ImageDraw.ImageDraw, x0: float, y0: float, x1: float, y1: float,
|
||||||
|
width: int, color: tuple[int, int, int], dash: float, gap: float) -> None:
|
||||||
|
length = math.hypot(x1 - x0, y1 - y0)
|
||||||
|
if length <= 0:
|
||||||
|
return
|
||||||
|
ux, uy = (x1 - x0) / length, (y1 - y0) / length
|
||||||
|
pos = 0.0
|
||||||
|
while pos < length:
|
||||||
|
end = min(pos + dash, length)
|
||||||
|
draw.line([(x0 + ux * pos, y0 + uy * pos), (x0 + ux * end, y0 + uy * end)], fill=color, width=width)
|
||||||
|
pos += dash + gap
|
||||||
|
|
||||||
|
|
||||||
|
def _dotted_edge(draw: ImageDraw.ImageDraw, x0: float, y0: float, x1: float, y1: float,
|
||||||
|
width: int, color: tuple[int, int, int], spacing: float) -> None:
|
||||||
|
length = math.hypot(x1 - x0, y1 - y0)
|
||||||
|
if length <= 0:
|
||||||
|
return
|
||||||
|
ux, uy = (x1 - x0) / length, (y1 - y0) / length
|
||||||
|
r = max(1, width / 2)
|
||||||
|
pos = 0.0
|
||||||
|
while pos <= length:
|
||||||
|
cx, cy = x0 + ux * pos, y0 + uy * pos
|
||||||
|
draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=color)
|
||||||
|
pos += spacing
|
||||||
|
|
||||||
|
|
||||||
|
def draw_widget_border(img: Image.Image, style: str, thickness: int, color: tuple[int, int, int],
|
||||||
|
radius: int = 0) -> None:
|
||||||
|
"""Draws a border inset within img's own bounds, mutating it in
|
||||||
|
place -- called once per widget's own region (routers/device.py's
|
||||||
|
_render_widgets, and each widget type's own dialog preview) before
|
||||||
|
that region's image is pasted onto the shared canvas, so a border
|
||||||
|
never straddles the boundary between two adjacent widgets. `color`
|
||||||
|
should already be an exact palette RGB (see resolve_border_color) so
|
||||||
|
the stroke quantizes with zero dithering error, same reasoning as
|
||||||
|
the weather/battery icons' exact-panel-ink-RGB fills.
|
||||||
|
|
||||||
|
"solid"/"dashed"/"dotted" are a single thickness-px stroke traced
|
||||||
|
just inside the image's edge; "fancy" is two thinner concentric
|
||||||
|
strokes with a gap between them, picture-frame-mat style. "none" (or
|
||||||
|
a non-positive thickness) draws nothing. `radius` is opt-in and only
|
||||||
|
honored by "solid"/"fancy" (rounded_rectangle instead of rectangle) --
|
||||||
|
"dashed"/"dotted" trace each of the 4 edges as independent straight
|
||||||
|
segments (see _dashed_edge/_dotted_edge) and ignore it, a documented
|
||||||
|
limitation rather than a bug. Defaults to 0 (unchanged sharp-corner
|
||||||
|
behavior) and no call site passes non-zero today -- this ships the
|
||||||
|
capability for a future per-widget "rounded border" setting without
|
||||||
|
changing default behavior anywhere (see tests/test_widget_border.py's
|
||||||
|
exact-corner-pixel assertions)."""
|
||||||
|
if style == "none" or thickness <= 0:
|
||||||
|
return
|
||||||
|
w, h = img.size
|
||||||
|
t = max(1, min(int(thickness), min(w, h) // 2))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
r = max(0, min(radius, (w - 1) // 2, (h - 1) // 2))
|
||||||
|
|
||||||
|
if style == "fancy":
|
||||||
|
line_t = max(1, t // 3)
|
||||||
|
gap = max(2, t - 2 * line_t)
|
||||||
|
if r:
|
||||||
|
draw.rounded_rectangle([0, 0, w - 1, h - 1], radius=r, outline=color, width=line_t)
|
||||||
|
else:
|
||||||
|
draw.rectangle([0, 0, w - 1, h - 1], outline=color, width=line_t)
|
||||||
|
inset = line_t + gap
|
||||||
|
if w - 2 * inset > 1 and h - 2 * inset > 1:
|
||||||
|
inner_r = max(0, min(r - inset, (w - 1 - 2 * inset) // 2, (h - 1 - 2 * inset) // 2)) if r else 0
|
||||||
|
if inner_r:
|
||||||
|
draw.rounded_rectangle([inset, inset, w - 1 - inset, h - 1 - inset], radius=inner_r,
|
||||||
|
outline=color, width=line_t)
|
||||||
|
else:
|
||||||
|
draw.rectangle([inset, inset, w - 1 - inset, h - 1 - inset], outline=color, width=line_t)
|
||||||
|
return
|
||||||
|
|
||||||
|
if style == "solid":
|
||||||
|
if r:
|
||||||
|
draw.rounded_rectangle([0, 0, w - 1, h - 1], radius=r, outline=color, width=t)
|
||||||
|
else:
|
||||||
|
draw.rectangle([0, 0, w - 1, h - 1], outline=color, width=t)
|
||||||
|
return
|
||||||
|
|
||||||
|
# dashed/dotted trace the same centered-on-the-edge path solid/
|
||||||
|
# fancy's rectangle outline draws, so all four styles sit at the
|
||||||
|
# same inset regardless of which is chosen.
|
||||||
|
half = t / 2
|
||||||
|
x0, y0, x1, y1 = half, half, w - 1 - half, h - 1 - half
|
||||||
|
edges = [(x0, y0, x1, y0), (x1, y0, x1, y1), (x1, y1, x0, y1), (x0, y1, x0, y0)]
|
||||||
|
if style == "dashed":
|
||||||
|
dash, gap = t * 3, t * 2
|
||||||
|
for ex0, ey0, ex1, ey1 in edges:
|
||||||
|
_dashed_edge(draw, ex0, ey0, ex1, ey1, t, color, dash, gap)
|
||||||
|
elif style == "dotted":
|
||||||
|
spacing = max(t * 2, t + 4)
|
||||||
|
for ex0, ey0, ex1, ey1 in edges:
|
||||||
|
_dotted_edge(draw, ex0, ey0, ex1, ey1, t, color, spacing)
|
||||||
|
|
||||||
|
|
||||||
# How each orientation maps the logically-composed image onto the native
|
# How each orientation maps the logically-composed image onto the native
|
||||||
# 800x480 panel. "portrait"/"portrait_flipped" compose at 480x800 (so the
|
# 800x480 panel. "portrait"/"portrait_flipped" compose at 480x800 (so the
|
||||||
# crop ratio matches how the frame actually hangs) and rotate into native
|
# crop ratio matches how the frame actually hangs) and rotate into native
|
||||||
@@ -88,12 +187,48 @@ DEFAULT_PALETTE_RGB = [
|
|||||||
|
|
||||||
PALETTE_LABELS = ["Black", "White", "Yellow", "Red", "Blue", "Green"]
|
PALETTE_LABELS = ["Black", "White", "Yellow", "Red", "Blue", "Green"]
|
||||||
|
|
||||||
|
# A community-measured alternative starting point for the same 6 slots,
|
||||||
|
# ported (data only, not code) from paperlesspaper/epdoptimize's
|
||||||
|
# src/dither/data/default-palettes.json "spectra6" entry (Apache
|
||||||
|
# License 2.0, https://github.com/paperlesspaper/epdoptimize) -- offered
|
||||||
|
# as a one-click "Load calibrated preset" in the Advanced configuration
|
||||||
|
# UI, not a new default: unlike DEFAULT_PALETTE_RGB above, these are an
|
||||||
|
# actual panel's measured appearance rather than idealized primaries
|
||||||
|
# (real Spectra 6 white/black are notably duller than pure #fff/#000),
|
||||||
|
# but measured from a different unit than any given frame's actual
|
||||||
|
# panel -- panel_style.py's own docstring already notes units vary
|
||||||
|
# enough to be worth calibrating per frame, and this hasn't been
|
||||||
|
# verified against this project's own hardware.
|
||||||
|
CALIBRATED_SPECTRA6_RGB = [
|
||||||
|
(0x1F, 0x22, 0x26), # BLACK
|
||||||
|
(0xB9, 0xC7, 0xC9), # WHITE
|
||||||
|
(0xC1, 0xBB, 0x1E), # YELLOW
|
||||||
|
(0x62, 0x20, 0x1E), # RED
|
||||||
|
(0x23, 0x3F, 0x8E), # BLUE
|
||||||
|
(0x35, 0x56, 0x3A), # GREEN
|
||||||
|
]
|
||||||
|
|
||||||
# The panel's actual 4-bit color codes (see firmware/components/epd7in3e),
|
# The panel's actual 4-bit color codes (see firmware/components/epd7in3e),
|
||||||
# in the same order as DEFAULT_PALETTE_RGB/PALETTE_LABELS -- fixed by the
|
# in the same order as DEFAULT_PALETTE_RGB/PALETTE_LABELS -- fixed by the
|
||||||
# hardware protocol, never user-configurable. 0x4 is intentionally unused
|
# hardware protocol, never user-configurable. 0x4 is intentionally unused
|
||||||
# upstream.
|
# upstream.
|
||||||
PANEL_CODES = [0x0, 0x1, 0x2, 0x3, 0x5, 0x6]
|
PANEL_CODES = [0x0, 0x1, 0x2, 0x3, 0x5, 0x6]
|
||||||
|
|
||||||
|
# Per-widget optional border (models.Widget.border_style, see
|
||||||
|
# draw_widget_border below). "none" is the default/no-op; the rest are
|
||||||
|
# thickness-px strokes inset within the widget's own region.
|
||||||
|
BORDER_STYLES = ["none", "solid", "dashed", "dotted", "fancy"]
|
||||||
|
BORDER_STYLE_LABELS = {
|
||||||
|
"none": "None",
|
||||||
|
"solid": "Solid",
|
||||||
|
"dashed": "Dashed",
|
||||||
|
"dotted": "Dotted",
|
||||||
|
"fancy": "Fancy (double line)",
|
||||||
|
}
|
||||||
|
MIN_BORDER_THICKNESS = 1
|
||||||
|
MAX_BORDER_THICKNESS = 8
|
||||||
|
DEFAULT_BORDER_THICKNESS = 3
|
||||||
|
|
||||||
|
|
||||||
def palette_to_hex(palette_rgb: list) -> list[str]:
|
def palette_to_hex(palette_rgb: list) -> list[str]:
|
||||||
"""[(0,0,0), ...] -> ["#000000", ...], for pre-filling the Advanced
|
"""[(0,0,0), ...] -> ["#000000", ...], for pre-filling the Advanced
|
||||||
@@ -101,6 +236,21 @@ def palette_to_hex(palette_rgb: list) -> list[str]:
|
|||||||
return ["#%02x%02x%02x" % tuple(c) for c in palette_rgb]
|
return ["#%02x%02x%02x" % tuple(c) for c in palette_rgb]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_border_color(color_index: int, palette_rgb: list | None) -> tuple[int, int, int]:
|
||||||
|
"""Widget.border_color_index -> an actual RGB tuple, against this
|
||||||
|
frame's tuned palette if it has one (falls back to
|
||||||
|
DEFAULT_PALETTE_RGB) -- so a border always renders as one of the
|
||||||
|
panel's real 6 ink colors and never needs to be dithered, same
|
||||||
|
reasoning as the weather/battery icons' exact-panel-ink-RGB fills
|
||||||
|
(see docs/widgets.md). Out-of-range indexes (a stale value from a
|
||||||
|
frame that used to have more colors, though that never happens
|
||||||
|
today) fall back to Black rather than raising."""
|
||||||
|
palette = palette_rgb or DEFAULT_PALETTE_RGB
|
||||||
|
if 0 <= color_index < len(palette):
|
||||||
|
return tuple(palette[color_index])
|
||||||
|
return tuple(palette[0])
|
||||||
|
|
||||||
|
|
||||||
def hex_to_rgb(hex_str: str) -> tuple[int, int, int] | None:
|
def hex_to_rgb(hex_str: str) -> tuple[int, int, int] | None:
|
||||||
""""#1a2b3c" -> (26, 43, 60), or None for anything that isn't exactly
|
""""#1a2b3c" -> (26, 43, 60), or None for anything that isn't exactly
|
||||||
a 6-hex-digit color (what <input type="color"> always sends, but a
|
a 6-hex-digit color (what <input type="color"> always sends, but a
|
||||||
@@ -388,9 +538,16 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
|
|||||||
return _transpose_and_pack(quantized, orientation)
|
return _transpose_and_pack(quantized, orientation)
|
||||||
|
|
||||||
|
|
||||||
|
def _png_bytes(img: Image.Image) -> bytes:
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.convert("RGB").save(buf, format="PNG")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], orientation: str = "landscape",
|
def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], orientation: str = "landscape",
|
||||||
palette_rgb: list | None = None, color_boost: float = 1.0, contrast_boost: float = 1.0,
|
palette_rgb: list | None = None, color_boost: float = 1.0, contrast_boost: float = 1.0,
|
||||||
dither_strength: float = 1.0, manage: dict | None = None, as_png: bool = False) -> bytes:
|
dither_strength: float = 1.0, manage: dict | None = None, as_png: bool = False,
|
||||||
|
capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]:
|
||||||
"""The widget system's compositor -- generalizes render_frame's tail
|
"""The widget system's compositor -- generalizes render_frame's tail
|
||||||
(paste, enhance once, overlay once, quantize once, pack once) from
|
(paste, enhance once, overlay once, quantize once, pack once) from
|
||||||
"compose one photo" to "paste N already-rendered regions, then run
|
"compose one photo" to "paste N already-rendered regions, then run
|
||||||
@@ -422,7 +579,14 @@ def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], o
|
|||||||
as_png=True returns a normal browser-viewable PNG in logical (upright)
|
as_png=True returns a normal browser-viewable PNG in logical (upright)
|
||||||
orientation instead of packed native-panel bytes, same convention as
|
orientation instead of packed native-panel bytes, same convention as
|
||||||
render_preview_png -- used for the web UI's live "how it's displaying"
|
render_preview_png -- used for the web UI's live "how it's displaying"
|
||||||
thumbnail."""
|
thumbnail.
|
||||||
|
|
||||||
|
capture_snapshot=True (only meaningful alongside as_png=False) returns
|
||||||
|
(packed_bytes, png_bytes) instead of just packed_bytes -- both derived
|
||||||
|
from the same already-quantized canvas, so a device-facing render can
|
||||||
|
also persist a browser-viewable copy (see routers/device.py's
|
||||||
|
_record_last_displayed) without re-running composition/quantization a
|
||||||
|
second time."""
|
||||||
logical_w, logical_h = logical_render_size(orientation)
|
logical_w, logical_h = logical_render_size(orientation)
|
||||||
canvas = Image.new("RGB", (logical_w, logical_h), LETTERBOX_BG)
|
canvas = Image.new("RGB", (logical_w, logical_h), LETTERBOX_BG)
|
||||||
for (x, y, w, h), region_img in regions:
|
for (x, y, w, h), region_img in regions:
|
||||||
@@ -432,10 +596,11 @@ def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], o
|
|||||||
fitted = _apply_manage_overlay(fitted, manage)
|
fitted = _apply_manage_overlay(fitted, manage)
|
||||||
quantized = _quantize(fitted, palette_rgb, dither_strength)
|
quantized = _quantize(fitted, palette_rgb, dither_strength)
|
||||||
if as_png:
|
if as_png:
|
||||||
buf = io.BytesIO()
|
return _png_bytes(quantized)
|
||||||
quantized.convert("RGB").save(buf, format="PNG")
|
packed = _transpose_and_pack(quantized, orientation)
|
||||||
return buf.getvalue()
|
if capture_snapshot:
|
||||||
return _transpose_and_pack(quantized, orientation)
|
return packed, _png_bytes(quantized)
|
||||||
|
return packed
|
||||||
|
|
||||||
|
|
||||||
def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
|
def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
|
||||||
@@ -451,14 +616,13 @@ def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
|
|||||||
fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost)
|
fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost)
|
||||||
fitted = _apply_manage_overlay(fitted, manage)
|
fitted = _apply_manage_overlay(fitted, manage)
|
||||||
quantized = _quantize(fitted, palette_rgb, dither_strength)
|
quantized = _quantize(fitted, palette_rgb, dither_strength)
|
||||||
buf = io.BytesIO()
|
return _png_bytes(quantized)
|
||||||
quantized.convert("RGB").save(buf, format="PNG")
|
|
||||||
return buf.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
def render_placeholder(lines: list[str], qr_url: str | None = None,
|
def render_placeholder(lines: list[str], qr_url: str | None = None,
|
||||||
orientation: str = "landscape", palette_rgb: list | None = None,
|
orientation: str = "landscape", palette_rgb: list | None = None,
|
||||||
manage: dict | None = None, as_png: bool = False) -> bytes:
|
manage: dict | None = None, as_png: bool = False,
|
||||||
|
capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]:
|
||||||
"""A readable full-panel message (plus an optional QR code) in the
|
"""A readable full-panel message (plus an optional QR code) in the
|
||||||
same packed format as render_frame -- what /frame/image serves for a
|
same packed format as render_frame -- what /frame/image serves for a
|
||||||
frame that isn't claimed or configured yet, so a fresh device shows
|
frame that isn't claimed or configured yet, so a fresh device shows
|
||||||
@@ -466,7 +630,8 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
|
|||||||
|
|
||||||
`manage`, same as render_frame's -- lets the manage button still work
|
`manage`, same as render_frame's -- lets the manage button still work
|
||||||
(at minimum, the scan-to-manage QR) on a frame that isn't configured
|
(at minimum, the scan-to-manage QR) on a frame that isn't configured
|
||||||
yet."""
|
yet. `capture_snapshot`, same as render_panel's -- (packed, png)
|
||||||
|
instead of just packed."""
|
||||||
margin = 24
|
margin = 24
|
||||||
logical_w, logical_h = logical_render_size(orientation)
|
logical_w, logical_h = logical_render_size(orientation)
|
||||||
img = Image.new("RGB", (logical_w, logical_h), (255, 255, 255))
|
img = Image.new("RGB", (logical_w, logical_h), (255, 255, 255))
|
||||||
@@ -530,7 +695,8 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
|
|||||||
img = _apply_manage_overlay(img, manage)
|
img = _apply_manage_overlay(img, manage)
|
||||||
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||||
if as_png:
|
if as_png:
|
||||||
buf = io.BytesIO()
|
return _png_bytes(quantized)
|
||||||
quantized.convert("RGB").save(buf, format="PNG")
|
packed = _transpose_and_pack(quantized, orientation)
|
||||||
return buf.getvalue()
|
if capture_snapshot:
|
||||||
return _transpose_and_pack(quantized, orientation)
|
return packed, _png_bytes(quantized)
|
||||||
|
return packed
|
||||||
|
|||||||
@@ -80,10 +80,10 @@ class ImmichClient:
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
def create_share_link(self, asset_id: str, expires_in_s: int) -> str:
|
def create_share_link(self, asset_ids: list[str], expires_in_s: int) -> str:
|
||||||
"""Creates a public, view-only Immich share link for a single
|
"""Creates a public, view-only Immich share link covering one or
|
||||||
asset, expiring expires_in_s seconds from now, and returns its
|
more assets, expiring expires_in_s seconds from now, and returns
|
||||||
public URL. Used by the manage-button overlay's share QR --
|
its public URL. Used by the manage-button overlay's share QR --
|
||||||
created lazily (only when someone actually scans it), not when
|
created lazily (only when someone actually scans it), not when
|
||||||
the button's pressed, so the expiry clock starts when it's
|
the button's pressed, so the expiry clock starts when it's
|
||||||
actually used."""
|
actually used."""
|
||||||
@@ -93,7 +93,7 @@ class ImmichClient:
|
|||||||
headers=self._headers,
|
headers=self._headers,
|
||||||
json={
|
json={
|
||||||
"type": "INDIVIDUAL",
|
"type": "INDIVIDUAL",
|
||||||
"assetIds": [asset_id],
|
"assetIds": list(asset_ids),
|
||||||
"expiresAt": expires_at,
|
"expiresAt": expires_at,
|
||||||
"allowUpload": False,
|
"allowUpload": False,
|
||||||
"allowDownload": True,
|
"allowDownload": True,
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Root-logger configuration: a rotating file handler under the same
|
||||||
|
/data volume as the sqlite DB and legacy config.json, so the admin log
|
||||||
|
viewer has something to read and log content survives container
|
||||||
|
restarts -- a redeploy happens on every push to main touching
|
||||||
|
server/**, which would make an in-memory-only log buffer nearly
|
||||||
|
useless in practice. Before this, the root logger had no handler at
|
||||||
|
all, so every module's logger.info() call (user creation, claims,
|
||||||
|
password resets, ...) was silently dropped rather than merely
|
||||||
|
un-viewable -- this fixes that too, not just adds a viewer."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
LOG_PATH = Path(os.environ.get("LOG_PATH", "/data/server.log"))
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging() -> None:
|
||||||
|
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
handler = RotatingFileHandler(LOG_PATH, maxBytes=2_000_000, backupCount=3)
|
||||||
|
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.addHandler(handler)
|
||||||
|
root.setLevel(os.environ.get("LOG_LEVEL", "INFO"))
|
||||||
|
|
||||||
|
|
||||||
|
def read_log_tail(lines: int) -> str:
|
||||||
|
if not LOG_PATH.exists():
|
||||||
|
return ""
|
||||||
|
text = LOG_PATH.read_text(errors="replace")
|
||||||
|
return "\n".join(text.splitlines()[-lines:])
|
||||||
+57
-17
@@ -16,14 +16,16 @@ pre-database config.json deployment on first boot."""
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from . import migration
|
from . import html_render, logging_setup, migration
|
||||||
from .auth import (
|
from .auth import (
|
||||||
browser_token_valid,
|
browser_token_valid,
|
||||||
current_user,
|
current_user,
|
||||||
@@ -38,12 +40,53 @@ from .routers.common import shell_context
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Before anything else logs: a handler exists to catch it, and it lands in
|
||||||
|
# the same persistent volume the admin log viewer reads from.
|
||||||
|
logging_setup.configure_logging()
|
||||||
|
|
||||||
# Schema + legacy-config import, before the first request is served.
|
# Schema + legacy-config import, before the first request is served.
|
||||||
migration.run_migrations()
|
migration.run_migrations()
|
||||||
|
|
||||||
app = FastAPI(title="ESPresso Frame Server")
|
@asynccontextmanager
|
||||||
|
async def _lifespan(app: FastAPI):
|
||||||
|
"""Startup does nothing browser-related -- html_render.start() is
|
||||||
|
lazy (only the weather widget's opt-in "modern" render style ever
|
||||||
|
triggers it, see that module's docstring), so a deployment that
|
||||||
|
never uses it never launches Chromium or needs Playwright's browser
|
||||||
|
binaries installed. Shutdown calls html_render.stop() unconditionally
|
||||||
|
(a no-op if it was never started) so a server restart never leaves
|
||||||
|
an orphaned Chromium process running."""
|
||||||
|
yield
|
||||||
|
html_render.stop()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="ESPresso Frame Server", lifespan=_lifespan)
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def log_device_requests(request: Request, call_next):
|
||||||
|
"""Access log for the firmware-facing /frame/* protocol -- the admin
|
||||||
|
log viewer otherwise only ever shows exceptions (device.py logs
|
||||||
|
those, not successful requests), so a slow-but-200 request or a
|
||||||
|
device hammering a stale/wrong token leaves no trace at all. Logs
|
||||||
|
the device id (query param, not the token -- never log credentials)
|
||||||
|
and wall time, which is exactly what's needed to spot a request that
|
||||||
|
blew past the firmware's fixed HTTP timeout without technically
|
||||||
|
failing server-side."""
|
||||||
|
if not request.url.path.startswith("/frame/"):
|
||||||
|
return await call_next(request)
|
||||||
|
start = time.monotonic()
|
||||||
|
device_id = request.query_params.get("id", "") or "-"
|
||||||
|
response = await call_next(request)
|
||||||
|
elapsed_ms = (time.monotonic() - start) * 1000
|
||||||
|
logger.info(
|
||||||
|
"%s %s id=%s -> %d (%.0fms)",
|
||||||
|
request.method, request.url.path, device_id, response.status_code, elapsed_ms,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||||
|
|
||||||
app.include_router(device.router)
|
app.include_router(device.router)
|
||||||
@@ -60,26 +103,23 @@ def health() -> dict:
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
def _device_credential_redirect(request: Request, db, allow_legacy: bool) -> str | None:
|
@app.get("/sw.js")
|
||||||
|
def service_worker() -> FileResponse:
|
||||||
|
# Served from / rather than /static/sw.js so its default scope is the
|
||||||
|
# whole app -- a SW can only ever control paths at or below its own URL.
|
||||||
|
return FileResponse("app/static/sw.js", media_type="application/javascript")
|
||||||
|
|
||||||
|
|
||||||
|
def _device_credential_redirect(request: Request, db) -> str | None:
|
||||||
"""The on-frame manage QR points at the server root with the device's
|
"""The on-frame manage QR points at the server root with the device's
|
||||||
own credentials (new firmware: ?id=&token=; deployed firmware:
|
own credentials (?id=&token=). Those scans get the frame's limited
|
||||||
?token=<legacy shared token>). Those scans get the frame's limited
|
manage page -- never the full UI, which requires a login."""
|
||||||
manage page -- never the full UI, which requires a login.
|
|
||||||
allow_legacy is False before /setup has run: at that point a bare
|
|
||||||
?token= hit is the admin coming through the token prompt to do
|
|
||||||
first-run setup, not a QR scan."""
|
|
||||||
device_id = request.query_params.get("id", "").strip().lower()
|
device_id = request.query_params.get("id", "").strip().lower()
|
||||||
token = request.query_params.get("token", "")
|
token = request.query_params.get("token", "")
|
||||||
if device_id and token:
|
if device_id and token:
|
||||||
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
|
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
|
||||||
if frame is not None and token == frame.device_token:
|
if frame is not None and token == frame.device_token:
|
||||||
return f"/m/{frame.manage_token}"
|
return f"/m/{frame.manage_token}"
|
||||||
if allow_legacy and token and management_token() and token == management_token():
|
|
||||||
frame = db.scalars(
|
|
||||||
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712
|
|
||||||
).first()
|
|
||||||
if frame is not None:
|
|
||||||
return f"/m/{frame.manage_token}"
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -90,7 +130,7 @@ def index(request: Request):
|
|||||||
else is walked through setup/login."""
|
else is walked through setup/login."""
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
have_users = users_exist(db)
|
have_users = users_exist(db)
|
||||||
manage_redirect = _device_credential_redirect(request, db, allow_legacy=have_users)
|
manage_redirect = _device_credential_redirect(request, db)
|
||||||
if manage_redirect is not None:
|
if manage_redirect is not None:
|
||||||
return RedirectResponse(manage_redirect, status_code=303)
|
return RedirectResponse(manage_redirect, status_code=303)
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
from .image_pipeline import DEFAULT_PALETTE_RGB, draw_text
|
from . import panel_style
|
||||||
|
from .image_pipeline import draw_text
|
||||||
|
|
||||||
PADDING = 16
|
PADDING = 16
|
||||||
QR_TEXT_GAP = 8
|
QR_TEXT_GAP = 8
|
||||||
@@ -27,9 +28,12 @@ BODY_FONT_SIZE = 20
|
|||||||
|
|
||||||
BATTERY_ICON_W = 40
|
BATTERY_ICON_W = 40
|
||||||
BATTERY_ICON_H = 22
|
BATTERY_ICON_H = 22
|
||||||
BATTERY_ICON_STROKE = 2
|
# Stroke/nub width/height are no longer fixed constants here -- panel_
|
||||||
|
# style.draw_battery_icon derives them from icon_w/icon_h itself (same
|
||||||
|
# formula widgets/battery.py's own icon already used). BATTERY_NUB_W
|
||||||
|
# below is kept only as this box's own outer-width estimate, not fed
|
||||||
|
# into the icon drawing itself.
|
||||||
BATTERY_NUB_W = 5
|
BATTERY_NUB_W = 5
|
||||||
BATTERY_NUB_H = 10
|
|
||||||
BATTERY_ICON_TEXT_GAP = 8
|
BATTERY_ICON_TEXT_GAP = 8
|
||||||
BATTERY_REGION_GAP = 8 # vertical gap below the manage QR box
|
BATTERY_REGION_GAP = 8 # vertical gap below the manage QR box
|
||||||
|
|
||||||
@@ -37,10 +41,6 @@ FACE_LABEL_PADDING = 8
|
|||||||
FACE_LABEL_GAP = 4 # distance from the face's anchor point to the label box
|
FACE_LABEL_GAP = 4 # distance from the face's anchor point to the label box
|
||||||
|
|
||||||
|
|
||||||
def _font(size: int) -> ImageFont.ImageFont:
|
|
||||||
return ImageFont.load_default(size=size)
|
|
||||||
|
|
||||||
|
|
||||||
def _qr_image(url: str, target_px: int = QR_TARGET_PX) -> Image.Image:
|
def _qr_image(url: str, target_px: int = QR_TARGET_PX) -> Image.Image:
|
||||||
import qrcode
|
import qrcode
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ def _qr_image(url: str, target_px: int = QR_TARGET_PX) -> Image.Image:
|
|||||||
return raw.resize((raw.width * scale, raw.height * scale), Image.NEAREST)
|
return raw.resize((raw.width * scale, raw.height * scale), Image.NEAREST)
|
||||||
|
|
||||||
|
|
||||||
def _text_box(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.ImageFont) -> tuple[int, int]:
|
def _text_box(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.FreeTypeFont) -> tuple[int, int]:
|
||||||
"""(width, height) of `lines` stacked with LINE_GAP between them, at
|
"""(width, height) of `lines` stacked with LINE_GAP between them, at
|
||||||
`font` -- the box _draw_text_box below will need."""
|
`font` -- the box _draw_text_box below will need."""
|
||||||
w = 0
|
w = 0
|
||||||
@@ -64,7 +64,7 @@ def _text_box(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.Image
|
|||||||
return w, h
|
return w, h
|
||||||
|
|
||||||
|
|
||||||
def _draw_centered_lines(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.ImageFont,
|
def _draw_centered_lines(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.FreeTypeFont,
|
||||||
center_x: int, top: int) -> None:
|
center_x: int, top: int) -> None:
|
||||||
y = top
|
y = top
|
||||||
for line in lines:
|
for line in lines:
|
||||||
@@ -82,7 +82,8 @@ def _draw_qr_box(img: Image.Image, draw: ImageDraw.ImageDraw, url: str, caption:
|
|||||||
(the battery, below the manage QR) use it instead of recomputing the
|
(the battery, below the manage QR) use it instead of recomputing the
|
||||||
same geometry a second time."""
|
same geometry a second time."""
|
||||||
qr_img = _qr_image(url)
|
qr_img = _qr_image(url)
|
||||||
text_w, text_h = _text_box(draw, caption, _font(TITLE_FONT_SIZE)) if caption else (0, 0)
|
caption_font = panel_style.font_bold(TITLE_FONT_SIZE)
|
||||||
|
text_w, text_h = _text_box(draw, caption, caption_font) if caption else (0, 0)
|
||||||
content_w = max(qr_img.width, text_w)
|
content_w = max(qr_img.width, text_w)
|
||||||
content_h = qr_img.height + (QR_TEXT_GAP + text_h if caption else 0)
|
content_h = qr_img.height + (QR_TEXT_GAP + text_h if caption else 0)
|
||||||
|
|
||||||
@@ -90,24 +91,26 @@ def _draw_qr_box(img: Image.Image, draw: ImageDraw.ImageDraw, url: str, caption:
|
|||||||
h = content_h + PADDING * 2
|
h = content_h + PADDING * 2
|
||||||
x0, y0 = _corner_origin(img.size, (w, h), corner)
|
x0, y0 = _corner_origin(img.size, (w, h), corner)
|
||||||
|
|
||||||
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
|
draw.rounded_rectangle([x0, y0, x0 + w, y0 + h], radius=panel_style.CHIP_RADIUS,
|
||||||
|
fill=(255, 255, 255), outline=(0, 0, 0))
|
||||||
center_x = x0 + w // 2
|
center_x = x0 + w // 2
|
||||||
img.paste(qr_img, (center_x - qr_img.width // 2, y0 + PADDING))
|
img.paste(qr_img, (center_x - qr_img.width // 2, y0 + PADDING))
|
||||||
if caption:
|
if caption:
|
||||||
_draw_centered_lines(img, draw, caption, _font(TITLE_FONT_SIZE), center_x, y0 + PADDING + qr_img.height + QR_TEXT_GAP)
|
_draw_centered_lines(img, draw, caption, caption_font, center_x, y0 + PADDING + qr_img.height + QR_TEXT_GAP)
|
||||||
return x0, y0, w, h
|
return x0, y0, w, h
|
||||||
|
|
||||||
|
|
||||||
def _draw_text_box(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str], corner: str) -> None:
|
def _draw_text_box(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str], corner: str) -> None:
|
||||||
"""White-padded box with centered text lines, placed in one of the
|
"""White-padded box with centered text lines, placed in one of the
|
||||||
panel's four corners."""
|
panel's four corners."""
|
||||||
font = _font(BODY_FONT_SIZE)
|
font = panel_style.font_regular(BODY_FONT_SIZE)
|
||||||
text_w, text_h = _text_box(draw, lines, font)
|
text_w, text_h = _text_box(draw, lines, font)
|
||||||
w = text_w + PADDING * 2
|
w = text_w + PADDING * 2
|
||||||
h = text_h + PADDING * 2
|
h = text_h + PADDING * 2
|
||||||
x0, y0 = _corner_origin(img.size, (w, h), corner)
|
x0, y0 = _corner_origin(img.size, (w, h), corner)
|
||||||
|
|
||||||
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
|
draw.rounded_rectangle([x0, y0, x0 + w, y0 + h], radius=panel_style.CHIP_RADIUS,
|
||||||
|
fill=(255, 255, 255), outline=(0, 0, 0))
|
||||||
_draw_centered_lines(img, draw, lines, font, x0 + w // 2, y0 + PADDING)
|
_draw_centered_lines(img, draw, lines, font, x0 + w // 2, y0 + PADDING)
|
||||||
|
|
||||||
|
|
||||||
@@ -123,34 +126,17 @@ def _corner_origin(img_size: tuple[int, int], box_size: tuple[int, int], corner:
|
|||||||
return img_w - PANEL_MARGIN - box_w, img_h - PANEL_MARGIN - box_h # bottom-right
|
return img_w - PANEL_MARGIN - box_w, img_h - PANEL_MARGIN - box_h # bottom-right
|
||||||
|
|
||||||
|
|
||||||
# DEFAULT_PALETTE_RGB order is [BLACK, WHITE, YELLOW, RED, BLUE, GREEN]
|
|
||||||
# (see image_pipeline.PANEL_CODES) -- picked by level so the fill itself
|
|
||||||
# carries the "how worried should I be" signal, not just the number next
|
|
||||||
# to it. Thresholds match the low-battery-alert spirit elsewhere in this
|
|
||||||
# project (not tied to a frame's own configured alert threshold, since
|
|
||||||
# this glyph has to make sense with no configuration at all).
|
|
||||||
_BATTERY_LOW = DEFAULT_PALETTE_RGB[3] # red
|
|
||||||
_BATTERY_MEDIUM = DEFAULT_PALETTE_RGB[2] # yellow
|
|
||||||
_BATTERY_HIGH = DEFAULT_PALETTE_RGB[5] # green
|
|
||||||
|
|
||||||
|
|
||||||
def _battery_fill_color(percent: int) -> tuple[int, int, int]:
|
|
||||||
if percent <= 15:
|
|
||||||
return _BATTERY_LOW
|
|
||||||
if percent <= 40:
|
|
||||||
return _BATTERY_MEDIUM
|
|
||||||
return _BATTERY_HIGH
|
|
||||||
|
|
||||||
|
|
||||||
def _draw_battery(img: Image.Image, draw: ImageDraw.ImageDraw, percent: int, anchor_x0: int, anchor_y0: int,
|
def _draw_battery(img: Image.Image, draw: ImageDraw.ImageDraw, percent: int, anchor_x0: int, anchor_y0: int,
|
||||||
anchor_w: int, anchor_h: int) -> None:
|
anchor_w: int, anchor_h: int) -> None:
|
||||||
"""Battery glyph (now actually filled to `percent`, not just a static
|
"""Battery glyph + "NN%" text, right-aligned under the given anchor
|
||||||
outline -- easy now that this renders server-side instead of being a
|
box (the manage QR box) -- a sensible default position, not a
|
||||||
fixed bitmap firmware drew) + "NN%" text, right-aligned under the
|
constraint anything else has to route around; move this call site's
|
||||||
given anchor box (the manage QR box) -- a sensible default position,
|
arguments to place it anywhere else instead. The glyph itself is
|
||||||
not a constraint anything else has to route around; move this call
|
panel_style.draw_battery_icon -- the one shared implementation
|
||||||
site's arguments to place it anywhere else instead."""
|
replacing what used to be a second, independent copy of widgets/
|
||||||
font = _font(BODY_FONT_SIZE)
|
battery.py's own icon-drawing code (same shape, same red/yellow/
|
||||||
|
green thresholds, previously kept in sync by convention only)."""
|
||||||
|
font = panel_style.font_regular(BODY_FONT_SIZE)
|
||||||
text = f"{percent}%"
|
text = f"{percent}%"
|
||||||
icon_total_w = BATTERY_ICON_W + BATTERY_NUB_W
|
icon_total_w = BATTERY_ICON_W + BATTERY_NUB_W
|
||||||
text_w = draw.textlength(text, font=font)
|
text_w = draw.textlength(text, font=font)
|
||||||
@@ -162,22 +148,14 @@ def _draw_battery(img: Image.Image, draw: ImageDraw.ImageDraw, percent: int, anc
|
|||||||
x0 = anchor_x0 + anchor_w - w
|
x0 = anchor_x0 + anchor_w - w
|
||||||
y0 = anchor_y0 + anchor_h + BATTERY_REGION_GAP
|
y0 = anchor_y0 + anchor_h + BATTERY_REGION_GAP
|
||||||
|
|
||||||
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
|
draw.rounded_rectangle([x0, y0, x0 + w, y0 + h], radius=panel_style.CHIP_RADIUS,
|
||||||
|
fill=(255, 255, 255), outline=(0, 0, 0))
|
||||||
|
|
||||||
icon_x = x0 + PADDING
|
icon_x = x0 + PADDING
|
||||||
icon_y = y0 + PADDING + (content_h - BATTERY_ICON_H) // 2
|
icon_y = y0 + PADDING + (content_h - BATTERY_ICON_H) // 2
|
||||||
inner_x0, inner_y0 = icon_x + BATTERY_ICON_STROKE, icon_y + BATTERY_ICON_STROKE
|
panel_style.draw_battery_icon(draw, icon_x, icon_y, BATTERY_ICON_W, BATTERY_ICON_H, percent)
|
||||||
inner_x1, inner_y1 = icon_x + BATTERY_ICON_W - BATTERY_ICON_STROKE, icon_y + BATTERY_ICON_H - BATTERY_ICON_STROKE
|
|
||||||
fill_x1 = inner_x0 + round((inner_x1 - inner_x0) * (percent / 100))
|
|
||||||
if fill_x1 > inner_x0:
|
|
||||||
draw.rectangle([inner_x0, inner_y0, fill_x1, inner_y1], fill=_battery_fill_color(percent))
|
|
||||||
draw.rectangle([icon_x, icon_y, icon_x + BATTERY_ICON_W, icon_y + BATTERY_ICON_H], outline=(0, 0, 0),
|
|
||||||
width=BATTERY_ICON_STROKE)
|
|
||||||
nub_y = icon_y + (BATTERY_ICON_H - BATTERY_NUB_H) // 2
|
|
||||||
draw.rectangle([icon_x + BATTERY_ICON_W, nub_y, icon_x + BATTERY_ICON_W + BATTERY_NUB_W, nub_y + BATTERY_NUB_H],
|
|
||||||
fill=(0, 0, 0))
|
|
||||||
draw_text(img, (icon_x + icon_total_w + BATTERY_ICON_TEXT_GAP, y0 + PADDING + (content_h - font.size) // 2),
|
draw_text(img, (icon_x + icon_total_w + BATTERY_ICON_TEXT_GAP, y0 + PADDING + (content_h - font.size) // 2),
|
||||||
text, font)
|
text, font, panel_style.battery_fill_color(percent))
|
||||||
|
|
||||||
|
|
||||||
def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anchor_x: int, anchor_y: int) -> None:
|
def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anchor_x: int, anchor_y: int) -> None:
|
||||||
@@ -185,7 +163,7 @@ def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anc
|
|||||||
anchor_y) point, flipped above if there's no room below, clamped to
|
anchor_y) point, flipped above if there's no room below, clamped to
|
||||||
stay fully on-panel -- unlike the four corner boxes (always in-bounds
|
stay fully on-panel -- unlike the four corner boxes (always in-bounds
|
||||||
by construction), a face can be anywhere, including near an edge."""
|
by construction), a face can be anywhere, including near an edge."""
|
||||||
font = _font(BODY_FONT_SIZE)
|
font = panel_style.font_regular(BODY_FONT_SIZE)
|
||||||
text_w = draw.textlength(name, font=font)
|
text_w = draw.textlength(name, font=font)
|
||||||
bbox = draw.textbbox((0, 0), name, font=font)
|
bbox = draw.textbbox((0, 0), name, font=font)
|
||||||
text_h = bbox[3] - bbox[1]
|
text_h = bbox[3] - bbox[1]
|
||||||
@@ -201,7 +179,8 @@ def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anc
|
|||||||
x0 = max(0, min(x0, img_w - w))
|
x0 = max(0, min(x0, img_w - w))
|
||||||
y0 = max(0, min(y0, img_h - h))
|
y0 = max(0, min(y0, img_h - h))
|
||||||
|
|
||||||
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
|
draw.rounded_rectangle([x0, y0, x0 + w, y0 + h], radius=panel_style.CHIP_RADIUS,
|
||||||
|
fill=(255, 255, 255), outline=(0, 0, 0))
|
||||||
draw_text(img, (x0 + FACE_LABEL_PADDING, y0 + FACE_LABEL_PADDING - bbox[1]), name, font)
|
draw_text(img, (x0 + FACE_LABEL_PADDING, y0 + FACE_LABEL_PADDING - bbox[1]), name, font)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+645
-234
@@ -22,16 +22,12 @@ from .db import SessionLocal, engine
|
|||||||
from .models import (
|
from .models import (
|
||||||
Base,
|
Base,
|
||||||
BatteryLog,
|
BatteryLog,
|
||||||
CalendarWidgetConfig,
|
|
||||||
Frame,
|
Frame,
|
||||||
FrameButtonAction,
|
|
||||||
FrameTaskList,
|
|
||||||
PhotoWidgetConfig,
|
PhotoWidgetConfig,
|
||||||
ServerSettings,
|
ServerSettings,
|
||||||
TaskWidgetConfig,
|
|
||||||
WhiteboardWidgetConfig,
|
|
||||||
Widget,
|
Widget,
|
||||||
)
|
)
|
||||||
|
from .widgets import default_button_actions
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -385,8 +381,9 @@ def _migration_17(conn) -> None:
|
|||||||
"SELECT COALESCE(MAX(sort_order), 0) FROM widgets WHERE frame_id = :frame_id"
|
"SELECT COALESCE(MAX(sort_order), 0) FROM widgets WHERE frame_id = :frame_id"
|
||||||
), {"frame_id": row["frame_id"]}).scalar()
|
), {"frame_id": row["frame_id"]}).scalar()
|
||||||
result = conn.execute(text(
|
result = conn.execute(text(
|
||||||
"INSERT INTO widgets (frame_id, widget_type, x, y, w, h, sort_order, created_at) "
|
"INSERT INTO widgets (frame_id, widget_type, x, y, w, h, sort_order, created_at, "
|
||||||
"VALUES (:frame_id, 'tasks', :x, :y, :w, :h, :sort_order, :created_at)"
|
"border_style, border_thickness, border_color_index) "
|
||||||
|
"VALUES (:frame_id, 'tasks', :x, :y, :w, :h, :sort_order, :created_at, 'none', 3, 0)"
|
||||||
), {"frame_id": row["frame_id"], "x": x, "y": y, "w": w, "h": h,
|
), {"frame_id": row["frame_id"], "x": x, "y": y, "w": w, "h": h,
|
||||||
"sort_order": max_sort + 1, "created_at": now})
|
"sort_order": max_sort + 1, "created_at": now})
|
||||||
new_widget_id = result.lastrowid
|
new_widget_id = result.lastrowid
|
||||||
@@ -624,6 +621,542 @@ def _migration_23(conn) -> None:
|
|||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_24(conn) -> None:
|
||||||
|
"""New widget type: standalone weather (current/hourly/daily/
|
||||||
|
multi_city display modes, pluggable Open-Meteo/NWS providers -- see
|
||||||
|
models.WeatherWidgetConfig, app/weather/, app/widgets/weather.py).
|
||||||
|
Lifts the calendar widget's embedded weather strip's underlying
|
||||||
|
fetch/render building blocks (app/weather/open_meteo.py, the icon-
|
||||||
|
drawing primitives now in app/weather_render.py) out into a widget
|
||||||
|
that can be placed/sized on its own -- CalendarWidgetConfig's own
|
||||||
|
weather_* columns are untouched, still working exactly as before.
|
||||||
|
|
||||||
|
Raw CREATE TABLE, not Base.metadata.create_all, same reasoning as
|
||||||
|
migration 20/21/23's own comments: create_all always reflects
|
||||||
|
models.py's CURRENT shape, so replaying the full chain on an old
|
||||||
|
database could collide with a later migration's ALTER TABLE on this
|
||||||
|
same table."""
|
||||||
|
conn.execute(text(
|
||||||
|
"CREATE TABLE weather_widget_configs ("
|
||||||
|
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
||||||
|
"mode TEXT NOT NULL DEFAULT 'current', "
|
||||||
|
"provider TEXT NOT NULL DEFAULT 'open_meteo', "
|
||||||
|
"units TEXT NOT NULL DEFAULT 'fahrenheit', "
|
||||||
|
"city_label TEXT, "
|
||||||
|
"city_latitude REAL, "
|
||||||
|
"city_longitude REAL, "
|
||||||
|
"hourly_interval_hours INTEGER NOT NULL DEFAULT 4, "
|
||||||
|
"daily_days INTEGER NOT NULL DEFAULT 5, "
|
||||||
|
"cities TEXT, "
|
||||||
|
"checked_at REAL NOT NULL DEFAULT 0.0, "
|
||||||
|
"cached TEXT)"
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_25(conn) -> None:
|
||||||
|
"""New widget type: battery (see models.BatteryWidgetConfig,
|
||||||
|
app/widgets/battery.py) -- shows the frame's own last-reported
|
||||||
|
battery level. No live upstream to poll and nothing to cache: unlike
|
||||||
|
every other widget type added since migration 20, the content is
|
||||||
|
frame-level state (Frame.battery_percent/battery_as_of) that already
|
||||||
|
existed before this widget did, so the only new column is a display
|
||||||
|
mode.
|
||||||
|
|
||||||
|
Raw CREATE TABLE, not Base.metadata.create_all, same reasoning as
|
||||||
|
migration 20/21/23/24's own comments: create_all always reflects
|
||||||
|
models.py's CURRENT shape, so replaying the full chain on an old
|
||||||
|
database could collide with a later migration's ALTER TABLE on this
|
||||||
|
same table."""
|
||||||
|
conn.execute(text(
|
||||||
|
"CREATE TABLE battery_widget_configs ("
|
||||||
|
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
||||||
|
"mode TEXT NOT NULL DEFAULT 'detailed')"
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_26(conn) -> None:
|
||||||
|
"""Per-widget border (see models.Widget.border_style/border_thickness/
|
||||||
|
border_color_index, image_pipeline.draw_widget_border) -- a shared
|
||||||
|
property on the widgets table itself, not a per-type config table,
|
||||||
|
since every widget type can have one regardless of widget_type.
|
||||||
|
border_style defaults to 'none' so existing widgets keep rendering
|
||||||
|
exactly as before until someone opts in via a widget's dialog.
|
||||||
|
|
||||||
|
Guarded per-column (unlike every earlier ALTER TABLE ADD COLUMN
|
||||||
|
migration in this file) because widgets is the one table
|
||||||
|
test_migrations.py's upgrade-path tests deliberately leave un-dropped
|
||||||
|
across a simulated old-schema_version replay (see those tests' own
|
||||||
|
comments: it hasn't changed shape since migration 16 created it, so
|
||||||
|
reusing the fresh-install create_all() copy -- which, unlike this
|
||||||
|
ALTER, already reflects models.py's current border_* columns -- was
|
||||||
|
safe up to now). Without the guard, replaying this migration in that
|
||||||
|
scenario re-adds a column that's already there and SQLite raises
|
||||||
|
"duplicate column name"."""
|
||||||
|
existing = {c["name"] for c in inspect(conn).get_columns("widgets")}
|
||||||
|
if "border_style" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE widgets ADD COLUMN border_style TEXT NOT NULL DEFAULT 'none'"))
|
||||||
|
if "border_thickness" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE widgets ADD COLUMN border_thickness INTEGER NOT NULL DEFAULT 3"))
|
||||||
|
if "border_color_index" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE widgets ADD COLUMN border_color_index INTEGER NOT NULL DEFAULT 0"))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_27(conn) -> None:
|
||||||
|
"""Per-photo-widget lock (models.PhotoWidgetConfig.locked) -- 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. Defaults to
|
||||||
|
unlocked so existing widgets keep rotating exactly as before.
|
||||||
|
|
||||||
|
Guarded per-column, same reasoning as migration 26's own comment:
|
||||||
|
photo_widget_configs isn't touched by test_migrations.py's simulated
|
||||||
|
pre-widget-system replays (unlike calendar/task/widgets tables those
|
||||||
|
tests DROP and recreate in an old shape), so it keeps the fresh-
|
||||||
|
install create_all() copy -- which already has this column -- when
|
||||||
|
those tests replay migrations 17+ from schema_version 16. Without
|
||||||
|
the guard, replaying this migration there re-adds a column that's
|
||||||
|
already there and SQLite raises "duplicate column name"."""
|
||||||
|
existing = {c["name"] for c in inspect(conn).get_columns("photo_widget_configs")}
|
||||||
|
if "locked" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE photo_widget_configs ADD COLUMN locked INTEGER NOT NULL DEFAULT 0"))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_28(conn) -> None:
|
||||||
|
"""One action per (widget, button) instead of an ordered per-button
|
||||||
|
list -- button-action editing moved from the frame-level "Button
|
||||||
|
assignments" card into each widget's own config dialog (see
|
||||||
|
models.FrameButtonAction's updated docstring, routers/api_widgets.py's
|
||||||
|
api_widget_config_save). Cross-widget execution order never actually
|
||||||
|
mattered (each widget's action only touches its own state), so this
|
||||||
|
only needs to de-dupe down to one row before the new unique index can
|
||||||
|
be created -- MIN(id) per (widget_id, button) survives, arbitrarily
|
||||||
|
but deterministically, since which specific extra binding a user's
|
||||||
|
old list happened to have doesn't matter anymore."""
|
||||||
|
conn.execute(text(
|
||||||
|
"DELETE FROM frame_button_actions WHERE id NOT IN "
|
||||||
|
"(SELECT MIN(id) FROM frame_button_actions GROUP BY widget_id, button)"
|
||||||
|
))
|
||||||
|
conn.execute(text(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS ix_frame_button_actions_widget_button "
|
||||||
|
"ON frame_button_actions (widget_id, button)"
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_29(conn) -> None:
|
||||||
|
"""Hold-for-global-action (see app/global_actions.py): holding NEXT/
|
||||||
|
BACK past hold_duration_ms triggers a frame-wide action instead of
|
||||||
|
the per-widget one a short press runs. next_hold_action/
|
||||||
|
back_hold_action are NULL (disabled) by default -- existing frames
|
||||||
|
get no new button behavior until someone opts in on the
|
||||||
|
Configuration tab. last_cycled_layout_id tracks where a repeated
|
||||||
|
"cycle saved layouts" hold should resume from.
|
||||||
|
|
||||||
|
Guarded per-column, same reasoning as migration 26/27's own
|
||||||
|
comments: frames is a table test_migrations.py's pre-widget-system
|
||||||
|
replay tests leave un-dropped (unlike calendar/task/widget tables
|
||||||
|
those tests DROP and recreate in an old shape), so it keeps the
|
||||||
|
fresh-install create_all() copy -- which already has these columns
|
||||||
|
-- when those tests replay migrations 17+ from schema_version 16.
|
||||||
|
Without the guard, replaying this migration there re-adds a column
|
||||||
|
that's already there and SQLite raises "duplicate column name"."""
|
||||||
|
existing = {c["name"] for c in inspect(conn).get_columns("frames")}
|
||||||
|
if "hold_duration_ms" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE frames ADD COLUMN hold_duration_ms INTEGER NOT NULL DEFAULT 3000"))
|
||||||
|
if "next_hold_action" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE frames ADD COLUMN next_hold_action TEXT"))
|
||||||
|
if "back_hold_action" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE frames ADD COLUMN back_hold_action TEXT"))
|
||||||
|
if "last_cycled_layout_id" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE frames ADD COLUMN last_cycled_layout_id INTEGER"))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_30(conn) -> None:
|
||||||
|
""""Now displaying" (models.Frame.last_displayed_image/
|
||||||
|
last_displayed_at) -- the web UI's header preview pair needs a frozen
|
||||||
|
record of exactly what the last device-facing render actually sent,
|
||||||
|
separate from the always-live "up next" re-render (see
|
||||||
|
routers/device.py's _record_last_displayed, api_frames.py's
|
||||||
|
/now-displaying endpoint). NULL/0.0 for every existing frame until
|
||||||
|
its next real device fetch -- no behavior change to what's served,
|
||||||
|
only a new thing recorded alongside it.
|
||||||
|
|
||||||
|
Guarded per-column, same reasoning as migration 26/27/29's own
|
||||||
|
comments: frames is a table test_migrations.py's pre-widget-system
|
||||||
|
replay tests leave un-dropped, so it keeps the fresh-install
|
||||||
|
create_all() copy -- which already has these columns -- when those
|
||||||
|
tests replay migrations 17+ from schema_version 16. Without the
|
||||||
|
guard, replaying this migration there re-adds a column that's already
|
||||||
|
there and SQLite raises "duplicate column name"."""
|
||||||
|
existing = {c["name"] for c in inspect(conn).get_columns("frames")}
|
||||||
|
if "last_displayed_image" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE frames ADD COLUMN last_displayed_image BLOB"))
|
||||||
|
if "last_displayed_at" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE frames ADD COLUMN last_displayed_at REAL NOT NULL DEFAULT 0.0"))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_31(conn) -> None:
|
||||||
|
"""Weather widget render style (models.WeatherWidgetConfig.
|
||||||
|
render_style): "classic" (existing hand-drawn PIL renderer,
|
||||||
|
unchanged) or "modern" (app/html_render.py's headless-Chromium/CSS
|
||||||
|
renderer). Every existing weather widget defaults to "classic" --
|
||||||
|
no behavior change until a widget's dialog switches it.
|
||||||
|
|
||||||
|
Guarded per-column, same reasoning as migration 30's own comment:
|
||||||
|
weather_widget_configs is a table some replay tests may re-create
|
||||||
|
fresh via create_all() (which already has this column) rather than
|
||||||
|
replaying migration 24's raw CREATE TABLE."""
|
||||||
|
existing = {c["name"] for c in inspect(conn).get_columns("weather_widget_configs")}
|
||||||
|
if "render_style" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE weather_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_32(conn) -> None:
|
||||||
|
"""Photos widget's own independent palette/dithering (models.Frame.
|
||||||
|
photo_palette_rgb/photo_dither_strength -- see widgets/photos.py's
|
||||||
|
render()). NULL/1.0 defaults reproduce the exact previous rendering
|
||||||
|
(same reference palette/strength as the main fields) until a frame's
|
||||||
|
Configuration tab sets them differently.
|
||||||
|
|
||||||
|
Guarded per-column, same reasoning as migration 30/31's own comments."""
|
||||||
|
existing = {c["name"] for c in inspect(conn).get_columns("frames")}
|
||||||
|
if "photo_palette_rgb" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE frames ADD COLUMN photo_palette_rgb TEXT"))
|
||||||
|
if "photo_dither_strength" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE frames ADD COLUMN photo_dither_strength REAL NOT NULL DEFAULT 1.0"))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_33(conn) -> None:
|
||||||
|
"""Battery widget render style (models.BatteryWidgetConfig.
|
||||||
|
render_style) -- same shape as migration 31's weather one. Every
|
||||||
|
existing battery widget defaults to "classic", no behavior change."""
|
||||||
|
existing = {c["name"] for c in inspect(conn).get_columns("battery_widget_configs")}
|
||||||
|
if "render_style" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE battery_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_34(conn) -> None:
|
||||||
|
"""Text widget render style (models.TextWidgetConfig.render_style) --
|
||||||
|
same shape as migration 31/33. Every existing text widget defaults to
|
||||||
|
"classic", no behavior change."""
|
||||||
|
existing = {c["name"] for c in inspect(conn).get_columns("text_widget_configs")}
|
||||||
|
if "render_style" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE text_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_35(conn) -> None:
|
||||||
|
"""Tasks widget render style (models.TaskWidgetConfig.render_style)
|
||||||
|
-- same shape as migration 31/33/34. Every existing tasks widget
|
||||||
|
defaults to "classic", no behavior change."""
|
||||||
|
existing = {c["name"] for c in inspect(conn).get_columns("task_widget_configs")}
|
||||||
|
if "render_style" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE task_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_36(conn) -> None:
|
||||||
|
"""Static image widget render style (models.StaticWidgetConfig.
|
||||||
|
render_style) -- same shape as migration 31/33/34/35."""
|
||||||
|
existing = {c["name"] for c in inspect(conn).get_columns("static_widget_configs")}
|
||||||
|
if "render_style" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE static_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_37(conn) -> None:
|
||||||
|
"""Whiteboard widget render style (models.WhiteboardWidgetConfig.
|
||||||
|
render_style) -- same shape as migration 36."""
|
||||||
|
existing = {c["name"] for c in inspect(conn).get_columns("whiteboard_widget_configs")}
|
||||||
|
if "render_style" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE whiteboard_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_38(conn) -> None:
|
||||||
|
"""Calendar widget render style (models.CalendarWidgetConfig.
|
||||||
|
render_style) -- same shape as migration 31/33/34/35/36/37."""
|
||||||
|
existing = {c["name"] for c in inspect(conn).get_columns("calendar_widget_configs")}
|
||||||
|
if "render_style" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE calendar_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_39(conn) -> None:
|
||||||
|
"""Frame-level curated theme for "modern" style widgets (models.
|
||||||
|
Frame.theme, see theme_tokens.THEMES) -- same guarded-per-column
|
||||||
|
shape as every prior migration."""
|
||||||
|
existing = {c["name"] for c in inspect(conn).get_columns("frames")}
|
||||||
|
if "theme" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE frames ADD COLUMN theme TEXT NOT NULL DEFAULT 'classic'"))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_40(conn) -> None:
|
||||||
|
"""Per-widget text-size multiplier (models.Widget.font_scale, see
|
||||||
|
panel_style.FONT_SCALE_CHOICES) -- a Widget-level column like
|
||||||
|
border_style/border_thickness/border_color_index, not a per-type
|
||||||
|
config field, since any widget type with body text can use it. Every
|
||||||
|
existing widget defaults to 1.0 (unchanged size) until its own
|
||||||
|
dialog's "Text size" picker sets it. Guarded per-column, same
|
||||||
|
reasoning as every prior migration's own comment."""
|
||||||
|
existing = {c["name"] for c in inspect(conn).get_columns("widgets")}
|
||||||
|
if "font_scale" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE widgets ADD COLUMN font_scale REAL NOT NULL DEFAULT 1.0"))
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_backfill_frame_widgets(conn, frame_row, now: float) -> None:
|
||||||
|
"""Raw-SQL equivalent of the old ORM-based _backfill_frame_widgets --
|
||||||
|
called from _migration_41 while the legacy Frame columns it reads
|
||||||
|
still physically exist, for the rare frame (if any) that somehow
|
||||||
|
reached this migration without ever getting a Widget during the long
|
||||||
|
window _ensure_widgets_backfilled ran unconditionally at every
|
||||||
|
startup between migration 16 and this one. Same mode dispatch,
|
||||||
|
including the calendar_photo_inlay two-widget split and the legacy
|
||||||
|
tasks-source carryover. Has to be hand-rolled in raw SQL rather than
|
||||||
|
reusing the old ORM helpers, since those read these columns off
|
||||||
|
models.Frame, which no longer declares them as of this migration."""
|
||||||
|
frame_id = frame_row["id"]
|
||||||
|
orientation = frame_row["orientation"] or "landscape"
|
||||||
|
cols, rows = grid.grid_dims(orientation)
|
||||||
|
mode = frame_row["mode"] if frame_row["mode"] in ("photos", "calendar", "whiteboard") else "photos"
|
||||||
|
|
||||||
|
def insert_widget(x, y, w, h, widget_type, sort_order):
|
||||||
|
# border_style/border_thickness/border_color_index/font_scale
|
||||||
|
# spelled out explicitly (migrations 26/40's own defaults)
|
||||||
|
# rather than relied on implicitly -- they're real SQL-level
|
||||||
|
# DEFAULTs in any database that reached this migration through
|
||||||
|
# the normal upgrade path, but this stays correct even if that
|
||||||
|
# ever stops being true.
|
||||||
|
result = conn.execute(text(
|
||||||
|
"INSERT INTO widgets (frame_id, widget_type, x, y, w, h, sort_order, created_at, "
|
||||||
|
"border_style, border_thickness, border_color_index, font_scale) "
|
||||||
|
"VALUES (:frame_id, :widget_type, :x, :y, :w, :h, :sort_order, :created_at, "
|
||||||
|
"'none', 3, 0, 1.0)"
|
||||||
|
), {"frame_id": frame_id, "widget_type": widget_type, "x": x, "y": y, "w": w, "h": h,
|
||||||
|
"sort_order": sort_order, "created_at": now})
|
||||||
|
return result.lastrowid
|
||||||
|
|
||||||
|
def insert_photo_config(widget_id):
|
||||||
|
conn.execute(text(
|
||||||
|
"INSERT INTO photo_widget_configs (widget_id, album_id, photo_order, display_mode, "
|
||||||
|
"queue_target_len, current_asset_id, current_asset_set_at, queue, queue_cursor, history, "
|
||||||
|
"excluded_asset_ids, locked) VALUES (:widget_id, :album_id, :photo_order, :display_mode, "
|
||||||
|
":queue_target_len, :current_asset_id, :current_asset_set_at, :queue, :queue_cursor, "
|
||||||
|
":history, :excluded_asset_ids, 0)"
|
||||||
|
), {"widget_id": widget_id, "album_id": frame_row["album_id"], "photo_order": frame_row["photo_order"],
|
||||||
|
"display_mode": frame_row["display_mode"], "queue_target_len": frame_row["queue_target_len"],
|
||||||
|
"current_asset_id": frame_row["current_asset_id"],
|
||||||
|
"current_asset_set_at": frame_row["current_asset_set_at"], "queue": frame_row["queue"],
|
||||||
|
"queue_cursor": frame_row["queue_cursor"], "history": frame_row["history"],
|
||||||
|
"excluded_asset_ids": frame_row["excluded_asset_ids"]})
|
||||||
|
|
||||||
|
def insert_calendar_config(widget_id):
|
||||||
|
conn.execute(text(
|
||||||
|
"INSERT INTO calendar_widget_configs (widget_id, view, week_start, browse_offset, checked_at, "
|
||||||
|
"cached_events, fetch_summary, weather_enabled, weather_units, weather_cities, "
|
||||||
|
"weather_checked_at, weather_cached, week_days, week_layout, week_start_offset, render_style) "
|
||||||
|
"VALUES (:widget_id, :view, :week_start, :browse_offset, :checked_at, :cached_events, "
|
||||||
|
":fetch_summary, :weather_enabled, :weather_units, :weather_cities, :weather_checked_at, "
|
||||||
|
":weather_cached, :week_days, :week_layout, :week_start_offset, 'classic')"
|
||||||
|
), {"widget_id": widget_id, "view": frame_row["calendar_view"],
|
||||||
|
"week_start": frame_row["calendar_week_start"], "browse_offset": frame_row["calendar_browse_offset"],
|
||||||
|
"checked_at": frame_row["calendar_checked_at"], "cached_events": frame_row["calendar_cached_events"],
|
||||||
|
"fetch_summary": frame_row["calendar_fetch_summary"],
|
||||||
|
"weather_enabled": frame_row["calendar_weather_enabled"],
|
||||||
|
"weather_units": frame_row["calendar_weather_units"],
|
||||||
|
"weather_cities": frame_row["calendar_weather_cities"],
|
||||||
|
"weather_checked_at": frame_row["calendar_weather_checked_at"],
|
||||||
|
"weather_cached": frame_row["calendar_weather_cached"], "week_days": frame_row["calendar_week_days"],
|
||||||
|
"week_layout": frame_row["calendar_week_layout"],
|
||||||
|
"week_start_offset": frame_row["calendar_week_start_offset"]})
|
||||||
|
|
||||||
|
def insert_whiteboard_config(widget_id):
|
||||||
|
conn.execute(text(
|
||||||
|
"INSERT INTO whiteboard_widget_configs (widget_id, user_id, url, checked_at, cached_image, "
|
||||||
|
"render_style) VALUES (:widget_id, :user_id, :url, :checked_at, :cached_image, 'classic')"
|
||||||
|
), {"widget_id": widget_id, "user_id": frame_row["whiteboard_user_id"], "url": frame_row["whiteboard_url"],
|
||||||
|
"checked_at": frame_row["whiteboard_checked_at"], "cached_image": frame_row["whiteboard_cached_image"]})
|
||||||
|
|
||||||
|
def insert_button_actions(widget_id, widget_type):
|
||||||
|
if widget_type == "whiteboard":
|
||||||
|
pairs = [("next", "check_now"), ("back", "check_now")]
|
||||||
|
elif widget_type in ("photos", "calendar"):
|
||||||
|
pairs = [("next", "advance"), ("back", "back")]
|
||||||
|
else:
|
||||||
|
pairs = []
|
||||||
|
for button, action in pairs:
|
||||||
|
conn.execute(text(
|
||||||
|
"INSERT INTO frame_button_actions (frame_id, button, widget_id, action, sort_order, created_at) "
|
||||||
|
"VALUES (:frame_id, :button, :widget_id, :action, 0, :created_at)"
|
||||||
|
), {"frame_id": frame_id, "button": button, "widget_id": widget_id, "action": action,
|
||||||
|
"created_at": now})
|
||||||
|
|
||||||
|
def maybe_add_tasks_widget(existing_rects, next_sort_order):
|
||||||
|
if not frame_row["calendar_tasks_calendar_key"] or not frame_row["calendar_tasks_user_id"]:
|
||||||
|
return
|
||||||
|
min_w, min_h = grid.MIN_FOOTPRINT["tasks"]
|
||||||
|
rect = grid.find_open_rect(orientation, existing_rects, min_w, min_h)
|
||||||
|
if rect is None:
|
||||||
|
logger.warning(
|
||||||
|
"Frame %d had a legacy task list configured but no open grid space for a "
|
||||||
|
"standalone tasks widget during backfill -- its task source was dropped", frame_id
|
||||||
|
)
|
||||||
|
return
|
||||||
|
x, y, w, h = rect
|
||||||
|
widget_id = insert_widget(x, y, w, h, "tasks", next_sort_order)
|
||||||
|
conn.execute(text(
|
||||||
|
"INSERT INTO task_widget_configs (widget_id, checked_at, cached, name, show_completed, "
|
||||||
|
"render_style) VALUES (:widget_id, :checked_at, :cached, '', 0, 'classic')"
|
||||||
|
), {"widget_id": widget_id, "checked_at": frame_row["calendar_tasks_checked_at"],
|
||||||
|
"cached": frame_row["calendar_tasks_cached"]})
|
||||||
|
conn.execute(text(
|
||||||
|
"INSERT INTO frame_task_lists (widget_id, user_id, calendar_key, included) "
|
||||||
|
"VALUES (:widget_id, :user_id, :calendar_key, 1)"
|
||||||
|
), {"widget_id": widget_id, "user_id": frame_row["calendar_tasks_user_id"],
|
||||||
|
"calendar_key": frame_row["calendar_tasks_calendar_key"]})
|
||||||
|
|
||||||
|
if mode == "calendar" and frame_row["calendar_photo_inlay"]:
|
||||||
|
half = cols // 2
|
||||||
|
cal_widget_id = insert_widget(0, 0, cols - half, rows, "calendar", 0)
|
||||||
|
photo_widget_id = insert_widget(cols - half, 0, half, rows, "photos", 1)
|
||||||
|
insert_calendar_config(cal_widget_id)
|
||||||
|
insert_photo_config(photo_widget_id)
|
||||||
|
insert_button_actions(cal_widget_id, "calendar")
|
||||||
|
maybe_add_tasks_widget([(0, 0, cols - half, rows), (cols - half, 0, half, rows)], 2)
|
||||||
|
return
|
||||||
|
|
||||||
|
widget_id = insert_widget(0, 0, cols, rows, mode, 0)
|
||||||
|
if mode == "photos":
|
||||||
|
insert_photo_config(widget_id)
|
||||||
|
elif mode == "calendar":
|
||||||
|
insert_calendar_config(widget_id)
|
||||||
|
elif mode == "whiteboard":
|
||||||
|
insert_whiteboard_config(widget_id)
|
||||||
|
insert_button_actions(widget_id, mode)
|
||||||
|
if mode == "calendar":
|
||||||
|
maybe_add_tasks_widget([(0, 0, cols, rows)], 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_41(conn) -> None:
|
||||||
|
"""Drops the legacy per-mode Frame columns the widget system
|
||||||
|
(migration 16) superseded -- mode, the photo-queue fields (album_id/
|
||||||
|
photo_order/display_mode/queue_target_len/current_asset_id/
|
||||||
|
current_asset_set_at/queue/queue_cursor/history/excluded_asset_ids),
|
||||||
|
every calendar_* field, every whiteboard_* field, and
|
||||||
|
legacy_token_enabled (models.py's own removal, alongside auth.py
|
||||||
|
dropping the shared MANAGEMENT_TOKEN device/browser fallback it
|
||||||
|
gated -- see auth.py's module docstring) -- see docs/widgets.md's
|
||||||
|
Known Gaps, which deliberately left this open as a much larger blast
|
||||||
|
radius than this project's usual same-migration-drop convention.
|
||||||
|
|
||||||
|
_ensure_widgets_backfilled ran unconditionally at the end of every
|
||||||
|
startup from migration 16 until this one, so in practice every frame
|
||||||
|
already has a Widget built from these columns' values by now; the
|
||||||
|
backfill loop below (_raw_backfill_frame_widgets) is the same safety
|
||||||
|
net migration 17/18 used for their own column drops, covering the
|
||||||
|
edge case of a frame that somehow reached this point with none (e.g.
|
||||||
|
a very old, never-restarted backup).
|
||||||
|
|
||||||
|
Guarded on "mode" existing, same reasoning as migration 26/27/29/
|
||||||
|
30/40's own comments: frames IS dropped/recreated here (unlike
|
||||||
|
widgets/photo_widget_configs, which those migrations left alone),
|
||||||
|
but a fresh-install create_all() copy already reflects today's
|
||||||
|
models.py -- i.e. the post-this-migration shape, missing "mode"
|
||||||
|
entirely -- so a test replaying migrations 16+ from an old
|
||||||
|
schema_version without also reconstructing frames' pre-41 columns
|
||||||
|
would otherwise hit "no such column: mode" here even though it has
|
||||||
|
nothing to do with what that test is actually exercising."""
|
||||||
|
if "mode" not in {c["name"] for c in inspect(conn).get_columns("frames")}:
|
||||||
|
return
|
||||||
|
now = time.time()
|
||||||
|
frame_rows = conn.execute(text(
|
||||||
|
"SELECT id, mode, orientation, album_id, photo_order, display_mode, queue_target_len, "
|
||||||
|
"current_asset_id, current_asset_set_at, queue, queue_cursor, history, excluded_asset_ids, "
|
||||||
|
"calendar_view, calendar_week_start, calendar_photo_inlay, calendar_browse_offset, "
|
||||||
|
"calendar_checked_at, calendar_cached_events, calendar_fetch_summary, calendar_weather_enabled, "
|
||||||
|
"calendar_weather_units, calendar_weather_cities, calendar_weather_checked_at, "
|
||||||
|
"calendar_weather_cached, calendar_week_days, calendar_week_layout, calendar_week_start_offset, "
|
||||||
|
"calendar_tasks_calendar_key, calendar_tasks_user_id, calendar_tasks_checked_at, "
|
||||||
|
"calendar_tasks_cached, whiteboard_user_id, whiteboard_url, whiteboard_checked_at, "
|
||||||
|
"whiteboard_cached_image FROM frames"
|
||||||
|
)).mappings().all()
|
||||||
|
|
||||||
|
for row in frame_rows:
|
||||||
|
has_widget = conn.execute(
|
||||||
|
text("SELECT 1 FROM widgets WHERE frame_id = :fid LIMIT 1"), {"fid": row["id"]}
|
||||||
|
).first()
|
||||||
|
if has_widget is None:
|
||||||
|
_raw_backfill_frame_widgets(conn, row, now)
|
||||||
|
|
||||||
|
conn.execute(text(
|
||||||
|
"CREATE TABLE frames_new ("
|
||||||
|
"id INTEGER PRIMARY KEY, "
|
||||||
|
"device_id TEXT UNIQUE, "
|
||||||
|
"name TEXT NOT NULL DEFAULT '', "
|
||||||
|
"owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
|
||||||
|
"controlled_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
|
||||||
|
"device_token TEXT NOT NULL, "
|
||||||
|
"device_token_ack INTEGER NOT NULL DEFAULT 0, "
|
||||||
|
"manage_token TEXT NOT NULL UNIQUE, "
|
||||||
|
"claimed_at REAL, "
|
||||||
|
"created_at REAL NOT NULL DEFAULT 0.0, "
|
||||||
|
"immich_url TEXT NOT NULL DEFAULT '', "
|
||||||
|
"immich_api_key TEXT NOT NULL DEFAULT '', "
|
||||||
|
"refresh_interval_s INTEGER NOT NULL DEFAULT 3600, "
|
||||||
|
"quiet_hours_enabled INTEGER NOT NULL DEFAULT 0, "
|
||||||
|
"quiet_hours_start TEXT NOT NULL DEFAULT '22:00', "
|
||||||
|
"quiet_hours_end TEXT NOT NULL DEFAULT '07:00', "
|
||||||
|
"timezone TEXT NOT NULL DEFAULT 'UTC', "
|
||||||
|
"orientation TEXT NOT NULL DEFAULT 'landscape', "
|
||||||
|
"palette_rgb TEXT, "
|
||||||
|
"color_boost REAL NOT NULL DEFAULT 1.0, "
|
||||||
|
"contrast_boost REAL NOT NULL DEFAULT 1.0, "
|
||||||
|
"dither_strength REAL NOT NULL DEFAULT 1.0, "
|
||||||
|
"photo_palette_rgb TEXT, "
|
||||||
|
"photo_dither_strength REAL NOT NULL DEFAULT 1.0, "
|
||||||
|
"theme TEXT NOT NULL DEFAULT 'classic', "
|
||||||
|
"battery_percent INTEGER NOT NULL DEFAULT -1, "
|
||||||
|
"battery_as_of REAL NOT NULL DEFAULT 0.0, "
|
||||||
|
"battery_history TEXT NOT NULL DEFAULT '[]', "
|
||||||
|
"last_seen REAL NOT NULL DEFAULT 0.0, "
|
||||||
|
"device_firmware_version TEXT NOT NULL DEFAULT '', "
|
||||||
|
"device_board_variant TEXT NOT NULL DEFAULT '', "
|
||||||
|
"battery_alert_threshold_pct INTEGER NOT NULL DEFAULT -1, "
|
||||||
|
"battery_alert_sent INTEGER NOT NULL DEFAULT 0, "
|
||||||
|
"firmware_available_version TEXT NOT NULL DEFAULT '', "
|
||||||
|
"firmware_update_repo_url TEXT NOT NULL DEFAULT '', "
|
||||||
|
"firmware_auto_update INTEGER NOT NULL DEFAULT 0, "
|
||||||
|
"firmware_update_token TEXT NOT NULL DEFAULT '', "
|
||||||
|
"firmware_update_checked_at REAL NOT NULL DEFAULT 0.0, "
|
||||||
|
"firmware_gitea_latest_version TEXT NOT NULL DEFAULT '', "
|
||||||
|
"hold_duration_ms INTEGER NOT NULL DEFAULT 3000, "
|
||||||
|
"next_hold_action TEXT, "
|
||||||
|
"back_hold_action TEXT, "
|
||||||
|
"last_cycled_layout_id INTEGER, "
|
||||||
|
"last_displayed_image BLOB, "
|
||||||
|
"last_displayed_at REAL NOT NULL DEFAULT 0.0, "
|
||||||
|
"stats_first_seen REAL NOT NULL DEFAULT 0.0, "
|
||||||
|
"stats_device_wakes INTEGER NOT NULL DEFAULT 0, "
|
||||||
|
"stats_photos_displayed INTEGER NOT NULL DEFAULT 0, "
|
||||||
|
"stats_photos_removed INTEGER NOT NULL DEFAULT 0, "
|
||||||
|
"stats_battery_reports INTEGER NOT NULL DEFAULT 0, "
|
||||||
|
"stats_recharge_cycles INTEGER NOT NULL DEFAULT 0, "
|
||||||
|
"stats_ota_updates_applied INTEGER NOT NULL DEFAULT 0, "
|
||||||
|
"stats_config_saves INTEGER NOT NULL DEFAULT 0)"
|
||||||
|
))
|
||||||
|
kept_columns = (
|
||||||
|
"id, device_id, name, owner_user_id, controlled_by_user_id, device_token, device_token_ack, "
|
||||||
|
"manage_token, claimed_at, created_at, immich_url, immich_api_key, refresh_interval_s, "
|
||||||
|
"quiet_hours_enabled, quiet_hours_start, quiet_hours_end, timezone, orientation, palette_rgb, "
|
||||||
|
"color_boost, contrast_boost, dither_strength, photo_palette_rgb, photo_dither_strength, theme, "
|
||||||
|
"battery_percent, battery_as_of, battery_history, last_seen, device_firmware_version, "
|
||||||
|
"device_board_variant, battery_alert_threshold_pct, battery_alert_sent, "
|
||||||
|
"firmware_available_version, firmware_update_repo_url, firmware_auto_update, "
|
||||||
|
"firmware_update_token, firmware_update_checked_at, firmware_gitea_latest_version, "
|
||||||
|
"hold_duration_ms, next_hold_action, back_hold_action, last_cycled_layout_id, "
|
||||||
|
"last_displayed_image, last_displayed_at, stats_first_seen, stats_device_wakes, "
|
||||||
|
"stats_photos_displayed, stats_photos_removed, stats_battery_reports, stats_recharge_cycles, "
|
||||||
|
"stats_ota_updates_applied, stats_config_saves"
|
||||||
|
)
|
||||||
|
conn.execute(text(f"INSERT INTO frames_new ({kept_columns}) SELECT {kept_columns} FROM frames"))
|
||||||
|
conn.execute(text("DROP TABLE frames"))
|
||||||
|
conn.execute(text("ALTER TABLE frames_new RENAME TO frames"))
|
||||||
|
|
||||||
|
|
||||||
MIGRATIONS = [
|
MIGRATIONS = [
|
||||||
(1, _migration_1),
|
(1, _migration_1),
|
||||||
(2, _migration_2),
|
(2, _migration_2),
|
||||||
@@ -648,6 +1181,24 @@ MIGRATIONS = [
|
|||||||
(21, _migration_21),
|
(21, _migration_21),
|
||||||
(22, _migration_22),
|
(22, _migration_22),
|
||||||
(23, _migration_23),
|
(23, _migration_23),
|
||||||
|
(24, _migration_24),
|
||||||
|
(25, _migration_25),
|
||||||
|
(26, _migration_26),
|
||||||
|
(27, _migration_27),
|
||||||
|
(28, _migration_28),
|
||||||
|
(29, _migration_29),
|
||||||
|
(30, _migration_30),
|
||||||
|
(31, _migration_31),
|
||||||
|
(32, _migration_32),
|
||||||
|
(33, _migration_33),
|
||||||
|
(34, _migration_34),
|
||||||
|
(35, _migration_35),
|
||||||
|
(36, _migration_36),
|
||||||
|
(37, _migration_37),
|
||||||
|
(38, _migration_38),
|
||||||
|
(39, _migration_39),
|
||||||
|
(40, _migration_40),
|
||||||
|
(41, _migration_41),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -664,18 +1215,53 @@ def run_migrations() -> None:
|
|||||||
# already added (e.g. "duplicate column name"). Jump
|
# already added (e.g. "duplicate column name"). Jump
|
||||||
# straight to the latest version instead.
|
# straight to the latest version instead.
|
||||||
_migration_1(conn)
|
_migration_1(conn)
|
||||||
latest = MIGRATIONS[-1][0]
|
current = MIGRATIONS[-1][0]
|
||||||
conn.execute(text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": latest})
|
conn.execute(text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": current})
|
||||||
else:
|
else:
|
||||||
current = row[0]
|
current = row[0]
|
||||||
for version, fn in MIGRATIONS:
|
|
||||||
if version > current:
|
# Each migration commits in its own transaction (rather than the
|
||||||
logger.info("Applying schema migration %d", version)
|
# whole batch sharing one, like this used to) so that _migration_41
|
||||||
fn(conn)
|
# can get a connection with no transaction pending on it yet --
|
||||||
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
|
# SQLite only honors toggling PRAGMA foreign_keys when issued as a
|
||||||
|
# connection's literal first statement, and it needs that off for
|
||||||
|
# its own DROP TABLE frames (frames is an ON DELETE CASCADE target
|
||||||
|
# for widgets/frame_button_actions/user_frames/etc, so leaving
|
||||||
|
# enforcement on there would cascade-delete every frame's widgets,
|
||||||
|
# not just the columns that migration means to drop). A crash
|
||||||
|
# partway through now simply leaves schema_version at the last
|
||||||
|
# migration that actually completed, same as it always could
|
||||||
|
# between separate runs of this function.
|
||||||
|
for version, fn in MIGRATIONS:
|
||||||
|
if version <= current:
|
||||||
|
continue
|
||||||
|
logger.info("Applying schema migration %d", version)
|
||||||
|
with engine.connect() as conn:
|
||||||
|
if fn is _migration_41:
|
||||||
|
# Executing this before anything else auto-begins
|
||||||
|
# SQLAlchemy's own Transaction bookkeeping too, so an
|
||||||
|
# explicit conn.begin() below would conflict with it --
|
||||||
|
# fn(conn) and the version UPDATE just ride that same
|
||||||
|
# auto-begun transaction, committed explicitly at the end.
|
||||||
|
conn.execute(text("PRAGMA foreign_keys=OFF"))
|
||||||
|
fn(conn)
|
||||||
|
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
|
||||||
|
conn.commit()
|
||||||
|
if fn is _migration_41:
|
||||||
|
# Restore it before this connection goes back to the
|
||||||
|
# pool -- otherwise a later checkout of the same
|
||||||
|
# underlying DBAPI connection (the connect-event
|
||||||
|
# listener in db.py only fires for a genuinely new one)
|
||||||
|
# would silently run with enforcement off. Has to
|
||||||
|
# happen AFTER commit(), same "no pending transaction"
|
||||||
|
# requirement as the OFF toggle above -- issuing it
|
||||||
|
# before the commit is exactly the mid-transaction
|
||||||
|
# no-op this migration exists to work around in the
|
||||||
|
# first place, just in the other direction.
|
||||||
|
conn.execute(text("PRAGMA foreign_keys=ON"))
|
||||||
|
|
||||||
_ensure_frame_one()
|
_ensure_frame_one()
|
||||||
_ensure_server_settings()
|
_ensure_server_settings()
|
||||||
_ensure_widgets_backfilled()
|
|
||||||
_ensure_frame_calendars_rekeyed()
|
_ensure_frame_calendars_rekeyed()
|
||||||
|
|
||||||
|
|
||||||
@@ -690,11 +1276,11 @@ def new_manage_token() -> str:
|
|||||||
def _ensure_frame_one() -> None:
|
def _ensure_frame_one() -> None:
|
||||||
"""First boot only (frames table empty): create frame #1 -- imported
|
"""First boot only (frames table empty): create frame #1 -- imported
|
||||||
verbatim from a legacy config.json if one exists, otherwise fresh
|
verbatim from a legacy config.json if one exists, otherwise fresh
|
||||||
defaults. Either way it's the legacy-token frame: the deployed
|
defaults -- plus a single full-panel photos widget carrying over
|
||||||
firmware sends no device id and (at most) the shared MANAGEMENT_TOKEN,
|
whatever photo-queue state that file had (the widget system's
|
||||||
and require_device resolves those requests here. The frames-nonempty
|
equivalent of what used to live directly on Frame; see migration
|
||||||
guard makes this idempotent; config.json is left untouched as the
|
41). The frames-nonempty guard makes this idempotent; config.json is
|
||||||
rollback path."""
|
left untouched as the rollback path."""
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
if db.scalars(select(Frame).limit(1)).first() is not None:
|
if db.scalars(select(Frame).limit(1)).first() is not None:
|
||||||
return
|
return
|
||||||
@@ -707,26 +1293,15 @@ def _ensure_frame_one() -> None:
|
|||||||
device_id=None,
|
device_id=None,
|
||||||
device_token=new_device_token(),
|
device_token=new_device_token(),
|
||||||
manage_token=new_manage_token(),
|
manage_token=new_manage_token(),
|
||||||
legacy_token_enabled=True,
|
|
||||||
created_at=time.time(),
|
created_at=time.time(),
|
||||||
immich_url=cfg.immich_url,
|
immich_url=cfg.immich_url,
|
||||||
immich_api_key=cfg.immich_api_key,
|
immich_api_key=cfg.immich_api_key,
|
||||||
album_id=cfg.album_id,
|
|
||||||
order=cfg.order,
|
|
||||||
refresh_interval_s=cfg.refresh_interval_s,
|
refresh_interval_s=cfg.refresh_interval_s,
|
||||||
quiet_hours_enabled=cfg.quiet_hours_enabled,
|
quiet_hours_enabled=cfg.quiet_hours_enabled,
|
||||||
quiet_hours_start=cfg.quiet_hours_start,
|
quiet_hours_start=cfg.quiet_hours_start,
|
||||||
quiet_hours_end=cfg.quiet_hours_end,
|
quiet_hours_end=cfg.quiet_hours_end,
|
||||||
timezone=cfg.timezone,
|
timezone=cfg.timezone,
|
||||||
display_mode="crop_faces" if cfg.smart_crop_faces else "crop_fill",
|
|
||||||
orientation=cfg.orientation,
|
orientation=cfg.orientation,
|
||||||
queue_target_len=cfg.queue_target_len,
|
|
||||||
current_asset_id=cfg.current_asset_id,
|
|
||||||
current_asset_set_at=cfg.current_asset_set_at,
|
|
||||||
queue=list(cfg.queue),
|
|
||||||
queue_cursor=cfg.queue_cursor,
|
|
||||||
history=list(cfg.history),
|
|
||||||
excluded_asset_ids=list(cfg.excluded_asset_ids),
|
|
||||||
battery_percent=cfg.battery_percent,
|
battery_percent=cfg.battery_percent,
|
||||||
battery_as_of=cfg.battery_as_of,
|
battery_as_of=cfg.battery_as_of,
|
||||||
battery_history=[list(pair) for pair in cfg.battery_history],
|
battery_history=[list(pair) for pair in cfg.battery_history],
|
||||||
@@ -749,11 +1324,31 @@ def _ensure_frame_one() -> None:
|
|||||||
stats_config_saves=cfg.stats.config_saves,
|
stats_config_saves=cfg.stats.config_saves,
|
||||||
)
|
)
|
||||||
db.add(frame)
|
db.add(frame)
|
||||||
db.flush() # assign frame.id for the battery log rows
|
db.flush() # assign frame.id for the battery log rows + widget FK
|
||||||
|
|
||||||
for pair in cfg.battery_log:
|
for pair in cfg.battery_log:
|
||||||
db.add(BatteryLog(frame_id=frame.id, ts=pair[0], percent=pair[1]))
|
db.add(BatteryLog(frame_id=frame.id, ts=pair[0], percent=pair[1]))
|
||||||
|
|
||||||
|
cols, rows = grid.grid_dims(frame.orientation)
|
||||||
|
widget = Widget(frame_id=frame.id, widget_type="photos", x=0, y=0, w=cols, h=rows,
|
||||||
|
sort_order=0, created_at=time.time())
|
||||||
|
db.add(widget)
|
||||||
|
db.flush() # assign widget.id for the config row's FK
|
||||||
|
db.add(PhotoWidgetConfig(
|
||||||
|
widget_id=widget.id,
|
||||||
|
album_id=cfg.album_id,
|
||||||
|
order=cfg.order,
|
||||||
|
display_mode="crop_faces" if cfg.smart_crop_faces else "crop_fill",
|
||||||
|
queue_target_len=cfg.queue_target_len,
|
||||||
|
current_asset_id=cfg.current_asset_id,
|
||||||
|
current_asset_set_at=cfg.current_asset_set_at,
|
||||||
|
queue=list(cfg.queue),
|
||||||
|
queue_cursor=cfg.queue_cursor,
|
||||||
|
history=list(cfg.history),
|
||||||
|
excluded_asset_ids=list(cfg.excluded_asset_ids),
|
||||||
|
))
|
||||||
|
db.add_all(default_button_actions(frame.id, widget.id, "photos"))
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# The single legacy firmware slot becomes frame #1's per-frame slot.
|
# The single legacy firmware slot becomes frame #1's per-frame slot.
|
||||||
@@ -783,188 +1378,6 @@ def _ensure_server_settings() -> None:
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def _photo_config_from_frame(frame: Frame, widget_id: int) -> PhotoWidgetConfig:
|
|
||||||
return PhotoWidgetConfig(
|
|
||||||
widget_id=widget_id,
|
|
||||||
album_id=frame.album_id,
|
|
||||||
order=frame.order,
|
|
||||||
display_mode=frame.display_mode,
|
|
||||||
queue_target_len=frame.queue_target_len,
|
|
||||||
current_asset_id=frame.current_asset_id,
|
|
||||||
current_asset_set_at=frame.current_asset_set_at,
|
|
||||||
queue=list(frame.queue),
|
|
||||||
queue_cursor=frame.queue_cursor,
|
|
||||||
history=list(frame.history),
|
|
||||||
excluded_asset_ids=list(frame.excluded_asset_ids),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _calendar_config_from_frame(frame: Frame, widget_id: int) -> CalendarWidgetConfig:
|
|
||||||
return CalendarWidgetConfig(
|
|
||||||
widget_id=widget_id,
|
|
||||||
view=frame.calendar_view,
|
|
||||||
week_start=frame.calendar_week_start,
|
|
||||||
browse_offset=frame.calendar_browse_offset,
|
|
||||||
checked_at=frame.calendar_checked_at,
|
|
||||||
cached_events=list(frame.calendar_cached_events) if frame.calendar_cached_events else None,
|
|
||||||
fetch_summary=frame.calendar_fetch_summary,
|
|
||||||
weather_enabled=frame.calendar_weather_enabled,
|
|
||||||
weather_units=frame.calendar_weather_units,
|
|
||||||
weather_cities=list(frame.calendar_weather_cities) if frame.calendar_weather_cities else None,
|
|
||||||
weather_checked_at=frame.calendar_weather_checked_at,
|
|
||||||
weather_cached=list(frame.calendar_weather_cached) if frame.calendar_weather_cached else None,
|
|
||||||
week_days=frame.calendar_week_days,
|
|
||||||
week_layout=frame.calendar_week_layout,
|
|
||||||
week_start_offset=frame.calendar_week_start_offset,
|
|
||||||
# tasks_* deliberately not carried over -- see
|
|
||||||
# _task_config_and_list_from_frame, a sibling standalone widget
|
|
||||||
# now, not part of this config.
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _task_config_and_list_from_frame(frame: Frame, widget_id: int) -> tuple[TaskWidgetConfig, FrameTaskList]:
|
|
||||||
"""Only ever called for a frame whose legacy calendar_tasks_* columns
|
|
||||||
(see Frame's own docstring on those -- a dead pre-widget-system
|
|
||||||
field set, same status as calendar_photo_inlay below) still carry a
|
|
||||||
configured source -- i.e. a database jumping straight from before
|
|
||||||
the widget system existed to after tasks became their own
|
|
||||||
multi-list widget type in a single upgrade, skipping both
|
|
||||||
intermediate periods where it would have lived on
|
|
||||||
CalendarWidgetConfig (_migration_17's extraction) and then a
|
|
||||||
single-source TaskWidgetConfig (_migration_18's extraction) instead.
|
|
||||||
Reproduces the same shape those two migrations arrive at directly:
|
|
||||||
a bare cache-state config plus one included FrameTaskList row."""
|
|
||||||
cfg = TaskWidgetConfig(
|
|
||||||
widget_id=widget_id,
|
|
||||||
checked_at=frame.calendar_tasks_checked_at,
|
|
||||||
cached=list(frame.calendar_tasks_cached) if frame.calendar_tasks_cached else None,
|
|
||||||
)
|
|
||||||
task_list = FrameTaskList(
|
|
||||||
widget_id=widget_id,
|
|
||||||
user_id=frame.calendar_tasks_user_id,
|
|
||||||
calendar_key=frame.calendar_tasks_calendar_key,
|
|
||||||
included=True,
|
|
||||||
)
|
|
||||||
return cfg, task_list
|
|
||||||
|
|
||||||
|
|
||||||
def _whiteboard_config_from_frame(frame: Frame, widget_id: int) -> WhiteboardWidgetConfig:
|
|
||||||
return WhiteboardWidgetConfig(
|
|
||||||
widget_id=widget_id,
|
|
||||||
user_id=frame.whiteboard_user_id,
|
|
||||||
url=frame.whiteboard_url,
|
|
||||||
checked_at=frame.whiteboard_checked_at,
|
|
||||||
cached_image=frame.whiteboard_cached_image,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _default_button_actions(frame_id: int, widget_id: int, widget_type: str) -> list[FrameButtonAction]:
|
|
||||||
"""NEXT/BACK -> whatever this widget's own advance/back concept is
|
|
||||||
(see app/widgets/ for the actual action registry, built in a later
|
|
||||||
phase) -- reproduces each mode's exact old button behavior for the
|
|
||||||
one auto-migrated widget, so upgrading changes nothing about what the
|
|
||||||
physical buttons do until someone deliberately reassigns them."""
|
|
||||||
if widget_type == "whiteboard":
|
|
||||||
# No real "next"/"back" concept for a static board -- both
|
|
||||||
# buttons already meant "check now" before this migration (see
|
|
||||||
# the old _advance_whiteboard_mode/_back_whiteboard_mode).
|
|
||||||
return [
|
|
||||||
FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="check_now"),
|
|
||||||
FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="check_now"),
|
|
||||||
]
|
|
||||||
return [
|
|
||||||
FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="advance"),
|
|
||||||
FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="back"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _maybe_add_legacy_tasks_widget(db, frame: Frame, existing: list[grid.Rect], next_sort_order: int) -> None:
|
|
||||||
"""Only relevant for a database jumping straight from before the
|
|
||||||
widget system existed to after tasks became their own widget type
|
|
||||||
in one upgrade (see _task_config_and_list_from_frame) --
|
|
||||||
frame.calendar_tasks_* is the dead legacy field set otherwise.
|
|
||||||
Requires both calendar_key and user_id (FrameTaskList.user_id is
|
|
||||||
NOT NULL) -- same guard _migration_18's own SQL extraction uses.
|
|
||||||
Auto-placed in whatever open space is left after the widget(s) above
|
|
||||||
it in _backfill_frame_widgets claimed theirs, same find_open_rect
|
|
||||||
logic a manual "add widget" uses; silently dropped (logged) if none
|
|
||||||
fits, same as this migration having nowhere else to put it either."""
|
|
||||||
if not frame.calendar_tasks_calendar_key or not frame.calendar_tasks_user_id:
|
|
||||||
return
|
|
||||||
min_w, min_h = grid.MIN_FOOTPRINT["tasks"]
|
|
||||||
rect = grid.find_open_rect(frame.orientation, existing, min_w, min_h)
|
|
||||||
if rect is None:
|
|
||||||
logger.warning(
|
|
||||||
"Frame %d had a legacy task list configured but no open grid space for a "
|
|
||||||
"standalone tasks widget during backfill -- its task source was dropped", frame.id
|
|
||||||
)
|
|
||||||
return
|
|
||||||
x, y, w, h = rect
|
|
||||||
task_widget = Widget(frame_id=frame.id, widget_type="tasks", x=x, y=y, w=w, h=h,
|
|
||||||
sort_order=next_sort_order, created_at=time.time())
|
|
||||||
db.add(task_widget)
|
|
||||||
db.flush()
|
|
||||||
cfg, task_list = _task_config_and_list_from_frame(frame, task_widget.id)
|
|
||||||
db.add(cfg)
|
|
||||||
db.add(task_list)
|
|
||||||
|
|
||||||
|
|
||||||
def _backfill_frame_widgets(db, frame: Frame) -> None:
|
|
||||||
cols, rows = grid.grid_dims(frame.orientation)
|
|
||||||
mode = frame.mode if frame.mode in ("photos", "calendar", "whiteboard") else "photos"
|
|
||||||
|
|
||||||
if mode == "calendar" and frame.calendar_photo_inlay:
|
|
||||||
# Reproduces the old fixed 50/50 inlay split as two independent
|
|
||||||
# widgets instead of silently dropping half of what the frame was
|
|
||||||
# showing -- see models.py's CalendarWidgetConfig docstring on why
|
|
||||||
# "photo inlay" isn't a widget-system concept anymore otherwise.
|
|
||||||
half = cols // 2
|
|
||||||
cal_widget = Widget(frame_id=frame.id, widget_type="calendar",
|
|
||||||
x=0, y=0, w=cols - half, h=rows, sort_order=0, created_at=time.time())
|
|
||||||
photo_widget = Widget(frame_id=frame.id, widget_type="photos",
|
|
||||||
x=cols - half, y=0, w=half, h=rows, sort_order=1, created_at=time.time())
|
|
||||||
db.add_all([cal_widget, photo_widget])
|
|
||||||
db.flush() # assign ids before the FK'd config rows reference them
|
|
||||||
db.add(_calendar_config_from_frame(frame, cal_widget.id))
|
|
||||||
db.add(_photo_config_from_frame(frame, photo_widget.id))
|
|
||||||
db.add_all(_default_button_actions(frame.id, cal_widget.id, "calendar"))
|
|
||||||
_maybe_add_legacy_tasks_widget(
|
|
||||||
db, frame, [(0, 0, cols - half, rows), (cols - half, 0, half, rows)], next_sort_order=2
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
widget = Widget(frame_id=frame.id, widget_type=mode, x=0, y=0, w=cols, h=rows,
|
|
||||||
sort_order=0, created_at=time.time())
|
|
||||||
db.add(widget)
|
|
||||||
db.flush()
|
|
||||||
if mode == "photos":
|
|
||||||
db.add(_photo_config_from_frame(frame, widget.id))
|
|
||||||
elif mode == "calendar":
|
|
||||||
db.add(_calendar_config_from_frame(frame, widget.id))
|
|
||||||
elif mode == "whiteboard":
|
|
||||||
db.add(_whiteboard_config_from_frame(frame, widget.id))
|
|
||||||
db.add_all(_default_button_actions(frame.id, widget.id, mode))
|
|
||||||
if mode == "calendar":
|
|
||||||
_maybe_add_legacy_tasks_widget(db, frame, [(0, 0, cols, rows)], next_sort_order=1)
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_widgets_backfilled() -> None:
|
|
||||||
"""Every frame needs at least one Widget once the widget system is
|
|
||||||
live -- runs unconditionally after every startup (both a from-scratch
|
|
||||||
_ensure_frame_one() install and an existing-install upgrade past
|
|
||||||
_migration_16 land here) and is a no-op for any frame that already
|
|
||||||
has one. Builds a widget that reproduces the frame's current mode/
|
|
||||||
settings/state exactly, so upgrading never changes what a frame
|
|
||||||
displays or what its physical buttons do on its own."""
|
|
||||||
with SessionLocal() as db:
|
|
||||||
for frame in db.scalars(select(Frame)).all():
|
|
||||||
has_widget = db.scalars(select(Widget).where(Widget.frame_id == frame.id).limit(1)).first()
|
|
||||||
if has_widget is not None:
|
|
||||||
continue
|
|
||||||
_backfill_frame_widgets(db, frame)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_frame_calendars_rekeyed() -> None:
|
def _ensure_frame_calendars_rekeyed() -> None:
|
||||||
"""Re-keys frame_calendars from frame_id to widget_id -- a frame can
|
"""Re-keys frame_calendars from frame_id to widget_id -- a frame can
|
||||||
hold more than one independent calendar widget (see the widget
|
hold more than one independent calendar widget (see the widget
|
||||||
@@ -977,27 +1390,25 @@ def _ensure_frame_calendars_rekeyed() -> None:
|
|||||||
and savable even while a frame's old `mode` was "photos"), not real
|
and savable even while a frame's old `mode` was "photos"), not real
|
||||||
live configuration.
|
live configuration.
|
||||||
|
|
||||||
Deliberately NOT a numbered migration: this needs each frame's
|
Deliberately NOT a numbered migration: every frame's calendar widget
|
||||||
calendar widget to already exist to know what to re-key against, and
|
must already exist to know what to re-key against, and for a genuine
|
||||||
those widget rows aren't created by a schema migration at all --
|
pre-widget-system database those rows only exist once _migration_41's
|
||||||
they come from _ensure_widgets_backfilled() above, which (like this
|
own backfill has run (a step inside that migration, not before it).
|
||||||
function) runs unconditionally after every startup rather than being
|
A numbered migration for this would race ahead of that backfill (the
|
||||||
tracked by schema_version. Running this as a numbered migration
|
numbered-migration loop runs top to bottom in one pass, see
|
||||||
would execute it *before* that backfill during a real upgrade (the
|
run_migrations), silently dropping every row -- caught by
|
||||||
numbered-migration loop runs first, see run_migrations), silently
|
test_migrations.py actually exercising the raw-SQL upgrade path
|
||||||
dropping every row -- caught by test_migrations.py actually exercising
|
instead of the fresh-install create_all() shortcut every other test
|
||||||
the raw-SQL upgrade path instead of the fresh-install create_all()
|
in that file takes.
|
||||||
shortcut every other test in that file takes.
|
|
||||||
|
|
||||||
Runs unconditionally after every startup, like _ensure_widgets_
|
Runs unconditionally after every startup instead; a no-op the moment
|
||||||
backfilled; a no-op the moment frame_calendars is already
|
frame_calendars is already widget_id-shaped (every fresh install,
|
||||||
widget_id-shaped (every fresh install, and any existing install
|
and any existing install after its first run past this code) --
|
||||||
after its first run past this code) -- SQLite can't ALTER a column's
|
SQLite can't ALTER a column's FK target or drop a column that's part
|
||||||
FK target or drop a column that's part of an index/FK constraint, so
|
of an index/FK constraint, so when it isn't a no-op this is the
|
||||||
when it isn't a no-op this is the standard SQLite "rebuild" pattern:
|
standard SQLite "rebuild" pattern: create the new-shape table, copy
|
||||||
create the new-shape table, copy matching rows across (joining to
|
matching rows across (joining to find each row's calendar widget),
|
||||||
find each row's calendar widget), drop the old table, rename the new
|
drop the old table, rename the new one into place."""
|
||||||
one into place."""
|
|
||||||
inspector = inspect(engine)
|
inspector = inspect(engine)
|
||||||
columns = {c["name"] for c in inspector.get_columns("frame_calendars")}
|
columns = {c["name"] for c in inspector.get_columns("frame_calendars")}
|
||||||
if "widget_id" in columns:
|
if "widget_id" in columns:
|
||||||
|
|||||||
+154
-134
@@ -117,14 +117,10 @@ class Frame(Base):
|
|||||||
__tablename__ = "frames"
|
__tablename__ = "frames"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
# 12 lowercase hex chars of the device's full STA MAC. NULL only for
|
# 12 lowercase hex chars of the device's full STA MAC. NULL until its
|
||||||
# the migrated legacy frame until its device first reports an id.
|
# device first reports an id.
|
||||||
device_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True)
|
device_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True)
|
||||||
name: Mapped[str] = mapped_column(String, default="")
|
name: Mapped[str] = mapped_column(String, default="")
|
||||||
# Renderer dispatch seam -- "photos", "calendar", or "whiteboard"
|
|
||||||
# (see routers/device.py RENDERERS/ADVANCE_RENDERERS/BACK_RENDERERS
|
|
||||||
# and routers/common.py FRAME_MODES).
|
|
||||||
mode: Mapped[str] = mapped_column(String, default="photos")
|
|
||||||
# Whose Immich library this frame pulls from; NULL = unclaimed.
|
# Whose Immich library this frame pulls from; NULL = unclaimed.
|
||||||
owner_user_id: Mapped[int | None] = mapped_column(
|
owner_user_id: Mapped[int | None] = mapped_column(
|
||||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
@@ -138,11 +134,6 @@ class Frame(Base):
|
|||||||
# pushing it in /frame/config responses.
|
# pushing it in /frame/config responses.
|
||||||
device_token_ack: Mapped[bool] = mapped_column(Boolean, default=False)
|
device_token_ack: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
manage_token: Mapped[str] = mapped_column(String, unique=True)
|
manage_token: Mapped[str] = mapped_column(String, unique=True)
|
||||||
# Migration window: this frame also accepts the legacy shared
|
|
||||||
# MANAGEMENT_TOKEN (and no-id requests resolve to it). Only ever the
|
|
||||||
# migrated frame #1; cleared from /admin once the device is on
|
|
||||||
# per-frame auth.
|
|
||||||
legacy_token_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
||||||
claimed_at: Mapped[float | None] = mapped_column(Float, nullable=True)
|
claimed_at: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||||
|
|
||||||
@@ -153,19 +144,13 @@ class Frame(Base):
|
|||||||
immich_url: Mapped[str] = mapped_column(String, default="")
|
immich_url: Mapped[str] = mapped_column(String, default="")
|
||||||
immich_api_key: Mapped[str] = mapped_column(String, default="")
|
immich_api_key: Mapped[str] = mapped_column(String, default="")
|
||||||
|
|
||||||
# -- settings (attribute names match the old FrameConfig fields) --
|
# -- settings --
|
||||||
album_id: Mapped[str] = mapped_column(String, default="")
|
|
||||||
order: Mapped[str] = mapped_column("photo_order", String, default="sequential")
|
|
||||||
refresh_interval_s: Mapped[int] = mapped_column(Integer, default=3600)
|
refresh_interval_s: Mapped[int] = mapped_column(Integer, default=3600)
|
||||||
quiet_hours_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
quiet_hours_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
quiet_hours_start: Mapped[str] = mapped_column(String, default="22:00")
|
quiet_hours_start: Mapped[str] = mapped_column(String, default="22:00")
|
||||||
quiet_hours_end: Mapped[str] = mapped_column(String, default="07:00")
|
quiet_hours_end: Mapped[str] = mapped_column(String, default="07:00")
|
||||||
timezone: Mapped[str] = mapped_column(String, default="UTC")
|
timezone: Mapped[str] = mapped_column(String, default="UTC")
|
||||||
# How a photo's aspect ratio is reconciled with the panel's -- see
|
|
||||||
# image_pipeline.DISPLAY_MODES.
|
|
||||||
display_mode: Mapped[str] = mapped_column(String, default="crop_faces")
|
|
||||||
orientation: Mapped[str] = mapped_column(String, default="landscape")
|
orientation: Mapped[str] = mapped_column(String, default="landscape")
|
||||||
queue_target_len: Mapped[int] = mapped_column(Integer, default=20)
|
|
||||||
# Advanced configuration: [[r,g,b], ...] x6 (black/white/yellow/red/
|
# Advanced configuration: [[r,g,b], ...] x6 (black/white/yellow/red/
|
||||||
# blue/green, matching image_pipeline.PANEL_CODES order) overriding
|
# blue/green, matching image_pipeline.PANEL_CODES order) overriding
|
||||||
# DEFAULT_PALETTE_RGB for this frame's actual panel. NULL = use the
|
# DEFAULT_PALETTE_RGB for this frame's actual panel. NULL = use the
|
||||||
@@ -178,113 +163,19 @@ class Frame(Base):
|
|||||||
# 0.0-1.0, see image_pipeline._quantize -- 1.0 matches this project's
|
# 0.0-1.0, see image_pipeline._quantize -- 1.0 matches this project's
|
||||||
# original always-on full-strength Floyd-Steinberg dithering.
|
# original always-on full-strength Floyd-Steinberg dithering.
|
||||||
dither_strength: Mapped[float] = mapped_column(Float, default=1.0)
|
dither_strength: Mapped[float] = mapped_column(Float, default=1.0)
|
||||||
|
# Same shape as palette_rgb/dither_strength above, but scoped to only
|
||||||
# -- calendar mode (see calendar_feed.py, calendar_render.py,
|
# the photos widget (widgets/photos.py quantizes against these itself,
|
||||||
# routers/device.py's RENDERERS["calendar"]) --
|
# before returning -- see its own docstring) -- lets a frame tune the
|
||||||
calendar_view: Mapped[str] = mapped_column(String, default="agenda") # "agenda" | "week" | "month"
|
# rest of its widgets' palette/dithering (e.g. a "modern" HTML-
|
||||||
# 0=Monday..6=Sunday (matches date.weekday()/calendar.Calendar) --
|
# rendered dashboard look) independently of what actually looks best
|
||||||
# which day week/month views start their grid on.
|
# for real photographs. NULL/1.0 = same defaults as the main fields.
|
||||||
calendar_week_start: Mapped[int] = mapped_column(Integer, default=0)
|
photo_palette_rgb: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||||
# Agenda view only; reuses this frame's existing photos-mode album/
|
photo_dither_strength: Mapped[float] = mapped_column(Float, default=1.0)
|
||||||
# queue, not a separate photo setup.
|
# Curated visual theme for "modern" (HTML/CSS) style widgets -- see
|
||||||
calendar_photo_inlay: Mapped[bool] = mapped_column(Boolean, default=False)
|
# theme_tokens.THEMES. Frame-level (like palette_rgb/dither_strength
|
||||||
# How many periods (unit depends on calendar_view: days/weeks/months)
|
# above), not per-widget, since a theme is "how this frame looks."
|
||||||
# NEXT/BACK have browsed from "today". Reset to 0 by the next normal
|
# Widgets rendered in classic (PIL) style ignore this entirely.
|
||||||
# (non-button) /frame/image request, and whenever calendar_view
|
theme: Mapped[str] = mapped_column(String, default="classic")
|
||||||
# itself changes -- a stale offset means something different in a
|
|
||||||
# different view's units.
|
|
||||||
calendar_browse_offset: Mapped[int] = mapped_column(Integer, default=0)
|
|
||||||
# Throttled merge-fetch cache (see routers/common.py's
|
|
||||||
# get_or_refresh_calendar_events) -- same shape as the
|
|
||||||
# firmware_update_checked_at/firmware_gitea_latest_version pattern
|
|
||||||
# below. One shared cache for every included user's merged events,
|
|
||||||
# not per-user.
|
|
||||||
calendar_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
|
||||||
calendar_cached_events: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
|
||||||
# "" when the last merge-fetch fully succeeded, else e.g. "1 of 2
|
|
||||||
# calendars unavailable" -- never names which user's feed failed, a
|
|
||||||
# shared household display shouldn't call out a specific person's
|
|
||||||
# outage to everyone who looks at it.
|
|
||||||
calendar_fetch_summary: Mapped[str] = mapped_column(String, default="")
|
|
||||||
|
|
||||||
# Optional weather strip, agenda/today & tomorrow/week views only --
|
|
||||||
# never month, there's no room (see calendar_render.py's _BUILDERS).
|
|
||||||
# Off by default.
|
|
||||||
calendar_weather_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
||||||
calendar_weather_units: Mapped[str] = mapped_column(String, default="fahrenheit") # "fahrenheit" | "celsius"
|
|
||||||
# [{"label", "latitude", "longitude"}, ...] -- each geocoded once via
|
|
||||||
# weather.geocode_city() when added from the Calendar tab.
|
|
||||||
calendar_weather_cities: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
|
||||||
# Throttled per-city forecast cache (see routers/common.py's
|
|
||||||
# get_or_refresh_weather) -- same shape idiom as
|
|
||||||
# calendar_checked_at/calendar_cached_events above.
|
|
||||||
# [{"label", "days": {"YYYY-MM-DD": {"code","high","low"}}}, ...]
|
|
||||||
calendar_weather_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
|
||||||
calendar_weather_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
|
||||||
|
|
||||||
# Week view: how many days to show (2-10, default 7 -- the original
|
|
||||||
# fixed behavior) and whether they're laid out as side-by-side
|
|
||||||
# columns or stacked bands (see calendar_render.py's _build_week).
|
|
||||||
calendar_week_days: Mapped[int] = mapped_column(Integer, default=7)
|
|
||||||
calendar_week_layout: Mapped[str] = mapped_column(String, default="horizontal") # "horizontal" | "vertical"
|
|
||||||
# Only used when calendar_week_days != 7 -- calendar_week_start's
|
|
||||||
# fixed-weekday anchor ("start on the most recent Monday") stops
|
|
||||||
# making sense once the view isn't a literal calendar week, so a
|
|
||||||
# non-7-day view instead starts this many days from today (0 =
|
|
||||||
# starts today, negative = starts in the past, positive = starts in
|
|
||||||
# the future). Ignored (calendar_week_start governs instead) at the
|
|
||||||
# default 7 days, so this has no effect until someone actually
|
|
||||||
# changes the day count.
|
|
||||||
calendar_week_start_offset: Mapped[int] = mapped_column(Integer, default=0)
|
|
||||||
|
|
||||||
# Optional task list, week view only -- takes the space of one day
|
|
||||||
# slot rather than adding an extra one (see calendar_render.py's
|
|
||||||
# _draw_tasks). CalDAV only (a task list is a VTODO collection, not
|
|
||||||
# something a plain ICS subscription meaningfully has); source is
|
|
||||||
# one specific linked user's own CalDAV calendar, same
|
|
||||||
# owner-controls-their-own-data permission split as FrameCalendar.
|
|
||||||
# calendar_tasks_user_id
|
|
||||||
# SET NULL on the user's deletion clears the source rather than
|
|
||||||
# leaving a dangling reference (checked_at isn't reset by that, but
|
|
||||||
# the next refresh attempt finds no source and just returns []).
|
|
||||||
calendar_tasks_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
||||||
calendar_tasks_user_id: Mapped[int | None] = mapped_column(
|
|
||||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
|
||||||
)
|
|
||||||
calendar_tasks_calendar_key: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
||||||
calendar_tasks_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
|
||||||
# [{"summary", "due" (ISO date/datetime string or None)}, ...],
|
|
||||||
# already filtered to outstanding (not-completed) tasks and sorted
|
|
||||||
# by due date -- see caldav_client.fetch_tasks.
|
|
||||||
calendar_tasks_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
|
||||||
|
|
||||||
# -- whiteboard mode (see webdav_client.py, whiteboard.py,
|
|
||||||
# routers/device.py's RENDERERS["whiteboard"]) -- a frame-wide
|
|
||||||
# setting like calendar mode's own frame_calendars source, not
|
|
||||||
# personal data, but still owner-gated the same way: only
|
|
||||||
# whiteboard_user_id may point the frame at their own account, since
|
|
||||||
# it's their credentials being used to fetch it. --
|
|
||||||
whiteboard_user_id: Mapped[int | None] = mapped_column(
|
|
||||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
|
||||||
)
|
|
||||||
# The specific .whiteboard file's WebDAV URL -- pasted directly, same
|
|
||||||
# idiom as calendar_ics_url, not discovered/browsed (unlike CalDAV's
|
|
||||||
# account-has-several-calendars case, a WebDAV account doesn't need
|
|
||||||
# a picker step here since the user already knows which one file).
|
|
||||||
whiteboard_url: Mapped[str] = mapped_column(String, default="")
|
|
||||||
whiteboard_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
|
||||||
# Cached rendered PNG bytes (see whiteboard.fetch_and_render) --
|
|
||||||
# BLOB rather than the JSON columns the rest of this cache-pattern
|
|
||||||
# family uses, since this is binary image data, not JSON-shaped.
|
|
||||||
whiteboard_cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
|
||||||
|
|
||||||
# -- state --
|
|
||||||
current_asset_id: Mapped[str] = mapped_column(String, default="")
|
|
||||||
current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0)
|
|
||||||
queue: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
|
|
||||||
queue_cursor: Mapped[int] = mapped_column(Integer, default=0)
|
|
||||||
history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
|
|
||||||
excluded_asset_ids: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
|
|
||||||
|
|
||||||
# -- telemetry --
|
# -- telemetry --
|
||||||
battery_percent: Mapped[int] = mapped_column(Integer, default=-1)
|
battery_percent: Mapped[int] = mapped_column(Integer, default=-1)
|
||||||
@@ -311,6 +202,33 @@ class Frame(Base):
|
|||||||
firmware_update_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
firmware_update_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
firmware_gitea_latest_version: Mapped[str] = mapped_column(String, default="")
|
firmware_gitea_latest_version: Mapped[str] = mapped_column(String, default="")
|
||||||
|
|
||||||
|
# -- hold-for-global-action (see app/global_actions.py) -- holding
|
||||||
|
# NEXT/BACK past hold_duration_ms triggers a global action instead of
|
||||||
|
# the per-widget one that a short press runs (models.FrameButtonAction).
|
||||||
|
# Not scoped to any widget, e.g. cycling saved layouts -- hence its
|
||||||
|
# own pair of frame-level columns rather than living in that table.
|
||||||
|
hold_duration_ms: Mapped[int] = mapped_column(Integer, default=3000)
|
||||||
|
next_hold_action: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
|
||||||
|
back_hold_action: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
|
||||||
|
# Where "cycle saved layouts" resumes from -- the last SavedLayout id
|
||||||
|
# it applied, so repeated holds advance through the list instead of
|
||||||
|
# re-applying the same one every time. Deliberately not a real FK:
|
||||||
|
# this is just a resume cursor, not a relationship needing cascade/
|
||||||
|
# referential integrity -- if that layout's since been deleted or
|
||||||
|
# renamed away, global_actions.cycle_layout just doesn't find it and
|
||||||
|
# starts over from the first one, same as an unset value.
|
||||||
|
last_cycled_layout_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
|
||||||
|
# -- "now displaying" (see routers/device.py's _record_last_displayed,
|
||||||
|
# api_frames.py's /now-displaying endpoint) -- exactly what the last
|
||||||
|
# device-facing render (/frame/image, /frame/advance, /frame/back, or
|
||||||
|
# a global hold action) actually sent, as an upright PNG, so the web
|
||||||
|
# UI's header preview can show it frozen alongside a live "up next"
|
||||||
|
# re-render instead of conflating the two. NULL until a real device
|
||||||
|
# has fetched at least once.
|
||||||
|
last_displayed_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True, default=None)
|
||||||
|
last_displayed_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
|
|
||||||
# -- stats (flattened from the old nested FrameStats) --
|
# -- stats (flattened from the old nested FrameStats) --
|
||||||
stats_first_seen: Mapped[float] = mapped_column(Float, default=0.0)
|
stats_first_seen: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
stats_device_wakes: Mapped[int] = mapped_column(Integer, default=0)
|
stats_device_wakes: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
@@ -433,7 +351,7 @@ class Widget(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
|
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
|
||||||
widget_type: Mapped[str] = mapped_column(String) # "photos" | "calendar" | "whiteboard" | "tasks"
|
widget_type: Mapped[str] = mapped_column(String) # "photos" | "calendar" | "whiteboard" | "tasks" | "static" | "text" | "weather" | "battery"
|
||||||
x: Mapped[int] = mapped_column(Integer)
|
x: Mapped[int] = mapped_column(Integer)
|
||||||
y: Mapped[int] = mapped_column(Integer)
|
y: Mapped[int] = mapped_column(Integer)
|
||||||
w: Mapped[int] = mapped_column(Integer)
|
w: Mapped[int] = mapped_column(Integer)
|
||||||
@@ -445,6 +363,26 @@ class Widget(Base):
|
|||||||
# table needs to match a specific attribute name here.
|
# table needs to match a specific attribute name here.
|
||||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||||
|
# Optional decorative border, drawn once around this widget's own
|
||||||
|
# region (routers/device.py's _render_widgets) regardless of
|
||||||
|
# widget_type -- a Widget-level property, not a per-type config
|
||||||
|
# column, since every widget type can have one. See
|
||||||
|
# image_pipeline.BORDER_STYLES/draw_widget_border. "none" (the
|
||||||
|
# default) draws nothing, so existing widgets don't suddenly grow a
|
||||||
|
# border. border_color_index indexes into the frame's palette_rgb
|
||||||
|
# (0-5, Black/White/Yellow/Red/Blue/Green) rather than storing an
|
||||||
|
# arbitrary hex -- an exact palette color quantizes with zero
|
||||||
|
# dithering error, same reasoning as the weather/battery icons'
|
||||||
|
# exact-panel-ink-RGB fills (see docs/widgets.md).
|
||||||
|
border_style: Mapped[str] = mapped_column(String, default="none")
|
||||||
|
border_thickness: Mapped[int] = mapped_column(Integer, default=3)
|
||||||
|
border_color_index: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
# Text-size multiplier for this widget's own body/title text -- another
|
||||||
|
# Widget-level property regardless of widget_type, same reasoning as
|
||||||
|
# border_style above (any widget type with text can use it). One of
|
||||||
|
# panel_style.FONT_SCALE_CHOICES; 1.0 (unchanged size) for every
|
||||||
|
# existing widget until its dialog's "Text size" picker sets it.
|
||||||
|
font_scale: Mapped[float] = mapped_column(Float, default=1.0)
|
||||||
|
|
||||||
__table_args__ = (Index("ix_widgets_frame", "frame_id"),)
|
__table_args__ = (Index("ix_widgets_frame", "frame_id"),)
|
||||||
|
|
||||||
@@ -469,6 +407,7 @@ class PhotoWidgetConfig(Base):
|
|||||||
queue_cursor: Mapped[int] = mapped_column(Integer, default=0)
|
queue_cursor: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
|
history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
|
||||||
excluded_asset_ids: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
|
excluded_asset_ids: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
|
||||||
|
locked: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|
||||||
|
|
||||||
class CalendarWidgetConfig(Base):
|
class CalendarWidgetConfig(Base):
|
||||||
@@ -500,6 +439,10 @@ class CalendarWidgetConfig(Base):
|
|||||||
week_days: Mapped[int] = mapped_column(Integer, default=7)
|
week_days: Mapped[int] = mapped_column(Integer, default=7)
|
||||||
week_layout: Mapped[str] = mapped_column(String, default="horizontal")
|
week_layout: Mapped[str] = mapped_column(String, default="horizontal")
|
||||||
week_start_offset: Mapped[int] = mapped_column(Integer, default=0)
|
week_start_offset: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
# classic (calendar_render.py) vs modern (app/calendar_html_render.py,
|
||||||
|
# agenda mode only so far -- see that module's docstring) -- see
|
||||||
|
# widgets/calendar.py's render().
|
||||||
|
render_style: Mapped[str] = mapped_column(String, default="classic")
|
||||||
|
|
||||||
|
|
||||||
class TaskWidgetConfig(Base):
|
class TaskWidgetConfig(Base):
|
||||||
@@ -538,6 +481,9 @@ class TaskWidgetConfig(Base):
|
|||||||
# outstanding ones -- off by default, same "opt into more" posture
|
# outstanding ones -- off by default, same "opt into more" posture
|
||||||
# as calendar_weather_enabled.
|
# as calendar_weather_enabled.
|
||||||
show_completed: Mapped[bool] = mapped_column(Boolean, default=False)
|
show_completed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
# classic (hand-drawn PIL, calendar_render._build_tasks) vs modern
|
||||||
|
# (app/html_render.py) -- see widgets/tasks.py's render().
|
||||||
|
render_style: Mapped[str] = mapped_column(String, default="classic")
|
||||||
|
|
||||||
|
|
||||||
class WhiteboardWidgetConfig(Base):
|
class WhiteboardWidgetConfig(Base):
|
||||||
@@ -551,6 +497,51 @@ class WhiteboardWidgetConfig(Base):
|
|||||||
url: Mapped[str] = mapped_column(String, default="")
|
url: Mapped[str] = mapped_column(String, default="")
|
||||||
checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||||
|
# classic (no chrome, unchanged) vs modern (app/html_render.py's
|
||||||
|
# rounded-corner shadowed card) -- see widgets/whiteboard.py's
|
||||||
|
# render().
|
||||||
|
render_style: Mapped[str] = mapped_column(String, default="classic")
|
||||||
|
|
||||||
|
|
||||||
|
class WeatherWidgetConfig(Base):
|
||||||
|
"""One weather widget's settings + cached-fetch state. Four display
|
||||||
|
modes (see app/widgets/weather.py): "current" (one city, current
|
||||||
|
temp + icon), "hourly" (one city, a row of ticks across the day),
|
||||||
|
"daily" (one city, a multi-day strip), "multi_city" (several cities'
|
||||||
|
current-day high/low/icon side by side -- the calendar widget's
|
||||||
|
embedded weather strip, lifted out into its own widget type).
|
||||||
|
`provider` selects which of app/weather/'s PROVIDERS actually fetches
|
||||||
|
("open_meteo" | "nws" -- see that package's own module docstring).
|
||||||
|
`cached`'s shape depends on `mode`: {"temp","category"} for current,
|
||||||
|
a list of {"time","temp","category"} for hourly, a
|
||||||
|
{"YYYY-MM-DD": {...}} dict for daily, or a list of
|
||||||
|
{"label","high","low","category"} for multi_city.
|
||||||
|
`render_style` picks which renderer draws the widget: "classic" (the
|
||||||
|
hand-drawn PIL primitives in app/weather_render.py, unchanged
|
||||||
|
default) or "modern" (app/html_render.py's Jinja2/headless-Chromium
|
||||||
|
path, "current"/"daily" modes only for now -- see weather.py's
|
||||||
|
render())."""
|
||||||
|
|
||||||
|
__tablename__ = "weather_widget_configs"
|
||||||
|
|
||||||
|
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
mode: Mapped[str] = mapped_column(String, default="current") # current | hourly | daily | multi_city
|
||||||
|
provider: Mapped[str] = mapped_column(String, default="open_meteo") # open_meteo | nws
|
||||||
|
render_style: Mapped[str] = mapped_column(String, default="classic") # classic | modern
|
||||||
|
units: Mapped[str] = mapped_column(String, default="fahrenheit") # fahrenheit | celsius
|
||||||
|
# Single-location modes only (current/hourly/daily) -- geocoded once
|
||||||
|
# via weather.geocode_city() when set, same idiom as
|
||||||
|
# CalendarWidgetConfig.weather_cities' per-entry shape.
|
||||||
|
city_label: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
|
city_latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
city_longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
hourly_interval_hours: Mapped[int] = mapped_column(Integer, default=4)
|
||||||
|
daily_days: Mapped[int] = mapped_column(Integer, default=5)
|
||||||
|
# multi_city mode only -- [{"label", "latitude", "longitude"}, ...],
|
||||||
|
# same shape as CalendarWidgetConfig.weather_cities.
|
||||||
|
cities: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||||
|
checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
|
cached: Mapped[dict | list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||||
|
|
||||||
|
|
||||||
class TextWidgetConfig(Base):
|
class TextWidgetConfig(Base):
|
||||||
@@ -585,6 +576,9 @@ class TextWidgetConfig(Base):
|
|||||||
font_family: Mapped[str] = mapped_column(String, default="sans")
|
font_family: Mapped[str] = mapped_column(String, default="sans")
|
||||||
align: Mapped[str] = mapped_column(String, default="left") # "left" | "center" | "right"
|
align: Mapped[str] = mapped_column(String, default="left") # "left" | "center" | "right"
|
||||||
background_color: Mapped[str] = mapped_column(String, default="#ffffff")
|
background_color: Mapped[str] = mapped_column(String, default="#ffffff")
|
||||||
|
# classic (hand-drawn PIL) vs modern (app/html_render.py) -- see
|
||||||
|
# widgets/text.py's render()/render_preview_png().
|
||||||
|
render_style: Mapped[str] = mapped_column(String, default="classic")
|
||||||
|
|
||||||
|
|
||||||
class StaticWidgetConfig(Base):
|
class StaticWidgetConfig(Base):
|
||||||
@@ -606,6 +600,28 @@ class StaticWidgetConfig(Base):
|
|||||||
# minus crop_faces -- no face detection for an uploaded image (see
|
# minus crop_faces -- no face detection for an uploaded image (see
|
||||||
# image_pipeline.STATIC_DISPLAY_MODES).
|
# image_pipeline.STATIC_DISPLAY_MODES).
|
||||||
display_mode: Mapped[str] = mapped_column(String, default="crop_fill")
|
display_mode: Mapped[str] = mapped_column(String, default="crop_fill")
|
||||||
|
# classic (no chrome, unchanged) vs modern (app/html_render.py's
|
||||||
|
# rounded-corner shadowed card) -- see widgets/static_image.py's
|
||||||
|
# render().
|
||||||
|
render_style: Mapped[str] = mapped_column(String, default="classic")
|
||||||
|
|
||||||
|
|
||||||
|
class BatteryWidgetConfig(Base):
|
||||||
|
"""One battery widget's display settings -- another no-live-upstream
|
||||||
|
type like StaticWidgetConfig/TextWidgetConfig, just showing existing
|
||||||
|
frame-level state (Frame.battery_percent/battery_as_of, already set
|
||||||
|
by routers/device.py's frame_battery on every device report) instead
|
||||||
|
of anything the widget itself fetches or the user authors. `mode`
|
||||||
|
"compact" is icon + percent only; "detailed" (default) adds the
|
||||||
|
routers.common.battery_estimate_s time-remaining estimate and the
|
||||||
|
last report's age. `render_style` picks classic (hand-drawn PIL) vs
|
||||||
|
modern (app/html_render.py) -- see widgets/battery.py's render()."""
|
||||||
|
|
||||||
|
__tablename__ = "battery_widget_configs"
|
||||||
|
|
||||||
|
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
mode: Mapped[str] = mapped_column(String, default="detailed") # compact | detailed
|
||||||
|
render_style: Mapped[str] = mapped_column(String, default="classic") # classic | modern
|
||||||
|
|
||||||
|
|
||||||
# widget_type -> its per-type extension table, keyed by widget_id. Used
|
# widget_type -> its per-type extension table, keyed by widget_id. Used
|
||||||
@@ -617,21 +633,24 @@ WIDGET_CONFIG_MODELS: dict[str, type] = {
|
|||||||
"whiteboard": WhiteboardWidgetConfig,
|
"whiteboard": WhiteboardWidgetConfig,
|
||||||
"tasks": TaskWidgetConfig,
|
"tasks": TaskWidgetConfig,
|
||||||
"static": StaticWidgetConfig,
|
"static": StaticWidgetConfig,
|
||||||
|
"battery": BatteryWidgetConfig,
|
||||||
"text": TextWidgetConfig,
|
"text": TextWidgetConfig,
|
||||||
|
"weather": WeatherWidgetConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class FrameButtonAction(Base):
|
class FrameButtonAction(Base):
|
||||||
"""One (widget, action) binding for one of a frame's two physical
|
"""One (widget, action) binding for one of a frame's two physical
|
||||||
buttons -- e.g. {button: "next", widget_id: <photo widget>, action:
|
buttons -- e.g. {button: "next", widget_id: <photo widget>, action:
|
||||||
"advance"}. A button can have several of these (sort_order gives
|
"advance"}. At most one binding per (widget, button) -- edited from
|
||||||
execution order); on a press, every row for that (frame, button) runs
|
that widget's own config dialog (routers/api_widgets.py's
|
||||||
-- see routers/device.py's frame_advance/frame_back. Deliberately
|
api_widget_config_save), prefilled with a sane default at widget
|
||||||
unconstrained about which widget/action pairs with which button (the
|
creation (app/widgets/default_button_actions). On a press, every
|
||||||
user's own idea for resolving "what does NEXT even mean with several
|
widget's row for that (frame, button) runs -- see routers/device.py's
|
||||||
widgets on screen": let them assign literally anything to either
|
frame_advance/frame_back. sort_order is unused (which widget's action
|
||||||
button, including mismatched combinations, rather than the server
|
runs first never matters: each only touches its own state, and one
|
||||||
guessing a sensible default)."""
|
shared re-render happens after all of them finish) but kept around so
|
||||||
|
dispatch has a stable, deterministic query order."""
|
||||||
|
|
||||||
__tablename__ = "frame_button_actions"
|
__tablename__ = "frame_button_actions"
|
||||||
|
|
||||||
@@ -645,6 +664,7 @@ class FrameButtonAction(Base):
|
|||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("ix_frame_button_actions_frame_button", "frame_id", "button", "sort_order"),
|
Index("ix_frame_button_actions_frame_button", "frame_id", "button", "sort_order"),
|
||||||
|
Index("ix_frame_button_actions_widget_button", "widget_id", "button", unique=True),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"""Shared visual language for everything drawn onto the e-ink panel
|
||||||
|
(excluding widgets/text.py, which already has its own richer multi-
|
||||||
|
family font picker and is left alone) -- spacing, ink-color resolution,
|
||||||
|
Inter font loading, and the small set of drawing primitives
|
||||||
|
(header bar, color chip, battery icon) more than one render module needs.
|
||||||
|
|
||||||
|
Centralizes what used to be independently redefined per render file
|
||||||
|
(calendar_render.py/weather_render.py each had their own MARGIN/BG/FG/
|
||||||
|
RULE, widgets/battery.py and manage_overlay.py each had their own
|
||||||
|
battery-glyph-drawing code) so the panel reads as one consistent system
|
||||||
|
instead of N separately-styled widgets. Still bound by the same hard
|
||||||
|
constraints as everything else that draws before the single whole-canvas
|
||||||
|
quantize/dither pass (see image_pipeline.py's module docstring/draw_text):
|
||||||
|
every fill here is one of DEFAULT_PALETTE_RGB's 6 exact colors, and text
|
||||||
|
always routes through image_pipeline.draw_text.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
from .image_pipeline import DEFAULT_PALETTE_RGB
|
||||||
|
|
||||||
|
# Spacing scale. CONTENT_MARGIN carries over calendar_render.py/
|
||||||
|
# weather_render.py's own long-tuned MARGIN=20 value unchanged (not
|
||||||
|
# re-tuned -- every wrap/truncation-width calc in those modules was
|
||||||
|
# measured against it). GUTTER is new: the inset every widget applies
|
||||||
|
# within its own target_w x target_h box (see card_canvas) to get a
|
||||||
|
# visible seam between adjacent widgets without touching grid.py's
|
||||||
|
# zero-gap cell math.
|
||||||
|
GUTTER = 6
|
||||||
|
CONTENT_MARGIN = 20
|
||||||
|
CARD_RADIUS = 12
|
||||||
|
CHIP_RADIUS = 4
|
||||||
|
|
||||||
|
# models.Widget.font_scale's allowed values -- a per-widget text-size
|
||||||
|
# multiplier (the Layout dialog's "Text size" picker), same "reject
|
||||||
|
# invalid, don't silently coerce" posture as border_style. Deliberately a
|
||||||
|
# small fixed set (a <select>, not a raw slider) rather than an arbitrary
|
||||||
|
# float: every _AGENDA_FONTS/_TASKS_FONTS-style tuple in calendar_render.py
|
||||||
|
# and every proportional size in html_render.py/calendar_html_render.py
|
||||||
|
# derives row heights/max_rows from the same scaled font size, so an
|
||||||
|
# unbounded scale risks a layout that no longer fits its own box.
|
||||||
|
FONT_SCALE_CHOICES = (1.0, 1.25, 1.5)
|
||||||
|
|
||||||
|
|
||||||
|
def scaled_size(value: float, font_scale: float) -> int:
|
||||||
|
"""Applies a widget's font_scale to a text-size value and rounds to
|
||||||
|
an int px, floored at 1 so an extreme scale can never zero out a
|
||||||
|
font. Shared by both the classic (PIL, calendar_render.py) and modern
|
||||||
|
(HTML/CSS, html_render.py/calendar_html_render.py) renderers so "make
|
||||||
|
this widget's text bigger" behaves identically regardless of
|
||||||
|
render_style -- every caller applies this immediately after its own
|
||||||
|
tier lookup/floor calc, so row heights/max_rows computed from the
|
||||||
|
result already account for the bigger text."""
|
||||||
|
return max(1, round(value * font_scale))
|
||||||
|
|
||||||
|
# Index constants into DEFAULT_PALETTE_RGB/a frame's own Frame.
|
||||||
|
# palette_rgb override -- same order as image_pipeline.PALETTE_LABELS.
|
||||||
|
BLACK, WHITE, YELLOW, RED, BLUE, GREEN = range(6)
|
||||||
|
|
||||||
|
# Which accent ink each widget kind's chrome (header bar, task checkbox,
|
||||||
|
# etc.) uses -- one dict, so "what color is a calendar header" has a
|
||||||
|
# single answer instead of being hardcoded separately everywhere a
|
||||||
|
# render module wants it. This is what makes a future global color
|
||||||
|
# theme *possible* without another pass through every render module: a
|
||||||
|
# per-frame override just needs to pick a different THEME mapping (or
|
||||||
|
# remap individual entries) here and resolve through theme_color/ink
|
||||||
|
# below, which already goes through a frame's own tuned Frame.
|
||||||
|
# palette_rgb -- swapping a slot's actual RGB (e.g. a custom "blue")
|
||||||
|
# already re-themes every widget that uses THEME_CALENDAR for its
|
||||||
|
# header, with no other code to touch. Weather deliberately maps to
|
||||||
|
# BLACK, not a color -- see weather_render's header call site -- so its
|
||||||
|
# own hand-drawn, already-colorful icons stay the star.
|
||||||
|
THEME_CALENDAR = BLUE
|
||||||
|
THEME_TASKS = GREEN
|
||||||
|
THEME_WEATHER = BLACK
|
||||||
|
THEME = {"calendar": THEME_CALENDAR, "tasks": THEME_TASKS, "weather": THEME_WEATHER}
|
||||||
|
|
||||||
|
|
||||||
|
def theme_color(widget_kind: str, palette_rgb: list | None = None) -> tuple[int, int, int]:
|
||||||
|
"""THEME[widget_kind] resolved against this frame's actual palette --
|
||||||
|
the one call every render module's header/accent chrome should go
|
||||||
|
through instead of hardcoding a palette index inline."""
|
||||||
|
return ink(palette_rgb, THEME[widget_kind])
|
||||||
|
|
||||||
|
|
||||||
|
def ink(palette_rgb: list | None, index: int) -> tuple[int, int, int]:
|
||||||
|
"""One of this frame's actual panel colors by DEFAULT_PALETTE_RGB
|
||||||
|
index -- generalizes the same resolution idiom weather_render._ink/
|
||||||
|
calendar_render._event_colors already used locally, so a custom
|
||||||
|
palette override (Frame.palette_rgb) still gets its own actual
|
||||||
|
yellow/red/blue/green, and every fill stays an exact, ditherless
|
||||||
|
palette match either way."""
|
||||||
|
return tuple((palette_rgb or DEFAULT_PALETTE_RGB)[index])
|
||||||
|
|
||||||
|
|
||||||
|
_FONT_DIR = Path(__file__).resolve().parent / "fonts"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=256)
|
||||||
|
def font_bold(size: int) -> ImageFont.FreeTypeFont:
|
||||||
|
return ImageFont.truetype(str(_FONT_DIR / "Inter-Bold.ttf"), size)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=256)
|
||||||
|
def font_regular(size: int) -> ImageFont.FreeTypeFont:
|
||||||
|
return ImageFont.truetype(str(_FONT_DIR / "Inter-Regular.ttf"), size)
|
||||||
|
|
||||||
|
|
||||||
|
def card_canvas(target_w: int, target_h: int,
|
||||||
|
bg: tuple[int, int, int] = (255, 255, 255)) -> tuple:
|
||||||
|
"""A full target_w x target_h canvas filled with `bg`, plus the
|
||||||
|
GUTTER-inset rect (x0, y0, w, h) every widget should draw its actual
|
||||||
|
chrome/content within -- this is the whole mechanism behind the
|
||||||
|
gutter between widgets (see module docstring): the widget's render()
|
||||||
|
contract (exact target_w x target_h in, same size out, unchanged) is
|
||||||
|
what routers/device.py pastes and what draw_widget_border frames, so
|
||||||
|
a border still frames the widget's true full box; only the widget's
|
||||||
|
own drawing backs off from that box's true edge."""
|
||||||
|
img = Image.new("RGB", (target_w, target_h), bg)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
x0, y0 = GUTTER, GUTTER
|
||||||
|
w, h = max(1, target_w - 2 * GUTTER), max(1, target_h - 2 * GUTTER)
|
||||||
|
return img, draw, (x0, y0, w, h)
|
||||||
|
|
||||||
|
|
||||||
|
def _clamped_radius(radius: int, w: int, h: int) -> int:
|
||||||
|
return max(0, min(radius, w // 2, h // 2))
|
||||||
|
|
||||||
|
|
||||||
|
def draw_header_bar(draw: ImageDraw.ImageDraw, rect: tuple[int, int, int, int], height: int,
|
||||||
|
fill: tuple[int, int, int], radius: int = CARD_RADIUS) -> None:
|
||||||
|
"""A widget's title bar: rounded top corners only (corners=(tl, tr,
|
||||||
|
bl, br), the bottom pair left square) so it reads as a card's header
|
||||||
|
fused to the content below it, not a standalone pill floating with a
|
||||||
|
gap above its own body."""
|
||||||
|
x0, y0, w, h = rect
|
||||||
|
r = _clamped_radius(radius, w, height * 2)
|
||||||
|
draw.rounded_rectangle([x0, y0, x0 + w, y0 + height], radius=r, fill=fill,
|
||||||
|
corners=(True, True, False, False))
|
||||||
|
|
||||||
|
|
||||||
|
def draw_color_chip(draw: ImageDraw.ImageDraw, x0: int, y0: int, x1: int, y1: int,
|
||||||
|
colors: list[tuple[int, int, int]], radius: int = CHIP_RADIUS) -> None:
|
||||||
|
"""One rounded chip for a single-source event/task, or that same
|
||||||
|
footprint split into equal-width side-by-side segments -- one per
|
||||||
|
contributing calendar -- for a deduplicated shared event (see
|
||||||
|
calendar_render._event_colors/calendar_feed.merge_events). Splitting
|
||||||
|
rather than e.g. concentric rings keeps every color equally "thick
|
||||||
|
and bold" at a glance, the same design goal a single pinned color
|
||||||
|
already has. Generalizes calendar_render.py's old private
|
||||||
|
_draw_color_bar so the radius comes from one shared constant."""
|
||||||
|
if len(colors) == 1:
|
||||||
|
r = _clamped_radius(radius, x1 - x0, y1 - y0)
|
||||||
|
draw.rounded_rectangle([x0, y0, x1, y1], radius=r, fill=colors[0])
|
||||||
|
return
|
||||||
|
seg_w = (x1 - x0) / len(colors)
|
||||||
|
for i, color in enumerate(colors):
|
||||||
|
seg_x0 = round(x0 + i * seg_w)
|
||||||
|
seg_x1 = round(x0 + (i + 1) * seg_w) - (2 if i < len(colors) - 1 else 0)
|
||||||
|
draw.rectangle([seg_x0, y0, seg_x1, y1], fill=color)
|
||||||
|
|
||||||
|
|
||||||
|
def battery_fill_color(percent: int, palette_rgb: list | None = None) -> tuple[int, int, int]:
|
||||||
|
"""Red/yellow/green by charge level -- the fill itself carries the
|
||||||
|
"how worried should I be" signal, not just the number next to it.
|
||||||
|
Shared threshold logic for widgets/battery.py and manage_overlay.py,
|
||||||
|
which previously each defined the same three-tier thresholds twice."""
|
||||||
|
if percent <= 15:
|
||||||
|
return ink(palette_rgb, RED)
|
||||||
|
if percent <= 40:
|
||||||
|
return ink(palette_rgb, YELLOW)
|
||||||
|
return ink(palette_rgb, GREEN)
|
||||||
|
|
||||||
|
|
||||||
|
def draw_battery_icon(draw: ImageDraw.ImageDraw, x0: int, y0: int, icon_w: int, icon_h: int,
|
||||||
|
percent: int, palette_rgb: list | None = None) -> None:
|
||||||
|
"""A rounded battery glyph -- outline + charge-level fill + terminal
|
||||||
|
nub -- anchored at (x0, y0), the body's own top-left corner (the nub
|
||||||
|
extends past icon_w on the right). The one shared implementation
|
||||||
|
behind what used to be two separate ImageDraw glyphs: widgets/
|
||||||
|
battery.py's own icon+percent widget, and manage_overlay.py's compact
|
||||||
|
battery readout on the "scan to manage" overlay -- same shape, same
|
||||||
|
red/yellow/green thresholds, previously kept in sync by convention
|
||||||
|
rather than by sharing code."""
|
||||||
|
stroke = max(2, icon_h // 12)
|
||||||
|
nub_w = max(3, icon_w // 10)
|
||||||
|
nub_h = icon_h // 2
|
||||||
|
radius = _clamped_radius(icon_h // 6, icon_w, icon_h)
|
||||||
|
|
||||||
|
inner_x0, inner_y0 = x0 + stroke, y0 + stroke
|
||||||
|
inner_x1, inner_y1 = x0 + icon_w - stroke, y0 + icon_h - stroke
|
||||||
|
fill_x1 = inner_x0 + round((inner_x1 - inner_x0) * (max(0, min(100, percent)) / 100))
|
||||||
|
if fill_x1 > inner_x0:
|
||||||
|
fill_radius = _clamped_radius(radius, fill_x1 - inner_x0, inner_y1 - inner_y0)
|
||||||
|
draw.rounded_rectangle([inner_x0, inner_y0, fill_x1, inner_y1], radius=fill_radius,
|
||||||
|
fill=battery_fill_color(percent, palette_rgb))
|
||||||
|
draw.rounded_rectangle([x0, y0, x0 + icon_w, y0 + icon_h], radius=radius, outline=(0, 0, 0), width=stroke)
|
||||||
|
nub_y = y0 + (icon_h - nub_h) // 2
|
||||||
|
nub_radius = _clamped_radius(max(1, nub_w // 3), nub_w, nub_h)
|
||||||
|
draw.rounded_rectangle([x0 + icon_w, nub_y, x0 + icon_w + nub_w, nub_y + nub_h], radius=nub_radius,
|
||||||
|
fill=(0, 0, 0))
|
||||||
@@ -211,11 +211,18 @@ def get_current(cfg: Frame, assets: list[dict], frame: Frame, in_quiet_hours: bo
|
|||||||
/api/queue), so without this an open browser tab polling overnight
|
/api/queue), so without this an open browser tab polling overnight
|
||||||
would silently advance the current photo on raw elapsed time alone,
|
would silently advance the current photo on raw elapsed time alone,
|
||||||
even though the device itself is correctly asleep through the
|
even though the device itself is correctly asleep through the
|
||||||
window (see main.py's _effective_refresh_interval_s)."""
|
window (see main.py's _effective_refresh_interval_s).
|
||||||
|
|
||||||
|
cfg.locked suppresses the elapsed-time trigger the same way
|
||||||
|
in_quiet_hours does -- a locked widget still needs an initial pick
|
||||||
|
if it somehow has none (an unconfigured widget just locked, or a
|
||||||
|
changed album), but once it has a current photo the whole point of
|
||||||
|
locking is that it stops moving on its own until explicitly
|
||||||
|
unlocked."""
|
||||||
valid_ids = {a["id"] for a in assets}
|
valid_ids = {a["id"] for a in assets}
|
||||||
needs_pick = not cfg.current_asset_id or cfg.current_asset_id not in valid_ids
|
needs_pick = not cfg.current_asset_id or cfg.current_asset_id not in valid_ids
|
||||||
time_elapsed = (time.time() - cfg.current_asset_set_at) >= frame.refresh_interval_s
|
time_elapsed = (time.time() - cfg.current_asset_set_at) >= frame.refresh_interval_s
|
||||||
stale = needs_pick or (time_elapsed and not in_quiet_hours)
|
stale = needs_pick or (time_elapsed and not in_quiet_hours and not cfg.locked)
|
||||||
if not stale:
|
if not stale:
|
||||||
return False
|
return False
|
||||||
advance_forced(cfg, assets, frame)
|
advance_forced(cfg, assets, frame)
|
||||||
|
|||||||
@@ -22,17 +22,16 @@ import time
|
|||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
from pydantic import BaseModel
|
from sqlalchemy import select
|
||||||
from sqlalchemy import delete, select
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .. import gitea_releases, grid, quiet_hours
|
from .. import gitea_releases, grid, quiet_hours, theme_tokens
|
||||||
from ..auth import require_frame_control, require_frame_view, require_user_api
|
from ..auth import require_frame_control, require_frame_view, require_user_api
|
||||||
from ..db import frame_locked, get_db
|
from ..db import frame_locked, get_db
|
||||||
|
from ..global_actions import GLOBAL_ACTIONS
|
||||||
from ..image_pipeline import PALETTE_LABELS, hex_to_rgb
|
from ..image_pipeline import PALETTE_LABELS, hex_to_rgb
|
||||||
from ..firmware import firmware_path, parse_app_version
|
from ..firmware import firmware_path, parse_app_version
|
||||||
from ..models import BatteryLog, Frame, FrameButtonAction, Widget
|
from ..models import BatteryLog, Frame, Widget
|
||||||
from ..widgets import WIDGET_TYPES
|
|
||||||
from .common import OVERDUE_FACTOR, battery_estimate_s, immich_client_for, immich_creds, valid_http_url
|
from .common import OVERDUE_FACTOR, battery_estimate_s, immich_client_for, immich_creds, valid_http_url
|
||||||
from .device import render_frame_preview_png
|
from .device import render_frame_preview_png
|
||||||
|
|
||||||
@@ -45,6 +44,11 @@ MAX_REFRESH_INTERVAL_S = 86400
|
|||||||
|
|
||||||
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
|
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
|
||||||
|
|
||||||
|
# See app/global_actions.py -- how long NEXT/BACK must be held before the
|
||||||
|
# device treats it as a hold instead of a short press.
|
||||||
|
MIN_HOLD_DURATION_MS = 3000
|
||||||
|
MAX_HOLD_DURATION_MS = 10000
|
||||||
|
|
||||||
|
|
||||||
def _reset_widget_layout_for_new_orientation(db: Session, frame_id: int, new_orientation: str) -> None:
|
def _reset_widget_layout_for_new_orientation(db: Session, frame_id: int, new_orientation: str) -> None:
|
||||||
"""A widget's x/y/w/h are grid cells relative to the OLD orientation's
|
"""A widget's x/y/w/h are grid cells relative to the OLD orientation's
|
||||||
@@ -101,6 +105,13 @@ def api_config_save(
|
|||||||
color_boost: float | None = Form(None),
|
color_boost: float | None = Form(None),
|
||||||
contrast_boost: float | None = Form(None),
|
contrast_boost: float | None = Form(None),
|
||||||
dither_strength: float | None = Form(None),
|
dither_strength: float | None = Form(None),
|
||||||
|
photo_palette: list[str] | None = Form(None),
|
||||||
|
photo_palette_reset: bool | None = Form(None),
|
||||||
|
photo_dither_strength: float | None = Form(None),
|
||||||
|
theme: str | None = Form(None),
|
||||||
|
hold_duration_ms: int | None = Form(None),
|
||||||
|
next_hold_action: str | None = Form(None),
|
||||||
|
back_hold_action: str | None = Form(None),
|
||||||
frame: Frame = Depends(require_frame_control),
|
frame: Frame = Depends(require_frame_control),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -120,7 +131,14 @@ def api_config_save(
|
|||||||
_reset_widget_layout_for_new_orientation) -- widget placement is
|
_reset_widget_layout_for_new_orientation) -- widget placement is
|
||||||
grid-cell-relative to the panel's long/short axis, which swaps on a
|
grid-cell-relative to the panel's long/short axis, which swaps on a
|
||||||
landscape<->portrait change, so an old placement is usually not just
|
landscape<->portrait change, so an old placement is usually not just
|
||||||
visually wrong but literally out of bounds on the new grid."""
|
visually wrong but literally out of bounds on the new grid.
|
||||||
|
|
||||||
|
hold_duration_ms/next_hold_action/back_hold_action configure hold-
|
||||||
|
for-global-action (see app/global_actions.py) -- a frame-wide
|
||||||
|
setting, not per-widget, hence living here rather than on
|
||||||
|
api_widgets.py's per-widget button-actions endpoint. An unrecognized
|
||||||
|
action value clears the binding rather than erroring, same posture
|
||||||
|
as this endpoint's other enum-ish fields (orientation, timezone)."""
|
||||||
with frame_locked(db, frame.id) as cfg:
|
with frame_locked(db, frame.id) as cfg:
|
||||||
if name is not None:
|
if name is not None:
|
||||||
cfg.name = name.strip()[:64] or cfg.name
|
cfg.name = name.strip()[:64] or cfg.name
|
||||||
@@ -168,6 +186,25 @@ def api_config_save(
|
|||||||
cfg.contrast_boost = max(0.0, min(2.0, contrast_boost))
|
cfg.contrast_boost = max(0.0, min(2.0, contrast_boost))
|
||||||
if dither_strength is not None:
|
if dither_strength is not None:
|
||||||
cfg.dither_strength = max(0.0, min(1.0, dither_strength))
|
cfg.dither_strength = max(0.0, min(1.0, dither_strength))
|
||||||
|
if photo_palette_reset:
|
||||||
|
cfg.photo_palette_rgb = None
|
||||||
|
elif photo_palette is not None:
|
||||||
|
if len(photo_palette) != len(PALETTE_LABELS):
|
||||||
|
raise HTTPException(400, f"Expected {len(PALETTE_LABELS)} palette colors, got {len(photo_palette)}")
|
||||||
|
parsed = [hex_to_rgb(h) for h in photo_palette]
|
||||||
|
if any(rgb is None for rgb in parsed):
|
||||||
|
raise HTTPException(400, "Palette colors must be #rrggbb hex values")
|
||||||
|
cfg.photo_palette_rgb = [list(rgb) for rgb in parsed]
|
||||||
|
if photo_dither_strength is not None:
|
||||||
|
cfg.photo_dither_strength = max(0.0, min(1.0, photo_dither_strength))
|
||||||
|
if theme is not None:
|
||||||
|
cfg.theme = theme if theme in theme_tokens.THEMES else "classic"
|
||||||
|
if hold_duration_ms is not None:
|
||||||
|
cfg.hold_duration_ms = max(MIN_HOLD_DURATION_MS, min(MAX_HOLD_DURATION_MS, hold_duration_ms))
|
||||||
|
if next_hold_action is not None:
|
||||||
|
cfg.next_hold_action = next_hold_action if next_hold_action in GLOBAL_ACTIONS else None
|
||||||
|
if back_hold_action is not None:
|
||||||
|
cfg.back_hold_action = back_hold_action if back_hold_action in GLOBAL_ACTIONS else None
|
||||||
cfg.stats_config_saves += 1
|
cfg.stats_config_saves += 1
|
||||||
|
|
||||||
return {"status": "saved"}
|
return {"status": "saved"}
|
||||||
@@ -224,6 +261,14 @@ def api_status(
|
|||||||
"device": {
|
"device": {
|
||||||
"last_seen": frame.last_seen or None,
|
"last_seen": frame.last_seen or None,
|
||||||
"overdue": bool(frame.last_seen and now - frame.last_seen > overdue_gap),
|
"overdue": bool(frame.last_seen and now - frame.last_seen > overdue_gap),
|
||||||
|
# When the device is next expected to check in, per the same
|
||||||
|
# sleep duration frame_config() actually hands it (see
|
||||||
|
# device.py's /frame/config) -- not the raw overdue_gap above,
|
||||||
|
# which is deliberately generous (OVERDUE_FACTOR) to avoid
|
||||||
|
# false alarms during quiet hours rather than a best guess.
|
||||||
|
"expected_next_checkin": (
|
||||||
|
frame.last_seen + quiet_hours.effective_refresh_interval_s(frame) if frame.last_seen else None
|
||||||
|
),
|
||||||
"firmware_version": frame.device_firmware_version or None,
|
"firmware_version": frame.device_firmware_version or None,
|
||||||
"firmware_available": frame.firmware_available_version or None,
|
"firmware_available": frame.firmware_available_version or None,
|
||||||
"battery": (
|
"battery": (
|
||||||
@@ -251,93 +296,23 @@ def api_frame_preview(
|
|||||||
return Response(content=png, media_type="image/png")
|
return Response(content=png, media_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
BUTTONS = ("next", "back")
|
@router.get("/api/frames/{frame_id}/now-displaying")
|
||||||
|
def api_frame_now_displaying(frame: Frame = Depends(require_frame_view)):
|
||||||
|
"""Exactly what was last actually sent to this frame's device (see
|
||||||
@router.get("/api/frames/{frame_id}/buttons")
|
routers/device.py's _record_last_displayed) -- the frozen "now
|
||||||
def api_buttons_get(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
displaying" half of the header preview pair, as opposed to /preview's
|
||||||
"""Everything the button-assignment UI needs in one call: every
|
always-live "up next" re-render. 404 (not a placeholder image) until
|
||||||
widget on the frame with the actions its type supports (see
|
the device has fetched at least once, so the web UI can show its own
|
||||||
app/widgets/*.py's ACTIONS/ACTION_LABELS), plus each button's current
|
empty state instead of a broken image. X-Displayed-At carries the
|
||||||
ordered list of (widget, action) bindings.
|
capture time (unix seconds) for a "N ago" label -- a header, not the
|
||||||
|
body, since the body is the raw PNG bytes."""
|
||||||
Includes each widget's placement (x/y/w/h) and the frame's grid
|
if frame.last_displayed_image is None:
|
||||||
dimensions -- two widgets of the same type otherwise look identical
|
raise HTTPException(404, "This frame hasn't displayed anything yet")
|
||||||
in the assignment UI's dropdowns (both just say "Photos"); the
|
return Response(
|
||||||
client derives a position label ("top-left" etc.) from this to tell
|
content=frame.last_displayed_image,
|
||||||
them apart, the same way you'd tell them apart by eye on the Layout
|
media_type="image/png",
|
||||||
canvas."""
|
headers={"X-Displayed-At": str(frame.last_displayed_at)},
|
||||||
cols, rows = grid.grid_dims(frame.orientation)
|
)
|
||||||
widgets = db.scalars(
|
|
||||||
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
|
||||||
).all()
|
|
||||||
widget_options = [
|
|
||||||
{
|
|
||||||
"id": w.id,
|
|
||||||
"widget_type": w.widget_type,
|
|
||||||
"x": w.x, "y": w.y, "w": w.w, "h": w.h,
|
|
||||||
"actions": [
|
|
||||||
{"action": action, "label": label}
|
|
||||||
for action, label in getattr(WIDGET_TYPES.get(w.widget_type), "ACTION_LABELS", {}).items()
|
|
||||||
],
|
|
||||||
}
|
|
||||||
for w in widgets
|
|
||||||
]
|
|
||||||
result = {"widgets": widget_options, "grid": {"cols": cols, "rows": rows}}
|
|
||||||
for button in BUTTONS:
|
|
||||||
rows = db.scalars(
|
|
||||||
select(FrameButtonAction)
|
|
||||||
.where(FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == button)
|
|
||||||
.order_by(FrameButtonAction.sort_order)
|
|
||||||
).all()
|
|
||||||
result[button] = [{"id": r.id, "widget_id": r.widget_id, "action": r.action} for r in rows]
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
class ButtonActionItem(BaseModel):
|
|
||||||
widget_id: int
|
|
||||||
action: str
|
|
||||||
|
|
||||||
|
|
||||||
class ButtonActionsRequest(BaseModel):
|
|
||||||
actions: list[ButtonActionItem]
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/api/frames/{frame_id}/buttons/{button}")
|
|
||||||
def api_buttons_save(
|
|
||||||
button: str, body: ButtonActionsRequest,
|
|
||||||
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Replaces the whole ordered action list for one button in a single
|
|
||||||
call -- simpler and more atomic than separate add/remove/reorder
|
|
||||||
endpoints for what's normally a list of one to a handful of entries,
|
|
||||||
and the UI always has the full list in hand anyway (see
|
|
||||||
static/frame_config.js)."""
|
|
||||||
if button not in BUTTONS:
|
|
||||||
raise HTTPException(404, "No such button")
|
|
||||||
widgets_by_id = {w.id: w for w in db.scalars(select(Widget).where(Widget.frame_id == frame.id))}
|
|
||||||
for item in body.actions:
|
|
||||||
widget = widgets_by_id.get(item.widget_id)
|
|
||||||
if widget is None:
|
|
||||||
raise HTTPException(400, f"No such widget: {item.widget_id}")
|
|
||||||
module = WIDGET_TYPES.get(widget.widget_type)
|
|
||||||
if module is None or item.action not in module.ACTIONS:
|
|
||||||
raise HTTPException(
|
|
||||||
400, f"{widget.widget_type} widgets don't support the {item.action!r} action"
|
|
||||||
)
|
|
||||||
with frame_locked(db, frame.id):
|
|
||||||
db.execute(
|
|
||||||
delete(FrameButtonAction).where(
|
|
||||||
FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == button
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for i, item in enumerate(body.actions):
|
|
||||||
db.add(FrameButtonAction(
|
|
||||||
frame_id=frame.id, button=button, widget_id=item.widget_id, action=item.action,
|
|
||||||
sort_order=i, created_at=time.time(),
|
|
||||||
))
|
|
||||||
db.commit()
|
|
||||||
return {"status": "saved"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/frames/{frame_id}/battery-log")
|
@router.get("/api/frames/{frame_id}/battery-log")
|
||||||
@@ -453,6 +428,7 @@ def api_firmware_check(
|
|||||||
"board": frame.device_board_variant or None,
|
"board": frame.device_board_variant or None,
|
||||||
"latest_version": frame.firmware_gitea_latest_version or None,
|
"latest_version": frame.firmware_gitea_latest_version or None,
|
||||||
"staged_version": frame.firmware_available_version or None,
|
"staged_version": frame.firmware_available_version or None,
|
||||||
|
"running_version": frame.device_firmware_version or None,
|
||||||
"update_available": update_available,
|
"update_available": update_available,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,12 +56,17 @@ LAYOUT_CONFIG_FIELDS: dict[str, tuple[str, ...]] = {
|
|||||||
"photos": ("album_id", "order", "display_mode", "queue_target_len"),
|
"photos": ("album_id", "order", "display_mode", "queue_target_len"),
|
||||||
"calendar": (
|
"calendar": (
|
||||||
"view", "week_start", "weather_enabled", "weather_units", "weather_cities",
|
"view", "week_start", "weather_enabled", "weather_units", "weather_cities",
|
||||||
"week_days", "week_layout", "week_start_offset",
|
"week_days", "week_layout", "week_start_offset", "render_style",
|
||||||
|
),
|
||||||
|
"tasks": ("name", "show_completed", "render_style"),
|
||||||
|
"static": ("display_mode", "original_filename", "render_style"),
|
||||||
|
"text": ("content", "font_size", "font_family", "align", "background_color", "render_style"),
|
||||||
|
"whiteboard": ("user_id", "url", "render_style"),
|
||||||
|
"battery": ("mode", "render_style"),
|
||||||
|
"weather": (
|
||||||
|
"mode", "provider", "units", "city_label", "city_latitude", "city_longitude",
|
||||||
|
"hourly_interval_hours", "daily_days", "cities", "render_style",
|
||||||
),
|
),
|
||||||
"tasks": ("name", "show_completed"),
|
|
||||||
"static": ("display_mode", "original_filename"),
|
|
||||||
"text": ("content", "font_size", "font_family", "align", "background_color"),
|
|
||||||
"whiteboard": ("user_id", "url"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# widget_type -> (FrameCalendar|FrameTaskList model, SavedLayoutSource.kind)
|
# widget_type -> (FrameCalendar|FrameTaskList model, SavedLayoutSource.kind)
|
||||||
@@ -236,25 +241,19 @@ def api_layout_delete(layout_id: int, request: Request, db: Session = Depends(ge
|
|||||||
return {"status": "deleted"}
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/frames/{frame_id}/layouts/{layout_id}/apply")
|
def apply_layout_to_frame(db: Session, frame: Frame, layout: SavedLayout) -> int:
|
||||||
def api_layout_apply(
|
"""Replaces frame's entire widget arrangement with layout's snapshot
|
||||||
layout_id: int, request: Request,
|
-- every current widget (and its own config/sources/button actions,
|
||||||
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db),
|
all ondelete="CASCADE") is deleted first, same "act unconditionally
|
||||||
):
|
on the server, confirm on the client" posture as
|
||||||
"""Replaces this frame's entire widget arrangement with a saved
|
|
||||||
layout's -- every current widget (and its own config/sources/button
|
|
||||||
actions, all ondelete="CASCADE") is deleted first, same "act
|
|
||||||
unconditionally on the server, confirm on the client" posture as
|
|
||||||
api_widgets.api_widgets_clear. A source whose owning user account
|
api_widgets.api_widgets_clear. A source whose owning user account
|
||||||
(or a whiteboard's user_id) no longer exists is silently dropped
|
(or a whiteboard's user_id) no longer exists is silently dropped
|
||||||
rather than left dangling -- config is JSON, not FK-checked, so
|
rather than left dangling -- config is JSON, not FK-checked, so
|
||||||
nothing enforces that at the storage layer."""
|
nothing enforces that at the storage layer. Shared by api_layout_apply
|
||||||
user = require_user_api(request, db)
|
(explicit user action) and global_actions.cycle_layout (a hold-
|
||||||
layout = _user_owned_layout(db, layout_id, user)
|
triggered global action, see app/global_actions.py) -- caller is
|
||||||
cols, rows = grid.grid_dims(frame.orientation)
|
responsible for checking the grid-size match first. Returns the
|
||||||
if (layout.cols, layout.rows) != (cols, rows):
|
number of widgets applied."""
|
||||||
raise HTTPException(400, "This layout was saved for a different frame size/orientation")
|
|
||||||
|
|
||||||
snapshots = db.scalars(
|
snapshots = db.scalars(
|
||||||
select(SavedLayoutWidget)
|
select(SavedLayoutWidget)
|
||||||
.where(SavedLayoutWidget.saved_layout_id == layout.id)
|
.where(SavedLayoutWidget.saved_layout_id == layout.id)
|
||||||
@@ -317,4 +316,22 @@ def api_layout_apply(
|
|||||||
sort_order=action.sort_order, created_at=time.time(),
|
sort_order=action.sort_order, created_at=time.time(),
|
||||||
))
|
))
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"status": "applied", "widget_count": len(snapshots)}
|
return len(snapshots)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/frames/{frame_id}/layouts/{layout_id}/apply")
|
||||||
|
def api_layout_apply(
|
||||||
|
layout_id: int, request: Request,
|
||||||
|
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Replaces this frame's entire widget arrangement with a saved
|
||||||
|
layout's -- see apply_layout_to_frame above for what that actually
|
||||||
|
does."""
|
||||||
|
user = require_user_api(request, db)
|
||||||
|
layout = _user_owned_layout(db, layout_id, user)
|
||||||
|
cols, rows = grid.grid_dims(frame.orientation)
|
||||||
|
if (layout.cols, layout.rows) != (cols, rows):
|
||||||
|
raise HTTPException(400, "This layout was saved for a different frame size/orientation")
|
||||||
|
|
||||||
|
widget_count = apply_layout_to_frame(db, frame, layout)
|
||||||
|
return {"status": "applied", "widget_count": widget_count}
|
||||||
|
|||||||
@@ -26,33 +26,45 @@ from pydantic import BaseModel
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .. import calendar_render, grid, photo_queue, quiet_hours, weather, webdav_client
|
from .. import calendar_render, grid, panel_style, photo_queue, quiet_hours, weather, weather_render, webdav_client
|
||||||
from ..auth import require_frame_control, require_frame_view, require_user_api
|
from ..auth import require_frame_control, require_frame_view, require_user_api
|
||||||
from ..db import frame_locked, get_db, widget_locked
|
from ..db import frame_locked, get_db, widget_locked
|
||||||
from ..image_pipeline import (
|
from ..image_pipeline import (
|
||||||
|
BORDER_STYLES,
|
||||||
DEFAULT_DISPLAY_MODE,
|
DEFAULT_DISPLAY_MODE,
|
||||||
DEFAULT_STATIC_DISPLAY_MODE,
|
DEFAULT_STATIC_DISPLAY_MODE,
|
||||||
DISPLAY_MODES,
|
DISPLAY_MODES,
|
||||||
hex_to_rgb,
|
hex_to_rgb,
|
||||||
|
logical_render_size,
|
||||||
|
MAX_BORDER_THICKNESS,
|
||||||
|
MIN_BORDER_THICKNESS,
|
||||||
|
PALETTE_LABELS,
|
||||||
STATIC_DISPLAY_MODES,
|
STATIC_DISPLAY_MODES,
|
||||||
render_preview_png,
|
render_preview_png,
|
||||||
|
compose_into,
|
||||||
|
_enhance,
|
||||||
|
_png_bytes,
|
||||||
|
_quantize,
|
||||||
)
|
)
|
||||||
from ..image_upload import decode_upload
|
from ..image_upload import decode_upload
|
||||||
from ..models import (
|
from ..models import (
|
||||||
CalendarWidgetConfig,
|
CalendarWidgetConfig,
|
||||||
Frame,
|
Frame,
|
||||||
|
FrameButtonAction,
|
||||||
FrameCalendar,
|
FrameCalendar,
|
||||||
FrameTaskList,
|
FrameTaskList,
|
||||||
PhotoWidgetConfig,
|
PhotoWidgetConfig,
|
||||||
StaticWidgetConfig,
|
StaticWidgetConfig,
|
||||||
TaskWidgetConfig,
|
TaskWidgetConfig,
|
||||||
TextWidgetConfig,
|
TextWidgetConfig,
|
||||||
|
WeatherWidgetConfig,
|
||||||
WhiteboardWidgetConfig,
|
WhiteboardWidgetConfig,
|
||||||
WIDGET_CONFIG_MODELS,
|
WIDGET_CONFIG_MODELS,
|
||||||
Widget,
|
Widget,
|
||||||
)
|
)
|
||||||
from ..text_content import has_text, parse_rich_text
|
from ..text_content import has_text, parse_rich_text
|
||||||
from ..widgets import WIDGET_TYPES
|
from ..widgets import WIDGET_TYPES, default_button_actions
|
||||||
|
from ..widgets import battery as battery_widget
|
||||||
from ..widgets import text as text_widget
|
from ..widgets import text as text_widget
|
||||||
from .common import (
|
from .common import (
|
||||||
calendar_sources_for_widget,
|
calendar_sources_for_widget,
|
||||||
@@ -60,6 +72,7 @@ from .common import (
|
|||||||
get_or_refresh_calendar_events_for_widget,
|
get_or_refresh_calendar_events_for_widget,
|
||||||
get_or_refresh_tasks_for_widget,
|
get_or_refresh_tasks_for_widget,
|
||||||
get_or_refresh_weather_for_widget,
|
get_or_refresh_weather_for_widget,
|
||||||
|
get_or_refresh_weather_widget_data,
|
||||||
get_or_refresh_whiteboard_for_widget,
|
get_or_refresh_whiteboard_for_widget,
|
||||||
immich_client_for,
|
immich_client_for,
|
||||||
immich_creds,
|
immich_creds,
|
||||||
@@ -79,9 +92,11 @@ CALENDAR_COLOR_INDEX_RANGE = range(2, 6) # Yellow/Red/Blue/Green -- see PALETTE
|
|||||||
MAX_TASKS_NAME_LEN = 40 # a sane on-panel-header length, see calendar_render._draw_tasks
|
MAX_TASKS_NAME_LEN = 40 # a sane on-panel-header length, see calendar_render._draw_tasks
|
||||||
|
|
||||||
|
|
||||||
def _widget_dict(w: Widget) -> dict:
|
def _widget_dict(w: Widget, locked: bool = False) -> dict:
|
||||||
return {"id": w.id, "widget_type": w.widget_type, "x": w.x, "y": w.y, "w": w.w, "h": w.h,
|
return {"id": w.id, "widget_type": w.widget_type, "x": w.x, "y": w.y, "w": w.w, "h": w.h,
|
||||||
"sort_order": w.sort_order}
|
"sort_order": w.sort_order, "border_style": w.border_style,
|
||||||
|
"border_thickness": w.border_thickness, "border_color_index": w.border_color_index,
|
||||||
|
"font_scale": w.font_scale, "locked": locked}
|
||||||
|
|
||||||
|
|
||||||
def require_widget_view(
|
def require_widget_view(
|
||||||
@@ -133,12 +148,21 @@ def api_widgets_list(request: Request, frame: Frame = Depends(require_frame_view
|
|||||||
widgets = db.scalars(
|
widgets = db.scalars(
|
||||||
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
||||||
).all()
|
).all()
|
||||||
|
# Layout canvas needs to know which photo widgets are locked (to draw
|
||||||
|
# the lock badge) -- a per-type config field, not on Widget itself,
|
||||||
|
# so it's a separate lookup rather than something _widget_dict can
|
||||||
|
# read straight off the row it's given.
|
||||||
|
photo_widget_ids = [w.id for w in widgets if w.widget_type == "photos"]
|
||||||
|
locked_by_widget_id = dict(db.execute(
|
||||||
|
select(PhotoWidgetConfig.widget_id, PhotoWidgetConfig.locked)
|
||||||
|
.where(PhotoWidgetConfig.widget_id.in_(photo_widget_ids))
|
||||||
|
).all()) if photo_widget_ids else {}
|
||||||
return {
|
return {
|
||||||
"orientation": frame.orientation,
|
"orientation": frame.orientation,
|
||||||
"grid": {"cols": cols, "rows": rows},
|
"grid": {"cols": cols, "rows": rows},
|
||||||
"widget_types": list(WIDGET_TYPES.keys()),
|
"widget_types": list(WIDGET_TYPES.keys()),
|
||||||
"min_footprint": grid.MIN_FOOTPRINT,
|
"min_footprint": grid.MIN_FOOTPRINT,
|
||||||
"widgets": [_widget_dict(w) for w in widgets],
|
"widgets": [_widget_dict(w, locked_by_widget_id.get(w.id, False)) for w in widgets],
|
||||||
"control": {
|
"control": {
|
||||||
"controller": (frame.controlled_by.display_name or frame.controlled_by.username) if frame.controlled_by else None,
|
"controller": (frame.controlled_by.display_name or frame.controlled_by.username) if frame.controlled_by else None,
|
||||||
"you": frame.controlled_by_user_id == user.id,
|
"you": frame.controlled_by_user_id == user.id,
|
||||||
@@ -196,6 +220,7 @@ def api_widget_create(
|
|||||||
db.add(widget)
|
db.add(widget)
|
||||||
db.flush()
|
db.flush()
|
||||||
db.add(WIDGET_CONFIG_MODELS[body.widget_type](widget_id=widget.id))
|
db.add(WIDGET_CONFIG_MODELS[body.widget_type](widget_id=widget.id))
|
||||||
|
db.add_all(default_button_actions(frame.id, widget.id, body.widget_type))
|
||||||
db.commit()
|
db.commit()
|
||||||
return _widget_dict(widget)
|
return _widget_dict(widget)
|
||||||
|
|
||||||
@@ -223,6 +248,63 @@ def api_widget_move(
|
|||||||
return _widget_dict(widget)
|
return _widget_dict(widget)
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetBorderRequest(BaseModel):
|
||||||
|
border_style: str
|
||||||
|
border_thickness: int
|
||||||
|
border_color_index: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/border")
|
||||||
|
def api_widget_border(
|
||||||
|
body: WidgetBorderRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Sets this widget's optional border -- a shared Widget-level
|
||||||
|
property (see models.Widget), not a per-type config field, since
|
||||||
|
every widget type can have one regardless of widget_type. Its own
|
||||||
|
endpoint (not folded into api_widget_config_save) for the same
|
||||||
|
reason: 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."""
|
||||||
|
frame, widget = frame_widget
|
||||||
|
if body.border_style not in BORDER_STYLES:
|
||||||
|
raise HTTPException(400, f"border_style must be one of {BORDER_STYLES}")
|
||||||
|
if not (0 <= body.border_color_index < len(PALETTE_LABELS)):
|
||||||
|
raise HTTPException(400, "border_color_index must be 0-5 (a panel palette color)")
|
||||||
|
thickness = max(MIN_BORDER_THICKNESS, min(MAX_BORDER_THICKNESS, body.border_thickness))
|
||||||
|
with frame_locked(db, frame.id):
|
||||||
|
widget.border_style = body.border_style
|
||||||
|
widget.border_thickness = thickness
|
||||||
|
widget.border_color_index = body.border_color_index
|
||||||
|
db.commit()
|
||||||
|
return _widget_dict(widget)
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetFontScaleRequest(BaseModel):
|
||||||
|
font_scale: float
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/font-scale")
|
||||||
|
def api_widget_font_scale(
|
||||||
|
body: WidgetFontScaleRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Sets this widget's text-size multiplier (the Layout dialog's "Text
|
||||||
|
size" picker) -- a shared Widget-level property (see models.Widget.
|
||||||
|
font_scale), not a per-type config field, since any widget type with
|
||||||
|
body text can use it. Its own endpoint for the same reason
|
||||||
|
api_widget_border has one: api_widget_config_save's per-type dispatch
|
||||||
|
edits a config row via widget_locked, and font_scale lives on Widget
|
||||||
|
itself, not any per-type config table."""
|
||||||
|
frame, widget = frame_widget
|
||||||
|
if body.font_scale not in panel_style.FONT_SCALE_CHOICES:
|
||||||
|
raise HTTPException(400, f"font_scale must be one of {panel_style.FONT_SCALE_CHOICES}")
|
||||||
|
with frame_locked(db, frame.id):
|
||||||
|
widget.font_scale = body.font_scale
|
||||||
|
db.commit()
|
||||||
|
return _widget_dict(widget)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/frames/{frame_id}/widgets/{widget_id}")
|
@router.delete("/api/frames/{frame_id}/widgets/{widget_id}")
|
||||||
def api_widget_delete(
|
def api_widget_delete(
|
||||||
widget_id: int, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
|
widget_id: int, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
|
||||||
@@ -265,9 +347,12 @@ def api_widget_config_save(
|
|||||||
album_id: str | None = Form(None),
|
album_id: str | None = Form(None),
|
||||||
order: str | None = Form(None),
|
order: str | None = Form(None),
|
||||||
display_mode: str | None = Form(None),
|
display_mode: str | None = Form(None),
|
||||||
|
static_render_style: str | None = Form(None),
|
||||||
|
whiteboard_render_style: str | None = Form(None),
|
||||||
queue_target_len: int | None = Form(None),
|
queue_target_len: int | None = Form(None),
|
||||||
# calendar
|
# calendar
|
||||||
calendar_view: str | None = Form(None),
|
calendar_view: str | None = Form(None),
|
||||||
|
calendar_render_style: str | None = Form(None),
|
||||||
calendar_week_start: int | None = Form(None),
|
calendar_week_start: int | None = Form(None),
|
||||||
calendar_week_days: int | None = Form(None),
|
calendar_week_days: int | None = Form(None),
|
||||||
calendar_week_layout: str | None = Form(None),
|
calendar_week_layout: str | None = Form(None),
|
||||||
@@ -277,12 +362,24 @@ def api_widget_config_save(
|
|||||||
# tasks
|
# tasks
|
||||||
tasks_name: str | None = Form(None),
|
tasks_name: str | None = Form(None),
|
||||||
tasks_show_completed: bool | None = Form(None),
|
tasks_show_completed: bool | None = Form(None),
|
||||||
|
tasks_render_style: str | None = Form(None),
|
||||||
# text
|
# text
|
||||||
text_html: str | None = Form(None),
|
text_html: str | None = Form(None),
|
||||||
text_font_size: int | None = Form(None),
|
text_font_size: int | None = Form(None),
|
||||||
text_font_family: str | None = Form(None),
|
text_font_family: str | None = Form(None),
|
||||||
|
text_render_style: str | None = Form(None),
|
||||||
text_align: str | None = Form(None),
|
text_align: str | None = Form(None),
|
||||||
text_background_color: str | None = Form(None),
|
text_background_color: str | None = Form(None),
|
||||||
|
# weather
|
||||||
|
weather_mode: str | None = Form(None),
|
||||||
|
weather_provider: str | None = Form(None),
|
||||||
|
weather_units: str | None = Form(None),
|
||||||
|
weather_hourly_interval_hours: int | None = Form(None),
|
||||||
|
weather_daily_days: int | None = Form(None),
|
||||||
|
weather_render_style: str | None = Form(None),
|
||||||
|
# battery
|
||||||
|
battery_mode: str | None = Form(None),
|
||||||
|
battery_render_style: str | None = Form(None),
|
||||||
):
|
):
|
||||||
"""Every field optional -- same partial-update, form-urlencoded
|
"""Every field optional -- same partial-update, form-urlencoded
|
||||||
convention as the old frame-level api_config_save, now scoped to one
|
convention as the old frame-level api_config_save, now scoped to one
|
||||||
@@ -345,6 +442,8 @@ def api_widget_config_save(
|
|||||||
# new unit label.
|
# new unit label.
|
||||||
ccfg.weather_checked_at = 0.0
|
ccfg.weather_checked_at = 0.0
|
||||||
ccfg.weather_units = calendar_weather_units
|
ccfg.weather_units = calendar_weather_units
|
||||||
|
if calendar_render_style is not None and calendar_render_style in ("classic", "modern"):
|
||||||
|
ccfg.render_style = calendar_render_style
|
||||||
elif widget.widget_type == "tasks":
|
elif widget.widget_type == "tasks":
|
||||||
with widget_locked(db, frame.id, widget.id) as (_, _, tcfg):
|
with widget_locked(db, frame.id, widget.id) as (_, _, tcfg):
|
||||||
if tasks_name is not None:
|
if tasks_name is not None:
|
||||||
@@ -355,10 +454,22 @@ def api_widget_config_save(
|
|||||||
if tasks_show_completed is not None and tasks_show_completed != tcfg.show_completed:
|
if tasks_show_completed is not None and tasks_show_completed != tcfg.show_completed:
|
||||||
tcfg.show_completed = tasks_show_completed
|
tcfg.show_completed = tasks_show_completed
|
||||||
tcfg.checked_at = 0.0 # pick up the change promptly
|
tcfg.checked_at = 0.0 # pick up the change promptly
|
||||||
|
if tasks_render_style is not None and tasks_render_style in ("classic", "modern"):
|
||||||
|
tcfg.render_style = tasks_render_style
|
||||||
elif widget.widget_type == "static":
|
elif widget.widget_type == "static":
|
||||||
with widget_locked(db, frame.id, widget.id) as (_, _, scfg):
|
with widget_locked(db, frame.id, widget.id) as (_, _, scfg):
|
||||||
if display_mode is not None:
|
if display_mode is not None:
|
||||||
scfg.display_mode = display_mode if display_mode in STATIC_DISPLAY_MODES else DEFAULT_STATIC_DISPLAY_MODE
|
scfg.display_mode = display_mode if display_mode in STATIC_DISPLAY_MODES else DEFAULT_STATIC_DISPLAY_MODE
|
||||||
|
if static_render_style is not None and static_render_style in ("classic", "modern"):
|
||||||
|
scfg.render_style = static_render_style
|
||||||
|
elif widget.widget_type == "whiteboard":
|
||||||
|
# user_id/url go through the dedicated /whiteboard-source endpoint
|
||||||
|
# (owner-only, JSON body) -- render_style is the one setting this
|
||||||
|
# widget type takes through the shared /config form, same as
|
||||||
|
# every other widget type's own render_style.
|
||||||
|
with widget_locked(db, frame.id, widget.id) as (_, _, wbcfg):
|
||||||
|
if whiteboard_render_style is not None and whiteboard_render_style in ("classic", "modern"):
|
||||||
|
wbcfg.render_style = whiteboard_render_style
|
||||||
elif widget.widget_type == "text":
|
elif widget.widget_type == "text":
|
||||||
with widget_locked(db, frame.id, widget.id) as (_, _, xcfg):
|
with widget_locked(db, frame.id, widget.id) as (_, _, xcfg):
|
||||||
if text_html is not None:
|
if text_html is not None:
|
||||||
@@ -379,11 +490,99 @@ def api_widget_config_save(
|
|||||||
xcfg.background_color = (
|
xcfg.background_color = (
|
||||||
text_background_color if hex_to_rgb(text_background_color) else "#ffffff"
|
text_background_color if hex_to_rgb(text_background_color) else "#ffffff"
|
||||||
)
|
)
|
||||||
|
if text_render_style is not None and text_render_style in ("classic", "modern"):
|
||||||
|
xcfg.render_style = text_render_style
|
||||||
|
elif widget.widget_type == "weather":
|
||||||
|
with widget_locked(db, frame.id, widget.id) as (_, _, wcfg):
|
||||||
|
if weather_mode is not None and weather_mode in ("current", "hourly", "daily", "multi_city"):
|
||||||
|
if weather_mode != wcfg.mode:
|
||||||
|
# A stale cache is a different shape under a
|
||||||
|
# different mode (a single-temp dict vs. an hourly
|
||||||
|
# list vs. a daily dict vs. a city list) -- clear it
|
||||||
|
# outright (not just force a refetch attempt) so a
|
||||||
|
# get_or_refresh_weather_widget_data call that happens
|
||||||
|
# to fail on the very first fetch under the new mode
|
||||||
|
# doesn't fall back to the old mode's incompatible
|
||||||
|
# cached shape.
|
||||||
|
wcfg.checked_at = 0.0
|
||||||
|
wcfg.cached = None
|
||||||
|
wcfg.mode = weather_mode
|
||||||
|
if weather_provider is not None and weather_provider in weather.PROVIDERS:
|
||||||
|
if weather_provider != wcfg.provider:
|
||||||
|
wcfg.checked_at = 0.0
|
||||||
|
wcfg.provider = weather_provider
|
||||||
|
if weather_units is not None and weather_units in ("fahrenheit", "celsius"):
|
||||||
|
if weather_units != wcfg.units:
|
||||||
|
# Cached temps are in the old unit -- force a refetch
|
||||||
|
# rather than showing stale numbers under a new unit
|
||||||
|
# label (same idiom as calendar_weather_units above).
|
||||||
|
wcfg.checked_at = 0.0
|
||||||
|
wcfg.units = weather_units
|
||||||
|
if weather_hourly_interval_hours is not None:
|
||||||
|
wcfg.hourly_interval_hours = max(1, min(24, weather_hourly_interval_hours))
|
||||||
|
if weather_daily_days is not None:
|
||||||
|
wcfg.daily_days = max(1, min(14, weather_daily_days))
|
||||||
|
if weather_render_style is not None and weather_render_style in ("classic", "modern"):
|
||||||
|
wcfg.render_style = weather_render_style
|
||||||
|
elif widget.widget_type == "battery":
|
||||||
|
with widget_locked(db, frame.id, widget.id) as (_, _, bcfg):
|
||||||
|
if battery_mode is not None:
|
||||||
|
bcfg.mode = battery_mode if battery_mode in ("compact", "detailed") else "detailed"
|
||||||
|
if battery_render_style is not None and battery_render_style in ("classic", "modern"):
|
||||||
|
bcfg.render_style = battery_render_style
|
||||||
with frame_locked(db, frame.id) as cfg:
|
with frame_locked(db, frame.id) as cfg:
|
||||||
cfg.stats_config_saves += 1
|
cfg.stats_config_saves += 1
|
||||||
return {"status": "saved"}
|
return {"status": "saved"}
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetButtonActionsRequest(BaseModel):
|
||||||
|
next_button_action: str
|
||||||
|
back_button_action: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/button-actions")
|
||||||
|
def api_widget_button_actions(
|
||||||
|
body: WidgetButtonActionsRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Sets this widget's NEXT/BACK button bindings (models.FrameButtonAction)
|
||||||
|
-- its own endpoint, not folded into api_widget_config_save, same
|
||||||
|
reasoning as api_widget_border above: these rows live in their own
|
||||||
|
table, not this widget's per-type config table. Replaces the old
|
||||||
|
frame-level "Button assignments" card (routers/api_frames.py's
|
||||||
|
api_buttons_get/api_buttons_save, now removed) -- each widget's own
|
||||||
|
dialog edits its own binding directly, prefilled at widget-creation
|
||||||
|
time with a sane default (see widgets.default_button_actions).
|
||||||
|
|
||||||
|
An empty string clears the binding for that button. Unlike
|
||||||
|
api_widget_config_save's silent-ignore-unrecognized-value posture,
|
||||||
|
a value outside this widget type's own ACTIONS is a 400 -- this
|
||||||
|
request body is specifically about button actions, so a bad value
|
||||||
|
here is a real client bug worth surfacing, not a stray field to
|
||||||
|
shrug off."""
|
||||||
|
frame, widget = frame_widget
|
||||||
|
valid_actions = set(WIDGET_TYPES[widget.widget_type].ACTIONS)
|
||||||
|
for value in (body.next_button_action, body.back_button_action):
|
||||||
|
if value != "" and value not in valid_actions:
|
||||||
|
raise HTTPException(400, f"{widget.widget_type} widgets don't support the {value!r} action")
|
||||||
|
with frame_locked(db, frame.id):
|
||||||
|
for button, value in (("next", body.next_button_action), ("back", body.back_button_action)):
|
||||||
|
existing = db.scalars(
|
||||||
|
select(FrameButtonAction).where(
|
||||||
|
FrameButtonAction.widget_id == widget.id, FrameButtonAction.button == button
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if value == "":
|
||||||
|
if existing is not None:
|
||||||
|
db.delete(existing)
|
||||||
|
elif existing is not None:
|
||||||
|
existing.action = value
|
||||||
|
else:
|
||||||
|
db.add(FrameButtonAction(frame_id=frame.id, button=button, widget_id=widget.id, action=value))
|
||||||
|
db.commit()
|
||||||
|
return {"status": "saved"}
|
||||||
|
|
||||||
|
|
||||||
# --- Photos: queue/thumbnail/preview ------------------------------------
|
# --- Photos: queue/thumbnail/preview ------------------------------------
|
||||||
|
|
||||||
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/queue")
|
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/queue")
|
||||||
@@ -404,6 +603,7 @@ def api_widget_queue(
|
|||||||
photo_queue.sync_queue_length(locked_pcfg, assets)
|
photo_queue.sync_queue_length(locked_pcfg, assets)
|
||||||
current_asset_id = locked_pcfg.current_asset_id
|
current_asset_id = locked_pcfg.current_asset_id
|
||||||
queue = list(locked_pcfg.queue)
|
queue = list(locked_pcfg.queue)
|
||||||
|
locked = locked_pcfg.locked
|
||||||
controller_id = locked_frame.controlled_by_user_id
|
controller_id = locked_frame.controlled_by_user_id
|
||||||
controller = (
|
controller = (
|
||||||
(locked_frame.controlled_by.display_name or locked_frame.controlled_by.username)
|
(locked_frame.controlled_by.display_name or locked_frame.controlled_by.username)
|
||||||
@@ -416,6 +616,7 @@ def api_widget_queue(
|
|||||||
return {
|
return {
|
||||||
"current": entry(current_asset_id) if current_asset_id else None,
|
"current": entry(current_asset_id) if current_asset_id else None,
|
||||||
"upcoming": [entry(asset_id) for asset_id in queue],
|
"upcoming": [entry(asset_id) for asset_id in queue],
|
||||||
|
"locked": locked,
|
||||||
"control": {"controller": controller, "you": controller_id == user.id},
|
"control": {"controller": controller, "you": controller_id == user.id},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,6 +686,26 @@ def api_widget_queue_remove(
|
|||||||
return {"status": "removed"}
|
return {"status": "removed"}
|
||||||
|
|
||||||
|
|
||||||
|
class QueueLockRequest(BaseModel):
|
||||||
|
locked: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/lock")
|
||||||
|
def api_widget_queue_lock(
|
||||||
|
body: QueueLockRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Freezes/unfreezes current_asset_id (models.PhotoWidgetConfig.locked)
|
||||||
|
-- while locked, neither the timer-elapsed auto-advance
|
||||||
|
(photo_queue.get_current) nor the advance/back button actions
|
||||||
|
(app/widgets/photos.py) change which photo is showing."""
|
||||||
|
frame, widget = frame_widget
|
||||||
|
_require_widget_type(widget, "photos")
|
||||||
|
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
||||||
|
cfg.locked = body.locked
|
||||||
|
return {"status": "saved", "locked": body.locked}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/thumbnail/{asset_id}")
|
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/thumbnail/{asset_id}")
|
||||||
def api_widget_thumbnail(
|
def api_widget_thumbnail(
|
||||||
asset_id: str, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
|
asset_id: str, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
|
||||||
@@ -493,8 +714,8 @@ def api_widget_thumbnail(
|
|||||||
"""Scoped to what this widget is actually showing/queuing -- a user
|
"""Scoped to what this widget is actually showing/queuing -- a user
|
||||||
merely linked to view this frame shouldn't be able to pull thumbnails
|
merely linked to view this frame shouldn't be able to pull thumbnails
|
||||||
for arbitrary asset ids in the owner's Immich library, only this
|
for arbitrary asset ids in the owner's Immich library, only this
|
||||||
widget's own curated album. Same rule device.frame_share and
|
widget's own curated album. Same rule manage.manage_thumbnail
|
||||||
manage.manage_thumbnail already enforce."""
|
already enforces."""
|
||||||
frame, widget = frame_widget
|
frame, widget = frame_widget
|
||||||
_require_widget_type(widget, "photos")
|
_require_widget_type(widget, "photos")
|
||||||
pcfg = db.get(PhotoWidgetConfig, widget.id)
|
pcfg = db.get(PhotoWidgetConfig, widget.id)
|
||||||
@@ -669,14 +890,30 @@ def api_widget_preview_calendar(
|
|||||||
ccfg = db.get(CalendarWidgetConfig, widget.id)
|
ccfg = db.get(CalendarWidgetConfig, widget.id)
|
||||||
events, summary = get_or_refresh_calendar_events_for_widget(db, frame, widget)
|
events, summary = get_or_refresh_calendar_events_for_widget(db, frame, widget)
|
||||||
weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if ccfg.weather_enabled else None
|
weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if ccfg.weather_enabled else None
|
||||||
png = calendar_render.render_calendar_preview_png(
|
|
||||||
events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation,
|
target_w, target_h = logical_render_size(frame.orientation)
|
||||||
palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary,
|
if ccfg.render_style == "modern":
|
||||||
week_start=ccfg.week_start,
|
from zoneinfo import ZoneInfo
|
||||||
weather_cities=weather_cities, weather_units=ccfg.weather_units,
|
|
||||||
week_days=ccfg.week_days, week_layout=ccfg.week_layout,
|
from .. import calendar_html_render
|
||||||
week_start_offset=ccfg.week_start_offset,
|
|
||||||
)
|
tz = ZoneInfo(frame.timezone) if frame.timezone else ZoneInfo("UTC")
|
||||||
|
img = calendar_html_render.build(
|
||||||
|
events, ccfg.view, ccfg.browse_offset, target_w, target_h, tz, ccfg.week_start, frame.palette_rgb,
|
||||||
|
weather_cities, ccfg.weather_units, ccfg.week_days, ccfg.week_layout, ccfg.week_start_offset,
|
||||||
|
frame.theme, widget.font_scale,
|
||||||
|
)
|
||||||
|
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
|
||||||
|
png = _png_bytes(quantized)
|
||||||
|
else:
|
||||||
|
png = calendar_render.render_calendar_preview_png(
|
||||||
|
events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation,
|
||||||
|
palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary,
|
||||||
|
week_start=ccfg.week_start,
|
||||||
|
weather_cities=weather_cities, weather_units=ccfg.weather_units,
|
||||||
|
week_days=ccfg.week_days, week_layout=ccfg.week_layout,
|
||||||
|
week_start_offset=ccfg.week_start_offset, font_scale=widget.font_scale,
|
||||||
|
)
|
||||||
return Response(content=png, media_type="image/png")
|
return Response(content=png, media_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
@@ -695,8 +932,19 @@ def api_widget_preview_tasks(
|
|||||||
raise HTTPException(400, "No task lists included on this widget yet")
|
raise HTTPException(400, "No task lists included on this widget yet")
|
||||||
tcfg = db.get(TaskWidgetConfig, widget.id)
|
tcfg = db.get(TaskWidgetConfig, widget.id)
|
||||||
tasks = get_or_refresh_tasks_for_widget(db, frame, widget)
|
tasks = get_or_refresh_tasks_for_widget(db, frame, widget)
|
||||||
png = calendar_render.render_tasks_preview_png(tasks, orientation=frame.orientation,
|
title = tcfg.name or "Tasks"
|
||||||
palette_rgb=frame.palette_rgb, title=tcfg.name or "Tasks")
|
if tcfg.render_style == "modern":
|
||||||
|
from .. import html_render
|
||||||
|
|
||||||
|
target_w, target_h = logical_render_size(frame.orientation)
|
||||||
|
img = html_render.build_tasks(tasks, target_w, target_h, frame.palette_rgb, title, frame.theme,
|
||||||
|
widget.font_scale)
|
||||||
|
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
|
||||||
|
png = _png_bytes(quantized)
|
||||||
|
else:
|
||||||
|
png = calendar_render.render_tasks_preview_png(tasks, orientation=frame.orientation,
|
||||||
|
palette_rgb=frame.palette_rgb, title=title,
|
||||||
|
font_scale=widget.font_scale)
|
||||||
return Response(content=png, media_type="image/png")
|
return Response(content=png, media_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
@@ -833,6 +1081,133 @@ def api_widget_weather_city_remove(
|
|||||||
return {"status": "saved"}
|
return {"status": "saved"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Weather widget: location/cities/preview -------------------------------
|
||||||
|
#
|
||||||
|
# Endpoint names here are "weather-location"/"weather-widget-cities" (not
|
||||||
|
# "weather-cities") specifically to avoid colliding with the calendar
|
||||||
|
# widget's own /weather-cities/add|remove route *patterns* above -- both
|
||||||
|
# are registered against the same {widget_id}-parameterized path shape,
|
||||||
|
# so a literal name clash there would silently shadow one of them
|
||||||
|
# regardless of each handler's own _require_widget_type check.
|
||||||
|
|
||||||
|
class WeatherLocationRequest(BaseModel):
|
||||||
|
name: str | None # None clears the location; else a free-text city name to geocode
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-location")
|
||||||
|
def api_widget_weather_location(
|
||||||
|
body: WeatherLocationRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Sets (or clears) this weather widget's single configured location
|
||||||
|
-- the current/hourly/daily modes' one city. A widget-wide display
|
||||||
|
setting like calendar_view/weather_units, not personal data, hence
|
||||||
|
require_widget_control rather than the calendar/tasks owner-adds/
|
||||||
|
anyone-mutes split (there's only ever one location and no per-person
|
||||||
|
ownership of it)."""
|
||||||
|
frame, widget = frame_widget
|
||||||
|
_require_widget_type(widget, "weather")
|
||||||
|
if body.name is None:
|
||||||
|
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
||||||
|
cfg.city_label = None
|
||||||
|
cfg.city_latitude = None
|
||||||
|
cfg.city_longitude = None
|
||||||
|
cfg.cached = None
|
||||||
|
cfg.checked_at = 0.0
|
||||||
|
return {"status": "saved", "city": None}
|
||||||
|
try:
|
||||||
|
city = weather.geocode_city(body.name)
|
||||||
|
except weather.WeatherFetchError as e:
|
||||||
|
raise HTTPException(400, str(e)) from e
|
||||||
|
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
||||||
|
cfg.city_label = city["label"]
|
||||||
|
cfg.city_latitude = city["latitude"]
|
||||||
|
cfg.city_longitude = city["longitude"]
|
||||||
|
cfg.cached = None
|
||||||
|
cfg.checked_at = 0.0 # pick up the new location promptly
|
||||||
|
return {"status": "saved", "city": city}
|
||||||
|
|
||||||
|
|
||||||
|
class WeatherWidgetCityAddRequest(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-widget-cities/add")
|
||||||
|
def api_widget_weather_widget_city_add(
|
||||||
|
body: WeatherWidgetCityAddRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""multi_city mode's city list -- same shape/gating as the calendar
|
||||||
|
widget's own weather-cities/add above, just scoped to this widget's
|
||||||
|
own WeatherWidgetConfig.cities."""
|
||||||
|
frame, widget = frame_widget
|
||||||
|
_require_widget_type(widget, "weather")
|
||||||
|
try:
|
||||||
|
city = weather.geocode_city(body.name)
|
||||||
|
except weather.WeatherFetchError as e:
|
||||||
|
raise HTTPException(400, str(e)) from e
|
||||||
|
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
||||||
|
cities = list(cfg.cities or [])
|
||||||
|
if any(c["label"] == city["label"] for c in cities):
|
||||||
|
raise HTTPException(400, f"{city['label']} is already on this widget's list")
|
||||||
|
cities.append(city)
|
||||||
|
cfg.cities = cities
|
||||||
|
cfg.checked_at = 0.0 # pick up the new city promptly
|
||||||
|
return {"status": "saved", "city": city}
|
||||||
|
|
||||||
|
|
||||||
|
class WeatherWidgetCityRemoveRequest(BaseModel):
|
||||||
|
label: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-widget-cities/remove")
|
||||||
|
def api_widget_weather_widget_city_remove(
|
||||||
|
body: WeatherWidgetCityRemoveRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
frame, widget = frame_widget
|
||||||
|
_require_widget_type(widget, "weather")
|
||||||
|
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
||||||
|
cities = [c for c in (cfg.cities or []) if c["label"] != body.label]
|
||||||
|
cfg.cities = cities
|
||||||
|
if cfg.cached:
|
||||||
|
cfg.cached = [c for c in cfg.cached if c.get("label") != body.label]
|
||||||
|
return {"status": "saved"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/weather")
|
||||||
|
def api_widget_preview_weather(
|
||||||
|
force: bool = False, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""The same throttled fetch cache a live device render would use, run
|
||||||
|
through the panel composition/quantization pipeline -- "how it will
|
||||||
|
look on the frame", same convention as the other preview endpoints.
|
||||||
|
force=True (the "Refresh now" button) bypasses the fetch throttle."""
|
||||||
|
frame, widget = frame_widget
|
||||||
|
_require_widget_type(widget, "weather")
|
||||||
|
wcfg = db.get(WeatherWidgetConfig, widget.id)
|
||||||
|
data = get_or_refresh_weather_widget_data(db, frame, widget, force=force)
|
||||||
|
if data is None:
|
||||||
|
if wcfg.mode == "multi_city":
|
||||||
|
raise HTTPException(400, "No cities added to this widget yet")
|
||||||
|
raise HTTPException(400, "No location set on this widget yet")
|
||||||
|
if wcfg.render_style == "modern" and wcfg.mode in ("current", "daily"):
|
||||||
|
# Same local-import reasoning as widgets/weather.py's render().
|
||||||
|
from .. import html_render
|
||||||
|
|
||||||
|
png = html_render.render_weather_preview_png(
|
||||||
|
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units,
|
||||||
|
city_label=wcfg.city_label or "", theme_name=frame.theme,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
png = weather_render.render_weather_preview_png(
|
||||||
|
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units,
|
||||||
|
city_label=wcfg.city_label or "", interval_hours=wcfg.hourly_interval_hours,
|
||||||
|
)
|
||||||
|
return Response(content=png, media_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
# --- Static image: upload/preview -----------------------------------------
|
# --- Static image: upload/preview -----------------------------------------
|
||||||
|
|
||||||
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/static-upload")
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/static-upload")
|
||||||
@@ -876,11 +1251,22 @@ def api_widget_preview_static(
|
|||||||
if not scfg.image:
|
if not scfg.image:
|
||||||
raise HTTPException(400, "No image uploaded to this widget yet")
|
raise HTTPException(400, "No image uploaded to this widget yet")
|
||||||
source = Image.open(io.BytesIO(scfg.image)).convert("RGB")
|
source = Image.open(io.BytesIO(scfg.image)).convert("RGB")
|
||||||
png = render_preview_png(
|
if scfg.render_style == "modern":
|
||||||
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
from .. import html_render
|
||||||
display_mode=scfg.display_mode, color_boost=frame.color_boost,
|
|
||||||
contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength,
|
target_w, target_h = logical_render_size(frame.orientation)
|
||||||
)
|
composed = compose_into(source, faces=None, target_w=target_w, target_h=target_h,
|
||||||
|
display_mode=scfg.display_mode)
|
||||||
|
fitted = _enhance(composed, frame.color_boost, frame.contrast_boost)
|
||||||
|
img = html_render.build_framed_image(fitted, target_w, target_h, frame.palette_rgb, frame.theme, "static")
|
||||||
|
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
|
||||||
|
png = _png_bytes(quantized)
|
||||||
|
else:
|
||||||
|
png = render_preview_png(
|
||||||
|
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
||||||
|
display_mode=scfg.display_mode, color_boost=frame.color_boost,
|
||||||
|
contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength,
|
||||||
|
)
|
||||||
return Response(content=png, media_type="image/png")
|
return Response(content=png, media_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
@@ -899,7 +1285,27 @@ def api_widget_preview_text(
|
|||||||
xcfg = db.get(TextWidgetConfig, widget.id)
|
xcfg = db.get(TextWidgetConfig, widget.id)
|
||||||
if not has_text(xcfg.content):
|
if not has_text(xcfg.content):
|
||||||
raise HTTPException(400, "No text authored on this widget yet")
|
raise HTTPException(400, "No text authored on this widget yet")
|
||||||
png = text_widget.render_preview_png(xcfg, orientation=frame.orientation, palette_rgb=frame.palette_rgb)
|
png = text_widget.render_preview_png(xcfg, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
||||||
|
theme_name=frame.theme)
|
||||||
|
return Response(content=png, media_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Battery: preview --------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/battery")
|
||||||
|
def api_widget_preview_battery(
|
||||||
|
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Unlike every other preview endpoint, there's no "not configured
|
||||||
|
yet" 400 case -- the content is frame-level state (battery_percent)
|
||||||
|
that either exists or doesn't, and render() already degrades to a
|
||||||
|
"No reports yet" placeholder either way, same as a live device
|
||||||
|
render would."""
|
||||||
|
frame, widget = frame_widget
|
||||||
|
_require_widget_type(widget, "battery")
|
||||||
|
png = battery_widget.render_preview_png(
|
||||||
|
db, frame, widget, orientation=frame.orientation, palette_rgb=frame.palette_rgb
|
||||||
|
)
|
||||||
return Response(content=png, media_type="image/png")
|
return Response(content=png, media_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
@@ -996,8 +1402,19 @@ def api_widget_preview_whiteboard(
|
|||||||
raise HTTPException(400, "No whiteboard configured on this widget yet")
|
raise HTTPException(400, "No whiteboard configured on this widget yet")
|
||||||
raise HTTPException(502, "Could not fetch/render the whiteboard yet -- check the URL and credentials")
|
raise HTTPException(502, "Could not fetch/render the whiteboard yet -- check the URL and credentials")
|
||||||
source = Image.open(io.BytesIO(png_bytes)).convert("RGB")
|
source = Image.open(io.BytesIO(png_bytes)).convert("RGB")
|
||||||
png = render_preview_png(
|
if wcfg.render_style == "modern":
|
||||||
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
from .. import html_render
|
||||||
display_mode="letterbox",
|
|
||||||
)
|
target_w, target_h = logical_render_size(frame.orientation)
|
||||||
|
composed = compose_into(source, faces=None, target_w=target_w, target_h=target_h,
|
||||||
|
display_mode="letterbox")
|
||||||
|
img = html_render.build_framed_image(composed, target_w, target_h, frame.palette_rgb, frame.theme,
|
||||||
|
"whiteboard")
|
||||||
|
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
|
||||||
|
png = _png_bytes(quantized)
|
||||||
|
else:
|
||||||
|
png = render_preview_png(
|
||||||
|
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
||||||
|
display_mode="letterbox",
|
||||||
|
)
|
||||||
return Response(content=png, media_type="image/png")
|
return Response(content=png, media_type="image/png")
|
||||||
|
|||||||
+139
-13
@@ -31,6 +31,7 @@ from ..models import (
|
|||||||
TaskWidgetConfig,
|
TaskWidgetConfig,
|
||||||
User,
|
User,
|
||||||
Widget,
|
Widget,
|
||||||
|
WeatherWidgetConfig,
|
||||||
WhiteboardWidgetConfig,
|
WhiteboardWidgetConfig,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -58,6 +59,12 @@ MIN_ESTIMATE_SAMPLES = 5 # discharge steps needed before trusting the average
|
|||||||
# single noisy reading needs rejecting at the per-wake-drop level, not
|
# single noisy reading needs rejecting at the per-wake-drop level, not
|
||||||
# just at the recharge-detection level.
|
# just at the recharge-detection level.
|
||||||
OUTLIER_MODIFIED_Z_THRESHOLD = 3.5
|
OUTLIER_MODIFIED_Z_THRESHOLD = 3.5
|
||||||
|
# Readings considered on each side of a given reading when
|
||||||
|
# _smooth_percents looks for local outliers. Needs to be at least half
|
||||||
|
# the length of the longest bad-reading burst a noisy divider produces
|
||||||
|
# (observed up to ~4 consecutive corrupted reports on one frame) so the
|
||||||
|
# good neighbors still outnumber the bad ones in the window.
|
||||||
|
BATTERY_SMOOTHING_WINDOW = 4
|
||||||
|
|
||||||
# "Overdue" threshold multiplier: the device should check in roughly every
|
# "Overdue" threshold multiplier: the device should check in roughly every
|
||||||
# refresh_interval_s; give it half again as long before flagging it.
|
# refresh_interval_s; give it half again as long before flagging it.
|
||||||
@@ -178,6 +185,50 @@ def _reject_outlier_drops(steps: list[tuple[int, float]]) -> list[tuple[int, flo
|
|||||||
return kept or steps # never filter down to nothing
|
return kept or steps # never filter down to nothing
|
||||||
|
|
||||||
|
|
||||||
|
def _smooth_percents(percents: list[int]) -> list[float]:
|
||||||
|
"""Replaces any reading that's a wild outlier against its own local
|
||||||
|
neighborhood with that neighborhood's median, before per-wake drop
|
||||||
|
steps are ever built from the series.
|
||||||
|
|
||||||
|
_reject_outlier_drops (above) only catches a bad reading by how much
|
||||||
|
it distorts the *steps* immediately on either side of it -- which is
|
||||||
|
exactly what one isolated glitch does, but a 1M-ohm divider (see
|
||||||
|
firmware/main/battery.c) doesn't always misfire in isolation: several
|
||||||
|
consecutive reports can drift or glitch together (a multi-minute
|
||||||
|
crawl from 68 up into the high 70s with nothing charging, or a run of
|
||||||
|
several ~40 reports spliced into an otherwise flat ~53 run). A step
|
||||||
|
computed *between* two bad readings in the same burst looks like an
|
||||||
|
ordinary small change, not an outlier, so it sails through
|
||||||
|
_reject_outlier_drops untouched.
|
||||||
|
|
||||||
|
A Hampel identifier catches that instead: each reading is compared to
|
||||||
|
the median of its own local window (not the whole series), using the
|
||||||
|
same MAD-based modified z-score as _reject_outlier_drops so this
|
||||||
|
adapts to how noisy a given frame's sensor actually is rather than a
|
||||||
|
fixed percent-point cutoff. A window of BATTERY_SMOOTHING_WINDOW
|
||||||
|
reports on each side tolerates a bad burst up to that long while
|
||||||
|
still being outvoted by the surrounding good readings."""
|
||||||
|
n = len(percents)
|
||||||
|
smoothed = list(percents)
|
||||||
|
for i in range(n):
|
||||||
|
lo = max(0, i - BATTERY_SMOOTHING_WINDOW)
|
||||||
|
hi = min(n, i + BATTERY_SMOOTHING_WINDOW + 1)
|
||||||
|
neighborhood = percents[lo:hi]
|
||||||
|
median = statistics.median(neighborhood)
|
||||||
|
abs_devs = [abs(v - median) for v in neighborhood]
|
||||||
|
# Unlike _reject_outlier_drops, no mean-of-abs-devs fallback here:
|
||||||
|
# a burst can be a big enough share of this small a window that
|
||||||
|
# the mean itself gets dragged up by the very values being
|
||||||
|
# tested, hiding them. A flat 1-percentage-point floor -- this
|
||||||
|
# project's smallest real unit of noise -- keeps the test from
|
||||||
|
# dividing by zero without being skewed by the burst it's
|
||||||
|
# checking.
|
||||||
|
mad = statistics.median(abs_devs) or 1
|
||||||
|
if abs(0.6745 * (percents[i] - median) / mad) > OUTLIER_MODIFIED_Z_THRESHOLD:
|
||||||
|
smoothed[i] = median
|
||||||
|
return smoothed
|
||||||
|
|
||||||
|
|
||||||
def battery_estimate_s(frame: Frame, db: Session) -> int | None:
|
def battery_estimate_s(frame: Frame, db: Session) -> int | None:
|
||||||
"""Remaining-time estimate from a recency-weighted average of the
|
"""Remaining-time estimate from a recency-weighted average of the
|
||||||
*per-wake* percent drop, over the last BATTERY_ESTIMATE_SAMPLE_COUNT
|
*per-wake* percent drop, over the last BATTERY_ESTIMATE_SAMPLE_COUNT
|
||||||
@@ -188,18 +239,21 @@ def battery_estimate_s(frame: Frame, db: Session) -> int | None:
|
|||||||
|
|
||||||
Consecutive reports are assumed to be consecutive wakes (firmware
|
Consecutive reports are assumed to be consecutive wakes (firmware
|
||||||
reports battery on every wake while on battery), so each step's
|
reports battery on every wake while on battery), so each step's
|
||||||
(prev_percent - next_percent) is that wake's cost. A step where
|
(prev_percent - next_percent) is that wake's cost. Raw percents go
|
||||||
percent went *up* is a recharge, not negative drain, and is skipped
|
through _smooth_percents first, which corrects readings (including
|
||||||
entirely rather than folded in as a weird outlier; a flat step
|
short bursts of them) that are wild outliers against their own local
|
||||||
(0% change) still counts as a real, cheap wake -- excluding those
|
neighborhood -- see that function's docstring for why that catches
|
||||||
would systematically overstate the per-wake cost by only counting
|
noise shapes _reject_outlier_drops can't. A step where percent went
|
||||||
the wakes that happened to tick the percentage down. The remaining
|
*up* is a recharge, not negative drain, and is skipped entirely
|
||||||
steps then get one more pass, _reject_outlier_drops, to catch the
|
rather than folded in as a weird outlier; a flat step (0% change)
|
||||||
single-noisy-reading case that "percent went up" alone can't (see
|
still counts as a real, cheap wake -- excluding those would
|
||||||
that function's docstring). Steps are weighted linearly by recency
|
systematically overstate the per-wake cost by only counting the
|
||||||
(step i of n gets weight i, 1-indexed) so a recent change in usage
|
wakes that happened to tick the percentage down. The remaining steps
|
||||||
pattern shows up quickly instead of being washed out by a long flat
|
then get one more pass, _reject_outlier_drops, to catch whatever
|
||||||
history.
|
single-noisy-reading shape survives smoothing (see that function's
|
||||||
|
docstring). Steps are weighted linearly by recency (step i of n gets
|
||||||
|
weight i, 1-indexed) so a recent change in usage pattern shows up
|
||||||
|
quickly instead of being washed out by a long flat history.
|
||||||
|
|
||||||
The resulting %/wake rate is then converted to wall-clock time using
|
The resulting %/wake rate is then converted to wall-clock time using
|
||||||
the frame's *current* refresh_interval_s and quiet-hours settings
|
the frame's *current* refresh_interval_s and quiet-hours settings
|
||||||
@@ -219,6 +273,7 @@ def battery_estimate_s(frame: Frame, db: Session) -> int | None:
|
|||||||
if len(rows) < MIN_ESTIMATE_SAMPLES + 1:
|
if len(rows) < MIN_ESTIMATE_SAMPLES + 1:
|
||||||
return None
|
return None
|
||||||
percents = list(reversed(rows)) # chronological order
|
percents = list(reversed(rows)) # chronological order
|
||||||
|
percents = _smooth_percents(percents)
|
||||||
|
|
||||||
steps: list[tuple[int, float]] = [] # (recency_weight, drop_pct)
|
steps: list[tuple[int, float]] = [] # (recency_weight, drop_pct)
|
||||||
for i in range(1, len(percents)):
|
for i in range(1, len(percents)):
|
||||||
@@ -449,7 +504,15 @@ def build_manage_content(db: Session, frame: Frame, request) -> dict:
|
|||||||
exif = asset.get("exifInfo") or {}
|
exif = asset.get("exifInfo") or {}
|
||||||
content["location_lines"] = _format_location(exif)
|
content["location_lines"] = _format_location(exif)
|
||||||
content["taken_at"] = _format_taken_at(exif)
|
content["taken_at"] = _format_taken_at(exif)
|
||||||
content["share_url"] = f"{base}/frame/share/{primary_cfg.current_asset_id}"
|
|
||||||
|
# Unlike location/date-taken (a single fixed corner, so tied to the
|
||||||
|
# one "primary" widget above), the share link covers every photo
|
||||||
|
# widget's current photo (see manage.manage_share) -- so it only
|
||||||
|
# needs *some* photo widget to have a current photo, not specifically
|
||||||
|
# the primary one, and doesn't depend on the EXIF fetch above
|
||||||
|
# succeeding.
|
||||||
|
if any(db.get(PhotoWidgetConfig, w.id).current_asset_id for w in photo_widgets):
|
||||||
|
content["share_url"] = f"{base}/frame/share/{frame.manage_token}"
|
||||||
|
|
||||||
panel_w, panel_h = logical_render_size(frame.orientation)
|
panel_w, panel_h = logical_render_size(frame.orientation)
|
||||||
face_labels: list[dict] = []
|
face_labels: list[dict] = []
|
||||||
@@ -566,6 +629,69 @@ def get_or_refresh_weather_for_widget(db: Session, frame: Frame, widget: Widget)
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
HOURLY_FETCH_HOURS = 48 # 2 days -- comfortably covers every hourly_interval_hours option (3/4/6/12) at any widget width
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_refresh_weather_widget_data(db: Session, frame: Frame, widget: Widget, force: bool = False):
|
||||||
|
"""Throttled fetch cache (weather.CHECK_INTERVAL_S) for the standalone
|
||||||
|
weather widget (see app/widgets/weather.py) -- reads/writes
|
||||||
|
WeatherWidgetConfig. What gets fetched depends on cfg.mode: current/
|
||||||
|
hourly/daily need a single configured location (city_latitude/
|
||||||
|
city_longitude); multi_city needs cfg.cities. None if not configured
|
||||||
|
yet, so render() falls back to a placeholder -- same convention as
|
||||||
|
get_or_refresh_whiteboard_for_widget. force=True (the "Refresh now"
|
||||||
|
button) bypasses the throttle entirely.
|
||||||
|
|
||||||
|
A single-location mode's fetch failure keeps the last-known cached
|
||||||
|
value (same reasoning as get_or_refresh_whiteboard_for_widget); a
|
||||||
|
multi_city fetch fails per-city (like get_or_refresh_weather_for_
|
||||||
|
widget's calendar-strip counterpart) so one broken city doesn't blank
|
||||||
|
the others."""
|
||||||
|
cfg = db.get(WeatherWidgetConfig, widget.id)
|
||||||
|
if cfg.mode == "multi_city":
|
||||||
|
if not cfg.cities:
|
||||||
|
return None
|
||||||
|
elif cfg.city_latitude is None or cfg.city_longitude is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
if not force and cfg.cached is not None and now - cfg.checked_at < weather.CHECK_INTERVAL_S:
|
||||||
|
return cfg.cached
|
||||||
|
|
||||||
|
if cfg.mode == "multi_city":
|
||||||
|
previous = {c["label"]: c for c in (cfg.cached or [])}
|
||||||
|
result = []
|
||||||
|
for city in cfg.cities:
|
||||||
|
try:
|
||||||
|
today = weather.fetch_daily(cfg.provider, city["latitude"], city["longitude"], cfg.units, 1)
|
||||||
|
d = next(iter(today.values())) if today else previous.get(city["label"], {})
|
||||||
|
except weather.WeatherFetchError as e:
|
||||||
|
logger.warning("Could not refresh weather for %s: %s", city["label"], e)
|
||||||
|
d = previous.get(city["label"], {})
|
||||||
|
result.append({
|
||||||
|
"label": city["label"], "high": d.get("high"), "low": d.get("low"),
|
||||||
|
"category": d.get("category", "cloudy"),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
if cfg.mode == "current":
|
||||||
|
result = weather.fetch_current(cfg.provider, cfg.city_latitude, cfg.city_longitude, cfg.units)
|
||||||
|
elif cfg.mode == "hourly":
|
||||||
|
result = weather.fetch_hourly(cfg.provider, cfg.city_latitude, cfg.city_longitude, cfg.units,
|
||||||
|
hours=HOURLY_FETCH_HOURS)
|
||||||
|
else: # "daily"
|
||||||
|
result = weather.fetch_daily(cfg.provider, cfg.city_latitude, cfg.city_longitude, cfg.units,
|
||||||
|
cfg.daily_days)
|
||||||
|
except weather.WeatherFetchError as e:
|
||||||
|
logger.warning("Could not refresh weather widget %d: %s", widget.id, e)
|
||||||
|
return cfg.cached
|
||||||
|
|
||||||
|
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
|
||||||
|
locked_cfg.cached = result
|
||||||
|
locked_cfg.checked_at = now
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
TASKS_COMPLETED_WINDOW_HOURS = 24 # how far back TaskWidgetConfig.show_completed looks
|
TASKS_COMPLETED_WINDOW_HOURS = 24 # how far back TaskWidgetConfig.show_completed looks
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+163
-68
@@ -14,20 +14,21 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.responses import FileResponse, RedirectResponse, Response
|
from fastapi.responses import FileResponse, Response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .. import grid, mail, quiet_hours
|
from .. import grid, mail, quiet_hours
|
||||||
from ..auth import get_server_settings, require_device
|
from ..auth import get_server_settings, require_device
|
||||||
from ..db import frame_locked, get_db
|
from ..db import SessionLocal, frame_locked, get_db
|
||||||
from ..firmware import firmware_path
|
from ..firmware import firmware_path
|
||||||
from ..image_pipeline import logical_render_size, render_panel, render_placeholder
|
from ..global_actions import GLOBAL_ACTIONS
|
||||||
from ..models import BatteryLog, Frame, FrameButtonAction, PhotoWidgetConfig, Widget
|
from ..image_pipeline import draw_widget_border, logical_render_size, render_panel, render_placeholder, resolve_border_color
|
||||||
|
from ..models import BatteryLog, Frame, FrameButtonAction, Widget
|
||||||
from ..widgets import WIDGET_TYPES
|
from ..widgets import WIDGET_TYPES
|
||||||
from .common import (
|
from .common import (
|
||||||
BATTERY_HISTORY_MAX,
|
BATTERY_HISTORY_MAX,
|
||||||
@@ -35,9 +36,6 @@ from .common import (
|
|||||||
RECHARGE_JUMP_PCT,
|
RECHARGE_JUMP_PCT,
|
||||||
RECHARGE_LOOKBACK,
|
RECHARGE_LOOKBACK,
|
||||||
build_manage_content,
|
build_manage_content,
|
||||||
immich_client_for,
|
|
||||||
immich_creds,
|
|
||||||
photo_widgets_for_frame,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -46,7 +44,7 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = None,
|
def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = None,
|
||||||
as_png: bool = False) -> bytes:
|
as_png: bool = False, capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]:
|
||||||
"""What an unclaimed or widget-less frame displays instead of real
|
"""What an unclaimed or widget-less frame displays instead of real
|
||||||
content -- instructions with a QR, rendered at 200 so the device
|
content -- instructions with a QR, rendered at 200 so the device
|
||||||
treats it as a perfectly normal image and never error-loops. The
|
treats it as a perfectly normal image and never error-loops. The
|
||||||
@@ -63,6 +61,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
|
|||||||
palette_rgb=frame.palette_rgb,
|
palette_rgb=frame.palette_rgb,
|
||||||
manage=manage,
|
manage=manage,
|
||||||
as_png=as_png,
|
as_png=as_png,
|
||||||
|
capture_snapshot=capture_snapshot,
|
||||||
)
|
)
|
||||||
if frame.owner_user_id is None:
|
if frame.owner_user_id is None:
|
||||||
return render_placeholder(
|
return render_placeholder(
|
||||||
@@ -71,6 +70,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
|
|||||||
palette_rgb=frame.palette_rgb,
|
palette_rgb=frame.palette_rgb,
|
||||||
manage=manage,
|
manage=manage,
|
||||||
as_png=as_png,
|
as_png=as_png,
|
||||||
|
capture_snapshot=capture_snapshot,
|
||||||
)
|
)
|
||||||
return render_placeholder(
|
return render_placeholder(
|
||||||
["Almost there!", "Add a widget for this frame at", base],
|
["Almost there!", "Add a widget for this frame at", base],
|
||||||
@@ -79,40 +79,94 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
|
|||||||
palette_rgb=frame.palette_rgb,
|
palette_rgb=frame.palette_rgb,
|
||||||
manage=manage,
|
manage=manage,
|
||||||
as_png=as_png,
|
as_png=as_png,
|
||||||
|
capture_snapshot=capture_snapshot,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_one_widget(frame_id: int, widget_id: int, orientation: str, panel_w: int, panel_h: int,
|
||||||
|
cell: tuple[int, int, int, int], is_normal_wake: bool,
|
||||||
|
) -> tuple[tuple[int, int, int, int], object] | None:
|
||||||
|
"""Renders exactly one widget on its own DB session, so several of
|
||||||
|
these can run concurrently in a thread pool -- see app/db.py's
|
||||||
|
module docstring: handlers already run multi-threaded (sync
|
||||||
|
handlers in FastAPI's threadpool, one process), and frame_locked/
|
||||||
|
widget_locked's per-frame threading.Lock is what makes that safe,
|
||||||
|
not anything about which Session object is in play. A SQLAlchemy
|
||||||
|
Session itself is never safe to share across threads, so each
|
||||||
|
concurrent render gets a fresh one rather than reusing the
|
||||||
|
request's. Most of a widget's render time is spent waiting on an
|
||||||
|
external call (Immich, a weather provider, CalDAV) with the DB
|
||||||
|
untouched, which is exactly the time this buys back."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
frame = db.get(Frame, frame_id)
|
||||||
|
widget = db.get(Widget, widget_id)
|
||||||
|
if widget is None:
|
||||||
|
return None # deleted between the listing query and this fetch -- skip it, not a 500
|
||||||
|
module = WIDGET_TYPES.get(widget.widget_type)
|
||||||
|
if module is None:
|
||||||
|
return None # unrecognized widget_type -- shouldn't happen, skip defensively rather than 500
|
||||||
|
px, py, pw, ph = grid.cell_to_pixels(orientation, panel_w, panel_h, cell)
|
||||||
|
img = module.render(db, frame, widget, pw, ph, is_normal_wake=is_normal_wake)
|
||||||
|
draw_widget_border(
|
||||||
|
img, widget.border_style, widget.border_thickness,
|
||||||
|
resolve_border_color(widget.border_color_index, frame.palette_rgb),
|
||||||
|
)
|
||||||
|
return (px, py, pw, ph), img
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wake: bool,
|
def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wake: bool,
|
||||||
as_png: bool = False) -> bytes:
|
as_png: bool = False, capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]:
|
||||||
"""The widget-system compositor: renders every widget on this frame
|
"""The widget-system compositor: renders every widget on this frame
|
||||||
into its own region (see app/grid.py for grid-cell -> pixel math) and
|
into its own region (see app/grid.py for grid-cell -> pixel math),
|
||||||
hands the results to image_pipeline.render_panel for the single
|
draws that widget's own optional border directly onto its region
|
||||||
shared paste/enhance/overlay/quantize/pack pass. Replaces the old
|
(models.Widget.border_style, a shared per-widget property no
|
||||||
per-mode RENDERERS dict -- a frame can now show several widgets at
|
widget_type module needs to know about) and hands the results to
|
||||||
once instead of exactly one mode owning the whole panel."""
|
image_pipeline.render_panel for the single shared paste/enhance/
|
||||||
|
overlay/quantize/pack pass. Replaces the old per-mode RENDERERS
|
||||||
|
dict -- a frame can now show several widgets at once instead of
|
||||||
|
exactly one mode owning the whole panel.
|
||||||
|
|
||||||
|
Widgets render concurrently (_render_one_widget, each on its own DB
|
||||||
|
session) rather than one at a time -- a layout with several
|
||||||
|
network-backed widgets (photos, weather, calendar) previously paid
|
||||||
|
their fetch latency serially, which could push a single /frame/*
|
||||||
|
response past the firmware's fixed HTTP timeout and show a
|
||||||
|
misleading "server failed" status screen even though the server
|
||||||
|
was simply still working. Futures are submitted in sort_order and
|
||||||
|
collected in that same order (not completion order) -- overlapping
|
||||||
|
widgets must still paint in the original z-order."""
|
||||||
all_widgets = db.scalars(
|
all_widgets = db.scalars(
|
||||||
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
||||||
).all()
|
).all()
|
||||||
panel_w, panel_h = logical_render_size(frame.orientation)
|
panel_w, panel_h = logical_render_size(frame.orientation)
|
||||||
regions = []
|
regions = []
|
||||||
for widget in all_widgets:
|
if all_widgets:
|
||||||
module = WIDGET_TYPES.get(widget.widget_type)
|
with ThreadPoolExecutor(max_workers=min(len(all_widgets), 8)) as pool:
|
||||||
if module is None:
|
futures = [
|
||||||
continue # unrecognized widget_type -- shouldn't happen, skip defensively rather than 500
|
pool.submit(
|
||||||
px, py, pw, ph = grid.cell_to_pixels(
|
_render_one_widget, frame.id, widget.id, frame.orientation, panel_w, panel_h,
|
||||||
frame.orientation, panel_w, panel_h, (widget.x, widget.y, widget.w, widget.h)
|
(widget.x, widget.y, widget.w, widget.h), is_normal_wake,
|
||||||
)
|
)
|
||||||
img = module.render(db, frame, widget, pw, ph, is_normal_wake=is_normal_wake)
|
for widget in all_widgets
|
||||||
regions.append(((px, py, pw, ph), img))
|
]
|
||||||
|
for future in futures:
|
||||||
|
result = future.result()
|
||||||
|
if result is not None:
|
||||||
|
regions.append(result)
|
||||||
return render_panel(
|
return render_panel(
|
||||||
regions, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
regions, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
||||||
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
|
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
|
||||||
dither_strength=frame.dither_strength, manage=manage, as_png=as_png,
|
dither_strength=frame.dither_strength, manage=manage, as_png=as_png,
|
||||||
|
capture_snapshot=capture_snapshot,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _render_frame_content(db: Session, frame: Frame, request: Request | None, manage: dict | None,
|
def _render_frame_content(db: Session, frame: Frame, request: Request | None, manage: dict | None,
|
||||||
is_normal_wake: bool, as_png: bool = False) -> bytes:
|
is_normal_wake: bool, as_png: bool = False,
|
||||||
|
capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]:
|
||||||
"""The top-level "what does this frame show right now" entry point.
|
"""The top-level "what does this frame show right now" entry point.
|
||||||
An unclaimed frame or one with no widgets yet gets the setup
|
An unclaimed frame or one with no widgets yet gets the setup
|
||||||
placeholder (needs `request` for its QR URLs -- only available on the
|
placeholder (needs `request` for its QR URLs -- only available on the
|
||||||
@@ -130,11 +184,11 @@ def _render_frame_content(db: Session, frame: Frame, request: Request | None, ma
|
|||||||
if request is None:
|
if request is None:
|
||||||
return render_placeholder(
|
return render_placeholder(
|
||||||
["Almost there!"], orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
["Almost there!"], orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
||||||
manage=manage, as_png=as_png,
|
manage=manage, as_png=as_png, capture_snapshot=capture_snapshot,
|
||||||
)
|
)
|
||||||
return _setup_placeholder(frame, request, manage=manage, as_png=as_png)
|
return _setup_placeholder(frame, request, manage=manage, as_png=as_png, capture_snapshot=capture_snapshot)
|
||||||
|
|
||||||
return _render_widgets(db, frame, manage, is_normal_wake, as_png=as_png)
|
return _render_widgets(db, frame, manage, is_normal_wake, as_png=as_png, capture_snapshot=capture_snapshot)
|
||||||
|
|
||||||
|
|
||||||
def render_frame_preview_png(db: Session, frame: Frame, request: Request) -> bytes:
|
def render_frame_preview_png(db: Session, frame: Frame, request: Request) -> bytes:
|
||||||
@@ -181,6 +235,22 @@ def _run_button_actions(db: Session, frame: Frame, button: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_global_action(db: Session, frame: Frame, button: str) -> None:
|
||||||
|
"""The hold-triggered counterpart to _run_button_actions -- runs
|
||||||
|
whichever entry in app/global_actions.GLOBAL_ACTIONS this button's
|
||||||
|
Frame.next_hold_action/back_hold_action points to, if any (unset or
|
||||||
|
unrecognized is a silent no-op, same posture as an unbound short-
|
||||||
|
press button). See routers/device.py's frame_global_next/back."""
|
||||||
|
action = frame.next_hold_action if button == "next" else frame.back_hold_action
|
||||||
|
action_fn = GLOBAL_ACTIONS.get(action) if action else None
|
||||||
|
if action_fn is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
action_fn(db, frame)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Global hold action %r failed for frame %d", action, frame.id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/frame/config")
|
@router.get("/frame/config")
|
||||||
def frame_config(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
def frame_config(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||||
"""Device-facing settings, polled by the frame alongside its
|
"""Device-facing settings, polled by the frame alongside its
|
||||||
@@ -206,12 +276,16 @@ def frame_config(request: Request, frame: Frame = Depends(require_device), db: S
|
|||||||
response = {
|
response = {
|
||||||
"refresh_interval_s": quiet_hours.effective_refresh_interval_s(locked),
|
"refresh_interval_s": quiet_hours.effective_refresh_interval_s(locked),
|
||||||
"firmware_version": locked.firmware_available_version or None,
|
"firmware_version": locked.firmware_available_version or None,
|
||||||
|
# Additive key -- old firmware's hand-rolled parser only ever
|
||||||
|
# extracts the keys it knows about, so this is safe for
|
||||||
|
# firmware that predates hold-for-global-action (see
|
||||||
|
# firmware/main/next_button.c, app/global_actions.py).
|
||||||
|
"hold_duration_ms": locked.hold_duration_ms,
|
||||||
}
|
}
|
||||||
# Per-frame token push: only once the device has introduced itself
|
# Per-frame token push: only until the device has authenticated
|
||||||
# by id (so the response to pure-legacy firmware stays byte-
|
# with it once (device_token_ack) -- no reason to keep sending it
|
||||||
# compatible with its 256-byte parse buffer), and only until the
|
# on every wake once the device has it.
|
||||||
# device has authenticated with the token once (device_token_ack).
|
if not locked.device_token_ack:
|
||||||
if locked.device_id is not None and not locked.device_token_ack:
|
|
||||||
response["device_token"] = locked.device_token
|
response["device_token"] = locked.device_token
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -220,6 +294,17 @@ def _manage_flag(request: Request) -> bool:
|
|||||||
return request.query_params.get("manage") == "1"
|
return request.query_params.get("manage") == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def _record_last_displayed(db: Session, frame: Frame, png_snapshot: bytes) -> None:
|
||||||
|
"""Persists exactly what a device-facing render just sent (upright
|
||||||
|
PNG, manage overlay included if present -- whatever's actually on the
|
||||||
|
panel) as this frame's "now displaying" snapshot, the frozen half of
|
||||||
|
the web UI's header preview pair (see api_frames.py's /now-displaying
|
||||||
|
endpoint and its always-live "up next" counterpart, /preview)."""
|
||||||
|
with frame_locked(db, frame.id) as locked:
|
||||||
|
locked.last_displayed_image = png_snapshot
|
||||||
|
locked.last_displayed_at = time.time()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/frame/image")
|
@router.get("/frame/image")
|
||||||
def frame_image(
|
def frame_image(
|
||||||
request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
|
request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
|
||||||
@@ -237,9 +322,14 @@ def frame_image(
|
|||||||
?manage=1 (the manage button) composites the manage overlay onto
|
?manage=1 (the manage button) composites the manage overlay onto
|
||||||
whatever this would have returned anyway -- see build_manage_content.
|
whatever this would have returned anyway -- see build_manage_content.
|
||||||
This is also the "normal wake" that resets any calendar widget's
|
This is also the "normal wake" that resets any calendar widget's
|
||||||
browse position back to today (see app/widgets/calendar.py)."""
|
browse position back to today (see app/widgets/calendar.py).
|
||||||
|
|
||||||
|
Also records what's returned as this frame's "now displaying"
|
||||||
|
snapshot (see _record_last_displayed) -- every other device-facing
|
||||||
|
render endpoint below does the same."""
|
||||||
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
||||||
content = _render_frame_content(db, frame, request, manage, is_normal_wake=True)
|
content, snapshot = _render_frame_content(db, frame, request, manage, is_normal_wake=True, capture_snapshot=True)
|
||||||
|
_record_last_displayed(db, frame, snapshot)
|
||||||
return Response(content=content, media_type="application/octet-stream")
|
return Response(content=content, media_type="application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
@@ -252,7 +342,10 @@ def frame_advance(request: Request, frame: Frame = Depends(require_device), db:
|
|||||||
device's next-photo button."""
|
device's next-photo button."""
|
||||||
_run_button_actions(db, frame, "next")
|
_run_button_actions(db, frame, "next")
|
||||||
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
||||||
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
|
content, snapshot = _render_frame_content(
|
||||||
|
db, frame, request=None, manage=manage, is_normal_wake=False, capture_snapshot=True
|
||||||
|
)
|
||||||
|
_record_last_displayed(db, frame, snapshot)
|
||||||
return Response(content=content, media_type="application/octet-stream")
|
return Response(content=content, media_type="application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
@@ -263,7 +356,40 @@ def frame_back(request: Request, frame: Frame = Depends(require_device), db: Ses
|
|||||||
with nothing to go back to. Used by the device's back-photo button."""
|
with nothing to go back to. Used by the device's back-photo button."""
|
||||||
_run_button_actions(db, frame, "back")
|
_run_button_actions(db, frame, "back")
|
||||||
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
||||||
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
|
content, snapshot = _render_frame_content(
|
||||||
|
db, frame, request=None, manage=manage, is_normal_wake=False, capture_snapshot=True
|
||||||
|
)
|
||||||
|
_record_last_displayed(db, frame, snapshot)
|
||||||
|
return Response(content=content, media_type="application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/frame/global-next")
|
||||||
|
def frame_global_next(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||||
|
"""Fires when the device detects NEXT held past Frame.hold_duration_ms
|
||||||
|
instead of a short press -- runs Frame.next_hold_action (see
|
||||||
|
app/global_actions.GLOBAL_ACTIONS) if one is set, then re-renders and
|
||||||
|
returns the whole panel same as /frame/advance. A separate endpoint
|
||||||
|
from /frame/advance (not a query flag on it) so the frozen short-press
|
||||||
|
path's behavior never has to account for the long-press case -- see
|
||||||
|
firmware/main/next_button.c for the short/long split."""
|
||||||
|
_run_global_action(db, frame, "next")
|
||||||
|
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
||||||
|
content, snapshot = _render_frame_content(
|
||||||
|
db, frame, request=None, manage=manage, is_normal_wake=False, capture_snapshot=True
|
||||||
|
)
|
||||||
|
_record_last_displayed(db, frame, snapshot)
|
||||||
|
return Response(content=content, media_type="application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/frame/global-back")
|
||||||
|
def frame_global_back(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||||
|
"""The mirror of /frame/global-next, for a held BACK button."""
|
||||||
|
_run_global_action(db, frame, "back")
|
||||||
|
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
||||||
|
content, snapshot = _render_frame_content(
|
||||||
|
db, frame, request=None, manage=manage, is_normal_wake=False, capture_snapshot=True
|
||||||
|
)
|
||||||
|
_record_last_displayed(db, frame, snapshot)
|
||||||
return Response(content=content, media_type="application/octet-stream")
|
return Response(content=content, media_type="application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
@@ -356,34 +482,3 @@ def frame_firmware(frame: Frame = Depends(require_device)):
|
|||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise HTTPException(404, "No firmware uploaded")
|
raise HTTPException(404, "No firmware uploaded")
|
||||||
return FileResponse(path, media_type="application/octet-stream")
|
return FileResponse(path, media_type="application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/frame/share/{asset_id}")
|
|
||||||
def frame_share(asset_id: str, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
|
||||||
"""Creates a 30-minute public Immich share link for asset_id and
|
|
||||||
redirects to it -- what the manage overlay's bottom-left QR code
|
|
||||||
points to. The link is created lazily, when this actually gets hit
|
|
||||||
(i.e. when someone scans it), not when the manage button was
|
|
||||||
pressed, so the 30-minute window starts when it's actually used.
|
|
||||||
Also scoped to the photo currently showing or queued on one of THIS
|
|
||||||
frame's own photo widgets -- not any arbitrary Immich asset id -- as
|
|
||||||
a second layer even a leaked token wouldn't bypass."""
|
|
||||||
url, key = immich_creds(frame)
|
|
||||||
if not url or not key:
|
|
||||||
raise HTTPException(400, "Immich URL/API key not configured yet")
|
|
||||||
|
|
||||||
photo_widgets = photo_widgets_for_frame(db, frame)
|
|
||||||
showing_or_queued = any(
|
|
||||||
asset_id == cfg.current_asset_id or asset_id in cfg.queue
|
|
||||||
for cfg in (db.get(PhotoWidgetConfig, w.id) for w in photo_widgets)
|
|
||||||
)
|
|
||||||
if not showing_or_queued:
|
|
||||||
raise HTTPException(404, "That photo isn't currently showing or queued on this frame")
|
|
||||||
|
|
||||||
client = immich_client_for(frame)
|
|
||||||
try:
|
|
||||||
share_url = client.create_share_link(asset_id, expires_in_s=1800)
|
|
||||||
except httpx.HTTPError as e:
|
|
||||||
raise HTTPException(502, f"Could not create share link: {e}") from e
|
|
||||||
|
|
||||||
return RedirectResponse(share_url)
|
|
||||||
|
|||||||
@@ -17,19 +17,28 @@ from fastapi.templating import Jinja2Templates
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .. import panel_style, theme_tokens, weather
|
||||||
from ..auth import can_view_frame, current_user
|
from ..auth import can_view_frame, current_user
|
||||||
from ..calendar_render import CALENDAR_VIEW_LABELS
|
from ..calendar_render import CALENDAR_VIEW_LABELS
|
||||||
from ..db import get_db
|
from ..db import get_db
|
||||||
|
from ..global_actions import GLOBAL_ACTION_LABELS
|
||||||
from ..image_pipeline import (
|
from ..image_pipeline import (
|
||||||
|
BORDER_STYLES,
|
||||||
|
BORDER_STYLE_LABELS,
|
||||||
|
CALIBRATED_SPECTRA6_RGB,
|
||||||
DEFAULT_PALETTE_RGB,
|
DEFAULT_PALETTE_RGB,
|
||||||
DISPLAY_MODE_LABELS,
|
DISPLAY_MODE_LABELS,
|
||||||
|
MAX_BORDER_THICKNESS,
|
||||||
|
MIN_BORDER_THICKNESS,
|
||||||
PALETTE_LABELS,
|
PALETTE_LABELS,
|
||||||
STATIC_DISPLAY_MODES,
|
STATIC_DISPLAY_MODES,
|
||||||
palette_to_hex,
|
palette_to_hex,
|
||||||
)
|
)
|
||||||
from ..models import (
|
from ..models import (
|
||||||
|
BatteryWidgetConfig,
|
||||||
CalendarWidgetConfig,
|
CalendarWidgetConfig,
|
||||||
Frame,
|
Frame,
|
||||||
|
FrameButtonAction,
|
||||||
FrameCalendar,
|
FrameCalendar,
|
||||||
FrameTaskList,
|
FrameTaskList,
|
||||||
PhotoWidgetConfig,
|
PhotoWidgetConfig,
|
||||||
@@ -38,10 +47,12 @@ from ..models import (
|
|||||||
TextWidgetConfig,
|
TextWidgetConfig,
|
||||||
User,
|
User,
|
||||||
UserFrame,
|
UserFrame,
|
||||||
|
WeatherWidgetConfig,
|
||||||
WhiteboardWidgetConfig,
|
WhiteboardWidgetConfig,
|
||||||
Widget,
|
Widget,
|
||||||
)
|
)
|
||||||
from ..quiet_hours import ALL_TIMEZONES
|
from ..quiet_hours import ALL_TIMEZONES
|
||||||
|
from ..widgets import WIDGET_TYPES
|
||||||
from ..widgets import text as text_widget
|
from ..widgets import text as text_widget
|
||||||
from .common import shell_context, widget_of_type
|
from .common import shell_context, widget_of_type
|
||||||
|
|
||||||
@@ -79,8 +90,11 @@ def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get
|
|||||||
timezones=ALL_TIMEZONES,
|
timezones=ALL_TIMEZONES,
|
||||||
palette_labels=PALETTE_LABELS,
|
palette_labels=PALETTE_LABELS,
|
||||||
default_palette_rgb=DEFAULT_PALETTE_RGB,
|
default_palette_rgb=DEFAULT_PALETTE_RGB,
|
||||||
|
calibrated_spectra6_hex=palette_to_hex(CALIBRATED_SPECTRA6_RGB),
|
||||||
palette_to_hex=palette_to_hex,
|
palette_to_hex=palette_to_hex,
|
||||||
photo_widget_id=photo_widget_id,
|
photo_widget_id=photo_widget_id,
|
||||||
|
global_action_labels=GLOBAL_ACTION_LABELS,
|
||||||
|
themes=theme_tokens.THEMES,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -211,11 +225,54 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
|
|||||||
if widget is None or widget.frame_id != frame.id:
|
if widget is None or widget.frame_id != frame.id:
|
||||||
raise HTTPException(404, "No such widget")
|
raise HTTPException(404, "No such widget")
|
||||||
|
|
||||||
|
# Every dialog includes the shared "Border" card (_widget_border_fields.html,
|
||||||
|
# models.Widget.border_style/border_thickness/border_color_index) --
|
||||||
|
# a Widget-level property, not a per-type config field, so this
|
||||||
|
# context is the same regardless of widget_type.
|
||||||
|
border_ctx = {
|
||||||
|
"border_styles": BORDER_STYLES,
|
||||||
|
"border_style_labels": BORDER_STYLE_LABELS,
|
||||||
|
"border_color_labels": PALETTE_LABELS,
|
||||||
|
"default_palette_rgb": DEFAULT_PALETTE_RGB,
|
||||||
|
"palette_to_hex": palette_to_hex,
|
||||||
|
"min_border_thickness": MIN_BORDER_THICKNESS,
|
||||||
|
"max_border_thickness": MAX_BORDER_THICKNESS,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Every dialog also includes the shared "Button actions" card
|
||||||
|
# (_widget_button_fields.html) if this widget type supports any --
|
||||||
|
# empty for tasks/static/text/battery, so the card renders nothing
|
||||||
|
# for those. Bindings, not the type's own config, so this lives in
|
||||||
|
# FrameButtonAction (see models.py), same reasoning as border_ctx
|
||||||
|
# above for why it's a separate card/endpoint from the type-specific
|
||||||
|
# form.
|
||||||
|
bindings = {
|
||||||
|
row.button: row.action
|
||||||
|
for row in db.scalars(
|
||||||
|
select(FrameButtonAction).where(FrameButtonAction.widget_id == widget.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
button_ctx = {
|
||||||
|
"button_action_labels": WIDGET_TYPES[widget.widget_type].ACTION_LABELS,
|
||||||
|
"next_button_action": bindings.get("next", ""),
|
||||||
|
"back_button_action": bindings.get("back", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
# The shared "Text size" card (_widget_font_scale_fields.html,
|
||||||
|
# models.Widget.font_scale) -- only included on the dialogs whose
|
||||||
|
# on-panel content is mostly body text (calendar/tasks; the other
|
||||||
|
# types are either image-only or, for text, already have their own
|
||||||
|
# richer per-widget font_size control -- see TextWidgetConfig).
|
||||||
|
font_scale_labels = {1.0: "Normal", 1.25: "Large", 1.5: "X-Large"}
|
||||||
|
font_scale_ctx = {
|
||||||
|
"font_scale_choices": [(v, font_scale_labels[v]) for v in panel_style.FONT_SCALE_CHOICES],
|
||||||
|
}
|
||||||
|
|
||||||
if widget.widget_type == "photos":
|
if widget.widget_type == "photos":
|
||||||
photo_cfg = db.get(PhotoWidgetConfig, widget.id)
|
photo_cfg = db.get(PhotoWidgetConfig, widget.id)
|
||||||
return templates.TemplateResponse("_widget_dialog_photos.html", {
|
return templates.TemplateResponse("_widget_dialog_photos.html", {
|
||||||
"request": request, "frame": frame, "widget": widget, "photo_cfg": photo_cfg,
|
"request": request, "frame": frame, "widget": widget, "photo_cfg": photo_cfg,
|
||||||
"display_mode_labels": DISPLAY_MODE_LABELS,
|
"display_mode_labels": DISPLAY_MODE_LABELS, **border_ctx, **button_ctx,
|
||||||
})
|
})
|
||||||
|
|
||||||
if widget.widget_type == "calendar":
|
if widget.widget_type == "calendar":
|
||||||
@@ -226,8 +283,7 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
|
|||||||
"calendar_users": _calendar_users_for_widget(db, frame.id, widget.id, user.id),
|
"calendar_users": _calendar_users_for_widget(db, frame.id, widget.id, user.id),
|
||||||
"week_start_labels": WEEK_START_LABELS,
|
"week_start_labels": WEEK_START_LABELS,
|
||||||
"calendar_color_labels": PALETTE_LABELS,
|
"calendar_color_labels": PALETTE_LABELS,
|
||||||
"default_palette_rgb": DEFAULT_PALETTE_RGB,
|
**border_ctx, **button_ctx, **font_scale_ctx,
|
||||||
"palette_to_hex": palette_to_hex,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if widget.widget_type == "tasks":
|
if widget.widget_type == "tasks":
|
||||||
@@ -236,8 +292,7 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
|
|||||||
"request": request, "frame": frame, "widget": widget, "task_cfg": task_cfg, "user": user,
|
"request": request, "frame": frame, "widget": widget, "task_cfg": task_cfg, "user": user,
|
||||||
"task_users": _task_users_for_widget(db, frame.id, widget.id, user.id),
|
"task_users": _task_users_for_widget(db, frame.id, widget.id, user.id),
|
||||||
"task_color_labels": PALETTE_LABELS,
|
"task_color_labels": PALETTE_LABELS,
|
||||||
"default_palette_rgb": DEFAULT_PALETTE_RGB,
|
**border_ctx, **button_ctx, **font_scale_ctx,
|
||||||
"palette_to_hex": palette_to_hex,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if widget.widget_type == "static":
|
if widget.widget_type == "static":
|
||||||
@@ -245,13 +300,14 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
|
|||||||
return templates.TemplateResponse("_widget_dialog_static.html", {
|
return templates.TemplateResponse("_widget_dialog_static.html", {
|
||||||
"request": request, "frame": frame, "widget": widget, "static_cfg": static_cfg,
|
"request": request, "frame": frame, "widget": widget, "static_cfg": static_cfg,
|
||||||
"display_mode_labels": {k: v for k, v in DISPLAY_MODE_LABELS.items() if k in STATIC_DISPLAY_MODES},
|
"display_mode_labels": {k: v for k, v in DISPLAY_MODE_LABELS.items() if k in STATIC_DISPLAY_MODES},
|
||||||
|
**border_ctx, **button_ctx,
|
||||||
})
|
})
|
||||||
|
|
||||||
if widget.widget_type == "text":
|
if widget.widget_type == "text":
|
||||||
text_cfg = db.get(TextWidgetConfig, widget.id)
|
text_cfg = db.get(TextWidgetConfig, widget.id)
|
||||||
return templates.TemplateResponse("_widget_dialog_text.html", {
|
return templates.TemplateResponse("_widget_dialog_text.html", {
|
||||||
"request": request, "frame": frame, "widget": widget, "text_cfg": text_cfg,
|
"request": request, "frame": frame, "widget": widget, "text_cfg": text_cfg,
|
||||||
"text_font_families": text_widget.FONT_FAMILIES,
|
"text_font_families": text_widget.FONT_FAMILIES, **border_ctx, **button_ctx,
|
||||||
})
|
})
|
||||||
|
|
||||||
if widget.widget_type == "whiteboard":
|
if widget.widget_type == "whiteboard":
|
||||||
@@ -261,8 +317,22 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
|
|||||||
)
|
)
|
||||||
return templates.TemplateResponse("_widget_dialog_whiteboard.html", {
|
return templates.TemplateResponse("_widget_dialog_whiteboard.html", {
|
||||||
"request": request, "frame": frame, "widget": widget, "user": user,
|
"request": request, "frame": frame, "widget": widget, "user": user,
|
||||||
|
"whiteboard_cfg": whiteboard_cfg,
|
||||||
"whiteboard_source": _whiteboard_source_info(db, whiteboard_cfg),
|
"whiteboard_source": _whiteboard_source_info(db, whiteboard_cfg),
|
||||||
"viewer_has_webdav_creds": viewer_has_webdav_creds,
|
"viewer_has_webdav_creds": viewer_has_webdav_creds, **border_ctx, **button_ctx,
|
||||||
|
})
|
||||||
|
|
||||||
|
if widget.widget_type == "weather":
|
||||||
|
weather_cfg = db.get(WeatherWidgetConfig, widget.id)
|
||||||
|
return templates.TemplateResponse("_widget_dialog_weather.html", {
|
||||||
|
"request": request, "frame": frame, "widget": widget, "weather_cfg": weather_cfg,
|
||||||
|
"weather_provider_labels": weather.PROVIDER_LABELS, **border_ctx, **button_ctx,
|
||||||
|
})
|
||||||
|
|
||||||
|
if widget.widget_type == "battery":
|
||||||
|
battery_cfg = db.get(BatteryWidgetConfig, widget.id)
|
||||||
|
return templates.TemplateResponse("_widget_dialog_battery.html", {
|
||||||
|
"request": request, "frame": frame, "widget": widget, "battery_cfg": battery_cfg, **border_ctx, **button_ctx,
|
||||||
})
|
})
|
||||||
|
|
||||||
raise HTTPException(400, f"Unknown widget type: {widget.widget_type}")
|
raise HTTPException(400, f"Unknown widget type: {widget.widget_type}")
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import logging
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse, Response
|
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -19,8 +19,14 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from .. import photo_queue, quiet_hours
|
from .. import photo_queue, quiet_hours
|
||||||
from ..db import get_db, widget_locked
|
from ..db import get_db, widget_locked
|
||||||
from ..models import Frame
|
from ..models import Frame, PhotoWidgetConfig
|
||||||
from .common import immich_client_for, list_assets, photo_widget_config_or_404
|
from .common import (
|
||||||
|
immich_client_for,
|
||||||
|
immich_creds,
|
||||||
|
list_assets,
|
||||||
|
photo_widget_config_or_404,
|
||||||
|
photo_widgets_for_frame,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -120,3 +126,37 @@ def manage_thumbnail(asset_id: str, frame: Frame = Depends(require_manage), db:
|
|||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
raise HTTPException(502, f"Could not download thumbnail from Immich: {e}") from e
|
raise HTTPException(502, f"Could not download thumbnail from Immich: {e}") from e
|
||||||
return Response(content=content, media_type=content_type)
|
return Response(content=content, media_type=content_type)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/frame/share/{manage_token}")
|
||||||
|
def manage_share(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
|
||||||
|
"""Creates a 30-minute public Immich share link covering every photo
|
||||||
|
widget's currently-displayed asset on this frame, and redirects to it
|
||||||
|
-- what the manage overlay's bottom-left QR code points to. Lazily
|
||||||
|
created (only when someone actually scans it, not when the manage
|
||||||
|
button was pressed), so the 30-minute window starts at actual use.
|
||||||
|
Keyed on this frame's own manage_token, like the rest of this router,
|
||||||
|
rather than device credentials -- a phone scanning a QR code has no
|
||||||
|
way to supply the device's ?id=/?token=, which is why this used to
|
||||||
|
silently fall back to whichever frame happened to still carry the
|
||||||
|
legacy migration token instead of the frame that was actually
|
||||||
|
scanned."""
|
||||||
|
photo_widgets = photo_widgets_for_frame(db, frame)
|
||||||
|
asset_ids: list[str] = []
|
||||||
|
for widget in photo_widgets:
|
||||||
|
cfg = db.get(PhotoWidgetConfig, widget.id)
|
||||||
|
if cfg.current_asset_id and cfg.current_asset_id not in asset_ids:
|
||||||
|
asset_ids.append(cfg.current_asset_id)
|
||||||
|
if not asset_ids:
|
||||||
|
raise HTTPException(404, "No photos currently showing on this frame")
|
||||||
|
|
||||||
|
url, key = immich_creds(frame)
|
||||||
|
if not url or not key:
|
||||||
|
raise HTTPException(400, "Immich URL/API key not configured yet")
|
||||||
|
|
||||||
|
client = immich_client_for(frame)
|
||||||
|
try:
|
||||||
|
share_url = client.create_share_link(asset_ids, expires_in_s=1800)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise HTTPException(502, f"Could not create share link: {e}") from e
|
||||||
|
return RedirectResponse(share_url)
|
||||||
|
|||||||
+32
-21
@@ -13,7 +13,7 @@ import logging
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -35,6 +35,7 @@ from ..auth import (
|
|||||||
verify_password,
|
verify_password,
|
||||||
)
|
)
|
||||||
from ..db import get_db
|
from ..db import get_db
|
||||||
|
from ..logging_setup import LOG_PATH, read_log_tail
|
||||||
from ..models import Frame, PasswordResetToken, PendingClaim, User, UserFrame
|
from ..models import Frame, PasswordResetToken, PendingClaim, User, UserFrame
|
||||||
from .common import valid_http_url
|
from .common import valid_http_url
|
||||||
|
|
||||||
@@ -569,6 +570,7 @@ def _render_admin(request: Request, db: Session, admin: User, notice: str | None
|
|||||||
"smtp": get_server_settings(db),
|
"smtp": get_server_settings(db),
|
||||||
"notice": notice,
|
"notice": notice,
|
||||||
"error": error,
|
"error": error,
|
||||||
|
"active_admin_tab": "main",
|
||||||
})
|
})
|
||||||
return templates.TemplateResponse("admin.html", ctx)
|
return templates.TemplateResponse("admin.html", ctx)
|
||||||
|
|
||||||
@@ -583,6 +585,35 @@ def admin_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
return _render_admin(request, db, user)
|
return _render_admin(request, db, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/logs", response_class=HTMLResponse)
|
||||||
|
def admin_logs_page(request: Request, lines: int = 500, db: Session = Depends(get_db)):
|
||||||
|
user = current_user(request, db)
|
||||||
|
if user is None:
|
||||||
|
return RedirectResponse("/login", status_code=303)
|
||||||
|
if not user.is_admin:
|
||||||
|
raise HTTPException(403, "Admin only")
|
||||||
|
from .common import shell_context
|
||||||
|
|
||||||
|
lines = max(50, min(lines, 5000))
|
||||||
|
ctx = shell_context(request, db, user, active_nav="admin")
|
||||||
|
ctx.update({
|
||||||
|
"active_admin_tab": "logs",
|
||||||
|
"log_exists": LOG_PATH.exists(),
|
||||||
|
"log_path": str(LOG_PATH),
|
||||||
|
"log_lines": lines,
|
||||||
|
"log_text": read_log_tail(lines),
|
||||||
|
})
|
||||||
|
return templates.TemplateResponse("admin_logs.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/logs/download")
|
||||||
|
def admin_logs_download(request: Request, db: Session = Depends(get_db)):
|
||||||
|
_require_admin_page(request, db)
|
||||||
|
if not LOG_PATH.exists():
|
||||||
|
raise HTTPException(404, "No log file yet")
|
||||||
|
return FileResponse(LOG_PATH, filename="server.log", media_type="text/plain")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/admin/users", response_class=HTMLResponse)
|
@router.post("/admin/users", response_class=HTMLResponse)
|
||||||
def admin_create_user(
|
def admin_create_user(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -676,26 +707,6 @@ def admin_link_user(
|
|||||||
notice=f"Linked '{target.username}' to frame #{frame_id}.")
|
notice=f"Linked '{target.username}' to frame #{frame_id}.")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/admin/frames/{frame_id}/end-legacy", response_class=HTMLResponse)
|
|
||||||
def admin_end_legacy(
|
|
||||||
frame_id: int,
|
|
||||||
request: Request,
|
|
||||||
csrf_token: str = Form(""),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Closes the legacy-token migration window once the device is
|
|
||||||
confirmed on per-frame auth (device_token_ack + recent last_seen in
|
|
||||||
the frames table below)."""
|
|
||||||
admin = _require_admin_page(request, db)
|
|
||||||
_check_form_csrf(request, db, csrf_token)
|
|
||||||
frame = db.get(Frame, frame_id)
|
|
||||||
if frame is None:
|
|
||||||
return _render_admin(request, db, admin, error="No such frame.")
|
|
||||||
frame.legacy_token_enabled = False
|
|
||||||
db.commit()
|
|
||||||
return _render_admin(request, db, admin, notice=f"Legacy token disabled for frame #{frame_id}.")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/admin/smtp", response_class=HTMLResponse)
|
@router.post("/admin/smtp", response_class=HTMLResponse)
|
||||||
def admin_smtp_save(
|
def admin_smtp_save(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -58,11 +58,20 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Registering this is what makes Chrome/Android offer the "Add to Home
|
||||||
|
// screen" install prompt -- a manifest link alone isn't enough. Served
|
||||||
|
// from /sw.js (not /static/sw.js) so its scope is the whole app.
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
window.addEventListener("load", function () {
|
||||||
|
navigator.serviceWorker.register("/sw.js");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Shared display names for widget_type, everywhere one shows up in the
|
// Shared display names for widget_type, everywhere one shows up in the
|
||||||
// UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns).
|
// UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns).
|
||||||
const WIDGET_LABELS = {
|
const WIDGET_LABELS = {
|
||||||
photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)', tasks: 'Tasks',
|
photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)', tasks: 'Tasks',
|
||||||
static: 'Static image', text: 'Text',
|
static: 'Static image', text: 'Text', weather: 'Weather', battery: 'Battery',
|
||||||
};
|
};
|
||||||
|
|
||||||
function showStatus(ok, message) {
|
function showStatus(ok, message) {
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ function renderDeviceStatusBar(device) {
|
|||||||
}
|
}
|
||||||
const now = Date.now() / 1000;
|
const now = Date.now() / 1000;
|
||||||
const rows = [];
|
const rows = [];
|
||||||
|
if (device.expected_next_checkin) {
|
||||||
|
const remaining = device.expected_next_checkin - now;
|
||||||
|
rows.push([
|
||||||
|
'Expected in',
|
||||||
|
remaining > 0 ? `~${formatDuration(remaining)}` : 'Any moment',
|
||||||
|
device.overdue,
|
||||||
|
]);
|
||||||
|
}
|
||||||
const ago = formatDuration(Math.max(0, now - device.last_seen));
|
const ago = formatDuration(Math.max(0, now - device.last_seen));
|
||||||
rows.push(['Last seen', `${ago} ago`, device.overdue]);
|
rows.push(['Last seen', `${ago} ago`, device.overdue]);
|
||||||
if (device.firmware_version) {
|
if (device.firmware_version) {
|
||||||
|
|||||||
+118
-223
@@ -59,6 +59,31 @@ document.getElementById('config-form').addEventListener('submit', async (e) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hold-for-global-action (see app/global_actions.py) -- a frame-wide
|
||||||
|
// setting, not per-widget, so it shares api_config_save/the /config
|
||||||
|
// endpoint rather than getting its own -- just a separate card/form on
|
||||||
|
// this page for a distinct-enough concern.
|
||||||
|
document.getElementById('hold-config-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const seconds = parseInt(document.getElementById('hold_duration_s').value, 10) || 3;
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
hold_duration_ms: String(seconds * 1000),
|
||||||
|
next_hold_action: document.getElementById('next_hold_action').value,
|
||||||
|
back_hold_action: document.getElementById('back_hold_action').value,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await apiError(resp));
|
||||||
|
showStatus(true, 'Saved.');
|
||||||
|
} catch (err) {
|
||||||
|
showStatus(false, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function takeControl() {
|
async function takeControl() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' });
|
const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' });
|
||||||
@@ -89,7 +114,7 @@ async function loadControl() {
|
|||||||
|
|
||||||
document.getElementById('take-control').addEventListener('click', takeControl);
|
document.getElementById('take-control').addEventListener('click', takeControl);
|
||||||
|
|
||||||
// ---- Advanced configuration: color palette ----
|
// ---- Advanced configuration: color palette(s) ----
|
||||||
//
|
//
|
||||||
// Hex field and R/G/B number fields are kept in sync live, both
|
// Hex field and R/G/B number fields are kept in sync live, both
|
||||||
// directions -- editing either updates the other plus the preview
|
// directions -- editing either updates the other plus the preview
|
||||||
@@ -97,9 +122,15 @@ document.getElementById('take-control').addEventListener('click', takeControl);
|
|||||||
// the server already validates as #rrggbb); the R/G/B fields are purely
|
// the server already validates as #rrggbb); the R/G/B fields are purely
|
||||||
// an alternate, more precise way to arrive at the same value than
|
// an alternate, more precise way to arrive at the same value than
|
||||||
// eyeballing a color-picker swatch.
|
// eyeballing a color-picker swatch.
|
||||||
|
//
|
||||||
|
// Parameterized by classPrefix ("palette" for the main one, "photo-
|
||||||
|
// palette" for the photos-only one added alongside it) rather than
|
||||||
|
// duplicated wholesale -- there are exactly two real instances of this,
|
||||||
|
// not a speculative future one, and the two would otherwise be ~90
|
||||||
|
// near-identical lines apart.
|
||||||
|
|
||||||
function paletteHexInputs() {
|
function paletteHexInputs(classPrefix) {
|
||||||
return Array.from(document.querySelectorAll('.palette-hex'))
|
return Array.from(document.querySelectorAll(`.${classPrefix}-hex`))
|
||||||
.sort((a, b) => Number(a.dataset.index) - Number(b.dataset.index));
|
.sort((a, b) => Number(a.dataset.index) - Number(b.dataset.index));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,45 +146,53 @@ function rgbFromHex(hex) {
|
|||||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||||
}
|
}
|
||||||
|
|
||||||
function paletteFieldsFor(index) {
|
function paletteFieldsFor(classPrefix, index) {
|
||||||
const at = (cls) => document.querySelector(`.${cls}[data-index="${index}"]`);
|
const at = (cls) => document.querySelector(`.${cls}[data-index="${index}"]`);
|
||||||
return { hex: at('palette-hex'), r: at('palette-r'), g: at('palette-g'), b: at('palette-b'), swatch: at('palette-swatch-preview') };
|
return {
|
||||||
|
hex: at(`${classPrefix}-hex`), r: at(`${classPrefix}-r`), g: at(`${classPrefix}-g`), b: at(`${classPrefix}-b`),
|
||||||
|
swatch: at(`${classPrefix}-swatch-preview`),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncPaletteFromHex(index) {
|
function syncPaletteFromHex(classPrefix, index) {
|
||||||
const f = paletteFieldsFor(index);
|
const f = paletteFieldsFor(classPrefix, index);
|
||||||
const rgb = rgbFromHex(f.hex.value);
|
const rgb = rgbFromHex(f.hex.value);
|
||||||
if (!rgb) return;
|
if (!rgb) return;
|
||||||
[f.r.value, f.g.value, f.b.value] = rgb;
|
[f.r.value, f.g.value, f.b.value] = rgb;
|
||||||
f.swatch.style.background = f.hex.value;
|
f.swatch.style.background = f.hex.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncPaletteFromRgb(index) {
|
function syncPaletteFromRgb(classPrefix, index) {
|
||||||
const f = paletteFieldsFor(index);
|
const f = paletteFieldsFor(classPrefix, index);
|
||||||
const hex = hexFromRgb(f.r.value, f.g.value, f.b.value);
|
const hex = hexFromRgb(f.r.value, f.g.value, f.b.value);
|
||||||
f.hex.value = hex;
|
f.hex.value = hex;
|
||||||
f.swatch.style.background = hex;
|
f.swatch.style.background = hex;
|
||||||
}
|
}
|
||||||
|
|
||||||
const palettePickerCount = paletteHexInputs().length;
|
function wirePaletteInputs(classPrefix) {
|
||||||
for (let i = 0; i < palettePickerCount; i++) {
|
const count = paletteHexInputs(classPrefix).length;
|
||||||
const f = paletteFieldsFor(i);
|
for (let i = 0; i < count; i++) {
|
||||||
f.hex.addEventListener('input', () => syncPaletteFromHex(i));
|
const f = paletteFieldsFor(classPrefix, i);
|
||||||
[f.r, f.g, f.b].forEach((el) => el.addEventListener('input', () => syncPaletteFromRgb(i)));
|
f.hex.addEventListener('input', () => syncPaletteFromHex(classPrefix, i));
|
||||||
|
[f.r, f.g, f.b].forEach((el) => el.addEventListener('input', () => syncPaletteFromRgb(classPrefix, i)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wirePaletteInputs('palette');
|
||||||
|
wirePaletteInputs('photo-palette');
|
||||||
|
|
||||||
// Sliders: live numeric readout next to each, no save until the button
|
// Sliders: live numeric readout next to each, no save until the button
|
||||||
// below is clicked.
|
// below is clicked.
|
||||||
['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => {
|
['color_boost', 'contrast_boost', 'dither_strength', 'photo_dither_strength'].forEach((id) => {
|
||||||
const input = document.getElementById(id);
|
const input = document.getElementById(id);
|
||||||
const readout = document.getElementById(`${id}_value`);
|
const readout = document.getElementById(`${id}_value`);
|
||||||
input.addEventListener('input', () => { readout.textContent = Number(input.value).toFixed(2); });
|
input.addEventListener('input', () => { readout.textContent = Number(input.value).toFixed(2); });
|
||||||
});
|
});
|
||||||
|
|
||||||
async function savePalette(extra) {
|
async function savePalette(classPrefix, paletteFormKey, extra) {
|
||||||
const body = new URLSearchParams(extra || {});
|
const body = new URLSearchParams(extra || {});
|
||||||
for (const input of paletteHexInputs()) {
|
for (const input of paletteHexInputs(classPrefix)) {
|
||||||
body.append('palette', input.value);
|
body.append(paletteFormKey, input.value);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||||
@@ -170,7 +209,7 @@ async function savePalette(extra) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('palette-save').addEventListener('click', () => {
|
document.getElementById('palette-save').addEventListener('click', () => {
|
||||||
savePalette({
|
savePalette('palette', 'palette', {
|
||||||
color_boost: document.getElementById('color_boost').value,
|
color_boost: document.getElementById('color_boost').value,
|
||||||
contrast_boost: document.getElementById('contrast_boost').value,
|
contrast_boost: document.getElementById('contrast_boost').value,
|
||||||
dither_strength: document.getElementById('dither_strength').value,
|
dither_strength: document.getElementById('dither_strength').value,
|
||||||
@@ -178,16 +217,65 @@ document.getElementById('palette-save').addEventListener('click', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('palette-reset').addEventListener('click', () => {
|
document.getElementById('palette-reset').addEventListener('click', () => {
|
||||||
const inputs = paletteHexInputs();
|
const inputs = paletteHexInputs('palette');
|
||||||
window.DEFAULT_PALETTE_HEX.forEach((hex, i) => {
|
window.DEFAULT_PALETTE_HEX.forEach((hex, i) => {
|
||||||
inputs[i].value = hex;
|
inputs[i].value = hex;
|
||||||
syncPaletteFromHex(i);
|
syncPaletteFromHex('palette', i);
|
||||||
});
|
});
|
||||||
['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => {
|
['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => {
|
||||||
document.getElementById(id).value = '1';
|
document.getElementById(id).value = '1';
|
||||||
document.getElementById(`${id}_value`).textContent = '1.00';
|
document.getElementById(`${id}_value`).textContent = '1.00';
|
||||||
});
|
});
|
||||||
savePalette({ palette_reset: 'true', color_boost: '1', contrast_boost: '1', dither_strength: '1' });
|
savePalette('palette', 'palette', { palette_reset: 'true', color_boost: '1', contrast_boost: '1', dither_strength: '1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fills the table with a community-measured starting point (see the
|
||||||
|
// card's own explanatory text) -- doesn't save by itself, same as
|
||||||
|
// editing the hex/RGB fields by hand; the user still clicks Save (or
|
||||||
|
// Reset) to commit or discard it.
|
||||||
|
document.getElementById('palette-load-calibrated').addEventListener('click', () => {
|
||||||
|
const inputs = paletteHexInputs('palette');
|
||||||
|
window.CALIBRATED_SPECTRA6_HEX.forEach((hex, i) => {
|
||||||
|
inputs[i].value = hex;
|
||||||
|
syncPaletteFromHex('palette', i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Photos configuration: its own separate palette/dithering ----
|
||||||
|
|
||||||
|
document.getElementById('photo-palette-save').addEventListener('click', () => {
|
||||||
|
savePalette('photo-palette', 'photo_palette', {
|
||||||
|
photo_dither_strength: document.getElementById('photo_dither_strength').value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('photo-palette-reset').addEventListener('click', () => {
|
||||||
|
const inputs = paletteHexInputs('photo-palette');
|
||||||
|
window.DEFAULT_PALETTE_HEX.forEach((hex, i) => {
|
||||||
|
inputs[i].value = hex;
|
||||||
|
syncPaletteFromHex('photo-palette', i);
|
||||||
|
});
|
||||||
|
document.getElementById('photo_dither_strength').value = '1';
|
||||||
|
document.getElementById('photo_dither_strength_value').textContent = '1.00';
|
||||||
|
savePalette('photo-palette', 'photo_palette', { photo_palette_reset: 'true', photo_dither_strength: '1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Theme ----
|
||||||
|
|
||||||
|
document.getElementById('theme-save').addEventListener('click', async () => {
|
||||||
|
const body = new URLSearchParams({ theme: document.getElementById('theme-select').value });
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await apiError(resp));
|
||||||
|
showStatus(true, 'Saved.');
|
||||||
|
loadPreview();
|
||||||
|
} catch (e) {
|
||||||
|
showStatus(false, e.message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Preview: current photo vs. how it renders with saved settings ----
|
// ---- Preview: current photo vs. how it renders with saved settings ----
|
||||||
@@ -314,8 +402,15 @@ async function loadFirmwareCheck(force) {
|
|||||||
statusEl.textContent = 'Waiting for the frame to check in before it can look up the right build.';
|
statusEl.textContent = 'Waiting for the frame to check in before it can look up the right build.';
|
||||||
btn.style.display = 'none';
|
btn.style.display = 'none';
|
||||||
} else if (data.update_available) {
|
} else if (data.update_available) {
|
||||||
statusEl.textContent = `Update available: v${data.latest_version}.`;
|
statusEl.textContent = `Update available: v${data.latest_version}` +
|
||||||
|
(data.running_version ? ` (currently running v${data.running_version}).` : '.');
|
||||||
btn.style.display = 'inline-block';
|
btn.style.display = 'inline-block';
|
||||||
|
} else if (data.latest_version && data.running_version && data.running_version !== data.latest_version) {
|
||||||
|
// Already staged (or auto-applied) but the frame hasn't woken up
|
||||||
|
// and picked it up yet -- not "up to date" until it actually has.
|
||||||
|
statusEl.textContent = `v${data.latest_version} staged -- applies next time the frame wakes ` +
|
||||||
|
`(currently running v${data.running_version}).`;
|
||||||
|
btn.style.display = 'none';
|
||||||
} else if (data.latest_version) {
|
} else if (data.latest_version) {
|
||||||
statusEl.textContent = `Up to date (v${data.latest_version}).`;
|
statusEl.textContent = `Up to date (v${data.latest_version}).`;
|
||||||
btn.style.display = 'none';
|
btn.style.display = 'none';
|
||||||
@@ -363,203 +458,3 @@ loadFirmwareCheck();
|
|||||||
// cheap either way.
|
// cheap either way.
|
||||||
setInterval(loadFirmwareCheck, 60000);
|
setInterval(loadFirmwareCheck, 60000);
|
||||||
|
|
||||||
// --- Button assignments -----------------------------------------------
|
|
||||||
// {widgets: [{id, widget_type, x, y, w, h, actions: [{action, label}]}],
|
|
||||||
// grid: {cols, rows}, next: [...], back: [...]} -- see api_frames.py's
|
|
||||||
// api_buttons_get. Each button's list is edited client-side (add/
|
|
||||||
// remove/reorder) then PUT as a whole -- simpler than separate reorder/
|
|
||||||
// add/remove endpoints for what's normally a handful of entries, and
|
|
||||||
// this file already has the full list in hand after any edit.
|
|
||||||
let buttonsData = null;
|
|
||||||
let widgetNames = {}; // widget id -> disambiguated display name, see buildWidgetNames
|
|
||||||
const BUTTONS = ['next', 'back'];
|
|
||||||
|
|
||||||
// "top-left"/"bottom"/"center" etc. from a widget's grid rect vs the
|
|
||||||
// frame's grid dims -- the same rough position you'd read off the
|
|
||||||
// Layout canvas by eye, used to tell apart two widgets of the same type
|
|
||||||
// that would otherwise both just say "Photos".
|
|
||||||
function widgetPositionLabel(w, grid) {
|
|
||||||
const cx = w.x + w.w / 2;
|
|
||||||
const cy = w.y + w.h / 2;
|
|
||||||
const horiz = cx < grid.cols / 2 ? 'left' : (cx > grid.cols / 2 ? 'right' : '');
|
|
||||||
const vert = cy < grid.rows / 2 ? 'top' : (cy > grid.rows / 2 ? 'bottom' : '');
|
|
||||||
if (!horiz && !vert) return 'center';
|
|
||||||
if (!vert) return horiz;
|
|
||||||
if (!horiz) return vert;
|
|
||||||
return `${vert}-${horiz}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A single widget of a given type keeps the plain type name ("Photos")
|
|
||||||
// -- the common case, no need to clutter it. Only widgets sharing a
|
|
||||||
// type with another widget on the same frame get a number + position
|
|
||||||
// suffix, numbered in reading order (top-to-bottom, left-to-right).
|
|
||||||
function buildWidgetNames(widgets, grid) {
|
|
||||||
const byType = {};
|
|
||||||
widgets.forEach((w) => { (byType[w.widget_type] = byType[w.widget_type] || []).push(w); });
|
|
||||||
const names = {};
|
|
||||||
Object.values(byType).forEach((group) => {
|
|
||||||
if (group.length === 1) {
|
|
||||||
names[group[0].id] = WIDGET_LABELS[group[0].widget_type] || group[0].widget_type;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const ordered = [...group].sort((a, b) => (a.y - b.y) || (a.x - b.x));
|
|
||||||
ordered.forEach((w, i) => {
|
|
||||||
const base = WIDGET_LABELS[w.widget_type] || w.widget_type;
|
|
||||||
names[w.id] = `${base} ${i + 1} (${widgetPositionLabel(w, grid)})`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return names;
|
|
||||||
}
|
|
||||||
|
|
||||||
function widgetActionLabel(widgetId, action) {
|
|
||||||
const w = buttonsData.widgets.find((w) => w.id === widgetId);
|
|
||||||
if (!w) return `(deleted widget): ${action}`;
|
|
||||||
const found = w.actions.find((a) => a.action === action);
|
|
||||||
const actionLabel = found ? found.label : action;
|
|
||||||
return `${widgetNames[widgetId] || WIDGET_LABELS[w.widget_type] || w.widget_type}: ${actionLabel}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderButtonList(button) {
|
|
||||||
const list = document.getElementById(`button-actions-${button}`);
|
|
||||||
const rows = buttonsData[button];
|
|
||||||
list.innerHTML = '';
|
|
||||||
if (!rows.length) {
|
|
||||||
list.innerHTML = '<li class="sub">Nothing assigned -- this button won’t do anything.</li>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
rows.forEach((row, i) => {
|
|
||||||
const li = document.createElement('li');
|
|
||||||
li.className = 'button-action-row';
|
|
||||||
|
|
||||||
const span = document.createElement('span');
|
|
||||||
span.textContent = widgetActionLabel(row.widget_id, row.action);
|
|
||||||
|
|
||||||
const controls = document.createElement('span');
|
|
||||||
controls.className = 'button-action-controls';
|
|
||||||
|
|
||||||
const up = document.createElement('button');
|
|
||||||
up.type = 'button';
|
|
||||||
up.className = 'icon-btn';
|
|
||||||
up.textContent = '↑';
|
|
||||||
up.title = 'Move up';
|
|
||||||
up.disabled = i === 0;
|
|
||||||
up.addEventListener('click', () => moveButtonAction(button, i, -1));
|
|
||||||
|
|
||||||
const down = document.createElement('button');
|
|
||||||
down.type = 'button';
|
|
||||||
down.className = 'icon-btn';
|
|
||||||
down.textContent = '↓';
|
|
||||||
down.title = 'Move down';
|
|
||||||
down.disabled = i === rows.length - 1;
|
|
||||||
down.addEventListener('click', () => moveButtonAction(button, i, 1));
|
|
||||||
|
|
||||||
const remove = document.createElement('button');
|
|
||||||
remove.type = 'button';
|
|
||||||
remove.className = 'icon-btn';
|
|
||||||
remove.textContent = '×';
|
|
||||||
remove.title = 'Remove';
|
|
||||||
remove.addEventListener('click', () => removeButtonAction(button, i));
|
|
||||||
|
|
||||||
controls.appendChild(up);
|
|
||||||
controls.appendChild(down);
|
|
||||||
controls.appendChild(remove);
|
|
||||||
li.appendChild(span);
|
|
||||||
li.appendChild(controls);
|
|
||||||
list.appendChild(li);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function populateActionSelect(button) {
|
|
||||||
const widgetSel = document.getElementById(`button-add-widget-${button}`);
|
|
||||||
const actionSel = document.getElementById(`button-add-action-${button}`);
|
|
||||||
actionSel.innerHTML = '';
|
|
||||||
const w = buttonsData.widgets.find((w) => String(w.id) === widgetSel.value);
|
|
||||||
if (!w) return;
|
|
||||||
w.actions.forEach((a) => {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = a.action;
|
|
||||||
opt.textContent = a.label;
|
|
||||||
actionSel.appendChild(opt);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function populateWidgetSelect(button) {
|
|
||||||
const widgetSel = document.getElementById(`button-add-widget-${button}`);
|
|
||||||
widgetSel.innerHTML = '';
|
|
||||||
buttonsData.widgets.forEach((w) => {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = w.id;
|
|
||||||
opt.textContent = widgetNames[w.id] || WIDGET_LABELS[w.widget_type] || w.widget_type;
|
|
||||||
widgetSel.appendChild(opt);
|
|
||||||
});
|
|
||||||
populateActionSelect(button);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveButtonActions(button) {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`${window.FRAME_BASE_API}/buttons/${button}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
actions: buttonsData[button].map((r) => ({ widget_id: r.widget_id, action: r.action })),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!resp.ok) throw new Error(await apiError(resp));
|
|
||||||
showStatus(true, 'Button assignments saved.');
|
|
||||||
} catch (e) {
|
|
||||||
showStatus(false, e.message);
|
|
||||||
await loadButtons(); // resync with server truth rather than leave a stale edit on screen
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveButtonAction(button, index, delta) {
|
|
||||||
const rows = buttonsData[button];
|
|
||||||
const target = index + delta;
|
|
||||||
if (target < 0 || target >= rows.length) return;
|
|
||||||
[rows[index], rows[target]] = [rows[target], rows[index]];
|
|
||||||
renderButtonList(button);
|
|
||||||
saveButtonActions(button);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeButtonAction(button, index) {
|
|
||||||
buttonsData[button].splice(index, 1);
|
|
||||||
renderButtonList(button);
|
|
||||||
saveButtonActions(button);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addButtonAction(button) {
|
|
||||||
const widgetSel = document.getElementById(`button-add-widget-${button}`);
|
|
||||||
const actionSel = document.getElementById(`button-add-action-${button}`);
|
|
||||||
if (!widgetSel.value || !actionSel.value) return;
|
|
||||||
buttonsData[button].push({ widget_id: Number(widgetSel.value), action: actionSel.value });
|
|
||||||
renderButtonList(button);
|
|
||||||
saveButtonActions(button);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadButtons() {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`${window.FRAME_BASE_API}/buttons`);
|
|
||||||
if (!resp.ok) throw new Error(await apiError(resp));
|
|
||||||
buttonsData = await resp.json();
|
|
||||||
widgetNames = buildWidgetNames(buttonsData.widgets, buttonsData.grid);
|
|
||||||
document.getElementById('button-assign-groups').style.display =
|
|
||||||
buttonsData.widgets.length ? '' : 'none';
|
|
||||||
document.getElementById('button-assign-empty-hint').style.display =
|
|
||||||
buttonsData.widgets.length ? 'none' : '';
|
|
||||||
BUTTONS.forEach((button) => {
|
|
||||||
renderButtonList(button);
|
|
||||||
populateWidgetSelect(button);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
showStatus(false, e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
BUTTONS.forEach((button) => {
|
|
||||||
document.getElementById(`button-add-widget-${button}`)
|
|
||||||
.addEventListener('change', () => populateActionSelect(button));
|
|
||||||
document.getElementById(`button-add-${button}`)
|
|
||||||
.addEventListener('click', () => addButtonAction(button));
|
|
||||||
});
|
|
||||||
|
|
||||||
loadButtons();
|
|
||||||
|
|||||||
@@ -58,48 +58,31 @@
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Live "how it's displaying" thumbnail. A real composite render (same
|
// Now-displaying / up-next header preview pair. "Up next" is a real
|
||||||
// pipeline /frame/image uses), not a cached snapshot, so it's on a slow
|
// composite render (same pipeline /frame/image uses), not a cached
|
||||||
// poll rather than something tighter like the 10s device-status poll --
|
// snapshot, so it's on a slow poll rather than something tighter like
|
||||||
// no need to hit Immich/calendar/whiteboard sources that often just for
|
// the 10s device-status poll -- no need to hit Immich/calendar/
|
||||||
// a header thumbnail. Click enlarges it in a dialog (which also fetches
|
// whiteboard sources that often just for a header thumbnail, and it
|
||||||
// a fresh render); clicking the enlarged image refreshes it again.
|
// shows layout edits live as they're made. "Now displaying" is the
|
||||||
|
// opposite: exactly the bytes last actually sent to the device (see
|
||||||
|
// routers/device.py's _record_last_displayed), frozen until the
|
||||||
|
// device's next real wake even while the layout is being edited live --
|
||||||
|
// that contrast is the point of showing both side by side.
|
||||||
(function () {
|
(function () {
|
||||||
var thumb = document.getElementById('frame-preview-thumb');
|
var nextThumb = document.getElementById('frame-preview-thumb');
|
||||||
var dialog = document.getElementById('frame-preview-dialog');
|
var nextDialog = document.getElementById('frame-preview-dialog');
|
||||||
var bigImg = document.getElementById('frame-preview-dialog-img');
|
var nextBigImg = document.getElementById('frame-preview-dialog-img');
|
||||||
var closeBtn = document.getElementById('frame-preview-dialog-close');
|
var nextCloseBtn = document.getElementById('frame-preview-dialog-close');
|
||||||
if (!thumb || !window.FRAME_BASE_API) return;
|
var nowThumb = document.getElementById('frame-preview-now-thumb');
|
||||||
|
var nowDialog = document.getElementById('frame-preview-now-dialog');
|
||||||
|
var nowBigImg = document.getElementById('frame-preview-now-dialog-img');
|
||||||
|
var nowCloseBtn = document.getElementById('frame-preview-now-dialog-close');
|
||||||
|
if (!nextThumb || !window.FRAME_BASE_API) return;
|
||||||
|
|
||||||
function previewUrl() {
|
// Same backdrop-click-to-close trick as #widget-dialog: a click that
|
||||||
return `${window.FRAME_BASE_API}/preview?t=${Date.now()}`;
|
// lands on the dialog element itself (not its content box) means the
|
||||||
}
|
// backdrop was hit.
|
||||||
function refreshThumb() {
|
function closeOnBackdropClick(dialog) {
|
||||||
thumb.src = previewUrl();
|
|
||||||
}
|
|
||||||
// Opening the dialog (or clicking the big image inside it) fetches a
|
|
||||||
// fresh render and keeps the header thumb in sync, so this single path
|
|
||||||
// covers both "enlarge" and the old click-to-refresh behavior.
|
|
||||||
function refreshBig() {
|
|
||||||
var url = previewUrl();
|
|
||||||
bigImg.src = url;
|
|
||||||
thumb.src = url;
|
|
||||||
}
|
|
||||||
|
|
||||||
thumb.addEventListener('click', function () {
|
|
||||||
if (!dialog) { refreshThumb(); return; }
|
|
||||||
refreshBig();
|
|
||||||
dialog.showModal();
|
|
||||||
});
|
|
||||||
refreshThumb();
|
|
||||||
setInterval(refreshThumb, 60000);
|
|
||||||
|
|
||||||
if (dialog && bigImg && closeBtn) {
|
|
||||||
bigImg.addEventListener('click', refreshBig);
|
|
||||||
closeBtn.addEventListener('click', function () { dialog.close(); });
|
|
||||||
// Same backdrop-click-to-close trick as #widget-dialog: a click that
|
|
||||||
// lands on the dialog element itself (not its content box) means the
|
|
||||||
// backdrop was hit.
|
|
||||||
dialog.addEventListener('click', function (e) {
|
dialog.addEventListener('click', function (e) {
|
||||||
if (e.target !== dialog) return;
|
if (e.target !== dialog) return;
|
||||||
var rect = dialog.getBoundingClientRect();
|
var rect = dialog.getBoundingClientRect();
|
||||||
@@ -107,4 +90,80 @@
|
|||||||
if (!inside) dialog.close();
|
if (!inside) dialog.close();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nextUrl() {
|
||||||
|
return `${window.FRAME_BASE_API}/preview?t=${Date.now()}`;
|
||||||
|
}
|
||||||
|
function refreshNext() {
|
||||||
|
nextThumb.src = nextUrl();
|
||||||
|
}
|
||||||
|
// Opening the dialog (or clicking the big image inside it) fetches a
|
||||||
|
// fresh render and keeps the header thumb in sync, so this single path
|
||||||
|
// covers both "enlarge" and the old click-to-refresh behavior.
|
||||||
|
function refreshNextBig() {
|
||||||
|
var url = nextUrl();
|
||||||
|
nextBigImg.src = url;
|
||||||
|
nextThumb.src = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextThumb.addEventListener('click', function () {
|
||||||
|
if (!nextDialog) { refreshNext(); return; }
|
||||||
|
refreshNextBig();
|
||||||
|
nextDialog.showModal();
|
||||||
|
});
|
||||||
|
refreshNext();
|
||||||
|
setInterval(refreshNext, 60000);
|
||||||
|
|
||||||
|
if (nextDialog && nextBigImg && nextCloseBtn) {
|
||||||
|
nextBigImg.addEventListener('click', refreshNextBig);
|
||||||
|
nextCloseBtn.addEventListener('click', function () { nextDialog.close(); });
|
||||||
|
closeOnBackdropClick(nextDialog);
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Now displaying" fetches rather than sets .src directly: it needs to
|
||||||
|
// tell a 404 (device hasn't fetched yet) apart from a real image to
|
||||||
|
// show its own empty state instead of a broken-image icon, and reads
|
||||||
|
// the capture time off X-Displayed-At for the "N ago" tooltip.
|
||||||
|
if (nowThumb) {
|
||||||
|
var nowObjectUrl = null;
|
||||||
|
function refreshNow() {
|
||||||
|
fetch(`${window.FRAME_BASE_API}/now-displaying?t=${Date.now()}`)
|
||||||
|
.then(function (resp) {
|
||||||
|
if (!resp.ok) {
|
||||||
|
nowThumb.classList.add('frame-preview-thumb-empty');
|
||||||
|
nowThumb.removeAttribute('src');
|
||||||
|
nowThumb.title = "Now displaying -- hasn't shown anything yet";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var displayedAt = resp.headers.get('X-Displayed-At');
|
||||||
|
nowThumb.title = displayedAt
|
||||||
|
? `Now displaying -- ${formatDuration(Math.max(0, Date.now() / 1000 - parseFloat(displayedAt)))} ago -- click to enlarge`
|
||||||
|
: 'Now displaying -- click to enlarge';
|
||||||
|
return resp.blob();
|
||||||
|
})
|
||||||
|
.then(function (blob) {
|
||||||
|
if (!blob) return;
|
||||||
|
nowThumb.classList.remove('frame-preview-thumb-empty');
|
||||||
|
var url = URL.createObjectURL(blob);
|
||||||
|
var old = nowObjectUrl;
|
||||||
|
nowObjectUrl = url;
|
||||||
|
nowThumb.src = url;
|
||||||
|
if (old) URL.revokeObjectURL(old);
|
||||||
|
})
|
||||||
|
.catch(function () { /* transient failure -- leave the last-known thumb showing */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
nowThumb.addEventListener('click', function () {
|
||||||
|
if (nowThumb.classList.contains('frame-preview-thumb-empty') || !nowDialog) return;
|
||||||
|
nowBigImg.src = nowThumb.src;
|
||||||
|
nowDialog.showModal();
|
||||||
|
});
|
||||||
|
refreshNow();
|
||||||
|
setInterval(refreshNow, 60000);
|
||||||
|
|
||||||
|
if (nowDialog && nowCloseBtn) {
|
||||||
|
nowCloseBtn.addEventListener('click', function () { nowDialog.close(); });
|
||||||
|
closeOnBackdropClick(nowDialog);
|
||||||
|
}
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -210,6 +210,14 @@ function renderCanvas() {
|
|||||||
label.textContent = WIDGET_LABELS[widget.widget_type] || widget.widget_type;
|
label.textContent = WIDGET_LABELS[widget.widget_type] || widget.widget_type;
|
||||||
box.appendChild(label);
|
box.appendChild(label);
|
||||||
|
|
||||||
|
if (widget.locked) {
|
||||||
|
const lockBadge = document.createElement('span');
|
||||||
|
lockBadge.className = 'widget-box-lock-badge';
|
||||||
|
lockBadge.textContent = '\u{1F512}'; // lock emoji -- open the gear icon to unlock
|
||||||
|
lockBadge.title = 'Locked -- won\'t change until unlocked in this widget\'s settings';
|
||||||
|
box.appendChild(lockBadge);
|
||||||
|
}
|
||||||
|
|
||||||
const settingsBtn = document.createElement('button');
|
const settingsBtn = document.createElement('button');
|
||||||
settingsBtn.type = 'button';
|
settingsBtn.type = 'button';
|
||||||
settingsBtn.className = 'widget-box-settings';
|
settingsBtn.className = 'widget-box-settings';
|
||||||
@@ -286,11 +294,13 @@ window.addEventListener('resize', () => {
|
|||||||
// icon was clicked).
|
// icon was clicked).
|
||||||
const DIALOG_INIT = {
|
const DIALOG_INIT = {
|
||||||
photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog,
|
photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog,
|
||||||
tasks: initTasksDialog, static: initStaticDialog, text: initTextDialog,
|
tasks: initTasksDialog, static: initStaticDialog, text: initTextDialog, weather: initWeatherDialog,
|
||||||
|
battery: initBatteryDialog,
|
||||||
};
|
};
|
||||||
const DIALOG_CLOSE = {
|
const DIALOG_CLOSE = {
|
||||||
photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog,
|
photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog,
|
||||||
tasks: closeTasksDialog, static: closeStaticDialog, text: closeTextDialog,
|
tasks: closeTasksDialog, static: closeStaticDialog, text: closeTextDialog, weather: closeWeatherDialog,
|
||||||
|
battery: closeBatteryDialog,
|
||||||
};
|
};
|
||||||
|
|
||||||
let openDialogWidgetType = null;
|
let openDialogWidgetType = null;
|
||||||
@@ -341,6 +351,7 @@ document.getElementById('widget-dialog').addEventListener('close', () => {
|
|||||||
openDialogWidgetType = null;
|
openDialogWidgetType = null;
|
||||||
window.FRAME_API = window.FRAME_BASE_API;
|
window.FRAME_API = window.FRAME_BASE_API;
|
||||||
document.getElementById('widget-dialog-body').innerHTML = '';
|
document.getElementById('widget-dialog-body').innerHTML = '';
|
||||||
|
loadWidgets(); // picks up anything the dialog changed that the canvas shows (e.g. the lock badge)
|
||||||
});
|
});
|
||||||
|
|
||||||
loadWidgets();
|
loadWidgets();
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 501 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "ESPresso Frame",
|
||||||
|
"short_name": "ESPresso",
|
||||||
|
"description": "Manage your e-ink photo frames.",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#f5f6f8",
|
||||||
|
"theme_color": "#2563eb",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/static/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||||
|
{ "src": "/static/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Presence-only service worker: satisfies the "installable" requirement
|
||||||
|
// (Chrome/Android in particular checks for a controlling SW with a fetch
|
||||||
|
// handler) without adding an offline cache -- every request just goes to
|
||||||
|
// the network as normal. Served from / (see app/main.py's /sw.js route)
|
||||||
|
// so its scope covers the whole app, not just /static/.
|
||||||
|
self.addEventListener("install", () => self.skipWaiting());
|
||||||
|
self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));
|
||||||
|
self.addEventListener("fetch", (event) => event.respondWith(fetch(event.request)));
|
||||||
+82
-19
@@ -156,6 +156,24 @@ button.linklike:hover { color: var(--text); background: none; }
|
|||||||
.admin-inline-form { display: flex; gap: 8px; align-items: center; margin-top: 6px; }
|
.admin-inline-form { display: flex; gap: 8px; align-items: center; margin-top: 6px; }
|
||||||
.admin-inline-form input[type="text"] { margin-top: 0; flex: 1; }
|
.admin-inline-form input[type="text"] { margin-top: 0; flex: 1; }
|
||||||
|
|
||||||
|
.log-view-controls { display: flex; gap: 10px; align-items: center; margin: 10px 0; font-size: 13px; }
|
||||||
|
.log-view-controls a:not(.btn-inline) { color: var(--text-muted); }
|
||||||
|
.log-view-controls a.active { color: var(--accent); font-weight: 600; }
|
||||||
|
.log-view {
|
||||||
|
background: var(--surface-alt);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
max-height: 65vh;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
font-family: ui-monospace, "SF Mono", Consolas, monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
h2.card-title, summary.card-title {
|
h2.card-title, summary.card-title {
|
||||||
font-size: 14.5px;
|
font-size: 14.5px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
@@ -243,6 +261,36 @@ input[type="range"] {
|
|||||||
}
|
}
|
||||||
.card + .card { margin-top: 20px; }
|
.card + .card { margin-top: 20px; }
|
||||||
|
|
||||||
|
/* Installed as a standalone app, the boxed-card look reads as "still a
|
||||||
|
website" -- flatten page-level cards into the page background so it
|
||||||
|
feels native. Cards inside the widget dialog keep their box: they're
|
||||||
|
grouping subsections of one form, not top-level page furniture. */
|
||||||
|
@media (display-mode: standalone) {
|
||||||
|
.card {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
.card + .card {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
#widget-dialog-body .card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 20px 22px 22px;
|
||||||
|
}
|
||||||
|
#widget-dialog-body .card + .card {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 22px;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
label { display: block; margin-top: 16px; font-size: 13px; font-weight: 600; color: var(--text); }
|
label { display: block; margin-top: 16px; font-size: 13px; font-weight: 600; color: var(--text); }
|
||||||
label:first-child { margin-top: 0; }
|
label:first-child { margin-top: 0; }
|
||||||
|
|
||||||
@@ -265,22 +313,6 @@ input:focus, select:focus {
|
|||||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-assign-label { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); margin: 0 0 8px; }
|
|
||||||
.button-action-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
|
||||||
.button-action-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--surface-alt);
|
|
||||||
}
|
|
||||||
.button-action-controls { display: flex; align-items: center; gap: 2px; flex: none; }
|
|
||||||
.button-action-controls .icon-btn { padding: 3px 6px; font-size: 13px; }
|
|
||||||
.button-action-controls .icon-btn:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
|
|
||||||
.saved-layout-add { display: flex; align-items: center; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
|
.saved-layout-add { display: flex; align-items: center; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
|
||||||
.saved-layout-add input { width: auto; flex: 1 1 200px; margin-top: 0; }
|
.saved-layout-add input { width: auto; flex: 1 1 200px; margin-top: 0; }
|
||||||
.saved-layout-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
.saved-layout-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||||
@@ -308,8 +340,6 @@ input:focus, select:focus {
|
|||||||
.saved-layout-controls { display: flex; align-items: center; gap: 2px; flex: none; }
|
.saved-layout-controls { display: flex; align-items: center; gap: 2px; flex: none; }
|
||||||
.saved-layout-controls .btn-inline { margin: 0; }
|
.saved-layout-controls .btn-inline { margin: 0; }
|
||||||
.saved-layout-rename-input { width: auto; flex: 1 1 160px; margin-top: 0; }
|
.saved-layout-rename-input { width: auto; flex: 1 1 160px; margin-top: 0; }
|
||||||
.button-action-add { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
|
||||||
.button-action-add select { width: auto; margin-top: 0; }
|
|
||||||
|
|
||||||
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 16px; }
|
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 16px; }
|
||||||
.checkbox-row input { width: auto; margin-top: 0; }
|
.checkbox-row input { width: auto; margin-top: 0; }
|
||||||
@@ -364,6 +394,7 @@ input:focus, select:focus {
|
|||||||
.calendar-user-name { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); }
|
.calendar-user-name { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); }
|
||||||
.calendar-row { flex-wrap: wrap; }
|
.calendar-row { flex-wrap: wrap; }
|
||||||
.calendar-color-picker { display: inline-flex; align-items: center; gap: 5px; margin-left: 8px; }
|
.calendar-color-picker { display: inline-flex; align-items: center; gap: 5px; margin-left: 8px; }
|
||||||
|
.border-color-picker { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
|
||||||
.color-swatch {
|
.color-swatch {
|
||||||
width: 20px; height: 20px; padding: 0; margin: 0;
|
width: 20px; height: 20px; padding: 0; margin: 0;
|
||||||
border: 2px solid var(--border); border-radius: 5px;
|
border: 2px solid var(--border); border-radius: 5px;
|
||||||
@@ -476,6 +507,20 @@ button.secondary:hover { background: var(--surface-alt); }
|
|||||||
.widget-box-remove { right: 4px; }
|
.widget-box-remove { right: 4px; }
|
||||||
.widget-box-settings { right: 28px; }
|
.widget-box-settings { right: 28px; }
|
||||||
.widget-box-remove:hover, .widget-box-settings:hover { background: var(--overlay-hover); }
|
.widget-box-remove:hover, .widget-box-settings:hover { background: var(--overlay-hover); }
|
||||||
|
.widget-box-lock-badge {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 4px;
|
||||||
|
left: 4px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--overlay);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 20px;
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none; /* passive indicator, not a control -- toggled from the widget's own dialog */
|
||||||
|
}
|
||||||
.widget-box-resize-handle {
|
.widget-box-resize-handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
@@ -722,13 +767,24 @@ code {
|
|||||||
}
|
}
|
||||||
.frame-name-edit button { margin-top: 0; }
|
.frame-name-edit button { margin-top: 0; }
|
||||||
|
|
||||||
|
.frame-preview-pair {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-left: 12px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.frame-preview-arrow {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
.frame-preview-thumb {
|
.frame-preview-thumb {
|
||||||
height: 44px;
|
height: 44px;
|
||||||
width: auto;
|
width: auto;
|
||||||
max-width: 130px;
|
max-width: 130px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
margin-left: 12px;
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--surface-alt);
|
background: var(--surface-alt);
|
||||||
@@ -736,6 +792,13 @@ code {
|
|||||||
transition: opacity .12s ease;
|
transition: opacity .12s ease;
|
||||||
}
|
}
|
||||||
.frame-preview-thumb:hover { opacity: 0.8; }
|
.frame-preview-thumb:hover { opacity: 0.8; }
|
||||||
|
.frame-preview-thumb-empty {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
|
width: 60px;
|
||||||
|
font-size: 0; /* no src yet -- suppresses the browser's fallback alt-text render */
|
||||||
|
}
|
||||||
|
.frame-preview-thumb-empty:hover { opacity: 0.3; }
|
||||||
|
|
||||||
.frame-preview-dialog {
|
.frame-preview-dialog {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// Battery widget dialog: display-mode setting and the rendered preview.
|
||||||
|
// Not a page-load script -- frame_layout.js fetches this widget's dialog
|
||||||
|
// HTML fragment, injects it into the shared <dialog>, points
|
||||||
|
// window.FRAME_API at this specific widget
|
||||||
|
// (/api/frames/{id}/widgets/{widget_id}), then calls initBatteryDialog().
|
||||||
|
|
||||||
|
function loadBatteryPreview() {
|
||||||
|
document.getElementById('battery-preview').src = `${window.FRAME_API}/preview/battery?_=${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initBatteryDialog() {
|
||||||
|
document.getElementById('battery-config-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
battery_mode: document.getElementById('battery_mode').value,
|
||||||
|
battery_render_style: document.getElementById('battery_render_style').value,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await apiError(resp));
|
||||||
|
showStatus(true, 'Saved.');
|
||||||
|
loadBatteryPreview();
|
||||||
|
} catch (e) {
|
||||||
|
showStatus(false, e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('battery-preview-refresh').addEventListener('click', loadBatteryPreview);
|
||||||
|
loadBatteryPreview();
|
||||||
|
initBorderFields();
|
||||||
|
initButtonActionFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBatteryDialog() {
|
||||||
|
// Nothing to tear down -- no poll interval, no upload state.
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Shared "Border" card (models.Widget.border_style/border_thickness/
|
||||||
|
// border_color_index, _widget_border_fields.html) -- present on every
|
||||||
|
// widget type's dialog regardless of widget_type, so this is one shared
|
||||||
|
// init function each widget_dialog_<type>.js's init<Type>Dialog() calls,
|
||||||
|
// rather than 8 copies of the same slider/swatch/save wiring. Not a
|
||||||
|
// page-load script by itself -- frame_layout.js loads it unconditionally
|
||||||
|
// (like every other widget_dialog_*.js) since which dialog is open, and
|
||||||
|
// therefore which init<Type>Dialog() calls initBorderFields(), varies.
|
||||||
|
|
||||||
|
function initBorderFields() {
|
||||||
|
const styleSelect = document.getElementById('border_style');
|
||||||
|
if (!styleSelect) return; // dialog fragment didn't render the border card -- shouldn't happen
|
||||||
|
|
||||||
|
const thickness = document.getElementById('border_thickness');
|
||||||
|
const thicknessValue = document.getElementById('border_thickness_value');
|
||||||
|
thickness.addEventListener('input', (e) => {
|
||||||
|
thicknessValue.textContent = `${e.target.value}px`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const colorIndexInput = document.getElementById('border_color_index');
|
||||||
|
document.querySelectorAll('#border-color-picker .color-swatch').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('#border-color-picker .color-swatch').forEach((el) => el.classList.remove('selected'));
|
||||||
|
btn.classList.add('selected');
|
||||||
|
colorIndexInput.value = btn.dataset.index;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('border-config-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const body = JSON.stringify({
|
||||||
|
border_style: styleSelect.value,
|
||||||
|
border_thickness: Number(thickness.value),
|
||||||
|
border_color_index: Number(colorIndexInput.value),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${window.FRAME_API}/border`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await apiError(resp));
|
||||||
|
showStatus(true, 'Border saved.');
|
||||||
|
} catch (err) {
|
||||||
|
showStatus(false, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Shared "Button actions" card (models.FrameButtonAction,
|
||||||
|
// _widget_button_fields.html) -- present on every widget type's dialog
|
||||||
|
// that supports any actions at all (the card renders nothing for
|
||||||
|
// tasks/static/text/battery, whose ACTIONS is empty), so this is one
|
||||||
|
// shared init function each widget_dialog_<type>.js's init<Type>Dialog()
|
||||||
|
// calls, rather than N copies of the same save wiring -- same pattern as
|
||||||
|
// widget_dialog_border.js. Not a page-load script by itself --
|
||||||
|
// frame_layout.js loads it unconditionally (like every other
|
||||||
|
// widget_dialog_*.js) since which dialog is open varies.
|
||||||
|
|
||||||
|
function initButtonActionFields() {
|
||||||
|
const form = document.getElementById('button-actions-form');
|
||||||
|
if (!form) return; // this widget type has no actions -- card didn't render
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const body = JSON.stringify({
|
||||||
|
next_button_action: document.getElementById('next_button_action').value,
|
||||||
|
back_button_action: document.getElementById('back_button_action').value,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${window.FRAME_API}/button-actions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await apiError(resp));
|
||||||
|
showStatus(true, 'Button actions saved.');
|
||||||
|
} catch (err) {
|
||||||
|
showStatus(false, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -81,6 +81,7 @@ function initCalendarDialog() {
|
|||||||
calendar_week_days: document.getElementById('calendar_week_days').value,
|
calendar_week_days: document.getElementById('calendar_week_days').value,
|
||||||
calendar_week_layout: document.getElementById('calendar_week_layout').value,
|
calendar_week_layout: document.getElementById('calendar_week_layout').value,
|
||||||
calendar_week_start_offset: document.getElementById('calendar_week_start_offset').value,
|
calendar_week_start_offset: document.getElementById('calendar_week_start_offset').value,
|
||||||
|
calendar_render_style: document.getElementById('calendar_render_style').value,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||||
@@ -197,6 +198,9 @@ function initCalendarDialog() {
|
|||||||
|
|
||||||
document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview);
|
document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview);
|
||||||
loadCalendarPreview();
|
loadCalendarPreview();
|
||||||
|
initBorderFields();
|
||||||
|
initFontScaleFields();
|
||||||
|
initButtonActionFields();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeCalendarDialog() {
|
function closeCalendarDialog() {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Shared "Text size" card (models.Widget.font_scale,
|
||||||
|
// _widget_font_scale_fields.html) -- only present on the calendar/tasks
|
||||||
|
// dialogs (see frame_pages.py's widget_dialog), same "one shared init
|
||||||
|
// function" shape as initBorderFields, just not included on every
|
||||||
|
// dialog since it isn't relevant to every widget type.
|
||||||
|
|
||||||
|
function initFontScaleFields() {
|
||||||
|
const select = document.getElementById('widget_font_scale');
|
||||||
|
if (!select) return; // dialog fragment didn't render the font-scale card -- shouldn't happen
|
||||||
|
|
||||||
|
document.getElementById('font-scale-config-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${window.FRAME_API}/font-scale`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ font_scale: Number(select.value) }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await apiError(resp));
|
||||||
|
showStatus(true, 'Text size saved.');
|
||||||
|
// Whichever dialog is actually open -- both loadCalendarPreview and
|
||||||
|
// loadTasksPreview are always defined (plain script tags, not
|
||||||
|
// module-scoped), so checking the function's existence isn't
|
||||||
|
// enough; check for the <img> it actually targets instead (calling
|
||||||
|
// the wrong one throws setting .src on a null element).
|
||||||
|
if (document.getElementById('calendar-preview')) loadCalendarPreview();
|
||||||
|
if (document.getElementById('tasks-preview')) loadTasksPreview();
|
||||||
|
} catch (err) {
|
||||||
|
showStatus(false, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,6 +8,33 @@
|
|||||||
// FRAME_API + a global loadQueue()" contract queue.js has always had.
|
// FRAME_API + a global loadQueue()" contract queue.js has always had.
|
||||||
|
|
||||||
let photosPollTimer = null;
|
let photosPollTimer = null;
|
||||||
|
let photoLocked = false;
|
||||||
|
let photoHasCurrent = false;
|
||||||
|
|
||||||
|
function renderLockButton() {
|
||||||
|
const btn = document.getElementById('lock-photo-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
btn.textContent = photoLocked ? 'Unlock this photo' : 'Lock this photo';
|
||||||
|
btn.classList.toggle('active', photoLocked);
|
||||||
|
btn.disabled = !photoHasCurrent && !photoLocked; // nothing displayed yet to lock
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleLock() {
|
||||||
|
const next = !photoLocked;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${window.FRAME_API}/lock`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ locked: next }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await apiError(resp));
|
||||||
|
photoLocked = next;
|
||||||
|
renderLockButton();
|
||||||
|
showStatus(true, photoLocked ? 'Locked -- this photo will stay put.' : 'Unlocked.');
|
||||||
|
} catch (e) {
|
||||||
|
showStatus(false, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadQueue() {
|
async function loadQueue() {
|
||||||
if (dragState) {
|
if (dragState) {
|
||||||
@@ -21,9 +48,14 @@ async function loadQueue() {
|
|||||||
currentEl.innerHTML =
|
currentEl.innerHTML =
|
||||||
'<p class="sub">Not available yet -- the owner needs to connect Immich (Settings) and pick an album.</p>';
|
'<p class="sub">Not available yet -- the owner needs to connect Immich (Settings) and pick an album.</p>';
|
||||||
renderUpcoming([]);
|
renderUpcoming([]);
|
||||||
|
photoHasCurrent = false;
|
||||||
|
renderLockButton();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
|
photoLocked = !!data.locked;
|
||||||
|
photoHasCurrent = !!data.current;
|
||||||
|
renderLockButton();
|
||||||
currentEl.innerHTML = '';
|
currentEl.innerHTML = '';
|
||||||
if (data.current) {
|
if (data.current) {
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
@@ -109,10 +141,14 @@ function initPhotosDialog() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('lock-photo-btn').addEventListener('click', toggleLock);
|
||||||
|
|
||||||
loadQueue();
|
loadQueue();
|
||||||
// Slow poll: picks up real changes (new photo displayed, queue edited
|
// Slow poll: picks up real changes (new photo displayed, queue edited
|
||||||
// from elsewhere) without a manual refresh. Skipped mid-drag.
|
// from elsewhere) without a manual refresh. Skipped mid-drag.
|
||||||
photosPollTimer = setInterval(loadQueue, 10000);
|
photosPollTimer = setInterval(loadQueue, 10000);
|
||||||
|
initBorderFields();
|
||||||
|
initButtonActionFields();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePhotosDialog() {
|
function closePhotosDialog() {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ function initStaticDialog() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const body = new URLSearchParams({
|
const body = new URLSearchParams({
|
||||||
display_mode: document.getElementById('display_mode').value,
|
display_mode: document.getElementById('display_mode').value,
|
||||||
|
static_render_style: document.getElementById('static_render_style').value,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||||
@@ -55,6 +56,8 @@ function initStaticDialog() {
|
|||||||
|
|
||||||
document.getElementById('static-preview-refresh').addEventListener('click', loadStaticPreview);
|
document.getElementById('static-preview-refresh').addEventListener('click', loadStaticPreview);
|
||||||
loadStaticPreview();
|
loadStaticPreview();
|
||||||
|
initBorderFields();
|
||||||
|
initButtonActionFields();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeStaticDialog() {
|
function closeStaticDialog() {
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ function initTasksDialog() {
|
|||||||
const body = new URLSearchParams({
|
const body = new URLSearchParams({
|
||||||
tasks_name: document.getElementById('tasks_name').value,
|
tasks_name: document.getElementById('tasks_name').value,
|
||||||
tasks_show_completed: String(document.getElementById('tasks_show_completed').checked),
|
tasks_show_completed: String(document.getElementById('tasks_show_completed').checked),
|
||||||
|
tasks_render_style: document.getElementById('tasks_render_style').value,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||||
@@ -88,6 +89,9 @@ function initTasksDialog() {
|
|||||||
|
|
||||||
document.getElementById('tasks-preview-refresh').addEventListener('click', loadTasksPreview);
|
document.getElementById('tasks-preview-refresh').addEventListener('click', loadTasksPreview);
|
||||||
loadTasksPreview();
|
loadTasksPreview();
|
||||||
|
initBorderFields();
|
||||||
|
initFontScaleFields();
|
||||||
|
initButtonActionFields();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeTasksDialog() {
|
function closeTasksDialog() {
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ function initTextDialog() {
|
|||||||
text_font_size: document.getElementById('text_font_size').value,
|
text_font_size: document.getElementById('text_font_size').value,
|
||||||
text_align: document.getElementById('text_align').value,
|
text_align: document.getElementById('text_align').value,
|
||||||
text_background_color: document.getElementById('text_background_color').value,
|
text_background_color: document.getElementById('text_background_color').value,
|
||||||
|
text_render_style: document.getElementById('text_render_style').value,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||||
@@ -130,6 +131,8 @@ function initTextDialog() {
|
|||||||
|
|
||||||
document.getElementById('text-preview-refresh').addEventListener('click', loadTextPreview);
|
document.getElementById('text-preview-refresh').addEventListener('click', loadTextPreview);
|
||||||
loadTextPreview();
|
loadTextPreview();
|
||||||
|
initBorderFields();
|
||||||
|
initButtonActionFields();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeTextDialog() {
|
function closeTextDialog() {
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// Weather widget dialog: mode/provider/units settings, location (single-
|
||||||
|
// city modes) or a city list (multi_city mode), and the rendered
|
||||||
|
// preview. Not a page-load script -- frame_layout.js fetches this
|
||||||
|
// widget's dialog HTML fragment, injects it into the shared <dialog>,
|
||||||
|
// points window.FRAME_API at this specific widget
|
||||||
|
// (/api/frames/{id}/widgets/{widget_id}), then calls initWeatherDialog().
|
||||||
|
|
||||||
|
// Only one of "Location" (current/hourly/daily -- one city) or "Cities"
|
||||||
|
// (multi_city -- a list) is ever relevant at a time; the interval/days
|
||||||
|
// rows are each specific to one mode too.
|
||||||
|
function updateWeatherFieldVisibility() {
|
||||||
|
const mode = document.getElementById('weather_mode').value;
|
||||||
|
document.getElementById('weather-hourly-interval-row').style.display = mode === 'hourly' ? '' : 'none';
|
||||||
|
document.getElementById('weather-daily-days-row').style.display = mode === 'daily' ? '' : 'none';
|
||||||
|
document.getElementById('weather-location-section').style.display = mode === 'multi_city' ? 'none' : '';
|
||||||
|
document.getElementById('weather-cities-section').style.display = mode === 'multi_city' ? '' : 'none';
|
||||||
|
// Modern style is only built for current/daily (see app/html_render.py) --
|
||||||
|
// hourly/multi_city always render classic server-side regardless of this
|
||||||
|
// setting, so hide the row entirely rather than offer a choice that's a
|
||||||
|
// silent no-op.
|
||||||
|
document.getElementById('weather-render-style-row').style.display =
|
||||||
|
(mode === 'current' || mode === 'daily') ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function addWeatherWidgetCityRow(label) {
|
||||||
|
const list = document.getElementById('weather-widget-city-list');
|
||||||
|
const empty = document.getElementById('weather-widget-city-empty');
|
||||||
|
if (empty) empty.remove();
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'checkbox-row';
|
||||||
|
li.style.cssText = 'justify-content: space-between; margin-top: 6px;';
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.textContent = label;
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'btn-inline secondary weather-widget-city-remove';
|
||||||
|
btn.dataset.label = label;
|
||||||
|
btn.textContent = 'Remove';
|
||||||
|
btn.addEventListener('click', removeWeatherWidgetCity);
|
||||||
|
li.appendChild(span);
|
||||||
|
li.appendChild(btn);
|
||||||
|
list.appendChild(li);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeWeatherWidgetCity(e) {
|
||||||
|
const label = e.target.dataset.label;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${window.FRAME_API}/weather-widget-cities/remove`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ label }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await apiError(resp));
|
||||||
|
e.target.closest('li').remove();
|
||||||
|
const list = document.getElementById('weather-widget-city-list');
|
||||||
|
if (!list.querySelector('li')) {
|
||||||
|
list.innerHTML = '<li class="sub" id="weather-widget-city-empty">No cities added yet.</li>';
|
||||||
|
}
|
||||||
|
showStatus(true, `${label} removed.`);
|
||||||
|
loadWeatherPreview(false);
|
||||||
|
} catch (e) {
|
||||||
|
showStatus(false, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadWeatherPreview(force) {
|
||||||
|
const suffix = force ? '&force=1' : '';
|
||||||
|
document.getElementById('weather-preview').src = `${window.FRAME_API}/preview/weather?_=${Date.now()}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initWeatherDialog() {
|
||||||
|
document.getElementById('weather_mode').addEventListener('change', updateWeatherFieldVisibility);
|
||||||
|
updateWeatherFieldVisibility();
|
||||||
|
|
||||||
|
document.getElementById('weather-config-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
weather_mode: document.getElementById('weather_mode').value,
|
||||||
|
weather_provider: document.getElementById('weather_provider').value,
|
||||||
|
weather_units: document.getElementById('weather_units').value,
|
||||||
|
weather_hourly_interval_hours: document.getElementById('weather_hourly_interval_hours').value,
|
||||||
|
weather_daily_days: document.getElementById('weather_daily_days').value,
|
||||||
|
weather_render_style: document.getElementById('weather_render_style').value,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await apiError(resp));
|
||||||
|
showStatus(true, 'Saved.');
|
||||||
|
loadWeatherPreview(false);
|
||||||
|
} catch (e) {
|
||||||
|
showStatus(false, e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('weather-location-set').addEventListener('click', async () => {
|
||||||
|
const input = document.getElementById('weather-location-input');
|
||||||
|
const name = input.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${window.FRAME_API}/weather-location`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await apiError(resp));
|
||||||
|
const data = await resp.json();
|
||||||
|
document.getElementById('weather-location-current').textContent = `Currently: ${data.city.label}`;
|
||||||
|
input.value = '';
|
||||||
|
showStatus(true, `Location set to ${data.city.label}.`);
|
||||||
|
loadWeatherPreview(false);
|
||||||
|
} catch (e) {
|
||||||
|
showStatus(false, e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('weather-location-clear').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${window.FRAME_API}/weather-location`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: null }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await apiError(resp));
|
||||||
|
document.getElementById('weather-location-current').textContent = 'No location set yet.';
|
||||||
|
showStatus(true, 'Location cleared.');
|
||||||
|
loadWeatherPreview(false);
|
||||||
|
} catch (e) {
|
||||||
|
showStatus(false, e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.weather-widget-city-remove').forEach((el) => el.addEventListener('click', removeWeatherWidgetCity));
|
||||||
|
|
||||||
|
document.getElementById('weather-widget-city-add').addEventListener('click', async () => {
|
||||||
|
const input = document.getElementById('weather-widget-city-input');
|
||||||
|
const name = input.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${window.FRAME_API}/weather-widget-cities/add`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await apiError(resp));
|
||||||
|
const data = await resp.json();
|
||||||
|
addWeatherWidgetCityRow(data.city.label);
|
||||||
|
input.value = '';
|
||||||
|
showStatus(true, `Added ${data.city.label}.`);
|
||||||
|
loadWeatherPreview(false);
|
||||||
|
} catch (e) {
|
||||||
|
showStatus(false, e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('weather-preview-refresh').addEventListener('click', () => loadWeatherPreview(true));
|
||||||
|
loadWeatherPreview(false);
|
||||||
|
initBorderFields();
|
||||||
|
initButtonActionFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeWeatherDialog() {
|
||||||
|
// Nothing to tear down -- no poll interval, unlike the photos dialog.
|
||||||
|
}
|
||||||
@@ -90,12 +90,33 @@ function initWhiteboardDialog() {
|
|||||||
whiteboardClearBtn.addEventListener('click', clearWhiteboardSource);
|
whiteboardClearBtn.addEventListener('click', clearWhiteboardSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.getElementById('whiteboard-config-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
whiteboard_render_style: document.getElementById('whiteboard_render_style').value,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await apiError(resp));
|
||||||
|
showStatus(true, 'Saved.');
|
||||||
|
loadWhiteboardPreview(false);
|
||||||
|
} catch (e) {
|
||||||
|
showStatus(false, e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Shows whatever's already cached (cheap, no refetch) on open; the
|
// Shows whatever's already cached (cheap, no refetch) on open; the
|
||||||
// button is the one place that means "no really, go check now" --
|
// button is the one place that means "no really, go check now" --
|
||||||
// bypasses the fetch throttle server-side (see api_widget_preview_
|
// bypasses the fetch throttle server-side (see api_widget_preview_
|
||||||
// whiteboard's `force` param).
|
// whiteboard's `force` param).
|
||||||
document.getElementById('whiteboard-preview-refresh').addEventListener('click', () => loadWhiteboardPreview(true));
|
document.getElementById('whiteboard-preview-refresh').addEventListener('click', () => loadWhiteboardPreview(true));
|
||||||
loadWhiteboardPreview(false);
|
loadWhiteboardPreview(false);
|
||||||
|
initBorderFields();
|
||||||
|
initButtonActionFields();
|
||||||
|
|
||||||
// --- file picker (Browse...) ---
|
// --- file picker (Browse...) ---
|
||||||
const browseToggle = document.getElementById('whiteboard-browse-toggle');
|
const browseToggle = document.getElementById('whiteboard-browse-toggle');
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<nav class="tabs">
|
||||||
|
<a href="/admin" class="{% if active_admin_tab == 'main' %}active{% endif %}">Users & Frames</a>
|
||||||
|
<a href="/admin/logs" class="{% if active_admin_tab == 'logs' %}active{% endif %}">Server Logs</a>
|
||||||
|
</nav>
|
||||||
@@ -7,10 +7,19 @@
|
|||||||
<button type="button" class="btn-inline" id="frame-name-save">Save</button>
|
<button type="button" class="btn-inline" id="frame-name-save">Save</button>
|
||||||
<button type="button" class="btn-inline secondary" id="frame-name-cancel">Cancel</button>
|
<button type="button" class="btn-inline secondary" id="frame-name-cancel">Cancel</button>
|
||||||
</span>
|
</span>
|
||||||
<img id="frame-preview-thumb" class="frame-preview-thumb" alt="Live preview of what the frame is displaying" title="What the frame is currently displaying -- click to enlarge">
|
<span class="frame-preview-pair">
|
||||||
|
<img id="frame-preview-now-thumb" class="frame-preview-thumb frame-preview-thumb-empty" alt="What the frame is currently displaying" title="Now displaying">
|
||||||
|
<span class="frame-preview-arrow" aria-hidden="true">→</span>
|
||||||
|
<img id="frame-preview-thumb" class="frame-preview-thumb" alt="Live preview of what the frame will show next" title="Up next -- live preview, updates as you edit the layout -- click to enlarge">
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<dialog id="frame-preview-now-dialog" class="frame-preview-dialog">
|
||||||
|
<button type="button" id="frame-preview-now-dialog-close" class="icon-btn" aria-label="Close" title="Close">×</button>
|
||||||
|
<img id="frame-preview-now-dialog-img" alt="What the frame is currently displaying">
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<dialog id="frame-preview-dialog" class="frame-preview-dialog">
|
<dialog id="frame-preview-dialog" class="frame-preview-dialog">
|
||||||
<button type="button" id="frame-preview-dialog-close" class="icon-btn" aria-label="Close" title="Close">×</button>
|
<button type="button" id="frame-preview-dialog-close" class="icon-btn" aria-label="Close" title="Close">×</button>
|
||||||
<img id="frame-preview-dialog-img" alt="Live preview of what the frame is displaying" title="Click to refresh">
|
<img id="frame-preview-dialog-img" alt="Live preview of what the frame will show next" title="Click to refresh">
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<section class="card" style="margin-top: 20px;">
|
||||||
|
<h2 class="card-title">Border</h2>
|
||||||
|
<p class="sub">An optional border drawn around this widget's own box --
|
||||||
|
"None" (the default) draws nothing. Shows on the frame's actual live
|
||||||
|
view, not in the standalone preview below.</p>
|
||||||
|
<form id="border-config-form">
|
||||||
|
<label>Style
|
||||||
|
<select id="border_style">
|
||||||
|
{% for style in border_styles %}
|
||||||
|
<option value="{{ style }}" {% if widget.border_style == style %}selected{% endif %}>{{ border_style_labels[style] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Thickness
|
||||||
|
<input type="range" id="border_thickness" min="{{ min_border_thickness }}" max="{{ max_border_thickness }}" step="1"
|
||||||
|
value="{{ widget.border_thickness }}">
|
||||||
|
<span class="slider-value" id="border_thickness_value">{{ widget.border_thickness }}px</span>
|
||||||
|
</label>
|
||||||
|
<label>Color</label>
|
||||||
|
{% set current_palette = frame.palette_rgb or default_palette_rgb %}
|
||||||
|
{% set current_hex = palette_to_hex(current_palette) %}
|
||||||
|
<div class="border-color-picker" id="border-color-picker">
|
||||||
|
{% for label in border_color_labels %}
|
||||||
|
<button type="button" class="color-swatch {% if widget.border_color_index == loop.index0 %}selected{% endif %}"
|
||||||
|
data-index="{{ loop.index0 }}" title="{{ label }}"
|
||||||
|
style="background-color: {{ current_hex[loop.index0] }};"></button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="border_color_index" value="{{ widget.border_color_index }}">
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{% if button_action_labels %}
|
||||||
|
<section class="card" style="margin-top: 20px;">
|
||||||
|
<h2 class="card-title">Button actions</h2>
|
||||||
|
<p class="sub">What the frame's physical NEXT/BACK buttons do to this
|
||||||
|
widget. Every widget on the frame runs its own binding when a
|
||||||
|
button is pressed -- this only affects this one.</p>
|
||||||
|
<form id="button-actions-form">
|
||||||
|
<label>Next button
|
||||||
|
<select id="next_button_action">
|
||||||
|
<option value="" {% if not next_button_action %}selected{% endif %}>(none)</option>
|
||||||
|
{% for action, label in button_action_labels.items() %}
|
||||||
|
<option value="{{ action }}" {% if next_button_action == action %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Back button
|
||||||
|
<select id="back_button_action">
|
||||||
|
<option value="" {% if not back_button_action %}selected{% endif %}>(none)</option>
|
||||||
|
{% for action, label in button_action_labels.items() %}
|
||||||
|
<option value="{{ action }}" {% if back_button_action == action %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<h2 class="dialog-title">Battery widget</h2>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="card-title">Settings</h2>
|
||||||
|
<p class="sub">Shows this frame's own last-reported battery level --
|
||||||
|
nothing to configure beyond how much detail to show.</p>
|
||||||
|
<form id="battery-config-form">
|
||||||
|
<label>Display mode
|
||||||
|
<select id="battery_mode">
|
||||||
|
<option value="compact" {% if battery_cfg and battery_cfg.mode == 'compact' %}selected{% endif %}>Compact (icon + percent only)</option>
|
||||||
|
<option value="detailed" {% if not battery_cfg or battery_cfg.mode == 'detailed' %}selected{% endif %}>Detailed (+ estimated time left, last report)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Render style
|
||||||
|
<select id="battery_render_style">
|
||||||
|
<option value="classic" {% if not battery_cfg or battery_cfg.render_style == 'classic' %}selected{% endif %}>Classic (hand-drawn icon)</option>
|
||||||
|
<option value="modern" {% if battery_cfg and battery_cfg.render_style == 'modern' %}selected{% endif %}>Modern (experimental)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% include "_widget_border_fields.html" %}
|
||||||
|
|
||||||
|
{% include "_widget_button_fields.html" %}
|
||||||
|
|
||||||
|
<section class="card" style="margin-top: 20px;">
|
||||||
|
<h2 class="card-title">Preview</h2>
|
||||||
|
<p class="sub">How this widget currently renders.</p>
|
||||||
|
<img class="preview-img" id="battery-preview" alt="Battery widget preview">
|
||||||
|
<button type="button" class="secondary" id="battery-preview-refresh">Refresh now</button>
|
||||||
|
</section>
|
||||||
@@ -40,6 +40,12 @@
|
|||||||
<p class="sub" style="margin-top: 4px;">0 = starts today, negative = starts in the
|
<p class="sub" style="margin-top: 4px;">0 = starts today, negative = starts in the
|
||||||
past, positive = starts in the future. Only used when Days to show isn't 7.</p>
|
past, positive = starts in the future. Only used when Days to show isn't 7.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<label>Render style
|
||||||
|
<select id="calendar_render_style">
|
||||||
|
<option value="classic" {% if calendar_cfg.render_style == 'classic' %}selected{% endif %}>Classic (hand-drawn)</option>
|
||||||
|
<option value="modern" {% if calendar_cfg.render_style == 'modern' %}selected{% endif %}>Modern (experimental, all views)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -127,6 +133,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% include "_widget_border_fields.html" %}
|
||||||
|
|
||||||
|
{% include "_widget_font_scale_fields.html" %}
|
||||||
|
|
||||||
|
{% include "_widget_button_fields.html" %}
|
||||||
|
|
||||||
<section class="card" style="margin-top: 20px;">
|
<section class="card" style="margin-top: 20px;">
|
||||||
<h2 class="card-title">Preview</h2>
|
<h2 class="card-title">Preview</h2>
|
||||||
<p class="sub">How this widget currently renders.</p>
|
<p class="sub">How this widget currently renders.</p>
|
||||||
|
|||||||
@@ -41,9 +41,17 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% include "_widget_border_fields.html" %}
|
||||||
|
|
||||||
|
{% include "_widget_button_fields.html" %}
|
||||||
|
|
||||||
<section class="card" style="margin-top: 20px;">
|
<section class="card" style="margin-top: 20px;">
|
||||||
<h2 class="card-title">Now displaying</h2>
|
<h2 class="card-title">Now displaying</h2>
|
||||||
<div id="current-thumb"><p class="sub">Loading...</p></div>
|
<div id="current-thumb"><p class="sub">Loading...</p></div>
|
||||||
|
<button type="button" class="secondary" id="lock-photo-btn" style="margin-top: 8px;">Lock this photo</button>
|
||||||
|
<p class="sub" style="margin-top: 4px;">While locked, this photo stays
|
||||||
|
on screen -- the refresh timer and the next/back buttons won't
|
||||||
|
change it until you unlock it.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card" style="margin-top: 20px;">
|
<section class="card" style="margin-top: 20px;">
|
||||||
|
|||||||
@@ -32,10 +32,20 @@
|
|||||||
exactly without cropping (an image with a different aspect ratio
|
exactly without cropping (an image with a different aspect ratio
|
||||||
looks stretched); <strong>Shrink to fit</strong> shows the whole
|
looks stretched); <strong>Shrink to fit</strong> shows the whole
|
||||||
image, letterboxed if needed.</p>
|
image, letterboxed if needed.</p>
|
||||||
|
<label>Render style
|
||||||
|
<select id="static_render_style">
|
||||||
|
<option value="classic" {% if not static_cfg or static_cfg.render_style == 'classic' %}selected{% endif %}>Classic (no frame)</option>
|
||||||
|
<option value="modern" {% if static_cfg and static_cfg.render_style == 'modern' %}selected{% endif %}>Modern (rounded-corner card, experimental)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% include "_widget_border_fields.html" %}
|
||||||
|
|
||||||
|
{% include "_widget_button_fields.html" %}
|
||||||
|
|
||||||
<section class="card" style="margin-top: 20px;">
|
<section class="card" style="margin-top: 20px;">
|
||||||
<h2 class="card-title">Preview</h2>
|
<h2 class="card-title">Preview</h2>
|
||||||
<p class="sub">How this widget currently renders.</p>
|
<p class="sub">How this widget currently renders.</p>
|
||||||
|
|||||||
@@ -55,10 +55,22 @@
|
|||||||
<input type="checkbox" id="tasks_show_completed" {% if task_cfg.show_completed %}checked{% endif %}>
|
<input type="checkbox" id="tasks_show_completed" {% if task_cfg.show_completed %}checked{% endif %}>
|
||||||
<label for="tasks_show_completed">Also show tasks completed in the last 24 hours</label>
|
<label for="tasks_show_completed">Also show tasks completed in the last 24 hours</label>
|
||||||
</div>
|
</div>
|
||||||
|
<label>Render style
|
||||||
|
<select id="tasks_render_style">
|
||||||
|
<option value="classic" {% if task_cfg.render_style == 'classic' %}selected{% endif %}>Classic (hand-drawn checklist)</option>
|
||||||
|
<option value="modern" {% if task_cfg.render_style == 'modern' %}selected{% endif %}>Modern (experimental)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% include "_widget_border_fields.html" %}
|
||||||
|
|
||||||
|
{% include "_widget_font_scale_fields.html" %}
|
||||||
|
|
||||||
|
{% include "_widget_button_fields.html" %}
|
||||||
|
|
||||||
<section class="card" style="margin-top: 20px;">
|
<section class="card" style="margin-top: 20px;">
|
||||||
<h2 class="card-title">Preview</h2>
|
<h2 class="card-title">Preview</h2>
|
||||||
<p class="sub">How this widget currently renders.</p>
|
<p class="sub">How this widget currently renders.</p>
|
||||||
|
|||||||
@@ -43,10 +43,20 @@
|
|||||||
<label>Background color
|
<label>Background color
|
||||||
<input type="color" id="text_background_color" value="{{ text_cfg.background_color if text_cfg else '#ffffff' }}">
|
<input type="color" id="text_background_color" value="{{ text_cfg.background_color if text_cfg else '#ffffff' }}">
|
||||||
</label>
|
</label>
|
||||||
|
<label>Render style
|
||||||
|
<select id="text_render_style">
|
||||||
|
<option value="classic" {% if not text_cfg or text_cfg.render_style == 'classic' %}selected{% endif %}>Classic (hand-drawn text)</option>
|
||||||
|
<option value="modern" {% if text_cfg and text_cfg.render_style == 'modern' %}selected{% endif %}>Modern (experimental)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% include "_widget_border_fields.html" %}
|
||||||
|
|
||||||
|
{% include "_widget_button_fields.html" %}
|
||||||
|
|
||||||
<section class="card" style="margin-top: 20px;">
|
<section class="card" style="margin-top: 20px;">
|
||||||
<h2 class="card-title">Preview</h2>
|
<h2 class="card-title">Preview</h2>
|
||||||
<p class="sub">How this widget currently renders.</p>
|
<p class="sub">How this widget currently renders.</p>
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<h2 class="dialog-title">Weather widget</h2>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="card-title">Display</h2>
|
||||||
|
<form id="weather-config-form">
|
||||||
|
<label>Mode
|
||||||
|
<select id="weather_mode">
|
||||||
|
<option value="current" {% if weather_cfg.mode == "current" %}selected{% endif %}>Current conditions</option>
|
||||||
|
<option value="hourly" {% if weather_cfg.mode == "hourly" %}selected{% endif %}>Hourly forecast</option>
|
||||||
|
<option value="daily" {% if weather_cfg.mode == "daily" %}selected{% endif %}>Multi-day forecast</option>
|
||||||
|
<option value="multi_city" {% if weather_cfg.mode == "multi_city" %}selected{% endif %}>Multiple cities</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div id="weather-render-style-row">
|
||||||
|
<label>Render style
|
||||||
|
<select id="weather_render_style">
|
||||||
|
<option value="classic" {% if weather_cfg.render_style == "classic" %}selected{% endif %}>Classic (hand-drawn icons)</option>
|
||||||
|
<option value="modern" {% if weather_cfg.render_style == "modern" %}selected{% endif %}>Modern (experimental, current/daily only)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>Weather source
|
||||||
|
<select id="weather_provider">
|
||||||
|
{% for value, label in weather_provider_labels.items() %}
|
||||||
|
<option value="{{ value }}" {% if weather_cfg.provider == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p class="sub" style="margin-top: 4px;">National Weather Service only covers US locations; Environment Canada only covers Canadian locations.</p>
|
||||||
|
<label>Units
|
||||||
|
<select id="weather_units">
|
||||||
|
<option value="fahrenheit" {% if weather_cfg.units == "fahrenheit" %}selected{% endif %}>Fahrenheit</option>
|
||||||
|
<option value="celsius" {% if weather_cfg.units == "celsius" %}selected{% endif %}>Celsius</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div id="weather-hourly-interval-row">
|
||||||
|
<label>Hours between ticks
|
||||||
|
<select id="weather_hourly_interval_hours">
|
||||||
|
{% for hours in (3, 4, 6, 12) %}
|
||||||
|
<option value="{{ hours }}" {% if weather_cfg.hourly_interval_hours == hours %}selected{% endif %}>{{ hours }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="weather-daily-days-row">
|
||||||
|
<label>Days to show
|
||||||
|
<input type="number" id="weather_daily_days" min="1" max="14" value="{{ weather_cfg.daily_days }}">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" id="weather-location-section" style="margin-top: 20px;">
|
||||||
|
<h2 class="card-title">Location</h2>
|
||||||
|
<p class="sub" id="weather-location-current">
|
||||||
|
{% if weather_cfg.city_label %}Currently: {{ weather_cfg.city_label }}{% else %}No location set yet.{% endif %}
|
||||||
|
</p>
|
||||||
|
<div class="checkbox-row" style="flex-wrap: wrap;">
|
||||||
|
<input type="text" id="weather-location-input" placeholder="e.g. Portland, OR" style="margin-top: 0; flex: 1 1 160px;">
|
||||||
|
<button type="button" class="btn-inline" id="weather-location-set">Set</button>
|
||||||
|
<button type="button" class="btn-inline secondary" id="weather-location-clear">Clear</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" id="weather-cities-section" style="margin-top: 20px;">
|
||||||
|
<h2 class="card-title">Cities</h2>
|
||||||
|
<ul class="calendar-user-list" id="weather-widget-city-list">
|
||||||
|
{% for c in weather_cfg.cities or [] %}
|
||||||
|
<li class="checkbox-row" style="justify-content: space-between; margin-top: 6px;">
|
||||||
|
<span>{{ c.label }}</span>
|
||||||
|
<button type="button" class="btn-inline secondary weather-widget-city-remove" data-label="{{ c.label }}">Remove</button>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="sub" id="weather-widget-city-empty">No cities added yet.</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<div class="checkbox-row" style="margin-top: 10px;">
|
||||||
|
<input type="text" id="weather-widget-city-input" placeholder="e.g. Portland, OR" style="margin-top: 0; flex: 1;">
|
||||||
|
<button type="button" class="btn-inline" id="weather-widget-city-add">Add</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% include "_widget_border_fields.html" %}
|
||||||
|
|
||||||
|
{% include "_widget_button_fields.html" %}
|
||||||
|
|
||||||
|
<section class="card" style="margin-top: 20px;">
|
||||||
|
<h2 class="card-title">Preview</h2>
|
||||||
|
<p class="sub">How this widget currently renders.</p>
|
||||||
|
<img class="preview-img" id="weather-preview" alt="Weather preview">
|
||||||
|
<button type="button" class="secondary" id="weather-preview-refresh">Refresh now</button>
|
||||||
|
</section>
|
||||||
@@ -50,6 +50,23 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card" style="margin-top: 20px;">
|
||||||
|
<h2 class="card-title">Settings</h2>
|
||||||
|
<form id="whiteboard-config-form">
|
||||||
|
<label>Render style
|
||||||
|
<select id="whiteboard_render_style">
|
||||||
|
<option value="classic" {% if not whiteboard_cfg or whiteboard_cfg.render_style == 'classic' %}selected{% endif %}>Classic (no frame)</option>
|
||||||
|
<option value="modern" {% if whiteboard_cfg and whiteboard_cfg.render_style == 'modern' %}selected{% endif %}>Modern (rounded-corner card, experimental)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% include "_widget_border_fields.html" %}
|
||||||
|
|
||||||
|
{% include "_widget_button_fields.html" %}
|
||||||
|
|
||||||
<section class="card" style="margin-top: 20px;">
|
<section class="card" style="margin-top: 20px;">
|
||||||
<h2 class="card-title">Preview</h2>
|
<h2 class="card-title">Preview</h2>
|
||||||
<p class="sub">How this widget currently renders.</p>
|
<p class="sub">How this widget currently renders.</p>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<section class="card" style="margin-top: 20px;">
|
||||||
|
<h2 class="card-title">Text size</h2>
|
||||||
|
<p class="sub">Scales this widget's own text up for easier reading on
|
||||||
|
the panel -- rows/columns re-fit around the bigger text automatically.</p>
|
||||||
|
<form id="font-scale-config-form">
|
||||||
|
<label>Size
|
||||||
|
<select id="widget_font_scale">
|
||||||
|
{% for value, label in font_scale_choices %}
|
||||||
|
<option value="{{ value }}" {% if widget.font_scale == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
{% block page_title %}Administration{% endblock %}
|
{% block page_title %}Administration{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% include "_admin_tabs.html" %}
|
||||||
|
|
||||||
{% if notice %}<div class="status ok">{{ notice }}</div>{% endif %}
|
{% if notice %}<div class="status ok">{{ notice }}</div>{% endif %}
|
||||||
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
|
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
|
||||||
|
|
||||||
@@ -107,20 +109,12 @@
|
|||||||
· linked: {{ links_by_frame.get(f.id, []) | map(attribute="username") | join(", ") or "nobody" }}<br>
|
· linked: {{ links_by_frame.get(f.id, []) | map(attribute="username") | join(", ") or "nobody" }}<br>
|
||||||
firmware: {{ f.device_firmware_version or "?" }} ({{ f.device_board_variant or "board unknown" }})
|
firmware: {{ f.device_firmware_version or "?" }} ({{ f.device_board_variant or "board unknown" }})
|
||||||
· token ack: {{ "yes" if f.device_token_ack else "no" }}
|
· token ack: {{ "yes" if f.device_token_ack else "no" }}
|
||||||
{% if f.legacy_token_enabled %}· <strong>legacy token window OPEN</strong>{% endif %}
|
|
||||||
</p>
|
</p>
|
||||||
<form method="post" action="/admin/frames/{{ f.id }}/link-user" class="admin-inline-form">
|
<form method="post" action="/admin/frames/{{ f.id }}/link-user" class="admin-inline-form">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<input type="text" name="username" placeholder="Link user by name" required>
|
<input type="text" name="username" placeholder="Link user by name" required>
|
||||||
<button type="submit" class="secondary btn-inline">Link</button>
|
<button type="submit" class="secondary btn-inline">Link</button>
|
||||||
</form>
|
</form>
|
||||||
{% if f.legacy_token_enabled %}
|
|
||||||
<form method="post" action="/admin/frames/{{ f.id }}/end-legacy" class="admin-inline-form"
|
|
||||||
onsubmit="return confirm('Close the legacy-token window for frame #{{ f.id }}? Only do this once the device has acknowledged its own token.');">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
||||||
<button type="submit" class="secondary btn-inline">Close legacy window</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
<form method="post" action="/admin/frames/{{ f.id }}/delete" class="admin-inline-form"
|
<form method="post" action="/admin/frames/{{ f.id }}/delete" class="admin-inline-form"
|
||||||
onsubmit="return confirm('Delete frame #{{ f.id }} and all its history?');">
|
onsubmit="return confirm('Delete frame #{{ f.id }} and all its history?');">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{% extends "app_base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Server Logs{% endblock %}
|
||||||
|
{% block page_title %}Administration{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include "_admin_tabs.html" %}
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-title-row">
|
||||||
|
<h2 class="card-title">Server Logs</h2>
|
||||||
|
<a href="/admin/logs/download" class="secondary btn-inline">Download full log</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not log_exists %}
|
||||||
|
<p class="sub">No log file yet -- nothing has been logged since this server last started.</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="sub">Last {{ log_lines }} lines of <code>{{ log_path }}</code>. Rotates at ~2MB
|
||||||
|
(older entries roll into <code>{{ log_path }}.1</code>, etc. -- not shown here; use
|
||||||
|
"Download full log" for just the current file).</p>
|
||||||
|
<div class="log-view-controls">
|
||||||
|
{% for n in [200, 500, 2000, 5000] %}
|
||||||
|
<a href="/admin/logs?lines={{ n }}" class="{% if log_lines == n %}active{% endif %}">{{ n }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
<a href="/admin/logs?lines={{ log_lines }}" class="secondary btn-inline">Refresh</a>
|
||||||
|
</div>
|
||||||
|
<pre class="log-view">{{ log_text }}</pre>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -17,6 +17,14 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<link rel="stylesheet" href="/static/theme.css">
|
<link rel="stylesheet" href="/static/theme.css">
|
||||||
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
|
<link rel="icon" href="/static/icons/favicon.png">
|
||||||
|
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
|
||||||
|
<meta name="theme-color" content="#2563eb">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="ESPresso">
|
||||||
{% block extra_head %}{% endblock %}
|
{% block extra_head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -17,6 +17,14 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<link rel="stylesheet" href="/static/theme.css">
|
<link rel="stylesheet" href="/static/theme.css">
|
||||||
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
|
<link rel="icon" href="/static/icons/favicon.png">
|
||||||
|
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
|
||||||
|
<meta name="theme-color" content="#2563eb">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="ESPresso">
|
||||||
{% block extra_head %}{% endblock %}
|
{% block extra_head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -55,37 +55,37 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2 class="card-title">Button assignments</h2>
|
<h2 class="card-title">Hold actions</h2>
|
||||||
<p class="sub">What the frame's physical NEXT and BACK buttons do --
|
<p class="sub">Holding NEXT or BACK past this duration runs a
|
||||||
assign one or more widget actions to each, in the order they
|
frame-wide action instead of each widget's normal short-press
|
||||||
should run. A button with several actions runs all of them, in
|
binding (set per-widget in that widget's own config dialog).
|
||||||
order, then the panel redraws once.</p>
|
Not scoped to any widget -- e.g. cycling through your saved
|
||||||
<p class="sub" id="button-assign-empty-hint" style="display: none;">
|
layouts.</p>
|
||||||
Add a widget on the Layout tab first -- there's nothing to assign
|
<form id="hold-config-form">
|
||||||
a button to yet.</p>
|
<label>Hold duration (seconds)
|
||||||
|
<input type="number" id="hold_duration_s" min="3" max="10"
|
||||||
<div id="button-assign-groups">
|
value="{{ (frame.hold_duration_ms // 1000) or 3 }}" required>
|
||||||
<div class="button-assign-group">
|
</label>
|
||||||
<h3 class="button-assign-label">NEXT button</h3>
|
<label>NEXT held action
|
||||||
<ul class="button-action-list" id="button-actions-next"></ul>
|
<select id="next_hold_action">
|
||||||
<div class="button-action-add">
|
<option value="" {% if not frame.next_hold_action %}selected{% endif %}>(none)</option>
|
||||||
<select id="button-add-widget-next"></select>
|
{% for action, label in global_action_labels.items() %}
|
||||||
<select id="button-add-action-next"></select>
|
<option value="{{ action }}" {% if frame.next_hold_action == action %}selected{% endif %}>{{ label }}</option>
|
||||||
<button type="button" class="secondary btn-inline" id="button-add-next">Add</button>
|
{% endfor %}
|
||||||
</div>
|
</select>
|
||||||
</div>
|
</label>
|
||||||
|
<label>BACK held action
|
||||||
<div class="button-assign-group" style="margin-top: 20px;">
|
<select id="back_hold_action">
|
||||||
<h3 class="button-assign-label">BACK button</h3>
|
<option value="" {% if not frame.back_hold_action %}selected{% endif %}>(none)</option>
|
||||||
<ul class="button-action-list" id="button-actions-back"></ul>
|
{% for action, label in global_action_labels.items() %}
|
||||||
<div class="button-action-add">
|
<option value="{{ action }}" {% if frame.back_hold_action == action %}selected{% endif %}>{{ label }}</option>
|
||||||
<select id="button-add-widget-back"></select>
|
{% endfor %}
|
||||||
<select id="button-add-action-back"></select>
|
</select>
|
||||||
<button type="button" class="secondary btn-inline" id="button-add-back">Add</button>
|
</label>
|
||||||
</div>
|
<button type="submit">Save</button>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="side-col">
|
<div class="side-col">
|
||||||
@@ -139,11 +139,12 @@
|
|||||||
|
|
||||||
<details class="card">
|
<details class="card">
|
||||||
<summary class="card-title">Advanced configuration</summary>
|
<summary class="card-title">Advanced configuration</summary>
|
||||||
<p class="sub">Color-quantization values used when dithering photos
|
<p class="sub">Color-quantization values used for every widget
|
||||||
for this panel -- approximations by default, since exact primaries
|
<strong>except photos</strong> (which has its own separate
|
||||||
aren't published. Tune them by comparing a rendered photo against
|
settings below) -- approximations by default, since exact
|
||||||
the physical panel; different panel units can vary enough to be
|
primaries aren't published. Tune them by comparing a rendered
|
||||||
worth calibrating per frame.</p>
|
widget against the physical panel; different panel units can
|
||||||
|
vary enough to be worth calibrating per frame.</p>
|
||||||
<div class="palette-table-wrap">
|
<div class="palette-table-wrap">
|
||||||
<table class="palette-table">
|
<table class="palette-table">
|
||||||
<thead><tr><th></th><th>Color</th><th>Hex</th><th>R</th><th>G</th><th>B</th></tr></thead>
|
<thead><tr><th></th><th>Color</th><th>Hex</th><th>R</th><th>G</th><th>B</th></tr></thead>
|
||||||
@@ -187,6 +188,68 @@
|
|||||||
|
|
||||||
<button type="button" class="secondary" id="palette-save" style="margin-top: 16px;">Save</button>
|
<button type="button" class="secondary" id="palette-save" style="margin-top: 16px;">Save</button>
|
||||||
<button type="button" class="secondary" id="palette-reset">Reset to defaults</button>
|
<button type="button" class="secondary" id="palette-reset">Reset to defaults</button>
|
||||||
|
<button type="button" class="secondary" id="palette-load-calibrated">Load calibrated Spectra 6 preset</button>
|
||||||
|
<p class="sub" style="margin-top: 8px;">Experimental: a community-measured
|
||||||
|
starting point (not this specific panel) -- fills the table above,
|
||||||
|
doesn't save by itself. Real Spectra 6 ink is duller than the
|
||||||
|
idealized defaults; this may or may not match your actual unit.
|
||||||
|
Compare against the physical panel before keeping it.</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="card">
|
||||||
|
<summary class="card-title">Theme</summary>
|
||||||
|
<p class="sub">A curated visual style for widgets using the
|
||||||
|
"modern" (experimental) render style -- font, corner radius,
|
||||||
|
shadow, and header accent. Widgets rendered in the classic
|
||||||
|
style are unaffected. Photos are unaffected too (see Photos
|
||||||
|
configuration below).</p>
|
||||||
|
<label>Theme
|
||||||
|
<select id="theme-select">
|
||||||
|
{% for key, t in themes.items() %}
|
||||||
|
<option value="{{ key }}" {% if frame.theme == key %}selected{% endif %}>{{ t.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="secondary" id="theme-save" style="margin-top: 16px;">Save</button>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="card">
|
||||||
|
<summary class="card-title">Photos configuration</summary>
|
||||||
|
<p class="sub">Palette and dithering used <strong>only</strong> by
|
||||||
|
the photos widget, independent of Advanced configuration above --
|
||||||
|
lets you tune the rest of this frame's widgets (e.g. a "modern"
|
||||||
|
HTML-rendered look) without changing what looks best for actual
|
||||||
|
photographs, or vice versa.</p>
|
||||||
|
<div class="palette-table-wrap">
|
||||||
|
<table class="palette-table">
|
||||||
|
<thead><tr><th></th><th>Color</th><th>Hex</th><th>R</th><th>G</th><th>B</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% set current_photo_palette = frame.photo_palette_rgb or default_palette_rgb %}
|
||||||
|
{% set current_photo_hex = palette_to_hex(current_photo_palette) %}
|
||||||
|
{% for label in palette_labels %}
|
||||||
|
<tr>
|
||||||
|
<td><span class="photo-palette-swatch-preview" data-index="{{ loop.index0 }}"
|
||||||
|
style="background: {{ current_photo_hex[loop.index0] }};"></span></td>
|
||||||
|
<td>{{ label }}</td>
|
||||||
|
<td><input type="text" class="photo-palette-hex" id="photo_palette_{{ loop.index0 }}" data-index="{{ loop.index0 }}"
|
||||||
|
value="{{ current_photo_hex[loop.index0] }}" maxlength="7" pattern="#[0-9a-fA-F]{6}"
|
||||||
|
spellcheck="false" autocomplete="off"></td>
|
||||||
|
<td><input type="number" class="photo-palette-rgb photo-palette-r" data-index="{{ loop.index0 }}"
|
||||||
|
min="0" max="255" value="{{ current_photo_palette[loop.index0][0] }}"></td>
|
||||||
|
<td><input type="number" class="photo-palette-rgb photo-palette-g" data-index="{{ loop.index0 }}"
|
||||||
|
min="0" max="255" value="{{ current_photo_palette[loop.index0][1] }}"></td>
|
||||||
|
<td><input type="number" class="photo-palette-rgb photo-palette-b" data-index="{{ loop.index0 }}"
|
||||||
|
min="0" max="255" value="{{ current_photo_palette[loop.index0][2] }}"></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<label>Dithering strength <span class="slider-value" id="photo_dither_strength_value">{{ "%.2f" | format(frame.photo_dither_strength) }}</span>
|
||||||
|
<input type="range" id="photo_dither_strength" min="0" max="1" step="0.05" value="{{ frame.photo_dither_strength }}">
|
||||||
|
</label>
|
||||||
|
<button type="button" class="secondary" id="photo-palette-save" style="margin-top: 16px;">Save</button>
|
||||||
|
<button type="button" class="secondary" id="photo-palette-reset">Reset to defaults</button>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
@@ -221,6 +284,7 @@
|
|||||||
<script>
|
<script>
|
||||||
window.FRAME_BASE_API = window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};
|
window.FRAME_BASE_API = window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};
|
||||||
window.DEFAULT_PALETTE_HEX = {{ palette_to_hex(default_palette_rgb) | tojson }};
|
window.DEFAULT_PALETTE_HEX = {{ palette_to_hex(default_palette_rgb) | tojson }};
|
||||||
|
window.CALIBRATED_SPECTRA6_HEX = {{ calibrated_spectra6_hex | tojson }};
|
||||||
window.PHOTO_WIDGET_PREVIEW_API = {{ (("/api/frames/" ~ frame.id ~ "/widgets/" ~ photo_widget_id) | tojson) if photo_widget_id else "null" }};
|
window.PHOTO_WIDGET_PREVIEW_API = {{ (("/api/frames/" ~ frame.id ~ "/widgets/" ~ photo_widget_id) | tojson) if photo_widget_id else "null" }};
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/device_status_bar.js"></script>
|
<script src="/static/device_status_bar.js"></script>
|
||||||
|
|||||||
@@ -74,6 +74,11 @@
|
|||||||
<script src="/static/widget_dialog_tasks.js"></script>
|
<script src="/static/widget_dialog_tasks.js"></script>
|
||||||
<script src="/static/widget_dialog_static.js"></script>
|
<script src="/static/widget_dialog_static.js"></script>
|
||||||
<script src="/static/widget_dialog_text.js"></script>
|
<script src="/static/widget_dialog_text.js"></script>
|
||||||
|
<script src="/static/widget_dialog_weather.js"></script>
|
||||||
|
<script src="/static/widget_dialog_battery.js"></script>
|
||||||
|
<script src="/static/widget_dialog_border.js"></script>
|
||||||
|
<script src="/static/widget_dialog_font_scale.js"></script>
|
||||||
|
<script src="/static/widget_dialog_button_actions.js"></script>
|
||||||
<script src="/static/frame_layout.js"></script>
|
<script src="/static/frame_layout.js"></script>
|
||||||
<script src="/static/saved_layouts.js"></script>
|
<script src="/static/saved_layouts.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{% macro day_section(header, weather_entries, weather_size, unit_suffix, rows, more_count, row_h, body_size, title_size, accent_start, header_h, accent_h) %}
|
||||||
|
<div class="day-section">
|
||||||
|
{#- Fixed height (not auto) -- every stacked day-section's header
|
||||||
|
must be exactly this tall regardless of whether THIS particular
|
||||||
|
day has a weather entry, or days with/without weather misalign
|
||||||
|
where their event rows start (see calendar_week_horizontal's
|
||||||
|
identical fix/reasoning). Bold-minimal: a slim accent-colored
|
||||||
|
rule (not a full gradient band) carries the theme identity --
|
||||||
|
the header text itself is plain ink, dithered at the base
|
||||||
|
amplitude like everything else, not the richer accent amplitude
|
||||||
|
the old white-on-gradient text needed to stay legible. #}
|
||||||
|
<div class="day-header" style="height: {{ header_h }}px;">
|
||||||
|
<div class="accent-rule" style="height: {{ accent_h }}px; background: {{ accent_start }};"></div>
|
||||||
|
<div class="day-header-row">
|
||||||
|
<div class="day-header-title" style="font-size: {{ title_size }}px;">{{ header }}</div>
|
||||||
|
{% if weather_entries %}
|
||||||
|
<div class="day-weather-row" style="font-size: {{ weather_size }}px;">
|
||||||
|
{% for we in weather_entries %}
|
||||||
|
<div class="day-weather-entry"><span class="day-weather-icon" style="font-size: {{ weather_size * 1.3 }}px;">{{ we.emoji }}</span><span>{{ we.high }}°/{{ we.low }}°{{ unit_suffix }}</span></div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="day-rows">
|
||||||
|
{% if not rows and not more_count %}
|
||||||
|
<div class="day-empty" style="font-size: {{ body_size }}px;">Nothing scheduled</div>
|
||||||
|
{% endif %}
|
||||||
|
{% for row in rows %}
|
||||||
|
<div class="day-row" style="height: {{ row_h }}px;">
|
||||||
|
<div class="day-chip">{% for c in row.colors %}<span style="background:{{ c }};"></span>{% endfor %}</div>
|
||||||
|
<div class="day-time" style="font-size: {{ body_size }}px;">{{ row.time }}</div>
|
||||||
|
<div class="day-summary" style="font-size: {{ body_size }}px;">{{ row.summary }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% if more_count %}<div class="day-more" style="font-size: {{ body_size }}px;">+{{ more_count }} more</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html><head><style>
|
||||||
|
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
|
||||||
|
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
|
||||||
|
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
|
||||||
|
.wrap {
|
||||||
|
width: {{ w }}px; height: {{ h }}px; padding: {{ pad }}px;
|
||||||
|
display: flex; flex-direction: column; justify-content: space-between;
|
||||||
|
}
|
||||||
|
.icon-wrap { display: flex; align-items: center; }
|
||||||
|
.icon-body {
|
||||||
|
width: {{ icon_w }}px;
|
||||||
|
height: {{ icon_h }}px;
|
||||||
|
border: {{ stroke }}px solid #000000;
|
||||||
|
border-radius: {{ icon_radius }}px;
|
||||||
|
padding: {{ stroke }}px;
|
||||||
|
}
|
||||||
|
.icon-fill {
|
||||||
|
width: {{ fill_pct }}%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: {{ fill_radius }}px;
|
||||||
|
background: linear-gradient(180deg, {{ fill_color }}, {{ fill_color_dark }});
|
||||||
|
}
|
||||||
|
.icon-nub {
|
||||||
|
width: {{ nub_w }}px;
|
||||||
|
height: {{ nub_h }}px;
|
||||||
|
background: #000000;
|
||||||
|
border-radius: 0 {{ nub_radius }}px {{ nub_radius }}px 0;
|
||||||
|
}
|
||||||
|
.pct { font-weight: 700; font-size: {{ pct_size }}px; line-height: 0.85; color: {{ fill_color }}; letter-spacing: -0.02em; }
|
||||||
|
.lines { margin-top: {{ line_gap }}px; }
|
||||||
|
.line { font-weight: 400; font-size: {{ line_size }}px; line-height: 1.3; color: #5b6674; }
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="icon-wrap">
|
||||||
|
<div class="icon-body"><div class="icon-fill"></div></div>
|
||||||
|
<div class="icon-nub"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="pct">{{ percent }}%</div>
|
||||||
|
<div class="lines">{% for line in lines %}<div class="line">{{ line }}</div>{% endfor %}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html><head><style>
|
||||||
|
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
|
||||||
|
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
|
||||||
|
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
|
||||||
|
.wrap {
|
||||||
|
width: {{ w - gutter * 2 }}px;
|
||||||
|
height: {{ h - gutter * 2 }}px;
|
||||||
|
margin: {{ gutter }}px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.day-section { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; overflow: hidden; }
|
||||||
|
.day-header { flex: 0 0 auto; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.accent-rule { width: 100%; border-radius: 100px; }
|
||||||
|
.day-header-row { flex: 1 1 auto; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding-top: 6px; min-height: 0; }
|
||||||
|
.day-header-title { font-weight: 700; color: #17233b; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.day-weather-row { display: flex; gap: 14px; flex: 0 0 auto; }
|
||||||
|
.day-weather-entry { display: flex; align-items: center; gap: 4px; color: #5b6674; }
|
||||||
|
.day-weather-icon { line-height: 1; }
|
||||||
|
.day-rows { flex: 1 1 auto; padding: 8px 0; overflow: hidden; }
|
||||||
|
.day-row { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.day-chip { display: flex; height: 12px; width: 10px; border-radius: 3px; overflow: hidden; flex: 0 0 auto; }
|
||||||
|
.day-chip span { flex: 1 1 0; }
|
||||||
|
.day-time { color: #5b6674; flex: 0 0 auto; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||||
|
.day-summary { color: #17233b; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.day-empty, .day-more { color: #5b6674; padding-top: 4px; }
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
{% import "_calendar_day_section.html.jinja" as ds %}
|
||||||
|
<div class="wrap">
|
||||||
|
{{ ds.day_section(header, weather_entries, weather_size, unit_suffix, rows, more_count, row_h, body_size, title_size, accent_start, header_h, accent_h) }}
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html><head><style>
|
||||||
|
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
|
||||||
|
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
|
||||||
|
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
|
||||||
|
.wrap {
|
||||||
|
width: {{ w - gutter * 2 }}px;
|
||||||
|
height: {{ h - gutter * 2 }}px;
|
||||||
|
margin: {{ gutter }}px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.accent-rule { flex: 0 0 auto; width: 100%; height: {{ accent_h }}px; border-radius: 100px; background: {{ accent_start }}; }
|
||||||
|
.weekday-row { display: flex; flex: 0 0 auto; margin-top: 6px; }
|
||||||
|
.weekday-cell { flex: 1 1 0; color: #17233b; font-weight: 700; font-size: {{ header_size }}px; text-align: center; padding: 4px 0; }
|
||||||
|
.weeks { flex: 1 1 auto; display: flex; flex-direction: column; margin-top: 2px; }
|
||||||
|
.week-row { flex: 1 1 0; display: flex; }
|
||||||
|
{#- A real palette color (pure black), not a pale gray -- this panel's
|
||||||
|
6-color palette has no gray to dither toward, so #e2e6ec-style
|
||||||
|
"hairlines" don't survive at all (confirmed by sampling actual
|
||||||
|
rendered pixels: every one came back pure white). Horizontal
|
||||||
|
rules only, between week rows -- enough for the grid to scan
|
||||||
|
top-to-bottom without boxing every single day cell, which read
|
||||||
|
more like the old structured-dashboard mockup than bold-minimal. #}
|
||||||
|
.week-row + .week-row { border-top: 1px solid #000000; }
|
||||||
|
.day-cell { flex: 1 1 0; padding: 4px; min-width: 0; overflow: hidden; }
|
||||||
|
{#- Bold everywhere, including out-of-month -- de-emphasis is via
|
||||||
|
smaller size only, not weight or a gray color. Regular-weight and
|
||||||
|
gray text are both individually fragile under Bayer ordered
|
||||||
|
dithering at small sizes (thin/low-contrast anti-aliased edges
|
||||||
|
have little "mass" to survive the bias+threshold step), and this
|
||||||
|
cell combined both, which degraded out-of-month day numbers into
|
||||||
|
unrecognizable speckle -- classic PIL's own de-emphasis trick
|
||||||
|
(weight instead of gray, see calendar_render._build_month's
|
||||||
|
docstring) doesn't transfer safely to this render path. #}
|
||||||
|
.day-num { font-size: {{ day_size }}px; font-weight: 700; color: #17233b; }
|
||||||
|
.day-num.out-of-month { font-size: {{ day_size * 0.8 }}px; }
|
||||||
|
.day-num.today {
|
||||||
|
display: inline-block; background: {{ accent_start }}; color: #ffffff;
|
||||||
|
border-radius: 4px; padding: 0 4px;
|
||||||
|
}
|
||||||
|
.dots { display: flex; gap: 3px; margin-top: 3px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.dot { width: {{ dot_size }}px; height: {{ dot_size }}px; border-radius: 50%; flex: 0 0 auto; }
|
||||||
|
.dot-more { font-size: {{ day_size * 0.8 }}px; color: #5b6674; }
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="accent-rule"></div>
|
||||||
|
<div class="weekday-row">
|
||||||
|
{% for name in day_names %}<div class="weekday-cell">{{ name }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="weeks">
|
||||||
|
{% for week in weeks %}
|
||||||
|
<div class="week-row">
|
||||||
|
{% for day in week %}
|
||||||
|
<div class="day-cell">
|
||||||
|
<span class="day-num {% if day.is_today %}today{% elif not day.in_month %}out-of-month{% endif %}">{{ day.day_num }}</span>
|
||||||
|
{% if day.dots %}
|
||||||
|
<div class="dots">
|
||||||
|
{% for c in day.dots %}<div class="dot" style="background:{{ c }};"></div>{% endfor %}
|
||||||
|
{% if day.more_count %}<span class="dot-more">+{{ day.more_count }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html><head><style>
|
||||||
|
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
|
||||||
|
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
|
||||||
|
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
|
||||||
|
.wrap {
|
||||||
|
width: {{ w - gutter * 2 }}px;
|
||||||
|
height: {{ h - gutter * 2 }}px;
|
||||||
|
margin: {{ gutter }}px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.day-section { display: flex; flex-direction: column; flex: 1 1 0; min-height: 0; overflow: hidden; }
|
||||||
|
{#- No divider line -- #e2e6ec was invisible on this panel's 6-color
|
||||||
|
palette anyway (no gray to dither toward, confirmed by sampling
|
||||||
|
rendered pixels), and the accent rule + margin at the top of the
|
||||||
|
next section already reads as a clear boundary without one. #}
|
||||||
|
.day-section + .day-section { margin-top: 8px; }
|
||||||
|
.day-header { flex: 0 0 auto; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.accent-rule { width: 100%; border-radius: 100px; }
|
||||||
|
.day-header-row { flex: 1 1 auto; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding-top: 4px; min-height: 0; }
|
||||||
|
.day-header-title { font-weight: 700; color: #17233b; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.day-weather-row { display: flex; gap: 12px; flex: 0 0 auto; }
|
||||||
|
.day-weather-entry { display: flex; align-items: center; gap: 4px; color: #5b6674; }
|
||||||
|
.day-weather-icon { line-height: 1; }
|
||||||
|
.day-rows { flex: 1 1 auto; padding: 4px 0; overflow: hidden; }
|
||||||
|
.day-row { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.day-chip { display: flex; height: 12px; width: 10px; border-radius: 3px; overflow: hidden; flex: 0 0 auto; }
|
||||||
|
.day-chip span { flex: 1 1 0; }
|
||||||
|
.day-time { color: #5b6674; flex: 0 0 auto; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||||
|
.day-summary { color: #17233b; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.day-empty, .day-more { color: #5b6674; padding-top: 2px; }
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
{% import "_calendar_day_section.html.jinja" as ds %}
|
||||||
|
<div class="wrap">
|
||||||
|
{% for day in days %}
|
||||||
|
{{ ds.day_section(day.header, day.weather_entries, weather_size, unit_suffix, day.rows, day.more_count, row_h, body_size, title_size, accent_start, header_h, accent_h) }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html><head><style>
|
||||||
|
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
|
||||||
|
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
|
||||||
|
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
|
||||||
|
.wrap {
|
||||||
|
width: {{ w - gutter * 2 }}px;
|
||||||
|
height: {{ h - gutter * 2 }}px;
|
||||||
|
margin: {{ gutter }}px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.accent-rule { flex: 0 0 auto; width: 100%; height: {{ accent_h }}px; border-radius: 100px; background: {{ accent_start }}; }
|
||||||
|
.cols { flex: 1 1 auto; display: flex; margin-top: 6px; min-height: 0; }
|
||||||
|
.col { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; }
|
||||||
|
{#- No divider line -- #e2e6ec was invisible on this panel's 6-color
|
||||||
|
palette anyway (no gray to dither toward, confirmed by sampling
|
||||||
|
rendered pixels); the shared accent rule above already frames the
|
||||||
|
whole week as one unit, and each column's own label anchors it. #}
|
||||||
|
.col + .col { margin-left: 4px; }
|
||||||
|
.col-header {
|
||||||
|
{#- Fixed height (not auto) -- every column must be exactly this tall
|
||||||
|
regardless of whether THIS particular day has a weather entry, or
|
||||||
|
columns with/without weather misalign their event rows to
|
||||||
|
different starting Y positions across the week grid. #}
|
||||||
|
height: {{ header_h }}px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 2px 6px 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.col-header .label {
|
||||||
|
font-weight: 700; color: #17233b; font-size: {{ header_size }}px;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.col-weather { display: flex; align-items: center; gap: 3px; color: #5b6674; font-size: {{ weather_size }}px; margin-top: 2px; }
|
||||||
|
.col-rows { flex: 1 1 auto; padding: 4px; overflow: hidden; }
|
||||||
|
.col-row { display: flex; align-items: center; gap: 4px; min-width: 0; }
|
||||||
|
.col-chip { width: 7px; height: 7px; border-radius: 2px; flex: 0 0 auto; }
|
||||||
|
.col-summary {
|
||||||
|
flex: 1 1 0; min-width: 0;
|
||||||
|
font-size: {{ chip_size }}px; color: #17233b; line-height: 1.3;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.col-more { font-size: {{ chip_size }}px; color: #5b6674; }
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="accent-rule"></div>
|
||||||
|
<div class="cols">
|
||||||
|
{% for col in cols %}
|
||||||
|
<div class="col">
|
||||||
|
<div class="col-header">
|
||||||
|
<div class="label">{{ col.label }}</div>
|
||||||
|
{% if col.weather %}<div class="col-weather"><span>{{ col.weather.emoji }}</span><span>{{ col.weather.high }}°/{{ col.weather.low }}°{{ unit_suffix }}</span></div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-rows">
|
||||||
|
{% for row in col.rows %}
|
||||||
|
<div class="col-row"><div class="col-chip" style="background:{{ row.color }};"></div><div class="col-summary">{{ row.summary }}</div></div>
|
||||||
|
{% endfor %}
|
||||||
|
{% if col.more_count %}<div class="col-more">+{{ col.more_count }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html><head><style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
|
||||||
|
.card {
|
||||||
|
width: {{ w - gutter * 2 }}px;
|
||||||
|
height: {{ h - gutter * 2 }}px;
|
||||||
|
margin: {{ gutter }}px;
|
||||||
|
border-radius: {{ radius }}px;
|
||||||
|
overflow: hidden;
|
||||||
|
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.25);{% endif %}
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
.card img {
|
||||||
|
width: 100%; height: 100%; display: block;
|
||||||
|
/* "contain", not "cover" -- the source image already went through
|
||||||
|
compose_into's own crop/fit (e.g. whiteboard's deliberate
|
||||||
|
letterbox-never-crop mode), so this card must not re-crop it;
|
||||||
|
the gutter inset is small relative to typical widget sizes, so
|
||||||
|
"contain" leaves at most a sliver of background visible, not a
|
||||||
|
real letterbox. */
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<div class="card"><img src="data:image/png;base64,{{ image_b64 }}"></div>
|
||||||
|
</body></html>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user