mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 16:56:33 +00:00
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:
parent
00b304c39f
commit
3be1a9a624
11 changed files with 1282 additions and 206 deletions
44
package-lock.json
generated
44
package-lock.json
generated
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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}` } });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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
217
server/src/shared/onvif.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 →</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>
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ function Sidebar(props: { activeNav?: string }) {
|
|||
<nav class="sidebar-nav">
|
||||
<NavItem href="/admin/" label="Overview" icon="■" active={a === "overview"} />
|
||||
<NavItem href="/admin/cameras" label="Cameras" icon="⚫" active={a === "cameras"} />
|
||||
<NavItem href="/admin/entities" label="Entities" icon="⚇" active={a === "entities"} />
|
||||
<NavItem href="/admin/layouts" label="Layouts" icon="▦" active={a === "layouts"} />
|
||||
<NavItem href="/admin/displays" label="Displays" icon="▪" active={a === "displays"} />
|
||||
<NavItem href="/admin/kiosks" label="Kiosks" icon="◈" active={a === "kiosks"} />
|
||||
|
|
|
|||
Loading…
Reference in a new issue