141 Commits
Author SHA1 Message Date
tfaour c0fefc19f1 Fix ee02 button-wakeup build: ext1 fallback for ESP32-S3
Firmware build check / build-check (push) Successful in 2m44s
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown() only exists on
ESP32-C6 (SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP), so the ee02
(ESP32-S3) build failed with implicit-declaration errors in
{back,next,combo}_button.c once the epd13in3e driver's #error stopped
masking it.

Each button file now branches on that capability macro: the C6 path
(devkit/xiao) is untouched, and ESP32-S3 uses
esp_sleep_enable_ext1_wakeup_io() instead. The earlier ext1 attempt was
rejected on C6 hardware because its pull resistor didn't hold across
RTC_PERIPH power-down -- tracing the same path in ESP-IDF source shows
gpio_config()'s pull_up_en already delegates to rtc_gpio_pullup_en()
for RTC-capable pins on every non-original-ESP32 target, so the pull-up
should already survive the same power-down on S3. The _io() variant is
additive, so the three button files don't need cross-file mask
coordination. Also widens the button GPIO Kconfig range for
IDF_TARGET_ESP32S3 (0-21, matching its RTC-IO set) instead of the
C6-shaped 0-7.

Verified: ee02, devkit, and xiao all build clean end-to-end locally
(native ESP-IDF v6.0, no Docker in this sandbox). NOT verified: whether
this actually avoids the spurious-instant-wakeup bug on real EE02
hardware -- that failure mode was only ever confirmed empirically, not
root-caused in a way a compile can check. continue-on-error stays on
in CI's ee02 build step until that's confirmed.
2026-08-04 22:46:13 +00:00
tfaour 454c03586e Port real epd13in3e driver from vendor code; fix wire-raster stride bug
Build and push server image / test (push) Successful in 43s
Firmware build check / build-check (push) Successful in 2m47s
Build and push server image / build-and-push (push) Successful in 4m34s
Build and push server image / deploy (push) Failing after 1m27s
Vendored the panel's init/LUT/refresh register sequence from three
independent Waveshare reference drivers for this exact panel+controller
(RaspberryPi/c, ESP32, and the ESP32-S3-ePaper-13.3E6 ESP-IDF example),
which all agree byte-for-byte. The epd13in3e.c #error is gone; it
compiles clean and links (verified via /build-firmware ee02).

That vendor code also revealed the panel's SPI wire raster is a native
1200x1600 (portrait), not 1600x1200 as previously assumed -- rotated 90
degrees from the panel's landscape mount/marketing size. The old
assumption wasn't just a rotation bug: 1600x1200 and 1200x1600 don't
share a row stride, so packing at the wrong one would have shredded
images into a repeating diagonal garble on real hardware, not just
displayed them sideways. Fixed with a new PANEL_WIRE_TRANSPOSE in
image_pipeline.py, applied after the existing per-frame
ORIENTATION_TRANSPOSE, with a direction-agnostic regression test that
catches the stride bug specifically (a byte-count check alone can't,
since both orientations pack to the same total size).

A full ee02 build still fails, but no longer because of this driver --
main/{back,next,combo}_button.c call an ESP32-C6-only deep-sleep
GPIO-wakeup API with no ESP32-S3 fallback, a separate pre-existing gap
that was simply hidden behind the panel driver's old #error. See
docs/hardware.md for details; CI's continue-on-error on this board
stays in place until that's fixed too.
2026-08-04 22:00:18 +00:00
tfaour fe5a2df074 Update remaining docs for the second panel/board (missed in the previous commit)
Build and push server image / test (push) Successful in 42s
Build and push server image / build-and-push (push) Successful in 3m32s
Build and push server image / deploy (push) Failing after 1m28s
docs/hardware.md and firmware/README.md were updated already; this
catches the root README, docs/architecture.md, docs/widgets.md, and
server/README.md -- all still described the project as single-panel/
single-chip (800x480, ESP32-C6 only) even after image_pipeline.py
stopped hardcoding that.
2026-08-04 20:50:29 +00:00
tfaour 474b92a282 Add server-side support for a second panel (13.3in Spectra 6 / EE02) and scaffold its firmware target
Build and push server image / test (push) Successful in 45s
Firmware build check / build-check (push) Successful in 2m50s
Build and push server image / build-and-push (push) Successful in 4m36s
Build and push server image / deploy (push) Failing after 1m34s
Server: Frame.panel_type (new column + migration) is auto-derived from
the device's reported board (X-Frame-Board), never user-set -- the
panel is a property of the hardware, not a picker in the UI.
image_pipeline's packing/render pipeline is parameterized by panel
geometry instead of hardcoded 800x480 globals, with the real confirmed
13.3in geometry (1600x1200) registered alongside the original 7.3in
panel. Existing 7.3in frames are unaffected (column default + board
mapping both resolve to the original panel).

Board identifiers are also renamed (devkit/xiao -> devkit_esp32c6/
xiao_esp32c6, plus new "ee02") since the EE02 board also carries a XIAO
module -- "xiao" alone stopped disambiguating hardware. The server
keeps accepting the legacy bare names indefinitely for already-flashed
devices.

Firmware: scaffolds a third build target (ee02, ESP32-S3 -- a real
chip-target change, not just a same-chip Kconfig variant like xiao) and
a new epd13in3e driver component skeleton. The actual panel init/LUT/
refresh register sequence isn't ported from vendor demo code yet (none
was available), so that component deliberately fails to compile
(#error) rather than risk sending unverified register values to real
hardware -- devkit/xiao are unaffected and build identically to before.
CI's ee02 build step is continue-on-error for the same reason.
2026-08-04 20:08:22 +00:00
tfaour 1d39e439ff Drop the last legacy widget-system and shared-token auth scaffolding
Firmware build check / build-check (push) Successful in 5m37s
Build and release firmware / build-and-release (push) Successful in 5m36s
Build and push server image / test (push) Successful in 1m37s
Build and push server image / build-and-push (push) Successful in 4m18s
Build and push server image / deploy (push) Failing after 1m20s
Server: migration 41 drops the pre-widget-system Frame columns
(mode/album_id/current_asset_id/queue/calendar_*/whiteboard_*, etc)
docs/widgets.md flagged as the deliberately-deferred Phase 6 cleanup,
with a raw-SQL backfill safety net for any frame that still somehow
lacks a Widget. Also drops legacy_token_enabled and the shared
MANAGEMENT_TOKEN fallback it gated in require_device/require_browser --
the per-frame manage_token/device_token flow (and the /m/ page) fully
supersede it now; MANAGEMENT_TOKEN's only remaining role is the
optional pre-setup claim gate. Confirmed with the maintainer that the
deployed frame is already off the shared token before removing the
server-side fallback.

Firmware: the captive portal's "Access Token" field and its NVS/
build_url plumbing only ever mattered for pointing new firmware at an
old pre-multi-frame server -- gone along with the server-side fallback
it fed. Version bump to publish the change.
2026-08-04 18:33:29 +00:00
tfaour 2868087467 Add a per-widget text-size picker for calendar/tasks legibility
Build and push server image / test (push) Successful in 42s
Build and push server image / build-and-push (push) Successful in 3m33s
Build and push server image / deploy (push) Failing after 1m24s
Calendar and tasks pack the most body text at the smallest default
sizes, so those two gear-icon dialogs get a "Text size" card (Normal/
Large/X-Large) alongside the existing Border card -- a new Widget-level
font_scale column with its own POST .../font-scale endpoint, same
Widget-property-not-config-field shape as border_style. Threaded through
every classic (calendar_render.py) and modern (html_render.py/
calendar_html_render.py) size calc via one shared panel_style.
scaled_size() so row heights/max_rows already derived from font size
re-fit around the bigger text automatically.
2026-08-02 03:23:27 +00:00
tfaour 09119e775f Deploy: retry "docker compose up -d" instead of guessing at a stale container
Build and push server image / test (push) Successful in 41s
Build and push server image / build-and-push (push) Successful in 3m56s
Build and push server image / deploy (push) Failing after 1m37s
The previous fix (kill anything on port 8420 before up) didn't help --
confirmed nothing was actually squatting on the port. The real cause,
per the maintainer: "up -d" run manually a few seconds after "down"
always succeeds, but scripted straight through (down && pull && up,
pull sometimes a no-op if the image is already cached) fails every
time. That's "down" returning before the OS/docker-proxy has actually
released port 8420 yet, not an orphaned container -- a timing race, not
a stuck process. Retrying "up -d" a few times with a short pause rides
out that race without needing to guess a fixed sleep long enough to
always cover it.
2026-08-01 12:46:05 +00:00
tfaour f209880fd0 Deploy: kill anything holding port 8420 before bringing the new container up
Build and push server image / test (push) Successful in 47s
Build and push server image / build-and-push (push) Successful in 4m14s
Build and push server image / deploy (push) Failing after 1m27s
"docker compose down" before "pull/up" (previous commit) didn't fix the
port conflict -- it only tears down containers this compose project
itself tracks, so a stale/orphaned container from an earlier deploy (or
anything else bound to 8420, especially with restart: unless-stopped
fighting back) slips through untouched and the new "up" fails with
"port is already allocated". This is root-cause-agnostic instead: find
and stop/remove *any* container publishing 8420, compose-managed or
not, right before pull/up. --remove-orphans on the down step too, for
services that used to be in the compose file and aren't anymore.
2026-08-01 12:05:58 +00:00
tfaour 455020cb1f Redo modern-style widgets in a "bold minimal" language, not just a reskin
Build and push server image / test (push) Successful in 55s
Build and push server image / build-and-push (push) Successful in 4m40s
Build and push server image / deploy (push) Failing after 1m33s
The first modern-style rollout translated each widget's existing classic
layout into HTML/CSS -- same gradient headers, same rounded-shadowed
card, prettier chrome around an unchanged composition. This actually
redesigns weather (current/daily), calendar (all four views), tasks, and
battery: no card/shadow anywhere, a slim accent-colored rule instead of
a full gradient banner (and only that rule dithers at the richer accent
amplitude now, not the header text sitting on it), and a dominant hero
value (temperature/percent) instead of a centered icon+number of equal
weight. Padding and type sizes scale as a clamped proportion of widget
size instead of fixed pixel values. Text and static/whiteboard are left
alone -- text already had zero chrome and its styling is user content,
not this system's to redesign; framed_image's card was already minimal.

Direction was picked from three divergent mockups reviewed with the
maintainer, then verified against the real render pipeline (actual
Chromium render, actual ordered dithering, actual theme system) rather
than just eyeballed -- that caught a day-section/month-grid divider
color (#e2e6ec) that's nowhere near this panel's 6-color palette and was
dithering to invisible white; fixed with a real black hairline in the
one place (month view) that still needed one.
2026-08-01 11:53:42 +00:00
tfaour 466efdb873 Deploy: docker compose down before pull/up, not just up -d
Build and push server image / test (push) Successful in 40s
Build and push server image / build-and-push (push) Successful in 3m29s
Build and push server image / deploy (push) Failing after 1m21s
up -d alone assumes the previous container releases port 8420 cleanly
before the new one binds -- it doesn't force that. The last three
deploys all failed with "port is already allocated" at exactly that
step; running compose down first (confirmed working when done manually
over SSH) guarantees the old container is fully stopped and removed
before the new one starts, closing the race.
2026-07-31 12:35:14 +00:00
tfaour e363db0e4e Clarify what a theme actually changes beyond the header accent
A theme's font_family applies to every text element in a modern-style
widget, not just the header title, and radius/shadow change the card's
whole look -- worth spelling out since the accent color alone
undersells how much a theme actually does, especially on text-heavy
widgets, and on the two widget kinds (battery, static/whiteboard) that
have no header at all.
2026-07-31 12:35:07 +00:00
tfaour 5f4f8f2ea7 Add a curated theme system for "modern" style widgets, inspired by Tesserae
Build and push server image / test (push) Successful in 44s
Build and push server image / build-and-push (push) Successful in 3m33s
Build and push server image / deploy (push) Failing after 1m27s
Frame.theme (7 presets in app/theme_tokens.py) drives font family,
corner radius, drop shadow, and an accent hue for every modern-style
widget's header/accent region. Rich accent colors (not just the 6 flat
panel inks) are approximated via denser Bayer stippling confined to just
that region (html_render.ordered_dither_regions), so icon/text content
elsewhere stays exactly as crisp as it is today -- verified directly
against real Chromium renders, both in unit tests and via run-server.
"classic" is a byte-identical no-visual-change default: weather's header
keeps its original fixed blue gradient, tasks/calendar keep their flat
THEME_* ink.

Themes are purely stylistic -- battery's charge-level color, calendar/
tasks' per-owner event chips, and text's own per-widget font choice are
never touched.
2026-07-31 10:34:28 +00:00
tfaour e331f5e5a1 Roll out "modern" HTML/CSS render style to every widget except photos
Build and push server image / test (push) Successful in 43s
Build and push server image / build-and-push (push) Successful in 3m51s
Build and push server image / deploy (push) Failing after 1m57s
Extends weather's experimental Chromium+Jinja2 render style to battery,
text, tasks, static image, whiteboard, and calendar (all four view
modes -- agenda/today_tomorrow/week/month), and gives the photos widget
its own genuinely independent palette + dithering strength.

Photos: Frame.photo_palette_rgb/photo_dither_strength (mirroring the
existing palette_rgb/dither_strength), with a second "Photos
configuration" card in Advanced Configuration. widgets/photos.py's
render() quantizes itself against these before returning -- no
render_panel changes needed, since photos is the only widget that
genuinely needs a different reference palette and can carry that
itself, the same way modern-style widgets already self-dither via
ordered_dither.

Battery/text/tasks/static image/whiteboard: same render_style pattern
weather established (render_style column, html_render.py build
function, Jinja2 template, dialog toggle). Static image/whiteboard get
their first-ever visual chrome (a rounded-corner shadowed card,
shared framed_image.html.jinja) since classic draws them with zero
frame at all. Fixed the same "preview endpoint bypasses render_style"
bug weather originally shipped with, for tasks/static/whiteboard/
calendar's preview endpoints.

Calendar: own module (app/calendar_html_render.py, mirroring
calendar_render.py's separation from the simpler widgets) covering all
four view modes, not just agenda -- reuses calendar_render's own
private helpers so event colors/times/weather/month-grid math match
classic exactly. Found and fixed two real cross-day layout bugs along
the way: a per-day header height that varied based on whether that
specific day had a weather entry (misaligning where every other day's
event rows started across the week/month grid), and regular-weight
small text being fragile under Bayer ordered dithering (out-of-month
day numbers degraded into unrecognizable speckle) -- fixed by using
bold everywhere and de-emphasizing via size instead of weight/gray,
since gray text has the same dithering fragility this project's PIL
renderers already avoid for exactly this reason.

Migrations 32-38 (Frame's two new columns, then one render_style column
per widget config table). 452 tests passing, including new dispatch/
migration coverage per widget type and a dedicated photos test proving
photo_palette_rgb produces genuinely independent quantization from the
frame's main palette_rgb.
2026-07-31 03:52:19 +00:00
tfaour c6dad191fb Fetch headless Chromium at container startup instead of build time
Build and push server image / test (push) Successful in 45s
Build and push server image / build-and-push (push) Successful in 4m17s
Build and push server image / deploy (push) Failing after 1m42s
build-and-push failed on the last deploy: chromium-headless-shell's
single ~181MB binary can't be split across Docker layers the way this
project's pip/npm installs were (those are many independently-
installable smaller packages; this is one file), and confirmed-failed
to push past the registry's per-layer size limit.

Moves the `playwright install chromium-headless-shell` step from the
Dockerfile to start.sh, caching into PLAYWRIGHT_BROWSERS_PATH on the
/data volume -- only the very first boot on a fresh volume downloads
it, every boot after that is a no-op check. The image itself no longer
grows by ~262MB, so nothing new gets pushed to the registry at all.
2026-07-31 02:18:36 +00:00
tfaour 8ea1c53ec3 Add experimental HTML/CSS "modern" render style for weather widget
Build and push server image / test (push) Successful in 39s
Build and push server image / build-and-push (push) Failing after 2m34s
Build and push server image / deploy (push) Has been skipped
The weather widget's icons/layout are hand-drawn PIL primitives -- clean
under quantization but flat, no gradients/shadows. Adds an opt-in
render_style="modern" (current/daily modes only) that instead renders a
Jinja2 template through a persistent headless-Chromium browser
(app/html_render.py), following the approach of Tesserae, an open-source
e-ink dashboard targeting this same panel family.

Key design points:
- The Chromium dependency (Playwright) is lazily imported only when a
  weather widget actually uses "modern" style, and the background browser
  itself only launches on first use -- every other widget type, and this
  one's own classic/hourly/multi_city paths, never pay for it.
- No Frame-level dithering setting needed: html_render dithers its own
  rendered widget to exact palette colors (Bayer/ordered, not
  Floyd-Steinberg) before compositing, so the shared whole-canvas
  Floyd-Steinberg pass sees zero quantization error there and leaves it
  untouched -- same trick draw_text/hand-drawn icons already use. Floyd-
  Steinberg keeps working unchanged for photos and every other widget.
- A "Load calibrated Spectra 6 preset" button in Advanced configuration
  offers a community-measured palette (data ported from
  paperlesspaper/epdoptimize, Apache 2.0) as an alternative starting
  point to the existing idealized DEFAULT_PALETTE_RGB -- fills the
  existing palette table, doesn't save by itself.

Known open risk, not resolved here: a headless Chromium binary is far
larger than the ~100MB single-layer limit that already forced this
project's pip/npm installs into split layers, and (unlike those) is a
single ~180MB file that can't be split across layers by ordinary
Dockerfile restructuring. Flagged prominently in server/Dockerfile and
docs/widgets.md -- treat this render style as experimental/local-only
until that's resolved.
2026-07-30 22:18:43 +00:00
tfaour d34eb1bf45 Modernize on-panel widget visuals: real typography, theme colors, gutter
Build and push server image / test (push) Successful in 38s
Build and push server image / build-and-push (push) Successful in 2m46s
Build and push server image / deploy (push) Successful in 58s
Introduces app/panel_style.py, a shared style module every render
module now draws through instead of independently duplicating margins/
colors/fonts: Inter Bold/Regular (already vendored, previously only
used by widgets/text.py) replace PIL's single-weight bundled default
font everywhere else; a per-widget-kind accent color (calendar=blue,
tasks=green, weather=black header) replaces plain black-on-white chrome
and is centralized in one THEME mapping so a future global theme only
needs to touch panel_style.py; a small per-widget gutter separates
adjacent widgets without touching grid.py's cell math; header bars,
color chips, and the battery icon get rounded corners.

Also drops the MUTED gray text color used throughout calendar_render.py
and weather_render.py -- a non-palette color that has no close match in
the panel's 6-ink palette and dithers into visible speckle once the
composited canvas is quantized. Secondary text now reads through size/
weight alone, always exact black.

widgets/battery.py and manage_overlay.py's previously-duplicated
battery-glyph-drawing code now share one implementation (panel_style.
draw_battery_icon). widgets/_shared.py's placeholder image is fixed to
use exact palette colors and route through image_pipeline.draw_text,
same as everything else -- it was quietly violating both rules already.

image_pipeline.draw_widget_border gains an opt-in radius param (default
0, unused by any call site) for a possible future rounded-border
setting -- doesn't touch the exact-corner-pixel behavior test_widget_
border.py already pins.

Deliberately out of scope: DEFAULT_PALETTE_RGB and the Floyd-Steinberg
quantization pipeline are untouched, per the prior reverted measured-
palette/OKLab attempt (05b417a/dfe9d701).
2026-07-30 03:12:17 +00:00
tfaour bcea090e73 Add LAYOUT_CONFIG_FIELDS step to the make-widget checklist
A new widget type shipping without an entry there fails silently --
no error, no test failure, it just saves/applies with an empty config
forever. Caught for real on the weather widget (37d57a1); adding the
step and a matching test-pattern bullet so the next widget type doesn't
repeat it.
2026-07-28 15:17:43 +00:00
tfaour 37d57a1f88 Fix saved layouts silently dropping weather widget settings
Build and push server image / test (push) Successful in 38s
Build and push server image / build-and-push (push) Successful in 2m37s
Build and push server image / deploy (push) Successful in 52s
LAYOUT_CONFIG_FIELDS never had a "weather" entry, so saving a layout
captured an empty config for any weather widget -- applying it back
(including via hold-to-cycle) reset mode/provider/city/units/etc to
defaults instead of restoring what was configured.
2026-07-28 14:44:15 +00:00
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
tfaour 7d34eca5d7 Bump firmware version to 1.4.0
Firmware build check / build-check (push) Successful in 2m53s
Build and release firmware / build-and-release (push) Successful in 2m51s
Release build for the per-widget button actions + hold-for-global-
action firmware changes (short/long press detection on next/back,
POST /frame/global-next|back). CI's firmware-build-check.yml already
confirmed both board variants compile clean at this commit.
2026-07-27 22:18:47 +00:00
tfaour fcf3aec4c0 Move button actions to per-widget config, add hold-for-global-action
Build and push server image / test (push) Successful in 36s
Firmware build check / build-check (push) Successful in 2m4s
Build and push server image / build-and-push (push) Successful in 3m12s
Build and push server image / deploy (push) Successful in 58s
Next/back button assignment moves from a frame-level "Button
assignments" card into each widget's own gear-icon dialog, prefilled
with a sane default at creation (photos/calendar -> advance/back,
whiteboard/weather -> check_now, others -> none). At most one binding
per (widget, button) now -- cross-widget execution order never
mattered since each widget's action only touches its own state.

New firmware capability: holding NEXT or BACK past a configurable
duration (min 3s, server-side default) triggers a frame-wide action
instead of the per-widget short-press one -- cycling saved layouts,
refreshing all widgets, or freezing/unfreezing every photo widget (see
app/global_actions.py). Firmware next/back checks gain the same
hold-duration polling the combo button already had; the threshold
comes from the previous wake's /frame/config fetch (persisted in NVS),
since this wake's button decision happens before that request.

Not done here: firmware/version.txt is intentionally left unbumped --
this hasn't been built or hardware-tested (no ESP-IDF toolchain in this
environment), so no firmware release build should be triggered yet.
2026-07-27 22:09:33 +00:00
tfaour 9911151d8d Add per-photo-widget lock (freezes current photo until unlocked)
Build and push server image / test (push) Successful in 33s
Build and push server image / build-and-push (push) Successful in 2m32s
Build and push server image / deploy (push) Successful in 57s
A "Lock this photo" button in the photos widget's dialog toggles
PhotoWidgetConfig.locked, which suppresses both the timer-elapsed
auto-advance and the advance/back button actions until unlocked. The
Layout tab canvas shows a lock badge on any locked photo widget's box.
2026-07-27 21:07:54 +00:00
tfaour 4e8c6e534b Install Node.js from Debian's own repo, drop NodeSource dependency
Build and push server image / test (push) Successful in 30s
Build and push server image / build-and-push (push) Successful in 2m34s
Build and push server image / deploy (push) Successful in 53s
deb.nodesource.com started intermittently 403ing today on both its
setup_*.x scripts and its GPG key (confirmed directly, not just via CI --
some setup_NN.x paths 403, others 200, no consistent pattern), and the
curl-piped-into-bash install pattern silently swallowed that failure
instead of breaking the build loudly: curl -f exits non-zero on a 403,
but bash then runs on empty stdin and exits 0, so the RUN kept going
into a broken fallback (Debian's own split nodejs package with no
bundled npm) rather than stopping.

This base image now tracks Debian trixie, whose own nodejs package
(20.19.2) is inside jsdom 29's engines range and clears express/
resvg-js's much lower floors -- the version gap that originally required
routing through NodeSource is gone, so this drops that whole external
dependency (and the curl/gnupg install-then-purge dance) rather than
just swapping to a different NodeSource script.
2026-07-27 20:40:30 +00:00
tfaour b15747a604 Add per-widget border option (style, thickness, palette color)
Build and push server image / test (push) Has been cancelled
Build and push server image / build-and-push (push) Has been cancelled
Build and push server image / deploy (push) Has been cancelled
A Widget-level property (border_style/border_thickness/border_color_index),
not a per-type config field, since every widget type can have one -- drawn
once centrally in device.py's _render_widgets before compositing, using
an exact panel palette color so it never dithers. Styles: solid, dashed,
dotted, and a fancy double-line picture-frame-mat look. Configurable from
a shared "Border" card in every widget's gear-icon dialog.
2026-07-27 19:51:04 +00:00
tfaour eb7127718b Add battery widget (device's own last-reported level, no live upstream)
Build and push server image / test (push) Successful in 30s
Build and push server image / build-and-push (push) Successful in 2m4s
Build and push server image / deploy (push) Successful in 50s
Shows Frame.battery_percent/battery_as_of, already set by every device
wake-on-battery report, plus routers/common.py's existing
battery_estimate_s time-remaining estimate -- nothing new to fetch or
cache. Compact (icon + percent) or detailed (+ estimate, last report
age) display mode. No button actions.
2026-07-27 19:02:21 +00:00
tfaour 90a014d161 Revert to hand-drawn weather icons, styled after EC's set but exact panel colors
Build and push server image / test (push) Successful in 30s
Build and push server image / build-and-push (push) Successful in 2m4s
Build and push server image / deploy (push) Successful in 49s
The vendored EC bitmaps looked good but dither into a visible speckle
once quantized to the panel's 6-color palette (their colors are
anti-aliased/arbitrary RGB, essentially never an exact palette match).
Hand-drawn icons filled with the frame's actual ink colors quantize with
zero dithering error to diffuse -- confirmed by running both through the
real quantize pass: the bitmap version speckles, the hand-drawn one is
pixel-identical before and after.

Redrawn to look more like EC's style this time around: pointed
triangular sun rays (the earlier attempt's thin-line rays read as a
crosshair, not a sun) and dendrite snowflakes (tick marks near each tip,
not a bare asterisk), plus the same cloud/raindrop/lightning-bolt shapes
as before. Removed the vendored server/app/weather_icons/ directory
entirely -- no longer used, and removes the icon-image licensing
question along with it.
2026-07-27 17:32:51 +00:00
tfaour efb0f2e22d Swap hand-drawn weather icons for Environment Canada's real icon set
Build and push server image / test (push) Successful in 29s
Build and push server image / build-and-push (push) Successful in 2m10s
Build and push server image / deploy (push) Successful in 51s
The hand-drawn glyphs (draw_cloud/draw_sun/draw_raindrop/draw_snowflake/
draw_lightning_bolt) are replaced by 7 vendored bitmaps, one per shared
weather category, sourced from weather.gc.ca's public icon set -- these
are small, flat-shaded images that dither cleanly onto the panel's
6-color palette and read as recognizable weather icons in a way the
hand-drawn attempt (a plain circle-with-ticks "sun") didn't. Used for
every provider's rendering (Open-Meteo, NWS, EC), not just when EC is
selected.

Vendored (not fetched live at render time), matching this project's
existing convention for the Noto Emoji fonts -- server/app/weather_icons/
SOURCE.md documents the source, attribution, and the licensing caveat
(this is a personal, non-commercial project; the icon images' own
copyright terms are less clearly permissive than the weather data's own
End-use Licence, since they're served from the public website rather
than ECCC's data servers).

draw_weather_icon's signature changes from (draw, cx, cy, r, category,
palette_rgb) to (img, cx, cy, r, category): pasting a bitmap needs the
Image object, not just an ImageDraw handle, and palette_rgb is no longer
needed since the shared _quantize step already maps whatever's on the
composited canvas to the frame's actual palette -- no per-icon color
resolution required anymore.
2026-07-27 17:22:59 +00:00
tfaour 270979949f Add Environment Canada as a third weather provider
Build and push server image / test (push) Successful in 29s
Build and push server image / build-and-push (push) Successful in 4m32s
Build and push server image / deploy (push) Successful in 49s
app/weather/ec.py -- api.weather.gc.ca's MSC GeoMet OGC API
(citypageweather-realtime collection), the modern replacement for the
old dd.weatheroffice.gc.ca XML feed (that host no longer resolves).
Unlike Open-Meteo/NWS's simple lat/lon REST, this collection is only
queryable by bounding box, so _nearest_site widens the box
progressively and picks the closest of the ~844 sites by straight-line
distance -- capped at 300km, calibrated against a real bug caught in
development where an unconditional "nearest site, however far" matched
a Miami, FL query to a site in Ontario 1824km away once the box widened
to cover the whole country.

EC's own numeric icon codes get a small confirmed-against-live-data
mapping table plus the same keyword-on-condition-text fallback NWS
already uses for anything unmapped. Daily periods are named ("Today"/
"Tonight"/"Tuesday"/...) rather than dated, so dates are inferred by
walking them in issued order.

Verified end-to-end against the real live API (Toronto, rural
Saskatchewan, a US border city, and a rejected far-away match) and
through the browser (daily mode, composited panel preview). Test
fixtures mirror the actual response shapes captured live. docs/
widgets.md and CLAUDE.md's TODO updated -- EC is no longer a documented
gap.
2026-07-27 16:50:40 +00:00
tfaour 52ebafab78 Add standalone weather widget (current/hourly/daily/multi-city, pluggable providers)
Build and push server image / test (push) Successful in 1m11s
Build and push server image / build-and-push (push) Successful in 2m3s
Build and push server image / deploy (push) Successful in 52s
New widget type with four display modes -- current conditions, an
hourly forecast strip, a multi-day forecast, and several cities' current
day side by side -- backed by a pluggable provider registry (app/weather/,
mirroring the app/widgets/ dispatch pattern): Open-Meteo (worldwide) and
NWS (US-only) both wired up now, Environment Canada documented as the
next one to add given its more involved station/grid-lookup API.

The calendar widget's existing embedded weather strip is untouched and
still Open-Meteo-only; this lifts the same underlying icon-drawing
primitives (now shared via app/weather_render.py, calendar_render.py
still imports draw_weather_row unchanged) into a widget that can be
placed and sized on its own. Icons are redrawn in the panel's actual ink
colors (yellow sun/bolt, blue rain/snow) instead of flat black, and
build_multi_city's icon/font sizing now scales with how many cities need
to fit rather than the box's height alone -- both fixed after catching
them via live browser verification, along with a mode-switch cache-shape
crash and a mobile-width dialog overflow.

New WeatherWidgetConfig table (migration 24), grid footprint, widget
module, common.py fetch/cache helper, router endpoints (location set/
clear, city add/remove, preview), dialog template + JS, and full test
coverage (providers, widget render, HTTP endpoints, migration replay).
docs/widgets.md and CLAUDE.md's TODO updated accordingly.
2026-07-27 16:16:43 +00:00
tfaour 6118705c37 Fix corrupted text in CLAUDE.md
Two bullets had a stray "a \"coming up this week\" widget" phrase
overwriting their actual sentence ending (the AGPL bullet's "silently
accepting it)" and the mobile-breakpoint bullet's "below it"), likely
from an earlier bad edit. Restored both from git history; the phrase
still exists correctly once, as its own CURRENT TODO bullet.
2026-07-27 14:46:17 +00:00
tfaour 5247f5e512 Merge remote-tracking branch 'origin/main'
Build and push server image / test (push) Successful in 1m15s
Build and push server image / build-and-push (push) Successful in 5m1s
Build and push server image / deploy (push) Successful in 52s
Resolved CURRENT TODO conflict: kept Thomas's reformatted list and new
items, dropped the two scan-to-download lines this branch just finished.
2026-07-27 14:39:27 +00:00
tfaour 8bcc574f99 Update CLAUDE.md: mark scan-to-download TODOs done, add commit/push convention
Standing convention: commit and push once a task is verified working,
without waiting for a separate go-ahead each time -- see the new bullet
under Conventions specific to this repo.
2026-07-27 14:38:36 +00:00
tfaour c323402895 Fix scan-to-download auth and share every photo widget's current photo
The share QR's URL carried no auth params at all, so it silently fell
back through require_device's legacy-token resolution to whichever
frame happened to still be flagged legacy -- working only by accident
for a single frame, sharing the wrong frame's photos for any other, and
going fully dead once that frame's legacy flag was cleared.

Move the endpoint to manage.py, keyed on the frame's own manage_token
(same pattern /m/<manage_token> already uses) instead of device auth.
Since the server now resolves assets itself instead of trusting a
caller-supplied asset_id, it naturally generalizes to gather every
photo widget's current photo into one Immich share link, not just one
"primary" widget's.
2026-07-27 14:38:26 +00:00
tfaour 6fa3e2c2d2 Update CLAUDE.md
Updated todo
2026-07-26 23:39:33 -04:00
tfaour af513c1b5a Added a todo 2026-07-27 02:33:52 +00:00
tfaour c7b164e6cd Update CLAUDE.md 2026-07-26 18:40:40 -04:00
tfaour 5dfa6c8197 Update CLAUDE.md 2026-07-26 09:15:30 -04:00
tfaour e362519261 Update CLAUDE.md
Just adding some todos
2026-07-25 15:08:41 -04:00
Thomas Faour c1c657c0a9 Saved layouts feature
Build and push server image / test (push) Successful in 29s
Build and push server image / build-and-push (push) Successful in 2m1s
Build and push server image / deploy (push) Successful in 56s
2026-07-25 18:18:48 +00:00
Thomas Faour edbd90745b Add font family choice to the text widget, move toolbar below the editor
Build and push server image / test (push) Successful in 29s
Build and push server image / build-and-push (push) Successful in 2m1s
Build and push server image / deploy (push) Successful in 51s
Six more vendored families alongside the existing Noto Sans (Inter,
Source Sans 3, Noto Serif, Crimson Text, Arvo, IBM Plex Mono -- sans/
serif/slab/mono variety), all OFL-licensed with their own per-family
license file in app/fonts/ since each has a different copyright holder.
Static Regular/Bold/Italic/BoldItalic builds only -- variable-font-only
families (Inter and Source Sans's current Google Fonts releases, plus
Playfair Display/Lora/Merriweather) were skipped in favor of static
builds from their own upstream repos, keeping every family's loading
code uniform with what was already there. Considered but deliberately
left out: Georgia -- a proprietary Microsoft core font, not freely
redistributable, unlike everything else vendored here.

Also moves the bold/italic/underline/color toolbar below the
contenteditable box per request, and reorders the dialog's Settings
card to a more natural family-then-size order.

Fixes a latent migration bug this surfaced: migration 20 (static image
widget) used Base.metadata.create_all, which creates every table
declared in Base.metadata that's missing, not just its own new one --
harmless when nothing else pending, but once TextWidgetConfig existed
it would silently pre-create text_widget_configs (in whatever shape
models.py currently declares) before migration 21 got a turn, so
migration 21's own CREATE TABLE (or a later ALTER TABLE adding
font_family) would collide with a table create_all had already leaked
into existence. Both migrations 20 and 21 now use raw, frozen CREATE
TABLE SQL instead, matching migration 17's existing precedent for
exactly this reason.
2026-07-25 16:05:22 +00:00
Thomas Faour 3735c5bfa7 Add a text widget (rich text: bold/italic/underline, per-run color/highlight)
Build and push server image / test (push) Successful in 27s
Build and push server image / build-and-push (push) Successful in 1m59s
Build and push server image / deploy (push) Successful in 1m9s
A new self-contained widget type showing user-authored rich text -- no
live upstream to poll, like the static image widget, just word-wrapped
styled text instead of an uploaded image.

The dialog's contenteditable HTML is never stored or replayed as HTML:
app/text_content.py parses it server-side (on save) into a plain
paragraphs-of-styled-runs structure -- the actual sanitization
boundary, since raw HTML never round-trips back into any browser DOM
(the dialog rebuilds its editor from that same JSON via
createElement/textContent). app/widgets/text.py renders it with a
custom word-wrap/shrink-to-fit layout, using real vendored font weights
(app/fonts/NotoSans-{Regular,Bold,Italic,BoldItalic}.ttf, OFL-licensed
like the emoji fonts already there) rather than every other widget's
single ImageFont.load_default() -- the one widget type where that
distinction matters.
2026-07-25 14:21:52 +00:00
Thomas Faour f1fda9bdee Move make-widget/run-server skills to root .claude/skills/
Nested .claude/skills/ dirs (previously under server/) are only
auto-discovered on-demand once a file under that subdirectory is
touched, so /make-widget and /run-server weren't invocable from a
fresh session. Root .claude/skills/ is scanned at session start.

Fixes setup.sh/start-server.sh's relative cd-depth math (was hardcoded
for the old server/.claude/skills/run-server/ depth) to instead
resolve the repo root via git and cd into server/ explicitly, and
updates SKILL.md/driver.py's path references to match the new layout.
2026-07-25 12:09:39 +00:00
Thomas Faour b2f63601c0 Add a make-widget skill; belatedly document the static image widget
Build and push server image / test (push) Successful in 26s
Build and push server image / build-and-push (push) Successful in 1m59s
Build and push server image / deploy (push) Successful in 55s
The static-image widget shipped without updating docs/widgets.md's
per-type enumeration -- fixed, and added a skill encoding the full
file-by-file checklist a new widget type touches, so future ones (a
text widget is next) don't repeat either gap.
2026-07-25 11:56:14 +00:00
Thomas Faour 35e80c6d1c Add a static image widget (PNG/JPEG/GIF/BMP/WEBP/TIFF/PDF upload)
Build and push server image / test (push) Successful in 26s
Build and push server image / build-and-push (push) Successful in 2m1s
Build and push server image / deploy (push) Successful in 59s
A new widget type showing a single user-uploaded image with no live
upstream to poll -- decoded once at upload time (PDF's first page via
pypdfium2, BSD-3-Clause/Apache-2.0, no copyleft exposure) into plain RGB
PNG bytes, then composed per a crop/stretch/letterbox display mode like
the photos widget.
2026-07-25 08:39:09 +00:00
Thomas Faour 4a2b1f3795 Let a tasks widget have a custom on-panel name
Build and push server image / test (push) Successful in 25s
Build and push server image / build-and-push (push) Successful in 1m59s
Build and push server image / deploy (push) Successful in 53s
Adds TaskWidgetConfig.name (migration 19, plain column add) shown at
the top of the widget on the actual panel instead of the hardcoded
"Tasks" header -- e.g. "Chores" or "Mom's list". The only widget type
with its own on-panel title at all, since it's the only one where
"which list is this" isn't already obvious from a calendar/photo/
whiteboard's own content.

Threaded through calendar_render's _draw_tasks/_build_tasks/
render_tasks/render_tasks_preview_png as a `title` param (default
"Tasks", truncated to fit -- a long custom name shouldn't be able to
overflow the widget's box), the config-save endpoint (tasks_name,
truncated server-side to a sane header length rather than rejected),
and a new "Settings" section in the tasks dialog.

Verified live in the browser: the name actually renders at the top of
the real composited panel (not just the dialog preview, which stays
gated on having a configured source), and persists correctly on both
desktop and mobile. Full suite (196 tests) passes.
2026-07-25 04:05:36 +00:00
Thomas Faour 14c47aa2a0 Let a tasks widget merge multiple task lists, checkbox+color like calendar
Build and push server image / test (push) Successful in 27s
Build and push server image / build-and-push (push) Successful in 2m8s
Build and push server image / deploy (push) Successful in 59s
Tasks widgets could only ever point at one CalDAV task list (a radio-
button picker, owner-only). Now they merge any number of included task
lists across every linked user, same checkbox-inclusion + optional
pinned-color shape a calendar widget already has for its calendars --
FrameTaskList mirrors FrameCalendar exactly, down to the same owner-
adds/anyone-mutes permission split (api_widget_task_list_select/
api_widget_task_list_color). Reused calendar_render._event_colors/
_draw_color_bar as-is for the per-task color bar -- a task dict's
owner_display_name/color_index is exactly that function's single-
source fallback shape.

Also added an opt-in "show tasks completed in the last 24 hours"
toggle (TaskWidgetConfig.show_completed): caldav_client.fetch_tasks
now accepts a completed_since cutoff and returns completed VTODOs
(with their completion time) instead of silently dropping them, and
_draw_tasks gives a completed task a filled checkbox + muted text
instead of the normal empty-box/due-date row.

Migration 18 splits the single-source TaskWidgetConfig columns
(added by 17, splitting tasks out of the calendar widget in the first
place) into frame_task_lists, carrying forward each widget's existing
single source as its first included list -- same shape migration 9
used carrying forward frame_calendars' old single opt-in.

Verified live in the browser (desktop + mobile): the new "Included
task lists" + "Recently completed" dialog sections, the show_completed
toggle actually persisting through a real HTTP round-trip, and no
regression in the calendar widget's own "Included calendars" dialog.
Full suite (192 tests, including new merge_tasks/config_save/migration
coverage) passes.
2026-07-25 03:39:26 +00:00
Thomas Faour b5c52004c8 Split the tasks feature out of the calendar widget into its own widget type
Build and push server image / test (push) Successful in 24s
Build and push server image / build-and-push (push) Successful in 2m1s
Build and push server image / deploy (push) Successful in 56s
Task lists used to be a week-view-only sub-feature bolted onto calendar
widgets (CalendarWidgetConfig.tasks_*), so a task list could only exist
tied to a calendar's view and only inside its footprint. Tasks are now
a standalone widget type (TaskWidgetConfig, app/widgets/tasks.py) that
can be placed and sized independently, same as photos/calendar/
whiteboard -- no separate "enabled" flag either, since being on the
grid at all is the on/off switch, matching every other widget type.

Migration 17 creates task_widget_configs, extracts any existing
calendar widget's configured task source into a new sibling tasks
widget (auto-placed in open grid space, source dropped+logged if truly
none is left), then drops calendar_widget_configs' now-dead tasks_*
columns in the same migration -- this project's usual same-migration-
drop convention. Also handles the rarer case of a database jumping
straight from before the widget system existed to after this split in
one boot, via the legacy Frame.calendar_tasks_* columns.

Verified live in the browser at desktop and mobile widths: adding a
Tasks widget, its own dialog (task-list source picker + preview), and
confirming the calendar widget's dialog no longer mentions tasks at
all. Full test suite (180 tests, including new coverage for the widget
render/actions, the migration's data-extraction path, and the
permission-boundary shape for tasks-source) passes.
2026-07-25 02:11:45 +00:00
Thomas Faour 9f3f4b6f62 Add a "Clear all" button to the Layout tab's widget canvas
Build and push server image / test (push) Successful in 23s
Build and push server image / build-and-push (push) Successful in 2m1s
Build and push server image / deploy (push) Successful in 54s
One request (DELETE /api/frames/{id}/widgets) removes every widget on
the frame in a single locked transaction, cascading their configs and
button-action bindings the same way single-widget delete already does.
Gated behind confirm() like the existing per-widget remove button, and
disabled when there's nothing to clear. Covered by the same owner/
unrelated-user/linked-but-not-controlling permission shape used
elsewhere (test_widget_placement.py).

Also fixes a real, pre-existing mobile bug this surfaced: the Layout
page's .layout grid used a bare `1fr` track on the <860px breakpoint
instead of `minmax(0, 1fr)` like the desktop rule already does, so a
wide enough descendant (previously nothing hit this; the new title-row
button did) would force the whole page into horizontal scroll on phone
widths. Verified before/after with the run-server driver's new
`viewport` command.
2026-07-25 01:32:25 +00:00
Thomas Faour 0e35735a2a Add mobile-viewport testing to run-server + require it in CLAUDE.md
Build and push server image / test (push) Successful in 24s
Build and push server image / build-and-push (push) Successful in 2m2s
Build and push server image / deploy (push) Successful in 55s
Confirmed the theatre-mode preview dialog actually renders correctly
at phone widths (390x844) -- centers properly, backdrop and close
button both fine, nothing overflows. Added a `viewport` command to the
driver (defaults to mobile, since that's the step easy to skip) and a
CLAUDE.md rule to screenshot both breakpoints for future UI changes,
since the 860px sidebar/mobile-bar fork is a real, previously-hit
source of bugs here.
2026-07-25 01:19:44 +00:00
Thomas Faour 20c7620393 Add a run-server skill for launching + browser-driving the FastAPI app
Build and push server image / test (push) Successful in 23s
Build and push server image / build-and-push (push) Successful in 2m0s
Build and push server image / deploy (push) Successful in 58s
This sandbox ships with no Python/Node/Docker/browser and no sudo, so
the bulk of this is setup.sh: bootstrap Python via uv, then get
Playwright's Chromium (and tmux, also missing) working by extracting
their .deb dependencies non-root instead of apt-get install. driver.py
is a small Playwright REPL standing in for chromium-cli, which isn't
available here either.

Also carves out .claude/skills/ from the blanket .claude/ gitignore --
skills are shared project tooling, not personal/local state.
2026-07-25 01:12:53 +00:00
Thomas Faour 173d82a238 Add theatre-mode dialog for the frame header preview thumbnail
Build and push server image / test (push) Successful in 23s
Build and push server image / build-and-push (push) Successful in 2m0s
Build and push server image / deploy (push) Successful in 55s
Clicking the small preview thumbnail now opens an enlarged version in
a dialog (fresh render, same as the old click-to-refresh), closable
via the X button or a backdrop click -- mirroring #widget-dialog's
existing pattern.
2026-07-25 00:50:55 +00:00
tfaour 914eaed71c Add CLAUDE.md and docs/widgets.md; fix stale single-mode architecture docs
Repo-tracked context so a fresh Claude Code session (this machine or a
remote/cloud one) gets accurate project context without relying on this
session's local, machine-specific memory: repo conventions (no
co-author trailers, flag copyleft deps explicitly, scope security
gates broadly -- each backed by a real past incident), testing/deploy
workflow, and pointers into the existing docs.

docs/architecture.md's sequence diagram and boot-flow text still
described the pre-widget-system single-photo-queue model (e.g. "force-
advance to next queued photo") even though that was fully replaced by
the widget system across this branch's recent history -- fixed, and
added docs/widgets.md distilling the widget system's actual design
(data model, grid placement, compositor, button-action dispatch) as
current-state documentation, including the still-open Phase 6 cleanup
(legacy Frame columns not yet dropped) as a known gap.
2026-07-24 20:04:08 -04:00
tfaour 289d308b57 Disambiguate same-type widgets in the button-assignment dropdowns
Build and push server image / test (push) Successful in 22s
Build and push server image / build-and-push (push) Successful in 2m0s
Build and push server image / deploy (push) Successful in 56s
Two widgets of the same type (e.g. two Photos widgets) both showed up
as plain "Photos" with no way to tell which was which. The buttons API
now includes each widget's grid placement plus the frame's grid dims;
the UI derives a rough position ("top-left", "right", etc.) from that
and only appends a number+position suffix when a type actually
repeats on the frame, leaving the common single-widget-per-type case
unchanged.
2026-07-24 18:00:01 -04:00
tfaour 8b9f636cce Mark whiteboard as (alpha); add the Phase 5 button-assignment UI
Build and push server image / test (push) Successful in 22s
Build and push server image / build-and-push (push) Successful in 1m59s
Build and push server image / deploy (push) Successful in 52s
Whiteboard rendering isn't fully reliable yet -- tag it (alpha)
everywhere it's user-facing (widget label, add-widget button, dialog
title, Settings' WebDAV section) via one shared WIDGET_LABELS map
(moved to common.js so both the Layout canvas and the new Configuration
tab section can use it).

Button assignments: a new "Button assignments" card on the
Configuration tab lets you assign an ordered list of (widget, action)
bindings to each physical NEXT/BACK button -- add/remove/reorder, all
autosaved. Backed by new GET/PUT /api/frames/{id}/buttons endpoints;
PUT replaces a button's whole list in one atomic, fully-validated call
rather than separate add/remove/reorder endpoints. Device-side
consumption already existed (routers/device.py's _run_button_actions);
this is the UI for what was previously only reachable via the default
migration mapping.
2026-07-24 17:13:51 -04:00
tfaour 9c8a87e90d Add a live preview thumbnail next to the frame name
Build and push server image / test (push) Successful in 22s
Build and push server image / build-and-push (push) Successful in 1m59s
Build and push server image / deploy (push) Successful in 48s
Small header thumbnail showing exactly what the frame is currently
displaying -- same widget compositor /frame/image uses, handed back as
a plain PNG (image_pipeline.render_panel/render_placeholder gain an
as_png option) instead of packed native-panel bytes. New session-authed
GET /api/frames/{id}/preview exposes it; click-to-refresh plus a slow
60s poll on the frame header so it doesn't hammer Immich/calendar
sources just for a header thumbnail.
2026-07-24 15:55:59 -04:00
tfaour 82f60ed428 Auto-reset device_token_ack when a reprovisioned frame is reclaimed
Build and push server image / test (push) Successful in 21s
Build and push server image / build-and-push (push) Successful in 1m58s
Build and push server image / deploy (push) Successful in 53s
Once set, device_token_ack permanently locks out id-only requests
(auth.require_device) -- fine for a device that still has its token,
but a reprovisioned device has wiped its own token locally and had no
way back in short of a manual DB edit. The device's captive portal
always redirects the phone to /claim?device_id=... after
(re)provisioning, so reopen the handshake window there instead,
scoped to a logged-in owner/linked user of that frame.
2026-07-24 15:30:45 -04:00
tfaour 569bf733e9 Show save/error status inside the open widget dialog, not behind it
Build and push server image / test (push) Successful in 21s
Build and push server image / build-and-push (push) Successful in 2m0s
Build and push server image / deploy (push) Successful in 54s
showStatus() always wrote to the page-level #result div, which sits
behind a <dialog>'s backdrop -- a save inside a widget's gear-icon
dialog produced a message the user couldn't see without closing the
dialog first. It now prefers a .dialog-result element inside whichever
<dialog> is currently open, falling back to #result everywhere else.

The dialog is a flex column with its body scrolling independently so
.dialog-result stays pinned as a visible footer regardless of scroll
position -- otherwise a save message on a long form (e.g. the calendar
dialog) could land off-screen below the fold with no visible feedback
at all.
2026-07-24 14:48:50 -04:00
tfaour cb11ffdd2d Fix Load Albums button in the photos widget dialog
Build and push server image / test (push) Successful in 21s
Build and push server image / build-and-push (push) Successful in 2m4s
Build and push server image / deploy (push) Successful in 56s
The dialog calls /albums via window.FRAME_API, but that's repointed to
this widget's own API base while a dialog is open (see frame_layout.js)
-- /albums is frame-level (lists the owner's whole Immich library, not
scoped to one photo widget), so it needs window.FRAME_BASE_API instead.
The button silently 404'd since the resulting URL never existed.
2026-07-24 14:39:01 -04:00
tfaour a33a3a71e4 Widget system Phase 4b: per-widget gear-icon config dialogs
Build and push server image / test (push) Successful in 21s
Build and push server image / build-and-push (push) Successful in 1m57s
Build and push server image / deploy (push) Successful in 52s
Replaces the Photos/Calendar/Whiteboard tabs with a single Layout page
(now the frame's landing route) where each widget gets a gear icon
opening a dialog scoped to that specific widget's own settings. This
was the missing piece for genuinely independent same-type widgets --
"the Calendar tab" never made sense once a frame could hold more than
one calendar widget with different settings.

Data layer: FrameCalendar re-keyed from frame_id to widget_id, so each
calendar widget has its own independent included-calendars set. The
rekey runs as an unconditional post-startup step (like the existing
widget backfill), not a numbered migration -- it depends on calendar
widgets already existing, which themselves come from that same
backfill step, not from schema migration. Registering it as a numbered
migration would have run it first during a real upgrade, silently
dropping every row; caught by a new test that exercises the raw-SQL
upgrade path instead of the fresh-install create_all() shortcut every
other migration test takes.

API layer: every endpoint that used to assume "the frame's widget of
this type" (photo queue/thumbnail/preview, calendar select/color/
tasks/weather, whiteboard source/browse/preview) moved into
api_widgets.py under /api/frames/{id}/widgets/{widget_id}/..., with a
new require_widget_view/control dependency pair mirroring the existing
frame-level ones. Device status (battery/last-seen/firmware) got its
own frame-level /status endpoint, split out of the old photo-specific
/queue it used to piggyback on -- fixes the status bar going silently
blank on any frame without a photo widget.

UI layer: each widget type's existing settings markup/JS was ported
into a dialog partial + an explicit init/close function pair (the
content is now fetched and injected on demand, not loaded at page load
time). window.FRAME_API is repointed to the open dialog's widget-scoped
API base for its duration and restored on close; a separate
window.FRAME_BASE_API stays stable for the always-present header/
status-bar scripts.

Caught during manual browser testing: the consolidated config-save
endpoint initially expected a JSON body while the copied-over dialog JS
posts form-urlencoded data (the old convention) -- fixed to match, with
new HTTP-level test coverage that would have caught it immediately.
2026-07-24 14:31:24 -04:00
tfaour 63751a79ad Update .gitea/workflows/server-docker-build.yml
Build and push server image / test (push) Successful in 19s
Build and push server image / build-and-push (push) Successful in 2m30s
Build and push server image / deploy (push) Successful in 54s
Fix user name
2026-07-24 13:39:02 -04:00
tfaour 86b94a9e64 Auto-deploy the server image to production over SSH after a build
Build and push server image / test (push) Successful in 19s
Build and push server image / build-and-push (push) Successful in 1m56s
Build and push server image / deploy (push) Failing after 1s
Adds a deploy job to the existing build-and-push workflow: SSHes into
the deploy host as a dedicated espressoframeuser account and runs
docker compose pull && up -d. Runs only after build-and-push succeeds,
using a key/host pulled from repo secrets (DEPLOY_SSH_KEY, DEPLOY_HOST,
optional DEPLOY_PORT).
2026-07-24 13:34:50 -04:00
tfaour 77fe78d874 Fix Layout tab canvas not rendering on mobile
Build and push server image / test (push) Successful in 20s
Build and push server image / build-and-push (push) Successful in 1m58s
The placement canvas sized itself with CSS aspect-ratio plus
percentage widths/heights on the widget boxes. aspect-ratio isn't
supported on every mobile browser this app gets viewed from, and
without it a percentage height on an absolutely-positioned child
collapses to 0 against an indeterminate-height ancestor -- the canvas
rendered with no visible size at all, just unstyled labels and
buttons floating in normal document flow. Confirmed working on
desktop, broken on the reporter's phone.

Canvas and widget-box geometry is now computed and applied in pixels
from JS (measuring the wrap's own width, deriving height from the
grid's row/col ratio), recalculated on window resize. Also gives
.widget-box's color-mix() background a plain-color fallback for the
same class of older-browser gap.
2026-07-24 13:08:09 -04:00
tfaour 5d4bb53b8a Widget system Phase 4a: widget CRUD + grid placement UI
Build and push server image / test (push) Successful in 19s
Build and push server image / build-and-push (push) Successful in 2m32s
Adds the actual "Android home screen" placement experience: a new
Layout tab with a pointer-driven canvas for dragging/resizing widgets
and adding new ones from a type picker. Backed by a new
routers/api_widgets.py (create/move/delete), which re-validates
bounds, minimum footprint, and no-overlap server-side regardless of
what the client already checked. A widget added without an explicit
position lands in the first open space that fits it
(grid.find_open_rect), so users don't have to hunt for empty space
themselves.

Also fixes a real latent bug this surfaced: changing a frame's
orientation swaps the widget grid's long/short axis, which left
existing widget placements out of bounds on the new grid with no
render-time safeguard. Orientation changes now reset the layout to a
single full-panel widget (keeping the first widget's type, dropping
the rest), with a client-side confirm before it happens.
2026-07-24 12:48:56 -04:00
tfaour 99069ba5fe Widget system Phase 3: calendar widgets become size-aware
Build and push server image / test (push) Successful in 19s
Build and push server image / build-and-push (push) Successful in 1m58s
calendar_render.py's _build_* functions now take a real target box
and pick font sizes/margins from three discrete size tiers (nearest
pixel-area fit) instead of always laying out at full panel size and
resizing after the fact -- a calendar widget placed smaller than the
full panel gets an actually-legible layout instead of shrunk text.
Month view falls back to agenda below the smallest tier, where 7
columns can no longer stay readable.

The old photo-inlay split (inlay_region/_content_region/_paste_inlay)
is deleted along with it -- arbitrary widget placement already
subsumes what a fixed half-panel split did, and every call site has
passed photo_inlay=None since the Phase 2 cutover.

Also adds HTTP-level test coverage for GET .../preview/calendar,
which had none before this -- it's what caught a stale photo_inlay
kwarg left over from the _build signature change that would have
TypeError'd on every request.
2026-07-24 09:38:10 -04:00
tfaour 37bd657299 Widget system Phase 2: full cutover to widget-based rendering
Build and push server image / test (push) Successful in 49s
Build and push server image / build-and-push (push) Successful in 1m56s
device.py's mode-keyed dispatch is replaced by a real compositor:
load a frame's widgets, compute pixel rects via app/grid.py, render
each through its widget module, and composite with render_panel.
Physical NEXT/BACK buttons now execute each frame's assigned
FrameButtonAction rows instead of one hardcoded per-mode action.

api_frames.py, manage.py, and common.py's build_manage_content are
repointed to read/write the frame's widget config rows instead of
the old Frame columns, and every settings page (Photos/Calendar/
Whiteboard tabs) now pre-fills its form from the same widget config
the write endpoints actually save to -- previously the read and
write sides would have silently diverged. The old mode selector and
photo-inlay checkbox are removed along with their now-inert wiring;
arbitrary widget placement subsumes what the fixed inlay split did.

Ships together with Phase 1 (per-type render/action modules) since
splitting the read/write cutover across deploys would have left
settings changes with no visible effect.
2026-07-24 09:26:28 -04:00
tfaour f48daa71c8 Widget system Phase 1: per-type render/action modules
Build and push server image / test (push) Successful in 17s
Build and push server image / build-and-push (push) Successful in 2m2s
New app/widgets/ package (photos.py, calendar.py, whiteboard.py, plus
the WIDGET_TYPES registry) -- the widget-system analogue of
routers/device.py's old RENDERERS/ADVANCE_RENDERERS/BACK_RENDERERS,
generalized from "one mode owns the whole panel" to "each widget renders
into its own region and responds to named button actions." Each module
exposes render(db, frame, widget, target_w, target_h) -> Image.Image
(never raises -- a widget's own fetch hiccup falls back to a small
placeholder rather than taking the whole panel's render down) and an
ACTIONS registry for NEXT/BACK button assignment.

Supporting changes needed to give the widget modules something to call,
all mechanical/behavior-preserving for every existing caller:

- image_pipeline.py: render_panel(regions, ...) generalizes render_frame's
  tail (paste, enhance once, overlay once, quantize once, pack once) from
  one photo to N regions -- not a restructuring, since calendar mode's
  photo-inlay feature already pastes a second composed image onto the
  canvas before that single shared pipeline runs.
- photo_queue.py: advance_forced/back_forced/remove_from_rotation/
  get_current take an explicit `frame` param now that `cfg` won't always
  be the Frame itself once photo-queue state moves to PhotoWidgetConfig
  -- caught a real latent bug while doing this: get_current was reading
  refresh_interval_s off `cfg`, but that's a frame-level wake-cadence
  setting, not something that becomes per-widget, so it now reads that
  off `frame` explicitly instead.
- routers/common.py: list_assets/fetch_source_and_faces take album_id/
  display_mode directly instead of a whole Frame (both only ever read
  that one attribute off it); new get_or_refresh_*_for_widget siblings
  of the existing calendar/weather/tasks/whiteboard cache helpers, read/
  writing the new per-widget config tables -- the Frame-scoped originals
  are untouched and still what routers/device.py's actual dispatch calls
  until the Phase 2 cutover.

26 new tests (95 total): render_panel size/placement/orientation
coverage, and per-widget-type render/action tests (unconfigured ->
placeholder, a fetch failure -> placeholder not a crash, actions mutate
the right state). Full suite passes; diff-reviewed to confirm
device.py's actual RENDERERS dispatch and the old Frame-scoped
get_or_refresh_* bodies are unchanged, so this is safe to deploy on its
own despite being step 1 of a two-step cutover (see the project plan on
why the *next* step, not this one, has to ship atomically).
2026-07-24 08:44:53 -04:00
tfaour 8bc0749b42 Widget system Phase 0: data model + migration
Build and push server image / test (push) Successful in 19s
Build and push server image / build-and-push (push) Successful in 2m5s
First step of replacing Frame.mode (one renderer owns the whole panel)
with an Android-home-screen-style widget system -- a frame will hold N
independently placed/sized widgets (photos/calendar/whiteboard), each
with its own config/state, plus fully user-assignable NEXT/BACK button
actions. Full plan at .claude/plans/prancy-snacking-iverson.md.

This phase is additive only and changes no existing behavior -- nothing
reads these new tables yet:

- models.py: Widget (placement) + PhotoWidgetConfig/CalendarWidgetConfig/
  WhiteboardWidgetConfig (per-type 1:1 extension tables, matching this
  codebase's existing convention of dedicated tables for naturally-scoped
  state rather than one wide table) + FrameButtonAction (ordered
  (widget, action) bindings per physical button).
- grid.py: pure snap-to-grid placement math, defined relative to the
  panel's long/short axis so it stays valid across
  logical_render_size(orientation)'s genuine width/height swap for
  portrait, not just a rotation applied at the end.
- db.py: widget_locked(), the widget-scoped equivalent of frame_locked()
  -- deliberately still locks at frame granularity (not a new per-widget
  lock) to avoid a new class of multi-lock deadlock bugs.
- migration.py: _migration_16 creates the new tables; a separate
  _ensure_widgets_backfilled() (ORM-based, not raw SQL -- much less
  error-prone for this much per-mode branching) gives every existing
  frame a widget reproducing its exact current mode/settings, so
  upgrading changes nothing about what a frame displays or what its
  buttons do. calendar_photo_inlay frames specifically get two widgets
  (calendar + photo, split like the old inlay did) rather than silently
  losing the photo half.

10 new tests covering fresh-install backfill, re-run idempotency, the
photo-inlay two-widget case, whiteboard's check_now button mapping, and
migration_16's actual CREATE TABLE path against a simulated pre-existing
database (not just the fresh-install create_all() shortcut). Full suite
(69 tests) passes.
2026-07-24 08:27:47 -04:00
tfaour 1c67dd20d7 Auto-restart the whiteboard render sidecar if it crashes
Build and push server image / test (push) Successful in 16s
Build and push server image / build-and-push (push) Successful in 1m57s
Root cause of "whiteboard stuck on old content, no errors anywhere":
the Node sidecar had crashed at some point and, since it was just a
bare backgrounded process with nothing supervising it, stayed dead
permanently. Every refresh since then hit connection-refused, which
get_or_refresh_whiteboard treats as a soft failure and falls back to
the last successfully cached image -- so it looked exactly like a
caching bug from the outside, silently, forever, with no error visible
anywhere except a crash trace that had already scrolled out of the log
buffer.

Wrap it in a restart loop instead of a bare `&` so a future crash (a
still-unknown third jsdom/Excalidraw edge case, most likely) is a
few-second hiccup instead of a silent permanent outage.
2026-07-23 21:22:17 -04:00
tfaour 1100580c2c Live-update whiteboard/tasks source display; add firmware CI build check
Build and push server image / test (push) Successful in 15s
Firmware build check / build-check (push) Successful in 2m6s
Build and push server image / build-and-push (push) Successful in 2m26s
Whiteboard and tasks-source save/clear used to just tell the user to
reload the page to see the change. Both endpoints always assign a
successful "set" to the calling user, so the new state is fully known
client-side already -- rewrite the "Currently using/showing ..." block
in place instead, no server round trip or reload needed.

Also adds .gitea/workflows/firmware-build-check.yml: builds both board
variants (devkit, xiao) on every push touching firmware/**, unlike
firmware-release-build.yml which only builds on a version.txt bump.
Verified both builds succeed locally against the actual ESP-IDF
toolchain before wiring this in.
2026-07-23 19:08:01 -04:00
tfaour 31adc34a19 Add a real pytest suite, gating the CI build
Build and push server image / test (push) Successful in 1m18s
Build and push server image / build-and-push (push) Successful in 2m12s
64 tests covering: auth/setup and the CSRF gate, the "owner adds their
own data, anyone linked can mute it" permission pattern shared across
calendar-select/tasks-source/whiteboard-source, migration correctness
(fresh install, idempotent re-run, expected columns), battery estimate
outlier rejection, calendar_feed's fetch/merge/partial-failure handling,
webdav_client's fetch/list-directory, the whiteboard force-refresh
throttle bypass and browse endpoint, and render-size invariants across
calendar views/orientations.

No DB/HTTP fixtures need Docker, Node, or a real Immich/CalDAV/WebDAV
server -- a fresh temp SQLite file plus a couple of small local HTTP
servers as test doubles cover it all. Table data is wiped and reseeded
between tests rather than relying on SQLAlchemy's transaction-rollback
isolation pattern, which needs a pysqlite event-listener workaround
app/db.py's engine doesn't have and has no reason to gain just for tests.

Wired into .gitea/workflows/server-docker-build.yml as its own job that
build-and-push now depends on, so a failing suite blocks the image push
rather than just running alongside it for show.
2026-07-23 18:51:54 -04:00
tfaour b4ca795003 Add a whiteboard file picker and force-refresh
Build and push server image / build-and-push (push) Successful in 1m57s
File picker: an optional WebDAV browse root in Settings
(User.webdav_base_url) plus a plain-PROPFIND directory listing
(webdav_client.list_directory) power a "Browse..." panel on a frame's
Whiteboard tab, so a file can be clicked into rather than typing its
exact WebDAV URL. Manual URL entry still works unchanged either way.

Force-refresh: get_or_refresh_whiteboard takes a force flag that skips
the fetch throttle entirely; the Whiteboard tab's refresh button now
passes it, so clicking it always re-fetches and re-renders instead of
possibly just re-showing the same cached image from within the last
~20 minutes.
2026-07-23 18:20:39 -04:00
tfaour 8556221b08 Skip font inlining in whiteboard SVG export
Build and push server image / build-and-push (push) Successful in 2m6s
exportToSvg's font-embedding path (base64 @font-face rules) goes through
the browser FontFace API, which jsdom doesn't implement -- that's what
crashed fontFacesStylesGenerator after the previous global-shimming fix
got past startup. skipInliningFonts avoids that path entirely; resvg
already falls back to system fonts for rasterizing regardless, so
embedded fonts were never going to affect the final PNG.
2026-07-23 17:56:48 -04:00
tfaour dadd9ec164 Fix whiteboard render sidecar crash on startup
Build and push server image / build-and-push (push) Successful in 1m57s
@excalidraw/utils is a browser bundle -- it references devicePixelRatio,
location, matchMedia etc. as bare globals the way inline <script> code
would, not as window.foo. Only copying window/document/navigator onto
Node's global left everything else undefined, so the very first bare
reference threw a ReferenceError as soon as the module loaded. Copy
jsdom's entire window onto global instead, enable pretendToBeVisual so
jsdom actually populates devicePixelRatio/requestAnimationFrame, and
stub matchMedia since jsdom doesn't implement it at all.
2026-07-23 17:50:27 -04:00
tfaour c171047adf Split server Dockerfile into smaller layers
Build and push server image / build-and-push (push) Successful in 2m2s
The 413 from git.thumeit.com was on a single blob PUT, not the whole
image -- split the Node.js apt install and the render-service npm
install into several smaller RUN layers (purging curl/gnupg in the
same layer they're installed in, cleaning npm's cache after each
package) so no single pushed blob is as large as before.
2026-07-23 17:23:20 -04:00
tfaour afbe9db409 Revert "Push server image to local registry instead of git.thumeit.com"
This reverts commit 8ae09f238b.
2026-07-23 17:17:46 -04:00
tfaour 49794b4973 Revert "Configure BuildKit to allow plain HTTP to the local registry"
This reverts commit 1f62653118.
2026-07-23 17:17:46 -04:00
tfaour 1f62653118 Configure BuildKit to allow plain HTTP to the local registry
Build and push server image / build-and-push (push) Failing after 10s
Docker defaults to HTTPS for any non-docker.io registry, and
10.0.0.246:3000 (a bare LAN IP:port) isn't serving valid HTTPS -- the
push kept going out over HTTPS and failing. Adds a buildkitd config
telling BuildKit specifically to use HTTP for that host.

This only fixes the actual build+push step (BuildKit). The "Log in"
step runs a plain `docker login` through the classic Docker CLI/daemon
instead, which doesn't read this config -- that one still needs
10.0.0.246:3000 added to "insecure-registries" in the actual Docker
daemon's /etc/docker/daemon.json on whatever host runs the Gitea
Actions runner, followed by a daemon restart. That's runner-host
infrastructure outside this repo.
2026-07-23 17:12:33 -04:00
tfaour 8ae09f238b Push server image to local registry instead of git.thumeit.com
Build and push server image / build-and-push (push) Failing after 9s
Cloudflare (fronting git.thumeit.com) started rejecting image pushes
with 413 Payload Too Large once whiteboard mode's Node runtime + native
resvg bindings made the image significantly bigger than before. Switched
the build/push target to a LAN-local registry (10.0.0.246:3000) that has
nothing in front of it to hit that limit, and updated
docker-compose.yml.example to match.
2026-07-23 17:10:21 -04:00
tfaour 644fdefa66 Add whiteboard frame mode (Nextcloud Whiteboard / Excalidraw over WebDAV)
Build and push server image / build-and-push (push) Failing after 1m10s
New third mode alongside photos/calendar: fetches a .whiteboard file
over plain WebDAV (Basic auth -- generic, not Nextcloud-specific) and
renders it via a small Node.js sidecar using Excalidraw's own real
export code (@excalidraw/utils + @resvg/resvg-js, no headless browser),
since a .whiteboard file turns out to be Excalidraw scene JSON, not an
image. The sidecar runs as a second process inside this same container
(Dockerfile installs Node, start.sh backgrounds it before exec'ing
uvicorn) rather than a separate docker-compose service -- lightweight,
stateless, reachable only at 127.0.0.1 from the Python process, nothing
worth independently scaling.

The rendered PNG is treated exactly like a photo from there on --
composed/quantized through the existing image_pipeline (letterboxed,
never cropped) rather than a second parallel rendering pipeline.

WebDAV credentials support the common "it's actually the same Nextcloud
account as my CalDAV" case (an explicit opt-in checkbox, not silently
inferred) while still working with any WebDAV server generically.
Frame-level source (URL + owning account) follows the same owner-
controls-their-own-data permission split as calendar sources and the
week view's task list: only the account owner can point a frame at it,
anyone linked can clear it.

Honest limitation: this environment has no Node.js/npm, so
render-service/ is written carefully against each library's documented
API (verified via the npm registry, including transitive dependency
licenses after the CalDAV/AGPL surprise earlier this session) but has
never actually been executed. First real docker build is the first
true test -- see render-service/README.md.
2026-07-23 17:02:08 -04:00
tfaour 14cf212a60 Move Order and Display mode to the Photos tab
Build and push server image / build-and-push (push) Successful in 53s
Both are photos-specific settings (rotation order, how a photo's aspect
ratio gets reconciled with the panel), not device-wide configuration --
they belonged on the Photos tab next to the album/queue settings, not
Configuration. No backend change: both already save through the shared
partial-update /config endpoint regardless of which tab's form sends
them.
2026-07-23 09:07:49 -04:00
tfaour db9a6f1875 Week view: relative start-day offset for non-7-day counts; hide week-only settings elsewhere
Build and push server image / build-and-push (push) Successful in 53s
calendar_week_start's fixed-weekday anchor ("start on the most recent
Monday") stops making sense once the view isn't a literal calendar
week, so a non-7-day week view now starts calendar_week_start_offset
days from today instead (0 = starts today, negative/positive = past/
future) -- calendar_week_start still governs at the default 7 days,
unchanged.

Also hides the Calendar tab's week-only fields (days to show, layout,
start offset) unless View is actually set to Week, and further hides
the new start-offset field specifically when Days to show is 7 (where
it has no effect). "Week starts on" stays visible for Month too, since
it actually still applies there.
2026-07-23 08:17:25 -04:00
tfaour ce8525bee8 Week view flexibility: configurable day count, layout, and a CalDAV task list
Build and push server image / build-and-push (push) Successful in 52s
- Day count (2-10, was fixed at 7) -- 5 days trims the weekend clutter
  without losing the grid format.
- Layout choice: days side by side (original behavior) or stacked
  vertically as full agenda-style sections (reuses _draw_agenda_day,
  same approach _build_today_tomorrow already used for a fixed 2 days).
- Optional task list (CalDAV VTODO collections only -- a plain ICS
  subscription doesn't meaningfully have one) that takes the space of
  one day slot instead of adding an extra one. Same owner-controls-
  their-own-data permission split as calendar sources: only the
  calendar's owner can point a frame's task list at it, but anyone
  linked to the frame can clear it.

Browse-offset paging now moves by N days (was hardcoded to weeks),
identical to the old behavior when days=7. Changing the day count
resets the browse offset, same reasoning as changing views already did.
2026-07-23 07:58:13 -04:00
tfaour 01b9e9f1d0 Reject outlier readings in the battery estimate
Build and push server image / build-and-push (push) Successful in 51s
A single noisy ADC/regulator glitch (see firmware/main/battery.c)
survives the existing recharge filter: whichever way it reads, one of
the two steps around it (into a dip, or out of a spike) still looks
like an ordinary drop and got averaged straight into the remaining-
time estimate, letting one bad reading swing it dramatically.

Added a MAD-based modified z-score outlier check on top of the
existing recency-weighted average. Had to special-case the standard
MAD degenerating to exactly 0, which happens whenever more than half
the steps share the same value -- the norm for battery data (most
wakes cost the same small integer percent), and exactly the shape a
single spliced-in glitch among a steady discharge rate has, so the
naive case would have let the outlier this is for sail straight
through. Falls back to mean absolute deviation there instead.

Verified against constructed glitch scenarios in both directions
(spurious dip and spurious spike): estimate now comes out identical
to the same series with the glitch removed entirely.
2026-07-23 06:22:12 -04:00
tfaour 33af5408fd Render calendar emoji in full color
Build and push server image / build-and-push (push) Successful in 54s
Switched from the monochrome emoji font to color: NotoColorEmoji's
embedded CBDT bitmap glyphs, rasterized once at their native 109px
size and scaled to the target row height (unlike normal vector text,
color bitmap glyphs aren't stored at arbitrary sizes). Confirmed by
rendering an actual agenda row through the real quantizer that the
dithered-to-6-color result still reads clearly, not just muddy noise.

Falls back to the monochrome font if a deployment's Pillow/FreeType
wasn't built with embedded color bitmap support, so this degrades
instead of crashing or showing nothing.
2026-07-23 04:25:10 -04:00
tfaour 67d99dd6c0 Actually render emoji in calendar event titles; stack duplicate shared events
Build and push server image / build-and-push (push) Successful in 53s
The earlier fix stripped emoji instead of rendering them, which wasn't
what was asked for. Event titles now draw with two fonts: the usual
default font for text, and a vendored monochrome emoji font (Noto
Emoji, OFL-1.1) for actual emoji runs, so they show up as real glyphs
instead of a tofu box or nothing at all. Monochrome rather than color,
since reliably rendering COLR/CBDT color glyphs depends on how Pillow's
FreeType was built -- not something to depend on across deployments.

Also: events sharing the exact same title and time across different
calendars (e.g. a shared family event synced onto more than one
person's calendar) now collapse into one row instead of showing twice,
with a color bar split between every contributing calendar so it's
still clear whose event it is.
2026-07-22 23:13:34 -04:00
tfaour 7fc262f9c3 Strip emoji from calendar event titles before rendering
Build and push server image / build-and-push (push) Successful in 49s
ImageFont.load_default() has no emoji glyphs, and PIL/FreeType don't
skip an unsupported codepoint -- they substitute a visible ".notdef"
tofu box, which read as a rendering glitch rather than "not supported."
Stripped instead (covers the standard emoji Unicode blocks, skin-tone
modifiers, and the ZWJ used to combine them into one glyph), with
surrounding whitespace collapsed.
2026-07-22 22:58:06 -04:00
tfaour 27cd6b3703 Manual per-calendar color choice for calendar mode
Build and push server image / build-and-push (push) Successful in 49s
Each linked person can pin one of the panel's four non-black/white
colors (Yellow/Red/Blue/Green) to their own calendar instead of relying
on calendar_render.py's old auto-cycle-by-owner-name order -- owner-only,
like adding a calendar in the first place. Colors resolve against
whichever palette a frame actually renders with (including a custom
Advanced configuration override), so a pinned "Blue" stays this frame's
actual blue. Event color bars/dots are also bigger and rounded now
across agenda/week/month views, easier to tell apart at a glance.
2026-07-22 22:30:55 -04:00
tfaour ffce798754 Add weather to calendar mode; fix CalDAV events never showing
Build and push server image / build-and-push (push) Successful in 48s
Weather: multiple cities per frame, geocoded via Open-Meteo (no API
key), shown above the event list on agenda/today & tomorrow/week views
-- never month, no room for it there. Hand-drawn sun/cloud/rain/snow/
thunderstorm icons (no new font/icon asset, same primitives-only
approach the rest of calendar_render.py already uses). City geocoding
handles "City, State" qualifiers Open-Meteo's own search doesn't
(disambiguates same-named cities, e.g. the three "Portland"s).

CalDAV fix: events were never showing despite calendars discovering
fine, because fetch_calendar_events relied on the calendar-query
REPORT's server-side time-range filter, which real servers implement
inconsistently (confirmed against a real server, not just guessed --
reproduced locally with Radicale). Switched to fetching every event
unfiltered and doing all date-window filtering/expansion client-side,
same approach already used for plain ICS feeds.
2026-07-22 22:22:11 -04:00
tfaour acdb929a99 Move mode picker below device status bar; fix invisible calendar lines; add CalDAV calendar support
Build and push server image / build-and-push (push) Successful in 50s
Calendar rule/grid lines were light gray, which dithers away to
near-invisible on the 6-color e-ink palette -- now black.

CalDAV accounts (Nextcloud, Fastmail, iCloud, ...) can now be linked
alongside the existing single ICS subscription, since one account can
expose several calendars. A frame's Calendar tab now lists calendars
per person rather than one opt-in per person: your own row shows every
calendar you have available with a full add/remove toggle, while other
linked users' rows show only calendars they've included, toggleable
off (mute) but not on -- only a calendar's owner can add it to a
shared frame. FrameCalendar replaces the old single-boolean
UserFrame.calendar_included; existing opt-ins are migrated forward.
2026-07-22 22:01:05 -04:00
tfaour 95d69a5512 Rewrite battery-remaining estimate around per-wake drop rate
Build and push server image / build-and-push (push) Successful in 42s
The old estimate used a single linear percent/second rate from the
current discharge cycle's battery_history, which resets to empty on
every recharge -- so "not enough data yet" kept showing up despite the
frame having plenty of history overall, and the rate it did compute was
tied to whatever refresh interval produced it (changing the interval
didn't move the estimate until enough new history accumulated under
the new setting).

Now pulls the last 100 rows from the permanent battery_log table
instead, and averages the *per-wake* percent drop (not per-second) --
recharge jumps are skipped rather than counted as negative drain,
flat/zero-drop wakes still count so the rate isn't overstated, and
more recent steps are weighted more heavily. The per-wake rate then
converts to wall-clock time using the frame's current
refresh_interval_s and quiet-hours settings, so halving the refresh
interval roughly halves the estimate immediately, and quiet hours
correctly stretches it out (fewer wakes/day at the same per-wake cost).
2026-07-22 21:21:17 -04:00
tfaour 3a0007118c Move frame name/mode into the page header; Calendar gets its own tab
Frame name (pencil-icon inline edit) and the Photos/Calendar mode
selector now live in the page header, shared across all four per-frame
pages instead of being buried in the Configuration form -- so renaming
a frame or flipping its mode no longer requires navigating to a
specific tab first.

Calendar settings move out of a conditionally-hidden card on the
Configuration tab into their own dedicated tab (new /frames/{id}/
calendar route), visually greyed out when the frame is in Photos mode
but still fully usable so calendar settings can be configured ahead of
switching modes. Also wires the week-start setting into the UI for the
first time (the column/backend support landed earlier but had no
control anywhere).
2026-07-22 21:21:00 -04:00
tfaour aa194be09a Calendar mode polish batch + Today & Tomorrow view
Responds to post-launch feedback on calendar mode: configurable
week-start day for week/month views, crisper non-antialiased text
(threshold-masked instead of drawn straight, so Floyd-Steinberg
dithering doesn't speckle glyph edges), a color-coded/proportionally
filled battery icon on the manage overlay, word-wrapped placeholder
text so "Calendar isn't set up yet" no longer clips in portrait, photo
inlay support extended from agenda-only to every view, and a fix so
manage-overlay face labels reposition correctly when a photo inlay is
active (they previously assumed the photo filled the whole canvas).

Also adds a fourth calendar view, "Today & Tomorrow" -- a two-day
agenda that reuses the same per-day row-layout helper the single-day
agenda view already has.
2026-07-22 21:20:44 -04:00
tfaour 716776c3f2 Bump firmware to 1.3.0
Build and release firmware / build-and-release (push) Successful in 1m48s
Manage overlay now composited server-side.
2026-07-22 19:07:29 -04:00
tfaour 0f98d96d25 Untrack .claude/ (editor tooling, not project content) 2026-07-22 19:07:08 -04:00
tfaour c007acde75 Add calendar frame mode + server-side manage overlay (server)
Build and push server image / build-and-push (push) Successful in 42s
calendar_feed.py/calendar_render.py: fetch/merge per-user ICS feeds,
render agenda/week/month views. manage_overlay.py: composites the
manage-button overlay server-side (QR, battery, location/date,
share-QR, face labels), reused by every render mode. device.py/common.py
wire both together: mode dispatch for /frame/image+advance+back, and
the &manage=1 flag. Plus UI (frame_config.html Calendar card, settings
calendar URL field) and the icalendar/recurring-ical-events deps.
2026-07-22 19:06:49 -04:00
tfaour 15e37c77cd Simplify firmware: manage overlay now composited server-side
Deletes manage_qr_overlay.c/.h; frame_client.c's manage-button flow is
now one fetch with &manage=1 instead of on-device QR/text generation
plus separate photo-info/face-labels requests.
2026-07-22 19:06:49 -04:00
tfaour 1fa1c68478 Add calendar frame mode data model (migration 7)
New columns, all ADD COLUMN with inert defaults -- no existing frame's
behavior changes until mode is explicitly switched to "calendar":

- users.calendar_ics_url: one personal iCal/CalDAV subscription per
  user, same shape as the existing per-user immich_url/immich_api_key.
- user_frames.calendar_included: explicit per-(user,frame) opt-in,
  default off. Being linked to a frame does not by itself contribute
  your calendar to it -- each person's calendar is their own data to
  share, not something a frame's controller decides on their behalf.
- frames.calendar_view/calendar_photo_inlay/calendar_browse_offset:
  per-frame display settings and NEXT/BACK navigation state.
- frames.calendar_checked_at/calendar_cached_events/calendar_fetch_summary:
  the throttled merge-fetch cache, same shape as the existing
  firmware_update_checked_at/firmware_gitea_latest_version pattern.
2026-07-22 19:05:02 -04:00
tfaour fc65b19cf2 Bump firmware to 1.2.4
Build and release firmware / build-and-release (push) Successful in 1m47s
Trimmed-mean battery ADC sampling to reduce noisy readings.
2026-07-22 16:57:34 -04:00
tfaour e1bca5a81a Fix false recharge-cycle detection from a single noisy battery reading
Build and push server image / build-and-push (push) Successful in 38s
A report was flagged as "the battery got recharged" (resetting
battery_history and stats_recharge_cycles, and re-arming the low-battery
alert) whenever it came in >= RECHARGE_JUMP_PCT above the single
immediately-previous report. That's exactly what a real recharge looks
like, but it's also exactly what a normal reading looks like right
after one noisy low report: e.g. 60, 59, 58, then a stray 53, then back
to a perfectly normal 58 -- 58 >= 53+5 falsely read as a recharge.

Now compared against the max of the last RECHARGE_LOOKBACK (3) reports
instead of just the one before it, so a lone stray reading doesn't get
to set the bar a normal reading then trips. A real recharge still needs
to clear all of them, so genuine recharges are still caught immediately
(verified: 18% -> 90% still triggers, history still resets).

Paired with the firmware-side battery.c change (trimmed-mean ADC
sampling) that reduces how often a stray reading like the 53 above
happens in the first place.
2026-07-22 16:51:37 -04:00
tfaour 845e4f9509 Reduce battery-reading noise with a trimmed-mean ADC sample
Sometimes a single reading came in noticeably off from the real trend
(a regulator/RF transient during sampling), and the next normal reading
would then look like a big jump relative to that bad one -- server-side,
enough to misfire the recharge-cycle heuristic (see the paired server
commit). Went from 8 raw-averaged samples to 16, sorted, with the 3
extreme samples on each end dropped before averaging the remaining 10 --
a handful of outliers can no longer skew the result the way a plain
average let them.
2026-07-22 16:51:28 -04:00
tfaour 02934b1d10 Bump firmware to 1.2.3
Build and release firmware / build-and-release (push) Successful in 1m48s
Fix stack buffer overflow in face-labels parsing.
2026-07-22 16:35:34 -04:00
tfaour f24c3b9c8e Gate firmware auto-check behind control+CSRF; skip faces with a null bounding box
Build and push server image / build-and-push (push) Successful in 39s
/api/frames/{id}/firmware/check could silently stage new firmware as a
side effect (the auto-apply path, when firmware_auto_update is on and
a newer release exists) but was gated by require_frame_view instead of
require_frame_control like its sibling firmware routes, and being a
GET, was exempt from the app's CSRF check (which only applies to
non-GET/HEAD/OPTIONS). A linked viewer without control -- or a
cross-site page riding a control-holding victim's session via a plain
GET -- could trigger an unreviewed firmware install. Now POST +
require_frame_control, matching /firmware/apply-latest; the frontend's
two callers (passive poll on page load, "Check now" button) both
already handle a 409 from a non-controller gracefully via the existing
apiError()/control-banner pattern, so this doesn't change UX for a
frame's actual controller.

Separately: Immich has been observed to return a face detection entry
with a null bounding-box field (a still-pending or otherwise
incomplete detection). Both places that do arithmetic on those fields
-- image_pipeline._face_aware_crop_box (crop_faces display mode) and
face_labels.compute_face_labels (manage-menu name labels) -- crashed
with an unhandled TypeError on such an entry, taking down that frame's
whole photo instead of the intended graceful fallback. Both now skip
any face missing a bounding-box field via a shared _has_bounding_box()
check; a face list with zero valid entries already degrades cleanly to
the plain center crop (the existing inf/-inf sentinel math already
handled "no faces" correctly, it just couldn't tell "none passed
Immich" apart from "one broken entry" before).
2026-07-22 16:21:37 -04:00
tfaour 38944a1287 Fix stack buffer overflow in face-labels parsing
fetch_face_labels() clamped the server-reported label count against
max_labels by casting the count to int first -- a value >= 2^31 (a
perfectly ordinary decimal in JSON) went negative under that cast, so
the comparison was always false and the clamp never fired. The loop
then ran with the full, unclamped count, writing past the caller's
fixed MANAGE_FACE_LABELS_MAX-element stack array on a crafted
/frame/face-labels response. Reachable by a compromised/malicious
tools server, or a MITM on the default plain-HTTP connection.

Fixed by comparing unsigned instead of casting to int.
2026-07-22 16:21:23 -04:00
tfaour 5b4fdbe330 Scope thumbnail access to the frame's own photos; validate Gitea repo URL
Build and push server image / build-and-push (push) Successful in 38s
Two fixes from a security pass over the server:

- /api/frames/{id}/thumbnail/{asset_id} accepted any asset id and
  fetched it via the frame owner's Immich credentials, unscoped to what
  that frame actually shows -- a user merely linked to view a frame
  could pull thumbnails for any asset in the owner's whole library, not
  just the frame's own album. Now scoped to current_asset_id/queue,
  matching the check device.frame_share and manage.manage_thumbnail
  already both apply.

- firmware_update_repo_url now has to be a plain http(s) URL. Unlike a
  one-off manual firmware upload (a deliberate, explicit act -- left
  alone), auto-update from a repo is a standing trust relationship: the
  frame keeps fetching from it and, with auto-update on, installs
  whatever it finds with nobody reviewing it first. Added a plain-
  language note next to the checkbox saying exactly that.
2026-07-22 12:57:11 -04:00
tfaour dcbc71e683 Show "Not enough data yet" instead of hiding the battery-estimate row
Build and push server image / build-and-push (push) Successful in 40s
Previously the row just disappeared whenever battery_estimate_s
couldn't be computed yet, which looked like the feature was gone.
Now it always shows once there's any battery reading at all, with a
placeholder until enough discharge history accumulates (matches the
"Not enough data yet." wording battery_chart.js already uses for the
same situation on the chart).
2026-07-22 11:35:38 -04:00
tfaour f4d2a23e8a Drop "On battery for", clarify the remaining-estimate label
Build and push server image / build-and-push (push) Successful in 38s
"On battery for" was clutter next to the actual number people care
about. Relabeled "Est. remaining" to "Est. battery life left" and
dropped the now-unused on_battery_since field from the /queue response.

battery_estimate_s itself is unchanged -- it still needs 2h of span and
a 2% drop within the current discharge cycle (reset on any 5%+ jump,
i.e. a recharge or reflash) before it'll show anything. A frame that's
been power-cycled/reflashed recently won't have an estimate yet; that's
expected, not a regression.
2026-07-22 11:29:17 -04:00
tfaour 55b53d5bb2 Fix migration runner crashing on a genuinely fresh database
Build and push server image / build-and-push (push) Successful in 39s
_migration_1() is Base.metadata.create_all() -- it already builds
today's full schema straight from models.py. Every migration after it
is an incremental ALTER/UPDATE meant to bring an *existing* install
forward from an older version; replaying them against a brand-new
database collided with columns create_all had already added ("duplicate
column name"), crashing on first boot.

Found while testing the device-status-bar change against a scratch DB.
Every real deployment has been migrating forward incrementally since
before this bug existed, so it never showed up in practice -- but any
brand-new install would have hit it. Fresh databases now jump straight
to the latest schema_version after create_all; existing databases keep
applying whichever migrations are still pending, same as before.
2026-07-22 10:48:36 -04:00
tfaour 60fcfca4a0 Make the device status card always visible, not just on Stats
Build and push server image / build-and-push (push) Successful in 39s
Moved out of the Stats tab's side column into a new horizontal bar
shared by every frame page (Photos/Configuration/Stats), sitting
between the page title and the tabs so it's on screen regardless of
which tab is active.

_device_status_bar.html is a new partial included via a device_status
block in app_base.html; device_status_bar.js is the fetch/render/poll
logic extracted from frame_stats.js and adapted to a wrapping row of
label/value pairs instead of a stacked list. frame_stats.html's Device
card and now-single-card .side-col are gone -- Battery history and
Lifetime stats just stack directly.
2026-07-22 10:46:05 -04:00
tfaour 996e06e2bc Bump firmware to 1.2.2
Build and release firmware / build-and-release (push) Successful in 1m51s
Read battery once, after the picture is pushed, instead of at boot.
2026-07-22 10:32:46 -04:00
tfaour d324bc4a57 Read battery once, after the picture is pushed, not at boot
Build and push server image / build-and-push (push) Successful in 40s
battery_read_percent() was called once at the very start of boot, before
WiFi even connects, and that value was reused both for the manage-menu
overlay and the server report. Taken right after a reset (e.g. the OTA
reboot that immediately precedes it), the rail may still be settling --
plausible source of noisy jumps in reported battery level.

Now there's a single read, in frame_client_run() right before
report_battery(), after the photo (and manage overlay, if shown) is
already on the panel -- the fetch/display work already done this cycle
is the settle time, no delay to guess. The manage overlay no longer
needs an early local reading at all: it shows the server's last-known
value instead, added to the /frame/photo-info response it already
fetches.
2026-07-22 10:32:20 -04:00
tfaour ac4b57e611 Bump firmware to 1.2.1
Build and release firmware / build-and-release (push) Successful in 1m48s
Captive portal redirect fix.
2026-07-22 09:48:34 -04:00
tfaour 462b558bef Fix captive portal redirect: visible countdown, keep AP up until it finishes
Previously the softAP was torn down (esp_restart) only 1s after sending
the success page, while the page's own redirect timer waited 7s -- so
the AP (and the phone's captive-portal session with it) was gone long
before the redirect could fire. Now the page shows a live 10s countdown
before redirecting, and the device holds the AP up for 11s so the
countdown always completes. Also added a "Redirect now" button for a
phone that's already reconnected to normal WiFi.
2026-07-22 09:45:59 -04:00
tfaour 9f9ad34a40 Fix stray tab whitespace in DEFAULT_PALETTE_RGB
Build and push server image / build-and-push (push) Successful in 41s
2026-07-22 08:46:46 -04:00
tfaour e48ac50ea1 Color/contrast/dithering sliders + before/after render preview
Advanced configuration gains three sliders (PIL ImageEnhance factors
for color/contrast, 0-2, 1=unchanged; a 0-1 dithering strength) applied
to every photo this frame renders. Confirmed the parameter conventions
against a similar project (jwchen119/EPF: ImageEnhance.Color/Contrast,
1.0 baseline) before implementing; dithering strength isn't natively
exposed by PIL's quantize(), so it's implemented by blending the source
toward its own flat/undithered quantization before running Floyd-
Steinberg on the blend -- at 0 there's no quantization error left to
diffuse (exactly the flat result), at 1 it's the original unmodified
behavior, with a smooth continuum between rather than dithering being
an on/off toggle.

image_pipeline.py split into composition (_compose), enhancement
(_enhance), quantization (_quantize), and transpose+pack stages so
render_frame (device bytes) and the new render_preview_png (a normal
viewable PNG, upright logical orientation) share the same pipeline
instead of duplicating it. Named-face overlay label math (face_labels.py)
was already routed through the shared _placement_transform, so it
needed no changes for the new params.

Also added the requested before/after comparison: the Configuration
tab's new Preview card shows the current photo's untouched Immich
preview next to that same photo run through the frame's actual saved
rendering pipeline (two new GET endpoints, /preview/original and
/preview/rendered) -- immediate visual feedback for tuning the palette
and these new sliders. "Refresh preview" re-fetches after saving.

Schema migration v6 adds color_boost/contrast_boost/dither_strength,
defaulting to 1.0/1.0/1.0 -- reproduces the exact previous rendering
until a frame's Configuration tab changes one.

Verified against the live-shaped test database: the migration, sliders
persisting and clamping out-of-range input, both preview endpoints
(real JPEG passthrough / real PNG at correct logical size+orientation),
confirmed dither_strength=0 actually changes the rendered bytes vs.
default, and the standing legacy-device curl suite.
2026-07-22 08:46:22 -04:00
tfaour 83c59af1dd Update server/app/image_pipeline.py
Build and push server image / build-and-push (push) Successful in 46s
Fix pallette defaults
2026-07-22 01:33:56 -04:00
tfaour 49bc9f9ec9 Display mode: crop to fill, crop to faces, stretch to fill, shrink to fit
Build and push server image / build-and-push (push) Successful in 40s
Replaces the smart_crop_faces boolean with a 4-way display_mode select
on each frame's Configuration tab (image_pipeline.DISPLAY_MODES):

- Crop to fill / Crop to faces: the previous False/True behavior,
  unchanged (center-crop trimming excess, optionally shifted to keep
  faces on screen).
- Stretch to fill (new): fills the panel exactly, aspect ratio not
  preserved -- a plain resize, no crop.
- Shrink to fit (new): the whole photo visible, letterboxed with white
  where it doesn't fill the panel.

Named-face overlay label positioning (face_labels.py, the manage menu's
"who's in this photo") now goes through a shared _placement_transform()
in image_pipeline.py instead of duplicating crop-box math, so label
placement stays correct (and in-bounds) under all four modes, not just
the two crop ones -- letterbox/stretch never crop a face out, so labels
just use straight scale+offset math there.

Schema migration v5 adds display_mode, backfills it from the old
boolean (True/False -> crop_faces/crop_fill), and drops the boolean.

Verified against the live-shaped test database: the migration
(existing frames correctly preserved as crop_faces), the config page's
new 4-option select, actual renders under letterbox (confirmed real
white letterbox padding in the packed panel-code bytes) and
stretch_fill, invalid-input fallback, and the standing legacy-device
curl suite.
2026-07-22 01:32:52 -04:00
tfaour e802882fc1 Palette calibration: precise hex/RGB inputs instead of a color picker
Build and push server image / build-and-push (push) Successful in 41s
A native <input type="color"> swatch can't be typed into precisely --
no way to enter an exact measured value. Replaced with a small table:
a read-only preview swatch, a hex text field, and three 0-255 number
fields (R/G/B) per ink color, kept in sync live in both directions
(editing hex updates R/G/B and the swatch; editing any of R/G/B updates
hex and the swatch). Hex stays the field actually read at save time --
the server-side validation (#rrggbb via hex_to_rgb) is unchanged, this
is a client-side-only swap of the input widget.
2026-07-22 01:26:03 -04:00
tfaour 5b11f2accb Per-frame palette calibration + sidebar battery indicator
Build and push server image / build-and-push (push) Successful in 40s
Advanced configuration (Configuration tab, collapsed <details> section):
a color picker per ink color (black/white/yellow/red/blue/green),
overriding image_pipeline.DEFAULT_PALETTE_RGB for that frame's actual
panel -- different units can vary enough from the documented
approximations to be worth calibrating once you can compare a rendered
photo against the real hardware. Stored as Frame.palette_rgb (NULL =
default, schema migration v4), threaded through render_frame/
render_placeholder/_quantize_and_pack (which now builds the PIL palette
image per call instead of once at import) so both photos and the
unclaimed/unconfigured placeholder screen respect it. "Reset to
defaults" clears back to NULL. Config-save validates exactly 6 #rrggbb
values, rejecting anything else with a 400.

Also: each frame's sidebar entry now shows its last-reported battery
percent (🔋NN%) next to the name, using the frame_dot's existing
recently-seen indicator conventions -- silent when never reported
(mains-only frames, or before the first report), matching how battery
is hidden everywhere else it's not applicable.

Verified against the same live-shaped database as the SMTP work: the
v3->v4 migration, save/reload/reset round trip through the real HTTP
route, an actual rendered image using a custom palette (confirmed via
its packed panel-code bytes), input validation, and the sidebar badge
against real battery data -- plus the standing legacy-device curl suite.
2026-07-22 01:19:06 -04:00
tfaour c1c803b497 SMTP: implicit TLS (port 465) support + fix quarantined mail
Build and push server image / build-and-push (push) Successful in 38s
Two real fixes to app/mail.py, both found by testing against an actual
mail server rather than just a fake stub:

- Replaces the STARTTLS-only smtp_use_tls boolean with a three-way
  smtp_encryption ("none"/"starttls"/"ssl"). Implicit TLS (port 465,
  what Purelymail and most providers offer alongside 587/STARTTLS) is a
  different handshake entirely -- TLS from the first byte, not a
  plaintext connection that gets upgraded -- so it needs its own
  smtplib.SMTP_SSL code path, not just a skipped starttls() call.
  Schema migration v3 adds the column, backfills it from the old
  boolean, and drops the boolean (safe on a live, populated DB).

- Outgoing mail was missing Date and Message-ID headers -- email.mime
  doesn't set either automatically, and a missing Message-ID in
  particular is enough for a strict content filter (confirmed via a
  real Postfix+Amavis mail server's logs: SPF/DKIM/DMARC all passed
  cleanly, but Amavis quarantined the message as "BAD-HEADER-0" purely
  for the missing id) to silently swallow an otherwise-legitimate
  email, even though smtplib reports success -- the send genuinely
  succeeds to the relay, it just never survives the recipient's own
  filtering. Both headers are now set, with the Message-ID's domain
  matching the From address.

Verified: SMTP_SSL path against a hand-rolled implicit-TLS fake server
(self-signed cert, client-side verification relaxed only in the test
harness -- production code keeps ssl.create_default_context()'s real
verification), the v2->v3 migration against live data, the full admin
SMTP-save + test-email round trip over HTTP, and the standing legacy-
device curl suite.
2026-07-22 01:07:50 -04:00
tfaour 8e10ca540e Add SMTP email: password reset + per-frame battery-threshold alerts
Build and push server image / build-and-push (push) Successful in 40s
Admin-configured SMTP (server/port/username/password/from address/
STARTTLS, a singleton server_settings row set from /admin -- not env
vars, since it's operator infrastructure a household admin sets up
once through the UI) powers two features, both requiring the relevant
user to have an email set in their own Settings:

- "Forgot password?" on /login emails a one-hour single-use reset link
  (password_reset_tokens table). The endpoint always returns the same
  generic "check your email" response regardless of whether the address
  matched an account, so it can't be used to enumerate registered users.
- A frame's Configuration tab can set a battery-alert threshold
  (Frame.battery_alert_threshold_pct, -1 = disabled); POST /frame/battery
  emails the owner the first time a report drops to or below it, then
  stays quiet for the rest of that discharge cycle (battery_alert_sent,
  reset alongside battery_history whenever the existing recharge-jump
  detection fires) -- not once per wake.

New app/mail.py wraps stdlib smtplib (no new dependency); send_email()
never raises, so a broken mail server can't 500 a battery report or a
password-reset request. Schema migration v2 adds users.email and the
two frame columns via ALTER TABLE (safe against the live, already-
populated database) plus the two new tables via the existing
create_all-based migration runner.

Verified against a real (already-migrated, real user/frame data)
database: the v1->v2 migration, admin SMTP config + test-email button,
full forgot/reset-password roundtrip (including single-use token
invalidation and the no-enumeration response), and the battery alert
firing exactly once per crossing against a hand-rolled fake SMTP
server -- all via curl end-to-end, plus the standing legacy-device
curl suite to confirm the device protocol is untouched.
2026-07-22 00:51:54 -04:00
tfaour a45444ab4b Bump firmware to 1.2.0
Build and release firmware / build-and-release (push) Successful in 1m54s
2026-07-22 00:34:07 -04:00
tfaour 8ac3fc0de3 Redesign phase D: sidebar app shell, per-frame tabs, namespaced API
Build and push server image / build-and-push (push) Successful in 43s
The web UI grows into the multi-frame world: a left sidebar lists the
user's frames (with an online dot driven by the same overdue math as
the Device panel; collapsible off-canvas with a hamburger on mobile),
and each frame gets three tabs -- Photos (album picker, now displaying,
the drag-to-reorder upcoming grid), Configuration (name/order/
orientation/refresh/quiet hours/timezone/smart crop + the firmware
card), and Stats (device telemetry, lifetime counters, battery chart).
Settings and Admin adopt the same shell. / becomes a routing hub:
first frame, empty-state onboarding page, setup/login, or the
manage-QR redirect.

The JSON API moves to /api/frames/{id}/... behind require_frame_view /
require_frame_control: any linked user (admins see all) can view; 404
for frames outside your view so ids aren't confirmed; mutations 409
with the holder's name unless you hold the soft control lock, and
POST take-control always flips it to you. Config saves are now partial
updates -- each tab posts only its own fields (checkboxes always sent
explicitly), so the split forms can't clobber each other.

All CSS moves to static/theme.css and the old 680-line inline script
block splits into static/*.js -- the Pointer Events drag-drop state
machine and the canvas battery chart ported intact, not rewritten. The
CSRF fetch wrapper now reads a <meta> tag. No build step, still vanilla.

Verified end-to-end: page/static/API suites, control-lock handoff in
both directions, partial-save field preservation, non-admin frame
isolation, and the legacy-device curl suite (still byte-identical
responses for the deployed frame).
2026-07-21 23:56:18 -04:00
tfaour 683e3881b1 Redesign phase C: claim flow, limited manage page, device protocol
The frame-claiming pipeline, end to end. Firmware: every request now
carries ?id=<12-hex STA MAC> via build_url (mirrored in build_ota_url),
and the captive portal's success page became a redirect that hands the
user's browser to <server>/claim?device_id=... after ~7s -- enough time
for the phone to drop the provisioning AP while the device reboots.
The server pushes a per-frame device token through /frame/config during
a one-time handshake; the firmware persists it to NVS (a dedicated
single-key write that deliberately doesn't reset the connected-once
flag or WiFi cache) and prefers it over the provisioned shared token
from the next request on. Config response buffer grows 256->512. Both
board variants compile clean; new firmware also works against an old
server (which ignores ?id=) and old firmware against this server (the
phase A legacy mapping), so either deploy order survives.

Server: /claim lands the captive-portal redirect -- claim-gated signup
(a valid unclaimed/unregistered device id IS the enrollment invitation),
pending claims for the user-beats-the-frame race (auto-attached at
self-registration, 24h expiry), and a waiting page that refreshes until
the frame checks in. Unclaimed/unconfigured frames get a rendered
instruction placeholder with a QR from /frame/image (200, never an
error loop) -- new qrcode dep, placeholder shares the exact
quantize/pack path photos use.

The on-frame manage QR now resolves to a limited no-login page: scans
of / carrying device credentials (new ?id&token or the legacy shared
token) 303 to /m/<manage_token>, which allows exactly view queue,
show-next, advance, back, and scoped thumbnails -- no settings, no
removal, no other frames. Full control means logging in.

One real protocol hole found by simulating full wake cycles: after
self-registration the device could never authenticate again (the wake
cycle fetches the image BEFORE /frame/config delivers its token).
require_device now treats the id itself as the credential until the
first authenticated request flips device_token_ack -- the same trust
level as open registration, closing permanently once the handshake
completes.
2026-07-21 23:44:22 -04:00
tfaour 1e8d6803ac Redesign phase B: users, sessions, first-run setup, admin panel
Real identity on top of phase A's schema: scrypt-hashed passwords
(stdlib, no new deps -- parameters baked into each stored hash),
server-side sessions (sha256 of the cookie value stored, 30-day rolling
expiry), and per-session CSRF tokens enforced on every mutating
session-authed request -- via X-CSRF-Token for the JSON API (a fetch()
wrapper in base.html injects it, so the existing page scripts didn't
need touching) and a hidden form field for the HTML forms.

/setup runs once while no users exist: creates admin #1, links every
existing frame to them (owner + controller), and inherits the migrated
Immich creds onto their account -- per-user creds are now the primary
source, with env vars still winning as the operator fallback. /login,
/logout, /settings (display name, Immich creds, password change), and
/admin (enroll users, reset passwords, link users to frames, close a
frame's legacy-token window, delete) round out the pages, all in the
existing template/card style.

The legacy shared token stays accepted on browser routes so the
deployed frame's on-panel manage QR keeps working until phase C swaps
it for the limited manage page; token access renders without nav or
CSRF shim and is exempt from CSRF (explicit credential, not an ambient
cookie). Device routes untouched -- the legacy curl suite passes
verbatim.

Identity is provider-pluggable (identity_provider/provider_subject
already modeled) so OIDC can land later without schema surgery.
2026-07-21 23:28:14 -04:00
tfaour 9fbbb8ed2b Redesign phase A: SQLite storage, per-frame data model, device identity
Replaces the single global config.json (whole-file pydantic model under
one RLock) with SQLite via SQLAlchemy 2.0: users/sessions/frames/links/
pending-claims/battery_log tables (models.py), a per-frame lock registry
(db.frame_locked) succeeding config.locked(), and hand-rolled schema
versioning (migration.py). A pre-database deployment's config.json is
imported verbatim as frame #1 on first boot and left untouched as the
rollback path; the old single firmware.bin slot becomes per-frame
firmware/<id>.bin.

Routes split out of the 900-line main.py into routers/device.py (the
frozen /frame/* protocol) and routers/api.py (web UI, still on the old
single-frame paths for now). Device auth moves to require_device, which
already speaks the full multi-frame protocol: per-frame device tokens
pushed via /frame/config and acknowledged on first use, self-
registration of unknown device ids as unclaimed frames, pending-claim
attachment, and the legacy-token migration window that keeps the
currently-deployed firmware (no id, shared MANAGEMENT_TOKEN) resolving
to frame #1 -- including the one-time binding of its device id when it
first reports one after a future OTA.

Externally identical for existing deployments: same paths, same token
semantics, same response shapes -- verified with a migration fixture,
the legacy-device curl suite, a 20-way concurrent-advance smoke test,
and a mutate-restart-assert persistence check against a fake Immich.

photo_queue.py ports nearly verbatim onto the Frame ORM row (MutableList
JSON columns make its in-place list mutations dirty-track); quiet-hours
math extracted unchanged into quiet_hours.py.
2026-07-21 23:21:38 -04:00
tfaour 6a0072e383 Add a "Check now" button to the Firmware update card
Build and push server image / build-and-push (push) Successful in 36s
GET /api/firmware/check's 15-minute throttle meant a genuinely new
Gitea release could sit invisible in the UI for up to that long even
though POST /api/firmware/apply-latest (unthrottled) would've picked
it up immediately. New ?force=true bypasses the throttle for an
explicit check; the button wires it up and surfaces errors instead of
failing silently like the passive poll.
2026-07-21 22:29:05 -04:00
tfaour fdb5dc7ab3 Bump firmware to 1.1.2a (test build)
Build and release firmware / build-and-release (push) Successful in 1m49s
2026-07-21 22:22:04 -04:00
263 changed files with 33086 additions and 3468 deletions
+132
View File
@@ -0,0 +1,132 @@
---
name: build-firmware
description: Compile the espresso_frame firmware (firmware/) for all three board variants (ESP32-C6 devkit/xiao, ESP32-S3 ee02) without Docker -- a native, non-container ESP-IDF v6.0 install. Use when asked to build the firmware, verify a firmware/main/*.c change actually compiles, or check the devkit/xiao/ee02 board targets.
---
Compiles `firmware/` (ESP-IDF, targeting ESP32-C6 for devkit/xiao and
ESP32-S3 for ee02) locally, without Docker -- CI's
`firmware-build-check.yml`/`firmware-release-build.yml`
build inside the `espressif/idf:release-v6.0` container image, but
**this sandbox cannot run containers at all**: `docker.io` installs and
`dockerd` starts fine even as root, but the sandbox strips
`cap_sys_admin` (and blocks the bare `unshare` syscall) from the
capability set regardless of uid, which container image-layer
extraction and namespace setup both require. Confirmed by hand:
`docker run hello-world` fails to extract even the tiny hello-world
layer ("failed to extract layer... operation not permitted" with the
overlayfs snapshotter; "unshare: operation not permitted" even with
the vfs storage driver instead). This is a hard restriction of the
sandbox itself, not a permissions/setup problem -- don't spend time
re-trying `--privileged`-equivalent flags or alternate storage drivers,
none of it routes around a missing `cap_sys_admin`.
The workaround: skip containers entirely and install ESP-IDF the same
way a developer would set it up on their own machine (`git clone` +
ESP-IDF's own `install.sh`) -- that path needs nothing this sandbox
disallows, just normal file/process operations.
## Setup (once per fresh container)
```bash
bash .claude/skills/build-firmware/setup.sh
```
Installs (via real `apt-get` -- this container actually has root and a
working package manager, unlike run-server's Chromium bootstrap which
had neither):
- OS build deps: `python3`/`venv`/`pip`, `cmake`, `ninja-build`,
`flex`/`bison`/`gperf`, `build-essential`, `libusb-1.0-0`.
- ESP-IDF itself: a shallow, single-branch, recursive-submodule clone
of `release/v6.0` (~700MB) into `~/.espressif-idf/esp-idf` -- matches
the IDF version CI's Docker image pins. Only clones once; re-running
`setup.sh` never touches an existing checkout.
- The esp32c6+esp32s3 toolchains + Python venv, via ESP-IDF's own
`./install.sh esp32c6,esp32s3` -- scoped to just this project's two
chip targets (see `firmware/README.md`'s board table: devkit/xiao are
esp32c6, ee02 is esp32s3), not every chip ESP-IDF supports, to keep
the download/disk footprint down. `install.sh` is already idempotent
on its own, so `setup.sh` always calls it rather than duplicating
that check -- a re-run costs a few seconds once everything's cached.
Takes a few minutes on a cold run (mostly `install.sh`'s own pip/tool
downloads), well under a minute on a re-run. Needs real root (`apt-get
install`) -- if this container ever runs as non-root, this setup
doesn't apply as-is (would need the same non-root apt-download +
`dpkg-deb -x` extraction dance `run-server`'s `setup.sh` uses for
Chromium).
Disk: budget ~4GB free before starting (esp-idf checkout + toolchain +
Python env land around 3.4GB in `~/.espressif`, plus the ~700MB
checkout itself). Confirmed working with as little as ~7GB free.
## Build
```bash
bash .claude/skills/build-firmware/build.sh # devkit (default)
bash .claude/skills/build-firmware/build.sh xiao
bash .claude/skills/build-firmware/build.sh ee02
bash .claude/skills/build-firmware/build.sh both # devkit + xiao
bash .claude/skills/build-firmware/build.sh all # devkit + xiao + ee02
```
Each board gets its own build directory and generated sdkconfig (see
`firmware/build_for_board.sh`'s own comment) -- building one never
disturbs the other. `build.sh` auto-runs `set-target` (esp32c6 for
devkit/xiao, esp32s3 for ee02) the very first time a board is built (no
generated sdkconfig yet); later builds skip straight to `idf.py build`.
Extra arguments pass straight through to `idf.py`, e.g.:
```bash
bash .claude/skills/build-firmware/build.sh xiao flash -p /dev/ttyUSB0
```
`flash`/`monitor` need an actual attached device and serial port --
this sandbox has neither, so those only work when this skill runs
somewhere hardware is actually plugged in (a real dev machine, or a
differently-configured environment with device passthrough).
A clean build of one board takes ~30s once the target's already been
configured (~1,000 build steps total split across the boards, most of
it ESP-IDF's own components -- this project's own `firmware/main/*.c`
and `firmware/components/*` sources are a small fraction of that and
compile in a few seconds). Output lands at
`firmware/build/espresso_frame.bin` (devkit),
`firmware/build_xiao/espresso_frame.bin` (xiao), or
`firmware/build_ee02/espresso_frame.bin` (ee02, once its driver actually
compiles -- see the note above) -- all three paths are gitignored (the
repo root `.gitignore`'s "ESP-IDF firmware build output" section), so
nothing here needs cleaning up before a commit.
## Verified
Both board variants (`devkit` set-target esp32c6 + build, `xiao`
set-target esp32c6 + build) built successfully end-to-end using this
exact setup.sh/build.sh pair, producing real
`espresso_frame.bin` images with normal free-space margins (41%/36%
of their respective app partitions) and no errors -- only one
pre-existing, unrelated warning (`battery.c`'s unused `TAG` when that
file's logging is compiled out). This is a real compile check, not
just a syntax read -- if a future change breaks the build, this skill
will actually catch it.
## Troubleshooting
- **`docker: ... unshare: operation not permitted` / `failed to
extract layer ... operation not permitted`**: expected in this
sandbox, see the top of this file. Don't debug it further -- use this
skill's native install instead.
- **`ESP-IDF not found at ... -- run setup.sh first`**: `build.sh`'s
own check for a missing `$IDF_DIR/export.sh` -- run `setup.sh` (see
above) before the first build.
- **`idf.py: command not found` if you try to run it directly**: same
gotcha `firmware/build_for_board.sh` already documents -- `idf.py` is
normally a shell *function* from ESP-IDF's `export.sh`, not on PATH
as a real executable, so it isn't inherited into a script's own
subshell even after sourcing `export.sh` in your interactive shell
first. Use `build.sh` (or `build_for_board.sh`, which calls
`python "$IDF_PATH/tools/idf.py"` directly) instead of typing
`idf.py` in a fresh script/subshell.
- **Disk pressure during `install.sh`**: this environment runs close to
full (single-digit GB free is normal, not a sign of a leak) --
`df -h /` before running `setup.sh` if a build mysteriously fails
partway with a "no space left on device"-shaped error.
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Builds (or flashes/monitors, if a serial port is actually attached)
# the espresso_frame firmware for one board variant, via the project's
# own firmware/build_for_board.sh -- this script just sources the
# ESP-IDF environment first and auto-runs `set-target` (esp32c6 for
# devkit/xiao, esp32s3 for ee02) on a board's very first build (a fresh
# clone has no generated sdkconfig yet, same reasoning as CI's own build
# steps -- see firmware/README.md's "Building for the Seeed XIAO
# ESP32-C6" section).
#
# NOTE: ee02 builds will fail to compile -- deliberately -- until
# firmware/components/epd13in3e's panel driver is ported from vendor
# demo code (see that component's own top-of-file comment). The build
# plumbing itself (target selection, partition table, sdkconfig
# layering) is exercised regardless; only the final compile step fails.
#
# Usage:
# build.sh # build devkit (default)
# build.sh devkit
# build.sh xiao
# build.sh ee02
# build.sh both # build devkit + xiao (unchanged meaning)
# build.sh all # build devkit + xiao + ee02
# build.sh xiao flash -p /dev/ttyUSB0 # only meaningful with real hardware attached
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
firmware_dir="$(git -C "$script_dir" rev-parse --show-toplevel)/firmware"
IDF_DIR="$HOME/.espressif-idf/esp-idf"
if [ ! -f "$IDF_DIR/export.sh" ]; then
echo "ESP-IDF not found at $IDF_DIR -- run setup.sh first" >&2
exit 1
fi
# export.sh is chatty and assumes an interactive shell prompt in spots;
# redirect its own stdout, not ours, so build.sh's actual output (and a
# real failure's stderr) stays visible.
source "$IDF_DIR/export.sh" > /dev/null
cd "$firmware_dir"
build_one() {
local board="$1"
shift
local sdkconfig target
case "$board" in
devkit) sdkconfig="sdkconfig"; target="esp32c6" ;;
xiao) sdkconfig="sdkconfig.xiao_local"; target="esp32c6" ;;
ee02) sdkconfig="sdkconfig.ee02_local"; target="esp32s3" ;;
*) echo "Unknown board '$board' -- expected 'devkit', 'xiao', or 'ee02'" >&2; exit 1 ;;
esac
if [ ! -f "$sdkconfig" ]; then
echo "==> $board: no generated sdkconfig yet, setting target $target"
./build_for_board.sh "$board" set-target "$target"
fi
local args=("$@")
if [ ${#args[@]} -eq 0 ]; then
args=(build)
fi
./build_for_board.sh "$board" "${args[@]}"
}
board="${1:-devkit}"
shift || true
case "$board" in
both)
build_one devkit "$@"
build_one xiao "$@"
;;
all)
build_one devkit "$@"
build_one xiao "$@"
build_one ee02 "$@"
;;
*)
build_one "$board" "$@"
;;
esac
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# One-time (idempotent) environment bootstrap for compiling the
# espresso_frame firmware (firmware/) without Docker -- see this
# skill's SKILL.md for why not Docker, even though that's what CI uses.
# Re-run any time; every step is safe/fast to repeat once already done.
set -euo pipefail
IDF_ROOT="$HOME/.espressif-idf"
IDF_DIR="$IDF_ROOT/esp-idf"
# Matches the espressif/idf:release-v6.0 image CI's
# firmware-build-check.yml/firmware-release-build.yml use -- keep this
# in sync with those workflow files if the project's pinned IDF version
# ever changes.
IDF_BRANCH="release/v6.0"
# 1. OS packages ESP-IDF's own install.sh needs (python3 + venv/pip,
# cmake, ninja, a C toolchain for the odd host-side code generator, git
# for the clone below, flex/bison/gperf for mbedtls/etc.'s generated
# parsers, libusb for esptool's USB/JTAG bits even though this skill
# doesn't flash real hardware). Installed via apt with real root --
# unlike run-server's Chromium bootstrap, this container actually has
# root and a working apt, so no non-root extraction dance is needed
# here.
PKGS="git python3 python3-venv python3-pip cmake ninja-build ccache libusb-1.0-0 wget flex bison gperf build-essential"
missing=()
for pkg in $PKGS; do
dpkg -s "$pkg" >/dev/null 2>&1 || missing+=("$pkg")
done
if [ ${#missing[@]} -gt 0 ]; then
echo "installing OS packages: ${missing[*]}"
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y "${missing[@]}"
fi
# 2. ESP-IDF checkout -- shallow, single branch, recursive submodules
# also shallow (~700MB total, vs. several GB for a full clone). Only
# clones once; re-running this script never re-clones or resets it, so
# any local changes you made for debugging survive a re-run.
if [ ! -d "$IDF_DIR/.git" ]; then
echo "cloning esp-idf $IDF_BRANCH into $IDF_DIR ..."
mkdir -p "$IDF_ROOT"
git clone --branch "$IDF_BRANCH" --depth 1 --shallow-submodules --recursive \
https://github.com/espressif/esp-idf.git "$IDF_DIR"
else
echo "esp-idf already cloned at $IDF_DIR"
fi
# 3. Toolchain + Python virtualenv, scoped to esp32c6+esp32s3 only --
# this project's two chip targets (see firmware/README.md's board
# table: devkit/xiao are esp32c6, ee02 is esp32s3). Scoping avoids
# downloading toolchains for every chip ESP-IDF supports, which matters
# given this container's disk headroom. install.sh is already
# idempotent on its own (checks what's present and skips it), so this
# always calls it rather than trying to duplicate that check here --
# a re-run only costs a few seconds once everything's cached.
echo "running esp-idf install.sh esp32c6,esp32s3 (fast if already installed) ..."
(cd "$IDF_DIR" && ./install.sh esp32c6,esp32s3)
echo "setup complete -> $IDF_DIR/export.sh (build.sh sources this for you)"
+195
View File
@@ -0,0 +1,195 @@
---
name: make-widget
description: Scaffold a new widget type for the espresso_frame server (the ~14-file checklist a widget type touches -- config table, grid footprint, render module, registry, migration, config-save + endpoints, dialog template + JS, script tag, WIDGET_LABELS, docs, saved-layout config allowlist, tests). Use when asked to add a new widget type to a frame's panel (e.g. "add a text widget", "add an RSS widget", "add a weather-only widget").
---
Adding a widget type is a very consistent, repeated pattern in this
codebase (`photos`/`calendar`/`whiteboard`/`tasks`/`static`) -- see
`docs/widgets.md` for the system's actual data model/rendering/dialog
architecture (read that first if you haven't). This skill is the
checklist of every file that pattern touches, so nothing gets silently
dropped (the static-image widget shipped without a `docs/widgets.md`
update; this skill exists so that doesn't keep happening).
**For a complete worked example touching every item below**, `git show
35e80c6 --stat` (the static-image widget's commit) in this repo.
All paths below are relative to `server/`.
## Before writing any code: shape decisions
Answer these first -- they determine which existing widget type is the
closest template to copy from:
- **Live upstream to poll, or self-contained/user-authored?** Calendar/
whiteboard/tasks fetch from somewhere external on a throttle
(`checked_at` + `get_or_refresh_*` in `routers/common.py`). Photos'
queue and the static image widget don't -- their content is set once
via the dialog (an upload, a pick) and just sits there until changed.
A text widget is almost certainly this second shape.
- **Single source, or multi-source merge?** Calendar/tasks merge
several *people's* data (`FrameCalendar`/`FrameTaskList`, owner-adds/
anyone-mutes). Only reach for that shape if the new type genuinely
needs to combine several linked users' own data -- most new widget
types are single-owner/single-config and don't need it.
- **Any button actions**, or is `ACTIONS = {}` correct (nothing to
advance/back/force)? Tasks and static image are both `{}`.
- **Minimum sane grid footprint** -- how small can this widget be
before its content is illegible/pointless?
Pick your template accordingly:
| New widget shape | Copy from |
|---|---|
| Self-contained, user-authored/uploaded, no fetch, no actions | `app/widgets/static_image.py` |
| Single external source, throttled fetch, one "check_now" action | `app/widgets/whiteboard.py` |
| Multi-source merge, owner-adds/anyone-mutes permissions | `app/widgets/tasks.py` (simpler) or `calendar.py` (also has size-tier rendering) |
| Stateful queue/rotation with advance/back | `app/widgets/photos.py` |
## The checklist
1. **`app/models.py`** -- new `<Type>WidgetConfig` table, `widget_id`
`Mapped[int]` primary key `ForeignKey("widgets.id", ondelete="CASCADE")`,
plus whatever fields the type needs. Add it to the `WIDGET_CONFIG_MODELS`
dict at the bottom of the file.
2. **`app/grid.py`** -- add an entry to `MIN_FOOTPRINT`.
3. **`app/widgets/<type>.py`** -- new module exposing:
- `render(db, frame, widget, target_w, target_h, is_normal_wake=True) -> Image.Image`
-- RGB, exactly `target_w x target_h`, **never raises** for a
foreseeable failure (missing config, fetch error) -- fall back to
`._shared.placeholder_image(target_w, target_h, [lines])` instead.
- `ACTIONS: dict[str, Callable[[Session, Frame, Widget], None]]`
- `ACTION_LABELS: dict[str, str]`
4. **`app/widgets/__init__.py`** -- import the new module, add it to
`WIDGET_TYPES`.
5. **`app/migration.py`** -- new `_migration_N`. A brand-new table with
no legacy data to carry forward is just
`Base.metadata.create_all(bind=conn)` (see `_migration_20`) -- it
only creates the one new table, existing ones are untouched. Register
`(N, _migration_N)` as the new last entry in `MIGRATIONS`.
6. **`app/routers/api_widgets.py`**:
- Add any new `Form(...)` fields to `api_widget_config_save`'s
signature, and a new `elif widget.widget_type == "<type>":` branch
inside its body. Reuse an existing field name (e.g. `display_mode`)
where the semantics genuinely match -- fields are namespaced by
which widget type actually reads them, not by name collision, so
this is safe (see the comment above `display_mode` in that
function).
- Add type-specific endpoints as needed (upload/source-select/etc.).
Use `require_widget_control` for widget-wide settings a dialog Save
button changes; use `require_widget_view` (not control) for the
owner-adds/anyone-mutes multi-source pattern, matching
`api_widget_calendar_select`/`api_widget_task_list_select`.
- Add a `GET .../preview/<type>` endpoint mirroring the others --
`render_preview_png` (the full palette/dither pipeline) for
image-like content, or a dedicated `render_<type>_preview_png` in a
rendering module for text/graphics content (see
`calendar_render.render_tasks_preview_png`).
7. **`app/routers/frame_pages.py`** -- import the new config model, add
an `if widget.widget_type == "<type>":` branch in `widget_dialog()`
returning `templates.TemplateResponse("_widget_dialog_<type>.html", {...})`.
8. **`app/templates/_widget_dialog_<type>.html`** -- the dialog
fragment: settings card(s) + `<img class="preview-img"
id="<type>-preview">` + a refresh button, using the existing
`.card`/`.card-title`/`.sub`/`.checkbox-row` classes from
`theme.css` rather than inventing new ones.
9. **`app/static/widget_dialog_<type>.js`** -- an `init<Type>Dialog()`/
`close<Type>Dialog()` pair (not a page-load script -- see any
existing `widget_dialog_*.js`'s header comment for the contract).
`window.fetch` already CSRF-injects (see `common.js`), so POSTs don't
need a manual header. **Never build user-supplied text into the DOM
via `innerHTML` string interpolation** -- use `textContent`/
`createElement` (a filename, a task summary, anything another linked
user's account could have set is a stored-XSS vector otherwise).
10. **`app/templates/frame_layout.html`** -- add
`<script src="/static/widget_dialog_<type>.js"></script>` next to
the other widget dialog scripts.
11. **`app/static/frame_layout.js`** -- add the type to both
`DIALOG_INIT` and `DIALOG_CLOSE`.
12. **`app/static/common.js`** -- add a `WIDGET_LABELS` entry (the
human label shown in the add-widget button, the widget box, and the
button-assignment picker in `frame_config.js`).
13. **`docs/widgets.md`** -- update every place that enumerates widget
types: the intro sentence, the `widget_type` column-value list, the
`MIN_FOOTPRINT` prose line, the `app/widgets/` module list. This is
the project's own "start here" doc per `CLAUDE.md` -- don't ship a
widget without it staying accurate.
14. **`app/routers/api_layouts.py`** -- add a `"<type>": (...)` entry to
`LAYOUT_CONFIG_FIELDS` listing the config columns that are an
authored *setting* (as opposed to runtime/cache state like a fetch
cache or queue position, which a saved layout deliberately leaves
out -- see the dict's own comment). Skipping this doesn't error or
warn anywhere: the widget just silently saves/applies with an empty
`{}` config forever, resetting to defaults on every layout apply or
hold-to-cycle. This actually shipped missing for the weather widget
-- caught only because a user noticed layout-cycling kept resetting
its city/mode.
## Tests (`server/tests/`)
- `test_widgets_<type>.py` -- unit-level `render()` tests, no HTTP:
correct size/mode with no config, with config, at
`grid.MIN_FOOTPRINT`'s smallest box, `ACTIONS == {}` if passive.
Mirror `test_widgets_static.py` (self-contained) or
`test_widgets_tasks.py` (fetch-backed, monkeypatches the fetch call).
- `test_widget_config_and_queue_endpoints.py` -- add a
`_add_<type>_widget` helper plus an HTTP-level
`test_config_save_updates_a_<type>_widget` test, and tests for any new
endpoints (upload/select/preview: 400 before configured, 200 after,
400 for the wrong widget type via `_require_widget_type`).
- `test_migrations.py` -- add the new table to
`test_expected_columns_exist_on_current_schema`'s spot-check
(`inspector.get_table_names()` or `inspector.get_columns(...)`).
- Owner-adds/anyone-mutes multi-source table? Add cases to
`test_permission_boundaries.py` following its existing
calendar-select/task-list-select pattern (owner can add, non-owner
can mute but not add, 404 for an unrelated widget id, 400 for the
wrong widget type).
- Any pure-logic helper module (decoding, parsing -- like
`app/image_upload.py`) gets its own `test_<module>.py`: no HTTP, no
DB, just the function.
- `test_saved_layouts.py` -- a `test_save_and_apply_round_trip_<type>_settings`
test: set every field the new `LAYOUT_CONFIG_FIELDS` entry lists,
save a layout, assert the `SavedLayoutWidget.config` snapshot has them
all, delete the frame's widgets, apply the layout back, assert the
new widget's config matches -- and that any runtime/cache field
(`checked_at`, a fetch cache, a queue) was *not* carried over. See
`test_save_and_apply_round_trip_weather_settings` for the pattern.
Run the full suite before calling it done:
```bash
cd server && .venv/bin/pytest -q
```
Comfortably under 30s for the whole suite (~200+ tests) -- there's no
reason to skip this or run a subset.
## Browser verification (required, not optional)
Per `CLAUDE.md`, reading the JS is not enough -- this project has
shipped UI bugs (mobile viewport CSS collapse, a dialog's status message
landing behind its own backdrop, a JSON/form body mismatch) that only
showed up live. Use the `run-server` skill:
- Clear existing widgets and add one of the new type
(`POST /api/frames/1/widgets`), resize it (`PATCH`), open its dialog
(`click .widget-box-settings`), exercise its actual settings/upload
flow through the real UI controls (not just a raw `fetch` in `eval` --
that only proves the endpoint works, not that the button is wired to
it), and check `console-errors` for anything beyond the expected
favicon 404.
- Check the full composited panel preview
(`#frame-preview-thumb` on `/frames/{id}/config`) actually shows the
new widget's content -- not just its own dialog's `preview/<type>`
image, which only proves the render function works in isolation.
- Screenshot **both** desktop (`viewport 1280 900`) and mobile
(the driver's default) widths -- the layout genuinely forks at the
860px breakpoint in `theme.css`.
## Commit
One commit for the whole widget (models + migration + render + router +
UI + tests + docs) -- this project's convention is one feature per
commit, not split by layer. No `Co-Authored-By: Claude` trailer (see
root `CLAUDE.md`).
+2
View File
@@ -0,0 +1,2 @@
# Generated by setup.sh -- bakes in this host's /tmp paths, not portable.
env.sh
+234
View File
@@ -0,0 +1,234 @@
---
name: run-server
description: Build, run, and drive the espresso_frame FastAPI server (server/) -- start it against a scratch DB, browser-test its UI, run its pytest suite. Use when asked to start the server, take a screenshot of a frame page, click through the web UI, or verify a server/UI change actually works.
---
The espresso_frame server is a FastAPI app (`app.main:app`) with a
server-rendered Jinja UI. For agent/automated use it's driven by a
Playwright REPL at `.claude/skills/run-server/driver.py`, run under
tmux -- `chromium-cli` isn't available in this container, so this
driver replaces it (same command vocabulary: nav/wait-for/click/fill/
screenshot/eval/console-errors).
This container ships with **no Python, Node, Docker, or browser, and
no sudo**. `setup.sh` bootstraps everything non-root; it's the bulk of
what makes this skill non-obvious. Run it once per fresh container.
Commands below (`setup.sh`, `start-server.sh`, `stop-server.sh`,
`env.sh`, `driver.py`) are invoked from the **repo root** via their
`.claude/skills/run-server/` path -- the scripts `cd` into `server/`
themselves. Anything under `.venv/` (the venv itself, `pytest`,
`uvicorn`) lives inside `server/`, so those commands need `server/`
prefixed or `cd server` first.
## Prerequisites
None to install manually -- `setup.sh` does it all without root, using
only `curl`/`apt-get download`/`dpkg-deb -x` (never `apt-get install`,
which needs root). It downloads ~450MB total (Python, Chromium, tmux,
shared libs) on first run.
```bash
bash .claude/skills/run-server/setup.sh
```
Re-running is safe and fast -- every step checks whether it already
happened (venv exists? chromium downloaded? libs extracted? tmux
present?) before doing any work.
This creates:
- `.venv/` -- Python 3.12 + `requirements.txt` + `playwright`, via `uv`
(a static Rust binary that fetches its own Python -- no compiler
needed, install via `curl -LsSf https://astral.sh/uv/install.sh | sh`)
- `~/.cache/ms-playwright/` -- Chromium (full `chrome` + headless-shell)
- `/tmp/run-server-chromium-deps/` -- Chromium's + tmux's shared libs
and fonts, extracted (not installed) from `.deb` files
- `.claude/skills/run-server/env.sh` -- the `PATH`/`LD_LIBRARY_PATH`/
`FONTCONFIG_PATH`/`RUN_SERVER_CHROME_BIN` exports the driver needs
(gitignored -- host-specific `/tmp` paths, regenerated by setup.sh)
## Run (agent path)
**1. Start the server** against a scratch DB (never the real
deployment's data -- see `CLAUDE.md`):
```bash
bash .claude/skills/run-server/start-server.sh
# -> server PID <pid> up on http://127.0.0.1:8420 (log: /tmp/run-server-scratch/server.log)
```
Optional args: `start-server.sh [scratch-dir] [port]` (defaults
`/tmp/run-server-scratch` / `8420`).
**2. Drive it**, wrapped in tmux so you can send one command at a time
and read the response before sending the next. `tmux` itself was
extracted the same non-root way as Chromium's libs (see Prerequisites)
and needs `LD_LIBRARY_PATH` set in *this* shell too, not just inside
the pane -- source `env.sh` before the first `tmux` call:
```bash
source .claude/skills/run-server/env.sh
tmux new-session -d -s runserver -x 200 -y 50
tmux send-keys -t runserver \
'source .claude/skills/run-server/env.sh && server/.venv/bin/python .claude/skills/run-server/driver.py' Enter
timeout 20 bash -c 'until tmux capture-pane -t runserver -p | grep -q "driver>"; do sleep 0.3; done'
# Every fresh scratch DB starts with no users -- bootstrap-admin
# completes first-run /setup and logs in (see Commands table):
tmux send-keys -t runserver 'bootstrap-admin' Enter
timeout 15 bash -c 'until tmux capture-pane -t runserver -p | grep -q "bootstrapped admin"; do sleep 0.3; done'
tmux send-keys -t runserver 'nav /frames/1/config' Enter
tmux send-keys -t runserver 'wait-for #frame-preview-thumb' Enter
tmux send-keys -t runserver 'screenshot before' Enter
tmux capture-pane -t runserver -p
```
Poll for a specific marker between `send-keys` and `capture-pane`
(`driver>`, `bootstrapped admin`, `screenshot:`, ...) rather than a
fixed `sleep` -- it's faster and fails loudly instead of capturing a
half-rendered screen. Give each poll its own `timeout` (~15-20s); don't
chain many polling loops inside one shell invocation -- see Gotchas.
Screenshots land in `/tmp/run-server-shots/` (override:
`SCREENSHOT_DIR`). **Actually look at them** -- a blank or error-page
screenshot is a failure to launch, not success.
**Test every UI change at both a desktop and a mobile viewport.** The
driver defaults to a desktop size (1280x900); switch with `viewport`.
The app's mobile breakpoint is 860px (`theme.css`) -- below that the
sidebar goes off-canvas behind a hamburger (`.mobile-bar`). A page that
looks right at 1280px can still overflow, overlap the mobile bar, or
mis-center a `<dialog>` at phone widths -- screenshot both:
```bash
tmux send-keys -t runserver 'viewport 1280 900' Enter # desktop (also the default)
tmux send-keys -t runserver 'screenshot desktop-x' Enter
tmux send-keys -t runserver 'viewport 390 844' Enter # iPhone-ish mobile width
tmux send-keys -t runserver 'screenshot mobile-x' Enter
```
### Driver commands
| command | what it does |
|---|---|
| `nav <path-or-url>` | navigate (relative paths resolve against `http://127.0.0.1:8420`, override with `RUN_SERVER_BASE_URL`) |
| `wait-for <selector>` | wait up to 10s for a selector (plain CSS -- see Gotchas for attribute selectors) |
| `click <selector>` | click an element |
| `fill <selector> <value>` | fill an input |
| `press <key>` | keyboard press (e.g. `Enter`) |
| `screenshot [name]` | → `/tmp/run-server-shots/<name>.png` |
| `eval <js>` | evaluate JS in the page, prints JSON |
| `console-errors` | prints all captured console/page errors as a JSON array |
| `viewport [w] [h]` | resize the viewport, default `390 844` -- use `1280 900` for desktop (see Gotchas re: real touch input) |
| `is-open <dialog-selector>` | prints `true`/`false` for a `<dialog>` element's `.open` -- use this instead of `wait-for sel[open]` |
| `bootstrap-admin [user] [pass]` | completes first-run `/setup` (defaults `admin`/`testpassword123`); links frame #1 and logs in |
| `quit` | closes the browser, exits the driver |
**3. Stop the server** when done:
```bash
bash .claude/skills/run-server/stop-server.sh
tmux kill-session -t runserver
```
## Run (human path)
```bash
cd server
DATABASE_URL="sqlite:////tmp/dev.db" CONFIG_PATH="/tmp/dev-config.json" \
.venv/bin/uvicorn app.main:app --reload --port 8420
```
Open `http://localhost:8420` in a real browser. `start.sh` (the Docker
entrypoint) is not this -- it also launches the Node whiteboard
render-service sidecar, which needs Node (not installed here) and
isn't needed for most UI testing.
## Test
```bash
cd server && .venv/bin/pytest
```
Uses its own tempfile SQLite per run (`tests/conftest.py`) -- no setup
needed beyond the venv.
---
## Gotchas
- **`viewport` only resizes the window -- it does not emulate touch
input.** `click` still dispatches a mouse click, not a tap; there's
no touch-delay, no `:hover`-stickiness-after-tap, no `hasTouch`
context. It catches real bugs (layout overflow, off-canvas sidebar,
a `<dialog>` mis-centering at phone widths) but won't catch anything
that's specifically a touch-vs-mouse event difference. Good enough
for CSS/layout verification; not a substitute for testing on an
actual phone if a change touches touch-specific interaction.
- **`chrome-headless-shell` (Playwright's default headless target)
crashes on basic calls in this container**, e.g. `page.set_content()`
returns `TargetClosedError`, even after every `ldd`-reported missing
library is resolved. The full `chrome` binary (`chromium-*/chrome-linux64/chrome`)
+ `--no-sandbox` is stable; `driver.py` and `setup.sh` both use it,
not the headless-shell default.
- **Missing fonts silently break `fill()`, not just rendering.** Before
`fontconfig`/`libfontconfig1` were extracted and `fonts.conf` pointed
at the extracted font dir, `page.fill()` ran with no error but left
inputs empty (`input_value()` returned `""`), and all text rendered
invisible in screenshots. It looks like a scripting bug, not a
missing-lib problem -- if `fill` silently no-ops, suspect fonts
before suspecting the selector or a race.
- **No `apt-get install` / `playwright install-deps` (no root) and no
`apt-get update` into the real `/var/lib/apt/lists` (root-owned).**
Worked around by redirecting apt's state dirs to a scratch,
user-writable path (`-o Dir::State::Lists=... -o Dir::Cache=...`),
which makes plain `update` and `install --download-only --print-uris`
work as a non-root user; then `dpkg-deb -x <deb> <root>` (extract,
not install) needs no root either. `setup.sh` does this for
Chromium's deps *and* for `tmux` itself, which also isn't
preinstalled.
- **`wait-for` with an attribute selector like `#frame-preview-dialog[open]`
is unreliable through `tmux send-keys`** -- shell/tmux escaping of
`[`/`]` easily mangles it (seen: a real 10s Playwright timeout from a
garbled selector, not a fast failure). Use the app-specific `is-open
<selector>` command instead of `wait-for sel[open]` to check a
`<dialog>`'s open state.
- **Don't chain many `tmux send-keys` + polling-`timeout` loops inside
one shell invocation.** Each poll can legitimately take up to its own
timeout (e.g. 15s) if a selector is wrong; five or six chained in one
command can add up past this tool's own command timeout even though
each individual step is fine. Send one or two commands per shell
call and check the pane before continuing.
- **A crashed `chrome-headless-shell` process can leave a large core
dump file** (`server/core`, ~170MB, from the crash described above)
if core dumps are enabled. It's not part of the app -- delete it, and
use full `chrome` (as `driver.py` does) to avoid triggering it again.
## Troubleshooting
- **`error while loading shared libraries: libglib-2.0.so.0` (or similar)
when launching Chromium directly**: `LD_LIBRARY_PATH` isn't set --
source `.claude/skills/run-server/env.sh` first, or run through
`driver.py`, which reads `RUN_SERVER_CHROME_BIN` from it.
- **`fc-list` prints nothing after extracting fonts**: `fonts.conf`'s
`<dir>` entries still point at the real (unpopulated) `/usr/share/fonts`.
`setup.sh` patches this with `sed`; if you extracted packages by hand,
do the same.
- **`E: Could not open lock file ... Permission denied` from `apt-get`**:
you're missing the `-o Dir::State::Lists=... -o Dir::Cache=...`
overrides -- plain `apt-get update`/`install` always needs root here.
- **`tmux: command not found`**: not preinstalled and no sudo; run
`setup.sh`, which fetches it the same non-root way as Chromium's libs.
- **`tmux: error while loading shared libraries: libutempter.so.0`**:
you sourced `env.sh` inside the driver's tmux pane but not in the
shell that *invokes* `tmux` itself -- `tmux` was extracted from the
same non-root `.deb` set as Chromium and needs `LD_LIBRARY_PATH` too.
`source .claude/skills/run-server/env.sh` before the first `tmux`
command, not just inside `send-keys`.
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""REPL driver for the espresso_frame server's web UI.
Playwright-based since chromium-cli isn't available in this container.
Reads one command per line from stdin, prints a result line -- built
for tmux send-keys/capture-pane use by an agent. Vocabulary mirrors
chromium-cli where it overlaps (nav/wait-for/click/fill/screenshot/
eval/console-errors).
Requires setup.sh to have run first (Python venv + Playwright Chromium
+ the non-root shared-lib/font extraction). Run via:
.claude/skills/run-server/env.sh sourced, then
server/.venv/bin/python .claude/skills/run-server/driver.py
See SKILL.md for the full agent-path invocation (tmux wrapping etc).
"""
import glob
import json
import os
import sys
from playwright.sync_api import sync_playwright
SHOT_DIR = os.environ.get("SCREENSHOT_DIR", "/tmp/run-server-shots")
os.makedirs(SHOT_DIR, exist_ok=True)
BASE = os.environ.get("RUN_SERVER_BASE_URL", "http://127.0.0.1:8420")
def find_chrome() -> str:
override = os.environ.get("RUN_SERVER_CHROME_BIN")
if override and os.path.exists(override):
return override
matches = glob.glob(os.path.expanduser("~/.cache/ms-playwright/chromium-*/chrome-linux64/chrome"))
if not matches:
sys.exit("chrome binary not found -- run setup.sh first")
return matches[0]
pw = sync_playwright().start()
# The FULL `chrome` binary, not chrome-headless-shell (Playwright's
# default headless target): chrome-headless-shell crashed on basic
# calls like set_content() in this container even once every
# ldd-reported missing lib was resolved. Full chrome + --no-sandbox is
# stable here.
browser = pw.chromium.launch(executable_path=find_chrome(), args=["--no-sandbox"])
page = browser.new_page(viewport={"width": 1280, "height": 900})
console_errors: list[str] = []
page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None)
page.on("pageerror", lambda exc: console_errors.append(str(exc)))
# Playwright auto-DISMISSES native confirm()/alert() dialogs by default
# (returns false) -- several destructive actions in this app (remove
# widget, clear all widgets) gate on `confirm()`, so without this a
# `click` on one of those buttons would silently no-op. Auto-accept
# instead, since a driver testing a "yes, do the destructive thing"
# flow needs the confirm to actually go through.
page.on("dialog", lambda dialog: dialog.accept())
def cmd_nav(arg):
url = arg if arg.startswith("http") else BASE + arg
page.goto(url)
print("nav ->", page.url)
def cmd_wait_for(arg):
page.wait_for_selector(arg, timeout=10000)
print("found:", arg)
def cmd_click(arg):
page.click(arg)
print("clicked:", arg)
def cmd_fill(arg):
sel, _, value = arg.partition(" ")
page.fill(sel, value)
print("filled:", sel, "=", value)
def cmd_press(arg):
page.keyboard.press(arg)
print("pressed:", arg)
def cmd_screenshot(arg):
name = arg or f"ss-{len(os.listdir(SHOT_DIR))}"
path = os.path.join(SHOT_DIR, name + ".png")
page.screenshot(path=path)
print("screenshot:", path)
def cmd_eval(arg):
try:
print(json.dumps(page.evaluate(arg)))
except Exception as e:
print("ERROR:", e)
def cmd_console_errors(_arg):
print(json.dumps(console_errors))
def cmd_viewport(arg):
"""Resize the viewport. No args -> 390x844 (iPhone-ish mobile
width); the page itself starts at 1280x900 (desktop) on launch, so
`viewport 1280 900` gets back to that. The app's mobile breakpoint
is 860px (see theme.css) -- anything under that exercises the
off-canvas sidebar/mobile-bar layout."""
parts = arg.split()
width = int(parts[0]) if len(parts) > 0 else 390
height = int(parts[1]) if len(parts) > 1 else 844
page.set_viewport_size({"width": width, "height": height})
print("viewport:", width, "x", height)
def cmd_is_open(arg):
"""App-specific: print whether a <dialog> element is open (true/false)."""
print(json.dumps(page.eval_on_selector(arg, "el => el.open")))
def cmd_bootstrap_admin(arg):
"""App-specific: complete first-run /setup (username/password args,
default admin/testpassword123). Every scratch DB starts with no
users, and /setup is the only way in -- it also auto-links the
pre-existing frame #1 (created by migrations) to the new admin, so
/frames/1/... is reachable right after this."""
parts = arg.split()
username = parts[0] if len(parts) > 0 else "admin"
password = parts[1] if len(parts) > 1 else "testpassword123"
page.goto(BASE + "/setup")
page.fill("input[name=username]", username)
page.fill("input[name=password]", password)
page.click("button[type=submit]")
page.wait_for_load_state("networkidle")
print("bootstrapped admin, now at:", page.url)
def cmd_quit(_arg):
browser.close()
pw.stop()
sys.exit(0)
COMMANDS = {
"nav": cmd_nav,
"wait-for": cmd_wait_for,
"click": cmd_click,
"fill": cmd_fill,
"press": cmd_press,
"screenshot": cmd_screenshot,
"eval": cmd_eval,
"console-errors": cmd_console_errors,
"viewport": cmd_viewport,
"is-open": cmd_is_open,
"bootstrap-admin": cmd_bootstrap_admin,
"quit": cmd_quit,
}
print("run-server driver -- commands:", ", ".join(COMMANDS), flush=True)
print("driver>", end=" ", flush=True)
for line in sys.stdin:
line = line.strip()
if not line:
print("driver>", end=" ", flush=True)
continue
cmd, _, rest = line.partition(" ")
fn = COMMANDS.get(cmd)
if fn is None:
print("unknown command:", cmd, "-- try one of:", ", ".join(COMMANDS))
else:
try:
fn(rest)
except Exception as e:
print("ERROR:", e)
print("driver>", end=" ", flush=True)
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# One-time (idempotent) environment bootstrap for running the
# espresso_frame FastAPI server and browser-driving its UI, in a
# container that ships with NO Python/Node/Docker/browser and NO sudo.
# Re-run any time; every step checks whether it already happened.
set -euo pipefail
cd "$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)/server"
UV_BIN="$HOME/.local/bin/uv"
DEPS_ROOT="/tmp/run-server-chromium-deps"
APT_WORK="/tmp/apt-work-run-server"
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$SKILL_DIR/env.sh"
# 1. uv: a static Rust binary that can fetch its own Python build with
# no C compiler needed (this container has none).
if [ ! -x "$UV_BIN" ]; then
echo "installing uv..."
curl -LsSf https://astral.sh/uv/install.sh | sh
fi
# 2. Python 3.12 + venv + server deps
if [ ! -x .venv/bin/uvicorn ]; then
echo "creating venv + installing server deps..."
"$UV_BIN" python install 3.12
"$UV_BIN" venv --python 3.12 .venv
"$UV_BIN" pip install -r requirements.txt
fi
# 3. Playwright (Python) + its Chromium download (~280MB: full chrome +
# chrome-headless-shell + ffmpeg)
if ! .venv/bin/python -c "import playwright" 2>/dev/null; then
echo "installing playwright..."
"$UV_BIN" pip install playwright
fi
if ! ls "$HOME"/.cache/ms-playwright/chromium-*/chrome-linux64/chrome >/dev/null 2>&1; then
echo "downloading chromium..."
.venv/bin/playwright install chromium
fi
# 4. Chromium's shared libs + fonts. `playwright install-deps` and
# `apt-get install` both need root; neither is available. Instead:
# download the .deb files directly (apt-get download works read-only
# without root once given a user-writable state dir) and extract
# (not install) them with dpkg-deb -x, which needs no root either.
if [ ! -f "$DEPS_ROOT/usr/lib/x86_64-linux-gnu/libglib-2.0.so.0" ]; then
echo "fetching chromium's shared libs + fonts (non-root)..."
mkdir -p "$APT_WORK/lists" "$APT_WORK/cache/archives/partial" "$APT_WORK/debs" "$DEPS_ROOT"
apt-get -o Dir::State::Lists="$APT_WORK/lists" -o Dir::Cache="$APT_WORK/cache" \
-o Dir::Etc::SourceParts=/dev/null update
PKGS="libglib2.0-0t64 libnspr4 libnss3 libatk1.0-0t64 libatk-bridge2.0-0t64
libdbus-1-3 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1
libxkbcommon0 libasound2t64 libatspi2.0-0t64 libcups2t64 libcairo2
libpango-1.0-0 libpangocairo-1.0-0 libx11-6 libxcb1 libxext6
fonts-liberation fontconfig libfontconfig1"
# ^ first row: what chrome-headless-shell's ldd reported missing.
# second row: what the FULL chrome binary additionally needed (we use
# full chrome, not headless-shell -- see Gotchas in SKILL.md).
apt-get -o Dir::State::Lists="$APT_WORK/lists" -o Dir::Cache="$APT_WORK/cache" \
-o Dir::Etc::SourceParts=/dev/null install --download-only --reinstall -y \
--print-uris $PKGS | grep -oP "^'[^']+'" | tr -d "'" > "$APT_WORK/urls.txt"
(cd "$APT_WORK/debs" && xargs -n1 -P8 curl -sS -O --max-time 30) < "$APT_WORK/urls.txt"
for f in "$APT_WORK"/debs/*.deb; do dpkg-deb -x "$f" "$DEPS_ROOT"; done
# fonts.conf as shipped points at the real /usr/share/fonts, which is
# root-owned and has nothing extracted into it. Point it at our
# extracted copy instead, and give it a writable cache dir.
mkdir -p /tmp/run-server-fontcache
sed -i "s#<dir>/usr/share/fonts</dir>#<dir>$DEPS_ROOT/usr/share/fonts</dir>#" \
"$DEPS_ROOT/etc/fonts/fonts.conf"
sed -i "s#<cachedir>.*</cachedir>#<cachedir>/tmp/run-server-fontcache</cachedir>#" \
"$DEPS_ROOT/etc/fonts/fonts.conf"
PATH="$DEPS_ROOT/usr/bin:$PATH" \
LD_LIBRARY_PATH="$DEPS_ROOT/usr/lib/x86_64-linux-gnu:$DEPS_ROOT/lib/x86_64-linux-gnu" \
FONTCONFIG_PATH="$DEPS_ROOT/etc/fonts" \
"$DEPS_ROOT/usr/bin/fc-cache" -f
fi
# 5. tmux -- also missing, also no apt/sudo. Same non-root download +
# dpkg-deb -x trick, into the same extracted root (so its `usr/bin` is
# already on PATH via env.sh).
if [ ! -f "$DEPS_ROOT/usr/bin/tmux" ]; then
echo "fetching tmux (non-root)..."
mkdir -p "$APT_WORK/lists" "$APT_WORK/cache/archives/partial" "$APT_WORK/debs" "$DEPS_ROOT"
apt-get -o Dir::State::Lists="$APT_WORK/lists" -o Dir::Cache="$APT_WORK/cache" \
-o Dir::Etc::SourceParts=/dev/null install --download-only --reinstall -y \
--print-uris tmux | grep -oP "^'[^']+'" | tr -d "'" > "$APT_WORK/tmux_urls.txt"
(cd "$APT_WORK/debs" && xargs -n1 -P3 curl -sS -O --max-time 30) < "$APT_WORK/tmux_urls.txt"
for f in $(sed -E 's#.*/##' "$APT_WORK/tmux_urls.txt"); do dpkg-deb -x "$APT_WORK/debs/$f" "$DEPS_ROOT"; done
fi
CHROME_BIN="$(ls "$HOME"/.cache/ms-playwright/chromium-*/chrome-linux64/chrome | head -1)"
cat > "$ENV_FILE" <<EOF
# Generated by setup.sh. Source this before running driver.py (it
# needs LD_LIBRARY_PATH/FONTCONFIG_PATH set before the Chromium
# subprocess launches -- driver.py does not source it for you).
# start-server.sh does NOT need this file -- uvicorn has no such deps.
export PATH="\$HOME/.local/bin:$DEPS_ROOT/usr/bin:\$PATH"
export LD_LIBRARY_PATH="$DEPS_ROOT/usr/lib/x86_64-linux-gnu:$DEPS_ROOT/lib/x86_64-linux-gnu"
export FONTCONFIG_PATH="$DEPS_ROOT/etc/fonts"
export RUN_SERVER_CHROME_BIN="$CHROME_BIN"
EOF
echo "setup complete -> $ENV_FILE"
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Background-launch the server against a scratch DB/config -- never the
# real deployment's data (see CLAUDE.md). Waits for readiness, prints
# the PID and log path. Usage: ./start-server.sh [scratch-dir] [port]
set -euo pipefail
cd "$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)/server"
SCRATCH="${1:-/tmp/run-server-scratch}"
PORT="${2:-8420}"
mkdir -p "$SCRATCH"
DATABASE_URL="sqlite:///$SCRATCH/test.db" \
CONFIG_PATH="$SCRATCH/config.json" \
LOG_PATH="$SCRATCH/app.log" \
.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port "$PORT" \
> "$SCRATCH/server.log" 2>&1 &
PID=$!
echo "$PID" > "$SCRATCH/server.pid"
for _ in $(seq 1 30); do
curl -sf -o /dev/null "http://127.0.0.1:$PORT/" && break
sleep 0.5
done
if ! curl -sf -o /dev/null "http://127.0.0.1:$PORT/"; then
echo "server did not become ready -- check $SCRATCH/server.log" >&2
exit 1
fi
echo "server PID $PID up on http://127.0.0.1:$PORT (log: $SCRATCH/server.log, db: $SCRATCH/test.db)"
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Usage: ./stop-server.sh [scratch-dir]
SCRATCH="${1:-/tmp/run-server-scratch}"
if [ -f "$SCRATCH/server.pid" ]; then
kill "$(cat "$SCRATCH/server.pid")" 2>/dev/null || true
rm -f "$SCRATCH/server.pid"
echo "stopped"
else
echo "no $SCRATCH/server.pid -- nothing to stop"
fi
+76
View File
@@ -0,0 +1,76 @@
name: Firmware build check
# Fires on every push touching firmware source, unlike
# firmware-release-build.yml (which only builds+publishes when
# firmware/version.txt itself is bumped -- the "cut a release" signal).
# This just verifies every board variant still compiles (or, for ee02,
# that everything up to its known/tracked #error still compiles --
# see that step's own comment); nothing else in CI catches a
# firmware/** push that breaks the build until someone happens to bump
# the version next.
on:
push:
branches: [main]
paths:
- "firmware/**"
- ".gitea/workflows/firmware-build-check.yml"
jobs:
build-check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Same docker create/cp/start pattern as firmware-release-build.yml
# (see that file's own comment for why -- the runner's job
# workspace lives in a named Docker volume, not a real host path,
# so a nested `docker run -v "$PWD:..."` bind-mounts nothing
# useful). No release/artifact step here -- this only needs to
# prove `idf.py build` still succeeds for each board.
- name: Build (devkit -- ESP32-C6-DevKitC-1)
run: |
cid=$(docker create -w /workspace/firmware espressif/idf:release-v6.0 bash -c '
git config --global --add safe.directory /workspace &&
. "$IDF_PATH/export.sh" &&
./build_for_board.sh devkit set-target esp32c6 &&
./build_for_board.sh devkit build
')
docker cp "$PWD/." "$cid:/workspace"
docker start -a "$cid"
docker rm "$cid"
- name: Build (xiao -- Seeed XIAO ESP32-C6)
run: |
cid=$(docker create -w /workspace/firmware espressif/idf:release-v6.0 bash -c '
git config --global --add safe.directory /workspace &&
. "$IDF_PATH/export.sh" &&
./build_for_board.sh xiao set-target esp32c6 &&
./build_for_board.sh xiao build
')
docker cp "$PWD/." "$cid:/workspace"
docker start -a "$cid"
docker rm "$cid"
# Expected to fail until firmware/components/epd13in3e's panel
# driver is ported from vendor demo code (deliberate #error, see
# that file's own top comment) -- continue-on-error so this known
# gap doesn't block every other firmware/** push. Still worth
# running: catches a regression in the surrounding scaffolding
# (Kconfig, main/CMakeLists.txt's component selection, sdkconfig
# layering) up to the point of that #error, same value a build
# check normally provides. Remove continue-on-error once
# epd13in3e's driver is real, so a build failure here goes back to
# being a genuine regression signal.
- name: Build (ee02 -- Seeed EE02, XIAO ESP32-S3 Plus + 13.3in panel)
continue-on-error: true
run: |
cid=$(docker create -w /workspace/firmware espressif/idf:release-v6.0 bash -c '
git config --global --add safe.directory /workspace &&
. "$IDF_PATH/export.sh" &&
./build_for_board.sh ee02 set-target esp32s3 &&
./build_for_board.sh ee02 build
')
docker cp "$PWD/." "$cid:/workspace"
docker start -a "$cid"
docker rm "$cid"
+60 -8
View File
@@ -18,7 +18,7 @@ jobs:
# actions) is a Node action that gets exec'd *inside* whatever container
# the job specifies, so checkout fails immediately with "node: not
# found" (hit this on the first real run). Checkout instead runs on the
# plain runner (which has Node), and only the two build steps below
# plain runner (which has Node), and only the three build steps below
# spin up the ESP-IDF image themselves (docker create/cp/start, see the
# comment on those steps for why not a plain `docker run -v`) -- the
# runner already bind-mounts the host's docker socket, so docker-in-
@@ -33,11 +33,13 @@ jobs:
id: version
run: echo "version=$(tr -d '[:space:]' < firmware/version.txt)" >> "$GITHUB_OUTPUT"
# Two board variants, two partition tables/flash sizes (see
# firmware/README.md's "Building for the Seeed XIAO ESP32-C6"
# section) -- build_for_board.sh gives each its own build dir/
# generated sdkconfig so this never fights over shared state.
# set-target first since a fresh checkout has no cached sdkconfig
# Three board variants: devkit/xiao (ESP32-C6, different partition
# tables/flash sizes -- see firmware/README.md's "Building for the
# Seeed XIAO ESP32-C6" section) and ee02 (ESP32-S3 + 13.3" panel,
# a genuinely different chip target, not just a Kconfig variant).
# build_for_board.sh gives each its own build dir/generated
# sdkconfig so this never fights over shared state. set-target
# first since a fresh checkout has no cached sdkconfig
# (firmware/sdkconfig* is gitignored, see firmware/.gitignore).
# safe.directory guards against git's "dubious ownership" check,
# since the container runs as root over content owned by a
@@ -64,8 +66,15 @@ jobs:
')
docker cp "$PWD/." "$cid:/workspace"
docker start -a "$cid"
docker cp "$cid:/workspace/firmware/build/espresso_frame.bin" /tmp/release-assets/firmware-devkit.bin
docker cp "$cid:/workspace/firmware/build/espresso_frame.bin" /tmp/release-assets/firmware-devkit_esp32c6.bin
docker rm "$cid"
# Rename bridge: fielded devices flashed before this rename still
# report the bare "devkit" board name and look up "firmware-
# devkit.bin" for their OTA check -- publish a duplicate under
# the old name too so they can update at all. Safe to drop this
# duplicate in a later release once no fielded device reports
# the bare name anymore.
cp /tmp/release-assets/firmware-devkit_esp32c6.bin /tmp/release-assets/firmware-devkit.bin
- name: Build (xiao -- Seeed XIAO ESP32-C6)
run: |
@@ -77,7 +86,35 @@ jobs:
')
docker cp "$PWD/." "$cid:/workspace"
docker start -a "$cid"
docker cp "$cid:/workspace/firmware/build_xiao/espresso_frame.bin" /tmp/release-assets/firmware-xiao.bin
docker cp "$cid:/workspace/firmware/build_xiao/espresso_frame.bin" /tmp/release-assets/firmware-xiao_esp32c6.bin
docker rm "$cid"
# Same rename-bridge reasoning as the devkit step above.
cp /tmp/release-assets/firmware-xiao_esp32c6.bin /tmp/release-assets/firmware-xiao.bin
# NOTE: this build is expected to FAIL until
# firmware/components/epd13in3e's panel driver is ported from
# vendor demo code (see that component's own top-of-file comment
# -- a deliberate #error, not a bug here). `continue-on-error` so
# this known, tracked gap doesn't block publishing the devkit/xiao
# release (those boards work today and shouldn't wait on ee02) --
# this step's own status still shows failed/red individually in
# the run's step list, it just doesn't fail the overall job. Once
# epd13in3e's driver is real, a build failure here becomes a
# genuine regression again -- remove `continue-on-error` at that
# point so it goes back to failing the job like the other two
# builds do.
- name: Build (ee02 -- Seeed EE02, XIAO ESP32-S3 Plus + 13.3in panel)
continue-on-error: true
run: |
cid=$(docker create -w /workspace/firmware espressif/idf:release-v6.0 bash -c '
git config --global --add safe.directory /workspace &&
. "$IDF_PATH/export.sh" &&
./build_for_board.sh ee02 set-target esp32s3 &&
./build_for_board.sh ee02 build
')
docker cp "$PWD/." "$cid:/workspace"
docker start -a "$cid"
docker cp "$cid:/workspace/firmware/build_ee02/espresso_frame.bin" /tmp/release-assets/firmware-ee02.bin
docker rm "$cid"
# Plain stdlib urllib rather than `requests` -- not guaranteed to be
@@ -149,10 +186,25 @@ jobs:
existing_assets = {a["name"]: a["id"] for a in release.get("assets", [])}
assets = [
("firmware-devkit_esp32c6.bin", "/tmp/release-assets/firmware-devkit_esp32c6.bin"),
("firmware-xiao_esp32c6.bin", "/tmp/release-assets/firmware-xiao_esp32c6.bin"),
("firmware-ee02.bin", "/tmp/release-assets/firmware-ee02.bin"),
# Rename-bridge duplicates for devices still on old firmware
# reporting the bare "devkit"/"xiao" board names -- see the
# build steps above. Safe to remove once no fielded device
# reports the bare name anymore.
("firmware-devkit.bin", "/tmp/release-assets/firmware-devkit.bin"),
("firmware-xiao.bin", "/tmp/release-assets/firmware-xiao.bin"),
]
for name, path in assets:
if not os.path.exists(path):
# Expected for firmware-ee02.bin while that build is
# still allowed to fail (continue-on-error, see the
# build step's own comment) -- publish whatever boards
# did build rather than crashing the whole release over
# a known, tracked gap.
print(f"Skipping {name}: build did not produce {path}")
continue
if name in existing_assets:
del_status, _ = req("DELETE", f"/releases/{release_id}/assets/{existing_assets[name]}")
print(f"Removed existing asset {name} (status {del_status})")
+58
View File
@@ -8,7 +8,27 @@ on:
- ".gitea/workflows/server-docker-build.yml"
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
working-directory: server
run: pip install -r requirements-dev.txt
- name: Run tests
working-directory: server
run: pytest
build-and-push:
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -32,3 +52,41 @@ jobs:
tags: |
git.thumeit.com/tfaour/espresso-frame-server:latest
git.thumeit.com/tfaour/espresso-frame-server:${{ gitea.sha }}
deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Deploy over SSH
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT || '22' }}
run: |
mkdir -p ~/.ssh
echo "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -p "$DEPLOY_PORT" "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
ssh -i ~/.ssh/deploy_key -p "$DEPLOY_PORT" -o StrictHostKeyChecking=yes \
espressoframe_deployer@"$DEPLOY_HOST" bash -s <<'REMOTE'
set -e
cd ~/espresso-frame
docker compose down --remove-orphans
docker compose pull
# "down" returning doesn't guarantee the OS/docker-proxy has
# actually released port 8420 yet -- an immediate "up -d" right
# after (especially with "pull" a no-op because the image was
# already cached) can lose that race and fail with "port is
# already allocated", even though the exact same "up -d" run a
# few seconds later succeeds every time. Retry instead of
# guessing at a fixed sleep long enough to always cover it.
for i in $(seq 1 10); do
if docker compose up -d; then
exit 0
fi
echo "docker compose up -d failed (attempt $i/10) -- retrying in 3s"
sleep 3
done
echo "docker compose up -d did not succeed after 10 attempts"
exit 1
REMOTE
+15
View File
@@ -10,6 +10,9 @@ firmware/dependencies.lock
firmware/build_xiao/
firmware/sdkconfig.xiao_local
firmware/sdkconfig.xiao_local.old
firmware/build_ee02/
firmware/sdkconfig.ee02_local
firmware/sdkconfig.ee02_local.old
# Python server
server/__pycache__/
@@ -17,6 +20,13 @@ server/**/__pycache__/
server/.venv/
server/*.egg-info/
server/data/
server/.pytest_cache/
# render-service/ (whiteboard mode's Node sidecar) -- installed fresh
# inside the Docker image, never committed. No package-lock.json exists
# yet either (no Node/npm available in this project's dev environment to
# generate one -- see render-service/README.md); if one's added later, do
# NOT ignore it, lockfiles belong in git.
server/render-service/node_modules/
# Real deploy config, copied from docker-compose.yml.example -- holds the
# Immich API key, must never be committed.
server/docker-compose.yml
@@ -27,3 +37,8 @@ server/docker-compose.yml
.idea/
*.swp
.DS_Store
.claude/*
# ...except Claude Code skills (e.g. agent-run instructions for this
# app) -- those are project tooling worth sharing, not personal/local
# state like settings.local.json or worktrees/.
!.claude/skills/
+94
View File
@@ -0,0 +1,94 @@
# espresso_frame
A DIY e-ink photo frame: an ESP32-C6 (`firmware/`, ESP-IDF) driving a
Waveshare 7.3" E Ink Spectra 6 panel (800x480, 6-color, SPI), paired with a
self-hosted FastAPI server (`server/`) that pulls from Immich, does all
image processing (crop/dither/quantize/pack), and serves a placeable
photos/calendar/whiteboard/weather widget system to the device.
CURRENT TODO
-sharing layouts with linked users
-a "coming up this week" widget
-on reset dismiss the menu.
Start here, don't re-derive from scratch:
- [`docs/architecture.md`](docs/architecture.md) -- how firmware and
server talk (sequence diagram, boot flow).
- [`docs/widgets.md`](docs/widgets.md) -- the server-side widget system
(data model, grid placement, compositor, button-action dispatch).
- [`docs/hardware.md`](docs/hardware.md) -- wiring.
- [`server/README.md`](server/README.md), [`firmware/README.md`](firmware/README.md)
-- per-component setup, config, and a lot of accumulated gotchas
(Immich API shape, TLS trust-anchor details, button GPIO wakeup
quirks, etc.) -- check these before assuming something is a new bug.
## Conventions specific to this repo
- **No `Co-Authored-By: Claude` trailers in commits.** Attribution lives
in the root [`README.md`](README.md) instead (see its last line) --
the maintainer's explicit preference, not the default.
- **Copyleft dependencies need an explicit flag, not a silent decision.**
Before adding anything LGPL/GPL/AGPL (or unclear), verify the actual
license via `pip show`/package metadata -- including transitive deps,
not just the top-level package -- and present the finding and tradeoff
in plain text rather than picking an approach unilaterally (hand-rolling
an alternative, swapping packages, silently accepting it). This project
has knowingly accepted AGPL-3.0-or-later exposure once already
(`icalendar-searcher`, a transitive dep of `caldav`) as a deliberate,
explicit call -- not a precedent for skipping the check next time.
- **Scope new auth/access-control broadly, not just to the literal
endpoint named.** When a request changes the trust model (e.g. adding
public-internet exposure), apply the new gate to every endpoint serving
real data or performing a real action, and call out anything you're
tempted to exclude and why. This repo shipped a token gate once that
covered `/api/*` but left `/frame/image` -- the actual photo bytes --
open; caught immediately in production.
- **Commit and push once a task is verified working, without waiting to
be asked separately.** Once tests pass (and, for UI changes, the
browser check has been done), stage the relevant files, write a normal
commit message, and push to the current branch -- the maintainer's
standing authorization for the commit/push step itself. This doesn't
relax anything else: still run `git status`/review the diff before
staging, still never force-push/amend a pushed commit/skip hooks, and
still surface anything that looks like it needs a real decision (e.g.
a change that would trigger `main`'s deploy workflow, see below)
instead of pushing through it silently.
## Working in this repo
- **Server tests**: `cd server && pytest` (SQLite, fixtures wipe/reseed
between tests -- see `tests/conftest.py`). Migration changes need a
matching test in `tests/test_migrations.py`; anything touching
`require_frame_view`/`require_frame_control` boundaries needs a
same-shape permission test (see `tests/test_permission_boundaries.py`
and `tests/test_button_actions.py` for the pattern: owner, linked user,
unrelated user, logged out).
- **UI changes**: verify in a real browser (Playwright), not just by
reading the JS -- this project has hit multiple bugs that only showed up
live (mobile viewport CSS collapse, a dialog's status message landing
behind its own backdrop, a JSON/form-urlencoded body mismatch). Spin up
`uvicorn app.main:app` against a scratch `DATABASE_URL`/`CONFIG_PATH`
sqlite file, don't touch the real deployment's data. `.claude/skills/run-server/`
(`/run-server`) has a driver for exactly this.
- **New/changed UI must work at both desktop and mobile widths --
screenshot both, don't assume one implies the other.** The layout
genuinely forks at the 860px breakpoint (`theme.css`): the sidebar
goes off-canvas behind a hamburger below it. A dialog, header
control, or new widget that looks right at a wide viewport can
overflow, overlap the mobile bar, or mis-center at phone widths.
`run-server`'s driver has a `viewport` command for exactly this
(defaults to a phone size; switch to `1280 900` for desktop).
- **Deploy**: Gitea Actions at `git.thumeit.com/tfaour/espresso_frame`
(`.gitea/workflows/server-docker-build.yml`: `test` -> `build-and-push`
-> `deploy` on any push to `main` touching `server/**`; `deploy` SSHes
into the host as `espressoframe_deployer` and runs `docker compose pull
&& docker compose up -d`). A separate workflow
(`firmware-release-build.yml`) builds+publishes firmware binaries as
Gitea release assets when `firmware/version.txt` changes. Poll CI status
with `curl https://git.thumeit.com/api/v1/repos/tfaour/espresso_frame/actions/tasks`
rather than asking the user to check.
- **Device-facing paths are frozen.** `/frame/image`, `/frame/advance`,
`/frame/back`, `/frame/config`, `/frame/battery`, `/frame/firmware` and
their exact JSON key names (`refresh_interval_s`, `firmware_version`,
etc.) are baked into deployed firmware -- never rename or restructure
these without a firmware-side migration story to match.
+21 -10
View File
@@ -1,9 +1,12 @@
# ESPresso Frame
A DIY e-ink photo frame: an ESP32-C6 pulls photos from your
[Immich](https://immich.app) library and displays them on a 7.3" full-color
A DIY e-ink photo frame: an ESP32 board pulls photos from your
[Immich](https://immich.app) library and displays them on a full-color
e-paper panel, waking on a timer to refresh and spending the rest of its
time in deep sleep.
time in deep sleep. The original build is a 7.3" panel on an ESP32-C6;
a larger 13.3" panel on Seeed's EE02 (ESP32-S3) is supported
server-side, but its firmware driver isn't working yet -- see
[`docs/hardware.md`](docs/hardware.md).
- **No cables to a computer, no SD card shuffling.** Provisioning is a
captive portal with a QR code drawn on the panel itself -- scan, join,
@@ -12,7 +15,9 @@ time in deep sleep.
all the work (pulling from Immich, cropping, dithering, packing into
the panel's exact pixel format) and hands the device a stream it can
write straight to SPI. The ESP32-C6 has no PSRAM and not much SRAM to
spare -- keeping it a dumb display client is what makes that workable.
spare -- keeping it a dumb display client is what makes that workable
(the same design carries over to the ESP32-S3 board even though it
does have PSRAM, for consistency).
- **Crops toward faces, not just the center**, using face bounding boxes
Immich already computed for its own People feature -- no bundled face
detector.
@@ -21,26 +26,32 @@ time in deep sleep.
## Hardware
- ESP32-C6 dev board (8MB flash)
- [Waveshare 7.3" E Ink Spectra 6 (E6)](https://www.waveshare.com/7.3inch-e-paper-hat-e.htm) panel -- 800x480, 6-color, SPI
- ESP32-C6 dev board (8MB flash), or Seeed's XIAO ESP32-C6 (production
board) -- both drive the panel below.
- [Waveshare 7.3" E Ink Spectra 6 (E6)](https://www.waveshare.com/7.3inch-e-paper-hat-e.htm) panel -- 800x480, 6-color, SPI.
- Experimental, not yet working: [Waveshare 13.3" E Ink Spectra 6](https://www.waveshare.com/13.3inch-e-paper-hat-plus-e.htm)
(1600x1200) on [Seeed's EE02](https://www.seeedstudio.com/XIAO-ePaper-DIY-Kit-EE02-for-13-3-Spectratm-6-E-Ink.html)
(ESP32-S3) -- server-side support exists, but the firmware driver's
panel init sequence isn't ported from vendor code yet.
See [`docs/hardware.md`](docs/hardware.md) for wiring and
See [`docs/hardware.md`](docs/hardware.md) for wiring,
[`docs/architecture.md`](docs/architecture.md) for how the two halves talk
to each other.
to each other, and [`docs/widgets.md`](docs/widgets.md) for the server's
placeable photos/calendar/whiteboard widget system.
## Getting started
1. **[`server/`](server/)** -- run the FastAPI server first (Docker
Compose, points at your Immich instance). See
[`server/README.md`](server/README.md).
2. **[`firmware/`](firmware/)** -- build and flash the ESP32-C6, then
2. **[`firmware/`](firmware/)** -- build and flash the board, then
scan the QR codes it draws on first boot to provision it. See
[`firmware/README.md`](firmware/README.md).
## Repo layout
```
firmware/ ESP-IDF project for the ESP32-C6
firmware/ ESP-IDF project (ESP32-C6 devkit/xiao boards, ESP32-S3 ee02)
server/ FastAPI server: Immich -> crop/dither/pack -> the frame
docs/ Wiring and architecture notes
```
+30 -15
View File
@@ -3,14 +3,15 @@
Two independent pieces talk over HTTP or HTTPS (the server itself always
speaks plain HTTP; HTTPS means a reverse proxy in front of it, see
[`firmware/README.md`](../firmware/README.md#http-vs-https)) on the local
network: the ESP32-C6 firmware, and a small FastAPI server that sits
between it and Immich.
network: the ESP32 firmware (ESP32-C6 for the devkit/xiao boards,
ESP32-S3 for ee02 -- see [`docs/hardware.md`](hardware.md)), and a small
FastAPI server that sits between it and Immich.
```mermaid
sequenceDiagram
participant Immich
participant Server as ESPresso Frame Server
participant Frame as ESP32-C6 Frame
participant Frame as ESP32 Frame
Note over Frame: First boot / never provisioned
Frame->>Frame: Generate AP SSID/password, draw QR + config QR on panel
@@ -22,18 +23,18 @@ sequenceDiagram
Frame->>Frame: Connect to home WiFi
alt next-photo button pressed
Frame->>Server: POST /frame/advance
Server->>Server: Force-advance to next queued photo, reset interval clock
Server->>Server: Run every action assigned to NEXT, in order<br/>(may span several widgets -- see docs/widgets.md)
else back-photo button pressed
Frame->>Server: POST /frame/back
Server->>Server: Return to previously-current photo (bounded history),<br/>reset interval clock
Server->>Server: Run every action assigned to BACK, in order
else normal wake
Frame->>Server: GET /frame/image
Server->>Server: Advance only if refresh_interval_s has elapsed<br/>since the current photo was set -- otherwise a no-op
Server->>Server: Render every widget on the panel into its own region<br/>(each independently idempotent -- a photo widget only<br/>actually advances once its own refresh_interval_s has elapsed)
end
Server->>Immich: List album assets / download preview / faces
Server->>Immich: List album assets / download preview / faces<br/>(once per photo widget on the panel)
Immich-->>Server: JPEG + face bounding boxes
Server->>Server: Crop (face-aware) + quantize (dither) + pack 4bpp
Server-->>Frame: 192,000 raw bytes, streamed
Server->>Server: Composite every widget's region onto one canvas,<br/>then enhance/overlay/quantize (dither)/pack 4bpp once
Server-->>Frame: packed 4bpp bytes, streamed<br/>(192,000 for the 7.3" panel; sized to whichever<br/>panel this frame's device reports, see Frame.panel_type)
Frame->>Frame: Write to panel SPI buffer, compute CRC32
alt CRC unchanged since last physical refresh
Frame->>Frame: Skip refresh (nothing visually changed)
@@ -45,6 +46,17 @@ sequenceDiagram
Frame->>Frame: Deep sleep (server-configured interval, or a short<br/>retry interval on any failure)
```
The device-facing endpoints above (`/frame/image`, `/frame/advance`,
`/frame/back`, `/frame/config`) are frozen -- baked into deployed firmware
-- and unchanged by any of this. What *does* change server-side: a frame's
panel isn't a single fixed "mode" anymore, it holds an arbitrary
arrangement of independently placed/sized widgets (photos/calendar/
whiteboard, including several of the same type), each rendered into its
own region and composited together, with NEXT/BACK each mapped to their
own ordered list of per-widget actions rather than one fixed meaning. See
[`docs/widgets.md`](widgets.md) for the widget system's data model,
placement grid, and button-action dispatch.
## Firmware boot flow
1. **No stored config** (first boot, or NVS erased): bring up the display,
@@ -72,8 +84,9 @@ sequenceDiagram
once).
- Fetch the frame and write it into the panel's SPI buffer
(`epd_write_frame()`), computing a CRC32 as it streams -- never
buffering the full ~192KB frame in RAM. The panel driver refuses to
write a short/wrong-size response into the buffer at all, so a
buffering the full packed frame in RAM (~192KB for the 7.3" panel;
proportionally more for the 13.3" panel). The panel driver refuses
to write a short/wrong-size response into the buffer at all, so a
truncated fetch can't corrupt what's already there.
- Compare the new CRC32 against the last one that was actually
refreshed onto the panel (persisted in NVS). If it matches -- the
@@ -91,9 +104,9 @@ sequenceDiagram
- Deep sleep for the server-configured interval on success, or a
shorter retry interval on any failure.
The menu/reset button's soft-reset and factory-reset tiers (held ~3s
or ~15s) are handled earlier, before any of this, and never return --
see [`firmware/README.md`](../firmware/README.md#managing-the-queue-soft-resetting-and-factory-resetting).
The menu/reset button's soft-reset (quick press) and factory-reset
(held ~15s) tiers are handled earlier, before any of this, and never
return -- see [`firmware/README.md`](../firmware/README.md#managing-the-queue-soft-resetting-and-factory-resetting).
See [`docs/hardware.md`](hardware.md) for wiring and
[`server/README.md`](../server/README.md) for the server side.
@@ -106,7 +119,9 @@ git history). Decoding a JPEG, then resizing/dithering/quantizing it to
the panel's 6-color palette, would be expensive on-device in both memory
and battery. Instead, the server does all of that with Pillow and hands
the frame a pre-packed, ready-to-stream buffer -- the device never
decodes an image at all.
decodes an image at all. The ee02 board's ESP32-S3 does have PSRAM, but
the same server-side design applies there too, for consistency and
battery reasons rather than because the C6's memory limit forces it.
## Why face detection isn't run on-device (or even on the server)
+124 -4
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
@@ -152,3 +152,123 @@ photo. A full-color refresh on this panel takes 15-30+ seconds and draws
more current than deep sleep by a wide margin -- expect battery life (if
not running from USB power) to be dominated by refresh frequency, not
sleep current.
## Board identifiers
Each board reports a name to the server (`X-Frame-Board`,
`CONFIG_FRAME_BOARD_NAME`) that's chip-qualified rather than the plain
`devkit`/`xiao` older firmware used -- `devkit_esp32c6`, `xiao_esp32c6`,
`ee02` (see below). This changed once a second XIAO-based board (EE02,
an ESP32-S3) existed and "xiao" alone stopped disambiguating hardware.
The server keeps accepting the old bare names indefinitely, since
already-flashed devices can't be retroactively renamed.
## 13.3" Spectra 6 panel on Seeed's EE02 board (panel driver ported, `ee02` builds end-to-end; unverified on real hardware)
A second panel size is supported server-side (the web UI shows a
read-only "Panel: 13.3\" Spectra 6" once a frame's device reports
itself as `ee02`), and **the panel driver itself is now real and
compiles clean** -- `firmware/components/epd13in3e`'s init/LUT/refresh
register sequence is a line-for-line port of Waveshare's own reference
drivers for this exact panel+controller, confirmed identically across
three independent vendor sources (Waveshare's RaspberryPi/c and ESP32
drivers for this panel, plus Waveshare's own ESP-IDF example for their
ESP32-S3-ePaper-13.3E6 driver board -- a different carrier than EE02,
but the same panel/controller, hence the same command bytes). See that
component's own top comment for details, and
`server/app/image_pipeline.py`'s `PANEL_WIRE_TRANSPOSE` for a load-bearing
correction that came with it: the panel's SPI wire raster is a *native
1200x1600 (portrait)* raster, rotated 90 degrees from the panel's
1600x1200 landscape mount/marketing size -- getting that backwards
doesn't just rotate the image, it shreds it (1600x1200 and 1200x1600
don't share a row stride).
**A full `ee02` build now succeeds** (verified locally with a native,
non-Docker ESP-IDF v6.0 install -- see
`.claude/skills/build-firmware/SKILL.md`); `firmware/main/{back,next,combo}_button.c`
used to call `esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown()`, an
ESP32-C6-only deep-sleep GPIO-wakeup API (gated by
`SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP`, which ESP32-S3's
`soc_caps.h` doesn't define) with no ESP32-S3 fallback path. Each of the
three button files now branches on that same capability macro: the
ESP32-C6 path (devkit/xiao) is untouched, and a new ESP32-S3 path uses
`esp_sleep_enable_ext1_wakeup_io()` (not the non-`_io()`
`esp_sleep_enable_ext1_wakeup()`, which resets any previously-registered
mask -- the `_io()` variant is additive, confirmed by reading
`esp_hw_support/sleep_modes.c`, so the three button files can each keep
registering their own GPIO independently, no combined-mask coordination
needed) plus `esp_sleep_get_ext1_wakeup_status()` for the wake-cause
check. The original C6 EXT1 attempt was rejected on hardware because
its pull resistor didn't hold across the RTC_PERIPH power-down (see
`firmware/main/next_button.c`'s `next_button_init()` comment) -- tracing
the same code path for ESP32-S3 shows `gpio_config()`'s `pull_up_en`
(already used by all three button files) delegates to
`rtc_gpio_pullup_en()` for RTC-capable pins on every non-original-ESP32
target (confirmed in `esp_driver_gpio/gpio.c`: `GPIO_RTCIO_ARE_INDEPENDENT`
is 1 for both C6 and S3, meaning the digital and RTC pull registers are
independent hardware and `gpio_config()` already sets the RTC one), so
the pull-up should already survive the same power-down on ESP32-S3
without any extra `rtc_gpio_*` calls. That reasoning is verified against
IDF source, **not against real EE02 hardware** -- a clean compile
confirms the code builds and links, not that it's actually
spurious-wakeup-free on a real board. CI's
`firmware-build-check.yml`/`firmware-release-build.yml`
`continue-on-error` on this board's step is intentionally still in place
until that hardware verification happens.
Confirmed so far:
- Panel: [Waveshare 13.3" e-Paper (E) Spectra 6](https://www.waveshare.com/13.3inch-e-paper-hat-plus-e.htm) --
1600x1200 mount size, 270.40x202.80mm, same 6-ink Spectra family as
the 7.3" panel (and, now vendor-confirmed, the identical 4-bit nibble
color codes). Full refresh ~19s. SPI wire raster is 1200x1600 (see
above).
- Board: [Seeed's EE02](https://www.seeedstudio.com/XIAO-ePaper-DIY-Kit-EE02-for-13-3-Spectratm-6-E-Ink.html) --
a XIAO ESP32-S3 Plus (16MB flash, 8MB PSRAM) socketed into a dedicated
driver PCB, one reset + three user buttons, JST 2.0mm battery
connector with built-in charging IC.
- Wiring (source: [github.com/rkaramandi/esphome-seeed-ee02](https://github.com/rkaramandi/esphome-seeed-ee02), a community integration, not Seeed's own schematic --
treat as a starting point, confirm before relying on it; Waveshare's
own ESP32-S3-ePaper-13.3E6 example uses different GPIO numbers, but
that's for Waveshare's own driver board, a different carrier than
EE02, so it doesn't apply here). Unlike epd7in3e's single chip-select,
this panel is driven as two halves sharing one CLK/MOSI/DC/RST/BUSY bus
with independent chip-selects -- now confirmed by the real driver code
too (master = left half, slave = right half of each row).
| Signal | GPIO | Kconfig option |
| --- | --- | --- |
| CLK | 7 | `EPD_PIN_CLK` |
| MOSI | 9 | `EPD_PIN_MOSI` |
| CS (master half) | 44 | `EPD_PIN_CS_MASTER` |
| CS (slave half) | 41 | `EPD_PIN_CS_SLAVE` |
| DC | 10 | `EPD_PIN_DC` |
| RST | 38 | `EPD_PIN_RST` |
| BUSY | 4 | `EPD_PIN_BUSY` |
| Panel power-enable | 43 | `EPD_PIN_POWER_EN` |
User buttons are reportedly at GPIO 2/3/5, but which physical button
maps to which logical role (next/back/menu) still isn't confirmed. The
firmware's button Kconfig options (`FRAME_NEXT_BUTTON_GPIO` etc.,
`firmware/main/Kconfig.projbuild`) now range to GPIO -1 to 21 under
`IDF_TARGET_ESP32S3` (the ESP32-S3's own ext1-wakeup-capable RTC-IO
range) instead of the ESP32-C6-shaped -1 to 7, so GPIO 2/3/5 fit
regardless -- but `firmware/sdkconfig.ee02` still deliberately doesn't
override the defaults inherited from the C6 boards (GPIO 2/0/1) until
the role mapping above is confirmed.
SPI clock is reportedly reliable only up to 2MHz on this
panel/board per the community ESPHome integration (vs. epd7in3e's 4MHz
default) -- see `firmware/sdkconfig.ee02`. Waveshare's own
ESP32-S3-ePaper-13.3E6 example defaults to 10MHz, but that's a
different carrier board, so it's a data point to try once real EE02
hardware exists, not a reason to bump the current conservative default
blind.
Remaining unknowns before trusting this on real hardware: whether the
ESP32-S3 button-wakeup path above actually avoids a spurious-instant-wakeup
on a real board (not just compiles), the button-to-role mapping, the
wiring table (community-sourced, not official), and
`PANEL_WIRE_TRANSPOSE`'s rotation *direction* (`ROTATE_90` vs
`ROTATE_270` -- a physical-assembly fact no vendor driver encodes, see
that dict's own comment in `image_pipeline.py`).
+584
View File
@@ -0,0 +1,584 @@
# Widget system
A frame's panel isn't one fixed "mode" anymore -- it holds N independently
placed/sized widgets (photos/calendar/whiteboard/tasks/static image/text/
weather/battery), like arranging icons on an Android home screen. A frame
can hold several widgets of the same type (e.g. two photo widgets pointed
at different Immich albums side by side).
This replaced an earlier design where `Frame.mode` picked exactly one
full-panel renderer; that column and the other per-mode `Frame` columns
it left behind (`album_id`, `calendar_*`, `whiteboard_*`, etc.) were
dropped in migration 41, once every phase of the rollout had shipped
(see "Known gaps" below for what's still open).
The device-facing contract is unchanged by any of this: `GET /frame/image`,
`POST /frame/advance`, `POST /frame/back` are the same frozen paths
firmware has always called (see `docs/architecture.md`) -- what changed is
entirely server-side, in how those endpoints decide what to render and what
a button press does.
## Data model
- `Widget` (`server/app/models.py`): `id`, `frame_id`, `widget_type`
(`"photos"` | `"calendar"` | `"whiteboard"` | `"tasks"` | `"static"` |
`"text"` | `"weather"` | `"battery"`), `x`/`y`/`w`/`h`
(grid cells), `sort_order`. Widgets never overlap (enforced server-side in
`routers/api_widgets.py`, re-validated regardless of what the client
already checked) -- that's what keeps compositing simple: no z-order,
no blending, just N independent regions pasted onto one shared canvas.
Also carries an optional per-widget border (`border_style` -- `"none"`
| `"solid"` | `"dashed"` | `"dotted"` | `"fancy"`, `border_thickness`,
`border_color_index`, an index into the frame's palette so a border
always renders as one of the panel's exact 6 ink colors) directly on
`Widget` itself rather than a per-type config table, since every
widget type can have one regardless of `widget_type`. Drawn by
`image_pipeline.draw_widget_border` onto each widget's own region in
`routers/device.py`'s `_render_widgets`, before that region is pasted
onto the shared canvas -- one central integration point instead of
every `app/widgets/*.py` module needing to know about it. Set via the
gear-icon dialog's shared "Border" card (`_widget_border_fields.html`,
included by every `_widget_dialog_*.html` template) and
`POST .../widgets/{id}/border`, its own endpoint (not folded into
`api_widget_config_save`) since that endpoint's per-type dispatch is
keyed on a config row via `widget_locked`, and border fields live on
`Widget` itself, not any per-type config table.
Also carries `font_scale` (one of `panel_style.FONT_SCALE_CHOICES` --
`1.0`/`1.25`/`1.5`, labeled Normal/Large/X-Large), a per-widget
legibility control: calendar and tasks widgets pack in the most body
text at the smallest default sizes, so their gear-icon dialogs get a
"Text size" card (`_widget_font_scale_fields.html`) the other types
don't. Same Widget-level-property-not-config-field reasoning as
border, and its own `POST .../widgets/{id}/font-scale` endpoint for
the same reason. `panel_style.scaled_size(value, font_scale)` is the
one shared multiply-and-round point every classic (`calendar_render.py`)
and modern (`html_render.py`/`calendar_html_render.py`) size calc
routes through immediately after its own tier lookup/floor, so row
heights and per-view row caps (already derived from the font size, not
a fixed constant) automatically re-fit around the bigger text instead
of overflowing their box.
- Per-type 1:1 extension tables -- `PhotoWidgetConfig`,
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`,
`StaticWidgetConfig`, `TextWidgetConfig`, `WeatherWidgetConfig`,
`BatteryWidgetConfig`, each keyed by `widget_id` with
`ondelete="CASCADE"` -- rather than one wide table with every type's
mostly-irrelevant columns. `TextWidgetConfig.content` is parsed rich
text (paragraphs of styled runs), never raw HTML -- see
`server/app/text_content.py`'s module docstring for why that parse
step is the widget's actual stored-XSS sanitization boundary.
`PhotoWidgetConfig`
mirrors `app/photo_queue.py`'s attribute names exactly, so that module's
advance/back/queue logic ports across widget instances unchanged.
`PhotoWidgetConfig.locked` (migration 27) freezes `current_asset_id`
against both the timer-elapsed auto-advance
(`photo_queue.get_current`) and the advance/back button actions
(`app/widgets/photos.py`'s `ACTIONS`) until unlocked -- toggled via a
"Lock this photo" button in the widget's own dialog
(`POST .../widgets/{id}/lock`), shown as a lock badge on the widget's
box on the Layout tab canvas.
`TaskWidgetConfig` used to be a handful of `tasks_*` columns bolted onto
`CalendarWidgetConfig` (a week-view-only, single-list task list); split
into its own widget type (migration 17) so a task list can be placed
and sized independent of any calendar's view/footprint, then (migration
18) given the same multi-source shape a calendar widget already has.
`WeatherWidgetConfig` similarly lifts `CalendarWidgetConfig`'s embedded
weather strip (still present and unchanged, `weather_*` columns) out
into its own placeable widget type (migration 24) -- see "Weather
widget" below. `BatteryWidgetConfig` (migration 25) is the odd one out
-- its actual content (`Frame.battery_percent`/`battery_as_of`) isn't
in this table at all, already existing frame-level state set by
`routers/device.py`'s `frame_battery` regardless of whether a battery
widget is even placed; the config row only holds a display-mode
setting (`"compact"` | `"detailed"`).
- `FrameCalendar`/`FrameTaskList` are keyed by `widget_id` (not
`frame_id`) since a frame can now have more than one independent
calendar/tasks widget, each with its own included set. Identical
shape and permission model (owner-added, anyone-linked-can-mute, see
"Per-widget config UI" below) -- `FrameTaskList` just has no `"ics"`
calendar_key variant, since a plain ICS subscription has no VTODO
(task) collection.
- `FrameButtonAction` (`id`, `frame_id`, `button` [`"next"`|`"back"`],
`widget_id`, `action`, `sort_order`) -- see "Button actions" below.
## Placement: a grid, not freeform pixels
`app/grid.py` is pure grid math, no I/O. The grid is `GRID_LONG=8` x
`GRID_SHORT=5` cells, defined relative to the panel's long/short axis
(not "landscape" specifically) so it stays valid across
`image_pipeline.logical_render_size(orientation)`'s genuine width/height
swap for portrait -- landscape orientations are 8 cols x 5 rows, portrait
are 5 cols x 8 rows, same cell size either way. **Changing a frame's
orientation invalidates its existing layout** (an 8x5 arrangement isn't
valid on a 5x8 grid) -- the server resets to one full-panel widget on an
orientation change rather than trying to remap coordinates.
Each widget type has a minimum grid footprint (`grid.MIN_FOOTPRINT`):
photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of
size-tier scaling), whiteboard 2x2, tasks 2x2, static image 1x1, text 2x1,
weather 2x2 (its hourly/daily strips need the room; current/multi_city
modes would tolerate smaller, but every mode shares one footprint value),
battery 1x1 (just an icon + a percent, legible even at a single cell,
like photos/static -- though see `MIN_FOOTPRINT`'s own comment in
`grid.py` on a mobile-width gear-icon click-target gap at that size,
already pre-existing for photos/static too). Enforced both client-side
(UX, in the Layout tab's drag/resize canvas -- `static/frame_layout.js`)
and server-side (`routers/api_widgets.py`) -- the client is never trusted
alone.
## Rendering: one shared compositor
`app/widgets/` is the render/action registry -- one module per
`widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`, `tasks.py`,
`static_image.py`, `text.py`, `weather.py`, `battery.py`), each exposing:
- `render(db, frame, widget, target_w, target_h, is_normal_wake) -> Image`:
an unquantized RGB image exactly `target_w x target_h`, the widget's
content composed into its own region. Never raises for a foreseeable
failure (an Immich hiccup, an unconfigured widget) -- falls back to a
small placeholder within its own region instead, so one widget having a
bad moment doesn't blank the whole panel.
- `ACTIONS: dict[str, Callable]` -- named button actions this type
supports (`"advance"`/`"back"` for photos and calendar, `"check_now"`
for whiteboard and weather -- both throttled external fetches with a
forced-refetch action). Empty for tasks, static image, text, and
battery -- nothing to advance/back/force for a passive checklist, a
fixed uploaded image, a fixed block of authored text, or a number the
device itself pushes on every wake.
- `ACTION_LABELS: dict[str, str]` -- human labels for the button-
assignment UI.
`routers/device.py`'s `_render_widgets` loads every `Widget` row for the
frame, maps each one's grid rect to pixels (`grid.cell_to_pixels`), calls
its module's `render()`, and hands the whole list of `(rect, image)`
regions to `image_pipeline.render_panel` -- which pastes every region onto
one shared canvas, then runs enhance/manage-overlay/quantize/dither/pack
**once** over the composited result. Quantizing the whole canvas together
(not each region separately before pasting) is what keeps the 6-color
e-ink dithering pattern consistent across a widget boundary instead of a
visible seam at the edge.
Calendar widgets pick from discrete size tiers (`calendar_render.py`'s
`_SIZE_TIERS`) for font size/margins/row heights based on their actual
grid footprint, rather than continuously scaling constants tuned for a
full ~800x480 canvas -- falls back to agenda view if a widget is too small
for month view to stay legible. These tiers are pixel-size constants
tuned against the 7.3" panel specifically; they aren't re-tuned or
verified yet for the 13.3" panel's larger native resolution (see
`docs/hardware.md`'s EE02 section) -- a widget's *grid footprint* (cell
count) works the same on either panel, but its rendered legibility at
that footprint's actual pixel size hasn't been checked on the bigger
panel.
### "Modern" render style (experimental)
Every widget type except photos has a `render_style` column (`"classic"`
default | `"modern"`) that swaps its hand-drawn PIL primitives for an
HTML/CSS render: a Jinja2 template (`app/templates/widget_html/`) drawn
through a persistent headless-Chromium browser (`app/html_render.py`,
Playwright) instead of `ImageDraw` -- gradients, shadows, and soft icon
shading PIL can't easily do. Calendar's own modern-style builders (all
four view modes) live in `app/calendar_html_render.py` rather than
`html_render.py` itself, mirroring `calendar_render.py`'s own separation
from the simpler widget types.
Every modern-style builder runs its own `ordered_dither` (Bayer/ordered,
not Floyd-Steinberg) before returning, committing the widget to exact
palette colors *before* compositing -- safe to mix with photo/other
classic-rendered widgets on the same frame without a Floyd-Steinberg
seam at the boundary, because ordered dithering has no cross-pixel error
term the way Floyd-Steinberg's diffusion does (see `html_render.py`'s
module docstring). No `Frame`-level dithering setting was needed to make
this work.
Not offered for the **photos** widget -- a real photograph isn't a
synthesized dashboard card, and photos has a different concern instead:
its own independent palette/dithering strength (`Frame.photo_palette_rgb`
/ `photo_dither_strength`, a second "Photos configuration" card in
Advanced Configuration, separate from the main `palette_rgb`/
`dither_strength` every other widget uses). `widgets/photos.py`'s
`render()` quantizes itself against these before returning, so a frame
can tune the rest of its widgets' look (e.g. a calibrated palette for
modern-style dashboard widgets) independently of what actually looks
best for real photographs, with no `render_panel` changes needed --
see that module's own docstring for the one small, accepted edge case
(a border on a photos widget whose palette genuinely diverges from the
frame's main one).
Playwright/Chromium is a real, heavyweight runtime dependency imported
lazily only when a widget actually uses modern style. Its browser binary
is fetched by `start.sh` at container startup rather than baked into the
image (see `server/Dockerfile`'s own comment) -- a single ~181MB
`chrome-headless-shell` binary can't be split across Docker layers the
way this project's pip/npm installs were, and confirmed-failed to push
to the registry as a build-time layer; cached on the `/data` volume
(`PLAYWRIGHT_BROWSERS_PATH`) so only the very first boot on a fresh
volume actually downloads it. Still real-panel-unverified -- treat every
"modern" style as experimental regardless of deploy status.
Per-widget-type notes:
- **weather**: `current`/`daily` modes only -- `hourly`/`multi_city`
always render classic regardless of this setting (see the Weather
widget section below).
- **calendar**: all four view modes (agenda/today_tomorrow/week/month)
have a modern builder -- the only widget type with full modern-style
coverage from the start, rather than a partial rollout like weather's.
Month view's "falls back to agenda below a size threshold" behavior
(`_month_view_fits`) is honored identically in both styles.
- **battery/text/tasks**: full coverage (both battery modes; text reuses
its own `_fit()` shrink-to-fit sizing logic, only the drawing differs).
- **static image/whiteboard**: modern style is the *first* visual chrome
either widget type has ever had (classic draws the image with zero
frame/card at all) -- a rounded-corner, shadowed card
(`framed_image.html.jinja`, shared between the two) wrapping the
already-composed image. Left alone by the "bold minimal" pass below --
it never had the reskinned-classic problem the other widgets did.
### "Bold minimal": a real redesign, not just a reskin
The initial modern-style rollout (above) mostly translated each widget's
*existing* classic layout into HTML/CSS -- same gradient header banner,
same rounded-shadowed white card, prettier chrome around an unchanged
composition. A second pass reworked weather (`current`/`daily`),
calendar (all four views), tasks, and battery into an actual different
visual language, picked from several divergent directions rendered
through the real pipeline and reviewed with the maintainer (not chosen
unilaterally -- see the "Reverted e-ink quantization attempt"-style
caution about visual changes needing more than one look). Text and
static/whiteboard were deliberately left as they were (see their notes
just above) -- text already had zero chrome and its styling is
user-authored content, not this system's to redesign; the framed-image
card was already minimal.
What changed, as a consistent language across every redesigned widget:
- **No card.** No rounded-corner white box, no drop shadow, no outer
border -- content sits directly on the shared white canvas. `theme
["radius"]`/`theme["shadow"]` are now unused by every redesigned
widget's builder (still resolved, for signature uniformity with
`resolve_theme`, but nothing reads them) -- a theme's radius/shadow
fields now only affect the *un*-redesigned modern widgets (static
image/whiteboard's `framed_image.html.jinja`).
- **A slim accent rule instead of a gradient banner.** Every widget that
used to have a colored header bar with white text on it (weather's
`build_daily`, tasks, calendar's four views) now has a thin (~4-8px)
accent-colored rounded rule, with the header text as plain ink below
it instead of white text on top of it -- only that thin rule dithers
at the theme's richer `accent_amplitude` via `ordered_dither_regions`
now, not the header text sitting on it, which reads as a legibility
improvement, not just a visual one (see "Rich accent hues" below).
- **A dominant hero value, not a centered icon+number of equal weight.**
Weather's `build_current` and battery's icon+percent used to be drawn
at roughly the same size, centered as a unit; both now put the numeric
value (temperature / battery percent) at a clearly dominant size, with
the icon small and secondary above it -- closer to a phone home-screen
widget than a dashboard tile.
- **Padding/type sizes as a proportion of widget size, clamped to a
floor/ceiling, not a fixed pixel value.** So a 1-2 grid-cell widget
doesn't get comically large padding relative to its content, and a
near-full-panel widget doesn't get comically small padding either --
see `html_render._clamp` and every redesigned `build_*`'s own
`pad`/size calculations (`base = min(target_w, target_h)`, then a
fraction of `base` clamped to tuned floor/ceiling values).
**A hairline color this palette can't actually render.** Auditing the
month view's grid during this pass turned up a real, pre-existing bug
carried forward unnoticed since the very first modern-style rollout:
`.day-cell`/`.day-section`/`.col` divider borders used a pale gray
(`#e2e6ec`) -- but `DEFAULT_PALETTE_RGB` has no gray in it at all (black/
white/yellow/red/blue/green only), so a color that close to white always
nearest-matches to pure white regardless of Bayer bias, at any amplitude
-- confirmed by sampling actual rendered pixels, not just eyeballing a
screenshot. The month grid's week-row dividers now use real solid black
(`RULE`-equivalent, matching how the *classic* PIL renderer always drew
them -- see `calendar_render.RULE`); the day-section/week-column dividers
were simply dropped instead, since the accent rule + spacing at the
start of the next section/column already read as a clear boundary
without a line at all once you could actually render one.
### Themes for modern-style widgets
`Frame.theme` (String, default `"classic"`, one Advanced Configuration
`<select>`) picks a curated visual preset for every modern-style widget
on that frame -- font family, corner radius, drop shadow, and an accent
hue for widgets with a header/accent region. Presets live in
`app/theme_tokens.py`'s `THEMES` dict; `resolve_theme(theme_name,
widget_kind, palette_rgb)` turns one into concrete, ready-to-render
values (`accent_hex`/`accent_hex_dark`, resolved `font_regular`/
`font_bold` file paths, `radius`, `shadow`, `accent_amplitude`). Inspired
by [Tesserae](https://github.com/dmellok/tesserae)'s (AGPL-3.0) own
three-layer CSS custom-property theme system -- this is an original
reimplementation of that *architecture*, not a copy of its token file
(see this repo's `CLAUDE.md` on copyleft dependencies).
**What a theme actually changes, in practice**: the accent color (now a
slim rule rather than a full header band -- see "Bold minimal" above) is
still the most visible change on widgets that have one, but `font_family`
applies to *every* text element in the widget, not just the header title
-- day labels, temperatures, task rows, event times, day numbers all
switch fonts too (e.g. "Moss" is serif, "Ochre" a slab serif), often
more noticeable than the accent color on text-heavy widgets. `radius`/
`shadow` only affect static image/whiteboard's card now (every other
modern-style widget dropped its card in the "bold minimal" pass); text
never used them (no card from the start) and weather/battery/tasks/
calendar no longer have a card for them to apply to either.
**A theme is purely stylistic, never functional color-coding.** Battery's
charge-level red/yellow/green, calendar/tasks' per-owner event color
chips, and text's user-authored inline run colors are status/identity
signals, not style choices -- no theme may recolor them, and every
`build_*`/`resolve_theme` call site that touches those stays on its own
existing logic untouched. Text's own per-widget `font_family` setting
(a user's explicit content-level choice, same carve-out reasoning) is
similarly never overridden by a theme -- `build_text` accepts a
`theme_name` param for signature uniformity with every other modern-
style builder but deliberately ignores it.
**Rich accent hues, not just the 6 exact panel inks.** A theme's
`accent_hex` can be any arbitrary color (e.g. terracotta, moss, slate) --
`html_render.ordered_dither_regions(rendered, palette_rgb,
base_amplitude, accent_regions=[(rect, amplitude), ...])` dithers the
whole widget at the existing safe default (`ordered_dither`'s tuned 48,
unchanged, still icon/text-legible) and then *separately* re-dithers
just the accent rectangle (a header bar's already-computed pixel rect)
at a theme's higher `accent_amplitude` (~130) and pastes it back. Safe
to do per-region for the same reason `ordered_dither` itself is safe
per-widget: ordered (Bayer) dithering has no cross-pixel error term, so
a region's result depends only on its own pixels. A single higher
amplitude applied to the *whole* widget instead was tried and rejected --
it washes out pale content (a weather icon's white cloud body nearly
vanished in testing); confining the higher amplitude to just the accent
rect avoids that while still letting the rect approximate a rich hue via
denser stippling instead of flatly snapping to one nearest ink (what
happens to a rich hue at the base amplitude).
**"classic" is a deliberately no-visual-change default.** Its
`accent_hex` is `None`, meaning "keep this widget kind's own pre-theme
look exactly": weather's header was always a fixed blue gradient (now
`theme_tokens._CLASSIC_WEATHER_GRADIENT`, byte-identical to the old
module-level `ACCENT_START`/`ACCENT_END` constants this system
replaced); tasks/calendar's header was always a flat single ink resolved
through `panel_style.THEME` (still is, just via `resolve_theme` now).
Widget kinds with no ink of their own (battery/text/static/whiteboard)
fall back to black, though none of their templates currently have an
accent-colored surface for it to visibly affect.
Which widgets get the richer accent-region treatment: weather's
`build_daily` (the slim rule, when `city_label` is set), tasks, and
calendar's four view builders -- each computes its own small accent-rule
pixel rect (a fixed-height band, not the old full header_h) and passes
just that to `ordered_dither_regions`. Weather's `build_current` and
battery have no accent surface at all (no header of any kind -- see
"Bold minimal" above) and static/whiteboard's shared `build_framed_image`
is unchanged from the original rollout; all three call plain
`ordered_dither` with no accent region.
## Button actions
Each physical button (NEXT/BACK) runs the `(widget, action)` binding of
every widget on the frame that has one -- **at most one binding per
widget per button** (a widget can't be bound to two different actions on
the same button). On a press, `routers/device.py`'s `_run_button_actions`
runs every widget's assigned action for that button (each in its own
`widget_locked` span -- never nested, since the underlying per-frame lock
isn't reentrant), catching and logging any single action's failure
without blocking the rest, then re-renders and returns the whole composed
panel once at the end regardless of which actions succeeded. Which
widget's action runs first never matters -- each only touches its own
state, and the shared re-render happens once, after all of them finish.
The UI for this lives in each widget's own gear-icon config dialog (the
"Button actions" card, `templates/_widget_button_fields.html` +
`static/widget_dialog_button_actions.js`, `POST
/api/frames/{id}/widgets/{widget_id}/button-actions`) -- not a frame-level
tab, since assigning a widget's next/back behavior is naturally part of
configuring that widget. The card only renders for widget types with a
non-empty `ACTIONS` (photos, calendar, whiteboard, weather); tasks/
static/text/battery have nothing to bind so the card is omitted for
them. An empty selection ("(none)") clears that button's binding for the
widget.
A newly-created widget (including the one auto-migrated from a frame's
old `mode` on upgrade) gets a sensible default binding reproducing its
old button behavior -- see `widgets.default_button_actions` (called from
both `migration.py`'s backfill and `api_widgets.py`'s
`api_widget_create`), so a widget is never left with nothing bound until
someone deliberately reassigns it.
### Hold-for-global-action
Holding NEXT or BACK past a configurable duration (`Frame.hold_duration_ms`,
minimum 3000ms, set on the Configuration tab) triggers a **global**
action instead of the per-widget one -- not scoped to any widget, e.g.
cycling through the user's saved layouts. See `app/global_actions.py`'s
`GLOBAL_ACTIONS`/`GLOBAL_ACTION_LABELS` registry and
`routers/device.py`'s `/frame/global-next`/`/frame/global-back` (the
device calls these instead of `/frame/advance`/`/frame/back` once it
detects a long press -- see `firmware/main/next_button.c`/`back_button.c`).
`Frame.next_hold_action`/`back_hold_action` pick which registry entry (if
any) each button's hold triggers; unset is a silent no-op, same
convention as an unbound short-press button.
## Per-widget config UI
Each widget has a gear-icon button on the Layout canvas that opens a
`<dialog>` with that widget's own settings (album, calendar/task-list
inclusion, whiteboard source, etc.) -- not a per-frame tab, since a
frame can now have several widgets of the same type with independent
settings. The
dialog HTML is injected server-rendered (`routers/frame_pages.py`'s
`widget_dialog`, dispatching on `widget.widget_type`); its JS is a
top-level, always-loaded file (`static/widget_dialog_*.js`) exposing
`init<Type>Dialog()`/`close<Type>Dialog()`, since dynamically-injected
HTML can't carry executable `<script>` tags. While a dialog is open,
`window.FRAME_API` is repointed at that widget's own API base
(`/api/frames/{id}/widgets/{widget_id}`) and restored on close;
`window.FRAME_BASE_API` stays pointed at the frame-level base throughout
for the always-present header/status-bar JS.
## Saved layouts
A user can snapshot a frame's whole widget arrangement -- every widget's
type/placement/settings, calendar/task sources, and button-action
bindings -- under a name (`SavedLayout` + `SavedLayoutWidget` +
`SavedLayoutSource` + `SavedLayoutButtonAction`, `server/app/models.py`),
then switch back to it later, or apply it to a *different* frame. Saved
layouts are owned by the **user**, not any one frame -- the same set
shows up (with a per-frame `compatible` flag) on every frame that user
controls whose grid matches (`grid.grid_dims(orientation)`'s cols/rows,
landscape-class 8x5 vs. portrait-class 5x8), not just the frame it was
captured from.
Saving only captures an authored *setting*, never runtime/cache state --
a photo widget's current queue position, a calendar's fetch cache, a
whiteboard's rendered-image cache, etc. are deliberately left out (see
`routers/api_layouts.py`'s `LAYOUT_CONFIG_FIELDS` allowlist per
`widget_type`), so applying a layout feels like a fresh widget of that
type with its settings pre-filled, not a resurrection of stale state
from whenever it was saved. A static-image widget's uploaded bytes are
the one exception carried through verbatim (`SavedLayoutWidget.image`).
Saving again under a name the user already has overwrites that layout's
snapshot in place rather than erroring or creating a duplicate --
`SavedLayout`'s own docstring.
Applying a layout to a frame (`api_layout_apply`, `require_frame_control`)
deletes every widget currently on that frame and recreates the saved
arrangement from scratch, remapping calendar/task sources and button
bindings onto the newly-created widget ids -- same "act unconditionally
on the server, confirm on the client" posture as the Layout tab's own
"Clear all". A source whose owning user account no longer exists is
silently dropped rather than left dangling (config is JSON, not
FK-checked, so nothing else would catch that).
The web UI lives in the Layout tab's "Saved layouts" card
(`static/saved_layouts.js`, `GET`/`POST /api/frames/{id}/layouts`,
`PATCH`/`DELETE /api/layouts/{id}`, `POST
/api/frames/{id}/layouts/{id}/apply`) -- name + Save, then a list of
saved layouts each with Apply/rename/delete, incompatible ones shown
greyed-out with a "different orientation" badge rather than hidden.
## Weather widget
A standalone widget type (`models.WeatherWidgetConfig`, `app/widgets/
weather.py`) -- distinct from, and unrelated in code to,
`CalendarWidgetConfig`'s own embedded weather strip (still present,
still Open-Meteo-only, still working exactly as before). Four display
modes (`WeatherWidgetConfig.mode`, switchable in the widget's dialog like
`calendar_view`):
- `current` -- one city's current temp + a condition icon.
- `hourly` -- one city, a row of ticks across the day at a configurable
interval (`hourly_interval_hours`: 3/4/6/12).
- `daily` -- one city, a multi-day strip (`daily_days`, 1-14).
- `multi_city` -- several cities' current-day high/low/icon side by
side -- the calendar widget's embedded strip, as a standalone
widget's whole content instead of a strip above an agenda day.
**Render style** (`WeatherWidgetConfig.render_style`, `"classic"` default
| `"modern"`, experimental) -- see "Modern render style" above; weather's
own modern coverage is `current`/`daily` only, `hourly`/`multi_city`
always render classic regardless of this setting.
`current`/`hourly`/`daily` share one configured location
(`city_label`/`city_latitude`/`city_longitude`, set via `POST .../
weather-location`, geocoded through `weather.geocode_city`); `multi_city`
has its own list (`cities`, add/remove via `POST .../weather-widget-
cities/add`|`remove` -- named to avoid colliding with the calendar
widget's own, differently-scoped `weather-cities/add`|`remove` routes,
which share the same `{widget_id}`-parameterized path shape).
**Providers** (`app/weather/`, a dispatch registry over pluggable
implementations mirroring `app/widgets/` itself): `WeatherWidgetConfig.
provider` selects which of `app/weather.PROVIDERS` actually fetches --
`"open_meteo"` (worldwide, no API key), `"nws"` (api.weather.gov, US
only, no API key, approximates "current" with the first hourly forecast
period rather than a real station observation), or `"ec"` (Environment
Canada, api.weather.gc.ca's MSC GeoMet OGC API, Canada only, no API key).
Every provider function returns already-normalized `{"category": ...}`
entries (one of `clear`/`partly_cloudy`/`cloudy`/`fog`/`rain`/`snow`/
`thunderstorm`) so `app/weather_render.py`'s drawing code never needs to
know which provider supplied an entry. `geocode_city` (name -> lat/lon)
always goes through Open-Meteo's free geocoder regardless of which
provider is chosen to fetch with the result.
EC's `citypageweather-realtime` collection is only queryable by bounding
box (OGC API - Features), not a direct by-coordinate endpoint -- unlike
Open-Meteo/NWS's simple lat/lon REST, `app/weather/ec.py`'s
`_nearest_site` widens the box progressively and picks the closest site
by straight-line distance, rejecting anything beyond 300 km (calibrated
against a real bug caught in development: an unconditional "nearest
site, however far" matched a Miami, FL query to a site in Ontario,
1824 km away, once the box widened enough to cover the whole country).
`app/weather_render.py` holds every weather-related drawing primitive:
`draw_weather_icon`/`draw_weather_row` (extracted out of
`calendar_render.py`, which still imports `draw_weather_row` for its own
embedded strip, unchanged) plus this widget's own `build_current`/
`build_hourly`/`build_daily`/`build_multi_city`, dispatched by `build()`
-- the weather analogue of `calendar_render.py`'s own `_build_tasks`/
`render_tasks_preview_png` relationship. Icons are hand-drawn (no custom
font/icon asset), styled after Environment Canada's own icon set
(pointed sun rays, a puffy cloud, teardrop rain, dendrite snowflakes, a
zigzag bolt) but filled with the panel's *exact* ink RGB values rather
than an arbitrary bitmap's anti-aliased colors -- a flat fill that's
already a palette color quantizes with zero dithering error to diffuse,
where a fetched/vendored icon's colors (almost never an exact match)
dither into a visible speckle at these small on-panel sizes (confirmed
by actually running one through the real quantize pass during
development). Used for every provider's rendering, not just when EC is
selected as the provider.
## Battery widget
The simplest widget type (`models.BatteryWidgetConfig`, `app/widgets/
battery.py`): shows this frame's own last-reported battery level. Unlike
every other widget type, there's no live upstream to poll and nothing to
cache -- the content is `Frame.battery_percent`/`battery_as_of`, set by
`routers/device.py`'s `frame_battery` on every device wake-on-battery
report, which already existed for the Device panel's own history chart
regardless of whether a battery widget is placed anywhere. The widget's
own config is just a display mode: `"compact"` (icon + percent) or
`"detailed"` (default, adds `routers/common.py`'s existing
`battery_estimate_s` time-remaining estimate and the last report's age).
`render()` falls back to a "No reports yet" placeholder for a frame that
has never reported (never run on battery, or not yet claimed by a
device) rather than showing a stale or fabricated number. The battery
icon fill color (red/yellow/green by percent) uses the same exact-panel-
ink-RGB approach as the weather icons above and `manage_overlay.py`'s own
battery glyph on the "scan to manage" overlay -- a separate, unrelated
piece of code with its own fixed small size, not shared with this
widget, but drawing from the same thresholds/colors so a battery glyph
reads the same wherever one shows up on a panel.
## Known gaps
The original 8-phase rollout plan's last phase is done: migration 41
dropped the legacy per-mode `Frame` columns (`mode`, `album_id`,
`current_asset_id`, all `calendar_*`, all `whiteboard_*`, `queue`, etc.)
-- see its own docstring in `app/migration.py` for the raw-SQL backfill
safety net that ran first, and `server/README.md` no longer describes
photos/calendar/whiteboard as per-frame "modes".
Still open:
- Whiteboard rendering is tagged **(alpha)** in the UI -- not fully
reliable yet, treat it as experimental if extending it.
+93 -29
View File
@@ -1,6 +1,10 @@
# ESPresso Frame Firmware
ESP-IDF firmware for the ESP32-C6. On first boot it provisions itself over
ESP-IDF firmware for the ESP32-C6 (devkit/xiao boards, 7.3" panel) or
ESP32-S3 (ee02 board, 13.3" panel -- see
[Building for Seeed's EE02](#building-for-seeeds-ee02-esp32-s3--133-panel-driver-ported-ee02-builds-end-to-end-unverified-on-real-hardware)
below; it builds end-to-end now, but is still unverified on real EE02
hardware). On first boot it provisions itself over
a WiFi captive portal; after that it wakes on a timer, fetches an
already-processed frame from the [server](../server/), streams it straight
to the panel over SPI, and goes back to deep sleep.
@@ -48,6 +52,40 @@ never clobbers the other:
(`./build_for_board.sh devkit ...` does the same for the dev board --
equivalent to a plain `idf.py`, just consistent with the XIAO invocation.)
### Building for Seeed's EE02 (ESP32-S3 + 13.3" panel, driver ported; `ee02` builds end-to-end, unverified on real hardware)
EE02 is a different chip (ESP32-S3, not C6), so it needs `set-target
esp32s3` instead of `esp32c6`, and its own partition table/flash-size
Kconfig sized for its 16MB flash
([`partitions_ee02.csv`](partitions_ee02.csv)):
```
./build_for_board.sh ee02 set-target esp32s3
./build_for_board.sh ee02 build
```
**This now compiles and links clean end-to-end**, verified locally with
a native, non-Docker ESP-IDF v6.0 install (see
`.claude/skills/build-firmware/SKILL.md`).
`firmware/components/epd13in3e`'s panel init/LUT/refresh register
sequence is a real, vendor-confirmed port (see that component's own top
comment and
[`docs/hardware.md`](../docs/hardware.md#133-spectra-6-panel-on-seeeds-ee02-board-panel-driver-ported-ee02-builds-end-to-end-unverified-on-real-hardware)
for the vendor sources and the load-bearing native-raster-orientation
correction that came with it). `main/{back,next,combo}_button.c` used to
call an ESP32-C6-only deep-sleep GPIO-wakeup API with no ESP32-S3
fallback; each now branches on `SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP`
to keep the ESP32-C6 path (devkit/xiao) untouched while using
`esp_sleep_enable_ext1_wakeup_io()` on ESP32-S3 -- see `docs/hardware.md`'s
same section for why the additive `_io()` variant needs no combined-mask
coordination across the three button files, and why the pull-resistor
concern that ruled out EXT1 wakeup on ESP32-C6 doesn't apply the same
way here. That reasoning is confirmed against ESP-IDF source, **not
against real EE02 hardware** -- CI's
(`.gitea/workflows/firmware-build-check.yml`/`firmware-release-build.yml`)
`continue-on-error` on this board's step is intentionally still in place
until it is.
## Configuration (`idf.py menuconfig`)
Under **ESPresso Frame Configuration**:
@@ -66,8 +104,9 @@ Under **ESPresso Frame Configuration**:
| `FRAME_NEXT_BUTTON_GPIO` | 2 | Next-photo button GPIO (-1 to disable). Must be 0-7 (ESP32-C6's deep-sleep-wakeup-capable pins) |
| `FRAME_BACK_BUTTON_GPIO` | 0 | Back-photo button GPIO (-1 to disable). Must be 0-7 |
| `FRAME_COMBO_BUTTON_GPIO` | 1 | Menu/reset button GPIO (-1 to disable). Must be 0-7 |
| `FRAME_COMBO_SOFT_RESET_HOLD_MS` | 3000 | How long the combo button must be held (then released) to soft-reset |
| `FRAME_COMBO_MENU_HOLD_MS` | 3000 | How long the combo button must be held (then released) to show the management menu |
| `FRAME_COMBO_FACTORY_RESET_HOLD_MS` | 15000 | How long the combo button must be held to factory-reset |
| `FRAME_HOLD_ACTION_MS` | 3000 | **Fallback only** -- how long NEXT/BACK must be held to trigger a global action instead of a short press; see below |
| `FRAME_BATTERY_ADC_GPIO` | -1 (disabled) | Battery voltage-divider ADC GPIO; see the Battery section below |
| `FRAME_VBUS_SENSE_GPIO` | -1 (disabled) | USB-power sense GPIO for hiding the battery indicator on mains |
@@ -84,6 +123,34 @@ reflashing. The Kconfig value only applies before the device has ever
successfully reached a configured server, or if the response doesn't
include a valid interval.
### Holding NEXT/BACK for a global action
Past `FRAME_HOLD_ACTION_MS`, holding NEXT or BACK stops meaning "advance/
back this widget" and instead triggers whatever frame-wide action (if
any) is configured for that button's hold on the server's Configuration
tab -- e.g. cycling through saved layouts (see
`server/app/global_actions.py`). Fires immediately at the threshold,
without waiting for release -- same convention as the combo button's
factory-reset tier below.
Same "fallback only" caveat as `FRAME_SLEEP_INTERVAL_S` above, but with
one more wrinkle: the server's actual `hold_duration_ms` (set on the
Configuration tab, `GET /frame/config`'s response) can't be used for
*this* wake's button decision -- that decision happens in `main.c`
before WiFi even connects, but `/frame/config` isn't fetched until near
the end of the wake cycle (after the image fetch, deliberately -- see
`frame_client_run`'s own comment on why). So the device always acts on
whatever value the *previous* wake fetched (persisted in NVS via
`frame_config_set_hold_duration_ms`), falling back to
`FRAME_HOLD_ACTION_MS` only before it's ever successfully fetched one.
In practice this means changing the duration on the Configuration tab
takes effect starting with the wake *after* the next one, not
immediately.
Holding a button through the poll loop keeps the device awake and
connected longer than a normal short-press wake -- the same tradeoff
already accepted for the combo button's menu/reset holds below.
### WiFi fast-connect
After a successful home-WiFi connection, the device caches the AP's
@@ -118,11 +185,16 @@ two-step setup screen:
portal's config page (`http://192.168.4.1/` by default), for a
one-scan shortcut once you've joined the AP.
The config page asks for your home WiFi SSID/password, the "Tools
The config page asks for your home WiFi SSID/password and the "Tools
Server" address (`host:port` of the [server](../server/) -- **not** your
Immich server; see below for the `https://` form), and an optional
"Access Token" (see below). Saving reboots the device, which then
connects to your home network and starts its normal fetch/sleep cycle.
Immich server; see below for the `https://` form). Saving hands your browser
off to the server's claim page (after ~7 seconds, giving your phone
time to rejoin its normal WiFi while the device reboots) so the frame
gets linked to your account; the device meanwhile connects to your home
network and starts its normal fetch/sleep cycle. The frame identifies
itself to the server by `?id=` (derived from its WiFi MAC) on every
request, and the server issues it a private per-frame token on first
contact -- no manual token handling involved.
## HTTP vs HTTPS
@@ -177,18 +249,6 @@ certificate was actually issued for -- a bare LAN IP address
(`https://192.168.1.50`) will fail the handshake even against a
perfectly valid cert for a different name.
## Access token
If the server has `MANAGEMENT_TOKEN` set (see
[`server/README.md`](../server/README.md)), it requires that same value
on every request -- the web UI *and* every device-facing request the
frame itself makes. Paste it into the captive portal's "Access Token"
field and the device sends it (`?token=...`) on every request
automatically, and bakes it into the manage-menu/share QR codes so
scanning them just works too. Leave it blank if the server has no
`MANAGEMENT_TOKEN` configured -- the default, unauthenticated-on-a-
trusted-LAN behavior from before.
## Skipping to the next photo
Wire a momentary push button between GPIO2 and GND (internal pull-up,
@@ -197,6 +257,8 @@ device (if asleep) and tells the server to advance to the next photo
right away, regardless of the configured refresh interval -- no long hold
needed, since advancing is easily reversible by pressing again. See
`FRAME_NEXT_BUTTON_GPIO` above to change the pin or disable the feature.
Holding it past `FRAME_HOLD_ACTION_MS` instead means something else
entirely -- see "Holding NEXT/BACK for a global action" above.
Normal wakes and reboots never advance the photo on their own -- the
server decides when to advance based on its own clock (see
@@ -216,7 +278,8 @@ disable the feature.
If there's nothing to go back to yet (freshly provisioned, or you've
already gone back as far as there is history), it's a no-op -- the
current photo stays exactly as it was, no flash on the panel.
current photo stays exactly as it was, no flash on the panel. Same
long-hold caveat as the next-photo button above.
## Battery (XIAO ESP32-C6)
@@ -255,9 +318,13 @@ One more button, wired between GPIO1 and GND (same wiring style as the
other buttons), covers three actions -- disambiguated purely by how
long it's held:
**A quick press** wakes the device and overlays several corners of
whatever photo is currently showing, leaving the middle of the photo
visible and unchanged:
**A quick press** soft-resets the device -- `esp_restart()`, keeping the
stored WiFi/server config. Useful for recovering a hung device without
losing setup.
**Holding it ~3 seconds, then releasing** wakes the device (if asleep)
and overlays several corners of whatever photo is currently showing,
leaving the middle of the photo visible and unchanged:
- **Top-right**: a QR code -- "SCAN TO MANAGE" -- linking to the
server's config page.
@@ -276,8 +343,9 @@ for faces Immich hasn't been told a name for; no face detection happens
on the device or the server, this is entirely Immich's own People
feature). A third press exits immediately rather than waiting out the
30-second timer. Holding the button during this stage doesn't trigger
either reset tier below -- the hold-duration read only ever happens
once, right when the device first wakes, before any menu is shown.
the factory-reset tier below -- the hold-duration read only ever
happens once, right when the device first wakes, before any menu is
shown.
The device stays awake for the whole menu interaction (up to three
physical refreshes: the base overlay, the escalated one, and
@@ -285,17 +353,13 @@ reverting), so this costs meaningfully more power than a normal wake --
expected for a deliberate, occasional action, same tradeoff as the
other buttons.
**Holding it ~3 seconds, then releasing** soft-resets the device --
`esp_restart()`, keeping the stored WiFi/server config. Useful for
recovering a hung device without losing setup.
**Holding it ~15 seconds** (whether or not you're still holding it --
this fires immediately, it doesn't wait for release) clears the stored
WiFi/server config and restarts into provisioning. From either power-on
or while the device is deep-asleep, since this GPIO is armed as a
wakeup source.
See `FRAME_COMBO_BUTTON_GPIO`, `FRAME_COMBO_SOFT_RESET_HOLD_MS`, and
See `FRAME_COMBO_BUTTON_GPIO`, `FRAME_COMBO_MENU_HOLD_MS`, and
`FRAME_COMBO_FACTORY_RESET_HOLD_MS` above to change the pin or hold
durations, or disable all three actions.
+33 -14
View File
@@ -1,28 +1,42 @@
#!/usr/bin/env bash
# Builds/flashes for a specific board variant. This project targets two:
# Builds/flashes for a specific board variant. This project targets three:
#
# devkit ESP32-C6-DevKitC-1 (8MB flash) -- the dev board. This is
# also the plain `idf.py` default (sdkconfig/build/), so this
# script's devkit mode is mostly for symmetry -- normal
# `idf.py build`/`flash` work fine too.
# `idf.py build`/`flash` work fine too. Reports itself as
# "devkit_esp32c6" (see main/Kconfig.projbuild).
# xiao Seeed XIAO ESP32-C6 (4MB flash) -- the production board.
# Reports itself as "xiao_esp32c6".
# ee02 Seeed EE02 (XIAO ESP32-S3 Plus, 16MB flash) + 13.3" Spectra 6
# panel -- a genuinely different chip target (esp32s3, not
# esp32c6), unlike xiao's same-chip Kconfig-only variant.
# Reports itself as "ee02". NOTE: the epd13in3e driver this
# board links (firmware/components/epd13in3e) doesn't actually
# work yet -- its panel init/LUT/refresh register sequence is
# still unported from vendor demo code (see that component's
# own top-of-file comment); building for ee02 will fail to
# compile until that lands, by design (a deliberate #error, not
# a bug in this script).
#
# The two need different partition tables (the XIAO's 4MB doesn't fit
# the dev board's two 2MB OTA app slots -- see partitions_xiao.csv,
# 1.875MB slots instead) and a different flash-size Kconfig. Rather
# than hand-editing the shared sdkconfig back and forth (fragile, easy
# to leave it in the wrong state for whichever board you flash next),
# each board gets its own build directory and its own generated
# sdkconfig, seeded from sdkconfig.defaults (shared) with the board's
# override file layered on top via ESP-IDF's own SDKCONFIG_DEFAULTS
# mechanism. Switching boards is just switching which one you invoke --
# neither ever touches the other's config or build output.
# The three need different partition tables (each flash size needs its
# own OTA app-slot sizing -- see partitions_xiao.csv/partitions_ee02.csv)
# and different flash-size Kconfig. Rather than hand-editing the shared
# sdkconfig back and forth (fragile, easy to leave it in the wrong state
# for whichever board you flash next), each board gets its own build
# directory and its own generated sdkconfig, seeded from
# sdkconfig.defaults (shared) with the board's override file layered on
# top via ESP-IDF's own SDKCONFIG_DEFAULTS mechanism. Switching boards is
# just switching which one you invoke -- none ever touches another's
# config or build output.
#
# Usage:
# ./build_for_board.sh xiao build
# ./build_for_board.sh xiao flash -p /dev/ttyUSB0
# ./build_for_board.sh xiao flash monitor -p /dev/ttyUSB0
# ./build_for_board.sh devkit build
# ./build_for_board.sh ee02 set-target esp32s3 # first build only, see below
# ./build_for_board.sh ee02 build
#
# Defaults to "build" if no idf.py subcommand is given.
@@ -32,7 +46,7 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$script_dir"
if [ $# -lt 1 ]; then
echo "Usage: $0 <devkit|xiao> [idf.py args...]" >&2
echo "Usage: $0 <devkit|xiao|ee02> [idf.py args...]" >&2
exit 1
fi
board="$1"
@@ -49,8 +63,13 @@ case "$board" in
sdkconfig_path="$script_dir/sdkconfig"
defaults="$script_dir/sdkconfig.defaults"
;;
ee02)
build_dir="$script_dir/build_ee02"
sdkconfig_path="$script_dir/sdkconfig.ee02_local"
defaults="$script_dir/sdkconfig.defaults;$script_dir/sdkconfig.ee02"
;;
*)
echo "Unknown board '$board' -- expected 'devkit' or 'xiao'" >&2
echo "Unknown board '$board' -- expected 'devkit', 'xiao', or 'ee02'" >&2
exit 1
;;
esac
@@ -0,0 +1,13 @@
# SRCS is conditional on which board's panel this build targets -- see
# epd7in3e/CMakeLists.txt's identical comment (the two components mirror
# each other: exactly one contributes actual object files/symbols to any
# given build, the other is required but empty).
if(CONFIG_FRAME_PANEL_EE02_13IN3)
set(srcs "epd13in3e.c")
else()
set(srcs "")
endif()
idf_component_register(SRCS ${srcs}
INCLUDE_DIRS "include"
PRIV_REQUIRES esp_driver_spi esp_driver_gpio)
+65
View File
@@ -0,0 +1,65 @@
menu "E-Paper Display (epd13in3e) Configuration"
config EPD_PIN_CLK
int "SPI CLK (SCLK) GPIO"
default 7
help
Defaults sourced from a community-verified ESPHome
integration for this exact board
(github.com/rkaramandi/esphome-seeed-ee02) -- NOT an
official Waveshare/Seeed reference driver (see
firmware/components/epd13in3e/epd13in3e.c's top comment,
which is about the still-unknown panel init/LUT/refresh
register sequence, a separate and larger unknown than this
pinout). Override if your own board wiring differs.
config EPD_PIN_MOSI
int "SPI MOSI (DIN) GPIO"
default 9
config EPD_PIN_CS_MASTER
int "SPI CS (master half) GPIO"
default 44
help
Unlike epd7in3e's single-CS interface, this panel is driven
as two halves over one shared CLK/MOSI/DC/RST/BUSY bus with
two independent chip-selects (master/slave) -- confirmed by
the same community ESPHome integration, not yet by this
component's own driver code (still unimplemented, see
epd13in3e.c).
config EPD_PIN_CS_SLAVE
int "SPI CS (slave half) GPIO"
default 41
config EPD_PIN_DC
int "Data/Command GPIO"
default 10
config EPD_PIN_RST
int "Reset GPIO"
default 38
config EPD_PIN_BUSY
int "Busy GPIO"
default 4
config EPD_PIN_POWER_EN
int "Panel power-enable GPIO"
default 43
help
No equivalent pin on epd7in3e's board -- the EE02 apparently
gates the panel's own power rail separately from the ESP32-S3
module's. Source: same community integration as the other
pins above.
config EPD_SPI_CLOCK_HZ
int "SPI clock speed (Hz)"
default 2000000
help
2MHz, not epd7in3e's 4MHz default -- the same community
integration notes higher rates were unreliable on this
panel/board combo. Revisit once wiring is confirmed on real
hardware.
endmenu
+438
View File
@@ -0,0 +1,438 @@
#include <string.h>
#include "driver/gpio.h"
#include "driver/spi_master.h"
#include "esp_heap_caps.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_check.h"
#include "esp_log.h"
#include "esp_rom_crc.h"
#include "epd13in3e.h"
/* Command bytes/register values below are a line-for-line transcription of
* Waveshare's official reference drivers for this exact panel+controller --
* confirmed identical across three independent sources (RaspberryPi/c,
* ESP32, and the ESP32-S3-ePaper-13.3E6 ESP-IDF example; see this
* component's header for repo paths). Same "don't clean these up" rule as
* epd7in3e.c: this class of panel controller has no public datasheet, so
* the vendor driver is the source of truth for every byte.
*
* Unlike epd7in3e's single chip-select, this panel is driven as two
* independent controllers sharing one CLK/MOSI/DC/RST/BUSY bus but with
* separate chip-selects (EPD_PIN_CS_MASTER/EPD_PIN_CS_SLAVE) -- most init
* commands broadcast to both (CS_ALL), a handful of power/boost commands
* go only to the master (which owns the shared analog rails), and actual
* frame data is split per-row into a left half (master) and right half
* (slave), 300 bytes each out of each 600-byte row. That split is why
* epd_write_frame below buffers the whole frame in PSRAM before sending
* anything (every row needs slicing in half before either half can go out),
* unlike epd7in3e.c's straight single-CS passthrough streaming.
*
* Pin numbers themselves are NOT from this vendor code -- Waveshare's
* ESP32-S3-ePaper-13.3E6 example targets Waveshare's own driver board, a
* different carrier than Seeed's EE02 this project actually uses, so its
* GPIO numbers don't apply here. EE02's pins remain sourced from a
* community-verified ESPHome integration (see this component's Kconfig),
* not an official reference. */
#define EPD_SPI_HOST SPI2_HOST
#define EPD_SPI_CHUNK_SIZE 4096
static const char *TAG = "epd13in3e";
#define EPD_CHECK(expr) ESP_RETURN_ON_ERROR((expr), TAG, #expr)
/* --- panel command opcodes --- */
#define PSR 0x00
#define PWR 0x01
#define POF 0x02
#define PON 0x04
#define BTST_N 0x05
#define BTST_P 0x06
#define DTM 0x10 /* data transfer (frame data) */
#define DRF 0x12 /* display refresh */
#define CDI 0x50
#define TCON 0x60
#define TRES 0x61
#define AN_TM 0x74
#define AGID 0x86
#define BUCK_BOOST_VDDN 0xB0
#define TFT_VCOM_POWER 0xB1
#define EN_BUF 0xB6
#define BOOST_VDDP_EN 0xB7
#define CCSET 0xE0
#define PWS 0xE3
#define CMD66 0xF0
#define DEEP_SLEEP 0x07
/* --- canned init parameter blobs (do NOT edit -- see top comment) --- */
static const uint8_t PSR_V[] = {0xDF, 0x69};
static const uint8_t PWR_V[] = {0x0F, 0x00, 0x28, 0x2C, 0x28, 0x38};
static const uint8_t POF_V[] = {0x00};
static const uint8_t DRF_V[] = {0x00};
static const uint8_t CDI_V[] = {0xF7};
static const uint8_t TCON_V[] = {0x03, 0x03};
static const uint8_t TRES_V[] = {0x04, 0xB0, 0x03, 0x20};
static const uint8_t CMD66_V[] = {0x49, 0x55, 0x13, 0x5D, 0x05, 0x10};
static const uint8_t EN_BUF_V[] = {0x07};
static const uint8_t CCSET_V[] = {0x01};
static const uint8_t PWS_V[] = {0x22};
static const uint8_t AN_TM_V[] = {0xC0, 0x1C, 0x1C, 0xCC, 0xCC, 0xCC, 0x15, 0x15, 0x55};
static const uint8_t AGID_V[] = {0x10};
static const uint8_t BTST_P_V[] = {0xE8, 0x28};
static const uint8_t BOOST_VDDP_EN_V[] = {0x01};
static const uint8_t BTST_N_V[] = {0xE8, 0x28};
static const uint8_t BUCK_BOOST_VDDN_V[] = {0x01};
static const uint8_t TFT_VCOM_POWER_V[] = {0x02};
static spi_device_handle_t s_spi;
static void epd_delay_ms(uint32_t ms)
{
vTaskDelay(pdMS_TO_TICKS(ms));
}
/* BUSY: LOW = busy, HIGH = idle -- same polarity/poll-interval reasoning
* as epd7in3e.c's identical comment (a tight 1ms-rounds-to-0-ticks poll
* starves the idle task badly enough to trip the watchdog). */
static void epd_wait_busy(void)
{
while (gpio_get_level((gpio_num_t)CONFIG_EPD_PIN_BUSY) == 0) {
epd_delay_ms(20);
}
}
static esp_err_t epd_spi_write(const uint8_t *data, size_t len)
{
while (len > 0) {
size_t n = len > EPD_SPI_CHUNK_SIZE ? EPD_SPI_CHUNK_SIZE : len;
spi_transaction_t t = {
.length = n * 8,
.tx_buffer = data,
};
EPD_CHECK(spi_device_polling_transmit(s_spi, &t));
data += n;
len -= n;
}
return ESP_OK;
}
/* Unlike epd7in3e.c's send_command/send_data, these do NOT touch CS --
* this panel's two independent chip-selects (and the "broadcast to both"
* vs "master only" split the init sequence needs) mean CS bracketing has
* to be the caller's decision, not baked into the byte-send primitive. */
static esp_err_t epd_send_command(uint8_t cmd)
{
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_DC, 0);
return epd_spi_write(&cmd, 1);
}
static esp_err_t epd_send_data(const uint8_t *data, size_t len)
{
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_DC, 1);
return epd_spi_write(data, len);
}
static esp_err_t epd_send_data_byte(uint8_t data)
{
return epd_send_data(&data, 1);
}
static void epd_cs_both(int level)
{
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, level);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_SLAVE, level);
}
/* Sends `cmd` + its data blob to both controllers at once (most of the
* init sequence -- shared display-timing/power registers). */
static esp_err_t epd_cmd_both(uint8_t cmd, const uint8_t *data, size_t len)
{
epd_cs_both(0);
esp_err_t err = epd_send_command(cmd);
if (err == ESP_OK && data != NULL) {
err = epd_send_data(data, len);
}
epd_cs_both(1);
return err;
}
/* Sends `cmd` + its data blob to the master controller only -- the boost/
* VCOM power registers the master alone owns. */
static esp_err_t epd_cmd_master(uint8_t cmd, const uint8_t *data, size_t len)
{
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 0);
esp_err_t err = epd_send_command(cmd);
if (err == ESP_OK && data != NULL) {
err = epd_send_data(data, len);
}
epd_cs_both(1);
return err;
}
/* 5-edge reset sequence (30ms each) -- per-vendor-source exact, more edges
* than epd7in3e.c's 3-edge/20ms reset. */
static void epd_reset(void)
{
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1);
epd_delay_ms(30);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 0);
epd_delay_ms(30);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1);
epd_delay_ms(30);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 0);
epd_delay_ms(30);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1);
epd_delay_ms(30);
}
/* Power on, refresh, power off -- mirrors EPD_TurnOnDisplay()/
* EPD_13IN3E_TurnOnDisplay() in the reference drivers. */
esp_err_t epd_turn_on_display(void)
{
EPD_CHECK(epd_cmd_both(PON, NULL, 0));
epd_wait_busy();
epd_delay_ms(50);
EPD_CHECK(epd_cmd_both(DRF, DRF_V, sizeof(DRF_V)));
epd_wait_busy();
epd_delay_ms(50);
EPD_CHECK(epd_cmd_both(POF, POF_V, sizeof(POF_V)));
/* No busy-wait after POF -- matches every reference driver. */
return ESP_OK;
}
esp_err_t epd_init(void)
{
gpio_config_t out_cfg = {
.pin_bit_mask = (1ULL << CONFIG_EPD_PIN_DC) | (1ULL << CONFIG_EPD_PIN_RST) |
(1ULL << CONFIG_EPD_PIN_CS_MASTER) | (1ULL << CONFIG_EPD_PIN_CS_SLAVE) |
(1ULL << CONFIG_EPD_PIN_POWER_EN),
.mode = GPIO_MODE_OUTPUT,
};
EPD_CHECK(gpio_config(&out_cfg));
gpio_config_t busy_cfg = {
.pin_bit_mask = (1ULL << CONFIG_EPD_PIN_BUSY),
.mode = GPIO_MODE_INPUT,
};
EPD_CHECK(gpio_config(&busy_cfg));
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1);
epd_cs_both(1);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_POWER_EN, 0);
spi_bus_config_t bus_cfg = {
.mosi_io_num = CONFIG_EPD_PIN_MOSI,
.miso_io_num = -1,
.sclk_io_num = CONFIG_EPD_PIN_CLK,
.quadwp_io_num = -1,
.quadhd_io_num = -1,
.max_transfer_sz = EPD_SPI_CHUNK_SIZE,
};
EPD_CHECK(spi_bus_initialize(EPD_SPI_HOST, &bus_cfg, SPI_DMA_CH_AUTO));
spi_device_interface_config_t dev_cfg = {
.clock_speed_hz = CONFIG_EPD_SPI_CLOCK_HZ,
.mode = 0,
.spics_io_num = -1, /* both chip-selects are bit-banged by hand above */
.queue_size = 1,
};
EPD_CHECK(spi_bus_add_device(EPD_SPI_HOST, &dev_cfg, &s_spi));
/* Panel power-enable rail (no equivalent on epd7in3e's board -- EE02
* gates it separately from the ESP32-S3 module's own supply). */
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_POWER_EN, 1);
epd_delay_ms(10);
epd_reset();
epd_wait_busy();
/* Master-only: shared analog-timing register. */
EPD_CHECK(epd_cmd_master(AN_TM, AN_TM_V, sizeof(AN_TM_V)));
/* Broadcast: display-timing/power-sequencing registers both
* controllers need identically. */
EPD_CHECK(epd_cmd_both(CMD66, CMD66_V, sizeof(CMD66_V)));
EPD_CHECK(epd_cmd_both(PSR, PSR_V, sizeof(PSR_V)));
EPD_CHECK(epd_cmd_both(CDI, CDI_V, sizeof(CDI_V)));
EPD_CHECK(epd_cmd_both(TCON, TCON_V, sizeof(TCON_V)));
EPD_CHECK(epd_cmd_both(AGID, AGID_V, sizeof(AGID_V)));
EPD_CHECK(epd_cmd_both(PWS, PWS_V, sizeof(PWS_V)));
EPD_CHECK(epd_cmd_both(CCSET, CCSET_V, sizeof(CCSET_V)));
EPD_CHECK(epd_cmd_both(TRES, TRES_V, sizeof(TRES_V)));
/* Master-only: boost/VCOM power programming. */
EPD_CHECK(epd_cmd_master(PWR, PWR_V, sizeof(PWR_V)));
EPD_CHECK(epd_cmd_master(EN_BUF, EN_BUF_V, sizeof(EN_BUF_V)));
EPD_CHECK(epd_cmd_master(BTST_P, BTST_P_V, sizeof(BTST_P_V)));
EPD_CHECK(epd_cmd_master(BOOST_VDDP_EN, BOOST_VDDP_EN_V, sizeof(BOOST_VDDP_EN_V)));
EPD_CHECK(epd_cmd_master(BTST_N, BTST_N_V, sizeof(BTST_N_V)));
EPD_CHECK(epd_cmd_master(BUCK_BOOST_VDDN, BUCK_BOOST_VDDN_V, sizeof(BUCK_BOOST_VDDN_V)));
EPD_CHECK(epd_cmd_master(TFT_VCOM_POWER, TFT_VCOM_POWER_V, sizeof(TFT_VCOM_POWER_V)));
ESP_LOGI(TAG, "EPD initialized (CLK=%d MOSI=%d CS_M=%d CS_S=%d DC=%d RST=%d BUSY=%d PWR_EN=%d)",
CONFIG_EPD_PIN_CLK, CONFIG_EPD_PIN_MOSI, CONFIG_EPD_PIN_CS_MASTER,
CONFIG_EPD_PIN_CS_SLAVE, CONFIG_EPD_PIN_DC, CONFIG_EPD_PIN_RST,
CONFIG_EPD_PIN_BUSY, CONFIG_EPD_PIN_POWER_EN);
return ESP_OK;
}
esp_err_t epd_write_frame(epd_read_fn_t read_fn, void *ctx, uint32_t *out_crc32)
{
ESP_RETURN_ON_FALSE(read_fn != NULL, ESP_ERR_INVALID_ARG, TAG, "read_fn required");
/* Every row has to be sliced into a left (master) and right (slave)
* half before either half can go out over SPI, so -- unlike
* epd7in3e.c's single-CS passthrough -- bytes can't be forwarded to
* the wire as they arrive. Buffer the whole ~938KB frame in PSRAM
* first (EE02's XIAO ESP32-S3 Plus has 8MB of it). */
uint8_t *frame = heap_caps_malloc(EPD_FRAME_BYTES, MALLOC_CAP_SPIRAM);
if (frame == NULL) {
ESP_LOGE(TAG, "OOM allocating %u-byte frame buffer", (unsigned)EPD_FRAME_BYTES);
return ESP_ERR_NO_MEM;
}
size_t total = 0;
uint32_t crc = 0;
size_t n;
while (total < EPD_FRAME_BYTES &&
(n = read_fn(frame + total, EPD_FRAME_BYTES - total, ctx)) > 0) {
crc = esp_rom_crc32_le(crc, frame + total, n);
total += n;
}
if (total != EPD_FRAME_BYTES) {
/* Same invariant as epd7in3e.c: never touch the panel on a
* short/wrong-size stream -- the visible screen is left exactly
* as it was. */
ESP_LOGE(TAG, "Stream supplied %u bytes, expected %u -- aborting refresh",
(unsigned)total, (unsigned)EPD_FRAME_BYTES);
free(frame);
return ESP_ERR_INVALID_SIZE;
}
/* De-interleave into one half-buffer at a time and DMA it out as a
* single contiguous transfer (chunked internally by epd_spi_write) --
* far fewer, far larger SPI transactions than sending 1600 separate
* 300-byte rows per side. */
const size_t HALF_ROW = EPD_BYTES_PER_ROW / 2; /* 300 */
const size_t HALF_BUF = HALF_ROW * EPD_HEIGHT; /* 480000 */
uint8_t *half = heap_caps_malloc(HALF_BUF, MALLOC_CAP_SPIRAM);
if (half == NULL) {
ESP_LOGE(TAG, "OOM allocating %u-byte half-frame scratch buffer", (unsigned)HALF_BUF);
free(frame);
return ESP_ERR_NO_MEM;
}
for (size_t r = 0; r < EPD_HEIGHT; r++) {
memcpy(half + r * HALF_ROW, frame + r * EPD_BYTES_PER_ROW, HALF_ROW);
}
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 0);
esp_err_t err = epd_send_command(DTM);
if (err == ESP_OK) {
err = epd_send_data(half, HALF_BUF);
}
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 1);
if (err == ESP_OK) {
for (size_t r = 0; r < EPD_HEIGHT; r++) {
memcpy(half + r * HALF_ROW, frame + r * EPD_BYTES_PER_ROW + HALF_ROW, HALF_ROW);
}
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_SLAVE, 0);
err = epd_send_command(DTM);
if (err == ESP_OK) {
err = epd_send_data(half, HALF_BUF);
}
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_SLAVE, 1);
}
free(half);
free(frame);
EPD_CHECK(err);
if (out_crc32 != NULL) {
*out_crc32 = crc;
}
return ESP_OK;
}
esp_err_t epd_display_stream(epd_read_fn_t read_fn, void *ctx)
{
esp_err_t err = epd_write_frame(read_fn, ctx, NULL);
if (err != ESP_OK) {
return err;
}
return epd_turn_on_display();
}
typedef struct {
const uint8_t *data;
size_t len;
size_t pos;
} epd_buf_ctx_t;
static size_t epd_buf_read(uint8_t *chunk, size_t chunk_size, void *ctx_)
{
epd_buf_ctx_t *c = (epd_buf_ctx_t *)ctx_;
size_t remaining = c->len - c->pos;
size_t n = remaining < chunk_size ? remaining : chunk_size;
if (n == 0) {
return 0;
}
memcpy(chunk, c->data + c->pos, n);
c->pos += n;
return n;
}
esp_err_t epd_display_buffer(const uint8_t *frame, size_t len)
{
epd_buf_ctx_t buf_ctx = { .data = frame, .len = len, .pos = 0 };
return epd_display_stream(epd_buf_read, &buf_ctx);
}
typedef struct {
uint8_t fill_byte;
size_t remaining;
} epd_fill_ctx_t;
static size_t epd_fill_read(uint8_t *chunk, size_t chunk_size, void *ctx_)
{
epd_fill_ctx_t *c = (epd_fill_ctx_t *)ctx_;
size_t n = c->remaining < chunk_size ? c->remaining : chunk_size;
if (n == 0) {
return 0;
}
memset(chunk, c->fill_byte, n);
c->remaining -= n;
return n;
}
esp_err_t epd_clear(epd_color_t color)
{
epd_fill_ctx_t fill_ctx = {
.fill_byte = (uint8_t)((color << 4) | color),
.remaining = EPD_FRAME_BYTES,
};
return epd_display_stream(epd_fill_read, &fill_ctx);
}
esp_err_t epd_sleep(void)
{
epd_cs_both(0);
EPD_CHECK(epd_send_command(DEEP_SLEEP));
EPD_CHECK(epd_send_data_byte(0xA5)); /* magic deep-sleep arg per every reference driver */
epd_cs_both(1);
epd_delay_ms(100);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_POWER_EN, 0);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 0);
return ESP_OK;
}
@@ -0,0 +1,100 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
/* Waveshare 13.3" e-Paper (E) Spectra 6 panel, driven by Seeed's EE02
* board (XIAO ESP32-S3 Plus). The panel is marketed/mounted as a
* 1600x1200 landscape rectangle (270.40x202.80mm), but its SPI
* controller addresses a native raster of 1200 columns x 1600 rows --
* i.e. the wire format is portrait, rotated 90 degrees from how the
* panel physically hangs. Confirmed identically across three independent
* vendor sources: Waveshare's RaspberryPi/c and ESP32 reference drivers
* for this exact panel (E-paper_Separate_Program/13.3inch_e-Paper_E in
* waveshare/e-Paper), and Waveshare's own ESP-IDF example for their
* ESP32-S3-ePaper-13.3E6 driver board (a *different* carrier board than
* Seeed's EE02, but the same panel+controller, hence the same command
* bytes/geometry -- only the GPIO numbers differ, and those come from
* EE02-specific sources, see this component's Kconfig). All three define
* EPD_WIDTH=1200/EPD_HEIGHT=1600 and split each row into two 600-byte
* (300px) halves sent to independent chip-selects: EPD_PIN_CS_MASTER
* gets the left half, EPD_PIN_CS_SLAVE the right -- see epd13in3e.c.
*
* Getting this backwards (assuming the wire raster matches the
* 1600x1200 mount/marketing size) doesn't just rotate the image -- 1600
* and 1200 don't share a row stride with 1200 and 1600 the other way
* (800 bytes/row x 1200 rows vs 600 bytes/row x 1600 rows), so a mismatch
* here slices real image rows at the wrong byte offsets and shreds the
* picture into a repeating diagonal garble, not a clean rotation.
* server/app/image_pipeline.py's PANEL_WIRE_TRANSPOSE handles the
* corresponding rotation server-side before packing bytes for this
* panel_type -- this header and that dict must agree on which axis is
* native. */
#define EPD_WIDTH 1200
#define EPD_HEIGHT 1600
#define EPD_BYTES_PER_ROW ((EPD_WIDTH + 1) / 2)
#define EPD_FRAME_BYTES (EPD_BYTES_PER_ROW * EPD_HEIGHT)
/* Same 6-ink Spectra family as the 7.3" panel, and (now confirmed by the
* same three vendor sources as the geometry above) the same 4-bit nibble
* codes as epd7in3e.h's epd_color_t -- matches
* server/app/image_pipeline.py's PANEL_CODES unconditionally, no
* panel-specific table needed there. */
typedef enum {
EPD_COLOR_BLACK = 0x0,
EPD_COLOR_WHITE = 0x1,
EPD_COLOR_YELLOW = 0x2,
EPD_COLOR_RED = 0x3,
EPD_COLOR_BLUE = 0x5,
EPD_COLOR_GREEN = 0x6,
} epd_color_t;
/** Configures SPI + GPIO and runs the panel's power-on register init sequence. */
esp_err_t epd_init(void);
/** Fills the whole panel with a single color and refreshes. */
esp_err_t epd_clear(epd_color_t color);
/**
* Called repeatedly by epd_display_stream() to fill up to chunk_size bytes
* into chunk. Must return the number of bytes written, or 0 once exhausted.
*/
typedef size_t (*epd_read_fn_t)(uint8_t *chunk, size_t chunk_size, void *ctx);
/**
* Streams a full frame (EPD_FRAME_BYTES bytes, packed 2 pixels/byte) to the
* panel via read_fn and refreshes. Pulling from a caller-supplied source
* instead of a single buffer lets callers feed the panel directly from an
* HTTP response without holding the whole ~960KB frame in RAM.
*/
esp_err_t epd_display_stream(epd_read_fn_t read_fn, void *ctx);
/**
* Like epd_display_stream(), but writes the frame into the panel's
* internal buffer over SPI WITHOUT triggering the physical refresh (the
* visible flash/flicker) -- call epd_turn_on_display() separately to make
* it visible. Returns ESP_ERR_INVALID_SIZE if read_fn didn't supply
* exactly EPD_FRAME_BYTES, same as epd_display_stream(); either way
* nothing is refreshed, so the visible screen is left untouched on
* error.
*
* If out_crc32 is non-NULL, it's set to a CRC32 of the bytes written --
* lets a caller compare against the last-displayed frame's CRC and skip
* the refresh entirely when nothing actually changed (e.g. redisplaying
* the same photo after a reboot).
*/
esp_err_t epd_write_frame(epd_read_fn_t read_fn, void *ctx, uint32_t *out_crc32);
/**
* Triggers the panel's physical refresh cycle (power on, refresh, power
* off) -- the visible flash/flicker sequence. Call after epd_write_frame()
* to make the written buffer visible.
*/
esp_err_t epd_turn_on_display(void);
/** Convenience wrapper around epd_display_stream() for an in-memory frame buffer. */
esp_err_t epd_display_buffer(const uint8_t *frame, size_t len);
/** Puts the panel into deep sleep to minimize power draw between refreshes. */
esp_err_t epd_sleep(void);
+13 -1
View File
@@ -1,3 +1,15 @@
idf_component_register(SRCS "epd7in3e.c"
# SRCS is conditional on which board's panel this build targets (see
# main/CMakeLists.txt's comment on why REQUIRES/PRIV_REQUIRES itself
# can't be) -- an ee02 build still always requires this component (so
# its Kconfig menu/include dir exist), but contributes zero object
# files/symbols to it, since epd13in3e.c provides the real epd_init()
# etc. for that board instead.
if(CONFIG_FRAME_PANEL_EE02_13IN3)
set(srcs "")
else()
set(srcs "epd7in3e.c")
endif()
idf_component_register(SRCS ${srcs}
INCLUDE_DIRS "include"
PRIV_REQUIRES esp_driver_spi esp_driver_gpio)
+14 -2
View File
@@ -1,3 +1,15 @@
idf_component_register(SRCS main.c wifi_provisioning.c frame_client.c qr_onboarding.c status_screen.c epd_draw.c next_button.c back_button.c combo_button.c manage_qr_overlay.c battery.c ota_update.c board_antenna.c
PRIV_REQUIRES esp_event nvs_flash esp_wifi esp_netif esp_http_server esp_http_client mbedtls dns_server epd7in3e qrcode epaper_fonts esp_driver_gpio esp_adc esp_https_ota app_update esp_app_format
# Both EPD driver components are always REQUIRED (REQUIRES/PRIV_REQUIRES
# can't itself depend on a Kconfig value -- ESP-IDF resolves the
# component dependency graph in an early pass that runs BEFORE Kconfig
# is generated, so a CONFIG_* check here would silently see an empty
# value every time; confirmed the hard way, see git history if this
# comment ever seems suspicious). Which one actually compiles anything
# is decided inside each component's own CMakeLists.txt (conditional
# SRCS, evaluated in the later, Kconfig-aware pass -- that's fine, it's
# only REQUIRES itself that has the early-pass restriction), keyed off
# the same CONFIG_FRAME_PANEL_EE02_13IN3 that main/epd_board.h uses to
# pick which header every source file sees -- exactly one of the two
# ever contributes actual object files/symbols to a given build.
idf_component_register(SRCS main.c wifi_provisioning.c frame_client.c qr_onboarding.c status_screen.c epd_draw.c next_button.c back_button.c combo_button.c battery.c ota_update.c board_antenna.c
PRIV_REQUIRES esp_event nvs_flash esp_wifi esp_netif esp_http_server esp_http_client mbedtls dns_server epd7in3e epd13in3e qrcode epaper_fonts esp_driver_gpio esp_adc esp_https_ota app_update esp_app_format
EMBED_FILES root.html)
+71 -27
View File
@@ -2,18 +2,29 @@ menu "ESPresso Frame Configuration"
config FRAME_BOARD_NAME
string "Board variant name, reported to the server"
default "devkit"
default "devkit_esp32c6"
help
Sent as the X-Frame-Board request header on every
GET /frame/config poll, so the server can learn which board
this device is and automatically fetch the right OTA build
from a configured Gitea repo's releases -- no manual "which
board" picker in the web UI. Must match one of the asset
names .gitea/workflows/firmware-release-build.yml publishes
(firmware-<name>.bin): "devkit" (this default, for the
plain ESP32-C6-DevKitC-1 build) or "xiao" (set via
sdkconfig.xiao for the Seeed XIAO ESP32-C6 build -- see
build_for_board.sh).
from a configured Gitea repo's releases, and (see
routers/device.py's BOARD_PANEL_MAP) which EPD panel it
drives -- no manual "which board/panel" picker in the web
UI. Must match one of the asset names
.gitea/workflows/firmware-release-build.yml publishes
(firmware-<name>.bin): "devkit_esp32c6" (this default, for
the plain ESP32-C6-DevKitC-1 build), "xiao_esp32c6" (set via
sdkconfig.xiao for the Seeed XIAO ESP32-C6 build), or "ee02"
(set via sdkconfig.ee02 for the Seeed EE02/XIAO ESP32-S3
Plus + 13.3" panel build) -- see build_for_board.sh.
Chip-qualified rather than plain "devkit"/"xiao": the EE02
board also sockets a XIAO module (an ESP32-S3 one), so
"xiao" alone stopped disambiguating hardware once EE02
existed. The server keeps accepting the old bare
"devkit"/"xiao" names indefinitely too, since already-
flashed devices report whatever name their current firmware
was built with and can't be retroactively renamed.
config FRAME_XIAO_ANTENNA_INIT
bool "Select onboard antenna on Seeed XIAO ESP32-C6 (RF switch init)"
@@ -33,6 +44,18 @@ menu "ESPresso Frame Configuration"
by default in sdkconfig.xiao; leave off for the DevKitC-1
dev board, which has no such switch.
config FRAME_PANEL_EE02_13IN3
bool "Build for the EE02 board + 13.3in Spectra 6 panel (ESP32-S3), not the 7.3in panel"
default n
help
Selects the epd13in3e driver component (13.3", 1600x1200)
instead of epd7in3e (7.3", 800x480) as main/epd_board.h's
target -- see firmware/components/epd13in3e. Firmware only
ever links one EPD driver at a time, same as the
devkit/xiao split links exactly one board's pin config.
Enabled by default in sdkconfig.ee02; leave off for the
ESP32-C6 boards (devkit/xiao), which drive the 7.3" panel.
config ESP_AP_SSID
string "Provisioning softAP SSID prefix"
default "ESPRESSO"
@@ -119,6 +142,7 @@ menu "ESPresso Frame Configuration"
config FRAME_NEXT_BUTTON_GPIO
int "Next-photo button GPIO (-1 to disable)"
default 2
range -1 21 if IDF_TARGET_ESP32S3
range -1 7
help
Button wired between this GPIO and GND (active-low, internal
@@ -126,14 +150,17 @@ menu "ESPresso Frame Configuration"
Pressing it wakes the device (if asleep), forces the server to
advance to the next photo immediately (POST /frame/advance)
regardless of the configured refresh interval, and displays
it. Must be GPIO 0-7 -- the only pins the ESP32-C6 can use as
a deep-sleep GPIO wakeup source, which is what lets a press
wake the device promptly instead of only being noticed during
its brief awake windows. Set to -1 to disable the feature.
it. Must be a deep-sleep-wakeup-capable GPIO: 0-7 on the
ESP32-C6, 0-21 on the ESP32-S3 (RTC-IO pins reachable by
esp_sleep_enable_ext1_wakeup_io()) -- required so a press
wakes the device promptly instead of only being noticed
during its brief awake windows. Set to -1 to disable the
feature.
config FRAME_BACK_BUTTON_GPIO
int "Back-photo button GPIO (-1 to disable)"
default 0
range -1 21 if IDF_TARGET_ESP32S3
range -1 7
help
Button wired between this GPIO and GND (active-low, internal
@@ -142,40 +169,40 @@ menu "ESPresso Frame Configuration"
to return to the previously-current photo immediately
(POST /frame/back), and displays it. Pressing next
afterwards returns to where you were before pressing back.
Must be GPIO 0-7 for the same deep-sleep-wakeup reason as
Must be a deep-sleep-wakeup-capable GPIO, same range as
FRAME_NEXT_BUTTON_GPIO above; defaults to a different pin
than the other buttons. Set to -1 to disable the feature.
config FRAME_COMBO_BUTTON_GPIO
int "Menu/reset button GPIO (-1 to disable)"
default 1
range -1 21 if IDF_TARGET_ESP32S3
range -1 7
help
Button wired between this GPIO and GND (active-low, internal
pull-up enabled in firmware -- no external resistor needed).
One pin, three actions depending on how long it's held:
a quick press shows the management menu (same as before);
holding it FRAME_COMBO_SOFT_RESET_HOLD_MS then releasing
soft-resets the device (reboots, keeps the stored WiFi/
server config); holding it all the way to
FRAME_COMBO_FACTORY_RESET_HOLD_MS clears the stored config
and restarts into provisioning, regardless of whether it's
released yet. Must be GPIO 0-7 for the same deep-sleep-
wakeup reason as FRAME_NEXT_BUTTON_GPIO above; defaults to
a quick press soft-resets the device (reboots, keeps the
stored WiFi/server config); holding it FRAME_COMBO_MENU_HOLD_MS
then releasing shows the management menu; holding it all the
way to FRAME_COMBO_FACTORY_RESET_HOLD_MS clears the stored
config and restarts into provisioning, regardless of whether
it's released yet. Must be a deep-sleep-wakeup-capable GPIO,
same range as FRAME_NEXT_BUTTON_GPIO above; defaults to
a different pin than the other buttons. Set to -1 to
disable the feature entirely (also disables the management
menu, both reset tiers, and factory-reset-via-button --
reconfiguring then only works by erasing NVS over USB, see
firmware/README.md).
config FRAME_COMBO_SOFT_RESET_HOLD_MS
int "Soft-reset hold duration (ms)"
config FRAME_COMBO_MENU_HOLD_MS
int "Management-menu hold duration (ms)"
default 3000
depends on FRAME_COMBO_BUTTON_GPIO >= 0
help
How long the menu/reset button must be held before releasing
it triggers a soft reset (reboot, config kept). Long enough
to be clearly distinct from a quick menu-opening press.
it shows the management menu instead of soft-resetting. Long
enough to be clearly distinct from a quick reset tap.
config FRAME_COMBO_FACTORY_RESET_HOLD_MS
int "Factory-reset hold duration (ms)"
@@ -185,8 +212,25 @@ menu "ESPresso Frame Configuration"
How long the menu/reset button must be held continuously
before the device clears its stored config and reboots into
provisioning, regardless of release. Comfortably longer than
FRAME_COMBO_SOFT_RESET_HOLD_MS so the two tiers can't be
confused for each other.
FRAME_COMBO_MENU_HOLD_MS so the two tiers can't be confused
for each other.
config FRAME_HOLD_ACTION_MS
int "Next/back hold-for-global-action duration (ms)"
default 3000
range 3000 10000
help
How long the NEXT or BACK button must be held before it
triggers a frame-wide action (see server/app/global_actions.py
-- e.g. cycling saved layouts) instead of that button's normal
short-press behavior. Only a first-boot/never-connected
fallback: once the device has fetched GET /frame/config at
least once, the server's own Frame.hold_duration_ms (set on
the Configuration tab) overrides this on every later boot --
see wifi_provisioning.h's frame_config_get_hold_duration_ms.
Floor matches the server's own minimum, so a long-held button
never means something different depending on which value
happened to apply.
config FRAME_BATTERY_ADC_GPIO
int "Battery voltage-divider ADC GPIO (-1 to disable)"
+52 -18
View File
@@ -1,10 +1,15 @@
#include <stdint.h>
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_sleep.h"
#include "soc/soc_caps.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "wifi_provisioning.h"
#include "back_button.h"
static const char *TAG = "back_button";
@@ -14,6 +19,7 @@ static const char *TAG = "back_button";
#define BACK_BUTTON_GPIO ((gpio_num_t)CONFIG_FRAME_BACK_BUTTON_GPIO)
#define BACK_BUTTON_DEBOUNCE_MS 20
#define BACK_BUTTON_DEBOUNCE_CHECKS 3
#define BACK_BUTTON_POLL_MS 100
void back_button_init(void)
{
@@ -30,13 +36,20 @@ void back_button_init(void)
};
gpio_config(&io_conf);
#if SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP
/* See next_button.c for why this API (not ext1) -- it manages the
* pull resistor across the sleep transition itself, so the pin
* doesn't float and wake the device spuriously. */
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << BACK_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
#else
/* See next_button.c for why ext1 is safe here on targets without the
* API above (e.g. ESP32-S3), and why _io() needs no cross-file mask
* coordination. */
ESP_ERROR_CHECK(esp_sleep_enable_ext1_wakeup_io(1ULL << BACK_BUTTON_GPIO, ESP_EXT1_WAKEUP_ANY_LOW));
#endif
}
bool back_button_check(void)
back_button_result_t back_button_check(void)
{
/* A quick tap can easily release before this runs (~0.4-0.5s into
* boot, confirmed on hardware -- a live gpio_get_level() check here
@@ -44,32 +57,53 @@ bool back_button_check(void)
* status register is latched at the moment of waking and isn't
* cleared until the next sleep entry, so it reliably reflects a tap
* regardless of how quickly it was released. */
if (esp_sleep_get_gpio_wakeup_status() & (1ULL << BACK_BUTTON_GPIO)) {
ESP_LOGI(TAG, "Back-photo button caused this wake, going back");
return true;
}
#if SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP
bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << BACK_BUTTON_GPIO);
#else
bool caused_wake = esp_sleep_get_ext1_wakeup_status() & (1ULL << BACK_BUTTON_GPIO);
#endif
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a fresh
* power-on/reflash) -- fall back to a live, debounced level check so
* holding the button down while powering on also works. */
if (gpio_get_level(BACK_BUTTON_GPIO) != 0) {
return false;
}
for (int i = 0; i < BACK_BUTTON_DEBOUNCE_CHECKS; i++) {
vTaskDelay(pdMS_TO_TICKS(BACK_BUTTON_DEBOUNCE_MS));
if (!caused_wake) {
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a
* fresh power-on/reflash) -- fall back to a live, debounced level
* check so holding the button down while powering on also
* works. */
if (gpio_get_level(BACK_BUTTON_GPIO) != 0) {
return false; /* noise, not a real press */
return BACK_BUTTON_NOT_PRESSED;
}
for (int i = 0; i < BACK_BUTTON_DEBOUNCE_CHECKS; i++) {
vTaskDelay(pdMS_TO_TICKS(BACK_BUTTON_DEBOUNCE_MS));
if (gpio_get_level(BACK_BUTTON_GPIO) != 0) {
return BACK_BUTTON_NOT_PRESSED; /* noise, not a real press */
}
}
}
ESP_LOGI(TAG, "Back-photo button held during power-on, going back");
return true;
/* Confirmed pressed -- measure how long, same reasoning/pattern as
* next_button_check(). */
uint32_t hold_threshold_ms;
if (frame_config_get_hold_duration_ms(&hold_threshold_ms) != ESP_OK) {
hold_threshold_ms = CONFIG_FRAME_HOLD_ACTION_MS;
}
uint32_t elapsed_ms = 0;
while (gpio_get_level(BACK_BUTTON_GPIO) == 0) {
if (elapsed_ms >= hold_threshold_ms) {
ESP_LOGI(TAG, "Back button held past %ums, triggering global hold action",
(unsigned)hold_threshold_ms);
return BACK_BUTTON_HOLD;
}
vTaskDelay(pdMS_TO_TICKS(BACK_BUTTON_POLL_MS));
elapsed_ms += BACK_BUTTON_POLL_MS;
}
ESP_LOGI(TAG, "Back-photo button short press (%ums), going back", (unsigned)elapsed_ms);
return BACK_BUTTON_SHORT_PRESS;
}
#else
void back_button_init(void) {}
bool back_button_check(void) { return false; }
back_button_result_t back_button_check(void) { return BACK_BUTTON_NOT_PRESSED; }
#endif
+18 -4
View File
@@ -12,9 +12,23 @@
*/
void back_button_init(void);
typedef enum {
BACK_BUTTON_NOT_PRESSED,
/** A short press -- same immediate-response reasoning as the
* next-photo button. */
BACK_BUTTON_SHORT_PRESS,
/** Held past the configured hold duration (see
* wifi_provisioning.h's frame_config_get_hold_duration_ms) --
* triggers a frame-wide action instead (see
* server/app/global_actions.py, frame_client.h's FETCH_GLOBAL_BACK).
* Fires immediately at the threshold, without waiting for release. */
BACK_BUTTON_HOLD,
} back_button_result_t;
/**
* Returns whether the back-photo button is currently held, debounced with
* a couple of short re-checks to reject noise. Same immediate-response
* reasoning as the next-photo button -- no long hold-to-confirm gate.
* Checks the back-photo button and, if it's pressed at all, blocks
* polling its level until either it's released (BACK_BUTTON_SHORT_PRESS)
* or the hold duration elapses (BACK_BUTTON_HOLD) -- same pattern as
* next_button_check(). Evaluated once per wake.
*/
bool back_button_check(void);
back_button_result_t back_button_check(void);
+30 -7
View File
@@ -1,3 +1,5 @@
#include <stdlib.h>
#include "driver/gpio.h"
#include "esp_adc/adc_cali_scheme.h"
#include "esp_adc/adc_oneshot.h"
@@ -11,7 +13,13 @@ static const char *TAG = "battery";
#if CONFIG_FRAME_BATTERY_ADC_GPIO >= 0
#define BATTERY_ADC_GPIO CONFIG_FRAME_BATTERY_ADC_GPIO
#define BATTERY_SAMPLES 8
#define BATTERY_SAMPLES 16
/* Trimmed mean: the extreme BATTERY_TRIM samples on each end (regulator/
* RF transients, not the true resting voltage) are dropped before
* averaging the rest -- a plain average lets even one or two of those
* skew the result enough to read as a real percent change downstream
* (see the recharge-jump handling in routers/device.py). */
#define BATTERY_TRIM 3
/* The external divider halves the battery voltage (2x200k, per the
* Seeed-documented XIAO wiring) so a full 4.2V cell reads ~2.1V at the
* pin, inside the 12dB-attenuation ADC range. */
@@ -34,6 +42,11 @@ static const struct {
{ 3300, 5 }, { 3000, 0 },
};
static int int_cmp(const void *a, const void *b)
{
return *(const int *)a - *(const int *)b;
}
static int mv_to_percent(int mv)
{
int n = sizeof(LIPO_CURVE) / sizeof(LIPO_CURVE[0]);
@@ -139,19 +152,17 @@ int battery_read_percent(void)
ESP_LOGW(TAG, "ADC calibration unavailable, using nominal scaling");
}
int mv_sum = 0;
int mv_samples[BATTERY_SAMPLES];
int samples = 0;
for (int i = 0; i < BATTERY_SAMPLES; i++) {
int value;
if (calibrated) {
if (adc_oneshot_get_calibrated_result(adc, cali, channel, &value) == ESP_OK) {
mv_sum += value;
samples++;
mv_samples[samples++] = value;
}
} else {
if (adc_oneshot_read(adc, channel, &value) == ESP_OK) {
mv_sum += value * 3300 / 4095; /* nominal 12-bit full scale at 12dB */
samples++;
mv_samples[samples++] = value * 3300 / 4095; /* nominal 12-bit full scale at 12dB */
}
}
}
@@ -167,7 +178,19 @@ int battery_read_percent(void)
return -1;
}
int battery_mv = (mv_sum / samples) * BATTERY_DIVIDER_RATIO;
/* Only trim if there's enough left afterward to still be a
* meaningful average -- falls back to a plain average of whatever
* came in on a wake where most reads failed. */
qsort(mv_samples, samples, sizeof(int), int_cmp);
int trim = (samples > 2 * BATTERY_TRIM) ? BATTERY_TRIM : 0;
int mv_sum = 0;
int kept = 0;
for (int i = trim; i < samples - trim; i++) {
mv_sum += mv_samples[i];
kept++;
}
int battery_mv = (mv_sum / kept) * BATTERY_DIVIDER_RATIO;
if (battery_mv < BATTERY_MV_MIN || battery_mv > BATTERY_MV_MAX) {
ESP_LOGI(TAG, "Reading %dmV outside plausible battery range, ignoring", battery_mv);
return -1;
+19 -7
View File
@@ -1,6 +1,7 @@
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_sleep.h"
#include "soc/soc_caps.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
@@ -31,10 +32,17 @@ void combo_button_init(void)
};
gpio_config(&io_conf);
#if SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP
/* See next_button.c for why this API (not ext1) -- it manages the
* pull resistor across the sleep transition itself, so the pin
* doesn't float and wake the device spuriously. */
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << COMBO_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
#else
/* See next_button.c for why ext1 is safe here on targets without the
* API above (e.g. ESP32-S3), and why _io() needs no cross-file mask
* coordination. */
ESP_ERROR_CHECK(esp_sleep_enable_ext1_wakeup_io(1ULL << COMBO_BUTTON_GPIO, ESP_EXT1_WAKEUP_ANY_LOW));
#endif
}
bool combo_button_check(void)
@@ -48,13 +56,17 @@ bool combo_button_check(void)
* caused the wake even if it's since been released -- in which case
* the poll loop below simply measures 0ms held, correctly resolving
* to a quick press rather than "not pressed at all." */
#if SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP
bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << COMBO_BUTTON_GPIO);
#else
bool caused_wake = esp_sleep_get_ext1_wakeup_status() & (1ULL << COMBO_BUTTON_GPIO);
#endif
if (!caused_wake && gpio_get_level(COMBO_BUTTON_GPIO) != 0) {
return false; /* not pressed, and didn't cause this wake either */
}
ESP_LOGI(TAG, "Combo button held -- quick press for menu, %dms for soft reset, %dms for factory reset",
CONFIG_FRAME_COMBO_SOFT_RESET_HOLD_MS, CONFIG_FRAME_COMBO_FACTORY_RESET_HOLD_MS);
ESP_LOGI(TAG, "Combo button held -- quick press for soft reset, %dms for menu, %dms for factory reset",
CONFIG_FRAME_COMBO_MENU_HOLD_MS, CONFIG_FRAME_COMBO_FACTORY_RESET_HOLD_MS);
int elapsed_ms = 0;
while (gpio_get_level(COMBO_BUTTON_GPIO) == 0) {
@@ -70,13 +82,13 @@ bool combo_button_check(void)
}
}
if (elapsed_ms >= CONFIG_FRAME_COMBO_SOFT_RESET_HOLD_MS) {
ESP_LOGW(TAG, "Held %dms and released, soft-restarting (config kept)", elapsed_ms);
esp_restart();
if (elapsed_ms >= CONFIG_FRAME_COMBO_MENU_HOLD_MS) {
ESP_LOGI(TAG, "Held %dms and released, showing management menu", elapsed_ms);
return true;
}
ESP_LOGI(TAG, "Quick press (%dms), showing management menu", elapsed_ms);
return true;
ESP_LOGW(TAG, "Quick press (%dms), soft-restarting (config kept)", elapsed_ms);
esp_restart();
}
bool combo_button_is_pressed(void)
+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.
+17
View File
@@ -0,0 +1,17 @@
#pragma once
/* Which EPD driver component this binary is built against -- exactly one,
* selected at compile time by CONFIG_FRAME_PANEL_EE02_13IN3 (see
* main/Kconfig.projbuild and main/CMakeLists.txt's matching PRIV_REQUIRES
* selection). Every file that used to `#include "epd7in3e.h"` directly
* includes this instead, so a build for the other board picks up the
* right EPD_WIDTH/EPD_HEIGHT/EPD_FRAME_BYTES/epd_color_t/epd_init() etc.
* with no other source change -- both driver components expose the same
* function/macro names (see epd13in3e.h), just sized for their own
* panel. */
#if CONFIG_FRAME_PANEL_EE02_13IN3
#include "epd13in3e.h"
#else
#include "epd7in3e.h"
#endif
+2 -2
View File
@@ -3,10 +3,10 @@
#include <stddef.h>
#include <stdint.h>
#include "epd7in3e.h"
#include "epd_board.h"
#include "fonts.h"
/** Sets one pixel in a malloc'd EPD_FRAME_BYTES buffer (packed 2px/byte, per epd7in3e.h). */
/** Sets one pixel in a malloc'd EPD_FRAME_BYTES buffer (packed 2px/byte, per epd_board.h). */
void epd_draw_pixel(uint8_t *frame, int x, int y, epd_color_t color);
/**
+135 -332
View File
@@ -14,12 +14,12 @@
#include "freertos/FreeRTOS.h"
#include "freertos/event_groups.h"
#include "epd7in3e.h"
#include "epd_board.h"
#include "status_screen.h"
#include "manage_qr_overlay.h"
#include "combo_button.h"
#include "ota_update.h"
#include "board_antenna.h"
#include "battery.h"
#include "frame_client.h"
@@ -95,15 +95,15 @@ static void save_wifi_cache(esp_netif_t *netif)
}
/* Builds a full URL from cfg->toolsserver + a path (no leading slash),
* appending cfg->access_token as ?token= if one's set. toolsserver is
* normally a bare "host:port", defaulting to plain http; it may instead
* carry an explicit "http://" or "https://" prefix to pick the scheme,
* e.g. "https://frame.example.com" if a reverse proxy is terminating
* TLS in front of the tools server. The token, once the server has
* MANAGEMENT_TOKEN set, is required on every request the server
* receives (device-facing endpoints included, not just the web UI) --
* this is the one chokepoint all of them go through, so every caller
* gets it for free instead of needing to remember to add it. */
* appending cfg->device_token as &token= once one's been delivered.
* toolsserver is normally a bare "host:port", defaulting to plain http;
* it may instead carry an explicit "http://" or "https://" prefix to
* pick the scheme, e.g. "https://frame.example.com" if a reverse proxy
* is terminating TLS in front of the tools server. Every URL carries
* ?id= (the device's MAC-derived identity -- how a multi-frame server
* tells frames apart and how an unknown frame self-registers) plus
* &token=. This is the one chokepoint all requests go through, so every
* caller gets both for free instead of needing to remember to add them. */
static void build_url(char *out, size_t out_size, const frame_config_t *cfg, const char *path)
{
const char *toolsserver = cfg->toolsserver;
@@ -113,8 +113,15 @@ static void build_url(char *out, size_t out_size, const frame_config_t *cfg, con
} else {
len = (size_t)snprintf(out, out_size, "http://%s/%s", toolsserver, path);
}
if (cfg->access_token[0] != '\0' && len < out_size) {
snprintf(out + len, out_size - len, "?token=%s", cfg->access_token);
char device_id[FRAME_DEVICE_ID_LEN + 1];
frame_device_id_get(device_id, sizeof(device_id));
if (len < out_size) {
len += (size_t)snprintf(out + len, out_size - len, "?id=%s", device_id);
}
if (cfg->device_token[0] != '\0' && len < out_size) {
snprintf(out + len, out_size - len, "&token=%s", cfg->device_token);
}
}
@@ -265,7 +272,20 @@ esp_err_t frame_wifi_connect_sta(const frame_config_t *cfg)
typedef struct {
bool reachable;
uint32_t refresh_interval_s; /* CONFIG_FRAME_SLEEP_INTERVAL_S if absent/unparseable */
/* How long NEXT/BACK must be held to trigger a global action instead
* of a short press (see next_button.h/back_button.h) --
* CONFIG_FRAME_HOLD_ACTION_MS if absent/unparseable (older server) or
* unreachable. Persisted via frame_config_set_hold_duration_ms() for
* the *next* boot's button-hold decision -- this fetch happens too
* late in the cycle for its own boot's decision, see that function's
* own doc comment. */
uint32_t hold_duration_ms;
char firmware_version[32]; /* server's uploaded OTA image version; empty if none/unreachable */
/* Per-frame token the server pushes until this device has
* authenticated with it once; empty when absent. Persisted via
* frame_config_set_device_token() and used by build_url() from the
* next request on. */
char device_token[FRAME_CFG_TOKEN_MAX_LEN + 1];
} frame_server_config_t;
/* Finds the first integer value associated with "key" in a small JSON
@@ -354,8 +374,10 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
frame_server_config_t result = {
.reachable = false,
.refresh_interval_s = CONFIG_FRAME_SLEEP_INTERVAL_S,
.hold_duration_ms = CONFIG_FRAME_HOLD_ACTION_MS,
};
result.firmware_version[0] = '\0';
result.device_token[0] = '\0';
char url[256];
build_url(url, sizeof(url), cfg, "frame/config");
@@ -380,7 +402,10 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
esp_http_client_fetch_headers(client);
result.reachable = true;
char body[256];
/* 512 (was 256): the response also carries "device_token" during the
* one-time identity handshake -- worst case is still well under half
* of this, the rest is headroom for future fields. */
char body[512];
int total = 0;
int n;
while (total < (int)sizeof(body) - 1 &&
@@ -399,251 +424,66 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
ESP_LOGW(TAG, "'%s' response missing refresh_interval_s, using fallback %ds", url,
(int)result.refresh_interval_s);
}
uint32_t hold_ms;
if (json_extract_uint(body, "hold_duration_ms", &hold_ms)) {
result.hold_duration_ms = hold_ms;
}
json_extract_string(body, "firmware_version", result.firmware_version, sizeof(result.firmware_version));
json_extract_string(body, "device_token", result.device_token, sizeof(result.device_token));
return result;
}
/* GETs the server's /frame/photo-info for the manage-button overlay:
* location/taken_at text (left empty if the server didn't have them --
* e.g. no GPS EXIF to geocode, or no capture date) and a share_url built
* from the returned asset_id, same construction pattern as
* run_fetch_cycle()'s management_url. Any failure (unreachable, no
* current photo, etc.) just leaves all outputs empty -- the caller
* treats that as "skip these optional overlay regions", not a hard
* error, since the base "scan to manage" QR should still show. */
static void fetch_photo_info(const frame_config_t *cfg, char *location_line1, size_t location_line1_size,
char *location_line2, size_t location_line2_size, char *taken_at, size_t taken_at_size,
char *share_url, size_t share_url_size)
{
location_line1[0] = '\0';
location_line2[0] = '\0';
taken_at[0] = '\0';
share_url[0] = '\0';
char url[256];
build_url(url, sizeof(url), cfg, "frame/photo-info");
/* CONFIG_FRAME_FETCH_TIMEOUT_MS, not the shorter SERVER_CHECK one:
* unlike fetch_frame_config() (always called after the image fetch
* has already warmed the connection, see frame_client_run()), this
* is the *first* network call of the wake cycle whenever the manage
* menu is opened -- same cold-connection latency spike that made
* the short timeout unreliable for /frame/config before, now worse
* with a real TLS handshake on top. Confirmed on hardware: this
* timed out under CONFIG_FRAME_SERVER_CHECK_TIMEOUT_MS while the
* rest of the cycle (a fresh connection, but not the *first* one)
* succeeded fine. */
esp_http_client_config_t config = {
.url = url,
.method = HTTP_METHOD_GET,
.timeout_ms = CONFIG_FRAME_FETCH_TIMEOUT_MS,
.crt_bundle_attach = esp_crt_bundle_attach,
};
esp_http_client_handle_t client = esp_http_client_init(&config);
esp_err_t err = esp_http_client_open(client, 0);
if (err != ESP_OK) {
ESP_LOGW(TAG, "'%s' not reachable: %s", url, esp_err_to_name(err));
esp_http_client_cleanup(client);
return;
}
int status = esp_http_client_fetch_headers(client) >= 0 ? esp_http_client_get_status_code(client) : -1;
if (status != 200) {
ESP_LOGW(TAG, "'%s' returned HTTP %d", url, status);
esp_http_client_close(client);
esp_http_client_cleanup(client);
return;
}
char body[384];
int total = 0;
int n;
while (total < (int)sizeof(body) - 1 &&
(n = esp_http_client_read(client, body + total, sizeof(body) - 1 - total)) > 0) {
total += n;
}
body[total] = '\0';
esp_http_client_close(client);
esp_http_client_cleanup(client);
json_extract_string(body, "location_line1", location_line1, location_line1_size);
json_extract_string(body, "location_line2", location_line2, location_line2_size);
json_extract_string(body, "taken_at", taken_at, taken_at_size);
char asset_id[48];
if (json_extract_string(body, "asset_id", asset_id, sizeof(asset_id))) {
char path[80];
snprintf(path, sizeof(path), "frame/share/%s", asset_id);
build_url(share_url, share_url_size, cfg, path);
}
}
/* GETs the server's /frame/face-labels for the manage-button's escalated
* "level 2" menu -- named-face positions, if Immich has any for the
* current photo. Response is a flattened, fixed-slot shape ("count",
* then name_0/x_0/y_0, name_1/x_1/y_1, ...) rather than a real JSON
* array, read with the same flat-scalar helpers as everywhere else in
* this file instead of needing an actual array parser. Any failure
* (unreachable, malformed response, etc.) just returns 0 -- named faces
* are a "nice to have" addition to the menu, not worth failing it over. */
static int fetch_face_labels(const frame_config_t *cfg, manage_face_label_t *out, int max_labels)
{
char url[256];
build_url(url, sizeof(url), cfg, "frame/face-labels");
/* Same reasoning as fetch_photo_info() -- this is a manage-menu
* request too, not a warmed-connection reachability check. */
esp_http_client_config_t config = {
.url = url,
.method = HTTP_METHOD_GET,
.timeout_ms = CONFIG_FRAME_FETCH_TIMEOUT_MS,
.crt_bundle_attach = esp_crt_bundle_attach,
};
esp_http_client_handle_t client = esp_http_client_init(&config);
esp_err_t err = esp_http_client_open(client, 0);
if (err != ESP_OK) {
ESP_LOGW(TAG, "'%s' not reachable: %s", url, esp_err_to_name(err));
esp_http_client_cleanup(client);
return 0;
}
int status = esp_http_client_fetch_headers(client) >= 0 ? esp_http_client_get_status_code(client) : -1;
if (status != 200) {
ESP_LOGW(TAG, "'%s' returned HTTP %d", url, status);
esp_http_client_close(client);
esp_http_client_cleanup(client);
return 0;
}
char body[768];
int total = 0;
int n;
while (total < (int)sizeof(body) - 1 &&
(n = esp_http_client_read(client, body + total, sizeof(body) - 1 - total)) > 0) {
total += n;
}
body[total] = '\0';
esp_http_client_close(client);
esp_http_client_cleanup(client);
uint32_t count = 0;
json_extract_uint(body, "count", &count);
if ((int)count > max_labels) {
count = (uint32_t)max_labels;
}
int found = 0;
for (uint32_t i = 0; i < count; i++) {
char key[16];
snprintf(key, sizeof(key), "name_%u", (unsigned)i);
if (!json_extract_string(body, key, out[found].name, sizeof(out[found].name))) {
continue;
}
snprintf(key, sizeof(key), "x_%u", (unsigned)i);
uint32_t x;
if (!json_extract_uint(body, key, &x)) {
continue;
}
snprintf(key, sizeof(key), "y_%u", (unsigned)i);
uint32_t y;
if (!json_extract_uint(body, key, &y)) {
continue;
}
out[found].x = (int)x;
out[found].y = (int)y;
found++;
}
return found;
}
typedef struct {
esp_http_client_handle_t client;
size_t stream_pos; /* running absolute offset into the frame, for overlay splicing */
const manage_overlay_set_t *overlay; /* NULL = no overlay this fetch */
} http_read_ctx_t;
/* Splices one overlay region's pixels over the real photo bytes in chunk
* wherever chunk's absolute byte range [chunk_start, chunk_start+chunk_len)
* within the full frame intersects that region's rectangle. Rows/chunks
* outside the region's footprint are left completely untouched.
* region->x0 is always even (see manage_qr_overlay.h), so byte_x0 below
* is exact. */
static void splice_overlay_region(uint8_t *chunk, size_t chunk_len, size_t chunk_start,
const manage_overlay_region_t *region)
{
int byte_x0 = region->x0 / 2;
int byte_w = region->w / 2;
size_t chunk_end = chunk_start + chunk_len;
for (int row = region->y0; row < region->y0 + region->h; row++) {
size_t row_start = (size_t)row * EPD_BYTES_PER_ROW + (size_t)byte_x0;
size_t row_end = row_start + (size_t)byte_w;
size_t lo = row_start > chunk_start ? row_start : chunk_start;
size_t hi = row_end < chunk_end ? row_end : chunk_end;
if (lo >= hi) {
continue;
}
size_t region_row_offset = (size_t)(row - region->y0) * (size_t)byte_w + (lo - row_start);
memcpy(chunk + (lo - chunk_start), region->buf + region_row_offset, hi - lo);
}
}
static void splice_overlay(uint8_t *chunk, size_t chunk_len, size_t chunk_start, const manage_overlay_set_t *overlay)
{
for (int i = 0; i < overlay->count; i++) {
splice_overlay_region(chunk, chunk_len, chunk_start, &overlay->regions[i]);
}
}
/* Pulls the next chunk straight out of the in-progress HTTP response --
* epd_write_frame() calls this to feed the panel without ever holding
* the full ~192KB frame in RAM. Splices in ctx->overlay's regions (if
* set) as chunks pass through, so the panel driver never needs to know
* an overlay exists at all. */
* the full ~192KB frame in RAM. Just a plain relay: the manage overlay
* (scan-to-manage QR, battery, location/date, share-QR, named face
* labels) is composited server-side now (see server/app/manage_overlay.py),
* baked into the same image bytes as any other render -- this function,
* like the rest of this file, has no idea an overlay exists. */
static size_t http_read_fn(uint8_t *chunk, size_t chunk_size, void *ctx_)
{
http_read_ctx_t *ctx = (http_read_ctx_t *)ctx_;
int n = esp_http_client_read(ctx->client, (char *)chunk, (int)chunk_size);
if (n <= 0) {
return 0;
}
if (ctx->overlay != NULL) {
splice_overlay(chunk, (size_t)n, ctx->stream_pos, ctx->overlay);
}
ctx->stream_pos += (size_t)n;
return (size_t)n;
return n > 0 ? (size_t)n : 0;
}
/* GETs /frame/image (FETCH_NORMAL), or POSTs /frame/advance or
* /frame/back to force a move in either direction (FETCH_ADVANCE /
* FETCH_BACK -- the next-photo / back-photo buttons), and streams the
* response directly into the panel, splicing in overlay's pixels (if
* non-NULL) as it streams. Returning non-ESP_OK means the panel was
* never actually refreshed -- epd_display_stream() (see epd7in3e.c)
* refuses to trigger a physical refresh on a short/wrong-size stream,
* so a failure here always leaves the visible screen exactly as it
* was. */
static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t action,
const manage_overlay_set_t *overlay)
/* GETs /frame/image (FETCH_NORMAL), or POSTs /frame/advance, /frame/back,
* /frame/global-next, or /frame/global-back to force a move/action
* (FETCH_ADVANCE / FETCH_BACK -- a short press; FETCH_GLOBAL_NEXT /
* FETCH_GLOBAL_BACK -- a held press, see next_button.h/back_button.h).
* manage=true (the manage button) appends &manage=1, telling the server
* to bake its overlay into this same response instead of returning the
* bare content -- see server/app/routers/device.py. Returning non-ESP_OK
* means the panel was never actually refreshed -- epd_display_stream()
* (see the active EPD driver component, main/epd_board.h) refuses to
* trigger a physical refresh on a short/wrong-size stream, so a failure
* here always leaves the visible screen exactly as it was. */
static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t action, bool manage)
{
const char *path = "frame/image";
if (action == FETCH_ADVANCE) {
path = "frame/advance";
} else if (action == FETCH_BACK) {
path = "frame/back";
} else if (action == FETCH_GLOBAL_NEXT) {
path = "frame/global-next";
} else if (action == FETCH_GLOBAL_BACK) {
path = "frame/global-back";
}
char url[256];
build_url(url, sizeof(url), cfg, path);
if (manage) {
size_t len = strlen(url);
if (len + strlen("&manage=1") < sizeof(url)) {
strcpy(url + len, "&manage=1");
}
}
esp_http_client_config_t config = {
.url = url,
@@ -670,7 +510,7 @@ static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t act
}
ESP_LOGI(TAG, "Fetching frame (%d bytes) from '%s'", content_length, url);
http_read_ctx_t ctx = { .client = client, .overlay = overlay };
http_read_ctx_t ctx = { .client = client };
uint32_t crc = 0;
err = epd_write_frame(http_read_fn, &ctx, &crc);
@@ -698,8 +538,7 @@ static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t act
return err;
}
#define MANAGE_MENU_MAX_LEVEL 2
#define MANAGE_MENU_LEVEL_TIMEOUT_MS 30000
#define MANAGE_MENU_TIMEOUT_MS 30000
#define MANAGE_MENU_POLL_MS 150
#define MANAGE_MENU_DEBOUNCE_MS 30
@@ -731,110 +570,42 @@ static bool wait_for_button_press(uint32_t timeout_ms)
return false;
}
/* Builds and shows one level of the manage menu: level 1 is the base
* overlay (management QR + location/date/share-QR wherever the server
* had that data); level 2 adds named-face labels on top. action only
* applies at level 1 -- escalating to level 2 redisplays the same
* photo, so it never re-advances/-backs. */
static esp_err_t show_menu_level(const frame_config_t *cfg, fetch_action_t action, int level,
int battery_percent)
/* Runs the manage-button view: fetches once with manage=1 (the server
* bakes its whole overlay -- scan-to-manage QR, battery, location/date/
* share-QR, every named face label, no more RAM-driven cap on how many --
* into the response), shows it, then waits up to 30s for either another
* press or the timeout before reverting to a plain fetch. Device stays
* awake throughout (doesn't sleep the panel or the chip). Returns
* non-ESP_OK only if the manage fetch itself failed; a revert failure
* after that is logged but doesn't count as an overall failure --
* something was already shown successfully, which was the point of the
* button. */
static esp_err_t run_management_menu(const frame_config_t *cfg, fetch_action_t action)
{
char management_url[256];
build_url(management_url, sizeof(management_url), cfg, "");
char location_line1[32];
char location_line2[32];
char taken_at[32];
/* Wider than the other URL buffers in this file: unlike a fixed path,
* this one stacks toolsserver (up to 128) + "/frame/share/" + an
* asset_id (up to 47) + "?token=" + an access_token (up to 64) --
* worst case ~266 bytes, which a 256-byte buffer could silently
* truncate the token off of (build_url()'s bounds check avoids an
* overflow, but a truncated/dropped token still means the resulting
* request just 401s with no obvious cause). */
char share_url[320];
fetch_photo_info(cfg, location_line1, sizeof(location_line1), location_line2,
sizeof(location_line2), taken_at, sizeof(taken_at), share_url, sizeof(share_url));
manage_face_label_t face_labels[MANAGE_FACE_LABELS_MAX];
int face_label_count = 0;
if (level >= 2) {
face_label_count = fetch_face_labels(cfg, face_labels, MANAGE_FACE_LABELS_MAX);
}
manage_overlay_content_t content = {
.management_url = management_url,
.location_line1 = location_line1[0] != '\0' ? location_line1 : NULL,
.location_line2 = location_line2[0] != '\0' ? location_line2 : NULL,
.taken_at = taken_at[0] != '\0' ? taken_at : NULL,
.share_url = share_url[0] != '\0' ? share_url : NULL,
.face_labels = face_labels,
.face_label_count = face_label_count,
.battery_percent = battery_percent,
};
manage_overlay_set_t overlay;
esp_err_t err = manage_overlay_render(&content, &overlay);
esp_err_t err = fetch_and_display(cfg, action, true);
if (err != ESP_OK) {
manage_overlay_free(&overlay);
return err;
ESP_LOGW(TAG, "Could not fetch manage view (%s), showing photo normally", esp_err_to_name(err));
return fetch_and_display(cfg, action, false);
}
err = fetch_and_display(cfg, action, &overlay);
manage_overlay_free(&overlay);
return err;
}
ESP_LOGI(TAG, "Showing manage view, waiting up to 30s");
wait_for_button_press(MANAGE_MENU_TIMEOUT_MS);
/* Runs the manage-button menu: level 1 (the base overlay) shows first;
* from there, each further press within 30s escalates one level (up to
* MANAGE_MENU_MAX_LEVEL, which adds named-face labels), and a press once
* already at the max level exits immediately instead of escalating
* further. A 30s timeout at any level also exits. Device stays awake
* throughout (doesn't sleep the panel or the chip). Returns non-ESP_OK
* only if the very first (level 1) render/fetch failed; failures after
* that (escalating, or the final revert) are logged but don't count as
* an overall failure -- something was already shown successfully, which
* was the point of the button. */
static esp_err_t run_management_menu(const frame_config_t *cfg, fetch_action_t action, int battery_percent)
{
int level = 1;
esp_err_t err = show_menu_level(cfg, action, level, battery_percent);
if (err != ESP_OK) {
ESP_LOGW(TAG, "Could not render management overlay (%s), showing photo normally", esp_err_to_name(err));
return fetch_and_display(cfg, action, NULL);
}
for (;;) {
ESP_LOGI(TAG, "Showing management menu level %d, waiting up to 30s", level);
bool pressed = wait_for_button_press(MANAGE_MENU_LEVEL_TIMEOUT_MS);
if (!pressed || level >= MANAGE_MENU_MAX_LEVEL) {
break; /* timeout at any level, or a press while already maxed out -- exit */
}
level++;
esp_err_t level_err = show_menu_level(cfg, FETCH_NORMAL, level, battery_percent);
if (level_err != ESP_OK) {
ESP_LOGW(TAG, "Could not render menu level %d (%s), reverting", level, esp_err_to_name(level_err));
break;
}
}
esp_err_t revert_err = fetch_and_display(cfg, FETCH_NORMAL, NULL);
esp_err_t revert_err = fetch_and_display(cfg, FETCH_NORMAL, false);
if (revert_err != ESP_OK) {
ESP_LOGW(TAG, "Failed to revert management overlay (%s)", esp_err_to_name(revert_err));
ESP_LOGW(TAG, "Failed to revert manage view (%s)", esp_err_to_name(revert_err));
}
return ESP_OK;
}
/* Runs the appropriate fetch for this cycle: a plain fetch, or -- if
* show_management_qr -- the escalating manage menu (see
* run_management_menu()). */
static esp_err_t run_fetch_cycle(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr,
int battery_percent)
* show_management_qr -- the manage view (see run_management_menu()). */
static esp_err_t run_fetch_cycle(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr)
{
if (!show_management_qr) {
return fetch_and_display(cfg, action, NULL);
return fetch_and_display(cfg, action, false);
}
return run_management_menu(cfg, action, battery_percent);
return run_management_menu(cfg, action);
}
/* Reports the battery percent to the server (POST /frame/battery).
@@ -878,8 +649,7 @@ static void report_battery(const frame_config_t *cfg, int percent)
esp_http_client_cleanup(client);
}
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr,
int battery_percent)
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr)
{
esp_err_t epd_err = epd_init();
bool have_display = (epd_err == ESP_OK);
@@ -913,14 +683,15 @@ void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool sho
* worth it to stop false-failing on the common case. */
bool image_ok = true;
if (have_display) {
esp_err_t fetch_err = run_fetch_cycle(cfg, action, show_management_qr, battery_percent);
esp_err_t fetch_err = run_fetch_cycle(cfg, action, show_management_qr);
image_ok = (fetch_err == ESP_OK);
if (!image_ok) {
/* epd_display_stream() never triggers a physical refresh on a
* failed/short/wrong-size stream (see epd7in3e.c), so the
* visible screen is guaranteed untouched here -- always safe
* to show what went wrong instead of leaving stale content
* with no indication anything failed. */
* failed/short/wrong-size stream (see the active EPD driver
* component, main/epd_board.h), so the visible screen is
* guaranteed untouched here -- always safe to show what went
* wrong instead of leaving stale content with no indication
* anything failed. */
ESP_LOGW(TAG, "Fetch/display failed (%s), retrying sooner", esp_err_to_name(fetch_err));
/* Covers the fast-connect cache's blind spot: WiFi can report
* a successful connection (cached static IP "worked" at the
@@ -948,9 +719,41 @@ void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool sho
* normal boot, not just the one right after an update). */
esp_ota_mark_app_valid_cancel_rollback();
/* Read now, not at boot: the photo (and, if shown, the manage
* overlay -- entirely server-composited now, using the server's
* own last-known battery value, not a local reading, see
* server/app/manage_overlay.py) is already on the panel, so
* there's no display deadline to beat.
* Reading here instead of right after waking sidesteps taking the
* ADC sample while the rail's still settling from whatever the
* boot/reset just did, with no need to guess a settle delay --
* the fetch/display work already done this cycle is the delay.
* Still safe re: the battery/button pin sharing (battery.h) --
* every button check main.c does happens well before this, at
* the very start of boot. */
int battery_percent = battery_read_percent();
report_battery(cfg, battery_percent);
frame_server_config_t server_cfg = fetch_frame_config(cfg);
sleep_seconds = server_cfg.reachable ? server_cfg.refresh_interval_s : CONFIG_FRAME_RETRY_INTERVAL_S;
if (server_cfg.reachable) {
/* For next boot's button-hold decision, not this one -- see
* frame_config_get_hold_duration_ms()'s own doc comment. */
frame_config_set_hold_duration_ms(server_cfg.hold_duration_ms);
}
/* One-time identity handshake: the server pushes this frame's
* own token until we've authenticated with it once. Persist it
* and use it immediately (the OTA below is part of this same
* cycle) via a local working copy -- cfg itself is const. */
frame_config_t updated_cfg;
if (server_cfg.device_token[0] != '\0' &&
strcmp(server_cfg.device_token, cfg->device_token) != 0) {
frame_config_set_device_token(server_cfg.device_token);
updated_cfg = *cfg;
snprintf(updated_cfg.device_token, sizeof(updated_cfg.device_token), "%s",
server_cfg.device_token);
cfg = &updated_cfg;
}
/* Last, deliberately -- the photo's already on screen and the
* battery report already sent, so a reboot here (whether OTA
+20 -12
View File
@@ -9,14 +9,19 @@
* Which photo-fetch behavior this wake cycle should use -- normally the
* idempotent GET /frame/image (the server decides on its own whether to
* advance, based on its configured refresh interval, so a plain
* wake/reboot never skips a photo just by asking), or POST
* /frame/advance / POST /frame/back to force a move in either direction
* (the next-photo / back-photo buttons).
* wake/reboot never skips a photo just by asking), POST /frame/advance /
* POST /frame/back to force a move in either direction (a short press of
* the next-photo / back-photo buttons), or POST /frame/global-next /
* POST /frame/global-back to run whatever frame-wide action (if any) is
* configured for a held press (see next_button.h/back_button.h's
* *_HOLD result and app/global_actions.py server-side).
*/
typedef enum {
FETCH_NORMAL,
FETCH_ADVANCE,
FETCH_BACK,
FETCH_GLOBAL_NEXT,
FETCH_GLOBAL_BACK,
} fetch_action_t;
/**
@@ -33,14 +38,17 @@ esp_err_t frame_wifi_connect_sta(const frame_config_t *cfg);
* deep-sleep until the next refresh.
*
* If show_management_qr is true (the manage button was held), the
* displayed photo gets a small "scan to manage" QR overlay in the
* top-right corner linking to the server's config page, held for 30
* seconds (the device stays awake), then reverted back to the plain
* photo before proceeding to the normal sleep-interval logic.
* request for that cycle carries &manage=1, and the server bakes its
* whole manage overlay (scan-to-manage QR, battery, location/date,
* share-QR, named face labels) directly into the image it returns --
* see server/app/manage_overlay.py; this device is otherwise unaware
* any of that exists, it just displays whatever comes back. Held for 30
* seconds (the device stays awake), then reverted back to a plain fetch
* before proceeding to the normal sleep-interval logic.
*
* battery_percent (0-100, or -1 for "no reading" -- see
* battery_read_percent()) is shown on the management menu overlay and
* reported to the server after a successful fetch; -1 skips both.
* Reads the battery (see battery_read_percent()) itself, once, after the
* photo is already on the panel, and reports it to the server on a
* successful fetch; a -1 reading ("no reading" -- on mains, disabled, or
* implausible) skips the report.
*/
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr,
int battery_percent);
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr);
+14 -12
View File
@@ -10,7 +10,6 @@
#include "next_button.h"
#include "back_button.h"
#include "combo_button.h"
#include "battery.h"
static const char *TAG = "main";
@@ -41,29 +40,32 @@ void app_main(void)
back_button_init();
combo_button_init();
bool next_pressed = next_button_check();
bool back_pressed = back_button_check();
next_button_result_t next_result = next_button_check();
back_button_result_t back_result = back_button_check();
/* Next takes priority over back if somehow both read pressed at once
* (e.g. both held through a power-on) -- an arbitrary but
* deterministic tie-break, not expected to matter in practice. */
fetch_action_t action = next_pressed ? FETCH_ADVANCE : back_pressed ? FETCH_BACK : FETCH_NORMAL;
* deterministic tie-break, not expected to matter in practice. Same
* priority applies whether the winning button resolved to a short
* press or a hold. */
fetch_action_t action;
if (next_result != NEXT_BUTTON_NOT_PRESSED) {
action = (next_result == NEXT_BUTTON_HOLD) ? FETCH_GLOBAL_NEXT : FETCH_ADVANCE;
} else if (back_result != BACK_BUTTON_NOT_PRESSED) {
action = (back_result == BACK_BUTTON_HOLD) ? FETCH_GLOBAL_BACK : FETCH_BACK;
} else {
action = FETCH_NORMAL;
}
/* Soft-resets or clears config + restarts internally for a medium/
* long hold and never returns in those cases -- only returns here
* for "not pressed" (false) or "quick press" (true, show the menu). */
bool show_management_qr = combo_button_check();
/* Must come after the button checks: the battery pin is (by design,
* on the XIAO board) shared with a button, and the ADC read briefly
* takes the pin over -- see battery.h. -1 = no reading (disabled,
* on mains, or implausible). */
int battery_percent = battery_read_percent();
frame_config_t cfg;
esp_err_t cfg_err = frame_config_load(&cfg);
if (cfg_err == ESP_OK) {
ESP_LOGI(TAG, "Found stored config for '%s', connecting to home WiFi", cfg.sta_ssid);
if (frame_wifi_connect_sta(&cfg) == ESP_OK) {
frame_client_run(&cfg, action, show_management_qr, battery_percent);
frame_client_run(&cfg, action, show_management_qr);
return; /* frame_client_run currently never returns */
}
ESP_LOGW(TAG, "Could not connect to stored WiFi after %d attempts, falling back to provisioning",
-356
View File
@@ -1,356 +0,0 @@
#include <stdlib.h>
#include <string.h>
#include "esp_check.h"
#include "epd7in3e.h"
#include "epd_draw.h"
#include "fonts.h"
#include "qrcodegen.h"
#include "manage_qr_overlay.h"
static const char *TAG = "manage_qr_overlay";
#define QR_MAX_VERSION 10
#define QR_BUFFER_LEN qrcodegen_BUFFER_LEN_FOR_VERSION(QR_MAX_VERSION)
/* Smaller than qr_onboarding.c's QR_MODULE_PX (8) -- these are compact
* corner popups, not a full-screen setup step. */
#define QR_MODULE_PX 4
#define PADDING 16
#define QR_TEXT_GAP 8
#define LINE_GAP 4
/* Distance from the panel's edges to each overlay box. Combined with
* EPD_WIDTH/EPD_HEIGHT and each region's forced-even width below, this
* guarantees x0 is always even -- required so a region's columns land on
* frame byte boundaries (2px/byte) when spliced into the fetch stream. */
#define PANEL_MARGIN 20
typedef enum {
CORNER_TOP_LEFT,
CORNER_TOP_RIGHT,
CORNER_BOTTOM_LEFT,
CORNER_BOTTOM_RIGHT,
} overlay_corner_t;
static void position_region(manage_overlay_region_t *region, overlay_corner_t corner)
{
switch (corner) {
case CORNER_TOP_LEFT:
region->x0 = PANEL_MARGIN;
region->y0 = PANEL_MARGIN;
break;
case CORNER_TOP_RIGHT:
region->x0 = EPD_WIDTH - PANEL_MARGIN - region->w;
region->y0 = PANEL_MARGIN;
break;
case CORNER_BOTTOM_LEFT:
region->x0 = PANEL_MARGIN;
region->y0 = EPD_HEIGHT - PANEL_MARGIN - region->h;
break;
case CORNER_BOTTOM_RIGHT:
region->x0 = EPD_WIDTH - PANEL_MARGIN - region->w;
region->y0 = EPD_HEIGHT - PANEL_MARGIN - region->h;
break;
}
}
static void draw_qr(uint8_t *buf, int stride, int width, int height, const uint8_t *qrcode, int origin_x,
int origin_y)
{
int size = qrcodegen_getSize(qrcode);
for (int y = 0; y < size; y++) {
for (int x = 0; x < size; x++) {
epd_color_t color = qrcodegen_getModule(qrcode, x, y) ? EPD_COLOR_BLACK : EPD_COLOR_WHITE;
for (int dy = 0; dy < QR_MODULE_PX; dy++) {
for (int dx = 0; dx < QR_MODULE_PX; dx++) {
epd_draw_pixel_ex(buf, stride, width, height, origin_x + x * QR_MODULE_PX + dx,
origin_y + y * QR_MODULE_PX + dy, color);
}
}
}
}
}
/* White-padded box with a QR code encoding payload, plus zero, one, or
* two centered caption lines beneath it (either may be NULL). Used for
* both the top-right "scan to manage" box (two lines) and the
* bottom-left share-link box (no lines). */
static esp_err_t render_qr_region(const char *payload, const char *line1, const char *line2, overlay_corner_t corner,
manage_overlay_region_t *out)
{
uint8_t temp_buffer[QR_BUFFER_LEN];
uint8_t qrcode[QR_BUFFER_LEN];
bool ok = qrcodegen_encodeText(payload, temp_buffer, qrcode, qrcodegen_Ecc_MEDIUM, qrcodegen_VERSION_MIN,
QR_MAX_VERSION, qrcodegen_Mask_AUTO, true);
ESP_RETURN_ON_FALSE(ok, ESP_FAIL, TAG, "QR encoding failed for '%s' (too long for max version)", payload);
int qr_size = qrcodegen_getSize(qrcode);
int qr_px = qr_size * QR_MODULE_PX;
/* Font24 (32x41px uppercase glyphs) is the only font vendored into
* this project -- see components/epaper_fonts. "SCAN TO MANAGE" on
* one line would be 448px wide, too wide for a compact corner box,
* so it's passed in pre-wrapped across two lines instead. */
int text_w = 0;
int text_h = 0;
if (line1 != NULL) {
int w1 = (int)strlen(line1) * Font24.Width;
int w2 = line2 != NULL ? (int)strlen(line2) * Font24.Width : 0;
text_w = w1 > w2 ? w1 : w2;
text_h = QR_TEXT_GAP + Font24.Height + (line2 != NULL ? LINE_GAP + Font24.Height : 0);
}
int content_w = qr_px > text_w ? qr_px : text_w;
int content_h = qr_px + text_h;
int w = content_w + PADDING * 2;
int h = content_h + PADDING * 2;
w += w % 2; /* keep byte-aligned (2px/byte) */
int stride = w / 2;
uint8_t *buf = malloc((size_t)stride * h);
ESP_RETURN_ON_FALSE(buf != NULL, ESP_ERR_NO_MEM, TAG, "Failed to allocate overlay region");
memset(buf, (EPD_COLOR_WHITE << 4) | EPD_COLOR_WHITE, (size_t)stride * h);
int center_x = w / 2;
int y = PADDING;
draw_qr(buf, stride, w, h, qrcode, center_x - qr_px / 2, y);
y += qr_px;
if (line1 != NULL) {
y += QR_TEXT_GAP;
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line1, center_x, y);
y += Font24.Height;
}
if (line2 != NULL) {
y += LINE_GAP;
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line2, center_x, y);
}
out->buf = buf;
out->w = w;
out->h = h;
position_region(out, corner);
return ESP_OK;
}
/* White-padded box with one or two centered lines of text (line2 may be
* NULL) -- used for the top-left location (city + state/country, two
* lines rather than cramming both onto one to keep the box from
* threatening to overlap the top-right QR box) and the bottom-right
* date-taken label (one line). */
static esp_err_t render_text_region(const char *line1, const char *line2, overlay_corner_t corner,
manage_overlay_region_t *out)
{
int w1 = (int)strlen(line1) * Font24.Width;
int w2 = line2 != NULL ? (int)strlen(line2) * Font24.Width : 0;
int text_w = w1 > w2 ? w1 : w2;
int text_h = Font24.Height + (line2 != NULL ? LINE_GAP + Font24.Height : 0);
int w = text_w + PADDING * 2;
int h = text_h + PADDING * 2;
w += w % 2;
int stride = w / 2;
uint8_t *buf = malloc((size_t)stride * h);
ESP_RETURN_ON_FALSE(buf != NULL, ESP_ERR_NO_MEM, TAG, "Failed to allocate overlay region");
memset(buf, (EPD_COLOR_WHITE << 4) | EPD_COLOR_WHITE, (size_t)stride * h);
int center_x = w / 2;
int y = PADDING;
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line1, center_x, y);
if (line2 != NULL) {
y += Font24.Height + LINE_GAP;
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line2, center_x, y);
}
out->buf = buf;
out->w = w;
out->h = h;
position_region(out, corner);
return ESP_OK;
}
/* Deliberately tighter than PADDING (used for the fixed QR/text corner
* boxes) -- these labels sit right next to a face rather than needing
* generous QR-scanning margin, and there can be several of them
* simultaneously (see MANAGE_FACE_LABELS_MAX's memory-budget note in
* the header). */
#define FACE_LABEL_PADDING 8
#define FACE_LABEL_GAP 4 /* distance from the face's anchor point to the label box */
/* White-padded single-line name label positioned near an arbitrary
* (anchor_x, anchor_y) face position, rather than a fixed corner --
* unlike the four corner regions (always in-bounds by construction),
* this needs real clamping since a face can be anywhere, including near
* an edge. Centered horizontally on the face, placed just below it by
* default, flipped above if there's no room below. */
static esp_err_t render_face_label_region(const char *name, int anchor_x, int anchor_y, manage_overlay_region_t *out)
{
int text_w = (int)strlen(name) * Font24.Width;
int w = text_w + FACE_LABEL_PADDING * 2;
int h = Font24.Height + FACE_LABEL_PADDING * 2;
w += w % 2;
int stride = w / 2;
uint8_t *buf = malloc((size_t)stride * h);
ESP_RETURN_ON_FALSE(buf != NULL, ESP_ERR_NO_MEM, TAG, "Failed to allocate overlay region");
memset(buf, (EPD_COLOR_WHITE << 4) | EPD_COLOR_WHITE, (size_t)stride * h);
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, name, w / 2, FACE_LABEL_PADDING);
int x0 = anchor_x - w / 2;
int y0 = anchor_y + FACE_LABEL_GAP;
if (y0 + h > EPD_HEIGHT) {
y0 = anchor_y - FACE_LABEL_GAP - h; /* no room below -- place above the face instead */
}
if (x0 < 0) {
x0 = 0;
} else if (x0 + w > EPD_WIDTH) {
x0 = EPD_WIDTH - w;
}
if (y0 < 0) {
y0 = 0;
} else if (y0 + h > EPD_HEIGHT) {
y0 = EPD_HEIGHT - h;
}
x0 -= x0 % 2; /* keep byte-aligned (2px/byte) */
out->buf = buf;
out->w = w;
out->h = h;
out->x0 = x0;
out->y0 = y0;
return ESP_OK;
}
/* Battery glyph dimensions -- a static outline (body rectangle + small
* terminal nub on the right), deliberately NOT a fill-level graphic. */
#define BATTERY_ICON_W 44
#define BATTERY_ICON_H 24
#define BATTERY_ICON_STROKE 2
#define BATTERY_NUB_W 6
#define BATTERY_NUB_H 12
#define BATTERY_ICON_TEXT_GAP 8
#define BATTERY_REGION_GAP 8 /* vertical gap below the manage QR box */
static void draw_battery_icon(uint8_t *buf, int stride, int width, int height, int x0, int y0)
{
for (int y = 0; y < BATTERY_ICON_H; y++) {
for (int x = 0; x < BATTERY_ICON_W; x++) {
bool edge = x < BATTERY_ICON_STROKE || x >= BATTERY_ICON_W - BATTERY_ICON_STROKE ||
y < BATTERY_ICON_STROKE || y >= BATTERY_ICON_H - BATTERY_ICON_STROKE;
if (edge) {
epd_draw_pixel_ex(buf, stride, width, height, x0 + x, y0 + y, EPD_COLOR_BLACK);
}
}
}
int nub_y = y0 + (BATTERY_ICON_H - BATTERY_NUB_H) / 2;
for (int y = 0; y < BATTERY_NUB_H; y++) {
for (int x = 0; x < BATTERY_NUB_W; x++) {
epd_draw_pixel_ex(buf, stride, width, height, x0 + BATTERY_ICON_W + x, nub_y + y, EPD_COLOR_BLACK);
}
}
}
/* White-padded box with the battery glyph and "NN%" beside it, placed
* directly below an already-positioned anchor region (the top-right
* manage QR box), right-aligned to the anchor's right edge. */
static esp_err_t render_battery_region(int percent, const manage_overlay_region_t *anchor,
manage_overlay_region_t *out)
{
char text[8];
snprintf(text, sizeof(text), "%d%%", percent);
int icon_total_w = BATTERY_ICON_W + BATTERY_NUB_W;
int text_w = (int)strlen(text) * Font24.Width;
int content_w = icon_total_w + BATTERY_ICON_TEXT_GAP + text_w;
int content_h = Font24.Height > BATTERY_ICON_H ? Font24.Height : BATTERY_ICON_H;
int w = content_w + PADDING * 2;
int h = content_h + PADDING * 2;
w += w % 2;
int stride = w / 2;
uint8_t *buf = malloc((size_t)stride * h);
ESP_RETURN_ON_FALSE(buf != NULL, ESP_ERR_NO_MEM, TAG, "Failed to allocate overlay region");
memset(buf, (EPD_COLOR_WHITE << 4) | EPD_COLOR_WHITE, (size_t)stride * h);
draw_battery_icon(buf, stride, w, h, PADDING, PADDING + (content_h - BATTERY_ICON_H) / 2);
epd_draw_text_ex(buf, stride, w, h, &Font24, text, PADDING + icon_total_w + BATTERY_ICON_TEXT_GAP,
PADDING + (content_h - Font24.Height) / 2);
out->buf = buf;
out->w = w;
out->h = h;
out->x0 = anchor->x0 + anchor->w - w;
out->x0 -= out->x0 % 2; /* keep byte-aligned (2px/byte) */
out->y0 = anchor->y0 + anchor->h + BATTERY_REGION_GAP;
return ESP_OK;
}
esp_err_t manage_overlay_render(const manage_overlay_content_t *content, manage_overlay_set_t *out)
{
out->count = 0;
esp_err_t err = render_qr_region(content->management_url, "SCAN TO", "MANAGE", CORNER_TOP_RIGHT,
&out->regions[out->count]);
if (err != ESP_OK) {
return err;
}
out->count++;
if (content->battery_percent >= 0 && content->battery_percent <= 100) {
/* Anchored below the manage QR box just rendered (regions[0]). */
if (render_battery_region(content->battery_percent, &out->regions[0], &out->regions[out->count]) ==
ESP_OK) {
out->count++;
}
}
if (content->location_line1 != NULL && content->location_line1[0] != '\0') {
const char *line2 =
(content->location_line2 != NULL && content->location_line2[0] != '\0') ? content->location_line2 : NULL;
if (render_text_region(content->location_line1, line2, CORNER_TOP_LEFT, &out->regions[out->count]) ==
ESP_OK) {
out->count++;
}
}
if (content->taken_at != NULL && content->taken_at[0] != '\0') {
if (render_text_region(content->taken_at, NULL, CORNER_BOTTOM_RIGHT, &out->regions[out->count]) == ESP_OK) {
out->count++;
}
}
if (content->share_url != NULL && content->share_url[0] != '\0') {
if (render_qr_region(content->share_url, "SCAN TO", "DOWNLOAD", CORNER_BOTTOM_LEFT,
&out->regions[out->count]) == ESP_OK) {
out->count++;
}
}
int face_count = content->face_label_count;
if (face_count > MANAGE_FACE_LABELS_MAX) {
face_count = MANAGE_FACE_LABELS_MAX;
}
for (int i = 0; content->face_labels != NULL && i < face_count; i++) {
const manage_face_label_t *label = &content->face_labels[i];
if (label->name[0] == '\0') {
continue;
}
if (render_face_label_region(label->name, label->x, label->y, &out->regions[out->count]) == ESP_OK) {
out->count++;
}
}
return ESP_OK;
}
void manage_overlay_free(manage_overlay_set_t *overlay)
{
for (int i = 0; i < overlay->count; i++) {
free(overlay->regions[i].buf);
overlay->regions[i].buf = NULL;
}
overlay->count = 0;
}
-61
View File
@@ -1,61 +0,0 @@
#pragma once
#include <stdint.h>
#include "esp_err.h"
/* 5 fixed regions (manage QR, battery indicator, location, date, share
* QR) plus up to MANAGE_FACE_LABELS_MAX arbitrary-position named-face
* labels (see manage_face_label_t below). MANAGE_FACE_LABELS_MAX is
* capped small deliberately, not arbitrarily -- each label is its own
* malloc'd buffer, and the fixed regions alone already use a meaningful
* chunk of the ESP32-C6's limited RAM; this keeps worst-case overlay
* memory well clear of what the WiFi/HTTP stack needs alongside it. */
#define MANAGE_FACE_LABELS_MAX 4
#define MANAGE_OVERLAY_MAX_REGIONS (5 + MANAGE_FACE_LABELS_MAX)
typedef struct {
uint8_t *buf; /* malloc'd (w/2)*h bytes, packed 2px/byte; owned by the region */
int x0, y0; /* top-left corner, panel pixel coordinates (x0 is always even) */
int w, h; /* pixel dimensions (w is always even) */
} manage_overlay_region_t;
typedef struct {
manage_overlay_region_t regions[MANAGE_OVERLAY_MAX_REGIONS];
int count;
} manage_overlay_set_t;
typedef struct {
char name[16];
int x, y; /* anchor point (bottom-center of the face), panel pixel coordinates */
} manage_face_label_t;
typedef struct {
const char *management_url; /* top-right QR + "SCAN TO"/"MANAGE" caption -- always shown */
const char *location_line1; /* top-left text, line 1 (city); NULL/empty skips this region */
const char *location_line2; /* top-left text, line 2 (state/country); NULL/empty is fine if line1 is set */
const char *taken_at; /* bottom-right text; NULL/empty skips this region */
const char *share_url; /* bottom-left QR + "SCAN TO"/"DOWNLOAD" caption; NULL/empty skips this region */
const manage_face_label_t *face_labels; /* named-face labels ("level 2" menu); NULL/empty count skips these */
int face_label_count; /* clamped to MANAGE_FACE_LABELS_MAX internally */
int battery_percent; /* 0-100 shows an icon + percent below the manage QR; -1 skips it */
} manage_overlay_content_t;
/**
* Renders the manage-button overlay: always a "scan to manage" QR in the
* top-right corner, plus whichever of location_line1/taken_at/share_url
* are non-NULL/non-empty in their own corners (top-left, bottom-right,
* bottom-left respectively), plus one region per entry in face_labels
* (positioned near that face rather than a fixed corner -- see
* render_face_label_region() in the .c file for the clamping logic).
* Each region is its own separately malloc'd small buffer (not a full
* EPD_FRAME_BYTES frame). A failure rendering the top-right region fails
* the whole call; a failure rendering any other region just skips that
* region and keeps going. Caller must call manage_overlay_free() on out
* regardless of the return value (out->count reflects however many
* regions were actually populated).
*/
esp_err_t manage_overlay_render(const manage_overlay_content_t *content, manage_overlay_set_t *out);
/** Frees every populated region's buffer in overlay. */
void manage_overlay_free(manage_overlay_set_t *overlay);
+87 -24
View File
@@ -1,10 +1,15 @@
#include <stdint.h>
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_sleep.h"
#include "soc/soc_caps.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "wifi_provisioning.h"
#include "next_button.h"
static const char *TAG = "next_button";
@@ -14,6 +19,7 @@ static const char *TAG = "next_button";
#define NEXT_BUTTON_GPIO ((gpio_num_t)CONFIG_FRAME_NEXT_BUTTON_GPIO)
#define NEXT_BUTTON_DEBOUNCE_MS 20
#define NEXT_BUTTON_DEBOUNCE_CHECKS 3
#define NEXT_BUTTON_POLL_MS 100
void next_button_init(void)
{
@@ -31,16 +37,47 @@ void next_button_init(void)
};
gpio_config(&io_conf);
/* Not esp_sleep_enable_ext1_wakeup_io(): its internal pull resistors
* don't hold once the RTC_PERIPH domain powers down for deep sleep, so
* the pin floats and reads spuriously low, waking the device instantly
* on every sleep entry (confirmed on hardware). This GPIO-wakeup
* variant manages the pull resistor itself across the sleep
* transition. */
#if SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP
/* Not esp_sleep_enable_ext1_wakeup_io() on its own: on a target
* without RTC-independent digital pull registers, ext1's internal
* pull resistors don't hold once the RTC_PERIPH domain powers down
* for deep sleep, so the pin floats and reads spuriously low, waking
* the device instantly on every sleep entry (confirmed on hardware,
* ESP32-C6). This GPIO-wakeup variant manages the pull resistor
* itself across the sleep transition, sidestepping the issue
* entirely -- but it only exists on chips with this capability
* (currently just ESP32-C6; see the #else below for other targets,
* e.g. ESP32-S3). */
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << NEXT_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
#else
/* No HP-periph-powerdown wakeup API here (e.g. ESP32-S3) -- fall
* back to ext1, but NOT naively: on every non-original-ESP32 target
* (ESP32-S3 included), gpio_pullup_en() -- which the gpio_config()
* call above invokes via pull_up_en -- delegates to
* rtc_gpio_pullup_en() for RTC-capable pins (confirmed in
* esp_driver_gpio's gpio.c: GPIO_RTCIO_ARE_INDEPENDENT is 1 for
* every target except the original ESP32, meaning digital and RTC
* pull registers are independent hardware and gpio_config() already
* routes the pull-up through the RTC pad's own register for these
* pins, not just the digital one). That's exactly what was missing
* in the ext1 attempt that failed on hardware above -- so on this
* target the pull-up already survives the RTC_PERIPH power-down
* ext1 wakeup requires, without needing a separate rtc_gpio_*_en()
* call here. _io() (not the bare esp_sleep_enable_ext1_wakeup(),
* which resets any previously-configured mask) is additive across
* this file's, back_button.c's, and combo_button.c's independent
* init calls -- confirmed in esp_hw_support's sleep_modes.c -- so no
* shared-mask coordination between the three button files is
* needed. Still unconfirmed on real EE02 hardware: this avoids the
* *documented* failure mode of the earlier ext1 attempt, but that
* attempt was never root-caused beyond "confirmed spurious wakeup on
* hardware" -- treat this as untested until it's actually run on an
* EE02 board. */
ESP_ERROR_CHECK(esp_sleep_enable_ext1_wakeup_io(1ULL << NEXT_BUTTON_GPIO, ESP_EXT1_WAKEUP_ANY_LOW));
#endif
}
bool next_button_check(void)
next_button_result_t next_button_check(void)
{
/* A quick tap can easily release before this runs (~0.4-0.5s into
* boot, confirmed on hardware -- a live gpio_get_level() check here
@@ -48,32 +85,58 @@ bool next_button_check(void)
* status register is latched at the moment of waking and isn't
* cleared until the next sleep entry, so it reliably reflects a tap
* regardless of how quickly it was released. */
if (esp_sleep_get_gpio_wakeup_status() & (1ULL << NEXT_BUTTON_GPIO)) {
ESP_LOGI(TAG, "Next-photo button caused this wake, forcing advance");
return true;
}
#if SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP
bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << NEXT_BUTTON_GPIO);
#else
bool caused_wake = esp_sleep_get_ext1_wakeup_status() & (1ULL << NEXT_BUTTON_GPIO);
#endif
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a fresh
* power-on/reflash) -- fall back to a live, debounced level check so
* holding the button down while powering on also works. */
if (gpio_get_level(NEXT_BUTTON_GPIO) != 0) {
return false;
}
for (int i = 0; i < NEXT_BUTTON_DEBOUNCE_CHECKS; i++) {
vTaskDelay(pdMS_TO_TICKS(NEXT_BUTTON_DEBOUNCE_MS));
if (!caused_wake) {
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a
* fresh power-on/reflash) -- fall back to a live, debounced level
* check so holding the button down while powering on also
* works. */
if (gpio_get_level(NEXT_BUTTON_GPIO) != 0) {
return false; /* noise, not a real press */
return NEXT_BUTTON_NOT_PRESSED;
}
for (int i = 0; i < NEXT_BUTTON_DEBOUNCE_CHECKS; i++) {
vTaskDelay(pdMS_TO_TICKS(NEXT_BUTTON_DEBOUNCE_MS));
if (gpio_get_level(NEXT_BUTTON_GPIO) != 0) {
return NEXT_BUTTON_NOT_PRESSED; /* noise, not a real press */
}
}
}
ESP_LOGI(TAG, "Next-photo button held during power-on, forcing advance");
return true;
/* Confirmed pressed (either the wake cause, or debounced during
* power-on) -- measure how long, same polling pattern as
* combo_button.c's own hold-tier detection. Reads the last hold
* duration the server reported (persisted from a previous cycle,
* see frame_config_get_hold_duration_ms's own doc comment), falling
* back to the Kconfig default before the device has ever fetched
* one. */
uint32_t hold_threshold_ms;
if (frame_config_get_hold_duration_ms(&hold_threshold_ms) != ESP_OK) {
hold_threshold_ms = CONFIG_FRAME_HOLD_ACTION_MS;
}
uint32_t elapsed_ms = 0;
while (gpio_get_level(NEXT_BUTTON_GPIO) == 0) {
if (elapsed_ms >= hold_threshold_ms) {
ESP_LOGI(TAG, "Next button held past %ums, triggering global hold action",
(unsigned)hold_threshold_ms);
return NEXT_BUTTON_HOLD;
}
vTaskDelay(pdMS_TO_TICKS(NEXT_BUTTON_POLL_MS));
elapsed_ms += NEXT_BUTTON_POLL_MS;
}
ESP_LOGI(TAG, "Next-photo button short press (%ums), forcing advance", (unsigned)elapsed_ms);
return NEXT_BUTTON_SHORT_PRESS;
}
#else
void next_button_init(void) {}
bool next_button_check(void) { return false; }
next_button_result_t next_button_check(void) { return NEXT_BUTTON_NOT_PRESSED; }
#endif
+22 -5
View File
@@ -12,10 +12,27 @@
*/
void next_button_init(void);
typedef enum {
NEXT_BUTTON_NOT_PRESSED,
/** A short press -- advancing a photo is low-stakes and should feel
* immediate, so this fires the moment the button releases (or right
* away for a wake-triggered press, once it's confirmed not a hold). */
NEXT_BUTTON_SHORT_PRESS,
/** Held past the configured hold duration (see
* wifi_provisioning.h's frame_config_get_hold_duration_ms) --
* triggers a frame-wide action instead (see
* server/app/global_actions.py, frame_client.h's FETCH_GLOBAL_NEXT).
* Fires immediately at the threshold, without waiting for release --
* same convention as combo_button.c's factory-reset tier. */
NEXT_BUTTON_HOLD,
} next_button_result_t;
/**
* Returns whether the next-photo button is currently held, debounced with
* a couple of short re-checks to reject noise. No long hold-to-confirm
* gate -- advancing a photo is low-stakes and should feel immediate, so
* this returns right away either way.
* Checks the next-photo button and, if it's pressed at all (either what
* caused this wake, per the latched wakeup-status register, or held
* through a debounced power-on check), blocks polling its level until
* either it's released (NEXT_BUTTON_SHORT_PRESS) or the hold duration
* elapses (NEXT_BUTTON_HOLD, returned immediately, not waiting for
* release). Evaluated once per wake.
*/
bool next_button_check(void);
next_button_result_t next_button_check(void);
+10 -3
View File
@@ -17,7 +17,7 @@ static const char *TAG = "ota_update";
#define OTA_HTTP_TIMEOUT_MS 30000
/* Built the same way as every other tools-server URL -- scheme/cert/
* token handling all come from build_url()'s conventions. Duplicated
* id/token handling all come from build_url()'s conventions. Duplicated
* tiny helper rather than exporting frame_client.c's static build_url();
* kept byte-identical in behavior (see frame_client.c). */
static void build_ota_url(char *out, size_t out_size, const frame_config_t *cfg)
@@ -29,8 +29,15 @@ static void build_ota_url(char *out, size_t out_size, const frame_config_t *cfg)
} else {
len = (size_t)snprintf(out, out_size, "http://%s/frame/firmware", toolsserver);
}
if (cfg->access_token[0] != '\0' && len < out_size) {
snprintf(out + len, out_size - len, "?token=%s", cfg->access_token);
char device_id[FRAME_DEVICE_ID_LEN + 1];
frame_device_id_get(device_id, sizeof(device_id));
if (len < out_size) {
len += (size_t)snprintf(out + len, out_size - len, "?id=%s", device_id);
}
if (cfg->device_token[0] != '\0' && len < out_size) {
snprintf(out + len, out_size - len, "&token=%s", cfg->device_token);
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
#include "esp_check.h"
#include "esp_log.h"
#include "epd7in3e.h"
#include "epd_board.h"
#include "epd_draw.h"
#include "fonts.h"
#include "qrcodegen.h"
+3 -4
View File
@@ -97,10 +97,9 @@
<input type="text" id="toolsserver" name="toolsserver" placeholder="e.g. 192.168.1.50:8080 or https://frame.example.com" maxlength="128" required>
</div>
<div class="input-group">
<label for="access_token">Access Token (optional)</label>
<input type="text" id="access_token" name="access_token" placeholder="only if the server's MANAGEMENT_TOKEN is set" maxlength="64">
</div>
<p style="font-size: 13px; color: #555;">After saving, this page will
take you to the server to claim your frame &mdash; reconnect to
your normal WiFi if it doesn't happen automatically.</p>
<button type="submit">Submit</button>
+1 -1
View File
@@ -3,7 +3,7 @@
#include "esp_check.h"
#include "epd7in3e.h"
#include "epd_board.h"
#include "epd_draw.h"
#include "fonts.h"
#include "wifi_provisioning.h"
+106 -17
View File
@@ -19,7 +19,7 @@
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "epd7in3e.h"
#include "epd_board.h"
#include "qr_onboarding.h"
#include "wifi_provisioning.h"
#include "board_antenna.h"
@@ -73,11 +73,10 @@ esp_err_t frame_config_load(frame_config_t *out)
return pass_err;
}
/* Also optional -- most deployments won't set a server-side
* MANAGEMENT_TOKEN at all, in which case this stays empty and the
* manage-menu QR just links to the page with no ?token=. */
len = sizeof(out->access_token);
esp_err_t token_err = nvs_get_str(handle, "access_token", out->access_token, &len);
/* Optional: absent until the server has pushed a per-frame token
* (see frame_config_set_device_token). */
len = sizeof(out->device_token);
esp_err_t token_err = nvs_get_str(handle, "device_token", out->device_token, &len);
if (token_err != ESP_OK && token_err != ESP_ERR_NVS_NOT_FOUND) {
nvs_close(handle);
return token_err;
@@ -87,6 +86,26 @@ esp_err_t frame_config_load(frame_config_t *out)
return ESP_OK;
}
void frame_device_id_get(char *out, size_t out_size)
{
uint8_t mac[6] = {0};
ESP_ERROR_CHECK(esp_read_mac(mac, ESP_MAC_WIFI_STA));
snprintf(out, out_size, "%02x%02x%02x%02x%02x%02x",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
}
void frame_config_set_device_token(const char *token)
{
nvs_handle_t handle;
if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &handle) != ESP_OK) {
return;
}
nvs_set_str(handle, "device_token", token);
nvs_commit(handle);
nvs_close(handle);
ESP_LOGI(TAG, "Stored server-issued device token");
}
esp_err_t frame_config_save(const frame_config_t *cfg)
{
nvs_handle_t handle;
@@ -103,9 +122,10 @@ esp_err_t frame_config_save(const frame_config_t *cfg)
err = nvs_set_str(handle, "toolsserver", cfg->toolsserver);
}
if (err == ESP_OK) {
err = nvs_set_str(handle, "access_token", cfg->access_token);
}
if (err == ESP_OK) {
/* Re-provisioning restarts the identity handshake: the server
* (possibly a different one now) re-issues a device token when
* the frame next introduces itself. */
nvs_erase_key(handle, "device_token");
/* Fresh (re)provisioning -- the next successful connection should
* show the status screen again. */
err = nvs_set_u8(handle, "connected_once", 0);
@@ -158,7 +178,7 @@ void frame_config_clear(void)
nvs_erase_key(handle, "sta_ssid");
nvs_erase_key(handle, "sta_pass");
nvs_erase_key(handle, "toolsserver");
nvs_erase_key(handle, "access_token");
nvs_erase_key(handle, "device_token");
nvs_erase_key(handle, "connected_once");
nvs_commit(handle);
nvs_close(handle);
@@ -200,6 +220,29 @@ void frame_config_invalidate_last_display_crc32(void)
nvs_close(handle);
}
esp_err_t frame_config_get_hold_duration_ms(uint32_t *out)
{
nvs_handle_t handle;
esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READONLY, &handle);
if (err != ESP_OK) {
return err;
}
err = nvs_get_u32(handle, "hold_ms", out);
nvs_close(handle);
return err;
}
void frame_config_set_hold_duration_ms(uint32_t ms)
{
nvs_handle_t handle;
if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &handle) != ESP_OK) {
return;
}
nvs_set_u32(handle, "hold_ms", ms);
nvs_commit(handle);
nvs_close(handle);
}
/* ------------------------------------------------------------------------
* WiFi fast-connect cache
* ---------------------------------------------------------------------- */
@@ -395,7 +438,6 @@ static esp_err_t save_config_post_handler(httpd_req_t *req)
extract_form_value(body, "ssid", cfg.sta_ssid, sizeof(cfg.sta_ssid));
extract_form_value(body, "password", cfg.sta_password, sizeof(cfg.sta_password));
extract_form_value(body, "toolsserver", cfg.toolsserver, sizeof(cfg.toolsserver));
extract_form_value(body, "access_token", cfg.access_token, sizeof(cfg.access_token));
if (strlen(cfg.sta_ssid) == 0 || strlen(cfg.toolsserver) == 0) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "SSID and Tools Server are required");
@@ -409,18 +451,65 @@ static esp_err_t save_config_post_handler(httpd_req_t *req)
return ESP_FAIL;
}
ESP_LOGI(TAG, "Saved config: ssid='%s' toolsserver='%s' access_token=%s", cfg.sta_ssid, cfg.toolsserver,
strlen(cfg.access_token) ? "set" : "none");
ESP_LOGI(TAG, "Saved config: ssid='%s' toolsserver='%s'", cfg.sta_ssid, cfg.toolsserver);
static const char resp[] =
"<html><body><h3>Saved. Restarting and connecting to your WiFi...</h3></body></html>";
/* The success page hands the browser off to the server's claim page,
* carrying this device's id -- how a frame gets linked to a user
* account. This page is entirely self-contained (no external
* resources) so it renders fully from what we send now, before the
* softAP goes away -- a phone mid-load of a remote asset would just
* time out once the AP drops. The visible countdown ticks down for
* PROVISIONING_COUNTDOWN_S seconds and then redirects; the AP is kept
* alive for one second longer than that (see the vTaskDelay below) so
* the countdown always finishes, and the phone has that whole window
* to rejoin its normal WiFi and let the redirect land on the real
* server. "Redirect now" covers a phone that's already reconnected.
* Scheme handling matches frame_client.c's build_url(): a bare host
* gets http://. */
char device_id[FRAME_DEVICE_ID_LEN + 1];
frame_device_id_get(device_id, sizeof(device_id));
char claim_url[FRAME_CFG_SERVER_MAX_LEN + 64];
const char *scheme = "";
if (strncmp(cfg.toolsserver, "http://", 7) != 0 && strncmp(cfg.toolsserver, "https://", 8) != 0) {
scheme = "http://";
}
snprintf(claim_url, sizeof(claim_url), "%s%s/claim?device_id=%s", scheme, cfg.toolsserver, device_id);
#define PROVISIONING_COUNTDOWN_S 10
char resp[1536];
snprintf(resp, sizeof(resp),
"<!doctype html><html><head>"
"<meta http-equiv=\"refresh\" content=\"%d;url=%s\">"
"<style>body{font-family:sans-serif;text-align:center;padding:2em}"
"#now{display:inline-block;margin-top:1em;padding:.6em 1.2em;"
"background:#2563eb;color:#fff;text-decoration:none;border-radius:8px}</style></head>"
"<body><h3>Saved &mdash; the frame is restarting</h3>"
"<p>Reconnect to your normal WiFi if it doesn't happen automatically.</p>"
"<p>Redirecting you in <span id=\"n\">%d</span> seconds&hellip;</p>"
"<p><a id=\"now\" href=\"%s\">Redirect now</a></p>"
"<script>"
"var n=%d,e=document.getElementById('n');"
"var t=setInterval(function(){"
"n--;if(e)e.textContent=n;"
"if(n<=0){clearInterval(t);location.href='%s';}"
"},1000);"
"</script>"
"</body></html>",
PROVISIONING_COUNTDOWN_S, claim_url, PROVISIONING_COUNTDOWN_S, claim_url,
PROVISIONING_COUNTDOWN_S, claim_url);
httpd_resp_set_type(req, "text/html");
httpd_resp_send(req, resp, HTTPD_RESP_USE_STRLEN);
/* Let the response flush to the client before rebooting into STA mode. */
vTaskDelay(pdMS_TO_TICKS(1000));
/* Keep the softAP up for the full visible countdown (plus a 1s margin
* for the response to flush and the JS timer to fire) before tearing
* it down -- see the comment above for why. */
vTaskDelay(pdMS_TO_TICKS((PROVISIONING_COUNTDOWN_S + 1) * 1000));
esp_restart();
#undef PROVISIONING_COUNTDOWN_S
return ESP_OK;
}
+45 -1
View File
@@ -11,13 +11,36 @@
#define FRAME_CFG_TOKEN_MAX_LEN 64
#define FRAME_AP_PASSWORD_LEN 10
#define FRAME_DEVICE_ID_LEN 12 /* 6-byte STA MAC as lowercase hex */
typedef struct {
char sta_ssid[FRAME_CFG_SSID_MAX_LEN + 1];
char sta_password[FRAME_CFG_PASSWORD_MAX_LEN + 1];
char toolsserver[FRAME_CFG_SERVER_MAX_LEN + 1];
char access_token[FRAME_CFG_TOKEN_MAX_LEN + 1]; /* optional; matches the server's MANAGEMENT_TOKEN */
/* Per-frame token issued by the server via GET /frame/config after
* this device first introduces itself by id (see frame_client.c's
* build_url). Not set at the captive portal; empty until the server
* pushes one. */
char device_token[FRAME_CFG_TOKEN_MAX_LEN + 1];
} frame_config_t;
/**
* This device's stable identity as reported to the server (?id= on every
* request): the full 6-byte STA MAC as 12 lowercase hex chars. Derived
* from the same MAC the provisioning AP SSID suffix comes from; never
* stored. out must hold at least FRAME_DEVICE_ID_LEN + 1 bytes.
*/
void frame_device_id_get(char *out, size_t out_size);
/**
* Persists (only) the server-issued per-frame device token -- called
* from the wake cycle when GET /frame/config delivers one. Deliberately
* touches nothing else: unlike frame_config_save() it must not reset
* the connected-once flag or invalidate the WiFi fast-connect cache,
* since nothing about the network changed.
*/
void frame_config_set_device_token(const char *token);
/**
* Loads the saved home-network config from NVS.
* Returns ESP_ERR_NVS_NOT_FOUND if the device has never been provisioned.
@@ -70,6 +93,27 @@ void frame_config_set_last_display_crc32(uint32_t crc32);
*/
void frame_config_invalidate_last_display_crc32(void);
/**
* Returns the hold_duration_ms the server most recently reported via GET
* /frame/config (see frame_client.c's fetch_frame_config/frame_client_run)
* -- how long NEXT/BACK must be held before next_button_check()/
* back_button_check() treat it as a hold-for-global-action instead of a
* short press. Returns ESP_ERR_NVS_NOT_FOUND if the device has never
* fetched one yet (fresh install/factory reset); caller should fall back
* to CONFIG_FRAME_HOLD_ACTION_MS in that case.
*
* Deliberately a *previous* cycle's value: this cycle's own button
* decision happens in main.c before WiFi even connects, but
* /frame/config isn't fetched until near the end of frame_client_run
* (after the image fetch, for connection-warmth/timeout reasons -- see
* its own comment) -- so there's no same-cycle fresh value to use yet.
*/
esp_err_t frame_config_get_hold_duration_ms(uint32_t *out);
/** Persists the hold duration reported by the server, for the *next*
* boot's button-hold decision to use. */
void frame_config_set_hold_duration_ms(uint32_t ms);
/**
* Returns this device's provisioning AP identity: a fixed SSID (from
* Kconfig) and a password that's generated once on first use and persisted
+13
View File
@@ -0,0 +1,13 @@
# Name, Type, SubType, Offset, Size, Flags
# Same OTA layout/offsets as partitions.csv (the 8MB dev-board table) --
# the XIAO ESP32-S3 Plus's 16MB flash has plenty of room for the same
# 2MB app slots (current firmware runs ~1.2MB, per partitions_xiao.csv's
# own sizing note) without needing to trim anything the way the 4MB xiao
# table did. Leaves ~12MB of the 16MB unused/unpartitioned for now --
# revisit sizing once a real build's actual footprint and any EE02-
# specific storage needs (if ever) are known.
nvs, data, nvs, 0x9000, 0x6000,
phy_init, data, phy, 0xf000, 0x1000,
ota_0, app, ota_0, 0x10000, 0x200000,
otadata, data, ota, 0x210000, 0x2000,
ota_1, app, ota_1, 0x220000, 0x200000,
1 # Name, Type, SubType, Offset, Size, Flags
2 # Same OTA layout/offsets as partitions.csv (the 8MB dev-board table) --
3 # the XIAO ESP32-S3 Plus's 16MB flash has plenty of room for the same
4 # 2MB app slots (current firmware runs ~1.2MB, per partitions_xiao.csv's
5 # own sizing note) without needing to trim anything the way the 4MB xiao
6 # table did. Leaves ~12MB of the 16MB unused/unpartitioned for now --
7 # revisit sizing once a real build's actual footprint and any EE02-
8 # specific storage needs (if ever) are known.
9 nvs, data, nvs, 0x9000, 0x6000,
10 phy_init, data, phy, 0xf000, 0x1000,
11 ota_0, app, ota_0, 0x10000, 0x200000,
12 otadata, data, ota, 0x210000, 0x2000,
13 ota_1, app, ota_1, 0x220000, 0x200000,
+50
View File
@@ -0,0 +1,50 @@
# Board-specific overrides for Seeed's EE02 (XIAO ESP32-S3 Plus + 13.3"
# Spectra 6 panel), layered on top of sdkconfig.defaults via
# SDKCONFIG_DEFAULTS -- see build_for_board.sh, which is the supported
# way to build with this file. Don't set this via a plain `idf.py
# menuconfig` on the default build; that writes straight into the shared
# sdkconfig, not this file.
#
# Unlike xiao (a same-chip Kconfig-only variant of the ESP32-C6 dev
# board), EE02 is a genuinely different chip target (ESP32-S3) --
# build_for_board.sh runs `set-target esp32s3` for this board before
# building, same as it runs `set-target esp32c6` for devkit/xiao.
CONFIG_FRAME_BOARD_NAME="ee02"
# Selects the epd13in3e driver component (13.3", 1600x1200) instead of
# epd7in3e -- see main/Kconfig.projbuild and main/CMakeLists.txt.
CONFIG_FRAME_PANEL_EE02_13IN3=y
# XIAO ESP32-S3 Plus: 16MB flash, 8MB PSRAM (vs. the plain XIAO ESP32-S3's
# 8MB/8MB) -- see partitions_ee02.csv, sized generously against this,
# not yet trimmed/tuned against a real build's actual footprint.
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_ee02.csv"
CONFIG_PARTITION_TABLE_FILENAME="partitions_ee02.csv"
# EE02's e-paper interface pin defaults live in
# firmware/components/epd13in3e/Kconfig instead of being overridden here
# (mirrors how epd7in3e's Kconfig defaults are devkit-shaped and
# sdkconfig.xiao only overrides the ones that actually differ) -- EE02's
# pins are a different Kconfig menu entirely (EPD_PIN_CS_MASTER/CS_SLAVE/
# POWER_EN don't exist on epd7in3e's board at all), not a same-menu
# override, so there's nothing to set here beyond selecting the component
# above.
#
# Deliberately NOT overriding FRAME_NEXT_BUTTON_GPIO/FRAME_BACK_BUTTON_
# GPIO/FRAME_COMBO_BUTTON_GPIO/FRAME_BATTERY_ADC_GPIO here, even though
# the same community source that gave the epd13in3e pinout also reports
# EE02 has 3 user buttons at GPIO2/3/5: which physical button maps to
# which logical role (next/back/combo) still isn't confirmed. The
# button GPIOs' `range -1 7` constraint (main/Kconfig.projbuild) -- which
# used to be hardcoded to the ESP32-C6's deep-sleep-wakeup-capable GPIO
# set -- now widens to `range -1 21` under IDF_TARGET_ESP32S3 (the
# ESP32-S3's own ext1-wakeup-capable RTC-IO range), so GPIO2/3/5 fit
# either way and nothing here needs adjusting on that front. What's
# still unconfirmed: (1) the button-to-role mapping above, and (2)
# whether the S3 button-wakeup path itself (ext1 + RTC pull-up, see
# main/next_button.c) actually avoids the spurious-instant-wakeup bug
# that ruled out ext1 on the ESP32-C6 -- that needs real EE02 hardware,
# not just a clean compile. FRAME_BATTERY_ADC_GPIO's `range -1 6` is a
# separate, still-unwidened concern -- it's the ESP32-C6's ADC-capable
# pin set, not a deep-sleep-wakeup range, and out of scope here.
+1 -1
View File
@@ -8,7 +8,7 @@ CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_xiao.csv"
CONFIG_PARTITION_TABLE_FILENAME="partitions_xiao.csv"
CONFIG_FRAME_BOARD_NAME="xiao"
CONFIG_FRAME_BOARD_NAME="xiao_esp32c6"
# Powers the XIAO's RF switch and selects its onboard antenna -- without
# this the softAP/STA radio doesn't reliably reach the antenna at all.
+1 -1
View File
@@ -1 +1 @@
1.1.2
1.4.2
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
**/__pycache__/
.venv/
*.egg-info/
data/
render-service/node_modules/
.git/
+106 -6
View File
@@ -2,18 +2,118 @@ FROM python:3.12-slim
WORKDIR /app
# tzdata: python:3.12-slim doesn't include it by default, so the zoneinfo
# database backing the web UI's "Timezone" setting (used by "Quiet hours")
# would have no named zones to resolve without this -- ZoneInfo() would
# raise for anything other than "UTC".
RUN apt-get update && apt-get install -y --no-install-recommends tzdata \
# tzdata/fonts/Node.js all from Debian's own repo in one layer -- no
# external curl/gnupg dance needed (see below for why that changed).
# tzdata: python:3.12-slim doesn't include it by default, so the
# zoneinfo database backing the web UI's "Timezone" setting (used by
# "Quiet hours") would have no named zones to resolve without this --
# ZoneInfo() would raise for anything other than "UTC".
# fontconfig/fonts-dejavu-core: whiteboard mode's render-service/ (own
# README there) needs something to render whiteboard text with.
#
# Node.js: whiteboard frame mode's render-service/ runs as a second
# process in this same container rather than a separate compose service
# -- it's a lightweight, stateless, localhost-only sidecar with nothing
# worth independently scaling or restarting. Used to be installed via
# NodeSource's setup script (Debian's own nodejs package was too old for
# jsdom's minimum back when this base image tracked Debian bookworm) --
# switched to Debian's own `nodejs`/`npm` packages after NodeSource's
# deb.nodesource.com started intermittently 403ing on both its setup_*.x
# scripts *and* its GPG key (a live NodeSource-side S3/CDN issue,
# confirmed 2026-07-27 by hitting deb.nodesource.com directly -- some
# setup_NN.x paths 403, others 200, no consistent pattern, so no
# NodeSource-hosted install path could be trusted not to silently break
# again). This base image now tracks Debian trixie, whose own `nodejs`
# package is 20.19.2 -- inside jsdom 29's stated engines range
# (`^20.19.0 || ^22.13.0 || >=24.0.0`) and well above express/resvg-js's
# much lower floors -- so there's no longer a version gap to route
# around NodeSource for. One less external dependency, and no more
# curl-piped-into-bash (that pattern is also what let the NodeSource
# failure go undetected here in the first place: `curl -f ... | bash -`
# on a 403 hands bash an empty, "successful" script instead of failing
# the RUN outright).
RUN apt-get update && apt-get install -y --no-install-recommends \
tzdata fontconfig fonts-dejavu-core nodejs npm \
&& rm -rf /var/lib/apt/lists/*
# EXPERIMENTAL. System libs a headless Chromium needs (app/html_render.py, the weather widget's
# opt-in "modern" render style), trimmed from Playwright's own full
# `install-deps chromium` list to just what a headless (no Xvfb),
# Latin-text-plus-emoji use case needs: dropped xvfb (only needed for a
# *headed* browser) and the CJK/Cyrillic/Thai locale font packages
# (fonts-ipafont-gothic, fonts-wqy-zenhei, fonts-tlwg-loma-otf,
# xfonts-cyrillic, xfonts-scalable, fonts-freefont-ttf, fonts-unifont) --
# fonts-noto-color-emoji is the one that actually matters here (real
# color emoji in the weather icons, vs. WeasyPrint/Pango's monochrome
# fallback glyphs in this feature's original spike).
RUN apt-get update && apt-get install -y --no-install-recommends \
libasound2t64 libatk-bridge2.0-0t64 libatk1.0-0t64 libatspi2.0-0t64 \
libcairo2 libcups2t64 libdbus-1-3 libdrm2 libgbm1 libglib2.0-0t64 \
libnspr4 libnss3 libpango-1.0-0 libx11-6 libxcb1 libxcomposite1 \
libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 \
fonts-noto-color-emoji libfontconfig1 libfreetype6 fonts-liberation \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
# Split across several layers rather than one `pip install -r
# requirements.txt` -- same Cloudflare single-blob/layer payload-size
# limit as render-service's npm installs below. The single combined
# layer was measured at ~113MB unpacked, over the limit on its own.
# Isolating the largest packages gets every layer's unpacked size well
# clear of 100MB (sqlalchemy ~15MB, pillow ~19MB, pypdfium2 ~8MB, the
# remaining `-r requirements.txt` layer ~71MB). Each package version here
# still comes from requirements.txt (`pip install -r` for everything that
# doesn't need its own layer skips these, since pip sees them already
# satisfied); the explicit versions below just control *when* each
# installs -- same "single source of truth, just splitting *when* it
# installs" tradeoff as the npm section's --no-save comment below.
RUN pip install --no-cache-dir sqlalchemy==2.0.51
RUN pip install --no-cache-dir pillow==12.3.0
RUN pip install --no-cache-dir pypdfium2==5.12.1
RUN pip install --no-cache-dir playwright==1.61.0
RUN pip install --no-cache-dir -r requirements.txt
# The headless Chromium binary itself is deliberately NOT installed
# here at build time. `playwright install chromium-headless-shell`
# unpacks to ~262MB, and its single `chrome-headless-shell` binary alone
# (measured: 181MB) is one file -- unlike the pip/npm splits above
# (independently-installable smaller packages moved into their own
# layers), a single 181MB file can't be divided across multiple <100MB
# Docker layers by any ordinary COPY/RUN restructuring; the whole file
# lands in whichever layer's diff contains it, which confirmed-failed
# to push to this project's registry (the same Cloudflare single-blob/
# layer limit that forced the pip/npm splits elsewhere in this file --
# see their comments). Fix: start.sh downloads it at container startup
# instead, cached on the /data volume (PLAYWRIGHT_BROWSERS_PATH below)
# so it survives restarts/redeploys and only ever downloads once per
# volume, not once per image layer. Trade-off: first boot on a fresh
# volume needs network access to Playwright's CDN -- true of Immich/
# weather API access too, so not a new requirement for this server.
ENV PLAYWRIGHT_BROWSERS_PATH=/data/.playwright-browsers
# render-service/'s dependencies installed as several separate layers
# rather than one `npm install` covering all of them -- a from-scratch
# push of this image once hit Cloudflare's payload-size limit on a
# single blob/layer upload (the registry sits behind it), and splitting
# a big layer into several smaller ones is the direct fix for exactly
# that failure mode, independent of anything about the registry itself.
# --no-save: package.json already fully declares these (with the exact
# same version pins used here) as the single source of truth for what
# this service depends on -- these calls are just about *when* each one
# gets installed for layer-size reasons, not re-deciding what's needed.
COPY render-service/package.json ./render-service/package.json
WORKDIR /app/render-service
RUN npm install --omit=dev --no-save express@^5.2.1 && npm cache clean --force
RUN npm install --omit=dev --no-save jsdom@^29.1.1 && npm cache clean --force
RUN npm install --omit=dev --no-save @excalidraw/[email protected] && npm cache clean --force
RUN npm install --omit=dev --no-save @resvg/[email protected] && npm cache clean --force
WORKDIR /app
COPY render-service/server.js ./render-service/server.js
COPY app ./app
COPY start.sh .
RUN chmod +x start.sh
EXPOSE 8420
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8420"]
CMD ["./start.sh"]
+276 -221
View File
@@ -8,225 +8,204 @@ algorithm itself -- it just streams the response straight to the panel.
## Setup
1. **Get an Immich API key**: in Immich, go to Account Settings -> API Keys
-> New API Key. Needs read access to albums/assets/faces, plus
`sharedLink.create` (for the manage overlay's "scan to download" QR,
which creates a temporary public share link) -- a plain read-only key
will 403 on that one specific feature while everything else works.
2. **Copy the compose file and fill in your Immich details**:
1. **Copy the compose file and run the server**:
```
cp docker-compose.yml.example docker-compose.yml
```
Edit `docker-compose.yml` and set `IMMICH_URL`/`IMMICH_API_KEY` under
`environment:`. `docker-compose.yml` is gitignored (it'll hold your real
API key) -- `docker-compose.yml.example` is the one that's committed.
3. **Run the server**:
```
docker compose up -d
```
4. Open `http://<this-machine>:8420/` in a browser, click **Load Albums**,
pick one, and **Save**. (The Immich URL/API key fields will already be
populated from the environment; changing them in the UI has no effect
as long as the env vars are set -- they win on every load.)
5. On the ESP32's captive portal setup form, set the **Tools Server** field
to `<this-machine>:8420`. This server always speaks plain HTTP itself --
for HTTPS, put a TLS-terminating reverse proxy (e.g. nginx) in front of
it and enter the proxy's `https://` address instead (see
`firmware/README.md`'s HTTPS section for what the ESP32 side needs).
6. **Optional: set `MANAGEMENT_TOKEN`** in `docker-compose.yml` to gate
the *entire server* -- the web UI (`/`, `/api/*`) and every
device-facing `/frame/*` endpoint -- behind a shared secret (leave
unset to keep it all open, the previous default -- fine on a trusted
LAN). If set, paste the same value into the ESP32's captive portal
setup form's **Access Token** field: the device then sends it on
every request it makes, and the manage-menu/share QR codes embed it
automatically (`?token=...`) so scanning them just works. Visiting
the web UI without a valid token in the URL shows a plain token-entry
prompt instead of the config UI; `/health` stays open regardless
(pure liveness, nothing sensitive in it).
7. **Optional: auto-update firmware from Gitea releases.** If you're
2. **First-run setup**: open `http://<this-machine>:8420/` -- you'll be
walked through creating the admin account. Every user has their own
login; the admin can enroll more from the Admin page (family members
can also self-enroll through the frame-claim flow, below).
3. **Connect your Immich library** (per user, in Settings): your Immich
URL and an API key. The key needs read access to
albums/assets/faces, plus `sharedLink.create` (for the on-frame
"scan to download" QR, which creates a temporary public share link)
-- a plain read-only key will 403 on that one feature while
everything else works. Frames you own pull from *your* library.
(`IMMICH_URL`/`IMMICH_API_KEY` env vars in `docker-compose.yml` still
work as an operator-level fallback and seed the first admin's
settings when migrating an older deployment.)
4. **Provision a frame**: power it on, join its `ESPRESSO_XXXXXX` WiFi
(instructions show on the panel), fill in your WiFi details and this
server's address (**Tools Server**, e.g. `<this-machine>:8420`).
After saving, your browser is redirected to this server's claim page
and the frame links to your account -- creating an account on the
spot if you don't have one (a valid frame is the invitation). The
server speaks plain HTTP itself -- for HTTPS, put a TLS-terminating
reverse proxy in front and enter the proxy's `https://` address
instead (see `firmware/README.md`'s HTTPS section).
5. **Each frame gets its own device token automatically** -- the server
issues it on the frame's first check-in, so there's nothing to
configure. `MANAGEMENT_TOKEN` in `docker-compose.yml` is optional and
only matters pre-setup: if set, it's the credential that gates who
gets to be the one to run first-run setup on a freshly deployed
server, before any admin account exists.
6. **Optional: auto-update firmware from Gitea releases.** If you're
pushing this repo to a Gitea instance, `.gitea/workflows/firmware-release-build.yml`
builds both supported boards and publishes them as release assets
(`firmware-xiao.bin`/`firmware-devkit.bin`) whenever `firmware/version.txt`
changes on `main`. In the web UI's "Firmware update" card, set the
**Gitea repo URL** to that repo (e.g. `https://git.example.com/owner/repo`);
if the repo is private, also set `GITEA_FIRMWARE_TOKEN` (a read-only
PAT) in `docker-compose.yml`. Which board's build to fetch is learned
from the frame itself (its `X-Frame-Board` header, `CONFIG_FRAME_BOARD_NAME`
on the firmware side) -- nothing to pick by hand, though the frame
does need to have checked in at least once first. The server then
periodically checks for a newer release and either shows an "Update
frame" button or, with **Automatically apply updates** checked,
stages it itself -- either way the frame only actually updates on its
own next wake (see `POST /api/firmware` above).
builds every supported board and publishes them as release assets
(`firmware-devkit_esp32c6.bin`/`firmware-xiao_esp32c6.bin`/
`firmware-ee02.bin`, plus `firmware-devkit.bin`/`firmware-xiao.bin`
duplicates for devices still on pre-rename firmware) whenever
`firmware/version.txt` changes on `main`. In a frame's
**Configuration** tab, set the **Gitea repo URL**; if the repo is
private, also set `GITEA_FIRMWARE_TOKEN` (a read-only PAT) in
`docker-compose.yml`. Which board's build to fetch is learned from the
frame itself (its `X-Frame-Board` header) -- nothing to pick by hand.
The same header also determines which EPD panel the frame renders for
(`Frame.panel_type`, see `docs/hardware.md`'s board identifiers
section) -- also never a manual setting. The server then periodically
checks for a newer release and either shows an "Update frame" button
or, with **Automatically apply updates** checked, stages it itself --
either way the frame only actually updates on its own next wake.
## Users, frames, and control
- **Users** log in with a session cookie; passwords are scrypt-hashed;
mutating requests are CSRF-protected. Sign-up paths: first-run setup
(admin #1), admin enrollment (Admin page), or the claim flow (a valid
unclaimed frame's `device_id` gates self-service signup).
- **Frames** identify themselves by `?id=` (MAC-derived) on every
request and authenticate with a per-frame device token the server
issues at first check-in. Unknown frames self-register as unclaimed;
claiming (via `/claim?device_id=...`) sets the owner -- whose Immich
library the frame renders from -- and links the account. Admins can
link additional users to any frame; every linked user sees it in
their sidebar.
- **Control** is a soft lock per frame: everyone linked can *view*;
changing settings/queue requires holding control, and "Take control"
always succeeds (the 409 error names the current holder). The
physical buttons on the frame ignore all of this.
- **The on-frame manage QR** opens a limited no-login page (`/m/<token>`):
view current + upcoming, "show next", advance, back -- nothing else.
The share QR stays public (it creates a 30-minute Immich share link
for exactly the photo shown).
- **Email (optional).** An admin sets an SMTP server once (`/admin` --
server, port, username/password, from address, STARTTLS on/off; a
"send test email to myself" button, delivered to the admin's own
email); each user sets their own email in Settings. Once both are in
place: **"Forgot password?"** on the login page emails a one-hour
reset link (a generic "check your email" response either way, so the
endpoint can't be used to enumerate accounts), and a frame's
Configuration tab can set a **battery-alert threshold** -- an email to
the frame's owner the first time a report drops to or below it, not
again until a recharge is detected and it crosses again. No SMTP
configured, or no email on the relevant account, and both features
silently no-op rather than erroring.
- **Server logs.** `/admin/logs` shows the tail of the process's own
log file (`LOG_PATH` env var, default `/data/server.log` -- the same
`/data` volume as the database and legacy config, so it survives
container restarts/redeploys; `LOG_LEVEL` env var, default `INFO`).
Rotates at ~2MB x 3 backups; the page only reads the current file,
"Download full log" streams it raw. There's no log shipping/
aggregation beyond this -- it's a single-container deployment, so
the file *is* the log.
## Endpoints
- `GET /` -- config UI (album, order, refresh interval, face-aware crop
toggle, upcoming-photos count, now-displaying + drag-to-reorder
upcoming grid -- not Immich URL/API key, see Setup above)
- `GET /api/albums` -- lists Immich albums (used by the config UI)
- `POST /api/config` -- saves
album/order/orientation/refresh_interval_s/smart_crop_faces/queue_target_len/quiet_hours_*/timezone.
`orientation` (`landscape`, `portrait`, `landscape_flipped`,
`portrait_flipped`) matches how the frame is physically hung: photos
are composed/cropped for that shape (portrait crops at 480x800), then
rotated into the panel's native 800x480 byte layout server-side --
the device never knows. Note the device-side manage-menu overlay
(QRs, text, battery indicator, face labels) still renders in native
panel orientation, so on a portrait-hung frame it appears rotated
90° to the viewer -- QR codes scan fine at any rotation, but the text
reads sideways. A known limitation, not planned to change soon.
`quiet_hours_enabled`/`quiet_hours_start`/`quiet_hours_end`
(`"HH:MM"`, may wrap past midnight, e.g. `22:00`-`07:00`) don't touch
the device at all -- purely a server decision about what
`refresh_interval_s` to hand back from `GET /frame/config` below,
computed in `_effective_refresh_interval_s`. Interpreted in the
`timezone` set from the web UI's "Timezone" dropdown (an IANA zone
name, e.g. `America/New_York`; defaults to `UTC`) -- no
docker-compose.yml edit or container restart needed to change it. The
device can still land one wake right
at the start of the window (nothing server-side can prevent that
without touching the firmware, since the device doesn't know wall-clock
time), but from that wake on it's told to sleep exactly until the
window ends. The "overdue" indicator in `/api/queue`'s `device` object
also accounts for this -- it won't falsely flag a device that's
legitimately sleeping through a long quiet-hours window
- `GET /frame/image` -- returns the current photo pre-processed into the
panel's raw 800x480, 4-bit-per-pixel, 2-pixels-per-byte format
(`application/octet-stream`, exactly 192,000 bytes). **Side-effect-free**
by default: it only actually advances to the next photo once
`refresh_interval_s` has elapsed since the current one was set, so
calling it repeatedly (e.g. the device rebooting unexpectedly) just
redisplays the same photo instead of skipping ahead.
- `POST /frame/advance` -- forces an immediate advance to the next photo,
ignoring `refresh_interval_s`, and resets the interval clock from now.
Same response shape as `/frame/image`. Used by the device's next-photo
button (see `firmware/README.md`). Every photo actually displayed this
way (or via the normal timer-based advance) is pushed onto a bounded
history (`app/photo_queue.py`, last 20) that `/frame/back` below can
return to.
- `POST /frame/back` -- returns to the previously-current photo (the
exact mirror of `/frame/advance`), and resets the interval clock from
now. A no-op (still 200, same photo) if there's no history yet.
Pressing advance afterwards returns to where you were before going
back -- it displaces the current photo onto the front of the upcoming
queue rather than discarding it. Same response shape as
`/frame/image`. Used by the device's back-photo button.
- `GET /frame/config` -- `{"refresh_interval_s": ..., "firmware_version": "1.2.3" | null}`,
polled by the frame each wake alongside its reachability check.
`firmware_version` is whatever's currently uploaded via
`POST /api/firmware` below (`null` if nothing's been uploaded) -- the
device compares it against its own running version
(`esp_app_get_description()->version`, sent as an `X-Frame-Version`
request header, stored as `device_firmware_version`) to decide whether
to OTA. The device also sends an `X-Frame-Board` header
(`CONFIG_FRAME_BOARD_NAME`, e.g. `"xiao"`), stored as
`device_board_variant` -- how the Gitea auto-update feature below
learns which board to fetch a release for, instead of a user picking
it
- `GET /frame/photo-info` -- `{"asset_id": ..., "location_line1": ... |
null, "location_line2": ... | null, "taken_at": ... | null}` for the
current photo (same idempotent current-photo semantics as
`/frame/image`). `location_line1`/`location_line2` are `city` /
`state-or-country` if Immich reverse-geocoded the photo's GPS EXIF
(both `null` if not) -- for US/Canada, the region line is the
abbreviated state/province (`"CA"`, `"ON"`); elsewhere it's the full
country name. `taken_at` is `MM/DD/YY` from the photo's EXIF capture
date, else `null`. Used by the device's manage button to build its
overlay text
- `GET /frame/share/{asset_id}` -- creates a 30-minute public, view-only
Immich share link for `asset_id` and redirects (302) to it. Only works
for the photo currently showing or in the upcoming queue on this frame
-- not any arbitrary Immich asset. The link is created on first hit
(i.e. when someone actually scans the manage overlay's share QR), not
when the button's pressed, so the 30-minute window starts when it's
actually used
- `GET /frame/face-labels` -- `{"count": N, "name_0": ..., "x_0": ...,
"y_0": ..., ...}` (up to 4 slots) -- named people from Immich's face
recognition, positioned in final 800x480 frame pixel space. Only faces
Immich already has an identified name for are included (no face
detection/recognition happens in this project, see
`app/face_labels.py`); `count: 0` if none are named. Used by the
device manage button's escalated second menu level
- `POST /frame/battery` -- `{"percent": 0-100}`; the device's last
battery reading, stored with a timestamp plus two histories: a
per-discharge-cycle one (reset whenever a report jumps up enough to
look like a recharge) feeding the "on battery for"/estimate numbers,
and a permanent, never-reset log (capped at `BATTERY_LOG_MAX`, ~2
years at hourly reports) feeding the web UI's battery graph. Only sent
when the device is actually running on battery (see
`firmware/README.md`'s Battery section) -- a frame on mains power
never reports
- `GET /api/battery-log` -- `{"log": [[timestamp, percent], ...]}`, the
full permanent battery history above; used by the web UI's "Battery
history" chart
- `GET /api/stats` -- lifetime, never-reset counters: `first_seen`,
`device_wakes`, `photos_displayed`, `photos_removed`,
`battery_reports`, `recharge_cycles`, `ota_updates_applied`,
`config_saves` (see `FrameStats` in `app/config.py`). Purely
informational -- nothing else reads these back -- shown in a
collapsed "Stats" section in the web UI
- `POST /api/firmware` -- multipart upload (`file`) of a built
`espresso_frame.bin`. Parses the embedded `esp_app_desc_t` (rejects
anything that isn't a valid image for this project) and stores it as
the available firmware; devices pick it up via `GET /frame/config`
above on their next wake
- `GET /frame/firmware` -- streams back whatever was last uploaded via
`POST /api/firmware`, for the device's OTA fetch. 404 if nothing's
been uploaded yet
- `GET /api/firmware/check` -- throttled (`gitea_releases.UPDATE_CHECK_INTERVAL_S`,
15 min) check of the configured Gitea repo's latest release for the
frame's board variant (learned from the device, see `device_board_variant`
below -- not user-configured). `{"enabled": false}` if no repo URL is
configured; otherwise `{"enabled": true, "board": "xiao" | null,
"latest_version": "1.2.3" | null, "staged_version": "1.2.2" | null,
"update_available": bool}`. `update_available` stays false until the
board is known, regardless of what Gitea has. If "Automatically apply
updates" is on and a newer release is found, this call also stages it
immediately (same effect as a manual upload) -- otherwise the web UI
shows an "Update frame" button
- `POST /api/firmware/apply-latest` -- the "Update frame" button: pulls
and stages the latest Gitea release right now, bypassing the check
throttle. 400 if no repo is configured or no device has checked in
yet (board unknown); 404 if the repo has no releases, or the latest
release has no asset for the frame's board
- `GET /api/queue` -- `{"current": {...} | null, "upcoming": [...],
"device": {"last_seen": ts | null, "overdue": bool,
"firmware_version": "1.2.3" | null, "firmware_available": "1.2.4" | null,
"battery": {"percent": N, "as_of": ts} | null, "on_battery_since": ts | null,
"battery_estimate_s": N | null}}`, each queue entry an asset id +
thumbnail URL; used by the config UI's "Device" panel
- `POST /api/queue/reorder` -- reorders the upcoming queue; body is
`{"queue": [asset_id, ...]}`. Tolerant of drift from the queue having
changed server-side since the client's last fetch (e.g. a top-up/trim)
-- unrecognized IDs in the body are dropped, and any currently-queued
photo missing from the body is appended rather than lost, instead of
rejecting the whole request
- `POST /api/queue/promote` -- moves one photo to the front of the queue;
body is `{"asset_id": "..."}`. Used by "Show next" in the web UI --
unlike `/reorder`, doesn't depend on the client knowing the queue's
full current order, so it can't fail from staleness
- `POST /api/queue/remove` -- permanently excludes a photo from this
frame's rotation; body is `{"asset_id": "..."}`. Doesn't touch Immich
or the album -- the photo just stops being selected by this frame
again (`app/photo_queue.py`'s `excluded_asset_ids`/`remove_from_rotation()`).
Works on the current photo too, in which case it immediately advances
to a different one (without recording the removed photo in history --
going back to a photo you just removed wouldn't make sense). Used by
the "×" button in the web UI on both the current-photo thumbnail and
each upcoming card
- `GET /api/photo-thumbnail/{asset_id}` -- proxies an Immich thumbnail so
the browser never needs the Immich API key directly
- `GET /health` -- liveness check
Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
`/admin`, `/admin/logs`, `/frames/{id}` (Photos), `/frames/{id}/config`,
`/frames/{id}/stats`, `/m/{manage_token}`.
### Device protocol (`/frame/*` -- paths frozen; auth = `?id=` + `?token=`)
- `GET /frame/image` -- the frame's current image, pre-processed into
the panel's raw 4-bit-per-pixel, 2-pixels-per-byte format
(`application/octet-stream`) -- 800x480/exactly 192,000 bytes for the
original 7.3" panel, 1600x1200/exactly 960,000 bytes for the 13.3"
panel (see `Frame.panel_type`/`image_pipeline.PANEL_SPECS`; a given
device's byte count is fixed by which firmware/panel it actually is).
**Side-effect-free** by default: it only actually advances once `refresh_interval_s` has
elapsed since the current photo was set, so an unexpected reboot just
redisplays the same photo. An unclaimed or not-yet-configured frame
gets a rendered instruction placeholder (with a claim QR) instead of
an error, so a fresh device never error-loops.
- `POST /frame/advance` / `POST /frame/back` -- the next/back photo
buttons: force an immediate move (mirror images of each other; back
pops a bounded 20-entry history and pushes the displaced photo onto
the front of the queue). Same response shape as `/frame/image`.
- `GET /frame/config` -- `{"refresh_interval_s": ..., "firmware_version":
... | null, "device_token": ...?}`, polled each wake. Captures the
`X-Frame-Version`/`X-Frame-Board` headers (running firmware + board
variant). `device_token` appears only during the one-time identity
handshake -- until the device authenticates with its issued token
once -- and the flat firmware parser's 512-byte buffer bounds how big
this response may grow.
- `GET /frame/photo-info` -- location/date overlay text for the manage
menu (city + abbreviated US/CAN region or country, `MM/DD/YY`).
- `GET /frame/share/{manage_token}` -- creates a 30-minute public Immich
share link covering every photo widget's currently-showing photo on
*this* frame and 302s to it. Authenticated by the frame's own
`manage_token` (see the manage QR below), not device credentials -- a
phone scanning the QR has no way to supply `?id=`/`?token=`.
- `GET /frame/face-labels` -- up to 4 named faces with positions in the
frame's own panel space (800x480 for the 7.3" panel, 1600x1200 for the
13.3"), flattened (`name_0`/`x_0`/`y_0`, ...) for the device's
flat-scalar parser.
- `POST /frame/battery` -- `{"percent": 0-100}`; per-discharge-cycle
history (feeds the runtime estimate) plus a permanent per-frame
battery log (the Stats chart). Only sent on battery power. Also where
the battery-alert threshold (below) is checked and, at most once per
discharge cycle, emailed to the owner.
- `GET /frame/firmware` -- streams the frame's staged OTA image.
### Web API (`/api/frames/{id}/...` -- session auth; *view* for reads, *control* for writes)
- `GET .../queue` -- current + upcoming (each entry id + thumbnail
URL), the control state (`{"controller": name, "you": bool}`), and
the device telemetry block (`last_seen`, `overdue` -- quiet-hours
aware -- firmware versions, battery + runtime estimate).
- `POST .../queue/reorder|promote|remove` -- reorder is drift-tolerant
(stale ids dropped, missing ids appended); promote is "Show next";
remove permanently excludes from this frame's rotation (never touches
Immich) and advances if it was current.
- `GET .../albums` -- the owner's Immich albums.
- `POST .../config` -- **partial** update: only provided fields change
(`name`, `album_id` -- resets queue/history on change --, `order`,
`refresh_interval_s`, `display_mode` (`crop_fill`/`crop_faces`/
`stretch_fill`/`letterbox`, see `image_pipeline.DISPLAY_MODES`),
`queue_target_len`, `orientation` (composed logically then rotated
server-side; the on-device manage overlay still renders native, a
known limitation), `quiet_hours_*` + `timezone` (a pure server-side
decision shaping what `refresh_interval_s` gets handed to the
device), `firmware_update_repo_url`, `firmware_auto_update`,
`battery_alert_threshold_pct` -- percent, or `-1`/blank to disable --,
`palette` -- exactly 6 `#rrggbb` values in black/white/yellow/red/
blue/green order --, `palette_reset` -- `true` clears back to the
default palette --, `color_boost`/`contrast_boost` -- PIL
`ImageEnhance` factors, 0-2, 1 = unchanged --, `dither_strength` --
0-1, blends toward a flat/undithered quantization before running
Floyd-Steinberg, so 0 = no dithering texture and 1 = full strength).
- `GET .../preview/original`, `GET .../preview/rendered` -- the
before/after comparison on the Configuration tab: the current
photo's Immich preview untouched (JPEG), and that same photo run
through this frame's actual saved rendering pipeline (PNG, upright
logical orientation, not packed device bytes) -- reflects saved
settings, not unsaved slider positions.
- `POST .../take-control` -- always succeeds for a linked user.
- `GET .../stats`, `GET .../battery-log`, `GET .../thumbnail/{asset_id}`.
- `POST .../firmware` (manual .bin upload, esp_app_desc_t-validated),
`GET .../firmware/check` (throttled 15 min; `?force=true` bypasses),
`POST .../firmware/apply-latest`.
### Manage-QR API (`/api/m/{manage_token}/...` -- token in path, no login)
- `GET queue`, `POST promote`, `POST advance`, `POST back`,
`GET thumbnail/{asset_id}` (scoped to this frame's current/queued
photos). Nothing else.
- `GET /health` -- liveness check, always open.
## Notes
- Album/order/refresh-interval/current photo/upcoming queue/etc. are
stored in `./data/config.json` on the host via the compose volume
mount. Immich URL/API key are too if set via the web UI, but
`IMMICH_URL`/`IMMICH_API_KEY` env vars (see Setup above) always take
precedence when present.
- All state (settings, current photo, upcoming queue, battery history,
stats) lives in a SQLite database at `./data/espresso.db` on the host
via the compose volume mount (`DATABASE_URL` env var to override --
any SQLAlchemy URL works, so a future move to Postgres is a config
change). A pre-database deployment's `./data/config.json` is imported
automatically on first boot (it becomes frame #1) and left untouched
afterwards as the rollback path. `IMMICH_URL`/`IMMICH_API_KEY` env
vars (see Setup above) still take precedence when present.
- The upcoming queue is a bounded lookahead, not the whole album --
"Upcoming photos to show" in the config UI (`queue_target_len`, 5-50,
default 20) controls its size and takes effect immediately (the queue
@@ -235,19 +214,79 @@ algorithm itself -- it just streams the response straight to the panel.
in sequential or shuffle order per the Order setting. Dragging photos
in the web UI (or using "Show next") only rearranges what's already in
that lookahead; it doesn't add or remove photos from the album.
- Every endpoint except `/` and `/health` -- the web UI's `/api/*` and
every device-facing `/frame/*` -- requires `?token=` (or the
`mgmt_token` cookie the web UI sets after a valid one) once
`MANAGEMENT_TOKEN` is set (see Setup above); unset, everything stays
open like before, which is still fine on a trusted home LAN. `/frame/share`
additionally stays scoped to only ever create a link for a photo this
frame is actually showing or has queued, not any Immich asset ID
someone might guess -- a second layer a leaked token alone wouldn't
bypass.
- The 6-color palette RGB values in `app/image_pipeline.py` are
approximations, not measured values (Waveshare doesn't publish exact
color primaries for this panel) -- tune them once you can compare a
rendered test image against the real panel.
- Auth in one breath: browsers use sessions (+CSRF), devices use
per-frame tokens (`?id=` + `?token=`), the manage QR and the
scan-to-download QR both use the frame's own `manage_token` (device
tokens don't work for either -- neither is ever called by firmware,
both are opened by a phone that has no way to supply `?id=`/`?token=`),
and `MANAGEMENT_TOKEN` is only ever the pre-setup claim gate (see
step 5 above).
- The calendar widget (`app/calendar_feed.py`) expands recurring events
(RRULE/EXDATE/DST) via [`recurring-ical-events`](https://pypi.org/project/recurring-ical-events/),
which is LGPL-3.0-or-later -- the only non-permissively-licensed
dependency here. It's used as an ordinary `pip install` runtime import,
never vendored or modified, so this project's own code stays under its
own license; LGPL's copyleft terms apply to that library itself, not
to code that merely links against it dynamically.
- CalDAV account support (`app/caldav_client.py`, alongside the plain ICS
subscription) wraps the `caldav` PyPI package. `caldav` itself is
dual-licensed GPL-3.0-or-later/Apache-2.0, but it hard-depends on
`icalendar-searcher`, which is **AGPL-3.0-or-later** -- the strongest
copyleft in this project's dependency tree, and the one whose
network-use clause is written specifically for server applications
like this one (not just "don't vendor/modify it," which was enough
reasoning for the LGPL dependency above). Taking this on was an
explicit, informed call by the project owner, not a default -- anyone
redistributing this project (vs. just self-hosting it) should
re-evaluate that tradeoff for their own situation before doing so.
- The whiteboard widget (`app/webdav_client.py`, `app/whiteboard.py`)
fetches a Nextcloud Whiteboard (or any WebDAV server's) `.whiteboard`
file -- which turns out to be Excalidraw scene JSON (elements/appState/
files), not an image -- and renders it via `render-service/`, a small
Node.js sidecar using Excalidraw's own real export code
(`@excalidraw/utils`'s `exportToSvg`) plus `@resvg/resvg-js` (a native
Rust SVG rasterizer, no headless browser) to turn that into a PNG. That
sidecar runs as a **second process inside this same container**
(`Dockerfile` installs Node, `start.sh` launches it in the background
before `exec`-ing uvicorn), reachable only at `127.0.0.1:3001` from the
Python process -- not a second docker-compose service, since it's
lightweight, stateless, and has nothing worth independently scaling or
restarting. License check (after getting burned once already in this
same file, on the CalDAV dependency below, into checking transitive
deps and not just top-level ones): Excalidraw, `@excalidraw/utils`,
every one of its own runtime dependencies, `@resvg/resvg-js`
(MPL-2.0 -- weak/file-level copyleft, doesn't extend to code that just
calls into it), `jsdom`, and `express` are all MIT/Apache-2.0/Zlib/
MPL-2.0 -- no repeat of the AGPL surprise. **Not runtime-tested against
a real `npm install`/`docker build`** -- this project's dev environment
has no Node.js/npm, only network access to the npm registry API (used
to verify the above and pick real, current dependency versions). See
`render-service/README.md` for exactly what is and isn't verified.
- Calendar event titles can contain emoji, which `ImageFont.load_default()`
(used for every other bit of text this project renders) has no glyphs
for -- PIL/FreeType substitute a visible ".notdef" tofu box rather than
skipping the codepoint. `app/calendar_render.py` draws emoji runs with
a vendored font instead (Google's Noto Emoji, OFL-1.1 -- license text
at `app/fonts/OFL.txt`), the one deliberate exception to this project's
usual "no new font/icon assets" default elsewhere in calendar_render.py
-- there's no way to hand-draw arbitrary emoji with primitives the way
the weather icons are. Full color (`app/fonts/NotoColorEmoji.ttf`,
embedded CBDT bitmap glyphs) is tried first and confirmed to hold up
fine through the panel's own Floyd-Steinberg dithering; a deployment
whose Pillow/FreeType wasn't built with embedded color bitmap support
falls back to a monochrome outline font (`app/fonts/NotoEmoji.ttf`)
instead of crashing or rendering nothing. Color glyphs are only stored
at one embedded bitmap size (109px), so they're rasterized once at
that size and scaled down to the target row height rather than drawn
directly like normal vector text.
- The 6-color palette RGB values in `app/image_pipeline.py`
(`DEFAULT_PALETTE_RGB`) are approximations, not measured values
(Waveshare doesn't publish exact color primaries for this panel).
Each frame's Configuration tab has an **Advanced configuration**
section (collapsed by default) with a color picker per ink color --
tune them once you can compare a rendered photo against the real
panel, and "Reset to defaults" to go back. Different panel units can
vary enough to be worth calibrating per frame.
## Deploying a pre-built image
@@ -279,3 +318,19 @@ CONFIG_PATH=./data/config.json uvicorn app.main:app --reload --host 0.0.0.0 --po
`--host 0.0.0.0` matters here: without it, uvicorn defaults to
`127.0.0.1` (localhost-only), which the ESP32 can't reach over the LAN.
The Docker image already binds `0.0.0.0` by default.
## Running tests
```
pip install -r requirements-dev.txt
pytest
```
Runs against a fresh temp SQLite database (`tests/conftest.py` sets
`DATABASE_URL` before anything imports `app.db`), with every table wiped
and reseeded (frame #1 + server settings, same as a real fresh install)
between tests -- no Docker, Node, or a real Immich/CalDAV/WebDAV server
needed; a few tests spin up small local HTTP servers as fixtures to
stand in for those. Also runs as its own job in
`.gitea/workflows/server-docker-build.yml`, gating the image build/push
-- a failing test suite blocks the push, not just decorates it.
+357
View File
@@ -0,0 +1,357 @@
"""Authentication: password hashing, user sessions + CSRF, the pre-setup
claim gate, and device resolution.
Three independent credential classes:
- User sessions (cookie "session", server-side sessions table, per-
session CSRF token required on mutating requests) -- humans.
- MANAGEMENT_TOKEN (env-only, optional). Only meaningful before any user
account exists yet (fresh install, or freshly migrated, before
/setup has been run): if set, it gates who gets to be the one to run
/setup and claim the first admin account; once a user exists, sessions
are the only way in. Not a standing bearer credential -- the on-panel
manage QR now embeds a frame's own per-frame manage_token (/m/, see
routers/manage.py) rather than this shared one; CSRF doesn't apply to
it either way (it's an explicit per-request credential, not an ambient
cookie a cross-site request could ride).
- Device credentials (?id= + ?token=, see require_device below).
"""
from __future__ import annotations
import hashlib
import hmac
import logging
import os
import secrets
import time
from fastapi import Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.orm import Session
from .db import get_db
from .migration import new_device_token, new_manage_token
from .models import Frame, PasswordResetToken, PendingClaim, ServerSettings, User, UserFrame, UserSession
logger = logging.getLogger(__name__)
MANAGEMENT_TOKEN_COOKIE = "mgmt_token"
SESSION_COOKIE = "session"
SESSION_LIFETIME_S = 30 * 86400
SESSION_REFRESH_BELOW_S = 15 * 86400 # rolling expiry: extend when under this much left
PASSWORD_RESET_TOKEN_LIFETIME_S = 3600
# stdlib scrypt instead of a passlib/argon2 dependency: zero new deps,
# and the parameters are baked into each stored hash so they can be
# raised later without invalidating existing ones.
_SCRYPT_N = 16384
_SCRYPT_R = 8
_SCRYPT_P = 1
def hash_password(password: str) -> str:
salt = os.urandom(16)
digest = hashlib.scrypt(
password.encode(), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P
)
return f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${salt.hex()}${digest.hex()}"
def verify_password(password: str, stored: str) -> bool:
try:
scheme, n, r, p, salt_hex, hash_hex = stored.split("$")
if scheme != "scrypt":
return False
digest = hashlib.scrypt(
password.encode(), salt=bytes.fromhex(salt_hex), n=int(n), r=int(r), p=int(p)
)
return hmac.compare_digest(digest.hex(), hash_hex)
except (ValueError, AttributeError):
return False
def _hash_session_token(value: str) -> str:
return hashlib.sha256(value.encode()).hexdigest()
def create_session(db: Session, user: User) -> tuple[str, UserSession]:
"""Returns (cookie_value, session row). Only the sha256 of the cookie
value is stored, so a leaked database doesn't yield usable cookies."""
cookie_value = secrets.token_urlsafe(32)
now = time.time()
session = UserSession(
token_hash=_hash_session_token(cookie_value),
user_id=user.id,
csrf_token=secrets.token_urlsafe(32),
created_at=now,
expires_at=now + SESSION_LIFETIME_S,
)
db.add(session)
# Opportunistic prune -- no background scheduler in this project.
for stale in db.scalars(select(UserSession).where(UserSession.expires_at < now)):
db.delete(stale)
db.commit()
return cookie_value, session
def destroy_session(db: Session, request: Request) -> None:
cookie_value = request.cookies.get(SESSION_COOKIE)
if not cookie_value:
return
session = db.scalars(
select(UserSession).where(UserSession.token_hash == _hash_session_token(cookie_value))
).first()
if session is not None:
db.delete(session)
db.commit()
def current_session(request: Request, db: Session) -> UserSession | None:
cookie_value = request.cookies.get(SESSION_COOKIE)
if not cookie_value:
return None
session = db.scalars(
select(UserSession).where(UserSession.token_hash == _hash_session_token(cookie_value))
).first()
now = time.time()
if session is None or session.expires_at < now:
return None
if session.expires_at - now < SESSION_REFRESH_BELOW_S:
session.expires_at = now + SESSION_LIFETIME_S
db.commit()
return session
def current_user(request: Request, db: Session) -> User | None:
session = current_session(request, db)
if session is None:
return None
return db.get(User, session.user_id)
def users_exist(db: Session) -> bool:
return db.scalars(select(User).limit(1)).first() is not None
def get_server_settings(db: Session) -> ServerSettings:
"""The SMTP config singleton -- migration.py guarantees row id=1
exists (created at startup if missing), so this is never None."""
settings = db.get(ServerSettings, 1)
assert settings is not None
return settings
def create_password_reset_token(db: Session, user: User) -> str:
token = secrets.token_urlsafe(32)
now = time.time()
# Opportunistic prune, same pattern as sessions/pending claims.
for stale in db.scalars(select(PasswordResetToken).where(PasswordResetToken.expires_at < now)):
db.delete(stale)
db.add(PasswordResetToken(
token=token, user_id=user.id, created_at=now,
expires_at=now + PASSWORD_RESET_TOKEN_LIFETIME_S,
))
db.commit()
return token
def consume_password_reset_token(db: Session, token: str) -> User | None:
"""Looks up the token and, if valid, deletes it (single-use) and
returns the user it was issued for. None for an unknown/expired
token -- callers show a generic error either way."""
row = db.get(PasswordResetToken, token)
if row is None or row.expires_at < time.time():
return None
user = db.get(User, row.user_id)
db.delete(row)
db.commit()
return user
def _csrf_ok(request: Request, session: UserSession) -> bool:
supplied = request.headers.get("X-CSRF-Token") or ""
return hmac.compare_digest(supplied, session.csrf_token)
def require_user_api(request: Request, db: Session = Depends(get_db)) -> User:
"""JSON-API dependency: a logged-in user, with CSRF enforced on
mutating methods (the session rides an ambient cookie; the CSRF
header is what proves the request came from our own JS, not a
cross-site form)."""
session = current_session(request, db)
if session is None:
raise HTTPException(401, "Not logged in")
if request.method not in ("GET", "HEAD", "OPTIONS") and not _csrf_ok(request, session):
raise HTTPException(403, "Missing or invalid CSRF token")
user = db.get(User, session.user_id)
if user is None:
raise HTTPException(401, "Not logged in")
return user
def require_admin_api(request: Request, db: Session = Depends(get_db)) -> User:
user = require_user_api(request, db)
if not user.is_admin:
raise HTTPException(403, "Admin only")
return user
def user_frames(db: Session, user: User) -> list[Frame]:
"""The frames this user sees in their sidebar: linked ones, or all of
them for an admin (admins are the household operators -- they see
unclaimed/new frames too, that's how those get adopted)."""
if user.is_admin:
return list(db.scalars(select(Frame).order_by(Frame.id)))
return list(
db.scalars(
select(Frame)
.join(UserFrame, UserFrame.frame_id == Frame.id)
.where(UserFrame.user_id == user.id)
.order_by(Frame.id)
)
)
def can_view_frame(db: Session, user: User, frame: Frame) -> bool:
return user.is_admin or db.get(UserFrame, (user.id, frame.id)) is not None
def require_frame_view(
frame_id: int, request: Request, db: Session = Depends(get_db)
) -> Frame:
"""JSON-API dependency: a logged-in user who is linked to this frame
(or an admin). 404 -- not 403 -- for frames outside the user's view,
so the API doesn't confirm which frame ids exist."""
user = require_user_api(request, db)
frame = db.get(Frame, frame_id)
if frame is None or not can_view_frame(db, user, frame):
raise HTTPException(404, "No such frame")
return frame
def require_frame_control(
frame_id: int, request: Request, db: Session = Depends(get_db)
) -> Frame:
"""View access plus the soft control lock: only the user currently
holding control may mutate settings/queue. The 409 payload names the
holder so the UI can offer "take control" instead of a dead end.
Physical device buttons don't go through this -- device actions are
device actions."""
user = require_user_api(request, db)
frame = db.get(Frame, frame_id)
if frame is None or not can_view_frame(db, user, frame):
raise HTTPException(404, "No such frame")
if frame.controlled_by_user_id != user.id:
holder = frame.controlled_by
raise HTTPException(
409,
{
"error": "not_controller",
"holder": (holder.display_name or holder.username) if holder else None,
},
)
return frame
def management_token() -> str:
"""The pre-setup claim-gate secret. Env-only, never stored -- same as
the old server, where the env var overrode anything on disk on every
load."""
return os.environ.get("MANAGEMENT_TOKEN", "")
def browser_token_valid(request: Request) -> bool:
"""Whether the request carries the current MANAGEMENT_TOKEN, via
query param or cookie. Only meaningful pre-setup (see require_browser
below) -- empty configured token => not valid (nothing to match)."""
token = management_token()
if not token:
return False
supplied = request.query_params.get("token") or request.cookies.get(MANAGEMENT_TOKEN_COOKIE)
return supplied is not None and supplied == token
def require_browser(request: Request, db: Session = Depends(get_db)) -> User | None:
"""Dependency for the web UI's /api/* routes: a real user session
(CSRF-checked on mutations, returns the User). While NO users exist
yet (fresh install, or freshly migrated, before /setup has been run)
the API instead stays open if no MANAGEMENT_TOKEN is set, or opens
to whoever supplies it if one is -- there's nobody to log in as yet,
so this is purely the claim gate for who gets to run /setup. Once a
user exists, only a session gets in."""
session = current_session(request, db)
if session is not None:
if request.method not in ("GET", "HEAD", "OPTIONS") and not _csrf_ok(request, session):
raise HTTPException(403, "Missing or invalid CSRF token")
user = db.get(User, session.user_id)
if user is not None:
return user
if not users_exist(db):
if not management_token() or browser_token_valid(request):
return None
raise HTTPException(401, "Not logged in")
def _register_frame(db: Session, device_id: str) -> Frame:
"""A device id we've never seen: self-register it as an unclaimed
frame (this fires from ANY /frame/* route -- the wake cycle hits
/frame/image before /frame/config). If a user already submitted a
claim for this id (they beat the device to the server after
provisioning), attach it now."""
frame = Frame(
name=f"Frame {device_id[-6:]}",
device_id=device_id,
device_token=new_device_token(),
manage_token=new_manage_token(),
created_at=time.time(),
)
db.add(frame)
db.flush()
now = time.time()
# Opportunistically prune expired claims while we're here.
for stale in db.scalars(select(PendingClaim).where(PendingClaim.expires_at < now)):
db.delete(stale)
pending = db.get(PendingClaim, device_id)
if pending is not None and pending.expires_at >= now:
frame.owner_user_id = pending.user_id
frame.claimed_at = now
db.add(UserFrame(user_id=pending.user_id, frame_id=frame.id))
db.delete(pending)
logger.info("Frame %s self-registered and attached pending claim by user %d",
device_id, pending.user_id)
else:
logger.info("Frame %s self-registered (unclaimed)", device_id)
return frame
def require_device(request: Request, db: Session = Depends(get_db)) -> Frame:
"""Resolves and authenticates the frame behind a /frame/* request.
Firmware sends ?id=<12-hex-mac>&token=<per-frame device token>."""
device_id = request.query_params.get("id", "").strip().lower()
token = request.query_params.get("token", "")
if not device_id:
raise HTTPException(401, "Missing device id")
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
if frame is None:
frame = _register_frame(db, device_id)
else:
token_ok = bool(token) and token == frame.device_token
if token_ok and not frame.device_token_ack:
frame.device_token_ack = True
logger.info("Frame #%d acknowledged its device token", frame.id)
elif not token_ok and frame.device_token_ack:
raise HTTPException(401, "Missing or invalid access token")
# else: handshake window -- the device registered but hasn't
# received its token yet (the wake cycle fetches the image
# BEFORE polling /frame/config, where the token is delivered) --
# the id stays the credential, same trust level as the open
# registration that created the row. Closes permanently on the
# first authenticated request.
frame.last_seen = time.time()
db.commit()
return frame
+225
View File
@@ -0,0 +1,225 @@
"""CalDAV account support: discovering which calendars an account exposes,
and fetching one calendar's events or tasks -- the second way (alongside
calendar_feed.py's single-file ICS subscription) a user can link a
calendar for calendar frame mode (Nextcloud, Fastmail, iCloud, Radicale,
Baikal, ...). Task lists (VTODO collections) are CalDAV-only -- a plain
ICS subscription doesn't meaningfully have one -- see fetch_tasks.
Thin wrapper around the `caldav` PyPI package (RFC 4791 client). NOTE ON
LICENSING: `caldav` itself is dual-licensed GPL-3.0-or-later / Apache-2.0,
but it hard-depends on `icalendar-searcher`, which is AGPL-3.0-or-later --
the strongest copyleft license in this project's dependency tree, and the
one whose network-use clause is specifically written for server
applications like this one. This was an explicit, informed call by the
project owner to accept that exposure rather than hand-roll a CalDAV
client -- see the server README's Notes section. Anyone redistributing
this project (as opposed to just self-hosting it) should reread that
tradeoff for their own situation.
Pure functions -- no ORM, no FastAPI Depends -- same testability
philosophy as calendar_feed.py. Event parsing/expansion reuses
icalendar + recurring_ical_events directly (rather than trusting each
CalDAV server's own possibly-inconsistent RRULE expansion) so a CalDAV
calendar and an ICS subscription behave identically once fetched.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import date, datetime
import caldav
import icalendar
import recurring_ical_events
logger = logging.getLogger(__name__)
HTTP_TIMEOUT_S = 15
class CalDavError(Exception):
"""Discovery or fetch failed -- network, auth, or an unexpected
server response. Raised loudly; callers (Settings' discover
endpoint, calendar_feed.merge_events) decide what to do. Wraps
whatever the caldav package/its transport raised, since that
exception hierarchy isn't something call sites should need to know
about directly."""
def discover_calendars(base_url: str, username: str, password: str) -> list[dict]:
"""[{"href": absolute_calendar_url, "display_name": str}, ...] for
every calendar in this account. base_url is the server's CalDAV
entry point (e.g. "https://cloud.example.com/remote.php/dav/" for
Nextcloud) -- the caller supplies it directly, same idiom as the
plain ICS subscription URL."""
try:
client = caldav.DAVClient(url=base_url, username=username, password=password, timeout=HTTP_TIMEOUT_S)
calendars = client.principal().calendars()
except Exception as e:
raise CalDavError(str(e)) from e
result = []
for cal in calendars:
try:
display_name = cal.get_display_name() or cal.name
except Exception:
display_name = None
result.append({"href": str(cal.url), "display_name": display_name or str(cal.url)})
return result
def fetch_calendar_events(calendar_url: str, username: str, password: str,
window_start: date, window_end: date) -> list[dict]:
"""One CalDAV calendar's events in [window_start, window_end] -- same
event dict shape as calendar_feed.fetch_source_events (no
"owner_display_name"; the caller adds that).
Deliberately does NOT use the calendar-query REPORT's server-side
time-range filter (caldav.Calendar.date_search) -- RFC 4791 leaves
that corner case underspecified and real servers disagree on it
(the caldav package's own docs warn "servers often behave
differently when presented with a search request"; confirmed here
too, once against a real server, as a calendar whose events just
silently never came back despite discovery/auth both working
fine). Instead this fetches every event in the calendar unfiltered
(get_events() is a plain "list VEVENTs" REPORT with no time-range
element -- the much more universally-supported case) and does 100%
of the date-window filtering/recurrence-expansion client-side via
icalendar + recurring_ical_events, exactly like calendar_feed.py
already does for plain ICS feeds. Heavier per-fetch (the whole
calendar, not just the window) but far more reliable."""
try:
client = caldav.DAVClient(url=calendar_url, username=username, password=password, timeout=HTTP_TIMEOUT_S)
calendar = caldav.Calendar(client=client, url=calendar_url)
objects = calendar.get_events()
except Exception as e:
raise CalDavError(str(e)) from e
events: list[dict] = []
for obj in objects:
try:
ical = icalendar.Calendar.from_ical(obj.data)
occurrences = recurring_ical_events.of(ical).between(window_start, window_end)
except Exception as e: # one malformed resource shouldn't blank the whole calendar
logger.warning("Could not parse a CalDAV event from %s: %s", calendar_url, e)
continue
for occ in occurrences:
dtstart = occ.get("DTSTART")
dtend = occ.get("DTEND")
if dtstart is None:
continue
start_dt = dtstart.dt
end_dt = dtend.dt if dtend is not None else start_dt
all_day = not isinstance(start_dt, datetime)
events.append({
"summary": str(occ.get("SUMMARY") or "(untitled)"),
"start": start_dt.isoformat(),
"end": end_dt.isoformat(),
"all_day": all_day,
})
return events
def fetch_tasks(calendar_url: str, username: str, password: str,
completed_since: datetime | None = None) -> list[dict]:
"""Outstanding VTODOs from one CalDAV task list, plus -- when
completed_since is given -- ones completed at or after that cutoff
(see routers/common.py's get_or_refresh_tasks_for_widget, which
passes "now - 24h" when TaskWidgetConfig.show_completed is on;
None, the default, means completed tasks are dropped entirely, the
original behavior). {"summary", "due" (ISO date/datetime string or
None), "completed_at" (ISO datetime string, or None for an
outstanding task)}, ... . Outstanding tasks sort first (by due date,
no-due-date last), any included completed ones after (most recently
completed first).
Fetches every task including completed ones and filters/sorts
client-side rather than trusting get_todos()'s own
include_completed/sort_keys server-side filtering, same reasoning as
fetch_calendar_events not trusting the time-range REPORT filter --
a simpler filter than a time range, but not worth re-litigating
which server-side filters are reliable one at a time."""
try:
client = caldav.DAVClient(url=calendar_url, username=username, password=password, timeout=HTTP_TIMEOUT_S)
calendar = caldav.Calendar(client=client, url=calendar_url)
objects = calendar.get_todos(include_completed=True)
except Exception as e:
raise CalDavError(str(e)) from e
outstanding: list[dict] = []
completed: list[dict] = []
for obj in objects:
try:
ical = icalendar.Calendar.from_ical(obj.data)
except Exception as e: # one malformed resource shouldn't blank the whole list
logger.warning("Could not parse a CalDAV task from %s: %s", calendar_url, e)
continue
for component in ical.walk("VTODO"):
status = str(component.get("STATUS") or "NEEDS-ACTION").upper()
summary = str(component.get("SUMMARY") or "(untitled)")
if status == "COMPLETED":
completed_prop = component.get("COMPLETED")
completed_dt = completed_prop.dt if completed_prop is not None else None
if completed_since is None or completed_dt is None or completed_dt < completed_since:
continue
completed.append({"summary": summary, "due": None, "completed_at": completed_dt.isoformat()})
else:
due = component.get("DUE")
outstanding.append({
"summary": summary,
"due": due.dt.isoformat() if due is not None else None,
"completed_at": None,
})
outstanding.sort(key=lambda t: (t["due"] is None, t["due"] or ""))
completed.sort(key=lambda t: t["completed_at"], reverse=True)
return outstanding + completed
@dataclass
class TaskSource:
"""One task list to merge in -- CalDAV only, no ICS variant (a plain
ICS subscription has no VTODO collection to speak of).
owner_display_name tags every task pulled from this source so a
merged checklist can show whose task is whose; color_index (2-5,
into image_pipeline.DEFAULT_PALETTE_RGB) is this list's manually
pinned color, or None for calendar_render.py's auto-cycle-by-owner-
name fallback -- see models.FrameTaskList."""
owner_display_name: str
url: str
username: str
password: str
color_index: int | None = None
def merge_tasks(sources: list[TaskSource], completed_since: datetime | None = None) -> tuple[list[dict], str]:
"""Fetches each source independently -- one broken list never blanks
another's tasks. Returns (merged_tasks, fetch_summary); fetch_summary
is "" when every source succeeded, else "N of M task lists
unavailable" (same no-naming-names posture as calendar_feed.
merge_events). No cross-list duplicate collapsing (unlike
merge_events) -- a task synced to two lists at once is rare enough,
and lower-stakes than a duplicated calendar event, not to be worth
the same de-dup machinery."""
merged: list[dict] = []
failures = 0
for source in sources:
try:
tasks = fetch_tasks(source.url, source.username, source.password, completed_since=completed_since)
except CalDavError:
failures += 1
continue
for task in tasks:
merged.append({
**task,
"owner_display_name": source.owner_display_name,
"color_index": source.color_index,
})
outstanding = [t for t in merged if t["completed_at"] is None]
completed = [t for t in merged if t["completed_at"] is not None]
outstanding.sort(key=lambda t: (t["due"] is None, t["due"] or ""))
completed.sort(key=lambda t: t["completed_at"], reverse=True)
summary = f"{failures} of {len(sources)} task lists unavailable" if failures else ""
return outstanding + completed, summary
+155
View File
@@ -0,0 +1,155 @@
"""Fetch, parse, and merge per-user calendar feeds -- ICS subscriptions
and (via caldav_client.py) CalDAV collections -- for calendar frame mode
(see routers/device.py's RENDERERS["calendar"] and calendar_render.py).
Pure functions -- no ORM, no FastAPI Depends. Callers (routers/common.py's
get_or_refresh_calendar_events) supply plain CalendarSource values, not
ORM objects, so this module stays testable against fixture .ics text with
no database or app involved.
Recurring events (RRULE/EXDATE/RDATE, DST-aware) are expanded via
recurring-ical-events rather than hand-rolled -- that's genuinely fiddly
to get right (see its own docs), not worth reinventing. It's LGPL-3.0 (an
ordinary runtime pip dependency, never vendored/modified -- see the
server README's Notes section for why that doesn't put this project's own
code under LGPL terms).
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime
import httpx
import icalendar
import recurring_ical_events
from . import caldav_client
HTTP_TIMEOUT_S = 15.0
FETCH_MAX_BYTES = 10 * 1024 * 1024 # sanity cap -- a real feed is KB, not MB
CHECK_INTERVAL_S = 20 * 60 # don't refetch/reparse any feed more often than this
# How far back/forward each merge-fetch expands recurring events. Households
# look back far less than they plan ahead, hence the asymmetry. Browsing
# outside this window (calendar_browse_offset) just yields an empty view,
# not an error -- self-heals on the next normal wake regardless.
EXPAND_WINDOW_PAST_DAYS = 30
EXPAND_WINDOW_FUTURE_DAYS = 200
class CalendarFetchError(Exception):
"""One feed was unreachable, not valid ICS, or too large. Raised by
fetch_source_events(); merge_events() is what catches this per-source
so one broken feed can't blank out another's events."""
def fetch_source_events(url: str, window_start: date, window_end: date) -> list[dict]:
"""One feed: download, parse, expand recurrences within
[window_start, window_end]. Raises CalendarFetchError on any problem
-- network, malformed ICS, or an oversized response."""
try:
with httpx.stream("GET", url, timeout=HTTP_TIMEOUT_S, follow_redirects=True) as resp:
resp.raise_for_status()
chunks = []
total = 0
for chunk in resp.iter_bytes():
total += len(chunk)
if total > FETCH_MAX_BYTES:
raise CalendarFetchError(f"Feed exceeds {FETCH_MAX_BYTES} bytes")
chunks.append(chunk)
body = b"".join(chunks)
except httpx.HTTPError as e:
raise CalendarFetchError(str(e)) from e
try:
cal = icalendar.Calendar.from_ical(body)
occurrences = recurring_ical_events.of(cal).between(window_start, window_end)
except Exception as e: # icalendar/recurring_ical_events raise a mix of ValueError-family exceptions
raise CalendarFetchError(f"Could not parse ICS feed: {e}") from e
events = []
for occ in occurrences:
dtstart = occ.get("DTSTART")
dtend = occ.get("DTEND")
if dtstart is None:
continue
start_dt = dtstart.dt
end_dt = dtend.dt if dtend is not None else start_dt
all_day = not isinstance(start_dt, datetime) # date, not datetime -- VALUE=DATE
events.append({
"summary": str(occ.get("SUMMARY") or "(untitled)"),
"start": start_dt.isoformat(),
"end": end_dt.isoformat(),
"all_day": all_day,
})
return events
@dataclass(frozen=True)
class CalendarSource:
"""One calendar to merge in: either a plain ICS subscription (kind
"ics", url is the feed itself) or one CalDAV collection (kind
"caldav", url is the calendar's own URL, username/password its
account credentials) -- see caldav_client.py. owner_display_name
tags every event pulled from this source so a merged agenda can show
whose event is whose. color_index (2-5, into
image_pipeline.DEFAULT_PALETTE_RGB) is this calendar's manually
pinned color, or None to fall back on calendar_render.py's old
auto-cycle-by-owner-name behavior -- see models.FrameCalendar."""
owner_display_name: str
kind: str
url: str
username: str = ""
password: str = ""
color_index: int | None = None
def merge_events(
sources: list[CalendarSource], window_start: date, window_end: date
) -> tuple[list[dict], str]:
"""Fetches each source independently -- one broken feed never blanks
another's events. Returns (merged_time_sorted_events, fetch_summary);
fetch_summary is "" when every source succeeded, else "N of M
calendars unavailable" (never *which* source -- naming whose feed is
down to everyone who looks at a shared household display is a bigger
overshare than the outage itself).
Events sharing the exact same (summary, start, end, all_day) across
different calendars -- e.g. a shared family event synced onto more
than one person's calendar -- collapse into one entry rather than
showing as duplicate rows. Every merged event carries a "sources"
list ([{"owner_display_name", "color_index"}, ...], length 1 for an
ordinary non-duplicated event) that calendar_render.py draws a
color indicator per entry of, so a collapsed event still visibly
shows every calendar it came from."""
merged: list[dict] = []
by_key: dict[tuple, dict] = {}
failures = 0
for source in sources:
try:
if source.kind == "caldav":
events = caldav_client.fetch_calendar_events(
source.url, source.username, source.password, window_start, window_end
)
else:
events = fetch_source_events(source.url, window_start, window_end)
except (CalendarFetchError, caldav_client.CalDavError):
failures += 1
continue
for event in events:
source_entry = {"owner_display_name": source.owner_display_name, "color_index": source.color_index}
key = (event["summary"], event["start"], event["end"], event["all_day"])
existing = by_key.get(key)
if existing is None:
event["sources"] = [source_entry]
by_key[key] = event
merged.append(event)
else:
existing["sources"].append(source_entry)
merged.sort(key=lambda e: e["start"])
summary = f"{failures} of {len(sources)} calendars unavailable" if failures else ""
return merged, summary
+347
View File
@@ -0,0 +1,347 @@
"""Calendar widget's "modern" render style -- all four view modes
(agenda/today_tomorrow/week/month), mirroring calendar_render.py's own
_build dispatch shape exactly so app/widgets/calendar.py and the
calendar preview endpoint can call either module identically. Kept in
its own module rather than joining app/html_render.py's other build_*
functions, mirroring calendar_render.py's own separation from the
simpler widgets (calendar is the one case where html_render.py growing
a 5th unrelated builder starts to hurt readability).
Reuses calendar_render's own private helpers (_events_on_day/_event_
colors/_event_start/_fmt_time/_weather_for_day/_month_view_fits/
_add_months) so a modern-style view's event list/colors/times/weather/
month-grid math match the classic renderer's data exactly -- only the
drawing differs, same relationship weather's build_current/build_daily
have with weather_render.py."""
from __future__ import annotations
import calendar as calendar_module
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
from PIL import Image
from . import html_render, panel_style, theme_tokens
from .calendar_render import (
MARGIN,
WEEKDAY_NAMES,
_add_months,
_event_colors,
_event_start,
_events_on_day,
_fmt_time,
_month_view_fits,
_weather_for_day,
)
def _weather_row(weather_cities, day, units) -> list[dict]:
entries = _weather_for_day(weather_cities, day)
return [
{"emoji": html_render.CATEGORY_EMOJI.get(e["category"], ""), "high": round(e["high"]), "low": round(e["low"])}
for e in entries
]
def _day_section_data(day: date, events: list[dict], tz: ZoneInfo, palette_rgb, weather_cities,
weather_units: str, owners_seen: list[str], rows_avail_h: int, row_h: int) -> dict:
"""One day's {header, weather_entries, rows, more_count} -- shared by
build_agenda/build_today_tomorrow/build_week's vertical layout, same
reuse relationship calendar_render._draw_agenda_day has with
_build_agenda/_build_today_tomorrow. `rows_avail_h` is the *rows*
area's own pixel budget only -- the caller has already reserved a
separate, uniform header_h (which is where weather actually renders,
see the day-header macro) for every section, so this function
doesn't need to account for weather space itself."""
header = day.strftime("%A, %B ") + str(day.day)
weather_entries = _weather_row(weather_cities, day, weather_units)
max_rows = max(0, rows_avail_h // row_h)
day_events = _events_on_day(events, day, tz)
rows = []
for event in day_events[:max_rows]:
colors = _event_colors(event, owners_seen, palette_rgb)
time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz))
rows.append({"colors": [html_render._rgb_to_hex(c) for c in colors], "time": time_str,
"summary": event["summary"]})
return {"header": header, "weather_entries": weather_entries, "rows": rows,
"more_count": max(0, len(day_events) - max_rows)}
def build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit", theme_name: str | None = None,
font_scale: float = 1.0) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_agenda.
Bold-minimal: no card/border/shadow (theme["radius"]/theme["shadow"]
are unused, same carve-out as weather's build_current/build_daily --
see docs/widgets.md). The day header is plain ink text under a slim
accent-colored rule instead of white text on a full gradient band --
only that thin rule dithers at the theme's richer accent_amplitude
now, not the header text sitting on top of it, which is a legibility
improvement over the old design, not just a visual one."""
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
day = datetime.now(tz).date() + timedelta(days=browse_offset)
title_size = panel_style.scaled_size(max(14, min(target_w, target_h) // 12), font_scale)
body_size = panel_style.scaled_size(max(11, min(target_w, target_h) // 20), font_scale)
weather_size = max(10, body_size - 2)
row_h = body_size + 14
unit_suffix = "F" if weather_units == "fahrenheit" else "C"
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.025, 4, 8))
# Single day -- no cross-section alignment concern, so header_h can
# simply reflect whether THIS day actually has weather (unlike
# build_today_tomorrow/build_week's vertical layout, which must
# reserve the same header_h for every stacked section regardless).
has_weather = bool(_weather_row(weather_cities, day, weather_units))
header_h = accent_h + 10 + title_size + ((weather_size + 10) if has_weather else 0)
owners_seen: list[str] = []
data = _day_section_data(day, events, tz, palette_rgb, weather_cities, weather_units, owners_seen,
target_h - header_h - MARGIN, row_h)
template = html_render._jinja_env.get_template("calendar_agenda.html.jinja")
html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER,
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
header=data["header"], title_size=title_size, header_h=header_h, accent_h=accent_h,
accent_start=theme["accent_hex"], weather_entries=data["weather_entries"],
weather_size=weather_size, unit_suffix=unit_suffix, rows=data["rows"],
more_count=data["more_count"], row_h=row_h, body_size=body_size,
)
rendered = html_render.render_html_to_image(html, target_w, target_h)
gutter = panel_style.GUTTER
accent_rect = (gutter, gutter, target_w - gutter, gutter + accent_h)
return html_render.ordered_dither_regions(
rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])]
)
def build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit", theme_name: str | None = None,
font_scale: float = 1.0) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_today_tomorrow
-- two day-sections stacked (see _day_section_data). Bold-minimal, no
card (see build_agenda's docstring) -- each section's own slim accent
rule dithers richer via ordered_dither_regions, not its header text."""
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
start_day = datetime.now(tz).date() + timedelta(days=browse_offset)
section_h = target_h // 2
title_size = panel_style.scaled_size(max(13, section_h // 8), font_scale)
body_size = panel_style.scaled_size(max(10, min(target_w, target_h) // 26), font_scale)
weather_size = max(9, body_size - 2)
row_h = body_size + 12
unit_suffix = "F" if weather_units == "fahrenheit" else "C"
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.02, 3, 6))
day_dates = [start_day + timedelta(days=i) for i in range(2)]
# Uniform across both stacked sections regardless of which day(s)
# actually have weather -- see _day_section_data's own docstring for
# why a per-day header height misaligns where rows start.
any_weather = any(_weather_row(weather_cities, d, weather_units) for d in day_dates)
header_h = accent_h + 8 + title_size + ((weather_size + 8) if any_weather else 0)
owners_seen: list[str] = []
days = [
_day_section_data(d, events, tz, palette_rgb, weather_cities, weather_units, owners_seen,
section_h - header_h, row_h)
for d in day_dates
]
template = html_render._jinja_env.get_template("calendar_today_tomorrow.html.jinja")
html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER,
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
days=days, title_size=title_size, header_h=header_h, accent_h=accent_h,
accent_start=theme["accent_hex"], weather_size=weather_size,
unit_suffix=unit_suffix, row_h=row_h, body_size=body_size,
)
rendered = html_render.render_html_to_image(html, target_w, target_h)
gutter = panel_style.GUTTER
accent_regions = [
((gutter, gutter + i * section_h, target_w - gutter, gutter + i * section_h + accent_h),
theme["accent_amplitude"])
for i in range(len(days))
]
return html_render.ordered_dither_regions(rendered, palette_rgb, accent_regions=accent_regions)
def build_week(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
week_start: int, palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit", days: int = 7, layout: str = "horizontal",
start_offset: int = 0, theme_name: str | None = None, font_scale: float = 1.0) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_week -- both
the vertical (stacked day-sections, reusing build_today_tomorrow's
template with an arbitrary day count) and horizontal (side-by-side
columns) layouts. Each header (per-section or per-column) dithers
richer via ordered_dither_regions."""
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
gutter = panel_style.GUTTER
today = datetime.now(tz).date()
if days == 7:
days_since_start = (today.weekday() - week_start) % 7
week_first_day = today - timedelta(days=days_since_start) + timedelta(days=days * browse_offset)
else:
week_first_day = today + timedelta(days=start_offset) + timedelta(days=days * browse_offset)
unit_suffix = "F" if weather_units == "fahrenheit" else "C"
owners_seen: list[str] = []
if layout == "vertical":
section_h = target_h // days
title_size = panel_style.scaled_size(max(11, min(20, section_h // 6)), font_scale)
body_size = panel_style.scaled_size(max(9, min(target_w, target_h) // (18 + days)), font_scale)
weather_size = max(8, body_size - 2)
row_h = body_size + 10
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.018, 3, 5))
day_dates = [week_first_day + timedelta(days=i) for i in range(days)]
# Uniform across all `days` stacked sections -- see
# _day_section_data's own docstring for why a per-day header
# height misaligns where rows start.
any_weather = any(_weather_row(weather_cities, d, weather_units) for d in day_dates)
header_h = accent_h + 6 + title_size + ((weather_size + 6) if any_weather else 0)
day_sections = [
_day_section_data(d, events, tz, palette_rgb, weather_cities, weather_units, owners_seen,
section_h - header_h, row_h)
for d in day_dates
]
template = html_render._jinja_env.get_template("calendar_today_tomorrow.html.jinja")
html = template.render(
w=target_w, h=target_h, gutter=gutter,
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
days=day_sections, title_size=title_size, header_h=header_h, accent_h=accent_h,
accent_start=theme["accent_hex"], weather_size=weather_size,
unit_suffix=unit_suffix, row_h=row_h, body_size=body_size,
)
rendered = html_render.render_html_to_image(html, target_w, target_h)
accent_regions = [
((gutter, gutter + i * section_h, target_w - gutter, gutter + i * section_h + accent_h),
theme["accent_amplitude"])
for i in range(len(day_sections))
]
return html_render.ordered_dither_regions(rendered, palette_rgb, accent_regions=accent_regions)
header_size = panel_style.scaled_size(max(10, min(16, (target_w // days) // 6)), font_scale)
chip_size = max(9, header_size - 3)
weather_size = max(8, chip_size - 1)
col_w = max(1, (target_w - panel_style.GUTTER * 2) // days)
row_h = chip_size + 8
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.02, 3, 6))
# Reserve weather-line room in every column's header uniformly
# (whether or not THIS specific day has a cached forecast) -- a
# per-column height that depends on that day's own data would
# misalign where each column's event rows start across the week
# grid the moment any single day lacks a forecast entry.
header_h = header_size + 8 + (weather_size + 4 if weather_cities else 0)
max_rows = max(0, (target_h - panel_style.GUTTER * 2 - accent_h - 6 - header_h) // row_h)
cols = []
for i in range(days):
day = week_first_day + timedelta(days=i)
label = day.strftime("%a %-d") if day != today else f"{day.strftime('%a %-d')}"
weather_entries = _weather_row(weather_cities, day, weather_units)
day_events = _events_on_day(events, day, tz)
rows = []
for event in day_events[:max_rows]:
color = html_render._rgb_to_hex(_event_colors(event, owners_seen, palette_rgb)[0])
summary = event["summary"] if event["all_day"] else f"{_fmt_time(_event_start(event, tz))[:-3]} {event['summary']}"
rows.append({"color": color, "summary": summary})
cols.append({
"label": label, "weather": weather_entries[0] if weather_entries else None,
"rows": rows, "more_count": max(0, len(day_events) - max_rows),
})
template = html_render._jinja_env.get_template("calendar_week_horizontal.html.jinja")
html = template.render(
w=target_w, h=target_h, gutter=gutter,
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
cols=cols, header_size=header_size, chip_size=chip_size,
header_h=header_h, accent_h=accent_h, weather_size=weather_size, unit_suffix=unit_suffix,
accent_start=theme["accent_hex"],
)
rendered = html_render.render_html_to_image(html, target_w, target_h)
accent_rect = (gutter, gutter, target_w - gutter, gutter + accent_h)
return html_render.ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
def build_month(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
week_start: int, palette_rgb: list | None = None, theme_name: str | None = None,
font_scale: float = 1.0) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_month --
density dots per day, not literal event text, same reasoning as the
classic renderer (real text at typical month-cell size is close to
unreadable on a 6-color dithered e-ink panel). The per-owner event
dots are identity-coding (like every other calendar view's chips) and
are never touched by a theme.
Bold-minimal: no card (see build_agenda's docstring); the old flat
accent-colored weekday-name band is now a slim accent rule above
plain bold weekday labels, matching every other calendar view's
header treatment -- only that rule dithers at the theme's richer
accent_amplitude via ordered_dither_regions. "Today" is still called
out with a small accent-filled pill around its day number (a
genuinely small accent surface, not a band, so it was left alone)."""
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
gutter = panel_style.GUTTER
today = datetime.now(tz).date()
target_month = _add_months(date(today.year, today.month, 1), browse_offset)
weeks_dates = list(
calendar_module.Calendar(firstweekday=week_start).monthdatescalendar(target_month.year, target_month.month)
)
day_names = [n[:3] for n in (WEEKDAY_NAMES[week_start:] + WEEKDAY_NAMES[:week_start])]
header_size = panel_style.scaled_size(max(11, min(16, target_h // 30)), font_scale)
day_size = panel_style.scaled_size(max(10, min(15, target_w // 55)), font_scale)
dot_size = max(4, day_size // 2)
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.02, 3, 6))
owners_seen: list[str] = []
weeks = []
for week in weeks_dates:
row = []
for day in week:
day_events = _events_on_day(events, day, tz)
dots = [html_render._rgb_to_hex(_event_colors(e, owners_seen, palette_rgb)[0]) for e in day_events[:4]]
row.append({
"day_num": day.day, "in_month": day.month == target_month.month,
"is_today": day == today, "dots": dots, "more_count": max(0, len(day_events) - 4),
})
weeks.append(row)
template = html_render._jinja_env.get_template("calendar_month.html.jinja")
html = template.render(
w=target_w, h=target_h, gutter=gutter,
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
day_names=day_names, weeks=weeks, accent_h=accent_h,
header_size=header_size, day_size=day_size, dot_size=dot_size, accent_start=theme["accent_hex"],
)
rendered = html_render.render_html_to_image(html, target_w, target_h)
accent_rect = (gutter, gutter, target_w - gutter, gutter + accent_h)
return html_render.ordered_dither_regions(rendered, palette_rgb,
accent_regions=[(accent_rect, theme["accent_amplitude"])])
def build(events: list[dict], view: str, browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
week_start: int, palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit", week_days: int = 7, week_layout: str = "horizontal",
week_start_offset: int = 0, theme_name: str | None = None, font_scale: float = 1.0) -> Image.Image:
"""Dispatches to the right build_* -- mirrors calendar_render._build's
exact "month falls back to agenda when it doesn't fit" resolution, so
a narrow month-mode widget set to modern style still gets a sensible
modern view instead of erroring or silently reverting to classic."""
effective_view = view
if view == "month" and not _month_view_fits(target_w, target_h):
effective_view = "agenda"
if effective_view == "agenda":
return build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities,
weather_units, theme_name, font_scale)
if effective_view == "today_tomorrow":
return build_today_tomorrow(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities,
weather_units, theme_name, font_scale)
if effective_view == "week":
return build_week(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, weather_cities,
weather_units, week_days, week_layout, week_start_offset, theme_name, font_scale)
return build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, theme_name, font_scale)
+888
View File
@@ -0,0 +1,888 @@
"""Renders calendar frame mode's three views (agenda/week/month), and the
separate standalone tasks widget (see models.TaskWidgetConfig -- a task
list used to be a calendar-widget-only week-view slot, split out into
its own widget type so it isn't tied to a calendar's view/footprint),
into the panel's packed format, following image_pipeline.
render_placeholder's own precedent: build an RGB canvas with
ImageDraw/ImageFont, then the same _quantize/_transpose_and_pack every
other renderer ends on.
Event dicts here are calendar_feed.py's shape: {"summary", "start", "end"
(ISO 8601 strings), "all_day", "sources": [{"owner_display_name",
"color_index"}, ...]} -- more than one entry in "sources" means
merge_events collapsed several calendars' identical (same title/time)
events into one, see _event_colors/panel_style.draw_color_chip below.
"""
from __future__ import annotations
import calendar as calendar_module
import io
import re
from datetime import date, datetime, timedelta
from functools import lru_cache
from pathlib import Path
from zoneinfo import ZoneInfo
from PIL import Image, ImageDraw, ImageFont
from . import panel_style
from .image_pipeline import (
DEFAULT_PALETTE_RGB,
EPD_HEIGHT,
EPD_WIDTH,
_apply_manage_overlay,
_quantize,
_transpose_and_pack,
draw_text,
logical_render_size,
)
from .weather import weather_category
from .weather_render import draw_weather_row
CALENDAR_VIEWS = ["agenda", "today_tomorrow", "week", "month"]
CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "today_tomorrow": "Agenda (today & tomorrow)",
"week": "Week", "month": "Month"}
WEEKDAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
# MARGIN carries panel_style.CONTENT_MARGIN's value unchanged (not
# re-tuned). BG/FG are this module's own plain black/white -- checkbox
# outlines, month-view grid hairlines -- not a text-emphasis concern (no
# MUTED gray here anymore -- see panel_style's module docstring for why:
# a mid-gray fill has no close palette match and dithers into speckle
# once the whole canvas is quantized. Secondary text now reads through
# size/weight alone, always exact black).
MARGIN = panel_style.CONTENT_MARGIN
BG = (255, 255, 255)
FG = (0, 0, 0)
# Structural dividers/grid lines (between stacked day sections, week
# columns, month cells) stay a plain black rule -- gray dithers away to
# near-invisible once quantized to the 6-color e-ink palette. Headers
# no longer use this: see panel_style.draw_header_bar/theme_color.
RULE = (0, 0, 0)
# Fallback for any event whose calendar has no manually pinned color
# (event["color_index"] is None): cycled per distinct owner_display_name
# so a merged multi-person calendar can still visually tell whose event
# is whose -- the panel's own non-black/white ink colors, skipping
# black/white (index 0/1 in DEFAULT_PALETTE_RGB) since those are already
# the page's text/background.
OWNER_COLORS = DEFAULT_PALETTE_RGB[2:]
def _event_colors(event: dict, owners_seen: list[str], palette_rgb: list | None) -> list[tuple[int, int, int]]:
"""One color per contributing calendar (event["sources"] -- see
calendar_feed.merge_events, which collapses events sharing the exact
same title/time across different calendars into one entry with
several sources, e.g. a shared family event synced onto more than
one person's calendar). Usually just one color; more than one is
what tells the "same event, more than one calendar" case apart from
an ordinary single-calendar event at render time -- see
panel_style.draw_color_chip. Each source's own manually pinned color
(FrameCalendar.color_index -- see routers/api_widgets.py's
api_widget_calendar_color) resolves against whichever palette this frame
actually renders with, so a pinned "Blue" stays this frame's actual
blue; a source with no color pinned falls back to the old
auto-cycle-by-owner-name behavior. owners_seen is shared across every
event/source in a render so that cycle stays consistent view-wide."""
sources = event.get("sources") or [
{"owner_display_name": event.get("owner_display_name"), "color_index": event.get("color_index")}
]
colors = []
for source in sources:
color_index = source.get("color_index")
if color_index is not None:
palette = palette_rgb or DEFAULT_PALETTE_RGB
colors.append(tuple(palette[color_index]))
continue
owner_display_name = source.get("owner_display_name")
if owner_display_name not in owners_seen:
owners_seen.append(owner_display_name)
colors.append(OWNER_COLORS[owners_seen.index(owner_display_name) % len(OWNER_COLORS)])
return colors
def _event_start(event: dict, tz: ZoneInfo) -> datetime | date:
"""Parses event["start"] and, for timed events, converts to `tz` --
calendar_feed.py stores whatever timezone each source event carried
(often UTC), but display/bucketing needs to happen in the frame's own
timezone."""
dt = datetime.fromisoformat(event["start"])
if event["all_day"]:
return dt if isinstance(dt, date) and not isinstance(dt, datetime) else dt.date()
return dt.astimezone(tz)
def _events_on_day(events: list[dict], day: date, tz: ZoneInfo) -> list[dict]:
on_day = [e for e in events if _local_date(e, tz) == day]
on_day.sort(key=lambda e: (not e["all_day"], e["start"]))
return on_day
def _local_date(event: dict, tz: ZoneInfo) -> date:
start = _event_start(event, tz)
return start if isinstance(start, date) and not isinstance(start, datetime) else start.date()
def _add_months(d: date, months: int) -> date:
total = d.month - 1 + months
year = d.year + total // 12
month = total % 12 + 1
day = min(d.day, calendar_module.monthrange(year, month)[1])
return date(year, month, day)
def _fmt_time(dt: datetime) -> str:
text = dt.strftime("%I:%M %p").lstrip("0")
return text if text else "12:00 AM"
def _fmt_task_due(due: str | None) -> str:
""""2026-07-25" or "2026-07-25T14:00:00+00:00" -> "Jul 25" -- tasks
only need a compact reminder of when they're due, not the precision
an event's own start/end time gets."""
if not due:
return ""
try:
dt = datetime.fromisoformat(due)
except ValueError:
return ""
d = dt.date() if isinstance(dt, datetime) else dt
return d.strftime("%b %-d")
# Neither Inter (panel_style.font_bold/font_regular, this module's own
# body/title font -- see MARGIN/BG/FG comment above) nor PIL's bundled
# default font has emoji glyphs, and PIL/FreeType don't skip an
# unsupported codepoint, they substitute a ".notdef" tofu box (a visible
# filled rectangle) -- reads as a rendering glitch, not "emoji not
# supported". So event titles get drawn with two fonts: the normal
# text font for everything else, and one of these for actual emoji runs
# (see _split_emoji_runs/_draw_mixed_line) -- both Noto Emoji, OFL-1.1,
# vendored at app/fonts/ (license alongside at app/fonts/OFL.txt).
#
# Color (NotoColorEmoji.ttf) is tried first: full-color CBDT bitmap
# glyphs, which the panel's own Floyd-Steinberg dithering turns into a
# recognizable (if slightly speckled) color rendering rather than a flat
# monochrome shape -- confirmed by actually rendering a test agenda row
# through the real quantizer, not just theorizing about it. Its one
# real quirk: CBDT stores glyphs at a single embedded bitmap size
# (_COLOR_EMOJI_NATIVE_SIZE), so every glyph is rasterized once at that
# size and scaled down to the target row height, unlike normal vector
# text which draws directly at whatever size is asked for.
#
# NotoEmoji.ttf (monochrome, vector) is the fallback for a deployment
# whose Pillow/FreeType wasn't built with embedded color bitmap support
# -- confirmed working locally, but that's a build-time detail this
# project doesn't control everywhere it might run, so a rendering
# failure falls back instead of showing nothing/crashing.
_COLOR_EMOJI_FONT_PATH = Path(__file__).parent / "fonts" / "NotoColorEmoji.ttf"
_MONO_EMOJI_FONT_PATH = Path(__file__).parent / "fonts" / "NotoEmoji.ttf"
_COLOR_EMOJI_NATIVE_SIZE = 109
@lru_cache(maxsize=1)
def _color_emoji_font() -> ImageFont.FreeTypeFont | None:
try:
return ImageFont.truetype(str(_COLOR_EMOJI_FONT_PATH), _COLOR_EMOJI_NATIVE_SIZE)
except Exception:
return None
@lru_cache(maxsize=None)
def _mono_emoji_font(size: int) -> ImageFont.FreeTypeFont:
return ImageFont.truetype(str(_MONO_EMOJI_FONT_PATH), size)
@lru_cache(maxsize=512)
def _emoji_glyph(run_text: str, target_h: int) -> Image.Image:
"""One emoji run (consecutive emoji collapse into a single run, see
_split_emoji_runs) as an RGBA image target_h tall, ready to
alpha-composite onto the canvas. Tries color first, falls back to
monochrome (rendered directly at target_h, since that font is
vector) if the color font failed to load or this Pillow/FreeType
build can't decode its embedded bitmaps. Cached -- the same emoji
recurs across a household's events, and rasterizing+scaling isn't
free."""
color_font = _color_emoji_font()
if color_font is not None:
try:
probe = ImageDraw.Draw(Image.new("RGBA", (1, 1)))
raw_w = max(1, round(probe.textlength(run_text, font=color_font)))
tmp = Image.new("RGBA", (raw_w, _COLOR_EMOJI_NATIVE_SIZE), (255, 255, 255, 0))
ImageDraw.Draw(tmp).text((0, 0), run_text, font=color_font, embedded_color=True)
scale = target_h / _COLOR_EMOJI_NATIVE_SIZE
return tmp.resize((max(1, round(raw_w * scale)), target_h), Image.LANCZOS)
except Exception:
pass # this deployment's Pillow can't render embedded color bitmaps -- fall back
mono_font = _mono_emoji_font(target_h)
bbox = mono_font.getbbox(run_text)
w, h = max(1, bbox[2] - bbox[0]), max(1, bbox[3] - bbox[1])
mask = Image.new("L", (w, h), 0)
ImageDraw.Draw(mask).text((-bbox[0], -bbox[1]), run_text, fill=255, font=mono_font)
glyph = Image.new("RGBA", (w, h), (255, 255, 255, 0))
glyph.paste((0, 0, 0, 255), (0, 0), mask)
return glyph
# Matches runs of actual emoji base characters (the standard Unicode
# emoji blocks -- stable ranges even as new individual emoji get added
# within them, so this doesn't need updating as emoji sets grow).
_EMOJI_PATTERN = re.compile(
"["
"\U0001F1E6-\U0001F1FF" # regional indicator symbols (flag emoji)
"\U0001F300-\U0001F5FF" # misc symbols & pictographs
"\U0001F600-\U0001F64F" # emoticons
"\U0001F680-\U0001F6FF" # transport & map symbols
"\U0001F900-\U0001F9FF" # supplemental symbols & pictographs
"\U0001FA70-\U0001FAFF" # symbols & pictographs extended-A
"\U00002600-\U000026FF" # misc symbols (☀☂☕ etc.)
"\U00002700-\U000027BF" # dingbats (✂✈✉ etc.)
"]+"
)
_EMOJI_SPLIT_PATTERN = re.compile(f"({_EMOJI_PATTERN.pattern})")
# Codepoints with no meaningful standalone glyph once color/ligature
# context is dropped: skin-tone modifiers (this is monochrome -- no
# color to modify), the variation selector that just requests emoji
# presentation, and the zero-width joiner used to fuse multiple emoji
# into one combined glyph. That fusion (e.g. the "family" emoji from
# four base emoji + 3 ZWJs) needs OpenType ligature substitution
# (raqm/harfbuzz), which Pillow only does with a specific, non-default
# build -- not something to depend on. Stripping the ZWJ instead means a
# ZWJ sequence just draws as its individual base glyphs side by side
# (four separate people instead of one family glyph) -- a real fallback,
# not a crash or tofu.
_EMOJI_MODIFIER_PATTERN = re.compile("[\U0001F3FB-\U0001F3FF\U0000FE0F\U0000200D]")
def _split_emoji_runs(text: str) -> list[tuple[str, bool]]:
"""text -> [(run, is_emoji), ...], modifier/joiner codepoints
dropped first (see _EMOJI_MODIFIER_PATTERN). Consecutive emoji
collapse into one run (_EMOJI_PATTERN's own "+"), consecutive
plain-text characters into the other."""
cleaned = _EMOJI_MODIFIER_PATTERN.sub("", text)
parts = [p for p in _EMOJI_SPLIT_PATTERN.split(cleaned) if p]
return [(p, bool(_EMOJI_PATTERN.fullmatch(p))) for p in parts]
def _draw_mixed_line(img: Image.Image, draw: ImageDraw.ImageDraw, xy: tuple[int, int], text: str,
text_font: ImageFont.ImageFont, max_width: int, fill: tuple[int, int, int] = FG) -> None:
"""Draws `text` left-to-right, switching between text_font (normal
characters) and an emoji glyph image (actual emoji runs, per
_split_emoji_runs/_emoji_glyph) so emoji visibly render instead of a
tofu box. Truncates with "..." once max_width is exceeded -- unlike
_truncate_to_width this can't binary-search a single font's metrics
across mixed fonts/images, so it works run-by-run instead (and can't
partially truncate an emoji run the way it can a text run -- one
that doesn't fit just isn't drawn). Fine for the short single-line
strings this draws (event/task titles), not meant as a general
rich-text layout engine. `fill` only affects text runs -- emoji
glyphs are already their own color."""
x, y = xy
cursor = x
# A little taller than text_font's own size so glyphs don't look
# cramped next to it; the -2 paste offset below roughly centers that
# against the surrounding text's row -- tuned by eye against a real
# rendered agenda row, not derived from font metrics.
emoji_h = text_font.size + 6
for run_text, is_emoji in _split_emoji_runs(text):
remaining = max_width - (cursor - x)
if remaining <= 0:
break
if is_emoji:
glyph = _emoji_glyph(run_text, emoji_h)
if glyph.width <= remaining:
img.paste(glyph, (round(cursor), y - 2), glyph)
cursor += glyph.width
else:
break
else:
run_w = draw.textlength(run_text, font=text_font)
if run_w <= remaining:
draw_text(img, (round(cursor), y), run_text, text_font, fill)
cursor += run_w
else:
draw_text(img, (round(cursor), y), _truncate_to_width(draw, run_text, text_font, remaining),
text_font, fill)
break
def _truncate_to_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont, max_width: int) -> str:
"""Pixel-width-aware truncation (unlike device.py's char-count
_truncate, tuned for a fixed firmware font at a fixed size) -- this
module draws at several different sizes, so truncation has to
measure the actual font/size in play. Still uses `draw.textlength`
for measurement (identical metrics to draw_text's own bbox), just
doesn't paint anything."""
if draw.textlength(text, font=font) <= max_width:
return text
ellipsis = "..."
lo, hi = 0, len(text)
while lo < hi:
mid = (lo + hi + 1) // 2
if draw.textlength(text[:mid] + ellipsis, font=font) <= max_width:
lo = mid
else:
hi = mid - 1
return text[:lo] + ellipsis if lo else ellipsis
# --- Size tiers ---------------------------------------------------------
#
# A calendar widget can now be placed at any grid footprint (see
# app/grid.py), not just the full panel -- these three discrete tiers
# (chosen by nearest-fit against the target box's pixel area) drive font
# sizes/margins instead of continuously scaling a layout that was tuned
# by eye for the full ~800x480 panel, which would risk ugly proportions
# at odd in-between sizes. Area-based (not width/height-based) so the
# same footprint tiers the same regardless of landscape/portrait target
# box shape.
_TIER_LARGE_AREA = 280_000 # near/at a full 800x480 panel (384,000px^2)
_TIER_MEDIUM_AREA = 120_000 # roughly a half-panel split
def _size_tier(target_w: int, target_h: int) -> str:
area = target_w * target_h
if area >= _TIER_LARGE_AREA:
return "large"
if area >= _TIER_MEDIUM_AREA:
return "medium"
return "small"
def _month_view_fits(target_w: int, target_h: int) -> bool:
"""Month view needs real width to keep 7 columns' day numbers and
density dots legible -- below the "small" size tier that stops being
true, so _build falls back to agenda view instead of drawing an
unreadable grid."""
return _size_tier(target_w, target_h) != "small"
# --- Weather strip, agenda/today & tomorrow/week views only (never
# month -- see _BUILDERS/_build) --------------------------------------
def _weather_for_day(weather_cities: list[dict] | None, day: date) -> list[dict]:
"""[{"label", "code", "high", "low", "category"}, ...] for every
configured city that has a cached forecast for this specific date --
weather_cities is routers/common.py's get_or_refresh_weather() cache
shape, [{"label", "days": {"YYYY-MM-DD": {"code","high","low"}}}]."""
if not weather_cities:
return []
key = day.isoformat()
entries = []
for city in weather_cities:
d = (city.get("days") or {}).get(key)
if d is None:
continue
# Just the city name on-panel ("Portland", not the full
# disambiguated "Portland, Oregon, United States") -- that fuller
# form matters for telling apart geocoder candidates when adding
# a city (see weather.geocode_city), not for a compact display row.
entries.append({"label": city["label"].split(",")[0].strip(), "high": d["high"], "low": d["low"],
"category": weather_category(d["code"])})
return entries
def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, events: list[dict], tz: ZoneInfo,
region: tuple[int, int, int, int], title_font: ImageFont.ImageFont,
body_font: ImageFont.ImageFont, owners_seen: list[str], palette_rgb: list | None = None,
weather_cities: list[dict] | None = None, weather_font: ImageFont.ImageFont | None = None,
weather_units: str = "fahrenheit") -> None:
"""Draws one day's header + weather strip (if any) + event rows
within `region` (x0, y0, w, h) -- factored out of _build_agenda so
the today-and-tomorrow view (_build_today_tomorrow) can stack two of
these vertically without duplicating the row-layout/truncation
logic. Weather is drawn above the event list -- eating into the same
row budget the event count is truncated against, exactly like the
header bar above it already does."""
x0, y0, w, h = region
header_h = title_font.size + 20
panel_style.draw_header_bar(draw, (x0, y0, w, header_h), header_h,
panel_style.theme_color("calendar", palette_rgb))
text_x0 = x0 + MARGIN
text_w = w - MARGIN * 2
header = day.strftime("%A, %B ") + str(day.day)
draw_text(img, (text_x0, y0 + (header_h - title_font.size) // 2),
_truncate_to_width(draw, header, title_font, text_w), title_font, BG)
y = y0 + header_h + 12
weather_entries = _weather_for_day(weather_cities, day)
if weather_entries:
y += draw_weather_row(img, draw, text_x0, y, text_w, weather_entries,
icon_r=title_font.size // 2, font=weather_font or body_font, units=weather_units,
palette_rgb=palette_rgb)
day_events = _events_on_day(events, day, tz)
row_h = body_font.size + 14
max_rows = max(0, (y0 + h - MARGIN - y) // row_h)
if not day_events:
draw_text(img, (text_x0, y), "Nothing scheduled", body_font)
for i, event in enumerate(day_events):
if i >= max_rows:
draw_text(img, (text_x0, y), f"+{len(day_events) - max_rows} more", body_font)
break
colors = _event_colors(event, owners_seen, palette_rgb)
panel_style.draw_color_chip(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors)
time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz))
prefix = f"{time_str} "
draw_text(img, (text_x0 + 18, y), prefix, body_font)
prefix_w = draw.textlength(prefix, font=body_font)
_draw_mixed_line(img, draw, (text_x0 + 18 + prefix_w, y), event["summary"],
body_font, text_w - 18 - prefix_w)
y += row_h
def _draw_tasks(img: Image.Image, draw: ImageDraw.ImageDraw, region: tuple[int, int, int, int],
tasks: list[dict], title_font: ImageFont.ImageFont, body_font: ImageFont.ImageFont,
palette_rgb: list | None = None, title: str = "Tasks") -> None:
"""A simple checklist filling `region` (x0, y0, w, h) -- a header
(`title`, truncated to fit -- TaskWidgetConfig.name or the "Tasks"
default; the only widget type with its own on-panel title, since
it's the only one where "which list is this" isn't obvious from its
content the way a calendar/photo/whiteboard's is), then a color chip
(reusing _event_colors/panel_style.draw_color_chip as-is: a task
dict's top-level owner_display_name/color_index is exactly
_event_colors' single-source fallback shape, since caldav_client.
merge_tasks doesn't cross-list-dedup tasks into a "sources" list the
way merge_events dedups events) + checkbox glyph + due date (if any)
+ summary per task, same header/row-cap/truncation shape as
_draw_agenda_day's event list so the standalone tasks widget (see
_build_tasks) reads as the same consistent design as everything
else on-panel, not a bolted-together look. Reuses _draw_mixed_line
so a task summary with emoji in it renders the same way an event
title's does.
Outstanding tasks get an empty checkbox; completed ones (only ever
present when TaskWidgetConfig.show_completed is on -- see
caldav_client.fetch_tasks' completed_since) get a filled checkbox in
this widget's own Green accent (see panel_style.THEME) -- that fill
is the "done" signal, no due-date prefix (irrelevant once done) and
no separate muted text treatment (see module-level MUTED removal
note above _event_colors)."""
x0, y0, w, h = region
header_h = title_font.size + 20
panel_style.draw_header_bar(draw, (x0, y0, w, header_h), header_h,
panel_style.theme_color("tasks", palette_rgb))
text_x0 = x0 + MARGIN
text_w = w - MARGIN * 2
draw_text(img, (text_x0, y0 + (header_h - title_font.size) // 2),
_truncate_to_width(draw, title or "Tasks", title_font, text_w), title_font, BG)
y = y0 + header_h + 12
row_h = body_font.size + 14
max_rows = max(0, (y0 + h - MARGIN - y) // row_h)
if not tasks:
draw_text(img, (text_x0, y), "Nothing outstanding", body_font)
return
owners_seen: list[str] = []
checkbox_fill = panel_style.theme_color("tasks", palette_rgb)
for i, task in enumerate(tasks):
if i >= max_rows:
draw_text(img, (text_x0, y), f"+{len(tasks) - max_rows} more", body_font)
break
done = task.get("completed_at") is not None
colors = _event_colors(task, owners_seen, palette_rgb)
panel_style.draw_color_chip(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors)
box = body_font.size - 6
box_x = text_x0 + 18
box_y = y + (row_h - box) // 2 - 5
box_r = min(panel_style.CHIP_RADIUS, box // 2)
if done:
draw.rounded_rectangle([box_x, box_y, box_x + box, box_y + box], radius=box_r, fill=checkbox_fill)
else:
draw.rounded_rectangle([box_x, box_y, box_x + box, box_y + box], radius=box_r, outline=FG, width=2)
text_x = box_x + box + 10
due_str = None if done else _fmt_task_due(task.get("due"))
prefix = f"{due_str} " if due_str else ""
if prefix:
draw_text(img, (text_x, y), prefix, body_font)
prefix_w = draw.textlength(prefix, font=body_font) if prefix else 0
_draw_mixed_line(img, draw, (round(text_x + prefix_w), y), task["summary"],
body_font, text_w - (text_x - text_x0) - prefix_w)
y += row_h
# Per-tier (title, body, weather) font sizes -- "Wednesday, July 22" at
# full size doesn't fit a narrow column, and a narrower box is exactly
# when a smaller font (rather than truncating to "Wednesday...") keeps
# the header actually informative.
_AGENDA_FONTS = {"large": (34, 22, 20), "medium": (24, 22, 16), "small": (18, 16, 13)}
def _build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit", font_scale: float = 1.0) -> Image.Image:
img, draw, region = panel_style.card_canvas(target_w, target_h)
title_size, body_size, weather_size = (
panel_style.scaled_size(v, font_scale) for v in _AGENDA_FONTS[_size_tier(target_w, target_h)]
)
title_font = panel_style.font_bold(title_size)
body_font = panel_style.font_regular(body_size)
weather_font = panel_style.font_regular(weather_size)
day = datetime.now(tz).date() + timedelta(days=browse_offset)
owners_seen: list[str] = []
_draw_agenda_day(img, draw, day, events, tz, region, title_font, body_font, owners_seen,
palette_rgb, weather_cities, weather_font, weather_units)
return img
_TODAY_TOMORROW_FONTS = {"large": (26, 18, 16), "medium": (20, 15, 13), "small": (15, 12, 10)}
def _build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit", font_scale: float = 1.0) -> Image.Image:
"""Two _draw_agenda_day sections stacked vertically (below each other
rather than side-by-side -- narrower than tall doesn't leave enough
width per day for the event-row text at smaller sizes). browse_offset
shifts the whole two-day window together, same "days" unit
_build_agenda already uses, so NEXT/BACK behaves identically across
both views."""
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
title_size, body_size, weather_size = (
panel_style.scaled_size(v, font_scale) for v in _TODAY_TOMORROW_FONTS[_size_tier(target_w, target_h)]
)
title_font = panel_style.font_bold(title_size)
body_font = panel_style.font_regular(body_size)
weather_font = panel_style.font_regular(weather_size)
start_day = datetime.now(tz).date() + timedelta(days=browse_offset)
section_h = ch // 2
owners_seen: list[str] = []
for i in range(2):
section_y0 = cy0 + i * section_h
if i > 0:
draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - MARGIN, section_y0)], fill=RULE)
_draw_agenda_day(img, draw, start_day + timedelta(days=i), events, tz,
(cx0, section_y0, cw, section_h), title_font, body_font, owners_seen,
palette_rgb, weather_cities, weather_font, weather_units)
return img
# Vertical layout's base (title, body, weather) sizes, before the
# per-day-count reduction below -- same three tiers as every other view.
_WEEK_VERTICAL_FONTS = {"large": (26, 18, 16), "medium": (20, 15, 13), "small": (16, 12, 10)}
# Horizontal layout's (header, chip, weather) sizes.
_WEEK_HORIZONTAL_FONTS = {"large": (18, 14, 12), "medium": (14, 12, 10), "small": (11, 10, 8)}
def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
week_start: int, palette_rgb: list | None = None,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
days: int = 7, layout: str = "horizontal",
start_offset: int = 0, font_scale: float = 1.0) -> Image.Image:
"""`days` (2-10, see routers/api_widgets.py's clamp) side-by-side
columns (layout="horizontal", the original fixed-at-7 behavior
generalized) or stacked bands (layout="vertical", reusing
_draw_agenda_day the same way _build_today_tomorrow does, just for
an arbitrary day count instead of a hardcoded 2).
At the default 7 days, the view anchors to week_start (a fixed
weekday, "start on the most recent Monday") exactly like before --
otherwise "start of the week" doesn't mean much for an arbitrary day
count, so it instead starts `start_offset` days from today (0 =
today, see routers/api_widgets.py's api_widget_config_save)."""
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
tier = _size_tier(target_w, target_h)
today = datetime.now(tz).date()
if days == 7:
days_since_start = (today.weekday() - week_start) % 7
week_first_day = today - timedelta(days=days_since_start) + timedelta(days=days * browse_offset)
else:
week_first_day = today + timedelta(days=start_offset) + timedelta(days=days * browse_offset)
owners_seen: list[str] = []
if layout == "vertical":
title_base, body_base, weather_base = _WEEK_VERTICAL_FONTS[tier]
title_font = panel_style.font_bold(panel_style.scaled_size(max(14, title_base - days), font_scale))
body_font = panel_style.font_regular(panel_style.scaled_size(max(11, body_base - days), font_scale))
weather_font = panel_style.font_regular(panel_style.scaled_size(max(9, weather_base - days), font_scale))
section_h = ch // days
for i in range(days):
section_y0 = cy0 + i * section_h
if i > 0:
draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - MARGIN, section_y0)], fill=RULE)
day = week_first_day + timedelta(days=i)
_draw_agenda_day(img, draw, day, events, tz, (cx0, section_y0, cw, section_h),
title_font, body_font, owners_seen, palette_rgb,
weather_cities, weather_font, weather_units)
return img
header_size, chip_size, weather_size = (
panel_style.scaled_size(v, font_scale) for v in _WEEK_HORIZONTAL_FONTS[tier]
)
header_font = panel_style.font_bold(header_size)
chip_font = panel_style.font_regular(chip_size)
weather_font = panel_style.font_regular(weather_size)
col_w = (cw - MARGIN * 2) // days
header_h = 44
for col in range(days):
day = week_first_day + timedelta(days=col)
x0 = cx0 + MARGIN + col * col_w
if col > 0:
draw.line([(x0, cy0 + MARGIN), (x0, cy0 + ch - MARGIN)], fill=RULE)
label = day.strftime("%a %-d") if day != today else f"* {day.strftime('%a %-d')}"
draw_text(img, (x0 + 6, cy0 + MARGIN), _truncate_to_width(draw, label, header_font, col_w - 10), header_font)
y = cy0 + MARGIN + header_h
# Columns are narrow, so only what actually fits gets drawn (see
# weather_render.draw_weather_row) -- typically one city, no label
# (the column itself makes which day it's for obvious; a city name
# wouldn't fit anyway). Never more than that -- this is already
# the tight view.
weather_entries = _weather_for_day(weather_cities, day)
if weather_entries:
y += draw_weather_row(img, draw, x0 + 4, y, col_w - 8, weather_entries,
icon_r=8, font=weather_font, units=weather_units, show_labels=False,
palette_rgb=palette_rgb)
row_h = chip_font.size + 10
max_rows = max(0, (cy0 + ch - MARGIN - y) // row_h)
day_events = _events_on_day(events, day, tz)
for i, event in enumerate(day_events):
if i >= max_rows:
draw_text(img, (x0 + 6, y), f"+{len(day_events) - max_rows}", chip_font)
break
colors = _event_colors(event, owners_seen, palette_rgb)
panel_style.draw_color_chip(draw, x0 + 4, y + 1, x0 + 11, y + row_h - 5, colors, radius=2)
if event["all_day"]:
_draw_mixed_line(img, draw, (x0 + 16, y), event["summary"], chip_font, col_w - 20)
else:
prefix = f"{_fmt_time(_event_start(event, tz))[:-3]} "
draw_text(img, (x0 + 16, y), prefix, chip_font)
prefix_w = draw.textlength(prefix, font=chip_font)
_draw_mixed_line(img, draw, (x0 + 16 + prefix_w, y), event["summary"],
chip_font, col_w - 20 - prefix_w)
y += row_h
return img
# Only "large"/"medium" in practice -- _build falls back to agenda view
# below the "small" tier (see _month_view_fits) -- but keyed defensively
# by tier rather than a bare bool so a future tier addition can't
# silently fall through to a KeyError here.
_MONTH_FONTS = {"large": (16, 18), "medium": (12, 13), "small": (12, 13)}
def _build_month(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
week_start: int, palette_rgb: list | None = None, font_scale: float = 1.0) -> Image.Image:
"""Density dots per day, not literal event text -- real text at
typical month-cell size (~100x70px) is close to unreadable on a
6-color dithered e-ink panel. Capped at 4 visible dots, "+N" beyond.
"Not in this month" day numbers used to be a muted gray -- now
de-emphasized by weight instead (Regular vs. Bold), same reasoning
as everywhere else this module dropped MUTED -- see module-level
comment above MARGIN/BG/FG."""
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
header_size, day_size = (
panel_style.scaled_size(v, font_scale) for v in _MONTH_FONTS[_size_tier(target_w, target_h)]
)
header_font = panel_style.font_bold(header_size)
day_font_in_month = panel_style.font_bold(day_size)
day_font_out_of_month = panel_style.font_regular(day_size)
today = datetime.now(tz).date()
target_month = _add_months(date(today.year, today.month, 1), browse_offset)
weeks = list(calendar_module.Calendar(firstweekday=week_start).monthdatescalendar(target_month.year, target_month.month))
col_w = (cw - MARGIN * 2) // 7
header_h = 28
grid_top = cy0 + MARGIN + header_h
row_h = (cy0 + ch - MARGIN - grid_top) // len(weeks)
today_accent = panel_style.theme_color("calendar", palette_rgb)
today_badge_r = min(panel_style.CHIP_RADIUS, 9)
day_names = WEEKDAY_NAMES[week_start:] + WEEKDAY_NAMES[:week_start]
for col, name in enumerate(day_names):
draw_text(img, (cx0 + MARGIN + col * col_w + 6, cy0 + MARGIN), name[:3], header_font)
owners_seen: list[str] = []
dot_r = 6
for row, week in enumerate(weeks):
for col, day in enumerate(week):
x0 = cx0 + MARGIN + col * col_w
y0 = grid_top + row * row_h
draw.rectangle([x0, y0, x0 + col_w, y0 + row_h], outline=RULE)
in_month = day.month == target_month.month
if day == today:
# A filled accent badge (this widget's own theme color,
# see panel_style.THEME) instead of the old bare outline
# -- an actual "today" indicator, not just an outline
# easy to miss at ~24px. Sized around the actual digit
# bbox (not a fixed pixel box) so a bold 2-digit day
# number ("30") fits as comfortably as a single digit
# ("3") at every size tier.
day_str = str(day.day)
text_x, text_y = x0 + 6, y0 + 4
dbbox = draw.textbbox((text_x, text_y), day_str, font=day_font_in_month)
pad = 3
badge_rect = [dbbox[0] - pad, dbbox[1] - pad, dbbox[2] + pad, dbbox[3] + pad]
badge_r = min(today_badge_r, (badge_rect[3] - badge_rect[1]) // 2)
draw.rounded_rectangle(badge_rect, radius=badge_r, fill=today_accent)
draw_text(img, (text_x, text_y), day_str, day_font_in_month, BG)
else:
day_font = day_font_in_month if in_month else day_font_out_of_month
draw_text(img, (x0 + 6, y0 + 4), str(day.day), day_font)
day_events = _events_on_day(events, day, tz)
dot_x = x0 + 8
dot_y = y0 + row_h - dot_r * 2 - 6
for i, event in enumerate(day_events[:4]):
# First contributing calendar's color only, even for a
# deduplicated shared event -- month view is density
# dots, not a place to also show which calendars a
# shared event came from (see _event_colors).
event_color = _event_colors(event, owners_seen, palette_rgb)[0]
draw.ellipse([dot_x, dot_y, dot_x + dot_r * 2, dot_y + dot_r * 2], fill=event_color)
dot_x += dot_r * 2 + 5
if len(day_events) > 4:
draw_text(img, (dot_x, dot_y - 2), f"+{len(day_events) - 4}", header_font)
return img
_BUILDERS = {"agenda": _build_agenda, "today_tomorrow": _build_today_tomorrow, "week": _build_week,
"month": _build_month}
def _build(events: list[dict], view: str, browse_offset: int, target_w: int, target_h: int, timezone: str,
fetch_summary: str, week_start: int, palette_rgb: list | None = None,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
week_days: int = 7, week_layout: str = "horizontal",
week_start_offset: int = 0, font_scale: float = 1.0) -> Image.Image:
tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC")
effective_view = view
if view == "month" and not _month_view_fits(target_w, target_h):
effective_view = "agenda"
if effective_view == "agenda":
img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb,
weather_cities, weather_units, font_scale)
elif effective_view == "today_tomorrow":
img = _build_today_tomorrow(events, browse_offset, target_w, target_h, tz, palette_rgb,
weather_cities, weather_units, font_scale)
elif effective_view == "week":
img = _build_week(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb,
weather_cities, weather_units, week_days, week_layout, week_start_offset, font_scale)
elif effective_view == "month":
# Never given weather -- no room for it at typical month-cell
# size, same reasoning that already keeps this view to density
# dots instead of literal event text (see _build_month's own
# docstring). Colors are still passed through, though -- that's
# a different concern (legibility of individual events) than
# needing a whole extra strip of content.
img = _build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, font_scale)
else:
img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb,
weather_cities, weather_units, font_scale)
if fetch_summary:
# Drawn as a final overlay onto the already-composited img (not
# inside any one _build_* branch above), so it offsets by
# panel_style.GUTTER itself to land inside the same visible
# margin every builder's own content already respects.
font = panel_style.font_regular(14 if _size_tier(target_w, target_h) != "small" else 11)
draw_text(img, (panel_style.GUTTER + MARGIN, target_h - panel_style.GUTTER - MARGIN - font.size),
fetch_summary, font)
return img
def render_calendar(events: list[dict], view: str, browse_offset: int, orientation: str,
palette_rgb: list | None, timezone: str,
fetch_summary: str = "", manage: dict | None = None, week_start: int = 0,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
week_days: int = 7, week_layout: str = "horizontal",
week_start_offset: int = 0, panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> bytes:
"""Renders one of CALENDAR_VIEWS full-panel to the panel's packed
format. Returns exactly panel_w*panel_h/2 bytes (see
image_pipeline.panel_size), same invariant every other renderer
honors. weather_cities is routers/common.py's get_or_refresh_weather()
cache, or None/[] to omit the weather strip entirely (also always
omitted for view == "month")."""
target_w, target_h = logical_render_size(orientation, panel_w, panel_h)
img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start,
palette_rgb, weather_cities, weather_units, week_days, week_layout, week_start_offset)
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
return _transpose_and_pack(quantized, orientation)
def render_calendar_preview_png(events: list[dict], view: str, browse_offset: int, orientation: str,
palette_rgb: list | None, timezone: str,
fetch_summary: str = "", manage: dict | None = None, week_start: int = 0,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
week_days: int = 7, week_layout: str = "horizontal",
week_start_offset: int = 0, font_scale: float = 1.0,
panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> bytes:
"""Same pipeline as render_calendar, but a normal browser-viewable
PNG in logical (upright) orientation -- mirrors
image_pipeline.render_preview_png's relationship to render_frame."""
target_w, target_h = logical_render_size(orientation, panel_w, panel_h)
img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start,
palette_rgb, weather_cities, weather_units, week_days, week_layout, week_start_offset, font_scale)
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO()
quantized.convert("RGB").save(buf, format="PNG")
return buf.getvalue()
# --- Standalone tasks widget (split out of the old calendar-widget-only
# week-view task list -- see models.TaskWidgetConfig) -----------------
_TASKS_FONTS = {"large": (24, 18), "medium": (20, 16), "small": (16, 13)}
def _build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None,
title: str = "Tasks", font_scale: float = 1.0) -> Image.Image:
"""A tasks widget's entire region is the checklist -- unlike the old
week-view slot, there's no day columns/header to share space with,
so this is just _draw_tasks over the whole box."""
img, draw, region = panel_style.card_canvas(target_w, target_h)
title_size, body_size = (
panel_style.scaled_size(v, font_scale) for v in _TASKS_FONTS[_size_tier(target_w, target_h)]
)
title_font = panel_style.font_bold(title_size)
body_font = panel_style.font_regular(body_size)
_draw_tasks(img, draw, region, tasks, title_font, body_font, palette_rgb, title)
return img
def render_tasks(tasks: list[dict], orientation: str, palette_rgb: list | None,
manage: dict | None = None, title: str = "Tasks",
panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> bytes:
"""Renders the tasks widget full-panel to the panel's packed format.
Returns exactly panel_w*panel_h/2 bytes, same invariant every other
renderer honors."""
target_w, target_h = logical_render_size(orientation, panel_w, panel_h)
img = _build_tasks(tasks, target_w, target_h, palette_rgb, title)
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
return _transpose_and_pack(quantized, orientation)
def render_tasks_preview_png(tasks: list[dict], orientation: str, palette_rgb: list | None,
manage: dict | None = None, title: str = "Tasks", font_scale: float = 1.0,
panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> bytes:
"""Same pipeline as render_tasks, but a normal browser-viewable PNG
in logical (upright) orientation -- mirrors render_calendar_preview_
png's relationship to render_calendar."""
target_w, target_h = logical_render_size(orientation, panel_w, panel_h)
img = _build_tasks(tasks, target_w, target_h, palette_rgb, title, font_scale=font_scale)
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO()
quantized.convert("RGB").save(buf, format="PNG")
return buf.getvalue()
+36 -107
View File
@@ -1,137 +1,85 @@
"""JSON-file-backed config: Immich connection, selected album, and cursor
state (which photo /frame/image serves next)."""
"""LEGACY config.json model -- kept only so migration.py can import an
existing single-frame deployment's state into the database on first
boot. Nothing else should import this module; runtime state lives in
SQLite (see models.py/db.py).
The file at CONFIG_PATH is deliberately never modified or deleted by the
migration: it's the rollback path (redeploying the pre-database server
image picks it right back up).
"""
from __future__ import annotations
import json
import os
from contextlib import contextmanager
from pathlib import Path
from threading import RLock
from typing import Iterator
from pydantic import BaseModel
CONFIG_PATH = Path(os.environ.get("CONFIG_PATH", "/data/config.json"))
# Reentrant so load()/save() can each take it internally for their own I/O
# while a caller also holds it for a whole locked() span (see below).
_lock = RLock()
class FrameStats(BaseModel):
"""Cumulative, lifetime counters -- purely informational, never read
back to drive any behavior, so there's no harm in them being a little
approximate at the edges. Shown in a collapsed "Stats" section in the
web UI (GET /api/stats). Never reset except by deleting config.json."""
first_seen: float = 0.0 # first time this frame ever checked in
device_wakes: int = 0 # wake cycles, counted once each via GET /frame/config
photos_displayed: int = 0 # times the current photo actually changed (any cause)
photos_removed: int = 0 # times a photo was permanently excluded from rotation
battery_reports: int = 0 # POST /frame/battery calls
recharge_cycles: int = 0 # times a battery recharge was detected
ota_updates_applied: int = 0 # times the device's reported firmware version changed
config_saves: int = 0 # POST /api/config calls
first_seen: float = 0.0
device_wakes: int = 0
photos_displayed: int = 0
photos_removed: int = 0
battery_reports: int = 0
recharge_cycles: int = 0
ota_updates_applied: int = 0
config_saves: int = 0
class FrameConfig(BaseModel):
immich_url: str = ""
immich_api_key: str = ""
management_token: str = "" # gates the web UI (see main.py); empty = no gate, open on trusted LAN
management_token: str = ""
album_id: str = ""
order: str = "sequential" # or "shuffle"
order: str = "sequential"
refresh_interval_s: int = 3600
# Quiet hours: no point waking the device overnight just to swap a
# photo nobody's looking at. Times are "HH:MM" interpreted in
# `timezone` below and may wrap past midnight (e.g. start=22:00,
# end=07:00). Purely a server-side decision -- the device is unaware,
# it just gets told a longer refresh_interval_s by GET /frame/config
# while quiet hours are in effect (see main.py's
# _effective_refresh_interval_s).
quiet_hours_enabled: bool = False
quiet_hours_start: str = "22:00"
quiet_hours_end: str = "07:00"
# IANA zone name (e.g. "America/New_York") quiet_hours_start/end are
# interpreted in. Set from the web UI rather than the container's TZ
# environment variable, so it survives container recreation and
# doesn't need a docker-compose.yml edit to change.
timezone: str = "UTC"
smart_crop_faces: bool = True
# How the physical frame is hung: landscape (native), portrait,
# landscape_flipped, portrait_flipped. Purely a server-side render
# decision -- the device always receives native 800x480 bytes.
orientation: str = "landscape"
# Current photo + upcoming queue (see app/photo_queue.py). current_asset_set_at
# is what lets the server decide "has it been long enough to advance" on its
# own clock, independent of how/why the device asked for a photo.
current_asset_id: str = ""
current_asset_set_at: float = 0.0
queue: list[str] = []
queue_cursor: int = 0 # internal bookkeeping for sequential queue top-up; not user-facing
queue_target_len: int = 20 # how many upcoming photos to keep queued/shown in the web UI
history: list[str] = [] # bounded stack of previously-current asset ids, most recent last
excluded_asset_ids: list[str] = [] # permanently removed from this frame's rotation (not deleted from Immich)
queue_cursor: int = 0
queue_target_len: int = 20
history: list[str] = []
excluded_asset_ids: list[str] = []
# Last battery report from the device (POST /frame/battery); -1 = never
# reported / not battery-powered. battery_as_of mirrors the
# current_asset_set_at timestamp pattern.
battery_percent: int = -1
battery_as_of: float = 0.0
# [timestamp, percent] pairs for the CURRENT discharge cycle only --
# reset whenever a report jumps up enough to indicate a recharge (see
# main.py). Feeds the "on battery for" and "estimated remaining"
# numbers in the web UI's Device panel.
battery_history: list = []
# Every report ever received, never reset by a recharge -- the
# permanent record behind the web UI's battery history graph. Capped
# generously (not a real limit at realistic report rates, just a
# safety bound), unlike battery_history above which is deliberately
# scoped to one cycle.
battery_log: list = []
# Device liveness/telemetry: last_seen is touched by every /frame/*
# request; device_firmware_version/device_board_variant come from the
# X-Frame-Version/X-Frame-Board headers the device sends with its
# config poll (CONFIG_FRAME_BOARD_NAME on the firmware side).
last_seen: float = 0.0
device_firmware_version: str = ""
device_board_variant: str = "" # "" until a device has ever checked in
# Version parsed out of the most recently uploaded OTA image
# (POST /api/firmware); "" = none uploaded yet.
device_board_variant: str = ""
firmware_available_version: str = ""
# Gitea-hosted firmware auto-update (see app/gitea_releases.py).
# repo_url empty = feature off, no Gitea calls made at all. Which
# release asset to pull is learned from the device itself
# (device_board_variant below, from its X-Frame-Board header) rather
# than picked by the user -- must match one of the names
# .gitea/workflows/firmware-release-build.yml publishes
# (firmware-<board_variant>.bin).
firmware_update_repo_url: str = "" # e.g. "https://git.example.com/owner/repo"
firmware_auto_update: bool = False # pull+stage a newer release with no button click
# Optional Gitea PAT (read-only access is enough) for a private repo's
# releases; blank is fine for a public repo. GITEA_FIRMWARE_TOKEN env
# var overrides, mirroring MANAGEMENT_TOKEN below -- never exposed to
# the web UI template or any JSON response.
firmware_update_repo_url: str = ""
firmware_auto_update: bool = False
firmware_update_token: str = ""
firmware_update_checked_at: float = 0.0 # throttle bookkeeping, see gitea_releases.UPDATE_CHECK_INTERVAL_S
firmware_gitea_latest_version: str = "" # latest release's version, from its tag name
firmware_update_checked_at: float = 0.0
firmware_gitea_latest_version: str = ""
stats: FrameStats = FrameStats()
def load() -> FrameConfig:
with _lock:
if not CONFIG_PATH.exists():
cfg = FrameConfig()
else:
cfg = FrameConfig(**json.loads(CONFIG_PATH.read_text()))
"""Reads the legacy file with the same env-override behavior the old
server applied on every load -- which is exactly how env-configured
IMMICH_URL/IMMICH_API_KEY get baked into the database at migration
time even though they were never written to the file itself."""
if not CONFIG_PATH.exists():
cfg = FrameConfig()
else:
cfg = FrameConfig(**json.loads(CONFIG_PATH.read_text()))
# IMMICH_URL/IMMICH_API_KEY/MANAGEMENT_TOKEN/GITEA_FIRMWARE_TOKEN set in
# the environment (e.g. docker-compose.yml, see
# docker-compose.yml.example) take precedence over whatever's saved in
# CONFIG_PATH, so credentials never need to go through the web UI.
env_url = os.environ.get("IMMICH_URL")
env_key = os.environ.get("IMMICH_API_KEY")
env_token = os.environ.get("MANAGEMENT_TOKEN")
@@ -146,22 +94,3 @@ def load() -> FrameConfig:
cfg.firmware_update_token = env_gitea_token
return cfg
def save(cfg: FrameConfig) -> None:
with _lock:
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
CONFIG_PATH.write_text(cfg.model_dump_json(indent=2))
@contextmanager
def locked() -> Iterator[None]:
"""Serializes an entire load-mutate-save cycle. load()/save() each
only lock their own I/O, which isn't enough by itself: uvicorn
dispatches sync routes to a thread pool, so two concurrent requests
(e.g. the device's own poll landing alongside a web UI edit) can each
load() the same on-disk state and the second save() silently clobber
the first's changes. Route handlers that mutate config should wrap
their whole load/mutate/save span in this."""
with _lock:
yield
+118
View File
@@ -0,0 +1,118 @@
"""Engine, sessions, and the per-frame lock that replaces the old
whole-config.json RLock.
Single uvicorn worker (see Dockerfile) -- handlers are sync and run in
the threadpool, so this is ordinary multi-threading in one process: the
same regime the old config.locked() RLock handled, now scoped per frame.
"""
from __future__ import annotations
import os
import threading
from contextlib import contextmanager
from typing import Iterator
from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session, sessionmaker
from .models import WIDGET_CONFIG_MODELS, Frame, Widget
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:////data/espresso.db")
_is_sqlite = DATABASE_URL.startswith("sqlite")
engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False} if _is_sqlite else {},
)
if _is_sqlite:
@event.listens_for(engine, "connect")
def _sqlite_pragmas(dbapi_connection, _record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.close()
# expire_on_commit=False so a Frame resolved by the require_device
# dependency (which commits its last_seen touch) stays usable in the
# route handler without a re-select per attribute. Freshness inside
# mutation spans is handled explicitly by frame_locked()'s refresh.
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
def get_db() -> Iterator[Session]:
"""FastAPI dependency: one session per request (FastAPI caches the
dependency, so require_device and the route handler share it)."""
db = SessionLocal()
try:
yield db
finally:
db.close()
# One lock per frame id, created on demand. Guarded by a module lock so
# two threads can't race to create different Lock objects for the same
# frame (which would defeat the whole point).
_frame_locks: dict[int, threading.Lock] = {}
_frame_locks_guard = threading.Lock()
def _get_lock(frame_id: int) -> threading.Lock:
with _frame_locks_guard:
lock = _frame_locks.get(frame_id)
if lock is None:
lock = threading.Lock()
_frame_locks[frame_id] = lock
return lock
@contextmanager
def frame_locked(db: Session, frame_id: int) -> Iterator[Frame]:
"""Serializes a whole read-modify-write span on one frame -- the
direct successor of the old config.locked(). The refresh() inside the
lock is what makes it correct: without it the session could hold
attribute state read *before* another thread's committed write, and
saving would silently clobber it (the same lost-update race the old
pattern's 're-read inside the lock' comment guarded against)."""
with _get_lock(frame_id):
frame = db.get(Frame, frame_id)
if frame is None:
raise LookupError(f"Frame {frame_id} does not exist")
db.refresh(frame)
yield frame
db.commit()
@contextmanager
def widget_locked(db: Session, frame_id: int, widget_id: int) -> Iterator[tuple[Frame, Widget, object]]:
"""Same lock/refresh/commit dance as frame_locked, additionally
resolving and refreshing the widget's own per-type config row
(PhotoWidgetConfig/CalendarWidgetConfig/WhiteboardWidgetConfig/
TaskWidgetConfig, see models.WIDGET_CONFIG_MODELS). Deliberately
still locks at *frame* granularity -- the exact same per-frame
threading.Lock frame_locked
uses, not a separate per-widget lock -- simplest, avoids a new class
of multi-lock deadlock bugs, and this project's actual concurrency
needs are tiny (a handful of users per household frame).
threading.Lock is not reentrant: a caller executing several widget
actions in one pass (e.g. a button press assigned multiple
(widget, action) pairs, see routers/device.py) MUST call this once
per action, sequentially, never nested inside an outer
frame_locked/widget_locked span for the same frame -- nesting would
deadlock instantly, not just misbehave."""
with frame_locked(db, frame_id) as frame:
widget = db.get(Widget, widget_id)
if widget is None or widget.frame_id != frame_id:
raise LookupError(f"Widget {widget_id} does not belong to frame {frame_id}")
config_model = WIDGET_CONFIG_MODELS[widget.widget_type]
config = db.get(config_model, widget_id)
if config is None:
raise LookupError(f"Widget {widget_id} has no {widget.widget_type} config row")
db.refresh(config)
yield frame, widget, config
+56 -46
View File
@@ -1,6 +1,6 @@
"""Maps named faces (from Immich's own face recognition/People feature)
onto their position in the final rendered 800x480 frame, for the
manage-button overlay's escalated "who's in this photo" menu level.
onto their position in the final rendered frame, for the manage-button
overlay's named-face labels (see manage_overlay.py, which draws them).
No face detection or recognition happens here or anywhere else in this
project -- Immich's GET /api/faces?id={assetId} already returns each
@@ -15,71 +15,81 @@ import io
from PIL import Image, ImageOps
from .image_pipeline import (
_face_aware_crop_box,
_plain_center_crop_box,
logical_render_size,
logical_to_native,
)
from .image_pipeline import EPD_HEIGHT, EPD_WIDTH, _has_bounding_box, _placement_transform, logical_render_size
# Small caps, not arbitrary: each label is its own malloc'd overlay
# buffer on the device (see firmware/main/manage_qr_overlay.c), and the
# four existing fixed corner regions already use a meaningful chunk of
# the ESP32-C6's limited RAM. Capping at 4 short names keeps the total
# overlay memory budget well clear of the WiFi/HTTP stack's own needs.
MAX_LABELED_FACES = 4
NAME_MAX_LEN = 10
# Not a memory constraint anymore (the overlay renders server-side now,
# not malloc'd per-label on the device) -- purely a legibility cap. A
# photo with a dozen named people would just be visual clutter regardless
# of what's rendering it.
MAX_LABELED_FACES = 6
def compute_face_labels(preview_bytes: bytes, faces: list[dict], smart_crop_faces: bool,
orientation: str = "landscape") -> list[dict]:
"""Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in native
800x480 panel pixel space at each named face's bottom-center point.
def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: str,
orientation: str = "landscape", region: tuple[int, int, int, int] | None = None,
panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> list[dict]:
"""Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in
logical (pre-rotation) frame space at each named face's bottom-center
point -- manage_overlay.compose() draws these directly onto the
logical-space image before it's rotated into native panel space, so
no rotation happens here (contrast with the old firmware-side
version, which drew post-rotation and needed logical_to_native).
Faces without an Immich-identified person name are skipped entirely.
preview_bytes must be the same preview image render_frame() used for
the currently-displayed frame, and smart_crop_faces/orientation must
match the settings that were active then -- otherwise the crop box and
rotation computed here won't match what's actually on screen.
the currently-displayed frame, and display_mode/orientation must
match the settings that were active then -- otherwise the placement
computed here won't match what's actually on screen.
The crop math runs in logical (pre-rotation) space, matching
render_frame()'s composition step; each anchor is then rotated into
native panel coordinates via logical_to_native(), since the firmware
draws labels in native space.
`region` is (x0, y0, w, h): where in the logical canvas the photo
actually landed, if not the whole thing -- e.g. a photo widget placed
in one corner of the panel rather than full-screen (see
routers/common.py's build_manage_content, which passes each photo
widget's own placement rect) -- without this a label would be placed
as if the photo filled the entire canvas, landing well off where the
widget actually is. None (the default) means the photo fills the
whole logical canvas.
The placement math matches render_frame()'s own composition step
exactly (see image_pipeline._placement_transform, shared so the two
can't drift apart).
"""
named = [face for face in faces if (face.get("person") or {}).get("name")]
if not named:
return []
logical_w, logical_h = logical_render_size(orientation)
if region is None:
logical_w, logical_h = logical_render_size(orientation, panel_w, panel_h)
region_x0, region_y0, target_w, target_h = 0, 0, logical_w, logical_h
else:
region_x0, region_y0, target_w, target_h = region
fitted = ImageOps.exif_transpose(Image.open(io.BytesIO(preview_bytes)).convert("RGB"))
if smart_crop_faces and faces:
left, top, right, bottom = _face_aware_crop_box(fitted.width, fitted.height, logical_w, logical_h, faces)
crop_w, crop_h = right - left, bottom - top
else:
left, top, crop_w, crop_h = _plain_center_crop_box(fitted.width, fitted.height, logical_w, logical_h)
scale_x, scale_y, offset_x, offset_y = _placement_transform(
fitted.width, fitted.height, target_w, target_h, display_mode, faces
)
labels = []
for face in named[:MAX_LABELED_FACES]:
if not _has_bounding_box(face):
continue
face_w = face.get("imageWidth") or fitted.width
face_h = face.get("imageHeight") or fitted.height
scale_x = fitted.width / face_w
scale_y = fitted.height / face_h
img_scale_x = fitted.width / face_w
img_scale_y = fitted.height / face_h
center_x = (face["boundingBoxX1"] + face["boundingBoxX2"]) / 2 * scale_x
bottom_y = face["boundingBoxY2"] * scale_y
center_x = (face["boundingBoxX1"] + face["boundingBoxX2"]) / 2 * img_scale_x
bottom_y = face["boundingBoxY2"] * img_scale_y
frame_x = (center_x - left) * (logical_w / crop_w)
frame_y = (bottom_y - top) * (logical_h / crop_h)
# Relative to the region's own origin first (matches
# _placement_transform's target_w/target_h space), then shifted
# into full-canvas coordinates.
region_x = center_x * scale_x + offset_x
region_y = bottom_y * scale_y + offset_y
if not (0 <= frame_x <= logical_w and 0 <= frame_y <= logical_h):
continue # this face got cropped out of the final frame entirely
if not (0 <= region_x <= target_w and 0 <= region_y <= target_h):
continue # this face got cropped out of the region entirely
name = face["person"]["name"]
if len(name) > NAME_MAX_LEN:
name = name[: NAME_MAX_LEN - 3] + "..."
native_x, native_y = logical_to_native(frame_x, frame_y, orientation)
labels.append({"name": name, "x": native_x, "y": native_y})
labels.append({"name": face["person"]["name"],
"x": int(region_x + region_x0), "y": int(region_y + region_y0)})
return labels
+7 -6
View File
@@ -1,7 +1,8 @@
"""Local firmware image storage + esp_app_desc_t parsing. Shared by the
manual upload path (POST /api/firmware) and the Gitea auto-update path
(see gitea_releases.py) -- both end up writing the same firmware.bin slot
that GET /frame/firmware streams to the device."""
"""Per-frame firmware image storage + esp_app_desc_t parsing. Shared by
the manual upload path and the Gitea auto-update path -- both end up
writing the same per-frame slot that GET /frame/firmware streams to the
device. The migration moves the old single /data/firmware.bin into frame
#1's slot."""
from __future__ import annotations
@@ -18,8 +19,8 @@ APP_DESC_MAGIC = 0xABCD5432
EXPECTED_PROJECT_NAME = "espresso_frame"
def firmware_path():
return config.CONFIG_PATH.parent / "firmware.bin"
def firmware_path(frame_id: int):
return config.CONFIG_PATH.parent / "firmware" / f"{frame_id}.bin"
def parse_app_version(data: bytes) -> str:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright (c) 2010-2013, Anton Koovit ([email protected]), with Reserved Font Name 'Arvo'
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2010 The Crimson Text Project Authors (https://github.com/googlefonts/Crimson)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+92
View File
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2010-2020 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2013 Google LLC
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More