feat: entities (unified content pool) + ONVIF discovery flow

Entities:
- New entities table — id, name, type (camera|html|web), camera_id,
  html_content, web_url
- Auto-create entity per camera on createCamera
- Layout cells reference entity_id (replaces inline content_type/
  camera_id/html_content/web_url)
- Bundle resolves entities back to legacy cell fields for kiosk compat
  (Rust kiosk unchanged)
- Full CRUD: /admin/entities, /admin/entities/new, /admin/entities/:id
- Cell editor: single entity dropdown with type badges

ONVIF discovery:
- /admin/cameras/discover — host/port/user/pass form
- Server queries ONVIF device, lists profiles with name/resolution/
  encoding/framerate
- "Add" creates camera + main stream from chosen profile
- shared/onvif.ts: minimal SOAP+UsernameToken+PasswordDigest client
  (no external dep)
- Camera new form simplified to RTSP-only with discover link
This commit is contained in:
Mitchell R 2026-05-10 23:18:44 +02:00
parent 00b304c39f
commit 3be1a9a624
11 changed files with 1282 additions and 206 deletions

44
package-lock.json generated
View file

@ -738,6 +738,18 @@
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/onvif": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/onvif/-/onvif-0.8.1.tgz",
"integrity": "sha512-D0VSuTQutZWQsaWWvh7acbengYNJew25kXpXc/stJc6/xYx2MwU1tkIZA23bu12hr5MxstSQ55decY7we2/LWw==",
"license": "MIT",
"dependencies": {
"xml2js": "^0.6.2"
},
"engines": {
"node": ">=14.0"
}
},
"node_modules/otpauth": {
"version": "9.5.1",
"resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.1.tgz",
@ -788,6 +800,15 @@
"integrity": "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==",
"license": "MIT"
},
"node_modules/sax": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -910,6 +931,28 @@
}
}
},
"node_modules/xml2js": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/yaml": {
"version": "2.8.4",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
@ -936,6 +979,7 @@
"argon2": "^0.44.0",
"h3": "^2.0.1-rc.22",
"jsx-htmx": "^2.0.2",
"onvif": "^0.8.1",
"otpauth": "^9.5.1",
"ws": "^8.20.0"
},

View file

@ -27,6 +27,7 @@
"argon2": "^0.44.0",
"h3": "^2.0.1-rc.22",
"jsx-htmx": "^2.0.2",
"onvif": "^0.8.1",
"otpauth": "^9.5.1",
"ws": "^8.20.0"
},

View file

