diff --git a/CLAUDE.md b/CLAUDE.md index ed172eb..98dbd2a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 - **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 +- **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 - **Log message strings MUST be string literals** (BSB SmartLogMeta extracts placeholders from literal type) - **Datetimes are ISO-8601 strings** stored as TEXT diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index 6a9103f..47e7c60 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -29,6 +29,18 @@ import { } from "../../web-templates/admin-pages.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 { return new Response(String(markup), { headers: { "content-type": "text/html; charset=utf-8" }, @@ -54,6 +66,29 @@ function sanitizeRtspUrl(raw: string): string { 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 { // ---- Overview ------------------------------------------------------------- @@ -180,11 +215,13 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { } try { - const profiles = await onvifDiscover({ host, port, username, password }); + const cameras = await onvifDiscover({ host, port, username, password }); return htmlPage(CameraDiscoverResultsPage({ user: user.username, host, - profiles, + username, + password, + cameras, })); } catch (err) { return htmlPage(CameraDiscoverPage({ @@ -198,42 +235,46 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { app.post("/admin/cameras/discover/add", async (event) => { const body = await readBody>(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; + const username = (body?.["username"] ?? "").trim(); + const password = body?.["password"] ?? ""; + let streams: DiscoverAddStream[] = []; + try { + const parsed = JSON.parse(body?.["streams_json"] ?? "[]") as DiscoverAddStream[]; + streams = Array.isArray(parsed) ? parsed : []; + } catch { + streams = []; + } - if (!rtspUrl) { + if (streams.length === 0) { 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 main = streams.find((s) => s.role === "main") ?? streams[0]!; + const mainRtspUrl = rtspWithCredentials(main.stream_uri, username, password); + const name = uniqueCameraName(deps, rawName); 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, + 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({ + camera_id: cam.id, + role: stream.role === "main" || stream.role === "sub" ? stream.role : "other", + name: stream.profile_name || stream.role, + rtsp_uri: rtspWithCredentials(stream.stream_uri, username, password), + profile_token: stream.profile_token || null, + width: Number.isFinite(width) ? width : null, + height: Number.isFinite(height) ? height : null, + encoding: stream.encoding || null, + framerate: Number.isFinite(framerate) ? framerate : null, + is_discovered: true, + }); + } notifyKiosks(); return new Response(null, { status: 302, headers: { location: "/admin/cameras" } }); diff --git a/server/src/plugins/service-store/repository.ts b/server/src/plugins/service-store/repository.ts index a68c55d..97e7a9b 100644 --- a/server/src/plugins/service-store/repository.ts +++ b/server/src/plugins/service-store/repository.ts @@ -1191,7 +1191,9 @@ export class Repository { for (const [k, v] of Object.entries(patch)) { if (k === "id" || k === "created_at") continue; 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; vals.push(id); diff --git a/server/src/shared/onvif.ts b/server/src/shared/onvif.ts index 57277f9..70b9478 100644 --- a/server/src/shared/onvif.ts +++ b/server/src/shared/onvif.ts @@ -14,11 +14,19 @@ import { createHash, randomBytes } from "node:crypto"; export interface DiscoveredProfile { 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"; +} + +export interface DiscoveredCamera { + name: string; + source_token: string | null; + profiles: DiscoveredProfile[]; } interface DiscoverInput { @@ -32,6 +40,12 @@ interface DiscoverInput { timeoutMs?: number; } +interface EndpointParts { + origin: string; + deviceUrl: string; + explicitMediaUrl: string | null; +} + function wsseHeader(username: string, password: string): string { // WS-Security UsernameToken with PasswordDigest (the ONVIF-standard form). // PasswordDigest = Base64( SHA1( nonce + created + password ) ) @@ -54,6 +68,10 @@ function wsseHeader(username: string, password: string): string { `; } +function optionalWsseHeader(username: string, password: string): string { + return username ? wsseHeader(username, password) : ""; +} + function escapeXml(s: string): string { return s .replace(/&/g, "&") @@ -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 { + try { + return await soap(url, action, body, timeoutMs); + } catch { + return null; + } +} + function buildEnvelope(headerXml: string, bodyXml: string): string { return ` { + 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), + `All`, + ).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. function pickNested(parentXml: string, tagLocalName: string): string | null { const m = parentXml.match(new RegExp(`<(?:[\\w-]+:)?${tagLocalName}\\b[^>]*>([\\s\\S]*?)`)); @@ -136,6 +224,50 @@ function splitProfiles(xml: string): string[] { 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(); + 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(); + 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 * 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 * omits them. */ -export async function discover(input: DiscoverInput): Promise { - const host = input.host; - const port = input.port || 80; - const mediaPath = input.mediaPath ?? "/onvif/Media"; - const mediaUrl = `http://${host}:${String(port)}${mediaPath}`; +export async function discover(input: DiscoverInput): Promise { const timeoutMs = input.timeoutMs ?? 8000; + const endpoint = normalizeEndpoint(input); + const mediaUrl = await discoverMediaUrl(input, endpoint, timeoutMs); const header = wsseHeader(input.username, input.password); @@ -169,6 +299,10 @@ export async function discover(input: DiscoverInput): Promise

- Connect to an ONVIF camera or NVR by host and credentials. Each profile - returned can be saved as a separate RTSP camera. + Connect to an ONVIF camera or NVR by host and credentials. Profiles + from the same video source are imported as streams on one camera.

@@ -272,25 +272,41 @@ export function CameraDiscoverPage(props: CameraDiscoverProps) { interface DiscoveredProfileRow { 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"; +} + +interface DiscoveredCameraRow { + name: string; + source_token: string | null; + profiles: DiscoveredProfileRow[]; } interface CameraDiscoverResultsProps { user: string; host: string; - profiles: DiscoveredProfileRow[]; + username: string; + password: string; + cameras: DiscoveredCameraRow[]; error?: string; success?: string; } -export function CameraDiscoverResultsPage(props: CameraDiscoverResultsProps) { +function CameraDiscoverResultsPageLegacy(props: { + user: string; + host: string; + profiles: DiscoveredProfileRow[]; + error?: string; + success?: string; +}) { return (

- Profiles reported by {props.host}. Click Add on - any row to import it as a camera. + Video sources reported by {props.host}. Each source imports + as one camera with its profiles saved as streams.

@@ -352,6 +368,75 @@ export function CameraDiscoverResultsPage(props: CameraDiscoverResultsProps) { ); } +export function CameraDiscoverResultsPage(props: CameraDiscoverResultsProps) { + return ( + +

+ Video sources reported by {props.host}. Each source imports + as one camera with its profiles saved as streams. +

+ {props.cameras.length === 0 ? ( +
No profiles returned
+ ) : props.cameras.map((cam) => ( +
+
+
+

{cam.name}

+ {cam.source_token ?
Source: {cam.source_token}
: ""} +
+ + + + + + + +
+
+
+ + + + + + + + + + + + {cam.profiles.map((p) => ( + + + + + + + + + ))} + +
RoleProfileEncodingResolutionFPSStream URI
{p.role}{p.profile_name}{p.encoding ? {p.encoding} : "-"}{p.width && p.height ? `${String(p.width)}x${String(p.height)}` : "-"}{p.framerate != null ? String(p.framerate) : "-"}{p.stream_uri}
+
+
+ )).join("")} + + + ); +} + // ---- Entities --------------------------------------------------------------- interface EntitiesPageProps {