c0fefc19f16da6e1b95af2a00f36165ae1acc209
44
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
454c03586e |
Port real epd13in3e driver from vendor code; fix wire-raster stride bug
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.
|
||
|
|
474b92a282 |
Add server-side support for a second panel (13.3in Spectra 6 / EE02) and scaffold its firmware target
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. |
||
|
|
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. |
||
|
|
2868087467 |
Add a per-widget text-size picker for calendar/tasks legibility
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. |
||
|
|
5f4f8f2ea7 |
Add a curated theme system for "modern" style widgets, inspired by Tesserae
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. |
||
|
|
e331f5e5a1 |
Roll out "modern" HTML/CSS render style to every widget except photos
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. |
||
|
|
8ea1c53ec3 |
Add experimental HTML/CSS "modern" render style for weather widget
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. |
||
|
|
37d57a1f88 |
Fix saved layouts silently dropping weather widget settings
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. |
||
|
|
dfe9d71971 |
Revert "Quantize with a measured Spectra 6 palette and OKLab-space ordered dithering"
This reverts commit
|
||
|
|
05b417a29b |
Quantize with a measured Spectra 6 palette and OKLab-space ordered dithering
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. |
||
|
|
a48c84ed4a |
Render widgets concurrently instead of one at a time
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. |
||
|
|
d1f1968317 |
Log device-facing /frame/* requests in the server log
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. |
||
|
|
83994aab7b |
Add an admin-only server log viewer to the web UI
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. |
||
|
|
3fdda096a9 |
Smooth battery percent readings before computing drop-rate steps
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. |
||
|
|
aa4a382c1b |
Add "now displaying" / "up next" preview pair to the frame header
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.
|
||
|
|
684225422c |
Distinguish "staged, not yet applied" from "up to date" in firmware check
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. |
||
|
|
fcf3aec4c0 |
Move button actions to per-widget config, add hold-for-global-action
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. |
||
|
|
9911151d8d |
Add per-photo-widget lock (freezes current photo until unlocked)
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. |
||
|
|
b15747a604 |
Add per-widget border option (style, thickness, palette color)
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. |
||
|
|
eb7127718b |
Add battery widget (device's own last-reported level, no live upstream)
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. |
||
|
|
90a014d161 |
Revert to hand-drawn weather icons, styled after EC's set but exact panel colors
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. |
||
|
|
efb0f2e22d |
Swap hand-drawn weather icons for Environment Canada's real icon set
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. |
||
|
|
270979949f |
Add Environment Canada as a third weather provider
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.
|
||
|
|
52ebafab78 |
Add standalone weather widget (current/hourly/daily/multi-city, pluggable providers)
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. |
||
|
|
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. |
||
|
|
c1c657c0a9 | Saved layouts feature | ||
|
|
edbd90745b |
Add font family choice to the text widget, move toolbar below the editor
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. |
||
|
|
3735c5bfa7 |
Add a text widget (rich text: bold/italic/underline, per-run color/highlight)
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.
|
||
|
|
35e80c6d1c |
Add a static image widget (PNG/JPEG/GIF/BMP/WEBP/TIFF/PDF upload)
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. |
||
|
|
4a2b1f3795 |
Let a tasks widget have a custom on-panel name
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. |
||
|
|
14c47aa2a0 |
Let a tasks widget merge multiple task lists, checkbox+color like calendar
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. |
||
|
|
b5c52004c8 |
Split the tasks feature out of the calendar widget into its own widget type
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. |
||
|
|
9f3f4b6f62 |
Add a "Clear all" button to the Layout tab's widget canvas
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.
|
||
|
|
289d308b57 |
Disambiguate same-type widgets in the button-assignment dropdowns
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.
|
||
|
|
8b9f636cce |
Mark whiteboard as (alpha); add the Phase 5 button-assignment UI
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.
|
||
|
|
9c8a87e90d |
Add a live preview thumbnail next to the frame name
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.
|
||
|
|
82f60ed428 |
Auto-reset device_token_ack when a reprovisioned frame is reclaimed
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. |
||
|
|
a33a3a71e4 |
Widget system Phase 4b: per-widget gear-icon config dialogs
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.
|
||
|
|
5d4bb53b8a |
Widget system Phase 4a: widget CRUD + grid placement UI
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. |
||
|
|
99069ba5fe |
Widget system Phase 3: calendar widgets become size-aware
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. |
||
|
|
37bd657299 |
Widget system Phase 2: full cutover to widget-based rendering
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. |
||
|
|
f48daa71c8 |
Widget system Phase 1: per-type render/action modules
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). |
||
|
|
8bc0749b42 |
Widget system Phase 0: data model + migration
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. |
||
|
|
31adc34a19 |
Add a real pytest suite, gating the CI build
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. |