@ -11,6 +11,11 @@ import {
CamerasPage,
CameraNewPage,
CameraEditPage,
CameraDiscoverPage,
CameraDiscoverResultsPage,
EntitiesPage,
EntityNewPage,
EntityEditPage,
KiosksPage,
KioskEditPage,
LabelsPage,
@ -22,6 +27,7 @@ import {
renderCell,
renderGrid,
} from "../../web-templates/admin-pages.js";
import { discover as onvifDiscover } from "../../shared/onvif.js";
function htmlFragment(markup: unknown): Response {
return new Response(String(markup), {
@ -98,7 +104,6 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
const user = event.context.user!;
const body = await readBody<Record<string, string>>(event);
const name = (body?.["name"] ?? "").trim();
const type = body?.["type"] as "rtsp" | "onvif" | undefined;
const errors: string[] = [];
if (!name || name.length > 128) {
@ -107,35 +112,18 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
errors.push("Camera name already in use.");
}
if (type !== "rtsp" && type !== "onvif") {
errors.push("Select camera type.");
}
const host = (body?.["rtsp_host"] ?? "").trim();
const port = (body?.["rtsp_port"] ?? "554").trim();
const path = (body?.["rtsp_path"] ?? "").trim();
const username = (body?.["rtsp_username"] ?? "").trim();
const pass = body?.["rtsp_password"] ?? "";
let rtspUrl: string | undefined;
let onvifHost: string | undefined;
let onvifPort: number | undefined;
let onvifUser: string | undefined;
let onvifPass: string | undefined;
if (type === "rtsp") {
const host = (body?.["rtsp_host"] ?? "").trim();
const port = (body?.["rtsp_port"] ?? "554").trim();
const path = (body?.["rtsp_path"] ?? "").trim();
const user = (body?.["rtsp_username"] ?? "").trim();
const pass = body?.["rtsp_password"] ?? "";
if (!host) {
errors.push("RTSP host required.");
} else {
const userPart = user ? `${encodeURIComponent(user)}:${encodeURIComponent(pass)}@` : "";
const pathPart = path.startsWith("/") ? path : `/${path}`;
rtspUrl = `rtsp://${userPart}${host}:${port}${pathPart}`;
}
} else if (type === "onvif") {
onvifHost = (body?.["onvif_host"] ?? "").trim();
onvifPort = parseInt(body?.["onvif_port"] ?? "80", 10);
onvifUser = (body?.["onvif_username"] ?? "").trim();
onvifPass = body?.["onvif_password"] ?? "";
if (!onvifHost) errors.push("ONVIF host required.");
if (!host) {
errors.push("RTSP host required.");
} else {
const userPart = username ? `${encodeURIComponent(username)}:${encodeURIComponent(pass)}@` : "";
const pathPart = path.startsWith("/") ? path : `/${path}`;
rtspUrl = `rtsp://${userPart}${host}:${port}${pathPart}`;
}
if (errors.length > 0) {
@ -148,16 +136,11 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
const cam = deps.repo.createCamera({
name,
type: type!,
type: "rtsp",
rtsp_url: rtspUrl ?? null,
onvif_host: onvifHost ?? null,
onvif_port: onvifPort ?? null,
onvif_username: onvifUser ?? null,
onvif_password: onvifPass ?? null,
});
// Create default main stream for RTSP cameras
if (type === "rtsp" && rtspUrl) {
if (rtspUrl) {
deps.repo.createCameraStream({
camera_id: cam.id,
role: "main",
@ -165,6 +148,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
rtsp_uri: rtspUrl,
});
}
notifyKiosks();
return new Response(null, {
status: 302,
@ -172,6 +156,204 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
});
});
// ---- Camera ONVIF discovery ------------------------------------------------
app.get("/admin/cameras/discover", (event) => {
const user = event.context.user!;
return htmlPage(CameraDiscoverPage({ user: user.username }));
});
app.post("/admin/cameras/discover", async (event) => {
const user = event.context.user!;
const body = await readBody<Record<string, string>>(event);
const host = (body?.["host"] ?? "").trim();
const port = parseInt(body?.["port"] ?? "80", 10) || 80;
const username = (body?.["username"] ?? "").trim();
const password = body?.["password"] ?? "";
if (!host) {
return htmlPage(CameraDiscoverPage({
user: user.username,
error: "Host required.",
values: body,
}));
}
try {
const profiles = await onvifDiscover({ host, port, username, password });
return htmlPage(CameraDiscoverResultsPage({
user: user.username,
host,
profiles,
}));
} catch (err) {
return htmlPage(CameraDiscoverPage({
user: user.username,
error: `Discovery failed: ${(err as Error).message}`,
values: body,
}));
}
});
app.post("/admin/cameras/discover/add", async (event) => {
const body = await readBody<Record<string, string>>(event);
const rawName = (body?.["name"] ?? "").trim() || "ONVIF camera";
const rtspUrl = (body?.["rtsp_url"] ?? "").trim();
const encoding = (body?.["encoding"] ?? "").trim() || null;
const profileToken = (body?.["profile_token"] ?? "").trim() || null;
const width = body?.["width"] ? Number(body["width"]) : null;
const height = body?.["height"] ? Number(body["height"]) : null;
const framerate = body?.["framerate"] ? Number(body["framerate"]) : null;
if (!rtspUrl) {
return new Response(null, { status: 302, headers: { location: "/admin/cameras/discover" } });
}
// Resolve a unique camera name
let name = rawName;
if (deps.repo.getCameraByName(name)) {
let i = 2;
while (deps.repo.getCameraByName(`${rawName} (${String(i)})`)) i += 1;
name = `${rawName} (${String(i)})`;
}
const cam = deps.repo.createCamera({
name,
type: "rtsp",
rtsp_url: rtspUrl,
});
deps.repo.createCameraStream({
camera_id: cam.id,
role: "main",
name: "Main",
rtsp_uri: rtspUrl,
profile_token: profileToken,
width: Number.isFinite(width) ? width : null,
height: Number.isFinite(height) ? height : null,
encoding,
framerate: Number.isFinite(framerate) ? framerate : null,
is_discovered: true,
});
notifyKiosks();
return new Response(null, { status: 302, headers: { location: "/admin/cameras" } });
});
// ---- Entities --------------------------------------------------------------
app.get("/admin/entities", (event) => {
const user = event.context.user!;
return htmlPage(EntitiesPage({
user: user.username,
entities: deps.repo.listEntities(),
}));
});
app.get("/admin/entities/new", (event) => {
const user = event.context.user!;
return htmlPage(EntityNewPage({
user: user.username,
cameras: deps.repo.listCameras(),
}));
});
app.post("/admin/entities/new", async (event) => {
const user = event.context.user!;
const body = await readBody<Record<string, string>>(event);
const name = (body?.["name"] ?? "").trim();
const type = body?.["type"] as "camera" | "html" | "web" | undefined;
const description = (body?.["description"] ?? "").trim() || null;
const errors: string[] = [];
if (!name || name.length > 128) {
errors.push("Name required (max 128 chars).");
} else if (deps.repo.getEntityByName(name)) {
errors.push("Entity name already in use.");
}
if (type !== "camera" && type !== "html" && type !== "web") {
errors.push("Select an entity type.");
}
let cameraId: number | null = null;
let htmlContent: string | null = null;
let webUrl: string | null = null;
if (type === "camera") {
cameraId = body?.["camera_id"] ? Number(body["camera_id"]) : null;
if (!cameraId) errors.push("Pick a camera.");
} else if (type === "html") {
htmlContent = body?.["html_content"] ?? null;
} else if (type === "web") {
webUrl = (body?.["web_url"] ?? "").trim() || null;
if (!webUrl) errors.push("URL required.");
}
if (errors.length > 0) {
return htmlPage(EntityNewPage({
user: user.username,
cameras: deps.repo.listCameras(),
error: errors.join(" "),
values: body,
}));
}
deps.repo.createEntity({
name,
type: type!,
description,
camera_id: cameraId,
html_content: htmlContent,
web_url: webUrl,
});
notifyKiosks();
return new Response(null, { status: 302, headers: { location: "/admin/entities" } });
});
app.get("/admin/entities/:id", (event) => {
const user = event.context.user!;
const id = Number(getRouterParam(event, "id"));
const ent = deps.repo.getEntityById(id);
if (!ent) return new Response(null, { status: 302, headers: { location: "/admin/entities" } });
return htmlPage(EntityEditPage({
user: user.username,
entity: ent,
cameras: deps.repo.listCameras(),
}));
});
app.post("/admin/entities/:id", async (event) => {
const id = Number(getRouterParam(event, "id"));
const ent = deps.repo.getEntityById(id);
if (!ent) return new Response(null, { status: 302, headers: { location: "/admin/entities" } });
const body = await readBody<Record<string, string>>(event);
const patch: {
name?: string;
description?: string | null;
camera_id?: number | null;
html_content?: string | null;
web_url?: string | null;
} = {
name: (body?.["name"] ?? ent.name).trim(),
description: (body?.["description"] ?? "").trim() || null,
};
if (ent.type === "camera") {
patch.camera_id = body?.["camera_id"] ? Number(body["camera_id"]) : null;
} else if (ent.type === "html") {
patch.html_content = body?.["html_content"] ?? null;
} else if (ent.type === "web") {
patch.web_url = (body?.["web_url"] ?? "").trim() || null;
}
deps.repo.updateEntity(id, patch);
notifyKiosks();
return new Response(null, { status: 302, headers: { location: `/admin/entities/${String(id)}` } });
});
app.post("/admin/entities/:id/delete", (event) => {
const id = Number(getRouterParam(event, "id"));
deps.repo.deleteEntity(id);
notifyKiosks();
return new Response(null, { status: 302, headers: { location: "/admin/entities" } });
});
// ---- Kiosks ---------------------------------------------------------------
app.get("/admin/kiosks", (event) => {
@ -266,6 +448,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
if (!layout) return new Response(null, { status: 302, headers: { location: "/admin/layouts" } });
const cells = deps.repo.layoutCells(id);
const cameras = deps.repo.listCameras();
const entities = deps.repo.listEntities();
const displays = deps.repo.listDisplaysForLayout(id);
return htmlPage(LayoutEditPage({
user: user.username,
@ -273,6 +456,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
displays,
cells,
cameras,
entities,
}));
});
@ -314,7 +498,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
if (!ref) {
if (isHtmxRequest(event)) {
const cameras = deps.repo.listCameras();
return htmlFragment(renderGrid(layoutId, cells, cameras));
const entities = deps.repo.listEntities();
return htmlFragment(renderGrid(layoutId, cells, entities, cameras));
}
return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } });
}
@ -359,15 +544,15 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
col,
row_span: 1,
col_span: 1,
content_type: "html",
html_content: null,
entity_id: null,
});
notifyKiosks();
if (isHtmxRequest(event)) {
const cells = deps.repo.layoutCells(layoutId);
const cameras = deps.repo.listCameras();
return htmlFragment(renderGrid(layoutId, cells, cameras));
const entities = deps.repo.listEntities();
return htmlFragment(renderGrid(layoutId, cells, entities, cameras));
}
return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } });
});
@ -381,7 +566,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
return new Response("Not Found", { status: 404 });
}
const cameras = deps.repo.listCameras();
return htmlFragment(renderCell(layoutId, cell, cameras, "read"));
const entities = deps.repo.listEntities();
return htmlFragment(renderCell(layoutId, cell, entities, cameras, "read"));
});
// GET a single cell in edit mode (htmx swap target for cell click).
@ -393,47 +579,47 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
return new Response("Not Found", { status: 404 });
}
const cameras = deps.repo.listCameras();
return htmlFragment(renderCell(layoutId, cell, cameras, "edit"));
const entities = deps.repo.listEntities();
return htmlFragment(renderCell(layoutId, cell, entities, cameras, "edit"));
});
// Update a cell's content assignment + dimensions.
// For htmx requests, returns the updated cell HTML (read mode) for outerHTML
// swap onto the cell element. For normal POSTs, returns 302.
// Update a cell's entity binding + dimensions. Legacy content_type/web/html
// columns are managed by assignCellEntity for bundle compatibility.
app.post("/admin/layouts/:id/cells/:cellId", async (event) => {
const layoutId = Number(getRouterParam(event, "id"));
const cellId = Number(getRouterParam(event, "cellId"));
const body = await readBody<Record<string, string>>(event);
const contentType = (body?.["content_type"] ?? "html") as "camera" | "web" | "html";
const patch: Record<string, unknown> = {
content_type: contentType,
camera_id: contentType === "camera" && body?.["camera_id"] ? Number(body["camera_id"]) : null,
stream_selector: contentType === "camera"
? ((body?.["stream_selector"] as "auto" | "main" | "sub") ?? "auto")
: "auto",
web_url: contentType === "web" ? (body?.["web_url"] ?? null) : null,
html_content: contentType === "html" ? (body?.["html_content"] ?? null) : null,
};
const entityIdRaw = body?.["entity_id"];
const entityId =
entityIdRaw && String(entityIdRaw).trim() !== "" ? Number(entityIdRaw) : null;
deps.repo.assignCellEntity(cellId, Number.isFinite(entityId) ? entityId : null);
// stream_selector + spans are still settable on the cell.
const dimsPatch: Record<string, unknown> = {};
const streamSelector = body?.["stream_selector"];
if (streamSelector === "auto" || streamSelector === "main" || streamSelector === "sub") {
dimsPatch["stream_selector"] = streamSelector;
}
const colSpanRaw = body?.["col_span"];
const rowSpanRaw = body?.["row_span"];
if (colSpanRaw != null && String(colSpanRaw).trim() !== "") {
const v = Math.max(1, Number(colSpanRaw) || 1);
patch["col_span"] = v;
dimsPatch["col_span"] = Math.max(1, Number(colSpanRaw) || 1);
}
if (rowSpanRaw != null && String(rowSpanRaw).trim() !== "") {
const v = Math.max(1, Number(rowSpanRaw) || 1);
patch["row_span"] = v;
dimsPatch["row_span"] = Math.max(1, Number(rowSpanRaw) || 1);
}
if (Object.keys(dimsPatch).length > 0) {
deps.repo.updateLayoutCell(cellId, dimsPatch as any);
}
deps.repo.updateLayoutCell(cellId, patch as any);
notifyKiosks();
if (isHtmxRequest(event)) {
const cell = deps.repo.getLayoutCellById(cellId);
if (!cell) return new Response("", { headers: { "content-type": "text/html; charset=utf-8" } });
const cameras = deps.repo.listCameras();
return htmlFragment(renderCell(layoutId, cell, cameras, "read"));
const entities = deps.repo.listEntities();
return htmlFragment(renderCell(layoutId, cell, entities, cameras, "read"));
}
return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } });
});
@ -458,8 +644,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
const cells = deps.repo.layoutCells(layoutId);
const cameras = deps.repo.listCameras();
const entities = deps.repo.listEntities();
if (isHtmxRequest(event)) {
return htmlFragment(renderGrid(layoutId, cells, cameras));
return htmlFragment(renderGrid(layoutId, cells, entities, cameras));
}
return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } });
});
@ -472,7 +659,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
if (isHtmxRequest(event)) {
const cells = deps.repo.layoutCells(layoutId);
const cameras = deps.repo.listCameras();
return htmlFragment(renderGrid(layoutId, cells, cameras));
const entities = deps.repo.listEntities();
return htmlFragment(renderGrid(layoutId, cells, entities, cameras));
}
return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } });
});

