Name cameras here, and rename them on the camera

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>
This commit is contained in:
Michael Mainguy 2026-09-20 07:42:28 -05:00
parent 814079d44d
commit 10d08e3b87
18 changed files with 647 additions and 23 deletions

1
.gitignore vendored
View File

@ -55,3 +55,4 @@ next-env.d.ts
/dist/
# Packed tarballs
/*.tgz
/.next-build/

View File

@ -1205,3 +1205,11 @@
{"id":"evt-zcdagtw2xx6s","type":"edge.added","subject":"iss-h1qwke5","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-h1qwke5","to":"src/app/cameras/[id]/"},"at":"2026-09-20T12:34:25.184Z","parents":["evt-axzsk844h3t1"],"hash":"ca635a8ac594869fbe0422a108581b1ea15cdfae99cc6efbd9c81596be58cbd0"}
{"id":"evt-g7bxh85qnrn3","type":"edge.added","subject":"iss-h1qwke5","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-h1qwke5","to":"area:device"},"at":"2026-09-20T12:34:25.185Z","parents":["evt-zcdagtw2xx6s"],"hash":"85805fe53d552bfb9f0af08ca1818e45303feeca828de05b114df0269eb1d8cb"}
{"id":"evt-tfmgjtfgk9n6","type":"edge.added","subject":"iss-h1qwke5","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-h1qwke5","to":"area:config"},"at":"2026-09-20T12:34:25.186Z","parents":["evt-g7bxh85qnrn3"],"hash":"d043f5205d996e94335d4382458df46b7367029fd7fc9e3da16cb8d73e222fb4"}
{"id":"evt-kx6ewq6c2x2t","type":"node.created","subject":"ver-wv3y7xz","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"A camera can be named in this app; that name wins wherever the camera is named, survives a rescan, and clearing it falls back to the camera's own name","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-20T12:42:07.274Z","parents":["evt-tfmgjtfgk9n6"],"hash":"67dd3c5d1330e6b4c904fc6fae06d387a0e7ecdc2141104128b28cd8d8afff62"}
{"id":"evt-redvb5a89cg5","type":"edge.added","subject":"ver-wv3y7xz","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-wv3y7xz","to":"iss-t2bmvgj"},"at":"2026-09-20T12:42:07.275Z","parents":["evt-kx6ewq6c2x2t"],"hash":"cb9d494cefc57e6054549ca862e0846b13d0d64d33764f597deff4655bcad787"}
{"id":"evt-77ck1p5b2qrk","type":"verification.recorded","subject":"ver-wv3y7xz","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"nickname added to the registry record with setCameraNickname, and cameraName() in the new client-safe src/lib/camera-name.ts used by the dashboard card, camera page, live page and window title, pop-out page and the recordings list. recordDiscovered keeps an existing nickname. Tests: camera-registry.test.ts (fallback chain, a scan leaving the nickname alone while the address changes, clearing it, unknown camera, the schema), name-form.test.tsx, actions.test.ts, live/page.test.tsx. Full suite 991 tests pass, 99.63% lines; tsc, eslint and a build clean."},"at":"2026-09-20T12:42:07.276Z","parents":["evt-redvb5a89cg5"],"hash":"1ec33d613afde513500cc114b4b7f874c7f4d1e85403114661b5907b53d062c3"}
{"id":"evt-vja9qyamfnv4","type":"node.created","subject":"ver-6qnn6cd","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"The camera's own name and location can be changed over ONVIF from the camera page, keeping its other configurable scopes, reporting what actually applied, and recording the change in the audit log","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-20T12:42:11.867Z","parents":["evt-77ck1p5b2qrk"],"hash":"57b2de4b4ef320e01f348a713e3a8e069c45c4d219e81a3648c87ea6c3007f7e"}
{"id":"evt-f16r6p9a97rb","type":"edge.added","subject":"ver-6qnn6cd","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-6qnn6cd","to":"iss-h1qwke5"},"at":"2026-09-20T12:42:11.868Z","parents":["evt-vja9qyamfnv4"],"hash":"04ebcb589d3cafc56ad1bc3267799cf60b407201061c024c02b3be9012344883"}
{"id":"evt-s05mgh083ghz","type":"verification.recorded","subject":"ver-6qnn6cd","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"getCameraScopes and setCameraScopes in src/lib/camera.ts: SetScopes replaces every configurable scope, so the others are read and sent back, fixed scopes are left out, and values are URL-encoded; the result is re-read from the camera. saveCameraNames in the camera page's actions writes it, reports applied or adjusted per field, and appends to the audit log. Tests against the fake camera (camera.test.ts \"camera name and location over ONVIF\") cover keeping other scopes, encoding, changing only what was given, and a refusal; actions.test.ts covers the action. Full suite 991 tests pass; tsc, eslint and a build clean. Not yet tried against a real camera: that write needs the user's go-ahead."},"at":"2026-09-20T12:42:11.869Z","parents":["evt-f16r6p9a97rb"],"hash":"8c2fbbfabf5962ce3eb4f291b4273166c6a39309688542a05faff3be2a9c5e52"}
{"id":"evt-kaxnd2x25951","type":"node.status_changed","subject":"iss-t2bmvgj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"in_progress"},"at":"2026-09-20T12:42:13.219Z","parents":["evt-s05mgh083ghz"],"hash":"62808924dff0c7b84d9ef3d4bb4a4df0d0ad062241ae980859cbecee798b8269"}
{"id":"evt-pvbrzehmt1bc","type":"node.status_changed","subject":"iss-h1qwke5","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"in_progress"},"at":"2026-09-20T12:42:14.303Z","parents":["evt-kaxnd2x25951"],"hash":"a04519d7428434e42e4d99da3ee04d4c29bd1510f71ec6f8adfe8c9cb47e535c"}

View File

@ -26,6 +26,9 @@ const project = process.cwd();
const turbopackRoot = linkedPackagesRoot(project, ["onvif"]);
const nextConfig: NextConfig = {
// A build and a dev server sharing .next fight over it; NEXT_DIST_DIR lets a build run
// to one side while someone is working.
...(process.env.NEXT_DIST_DIR && { distDir: process.env.NEXT_DIST_DIR }),
// What gets published: a minimal server plus only the files it needs, so installing the
// package doesn't drag in Next and React (vrek iss-ej4ahga).
output: "standalone",

View File

@ -11,6 +11,7 @@ import {
import Link from "next/link";
import { useEffect, useState } from "react";
import type { CameraSummary } from "@/lib/camera-registry";
import { cameraName } from "@/lib/camera-name";
import {
useCameraInfo,
useCameraSnapshot,
@ -86,14 +87,14 @@ function Snapshot({
onClick={(e) => {
if (openPopout(cam.id, intervalMs, aspect)) e.preventDefault();
}}
aria-label={`Pop out ${cam.name ?? cam.host}`}
title={`Pop out ${cam.name ?? cam.host}`}
aria-label={`Pop out ${cameraName(cam, cam.host)}`}
title={`Pop out ${cameraName(cam, cam.host)}`}
className="absolute inset-0 block"
>
{frame && (
<FrameImage
frame={frame}
alt={cam.name ?? cam.host}
alt={cameraName(cam, cam.host)}
className={`h-full w-full object-contain ${paused || poppedOut ? "opacity-40" : ""}`}
/>
)}
@ -131,7 +132,7 @@ function usePoppedOut(id: string) {
* a name saying what it does and to which camera (pri-8ev5m89).
*/
function EditLink({ cam }: { cam: CameraSummary }) {
const name = cam.name ?? cam.host;
const name = cameraName(cam, cam.host);
return (
<Link
href={`/cameras/${cam.id}`}
@ -204,7 +205,7 @@ export default function CameraCard({
<div className="flex items-center justify-between gap-2">
<Link href={`/cameras/${cam.id}`} className="font-medium hover:underline">
{cam.name ?? info.data?.model ?? "Unnamed camera"}
{cameraName(cam, info.data?.model)}
</Link>
</div>

View File

@ -2,6 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const m = vi.hoisted(() => ({
access: vi.fn(),
setCameraScopes: vi.fn(),
setCameraNickname: vi.fn(),
appendAudit: vi.fn(),
verifySession: vi.fn(),
getCameraRecord: vi.fn(),
updateImageSettings: vi.fn(),
@ -12,13 +15,19 @@ vi.mock("@/lib/session", () => ({ verifySession: m.verifySession }));
vi.mock("@/lib/camera-registry", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/camera-registry")>()),
getCameraRecord: m.getCameraRecord,
setCameraNickname: m.setCameraNickname,
}));
vi.mock("@/lib/audit-log", () => ({ appendAudit: m.appendAudit }));
vi.mock("@/lib/camera", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/camera")>()),
setCameraScopes: m.setCameraScopes,
}));
vi.mock("@/lib/camera-config", () => ({
updateImageSettings: m.updateImageSettings,
updateStreamSettings: m.updateStreamSettings,
}));
const { saveImageSettings, saveStreamSettings } = await import("./actions");
const { saveCameraNames, saveImageSettings, saveStreamSettings } = await import("./actions");
const { SAVE_MESSAGES } = await import("./save-result");
const { CameraAuthError, CameraInactiveError } = await import("@/lib/camera");
@ -119,3 +128,68 @@ describe("saveStreamSettings", () => {
});
});
});
describe("saveCameraNames (iss-t2bmvgj, iss-h1qwke5)", () => {
beforeEach(() => {
m.setCameraNickname.mockResolvedValue(undefined);
m.appendAudit.mockResolvedValue(undefined);
m.setCameraScopes.mockImplementation(async (_t: unknown, patch: Record<string, string>) => patch);
});
it("names the camera here without touching the camera itself", async () => {
const result = await saveCameraNames(ID, { nickname: "Front door" });
expect(m.setCameraNickname).toHaveBeenCalledWith(ID, "Front door");
expect(m.setCameraScopes).not.toHaveBeenCalled();
expect(result).toEqual({
ok: true,
outcome: "applied",
fields: [{ field: "nickname", requested: "Front door", actual: "Front door", applied: true }],
});
expect(m.appendAudit).toHaveBeenCalledWith(
expect.objectContaining({ cameraId: ID, actor: "admin", section: "name", outcome: "applied" }),
);
});
it("clears the name here when given an empty one", async () => {
await saveCameraNames(ID, { nickname: "" });
expect(m.setCameraNickname).toHaveBeenCalledWith(ID, undefined);
});
it("writes the camera's own name and location to the camera", async () => {
const result = await saveCameraNames(ID, { name: "Porch", location: "Front door" });
expect(m.setCameraScopes).toHaveBeenCalledWith(target, { name: "Porch", location: "Front door" });
expect(m.setCameraNickname).not.toHaveBeenCalled();
expect(result).toMatchObject({ ok: true, outcome: "applied" });
});
it("says so when the camera kept something different", async () => {
m.setCameraScopes.mockResolvedValue({ name: "PORCH" });
const result = await saveCameraNames(ID, { name: "Porch" });
expect(result).toEqual({
ok: true,
outcome: "adjusted",
fields: [{ field: "name", requested: "Porch", actual: "PORCH", applied: false }],
});
});
it("refuses a name that isn't one, without writing anything", async () => {
const result = await saveCameraNames(ID, { nickname: "x".repeat(65) });
expect(result).toMatchObject({ ok: false, reason: "invalid" });
expect(m.setCameraNickname).not.toHaveBeenCalled();
});
it("explains a camera that refuses the rename", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
m.setCameraScopes.mockRejectedValue(new Error("Sender not authorized"));
const result = await saveCameraNames(ID, { name: "Porch" });
expect(result).toEqual({ ok: false, reason: "camera", message: SAVE_MESSAGES.unreachable });
});
it("refuses when signed out", async () => {
m.access.mockResolvedValue("signed-out");
expect(await saveCameraNames(ID, { nickname: "Front door" })).toMatchObject({ reason: "signed-out" });
expect(m.setCameraNickname).not.toHaveBeenCalled();
});
});

