From 7fbda3c2b30998ad5febd6b06c9f5cee21f782c5 Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Sun, 10 May 2026 21:39:09 +0200 Subject: [PATCH] refactor: merge templates into layouts, displays from kiosks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Eliminated layout_templates as separate entity — regions/grid now live directly on layouts - Displays created from kiosk pairing (not standalone), each display has kiosk_id FK - Removed Templates from sidebar nav and all template routes/pages - Layout creation uses preset buttons (fullscreen, 2x2, 1+3, 3x3) that set regions directly on the layout - Setup no longer creates default display/layout (deferred to pairing) - Pairing creates HDMI-0 display for new kiosk - Bundle reads regions from layout directly, no template lookup - Rust kiosk updated to match new bundle format - DB migration adds regions/grid_cols/grid_rows to layouts, kiosk_id to displays, copies existing template data --- docs/ARCHITECTURE.md | 143 +++++ kiosk/src/bundle.rs | 13 +- kiosk/src/ui.rs | 14 +- .../service-admin-http/routes-admin.ts | 153 ++---- .../service-admin-http/routes-setup.ts | 27 +- server/src/plugins/service-store/index.ts | 8 +- server/src/plugins/service-store/mappers.ts | 6 +- .../src/plugins/service-store/migrations.ts | 37 +- .../src/plugins/service-store/repository.ts | 61 ++- server/src/schemas/wire/bundle.ts | 29 +- server/src/shared/bundle.ts | 31 +- server/src/shared/pairing.ts | 10 +- server/src/shared/types.ts | 10 +- server/src/web-templates/admin-pages.tsx | 488 ++++++------------ server/src/web-templates/layout.tsx | 1 - 15 files changed, 500 insertions(+), 531 deletions(-) create mode 100644 docs/ARCHITECTURE.md diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..11c08a1 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,143 @@ +# BetterFrame — Architecture + +## Goals + +- Display up to 32 cameras simultaneously on a Pi 5 driving HDMI. +- Mixed cells: cameras, web pages (iframe), and custom HTML. +- Layouts switch with no perceptible latency, driven by API or camera events. +- Layout templates (named regions) compile to a pixel grid at runtime. +- Cameras configured via raw RTSP or ONVIF (auto-discover streams + capabilities). +- API-key-protected REST API for everything except local kiosk reads. +- Single display in v1; data model already supports multi-display. + +## Process layout + +Two processes on the Pi, coordinating over a local WebSocket: + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Raspberry Pi 5 │ +│ │ +│ ┌────────────────────────────┐ ┌───────────────────────────┐ │ +│ │ Kiosk (Rust + GTK4) │ │ Backend (FastAPI) │ │ +│ │ │ │ │ │ +│ │ Decoder pool (warm/hot) │◄───┤ - SQLite │ │ +│ │ Grid renderer (GTK4) │ │ - ONVIF service │ │ +│ │ WebKitGTK pool │ WS │ - Layout API │ │ +│ │ │ │ - Event rules engine │ │ +│ └──────────────┬─────────────┘ │ - API key auth │ │ +│ │ │ - Static admin UI │ │ +│ │ RTSP └────────────┬──────────────┘ │ +└─────────────────┼───────────────────────────────┼────────────────┘ + ▼ │ + ┌─────────────────┐ ▼ + │ IP cameras │ LAN clients (port 8080) + │ RTSP / ONVIF │ + └─────────────────┘ +``` + +## Why these choices + +**Rust kiosk + Python backend.** Rust where the latency budget is tight +(pipeline state changes, decoder management, render loop). Python where the +ecosystem matters (`onvif-zeep`, FastAPI, alembic). They communicate via +WebSocket so neither is locked to the other's runtime. + +**SQLite, not Postgres.** Total dataset is hundreds of rows. WAL mode handles +the kiosk-as-reader case fine, atomic schema migrations are easy, single-file +backup is trivial. + +**GStreamer for video.** Only realistic choice on Linux for hardware-accelerated +multi-camera. Pi 5 V4L2 M2M decoder is exposed via `v4l2h264dec`; `gstreamer-rs` +bindings are mature. + +## Stream warmth model + +Each `(camera_id, stream_type)` pair is in one of four states: + +| State | RTSP open | Decoder running | Visible | Promote cost | +|----------|-----------|-----------------|---------|--------------| +| Hot | yes | yes | yes | 0 | +| Warm | yes | yes (paused) | no | ~1 frame | +| Cooling | yes | yes | no | 0 | +| Cold | no | no | no | 1-3 seconds | + +The kiosk computes the needed warm set on every layout activation: + +``` +warm_set = + streams_used_by_active_layout + ∪ streams_in_layout_preload_list + ∪ streams_used_by_priority_hot_layouts (always-on) + ∪ streams_currently_in_cooling_window +``` + +Anything outside that set transitions to cooling, then cold when its timeout +expires. + +## Layout templates + +Templates define named regions in a normalized 12×12 grid. Layouts reference a +template and bind cameras or content to its named regions. + +```yaml +templates: + - id: 1-big-7-small + regions: + - { name: main, x: 0, y: 0, w: 8, h: 8 } + - { name: tr-1, x: 8, y: 0, w: 4, h: 2 } + - { name: tr-2, x: 8, y: 2, w: 4, h: 2 } + # ... + +layouts: + - id: front-overview + template_id: 1-big-7-small + bindings: + main: { type: camera, camera_id: 1, stream: main } + tr-1: { type: camera, camera_id: 2, stream: sub } + br-3: { type: web, url: "http://homeassistant.local/dashboard" } + priority: hot + cooling_timeout_seconds: 300 + preload_camera_ids: [4, 5] +``` + +Templates compile to pixel rectangles at the kiosk based on actual display +resolution. Cells under 20% of total display area default to sub-stream; +≥20% default to main; per-cell override always wins. + +## Event rules engine + +ONVIF cameras with event support get a persistent PullPoint subscription managed +by the backend. Events are normalized to `{camera_id, topic, payload}` and +matched against rules: + +```yaml +event_rules: + - when: + camera_id: 5 + topic: "tns1:RuleEngine/CellMotionDetector/Motion" + property_op: "Changed" + do: + action: activate_layout + layout_id: front-door-zoom + revert_after_seconds: 60 + revert_to: previous + cooldown_seconds: 30 +``` + +External systems fire synthetic events via `POST /api/events/trigger`, so +non-ONVIF inputs work through the same engine. + +## Auth + +- **Kiosk → backend**: WebSocket on `127.0.0.1:8000`, no auth (loopback only). +- **LAN → backend**: HTTP on `0.0.0.0:8080`, every route requires `X-API-Key`. + +Two listeners, two middleware stacks, same FastAPI app. + +## Multi-display readiness + +Schema includes `display_id` on `layouts` and a `displays` table. v1 hard-codes +a single display row. The kiosk↔backend protocol includes `display_id` in +every activation message. Adding a second display later: new `displays` row, +new kiosk instance bound to it, no API changes. diff --git a/kiosk/src/bundle.rs b/kiosk/src/bundle.rs index 1130ea0..62f3e5d 100644 --- a/kiosk/src/bundle.rs +++ b/kiosk/src/bundle.rs @@ -25,7 +25,9 @@ pub struct BundleDisplay { pub struct BundleLayout { pub id: u32, pub name: String, - pub template: Option, + pub regions: Vec, + pub grid_cols: u32, + pub grid_rows: u32, pub priority: String, pub cooling_timeout_seconds: Option, pub preload_camera_ids: Vec, @@ -34,15 +36,6 @@ pub struct BundleLayout { pub cells: Vec, } -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct BundleTemplate { - pub id: u32, - pub name: String, - pub regions: Vec, - pub grid_cols: u32, - pub grid_rows: u32, -} - #[derive(Debug, Clone, Deserialize, Serialize)] pub struct BundleRegion { pub name: String, diff --git a/kiosk/src/ui.rs b/kiosk/src/ui.rs index 116644f..8f71728 100644 --- a/kiosk/src/ui.rs +++ b/kiosk/src/ui.rs @@ -120,14 +120,14 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle) { return; }; - let Some(ref template) = layout.template else { - warn!("layout has no template"); + if layout.regions.is_empty() { + warn!("layout has no regions"); show_logo(window); return; - }; + } info!("rendering layout '{}' with {}x{} grid, {} cells", - layout.name, template.grid_cols, template.grid_rows, layout.cells.len()); + layout.name, layout.grid_cols, layout.grid_rows, layout.cells.len()); let grid = Grid::new(); grid.set_row_homogeneous(true); @@ -141,9 +141,9 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle) { let pipelines: Rc>> = Rc::new(RefCell::new(Vec::new())); for cell in &layout.cells { - let region = template.regions.iter().find(|r| r.name == cell.region_name); + let region = layout.regions.iter().find(|r| r.name == cell.region_name); let Some(region) = region else { - warn!("region '{}' not found in template", cell.region_name); + warn!("region '{}' not found in layout", cell.region_name); continue; }; @@ -200,7 +200,7 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle) { } // Fill empty regions - for region in &template.regions { + for region in &layout.regions { if !layout.cells.iter().any(|c| c.region_name == region.name) { let empty = GtkBox::new(Orientation::Vertical, 0); add_css(&empty, "box { background-color: #111; }"); diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index f3c3e06..8be0a3c 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -13,16 +13,13 @@ import { KiosksPage, KioskEditPage, LabelsPage, - TemplatesPage, - TemplateNewPage, - TemplateEditPage, LayoutsPage, LayoutNewPage, LayoutEditPage, DisplaysPage, DisplayEditPage, } from "../../web-templates/admin-pages.js"; -import type { LayoutTemplate, Display } from "../../shared/types.js"; +import type { Display } from "../../shared/types.js"; function sanitizeRtspUrl(raw: string): string { const match = raw.match(/^(rtsp:\/\/)([^@]+)@(.+)$/); @@ -196,33 +193,47 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); }); - // ---- Templates (Layout Templates) ------------------------------------------ + // ---- Layouts --------------------------------------------------------------- - app.get("/admin/templates", (event) => { + app.get("/admin/layouts", (event) => { const user = event.context.user!; - const templates = deps.repo.listLayoutTemplates(); - return htmlPage(TemplatesPage({ user: user.username, templates })); + const layouts = deps.repo.listLayouts(); + const displayIds = [...new Set(layouts.map((l) => l.display_id))]; + const displays = new Map(); + for (const did of displayIds) { + const d = deps.repo.getDisplayById(did); + if (d) displays.set(did, d); + } + return htmlPage(LayoutsPage({ user: user.username, layouts, displays })); }); - app.get("/admin/templates/new", (event) => { + app.get("/admin/layouts/new", (event) => { const user = event.context.user!; - return htmlPage(TemplateNewPage({ user: user.username })); + return htmlPage(LayoutNewPage({ + user: user.username, + displays: deps.repo.listDisplays(), + })); }); - app.post("/admin/templates/new", async (event) => { + app.post("/admin/layouts/new", async (event) => { const user = event.context.user!; const body = await readBody>(event); + const name = (body?.["name"] ?? "").trim(); const preset = body?.["preset"] ?? "custom"; - let name = (body?.["name"] ?? "").trim(); + const displayId = parseInt(body?.["display_id"] ?? "", 10); + const priority = body?.["priority"] ?? "normal"; + const description = (body?.["description"] ?? "").trim() || null; + const isDefault = body?.["is_default"] === "1"; + const resetsIdleTimer = body?.["resets_idle_timer"] === "1"; const errors: string[] = []; - if (!name || name.length > 128) { - errors.push("Name required (max 128 chars)."); - } + if (!name || name.length > 128) errors.push("Name required (max 128 chars)."); + if (isNaN(displayId)) errors.push("Select a display."); - let regions: Array<{ name: string; row: number; col: number; rowSpan: number; colSpan: number }> = []; - let gridCols = 12; - let gridRows = 12; + type Region = { name: string; row: number; col: number; rowSpan: number; colSpan: number }; + let regions: Region[] = []; + let gridCols = 1; + let gridRows = 1; if (preset === "fullscreen") { gridCols = 1; @@ -262,14 +273,14 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { ]; } else { // Custom - gridCols = parseInt(body?.["grid_cols"] ?? "12", 10); - gridRows = parseInt(body?.["grid_rows"] ?? "12", 10); + gridCols = parseInt(body?.["grid_cols"] ?? "1", 10); + gridRows = parseInt(body?.["grid_rows"] ?? "1", 10); if (isNaN(gridCols) || gridCols < 1 || gridCols > 12) errors.push("Grid columns must be 1-12."); if (isNaN(gridRows) || gridRows < 1 || gridRows > 12) errors.push("Grid rows must be 1-12."); const regionsStr = (body?.["regions"] ?? "").trim(); if (!regionsStr) { - errors.push("Regions JSON is required for custom templates."); + errors.push("Regions JSON is required for custom layout."); } else { try { regions = JSON.parse(regionsStr); @@ -282,97 +293,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { } } - if (errors.length > 0) { - return htmlPage(TemplateNewPage({ - user: user.username, - error: errors.join(" "), - values: body, - })); - } - - deps.repo.createLayoutTemplate({ - name, - regions, - grid_cols: gridCols, - grid_rows: gridRows, - }); - - return new Response(null, { status: 302, headers: { location: "/admin/templates" } }); - }); - - app.get("/admin/templates/:id", (event) => { - const user = event.context.user!; - const id = Number(getRouterParam(event, "id")); - const template = deps.repo.getLayoutTemplateById(id); - if (!template) return new Response(null, { status: 302, headers: { location: "/admin/templates" } }); - return htmlPage(TemplateEditPage({ user: user.username, template })); - }); - - app.post("/admin/templates/:id", async (event) => { - const id = Number(getRouterParam(event, "id")); - const body = await readBody>(event); - deps.repo.updateLayoutTemplate(id, { - name: body?.["name"], - description: body?.["description"] || null, - }); - return new Response(null, { status: 302, headers: { location: `/admin/templates/${id}` } }); - }); - - app.post("/admin/templates/:id/delete", (event) => { - const id = Number(getRouterParam(event, "id")); - deps.repo.deleteLayoutTemplate(id); - return new Response(null, { status: 302, headers: { location: "/admin/templates" } }); - }); - - // ---- Layouts --------------------------------------------------------------- - - app.get("/admin/layouts", (event) => { - const user = event.context.user!; - const layouts = deps.repo.listLayouts(); - const templateIds = [...new Set(layouts.map((l) => l.template_id))]; - const displayIds = [...new Set(layouts.map((l) => l.display_id))]; - const templates = new Map(); - for (const tid of templateIds) { - const t = deps.repo.getLayoutTemplateById(tid); - if (t) templates.set(tid, t); - } - const displays = new Map(); - for (const did of displayIds) { - const d = deps.repo.getDisplayById(did); - if (d) displays.set(did, d); - } - return htmlPage(LayoutsPage({ user: user.username, layouts, templates, displays })); - }); - - app.get("/admin/layouts/new", (event) => { - const user = event.context.user!; - return htmlPage(LayoutNewPage({ - user: user.username, - templates: deps.repo.listLayoutTemplates(), - displays: deps.repo.listDisplays(), - })); - }); - - app.post("/admin/layouts/new", async (event) => { - const user = event.context.user!; - const body = await readBody>(event); - const name = (body?.["name"] ?? "").trim(); - const templateId = parseInt(body?.["template_id"] ?? "", 10); - const displayId = parseInt(body?.["display_id"] ?? "", 10); - const priority = body?.["priority"] ?? "normal"; - const description = (body?.["description"] ?? "").trim() || null; - const isDefault = body?.["is_default"] === "1"; - const resetsIdleTimer = body?.["resets_idle_timer"] === "1"; - const errors: string[] = []; - - if (!name || name.length > 128) errors.push("Name required (max 128 chars)."); - if (isNaN(templateId)) errors.push("Select a template."); - if (isNaN(displayId)) errors.push("Select a display."); - if (errors.length > 0) { return htmlPage(LayoutNewPage({ user: user.username, - templates: deps.repo.listLayoutTemplates(), displays: deps.repo.listDisplays(), error: errors.join(" "), values: body, @@ -382,7 +305,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const layout = deps.repo.createLayout({ name, description, - template_id: templateId, + regions, + grid_cols: gridCols, + grid_rows: gridRows, display_id: displayId, priority, is_default: isDefault, @@ -397,8 +322,6 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const id = Number(getRouterParam(event, "id")); const layout = deps.repo.getLayoutById(id); if (!layout) return new Response(null, { status: 302, headers: { location: "/admin/layouts" } }); - const template = deps.repo.getLayoutTemplateById(layout.template_id); - if (!template) return new Response(null, { status: 302, headers: { location: "/admin/layouts" } }); const display = deps.repo.getDisplayById(layout.display_id); if (!display) return new Response(null, { status: 302, headers: { location: "/admin/layouts" } }); const cells = deps.repo.layoutCells(id); @@ -406,7 +329,6 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { return htmlPage(LayoutEditPage({ user: user.username, layout, - template, display, cells, cameras, @@ -479,7 +401,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const display = deps.repo.getDisplayById(id); if (!display) return new Response(null, { status: 302, headers: { location: "/admin/displays" } }); const layouts = deps.repo.layoutsForDisplay(id); - return htmlPage(DisplayEditPage({ user: user.username, display, layouts })); + const kiosk = display.kiosk_id ? deps.repo.getKioskById(display.kiosk_id) : null; + return htmlPage(DisplayEditPage({ user: user.username, display, layouts, kioskName: kiosk?.name ?? null })); }); app.post("/admin/displays/:id", async (event) => { @@ -635,11 +558,13 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { name: kl.name, role: kl.role, })); + const displays = deps.repo.listDisplaysForKiosk(id); return htmlPage(KioskEditPage({ user: user.username, kiosk, labels: kioskLabels, allLabels: deps.repo.listLabels(), + displays, })); }); diff --git a/server/src/plugins/service-admin-http/routes-setup.ts b/server/src/plugins/service-admin-http/routes-setup.ts index fd70725..78a7b0b 100644 --- a/server/src/plugins/service-admin-http/routes-setup.ts +++ b/server/src/plugins/service-admin-http/routes-setup.ts @@ -45,30 +45,9 @@ export function registerSetupRoutes(app: H3, deps: AdminDeps): void { deps.repo.setSetupExtra("cluster_key_encrypted", encryptedCluster); deps.repo.markClusterKeyProvisioned(); - // Create default display, template, and layout - const display = deps.repo.createDefaultDisplay(); - const template = deps.repo.createLayoutTemplate({ - name: "Fullscreen", - description: "Single region covering the entire display", - regions: [{ name: "main", row: 0, col: 0, rowSpan: 1, colSpan: 1 }], - grid_cols: 1, - grid_rows: 1, - is_builtin: true, - }); - const layout = deps.repo.createLayout({ - name: "Default", - description: "Default layout — BetterFrame logo", - template_id: template.id, - display_id: display.id, - is_default: true, - }); - deps.repo.createLayoutCell({ - layout_id: layout.id, - region_name: "main", - content_type: "html", - html_content: '

