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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
- 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.
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.
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.
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.
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.
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.
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.
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.
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).
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).
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.
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.
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.
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.
/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).
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.
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).
"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.