15 Commits
Author SHA1 Message Date
tfaour d974e872ba Split the pip install into multiple Dockerfile layers
Build and push server image / test (push) Successful in 36s
Build and push server image / build-and-push (push) Successful in 2m34s
Build and push server image / deploy (push) Successful in 49s
The combined pip install layer was already over Cloudflare's
single-blob/layer payload-size limit (~113MB unpacked) before any
recent change -- the last two build-and-push CI runs were failing on
it. Isolate the three largest packages (sqlalchemy, pillow, pypdfium2)
into their own layers, same fix already applied to render-service's
npm installs below for the same limit.
2026-07-28 04:31:18 +00:00
tfaour dfe9d71971 Revert "Quantize with a measured Spectra 6 palette and OKLab-space ordered dithering"
This reverts commit 05b417a29b.
2026-07-28 04:29:59 +00:00
tfaour 05b417a29b Quantize with a measured Spectra 6 palette and OKLab-space ordered dithering
Build and push server image / test (push) Successful in 58s
Build and push server image / build-and-push (push) Successful in 2m44s
Build and push server image / deploy (push) Successful in 53s
DEFAULT_PALETTE_RGB was a guessed approximation of the panel's ink
colors (pure sRGB primaries); swap in epdoptimize's measured spectra6
palette instead, which is far more muted/darker, matching how these
inks actually look.

_quantize now matches against the palette in OKLab space (perceptual
distance) instead of PIL's raw-RGB quantize(), with lightness weighted
down relative to hue/chroma when selecting the nearest color -- this
palette's inks are lit so differently from their sRGB namesakes
(muted dark red, bright yellow) that unweighted distance let lightness
dominate and mismatch hue (pure red nearest "yellow").