BetterFrame

', - }); - deps.repo.updateDisplay(display.id, { default_layout_id: layout.id }); + // Setup only creates admin user + cluster key. + // Displays are created when kiosks are paired (kiosk reports HDMI ports). + // Layouts are created by admin after pairing. deps.repo.markSetupComplete(); return new Response(null, { diff --git a/server/src/plugins/service-store/index.ts b/server/src/plugins/service-store/index.ts index a6fa0b1..d90c568 100644 --- a/server/src/plugins/service-store/index.ts +++ b/server/src/plugins/service-store/index.ts @@ -121,8 +121,12 @@ export class Plugin extends BSBService, typeof Event this.db.exec("PRAGMA busy_timeout = 10000"); obs.log.info("running {n} migrations", { n: MIGRATIONS.length }); - for (const stmt of MIGRATIONS) { - this.db.exec(stmt); + for (const entry of MIGRATIONS) { + if (typeof entry === "string") { + this.db.exec(entry); + } else { + entry(this.db); + } } this._repo = new Repository(this.db, async (table, op, id) => { diff --git a/server/src/plugins/service-store/mappers.ts b/server/src/plugins/service-store/mappers.ts index bf82cd1..1fe89dc 100644 --- a/server/src/plugins/service-store/mappers.ts +++ b/server/src/plugins/service-store/mappers.ts @@ -109,6 +109,7 @@ export function rowToDisplay(r: Row): Display { name: s(r["name"]), index: n(r["index"]), is_primary: b(r["is_primary"]), + kiosk_id: nn(r["kiosk_id"]), width_px: n(r["width_px"]), height_px: n(r["height_px"]), default_layout_id: nn(r["default_layout_id"]), @@ -175,7 +176,10 @@ export function rowToLayout(r: Row): Layout { id: n(r["id"]), name: s(r["name"]), description: sn(r["description"]), - template_id: n(r["template_id"]), + template_id: nn(r["template_id"]), + regions: j(r["regions"], []), + grid_cols: n(r["grid_cols"]) || 1, + grid_rows: n(r["grid_rows"]) || 1, display_id: n(r["display_id"]), priority: s(r["priority"]) as LayoutPriority, cooling_timeout_seconds: nn(r["cooling_timeout_seconds"]), diff --git a/server/src/plugins/service-store/migrations.ts b/server/src/plugins/service-store/migrations.ts index 6da0cfc..d80c7f3 100644 --- a/server/src/plugins/service-store/migrations.ts +++ b/server/src/plugins/service-store/migrations.ts @@ -13,7 +13,25 @@ * SQLAlchemy's DateTime adapter — we avoid the whole class of issue here.) */ -export const MIGRATIONS: readonly string[] = [ +/** + * 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[] = [ // ---- users --------------------------------------------------------------- `CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -249,4 +267,21 @@ export const MIGRATIONS: readonly string[] = [ `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)`, + + // ---- v0.2: flatten layout_templates into layouts, display→kiosk inversion --- + (db: DatabaseSync) => { + 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)`, ]; diff --git a/server/src/plugins/service-store/repository.ts b/server/src/plugins/service-store/repository.ts index f673e6f..388c1a1 100644 --- a/server/src/plugins/service-store/repository.ts +++ b/server/src/plugins/service-store/repository.ts @@ -350,7 +350,7 @@ export class Repository { createDefaultDisplay(): Display { const result = this.prep( `INSERT INTO displays (name, "index", is_primary) - VALUES ('primary', 0, 1)`, + VALUES ('primary', 0, 0)`, ).run(); const id = Number(result.lastInsertRowid); void this.notify("displays", "create", id); @@ -359,6 +359,43 @@ export class Repository { return d; } + createDisplayForKiosk(kioskId: number, input: { + name: string; + index?: number; + width_px?: number; + height_px?: number; + }): Display { + // Find next available index + const idx = input.index ?? this.nextDisplayIndex(); + const result = this.prep( + `INSERT INTO displays (name, "index", is_primary, kiosk_id, width_px, height_px) + VALUES (?, ?, 0, ?, ?, ?)`, + ).run( + input.name, + idx, + kioskId, + input.width_px ?? 1920, + input.height_px ?? 1080, + ); + const id = Number(result.lastInsertRowid); + void this.notify("displays", "create", id); + const d = this.getDisplayById(id); + if (!d) throw new Error("display vanished after insert"); + return d; + } + + listDisplaysForKiosk(kioskId: number): Display[] { + const rs = this.prep( + 'SELECT * FROM displays WHERE kiosk_id = ? ORDER BY "index"', + ).all(kioskId); + 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; + return (r?.m ?? -1) + 1; + } + updateDisplay(id: number, patch: Partial): void { const sets: string[] = []; const vals: unknown[] = []; @@ -457,7 +494,10 @@ export class Repository { createLayout(input: { name: string; description?: string | null; - template_id: number; + template_id?: number | null; + regions: unknown; + grid_cols: number; + grid_rows: number; display_id: number; priority?: string; cooling_timeout_seconds?: number | null; @@ -466,12 +506,15 @@ export class Repository { resets_idle_timer?: boolean; }): Layout { const result = this.prep( - `INSERT INTO layouts (name, description, template_id, display_id, priority, cooling_timeout_seconds, preload_camera_ids, is_default, resets_idle_timer) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO layouts (name, description, template_id, regions, grid_cols, grid_rows, display_id, priority, cooling_timeout_seconds, preload_camera_ids, is_default, resets_idle_timer) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( input.name, input.description ?? null, - input.template_id, + input.template_id ?? null, + J(input.regions), + input.grid_cols, + input.grid_rows, input.display_id, input.priority ?? "normal", input.cooling_timeout_seconds ?? null, @@ -492,7 +535,7 @@ export class Repository { for (const [k, v] of Object.entries(patch)) { if (k === "id") continue; sets.push(`${k} = ?`); - if (k === "preload_camera_ids") vals.push(J(v)); + if (k === "preload_camera_ids" || k === "regions") vals.push(J(v)); else if (typeof v === "boolean") vals.push(B(v)); else vals.push(v === undefined ? null : v); } @@ -810,19 +853,17 @@ export class Repository { key_prefix: string; capabilities?: string[]; hardware_model?: string | null; - display_id?: number | null; }): Kiosk { const result = this.prep( `INSERT INTO kiosks - (name, key_hash, key_prefix, capabilities, hardware_model, display_id, paired_at) - VALUES (?, ?, ?, ?, ?, ?, ?)`, + (name, key_hash, key_prefix, capabilities, hardware_model, paired_at) + VALUES (?, ?, ?, ?, ?, ?)`, ).run( input.name, input.key_hash, input.key_prefix, J(input.capabilities ?? []), input.hardware_model ?? null, - input.display_id ?? null, isoNow(), ); const id = Number(result.lastInsertRowid); diff --git a/server/src/schemas/wire/bundle.ts b/server/src/schemas/wire/bundle.ts index 97f91cb..c4d5ade 100644 --- a/server/src/schemas/wire/bundle.ts +++ b/server/src/schemas/wire/bundle.ts @@ -58,24 +58,13 @@ const camera = av.object( { unknownKeys: "reject" }, ); -const layoutTemplate = av.object( +const layoutRegion = av.object( { - id: av.int().min(1), - name: av.string().minLength(1).maxLength(128), - regions: av.array( - av.object( - { - name: av.string().minLength(1).maxLength(64), - row: av.int().min(0).max(11), - col: av.int().min(0).max(11), - rowSpan: av.int().min(1).max(12), - colSpan: av.int().min(1).max(12), - }, - { unknownKeys: "reject" }, - ), - ), - grid_cols: av.int().min(1).max(64), - grid_rows: av.int().min(1).max(64), + name: av.string().minLength(1).maxLength(64), + row: av.int().min(0).max(11), + col: av.int().min(0).max(11), + rowSpan: av.int().min(1).max(12), + colSpan: av.int().min(1).max(12), }, { unknownKeys: "reject" }, ); @@ -98,8 +87,9 @@ const layout = av.object( { id: av.int().min(1), name: av.string().minLength(1).maxLength(128), - template_id: av.int().min(1), - display_id: av.int().min(1), + regions: av.array(layoutRegion), + grid_cols: av.int().min(1).max(64), + grid_rows: av.int().min(1).max(64), priority: layoutPriority, cooling_timeout_seconds: av.nullable(av.int().min(0)), preload_camera_ids: av.array(av.int().min(1)), @@ -117,7 +107,6 @@ export const kioskBundle = av.object( labels: av.array(av.string()), operate_labels: av.array(av.string()), cameras: av.array(camera), - templates: av.array(layoutTemplate), layouts: av.array(layout), version: av.string().minLength(1).maxLength(64), }, diff --git a/server/src/shared/bundle.ts b/server/src/shared/bundle.ts index 516427b..9f1894a 100644 --- a/server/src/shared/bundle.ts +++ b/server/src/shared/bundle.ts @@ -43,13 +43,9 @@ export interface BundleCell { export interface BundleLayout { id: number; name: string; - template: { - id: number; - name: string; - regions: unknown; - grid_cols: number; - grid_rows: number; - } | null; + regions: unknown; + grid_cols: number; + grid_rows: number; priority: string; cooling_timeout_seconds: number | null; preload_camera_ids: number[]; @@ -84,9 +80,15 @@ export function generateBundle( clusterKey: string | undefined, ): KioskBundle | null { const kiosk = repo.getKioskById(kioskId); - if (!kiosk || !kiosk.display_id) return null; + if (!kiosk) return null; - const display = repo.getDisplayById(kiosk.display_id); + // Find display for this kiosk (displays now point to kiosks via kiosk_id) + const kioskDisplays = repo.listDisplaysForKiosk(kioskId); + // Fall back to legacy kiosk.display_id if no displays point to this kiosk yet + let display = kioskDisplays[0] ?? null; + if (!display && kiosk.display_id) { + display = repo.getDisplayById(kiosk.display_id); + } if (!display) return null; const layouts = repo.layoutsForDisplayId(display.id); @@ -97,17 +99,12 @@ export function generateBundle( const bundleLayouts: BundleLayout[] = layouts.map((l) => { const cells = repo.layoutCells(l.id); - const template = l.template_id ? repo.getLayoutTemplateById(l.template_id) : null; return { id: l.id, name: l.name, - template: template ? { - id: template.id, - name: template.name, - regions: template.regions, - grid_cols: template.grid_cols, - grid_rows: template.grid_rows, - } : null, + regions: l.regions, + grid_cols: l.grid_cols, + grid_rows: l.grid_rows, priority: l.priority, cooling_timeout_seconds: l.cooling_timeout_seconds, preload_camera_ids: l.preload_camera_ids, diff --git a/server/src/shared/pairing.ts b/server/src/shared/pairing.ts index 529b03e..3aaef69 100644 --- a/server/src/shared/pairing.ts +++ b/server/src/shared/pairing.ts @@ -123,17 +123,17 @@ export async function confirmPairing( const kioskKeyHash = await auth.hashPassword(kioskKeyPlaintext); const kioskKeyPrefix = kioskKeyPlaintext.slice(0, 8); - // Auto-assign to primary display - const displays = repo.listDisplays(); - const primaryDisplay = displays.find((d) => d.is_primary) ?? displays[0]; - const kiosk = repo.createKiosk({ name: kioskName, key_hash: kioskKeyHash, key_prefix: kioskKeyPrefix, capabilities: pc.kiosk_capabilities, hardware_model: pc.kiosk_hardware_model, - display_id: primaryDisplay?.id ?? null, + }); + + // Create a default display for this kiosk (HDMI-0) + repo.createDisplayForKiosk(kiosk.id, { + name: `${kioskName} HDMI-0`, }); // Attach initial labels diff --git a/server/src/shared/types.ts b/server/src/shared/types.ts index b9c257c..09b6b7f 100644 --- a/server/src/shared/types.ts +++ b/server/src/shared/types.ts @@ -72,7 +72,8 @@ export interface Display { id: number; name: string; index: number; // unique - is_primary: boolean; + is_primary: boolean; // deprecated — kept for backward compat, not used + kiosk_id: number | null; // FK → kiosks; displays belong to kiosks width_px: number; height_px: number; default_layout_id: number | null; @@ -139,7 +140,10 @@ export interface Layout { id: number; name: string; description: string | null; - template_id: number; + template_id: number | null; // deprecated — kept nullable for backward compat + regions: LayoutRegion[]; + grid_cols: number; + grid_rows: number; display_id: number; priority: LayoutPriority; cooling_timeout_seconds: number | null; @@ -175,7 +179,7 @@ export interface Kiosk { paired_at: string | null; last_seen_at: string | null; last_bundle_version: string | null; - display_id: number | null; + display_id: number | null; // deprecated — displays now point to kiosks via kiosk_id created_at: string; } diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 6b10efb..51e38ae 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -11,7 +11,6 @@ import type { Layout as LayoutType, LayoutCell, LayoutRegion, - LayoutTemplate, PairingCode, EventLog, } from "../shared/types.js"; @@ -712,6 +711,7 @@ interface KioskEditProps { kiosk: Kiosk; labels: Array<{ label_id: number; name: string; role: string }>; allLabels: Label[]; + displays?: Display[]; error?: string; success?: string; } @@ -753,6 +753,29 @@ export function KioskEditPage(props: KioskEditProps) { + {/* Associated displays */} +
+

