@bitpoolos/edge-bacnet 1.5.3 → 1.6.1

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