Dithering switched from Floyd-Steinberg error diffusion to a Bayer
ordered dither: true error diffusion is an inherently serial per-pixel
loop, and doing that in pure Python for a full 800x480 panel took
~1s, blowing past the render-latency budget the "render widgets
concurrently" fix (previous commit) exists to protect. The ordered
dither finds each pixel's true nearest and second-nearest palette
color and mixes between them (via projection onto that segment, not
distance ratio) using a tiled Bayer threshold -- fully vectorized, no
Python-level pixel loop.
2026-07-28 04:20:25 +00:00
tfaour a48c84ed4a Render widgets concurrently instead of one at a time
Build and push server image / test (push) Successful in 40s
Build and push server image / build-and-push (push) Failing after 1m57s
Build and push server image / deploy (push) Has been skipped
A layout with several network-backed widgets (photos, weather,
calendar) paid their fetch latency serially in one /frame/* request,
which could exceed the firmware's fixed HTTP timeout and show a false
"server failed" status screen even though the server was still
working -- most visibly on the hold-triggered "cycle layouts" action,
which swaps in a whole new, cold-started widget set. Each widget now
renders on its own DB session in a thread pool (a plain Session isn't
thread-safe to share, but the per-frame threading.Lock in
frame_locked/widget_locked already made this kind of concurrency safe
by design -- see app/db.py); regions are still collected in
sort_order so overlapping widgets paint in the same z-order as before.
2026-07-28 03:40:07 +00:00
tfaour d1f1968317 Log device-facing /frame/* requests in the server log
Build and push server image / test (push) Successful in 39s
Build and push server image / build-and-push (push) Failing after 1m59s
Build and push server image / deploy (push) Has been skipped
The admin log viewer only ever showed exceptions from device.py, not
successful requests -- no way to see a request that was slow-but-200,
or a device probing with a stale/wrong token. Adds a middleware that
logs method, path, device id (never the token), status, and wall time
for every /frame/* request.
2026-07-28 03:24:13 +00:00
tfaour 83994aab7b Add an admin-only server log viewer to the web UI
Build and push server image / test (push) Successful in 42s
Build and push server image / build-and-push (push) Successful in 2m35s
Build and push server image / deploy (push) Successful in 51s
The root logger previously had no handler at all, so every module's
logger.info() call (user creation, claims, password resets, ...) was
silently dropped, not just unviewable. Adds a RotatingFileHandler
writing into the existing /data volume so log content also survives
container restarts/redeploys, plus /admin/logs (tail + line-count
picker + full-file download) alongside the existing Users & Frames
admin page.
2026-07-28 03:13:10 +00:00
tfaour 5866c2f040 Flatten page-level cards when installed as a standalone PWA
Build and push server image / test (push) Successful in 40s
Build and push server image / build-and-push (push) Successful in 2m38s
Build and push server image / deploy (push) Successful in 51s
The boxed-card look reads as "still a website" once the app is
running full-screen off the home screen. Scoped to
display-mode: standalone so the regular browser-tab view is
untouched; dialog-internal cards keep their box since they group
subsections of one form rather than acting as page furniture.
2026-07-28 02:40:58 +00:00
tfaour dd038f8e46 Make the server installable as a home-screen PWA
Build and push server image / test (push) Successful in 36s
Build and push server image / build-and-push (push) Successful in 2m37s
Build and push server image / deploy (push) Successful in 57s
Adds a web manifest, hand-drawn cup+frame icons, and a presence-only
service worker (no offline caching) so mobile browsers offer
"Add to Home Screen" for the server UI.
2026-07-28 02:26:54 +00:00
tfaour 3fdda096a9 Smooth battery percent readings before computing drop-rate steps
Build and push server image / test (push) Successful in 37s
Build and push server image / build-and-push (push) Successful in 2m36s
Build and push server image / deploy (push) Successful in 53s
A 1M-ohm divider (way over the ~10k source impedance the ESP32 ADC's
sample-and-hold expects) doesn't always misfire in isolation -- short
bursts of a few consecutive bad readings, and multi-reading drifts,
both slip past the existing step-level MAD outlier rejection since the
steps between two bad readings in the same burst look ordinary. Add a
Hampel-filter smoothing pass (local-neighborhood MAD, same statistical
approach as the existing outlier rejection) ahead of it.
2026-07-28 02:09:12 +00:00
tfaour 575b3cfa61 Add expected time to device status bar
Build and push server image / test (push) Successful in 39s
Build and push server image / build-and-push (push) Successful in 2m38s
Build and push server image / deploy (push) Successful in 58s
2026-07-28 01:37:33 +00:00
tfaour aa4a382c1b Add "now displaying" / "up next" preview pair to the frame header
Build and push server image / test (push) Successful in 37s
Build and push server image / build-and-push (push) Successful in 2m40s
Build and push server image / deploy (push) Successful in 57s
The server now records exactly what was last sent to the device on
every device-facing render (/frame/image, /frame/advance, /frame/back,
and the global hold actions), persisted as Frame.last_displayed_image/
_at and served back via GET /api/frames/{id}/now-displaying. The
header thumbnail is split into that frozen "now displaying" snapshot
and the existing live "up next" re-render, with an arrow between them
-- so editing a layout shows the change immediately on the right while
the left stays exactly what's actually on the panel until the device's
next real wake.
2026-07-28 00:41:11 +00:00
tfaour 684225422c Distinguish "staged, not yet applied" from "up to date" in firmware check
Build and push server image / test (push) Successful in 37s
Build and push server image / build-and-push (push) Successful in 2m36s
Build and push server image / deploy (push) Successful in 57s
update_available only compared the latest Gitea release against what's
staged, not what the frame is actually running -- so once a release
was staged (manually or via auto-update) but the frame hadn't woken up
and applied it yet, "Check now" reported "Up to date" even though the
device was still on the old version. Report the frame's actual running
version and use it to show a distinct "staged, applies on next wake"
message instead.
2026-07-27 22:55:40 +00:00
tfaour 08960c9eec Swap combo button tiers: quick press resets, ~3s hold shows menu
Firmware build check / build-check (push) Successful in 2m45s
Build and release firmware / build-and-release (push) Successful in 2m45s
Quick reset is now the fast/default action; summoning the management
menu takes a deliberate hold. Factory reset at ~15s is unchanged.
Renamed FRAME_COMBO_SOFT_RESET_HOLD_MS -> FRAME_COMBO_MENU_HOLD_MS to
match its new meaning. Bumps firmware to 1.4.1.
2026-07-27 22:40:44 +00:00
tfaour f0c21af220 Update CLAUDE.md: mark button-actions, battery-widget, scan-to-download TODOs done 2026-07-27 22:33:56 +00:00
tfaour 8602ee3add Add build-firmware skill: native ESP-IDF build, no Docker needed
CI builds firmware inside the espressif/idf Docker image, but this
sandbox can't run containers at all -- it strips cap_sys_admin (and
blocks unshare) from the capability set even for root, which container
image-layer extraction and namespace setup both need. Confirmed by
hand: docker.io installs and dockerd starts fine, but even a bare
`docker run hello-world` fails to extract its own layer.

Works around it by installing ESP-IDF natively instead (git clone +
its own install.sh, scoped to just this project's esp32c6 target) --
the same way a developer would set it up on their own machine, needing
nothing this sandbox disallows. Verified end-to-end: both board
variants (devkit, xiao) build clean from a fresh checkout via the
packaged setup.sh/build.sh.
2026-07-27 22:31:30 +00:00
47 changed files with 1331 additions and 155 deletions
+128
View File
@@ -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.
+63
View File
@@ -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
+58
View File
@@ -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=$!
+1 -5
View File
@@ -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
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+12 -13
View File
@@ -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)"
+7 -7
View File
@@ -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)
+4 -4
View File
@@ -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
View File
@@ -1 +1 @@
1.4.0
1.4.1
+16
View File
@@ -37,6 +37,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
# Split across several layers rather than one `pip install -r
# requirements.txt` -- same Cloudflare single-blob/layer payload-size
# limit as render-service's npm installs below. The single combined
# layer was measured at ~113MB unpacked, over the limit on its own.
# Isolating the three largest packages gets every layer's unpacked size
# well clear of 100MB (sqlalchemy ~15MB, pillow ~19MB, pypdfium2 ~8MB,
# the remaining `-r requirements.txt` layer ~71MB). Each package
# version here still comes from requirements.txt (`pip install -r` for
# everything that doesn't need its own layer skips these three, since
# pip sees them already satisfied); the explicit versions below just
# control *when* each installs -- same "single source of truth, just
# splitting *when* it installs" tradeoff as the npm section's --no-save
# comment below.
RUN pip install --no-cache-dir sqlalchemy==2.0.51
RUN pip install --no-cache-dir pillow==12.3.0
RUN pip install --no-cache-dir pypdfium2==5.12.1
RUN pip install --no-cache-dir -r requirements.txt
# render-service/'s dependencies installed as several separate layers
+9 -1
View File
@@ -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=`)
+31 -15
View File
@@ -496,9 +496,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 +537,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 +554,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 +574,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 +588,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 +653,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
+34
View File
@@ -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
View File
@@ -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:
+25
View File
@@ -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),
]
+10
View File
@@ -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)
+28
View File
@@ -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,
}
+66 -12
View File
@@ -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
View File
@@ -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")
+32 -1
View File
@@ -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,
+9
View File
@@ -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 = {
+8
View File
@@ -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) {
+8 -1
View File
@@ -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';
+99 -40
View File
@@ -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

+14
View File
@@ -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" }
]
}
+8
View File
@@ -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)));
+67 -1
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
<nav class="tabs">
<a href="/admin" class="{% if active_admin_tab == 'main' %}active{% endif %}">Users &amp; Frames</a>
<a href="/admin/logs" class="{% if active_admin_tab == 'logs' %}active{% endif %}">Server Logs</a>
</nav>
+11 -2
View File
@@ -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">&rarr;</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">&times;</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">&times;</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>
+2
View File
@@ -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 %}
+30
View File
@@ -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 %}
+8
View File
@@ -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>
+8
View File
@@ -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>
+4
View File
@@ -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
+75
View File
@@ -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 "-&gt;".
assert f"GET /frame/config id={frame.device_id} -&gt; 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
+38 -5
View File
@@ -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)"
+89
View File
@@ -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"
+20
View File
@@ -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
+94
View File
@@ -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)