src.nth.io/

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore4
-rw-r--r--scrypted/README.md121
-rw-r--r--scrypted/package.json11
-rw-r--r--scrypted/scrypted-viewport.ts552
-rw-r--r--scrypted/tsconfig.json14
5 files changed, 451 insertions, 251 deletions
diff --git a/.gitignore b/.gitignore
index cb13780..0dcf175 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,10 @@ dependencies.lock
managed_components/
/.cache/
+# Scrypted-side script: optional local type-checking
+scrypted/node_modules/
+scrypted/package-lock.json
+
# Emacs lock files
.#*
\#*\#
diff --git a/scrypted/README.md b/scrypted/README.md
index f279ca9..5f25c70 100644
--- a/scrypted/README.md
+++ b/scrypted/README.md
@@ -6,78 +6,91 @@ The ESP32 firmware needs something on the Scrypted side that:
- starts a JPEG stream to a viewport when its bound camera fires an event (doorbell ring, motion, person), and
- stops the stream when the viewport reports `sleep` or its own per-stream timer expires.
-`scrypted-viewport.ts` in this directory does all of that as a single-file TypeScript script for the **Scripts plugin**. v2 will replace this with a packaged plugin doing `POST /stream` over MJPEG; for now this is enough to bring up end-to-end functionality.
+`scrypted-viewport.ts` in this directory does all of that as a single-file TypeScript script for the **Scripts plugin**. Each viewport is a child Scrypted device under the script — you add, remove, and edit viewports entirely through the Scrypted UI; no script editing required after the initial paste.
+
+v2 will replace this with a packaged plugin doing `POST /stream` over MJPEG; for now this is enough to bring up end-to-end functionality.
## Install
1. In Scrypted's web UI: **Plugins → search "Scripts" → install** (if you don't already have it).
2. **+ Add Device → Scripts plugin → New Script**.
3. Paste the entire contents of `scrypted-viewport.ts` into the editor and **Save**.
-4. Edit the `BINDINGS` array at the top of the script for your setup:
-
- ```ts
- const BINDINGS: Binding[] = [
- {
- name: "mudroom", // matches /config viewport
- host: "192.168.1.42", // viewport IP (LAN-resolvable)
- cameraId: "abcdef0123456789", // Scrypted device id
- orientation: "portrait", // 480x800
- },
- ];
- ```
-
- - **`name`**: must match what the script POSTs to the device's `/config` (also drives `viewport-<name>.local`).
- - **`host`**: viewport's current IP or hostname. v1 has no mDNS-SD discovery — set it manually and update if DHCP renumbers (or use a DHCP reservation).
- - **`cameraId`**: the Scrypted device id (not the human name) of the camera to bind. Grab it from the URL bar on the camera's settings page in Scrypted.
- - **`orientation`**: `portrait` (480×800) or `landscape` (800×480). Sent to the device via `/config` and used to size the snapshots.
-
-5. Save. The script's `start()` runs immediately. Within a few seconds you should see lines in the Scrypted log:
-
- ```
- Scrypted Viewport script up. Callback URL base: http://scrypted.local:11080/endpoint/<scriptId>
- Bindings: 1 viewport(s)
- subscribed to "Front Door" events for viewport "mudroom"
- ```
-
-6. On the viewport, `GET /config` should now show the populated `scrypted` URL and the `viewport` name. `GET /state` should show `configured: true` and `state: "asleep"`.
-
-## Tuning constants
-
-Defined right above the class:
-
-| Constant | Default | What it controls |
-| --- | --- | --- |
-| `IDLE_TIMEOUT_MS` | 60000 | Sent to the device in `/config`. Both sides use this value independently. |
-| `STREAM_TIMEOUT_MS` | == `IDLE_TIMEOUT_MS` | Scrypted-side per-stream cutoff. Keep it the same as the device's idle timeout so both ends agree. |
-| `FRAME_INTERVAL_MS` | 1000 | Snapshot push cadence during an active stream. ~1 fps is fine for ambient camera viewing. |
-| `REREGISTER_INTERVAL_MS` | 300000 | Re-issue `/config` to every viewport every 5 minutes so a viewport that rebooted or moved IPs re-syncs without manual intervention. |
-| `HTTP_TIMEOUT_MS` | 1000 | Per-call timeout for outbound POSTs. Matches the device's own 1 s timeout. |
+4. Open the newly-created "Scrypted Viewport" device.
+
+The script is now running. Time to add viewports.
+
+## Adding a viewport
+
+On the "Scrypted Viewport" device page, click **+ Add Device**. You'll get a small form:
+
+| Field | What to enter |
+| --- | --- |
+| **Viewport name** | Lowercase routing key, e.g. `mudroom`. Becomes the device's mDNS hostname (`viewport-mudroom.local`) and the value the firmware sends back in callbacks. |
+| **IP or hostname** | Viewport's LAN address, e.g. `192.168.1.42`. Set a DHCP reservation if you want it to stay put. |
+| **Camera** | Dropdown — pick the camera whose events should wake this viewport. The dropdown is filtered to devices implementing `Camera`. |
+| **Orientation** | `portrait` (480×800, default) or `landscape` (800×480). Tells the device + script what dimensions to send. |
+
+Click **Create**. The script immediately POSTs `/config` to the device. Within a second or two, the viewport device should show up in Scrypted with its own page.
+
+## Editing a viewport
+
+Open the viewport device's page. It has its own Settings tab with the same four fields, plus two extras:
+
+- **Idle timeout (ms)** — sent to the device in `/config`. Both sides time independently. `0` disables the device-side idle timer; non-zero must be ≥ 5000. Default 60000.
+- **Brightness (0–100)** — gamma-corrected on the panel. Default 80.
+
+Changing any setting triggers an immediate re-register and re-subscribes the camera listener if the camera changed.
+
+## Removing a viewport
+
+Open the viewport device's page → **Settings** menu → **Delete Device**. The script stops any active stream, unsubscribes from the camera, and forgets the binding. The physical device keeps its NVS-stored config until it gets a fresh `/config` from somewhere — or hit **BOOT for 5 s** on the panel to factory-reset.
+
+## Global tuning
+
+Open the parent "Scrypted Viewport" device's Settings page:
+
+- **Frame push interval (ms)** — how often a snapshot is pushed during an active stream. 1000 = 1 fps. Lower for faster updates at higher CPU cost.
## What the script does on each event
-- **Camera event (doorbell ring / motion / person)** → call `startStream(binding.name)`:
- - cancel any previous stream for that viewport,
+- **Camera event (doorbell ring / motion / person)** → look up the viewport bound to that camera → `startStream(viewport)`:
+ - cancel any prior stream + safety timer for that viewport,
- POST `{state: "wake"}` to the device,
- start the snapshot interval,
- - arm a `STREAM_TIMEOUT_MS` safety timer.
+ - arm a per-stream safety timer at the viewport's `idle_timeout_ms`.
- **Device-initiated `{state: "wake"}` callback** (operator tapped the panel) → same `startStream` path.
-- **Device-initiated `{state: "sleep"}` callback** → `stopStream` with `sendSleep=false` (the device already knows).
-- **Per-stream safety timer fires** → `stopStream` with `sendSleep=true` (tell the device to sleep).
-- **`POST /frame` returns 409** → device went to sleep on its own (tap-to-sleep, or its idle timer); `stopStream` with `sendSleep=false`.
+- **Device-initiated `{state: "sleep"}` callback** → `stopStream` without echoing sleep back (the device already knows).
+- **Per-stream safety timer fires** → `stopStream` and POST `{state: "sleep"}` to the device.
+- **`POST /frame` returns 409** → device went to sleep on its own (tap-to-sleep, or its idle timer); `stopStream` without echo.
+
+## What the script does on script load + every 5 minutes
+
+- Re-POST `/config` to every known viewport with its current settings. A device that rebooted or got a new DHCP lease re-syncs within 5 minutes without manual intervention.
+
+## Local type-checking (optional)
+
+If you want red squigglies in your editor instead of just trusting the runtime:
+
+```bash
+cd scrypted
+npm install
+```
+
+That pulls in `@scrypted/sdk` and `@types/node` so the TS server can resolve everything. Nothing here is shipped — install is still "paste into Scrypted's web UI."
## v1 limitations
- Snapshot-rate only (~1 fps). Live MJPEG over `POST /stream` is v2.
-- Manual IP per viewport (no mDNS-SD discovery yet — that's v1.1).
-- Camera must respect `picture.width` / `picture.height` in `PictureOptions` or be paired with a snapshot plugin that resizes. If the camera returns the wrong size, the device rejects `/frame` with 400 and you'll see warning logs. Workaround: configure the camera plugin's snapshot size, or wait for v2 which will resize Scrypted-side via FFmpeg.
+- Manual IP per viewport (no mDNS-SD discovery yet). DHCP reservation is the simplest workaround.
+- Camera must respect `picture.width` / `picture.height` in `PictureOptions` or be paired with a snapshot plugin that resizes. If the camera returns the wrong size, the device rejects `/frame` with 400 and you'll see warning logs. Workaround: configure the camera plugin's snapshot size, or wait for v2 (FFmpeg-side resize).
- No retry on transport errors. Best-effort matches the device's own semantics; the next event or callback re-syncs.
## End-to-end smoke test
-Once installed and bindings are filled in:
+After installing the script and adding one viewport binding:
-1. **Viewport boots** with no `/config` yet → IP screen on panel.
-2. Script runs → `POST /config` lands → viewport → `state: asleep`, backlight off.
-3. Tap viewport → device POSTs `{viewport: "mudroom", state: "wake"}` → script logs `recv mudroom -> wake` → snapshots start flowing for `STREAM_TIMEOUT_MS`.
-4. Tap viewport again → device POSTs `{state: "sleep"}` → script stops streaming.
-5. Trigger the bound camera (doorbell, motion sensor, or person detection) → script POSTs `{state: "wake"}` to viewport → snapshots flow for `STREAM_TIMEOUT_MS`, then both sides time out and sleep.
+1. **Fresh viewport** boots → IP screen on panel.
+2. Script's `getDevice()` runs on script start → `POST /config` lands → viewport → `state: asleep`, backlight off.
+3. **Tap the viewport** → device POSTs `{viewport, state: "wake"}` → script logs `recv "<name>" -> wake` → snapshots start flowing for `idle_timeout_ms`.
+4. **Tap again** → device POSTs `{state: "sleep"}` → script stops streaming.
+5. **Trigger the bound camera** (doorbell, motion sensor, or person detection) → script POSTs `{state: "wake"}` → snapshots flow until either side's idle timer cuts off.
diff --git a/scrypted/package.json b/scrypted/package.json
new file mode 100644
index 0000000..7dc6771
--- /dev/null
+++ b/scrypted/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "scrypted-viewport-script",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Workspace metadata so editors can find @scrypted/sdk types when iterating on scrypted-viewport.ts locally. The actual install is to paste the file into Scrypted's Scripts plugin; nothing here is shipped.",
+ "devDependencies": {
+ "@scrypted/sdk": "*",
+ "@types/node": "*",
+ "typescript": "*"
+ }
+}
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;
diff --git a/scrypted/tsconfig.json b/scrypted/tsconfig.json
new file mode 100644
index 0000000..69cc356
--- /dev/null
+++ b/scrypted/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Node",
+ "lib": ["ES2022", "DOM"],
+ "types": ["node"],
+ "strict": false,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true
+ },
+ "include": ["scrypted-viewport.ts"]
+}