UNPKG

@strato-automation/node-red-contrib-strato-automation

Version:
2,858 lines 127 kB
<!-- 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>