// Scrypted Viewport — v1 Scripts-plugin script // // SCRIPT_VERSION is bumped on every commit that touches this file. // The boot log emits it so we can verify the user re-pasted the // latest version when reading the plugin console. Format is the // short git hash of the commit that added this constant — if the // hash in the log doesn't match the HEAD this file came from, the // Scrypted Script editor is still on stale code. const SCRIPT_VERSION = "3821512"; // // 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" (friendly routing key) // 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. // // Streaming // --------- // On wake we subscribe to the camera's video stream, spawn one ffmpeg // child that scales + rotates to panel-native 800x480 and re-encodes to // MJPEG (lanczos; q:v = jpeg_quality, default 1) at the source rate // (~24fps), demux JPEG frames out of stdout (FFD8…FFD9), and write each // one — framed with a 16-byte header — to the firmware over a raw TCP // data socket (port 81), not per-frame HTTP. Backpressure is skip-oldest: // when the socket can't accept a write, the newest frame replaces any // held one so we only ever fall behind by ~1 frame. // // Limits // ------ // - Manual IP per viewport (see README for how to find it via mDNS from // your shell). DHCP reservation recommended so the IP stays stable. // - Camera must expose a video stream; pure-snapshot cameras need a // transcoder mixin upstream. // 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 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 require: any; // 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; type StartStop = any; // --------------------------------------------------------------------------- // Cross-reload shutdown cleaners. // // Scrypted's Scripts sandbox doesn't release prior-load resources before // constructing a new instance, so anything long-lived (setInterval, sockets, // ffmpeg children, AbortControllers, camera event registrations) leaks across // every re-paste of the script unless we explicitly tear it down. Every such // resource pushes a cleanup closure here; the constructor drains the list at // the very start of `start()` so the new load begins with empty state. // // One unified array keeps the cleanup semantics simple: register the closure // that knows how to release the resource (clearInterval / abort.abort() / // reg.removeListener()), and we walk and call them on the next reload. // Drain and run every registered cleanup, leaving the array empty so the // next phase can start populating it again. Called from two paths: // - script load: tears down resources left over from a previous Provider // instance the Scrypted sandbox didn't release // - StartStop.stop(): explicit user-driven cleanup via the Scripts UI function drainShutdownCleaners(console: any, reason: string) { const G = globalThis as any; if (Array.isArray(G.__viewportShutdownCleaners) && G.__viewportShutdownCleaners.length > 0) { // Snapshot + reset BEFORE walking — some cleanups (stream // aborts) fire abort listeners that splice themselves out of // the array, which would skip elements if we iterated directly. const prior = G.__viewportShutdownCleaners.slice(); G.__viewportShutdownCleaners = []; console.log(`${reason}: tearing down ${prior.length} resources`); for (const cleanup of prior) { try { cleanup(); } catch (e) { try { console.warn("shutdown cleanup failed:", (e as Error).message); } catch {} } } } else { G.__viewportShutdownCleaners = []; } } function pushShutdownCleaner(cleanup: () => void) { const G = globalThis as any; if (!Array.isArray(G.__viewportShutdownCleaners)) G.__viewportShutdownCleaners = []; G.__viewportShutdownCleaners.push(cleanup); } // Tuning constants. // Stream rate is paced by camera + TCP backpressure; no app-level fps // cap, no per-frame pipelining semaphore. const REREGISTER_INTERVAL_MS = 5 * 60_000; // 5s is generous for snapshot POSTs (the only HTTP traffic during a // stream) and survives long-tail latency on a busy Scrypted host. const HTTP_TIMEOUT_MS = 5_000; const DEFAULT_IDLE_TIMEOUT_MS = 60_000; const DEFAULT_BRIGHTNESS = 100; // JPEG end-of-image marker — the frame delimiter for the MJPEG demux loop. const JPEG_EOI = Buffer.from([0xff, 0xd9]); // --------------------------------------------------------------------------- // mDNS discovery of viewports on the LAN. // // The firmware advertises `_scrypted-viewport._tcp` (port 80) with TXT // records name/version/resolution/orientation/mac. We browse with a plain // dgram socket on an EPHEMERAL port: per RFC 6762 §6.7 a query from a // non-5353 source port is a "legacy unicast" query and responders reply // unicast straight back to that port — verified in the ESP-IDF responder // (mdns_send.c directs the answer at the querier's addr/port whenever // src_port != 5353). This sidesteps binding :5353 entirely, so we never // conflict with Scrypted's own HomeKit mDNS stack or a host avahi-daemon, // and it works under Docker host-networking or a native install alike. // // One response packet carries PTR + SRV + TXT + A together (RFC 6763 // §12.1 additional-record rules), so correlation is per-packet — no // follow-up queries needed. const MDNS_SERVICE = "_scrypted-viewport._tcp.local"; type DiscoveredViewport = { name: string; // TXT name — the viewport routing key ip: string; // A record port: number; // SRV port (the HTTP control plane, 80) hostname: string; // SRV target, e.g. "viewport-kitchen.local" version?: string; resolution?: string; orientation?: string; mac?: string; // stable identity (firmware ≥ the mac-TXT build) }; // Build the one-shot PTR question for MDNS_SERVICE. function buildMdnsQuery(): Buffer { const labels = MDNS_SERVICE.split("."); let qnameLen = 1; // trailing root byte for (const l of labels) qnameLen += 1 + l.length; const buf = Buffer.alloc(12 + qnameLen + 4); buf.writeUInt16BE(Date.now() & 0xffff, 0); // ID — echoed in legacy-unicast replies buf.writeUInt16BE(1, 4); // QDCOUNT=1; flags + other counts 0 let off = 12; for (const l of labels) { buf.writeUInt8(l.length, off++); buf.write(l, off, "ascii"); off += l.length; } buf.writeUInt8(0, off++); buf.writeUInt16BE(12, off); // QTYPE PTR buf.writeUInt16BE(1, off + 2); // QCLASS IN return buf; } // Decode a (possibly compressed) DNS name at `off`. Returns the dotted // name and the offset just past its in-place encoding. function readDnsName(msg: Buffer, off: number): { name: string; next: number } { const parts: string[] = []; let next = -1; let jumps = 0; while (off < msg.length) { const len = msg[off]; if (len === 0) { if (next < 0) next = off + 1; break; } if ((len & 0xc0) === 0xc0) { // compression pointer if (next < 0) next = off + 2; if (++jumps > 16) break; // malformed-loop guard off = ((len & 0x3f) << 8) | msg[off + 1]; continue; } parts.push(msg.subarray(off + 1, off + 1 + len).toString("ascii")); off += 1 + len; } return { name: parts.join("."), next: next < 0 ? off : next }; } // Parse one mDNS response packet into any advertised viewports it contains. // Correlation is within the packet: PTR names an instance; the instance's // SRV gives hostname+port; TXT gives the metadata; A maps hostname → IPv4. function parseMdnsResponse(msg: Buffer): DiscoveredViewport[] { try { if (msg.length < 12) return []; const qd = msg.readUInt16BE(4); const total = msg.readUInt16BE(6) + msg.readUInt16BE(8) + msg.readUInt16BE(10); let off = 12; for (let i = 0; i < qd; i++) off = readDnsName(msg, off).next + 4; const ptrs: string[] = []; const srvs = new Map(); const txts = new Map>(); const addrs = new Map(); for (let i = 0; i < total && off < msg.length; i++) { const rec = readDnsName(msg, off); off = rec.next; if (off + 10 > msg.length) break; const type = msg.readUInt16BE(off); const rdlen = msg.readUInt16BE(off + 8); off += 10; if (off + rdlen > msg.length) break; const key = rec.name.toLowerCase(); if (type === 12 && key === MDNS_SERVICE) { // PTR ptrs.push(readDnsName(msg, off).name); } else if (type === 33) { // SRV srvs.set(key, { target: readDnsName(msg, off + 6).name, port: msg.readUInt16BE(off + 4) }); } else if (type === 16) { // TXT const kv: Record = {}; for (let t = off; t < off + rdlen; ) { const l = msg[t]; const s = msg.subarray(t + 1, t + 1 + l).toString("utf8"); const eq = s.indexOf("="); if (eq > 0) kv[s.slice(0, eq)] = s.slice(eq + 1); t += 1 + l; } txts.set(key, kv); } else if (type === 1 && rdlen === 4) { // A addrs.set(key, `${msg[off]}.${msg[off + 1]}.${msg[off + 2]}.${msg[off + 3]}`); } off += rdlen; } const out: DiscoveredViewport[] = []; for (const inst of ptrs) { const key = inst.toLowerCase(); const srv = srvs.get(key); const txt = txts.get(key) || {}; const ip = srv ? addrs.get(srv.target.toLowerCase()) : undefined; if (!srv || !ip) continue; out.push({ name: txt["name"] || inst.split(".")[0], ip, port: srv.port, hostname: srv.target, version: txt["version"], resolution: txt["resolution"], orientation: txt["orientation"], mac: txt["mac"], }); } return out; } catch { return []; } } // One-shot browse. Best-effort by design: any failure (sandbox without // dgram, no multicast route, zero responders) resolves to [] after the // timeout — callers degrade to manual host entry. function mdnsBrowse(console: any, timeoutMs = 1200): Promise { return new Promise((resolve) => { let sock: any = null; const found = new Map(); // dedupe by mac, else ip let done = false; const finish = () => { if (done) return; done = true; try { sock?.close(); } catch {} resolve(Array.from(found.values())); }; try { const dgram = require("dgram"); sock = dgram.createSocket({ type: "udp4", reuseAddr: true }); sock.on("error", (e: Error) => { console.warn(`mdns browse: ${e.message}`); finish(); }); sock.on("message", (msg: Buffer) => { for (const v of parseMdnsResponse(msg)) found.set(v.mac || v.ip, v); }); const query = buildMdnsQuery(); const send = () => { try { sock.send(query, 5353, "224.0.0.251"); } catch {} }; sock.bind(0, send); // ephemeral source port → unicast replies setTimeout(send, 400); // mDNS is lossy; ask twice setTimeout(finish, timeoutMs); } catch (e) { console.warn(`mdns browse unavailable: ${(e as Error).message}`); finish(); } }); } // "10.0.13.83 — kitchen (v1.3.2, 480x800)" — shown as a host choice; only // the first whitespace-delimited token (the address) is stored. function hostChoice(d: DiscoveredViewport): string { return `${d.ip} — ${d.name} (v${d.version || "?"}, ${d.resolution || "?"})`; } function parseHostInput(value: any): string { return String(value ?? "").trim().split(/\s/)[0]; } // ============================================================================ // 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; } // ffmpeg mjpeg encoder -q:v. Valid range 1..31, lower = higher // quality + bigger JPEG (1 ≈ visually lossless, 31 ≈ very lossy). // Default 1 — with HTTP keep-alive + NODELAY we have plenty of // body-upload headroom for the bigger frames. Bump up only if // you're chasing fewer bytes on the wire at the cost of visible // artifacts. get jpegQuality(): number { const v = this.storage.getItem("jpeg_quality"); const parsed = v ? parseInt(v, 10) : NaN; return Number.isFinite(parsed) ? Math.max(1, Math.min(31, parsed)) : 1; } // Emergency cap on Node's TCP send buffer for the stream socket, // in MB. Skip-oldest backpressure handling (see stream send loop) // keeps only one in-flight frame plus one pending — Node's queue // should stay at ~1 frame steady-state. This cap is the safety net // for a stuck connection that never fires 'drain': when exceeded, // the socket is destroyed and reconnected so a fresh frame can land. get maxNodeBufMb(): number { const v = this.storage.getItem("max_node_buf_mb"); const parsed = v ? parseInt(v, 10) : NaN; return Number.isFinite(parsed) ? Math.max(1, Math.min(200, parsed)) : 20; } // Cold-start mitigation: request a prebuffer from the rebroadcast // plugin so getVideoStream hands ffmpeg a buffer that already begins on // a recent keyframe — eliminating the wait for the camera's NEXT // keyframe (the ~5-6s first-frame gap we measured). Because the whole // pipeline is skip-to-freshest (ffmpeg unpaced, Scrypted drop-oldest, // firmware FIONREAD-skip), the prebuffered burst collapses: the panel // catches up to live within a fraction of a second and steady-state // g2g is unchanged — it only kills the startup gap, no permanent lag. // Default 3000ms: large enough to reliably contain a keyframe for the // ~5s GOP measured here, small enough that the startup burst is modest. // To work, the prebuffer must be ≥ the source GOP; raise it if the // spawned→first-byte gap doesn't shrink. 0 = off (live edge). get streamPrebufferMs(): number { const v = this.storage.getItem("stream_prebuffer_ms"); const parsed = v ? parseInt(v, 10) : NaN; // Default 6000ms — must exceed the camera's keyframe interval (measured // 5.044s on this Unifi cam) so the requested backfill is guaranteed to // contain at least one IDR; otherwise ffmpeg still waits for the next // live keyframe. The Scrypted-side prebuffer for the chosen stream must // ALSO be enabled and ≥ that interval (enable it under the camera's // Stream Management → per-stream tab). 0 = off (cold live-edge, ~6s). return Number.isFinite(parsed) ? Math.max(0, Math.min(12000, parsed)) : 6000; } // True when the bound camera can emit a doorbell ring — i.e. it // advertises BinarySensor (Unifi pushes BinarySensor onto the camera // device when isDoorbell) or self-reports as a Doorbell. Drives both // the default triggers and whether "doorbell" is even offered as a // wake option: a plain camera never rings, so don't show the choice. get cameraIsDoorbell(): boolean { const id = this.cameraId; if (!id) return false; try { const cam: any = systemManager.getDeviceById(id); const ifaces: string[] = cam?.interfaces || []; return ifaces.includes(ScryptedInterface.BinarySensor) || cam?.type === ScryptedDeviceType.Doorbell; } catch { return false; } } // The wake options applicable to the bound camera. "doorbell" only // appears for doorbell-capable cameras. get triggerChoices(): string[] { return this.cameraIsDoorbell ? ["doorbell", "person", "motion"] : ["person", "motion"]; } // Which camera-event types wake this viewport. Empty = tap-only, never // woken by Scrypted. Default = person + doorbell (doorbell only when the // camera is doorbell-capable); motion is opt-in since doorbell cameras // are very chatty with motion and would wake the panel constantly. get triggers(): Set { const v = this.storage.getItem("triggers"); if (v === null) return new Set(this.cameraIsDoorbell ? ["doorbell", "person"] : ["person"]); try { return new Set(JSON.parse(v)); } catch { return new Set(); } } async getSettings(): Promise { // Discovered viewports become host-field choices (best-effort, [] // on any failure — manual entry always works). const discovered = await this.provider.browseCached(); const settings: Setting[] = [ { group: "Binding", key: "display_name", title: "Viewport name", description: "Friendly routing key used in firmware registration and callbacks. Renaming re-registers the device (its mDNS hostname follows). This — not the Scrypted device-name pencil — is the name the firmware knows.", placeholder: "mudroom", value: this.storage.getItem("display_name") || this.name || "", } as any, { group: "Binding", key: "host", title: "IP or hostname", description: "Viewport's address on the LAN. Viewports discovered via mDNS appear as choices (picking one stores just the address); manual entry also works — the device's info screen shows its MAC + IP.", placeholder: "192.168.1.42", combobox: true, choices: discovered.map(hostChoice), value: this.host, } 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. Defaults to person + doorbell; motion is opt-in (doorbell cameras fire motion constantly). \"doorbell\" only appears for doorbell-capable cameras. Clear all for tap-only mode (never woken by Scrypted; user must tap).", choices: this.triggerChoices, 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: "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, { group: "Display", key: "jpeg_quality", title: "JPEG quality (1–31, lower = better)", description: "ffmpeg mjpeg encoder -q:v. 1 ≈ visually lossless (~140KB at panel-native), 5 ≈ good (~70KB), 10+ noticeably lossy. Default 1.", type: "number", value: this.jpegQuality, } as any, { group: "Display", key: "max_node_buf_mb", title: "Max Scrypted-side buffer (MB)", description: "Emergency cap on Node's TCP send queue for the stream socket. Skip-oldest backpressure handling normally keeps the queue at ~1 in-flight frame, so this should rarely trigger; if it does (stuck connection that never fires 'drain') the socket is destroyed and reconnected. Default 20.", type: "number", value: this.maxNodeBufMb, } as any, { group: "Display", key: "stream_prebuffer_ms", title: "Stream prebuffer (ms)", description: "Cold-start mitigation. Requests this much prebuffer from the rebroadcast plugin so the live stream opens on an already-buffered keyframe instead of waiting for the camera's next one — cuts the ~5–6s first-frame gap. Because the pipeline is skip-to-freshest, the buffered burst collapses to live within a fraction of a second, so this does NOT add steady-state latency — it only removes the startup gap. Must be ≥ the camera's keyframe interval (GOP) to help. Default 3000; raise toward the GOP if the spawned→first-byte gap doesn't shrink. 0 = off (live edge).", type: "number", value: this.streamPrebufferMs, } as any, { group: "Actions", key: "action_wake", title: "Wake now", description: "Toggle on to POST /state {wake} and start streaming the bound camera. Resets automatically after firing.", type: "boolean", value: false, } as any, { group: "Actions", key: "action_sleep", title: "Sleep now", description: "Toggle on to POST /state {sleep} and stop the active stream. Resets automatically after firing.", type: "boolean", value: false, } as any, ]; // Live device snapshot: GET /state then /config sequentially // (parallel ate both httpd slots simultaneously after Phase 2 // dropped max_open_sockets to 2, and could collide with an // in-flight stream-socket cap-flush reconnect). 3s timeout is // generous but not so long that an offline device feels // unresponsive in the UI. // // One automatic retry on failure: "fetch failed" at the socket // level usually means the firmware's httpd worker pool was // briefly saturated (the stream connection plus an /state poll // can starve a third request on max_open_sockets=2). A 250 ms // pause then try again before giving up — eliminates the // sporadic "offline / unreachable" the Settings page would // otherwise show during normal streaming. const fetchJsonRetry = async (url: string): Promise => { let lastErr: any; for (let attempt = 0; attempt < 2; attempt++) { try { const r = await fetch(url, { signal: AbortSignal.timeout(3000) }); return await r.json(); } catch (e) { lastErr = e; if (attempt === 0) await new Promise(res => setTimeout(res, 250)); } } throw lastErr; }; if (this.host) { try { const stateRes = await fetchJsonRetry(`http://${this.host}/state`); const configRes = await fetchJsonRetry(`http://${this.host}/config`); // Seed the stable identity for mdns auto-heal: MAC survives // renames and renumbers, unlike name or host. if (stateRes?.mac) this.storage.setItem("mac", String(stateRes.mac)); 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) { if (key.startsWith("_")) return; // ignore read-only status fields if (key === "action_wake" || key === "action_sleep") { // Manual override from the Scrypted UI. Wake also starts a // stream so the user sees the camera immediately; Sleep // tears down the live ffmpeg and POSTs sleep. // Boolean acts as a one-shot trigger — fire on truthy then // re-render with the toggle cleared so it's ready to fire // again next time. const truthy = value === true || value === "true"; if (!truthy) return; if (!this.host) return; if (key === "action_wake") { if (!this.provider.streams.has(this.nativeId!) && !this.provider.streamStarting.has(this.nativeId!)) { this.provider.streamStarting.add(this.nativeId!); this.provider.startStream(this) .catch(e => this.console.error("manual wake failed", e)) .finally(() => this.provider.streamStarting.delete(this.nativeId!)); } } else { this.provider.stopStream(this.nativeId!, /*sendSleep=*/ true); } return; } if (key === "display_name") { // The stored display_name is the canonical name (v.name drifts // on reload, so registration deliberately ignores it). Renames // therefore must land here — update storage, mirror the name // onto the Scrypted device record so the UI matches, and // re-register so the firmware + its mDNS hostname follow. const newName = String(value ?? "").trim(); if (!newName) return; // name is load-bearing; ignore empty this.storage.setItem("display_name", newName); try { await deviceManager.onDeviceDiscovered({ providerNativeId: this.provider.nativeId, nativeId: this.nativeId, name: newName, type: ScryptedDeviceType.SmartDisplay, interfaces: [ScryptedInterface.Settings], }); } catch (e) { this.console.warn(`rename: device record update failed: ${(e as Error).message}`); } await this.provider.onBindingChanged(this); return; } if (key === "triggers") { // multi-select arrives as array; serialise to JSON for storage. // Strip "doorbell" if the bound camera can't ring (mirror of // createDevice + triggerChoices) so a stale/forced selection // can't smuggle doorbell onto a plain camera. let arr = Array.isArray(value) ? (value as string[]) : []; if (!this.cameraIsDoorbell) arr = arr.filter(t => t !== "doorbell"); this.storage.setItem("triggers", JSON.stringify(arr)); } else { // host may arrive as a discovered choice "ip — name (v..)" — // keep only the address token. this.storage.setItem(key, key === "host" ? parseHostInput(value) : String(value ?? "")); if (key === "cameraId") { // The camera binding drives which wake triggers are valid // (doorbell only for doorbell cameras). Reconcile the stored // selection to the new camera's choices so we never persist // a trigger the camera can't emit. const valid = new Set(this.triggerChoices); const reconciled = Array.from(this.triggers).filter(t => valid.has(t)); this.storage.setItem("triggers", JSON.stringify(reconciled)); } } await this.provider.onBindingChanged(this); // After a camera change, tell the Scrypted console the Settings // interface changed so it re-fetches getSettings() and re-renders // the Wake-triggers choices live (showing/hiding doorbell) instead // of waiting for a manual page reload. if (key === "cameraId") { try { await (this as any).onDeviceEvent?.(ScryptedInterface.Settings, undefined); } catch {} } } } // ============================================================================ // Parent: provider + HTTP handler + global tuning // ============================================================================ class ScryptedViewportProvider extends ScryptedDeviceBase implements DeviceProvider, DeviceCreator, HttpRequestHandler, Settings, StartStop { private viewports = new Map(); // nativeId -> child instance private listeners = new Map(); // nativeId -> all event listeners for this viewport (camera + child devices) // nativeId -> "cameraId|trigger,trigger" signature of the currently // attached listener set. Source of truth for attachListener // idempotency: lets it self-heal (re-attach after the reload // storage-race) without stacking duplicate listeners on every // register cycle, and forces a re-attach when the camera OR the // selected wake triggers change. "" = processed-but-no-camera // (suppresses repeated "no camera assigned" warnings); absent = // never processed. private attachedListenerSig = new Map(); // nativeId -> last-registered config signature, so the "registered" line // logs only on a real change, not every 5-minute reregister cycle. private lastRegisterSig = new Map(); // nativeId -> stream control (accessed by Viewport.putSetting for // manual wake/sleep). Keyed by nativeId, NOT v.name: the name can // briefly drift to the nativeId on script reload (see // registerViewport), which would strand or duplicate a stream keyed // under the drifted value. streams = new Map(); private scryptedBase = ""; // Last mDNS browse, reused briefly so settings-UI re-renders (which // call getSettings repeatedly) don't spam the LAN with queries. private lastBrowse: { at: number; results: DiscoveredViewport[] } | null = null; async browseCached(): Promise { if (this.lastBrowse && Date.now() - this.lastBrowse.at < 30_000) { return this.lastBrowse.results; } const results = await mdnsBrowse(this.console); this.lastBrowse = { at: Date.now(), results }; return results; } constructor(nativeId?: string) { super(nativeId); // Initialise running=false synchronously so the device record // has a defined value at registration time. this.running = false; // Auto-start on script load. start() is idempotent — if the user // later clicks STOP in the Scripts UI, stop() drains everything // and start() doesn't re-fire until they click START. Across a // script re-paste the constructor runs again, start() drains any // prior load's resources first and re-bootstraps cleanly. this.start().catch(e => this.console.error("start failed", e)); } // ------------------------------------------------------------------------ // StartStop — public lifecycle controls exposed to the Scripts UI // ------------------------------------------------------------------------ // // Scrypted's Scripts plugin (plugins/core/src/scrypted-eval.ts mergeHandler) // auto-detects this interface from the start()/stop() method names; the // 'Status and Controls' panel's STOP/START buttons call these directly. // Confirmed empirically: clicking STOP fires our stop() — verified in // the v101fb3e session that explored OnOff alongside StartStop and // observed only stop() being called. // // Semantics: // start(): drain any leftover resources (previous load or current // in-flight), then bootstrap. Idempotent. // stop(): drain every resource (streams, listeners, intervals) // and clear in-memory maps. Idempotent. // Single-flight guard: the constructor auto-start and a UI START // click can overlap while bootstrap() is mid-await (running only // flips true afterwards); share the in-flight promise instead of // running bootstrap twice. private startingPromise: Promise | null = null; async start() { if (this.running) return; if (!this.startingPromise) { this.startingPromise = this.bootstrap() .finally(() => { this.startingPromise = null; }); } await this.startingPromise; this.running = true; } async stop() { // ALWAYS drain — never gate on this.running. Live resources // (ffmpeg children, sockets, intervals, listeners) live in the // global __viewportShutdownCleaners array, which is the real // source of truth. The Scrypted Scripts sandbox leaks instances // across re-pastes, so the instance that receives this Stop click // can have this.running===false (a newer load owns the resources, // or bootstrap() is mid-await) while subprocesses are still alive. // The old `if (!this.running) return` short-circuited those teardowns // and orphaned ffmpeg/sockets — the "Stop does nothing" symptom. // 1. Drain the global cleaner array: aborts every stream (→ ffmpeg // SIGTERM, socket destroy, streamLogger + idle-timeout clear), // removes every camera + system event listener, clears the // reregister interval and the diagnostic listener. drainShutdownCleaners(this.console, "stop"); // 2. Cancel any pending per-viewport debounce timers — these aren't // in the cleaner array and would otherwise fire ~300ms later and // re-attach/re-register against a stopped provider. for (const t of this.bindingDebounce.values()) { try { clearTimeout(t); } catch {} } this.bindingDebounce.clear(); // 3. Drop all in-memory state so a later start() rebuilds from // scratch — identical to a fresh load. (Child DEVICE records and // their storage persist; only our live instances/listeners go.) this.viewports.clear(); this.listeners.clear(); this.streams.clear(); this.attachedListenerSig.clear(); this.lastRegisterSig.clear(); this.running = false; } // ------------------------------------------------------------------------ // 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 async bootstrap() { // Unified script-reload cleanup — MUST run before anything below // attaches listeners or spawns resources. // // Scrypted's Scripts sandbox does NOT release a previous load's // resources before constructing a new instance. setInterval // handles, TCP sockets, ffmpeg children, AbortControllers, and // camera event registrations all survive a script re-paste and // accumulate over time. Every long-lived resource pushes a // cleanup closure onto a single globalThis-anchored array; we // drain that array here so the prior load's resources die before // this load creates its own. Draining any later (it used to run // at the END of bootstrap) would tear down the listeners the // child re-discovery loop below just attached — and, because the // instance maps still referenced them, block the register-cycle // self-heal from ever re-attaching. drainShutdownCleaners(this.console, "start"); // Debounce timers are instance-private and can't self-register a // closure at creation time that survives sensibly, so clear them // wholesale on the next load: a timer pending across a re-paste // would fire against this dead instance ~300ms into the new load // and attach a duplicate camera listener. pushShutdownCleaner(() => { for (const t of this.bindingDebounce.values()) { try { clearTimeout(t); } catch {} } this.bindingDebounce.clear(); }); // 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 (script=${SCRIPT_VERSION}). Callback URL base: ${this.scryptedBase}`); // Override the device type that the @scrypted/core Scripts plugin // hardcodes (`ScryptedDeviceType.Unknown` at plugins/core/src/ // script.ts:65) so the UI displays a meaningful label above the // Status and Controls panel instead of "Unknown". This Provider // semantically bridges multiple child viewport devices, so Bridge // fits. The call happens after Scripts plugin's postRunScript- // driven discovery, so this update wins. // // We pass the full interface set explicitly: passing a partial // list would drop interfaces the auto-detection found. Order // mirrors the class's implements clause. try { await deviceManager.onDeviceDiscovered({ providerNativeId: "scriptcore", nativeId: this.nativeId, name: this.name || this.providedName, type: ScryptedDeviceType.Bridge, interfaces: [ ScryptedInterface.Scriptable, ScryptedInterface.Program, ScryptedInterface.Settings, ScryptedInterface.DeviceProvider, ScryptedInterface.DeviceCreator, ScryptedInterface.HttpRequestHandler, ScryptedInterface.StartStop, ], }); } catch (e) { this.console.warn(`type override failed: ${(e as Error).message}`); } // 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. const staleChildIds: string[] = []; for (const nativeId of this.childIds) { try { // A childId whose storage container is gone is a stale entry // — the device was deleted in the UI (or never persisted) but // its id lingered in our childIds list. Re-discovering it would // resurrect a ghost the user deleted, so prune it instead. const store = deviceManager.getDeviceStorage(nativeId); if (!store) { this.console.warn(`pruning stale child ${nativeId} — no storage (deleted or never persisted)`); staleChildIds.push(nativeId); continue; } // Use the persisted display_name as the canonical device // name so a script reload doesn't reset it to the nativeId. // First-time provision falls back to the nativeId. const displayName = store.getItem("display_name") || nativeId; await deviceManager.onDeviceDiscovered({ providerNativeId: this.nativeId, nativeId, name: displayName, type: ScryptedDeviceType.SmartDisplay, interfaces: [ScryptedInterface.Settings], }); await this.getDevice(nativeId); } // Other (transient) load errors are logged but NOT pruned — only a // missing storage container is a definitive "device gone" signal. catch (e) { this.console.warn(`load child ${nativeId} failed:`, (e as Error).message); } } if (staleChildIds.length) { this.childIds = this.childIds.filter(id => !staleChildIds.includes(id)); this.console.log(`pruned ${staleChildIds.length} stale child id(s): ${staleChildIds.join(", ")}`); } const reregisterHandle = setInterval(() => { for (const v of this.viewports.values()) { this.registerViewport(v).catch(() => {}); } }, REREGISTER_INTERVAL_MS); pushShutdownCleaner(() => clearInterval(reregisterHandle)); } // ------------------------------------------------------------------------ // 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(nativeId, /*sendSleep=*/ false); this.detachListener(nativeId); this.viewports.delete(nativeId); } // Drop every per-device bookkeeping entry, not just the instance. this.lastRegisterSig.delete(nativeId); this.streamStarting.delete(nativeId); const t = this.bindingDebounce.get(nativeId); if (t) { clearTimeout(t); this.bindingDebounce.delete(nativeId); } this.childIds = this.childIds.filter(x => x !== nativeId); } // ------------------------------------------------------------------------ // DeviceCreator — "+ Add Device" form on the parent // ------------------------------------------------------------------------ async getCreateDeviceSettings(): Promise { // Best-effort LAN browse — discovered viewports become dropdown // choices on the host field; [] just means manual entry. const discovered = await this.browseCached(); return [ { key: "name", title: "Viewport name", description: 'Friendly routing key sent back in callbacks. Lowercase, no spaces. Example: "mudroom".', placeholder: "mudroom", }, { key: "host", title: "IP or hostname", description: "Where the firmware lives on the LAN. Viewports discovered via mDNS appear as choices (picking one stores just the address); manual entry of an IP or `viewport-.local` also works.", placeholder: "192.168.1.42", combobox: true, choices: discovered.map(hostChoice), } as any, { key: "cameraId", title: "Camera", type: "device", deviceFilter: `interfaces.includes('${ScryptedInterface.Camera}')`, }, { key: "triggers", title: "Wake triggers", description: "Which camera-event types automatically wake the viewport. Defaults to person + doorbell; motion is opt-in (doorbell cameras fire motion constantly). If the camera isn't a doorbell, the doorbell trigger is dropped automatically on save. Clear all for tap-only mode.", // Choices can't depend on the camera picked in this same // (static) form, so all three are offered here; createDevice // strips "doorbell" when the chosen camera can't ring, and // the per-device settings page hides it thereafter. choices: ["doorbell", "motion", "person"], multiple: true, value: ["doorbell", "person"], } as any, { key: "orientation", title: "Orientation", choices: ["portrait", "landscape"], value: "portrait", }, ]; } async createDevice(settings: DeviceCreatorSettings): Promise { const host = parseHostInput(settings.host); // Name precedence: what the user typed > the discovered device's // advertised TXT name (so picking "10.0.13.83 — kitchen (…)" from // the dropdown names the viewport "kitchen" without retyping) > // the "viewport" fallback. let name = String(settings.name || "").trim(); if (!name) { const discovered = await this.browseCached(); const d = discovered.find(x => x.ip === host || x.hostname === host); name = d?.name || "viewport"; } 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); // A discovered-choice pick arrives as "ip — name (v..)"; store the // address token only. Manual IPs/hostnames pass through unchanged. childStore.setItem("host", host); childStore.setItem("cameraId", String(settings.cameraId || "")); childStore.setItem("orientation", String(settings.orientation || "portrait")); // settings.triggers arrives as an array from the multi-select. // JSON-encode to match how Viewport.putSetting stores it on // subsequent edits. Strip "doorbell" if the chosen camera can't // ring — the static add-form can't filter choices by camera, so we // enforce it here (mirror of Viewport.triggerChoices). let camIsDoorbell = false; if (settings.cameraId) { try { const cam: any = systemManager.getDeviceById(String(settings.cameraId)); const ifaces: string[] = cam?.interfaces || []; camIsDoorbell = ifaces.includes(ScryptedInterface.BinarySensor) || cam?.type === ScryptedDeviceType.Doorbell; } catch { /* leave false */ } } let trigs = Array.isArray(settings.triggers) ? settings.triggers : (camIsDoorbell ? ["doorbell", "person"] : ["person"]); if (!camIsDoorbell) trigs = trigs.filter((t: string) => t !== "doorbell"); childStore.setItem("triggers", JSON.stringify(trigs)); this.childIds = [...this.childIds, nativeId]; this.console.log(`created viewport "${name}" (${nativeId})`); // Instantiate the child — getDevice runs the first register cycle // (POST /config to the device) for a new instance itself. await this.getDevice(nativeId); return nativeId; } // ------------------------------------------------------------------------ // Per-binding plumbing (camera subscription + /config registration) // ------------------------------------------------------------------------ // Per-viewport debounce timer. Scrypted's Settings UI does one // putSetting per field on save, so a typical "Save" with 5 fields // changed used to register 5 times. Coalesce into a single apply. private bindingDebounce = new Map(); onBindingChanged = async (v: Viewport): Promise => { const nid = v.nativeId!; const pending = this.bindingDebounce.get(nid); if (pending) clearTimeout(pending); this.bindingDebounce.set(nid, setTimeout(() => { this.bindingDebounce.delete(nid); const tag = v.name || v.storage.getItem("display_name") || nid; this.console.log(`onBindingChanged "${tag}": re-attach (host=${v.host || "?"} cameraId=${v.cameraId || "?"})`); this.detachListener(nid); // Any active stream for this viewport is now stale (camera // or orientation may have changed). Stop cleanly; if it // was live we relaunch immediately under the new settings // so the user sees the change without waiting for the next // camera event. const wasStreaming = this.streams.has(nid); this.stopStream(nid, /*sendSleep=*/ false); this.attachListener(v); this.registerViewport(v) .then(() => { if (wasStreaming) { if (this.streamStarting.has(nid)) return; this.streamStarting.add(nid); this.startStream(v) .catch(e => this.console.error("restart after setting change failed", e)) .finally(() => this.streamStarting.delete(nid)); } }) .catch(() => {}); }, 300)); }; private attachListener(v: Viewport) { const tag = v.name || v.storage.getItem("display_name") || v.nativeId; const nid = v.nativeId!; const want = v.cameraId || ""; const triggers = Array.from(v.triggers).sort(); const wantSig = want ? `${want}|${triggers.join(",")}` : ""; const have = this.attachedListenerSig.get(nid); // Idempotent fast-path: already listening on the right camera with // the right trigger set and the listeners are still installed → // nothing to do. This is what lets registerViewport call us on // every (5-min) cycle to self-heal the reload storage-race without // restacking listeners or spamming logs. (The listener-less cases // — no camera, or tap-only with zero triggers — dedup on the // signature alone.) const wantAnyListener = want !== "" && triggers.length > 0; if (wantSig === have && (!wantAnyListener || (this.listeners.get(nid)?.length ?? 0) > 0)) return; // State changed (first attach, camera swapped, triggers changed, // or listeners lost): tear down whatever was there before // re-deciding. this.detachListener(nid); if (!want) { // Record the empty state so subsequent register cycles don't // re-warn every 5 minutes. Cleared by detachListener on rebind. this.attachedListenerSig.set(nid, ""); this.console.warn(`viewport "${tag}": no camera assigned — open Settings and pick a camera; subscription skipped`); return; } const cam = systemManager.getDeviceById(v.cameraId); if (!cam) { // Leave the signature unset so the next register cycle retries // (the camera device may simply not be loaded yet). this.console.warn(`viewport "${tag}": camera ${v.cameraId} not found`); return; } // Scrypted's ScryptedDevice.listen(event, cb) takes a SINGLE // interface (or EventListenerOptions {event}), never an array. // Confirmed in sdk/types/src/types.input.ts:21. Passing an array // stringifies to "BinarySensor,MotionSensor,..." which matches // nothing — events leak through with broken filtering. One // listen() per interface is the right shape. // // For Unifi doorbells the bell-press lives on the camera device // itself (unifi-protect/src/main.ts pushes BinarySensor onto the // camera's interfaces when isDoorbell). So listening on `cam` // for BinarySensor is all that's needed — no child traversal. // // Subscribe ONLY to the interfaces the selected wake triggers // need — a doorbell-only viewport shouldn't take a callback per // motion re-assert. handleCameraEvent still filters by trigger // (defense in depth: ObjectDetector fires for non-person classes // too). Zero triggers = tap-only mode: no listeners at all. const ifaces: any[] = []; if (triggers.includes("doorbell")) ifaces.push(ScryptedInterface.BinarySensor); if (triggers.includes("motion")) ifaces.push(ScryptedInterface.MotionSensor); if (triggers.includes("person")) ifaces.push(ScryptedInterface.ObjectDetector); if (!ifaces.length) { this.attachedListenerSig.set(nid, wantSig); this.console.log(`viewport "${tag}": tap-only (no wake triggers selected) — camera subscription skipped`); return; } const regs: EventListenerRegister[] = []; for (const iface of ifaces) { const reg = (cam as any).listen(iface, (source: any, details: any, data: any) => { this.handleCameraEvent(v, details, data); }); regs.push(reg); } // One cross-reload cleaner for the whole attach: removes every // listener AND invalidates this instance's bookkeeping. Without // the map invalidation, a drain performed by another instance // (Stop click landing on an old load) leaves the signature + // listeners claiming live registrations, and the idempotent // fast-path above then blocks the register-cycle self-heal from // ever re-attaching. pushShutdownCleaner(() => { for (const reg of regs) { try { reg.removeListener(); } catch {} } if (this.listeners.get(nid) === regs) { this.listeners.delete(nid); this.attachedListenerSig.delete(nid); } }); this.listeners.set(nid, regs); this.attachedListenerSig.set(nid, wantSig); this.console.log(`viewport "${tag}": subscribed to [${cam.name || cam.id} (${ifaces.join("+")})]`); } private detachListener(nativeId: string) { const regs = this.listeners.get(nativeId); if (regs) { for (const r of regs) { try { r.removeListener(); } catch {} } this.listeners.delete(nativeId); } // Clear the idempotency tracker so the next attachListener re-decides // from scratch (re-attach after a rebind / camera or trigger change). this.attachedListenerSig.delete(nativeId); } private async registerViewport(v: Viewport) { // display_name is the canonical user-facing name (written on // createDevice and on every Settings save). v.name is just a // render of it from the Scrypted device record and can briefly // drift to the nativeId on script reload, so prefer the storage // value as the source of truth. const stored = v.storage.getItem("display_name"); const name = (stored && stored.trim()) || (v.name && v.name.trim()) || ""; if (!name) { this.console.warn(`register skipped — empty name on ${v.nativeId}; will retry on next event`); return; } if (!v.host) { 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 { 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); // Log only when the registration actually changed (first time, or a // setting/host change) — not on every 5-minute reregister cycle. const sig = `${v.host}|${name}|${v.idleTimeoutMs}|${v.orientation}|${v.brightness}`; if (this.lastRegisterSig.get(v.nativeId!) !== sig) { this.lastRegisterSig.set(v.nativeId!, sig); this.console.log(`registered "${name}" (${v.host})`); } // Self-heal the camera subscription. A successful /config POST // proves storage is attached (host is readable) — the exact // moment the bootstrap attachListener may have run too early // (storage race) and skipped with an empty cameraId, never to // retry. attachListener is idempotent, so this is a no-op once // the right listener is in place; on the racy reload it's what // finally wires the doorbell/motion subscription without the // user having to re-save the setting. this.attachListener(v); } catch (e) { this.console.warn(`register "${name}" failed:`, (e as Error).message); await this.tryMdnsHeal(v, name); } } // Register failed → maybe the device renumbered (DHCP). Browse the LAN // and match by MAC first (stable identity, seeded from /state or a // prior heal), falling back to the advertised name. On a hit at a // different address, rewrite host and retry the register once. Runs // only on register failure, so it's naturally rate-limited to the // 5-minute reregister cycle; the recursion terminates because a second // failure re-browses and finds match.ip === v.host. private async tryMdnsHeal(v: Viewport, name: string) { try { // Fresh browse, not the 30s cache — we're diagnosing a failure. const discovered = await mdnsBrowse(this.console); const knownMac = v.storage.getItem("mac") || ""; const match = (knownMac ? discovered.find(d => d.mac === knownMac) : undefined) ?? discovered.find(d => d.name === name); if (!match || match.ip === v.host) return; this.console.log(`viewport "${name}": host ${v.host || "?"} -> ${match.ip} (mdns auto-heal)`); v.storage.setItem("host", match.ip); if (match.mac) v.storage.setItem("mac", match.mac); await this.registerViewport(v); } catch (e) { this.console.warn(`mdns heal "${name}" failed:`, (e as Error).message); } } // ------------------------------------------------------------------------ // Camera event → stream // ------------------------------------------------------------------------ // Per-viewport "stream is actively starting" guard. handleCameraEvent // can fire multiple times in the same second (MotionSensor often // re-asserts every ~500 ms while motion is sustained); without this, // each event launches its own startStream which races with the // previous one and saturates the firmware's httpd. If a stream is // already live we just leave it running — the per-stream timeout // anchored to the event still fires correctly. streamStarting = new Set(); private handleCameraEvent(v: Viewport, details: any, data: any) { // Ignore ALL events while a stream is already live OR starting for // this viewport. The wake window is anchored to the FIRST event; // subsequent events (motion re-asserts every ~500ms, person detect // fires repeatedly, the doorbell ring lands mid-motion) must not // queue, relaunch, or extend it. Cheapest possible early-out — before // trigger evaluation — so a live stream sees zero per-event work. if (this.streams.has(v.nativeId!) || this.streamStarting.has(v.nativeId!)) return; const iface = details.eventInterface; const allowed = v.triggers; let trigger = false; 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; } if (!trigger) return; // Capture the wall-clock at event arrival so every downstream // log line can rebase onto it (+Xms since event) — the anchor for the // cold-start stamps (spawned / first byte / first frame). const tEvent = Date.now(); this.console.log(`event ${iface} -> "${v.name}": fired at +0ms (wake)`); this.streamStarting.add(v.nativeId!); this.startStream(v, tEvent) .catch(e => this.console.error("startStream failed", e)) .finally(() => this.streamStarting.delete(v.nativeId!)); } // alreadyAwake: the device initiated this wake (panel tap), so the // {state:"wake"} POST back to it would be a pointless round-trip on // the cold-start critical path. async startStream(v: Viewport, tEvent: number = Date.now(), alreadyAwake = false) { const since = () => Date.now() - tEvent; this.console.log(`stream "${v.name}": start +${since()}ms`); // (event_us_low is stamped per frame at emit time inside the // demux loop — see the writeUInt32BE call below. This gives // "age of the currently-displayed frame" semantics for g2g, // not "time since wake".) // Race rule: cancel pending operations on every callback before // beginning a fresh stream. this.stopStream(v.nativeId!, /*sendSleep=*/ false); if (!v.host || !v.cameraId) return; // Compensating sleep for bail-out paths below: once the wake POST // has gone out, giving up without it strands the panel on the // "Loading..." screen — forever, if its idle timer is disabled // (idle_timeout_ms=0). A dark panel is the honest failure signal. const bailSleep = () => { this.postJSON(`http://${v.host}/state`, { state: "sleep" }).catch(() => {}); }; if (!alreadyAwake) { await this.postJSON(`http://${v.host}/state`, { state: "wake" }); } const cam: any = systemManager.getDeviceById(v.cameraId); if (!cam) { this.console.warn(`stream "${v.name}": camera ${v.cameraId} not found — sleeping panel`); bailSleep(); return; } // No pre-stream snapshot: the prebuffered stream now paints in ~0.7s // (see the prebuffer selection below), so the old takePicture→POST // first-paint bridge was slower than the stream it was covering and // just added camera load + a stale-overpaint risk. On the slow paths // (no/cold prebuffer) the panel simply shows its prior frame until the // stream's first frame lands. // Fetch the panel's native dimensions from the firmware and // cache them on the viewport's storage. Falls back to 800x480 // if /state is unreachable (e.g. mid-reboot). Panel dims never // change for a given device so this only really needs to run // once per discovery; refreshing on every wake costs ~5 ms and // self-heals if the firmware is replaced. const pw = parseInt(v.storage.getItem("panel_w") || "0", 10); const ph = parseInt(v.storage.getItem("panel_h") || "0", 10); let panelW = pw || 800; let panelH = ph || 480; try { const st = await fetch(`http://${v.host}/state`, { signal: AbortSignal.timeout(1500) }).then(r => r.json()); if (st?.panel_width && st?.panel_height) { panelW = Number(st.panel_width); panelH = Number(st.panel_height); v.storage.setItem("panel_w", String(panelW)); v.storage.setItem("panel_h", String(panelH)); } } catch { /* keep cached values */ } // Always send panel-native dimensions (panelW x panelH). For a // portrait viewport we scale to the logical (panelH x panelW) // target then transpose 90° CW so the buffer that arrives at the // panel is already in the right rotation. The firmware never // touches pixels — the hardware JPEG decoder writes BGR888 // straight into a DMA buffer that gets handed to the DSI engine. // Filter order matters here. Earlier we did // scale=480:800,transpose=1 // which intermittently produced a JPEG with a SOF marker // reporting 480x800 — the firmware then rejected it with // "expected 800x480, got 480x800". Rotating *first* and then // scaling to an EXPLICIT panelWxpanelH (with setsar to clear // any leftover aspect-ratio metadata) makes the final encoded // dimensions deterministic regardless of source resolution. const vf = this.buildVf(v.orientation, panelW, panelH); const qv = String(v.jpegQuality); // Diagnostic — confirms which filter chain the *currently loaded* // script is actually using. If you don't see this line in the // plugin log, the Scrypted Script editor is still on stale code // and a re-paste/save didn't take. If you do see it but the // firmware still rejects 480x800, the rotation didn't apply // (very rare ffmpeg build issue) and we'd need to look at // installed ffmpeg version. // (stream config log emitted after substream selection below) // Pull the camera's video stream, convert to ffmpeg input args, and // pipe through a single ffmpeg child: input → scale(lanczos) → // mjpeg q:v 2 → image2pipe. We then framer the raw MJPEG bytes // out of stdout into individual JPEGs and POST each one. This // beats the snapshot path because: // - the camera's main encoder is producing keyframes anyway, so // we're paying ~zero extra on the source side, // - ffmpeg sustains real fps; the takePicture loop never could, // - quality stays high (lanczos + q:v 2 ≈ visually lossless). // Stream-source choice drives end-to-end latency more than // anything else. remote-recorder hands us the high-bitrate // main encoder with a large GOP and the camera's own ~10s // prebuffer baked in — we'd watch the past, not the present. // Walk substreams from lowest-latency → highest-latency and // take the first one that resolves. // Source substream selection. We always want the highest-fps // highest-quality option that still has acceptable latency. // The wire cost is unaffected — we re-encode to panel-native // 800x480 mjpeg q:v 1 regardless of input resolution — so the // only tradeoff is Scrypted-side ffmpeg CPU (irrelevant here). // // Order of preference: // medium-resolution — usually the camera's main 1080p // stream at 15-30 fps, low latency // local — main stream for local clients // remote — main stream for remote clients // (camera default) — last-ditch fallback // // Explicitly NOT trying: // low-resolution — preview substream, capped 5-8 fps // remote-recorder — has ~10s prebuffer baked in (we'd watch // the past, not the present) let stream: any; let pickedDest = "(default)"; // prebuffer>0 asks the rebroadcast plugin to seek back to a // recent keyframe so ffmpeg starts decoding immediately instead // of waiting for the camera's next IDR. Omitted entirely when 0 // so the request shape is byte-identical to the old live-edge // behavior (no accidental prebuffer if the plugin defaults it). const prebufferMs = v.streamPrebufferMs; // Only ONE stream typically carries a maintained prebuffer (the one // HomeKit/NVR keeps hot — e.g. "High" with 10s here). The Medium/Low // substreams have none, so requesting them forces a cold RTSP connect // that waits for the next live keyframe (~6s). To get an instant // start we must pick the prebuffered stream BY ID. We downscale every // source to 800x480, so its native resolution is irrelevant to output // — the only cost is a little extra Scrypted-side decode CPU. let prebufferedId: string | null = null; let streamOptsList: any[] = []; try { streamOptsList = (await cam.getVideoStreamOptions?.()) || []; // Among streams that carry a maintained prebuffer, pick the SMALLEST // one that still covers the panel — minimizes Scrypted-side decode // cost (we downscale to 800x480 regardless). Picking the largest // (e.g. 5MP "High") needlessly halves the achievable fps; picking // below panel res would force an upscale. panel long/short edges // guard against the sub-panel "Low" substream. const panelPixels = panelW * panelH; const prebuffered = streamOptsList.filter(o => (o.prebuffer ?? 0) > 0); const covers = prebuffered .filter(o => (o.video?.width ?? 0) * (o.video?.height ?? 0) >= panelPixels) .sort((a, b) => (a.video.width * a.video.height) - (b.video.width * b.video.height)); // Prefer smallest-that-covers; else the largest available prebuffered // (better an upscale than no prebuffer at all). const best = covers[0] ?? prebuffered.sort((a, b) => ((b.video?.width ?? 0) * (b.video?.height ?? 0)) - ((a.video?.width ?? 0) * (a.video?.height ?? 0)))[0]; if (best) prebufferedId = best.id; } catch (e) { this.console.warn(`stream "${v.name}": getVideoStreamOptions failed: ${(e as Error).message}`); } // Preferred path: the prebuffered stream by id, with our backfill // request — hands ffmpeg a buffered keyframe immediately. if (prebufferMs > 0 && prebufferedId != null) { try { stream = await cam.getVideoStream({ id: prebufferedId, prebuffer: prebufferMs }); pickedDest = `id:${prebufferedId}(prebuffered)`; } catch { /* fall through to destination loop */ } } // Fallback: original destination preference (no prebuffer available, // or the id request failed). if (!stream) { const streamOpts = (destination: string): any => prebufferMs > 0 ? { destination, prebuffer: prebufferMs } : { destination }; for (const destination of ["medium-resolution", "local", "remote"]) { try { stream = await cam.getVideoStream(streamOpts(destination)); pickedDest = destination; break; } catch { /* try next */ } } } if (!stream) { try { stream = await cam.getVideoStream(); pickedDest = "(camera-default)"; } catch (e) { this.console.warn(`stream "${v.name}": no video stream available (${(e as Error).message}) — sleeping panel`); bailSleep(); return; } } // True only when we actually got the prebuffered-by-id stream — drives // the burst-friendly ffmpeg input flags below. const usingPrebuffer = pickedDest.includes("prebuffered"); this.console.log(`stream "${v.name}": orientation=${v.orientation} panel=${panelW}x${panelH} vf="${vf}" substream=${pickedDest} prebuffer=${prebufferMs}ms usingPrebuffer=${usingPrebuffer}`); let ffmpegInput: any; try { const ffmpegInputBuf: Buffer = await mediaManager.convertMediaObjectToBuffer( stream, "x-scrypted/x-ffmpeg-input"); ffmpegInput = JSON.parse(ffmpegInputBuf.toString("utf8")); } catch (e) { this.console.warn(`"${v.name}" no usable video stream for ffmpeg (${(e as Error).message}) — sleeping panel`); bailSleep(); return; } const { spawn } = require("child_process"); const ffmpegPath = (mediaManager.getFFmpegPath ? await mediaManager.getFFmpegPath() : undefined) || "ffmpeg"; const abort = new AbortController(); // Register with the cross-reload cleanup so a script re-paste // during an active stream tears down ffmpeg + the firmware // socket + the stats interval via the abort listeners below // — instead of orphaning them against a stale Provider. const releaseShutdownCleanup = (() => { const cleanup = () => { try { abort.abort(); } catch {} }; pushShutdownCleaner(cleanup); // Once the stream ends normally (timeout / stopStream), drop // its cleanup so it doesn't run later against a long-dead // AbortController. We splice rather than mark-dead so the // cleanup list stays compact across many stream cycles. return () => { const G = globalThis as any; if (!Array.isArray(G.__viewportShutdownCleaners)) return; const i = G.__viewportShutdownCleaners.indexOf(cleanup); if (i >= 0) G.__viewportShutdownCleaners.splice(i, 1); }; })(); abort.signal.addEventListener("abort", releaseShutdownCleanup); // ── DATA PLANE: raw TCP socket to firmware port 81 ──────────── // Replaces per-frame HTTP POSTs. One socket per stream session. // Frame format on the wire (big-endian, 16-byte v1 header): // ["VPRT"][4 bytes jpeg_len][4 bytes seq][4 bytes event_us_low] // followed by jpeg_len bytes of JPEG body. // // Backpressure strategy — skip-oldest: // sock.write() returns false → mark backpressured, hold the // NEXT frame in a single-slot `pendingFrame`. New frames // arriving during backpressure REPLACE the held one (drop // oldest). On 'drain', flush the held frame and clear the // flag. The in-flight write is already past us — we never // queue more than (1 in-flight + 1 pending) ≈ ~400KB at our // frame sizes, regardless of sustained mismatch. // // No HTTP headers, no per-frame ACK round-trip, no Nagle/ // delayed-ACK dance, no httpd worker churn. const net = require("net"); let sock: any = null; let socketReady = false; // While true, we hold the most recent frame in `pendingFrame` // instead of calling write(). On 'drain' we flush the held // frame (if any) and clear the flag. Skip-oldest semantics: // a new ffmpeg frame arriving during backpressure replaces // whatever was held — the older pending frame is dropped. let socketBackpressured = false; let pendingFrame: Buffer | null = null; let seq = 0; let droppedFrames = 0; // dropped because socket wasn't open yet let droppedOldest = 0; // dropped from pending slot when a newer frame replaced it let sentFrames = 0; let bytesSent = 0; let flushCount = 0; // socket destroy+reconnect count due to buffer cap (safety net) let lastFlushCount = 0; // flushCount at last window roll, to detect a flush this window // Duty cycle: framesSampled = every ffmpeg frame we considered // sending this window; framesUnderBp = the subset for which the // socket was backpressured at decision time. Ratio = % of frames // the link couldn't accept on demand — a continuous measure that // complements the point-in-time backpressured= flag. let framesSampled = 0; let framesUnderBp = 0; let lastLogMs = Date.now(); let workBuf: Buffer = Buffer.alloc(0); // Latency probe — wall-clock from "ffmpeg emitted the JPEG" // to "kernel accepted the socket.write". With TCP_NODELAY on // the stream socket this is sub-millisecond steady state; // double-digit ms numbers here mean kernel send buffer is // full (= firmware can't ingest fast enough), which is fine // — we don't gate on it and the firmware's FIONREAD skip // drops the surplus before decode. const writeLatencies: number[] = []; // Write a fully-framed buffer (header + jpeg already concatenated) // and update accounting. Returns whatever sock.write() returned // so the caller can flip the backpressure flag. const writeFramed = (buf: Buffer): boolean => { const t0 = Date.now(); const ok = sock.write(buf); writeLatencies.push(Date.now() - t0); if (writeLatencies.length > 200) writeLatencies.shift(); bytesSent += buf.length; sentFrames++; return ok; }; // Single-flight reconnect. A failed Node connect emits 'error' // FOLLOWED BY 'close'; scheduling from both (as this used to) // doubles the outstanding attempts on every failure — an // exponential connection storm against a rebooting device. // 'close' always fires last, so it is the one reconnect trigger; // the pending-timer guard collapses any duplicates. let reconnectTimer: NodeJS.Timeout | null = null; const scheduleReconnect = () => { if (reconnectTimer || abort.signal.aborted) return; reconnectTimer = setTimeout(() => { reconnectTimer = null; openStreamSocket(); }, 500); }; const openStreamSocket = () => { if (abort.signal.aborted) return; // Tear down the previous socket first so a straggler that // connects late can't linger with live handlers. if (sock) { try { sock.removeAllListeners(); sock.destroy(); } catch {} } socketReady = false; socketBackpressured = false; // Drop any frame held from the previous socket — it's stale // and addressed to a dead connection. if (pendingFrame) { droppedOldest++; pendingFrame = null; } this.console.log(`stream "${v.name}": socket connect requested +${since()}ms`); sock = net.createConnection({ host: v.host, port: 81, noDelay: true, // TCP_NODELAY on the outbound socket }); sock.on("connect", () => { socketReady = true; this.console.log(`stream "${v.name}": socket connect open +${since()}ms`); }); sock.on("drain", () => { socketBackpressured = false; if (pendingFrame && socketReady) { const buf = pendingFrame; pendingFrame = null; const ok = writeFramed(buf); if (!ok) socketBackpressured = true; } }); sock.on("error", (e: Error) => { this.console.warn(`stream "${v.name}" socket: ${e.message}`); socketReady = false; // no reconnect here — 'close' follows 'error' and handles it }); sock.on("close", () => { socketReady = false; scheduleReconnect(); }); }; openStreamSocket(); abort.signal.addEventListener("abort", () => { if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } try { sock?.destroy(); } catch {} }); // Auto-restart accounting: cameras occasionally end their RTSP // stream mid-event (network blip, source rotation, etc.) and // ffmpeg exits clean. If the stream-timeout hasn't fired yet // we respawn so the panel doesn't freeze on a stale frame. // Capped at 5 restarts per 60s — past that we give up and // wait for the next camera event. let currentProc: any = null; let restartCount = 0; let restartWindow = Date.now(); const spawnFfmpeg = () => { if (abort.signal.aborted) return; workBuf = Buffer.alloc(0); // reset framer state on each respawn // Resume-scan offset into workBuf: when a chunk arrives without // completing a frame, the next search only needs to cover the // new bytes (minus 1 for a marker straddling the boundary) // instead of rescanning a partially-arrived ~140KB frame from // byte 0 on every chunk. let scanFrom = 0; const p = spawn(ffmpegPath, [ "-hide_banner", "-loglevel", "error", // INPUT flags depend on the source path: // • live-edge: aggressive low-latency tuning — no input // buffering, unbuffered direct I/O, minimal probe — so a // live RTSP stream is decoded straight through with the // least added latency. // • prebuffered: the rebroadcaster hands us a ~6s BURST of // buffered frames (starting on a keyframe). "-avioflags // direct" + "-fflags nobuffer" throttle that burst to tiny // unbuffered socket reads — measured first-frame scaled // linearly with prebuffer size, i.e. ffmpeg was draining // the burst slowly, not waiting for a keyframe. Dropping // those two lets ffmpeg gulp the burst and emit the buffered // keyframe almost immediately. Keep genpts+discardcorrupt; // let Scrypted's own probesize/analyzeduration (in // inputArguments) stand. ...(usingPrebuffer ? ["-fflags", "+genpts+discardcorrupt"] : ["-fflags", "+genpts+nobuffer+discardcorrupt", "-flags", "low_delay", "-avioflags", "direct", "-probesize", "32", "-analyzeduration", "0"]), ...(ffmpegInput.inputArguments || []), "-an", "-sn", "-vf", vf, // -fps_mode drop: when the decoder is behind, throw the // late frame on the floor instead of queueing it. Without // this, ffmpeg's output queue fills up and the displayed // image lags further and further behind reality. "-fps_mode", "drop", "-c:v", "mjpeg", "-q:v", qv, "-f", "image2pipe", "-flush_packets", "1", "pipe:1", ]); currentProc = p; // Single cold-start stamp: first framed JPEG out. One-shot per // stream; a wake-to-video regression shows up here immediately // (~0.7s on the prebuffered path, ~5s on a cold live-edge GOP). let firstFfmpegFrameLogged = false; p.stdout.on("data", (chunk: Buffer) => { if (abort.signal.aborted) return; workBuf = workBuf.length === 0 ? chunk : Buffer.concat([workBuf, chunk]); while (true) { const eoi = workBuf.indexOf(JPEG_EOI, scanFrom); if (eoi < 0) { scanFrom = Math.max(0, workBuf.length - 1); break; } const frame = workBuf.subarray(0, eoi + 2); workBuf = workBuf.subarray(eoi + 2); scanFrom = 0; if (frame.length < 4 || frame[0] !== 0xff || frame[1] !== 0xd8) continue; if (!firstFfmpegFrameLogged) { this.console.log(`stream "${v.name}": first ffmpeg frame +${since()}ms (jpeg=${(frame.length / 1024).toFixed(0)}KB)`); firstFfmpegFrameLogged = true; } // Drop only when the socket isn't connected yet // (initial-open race) — once it's up we keep writing // through normal backpressure and let the firmware's // FIONREAD skip shed superseded frames before decode. if (!socketReady) { droppedFrames++; continue; } framesSampled++; if (socketBackpressured) framesUnderBp++; // Emergency safety net: if writableLength somehow // grows past the cap (e.g. a stuck connection that // never fires 'drain'), destroy the socket so the // reconnect path clears the backlog. Should be rare // with skip-oldest in place — steady-state writableLength // stays ~1 in-flight frame. const queued = sock.writableLength ?? 0; if (queued > v.maxNodeBufMb * 1024 * 1024) { flushCount++; this.console.log( `stream "${v.name}": buffer ${(queued / (1024 * 1024)).toFixed(1)}MB > ` + `${v.maxNodeBufMb}MB cap — destroying socket to drop backlog (flush #${flushCount})`); try { sock.destroy(); } catch {} droppedFrames++; continue; } seq++; // 16-byte v1 header. Magic "VPRT" (0x56505254) lets // the firmware autodetect old-vs-new clients during // the rollout window. event_us_low stamped at capture // time (here), not write time — so a frame held in // the pending slot keeps its true age and g2g reflects // any hold latency we added. const header = Buffer.alloc(16); header.writeUInt32BE(0x56505254, 0); // "VPRT" header.writeUInt32BE(frame.length, 4); header.writeUInt32BE(seq, 8); header.writeUInt32BE((Date.now() * 1000) >>> 0, 12); // Single combined buffer so header + body hit the wire // as one TCP segment when possible. const framed = Buffer.concat([header, frame]); if (socketBackpressured) { // Skip-oldest: replace whatever's in the pending slot. // The in-flight frame (last one we called write() on) // is already past us — kernel/Node buffer is draining // it. We only ever hold the freshest "next to send". if (pendingFrame) droppedOldest++; pendingFrame = framed; continue; } const ok = writeFramed(framed); if (!ok) socketBackpressured = true; } }); p.stderr.on("data", (chunk: Buffer) => { if (abort.signal.aborted) return; const text = chunk.toString("utf8").trim(); if (!text) return; if (text.includes("Immediate exit requested")) return; this.console.warn(`ffmpeg "${v.name}": ${text}`); }); p.on("error", (e: any) => { if (!abort.signal.aborted) this.console.warn(`ffmpeg "${v.name}" spawn error:`, e.message); }); p.on("close", (code: number) => { if (abort.signal.aborted) return; const now = Date.now(); if (now - restartWindow > 60_000) { // rolling 60s window restartCount = 0; restartWindow = now; } if (restartCount >= 5) { this.console.warn(`ffmpeg "${v.name}" exited (code=${code}) and has restarted ≥5x in the last 60s — giving up; next camera event will retry`); this.stopStream(v.nativeId!); return; } restartCount++; this.console.log(`ffmpeg "${v.name}" exited (code=${code}) — respawning (#${restartCount}/5 within window)`); setTimeout(spawnFfmpeg, 250); }); }; spawnFfmpeg(); abort.signal.addEventListener("abort", () => { try { currentProc?.kill("SIGTERM"); } catch {} }); // Unified stream-health log every 10s: Scrypted-side sent fps // + firmware-side painted fps side-by-side, so the user can // see at a glance "we sent N, the panel showed M." The gap // (sent - painted) is what the firmware's FIONREAD skip // dropped to keep the panel showing the freshest frame. // Includes firmware-side per-stage timings (recv/dec/paint/ // idle min/avg/max) + glass-to-glass age of the most recent // painted frame. /state poll is folded in so there's one log // line per window instead of two interleaved timelines. const streamLogger = setInterval(async () => { const now = Date.now(); const window = (now - lastLogMs) / 1000; // Roll the window even on quiet ticks — otherwise the first // active window after a lull spans the whole quiet stretch // and under-reports every rate (which can also suppress the // fw-skipped noteworthy trigger). lastLogMs = now; if (window <= 0 || (sentFrames === 0 && droppedFrames === 0)) return; const sentRate = sentFrames / window; const dropRate = droppedFrames / window; const mbPerSec = (bytesSent / window) / (1024 * 1024); const sortedW = writeLatencies.slice().sort((a, b) => a - b); const p50 = sortedW.length ? sortedW[Math.floor(sortedW.length * 0.5)] : 0; const p95 = sortedW.length ? sortedW[Math.floor(sortedW.length * 0.95)] : 0; const max = sortedW.length ? sortedW[sortedW.length - 1] : 0; // Best-effort firmware-side snapshot. Timeout < 1s so a // missed /state never wedges the logger. let painted = "?", paintedMb = "?", g2g = "?", paintedNum = -1; let recvStr = "?", decStr = "?", paintStr = "?", idleStr = "?"; let tempStr = "?"; try { const st: any = await fetch(`http://${v.host}/state`, { signal: AbortSignal.timeout(800), }).then(r => r.json()); const fs = st?.stream; if (fs?.frames && fs.window_us > 0) { paintedNum = fs.frames / (fs.window_us / 1e6); painted = paintedNum.toFixed(1); paintedMb = ((fs.bytes / (fs.window_us / 1e6)) / (1024 * 1024)).toFixed(2); recvStr = `${fs.recv_min_us}/${fs.recv_avg_us}/${fs.recv_max_us}`; decStr = `${fs.dec_min_us}/${fs.dec_avg_us}/${fs.dec_max_us}`; paintStr = `${fs.paint_min_us}/${fs.paint_avg_us}/${fs.paint_max_us}`; idleStr = `${fs.idle_min_us}/${fs.idle_avg_us}/${fs.idle_max_us}`; } // On-die junction temp (°C); free thermal trending under // streaming load, the hottest thing this device does. if (typeof st?.temp_c === "number") tempStr = st.temp_c.toFixed(1); if (fs?.last_paint_event_us_low) { const nowUsLow = (Date.now() * 1000) >>> 0; const diff = (nowUsLow - fs.last_paint_event_us_low) >>> 0; if (diff < 30_000_000) g2g = (diff / 1000).toFixed(0) + "ms"; } } catch { /* keep the local stats; firmware-side just shows ? */ } const skipped = paintedNum >= 0 ? Math.max(0, sentRate - paintedNum).toFixed(1) : "?"; // node_buf = bytes Scrypted has handed to socket.write but // the kernel send buffer hasn't accepted yet — they sit in // Node's internal queue, NOT on the wire. Divide by the // current send rate to estimate how many seconds of // already-emitted frames are waiting at the source. This // is the load-bearing piece of the g2g "buffer depth" // story: when this is large, the firmware can't possibly // be showing the freshest bytes because we haven't even // sent them yet. const nodeBufBytes = sock?.writableLength ?? 0; const sentBps = mbPerSec * 1024 * 1024; const nodeBufMs = sentBps > 0 ? (nodeBufBytes / sentBps * 1000).toFixed(0) : "?"; // Log only noteworthy windows to keep the console readable when a // chatty camera keeps a viewport streaming for long stretches. A // healthy 24fps window prints nothing (the per-wake lines already // confirm liveness); we surface only degradation: socket-not-ready // drops, a buffer-cap flush this window, the firmware shedding ≥2fps // to stay fresh, or painted dipping below ~20fps. const flushedThisWindow = flushCount - lastFlushCount; lastFlushCount = flushCount; const skippedNum = paintedNum >= 0 ? Math.max(0, sentRate - paintedNum) : 0; const noteworthy = droppedFrames > 0 || flushedThisWindow > 0 || skippedNum >= 2 || (paintedNum >= 0 && paintedNum < 20); if (noteworthy) this.console.log( `stream "${v.name}": sent=${sentRate.toFixed(1)}fps painted=${painted}fps ` + `(fw-skipped=${skipped}fps, drop-oldest=${droppedOldest}, drops=${droppedFrames}, flushes=${flushCount}) ` + `${mbPerSec.toFixed(2)}MB/s sent / ${paintedMb}MB/s painted | ` + `socket.write p50=${p50}ms p95=${p95}ms max=${max}ms backpressured=${socketBackpressured} bp=${framesSampled > 0 ? ((framesUnderBp / framesSampled) * 100).toFixed(0) : "?"}% ` + `node_buf=${(nodeBufBytes / 1024).toFixed(0)}KB≈${nodeBufMs}ms/${v.maxNodeBufMb}MB cap | ` + `recv=${recvStr}us dec=${decStr}us paint=${paintStr}us idle=${idleStr}us | temp=${tempStr}C | g2g=${g2g}`); droppedFrames = 0; droppedOldest = 0; framesSampled = 0; framesUnderBp = 0; sentFrames = 0; bytesSent = 0; writeLatencies.length = 0; }, 10_000); abort.signal.addEventListener("abort", () => clearInterval(streamLogger)); const timeoutMs = v.idleTimeoutMs > 0 ? v.idleTimeoutMs : DEFAULT_IDLE_TIMEOUT_MS; const timeout = setTimeout(() => { this.console.log(`"${v.name}": Scrypted-side stream timeout — stopping`); // A never-sleep viewport (idle_timeout_ms=0, firmware idle // timer disabled) still gets its ffmpeg/socket reclaimed by // this safety timer, but we must not POST sleep and override // the user's always-on setting — the panel just keeps showing // the last frame. this.stopStream(v.nativeId!, /*sendSleep=*/ v.idleTimeoutMs > 0); }, timeoutMs); // Clear the idle timer on ANY teardown path, not just stopStream. // On a StartStop.stop()/re-paste drain the stream is killed via its // abort cleaner (not stopStream), which would otherwise leave this // setTimeout dangling for up to idleTimeoutMs (default 60s). abort.signal.addEventListener("abort", () => clearTimeout(timeout)); // The latest spawned ffmpeg child is held in the spawnFfmpeg // closure (currentProc); the abort signal listener kills it on // shutdown. We don't store the proc in the streams entry // because it can change across auto-restarts. this.streams.set(v.nativeId!, { timeout, abort }); } stopStream(nativeId: string, sendSleep = true) { const s = this.streams.get(nativeId); if (!s) return; s.abort.abort(); // aborts the in-flight ffmpeg child via its listener clearTimeout(s.timeout); this.streams.delete(nativeId); const v = this.viewports.get(nativeId); if (sendSleep && v?.host) { this.postJSON(`http://${v.host}/state`, { state: "sleep" }).catch(() => {}); } } // ffmpeg -vf filter chain producing panel-native 800x480 BGR888. // Rotation goes FIRST so the final mjpeg encoder sees the exact target // dimensions — earlier we observed mjpeg writing pre-rotation dims into // the JPEG SOF marker when scale came first, breaking the firmware's // strict dim check. private buildVf(orientation: string, panelW: number, panelH: number): string { return orientation === "portrait" ? `transpose=1,scale=${panelW}:${panelH}:flags=lanczos,setsar=1` : `scale=${panelW}:${panelH}:flags=lanczos,setsar=1`; } // Inbound routing key → viewport. Match the persisted display_name // first — same precedence registerViewport uses — because v.name can // briefly drift to the nativeId on script reload, which would 404 a // device tap-wake arriving right after a re-paste. private findByName(name: string): Viewport | undefined { for (const v of this.viewports.values()) { try { if (v.storage.getItem("display_name") === name) return v; } catch { /* storage not attached yet — fall through to v.name */ } } for (const v of this.viewports.values()) if (v.name === name) return v; return undefined; } // ------------------------------------------------------------------------ // 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") { // Same guard as every other start path: if a stream is live or // mid-start, leave it alone — a concurrent second startStream // would overwrite the streams entry and orphan the first ffmpeg. const nid = v.nativeId!; if (!this.streams.has(nid) && !this.streamStarting.has(nid)) { this.streamStarting.add(nid); // Fire-and-forget: the device's outbound POST has a 1s // timeout, so awaiting a multi-second startStream here // just turns every tap-wake into a firmware-side // state_post_failure. alreadyAwake — it woke itself. this.startStream(v, Date.now(), /*alreadyAwake=*/ true) .catch(e => this.console.error("device-initiated wake failed", e)) .finally(() => this.streamStarting.delete(nid)); } } else { this.stopStream(v.nativeId!, /*sendSleep=*/ false); } response.send("", { code: 204 }); } // ------------------------------------------------------------------------ // 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: "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) {} // ------------------------------------------------------------------------ // 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) { throw new Error(`POST ${url} -> ${res.status}`); } } } export default ScryptedViewportProvider;