// Scrypted Viewport — v1 Scripts-plugin script // // Architecture // ------------ // One parent device (DeviceProvider + DeviceCreator + HttpRequestHandler) // spawns N child "Viewport" devices via the Scrypted UI. Each child holds // the per-viewport binding (host, camera, orientation, idle timeout, // brightness) as plain device settings and is editable from its own // Settings page in the Scrypted UI. The parent owns the camera event // subscriptions, the snapshot push loop, the per-stream safety timer, // and the inbound `POST /state` handler. // // Install // ------- // 1. In Scrypted: Plugins → install "Scripts" if needed. // 2. + Add Device → Scripts plugin → New Script. // 3. Paste this entire file. Save. // 4. Open the new "Scrypted Viewport" device. Click "+ Add Device" on // its page to create a viewport binding: // Viewport name e.g. "mudroom" (becomes the routing key + mDNS name) // IP or hostname e.g. "192.168.1.42" // Camera pick from the dropdown of Camera devices // Orientation portrait (480×800) or landscape (800×480) // 5. The script POSTs /config to the device immediately and re-issues it // every 5 minutes so a reboot or DHCP renumber re-syncs. // 6. Edit a viewport's settings from its own device page in the UI. The // script re-registers and re-subscribes whenever a setting changes. // // v1 limits (path to v2: a packaged plugin with FFmpeg streaming) // --------------------------------------------------------------- // - Snapshot-rate (~1 fps) via Camera.takePicture(). Live-rate MJPEG over // POST /stream is v2. // - Manual IP per viewport (no mDNS-SD discovery). Use a DHCP reservation. // - Camera must respect picture.width/height OR be paired with a snapshot // plugin that resizes. Otherwise /frame returns 400. // The Scripts plugin (in @scrypted/core) evaluates this file inside the // scryptedEval sandbox. The runtime pre-injects the SDK names as scope // locals — no `import` is needed (or allowed: any `import ... from // "@scrypted/sdk"` compiles to a require() that fails to resolve). // All we do here is `declare` each one so TypeScript is happy; the // declarations erase at compile time and the values come from the // runtime scope. declare const sdk: any; declare const ScryptedDeviceBase: any; declare const ScryptedDeviceType: any; declare const ScryptedInterface: any; declare const systemManager: any; declare const endpointManager: any; declare const mediaManager: any; declare const deviceManager: any; 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`. type DeviceCreator = any; type DeviceCreatorSettings = any; type DeviceProvider = any; type EventListenerRegister = any; type HttpRequest = any; type HttpRequestHandler = any; type HttpResponse = any; type Setting = any; type Settings = any; type SettingValue = any; // Tuning constants. Frame interval is also exposed on the parent's // Settings page so it can be tweaked without editing the script. const DEFAULT_FRAME_INTERVAL_MS = 1_000; 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 // ============================================================================ class Viewport extends ScryptedDeviceBase implements Settings { constructor(public provider: ScryptedViewportProvider, nativeId: string) { super(nativeId); } get host(): string { return this.storage.getItem("host") || ""; } get cameraId(): string { return this.storage.getItem("cameraId") || ""; } get orientation(): "portrait" | "landscape" { const v = this.storage.getItem("orientation"); return v === "landscape" ? "landscape" : "portrait"; } get idleTimeoutMs(): number { const v = this.storage.getItem("idle_timeout_ms"); return v ? Math.max(0, parseInt(v, 10) || 0) : DEFAULT_IDLE_TIMEOUT_MS; } get brightness(): number { 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. 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", 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, }, { key: "orientation", title: "Orientation", description: "Panel orientation. Frames are sent at this effective resolution.", choices: ["portrait", "landscape"], value: this.orientation, }, { 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.", type: "number", value: this.idleTimeoutMs, }, { key: "brightness", title: "Brightness (0–100)", description: "Sent to the device via /config. Gamma-corrected on the panel.", type: "number", value: this.brightness, }, ]; } async putSetting(key: string, value: SettingValue) { this.storage.setItem(key, String(value ?? "")); await this.provider.onBindingChanged(this); } } // ============================================================================ // Parent: provider + HTTP handler + global tuning // ============================================================================ class ScryptedViewportProvider extends ScryptedDeviceBase implements DeviceProvider, DeviceCreator, HttpRequestHandler, Settings { private viewports = new Map(); // nativeId -> child instance private listeners = new Map(); // nativeId -> camera event listener private streams = new Map stream control interval: NodeJS.Timeout; timeout: NodeJS.Timeout; abort: AbortController; }>(); private scryptedBase = ""; constructor(nativeId?: string) { super(nativeId); this.start().catch(e => this.console.error("start failed", e)); } // ------------------------------------------------------------------------ // Lifecycle // ------------------------------------------------------------------------ private get childIds(): string[] { try { return JSON.parse(this.storage.getItem("childIds") || "[]"); } catch { return []; } } private set childIds(ids: string[]) { 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 // (string) — passing this.id (numeric Scrypted DB ID) throws // "invalid nativeId N". this.nativeId is the right key, and an // omitted nativeId falls back to the plugin's own endpoint. const raw = await endpointManager.getInsecurePublicLocalEndpoint(this.nativeId); this.scryptedBase = raw.replace(/\/$/, ""); this.console.log(`Scrypted Viewport up. Callback URL base: ${this.scryptedBase}`); // Re-discover every known child so Scrypted reattaches its storage // to the nativeId. Without this, `new Viewport(...)` instantiates // with `this.storage === undefined` and every storage-backed getter // (host / cameraId / orientation / ...) throws on script reload. // Then eagerly instantiate so each child's registration + camera // event subscription happen at plugin load. for (const nativeId of this.childIds) { try { await deviceManager.onDeviceDiscovered({ providerNativeId: this.nativeId, nativeId, name: nativeId, // overridden by Scrypted from its existing record type: ScryptedDeviceType.SmartDisplay, interfaces: [ScryptedInterface.Settings], }); await this.getDevice(nativeId); } catch (e) { this.console.warn(`load child ${nativeId} failed:`, (e as Error).message); } } // Periodic re-register so a device that rebooted or got a new IP // re-syncs without manual intervention. setInterval(() => { for (const v of this.viewports.values()) { this.registerViewport(v).catch(() => {}); } }, REREGISTER_INTERVAL_MS); } // ------------------------------------------------------------------------ // DeviceProvider // ------------------------------------------------------------------------ async getDevice(nativeId: string): Promise { let v = this.viewports.get(nativeId); if (!v) { v = new Viewport(this, nativeId); this.viewports.set(nativeId, v); this.attachListener(v); await this.registerViewport(v); } return v; } async releaseDevice(id: string, nativeId: string) { const v = this.viewports.get(nativeId); if (v) { this.stopStream(v.name, /*sendSleep=*/ false); this.detachListener(nativeId); this.viewports.delete(nativeId); } this.childIds = this.childIds.filter(x => x !== nativeId); } // ------------------------------------------------------------------------ // DeviceCreator — "+ Add Device" form on the parent // ------------------------------------------------------------------------ async getCreateDeviceSettings(): Promise { return [ { key: "name", title: "Viewport name", description: 'Routing key sent back in callbacks + basis for the device\'s mDNS hostname (viewport-.local). Lowercase, no spaces. Example: "mudroom".', placeholder: "mudroom", }, { key: "host", 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", title: "Camera", type: "device", deviceFilter: `interfaces.includes('${ScryptedInterface.Camera}')`, }, { key: "orientation", title: "Orientation", choices: ["portrait", "landscape"], value: "portrait", }, ]; } async createDevice(settings: DeviceCreatorSettings): Promise { const name = String(settings.name || "viewport").trim(); const nativeId = `vp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; // 1. Register the device with Scrypted FIRST. deviceManager // materialises the storage container only after discovery — // calling getDeviceStorage before this returns undefined and // setItem() throws "Cannot read properties of undefined". await deviceManager.onDeviceDiscovered({ providerNativeId: this.nativeId, nativeId, name, type: ScryptedDeviceType.SmartDisplay, interfaces: [ScryptedInterface.Settings], }); // 2. Now safe to seed the child's storage from the form values. // display_name is the canonical user-facing name; v.name (the // ScryptedDeviceBase one) is async-loaded from Scrypted's record // and races with our first registerViewport call, so we mirror // 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 || "")); childStore.setItem("cameraId", String(settings.cameraId || "")); childStore.setItem("orientation", String(settings.orientation || "portrait")); this.childIds = [...this.childIds, nativeId]; this.console.log(`created viewport "${name}" (${nativeId})`); // Kick off the first register cycle (mDNS resolve + POST /config). // Fire-and-forget — the new device shows up immediately either way. const child = await this.getDevice(nativeId); if (child) this.registerViewport(child).catch(() => {}); return nativeId; } // ------------------------------------------------------------------------ // Per-binding plumbing (camera subscription + /config registration) // ------------------------------------------------------------------------ onBindingChanged = async (v: Viewport): Promise => { const nid = v.nativeId!; this.detachListener(nid); // Any active stream for this viewport is now stale (camera may have // changed). Stop it cleanly; the next event/wake will start fresh. this.stopStream(v.name, /*sendSleep=*/ false); this.attachListener(v); await this.registerViewport(v); }; private attachListener(v: Viewport) { if (!v.cameraId) return; const cam = systemManager.getDeviceById(v.cameraId); const tag = v.name || v.storage.getItem("display_name") || v.nativeId; if (!cam) { this.console.warn(`viewport "${tag}": camera ${v.cameraId} not found`); return; } const ifaces = [ ScryptedInterface.BinarySensor, // doorbell ScryptedInterface.MotionSensor, // motion ScryptedInterface.ObjectDetector, // person / etc ]; const reg = cam.listen(ifaces, (source, details, data) => { this.handleCameraEvent(v, details, data); }); this.listeners.set(v.nativeId!, reg); this.console.log(`viewport "${tag}": subscribed to "${cam.name}"`); } private detachListener(nativeId: string) { const reg = this.listeners.get(nativeId); if (reg) { try { reg.removeListener(); } catch {} this.listeners.delete(nativeId); } } 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 // device-record load and event delivery); POSTing /config with an // empty viewport just gets a 400 from the firmware. Fall back to // the stored display name from storage if it's there, else skip. const name = (v.name && v.name.trim()) || v.storage.getItem("display_name") || ""; if (!name) { 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)`); return; } try { await this.postJSON(`http://${v.host}/config`, { viewport: name, scrypted: this.scryptedBase, idle_timeout_ms: v.idleTimeoutMs, orientation: v.orientation, brightness: v.brightness, }); // Cache the name in storage so a future empty-.name event can // still find it. createDevice + putSetting always update this. v.storage.setItem("display_name", name); this.console.log(`registered "${name}" (${v.host})`); } catch (e) { this.console.warn(`register "${name}" failed:`, (e as Error).message); } } // ------------------------------------------------------------------------ // Camera event → stream // ------------------------------------------------------------------------ private handleCameraEvent(v: Viewport, details: any, data: any) { const iface = details.eventInterface; let trigger = false; if (iface === ScryptedInterface.BinarySensor && data === true) trigger = true; if (iface === ScryptedInterface.MotionSensor && data === true) trigger = true; if (iface === ScryptedInterface.ObjectDetector) { const detections = data?.detections ?? []; if (detections.some((d: any) => d?.className === "person")) trigger = true; } if (!trigger) return; this.console.log(`event ${iface} -> "${v.name}": wake`); this.startStream(v).catch(e => this.console.error("startStream failed", e)); } private async startStream(v: Viewport) { // Race rule: cancel pending operations on every callback before // beginning a fresh stream. this.stopStream(v.name, /*sendSleep=*/ false); if (!v.host || !v.cameraId) return; await this.postJSON(`http://${v.host}/state`, { state: "wake" }); const abort = new AbortController(); const interval = setInterval(() => { this.pushFrame(v, abort).catch(e => { if (!abort.signal.aborted) this.console.warn(`pushFrame "${v.name}":`, (e as Error).message); }); }, this.frameIntervalMs); const timeoutMs = v.idleTimeoutMs > 0 ? v.idleTimeoutMs : DEFAULT_IDLE_TIMEOUT_MS; const timeout = setTimeout(() => { this.console.log(`"${v.name}": Scrypted-side stream timeout — stopping`); this.stopStream(v.name); }, timeoutMs); this.streams.set(v.name, { interval, timeout, abort }); } private stopStream(name: string, sendSleep = true) { const s = this.streams.get(name); if (!s) return; s.abort.abort(); clearInterval(s.interval); clearTimeout(s.timeout); this.streams.delete(name); if (sendSleep) { const v = this.findByName(name); if (v?.host) { this.postJSON(`http://${v.host}/state`, { state: "sleep" }).catch(() => {}); } } } private findByName(name: string): Viewport | undefined { for (const v of this.viewports.values()) if (v.name === name) return v; return undefined; } private async pushFrame(v: Viewport, abort: AbortController) { if (abort.signal.aborted) return; const cam: any = systemManager.getDeviceById(v.cameraId); if (!cam) return; const w = v.orientation === "portrait" ? 480 : 800; const h = v.orientation === "portrait" ? 800 : 480; const picture = await cam.takePicture({ picture: { width: w, height: h }, reason: "event", }); const buf: Buffer = await mediaManager.convertMediaObjectToBuffer(picture, "image/jpeg"); const res = await fetch(`http://${v.host}/frame`, { method: "POST", headers: { "Content-Type": "image/jpeg" }, body: buf, signal: abort.signal, }); if (res.status === 409) { this.console.log(`"${v.name}" returned 409 — device went to sleep, stopping stream`); this.stopStream(v.name, /*sendSleep=*/ false); } else if (res.status === 400) { const reason = await res.text().catch(() => ""); this.console.warn(`"${v.name}" returned 400: ${reason}`); } else if (!res.ok) { this.console.warn(`"${v.name}" /frame -> ${res.status}`); } } // ------------------------------------------------------------------------ // Inbound: device → Scrypted POST /state // ------------------------------------------------------------------------ async onRequest(request: HttpRequest, response: HttpResponse) { if (request.method !== "POST") { response.send("", { code: 405 }); return; } if (!request.url.endsWith("/state")) { response.send("", { code: 404 }); return; } let body: any; try { body = JSON.parse(request.body); } catch { response.send("invalid JSON", { code: 400 }); return; } const { viewport, state } = body ?? {}; const v = typeof viewport === "string" ? this.findByName(viewport) : undefined; if (!v) { response.send(`unknown viewport: ${viewport}`, { code: 404 }); return; } if (state !== "wake" && state !== "sleep") { response.send("state must be wake or sleep", { code: 400 }); return; } this.console.log(`recv "${viewport}" -> ${state} (device-initiated)`); if (state === "wake") { await this.startStream(v); } else { this.stopStream(v.name, /*sendSleep=*/ false); } response.send("", { code: 204 }); } // ------------------------------------------------------------------------ // Parent Settings — global tuning only // ------------------------------------------------------------------------ async getSettings(): Promise { 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, }, ]; } async putSetting(key: string, value: SettingValue) { this.storage.setItem(key, String(value ?? "")); } // ------------------------------------------------------------------------ // Tiny HTTP helper // ------------------------------------------------------------------------ private async postJSON(url: string, body: any) { const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(HTTP_TIMEOUT_MS), }); if (!res.ok && res.status !== 204) { const text = await res.text().catch(() => ""); throw new Error(`POST ${url} -> ${res.status} ${text}`); } } } export default ScryptedViewportProvider;