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.
34 KiB
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 inrouters/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 onWidgetitself rather than a per-type config table, since every widget type can have one regardless ofwidget_type. Drawn byimage_pipeline.draw_widget_borderonto each widget's own region inrouters/device.py's_render_widgets, before that region is pasted onto the shared canvas -- one central integration point instead of everyapp/widgets/*.pymodule needing to know about it. Set via the gear-icon dialog's shared "Border" card (_widget_border_fields.html, included by every_widget_dialog_*.htmltemplate) andPOST .../widgets/{id}/border, its own endpoint (not folded intoapi_widget_config_save) since that endpoint's per-type dispatch is keyed on a config row viawidget_locked, and border fields live onWidgetitself, not any per-type config table. Also carriesfont_scale(one ofpanel_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 ownPOST .../widgets/{id}/font-scaleendpoint 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 bywidget_idwithondelete="CASCADE"-- rather than one wide table with every type's mostly-irrelevant columns.TextWidgetConfig.contentis parsed rich text (paragraphs of styled runs), never raw HTML -- seeserver/app/text_content.py's module docstring for why that parse step is the widget's actual stored-XSS sanitization boundary.PhotoWidgetConfigmirrorsapp/photo_queue.py's attribute names exactly, so that module's advance/back/queue logic ports across widget instances unchanged.PhotoWidgetConfig.locked(migration 27) freezescurrent_asset_idagainst both the timer-elapsed auto-advance (photo_queue.get_current) and the advance/back button actions (app/widgets/photos.py'sACTIONS) 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.TaskWidgetConfigused to be a handful oftasks_*columns bolted ontoCalendarWidgetConfig(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.WeatherWidgetConfigsimilarly liftsCalendarWidgetConfig'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 byrouters/device.py'sframe_batteryregardless of whether a battery widget is even placed; the config row only holds a display-mode setting ("compact"|"detailed"). FrameCalendar/FrameTaskListare keyed bywidget_id(notframe_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) --FrameTaskListjust 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 exactlytarget_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/dailymodes only --hourly/multi_cityalways 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 withresolve_theme, but nothing reads them) -- a theme's radius/shadow fields now only affect the un-redesigned modern widgets (static image/whiteboard'sframed_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 richeraccent_amplitudeviaordered_dither_regionsnow, 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_currentand 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._clampand every redesignedbuild_*'s ownpad/size calculations (base = min(target_w, target_h), then a fraction ofbaseclamped 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'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.