UNPKG

ts-onvif

Version:

Client to ONVIF devices

523 lines 21.4 kB
"use strict"; /** * Events ver10 module * @author Andrew D.Laptev <a.d.laptev@gmail.com> * @see https://www.onvif.org/specs/core/ONVIF-Core-Specification.pdf * @see https://www.onvif.org/ver10/events/wsdl/event.wsdl */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Subscription = void 0; const utils_1 = require("./utils"); const events_1 = require("events"); const https_1 = __importDefault(require("https")); const http_1 = __importDefault(require("http")); const RETRY_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENETUNREACH', 'HPE_CLOSED_CONNECTION']; const PULL_TIMEOUT = 'PT1M'; const MAX_EVENT_RECONNECT_MS = (0, utils_1.toMs)(PULL_TIMEOUT) * 2; const MIN_EVENT_RECONNECT_MS = 1000; const MESSAGE_LIMIT = 10; const PULL_TERMINATION_TIME = (0, utils_1.toIsoDuration)((0, utils_1.toMs)(PULL_TIMEOUT) * 4); const PUSH_TERMINATION_TIME = 2 * 60 * 60 * 1000; function isSoapError(error) { return typeof error === 'object' && error !== null && 'code' in error && RETRY_ERROR_CODES.includes(error.code); } function localTerminationTime(response) { return new Date(Date.now() - response.currentTime.getTime() + response.terminationTime.getTime()); } /** * Events service */ class Events { onvif; agent; globalSubscription; constructor(onvif) { this.onvif = onvif; // For events (PullMessages, Renew) we need to disable keep-alive and keep connection close this.agent = this.createAgent(); this.globalSubscription = new Subscription(onvif); this.globalSubscription.on('data', (msg) => onvif.emit('event', msg)); } createAgent() { const httpLibrary = this.onvif.useSecure ? https_1.default : http_1.default; return new httpLibrary.Agent({ keepAlive: false }); } /** * Drop in-flight PullMessages sockets (long-poll up to PULL_TIMEOUT) so unsubscribe * does not leave Happytime / cameras stuck with an open request. */ resetAgent() { try { this.agent.destroy(); } catch { // ignore } this.agent = this.createAgent(); } /** * The WS-BaseNotification specification defines a set of OPTIONAL WS-ResouceProperties. This specification does not * require the implementation of the WS-ResourceProperty interface. Instead, the subsequent direct interface shall be * implemented by an ONVIF compliant device in order to provide information about the FilterDialects, Schema files * and topics supported by the device. */ async getEventProperties() { const body = { GetEventProperties: { $: { xmlns: 'http://www.onvif.org/ver10/events/wsdl', }, }, }; const [data] = await this.onvif.request({ service: 'events', body }); return data.getEventPropertiesResponse; } async getServiceCapabilities() { const body = { GetServiceCapabilities: { $: { xmlns: 'http://www.onvif.org/ver10/events/wsdl', }, }, }; const [data] = await this.onvif.request({ service: 'events', body }); return data.getServiceCapabilitiesResponse.capabilities; } static filterToBuild(filter) { if (!filter) { return undefined; } const built = {}; if (filter.topicExpression?.length) { built['wsnt:TopicExpression'] = filter.topicExpression.map(({ dialect, expression }) => ({ $: { Dialect: dialect }, _: expression, })); } if (filter.messageContent) { built['wsnt:MessageContent'] = { $: { Dialect: 'http://www.onvif.org/ver10/tev/messageContentFilter/ItemFilter', }, _: filter.messageContent, }; } return Object.keys(built).length ? built : undefined; } /** * This method returns a PullPointSubscription that can be polled using PullMessages. This message contains the same * elements as the SubscriptionRequest of the WS-BaseNotification without the ConsumerReference. * If no Filter is specified the pullpoint notifies all occurring events to the client. * This method is mandatory. */ async createPullPointSubscription(options) { const filter = Events.filterToBuild(options?.filter); const body = { CreatePullPointSubscription: { $: { xmlns: 'http://www.onvif.org/ver10/events/wsdl', 'xmlns:tns1': 'http://www.onvif.org/ver10/topics', 'xmlns:wsnt': 'http://docs.oasis-open.org/wsn/b-2', }, InitialTerminationTime: (0, utils_1.toIsoDuration)(options?.initialTerminationTime ?? 'PT2M'), ...(filter && { Filter: filter }), SubscriptionPolicy: options?.subscriptionPolicy ?? {}, }, }; const [data] = await this.onvif.request({ service: 'events', body }); const pullPointSubscription = data.createPullPointSubscriptionResponse; return pullPointSubscription; } static eventBrokerToBuild(eventBroker) { return { Address: eventBroker.address, TopicPrefix: eventBroker.topicPrefix, ...(eventBroker.userName !== undefined && { UserName: eventBroker.userName }), ...(eventBroker.password !== undefined && { Password: eventBroker.password }), ...(eventBroker.certificateID !== undefined && { CertificateID: eventBroker.certificateID }), ...(eventBroker.publishFilter !== undefined && { PublishFilter: eventBroker.publishFilter }), ...(eventBroker.qoS !== undefined && { QoS: eventBroker.qoS }), ...(eventBroker.certPathValidationPolicyID !== undefined && { CertPathValidationPolicyID: eventBroker.certPathValidationPolicyID, }), ...(eventBroker.metadataFilter !== undefined && { MetadataFilter: eventBroker.metadataFilter }), }; } async addEventBroker({ eventBroker }) { const body = { AddEventBroker: { $: { xmlns: 'http://www.onvif.org/ver10/events/wsdl', }, EventBroker: Events.eventBrokerToBuild(eventBroker), }, }; await this.onvif.request({ service: 'events', body }); } async deleteEventBroker({ address }) { const body = { DeleteEventBroker: { $: { xmlns: 'http://www.onvif.org/ver10/events/wsdl', }, Address: address, }, }; await this.onvif.request({ service: 'events', body }); } async getEventBrokers({ address } = {}) { const body = { GetEventBrokers: { $: { xmlns: 'http://www.onvif.org/ver10/events/wsdl', }, ...(address !== undefined && { Address: address }), }, }; const [data] = await this.onvif.request({ service: 'events', body, array: ['eventBroker'] }); return data.getEventBrokersResponse.eventBroker ?? []; } /** * Subscribe to events using WS-BaseNotification producer * @param options */ async subscribe(options) { const initialTerminationTime = options.terminationTime ?? PUSH_TERMINATION_TIME; const filter = Events.filterToBuild(options?.filter); const body = { Subscribe: { $: { xmlns: 'http://docs.oasis-open.org/wsn/b-2', 'xmlns:a': 'http://www.w3.org/2005/08/addressing', }, ConsumerReference: { 'a:Address': options.url, }, InitialTerminationTime: (0, utils_1.toIsoDuration)(initialTerminationTime), ...(filter && { Filter: filter }), }, }; const [data] = await this.onvif.request({ service: 'events', body, soapHeaders: { 'a:Action': { $: { 's:mustUnderstand': '1' }, _: 'http://docs.oasis-open.org/wsn/bw-2/NotificationProducer/SubscribeRequest', }, 'a:To': { $: { 's:mustUnderstand': '1' }, _: String(this.onvif.uri.events), }, }, }); if (options.renew) { setTimeout(() => { this.subscribe(options); }, (0, utils_1.toMs)(initialTerminationTime)); } return data.subscribeResponse; } /** * Unsubscribe from WS-BaseNotification push events */ async unsubscribe(subscription) { const subscriptionParams = getSubscriptionUrlAndHeaders(subscription, 'http://docs.oasis-open.org/wsn/bw-2/SubscriptionManager/UnsubscribeRequest'); const body = { Unsubscribe: { $: { xmlns: 'http://docs.oasis-open.org/wsn/b-2' } }, }; await this.onvif.request({ url: subscriptionParams.url, body, soapHeaders: subscriptionParams.additionalSoapHeaders, }); } /** * Renew for WS-BaseNotification push event * @param subscription * @param terminationTime time in milliseconds */ async renew(subscription, terminationTime) { const subscriptionParams = getSubscriptionUrlAndHeaders(subscription, 'http://docs.oasis-open.org/wsn/bw-2/SubscriptionManager/RenewRequest'); const body = { Renew: { $: { xmlns: 'http://docs.oasis-open.org/wsn/b-2' }, TerminationTime: (0, utils_1.toIsoDuration)(terminationTime) }, }; const [data] = await this.onvif.request({ url: subscriptionParams.url, body, soapHeaders: subscriptionParams.additionalSoapHeaders, }); return data.renewResponse; } /** * Get status for WS-BaseNotification push events */ async getStatus(subscription) { const subscriptionParams = getSubscriptionUrlAndHeaders(subscription, 'http://docs.oasis-open.org/wsn/bw-2/GetStatusRequest'); const body = { GetStatus: { $: { xmlns: 'http://docs.oasis-open.org/wsn/b-2' } }, }; const [data] = await this.onvif.request({ url: subscriptionParams.url, body, soapHeaders: subscriptionParams.additionalSoapHeaders, }); return data.getStatusResponse; } } exports.default = Events; /** * Subscription to events class * @event data - emitted when event is received * @event error - emitted when error occurs * @example * ```ts * const sub = new Subscription(cam, 'pullPoint', { * filter: { * topicExpression: [ * { * expression: 'tns1:RuleEngine/PeopleDetector/People', * dialect: 'http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet', * }, * ], * }, * }); * sub.on('data', console.log); * await sub.subscribe(); * ``` */ class Subscription extends events_1.EventEmitter { onvif; subscription; messageLimit; eventReconnectMs; options; constructor(onvif, options = {}) { super({ captureRejections: true }); this.messageLimit = MESSAGE_LIMIT; this.eventReconnectMs = MIN_EVENT_RECONNECT_MS; this.onvif = onvif; this.options = options; } /** When false, in-flight pull loops must not renew or auto-resubscribe. */ pulling = false; async subscribe() { this.subscription = await this.onvif.events.createPullPointSubscription({ initialTerminationTime: PULL_TERMINATION_TIME, ...this.options, }); this.pulling = true; this.eventPull(); } /** * Loop events from the subscription */ async eventPull() { if (!this.pulling || !this.subscription) { return; } try { const msgs = await this.pullMessages({ messageLimit: this.messageLimit, timeout: PULL_TIMEOUT, }); if (!this.pulling || !this.subscription) { return; } this.eventReconnectMs = MIN_EVENT_RECONNECT_MS; msgs.notificationMessage.forEach((msg) => { this.emit('data', msg); }); if (+msgs.terminationTime <= Date.now()) { // Axis cameras require us to Rewew the Pull Point Subscription const renewData = await this.renew(); if (!this.pulling || !this.subscription) { return; } this.subscription.terminationTime = localTerminationTime(renewData); } else { this.subscription.terminationTime = msgs.terminationTime; } this.eventPull(); // go around the loop again, once the RENEW has completed (and terminationTime updated) } catch (error) { if (!this.pulling || !this.subscription) { return; } if (isSoapError(error) && +this.subscription.terminationTime > Date.now()) { // connection reset (request ended without messages, closed connection) when we're still on the surrent // subscription - restart Event loop for pullMessages request try { // force renew and resync const renewData = await this.renew(); if (this.pulling && this.subscription) { this.subscription.terminationTime = localTerminationTime(renewData); await this.setSynchronizationPoint(); } } catch (renewError) { // do nothing, we're just trying not to lose subscription this.emit('connectionError', renewError); } if (!this.pulling) { return; } this.emit('connectionError', error); this.restartEventRequest(); } else { // there was an error pulling the message (device shut down, corrupted) this.emit('error', error); const shouldResubscribe = this.pulling; await this.unsubscribe(); if (shouldResubscribe) { await this.subscribe(); } } } } /** * Pull messages from the subscription * @param options */ async pullMessages(options) { const subscriptionParams = this.getSubscriptionUrlAndHeaders('http://www.onvif.org/ver10/events/wsdl/PullPointSubscription/PullMessagesRequest'); // ONVIF Spec says cameras must support 1 Minute wait times. Ensure network socket has a replyTimeout that is larger than 1 minute const timeout = options?.timeout ?? PULL_TIMEOUT; const body = { 'tev:PullMessages': { $: { 'xmlns:wsnt': 'http://docs.oasis-open.org/wsn/b-2', 'xmlns:tev': 'http://www.onvif.org/ver10/events/wsdl', }, 'tev:Timeout': timeout, 'tev:MessageLimit': options?.messageLimit ?? 10, }, }; const [data] = await this.onvif.request({ url: subscriptionParams.url, body, // timeout + 2 sec // ensures the socket does not get closed too early while the camera has up to 1 minute to reply timeout: (0, utils_1.toMs)(timeout) + 2 * 1000, soapHeaders: subscriptionParams.additionalSoapHeaders, agent: this.onvif.events.agent, array: ['notificationMessage'], }); return { notificationMessage: [], ...data.pullMessagesResponse }; } /** * Properties inform a client about property creation, changes and deletion in a uniform way. When a client wants to * synchronize its properties with the properties of the device, it can request a synchronization point which repeats * the current status of all properties to which a client has subscribed. The PropertyOperation of all produced * notifications is set to “Initialized”. The Synchronization Point is requested directly from the SubscriptionManager * which was returned in either the SubscriptionResponse or in the CreatePullPointSubscriptionResponse. The property * update is transmitted via the notification transportation of the notification interface. This method is mandatory. */ async setSynchronizationPoint() { const subscriptionParams = this.getSubscriptionUrlAndHeaders('http://www.onvif.org/ver10/events/wsdl/PullPointSubscription/SetSynchronizationPointRequest'); const body = { SetSynchronizationPoint: { $: { xmlns: 'http://www.onvif.org/ver10/events/wsdl' } } }; await this.onvif.request({ url: subscriptionParams.url, body, soapHeaders: subscriptionParams.additionalSoapHeaders, }); } /** * Renew the subscription */ async renew() { const subscriptionParams = this.getSubscriptionUrlAndHeaders('http://docs.oasis-open.org/wsn/bw-2/SubscriptionManager/RenewRequest'); // x2 larger than the pull timeout const terminationTime = PULL_TERMINATION_TIME; const body = { Renew: { $: { xmlns: 'http://docs.oasis-open.org/wsn/b-2' }, TerminationTime: terminationTime }, }; const [data] = await this.onvif.request({ url: subscriptionParams.url, body, soapHeaders: subscriptionParams.additionalSoapHeaders, }); return data.renewResponse; } /** * The device shall provide the following Unsubscribe command for all SubscriptionManager endpoints returned * by the CreatePullPointSubscription command. * This command shall terminate the lifetime of a pull point. */ async unsubscribe() { this.pulling = false; if (!this.subscription) { return; } const subscriptionParams = this.getSubscriptionUrlAndHeaders('http://docs.oasis-open.org/wsn/bw-2/SubscriptionManager/UnsubscribeRequest'); const body = { Unsubscribe: { $: { xmlns: 'http://docs.oasis-open.org/wsn/b-2' } }, }; // Drop local state first so an in-flight eventPull cannot auto-resubscribe after a race. delete this.subscription; // Abort long-poll PullMessages before/while sending Unsubscribe. this.onvif.events.resetAgent(); try { await this.onvif.request({ url: subscriptionParams.url, body, soapHeaders: subscriptionParams.additionalSoapHeaders, }); } catch { // Subscription may already be gone on the device; local state is cleared. } } /** * Restart the event request with an increasing interval when the connection to the device is refused * @private */ restartEventRequest() { if (!this.pulling) { return; } setTimeout(() => { if (this.pulling) { this.eventPull(); } }, this.eventReconnectMs); if (this.eventReconnectMs < MAX_EVENT_RECONNECT_MS) { this.eventReconnectMs = 1.111 * this.eventReconnectMs; } else { this.eventReconnectMs = MAX_EVENT_RECONNECT_MS; } } /** * Get params for concrete subscription * @private */ getSubscriptionUrlAndHeaders(soapAction = 'http://www.onvif.org/ver10/events/wsdl/PullPointSubscription/PullMessagesRequest') { return getSubscriptionUrlAndHeaders(this.subscription, soapAction); } } exports.Subscription = Subscription; function getSubscriptionUrlAndHeaders(subscription, soapAction = 'http://www.onvif.org/ver10/events/wsdl/PullPointSubscription/PullMessagesRequest') { if (!subscription?.subscriptionReference?.address) { throw new Error(`You should have working subscription. ${JSON.stringify(subscription)}`); } const url = new URL(subscription.subscriptionReference.address); const axisSubscriptionId = subscription?.subscriptionReference.referenceParameters?.subscriptionId; const additionalSoapHeaders = { 'a:Action': { $: { 's:mustUnderstand': '1' }, _: soapAction }, 'a:To': { $: { 's:mustUnderstand': '1' }, _: url.href }, ...(axisSubscriptionId && { SubscriptionId: { $: { 'a:IsReferenceParameter': 'true', xmlns: 'http://www.axis.com/2009/event' }, _: axisSubscriptionId, }, }), }; return { url, axisSubscriptionId, additionalSoapHeaders }; } //# sourceMappingURL=events.js.map