Displays

+ {props.displays && props.displays.length > 0 ? ( +
+ + + + {props.displays.map((d) => ( + + + + + + ))} + +
NameResolutionIndex
{d.name}{String(d.width_px)}x{String(d.height_px)}{String(d.index)}
+
+ ) : ( +

No displays associated with this kiosk

+ )} +
+

Labels

{props.labels.length > 0 ? ( @@ -851,252 +874,11 @@ export function LabelsPage(props: LabelsPageProps) { ); } -// ---- Templates (Layout Templates) ------------------------------------------- - -interface TemplatesPageProps { - user: string; - templates: LayoutTemplate[]; -} - -export function TemplatesPage(props: TemplatesPageProps) { - return ( - -
-

All Templates

- New Template -
-

- Templates define named regions on a grid. Layouts bind content into these regions. -

-
- - - - - - - - - - - {props.templates.length === 0 ? ( - - ) : ( - props.templates.map((t) => ( - - - - - - - )) - )} - -
NameGridRegionsType
No templates created yet
{t.name}{String(t.grid_cols)}x{String(t.grid_rows)}{String(t.regions.length)} region{t.regions.length !== 1 ? "s" : ""} - {t.is_builtin - ? Built-in - : Custom - } -
-
-
- ); -} - -// ---- Template New ----------------------------------------------------------- - -interface TemplateNewPageProps { - user: string; - error?: string; - values?: Record; -} - -export function TemplateNewPage(props: TemplateNewPageProps) { - const v = props.values ?? {}; - return ( - -
-
-