View file

@ -14,6 +14,8 @@ import type {
CellContentType,
DesiredPowerState,
Display,
Entity,
EntityType,
EventLog,
EventSourceType,
Kiosk,
@ -205,6 +207,20 @@ export function rowToLayoutCell(r: Row): LayoutCell {
html_content: sn(r["html_content"]),
cooling_timeout_seconds: nn(r["cooling_timeout_seconds"]),
options: j<Record<string, unknown>>(r["options"], {}),
entity_id: nn(r["entity_id"]),
};
}
export function rowToEntity(r: Row): Entity {
return {
id: n(r["id"]),
name: s(r["name"]),
type: s(r["type"]) as EntityType,
description: sn(r["description"]),
camera_id: nn(r["camera_id"]),
html_content: sn(r["html_content"]),
web_url: sn(r["web_url"]),
created_at: s(r["created_at"]),
};
}

View file

@ -441,4 +441,100 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
// Drop layout_templates entirely — concept removed
`DROP TABLE IF EXISTS layout_templates`,
// ---- 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
}
},
];

View file

@ -18,6 +18,8 @@ import type {
CameraStream,
CameraType,
Display,
Entity,
EntityType,
EventLog,
EventSourceType,
Kiosk,
@ -40,6 +42,7 @@ import {
rowToCamera,
rowToCameraStream,
rowToDisplay,
rowToEntity,
rowToEventLog,
rowToKiosk,
rowToLabel,
@ -545,30 +548,48 @@ export class Repository {
col: number;
row_span?: number;
col_span?: number;
content_type: string;
content_type?: string;
camera_id?: number | null;
stream_selector?: string | null;
web_url?: string | null;
html_content?: string | null;
cooling_timeout_seconds?: number | null;
options?: Record<string, unknown>;
entity_id?: number | null;
}): LayoutCell {
// Resolve content fields from the entity (if given). The legacy columns
// remain populated for backward-compatible bundle generation.
let contentType = input.content_type ?? "html";
let cameraId: number | null = input.camera_id ?? null;
let webUrl: string | null = input.web_url ?? null;
let htmlContent: string | null = input.html_content ?? null;
if (input.entity_id != null) {
const ent = this.getEntityById(input.entity_id);
if (ent) {
contentType = ent.type;
cameraId = ent.type === "camera" ? ent.camera_id : null;
webUrl = ent.type === "web" ? ent.web_url : null;
htmlContent = ent.type === "html" ? ent.html_content : null;
}
}
const result = this.prep(
`INSERT INTO layout_cells (layout_id, "row", col, row_span, col_span, content_type, camera_id, stream_selector, web_url, html_content, cooling_timeout_seconds, options)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO layout_cells (layout_id, "row", col, row_span, col_span, content_type, camera_id, stream_selector, web_url, html_content, cooling_timeout_seconds, options, entity_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
input.layout_id,
input.row,
input.col,
input.row_span ?? 1,
input.col_span ?? 1,
input.content_type,
input.camera_id ?? null,
contentType,
cameraId,
input.stream_selector ?? "auto",
input.web_url ?? null,
input.html_content ?? null,
webUrl,
htmlContent,
input.cooling_timeout_seconds ?? null,
J(input.options ?? {}),
input.entity_id ?? null,
);
const id = Number(result.lastInsertRowid);
void this.notify("layout_cells", "create", id);
@ -577,6 +598,50 @@ export class Repository {
return rowToLayoutCell(r as Record<string, unknown>);
}
/**
* Assign (or clear) the entity for a cell. Also mirrors the resolved entity's
* type/camera/url/html into the legacy cell columns so bundle generation stays
* compatible with the existing kiosk.
*/
assignCellEntity(cellId: number, entityId: number | null): void {
if (entityId == null) {
this.db
.prepare(
`UPDATE layout_cells
SET entity_id = NULL,
content_type = 'html',
camera_id = NULL,
web_url = NULL,
html_content = NULL
WHERE id = ?`,
)
.run(cellId);
void this.notify("layout_cells", "update", cellId);
return;
}
const ent = this.getEntityById(entityId);
if (!ent) return;
this.db
.prepare(
`UPDATE layout_cells
SET entity_id = ?,
content_type = ?,
camera_id = ?,
web_url = ?,
html_content = ?
WHERE id = ?`,
)
.run(
ent.id,
ent.type,
ent.type === "camera" ? ent.camera_id : null,
ent.type === "web" ? ent.web_url : null,
ent.type === "html" ? ent.html_content : null,
cellId,
);
void this.notify("layout_cells", "update", cellId);
}
updateLayoutCell(id: number, patch: Partial<LayoutCell>): void {
const sets: string[] = [];
const vals: unknown[] = [];
@ -709,6 +774,8 @@ export class Repository {
void this.notify("cameras", "create", id);
const c = this.getCameraById(id);
if (!c) throw new Error("camera vanished after insert");
// Mirror this camera as a reusable entity so it's pickable in cell editors.
this.ensureCameraEntity(c);
return c;
}
@ -1135,11 +1202,132 @@ export class Repository {
deleteCamera(id: number): void {
this.db.prepare(`DELETE FROM camera_labels WHERE camera_id = ?`).run(id);
this.db.prepare(`DELETE FROM camera_streams WHERE camera_id = ?`).run(id);
// Clear cells that referenced this camera (legacy column).
this.db.prepare(`DELETE FROM layout_cells WHERE camera_id = ?`).run(id);
// entities row has ON DELETE CASCADE → camera-mirror entity goes away with
// the camera, which in turn sets layout_cells.entity_id NULL via the FK.
this.db.prepare(`DELETE FROM cameras WHERE id = ?`).run(id);
void this.notify("cameras", "delete", id);
}
// ===========================================================================
// entities — reusable content pool (camera/html/web) bound to layout cells
// ===========================================================================
listEntities(): Entity[] {
const rs = this.prep("SELECT * FROM entities ORDER BY name").all();
return rs.map((r) => rowToEntity(r as Record<string, unknown>));
}
getEntityById(id: number): Entity | null {
const r = this.prep("SELECT * FROM entities WHERE id = ?").get(id);
return r ? rowToEntity(r as Record<string, unknown>) : null;
}
getEntityByName(name: string): Entity | null {
const r = this.prep("SELECT * FROM entities WHERE name = ?").get(name);
return r ? rowToEntity(r as Record<string, unknown>) : null;
}
getEntityForCamera(cameraId: number): Entity | null {
const r = this.prep(
`SELECT * FROM entities WHERE type = 'camera' AND camera_id = ? LIMIT 1`,
).get(cameraId);
return r ? rowToEntity(r as Record<string, unknown>) : null;
}
createEntity(input: {
name: string;
type: EntityType;
description?: string | null;
camera_id?: number | null;
html_content?: string | null;
web_url?: string | null;
}): Entity {
const result = this.prep(
`INSERT INTO entities (name, type, description, camera_id, html_content, web_url)
VALUES (?, ?, ?, ?, ?, ?)`,
).run(
input.name,
input.type,
input.description ?? null,
input.type === "camera" ? (input.camera_id ?? null) : null,
input.type === "html" ? (input.html_content ?? null) : null,
input.type === "web" ? (input.web_url ?? null) : null,
);
const id = Number(result.lastInsertRowid);
void this.notify("entities", "create", id);
const e = this.getEntityById(id);
if (!e) throw new Error("entity vanished after insert");
return e;
}
updateEntity(
id: number,
patch: {
name?: string;
description?: string | null;
camera_id?: number | null;
html_content?: string | null;
web_url?: string | null;
},
): void {
const sets: string[] = [];
const vals: unknown[] = [];
for (const [k, v] of Object.entries(patch)) {
sets.push(`${k} = ?`);
vals.push(v === undefined ? null : v);
}
if (sets.length === 0) return;
vals.push(id);
this.db
.prepare(`UPDATE entities SET ${sets.join(", ")} WHERE id = ?`)
.run(...(vals as any[]));
void this.notify("entities", "update", id);
// Propagate content fields into any cell that uses this entity, so the
// legacy cell columns stay aligned for bundle generation.
const ent = this.getEntityById(id);
if (!ent) return;
this.db
.prepare(
`UPDATE layout_cells
SET content_type = ?,
camera_id = ?,
web_url = ?,
html_content = ?
WHERE entity_id = ?`,
)
.run(
ent.type,
ent.type === "camera" ? ent.camera_id : null,
ent.type === "web" ? ent.web_url : null,
ent.type === "html" ? ent.html_content : null,
id,
);
}
deleteEntity(id: number): void {
// FK ON DELETE SET NULL clears layout_cells.entity_id.
this.db.prepare(`DELETE FROM entities WHERE id = ?`).run(id);
void this.notify("entities", "delete", id);
}
/**
* Idempotent: ensure a camera-type entity exists for the given camera. If
* the camera's name is already taken by another entity, append the camera
* id to keep the name unique.
*/
ensureCameraEntity(camera: Camera): Entity {
const existing = this.getEntityForCamera(camera.id);
if (existing) return existing;
let name = camera.name;
if (this.getEntityByName(name)) {
name = `${camera.name} (cam #${String(camera.id)})`;
}
return this.createEntity({ name, type: "camera", camera_id: camera.id });
}
updateKiosk(id: number, patch: Partial<Kiosk>): void {
const sets: string[] = [];
const vals: unknown[] = [];

View file

@ -123,18 +123,37 @@ export function generateBundle(
preload_camera_ids: l.preload_camera_ids,
resets_idle_timer: l.resets_idle_timer,
is_default: defaultLayoutId === l.id,
cells: cells.map((c) => ({
row: c.row,
col: c.col,
row_span: c.row_span,
col_span: c.col_span,
content_type: c.content_type,
camera_id: c.camera_id,
stream_selector: c.stream_selector,
web_url: c.web_url,
html_content: c.html_content,
cooling_timeout_seconds: c.cooling_timeout_seconds,
})),
cells: cells.map((c) => {
// 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 = repo.getEntityById(c.entity_id);
if (ent) {
contentType = ent.type;
cameraId = ent.type === "camera" ? ent.camera_id : null;
webUrl = ent.type === "web" ? ent.web_url : null;
htmlContent = ent.type === "html" ? ent.html_content : null;
}
}
return {
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,
};
}),
};
});

