Redesign phase A: SQLite storage, per-frame data model, device identity
Replaces the single global config.json (whole-file pydantic model under one RLock) with SQLite via SQLAlchemy 2.0: users/sessions/frames/links/ pending-claims/battery_log tables (models.py), a per-frame lock registry (db.frame_locked) succeeding config.locked(), and hand-rolled schema versioning (migration.py). A pre-database deployment's config.json is imported verbatim as frame #1 on first boot and left untouched as the rollback path; the old single firmware.bin slot becomes per-frame firmware/<id>.bin. Routes split out of the 900-line main.py into routers/device.py (the frozen /frame/* protocol) and routers/api.py (web UI, still on the old single-frame paths for now). Device auth moves to require_device, which already speaks the full multi-frame protocol: per-frame device tokens pushed via /frame/config and acknowledged on first use, self- registration of unknown device ids as unclaimed frames, pending-claim attachment, and the legacy-token migration window that keeps the currently-deployed firmware (no id, shared MANAGEMENT_TOKEN) resolving to frame #1 -- including the one-time binding of its device id when it first reports one after a future OTA. Externally identical for existing deployments: same paths, same token semantics, same response shapes -- verified with a migration fixture, the legacy-device curl suite, a 20-way concurrent-advance smoke test, and a mutate-restart-assert persistence check against a fake Immich. photo_queue.py ports nearly verbatim onto the Frame ORM row (MutableList JSON columns make its in-place list mutations dirty-track); quiet-hours math extracted unchanged into quiet_hours.py.
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
"""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, 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="")
|
||||
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 for future calendar/canva modes -- only
|
||||
# "photos" is registered today (see routers/device.py RENDERERS).
|
||||
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")
|
||||
smart_crop_faces: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
orientation: Mapped[str] = mapped_column(String, default="landscape")
|
||||
queue_target_len: Mapped[int] = mapped_column(Integer, default=20)
|
||||
|
||||
# -- 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="")
|
||||
|
||||
# -- 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="")
|
||||
|
||||
# -- 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 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 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"),)
|
||||
Reference in New Issue
Block a user