@bitpoolos/edge-bacnet 1.5.3 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1564 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const os = require("os");
4
+ const { debounce } = require("./common");
5
+ const { generatePrimeVueAppHtmlStatic, getLogoAsBase64 } = require("./ssrHtmlExporter.js");
6
+ const { Worker } = require("worker_threads");
7
+
8
+ module.exports = function (RED) {
9
+ let context;
10
+ let node;
11
+
12
+ function BacnetInspector(config) {
13
+ RED.nodes.createNode(this, config);
14
+ node = this;
15
+ context = node.context();
16
+ node.name = config.name;
17
+ node.siteName = config.siteName;
18
+ node.uniqueReadTopics = config.uniqueReadTopics;
19
+ node.totalUniqueReadCount = config.totalUniqueReadCount;
20
+ node.statBlock = {
21
+ ok: 0,
22
+ error: 0,
23
+ missing: 0,
24
+ warnings: 0,
25
+ moved: 0,
26
+ deviceIdChange: 0,
27
+ deviceIdConflict: 0,
28
+ unmapped: 0,
29
+ offlinePercentage: 0,
30
+ };
31
+
32
+ // Cache for flow context data
33
+ let cachedData = {
34
+ uniqueTopics: new Set(),
35
+ uniqueReadTopics: new Set(),
36
+ topicStatusMap: new Map(),
37
+ topicDataMap: new Map(),
38
+ ReadList: new Map(),
39
+ totalUniquePolledCount: 0,
40
+ onlineCount: 0,
41
+ offlineCount: 0,
42
+ offlinePercentage: 0,
43
+ entriesWithErrors: new Map(),
44
+ totalUniqueReadCount: 0,
45
+ site_Name: node.siteName,
46
+ };
47
+
48
+ // Track what data has been modified and needs to be synced
49
+ let dirtyFlags = {
50
+ uniqueTopics: false,
51
+ uniqueReadTopics: false,
52
+ topicStatusMap: false,
53
+ topicDataMap: false,
54
+ ReadList: false,
55
+ totalUniquePolledCount: false,
56
+ onlineCount: false,
57
+ offlineCount: false,
58
+ offlinePercentage: false,
59
+ entriesWithErrors: false,
60
+ totalUniqueReadCount: false,
61
+ site_Name: false,
62
+ };
63
+
64
+ // Initialize cache from flow context
65
+ initializeCache();
66
+
67
+ function initializeCache() {
68
+ let flow = context.flow;
69
+
70
+ // Load data from flow context into cache
71
+ const uniqueTopics = flow.get("uniqueTopics") || [];
72
+ const uniqueReadTopics = flow.get("uniqueReadTopics") || [];
73
+ const topicStatusMap = flow.get("topicStatusMap") || {};
74
+ const topicDataMap = flow.get("topicDataMap") || {};
75
+ const ReadList = flow.get("ReadList") || {};
76
+ const entriesWithErrors = flow.get("entriesWithErrors") || {};
77
+
78
+ // Convert arrays to Sets for faster lookups
79
+ cachedData.uniqueTopics = new Set(uniqueTopics);
80
+ cachedData.uniqueReadTopics = new Set(uniqueReadTopics);
81
+
82
+ // Convert objects to Maps for better performance
83
+ Object.entries(topicStatusMap).forEach(([key, value]) => {
84
+ cachedData.topicStatusMap.set(key, value);
85
+ });
86
+
87
+ Object.entries(topicDataMap).forEach(([key, value]) => {
88
+ cachedData.topicDataMap.set(key, value);
89
+ });
90
+
91
+ Object.entries(ReadList).forEach(([key, value]) => {
92
+ cachedData.ReadList.set(key, value);
93
+ });
94
+
95
+ Object.entries(entriesWithErrors).forEach(([key, value]) => {
96
+ cachedData.entriesWithErrors.set(key, value);
97
+ });
98
+
99
+ // Load scalar values
100
+ cachedData.totalUniquePolledCount = flow.get("totalUniquePolledCount") || 0;
101
+ cachedData.onlineCount = flow.get("onlineCount") || 0;
102
+ cachedData.offlineCount = flow.get("offlineCount") || 0;
103
+ cachedData.offlinePercentage = flow.get("offlinePercentage") || 0;
104
+ cachedData.totalUniqueReadCount = flow.get("totalUniqueReadCount") || 0;
105
+ cachedData.site_Name = flow.get("site_Name") || node.siteName;
106
+ }
107
+
108
+ // Constants for batching and interval times
109
+ const SYNC_INTERVAL = 5000; // Sync with flow context every 5 seconds
110
+ let messageQueue = [];
111
+ const MAX_BATCH_SIZE = 1000;
112
+ const BATCH_PROCESS_INTERVAL = 200;
113
+
114
+ // Set up periodic intervals
115
+ this.batchInterval = setInterval(processBatch, BATCH_PROCESS_INTERVAL);
116
+ this.syncInterval = setInterval(syncWithFlowContext, SYNC_INTERVAL);
117
+
118
+ const debouncedGetBacnetStats = debounce((node, context, msg) => {
119
+ getModelStats();
120
+ }, 3000);
121
+
122
+ // Function to sync cache to flow context
123
+ function syncWithFlowContext() {
124
+ const flow = context.flow;
125
+
126
+ // Only sync data that has been modified
127
+ if (dirtyFlags.uniqueTopics) {
128
+ flow.set("uniqueTopics", Array.from(cachedData.uniqueTopics));
129
+ dirtyFlags.uniqueTopics = false;
130
+ }
131
+
132
+ if (dirtyFlags.uniqueReadTopics) {
133
+ flow.set("uniqueReadTopics", Array.from(cachedData.uniqueReadTopics));
134
+ dirtyFlags.uniqueReadTopics = false;
135
+ }
136
+
137
+ if (dirtyFlags.topicStatusMap) {
138
+ flow.set("topicStatusMap", Object.fromEntries(cachedData.topicStatusMap));
139
+ dirtyFlags.topicStatusMap = false;
140
+ }
141
+
142
+ if (dirtyFlags.topicDataMap) {
143
+ flow.set("topicDataMap", Object.fromEntries(cachedData.topicDataMap));
144
+ dirtyFlags.topicDataMap = false;
145
+ }
146
+
147
+ if (dirtyFlags.ReadList) {
148
+ flow.set("ReadList", Object.fromEntries(cachedData.ReadList));
149
+ dirtyFlags.ReadList = false;
150
+ }
151
+
152
+ if (dirtyFlags.entriesWithErrors) {
153
+ flow.set("entriesWithErrors", Object.fromEntries(cachedData.entriesWithErrors));
154
+ dirtyFlags.entriesWithErrors = false;
155
+ }
156
+
157
+ if (dirtyFlags.totalUniquePolledCount) {
158
+ flow.set("totalUniquePolledCount", cachedData.totalUniquePolledCount);
159
+ dirtyFlags.totalUniquePolledCount = false;
160
+ }
161
+
162
+ if (dirtyFlags.onlineCount) {
163
+ flow.set("onlineCount", cachedData.onlineCount);
164
+ dirtyFlags.onlineCount = false;
165
+ }
166
+
167
+ if (dirtyFlags.offlineCount) {
168
+ flow.set("offlineCount", cachedData.offlineCount);
169
+ dirtyFlags.offlineCount = false;
170
+ }
171
+
172
+ if (dirtyFlags.offlinePercentage) {
173
+ flow.set("offlinePercentage", cachedData.offlinePercentage);
174
+ dirtyFlags.offlinePercentage = false;
175
+ }
176
+
177
+ if (dirtyFlags.totalUniqueReadCount) {
178
+ flow.set("totalUniqueReadCount", cachedData.totalUniqueReadCount);
179
+ dirtyFlags.totalUniqueReadCount = false;
180
+ }
181
+
182
+ if (dirtyFlags.site_Name) {
183
+ flow.set("site_Name", cachedData.site_Name);
184
+ dirtyFlags.site_Name = false;
185
+ }
186
+ }
187
+
188
+ node.on("input", function (msg, send, done) {
189
+ if (msg.type === "getBacnetStats") {
190
+ processMessage(msg, send, done);
191
+ } else if (msg.type === "Read") {
192
+ calculateCombinedReadList(node, msg);
193
+ if (done) done();
194
+ } else if (msg.payload && msg.payload.error !== undefined && msg.payload.error !== "none") {
195
+ // bacnet error msg found
196
+ setErrorTopics(msg);
197
+ if (done) done();
198
+ } else if (msg.payload && msg.topic) {
199
+ //regular bacnet output
200
+ debouncedGetBacnetStats(node, context, msg);
201
+ // Queue the message for batch processing instead of immediate processing
202
+ messageQueue.push({ msg, send, done });
203
+ if (messageQueue.length >= MAX_BATCH_SIZE) {
204
+ processBatch();
205
+ }
206
+ } else if (msg.type === "sendMqttStats") {
207
+ // Make sure we have the latest statBlock values before sending stats
208
+ syncStatBlockWithWorkerResults().then(() => {
209
+ let statBlock = node.statBlock;
210
+ for (let key in statBlock) {
211
+ let value = statBlock[key];
212
+ let keyText = key.toUpperCase();
213
+ let newMsg = {
214
+ topic: `EDGE_DEVICE_${node.siteName}/BACNETSTATS/${keyText}`,
215
+ payload: value,
216
+ };
217
+ node.send(newMsg);
218
+ }
219
+ if (done) done();
220
+ });
221
+ } else if (msg.reset === true) {
222
+ node.status({ text: "Resetting..." });
223
+
224
+ // Reset the cached data
225
+ cachedData.uniqueTopics.clear();
226
+ cachedData.uniqueReadTopics.clear();
227
+ cachedData.topicStatusMap.clear();
228
+ cachedData.topicDataMap.clear();
229
+ cachedData.ReadList.clear();
230
+ cachedData.entriesWithErrors.clear();
231
+ cachedData.totalUniquePolledCount = 0;
232
+ cachedData.onlineCount = 0;
233
+ cachedData.offlineCount = 0;
234
+ cachedData.offlinePercentage = 0;
235
+ cachedData.totalUniqueReadCount = 0;
236
+
237
+ // Force immediate sync with flow context
238
+ Object.keys(dirtyFlags).forEach((key) => {
239
+ dirtyFlags[key] = true;
240
+ });
241
+ syncWithFlowContext();
242
+
243
+ setTimeout(() => {
244
+ node.status({ text: "" });
245
+ }, 2000);
246
+ if (done) done();
247
+ } else {
248
+ if (done) done();
249
+ }
250
+ });
251
+
252
+ // Process individual messages that shouldn't be batched
253
+ function processMessage(msg, send, done) {
254
+ try {
255
+ getBacnetStats(node, context, msg);
256
+ if (done) done();
257
+ } catch (error) {
258
+ console.error("Error processing message:", error);
259
+ if (done) done(error);
260
+ }
261
+ }
262
+
263
+ // Process a batch of messages efficiently
264
+ function processBatch() {
265
+ if (messageQueue.length === 0) return;
266
+
267
+ const batch = messageQueue.splice(0, MAX_BATCH_SIZE);
268
+ const combinedUpdates = {};
269
+
270
+ // Aggregate updates from batch by topic (keep only most recent message per topic)
271
+ batch.forEach(({ msg }) => {
272
+ const topic = msg.topic;
273
+ if (!combinedUpdates[topic]) combinedUpdates[topic] = msg;
274
+ else {
275
+ // Keep only the most recent message for each topic
276
+ // Check timestamp if available
277
+ if (msg.payload.timestamp > combinedUpdates[topic].payload.timestamp) {
278
+ combinedUpdates[topic] = msg;
279
+ }
280
+ }
281
+ });
282
+
283
+ // Process aggregated data once
284
+ processBatchData(Object.values(combinedUpdates));
285
+
286
+ // Complete all message callbacks
287
+ batch.forEach(({ done }) => done && done());
288
+ }
289
+
290
+ // Efficiently process a batch of aggregated data
291
+ function processBatchData(messages) {
292
+ if (messages.length === 0) return;
293
+
294
+ // Use cached data instead of flow context
295
+ const now = new Date();
296
+
297
+ // Process all messages in the batch
298
+ messages.forEach((msg) => {
299
+ const topic = msg.topic;
300
+ const status = msg.payload.status;
301
+ const error = msg.payload.error;
302
+ const presentValue = msg.payload.presentValue;
303
+ const timestamp = msg.payload.timestamp;
304
+
305
+ // Extract properties only if they exist
306
+ const deviceID = msg.payload.meta?.device?.deviceId;
307
+ const objectType = msg.payload.meta?.objectId?.type;
308
+ const objectInstance = msg.payload.meta?.objectId?.instance;
309
+ let pointName = msg.payload?.objectName;
310
+
311
+ if (pointName !== undefined) {
312
+ pointName = pointName + "_" + getObjectType(objectType) + "_" + objectInstance;
313
+ }
314
+
315
+ const displayName = msg.payload?.displayName;
316
+ const deviceName = msg.payload.meta?.device?.deviceName;
317
+
318
+ const ipAddress =
319
+ typeof msg.payload.meta?.device?.address === "object"
320
+ ? msg.payload.meta.device.address.address
321
+ : msg.payload.meta?.device?.address;
322
+
323
+ // Only proceed if the status key exists
324
+ if (status !== undefined) {
325
+ // Update site_Name in cache
326
+ cachedData.site_Name = node.siteName;
327
+ dirtyFlags.site_Name = true;
328
+
329
+ // Check if the topic is already in the unique topics list
330
+ if (!cachedData.uniqueTopics.has(topic)) {
331
+ cachedData.uniqueTopics.add(topic);
332
+ cachedData.totalUniquePolledCount++;
333
+ dirtyFlags.uniqueTopics = true;
334
+ dirtyFlags.totalUniquePolledCount = true;
335
+ }
336
+
337
+ // Update the status in the topicStatusMap
338
+ const oldStatus = cachedData.topicStatusMap.get(topic);
339
+ if (oldStatus !== status) {
340
+ // Adjust counts based on the previous status
341
+ if (oldStatus === "online") {
342
+ cachedData.onlineCount--;
343
+ dirtyFlags.onlineCount = true;
344
+ } else if (oldStatus === "offline") {
345
+ cachedData.offlineCount--;
346
+ dirtyFlags.offlineCount = true;
347
+ }
348
+
349
+ // Update with the new status
350
+ cachedData.topicStatusMap.set(topic, status);
351
+ dirtyFlags.topicStatusMap = true;
352
+
353
+ // Adjust counts based on the new status
354
+ if (status === "online") {
355
+ cachedData.onlineCount++;
356
+ dirtyFlags.onlineCount = true;
357
+ } else if (status === "offline") {
358
+ cachedData.offlineCount++;
359
+ dirtyFlags.offlineCount = true;
360
+ }
361
+ }
362
+
363
+ // Handle topicDataMap updates
364
+ let topicData = cachedData.topicDataMap.get(topic);
365
+ if (!topicData) {
366
+ // Create new entry
367
+ topicData = {
368
+ presentValue: presentValue,
369
+ status: status,
370
+ bacnetLastSeen: timestamp,
371
+ lastCOVTime: now.getTime(),
372
+ };
373
+
374
+ // Add properties conditionally if they exist or are explicitly set to 0
375
+ if (deviceID !== undefined) topicData.deviceID = deviceID;
376
+ if (objectType !== undefined) topicData.objectType = objectType;
377
+ if (objectInstance !== undefined) topicData.objectInstance = objectInstance;
378
+ if (pointName !== undefined) topicData.pointName = pointName;
379
+ if (displayName !== undefined) topicData.displayName = displayName;
380
+ if (deviceName !== undefined) topicData.deviceName = deviceName;
381
+ if (ipAddress !== undefined) topicData.ipAddress = ipAddress;
382
+ if (error !== undefined) topicData.error = error;
383
+ topicData.key = topicData.deviceID + ":" + topicData.objectType + ":" + topicData.objectInstance;
384
+
385
+ cachedData.topicDataMap.set(topic, topicData);
386
+ dirtyFlags.topicDataMap = true;
387
+ } else {
388
+ // Update existing entry
389
+ let entryChanged = false;
390
+
391
+ if (presentValue !== topicData.presentValue) {
392
+ topicData.presentValue = presentValue;
393
+ topicData.lastCOVTime = now.getTime();
394
+ entryChanged = true;
395
+ }
396
+
397
+ topicData.bacnetLastSeen = timestamp;
398
+ entryChanged = true;
399
+
400
+ // Update properties conditionally if they exist or are explicitly set to 0
401
+ if (deviceID !== undefined && topicData.deviceID !== deviceID) {
402
+ topicData.deviceID = deviceID;
403
+ entryChanged = true;
404
+ }
405
+ if (objectType !== undefined && topicData.objectType !== objectType) {
406
+ topicData.objectType = objectType;
407
+ entryChanged = true;
408
+ }
409
+ if (objectInstance !== undefined && topicData.objectInstance !== objectInstance) {
410
+ topicData.objectInstance = objectInstance;
411
+ entryChanged = true;
412
+ }
413
+ if (pointName !== undefined && topicData.pointName !== pointName) {
414
+ topicData.pointName = pointName;
415
+ entryChanged = true;
416
+ }
417
+ if (displayName !== undefined && topicData.displayName !== displayName) {
418
+ topicData.displayName = displayName;
419
+ entryChanged = true;
420
+ }
421
+ if (deviceName !== undefined && topicData.deviceName !== deviceName) {
422
+ topicData.deviceName = deviceName;
423
+ entryChanged = true;
424
+ }
425
+ if (ipAddress !== undefined && topicData.ipAddress !== ipAddress) {
426
+ topicData.ipAddress = ipAddress;
427
+ entryChanged = true;
428
+ }
429
+ if (error !== undefined && topicData.error !== error) {
430
+ topicData.error = error;
431
+ entryChanged = true;
432
+ }
433
+
434
+ if (entryChanged) {
435
+ topicData.key = topicData.deviceID + ":" + topicData.objectType + ":" + topicData.objectInstance;
436
+ dirtyFlags.topicDataMap = true;
437
+ }
438
+ }
439
+ }
440
+ });
441
+
442
+ // Update calculated statistics
443
+ if (dirtyFlags.onlineCount || dirtyFlags.offlineCount) {
444
+ const offlinePercentage = (cachedData.offlineCount / (cachedData.onlineCount + cachedData.offlineCount)) * 100;
445
+ node.statBlock.offlinePercentage = offlinePercentage;
446
+ cachedData.offlinePercentage = offlinePercentage;
447
+ dirtyFlags.offlinePercentage = true;
448
+ }
449
+
450
+ // Update the node status
451
+ node.status({ text: "Points Online: " + cachedData.onlineCount + "/" + cachedData.totalUniquePolledCount });
452
+ }
453
+
454
+ //API Request Handlers Start
455
+
456
+ // Serve custom HTML when "inspector" node is used
457
+ RED.httpAdmin.get("/inspector", function (req, res) {
458
+ const htmlPath = path.join(__dirname, "inspector.html"); // Path to your .html file
459
+ fs.readFile(htmlPath, "utf8", (err, data) => {
460
+ if (err) {
461
+ res.status(500).send("Error loading HTML file");
462
+ } else {
463
+ res.send(data); // Send the file contents as the response
464
+ }
465
+ });
466
+ });
467
+
468
+ //inspector page data handler
469
+ RED.httpAdmin.get("/getModelStats", async function (req, res) {
470
+ try {
471
+ let result = await getModelStatsData();
472
+
473
+ if (result) {
474
+ res.send(result);
475
+ } else {
476
+ res.status(400).send("Error getting data");
477
+ }
478
+ } catch (e) {
479
+ console.log("Error getting model stats: ", e);
480
+ res.status(400).send("Error getting data");
481
+ }
482
+ });
483
+
484
+ //get export read list
485
+ RED.httpAdmin.get("/pointstoread", async function (req, res) {
486
+ try {
487
+ let result = await exportTotalReadList();
488
+ if (result) {
489
+ res.set(result.headers);
490
+ res.send(result.payload);
491
+ } else {
492
+ res.status(400).send("Error getting read list");
493
+ }
494
+ } catch (e) {
495
+ console.log("Error getting read list: ", e);
496
+ res.status(400).send("Error getting read list");
497
+ }
498
+ });
499
+
500
+ //get point errors
501
+ RED.httpAdmin.get("/getpointerrors", async function (req, res) {
502
+ try {
503
+ let result = await getErrorTopics();
504
+ if (result) {
505
+ res.set(result.headers);
506
+ res.send(result.payload);
507
+ } else {
508
+ res.status(400).send("Error getting read list");
509
+ }
510
+ } catch (e) {
511
+ console.log("Error getting point errors: ", e);
512
+ res.status(400).send("Error getting point errors");
513
+ }
514
+ });
515
+
516
+ //get point errors
517
+ RED.httpAdmin.get("/getmodelstatscsv", async function (req, res) {
518
+ try {
519
+ let result = await getModelStatsData();
520
+ if (result.resultList) {
521
+ let csvResult = jsonToCsv(result.resultList);
522
+
523
+ if (csvResult) {
524
+ let headers = {
525
+ "Content-Disposition": 'attachment; filename="' + node.siteName + "_ModelStats_" + getCurrentTimestamp() + '.csv"',
526
+ "Content-Type": "text/csv",
527
+ };
528
+ res.set(headers);
529
+ res.send(csvResult);
530
+ } else {
531
+ res.status(400).send("Error getting read list");
532
+ }
533
+ }
534
+ } catch (e) {
535
+ console.log("Error getting model stats csv ", e);
536
+ res.status(400).send("Error getting model stats csv");
537
+ }
538
+ });
539
+
540
+ RED.httpAdmin.get("/publishedpointslist", async function (req, res) {
541
+ try {
542
+ let result = await getPublishedPointsList();
543
+ if (result.payload) {
544
+ res.set(result.headers);
545
+ res.send(result.payload);
546
+ } else {
547
+ res.status(400).send("Error getting published points list");
548
+ }
549
+ } catch (e) {
550
+ console.log("Error getting published points list: ", e);
551
+ res.status(400).send("Error getting published points list");
552
+ }
553
+ });
554
+
555
+ // HTTP endpoint to download the HTML directly
556
+ RED.httpAdmin.get("/inspector-downloadhtml", async function (req, res) {
557
+ try {
558
+ // Get filter parameters from query string
559
+ const filterKey = req.query.filter;
560
+ const filterValue = req.query.value;
561
+
562
+ // Get app data from your data source with optional filtering
563
+ const appData = await getModelStatsSSRData(filterKey, filterValue);
564
+
565
+ // Generate HTML
566
+ const html = await generatePrimeVueAppHtmlStatic(appData, null, {
567
+ title: "BACnet Inspector Export",
568
+ logoBase64: await getLogoAsBase64(path.join(__dirname, "/resources/Logo_Simplified_Positive.svg")),
569
+ });
570
+
571
+ // Set headers and send response
572
+ res.setHeader("Content-Type", "text/html");
573
+ res.setHeader("Content-Disposition", `attachment; filename="bacnet-inspector-${Date.now()}.html"`);
574
+ res.send(html);
575
+ } catch (error) {
576
+ console.error("Error generating export:", error);
577
+ res.status(500).send("Error generating export");
578
+ }
579
+ });
580
+
581
+ // API Request Handlers End
582
+
583
+ // Worker functions
584
+
585
+ function setErrorTopics(msg) {
586
+ let topic = msg.topic;
587
+ let error = msg.payload.error;
588
+
589
+ // Extract properties only if they exist
590
+ let deviceID = msg.payload.meta?.device?.deviceId;
591
+ let objectType = msg.payload.meta?.objectId?.type;
592
+ let objectInstance = msg.payload.meta?.objectId?.instance;
593
+ let pointName = msg.payload?.objectName;
594
+ let displayName = msg.payload?.displayName;
595
+ let deviceName = msg.payload.meta?.device?.deviceName;
596
+
597
+ let ipAddress =
598
+ typeof msg.payload.meta?.device?.address === "object"
599
+ ? msg.payload.meta.device.address.address
600
+ : msg.payload.meta?.device?.address;
601
+
602
+ if (error !== undefined && error !== "none") {
603
+ // Use the cache instead of direct flow context access
604
+ cachedData.entriesWithErrors.set(topic, {
605
+ deviceID: deviceID,
606
+ objectType: objectType,
607
+ objectInstance: objectInstance,
608
+ pointName: pointName,
609
+ displayName: displayName,
610
+ deviceName: deviceName,
611
+ ipAddress: ipAddress,
612
+ error: error,
613
+ });
614
+
615
+ // Mark as dirty so it will be synced to flow context
616
+ dirtyFlags.entriesWithErrors = true;
617
+ }
618
+ }
619
+
620
+ function getErrorTopics() {
621
+ let csvOutput = "topic,deviceID,objectType,objectInstance,pointName,displayName,deviceName,ipAddress,error\n";
622
+ let msg = {};
623
+
624
+ // Use the cached entries with errors instead of accessing flow context
625
+ for (const [entry, errorData] of cachedData.entriesWithErrors.entries()) {
626
+ csvOutput =
627
+ csvOutput +
628
+ entry +
629
+ "," +
630
+ errorData.deviceID +
631
+ "," +
632
+ errorData.objectType +
633
+ "," +
634
+ errorData.objectInstance +
635
+ "," +
636
+ errorData.pointName +
637
+ "," +
638
+ errorData.displayName +
639
+ "," +
640
+ errorData.deviceName +
641
+ "," +
642
+ errorData.ipAddress +
643
+ "," +
644
+ errorData.error +
645
+ "\n";
646
+ }
647
+
648
+ msg.headers = {
649
+ "Content-Disposition": 'attachment; filename="' + node.siteName + "_PointErrors_" + getCurrentTimestamp() + '.csv"',
650
+ "Content-Type": "text/csv",
651
+ };
652
+
653
+ msg.payload = csvOutput;
654
+ msg.topic = "csvOutput";
655
+ return msg;
656
+ }
657
+
658
+ //formats a csv data structure for api request of the read list
659
+ function exportTotalReadList() {
660
+ // // NOTE: You must do a full pull of the site (preferably after a reset of the read stats)
661
+ // // to ensure the context is built out correctly and has the correct data before exporting.
662
+ let flow = context.flow;
663
+ let Read_Data = flow.get("ReadList") || {};
664
+ let csvOutputStr = "ipAddress,deviceId,deviceName,pointName,objectType,displayName,area,full topic,objectInstance,key\n";
665
+ let msg = {};
666
+
667
+ for (let topic in Read_Data) {
668
+ // Loop through the topic data map
669
+
670
+ csvOutputStr =
671
+ csvOutputStr +
672
+ (Read_Data[topic].ipAddress || "") +
673
+ "," +
674
+ (Read_Data[topic].deviceID || "") +
675
+ "," +
676
+ (Read_Data[topic].deviceName || "") +
677
+ "," +
678
+ (Read_Data[topic].pointName || "") +
679
+ "," +
680
+ (Read_Data[topic].objectType === 0 ? "0" : Read_Data[topic].objectType || "") +
681
+ "," + // Ensure "0" is output if objectType is 0
682
+ (Read_Data[topic].displayName || "") +
683
+ "," +
684
+ (Read_Data[topic].area || "") +
685
+ "," + // area
686
+ topic +
687
+ "," +
688
+ (Read_Data[topic].objectInstance || "") +
689
+ "," +
690
+ (Read_Data[topic].key || "") +
691
+ "\n";
692
+ }
693
+
694
+ let site_Name = flow.get("site_Name") || "";
695
+ msg.payload = csvOutputStr;
696
+ msg.headers = {
697
+ "Content-Disposition": 'attachment; filename="' + site_Name + "_PointsToRead_" + getCurrentTimestamp() + '.csv"',
698
+ "Content-Type": "text/csv",
699
+ };
700
+ return msg;
701
+ }
702
+
703
+ // calculates combined read lists that are linked to the inspector node. Sets to flow context and node status
704
+ function calculateCombinedReadList(node, msg) {
705
+ // Use Set directly from cache for better performance
706
+ let topicSet = cachedData.uniqueReadTopics;
707
+ let pointsToRead = msg.options.pointsToRead;
708
+ let readNodeName = msg.readNodeName;
709
+
710
+ var device_info = "";
711
+ var device_IP = "";
712
+ var device_ID = "";
713
+ var device_Name = "";
714
+ var display_Name = "";
715
+ var point_Name = "";
716
+ var area = "";
717
+ var object_Type = -1;
718
+ var object_Instance = -1;
719
+
720
+ // Loop through the pointsToRead section
721
+ for (let deviceKey in pointsToRead) {
722
+ // Loop through each device
723
+ let device = pointsToRead[deviceKey]; // Get the device object
724
+
725
+ device_info = deviceKey.split("-");
726
+ device_IP = device_info[0];
727
+ device_ID = device_info[1];
728
+ device_Name = device.deviceName;
729
+
730
+ area = readNodeName;
731
+
732
+ // Loop through each point in the device
733
+ for (let pointKey in device) {
734
+ if (device[pointKey].displayName) {
735
+ display_Name = device[pointKey].displayName;
736
+ point_Name = device[pointKey].objectName;
737
+ object_Type = device[pointKey].meta.objectId.type;
738
+ object_Instance = device[pointKey].meta.objectId.instance;
739
+
740
+ // Calculate the topic
741
+ var topic = readNodeName + "/" + display_Name;
742
+
743
+ // Get existing entry or create new one
744
+ let topicData = cachedData.ReadList.get(topic) || {};
745
+
746
+ // Update properties conditionally if they exist or are explicitly set to 0
747
+ if (device_ID !== undefined) topicData.deviceID = device_ID;
748
+ if (object_Type !== undefined) topicData.objectType = object_Type;
749
+ if (object_Instance !== undefined) topicData.objectInstance = object_Instance;
750
+ if (point_Name !== undefined) topicData.pointName = point_Name;
751
+ if (display_Name !== undefined) topicData.displayName = display_Name;
752
+ if (device_Name !== undefined) topicData.deviceName = device_Name;
753
+ if (device_IP !== undefined) topicData.ipAddress = device_IP;
754
+ if (area !== undefined) topicData.area = area;
755
+ topicData.key = device_ID + ":" + object_Type + ":" + object_Instance;
756
+
757
+ // Update the cache
758
+ cachedData.ReadList.set(topic, topicData);
759
+ dirtyFlags.ReadList = true;
760
+
761
+ // Add the topic to the set if it's not already present
762
+ if (!topicSet.has(topic)) {
763
+ topicSet.add(topic);
764
+ cachedData.totalUniqueReadCount++;
765
+ dirtyFlags.uniqueReadTopics = true;
766
+ dirtyFlags.totalUniqueReadCount = true;
767
+ }
768
+ }
769
+ }
770
+ }
771
+
772
+ // Update the node property
773
+ node.totalUniqueReadCount = cachedData.totalUniqueReadCount;
774
+
775
+ // Force sync with flow context to ensure data is immediately available
776
+ syncWithFlowContext();
777
+
778
+ // Update the node status
779
+ node.status({ text: "Points To Read: " + cachedData.totalUniqueReadCount });
780
+ }
781
+
782
+ function getPublishedPointsList() {
783
+ node.warn("Generating Published Points List...");
784
+ let flow = context.flow;
785
+ let now = new Date();
786
+
787
+ let topicDataMap = flow.get("topicDataMap") || {}; // Store presentValue, timestamp, and last value change time per topic
788
+ let totalUniqueCount = flow.get("totalUniquePolledCount") || 0;
789
+ let onlineCount = flow.get("onlineCount") || 0;
790
+ let offlineCount = flow.get("offlineCount") || 0;
791
+ let site_Name = node.siteName;
792
+ let IP_Add = getIPAddresses(); // Get the IP address of the current device
793
+ let IP_Str = "NA";
794
+ if (IP_Add[0]) {
795
+ IP_Str = IP_Add[0].replace(/\./g, "");
796
+ }
797
+
798
+ // Calculate the average timeSinceCOVSec across all unique topics
799
+ let totalCOVTime = 0;
800
+ let topicCount = 0;
801
+ let csvOutputStr =
802
+ "ipAddress,deviceId,deviceName,pointName,objectType,displayName,area,full topic,preset value,status,bacnet last seen,lastCovTime (UTC),timeSinceCov (Seconds),timeSinceCov,objectInstance,key\n";
803
+
804
+ for (let topic in topicDataMap) {
805
+ // Loop through the topic data map
806
+
807
+ let timeDiffMs = now.getTime() - topicDataMap[topic].lastCOVTime; // Calculate the time since last value change
808
+ let hours = Math.floor(timeDiffMs / (1000 * 60 * 60)); // Calculate hours value
809
+ let minutes = Math.floor((timeDiffMs % (1000 * 60 * 60)) / (1000 * 60)); // Calculate minutes value
810
+ let seconds = Math.floor((timeDiffMs % (1000 * 60)) / 1000); // Calculate seconds value
811
+
812
+ topicDataMap[topic].timeSinceCOVSec = timeDiffMs / 1000; // Output the time difference as seconds
813
+ topicDataMap[topic].timeSinceCOVFormatted = `${hours}hrs ${minutes}min ${seconds}sec`; // Output as hours, minutes and seconds
814
+
815
+ if (topicDataMap[topic].timeSinceCOVSec !== undefined) {
816
+ totalCOVTime += topicDataMap[topic].timeSinceCOVSec;
817
+ topicCount++;
818
+ }
819
+
820
+ // Calculate the area
821
+ var area = removeFirstTwoTopicLevels(topic); // Remove the first two levels from the full topic
822
+ area = area.replace(topicDataMap[topic].displayName || "", ""); // Remove the display name
823
+ area = removeTrailingSlash(area); // Remove the trailing slash
824
+
825
+ csvOutputStr =
826
+ csvOutputStr +
827
+ (topicDataMap[topic].ipAddress || "") +
828
+ "," +
829
+ (topicDataMap[topic].deviceID || "") +
830
+ "," +
831
+ (topicDataMap[topic].deviceName || "") +
832
+ "," +
833
+ (topicDataMap[topic].pointName || "") +
834
+ "," +
835
+ (topicDataMap[topic].objectType === 0 ? "0" : topicDataMap[topic].objectType || "") +
836
+ "," + // Ensure "0" is output if objectType is 0
837
+ (topicDataMap[topic].displayName || "") +
838
+ "," +
839
+ area +
840
+ "," + // area
841
+ topic +
842
+ "," +
843
+ topicDataMap[topic].presentValue +
844
+ "," +
845
+ topicDataMap[topic].status +
846
+ "," +
847
+ new Date(topicDataMap[topic].bacnetLastSeen).toLocaleString().replace(",", "") +
848
+ "," +
849
+ new Date(topicDataMap[topic].lastCOVTime).toLocaleString().replace(",", "") +
850
+ "," +
851
+ topicDataMap[topic].timeSinceCOVSec +
852
+ "," +
853
+ topicDataMap[topic].timeSinceCOVFormatted +
854
+ "," +
855
+ (topicDataMap[topic].objectInstance || "") +
856
+ "," +
857
+ topicDataMap[topic].key +
858
+ "\n";
859
+ }
860
+
861
+ let averageCOVSec = topicCount > 0 ? totalCOVTime / topicCount : 0; // Avoid division by zero
862
+ let newMsg = { payload: "" };
863
+
864
+ // Set up the tagging
865
+ const baseMsg = {
866
+ PoolTags: "geoAddr=" + site_Name,
867
+ meta: "geoAddr=" + site_Name
868
+ };
869
+
870
+ const lastPointPushedTime = flow.get("Last_Point_Pushed_Time") || "UNKNOWN";
871
+
872
+ newMsg.topic = "EDGE_DEVICE_" + IP_Str + "/STATUS/LAST_POINT_PUSHED_TIME";
873
+ newMsg.payload = lastPointPushedTime;
874
+ node.send([Object.assign({}, baseMsg, newMsg)]);
875
+
876
+ newMsg.topic = "EDGE_DEVICE_" + IP_Str + "/STATUS/LAST_STAT_CALC_TIME";
877
+ newMsg.payload = now.toISOString();
878
+ node.send([Object.assign({}, baseMsg, newMsg)]);
879
+
880
+ newMsg.topic = "EDGE_DEVICE_" + IP_Str + "/STATUS/UPTIME";
881
+ newMsg.payload = getUptime();
882
+ node.send([Object.assign({}, baseMsg, newMsg)]);
883
+
884
+
885
+ newMsg.topic = "EDGE_DEVICE_" + IP_Str + "/STATUS/ONLINE_POINTS";
886
+ newMsg.payload = onlineCount;
887
+ node.send([Object.assign({}, baseMsg, newMsg)]);
888
+
889
+ newMsg.topic = "EDGE_DEVICE_" + IP_Str + "/STATUS/OFFLINE_POINTS";
890
+ newMsg.payload = offlineCount;
891
+ node.send([Object.assign({}, baseMsg, newMsg)]);
892
+
893
+ newMsg.topic = "EDGE_DEVICE_" + IP_Str + "/STATUS/TOTAL_POLLED_POINTS";
894
+ newMsg.payload = totalUniqueCount;
895
+ node.send([Object.assign({}, baseMsg, newMsg)]);
896
+
897
+ newMsg.topic = "EDGE_DEVICE_" + IP_Str + "/STATUS/AVERAGE_TIME_SINCE_COV_IN_SECONDS";
898
+ newMsg.payload = averageCOVSec;
899
+ node.send([Object.assign({}, baseMsg, newMsg)]);
900
+
901
+ let totalUniqueReadCount = flow.get("totalUniqueReadCount") || 0;
902
+ newMsg.topic = "EDGE_DEVICE_" + IP_Str + "/STATUS/TOTAL_POINTS_TO_READ";
903
+ newMsg.payload = totalUniqueReadCount;
904
+ node.send([Object.assign({}, baseMsg, newMsg)]);
905
+
906
+ let DiscoveryPointCount = flow.get("discoveryPointCount") || 0;
907
+
908
+ newMsg.topic = "EDGE_DEVICE_" + IP_Str + "/STATUS/DISCOVERED_POINT_COUNT";
909
+ newMsg.payload = DiscoveryPointCount;
910
+ node.send([Object.assign({}, baseMsg, newMsg)]);
911
+
912
+ let DiscoveryDeviceCount = flow.get("discoveryDeviceCount") || 0;
913
+
914
+ newMsg.topic = "EDGE_DEVICE_" + IP_Str + "/STATUS/DISCOVERED_DEVICE_COUNT";
915
+ newMsg.payload = DiscoveryDeviceCount;
916
+ node.send([Object.assign({}, baseMsg, newMsg)]);
917
+
918
+ let msg = {};
919
+ msg.payload = csvOutputStr;
920
+ msg.headers = {
921
+ "Content-Disposition": 'attachment; filename="' + site_Name + "_PublishedPointsList_" + getCurrentTimestamp() + '.csv"',
922
+ "Content-Type": "text/csv",
923
+ };
924
+
925
+ return msg;
926
+ }
927
+
928
+ //calculates bacnet stats on correctly injected msg to inspector node
929
+ //fired on every valid bacnet output
930
+ function getBacnetStats(node, context, msg) {
931
+ let flow = context.flow;
932
+ let now = new Date(); // Create a new Date object
933
+
934
+ // Initialize or retrieve the current list of unique topics and the count from the context context
935
+ let uniqueTopics = flow.get("uniqueReadTopics") || [];
936
+ let topicStatusMap = flow.get("topicStatusMap") || {}; // Store status per topic
937
+ let topicDataMap = flow.get("topicDataMap") || {}; // Store presentValue, timestamp, and last value change time per topic
938
+ let totalUniqueCount = flow.get("totalUniquePolledCount") || 0;
939
+ let onlineCount = flow.get("onlineCount") || 0;
940
+ let offlineCount = flow.get("offlineCount") || 0;
941
+
942
+ flow.set("site_Name", node.siteName);
943
+
944
+ // Calculate the IP address of the edge device
945
+ var IP_Add = getIPAddresses(); // Get the IP address of the current device
946
+ var IP_Str = "NA";
947
+ if (IP_Add[0]) {
948
+ IP_Str = IP_Add[0].replace(/\./g, "");
949
+ } // If an IP address of the first network adapter exists remove the full stops
950
+
951
+ // Get the current message's topic, status, presentValue, and timestamp
952
+ let topic = msg.topic;
953
+ let status = msg.payload.status;
954
+ let error = msg.payload.error;
955
+ let presentValue = msg.payload.presentValue;
956
+ let timestamp = msg.payload.timestamp;
957
+
958
+ // Extract properties only if they exist
959
+ let deviceID = msg.payload.meta?.device?.deviceId;
960
+ let objectType = msg.payload.meta?.objectId?.type;
961
+ let objectInstance = msg.payload.meta?.objectId?.instance;
962
+ let pointName = msg.payload?.objectName;
963
+
964
+ if (pointName !== undefined) {
965
+ pointName = pointName + "_" + getObjectType(objectType) + "_" + objectInstance;
966
+ }
967
+
968
+ let displayName = msg.payload?.displayName;
969
+ let deviceName = msg.payload.meta?.device?.deviceName;
970
+
971
+ let ipAddress =
972
+ typeof msg.payload.meta?.device?.address === "object"
973
+ ? msg.payload.meta.device.address.address
974
+ : msg.payload.meta?.device?.address;
975
+
976
+ // Only proceed if the status key exists
977
+ if (status !== undefined) {
978
+ // Check if the topic is already in the unique topics list
979
+ if (!uniqueTopics.includes(topic)) {
980
+ uniqueTopics.push(topic);
981
+ totalUniqueCount++;
982
+ }
983
+
984
+ // Update the status in the topicStatusMap
985
+ if (topicStatusMap[topic] !== status) {
986
+ // Adjust counts based on the previous status
987
+ if (topicStatusMap[topic] === "online") {
988
+ onlineCount--;
989
+ } else if (topicStatusMap[topic] === "offline") {
990
+ offlineCount--;
991
+ }
992
+
993
+ // Update with the new status
994
+ topicStatusMap[topic] = status;
995
+
996
+ // Adjust counts based on the new status
997
+ if (status === "online") {
998
+ onlineCount++;
999
+ } else if (status === "offline") {
1000
+ offlineCount++;
1001
+ }
1002
+ }
1003
+
1004
+ // Create an entry in the data map if it doesn't already exist
1005
+ if (!topicDataMap[topic]) {
1006
+ topicDataMap[topic] = {
1007
+ presentValue: presentValue,
1008
+ status: status,
1009
+ bacnetLastSeen: timestamp,
1010
+ lastCOVTime: now.getTime(), // Initialize last value change time
1011
+ };
1012
+
1013
+ // Add properties conditionally if they exist or are explicitly set to 0
1014
+ if (deviceID !== undefined) topicDataMap[topic].deviceID = deviceID;
1015
+ if (objectType !== undefined) topicDataMap[topic].objectType = objectType;
1016
+ if (objectInstance !== undefined) topicDataMap[topic].objectInstance = objectInstance;
1017
+ if (pointName !== undefined) topicDataMap[topic].pointName = pointName;
1018
+ if (displayName !== undefined) topicDataMap[topic].displayName = displayName;
1019
+ if (deviceName !== undefined) topicDataMap[topic].deviceName = deviceName;
1020
+ if (ipAddress !== undefined) topicDataMap[topic].ipAddress = ipAddress;
1021
+ if (error !== undefined) topicDataMap[topic].error = error;
1022
+ topicDataMap[topic].key =
1023
+ topicDataMap[topic].deviceID + ":" + topicDataMap[topic].objectType + ":" + topicDataMap[topic].objectInstance;
1024
+ } else {
1025
+ // If the entry already exists
1026
+
1027
+ if (presentValue != topicDataMap[topic].presentValue) {
1028
+ // If the present value has changed
1029
+ topicDataMap[topic].lastCOVTime = now.getTime(); // Update last value change time
1030
+ }
1031
+
1032
+ topicDataMap[topic].presentValue = presentValue; // Update the existing timestamp and present value
1033
+ topicDataMap[topic].bacnetLastSeen = timestamp; // Update timestamp
1034
+
1035
+ // Update properties conditionally if they exist or are explicitly set to 0
1036
+ if (deviceID !== undefined) topicDataMap[topic].deviceID = deviceID;
1037
+ if (objectType !== undefined) topicDataMap[topic].objectType = objectType;
1038
+ if (objectInstance !== undefined) topicDataMap[topic].objectInstance = objectInstance;
1039
+ if (pointName !== undefined) topicDataMap[topic].pointName = pointName;
1040
+ if (displayName !== undefined) topicDataMap[topic].displayName = displayName;
1041
+ if (deviceName !== undefined) topicDataMap[topic].deviceName = deviceName;
1042
+ if (ipAddress !== undefined) topicDataMap[topic].ipAddress = ipAddress;
1043
+ if (error !== undefined) topicDataMap[topic].error = error;
1044
+ topicDataMap[topic].key =
1045
+ topicDataMap[topic].deviceID + ":" + topicDataMap[topic].objectType + ":" + topicDataMap[topic].objectInstance;
1046
+ }
1047
+ } else {
1048
+ node.warn("Status key is missing for topic: " + topic);
1049
+ }
1050
+
1051
+ let offlinePercentage = (offlineCount / (onlineCount + offlineCount)) * 100;
1052
+
1053
+ node.statBlock.offlinePercentage = offlinePercentage;
1054
+
1055
+ // Store the updated unique topics, status map, topic data map, and counts back in the context context
1056
+ flow.set("uniqueTopics", uniqueTopics);
1057
+ flow.set("topicStatusMap", topicStatusMap);
1058
+ flow.set("topicDataMap", topicDataMap);
1059
+ flow.set("totalUniquePolledCount", totalUniqueCount);
1060
+ flow.set("onlineCount", onlineCount);
1061
+ flow.set("offlineCount", offlineCount);
1062
+ flow.set("offlinePercentage", offlinePercentage);
1063
+
1064
+ // Update the node status
1065
+ node.status({ text: "Points Online: " + onlineCount + "/" + totalUniqueCount });
1066
+ }
1067
+
1068
+ function makeGetRequest(route) {
1069
+ return new Promise((resolve, reject) => {
1070
+ try {
1071
+ const httpModule = RED.settings.https ? require("https") : require("http");
1072
+ let host = RED.settings.uiHost || "localhost";
1073
+ if (host === "0.0.0.0") host = "localhost";
1074
+ const port = RED.settings.uiPort || 1880;
1075
+ const url = `${RED.settings.https ? "https" : "http"}://${host}:${port}${route}`;
1076
+
1077
+ httpModule
1078
+ .get(url, (res) => {
1079
+ let data = "";
1080
+ // A chunk of data has been received
1081
+ res.on("data", (chunk) => {
1082
+ data += chunk;
1083
+ });
1084
+
1085
+ // The whole response has been received
1086
+ res.on("end", () => {
1087
+ try {
1088
+ const parsedData = JSON.parse(data);
1089
+ resolve(parsedData);
1090
+ } catch (e) {
1091
+ resolve(data);
1092
+ }
1093
+ });
1094
+ })
1095
+ .on("error", (err) => {
1096
+ reject(err);
1097
+ });
1098
+ } catch (e) {
1099
+ reject(err);
1100
+ }
1101
+ });
1102
+ }
1103
+
1104
+ async function parseBacnetInfo() {
1105
+ let flow = context.flow;
1106
+ try {
1107
+ const data = await makeGetRequest("/bitpool-bacnet-data/getNetworkTree");
1108
+ if (data) {
1109
+ const devices = Object.keys(data.pointList);
1110
+ let pointCount = 0;
1111
+ for (const guid of devices) {
1112
+ const deviceObject = data.pointList[guid];
1113
+ const points = Object.keys(deviceObject);
1114
+ pointCount = pointCount + points.length;
1115
+ }
1116
+
1117
+ flow.set("discoveryPointCount", pointCount);
1118
+ flow.set("discoveryDeviceCount", devices.length);
1119
+ flow.set("discoveryList", data.pointList);
1120
+ }
1121
+ } catch (error) {
1122
+ //console.error("Error:", error);
1123
+ }
1124
+ }
1125
+
1126
+ // Worker thread resource management
1127
+ let activeWorker = null;
1128
+ let isWorkerBusy = false;
1129
+ let workerQueue = [];
1130
+
1131
+ // Helper function to run tasks with the worker
1132
+ async function runWithWorker(task) {
1133
+ // If there's already a task running, queue this one
1134
+ if (isWorkerBusy) {
1135
+ return new Promise((resolve, reject) => {
1136
+ workerQueue.push({ task, resolve, reject });
1137
+ });
1138
+ }
1139
+
1140
+ isWorkerBusy = true;
1141
+
1142
+ try {
1143
+ // Create worker if needed
1144
+ if (!activeWorker) {
1145
+ activeWorker = new Worker(path.join(__dirname, "bacnet_inspector_worker.js"));
1146
+ }
1147
+
1148
+ // Set up promise for worker response
1149
+ const workerPromise = new Promise((resolve, reject) => {
1150
+ const messageHandler = (data) => {
1151
+ activeWorker.removeListener("error", errorHandler);
1152
+ activeWorker.removeListener("exit", exitHandler);
1153
+ resolve(data);
1154
+ };
1155
+
1156
+ const errorHandler = (error) => {
1157
+ activeWorker.removeListener("message", messageHandler);
1158
+ activeWorker.removeListener("exit", exitHandler);
1159
+ reject(error);
1160
+ };
1161
+
1162
+ const exitHandler = (code) => {
1163
+ activeWorker.removeListener("message", messageHandler);
1164
+ activeWorker.removeListener("error", errorHandler);
1165
+ if (code !== 0) {
1166
+ reject(new Error(`Worker stopped with exit code ${code}`));
1167
+ }
1168
+ };
1169
+
1170
+ activeWorker.once("message", messageHandler);
1171
+ activeWorker.once("error", errorHandler);
1172
+ activeWorker.once("exit", exitHandler);
1173
+
1174
+ // Send the task data to the worker
1175
+ activeWorker.postMessage(task);
1176
+ });
1177
+
1178
+ // Add timeout protection
1179
+ const result = await Promise.race([
1180
+ workerPromise,
1181
+ new Promise((_, reject) => setTimeout(() => reject(new Error("Worker timeout")), 30000)),
1182
+ ]);
1183
+
1184
+ return result;
1185
+ } catch (error) {
1186
+ throw error;
1187
+ } finally {
1188
+ isWorkerBusy = false;
1189
+
1190
+ // Process next task in queue if any
1191
+ if (workerQueue.length > 0) {
1192
+ const nextTask = workerQueue.shift();
1193
+ runWithWorker(nextTask.task).then(nextTask.resolve).catch(nextTask.reject);
1194
+ } else if (activeWorker) {
1195
+ // If queue is empty, terminate worker after a delay to allow for quickly successive requests
1196
+ setTimeout(() => {
1197
+ if (!isWorkerBusy && activeWorker) {
1198
+ activeWorker.terminate().catch((err) => {
1199
+ console.error("Error terminating worker:", err);
1200
+ });
1201
+ activeWorker = null;
1202
+ }
1203
+ }, 5000); // Keep worker alive for 5 seconds in case of another request
1204
+ }
1205
+ }
1206
+ }
1207
+
1208
+ // used by inspector custom ui page to get all necessary data
1209
+ async function getModelStats() {
1210
+ let flow = context.flow;
1211
+
1212
+ try {
1213
+ await parseBacnetInfo();
1214
+
1215
+ let ReadList = flow.get("ReadList") || {};
1216
+ let DiscoveryList = flow.get("discoveryList") || {};
1217
+ let PublishList = flow.get("topicDataMap") || {};
1218
+ let statBlock = node.statBlock;
1219
+
1220
+ // Use the worker pool instead of creating a new worker each time
1221
+ const result = await runWithWorker({
1222
+ ReadList,
1223
+ DiscoveryList,
1224
+ PublishList,
1225
+ statBlock,
1226
+ });
1227
+
1228
+ // Update the node with the results
1229
+ flow.set("discoveryList", DiscoveryList);
1230
+ flow.set("topicDataMap", PublishList);
1231
+ result.stat_counts.statBlock = result.statBlock;
1232
+ node.resultList = result.ResultList;
1233
+ node.statCounts = result.stat_counts;
1234
+ } catch (e) {
1235
+ console.log("getModelStats error: ", e);
1236
+ }
1237
+ }
1238
+
1239
+ function getModelStatsData() {
1240
+ return { siteName: node.siteName, resultList: node.resultList, statCounts: node.statCounts };
1241
+ }
1242
+
1243
+ async function getModelStatsSSRData(filterKey = null, filterValue = null) {
1244
+ try {
1245
+ // Get the base data
1246
+ const baseData = await getModelStatsData();
1247
+
1248
+ // If we don't have data yet or need to filter, use the worker
1249
+ if (!baseData.resultList || Object.keys(baseData.resultList).length === 0 || (filterKey && filterValue)) {
1250
+ const flow = context.flow;
1251
+
1252
+ // First ensure we have discovery info
1253
+ await parseBacnetInfo();
1254
+
1255
+ // Get the raw data
1256
+ const ReadList = flow.get("ReadList") || {};
1257
+ const DiscoveryList = flow.get("discoveryList") || {};
1258
+ const PublishList = flow.get("topicDataMap") || {};
1259
+ const statBlock = { ...node.statBlock };
1260
+
1261
+ // Process with worker
1262
+ const result = await runWithWorker({
1263
+ ReadList,
1264
+ DiscoveryList,
1265
+ PublishList,
1266
+ statBlock,
1267
+ filterKey,
1268
+ filterValue,
1269
+ });
1270
+
1271
+ // For SSR data we just need the filtered table data
1272
+ let tableData = Object.values(result.ResultList || {});
1273
+
1274
+ // Create a complete statCounts object with all properties
1275
+ const completeStatCounts = {
1276
+ ...result.stat_counts,
1277
+ statBlock: result.statBlock || {}
1278
+ };
1279
+
1280
+ return {
1281
+ tableData: tableData,
1282
+ siteName: baseData.siteName || "Unknown Site",
1283
+ statCounts: completeStatCounts,
1284
+ };
1285
+ }
1286
+
1287
+ // If no filter is applied and we have cached data, use that
1288
+ if (!filterKey || !filterValue) {
1289
+ // Ensure the statCounts has all required properties
1290
+ const completeStatCounts = {
1291
+ ...baseData.statCounts,
1292
+ statBlock: baseData.statCounts?.statBlock || node.statBlock || {}
1293
+ };
1294
+
1295
+ return {
1296
+ tableData: Object.values(baseData.resultList),
1297
+ siteName: baseData.siteName || "Unknown Site",
1298
+ statCounts: completeStatCounts,
1299
+ };
1300
+ }
1301
+
1302
+ // Apply filtering to cached data
1303
+ let tableData = [];
1304
+ try {
1305
+ // Convert resultList to array and apply filtering
1306
+ tableData = Object.values(baseData.resultList).filter((item) => {
1307
+ if (!filterKey || !filterValue) return true;
1308
+
1309
+ try {
1310
+ // Handle nested properties using dot notation
1311
+ const value = filterKey.split(".").reduce((obj, key) => {
1312
+ if (obj === null || obj === undefined) return undefined;
1313
+ return obj[key];
1314
+ }, item);
1315
+
1316
+ // Handle undefined/null values
1317
+ if (value === undefined || value === null) {
1318
+ return false;
1319
+ }
1320
+
1321
+ // Split filter values by comma and trim whitespace
1322
+ const filterValues = filterValue.split(",").map((v) => v.trim());
1323
+
1324
+ // Case-insensitive string comparison for string values
1325
+ if (typeof value === "string") {
1326
+ const valueLower = value.toLowerCase();
1327
+ return filterValues.some((filterVal) => valueLower.includes(filterVal.toLowerCase()));
1328
+ }
1329
+
1330
+ // Direct comparison for non-string values
1331
+ return filterValues.some((filterVal) => {
1332
+ // Try to convert filterVal to the same type as value for comparison
1333
+ const convertedFilterVal = typeof value === "number" ? Number(filterVal) : filterVal;
1334
+ return value === convertedFilterVal;
1335
+ });
1336
+ } catch (filterError) {
1337
+ console.error("Error applying filter:", filterError);
1338
+ return false; // Skip items that cause filter errors
1339
+ }
1340
+ });
1341
+ } catch (filterError) {
1342
+ console.error("Error processing table data:", filterError);
1343
+ // If filtering fails, return all data
1344
+ tableData = Object.values(baseData.resultList);
1345
+ }
1346
+
1347
+ // Create a complete statCounts object with all properties
1348
+ const completeStatCounts = {
1349
+ ...result.stat_counts,
1350
+ statBlock: result.statBlock || {}
1351
+ };
1352
+
1353
+ return {
1354
+ tableData: tableData,
1355
+ siteName: baseData.siteName || "Unknown Site",
1356
+ statCounts: completeStatCounts,
1357
+ };
1358
+ } catch (error) {
1359
+ console.error("Error in getModelStatsSSRData:", error);
1360
+
1361
+ // Return a safe default object in case of errors
1362
+ return {
1363
+ tableData: [],
1364
+ siteName: "Unknown Site",
1365
+ statCounts: {},
1366
+ };
1367
+ }
1368
+ }
1369
+
1370
+ // Start Common Functions
1371
+
1372
+ function getCurrentTimestamp() {
1373
+ const now = new Date();
1374
+ const year = now.getFullYear();
1375
+ const month = String(now.getMonth() + 1).padStart(2, "0"); // Months are 0-indexed
1376
+ const day = String(now.getDate()).padStart(2, "0");
1377
+ const hours = String(now.getHours()).padStart(2, "0");
1378
+ const minutes = String(now.getMinutes()).padStart(2, "0");
1379
+ const seconds = String(now.getSeconds()).padStart(2, "0");
1380
+
1381
+ return `${year}${month}${day}_${hours}${minutes}${seconds}`;
1382
+ }
1383
+
1384
+ // Convert JSON object to CSV
1385
+ function jsonToCsv(jsonObject) {
1386
+ const rows = [];
1387
+ const headers = new Set();
1388
+
1389
+ // Collect all unique headers
1390
+ for (const key in jsonObject) {
1391
+ Object.keys(jsonObject[key]).forEach((header) => headers.add(header));
1392
+ }
1393
+
1394
+ // Convert Set to Array for consistent ordering
1395
+ const headersArray = Array.from(headers);
1396
+ rows.push(headersArray.join(",")); // Add headers as the first row
1397
+
1398
+ // Add each object's values as rows
1399
+ for (const key in jsonObject) {
1400
+ const row = headersArray.map((header) => {
1401
+ const value = jsonObject[key][header];
1402
+ return typeof value === "string" ? `"${value}"` : value ?? "N/A"; // Handle undefined values
1403
+ });
1404
+ rows.push(row.join(","));
1405
+ }
1406
+
1407
+ return rows.join("\n");
1408
+ }
1409
+
1410
+ // GET IP ADDRESSES ====================================================================
1411
+ // This functions get the IP address of all of the network adapters and returns them in
1412
+ // an array.
1413
+ // =====================================================================================
1414
+ function getIPAddresses() {
1415
+ const interfaces = os.networkInterfaces(); // Get the network interfaces object
1416
+ let addresses = []; // Delcare an array to hold the addresses
1417
+ for (let iface in interfaces) {
1418
+ // Loop through each interface
1419
+ interfaces[iface].forEach((details) => {
1420
+ // Get the IPV4 addres for each interface
1421
+ if (details.family === "IPv4" && !details.internal) {
1422
+ addresses.push(details.address);
1423
+ }
1424
+ });
1425
+ }
1426
+ return addresses; // Return the addresses array
1427
+ }
1428
+
1429
+ // CALCULATE SYSTEM UPTIME =============================================================
1430
+ // This function calculates the time since last restart of the system.
1431
+ // =====================================================================================
1432
+ function getUptime() {
1433
+ const uptimeSeconds = os.uptime(); // Get the uptime of the system in seconds
1434
+
1435
+ // Calculate days, hours, minutes, and seconds
1436
+ const days = Math.floor(uptimeSeconds / (24 * 60 * 60));
1437
+ const hours = Math.floor((uptimeSeconds % (24 * 60 * 60)) / (60 * 60));
1438
+ const minutes = Math.floor((uptimeSeconds % (60 * 60)) / 60);
1439
+ const seconds = Math.floor(uptimeSeconds % 60);
1440
+
1441
+ // Format the uptime as a string
1442
+ const uptime_str = `Uptime: ${days} days, ${hours} hours, ${minutes} minutes, ${seconds} seconds`;
1443
+
1444
+ return uptime_str; // Return the result
1445
+ }
1446
+
1447
+ // REMOVE FIRST TWO TOPIC LEVELS =======================================================
1448
+ // This function removes the first 2 mqtt topic levels from a string.
1449
+ // =====================================================================================
1450
+ function removeFirstTwoTopicLevels(topic) {
1451
+ const topicLevels = topic.split("/");
1452
+ if (topicLevels.length <= 2) {
1453
+ // If there are less than or exactly two levels, return an empty string
1454
+ return "";
1455
+ }
1456
+ // Join the remaining levels after the first two
1457
+ return topicLevels.slice(2).join("/");
1458
+ }
1459
+
1460
+ // REMOVE TRAILING SLASH ===============================================================
1461
+ // This function removes the trailing slash from a string.
1462
+ // =====================================================================================
1463
+ function removeTrailingSlash(str) {
1464
+ return str.endsWith("/") ? str.slice(0, -1) : str;
1465
+ }
1466
+
1467
+ // GET OBJECT TYPE =====================================================================
1468
+ // This function returns the string representation of the object type.
1469
+ // =====================================================================================
1470
+ function getObjectType(objectId) {
1471
+ switch (objectId) {
1472
+ case 0:
1473
+ return "AI";
1474
+ case 1:
1475
+ return "AO";
1476
+ case 2:
1477
+ return "AV";
1478
+ case 3:
1479
+ return "BI";
1480
+ case 4:
1481
+ return "BO";
1482
+ case 5:
1483
+ return "BV";
1484
+ case 8:
1485
+ return "Device";
1486
+ case 13:
1487
+ return "MI";
1488
+ case 14:
1489
+ return "MO";
1490
+ case 19:
1491
+ return "MV";
1492
+ case 40:
1493
+ return "CS";
1494
+ default:
1495
+ return "";
1496
+ }
1497
+ }
1498
+
1499
+ // End Common Functions
1500
+
1501
+ // Clean up resources when node is closed (on redeploy or shutdown)
1502
+ this.on("close", function (done) {
1503
+ // Clear all intervals
1504
+ if (this.batchInterval) {
1505
+ clearInterval(this.batchInterval);
1506
+ this.batchInterval = null;
1507
+ }
1508
+
1509
+ if (this.syncInterval) {
1510
+ clearInterval(this.syncInterval);
1511
+ this.syncInterval = null;
1512
+ }
1513
+
1514
+ // Terminate worker if it exists
1515
+ if (activeWorker) {
1516
+ activeWorker
1517
+ .terminate()
1518
+ .then(() => {
1519
+ activeWorker = null;
1520
+ done();
1521
+ })
1522
+ .catch((err) => {
1523
+ console.error("Error terminating worker:", err);
1524
+ activeWorker = null;
1525
+ done();
1526
+ });
1527
+ } else {
1528
+ done();
1529
+ }
1530
+ });
1531
+
1532
+ // Add this function to synchronize statBlock with worker results
1533
+ async function syncStatBlockWithWorkerResults() {
1534
+ try {
1535
+ // Reset the statBlock before getting new stats
1536
+ node.statBlock = {
1537
+ ok: 0,
1538
+ error: 0,
1539
+ missing: 0,
1540
+ warnings: 0,
1541
+ moved: 0,
1542
+ deviceIdChange: 0,
1543
+ deviceIdConflict: 0,
1544
+ unmapped: 0,
1545
+ offlinePercentage: node.statBlock.offlinePercentage || 0,
1546
+ };
1547
+
1548
+ // Call getModelStats to ensure we have the latest data
1549
+ await getModelStats();
1550
+
1551
+ // If we have stat counts from the worker, update the node's statBlock
1552
+ if (node.statCounts && node.statCounts.statBlock) {
1553
+ // Update node.statBlock with values from worker
1554
+ for (let key in node.statCounts.statBlock) {
1555
+ node.statBlock[key] = node.statCounts.statBlock[key];
1556
+ }
1557
+ }
1558
+ } catch (error) {
1559
+ console.error("Error syncing statBlock with worker results:", error);
1560
+ }
1561
+ }
1562
+ }
1563
+ RED.nodes.registerType("Bacnet-Inspector", BacnetInspector);
1564
+ };