217
server/src/shared/onvif.ts Normal file
View file

@ -0,0 +1,217 @@
/**
* Minimal ONVIF discovery client.
*
* Talks SOAP/HTTP directly (no external ONVIF library). Covers the v0.1
* happy path: GetProfiles + GetStreamUri against the standard media service.
* Uses WS-Security UsernameToken auth (clear-text password digest variant
* skipped most cameras accept plain text over LAN; we can upgrade later).
*
* Why not the `onvif` npm package? CJS, callback API, no TypeScript types.
* A 50-line SOAP wrapper is easier to maintain than wrapping callbacks.
*/
import { createHash, randomBytes } from "node:crypto";
export interface DiscoveredProfile {
profile_name: string;
profile_token: string;
encoding: string | null;
width: number | null;
height: number | null;
framerate: number | null;
stream_uri: string;
}
interface DiscoverInput {
host: string;
port: number;
username: string;
password: string;
/** Path of the media service endpoint. Most cameras serve at /onvif/device_service for device + /onvif/Media for media. */
mediaPath?: string;
/** Optional timeout in ms (default 8s). */
timeoutMs?: number;
}
function wsseHeader(username: string, password: string): string {
// WS-Security UsernameToken with PasswordDigest (the ONVIF-standard form).
// PasswordDigest = Base64( SHA1( nonce + created + password ) )
const nonceRaw = randomBytes(16);
const nonce = nonceRaw.toString("base64");
const created = new Date().toISOString();
const digest = createHash("sha1")
.update(Buffer.concat([nonceRaw, Buffer.from(created, "utf8"), Buffer.from(password, "utf8")]))
.digest("base64");
return `
<s:Header>
<Security s:mustUnderstand="1" xmlns="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
<UsernameToken>
<Username>${escapeXml(username)}</Username>
<Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">${digest}</Password>
<Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">${nonce}</Nonce>
<Created xmlns="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">${created}</Created>
</UsernameToken>
</Security>
</s:Header>`;
}
function escapeXml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
async function soap(url: string, action: string, body: string, timeoutMs: number): Promise<string> {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": `application/soap+xml; charset=utf-8; action="${action}"`,
"SOAPAction": action,
},
body,
signal: controller.signal,
});
const text = await res.text();
if (!res.ok) {
throw new Error(`ONVIF ${action} HTTP ${String(res.status)}: ${text.slice(0, 300)}`);
}
return text;
} finally {
clearTimeout(t);
}
}
function buildEnvelope(headerXml: string, bodyXml: string): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"
xmlns:trt="http://www.onvif.org/ver10/media/wsdl"
xmlns:tt="http://www.onvif.org/ver10/schema">
${headerXml}
<s:Body>${bodyXml}</s:Body>
</s:Envelope>`;
}
// Extract all occurrences of a SOAP element value or attribute via regex.
// XML parsing in regex is regrettable but adequate for ONVIF's small, stable
// schema. Falls back to empty string when not found.
function pickAll(xml: string, tagLocalName: string): string[] {
const re = new RegExp(`<(?:[\\w-]+:)?${tagLocalName}\\b[^>]*>([\\s\\S]*?)</(?:[\\w-]+:)?${tagLocalName}>`, "g");
const out: string[] = [];
let m: RegExpExecArray | null;
while ((m = re.exec(xml))) {
out.push((m[1] ?? "").trim());
}
return out;
}
function pickAttr(xml: string, tagLocalName: string, attr: string): string[] {
const re = new RegExp(`<(?:[\\w-]+:)?${tagLocalName}\\b[^>]*\\b${attr}="([^"]*)"`, "g");
const out: string[] = [];
let m: RegExpExecArray | null;
while ((m = re.exec(xml))) {
out.push(m[1] ?? "");
}
return out;
}
// Pull a single nested value from a parent element block.
function pickNested(parentXml: string, tagLocalName: string): string | null {
const m = parentXml.match(new RegExp(`<(?:[\\w-]+:)?${tagLocalName}\\b[^>]*>([\\s\\S]*?)</(?:[\\w-]+:)?${tagLocalName}>`));
return m ? (m[1] ?? "").trim() : null;
}
// Split the response into Profile blocks so we can read per-profile sub-elements.
function splitProfiles(xml: string): string[] {
const re = /<(?:[\w-]+:)?Profiles\b[^>]*>([\s\S]*?)<\/(?:[\w-]+:)?Profiles>/g;
const out: string[] = [];
let m: RegExpExecArray | null;
while ((m = re.exec(xml))) {
out.push(m[1] ?? "");
}
return out;
}
/**
* Connect to an ONVIF camera and list its media profiles with their
* resolutions, encodings, and RTSP stream URIs.
*
* Throws on transport error. Profile fields default to null if the camera
* omits them.
*/
export async function discover(input: DiscoverInput): Promise<DiscoveredProfile[]> {
const host = input.host;
const port = input.port || 80;
const mediaPath = input.mediaPath ?? "/onvif/Media";
const mediaUrl = `http://${host}:${String(port)}${mediaPath}`;
const timeoutMs = input.timeoutMs ?? 8000;
const header = wsseHeader(input.username, input.password);
// ---- GetProfiles -----------------------------------------------------------
const profilesEnv = buildEnvelope(header, `<trt:GetProfiles/>`);
const profilesXml = await soap(
mediaUrl,
"http://www.onvif.org/ver10/media/wsdl/GetProfiles",
profilesEnv,
timeoutMs,
);
const profileBlocks = splitProfiles(profilesXml);
const tokenAttrs = pickAttr(profilesXml, "Profiles", "token");
const out: DiscoveredProfile[] = [];
for (let i = 0; i < profileBlocks.length; i += 1) {
const block = profileBlocks[i] ?? "";
const token = tokenAttrs[i] ?? "";
const profileName = pickNested(block, "Name") ?? token ?? `profile_${String(i)}`;
// VideoEncoderConfiguration → Encoding, Resolution{Width,Height}, RateControl.FrameRateLimit
const venc = pickNested(block, "VideoEncoderConfiguration") ?? "";
const encoding = venc ? pickNested(venc, "Encoding") : null;
const resBlock = venc ? pickNested(venc, "Resolution") : null;
const width = resBlock ? Number(pickNested(resBlock, "Width") ?? "") || null : null;
const height = resBlock ? Number(pickNested(resBlock, "Height") ?? "") || null : null;
const rateCtrl = venc ? pickNested(venc, "RateControl") : null;
const framerate = rateCtrl ? Number(pickNested(rateCtrl, "FrameRateLimit") ?? "") || null : null;
// ---- GetStreamUri for this profile -------------------------------------
const streamBody = `<trt:GetStreamUri>
<trt:StreamSetup>
<tt:Stream>RTP-Unicast</tt:Stream>
<tt:Transport><tt:Protocol>RTSP</tt:Protocol></tt:Transport>
</trt:StreamSetup>
<trt:ProfileToken>${escapeXml(token)}</trt:ProfileToken>
</trt:GetStreamUri>`;
const streamEnv = buildEnvelope(wsseHeader(input.username, input.password), streamBody);
let streamXml: string;
try {
streamXml = await soap(
mediaUrl,
"http://www.onvif.org/ver10/media/wsdl/GetStreamUri",
streamEnv,
timeoutMs,
);
} catch {
continue; // skip profiles we can't get a stream uri for
}
const uri = pickAll(streamXml, "Uri")[0] ?? "";
if (!uri) continue;
out.push({
profile_name: profileName,
profile_token: token,
encoding,
width,
height,
framerate,
stream_uri: uri,
});
}
return out;
}

