/** * 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 ` ${escapeXml(username)} ${digest} ${nonce} ${created} `; } function escapeXml(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } async function soap(url: string, action: string, body: string, timeoutMs: number): Promise { 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 ` ${headerXml} ${bodyXml} `; } // 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]*?)`, "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]*?)`)); 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 { 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, ``); 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 = ` RTP-Unicast RTSP ${escapeXml(token)} `; 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; }