@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.
- package/CHANGELOG.md +143 -10
- package/bacnet_client.js +123 -107
- package/bacnet_gateway.html +22 -6
- package/bacnet_gateway.js +346 -250
- package/bacnet_inspector.html +43 -0
- package/bacnet_inspector.js +1564 -0
- package/bacnet_inspector_worker.js +535 -0
- package/bacnet_read.html +27 -27
- package/bacnet_read.js +0 -3
- package/common.js +201 -38
- package/inspector.html +460 -0
- package/package.json +6 -2
- package/resources/Logo_Simplified_Positive.svg +32 -0
- package/resources/downloadAsHtml.js +654 -0
- package/resources/icons/device-id-change-icon.svg +4 -0
- package/resources/icons/device-id-conflict-icon.svg +4 -0
- package/resources/icons/favicon.ico +0 -0
- package/resources/icons/points-error-icon.svg +4 -0
- package/resources/icons/points-missing-icon.svg +4 -0
- package/resources/icons/points-ok-icon.svg +4 -0
- package/resources/icons/points-unmapped-icon.svg +5 -0
- package/resources/icons/points-warning-icon.svg +4 -0
- package/resources/inspector.css +25312 -0
- package/resources/inspectorStyle.css +254 -0
- package/resources/inspectorStyles.css +478 -0
- package/resources/primevue.min.js +1 -0
- package/resources/style.css +17 -1
- package/resources/vue3513.global.prod.js +9 -0
- package/ssrHtmlExporter.js +535 -0
- package/treeBuilder.js +3 -3
|
@@ -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
|
+
};
|