Add ONVIF camera app with a server-rendered camera dashboard
Discovers ONVIF cameras (Hikvision/Annke) over WS-Discovery, keeps a server-side registry and an encrypted credential store, and serves info, snapshot and credentials routes for each camera. The home page is now a Server Component that lists known cameras from the registry on load, without a scan (vrek iss-a0hz0py). The snapshot refresh rate lives in the URL (?refresh=), and a scan refreshes the server-rendered list. Route input is validated with zod (iss-rjqy3hy), and /api/discover no longer returns raw error messages (iss-dbwgww8). Adds @tanstack/react-query and zod as dependencies, with a QueryClient provider in the root layout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5e73d3ffe0
commit
f39d16a8c0
4
.gitignore
vendored
4
.gitignore
vendored
@ -39,3 +39,7 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# camera credentials store (passwords)
|
||||
/.data/
|
||||
/snapshot.jpg
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
// onvif uses Node's dgram/os modules; load it with native require instead of bundling.
|
||||
serverExternalPackages: ["onvif"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
82
package-lock.json
generated
82
package-lock.json
generated
@ -8,9 +8,13 @@
|
||||
"name": "cameras",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.103.1",
|
||||
"next": "16.3.5",
|
||||
"onvif": "^0.8.3",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
"react-dom": "19.2.8",
|
||||
"server-only": "^0.0.1",
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@ -1676,6 +1680,32 @@
|
||||
"tailwindcss": "4.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.103.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.103.1.tgz",
|
||||
"integrity": "sha512-rms8HqTGp6zA00dM+cUQ2eBcgzNJefuu5CAMB37i/6MiGT1zulPOytCFu2a0qjLqVR2n1jENPj9woqFQNuCzWA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.103.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.103.1.tgz",
|
||||
"integrity": "sha512-rmAPPApNEK17VXRJhtE1rja53n23VV5w30C0saCgJhhX+EpdUnVqMGV/s5nnzV43X75KTHKzuoiuxSzGTBIkag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.103.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
|
||||
@ -5504,6 +5534,18 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/onvif": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/onvif/-/onvif-0.8.3.tgz",
|
||||
"integrity": "sha512-55gkQKRHVehU4tZWR/dy6ALLU9a6A3YfmX6da/joLFfNtH3YSLaw2yuioGLC8H5ZjHqY8I9UDSboPiQqEFL+tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@ -5930,6 +5972,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
|
||||
"integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
@ -5946,6 +5997,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/server-only": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz",
|
||||
"integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
@ -6848,6 +6905,28 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xml2js": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
|
||||
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sax": ">=0.6.0",
|
||||
"xmlbuilder": "~11.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder": {
|
||||
"version": "11.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
|
||||
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
@ -6872,7 +6951,6 @@
|
||||
"version": "4.6.5",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
|
||||
"integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
|
||||
@ -9,9 +9,13 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.103.1",
|
||||
"next": "16.3.5",
|
||||
"onvif": "^0.8.3",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
"react-dom": "19.2.8",
|
||||
"server-only": "^0.0.1",
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
35
scripts/camera-check.ts
Normal file
35
scripts/camera-check.ts
Normal file
@ -0,0 +1,35 @@
|
||||
// Checks ONVIF login, profiles and snapshot for one camera, outside of Next.js.
|
||||
// The camera must be in .data/cameras.json (run a scan in the app first).
|
||||
// Usage: npx tsx --env-file=.env.local --conditions=react-server scripts/camera-check.ts <id-or-ip>
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { getCameraInfo, getSnapshot } from "../src/lib/camera";
|
||||
import type { CameraRecord } from "../src/lib/camera-registry";
|
||||
|
||||
const [idOrHost] = process.argv.slice(2);
|
||||
if (!idOrHost) {
|
||||
console.error("Usage: scripts/camera-check.ts <id-or-ip>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const registry: Record<string, CameraRecord> = JSON.parse(
|
||||
readFileSync(".data/cameras.json", "utf8"),
|
||||
);
|
||||
const record = Object.values(registry).find((r) => r.id === idOrHost || r.host === idOrHost);
|
||||
if (!record) {
|
||||
console.error(`${idOrHost} is not in .data/cameras.json; scan the network first.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const target = { id: record.id, host: record.host, port: record.port };
|
||||
console.log(`Camera ${record.id} at ${record.host}:${record.port}`);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
console.log(JSON.stringify(await getCameraInfo(target), null, 2));
|
||||
const snap = await getSnapshot(target);
|
||||
writeFileSync("snapshot.jpg", snap.body);
|
||||
console.log(`Snapshot: ${snap.contentType}, ${snap.body.length} bytes -> snapshot.jpg`);
|
||||
} catch (err) {
|
||||
console.error(`Error (${(err as Error).name}):`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
process.exit(0);
|
||||
})();
|
||||
100
scripts/probe-debug.mjs
Normal file
100
scripts/probe-debug.mjs
Normal file
@ -0,0 +1,100 @@
|
||||
// WS-Discovery diagnostic: sends several Probe variants and listens both on the
|
||||
// sending socket (unicast replies) and on 239.255.255.250:3702 (multicast replies).
|
||||
// Usage: node scripts/probe-debug.mjs [interfaceName] [timeoutMs] [targetIp]
|
||||
// With targetIp, probes are sent by unicast to targetIp:3702 instead of multicast.
|
||||
import dgram from "node:dgram";
|
||||
import os from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const ifaceName = process.argv[2];
|
||||
const timeoutMs = Number(process.argv[3]) || 5000;
|
||||
const target = process.argv[4];
|
||||
const GROUP = "239.255.255.250";
|
||||
const PORT = 3702;
|
||||
|
||||
const ifaces = os.networkInterfaces();
|
||||
const candidates = Object.entries(ifaces).flatMap(([name, addrs]) =>
|
||||
(addrs ?? [])
|
||||
.filter((a) => a.family === "IPv4" && !a.internal)
|
||||
.map((a) => ({ name, address: a.address })),
|
||||
);
|
||||
console.log("IPv4 interfaces:", candidates.map((c) => `${c.name}=${c.address}`).join(", "));
|
||||
const iface = ifaceName ? candidates.find((c) => c.name === ifaceName) : candidates[0];
|
||||
if (!iface) {
|
||||
console.error(`No IPv4 interface ${ifaceName ?? ""} found`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Using ${iface.name} (${iface.address}), timeout ${timeoutMs}ms\n`);
|
||||
|
||||
const probe = (types) => {
|
||||
const id = `urn:uuid:${randomUUID()}`;
|
||||
return {
|
||||
id,
|
||||
xml:
|
||||
`<?xml version="1.0" encoding="UTF-8"?>` +
|
||||
`<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" ` +
|
||||
`xmlns:a="http://schemas.xmlsoap.org/ws/2004/08/addressing" ` +
|
||||
`xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery" ` +
|
||||
`xmlns:dn="http://www.onvif.org/ver10/network/wsdl" ` +
|
||||
`xmlns:tds="http://www.onvif.org/ver10/device/wsdl">` +
|
||||
`<s:Header><a:MessageID>${id}</a:MessageID>` +
|
||||
`<a:To s:mustUnderstand="1">urn:schemas-xmlsoap-org:ws:2005:04:discovery</a:To>` +
|
||||
`<a:Action s:mustUnderstand="1">http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe</a:Action>` +
|
||||
`</s:Header><s:Body><d:Probe>${types ? `<d:Types>${types}</d:Types>` : ""}</d:Probe></s:Body>` +
|
||||
`</s:Envelope>`,
|
||||
};
|
||||
};
|
||||
const variants = {
|
||||
"NetworkVideoTransmitter": probe("dn:NetworkVideoTransmitter"),
|
||||
"tds:Device": probe("tds:Device"),
|
||||
"no Types": probe(null),
|
||||
};
|
||||
|
||||
const seen = new Set();
|
||||
function report(via) {
|
||||
return (msg, rinfo) => {
|
||||
const text = msg.toString();
|
||||
if (/<[^>]*:?Probe>/.test(text) && !/ProbeMatch/.test(text)) return; // our own / others' probes
|
||||
const relates = text.match(/RelatesTo[^>]*>([^<]+)</)?.[1];
|
||||
const variant = Object.entries(variants).find(([, v]) => v.id === relates)?.[0] ?? "unknown";
|
||||
const xaddrs = text.match(/XAddrs[^>]*>([^<]+)</)?.[1];
|
||||
const key = `${via}|${rinfo.address}|${variant}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
console.log(`REPLY via ${via} from ${rinfo.address}:${rinfo.port} to probe "${variant}"`);
|
||||
console.log(` XAddrs: ${xaddrs ?? "(none)"}`);
|
||||
if (!xaddrs) console.log(` raw: ${text.slice(0, 500)}`);
|
||||
};
|
||||
}
|
||||
|
||||
const unicast = dgram.createSocket({ type: "udp4", reuseAddr: true });
|
||||
unicast.on("message", report("unicast socket"));
|
||||
unicast.on("error", (e) => console.error("unicast socket error:", e));
|
||||
|
||||
const mcast = dgram.createSocket({ type: "udp4", reuseAddr: true });
|
||||
mcast.on("message", report("multicast 3702"));
|
||||
mcast.on("error", (e) => console.error(`multicast listener error (port ${PORT} busy?):`, e.message));
|
||||
mcast.bind(PORT, () => {
|
||||
try {
|
||||
mcast.addMembership(GROUP, iface.address);
|
||||
} catch (e) {
|
||||
console.error("addMembership failed:", e.message);
|
||||
}
|
||||
});
|
||||
|
||||
unicast.bind(0, iface.address, () => {
|
||||
unicast.setMulticastInterface(iface.address);
|
||||
unicast.setMulticastTTL(4);
|
||||
console.log(`Sending from ${iface.address}:${unicast.address().port}`);
|
||||
for (const [name, { xml }] of Object.entries(variants)) {
|
||||
unicast.send(xml, PORT, target ?? GROUP, (err) =>
|
||||
console.log(`sent "${name}" to ${target ?? GROUP}${err ? ` ERROR ${err.message}` : ""}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
console.log(`\nDone. ${seen.size} reply(ies).`);
|
||||
unicast.close();
|
||||
mcast.close();
|
||||
}, timeoutMs);
|
||||
56
src/app/api/cameras/[id]/credentials/route.ts
Normal file
56
src/app/api/cameras/[id]/credentials/route.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { resetConnection, testCredentials } from "@/lib/camera";
|
||||
import { cameraErrorResponse, cameraTarget } from "@/lib/camera-route";
|
||||
import {
|
||||
credentialsSchema,
|
||||
deleteCredentials,
|
||||
describeCredentials,
|
||||
setCredentials,
|
||||
} from "@/lib/credential-store";
|
||||
|
||||
// Passwords are write-only: GET reports whether one is set, never its value.
|
||||
|
||||
export async function GET(_request: Request, ctx: RouteContext<"/api/cameras/[id]/credentials">) {
|
||||
const target = await cameraTarget(ctx.params);
|
||||
if (target instanceof Response) return target;
|
||||
try {
|
||||
return Response.json(await describeCredentials(target.id));
|
||||
} catch (err) {
|
||||
return cameraErrorResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request, ctx: RouteContext<"/api/cameras/[id]/credentials">) {
|
||||
const target = await cameraTarget(ctx.params);
|
||||
if (target instanceof Response) return target;
|
||||
|
||||
const parsed = credentialsSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return Response.json({ error: "Username is required" }, { status: 400 });
|
||||
}
|
||||
const { username, password } = parsed.data;
|
||||
|
||||
// Only store a login the camera actually accepts.
|
||||
try {
|
||||
await testCredentials(target, { username, password });
|
||||
await setCredentials(target.id, { username, password });
|
||||
resetConnection(target);
|
||||
return Response.json(await describeCredentials(target.id));
|
||||
} catch (err) {
|
||||
return cameraErrorResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
ctx: RouteContext<"/api/cameras/[id]/credentials">,
|
||||
) {
|
||||
const target = await cameraTarget(ctx.params);
|
||||
if (target instanceof Response) return target;
|
||||
try {
|
||||
await deleteCredentials(target.id);
|
||||
resetConnection(target);
|
||||
return Response.json(await describeCredentials(target.id));
|
||||
} catch (err) {
|
||||
return cameraErrorResponse(err);
|
||||
}
|
||||
}
|
||||
13
src/app/api/cameras/[id]/info/route.ts
Normal file
13
src/app/api/cameras/[id]/info/route.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { getCameraInfo } from "@/lib/camera";
|
||||
import { cameraErrorResponse, cameraTarget } from "@/lib/camera-route";
|
||||
|
||||
export async function GET(_request: Request, ctx: RouteContext<"/api/cameras/[id]/info">) {
|
||||
const target = await cameraTarget(ctx.params);
|
||||
if (target instanceof Response) return target;
|
||||
|
||||
try {
|
||||
return Response.json(await getCameraInfo(target));
|
||||
} catch (err) {
|
||||
return cameraErrorResponse(err);
|
||||
}
|
||||
}
|
||||
17
src/app/api/cameras/[id]/snapshot/route.ts
Normal file
17
src/app/api/cameras/[id]/snapshot/route.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { getSnapshot } from "@/lib/camera";
|
||||
import { cameraErrorResponse, cameraTarget } from "@/lib/camera-route";
|
||||
|
||||
export async function GET(request: Request, ctx: RouteContext<"/api/cameras/[id]/snapshot">) {
|
||||
const target = await cameraTarget(ctx.params);
|
||||
if (target instanceof Response) return target;
|
||||
const profile = new URL(request.url).searchParams.get("profile") ?? undefined;
|
||||
|
||||
try {
|
||||
const { contentType, body } = await getSnapshot(target, profile);
|
||||
return new Response(new Uint8Array(body), {
|
||||
headers: { "Content-Type": contentType, "Cache-Control": "no-store" },
|
||||
});
|
||||
} catch (err) {
|
||||
return cameraErrorResponse(err);
|
||||
}
|
||||
}
|
||||
22
src/app/api/discover/route.ts
Normal file
22
src/app/api/discover/route.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { recordDiscovered } from "@/lib/camera-registry";
|
||||
import { discoverCameras, discoverRequestSchema } from "@/lib/onvif";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// An empty body means "use the defaults".
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = discoverRequestSchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
return Response.json({ error: "Invalid scan options" }, { status: 400 });
|
||||
}
|
||||
const { timeout, unicastSweep } = parsed.data;
|
||||
|
||||
try {
|
||||
const cameras = await discoverCameras({ timeoutMs: timeout, unicastSweep });
|
||||
await recordDiscovered(cameras);
|
||||
return Response.json({ cameras });
|
||||
} catch (err) {
|
||||
// OS and socket errors can carry interface names and addresses; keep them in the server log.
|
||||
console.error("Network scan failed", err);
|
||||
return Response.json({ error: "Network scan failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
359
src/app/camera-card.tsx
Normal file
359
src/app/camera-card.tsx
Normal file
@ -0,0 +1,359 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { CameraInfo } from "@/lib/camera";
|
||||
import type { CameraSummary } from "@/lib/camera-registry";
|
||||
|
||||
type Problem = { kind: "inactive" | "auth" | "error"; message: string };
|
||||
|
||||
const DEFAULT_USERNAME = "camera";
|
||||
|
||||
async function problemFrom(res: Response): Promise<Problem> {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const kind =
|
||||
data.code === "inactive" ? "inactive" : res.status === 401 || data.code === "auth" ? "auth" : "error";
|
||||
return { kind, message: data.error ?? `HTTP ${res.status}` };
|
||||
}
|
||||
|
||||
function apiUrl(cam: CameraSummary, path: string) {
|
||||
return `/api/cameras/${cam.id}/${path}`;
|
||||
}
|
||||
|
||||
/** The camera's own web interface; it can't be framed (X-Frame-Options: SAMEORIGIN). */
|
||||
function webUiUrl(cam: CameraSummary) {
|
||||
return `http://${cam.host}${cam.port === 80 ? "" : `:${cam.port}`}/`;
|
||||
}
|
||||
|
||||
function WebUiLink({ cam, children }: { cam: CameraSummary; children: React.ReactNode }) {
|
||||
return (
|
||||
<a href={webUiUrl(cam)} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
{children} ↗
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupPanel({ cam, onCheckAgain }: { cam: CameraSummary; onCheckAgain: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded border border-amber-400/60 p-3 text-sm">
|
||||
<div className="font-medium">Setup required: camera not activated</div>
|
||||
<ol className="list-decimal space-y-1 pl-5 text-zinc-700 dark:text-zinc-300">
|
||||
<li>
|
||||
<WebUiLink cam={cam}>Open the camera's web page</WebUiLink> and activate it by
|
||||
choosing an admin password. Save it in your password manager; this app never stores
|
||||
it.
|
||||
</li>
|
||||
<li>
|
||||
Enable ONVIF (usually Configuration → Network → Advanced Settings → Integration
|
||||
Protocol).
|
||||
</li>
|
||||
<li>
|
||||
On the same page, add an ONVIF user named <code>{DEFAULT_USERNAME}</code> with the{" "}
|
||||
<strong>Operator</strong> level.
|
||||
</li>
|
||||
<li>Come back here and click Check again, then enter that user's login.</li>
|
||||
</ol>
|
||||
<div>
|
||||
<button
|
||||
onClick={onCheckAgain}
|
||||
className="rounded bg-black px-3 py-1 text-white dark:bg-white dark:text-black"
|
||||
>
|
||||
Check again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls the snapshot route, requesting the next frame only after the current one has
|
||||
* arrived. Stops on the first failure and reports it; the parent restarts it by
|
||||
* clearing `paused`.
|
||||
*/
|
||||
function Snapshot({
|
||||
cam,
|
||||
intervalMs,
|
||||
paused,
|
||||
onProblem,
|
||||
}: {
|
||||
cam: CameraSummary;
|
||||
intervalMs: number;
|
||||
paused: boolean;
|
||||
onProblem: (p: Problem) => void;
|
||||
}) {
|
||||
const [frame, setFrame] = useState<string | null>(null);
|
||||
const frameRef = useRef<string | null>(null);
|
||||
const onProblemRef = useRef(onProblem);
|
||||
useEffect(() => {
|
||||
onProblemRef.current = onProblem;
|
||||
});
|
||||
|
||||
useEffect(() => () => {
|
||||
if (frameRef.current) URL.revokeObjectURL(frameRef.current);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const res = await fetch(apiUrl(cam, "snapshot"), { cache: "no-store" });
|
||||
if (cancelled) return;
|
||||
if (!res.ok) return onProblemRef.current(await problemFrom(res));
|
||||
const url = URL.createObjectURL(await res.blob());
|
||||
if (cancelled) return URL.revokeObjectURL(url);
|
||||
if (frameRef.current) URL.revokeObjectURL(frameRef.current);
|
||||
frameRef.current = url;
|
||||
setFrame(url);
|
||||
timer = setTimeout(poll, intervalMs);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
onProblemRef.current({
|
||||
kind: "error",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
poll();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [cam, intervalMs, paused]);
|
||||
|
||||
return (
|
||||
<div className="relative aspect-video overflow-hidden rounded bg-zinc-100 dark:bg-zinc-900">
|
||||
{frame && (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- live frames, not optimizable
|
||||
<img
|
||||
src={frame}
|
||||
alt={cam.name ?? cam.host}
|
||||
className={`h-full w-full object-contain ${paused ? "opacity-40" : ""}`}
|
||||
/>
|
||||
)}
|
||||
{!frame && !paused && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-sm text-zinc-500">
|
||||
Loading…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CredentialsForm({
|
||||
cam,
|
||||
reason,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}: {
|
||||
cam: CameraSummary;
|
||||
reason?: string;
|
||||
onSaved: () => void;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const [username, setUsername] = useState(DEFAULT_USERNAME);
|
||||
const [password, setPassword] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Prefill the username only; the API never returns passwords.
|
||||
useEffect(() => {
|
||||
fetch(apiUrl(cam, "credentials"))
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
// A username the user has already typed wins over the saved one.
|
||||
if (data?.username) {
|
||||
setUsername((u) => (u === DEFAULT_USERNAME ? data.username : u));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [cam]);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(apiUrl(cam, "credentials"), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const problem = await problemFrom(res);
|
||||
setError(problem.kind === "auth" ? "Camera rejected that login." : problem.message);
|
||||
return;
|
||||
}
|
||||
setPassword("");
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="flex flex-col gap-2 rounded border border-amber-400/60 p-3 text-sm">
|
||||
<div className="font-medium">Camera login</div>
|
||||
{reason && <div className="text-xs text-zinc-500">{reason}</div>}
|
||||
<div className="text-xs text-zinc-500">
|
||||
Use the ONVIF user you created on the camera (
|
||||
<WebUiLink cam={cam}>camera web page</WebUiLink>).
|
||||
</div>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Username"
|
||||
autoComplete="off"
|
||||
required
|
||||
className="rounded border border-zinc-300 bg-transparent px-2 py-1 dark:border-zinc-700"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
autoComplete="new-password"
|
||||
className="rounded border border-zinc-300 bg-transparent px-2 py-1 dark:border-zinc-700"
|
||||
/>
|
||||
{error && <div className="text-red-600">{error}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded bg-black px-3 py-1 text-white disabled:opacity-50 dark:bg-white dark:text-black"
|
||||
>
|
||||
{saving ? "Checking…" : "Save"}
|
||||
</button>
|
||||
{onCancel && (
|
||||
<button type="button" onClick={onCancel} className="rounded px-3 py-1 underline">
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CameraCard({
|
||||
cam,
|
||||
intervalMs,
|
||||
}: {
|
||||
cam: CameraSummary;
|
||||
intervalMs: number;
|
||||
}) {
|
||||
const [info, setInfo] = useState<CameraInfo | null>(null);
|
||||
const [problem, setProblem] = useState<Problem | null>(null);
|
||||
const [editingLogin, setEditingLogin] = useState(false);
|
||||
// Bumped to retry after a failure or a credentials change.
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch(apiUrl(cam, "info"))
|
||||
.then(async (res) => {
|
||||
if (cancelled) return;
|
||||
if (!res.ok) return setProblem(await problemFrom(res));
|
||||
setInfo(await res.json());
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setProblem({ kind: "error", message: String(err) });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [cam, attempt]);
|
||||
|
||||
function retry() {
|
||||
setProblem(null);
|
||||
setEditingLogin(false);
|
||||
setAttempt((a) => a + 1);
|
||||
}
|
||||
|
||||
const showSetup = problem?.kind === "inactive";
|
||||
const showLogin = !showSetup && (problem?.kind === "auth" || editingLogin);
|
||||
|
||||
return (
|
||||
<li className="flex flex-col gap-3 rounded-lg border border-zinc-200 p-4 dark:border-zinc-800">
|
||||
<Snapshot
|
||||
key={attempt}
|
||||
cam={cam}
|
||||
intervalMs={intervalMs}
|
||||
paused={problem !== null || !info}
|
||||
onProblem={setProblem}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div className="font-medium">{cam.name ?? info?.model ?? "Unnamed camera"}</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono text-sm">
|
||||
{cam.host}:{cam.port}
|
||||
</span>
|
||||
<span className="flex gap-3 text-xs">
|
||||
<WebUiLink cam={cam}>Web page</WebUiLink>
|
||||
{!showLogin && !showSetup && (
|
||||
<button onClick={() => setEditingLogin(true)} className="underline">
|
||||
Change login
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{info?.manufacturer && (
|
||||
<div className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{info.manufacturer} {info.model} · fw {info.firmwareVersion}
|
||||
</div>
|
||||
)}
|
||||
{cam.location && (
|
||||
<div className="text-sm text-zinc-600 dark:text-zinc-400">{cam.location}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showSetup && <SetupPanel cam={cam} onCheckAgain={retry} />}
|
||||
|
||||
{showLogin && (
|
||||
<CredentialsForm
|
||||
cam={cam}
|
||||
reason={problem?.kind === "auth" ? problem.message : undefined}
|
||||
onSaved={retry}
|
||||
onCancel={problem?.kind === "auth" ? undefined : () => setEditingLogin(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{problem?.kind === "error" && (
|
||||
<div className="flex items-center justify-between gap-2 text-sm text-red-600">
|
||||
<span>Stopped: {problem.message}</span>
|
||||
<button onClick={retry} className="underline">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{info && info.profiles.length > 0 && (
|
||||
<table className="text-xs">
|
||||
<thead className="text-left text-zinc-500">
|
||||
<tr>
|
||||
<th className="font-normal">Profile</th>
|
||||
<th className="font-normal">Codec</th>
|
||||
<th className="font-normal">Resolution</th>
|
||||
<th className="font-normal">FPS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="font-mono">
|
||||
{info.profiles.map((p) => (
|
||||
<tr key={p.token}>
|
||||
<td>{p.name ?? p.token}</td>
|
||||
<td>{p.encoding ?? "?"}</td>
|
||||
<td>{p.width && p.height ? `${p.width}×${p.height}` : "?"}</td>
|
||||
<td>{p.fps ?? "?"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
77
src/app/camera-scanner.tsx
Normal file
77
src/app/camera-scanner.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
/**
|
||||
* Runs a network scan. The scan writes the server-side registry, so afterwards the
|
||||
* page is refreshed and the server re-renders the camera list.
|
||||
*/
|
||||
export default function CameraScanner() {
|
||||
const router = useRouter();
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [timeout, setTimeoutSecs] = useState(5);
|
||||
const [unicastSweep, setUnicastSweep] = useState(true);
|
||||
|
||||
async function scan() {
|
||||
setScanning(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/discover", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
// The server rejects timeouts outside 1–30 s; keep a half-typed value in range.
|
||||
body: JSON.stringify({
|
||||
timeout: Math.min(Math.max(Math.round(timeout) || 5, 1), 30) * 1000,
|
||||
unicastSweep,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||
const found = data.cameras.length;
|
||||
setResult(`Scan found ${found} camera${found === 1 ? "" : "s"}`);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={scan}
|
||||
disabled={scanning}
|
||||
className="rounded-md bg-black px-4 py-2 text-sm font-medium text-white disabled:opacity-50 dark:bg-white dark:text-black"
|
||||
>
|
||||
{scanning ? "Scanning…" : "Scan network"}
|
||||
</button>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
Timeout
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={30}
|
||||
value={timeout}
|
||||
onChange={(e) => setTimeoutSecs(Number(e.target.value))}
|
||||
className="w-16 rounded border border-zinc-300 bg-transparent px-2 py-1 dark:border-zinc-700"
|
||||
/>
|
||||
s
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={unicastSweep}
|
||||
onChange={(e) => setUnicastSweep(e.target.checked)}
|
||||
/>
|
||||
Unicast sweep of local subnet
|
||||
</label>
|
||||
{result && <p className="w-full text-sm text-zinc-600 dark:text-zinc-400">{result}</p>}
|
||||
{error && <p className="w-full text-sm text-red-600">Error: {error}</p>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Providers from "./providers";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "ONVIF Cameras",
|
||||
description: "Discover ONVIF cameras on the local network",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
@ -23,7 +24,9 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,69 +1,39 @@
|
||||
import Image from "next/image";
|
||||
import { listCameras } from "@/lib/camera-registry";
|
||||
import CameraCard from "./camera-card";
|
||||
import CameraScanner from "./camera-scanner";
|
||||
import RefreshRateSelect from "./refresh-rate-select";
|
||||
import { refreshMsSchema } from "./refresh-rate";
|
||||
|
||||
export default async function Home({ searchParams }: PageProps<"/">) {
|
||||
const [cameras, { refresh }] = await Promise.all([listCameras(), searchParams]);
|
||||
const intervalMs = refreshMsSchema.parse(refresh);
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert h-5 w-[100px]"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the{" "}
|
||||
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
|
||||
page.tsx
|
||||
</code>{" "}
|
||||
file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
<main className="mx-auto w-full max-w-4xl flex-1 px-4 py-12 font-sans">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">ONVIF Cameras</h1>
|
||||
<p className="mt-2 text-zinc-600 dark:text-zinc-400">
|
||||
Discover ONVIF cameras on the local network using WS-Discovery.
|
||||
</p>
|
||||
|
||||
<section className="mt-8">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<CameraScanner />
|
||||
<RefreshRateSelect value={intervalMs} />
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{cameras.length === 0
|
||||
? "No cameras known yet. Scan the network to find them."
|
||||
: `${cameras.length} camera${cameras.length === 1 ? "" : "s"}`}
|
||||
</p>
|
||||
<ul className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
{cameras.map((cam) => (
|
||||
<CameraCard key={cam.id} cam={cam} intervalMs={intervalMs} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert h-[14px] w-4"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={14}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
23
src/app/providers.tsx
Normal file
23
src/app/providers.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
function makeQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// Device state is never served from cache; always refetch.
|
||||
staleTime: 0,
|
||||
// One retry at most, so an offline camera isn't hammered.
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||
// One client per browser session; useState keeps it stable across re-renders.
|
||||
const [queryClient] = useState(makeQueryClient);
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
}
|
||||
27
src/app/refresh-rate-select.tsx
Normal file
27
src/app/refresh-rate-select.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { REFRESH_OPTIONS_MS } from "./refresh-rate";
|
||||
|
||||
/** Keeps the refresh rate in the URL so the server-rendered grid can read it. */
|
||||
export default function RefreshRateSelect({ value }: { value: number }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
Snapshot refresh
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => router.replace(`${pathname}?refresh=${e.target.value}`, { scroll: false })}
|
||||
className="rounded border border-zinc-300 bg-transparent px-2 py-1 dark:border-zinc-700"
|
||||
>
|
||||
{REFRESH_OPTIONS_MS.map((ms) => (
|
||||
<option key={ms} value={ms}>
|
||||
{ms / 1000} s
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
11
src/app/refresh-rate.ts
Normal file
11
src/app/refresh-rate.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Snapshot refresh intervals offered in the UI, in milliseconds. */
|
||||
export const REFRESH_OPTIONS_MS = [250, 500, 1000, 2000, 5000] as const;
|
||||
export const DEFAULT_REFRESH_MS = 1000;
|
||||
|
||||
/** The `?refresh=` search param; anything unexpected falls back to the default. */
|
||||
export const refreshMsSchema = z.coerce
|
||||
.number()
|
||||
.refine((ms) => (REFRESH_OPTIONS_MS as readonly number[]).includes(ms))
|
||||
.catch(DEFAULT_REFRESH_MS);
|
||||
125
src/lib/camera-registry.ts
Normal file
125
src/lib/camera-registry.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import "server-only";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { connection } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { CredentialStoreError, moveCredentials } from "./credential-store";
|
||||
|
||||
/**
|
||||
* Last known address of each camera, keyed by a stable ID derived from its
|
||||
* WS-Discovery endpoint reference (usually a MAC-based UUID). Every scan refreshes it,
|
||||
* so a camera that changes IP keeps its stored login. Not secret: no credentials here.
|
||||
*/
|
||||
export interface CameraRecord {
|
||||
id: string;
|
||||
urn: string;
|
||||
host: string;
|
||||
port: number;
|
||||
name?: string;
|
||||
location?: string;
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
/** What the dashboard is given for each known camera: no URN, nothing secret. */
|
||||
export interface CameraSummary {
|
||||
id: string;
|
||||
host: string;
|
||||
port: number;
|
||||
name?: string;
|
||||
location?: string;
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
const REGISTRY_PATH =
|
||||
process.env.CAMERA_REGISTRY_FILE ?? path.join(process.cwd(), ".data", "cameras.json");
|
||||
|
||||
const UUID_URN = /^urn:uuid:([0-9a-f-]{36})$/i;
|
||||
|
||||
export function cameraIdFromUrn(urn: string): string {
|
||||
const uuid = urn.match(UUID_URN)?.[1];
|
||||
return uuid
|
||||
? uuid.toLowerCase()
|
||||
: createHash("sha256").update(urn).digest("hex").slice(0, 32);
|
||||
}
|
||||
|
||||
export const cameraIdSchema = z.string().regex(/^[0-9a-f-]{32,36}$/);
|
||||
|
||||
export function isValidCameraId(id: string): boolean {
|
||||
return cameraIdSchema.safeParse(id).success;
|
||||
}
|
||||
|
||||
let cache: Promise<Record<string, CameraRecord>> | null = null;
|
||||
|
||||
function load(): Promise<Record<string, CameraRecord>> {
|
||||
cache ??= readFile(REGISTRY_PATH, "utf8")
|
||||
.then((text) => JSON.parse(text) as Record<string, CameraRecord>)
|
||||
.catch((err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "ENOENT") return {};
|
||||
cache = null;
|
||||
throw err;
|
||||
});
|
||||
return cache;
|
||||
}
|
||||
|
||||
async function save(registry: Record<string, CameraRecord>) {
|
||||
await mkdir(path.dirname(REGISTRY_PATH), { recursive: true, mode: 0o700 });
|
||||
const tmp = `${REGISTRY_PATH}.${process.pid}.tmp`;
|
||||
await writeFile(tmp, JSON.stringify(registry, null, 2), { mode: 0o600 });
|
||||
await rename(tmp, REGISTRY_PATH);
|
||||
cache = Promise.resolve(registry);
|
||||
}
|
||||
|
||||
export async function getCameraRecord(id: string): Promise<CameraRecord | null> {
|
||||
return (await load())[id] ?? null;
|
||||
}
|
||||
|
||||
/** Every known camera, ordered by name then address. Read per request, never prerendered. */
|
||||
export async function listCameras(): Promise<CameraSummary[]> {
|
||||
await connection();
|
||||
return Object.values(await load())
|
||||
.map(({ id, host, port, name, location, lastSeen }) => ({
|
||||
id,
|
||||
host,
|
||||
port,
|
||||
name,
|
||||
location,
|
||||
lastSeen,
|
||||
}))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.name ?? "").localeCompare(b.name ?? "") ||
|
||||
a.host.localeCompare(b.host, undefined, { numeric: true }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function recordDiscovered(
|
||||
cameras: {
|
||||
id: string;
|
||||
urn: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
name?: string;
|
||||
location?: string;
|
||||
}[],
|
||||
) {
|
||||
if (cameras.length === 0) return;
|
||||
const registry = { ...(await load()) };
|
||||
const now = new Date().toISOString();
|
||||
for (const cam of cameras) {
|
||||
registry[cam.id] = {
|
||||
id: cam.id,
|
||||
urn: cam.urn,
|
||||
host: cam.hostname,
|
||||
port: cam.port,
|
||||
name: cam.name,
|
||||
location: cam.location,
|
||||
lastSeen: now,
|
||||
};
|
||||
// Logins saved before cameras were keyed by ID were stored under "host:port".
|
||||
await moveCredentials(`${cam.hostname}:${cam.port}`, cam.id).catch((err) => {
|
||||
if (!(err instanceof CredentialStoreError)) throw err;
|
||||
});
|
||||
}
|
||||
await save(registry);
|
||||
}
|
||||
49
src/lib/camera-route.ts
Normal file
49
src/lib/camera-route.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import "server-only";
|
||||
import {
|
||||
CameraAuthError,
|
||||
CameraInactiveError,
|
||||
isAllowedHost,
|
||||
type CameraTarget,
|
||||
} from "./camera";
|
||||
import { getCameraRecord, isValidCameraId } from "./camera-registry";
|
||||
import { CredentialStoreError } from "./credential-store";
|
||||
|
||||
/**
|
||||
* Resolves the [id] route param to the camera's last known address. The address comes
|
||||
* from the server-side registry, never from the client, so a stored login can only be
|
||||
* sent to the camera it belongs to.
|
||||
*/
|
||||
export async function cameraTarget(
|
||||
params: Promise<{ id: string }>,
|
||||
): Promise<CameraTarget | Response> {
|
||||
const { id } = await params;
|
||||
if (!isValidCameraId(id)) {
|
||||
return Response.json({ error: "Invalid camera ID" }, { status: 400 });
|
||||
}
|
||||
const record = await getCameraRecord(id);
|
||||
if (!record) {
|
||||
return Response.json({ error: "Unknown camera; scan the network again" }, { status: 404 });
|
||||
}
|
||||
if (!isAllowedHost(record.host)) {
|
||||
return Response.json({ error: "Camera address is not a private IPv4 address" }, { status: 400 });
|
||||
}
|
||||
return { id, host: record.host, port: record.port };
|
||||
}
|
||||
|
||||
/**
|
||||
* Error codes the UI acts on: "inactive" shows the setup panel, "auth" stops polling
|
||||
* and asks for a login, "store" is a problem with the encrypted credentials file.
|
||||
*/
|
||||
export function cameraErrorResponse(err: unknown): Response {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (err instanceof CameraInactiveError) {
|
||||
return Response.json({ error: message, code: "inactive" }, { status: 409 });
|
||||
}
|
||||
if (err instanceof CameraAuthError) {
|
||||
return Response.json({ error: message, code: "auth" }, { status: 401 });
|
||||
}
|
||||
if (err instanceof CredentialStoreError) {
|
||||
return Response.json({ error: message, code: "store" }, { status: 500 });
|
||||
}
|
||||
return Response.json({ error: message }, { status: 502 });
|
||||
}
|
||||
274
src/lib/camera.ts
Normal file
274
src/lib/camera.ts
Normal file
@ -0,0 +1,274 @@
|
||||
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";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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"));
|
||||
return reject(
|
||||
asAuthError(new Error(`Camera returned no media profiles${warning ? `: ${warning}` : ""}`)),
|
||||
);
|
||||
}
|
||||
resolve(cam);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function toProfile(p: CamProfile): CameraProfile {
|
||||
// Media1 uses videoEncoderConfiguration; Media2 profiles use configurations.videoEncoder.
|
||||
const enc = p.videoEncoderConfiguration ?? p.configurations?.videoEncoder;
|
||||
const resolution = enc?.resolution;
|
||||
return {
|
||||
token: p.token ?? p.$?.token,
|
||||
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,
|
||||
};
|
||||
}
|
||||
167
src/lib/credential-store.ts
Normal file
167
src/lib/credential-store.ts
Normal file
@ -0,0 +1,167 @@
|
||||
import "server-only";
|
||||
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
|
||||
export interface Credentials {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** A login submitted from the web UI. Password may be empty (some cameras allow it). */
|
||||
export const credentialsSchema = z.object({
|
||||
username: z.string().min(1).max(64),
|
||||
password: z.string().max(256),
|
||||
}) satisfies z.ZodType<Credentials>;
|
||||
|
||||
// Stored on the server's disk only, encrypted with CAMERA_CREDENTIALS_KEY. The API never
|
||||
// returns passwords; the web UI can only set, replace, or clear them.
|
||||
const STORE_PATH =
|
||||
process.env.CAMERA_CREDENTIALS_FILE ?? path.join(process.cwd(), ".data", "credentials.json");
|
||||
|
||||
type Store = Record<string, Credentials>;
|
||||
|
||||
/** On-disk format: the whole store encrypted with AES-256-GCM. */
|
||||
interface EncryptedFile {
|
||||
version: 1;
|
||||
kdf: "scrypt";
|
||||
salt: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export class CredentialStoreError extends Error {
|
||||
name = "CredentialStoreError";
|
||||
}
|
||||
|
||||
// scrypt is deliberately slow, so derive once per salt.
|
||||
const derivedKeys = new Map<string, Buffer>();
|
||||
let fileSalt: Buffer | null = null;
|
||||
|
||||
function encryptionKey(salt: Buffer): Buffer {
|
||||
const secret = process.env.CAMERA_CREDENTIALS_KEY;
|
||||
if (!secret) {
|
||||
throw new CredentialStoreError(
|
||||
"CAMERA_CREDENTIALS_KEY is not set in .env.local; stored camera logins can't be read or saved.",
|
||||
);
|
||||
}
|
||||
const id = `${salt.toString("base64")}:${secret}`;
|
||||
let key = derivedKeys.get(id);
|
||||
if (!key) {
|
||||
key = scryptSync(secret, salt, 32);
|
||||
derivedKeys.set(id, key);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function encrypt(store: Store): EncryptedFile {
|
||||
fileSalt ??= randomBytes(16);
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv("aes-256-gcm", encryptionKey(fileSalt), iv);
|
||||
const data = Buffer.concat([cipher.update(JSON.stringify(store), "utf8"), cipher.final()]);
|
||||
return {
|
||||
version: 1,
|
||||
kdf: "scrypt",
|
||||
salt: fileSalt.toString("base64"),
|
||||
iv: iv.toString("base64"),
|
||||
tag: cipher.getAuthTag().toString("base64"),
|
||||
data: data.toString("base64"),
|
||||
};
|
||||
}
|
||||
|
||||
function decrypt(file: EncryptedFile): Store {
|
||||
const salt = Buffer.from(file.salt, "base64");
|
||||
const decipher = createDecipheriv(
|
||||
"aes-256-gcm",
|
||||
encryptionKey(salt),
|
||||
Buffer.from(file.iv, "base64"),
|
||||
);
|
||||
decipher.setAuthTag(Buffer.from(file.tag, "base64"));
|
||||
try {
|
||||
const text = Buffer.concat([
|
||||
decipher.update(Buffer.from(file.data, "base64")),
|
||||
decipher.final(),
|
||||
]).toString("utf8");
|
||||
fileSalt = salt;
|
||||
return JSON.parse(text) as Store;
|
||||
} catch {
|
||||
throw new CredentialStoreError(
|
||||
`Can't decrypt ${STORE_PATH}: CAMERA_CREDENTIALS_KEY is wrong or the file is corrupt.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let cache: Promise<Store> | null = null;
|
||||
|
||||
function load(): Promise<Store> {
|
||||
cache ??= readFile(STORE_PATH, "utf8")
|
||||
.then(async (text) => {
|
||||
const parsed = JSON.parse(text);
|
||||
if (parsed?.version === 1 && parsed.kdf === "scrypt") return decrypt(parsed);
|
||||
// Plaintext file from before encryption was added: re-save it encrypted.
|
||||
await save(parsed as Store);
|
||||
return parsed as Store;
|
||||
})
|
||||
.catch((err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "ENOENT") return {};
|
||||
cache = null;
|
||||
throw err;
|
||||
});
|
||||
return cache;
|
||||
}
|
||||
|
||||
async function save(store: Store) {
|
||||
const contents = JSON.stringify(encrypt(store), null, 2);
|
||||
await mkdir(path.dirname(STORE_PATH), { recursive: true, mode: 0o700 });
|
||||
const tmp = `${STORE_PATH}.${process.pid}.tmp`;
|
||||
await writeFile(tmp, contents, { mode: 0o600 });
|
||||
await rename(tmp, STORE_PATH);
|
||||
cache = Promise.resolve(store);
|
||||
}
|
||||
|
||||
// Entries are keyed by camera ID (see camera-registry.ts), so they survive IP changes.
|
||||
|
||||
/** Stored credentials for the camera, falling back to ONVIF_USERNAME / ONVIF_PASSWORD. */
|
||||
export async function getCredentials(cameraId: string): Promise<Credentials | null> {
|
||||
const stored = (await load())[cameraId];
|
||||
if (stored) return stored;
|
||||
if (process.env.ONVIF_USERNAME || process.env.ONVIF_PASSWORD) {
|
||||
return {
|
||||
username: process.env.ONVIF_USERNAME ?? "",
|
||||
password: process.env.ONVIF_PASSWORD ?? "",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function setCredentials(cameraId: string, creds: Credentials) {
|
||||
await save({ ...(await load()), [cameraId]: creds });
|
||||
}
|
||||
|
||||
export async function deleteCredentials(cameraId: string) {
|
||||
const store = { ...(await load()) };
|
||||
delete store[cameraId];
|
||||
await save(store);
|
||||
}
|
||||
|
||||
/** Re-keys an entry (used to migrate old "host:port" keys to camera IDs). */
|
||||
export async function moveCredentials(fromKey: string, toKey: string) {
|
||||
const store = await load();
|
||||
if (!(fromKey in store) || toKey in store) return;
|
||||
const next = { ...store, [toKey]: store[fromKey] };
|
||||
delete next[fromKey];
|
||||
await save(next);
|
||||
}
|
||||
|
||||
/** What the web UI may see: never the password itself. */
|
||||
export async function describeCredentials(cameraId: string) {
|
||||
const stored = (await load())[cameraId];
|
||||
const effective = await getCredentials(cameraId);
|
||||
return {
|
||||
source: stored ? "stored" : effective ? "env" : "none",
|
||||
username: effective?.username ?? null,
|
||||
hasPassword: Boolean(effective?.password),
|
||||
} as const;
|
||||
}
|
||||
222
src/lib/onvif.ts
Normal file
222
src/lib/onvif.ts
Normal file
@ -0,0 +1,222 @@
|
||||
import "server-only";
|
||||
import dgram from "node:dgram";
|
||||
import os from "node:os";
|
||||
import { Discovery } from "onvif";
|
||||
import { z } from "zod";
|
||||
import { guid, linerase, parseSOAPString } from "onvif/lib/utils";
|
||||
import { cameraIdFromUrn } from "./camera-registry";
|
||||
|
||||
export type DiscoverySource = "multicast" | "unicast";
|
||||
|
||||
export interface DiscoveredCamera {
|
||||
/** Stable ID derived from the WS-Discovery endpoint reference; survives IP changes. */
|
||||
id: string;
|
||||
urn: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
xaddrs: string[];
|
||||
name?: string;
|
||||
hardware?: string;
|
||||
location?: string;
|
||||
scopes: string[];
|
||||
foundBy: DiscoverySource[];
|
||||
}
|
||||
|
||||
export interface DiscoverOptions {
|
||||
timeoutMs?: number;
|
||||
/** Also send the probe by unicast to every host on the local IPv4 subnets. */
|
||||
unicastSweep?: boolean;
|
||||
}
|
||||
|
||||
/** Scan options accepted from the web UI; omitted fields take their defaults. */
|
||||
export const discoverRequestSchema = z.object({
|
||||
timeout: z.number().int().min(1000).max(30_000).default(5000),
|
||||
unicastSweep: z.boolean().default(true),
|
||||
});
|
||||
|
||||
interface ProbeMatch {
|
||||
endpointReference?: { address?: string };
|
||||
XAddrs?: string;
|
||||
scopes?: string | { _?: string };
|
||||
}
|
||||
|
||||
const WS_DISCOVERY_PORT = 3702;
|
||||
// Largest subnet we will sweep: a /20 is 4094 hosts.
|
||||
const MIN_SWEEP_PREFIX = 20;
|
||||
const SWEEP_BATCH_SIZE = 64;
|
||||
const SWEEP_BATCH_DELAY_MS = 15;
|
||||
|
||||
// Discovery is an EventEmitter singleton that emits "error" for malformed replies;
|
||||
// without a listener Node would throw and take down the server process.
|
||||
let errorListenerAttached = false;
|
||||
function ensureErrorListener() {
|
||||
if (errorListenerAttached) return;
|
||||
Discovery.on("error", (err: unknown) => {
|
||||
console.warn("[onvif] discovery error:", err);
|
||||
});
|
||||
errorListenerAttached = true;
|
||||
}
|
||||
|
||||
function scopeValue(scopes: string[], key: string): string | undefined {
|
||||
const prefix = `onvif://www.onvif.org/${key}/`;
|
||||
const match = scopes.find((s) => s.startsWith(prefix));
|
||||
return match ? decodeURIComponent(match.slice(prefix.length)) : undefined;
|
||||
}
|
||||
|
||||
function toCamera(raw: unknown, source: DiscoverySource): DiscoveredCamera | null {
|
||||
const match = (raw as { probeMatches?: { probeMatch?: ProbeMatch } })
|
||||
?.probeMatches?.probeMatch;
|
||||
if (!match?.XAddrs) return null;
|
||||
|
||||
const xaddrs = match.XAddrs.split(/\s+/).filter(Boolean);
|
||||
// Prefer an IPv4 XAddr; cameras often also advertise IPv6 addresses.
|
||||
const primary = new URL(xaddrs.find((x) => !x.includes("[")) ?? xaddrs[0]);
|
||||
const scopeString =
|
||||
typeof match.scopes === "string" ? match.scopes : match.scopes?._ ?? "";
|
||||
const scopes = scopeString.split(/\s+/).filter(Boolean);
|
||||
|
||||
const urn = match.endpointReference?.address ?? xaddrs[0];
|
||||
return {
|
||||
id: cameraIdFromUrn(urn),
|
||||
urn,
|
||||
hostname: primary.hostname,
|
||||
port: Number(primary.port) || 80,
|
||||
xaddrs,
|
||||
name: scopeValue(scopes, "name"),
|
||||
hardware: scopeValue(scopes, "hardware"),
|
||||
location: scopeValue(scopes, "location"),
|
||||
scopes,
|
||||
foundBy: [source],
|
||||
};
|
||||
}
|
||||
|
||||
function multicastProbe(timeoutMs: number): Promise<DiscoveredCamera[]> {
|
||||
ensureErrorListener();
|
||||
return new Promise((resolve, reject) => {
|
||||
Discovery.probe({ timeout: timeoutMs, resolve: false }, (err, devices) => {
|
||||
// Parse errors from individual devices come back as an array; only fail on
|
||||
// socket-level errors.
|
||||
if (err && !Array.isArray(err)) return reject(err);
|
||||
resolve(
|
||||
(devices ?? [])
|
||||
.map((d) => toCamera(d, "multicast"))
|
||||
.filter((c) => c !== null),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function ipToInt(ip: string): number {
|
||||
return ip.split(".").reduce((acc, octet) => (acc << 8) + Number(octet), 0) >>> 0;
|
||||
}
|
||||
|
||||
function intToIp(n: number): string {
|
||||
return [24, 16, 8, 0].map((shift) => (n >>> shift) & 255).join(".");
|
||||
}
|
||||
|
||||
/** Every host address on the machine's non-internal IPv4 subnets, excluding itself. */
|
||||
function localSubnetHosts(): string[] {
|
||||
const hosts = new Set<string>();
|
||||
for (const addrs of Object.values(os.networkInterfaces())) {
|
||||
for (const addr of addrs ?? []) {
|
||||
if (addr.family !== "IPv4" || addr.internal || !addr.cidr) continue;
|
||||
if (addr.address.startsWith("169.254.")) continue;
|
||||
const prefix = Number(addr.cidr.split("/")[1]);
|
||||
if (prefix < MIN_SWEEP_PREFIX || prefix > 30) {
|
||||
console.warn(`[onvif] skipping unicast sweep of ${addr.cidr}`);
|
||||
continue;
|
||||
}
|
||||
const mask = (0xffffffff << (32 - prefix)) >>> 0;
|
||||
const network = (ipToInt(addr.address) & mask) >>> 0;
|
||||
const broadcast = (network | ~mask) >>> 0;
|
||||
for (let n = network + 1; n < broadcast; n++) {
|
||||
const ip = intToIp(n);
|
||||
if (ip !== addr.address) hosts.add(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...hosts];
|
||||
}
|
||||
|
||||
function probeMessage(messageId: string): Buffer {
|
||||
return Buffer.from(
|
||||
'<?xml version="1.0" encoding="UTF-8"?>' +
|
||||
'<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" ' +
|
||||
'xmlns:a="http://schemas.xmlsoap.org/ws/2004/08/addressing" ' +
|
||||
'xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery" ' +
|
||||
'xmlns:dn="http://www.onvif.org/ver10/network/wsdl">' +
|
||||
`<s:Header><a:MessageID>${messageId}</a:MessageID>` +
|
||||
"<a:To>urn:schemas-xmlsoap-org:ws:2005:04:discovery</a:To>" +
|
||||
"<a:Action>http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe</a:Action>" +
|
||||
"</s:Header><s:Body><d:Probe><d:Types>dn:NetworkVideoTransmitter</d:Types></d:Probe></s:Body>" +
|
||||
"</s:Envelope>",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the WS-Discovery Probe directly to each host on the local subnets. Works on
|
||||
* networks that drop multicast (e.g. Wi-Fi access points that don't forward it upstream).
|
||||
*/
|
||||
function unicastSweep(timeoutMs: number): Promise<DiscoveredCamera[]> {
|
||||
const hosts = localSubnetHosts();
|
||||
if (hosts.length === 0) return Promise.resolve([]);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const cameras = new Map<string, DiscoveredCamera>();
|
||||
const message = probeMessage(`urn:uuid:${guid()}`);
|
||||
const socket = dgram.createSocket("udp4");
|
||||
let closed = false;
|
||||
|
||||
socket.on("message", (msg) => {
|
||||
parseSOAPString(msg.toString(), (err, data) => {
|
||||
if (err || !data) return;
|
||||
const camera = toCamera(linerase(data), "unicast");
|
||||
if (camera && !cameras.has(camera.urn)) cameras.set(camera.urn, camera);
|
||||
});
|
||||
});
|
||||
socket.on("error", (err) => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
socket.close();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
socket.bind(0, async () => {
|
||||
for (let i = 0; i < hosts.length && !closed; i += SWEEP_BATCH_SIZE) {
|
||||
for (const host of hosts.slice(i, i + SWEEP_BATCH_SIZE)) {
|
||||
// Send errors (e.g. EHOSTDOWN for absent hosts) are expected; ignore them.
|
||||
socket.send(message, WS_DISCOVERY_PORT, host, () => {});
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, SWEEP_BATCH_DELAY_MS));
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
socket.close();
|
||||
resolve([...cameras.values()]);
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
export async function discoverCameras({
|
||||
timeoutMs = 5000,
|
||||
unicastSweep: sweep = true,
|
||||
}: DiscoverOptions = {}): Promise<DiscoveredCamera[]> {
|
||||
const results = await Promise.all([
|
||||
multicastProbe(timeoutMs),
|
||||
sweep ? unicastSweep(timeoutMs) : Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const merged = new Map<string, DiscoveredCamera>();
|
||||
for (const camera of results.flat()) {
|
||||
const existing = merged.get(camera.urn);
|
||||
if (existing) {
|
||||
existing.foundBy = [...new Set([...existing.foundBy, ...camera.foundBy])];
|
||||
} else {
|
||||
merged.set(camera.urn, { ...camera });
|
||||
}
|
||||
}
|
||||
return [...merged.values()];
|
||||
}
|
||||
13
src/types/onvif-utils.d.ts
vendored
Normal file
13
src/types/onvif-utils.d.ts
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
// onvif ships no typings for its internal helpers; these are the ones we reuse so
|
||||
// unicast replies parse into exactly the same shape as Discovery.probe results.
|
||||
declare module "onvif/lib/utils" {
|
||||
export function parseSOAPString(
|
||||
xml: string,
|
||||
callback: (err: Error | null, data: unknown[] | null, xml: string, statusCode?: number) => void,
|
||||
statusCode?: number,
|
||||
): void;
|
||||
export function linerase(xml: unknown): unknown;
|
||||
export function guid(): string;
|
||||
/** Extracts Digest WWW-Authenticate challenges from Node's res.rawHeaders. */
|
||||
export function getDigestHeaders(rawHeaders: string[]): string[];
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user