From e242322d1362e68492d0c0e2d080eb92050c1e97 Mon Sep 17 00:00:00 2001 From: Cory Kringlen Date: Sat, 20 Jun 2026 23:45:54 -0700 Subject: [PATCH] reolink(nvr): ONVIF push-based motion for Home Hub battery cameras Battery/solar cameras behind a Reolink Home Hub (Pro) sleep to save power. While asleep the hub answers the GetMdState/GetAiState polling the NVR client relies on with "device offline", so motion never reaches Scrypted/HomeKit and HomeKit Secure Video never records. The hub does, however, push these events over ONVIF PullPoint. Add an opt-in ONVIF push path for hub channels: - onvif-api.ts: extract the source channel token from each event ( SimpleItem VideoSourceConfigurationToken/Source/VideoSourceToken), interpret the topic into a motion flag plus an optional object class, and emit a separate channel-aware 'onvifChannelEvent' (motion, class, channel). The existing 'onvifEvent'/'event'/'data' emissions are left unchanged, so this is purely additive and existing consumers are unaffected. - nvr.ts: open ONE hub-level PullPoint subscription, route each event by channel to the matching camera, mapping Reolink rule topics (People/Vehicle/DogCat/Face/Package + CellMotion/MotionAlarm) to motion and an optional ObjectDetector class. Raw event XML is logged only when 'Debug Events' is enabled. - camera.ts: add a 'Use ONVIF for Object Detection' control that registers the channel and a debounced triggerOnvifMotion() that sets motionDetected and fires the ObjectDetector event. Validated on real hardware (Home Hub Pro, firmware-current) with sleeping battery cameras: person/vehicle/motion events now arrive over the push subscription and trigger HomeKit recordings where polling returned offline. Co-Authored-By: Claude Opus 4.8 --- plugins/onvif/src/onvif-api.ts | 60 ++++++++++++++ plugins/reolink/src/nvr/camera.ts | 52 +++++++++++-- plugins/reolink/src/nvr/nvr.ts | 125 ++++++++++++++++++++++++++++++ 3 files changed, 231 insertions(+), 6 deletions(-) diff --git a/plugins/onvif/src/onvif-api.ts b/plugins/onvif/src/onvif-api.ts index 5f70c1db10..e35dedd5fb 100644 --- a/plugins/onvif/src/onvif-api.ts +++ b/plugins/onvif/src/onvif-api.ts @@ -94,6 +94,17 @@ export class OnvifCameraAPI { listenEvents() { const ret = new EventEmitter(); + // Reolink Home Hub / NVR AI rule topics -> Reolink object class keys. These + // match the keys used by the polled path (AIState / getObjectTypes), so a + // channel can gate ONVIF-pushed detections the same way it gates polled ones. + const reolinkRuleClasses: Record = { + PeopleDetect: 'people', + VehicleDetect: 'vehicle', + DogCatDetect: 'dog_cat', + FaceDetect: 'face', + Package: 'package', + }; + this.cam.on('event', (event: any, xml: string) => { ret.emit('data', xml); @@ -103,9 +114,43 @@ export class OnvifCameraAPI { const dataValue = event.message.message.data.simpleItem.$.Value; const eventTopic = stripNamespaces(event.topic._); + // Legacy raw signal: (topic, value). Existing consumers (e.g. the standalone + // Reolink camera in the reolink plugin) parse the topic themselves. Preserved + // verbatim for backward compatibility; the interpreted/channel-aware signal is + // emitted separately as 'onvifChannelEvent' below. ret.emit('onvifEvent', eventTopic, dataValue); + // Extract the source channel token. Reolink hubs/NVRs multiplex every channel's + // events over a single subscription; the Source SimpleItem carries the (zero-padded) + // channel, e.g. . + let sourceChannel: number; + try { + const sourceItems = event.message.message.source?.simpleItem; + const items = Array.isArray(sourceItems) ? sourceItems : (sourceItems ? [sourceItems] : []); + for (const item of items) { + const name = item?.$?.Name; + if (name === 'VideoSourceConfigurationToken' || name === 'Source' || name === 'VideoSourceToken') { + const parsed = parseInt(item?.$?.Value, 10); + if (!Number.isNaN(parsed)) + sourceChannel = parsed; + } + } + } + catch (e) { + // Malformed/unexpected source shape; the event simply won't be channel-routed. + // Optional chaining handles the common "no source element" case without + // throwing, so reaching here means genuinely unexpected data worth surfacing. + this.console.warn('error parsing onvif event source channel', e); + } + + // Interpret the topic into a motion/object-class signal once, here, so + // multiplexed hub/NVR consumers can route by channel without re-parsing + // ONVIF topics. Emitted (with the channel) as 'onvifChannelEvent' below. + let pushMotion = false; + let pushClass: string | undefined; + if (eventTopic.includes('MotionAlarm')) { + pushMotion = !!dataValue; // ret.emit('event', OnvifEvent.MotionBuggy); if (dataValue) ret.emit('event', OnvifEvent.MotionStart) @@ -149,6 +194,16 @@ export class OnvifCameraAPI { // unclear if the IsMotion false is indicative of motion stop? if (event.message.message.data.simpleItem.$.Name === 'IsMotion' && dataValue) { ret.emit('event', OnvifEvent.MotionBuggy); + pushMotion = true; + } + } + // Reolink Home Hub / NVR AI rules. The object class is the final topic + // segment; these topics are not part of the standalone OnvifEvent mapping, + // so they are surfaced on the channel-aware push signal only. + else if (eventTopic.includes('RuleEngine/MyRuleDetector/')) { + if (dataValue) { + pushClass = reolinkRuleClasses[eventTopic.split('/').pop()]; + pushMotion = true; } } else if (eventTopic.includes('RuleEngine/ObjectDetector')) { @@ -158,12 +213,17 @@ export class OnvifCameraAPI { const className = this.detections.get(eventName); this.console.log('object detected:', className); ret.emit('event', OnvifEvent.Detection, className); + pushClass = className; + pushMotion = true; } catch (e) { this.console.warn('error parsing detection', e); } } } + + if (sourceChannel !== undefined && (pushMotion || pushClass)) + ret.emit('onvifChannelEvent', pushMotion, pushClass, sourceChannel); }); return ret; } diff --git a/plugins/reolink/src/nvr/camera.ts b/plugins/reolink/src/nvr/camera.ts index 045235a5ce..6450fd51f2 100644 --- a/plugins/reolink/src/nvr/camera.ts +++ b/plugins/reolink/src/nvr/camera.ts @@ -161,6 +161,14 @@ export class ReolinkNvrCamera extends RtspSmartCamera implements Camera, DeviceP title: 'Use ONVIF for Two-Way Audio', type: 'boolean', }, + useOnvifDetection: { + subgroup: 'Advanced', + title: 'Use ONVIF for Object Detection', + description: "Subscribe to the hub's ONVIF event stream (push) for motion/object detection instead of polling. Recommended for battery cameras that sleep, which polling cannot reliably catch.", + type: 'boolean', + immediate: true, + onPut: async () => await this.nvrDevice.ensureOnvifEvents(), + }, prebufferSet: { type: 'boolean', hide: true @@ -505,11 +513,40 @@ export class ReolinkNvrCamera extends RtspSmartCamera implements Camera, DeviceP } createOnvifClient() { - const { username, password, httpPort, ipAddress } = this.nvrDevice.storageSettings.values; - const address = `${ipAddress}:${httpPort}`; + const { username, password, onvifPort, ipAddress } = this.nvrDevice.storageSettings.values; + // Reolink ONVIF runs on a dedicated port (onvifPort, default 8000), not the api.cgi port. + const address = `${ipAddress}:${onvifPort}`; return connectCameraAPI(address, username, password, this.getLogger(), undefined); } + // Called by the NVR device's hub-level ONVIF event router when an event for this + // camera's channel arrives. Sets debounced motion using the same motionTimeout timer + // and score:1 convention as processEvents() (which is skipped while ONVIF is the + // motion source for this camera, so the two never race on motionDetected). + triggerOnvifMotion(className?: string) { + const logger = this.getLogger(); + if (this.storageSettings.values.debugEvents) + logger.log(`ONVIF motion (ch ${this.getRtspChannel()})${className ? ' class=' + className : ''}`); + this.motionDetected = true; + this.motionTimeout && clearTimeout(this.motionTimeout); + this.motionTimeout = setTimeout(() => this.motionDetected = false, (this.storageSettings.values.motionTimeout || 20) * 1000); + if (className) { + // Only surface object detections if this camera actually advertises this + // class (same gate as getDeviceInterfaces / the polled path); avoids emitting + // on an interface the device doesn't provide, or a class it doesn't support. + // getObjectTypes() reads cached AI state. + this.getObjectTypes().then(({ classes }) => { + if (!classes.includes(className)) + return; + const od: ObjectsDetected = { + timestamp: Date.now(), + detections: [{ className, score: 1 }], + }; + sdk.deviceManager.onDeviceEvent(this.nativeId, ScryptedInterface.ObjectDetector, od); + }).catch(() => { }); + } + } + async processEvents(events: EventsResponse) { const logger = this.getLogger(); @@ -518,7 +555,10 @@ export class ReolinkNvrCamera extends RtspSmartCamera implements Camera, DeviceP logger.debug(`Events received: ${JSON.stringify(events)}`); } - if (events.motion !== this.motionDetected) { + // When ONVIF push is the motion source for this camera, ignore the polled motion + // state (unreliable for sleeping battery cams, and it would race triggerOnvifMotion's + // timer). triggerOnvifMotion drives motionDetected in that case. + if (!this.storageSettings.values.useOnvifDetection && events.motion !== this.motionDetected) { if (events.motion) { this.motionDetected = true; @@ -676,7 +716,7 @@ export class ReolinkNvrCamera extends RtspSmartCamera implements Camera, DeviceP // 2: support main/sub stream const abilities = this.getAbilities(); - const { channelInfo } = this.getDeviceData(); + const { channelInfo } = this.getDeviceData() ?? {}; const live = abilities?.live?.ver; const [rtmpMain, rtmpExt, rtmpSub, rtspMain, rtspSub] = streams; @@ -684,7 +724,7 @@ export class ReolinkNvrCamera extends RtspSmartCamera implements Camera, DeviceP const mainEncType = abilities?.mainEncType?.ver; - const isHomeHub = this.nvrDevice.info.model === 'Reolink Home Hub'; + const isHomeHub = this.nvrDevice.info.model?.startsWith('Reolink Home Hub'); if (isHomeHub) { streams.push(...[rtspMain, rtspSub]); } else if (live === 2) { @@ -702,7 +742,7 @@ export class ReolinkNvrCamera extends RtspSmartCamera implements Camera, DeviceP streams.push(rtmpMain, rtmpExt, rtmpSub, rtspMain, rtspSub); } - if (channelInfo.typeInfo && + if (channelInfo?.typeInfo && [ "Reolink TrackMix PoE", "Reolink TrackMix WiFi", diff --git a/plugins/reolink/src/nvr/nvr.ts b/plugins/reolink/src/nvr/nvr.ts index cb513ff201..aed2f470b7 100644 --- a/plugins/reolink/src/nvr/nvr.ts +++ b/plugins/reolink/src/nvr/nvr.ts @@ -4,6 +4,7 @@ import { StorageSettings } from "@scrypted/sdk/storage-settings"; import { DevInfo } from "../probe"; import { ReolinkNvrCamera } from "./camera"; import { DeviceInputData, ReolinkNvrClient } from "./api"; +import { connectCameraAPI, OnvifCameraAPI } from "../onvif-api"; export class ReolinkNvrDevice extends ScryptedDeviceBase implements Settings, DeviceDiscovery, DeviceProvider, Reboot { storageSettings = new StorageSettings(this, { @@ -53,6 +54,14 @@ export class ReolinkNvrDevice extends ScryptedDeviceBase implements Settings, De type: 'number', onPut: async () => await this.reinit() }, + onvifPort: { + subgroup: 'Advanced', + title: 'ONVIF Port', + placeholder: '8000', + defaultValue: 8000, + type: 'number', + onPut: async () => await this.reinit() + }, abilities: { json: true, hide: true, @@ -85,6 +94,12 @@ export class ReolinkNvrDevice extends ScryptedDeviceBase implements Settings, De lastDevicesStatusCheck = undefined; cameraNativeMap = new Map(); processing = false; + onvifClient: OnvifCameraAPI; + onvifEmitter: ReturnType; + onvifStarting = false; + // Bumped by stopOnvifEvents() so an in-flight startOnvifEvents() still awaiting the + // network can detect it was torn down and abandon itself instead of resurrecting. + onvifGeneration = 0; constructor(nativeId: string, plugin: ReolinkProvider) { super(nativeId); @@ -106,6 +121,9 @@ export class ReolinkNvrDevice extends ScryptedDeviceBase implements Settings, De async reinit() { this.client = undefined; + // Tear the ONVIF subscription down too so address/onvifPort changes take effect; + // the device interval restarts it on the next tick via ensureOnvifEvents(). + await this.stopOnvifEvents(); // await this.init(); } @@ -115,6 +133,10 @@ export class ReolinkNvrDevice extends ScryptedDeviceBase implements Settings, De const logger = this.getLogger(); setInterval(async () => { + // Start the hub-level ONVIF push subscription once cameras are loaded and + // at least one has "Use ONVIF for Object Detection" enabled. No-op once running. + this.ensureOnvifEvents().catch(() => { }); + if (this.processing || !client) { return; } @@ -257,6 +279,109 @@ export class ReolinkNvrDevice extends ScryptedDeviceBase implements Settings, De return this.client; } + async ensureOnvifEvents() { + const someCameraWantsOnvif = [...this.cameraNativeMap.values()] + .some(c => c?.storageSettings?.values?.useOnvifDetection); + if (someCameraWantsOnvif) + await this.startOnvifEvents(); + else + await this.stopOnvifEvents(); + } + + async stopOnvifEvents() { + // Nothing running and no start in flight -> nothing to tear down. (Avoids bumping + // onvifGeneration on every idle 1s tick when no camera wants ONVIF.) + if (!this.onvifClient && !this.onvifEmitter && !this.onvifStarting) + return; + // Invalidate any in-flight start (onvifStarting) so it abandons rather than + // resurrecting a subscription, even before it has assigned onvifClient. + this.onvifGeneration++; + try { await this.onvifClient?.unsubscribe(); } catch (e) { } + this.onvifEmitter = undefined; + this.onvifClient = undefined; + } + + async startOnvifEvents() { + if (this.onvifEmitter || this.onvifStarting) + return; + this.onvifStarting = true; + const generation = this.onvifGeneration; + const logger = this.getLogger(); + // Held locally (not in this.onvifClient) until the subscription is fully + // established, so an error after connect still has a handle to unsubscribe. + let client: OnvifCameraAPI; + try { + const { ipAddress, onvifPort, username, password } = this.storageSettings.values; + const address = `${ipAddress}:${onvifPort}`; + client = await connectCameraAPI(address, username, password, logger, undefined); + try { await client.supportsEvents(); } catch (e) { } + // createSubscription() (createPullPointSubscription) is what starts the onvif + // library's PullMessages loop; that loop both delivers events and keeps the + // subscription alive, so it is not explicitly renewed. listenEvents() below only + // attaches handlers to that loop's emissions. This mirrors the established + // onvif-events.ts pattern (createSubscription -> listenEvents). + // KNOWN LIMITATION (follow-up): there is no plugin-level liveness watchdog. If + // the library's pull loop wedges on a non-retryable error, onvifEmitter/onvifClient + // stay set and ensureOnvifEvents() considers it healthy, so it is never restarted. + // For sleeping battery cameras this is hard to distinguish from a legitimately + // quiet channel; revisit before depending on this as the sole motion source. + await client.createSubscription(); + const emitter = client.listenEvents(); + emitter.on('onvifChannelEvent', (motion: boolean, className: string | undefined, channel: number) => { + try { + this.routeOnvifEvent(motion, className, channel); + } catch (e) { + logger.error('error routing onvif event', e); + } + }); + emitter.on('data', (xml: string) => { + if (this.storageSettings.values.debugEvents) + logger.log(`ONVIF raw: ${xml}`); + }); + // A teardown (stopOnvifEvents) raced this start; abandon it rather than + // resurrecting a subscription that was meant to be torn down. + if (generation !== this.onvifGeneration) { + try { await client.unsubscribe(); } catch (e) { } + return; + } + this.onvifClient = client; + this.onvifEmitter = emitter; + logger.log('Hub-level ONVIF event subscription started'); + } catch (e) { + // The 1s device interval calls ensureOnvifEvents() again, so a failed start + // is retried on the next tick; tear down the connected-but-unstored client + // (the leak source) and clean up the partial state here. + logger.error('Failed to start hub ONVIF events, will retry', e); + try { await client?.unsubscribe(); } catch (e2) { } + this.onvifEmitter = undefined; + this.onvifClient = undefined; + } finally { + this.onvifStarting = false; + } + } + + // Routes an interpreted ONVIF push event (see OnvifCameraAPI.listenEvents) to the + // camera on the matching channel. The ONVIF Source token's numeric value equals the + // camera's stored rtspChannel (verified on a Home Hub Pro: token 001 -> rtspChannel 1 + // / Front Door, 005 -> rtspChannel 5 / Gate Area), so they are compared directly. This + // is distinct from the RTSP stream-path index, which is rtspChannel + 1. + routeOnvifEvent(motion: boolean, className: string | undefined, channel: number) { + // Defensive: the emit side (onvif-api) only fires onvifChannelEvent when the channel + // is set and motion||class is present, so these guards are normally unreachable; kept + // so routeOnvifEvent stays safe if called from another path in the future. + if (channel === undefined || channel === null) + return; + if (!motion && !className) + return; + + const target = [...this.cameraNativeMap.values()].find(c => + c + && Number(c.storageSettings.values.rtspChannel) === channel + && c.storageSettings.values.useOnvifDetection); + + target?.triggerOnvifMotion(className); + } + updateDeviceInfo(devInfo: DevInfo) { const info = this.info || {}; info.ip = this.storageSettings.values.ipAddress;