/** * Bundle generation — display-chain routing. * * kiosk.display_id → layouts for display → cells → cameras * No label filtering for v0.1. */ import { createHash } from "node:crypto"; import type { Observable } from "@bsb/base"; import type { Repository } from "./db/repository.js"; import type { SecretsApi } from "./secrets.js"; import type { Camera, CameraStream } from "./types.js"; /** * Build a playable RTSP URL from stream component columns + camera credentials. * If the stream has rtsp_host/rtsp_path set (ONVIF-discovered), constructs the * URL from components with properly URL-encoded username and password from the * camera row. Otherwise falls back to the stream's rtsp_uri as-is (backward * compat for RTSP-type cameras and legacy data). */ function buildStreamRtspUri(stream: CameraStream, cam: Camera): string { // Only build from components if both host and path are present if (stream.rtsp_host && stream.rtsp_path != null) { const host = stream.rtsp_host; const port = stream.rtsp_port ?? 554; const path = stream.rtsp_path.startsWith("/") ? stream.rtsp_path : `/${stream.rtsp_path}`; // Inject credentials from the camera row let userinfo = ""; if (cam.onvif_username) { const user = encodeURIComponent(cam.onvif_username); const pass = cam.onvif_password ? encodeURIComponent(cam.onvif_password) : ""; userinfo = pass ? `${user}:${pass}@` : `${user}@`; } const portSuffix = port === 554 ? "" : `:${String(port)}`; return `rtsp://${userinfo}${host}${portSuffix}${path}`; } // Backward compat: use the stored rtsp_uri as-is return stream.rtsp_uri; } export interface BundleCamera { id: number; name: string; type: string; rtsp_url: string | null; onvif_host: string | null; onvif_port: number | null; onvif_username: string | null; onvif_password_encrypted: string | null; event_source: string; event_sink: string; stream_policy: string; streams: Array<{ id: number; role: string; name: string; /** Final playable RTSP URL with properly encoded credentials. */ rtsp_uri: string; width: number | null; height: number | null; encoding: string | null; framerate: number | null; }>; } export interface BundleCell { row: number; col: number; row_span: number; col_span: number; content_type: string; camera_id: number | null; stream_selector: string | null; web_url: string | null; html_content: string | null; cooling_timeout_seconds: number | null; fit: "cover" | "contain" | "fill"; /** Smart URL action steps — automated login/navigation sequence. */ smart_url?: { steps: Array<{ type: string; url?: string; selector?: string; value?: string; value_encrypted?: string; delay_ms?: number; timeout_ms?: number; script?: string; }>; login_detect_url?: string; session_check_interval_ms?: number; }; } export interface BundleLayout { id: number; name: string; /** Computed from cells: max(col + col_span). 1 if no cells. */ grid_cols: number; /** Computed from cells: max(row + row_span). 1 if no cells. */ grid_rows: number; priority: string; cooling_timeout_seconds: number | null; preload_camera_ids: number[]; resets_idle_timer: boolean; /** True if the kiosk's display has this layout as its default_layout_id. */ is_default: boolean; cells: BundleCell[]; } export interface BundleDisplay { id: number; name: string; width_px: number; height_px: number; idle_timeout_seconds: number; sleep_timeout_seconds: number; default_layout_id: number | null; } export interface BundleDisplayWithLayouts extends BundleDisplay { layouts: BundleLayout[]; } export interface BundleGpioBinding { id: number; chip: string; pin: number; direction: "in" | "out"; pull: "up" | "down" | "none" | null; edge: "rising" | "falling" | "both" | null; topic: string; } export interface KioskBundle { kiosk_id: number; kiosk_name: string; /** * @deprecated Use `displays` (array). Kept for backward compat with older * kiosk builds that consume a single display. Mirrors `displays[0]`. */ display: BundleDisplay; /** * @deprecated Use `displays[N].layouts`. Mirrors `displays[0].layouts` for * older kiosk builds. */ layouts: BundleLayout[]; /** All physical displays driven by this kiosk. New (multi-display) shape. */ displays: BundleDisplayWithLayouts[]; cameras: BundleCamera[]; gpio_bindings: BundleGpioBinding[]; version: string; } export async function generateBundle( repo: Repository, secrets: SecretsApi, kioskId: number, clusterKey: string | undefined, obs?: Observable, ): Promise { const span = obs?.startSpan("generateBundle", { "kiosk.id": kioskId }); const kiosk = await repo.getKioskById(kioskId); if (!kiosk) { span?.log.info("bundle: kiosk {id} not found", { id: String(kioskId) }); span?.end(); return null; } // Per-kiosk encryption key (preferred) — decrypt from server storage. let kioskEncryptKey: string | undefined; if (kiosk.encrypt_key_encrypted) { try { kioskEncryptKey = secrets.decryptString(kiosk.encrypt_key_encrypted, "kiosk-encrypt"); } catch { // Decrypt failed — fall back to cluster key. } } // Find all displays for this kiosk (displays now point to kiosks via kiosk_id) const kioskDisplays = await repo.listDisplaysForKiosk(kioskId); // Fall back to legacy kiosk.display_id if no displays point to this kiosk yet const allDisplays = kioskDisplays.length > 0 ? kioskDisplays : (kiosk.display_id ? [await repo.getDisplayById(kiosk.display_id)].filter((d): d is NonNullable => d != null) : []); // Admin can disable a display — kiosk must never open a window on it. const displays = allDisplays.filter((d) => d.is_enabled); if (displays.length === 0) { span?.log.info("bundle: kiosk {id} has no enabled displays", { id: String(kioskId) }); span?.end(); return null; } // Collect camera IDs across ALL displays' layouts (de-duped). const allLayoutIds = new Set(); for (const d of displays) { for (const l of await repo.layoutsForDisplayId(d.id)) allLayoutIds.add(l.id); } const cameras = await repo.camerasForLayoutIds([...allLayoutIds]); async function buildLayouts(displayId: number, defaultLayoutId: number | null): Promise { const layouts = await repo.layoutsForDisplayId(displayId); const result: BundleLayout[] = []; for (const l of layouts) { const cells = await repo.layoutCells(l.id); let gridCols = 1; let gridRows = 1; for (const c of cells) { const right = c.col + c.col_span; const bottom = c.row + c.row_span; if (right > gridCols) gridCols = right; if (bottom > gridRows) gridRows = bottom; } const bundleCells: BundleCell[] = []; for (const c of cells) { // If the cell has an entity, prefer its current content so admin // edits to the entity propagate without forcing a cell-touch. The // bundle still ships the legacy camera_id/web_url/html_content shape // so the existing Rust kiosk consumes it unchanged. let contentType = c.content_type; let cameraId = c.camera_id; let webUrl = c.web_url; let htmlContent = c.html_content; if (c.entity_id != null) { const ent = await repo.getEntityById(c.entity_id); if (ent) { // Dashboard entities are surfaced to the kiosk as `web` cells // pointing at /dash/ — kiosk WebKit handles them // identically to user-supplied web cells. contentType = ent.type === "dashboard" ? "web" : ent.type; cameraId = ent.type === "camera" ? ent.camera_id : null; webUrl = ent.type === "web" ? ent.web_url : ent.type === "dashboard" && ent.dashboard_id ? `/dash/${ent.dashboard_id}` : null; htmlContent = ent.type === "html" ? ent.html_content : null; } } bundleCells.push({ row: c.row, col: c.col, row_span: c.row_span, col_span: c.col_span, content_type: contentType, camera_id: cameraId, stream_selector: c.stream_selector, web_url: webUrl, html_content: htmlContent, cooling_timeout_seconds: c.cooling_timeout_seconds, fit: c.fit, // Smart URL: encrypted credentials use per-kiosk key so each // kiosk's bundle has uniquely encrypted values. smart_url: c.options?.["smart_url"] ? (() => { const raw = c.options["smart_url"] as any; const steps = Array.isArray(raw.steps) ? raw.steps.map((s: any) => { const step = { ...s }; // Encrypt plaintext values with per-kiosk key for transport. const ek = kioskEncryptKey ?? clusterKey; if (step.value && step.type === "fill" && ek) { step.value_encrypted = secrets.encryptForCluster(step.value, ek); delete step.value; } return step; }) : []; return { steps, login_detect_url: raw.login_detect_url, session_check_interval_ms: raw.session_check_interval_ms, }; })() : undefined, }); } result.push({ id: l.id, name: l.name, grid_cols: gridCols, grid_rows: gridRows, priority: l.priority, cooling_timeout_seconds: l.cooling_timeout_seconds, preload_camera_ids: l.preload_camera_ids, resets_idle_timer: l.resets_idle_timer, is_default: defaultLayoutId === l.id, cells: bundleCells, }); } return result; } const bundleDisplays: BundleDisplayWithLayouts[] = []; for (const display of displays) { bundleDisplays.push({ id: display.id, name: display.name, width_px: display.width_px, height_px: display.height_px, idle_timeout_seconds: display.idle_timeout_seconds, sleep_timeout_seconds: display.sleep_timeout_seconds, default_layout_id: display.default_layout_id, layouts: await buildLayouts(display.id, display.default_layout_id), }); } const bundleCameras: BundleCamera[] = []; for (const cam of cameras) { const streams = await repo.listCameraStreams(cam.id); const effectiveStreams = streams.length > 0 ? streams : ( cam.type === "rtsp" && cam.rtsp_url ? [{ id: 0, role: "main" as const, name: "Main", rtsp_uri: cam.rtsp_url, width: null, height: null, encoding: null, framerate: null, }] : [] ); // Encrypt camera password with per-kiosk key if available (stronger // isolation — compromised SD only exposes this kiosk's cameras). Falls // back to shared cluster_key for kiosks that paired before per-kiosk // keys were introduced. let onvifPwEncrypted: string | null = null; const encryptKey = kioskEncryptKey ?? clusterKey; if (cam.onvif_password && encryptKey) { onvifPwEncrypted = secrets.encryptForCluster(cam.onvif_password, encryptKey); } bundleCameras.push({ id: cam.id, name: cam.name, type: cam.type, rtsp_url: cam.rtsp_url, onvif_host: cam.onvif_host, onvif_port: cam.onvif_port, onvif_username: cam.onvif_username, onvif_password_encrypted: onvifPwEncrypted, event_source: cam.event_source, event_sink: cam.event_sink, stream_policy: cam.stream_policy, streams: effectiveStreams.map((s) => ({ id: s.id, role: s.role, name: s.name, // Build final playable URL from components + camera credentials // when available; falls back to stored rtsp_uri for backward compat. rtsp_uri: "rtsp_host" in s ? buildStreamRtspUri(s as CameraStream, cam) : s.rtsp_uri, width: s.width, height: s.height, encoding: s.encoding, framerate: s.framerate, })), }); } const gpioBindings: BundleGpioBinding[] = (await repo.listGpioBindings(kioskId)).map((g) => ({ id: g.id, chip: g.chip, pin: g.pin, direction: g.direction, pull: g.pull, edge: g.edge, topic: g.topic, })); // Mirror first display into the legacy top-level `display` + `layouts` so // older kiosk builds keep working unchanged. New builds should read // `displays`. const primary = bundleDisplays[0]!; const bundle: KioskBundle = { kiosk_id: kioskId, kiosk_name: kiosk.name, display: { id: primary.id, name: primary.name, width_px: primary.width_px, height_px: primary.height_px, idle_timeout_seconds: primary.idle_timeout_seconds, sleep_timeout_seconds: primary.sleep_timeout_seconds, default_layout_id: primary.default_layout_id, }, layouts: primary.layouts, displays: bundleDisplays, cameras: bundleCameras, gpio_bindings: gpioBindings, version: "", }; bundle.version = createHash("sha256") .update(JSON.stringify(bundle)) .digest("hex"); span?.log.info("bundle generated for kiosk {id} version {ver}", { id: String(kioskId), ver: bundle.version.slice(0, 12), }); span?.end(); return bundle; }