diff options
| author | Luke Hoersten <[email protected]> | 2026-06-21 10:55:42 -0500 |
|---|---|---|
| committer | Luke Hoersten <[email protected]> | 2026-06-21 10:55:42 -0500 |
| commit | 2ca1307b34b82a4b5e3e83b50c02e3a93b2c7227 (patch) | |
| tree | e6e3d9cbad5f98801387a4dd68cab9846f502f21 | |
| parent | 0bf731cfc966a7cfad0763f22f6196ebac3d16a0 (diff) | |
scrypted: subscribe to camera child devices (fix Unifi doorbell wake)
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<nativeId, EventListenerRegister> to
Map<nativeId, EventListenerRegister[]> so detach/script-reload cleanup
removes every listener, not just the camera's.
| -rw-r--r-- | scrypted/scrypted-viewport.ts | 56 |
1 files 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<string, Viewport>(); // nativeId -> child instance - private listeners = new Map<string, EventListenerRegister>(); // nativeId -> camera event listener + private listeners = new Map<string, EventListenerRegister[]>(); // nativeId -> all event listeners for this viewport (camera + child devices) streams = new Map<string, { // viewport name -> 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); } } |
