@strato-automation/node-red-contrib-strato-automation
Version:
Strato Automation Official Node Red Library
2,858 lines • 127 kB
HTML
<!-- CodeRabbit: BACnet Controller node UI implementation -->
<script type="text/javascript">
var bacnetGuidRewriteHistory = [];
function generateNodeIdSafe() {
try {
if (typeof generateNodeId === "function") return generateNodeId();
} catch (error) {}
try {
if (RED && RED.nodes && typeof RED.nodes.id === "function") return RED.nodes.id();
} catch (error) {}
return "n" + Math.random().toString(16).slice(2) + Date.now().toString(16);
}
function generateGuidSafe() {
try {
if (window.crypto && crypto.randomUUID) return crypto.randomUUID();
} catch (error) {}
return "g" + Math.random().toString(16).slice(2) + Date.now().toString(16);
}
function rectanglesOverlap(rectA, rectB) {
return !(
rectA.x + rectA.w <= rectB.x ||
rectB.x + rectB.w <= rectA.x ||
rectA.y + rectA.h <= rectB.y ||
rectB.y + rectB.h <= rectA.y
);
}
function getNodeRectangle(node) {
return {
x: (node.x || 0) - 55,
y: (node.y || 0) - 12,
w: 110,
h: 44
};
}
function hasNodeOverlapInLayer(layerId, rectangle, ignoreNodeId) {
var nodesInLayer = RED.nodes && typeof RED.nodes.filterNodes === "function" ? RED.nodes.filterNodes({ z: layerId }) : [];
for (var index = 0; index < nodesInLayer.length; index++) {
var node = nodesInLayer[index];
if (!node || !node.id || node.id === ignoreNodeId) continue;
var nodeDefinition = node._def || {};
if (nodeDefinition.category === "config") continue;
var nodeRectangle = getNodeRectangle(node);
if (rectanglesOverlap(rectangle, nodeRectangle)) return true;
}
return false;
}
function placeNodeAvoidingCollisions(node, options) {
options = options || {};
var deltaX = options.dx || 0;
var deltaY = options.dy || 60;
var maxTries = options.max || 300;
var baseX = (options.baseX != null ? options.baseX : node.x || 0) + deltaX;
var baseY = options.baseY != null ? options.baseY : node.y || 0;
var layerId = node.z;
var tryRectangle = { x: baseX - 55, y: baseY - 12, w: 110, h: 44 };
var tries = 0;
// First check if the initial position is free
if (!hasNodeOverlapInLayer(layerId, tryRectangle, node.id)) {
node.x = baseX;
node.y = baseY;
node.dirty = true;
node.changed = true;
return node;
}
// Simple incremental search downward (original behavior, but with better collision detection)
while (hasNodeOverlapInLayer(layerId, tryRectangle, node.id) && tries < maxTries) {
baseY += deltaY;
tryRectangle.y = baseY - 12;
tries++;
}
node.x = baseX;
node.y = baseY;
node.dirty = true;
node.changed = true;
return node;
}
function addPointNode(RED, sourceNode, nodeData) {
if (!nodeData.id) nodeData.id = generateNodeIdSafe();
if (nodeData.z == null) nodeData.z = sourceNode.z;
if (nodeData.x == null) nodeData.x = (sourceNode.x || 0) + 55;
if (nodeData.y == null) nodeData.y = sourceNode.y || 0;
// Store the intended position before import (always use explicit position, no collision avoidance)
var intendedX = nodeData.x;
var intendedY = nodeData.y;
RED.view.importNodes(nodeData);
var addedNode = RED.nodes.node(nodeData.id);
if (addedNode) {
// Always use the explicit position from proposeYSlots - no collision avoidance
// Set position immediately after import to prevent any automatic repositioning
addedNode.x = intendedX;
addedNode.y = intendedY;
addedNode.dirty = true;
addedNode.changed = true;
// Force redraw to ensure position is applied
try {
if (RED.view && RED.view.redraw) {
RED.view.redraw();
}
} catch (e) {
// Ignore errors
}
}
return addedNode;
}
function selectNewNodesInEditor(nodesList) {
nodesList = (nodesList || []).filter(Boolean);
if (!nodesList.length) return;
try {
if (RED.view && typeof RED.view.clearSelection === "function") RED.view.clearSelection();
if (RED.view && typeof RED.view.select === "function") {
RED.view.select({ nodes: nodesList, links: [], groups: [] });
} else {
nodesList.forEach(function (node) {
node.selected = true;
});
}
} finally {
if (RED.view && typeof RED.view.redraw === "function") RED.view.redraw(true);
}
}
function distanceBetweenNodes(nodeA, nodeB) {
var deltaX = (nodeA.x || 0) - (nodeB.x || 0);
var deltaY = (nodeA.y || 0) - (nodeB.y || 0);
return Math.sqrt(deltaX * deltaX + deltaY * deltaY);
}
function recordGuidRewriteEvent(oldGuid, newGuid, controllerNode) {
bacnetGuidRewriteHistory.push({
oldG: String(oldGuid || ""),
newG: String(newGuid || ""),
z: controllerNode.z,
x: controllerNode.x || 0,
y: controllerNode.y || 0,
when: Date.now()
});
bacnetGuidRewriteHistory = bacnetGuidRewriteHistory.filter(function (rewriteEntry) {
return Date.now() - rewriteEntry.when < 60000;
});
}
function maybeRebindPointToNewGuid(pointNode) {
if (!pointNode || !(pointNode.type === "bacnet-point" || pointNode.type === "bacnet-point-in" || pointNode.type === "bacnet-point-out")) return;
var controllerGuid = String(pointNode.controller_guid || "");
if (!controllerGuid) return;
var candidateRewrites = bacnetGuidRewriteHistory.filter(function (rewriteEntry) {
return rewriteEntry.oldG === controllerGuid && rewriteEntry.z === pointNode.z;
});
if (!candidateRewrites.length) return;
candidateRewrites.sort(function (rewriteA, rewriteB) {
return distanceBetweenNodes(rewriteA, pointNode) - distanceBetweenNodes(rewriteB, pointNode);
});
var bestRewrite = candidateRewrites[0];
var now = Date.now();
var recentlyAdded = pointNode.__bac_added_at && (now - pointNode.__bac_added_at) < 10000;
if (!recentlyAdded) return;
if (distanceBetweenNodes(bestRewrite, pointNode) > 400) return;
pointNode.controller_guid = bestRewrite.newG;
pointNode.changed = true;
pointNode.__bac_added_at = 0;
try {
RED.nodes.dirty(true);
RED.view.redraw(true);
} catch (error) {}
}
/**
* Rebind all points in the same flow that reference the old GUID to use the new GUID.
* This is called when a controller is pasted and gets a new GUID.
* @param {string} oldGuid - The old controller GUID
* @param {string} newGuid - The new controller GUID
* @param {string} flowId - The flow ID (z property) to search within
*/
function rebindAllPointsInFlow(oldGuid, newGuid, flowId) {
if (!oldGuid || !newGuid || !flowId) return;
var allPoints = RED.nodes && RED.nodes.filterNodes ? RED.nodes.filterNodes({
type: function(type) {
return type === "bacnet-point" || type === "bacnet-point-in" || type === "bacnet-point-out";
},
z: flowId
}) : [];
var reboundCount = 0;
allPoints.forEach(function (pointNode) {
if (!pointNode || !pointNode.id) return;
var pointGuid = String(pointNode.controller_guid || "");
if (pointGuid === oldGuid) {
pointNode.controller_guid = newGuid;
pointNode.changed = true;
reboundCount++;
}
});
if (reboundCount > 0) {
try {
RED.nodes.dirty(true);
RED.view.redraw(true);
} catch (error) {}
}
}
/**
* Ensure a controller node has a unique GUID.
* Only changes the GUID if:
* 1. The node has no GUID (new node)
* 2. The node was just added (paste/copy operation)
*
* GUIDs should NEVER change for existing nodes that are being edited.
* @param {string} nodeId - The node ID to check
* @param {boolean} isNewlyAdded - True if this node was just added (paste/palette), false if editing existing node
*/
function ensureUniqueGuidValueForNodeId(nodeId, isNewlyAdded) {
try {
var controllerNode = RED.nodes.node(nodeId);
if (!controllerNode) return;
var guidValue = String(controllerNode.guid || "");
// If no GUID exists, generate one (new node)
if (!guidValue) {
guidValue = generateGuidSafe();
controllerNode.guid = guidValue;
// Update the input field if the editor is open
var guidInput = $("#node-input-guid");
if (guidInput.length) guidInput.val(guidValue);
controllerNode.changed = true;
try {
RED.nodes.dirty(true);
} catch (error) {}
return;
}
// Only check for duplicates and change GUID if this is a newly added node (paste/copy)
// Existing nodes being edited should NEVER have their GUID changed
if (!isNewlyAdded) {
return;
}
var allControllers = RED.nodes && RED.nodes.filterNodes ? RED.nodes.filterNodes({ type: "bacnet-controller" }) : [];
var hasDuplicate = false;
for (var index = 0; index < allControllers.length; index++) {
var otherController = allControllers[index];
if (otherController && otherController.id !== nodeId && String(otherController.guid || "") === guidValue) {
hasDuplicate = true;
break;
}
}
// Only change GUID if duplicate found AND this is a newly added node
if (hasDuplicate) {
var oldGuid = guidValue;
var newGuid = generateGuidSafe();
controllerNode.guid = newGuid;
var guidInput = $("#node-input-guid");
if (guidInput.length) guidInput.val(newGuid);
controllerNode.changed = true;
recordGuidRewriteEvent(oldGuid, newGuid, controllerNode);
// Immediately rebind all points in the same flow that reference the old GUID
// This handles the case where controller + points are pasted together
rebindAllPointsInFlow(oldGuid, newGuid, controllerNode.z);
// Also try the distance-based rebinding for any points that might have been added before the controller
var nearbyNodes = RED.nodes && RED.nodes.filterNodes ? RED.nodes.filterNodes({ z: controllerNode.z }) : [];
nearbyNodes.forEach(function (pointNode) {
if (pointNode && pointNode.id !== controllerNode.id) maybeRebindPointToNewGuid(pointNode);
});
try {
RED.nodes.dirty(true);
RED.view.redraw(true);
} catch (error) {}
}
} catch (error) {}
}
RED.events.on("nodes:add", function (addedNode) {
try {
if (!addedNode) return;
if (addedNode.type === "bacnet-controller") {
// Node was just added (from palette or paste) - check for duplicates
ensureUniqueGuidValueForNodeId(addedNode.id, true);
// After a short delay, check again for any points that might have been added after the controller
// This handles the case where points are pasted after the controller
setTimeout(function() {
try {
var controllerNode = RED.nodes.node(addedNode.id);
if (controllerNode && controllerNode.guid) {
var recentRewrites = bacnetGuidRewriteHistory.filter(function (rewriteEntry) {
return rewriteEntry.z === controllerNode.z &&
rewriteEntry.newG === controllerNode.guid &&
(Date.now() - rewriteEntry.when) < 5000; // Within last 5 seconds
});
if (recentRewrites.length > 0) {
var rewrite = recentRewrites[0];
rebindAllPointsInFlow(rewrite.oldG, rewrite.newG, controllerNode.z);
}
}
} catch (error) {}
}, 100);
} else if (addedNode.type === "bacnet-point" || addedNode.type === "bacnet-point-in" || addedNode.type === "bacnet-point-out") {
addedNode.__bac_added_at = Date.now();
maybeRebindPointToNewGuid(addedNode);
// Also check if there's a recently added controller in the same flow that had its GUID changed
setTimeout(function() {
try {
var pointNode = RED.nodes.node(addedNode.id);
if (!pointNode) return;
var pointGuid = String(pointNode.controller_guid || "");
if (!pointGuid) return;
var recentRewrites = bacnetGuidRewriteHistory.filter(function (rewriteEntry) {
return rewriteEntry.oldG === pointGuid &&
rewriteEntry.z === pointNode.z &&
(Date.now() - rewriteEntry.when) < 5000; // Within last 5 seconds
});
if (recentRewrites.length > 0) {
var rewrite = recentRewrites[0];
pointNode.controller_guid = rewrite.newG;
pointNode.changed = true;
try {
RED.nodes.dirty(true);
RED.view.redraw(true);
} catch (error) {}
}
} catch (error) {}
}, 50);
}
} catch (error) {}
});
</script>
<script type="text/javascript">
RED.nodes.registerType("bacnet-controller", {
category: "Strato Automation",
color: "#f3b567",
defaults: {
name: { value: "", label: "Name" },
ocn_server_node: { type: "ocn-server", required: true, label: "OCN Server" },
deviceInstance: { value: "" },
networkNumber: { value: "" },
deviceName: { value: "" },
modelName: { value: "" },
description: { value: "" },
address: { value: "" },
vendorId: { value: "" },
thirdParty: { value: false },
guid: { value: "" },
spawnLabelMode: { value: "instance" },
spawnObjectLabelMode: { value: "name" },
spawnSourceMode: { value: "mqtt" }
},
inputs: 0,
outputs: 0,
icon: "font-awesome/fa-microchip",
align: "left",
paletteLabel: function () {
return "BACnet Controller";
},
label: function () {
var deviceName = String(this.deviceName || "");
var deviceInstance = String(this.deviceInstance || "");
var name = String(this.name || "");
return name || (deviceName && deviceInstance ? deviceName + " " + String.fromCharCode(183) + " " + deviceInstance : "BACnet Controller");
},
oneditsave: function () {
try {
var inputsTabVisible = $("#node-bc-tab-inputs").is(":visible");
var outputsTabVisible = $("#node-bc-tab-outputs").is(":visible");
if (inputsTabVisible && $(".nr-obj-cb:checked").length > 0) {
$("#nr-obj-add").trigger("click");
}
if (outputsTabVisible && $(".nr-obj-out-cb:checked").length > 0) {
$("#nr-obj-out-add").trigger("click");
}
} catch (error) {}
},
oneditprepare: function () {
var nodeId = this.id;
var guidValue = $("#node-input-guid").val();
// Only generate GUID if it doesn't exist (new node)
if (!guidValue) {
guidValue = generateGuidSafe();
$("#node-input-guid").val(guidValue);
// Mark as newly added so duplicate check can run
var controllerNode = RED.nodes.node(nodeId);
if (controllerNode) {
controllerNode.guid = guidValue;
ensureUniqueGuidValueForNodeId(nodeId, true);
}
} else {
// Existing node being edited - ensure GUID is set but NEVER change it
ensureUniqueGuidValueForNodeId(nodeId, false);
}
var controllerInstance = RED.nodes.node(nodeId);
var validSpawnModes = { instance: true, device: true, both: true };
var validObjectLabelModes = { name: true, id: true, both: true };
function buildObjectLabel(mode, objectName, objectId) {
var cleanName = String(objectName || "").trim();
var cleanId = String(objectId || "").trim();
if (mode === "id") return cleanId || cleanName;
if (mode === "both") {
if (cleanName && cleanId) return cleanName + " (" + cleanId + ")";
return cleanName || cleanId;
}
return cleanName || cleanId;
}
function setSpawnLabelMode(mode) {
if (!validSpawnModes[mode]) mode = "instance";
$("#nr-obj-labelmode").val(mode);
$("#nr-obj-out-labelmode").val(mode);
$("#node-input-spawnLabelMode").val(mode);
if (controllerInstance) {
if (controllerInstance.spawnLabelMode !== mode) {
controllerInstance.spawnLabelMode = mode;
controllerInstance.changed = true;
try {
RED.nodes.dirty(true);
} catch (error) {}
}
}
}
function setSpawnObjectLabelMode(mode) {
if (!validObjectLabelModes[mode]) mode = "name";
$("#nr-obj-objectlabelmode").val(mode);
$("#nr-obj-out-objectlabelmode").val(mode);
$("#node-input-spawnObjectLabelMode").val(mode);
if (controllerInstance) {
if (controllerInstance.spawnObjectLabelMode !== mode) {
controllerInstance.spawnObjectLabelMode = mode;
controllerInstance.changed = true;
try {
RED.nodes.dirty(true);
} catch (error) {}
}
}
}
var validSourceModes = { mqtt: true, api: true };
function setSpawnSourceMode(mode) {
if (!validSourceModes[mode]) mode = "mqtt";
$("#nr-obj-sourcemode").val(mode);
$("#node-input-spawnSourceMode").val(mode);
if (controllerInstance) {
if (controllerInstance.spawnSourceMode !== mode) {
controllerInstance.spawnSourceMode = mode;
controllerInstance.changed = true;
try {
RED.nodes.dirty(true);
} catch (error) {}
}
}
}
function setSpawnAcknowledgedMqtt(checked) {
$("#nr-obj-out-acknowledged").prop("checked", !!checked);
$("#node-input-spawnAcknowledgedMqtt").prop("checked", !!checked);
if (controllerInstance) {
if (controllerInstance.spawnAcknowledgedMqtt !== !!checked) {
controllerInstance.spawnAcknowledgedMqtt = !!checked;
controllerInstance.changed = true;
try {
RED.nodes.dirty(true);
} catch (error) {}
}
}
}
function toAbbr(typeValue) {
var typeMap = { 0: "AI", 1: "AO", 2: "AV", 3: "BI", 4: "BO", 5: "BV", 12: "PID", 19: "MSV" };
if (typeof typeValue === "number") return typeMap[typeValue] || String(typeValue);
var stringValue = String(typeValue || "").trim().toUpperCase();
if (/^\d+$/.test(stringValue)) return typeMap[+stringValue] || stringValue;
return typeMap[stringValue] != null ? typeMap[stringValue] : stringValue;
}
function buildDeviceLabel(labelMode, deviceName, deviceInstance) {
var cleanName = String(deviceName || "").trim();
var cleanInstance = String(deviceInstance || "").trim();
if (labelMode === "device") return cleanName || cleanInstance;
if (labelMode === "both") {
var dot = " " + String.fromCharCode(183) + " ";
if (cleanName && cleanInstance) return cleanName + dot + cleanInstance;
return cleanName || cleanInstance;
}
return cleanInstance || cleanName;
}
function renderSelectedControllerLabel() {
var deviceName = $("#node-input-deviceName").val() || "";
var deviceInstance = $("#node-input-deviceInstance").val() || "";
var networkNumber = $("#node-input-networkNumber").val() || "";
// If deviceName is empty but modelName exists, use modelName as fallback
if (!deviceName) {
deviceName = $("#node-input-modelName").val() || "";
}
var dot = " " + String.fromCharCode(183) + " ";
var netLabel = networkNumber ? "Net " + networkNumber : "No net number";
$("#nr-bc-selected").text(deviceName && deviceInstance ? deviceName + dot + deviceInstance + " @ " + netLabel : "None selected");
}
function highlightCurrentControllerRow() {
var selectedDeviceInstance = String($("#node-input-deviceInstance").val() || "");
var selectedNetworkNumber = String($("#node-input-networkNumber").val() || "");
$(".nr-bacnet-row").each(function () {
var row = $(this);
var rowDeviceInstance = String(row.data("di") || row.attr("data-di") || "");
var rowNetworkNumber = String(row.data("nn") || row.attr("data-nn") || "");
var diMatch = rowDeviceInstance === selectedDeviceInstance;
var nnMatch = !selectedNetworkNumber || !rowNetworkNumber || rowNetworkNumber === selectedNetworkNumber;
if (diMatch && nnMatch) {
row.css("outline", "2px solid #4caf50");
} else {
row.css("outline", "none");
}
});
}
setSpawnLabelMode((controllerInstance && controllerInstance.spawnLabelMode) || $("#node-input-spawnLabelMode").val() || "instance");
setSpawnObjectLabelMode((controllerInstance && controllerInstance.spawnObjectLabelMode) || $("#node-input-spawnObjectLabelMode").val() || "name");
setSpawnSourceMode((controllerInstance && controllerInstance.spawnSourceMode) || $("#node-input-spawnSourceMode").val() || "mqtt");
setSpawnAcknowledgedMqtt(controllerInstance && controllerInstance.spawnAcknowledgedMqtt !== false);
function propagateSelectionToLinkedNodes() {
var guid = $("#node-input-guid").val() || "";
var deviceInstance = $("#node-input-deviceInstance").val() || "";
var deviceName = $("#node-input-deviceName").val() || "";
var networkNumber = $("#node-input-networkNumber").val() || "";
["bacnet-point", "bacnet-point-in", "bacnet-point-out"].forEach(function (nodeType) {
var pointNodes = RED.nodes && RED.nodes.filterNodes ? RED.nodes.filterNodes({ type: nodeType }) : [];
pointNodes.forEach(function (pointNode) {
if (String(pointNode.controller_guid || "") !== String(guid)) return;
if (nodeType === "bacnet-point-out") {
pointNode.deviceInstance = deviceInstance;
pointNode.deviceName = deviceName;
pointNode.networkNumber = networkNumber;
} else {
pointNode.controllerDeviceInstance = deviceInstance;
pointNode.controllerName = deviceName;
}
var objectName = (pointNode.objectName || "").trim();
var labelMode = String(pointNode.labelMode || "instance");
var objectLabelMode = String(pointNode.objectLabelMode || "name");
var deviceLabelName = nodeType === "bacnet-point-out" ? (pointNode.deviceName || "") : (pointNode.controllerName || "");
var deviceLabelInstance = nodeType === "bacnet-point-out" ? (pointNode.deviceInstance || "") : (pointNode.controllerDeviceInstance || "");
var deviceLabel = buildDeviceLabel(labelMode, deviceLabelName, deviceLabelInstance);
var hasObjectType = pointNode.objectType != null && String(pointNode.objectType) !== "";
var hasObjectInstance = pointNode.objectInstance != null && String(pointNode.objectInstance) !== "";
var objectId = hasObjectType && hasObjectInstance ? toAbbr(pointNode.objectType) + "-" + pointNode.objectInstance : "";
var objectLabel = buildObjectLabel(objectLabelMode, objectName, objectId);
var separator = " " + String.fromCharCode(183) + " ";
if (labelMode === "both") {
var dot = " " + String.fromCharCode(183) + " ";
var labelParts = [];
if (objectLabel) labelParts.push(objectLabel);
if (deviceLabelName) labelParts.push(deviceLabelName);
if (deviceLabelInstance) labelParts.push(deviceLabelInstance);
if (!labelParts.length) labelParts.push(objectId || objectName || "");
pointNode.previewLabel = labelParts.length
? labelParts.join(dot)
: objectLabel ||
deviceLabel ||
objectId ||
(nodeType === "bacnet-point-out"
? "BACnet Point Out"
: "BACnet Point");
} else {
if (objectLabel && deviceLabel) {
pointNode.previewLabel = objectLabel + separator + deviceLabel;
} else if (objectLabel) {
pointNode.previewLabel = objectLabel;
} else if (deviceLabel) {
pointNode.previewLabel = deviceLabel;
} else {
pointNode.previewLabel =
objectId ||
objectName ||
(nodeType === "bacnet-point-out"
? "BACnet Point Out"
: "BACnet Point");
}
}
pointNode.changed = true;
});
});
try {
RED.nodes.dirty(true);
RED.view.redraw(true);
} catch (error) {}
applyDefaultSourceToLinkedPoints();
}
function propagateOcnToLinkedPoints(configId) {
var guid = $("#node-input-guid").val() || "";
var ocnServerNodeId = String(configId || "");
["bacnet-point", "bacnet-point-in", "bacnet-point-out"].forEach(function (nodeType) {
var pointNodes = RED.nodes && RED.nodes.filterNodes ? RED.nodes.filterNodes({ type: nodeType }) : [];
pointNodes.forEach(function (pointNode) {
if (String(pointNode.controller_guid || "") !== String(guid)) return;
var previousValue =
typeof pointNode.ocn_server_node === "object"
? (pointNode.ocn_server_node && pointNode.ocn_server_node.id) || ""
: String(pointNode.ocn_server_node || "");
if (previousValue !== ocnServerNodeId) {
pointNode.ocn_server_node = ocnServerNodeId;
pointNode.changed = true;
}
});
});
try {
RED.nodes.dirty(true);
RED.view.redraw(true);
} catch (error) {}
applyDefaultSourceToLinkedPoints();
}
function applyDefaultSourceToLinkedPoints() {
var guid = $("#node-input-guid").val() || "";
if (!guid) return;
var desiredSource = determineDefaultPointSource();
var desiredAcknowledged = determineDefaultAcknowledgedMqtt();
var isAnyPointDirty = false;
["bacnet-point", "bacnet-point-in"].forEach(function (nodeType) {
var pointNodes = RED.nodes && RED.nodes.filterNodes ? RED.nodes.filterNodes({ type: nodeType }) : [];
pointNodes.forEach(function (pointNode) {
if (String(pointNode.controller_guid || "") !== String(guid)) return;
var currentSource = String(pointNode.source || "").trim();
if (!currentSource || currentSource === "") {
pointNode.source = desiredSource;
pointNode.changed = true;
isAnyPointDirty = true;
}
});
});
var outNodes = RED.nodes && RED.nodes.filterNodes ? RED.nodes.filterNodes({ type: "bacnet-point-out" }) : [];
outNodes.forEach(function (pointNode) {
if (String(pointNode.controller_guid || "") !== String(guid)) return;
if (pointNode.acknowledged_mqtt === undefined) {
pointNode.acknowledged_mqtt = desiredAcknowledged;
pointNode.changed = true;
isAnyPointDirty = true;
}
});
if (isAnyPointDirty) {
try {
RED.nodes.dirty(true);
RED.view.redraw(true);
} catch (error) {}
}
}
var activeTabId = "node-bc-tab-general";
var tabs = RED.tabs.create({
id: "node-bc-tabs",
onchange: function (tab) {
activeTabId = tab.id;
$("#node-bc-tabs-content").children().hide();
$("#" + tab.id).show();
if (tab.id === "node-bc-tab-inputs") {
// Always reload objects when switching to this tab to ensure fresh data
var configId = $("#node-input-ocn_server_node").val() || "";
var deviceInstance = $("#node-input-deviceInstance").val() || "";
var networkNumber = $("#node-input-networkNumber").val() || "";
if (configId && configId !== "_ADD_" && deviceInstance) {
// Always reload to get latest data (watcher might have fetched data since last check)
loadObjectsForCurrentController(true);
} else {
// If no controller selected, render empty list with message
renderObjectsList([], "", "in");
}
}
if (tab.id === "node-bc-tab-outputs") {
// Always reload objects when switching to this tab to ensure fresh data
var configId = $("#node-input-ocn_server_node").val() || "";
var deviceInstance = $("#node-input-deviceInstance").val() || "";
var networkNumber = $("#node-input-networkNumber").val() || "";
if (configId && configId !== "_ADD_" && deviceInstance) {
// Always reload to get latest data (watcher might have fetched data since last check)
loadObjectsForOutputs(true);
} else {
// If no controller selected, render empty list with message
renderObjectsList([], "", "out");
}
}
}
});
tabs.addTab({ id: "node-bc-tab-general", label: "General", iconClass: "fa fa-sliders" });
tabs.addTab({ id: "node-bc-tab-inputs", label: "Read Objects", iconClass: "fa fa-sign-in" });
tabs.addTab({ id: "node-bc-tab-outputs", label: "Write Objects", iconClass: "fa fa-sign-out" });
tabs.activateTab("node-bc-tab-general");
var lastDevices = [];
var isLoadingControllers = false;
var isLoadingPoints = false;
var isLoadingOutputs = false;
var initialConfigId = $("#node-input-ocn_server_node").val() || "";
var selectedKeys = new Set();
var selectedOutKeys = new Set();
var currentObjectsCache = [];
function parseErrorFromAjax(xhr) {
try {
if (xhr && typeof xhr.responseText === "string") {
var text = xhr.responseText.replace(/^\)\]\}',?\s*/, "");
var json = JSON.parse(text);
if (json && json.error) return String(json.error);
}
} catch (error) {}
return "HTTP " + (xhr && xhr.status ? xhr.status : "");
}
function renderFilteredControllers(devices, query) {
var container = $("#nr-bacnet-list");
container.empty();
var controllersArray = Array.isArray(devices) ? devices : [];
var searchTerm = (query || "").toLowerCase();
if (searchTerm) {
controllersArray = controllersArray.filter(function (deviceInfo) {
return (
(deviceInfo.deviceName || "").toLowerCase().includes(searchTerm) ||
(deviceInfo.modelName || "").toLowerCase().includes(searchTerm) ||
String(deviceInfo.deviceInstance || "").includes(searchTerm) ||
String(deviceInfo.networkNumber || "").includes(searchTerm)
);
});
}
if (!controllersArray.length) {
container.append('<div style="padding:8px;color:#666">No controllers</div>');
return;
}
controllersArray.forEach(function (deviceInfo) {
var row = $(
'<div class="nr-bacnet-row" tabindex="0" style="display:flex;align-items:center;justify-content:space-between;padding:8px 10px;border:1px solid #ddd;border-radius:8px;margin-bottom:8px;cursor:pointer"></div>'
);
row.attr("data-di", String(deviceInfo.deviceInstance)).attr("data-nn", deviceInfo.networkNumber != null ? String(deviceInfo.networkNumber) : "");
row.data("di", String(deviceInfo.deviceInstance)).data("nn", deviceInfo.networkNumber != null ? String(deviceInfo.networkNumber) : "");
var leftColumn = $('<div style="min-width:0;flex:1"></div>');
var nameElement = $('<div style="font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></div>').text(
deviceInfo.deviceName || "(unnamed)"
);
var dot = " " + "·" + " ";
var thirdPartyBadge = deviceInfo.thirdParty ? dot + "3rd-party" : "";
var netLabel = deviceInfo.networkNumber != null && String(deviceInfo.networkNumber) !== "" ? "Net " + deviceInfo.networkNumber : "No net number";
var detailsElement = $('<div style="font-size:12px;color:#666"></div>').text(
"Inst " +
deviceInfo.deviceInstance +
dot +
netLabel +
(deviceInfo.modelName ? dot + deviceInfo.modelName : "") +
thirdPartyBadge
);
leftColumn.append(nameElement).append(detailsElement);
// Add online/offline indicator (Kendo checkCircleIcon per ICON_REFERENCE)
// Online: green; Offline: same icon greyed out
var isOnline = deviceInfo.isOnline !== false; // Default to true for backward compatibility
var iconColor = isOnline ? "#4caf50" : "#9e9e9e";
var svgContent = '<path fill="currentColor" d="M256 32C132.3 32 32 132.3 32 256s100.3 224 224 224 224-100.3 224-224S379.7 32 256 32m-32 352L96 256l45-45 83 83 147-147 45 45z"/>';
var statusIcon = $('<span style="flex-shrink:0;margin-left:8px;width:16px;height:16px;display:inline-flex;align-items:center;justify-content:center;color:' + iconColor + ';" title="' + (isOnline ? 'Online' : 'Offline') + '"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="16" height="16">' + svgContent + '</svg></span>');
row.append(leftColumn);
row.append(statusIcon);
row.on("click keypress", function (event) {
if (event.type === "keypress" && event.key !== "Enter") return;
var previousDeviceInstance = $("#node-input-deviceInstance").val() || "";
var previousNetworkNumber = $("#node-input-networkNumber").val() || "";
var newDeviceInstance = deviceInfo.deviceInstance;
var newNetworkNumber = deviceInfo.networkNumber || "";
var hasChanged = String(previousDeviceInstance) !== String(newDeviceInstance) || String(previousNetworkNumber) !== String(newNetworkNumber);
$("#node-input-deviceInstance").val(deviceInfo.deviceInstance);
$("#node-input-networkNumber").val(deviceInfo.networkNumber != null ? deviceInfo.networkNumber : "");
// Use deviceName from deviceInfo, fallback to modelName if deviceName is empty
var selectedDeviceName = deviceInfo.deviceName || deviceInfo.modelName || "";
$("#node-input-deviceName").val(selectedDeviceName);
$("#node-input-modelName").val(deviceInfo.modelName || "");
$("#node-input-description").val(deviceInfo.description || "");
$("#node-input-address").val(deviceInfo.address || "");
$("#node-input-vendorId").val(deviceInfo.vendorId != null ? String(deviceInfo.vendorId) : "");
$("#node-input-thirdParty").prop("checked", !!deviceInfo.thirdParty);
$("#nr-bacnet-tip").hide();
// Update controllerInstance deviceName to match selected controller
if (controllerInstance) {
controllerInstance.deviceName = selectedDeviceName;
}
renderSelectedControllerLabel();
highlightCurrentControllerRow();
if (hasChanged && controllerInstance) {
controllerInstance.changed = true;
try {
RED.nodes.dirty(true);
} catch (error) {}
// Don't refresh labels here - labels will update on deploy
// The backend refreshMapping will happen on deploy via the deploy event handler
}
propagateSelectionToLinkedNodes();
// Always load objects list when a controller is selected, so it's ready when user switches tabs
// But wait a bit if controllers are currently loading (auto-refresh might be in progress)
if (hasChanged && newDeviceInstance) {
// If controllers are loading (auto-refresh in progress), wait for it to complete
if (isLoadingControllers) {
// Wait for refresh to complete, then load objects
var checkRefreshComplete = setInterval(function() {
if (!isLoadingControllers) {
clearInterval(checkRefreshComplete);
// Refresh completed, now load objects
loadObjectsForCurrentController(false);
loadObjectsForOutputs(false);
// If user is already on inputs/outputs tab, show tips
if (activeTabId === "node-bc-tab-inputs") loadObjectsForCurrentController(true);
if (activeTabId === "node-bc-tab-outputs") loadObjectsForOutputs(true);
}
}, 100);
// Safety timeout - stop checking after 10 seconds
setTimeout(function() {
clearInterval(checkRefreshComplete);
if (isLoadingControllers) {
// Refresh seems stuck, load objects anyway
loadObjectsForCurrentController(false);
loadObjectsForOutputs(false);
if (activeTabId === "node-bc-tab-inputs") loadObjectsForCurrentController(true);
if (activeTabId === "node-bc-tab-outputs") loadObjectsForOutputs(true);
}
}, 10000);
} else {
// No refresh in progress, load objects immediately
loadObjectsForCurrentController(false);
loadObjectsForOutputs(false);
// If user is already on inputs/outputs tab, show tips
if (activeTabId === "node-bc-tab-inputs") loadObjectsForCurrentController(true);
if (activeTabId === "node-bc-tab-outputs") loadObjectsForOutputs(true);
}
} else if (newDeviceInstance) {
// Even if controller hasn't changed, ensure objects are loaded if user switches tabs
// This handles the case where user selects the same controller again
if (activeTabId === "node-bc-tab-inputs") loadObjectsForCurrentController(true);
if (activeTabId === "node-bc-tab-outputs") loadObjectsForOutputs(true);
}
});
container.append(row);
});
highlightCurrentControllerRow();
}
function setLoadingControllers(isLoading) {
isLoadingControllers = !!isLoading;
$("#nr-refresh").prop("disabled", isLoadingControllers);
$("#nr-refresh-icon")
.removeClass("fa-refresh fa-spinner fa-spin")
.addClass(isLoadingControllers ? "fa-spinner fa-spin" : "fa-refresh");
$("#nr-bacnet-loading").css("display", isLoadingControllers ? "flex" : "none");
}
function selectionExistsInDeviceList(deviceList) {
var selectedDeviceInstance = $("#node-input-deviceInstance").val() || "";
var selectedNetworkNumber = $("#node-input-networkNumber").val() || "";
if (!selectedDeviceInstance || !selectedNetworkNumber) return true;
for (var index = 0; index < deviceList.length; index++) {
var deviceInfo = deviceList[index];
if (
String(deviceInfo.deviceInstance) === String(selectedDeviceInstance) &&
String(deviceInfo.networkNumber) === String(selectedNetworkNumber)
) {
return true;
}
}
return false;
}
function clearSelectionWithNotice() {
$("#node-input-deviceInstance").val("");
$("#node-input-networkNumber").val("");
$("#node-input-deviceName").val("");
$("#node-input-modelName").val("");
$("#node-input-description").val("");
$("#node-input-address").val("");
$("#node-input-vendorId").val("");
$("#node-input-thirdParty").prop("checked", false);
renderSelectedControllerLabel();
$("#nr-bacnet-tip").text("Cannot find this controller anymore, choose another").show();
}
function loadControllers(forceRefresh) {
// Allow recursive calls when forceRefresh is true (for auto-refresh on no cache)
if (isLoadingControllers && !forceRefresh) return;
var configId = $("#node-input-ocn_server_node").val() || "";
// Don't make request if configId is empty or the placeholder "_ADD_"
if (!configId || configId === "_ADD_") {
$("#nr-bacnet-tip").text("Select an OCN Server profile").show();
$("#nr-bacnet-list").empty();
lastDevices = []; // Clear cached devices when no config selected
setLoadingControllers(false);
return;
}
// Always use cached data unless forceRefresh is true OR no cache exists
if (!forceRefresh) {
// First check if cache actually has data by fetching the network cache summary
setLoadingControllers(true);
var shouldAutoRefresh = false;
$.ajax({
url: "bacnet-controller/fullNetworkCache",
method: "GET",
data: { configId: configId },
dataType: "json"
})
.done(function(fullNetworkData) {
// Check if cache actually has data
var hasCache = fullNetworkData.summary && fullNetworkData.summary.lastFetchedAt && fullNetworkData.summary.lastFetchedAt > 0;
if (!hasCache) {
// No cache - automatically refresh
shouldAutoRefresh = true;
setLoadingControllers(false);
// Call refresh logic directly
loadControllers(true);
return;
} else {
// Cache exists - load controllers from backend
if (!lastDevices || lastDevices.length === 0) {
// Load cached data from backend without refreshing
$.ajax({
url: "bacnet-controller/devices",
method: "GET",
cache: false,
data: { nodeId: nodeId, configId: configId, forceRefresh: "false" },
dataType: "text"
})
.done(function (_unusedUrl, _unusedStatus, xhr) {
var responseText = xhr.responseText || "[]";
try {
responseText = responseText.replace(/^\)\]\}',?\s*/, "");
} catch (error) {}
var parsedArray = [];
try {
parsedArray = JSON.parse(responseText);
} catch (error) {}
var deviceList = [];
if (Array.isArray(parsedArray)) {
deviceList = parsedArray;
} else if (parsedArray && Array.isArray(parsedArray.devices)) {
deviceList = parsedArray.devices;
}
lastDevices = deviceList;
renderFilteredControllers(lastDevices, $("#nr-bacnet-search").val());
setLoadingControllers(false);
// After refresh completes and lastDevices is populated, sync deviceName
if (forceRefresh && controllerInstance) {
setTimeout(function() {
syncDeviceNameAfterRefresh();
}, 100); // Small delay to ensure lastDevices is set
}
})
.fail(function(xhr, status, error) {
if (xhr && xhr.status === 400) {
$("#nr-bacnet-tip").text("Invalid OCN Server configuration").show();
}
lastDevices = [];
renderFilteredControllers([], $("#nr-bacnet-search").val());
setLoadingControllers(false);
});
} else {
// Use cached data from this dialog session
renderFilteredControllers(lastDevices, $("#nr-bacnet-search").val());
setLoadingControllers(false); // Ensure loading is cleared
}
return; // Exit early if cache exists
}
})
.fail(function(xhr) {
// If we can't check cache status, assume no cache and auto-refresh
setLoadingControllers(false);
loadControllers(true);
});
return; // Exit early - will either load cache or trigger refresh
}
$("#nr-bacnet-tip").hide();
setLoadingControllers(true);
$.ajax({
url: "bacnet-controller/devices",
method: "GET",
cache: false,
data: { nodeId: nodeId, configId: configId, forceRefresh: forceRefresh ? "true" : "false" },
dataType: "text"
})
.done(function (_unusedUrl, _unusedStatus, xhr) {
var responseText = xhr.responseText || "[]";
try {
responseText = responseText.replace(/^\)\]\}',?\s*/, "");
} catch (error) {}
var parsedArray = [];
try {
parsedArray = JSON.parse(responseText);
} catch (error) {}
var deviceList = Array.isArray(parsedArray) ? parsedArray : [];
lastDevices = deviceList;
if (!selectionExistsInDeviceList(lastDevices)) clearSelectionWithNotice();
// After refresh completes and lastDevices is populated, sync deviceName
// Backend updates node.deviceName in two ways:
// 1. Directly in the refresh handler (line 645 in bacnet-controller.js)
// 2. Via checkPresenceAndSetStatus() triggered by watcher "update" event
if (forceRefresh && controllerInstance) {
var selectedDeviceInstance = $("#node-input-deviceInstance").val() || "";
var selectedNetworkNumber = $("#node-input-networkNumber").val() || "";
if (selectedDeviceInstance) {
// Wait a bit for backend to update node.deviceName, then sync
setTimeout(function() {
// Re-fetch the node instance to get backend-updated deviceName
var updatedNode = RED.nodes.node(controllerInstance.id);
var deviceNameToUse = null;
// First check if backend already updated it
if (updatedNode && updatedNode.deviceName) {
var currentInputValue = $("#node-input-deviceName").val() || "";
// Backend updated it - sync input field to match
if (updatedNode.deviceName !== currentInputValue) {
deviceNameToUse = updatedNode.deviceName;
controllerInstance.deviceName = deviceNameToUse;
}
}
// If backend hasn't updated it yet, get from refreshed list (now lastDevices is populated)
if (!deviceNameToUse) {
var updatedController = lastDevices.find(function(c) {
// Handle networkNumber comparison correctly - 0 is a valid value, not falsy
var cNetworkNumber = c.networkNumber != null ? String(c.networkNumber) : "";
var selectedNetwork = selectedNetworkNumber != null && selectedNetworkNumber !== "" ? String(selectedNetworkNumber) : "";
var deviceMatch = String(c.deviceInstance) === String(selectedDeviceInstance);
var networkMatch = cNetworkNumber === selectedNetwork;
return deviceMatch && networkMatch;
});
if (updatedController && updatedController.deviceName) {
deviceNameToUse = updatedController.deviceName;
// Update node instance so it saves on deploy
if (updatedNode) {
updatedNode.deviceName = deviceNameToUse;
updatedNode.changed = true;
}
controllerInstance.deviceName = deviceNameToUse;
// Mark flow as dirty
try {
RED.nodes.dirty(true);
} catch (e) {
// Ignore errors
}
}
}
// Sync input field to node instance value (for proper save on deploy)
// This ensures Node-RED saves the correct value when dialog closes
if (deviceNameToUse) {
var currentInputValue = $("#node-input-deviceName").val() || "";
if (deviceNameToUse !== currentInputValue) {
// Update input field - this ensures it saves correctly
// Note: Updating node.deviceName causes immediate label update (can't avoid this)
// But syncing input field ensures it saves correctly on deploy
$("#node-input-deviceName").val(deviceNameToUse);
}
}
}, 500); // Wait for backend updates to complete
}
}
renderSelectedControllerLabel();
renderFilteredControllers(lastDevices, $("#nr-bacnet-search").val());
setLoadingControllers(false);
// After refresh completes, if a controller is selected, reload objects to get fresh data
var selectedDeviceInstance = $("#node-input-deviceInstance").val() || "";
if (forceRefresh && selectedDeviceInstance) {
// Small delay to ensure backend has processed the refresh and objects are available
setTimeout(function() {
loadObjectsForCurrentController(false);
loadObjectsForOutputs(false);
// If user is on inputs/outputs tab, reload with tips
if (activeTabId === "node-bc-tab-inputs") loadObjectsForCurrentController(true);
if (activeTabId === "node-bc-tab-outputs") loadObjectsForOutputs(true);
}, 500);
}
// Mark all point nodes as dirty and refresh their labels
// The backend refreshMapping() updates node properties (controllerName, controllerDeviceInstance, objectName, etc.)
// Labels are defined in HTML files and need to be refreshed from frontend using RED.view.redraw()
// Use longer delay to ensure backend refreshMapping() completes (it's async)
if (forceRefresh) {
setTimeout(function() {
refreshPointLabels();
}, 500);
}
})
.fail(function (xhr) {
var message = parseErrorFromAjax(xhr);
if (/missing|token|bearer|credential|profile|config/i.test(message)) {
$("#nr-bacnet-tip").text("Deploy for your new profile to take effect").show();
} else if (xhr.status === 401 || xhr.status === 403) {
$("#nr-bacnet-tip").text("Unauthorized: check API token in OCN Server").show();
} else if (xhr.status === 0) {
// Status 0 usually means CORS error, network error, or connection refused
$("#nr-bacnet-tip").text("Network error: Cannot connect to OCN server. Check if the server is running and accessible.").show();
} else if (xhr.status >= 500) {
$("#nr-bacnet-tip").text("Server error: OCN server returned an error. Please try again later.").show();
} else {
$("#nr-bacnet-tip").text(message || "Error loading devices").show();
}
$("#nr-bacnet-list").empty();
setLoadingControllers(false);
});
}
// refreshAllLabels is defined here but will be moved to global scope
function setLoadingPoints(isLoading) {
isLoadingPoints = !!isLoading;
$("#nr-obj-refresh").prop("disabled", isLoadingPoints);
$("#nr-obj-refresh-icon")
.removeClass("fa-refresh fa-spinner fa-spin")
.addClass(isLoadingPoints ? "fa-spinner fa-spin" : "fa-refresh");
$("#nr-obj-loading").css("display", isLoadingPoints ? "flex" : "none");
}
function setLoadingOutputs(isLoading) {
isLoadingOutputs = !!isLoading;
$("#nr-obj-out-refresh").prop("disabled", isLoadingOutputs);
$("#nr-obj-out-refresh-icon")
.removeClass("fa-refresh fa-spinner fa-spin")
.addClass(isLoadingOutputs ? "fa-spinner fa-spin" : "fa-refresh");
$("#nr-obj-out-loading").css("display", isLoadingOutputs ? "flex" : "none");
}
function renderObjectsList(items, query, mode) {
currentObjectsCache = Array.isArray(items) ? items : [];
var objectsArray = currentObjectsCache.slice();
var searchTerm = (query || "").toLowerCase();
if (searchTerm) {
objectsArray = objectsArray.filter(function (objectInfo) {
var typeAbbr = toAbbr(objectInfo.objectType);
var compositeA = typeAbbr + "-" + String(objectInfo.objectInstance);
var compositeN = String(objectInfo.objectType) + "-" + String(objectInfo.objectInstance);
var blob = [
objectInfo.objectName || "",
objectInfo.units || "",
String(objectInfo.objectInstance || ""),
String(objectInfo.objectType || ""),
String(objectInfo.objectTypeName || ""),
compositeA,
compositeN
]
.join(" ")
.toLowerCase();
return blob.includes(searchTerm);
});
}
// Sort lists based on mode:
// - "in" mode (Read Objects): AI (0) and BI (3) appear first
// - "out" mode (Write Objects): AO (1) and BO (4) appear first
if (mode === "in") {
objectsArray.sort(function (a, b) {
var aType = Number(a.objectType) || 0;
var bType = Number(b.objectType) || 0;
var aIsPriority = (aType === 0 || aType === 3); // AI or BI
var bIsPriority = (bType === 0 || bType === 3); // AI or BI
// If one is priority (AI/BI) and the other isn't, priority goes first
if (aIsPriority && !bIsPriority) return -1;
if (!aIsPriority && bIsPriority) return 1;
// If both are priority or both are not priority, sort by type then instance
if (aType !== bType) return aType - bType;
return (Number(a.objectInstance) || 0) - (Number(b.objectInstance) || 0);
});
} else if (mode === "out") {
objectsArray.sort(function (a, b) {
var aType = Number(a.objectType) || 0;
var bType = Number(b.objectType) || 0;
var aIsPriority = (aType === 1 || aType === 4); // AO or BO
var bIsPriority = (bType === 1 || bType === 4); // AO or BO
// If one is priority (AO/BO) and the other isn't, priority goes first
if (aIsPriority && !bIsPriority) return -1;
if (!aIsPriority && bIsPriority) return 1;
// If both are priority or both are not priority, sort by type then instance
if (aType !== bType) return aType - bType;
return (Number(a.objectInstance) || 0) - (Number(b.objectInstance) || 0);
});
}
var container = mode === "out" ? $("#nr-obj-out-list") : $("#nr-obj-list");
var selectionSet = mode === "out" ? selectedOutKeys : selectedKeys;
container.empty();
// Clear loading state if it's stuck (safety check)
var isLoading = mode === "out" ? isLoadingOutputs : isLoadingPoints;
if (isLoading && objectsArray.length === 0 && currentObjectsCache.length === 0) {
// If we're rendering an empty list but loading state is still true, clear it
// This prevents stuck "Loading objects..." messages
var setLoading = mode === "out" ? setLoadingOutputs : setLoadingPoints;
setLoading(false);
isLoading = false;
}
if (!objectsArray.length) {
// Provide helpful messages based on state
var configId = $("#node-input-ocn_server_node").val() || "";
var deviceInstance = $("#node-input-deviceInstance").val() || "";
var message = "";
if (isLoading) {
message = "Loading objects...";
} else if (!configId || configId === "_ADD_") {
message = "Select an OCN Server profile first";
} else if (!deviceInstance || deviceInstance === "") {
message = "Select a controller from the General tab to see objects";
} else if (currentObjectsCache.length === 0) {
// Check if this might be because data isn't loaded yet
// Suggest refreshing the controller list first, which will trigger watcher to fetch data
message = "No objects found. The controller data may not be loaded yet. Go to the General tab and click the refresh button to load controller data, then return here.";
} else if (searchTerm) {
message = "No objects match your search. Try a different search term.";
} else {
message = "No objects found for this controller. Try clicking the refresh button to reload objects.";
}
container.append('<div style="padding:12px;color:#666;font-size:13px;line-height:1.4">' + message + '</div>');
return;
}
var rowsData = [];
objectsArray.forEach(function (objectInfo, index) {
var key = String(objectInfo.objectType) + "|" + String(objectInfo.objectInstance);
var row = $(
'<div class="' +
(mode === "out" ? "nr-obj-out-row" : "nr-obj-row") +
'" tabindex="0" style="display:flex;align-items:center;gap:8px;padding:6px 8px;border:1px solid #ddd;border-radius:8px;margin-bottom:8px;cursor:pointer;user-select:none" data-drag-index="' +
index +
'"></div>'
);
var checkbox = $('<input type="checkbox" class="' + (mode === "out" ? "nr-obj-out-cb" : "nr-obj-cb") + '">')
.prop("checked", selectionSet.has(key))
.data("key", key)
.css({ margin: "0 6px 0 0" });
var leftColumn = $('<div style="min-width:0;flex:1"></div>');
var typeAbbr = toAbbr(objectInfo.objectType);
var dot = " " + String.fromCharCode(183) + " ";
var titleParts = [];
if (objectInfo.objectName) titleParts.push(objectInfo.objectName);
if (objectInfo.deviceName) titleParts.push(objectInfo.deviceName);
var titleElement = $('<div style="font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></div>').text(
titleParts.join(dot)
);
var subtitleParts = [typeAbbr + "-" + objectInfo.objectInstance];
if (objectInfo.deviceInstance != null && objectInfo.deviceInstance !== "") subtitleParts.push(String(objectInfo.deviceInstance));
if (objectInfo.presentValue != null && objectInfo.presentValue !== "") {
subtitleParts.push("PV: " + objectInfo.presentValue + (objectInfo.units ? " " + objectInfo.units : ""));
}
var subtitleElement = $('<div style="font-size:12px;color:#666"></div>').text(subtitleParts.join(dot));
leftColumn.append(titleElement).append(subtitleElement);
row.append(checkbox).append(leftColumn);
checkbox.on("change", function () {
var checkboxKey = $(this).data("key");
if (this.checked) selectionSet.add(checkboxKey);
else selectionSet.delete(checkboxKey);
(mode === "out" ? $("#nr-obj-out-count") : $("#nr-obj-count")).text(selectionSet.size + " selected");
});
row.on("click", function (event) {
if (event.target && event.target.tagName === "INPUT") return;
checkbox.prop("checked", !checkbox.prop("checked")).trigger("change");
});
row.on("keypress", function (event) {
if (event.key === "Enter") {
checkbox.prop("checked", !checkbox.prop("checked")).trigger("change");
}
});
rowsData.push({ row: row, checkbox: checkbox, key: key });
container.append(row);
});
setupDragSelect(container, rowsData, selectionSet);
}
function setupDragSelect(container, rowsData, selectionSet) {
var state = container.data("dragSelectState");
if (!state) {
state = {
box: container,
rowsData: rowsData,
selSet: selectionSet,
active: false,
pending: false,
startIndex: -1,
lastIndex: -1,
targetState: false,
original: [],
suppressClick: false,
startClientX: 0,
startClientY: 0,
currentClientX: 0,
currentClientY: 0,
scrollDelta: 0,
scrollRAF: null,
outside: false
};
container.data("dragSelectState", state);
container.on("mousedown", "[data-drag-index]", function (event) {
if (event.button !== 0) return;
if (
event.target &&
(event.target.tagName === "INPUT" || $(event.target).closest("input[type='checkbox']").length)
)
return;
var index = Number($(this).attr("data-drag-index"));
if (isNaN(index)) return;
beginDragCandidate(state, index, event);
event.preventDefault();
});
}
state.rowsData = rowsData;
state.selSet = selectionSet;
}
function beginDragCandidate(state, index, event) {
if (state.active || state.pending) {
finishDragSelection(state);
}
if (!state.rowsData || !state.rowsData.length) return;
state.pending = true;
state.active = false;
state.outside = false;
state.startIndex = index;
state.lastIndex = index;
state.targetState = !(state.rowsData[index] && state.rowsData[index].checkbox.prop("checked"));
state.original = [];
state.suppressClick = false;
state.startClientX = event.clientX;
state.startClientY = event.clientY;
state.currentClientX = event.clientX;
state.currentClientY = event.clientY;
state.scrollDelta = 0;
if (state.scrollRAF) {
cancelAnimationFrame(state.scrollRAF);
state.scrollRAF = null;
}
$(document).on("mousemove.dragSelect", function (moveEvent) {
updateDragSelection(state, moveEvent);
});
$(document).on("mouseup.dragSelect", function () {
finishDragSelection(state);
});
try {
if (window.getSelection) {
window.getSelection().removeAllRanges();
}
} catch (_ignore) {}
}
function activateDragSelection(state) {
if (state.active || !state.rowsData || !state.rowsData.length) return;
state.original = state.rowsData.map(function (entry) {
return !!entry.checkbox.prop("checked");
});
state.active = true;
state.pending = false;
state.suppressClick = true;
applyDragRange(state, state.startIndex);
}
function updateDragSelection(state, event) {
state.currentClientX = event.clientX;
state.currentClientY = event.clientY;
if (state.pending && !state.active) {
var deltaX = Math.abs(event.clientX - state.startClientX);
var deltaY = Math.abs(event.clientY - state.startClientY);
if (deltaX > 3 || deltaY > 3) {
activateDragSelection(state);
}
}
var boxElement = state.box[0];
var rect = boxElement.getBoundingClientRect();
var margin = Math.min(rect.height * 0.25, 80);
var edgeStep = 4;
var delta = 0;
var insideX = event.clientX >= rect.left && event.clientX <= rect.right;
if (event.clientY < rect.top) {
delta = -edgeStep;
} else if (event.clientY > rect.bottom) {
delta = edgeStep;
} else if (margin > 0 && event.clientY < rect.top + margin) {
var topFactor = (rect.top + margin - event.clientY) / margin;
delta = -Math.max(1, Math.min(3, 1 + topFactor * 3));
} else if (margin > 0 && event.clientY > rect.bottom - margin) {
var bottomFactor = (event.clientY - (rect.bottom - margin)) / margin;
delta = Math.max(1, Math.min(3, 1 + bottomFactor * 3));
}
var insideY = event.clientY >= rect.top && event.clientY <= rect.bottom;
if (!insideX || !insideY) {
if (state.active) {
state.outside = true;
}
state.scrollDelta = 0;
if (state.scrollRAF) {
cancelAnimationFrame(state.scrollRAF);
state.scrollRAF = null;
}
return;
}
if (state.outside) {
state.outside = false;
}
if (state.active) {
if (delta) {
boxElement.scrollTop += delta;
}
state.scrollDelta = delta;
ensureDragScroll(state);
} else {
state.scrollDelta = 0;
}
if (!state.active) return;
var index = resolveIndexFromEvent(state, event);
if (index == null) return;
index = Math.max(0, Math.min(state.rowsData.length - 1, index));
if (index !== state.lastIndex) {
applyDragRange(state, index);
}
}
function finishDragSelection(state) {
$(document).off(".dragSelect");
if (state.active) {
state.active = false;
state.original = [];
state.startIndex = -1;
state.lastIndex = -1;
state.outside = false;
setTimeout(function () {
state.suppressClick = false;
}, 0);
}
state.pending = false;
state.scrollDelta = 0;
if (state.scrollRAF) {
cancelAnimationFrame(state.scrollRAF);
state.scrollRAF = null;
}
}
function applyDragRange(state, index) {
var rows = state.rowsData;
if (!rows || !rows.length) return;
var start = Math.min(state.startIndex, index);
var end = Math.max(state.startIndex, index);
for (var i = 0; i < rows.length; i++) {
var shouldCheck = i >= start && i <= end ? state.targetState : state.original[i];
var checkbox = rows[i].checkbox;
if (!!checkbox.prop("checked") !== shouldCheck) {
checkbox.prop("checked", shouldCheck).trigger("change");
}
}
state.lastIndex = index;
}
function ensureDragScroll(state) {
if (state.scrollRAF || !state.scrollDelta) return;
state.scrollRAF = requestAnimationFrame(function step() {
state.scrollRAF = null;
if (!state.active) {
state.scrollDelta = 0;
return;
}
if (state.scrollDelta) {
var boxElement = state.box[0];
boxElement.scrollTop += state.scrollDelta;
var index = resolveIndexFromPoint(state, state.currentClientX, state.currentClientY);
if (index != null) {
index = Math.max(0, Math.min(state.rowsData.length - 1, index));
if (index !== state.lastIndex) {
applyDragRange(state, index);
}
}
state.scrollRAF = requestAnimationFrame(step);
}
});
}
function resolveIndexFromPoint(state, clientX, clientY) {
var element = document.elementFromPoint(clientX, clientY);
if (element) {
var node = element.closest ? element.closest("[data-drag-index]") : $(element).closest("[data-drag-index]")[0];
if (node) {
return Number(node.getAttribute("data-drag-index"));
}
}
var rect = state.box[0].getBoundingClientRect();
if (clientX < rect.left || clientX > rect.right) return null;
if (clientY < rect.top || clientY > rect.bottom) return null;
return null;
}
function resolveIndexFromEvent(state, event) {
return resolveIndexFromPoint(state, event.clientX, event.clientY);
}
function loadObjectsGeneric(showTips, mode) {
var setLoading = mode === "out" ? setLoadingOutputs : setLoadingPoints;
var updateList = function (objects) {
renderObjectsList(objects, mode === "out" ? $("#nr-obj-out-search").val() : $("#nr-obj-search").val(), mode);
};
if (mode === "out" ? isLoadingOutputs : isLoadingPoints) return;
var configId = $("#node-input-ocn_server_node").val() || "";
var deviceInstance = $("#node-input-deviceInstance").val() || "";
var networkNumber = $("#node-input-networkNumber").val() || "";
// Don't load if configId is empty, "_ADD_", or if deviceInstance is missing
// networkNumber can be empty string or 0 (both are valid)
if (!configId || configId === "_ADD_" || !deviceInstance || deviceInstance === "") {
// Clear loading state to prevent stuck "Loading objects..." message
setLoading(false);
if (showTips) {
var tipMessage = "";
if (!configId || configId === "_ADD_") {
tipMessage = "Select an OCN Server profile first";
} else {
tipMessage = "Select a controller from the General tab first";
}
(mode === "out" ? $("#nr-obj-out-tip") : $("#nr-obj-tip")).text(tipMessage).show();
(mode === "out" ? $("#nr-obj-out-list") : $("#nr-obj-list")).empty();
} else {
// Even if not showing tips, clear the list so renderObjectsList can show appropriate message
(mode === "out" ? $("#nr-obj-out-list") : $("#nr-obj-list")).empty();
// Render empty list with appropriate message
renderObjectsList([], "", mode);
}
return;
}
// networkNumber is optional - use empty string if not provided
if (networkNumber === "") {
networkNumber = "";
}
(mode === "out" ? $("#nr-obj-out-tip") : $("#nr-obj-tip")).hide();
setLoading(true);
// Add timeout to prevent stuck loading state (30 seconds)
var loadingTimeout = setTimeout(function() {
setLoading(false);
var errorMsg = "Request timed out. Check your connection and try refreshing.";
(mode === "out" ? $("#nr-obj-out-tip") : $("#nr-obj-tip")).text(errorMsg).show();
renderObjectsList([], "", mode);
}, 30000);
$.ajax({
url: "bacnet-controller/objects",
method: "GET",
cache: false,
timeout: 30000, // 30 second timeout
data: { configId: configId, deviceInstance: deviceInstance, networkNumber: networkNumber },
dataType: "text"
})
.done(function (_unusedUrl, _unusedStatus, xhr) {
clearTimeout(loadingTimeout);
var responseText = xhr.responseText || "[]";
try {
responseText = responseText.replace(/^\)\]\}',?\s*/, "");
} catch (error) {}
var parsedArray = [];
try {
parsedArray = JSON.parse(responseText);
} catch (error) {}
updateList(parsedArray);
})
.fail(function (xhr) {
clearTimeout(loadingTimeout);
var message = parseErrorFromAjax(xhr);
if (xhr.status === 0 || xhr.statusText === "timeout") {
message = "Request timed out or connection failed. Check your connection and try refreshing.";
}
(mode === "out" ? $("#nr-obj-out-tip") : $("#nr-obj-tip")).text(message).show();
(mode === "out" ? $("#nr-obj-out-list") : $("#nr-obj-list")).empty();
renderObjectsList([], "", mode);
})
.always(function () {
clearTimeout(loadingTimeout);
setLoading(false);
});
}
function loadObjectsForCurrentController(showTips) {
loadObjectsGeneric(showTips, "in");
}
function loadObjectsForOutputs(showTips) {
loadObjectsGeneric(showTips, "out");
}
/**
* Place spawned points on a fixed stair pattern anchored to the controller.
* No viewport/workspace clamping or alternate columns — same diagonal everywhere.
* @param stepY unused; vertical step is fixed (20px) for a consistent stair.
*/
function proposeYSlots(controllerNode, pointCount, baseX, stepY) {
var placements = [];
if (!pointCount || pointCount < 1) {
return placements;
}
var controllerX = controllerNode.x || 0;
var controllerY = controllerNode.y || 0;
// Stair: start at controller + (50, 20), then step right by 15/20/25 and down 20px each point
var stairXOffset = 50;
var stairYOffset = 20;
var defaultStartX = controllerX + stairXOffset;
var actualStairXOffset = stairXOffset + (baseX - defaultStartX);
var xIncrementPattern = [15, 20, 25];
var currentX = controllerX + actualStairXOffset;
var currentY = controllerY + stairYOffset;
for (var k = 0; k < pointCount; k++) {
var nextX;
var nextY;
if (k === 0) {
nextX = currentX;
nextY = currentY;
} else {
var xIncrement = xIncrementPattern[(k - 1) % xIncrementPattern.length];
nextX = currentX + xIncrement;
nextY = currentY + stairYOffset;
}
currentX = nextX;
currentY = nextY;
placements.push({ x: nextX, y: nextY });
}
return placements;
}
function determineDefaultPointSource() {
return $("#node-input-spawnSourceMode").val() || $("#nr-obj-sourcemode").val() || "mqtt";
}
function determineDefaultAcknowledgedMqtt() {
var val = $("#node-input-spawnAcknowledgedMqtt").prop("checked");
if (val !== undefined) return !!val;
return $("#nr-obj-out-acknowledged").is(":checked");
}
function applySourceToLinkedPoints() {
var guid = $("#node-input-guid").val() || "";
if (!guid) return;
var desiredSource = determineDefaultPointSource();
var isAnyPointDirty = false;
["bacnet-point", "bacnet-point-in"].forEach(function (nodeType) {
var pointNodes = RED.nodes && RED.nodes.filterNodes ? RED.nodes.filterNodes({ type: nodeType }) : [];
pointNodes.forEach(function (pointNode) {
if (String(pointNode.controller_guid || "") !== String(guid)) return;
pointNode.source = desiredSource;
pointNode.changed = true;
isAnyPointDirty = true;
});
});
if (isAnyPointDirty) {
try {
RED.nodes.dirty(true);
RED.view.redraw(true);
} catch (error) {}
}
}
function applyAcknowledgedToLinkedPoints() {
var guid = $("#node-input-guid").val() || "";
if (!guid) return;
var desiredAcknowledged = determineDefaultAcknowledgedMqtt();
var isAnyPointDirty = false;
var pointNodes = RED.nodes && RED.nodes.filterNodes ? RED.nodes.filterNodes({ type: "bacnet-point-out" }) : [];
pointNodes.forEach(function (pointNode) {
if (String(pointNode.controller_guid || "") !== String(guid)) return;
pointNode.acknowledged_mqtt = desiredAcknowledged;
pointNode.changed = true;
isAnyPointDirty = true;
});
if (isAnyPointDirty) {
try {
RED.nodes.dirty(true);
RED.view.redraw(true);
} catch (error) {}
}
}
function addSelectedPointsAsNodesInputs() {
if (selectedKeys.size === 0) {
tabs && tabs.activateTab && tabs.activateTab("node-bc-tab-general");
return [];
}
var controllerNode = RED.nodes.node(nodeId);
if (!controllerNode) {
tabs && tabs.activateTab && tabs.activateTab("node-bc-tab-general");
return [];
}
var guid = $("#node-input-guid").val() || generateGuidSafe();
$("#node-input-guid").val(guid);
// This is during edit, not a new node - don't change existing GUIDs
ensureUniqueGuidValueForNodeId(nodeId, false);
var configId = $("#node-input-ocn_server_node").val() || "";
var objectsByKey = new Map();
currentObjectsCache.forEach(function (objectInfo) {
objectsByKey.set(String(objectInfo.objectType) + "|" + String(objectInfo.objectInstance), objectInfo);
});
var pickedObjects = [];
selectedKeys.forEach(function (key) {
var objectInfo = objectsByKey.get(key);
if (objectInfo) pickedObjects.push(objectInfo);
});
if (!pickedObjects.length) {
tabs && tabs.activateTab && tabs.activateTab("node-bc-tab-general");
return [];
}
var stepY = 60;
var xOffset = 55;
var targetX = (controllerNode.x || 0) + xOffset;
var slotPositions = proposeYSlots(controllerNode, pickedObjects.length, targetX, stepY);
var createdNodes = [];
var deviceName = $("#node-input-deviceName").val() || "";
var deviceInstance = $("#node-input-deviceInstance").val() || "";
var labelMode = $("#nr-obj-labelmode").val() || "instance";
var objectLabelMode = $("#nr-obj-objectlabelmode").val() || "name";
var defaultSource = determineDefaultPointSource();
pickedObjects.forEach(function (objectInfo, index) {
var slot = slotPositions[index] || {
x: targetX,
y: (controllerNode.y || 0) + stepY * (index + 1)
};
var deviceLabel = buildDeviceLabel(labelMode, deviceName, deviceInstance);
var objectId =
objectInfo.objectType != null && objectInfo.objectInstance != null
? toAbbr(objectInfo.objectType) + "-" + objectInfo.objectInstance
: "";
var objectLabel = buildObjectLabel(objectLabelMode, objectInfo.objectName || "", objectId);
var previewLabel;
var separator = " " + String.fromCharCode(183) + " ";
if (labelMode === "both") {
var dot = " " + String.fromCharCode(183) + " ";
var parts = [];
if (objectLabel) parts.push(objectLabel);
if (deviceName) parts.push(deviceName);
if (deviceInstance) parts.push(deviceInstance);
if (!parts.length) parts.push(objectId || objectInfo.objectName || "");
previewLabel = parts.length ? parts.join(dot) : deviceLabel || objectId || "BACnet Point";
} else {
if (objectLabel && deviceLabel) {
previewLabel = objectLabel + separator + deviceLabel;
} else if (objectLabel) {
previewLabel = objectLabel;
} else if (deviceLabel) {
previewLabel = deviceLabel;
} else {
previewLabel = objectId || objectInfo.objectName || "BACnet Point";
}
}
var newNodeData = {
id: generateNodeIdSafe(),
type: "bacnet-point-in",
z: controllerNode.z,
x: slot.x,
y: slot.y,
name: "",
ocn_server_node: configId,
source: defaultSource,
topic: "default",
objectType: objectInfo.objectType,
objectInstance: objectInfo.objectInstance,
objectName: objectInfo.objectName || "",
description: objectInfo.description || "",
units: objectInfo.units || "",
autoLabel: true,
previewLabel: previewLabel,
controller_guid: guid,
controllerName: deviceName,
controllerDeviceInstance: deviceInstance,
labelMode: labelMode,
objectLabelMode: objectLabelMode,
wires: [[], []]
};
var addedNode = addPointNode(RED, controllerNode, newNodeData);
if (addedNode) createdNodes.push(addedNode);
});
if (createdNodes.length) {
// Delay dirty flag to ensure nodes are fully registered before the hook checks
setTimeout(function () {
RED.nodes.dirty(true);
}, 10);
if (RED.view && RED.view.state) RED.view.state(RED.state.DEFAULT);
setTimeout(function () {
selectNewNodesInEditor(createdNodes);
}, 0);
}
return createdNodes;
}
function addAllSelectedPointsAsNodes() {
var inputNodes = addSelectedPointsAsNodesInputs();
var outputNodes = [];
// If input nodes were created, offset output nodes to continue stair pattern
if (inputNodes && inputNodes.length > 0) {
// Find the last input node (bottom-right most)
var lastInputNode = null;
var maxInputY = -Infinity;
var maxInputX = -Infinity;
for (var i = 0; i < inputNodes.length; i++) {
var node = inputNodes[i];
var nodeY = node.y || 0;
var nodeX = node.x || 0;
// Find the bottom-most node, and if tied, the right-most
if (nodeY > maxInputY || (nodeY === maxInputY && nodeX > maxInputX)) {
maxInputY = nodeY;
maxInputX = nodeX;
lastInputNode = node;
}
}
if (lastInputNode) {
outputNodes = addSelectedPointsAsNodesOutputs(
undefined,
undefined,
lastInputNode,
inputNodes.length
);
} else {
outputNodes = addSelectedPointsAsNodesOutputs();
}
} else {
outputNodes = addSelectedPointsAsNodesOutputs();
}
// Combine all created nodes and ensure deploy is enabled
var allCreatedNodes = (inputNodes || []).concat(outputNodes || []);
if (allCreatedNodes.length > 0) {
// Delay dirty flag to ensure all nodes are fully registered
setTimeout(function () {
RED.nodes.dirty(true);
}, 20);
if (RED.view && RED.view.state) RED.view.state(RED.state.DEFAULT);
setTimeout(function () {
selectNewNodesInEditor(allCreatedNodes);
}, 0);
}
return allCreatedNodes;
}
/**
* @param lastInputNode when spawning outputs after inputs, continue the same stair from this node
* @param inputStairCount number of input nodes just placed (used to index the stair increments)
*/
function addSelectedPointsAsNodesOutputs(customXOffset, customYOffset, lastInputNode, inputStairCount) {
if (selectedOutKeys.size === 0) {
tabs && tabs.activateTab && tabs.activateTab("node-bc-tab-general");
return [];
}
var controllerNode = RED.nodes.node(nodeId);
if (!controllerNode) {
tabs && tabs.activateTab && tabs.activateTab("node-bc-tab-general");
return [];
}
var guid = $("#node-input-guid").val() || generateGuidSafe();
$("#node-input-guid").val(guid);
// This is during edit, not a new node - don't change existing GUIDs
ensureUniqueGuidValueForNodeId(nodeId, false);
var configId = $("#node-input-ocn_server_node").val() || "";
var objectsByKey = new Map();
currentObjectsCache.forEach(function (objectInfo) {
objectsByKey.set(String(objectInfo.objectType) + "|" + String(objectInfo.objectInstance), objectInfo);
});
var pickedObjects = [];
selectedOutKeys.forEach(function (key) {
var objectInfo = objectsByKey.get(key);
if (objectInfo) pickedObjects.push(objectInfo);
});
if (!pickedObjects.length) {
tabs && tabs.activateTab && tabs.activateTab("node-bc-tab-general");
return [];
}
var defaultStepY = 60;
var defaultStepX = 20;
var createdNodes = [];
var deviceName = $("#node-input-deviceName").val() || "";
var deviceInstance = $("#node-input-deviceInstance").val() || "";
var labelMode = $("#nr-obj-out-labelmode").val() || "instance";
var objectLabelMode = $("#nr-obj-out-objectlabelmode").val() || "name";
var defaultAcknowledged = determineDefaultAcknowledgedMqtt();
// Helper function to create output node
function createOutputNode(slot, objectInfo, index) {
var deviceLabel = buildDeviceLabel(labelMode, deviceName, deviceInstance);
var objectId =
objectInfo.objectType != null && objectInfo.objectInstance != null
? toAbbr(objectInfo.objectType) + "-" + objectInfo.objectInstance
: "";
var objectLabel = buildObjectLabel(objectLabelMode, objectInfo.objectName || "", objectId);
var previewLabel;
var separator = " " + String.fromCharCode(183) + " ";
if (labelMode === "both") {
var dot = " " + String.fromCharCode(183) + " ";
var parts = [];
if (objectLabel) parts.push(objectLabel);
if (deviceName) parts.push(deviceName);
if (deviceInstance) parts.push(deviceInstance);
if (!parts.length) parts.push(objectId || objectInfo.objectName || "");
previewLabel = parts.length ? parts.join(dot) : deviceLabel || objectId || "BACnet Point Out";
} else {
if (objectLabel && deviceLabel) {
previewLabel = objectLabel + separator + deviceLabel;
} else if (objectLabel) {
previewLabel = objectLabel;
} else if (deviceLabel) {
previewLabel = deviceLabel;
} else {
previewLabel = objectId || objectInfo.objectName || "BACnet Point Out";
}
}
var newNodeData = {
id: generateNodeIdSafe(),
type: "bacnet-point-out",
z: controllerNode.z,
x: slot.x,
y: slot.y,
name: "",
ocn_server_node: configId,
acknowledged_mqtt: defaultAcknowledged,
output_type: "ip",
topic: "default",
objectType: objectInfo.objectType,
objectInstance: objectInfo.objectInstance,
objectName: objectInfo.objectName || "",
description: objectInfo.description || "",
units: objectInfo.units || "",
deviceInstance: deviceInstance,
deviceName: deviceName,
networkNumber: $("#node-input-networkNumber").val() || "",
priority: "9",
bypass_priority: false,
send_policy: "Always",
autoLabel: true,
previewLabel: previewLabel,
labelMode: labelMode,
objectLabelMode: objectLabelMode,
controller_guid: guid,
wires: [[], []]
};
var addedNode = addPointNode(RED, controllerNode, newNodeData);
if (addedNode) createdNodes.push(addedNode);
}
// Continue the same stair as proposeYSlots: [15,20,25] on X and +20 on Y per step
var stairXIncrements = [15, 20, 25];
var stairDeltaY = 20;
if (lastInputNode && inputStairCount > 0) {
var ox = lastInputNode.x || 0;
var oy = lastInputNode.y || 0;
pickedObjects.forEach(function (objectInfo, index) {
var xInc = stairXIncrements[(inputStairCount - 1 + index) % 3];
ox += xInc;
oy += stairDeltaY;
createOutputNode({ x: ox, y: oy }, objectInfo, index);
});
} else {
// Normal spawning - use proposeYSlots
var xOffset = (customXOffset !== undefined) ? customXOffset : 55;
var targetX = (controllerNode.x || 0) + xOffset;
var slotPositions = proposeYSlots(controllerNode, pickedObjects.length, targetX, defaultStepY);
pickedObjects.forEach(function (objectInfo, index) {
var baseSlot = slotPositions[index] || {
x: targetX,
y: (controllerNode.y || 0) + defaultStepY * (index + 1)
};
var slot = {
x: baseSlot.x,
y: (customYOffset !== undefined) ? (controllerNode.y || 0) + customYOffset + defaultStepY * index : baseSlot.y
};
createOutputNode(slot, objectInfo, index);
});
}
if (createdNodes.length) {
// Delay dirty flag to ensure nodes are fully registered before the hook checks
setTimeout(function () {
RED.nodes.dirty(true);
}, 10);
if (RED.view && RED.view.state) RED.view.state(RED.state.DEFAULT);
setTimeout(function () {
selectNewNodesInEditor(createdNodes);
}, 0);
}
return createdNodes;
}
$("#nr-obj-add").on("click", addAllSelectedPointsAsNodes);
$("#nr-obj-back").on("click", function () {
tabs.activateTab("node-bc-tab-general");
});
$("#nr-obj-refresh").on("click", function () {
loadControllers(true);
});
$("#nr-obj-search").on("input", function () {
renderObjectsList(currentObjectsCache, this.value, "in");
});
$("#nr-obj-selectall").on("click", function () {
selectedKeys.clear();
currentObjectsCache.forEach(function (objectInfo) {
selectedKeys.add(String(objectInfo.objectType) + "|" + String(objectInfo.objectInstance));
});
$("#nr-obj-count").text(selectedKeys.size + " selected");
renderObjectsList(currentObjectsCache, $("#nr-obj-search").val(), "in");
});
$("#nr-obj-clear").on("click", function () {
selectedKeys.clear();
$("#nr-obj-count").text("0 selected");
renderObjectsList(currentObjectsCache, $("#nr-obj-search").val(), "in");
});
$("#nr-obj-labelmode").on("change", function () {
setSpawnLabelMode($(this).val());
});
$("#nr-obj-objectlabelmode").on("change", function () {
setSpawnObjectLabelMode($(this).val());
});
$("#nr-obj-sourcemode").on("change", function () {
setSpawnSourceMode($(this).val());
});
$("#nr-obj-apply-source").on("click", applySourceToLinkedPoints);
$("#nr-obj-out-add").on("click", addAllSelectedPointsAsNodes);
$("#nr-obj-out-back").on("click", function () {
tabs.activateTab("node-bc-tab-general");
});
$("#nr-obj-out-refresh").on("click", function () {
loadControllers(true);
});
$("#nr-obj-out-search").on("input", function () {
renderObjectsList(currentObjectsCache, this.value, "out");
});
$("#nr-obj-out-selectall").on("click", function () {
selectedOutKeys.clear();
currentObjectsCache.forEach(function (objectInfo) {
selectedOutKeys.add(String(objectInfo.objectType) + "|" + String(objectInfo.objectInstance));
});
$("#nr-obj-out-count").text(selectedOutKeys.size + " selected");
renderObjectsList(currentObjectsCache, $("#nr-obj-out-search").val(), "out");
});
$("#nr-obj-out-clear").on("click", function () {
selectedOutKeys.clear();
$("#nr-obj-out-count").text("0 selected");
renderObjectsList(currentObjectsCache, $("#nr-obj-out-search").val(), "out");
});
$("#nr-obj-out-labelmode").on("change", function () {
setSpawnLabelMode($(this).val());
});
$("#nr-obj-out-objectlabelmode").on("change", function () {
setSpawnObjectLabelMode($(this).val());
});
$("#nr-obj-out-acknowledged").on("change", function () {
var checked = $(this).is(":checked");
$("#node-input-spawnAcknowledgedMqtt").prop("checked", checked);
if (controllerInstance) {
if (controllerInstance.spawnAcknowledgedMqtt !== checked) {
controllerInstance.spawnAcknowledgedMqtt = checked;
controllerInstance.changed = true;
try {
RED.nodes.dirty(true);
} catch (error) {}
}
}
});
$("#nr-obj-out-apply-source").on("click", applyAcknowledgedToLinkedPoints);
$("#nr-refresh").on("click", function () {
// Force refresh - this will trigger API call and update all points/controllers
loadControllers(true);
});
$("#nr-bacnet-search").on("input", function () {
renderFilteredControllers(lastDevices, this.value);
});
$("#node-input-ocn_server_node").on("change", function () {
var value = $(this).val() || "";
if (value && value !== initialConfigId) {
if (controllerInstance) {
controllerInstance.changed = true;
try {
RED.nodes.dirty(true);
} catch (error) {}
}
// Load controllers for new config (only once unless refresh is pressed)
loadControllers(false);
if (activeTabId === "node-bc-tab-inputs") loadObjectsForCurrentController(true);
if (activeTabId === "node-bc-tab-outputs") loadObjectsForOutputs(true);
propagateOcnToLinkedPoints(value);
} else {
applyDefaultSourceToLinkedPoints();
}
initialConfigId = value;
});
$("#node-input-vendorId, #node-input-modelName, #node-input-thirdParty").on("change input", function () {
applyDefaultSourceToLinkedPoints();
});
// Initialize loading state to false when dialog opens
setLoadingControllers(false);
// Clear lastDevices when dialog opens - will be repopulated if cache exists
lastDevices = [];
$("#nr-bacnet-list").html("Loading controllers...");
renderSelectedControllerLabel();
setTimeout(function () {
var configId = $("#node-input-ocn_server_node").val() || "";
var deviceInstance = $("#node-input-deviceInstance").val() || "";
// Load controllers - always use cached data, never auto-fetch
// Don't load if configId is empty or the placeholder "_ADD_"
if (configId && configId !== "_ADD_") {
// Load cached data only - user must press refresh to fetch new data
loadControllers(false);
// If a controller is already selected, pre-load objects list so it's ready when user switches tabs
if (deviceInstance) {
loadObjectsForCurrentController(false);
loadObjectsForOutputs(false);
}
} else {
$("#nr-bacnet-list").html("Select OCN Server and press refresh to list controllers.");
setLoadingControllers(false); // Ensure loading is cleared
}
}, 50);
}
});
// Hook up label refresh events (similar to points)
RED.events.on("flows:loaded", function() {
refreshAllLabels();
});
RED.events.on("deploy", function() {
// Each point node has its own deploy handler that calls refreshMapping() directly
// We need to wait for all those backend updates to complete, then refresh labels
// Use a longer delay to ensure all backend refreshMapping() calls complete
// The backend deploy handlers run at 200ms, and refreshMapping() is async, so we need more time
// Also, backend changes need to be persisted (RED.nodes.dirty) before frontend can read them
setTimeout(function() {
// Call point label refresh functions to sync frontend with backend
// These functions clear label cache and trigger redraw
if (window.__bnPointForceRefresh && typeof window.__bnPointForceRefresh === "function") {
try {
window.__bnPointForceRefresh();
} catch (e) {
}
} else {
}
if (window.__bnPointOutForceRefresh && typeof window.__bnPointOutForceRefresh === "function") {
window.__bnPointOutForceRefresh();
}
// Also refresh all labels (including controller labels)
refreshAllLabels();
// Second refresh after a delay to catch any late updates
setTimeout(function() {
if (window.__bnPointForceRefresh && typeof window.__bnPointForceRefresh === "function") {
window.__bnPointForceRefresh();
}
if (window.__bnPointOutForceRefresh && typeof window.__bnPointOutForceRefresh === "function") {
window.__bnPointOutForceRefresh();
}
refreshAllLabels();
}, 1500);
// Third refresh after another delay to ensure backend updates are fully synced
setTimeout(function() {
if (window.__bnPointForceRefresh && typeof window.__bnPointForceRefresh === "function") {
window.__bnPointForceRefresh();
}
if (window.__bnPointOutForceRefresh && typeof window.__bnPointOutForceRefresh === "function") {
window.__bnPointOutForceRefresh();
}
refreshAllLabels();
}, 3000);
}, 3000); // Increased delay to ensure all backend refreshMapping() calls complete and are persisted
});
// Refresh labels when nodes are added, removed, or changed
RED.events.on("nodes:add", function(addedNode) {
if (addedNode && addedNode.type === "bacnet-controller") {
// Small delay to ensure node is fully initialized
setTimeout(refreshAllLabels, 100);
}
});
RED.events.on("nodes:remove", function(removedNode) {
if (removedNode && removedNode.type === "bacnet-controller") {
setTimeout(refreshAllLabels, 100);
}
});
// Don't refresh labels on nodes:change - labels should only update on deploy
// The nodes:change event fires when editing nodes, but changes aren't applied until deploy
// Labels will be refreshed on deploy via the deploy event handler
// Function to refresh point labels when controller changes or refresh is pressed
function refreshPointLabels() {
try {
var nodesMarkedDirty = 0;
// Find all point nodes and controller nodes - backend refreshMapping() is called on point nodes
// Controller nodes are updated via watcher listeners in checkPresenceAndSetStatus()
if (RED.nodes && typeof RED.nodes.filterNodes === "function") {
var allPointInNodes = RED.nodes.filterNodes({ type: "bacnet-point-in" }) || [];
var allPointOutNodes = RED.nodes.filterNodes({ type: "bacnet-point-out" }) || [];
var allPointNodes = allPointInNodes.concat(allPointOutNodes);
var allControllerNodes = RED.nodes.filterNodes({ type: "bacnet-controller" }) || [];
// Store old labels for controller nodes (before any updates)
function computeControllerLabelForBrowserLogging(controllerNode) {
if (controllerNode.name && String(controllerNode.name).trim()) {
return controllerNode.name;
}
var deviceName = controllerNode.deviceName || "";
var deviceInstance = controllerNode.deviceInstance || "";
if (deviceName && deviceInstance) {
return deviceName + " " + String.fromCharCode(183) + " " + deviceInstance;
}
return "BACnet Controller";
}
var oldControllerLabelsMap = {};
allControllerNodes.forEach(function(controllerNode) {
if (controllerNode && controllerNode.id) {
oldControllerLabelsMap[controllerNode.id] = computeControllerLabelForBrowserLogging(controllerNode);
}
});
// Helper function to compute label for logging (matches HTML label logic)
function computeLabelForBrowserLogging(pointNode) {
if (pointNode.name && String(pointNode.name).trim()) {
return pointNode.name;
}
var nodeType = pointNode.type || "";
var isPointOut = nodeType === "bacnet-point-out";
var controllerName = isPointOut ? (pointNode.deviceName || "") : (pointNode.controllerName || "");
var controllerDeviceInstance = isPointOut ? (pointNode.deviceInstance || "") : (pointNode.controllerDeviceInstance || "");
var objectName = (pointNode.objectName || "").trim();
var labelMode = pointNode.labelMode || "instance";
var objectLabelMode = pointNode.objectLabelMode || "name";
var TYPE_MAP = { 0: "AI", 1: "AO", 2: "AV", 3: "BI", 4: "BO", 5: "BV", 12: "MI", 13: "MO", 14: "MV", 19: "MSV" };
// Build device label
var deviceLabel = "";
if (labelMode === "name") {
deviceLabel = controllerName || "";
} else if (labelMode === "instance") {
deviceLabel = controllerDeviceInstance || "";
} else if (labelMode === "both") {
var parts = [];
if (controllerName) parts.push(controllerName);
if (controllerDeviceInstance) parts.push(controllerDeviceInstance);
deviceLabel = parts.join(" ");
}
// Build object label
var objectId = "";
if (pointNode.objectType != null && pointNode.objectInstance != null) {
var typeAbbr = TYPE_MAP[pointNode.objectType] || String(pointNode.objectType);
objectId = typeAbbr + "-" + String(pointNode.objectInstance);
}
var objectLabel = "";
if (objectLabelMode === "name") {
objectLabel = objectName || "";
} else if (objectLabelMode === "id") {
objectLabel = objectId || "";
} else if (objectLabelMode === "both") {
var parts = [];
if (objectName) parts.push(objectName);
if (objectId) parts.push(objectId);
objectLabel = parts.join(" ");
}
var separator = " · ";
var defaultLabel = isPointOut ? "BACnet Point Out" : "BACnet Point";
if (labelMode === "both") {
var labelParts = [];
if (objectLabel) labelParts.push(objectLabel);
if (controllerName) labelParts.push(controllerName);
if (controllerDeviceInstance) labelParts.push(controllerDeviceInstance);
if (!labelParts.length) labelParts.push(objectId || objectName || "");
return labelParts.length ? labelParts.join(separator) : defaultLabel;
} else {
if (objectLabel && deviceLabel) {
return objectLabel + separator + deviceLabel;
} else if (objectLabel) {
return objectLabel;
} else if (deviceLabel) {
return deviceLabel;
} else {
return objectId || objectName || defaultLabel;
}
}
}
// Function to check and update object properties
function checkObjectUpdates(pointNode, controllerData) {
if (!pointNode || !controllerData) return;
var controllerGuid = String(pointNode.controller_guid || "");
if (!controllerGuid) return;
if (controllerData.objByKey &&
pointNode.objectType != null && pointNode.objectInstance != null) {
var lookupKey = String(pointNode.objectType) + "|" + String(pointNode.objectInstance);
var objectEntry = controllerData.objByKey[lookupKey];
if (objectEntry) {
var currentObjectName = String(pointNode.objectName || "");
var newObjectName = String(objectEntry.objectName || "");
if (newObjectName !== currentObjectName) {
pointNode.objectName = newObjectName;
pointNode.changed = true;
}
}
}
}
// Fetch controller data for all point nodes
var loggedControllers = {};
var controllerDataMap = {};
var ajaxPromises = [];
allPointNodes.forEach(function(pointNode) {
if (pointNode && !pointNode._closed) {
var controllerGuid = String(pointNode.controller_guid || "");
if (controllerGuid && !loggedControllers[controllerGuid]) {
loggedControllers[controllerGuid] = true;
var ajaxPromise = $.ajax({
url: "bacnet-controller/lookupController",
method: "GET",
data: { guid: controllerGuid },
dataType: "json"
})
.done(function(data) {
if (data && data.controller) {
controllerDataMap[controllerGuid] = data.controller;
}
})
.fail(function() {});
ajaxPromises.push(ajaxPromise);
}
}
});
// Also fetch controller data for controller nodes themselves
allControllerNodes.forEach(function(controllerNode) {
if (controllerNode && !controllerNode._closed) {
var controllerGuid = String(controllerNode.guid || "");
if (controllerGuid && !loggedControllers[controllerGuid]) {
loggedControllers[controllerGuid] = true;
var ajaxPromise = $.ajax({
url: "bacnet-controller/lookupController",
method: "GET",
data: { guid: controllerGuid },
dataType: "json"
})
.done(function(data) {
if (data && data.controller) {
controllerDataMap[controllerGuid] = data.controller;
}
})
.fail(function() {});
ajaxPromises.push(ajaxPromise);
}
}
});
// Wait for all AJAX calls to complete, then update nodes
$.when.apply($, ajaxPromises).done(function() {
// Update point nodes
allPointNodes.forEach(function(pointNode) {
if (pointNode && !pointNode._closed) {
var controllerGuid = String(pointNode.controller_guid || "");
if (controllerDataMap[controllerGuid]) {
checkObjectUpdates(pointNode, controllerDataMap[controllerGuid]);
}
// Re-fetch the node to get latest properties
var latestNode = RED.nodes.node(pointNode.id);
if (latestNode && latestNode.objectName !== pointNode.objectName) {
pointNode.objectName = latestNode.objectName;
}
// Mark node as changed to trigger label refresh
pointNode.changed = true;
nodesMarkedDirty++;
}
});
// Update controller nodes
allControllerNodes.forEach(function(controllerNode) {
if (controllerNode && !controllerNode._closed) {
var controllerGuid = String(controllerNode.guid || "");
if (controllerGuid && controllerDataMap[controllerGuid]) {
var controllerData = controllerDataMap[controllerGuid];
var currentDeviceName = String(controllerNode.deviceName || "");
var newDeviceName = String(controllerData.deviceName || controllerData.modelName || "");
if (newDeviceName && newDeviceName !== currentDeviceName) {
var latestControllerNode = RED.nodes.node(controllerNode.id);
if (latestControllerNode && latestControllerNode.deviceName && latestControllerNode.deviceName !== controllerNode.deviceName) {
controllerNode.deviceName = latestControllerNode.deviceName;
} else {
controllerNode.deviceName = newDeviceName;
if (latestControllerNode) {
latestControllerNode.deviceName = newDeviceName;
}
}
controllerNode.changed = true;
nodesMarkedDirty++;
}
}
}
});
if (nodesMarkedDirty > 0) {
RED.nodes.dirty(true);
if (RED.view && typeof RED.view.redraw === "function") {
RED.view.redraw(true);
}
}
});
}
} catch (e) {
// Ignore errors
}
}
// Define refreshAllLabels in global scope so it can be accessed by event handlers
function refreshAllLabels() {
try {
var controllerNodes = [];
if (RED.nodes && typeof RED.nodes.filterNodes === "function") {
controllerNodes = RED.nodes.filterNodes({ type: "bacnet-controller" }) || [];
}
// Don't mark controller nodes as changed here - only mark if there's an actual change
// This prevents the node from always being dirty
// Labels will still refresh via RED.view.redraw() without marking as changed
// Check if any nodes have changed = true before setting dirty
// This prevents deploy button from being active when no nodes are changed
var hasChangedNodes = false;
if (RED.nodes && typeof RED.nodes.filterNodes === "function") {
var allNodes = RED.nodes.filterNodes() || [];
for (var i = 0; i < allNodes.length; i++) {
var node = allNodes[i];
if (node && !node._closed && node.changed === true) {
hasChangedNodes = true;
break;
}
}
}
// Trigger redraw to update labels immediately
// Only set dirty if there are actually changed nodes
try {
if (hasChangedNodes && RED.nodes && typeof RED.nodes.dirty === "function") {
RED.nodes.dirty(true);
}
RED.view.redraw(true);
} catch (error) {
// Ignore errors
}
} catch (refreshError) {
// Ignore errors
}
}
// Browser-side status monitoring (no logging)
(function() {
var statusCheckIntervals = {};
var lastKnownStatus = {};
function getControllerStatus(nodeId) {
try {
var node = RED.nodes.node(nodeId);
if (!node) return null;
return node.status ? node.status() : null;
} catch (e) {
return null;
}
}
function checkControllerStatus(nodeId) {
try {
var node = RED.nodes.node(nodeId);
if (!node || node._closed || node.type !== "bacnet-controller") {
// Node doesn't exist or is closed - stop monitoring
if (statusCheckIntervals[nodeId]) {
clearInterval(statusCheckIntervals[nodeId]);
delete statusCheckIntervals[nodeId];
delete lastKnownStatus[nodeId];
}
return;
}
var currentStatus = getControllerStatus(nodeId);
if (currentStatus) {
var lastStatus = lastKnownStatus[nodeId];
lastKnownStatus[nodeId] = {
fill: currentStatus.fill,
shape: currentStatus.shape,
text: currentStatus.text
};
// If connected, stop monitoring
if (currentStatus.fill === "green") {
if (statusCheckIntervals[nodeId]) {
clearInterval(statusCheckIntervals[nodeId]);
delete statusCheckIntervals[nodeId];
}
}
}
} catch (e) {
// Ignore errors
}
}
function startMonitoringController(nodeId) {
if (statusCheckIntervals[nodeId]) {
return; // Already monitoring
}
// Check immediately
checkControllerStatus(nodeId);
// Then check periodically
statusCheckIntervals[nodeId] = setInterval(function() {
checkControllerStatus(nodeId);
}, 1000); // Check every second
}
function stopMonitoringController(nodeId) {
if (statusCheckIntervals[nodeId]) {
clearInterval(statusCheckIntervals[nodeId]);
delete statusCheckIntervals[nodeId];
delete lastKnownStatus[nodeId];
}
}
// Monitor all controller nodes on flows:loaded
RED.events.on("flows:loaded", function() {
setTimeout(function() {
var controllerNodes = RED.nodes.filterNodes ? RED.nodes.filterNodes({ type: "bacnet-controller" }) : [];
controllerNodes.forEach(function(node) {
if (node && node.id) {
startMonitoringController(node.id);
}
});
}, 500);
});
// Monitor controller nodes on deploy
RED.events.on("deploy", function() {
setTimeout(function() {
var controllerNodes = RED.nodes.filterNodes ? RED.nodes.filterNodes({ type: "bacnet-controller" }) : [];
controllerNodes.forEach(function(node) {
if (node && node.id && node.ocn_server_node) {
startMonitoringController(node.id);
// Also do periodic checks with delays to catch retries
var retryDelays = [500, 1000, 2000, 3000, 5000, 8000];
retryDelays.forEach(function(delay, index) {
setTimeout(function() {
var currentNode = RED.nodes.node(node.id);
if (currentNode && !currentNode._closed) {
checkControllerStatus(node.id);
}
}, delay);
});
}
});
}, 100);
});
// Monitor new controller nodes when added
RED.events.on("nodes:add", function(addedNode) {
if (addedNode && addedNode.type === "bacnet-controller" && addedNode.id) {
setTimeout(function() {
startMonitoringController(addedNode.id);
}, 500);
}
});
// Stop monitoring when nodes are removed
RED.events.on("nodes:remove", function(removedNode) {
if (removedNode && removedNode.type === "bacnet-controller" && removedNode.id) {
stopMonitoringController(removedNode.id);
}
});
})();
</script>
<style>
#nr-obj-actions-top,
#nr-obj-out-actions-top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
#nr-obj-toolbar,
#nr-obj-out-toolbar {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
margin: 6px 0;
}
</style>
<script type="text/html" data-template-name="bacnet-controller">
<div class="form-row">
<ul id="node-bc-tabs" class="red-ui-tabs"></ul>
</div>
<div id="node-bc-tabs-content" style="min-height:420px;">
<div id="node-bc-tab-general" style="display:none">
<div class="form-row">
<label for="node-input-name"><i class="fa fa-bookmark"></i> Name</label>
<input type="text" id="node-input-name" placeholder="Optional name" />
</div>
<div class="form-row">
<label for="node-input-ocn_server_node"><i class="fa fa-server"></i> OCN Server</label>
<input type="text" id="node-input-ocn_server_node" />
</div>
<div class="form-row">
<span id="nr-bc-selected" style="display:inline-block;padding:6px 8px;background:#f5f5f5;border-radius:6px"
>None selected</span
>
</div>
<div class="form-row">
<div style="display:flex;align-items:center;justify-content:space-between;width:100%;height:32px;">
<label style="line-height:32px;">Controllers</label>
<button
type="button"
class="red-ui-button"
id="nr-refresh"
title="Refresh list"
style="width:32px;height:32px;display:flex;align-items:center;justify-content:center;padding:0;"
>
<i id="nr-refresh-icon" class="fa fa-refresh" style="font-size:14px;"></i>
</button>
</div>
<div
id="nr-bacnet-tip"
style="display:none;margin:6px 0 0 0;padding:6px 8px;border:1px solid #ffd37a;background:#fff6df;color:#7a5b00;border-radius:6px;font-size:12px;"
></div>
<input
type="text"
id="nr-bacnet-search"
placeholder="Search name/model/instance/network"
style="width:100%;margin:6px 0 8px 0;padding:6px 8px;border:1px solid #ddd;border-radius:6px"
/>
<div id="nr-bacnet-list-wrap" style="position:relative;">
<div
id="nr-bacnet-list"
style="height:240px;overflow:auto;border:1px solid #ddd;border-radius:8px;padding:6px"
></div>
<div
id="nr-bacnet-loading"
style="display:none;position:absolute;left:0;top:0;right:0;bottom:0;background:rgba(255,255,255,0.7);align-items:center;justify-content:center;border:1px solid #ddd;border-radius:8px;"
>
<i class="fa fa-spinner fa-spin" style="font-size:18px;margin-right:8px;"></i>
Loading...
</div>
</div>
</div>
<input type="hidden" id="node-input-deviceInstance" />
<input type="hidden" id="node-input-networkNumber" />
<input type="hidden" id="node-input-deviceName" />
<input type="hidden" id="node-input-modelName" />
<input type="hidden" id="node-input-description" />
<input type="hidden" id="node-input-address" />
<input type="hidden" id="node-input-vendorId" />
<input type="checkbox" id="node-input-thirdParty" style="display:none" />
<input type="hidden" id="node-input-guid" />
<input type="hidden" id="node-input-spawnLabelMode" />
<input type="hidden" id="node-input-spawnObjectLabelMode" />
<input type="hidden" id="node-input-spawnSourceMode" />
<input type="checkbox" id="node-input-spawnAcknowledgedMqtt" style="display:none" checked />
</div>
<div id="node-bc-tab-inputs" style="display:none">
<div id="nr-obj-actions-top">
<div style="display:flex;gap:8px;align-items:center;">
<button type="button" class="red-ui-button red-ui-button-primary" id="nr-obj-add">Add nodes</button>
</div>
<div style="display:flex;gap:8px;align-items:center;">
<button
type="button"
class="red-ui-button"
id="nr-obj-refresh"
title="Refresh points"
style="width:32px;height:32px;display:flex;align-items:center;justify-content:center;padding:0;"
>
<i id="nr-obj-refresh-icon" class="fa fa-refresh" style="font-size:14px;"></i>
</button>
</div>
</div>
<div id="nr-obj-toolbar">
<div style="display:flex;gap:8px;align-items:center;">
<input
type="text"
id="nr-obj-search"
placeholder="Search name/type/instance"
style="width:260px;padding:6px 8px;border:1px solid #ddd;border-radius:6px"
/>
<button type="button" class="red-ui-button" id="nr-obj-selectall">Select all</button>
<button type="button" class="red-ui-button" id="nr-obj-clear">Clear</button>
<span id="nr-obj-count" style="color:#666;margin-left:8px;">0 selected</span>
</div>
</div>
<div
id="nr-obj-tip"
style="display:none;margin:6px 0 6px 0;padding:6px 8px;border:1px solid #ffd37a;background:#fff6df;color:#7a5b00;border-radius:6px;font-size:12px;"
></div>
<div id="nr-obj-list-wrap" style="position:relative;">
<div id="nr-obj-list" style="height:240px;overflow:auto;border:1px solid #ddd;border-radius:8px;padding:6px"></div>
<div
id="nr-obj-loading"
style="display:none;position:absolute;left:0;top:0;right:0;bottom:0;background:rgba(255,255,255,0.7);align-items:center;justify-content:center;border:1px solid #ddd;border-radius:8px;"
>
<i class="fa fa-spinner fa-spin" style="font-size:18px;margin-right:8px;"></i>
Loading...
</div>
</div>
<div class="form-row" style="margin-top:10px;display:flex;align-items:center;">
<label for="nr-obj-labelmode" style="min-width:100px;">Device Label</label>
<select id="nr-obj-labelmode" style="min-width:160px">
<option value="instance" selected>Device instance</option>
<option value="device">Device name</option>
<option value="both">Device name + instance</option>
</select>
</div>
<div class="form-row" style="margin-top:10px;display:flex;align-items:center;">
<label for="nr-obj-objectlabelmode" style="min-width:100px;">Object Label</label>
<select id="nr-obj-objectlabelmode" style="min-width:160px">
<option value="name" selected>Object name</option>
<option value="id">Object ID</option>
<option value="both">Object name + ID</option>
</select>
</div>
<div class="form-row" style="margin-top:10px;display:flex;flex-wrap:nowrap;align-items:center;">
<label for="nr-obj-sourcemode" style="flex-shrink:0;width:100px;">Data Source</label>
<select id="nr-obj-sourcemode" style="width:140px;flex-shrink:0;">
<option value="mqtt" selected>MQTT</option>
<option value="api">API (HTTPS)</option>
</select>
<button type="button" class="red-ui-button" id="nr-obj-apply-source" style="flex-shrink:0;margin-left:8px;" title="Apply to all linked input points">Apply to linked points</button>
</div>
</div>
<div id="node-bc-tab-outputs" style="display:none">
<div id="nr-obj-out-actions-top">
<div style="display:flex;gap:8px;align-items:center;">
<button type="button" class="red-ui-button red-ui-button-primary" id="nr-obj-out-add">Add nodes</button>
</div>
<div style="display:flex;gap:8px;align-items:center;">
<button
type="button"
class="red-ui-button"
id="nr-obj-out-refresh"
title="Refresh points"
style="width:32px;height:32px;display:flex;align-items:center;justify-content:center;padding:0;"
>
<i id="nr-obj-out-refresh-icon" class="fa fa-refresh" style="font-size:14px;"></i>
</button>
</div>
</div>
<div id="nr-obj-out-toolbar">
<div style="display:flex;gap:8px;align-items:center;">
<input
type="text"
id="nr-obj-out-search"
placeholder="Search name/type/instance"
style="width:260px;padding:6px 8px;border:1px solid #ddd;border-radius:6px"
/>
<button type="button" class="red-ui-button" id="nr-obj-out-selectall">Select all</button>
<button type="button" class="red-ui-button" id="nr-obj-out-clear">Clear</button>
<span id="nr-obj-out-count" style="color:#666;margin-left:8px;">0 selected</span>
</div>
</div>
<div
id="nr-obj-out-tip"
style="display:none;margin:6px 0 6px 0;padding:6px 8px;border:1px solid #ffd37a;background:#fff6df;color:#7a5b00;border-radius:6px;font-size:12px;"
></div>
<div id="nr-obj-out-list-wrap" style="position:relative;">
<div id="nr-obj-out-list" style="height:240px;overflow:auto;border:1px solid #ddd;border-radius:8px;padding:6px"></div>
<div
id="nr-obj-out-loading"
style="display:none;position:absolute;left:0;top:0;right:0;bottom:0;background:rgba(255,255,255,0.7);align-items:center;justify-content:center;border:1px solid #ddd;border-radius:8px;"
>
<i class="fa fa-spinner fa-spin" style="font-size:18px;margin-right:8px;"></i>
Loading...
</div>
</div>
<div class="form-row" style="margin-top:10px;display:flex;align-items:center;">
<label for="nr-obj-out-labelmode" style="min-width:100px;">Device Label</label>
<select id="nr-obj-out-labelmode" style="min-width:160px">
<option value="instance" selected>Device instance</option>
<option value="device">Device name</option>
<option value="both">Device name + instance</option>
</select>
</div>
<div class="form-row" style="margin-top:10px;display:flex;align-items:center;">
<label for="nr-obj-out-objectlabelmode" style="min-width:100px;">Object Label</label>
<select id="nr-obj-out-objectlabelmode" style="min-width:160px">
<option value="name" selected>Object name</option>
<option value="id">Object ID</option>
<option value="both">Object name + ID</option>
</select>
</div>
<div class="form-row" style="margin-top:10px;display:flex;flex-wrap:nowrap;align-items:center;">
<label for="nr-obj-out-acknowledged" style="flex-shrink:0;width:100px;">Acknowledged (MQTT)</label>
<input type="checkbox" id="nr-obj-out-acknowledged" style="width:auto;margin-top:0;flex-shrink:0;" checked />
<button type="button" class="red-ui-button" id="nr-obj-out-apply-source" style="flex-shrink:0;margin-left:8px;" title="Apply to all linked output points">Apply to linked points</button>
</div>
</div>
</div>
</script>
<script type="text/html" data-help-name="bacnet-controller">
<p>BACnet controller node.</p>
</script>