src.nth.io/

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-07-17 19:49:47 -0500
committerLuke Hoersten <[email protected]>2026-07-17 19:49:47 -0500
commit3821512b18be69cad002faf77f1e5844bda13c95 (patch)
tree885f61c3c76f5e4dc4f8d16050da6bc96abe032d
parent2a4b8296d71f25e5a29a07ea6cc9f3ac31676c48 (diff)
scrypted: mdns discovery — browse, host choices, auto-heal
Browse for _scrypted-viewport._tcp with a plain dgram socket on an EPHEMERAL port: per RFC 6762 §6.7 a query from a non-5353 source port is a legacy unicast query and responders reply unicast to that port — verified in the ESP-IDF responder (mdns_send.c answers to the querier's addr/port whenever src_port != 5353). No :5353 bind means no conflict with Scrypted's own HomeKit mDNS or a host avahi-daemon, and it works under Docker host-networking or native alike. One response packet carries PTR+SRV+TXT+A (RFC 6763 §12.1), so parsing is per-packet with no follow-up queries; the wire codec (name compression included) is ~150 lines, no dependencies. Surfaced three ways: - add-device form: host field is a combobox listing discovered viewports as "ip — name (vX, WxH)"; manual entry still works and only the address token is stored. - child settings page: same choices on the existing host field. - auto-heal: when a /config register fails, re-browse and match by MAC (seeded from /state and discovery; survives renames) then by name; on a hit at a new address, rewrite host and retry once. Runs only on register failure, so it's rate-limited to the 5-min cycle and removes the need for a DHCP reservation. Browses are best-effort ([] on any failure) and cached 30s so settings re-renders don't spam the LAN. diagnostic.ts gains the same browse as a paste-and-run probe that validates dgram + multicast reachability from inside the real Scrypted sandbox before trusting the feature.
-rw-r--r--scrypted/diagnostic.ts139
-rw-r--r--scrypted/scrypted-viewport.ts248
2 files changed, 381 insertions, 6 deletions
diff --git a/scrypted/diagnostic.ts b/scrypted/diagnostic.ts
index e7a3ba3..29beb4f 100644
--- a/scrypted/diagnostic.ts
+++ b/scrypted/diagnostic.ts
@@ -1,7 +1,17 @@
// Paste into the diagnostic / probe Scrypted script. Save + Run.
-// Tells us which Node/mDNS modules the scriptedEval sandbox lets us
+//
+// Probe 1: which Node/mDNS modules the scriptedEval sandbox lets us
// require() — drives whether the auto-discovery feature can use a
// proper library or has to fall back to raw multicast UDP.
+//
+// Probe 2: one-shot mDNS browse for `_scrypted-viewport._tcp` using the
+// exact dgram legacy-unicast approach the main plugin uses (ephemeral
+// source port; responders reply unicast per RFC 6762 §6.7). Validates,
+// from inside the real sandbox: (a) dgram works, (b) multicast TX
+// reaches the LAN, (c) the firmware answers. Expected output: one `rx`
+// line + one parsed line per viewport on the LAN. Zero `rx` lines from
+// a Docker install usually means bridge networking — switch the
+// container to host networking.
declare const require: any;
@@ -22,3 +32,130 @@ for (const mod of [
console.log(` ${mod.padEnd(16)} -> ${e?.code || "ERR"}: ${e?.message || e}`);
}
}
+
+// ---- Probe 2: mDNS browse (same code path as the plugin's mdnsBrowse) ----
+
+const MDNS_SERVICE = "_scrypted-viewport._tcp.local";
+
+function buildMdnsQuery(): Buffer {
+ const labels = MDNS_SERVICE.split(".");
+ let qnameLen = 1;
+ for (const l of labels) qnameLen += 1 + l.length;
+ const buf = Buffer.alloc(12 + qnameLen + 4);
+ buf.writeUInt16BE(Date.now() & 0xffff, 0);
+ buf.writeUInt16BE(1, 4);
+ let off = 12;
+ for (const l of labels) {
+ buf.writeUInt8(l.length, off++);
+ buf.write(l, off, "ascii");
+ off += l.length;
+ }
+ buf.writeUInt8(0, off++);
+ buf.writeUInt16BE(12, off); // PTR
+ buf.writeUInt16BE(1, off + 2); // IN
+ return buf;
+}
+
+function readDnsName(msg: Buffer, off: number): { name: string; next: number } {
+ const parts: string[] = [];
+ let next = -1, jumps = 0;
+ while (off < msg.length) {
+ const len = msg[off];
+ if (len === 0) { if (next < 0) next = off + 1; break; }
+ if ((len & 0xc0) === 0xc0) {
+ if (next < 0) next = off + 2;
+ if (++jumps > 16) break;
+ off = ((len & 0x3f) << 8) | msg[off + 1];
+ continue;
+ }
+ parts.push(msg.subarray(off + 1, off + 1 + len).toString("ascii"));
+ off += 1 + len;
+ }
+ return { name: parts.join("."), next: next < 0 ? off : next };
+}
+
+function parseMdnsResponse(msg: Buffer): any[] {
+ try {
+ if (msg.length < 12) return [];
+ const qd = msg.readUInt16BE(4);
+ const total = msg.readUInt16BE(6) + msg.readUInt16BE(8) + msg.readUInt16BE(10);
+ let off = 12;
+ for (let i = 0; i < qd; i++) off = readDnsName(msg, off).next + 4;
+ const ptrs: string[] = [];
+ const srvs = new Map<string, { target: string; port: number }>();
+ const txts = new Map<string, Record<string, string>>();
+ const addrs = new Map<string, string>();
+ for (let i = 0; i < total && off < msg.length; i++) {
+ const rec = readDnsName(msg, off);
+ off = rec.next;
+ if (off + 10 > msg.length) break;
+ const type = msg.readUInt16BE(off);
+ const rdlen = msg.readUInt16BE(off + 8);
+ off += 10;
+ if (off + rdlen > msg.length) break;
+ const key = rec.name.toLowerCase();
+ if (type === 12 && key === MDNS_SERVICE) {
+ ptrs.push(readDnsName(msg, off).name);
+ } else if (type === 33) {
+ srvs.set(key, { target: readDnsName(msg, off + 6).name,
+ port: msg.readUInt16BE(off + 4) });
+ } else if (type === 16) {
+ const kv: Record<string, string> = {};
+ for (let t = off; t < off + rdlen; ) {
+ const l = msg[t];
+ const s = msg.subarray(t + 1, t + 1 + l).toString("utf8");
+ const eq = s.indexOf("=");
+ if (eq > 0) kv[s.slice(0, eq)] = s.slice(eq + 1);
+ t += 1 + l;
+ }
+ txts.set(key, kv);
+ } else if (type === 1 && rdlen === 4) {
+ addrs.set(key, `${msg[off]}.${msg[off + 1]}.${msg[off + 2]}.${msg[off + 3]}`);
+ }
+ off += rdlen;
+ }
+ const out: any[] = [];
+ for (const inst of ptrs) {
+ const key = inst.toLowerCase();
+ const srv = srvs.get(key);
+ const txt = txts.get(key) || {};
+ const ip = srv ? addrs.get(srv.target.toLowerCase()) : undefined;
+ if (!srv || !ip) continue;
+ out.push({ inst, ip, port: srv.port, hostname: srv.target, txt });
+ }
+ return out;
+ } catch { return []; }
+}
+
+(async () => {
+ console.log("------ mdns browse probe (_scrypted-viewport._tcp) ------");
+ try {
+ const dgram = require("dgram");
+ const sock = dgram.createSocket({ type: "udp4", reuseAddr: true });
+ let rx = 0, parsed = 0;
+ sock.on("error", (e: Error) => console.log(` socket error: ${e.message}`));
+ sock.on("message", (msg: Buffer, rinfo: any) => {
+ rx++;
+ console.log(` rx ${rinfo.address}:${rinfo.port} ${msg.length}B`);
+ for (const v of parseMdnsResponse(msg)) {
+ parsed++;
+ console.log(` -> ${v.ip}:${v.port} name=${v.txt.name} mac=${v.txt.mac ?? "(pre-mac fw)"} ` +
+ `v=${v.txt.version} ${v.txt.resolution} ${v.txt.orientation} host=${v.hostname}`);
+ }
+ });
+ const query = buildMdnsQuery();
+ const send = () => { try { sock.send(query, 5353, "224.0.0.251"); } catch (e: any) { console.log(` send failed: ${e?.message}`); } };
+ sock.bind(0, () => {
+ console.log(` bound ephemeral port ${sock.address().port}; querying 224.0.0.251:5353 ...`);
+ send();
+ });
+ setTimeout(send, 400);
+ setTimeout(() => {
+ try { sock.close(); } catch {}
+ console.log(` browse done: ${rx} response packet(s), ${parsed} viewport record(s)`);
+ if (rx === 0) console.log(" (0 responses: if Scrypted runs in Docker, check it uses host networking)");
+ }, 1500);
+ } catch (e: any) {
+ console.log(` probe failed: ${e?.message || e}`);
+ }
+})();
diff --git a/scrypted/scrypted-viewport.ts b/scrypted/scrypted-viewport.ts
index fcb02cb..f17b633 100644
--- a/scrypted/scrypted-viewport.ts
+++ b/scrypted/scrypted-viewport.ts
@@ -137,6 +137,189 @@ const DEFAULT_BRIGHTNESS = 100;
// JPEG end-of-image marker — the frame delimiter for the MJPEG demux loop.
const JPEG_EOI = Buffer.from([0xff, 0xd9]);
+// ---------------------------------------------------------------------------
+// mDNS discovery of viewports on the LAN.
+//
+// The firmware advertises `_scrypted-viewport._tcp` (port 80) with TXT
+// records name/version/resolution/orientation/mac. We browse with a plain
+// dgram socket on an EPHEMERAL port: per RFC 6762 §6.7 a query from a
+// non-5353 source port is a "legacy unicast" query and responders reply
+// unicast straight back to that port — verified in the ESP-IDF responder
+// (mdns_send.c directs the answer at the querier's addr/port whenever
+// src_port != 5353). This sidesteps binding :5353 entirely, so we never
+// conflict with Scrypted's own HomeKit mDNS stack or a host avahi-daemon,
+// and it works under Docker host-networking or a native install alike.
+//
+// One response packet carries PTR + SRV + TXT + A together (RFC 6763
+// §12.1 additional-record rules), so correlation is per-packet — no
+// follow-up queries needed.
+
+const MDNS_SERVICE = "_scrypted-viewport._tcp.local";
+
+type DiscoveredViewport = {
+ name: string; // TXT name — the viewport routing key
+ ip: string; // A record
+ port: number; // SRV port (the HTTP control plane, 80)
+ hostname: string; // SRV target, e.g. "viewport-kitchen.local"
+ version?: string;
+ resolution?: string;
+ orientation?: string;
+ mac?: string; // stable identity (firmware ≥ the mac-TXT build)
+};
+
+// Build the one-shot PTR question for MDNS_SERVICE.
+function buildMdnsQuery(): Buffer {
+ const labels = MDNS_SERVICE.split(".");
+ let qnameLen = 1; // trailing root byte
+ for (const l of labels) qnameLen += 1 + l.length;
+ const buf = Buffer.alloc(12 + qnameLen + 4);
+ buf.writeUInt16BE(Date.now() & 0xffff, 0); // ID — echoed in legacy-unicast replies
+ buf.writeUInt16BE(1, 4); // QDCOUNT=1; flags + other counts 0
+ let off = 12;
+ for (const l of labels) {
+ buf.writeUInt8(l.length, off++);
+ buf.write(l, off, "ascii");
+ off += l.length;
+ }
+ buf.writeUInt8(0, off++);
+ buf.writeUInt16BE(12, off); // QTYPE PTR
+ buf.writeUInt16BE(1, off + 2); // QCLASS IN
+ return buf;
+}
+
+// Decode a (possibly compressed) DNS name at `off`. Returns the dotted
+// name and the offset just past its in-place encoding.
+function readDnsName(msg: Buffer, off: number): { name: string; next: number } {
+ const parts: string[] = [];
+ let next = -1;
+ let jumps = 0;
+ while (off < msg.length) {
+ const len = msg[off];
+ if (len === 0) { if (next < 0) next = off + 1; break; }
+ if ((len & 0xc0) === 0xc0) { // compression pointer
+ if (next < 0) next = off + 2;
+ if (++jumps > 16) break; // malformed-loop guard
+ off = ((len & 0x3f) << 8) | msg[off + 1];
+ continue;
+ }
+ parts.push(msg.subarray(off + 1, off + 1 + len).toString("ascii"));
+ off += 1 + len;
+ }
+ return { name: parts.join("."), next: next < 0 ? off : next };
+}
+
+// Parse one mDNS response packet into any advertised viewports it contains.
+// Correlation is within the packet: PTR names an instance; the instance's
+// SRV gives hostname+port; TXT gives the metadata; A maps hostname → IPv4.
+function parseMdnsResponse(msg: Buffer): DiscoveredViewport[] {
+ try {
+ if (msg.length < 12) return [];
+ const qd = msg.readUInt16BE(4);
+ const total = msg.readUInt16BE(6) + msg.readUInt16BE(8) + msg.readUInt16BE(10);
+ let off = 12;
+ for (let i = 0; i < qd; i++) off = readDnsName(msg, off).next + 4;
+
+ const ptrs: string[] = [];
+ const srvs = new Map<string, { target: string; port: number }>();
+ const txts = new Map<string, Record<string, string>>();
+ const addrs = new Map<string, string>();
+ for (let i = 0; i < total && off < msg.length; i++) {
+ const rec = readDnsName(msg, off);
+ off = rec.next;
+ if (off + 10 > msg.length) break;
+ const type = msg.readUInt16BE(off);
+ const rdlen = msg.readUInt16BE(off + 8);
+ off += 10;
+ if (off + rdlen > msg.length) break;
+ const key = rec.name.toLowerCase();
+ if (type === 12 && key === MDNS_SERVICE) { // PTR
+ ptrs.push(readDnsName(msg, off).name);
+ } else if (type === 33) { // SRV
+ srvs.set(key, { target: readDnsName(msg, off + 6).name,
+ port: msg.readUInt16BE(off + 4) });
+ } else if (type === 16) { // TXT
+ const kv: Record<string, string> = {};
+ for (let t = off; t < off + rdlen; ) {
+ const l = msg[t];
+ const s = msg.subarray(t + 1, t + 1 + l).toString("utf8");
+ const eq = s.indexOf("=");
+ if (eq > 0) kv[s.slice(0, eq)] = s.slice(eq + 1);
+ t += 1 + l;
+ }
+ txts.set(key, kv);
+ } else if (type === 1 && rdlen === 4) { // A
+ addrs.set(key, `${msg[off]}.${msg[off + 1]}.${msg[off + 2]}.${msg[off + 3]}`);
+ }
+ off += rdlen;
+ }
+
+ const out: DiscoveredViewport[] = [];
+ for (const inst of ptrs) {
+ const key = inst.toLowerCase();
+ const srv = srvs.get(key);
+ const txt = txts.get(key) || {};
+ const ip = srv ? addrs.get(srv.target.toLowerCase()) : undefined;
+ if (!srv || !ip) continue;
+ out.push({
+ name: txt["name"] || inst.split(".")[0],
+ ip,
+ port: srv.port,
+ hostname: srv.target,
+ version: txt["version"],
+ resolution: txt["resolution"],
+ orientation: txt["orientation"],
+ mac: txt["mac"],
+ });
+ }
+ return out;
+ } catch { return []; }
+}
+
+// One-shot browse. Best-effort by design: any failure (sandbox without
+// dgram, no multicast route, zero responders) resolves to [] after the
+// timeout — callers degrade to manual host entry.
+function mdnsBrowse(console: any, timeoutMs = 1200): Promise<DiscoveredViewport[]> {
+ return new Promise((resolve) => {
+ let sock: any = null;
+ const found = new Map<string, DiscoveredViewport>(); // dedupe by mac, else ip
+ let done = false;
+ const finish = () => {
+ if (done) return;
+ done = true;
+ try { sock?.close(); } catch {}
+ resolve(Array.from(found.values()));
+ };
+ try {
+ const dgram = require("dgram");
+ sock = dgram.createSocket({ type: "udp4", reuseAddr: true });
+ sock.on("error", (e: Error) => {
+ console.warn(`mdns browse: ${e.message}`);
+ finish();
+ });
+ sock.on("message", (msg: Buffer) => {
+ for (const v of parseMdnsResponse(msg)) found.set(v.mac || v.ip, v);
+ });
+ const query = buildMdnsQuery();
+ const send = () => { try { sock.send(query, 5353, "224.0.0.251"); } catch {} };
+ sock.bind(0, send); // ephemeral source port → unicast replies
+ setTimeout(send, 400); // mDNS is lossy; ask twice
+ setTimeout(finish, timeoutMs);
+ } catch (e) {
+ console.warn(`mdns browse unavailable: ${(e as Error).message}`);
+ finish();
+ }
+ });
+}
+
+// "10.0.13.83 — kitchen (v1.3.2, 480x800)" — shown as a host choice; only
+// the first whitespace-delimited token (the address) is stored.
+function hostChoice(d: DiscoveredViewport): string {
+ return `${d.ip} — ${d.name} (v${d.version || "?"}, ${d.resolution || "?"})`;
+}
+function parseHostInput(value: any): string {
+ return String(value ?? "").trim().split(/\s/)[0];
+}
+
// ============================================================================
// Child: one viewport binding
// ============================================================================
@@ -236,13 +419,18 @@ class Viewport extends ScryptedDeviceBase implements Settings {
}
async getSettings(): Promise<Setting[]> {
+ // Discovered viewports become host-field choices (best-effort, []
+ // on any failure — manual entry always works).
+ const discovered = await this.provider.browseCached();
const settings: Setting[] = [
{
group: "Binding",
key: "host",
title: "IP or hostname",
- description: "Viewport's address on the LAN. Set this manually — find it via your DHCP table, or `dns-sd -G v4 viewport-<mac>.local` on macOS, or `avahi-resolve -n viewport-<mac>.local` on Linux. The info screen on the device itself shows its MAC + IP.",
+ description: "Viewport's address on the LAN. Viewports discovered via mDNS appear as choices (picking one stores just the address); manual entry also works — the device's info screen shows its MAC + IP.",
placeholder: "192.168.1.42",
+ combobox: true,
+ choices: discovered.map(hostChoice),
value: this.host,
} as any,
{
@@ -360,6 +548,9 @@ class Viewport extends ScryptedDeviceBase implements Settings {
try {
const stateRes = await fetchJsonRetry(`http://${this.host}/state`);
const configRes = await fetchJsonRetry(`http://${this.host}/config`);
+ // Seed the stable identity for mdns auto-heal: MAC survives
+ // renames and renumbers, unlike name or host.
+ if (stateRes?.mac) this.storage.setItem("mac", String(stateRes.mac));
settings.push(
{ group: "Status (live)", key: "_st_name", title: "name", value: stateRes.name, readonly: true } as any,
{ group: "Status (live)", key: "_st_mac", title: "mac", value: stateRes.mac, readonly: true } as any,
@@ -419,7 +610,10 @@ class Viewport extends ScryptedDeviceBase implements Settings {
if (!this.cameraIsDoorbell) arr = arr.filter(t => t !== "doorbell");
this.storage.setItem("triggers", JSON.stringify(arr));
} else {
- this.storage.setItem(key, String(value ?? ""));
+ // host may arrive as a discovered choice "ip — name (v..)" —
+ // keep only the address token.
+ this.storage.setItem(key, key === "host" ? parseHostInput(value)
+ : String(value ?? ""));
if (key === "cameraId") {
// The camera binding drives which wake triggers are valid
// (doorbell only for doorbell cameras). Reconcile the stored
@@ -470,6 +664,18 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
abort: AbortController; // also tears down the ffmpeg child via its listener
}>();
private scryptedBase = "";
+ // Last mDNS browse, reused briefly so settings-UI re-renders (which
+ // call getSettings repeatedly) don't spam the LAN with queries.
+ private lastBrowse: { at: number; results: DiscoveredViewport[] } | null = null;
+
+ async browseCached(): Promise<DiscoveredViewport[]> {
+ if (this.lastBrowse && Date.now() - this.lastBrowse.at < 30_000) {
+ return this.lastBrowse.results;
+ }
+ const results = await mdnsBrowse(this.console);
+ this.lastBrowse = { at: Date.now(), results };
+ return results;
+ }
constructor(nativeId?: string) {
super(nativeId);
@@ -711,6 +917,9 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
// ------------------------------------------------------------------------
async getCreateDeviceSettings(): Promise<Setting[]> {
+ // Best-effort LAN browse — discovered viewports become dropdown
+ // choices on the host field; [] just means manual entry.
+ const discovered = await this.browseCached();
return [
{
key: "name",
@@ -721,9 +930,11 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
{
key: "host",
title: "IP or hostname",
- description: "Where the firmware lives on the LAN — either an IP or `viewport-<mac>.local` (the device prints its own MAC on the info screen). The script POSTs to this string directly; no auto-resolution.",
+ description: "Where the firmware lives on the LAN. Viewports discovered via mDNS appear as choices (picking one stores just the address); manual entry of an IP or `viewport-<mac>.local` also works.",
placeholder: "192.168.1.42",
- },
+ combobox: true,
+ choices: discovered.map(hostChoice),
+ } as any,
{
key: "cameraId",
title: "Camera",
@@ -774,7 +985,9 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
// it into storage as a stable fallback for register/log paths.
const childStore = deviceManager.getDeviceStorage(nativeId);
childStore.setItem("display_name", name);
- childStore.setItem("host", String(settings.host || ""));
+ // A discovered-choice pick arrives as "ip — name (v..)"; store the
+ // address token only. Manual IPs/hostnames pass through unchanged.
+ childStore.setItem("host", parseHostInput(settings.host));
childStore.setItem("cameraId", String(settings.cameraId || ""));
childStore.setItem("orientation", String(settings.orientation || "portrait"));
// settings.triggers arrives as an array from the multi-select.
@@ -977,6 +1190,31 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
this.attachListener(v);
} catch (e) {
this.console.warn(`register "${name}" failed:`, (e as Error).message);
+ await this.tryMdnsHeal(v, name);
+ }
+ }
+
+ // Register failed → maybe the device renumbered (DHCP). Browse the LAN
+ // and match by MAC first (stable identity, seeded from /state or a
+ // prior heal), falling back to the advertised name. On a hit at a
+ // different address, rewrite host and retry the register once. Runs
+ // only on register failure, so it's naturally rate-limited to the
+ // 5-minute reregister cycle; the recursion terminates because a second
+ // failure re-browses and finds match.ip === v.host.
+ private async tryMdnsHeal(v: Viewport, name: string) {
+ try {
+ // Fresh browse, not the 30s cache — we're diagnosing a failure.
+ const discovered = await mdnsBrowse(this.console);
+ const knownMac = v.storage.getItem("mac") || "";
+ const match = (knownMac ? discovered.find(d => d.mac === knownMac) : undefined)
+ ?? discovered.find(d => d.name === name);
+ if (!match || match.ip === v.host) return;
+ this.console.log(`viewport "${name}": host ${v.host || "?"} -> ${match.ip} (mdns auto-heal)`);
+ v.storage.setItem("host", match.ip);
+ if (match.mac) v.storage.setItem("mac", match.mac);
+ await this.registerViewport(v);
+ } catch (e) {
+ this.console.warn(`mdns heal "${name}" failed:`, (e as Error).message);
}
}