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
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# camera credentials store (passwords)
|
||||||
|
/.data/
|
||||||
|
/snapshot.jpg
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
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;
|
export default nextConfig;
|
||||||
|
|||||||
82
package-lock.json
generated
82
package-lock.json
generated
@ -8,9 +8,13 @@
|
|||||||
"name": "cameras",
|
"name": "cameras",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.103.1",
|
||||||
"next": "16.3.5",
|
"next": "16.3.5",
|
||||||
|
"onvif": "^0.8.3",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8",
|
||||||
|
"server-only": "^0.0.1",
|
||||||
|
"zod": "^4.6.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@ -1676,6 +1680,32 @@
|
|||||||
"tailwindcss": "4.3.3"
|
"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": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.4",
|
"version": "0.10.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
|
||||||
@ -5504,6 +5534,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@ -5930,6 +5972,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
@ -5946,6 +5997,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"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": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
@ -6848,6 +6905,28 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
@ -6872,7 +6951,6 @@
|
|||||||
"version": "4.6.5",
|
"version": "4.6.5",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
|
||||||
"integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
|
"integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
|||||||
@ -9,9 +9,13 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.103.1",
|
||||||
"next": "16.3.5",
|
"next": "16.3.5",
|
||||||
|
"onvif": "^0.8.3",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8",
|
||||||
|
"server-only": "^0.0.1",
|
||||||
|
"zod": "^4.6.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@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 type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import Providers from "./providers";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "ONVIF Cameras",
|
||||||
description: "Generated by create next app",
|
description: "Discover ONVIF cameras on the local network",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
@ -23,7 +24,9 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
|
|||||||
lang="en"
|
lang="en"
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
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>
|
</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 (
|
return (
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<main className="mx-auto w-full max-w-4xl flex-1 px-4 py-12 font-sans">
|
||||||
<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">
|
<h1 className="text-3xl font-semibold tracking-tight">ONVIF Cameras</h1>
|
||||||
<Image
|
<p className="mt-2 text-zinc-600 dark:text-zinc-400">
|
||||||
className="dark:invert h-5 w-[100px]"
|
Discover ONVIF cameras on the local network using WS-Discovery.
|
||||||
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.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<section className="mt-8">
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<CameraScanner />
|
||||||
|
<RefreshRateSelect value={intervalMs} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
<div className="mt-6">
|
||||||
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]"
|
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
{cameras.length === 0
|
||||||
target="_blank"
|
? "No cameras known yet. Scan the network to find them."
|
||||||
rel="noopener noreferrer"
|
: `${cameras.length} camera${cameras.length === 1 ? "" : "s"}`}
|
||||||
>
|
</p>
|
||||||
<Image
|
<ul className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||||
className="dark:invert h-[14px] w-4"
|
{cameras.map((cam) => (
|
||||||
src="/vercel.svg"
|
<CameraCard key={cam.id} cam={cam} intervalMs={intervalMs} />
|
||||||
alt="Vercel logomark"
|
))}
|
||||||
width={16}
|
</ul>
|
||||||
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>
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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