// 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 we currently have a listener attached for.
// 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. "" = processed-but-no-camera
// (suppresses repeated "no camera assigned" warnings); absent = never
// processed.
private attachedCameraId = 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.attachedCameraId.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 have = this.attachedCameraId.get(nid);
// Idempotent fast-path: already listening on the right camera 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 empty-camera case dedups on `have === want` below.)
if (want === have && (want === "" || (this.listeners.get(nid)?.length ?? 0) > 0)) return;
// State changed (first attach, camera swapped, 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.attachedCameraId.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 attachedCameraId 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.
const ifaces = [
ScryptedInterface.BinarySensor, // doorbell ring
ScryptedInterface.MotionSensor, // motion
ScryptedInterface.ObjectDetector, // person / etc
];
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 attachedCameraId +
// 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.attachedCameraId.delete(nid);
}
});
this.listeners.set(nid, regs);
this.attachedCameraId.set(nid, want);
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 swap).
this.attachedCameraId.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;