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 | |
| 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')
| -rw-r--r-- | scrypted/README.md | 121 | ||||
| -rw-r--r-- | scrypted/package.json | 11 | ||||
| -rw-r--r-- | scrypted/scrypted-viewport.ts | 552 | ||||
| -rw-r--r-- | scrypted/tsconfig.json | 14 |
4 files changed, 447 insertions, 251 deletions
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"] +} |