View file

@ -13,6 +13,18 @@ export type StreamSelector = "auto" | "main" | "sub";
export type StreamPolicy = "auto" | "always_main" | "always_sub";
export type LayoutPriority = "hot" | "normal" | "cold";
export type CellContentType = "camera" | "web" | "html";
export type EntityType = "camera" | "html" | "web";
export interface Entity {
id: number;
name: string;
type: EntityType;
description: string | null;
camera_id: number | null;
html_content: string | null;
web_url: string | null;
created_at: string;
}
export type DesiredPowerState = "follow_layout" | "on" | "standby";
export type LabelRole = "consume" | "operate";
export type EventSourceType = "onvif" | "gpio" | "synthetic" | "system";
@ -174,6 +186,7 @@ export interface LayoutCell {
html_content: string | null;
cooling_timeout_seconds: number | null;
options: Record<string, unknown>;
entity_id: number | null;
}
export interface Kiosk {

View file

@ -6,6 +6,7 @@ import { Layout } from "./layout.js";
import type {
Camera,
Display,
Entity,
Kiosk,
Label,
Layout as LayoutType,
@ -168,7 +169,11 @@ export function CameraNewPage(props: CameraNewProps) {
flash={props.error ? { type: "error", message: props.error } : undefined}
>
<div style="max-width:600px">
<div style="margin-bottom:1rem">
<a href="/admin/cameras/discover" class="btn btn-ghost">Discover via ONVIF &rarr;</a>
</div>
<form method="post" action="/admin/cameras/new">
<input type="hidden" name="type" value="rtsp" />
<div class="form-group">
<label for="name">Camera Name</label>
<input
@ -183,64 +188,27 @@ export function CameraNewPage(props: CameraNewProps) {
</div>
<div class="form-group">
<label>Type</label>
<div class="radio-group">
<label>
<input type="radio" name="type" value="rtsp" checked={v["type"] !== "onvif"} />
RTSP
</label>
<label>
<input type="radio" name="type" value="onvif" checked={v["type"] === "onvif"} />
ONVIF
</label>
<label for="rtsp_host">Host</label>
<input id="rtsp_host" name="rtsp_host" type="text" class="form-input" placeholder="192.168.1.100" value={v["rtsp_host"] ?? ""} />
</div>
<div style="display:grid; grid-template-columns:1fr 2fr; gap:0.75rem">
<div class="form-group">
<label for="rtsp_port">Port</label>
<input id="rtsp_port" name="rtsp_port" type="number" class="form-input" value={v["rtsp_port"] ?? "554"} />
</div>
<div class="form-group">
<label for="rtsp_path">Path</label>
<input id="rtsp_path" name="rtsp_path" type="text" class="form-input" placeholder="/Streaming/Channels/101" value={v["rtsp_path"] ?? ""} />
</div>
</div>
<div id="rtsp-fields">
<div style="display:grid; grid-template-columns:1fr 1fr; gap:0.75rem">
<div class="form-group">
<label for="rtsp_host">Host</label>
<input id="rtsp_host" name="rtsp_host" type="text" class="form-input" placeholder="192.168.1.100" value={v["rtsp_host"] ?? ""} />
</div>
<div style="display:grid; grid-template-columns:1fr 2fr; gap:0.75rem">
<div class="form-group">
<label for="rtsp_port">Port</label>
<input id="rtsp_port" name="rtsp_port" type="number" class="form-input" value={v["rtsp_port"] ?? "554"} />
</div>
<div class="form-group">
<label for="rtsp_path">Path</label>
<input id="rtsp_path" name="rtsp_path" type="text" class="form-input" placeholder="/Streaming/Channels/101" value={v["rtsp_path"] ?? ""} />
</div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:0.75rem">
<div class="form-group">
<label for="rtsp_username">Username</label>
<input id="rtsp_username" name="rtsp_username" type="text" class="form-input" value={v["rtsp_username"] ?? ""} />
</div>
<div class="form-group">
<label for="rtsp_password">Password</label>
<input id="rtsp_password" name="rtsp_password" type="password" class="form-input" value={v["rtsp_password"] ?? ""} />
</div>
</div>
</div>
<div id="onvif-fields" style="display:none">
<div class="form-group">
<label for="onvif_host">Host</label>
<input id="onvif_host" name="onvif_host" type="text" class="form-input" value={v["onvif_host"] ?? ""} />
<label for="rtsp_username">Username</label>
<input id="rtsp_username" name="rtsp_username" type="text" class="form-input" value={v["rtsp_username"] ?? ""} />
</div>
<div class="form-group">
<label for="onvif_port">Port</label>
<input id="onvif_port" name="onvif_port" type="number" class="form-input" value={v["onvif_port"] ?? "80"} />
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:0.75rem">
<div class="form-group">
<label for="onvif_username">Username</label>
<input id="onvif_username" name="onvif_username" type="text" class="form-input" value={v["onvif_username"] ?? ""} />
</div>
<div class="form-group">
<label for="onvif_password">Password</label>
<input id="onvif_password" name="onvif_password" type="password" class="form-input" />
</div>
<label for="rtsp_password">Password</label>
<input id="rtsp_password" name="rtsp_password" type="password" class="form-input" value={v["rtsp_password"] ?? ""} />
</div>
</div>
@ -248,18 +216,361 @@ export function CameraNewPage(props: CameraNewProps) {
<a href="/admin/cameras" class="btn btn-ghost" style="margin-left:0.5rem">Cancel</a>
</form>
</div>
</Layout>
);
}
<script>{js(
`(function(){` +
`var radios=document.querySelectorAll('input[name="type"]');` +
`var rd=document.getElementById("rtsp-fields");` +
`var od=document.getElementById("onvif-fields");` +
`function t(){var el=document.querySelector('input[name="type"]:checked');` +
`var v=el?el.value:"rtsp";` +
`if(rd)rd.style.display=v==="rtsp"?"block":"none";` +
`if(od)od.style.display=v==="onvif"?"block":"none";}` +
`radios.forEach(function(r){r.addEventListener("change",t)});t();})()`
)}</script>
// ---- Camera ONVIF Discovery ------------------------------------------------
interface CameraDiscoverProps {
user: string;
error?: string;
values?: Record<string, string>;
}
export function CameraDiscoverPage(props: CameraDiscoverProps) {
const v = props.values ?? {};
return (
<Layout
title="Discover ONVIF Cameras"
user={props.user}
activeNav="cameras"
flash={props.error ? { type: "error", message: props.error } : undefined}
>
<div style="max-width:600px">
<p style="color:#666; margin-bottom:1rem">
Connect to an ONVIF camera or NVR by host and credentials. Each profile
returned can be saved as a separate RTSP camera.
</p>
<form method="post" action="/admin/cameras/discover" class="card">
<div class="form-group">
<label for="host">Host</label>
<input id="host" name="host" type="text" class="form-input" placeholder="192.168.1.100" required value={v["host"] ?? ""} />
</div>
<div class="form-group">
<label for="port">Port</label>
<input id="port" name="port" type="number" class="form-input" value={v["port"] ?? "80"} />
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:0.75rem">
<div class="form-group">
<label for="username">Username</label>
<input id="username" name="username" type="text" class="form-input" value={v["username"] ?? ""} />
</div>
<div class="form-group">
<label for="password">Password</label>
<input id="password" name="password" type="password" class="form-input" value={v["password"] ?? ""} />
</div>
</div>
<button type="submit" class="btn btn-primary">Discover</button>
<a href="/admin/cameras/new" class="btn btn-ghost" style="margin-left:0.5rem">Cancel</a>
</form>
</div>
</Layout>
);
}
interface DiscoveredProfileRow {
profile_name: string;
profile_token: string;
encoding: string | null;
width: number | null;
height: number | null;
framerate: number | null;
stream_uri: string;
}
interface CameraDiscoverResultsProps {
user: string;
host: string;
profiles: DiscoveredProfileRow[];
error?: string;
success?: string;
}
export function CameraDiscoverResultsPage(props: CameraDiscoverResultsProps) {
return (
<Layout
title="ONVIF Profiles"
user={props.user}
activeNav="cameras"
flash={
props.error ? { type: "error", message: props.error }
: props.success ? { type: "success", message: props.success }
: undefined
}
>
<p style="color:#666; margin-bottom:1rem">
Profiles reported by <strong>{props.host}</strong>. Click <em>Add</em> on
any row to import it as a camera.
</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Profile</th>
<th>Encoding</th>
<th>Resolution</th>
<th>FPS</th>
<th>Stream URI</th>
<th></th>
</tr>
</thead>
<tbody>
{props.profiles.length === 0 ? (
<tr><td colspan="6" style="text-align:center; color:#999; padding:2rem">No profiles returned</td></tr>
) : (
props.profiles.map((p) => (
<tr>
<td><strong>{p.profile_name}</strong></td>
<td>{p.encoding ? <span class="badge badge-blue">{p.encoding}</span> : "—"}</td>
<td>{p.width && p.height ? `${String(p.width)}x${String(p.height)}` : "—"}</td>
<td>{p.framerate != null ? String(p.framerate) : "—"}</td>
<td style="font-size:0.75rem; word-break:break-all; max-width:300px">{p.stream_uri}</td>
<td>
<form method="post" action="/admin/cameras/discover/add" style="display:inline">
<input type="hidden" name="name" value={`${props.host}: ${p.profile_name}`} />
<input type="hidden" name="rtsp_url" value={p.stream_uri} />
<input type="hidden" name="encoding" value={p.encoding ?? ""} />
<input type="hidden" name="width" value={String(p.width ?? "")} />
<input type="hidden" name="height" value={String(p.height ?? "")} />
<input type="hidden" name="framerate" value={String(p.framerate ?? "")} />
<input type="hidden" name="profile_token" value={p.profile_token} />
<button type="submit" class="btn btn-sm btn-primary">Add</button>
</form>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div style="margin-top:1rem">
<a href="/admin/cameras/discover" class="btn btn-ghost">Discover Another</a>
<a href="/admin/cameras" class="btn btn-ghost" style="margin-left:0.5rem">Back to Cameras</a>
</div>
</Layout>
);
}
// ---- Entities ---------------------------------------------------------------
interface EntitiesPageProps {
user: string;
entities: Entity[];
}
function entityBadge(type: string) {
const cls = type === "camera" ? "badge-blue" : type === "web" ? "badge-green" : "badge-gray";
return <span class={`badge ${cls}`}>{type}</span>;
}
function entityDetail(e: Entity): string {
if (e.type === "camera") return e.camera_id ? `cam #${String(e.camera_id)}` : "—";
if (e.type === "web") return e.web_url ?? "—";
if (e.type === "html") return e.html_content ? `${e.html_content.slice(0, 80)}` : "—";
return "—";
}
export function EntitiesPage(props: EntitiesPageProps) {
return (
<Layout title="Entities" user={props.user} activeNav="entities">
<div class="section-header">
<h2 class="section-title">All Entities</h2>
<a href="/admin/entities/new" class="btn btn-primary">New Entity</a>
</div>
<p style="color:#666; margin-bottom:1.25rem">
Entities are reusable content blocks (a camera reference, an HTML
snippet, or a web page). Bind one entity to any number of layout cells
edit the entity once and every cell updates.
</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
{props.entities.length === 0 ? (
<tr><td colspan="3" style="text-align:center; color:#999; padding:2rem">No entities yet</td></tr>
) : (
props.entities.map((e) => (
<tr>
<td><a href={`/admin/entities/${e.id}`}><strong>{e.name}</strong></a></td>
<td>{entityBadge(e.type)}</td>
<td style="color:#666; font-size:0.85rem">{entityDetail(e)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Layout>
);
}
interface EntityNewPageProps {
user: string;
cameras: Camera[];
error?: string;
values?: Record<string, string>;
}
function entityFormScript(rootId: string): string {
// Show/hide type-specific fieldsets when the type select changes.
return (
`(function(){` +
`var root=document.getElementById('${rootId}');` +
`if(!root)return;` +
`var sel=root.querySelector('select[name="type"]');` +
`var cf=root.querySelector('.ent-fields-camera');` +
`var wf=root.querySelector('.ent-fields-web');` +
`var hf=root.querySelector('.ent-fields-html');` +
`function t(){var v=sel?sel.value:"html";` +
`if(cf)cf.style.display=v==='camera'?'block':'none';` +
`if(wf)wf.style.display=v==='web'?'block':'none';` +
`if(hf)hf.style.display=v==='html'?'block':'none';}` +
`if(sel)sel.addEventListener('change',t);t();})()`
);
}
export function EntityNewPage(props: EntityNewPageProps) {
const v = props.values ?? {};
const selType = v["type"] ?? "html";
return (
<Layout
title="New Entity"
user={props.user}
activeNav="entities"
flash={props.error ? { type: "error", message: props.error } : undefined}
>
<div id="entity-new-root" style="max-width:600px">
<form method="post" action="/admin/entities/new" class="card">
<div class="form-group">
<label for="name">Name</label>
<input id="name" name="name" type="text" class="form-input" required maxlength="128" value={v["name"] ?? ""} />
</div>
<div class="form-group">
<label for="type">Type</label>
<select id="type" name="type" class="form-input">
<option value="html" selected={selType === "html"}>HTML snippet</option>
<option value="web" selected={selType === "web"}>Web URL</option>
<option value="camera" selected={selType === "camera"}>Camera reference</option>
</select>
</div>
<div class="form-group">
<label for="description">Description (optional)</label>
<input id="description" name="description" type="text" class="form-input" value={v["description"] ?? ""} />
</div>
<div class="ent-fields-camera" style={selType === "camera" ? "" : "display:none"}>
<div class="form-group">
<label for="camera_id">Camera</label>
<select id="camera_id" name="camera_id" class="form-input">
<option value="">-- Select --</option>
{props.cameras.map((cam) => (
<option value={String(cam.id)} selected={v["camera_id"] === String(cam.id)}>{cam.name}</option>
))}
</select>
</div>
</div>
<div class="ent-fields-web" style={selType === "web" ? "" : "display:none"}>
<div class="form-group">
<label for="web_url">URL</label>
<input id="web_url" name="web_url" type="url" class="form-input" placeholder="https://example.com" value={v["web_url"] ?? ""} />
</div>
</div>
<div class="ent-fields-html" style={selType === "html" ? "" : "display:none"}>
<div class="form-group">
<label for="html_content">HTML</label>
<textarea id="html_content" name="html_content" class="form-input" rows="6">{v["html_content"] ?? ""}</textarea>
</div>
</div>
<button type="submit" class="btn btn-primary">Create Entity</button>
<a href="/admin/entities" class="btn btn-ghost" style="margin-left:0.5rem">Cancel</a>
</form>
</div>
<script>{js(entityFormScript("entity-new-root"))}</script>
</Layout>
);
}
interface EntityEditPageProps {
user: string;
entity: Entity;
cameras: Camera[];
error?: string;
success?: string;
}
export function EntityEditPage(props: EntityEditPageProps) {
const e = props.entity;
return (
<Layout
title={`Entity: ${e.name}`}
user={props.user}
activeNav="entities"
flash={
props.error ? { type: "error", message: props.error }
: props.success ? { type: "success", message: props.success }
: undefined
}
>
<div id="entity-edit-root" style="max-width:600px">
<div class="card" style="margin-bottom:1rem">
<div style="margin-bottom:0.75rem">Type: {entityBadge(e.type)}</div>
<form method="post" action={`/admin/entities/${e.id}`}>
{/* Type is fixed after creation — switching type would break attached cells. */}
<input type="hidden" name="type" value={e.type} />
<div class="form-group">
<label for="name">Name</label>
<input id="name" name="name" type="text" class="form-input" required value={e.name} maxlength="128" />
</div>
<div class="form-group">
<label for="description">Description</label>
<input id="description" name="description" type="text" class="form-input" value={e.description ?? ""} />
</div>
{e.type === "camera" && (
<div class="form-group">
<label for="camera_id">Camera</label>
<select id="camera_id" name="camera_id" class="form-input">
<option value="">-- Select --</option>
{props.cameras.map((cam) => (
<option value={String(cam.id)} selected={e.camera_id === cam.id}>{cam.name}</option>
))}
</select>
</div>
)}
{e.type === "web" && (
<div class="form-group">
<label for="web_url">URL</label>
<input id="web_url" name="web_url" type="url" class="form-input" value={e.web_url ?? ""} />
</div>
)}
{e.type === "html" && (
<div class="form-group">
<label for="html_content">HTML</label>
<textarea id="html_content" name="html_content" class="form-input" rows="8">{e.html_content ?? ""}</textarea>
</div>
)}
<button type="submit" class="btn btn-primary">Save</button>
<a href="/admin/entities" class="btn btn-ghost" style="margin-left:0.5rem">Back</a>
</form>
</div>
<form method="post" action={`/admin/entities/${e.id}/delete`}>
<button type="submit" class="btn btn-danger" {...{"onclick": "return confirm('Delete this entity? Cells using it will be left empty.')"}}>Delete Entity</button>
</form>
</div>
</Layout>
);
}
@ -1013,6 +1324,7 @@ interface LayoutEditPageProps {
displays: Display[];
cells: LayoutCell[];
cameras: Camera[];
entities: Entity[];
/** If set, render the content-assignment form for this cell beneath the grid. */
selectedCellId?: number | null;
error?: string;
@ -1050,7 +1362,15 @@ export const LAYOUT_BUILDER_CSS = `
.layout-empty-add:hover { background: #1e40af; }
`;
function cellLabel(c: LayoutCell, cameraById: Map<number, Camera>): string {
function cellLabel(
c: LayoutCell,
entityById: Map<number, Entity>,
cameraById: Map<number, Camera>,
): string {
if (c.entity_id != null) {
const ent = entityById.get(c.entity_id);
if (ent) return ent.name;
}
if (c.content_type === "camera" && c.camera_id) {
return cameraById.get(c.camera_id)?.name ?? `cam #${String(c.camera_id)}`;
}
@ -1071,11 +1391,14 @@ function cellGridStyle(c: LayoutCell): string {
export function renderCell(
layoutId: number,
c: LayoutCell,
entities: Entity[],
cameras: Camera[],
mode: "read" | "edit",
): string {
const cameraById = new Map<number, Camera>();
for (const cam of cameras) cameraById.set(cam.id, cam);
const entityById = new Map<number, Entity>();
for (const e of entities) entityById.set(e.id, e);
const style = cellGridStyle(c);
const cellGetUrl = `/admin/layouts/${String(layoutId)}/cells/${String(c.id)}`;
const cellEditUrl = `${cellGetUrl}/edit`;
@ -1093,46 +1416,27 @@ export function renderCell(
hx-swap="outerHTML"
>
<div class="form-group">
<label>Content Type</label>
<div class="radio-group" style="display:flex; gap:0.5rem; flex-wrap:wrap; font-size:0.75rem">
<label><input type="radio" name="content_type" value="camera" checked={c.content_type === "camera"} /> Camera</label>
<label><input type="radio" name="content_type" value="web" checked={c.content_type === "web"} /> Web</label>
<label><input type="radio" name="content_type" value="html" checked={c.content_type === "html"} /> HTML</label>
<label>Entity</label>
<select name="entity_id" class="form-input">
<option value="">-- Empty --</option>
{entities.map((e) => (
<option value={String(e.id)} selected={c.entity_id === e.id}>
[{e.type}] {e.name}
</option>
))}
</select>
<div class="form-hint" style="font-size:0.7rem">
<a href="/admin/entities/new" target="_blank">+ New entity</a>
</div>
</div>
<div id={`cell-camera-fields-${String(c.id)}`} class="cell-fields-camera" style={c.content_type === "camera" ? "" : "display:none"}>
<div class="form-group">
<label>Camera</label>
<select name="camera_id" class="form-input">
<option value="">-- Select --</option>
{cameras.map((cam) => (
<option value={String(cam.id)} selected={c.camera_id === cam.id}>{cam.name}</option>
))}
</select>
</div>
<div class="form-group">
<label>Stream</label>
<select name="stream_selector" class="form-input">
<option value="auto" selected={c.stream_selector === "auto"}>Auto</option>
<option value="main" selected={c.stream_selector === "main"}>Main</option>
<option value="sub" selected={c.stream_selector === "sub"}>Sub</option>
</select>
</div>
</div>
<div id={`cell-web-fields-${String(c.id)}`} class="cell-fields-web" style={c.content_type === "web" ? "" : "display:none"}>
<div class="form-group">
<label>URL</label>
<input name="web_url" type="url" class="form-input" placeholder="https://example.com" value={c.web_url ?? ""} />
</div>
</div>
<div id={`cell-html-fields-${String(c.id)}`} class="cell-fields-html" style={c.content_type === "html" ? "" : "display:none"}>
<div class="form-group">
<label>HTML</label>
<textarea name="html_content" class="form-input" rows="3" placeholder="<div>...</div>">{c.html_content ?? ""}</textarea>
</div>
<div class="form-group" style={(c.entity_id != null && entityById.get(c.entity_id)?.type === "camera") ? "" : "display:none"}>
<label>Stream</label>
<select name="stream_selector" class="form-input">
<option value="auto" selected={c.stream_selector === "auto"}>Auto</option>
<option value="main" selected={c.stream_selector === "main"}>Main</option>
<option value="sub" selected={c.stream_selector === "sub"}>Sub</option>
</select>
</div>
<div class="form-group span-grid">
@ -1165,30 +1469,18 @@ export function renderCell(
>Delete</button>
</div>
</form>
<script>{js(
`(function(){` +
`var root=document.getElementById('cell-${String(c.id)}');` +
`if(!root)return;` +
`var rs=root.querySelectorAll('input[name="content_type"]');` +
`var cf=root.querySelector('.cell-fields-camera');` +
`var wf=root.querySelector('.cell-fields-web');` +
`var hf=root.querySelector('.cell-fields-html');` +
`function t(){var el=root.querySelector('input[name="content_type"]:checked');` +
`var v=el?el.value:"html";` +
`if(cf)cf.style.display=v==="camera"?"block":"none";` +
`if(wf)wf.style.display=v==="web"?"block":"none";` +
`if(hf)hf.style.display=v==="html"?"block":"none";}` +
`rs.forEach(function(r){r.addEventListener("change",t)});t();})()`
)}</script>
</div>
);
}
// Read mode.
const isEmpty = (c.content_type === "html" && !c.html_content)
// Read mode. Empty when no entity is bound.
const ent = c.entity_id != null ? entityById.get(c.entity_id) ?? null : null;
const isEmpty = !ent && (
(c.content_type === "html" && !c.html_content)
|| (c.content_type === "camera" && !c.camera_id)
|| (c.content_type === "web" && !c.web_url);
const label = cellLabel(c, cameraById);
|| (c.content_type === "web" && !c.web_url)
);
const label = cellLabel(c, entityById, cameraById);
return (
<div
@ -1280,6 +1572,7 @@ export function renderCell(
export function renderGrid(
layoutId: number,
cells: LayoutCell[],
entities: Entity[],
cameras: Camera[],
): string {
if (cells.length === 0) {
@ -1313,7 +1606,7 @@ export function renderGrid(
class="layout-builder"
style={`grid-template-columns:repeat(${String(gridCols)}, 1fr); grid-template-rows:repeat(${String(gridRows)}, 1fr)`}
>
{cells.map((c) => renderCell(layoutId, c, cameras, "read"))}
{cells.map((c) => renderCell(layoutId, c, entities, cameras, "read"))}
</div>
);
}
@ -1408,7 +1701,7 @@ export function LayoutEditPage(props: LayoutEditPageProps) {
Click a cell to edit content in-place.
</p>
<div id="layout-grid">
{renderGrid(l.id, cells, props.cameras)}
{renderGrid(l.id, cells, props.entities, props.cameras)}
</div>
</div>

View file

@ -43,6 +43,7 @@ function Sidebar(props: { activeNav?: string }) {
<nav class="sidebar-nav">
<NavItem href="/admin/" label="Overview" icon="&#9632;" active={a === "overview"} />
<NavItem href="/admin/cameras" label="Cameras" icon="&#9899;" active={a === "cameras"} />
<NavItem href="/admin/entities" label="Entities" icon="&#9863;" active={a === "entities"} />
<NavItem href="/admin/layouts" label="Layouts" icon="&#9638;" active={a === "layouts"} />
<NavItem href="/admin/displays" label="Displays" icon="&#9642;" active={a === "displays"} />
<NavItem href="/admin/kiosks" label="Kiosks" icon="&#9672;" active={a === "kiosks"} />