@@ -65,7 +72,7 @@ export default async function CameraPage({ params }: PageProps<"/cameras/[id]">)
-
+
diff --git a/src/app/recordings/page.tsx b/src/app/recordings/page.tsx
index 9760b25..e25bca5 100644
--- a/src/app/recordings/page.tsx
+++ b/src/app/recordings/page.tsx
@@ -1,7 +1,7 @@
import { ArrowLeft, Film } from "lucide-react";
import Link from "next/link";
import { requirePageAccess } from "@/lib/access";
-import { allCameraRecords } from "@/lib/camera-registry";
+import { allCameraRecords, cameraName } from "@/lib/camera-registry";
import { cameraIdSchema } from "@/lib/camera-registry";
import { keepDays, listClips, recordStream } from "@/lib/recordings";
import ClipList from "./clip-list";
@@ -12,7 +12,7 @@ export default async function RecordingsPage({ searchParams }: PageProps<"/recor
const { camera } = await searchParams;
const only = cameraIdSchema.safeParse(camera).data;
const [clips, cameras] = await Promise.all([listClips(only), allCameraRecords()]);
- const names = Object.fromEntries(cameras.map((c) => [c.id, c.name ?? `${c.host}:${c.port}`]));
+ const names = Object.fromEntries(cameras.map((c) => [c.id, cameraName(c, `${c.host}:${c.port}`)]));
const shown = only ? names[only] : null;
return (
diff --git a/src/app/recordings/play/page.tsx b/src/app/recordings/play/page.tsx
index 5a3bedd..8a8140b 100644
--- a/src/app/recordings/play/page.tsx
+++ b/src/app/recordings/play/page.tsx
@@ -1,7 +1,7 @@
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { requirePageAccess } from "@/lib/access";
-import { allCameraRecords } from "@/lib/camera-registry";
+import { allCameraRecords, cameraName as nameOf } from "@/lib/camera-registry";
import { cameraIdFromFolder, clipFile, listClips } from "@/lib/recordings";
import ClipPlayer from "./clip-player";
@@ -20,7 +20,7 @@ async function clipFor(id: string) {
async function cameraName(cameraId: string): Promise {
const camera = (await allCameraRecords()).find((c) => c.id === cameraId);
- return camera?.name ?? (camera ? `${camera.host}:${camera.port}` : "Unknown camera");
+ return camera ? nameOf(camera, `${camera.host}:${camera.port}`) : "Unknown camera";
}
export async function generateMetadata({ searchParams }: PageProps<"/recordings/play">): Promise {
diff --git a/src/lib/camera-name.ts b/src/lib/camera-name.ts
new file mode 100644
index 0000000..60facdf
--- /dev/null
+++ b/src/lib/camera-name.ts
@@ -0,0 +1,19 @@
+import { z } from "zod";
+
+/**
+ * How a camera is named (vrek iss-t2bmvgj). Plain, shared code: the dashboard and the
+ * camera page name cameras in the browser, so this can't live in the registry, which is
+ * server-only.
+ */
+
+/** A nickname, then what the camera calls itself; the model is the caller's last resort. */
+export function cameraName(camera: { name?: string; nickname?: string }, model?: string): string {
+ return camera.nickname || camera.name || model || "Unnamed camera";
+}
+
+export const nicknameSchema = z
+ .string()
+ .trim()
+ .max(64, "64 characters at most")
+ .regex(/^[^\p{Cc}]*$/u, "No control characters")
+ .transform((value) => value || undefined);
diff --git a/src/lib/camera-registry.test.ts b/src/lib/camera-registry.test.ts
index f24a189..01516cf 100644
--- a/src/lib/camera-registry.test.ts
+++ b/src/lib/camera-registry.test.ts
@@ -47,6 +47,62 @@ describe("isValidCameraId", () => {
});
});
+describe("cameraName (iss-t2bmvgj)", () => {
+ it("prefers the name given here, then the camera's own, then the model", async () => {
+ const { cameraName } = await load();
+ expect(cameraName({ nickname: "Front door", name: "I91ET" }, "DS-2CD")).toBe("Front door");
+ expect(cameraName({ name: "I91ET" }, "DS-2CD")).toBe("I91ET");
+ expect(cameraName({}, "DS-2CD")).toBe("DS-2CD");
+ expect(cameraName({})).toBe("Unnamed camera");
+ // An emptied nickname is not a name.
+ expect(cameraName({ nickname: "", name: "I91ET" })).toBe("I91ET");
+ });
+});
+
+describe("setCameraNickname (iss-t2bmvgj)", () => {
+ it("names a camera here, and a later scan leaves that name alone", async () => {
+ const { recordDiscovered, setCameraNickname, getCameraRecord, listCameras } = await load();
+ await recordDiscovered([found({ name: "I91ET" })]);
+ const id = found().id;
+
+ await setCameraNickname(id, "Front door");
+ expect((await getCameraRecord(id))?.nickname).toBe("Front door");
+
+ await recordDiscovered([found({ name: "I91ET", hostname: "192.168.1.99" })]);
+ const after = await getCameraRecord(id);
+ expect(after).toMatchObject({ nickname: "Front door", name: "I91ET", host: "192.168.1.99" });
+
+ // The dashboard orders by what it shows, so the nickname decides.
+ expect((await listCameras())[0].nickname).toBe("Front door");
+ });
+
+ it("clears the name, falling back to the camera's own", async () => {
+ const { recordDiscovered, setCameraNickname, getCameraRecord, cameraName } = await load();
+ await recordDiscovered([found({ name: "I91ET" })]);
+ await setCameraNickname(found().id, "Front door");
+ await setCameraNickname(found().id, "");
+
+ const record = (await getCameraRecord(found().id))!;
+ expect(record.nickname).toBeUndefined();
+ expect(cameraName(record)).toBe("I91ET");
+ });
+
+ it("refuses a camera it doesn't know", async () => {
+ const { setCameraNickname } = await load();
+ await expect(setCameraNickname(found().id, "Nope")).rejects.toThrow("Unknown camera");
+ });
+});
+
+describe("nicknameSchema", () => {
+ it("accepts a plain name and trims it, and refuses nonsense", async () => {
+ const { nicknameSchema } = await load();
+ expect(nicknameSchema.parse(" Front door ")).toBe("Front door");
+ expect(nicknameSchema.parse("")).toBeUndefined();
+ expect(nicknameSchema.safeParse("x".repeat(65)).success).toBe(false);
+ expect(nicknameSchema.safeParse("bad\nname").success).toBe(false);
+ });
+});
+
describe("registry", () => {
it("lists every camera for startup code, without the request-only connection() (iss-ws9nb88)", async () => {
const { recordDiscovered, allCameraRecords, listCameras } = await load();
diff --git a/src/lib/camera-registry.ts b/src/lib/camera-registry.ts
index 0c1ecce..ccef15f 100644
--- a/src/lib/camera-registry.ts
+++ b/src/lib/camera-registry.ts
@@ -4,6 +4,7 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import path from "node:path";
import { connection } from "next/server";
import { z } from "zod";
+import { cameraName } from "./camera-name";
import { registryFile } from "./paths";
import { CredentialStoreError, moveCredentials } from "./credential-store";
@@ -17,8 +18,11 @@ export interface CameraRecord {
urn: string;
host: string;
port: number;
+ /** What the camera calls itself, from its ONVIF scopes. */
name?: string;
location?: string;
+ /** What we call it here, which wins wherever a camera is named (vrek iss-t2bmvgj). */
+ nickname?: string;
lastSeen: string;
}
@@ -29,6 +33,7 @@ export interface CameraSummary {
port: number;
name?: string;
location?: string;
+ nickname?: string;
lastSeen: string;
}
@@ -89,17 +94,18 @@ export async function allCameraRecords(): Promise {
export async function listCameras(): Promise {
await connection();
return Object.values(await load())
- .map(({ id, host, port, name, location, lastSeen }) => ({
+ .map(({ id, host, port, name, location, nickname, lastSeen }) => ({
id,
host,
port,
name,
+ nickname,
location,
lastSeen,
}))
.sort(
(a, b) =>
- (a.name ?? "").localeCompare(b.name ?? "") ||
+ cameraName(a).localeCompare(cameraName(b)) ||
a.host.localeCompare(b.host, undefined, { numeric: true }),
);
}
@@ -119,6 +125,8 @@ export async function recordDiscovered(
const now = new Date().toISOString();
for (const cam of cameras) {
registry[cam.id] = {
+ // A scan says what the camera calls itself; what we call it here is ours to keep.
+ nickname: registry[cam.id]?.nickname,
id: cam.id,
urn: cam.urn,
host: cam.hostname,
@@ -134,3 +142,15 @@ export async function recordDiscovered(
}
await save(registry);
}
+
+/** Names a camera in this app, or clears the name with an empty string. */
+export async function setCameraNickname(id: string, nickname?: string): Promise {
+ const registry = { ...(await load()) };
+ const record = registry[id];
+ if (!record) throw new Error("Unknown camera");
+ registry[id] = { ...record, nickname: nickname || undefined };
+ await save(registry);
+ return registry[id];
+}
+
+export { cameraName, nicknameSchema } from "./camera-name";
diff --git a/src/lib/camera.test.ts b/src/lib/camera.test.ts
index 71dca31..2a6d03e 100644
--- a/src/lib/camera.test.ts
+++ b/src/lib/camera.test.ts
@@ -19,6 +19,8 @@ const fake = await vi.hoisted(async () => {
deviceInformation: (done: Callback>) => void;
snapshotUri: (options: { profileToken?: string }, done: Callback<{ uri?: string }>) => void;
streamUri?: (options: { protocol: string; profileToken?: string }, done: Callback<{ uri?: string }>) => void;
+ scopes?: () => { ScopeDef: string; ScopeItem: string }[];
+ setScopes?: (uris: string[]) => Error | null;
}
class FakeCam extends EventEmitter {
static instances: FakeCam[] = [];
@@ -40,6 +42,12 @@ const fake = await vi.hoisted(async () => {
getStreamUri(options: { protocol: string; profileToken?: string }, cb: Callback<{ uri?: string }>) {
FakeCam.script.streamUri!(options, cb);
}
+ getScopes(cb: Callback<{ ScopeDef: string; ScopeItem: string }[]>) {
+ cb(null, FakeCam.script.scopes!());
+ }
+ setScopes(uris: string[], cb: (err: Error | null) => void) {
+ cb(FakeCam.script.setScopes!(uris));
+ }
digestAuth(challenges: string[], req: { method: string; path: string }) {
return `Digest from=${challenges.length} ${req.method} ${req.path}`;
}
@@ -123,9 +131,27 @@ function healthyCamera() {
snapshotUri: (_options, done) => done(null, { uri: advertisedUri() }),
streamUri: (options, done) =>
done(null, { uri: `rtsp://camera.internal:554/Streaming/Channels/${options.profileToken}?transportmode=unicast` }),
+ scopes: () => [...scopes],
+ setScopes: (uris) => {
+ scopes = [
+ ...scopes.filter((s) => s.ScopeDef === "Fixed"),
+ ...uris.map((ScopeItem) => ({ ScopeDef: "Configurable", ScopeItem })),
+ ];
+ return null;
+ },
};
}
+/** What the camera advertises; SetScopes replaces the configurable ones (iss-h1qwke5). */
+let scopes: { ScopeDef: string; ScopeItem: string }[];
+beforeEach(() => {
+ scopes = [
+ { ScopeDef: "Fixed", ScopeItem: "onvif://www.onvif.org/type/video_encoder" },
+ { ScopeDef: "Configurable", ScopeItem: "onvif://www.onvif.org/name/I91ET" },
+ { ScopeDef: "Configurable", ScopeItem: "onvif://www.onvif.org/hardware/I91ET" },
+ ];
+});
+
beforeEach(async () => {
FakeCam.instances = [];
snapshotRequests.length = 0;
@@ -478,3 +504,33 @@ describe("rtspSourceWithLogin (iss-nk6zrzv)", () => {
await expect(camera.rtspSourceWithLogin(target(), "main")).rejects.toThrow(/SOAP fault|no stream URI/);
});
});
+
+describe("camera name and location over ONVIF (iss-h1qwke5)", () => {
+ it("reads what the camera calls itself", async () => {
+ scopes.push({ ScopeDef: "Configurable", ScopeItem: "onvif://www.onvif.org/location/Front%20door" });
+ expect(await camera.getCameraScopes(target())).toEqual({ name: "I91ET", location: "Front door" });
+ });
+
+ it("renames it, keeping the camera's other configurable scopes and leaving fixed ones alone", async () => {
+ const applied = await camera.setCameraScopes(target(), { name: "Porch", location: "Front door" });
+
+ expect(applied).toEqual({ name: "Porch", location: "Front door" });
+ const uris = scopes.map((s) => s.ScopeItem);
+ expect(uris).toContain("onvif://www.onvif.org/hardware/I91ET");
+ expect(uris).toContain("onvif://www.onvif.org/type/video_encoder"); // fixed, untouched
+ expect(uris).not.toContain("onvif://www.onvif.org/name/I91ET");
+ });
+
+ it("escapes what it sends, and changes only what it was given", async () => {
+ await camera.setCameraScopes(target(), { location: "Back garden & shed" });
+ expect(scopes.map((s) => s.ScopeItem)).toContain(
+ "onvif://www.onvif.org/location/Back%20garden%20%26%20shed",
+ );
+ expect(await camera.getCameraScopes(target())).toMatchObject({ name: "I91ET" });
+ });
+
+ it("passes a refusal from the camera to the caller", async () => {
+ FakeCam.script.setScopes = () => new Error("Sender not authorized");
+ await expect(camera.setCameraScopes(target(), { name: "Porch" })).rejects.toThrow("Sender not authorized");
+ });
+});
diff --git a/src/lib/camera.ts b/src/lib/camera.ts
index b7610a2..d606a56 100644
--- a/src/lib/camera.ts
+++ b/src/lib/camera.ts
@@ -314,3 +314,68 @@ export async function rtspSourceWithLogin(target: CameraTarget, stream: "main" |
}
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 {
+ 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 {
+ 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 {
+ 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((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);
+}