cameras/scripts/probe-debug.mjs
Michael Mainguy f39d16a8c0 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>
2026-09-19 08:40:32 -05:00

101 lines
4.0 KiB
JavaScript

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