src.nth.io/

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-06-14 10:57:01 -0500
committerLuke Hoersten <[email protected]>2026-06-14 10:57:01 -0500
commit1ba5250c96644440226ad71922f40c7eaa4ef649 (patch)
tree2e7b4262d1aa5528b224b6d8a176b7d19a91a986
parent52f4105aa059b9d7310d80cfb86abe844f7be05a (diff)
scrypted/script: auto-populate viewport host via mDNS
The host field is now optional in "+ Add Device". The script tries dns.promises.lookup("viewport-<name>.local") via the OS resolver (Bonjour on macOS, nss-mdns on Linux, host networking on Docker) on: - every registerViewport call (plugin start, child instantiation, settings change, periodic 5-min refresh), - right after createDevice so a fresh viewport's host field is populated by the time the operator opens its settings page. On a successful lookup that differs from the stored host, the resolved IP is written back to the child's storage. POST /config and POST /frame then use the resolved value. Falls back gracefully to the operator-entered host (with a one-line warning if both are empty). A per-viewport "Auto-resolve via mDNS" toggle (default on) opts out — useful for cross-VLAN setups or hosts where mDNS doesn't reach. scrypted/README.md adds a "How mDNS auto-resolve works" section covering the per-OS resolver requirements and the Docker host- networking note.
-rw-r--r--scrypted/README.md13
-rw-r--r--scrypted/scrypted-viewport.ts58
2 files changed, 66 insertions, 5 deletions
diff --git a/scrypted/README.md b/scrypted/README.md
index 5f25c70..0b6e8e3 100644
--- a/scrypted/README.md
+++ b/scrypted/README.md
@@ -26,7 +26,7 @@ On the "Scrypted Viewport" device page, click **+ Add Device**. You'll get a sma
| Field | What to enter |
| --- | --- |
| **Viewport name** | Lowercase routing key, e.g. `mudroom`. Becomes the device's mDNS hostname (`viewport-mudroom.local`) and the value the firmware sends back in callbacks. |
-| **IP or hostname** | Viewport's LAN address, e.g. `192.168.1.42`. Set a DHCP reservation if you want it to stay put. |
+| **IP or hostname** | Optional. Leave blank and the script auto-resolves `viewport-<name>.local` via the OS mDNS resolver (Bonjour on macOS, nss-mdns on Linux, host networking in Docker). Set manually if mDNS resolution isn't available in your network. |
| **Camera** | Dropdown — pick the camera whose events should wake this viewport. The dropdown is filtered to devices implementing `Camera`. |
| **Orientation** | `portrait` (480×800, default) or `landscape` (800×480). Tells the device + script what dimensions to send. |
@@ -85,6 +85,17 @@ That pulls in `@scrypted/sdk` and `@types/node` so the TS server can resolve eve
- Camera must respect `picture.width` / `picture.height` in `PictureOptions` or be paired with a snapshot plugin that resizes. If the camera returns the wrong size, the device rejects `/frame` with 400 and you'll see warning logs. Workaround: configure the camera plugin's snapshot size, or wait for v2 (FFmpeg-side resize).
- No retry on transport errors. Best-effort matches the device's own semantics; the next event or callback re-syncs.
+## How mDNS auto-resolve works
+
+Every time the script registers a viewport (on plugin start, every 5 minutes, on settings change, after a fresh `+ Add Device`), it tries `dns.lookup('viewport-<name>.local')` first. If the OS returns an IP, that IP overwrites the `host` field in the viewport's storage. Subsequent `POST /config` and `POST /frame` use the resolved IP.
+
+If the OS resolver doesn't know about `.local`:
+- **macOS**: works out of the box (Bonjour).
+- **Linux**: install `libnss-mdns` and ensure `mdns` is in `/etc/nsswitch.conf`'s `hosts:` line.
+- **Docker**: use `--network host` (Scrypted's recommended setup). Bridge networking breaks `.local` resolution.
+
+When mDNS doesn't resolve, the script silently falls back to the operator-entered `host`. You can disable mDNS per viewport via the **Auto-resolve via mDNS** toggle on its Settings page.
+
## End-to-end smoke test
After installing the script and adding one viewport binding:
diff --git a/scrypted/scrypted-viewport.ts b/scrypted/scrypted-viewport.ts
index 7d10a57..e2c5ad6 100644
--- a/scrypted/scrypted-viewport.ts
+++ b/scrypted/scrypted-viewport.ts
@@ -50,6 +50,8 @@ import sdk, {
SettingValue,
} from "@scrypted/sdk";
+import { promises as dns } from "dns";
+
const { systemManager, endpointManager, mediaManager, deviceManager } = sdk;
// Tuning constants. Frame interval is also exposed on the parent's
@@ -59,6 +61,22 @@ const REREGISTER_INTERVAL_MS = 5 * 60_000;
const HTTP_TIMEOUT_MS = 1_000;
const DEFAULT_IDLE_TIMEOUT_MS = 60_000;
const DEFAULT_BRIGHTNESS = 80;
+const MDNS_LOOKUP_TIMEOUT_MS = 1_500;
+
+// Resolve `viewport-<name>.local` via the OS resolver (Bonjour on macOS,
+// nss-mdns on Linux, host networking on Docker). Returns null on failure or
+// timeout — caller falls back to the operator-entered host.
+async function lookupMdns(hostname: string): Promise<string | null> {
+ try {
+ const lookup = dns.lookup(hostname, { family: 4 });
+ const timeout = new Promise<null>(r => setTimeout(() => r(null), MDNS_LOOKUP_TIMEOUT_MS));
+ const result = await Promise.race([lookup, timeout]);
+ if (!result) return null;
+ return (result as { address: string }).address || null;
+ } catch {
+ return null;
+ }
+}
// ============================================================================
// Child: one viewport binding
@@ -83,17 +101,27 @@ class Viewport extends ScryptedDeviceBase implements Settings {
const v = this.storage.getItem("brightness");
return v ? Math.max(0, Math.min(100, parseInt(v, 10) || 0)) : DEFAULT_BRIGHTNESS;
}
+ get mdnsAuto(): boolean {
+ return this.storage.getItem("mdns_auto") !== "false"; // default true
+ }
async getSettings(): Promise<Setting[]> {
return [
{
key: "host",
title: "IP or hostname",
- description: "Viewport's address on the LAN. Update if DHCP renumbers (or use a DHCP reservation).",
+ description: "Viewport's address on the LAN. Auto-resolved via mDNS when the toggle below is on; otherwise set manually.",
placeholder: "192.168.1.42",
value: this.host,
},
{
+ key: "mdns_auto",
+ title: "Auto-resolve via mDNS",
+ description: "Look up viewport-<name>.local via the OS resolver on every register and overwrite the host field with the discovered IP. Disable for cross-VLAN setups or hosts where mDNS resolution doesn't work.",
+ type: "boolean",
+ value: this.mdnsAuto,
+ },
+ {
key: "cameraId",
title: "Camera",
description: "Camera whose events drive this viewport's wake/sleep, and whose snapshots get streamed.",
@@ -231,8 +259,9 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
},
{
key: "host",
- title: "IP or hostname",
- placeholder: "192.168.1.42",
+ title: "IP or hostname (optional)",
+ description: "Leave blank to auto-resolve via mDNS (viewport-<name>.local). Set manually if mDNS doesn't reach this network.",
+ placeholder: "auto-resolve via mDNS",
},
{
key: "cameraId",
@@ -270,6 +299,15 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
this.childIds = [...this.childIds, nativeId];
this.console.log(`created viewport "${name}" (${nativeId})`);
+
+ // Fire-and-forget mDNS resolve so the host field is auto-populated
+ // by the time the operator opens the new device's settings page.
+ // getDevice() above already attempted a register; this catches the
+ // case where the operator left host blank and mDNS resolution was
+ // the only way to fill it.
+ const child = this.viewports.get(nativeId);
+ if (child) this.registerViewport(child).catch(() => {});
+
return nativeId;
}
@@ -314,8 +352,20 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
}
}
+ private async refreshHostFromMdns(v: Viewport): Promise<void> {
+ if (!v.mdnsAuto || !v.name) return;
+ const ip = await lookupMdns(`viewport-${v.name}.local`);
+ if (!ip || ip === v.host) return;
+ this.console.log(`mDNS: "${v.name}" host "${v.host || "(empty)"}" -> "${ip}"`);
+ v.storage.setItem("host", ip);
+ }
+
private async registerViewport(v: Viewport) {
- if (!v.host) return;
+ await this.refreshHostFromMdns(v);
+ if (!v.host) {
+ this.console.warn(`register "${v.name}" skipped — no host (set one manually or check mDNS)`);
+ return;
+ }
try {
await this.postJSON(`http://${v.host}/config`, {
viewport: v.name,