"""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, CalendarWidgetConfig, Frame, FrameButtonAction, PhotoWidgetConfig, ServerSettings, TaskWidgetConfig, WhiteboardWidgetConfig, Widget, ) 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) " "VALUES (:frame_id, 'tasks', :x, :y, :w, :h, :sort_order, :created_at)" ), {"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")) 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), ] 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) latest = MIGRATIONS[-1][0] conn.execute(text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": latest}) else: current = row[0] for version, fn in MIGRATIONS: if version > current: logger.info("Applying schema migration %d", version) fn(conn) conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version}) _ensure_frame_one() _ensure_server_settings() _ensure_widgets_backfilled() _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. Either way it's the legacy-token frame: the deployed firmware sends no device id and (at most) the shared MANAGEMENT_TOKEN, and require_device resolves those requests here. 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(), legacy_token_enabled=True, created_at=time.time(), immich_url=cfg.immich_url, immich_api_key=cfg.immich_api_key, album_id=cfg.album_id, order=cfg.order, 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, display_mode="crop_faces" if cfg.smart_crop_faces else "crop_fill", orientation=cfg.orientation, 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), 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 for pair in cfg.battery_log: db.add(BatteryLog(frame_id=frame.id, ts=pair[0], percent=pair[1])) 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 _photo_config_from_frame(frame: Frame, widget_id: int) -> PhotoWidgetConfig: return PhotoWidgetConfig( widget_id=widget_id, album_id=frame.album_id, order=frame.order, display_mode=frame.display_mode, queue_target_len=frame.queue_target_len, current_asset_id=frame.current_asset_id, current_asset_set_at=frame.current_asset_set_at, queue=list(frame.queue), queue_cursor=frame.queue_cursor, history=list(frame.history), excluded_asset_ids=list(frame.excluded_asset_ids), ) def _calendar_config_from_frame(frame: Frame, widget_id: int) -> CalendarWidgetConfig: return CalendarWidgetConfig( widget_id=widget_id, view=frame.calendar_view, week_start=frame.calendar_week_start, browse_offset=frame.calendar_browse_offset, checked_at=frame.calendar_checked_at, cached_events=list(frame.calendar_cached_events) if frame.calendar_cached_events else None, fetch_summary=frame.calendar_fetch_summary, weather_enabled=frame.calendar_weather_enabled, weather_units=frame.calendar_weather_units, weather_cities=list(frame.calendar_weather_cities) if frame.calendar_weather_cities else None, weather_checked_at=frame.calendar_weather_checked_at, weather_cached=list(frame.calendar_weather_cached) if frame.calendar_weather_cached else None, week_days=frame.calendar_week_days, week_layout=frame.calendar_week_layout, week_start_offset=frame.calendar_week_start_offset, # tasks_* deliberately not carried over -- see _task_config_from_frame, # a sibling standalone widget now, not part of this config. ) def _task_config_from_frame(frame: Frame, widget_id: int) -> TaskWidgetConfig: """Only ever called for a frame whose legacy calendar_tasks_* columns (see Frame's own docstring on those -- a dead pre-widget-system field set, same status as calendar_photo_inlay below) still carry a configured source -- i.e. a database jumping straight from before the widget system existed to after tasks became its own widget type in a single upgrade, skipping the intermediate period where it would have lived on CalendarWidgetConfig instead (see _migration_17's own extraction of *that* case).""" return TaskWidgetConfig( widget_id=widget_id, user_id=frame.calendar_tasks_user_id, calendar_key=frame.calendar_tasks_calendar_key, checked_at=frame.calendar_tasks_checked_at, cached=list(frame.calendar_tasks_cached) if frame.calendar_tasks_cached else None, ) def _whiteboard_config_from_frame(frame: Frame, widget_id: int) -> WhiteboardWidgetConfig: return WhiteboardWidgetConfig( widget_id=widget_id, user_id=frame.whiteboard_user_id, url=frame.whiteboard_url, checked_at=frame.whiteboard_checked_at, cached_image=frame.whiteboard_cached_image, ) def _default_button_actions(frame_id: int, widget_id: int, widget_type: str) -> list[FrameButtonAction]: """NEXT/BACK -> whatever this widget's own advance/back concept is (see app/widgets/ for the actual action registry, built in a later phase) -- reproduces each mode's exact old button behavior for the one auto-migrated widget, so upgrading changes nothing about what the physical buttons do until someone deliberately reassigns them.""" if widget_type == "whiteboard": # No real "next"/"back" concept for a static board -- both # buttons already meant "check now" before this migration (see # the old _advance_whiteboard_mode/_back_whiteboard_mode). return [ FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="check_now"), FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="check_now"), ] return [ FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="advance"), FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="back"), ] def _maybe_add_legacy_tasks_widget(db, frame: Frame, existing: list[grid.Rect], next_sort_order: int) -> None: """Only relevant for a database jumping straight from before the widget system existed to after tasks became its own widget type in one upgrade (see _task_config_from_frame) -- frame.calendar_tasks_* is the dead legacy field set otherwise. Auto-placed in whatever open space is left after the widget(s) above it in _backfill_frame_ widgets claimed theirs, same find_open_rect logic a manual "add widget" uses; silently dropped (logged) if none fits, same as this migration having nowhere else to put it either.""" if not frame.calendar_tasks_calendar_key: return min_w, min_h = grid.MIN_FOOTPRINT["tasks"] rect = grid.find_open_rect(frame.orientation, existing, 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 task_widget = Widget(frame_id=frame.id, widget_type="tasks", x=x, y=y, w=w, h=h, sort_order=next_sort_order, created_at=time.time()) db.add(task_widget) db.flush() db.add(_task_config_from_frame(frame, task_widget.id)) def _backfill_frame_widgets(db, frame: Frame) -> None: cols, rows = grid.grid_dims(frame.orientation) mode = frame.mode if frame.mode in ("photos", "calendar", "whiteboard") else "photos" if mode == "calendar" and frame.calendar_photo_inlay: # Reproduces the old fixed 50/50 inlay split as two independent # widgets instead of silently dropping half of what the frame was # showing -- see models.py's CalendarWidgetConfig docstring on why # "photo inlay" isn't a widget-system concept anymore otherwise. half = cols // 2 cal_widget = Widget(frame_id=frame.id, widget_type="calendar", x=0, y=0, w=cols - half, h=rows, sort_order=0, created_at=time.time()) photo_widget = Widget(frame_id=frame.id, widget_type="photos", x=cols - half, y=0, w=half, h=rows, sort_order=1, created_at=time.time()) db.add_all([cal_widget, photo_widget]) db.flush() # assign ids before the FK'd config rows reference them db.add(_calendar_config_from_frame(frame, cal_widget.id)) db.add(_photo_config_from_frame(frame, photo_widget.id)) db.add_all(_default_button_actions(frame.id, cal_widget.id, "calendar")) _maybe_add_legacy_tasks_widget( db, frame, [(0, 0, cols - half, rows), (cols - half, 0, half, rows)], next_sort_order=2 ) return widget = Widget(frame_id=frame.id, widget_type=mode, x=0, y=0, w=cols, h=rows, sort_order=0, created_at=time.time()) db.add(widget) db.flush() if mode == "photos": db.add(_photo_config_from_frame(frame, widget.id)) elif mode == "calendar": db.add(_calendar_config_from_frame(frame, widget.id)) elif mode == "whiteboard": db.add(_whiteboard_config_from_frame(frame, widget.id)) db.add_all(_default_button_actions(frame.id, widget.id, mode)) if mode == "calendar": _maybe_add_legacy_tasks_widget(db, frame, [(0, 0, cols, rows)], next_sort_order=1) def _ensure_widgets_backfilled() -> None: """Every frame needs at least one Widget once the widget system is live -- runs unconditionally after every startup (both a from-scratch _ensure_frame_one() install and an existing-install upgrade past _migration_16 land here) and is a no-op for any frame that already has one. Builds a widget that reproduces the frame's current mode/ settings/state exactly, so upgrading never changes what a frame displays or what its physical buttons do on its own.""" with SessionLocal() as db: for frame in db.scalars(select(Frame)).all(): has_widget = db.scalars(select(Widget).where(Widget.frame_id == frame.id).limit(1)).first() if has_widget is not None: continue _backfill_frame_widgets(db, frame) 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: this needs each frame's calendar widget to already exist to know what to re-key against, and those widget rows aren't created by a schema migration at all -- they come from _ensure_widgets_backfilled() above, which (like this function) runs unconditionally after every startup rather than being tracked by schema_version. Running this as a numbered migration would execute it *before* that backfill during a real upgrade (the numbered-migration loop runs first, 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, like _ensure_widgets_ backfilled; 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)" ))