Compare commits
13
Commits
v1.4.0
...
05b417a29b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05b417a29b | ||
|
|
a48c84ed4a | ||
|
|
d1f1968317 | ||
|
|
83994aab7b | ||
|
|
5866c2f040 | ||
|
|
dd038f8e46 | ||
|
|
3fdda096a9 | ||
|
|
575b3cfa61 | ||
|
|
aa4a382c1b | ||
|
|
684225422c | ||
|
|
08960c9eec | ||
|
|
f0c21af220 | ||
|
|
8602ee3add |
@@ -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)"
|
||||
@@ -11,6 +11,7 @@ mkdir -p "$SCRATCH"
|
||||
|
||||
DATABASE_URL="sqlite:///$SCRATCH/test.db" \
|
||||
CONFIG_PATH="$SCRATCH/config.json" \
|
||||
LOG_PATH="$SCRATCH/app.log" \
|
||||
.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port "$PORT" \
|
||||
> "$SCRATCH/server.log" 2>&1 &
|
||||
PID=$!
|
||||
|
||||
@@ -7,13 +7,9 @@ image processing (crop/dither/quantize/pack), and serves a placeable
|
||||
photos/calendar/whiteboard/weather widget system to the device.
|
||||
|
||||
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)
|
||||
-battery life widget
|
||||
-sharing layouts with linked users
|
||||
-a "coming up this week" widget
|
||||
-scan to download for non-immich photos too?
|
||||
-switch button reset action? and on reset dismiss the menu.
|
||||
-on reset dismiss the menu.
|
||||
|
||||
Start here, don't re-derive from scratch:
|
||||
- [`docs/architecture.md`](docs/architecture.md) -- how firmware and
|
||||
|
||||
@@ -102,9 +102,9 @@ placement grid, and button-action dispatch.
|
||||
- Deep sleep for the server-configured interval on success, or a
|
||||
shorter retry interval on any failure.
|
||||
|
||||
The menu/reset button's soft-reset and factory-reset tiers (held ~3s
|
||||
or ~15s) are handled earlier, before any of this, and never return --
|
||||
see [`firmware/README.md`](../firmware/README.md#managing-the-queue-soft-resetting-and-factory-resetting).
|
||||
The menu/reset button's soft-reset (quick press) and factory-reset
|
||||
(held ~15s) tiers are handled earlier, before any of this, and never
|
||||
return -- see [`firmware/README.md`](../firmware/README.md#managing-the-queue-soft-resetting-and-factory-resetting).
|
||||
|
||||
See [`docs/hardware.md`](hardware.md) for wiring and
|
||||
[`server/README.md`](../server/README.md) for the server side.
|
||||
|
||||
+4
-4
@@ -40,10 +40,10 @@ pressed):
|
||||
photo; see
|
||||
[`firmware/README.md`](../firmware/README.md#going-back-to-the-previous-photo).
|
||||
- **Menu / reset (GPIO1)**: one button, three actions by hold duration --
|
||||
a quick press overlays a "scan to manage" QR code on the current photo
|
||||
for 30 seconds; holding ~3s then releasing soft-resets the device
|
||||
(config kept); holding ~15s factory-resets it (clears WiFi/server
|
||||
config, reprovisions); see
|
||||
a quick press soft-resets the device (config kept); holding ~3s then
|
||||
releasing overlays a "scan to manage" QR code on the current photo for
|
||||
30 seconds; holding ~15s factory-resets it (clears WiFi/server config,
|
||||
reprovisions); see
|
||||
[`firmware/README.md`](../firmware/README.md#managing-the-queue-soft-resetting-and-factory-resetting).
|
||||
|
||||
All three pins were picked because they're within GPIO 0-7 -- the only
|
||||
|
||||
+12
-11
@@ -66,7 +66,7 @@ Under **ESPresso Frame Configuration**:
|
||||
| `FRAME_NEXT_BUTTON_GPIO` | 2 | Next-photo button GPIO (-1 to disable). Must be 0-7 (ESP32-C6's deep-sleep-wakeup-capable pins) |
|
||||
| `FRAME_BACK_BUTTON_GPIO` | 0 | Back-photo button GPIO (-1 to disable). Must be 0-7 |
|
||||
| `FRAME_COMBO_BUTTON_GPIO` | 1 | Menu/reset button GPIO (-1 to disable). Must be 0-7 |
|
||||
| `FRAME_COMBO_SOFT_RESET_HOLD_MS` | 3000 | How long the combo button must be held (then released) to soft-reset |
|
||||
| `FRAME_COMBO_MENU_HOLD_MS` | 3000 | How long the combo button must be held (then released) to show the management menu |
|
||||
| `FRAME_COMBO_FACTORY_RESET_HOLD_MS` | 15000 | How long the combo button must be held to factory-reset |
|
||||
| `FRAME_HOLD_ACTION_MS` | 3000 | **Fallback only** -- how long NEXT/BACK must be held to trigger a global action instead of a short press; see below |
|
||||
| `FRAME_BATTERY_ADC_GPIO` | -1 (disabled) | Battery voltage-divider ADC GPIO; see the Battery section below |
|
||||
@@ -294,9 +294,13 @@ One more button, wired between GPIO1 and GND (same wiring style as the
|
||||
other buttons), covers three actions -- disambiguated purely by how
|
||||
long it's held:
|
||||
|
||||
**A quick press** wakes the device and overlays several corners of
|
||||
whatever photo is currently showing, leaving the middle of the photo
|
||||
visible and unchanged:
|
||||
**A quick press** soft-resets the device -- `esp_restart()`, keeping the
|
||||
stored WiFi/server config. Useful for recovering a hung device without
|
||||
losing setup.
|
||||
|
||||
**Holding it ~3 seconds, then releasing** wakes the device (if asleep)
|
||||
and overlays several corners of whatever photo is currently showing,
|
||||
leaving the middle of the photo visible and unchanged:
|
||||
|
||||
- **Top-right**: a QR code -- "SCAN TO MANAGE" -- linking to the
|
||||
server's config page.
|
||||
@@ -315,8 +319,9 @@ for faces Immich hasn't been told a name for; no face detection happens
|
||||
on the device or the server, this is entirely Immich's own People
|
||||
feature). A third press exits immediately rather than waiting out the
|
||||
30-second timer. Holding the button during this stage doesn't trigger
|
||||
either reset tier below -- the hold-duration read only ever happens
|
||||
once, right when the device first wakes, before any menu is shown.
|
||||
the factory-reset tier below -- the hold-duration read only ever
|
||||
happens once, right when the device first wakes, before any menu is
|
||||
shown.
|
||||
|
||||
The device stays awake for the whole menu interaction (up to three
|
||||
physical refreshes: the base overlay, the escalated one, and
|
||||
@@ -324,17 +329,13 @@ reverting), so this costs meaningfully more power than a normal wake --
|
||||
expected for a deliberate, occasional action, same tradeoff as the
|
||||
other buttons.
|
||||
|
||||
**Holding it ~3 seconds, then releasing** soft-resets the device --
|
||||
`esp_restart()`, keeping the stored WiFi/server config. Useful for
|
||||
recovering a hung device without losing setup.
|
||||
|
||||
**Holding it ~15 seconds** (whether or not you're still holding it --
|
||||
this fires immediately, it doesn't wait for release) clears the stored
|
||||
WiFi/server config and restarts into provisioning. From either power-on
|
||||
or while the device is deep-asleep, since this GPIO is armed as a
|
||||
wakeup source.
|
||||
|
||||
See `FRAME_COMBO_BUTTON_GPIO`, `FRAME_COMBO_SOFT_RESET_HOLD_MS`, and
|
||||
See `FRAME_COMBO_BUTTON_GPIO`, `FRAME_COMBO_MENU_HOLD_MS`, and
|
||||
`FRAME_COMBO_FACTORY_RESET_HOLD_MS` above to change the pin or hold
|
||||
durations, or disable all three actions.
|
||||
|
||||
|
||||
@@ -154,13 +154,12 @@ menu "ESPresso Frame Configuration"
|
||||
Button wired between this GPIO and GND (active-low, internal
|
||||
pull-up enabled in firmware -- no external resistor needed).
|
||||
One pin, three actions depending on how long it's held:
|
||||
a quick press shows the management menu (same as before);
|
||||
holding it FRAME_COMBO_SOFT_RESET_HOLD_MS then releasing
|
||||
soft-resets the device (reboots, keeps the stored WiFi/
|
||||
server config); holding it all the way to
|
||||
FRAME_COMBO_FACTORY_RESET_HOLD_MS clears the stored config
|
||||
and restarts into provisioning, regardless of whether it's
|
||||
released yet. Must be GPIO 0-7 for the same deep-sleep-
|
||||
a quick press soft-resets the device (reboots, keeps the
|
||||
stored WiFi/server config); holding it FRAME_COMBO_MENU_HOLD_MS
|
||||
then releasing shows the management menu; holding it all the
|
||||
way to FRAME_COMBO_FACTORY_RESET_HOLD_MS clears the stored
|
||||
config and restarts into provisioning, regardless of whether
|
||||
it's released yet. Must be GPIO 0-7 for the same deep-sleep-
|
||||
wakeup reason as FRAME_NEXT_BUTTON_GPIO above; defaults to
|
||||
a different pin than the other buttons. Set to -1 to
|
||||
disable the feature entirely (also disables the management
|
||||
@@ -168,14 +167,14 @@ menu "ESPresso Frame Configuration"
|
||||
reconfiguring then only works by erasing NVS over USB, see
|
||||
firmware/README.md).
|
||||
|
||||
config FRAME_COMBO_SOFT_RESET_HOLD_MS
|
||||
int "Soft-reset hold duration (ms)"
|
||||
config FRAME_COMBO_MENU_HOLD_MS
|
||||
int "Management-menu hold duration (ms)"
|
||||
default 3000
|
||||
depends on FRAME_COMBO_BUTTON_GPIO >= 0
|
||||
help
|
||||
How long the menu/reset button must be held before releasing
|
||||
it triggers a soft reset (reboot, config kept). Long enough
|
||||
to be clearly distinct from a quick menu-opening press.
|
||||
it shows the management menu instead of soft-resetting. Long
|
||||
enough to be clearly distinct from a quick reset tap.
|
||||
|
||||
config FRAME_COMBO_FACTORY_RESET_HOLD_MS
|
||||
int "Factory-reset hold duration (ms)"
|
||||
@@ -185,8 +184,8 @@ menu "ESPresso Frame Configuration"
|
||||
How long the menu/reset button must be held continuously
|
||||
before the device clears its stored config and reboots into
|
||||
provisioning, regardless of release. Comfortably longer than
|
||||
FRAME_COMBO_SOFT_RESET_HOLD_MS so the two tiers can't be
|
||||
confused for each other.
|
||||
FRAME_COMBO_MENU_HOLD_MS so the two tiers can't be confused
|
||||
for each other.
|
||||
|
||||
config FRAME_HOLD_ACTION_MS
|
||||
int "Next/back hold-for-global-action duration (ms)"
|
||||
|
||||
@@ -53,8 +53,8 @@ bool combo_button_check(void)
|
||||
return false; /* not pressed, and didn't cause this wake either */
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Combo button held -- quick press for menu, %dms for soft reset, %dms for factory reset",
|
||||
CONFIG_FRAME_COMBO_SOFT_RESET_HOLD_MS, CONFIG_FRAME_COMBO_FACTORY_RESET_HOLD_MS);
|
||||
ESP_LOGI(TAG, "Combo button held -- quick press for soft reset, %dms for menu, %dms for factory reset",
|
||||
CONFIG_FRAME_COMBO_MENU_HOLD_MS, CONFIG_FRAME_COMBO_FACTORY_RESET_HOLD_MS);
|
||||
|
||||
int elapsed_ms = 0;
|
||||
while (gpio_get_level(COMBO_BUTTON_GPIO) == 0) {
|
||||
@@ -70,13 +70,13 @@ bool combo_button_check(void)
|
||||
}
|
||||
}
|
||||
|
||||
if (elapsed_ms >= CONFIG_FRAME_COMBO_SOFT_RESET_HOLD_MS) {
|
||||
ESP_LOGW(TAG, "Held %dms and released, soft-restarting (config kept)", elapsed_ms);
|
||||
esp_restart();
|
||||
if (elapsed_ms >= CONFIG_FRAME_COMBO_MENU_HOLD_MS) {
|
||||
ESP_LOGI(TAG, "Held %dms and released, showing management menu", elapsed_ms);
|
||||
return true;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Quick press (%dms), showing management menu", elapsed_ms);
|
||||
return true;
|
||||
ESP_LOGW(TAG, "Quick press (%dms), soft-restarting (config kept)", elapsed_ms);
|
||||
esp_restart();
|
||||
}
|
||||
|
||||
bool combo_button_is_pressed(void)
|
||||
|
||||
@@ -16,11 +16,11 @@ void combo_button_init(void);
|
||||
* Checks the combined menu/reset button and acts on how long it was
|
||||
* held, evaluated once per wake:
|
||||
* - Not pressed: returns false immediately.
|
||||
* - Released before CONFIG_FRAME_COMBO_SOFT_RESET_HOLD_MS (a quick
|
||||
* press): returns true -- caller should show the management menu.
|
||||
* - Released between the soft-reset and factory-reset thresholds: a
|
||||
* soft reset (esp_restart(), stored WiFi/server config kept) --
|
||||
* - Released before CONFIG_FRAME_COMBO_MENU_HOLD_MS (a quick press):
|
||||
* a soft reset (esp_restart(), stored WiFi/server config kept) --
|
||||
* never returns.
|
||||
* - Released between the menu and factory-reset thresholds: returns
|
||||
* true -- caller should show the management menu.
|
||||
* - Held through CONFIG_FRAME_COMBO_FACTORY_RESET_HOLD_MS: a factory
|
||||
* reset (frame_config_clear() + esp_restart(), fires immediately
|
||||
* without waiting for release) -- never returns.
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.4.0
|
||||
1.4.1
|
||||
|
||||
+9
-1
@@ -91,11 +91,19 @@ algorithm itself -- it just streams the response straight to the panel.
|
||||
again until a recharge is detected and it crosses again. No SMTP
|
||||
configured, or no email on the relevant account, and both features
|
||||
silently no-op rather than erroring.
|
||||
- **Server logs.** `/admin/logs` shows the tail of the process's own
|
||||
log file (`LOG_PATH` env var, default `/data/server.log` -- the same
|
||||
`/data` volume as the database and legacy config, so it survives
|
||||
container restarts/redeploys; `LOG_LEVEL` env var, default `INFO`).
|
||||
Rotates at ~2MB x 3 backups; the page only reads the current file,
|
||||
"Download full log" streams it raw. There's no log shipping/
|
||||
aggregation beyond this -- it's a single-container deployment, so
|
||||
the file *is* the log.
|
||||
|
||||
## Endpoints
|
||||
|
||||
Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
||||
`/admin`, `/frames/{id}` (Photos), `/frames/{id}/config`,
|
||||
`/admin`, `/admin/logs`, `/frames/{id}` (Photos), `/frames/{id}/config`,
|
||||
`/frames/{id}/stats`, `/m/{manage_token}`.
|
||||
|
||||
### Device protocol (`/frame/*` -- paths frozen; auth = `?id=` + `?token=`)
|
||||
|
||||
+213
-47
@@ -5,13 +5,14 @@ from __future__ import annotations
|
||||
import io
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageEnhance, ImageFont, ImageOps
|
||||
|
||||
EPD_WIDTH = 800
|
||||
EPD_HEIGHT = 480
|
||||
|
||||
# PIL's TrueType rendering antialiases by default (graduated gray edge
|
||||
# pixels). Those survive straight into _quantize's Floyd-Steinberg
|
||||
# pixels). Those survive straight into _quantize's error-diffusion
|
||||
# dithering, which -- confirmed visually -- turns them into scattered
|
||||
# colored speckles along every glyph edge once forced onto the panel's 6
|
||||
# colors, since a mid-gray input has no close palette match and the
|
||||
@@ -148,20 +149,24 @@ def logical_to_native(x: float, y: float, orientation: str) -> tuple[int, int]:
|
||||
return int(logical_h - 1 - y), int(x)
|
||||
return int(x), int(y)
|
||||
|
||||
# Approximate sRGB for each of the panel's 6 ink colors -- reasonable
|
||||
# placeholders, not measured values (Waveshare doesn't publish exact
|
||||
# color primaries for this panel). This is the fallback for any frame
|
||||
# that hasn't tuned its own (Frame.palette_rgb, set from a frame's
|
||||
# Configuration tab -- "Advanced configuration" -- once you can compare
|
||||
# a rendered test image against the real panel; different panel units
|
||||
# can vary enough to be worth calibrating per frame).
|
||||
# Measured sRGB appearance of each of the panel's 6 ink colors on an
|
||||
# actual Spectra 6 panel -- sourced from epdoptimize's "spectra6" palette
|
||||
# (github.com/paperlesspaper/epdoptimize, src/dither/data/default-palettes
|
||||
# .json), not our own calibration, but a much better starting point than a
|
||||
# guess: e-ink ink never reaches full sRGB saturation/contrast, so this is
|
||||
# uniformly darker and more muted than the naive (0,0,0)/(255,255,255)/pure
|
||||
# hues this used to be. This is the fallback for any frame that hasn't
|
||||
# tuned its own (Frame.palette_rgb, set from a frame's Configuration tab
|
||||
# -- "Advanced configuration" -- once you can compare a rendered test
|
||||
# image against the real panel; different panel units can vary enough to
|
||||
# be worth calibrating per frame).
|
||||
DEFAULT_PALETTE_RGB = [
|
||||
(0, 0, 0), # BLACK
|
||||
(255, 255, 255), # WHITE
|
||||
(255, 219, 0), # YELLOW
|
||||
(207, 0, 15), # RED
|
||||
(0, 39, 133), # BLUE
|
||||
(0, 133, 55), # GREEN
|
||||
(31, 34, 38), # BLACK
|
||||
(185, 199, 201), # WHITE
|
||||
(193, 187, 30), # YELLOW
|
||||
(98, 32, 30), # RED
|
||||
(35, 63, 142), # BLUE
|
||||
(53, 86, 58), # GREEN
|
||||
]
|
||||
|
||||
PALETTE_LABELS = ["Black", "White", "Yellow", "Red", "Blue", "Green"]
|
||||
@@ -222,10 +227,137 @@ def hex_to_rgb(hex_str: str) -> tuple[int, int, int] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _build_palette_image(palette_rgb: list) -> Image.Image:
|
||||
pal_img = Image.new("P", (1, 1))
|
||||
pal_img.putpalette([channel for rgb in palette_rgb for channel in rgb])
|
||||
return pal_img
|
||||
def _rgb_to_oklab(rgb: "np.ndarray") -> "np.ndarray":
|
||||
"""(...,3) uint8/float sRGB -> (...,3) float32 OKLab (Bjorn Ottosson's
|
||||
formulation, https://bottosson.github.io/posts/oklab/). Used instead
|
||||
of raw RGB distance for palette matching/error diffusion below --
|
||||
Euclidean distance in OKLab tracks perceived color difference far
|
||||
better than in RGB, which matters a lot once the "colors" being
|
||||
matched against are a 6-entry palette this coarse."""
|
||||
linear = (rgb.astype(np.float32) / 255.0)
|
||||
linear = np.where(linear <= 0.04045, linear / 12.92, ((linear + 0.055) / 1.055) ** 2.4)
|
||||
r, g, b = linear[..., 0], linear[..., 1], linear[..., 2]
|
||||
|
||||
l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
|
||||
m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
|
||||
s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
|
||||
l_, m_, s_ = np.cbrt(l), np.cbrt(m), np.cbrt(s)
|
||||
|
||||
L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_
|
||||
a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_
|
||||
b2 = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_
|
||||
return np.stack([L, a, b2], axis=-1)
|
||||
|
||||
|
||||
# Lightness is weighted down relative to a/b when *choosing* the nearest
|
||||
# palette entry (established color-difference formulas -- CIE94, CMC --
|
||||
# do the same, on the general principle that a lightness mismatch reads
|
||||
# as less objectionable than a hue mismatch). Not optional polish: this
|
||||
# palette's ink colors are far darker/lighter than their sRGB namesakes
|
||||
# (e.g. "red" ink is a dark #62201E, "yellow" ink is a bright #C1BB1E),
|
||||
# so unweighted OKLab distance lets that lightness gap dominate and pure
|
||||
# saturated red (high L) ends up nearer "yellow" (L=0.77) than "red"
|
||||
# (L=0.35) even though red is unambiguously closer in hue/chroma (a/b) --
|
||||
# confirmed both analytically and by DEFAULT_PALETTE_RGB's own test
|
||||
# coverage (test_render_size_invariants.py's pure-red/pure-blue check).
|
||||
_LIGHTNESS_MATCH_WEIGHT = 0.5
|
||||
|
||||
|
||||
def _nearest_palette_indices(oklab_pixels: "np.ndarray", palette_oklab: "np.ndarray") -> "np.ndarray":
|
||||
"""(H,W,3) OKLab pixels, (K,3) OKLab palette -> (H,W) index array, no
|
||||
error diffusion -- the "flat"/undithered quantization, vectorized
|
||||
(K is always 6, so brute-force all-pairs distance is cheap and this
|
||||
stays a single numpy call rather than a per-pixel Python loop)."""
|
||||
diffs2 = (oklab_pixels[:, :, None, :] - palette_oklab[None, None, :, :]) ** 2
|
||||
weights = np.array([_LIGHTNESS_MATCH_WEIGHT, 1.0, 1.0], dtype=np.float32)
|
||||
dist2 = (diffs2 * weights).sum(axis=-1)
|
||||
return np.argmin(dist2, axis=2)
|
||||
|
||||
|
||||
def _bayer_matrix(n: int) -> "np.ndarray":
|
||||
"""Recursive construction of the standard n x n (n a power of 2)
|
||||
Bayer ordered-dithering threshold matrix, values 0..n*n-1, each used
|
||||
exactly once -- the classic recursive doubling
|
||||
(https://en.wikipedia.org/wiki/Ordered_dithering)."""
|
||||
if n == 1:
|
||||
return np.zeros((1, 1))
|
||||
smaller = _bayer_matrix(n // 2)
|
||||
return np.block([
|
||||
[4 * smaller, 4 * smaller + 2],
|
||||
[4 * smaller + 3, 4 * smaller + 1],
|
||||
])
|
||||
|
||||
|
||||
# Normalized to [0, 1): a deterministic per-pixel threshold tiled across
|
||||
# the image, used (like classic ordered/Bayer dithering) to decide, for
|
||||
# each pixel, whether it plots as its nearest or second-nearest palette
|
||||
# color -- see _ordered_dither_oklab.
|
||||
_BAYER_8 = (_bayer_matrix(8) + 0.5) / 64.0
|
||||
|
||||
|
||||
def _ordered_dither_oklab(oklab_pixels: "np.ndarray", palette_oklab: "np.ndarray") -> "np.ndarray":
|
||||
"""(H,W,3) OKLab pixels, (K,3) OKLab palette -> (H,W) uint8 index
|
||||
array, ordered (Bayer matrix) dithering -- picked over error
|
||||
diffusion (Floyd-Steinberg/Atkinson/etc.) specifically because it's
|
||||
fully vectorizable: no pixel-to-pixel dependency to chain through a
|
||||
Python loop, just a fixed number of numpy calls over the whole
|
||||
image. A straight per-pixel error-diffusion loop in Python was
|
||||
measured at ~1s for a full 800x480 panel -- see
|
||||
test_widgets_render_concurrently's latency budget (the whole reason
|
||||
widgets render concurrently in the first place, see git history) --
|
||||
which this avoids entirely.
|
||||
|
||||
Finds each pixel's true nearest and second-nearest palette color and
|
||||
mixes between exactly those two, using the Bayer threshold as the
|
||||
per-pixel coin flip -- the standard generalization of ordered
|
||||
dithering to a palette whose entries aren't evenly spaced (unlike,
|
||||
say, dithering 0-255 gray down to a handful of even steps). The
|
||||
mixing fraction is the pixel's projection onto the segment from its
|
||||
nearest color to its second-nearest, NOT distance-to-nearest over
|
||||
total distance (d0/(d0+d1)) -- an earlier version used that ratio
|
||||
and it's wrong whenever the second-nearest color is simply far away
|
||||
in an unrelated direction rather than genuinely "on the other side"
|
||||
of the pixel: d1 being large made the ratio look small-mixing-needed
|
||||
only when d0 was *also* comparably large, so a pixel sitting almost
|
||||
exactly on its nearest color still got a large fraction of an
|
||||
unrelated second color -- confirmed visually as entire regions
|
||||
(e.g. a pale sky, clearly nearest White) rendering as flat blocks of
|
||||
a wrong, unrelated color (Yellow) instead of White. Projection onto
|
||||
the actual nearest-neighbor segment doesn't have that failure mode:
|
||||
a pixel essentially at c0 projects to ~0 regardless of where c1 is."""
|
||||
weights = np.array([_LIGHTNESS_MATCH_WEIGHT, 1.0, 1.0], dtype=np.float32)
|
||||
scale = np.sqrt(weights)
|
||||
pixels_w = oklab_pixels * scale
|
||||
palette_w = palette_oklab * scale
|
||||
|
||||
dist2 = ((pixels_w[:, :, None, :] - palette_w[None, None, :, :]) ** 2).sum(axis=-1) # (H, W, K)
|
||||
order = np.argsort(dist2, axis=-1)
|
||||
idx0, idx1 = order[..., 0], order[..., 1]
|
||||
|
||||
c0 = palette_w[idx0] # (H, W, 3)
|
||||
c1 = palette_w[idx1] # (H, W, 3)
|
||||
segment = c1 - c0
|
||||
to_pixel = pixels_w - c0
|
||||
segment_len2 = (segment * segment).sum(axis=-1)
|
||||
t = np.divide((to_pixel * segment).sum(axis=-1), segment_len2,
|
||||
out=np.zeros_like(segment_len2), where=segment_len2 > 1e-12)
|
||||
t = np.clip(t, 0.0, 1.0)
|
||||
|
||||
h, w, _ = oklab_pixels.shape
|
||||
threshold = np.tile(_BAYER_8, (h // 8 + 1, w // 8 + 1))[:h, :w]
|
||||
use_second = threshold < t
|
||||
return np.where(use_second, idx1, idx0).astype(np.uint8)
|
||||
|
||||
|
||||
def _index_array_to_p_image(idx_array: "np.ndarray", palette_rgb: list) -> Image.Image:
|
||||
"""(H,W) palette-index array -> a PIL "P"-mode image carrying
|
||||
`palette_rgb` as its palette, so downstream code (as_png's
|
||||
.convert("RGB"), _transpose_and_pack's pixels[x, y] index lookups)
|
||||
behaves exactly as it did with PIL's own quantize()."""
|
||||
img = Image.fromarray(idx_array, mode="P")
|
||||
padded = list(palette_rgb) + [(0, 0, 0)] * (256 - len(palette_rgb))
|
||||
img.putpalette([channel for rgb in padded for channel in rgb])
|
||||
return img
|
||||
|
||||
|
||||
def _plain_center_crop_box(
|
||||
@@ -403,21 +535,39 @@ def _enhance(img: Image.Image, color_boost: float, contrast_boost: float) -> Ima
|
||||
|
||||
def _quantize(img: Image.Image, palette_rgb: list | None, dither_strength: float) -> Image.Image:
|
||||
"""RGB -> palette-quantized P-mode image, same size/orientation as
|
||||
`img` (no rotation here). dither_strength blends `img` toward its own
|
||||
flat (undithered) quantization before running Floyd-Steinberg on the
|
||||
blend: at 0 there's zero quantization error left to diffuse (so the
|
||||
result IS the flat quantization, no dithering texture at all); at 1
|
||||
it's `img` unchanged (full-strength dithering, this project's
|
||||
original always-on behavior); values between give a smooth continuum
|
||||
of dithering intensity rather than an on/off toggle."""
|
||||
palette_image = _build_palette_image(palette_rgb or DEFAULT_PALETTE_RGB)
|
||||
if dither_strength >= 1.0:
|
||||
return img.quantize(palette=palette_image, dither=Image.Dither.FLOYDSTEINBERG)
|
||||
`img` (no rotation here). Matches against the palette in OKLab space
|
||||
(perceptual distance, not raw RGB -- see _rgb_to_oklab/
|
||||
_nearest_palette_indices) and, when dithering, jitters that match
|
||||
with a Bayer ordered-dither pattern rather than Floyd-Steinberg error
|
||||
diffusion -- see _ordered_dither_oklab for why (short version: error
|
||||
diffusion is inherently a serial per-pixel loop, and doing that in
|
||||
Python for a full 800x480 panel blew well past this project's
|
||||
render-latency budget). dither_strength blends `img` toward its own
|
||||
flat (undithered) quantization before dithering the blend: at 0 the
|
||||
blend IS the flat quantization (nothing left for the jitter to push
|
||||
across a color boundary, so no dithering texture at all); at 1 it's
|
||||
`img` unchanged (full-strength dithering, this project's original
|
||||
always-on behavior); values between give a smooth continuum of
|
||||
dithering intensity rather than an on/off toggle."""
|
||||
palette_rgb = palette_rgb or DEFAULT_PALETTE_RGB
|
||||
palette_oklab = _rgb_to_oklab(np.asarray(palette_rgb, dtype=np.float32))
|
||||
|
||||
rgb_array = np.asarray(img.convert("RGB"))
|
||||
oklab_pixels = _rgb_to_oklab(rgb_array)
|
||||
|
||||
if dither_strength <= 0.0:
|
||||
return img.quantize(palette=palette_image, dither=Image.Dither.NONE)
|
||||
flat = img.quantize(palette=palette_image, dither=Image.Dither.NONE).convert("RGB")
|
||||
blended = Image.blend(flat, img, dither_strength)
|
||||
return blended.quantize(palette=palette_image, dither=Image.Dither.FLOYDSTEINBERG)
|
||||
flat_idx = _nearest_palette_indices(oklab_pixels, palette_oklab)
|
||||
return _index_array_to_p_image(flat_idx.astype(np.uint8), palette_rgb)
|
||||
|
||||
if dither_strength < 1.0:
|
||||
flat_idx = _nearest_palette_indices(oklab_pixels, palette_oklab)
|
||||
palette_arr = np.asarray(palette_rgb, dtype=np.uint8)
|
||||
flat_rgb = Image.fromarray(palette_arr[flat_idx], mode="RGB")
|
||||
blended = Image.blend(flat_rgb, img.convert("RGB"), dither_strength)
|
||||
oklab_pixels = _rgb_to_oklab(np.asarray(blended))
|
||||
|
||||
dithered_idx = _ordered_dither_oklab(oklab_pixels, palette_oklab)
|
||||
return _index_array_to_p_image(dithered_idx, palette_rgb)
|
||||
|
||||
|
||||
def _transpose_and_pack(quantized: Image.Image, orientation: str) -> bytes:
|
||||
@@ -496,9 +646,16 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
|
||||
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",
|
||||
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
|
||||
(paste, enhance once, overlay once, quantize once, pack once) from
|
||||
"compose one photo" to "paste N already-rendered regions, then run
|
||||
@@ -530,7 +687,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)
|
||||
orientation instead of packed native-panel bytes, same convention as
|
||||
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)
|
||||
canvas = Image.new("RGB", (logical_w, logical_h), LETTERBOX_BG)
|
||||
for (x, y, w, h), region_img in regions:
|
||||
@@ -540,10 +704,11 @@ def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], o
|
||||
fitted = _apply_manage_overlay(fitted, manage)
|
||||
quantized = _quantize(fitted, palette_rgb, dither_strength)
|
||||
if as_png:
|
||||
buf = io.BytesIO()
|
||||
quantized.convert("RGB").save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
return _transpose_and_pack(quantized, orientation)
|
||||
return _png_bytes(quantized)
|
||||
packed = _transpose_and_pack(quantized, orientation)
|
||||
if capture_snapshot:
|
||||
return packed, _png_bytes(quantized)
|
||||
return packed
|
||||
|
||||
|
||||
def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
|
||||
@@ -559,14 +724,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 = _apply_manage_overlay(fitted, manage)
|
||||
quantized = _quantize(fitted, palette_rgb, dither_strength)
|
||||
buf = io.BytesIO()
|
||||
quantized.convert("RGB").save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
return _png_bytes(quantized)
|
||||
|
||||
|
||||
def render_placeholder(lines: list[str], qr_url: str | None = None,
|
||||
orientation: str = "landscape", palette_rgb: list | None = None,
|
||||
manage: dict | None = None, as_png: bool = False) -> 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
|
||||
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
|
||||
@@ -574,7 +738,8 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
|
||||
|
||||
`manage`, same as render_frame's -- lets the manage button still work
|
||||
(at minimum, the scan-to-manage QR) on a frame that isn't configured
|
||||
yet."""
|
||||
yet. `capture_snapshot`, same as render_panel's -- (packed, png)
|
||||
instead of just packed."""
|
||||
margin = 24
|
||||
logical_w, logical_h = logical_render_size(orientation)
|
||||
img = Image.new("RGB", (logical_w, logical_h), (255, 255, 255))
|
||||
@@ -638,7 +803,8 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
|
||||
img = _apply_manage_overlay(img, manage)
|
||||
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||
if as_png:
|
||||
buf = io.BytesIO()
|
||||
quantized.convert("RGB").save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
return _transpose_and_pack(quantized, orientation)
|
||||
return _png_bytes(quantized)
|
||||
packed = _transpose_and_pack(quantized, orientation)
|
||||
if capture_snapshot:
|
||||
return packed, _png_bytes(quantized)
|
||||
return packed
|
||||
|
||||
@@ -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:])
|
||||
+38
-2
@@ -16,14 +16,15 @@ pre-database config.json deployment on first boot."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
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.templating import Jinja2Templates
|
||||
from sqlalchemy import select
|
||||
|
||||
from . import migration
|
||||
from . import logging_setup, migration
|
||||
from .auth import (
|
||||
browser_token_valid,
|
||||
current_user,
|
||||
@@ -38,12 +39,40 @@ from .routers.common import shell_context
|
||||
|
||||
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.
|
||||
migration.run_migrations()
|
||||
|
||||
app = FastAPI(title="ESPresso Frame Server")
|
||||
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.include_router(device.router)
|
||||
@@ -60,6 +89,13 @@ def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@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, allow_legacy: bool) -> str | None:
|
||||
"""The on-frame manage QR points at the server root with the device's
|
||||
own credentials (new firmware: ?id=&token=; deployed firmware:
|
||||
|
||||
@@ -774,6 +774,30 @@ def _migration_29(conn) -> None:
|
||||
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"))
|
||||
|
||||
|
||||
MIGRATIONS = [
|
||||
(1, _migration_1),
|
||||
(2, _migration_2),
|
||||
@@ -804,6 +828,7 @@ MIGRATIONS = [
|
||||
(27, _migration_27),
|
||||
(28, _migration_28),
|
||||
(29, _migration_29),
|
||||
(30, _migration_30),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -328,6 +328,16 @@ class Frame(Base):
|
||||
# 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_first_seen: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
stats_device_wakes: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
@@ -244,6 +244,14 @@ def api_status(
|
||||
"device": {
|
||||
"last_seen": frame.last_seen or None,
|
||||
"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_available": frame.firmware_available_version or None,
|
||||
"battery": (
|
||||
@@ -271,6 +279,25 @@ def api_frame_preview(
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
@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
|
||||
routers/device.py's _record_last_displayed) -- the frozen "now
|
||||
displaying" half of the header preview pair, as opposed to /preview's
|
||||
always-live "up next" re-render. 404 (not a placeholder image) until
|
||||
the device has fetched at least once, so the web UI can show its own
|
||||
empty state instead of a broken image. X-Displayed-At carries the
|
||||
capture time (unix seconds) for a "N ago" label -- a header, not the
|
||||
body, since the body is the raw PNG bytes."""
|
||||
if frame.last_displayed_image is None:
|
||||
raise HTTPException(404, "This frame hasn't displayed anything yet")
|
||||
return Response(
|
||||
content=frame.last_displayed_image,
|
||||
media_type="image/png",
|
||||
headers={"X-Displayed-At": str(frame.last_displayed_at)},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/battery-log")
|
||||
def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
||||
rows = db.execute(
|
||||
@@ -384,6 +411,7 @@ def api_firmware_check(
|
||||
"board": frame.device_board_variant or None,
|
||||
"latest_version": frame.firmware_gitea_latest_version or None,
|
||||
"staged_version": frame.firmware_available_version or None,
|
||||
"running_version": frame.device_firmware_version or None,
|
||||
"update_available": update_available,
|
||||
}
|
||||
|
||||
|
||||
@@ -59,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
|
||||
# just at the recharge-detection level.
|
||||
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
|
||||
# refresh_interval_s; give it half again as long before flagging it.
|
||||
@@ -179,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
|
||||
|
||||
|
||||
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:
|
||||
"""Remaining-time estimate from a recency-weighted average of the
|
||||
*per-wake* percent drop, over the last BATTERY_ESTIMATE_SAMPLE_COUNT
|
||||
@@ -189,18 +239,21 @@ def battery_estimate_s(frame: Frame, db: Session) -> int | None:
|
||||
|
||||
Consecutive reports are assumed to be consecutive wakes (firmware
|
||||
reports battery on every wake while on battery), so each step's
|
||||
(prev_percent - next_percent) is that wake's cost. A step where
|
||||
percent went *up* is a recharge, not negative drain, and is skipped
|
||||
entirely rather than folded in as a weird outlier; a flat step
|
||||
(0% change) still counts as a real, cheap wake -- excluding those
|
||||
would systematically overstate the per-wake cost by only counting
|
||||
the wakes that happened to tick the percentage down. The remaining
|
||||
steps then get one more pass, _reject_outlier_drops, to catch the
|
||||
single-noisy-reading case that "percent went up" alone can't (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.
|
||||
(prev_percent - next_percent) is that wake's cost. Raw percents go
|
||||
through _smooth_percents first, which corrects readings (including
|
||||
short bursts of them) that are wild outliers against their own local
|
||||
neighborhood -- see that function's docstring for why that catches
|
||||
noise shapes _reject_outlier_drops can't. A step where percent went
|
||||
*up* is a recharge, not negative drain, and is skipped entirely
|
||||
rather than folded in as a weird outlier; a flat step (0% change)
|
||||
still counts as a real, cheap wake -- excluding those would
|
||||
systematically overstate the per-wake cost by only counting the
|
||||
wakes that happened to tick the percentage down. The remaining steps
|
||||
then get one more pass, _reject_outlier_drops, to catch whatever
|
||||
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 frame's *current* refresh_interval_s and quiet-hours settings
|
||||
@@ -220,6 +273,7 @@ def battery_estimate_s(frame: Frame, db: Session) -> int | None:
|
||||
if len(rows) < MIN_ESTIMATE_SAMPLES + 1:
|
||||
return None
|
||||
percents = list(reversed(rows)) # chronological order
|
||||
percents = _smooth_percents(percents)
|
||||
|
||||
steps: list[tuple[int, float]] = [] # (recency_weight, drop_pct)
|
||||
for i in range(1, len(percents)):
|
||||
|
||||
+105
-27
@@ -14,6 +14,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, Response
|
||||
@@ -23,7 +24,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from .. import grid, mail, quiet_hours
|
||||
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 ..global_actions import GLOBAL_ACTIONS
|
||||
from ..image_pipeline import draw_widget_border, logical_render_size, render_panel, render_placeholder, resolve_border_color
|
||||
@@ -43,7 +44,7 @@ router = APIRouter()
|
||||
|
||||
|
||||
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
|
||||
content -- instructions with a QR, rendered at 200 so the device
|
||||
treats it as a perfectly normal image and never error-loops. The
|
||||
@@ -60,6 +61,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
|
||||
palette_rgb=frame.palette_rgb,
|
||||
manage=manage,
|
||||
as_png=as_png,
|
||||
capture_snapshot=capture_snapshot,
|
||||
)
|
||||
if frame.owner_user_id is None:
|
||||
return render_placeholder(
|
||||
@@ -68,6 +70,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
|
||||
palette_rgb=frame.palette_rgb,
|
||||
manage=manage,
|
||||
as_png=as_png,
|
||||
capture_snapshot=capture_snapshot,
|
||||
)
|
||||
return render_placeholder(
|
||||
["Almost there!", "Add a widget for this frame at", base],
|
||||
@@ -76,11 +79,46 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
|
||||
palette_rgb=frame.palette_rgb,
|
||||
manage=manage,
|
||||
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,
|
||||
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
|
||||
into its own region (see app/grid.py for grid-cell -> pixel math),
|
||||
draws that widget's own optional border directly onto its region
|
||||
@@ -89,34 +127,46 @@ def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wa
|
||||
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."""
|
||||
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(
|
||||
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
||||
).all()
|
||||
panel_w, panel_h = logical_render_size(frame.orientation)
|
||||
regions = []
|
||||
for widget in all_widgets:
|
||||
module = WIDGET_TYPES.get(widget.widget_type)
|
||||
if module is None:
|
||||
continue # unrecognized widget_type -- shouldn't happen, skip defensively rather than 500
|
||||
px, py, pw, ph = grid.cell_to_pixels(
|
||||
frame.orientation, panel_w, panel_h, (widget.x, widget.y, widget.w, widget.h)
|
||||
)
|
||||
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),
|
||||
)
|
||||
regions.append(((px, py, pw, ph), img))
|
||||
if all_widgets:
|
||||
with ThreadPoolExecutor(max_workers=min(len(all_widgets), 8)) as pool:
|
||||
futures = [
|
||||
pool.submit(
|
||||
_render_one_widget, frame.id, widget.id, frame.orientation, panel_w, panel_h,
|
||||
(widget.x, widget.y, widget.w, widget.h), is_normal_wake,
|
||||
)
|
||||
for widget in all_widgets
|
||||
]
|
||||
for future in futures:
|
||||
result = future.result()
|
||||
if result is not None:
|
||||
regions.append(result)
|
||||
return render_panel(
|
||||
regions, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
||||
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
|
||||
dither_strength=frame.dither_strength, manage=manage, as_png=as_png,
|
||||
capture_snapshot=capture_snapshot,
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
An unclaimed frame or one with no widgets yet gets the setup
|
||||
placeholder (needs `request` for its QR URLs -- only available on the
|
||||
@@ -134,11 +184,11 @@ def _render_frame_content(db: Session, frame: Frame, request: Request | None, ma
|
||||
if request is None:
|
||||
return render_placeholder(
|
||||
["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:
|
||||
@@ -245,6 +295,17 @@ def _manage_flag(request: Request) -> bool:
|
||||
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")
|
||||
def frame_image(
|
||||
request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
|
||||
@@ -262,9 +323,14 @@ def frame_image(
|
||||
?manage=1 (the manage button) composites the manage overlay onto
|
||||
whatever this would have returned anyway -- see build_manage_content.
|
||||
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
|
||||
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")
|
||||
|
||||
|
||||
@@ -277,7 +343,10 @@ def frame_advance(request: Request, frame: Frame = Depends(require_device), db:
|
||||
device's next-photo button."""
|
||||
_run_button_actions(db, frame, "next")
|
||||
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")
|
||||
|
||||
|
||||
@@ -288,7 +357,10 @@ 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."""
|
||||
_run_button_actions(db, frame, "back")
|
||||
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")
|
||||
|
||||
|
||||
@@ -303,7 +375,10 @@ def frame_global_next(request: Request, frame: Frame = Depends(require_device),
|
||||
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 = _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")
|
||||
|
||||
|
||||
@@ -312,7 +387,10 @@ def frame_global_back(request: Request, frame: Frame = Depends(require_device),
|
||||
"""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 = _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")
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import logging
|
||||
import time
|
||||
|
||||
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 sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -35,6 +35,7 @@ from ..auth import (
|
||||
verify_password,
|
||||
)
|
||||
from ..db import get_db
|
||||
from ..logging_setup import LOG_PATH, read_log_tail
|
||||
from ..models import Frame, PasswordResetToken, PendingClaim, User, UserFrame
|
||||
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),
|
||||
"notice": notice,
|
||||
"error": error,
|
||||
"active_admin_tab": "main",
|
||||
})
|
||||
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)
|
||||
|
||||
|
||||
@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)
|
||||
def admin_create_user(
|
||||
request: Request,
|
||||
|
||||
@@ -58,6 +58,15 @@
|
||||
}
|
||||
})();
|
||||
|
||||
// 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
|
||||
// UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns).
|
||||
const WIDGET_LABELS = {
|
||||
|
||||
@@ -18,6 +18,14 @@ function renderDeviceStatusBar(device) {
|
||||
}
|
||||
const now = Date.now() / 1000;
|
||||
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));
|
||||
rows.push(['Last seen', `${ago} ago`, device.overdue]);
|
||||
if (device.firmware_version) {
|
||||
|
||||
@@ -339,8 +339,15 @@ async function loadFirmwareCheck(force) {
|
||||
statusEl.textContent = 'Waiting for the frame to check in before it can look up the right build.';
|
||||
btn.style.display = 'none';
|
||||
} 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';
|
||||
} 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) {
|
||||
statusEl.textContent = `Up to date (v${data.latest_version}).`;
|
||||
btn.style.display = 'none';
|
||||
|
||||
@@ -58,48 +58,31 @@
|
||||
});
|
||||
})();
|
||||
|
||||
// Live "how it's displaying" thumbnail. A real composite render (same
|
||||
// pipeline /frame/image uses), not a cached snapshot, so it's on a slow
|
||||
// poll rather than something tighter like the 10s device-status poll --
|
||||
// no need to hit Immich/calendar/whiteboard sources that often just for
|
||||
// a header thumbnail. Click enlarges it in a dialog (which also fetches
|
||||
// a fresh render); clicking the enlarged image refreshes it again.
|
||||
// Now-displaying / up-next header preview pair. "Up next" is a real
|
||||
// composite render (same pipeline /frame/image uses), not a cached
|
||||
// snapshot, so it's on a slow poll rather than something tighter like
|
||||
// the 10s device-status poll -- no need to hit Immich/calendar/
|
||||
// whiteboard sources that often just for a header thumbnail, and it
|
||||
// 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 () {
|
||||
var thumb = document.getElementById('frame-preview-thumb');
|
||||
var dialog = document.getElementById('frame-preview-dialog');
|
||||
var bigImg = document.getElementById('frame-preview-dialog-img');
|
||||
var closeBtn = document.getElementById('frame-preview-dialog-close');
|
||||
if (!thumb || !window.FRAME_BASE_API) return;
|
||||
var nextThumb = document.getElementById('frame-preview-thumb');
|
||||
var nextDialog = document.getElementById('frame-preview-dialog');
|
||||
var nextBigImg = document.getElementById('frame-preview-dialog-img');
|
||||
var nextCloseBtn = document.getElementById('frame-preview-dialog-close');
|
||||
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() {
|
||||
return `${window.FRAME_BASE_API}/preview?t=${Date.now()}`;
|
||||
}
|
||||
function refreshThumb() {
|
||||
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.
|
||||
// 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.
|
||||
function closeOnBackdropClick(dialog) {
|
||||
dialog.addEventListener('click', function (e) {
|
||||
if (e.target !== dialog) return;
|
||||
var rect = dialog.getBoundingClientRect();
|
||||
@@ -107,4 +90,80 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
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)));
|
||||
@@ -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 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 {
|
||||
font-size: 14.5px;
|
||||
font-weight: 650;
|
||||
@@ -243,6 +261,36 @@ input[type="range"] {
|
||||
}
|
||||
.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:first-child { margin-top: 0; }
|
||||
|
||||
@@ -719,13 +767,24 @@ code {
|
||||
}
|
||||
.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 {
|
||||
height: 44px;
|
||||
width: auto;
|
||||
max-width: 130px;
|
||||
object-fit: contain;
|
||||
vertical-align: middle;
|
||||
margin-left: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface-alt);
|
||||
@@ -733,6 +792,13 @@ code {
|
||||
transition: opacity .12s ease;
|
||||
}
|
||||
.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 {
|
||||
position: fixed;
|
||||
|
||||
@@ -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 secondary" id="frame-name-cancel">Cancel</button>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
{% block page_title %}Administration{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "_admin_tabs.html" %}
|
||||
|
||||
{% if notice %}<div class="status ok">{{ notice }}</div>{% endif %}
|
||||
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
|
||||
|
||||
|
||||
@@ -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>
|
||||
<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 %}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -17,6 +17,14 @@
|
||||
})();
|
||||
</script>
|
||||
<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 %}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -3,6 +3,7 @@ starlette==0.41.3
|
||||
uvicorn[standard]==0.34.0
|
||||
httpx==0.28.1
|
||||
pillow==12.3.0
|
||||
numpy==2.5.1
|
||||
python-multipart==0.0.20
|
||||
jinja2==3.1.5
|
||||
sqlalchemy==2.0.51
|
||||
|
||||
@@ -29,6 +29,10 @@ from pathlib import Path
|
||||
|
||||
_tmp_dir = tempfile.mkdtemp(prefix="espresso_frame_tests_")
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{Path(_tmp_dir) / 'test.db'}"
|
||||
# Same reasoning as DATABASE_URL above: logging_setup.configure_logging()
|
||||
# also runs as an app.main import-time side effect and would otherwise
|
||||
# try to create the real /data directory.
|
||||
os.environ["LOG_PATH"] = str(Path(_tmp_dir) / "server.log")
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Permission boundary + basic content checks for the admin log viewer
|
||||
(routers/pages.py's admin_logs_page/admin_logs_download) -- see
|
||||
CLAUDE.md's note that anything gated by an admin/permission check needs
|
||||
a same-shape test: admin, non-admin logged in, logged out."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.logging_setup import LOG_PATH
|
||||
from app.models import Frame
|
||||
|
||||
from .conftest import login, make_user
|
||||
|
||||
|
||||
def _setup_admin_and_user(client, db_session) -> None:
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
make_user(db_session, "bob")
|
||||
|
||||
|
||||
def test_admin_can_view_logs(client, db_session):
|
||||
_setup_admin_and_user(client, db_session)
|
||||
login(client, "alice", "hunter22")
|
||||
logging.getLogger("app.test").info("marker-line-for-test")
|
||||
resp = client.get("/admin/logs")
|
||||
assert resp.status_code == 200
|
||||
assert "marker-line-for-test" in resp.text
|
||||
|
||||
|
||||
def test_non_admin_forbidden_from_logs(client, db_session):
|
||||
_setup_admin_and_user(client, db_session)
|
||||
login(client, "bob")
|
||||
resp = client.get("/admin/logs")
|
||||
assert resp.status_code == 403
|
||||
resp = client.get("/admin/logs/download")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_logged_out_redirected_from_logs_page(client, db_session):
|
||||
_setup_admin_and_user(client, db_session)
|
||||
client.cookies.clear() # /setup itself logs alice in
|
||||
resp = client.get("/admin/logs")
|
||||
assert resp.status_code == 303
|
||||
assert resp.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_admin_can_download_log_file(client, db_session):
|
||||
_setup_admin_and_user(client, db_session)
|
||||
login(client, "alice", "hunter22")
|
||||
logging.getLogger("app.test").info("marker-line-for-download")
|
||||
resp = client.get("/admin/logs/download")
|
||||
assert resp.status_code == 200
|
||||
assert b"marker-line-for-download" in resp.content
|
||||
|
||||
|
||||
def test_device_requests_are_logged(client, db_session):
|
||||
_setup_admin_and_user(client, db_session)
|
||||
frame = db_session.get(Frame, 1)
|
||||
frame.device_id = "aabbccddeeff"
|
||||
db_session.commit()
|
||||
resp = client.get(f"/frame/config?id={frame.device_id}&token={frame.device_token}")
|
||||
assert resp.status_code == 200
|
||||
|
||||
login(client, "alice", "hunter22")
|
||||
log_resp = client.get("/admin/logs")
|
||||
# Jinja HTML-escapes the rendered <pre>, so "->" becomes "->".
|
||||
assert f"GET /frame/config id={frame.device_id} -> 200" in log_resp.text
|
||||
|
||||
|
||||
def test_download_404s_before_any_log_written(client, db_session, monkeypatch):
|
||||
_setup_admin_and_user(client, db_session)
|
||||
login(client, "alice", "hunter22")
|
||||
monkeypatch.setattr("app.routers.pages.LOG_PATH", LOG_PATH.parent / "does-not-exist.log")
|
||||
resp = client.get("/admin/logs/download")
|
||||
assert resp.status_code == 404
|
||||
@@ -1,11 +1,10 @@
|
||||
"""_reject_outlier_drops -- the outlier-rejection pass in the battery
|
||||
remaining-time estimate (see routers/common.py's battery_estimate_s).
|
||||
Pure function, no DB/HTTP -- (recency_weight, drop_pct) pairs in,
|
||||
filtered pairs out."""
|
||||
"""_reject_outlier_drops and _smooth_percents -- the two outlier-rejection
|
||||
passes in the battery remaining-time estimate (see routers/common.py's
|
||||
battery_estimate_s). Pure functions, no DB/HTTP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.routers.common import _reject_outlier_drops
|
||||
from app.routers.common import _reject_outlier_drops, _smooth_percents
|
||||
|
||||
|
||||
def _steps(drops: list[float]) -> list[tuple[int, float]]:
|
||||
@@ -69,3 +68,37 @@ def test_never_filters_down_to_nothing():
|
||||
steps = _steps([1, 1, 1, 1, 10, 10, 10, 10])
|
||||
kept = _reject_outlier_drops(steps)
|
||||
assert len(kept) > 0
|
||||
|
||||
|
||||
def test_smooth_corrects_isolated_spike():
|
||||
percents = [70, 70, 70, 70, 70, 90, 70, 70, 70, 70, 70]
|
||||
smoothed = _smooth_percents(percents)
|
||||
assert smoothed[5] == 70
|
||||
assert smoothed[:5] == percents[:5]
|
||||
assert smoothed[6:] == percents[6:]
|
||||
|
||||
|
||||
def test_smooth_corrects_short_burst():
|
||||
"""The shape seen in production: several consecutive corrupted
|
||||
reports (a 1M-ohm divider glitching for a few reports in a row, not
|
||||
just one) spliced into an otherwise flat run. A step computed
|
||||
between two of these looks like an ordinary small change, which is
|
||||
exactly why _reject_outlier_drops alone can't catch this shape."""
|
||||
percents = [53, 53, 53, 53, 41, 40, 40, 42, 53, 53, 53, 53]
|
||||
smoothed = _smooth_percents(percents)
|
||||
assert smoothed[4:8] == [53, 53, 53, 53]
|
||||
assert smoothed[:4] == percents[:4]
|
||||
assert smoothed[8:] == percents[8:]
|
||||
|
||||
|
||||
def test_smooth_leaves_gradual_legitimate_trend_alone():
|
||||
"""A slow, steady climb (recharge) or decline spread over many
|
||||
reports is a real trend, not a local glitch -- each reading is close
|
||||
to its own neighborhood's median, so nothing should be flagged."""
|
||||
percents = list(range(80, 60, -2)) # 80, 78, 76, ... steady discharge
|
||||
assert _smooth_percents(percents) == percents
|
||||
|
||||
|
||||
def test_smooth_identical_readings_untouched():
|
||||
percents = [50] * 12
|
||||
assert _smooth_percents(percents) == percents
|
||||
|
||||
@@ -144,3 +144,50 @@ def test_two_widget_frame_composites_both_and_next_targets_calendar(client, db_s
|
||||
photo_cfg = db_session.get(PhotoWidgetConfig, photo_widget.id)
|
||||
assert cal_cfg.browse_offset == 1
|
||||
assert photo_cfg.current_asset_id == "" # untouched -- NEXT was never bound to it
|
||||
|
||||
|
||||
def test_widgets_render_concurrently(client, db_session, monkeypatch):
|
||||
"""Two independent, slow widgets on one frame should render in
|
||||
roughly the time of the slowest one, not the sum -- the actual fix
|
||||
for the "hold to cycle layouts times out and shows a false server-
|
||||
failed status screen" bug: several network-backed widgets (photos,
|
||||
weather, calendar) rendering one after another could push a single
|
||||
/frame/* response past the firmware's fixed HTTP timeout even though
|
||||
the server was simply still working."""
|
||||
monkeypatch.setattr(widgets.photos, "immich_client_for", lambda frame: object())
|
||||
monkeypatch.setattr(widgets.photos, "list_assets", lambda client, album_id: _ASSETS)
|
||||
|
||||
from PIL import Image
|
||||
source = Image.new("RGB", (100, 80), (10, 20, 30))
|
||||
|
||||
def _slow_fetch(client, mode, asset_id):
|
||||
time.sleep(0.25)
|
||||
return source, None
|
||||
|
||||
monkeypatch.setattr(widgets.photos, "fetch_source_and_faces", _slow_fetch)
|
||||
|
||||
frame = Frame(
|
||||
name="Concurrency Frame", device_id="112233445566", device_token="devtok-3",
|
||||
manage_token="mtok-3", orientation="landscape", created_at=time.time(),
|
||||
)
|
||||
db_session.add(frame)
|
||||
db_session.flush()
|
||||
|
||||
widget_a = Widget(frame_id=frame.id, widget_type="photos", x=0, y=0, w=4, h=5,
|
||||
sort_order=0, created_at=time.time())
|
||||
widget_b = Widget(frame_id=frame.id, widget_type="photos", x=4, y=0, w=4, h=5,
|
||||
sort_order=1, created_at=time.time())
|
||||
db_session.add_all([widget_a, widget_b])
|
||||
db_session.flush()
|
||||
db_session.add(PhotoWidgetConfig(widget_id=widget_a.id, album_id="album-a"))
|
||||
db_session.add(PhotoWidgetConfig(widget_id=widget_b.id, album_id="album-b"))
|
||||
db_session.commit()
|
||||
|
||||
start = time.monotonic()
|
||||
resp = client.get(f"/frame/image?id={frame.device_id}&token={frame.device_token}")
|
||||
elapsed = time.monotonic() - start
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.content) == EXPECTED_BYTES
|
||||
# Serial would be ~0.5s (2 x 0.25s); concurrent should land near 0.25s.
|
||||
assert elapsed < 0.45, f"widgets rendered serially, not concurrently ({elapsed:.2f}s)"
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""POST /api/frames/{id}/firmware/check -- the "Check now" button's
|
||||
endpoint. update_available compares the latest Gitea release against
|
||||
what's *staged* (firmware_available_version), not what the device is
|
||||
actually running (device_firmware_version) -- those can differ once a
|
||||
release has been staged/auto-applied but the frame hasn't woken up and
|
||||
picked it up yet. running_version lets the UI tell "up to date" apart
|
||||
from "staged, waiting for the frame to apply it" instead of collapsing
|
||||
both into the same message."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app import gitea_releases
|
||||
from app.models import Frame
|
||||
|
||||
from .conftest import csrf_headers
|
||||
|
||||
|
||||
def _setup_frame(db_session, monkeypatch, latest_version, **overrides):
|
||||
"""firmware_update_checked_at starts at 0, so the endpoint always
|
||||
tries a real Gitea fetch on a fresh frame regardless of force= --
|
||||
stub it out rather than hitting the network."""
|
||||
monkeypatch.setattr(
|
||||
gitea_releases, "fetch_latest_release", lambda *a, **k: {"version": latest_version, "assets": {}}
|
||||
)
|
||||
frame = db_session.get(Frame, 1)
|
||||
frame.firmware_update_repo_url = "https://git.example.com/owner/repo"
|
||||
frame.device_board_variant = "devkit"
|
||||
for key, value in overrides.items():
|
||||
setattr(frame, key, value)
|
||||
db_session.commit()
|
||||
return frame
|
||||
|
||||
|
||||
def test_up_to_date_when_running_matches_latest(client, db_session, monkeypatch):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
_setup_frame(
|
||||
db_session,
|
||||
monkeypatch,
|
||||
"1.4.1",
|
||||
firmware_available_version="1.4.1",
|
||||
device_firmware_version="1.4.1",
|
||||
)
|
||||
|
||||
resp = client.post("/api/frames/1/firmware/check", headers=csrf_headers(client))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["update_available"] is False
|
||||
assert data["latest_version"] == "1.4.1"
|
||||
assert data["running_version"] == "1.4.1"
|
||||
|
||||
|
||||
def test_staged_but_not_yet_running_is_not_update_available(client, db_session, monkeypatch):
|
||||
"""A release already staged (e.g. by a previous auto-update) but not
|
||||
yet applied by the device isn't "an update is available" -- there's
|
||||
nothing left to fetch/stage -- but it also isn't silently "up to
|
||||
date" from the UI's perspective, since running_version still lags."""
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
_setup_frame(
|
||||
db_session,
|
||||
monkeypatch,
|
||||
"1.4.1",
|
||||
firmware_available_version="1.4.1",
|
||||
device_firmware_version="1.3.0",
|
||||
)
|
||||
|
||||
resp = client.post("/api/frames/1/firmware/check", headers=csrf_headers(client))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["update_available"] is False
|
||||
assert data["latest_version"] == "1.4.1"
|
||||
assert data["staged_version"] == "1.4.1"
|
||||
assert data["running_version"] == "1.3.0"
|
||||
|
||||
|
||||
def test_update_available_reports_running_version(client, db_session, monkeypatch):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
_setup_frame(
|
||||
db_session,
|
||||
monkeypatch,
|
||||
"1.4.1",
|
||||
firmware_available_version="1.3.0",
|
||||
device_firmware_version="1.3.0",
|
||||
)
|
||||
|
||||
resp = client.post("/api/frames/1/firmware/check", headers=csrf_headers(client))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["update_available"] is True
|
||||
assert data["running_version"] == "1.3.0"
|
||||
@@ -92,6 +92,7 @@ def test_expected_columns_exist_on_current_schema():
|
||||
button_action_indexes = {idx["name"] for idx in inspector.get_indexes("frame_button_actions")}
|
||||
assert "ix_frame_button_actions_widget_button" in button_action_indexes # migration 28
|
||||
assert {"hold_duration_ms", "next_hold_action", "back_hold_action", "last_cycled_layout_id"} <= frame_columns
|
||||
assert {"last_displayed_image", "last_displayed_at"} <= frame_columns # migration 30
|
||||
|
||||
|
||||
# --- widget system backfill (migration 16 + _ensure_widgets_backfilled) ---
|
||||
@@ -331,6 +332,25 @@ def test_migration_29_adds_hold_action_columns_to_an_existing_database(db_sessio
|
||||
assert frame.last_cycled_layout_id is None
|
||||
|
||||
|
||||
def test_migration_30_adds_now_displaying_columns_to_an_existing_database(db_session):
|
||||
"""Exercises _migration_30's real guarded ALTER path (frames isn't
|
||||
dropped/recreated by the pre-widget-system replay tests, so its
|
||||
columns must be added defensively, same reasoning as migration
|
||||
26/27/29's own comments)."""
|
||||
with db_module.engine.begin() as conn:
|
||||
conn.execute(text("UPDATE schema_version SET version = 29"))
|
||||
|
||||
run_migrations()
|
||||
|
||||
with db_module.engine.connect() as conn:
|
||||
version = conn.execute(text("SELECT version FROM schema_version")).scalar()
|
||||
assert version == MIGRATIONS[-1][0]
|
||||
|
||||
frame = db_session.get(Frame, 1)
|
||||
assert frame.last_displayed_image is None
|
||||
assert frame.last_displayed_at == 0.0
|
||||
|
||||
|
||||
def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(db_session):
|
||||
"""Exercises _migration_17 and _migration_18's actual data-extraction
|
||||
SQL back to back (the real "existing widget-system database
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""GET /api/frames/{id}/now-displaying -- the frozen half of the header
|
||||
preview pair (see routers/device.py's _record_last_displayed). Distinct
|
||||
from /preview (test_frame_preview.py): that one always live-renders,
|
||||
this one serves back exactly whatever bytes a device-facing endpoint
|
||||
last actually sent, recorded as a side effect of /frame/image,
|
||||
/frame/advance, and /frame/back."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app.image_pipeline import logical_render_size
|
||||
from app.models import Frame
|
||||
|
||||
from .conftest import link_user, login, make_user
|
||||
|
||||
|
||||
def test_now_displaying_404s_before_any_device_fetch(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
|
||||
resp = client.get("/api/frames/1/now-displaying")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_frame_image_records_now_displaying(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
frame = db_session.get(Frame, 1)
|
||||
|
||||
resp = client.get("/frame/image")
|
||||
assert resp.status_code == 200
|
||||
|
||||
resp = client.get("/api/frames/1/now-displaying")
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == "image/png"
|
||||
assert "X-Displayed-At" in resp.headers
|
||||
assert float(resp.headers["X-Displayed-At"]) > 0
|
||||
|
||||
img = Image.open(io.BytesIO(resp.content))
|
||||
assert img.size == logical_render_size(frame.orientation)
|
||||
|
||||
|
||||
def test_advance_and_back_also_update_now_displaying(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
db_session.get(Frame, 1)
|
||||
|
||||
client.get("/frame/image")
|
||||
first = client.get("/api/frames/1/now-displaying")
|
||||
assert first.status_code == 200
|
||||
|
||||
resp = client.post("/frame/advance")
|
||||
assert resp.status_code == 200
|
||||
after_advance = client.get("/api/frames/1/now-displaying")
|
||||
assert after_advance.status_code == 200
|
||||
|
||||
resp = client.post("/frame/back")
|
||||
assert resp.status_code == 200
|
||||
after_back = client.get("/api/frames/1/now-displaying")
|
||||
assert after_back.status_code == 200
|
||||
assert float(after_back.headers["X-Displayed-At"]) >= float(first.headers["X-Displayed-At"])
|
||||
|
||||
|
||||
def test_now_displaying_visible_to_linked_user(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
bob = make_user(db_session, "bob")
|
||||
frame = db_session.get(Frame, 1)
|
||||
link_user(db_session, bob, frame)
|
||||
client.get("/frame/image")
|
||||
|
||||
client.cookies.clear()
|
||||
login(client, "bob")
|
||||
resp = client.get("/api/frames/1/now-displaying")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_now_displaying_hidden_from_unrelated_user(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
make_user(db_session, "mallory")
|
||||
client.get("/frame/image")
|
||||
|
||||
client.cookies.clear()
|
||||
login(client, "mallory")
|
||||
resp = client.get("/api/frames/1/now-displaying")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_now_displaying_requires_login(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
client.get("/frame/image")
|
||||
|
||||
client.cookies.clear()
|
||||
resp = client.get("/api/frames/1/now-displaying")
|
||||
assert resp.status_code in (401, 403)
|
||||
@@ -135,7 +135,7 @@ def test_solid_border_draws_the_configured_palette_color(client, db_session):
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
img = _preview_pixels(client)
|
||||
assert img.getpixel((0, 0)) == (207, 0, 15) # DEFAULT_PALETTE_RGB[3], top-left corner of the stroke
|
||||
assert img.getpixel((0, 0)) == (98, 32, 30) # DEFAULT_PALETTE_RGB[3], top-left corner of the stroke
|
||||
|
||||
|
||||
def test_no_border_leaves_the_edge_unmarked(client, db_session):
|
||||
@@ -146,4 +146,4 @@ def test_no_border_leaves_the_edge_unmarked(client, db_session):
|
||||
render path already painting it that color."""
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
img = _preview_pixels(client)
|
||||
assert img.getpixel((0, 0)) != (207, 0, 15)
|
||||
assert img.getpixel((0, 0)) != (98, 32, 30)
|
||||
|
||||
Reference in New Issue
Block a user