View File

@ -2,9 +2,21 @@
import { z } from "zod";
import { access } from "@/lib/access";
import { CameraAuthError, CameraInactiveError, isAllowedHost, type CameraTarget } from "@/lib/camera";
import { updateImageSettings, updateStreamSettings } from "@/lib/camera-config";
import { cameraIdSchema, getCameraRecord } from "@/lib/camera-registry";
import {
CameraAuthError,
CameraInactiveError,
isAllowedHost,
setCameraScopes,
type CameraTarget,
} from "@/lib/camera";
import { updateImageSettings, updateStreamSettings, type FieldOutcome } from "@/lib/camera-config";
import {
cameraIdSchema,
getCameraRecord,
nicknameSchema,
setCameraNickname,
} from "@/lib/camera-registry";
import { appendAudit } from "@/lib/audit-log";
import { verifySession } from "@/lib/session";
import { SAVE_MESSAGES, type SaveResult } from "./save-result";
@ -74,3 +86,81 @@ export async function saveStreamSettings(
return cameraProblem(err);
}
}
/**
* Names a camera (vrek iss-t2bmvgj, iss-h1qwke5). The nickname is ours and is written to
* the registry; the name and location are the camera's own and go to it over ONVIF, which
* is what other software on the network sees. Either may be left out.
*/
export async function saveCameraNames(
cameraId: string,
patch: { nickname?: string; name?: string; location?: string },
): Promise<SaveResult> {
const target = await resolve(cameraId);
if ("ok" in target) return target;
const fields = z
.object({
nickname: nicknameSchema.optional(),
name: nicknameSchema.optional(),
location: nicknameSchema.optional(),
})
.safeParse(patch);
if (!fields.success) {
return {
ok: false,
reason: "invalid",
message: "That name can't be used",
issues: fields.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`),
};
}
const { nickname, name, location } = fields.data;
const record = await getCameraRecord(target.id);
const who = await actor();
const outcomes: FieldOutcome[] = [];
if ("nickname" in patch) {
await setCameraNickname(target.id, nickname);
outcomes.push({ field: "nickname", requested: nickname ?? null, actual: nickname ?? null, applied: true });
await appendAudit({
cameraId: target.id,
actor: who,
section: "name",
outcome: "applied",
changes: [{ field: "nickname", from: record?.nickname ?? null, to: nickname ?? null }],
});
}
if (name !== undefined || location !== undefined) {
try {
// The camera decides what sticks, so report what it says afterwards.
const applied = await setCameraScopes(target, { name, location });
const asked = { name, location };
for (const field of ["name", "location"] as const) {
if (asked[field] === undefined) continue;
outcomes.push({
field,
requested: asked[field],
actual: applied[field] ?? null,
applied: applied[field] === asked[field],
});
}
await appendAudit({
cameraId: target.id,
actor: who,
section: "name",
outcome: outcomes.every((o) => o.applied) ? "applied" : "adjusted",
changes: outcomes.map((o) => ({ field: o.field, from: record?.[o.field as "name" | "location"] ?? null, to: o.requested, actual: o.actual })),
});
} catch (err) {
return cameraProblem(err);
}
}
return {
ok: true,
outcome: outcomes.every((o) => o.applied) ? "applied" : "adjusted",
fields: outcomes,
};
}

View File

@ -68,10 +68,14 @@ describe("/cameras/[id]/live", () => {
);
});
it("titles the window after the camera", async () => {
it("titles the window after the camera, preferring the name given here", async () => {
expect(await generateMetadata(props())).toEqual({ title: "I91ET · live" });
getCameraRecord.mockResolvedValue({ ...record, nickname: "Front door" });
expect(await generateMetadata(props())).toEqual({ title: "Front door · live" });
getCameraRecord.mockResolvedValue({ ...record, name: undefined });
expect(await generateMetadata(props())).toEqual({ title: "Camera · live" });
expect(await generateMetadata(props())).toEqual({ title: "Unnamed camera · live" });
});
it.each([

View File

@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { requirePageAccess } from "@/lib/access";
import { isAllowedHost } from "@/lib/camera";
import { getCameraRecord, isValidCameraId } from "@/lib/camera-registry";
import { cameraName, getCameraRecord, isValidCameraId } from "@/lib/camera-registry";
import { refreshMsSchema } from "../../../refresh-rate";
import LiveView from "./live-view";
@ -16,7 +16,7 @@ async function cameraFor(id: string) {
export async function generateMetadata({ params }: PageProps<"/cameras/[id]/live">): Promise<Metadata> {
await requirePageAccess();
const record = await cameraFor((await params).id);
return { title: `${record.name ?? "Camera"} · live` };
return { title: `${cameraName(record)} · live` };
}
/**
@ -30,7 +30,7 @@ export default async function LivePage({ params, searchParams }: PageProps<"/cam
return (
<main className="relative flex-1 bg-black font-sans text-white">
<LiveView id={record.id} name={record.name ?? "Unnamed camera"} intervalMs={refreshMsSchema.parse(refresh)} />
<LiveView id={record.id} name={cameraName(record)} intervalMs={refreshMsSchema.parse(refresh)} />
</main>
);
}

View File

@ -0,0 +1,92 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CameraRecord } from "@/lib/camera-registry";
const saveCameraNames = vi.fn();
vi.mock("./actions", () => ({ saveCameraNames }));
const refresh = vi.fn();
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh }) }));
const { default: NameForm } = await import("./name-form");
afterEach(cleanup);
const record: CameraRecord = {
id: "22ec0000-8b90-11b5-845d-d03bf404af54",
urn: "urn:uuid:22ec0000-8b90-11b5-845d-d03bf404af54",
host: "192.168.17.129",
port: 80,
name: "I91ET",
lastSeen: "",
};
const applied = { ok: true, outcome: "applied", fields: [] };
beforeEach(() => {
saveCameraNames.mockReset().mockResolvedValue(applied);
refresh.mockReset();
});
const open = (only: CameraRecord = record) => {
render(<NameForm record={only} />);
fireEvent.click(screen.getByRole("button", { name: "Rename" }));
};
describe("NameForm", () => {
it("offers both names: ours and the camera's own", () => {
open();
expect((screen.getByLabelText(/Name in this app/) as HTMLInputElement).value).toBe("");
expect(screen.getByLabelText(/Name in this app/).getAttribute("placeholder")).toBe("I91ET");
expect((screen.getByLabelText(/Name on the camera/) as HTMLInputElement).value).toBe("I91ET");
expect(screen.getByLabelText(/Location on the camera/)).toBeTruthy();
});
it("sends only the name kept here when that is all that changed", async () => {
open();
fireEvent.change(screen.getByLabelText(/Name in this app/), { target: { value: "Front door" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(saveCameraNames).toHaveBeenCalledWith(record.id, { nickname: "Front door" }));
await waitFor(() => expect(refresh).toHaveBeenCalled());
expect(screen.getByRole("status").textContent).toContain("Saved.");
});
it("sends the camera's own name and location only when they change", async () => {
open();
fireEvent.change(screen.getByLabelText(/Name on the camera/), { target: { value: "Porch" } });
fireEvent.change(screen.getByLabelText(/Location on the camera/), { target: { value: "Drive" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(saveCameraNames).toHaveBeenCalledWith(record.id, {
nickname: "",
name: "Porch",
location: "Drive",
}),
);
});
it("says when the camera kept something different", async () => {
saveCameraNames.mockResolvedValue({ ok: true, outcome: "adjusted", fields: [] });
open();
fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect((await screen.findByRole("status")).textContent).toContain("the camera adjusted it");
});
it("keeps the form open and says why when a save fails", async () => {
saveCameraNames.mockResolvedValue({ ok: false, reason: "camera", message: "Couldn't reach the camera." });
open();
fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect((await screen.findByRole("alert")).textContent).toContain("Couldn't reach the camera.");
expect(screen.getByLabelText(/Name in this app/)).toBeTruthy();
expect(refresh).not.toHaveBeenCalled();
});
it("can be cancelled without saving", () => {
open();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.queryByLabelText(/Name in this app/)).toBeNull();
expect(saveCameraNames).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,128 @@
"use client";
import { CircleAlert, CircleCheck, LoaderCircle, Pencil, Save, X } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import type { CameraRecord } from "@/lib/camera-registry";
import { saveCameraNames } from "./actions";
import type { SaveResult } from "./save-result";
/**
* Names a camera (vrek iss-t2bmvgj, iss-h1qwke5). The nickname is kept by this app and
* wins wherever the camera is named; the name and location belong to the camera itself and
* are what everything else on the network sees.
*/
export default function NameForm({ record }: { record: CameraRecord }) {
const router = useRouter();
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [result, setResult] = useState<SaveResult | null>(null);
const [form, setForm] = useState({
nickname: record.nickname ?? "",
name: record.name ?? "",
location: record.location ?? "",
});
async function submit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setResult(null);
const patch: { nickname?: string; name?: string; location?: string } = { nickname: form.nickname };
if (form.name !== (record.name ?? "")) patch.name = form.name;
if (form.location !== (record.location ?? "")) patch.location = form.location;
const saved = await saveCameraNames(record.id, patch);
setResult(saved);
setSaving(false);
if (saved.ok) {
setEditing(false);
router.refresh();
}
}
if (!editing) {
return (
<div className="flex flex-wrap items-center gap-3 text-sm">
<button onClick={() => setEditing(true)} className="inline-flex items-center gap-1 underline">
<Pencil aria-hidden className="size-4" />
Rename
</button>
{result?.ok && (
<span role="status" className="inline-flex items-center gap-1.5 text-zinc-600 dark:text-zinc-400">
<CircleCheck aria-hidden className="size-4" />
{result.outcome === "applied" ? "Saved." : "Saved, but the camera adjusted it."}
</span>
)}
</div>
);
}
return (
<form onSubmit={submit} className="flex w-full flex-col gap-2 text-sm sm:w-96">
<label className="flex flex-col gap-1">
Name in this app
<input
value={form.nickname}
onChange={(e) => setForm({ ...form, nickname: e.target.value })}
placeholder={record.name ?? "Unnamed camera"}
maxLength={64}
className="rounded border border-zinc-300 bg-transparent px-2 py-1 dark:border-zinc-700"
/>
<span className="text-xs text-zinc-500">Shown everywhere here. Leave empty to use the camera&apos;s own name.</span>
</label>
<label className="flex flex-col gap-1">
Name on the camera
<input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
maxLength={64}
className="rounded border border-zinc-300 bg-transparent px-2 py-1 dark:border-zinc-700"
/>
</label>
<label className="flex flex-col gap-1">
Location on the camera
<input
value={form.location}
onChange={(e) => setForm({ ...form, location: e.target.value })}
maxLength={64}
className="rounded border border-zinc-300 bg-transparent px-2 py-1 dark:border-zinc-700"
/>
<span className="text-xs text-zinc-500">
These two are the camera&apos;s own, so anything else on your network sees them too.
</span>
</label>
{result && !result.ok && (
<p role="alert" className="inline-flex items-center gap-1.5 text-red-600">
<CircleAlert aria-hidden className="size-4 shrink-0" />
{result.message}
</p>
)}
<div className="flex items-center gap-2">
<button
type="submit"
disabled={saving}
className="inline-flex items-center gap-1.5 rounded bg-black px-3 py-1 text-white disabled:opacity-50 dark:bg-white dark:text-black"
>
{saving ? (
<LoaderCircle aria-hidden className="size-4 animate-spin motion-reduce:animate-none" />
) : (
<Save aria-hidden className="size-4" />
)}
{saving ? "Saving…" : "Save"}
</button>
<button
type="button"
onClick={() => setEditing(false)}
className="inline-flex items-center gap-1 underline"
>
<X aria-hidden className="size-4" />
Cancel
</button>
</div>
</form>
);
}

View File

@ -4,9 +4,10 @@ import { notFound } from "next/navigation";
import { Suspense } from "react";
import { requirePageAccess } from "@/lib/access";
import { isAllowedHost } from "@/lib/camera";
import { getCameraRecord, isValidCameraId } from "@/lib/camera-registry";
import { cameraName, getCameraRecord, isValidCameraId } from "@/lib/camera-registry";
import { RefreshButton } from "./client-controls";
import CameraLoginControls from "./camera-login-controls";
import NameForm from "./name-form";
import LivePanel from "./live-panel";
import { CameraSettingsSections } from "./settings-sections";
@ -33,12 +34,18 @@ export default async function CameraPage({ params }: PageProps<"/cameras/[id]">)
<div>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<Cctv aria-hidden className="size-6" />
{record.name ?? "Unnamed camera"}
{cameraName(record)}
</h1>
<p className="font-mono text-sm text-zinc-600 dark:text-zinc-400">
{record.host}:{record.port}
{record.location ? <span className="font-sans"> · {record.location}</span> : null}
{record.nickname && record.name ? (
<span className="font-sans"> · the camera calls itself {record.name}</span>
) : null}
</p>
<div className="mt-2">
<NameForm record={record} />
</div>
</div>
<div className="flex flex-col items-end gap-2">
<div className="flex items-center gap-3">
@ -65,7 +72,7 @@ export default async function CameraPage({ params }: PageProps<"/cameras/[id]">)
</div>
<div className="mt-6">
<LivePanel cameraId={record.id} name={record.name ?? "Unnamed camera"} />
<LivePanel cameraId={record.id} name={cameraName(record)} />
</div>
<div className="mt-6">

View File

@ -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 (

View File

@ -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<string> {
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<Metadata> {

19
src/lib/camera-name.ts Normal file
View File

@ -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);

View File

@ -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();

View File

@ -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<CameraRecord[]> {
export async function listCameras(): Promise<CameraSummary[]> {
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<CameraRecord> {
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";

View File

@ -19,6 +19,8 @@ const fake = await vi.hoisted(async () => {
deviceInformation: (done: Callback<Record<string, string>>) => 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");
});
});

View File

@ -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<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);
}