From 2ca1307b34b82a4b5e3e83b50c02e3a93b2c7227 Mon Sep 17 00:00:00 2001 From: Luke Hoersten Date: Sun, 21 Jun 2026 10:55:42 -0500 Subject: scrypted: subscribe to camera child devices (fix Unifi doorbell wake) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unifi doorbell cameras expose the bell button as a child device of the camera (separate nativeId, its own BinarySensor interface), not as a property of the camera itself. The previous code did cam.listen() on the parent camera only, so motion + person events arrived (those fire on the camera itself) but bell-press events never reached handleCameraEvent. HomeKit kept working because Scrypted's HomeKit bridge auto-syncs all child devices; our plugin was silently the only consumer missing the event. Confirmed by the trace log in 3b0ab73 staying silent across a bell press while motion events still printed. Fix: in attachListener, walk systemManager.getDeviceIds() and pick any device with providerId === cam.id as an additional listen target. Subscribe on all of them with the same iface list — listen() no-ops on unsupported ifaces, so we don't have to introspect each child. Tracking changed from Map to Map so detach/script-reload cleanup removes every listener, not just the camera's. --- scrypted/scrypted-viewport.ts | 56 ++++++++++++++++++++++++++++++------------- 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/scrypted/scrypted-viewport.ts b/scrypted/scrypted-viewport.ts index 25f784f..250cf9c 100644 --- a/scrypted/scrypted-viewport.ts +++ b/scrypted/scrypted-viewport.ts @@ -313,7 +313,7 @@ class ScryptedViewportProvider extends ScryptedDeviceBase implements DeviceProvider, DeviceCreator, HttpRequestHandler, Settings { private viewports = new Map(); // nativeId -> child instance - private listeners = new Map(); // nativeId -> camera event listener + private listeners = new Map(); // nativeId -> all event listeners for this viewport (camera + child devices) streams = new Map stream control (accessed by Viewport.putSetting for manual wake/sleep) timeout: NodeJS.Timeout; abort: AbortController; // also tears down the ffmpeg child via its listener @@ -567,27 +567,49 @@ class ScryptedViewportProvider extends ScryptedDeviceBase 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); - // Track on globalThis so script reload can remove this - // listener from the camera plugin. Without that, every - // re-paste leaves a dead callback subscribed to the camera - // and every motion/doorbell event triggers handleCameraEvent - // N times for N stacked reloads — observable as duplicate - // "stream start" log lines + two simultaneous pushSnapshots - // racing for the firmware decoder lock. + // Unifi doorbell cameras expose the bell-press as a *child + // device* of the camera (the bell button has its own nativeId + // and BinarySensor interface), not as a property of the camera + // itself. cam.listen() on the parent only sees motion + object + // events; without subscribing on the children we miss the bell + // press entirely. Symptom: HomeKit gets the doorbell event + // (Scrypted auto-syncs child devices to the bridge) but our + // handleCameraEvent never fires. Walk providerId to find + // children of this camera and listen on each too. Safe to send + // the same iface list to every target — listen() no-ops on + // ifaces the device doesn't expose. + const targets: any[] = [cam]; + const ids = (systemManager as any).getDeviceIds?.() ?? []; + for (const id of ids) { + if (id === cam.id) continue; + const d: any = systemManager.getDeviceById(id); + if (d?.providerId === cam.id) targets.push(d); + } + const G = globalThis as any; if (!G.__viewportListenerCleaners) G.__viewportListenerCleaners = []; - G.__viewportListenerCleaners.push(() => { try { reg.removeListener(); } catch {} }); - this.console.log(`viewport "${tag}": subscribed to "${cam.name}"`); + const regs: EventListenerRegister[] = []; + const targetNames: string[] = []; + for (const t of targets) { + const reg = t.listen(ifaces, (source: any, details: any, data: any) => { + this.handleCameraEvent(v, details, data); + }); + regs.push(reg); + targetNames.push(t.name || t.id); + // Track on globalThis so script reload can remove every + // listener from the camera/child plugins. Without that, + // every re-paste leaves dead callbacks subscribed and each + // event triggers handleCameraEvent N times for N reloads. + G.__viewportListenerCleaners.push(() => { try { reg.removeListener(); } catch {} }); + } + this.listeners.set(v.nativeId!, regs); + this.console.log(`viewport "${tag}": subscribed to [${targetNames.join(", ")}]`); } private detachListener(nativeId: string) { - const reg = this.listeners.get(nativeId); - if (reg) { - try { reg.removeListener(); } catch {} + const regs = this.listeners.get(nativeId); + if (regs) { + for (const r of regs) { try { r.removeListener(); } catch {} } this.listeners.delete(nativeId); } } -- cgit v1.2.3