@bitpoolos/edge-bacnet 1.6.2 → 1.6.4
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 +114 -66
- package/bacnet_client.js +65 -56
- package/bacnet_gateway.html +233 -18
- package/bacnet_gateway.js +41 -8
- package/bacnet_inspector.js +88 -12
- package/bacnet_inspector_worker.js +13 -3
- package/bacnet_read.html +223 -7
- package/bacnet_read.js +13 -1
- package/common.js +17 -78
- package/inspector.html +35 -1
- package/package.json +2 -2
- package/resources/style.css +28 -1
- package/treeBuilder.js +683 -567
package/bacnet_gateway.html
CHANGED
|
@@ -136,6 +136,16 @@
|
|
|
136
136
|
body: JSON.stringify({ k: deviceKey, p: pointName, n: pointDisplayName }),
|
|
137
137
|
}).then((res) => res.json());
|
|
138
138
|
}
|
|
139
|
+
applyDisplayNames(pointsToRead) {
|
|
140
|
+
return fetch(RED.settings.httpNodeRoot + "bitpool-bacnet-data/applyDisplayNames", {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: {
|
|
143
|
+
Accept: "application/json",
|
|
144
|
+
"Content-Type": "application/json",
|
|
145
|
+
},
|
|
146
|
+
body: JSON.stringify({ pointsToRead: pointsToRead }),
|
|
147
|
+
}).then((res) => res.json());
|
|
148
|
+
}
|
|
139
149
|
importReadList(payload) {
|
|
140
150
|
return fetch(RED.settings.httpNodeRoot + "bitpool-bacnet-data/importReadList", {
|
|
141
151
|
method: "POST",
|
|
@@ -396,25 +406,130 @@
|
|
|
396
406
|
});
|
|
397
407
|
});
|
|
398
408
|
|
|
409
|
+
function setDataModelUpdateStatus(status, type, autoClear) {
|
|
410
|
+
const statusElement = $("#data-model-update-status");
|
|
411
|
+
statusElement.text(status);
|
|
412
|
+
|
|
413
|
+
// Remove existing status classes
|
|
414
|
+
statusElement.removeClass("status-success status-error status-warning status-info");
|
|
415
|
+
|
|
416
|
+
// Add appropriate styling based on type
|
|
417
|
+
switch (type) {
|
|
418
|
+
case "success":
|
|
419
|
+
statusElement.addClass("status-success");
|
|
420
|
+
break;
|
|
421
|
+
case "error":
|
|
422
|
+
statusElement.addClass("status-error");
|
|
423
|
+
break;
|
|
424
|
+
case "warning":
|
|
425
|
+
statusElement.addClass("status-warning");
|
|
426
|
+
break;
|
|
427
|
+
case "info":
|
|
428
|
+
default:
|
|
429
|
+
statusElement.addClass("status-info");
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Auto-clear success messages after 5 seconds
|
|
434
|
+
if (autoClear !== false && type === "success") {
|
|
435
|
+
setTimeout(() => {
|
|
436
|
+
statusElement.text("");
|
|
437
|
+
statusElement.removeClass("status-success");
|
|
438
|
+
}, 5000);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
399
442
|
//Import complete Data model
|
|
400
443
|
$("#file-upload-database").on("change", function (event) {
|
|
401
444
|
const input = event.target.files[0];
|
|
445
|
+
|
|
446
|
+
if (!input) {
|
|
447
|
+
setDataModelUpdateStatus("No file selected", "warning");
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Validate file type
|
|
452
|
+
if (!input.name.toLowerCase().endsWith('.json')) {
|
|
453
|
+
setDataModelUpdateStatus("Please select a valid JSON file", "error");
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Check file size (warn if larger than 10MB)
|
|
458
|
+
const fileSizeMB = (input.size / (1024 * 1024)).toFixed(2);
|
|
459
|
+
if (input.size > 10 * 1024 * 1024) {
|
|
460
|
+
setDataModelUpdateStatus(`Large file detected (${fileSizeMB}MB). Import may take several minutes...`, "warning");
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Confirmation dialog for potentially destructive operation
|
|
464
|
+
if (!confirm(`Are you sure you want to import this data model?\n\nFile: ${input.name}\nSize: ${fileSizeMB}MB\n\nThis will replace the existing data model and cannot be undone.`)) {
|
|
465
|
+
// Reset the file input
|
|
466
|
+
$(this).val('');
|
|
467
|
+
setDataModelUpdateStatus("Import cancelled", "info");
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
402
471
|
const reader = new FileReader();
|
|
403
472
|
|
|
404
|
-
reader.
|
|
405
|
-
|
|
473
|
+
reader.onloadstart = function () {
|
|
474
|
+
setDataModelUpdateStatus("Reading file...", "info");
|
|
475
|
+
};
|
|
406
476
|
|
|
407
|
-
|
|
477
|
+
reader.onprogress = function (e) {
|
|
478
|
+
if (e.lengthComputable) {
|
|
479
|
+
const percentLoaded = Math.round((e.loaded / e.total) * 100);
|
|
480
|
+
setDataModelUpdateStatus(`Reading file... ${percentLoaded}%`, "info");
|
|
481
|
+
}
|
|
482
|
+
};
|
|
408
483
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
484
|
+
reader.onload = function (e) {
|
|
485
|
+
setDataModelUpdateStatus("Parsing JSON data...", "info");
|
|
486
|
+
|
|
487
|
+
try {
|
|
488
|
+
const text = e.target.result;
|
|
489
|
+
let jsonPayload = JSON.parse(text);
|
|
490
|
+
|
|
491
|
+
setDataModelUpdateStatus("Uploading data model to server...", "info");
|
|
492
|
+
|
|
493
|
+
$.ajax({
|
|
494
|
+
type: "POST",
|
|
495
|
+
url: RED.settings.httpNodeRoot + "bitpool-bacnet-data/updateDataModel",
|
|
496
|
+
dataType: "json",
|
|
497
|
+
contentType: "application/json",
|
|
498
|
+
data: JSON.stringify(jsonPayload),
|
|
499
|
+
success: function (data, status, xhr) {
|
|
500
|
+
setDataModelUpdateStatus(`Data model imported successfully! (${fileSizeMB}MB processed)`, "success");
|
|
501
|
+
// Clear the file input
|
|
502
|
+
$("#file-upload-database").val('');
|
|
503
|
+
},
|
|
504
|
+
error: function (xhr, status, error) {
|
|
505
|
+
let errorMsg = "Failed to import data model";
|
|
506
|
+
if (xhr.responseText) {
|
|
507
|
+
try {
|
|
508
|
+
const errorResponse = JSON.parse(xhr.responseText);
|
|
509
|
+
errorMsg += ": " + (errorResponse.message || errorResponse.error || xhr.responseText);
|
|
510
|
+
} catch (e) {
|
|
511
|
+
errorMsg += ": " + xhr.responseText;
|
|
512
|
+
}
|
|
513
|
+
} else {
|
|
514
|
+
errorMsg += ": " + (error || status || "Unknown error");
|
|
515
|
+
}
|
|
516
|
+
setDataModelUpdateStatus(errorMsg, "error");
|
|
517
|
+
// Clear the file input
|
|
518
|
+
$("#file-upload-database").val('');
|
|
519
|
+
},
|
|
520
|
+
timeout: 0,
|
|
521
|
+
});
|
|
522
|
+
} catch (parseError) {
|
|
523
|
+
setDataModelUpdateStatus("Invalid JSON file: " + parseError.message, "error");
|
|
524
|
+
// Clear the file input
|
|
525
|
+
$("#file-upload-database").val('');
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
reader.onerror = function () {
|
|
530
|
+
setDataModelUpdateStatus("Error reading file", "error");
|
|
531
|
+
// Clear the file input
|
|
532
|
+
$("#file-upload-database").val('');
|
|
418
533
|
};
|
|
419
534
|
|
|
420
535
|
reader.readAsText(input);
|
|
@@ -422,16 +537,82 @@
|
|
|
422
537
|
|
|
423
538
|
// Export complete Data model
|
|
424
539
|
$("#file-export-database").click(function (params) {
|
|
540
|
+
// Confirmation dialog
|
|
541
|
+
if (!confirm("Are you sure you want to export the complete data model?\n\nThis may take some time for large databases.")) {
|
|
542
|
+
setDataModelUpdateStatus("Export cancelled", "info");
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
setDataModelUpdateStatus("Retrieving data model from server...", "info");
|
|
547
|
+
|
|
548
|
+
const startTime = Date.now();
|
|
549
|
+
|
|
425
550
|
$.ajax({
|
|
426
551
|
url: RED.settings.httpNodeRoot + "bitpool-bacnet-data/getDataModel",
|
|
427
552
|
success: function (deviceList) {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
553
|
+
try {
|
|
554
|
+
setDataModelUpdateStatus("Preparing download...", "info");
|
|
555
|
+
|
|
556
|
+
// Calculate data size
|
|
557
|
+
const jsonString = JSON.stringify(deviceList);
|
|
558
|
+
const dataSize = new Blob([jsonString]).size;
|
|
559
|
+
const dataSizeMB = (dataSize / (1024 * 1024)).toFixed(2);
|
|
560
|
+
|
|
561
|
+
// Create a Blob with the JSON data
|
|
562
|
+
const blob = new Blob([jsonString], { type: 'application/json' });
|
|
563
|
+
|
|
564
|
+
// Create an object URL from the Blob
|
|
565
|
+
const url = URL.createObjectURL(blob);
|
|
566
|
+
|
|
567
|
+
// Generate filename with timestamp
|
|
568
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
|
|
569
|
+
const filename = `edge-bacnet-datastore-${timestamp}.json`;
|
|
570
|
+
|
|
571
|
+
// Set the URL to the link and trigger download
|
|
572
|
+
let aEle = document.getElementById("exportJSON");
|
|
573
|
+
aEle.setAttribute("href", url);
|
|
574
|
+
aEle.setAttribute("download", filename);
|
|
575
|
+
aEle.click();
|
|
576
|
+
|
|
577
|
+
// Calculate duration
|
|
578
|
+
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
579
|
+
|
|
580
|
+
setDataModelUpdateStatus(`Export completed successfully! (${dataSizeMB}MB, ${duration}s)`, "success");
|
|
581
|
+
|
|
582
|
+
// Release the object URL when done
|
|
583
|
+
setTimeout(() => {
|
|
584
|
+
URL.revokeObjectURL(url);
|
|
585
|
+
}, 2000);
|
|
586
|
+
|
|
587
|
+
} catch (error) {
|
|
588
|
+
console.error("Error exporting data model:", error);
|
|
589
|
+
setDataModelUpdateStatus("Failed to create download file: " + error.message, "error");
|
|
590
|
+
}
|
|
433
591
|
},
|
|
434
|
-
|
|
592
|
+
error: function (xhr, status, error) {
|
|
593
|
+
console.error("AJAX error retrieving data model:", status, error);
|
|
594
|
+
let errorMsg = "Failed to retrieve data model from server";
|
|
595
|
+
|
|
596
|
+
if (xhr.status === 0) {
|
|
597
|
+
errorMsg += " (Connection timeout or server unavailable)";
|
|
598
|
+
} else if (xhr.status === 404) {
|
|
599
|
+
errorMsg += " (Endpoint not found)";
|
|
600
|
+
} else if (xhr.status === 500) {
|
|
601
|
+
errorMsg += " (Internal server error)";
|
|
602
|
+
} else if (xhr.responseText) {
|
|
603
|
+
try {
|
|
604
|
+
const errorResponse = JSON.parse(xhr.responseText);
|
|
605
|
+
errorMsg += ": " + (errorResponse.message || errorResponse.error);
|
|
606
|
+
} catch (e) {
|
|
607
|
+
errorMsg += ": " + xhr.responseText;
|
|
608
|
+
}
|
|
609
|
+
} else {
|
|
610
|
+
errorMsg += ": " + (error || status || "Unknown error");
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
setDataModelUpdateStatus(errorMsg, "error");
|
|
614
|
+
},
|
|
615
|
+
timeout: 60000, // 60 second timeout for large exports
|
|
435
616
|
});
|
|
436
617
|
});
|
|
437
618
|
|
|
@@ -818,6 +999,39 @@
|
|
|
818
999
|
.database-file-label-div {
|
|
819
1000
|
padding-top: 15px;
|
|
820
1001
|
}
|
|
1002
|
+
/* Status indicator styles */
|
|
1003
|
+
.status-success {
|
|
1004
|
+
color: #28a745 !important;
|
|
1005
|
+
font-weight: 500;
|
|
1006
|
+
background-color: #d4edda;
|
|
1007
|
+
border: 1px solid #c3e6cb;
|
|
1008
|
+
border-radius: 4px;
|
|
1009
|
+
padding: 8px 12px;
|
|
1010
|
+
}
|
|
1011
|
+
.status-error {
|
|
1012
|
+
color: #dc3545 !important;
|
|
1013
|
+
font-weight: 500;
|
|
1014
|
+
background-color: #f8d7da;
|
|
1015
|
+
border: 1px solid #f5c6cb;
|
|
1016
|
+
border-radius: 4px;
|
|
1017
|
+
padding: 8px 12px;
|
|
1018
|
+
}
|
|
1019
|
+
.status-warning {
|
|
1020
|
+
color: #856404 !important;
|
|
1021
|
+
font-weight: 500;
|
|
1022
|
+
background-color: #fff3cd;
|
|
1023
|
+
border: 1px solid #ffeaa7;
|
|
1024
|
+
border-radius: 4px;
|
|
1025
|
+
padding: 8px 12px;
|
|
1026
|
+
}
|
|
1027
|
+
.status-info {
|
|
1028
|
+
color: #0c5460 !important;
|
|
1029
|
+
font-weight: 500;
|
|
1030
|
+
background-color: #d1ecf1;
|
|
1031
|
+
border: 1px solid #bee5eb;
|
|
1032
|
+
border-radius: 4px;
|
|
1033
|
+
padding: 8px 12px;
|
|
1034
|
+
}
|
|
821
1035
|
</style>
|
|
822
1036
|
|
|
823
1037
|
<div class="form-row node-input-read-tabs-row">
|
|
@@ -1070,6 +1284,7 @@
|
|
|
1070
1284
|
<input id="file-export-database" class="inputStyle" style="width: 258px; display: none;" />
|
|
1071
1285
|
<a id="exportJSON" style="display: none"></a>
|
|
1072
1286
|
</div>
|
|
1287
|
+
<div id="data-model-update-status" class="data-model-update-status" style="margin-top: 10px;"></div>
|
|
1073
1288
|
</div>
|
|
1074
1289
|
</div>
|
|
1075
1290
|
</script>
|
package/bacnet_gateway.js
CHANGED
|
@@ -215,11 +215,14 @@ module.exports = function (RED) {
|
|
|
215
215
|
if (
|
|
216
216
|
node.bacnetServerEnabled == true &&
|
|
217
217
|
node.bacnetClient &&
|
|
218
|
-
node.bacnetServer
|
|
219
|
-
nodeContext.get("serverWritePropEvent") == false
|
|
218
|
+
node.bacnetServer
|
|
220
219
|
) {
|
|
221
220
|
try {
|
|
222
|
-
|
|
221
|
+
// Clean up any existing listeners to prevent stale references
|
|
222
|
+
node.bacnetServer.removeAllListeners('writeProperty');
|
|
223
|
+
|
|
224
|
+
// Store the event handler function so we can clean it up later
|
|
225
|
+
node.writePropertyHandler = (topic, newValue) => {
|
|
223
226
|
let formattedTopic = topic;
|
|
224
227
|
if (
|
|
225
228
|
node.nodeName !== "gateway" &&
|
|
@@ -234,8 +237,9 @@ module.exports = function (RED) {
|
|
|
234
237
|
}
|
|
235
238
|
|
|
236
239
|
node.send({ payload: newValue, topic: formattedTopic });
|
|
237
|
-
}
|
|
238
|
-
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
node.bacnetServer.on("writeProperty", node.writePropertyHandler);
|
|
239
243
|
} catch (e) {
|
|
240
244
|
console.log("Bacnet gateway node server writePoperty error: ", e);
|
|
241
245
|
}
|
|
@@ -270,7 +274,7 @@ module.exports = function (RED) {
|
|
|
270
274
|
logOut("Error updating priorityQueue: ", error);
|
|
271
275
|
});
|
|
272
276
|
} else if (msg.testFunc == true) {
|
|
273
|
-
node.bacnetClient.testFunction(msg.address, msg.port, msg.type, msg.instance, msg.property);
|
|
277
|
+
node.bacnetClient.testFunction(msg.address, msg.port, msg.type, msg.instance, msg.property, nodeWarn);
|
|
274
278
|
} else if (msg.applyDisplayNames) {
|
|
275
279
|
node.status({ fill: "blue", shape: "dot", text: "Updating display names" });
|
|
276
280
|
setTimeout(() => {
|
|
@@ -300,7 +304,14 @@ module.exports = function (RED) {
|
|
|
300
304
|
});
|
|
301
305
|
|
|
302
306
|
node.on("close", function () {
|
|
303
|
-
//
|
|
307
|
+
// Clean up the writeProperty event listener
|
|
308
|
+
if (node.bacnetServer && node.writePropertyHandler) {
|
|
309
|
+
node.bacnetServer.removeListener('writeProperty', node.writePropertyHandler);
|
|
310
|
+
node.writePropertyHandler = null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Reset the serverWritePropEvent flag so it can be re-registered
|
|
314
|
+
nodeContext.set("serverWritePropEvent", false);
|
|
304
315
|
});
|
|
305
316
|
} catch (e) {
|
|
306
317
|
console.log("Bacnet node event handler error: ", e);
|
|
@@ -461,7 +472,7 @@ module.exports = function (RED) {
|
|
|
461
472
|
node.bacnetClient
|
|
462
473
|
.updateDataModel(req)
|
|
463
474
|
.then(function (result) {
|
|
464
|
-
res.send(
|
|
475
|
+
res.send(true);
|
|
465
476
|
})
|
|
466
477
|
.catch(function (error) {
|
|
467
478
|
res.send(error);
|
|
@@ -560,6 +571,24 @@ module.exports = function (RED) {
|
|
|
560
571
|
}
|
|
561
572
|
});
|
|
562
573
|
|
|
574
|
+
//route handler for applyDisplayNames
|
|
575
|
+
RED.httpAdmin.post("/bitpool-bacnet-data/applyDisplayNames", function (req, res) {
|
|
576
|
+
if (!node.bacnetClient) {
|
|
577
|
+
logOut("Issue with the bacnetClient while applying display names: ", node.bacnetClient);
|
|
578
|
+
res.send(false);
|
|
579
|
+
} else {
|
|
580
|
+
node.bacnetClient
|
|
581
|
+
.applyDisplayNames(req.body.pointsToRead)
|
|
582
|
+
.then(function (result) {
|
|
583
|
+
res.send(result);
|
|
584
|
+
})
|
|
585
|
+
.catch(function (error) {
|
|
586
|
+
res.send(error);
|
|
587
|
+
logOut("Error applying display names: ", error);
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
|
|
563
592
|
//route handler for importReadList
|
|
564
593
|
RED.httpAdmin.post("/bitpool-bacnet-data/importReadList", function (req, res) {
|
|
565
594
|
if (!node.bacnetClient) {
|
|
@@ -948,6 +977,10 @@ module.exports = function (RED) {
|
|
|
948
977
|
}
|
|
949
978
|
return pointName;
|
|
950
979
|
}
|
|
980
|
+
|
|
981
|
+
function nodeWarn(message) {
|
|
982
|
+
node.warn(message);
|
|
983
|
+
}
|
|
951
984
|
}
|
|
952
985
|
RED.nodes.registerType("Bacnet-Gateway", BitpoolBacnetGatewayDevice);
|
|
953
986
|
};
|
package/bacnet_inspector.js
CHANGED
|
@@ -61,9 +61,41 @@ module.exports = function (RED) {
|
|
|
61
61
|
site_Name: false,
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
+
// Function to update node status with combined information
|
|
65
|
+
function updateNodeStatus() {
|
|
66
|
+
// Calculate offline percentage for display
|
|
67
|
+
const totalPolledPoints = cachedData.onlineCount + cachedData.offlineCount;
|
|
68
|
+
const offlinePercentage = totalPolledPoints > 0 ?
|
|
69
|
+
Math.round((cachedData.offlineCount / totalPolledPoints) * 100) : 0;
|
|
70
|
+
|
|
71
|
+
// Build comprehensive status text
|
|
72
|
+
const statusParts = [];
|
|
73
|
+
|
|
74
|
+
// Add online/offline info if we have polled points
|
|
75
|
+
if (totalPolledPoints > 0) {
|
|
76
|
+
statusParts.push(`Online: ${cachedData.onlineCount}/${totalPolledPoints} (${100 - offlinePercentage}%)`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Add points to read info if we have read list
|
|
80
|
+
if (cachedData.totalUniqueReadCount > 0) {
|
|
81
|
+
statusParts.push(`Total Points: ${cachedData.totalUniqueReadCount}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Fallback status if no data
|
|
85
|
+
if (statusParts.length === 0) {
|
|
86
|
+
statusParts.push("No data");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Update node status
|
|
90
|
+
node.status({ text: statusParts.join(" | ") });
|
|
91
|
+
}
|
|
92
|
+
|
|
64
93
|
// Initialize cache from flow context
|
|
65
94
|
initializeCache();
|
|
66
95
|
|
|
96
|
+
// Set initial status
|
|
97
|
+
updateNodeStatus();
|
|
98
|
+
|
|
67
99
|
function initializeCache() {
|
|
68
100
|
let flow = context.flow;
|
|
69
101
|
|
|
@@ -187,21 +219,31 @@ module.exports = function (RED) {
|
|
|
187
219
|
} else if (msg.type === "Read") {
|
|
188
220
|
calculateCombinedReadList(node, msg);
|
|
189
221
|
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
222
|
} else if (msg.payload && msg.topic) {
|
|
195
|
-
//regular bacnet output
|
|
223
|
+
//regular bacnet output (including those with errors)
|
|
196
224
|
// Queue the message for batch processing instead of immediate processing
|
|
197
225
|
messageQueue.push({ msg, send, done });
|
|
198
226
|
if (messageQueue.length >= MAX_BATCH_SIZE) {
|
|
199
227
|
processBatch();
|
|
200
228
|
}
|
|
229
|
+
|
|
230
|
+
// Also handle error tracking for messages with errors or offline status
|
|
231
|
+
if ((msg.payload.error !== undefined && msg.payload.error !== "none") ||
|
|
232
|
+
(msg.payload.status !== undefined && msg.payload.status === "offline")) {
|
|
233
|
+
setErrorTopics(msg);
|
|
234
|
+
}
|
|
201
235
|
} else if (msg.type === "sendMqttStats") {
|
|
202
236
|
// Make sure we have the latest statBlock values before sending stats
|
|
203
237
|
syncStatBlockWithWorkerResults().then(() => {
|
|
204
238
|
let statBlock = node.statBlock;
|
|
239
|
+
let statCounts = node.statCounts || {};
|
|
240
|
+
|
|
241
|
+
// Calculate appropriate totals for percentage calculations
|
|
242
|
+
const readCount = statCounts.readCount || 0;
|
|
243
|
+
const discoveryCount = statCounts.discoveryCount || 0;
|
|
244
|
+
const totalDevices = statCounts.totalDevices || 1; // Fallback to 1 to prevent division by zero
|
|
245
|
+
|
|
246
|
+
// Send raw values
|
|
205
247
|
for (let key in statBlock) {
|
|
206
248
|
let value = statBlock[key];
|
|
207
249
|
let keyText = key.toUpperCase();
|
|
@@ -211,6 +253,35 @@ module.exports = function (RED) {
|
|
|
211
253
|
};
|
|
212
254
|
node.send(newMsg);
|
|
213
255
|
}
|
|
256
|
+
|
|
257
|
+
// Send percentage values
|
|
258
|
+
for (let key in statBlock) {
|
|
259
|
+
let rawValue = statBlock[key];
|
|
260
|
+
let percentage = 0;
|
|
261
|
+
|
|
262
|
+
// Calculate percentage based on appropriate denominator (rounded to 2 decimal places)
|
|
263
|
+
if (key === 'unmapped') {
|
|
264
|
+
// Unmapped points are from discovery list
|
|
265
|
+
percentage = discoveryCount > 0 ? Math.round((rawValue / discoveryCount) * 10000) / 100 : 0;
|
|
266
|
+
} else if (key === 'offlinePercentage') {
|
|
267
|
+
// Already a percentage, just round to 2 decimal places
|
|
268
|
+
percentage = Math.round(rawValue * 100) / 100;
|
|
269
|
+
} else if (key === 'deviceIdConflict') {
|
|
270
|
+
// Device conflicts as percentage of total devices
|
|
271
|
+
percentage = totalDevices > 0 ? Math.round((rawValue / totalDevices) * 10000) / 100 : 0;
|
|
272
|
+
} else {
|
|
273
|
+
// All other stats (ok, error, missing, warnings, moved, deviceIdChange) are based on read list
|
|
274
|
+
percentage = readCount > 0 ? Math.round((rawValue / readCount) * 10000) / 100 : 0;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
let keyText = key.toUpperCase();
|
|
278
|
+
let percentageMsg = {
|
|
279
|
+
topic: `EDGE_DEVICE_${node.siteName}/BACNETSTATS/${keyText}PERCENTAGE`,
|
|
280
|
+
payload: percentage,
|
|
281
|
+
};
|
|
282
|
+
node.send(percentageMsg);
|
|
283
|
+
}
|
|
284
|
+
|
|
214
285
|
if (done) done();
|
|
215
286
|
});
|
|
216
287
|
|
|
@@ -419,6 +490,10 @@ module.exports = function (RED) {
|
|
|
419
490
|
topicData.error = error;
|
|
420
491
|
entryChanged = true;
|
|
421
492
|
}
|
|
493
|
+
if (status !== undefined && topicData.status !== status) {
|
|
494
|
+
topicData.status = status;
|
|
495
|
+
entryChanged = true;
|
|
496
|
+
}
|
|
422
497
|
|
|
423
498
|
if (entryChanged) {
|
|
424
499
|
topicData.key = topicData.deviceID + ":" + topicData.objectType + ":" + topicData.objectInstance;
|
|
@@ -436,8 +511,8 @@ module.exports = function (RED) {
|
|
|
436
511
|
dirtyFlags.offlinePercentage = true;
|
|
437
512
|
}
|
|
438
513
|
|
|
439
|
-
// Update the node status
|
|
440
|
-
|
|
514
|
+
// Update the node status with combined information
|
|
515
|
+
updateNodeStatus();
|
|
441
516
|
|
|
442
517
|
// Periodically call getModelStats to keep model stats updated
|
|
443
518
|
// Use a debounce pattern to avoid calling it too frequently
|
|
@@ -581,6 +656,7 @@ module.exports = function (RED) {
|
|
|
581
656
|
function setErrorTopics(msg) {
|
|
582
657
|
let topic = msg.topic;
|
|
583
658
|
let error = msg.payload.error;
|
|
659
|
+
let status = msg.payload.status;
|
|
584
660
|
|
|
585
661
|
// Extract properties only if they exist
|
|
586
662
|
let deviceID = msg.payload.meta?.device?.deviceId;
|
|
@@ -595,7 +671,8 @@ module.exports = function (RED) {
|
|
|
595
671
|
? msg.payload.meta.device.address.address
|
|
596
672
|
: msg.payload.meta?.device?.address;
|
|
597
673
|
|
|
598
|
-
|
|
674
|
+
// Track entries with explicit errors or offline status
|
|
675
|
+
if ((error !== undefined && error !== "none") || (status !== undefined && status === "offline")) {
|
|
599
676
|
// Use the cache instead of direct flow context access
|
|
600
677
|
cachedData.entriesWithErrors.set(topic, {
|
|
601
678
|
deviceID: deviceID,
|
|
@@ -605,7 +682,7 @@ module.exports = function (RED) {
|
|
|
605
682
|
displayName: displayName,
|
|
606
683
|
deviceName: deviceName,
|
|
607
684
|
ipAddress: ipAddress,
|
|
608
|
-
error: error,
|
|
685
|
+
error: error || (status === "offline" ? "Point offline" : "N/A"),
|
|
609
686
|
});
|
|
610
687
|
|
|
611
688
|
// Mark as dirty so it will be synced to flow context
|
|
@@ -771,12 +848,11 @@ module.exports = function (RED) {
|
|
|
771
848
|
// Force sync with flow context to ensure data is immediately available
|
|
772
849
|
syncWithFlowContext();
|
|
773
850
|
|
|
774
|
-
// Update the node status
|
|
775
|
-
|
|
851
|
+
// Update the node status with combined information
|
|
852
|
+
updateNodeStatus();
|
|
776
853
|
}
|
|
777
854
|
|
|
778
855
|
function getPublishedPointsList() {
|
|
779
|
-
node.warn("Generating Published Points List...");
|
|
780
856
|
let flow = context.flow;
|
|
781
857
|
let now = new Date();
|
|
782
858
|
|
|
@@ -149,6 +149,7 @@ function processModelStats(data) {
|
|
|
149
149
|
// Store the data under all variations
|
|
150
150
|
const publishData = {
|
|
151
151
|
error: data.error || "N/A",
|
|
152
|
+
status: data.status || "N/A",
|
|
152
153
|
presentValue: data.presentValue,
|
|
153
154
|
bacnetLastSeen: data.bacnetLastSeen
|
|
154
155
|
};
|
|
@@ -232,15 +233,24 @@ function processModelStats(data) {
|
|
|
232
233
|
|
|
233
234
|
if (DiscoveryKeyMatch) {
|
|
234
235
|
if (PublishPointTopicMatch) {
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
236
|
+
const publishData = PublishTopicsNormalized.get(matchVariation);
|
|
237
|
+
const error = publishData.error;
|
|
238
|
+
const status = publishData.status;
|
|
239
|
+
|
|
240
|
+
// Check if point has error or is offline
|
|
241
|
+
if ((error !== "none" && error !== "N/A") || status === "offline") {
|
|
242
|
+
if (status === "offline") {
|
|
243
|
+
PointResult.dataModelStatus = `Point Error - Matched in Discovery / Data Model and publishing, however point status is offline`;
|
|
244
|
+
} else {
|
|
245
|
+
PointResult.dataModelStatus = `Point Error - Matched in Discovery / Data Model and publishing, however BACNet Error is present: ${error}`;
|
|
246
|
+
}
|
|
238
247
|
statBlock.error++;
|
|
239
248
|
} else {
|
|
240
249
|
PointResult.dataModelStatus = "Point Ok - Matched in Discovery / Data Model and publishing";
|
|
241
250
|
statBlock.ok++;
|
|
242
251
|
}
|
|
243
252
|
PointResult.error = error;
|
|
253
|
+
PointResult.status = status; // Also store the status for reference
|
|
244
254
|
pointOkCount++;
|
|
245
255
|
} else {
|
|
246
256
|
if (DiscoveryPointMap[ReadPointKey].objectName !== ReadPointObj.pointName) {
|