mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 17:56:34 +00:00
fix(onvif): import profiles as streams
This commit is contained in:
parent
3be1a9a624
commit
02e57a5d54
5 changed files with 308 additions and 43 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
|
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();
|
notifyKiosks();
|
||||||
|
|
||||||
return new Response(null, { status: 302, headers: { location: "/admin/cameras" } });
|
return new Response(null, { status: 302, headers: { location: "/admin/cameras" } });
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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, "&")
|
.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<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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue