Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions plugins/onvif/src/onvif-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
PeopleDetect: 'people',
VehicleDetect: 'vehicle',
DogCatDetect: 'dog_cat',
FaceDetect: 'face',
Package: 'package',
};

this.cam.on('event', (event: any, xml: string) => {
ret.emit('data', xml);

Expand All @@ -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. <tt:Source><tt:SimpleItem Name="Source" Value="003"/></tt:Source>.
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)
Expand Down Expand Up @@ -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')) {
Expand All @@ -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;
}
Expand Down
52 changes: 46 additions & 6 deletions plugins/reolink/src/nvr/camera.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(() => { });
Comment thread
Copilot marked this conversation as resolved.
}
}

async processEvents(events: EventsResponse) {
const logger = this.getLogger();

Expand All @@ -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;
Expand Down Expand Up @@ -676,15 +716,15 @@ 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;
streams.splice(0, streams.length);

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) {
Expand All @@ -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",
Expand Down
125 changes: 125 additions & 0 deletions plugins/reolink/src/nvr/nvr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -85,6 +94,12 @@ export class ReolinkNvrDevice extends ScryptedDeviceBase implements Settings, De
lastDevicesStatusCheck = undefined;
cameraNativeMap = new Map<string, ReolinkNvrCamera>();
processing = false;
onvifClient: OnvifCameraAPI;
onvifEmitter: ReturnType<OnvifCameraAPI['listenEvents']>;
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);
Expand All @@ -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();
}

Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down