"""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 select, text from . import config from .db import SessionLocal, engine from .models import Base, BatteryLog, Frame, ServerSettings 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")) 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), ] 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() 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()