From 79ac384dbb6177d2d828d6ddc2db516888038ae8 Mon Sep 17 00:00:00 2001 From: Luke Hoersten Date: Sun, 14 Jun 2026 20:48:05 -0500 Subject: scrypted: per-viewport frame interval + trigger picker + live status; drop mDNS auto-resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move "frame push interval" from the parent's global setting to a per-viewport child setting (different cameras want different rates). Clamped to ≥33 ms (~30 fps max). Default still 1000 ms (1 fps). - New "Wake triggers" multi-select on each viewport: doorbell, motion, person. Default = all three. Clear all of them for tap-only mode. handleCameraEvent now gates by this set; clearing it cleanly turns a viewport into a tap-only display with no auto-wake. - Add a "Status (live)" group at the bottom of each viewport's Settings page that fetches /state + /config in parallel (1.5 s timeout) and surfaces every field — name, mac, ip, awake/asleep, configured, uptime, frame + error counters, resolution, free heap/psram, firmware version, registered scrypted URL. Offline devices say so cleanly. - Drop mDNS auto-resolve: no more lookupMdns / refreshHostFromMdns, no per-viewport "auto-resolve via mDNS" toggle, no `dns` require. The host field is just an operator-set string now. The README documents the manual lookup commands (dns-sd, avahi-resolve) so finding the device IP stays a one-liner. - Parent's Settings page is now informational-only (viewport count + callback base URL); per-viewport tuning lives on each child. --- scrypted/README.md | 40 ++++++---- scrypted/scrypted-viewport.ts | 179 ++++++++++++++++++++++++++---------------- 2 files changed, 135 insertions(+), 84 deletions(-) diff --git a/scrypted/README.md b/scrypted/README.md index 3666dbd..c825b7c 100644 --- a/scrypted/README.md +++ b/scrypted/README.md @@ -34,23 +34,27 @@ Click **Create**. The script immediately POSTs `/config` to the device. Within a ## Editing a viewport -Open the viewport device's page. It has its own Settings tab with the same four fields, plus two extras: +Open the viewport device's page → **Settings**. The fields are grouped: -- **Idle timeout (ms)** — sent to the device in `/config`. Both sides time independently. `0` disables the device-side idle timer; non-zero must be ≥ 5000. Default 60000. +**Binding** +- **IP or hostname** — where the firmware lives on the LAN. +- **Camera** — which Scrypted camera drives the wake events + the snapshot source. +- **Wake triggers** — multi-select of `doorbell`, `motion`, `person`. Clear all of them for tap-only mode (the viewport never wakes from Scrypted; user must tap the panel to see the camera). Default: all three on. + +**Display** +- **Orientation** — `portrait` (480×800) or `landscape` (800×480). Sent to the device in `/config`. - **Brightness (0–100)** — gamma-corrected on the panel. Default 80. +- **Idle timeout (ms)** — how long the device stays awake after the last paint before it sleeps itself. `0` disables; non-zero must be ≥ 5000. Default 60000. +- **Frame push interval (ms)** — how often a JPEG is POSTed during an active stream. 1000 = 1 fps; 100 = 10 fps. Clamped to ≥ 33 ms (~30 fps max). Default 1000. + +**Status (live)** — read-only fields that fetch `/state` + `/config` from the device every time you open the Settings page: name, MAC, IP, awake/asleep, configured flag, uptime, frame counters, error counters, resolution, free heap, free PSRAM, firmware version, and the registered scrypted callback URL. If the device is offline you'll just see "device: offline / unreachable". -Changing any setting triggers an immediate re-register and re-subscribes the camera listener if the camera changed. +Changing any binding setting triggers an immediate re-register + re-subscribes the camera listener if the camera changed. ## Removing a viewport Open the viewport device's page → **Settings** menu → **Delete Device**. The script stops any active stream, unsubscribes from the camera, and forgets the binding. The physical device keeps its NVS-stored config until it gets a fresh `/config` from somewhere — to fully wipe it, plug USB and run `idf.py erase-flash` then reflash. -## Global tuning - -Open the parent "Scrypted Viewport" device's Settings page: - -- **Frame push interval (ms)** — how often a snapshot is pushed during an active stream. 1000 = 1 fps. Lower for faster updates at higher CPU cost. - ## What the script does on each event - **Camera event (doorbell ring / motion / person)** → look up the viewport bound to that camera → `startStream(viewport)`: @@ -85,16 +89,20 @@ 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 +## Finding a viewport's IP / hostname + +The script does NOT auto-resolve mDNS — it just POSTs to the host string you enter. The host field accepts either an IP or a hostname (the OS resolver handles `.local` lookups inside the Scrypted container). + +On a fresh device the mDNS hostname is `viewport-.local` (the MAC with colons stripped). You can read the MAC straight off the device's info screen at boot, or discover it from your shell: -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. +| OS | Browse all viewports on the LAN | Resolve a hostname → IP | +|---|---|---| +| macOS | `dns-sd -B _scrypted-viewport._tcp local.` | `dns-sd -G v4 viewport-.local` | +| Linux (with `avahi`) | `avahi-browse -tr _scrypted-viewport._tcp` | `avahi-resolve -n viewport-.local` | -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. +Once you've POSTed `/config` with a friendlier name (e.g. `mudroom`) via the kitchen viewport's settings, the device re-advertises as `viewport-mudroom.local` and you can use that instead. The MAC-derived hostname stays available as a fallback. -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. +If Scrypted runs in a Docker container without host networking, `.local` resolution won't reach the LAN — enter a static IP and add a DHCP reservation so it stays stable. ## End-to-end smoke test diff --git a/scrypted/scrypted-viewport.ts b/scrypted/scrypted-viewport.ts index 9c283e8..d6545cf 100644 --- a/scrypted/scrypted-viewport.ts +++ b/scrypted/scrypted-viewport.ts @@ -53,7 +53,6 @@ declare const log: any; declare const device: any; declare const require: any; -const dns = require("dns").promises; // Loose type aliases — purely cosmetic for the rest of the script's // signatures, since the runtime values are `any`. @@ -75,22 +74,6 @@ 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 @@ -115,60 +98,126 @@ 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 + get frameIntervalMs(): number { + const v = this.storage.getItem("frame_interval_ms"); + const parsed = v ? parseInt(v, 10) : NaN; + return Number.isFinite(parsed) ? Math.max(33, parsed) : DEFAULT_FRAME_INTERVAL_MS; + } + // Which camera-event types wake this viewport. Empty = tap-only, + // never woken by Scrypted. Default = all three (doorbell + motion + + // person detection). + get triggers(): Set { + const v = this.storage.getItem("triggers"); + if (v === null) return new Set(["doorbell", "motion", "person"]); + try { return new Set(JSON.parse(v)); } catch { return new Set(); } } async getSettings(): Promise { - return [ + const settings: Setting[] = [ { + group: "Binding", key: "host", title: "IP or hostname", - description: "Viewport's address on the LAN. Auto-resolved via mDNS when the toggle below is on; otherwise set manually.", + description: "Viewport's address on the LAN. Set this manually — find it via your DHCP table, or `dns-sd -G v4 viewport-.local` on macOS, or `avahi-resolve -n viewport-.local` on Linux. The info screen on the device itself shows its MAC + IP.", 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, - }, + } as any, { + group: "Binding", key: "cameraId", title: "Camera", description: "Camera whose events drive this viewport's wake/sleep, and whose snapshots get streamed.", type: "device", deviceFilter: `interfaces.includes('${ScryptedInterface.Camera}')`, value: this.cameraId, - }, + } as any, { + group: "Binding", + key: "triggers", + title: "Wake triggers", + description: "Which camera-event types automatically wake the viewport. Clear all of them for tap-only mode (the viewport never wakes from Scrypted; user must tap to see the camera).", + choices: ["doorbell", "motion", "person"], + multiple: true, + value: Array.from(this.triggers), + } as any, + { + group: "Display", key: "orientation", title: "Orientation", description: "Panel orientation. Frames are sent at this effective resolution.", choices: ["portrait", "landscape"], value: this.orientation, - }, + } as any, { + group: "Display", + key: "brightness", + title: "Brightness (0–100)", + description: "Sent to the device via /config. Gamma-corrected on the panel.", + type: "number", + value: this.brightness, + } as any, + { + group: "Display", key: "idle_timeout_ms", title: "Idle timeout (ms)", - description: "Sent to the device via /config; both sides time independently. 0 disables the device-side idle timer; non-zero must be ≥ 5000.", + description: "How long the device stays awake after the last paint before it sleeps itself. 0 disables; non-zero must be ≥ 5000.", type: "number", value: this.idleTimeoutMs, - }, + } as any, { - key: "brightness", - title: "Brightness (0–100)", - description: "Sent to the device via /config. Gamma-corrected on the panel.", + group: "Display", + key: "frame_interval_ms", + title: "Frame push interval (ms)", + description: `How often a JPEG snapshot is POSTed during an active stream. Lower = smoother but more network + CPU; clamped to ≥ 33 ms (~30 fps max). Default ${DEFAULT_FRAME_INTERVAL_MS}.`, type: "number", - value: this.brightness, - }, + value: this.frameIntervalMs, + } as any, ]; + + // Live device snapshot: GET /state + /config in parallel with a + // short timeout, then render as a read-only "Status" section. If + // the device is offline we still surface the binding fields so the + // operator can change them — the status fields just say "offline". + if (this.host) { + try { + const ctrl = AbortSignal.timeout(1500); + const [stateRes, configRes] = await Promise.all([ + fetch(`http://${this.host}/state`, { signal: ctrl }).then(r => r.json()), + fetch(`http://${this.host}/config`, { signal: ctrl }).then(r => r.json()), + ]); + 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, + { group: "Status (live)", key: "_st_ip", title: "ip", value: stateRes.ip, readonly: true } as any, + { group: "Status (live)", key: "_st_state", title: "state", value: stateRes.state, readonly: true } as any, + { group: "Status (live)", key: "_st_cfg", title: "configured", value: String(stateRes.configured), readonly: true } as any, + { group: "Status (live)", key: "_st_uptime", title: "uptime (ms)", value: String(stateRes.uptime_ms), readonly: true } as any, + { group: "Status (live)", key: "_st_last", title: "last frame (ms ago)", value: String(stateRes.last_frame_ms_ago ?? "(none)"), readonly: true } as any, + { group: "Status (live)", key: "_st_fr", title: "frames received", value: String(stateRes.frames_received), readonly: true } as any, + { group: "Status (live)", key: "_st_err", title: "decode errors", value: String(stateRes.decode_errors), readonly: true } as any, + { group: "Status (live)", key: "_st_post", title: "state post failures", value: String(stateRes.state_post_failures), readonly: true } as any, + { group: "Status (live)", key: "_st_res", title: "resolution", value: stateRes.resolution, readonly: true } as any, + { group: "Status (live)", key: "_st_heap", title: "free heap (bytes)", value: String(stateRes.free_heap), readonly: true } as any, + { group: "Status (live)", key: "_st_psram", title: "free PSRAM (bytes)", value: String(stateRes.free_psram), readonly: true } as any, + { group: "Status (live)", key: "_st_ver", title: "firmware", value: stateRes.version, readonly: true } as any, + { group: "Status (live)", key: "_cfg_scrypt",title: "config: scrypted URL",value: configRes.scrypted ?? "(not set)", readonly: true } as any, + ); + } catch (e) { + settings.push({ group: "Status (live)", key: "_st_err", title: "device", value: `offline / unreachable (${(e as Error).message})`, readonly: true } as any); + } + } + + return settings; } async putSetting(key: string, value: SettingValue) { - this.storage.setItem(key, String(value ?? "")); + if (key.startsWith("_")) return; // ignore read-only status fields + if (key === "triggers") { + // multi-select arrives as array; serialise to JSON for storage + this.storage.setItem("triggers", JSON.stringify(Array.isArray(value) ? value : [])); + } else { + this.storage.setItem(key, String(value ?? "")); + } await this.provider.onBindingChanged(this); } } @@ -206,11 +255,6 @@ class ScryptedViewportProvider extends ScryptedDeviceBase this.storage.setItem("childIds", JSON.stringify(ids)); } - private get frameIntervalMs(): number { - const v = this.storage.getItem("frame_interval_ms"); - return v ? Math.max(100, parseInt(v, 10) || DEFAULT_FRAME_INTERVAL_MS) - : DEFAULT_FRAME_INTERVAL_MS; - } private async start() { // endpointManager.getInsecurePublicLocalEndpoint() takes a nativeId @@ -388,14 +432,6 @@ 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) { // Guard against transient empty names. Scrypted occasionally hands // us a Viewport whose `.name` hasn't resolved yet (race between @@ -407,9 +443,8 @@ class ScryptedViewportProvider extends ScryptedDeviceBase this.console.warn(`register skipped — empty name on ${v.nativeId}; will retry on next event`); return; } - await this.refreshHostFromMdns(v); if (!v.host) { - this.console.warn(`register "${name}" skipped — no host (set one manually or check mDNS)`); + this.console.warn(`register "${name}" skipped — no host. Set the viewport's "IP or hostname" field; see the README for how to find it via mDNS from your shell.`); return; } try { @@ -435,10 +470,11 @@ class ScryptedViewportProvider extends ScryptedDeviceBase private handleCameraEvent(v: Viewport, details: any, data: any) { const iface = details.eventInterface; + const allowed = v.triggers; let trigger = false; - if (iface === ScryptedInterface.BinarySensor && data === true) trigger = true; - if (iface === ScryptedInterface.MotionSensor && data === true) trigger = true; - if (iface === ScryptedInterface.ObjectDetector) { + if (allowed.has("doorbell") && iface === ScryptedInterface.BinarySensor && data === true) trigger = true; + if (allowed.has("motion") && iface === ScryptedInterface.MotionSensor && data === true) trigger = true; + if (allowed.has("person") && iface === ScryptedInterface.ObjectDetector) { const detections = data?.detections ?? []; if (detections.some((d: any) => d?.className === "person")) trigger = true; } @@ -461,7 +497,7 @@ class ScryptedViewportProvider extends ScryptedDeviceBase this.pushFrame(v, abort).catch(e => { if (!abort.signal.aborted) this.console.warn(`pushFrame "${v.name}":`, (e as Error).message); }); - }, this.frameIntervalMs); + }, v.frameIntervalMs); const timeoutMs = v.idleTimeoutMs > 0 ? v.idleTimeoutMs : DEFAULT_IDLE_TIMEOUT_MS; const timeout = setTimeout(() => { @@ -555,24 +591,31 @@ class ScryptedViewportProvider extends ScryptedDeviceBase } // ------------------------------------------------------------------------ - // Parent Settings — global tuning only + // Parent Settings — informational only; per-viewport tuning lives on each + // child's own Settings page. // ------------------------------------------------------------------------ async getSettings(): Promise { + const count = this.viewports.size; return [ { - key: "frame_interval_ms", - title: "Frame push interval (ms)", - description: "How often a snapshot is pushed to each viewport during an active stream. 1000 = 1 fps.", - type: "number", - value: this.frameIntervalMs, - }, + key: "viewport_count", + title: "Registered viewports", + description: "Number of child viewport bindings under this parent. Each one's host / camera / brightness / orientation / fps lives on its own Settings page.", + value: String(count), + readonly: true, + } as any, + { + key: "callback_base", + title: "Callback base URL", + description: "Endpoint the firmware POSTs back to for tap-initiated wake/sleep.", + value: this.scryptedBase || "(not yet resolved)", + readonly: true, + } as any, ]; } - async putSetting(key: string, value: SettingValue) { - this.storage.setItem(key, String(value ?? "")); - } + async putSetting(_key: string, _value: SettingValue) {} // ------------------------------------------------------------------------ // Tiny HTTP helper -- cgit v1.2.3