diff options
| author | Luke Hoersten <[email protected]> | 2026-06-14 10:53:37 -0500 |
|---|---|---|
| committer | Luke Hoersten <[email protected]> | 2026-06-14 10:53:37 -0500 |
| commit | 52f4105aa059b9d7310d80cfb86abe844f7be05a (patch) | |
| tree | 9c1dc09da9b77ec75334c0772d2179487b1f1f71 /scrypted/scrypted-viewport.ts | |
| parent | 334455c8ab793e2303e5e3669fa6deba179412be (diff) | |
Path B: per-viewport Scrypted devices with UI-driven settings
Convert the Scripts-plugin script from a hardcoded BINDINGS constant
into a DeviceProvider + DeviceCreator + HttpRequestHandler. Each
viewport is now a child Scrypted device under the parent script with
its own Settings page; operators add, edit, and delete viewports
entirely through the Scrypted UI.
Parent (ScryptedViewportProvider):
- DeviceProvider: getDevice(nativeId) instantiates a Viewport, attaches
its camera event listener, and posts /config. releaseDevice tears
down stream + listener + child storage entry.
- DeviceCreator: "+ Add Device" on the parent's page shows a small form
(name / host / camera picker filtered to Camera interfaces /
orientation choice). createDevice() pre-populates the child's storage
via deviceManager.getDeviceStorage(nativeId) and registers it under
the parent.
- Tracks known child nativeIds in its own storage as a JSON array so it
can eagerly instantiate every child on plugin start (each
registration + camera subscription happens at load time, not lazily).
- 5-min re-register loop catches devices that rebooted or got new DHCP
leases.
- HttpRequestHandler routes POST <base>/state on the parent's endpoint;
body {viewport, state} is matched against child names. Honors the
spec race rules: every callback cancels any prior stream + safety
timer for that viewport before applying the new state, and a /frame
409 stops the stream without echoing sleep back.
- Global tuning (frame_interval_ms) lives on the parent's Settings.
Child (Viewport):
- Settings: host (string), camera (type=device with deviceFilter for
the Camera interface — the UX win), orientation (choices), idle
timeout, brightness. All persisted via this.storage.
- putSetting fires onBindingChanged() on the parent so re-register +
re-subscribe happen immediately when any field changes.
scrypted/README.md rewritten for the UI-driven flow — install + add
device + edit + remove + global tuning + smoke test — no more "edit
BINDINGS and re-save."
scrypted/package.json + tsconfig.json: optional `npm install` so
editors can resolve @scrypted/sdk types. Nothing here ships — install
remains "paste into Scripts plugin." node_modules ignored.
Diffstat (limited to 'scrypted/scrypted-viewport.ts')
| -rw-r--r-- | scrypted/scrypted-viewport.ts | 552 |
1 files changed, 355 insertions, 197 deletions
diff --git a/scrypted/scrypted-viewport.ts b/scrypted/scrypted-viewport.ts index 026a3e2..7d10a57 100644 --- a/scrypted/scrypted-viewport.ts +++ b/scrypted/scrypted-viewport.ts @@ -1,106 +1,146 @@ // Scrypted Viewport — v1 Scripts-plugin script // -// What this does -// -------------- -// One Scrypted Script binds N viewport devices (the ESP32-P4 panels) to N -// Scrypted cameras. On a camera event (doorbell ring, motion, person), it -// wakes the bound viewport and streams snapshots until the viewport tells -// us to stop (or the per-stream timeout cuts us off). When the operator -// taps the viewport, the device POSTs us a wake/sleep call and we mirror -// the stream state. +// 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 <base>/state` handler. // -// How to install -// -------------- -// 1. In Scrypted's web UI, install the "Scripts" plugin if you haven't. -// 2. Create a new script device under that plugin. -// 3. Paste this whole file into the script editor and save. -// 4. Edit the BINDINGS constant below — one entry per viewport device, -// matching the `viewport` name the device will use in /config and -// the Scrypted device ID of the camera you want it bound to. -// 5. The script registers each viewport with `/config` on load and again -// every REREGISTER_INTERVAL_MS to handle DHCP renumbering / reboots. +// Install +// ------- +// 1. In Scrypted: Plugins → install "Scripts" if needed. +// 2. + Add Device → Scripts plugin → New Script. +// 3. Paste this entire file. Save. +// 4. Open the new "Scrypted Viewport" device. Click "+ Add Device" on +// its page to create a viewport binding: +// Viewport name e.g. "mudroom" (becomes the routing key + mDNS name) +// IP or hostname e.g. "192.168.1.42" +// Camera pick from the dropdown of Camera devices +// Orientation portrait (480×800) or landscape (800×480) +// 5. The script POSTs /config to the device immediately and re-issues it +// every 5 minutes so a reboot or DHCP renumber re-syncs. +// 6. Edit a viewport's settings from its own device page in the UI. The +// script re-registers and re-subscribes whenever a setting changes. // -// Protocol contract is in this repo's README.md and TESTING.md. -// -// Limitations of this v1 -// ---------------------- -// - Snapshot-rate (~1 fps) via Camera.takePicture(). Live-rate streaming -// over POST /stream is v2 (custom plugin with FFmpeg/MJPEG). -// - Viewport IP is operator-configured, not mDNS-discovered. Add IPs to -// each BINDINGS entry; if a viewport gets a new DHCP lease, update the -// IP and re-save. (Proper bonjour-service discovery is a v1.1 addition.) -// - Camera must respect `picture.width`/`picture.height` in -// PictureOptions, OR be paired with a snapshot plugin that resizes. -// If the camera returns a different size, /frame will reject with 400 -// and the next attempt will fail the same way until the camera is -// reconfigured. +// v1 limits (path to v2: a packaged plugin with FFmpeg streaming) +// --------------------------------------------------------------- +// - Snapshot-rate (~1 fps) via Camera.takePicture(). Live-rate MJPEG over +// POST /stream is v2. +// - Manual IP per viewport (no mDNS-SD discovery). Use a DHCP reservation. +// - Camera must respect picture.width/height OR be paired with a snapshot +// plugin that resizes. Otherwise /frame returns 400. import sdk, { + DeviceCreator, + DeviceCreatorSettings, + DeviceProvider, + EventListenerRegister, HttpRequest, HttpRequestHandler, HttpResponse, ScryptedDeviceBase, + ScryptedDeviceType, ScryptedInterface, - EventListenerRegister, + Setting, + Settings, + SettingValue, } from "@scrypted/sdk"; -const { systemManager, endpointManager, mediaManager } = sdk; +const { systemManager, endpointManager, mediaManager, deviceManager } = sdk; + +// Tuning constants. Frame interval is also exposed on the parent's +// Settings page so it can be tweaked without editing the script. +const DEFAULT_FRAME_INTERVAL_MS = 1_000; +const REREGISTER_INTERVAL_MS = 5 * 60_000; +const HTTP_TIMEOUT_MS = 1_000; +const DEFAULT_IDLE_TIMEOUT_MS = 60_000; +const DEFAULT_BRIGHTNESS = 80; // ============================================================================ -// CONFIG — edit these for your setup +// Child: one viewport binding // ============================================================================ -interface Binding { - /** Viewport name; matches the device's /config `viewport` field and its - * mDNS hostname (viewport-<name>.local). Must be unique. */ - name: string; - /** Operator-set IP or hostname of the viewport. Plain IP is simplest. */ - host: string; - /** Scrypted device id (not name) of the camera. Get it from the URL - * bar of the camera's settings page in Scrypted. */ - cameraId: string; - /** Physical orientation of the panel. `portrait` = 480x800, `landscape` - * = 800x480. Frames are sent at this resolution. */ - orientation: "portrait" | "landscape"; -} - -const BINDINGS: Binding[] = [ - // { - // name: "mudroom", - // host: "192.168.1.42", - // cameraId: "abcdef0123456789", - // orientation: "portrait", - // }, -]; - -/** Sent to the device in /config. The device's idle timer and Scrypted's - * per-stream timer both use this value, independently. */ -const IDLE_TIMEOUT_MS = 60_000; - -/** Hard ceiling on a stream session. Stops streaming this many ms after - * the most recent wake event. Matches the device's idle timeout so the - * two sides cut off together. */ -const STREAM_TIMEOUT_MS = IDLE_TIMEOUT_MS; +class Viewport extends ScryptedDeviceBase implements Settings { + constructor(public provider: ScryptedViewportProvider, nativeId: string) { + super(nativeId); + } -/** Time between snapshot pushes during an active stream. 1 fps is fine - * for v1 ambient viewing; tune as desired. */ -const FRAME_INTERVAL_MS = 1_000; + 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; + } -/** Re-issue POST /config to every viewport at this cadence so a viewport - * that rebooted (or got a new IP) re-syncs without manual intervention. */ -const REREGISTER_INTERVAL_MS = 5 * 60_000; + async getSettings(): Promise<Setting[]> { + return [ + { + key: "host", + title: "IP or hostname", + description: "Viewport's address on the LAN. Update if DHCP renumbers (or use a DHCP reservation).", + placeholder: "192.168.1.42", + value: this.host, + }, + { + key: "cameraId", + title: "Camera", + description: "Camera whose events drive this viewport's wake/sleep, and whose snapshots get streamed.", + type: "device", + deviceFilter: `interfaces.includes('${ScryptedInterface.Camera}')`, + value: this.cameraId, + }, + { + key: "orientation", + title: "Orientation", + description: "Panel orientation. Frames are sent at this effective resolution.", + choices: ["portrait", "landscape"], + value: this.orientation, + }, + { + key: "idle_timeout_ms", + title: "Idle timeout (ms)", + description: "Sent to the device via /config; both sides time independently. 0 disables the device-side idle timer; non-zero must be ≥ 5000.", + type: "number", + value: this.idleTimeoutMs, + }, + { + key: "brightness", + title: "Brightness (0–100)", + description: "Sent to the device via /config. Gamma-corrected on the panel.", + type: "number", + value: this.brightness, + }, + ]; + } -/** Outbound HTTP timeout per call. */ -const HTTP_TIMEOUT_MS = 1_000; + async putSetting(key: string, value: SettingValue) { + this.storage.setItem(key, String(value ?? "")); + await this.provider.onBindingChanged(this); + } +} // ============================================================================ -// Implementation +// Parent: provider + HTTP handler + global tuning // ============================================================================ -class ScryptedViewportScript extends ScryptedDeviceBase implements HttpRequestHandler { - private bindings = new Map<string, Binding>(); - private listeners: EventListenerRegister[] = []; - private streams = new Map<string, { +class ScryptedViewportProvider extends ScryptedDeviceBase + implements DeviceProvider, DeviceCreator, HttpRequestHandler, Settings { + + private viewports = new Map<string, Viewport>(); // nativeId -> child instance + private listeners = new Map<string, EventListenerRegister>(); // nativeId -> camera event listener + private streams = new Map<string, { // viewport name -> stream control interval: NodeJS.Timeout; timeout: NodeJS.Timeout; abort: AbortController; @@ -112,110 +152,225 @@ class ScryptedViewportScript extends ScryptedDeviceBase implements HttpRequestHa this.start().catch(e => this.console.error("start failed", e)); } - private async start() { - for (const b of BINDINGS) this.bindings.set(b.name, b); + // ------------------------------------------------------------------------ + // Lifecycle + // ------------------------------------------------------------------------ - // Endpoint URL the devices will POST callbacks to. + private get childIds(): string[] { + try { return JSON.parse(this.storage.getItem("childIds") || "[]"); } + catch { return []; } + } + private set childIds(ids: string[]) { + this.storage.setItem("childIds", JSON.stringify(ids)); + } + + private get frameIntervalMs(): number { + const v = this.storage.getItem("frame_interval_ms"); + return v ? Math.max(100, parseInt(v, 10) || DEFAULT_FRAME_INTERVAL_MS) + : DEFAULT_FRAME_INTERVAL_MS; + } + + private async start() { const raw = await endpointManager.getInsecurePublicLocalEndpoint(this.id); this.scryptedBase = raw.replace(/\/$/, ""); - this.console.log(`Scrypted Viewport script up. Callback URL base: ${this.scryptedBase}`); - this.console.log(`Bindings: ${BINDINGS.length} viewport(s)`); - - // Register every viewport on boot and on a slow refresh timer. - await this.registerAll(); - setInterval(() => this.registerAll().catch(() => {}), REREGISTER_INTERVAL_MS); - - // Subscribe to camera events for every binding. - for (const b of BINDINGS) { - const camera = systemManager.getDeviceById(b.cameraId); - if (!camera) { - this.console.warn(`camera ${b.cameraId} for viewport "${b.name}" not found`); - continue; + this.console.log(`Scrypted Viewport up. Callback URL base: ${this.scryptedBase}`); + + // Eagerly instantiate every known child so its registration + camera + // event subscription happen at plugin load (rather than waiting for + // some other part of Scrypted to touch the child). + for (const nativeId of this.childIds) { + try { await this.getDevice(nativeId); } + catch (e) { this.console.warn(`load child ${nativeId} failed:`, (e as Error).message); } + } + + // Periodic re-register so a device that rebooted or got a new IP + // re-syncs without manual intervention. + setInterval(() => { + for (const v of this.viewports.values()) { + this.registerViewport(v).catch(() => {}); } - const ifaces = [ - ScryptedInterface.BinarySensor, // doorbell - ScryptedInterface.MotionSensor, // motion - ScryptedInterface.ObjectDetector, // person/vehicle/... - ]; - const reg = camera.listen(ifaces, (source, details, data) => { - this.handleCameraEvent(b, details, data); - }); - this.listeners.push(reg); - this.console.log(`subscribed to "${camera.name}" events for viewport "${b.name}"`); + }, REREGISTER_INTERVAL_MS); + } + + // ------------------------------------------------------------------------ + // DeviceProvider + // ------------------------------------------------------------------------ + + async getDevice(nativeId: string): Promise<Viewport> { + 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; } - private viewportUrl(host: string, path: string) { - return `http://${host}${path}`; + async releaseDevice(id: string, nativeId: string) { + const v = this.viewports.get(nativeId); + if (v) { + this.stopStream(v.name, /*sendSleep=*/ false); + this.detachListener(nativeId); + this.viewports.delete(nativeId); + } + this.childIds = this.childIds.filter(x => x !== nativeId); } - private async registerAll() { - for (const b of BINDINGS) { - try { - await this.postJSON(this.viewportUrl(b.host, "/config"), { - viewport: b.name, - scrypted: this.scryptedBase, - idle_timeout_ms: IDLE_TIMEOUT_MS, - orientation: b.orientation, - brightness: 80, - }); - } catch (e) { - this.console.warn(`register ${b.name} (${b.host}) failed:`, (e as Error).message); - } + // ------------------------------------------------------------------------ + // DeviceCreator — "+ Add Device" form on the parent + // ------------------------------------------------------------------------ + + async getCreateDeviceSettings(): Promise<Setting[]> { + return [ + { + key: "name", + title: "Viewport name", + description: 'Routing key sent back in callbacks + basis for the device\'s mDNS hostname (viewport-<name>.local). Lowercase, no spaces. Example: "mudroom".', + placeholder: "mudroom", + }, + { + key: "host", + title: "IP or hostname", + placeholder: "192.168.1.42", + }, + { + key: "cameraId", + title: "Camera", + type: "device", + deviceFilter: `interfaces.includes('${ScryptedInterface.Camera}')`, + }, + { + key: "orientation", + title: "Orientation", + choices: ["portrait", "landscape"], + value: "portrait", + }, + ]; + } + + async createDevice(settings: DeviceCreatorSettings): Promise<string> { + const name = String(settings.name || "viewport").trim(); + const nativeId = `vp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; + + // Pre-populate the child's storage so its first registration uses + // the form values rather than empty defaults. + const childStore = deviceManager.getDeviceStorage(nativeId); + childStore.setItem("host", String(settings.host || "")); + childStore.setItem("cameraId", String(settings.cameraId || "")); + childStore.setItem("orientation", String(settings.orientation || "portrait")); + + await deviceManager.onDeviceDiscovered({ + providerNativeId: this.nativeId, + nativeId, + name, + type: ScryptedDeviceType.Sensor, + interfaces: [ScryptedInterface.Settings], + }); + + this.childIds = [...this.childIds, nativeId]; + this.console.log(`created viewport "${name}" (${nativeId})`); + return nativeId; + } + + // ------------------------------------------------------------------------ + // Per-binding plumbing (camera subscription + /config registration) + // ------------------------------------------------------------------------ + + onBindingChanged = async (v: Viewport): Promise<void> => { + const nid = v.nativeId!; + this.detachListener(nid); + // Any active stream for this viewport is now stale (camera may have + // changed). Stop it cleanly; the next event/wake will start fresh. + this.stopStream(v.name, /*sendSleep=*/ false); + this.attachListener(v); + await this.registerViewport(v); + }; + + private attachListener(v: Viewport) { + if (!v.cameraId) return; + const cam = systemManager.getDeviceById(v.cameraId); + if (!cam) { + this.console.warn(`viewport "${v.name}": camera ${v.cameraId} not found`); + return; + } + const ifaces = [ + ScryptedInterface.BinarySensor, // doorbell + ScryptedInterface.MotionSensor, // motion + ScryptedInterface.ObjectDetector, // person / etc + ]; + const reg = cam.listen(ifaces, (source, details, data) => { + this.handleCameraEvent(v, details, data); + }); + this.listeners.set(v.nativeId!, reg); + this.console.log(`viewport "${v.name}": subscribed to "${cam.name}"`); + } + + private detachListener(nativeId: string) { + const reg = this.listeners.get(nativeId); + if (reg) { + try { reg.removeListener(); } catch {} + this.listeners.delete(nativeId); + } + } + + private async registerViewport(v: Viewport) { + if (!v.host) return; + try { + await this.postJSON(`http://${v.host}/config`, { + viewport: v.name, + scrypted: this.scryptedBase, + idle_timeout_ms: v.idleTimeoutMs, + orientation: v.orientation, + brightness: v.brightness, + }); + this.console.log(`registered "${v.name}" (${v.host})`); + } catch (e) { + this.console.warn(`register "${v.name}" failed:`, (e as Error).message); } } // ------------------------------------------------------------------------ - // Camera event → wake the bound viewport + // Camera event → stream // ------------------------------------------------------------------------ - private handleCameraEvent(b: Binding, details: any, data: any) { + private handleCameraEvent(v: Viewport, details: any, data: any) { const iface = details.eventInterface as ScryptedInterface; let trigger = false; - if (iface === ScryptedInterface.BinarySensor && data === true) trigger = true; if (iface === ScryptedInterface.MotionSensor && data === true) trigger = true; if (iface === ScryptedInterface.ObjectDetector) { const detections = data?.detections ?? []; - // Heuristic: any person detection wakes the viewport. Customize - // here if you want to filter by zone, confidence, class, etc. if (detections.some((d: any) => d?.className === "person")) trigger = true; } if (!trigger) return; - - this.console.log(`event ${iface} on ${b.name} -> wake + stream`); - this.startStream(b.name).catch(e => this.console.error("startStream failed", e)); + this.console.log(`event ${iface} -> "${v.name}": wake`); + this.startStream(v).catch(e => this.console.error("startStream failed", e)); } - // ------------------------------------------------------------------------ - // Stream control - // ------------------------------------------------------------------------ + private async startStream(v: Viewport) { + // Race rule: cancel pending operations on every callback before + // beginning a fresh stream. + this.stopStream(v.name, /*sendSleep=*/ false); - private async startStream(name: string) { - const b = this.bindings.get(name); - if (!b) return; + if (!v.host || !v.cameraId) return; - // Race rule from spec: cancel pending operations on every callback. - // Stopping an already-running stream first ensures the safety timeout - // restarts cleanly and we don't double-send /state {wake}. - this.stopStream(name, /*sendSleep=*/ false); - - // Wake the device first so it shows the loading screen before frames arrive. - await this.postJSON(this.viewportUrl(b.host, "/state"), { state: "wake" }); + await this.postJSON(`http://${v.host}/state`, { state: "wake" }); const abort = new AbortController(); const interval = setInterval(() => { - this.pushFrame(b, abort).catch(e => { - if (!abort.signal.aborted) this.console.warn(`pushFrame ${name} failed:`, (e as Error).message); + this.pushFrame(v, abort).catch(e => { + if (!abort.signal.aborted) this.console.warn(`pushFrame "${v.name}":`, (e as Error).message); }); - }, FRAME_INTERVAL_MS); + }, this.frameIntervalMs); + const timeoutMs = v.idleTimeoutMs > 0 ? v.idleTimeoutMs : DEFAULT_IDLE_TIMEOUT_MS; const timeout = setTimeout(() => { - this.console.log(`${name} stream timeout — stopping`); - this.stopStream(name); - }, STREAM_TIMEOUT_MS); + this.console.log(`"${v.name}": Scrypted-side stream timeout — stopping`); + this.stopStream(v.name); + }, timeoutMs); - this.streams.set(name, { interval, timeout, abort }); + this.streams.set(v.name, { interval, timeout, abort }); } private stopStream(name: string, sendSleep = true) { @@ -225,35 +380,34 @@ class ScryptedViewportScript extends ScryptedDeviceBase implements HttpRequestHa clearInterval(s.interval); clearTimeout(s.timeout); this.streams.delete(name); - if (sendSleep) { - const b = this.bindings.get(name); - if (b) { - this.postJSON(this.viewportUrl(b.host, "/state"), { state: "sleep" }) - .catch(() => {}); + const v = this.findByName(name); + if (v?.host) { + this.postJSON(`http://${v.host}/state`, { state: "sleep" }).catch(() => {}); } } } - private async pushFrame(b: Binding, abort: AbortController) { - if (abort.signal.aborted) return; + private findByName(name: string): Viewport | undefined { + for (const v of this.viewports.values()) if (v.name === name) return v; + return undefined; + } - const camera: any = systemManager.getDeviceById(b.cameraId); - if (!camera) return; + private async pushFrame(v: Viewport, abort: AbortController) { + if (abort.signal.aborted) return; + const cam: any = systemManager.getDeviceById(v.cameraId); + if (!cam) return; - const w = b.orientation === "portrait" ? 480 : 800; - const h = b.orientation === "portrait" ? 800 : 480; + const w = v.orientation === "portrait" ? 480 : 800; + const h = v.orientation === "portrait" ? 800 : 480; - // Ask the camera for a JPEG at the panel's effective resolution. Many - // snapshot providers honor this; some ignore it and return native - // resolution (in which case the device will reject with 400). - const picture = await camera.takePicture({ + const picture = await cam.takePicture({ picture: { width: w, height: h }, reason: "event", }); const buf: Buffer = await mediaManager.convertMediaObjectToBuffer(picture, "image/jpeg"); - const res = await fetch(this.viewportUrl(b.host, "/frame"), { + const res = await fetch(`http://${v.host}/frame`, { method: "POST", headers: { "Content-Type": "image/jpeg" }, body: buf, @@ -261,63 +415,67 @@ class ScryptedViewportScript extends ScryptedDeviceBase implements HttpRequestHa }); if (res.status === 409) { - this.console.log(`${b.name} returned 409 — device went to sleep, stopping stream`); - this.stopStream(b.name, /*sendSleep=*/ false); + this.console.log(`"${v.name}" returned 409 — device went to sleep, stopping stream`); + this.stopStream(v.name, /*sendSleep=*/ false); } else if (res.status === 400) { - // Most likely a dimension mismatch — log once per stream window. const reason = await res.text().catch(() => ""); - this.console.warn(`${b.name} returned 400: ${reason}`); + this.console.warn(`"${v.name}" returned 400: ${reason}`); } else if (!res.ok) { - this.console.warn(`${b.name} /frame failed: ${res.status}`); + this.console.warn(`"${v.name}" /frame -> ${res.status}`); } } // ------------------------------------------------------------------------ - // Inbound: device -> Scrypted POST /state + // 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; - } + 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; - } + try { body = JSON.parse(request.body); } + catch { response.send("invalid JSON", { code: 400 }); return; } + const { viewport, state } = body ?? {}; - if (typeof viewport !== "string" || !this.bindings.has(viewport)) { - response.send(`unknown viewport: ${viewport}`, { code: 404 }); - return; - } + 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 }); + response.send("state must be wake or sleep", { code: 400 }); return; } - this.console.log(`recv ${viewport} -> ${state} (device-initiated)`); + this.console.log(`recv "${viewport}" -> ${state} (device-initiated)`); if (state === "wake") { - // Race rule: startStream cancels pending operations first. - await this.startStream(viewport); + await this.startStream(v); } else { - // Race rule: stopStream clears its safety timeout. Don't echo a - // /state {sleep} back to the device — it already knows. - this.stopStream(viewport, /*sendSleep=*/ false); + this.stopStream(v.name, /*sendSleep=*/ false); } - response.send("", { code: 204 }); } // ------------------------------------------------------------------------ + // Parent Settings — global tuning only + // ------------------------------------------------------------------------ + + async getSettings(): Promise<Setting[]> { + return [ + { + key: "frame_interval_ms", + title: "Frame push interval (ms)", + description: "How often a snapshot is pushed to each viewport during an active stream. 1000 = 1 fps.", + type: "number", + value: this.frameIntervalMs, + }, + ]; + } + + async putSetting(key: string, value: SettingValue) { + this.storage.setItem(key, String(value ?? "")); + } + + // ------------------------------------------------------------------------ // Tiny HTTP helper // ------------------------------------------------------------------------ @@ -335,4 +493,4 @@ class ScryptedViewportScript extends ScryptedDeviceBase implements HttpRequestHa } } -export default ScryptedViewportScript; +export default ScryptedViewportProvider; |
