Files
espresso_frame/server/app/models.py
T
tfaour 474b92a282
Build and push server image / test (push) Successful in 45s
Firmware build check / build-check (push) Successful in 2m50s
Build and push server image / build-and-push (push) Successful in 4m36s
Build and push server image / deploy (push) Failing after 1m34s
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.
2026-08-04 20:08:22 +00:00

844 lines
45 KiB
Python

"""SQLAlchemy models: users, sessions, frames, links, claims, battery log.
One deliberately WIDE `frames` row per frame (settings + state + telemetry
+ stats together): every device request touches exactly one row, so the
per-frame lock in db.frame_locked() keeps the old whole-config-lock
semantics trivially correct, and SQLite doesn't care about row width.
The queue/history/excluded/battery_history columns are MutableList-mapped
JSON: photo_queue.py mutates them in place (pop/append/insert), which a
plain JSON column would silently not persist -- MutableList marks the row
dirty on in-place changes.
The ORM attribute for the photo ordering setting is `order` (matching the
old FrameConfig field name so photo_queue.py ports unchanged) but the
column is named photo_order to stay clear of the SQL keyword.
"""
from __future__ import annotations
import time
from sqlalchemy import JSON, Boolean, Float, ForeignKey, Index, Integer, LargeBinary, String
from sqlalchemy.ext.mutable import MutableList
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
# Normalized to lowercase in code before insert/lookup -- portable
# case-insensitive uniqueness without SQLite-only COLLATE NOCASE.
username: Mapped[str] = mapped_column(String, unique=True)
display_name: Mapped[str] = mapped_column(String, default="")
# Pluggable identity: "local" now; an OIDC provider later would set
# provider_subject and leave password_hash NULL.
identity_provider: Mapped[str] = mapped_column(String, default="local")
provider_subject: Mapped[str] = mapped_column(String, default="")
password_hash: Mapped[str | None] = mapped_column(String, nullable=True)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
immich_url: Mapped[str] = mapped_column(String, default="")
immich_api_key: Mapped[str] = mapped_column(String, default="")
# Password-reset emails and battery-threshold alerts (frames.owner's
# email -- see routers/device.py's frame_battery) go here; blank = no
# email configured, both features silently no-op for this user.
email: Mapped[str] = mapped_column(String, default="")
# Personal ICS subscription URL (no OAuth) for calendar frame mode --
# see calendar_feed.py. Setting this alone shows up nowhere: a linked
# frame only pulls this user's events in once they've also added it
# on that frame's own Calendar tab (FrameCalendar below).
calendar_ics_url: Mapped[str] = mapped_column(String, default="")
# A CalDAV account (Nextcloud, Fastmail, iCloud, ...) alongside the
# plain ICS subscription above -- see caldav_client.py. calendar_url
# is the server's CalDAV entry point the user pasted in, not any one
# calendar's own URL; the individual calendars it exposes are
# discovered and cached below.
calendar_caldav_url: Mapped[str] = mapped_column(String, default="")
calendar_caldav_username: Mapped[str] = mapped_column(String, default="")
calendar_caldav_password: Mapped[str] = mapped_column(String, default="")
# [{"href", "display_name"}, ...] from the last successful
# caldav_client.discover_calendars() call, refreshed by Settings'
# "Discover calendars" button -- NULL until discovery has ever
# succeeded. This is what a frame's Calendar tab offers the user to
# add, without hitting the CalDAV server on every page load.
calendar_caldav_calendars: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
calendar_caldav_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
# WebDAV credentials for whiteboard frame mode (see webdav_client.py,
# whiteboard.py) -- generic WebDAV, not Nextcloud-specific, but
# webdav_reuse_caldav_creds is a convenience for the common case
# where it IS the same Nextcloud account as calendar_caldav_*: skip
# re-entering the same username/password, since Nextcloud's CalDAV
# and general-file-WebDAV both sit under the one account. Doesn't
# try to be clever and derive the reuse automatically -- an explicit
# opt-in, same as everywhere else in this project defaults features
# off rather than silently inferring them.
webdav_username: Mapped[str] = mapped_column(String, default="")
webdav_password: Mapped[str] = mapped_column(String, default="")
webdav_reuse_caldav_creds: Mapped[bool] = mapped_column(Boolean, default=False)
# Optional starting folder for the whiteboard dialog's file-picker
# (see routers/api_widgets.py's api_widget_whiteboard_browse) --
# purely a convenience for browsing to a file rather than typing its
# full URL.
# Never used for fetching/rendering itself, which always uses the
# frame's own saved whiteboard_url regardless of whether this is set.
webdav_base_url: Mapped[str] = mapped_column(String, default="")
created_at: Mapped[float] = mapped_column(Float, default=time.time)
__table_args__ = (
Index(
"ix_users_provider_subject",
"identity_provider",
"provider_subject",
unique=True,
sqlite_where=provider_subject != "",
),
)
class UserSession(Base):
__tablename__ = "sessions"
id: Mapped[int] = mapped_column(primary_key=True)
token_hash: Mapped[str] = mapped_column(String, unique=True) # sha256 hex of cookie value
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
csrf_token: Mapped[str] = mapped_column(String)
created_at: Mapped[float] = mapped_column(Float, default=time.time)
expires_at: Mapped[float] = mapped_column(Float, index=True)
user: Mapped[User] = relationship()
class Frame(Base):
__tablename__ = "frames"
id: Mapped[int] = mapped_column(primary_key=True)
# 12 lowercase hex chars of the device's full STA MAC. NULL until its
# device first reports an id.
device_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True)
name: Mapped[str] = mapped_column(String, default="")
# Whose Immich library this frame pulls from; NULL = unclaimed.
owner_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
# "Take control" soft lock -- only this user may mutate settings/queue.
controlled_by_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
device_token: Mapped[str] = mapped_column(String)
# Device has authenticated with device_token at least once -- stop
# pushing it in /frame/config responses.
device_token_ack: Mapped[bool] = mapped_column(Boolean, default=False)
manage_token: Mapped[str] = mapped_column(String, unique=True)
claimed_at: Mapped[float | None] = mapped_column(Float, nullable=True)
created_at: Mapped[float] = mapped_column(Float, default=time.time)
# Migration staging only: Immich creds imported from the legacy
# config.json/env live here until /setup copies them to admin #1.
# Runtime resolution prefers owner creds, then env, then these (see
# routers/common.py immich_creds()).
immich_url: Mapped[str] = mapped_column(String, default="")
immich_api_key: Mapped[str] = mapped_column(String, default="")
# -- settings --
refresh_interval_s: Mapped[int] = mapped_column(Integer, default=3600)
quiet_hours_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
quiet_hours_start: Mapped[str] = mapped_column(String, default="22:00")
quiet_hours_end: Mapped[str] = mapped_column(String, default="07:00")
timezone: Mapped[str] = mapped_column(String, default="UTC")
orientation: Mapped[str] = mapped_column(String, default="landscape")
# Which EPD panel this frame renders for (image_pipeline.PANEL_SPECS
# key) -- a property of the device's hardware, auto-derived from its
# self-reported board (see routers/device.py's BOARD_PANEL_MAP), never
# a user-editable setting: a mismatched value would corrupt every
# image sent to the device. Defaults to the original 7.3" panel this
# project shipped with.
panel_type: Mapped[str] = mapped_column(String, default="epd7in3e")
# Advanced configuration: [[r,g,b], ...] x6 (black/white/yellow/red/
# blue/green, matching image_pipeline.PANEL_CODES order) overriding
# DEFAULT_PALETTE_RGB for this frame's actual panel. NULL = use the
# default -- most frames never touch this.
palette_rgb: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# Advanced configuration: PIL ImageEnhance factors, 1.0 = unchanged
# (see image_pipeline.render_frame).
color_boost: Mapped[float] = mapped_column(Float, default=1.0)
contrast_boost: Mapped[float] = mapped_column(Float, default=1.0)
# 0.0-1.0, see image_pipeline._quantize -- 1.0 matches this project's
# original always-on full-strength Floyd-Steinberg dithering.
dither_strength: Mapped[float] = mapped_column(Float, default=1.0)
# Same shape as palette_rgb/dither_strength above, but scoped to only
# the photos widget (widgets/photos.py quantizes against these itself,
# before returning -- see its own docstring) -- lets a frame tune the
# rest of its widgets' palette/dithering (e.g. a "modern" HTML-
# rendered dashboard look) independently of what actually looks best
# for real photographs. NULL/1.0 = same defaults as the main fields.
photo_palette_rgb: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
photo_dither_strength: Mapped[float] = mapped_column(Float, default=1.0)
# Curated visual theme for "modern" (HTML/CSS) style widgets -- see
# theme_tokens.THEMES. Frame-level (like palette_rgb/dither_strength
# above), not per-widget, since a theme is "how this frame looks."
# Widgets rendered in classic (PIL) style ignore this entirely.
theme: Mapped[str] = mapped_column(String, default="classic")
# -- telemetry --
battery_percent: Mapped[int] = mapped_column(Integer, default=-1)
battery_as_of: Mapped[float] = mapped_column(Float, default=0.0)
# Current discharge cycle only (reset on recharge detection); the
# permanent record is the battery_log table.
battery_history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
last_seen: Mapped[float] = mapped_column(Float, default=0.0)
device_firmware_version: Mapped[str] = mapped_column(String, default="")
device_board_variant: Mapped[str] = mapped_column(String, default="")
# Battery-low email alert (see routers/device.py's frame_battery).
# -1 = disabled. Sent to the owner's email once per discharge cycle
# (battery_alert_sent resets alongside battery_history whenever a
# recharge is detected, same trigger as stats_recharge_cycles).
battery_alert_threshold_pct: Mapped[int] = mapped_column(Integer, default=-1)
battery_alert_sent: Mapped[bool] = mapped_column(Boolean, default=False)
# -- firmware / OTA (per frame; image lives at /data/firmware/<id>.bin) --
firmware_available_version: Mapped[str] = mapped_column(String, default="")
firmware_update_repo_url: Mapped[str] = mapped_column(String, default="")
firmware_auto_update: Mapped[bool] = mapped_column(Boolean, default=False)
firmware_update_token: Mapped[str] = mapped_column(String, default="")
firmware_update_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
firmware_gitea_latest_version: Mapped[str] = mapped_column(String, default="")
# -- hold-for-global-action (see app/global_actions.py) -- holding
# NEXT/BACK past hold_duration_ms triggers a global action instead of
# the per-widget one that a short press runs (models.FrameButtonAction).
# Not scoped to any widget, e.g. cycling saved layouts -- hence its
# own pair of frame-level columns rather than living in that table.
hold_duration_ms: Mapped[int] = mapped_column(Integer, default=3000)
next_hold_action: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
back_hold_action: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
# Where "cycle saved layouts" resumes from -- the last SavedLayout id
# it applied, so repeated holds advance through the list instead of
# re-applying the same one every time. Deliberately not a real FK:
# this is just a resume cursor, not a relationship needing cascade/
# referential integrity -- if that layout's since been deleted or
# renamed away, global_actions.cycle_layout just doesn't find it and
# starts over from the first one, same as an unset value.
last_cycled_layout_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
# -- "now displaying" (see routers/device.py's _record_last_displayed,
# api_frames.py's /now-displaying endpoint) -- exactly what the last
# device-facing render (/frame/image, /frame/advance, /frame/back, or
# a global hold action) actually sent, as an upright PNG, so the web
# UI's header preview can show it frozen alongside a live "up next"
# re-render instead of conflating the two. NULL until a real device
# has fetched at least once.
last_displayed_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True, default=None)
last_displayed_at: Mapped[float] = mapped_column(Float, default=0.0)
# -- stats (flattened from the old nested FrameStats) --
stats_first_seen: Mapped[float] = mapped_column(Float, default=0.0)
stats_device_wakes: Mapped[int] = mapped_column(Integer, default=0)
stats_photos_displayed: Mapped[int] = mapped_column(Integer, default=0)
stats_photos_removed: Mapped[int] = mapped_column(Integer, default=0)
stats_battery_reports: Mapped[int] = mapped_column(Integer, default=0)
stats_recharge_cycles: Mapped[int] = mapped_column(Integer, default=0)
stats_ota_updates_applied: Mapped[int] = mapped_column(Integer, default=0)
stats_config_saves: Mapped[int] = mapped_column(Integer, default=0)
owner: Mapped[User | None] = relationship(foreign_keys=[owner_user_id])
controlled_by: Mapped[User | None] = relationship(foreign_keys=[controlled_by_user_id])
class UserFrame(Base):
"""A user linked to a frame: sees it in their sidebar, may view its
pages, and may take control. Ownership (whose Immich creds the frame
renders from) is frames.owner_user_id, separate from linking."""
__tablename__ = "user_frames"
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
frame_id: Mapped[int] = mapped_column(
ForeignKey("frames.id", ondelete="CASCADE"), primary_key=True
)
created_at: Mapped[float] = mapped_column(Float, default=time.time)
class FrameCalendar(Base):
"""One calendar included on one frame -- calendar_key is "ics" (the
owner's single calendar_ics_url) or "caldav:<href>" (one of the
owner's CalDAV collections; href matches an entry in
User.calendar_caldav_calendars). Replaces the old single
UserFrame.calendar_included boolean now that a CalDAV account can
expose more than one calendar.
A row only ever gets created by its own owner (adding a calendar to
a frame is each person's own data-sharing choice, not something a
frame's controller decides on their behalf) -- but once it exists,
ANY user linked to the frame may flip included back to False, muting
a calendar they'd rather not see on a shared display even though
they don't own it. Only the owner may flip it back to True. See
routers/api_widgets.py's api_widget_calendar_select.
Keyed by widget_id, not frame_id -- a frame can hold more than one
independent calendar widget (see Widget), each with its own included-
calendars set; "included on this frame" stopped being unambiguous
the moment that became possible (see migration.py's
_ensure_frame_calendars_rekeyed, which re-keyed this table)."""
__tablename__ = "frame_calendars"
id: Mapped[int] = mapped_column(primary_key=True)
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"))
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
calendar_key: Mapped[str] = mapped_column(String)
# Snapshot label for display -- so the list still reads sensibly even
# if the owner's CalDAV account later stops offering this calendar.
calendar_label: Mapped[str] = mapped_column(String, default="")
included: Mapped[bool] = mapped_column(Boolean, default=True)
# Index into image_pipeline.DEFAULT_PALETTE_RGB/PALETTE_LABELS (2-5:
# Yellow/Red/Blue/Green -- 0/1 are reserved, already the page's
# text/background) pinning this calendar's events to a specific
# panel color rather than calendar_render.py's old owner-name
# auto-cycle. NULL keeps the auto-cycle behavior. Only the calendar's
# owner may set this -- see routers/api_widgets.py's api_widget_calendar_color.
color_index: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
__table_args__ = (
Index("ix_frame_calendars_unique", "widget_id", "user_id", "calendar_key", unique=True),
)
class FrameTaskList(Base):
"""One CalDAV task list included on one tasks widget -- calendar_key
is "caldav:<href>" (an entry in User.calendar_caldav_calendars; no
"ics" variant, unlike FrameCalendar -- a plain ICS subscription has
no VTODO collection). Same owner-controls-their-own-data shape as
FrameCalendar in every other respect: a row is only ever created by
its own owner, but any user linked to the frame may flip included
back to False, and only the owner may flip it back to True or set
color_index. See routers/api_widgets.py's api_widget_task_list_select/
api_widget_task_list_color."""
__tablename__ = "frame_task_lists"
id: Mapped[int] = mapped_column(primary_key=True)
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"))
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
calendar_key: Mapped[str] = mapped_column(String)
calendar_label: Mapped[str] = mapped_column(String, default="")
included: Mapped[bool] = mapped_column(Boolean, default=True)
color_index: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
__table_args__ = (
Index("ix_frame_task_lists_unique", "widget_id", "user_id", "calendar_key", unique=True),
)
class Widget(Base):
"""One placed/sized content item on a frame's panel -- the unit the
widget system replaces the old single Frame.mode with (see
app/grid.py for the grid this x/y/w/h is measured in, and app/widgets/
for the widget_type -> render/action dispatch registry). Widgets never
overlap (enforced server-side in routers/api_widgets.py), which is
what keeps compositing simple: no z-order, no blending, just N
independent regions pasted onto one shared canvas before a single
shared dither/quantize pass (see image_pipeline.render_panel).
widget_type selects which of the three per-type extension tables below
(PhotoWidgetConfig/CalendarWidgetConfig/WhiteboardWidgetConfig) holds
this widget's actual settings/state -- a 1:1 relational split rather
than one wide table with every type's columns, matching how
FrameCalendar/BatteryLog are already their own tables in this
codebase rather than crammed onto Frame."""
__tablename__ = "widgets"
id: Mapped[int] = mapped_column(primary_key=True)
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
widget_type: Mapped[str] = mapped_column(String) # "photos" | "calendar" | "whiteboard" | "tasks" | "static" | "text" | "weather" | "battery"
x: Mapped[int] = mapped_column(Integer)
y: Mapped[int] = mapped_column(Integer)
w: Mapped[int] = mapped_column(Integer)
h: Mapped[int] = mapped_column(Integer)
# Display/tie-break ordering only (e.g. listing widgets in a UI) --
# NOT a z-order, since widgets never overlap. Named sort_order, not
# order, to sidestep the SQL-keyword dance Frame.order needed
# (mapped to a differently-named column) -- nothing outside this
# table needs to match a specific attribute name here.
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[float] = mapped_column(Float, default=time.time)
# Optional decorative border, drawn once around this widget's own
# region (routers/device.py's _render_widgets) regardless of
# widget_type -- a Widget-level property, not a per-type config
# column, since every widget type can have one. See
# image_pipeline.BORDER_STYLES/draw_widget_border. "none" (the
# default) draws nothing, so existing widgets don't suddenly grow a
# border. border_color_index indexes into the frame's palette_rgb
# (0-5, Black/White/Yellow/Red/Blue/Green) rather than storing an
# arbitrary hex -- an exact palette color quantizes with zero
# dithering error, same reasoning as the weather/battery icons'
# exact-panel-ink-RGB fills (see docs/widgets.md).
border_style: Mapped[str] = mapped_column(String, default="none")
border_thickness: Mapped[int] = mapped_column(Integer, default=3)
border_color_index: Mapped[int] = mapped_column(Integer, default=0)
# Text-size multiplier for this widget's own body/title text -- another
# Widget-level property regardless of widget_type, same reasoning as
# border_style above (any widget type with text can use it). One of
# panel_style.FONT_SCALE_CHOICES; 1.0 (unchanged size) for every
# existing widget until its dialog's "Text size" picker sets it.
font_scale: Mapped[float] = mapped_column(Float, default=1.0)
__table_args__ = (Index("ix_widgets_frame", "frame_id"),)
class PhotoWidgetConfig(Base):
"""One photo widget's settings + queue state. Attribute names match
Frame's old photo-queue columns exactly (down to `order`'s same
photo_order column-name dodge) -- app/photo_queue.py's 5 functions
are duck-typed against these exact names (never isinstance-checked
against Frame), so they port unchanged onto this table."""
__tablename__ = "photo_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
album_id: Mapped[str] = mapped_column(String, default="")
order: Mapped[str] = mapped_column("photo_order", String, default="sequential")
display_mode: Mapped[str] = mapped_column(String, default="crop_faces")
queue_target_len: Mapped[int] = mapped_column(Integer, default=20)
current_asset_id: Mapped[str] = mapped_column(String, default="")
current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0)
queue: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
queue_cursor: Mapped[int] = mapped_column(Integer, default=0)
history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
excluded_asset_ids: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
locked: Mapped[bool] = mapped_column(Boolean, default=False)
class CalendarWidgetConfig(Base):
"""One calendar widget's settings + cached-fetch state -- the same
fields that used to live as calendar_* columns directly on Frame,
minus calendar_photo_inlay (dropped: arbitrary widget placement
subsumes what a fixed 50/50 inlay split did, so it's not a special
case anymore, just place a photo widget alongside) and minus
tasks_* (also dropped: split out into its own standalone widget
type, see TaskWidgetConfig, so a task list isn't tied to a
calendar's week view/footprint anymore). "Included calendars" is
its own table (FrameCalendar), widget_id-keyed so each calendar
widget on a frame has its own independent set."""
__tablename__ = "calendar_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
view: Mapped[str] = mapped_column(String, default="agenda")
week_start: Mapped[int] = mapped_column(Integer, default=0)
browse_offset: Mapped[int] = mapped_column(Integer, default=0)
checked_at: Mapped[float] = mapped_column(Float, default=0.0)
cached_events: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
fetch_summary: Mapped[str] = mapped_column(String, default="")
weather_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
weather_units: Mapped[str] = mapped_column(String, default="fahrenheit")
weather_cities: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
weather_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
weather_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
week_days: Mapped[int] = mapped_column(Integer, default=7)
week_layout: Mapped[str] = mapped_column(String, default="horizontal")
week_start_offset: Mapped[int] = mapped_column(Integer, default=0)
# classic (calendar_render.py) vs modern (app/calendar_html_render.py,
# agenda mode only so far -- see that module's docstring) -- see
# widgets/calendar.py's render().
render_style: Mapped[str] = mapped_column(String, default="classic")
class TaskWidgetConfig(Base):
"""One tasks widget's settings + cached-fetch state -- split out of
CalendarWidgetConfig (which used to carry these as tasks_* columns,
a week-view-only task list bolted onto a calendar widget) so a task
list can be placed and sized on its own, independent of any
calendar's view/footprint. No separate "enabled" flag -- unlike the
old bolted-on version, the widget's mere presence on the grid is the
on/off switch, same as every other widget type.
Which task lists feed this widget lives in FrameTaskList, not here
-- a widget can merge more than one person's list, mirroring
CalendarWidgetConfig/FrameCalendar exactly (this used to be a single
user_id/calendar_key pair here, one list only; migration 18 carried
each widget's existing single source forward as its first
FrameTaskList row when splitting this out)."""
__tablename__ = "task_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
# Shown on-panel in place of the default "Tasks" header (see
# calendar_render._draw_tasks) -- "" keeps the default. 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
# its content the way a calendar/photo/whiteboard's is.
name: Mapped[str] = mapped_column(String, default="")
checked_at: Mapped[float] = mapped_column(Float, default=0.0)
# [{"summary", "due", "completed_at" (ISO date/datetime strings or
# None), "owner_display_name", "color_index"}, ...] -- the merged
# multi-list result, same general shape as CalendarWidgetConfig.
# cached_events. See caldav_client.merge_tasks.
cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# Also include tasks completed in the last 24h (drawn checked-box +
# muted, see calendar_render._draw_tasks) rather than just
# outstanding ones -- off by default, same "opt into more" posture
# as calendar_weather_enabled.
show_completed: Mapped[bool] = mapped_column(Boolean, default=False)
# classic (hand-drawn PIL, calendar_render._build_tasks) vs modern
# (app/html_render.py) -- see widgets/tasks.py's render().
render_style: Mapped[str] = mapped_column(String, default="classic")
class WhiteboardWidgetConfig(Base):
"""One whiteboard widget's source + rendered-PNG cache -- the same
fields that used to live as whiteboard_* columns directly on Frame."""
__tablename__ = "whiteboard_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
url: Mapped[str] = mapped_column(String, default="")
checked_at: Mapped[float] = mapped_column(Float, default=0.0)
cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
# classic (no chrome, unchanged) vs modern (app/html_render.py's
# rounded-corner shadowed card) -- see widgets/whiteboard.py's
# render().
render_style: Mapped[str] = mapped_column(String, default="classic")
class WeatherWidgetConfig(Base):
"""One weather widget's settings + cached-fetch state. Four display
modes (see app/widgets/weather.py): "current" (one city, current
temp + icon), "hourly" (one city, a row of ticks across the day),
"daily" (one city, a multi-day strip), "multi_city" (several cities'
current-day high/low/icon side by side -- the calendar widget's
embedded weather strip, lifted out into its own widget type).
`provider` selects which of app/weather/'s PROVIDERS actually fetches
("open_meteo" | "nws" -- see that package's own module docstring).
`cached`'s shape depends on `mode`: {"temp","category"} for current,
a list of {"time","temp","category"} for hourly, a
{"YYYY-MM-DD": {...}} dict for daily, or a list of
{"label","high","low","category"} for multi_city.
`render_style` picks which renderer draws the widget: "classic" (the
hand-drawn PIL primitives in app/weather_render.py, unchanged
default) or "modern" (app/html_render.py's Jinja2/headless-Chromium
path, "current"/"daily" modes only for now -- see weather.py's
render())."""
__tablename__ = "weather_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
mode: Mapped[str] = mapped_column(String, default="current") # current | hourly | daily | multi_city
provider: Mapped[str] = mapped_column(String, default="open_meteo") # open_meteo | nws
render_style: Mapped[str] = mapped_column(String, default="classic") # classic | modern
units: Mapped[str] = mapped_column(String, default="fahrenheit") # fahrenheit | celsius
# Single-location modes only (current/hourly/daily) -- geocoded once
# via weather.geocode_city() when set, same idiom as
# CalendarWidgetConfig.weather_cities' per-entry shape.
city_label: Mapped[str | None] = mapped_column(String, nullable=True)
city_latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
city_longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
hourly_interval_hours: Mapped[int] = mapped_column(Integer, default=4)
daily_days: Mapped[int] = mapped_column(Integer, default=5)
# multi_city mode only -- [{"label", "latitude", "longitude"}, ...],
# same shape as CalendarWidgetConfig.weather_cities.
cities: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
checked_at: Mapped[float] = mapped_column(Float, default=0.0)
cached: Mapped[dict | list | None] = mapped_column(JSON, nullable=True, default=None)
class TextWidgetConfig(Base):
"""One text widget's authored content + display settings -- another
no-live-upstream type like StaticWidgetConfig, just parsed rich text
instead of an uploaded image. content is never raw HTML: the
dialog's contenteditable innerHTML is parsed server-side (see
app/text_content.py, the sanitization boundary) into this plain
run structure at save time, so render() (app/widgets/text.py) never
re-parses/sanitizes HTML on every panel refresh, and the dialog never
re-injects stored HTML via innerHTML when reopened.
[[{"text","bold","italic","underline","color","bg"}, ...], ...] --
outer list is paragraphs (line breaks), inner list is styled runs
within that paragraph. color/bg are "#rrggbb" or null (falls back to
black text / no highlight). NULL (not just []) means never
configured, matching StaticWidgetConfig.image's None-vs-empty
convention for "not configured yet"."""
__tablename__ = "text_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
content: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# Base point size for the whole block -- render() shrinks this down
# (never up) to fit the widget's actual box; per-run font size isn't
# supported, only the bold/italic/underline/color/bg style flags are
# per-run (see app/text_content.py) -- keeps the wrap/shrink-to-fit
# layout in app/widgets/text.py to one size per render pass.
font_size: Mapped[int] = mapped_column(Integer, default=28)
# A key into app/widgets/text.py's FONT_FAMILIES, also whole-widget
# not per-run (see font_size above for why).
font_family: Mapped[str] = mapped_column(String, default="sans")
align: Mapped[str] = mapped_column(String, default="left") # "left" | "center" | "right"
background_color: Mapped[str] = mapped_column(String, default="#ffffff")
# classic (hand-drawn PIL) vs modern (app/html_render.py) -- see
# widgets/text.py's render()/render_preview_png().
render_style: Mapped[str] = mapped_column(String, default="classic")
class StaticWidgetConfig(Base):
"""One static-image widget's uploaded content + display settings --
unlike every other widget type, this one has no live upstream to
poll (Immich/CalDAV/WebDAV): the "source" is whatever the user last
uploaded (see routers/api_widgets.py's api_widget_static_upload,
app/image_upload.py), decoded once at upload time into plain RGB PNG
bytes so app/widgets/static_image.py's render() never re-runs
PDF/GIF decoding on every panel refresh."""
__tablename__ = "static_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
original_filename: Mapped[str] = mapped_column(String, default="")
uploaded_at: Mapped[float] = mapped_column(Float, default=0.0)
# Same DISPLAY_MODES vocabulary as PhotoWidgetConfig.display_mode,
# minus crop_faces -- no face detection for an uploaded image (see
# image_pipeline.STATIC_DISPLAY_MODES).
display_mode: Mapped[str] = mapped_column(String, default="crop_fill")
# classic (no chrome, unchanged) vs modern (app/html_render.py's
# rounded-corner shadowed card) -- see widgets/static_image.py's
# render().
render_style: Mapped[str] = mapped_column(String, default="classic")
class BatteryWidgetConfig(Base):
"""One battery widget's display settings -- another no-live-upstream
type like StaticWidgetConfig/TextWidgetConfig, just showing existing
frame-level state (Frame.battery_percent/battery_as_of, already set
by routers/device.py's frame_battery on every device report) instead
of anything the widget itself fetches or the user authors. `mode`
"compact" is icon + percent only; "detailed" (default) adds the
routers.common.battery_estimate_s time-remaining estimate and the
last report's age. `render_style` picks classic (hand-drawn PIL) vs
modern (app/html_render.py) -- see widgets/battery.py's render()."""
__tablename__ = "battery_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
mode: Mapped[str] = mapped_column(String, default="detailed") # compact | detailed
render_style: Mapped[str] = mapped_column(String, default="classic") # classic | modern
# widget_type -> its per-type extension table, keyed by widget_id. Used
# by db.widget_locked() to resolve the right config row without importing
# app/widgets/'s heavier render/action registry just for this lookup.
WIDGET_CONFIG_MODELS: dict[str, type] = {
"photos": PhotoWidgetConfig,
"calendar": CalendarWidgetConfig,
"whiteboard": WhiteboardWidgetConfig,
"tasks": TaskWidgetConfig,
"static": StaticWidgetConfig,
"battery": BatteryWidgetConfig,
"text": TextWidgetConfig,
"weather": WeatherWidgetConfig,
}
class FrameButtonAction(Base):
"""One (widget, action) binding for one of a frame's two physical
buttons -- e.g. {button: "next", widget_id: <photo widget>, action:
"advance"}. At most one binding per (widget, button) -- edited from
that widget's own config dialog (routers/api_widgets.py's
api_widget_config_save), prefilled with a sane default at widget
creation (app/widgets/default_button_actions). On a press, every
widget's row for that (frame, button) runs -- see routers/device.py's
frame_advance/frame_back. sort_order is unused (which widget's action
runs first never matters: each only touches its own state, and one
shared re-render happens after all of them finish) but kept around so
dispatch has a stable, deterministic query order."""
__tablename__ = "frame_button_actions"
id: Mapped[int] = mapped_column(primary_key=True)
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
button: Mapped[str] = mapped_column(String) # "next" | "back"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"))
action: Mapped[str] = mapped_column(String) # e.g. "advance", "back", "check_now" -- see app/widgets/
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[float] = mapped_column(Float, default=time.time)
__table_args__ = (
Index("ix_frame_button_actions_frame_button", "frame_id", "button", "sort_order"),
Index("ix_frame_button_actions_widget_button", "widget_id", "button", unique=True),
)
class SavedLayout(Base):
"""A named snapshot of one frame's widget arrangement (types,
placement, per-widget settings, button assignments) -- owned by a
*user*, not a frame, so it can be applied to any frame that user
controls whose grid matches (see docs/widgets.md's "Saved layouts").
cols/rows is the grid.grid_dims(orientation) the snapshot was taken
at -- an 8x5 (landscape-class) layout isn't meaningful on a 5x8
(portrait-class) frame, same reasoning as grid.py's own orientation-
change note.
Saving again with a name that already exists for this user
overwrites that layout's snapshot in place (see routers/
api_layouts.py's api_layout_save) rather than erroring or quietly
creating a second layout with the same name -- the "named save slot"
behavior people expect."""
__tablename__ = "saved_layouts"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
name: Mapped[str] = mapped_column(String)
cols: Mapped[int] = mapped_column(Integer)
rows: Mapped[int] = mapped_column(Integer)
created_at: Mapped[float] = mapped_column(Float, default=time.time)
updated_at: Mapped[float] = mapped_column(Float, default=time.time)
__table_args__ = (
Index("ix_saved_layouts_user_name", "user_id", "name", unique=True),
)
class SavedLayoutWidget(Base):
"""One captured widget's type/placement/settings within a
SavedLayout -- the snapshot analogue of Widget plus its per-type
config row, minus anything that's runtime/cache state rather than an
authored setting (a photo widget's current queue position, a
calendar's fetch cache, a whiteboard's rendered-image cache, etc.)
-- see api_layouts.LAYOUT_CONFIG_FIELDS for the exact per-type field
allowlist. `config` holds every JSON-safe captured setting; `image`
is only ever populated for a static-image widget's uploaded bytes
(its own BLOB column rather than folding base64 into the JSON, same
reasoning as StaticWidgetConfig.image itself)."""
__tablename__ = "saved_layout_widgets"
id: Mapped[int] = mapped_column(primary_key=True)
saved_layout_id: Mapped[int] = mapped_column(ForeignKey("saved_layouts.id", ondelete="CASCADE"))
widget_type: Mapped[str] = mapped_column(String)
x: Mapped[int] = mapped_column(Integer)
y: Mapped[int] = mapped_column(Integer)
w: Mapped[int] = mapped_column(Integer)
h: Mapped[int] = mapped_column(Integer)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
config: Mapped[dict] = mapped_column(JSON, default=dict)
image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
__table_args__ = (Index("ix_saved_layout_widgets_layout", "saved_layout_id"),)
class SavedLayoutSource(Base):
"""One included calendar/task-list source captured on a calendar or
tasks SavedLayoutWidget -- the snapshot analogue of FrameCalendar/
FrameTaskList. `kind` ("calendar" | "task") distinguishes which,
since both shapes are otherwise identical and sharing one table
avoids a near-duplicate SavedLayoutTaskSource table."""
__tablename__ = "saved_layout_sources"
id: Mapped[int] = mapped_column(primary_key=True)
saved_layout_widget_id: Mapped[int] = mapped_column(
ForeignKey("saved_layout_widgets.id", ondelete="CASCADE")
)
kind: Mapped[str] = mapped_column(String)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
calendar_key: Mapped[str] = mapped_column(String)
calendar_label: Mapped[str] = mapped_column(String, default="")
included: Mapped[bool] = mapped_column(Boolean, default=True)
color_index: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
__table_args__ = (Index("ix_saved_layout_sources_widget", "saved_layout_widget_id"),)
class SavedLayoutButtonAction(Base):
"""One (button, action) binding captured for one SavedLayoutWidget --
the snapshot analogue of FrameButtonAction. References the captured
widget directly rather than a frame_id/widget_id pair (neither
exists until the layout is applied) so applying can remap it onto
whichever new Widget row that captured widget becomes -- see
routers/api_layouts.py's api_layout_apply."""
__tablename__ = "saved_layout_button_actions"
id: Mapped[int] = mapped_column(primary_key=True)
saved_layout_widget_id: Mapped[int] = mapped_column(
ForeignKey("saved_layout_widgets.id", ondelete="CASCADE")
)
button: Mapped[str] = mapped_column(String)
action: Mapped[str] = mapped_column(String)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
__table_args__ = (Index("ix_saved_layout_button_actions_widget", "saved_layout_widget_id"),)
class PendingClaim(Base):
"""A claim submitted before the frame's first check-in (the user beat
the device to the server after provisioning). Attached automatically
when a device with this id self-registers; expired rows are pruned
opportunistically."""
__tablename__ = "pending_claims"
device_id: Mapped[str] = mapped_column(String, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
created_at: Mapped[float] = mapped_column(Float, default=time.time)
expires_at: Mapped[float] = mapped_column(Float)
class ServerSettings(Base):
"""Singleton row (id always 1) holding operator-level SMTP config, set
from /admin -- not env vars, since this is infrastructure a household
admin configures once through the UI rather than at container
deploy time. Used for password-reset emails and battery-threshold
alerts (see app/mail.py). smtp_host empty = email sending disabled;
every send site checks that and no-ops rather than erroring."""
__tablename__ = "server_settings"
id: Mapped[int] = mapped_column(primary_key=True)
smtp_host: Mapped[str] = mapped_column(String, default="")
smtp_port: Mapped[int] = mapped_column(Integer, default=587)
smtp_username: Mapped[str] = mapped_column(String, default="")
smtp_password: Mapped[str] = mapped_column(String, default="")
smtp_from_address: Mapped[str] = mapped_column(String, default="")
# "none" (plaintext, port 25 typically), "starttls" (upgrades a
# plaintext connection, port 587 typically), or "ssl" (TLS from the
# first byte -- a different handshake entirely, not just starttls()
# skipped; port 465 typically). See app/mail.py.
smtp_encryption: Mapped[str] = mapped_column(String, default="starttls")
class PasswordResetToken(Base):
"""A single-use, time-limited "forgot password" link. token is the
URL-safe secret itself (not hashed, like PendingClaim/manage_token --
it's a short-lived bearer credential emailed once, not a long-lived
session secret)."""
__tablename__ = "password_reset_tokens"
token: Mapped[str] = mapped_column(String, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
created_at: Mapped[float] = mapped_column(Float, default=time.time)
expires_at: Mapped[float] = mapped_column(Float)
class BatteryLog(Base):
"""Every battery report ever, per frame -- the permanent record behind
the battery history chart (was a 20k-entry JSON array in config.json)."""
__tablename__ = "battery_log"
id: Mapped[int] = mapped_column(primary_key=True)
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
ts: Mapped[float] = mapped_column(Float)
percent: Mapped[int] = mapped_column(Integer)
__table_args__ = (Index("ix_battery_log_frame_ts", "frame_id", "ts"),)