src.nth.io/

summaryrefslogtreecommitdiff
path: root/scrypted/scrypted-viewport.ts
diff options
context:
space:
mode:
Diffstat (limited to 'scrypted/scrypted-viewport.ts')
-rw-r--r--scrypted/scrypted-viewport.ts552
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;