src.nth.io/

summaryrefslogtreecommitdiff
path: root/scrypted
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-06-14 20:48:05 -0500
committerLuke Hoersten <[email protected]>2026-06-14 20:48:05 -0500
commit79ac384dbb6177d2d828d6ddc2db516888038ae8 (patch)
tree097974960310e3293c325d2f7c0410f926976118 /scrypted
parentaadfa077e81f0ad9677aa7f4504feef55718da99 (diff)
scrypted: per-viewport frame interval + trigger picker + live status; drop mDNS auto-resolve
- 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.
Diffstat (limited to 'scrypted')
-rw-r--r--scrypted/README.md40
-rw-r--r--scrypted/scrypted-viewport.ts179
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-<mac>.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-<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.
+| OS | Browse all viewports on the LAN | Resolve a hostname → IP |
+|---|---|---|
+| macOS | `dns-sd -B _scrypted-viewport._tcp local.` | `dns-sd -G v4 viewport-<mac>.local` |
+| Linux (with `avahi`) | `avahi-browse -tr _scrypted-viewport._tcp` | `avahi-resolve -n viewport-<mac>.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-<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
@@ -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<string> {
+ 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<Setting[]> {
- 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-<mac>.local` on macOS, or `avahi-resolve -n viewport-<mac>.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-<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,
- },
+ } 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<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) {
// 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<Setting[]> {
+ 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