fix(onvif): import profiles as streams

This commit is contained in:
Mitchell R 2026-05-11 00:20:48 +02:00
parent 3be1a9a624
commit 02e57a5d54
No known key found for this signature in database
5 changed files with 308 additions and 43 deletions

View file

@ -245,6 +245,7 @@ Everything else is a shared module (plain TS, no BSB lifecycle).
- **BSB config doesn't apply schema defaults** for keys missing from sec-config.yaml. Always declare config values explicitly - **BSB config doesn't apply schema defaults** for keys missing from sec-config.yaml. Always declare config values explicitly
- **Cookie signing uses HKDF-derived key** (deterministic). NOT encryptString (random IV = non-deterministic = broken) - **Cookie signing uses HKDF-derived key** (deterministic). NOT encryptString (random IV = non-deterministic = broken)
- **RTSP URLs with special chars** in password: URL-encode user/pass components. Camera form splits into host/port/path/user/pass fields, builds URL server-side - **RTSP URLs with special chars** in password: URL-encode user/pass components. Camera form splits into host/port/path/user/pass fields, builds URL server-side
- **ONVIF discovery import**: ONVIF profiles are streams, not cameras. Group profiles by VideoSourceConfiguration/SourceToken (fallback to channel-ish URI/name), assign largest stream `main`, next `sub`, rest `other`, and import one camera with multiple `camera_streams`. If RTSP URIs omit userinfo, inject the ONVIF username/password before storing so kiosk playback avoids RTSP 401.
- **GStreamer on Pi5**: hw H265 decoder rejects non-standard resolutions (960x1080). Use avdec_h265 (sw) as fallback - **GStreamer on Pi5**: hw H265 decoder rejects non-standard resolutions (960x1080). Use avdec_h265 (sw) as fallback
- **Log message strings MUST be string literals** (BSB SmartLogMeta extracts placeholders from literal type) - **Log message strings MUST be string literals** (BSB SmartLogMeta extracts placeholders from literal type)
- **Datetimes are ISO-8601 strings** stored as TEXT - **Datetimes are ISO-8601 strings** stored as TEXT

View file

