UNPKG

@bitpoolos/edge-bacnet

Version:

A bacnet gateway for node-red

742 lines (671 loc) 25.5 kB
const { queueConfigStore } = require("./common"); const { BacnetDevice } = require("./bacnet_device"); // Global state for smart caching - minimal memory overhead let lastCacheTime = 0; let lastDataHash = ""; let cacheInterval = 30000; // Start with 30 seconds let consecutiveNoChangeCount = 0; const MAX_CACHE_INTERVAL = 300000; // Max 5 minutes const MIN_CACHE_INTERVAL = 20000; // Min 20 seconds /** * Simple hash function for change detection */ function simpleHash(data) { const str = JSON.stringify(data); let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; // Convert to 32bit integer } return hash.toString(); } /** * The `treeBuilder` class is responsible for building and processing the network tree structure. * It takes in a list of devices, a network tree object, a render list, a render list count, and an initial tree build flag. * The class provides methods for caching data, processing a device and its points, adding the root device folder to the render list, * processing points for a device, processing an individual point, extracting point properties, formatting display name for a point, * updating the render list, creating the folder JSON structure for a device, finalizing the network tree data, checking the interrupt flag, * getting the device IP address, computing the device name, sorting points, sorting devices, getting the point icon, and getting the device icon. * * @class * @name treeBuilder * @param {Array} deviceList - The list of devices. * @param {Object} networkTree - The network tree object. * @param {Array} renderList - The render list. * @param {number} renderListCount - The render list count. * @param {boolean} initialTreeBuild - The initial tree build flag. */ class treeBuilder { constructor(deviceList, networkTree, renderList, renderListCount, initialTreeBuild) { this.deviceList = deviceList; this.networkTree = networkTree; this.renderList = renderList; this.renderListCount = renderListCount; this.initialTreeBuild = initialTreeBuild; } /** * Smart cache with change detection and adaptive timing. * Only writes to file when data actually changes, with intelligent interval adjustment. * * @returns {void} */ cacheData() { const now = Date.now(); // Always allow caching on initial build if (this.initialTreeBuild) { this.performCache(); return; } // Check if enough time has passed since last cache attempt if (now - lastCacheTime < cacheInterval) { return; // Skip this cache attempt } // Prepare data for hashing (only essential data to minimize hash computation) const cacheData = { deviceList: this.deviceList, pointList: this.networkTree, }; // Generate hash of current data const currentHash = simpleHash(cacheData); // Check if data has actually changed if (currentHash === lastDataHash) { // No changes detected - increase cache interval (adaptive backing off) consecutiveNoChangeCount++; // Exponential backoff with cap if (consecutiveNoChangeCount >= 3) { cacheInterval = Math.min(cacheInterval * 1.5, MAX_CACHE_INTERVAL); } lastCacheTime = now; return; // Skip caching since no changes } // Data has changed - perform cache and reset adaptive timing lastDataHash = currentHash; lastCacheTime = now; consecutiveNoChangeCount = 0; // Reset interval to be more responsive during active changes cacheInterval = Math.max(cacheInterval * 0.8, MIN_CACHE_INTERVAL); // Perform the actual cache operation this.performCache(); } /** * Performs the actual cache operation */ performCache() { // Cache only the essential data, exclude renderList as it's too large and can be rebuilt queueConfigStore({ deviceList: this.deviceList, pointList: this.networkTree, // renderList: excluded to reduce file size - will be rebuilt from deviceList and networkTree // renderListCount: excluded as it can be recalculated }); } /** * Process a device and its points. * * @param {Device} device - The device to process. * @param {number} index - The index of the device. * @returns {Promise<void>} - A promise that resolves when the device and its points have been processed. */ async processDevice(device, index) { const ipAddress = this.getDeviceIpAddress(device); const deviceId = device.getDeviceId(); const deviceName = this.computeDeviceName(device); const deviceKey = `${ipAddress}-${deviceId}`; const deviceObject = this.networkTree[deviceKey]; // Add the root device folder to the render list if (this.initialTreeBuild) { this.addRootDeviceFolder(device, deviceName, index, ipAddress, deviceId); } // Check if the device object exists and the device name is valid if (deviceObject) { await this.processDevicePoints(device, deviceObject, deviceName, ipAddress, deviceId, index); //delete dummy object if all conditions satisfied if (deviceName !== null) { if (deviceId !== null) { let lastIndex = deviceName.lastIndexOf(deviceId); if (lastIndex) { let formattedName = deviceName.substring(0, lastIndex); formattedName = `${formattedName.trim()}_Device_${deviceId}`; if ( this.networkTree[deviceKey][formattedName] && Object.keys(this.networkTree[deviceKey][formattedName]).length > 0 && this.networkTree[deviceKey]["device"] ) { delete this.networkTree[deviceKey]["device"]; } } } } } else { //invalid ip object, likely dumb mstp router if (device.getIsDumbMstpRouter()) { //update dumb mstp router name await this.updateDumbMstpRouterName(deviceName, ipAddress, deviceId); } } } async updateDumbMstpRouterName(deviceName, ipAddress, deviceId) { return new Promise((resolve, reject) => { let listDeviceIndex = this.renderList.findIndex( (item) => item.deviceId == deviceId && item.ipAddr == ipAddress && item.isDumbMstpRouter == true ); if (listDeviceIndex !== -1) { this.renderList[listDeviceIndex].label = deviceName; this.renderList[listDeviceIndex].data = deviceName; } resolve(); }); } /** * Add the root device folder to the render list. * * @param {Object} device - The device object. * @param {string} deviceName - The name of the device. * @param {number} index - The index of the device. * @param {string} ipAddress - The IP address of the device. * @param {string} deviceId - The ID of the device. * @returns {void} */ addRootDeviceFolder(device, deviceName, index, ipAddress, deviceId) { if (!this.renderList) { this.renderList = []; } if (!device.getIsMstpDevice()) { let displayName = deviceName ? deviceName : `${ipAddress} - ${deviceId}`; // Check if the device already exists in the renderList const existingDeviceIndex = this.renderList.findIndex((item) => item.deviceId === deviceId && item.ipAddr === ipAddress); if (existingDeviceIndex === -1) { // Device not found, add new entry let isDumbMstpRouter = false; if (device.getIsDumbMstpRouter() && deviceId == null) isDumbMstpRouter = true; const rootFolder = { key: index, label: displayName, data: displayName, icon: this.getDeviceIcon(device), children: [ { key: `${deviceId}-0`, label: "Points", data: "Points Folder", icon: "pi pi-circle-fill", type: "pointFolder", children: [], }, ], type: "device", lastSeen: device.getLastSeen(), showAdded: false, ipAddr: ipAddress, deviceId, isMstpDevice: device.getIsMstpDevice(), initialName: device.getDeviceName(), isDumbMstpRouter: isDumbMstpRouter, }; // Add the root folder to the render list this.renderList.push(rootFolder); } } } addEmptyIpRootDevice(childDevice) { const ipAddress = this.getDeviceIpAddress(childDevice); //let deviceIndex = this.deviceList.findIndex(ele => ele.address === ipAddress && ele.deviceId === null && ele.deviceName === ipAddress && ele.displayName === ipAddress); let deviceIndex = this.deviceList.findIndex((ele) => ele.address === ipAddress && ele.deviceId === null); if (deviceIndex === -1) { let newDevice = { address: ipAddress, isMstp: false, deviceId: null, maxApdu: childDevice.getMaxApdu(), segmentation: childDevice.getSegmentation(), vendorId: childDevice.getVendorId(), lastSeen: null, deviceName: ipAddress, pointsList: [], pointListUpdateTs: null, manualDiscoveryMode: false, pointListRetryCount: 0, priorityQueueIsActive: false, priorityQueue: [], lastPriorityQueueTS: null, childDevices: [childDevice.getDeviceId()], parentDeviceId: null, displayName: ipAddress, protocolServicesSupported: [], isProtocolServicesSet: false, isInitialQuery: true, isDumbMstpRouter: true, }; let newBacnetDevice = new BacnetDevice(true, newDevice); this.deviceList.push(newBacnetDevice); } } /** * Process the points of a device and add them to the render list. * * @param {Device} device - The device object. * @param {Object} deviceObject - The object containing the points of the device. * @param {string} deviceName - The name of the device. * @param {string} ipAddress - The IP address of the device. * @param {string} deviceId - The ID of the device. * @param {number} index - The index of the device. * @returns {Promise<void>} - A promise that resolves when the points have been processed and added to the render list. */ async processDevicePoints(device, deviceObject, deviceName, ipAddress, deviceId, index) { // Initialize the list of children points let children = []; // Process each point in the device object for (const pointName in deviceObject) { // Ensure processing should continue this.checkInterruptFlag(); // Process the point and add it to the list of children const point = deviceObject[pointName]; const childPoint = await this.processPoint(point, pointName, index, deviceName, device); children.push(childPoint); } // Add the device and its children to the render list this.updateRenderList(children, device, deviceName, index, ipAddress, deviceId); } /** * Process a point and create a child point object. * * @param {Object} point - The point to process. * @param {string} pointName - The name of the point. * @param {number} index - The index of the point. * @param {string} deviceName - The name of the parent device. * @param {Object} device - The parent device object. * @returns {Object} - The child point object. */ async processPoint(point, pointName, index, deviceName, device) { // Get the properties and data of the point const pointProperties = this.extractPointProperties(point); // Determine the point's display name and apply formatting if necessary const displayName = this.formatDisplayName(point, pointName); // Create the child point object return { key: `${index}-0-${pointName}`, label: displayName, data: displayName, pointName, icon: this.getPointIcon(point), children: pointProperties, type: "point", parentDevice: deviceName, parentDeviceId: device.getDeviceId(), showAdded: false, bacnetType: point.meta.objectId.type, bacnetInstance: point.meta.objectId.instance, }; } /** * Extracts the properties of a point object. * * @param {Object} point - The point object to extract properties from. * @returns {Array} - An array of point properties. */ extractPointProperties(point) { const pointProperties = []; // Add properties such as name, type, instance, and others this.addPointProperty(pointProperties, "Name", point.objectName); this.addPointProperty(pointProperties, "Object Type", point.meta.objectId.type); this.addPointProperty(pointProperties, "Object Instance", point.meta.objectId.instance); this.addPointProperty(pointProperties, "Description", point.description); this.addPointProperty(pointProperties, "Units", point.units); this.addPointProperty(pointProperties, "Present Value", point.presentValue); this.addPointProperty(pointProperties, "System Status", point.systemStatus); this.addPointProperty(pointProperties, "Modification Date", point.modificationDate); this.addPointProperty(pointProperties, "Program State", point.programState); this.addPointProperty(pointProperties, "Record Count", point.recordCount); // Return the array of point properties return pointProperties; } /** * Adds a property to the list of point properties. * * @param {Array} properties - The list of point properties. * @param {string} label - The label of the property. * @param {any} value - The value of the property. * @returns {void} */ addPointProperty(properties, label, value) { if (value !== null && value !== undefined && value !== "") { properties.push({ label: `${label}: ${value}`, data: value, icon: "pi pi-cog", children: null, }); } } /** * Formats the display name for a point. * * If the point has a display name, it returns the display name. * Otherwise, it removes any special characters from the point name and returns it. * * @param {Object} point - The point object. * @param {string} pointName - The name of the point. * @returns {string} - The formatted display name. */ formatDisplayName(point, pointName) { const reg = /[$#\/\\+]/gi; if (point.displayName) { return point.displayName; } return pointName.replace(reg, ""); } /** * Updates the render list with the folder structure for a device. * * @param {Array} children - The children of the device. * @param {Object} device - The device object. * @param {string} deviceName - The name of the device. * @param {number} index - The index of the device. * @param {string} ipAddress - The IP address of the device. * @param {string} deviceId - The ID of the device. * @returns {void} */ updateRenderList(children, device, deviceName, index, ipAddress, deviceId) { // Create the folder structure for the device const folderJson = this.createFolderJson(children, device.hasChildDevices(), deviceId); if (!this.renderList) { this.renderList = []; } // Find the device's entry in the render list let foundIndex = this.renderList.findIndex((ele) => ele.deviceId == deviceId && ele.ipAddr == ipAddress); // If the device is not in the render list, add it as a new entry const newDeviceEntry = { key: index, label: deviceName, data: deviceName, icon: this.getDeviceIcon(device), children: folderJson, type: "device", lastSeen: device.getLastSeen(), showAdded: false, ipAddr: ipAddress, deviceId, isMstpDevice: device.getIsMstpDevice(), initialName: device.getDeviceName(), }; if (device.getIsMstpDevice()) { // For child MSTP devices, find the parent device and the MSTP network folder const parentDeviceId = device.getParentDeviceId(); const parentDeviceIndex = this.renderList.findIndex((ele) => ele.deviceId == parentDeviceId && ele.ipAddr == ipAddress); const mstpNetworkNumber = device.getMstpNetworkNumber(); if (parentDeviceIndex !== -1) { let parentDeviceEntry = this.renderList[parentDeviceIndex]; let mstpNetworkFolder = parentDeviceEntry.children.find((child) => child.label === `MSTP NET${mstpNetworkNumber}`); // Create the MSTP network folder if it doesn't exist if (!mstpNetworkFolder) { mstpNetworkFolder = { key: `${deviceId}-mstp-${mstpNetworkNumber}`, label: `MSTP NET${mstpNetworkNumber}`, data: `Devices Folder (${mstpNetworkNumber})`, icon: "pi pi-database", type: "mstpfolder", children: [], }; parentDeviceEntry.children.push(mstpNetworkFolder); } // Add or update the child MSTP device in the MSTP folder const mstpDeviceIndex = mstpNetworkFolder.children.findIndex( (ele) => ele.deviceId == deviceId && ele.ipAddr == ipAddress ); if (mstpDeviceIndex === -1) { mstpNetworkFolder.children.push(newDeviceEntry); } else { mstpNetworkFolder.children[mstpDeviceIndex] = newDeviceEntry; } } else { //no parent found in render list if (parentDeviceId !== null) { let parentDeviceListIndex = this.deviceList.findIndex((ele) => ele.getDeviceId() == parentDeviceId); if (parentDeviceListIndex !== -1) { let parentDevice = this.deviceList[parentDeviceListIndex]; this.addRootDeviceFolder( parentDevice, parentDevice.getDeviceName(), parentDeviceListIndex, parentDevice.getAddress(), parentDeviceId ); } } else { this.addEmptyIpRootDevice(device); } } } else { // Add the new device entry to the root of the render list if (foundIndex === -1) { this.renderList.push(newDeviceEntry); } else { // If the device is already in the render list, preserve existing MSTP folders const existingDevice = this.renderList[foundIndex]; // Preserve existing MSTP folders while updating the device info const existingMstpFolders = existingDevice.children.filter( (child) => child.type === "mstpfolder" || (child.label && child.label.includes("MSTP")) ); // Start with the new device structure const updatedDevice = { ...newDeviceEntry }; // Add back any existing MSTP folders existingMstpFolders.forEach((mstpFolder) => { const existingMstpIndex = updatedDevice.children.findIndex((child) => child.label === mstpFolder.label); if (existingMstpIndex === -1) { // MSTP folder doesn't exist in new structure, add it updatedDevice.children.push(mstpFolder); } else { // MSTP folder exists, keep the existing one (with all its children) updatedDevice.children[existingMstpIndex] = mstpFolder; } }); this.renderList[foundIndex] = updatedDevice; } } } /** * Creates a folder JSON object for the network tree. * * @param {Array} children - The children nodes of the folder. * @param {boolean} hasChildDevices - Indicates if the device has child devices. * @param {string} deviceId - The ID of the device. * @returns {Array} - The folder JSON object. */ createFolderJson(children, hasChildDevices, deviceId) { const folders = [ { key: `${deviceId}-0`, label: "Points", data: "Points Folder", icon: "pi pi-circle-fill", type: "pointFolder", children: children.sort(this.sortPoints), }, ]; return folders; } /** * Finalize the network tree data * * @returns {Object} The finalized network tree data * - renderList: The list of devices and their points * - deviceList: The list of devices * - pointList: The list of points in the network tree * - pollFrequency: The polling schedule for discovery */ finalizeNetworkTreeData() { this.renderList.sort(this.sortDevices); return { renderList: this.renderList, deviceList: this.deviceList, pointList: this.networkTree, pollFrequency: this.discover_polling_schedule, }; } /** * Checks if the buildTreeException flag is set and throws an error if it is. * * @throws {Error} - Throws an error with the message 'Build tree interrupted' if the buildTreeException flag is set. */ checkInterruptFlag() { if (this.buildTreeException) { throw new Error("Build tree interrupted"); } } /** * Returns the IP address of the given device. * * @param {object} device - The device object. * @returns {string} The IP address of the device. */ getDeviceIpAddress(device) { switch (typeof device.getAddress()) { case "object": return device.getAddress().address; case "string": return device.getAddress(); default: return device.getAddress(); } } /** * Computes the device name based on the provided device object. * If the device has a display name, it will be returned. * Otherwise, the device name will be returned. * * @param {Object} device - The device object. * @returns {string} - The computed device name. */ computeDeviceName(device) { if (device.getDeviceName() == null && device.getDisplayName() == null) { return `${this.getDeviceIpAddress(device)}-${device.getDeviceId()}`; } else if (device.getDisplayName() !== null && device.getDisplayName() !== "" && device.getDisplayName() !== undefined) { return device.getDisplayName(); } return device.getDeviceName(); } /** * Sorts the points based on their BACnet type and label. * * @param {Object} a - The first point object to compare. * @param {Object} b - The second point object to compare. * @returns {number} - A negative number if a should be sorted before b, a positive number if b should be sorted before a, or 0 if they are equal. */ sortPoints(a, b) { if (a.bacnetType > b.bacnetType) { return 1; } else if (a.bacnetType < b.bacnetType) { return -1; } else if (a.bacnetType == b.bacnetType) { return 0; } return a.label.localeCompare(b.label); } /** * Sorts devices based on their deviceId. * * @param {Object} a - The first device object to compare. * @param {Object} b - The second device object to compare. * @returns {number} - Returns -1 if a.deviceId is less than b.deviceId, 1 if a.deviceId is greater than b.deviceId, or 0 if they are equal. */ sortDevices(a, b) { if (a.deviceId < b.deviceId) { return -1; } else if (a.deviceId > b.deviceId) { return 1; } return 0; // deviceIds are equal } /** * Returns the icon class name for a given point based on its object type. * * @param {Object} values - The values object containing the point's metadata. * @returns {string} - The icon class name for the point. */ getPointIcon(values) { const objectId = values.meta.objectId.type; const hasPriorityArray = values.hasPriorityArray && values.hasOwnProperty("hasPriorityArray") ? values.hasPriorityArray : false; if (hasPriorityArray) { return "pi writePointIcon"; } else { switch (objectId) { case 0: //AI return "pi readPointIcon"; case 1: //AO return "pi readPointIcon"; case 2: //AV return "pi readPointIcon"; case 3: //BI return "pi readPointIcon"; case 4: //BO return "pi readPointIcon"; case 5: //BV return "pi readPointIcon"; case 8: //Device return "pi pi-box"; case 13: //MI return "pi readPointIcon"; case 14: //MO return "pi readPointIcon"; case 19: //MV return "pi readPointIcon"; case 10: //File return "pi pi-file"; case 16: //Program return "pi pi-database"; case 20: //Trendlog return "pi pi-chart-line"; case 15: //Notification Class return "pi pi-bell"; case 56: return "pi pi-sitemap"; case 178: return "pi pi-lock"; case 17: return "pi pi-calendar"; case 6: return "pi pi-calendar"; default: //Return circle for all other types return "pi readPointIcon"; } } } /** * Returns the icon for a given device based on its properties. * * @param {Object} device - The device object. * @returns {string} - The icon class name. */ getDeviceIcon(device) { const isMstp = device.getIsMstpDevice(); const manualDiscoveryMode = device.getManualDiscoveryMode(); if (manualDiscoveryMode == true) { return "pi pi-question-circle"; } else if (manualDiscoveryMode == false) { if (isMstp == true) { return "pi pi-box"; } else if (isMstp == false) { return "pi pi-server"; } } return "pi pi-server"; } } module.exports = { treeBuilder };