from __future__ import annotations import json import sqlite3 import uuid from datetime import UTC, datetime from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parent.parent DEFAULT_DB = ROOT / "data" / "streamdeck.sqlite" def utc_now() -> str: return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z") class Database: def __init__(self, path: Path = DEFAULT_DB): self.path = path self.path.parent.mkdir(parents=True, exist_ok=True) self.conn = sqlite3.connect(self.path, check_same_thread=False) self.conn.row_factory = sqlite3.Row self.conn.execute("PRAGMA foreign_keys = ON") self.migrate() self.ensure_defaults() def migrate(self) -> None: self.conn.executescript( """ CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS profiles ( id TEXT PRIMARY KEY, name TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS folders ( id TEXT PRIMARY KEY, profile_id TEXT NOT NULL, parent_id TEXT, name TEXT NOT NULL, is_root INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, FOREIGN KEY(profile_id) REFERENCES profiles(id) ON DELETE CASCADE, FOREIGN KEY(parent_id) REFERENCES folders(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS buttons ( id TEXT PRIMARY KEY, profile_id TEXT NOT NULL, folder_id TEXT NOT NULL, position INTEGER NOT NULL, physical_button INTEGER NOT NULL, label TEXT NOT NULL DEFAULT '', color TEXT NOT NULL DEFAULT '#111827', icon TEXT NOT NULL DEFAULT '', trigger_mode TEXT NOT NULL DEFAULT 'down', action_type TEXT NOT NULL DEFAULT 'noop', action_config TEXT NOT NULL DEFAULT '{}', updated_at TEXT NOT NULL, UNIQUE(folder_id, position), UNIQUE(folder_id, physical_button), FOREIGN KEY(profile_id) REFERENCES profiles(id) ON DELETE CASCADE, FOREIGN KEY(folder_id) REFERENCES folders(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS manual_apps ( id TEXT PRIMARY KEY, name TEXT NOT NULL, path TEXT NOT NULL, args TEXT, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS event_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at TEXT NOT NULL, event_type TEXT NOT NULL, payload TEXT NOT NULL ); """ ) self.conn.commit() def ensure_defaults(self) -> None: if not self.get_setting("click_check"): self.set_setting("click_check", False) if not self.get_setting("serial_port"): self.set_setting("serial_port", None) profile = self.conn.execute("SELECT * FROM profiles LIMIT 1").fetchone() if profile is None: profile_id = self.create_profile("Default", make_active=False) self.set_setting("active_profile_id", profile_id) root_id = self.get_root_folder(profile_id)["id"] self.set_setting("active_folder_id", root_id) else: active_profile = self.get_setting("active_profile_id") or profile["id"] self.set_setting("active_profile_id", active_profile) active_folder = self.get_setting("active_folder_id") or self.get_root_folder(active_profile)["id"] self.set_setting("active_folder_id", active_folder) self.sync_physical_layouts() def row_to_dict(self, row: sqlite3.Row | None) -> dict[str, Any] | None: return dict(row) if row else None def get_setting(self, key: str) -> Any: row = self.conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone() return json.loads(row["value"]) if row else None def set_setting(self, key: str, value: Any) -> None: self.conn.execute( "INSERT INTO settings(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", (key, json.dumps(value)), ) self.conn.commit() def settings(self) -> dict[str, Any]: rows = self.conn.execute("SELECT key, value FROM settings").fetchall() return {row["key"]: json.loads(row["value"]) for row in rows} def create_profile(self, name: str, make_active: bool = True) -> str: profile_id = str(uuid.uuid4()) self.conn.execute( "INSERT INTO profiles(id, name, created_at) VALUES(?, ?, ?)", (profile_id, name, utc_now()), ) root_id = str(uuid.uuid4()) self.conn.execute( "INSERT INTO folders(id, profile_id, parent_id, name, is_root, created_at) VALUES(?, ?, NULL, ?, 1, ?)", (root_id, profile_id, "Root", utc_now()), ) self._seed_buttons(profile_id, root_id) self.conn.commit() self.sync_physical_layouts() if make_active: self.set_setting("active_profile_id", profile_id) self.set_setting("active_folder_id", root_id) return profile_id def update_profile(self, profile_id: str, name: str | None = None, active: bool | None = None) -> None: if name is not None: self.conn.execute("UPDATE profiles SET name = ? WHERE id = ?", (name, profile_id)) if active: self.set_setting("active_profile_id", profile_id) self.set_setting("active_folder_id", self.get_root_folder(profile_id)["id"]) self.conn.commit() def delete_profile(self, profile_id: str) -> None: if profile_id == self.first_profile()["id"]: raise ValueError("Cannot delete the first profile because it owns the hardware layout.") count = self.conn.execute("SELECT COUNT(*) AS count FROM profiles").fetchone()["count"] if count <= 1: raise ValueError("Cannot delete the only profile.") self.conn.execute("DELETE FROM profiles WHERE id = ?", (profile_id,)) if self.get_setting("active_profile_id") == profile_id: next_profile = self.conn.execute("SELECT id FROM profiles LIMIT 1").fetchone()["id"] self.set_setting("active_profile_id", next_profile) self.set_setting("active_folder_id", self.get_root_folder(next_profile)["id"]) self.conn.commit() def create_folder(self, profile_id: str, parent_id: str | None, name: str) -> str: parent_id = parent_id or self.get_root_folder(profile_id)["id"] folder_id = str(uuid.uuid4()) self.conn.execute( "INSERT INTO folders(id, profile_id, parent_id, name, is_root, created_at) VALUES(?, ?, ?, ?, 0, ?)", (folder_id, profile_id, parent_id, name, utc_now()), ) self._seed_buttons(profile_id, folder_id) self.conn.commit() self.sync_physical_layouts() return folder_id def update_folder(self, folder_id: str, name: str | None = None, parent_id: str | None = None) -> None: if name is not None: self.conn.execute("UPDATE folders SET name = ? WHERE id = ?", (name, folder_id)) if parent_id is not None: self.conn.execute("UPDATE folders SET parent_id = ? WHERE id = ? AND is_root = 0", (parent_id, folder_id)) self.conn.commit() def delete_folder(self, folder_id: str) -> None: row = self.conn.execute("SELECT is_root, profile_id FROM folders WHERE id = ?", (folder_id,)).fetchone() if not row: return if row["is_root"]: raise ValueError("Cannot delete the root folder.") self.conn.execute("DELETE FROM folders WHERE id = ?", (folder_id,)) if self.get_setting("active_folder_id") == folder_id: self.set_setting("active_folder_id", self.get_root_folder(row["profile_id"])["id"]) self.conn.commit() def update_button(self, button_id: str, changes: dict[str, Any]) -> dict[str, Any]: physical_button = changes.pop("physical_button", None) current = self.get_button(button_id) if physical_button is not None and int(physical_button) != current["physical_button"]: if not self.can_edit_physical_layout(current["folder_id"]): raise ValueError("Physical button layout can only be changed in the first profile's root folder.") allowed = {"label", "color", "icon", "trigger_mode", "action_type"} sets: list[str] = [] values: list[Any] = [] for key in allowed: if key in changes and changes[key] is not None: sets.append(f"{key} = ?") values.append(changes[key]) if "action_config" in changes and changes["action_config"] is not None: sets.append("action_config = ?") values.append(json.dumps(changes["action_config"])) if sets: sets.append("updated_at = ?") values.append(utc_now()) values.append(button_id) self.conn.execute(f"UPDATE buttons SET {', '.join(sets)} WHERE id = ?", values) self.conn.commit() if physical_button is not None and int(physical_button) != current["physical_button"]: self.move_physical_button(current["folder_id"], current["position"], int(physical_button)) return self.get_button(button_id) def move_physical_button(self, folder_id: str, position: int, physical_button: int) -> None: if not self.can_edit_physical_layout(folder_id): raise ValueError("Physical button layout can only be changed in the first profile's root folder.") if position < 1 or position > 10 or physical_button < 1 or physical_button > 10: raise ValueError("Position and physical button must be between 1 and 10.") mapping = self.physical_layout_mapping() old_physical = mapping[position] old_position = next((slot for slot, physical in mapping.items() if physical == physical_button), None) mapping[position] = physical_button if old_position and old_position != position: mapping[old_position] = old_physical self.sync_physical_layouts(mapping) def get_button(self, button_id: str) -> dict[str, Any]: row = self.conn.execute("SELECT * FROM buttons WHERE id = ?", (button_id,)).fetchone() button = dict(row) button["action_config"] = json.loads(button["action_config"]) return button def find_button_for_event(self, profile_id: str, folder_id: str, physical_button: int) -> dict[str, Any] | None: row = self.conn.execute( "SELECT * FROM buttons WHERE profile_id = ? AND folder_id = ? AND physical_button = ?", (profile_id, folder_id, physical_button), ).fetchone() if not row: return None button = dict(row) button["action_config"] = json.loads(button["action_config"]) return button def add_manual_app(self, name: str, path: str, args: str | None = None) -> dict[str, Any]: app_id = str(uuid.uuid4()) self.conn.execute( "INSERT INTO manual_apps(id, name, path, args, created_at) VALUES(?, ?, ?, ?, ?)", (app_id, name, path, args, utc_now()), ) self.conn.commit() return {"id": app_id, "name": name, "path": path, "args": args, "source": "manual"} def manual_apps(self) -> list[dict[str, Any]]: return [dict(row) | {"source": "manual"} for row in self.conn.execute("SELECT * FROM manual_apps ORDER BY name").fetchall()] def add_event(self, event_type: str, payload: dict[str, Any]) -> None: self.conn.execute( "INSERT INTO event_history(created_at, event_type, payload) VALUES(?, ?, ?)", (utc_now(), event_type, json.dumps(payload)), ) self.conn.commit() def state(self) -> dict[str, Any]: settings = self.settings() profiles = [dict(row) for row in self.conn.execute("SELECT * FROM profiles ORDER BY created_at").fetchall()] folders = [dict(row) for row in self.conn.execute("SELECT * FROM folders ORDER BY is_root DESC, created_at").fetchall()] buttons = [] for row in self.conn.execute("SELECT * FROM buttons ORDER BY folder_id, position").fetchall(): button = dict(row) button["action_config"] = json.loads(button["action_config"]) buttons.append(button) return { "settings": settings, "profiles": profiles, "folders": folders, "buttons": buttons, "layout": { "canonical_profile_id": self.first_profile()["id"], "canonical_folder_id": self.canonical_layout_folder()["id"], "mapping": self.physical_layout_mapping(), }, } def get_root_folder(self, profile_id: str) -> dict[str, Any]: row = self.conn.execute("SELECT * FROM folders WHERE profile_id = ? AND is_root = 1", (profile_id,)).fetchone() return dict(row) def first_profile(self) -> dict[str, Any]: row = self.conn.execute("SELECT * FROM profiles ORDER BY created_at LIMIT 1").fetchone() if row is None: raise ValueError("No profiles exist.") return dict(row) def canonical_layout_folder(self) -> dict[str, Any]: return self.get_root_folder(self.first_profile()["id"]) def can_edit_physical_layout(self, folder_id: str) -> bool: return folder_id == self.canonical_layout_folder()["id"] def physical_layout_mapping(self) -> dict[int, int]: folder_id = self.canonical_layout_folder()["id"] rows = self.conn.execute( "SELECT position, physical_button FROM buttons WHERE folder_id = ? ORDER BY position", (folder_id,), ).fetchall() mapping = {int(row["position"]): int(row["physical_button"]) for row in rows} for position in range(1, 11): mapping.setdefault(position, position) return mapping def sync_physical_layouts(self, mapping: dict[int, int] | None = None) -> None: if self.conn.execute("SELECT COUNT(*) AS count FROM profiles").fetchone()["count"] == 0: return mapping = mapping or self.physical_layout_mapping() now = utc_now() folders = self.conn.execute("SELECT id FROM folders").fetchall() for folder in folders: folder_id = folder["id"] for position in range(1, 11): self.conn.execute( "UPDATE buttons SET physical_button = ?, updated_at = ? WHERE folder_id = ? AND position = ?", (-position, now, folder_id, position), ) for position, physical_button in mapping.items(): self.conn.execute( "UPDATE buttons SET physical_button = ?, updated_at = ? WHERE folder_id = ? AND position = ?", (physical_button, now, folder_id, position), ) self.conn.commit() def next_folder_id(self, profile_id: str, current_folder_id: str, direction: str = "next") -> str | None: rows = self.conn.execute( "SELECT id FROM folders WHERE profile_id = ? ORDER BY is_root DESC, created_at, name", (profile_id,), ).fetchall() folder_ids = [row["id"] for row in rows] if not folder_ids: return None if current_folder_id not in folder_ids: return folder_ids[0] offset = -1 if direction == "previous" else 1 current_index = folder_ids.index(current_folder_id) return folder_ids[(current_index + offset) % len(folder_ids)] def _seed_buttons(self, profile_id: str, folder_id: str) -> None: now = utc_now() for position in range(1, 11): self.conn.execute( """ INSERT INTO buttons( id, profile_id, folder_id, position, physical_button, label, color, icon, trigger_mode, action_type, action_config, updated_at ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 'down', 'noop', '{}', ?) """, (str(uuid.uuid4()), profile_id, folder_id, position, position, f"Button {position}", "#111827", "", now), )