src.nth.io/

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-07-17 20:04:05 -0500
committerLuke Hoersten <[email protected]>2026-07-17 20:04:05 -0500
commit118a04f9a56ab338da746f566fca8799fbd3e13d (patch)
treef1ee7c611ff585e51c50bf03ea2cdaaf6eda030d
parent3350c4b1541d626189bc6554a774c6503e4c2f52 (diff)
scrypted: subscribe only to selected wake-trigger interfaces
attachListener listened on BinarySensor+MotionSensor+ObjectDetector unconditionally and relied on handleCameraEvent to filter by trigger — correct, but a doorbell-only viewport still took a callback per motion re-assert on a chatty camera, and the "subscribed to [...]" log implied all three were wake sources. The listener set now follows the selected triggers (zero triggers = tap-only: no listeners at all), and the idempotency key becomes a "cameraId|triggers" signature (attachedCameraId -> attachedListenerSig) so a trigger change forces a re-attach through the existing rebind path. Event-time filtering stays as defense in depth (ObjectDetector still needs the person-class check).
-rw-r--r--scrypted/scrypted-viewport.ts81
1 files changed, 50 insertions, 31 deletions
diff --git a/scrypted/scrypted-viewport.ts b/scrypted/scrypted-viewport.ts
index bfb776d..3502493 100644
--- a/scrypted/scrypted-viewport.ts
+++ b/scrypted/scrypted-viewport.ts
@@ -675,13 +675,15 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
private viewports = new Map<string, Viewport>(); // nativeId -> child instance
private listeners = new Map<string, EventListenerRegister[]>(); // nativeId -> all event listeners for this viewport (camera + child devices)
- // nativeId -> cameraId we currently have a listener attached for.
- // Source of truth for attachListener idempotency: lets it self-heal
- // (re-attach after the reload storage-race) without stacking duplicate
- // listeners on every register cycle. "" = processed-but-no-camera
- // (suppresses repeated "no camera assigned" warnings); absent = never
- // processed.
- private attachedCameraId = new Map<string, string>();
+ // nativeId -> "cameraId|trigger,trigger" signature of the currently
+ // attached listener set. Source of truth for attachListener
+ // idempotency: lets it self-heal (re-attach after the reload
+ // storage-race) without stacking duplicate listeners on every
+ // register cycle, and forces a re-attach when the camera OR the
+ // selected wake triggers change. "" = processed-but-no-camera
+ // (suppresses repeated "no camera assigned" warnings); absent =
+ // never processed.
+ private attachedListenerSig = new Map<string, string>();
// nativeId -> last-registered config signature, so the "registered" line
// logs only on a real change, not every 5-minute reregister cycle.
private lastRegisterSig = new Map<string, string>();
@@ -780,7 +782,7 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
this.viewports.clear();
this.listeners.clear();
this.streams.clear();
- this.attachedCameraId.clear();
+ this.attachedListenerSig.clear();
this.lastRegisterSig.clear();
this.running = false;
}
@@ -1105,29 +1107,36 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
const tag = v.name || v.storage.getItem("display_name") || v.nativeId;
const nid = v.nativeId!;
const want = v.cameraId || "";
- const have = this.attachedCameraId.get(nid);
-
- // Idempotent fast-path: already listening on the right camera and
- // the listeners are still installed → nothing to do. This is what
- // lets registerViewport call us on every (5-min) cycle to self-heal
- // the reload storage-race without restacking listeners or spamming
- // logs. (The empty-camera case dedups on `have === want` below.)
- if (want === have && (want === "" || (this.listeners.get(nid)?.length ?? 0) > 0)) return;
-
- // State changed (first attach, camera swapped, or listeners lost):
- // tear down whatever was there before re-deciding.
+ const triggers = Array.from(v.triggers).sort();
+ const wantSig = want ? `${want}|${triggers.join(",")}` : "";
+ const have = this.attachedListenerSig.get(nid);
+
+ // Idempotent fast-path: already listening on the right camera with
+ // the right trigger set and the listeners are still installed →
+ // nothing to do. This is what lets registerViewport call us on
+ // every (5-min) cycle to self-heal the reload storage-race without
+ // restacking listeners or spamming logs. (The listener-less cases
+ // — no camera, or tap-only with zero triggers — dedup on the
+ // signature alone.)
+ const wantAnyListener = want !== "" && triggers.length > 0;
+ if (wantSig === have &&
+ (!wantAnyListener || (this.listeners.get(nid)?.length ?? 0) > 0)) return;
+
+ // State changed (first attach, camera swapped, triggers changed,
+ // or listeners lost): tear down whatever was there before
+ // re-deciding.
this.detachListener(nid);
if (!want) {
// Record the empty state so subsequent register cycles don't
// re-warn every 5 minutes. Cleared by detachListener on rebind.
- this.attachedCameraId.set(nid, "");
+ this.attachedListenerSig.set(nid, "");
this.console.warn(`viewport "${tag}": no camera assigned — open Settings and pick a camera; subscription skipped`);
return;
}
const cam = systemManager.getDeviceById(v.cameraId);
if (!cam) {
- // Leave attachedCameraId unset so the next register cycle retries
+ // Leave the signature unset so the next register cycle retries
// (the camera device may simply not be loaded yet).
this.console.warn(`viewport "${tag}": camera ${v.cameraId} not found`);
return;
@@ -1143,11 +1152,21 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
// itself (unifi-protect/src/main.ts pushes BinarySensor onto the
// camera's interfaces when isDoorbell). So listening on `cam`
// for BinarySensor is all that's needed — no child traversal.
- const ifaces = [
- ScryptedInterface.BinarySensor, // doorbell ring
- ScryptedInterface.MotionSensor, // motion
- ScryptedInterface.ObjectDetector, // person / etc
- ];
+ //
+ // Subscribe ONLY to the interfaces the selected wake triggers
+ // need — a doorbell-only viewport shouldn't take a callback per
+ // motion re-assert. handleCameraEvent still filters by trigger
+ // (defense in depth: ObjectDetector fires for non-person classes
+ // too). Zero triggers = tap-only mode: no listeners at all.
+ const ifaces: any[] = [];
+ if (triggers.includes("doorbell")) ifaces.push(ScryptedInterface.BinarySensor);
+ if (triggers.includes("motion")) ifaces.push(ScryptedInterface.MotionSensor);
+ if (triggers.includes("person")) ifaces.push(ScryptedInterface.ObjectDetector);
+ if (!ifaces.length) {
+ this.attachedListenerSig.set(nid, wantSig);
+ this.console.log(`viewport "${tag}": tap-only (no wake triggers selected) — camera subscription skipped`);
+ return;
+ }
const regs: EventListenerRegister[] = [];
for (const iface of ifaces) {
@@ -1159,7 +1178,7 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
// One cross-reload cleaner for the whole attach: removes every
// listener AND invalidates this instance's bookkeeping. Without
// the map invalidation, a drain performed by another instance
- // (Stop click landing on an old load) leaves attachedCameraId +
+ // (Stop click landing on an old load) leaves the signature +
// listeners claiming live registrations, and the idempotent
// fast-path above then blocks the register-cycle self-heal from
// ever re-attaching.
@@ -1167,11 +1186,11 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
for (const reg of regs) { try { reg.removeListener(); } catch {} }
if (this.listeners.get(nid) === regs) {
this.listeners.delete(nid);
- this.attachedCameraId.delete(nid);
+ this.attachedListenerSig.delete(nid);
}
});
this.listeners.set(nid, regs);
- this.attachedCameraId.set(nid, want);
+ this.attachedListenerSig.set(nid, wantSig);
this.console.log(`viewport "${tag}": subscribed to [${cam.name || cam.id} (${ifaces.join("+")})]`);
}
@@ -1182,8 +1201,8 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
this.listeners.delete(nativeId);
}
// Clear the idempotency tracker so the next attachListener re-decides
- // from scratch (re-attach after a rebind / camera swap).
- this.attachedCameraId.delete(nativeId);
+ // from scratch (re-attach after a rebind / camera or trigger change).
+ this.attachedListenerSig.delete(nativeId);
}
private async registerViewport(v: Viewport) {