Choose a Preset

-

- Pick a preset to get started quickly, or choose Custom to define your own regions. -

-
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
-
- -
-

Custom Template

-
- -
- - -
-
- - -
-
- - -
-
- - -
- Array of regions: name, row, col, rowSpan, colSpan. Grid is zero-indexed. -
-
- - Cancel -
-
-
-
- ); -} - -// ---- Template Edit ---------------------------------------------------------- - -interface TemplateEditPageProps { - user: string; - template: LayoutTemplate; - error?: string; - success?: string; -} - -export function TemplateEditPage(props: TemplateEditPageProps) { - const t = props.template; - return ( - -
-
-

Edit Template

-
-
- - -
-
- - -
- - Back -
-
- -
-

Grid: {String(t.grid_cols)}x{String(t.grid_rows)}

-
- - - - - - - - - - {t.regions.length === 0 ? ( - - ) : ( - t.regions.map((r) => ( - - - - - - )) - )} - -
RegionPositionSize
No regions defined
{r.name}row {String(r.row)}, col {String(r.col)}{String(r.rowSpan)}x{String(r.colSpan)}
-
-
- - {/* Visual grid preview */} - {t.regions.length > 0 && ( -
-

Preview

-
- {t.regions.map((r) => ( -
- {r.name} -
- ))} -
-
- )} - - {!t.is_builtin && ( -
- -
- )} -
-
- ); -} - // ---- Layouts ---------------------------------------------------------------- interface LayoutsPageProps { user: string; layouts: LayoutType[]; - templates: Map; displays: Map; } @@ -1108,14 +890,14 @@ export function LayoutsPage(props: LayoutsPageProps) { New Layout

