The weather widget's icons/layout are hand-drawn PIL primitives -- clean under quantization but flat, no gradients/shadows. Adds an opt-in render_style="modern" (current/daily modes only) that instead renders a Jinja2 template through a persistent headless-Chromium browser (app/html_render.py), following the approach of Tesserae, an open-source e-ink dashboard targeting this same panel family. Key design points: - The Chromium dependency (Playwright) is lazily imported only when a weather widget actually uses "modern" style, and the background browser itself only launches on first use -- every other widget type, and this one's own classic/hourly/multi_city paths, never pay for it. - No Frame-level dithering setting needed: html_render dithers its own rendered widget to exact palette colors (Bayer/ordered, not Floyd-Steinberg) before compositing, so the shared whole-canvas Floyd-Steinberg pass sees zero quantization error there and leaves it untouched -- same trick draw_text/hand-drawn icons already use. Floyd- Steinberg keeps working unchanged for photos and every other widget. - A "Load calibrated Spectra 6 preset" button in Advanced configuration offers a community-measured palette (data ported from paperlesspaper/epdoptimize, Apache 2.0) as an alternative starting point to the existing idealized DEFAULT_PALETTE_RGB -- fills the existing palette table, doesn't save by itself. Known open risk, not resolved here: a headless Chromium binary is far larger than the ~100MB single-layer limit that already forced this project's pip/npm installs into split layers, and (unlike those) is a single ~180MB file that can't be split across layers by ordinary Dockerfile restructuring. Flagged prominently in server/Dockerfile and docs/widgets.md -- treat this render style as experimental/local-only until that's resolved.
920 lines
50 KiB
Python
920 lines
50 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.
|
|
`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")
|
|
|
|
|
|
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"),)
|