The server now records exactly what was last sent to the device on
every device-facing render (/frame/image, /frame/advance, /frame/back,
and the global hold actions), persisted as Frame.last_displayed_image/
_at and served back via GET /api/frames/{id}/now-displaying. The
header thumbnail is split into that frozen "now displaying" snapshot
and the existing live "up next" re-render, with an arrow between them
-- so editing a layout shows the change immediately on the right while
the left stays exactly what's actually on the panel until the device's
next real wake.
914 lines
49 KiB
Python
914 lines
49 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 only for
|
|
# the migrated legacy frame 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="")
|
|
# Renderer dispatch seam -- "photos", "calendar", or "whiteboard"
|
|
# (see routers/device.py RENDERERS/ADVANCE_RENDERERS/BACK_RENDERERS
|
|
# and routers/common.py FRAME_MODES).
|
|
mode: Mapped[str] = mapped_column(String, default="photos")
|
|
# 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)
|
|
# Migration window: this frame also accepts the legacy shared
|
|
# MANAGEMENT_TOKEN (and no-id requests resolve to it). Only ever the
|
|
# migrated frame #1; cleared from /admin once the device is on
|
|
# per-frame auth.
|
|
legacy_token_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
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 (attribute names match the old FrameConfig fields) --
|
|
album_id: Mapped[str] = mapped_column(String, default="")
|
|
order: Mapped[str] = mapped_column("photo_order", String, default="sequential")
|
|
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")
|
|
# How a photo's aspect ratio is reconciled with the panel's -- see
|
|
# image_pipeline.DISPLAY_MODES.
|
|
display_mode: Mapped[str] = mapped_column(String, default="crop_faces")
|
|
orientation: Mapped[str] = mapped_column(String, default="landscape")
|
|
queue_target_len: Mapped[int] = mapped_column(Integer, default=20)
|
|
# 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)
|
|
|
|
# -- calendar mode (see calendar_feed.py, calendar_render.py,
|
|
# routers/device.py's RENDERERS["calendar"]) --
|
|
calendar_view: Mapped[str] = mapped_column(String, default="agenda") # "agenda" | "week" | "month"
|
|
# 0=Monday..6=Sunday (matches date.weekday()/calendar.Calendar) --
|
|
# which day week/month views start their grid on.
|
|
calendar_week_start: Mapped[int] = mapped_column(Integer, default=0)
|
|
# Agenda view only; reuses this frame's existing photos-mode album/
|
|
# queue, not a separate photo setup.
|
|
calendar_photo_inlay: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
# How many periods (unit depends on calendar_view: days/weeks/months)
|
|
# NEXT/BACK have browsed from "today". Reset to 0 by the next normal
|
|
# (non-button) /frame/image request, and whenever calendar_view
|
|
# itself changes -- a stale offset means something different in a
|
|
# different view's units.
|
|
calendar_browse_offset: Mapped[int] = mapped_column(Integer, default=0)
|
|
# Throttled merge-fetch cache (see routers/common.py's
|
|
# get_or_refresh_calendar_events) -- same shape as the
|
|
# firmware_update_checked_at/firmware_gitea_latest_version pattern
|
|
# below. One shared cache for every included user's merged events,
|
|
# not per-user.
|
|
calendar_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
|
calendar_cached_events: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
|
# "" when the last merge-fetch fully succeeded, else e.g. "1 of 2
|
|
# calendars unavailable" -- never names which user's feed failed, a
|
|
# shared household display shouldn't call out a specific person's
|
|
# outage to everyone who looks at it.
|
|
calendar_fetch_summary: Mapped[str] = mapped_column(String, default="")
|
|
|
|
# Optional weather strip, agenda/today & tomorrow/week views only --
|
|
# never month, there's no room (see calendar_render.py's _BUILDERS).
|
|
# Off by default.
|
|
calendar_weather_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
calendar_weather_units: Mapped[str] = mapped_column(String, default="fahrenheit") # "fahrenheit" | "celsius"
|
|
# [{"label", "latitude", "longitude"}, ...] -- each geocoded once via
|
|
# weather.geocode_city() when added from the Calendar tab.
|
|
calendar_weather_cities: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
|
# Throttled per-city forecast cache (see routers/common.py's
|
|
# get_or_refresh_weather) -- same shape idiom as
|
|
# calendar_checked_at/calendar_cached_events above.
|
|
# [{"label", "days": {"YYYY-MM-DD": {"code","high","low"}}}, ...]
|
|
calendar_weather_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
|
calendar_weather_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
|
|
|
# Week view: how many days to show (2-10, default 7 -- the original
|
|
# fixed behavior) and whether they're laid out as side-by-side
|
|
# columns or stacked bands (see calendar_render.py's _build_week).
|
|
calendar_week_days: Mapped[int] = mapped_column(Integer, default=7)
|
|
calendar_week_layout: Mapped[str] = mapped_column(String, default="horizontal") # "horizontal" | "vertical"
|
|
# Only used when calendar_week_days != 7 -- 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 view instead starts this many days from today (0 =
|
|
# starts today, negative = starts in the past, positive = starts in
|
|
# the future). Ignored (calendar_week_start governs instead) at the
|
|
# default 7 days, so this has no effect until someone actually
|
|
# changes the day count.
|
|
calendar_week_start_offset: Mapped[int] = mapped_column(Integer, default=0)
|
|
|
|
# Optional task list, week view only -- takes the space of one day
|
|
# slot rather than adding an extra one (see calendar_render.py's
|
|
# _draw_tasks). CalDAV only (a task list is a VTODO collection, not
|
|
# something a plain ICS subscription meaningfully has); source is
|
|
# one specific linked user's own CalDAV calendar, same
|
|
# owner-controls-their-own-data permission split as FrameCalendar.
|
|
# calendar_tasks_user_id
|
|
# SET NULL on the user's deletion clears the source rather than
|
|
# leaving a dangling reference (checked_at isn't reset by that, but
|
|
# the next refresh attempt finds no source and just returns []).
|
|
calendar_tasks_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
calendar_tasks_user_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
|
)
|
|
calendar_tasks_calendar_key: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
calendar_tasks_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
|
# [{"summary", "due" (ISO date/datetime string or None)}, ...],
|
|
# already filtered to outstanding (not-completed) tasks and sorted
|
|
# by due date -- see caldav_client.fetch_tasks.
|
|
calendar_tasks_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
|
|
|
# -- whiteboard mode (see webdav_client.py, whiteboard.py,
|
|
# routers/device.py's RENDERERS["whiteboard"]) -- a frame-wide
|
|
# setting like calendar mode's own frame_calendars source, not
|
|
# personal data, but still owner-gated the same way: only
|
|
# whiteboard_user_id may point the frame at their own account, since
|
|
# it's their credentials being used to fetch it. --
|
|
whiteboard_user_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
|
)
|
|
# The specific .whiteboard file's WebDAV URL -- pasted directly, same
|
|
# idiom as calendar_ics_url, not discovered/browsed (unlike CalDAV's
|
|
# account-has-several-calendars case, a WebDAV account doesn't need
|
|
# a picker step here since the user already knows which one file).
|
|
whiteboard_url: Mapped[str] = mapped_column(String, default="")
|
|
whiteboard_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
|
# Cached rendered PNG bytes (see whiteboard.fetch_and_render) --
|
|
# BLOB rather than the JSON columns the rest of this cache-pattern
|
|
# family uses, since this is binary image data, not JSON-shaped.
|
|
whiteboard_cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
|
|
|
# -- state --
|
|
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)
|
|
|
|
# -- 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)
|
|
|
|
__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)
|
|
|
|
|
|
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)
|
|
|
|
|
|
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)
|
|
|
|
|
|
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."""
|
|
|
|
__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
|
|
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")
|
|
|
|
|
|
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")
|
|
|
|
|
|
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."""
|
|
|
|
__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
|
|
|
|
|
|
# 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"),)
|