UNPKG

@bitpoolos/edge-bacnet

Version:

A bacnet gateway for node-red

437 lines (386 loc) 11.9 kB
/* MIT License Copyright 2021, 2025 - Bitpool Pty Ltd */ const { randomUUID } = require("crypto"); const os = require("os"); const path = require("path"); const baEnum = require("./resources/node-bacstack-ts/dist/index.js").enum; const fs = require("fs"); const fs2 = require("fs").promises; class BacnetConfig { constructor( device, objects, bacnet_polling_schedule, apduTimeout, localIpAdrress, roundDecimal, local_device_port, apduSize, maxSegments, broadCastAddr ) { this.device = { deviceId: device.deviceId, address: device.address, }; this.polling = { schedule: bacnet_polling_schedule, }; this.objects = [ { objectId: { type: objects.object_type, instance: objects.instance, properties: objects.object_props, }, }, ]; this.apduTimeout = apduTimeout; this.localIpAdrress = localIpAdrress; this.roundDecimal = roundDecimal; this.port = local_device_port; this.apduSize = apduSize; this.maxSegments = maxSegments; this.broadCastAddr = broadCastAddr; } } class BacnetClientConfig { constructor( apduTimeout, localIpAdrress, local_device_port, apduSize, maxSegments, broadCastAddr, discover_polling_schedule, toRestartNodeRed, deviceId, manual_instance_range_enabled, manual_instance_range_start, manual_instance_range_end, device_read_schedule, retries, cacheFileEnabled, sanitise_device_schedule, portRangeMatrix, enable_device_discovery ) { this.apduTimeout = apduTimeout; this.localIpAdrress = localIpAdrress; this.port = local_device_port; this.apduSize = apduSize; this.maxSegments = maxSegments; this.broadCastAddr = broadCastAddr; this.discover_polling_schedule = discover_polling_schedule; this.toRestartNodeRed = toRestartNodeRed; this.deviceId = deviceId; this.manual_instance_range_enabled = manual_instance_range_enabled; this.manual_instance_range_start = manual_instance_range_start; this.manual_instance_range_end = manual_instance_range_end; this.device_read_schedule = device_read_schedule; this.retries = retries; this.cacheFileEnabled = cacheFileEnabled; this.sanitise_device_schedule = sanitise_device_schedule; this.portRangeMatrix = this.generatePortRangeArray(portRangeMatrix); this.enable_device_discovery = enable_device_discovery; } generatePortRangeArray(rangeMatrix) { let portArray = []; for (let x = 0; x < rangeMatrix.length; x++) { let rangeEntry = rangeMatrix[x]; let start = parseInt(rangeEntry.start); let end = parseInt(rangeEntry.end); for (let i = start; i <= end; i++) { portArray.push(i); } } return portArray; } } class ReadCommandConfig { constructor(pointsToRead, objectProperties, decimalPrecision) { this.pointsToRead = pointsToRead; this.objectProperties = objectProperties; this.precision = decimalPrecision; } } class WriteCommandConfig { constructor(device, objects) { this.device = { deviceId: device.deviceId, address: device.address, }; this.objects = [ { objectId: { type: objects.object_type, instance: objects.instance, properties: objects.object_props, }, }, ]; } } const getUnit = function (id) { for (var key in baEnum.EngineeringUnits) { if (baEnum.EngineeringUnits[key] == id) { if (baEnum.EngineeringUnits.hasOwnProperty(key)) { let unitsArr = key.split("_"); let unit; unitsArr.forEach((ele, index) => { if (index == 0) { unit = ele.toLowerCase(); } else { unit += "-" + ele.toLowerCase(); } }); return unit; } } } return "no-units"; }; const generateId = function () { return randomUUID(); }; const getIpAddress = function () { return new Promise(function (resolve, reject) { const nets = os.networkInterfaces(); const results = Object.create(null); // Or just '{}', an empty object for (const name of Object.keys(nets)) { for (const net of nets[name]) { // Skip over non-IPv4 and internal (i.e. 127.0.0.1) addresses let family = parseInt(net.family.toString().match(/[0-9]/)); if (family === 4 && !net.internal) { if (!results[name]) { results[name] = []; } results[name].push(net.address); } } } if (os.version().includes("Ubuntu") || os.version().includes("SMP")) { let allInterfaceName = "All interfaces"; if (!results[allInterfaceName]) { results[allInterfaceName] = []; } results[allInterfaceName].push("0.0.0.0"); } else if (os.version().includes("Windows")) { //do nothing } resolve(results); }); }; const roundDecimalPlaces = function (value, decimals) { if (decimals) return Number(Math.round(value + "e" + decimals) + "e-" + decimals); return value; }; const getStoragePath = (fileName) => { const storagePath = process.env.BACNET_STORAGE_PATH; if (storagePath) { if (!fs.existsSync(storagePath)) { fs.mkdirSync(storagePath, { recursive: true }); } return path.join(storagePath, fileName); } return fileName; }; let storeQueue = []; let isStoreProcessing = false; async function queueConfigStore(data) { storeQueue.push(data); if (!isStoreProcessing) { isStoreProcessing = true; while (storeQueue.length > 0) { const nextData = storeQueue.pop(); // Get most recent data storeQueue.length = 0; // Clear any accumulated data await Store_Config(nextData); // Add small delay between attempts await new Promise((resolve) => setTimeout(resolve, 100)); } isStoreProcessing = false; } } async function Store_Config(data) { const mainFile = getStoragePath("edge-bacnet-datastore.cfg"); const tempFile = getStoragePath("edge-bacnet-datastore.cfg.tmp"); const backupFile = getStoragePath("edge-bacnet-datastore.cfg.bak"); try { // First validate the JSON to ensure it's valid before writing try { JSON.parse(JSON.stringify(data)); } catch (jsonError) { console.error("Invalid JSON data detected:", jsonError); return false; } // Write to temporary file first await fs2.writeFile(tempFile, JSON.stringify(data, null, 2), { encoding: "utf8" }); // Verify the temporary file is valid JSON try { const tempContent = await fs2.readFile(tempFile, "utf8"); JSON.parse(tempContent); } catch (verifyError) { console.error("Temporary file validation failed:", verifyError); await fs2.unlink(tempFile).catch(() => { }); return false; } // Create backup of current file if it exists try { await fs2.access(mainFile); await fs2.copyFile(mainFile, backupFile); } catch (backupError) { // If main file doesn't exist, no backup needed } // Atomic rename of temporary file to main file await fs2.rename(tempFile, mainFile); return true; } catch (error) { console.error("Store_Config error:", error); // Cleanup temporary file if it exists try { await fs2.unlink(tempFile).catch(() => { }); } catch (cleanupError) { } // If main file is corrupted and backup exists, restore from backup try { const backupExists = await fs2.access(backupFile).catch(() => false); if (backupExists) { await fs2.copyFile(backupFile, mainFile); console.log("Restored from backup file"); } } catch (restoreError) { console.error("Failed to restore from backup:", restoreError); } return false; } } async function Read_Config_Async() { // todo rename function, not using sync const mainFile = getStoragePath("edge-bacnet-datastore.cfg"); const backupFile = getStoragePath("edge-bacnet-datastore.cfg.bak"); const defaultData = "{}"; try { // Try to read the main file const data = await fs2.readFile(mainFile, { encoding: "utf8" }); // Validate JSON try { JSON.parse(data); return data; } catch (jsonError) { console.error("Main file contains invalid JSON, attempting backup recovery"); // Try to read backup file try { const backupData = await fs2.readFile(backupFile, { encoding: "utf8" }); JSON.parse(backupData); // Validate backup JSON // Restore from backup await fs.copyFile(backupFile, mainFile); console.log("Successfully restored from backup file"); return backupData; } catch (backupError) { console.error("Backup recovery failed, creating new file"); await Store_Config(defaultData); return defaultData; } } } catch (error) { console.error("Error reading config:", error); await Store_Config(defaultData); return defaultData; } } // STORE CONFIG FUNCTION - BACNET SERVER ========================================== // // ================================================================================ async function Store_Config_Server(data) { try { await fs.writeFile(getStoragePath("edge-bacnet-server-datastore.cfg"), data, (err) => { if (err) { //console.log("Store_Config_Server writeFile error: ", err); } }); } catch (err) { } } // READ CONFIG SYNC FUNCTION - BACNET SERVER ====================================== // // ================================================================================ function Read_Config_Sync_Server() { var data = "{}"; try { data = fs.readFileSync(getStoragePath("edge-bacnet-server-datastore.cfg"), { encoding: "utf8", flag: "r" }); } catch (err) { if (err.errno == -4058) { data = "{}"; Store_Config_Server(data); } } return data; } function isNumber(value) { return value != null && typeof value === "number" && !isNaN(value); } function decodeBitArray(size, bits) { let array = []; for (let i = 0; i < bits.length; i++) { let bit = bits[i]; let bitString = bit.toString(2); if (bitString.length < size) { const remainingLength = size - bitString.length; const backFillString = "0".repeat(remainingLength); array.push(backFillString + bitString); } else if (bitString.length == size) { array.push(bitString); } if (i == bits.length - 1) { return array; } } } function getBacnetErrorString(classInt, codeInt) { const classString = Object.keys(baEnum.ErrorClass).find((key) => baEnum.ErrorClass[key] === classInt); const codeString = Object.keys(baEnum.ErrorCode).find((key) => baEnum.ErrorCode[key] === codeInt); return `BacnetError - Class:${classString} - Code:${codeString}`; } function parseBacnetError(error) { let err = error.message; if (err.includes("Class") && err.includes("Code")) { const match = err.match(/Class:(\d+) - Code:(\d+)/); if (match) { err = getBacnetErrorString(parseInt(match[1], 10), parseInt(match[2], 10)); } } else if (err.includes("ERR_TIMEOUT")) { err = "Request TIMEOUT"; } return err; } function debounce(func, wait) { let timeout; return function (...args) { const context = this; // Clear the previous timeout clearTimeout(timeout); // Set a new timeout timeout = setTimeout(() => { func.apply(context, args); }, wait); }; } module.exports = { BacnetConfig, BacnetClientConfig, ReadCommandConfig, WriteCommandConfig, getUnit, generateId, getIpAddress, roundDecimalPlaces, queueConfigStore, Store_Config, Read_Config_Async, Store_Config_Server, Read_Config_Sync_Server, isNumber, decodeBitArray, parseBacnetError, getBacnetErrorString, debounce, };