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.
This commit is contained in:
Mitchell R 2026-05-13 03:57:12 +02:00
parent 54d4dfefa8
commit 7c88d7f733
4 changed files with 83 additions and 11 deletions

View file

@ -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"))

View file

@ -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<number>();
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() };

View file

@ -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");
},
];

View file

@ -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<string, unknown>));
}
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;
}