- A layout binds cameras and other content into a template's regions for one display. + A layout defines a grid of regions and binds cameras or other content into them for a display.

- + @@ -1126,12 +908,11 @@ export function LayoutsPage(props: LayoutsPageProps) { ) : ( props.layouts.map((l) => { - const tmpl = props.templates.get(l.template_id); const disp = props.displays.get(l.display_id); return ( - + - {t.regions.map((r) => { + {l.regions.map((r) => { const cell = cellByRegion.get(r.name); return ( @@ -1422,7 +1275,7 @@ export function LayoutEditPage(props: LayoutEditPageProps) {
NameTemplateGrid Display Priority Default
No layouts created yet
{l.name}{tmpl ? tmpl.name : `#${String(l.template_id)}`}{String(l.grid_cols)}x{String(l.grid_rows)} ({String(l.regions.length)} regions) {disp ? disp.name : `#${String(l.display_id)}`} {l.priority === "hot" @@ -1159,7 +940,6 @@ export function LayoutsPage(props: LayoutsPageProps) { interface LayoutNewPageProps { user: string; - templates: LayoutTemplate[]; displays: Display[]; error?: string; values?: Record; @@ -1174,73 +954,119 @@ export function LayoutNewPage(props: LayoutNewPageProps) { activeNav="layouts" flash={props.error ? { type: "error", message: props.error } : undefined} > -
-
-
- - +
+ {/* Quick presets */} +
+

Quick Create from Preset

+

+ Pick a preset grid layout. You can also define a custom grid below. +

+
+ {[ + { preset: "fullscreen", label: "Fullscreen", desc: "1x1 grid, single region" }, + { preset: "2x2", label: "2x2 Grid", desc: "4 equal regions" }, + { preset: "1plus3", label: "1+3", desc: "Large left, 3 stacked right" }, + { preset: "3x3", label: "3x3 Grid", desc: "9 equal regions" }, + ].map((p) => ( + + + + + + + + + ))}
+
-
- - - {props.templates.length === 0 && ( -
- No templates exist. Create one first. + {/* Full form */} +
+

Custom Layout

+
+ +
+ + +
+ +
+ + + {props.displays.length === 0 && ( +
+ No displays exist yet. Pair a kiosk first to create a display. +
+ )} +
+ +
+
+ +
- )} -
+
+ + +
+
-
- - -
+
+ + +
+ Array of regions: name, row, col, rowSpan, colSpan. Grid is zero-indexed. +
+
-
- - -
+
+ + +
-
- - -
+
+ + +
-
- -
+
+ +
-
- -
+
+ +
- - Cancel - + + Cancel + +
); @@ -1251,7 +1077,6 @@ export function LayoutNewPage(props: LayoutNewPageProps) { interface LayoutEditPageProps { user: string; layout: LayoutType; - template: LayoutTemplate; display: Display; cells: LayoutCell[]; cameras: Camera[]; @@ -1261,7 +1086,6 @@ interface LayoutEditPageProps { export function LayoutEditPage(props: LayoutEditPageProps) { const l = props.layout; - const t = props.template; // Build a map from region_name → cell for easy lookup const cellByRegion = new Map(); for (const c of props.cells) { @@ -1326,17 +1150,17 @@ export function LayoutEditPage(props: LayoutEditPageProps) { Back
-
Template: {t.name} ({String(t.grid_cols)}x{String(t.grid_rows)})
+
Grid: {String(l.grid_cols)}x{String(l.grid_rows)}, {String(l.regions.length)} region{l.regions.length !== 1 ? "s" : ""}
- {/* Template preview with cell assignments */} - {t.regions.length > 0 && ( + {/* Grid preview with cell assignments */} + {l.regions.length > 0 && (

Grid Preview

-
- {t.regions.map((r) => { +
+ {l.regions.map((r) => { const cell = cellByRegion.get(r.name); let label = r.name; let bgColor = "#f9fafb"; @@ -1363,6 +1187,35 @@ export function LayoutEditPage(props: LayoutEditPageProps) {
)} + {/* Regions table */} +
+

Regions

+
+ + + + + + + + + + {l.regions.length === 0 ? ( + + ) : ( + l.regions.map((r) => ( + + + + + + )) + )} + +
RegionPositionSize
No regions defined
{r.name}row {String(r.row)}, col {String(r.col)}{String(r.rowSpan)}x{String(r.colSpan)}
+
+
+ {/* Cell assignments table */}

Cell Assignments

@@ -1376,7 +1229,7 @@ export function LayoutEditPage(props: LayoutEditPageProps) {
diff --git a/server/src/web-templates/layout.tsx b/server/src/web-templates/layout.tsx index dd86e47..cf625f7 100644 --- a/server/src/web-templates/layout.tsx +++ b/server/src/web-templates/layout.tsx @@ -44,7 +44,6 @@ function Sidebar(props: { activeNav?: string }) { -