@bitpoolos/edge-bacnet 1.5.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,535 @@
1
+ const { parentPort } = require("worker_threads");
2
+ const { Worker } = require("worker_threads");
3
+ const path = require("path");
4
+
5
+ function getRelativeTime(timestamp) {
6
+ if (!timestamp) return "N/A";
7
+ let now = Date.now();
8
+ let timeDiff = now - timestamp;
9
+ if (timeDiff < 0) return "In the future";
10
+
11
+ let seconds = Math.floor(timeDiff / 1000);
12
+ let minutes = Math.floor(seconds / 60);
13
+ let hours = Math.floor(minutes / 60);
14
+ let days = Math.floor(hours / 24);
15
+ let weeks = Math.floor(days / 7);
16
+ let months = Math.floor(days / 30);
17
+ let years = Math.floor(days / 365);
18
+
19
+ if (seconds < 60) return `${seconds} seconds ago`;
20
+ if (minutes < 60) return `${minutes} minutes ago`;
21
+ if (hours < 24) return `${hours} hours ago`;
22
+ if (days < 7) return `${days} days ago`;
23
+ if (weeks < 4) return `${weeks} weeks ago`;
24
+ if (months < 12) return `${months} months ago`;
25
+ return `${years} years ago`;
26
+ }
27
+
28
+ // Main processing function
29
+ function processModelStats(data) {
30
+ const { ReadList, DiscoveryList, PublishList, statBlock, filterKey, filterValue } = data;
31
+ const ResultList = {};
32
+ let readCount = 0;
33
+ let pointOkCount = 0;
34
+ let pointNotInDiscoveryCount = 0;
35
+ let pointMatchDiscoveryNotPublishingCount = 0;
36
+ let discoveryCount = 0;
37
+ let unmappedCount = 0;
38
+
39
+ // Reset all statBlock counters to ensure fresh counts
40
+ statBlock.ok = 0;
41
+ statBlock.error = 0;
42
+ statBlock.missing = 0;
43
+ statBlock.warnings = 0;
44
+ statBlock.unmapped = 0;
45
+ statBlock.moved = 0;
46
+ statBlock.deviceIdChange = 0;
47
+ statBlock.deviceIdConflict = 0;
48
+
49
+ // Add this section to track device ID conflicts
50
+ const deviceIdCount = {}; // Track count of each device ID
51
+ const deviceIdIPs = {}; // Track IPs for each device ID for reporting
52
+ const deviceIpDetails = {}; // Store additional details for debugging
53
+
54
+ // Count device IDs while preprocessing DiscoveryList
55
+ for (const [DiscoveryDevice, DiscoveryDeviceObj] of Object.entries(DiscoveryList)) {
56
+ // Improved parsing with validation
57
+ const parts = DiscoveryDevice.split("-");
58
+ if (parts.length < 2) continue; // Skip invalid format
59
+
60
+ const IP = parts[0];
61
+ // Handle case where deviceID might be after the first dash
62
+ // If there are multiple dashes, combine all parts after the first dash
63
+ const DeviceID = parts.slice(1).join("-").trim();
64
+
65
+ // Skip entries with empty deviceIDs
66
+ if (!DeviceID) continue;
67
+
68
+ // Normalize deviceID to avoid false conflicts
69
+ // Convert to string and trim to avoid type and whitespace issues
70
+ const normalizedDeviceID = String(DeviceID).trim();
71
+
72
+ // Initialize or increment device ID count
73
+ deviceIdCount[normalizedDeviceID] = (deviceIdCount[normalizedDeviceID] || 0) + 1;
74
+
75
+ // Track IPs for this device ID
76
+ if (!deviceIdIPs[normalizedDeviceID]) {
77
+ deviceIdIPs[normalizedDeviceID] = new Set();
78
+ deviceIpDetails[normalizedDeviceID] = [];
79
+ }
80
+ deviceIdIPs[normalizedDeviceID].add(IP);
81
+
82
+ // Store enough details to help debug the issue
83
+ deviceIpDetails[normalizedDeviceID].push({
84
+ fullKey: DiscoveryDevice,
85
+ ip: IP,
86
+ deviceId: DeviceID,
87
+ normalizedDeviceId: normalizedDeviceID,
88
+ pointCount: Object.keys(DiscoveryDeviceObj).length
89
+ });
90
+ }
91
+
92
+ // Count conflicts - a conflict exists when a device ID appears with multiple IPs
93
+ for (const [deviceId, ipSet] of Object.entries(deviceIdIPs)) {
94
+ // Only consider it a conflict if there are truly multiple distinct IPs
95
+ // This additional validation helps filter out false positives
96
+ if (ipSet.size > 1) {
97
+ // Filter out any possible empty or invalid IPs
98
+ const validIPs = Array.from(ipSet).filter(ip => ip && ip.trim().length > 0);
99
+
100
+ if (validIPs.length > 1) {
101
+ statBlock.deviceIdConflict++;
102
+
103
+ // Optionally, add detailed information to a result list
104
+ // This can be useful if you want to display the conflicts
105
+ const ipsList = validIPs.join(', ');
106
+ const conflictKey = `conflict:${deviceId}`;
107
+
108
+ // Get more detailed information about the conflict for display
109
+ const detailsText = deviceIpDetails[deviceId]
110
+ .map(d => `[${d.ip} has ${d.pointCount} points]`)
111
+ .join(', ');
112
+
113
+ ResultList[conflictKey] = {
114
+ deviceID: deviceId,
115
+ ipAddress: ipsList,
116
+ dataModelStatus: `Device ID Conflict - Same ID (${deviceId}) found on multiple IP addresses: ${ipsList}. ${detailsText}`,
117
+ pointInReadList: false,
118
+ pointMatchedDiscoveryList: true,
119
+ pointBeingPublished: false,
120
+ // Add other required fields with empty/default values
121
+ objectType: 'N/A',
122
+ objectInstance: 'N/A',
123
+ pointName: 'N/A',
124
+ displayName: 'Device ID Conflict',
125
+ deviceName: 'Multiple Devices',
126
+ presentValue: null,
127
+ topic: 'N/A',
128
+ discoveredBACnetPointName: 'N/A',
129
+ lastSeen: 'N/A',
130
+ error: 'Device ID Conflict'
131
+ };
132
+ }
133
+ }
134
+ }
135
+
136
+ // Create a more flexible structure for matching - store multiple variations of the normalized topic
137
+ const PublishTopicsNormalized = new Map();
138
+ for (const [publishTopic, data] of Object.entries(PublishList)) {
139
+ const parts = publishTopic.split("/");
140
+
141
+ // Store multiple variations to increase match chances
142
+ // 1. Full lowercased path
143
+ const fullPath = publishTopic.toLowerCase();
144
+ // 2. Remove the first 2 levels (traditional approach)
145
+ const withoutPrefix = parts.length > 2 ? parts.slice(2).join("/").toLowerCase() : "";
146
+ // 3. Just the last segment (for very short read paths)
147
+ const lastSegment = parts.length > 0 ? parts[parts.length - 1].toLowerCase() : "";
148
+
149
+ // Store the data under all variations
150
+ const publishData = {
151
+ error: data.error || "N/A",
152
+ presentValue: data.presentValue,
153
+ bacnetLastSeen: data.bacnetLastSeen
154
+ };
155
+
156
+ PublishTopicsNormalized.set(fullPath, publishData);
157
+ if (withoutPrefix) PublishTopicsNormalized.set(withoutPrefix, publishData);
158
+ if (lastSegment) PublishTopicsNormalized.set(lastSegment, publishData);
159
+ }
160
+
161
+ // Preprocess DiscoveryList
162
+ const DiscoveryKeys = new Set();
163
+ const DiscoveryPointMap = {};
164
+
165
+ for (const [DiscoveryDevice, DiscoveryDeviceObj] of Object.entries(DiscoveryList)) {
166
+ const [IP, DeviceID] = DiscoveryDevice.split("-");
167
+
168
+ for (const [DiscoveryPoint, DiscoveryPointObj] of Object.entries(DiscoveryDeviceObj)) {
169
+ const DiscPointKey = `${IP}:${DeviceID}:${DiscoveryPointObj.meta.objectId.type}:${DiscoveryPointObj.meta.objectId.instance}`;
170
+ DiscoveryKeys.add(DiscPointKey);
171
+ DiscoveryPointMap[DiscPointKey] = DiscoveryPointObj;
172
+ }
173
+ }
174
+
175
+ // Preprocess DiscoveryKeys into structured maps
176
+ const discoveryMap = {
177
+ withoutIP: new Set(),
178
+ withoutTypeAndInstance: new Set(),
179
+ deviceIDOnly: new Set(),
180
+ ipTypeInstance: new Set(),
181
+ deviceIPOnly: new Set(),
182
+ };
183
+
184
+ DiscoveryKeys.forEach((key) => {
185
+ const parts = key.split(":");
186
+ discoveryMap.withoutIP.add(parts.slice(-3).join(":"));
187
+ discoveryMap.withoutTypeAndInstance.add(parts.slice(0, 2).join(":"));
188
+ discoveryMap.deviceIDOnly.add(parts[1]);
189
+ discoveryMap.ipTypeInstance.add([parts[0], parts[2], parts[3]].join(":"));
190
+ discoveryMap.deviceIPOnly.add(parts[0]);
191
+ });
192
+
193
+ // Process read points
194
+ for (const [ReadPoint, ReadPointObj] of Object.entries(ReadList)) {
195
+ const ReadPointKey = `${ReadPointObj.ipAddress}:${ReadPointObj.key}`;
196
+ const DiscoveryKeyMatch = DiscoveryKeys.has(ReadPointKey);
197
+
198
+ // Create multiple variations of the read point path to increase match chances
199
+ const parts = ReadPoint.split("/");
200
+ const normalizedFull = ReadPoint.toLowerCase();
201
+ const normalizedNoPrefix = parts.length > 2 ? parts.slice(2).join("/").toLowerCase() : "";
202
+ const normalizedLastSegment = parts.length > 0 ? parts[parts.length - 1].toLowerCase() : "";
203
+
204
+ // Try each variation to find a match
205
+ let matchVariation = null;
206
+ let PublishPointTopicMatch = false;
207
+
208
+ if (PublishTopicsNormalized.has(normalizedFull)) {
209
+ matchVariation = normalizedFull;
210
+ PublishPointTopicMatch = true;
211
+ } else if (normalizedNoPrefix && PublishTopicsNormalized.has(normalizedNoPrefix)) {
212
+ matchVariation = normalizedNoPrefix;
213
+ PublishPointTopicMatch = true;
214
+ } else if (normalizedLastSegment && PublishTopicsNormalized.has(normalizedLastSegment)) {
215
+ matchVariation = normalizedLastSegment;
216
+ PublishPointTopicMatch = true;
217
+ }
218
+
219
+ const PointResult = { ...ReadPointObj };
220
+ PointResult.topic = ReadPoint;
221
+ PointResult.pointInReadList = true;
222
+ PointResult.pointMatchedDiscoveryList = DiscoveryKeyMatch;
223
+ PointResult.pointBeingPublished = PublishPointTopicMatch;
224
+
225
+ if (PublishPointTopicMatch && matchVariation) {
226
+ const publishData = PublishTopicsNormalized.get(matchVariation);
227
+ PointResult.presentValue = publishData.presentValue ?? null;
228
+ PointResult.lastSeenTimestamp = publishData.bacnetLastSeen;
229
+ } else {
230
+ PointResult.presentValue = null;
231
+ }
232
+
233
+ if (DiscoveryKeyMatch) {
234
+ if (PublishPointTopicMatch) {
235
+ const error = PublishTopicsNormalized.get(matchVariation).error;
236
+ if (error !== "none" && error !== "N/A") {
237
+ PointResult.dataModelStatus = `Point Error - Matched in Discovery / Data Model and publishing, however BACNet Error is present: ${error}`;
238
+ statBlock.error++;
239
+ } else {
240
+ PointResult.dataModelStatus = "Point Ok - Matched in Discovery / Data Model and publishing";
241
+ statBlock.ok++;
242
+ }
243
+ PointResult.error = error;
244
+ pointOkCount++;
245
+ } else {
246
+ if (DiscoveryPointMap[ReadPointKey].objectName !== ReadPointObj.pointName) {
247
+ PointResult.dataModelStatus = "Point Missing - Point name in BACnet controller has been changed. Point not publishing.";
248
+ } else {
249
+ PointResult.dataModelStatus = "Point Warning - Matched in Discovery / Data Model ok but not publishing";
250
+ }
251
+ PointResult.error = "N/A";
252
+ pointMatchDiscoveryNotPublishingCount++;
253
+ }
254
+
255
+ PointResult.discoveredBACnetPointName = DiscoveryPointMap[ReadPointKey].objectName;
256
+ const timestamp = DiscoveryPointMap[ReadPointKey].timestamp ?? "N/A";
257
+ PointResult.lastSeen = getRelativeTime(timestamp);
258
+ } else {
259
+ PointResult.dataModelStatus = "Point Missing - Point in Read List but not Discovery / Data Model and not publishing";
260
+
261
+ const readParts = ReadPointKey.split(":");
262
+ const readWithoutIP = readParts.slice(-3).join(":");
263
+ const readWithoutTypeAndInstance = readParts.slice(0, 2).join(":");
264
+ const readDeviceIDOnly = readParts[1];
265
+ const readIPTypeInstance = [readParts[0], readParts[2], readParts[3]].join(":");
266
+ const readDeviceIPOnly = readParts[0];
267
+
268
+ if (discoveryMap.withoutIP.has(readWithoutIP)) {
269
+ PointResult.dataModelStatus += ". It appears like the IP associated with this device ID has changed.";
270
+ } else if (discoveryMap.withoutTypeAndInstance.has(readWithoutTypeAndInstance)) {
271
+ PointResult.dataModelStatus +=
272
+ ". It appears like the device ID exists in the discovery with the correct IP but does not contain the required point (object type and instance).";
273
+ } else if (discoveryMap.deviceIDOnly.has(readDeviceIDOnly)) {
274
+ PointResult.dataModelStatus +=
275
+ ". It appears like the device ID exists in the discovery but does not have the correct IP and does not have the required point (object type and instance).";
276
+ } else if (discoveryMap.ipTypeInstance.has(readIPTypeInstance)) {
277
+ PointResult.dataModelStatus +=
278
+ ". It appears like the IP exists in the discovery but does not have the correct device ID. It does however have the required point (object type and instance).";
279
+ } else if (discoveryMap.deviceIPOnly.has(readDeviceIPOnly)) {
280
+ PointResult.dataModelStatus +=
281
+ ". It appears like the IP exists in the discovery but does not have the correct device ID and does not have the required point (object type and instance).";
282
+ } else {
283
+ PointResult.dataModelStatus +=
284
+ ". No matching combination of IP or ID and point (type and instance) can be found. Device is likely not on the network.";
285
+ }
286
+
287
+ PointResult.discoveredBACnetPointName = "N/A";
288
+ PointResult.lastSeen = "N/A";
289
+ PointResult.error = "N/A";
290
+ pointNotInDiscoveryCount++;
291
+ }
292
+
293
+ ResultList[ReadPointKey] = PointResult;
294
+ readCount++;
295
+ }
296
+
297
+ // Process unmapped points
298
+ const ReadKeys = new Set(Object.values(ReadList).map((ReadPointObj) => `${ReadPointObj.ipAddress}:${ReadPointObj.key}`));
299
+ const UnmappedKeys = {
300
+ IDName: new Map(),
301
+ IPTypeInstanceName: new Map(),
302
+ };
303
+
304
+ for (const DiscoveryKey of DiscoveryKeys) {
305
+ if (!ReadKeys.has(DiscoveryKey)) {
306
+ const parts = DiscoveryKey.split(":");
307
+ const unmappedPoint = {
308
+ deviceID: parts[1],
309
+ objectType: parts[2],
310
+ objectInstance: parts[3],
311
+ pointName: DiscoveryPointMap[DiscoveryKey].objectName,
312
+ displayName: DiscoveryPointMap[DiscoveryKey].displayName,
313
+ deviceName: DiscoveryPointMap[DiscoveryKey]?.meta?.device?.deviceName || "N/A",
314
+ ipAddress: parts[0] || "N/A",
315
+ presentValue: DiscoveryPointMap[DiscoveryKey].presentValue || null,
316
+ area: "N/A",
317
+ key: DiscoveryKey,
318
+ topic: "N/A",
319
+ pointInReadList: false,
320
+ pointMatchedDiscoveryList: false,
321
+ pointBeingPublished: false,
322
+ dataModelStatus: "Point Unmapped - In discovery list but not read list",
323
+ discoveredBACnetPointName: "N/A",
324
+ lastSeen: "N/A",
325
+ error: "N/A",
326
+ };
327
+
328
+ ResultList[DiscoveryKey] = unmappedPoint;
329
+
330
+ UnmappedKeys.IDName.set(unmappedPoint.deviceID + ":" + unmappedPoint.pointName, unmappedPoint.objectInstance);
331
+ UnmappedKeys.IPTypeInstanceName.set(
332
+ `${unmappedPoint.ipAddress}:${unmappedPoint.objectType}:${unmappedPoint.objectInstance}:${unmappedPoint.pointName}`,
333
+ unmappedPoint.deviceID
334
+ );
335
+ unmappedCount++;
336
+ }
337
+ discoveryCount++;
338
+ }
339
+
340
+ // Find moved points
341
+ for (const [ResultPoint, ResultPointObj] of Object.entries(ResultList)) {
342
+ if (
343
+ ResultPointObj.dataModelStatus.includes(
344
+ ". It appears like the device ID exists in the discovery with the correct IP but does not contain the required point (object type and instance)."
345
+ )
346
+ ) {
347
+ const key = ResultPointObj.deviceID + ":" + ResultPointObj.pointName;
348
+ if (UnmappedKeys.IDName.has(key)) {
349
+ ResultList[
350
+ ResultPoint
351
+ ].dataModelStatus = `Point Moved - Point not publishing - Point potentially moved from object instance ${ResultList[ResultPoint].objectInstance
352
+ } to ${UnmappedKeys.IDName.get(key)}.`;
353
+ statBlock.moved++;
354
+ }
355
+ } else if (
356
+ ResultPointObj.dataModelStatus.includes(
357
+ ". It appears like the IP exists in the discovery but does not have the correct device ID. It does however have the required point (object type and instance)."
358
+ )
359
+ ) {
360
+ const key = `${ResultPointObj.ipAddress}:${ResultPointObj.objectType}:${ResultPointObj.objectInstance}:${ResultPointObj.pointName}`;
361
+ if (UnmappedKeys.IPTypeInstanceName.has(key)) {
362
+ ResultList[ResultPoint].dataModelStatus = `Device ID Changed - Point not publishing - Device ID potentially change from ${ResultList[ResultPoint].deviceID
363
+ } to ${UnmappedKeys.IPTypeInstanceName.get(key)}.`;
364
+ statBlock.deviceIdChange++;
365
+ }
366
+ }
367
+ }
368
+
369
+ // Update statBlock
370
+ statBlock.missing = pointMatchDiscoveryNotPublishingCount;
371
+ statBlock.warnings = pointMatchDiscoveryNotPublishingCount;
372
+ statBlock.unmapped = unmappedCount;
373
+
374
+ // If we're asked to filter the results directly in the worker
375
+ if (filterKey && filterValue) {
376
+ const filteredList = {};
377
+
378
+ // Apply filtering to the ResultList
379
+ for (const [key, item] of Object.entries(ResultList)) {
380
+ try {
381
+ // Handle nested properties using dot notation
382
+ const value = filterKey.split(".").reduce((obj, key) => {
383
+ if (obj === null || obj === undefined) return undefined;
384
+ return obj[key];
385
+ }, item);
386
+
387
+ // Handle undefined/null values
388
+ if (value === undefined || value === null) {
389
+ continue;
390
+ }
391
+
392
+ // Split filter values by comma and trim whitespace
393
+ const filterValues = filterValue.split(",").map((v) => v.trim());
394
+
395
+ // Case-insensitive string comparison for string values
396
+ if (typeof value === "string") {
397
+ const valueLower = value.toLowerCase();
398
+ if (filterValues.some((filterVal) => valueLower.includes(filterVal.toLowerCase()))) {
399
+ filteredList[key] = item;
400
+ }
401
+ continue;
402
+ }
403
+
404
+ // Direct comparison for non-string values
405
+ if (
406
+ filterValues.some((filterVal) => {
407
+ // Try to convert filterVal to the same type as value for comparison
408
+ const convertedFilterVal = typeof value === "number" ? Number(filterVal) : filterVal;
409
+ return value === convertedFilterVal;
410
+ })
411
+ ) {
412
+ filteredList[key] = item;
413
+ }
414
+ } catch (err) {
415
+ // Skip items that cause filter errors
416
+ continue;
417
+ }
418
+ }
419
+
420
+ // Return the filtered list instead
421
+ return {
422
+ ResultList: filteredList,
423
+ statBlock,
424
+ stat_counts: {
425
+ readCount,
426
+ discoveryCount,
427
+ pointOkCount,
428
+ pointNotInDiscoveryCount,
429
+ pointMatchDiscoveryNotPublishingCount,
430
+ unmappedCount,
431
+ },
432
+ };
433
+ }
434
+
435
+ return {
436
+ ResultList,
437
+ statBlock,
438
+ stat_counts: {
439
+ readCount,
440
+ discoveryCount,
441
+ pointOkCount,
442
+ pointNotInDiscoveryCount,
443
+ pointMatchDiscoveryNotPublishingCount,
444
+ unmappedCount,
445
+ },
446
+ };
447
+ }
448
+
449
+ // Listen for messages from the main thread
450
+ parentPort.on("message", (data) => {
451
+ try {
452
+ const result = processModelStats(data);
453
+ parentPort.postMessage(result);
454
+ } catch (error) {
455
+ parentPort.postMessage({ error: error.message });
456
+ }
457
+ });
458
+
459
+ // Add these variables at the top with your other worker variables
460
+ let activeWorker = null;
461
+ let isWorkerBusy = false;
462
+ let workerQueue = [];
463
+ let workerTaskCount = 0; // Add this to track number of tasks processed
464
+ const MAX_TASKS_PER_WORKER = 5; // Terminate worker after 5 tasks
465
+
466
+ // Then modify the runWithWorker function
467
+ async function runWithWorker(task) {
468
+ if (isWorkerBusy) {
469
+ return new Promise((resolve, reject) => {
470
+ workerQueue.push({ task, resolve, reject });
471
+ });
472
+ }
473
+
474
+ isWorkerBusy = true;
475
+
476
+ try {
477
+ // Create worker if needed
478
+ if (!activeWorker) {
479
+ activeWorker = new Worker(path.join(__dirname, "bacnet_inspector_worker.js"));
480
+ workerTaskCount = 0; // Reset task count for new worker
481
+ }
482
+
483
+ // Increment task count
484
+ workerTaskCount++;
485
+ // Set up promise to get worker response
486
+ const result = await new Promise((resolve, reject) => {
487
+ const messageHandler = (data) => {
488
+ activeWorker.removeListener("error", errorHandler);
489
+ resolve(data);
490
+ };
491
+
492
+ const errorHandler = (error) => {
493
+ activeWorker.removeListener("message", messageHandler);
494
+ reject(error);
495
+ };
496
+
497
+ activeWorker.once("message", messageHandler);
498
+ activeWorker.once("error", errorHandler);
499
+
500
+ // Send the task data to the worker
501
+ activeWorker.postMessage(task);
502
+ });
503
+
504
+ return result;
505
+ } catch (error) {
506
+ throw error;
507
+ } finally {
508
+ isWorkerBusy = false;
509
+
510
+ // Process next task in queue or terminate worker
511
+ if (workerQueue.length > 0) {
512
+ const nextTask = workerQueue.shift();
513
+ runWithWorker(nextTask.task).then(nextTask.resolve).catch(nextTask.reject);
514
+ } else {
515
+ // Check if worker has processed too many tasks
516
+ if (workerTaskCount >= MAX_TASKS_PER_WORKER && activeWorker) {
517
+ // Terminate worker immediately
518
+ activeWorker.terminate().catch((err) => {
519
+ console.error("Error terminating worker:", err);
520
+ });
521
+ activeWorker = null;
522
+ } else if (activeWorker) {
523
+ // Keep original timeout logic as a backup
524
+ setTimeout(() => {
525
+ if (!isWorkerBusy && activeWorker) {
526
+ activeWorker.terminate().catch((err) => {
527
+ console.error("Error terminating worker:", err);
528
+ });
529
+ activeWorker = null;
530
+ }
531
+ }, 2000); // Reduced from 5000ms to 2000ms
532
+ }
533
+ }
534
+ }
535
+ }