From 1ba5250c96644440226ad71922f40c7eaa4ef649 Mon Sep 17 00:00:00 2001 From: Luke Hoersten Date: Sun, 14 Jun 2026 10:57:01 -0500 Subject: scrypted/script: auto-populate viewport host via mDNS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host field is now optional in "+ Add Device". The script tries dns.promises.lookup("viewport-.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. --- scrypted/README.md | 13 +++++++++- scrypted/scrypted-viewport.ts | 58 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 5 deletions(-) (limited to 'scrypted') 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-.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-.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-.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 { + try { + const lookup = dns.lookup(hostname, { family: 4 }); + const timeout = new Promise(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,16 +101,26 @@ 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 { 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-.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", @@ -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-.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 { + 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, -- cgit v1.2.3