@ -29,6 +29,18 @@ import {
} from "../../web-templates/admin-pages.js"; } from "../../web-templates/admin-pages.js";
import { discover as onvifDiscover } from "../../shared/onvif.js"; import { discover as onvifDiscover } from "../../shared/onvif.js";
interface DiscoverAddStream {
profile_name: string;
profile_token: string;
source_token: string | null;
encoding: string | null;
width: number | null;
height: number | null;
framerate: number | null;
stream_uri: string;
role: "main" | "sub" | "other";
}
function htmlFragment(markup: unknown): Response { function htmlFragment(markup: unknown): Response {
return new Response(String(markup), { return new Response(String(markup), {
headers: { "content-type": "text/html; charset=utf-8" }, headers: { "content-type": "text/html; charset=utf-8" },
@ -54,6 +66,29 @@ function sanitizeRtspUrl(raw: string): string {
return `${scheme}${user}:${pass}@${rest}`; return `${scheme}${user}:${pass}@${rest}`;
} }
function uniqueCameraName(deps: AdminDeps, rawName: string): string {
let name = rawName;
if (deps.repo.getCameraByName(name)) {
let i = 2;
while (deps.repo.getCameraByName(`${rawName} (${String(i)})`)) i += 1;
name = `${rawName} (${String(i)})`;
}
return name;
}
function rtspWithCredentials(raw: string, username: string, password: string): string {
if (!username) return raw;
try {
const url = new URL(raw);
if (url.protocol !== "rtsp:" || url.username) return raw;
url.username = username;
url.password = password;
return url.toString();
} catch {
return raw;
}
}
export function registerAdminRoutes(app: H3, deps: AdminDeps): void { export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
// ---- Overview ------------------------------------------------------------- // ---- Overview -------------------------------------------------------------
@ -180,11 +215,13 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
} }
try { try {
const profiles = await onvifDiscover({ host, port, username, password }); const cameras = await onvifDiscover({ host, port, username, password });
return htmlPage(CameraDiscoverResultsPage({ return htmlPage(CameraDiscoverResultsPage({
user: user.username, user: user.username,
host, host,
profiles, username,
password,
cameras,
})); }));
} catch (err) { } catch (err) {
return htmlPage(CameraDiscoverPage({ return htmlPage(CameraDiscoverPage({
@ -198,42 +235,46 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
app.post("/admin/cameras/discover/add", async (event) => { app.post("/admin/cameras/discover/add", async (event) => {
const body = await readBody<Record<string, string>>(event); const body = await readBody<Record<string, string>>(event);
const rawName = (body?.["name"] ?? "").trim() || "ONVIF camera"; const rawName = (body?.["name"] ?? "").trim() || "ONVIF camera";
const rtspUrl = (body?.["rtsp_url"] ?? "").trim(); const username = (body?.["username"] ?? "").trim();
const encoding = (body?.["encoding"] ?? "").trim() || null; const password = body?.["password"] ?? "";
const profileToken = (body?.["profile_token"] ?? "").trim() || null; let streams: DiscoverAddStream[] = [];
const width = body?.["width"] ? Number(body["width"]) : null; try {
const height = body?.["height"] ? Number(body["height"]) : null; const parsed = JSON.parse(body?.["streams_json"] ?? "[]") as DiscoverAddStream[];
const framerate = body?.["framerate"] ? Number(body["framerate"]) : null; streams = Array.isArray(parsed) ? parsed : [];
} catch {
streams = [];
}
if (!rtspUrl) { if (streams.length === 0) {
return new Response(null, { status: 302, headers: { location: "/admin/cameras/discover" } }); return new Response(null, { status: 302, headers: { location: "/admin/cameras/discover" } });
} }
// Resolve a unique camera name const main = streams.find((s) => s.role === "main") ?? streams[0]!;
let name = rawName; const mainRtspUrl = rtspWithCredentials(main.stream_uri, username, password);
if (deps.repo.getCameraByName(name)) { const name = uniqueCameraName(deps, rawName);
let i = 2;
while (deps.repo.getCameraByName(`${rawName} (${String(i)})`)) i += 1;
name = `${rawName} (${String(i)})`;
}
const cam = deps.repo.createCamera({ const cam = deps.repo.createCamera({
name, name,
type: "rtsp", type: "rtsp",
rtsp_url: rtspUrl, rtsp_url: mainRtspUrl,
}); });
for (const stream of streams) {
const width = stream.width == null ? null : Number(stream.width);
const height = stream.height == null ? null : Number(stream.height);
const framerate = stream.framerate == null ? null : Number(stream.framerate);
deps.repo.createCameraStream({ deps.repo.createCameraStream({
camera_id: cam.id, camera_id: cam.id,
role: "main", role: stream.role === "main" || stream.role === "sub" ? stream.role : "other",
name: "Main", name: stream.profile_name || stream.role,
rtsp_uri: rtspUrl, rtsp_uri: rtspWithCredentials(stream.stream_uri, username, password),
profile_token: profileToken, profile_token: stream.profile_token || null,
width: Number.isFinite(width) ? width : null, width: Number.isFinite(width) ? width : null,
height: Number.isFinite(height) ? height : null, height: Number.isFinite(height) ? height : null,
encoding, encoding: stream.encoding || null,
framerate: Number.isFinite(framerate) ? framerate : null, framerate: Number.isFinite(framerate) ? framerate : null,
is_discovered: true, is_discovered: true,
}); });
}
notifyKiosks(); notifyKiosks();
return new Response(null, { status: 302, headers: { location: "/admin/cameras" } }); return new Response(null, { status: 302, headers: { location: "/admin/cameras" } });

View file

@ -1191,7 +1191,9 @@ export class Repository {
for (const [k, v] of Object.entries(patch)) { for (const [k, v] of Object.entries(patch)) {
if (k === "id" || k === "created_at") continue; if (k === "id" || k === "created_at") continue;
sets.push(`${k} = ?`); sets.push(`${k} = ?`);
vals.push(v === undefined ? null : v); if (k === "capabilities") vals.push(J(v));
else if (typeof v === "boolean") vals.push(B(v));
else vals.push(v === undefined ? null : v);
} }
if (sets.length === 0) return; if (sets.length === 0) return;
vals.push(id); vals.push(id);

View file

@ -14,11 +14,19 @@ import { createHash, randomBytes } from "node:crypto";
export interface DiscoveredProfile { export interface DiscoveredProfile {
profile_name: string; profile_name: string;
profile_token: string; profile_token: string;
source_token: string | null;
encoding: string | null; encoding: string | null;
width: number | null; width: number | null;
height: number | null; height: number | null;
framerate: number | null; framerate: number | null;
stream_uri: string; stream_uri: string;
role: "main" | "sub" | "other";
}
export interface DiscoveredCamera {
name: string;
source_token: string | null;
profiles: DiscoveredProfile[];
} }
interface DiscoverInput { interface DiscoverInput {
@ -32,6 +40,12 @@ interface DiscoverInput {
timeoutMs?: number; timeoutMs?: number;
} }
interface EndpointParts {
origin: string;
deviceUrl: string;
explicitMediaUrl: string | null;
}
function wsseHeader(username: string, password: string): string { function wsseHeader(username: string, password: string): string {
// WS-Security UsernameToken with PasswordDigest (the ONVIF-standard form). // WS-Security UsernameToken with PasswordDigest (the ONVIF-standard form).
// PasswordDigest = Base64( SHA1( nonce + created + password ) ) // PasswordDigest = Base64( SHA1( nonce + created + password ) )
@ -54,6 +68,10 @@ function wsseHeader(username: string, password: string): string {
</s:Header>`; </s:Header>`;
} }
function optionalWsseHeader(username: string, password: string): string {
return username ? wsseHeader(username, password) : "";
}
function escapeXml(s: string): string { function escapeXml(s: string): string {
return s return s
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
@ -86,6 +104,14 @@ async function soap(url: string, action: string, body: string, timeoutMs: number
} }
} }
async function trySoap(url: string, action: string, body: string, timeoutMs: number): Promise<string | null> {
try {
return await soap(url, action, body, timeoutMs);
} catch {
return null;
}
}
function buildEnvelope(headerXml: string, bodyXml: string): string { function buildEnvelope(headerXml: string, bodyXml: string): string {
return `<?xml version="1.0" encoding="UTF-8"?> return `<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" <s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"
@ -119,6 +145,68 @@ function pickAttr(xml: string, tagLocalName: string, attr: string): string[] {
return out; return out;
} }
function pickFirstXAddr(parentXml: string, tagLocalName: string): string | null {
const block = pickNested(parentXml, tagLocalName);
if (!block) return null;
return pickNested(block, "XAddr");
}
function normalizeEndpoint(input: DiscoverInput): EndpointParts {
const raw = input.host.trim();
const port = input.port || 80;
if (/^https?:\/\//i.test(raw)) {
const u = new URL(raw);
const resolvedPort = u.port ? Number(u.port) : u.protocol === "https:" ? 443 : port;
const origin = `${u.protocol}//${u.hostname}:${String(resolvedPort)}`;
const path = u.pathname && u.pathname !== "/" ? u.pathname : "";
return {
origin,
deviceUrl: path ? `${origin}${path}` : `${origin}/onvif/device_service`,
explicitMediaUrl: path ? `${origin}${path}` : null,
};
}
const origin = `http://${raw}:${String(port)}`;
return {
origin,
deviceUrl: `${origin}/onvif/device_service`,
explicitMediaUrl: null,
};
}
async function discoverMediaUrl(input: DiscoverInput, endpoint: EndpointParts, timeoutMs: number): Promise<string> {
if (input.mediaPath) {
return `${endpoint.origin}${input.mediaPath.startsWith("/") ? input.mediaPath : `/${input.mediaPath}`}`;
}
if (endpoint.explicitMediaUrl) {
return endpoint.explicitMediaUrl;
}
const capabilitiesEnv = buildEnvelope(
optionalWsseHeader(input.username, input.password),
`<tds:GetCapabilities><tds:Category>All</tds:Category></tds:GetCapabilities>`,
).replace(
'xmlns:tt="http://www.onvif.org/ver10/schema">',
'xmlns:tt="http://www.onvif.org/ver10/schema" xmlns:tds="http://www.onvif.org/ver10/device/wsdl">',
);
const capabilitiesXml = await trySoap(
endpoint.deviceUrl,
"http://www.onvif.org/ver10/device/wsdl/GetCapabilities",
capabilitiesEnv,
timeoutMs,
);
if (capabilitiesXml) {
const mediaXAddr = pickFirstXAddr(capabilitiesXml, "Media");
if (mediaXAddr) return mediaXAddr;
}
// Common vendor endpoints. Prefer lower-case media_service because many NVRs
// advertise that path and return 404 for /onvif/Media.
return `${endpoint.origin}/onvif/media_service`;
}
// Pull a single nested value from a parent element block. // Pull a single nested value from a parent element block.
function pickNested(parentXml: string, tagLocalName: string): string | null { function pickNested(parentXml: string, tagLocalName: string): string | null {
const m = parentXml.match(new RegExp(`<(?:[\\w-]+:)?${tagLocalName}\\b[^>]*>([\\s\\S]*?)</(?:[\\w-]+:)?${tagLocalName}>`)); const m = parentXml.match(new RegExp(`<(?:[\\w-]+:)?${tagLocalName}\\b[^>]*>([\\s\\S]*?)</(?:[\\w-]+:)?${tagLocalName}>`));
@ -136,6 +224,50 @@ function splitProfiles(xml: string): string[] {
return out; return out;
} }
function streamArea(p: DiscoveredProfile): number {
return (p.width ?? 0) * (p.height ?? 0);
}
function roleProfiles(profiles: DiscoveredProfile[]): DiscoveredProfile[] {
const ordered = [...profiles].sort((a, b) => streamArea(b) - streamArea(a));
const roles = new Map<DiscoveredProfile, "main" | "sub" | "other">();
for (let i = 0; i < ordered.length; i += 1) {
roles.set(ordered[i]!, i === 0 ? "main" : i === 1 ? "sub" : "other");
}
return profiles.map((p) => ({ ...p, role: roles.get(p) ?? "other" }));
}
function profileGroupKey(profileName: string, sourceToken: string | null, streamUri: string): string {
if (sourceToken) return `source:${sourceToken}`;
const match = streamUri.match(/(?:channel|channels|video|cam|camera)[/_-]?(\d+)/i)
?? profileName.match(/(?:channel|channels|video|cam|camera|profile)[/_ -]?(\d+)/i);
return match?.[1] ? `channel:${match[1]}` : "source:default";
}
function groupProfiles(host: string, profiles: DiscoveredProfile[]): DiscoveredCamera[] {
const groups = new Map<string, DiscoveredProfile[]>();
for (const profile of profiles) {
const key = profileGroupKey(profile.profile_name, profile.source_token, profile.stream_uri);
groups.set(key, [...(groups.get(key) ?? []), profile]);
}
const out: DiscoveredCamera[] = [];
let i = 1;
for (const [key, group] of groups) {
const sourceToken = group.find((p) => p.source_token)?.source_token ?? null;
const name = groups.size === 1
? host
: sourceToken ? `${host} ${sourceToken}` : `${host} camera ${String(i)}`;
out.push({
name,
source_token: sourceToken ?? (key.startsWith("channel:") ? key.slice("channel:".length) : null),
profiles: roleProfiles(group),
});
i += 1;
}
return out;
}
/** /**
* Connect to an ONVIF camera and list its media profiles with their * Connect to an ONVIF camera and list its media profiles with their
* resolutions, encodings, and RTSP stream URIs. * resolutions, encodings, and RTSP stream URIs.
@ -143,12 +275,10 @@ function splitProfiles(xml: string): string[] {
* Throws on transport error. Profile fields default to null if the camera * Throws on transport error. Profile fields default to null if the camera
* omits them. * omits them.
*/ */
export async function discover(input: DiscoverInput): Promise<DiscoveredProfile[]> { export async function discover(input: DiscoverInput): Promise<DiscoveredCamera[]> {
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 timeoutMs = input.timeoutMs ?? 8000;
const endpoint = normalizeEndpoint(input);
const mediaUrl = await discoverMediaUrl(input, endpoint, timeoutMs);
const header = wsseHeader(input.username, input.password); const header = wsseHeader(input.username, input.password);
@ -169,6 +299,10 @@ export async function discover(input: DiscoverInput): Promise<DiscoveredProfile[
const block = profileBlocks[i] ?? ""; const block = profileBlocks[i] ?? "";
const token = tokenAttrs[i] ?? ""; const token = tokenAttrs[i] ?? "";
const profileName = pickNested(block, "Name") ?? token ?? `profile_${String(i)}`; const profileName = pickNested(block, "Name") ?? token ?? `profile_${String(i)}`;
const vsrc = pickNested(block, "VideoSourceConfiguration") ?? "";
const sourceToken = vsrc
? pickNested(vsrc, "SourceToken") ?? pickAttr(vsrc, "VideoSourceConfiguration", "token")[0] ?? null
: null;
// VideoEncoderConfiguration → Encoding, Resolution{Width,Height}, RateControl.FrameRateLimit // VideoEncoderConfiguration → Encoding, Resolution{Width,Height}, RateControl.FrameRateLimit
const venc = pickNested(block, "VideoEncoderConfiguration") ?? ""; const venc = pickNested(block, "VideoEncoderConfiguration") ?? "";
@ -205,13 +339,15 @@ export async function discover(input: DiscoverInput): Promise<DiscoveredProfile[
out.push({ out.push({
profile_name: profileName, profile_name: profileName,
profile_token: token, profile_token: token,
source_token: sourceToken,
encoding, encoding,
width, width,
height, height,
framerate, framerate,
stream_uri: uri, stream_uri: uri,
role: "other",
}); });
} }
return out; return groupProfiles(input.host, out);
} }

View file

@ -239,8 +239,8 @@ export function CameraDiscoverPage(props: CameraDiscoverProps) {
> >
<div style="max-width:600px"> <div style="max-width:600px">
<p style="color:#666; margin-bottom:1rem"> <p style="color:#666; margin-bottom:1rem">
Connect to an ONVIF camera or NVR by host and credentials. Each profile Connect to an ONVIF camera or NVR by host and credentials. Profiles
returned can be saved as a separate RTSP camera. from the same video source are imported as streams on one camera.
</p> </p>
<form method="post" action="/admin/cameras/discover" class="card"> <form method="post" action="/admin/cameras/discover" class="card">
<div class="form-group"> <div class="form-group">
@ -272,25 +272,41 @@ export function CameraDiscoverPage(props: CameraDiscoverProps) {
interface DiscoveredProfileRow { interface DiscoveredProfileRow {
profile_name: string; profile_name: string;
profile_token: string; profile_token: string;
source_token: string | null;
encoding: string | null; encoding: string | null;
width: number | null; width: number | null;
height: number | null; height: number | null;
framerate: number | null; framerate: number | null;
stream_uri: string; stream_uri: string;
role: "main" | "sub" | "other";
}
interface DiscoveredCameraRow {
name: string;
source_token: string | null;
profiles: DiscoveredProfileRow[];
} }
interface CameraDiscoverResultsProps { interface CameraDiscoverResultsProps {
user: string; user: string;
host: string; host: string;
profiles: DiscoveredProfileRow[]; username: string;
password: string;
cameras: DiscoveredCameraRow[];
error?: string; error?: string;
success?: string; success?: string;
} }
export function CameraDiscoverResultsPage(props: CameraDiscoverResultsProps) { function CameraDiscoverResultsPageLegacy(props: {
user: string;
host: string;
profiles: DiscoveredProfileRow[];
error?: string;
success?: string;
}) {
return ( return (
<Layout <Layout
title="ONVIF Profiles" title="ONVIF Cameras"
user={props.user} user={props.user}
activeNav="cameras" activeNav="cameras"
flash={ flash={
@ -300,8 +316,8 @@ export function CameraDiscoverResultsPage(props: CameraDiscoverResultsProps) {
} }
> >
<p style="color:#666; margin-bottom:1rem"> <p style="color:#666; margin-bottom:1rem">
Profiles reported by <strong>{props.host}</strong>. Click <em>Add</em> on Video sources reported by <strong>{props.host}</strong>. Each source imports
any row to import it as a camera. as one camera with its profiles saved as streams.
</p> </p>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
@ -352,6 +368,75 @@ export function CameraDiscoverResultsPage(props: CameraDiscoverResultsProps) {
); );
} }
export function CameraDiscoverResultsPage(props: CameraDiscoverResultsProps) {
return (
<Layout
title="ONVIF Cameras"
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">
Video sources reported by <strong>{props.host}</strong>. Each source imports
as one camera with its profiles saved as streams.
</p>
{props.cameras.length === 0 ? (
<div class="card" style="text-align:center; color:#999; padding:2rem">No profiles returned</div>
) : props.cameras.map((cam) => (
<div class="card" style="margin-bottom:1rem">
<div class="section-header" style="margin-bottom:0.75rem">
<div>
<h2 class="section-title" style="font-size:1rem">{cam.name}</h2>
{cam.source_token ? <div style="color:#666; font-size:0.8rem">Source: {cam.source_token}</div> : ""}
</div>
<form method="post" action="/admin/cameras/discover/add" style="display:inline">
<input type="hidden" name="name" value={cam.name} />
<input type="hidden" name="username" value={props.username} />
<input type="hidden" name="password" value={props.password} />
<input type="hidden" name="streams_json" value={JSON.stringify(cam.profiles)} />
<button type="submit" class="btn btn-sm btn-primary">Add Camera</button>
</form>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Role</th>
<th>Profile</th>
<th>Encoding</th>
<th>Resolution</th>
<th>FPS</th>
<th>Stream URI</th>
</tr>
</thead>
<tbody>
{cam.profiles.map((p) => (
<tr>
<td><span class="badge badge-gray">{p.role}</span></td>
<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>
</tr>
))}
</tbody>
</table>
</div>
</div>
)).join("")}
<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 --------------------------------------------------------------- // ---- Entities ---------------------------------------------------------------
interface EntitiesPageProps { interface EntitiesPageProps {