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