From 7c88d7f7334efcfb31dde274d4740b405e441941 Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Wed, 13 May 2026 03:57:12 +0200 Subject: [PATCH] fix(displays): use kiosk-local indices Kiosk heartbeat reports local display positions so the server can sync physical outputs without consuming global display indices. Migrate displays.index away from global uniqueness because display numbering is only meaningful within a kiosk. --- kiosk/src/server.rs | 4 +- server/src/plugins/service-api-http/index.ts | 31 +++++++++-- .../src/plugins/service-store/migrations.ts | 52 +++++++++++++++++++ .../src/plugins/service-store/repository.ts | 7 ++- 4 files changed, 83 insertions(+), 11 deletions(-) diff --git a/kiosk/src/server.rs b/kiosk/src/server.rs index 7cc6511..314cb83 100644 --- a/kiosk/src/server.rs +++ b/kiosk/src/server.rs @@ -200,8 +200,8 @@ pub fn heartbeat( hw: &crate::hwmon::HwInfo, ) { let client = reqwest::blocking::Client::new(); - let display_info: Vec<_> = displays.iter().map(|(name, w, h)| { - serde_json::json!({ "name": name, "width_px": w, "height_px": h }) + let display_info: Vec<_> = displays.iter().enumerate().map(|(index, (name, w, h))| { + serde_json::json!({ "index": index, "name": name, "width_px": w, "height_px": h }) }).collect(); let _ = client .post(format!("{server}/api/kiosk/heartbeat")) diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index 320c4ba..4ac337d 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -258,7 +258,7 @@ function registerKioskRoutes( bundle_version?: string; kiosk_app_version?: string; os_version?: string; - displays?: Array<{ name: string; width_px: number; height_px: number }>; + displays?: Array<{ index?: number; name: string; width_px: number; height_px: number }>; cpu_temp_c?: number | null; fan_rpm?: number | null; fan_pwm?: number | null; @@ -276,24 +276,45 @@ function registerKioskRoutes( // Sync displays reported by the kiosk if (Array.isArray(body?.displays)) { const existing = repo.listDisplaysForKiosk(kiosk.id); - for (const reported of body.displays) { - const match = existing.find((d) => d.name.endsWith(reported.name)); + const seenDisplayIds = new Set(); + for (const [position, reported] of body.displays.entries()) { + const reportedIndex = Number.isInteger(reported.index) && reported.index! >= 0 + ? reported.index! + : position; + const match = existing.find((d) => d.name.endsWith(reported.name)) + ?? existing.find((d) => d.index === reportedIndex); if (match) { - if (match.width_px !== reported.width_px || match.height_px !== reported.height_px) { + seenDisplayIds.add(match.id); + if ( + match.name !== reported.name + || match.index !== reportedIndex + || match.width_px !== reported.width_px + || match.height_px !== reported.height_px + ) { repo.updateDisplay(match.id, { + name: reported.name, + index: reportedIndex, width_px: reported.width_px, height_px: reported.height_px, } as any); } } else { // New display — create it - repo.createDisplayForKiosk(kiosk.id, { + const created = repo.createDisplayForKiosk(kiosk.id, { name: reported.name, + index: reportedIndex, width_px: reported.width_px, height_px: reported.height_px, }); + seenDisplayIds.add(created.id); } } + for (const display of existing) { + if (seenDisplayIds.has(display.id) || !display.is_enabled) continue; + if (!display.name.endsWith(" HDMI-0")) continue; + if (repo.listLayoutsForDisplay(display.id).length > 0) continue; + repo.updateDisplay(display.id, { is_enabled: false } as any); + } } return { ok: true, now: new Date().toISOString() }; diff --git a/server/src/plugins/service-store/migrations.ts b/server/src/plugins/service-store/migrations.ts index f87b92d..cf4c464 100644 --- a/server/src/plugins/service-store/migrations.ts +++ b/server/src/plugins/service-store/migrations.ts @@ -657,4 +657,56 @@ export const MIGRATIONS: readonly MigrationEntry[] = [ (db: DatabaseSync) => { addColumnIfNotExists(db, "displays", "is_enabled", "INTEGER NOT NULL DEFAULT 1"); }, + + // ---- 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"); + }, ]; diff --git a/server/src/plugins/service-store/repository.ts b/server/src/plugins/service-store/repository.ts index 530bb82..8f7a3a1 100644 --- a/server/src/plugins/service-store/repository.ts +++ b/server/src/plugins/service-store/repository.ts @@ -373,8 +373,7 @@ export class Repository { width_px?: number; height_px?: number; }): Display { - // Find next available index - const idx = input.index ?? this.nextDisplayIndex(); + const idx = input.index ?? this.nextDisplayIndexForKiosk(kioskId); const result = this.prep( `INSERT INTO displays (name, "index", is_primary, kiosk_id, width_px, height_px) VALUES (?, ?, 0, ?, ?, ?)`, @@ -399,8 +398,8 @@ export class Repository { return rs.map((r) => rowToDisplay(r as Record)); } - private nextDisplayIndex(): number { - const r = this.prep('SELECT MAX("index") AS m FROM displays').get() as { m: number | null } | undefined; + private nextDisplayIndexForKiosk(kioskId: number): number { + const r = this.prep('SELECT MAX("index") AS m FROM displays WHERE kiosk_id = ?').get(kioskId) as { m: number | null } | undefined; return (r?.m ?? -1) + 1; }