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.
1451 lines
73 KiB
Python
1451 lines
73 KiB
Python
"""Schema versioning + one-time import of a legacy config.json deployment.
|
|
|
|
Hand-rolled on purpose (vs alembic): single worker, single SQLite file,
|
|
~30 lines of runner. Each migration is (version, fn(connection)); v1 is
|
|
just create_all. DDL stays dialect-neutral so a future move to Postgres
|
|
is a DATABASE_URL change, not a rewrite.
|
|
|
|
Run at import time from main.py, before any request is served.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import secrets
|
|
import shutil
|
|
import time
|
|
|
|
from sqlalchemy import inspect, select, text
|
|
|
|
from . import config, grid
|
|
from .db import SessionLocal, engine
|
|
from .models import (
|
|
Base,
|
|
BatteryLog,
|
|
Frame,
|
|
PhotoWidgetConfig,
|
|
ServerSettings,
|
|
Widget,
|
|
)
|
|
from .widgets import default_button_actions
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _migration_1(conn) -> None:
|
|
Base.metadata.create_all(bind=conn)
|
|
|
|
|
|
def _migration_2(conn) -> None:
|
|
"""Adds email (users) and battery-alert threshold (frames) columns,
|
|
plus the new server_settings/password_reset_tokens tables. ALTER
|
|
TABLE ADD COLUMN with a default is safe on SQLite against a live,
|
|
already-populated database -- existing rows just get the default."""
|
|
conn.execute(text("ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN battery_alert_threshold_pct INTEGER NOT NULL DEFAULT -1"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN battery_alert_sent INTEGER NOT NULL DEFAULT 0"))
|
|
Base.metadata.create_all(bind=conn) # creates the two new tables only; existing ones untouched
|
|
|
|
|
|
def _migration_3(conn) -> None:
|
|
"""Replaces the STARTTLS-or-nothing smtp_use_tls boolean with a
|
|
three-way smtp_encryption ("none"/"starttls"/"ssl") -- implicit TLS
|
|
(port 465 typically) is a different handshake entirely, not just a
|
|
skipped starttls() call, so it needs its own connection path in
|
|
app/mail.py."""
|
|
conn.execute(text("ALTER TABLE server_settings ADD COLUMN smtp_encryption TEXT NOT NULL DEFAULT 'starttls'"))
|
|
conn.execute(text(
|
|
"UPDATE server_settings SET smtp_encryption = CASE WHEN smtp_use_tls THEN 'starttls' ELSE 'none' END"
|
|
))
|
|
conn.execute(text("ALTER TABLE server_settings DROP COLUMN smtp_use_tls"))
|
|
|
|
|
|
def _migration_4(conn) -> None:
|
|
"""Advanced configuration: a per-frame color palette override. NULL
|
|
for every existing row -- exactly "use the default", no behavior
|
|
change until a frame's Configuration tab sets one."""
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN palette_rgb TEXT"))
|
|
|
|
|
|
def _migration_5(conn) -> None:
|
|
"""Replaces the smart_crop_faces boolean with display_mode (see
|
|
image_pipeline.DISPLAY_MODES) -- crop_faces/crop_fill are exactly
|
|
the old True/False behavior, stretch_fill/letterbox are new."""
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN display_mode TEXT NOT NULL DEFAULT 'crop_faces'"))
|
|
conn.execute(text(
|
|
"UPDATE frames SET display_mode = CASE WHEN smart_crop_faces THEN 'crop_faces' ELSE 'crop_fill' END"
|
|
))
|
|
conn.execute(text("ALTER TABLE frames DROP COLUMN smart_crop_faces"))
|
|
|
|
|
|
def _migration_6(conn) -> None:
|
|
"""Advanced configuration: color/contrast enhancement + dithering
|
|
strength (image_pipeline.render_frame). Defaults (1.0/1.0/1.0)
|
|
reproduce the exact previous rendering -- no behavior change until a
|
|
frame's Configuration tab adjusts one."""
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN color_boost REAL NOT NULL DEFAULT 1.0"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN contrast_boost REAL NOT NULL DEFAULT 1.0"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN dither_strength REAL NOT NULL DEFAULT 1.0"))
|
|
|
|
|
|
def _migration_7(conn) -> None:
|
|
"""Calendar frame mode: a personal ICS subscription per user
|
|
(users.calendar_ics_url), an explicit per-(user,frame) opt-in into a
|
|
frame's merged calendar (user_frames.calendar_included, default off
|
|
-- linking to a frame does not auto-include your calendar there),
|
|
and the frame-level view/inlay/browse-offset/cache settings calendar
|
|
mode needs (see calendar_feed.py, calendar_render.py,
|
|
routers/device.py's RENDERERS["calendar"]). Every new column has a
|
|
behavior-preserving default -- no existing frame's behavior changes
|
|
until its mode is actually switched to "calendar"."""
|
|
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_ics_url TEXT NOT NULL DEFAULT ''"))
|
|
conn.execute(text("ALTER TABLE user_frames ADD COLUMN calendar_included INTEGER NOT NULL DEFAULT 0"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_view TEXT NOT NULL DEFAULT 'agenda'"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_photo_inlay INTEGER NOT NULL DEFAULT 0"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_browse_offset INTEGER NOT NULL DEFAULT 0"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_checked_at REAL NOT NULL DEFAULT 0.0"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_cached_events TEXT"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_fetch_summary TEXT NOT NULL DEFAULT ''"))
|
|
|
|
|
|
def _migration_8(conn) -> None:
|
|
"""Configurable week-start day for calendar mode's week/month views
|
|
(0=Monday..6=Sunday, matching Python's date.weekday()/calendar.Calendar
|
|
convention exactly -- no translation needed at render time). Default 0
|
|
(Monday) matches calendar_render.py's previous hardcoded behavior, so
|
|
this is a no-op for every existing frame until changed."""
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_week_start INTEGER NOT NULL DEFAULT 0"))
|
|
|
|
|
|
|
|
def _migration_9(conn) -> None:
|
|
"""CalDAV support alongside the plain ICS subscription (see
|
|
caldav_client.py), and the frame_calendars table that replaces
|
|
user_frames.calendar_included now that one account (CalDAV) can
|
|
expose more than one calendar -- see models.py's FrameCalendar.
|
|
Existing single-calendar opt-ins are carried forward as "ics" rows
|
|
before the old column is dropped, so nobody's frame goes silently
|
|
calendar-less after this migration."""
|
|
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_url TEXT NOT NULL DEFAULT ''"))
|
|
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_username TEXT NOT NULL DEFAULT ''"))
|
|
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_password TEXT NOT NULL DEFAULT ''"))
|
|
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_calendars TEXT"))
|
|
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_checked_at REAL NOT NULL DEFAULT 0.0"))
|
|
|
|
conn.execute(text(
|
|
"CREATE TABLE frame_calendars ("
|
|
"id INTEGER PRIMARY KEY, "
|
|
"frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE, "
|
|
"user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
|
|
"calendar_key TEXT NOT NULL, "
|
|
"calendar_label TEXT NOT NULL DEFAULT '', "
|
|
"included INTEGER NOT NULL DEFAULT 1)"
|
|
))
|
|
conn.execute(text(
|
|
"CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (frame_id, user_id, calendar_key)"
|
|
))
|
|
conn.execute(text(
|
|
"INSERT INTO frame_calendars (frame_id, user_id, calendar_key, calendar_label, included) "
|
|
"SELECT uf.frame_id, uf.user_id, 'ics', 'My calendar', 1 "
|
|
"FROM user_frames uf JOIN users u ON u.id = uf.user_id "
|
|
"WHERE uf.calendar_included = 1 AND u.calendar_ics_url != ''"
|
|
))
|
|
conn.execute(text("ALTER TABLE user_frames DROP COLUMN calendar_included"))
|
|
|
|
|
|
def _migration_10(conn) -> None:
|
|
"""Optional weather strip for calendar mode (agenda/today & tomorrow/
|
|
week views -- never month, see calendar_render.py's _BUILDERS).
|
|
Multiple cities per frame (calendar_weather_cities), each geocoded
|
|
once via weather.py's Open-Meteo lookup (no API key) and their daily
|
|
forecasts refreshed on their own throttle, same shape idiom as
|
|
calendar_checked_at/calendar_cached_events. Off by default -- no
|
|
existing frame's render changes until its Calendar tab turns it on."""
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_enabled INTEGER NOT NULL DEFAULT 0"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_units TEXT NOT NULL DEFAULT 'fahrenheit'"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_cities TEXT"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_checked_at REAL NOT NULL DEFAULT 0.0"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_cached TEXT"))
|
|
|
|
|
|
def _migration_11(conn) -> None:
|
|
"""Manual per-calendar color choice (frame_calendars.color_index,
|
|
2-5 into image_pipeline.DEFAULT_PALETTE_RGB -- Yellow/Red/Blue/
|
|
Green). calendar_render.py's event color bar/dot used to auto-cycle
|
|
through those same four colors in whatever order calendars happened
|
|
to appear; this lets a household pin a specific one instead so it
|
|
stays stable and recognizable. NULL (the default) keeps the old
|
|
auto-cycle behavior -- no existing frame's render changes until
|
|
someone actually picks a color."""
|
|
conn.execute(text("ALTER TABLE frame_calendars ADD COLUMN color_index INTEGER"))
|
|
|
|
|
|
def _migration_12(conn) -> None:
|
|
"""Week view flexibility: a configurable day count (2-10, default 7
|
|
-- the original fixed behavior) and a horizontal/vertical layout
|
|
choice, plus an optional CalDAV task list that takes the space of
|
|
one day slot when enabled (see calendar_render.py's _build_week/
|
|
_draw_tasks). Every new column has a behavior-preserving default --
|
|
no existing frame's render changes until its Calendar tab touches
|
|
one of these."""
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_week_days INTEGER NOT NULL DEFAULT 7"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_week_layout TEXT NOT NULL DEFAULT 'horizontal'"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_enabled INTEGER NOT NULL DEFAULT 0"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_calendar_key TEXT"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_checked_at REAL NOT NULL DEFAULT 0.0"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_cached TEXT"))
|
|
|
|
|
|
def _migration_13(conn) -> None:
|
|
"""A day-count-relative start offset for the week view
|
|
(calendar_week_start_offset), used instead of calendar_week_start's
|
|
fixed-weekday anchor once the view isn't a literal 7-day week --
|
|
"start on the most recent Monday" stops meaning much for e.g. a
|
|
5-day view. Default 0 (starts today) is a behavior-preserving no-op
|
|
until someone changes the day count away from 7."""
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_week_start_offset INTEGER NOT NULL DEFAULT 0"))
|
|
|
|
|
|
def _migration_14(conn) -> None:
|
|
"""Whiteboard frame mode: generic WebDAV credentials per user
|
|
(webdav_username/password, plus webdav_reuse_caldav_creds as a
|
|
convenience when it's the same Nextcloud account as an already-
|
|
configured CalDAV one -- see models.py's User docstring), and the
|
|
frame-level whiteboard source (whiteboard_user_id/url) + rendered-
|
|
PNG cache (see webdav_client.py, whiteboard.py,
|
|
routers/device.py's RENDERERS["whiteboard"]). Every new column has a
|
|
behavior-preserving default -- no existing frame's render changes
|
|
until its mode is actually switched to "whiteboard"."""
|
|
conn.execute(text("ALTER TABLE users ADD COLUMN webdav_username TEXT NOT NULL DEFAULT ''"))
|
|
conn.execute(text("ALTER TABLE users ADD COLUMN webdav_password TEXT NOT NULL DEFAULT ''"))
|
|
conn.execute(text("ALTER TABLE users ADD COLUMN webdav_reuse_caldav_creds INTEGER NOT NULL DEFAULT 0"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_url TEXT NOT NULL DEFAULT ''"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_checked_at REAL NOT NULL DEFAULT 0.0"))
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_cached_image BLOB"))
|
|
|
|
|
|
def _migration_15(conn) -> None:
|
|
"""Optional starting folder for the whiteboard file-picker (see
|
|
models.py's User.webdav_base_url docstring) -- purely a browsing
|
|
convenience, never used for actual fetch/render."""
|
|
conn.execute(text("ALTER TABLE users ADD COLUMN webdav_base_url TEXT NOT NULL DEFAULT ''"))
|
|
|
|
|
|
def _migration_16(conn) -> None:
|
|
"""Widget system: a frame can now hold N independently placed/sized
|
|
widgets (photos/calendar/whiteboard) instead of exactly one mode-wide
|
|
renderer -- see models.py's Widget/PhotoWidgetConfig/
|
|
CalendarWidgetConfig/WhiteboardWidgetConfig/FrameButtonAction,
|
|
app/grid.py, app/widgets/.
|
|
|
|
This migration only creates the new (empty) tables -- it does NOT
|
|
backfill a widget per existing frame here. That backfill (reading
|
|
each frame's current mode/settings to build a widget that reproduces
|
|
its exact current display, including the calendar_photo_inlay ->
|
|
two-widgets special case) is real per-mode branching logic that's
|
|
much less error-prone written as typed ORM object construction than
|
|
as hand-written column-by-column SQL -- see _ensure_widgets_backfilled,
|
|
called unconditionally at the end of run_migrations() for both this
|
|
upgrade path AND the from-scratch _ensure_frame_one() path, so both
|
|
produce the same default-widget invariant from one place rather than
|
|
two separately-maintained ones. Every existing frame is briefly
|
|
widget-less between this migration and that call within the same
|
|
startup, not across restarts -- nothing reads these tables yet at
|
|
that point regardless."""
|
|
conn.execute(text(
|
|
"CREATE TABLE widgets ("
|
|
"id INTEGER PRIMARY KEY, "
|
|
"frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE, "
|
|
"widget_type TEXT NOT NULL, "
|
|
"x INTEGER NOT NULL, "
|
|
"y INTEGER NOT NULL, "
|
|
"w INTEGER NOT NULL, "
|
|
"h INTEGER NOT NULL, "
|
|
"sort_order INTEGER NOT NULL DEFAULT 0, "
|
|
"created_at REAL NOT NULL DEFAULT 0.0)"
|
|
))
|
|
conn.execute(text("CREATE INDEX ix_widgets_frame ON widgets (frame_id)"))
|
|
|
|
conn.execute(text(
|
|
"CREATE TABLE photo_widget_configs ("
|
|
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
|
"album_id TEXT NOT NULL DEFAULT '', "
|
|
"photo_order TEXT NOT NULL DEFAULT 'sequential', "
|
|
"display_mode TEXT NOT NULL DEFAULT 'crop_faces', "
|
|
"queue_target_len INTEGER NOT NULL DEFAULT 20, "
|
|
"current_asset_id TEXT NOT NULL DEFAULT '', "
|
|
"current_asset_set_at REAL NOT NULL DEFAULT 0.0, "
|
|
"queue TEXT NOT NULL DEFAULT '[]', "
|
|
"queue_cursor INTEGER NOT NULL DEFAULT 0, "
|
|
"history TEXT NOT NULL DEFAULT '[]', "
|
|
"excluded_asset_ids TEXT NOT NULL DEFAULT '[]')"
|
|
))
|
|
|
|
conn.execute(text(
|
|
"CREATE TABLE calendar_widget_configs ("
|
|
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
|
"view TEXT NOT NULL DEFAULT 'agenda', "
|
|
"week_start INTEGER NOT NULL DEFAULT 0, "
|
|
"browse_offset INTEGER NOT NULL DEFAULT 0, "
|
|
"checked_at REAL NOT NULL DEFAULT 0.0, "
|
|
"cached_events TEXT, "
|
|
"fetch_summary TEXT NOT NULL DEFAULT '', "
|
|
"weather_enabled INTEGER NOT NULL DEFAULT 0, "
|
|
"weather_units TEXT NOT NULL DEFAULT 'fahrenheit', "
|
|
"weather_cities TEXT, "
|
|
"weather_checked_at REAL NOT NULL DEFAULT 0.0, "
|
|
"weather_cached TEXT, "
|
|
"week_days INTEGER NOT NULL DEFAULT 7, "
|
|
"week_layout TEXT NOT NULL DEFAULT 'horizontal', "
|
|
"week_start_offset INTEGER NOT NULL DEFAULT 0, "
|
|
"tasks_enabled INTEGER NOT NULL DEFAULT 0, "
|
|
"tasks_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
|
|
"tasks_calendar_key TEXT, "
|
|
"tasks_checked_at REAL NOT NULL DEFAULT 0.0, "
|
|
"tasks_cached TEXT)"
|
|
))
|
|
|
|
conn.execute(text(
|
|
"CREATE TABLE whiteboard_widget_configs ("
|
|
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
|
"user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
|
|
"url TEXT NOT NULL DEFAULT '', "
|
|
"checked_at REAL NOT NULL DEFAULT 0.0, "
|
|
"cached_image BLOB)"
|
|
))
|
|
|
|
conn.execute(text(
|
|
"CREATE TABLE frame_button_actions ("
|
|
"id INTEGER PRIMARY KEY, "
|
|
"frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE, "
|
|
"button TEXT NOT NULL, "
|
|
"widget_id INTEGER NOT NULL REFERENCES widgets(id) ON DELETE CASCADE, "
|
|
"action TEXT NOT NULL, "
|
|
"sort_order INTEGER NOT NULL DEFAULT 0, "
|
|
"created_at REAL NOT NULL DEFAULT 0.0)"
|
|
))
|
|
conn.execute(text(
|
|
"CREATE INDEX ix_frame_button_actions_frame_button ON frame_button_actions (frame_id, button, sort_order)"
|
|
))
|
|
|
|
|
|
def _migration_17(conn) -> None:
|
|
"""Splits the calendar widget's old week-view-only task list out into
|
|
its own standalone widget type (see models.TaskWidgetConfig,
|
|
app/widgets/tasks.py) -- a task list is no longer tied to a
|
|
calendar's view or footprint, and can be placed/sized on its own.
|
|
|
|
Every calendar_widget_configs row that still has a task source
|
|
configured gets a new sibling `tasks` widget carrying that source
|
|
over, auto-placed in whatever open grid space is left on its frame
|
|
(same find_open_rect logic a manual "add widget" uses; if truly none
|
|
is left, the source is dropped and logged -- rare enough, and with
|
|
no interactive way to ask during a boot-time migration, that this is
|
|
an acceptable edge case). calendar_widget_configs then drops its now
|
|
-dead tasks_* columns -- this project's usual same-migration-drop
|
|
convention (see docs/widgets.md's Known Gaps for the one deliberate,
|
|
much-larger-blast-radius exception)."""
|
|
conn.execute(text(
|
|
"CREATE TABLE task_widget_configs ("
|
|
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
|
"user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
|
|
"calendar_key TEXT, "
|
|
"checked_at REAL NOT NULL DEFAULT 0.0, "
|
|
"cached TEXT)"
|
|
))
|
|
|
|
rows = conn.execute(text(
|
|
"SELECT cwc.widget_id, w.frame_id, f.orientation, "
|
|
"cwc.tasks_user_id, cwc.tasks_calendar_key, cwc.tasks_checked_at, cwc.tasks_cached "
|
|
"FROM calendar_widget_configs cwc "
|
|
"JOIN widgets w ON w.id = cwc.widget_id "
|
|
"JOIN frames f ON f.id = w.frame_id "
|
|
"WHERE cwc.tasks_calendar_key IS NOT NULL"
|
|
)).mappings().all()
|
|
|
|
skipped = 0
|
|
now = time.time()
|
|
for row in rows:
|
|
existing = conn.execute(text(
|
|
"SELECT x, y, w, h FROM widgets WHERE frame_id = :frame_id"
|
|
), {"frame_id": row["frame_id"]}).all()
|
|
min_w, min_h = grid.MIN_FOOTPRINT["tasks"]
|
|
rect = grid.find_open_rect(row["orientation"], [tuple(r) for r in existing], min_w, min_h)
|
|
if rect is None:
|
|
skipped += 1
|
|
continue
|
|
x, y, w, h = rect
|
|
max_sort = conn.execute(text(
|
|
"SELECT COALESCE(MAX(sort_order), 0) FROM widgets WHERE frame_id = :frame_id"
|
|
), {"frame_id": row["frame_id"]}).scalar()
|
|
result = conn.execute(text(
|
|
"INSERT INTO widgets (frame_id, widget_type, x, y, w, h, sort_order, created_at, "
|
|
"border_style, border_thickness, border_color_index) "
|
|
"VALUES (:frame_id, 'tasks', :x, :y, :w, :h, :sort_order, :created_at, 'none', 3, 0)"
|
|
), {"frame_id": row["frame_id"], "x": x, "y": y, "w": w, "h": h,
|
|
"sort_order": max_sort + 1, "created_at": now})
|
|
new_widget_id = result.lastrowid
|
|
conn.execute(text(
|
|
"INSERT INTO task_widget_configs (widget_id, user_id, calendar_key, checked_at, cached) "
|
|
"VALUES (:widget_id, :user_id, :calendar_key, :checked_at, :cached)"
|
|
), {"widget_id": new_widget_id, "user_id": row["tasks_user_id"],
|
|
"calendar_key": row["tasks_calendar_key"], "checked_at": row["tasks_checked_at"],
|
|
"cached": row["tasks_cached"]})
|
|
|
|
if skipped:
|
|
logger.warning(
|
|
"%d calendar widget(s) had a task list configured but no open grid space for a "
|
|
"standalone tasks widget -- their task source was dropped", skipped
|
|
)
|
|
|
|
# Rebuild calendar_widget_configs without the now-dead tasks_*
|
|
# columns -- SQLite can't drop tasks_user_id directly (it's part of
|
|
# an FK constraint), same situation frame_calendars hit in
|
|
# _migration_9, same rebuild-create-copy-drop-rename fix.
|
|
conn.execute(text(
|
|
"CREATE TABLE calendar_widget_configs_new ("
|
|
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
|
"view TEXT NOT NULL DEFAULT 'agenda', "
|
|
"week_start INTEGER NOT NULL DEFAULT 0, "
|
|
"browse_offset INTEGER NOT NULL DEFAULT 0, "
|
|
"checked_at REAL NOT NULL DEFAULT 0.0, "
|
|
"cached_events TEXT, "
|
|
"fetch_summary TEXT NOT NULL DEFAULT '', "
|
|
"weather_enabled INTEGER NOT NULL DEFAULT 0, "
|
|
"weather_units TEXT NOT NULL DEFAULT 'fahrenheit', "
|
|
"weather_cities TEXT, "
|
|
"weather_checked_at REAL NOT NULL DEFAULT 0.0, "
|
|
"weather_cached TEXT, "
|
|
"week_days INTEGER NOT NULL DEFAULT 7, "
|
|
"week_layout TEXT NOT NULL DEFAULT 'horizontal', "
|
|
"week_start_offset INTEGER NOT NULL DEFAULT 0)"
|
|
))
|
|
conn.execute(text(
|
|
"INSERT INTO calendar_widget_configs_new "
|
|
"(widget_id, view, week_start, browse_offset, checked_at, cached_events, fetch_summary, "
|
|
"weather_enabled, weather_units, weather_cities, weather_checked_at, weather_cached, "
|
|
"week_days, week_layout, week_start_offset) "
|
|
"SELECT widget_id, view, week_start, browse_offset, checked_at, cached_events, fetch_summary, "
|
|
"weather_enabled, weather_units, weather_cities, weather_checked_at, weather_cached, "
|
|
"week_days, week_layout, week_start_offset "
|
|
"FROM calendar_widget_configs"
|
|
))
|
|
conn.execute(text("DROP TABLE calendar_widget_configs"))
|
|
conn.execute(text("ALTER TABLE calendar_widget_configs_new RENAME TO calendar_widget_configs"))
|
|
|
|
|
|
def _migration_18(conn) -> None:
|
|
"""A tasks widget can now merge more than one person's CalDAV task
|
|
list, checkbox-included with an optional pinned color each -- same
|
|
multi-source shape calendar widgets already have (models.
|
|
FrameCalendar), rather than the single user_id/calendar_key pair
|
|
migration 17 gave TaskWidgetConfig when tasks first became their own
|
|
widget type. Also adds show_completed (see caldav_client.
|
|
fetch_tasks' completed_since -- off by default, so this migration
|
|
changes no widget's on-panel appearance by itself).
|
|
|
|
Each task_widget_configs row's existing single source, if any,
|
|
carries forward as that widget's first frame_task_lists row
|
|
(included) before the now-dead user_id/calendar_key columns are
|
|
dropped -- same "carry forward the old single opt-in as a row before
|
|
dropping the column" shape _migration_9 used for frame_calendars."""
|
|
conn.execute(text(
|
|
"CREATE TABLE frame_task_lists ("
|
|
"id INTEGER PRIMARY KEY, "
|
|
"widget_id INTEGER NOT NULL REFERENCES widgets(id) ON DELETE CASCADE, "
|
|
"user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
|
|
"calendar_key TEXT NOT NULL, "
|
|
"calendar_label TEXT NOT NULL DEFAULT '', "
|
|
"included INTEGER NOT NULL DEFAULT 1, "
|
|
"color_index INTEGER)"
|
|
))
|
|
conn.execute(text(
|
|
"CREATE UNIQUE INDEX ix_frame_task_lists_unique ON frame_task_lists (widget_id, user_id, calendar_key)"
|
|
))
|
|
conn.execute(text(
|
|
"INSERT INTO frame_task_lists (widget_id, user_id, calendar_key, included) "
|
|
"SELECT widget_id, user_id, calendar_key, 1 FROM task_widget_configs "
|
|
"WHERE calendar_key IS NOT NULL AND user_id IS NOT NULL"
|
|
))
|
|
|
|
conn.execute(text(
|
|
"CREATE TABLE task_widget_configs_new ("
|
|
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
|
"checked_at REAL NOT NULL DEFAULT 0.0, "
|
|
"cached TEXT, "
|
|
"show_completed INTEGER NOT NULL DEFAULT 0)"
|
|
))
|
|
conn.execute(text(
|
|
"INSERT INTO task_widget_configs_new (widget_id, checked_at, cached) "
|
|
"SELECT widget_id, checked_at, cached FROM task_widget_configs"
|
|
))
|
|
conn.execute(text("DROP TABLE task_widget_configs"))
|
|
conn.execute(text("ALTER TABLE task_widget_configs_new RENAME TO task_widget_configs"))
|
|
|
|
|
|
def _migration_19(conn) -> None:
|
|
"""Optional custom on-panel name for a tasks widget (see
|
|
calendar_render._draw_tasks), replacing the default "Tasks" header
|
|
-- 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 default) keeps the old hardcoded text, so
|
|
this changes no existing widget's appearance by itself. Plain
|
|
column add, no FK/index involved -- no rebuild-table dance needed
|
|
(unlike task_widget_configs' two previous migrations)."""
|
|
conn.execute(text("ALTER TABLE task_widget_configs ADD COLUMN name TEXT NOT NULL DEFAULT ''"))
|
|
|
|
|
|
def _migration_20(conn) -> None:
|
|
"""New widget type: a static image widget shows whatever single
|
|
image (or a PDF's first page) the user last uploaded (see
|
|
app/image_upload.py, routers/api_widgets.py's api_widget_static_
|
|
upload) -- no live upstream to poll, unlike every other widget type.
|
|
|
|
Raw CREATE TABLE, not Base.metadata.create_all (this migration
|
|
originally used create_all -- switched retroactively once it turned
|
|
out to matter): create_all creates every table declared in Base.
|
|
metadata that's missing, not just this migration's own new one, so
|
|
it would just as happily create text_widget_configs (a LATER
|
|
migration's model, once TextWidgetConfig existed in models.py) years
|
|
before migration 21 gets a turn -- and then migration 21's own
|
|
CREATE TABLE collides with the one create_all already snuck in. Same
|
|
fix, same reasoning as migration 21's own comment about migration
|
|
22's ALTER TABLE -- see that one for the fuller explanation."""
|
|
conn.execute(text(
|
|
"CREATE TABLE static_widget_configs ("
|
|
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
|
"image BLOB, "
|
|
"original_filename TEXT NOT NULL DEFAULT '', "
|
|
"uploaded_at REAL NOT NULL DEFAULT 0.0, "
|
|
"display_mode TEXT NOT NULL DEFAULT 'crop_fill')"
|
|
))
|
|
|
|
|
|
def _migration_21(conn) -> None:
|
|
"""New widget type: a text widget shows user-authored rich text (see
|
|
app/text_content.py, app/widgets/text.py, models.TextWidgetConfig)
|
|
-- another no-live-upstream type like migration 20's static image.
|
|
|
|
Raw CREATE TABLE (not Base.metadata.create_all, unlike migration 20's
|
|
static_widget_configs) because migration 22 adds a column to this
|
|
same table right after -- create_all always reflects models.py's
|
|
CURRENT shape, so replaying the full migration chain on an old
|
|
database would have it already include that later column and
|
|
collide with migration 22's ALTER TABLE. Same reason migration 17's
|
|
task_widget_configs CREATE TABLE is raw SQL rather than create_all,
|
|
ahead of migration 19's ALTER TABLE ADD COLUMN name."""
|
|
conn.execute(text(
|
|
"CREATE TABLE text_widget_configs ("
|
|
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
|
"content TEXT, "
|
|
"font_size INTEGER NOT NULL DEFAULT 28, "
|
|
"align TEXT NOT NULL DEFAULT 'left', "
|
|
"background_color TEXT NOT NULL DEFAULT '#ffffff')"
|
|
))
|
|
|
|
|
|
def _migration_22(conn) -> None:
|
|
"""Adds a font family choice to the text widget (app/widgets/text.py's
|
|
FONT_FAMILIES) alongside its existing font_size -- both whole-widget
|
|
settings, not per-run. "sans" (Noto Sans) matches the column default
|
|
so existing text widgets keep rendering in the same font they always
|
|
have."""
|
|
conn.execute(text("ALTER TABLE text_widget_configs ADD COLUMN font_family TEXT NOT NULL DEFAULT 'sans'"))
|
|
|
|
|
|
def _migration_23(conn) -> None:
|
|
"""New feature: named, user-owned saved layouts (see models.
|
|
SavedLayout/SavedLayoutWidget/SavedLayoutSource/
|
|
SavedLayoutButtonAction, routers/api_layouts.py) -- a snapshot of a
|
|
frame's widget arrangement a user can capture and later apply to any
|
|
frame they control whose grid matches, instead of manually rebuilding
|
|
it widget by widget.
|
|
|
|
Raw CREATE TABLE, not Base.metadata.create_all, same reasoning as
|
|
migration 20/21's own comments: create_all always reflects models.py's
|
|
CURRENT shape, so replaying the full chain on an old database could
|
|
collide with a later migration's ALTER TABLE on one of these same
|
|
tables."""
|
|
conn.execute(text(
|
|
"CREATE TABLE saved_layouts ("
|
|
"id INTEGER PRIMARY KEY, "
|
|
"user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
|
|
"name TEXT NOT NULL, "
|
|
"cols INTEGER NOT NULL, "
|
|
"rows INTEGER NOT NULL, "
|
|
"created_at REAL NOT NULL DEFAULT 0.0, "
|
|
"updated_at REAL NOT NULL DEFAULT 0.0)"
|
|
))
|
|
conn.execute(text(
|
|
"CREATE UNIQUE INDEX ix_saved_layouts_user_name ON saved_layouts (user_id, name)"
|
|
))
|
|
conn.execute(text(
|
|
"CREATE TABLE saved_layout_widgets ("
|
|
"id INTEGER PRIMARY KEY, "
|
|
"saved_layout_id INTEGER NOT NULL REFERENCES saved_layouts(id) ON DELETE CASCADE, "
|
|
"widget_type TEXT NOT NULL, "
|
|
"x INTEGER NOT NULL, y INTEGER NOT NULL, w INTEGER NOT NULL, h INTEGER NOT NULL, "
|
|
"sort_order INTEGER NOT NULL DEFAULT 0, "
|
|
"config TEXT NOT NULL DEFAULT '{}', "
|
|
"image BLOB)"
|
|
))
|
|
conn.execute(text(
|
|
"CREATE INDEX ix_saved_layout_widgets_layout ON saved_layout_widgets (saved_layout_id)"
|
|
))
|
|
conn.execute(text(
|
|
"CREATE TABLE saved_layout_sources ("
|
|
"id INTEGER PRIMARY KEY, "
|
|
"saved_layout_widget_id INTEGER NOT NULL REFERENCES saved_layout_widgets(id) ON DELETE CASCADE, "
|
|
"kind TEXT NOT NULL, "
|
|
"user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
|
|
"calendar_key TEXT NOT NULL, "
|
|
"calendar_label TEXT NOT NULL DEFAULT '', "
|
|
"included INTEGER NOT NULL DEFAULT 1, "
|
|
"color_index INTEGER)"
|
|
))
|
|
conn.execute(text(
|
|
"CREATE INDEX ix_saved_layout_sources_widget ON saved_layout_sources (saved_layout_widget_id)"
|
|
))
|
|
conn.execute(text(
|
|
"CREATE TABLE saved_layout_button_actions ("
|
|
"id INTEGER PRIMARY KEY, "
|
|
"saved_layout_widget_id INTEGER NOT NULL REFERENCES saved_layout_widgets(id) ON DELETE CASCADE, "
|
|
"button TEXT NOT NULL, "
|
|
"action TEXT NOT NULL, "
|
|
"sort_order INTEGER NOT NULL DEFAULT 0)"
|
|
))
|
|
conn.execute(text(
|
|
"CREATE INDEX ix_saved_layout_button_actions_widget ON saved_layout_button_actions (saved_layout_widget_id)"
|
|
))
|
|
|
|
|
|
def _migration_24(conn) -> None:
|
|
"""New widget type: standalone weather (current/hourly/daily/
|
|
multi_city display modes, pluggable Open-Meteo/NWS providers -- see
|
|
models.WeatherWidgetConfig, app/weather/, app/widgets/weather.py).
|
|
Lifts the calendar widget's embedded weather strip's underlying
|
|
fetch/render building blocks (app/weather/open_meteo.py, the icon-
|
|
drawing primitives now in app/weather_render.py) out into a widget
|
|
that can be placed/sized on its own -- CalendarWidgetConfig's own
|
|
weather_* columns are untouched, still working exactly as before.
|
|
|
|
Raw CREATE TABLE, not Base.metadata.create_all, same reasoning as
|
|
migration 20/21/23's own comments: create_all always reflects
|
|
models.py's CURRENT shape, so replaying the full chain on an old
|
|
database could collide with a later migration's ALTER TABLE on this
|
|
same table."""
|
|
conn.execute(text(
|
|
"CREATE TABLE weather_widget_configs ("
|
|
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
|
"mode TEXT NOT NULL DEFAULT 'current', "
|
|
"provider TEXT NOT NULL DEFAULT 'open_meteo', "
|
|
"units TEXT NOT NULL DEFAULT 'fahrenheit', "
|
|
"city_label TEXT, "
|
|
"city_latitude REAL, "
|
|
"city_longitude REAL, "
|
|
"hourly_interval_hours INTEGER NOT NULL DEFAULT 4, "
|
|
"daily_days INTEGER NOT NULL DEFAULT 5, "
|
|
"cities TEXT, "
|
|
"checked_at REAL NOT NULL DEFAULT 0.0, "
|
|
"cached TEXT)"
|
|
))
|
|
|
|
|
|
def _migration_25(conn) -> None:
|
|
"""New widget type: battery (see models.BatteryWidgetConfig,
|
|
app/widgets/battery.py) -- shows the frame's own last-reported
|
|
battery level. No live upstream to poll and nothing to cache: unlike
|
|
every other widget type added since migration 20, the content is
|
|
frame-level state (Frame.battery_percent/battery_as_of) that already
|
|
existed before this widget did, so the only new column is a display
|
|
mode.
|
|
|
|
Raw CREATE TABLE, not Base.metadata.create_all, same reasoning as
|
|
migration 20/21/23/24's own comments: create_all always reflects
|
|
models.py's CURRENT shape, so replaying the full chain on an old
|
|
database could collide with a later migration's ALTER TABLE on this
|
|
same table."""
|
|
conn.execute(text(
|
|
"CREATE TABLE battery_widget_configs ("
|
|
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
|
"mode TEXT NOT NULL DEFAULT 'detailed')"
|
|
))
|
|
|
|
|
|
def _migration_26(conn) -> None:
|
|
"""Per-widget border (see models.Widget.border_style/border_thickness/
|
|
border_color_index, image_pipeline.draw_widget_border) -- a shared
|
|
property on the widgets table itself, not a per-type config table,
|
|
since every widget type can have one regardless of widget_type.
|
|
border_style defaults to 'none' so existing widgets keep rendering
|
|
exactly as before until someone opts in via a widget's dialog.
|
|
|
|
Guarded per-column (unlike every earlier ALTER TABLE ADD COLUMN
|
|
migration in this file) because widgets is the one table
|
|
test_migrations.py's upgrade-path tests deliberately leave un-dropped
|
|
across a simulated old-schema_version replay (see those tests' own
|
|
comments: it hasn't changed shape since migration 16 created it, so
|
|
reusing the fresh-install create_all() copy -- which, unlike this
|
|
ALTER, already reflects models.py's current border_* columns -- was
|
|
safe up to now). Without the guard, replaying this migration in that
|
|
scenario re-adds a column that's already there and SQLite raises
|
|
"duplicate column name"."""
|
|
existing = {c["name"] for c in inspect(conn).get_columns("widgets")}
|
|
if "border_style" not in existing:
|
|
conn.execute(text("ALTER TABLE widgets ADD COLUMN border_style TEXT NOT NULL DEFAULT 'none'"))
|
|
if "border_thickness" not in existing:
|
|
conn.execute(text("ALTER TABLE widgets ADD COLUMN border_thickness INTEGER NOT NULL DEFAULT 3"))
|
|
if "border_color_index" not in existing:
|
|
conn.execute(text("ALTER TABLE widgets ADD COLUMN border_color_index INTEGER NOT NULL DEFAULT 0"))
|
|
|
|
|
|
def _migration_27(conn) -> None:
|
|
"""Per-photo-widget lock (models.PhotoWidgetConfig.locked) -- freezes
|
|
current_asset_id against both the timer-elapsed auto-advance
|
|
(photo_queue.get_current) and the advance/back button actions
|
|
(app/widgets/photos.py's ACTIONS) until unlocked. Defaults to
|
|
unlocked so existing widgets keep rotating exactly as before.
|
|
|
|
Guarded per-column, same reasoning as migration 26's own comment:
|
|
photo_widget_configs isn't touched by test_migrations.py's simulated
|
|
pre-widget-system replays (unlike calendar/task/widgets tables those
|
|
tests DROP and recreate in an old shape), so it keeps the fresh-
|
|
install create_all() copy -- which already has this column -- when
|
|
those tests replay migrations 17+ from schema_version 16. Without
|
|
the guard, replaying this migration there re-adds a column that's
|
|
already there and SQLite raises "duplicate column name"."""
|
|
existing = {c["name"] for c in inspect(conn).get_columns("photo_widget_configs")}
|
|
if "locked" not in existing:
|
|
conn.execute(text("ALTER TABLE photo_widget_configs ADD COLUMN locked INTEGER NOT NULL DEFAULT 0"))
|
|
|
|
|
|
def _migration_28(conn) -> None:
|
|
"""One action per (widget, button) instead of an ordered per-button
|
|
list -- button-action editing moved from the frame-level "Button
|
|
assignments" card into each widget's own config dialog (see
|
|
models.FrameButtonAction's updated docstring, routers/api_widgets.py's
|
|
api_widget_config_save). Cross-widget execution order never actually
|
|
mattered (each widget's action only touches its own state), so this
|
|
only needs to de-dupe down to one row before the new unique index can
|
|
be created -- MIN(id) per (widget_id, button) survives, arbitrarily
|
|
but deterministically, since which specific extra binding a user's
|
|
old list happened to have doesn't matter anymore."""
|
|
conn.execute(text(
|
|
"DELETE FROM frame_button_actions WHERE id NOT IN "
|
|
"(SELECT MIN(id) FROM frame_button_actions GROUP BY widget_id, button)"
|
|
))
|
|
conn.execute(text(
|
|
"CREATE UNIQUE INDEX IF NOT EXISTS ix_frame_button_actions_widget_button "
|
|
"ON frame_button_actions (widget_id, button)"
|
|
))
|
|
|
|
|
|
def _migration_29(conn) -> None:
|
|
"""Hold-for-global-action (see app/global_actions.py): holding NEXT/
|
|
BACK past hold_duration_ms triggers a frame-wide action instead of
|
|
the per-widget one a short press runs. next_hold_action/
|
|
back_hold_action are NULL (disabled) by default -- existing frames
|
|
get no new button behavior until someone opts in on the
|
|
Configuration tab. last_cycled_layout_id tracks where a repeated
|
|
"cycle saved layouts" hold should resume from.
|
|
|
|
Guarded per-column, same reasoning as migration 26/27's own
|
|
comments: frames is a table test_migrations.py's pre-widget-system
|
|
replay tests leave un-dropped (unlike calendar/task/widget tables
|
|
those tests DROP and recreate in an old shape), so it keeps the
|
|
fresh-install create_all() copy -- which already has these columns
|
|
-- when those tests replay migrations 17+ from schema_version 16.
|
|
Without the guard, replaying this migration there re-adds a column
|
|
that's already there and SQLite raises "duplicate column name"."""
|
|
existing = {c["name"] for c in inspect(conn).get_columns("frames")}
|
|
if "hold_duration_ms" not in existing:
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN hold_duration_ms INTEGER NOT NULL DEFAULT 3000"))
|
|
if "next_hold_action" not in existing:
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN next_hold_action TEXT"))
|
|
if "back_hold_action" not in existing:
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN back_hold_action TEXT"))
|
|
if "last_cycled_layout_id" not in existing:
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN last_cycled_layout_id INTEGER"))
|
|
|
|
|
|
def _migration_30(conn) -> None:
|
|
""""Now displaying" (models.Frame.last_displayed_image/
|
|
last_displayed_at) -- the web UI's header preview pair needs a frozen
|
|
record of exactly what the last device-facing render actually sent,
|
|
separate from the always-live "up next" re-render (see
|
|
routers/device.py's _record_last_displayed, api_frames.py's
|
|
/now-displaying endpoint). NULL/0.0 for every existing frame until
|
|
its next real device fetch -- no behavior change to what's served,
|
|
only a new thing recorded alongside it.
|
|
|
|
Guarded per-column, same reasoning as migration 26/27/29's own
|
|
comments: frames is a table test_migrations.py's pre-widget-system
|
|
replay tests leave un-dropped, so it keeps the fresh-install
|
|
create_all() copy -- which already has these columns -- when those
|
|
tests replay migrations 17+ from schema_version 16. Without the
|
|
guard, replaying this migration there re-adds a column that's already
|
|
there and SQLite raises "duplicate column name"."""
|
|
existing = {c["name"] for c in inspect(conn).get_columns("frames")}
|
|
if "last_displayed_image" not in existing:
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN last_displayed_image BLOB"))
|
|
if "last_displayed_at" not in existing:
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN last_displayed_at REAL NOT NULL DEFAULT 0.0"))
|
|
|
|
|
|
def _migration_31(conn) -> None:
|
|
"""Weather widget render style (models.WeatherWidgetConfig.
|
|
render_style): "classic" (existing hand-drawn PIL renderer,
|
|
unchanged) or "modern" (app/html_render.py's headless-Chromium/CSS
|
|
renderer). Every existing weather widget defaults to "classic" --
|
|
no behavior change until a widget's dialog switches it.
|
|
|
|
Guarded per-column, same reasoning as migration 30's own comment:
|
|
weather_widget_configs is a table some replay tests may re-create
|
|
fresh via create_all() (which already has this column) rather than
|
|
replaying migration 24's raw CREATE TABLE."""
|
|
existing = {c["name"] for c in inspect(conn).get_columns("weather_widget_configs")}
|
|
if "render_style" not in existing:
|
|
conn.execute(text("ALTER TABLE weather_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
|
|
|
|
|
|
def _migration_32(conn) -> None:
|
|
"""Photos widget's own independent palette/dithering (models.Frame.
|
|
photo_palette_rgb/photo_dither_strength -- see widgets/photos.py's
|
|
render()). NULL/1.0 defaults reproduce the exact previous rendering
|
|
(same reference palette/strength as the main fields) until a frame's
|
|
Configuration tab sets them differently.
|
|
|
|
Guarded per-column, same reasoning as migration 30/31's own comments."""
|
|
existing = {c["name"] for c in inspect(conn).get_columns("frames")}
|
|
if "photo_palette_rgb" not in existing:
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN photo_palette_rgb TEXT"))
|
|
if "photo_dither_strength" not in existing:
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN photo_dither_strength REAL NOT NULL DEFAULT 1.0"))
|
|
|
|
|
|
def _migration_33(conn) -> None:
|
|
"""Battery widget render style (models.BatteryWidgetConfig.
|
|
render_style) -- same shape as migration 31's weather one. Every
|
|
existing battery widget defaults to "classic", no behavior change."""
|
|
existing = {c["name"] for c in inspect(conn).get_columns("battery_widget_configs")}
|
|
if "render_style" not in existing:
|
|
conn.execute(text("ALTER TABLE battery_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
|
|
|
|
|
|
def _migration_34(conn) -> None:
|
|
"""Text widget render style (models.TextWidgetConfig.render_style) --
|
|
same shape as migration 31/33. Every existing text widget defaults to
|
|
"classic", no behavior change."""
|
|
existing = {c["name"] for c in inspect(conn).get_columns("text_widget_configs")}
|
|
if "render_style" not in existing:
|
|
conn.execute(text("ALTER TABLE text_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
|
|
|
|
|
|
def _migration_35(conn) -> None:
|
|
"""Tasks widget render style (models.TaskWidgetConfig.render_style)
|
|
-- same shape as migration 31/33/34. Every existing tasks widget
|
|
defaults to "classic", no behavior change."""
|
|
existing = {c["name"] for c in inspect(conn).get_columns("task_widget_configs")}
|
|
if "render_style" not in existing:
|
|
conn.execute(text("ALTER TABLE task_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
|
|
|
|
|
|
def _migration_36(conn) -> None:
|
|
"""Static image widget render style (models.StaticWidgetConfig.
|
|
render_style) -- same shape as migration 31/33/34/35."""
|
|
existing = {c["name"] for c in inspect(conn).get_columns("static_widget_configs")}
|
|
if "render_style" not in existing:
|
|
conn.execute(text("ALTER TABLE static_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
|
|
|
|
|
|
def _migration_37(conn) -> None:
|
|
"""Whiteboard widget render style (models.WhiteboardWidgetConfig.
|
|
render_style) -- same shape as migration 36."""
|
|
existing = {c["name"] for c in inspect(conn).get_columns("whiteboard_widget_configs")}
|
|
if "render_style" not in existing:
|
|
conn.execute(text("ALTER TABLE whiteboard_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
|
|
|
|
|
|
def _migration_38(conn) -> None:
|
|
"""Calendar widget render style (models.CalendarWidgetConfig.
|
|
render_style) -- same shape as migration 31/33/34/35/36/37."""
|
|
existing = {c["name"] for c in inspect(conn).get_columns("calendar_widget_configs")}
|
|
if "render_style" not in existing:
|
|
conn.execute(text("ALTER TABLE calendar_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
|
|
|
|
|
|
def _migration_39(conn) -> None:
|
|
"""Frame-level curated theme for "modern" style widgets (models.
|
|
Frame.theme, see theme_tokens.THEMES) -- same guarded-per-column
|
|
shape as every prior migration."""
|
|
existing = {c["name"] for c in inspect(conn).get_columns("frames")}
|
|
if "theme" not in existing:
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN theme TEXT NOT NULL DEFAULT 'classic'"))
|
|
|
|
|
|
def _migration_40(conn) -> None:
|
|
"""Per-widget text-size multiplier (models.Widget.font_scale, see
|
|
panel_style.FONT_SCALE_CHOICES) -- a Widget-level column like
|
|
border_style/border_thickness/border_color_index, not a per-type
|
|
config field, since any widget type with body text can use it. Every
|
|
existing widget defaults to 1.0 (unchanged size) until its own
|
|
dialog's "Text size" picker sets it. Guarded per-column, same
|
|
reasoning as every prior migration's own comment."""
|
|
existing = {c["name"] for c in inspect(conn).get_columns("widgets")}
|
|
if "font_scale" not in existing:
|
|
conn.execute(text("ALTER TABLE widgets ADD COLUMN font_scale REAL NOT NULL DEFAULT 1.0"))
|
|
|
|
|
|
def _raw_backfill_frame_widgets(conn, frame_row, now: float) -> None:
|
|
"""Raw-SQL equivalent of the old ORM-based _backfill_frame_widgets --
|
|
called from _migration_41 while the legacy Frame columns it reads
|
|
still physically exist, for the rare frame (if any) that somehow
|
|
reached this migration without ever getting a Widget during the long
|
|
window _ensure_widgets_backfilled ran unconditionally at every
|
|
startup between migration 16 and this one. Same mode dispatch,
|
|
including the calendar_photo_inlay two-widget split and the legacy
|
|
tasks-source carryover. Has to be hand-rolled in raw SQL rather than
|
|
reusing the old ORM helpers, since those read these columns off
|
|
models.Frame, which no longer declares them as of this migration."""
|
|
frame_id = frame_row["id"]
|
|
orientation = frame_row["orientation"] or "landscape"
|
|
cols, rows = grid.grid_dims(orientation)
|
|
mode = frame_row["mode"] if frame_row["mode"] in ("photos", "calendar", "whiteboard") else "photos"
|
|
|
|
def insert_widget(x, y, w, h, widget_type, sort_order):
|
|
# border_style/border_thickness/border_color_index/font_scale
|
|
# spelled out explicitly (migrations 26/40's own defaults)
|
|
# rather than relied on implicitly -- they're real SQL-level
|
|
# DEFAULTs in any database that reached this migration through
|
|
# the normal upgrade path, but this stays correct even if that
|
|
# ever stops being true.
|
|
result = conn.execute(text(
|
|
"INSERT INTO widgets (frame_id, widget_type, x, y, w, h, sort_order, created_at, "
|
|
"border_style, border_thickness, border_color_index, font_scale) "
|
|
"VALUES (:frame_id, :widget_type, :x, :y, :w, :h, :sort_order, :created_at, "
|
|
"'none', 3, 0, 1.0)"
|
|
), {"frame_id": frame_id, "widget_type": widget_type, "x": x, "y": y, "w": w, "h": h,
|
|
"sort_order": sort_order, "created_at": now})
|
|
return result.lastrowid
|
|
|
|
def insert_photo_config(widget_id):
|
|
conn.execute(text(
|
|
"INSERT INTO photo_widget_configs (widget_id, album_id, photo_order, display_mode, "
|
|
"queue_target_len, current_asset_id, current_asset_set_at, queue, queue_cursor, history, "
|
|
"excluded_asset_ids, locked) VALUES (:widget_id, :album_id, :photo_order, :display_mode, "
|
|
":queue_target_len, :current_asset_id, :current_asset_set_at, :queue, :queue_cursor, "
|
|
":history, :excluded_asset_ids, 0)"
|
|
), {"widget_id": widget_id, "album_id": frame_row["album_id"], "photo_order": frame_row["photo_order"],
|
|
"display_mode": frame_row["display_mode"], "queue_target_len": frame_row["queue_target_len"],
|
|
"current_asset_id": frame_row["current_asset_id"],
|
|
"current_asset_set_at": frame_row["current_asset_set_at"], "queue": frame_row["queue"],
|
|
"queue_cursor": frame_row["queue_cursor"], "history": frame_row["history"],
|
|
"excluded_asset_ids": frame_row["excluded_asset_ids"]})
|
|
|
|
def insert_calendar_config(widget_id):
|
|
conn.execute(text(
|
|
"INSERT INTO calendar_widget_configs (widget_id, view, week_start, browse_offset, checked_at, "
|
|
"cached_events, fetch_summary, weather_enabled, weather_units, weather_cities, "
|
|
"weather_checked_at, weather_cached, week_days, week_layout, week_start_offset, render_style) "
|
|
"VALUES (:widget_id, :view, :week_start, :browse_offset, :checked_at, :cached_events, "
|
|
":fetch_summary, :weather_enabled, :weather_units, :weather_cities, :weather_checked_at, "
|
|
":weather_cached, :week_days, :week_layout, :week_start_offset, 'classic')"
|
|
), {"widget_id": widget_id, "view": frame_row["calendar_view"],
|
|
"week_start": frame_row["calendar_week_start"], "browse_offset": frame_row["calendar_browse_offset"],
|
|
"checked_at": frame_row["calendar_checked_at"], "cached_events": frame_row["calendar_cached_events"],
|
|
"fetch_summary": frame_row["calendar_fetch_summary"],
|
|
"weather_enabled": frame_row["calendar_weather_enabled"],
|
|
"weather_units": frame_row["calendar_weather_units"],
|
|
"weather_cities": frame_row["calendar_weather_cities"],
|
|
"weather_checked_at": frame_row["calendar_weather_checked_at"],
|
|
"weather_cached": frame_row["calendar_weather_cached"], "week_days": frame_row["calendar_week_days"],
|
|
"week_layout": frame_row["calendar_week_layout"],
|
|
"week_start_offset": frame_row["calendar_week_start_offset"]})
|
|
|
|
def insert_whiteboard_config(widget_id):
|
|
conn.execute(text(
|
|
"INSERT INTO whiteboard_widget_configs (widget_id, user_id, url, checked_at, cached_image, "
|
|
"render_style) VALUES (:widget_id, :user_id, :url, :checked_at, :cached_image, 'classic')"
|
|
), {"widget_id": widget_id, "user_id": frame_row["whiteboard_user_id"], "url": frame_row["whiteboard_url"],
|
|
"checked_at": frame_row["whiteboard_checked_at"], "cached_image": frame_row["whiteboard_cached_image"]})
|
|
|
|
def insert_button_actions(widget_id, widget_type):
|
|
if widget_type == "whiteboard":
|
|
pairs = [("next", "check_now"), ("back", "check_now")]
|
|
elif widget_type in ("photos", "calendar"):
|
|
pairs = [("next", "advance"), ("back", "back")]
|
|
else:
|
|
pairs = []
|
|
for button, action in pairs:
|
|
conn.execute(text(
|
|
"INSERT INTO frame_button_actions (frame_id, button, widget_id, action, sort_order, created_at) "
|
|
"VALUES (:frame_id, :button, :widget_id, :action, 0, :created_at)"
|
|
), {"frame_id": frame_id, "button": button, "widget_id": widget_id, "action": action,
|
|
"created_at": now})
|
|
|
|
def maybe_add_tasks_widget(existing_rects, next_sort_order):
|
|
if not frame_row["calendar_tasks_calendar_key"] or not frame_row["calendar_tasks_user_id"]:
|
|
return
|
|
min_w, min_h = grid.MIN_FOOTPRINT["tasks"]
|
|
rect = grid.find_open_rect(orientation, existing_rects, min_w, min_h)
|
|
if rect is None:
|
|
logger.warning(
|
|
"Frame %d had a legacy task list configured but no open grid space for a "
|
|
"standalone tasks widget during backfill -- its task source was dropped", frame_id
|
|
)
|
|
return
|
|
x, y, w, h = rect
|
|
widget_id = insert_widget(x, y, w, h, "tasks", next_sort_order)
|
|
conn.execute(text(
|
|
"INSERT INTO task_widget_configs (widget_id, checked_at, cached, name, show_completed, "
|
|
"render_style) VALUES (:widget_id, :checked_at, :cached, '', 0, 'classic')"
|
|
), {"widget_id": widget_id, "checked_at": frame_row["calendar_tasks_checked_at"],
|
|
"cached": frame_row["calendar_tasks_cached"]})
|
|
conn.execute(text(
|
|
"INSERT INTO frame_task_lists (widget_id, user_id, calendar_key, included) "
|
|
"VALUES (:widget_id, :user_id, :calendar_key, 1)"
|
|
), {"widget_id": widget_id, "user_id": frame_row["calendar_tasks_user_id"],
|
|
"calendar_key": frame_row["calendar_tasks_calendar_key"]})
|
|
|
|
if mode == "calendar" and frame_row["calendar_photo_inlay"]:
|
|
half = cols // 2
|
|
cal_widget_id = insert_widget(0, 0, cols - half, rows, "calendar", 0)
|
|
photo_widget_id = insert_widget(cols - half, 0, half, rows, "photos", 1)
|
|
insert_calendar_config(cal_widget_id)
|
|
insert_photo_config(photo_widget_id)
|
|
insert_button_actions(cal_widget_id, "calendar")
|
|
maybe_add_tasks_widget([(0, 0, cols - half, rows), (cols - half, 0, half, rows)], 2)
|
|
return
|
|
|
|
widget_id = insert_widget(0, 0, cols, rows, mode, 0)
|
|
if mode == "photos":
|
|
insert_photo_config(widget_id)
|
|
elif mode == "calendar":
|
|
insert_calendar_config(widget_id)
|
|
elif mode == "whiteboard":
|
|
insert_whiteboard_config(widget_id)
|
|
insert_button_actions(widget_id, mode)
|
|
if mode == "calendar":
|
|
maybe_add_tasks_widget([(0, 0, cols, rows)], 1)
|
|
|
|
|
|
def _migration_41(conn) -> None:
|
|
"""Drops the legacy per-mode Frame columns the widget system
|
|
(migration 16) superseded -- mode, the photo-queue fields (album_id/
|
|
photo_order/display_mode/queue_target_len/current_asset_id/
|
|
current_asset_set_at/queue/queue_cursor/history/excluded_asset_ids),
|
|
every calendar_* field, every whiteboard_* field, and
|
|
legacy_token_enabled (models.py's own removal, alongside auth.py
|
|
dropping the shared MANAGEMENT_TOKEN device/browser fallback it
|
|
gated -- see auth.py's module docstring) -- see docs/widgets.md's
|
|
Known Gaps, which deliberately left this open as a much larger blast
|
|
radius than this project's usual same-migration-drop convention.
|
|
|
|
_ensure_widgets_backfilled ran unconditionally at the end of every
|
|
startup from migration 16 until this one, so in practice every frame
|
|
already has a Widget built from these columns' values by now; the
|
|
backfill loop below (_raw_backfill_frame_widgets) is the same safety
|
|
net migration 17/18 used for their own column drops, covering the
|
|
edge case of a frame that somehow reached this point with none (e.g.
|
|
a very old, never-restarted backup).
|
|
|
|
Guarded on "mode" existing, same reasoning as migration 26/27/29/
|
|
30/40's own comments: frames IS dropped/recreated here (unlike
|
|
widgets/photo_widget_configs, which those migrations left alone),
|
|
but a fresh-install create_all() copy already reflects today's
|
|
models.py -- i.e. the post-this-migration shape, missing "mode"
|
|
entirely -- so a test replaying migrations 16+ from an old
|
|
schema_version without also reconstructing frames' pre-41 columns
|
|
would otherwise hit "no such column: mode" here even though it has
|
|
nothing to do with what that test is actually exercising."""
|
|
if "mode" not in {c["name"] for c in inspect(conn).get_columns("frames")}:
|
|
return
|
|
now = time.time()
|
|
frame_rows = conn.execute(text(
|
|
"SELECT id, mode, orientation, album_id, photo_order, display_mode, queue_target_len, "
|
|
"current_asset_id, current_asset_set_at, queue, queue_cursor, history, excluded_asset_ids, "
|
|
"calendar_view, calendar_week_start, calendar_photo_inlay, calendar_browse_offset, "
|
|
"calendar_checked_at, calendar_cached_events, calendar_fetch_summary, calendar_weather_enabled, "
|
|
"calendar_weather_units, calendar_weather_cities, calendar_weather_checked_at, "
|
|
"calendar_weather_cached, calendar_week_days, calendar_week_layout, calendar_week_start_offset, "
|
|
"calendar_tasks_calendar_key, calendar_tasks_user_id, calendar_tasks_checked_at, "
|
|
"calendar_tasks_cached, whiteboard_user_id, whiteboard_url, whiteboard_checked_at, "
|
|
"whiteboard_cached_image FROM frames"
|
|
)).mappings().all()
|
|
|
|
for row in frame_rows:
|
|
has_widget = conn.execute(
|
|
text("SELECT 1 FROM widgets WHERE frame_id = :fid LIMIT 1"), {"fid": row["id"]}
|
|
).first()
|
|
if has_widget is None:
|
|
_raw_backfill_frame_widgets(conn, row, now)
|
|
|
|
conn.execute(text(
|
|
"CREATE TABLE frames_new ("
|
|
"id INTEGER PRIMARY KEY, "
|
|
"device_id TEXT UNIQUE, "
|
|
"name TEXT NOT NULL DEFAULT '', "
|
|
"owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
|
|
"controlled_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
|
|
"device_token TEXT NOT NULL, "
|
|
"device_token_ack INTEGER NOT NULL DEFAULT 0, "
|
|
"manage_token TEXT NOT NULL UNIQUE, "
|
|
"claimed_at REAL, "
|
|
"created_at REAL NOT NULL DEFAULT 0.0, "
|
|
"immich_url TEXT NOT NULL DEFAULT '', "
|
|
"immich_api_key TEXT NOT NULL DEFAULT '', "
|
|
"refresh_interval_s INTEGER NOT NULL DEFAULT 3600, "
|
|
"quiet_hours_enabled INTEGER NOT NULL DEFAULT 0, "
|
|
"quiet_hours_start TEXT NOT NULL DEFAULT '22:00', "
|
|
"quiet_hours_end TEXT NOT NULL DEFAULT '07:00', "
|
|
"timezone TEXT NOT NULL DEFAULT 'UTC', "
|
|
"orientation TEXT NOT NULL DEFAULT 'landscape', "
|
|
"palette_rgb TEXT, "
|
|
"color_boost REAL NOT NULL DEFAULT 1.0, "
|
|
"contrast_boost REAL NOT NULL DEFAULT 1.0, "
|
|
"dither_strength REAL NOT NULL DEFAULT 1.0, "
|
|
"photo_palette_rgb TEXT, "
|
|
"photo_dither_strength REAL NOT NULL DEFAULT 1.0, "
|
|
"theme TEXT NOT NULL DEFAULT 'classic', "
|
|
"battery_percent INTEGER NOT NULL DEFAULT -1, "
|
|
"battery_as_of REAL NOT NULL DEFAULT 0.0, "
|
|
"battery_history TEXT NOT NULL DEFAULT '[]', "
|
|
"last_seen REAL NOT NULL DEFAULT 0.0, "
|
|
"device_firmware_version TEXT NOT NULL DEFAULT '', "
|
|
"device_board_variant TEXT NOT NULL DEFAULT '', "
|
|
"battery_alert_threshold_pct INTEGER NOT NULL DEFAULT -1, "
|
|
"battery_alert_sent INTEGER NOT NULL DEFAULT 0, "
|
|
"firmware_available_version TEXT NOT NULL DEFAULT '', "
|
|
"firmware_update_repo_url TEXT NOT NULL DEFAULT '', "
|
|
"firmware_auto_update INTEGER NOT NULL DEFAULT 0, "
|
|
"firmware_update_token TEXT NOT NULL DEFAULT '', "
|
|
"firmware_update_checked_at REAL NOT NULL DEFAULT 0.0, "
|
|
"firmware_gitea_latest_version TEXT NOT NULL DEFAULT '', "
|
|
"hold_duration_ms INTEGER NOT NULL DEFAULT 3000, "
|
|
"next_hold_action TEXT, "
|
|
"back_hold_action TEXT, "
|
|
"last_cycled_layout_id INTEGER, "
|
|
"last_displayed_image BLOB, "
|
|
"last_displayed_at REAL NOT NULL DEFAULT 0.0, "
|
|
"stats_first_seen REAL NOT NULL DEFAULT 0.0, "
|
|
"stats_device_wakes INTEGER NOT NULL DEFAULT 0, "
|
|
"stats_photos_displayed INTEGER NOT NULL DEFAULT 0, "
|
|
"stats_photos_removed INTEGER NOT NULL DEFAULT 0, "
|
|
"stats_battery_reports INTEGER NOT NULL DEFAULT 0, "
|
|
"stats_recharge_cycles INTEGER NOT NULL DEFAULT 0, "
|
|
"stats_ota_updates_applied INTEGER NOT NULL DEFAULT 0, "
|
|
"stats_config_saves INTEGER NOT NULL DEFAULT 0)"
|
|
))
|
|
kept_columns = (
|
|
"id, device_id, name, owner_user_id, controlled_by_user_id, device_token, device_token_ack, "
|
|
"manage_token, claimed_at, created_at, immich_url, immich_api_key, refresh_interval_s, "
|
|
"quiet_hours_enabled, quiet_hours_start, quiet_hours_end, timezone, orientation, palette_rgb, "
|
|
"color_boost, contrast_boost, dither_strength, photo_palette_rgb, photo_dither_strength, theme, "
|
|
"battery_percent, battery_as_of, battery_history, last_seen, device_firmware_version, "
|
|
"device_board_variant, battery_alert_threshold_pct, battery_alert_sent, "
|
|
"firmware_available_version, firmware_update_repo_url, firmware_auto_update, "
|
|
"firmware_update_token, firmware_update_checked_at, firmware_gitea_latest_version, "
|
|
"hold_duration_ms, next_hold_action, back_hold_action, last_cycled_layout_id, "
|
|
"last_displayed_image, last_displayed_at, stats_first_seen, stats_device_wakes, "
|
|
"stats_photos_displayed, stats_photos_removed, stats_battery_reports, stats_recharge_cycles, "
|
|
"stats_ota_updates_applied, stats_config_saves"
|
|
)
|
|
conn.execute(text(f"INSERT INTO frames_new ({kept_columns}) SELECT {kept_columns} FROM frames"))
|
|
conn.execute(text("DROP TABLE frames"))
|
|
conn.execute(text("ALTER TABLE frames_new RENAME TO frames"))
|
|
|
|
|
|
def _migration_42(conn) -> None:
|
|
"""Which EPD panel a frame renders for (models.Frame.panel_type, see
|
|
image_pipeline.PANEL_SPECS) -- same guarded-per-column shape as every
|
|
prior migration. Every existing frame defaults to 'epd7in3e' (the
|
|
original 7.3" panel), auto-corrected on next check-in if the device
|
|
actually reports a different board (routers/device.py's
|
|
BOARD_PANEL_MAP)."""
|
|
existing = {c["name"] for c in inspect(conn).get_columns("frames")}
|
|
if "panel_type" not in existing:
|
|
conn.execute(text("ALTER TABLE frames ADD COLUMN panel_type TEXT NOT NULL DEFAULT 'epd7in3e'"))
|
|
|
|
|
|
MIGRATIONS = [
|
|
(1, _migration_1),
|
|
(2, _migration_2),
|
|
(3, _migration_3),
|
|
(4, _migration_4),
|
|
(5, _migration_5),
|
|
(6, _migration_6),
|
|
(7, _migration_7),
|
|
(8, _migration_8),
|
|
(9, _migration_9),
|
|
(10, _migration_10),
|
|
(11, _migration_11),
|
|
(12, _migration_12),
|
|
(13, _migration_13),
|
|
(14, _migration_14),
|
|
(15, _migration_15),
|
|
(16, _migration_16),
|
|
(17, _migration_17),
|
|
(18, _migration_18),
|
|
(19, _migration_19),
|
|
(20, _migration_20),
|
|
(21, _migration_21),
|
|
(22, _migration_22),
|
|
(23, _migration_23),
|
|
(24, _migration_24),
|
|
(25, _migration_25),
|
|
(26, _migration_26),
|
|
(27, _migration_27),
|
|
(28, _migration_28),
|
|
(29, _migration_29),
|
|
(30, _migration_30),
|
|
(31, _migration_31),
|
|
(32, _migration_32),
|
|
(33, _migration_33),
|
|
(34, _migration_34),
|
|
(35, _migration_35),
|
|
(36, _migration_36),
|
|
(37, _migration_37),
|
|
(38, _migration_38),
|
|
(39, _migration_39),
|
|
(40, _migration_40),
|
|
(41, _migration_41),
|
|
(42, _migration_42),
|
|
]
|
|
|
|
|
|
def run_migrations() -> None:
|
|
with engine.begin() as conn:
|
|
conn.execute(text("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)"))
|
|
row = conn.execute(text("SELECT version FROM schema_version")).fetchone()
|
|
if row is None:
|
|
# Brand new database: _migration_1's create_all() already
|
|
# produces today's full schema straight from models.py.
|
|
# Every migration after it is an incremental ALTER/UPDATE
|
|
# meant to bring an *existing* install forward -- replaying
|
|
# those here would just collide with columns create_all
|
|
# already added (e.g. "duplicate column name"). Jump
|
|
# straight to the latest version instead.
|
|
_migration_1(conn)
|
|
current = MIGRATIONS[-1][0]
|
|
conn.execute(text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": current})
|
|
else:
|
|
current = row[0]
|
|
|
|
# Each migration commits in its own transaction (rather than the
|
|
# whole batch sharing one, like this used to) so that _migration_41
|
|
# can get a connection with no transaction pending on it yet --
|
|
# SQLite only honors toggling PRAGMA foreign_keys when issued as a
|
|
# connection's literal first statement, and it needs that off for
|
|
# its own DROP TABLE frames (frames is an ON DELETE CASCADE target
|
|
# for widgets/frame_button_actions/user_frames/etc, so leaving
|
|
# enforcement on there would cascade-delete every frame's widgets,
|
|
# not just the columns that migration means to drop). A crash
|
|
# partway through now simply leaves schema_version at the last
|
|
# migration that actually completed, same as it always could
|
|
# between separate runs of this function.
|
|
for version, fn in MIGRATIONS:
|
|
if version <= current:
|
|
continue
|
|
logger.info("Applying schema migration %d", version)
|
|
with engine.connect() as conn:
|
|
if fn is _migration_41:
|
|
# Executing this before anything else auto-begins
|
|
# SQLAlchemy's own Transaction bookkeeping too, so an
|
|
# explicit conn.begin() below would conflict with it --
|
|
# fn(conn) and the version UPDATE just ride that same
|
|
# auto-begun transaction, committed explicitly at the end.
|
|
conn.execute(text("PRAGMA foreign_keys=OFF"))
|
|
fn(conn)
|
|
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
|
|
conn.commit()
|
|
if fn is _migration_41:
|
|
# Restore it before this connection goes back to the
|
|
# pool -- otherwise a later checkout of the same
|
|
# underlying DBAPI connection (the connect-event
|
|
# listener in db.py only fires for a genuinely new one)
|
|
# would silently run with enforcement off. Has to
|
|
# happen AFTER commit(), same "no pending transaction"
|
|
# requirement as the OFF toggle above -- issuing it
|
|
# before the commit is exactly the mid-transaction
|
|
# no-op this migration exists to work around in the
|
|
# first place, just in the other direction.
|
|
conn.execute(text("PRAGMA foreign_keys=ON"))
|
|
|
|
_ensure_frame_one()
|
|
_ensure_server_settings()
|
|
_ensure_frame_calendars_rekeyed()
|
|
|
|
|
|
def new_device_token() -> str:
|
|
return secrets.token_urlsafe(32)
|
|
|
|
|
|
def new_manage_token() -> str:
|
|
return secrets.token_urlsafe(16)
|
|
|
|
|
|
def _ensure_frame_one() -> None:
|
|
"""First boot only (frames table empty): create frame #1 -- imported
|
|
verbatim from a legacy config.json if one exists, otherwise fresh
|
|
defaults -- plus a single full-panel photos widget carrying over
|
|
whatever photo-queue state that file had (the widget system's
|
|
equivalent of what used to live directly on Frame; see migration
|
|
41). The frames-nonempty guard makes this idempotent; config.json is
|
|
left untouched as the rollback path."""
|
|
with SessionLocal() as db:
|
|
if db.scalars(select(Frame).limit(1)).first() is not None:
|
|
return
|
|
|
|
cfg = config.load() # all defaults if the file doesn't exist
|
|
had_file = config.CONFIG_PATH.exists()
|
|
|
|
frame = Frame(
|
|
name="Frame 1",
|
|
device_id=None,
|
|
device_token=new_device_token(),
|
|
manage_token=new_manage_token(),
|
|
created_at=time.time(),
|
|
immich_url=cfg.immich_url,
|
|
immich_api_key=cfg.immich_api_key,
|
|
refresh_interval_s=cfg.refresh_interval_s,
|
|
quiet_hours_enabled=cfg.quiet_hours_enabled,
|
|
quiet_hours_start=cfg.quiet_hours_start,
|
|
quiet_hours_end=cfg.quiet_hours_end,
|
|
timezone=cfg.timezone,
|
|
orientation=cfg.orientation,
|
|
battery_percent=cfg.battery_percent,
|
|
battery_as_of=cfg.battery_as_of,
|
|
battery_history=[list(pair) for pair in cfg.battery_history],
|
|
last_seen=cfg.last_seen,
|
|
device_firmware_version=cfg.device_firmware_version,
|
|
device_board_variant=cfg.device_board_variant,
|
|
firmware_available_version=cfg.firmware_available_version,
|
|
firmware_update_repo_url=cfg.firmware_update_repo_url,
|
|
firmware_auto_update=cfg.firmware_auto_update,
|
|
firmware_update_token=cfg.firmware_update_token,
|
|
firmware_update_checked_at=cfg.firmware_update_checked_at,
|
|
firmware_gitea_latest_version=cfg.firmware_gitea_latest_version,
|
|
stats_first_seen=cfg.stats.first_seen,
|
|
stats_device_wakes=cfg.stats.device_wakes,
|
|
stats_photos_displayed=cfg.stats.photos_displayed,
|
|
stats_photos_removed=cfg.stats.photos_removed,
|
|
stats_battery_reports=cfg.stats.battery_reports,
|
|
stats_recharge_cycles=cfg.stats.recharge_cycles,
|
|
stats_ota_updates_applied=cfg.stats.ota_updates_applied,
|
|
stats_config_saves=cfg.stats.config_saves,
|
|
)
|
|
db.add(frame)
|
|
db.flush() # assign frame.id for the battery log rows + widget FK
|
|
|
|
for pair in cfg.battery_log:
|
|
db.add(BatteryLog(frame_id=frame.id, ts=pair[0], percent=pair[1]))
|
|
|
|
cols, rows = grid.grid_dims(frame.orientation)
|
|
widget = Widget(frame_id=frame.id, widget_type="photos", x=0, y=0, w=cols, h=rows,
|
|
sort_order=0, created_at=time.time())
|
|
db.add(widget)
|
|
db.flush() # assign widget.id for the config row's FK
|
|
db.add(PhotoWidgetConfig(
|
|
widget_id=widget.id,
|
|
album_id=cfg.album_id,
|
|
order=cfg.order,
|
|
display_mode="crop_faces" if cfg.smart_crop_faces else "crop_fill",
|
|
queue_target_len=cfg.queue_target_len,
|
|
current_asset_id=cfg.current_asset_id,
|
|
current_asset_set_at=cfg.current_asset_set_at,
|
|
queue=list(cfg.queue),
|
|
queue_cursor=cfg.queue_cursor,
|
|
history=list(cfg.history),
|
|
excluded_asset_ids=list(cfg.excluded_asset_ids),
|
|
))
|
|
db.add_all(default_button_actions(frame.id, widget.id, "photos"))
|
|
|
|
db.commit()
|
|
|
|
# The single legacy firmware slot becomes frame #1's per-frame slot.
|
|
legacy_bin = config.CONFIG_PATH.parent / "firmware.bin"
|
|
if legacy_bin.exists():
|
|
per_frame_dir = config.CONFIG_PATH.parent / "firmware"
|
|
per_frame_dir.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(legacy_bin, per_frame_dir / f"{frame.id}.bin")
|
|
|
|
if had_file:
|
|
logger.info(
|
|
"Imported legacy config.json as frame #%d (%d battery log entries)",
|
|
frame.id,
|
|
len(cfg.battery_log),
|
|
)
|
|
else:
|
|
logger.info("Fresh install: created default frame #%d", frame.id)
|
|
|
|
|
|
def _ensure_server_settings() -> None:
|
|
"""The SMTP config singleton (id=1) -- created with everything blank
|
|
(email sending disabled) the first time this runs; /admin edits it in
|
|
place from then on."""
|
|
with SessionLocal() as db:
|
|
if db.get(ServerSettings, 1) is None:
|
|
db.add(ServerSettings(id=1))
|
|
db.commit()
|
|
|
|
|
|
def _ensure_frame_calendars_rekeyed() -> None:
|
|
"""Re-keys frame_calendars from frame_id to widget_id -- a frame can
|
|
hold more than one independent calendar widget (see the widget
|
|
system), each with its own included-calendars set, so "included on
|
|
this frame" no longer means anything unambiguous (see
|
|
models.FrameCalendar). Existing rows attach to their frame's calendar
|
|
widget if it has one; rows for a frame with no calendar widget at all
|
|
are dropped -- they were already-dormant settings for content
|
|
nothing ever actually displayed (the Calendar tab stayed reachable
|
|
and savable even while a frame's old `mode` was "photos"), not real
|
|
live configuration.
|
|
|
|
Deliberately NOT a numbered migration: every frame's calendar widget
|
|
must already exist to know what to re-key against, and for a genuine
|
|
pre-widget-system database those rows only exist once _migration_41's
|
|
own backfill has run (a step inside that migration, not before it).
|
|
A numbered migration for this would race ahead of that backfill (the
|
|
numbered-migration loop runs top to bottom in one pass, see
|
|
run_migrations), silently dropping every row -- caught by
|
|
test_migrations.py actually exercising the raw-SQL upgrade path
|
|
instead of the fresh-install create_all() shortcut every other test
|
|
in that file takes.
|
|
|
|
Runs unconditionally after every startup instead; a no-op the moment
|
|
frame_calendars is already widget_id-shaped (every fresh install,
|
|
and any existing install after its first run past this code) --
|
|
SQLite can't ALTER a column's FK target or drop a column that's part
|
|
of an index/FK constraint, so when it isn't a no-op this is the
|
|
standard SQLite "rebuild" pattern: create the new-shape table, copy
|
|
matching rows across (joining to find each row's calendar widget),
|
|
drop the old table, rename the new one into place."""
|
|
inspector = inspect(engine)
|
|
columns = {c["name"] for c in inspector.get_columns("frame_calendars")}
|
|
if "widget_id" in columns:
|
|
return
|
|
with engine.begin() as conn:
|
|
conn.execute(text(
|
|
"CREATE TABLE frame_calendars_new ("
|
|
"id INTEGER PRIMARY KEY, "
|
|
"widget_id INTEGER NOT NULL REFERENCES widgets(id) ON DELETE CASCADE, "
|
|
"user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
|
|
"calendar_key TEXT NOT NULL, "
|
|
"calendar_label TEXT NOT NULL DEFAULT '', "
|
|
"included INTEGER NOT NULL DEFAULT 1, "
|
|
"color_index INTEGER)"
|
|
))
|
|
conn.execute(text(
|
|
"INSERT INTO frame_calendars_new (widget_id, user_id, calendar_key, calendar_label, included, color_index) "
|
|
"SELECT w.id, fc.user_id, fc.calendar_key, fc.calendar_label, fc.included, fc.color_index "
|
|
"FROM frame_calendars fc "
|
|
"JOIN widgets w ON w.frame_id = fc.frame_id AND w.widget_type = 'calendar'"
|
|
))
|
|
conn.execute(text("DROP TABLE frame_calendars"))
|
|
conn.execute(text("ALTER TABLE frame_calendars_new RENAME TO frame_calendars"))
|
|
conn.execute(text(
|
|
"CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (widget_id, user_id, calendar_key)"
|
|
))
|