Both of your cameras call themselves "I91ET", so the slimmed dashboard showed two identical names. - A nickname kept in the registry now wins wherever a camera is named: cards, camera page, live view and its window title, pop-outs and the recordings list. It survives a rescan, and clearing it falls back to the camera's own name, then its model. - Rename on the camera page also writes the camera's own name and location over ONVIF SetScopes, so everything else on the network sees them. Since SetScopes replaces every configurable scope, the others are read and sent back untouched; the result is re-read from the camera and reported as applied or adjusted, and the change is audited. - cameraName() lives in a client-safe module, because the registry is server-only and the dashboard names cameras in the browser. - NEXT_DIST_DIR lets a build run while dev servers hold .next. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
382 lines
13 KiB
TypeScript
382 lines
13 KiB
TypeScript
import "server-only";
|
|
import http from "node:http";
|
|
import https from "node:https";
|
|
import { Cam, type CamProfile } from "onvif";
|
|
import { getDigestHeaders } from "onvif/lib/utils";
|
|
import { getCredentials, type Credentials } from "./credential-store";
|
|
|
|
export interface CameraProfile {
|
|
token: string;
|
|
name?: string;
|
|
encoding?: string;
|
|
width?: number;
|
|
height?: number;
|
|
fps?: number;
|
|
}
|
|
|
|
export interface CameraInfo {
|
|
manufacturer?: string;
|
|
model?: string;
|
|
firmwareVersion?: string;
|
|
serialNumber?: string;
|
|
profiles: CameraProfile[];
|
|
}
|
|
|
|
export interface Snapshot {
|
|
contentType: string;
|
|
body: Buffer;
|
|
}
|
|
|
|
const REQUEST_TIMEOUT_MS = 10_000;
|
|
|
|
/** A camera in the registry: stable ID plus its last known address. */
|
|
export interface CameraTarget {
|
|
id: string;
|
|
host: string;
|
|
port: number;
|
|
}
|
|
|
|
/** The camera rejected (or was never given) a username/password. */
|
|
export class CameraAuthError extends Error {
|
|
name = "CameraAuthError";
|
|
constructor(
|
|
message: string,
|
|
/** True when no login was saved at all, as opposed to the camera rejecting one. */
|
|
readonly missingLogin = false,
|
|
) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The camera hasn't been activated (first admin password never set). Vendors such as
|
|
* Hikvision refuse every ONVIF request, even unauthenticated ones, until then.
|
|
*/
|
|
export class CameraInactiveError extends Error {
|
|
name = "CameraInactiveError";
|
|
}
|
|
|
|
const INACTIVE_RESPONSE = /device is inactive|not activated|inactive device/i;
|
|
|
|
const AUTH_FAILURE = /\b401\b|not ?authori[sz]ed|authenticat|unauthori[sz]ed/i;
|
|
|
|
function asAuthError(err: unknown): unknown {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
return AUTH_FAILURE.test(message) ? new CameraAuthError(message) : err;
|
|
}
|
|
|
|
/**
|
|
* Only RFC 1918 IPv4 addresses may be targeted, so the snapshot/info routes can't be
|
|
* used to make this server request arbitrary hosts.
|
|
*/
|
|
export function isAllowedHost(host: string): boolean {
|
|
const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
if (!m) return false;
|
|
const [a, b, c, d] = m.slice(1).map(Number);
|
|
if ([a, b, c, d].some((n) => n > 255)) return false;
|
|
return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
|
|
}
|
|
|
|
// Connected Cam instances are reused across requests; connect() makes several SOAP
|
|
// calls, which is too slow to repeat for every snapshot.
|
|
const cams = new Map<string, Promise<Cam>>();
|
|
const snapshotUris = new Map<string, Promise<string>>();
|
|
|
|
/**
|
|
* Connects and loads profiles. Rejects with CameraInactiveError for unactivated cameras
|
|
* and CameraAuthError if there is no login or it is refused. With no credentials it still
|
|
* contacts the camera (GetSystemDateAndTime needs no login) to tell those two apart.
|
|
*/
|
|
function openCam(host: string, port: number, creds: Credentials | null): Promise<Cam> {
|
|
return new Promise<Cam>((resolve, reject) => {
|
|
const cam = new Cam({
|
|
hostname: host,
|
|
port,
|
|
...(creds ?? {}),
|
|
timeout: REQUEST_TIMEOUT_MS,
|
|
// Use the discovered address even if the camera advertises another one.
|
|
preserveAddress: true,
|
|
autoconnect: false,
|
|
});
|
|
let inactive = false;
|
|
cam.on("rawResponse", (body: string) => {
|
|
if (INACTIVE_RESPONSE.test(body)) inactive = true;
|
|
});
|
|
// If GetProfiles fails (typically bad credentials), connect() still succeeds but
|
|
// only emits a "warning" and leaves profiles empty; surface that as an error.
|
|
let warning: string | undefined;
|
|
cam.on("warning", (w: unknown) => (warning = String(w)));
|
|
cam.connect((err) => {
|
|
if (inactive) {
|
|
return reject(new CameraInactiveError("Camera has not been activated yet"));
|
|
}
|
|
if (err) return reject(asAuthError(err));
|
|
if (!cam.profiles?.length) {
|
|
if (!creds) return reject(new CameraAuthError("No login saved for this camera", true));
|
|
return reject(
|
|
asAuthError(new Error(`Camera returned no media profiles${warning ? `: ${warning}` : ""}`)),
|
|
);
|
|
}
|
|
resolve(cam);
|
|
});
|
|
});
|
|
}
|
|
|
|
/** The cached, logged-in connection for a camera (shared with camera-settings.ts). */
|
|
export function connect(target: CameraTarget): Promise<Cam> {
|
|
const key = `${target.host}:${target.port}`;
|
|
let pending = cams.get(key);
|
|
if (!pending) {
|
|
pending = getCredentials(target.id).then((creds) => openCam(target.host, target.port, creds));
|
|
pending.catch(() => cams.delete(key));
|
|
cams.set(key, pending);
|
|
}
|
|
return pending;
|
|
}
|
|
|
|
/** Verifies a login against the camera without touching cached connections. */
|
|
export async function testCredentials(target: CameraTarget, creds: Credentials) {
|
|
await openCam(target.host, target.port, creds);
|
|
}
|
|
|
|
/** Drops cached connections so the next request logs in again. */
|
|
export function resetConnection({ host, port }: CameraTarget) {
|
|
const prefix = `${host}:${port}`;
|
|
cams.delete(prefix);
|
|
for (const key of snapshotUris.keys()) {
|
|
if (key.startsWith(`${prefix}/`)) snapshotUris.delete(key);
|
|
}
|
|
}
|
|
|
|
/** A profile's video encoder: Media1 uses videoEncoderConfiguration, Media2 configurations.videoEncoder. */
|
|
export function encoderConfig(p: CamProfile) {
|
|
return p.videoEncoderConfiguration ?? p.configurations?.videoEncoder;
|
|
}
|
|
|
|
/** A profile's token: an element in Media1, an attribute in Media2. */
|
|
export function profileToken(p: CamProfile): string {
|
|
return p.token ?? p.$?.token;
|
|
}
|
|
|
|
function toProfile(p: CamProfile): CameraProfile {
|
|
const enc = encoderConfig(p);
|
|
const resolution = enc?.resolution;
|
|
return {
|
|
token: profileToken(p),
|
|
name: p.name,
|
|
encoding: enc?.encoding,
|
|
width: resolution?.width,
|
|
height: resolution?.height,
|
|
fps: enc?.rateControl?.frameRateLimit ?? enc?.rateControl?.$?.FrameRateLimit,
|
|
};
|
|
}
|
|
|
|
export async function getCameraInfo(target: CameraTarget): Promise<CameraInfo> {
|
|
const cam = await connect(target);
|
|
const device = await new Promise<Record<string, string>>((resolve, reject) =>
|
|
cam.getDeviceInformation((err, info) => (err ? reject(err) : resolve(info ?? {}))),
|
|
).catch(() => ({}) as Record<string, string>);
|
|
|
|
return {
|
|
manufacturer: device.manufacturer,
|
|
model: device.model,
|
|
firmwareVersion: device.firmwareVersion,
|
|
serialNumber: device.serialNumber,
|
|
profiles: (cam.profiles ?? []).map(toProfile),
|
|
};
|
|
}
|
|
|
|
function snapshotUri(target: CameraTarget, profileToken?: string): Promise<string> {
|
|
const key = `${target.host}:${target.port}/${profileToken ?? ""}`;
|
|
let pending = snapshotUris.get(key);
|
|
if (!pending) {
|
|
pending = connect(target).then(
|
|
(cam) =>
|
|
new Promise<string>((resolve, reject) =>
|
|
cam.getSnapshotUri(profileToken ? { profileToken } : {}, (err, res) =>
|
|
err || !res?.uri
|
|
? reject(err ?? new Error("Camera returned no snapshot URI"))
|
|
: resolve(res.uri),
|
|
),
|
|
),
|
|
);
|
|
pending.catch(() => snapshotUris.delete(key));
|
|
snapshotUris.set(key, pending);
|
|
}
|
|
return pending;
|
|
}
|
|
|
|
interface HttpResult {
|
|
status: number;
|
|
headers: http.IncomingHttpHeaders;
|
|
rawHeaders: string[];
|
|
body: Buffer;
|
|
}
|
|
|
|
function httpGet(url: URL, authorization?: string): Promise<HttpResult> {
|
|
const lib = url.protocol === "https:" ? https : http;
|
|
return new Promise((resolve, reject) => {
|
|
const req = lib.request(
|
|
url,
|
|
{
|
|
method: "GET",
|
|
headers: authorization ? { Authorization: authorization } : {},
|
|
// Cameras commonly use self-signed certificates.
|
|
rejectUnauthorized: false,
|
|
timeout: REQUEST_TIMEOUT_MS,
|
|
},
|
|
(res) => {
|
|
const chunks: Buffer[] = [];
|
|
res.on("data", (c: Buffer) => chunks.push(c));
|
|
res.on("end", () =>
|
|
resolve({
|
|
status: res.statusCode ?? 0,
|
|
headers: res.headers,
|
|
rawHeaders: res.rawHeaders,
|
|
body: Buffer.concat(chunks),
|
|
}),
|
|
);
|
|
res.on("error", reject);
|
|
},
|
|
);
|
|
req.on("timeout", () => req.destroy(new Error("Snapshot request timed out")));
|
|
req.on("error", reject);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Fetches a JPEG from the camera's snapshot URI, answering a Digest or Basic
|
|
* challenge with the ONVIF credentials.
|
|
*/
|
|
export async function getSnapshot(target: CameraTarget, profileToken?: string): Promise<Snapshot> {
|
|
const { host } = target;
|
|
let uri: string;
|
|
try {
|
|
uri = await snapshotUri(target, profileToken);
|
|
} catch (err) {
|
|
resetConnection(target);
|
|
throw err;
|
|
}
|
|
|
|
const url = new URL(uri);
|
|
// Some cameras advertise an unreachable or internal hostname in the snapshot URI.
|
|
url.hostname = host;
|
|
let res = await httpGet(url);
|
|
|
|
if (res.status === 401) {
|
|
const cam = await connect(target);
|
|
const { username, password } = (await getCredentials(target.id)) ?? {
|
|
username: "",
|
|
password: "",
|
|
};
|
|
const digest = getDigestHeaders(res.rawHeaders);
|
|
const authorization = digest.length
|
|
? cam.digestAuth(digest, { method: "GET", path: url.pathname + url.search })
|
|
: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
|
|
res = await httpGet(url, authorization);
|
|
}
|
|
|
|
if (res.status === 401) {
|
|
resetConnection(target);
|
|
throw new CameraAuthError("Snapshot login rejected (HTTP 401)");
|
|
}
|
|
if (res.status !== 200) {
|
|
throw new Error(`Snapshot request failed with HTTP ${res.status}`);
|
|
}
|
|
return {
|
|
contentType: String(res.headers["content-type"] ?? "image/jpeg"),
|
|
body: res.body,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The camera's RTSP address for its main (first) or sub (second) stream, with the stored
|
|
* login embedded, for the local video bridge only (vrek iss-nk6zrzv). Contains the camera
|
|
* password: never return it to a client or log it.
|
|
*/
|
|
export async function rtspSourceWithLogin(target: CameraTarget, stream: "main" | "sub"): Promise<string> {
|
|
const cam = await connect(target);
|
|
const profile = (cam.profiles ?? [])[stream === "main" ? 0 : 1];
|
|
if (!profile) throw new Error(`Camera has no ${stream} stream`);
|
|
const uri = await new Promise<string>((resolve, reject) =>
|
|
cam.getStreamUri({ protocol: "RTSP", profileToken: profileToken(profile) }, (err, res) =>
|
|
err || !res?.uri ? reject(err ?? new Error("Camera returned no stream URI")) : resolve(res.uri),
|
|
),
|
|
);
|
|
const url = new URL(uri);
|
|
// Some cameras advertise an unreachable or internal hostname in the stream URI.
|
|
url.hostname = target.host;
|
|
const creds = await getCredentials(target.id);
|
|
if (creds) {
|
|
url.username = encodeURIComponent(creds.username);
|
|
url.password = encodeURIComponent(creds.password);
|
|
}
|
|
return url.toString();
|
|
}
|
|
|
|
/** The camera's own name and location, as discovery sees them (vrek iss-h1qwke5). */
|
|
export interface CameraScopes {
|
|
name?: string;
|
|
location?: string;
|
|
}
|
|
|
|
const SCOPE_PREFIX = "onvif://www.onvif.org/";
|
|
const scopeUri = (key: string, value: string) => `${SCOPE_PREFIX}${key}/${encodeURIComponent(value)}`;
|
|
|
|
function scopeValue(uris: string[], key: string): string | undefined {
|
|
const prefix = `${SCOPE_PREFIX}${key}/`;
|
|
const match = uris.find((uri) => uri.startsWith(prefix));
|
|
return match ? decodeURIComponent(match.slice(prefix.length)) : undefined;
|
|
}
|
|
|
|
/** A scope as the library reports it: Media1 nests the parts, Media2 uses attributes. */
|
|
type Scope = { ScopeDef?: string; ScopeItem?: string; scopeDef?: string; scopeItem?: string };
|
|
|
|
const asUri = (scope: Scope) => scope.ScopeItem ?? scope.scopeItem ?? "";
|
|
const isConfigurable = (scope: Scope) =>
|
|
(scope.ScopeDef ?? scope.scopeDef ?? "").toLowerCase() === "configurable";
|
|
|
|
function readScopes(cam: Cam): Promise<Scope[]> {
|
|
return new Promise((resolve, reject) =>
|
|
(cam as unknown as { getScopes(cb: (err: Error | null, scopes?: Scope[]) => void): void }).getScopes(
|
|
(err, scopes) => (err ? reject(err) : resolve(scopes ?? [])),
|
|
),
|
|
);
|
|
}
|
|
|
|
export async function getCameraScopes(target: CameraTarget): Promise<CameraScopes> {
|
|
const scopes = await readScopes(await connect(target));
|
|
const uris = scopes.map(asUri);
|
|
return { name: scopeValue(uris, "name"), location: scopeValue(uris, "location") };
|
|
}
|
|
|
|
/**
|
|
* Renames the camera itself. SetScopes replaces every configurable scope, so the others
|
|
* are read first and sent back untouched; fixed scopes are the camera's own and are left
|
|
* out. Returns what the camera reports afterwards, which is what actually applied.
|
|
*/
|
|
export async function setCameraScopes(target: CameraTarget, patch: CameraScopes): Promise<CameraScopes> {
|
|
const cam = await connect(target);
|
|
const existing = await readScopes(cam);
|
|
const keep = existing
|
|
.filter(isConfigurable)
|
|
.map(asUri)
|
|
.filter((uri) => uri && !uri.startsWith(`${SCOPE_PREFIX}name/`) && !uri.startsWith(`${SCOPE_PREFIX}location/`));
|
|
|
|
const wanted = [...keep];
|
|
const current = await getCameraScopes(target);
|
|
const name = patch.name ?? current.name;
|
|
const location = patch.location ?? current.location;
|
|
if (name) wanted.push(scopeUri("name", name));
|
|
if (location) wanted.push(scopeUri("location", location));
|
|
|
|
await new Promise<void>((resolve, reject) =>
|
|
(cam as unknown as { setScopes(uris: string[], cb: (err: Error | null) => void): void }).setScopes(
|
|
wanted,
|
|
(err) => (err ? reject(err) : resolve()),
|
|
),
|
|
);
|
|
return getCameraScopes(target);
|
|
}
|