2026-05-09 23:09:13 +00:00
|
|
|
/**
|
|
|
|
|
* Database migrations.
|
|
|
|
|
*
|
|
|
|
|
* Idempotent — `service-store.init()` runs ALL of these on every startup,
|
|
|
|
|
* inside one transaction. SQLite tolerates `IF NOT EXISTS` everywhere we
|
|
|
|
|
* need it. When schemas change non-additively, we'll graduate to a real
|
|
|
|
|
* versioned migrator; for v0.1 this is sufficient.
|
|
|
|
|
*
|
|
|
|
|
* NOTE on datetimes: stored as TEXT in ISO-8601 UTC ("YYYY-MM-DDTHH:MM:SS.sssZ").
|
|
|
|
|
* Application code uses `new Date().toISOString()` for writes and
|
|
|
|
|
* `new Date(value)` for reads. No tz-aware datetime gotcha because TEXT is
|
|
|
|
|
* pure string round-trip. (Old python build hit a pain point with
|
|
|
|
|
* SQLAlchemy's DateTime adapter — we avoid the whole class of issue here.)
|
|
|
|
|
*/
|
|
|
|
|
|
2026-05-10 19:39:09 +00:00
|
|
|
/**
|
|
|
|
|
* A migration entry: either a plain SQL string or a function receiving the DB.
|
|
|
|
|
* Functions are used for ALTER TABLE which lacks IF NOT EXISTS in SQLite.
|
|
|
|
|
*/
|
|
|
|
|
import type { DatabaseSync } from "node:sqlite";
|
|
|
|
|
export type MigrationEntry = string | ((db: DatabaseSync) => void);
|
|
|
|
|
|
|
|
|
|
function addColumnIfNotExists(
|
|
|
|
|
db: DatabaseSync,
|
|
|
|
|
table: string,
|
|
|
|
|
column: string,
|
|
|
|
|
definition: string,
|
|
|
|
|
): void {
|
|
|
|
|
const cols = db.prepare(`PRAGMA table_info("${table}")`).all() as Array<{ name: string }>;
|
|
|
|
|
if (cols.some((c) => c.name === column)) return;
|
|
|
|
|
db.exec(`ALTER TABLE "${table}" ADD COLUMN ${column} ${definition}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const MIGRATIONS: readonly MigrationEntry[] = [
|
2026-05-09 23:09:13 +00:00
|
|
|
// ---- users ---------------------------------------------------------------
|
|
|
|
|
`CREATE TABLE IF NOT EXISTS users (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
username TEXT NOT NULL UNIQUE,
|
|
|
|
|
password_hash TEXT NOT NULL,
|
|
|
|
|
role TEXT NOT NULL DEFAULT 'operator' CHECK(role IN ('admin', 'operator')),
|
|
|
|
|
is_active INTEGER NOT NULL DEFAULT 1,
|
|
|
|
|
totp_enabled INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
totp_secret_encrypted TEXT,
|
|
|
|
|
recovery_codes_hashed TEXT NOT NULL DEFAULT '[]',
|
|
|
|
|
must_change_password INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
failed_login_count INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
locked_until TEXT,
|
|
|
|
|
last_login_at TEXT,
|
|
|
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
|
|
|
) STRICT`,
|
|
|
|
|
|
|
|
|
|
// ---- sessions ------------------------------------------------------------
|
|
|
|
|
`CREATE TABLE IF NOT EXISTS sessions (
|
|
|
|
|
id TEXT PRIMARY KEY,
|
|
|
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
|
|
|
csrf_token TEXT NOT NULL,
|
|
|
|
|
totp_pending INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
user_agent TEXT,
|
|
|
|
|
ip_address TEXT,
|
|
|
|
|
issued_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
|
|
|
last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
|
|
|
expires_at TEXT NOT NULL,
|
|
|
|
|
revoked_at TEXT
|
|
|
|
|
) STRICT`,
|
|
|
|
|
|
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)`,
|
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_sessions_active
|
|
|
|
|
ON sessions(expires_at)
|
|
|
|
|
WHERE revoked_at IS NULL`,
|
|
|
|
|
|
|
|
|
|
// ---- api_keys ------------------------------------------------------------
|
|
|
|
|
`CREATE TABLE IF NOT EXISTS api_keys (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
name TEXT NOT NULL,
|
|
|
|
|
key_hash TEXT NOT NULL,
|
|
|
|
|
key_prefix TEXT NOT NULL,
|
|
|
|
|
scopes TEXT NOT NULL DEFAULT '[]',
|
|
|
|
|
expires_at TEXT,
|
|
|
|
|
last_used_at TEXT,
|
|
|
|
|
last_used_ip TEXT,
|
|
|
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
|
|
|
revoked_at TEXT
|
|
|
|
|
) STRICT`,
|
|
|
|
|
|
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix)`,
|
|
|
|
|
|
|
|
|
|
// ---- setup_state (singleton row, id=1) -----------------------------------
|
|
|
|
|
`CREATE TABLE IF NOT EXISTS setup_state (
|
|
|
|
|
id INTEGER PRIMARY KEY CHECK(id = 1),
|
|
|
|
|
is_complete INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
cluster_key_provisioned INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
nodered_flows_deployed INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
completed_at TEXT,
|
|
|
|
|
extras TEXT NOT NULL DEFAULT '{}'
|
|
|
|
|
) STRICT`,
|
|
|
|
|
`INSERT OR IGNORE INTO setup_state (id) VALUES (1)`,
|
|
|
|
|
|
|
|
|
|
// ---- displays ------------------------------------------------------------
|
|
|
|
|
`CREATE TABLE IF NOT EXISTS displays (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
name TEXT NOT NULL,
|
|
|
|
|
"index" INTEGER NOT NULL UNIQUE,
|
|
|
|
|
is_primary INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
width_px INTEGER NOT NULL DEFAULT 1920,
|
|
|
|
|
height_px INTEGER NOT NULL DEFAULT 1080,
|
|
|
|
|
default_layout_id INTEGER,
|
|
|
|
|
idle_timeout_seconds INTEGER NOT NULL DEFAULT 600,
|
|
|
|
|
sleep_timeout_seconds INTEGER NOT NULL DEFAULT 1800,
|
|
|
|
|
cec_enabled INTEGER NOT NULL DEFAULT 1,
|
|
|
|
|
cec_device_path TEXT,
|
|
|
|
|
cec_logical_address INTEGER,
|
|
|
|
|
desired_power_state TEXT NOT NULL DEFAULT 'follow_layout'
|
|
|
|
|
CHECK(desired_power_state IN ('follow_layout', 'on', 'standby')),
|
2026-05-21 07:10:30 +00:00
|
|
|
actual_power_state TEXT NOT NULL DEFAULT 'unknown'
|
|
|
|
|
CHECK(actual_power_state IN ('awake', 'standby', 'unknown')),
|
|
|
|
|
actual_power_state_at TEXT,
|
2026-05-09 23:09:13 +00:00
|
|
|
state_check_enabled INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
state_check_interval_seconds INTEGER NOT NULL DEFAULT 60
|
|
|
|
|
) STRICT`,
|
|
|
|
|
|
|
|
|
|
// ---- cameras -------------------------------------------------------------
|
|
|
|
|
`CREATE TABLE IF NOT EXISTS cameras (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
name TEXT NOT NULL UNIQUE,
|
|
|
|
|
type TEXT NOT NULL CHECK(type IN ('rtsp', 'onvif')),
|
|
|
|
|
rtsp_url TEXT,
|
|
|
|
|
onvif_host TEXT,
|
|
|
|
|
onvif_port INTEGER,
|
|
|
|
|
onvif_username TEXT,
|
|
|
|
|
onvif_password TEXT,
|
|
|
|
|
capabilities TEXT NOT NULL DEFAULT '[]',
|
|
|
|
|
stream_policy TEXT NOT NULL DEFAULT 'auto'
|
|
|
|
|
CHECK(stream_policy IN ('auto', 'always_main', 'always_sub')),
|
|
|
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
|
|
|
last_seen_at TEXT,
|
|
|
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
|
|
|
) STRICT`,
|
|
|
|
|
|
|
|
|
|
`CREATE TABLE IF NOT EXISTS camera_streams (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
camera_id INTEGER NOT NULL REFERENCES cameras(id) ON DELETE CASCADE,
|
|
|
|
|
role TEXT NOT NULL CHECK(role IN ('main', 'sub', 'other')),
|
|
|
|
|
name TEXT NOT NULL,
|
|
|
|
|
profile_token TEXT,
|
|
|
|
|
rtsp_uri TEXT NOT NULL,
|
|
|
|
|
width INTEGER,
|
|
|
|
|
height INTEGER,
|
|
|
|
|
encoding TEXT,
|
|
|
|
|
framerate REAL,
|
|
|
|
|
bitrate_kbps INTEGER,
|
|
|
|
|
is_discovered INTEGER NOT NULL DEFAULT 0
|
|
|
|
|
) STRICT`,
|
|
|
|
|
|
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_camera_streams_camera ON camera_streams(camera_id)`,
|
|
|
|
|
|
|
|
|
|
// ---- layout templates + layouts + cells ----------------------------------
|
|
|
|
|
`CREATE TABLE IF NOT EXISTS layout_templates (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
name TEXT NOT NULL UNIQUE,
|
|
|
|
|
description TEXT,
|
|
|
|
|
regions TEXT NOT NULL DEFAULT '[]',
|
|
|
|
|
grid_cols INTEGER NOT NULL DEFAULT 12,
|
|
|
|
|
grid_rows INTEGER NOT NULL DEFAULT 12,
|
|
|
|
|
is_builtin INTEGER NOT NULL DEFAULT 0
|
|
|
|
|
) STRICT`,
|
|
|
|
|
|
|
|
|
|
`CREATE TABLE IF NOT EXISTS layouts (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
name TEXT NOT NULL UNIQUE,
|
|
|
|
|
description TEXT,
|
|
|
|
|
template_id INTEGER NOT NULL REFERENCES layout_templates(id),
|
|
|
|
|
display_id INTEGER NOT NULL REFERENCES displays(id),
|
|
|
|
|
priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('hot', 'normal', 'cold')),
|
|
|
|
|
cooling_timeout_seconds INTEGER,
|
|
|
|
|
preload_camera_ids TEXT NOT NULL DEFAULT '[]',
|
|
|
|
|
is_default INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
resets_idle_timer INTEGER NOT NULL DEFAULT 1
|
|
|
|
|
) STRICT`,
|
|
|
|
|
|
|
|
|
|
`CREATE TABLE IF NOT EXISTS layout_cells (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE,
|
|
|
|
|
region_name TEXT NOT NULL,
|
2026-05-11 07:38:50 +00:00
|
|
|
content_type TEXT NOT NULL CHECK(content_type IN ('none', 'camera', 'web', 'html')),
|
2026-05-09 23:09:13 +00:00
|
|
|
camera_id INTEGER REFERENCES cameras(id) ON DELETE SET NULL,
|
|
|
|
|
stream_selector TEXT NOT NULL DEFAULT 'auto'
|
|
|
|
|
CHECK(stream_selector IN ('auto', 'main', 'sub')),
|
|
|
|
|
web_url TEXT,
|
|
|
|
|
html_content TEXT,
|
|
|
|
|
cooling_timeout_seconds INTEGER,
|
|
|
|
|
options TEXT NOT NULL DEFAULT '{}'
|
|
|
|
|
) STRICT`,
|
|
|
|
|
|
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_layout_cells_layout ON layout_cells(layout_id)`,
|
|
|
|
|
|
|
|
|
|
// ---- kiosks --------------------------------------------------------------
|
|
|
|
|
`CREATE TABLE IF NOT EXISTS kiosks (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
name TEXT NOT NULL UNIQUE,
|
|
|
|
|
description TEXT,
|
|
|
|
|
key_hash TEXT NOT NULL,
|
|
|
|
|
key_prefix TEXT NOT NULL,
|
|
|
|
|
capabilities TEXT NOT NULL DEFAULT '[]',
|
|
|
|
|
hardware_model TEXT,
|
|
|
|
|
os_version TEXT,
|
|
|
|
|
kiosk_app_version TEXT,
|
|
|
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
|
|
|
paired_at TEXT,
|
|
|
|
|
last_seen_at TEXT,
|
|
|
|
|
last_bundle_version TEXT,
|
|
|
|
|
display_id INTEGER REFERENCES displays(id) ON DELETE SET NULL,
|
|
|
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
|
|
|
) STRICT`,
|
|
|
|
|
|
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_kiosks_prefix ON kiosks(key_prefix)`,
|
|
|
|
|
|
|
|
|
|
// ---- labels --------------------------------------------------------------
|
|
|
|
|
`CREATE TABLE IF NOT EXISTS labels (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
name TEXT NOT NULL UNIQUE,
|
|
|
|
|
description TEXT,
|
|
|
|
|
color TEXT,
|
|
|
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
|
|
|
) STRICT`,
|
|
|
|
|
|
|
|
|
|
`CREATE TABLE IF NOT EXISTS kiosk_labels (
|
|
|
|
|
kiosk_id INTEGER NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE,
|
|
|
|
|
label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
|
|
|
|
|
role TEXT NOT NULL CHECK(role IN ('consume', 'operate')),
|
|
|
|
|
PRIMARY KEY (kiosk_id, label_id, role)
|
|
|
|
|
) STRICT`,
|
|
|
|
|
|
|
|
|
|
`CREATE TABLE IF NOT EXISTS camera_labels (
|
|
|
|
|
camera_id INTEGER NOT NULL REFERENCES cameras(id) ON DELETE CASCADE,
|
|
|
|
|
label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
|
|
|
|
|
PRIMARY KEY (camera_id, label_id)
|
|
|
|
|
) STRICT`,
|
|
|
|
|
|
|
|
|
|
`CREATE TABLE IF NOT EXISTS layout_labels (
|
|
|
|
|
layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE,
|
|
|
|
|
label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
|
|
|
|
|
PRIMARY KEY (layout_id, label_id)
|
|
|
|
|
) STRICT`,
|
|
|
|
|
|
|
|
|
|
// ---- pairing_codes -------------------------------------------------------
|
|
|
|
|
`CREATE TABLE IF NOT EXISTS pairing_codes (
|
|
|
|
|
code TEXT PRIMARY KEY,
|
|
|
|
|
kiosk_proposed_name TEXT,
|
|
|
|
|
kiosk_hardware_model TEXT,
|
|
|
|
|
kiosk_capabilities TEXT NOT NULL DEFAULT '[]',
|
|
|
|
|
issued_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
|
|
|
expires_at TEXT NOT NULL,
|
|
|
|
|
consumed_at TEXT,
|
|
|
|
|
consumed_by_kiosk_id INTEGER REFERENCES kiosks(id) ON DELETE SET NULL,
|
|
|
|
|
extras TEXT NOT NULL DEFAULT '{}'
|
|
|
|
|
) STRICT`,
|
|
|
|
|
|
|
|
|
|
// ---- event_log -----------------------------------------------------------
|
|
|
|
|
`CREATE TABLE IF NOT EXISTS event_log (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
source_kiosk_id INTEGER REFERENCES kiosks(id) ON DELETE SET NULL,
|
|
|
|
|
source_camera_id INTEGER REFERENCES cameras(id) ON DELETE SET NULL,
|
|
|
|
|
source_type TEXT NOT NULL CHECK(source_type IN ('onvif', 'gpio', 'synthetic', 'system')),
|
|
|
|
|
topic TEXT NOT NULL,
|
|
|
|
|
property_op TEXT,
|
|
|
|
|
payload TEXT NOT NULL DEFAULT '{}',
|
|
|
|
|
received_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
|
|
|
forwarded_to_nodered INTEGER NOT NULL DEFAULT 0
|
|
|
|
|
) STRICT`,
|
|
|
|
|
|
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_event_log_received ON event_log(received_at DESC)`,
|
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_event_log_topic ON event_log(topic, received_at DESC)`,
|
2026-05-10 19:39:09 +00:00
|
|
|
|
|
|
|
|
// ---- v0.2: flatten layout_templates into layouts, display→kiosk inversion ---
|
|
|
|
|
(db: DatabaseSync) => {
|
2026-05-10 20:18:03 +00:00
|
|
|
// Skip entirely if v0.5 rebuild already dropped template_id (idempotent re-run)
|
|
|
|
|
const cols = db.prepare(`PRAGMA table_info("layouts")`).all() as Array<{ name: string }>;
|
|
|
|
|
const hasTemplateId = cols.some((c) => c.name === "template_id");
|
|
|
|
|
if (!hasTemplateId) {
|
|
|
|
|
// Just ensure displays.kiosk_id exists for fresh-but-post-v0.5 DBs
|
|
|
|
|
addColumnIfNotExists(db, "displays", "kiosk_id", "INTEGER REFERENCES kiosks(id) ON DELETE SET NULL");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 19:39:09 +00:00
|
|
|
addColumnIfNotExists(db, "layouts", "regions", "TEXT NOT NULL DEFAULT '[]'");
|
|
|
|
|
addColumnIfNotExists(db, "layouts", "grid_cols", "INTEGER NOT NULL DEFAULT 1");
|
|
|
|
|
addColumnIfNotExists(db, "layouts", "grid_rows", "INTEGER NOT NULL DEFAULT 1");
|
|
|
|
|
|
|
|
|
|
// Copy template data into layouts (idempotent — only updates rows where regions is still '[]')
|
|
|
|
|
db.exec(`UPDATE layouts SET
|
|
|
|
|
regions = COALESCE((SELECT lt.regions FROM layout_templates lt WHERE lt.id = layouts.template_id), '[]'),
|
|
|
|
|
grid_cols = COALESCE((SELECT lt.grid_cols FROM layout_templates lt WHERE lt.id = layouts.template_id), 1),
|
|
|
|
|
grid_rows = COALESCE((SELECT lt.grid_rows FROM layout_templates lt WHERE lt.id = layouts.template_id), 1)
|
|
|
|
|
WHERE regions = '[]' AND template_id IS NOT NULL`);
|
|
|
|
|
|
|
|
|
|
addColumnIfNotExists(db, "displays", "kiosk_id", "INTEGER REFERENCES kiosks(id) ON DELETE SET NULL");
|
|
|
|
|
},
|
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_displays_kiosk ON displays(kiosk_id)`,
|
2026-05-10 19:55:19 +00:00
|
|
|
|
|
|
|
|
// ---- v0.3: decouple layouts from displays via join table -------------------
|
|
|
|
|
// Layouts become standalone entities; displays maintain a list of available
|
|
|
|
|
// layouts via display_layouts. Old layouts.display_id column is kept (SQLite
|
|
|
|
|
// can't drop columns) but no longer used by the application.
|
|
|
|
|
`CREATE TABLE IF NOT EXISTS display_layouts (
|
|
|
|
|
display_id INTEGER NOT NULL REFERENCES displays(id) ON DELETE CASCADE,
|
|
|
|
|
layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE,
|
|
|
|
|
PRIMARY KEY (display_id, layout_id)
|
|
|
|
|
) STRICT`,
|
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_display_layouts_layout ON display_layouts(layout_id)`,
|
|
|
|
|
(db: DatabaseSync) => {
|
|
|
|
|
// Backfill: every existing layout that has display_id gets attached to
|
|
|
|
|
// that display via the new join table. Idempotent via INSERT OR IGNORE.
|
|
|
|
|
const cols = db.prepare(`PRAGMA table_info("layouts")`).all() as Array<{ name: string }>;
|
|
|
|
|
if (!cols.some((c) => c.name === "display_id")) return;
|
|
|
|
|
const rows = db
|
|
|
|
|
.prepare(`SELECT id, display_id FROM layouts WHERE display_id IS NOT NULL`)
|
|
|
|
|
.all() as Array<{ id: number; display_id: number | null }>;
|
|
|
|
|
const ins = db.prepare(
|
|
|
|
|
`INSERT OR IGNORE INTO display_layouts (display_id, layout_id) VALUES (?, ?)`,
|
|
|
|
|
);
|
|
|
|
|
for (const r of rows) {
|
|
|
|
|
if (r.display_id != null) ins.run(r.display_id, r.id);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// ---- v0.4: cells own their position; drop regions/grid_*/is_default ----------
|
|
|
|
|
// layout_cells now have row/col/row_span/col_span columns directly. Existing
|
|
|
|
|
// cells get backfilled by parsing layouts.regions JSON and matching on
|
|
|
|
|
// region_name. The old columns (regions, grid_cols, grid_rows, is_default,
|
|
|
|
|
// region_name) are kept on the row (SQLite can't drop columns) but no longer
|
|
|
|
|
// used by the application.
|
|
|
|
|
(db: DatabaseSync) => {
|
|
|
|
|
addColumnIfNotExists(db, "layout_cells", "row", "INTEGER NOT NULL DEFAULT 0");
|
|
|
|
|
addColumnIfNotExists(db, "layout_cells", "col", "INTEGER NOT NULL DEFAULT 0");
|
|
|
|
|
addColumnIfNotExists(db, "layout_cells", "row_span", "INTEGER NOT NULL DEFAULT 1");
|
|
|
|
|
addColumnIfNotExists(db, "layout_cells", "col_span", "INTEGER NOT NULL DEFAULT 1");
|
|
|
|
|
|
|
|
|
|
// Backfill: parse each layout's regions JSON, match cells by region_name,
|
|
|
|
|
// copy row/col/rowSpan/colSpan onto the cell row. Only update cells that
|
|
|
|
|
// still have the default 0,0,1,1 (idempotent re-runs become no-ops once the
|
|
|
|
|
// operator has edited cells through the new UI).
|
|
|
|
|
const cellCols = db.prepare(`PRAGMA table_info("layout_cells")`).all() as Array<{ name: string }>;
|
|
|
|
|
const hasRegionName = cellCols.some((c) => c.name === "region_name");
|
|
|
|
|
const layoutCols = db.prepare(`PRAGMA table_info("layouts")`).all() as Array<{ name: string }>;
|
|
|
|
|
const hasRegions = layoutCols.some((c) => c.name === "regions");
|
|
|
|
|
if (!hasRegionName || !hasRegions) return;
|
|
|
|
|
|
|
|
|
|
const layouts = db
|
|
|
|
|
.prepare(`SELECT id, regions FROM layouts WHERE regions IS NOT NULL AND regions != '[]'`)
|
|
|
|
|
.all() as Array<{ id: number; regions: string }>;
|
|
|
|
|
const updateCell = db.prepare(
|
|
|
|
|
`UPDATE layout_cells
|
|
|
|
|
SET row = ?, col = ?, row_span = ?, col_span = ?
|
|
|
|
|
WHERE id = ?
|
|
|
|
|
AND row = 0 AND col = 0 AND row_span = 1 AND col_span = 1`,
|
|
|
|
|
);
|
|
|
|
|
for (const l of layouts) {
|
|
|
|
|
let regions: Array<{ name: string; row: number; col: number; rowSpan: number; colSpan: number }>;
|
|
|
|
|
try {
|
|
|
|
|
regions = JSON.parse(l.regions);
|
|
|
|
|
} catch {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (!Array.isArray(regions)) continue;
|
|
|
|
|
const cells = db
|
|
|
|
|
.prepare(`SELECT id, region_name FROM layout_cells WHERE layout_id = ?`)
|
|
|
|
|
.all(l.id) as Array<{ id: number; region_name: string }>;
|
|
|
|
|
for (const c of cells) {
|
|
|
|
|
const r = regions.find((reg) => reg.name === c.region_name);
|
|
|
|
|
if (!r) continue;
|
|
|
|
|
updateCell.run(
|
|
|
|
|
Number(r.row) || 0,
|
|
|
|
|
Number(r.col) || 0,
|
|
|
|
|
Number(r.rowSpan) || 1,
|
|
|
|
|
Number(r.colSpan) || 1,
|
|
|
|
|
c.id,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-05-10 20:03:32 +00:00
|
|
|
|
|
|
|
|
// ---- v0.5: rebuild layouts table to drop legacy columns
|
|
|
|
|
// SQLite can't drop columns, so rebuild: create new schema → copy data →
|
|
|
|
|
// drop old → rename. Removes template_id, display_id, regions, grid_cols,
|
|
|
|
|
// grid_rows, is_default — cells own position now, displays attach via join.
|
|
|
|
|
(db: DatabaseSync) => {
|
|
|
|
|
const cols = db.prepare(`PRAGMA table_info("layouts")`).all() as Array<{ name: string }>;
|
|
|
|
|
const hasTemplateId = cols.some((c) => c.name === "template_id");
|
|
|
|
|
if (!hasTemplateId) return; // already migrated
|
|
|
|
|
|
|
|
|
|
db.exec("PRAGMA foreign_keys = OFF");
|
|
|
|
|
db.exec(`
|
|
|
|
|
CREATE TABLE layouts_new (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
name TEXT NOT NULL UNIQUE,
|
|
|
|
|
description TEXT,
|
|
|
|
|
priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('hot', 'normal', 'cold')),
|
|
|
|
|
cooling_timeout_seconds INTEGER,
|
|
|
|
|
preload_camera_ids TEXT NOT NULL DEFAULT '[]',
|
|
|
|
|
resets_idle_timer INTEGER NOT NULL DEFAULT 1
|
|
|
|
|
) STRICT;
|
|
|
|
|
|
|
|
|
|
INSERT INTO layouts_new (id, name, description, priority, cooling_timeout_seconds, preload_camera_ids, resets_idle_timer)
|
|
|
|
|
SELECT id, name, description, priority, cooling_timeout_seconds, preload_camera_ids, resets_idle_timer FROM layouts;
|
|
|
|
|
|
|
|
|
|
DROP TABLE layouts;
|
|
|
|
|
ALTER TABLE layouts_new RENAME TO layouts;
|
|
|
|
|
`);
|
|
|
|
|
db.exec("PRAGMA foreign_keys = ON");
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Same cleanup for layout_cells — drop region_name, layout_id FK stays
|
|
|
|
|
(db: DatabaseSync) => {
|
|
|
|
|
const cols = db.prepare(`PRAGMA table_info("layout_cells")`).all() as Array<{ name: string }>;
|
|
|
|
|
const hasRegionName = cols.some((c) => c.name === "region_name");
|
|
|
|
|
if (!hasRegionName) return;
|
|
|
|
|
|
|
|
|
|
db.exec("PRAGMA foreign_keys = OFF");
|
|
|
|
|
db.exec(`
|
|
|
|
|
CREATE TABLE layout_cells_new (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE,
|
|
|
|
|
row INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
col INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
row_span INTEGER NOT NULL DEFAULT 1,
|
|
|
|
|
col_span INTEGER NOT NULL DEFAULT 1,
|
2026-05-11 07:38:50 +00:00
|
|
|
content_type TEXT NOT NULL CHECK(content_type IN ('none', 'camera', 'web', 'html')),
|
2026-05-10 20:03:32 +00:00
|
|
|
camera_id INTEGER REFERENCES cameras(id) ON DELETE SET NULL,
|
|
|
|
|
stream_selector TEXT,
|
|
|
|
|
web_url TEXT,
|
|
|
|
|
html_content TEXT,
|
|
|
|
|
cooling_timeout_seconds INTEGER,
|
|
|
|
|
options TEXT NOT NULL DEFAULT '{}'
|
|
|
|
|
) STRICT;
|
|
|
|
|
|
|
|
|
|
INSERT INTO layout_cells_new (id, layout_id, row, col, row_span, col_span, content_type, camera_id, stream_selector, web_url, html_content, cooling_timeout_seconds, options)
|
|
|
|
|
SELECT id, layout_id, row, col, row_span, col_span, content_type, camera_id, stream_selector, web_url, html_content, cooling_timeout_seconds, options FROM layout_cells;
|
|
|
|
|
|
|
|
|
|
DROP TABLE layout_cells;
|
|
|
|
|
ALTER TABLE layout_cells_new RENAME TO layout_cells;
|
|
|
|
|
`);
|
|
|
|
|
db.exec("PRAGMA foreign_keys = ON");
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Drop layout_templates entirely — concept removed
|
|
|
|
|
`DROP TABLE IF EXISTS layout_templates`,
|
2026-05-10 21:18:44 +00:00
|
|
|
|
|
|
|
|
// ---- v0.8: entities — unified content pool for layout cells -----------------
|
|
|
|
|
// Admin creates a reusable "entity" (camera reference, html snippet, web page)
|
|
|
|
|
// once and binds it to one or more layout cells. Cameras get an automatic
|
|
|
|
|
// mirror entity so existing layouts keep working.
|
|
|
|
|
`CREATE TABLE IF NOT EXISTS entities (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
name TEXT NOT NULL UNIQUE,
|
|
|
|
|
type TEXT NOT NULL CHECK(type IN ('camera', 'html', 'web')),
|
|
|
|
|
description TEXT,
|
|
|
|
|
camera_id INTEGER REFERENCES cameras(id) ON DELETE CASCADE,
|
|
|
|
|
html_content TEXT,
|
|
|
|
|
web_url TEXT,
|
|
|
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
|
|
|
) STRICT`,
|
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_entities_camera ON entities(camera_id)`,
|
|
|
|
|
|
|
|
|
|
(db: DatabaseSync) => {
|
|
|
|
|
addColumnIfNotExists(db, "layout_cells", "entity_id", "INTEGER REFERENCES entities(id) ON DELETE SET NULL");
|
|
|
|
|
},
|
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_layout_cells_entity ON layout_cells(entity_id)`,
|
|
|
|
|
|
|
|
|
|
// Backfill 1: ensure every camera has a mirror entity (name = camera.name)
|
|
|
|
|
(db: DatabaseSync) => {
|
|
|
|
|
const cams = db.prepare(`SELECT id, name FROM cameras`).all() as Array<{ id: number; name: string }>;
|
|
|
|
|
const has = db.prepare(`SELECT id FROM entities WHERE type = 'camera' AND camera_id = ?`);
|
|
|
|
|
const ins = db.prepare(
|
|
|
|
|
`INSERT OR IGNORE INTO entities (name, type, camera_id) VALUES (?, 'camera', ?)`,
|
|
|
|
|
);
|
|
|
|
|
for (const c of cams) {
|
|
|
|
|
const existing = has.get(c.id);
|
|
|
|
|
if (existing) continue;
|
|
|
|
|
// Resolve name collision by appending the camera id
|
|
|
|
|
const taken = db.prepare(`SELECT id FROM entities WHERE name = ?`).get(c.name);
|
|
|
|
|
const useName = taken ? `${c.name} (cam #${String(c.id)})` : c.name;
|
|
|
|
|
ins.run(useName, c.id);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Backfill 2: for each cell, set entity_id based on legacy content_type fields
|
|
|
|
|
(db: DatabaseSync) => {
|
|
|
|
|
const cells = db
|
|
|
|
|
.prepare(
|
|
|
|
|
`SELECT id, content_type, camera_id, html_content, web_url, entity_id
|
|
|
|
|
FROM layout_cells
|
|
|
|
|
WHERE entity_id IS NULL`,
|
|
|
|
|
)
|
|
|
|
|
.all() as Array<{
|
|
|
|
|
id: number;
|
|
|
|
|
content_type: string;
|
|
|
|
|
camera_id: number | null;
|
|
|
|
|
html_content: string | null;
|
|
|
|
|
web_url: string | null;
|
|
|
|
|
entity_id: number | null;
|
|
|
|
|
}>;
|
|
|
|
|
|
|
|
|
|
const findCameraEntity = db.prepare(`SELECT id FROM entities WHERE type = 'camera' AND camera_id = ?`);
|
|
|
|
|
const insertEntity = db.prepare(
|
|
|
|
|
`INSERT INTO entities (name, type, html_content, web_url) VALUES (?, ?, ?, ?)`,
|
|
|
|
|
);
|
|
|
|
|
const setCellEntity = db.prepare(`UPDATE layout_cells SET entity_id = ? WHERE id = ?`);
|
|
|
|
|
const nameExists = db.prepare(`SELECT 1 FROM entities WHERE name = ?`);
|
|
|
|
|
|
|
|
|
|
let autoCounter = 1;
|
|
|
|
|
function uniqueName(base: string): string {
|
|
|
|
|
// Find a unique entity name for the auto-created snippet
|
|
|
|
|
let candidate = base;
|
|
|
|
|
while (nameExists.get(candidate)) {
|
|
|
|
|
candidate = `${base} ${String(autoCounter)}`;
|
|
|
|
|
autoCounter += 1;
|
|
|
|
|
}
|
|
|
|
|
autoCounter += 1;
|
|
|
|
|
return candidate;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const cell of cells) {
|
|
|
|
|
if (cell.content_type === "camera" && cell.camera_id != null) {
|
|
|
|
|
const ent = findCameraEntity.get(cell.camera_id) as { id: number } | undefined;
|
|
|
|
|
if (ent) setCellEntity.run(ent.id, cell.id);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (cell.content_type === "html" && cell.html_content) {
|
|
|
|
|
const name = uniqueName(`Cell ${String(cell.id)} HTML`);
|
|
|
|
|
const r = insertEntity.run(name, "html", cell.html_content, null);
|
|
|
|
|
setCellEntity.run(Number(r.lastInsertRowid), cell.id);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (cell.content_type === "web" && cell.web_url) {
|
|
|
|
|
const name = uniqueName(`Cell ${String(cell.id)} Web`);
|
|
|
|
|
const r = insertEntity.run(name, "web", null, cell.web_url);
|
|
|
|
|
setCellEntity.run(Number(r.lastInsertRowid), cell.id);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
// empty cell — leave entity_id null
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-05-11 07:38:50 +00:00
|
|
|
|
|
|
|
|
// ---- v0.9: explicit empty layout cell state -------------------------------
|
|
|
|
|
(db: DatabaseSync) => {
|
|
|
|
|
const createSql = db
|
|
|
|
|
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'layout_cells'")
|
|
|
|
|
.get() as { sql?: string } | undefined;
|
|
|
|
|
if (createSql?.sql?.includes("'none'")) return;
|
|
|
|
|
|
|
|
|
|
db.exec("PRAGMA foreign_keys = OFF");
|
|
|
|
|
db.exec(`
|
|
|
|
|
CREATE TABLE layout_cells_new (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE,
|
|
|
|
|
row INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
col INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
row_span INTEGER NOT NULL DEFAULT 1,
|
|
|
|
|
col_span INTEGER NOT NULL DEFAULT 1,
|
|
|
|
|
content_type TEXT NOT NULL CHECK(content_type IN ('none', 'camera', 'web', 'html')),
|
|
|
|
|
camera_id INTEGER REFERENCES cameras(id) ON DELETE SET NULL,
|
|
|
|
|
stream_selector TEXT,
|
|
|
|
|
web_url TEXT,
|
|
|
|
|
html_content TEXT,
|
|
|
|
|
cooling_timeout_seconds INTEGER,
|
|
|
|
|
options TEXT NOT NULL DEFAULT '{}',
|
|
|
|
|
entity_id INTEGER REFERENCES entities(id) ON DELETE SET NULL
|
|
|
|
|
) STRICT;
|
|
|
|
|
|
|
|
|
|
INSERT INTO layout_cells_new (
|
|
|
|
|
id, layout_id, row, col, row_span, col_span,
|
|
|
|
|
content_type, camera_id, stream_selector, web_url, html_content,
|
|
|
|
|
cooling_timeout_seconds, options, entity_id
|
|
|
|
|
)
|
|
|
|
|
SELECT
|
|
|
|
|
id, layout_id, row, col, row_span, col_span,
|
|
|
|
|
CASE
|
|
|
|
|
WHEN entity_id IS NULL
|
|
|
|
|
AND content_type = 'html'
|
|
|
|
|
AND (html_content IS NULL OR html_content = '')
|
|
|
|
|
THEN 'none'
|
|
|
|
|
ELSE content_type
|
|
|
|
|
END,
|
|
|
|
|
camera_id, stream_selector, web_url, html_content,
|
|
|
|
|
cooling_timeout_seconds, options, entity_id
|
|
|
|
|
FROM layout_cells;
|
|
|
|
|
|
|
|
|
|
DROP TABLE layout_cells;
|
|
|
|
|
ALTER TABLE layout_cells_new RENAME TO layout_cells;
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_layout_cells_layout ON layout_cells(layout_id);
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_layout_cells_entity ON layout_cells(entity_id);
|
|
|
|
|
`);
|
|
|
|
|
db.exec("PRAGMA foreign_keys = ON");
|
|
|
|
|
},
|
2026-05-11 09:47:07 +00:00
|
|
|
|
|
|
|
|
// ---- hwmon columns on kiosks: cpu_temp_c, fan_rpm, fan_pwm ------
|
|
|
|
|
(db: DatabaseSync) => {
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "cpu_temp_c", "REAL");
|
2026-05-21 00:03:05 +00:00
|
|
|
addColumnIfNotExists(db, "kiosks", "cpu_load_percent", "REAL");
|
2026-05-11 09:47:07 +00:00
|
|
|
addColumnIfNotExists(db, "kiosks", "fan_rpm", "INTEGER");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "fan_pwm", "INTEGER");
|
2026-05-21 00:03:05 +00:00
|
|
|
addColumnIfNotExists(db, "kiosks", "memory_total_mb", "INTEGER");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "memory_used_mb", "INTEGER");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "disk_total_mb", "INTEGER");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "disk_free_mb", "INTEGER");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "disk_used_percent", "REAL");
|
2026-05-11 09:47:07 +00:00
|
|
|
},
|
2026-05-11 11:52:22 +00:00
|
|
|
|
|
|
|
|
// ---- per-cell content fit (cover|contain|fill) ----
|
|
|
|
|
(db: DatabaseSync) => {
|
|
|
|
|
addColumnIfNotExists(db, "layout_cells", "fit", "TEXT NOT NULL DEFAULT 'cover'");
|
|
|
|
|
},
|
2026-05-12 23:18:22 +00:00
|
|
|
|
2026-05-12 23:47:53 +00:00
|
|
|
// ---- entities.dashboard — Node-RED Dashboard tab entity type ---------------
|
|
|
|
|
// Adds dashboard_id column and broadens the type CHECK to include
|
|
|
|
|
// 'dashboard'. SQLite can't ALTER a CHECK in place — rebuild the table when
|
|
|
|
|
// the old constraint is detected.
|
|
|
|
|
(db: DatabaseSync) => {
|
|
|
|
|
addColumnIfNotExists(db, "entities", "dashboard_id", "TEXT");
|
|
|
|
|
|
|
|
|
|
const row = db
|
|
|
|
|
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'entities'")
|
|
|
|
|
.get() as { sql?: string } | undefined;
|
|
|
|
|
if (!row?.sql) return;
|
|
|
|
|
if (row.sql.includes("'dashboard'")) return; // already migrated
|
|
|
|
|
|
|
|
|
|
db.exec("PRAGMA foreign_keys = OFF");
|
|
|
|
|
db.exec(`
|
|
|
|
|
CREATE TABLE entities_new (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
name TEXT NOT NULL UNIQUE,
|
|
|
|
|
type TEXT NOT NULL CHECK(type IN ('camera', 'html', 'web', 'dashboard')),
|
|
|
|
|
description TEXT,
|
|
|
|
|
camera_id INTEGER REFERENCES cameras(id) ON DELETE CASCADE,
|
|
|
|
|
html_content TEXT,
|
|
|
|
|
web_url TEXT,
|
|
|
|
|
dashboard_id TEXT,
|
|
|
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
|
|
|
) STRICT;
|
|
|
|
|
|
|
|
|
|
INSERT INTO entities_new (id, name, type, description, camera_id, html_content, web_url, dashboard_id, created_at)
|
|
|
|
|
SELECT id, name, type, description, camera_id, html_content, web_url, dashboard_id, created_at FROM entities;
|
|
|
|
|
|
|
|
|
|
DROP TABLE entities;
|
|
|
|
|
ALTER TABLE entities_new RENAME TO entities;
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_entities_camera ON entities(camera_id);
|
|
|
|
|
`);
|
|
|
|
|
db.exec("PRAGMA foreign_keys = ON");
|
|
|
|
|
},
|
|
|
|
|
|
2026-05-12 23:18:22 +00:00
|
|
|
// ---- kiosk GPIO bindings ----
|
|
|
|
|
`CREATE TABLE IF NOT EXISTS kiosk_gpio_bindings (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
kiosk_id INTEGER NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE,
|
|
|
|
|
chip TEXT NOT NULL DEFAULT 'gpiochip0',
|
|
|
|
|
pin INTEGER NOT NULL,
|
|
|
|
|
direction TEXT NOT NULL CHECK(direction IN ('in', 'out')),
|
|
|
|
|
pull TEXT CHECK(pull IN ('up', 'down', 'none')),
|
|
|
|
|
edge TEXT CHECK(edge IN ('rising', 'falling', 'both')),
|
|
|
|
|
topic TEXT NOT NULL,
|
|
|
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
|
|
|
) STRICT`,
|
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_kiosk_gpio_bindings_kiosk ON kiosk_gpio_bindings(kiosk_id)`,
|
2026-05-13 00:59:28 +00:00
|
|
|
|
|
|
|
|
// ---- displays.is_enabled — admin toggle to suppress window on a display ----
|
|
|
|
|
(db: DatabaseSync) => {
|
|
|
|
|
addColumnIfNotExists(db, "displays", "is_enabled", "INTEGER NOT NULL DEFAULT 1");
|
|
|
|
|
},
|
2026-05-13 01:57:12 +00:00
|
|
|
|
|
|
|
|
// ---- displays.index is local to the kiosk, not globally unique -------------
|
|
|
|
|
(db: DatabaseSync) => {
|
|
|
|
|
const row = db
|
|
|
|
|
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'displays'")
|
|
|
|
|
.get() as { sql?: string } | undefined;
|
|
|
|
|
if (!row?.sql || !row.sql.includes('"index" INTEGER NOT NULL UNIQUE')) return;
|
|
|
|
|
|
|
|
|
|
db.exec("PRAGMA foreign_keys = OFF");
|
|
|
|
|
db.exec(`
|
|
|
|
|
CREATE TABLE displays_new (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
name TEXT NOT NULL,
|
|
|
|
|
"index" INTEGER NOT NULL,
|
|
|
|
|
is_primary INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
width_px INTEGER NOT NULL DEFAULT 1920,
|
|
|
|
|
height_px INTEGER NOT NULL DEFAULT 1080,
|
|
|
|
|
default_layout_id INTEGER,
|
|
|
|
|
idle_timeout_seconds INTEGER NOT NULL DEFAULT 600,
|
|
|
|
|
sleep_timeout_seconds INTEGER NOT NULL DEFAULT 1800,
|
|
|
|
|
cec_enabled INTEGER NOT NULL DEFAULT 1,
|
|
|
|
|
cec_device_path TEXT,
|
|
|
|
|
cec_logical_address INTEGER,
|
|
|
|
|
desired_power_state TEXT NOT NULL DEFAULT 'follow_layout'
|
|
|
|
|
CHECK(desired_power_state IN ('follow_layout', 'on', 'standby')),
|
|
|
|
|
state_check_enabled INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
state_check_interval_seconds INTEGER NOT NULL DEFAULT 60,
|
|
|
|
|
kiosk_id INTEGER REFERENCES kiosks(id) ON DELETE SET NULL,
|
|
|
|
|
is_enabled INTEGER NOT NULL DEFAULT 1
|
|
|
|
|
) STRICT;
|
|
|
|
|
|
|
|
|
|
INSERT INTO displays_new (
|
|
|
|
|
id, name, "index", is_primary, width_px, height_px, default_layout_id,
|
|
|
|
|
idle_timeout_seconds, sleep_timeout_seconds, cec_enabled, cec_device_path,
|
|
|
|
|
cec_logical_address, desired_power_state, state_check_enabled,
|
|
|
|
|
state_check_interval_seconds, kiosk_id, is_enabled
|
|
|
|
|
)
|
|
|
|
|
SELECT
|
|
|
|
|
id, name, "index", is_primary, width_px, height_px, default_layout_id,
|
|
|
|
|
idle_timeout_seconds, sleep_timeout_seconds, cec_enabled, cec_device_path,
|
|
|
|
|
cec_logical_address, desired_power_state, state_check_enabled,
|
|
|
|
|
state_check_interval_seconds, kiosk_id, is_enabled
|
|
|
|
|
FROM displays;
|
|
|
|
|
|
|
|
|
|
DROP TABLE displays;
|
|
|
|
|
ALTER TABLE displays_new RENAME TO displays;
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_displays_kiosk ON displays(kiosk_id);
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_displays_kiosk_index
|
|
|
|
|
ON displays(kiosk_id, "index");
|
|
|
|
|
`);
|
|
|
|
|
db.exec("PRAGMA foreign_keys = ON");
|
|
|
|
|
},
|
2026-05-13 18:56:42 +00:00
|
|
|
|
|
|
|
|
// ---- firmware OTA --------------------------------------------------------
|
|
|
|
|
// One row per signed kiosk binary. arch lets us hold images for
|
|
|
|
|
// aarch64-pi5 + x86_64 + future targets side by side. signature is
|
|
|
|
|
// Ed25519(sha256(binary)) by the server's firmware-signing key — kiosk
|
|
|
|
|
// verifies before swap.
|
|
|
|
|
`CREATE TABLE IF NOT EXISTS firmware_releases (
|
|
|
|
|
id TEXT PRIMARY KEY,
|
|
|
|
|
version TEXT NOT NULL,
|
|
|
|
|
channel TEXT NOT NULL CHECK(channel IN ('stable', 'beta', 'dev')),
|
|
|
|
|
arch TEXT NOT NULL,
|
|
|
|
|
artifact_path TEXT NOT NULL,
|
|
|
|
|
size_bytes INTEGER NOT NULL,
|
|
|
|
|
sha256 TEXT NOT NULL,
|
|
|
|
|
signature TEXT NOT NULL,
|
|
|
|
|
release_notes TEXT,
|
|
|
|
|
uploaded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
|
|
|
uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
|
|
|
|
yanked_at TEXT
|
|
|
|
|
) STRICT`,
|
|
|
|
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_firmware_releases_version_arch ON firmware_releases(version, arch)`,
|
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_firmware_releases_channel ON firmware_releases(channel, arch, uploaded_at DESC)`,
|
|
|
|
|
|
|
|
|
|
`CREATE TABLE IF NOT EXISTS firmware_rollouts (
|
|
|
|
|
id TEXT PRIMARY KEY,
|
|
|
|
|
release_id TEXT NOT NULL REFERENCES firmware_releases(id) ON DELETE CASCADE,
|
|
|
|
|
target_kiosk_ids TEXT NOT NULL DEFAULT '[]',
|
|
|
|
|
state TEXT NOT NULL DEFAULT 'queued' CHECK(state IN ('queued', 'active', 'paused', 'complete')),
|
|
|
|
|
percentage INTEGER NOT NULL DEFAULT 100 CHECK(percentage BETWEEN 1 AND 100),
|
|
|
|
|
started_at TEXT,
|
|
|
|
|
finished_at TEXT,
|
|
|
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
|
|
|
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL
|
|
|
|
|
) STRICT`,
|
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_firmware_rollouts_state ON firmware_rollouts(state)`,
|
|
|
|
|
|
2026-05-20 04:19:46 +00:00
|
|
|
// ---- full OS OTA ---------------------------------------------------------
|
|
|
|
|
// One row per signed RAUC bundle. compatibility must match the kiosk's RAUC
|
|
|
|
|
// compatible string (for example betterframe-rpi5-aarch64).
|
|
|
|
|
`CREATE TABLE IF NOT EXISTS os_update_releases (
|
|
|
|
|
id TEXT PRIMARY KEY,
|
|
|
|
|
version TEXT NOT NULL,
|
|
|
|
|
channel TEXT NOT NULL CHECK(channel IN ('stable', 'beta', 'dev')),
|
|
|
|
|
compatibility TEXT NOT NULL,
|
|
|
|
|
artifact_path TEXT NOT NULL,
|
|
|
|
|
size_bytes INTEGER NOT NULL,
|
|
|
|
|
sha256 TEXT NOT NULL,
|
|
|
|
|
bundle_format TEXT NOT NULL DEFAULT 'raucb' CHECK(bundle_format = 'raucb'),
|
|
|
|
|
release_notes TEXT,
|
|
|
|
|
uploaded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
|
|
|
uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
|
|
|
|
yanked_at TEXT
|
|
|
|
|
) STRICT`,
|
|
|
|
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_os_update_releases_version_compat ON os_update_releases(version, compatibility)`,
|
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_os_update_releases_channel ON os_update_releases(channel, compatibility, uploaded_at DESC)`,
|
|
|
|
|
|
|
|
|
|
`CREATE TABLE IF NOT EXISTS os_update_rollouts (
|
|
|
|
|
id TEXT PRIMARY KEY,
|
|
|
|
|
release_id TEXT NOT NULL REFERENCES os_update_releases(id) ON DELETE CASCADE,
|
|
|
|
|
target_kiosk_ids TEXT NOT NULL DEFAULT '[]',
|
|
|
|
|
state TEXT NOT NULL DEFAULT 'queued' CHECK(state IN ('queued', 'active', 'paused', 'complete')),
|
|
|
|
|
percentage INTEGER NOT NULL DEFAULT 100 CHECK(percentage BETWEEN 1 AND 100),
|
|
|
|
|
started_at TEXT,
|
|
|
|
|
finished_at TEXT,
|
|
|
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
|
|
|
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL
|
|
|
|
|
) STRICT`,
|
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_os_update_rollouts_state ON os_update_rollouts(state)`,
|
|
|
|
|
|
2026-05-13 18:56:42 +00:00
|
|
|
// Per-kiosk firmware preferences + update tracking.
|
|
|
|
|
(db: DatabaseSync) => {
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "firmware_channel", "TEXT NOT NULL DEFAULT 'stable'");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "firmware_target_version", "TEXT");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "firmware_last_attempt_at", "TEXT");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "firmware_last_attempt_version", "TEXT");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "firmware_last_error", "TEXT");
|
2026-05-20 04:19:46 +00:00
|
|
|
addColumnIfNotExists(db, "kiosks", "os_update_channel", "TEXT NOT NULL DEFAULT 'stable'");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "os_update_target_version", "TEXT");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "os_update_last_attempt_at", "TEXT");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "os_update_last_attempt_version", "TEXT");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "os_update_last_error", "TEXT");
|
2026-05-13 18:56:42 +00:00
|
|
|
},
|
2026-05-14 05:24:21 +00:00
|
|
|
|
|
|
|
|
// ---- Kiosk LAN-side local server: reported via heartbeat ------------------
|
|
|
|
|
(db: DatabaseSync) => {
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "local_key", "TEXT");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "local_port", "INTEGER");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "local_last_ip", "TEXT");
|
2026-05-21 07:23:50 +00:00
|
|
|
addColumnIfNotExists(db, "kiosks", "reported_hostname", "TEXT");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "network_interfaces_json", "TEXT");
|
2026-05-14 05:24:21 +00:00
|
|
|
},
|
2026-05-14 05:38:18 +00:00
|
|
|
|
|
|
|
|
// ---- Audit log -----------------------------------------------------------
|
|
|
|
|
// Append-only record of security-relevant actions: logins, API key use,
|
|
|
|
|
// kiosk pair/replace, firmware upload/yank/rollout, admin CRUD of any
|
|
|
|
|
// resource. Read-only via admin UI, filterable.
|
|
|
|
|
`CREATE TABLE IF NOT EXISTS audit_log (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
ts TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
|
|
|
actor_type TEXT NOT NULL CHECK(actor_type IN ('user', 'api_key', 'system', 'kiosk')),
|
|
|
|
|
actor_id INTEGER,
|
|
|
|
|
actor_label TEXT,
|
|
|
|
|
action TEXT NOT NULL,
|
|
|
|
|
resource_type TEXT,
|
|
|
|
|
resource_id TEXT,
|
|
|
|
|
ip TEXT,
|
|
|
|
|
metadata TEXT NOT NULL DEFAULT '{}',
|
|
|
|
|
result TEXT NOT NULL DEFAULT 'ok' CHECK(result IN ('ok', 'failed'))
|
|
|
|
|
) STRICT`,
|
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_audit_log_ts ON audit_log(ts DESC)`,
|
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action, ts DESC)`,
|
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_type, actor_id, ts DESC)`,
|
2026-05-20 01:18:11 +00:00
|
|
|
|
|
|
|
|
// ---- Managed-image device config -----------------------------------------
|
|
|
|
|
// For kiosks running our pre-built Pi image (managed_image=1), admins can
|
|
|
|
|
// push hostname / timezone / network / wifi config. Kiosk pulls on heartbeat
|
|
|
|
|
// when server's version > applied_version, applies via a privileged helper,
|
|
|
|
|
// echoes applied_version back. managed_config_error captures last failure.
|
|
|
|
|
(db: DatabaseSync) => {
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "managed_image", "INTEGER NOT NULL DEFAULT 0");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "managed_config_json", "TEXT");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "managed_config_version", "INTEGER NOT NULL DEFAULT 0");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "managed_config_applied_version", "INTEGER NOT NULL DEFAULT 0");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "managed_config_applied_at", "TEXT");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "managed_config_error", "TEXT");
|
|
|
|
|
},
|
2026-05-21 00:03:05 +00:00
|
|
|
|
|
|
|
|
// Backfill RTSP cameras created before camera_streams became mandatory for
|
|
|
|
|
// rendering. Without this, the kiosk sees a camera but no playable stream.
|
|
|
|
|
(db: DatabaseSync) => {
|
|
|
|
|
db.exec(`
|
|
|
|
|
INSERT INTO camera_streams (camera_id, role, name, rtsp_uri, is_discovered)
|
|
|
|
|
SELECT c.id, 'main', 'Main', c.rtsp_url, 0
|
|
|
|
|
FROM cameras c
|
|
|
|
|
WHERE c.type = 'rtsp'
|
|
|
|
|
AND c.rtsp_url IS NOT NULL
|
|
|
|
|
AND c.rtsp_url != ''
|
|
|
|
|
AND NOT EXISTS (
|
|
|
|
|
SELECT 1 FROM camera_streams s WHERE s.camera_id = c.id
|
|
|
|
|
)
|
|
|
|
|
`);
|
|
|
|
|
},
|
2026-05-21 07:10:30 +00:00
|
|
|
|
|
|
|
|
// Display power state reported by kiosk heartbeat.
|
|
|
|
|
(db: DatabaseSync) => {
|
|
|
|
|
addColumnIfNotExists(db, "displays", "actual_power_state", "TEXT NOT NULL DEFAULT 'unknown'");
|
|
|
|
|
addColumnIfNotExists(db, "displays", "actual_power_state_at", "TEXT");
|
|
|
|
|
},
|
2026-05-21 07:23:50 +00:00
|
|
|
|
2026-05-21 08:19:39 +00:00
|
|
|
// Kiosk reports active layout per display via layout.changed events.
|
|
|
|
|
// Persist on the display row so the admin UI can highlight which layout
|
|
|
|
|
// is currently rendering instead of defaulting to first-in-list.
|
|
|
|
|
(db: DatabaseSync) => {
|
|
|
|
|
addColumnIfNotExists(db, "displays", "active_layout_id", "INTEGER REFERENCES layouts(id) ON DELETE SET NULL");
|
|
|
|
|
},
|
|
|
|
|
|
2026-05-21 08:14:52 +00:00
|
|
|
// Backfill hwmon/telemetry columns. They were originally added inline to
|
|
|
|
|
// an earlier migration entry; existing deploys had already passed that
|
|
|
|
|
// index via PRAGMA user_version, so the new columns silently never landed.
|
|
|
|
|
// Re-add idempotently here so replaceKioskKey / heartbeat stop hitting
|
|
|
|
|
// "no such column" on upgrade.
|
|
|
|
|
(db: DatabaseSync) => {
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "cpu_load_percent", "REAL");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "memory_total_mb", "INTEGER");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "memory_used_mb", "INTEGER");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "disk_total_mb", "INTEGER");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "disk_free_mb", "INTEGER");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "disk_used_percent", "REAL");
|
|
|
|
|
},
|
2026-05-21 09:34:29 +00:00
|
|
|
|
|
|
|
|
// ---- kiosk_logs: dedicated table for kiosk application logs ---------------
|
|
|
|
|
`CREATE TABLE IF NOT EXISTS kiosk_logs (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
kiosk_id INTEGER NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE,
|
|
|
|
|
level TEXT NOT NULL CHECK(level IN ('debug', 'info', 'warn', 'error')),
|
|
|
|
|
message TEXT NOT NULL,
|
|
|
|
|
context TEXT NOT NULL DEFAULT '{}',
|
|
|
|
|
logged_at TEXT NOT NULL,
|
|
|
|
|
received_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
|
|
|
) STRICT`,
|
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_kiosk_logs_kiosk_received ON kiosk_logs(kiosk_id, received_at DESC)`,
|
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_kiosk_logs_level ON kiosk_logs(level, received_at DESC)`,
|
2026-05-21 09:46:20 +00:00
|
|
|
|
|
|
|
|
// Catch-all backfill for tables/columns that were added inside earlier
|
|
|
|
|
// migration entries after existing deploys had already passed those
|
|
|
|
|
// indices via PRAGMA user_version. All IF NOT EXISTS / addColumnIfNotExists
|
|
|
|
|
// so they're safe to run on fresh DBs too.
|
|
|
|
|
(db: DatabaseSync) => {
|
|
|
|
|
// --- reported hostname + network interfaces (heartbeat telemetry) ---
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "reported_hostname", "TEXT");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "network_interfaces_json", "TEXT");
|
|
|
|
|
|
|
|
|
|
// --- OS update per-kiosk prefs ---
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "os_update_channel", "TEXT NOT NULL DEFAULT 'stable'");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "os_update_target_version", "TEXT");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "os_update_last_attempt_at", "TEXT");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "os_update_last_attempt_version", "TEXT");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "os_update_last_error", "TEXT");
|
|
|
|
|
|
|
|
|
|
// --- OS update releases + rollouts tables ---
|
|
|
|
|
db.exec(`CREATE TABLE IF NOT EXISTS os_update_releases (
|
|
|
|
|
id TEXT PRIMARY KEY,
|
|
|
|
|
version TEXT NOT NULL,
|
|
|
|
|
channel TEXT NOT NULL CHECK(channel IN ('stable', 'beta', 'dev')),
|
|
|
|
|
compatibility TEXT NOT NULL,
|
|
|
|
|
artifact_path TEXT NOT NULL,
|
|
|
|
|
size_bytes INTEGER NOT NULL,
|
|
|
|
|
sha256 TEXT NOT NULL,
|
|
|
|
|
bundle_format TEXT NOT NULL DEFAULT 'raucb',
|
|
|
|
|
release_notes TEXT,
|
|
|
|
|
uploaded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
|
|
|
uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
|
|
|
|
yanked_at TEXT
|
|
|
|
|
) STRICT`);
|
|
|
|
|
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_os_update_releases_version_compat ON os_update_releases(version, compatibility)`);
|
|
|
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_os_update_releases_channel ON os_update_releases(channel, compatibility, uploaded_at DESC)`);
|
|
|
|
|
|
|
|
|
|
db.exec(`CREATE TABLE IF NOT EXISTS os_update_rollouts (
|
|
|
|
|
id TEXT PRIMARY KEY,
|
|
|
|
|
release_id TEXT NOT NULL REFERENCES os_update_releases(id) ON DELETE CASCADE,
|
|
|
|
|
target_kiosk_ids TEXT NOT NULL DEFAULT '[]',
|
|
|
|
|
state TEXT NOT NULL DEFAULT 'queued' CHECK(state IN ('queued', 'active', 'paused', 'complete')),
|
|
|
|
|
percentage INTEGER NOT NULL DEFAULT 100 CHECK(percentage BETWEEN 1 AND 100),
|
|
|
|
|
started_at TEXT,
|
|
|
|
|
finished_at TEXT,
|
|
|
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
|
|
|
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL
|
|
|
|
|
) STRICT`);
|
|
|
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_os_update_rollouts_state ON os_update_rollouts(state)`);
|
|
|
|
|
|
|
|
|
|
// --- managed-image config columns ---
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "managed_image", "INTEGER NOT NULL DEFAULT 0");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "managed_config_json", "TEXT");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "managed_config_version", "INTEGER NOT NULL DEFAULT 0");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "managed_config_applied_version", "INTEGER NOT NULL DEFAULT 0");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "managed_config_applied_at", "TEXT");
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "managed_config_error", "TEXT");
|
|
|
|
|
|
|
|
|
|
// --- display active layout ---
|
|
|
|
|
addColumnIfNotExists(db, "displays", "active_layout_id", "INTEGER REFERENCES layouts(id) ON DELETE SET NULL");
|
2026-05-23 00:56:56 +00:00
|
|
|
|
|
|
|
|
// --- per-kiosk encryption key ---
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "encrypt_key_encrypted", "TEXT");
|
|
|
|
|
|
|
|
|
|
// --- ONVIF event routing ---
|
|
|
|
|
addColumnIfNotExists(db, "cameras", "event_source", "TEXT NOT NULL DEFAULT 'auto'");
|
|
|
|
|
addColumnIfNotExists(db, "cameras", "event_sink", "TEXT NOT NULL DEFAULT 'auto'");
|
|
|
|
|
addColumnIfNotExists(db, "cameras", "supported_event_topics", "TEXT NOT NULL DEFAULT '[]'");
|
|
|
|
|
|
|
|
|
|
// --- cloud accounts table ---
|
|
|
|
|
db.exec(`CREATE TABLE IF NOT EXISTS cloud_accounts (
|
|
|
|
|
id TEXT PRIMARY KEY,
|
|
|
|
|
vendor TEXT NOT NULL,
|
|
|
|
|
name TEXT NOT NULL,
|
|
|
|
|
credentials_encrypted TEXT NOT NULL,
|
|
|
|
|
is_active INTEGER NOT NULL DEFAULT 1,
|
|
|
|
|
last_sync_at TEXT,
|
|
|
|
|
last_sync_error TEXT,
|
|
|
|
|
camera_count INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
|
|
|
) STRICT`);
|
|
|
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_cloud_accounts_vendor ON cloud_accounts(vendor)`);
|
2026-05-21 09:46:20 +00:00
|
|
|
},
|
2026-05-22 22:38:54 +00:00
|
|
|
|
2026-05-22 23:36:43 +00:00
|
|
|
// Per-kiosk encryption key. Replaces shared cluster_key for bundle
|
|
|
|
|
// encryption. Generated at pairing, stored encrypted with server secret,
|
|
|
|
|
// delivered to kiosk once. Compromised SD → only this kiosk's camera
|
|
|
|
|
// passwords exposed (not fleet-wide).
|
|
|
|
|
(db: DatabaseSync) => {
|
|
|
|
|
addColumnIfNotExists(db, "kiosks", "encrypt_key_encrypted", "TEXT");
|
|
|
|
|
},
|
|
|
|
|
|
feat(cloud-cameras): Hik-Connect + Dahua + Tuya + Uniview + TP-Link integrations
Cloud camera platform integrations with provider interface pattern:
Framework (cloud-cameras/types.ts):
- CloudCameraProvider interface: testCredentials, listCameras,
getStreamUrl, credentialFields
- CloudAccount model + vendor registry
- Multiple accounts per vendor per tenant supported
- All auth on server — kiosk only gets streaming URLs
Vendors:
- Hik-Connect: token auth, device list via OpenAPI, local RTSP
(cloud P2P relay requires native SDK — not supported yet)
- Dahua: HTTP Basic/Digest against device ISAPI, channel enumeration,
RTSP URL construction per channel
- Tuya: OAuth2 + HMAC-SHA256, device list + stream allocation via
IoT Cloud API, RTSP/HLS URL from allocate endpoint
- Uniview: HTTP Basic against LightAPI, channel enumeration via
/LAPI/V1.0/Channels, RTSP per channel
- TP-Link: no cloud API, direct RTSP + TCP port probe for testing
DB: cloud_accounts table (SQLite migration) for storing encrypted
credentials per vendor per tenant.
Admin UI for account management TODO — provider framework + DB ready.
2026-05-23 00:25:44 +00:00
|
|
|
// Cloud camera accounts: per-vendor, multiple accounts per vendor.
|
|
|
|
|
// Credentials encrypted with server secret. Sync runs server-side,
|
|
|
|
|
// streaming URLs delivered to kiosks via the bundle.
|
|
|
|
|
`CREATE TABLE IF NOT EXISTS cloud_accounts (
|
|
|
|
|
id TEXT PRIMARY KEY,
|
|
|
|
|
vendor TEXT NOT NULL,
|
|
|
|
|
name TEXT NOT NULL,
|
|
|
|
|
credentials_encrypted TEXT NOT NULL,
|
|
|
|
|
is_active INTEGER NOT NULL DEFAULT 1,
|
|
|
|
|
last_sync_at TEXT,
|
|
|
|
|
last_sync_error TEXT,
|
|
|
|
|
camera_count INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
|
|
|
) STRICT`,
|
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_cloud_accounts_vendor ON cloud_accounts(vendor)`,
|
|
|
|
|
|
2026-05-22 22:38:54 +00:00
|
|
|
// ONVIF event routing: per-camera event_source (who polls), event_sink
|
|
|
|
|
// (where push callbacks go), and discovered supported topics.
|
|
|
|
|
(db: DatabaseSync) => {
|
|
|
|
|
addColumnIfNotExists(db, "cameras", "event_source", "TEXT NOT NULL DEFAULT 'auto'");
|
|
|
|
|
addColumnIfNotExists(db, "cameras", "event_sink", "TEXT NOT NULL DEFAULT 'auto'");
|
|
|
|
|
addColumnIfNotExists(db, "cameras", "supported_event_topics", "TEXT NOT NULL DEFAULT '[]'");
|
|
|
|
|
},
|
2026-05-23 09:36:49 +00:00
|
|
|
|
|
|
|
|
// Cloud camera type + cloud-linked fields. Rebuild cameras table to add
|
|
|
|
|
// 'cloud' to type CHECK. Cloud cameras are managed by sync — not editable.
|
|
|
|
|
(db: DatabaseSync) => {
|
|
|
|
|
addColumnIfNotExists(db, "cameras", "cloud_account_id", "TEXT");
|
|
|
|
|
addColumnIfNotExists(db, "cameras", "cloud_vendor_camera_id", "TEXT");
|
|
|
|
|
addColumnIfNotExists(db, "cameras", "cloud_stream_url", "TEXT");
|
|
|
|
|
addColumnIfNotExists(db, "cameras", "cloud_stream_type", "TEXT");
|
|
|
|
|
|
|
|
|
|
// Rebuild to widen CHECK constraint to include 'cloud'.
|
|
|
|
|
const row = db
|
|
|
|
|
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'cameras'")
|
|
|
|
|
.get() as { sql?: string } | undefined;
|
|
|
|
|
if (!row?.sql || row.sql.includes("'cloud'")) return;
|
|
|
|
|
|
|
|
|
|
db.exec("PRAGMA foreign_keys = OFF");
|
|
|
|
|
db.exec(`
|
|
|
|
|
CREATE TABLE cameras_new (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
name TEXT NOT NULL UNIQUE,
|
|
|
|
|
type TEXT NOT NULL CHECK(type IN ('rtsp', 'onvif', 'cloud')),
|
|
|
|
|
rtsp_url TEXT,
|
|
|
|
|
onvif_host TEXT,
|
|
|
|
|
onvif_port INTEGER,
|
|
|
|
|
onvif_username TEXT,
|
|
|
|
|
onvif_password TEXT,
|
|
|
|
|
capabilities TEXT NOT NULL DEFAULT '[]',
|
|
|
|
|
stream_policy TEXT NOT NULL DEFAULT 'auto'
|
|
|
|
|
CHECK(stream_policy IN ('auto', 'always_main', 'always_sub')),
|
|
|
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
|
|
|
last_seen_at TEXT,
|
|
|
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
|
|
|
event_source TEXT NOT NULL DEFAULT 'auto',
|
|
|
|
|
event_sink TEXT NOT NULL DEFAULT 'auto',
|
|
|
|
|
supported_event_topics TEXT NOT NULL DEFAULT '[]',
|
|
|
|
|
cloud_account_id TEXT,
|
|
|
|
|
cloud_vendor_camera_id TEXT,
|
|
|
|
|
cloud_stream_url TEXT,
|
|
|
|
|
cloud_stream_type TEXT
|
|
|
|
|
) STRICT;
|
|
|
|
|
|
|
|
|
|
INSERT INTO cameras_new (
|
|
|
|
|
id, name, type, rtsp_url, onvif_host, onvif_port, onvif_username, onvif_password,
|
|
|
|
|
capabilities, stream_policy, enabled, last_seen_at, created_at,
|
|
|
|
|
event_source, event_sink, supported_event_topics,
|
|
|
|
|
cloud_account_id, cloud_vendor_camera_id, cloud_stream_url, cloud_stream_type
|
|
|
|
|
)
|
|
|
|
|
SELECT
|
|
|
|
|
id, name, type, rtsp_url, onvif_host, onvif_port, onvif_username, onvif_password,
|
|
|
|
|
capabilities, stream_policy, enabled, last_seen_at, created_at,
|
|
|
|
|
event_source, event_sink, supported_event_topics,
|
|
|
|
|
cloud_account_id, cloud_vendor_camera_id, cloud_stream_url, cloud_stream_type
|
|
|
|
|
FROM cameras;
|
|
|
|
|
|
|
|
|
|
DROP TABLE cameras;
|
|
|
|
|
ALTER TABLE cameras_new RENAME TO cameras;
|
|
|
|
|
`);
|
|
|
|
|
db.exec("PRAGMA foreign_keys = ON");
|
|
|
|
|
},
|
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_cameras_cloud_account ON cameras(cloud_account_id)`,
|
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_cameras_cloud_vendor ON cameras(cloud_account_id, cloud_vendor_camera_id)`,
|
2026-05-09 23:09:13 +00:00
|
|
|
];
|