UNPKG

@bacnet-js/device

Version:

A TypeScript library for implementing BACnet IP devices in Node.js.

539 lines 26.6 kB
import { BDError, } from '../../errors.js'; import { BDObject, } from '../generic/object.js'; import { isDstInEffect, } from '../../utils.js'; import { BDAbstractProperty, BDArrayProperty, BDPolledArrayProperty, BDPolledSingletProperty, BDSingletProperty, } from '../../properties/index.js'; import bacnet, { ErrorCode, ErrorClass, ObjectType, ApplicationTag, PropertyIdentifier, Segmentation, DeviceStatus, ServicesSupported, ServicesSupportedBitString, ObjectTypesSupported, ObjectTypesSupportedBitString, } from '@bacnet-js/client'; import {} from '@bacnet-js/client/dist/lib/EventTypes.js'; import {} from './types.js'; import { sendConfirmedCovNotification, sendUnconfirmedCovNotification, } from './utils.js'; import { device as debug } from '../../debug.js'; import fastq from 'fastq'; import { AsyncEventEmitter } from '../../events.js'; import { SubscriptionStore } from './subscriptionstore.js'; import { getObjectUID, getPropertyUID } from '../../uids.js'; import { BDNumericObject } from '../numeric/numeric.js'; const { default: BACnetClient } = bacnet; /** * Implements a BACnet Device object * * The Device object is a specialized BACnet object that represents the BACnet device itself. * It serves as a container for all other BACnet objects and provides device-level properties * and services. Each BACnet node hosts exactly one Device object. * * According to the BACnet specification, the Device object includes standard properties: * - Object_Identifier (automatically added by BACnetObject) * - Object_Name (automatically added by BACnetObject) * - Object_Type (automatically added by BACnetObject) * - System_Status * - Vendor_Name * - Vendor_Identifier * - Model_Name * - Firmware_Revision * - Application_Software_Version * - Protocol_Version * - Protocol_Revision * - Protocol_Services_Supported * - Protocol_Object_Types_Supported * - Object_List * - And other properties related to device capabilities and configuration * * @extends BDObject */ export class BDDevice extends BDObject { /** * @see https://bacnet.org/assigned-vendor-ids/ */ #vendorId; /** The underlying BACnet client from the bacnet library */ #client; /** Queue for processing COV notifications */ #covqueue; /** Map of active subscriptions organized by object type and instance */ #subscriptions; /** * Map of all objects in this device, organized by type and instance * @private */ #objects; #objectData; #knownDevices; objectList; structuredObjectList; protocolVersion; protocolRevision; protocolServicesSupported; protocolObjectTypesSupported; activeCovSubscriptions; vendorIdentifier; vendorName; modelName; firmwareRevision; applicationSoftwareVersion; databaseRevision; deviceAddressBinding; location; serialNumber; maxApduLengthAccepted; apduTimeout; numberOfApduRetries; apduSegmentTimeout; segmentationSupported; maxSegmentsAccepted; utcOffset; localDate; localTime; daylightSavingsStatus; systemStatus; /** * Creates a new BACnet Device object * * This constructor initializes a Device object with all required properties * according to the BACnet specification, including support for basic BACnet * services and object types. * * @param instance - Device instance number (0-4194303). Must be unique on the BACnet network. * @param opts - Configuration options for this device * * @see {@link https://kargs.net/BACnet/Foundations2012-BACnetDeviceID.pdf} */ constructor(instance, opts) { super({ type: ObjectType.DEVICE, instance }, opts.name, opts.description); this.#vendorId = opts.vendorId ?? 0; this.#objects = new Map(); this.#objectData = []; this.#knownDevices = new Map(); this.#covqueue = fastq.promise(null, this.#covQueueWorker, 1); this.#subscriptions = new SubscriptionStore(); this.#client = new BACnetClient(opts) .on('whoHas', this.#onBacnetWhoHas) .on('iAm', this.#onBacnetIAm) .on('iHave', this.#onBacnetIHave) .on('error', this.#onBacnetError) .on('readRange', this.#onBacnetReadRange) .on('deviceCommunicationControl', this.#onBacnetDeviceCommunicationControl) .on('listening', this.#onBacnetListening) .on('readProperty', this.#onBacnetReadProperty) .on('whoIs', this.#onBacnetWhoIs) .on('subscribeCov', this.#onBacnetSubscribeCov) .on('subscribeProperty', this.#onBacnetSubscribeProperty) .on('readPropertyMultiple', this.#onBacnetReadPropertyMultiple) .on('writeProperty', this.#onBacnetWriteProperty) .on('addListElement', this.#onBacnetAddListElement) .on('removeListElement', this.#onBacnetRemoveListElement) .on('getEventInformation', this.#onBacnetGetEventInformation) .on('unhandledEvent', this.#onBacnetUnhandledEvent); this.addObject(this); // ================== PROPERTIES RELATED TO CHILD OBJECTS ================= this.objectList = this.addProperty(new BDPolledArrayProperty(PropertyIdentifier.OBJECT_LIST, () => this.#objectData)); this.structuredObjectList = this.addProperty(new BDPolledArrayProperty(PropertyIdentifier.STRUCTURED_OBJECT_LIST, () => this.#objectData)); // ====================== PROTOCOL-RELATED PROPERTIES ===================== this.protocolVersion = this.addProperty(new BDSingletProperty(PropertyIdentifier.PROTOCOL_VERSION, ApplicationTag.UNSIGNED_INTEGER, false, 1)); this.protocolRevision = this.addProperty(new BDSingletProperty(PropertyIdentifier.PROTOCOL_REVISION, ApplicationTag.UNSIGNED_INTEGER, false, 24)); const supportedServicesBitString = new ServicesSupportedBitString(ServicesSupported.WHO_IS, ServicesSupported.I_AM, ServicesSupported.READ_PROPERTY, ServicesSupported.WRITE_PROPERTY, ServicesSupported.SUBSCRIBE_COV, ServicesSupported.CONFIRMED_COV_NOTIFICATION, ServicesSupported.UNCONFIRMED_COV_NOTIFICATION); this.protocolServicesSupported = this.addProperty(new BDSingletProperty(PropertyIdentifier.PROTOCOL_SERVICES_SUPPORTED, ApplicationTag.BIT_STRING, false, supportedServicesBitString)); const supportedObjectTypesBitString = new ObjectTypesSupportedBitString(ObjectTypesSupported.DEVICE, ObjectTypesSupported.BINARY_VALUE, ObjectTypesSupported.ANALOG_VALUE, ObjectTypesSupported.ANALOG_INPUT, ObjectTypesSupported.ANALOG_OUTPUT, ObjectTypesSupported.DATE_VALUE, ObjectTypesSupported.TIME_VALUE, ObjectTypesSupported.DATETIME_VALUE, ObjectTypesSupported.INTEGER_VALUE, ObjectTypesSupported.POSITIVE_INTEGER_VALUE, ObjectTypesSupported.MULTI_STATE_VALUE, ObjectTypesSupported.CHARACTERSTRING_VALUE); this.protocolObjectTypesSupported = this.addProperty(new BDSingletProperty(PropertyIdentifier.PROTOCOL_OBJECT_TYPES_SUPPORTED, ApplicationTag.BIT_STRING, false, supportedObjectTypesBitString)); // ==================== SUBSCRIPTION-RELATED PROPERTIES =================== this.activeCovSubscriptions = this.addProperty(new BDPolledArrayProperty(PropertyIdentifier.ACTIVE_COV_SUBSCRIPTIONS, () => this.#subscriptions.getDeviceSubscriptionData())); // ========================== METADATA PROPERTIES ========================= this.vendorIdentifier = this.addProperty(new BDSingletProperty(PropertyIdentifier.VENDOR_IDENTIFIER, ApplicationTag.UNSIGNED_INTEGER, false, this.#vendorId)); this.vendorName = this.addProperty(new BDSingletProperty(PropertyIdentifier.VENDOR_NAME, ApplicationTag.CHARACTER_STRING, false, opts.vendorName ?? 'w')); this.modelName = this.addProperty(new BDSingletProperty(PropertyIdentifier.MODEL_NAME, ApplicationTag.CHARACTER_STRING, false, opts.modelName)); this.firmwareRevision = this.addProperty(new BDSingletProperty(PropertyIdentifier.FIRMWARE_REVISION, ApplicationTag.CHARACTER_STRING, false, opts.firmwareRevision)); this.applicationSoftwareVersion = this.addProperty(new BDSingletProperty(PropertyIdentifier.APPLICATION_SOFTWARE_VERSION, ApplicationTag.CHARACTER_STRING, false, opts.applicationSoftwareVersion)); this.databaseRevision = this.addProperty(new BDSingletProperty(PropertyIdentifier.DATABASE_REVISION, ApplicationTag.UNSIGNED_INTEGER, false, opts.databaseRevision)); // Bindings can be discovered via the "Who-Is" and "I-Am" services. // This property represents a list of static bindings and we can leave it empty. this.deviceAddressBinding = this.addProperty(new BDArrayProperty(PropertyIdentifier.DEVICE_ADDRESS_BINDING, false, [])); // In your device constructor this.location = this.addProperty(new BDSingletProperty(PropertyIdentifier.LOCATION, ApplicationTag.CHARACTER_STRING, false, opts.location ?? 'w')); this.serialNumber = this.addProperty(new BDSingletProperty(PropertyIdentifier.SERIAL_NUMBER, ApplicationTag.CHARACTER_STRING, false, opts.serialNumber ?? 'w')); // ======================== APDU-RELATED PROPERTIES ======================= this.maxApduLengthAccepted = this.addProperty(new BDSingletProperty(PropertyIdentifier.MAX_APDU_LENGTH_ACCEPTED, ApplicationTag.UNSIGNED_INTEGER, false, opts.apduMaxLength ?? 1476)); this.apduTimeout = this.addProperty(new BDSingletProperty(PropertyIdentifier.APDU_TIMEOUT, ApplicationTag.UNSIGNED_INTEGER, false, opts.apduTimeout ?? 6000)); this.numberOfApduRetries = this.addProperty(new BDSingletProperty(PropertyIdentifier.NUMBER_OF_APDU_RETRIES, ApplicationTag.UNSIGNED_INTEGER, false, opts.apduRetries ?? 3)); this.apduSegmentTimeout = this.addProperty(new BDSingletProperty(PropertyIdentifier.APDU_SEGMENT_TIMEOUT, ApplicationTag.UNSIGNED_INTEGER, false, opts.apduSegmentTimeout ?? 2000)); // ======================== SEGMENTATION PROPERTIES ======================= this.segmentationSupported = this.addProperty(new BDSingletProperty(PropertyIdentifier.SEGMENTATION_SUPPORTED, ApplicationTag.ENUMERATED, false, Segmentation.SEGMENTED_BOTH)); // Accepter values: 2, 4, 8, 16, 32, 64 and 0 for "unspecified" this.maxSegmentsAccepted = this.addProperty(new BDSingletProperty(PropertyIdentifier.MAX_SEGMENTS_ACCEPTED, ApplicationTag.UNSIGNED_INTEGER, false, 16)); // ======================== TIME-RELATED PROPERTIES ======================= this.utcOffset = this.addProperty(new BDPolledSingletProperty(PropertyIdentifier.UTC_OFFSET, ApplicationTag.SIGNED_INTEGER, (ctx) => ctx.date.getTimezoneOffset() * -1)); this.localDate = this.addProperty(new BDPolledSingletProperty(PropertyIdentifier.LOCAL_DATE, ApplicationTag.DATE, (ctx) => ctx.date)); this.localTime = this.addProperty(new BDPolledSingletProperty(PropertyIdentifier.LOCAL_TIME, ApplicationTag.TIME, (ctx) => ctx.date)); this.daylightSavingsStatus = this.addProperty(new BDPolledSingletProperty(PropertyIdentifier.DAYLIGHT_SAVINGS_STATUS, ApplicationTag.BOOLEAN, (ctx) => isDstInEffect(ctx.date))); // ======================= STATUS-RELATED PROPERTIES ====================== this.systemStatus = this.addProperty(new BDSingletProperty(PropertyIdentifier.SYSTEM_STATUS, ApplicationTag.ENUMERATED, false, DeviceStatus.OPERATIONAL)); } // ========================================================================== // PUBLIC METHODS // ========================================================================== /** * Adds a BACnet object to this device * * This method registers a new BACnet object with the device and adds it to the * device's object list. The object must have a unique identifier (type and instance). * * @param object - The BACnet object to add to this device * @returns The added object * @throws Error if an object with the same identifier already exists * @typeParam T - The specific BACnet object type */ addObject(object) { if (this.#objects.has(object.uid)) { throw new Error('Cannot register object: duplicate object identifier'); } this.#objects.set(object.uid, object); this.#objectData.push({ type: ApplicationTag.OBJECTIDENTIFIER, value: object.identifier }); object.on('aftercov', this.#onChildAfterCov); return object; } // ========================================================================== // INTERNAL HELPER METHODS // ========================================================================== async #wrapReqHandler(req, handler) { const { header, service, invokeId } = req; await this.transaction(handler).catch((err) => { if (err instanceof BDError) { if (header?.expectingReply) { debug('error while handling request: %s', err.stack ?? err.message); this.#client.errorResponse(header.sender, service, invokeId, err.class, err.code); } } else { if (header?.expectingReply) { debug('unexpected error while handling request: %s', err.stack ?? err.message); this.#client.errorResponse(header.sender, service, invokeId, ErrorClass.DEVICE, ErrorCode.INTERNAL_ERROR); } } }); } #getObjectByIdOrThrow(objectId) { const object = this.#objects.get(getObjectUID(objectId)); if (object) { return object; } throw new BDError('unknown object', ErrorCode.UNKNOWN_OBJECT, ErrorClass.OBJECT); } /** * Worker function for processing the COV notification queue * * This method processes each COV notification and sends it to all * applicable subscribers. * * @param cov - The change of value data to process * @private */ #covQueueWorker = async (cov) => { const propertyUid = getPropertyUID(cov.object.uid, cov.property.identifier); for (const subscription of this.#subscriptions.getPropertySubscriptions(propertyUid)) { if (cov.property.identifier === PropertyIdentifier.PRESENT_VALUE && cov.property === subscription.property && subscription.object instanceof BDNumericObject && subscription.lastDataSent && Math.abs(cov.value.value - subscription.lastDataSent.value) < subscription.object.covIncrement.getData().value) { continue; } subscription.lastDataSent = cov.value; if (subscription.issueConfirmedNotifications) { await sendConfirmedCovNotification(this.#client, this, subscription, cov); subscription.covIncrement += 1; } else { subscription.covIncrement += 1; await sendUnconfirmedCovNotification(this.#client, this, subscription, cov); } } }; // ========================================================================== // LISTENERS FOR CHILD OBJECT EVENTS // ========================================================================== /** * Handles 'aftercov' events from child BACnet objects * * This method propagates Change of Value (COV) events from contained objects * to the device's subscribers, allowing for device-wide COV monitoring. * * @param object - The object that changed * @param property - The property that changed * @param value - The new value * @private */ #onChildAfterCov = async (object, property, value) => { // We do not `await` the promise as we do not want slow consumers of // confirmed CoV notifications in the BACnet network to indirectly block // further operations on this object. this.#covqueue.push({ object, property, value }); }; // ========================================================================== // LISTENERS FOR BACNET SERVICE EVENTS // ========================================================================== /** * Handles ReadProperty requests from other BACnet devices * * This method processes ReadProperty requests and returns the requested * property value or an appropriate error. * * @param req - The ReadProperty request content * @private */ #onBacnetReadProperty = (req) => { this.#wrapReqHandler(req, async () => { const { payload: { objectId, property }, address, header, service, invokeId } = req; debug('req #%s: readProperty, object %s %s, property %s', invokeId, ObjectType[objectId.type], objectId.instance, PropertyIdentifier[property.id]); const data = await this.#getObjectByIdOrThrow(objectId).___readProperty(property); this.#client.readPropertyResponse(header.sender, invokeId, objectId, property, data); }); }; /** * Handles SubscribeCOV requests from other BACnet devices * * This method processes subscription requests for COV notifications * and either creates a new subscription or updates an existing one. * * @param req - The SubscribeCOV request content * @private */ #onBacnetSubscribeCov = (req) => { this.#wrapReqHandler(req, async () => { const { payload: { subscriberProcessId, monitoredObjectId, issueConfirmedNotifications, lifetime }, header, service, invokeId } = req; const object = this.#getObjectByIdOrThrow(monitoredObjectId); const property = object.___getPropertyOrThrow(PropertyIdentifier.PRESENT_VALUE); debug('new subscription: object %s %s', ObjectType[monitoredObjectId.type], monitoredObjectId.instance); const sub = { subscriptionProcessId: subscriberProcessId, issueConfirmedNotifications, expiresAt: Date.now() + (lifetime * 1000), // TODO: handle value-specific subscriptions when index > 0 monitoredProperty: { id: PropertyIdentifier.PRESENT_VALUE, index: 0 }, monitoredObjectId, subscriber: header.sender, covIncrement: 0, timeRemaining: lifetime, recipient: { address: [0], network: 0 }, lastDataSent: null, property, object, }; this.#subscriptions.add(sub); this.#client.simpleAckResponse(header.sender, service, invokeId); }); }; /** * Handles SubscribeCOVProperty requests from other BACnet devices * * This method is not fully implemented yet as it requires additional * support from the underlying BACnet library. * * Implementing onSubscribeCovProperty requires the underlying * @bacnet-js/client library to add support for the * full payload of this kind of event, which includes - in addition * to the properties of the standard onSubscribeCov event - the * following: * - monitored property: reference to the specific property being monitored * - covIncrement: (optional) minimum value change required to send cov * * @param req - The SubscribeCOVProperty request content * @private */ #onBacnetSubscribeProperty = (req) => { debug('new request: subscribeProperty'); this.#onBacnetUnsupportedService(req); // const { payload: { subscriberProcessId, monitoredObjectId, issueConfirmedNotifications, lifetime }, header, service, invokeId } = req; }; /** * Handles WhoIs requests from other BACnet devices * * This method responds with an IAm message identifying this device. * * @param req - The WhoIs request content * @private */ #onBacnetWhoIs = (req) => { this.#wrapReqHandler(req, async () => { debug('new request: whoIs'); const { header } = req; if (!header) return; this.#client.iAmResponse(header.sender, this.identifier.instance, Segmentation.NO_SEGMENTATION, this.#vendorId); }); }; /** * Handles WhoHas requests from other BACnet devices * * Currently not fully implemented, returns an error response. * * @param req - The WhoHas request content * @private */ #onBacnetWhoHas = (req) => { debug('new request: whoHas'); this.#onBacnetUnsupportedService(req); }; /** * Handles IAm notifications from other BACnet devices * * Currently not fully implemented, returns an error response. * * @param req - The IAm notification content * @private */ #onBacnetIAm = (req) => { debug('new request: iAm'); this.#wrapReqHandler(req, async () => { const { payload } = req; const { deviceId } = payload; // TODO: handle duplicate deviceId(s) this.#knownDevices.set(deviceId, payload); }); }; /** * Handles IHave notifications from other BACnet devices * * Currently not fully implemented, returns an error response. * * @param req - The IHave notification content * @private */ #onBacnetIHave = (req) => { debug('new request: iHave'); this.#onBacnetUnsupportedService(req); // const { header, service, invokeId } = req; // TODO: implement }; /** * Handles ReadRange requests from other BACnet devices * * Currently not fully implemented, returns an error response. * * @param req - The ReadRange request content * @private */ #onBacnetReadRange = (req) => { debug('new request: readRange'); this.#onBacnetUnsupportedService(req); }; /** * Handles DeviceCommunicationControl requests from other BACnet devices * * Currently not fully implemented, returns an error response. * * @param req - The DeviceCommunicationControl request content * @private */ #onBacnetDeviceCommunicationControl = (req) => { debug('new request: deviceCommunicationControl'); this.#onBacnetUnsupportedService(req); }; /** * Handles ReadPropertyMultiple requests from other BACnet devices * * This method processes requests to read multiple properties and * returns all the requested property values in a single response. * * @param req - The ReadPropertyMultiple request content * @private */ #onBacnetReadPropertyMultiple = (req) => { debug('new request: readPropertyMultiple'); this.#wrapReqHandler(req, async () => { const { header, invokeId, payload: { properties } } = req; if (!header) return; const values = []; for (const { objectId, properties: objProperties } of properties) { const object = this.#objects.get(getObjectUID(objectId)); if (object) { values.push(await object.___readPropertyMultiple(objProperties)); } } this.#client.readPropertyMultipleResponse(header.sender, invokeId, values); }); }; /** * Handles WriteProperty requests from other BACnet devices * * This method processes requests to write values to properties. * * @param req - The WriteProperty request content * @private */ #onBacnetWriteProperty = (req) => { debug('req #%s: writeProperty'); this.#wrapReqHandler(req, async () => { const { header, service, invokeId, payload: { objectId, property, value } } = req; const _value = value?.value; const _property = value?.property ?? property; if (!_value || !_property) { throw new BDError('inconsistent parameters', ErrorCode.INCONSISTENT_PARAMETERS, ErrorClass.SERVICES); } await this.#getObjectByIdOrThrow(objectId).___writeProperty(_property, _value); this.#client.simpleAckResponse(header.sender, service, invokeId); }); }; /** * Handles AddListElement requests from other BACnet devices * * This method processes requests to add elements to list properties. * Not fully implemented yet. * * @param req - The AddListElement request content * @private */ #onBacnetAddListElement = (req) => { this.#onBacnetUnsupportedService(req); }; /** * Handles RemoveListElement requests from other BACnet devices * * This method processes requests to remove elements from list properties. * Not fully implemented yet. * * @param req - The RemoveListElement request content * @private */ #onBacnetRemoveListElement = (req) => { this.#onBacnetUnsupportedService(req); }; #onBacnetGetEventInformation = (req) => { this.#onBacnetUnsupportedService(req); }; #onBacnetUnhandledEvent = (req) => { this.#onBacnetUnsupportedService(req); }; #onBacnetUnsupportedService = (req) => { const { header, invokeId, service } = req; debug('req #%s: %s (not supported)', invokeId, service ? ServicesSupported[service] : 'unknown'); if (!header || !invokeId || typeof service !== 'number') { return; } if (header.expectingReply) { this.#client.errorResponse(header.sender, service, invokeId, ErrorClass.SERVICES, ErrorCode.SERVICE_REQUEST_DENIED); } }; /** * Handles errors from the BACnet client * * This method emits errors to allow the application to handle them. * * @param err - The error that occurred * @private */ #onBacnetError = (err) => { debug('server error', err); this.___emit('error', err); }; /** * Handles the listening event from the BACnet client * * This method emits a listening event when the BACnet node * starts listening on the network. * * @private */ #onBacnetListening = () => { debug('server is listening'); this.___emit('listening'); }; } //# sourceMappingURL=device.js.map