UNPKG

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

Version:
2,163 lines 84 kB
<script type="text/javascript">
  (function () {
    if (window.__bnPointLabelCacheInit) return;
    window.__bnPointLabelCacheInit = true;
    window.__bnPointLabelCache = window.__bnPointLabelCache || {};

    function toAbbr(typeValue) {
      var typeAbbreviationMap = {
        0: "AI",
        1: "AO",
        2: "AV",
        3: "BI",
        4: "BO",
        5: "BV",
        12: "PID",
        19: "MSV",
      };
      if (typeof typeValue === "number") {
        return typeAbbreviationMap[typeValue] || String(typeValue);
      }
      var normalizedType = String(typeValue || "")
        .trim()
        .toUpperCase();
      if (/^\d+$/.test(normalizedType)) {
        return typeAbbreviationMap[+normalizedType] || normalizedType;
      }
      return typeAbbreviationMap[normalizedType] != null
        ? typeAbbreviationMap[normalizedType]
        : normalizedType;
    }

    function buildDeviceLabel(mode, name, instance) {
      var cleanName = String(name || "").trim();
      var cleanInstance = String(instance || "").trim();
      if (mode === "device") return cleanName || cleanInstance;
      if (mode === "both") {
        var middleDotSeparator = " " + String.fromCharCode(183) + " ";
        if (cleanName && cleanInstance)
          return cleanName + middleDotSeparator + cleanInstance;
        return cleanName || cleanInstance;
      }
      return cleanInstance || cleanName;
    }

    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 computePreviewLabel(pointNode, controllerInfo) {
      var controllerName =
        (controllerInfo && controllerInfo.controllerName) ||
        pointNode.controllerName ||
        "";
      var controllerDeviceInstance =
        (controllerInfo && controllerInfo.controllerDeviceInstance) ||
        pointNode.controllerDeviceInstance ||
        "";
      var labelMode = pointNode.labelMode || "instance";
      var objectLabelMode = pointNode.objectLabelMode || "name";
      var deviceLabel = buildDeviceLabel(
        labelMode,
        controllerName,
        controllerDeviceInstance
      );
      var objectId =
        pointNode.objectType != null && pointNode.objectInstance != null
          ? toAbbr(pointNode.objectType) + "-" + pointNode.objectInstance
          : "";
      var objectName = (pointNode.objectName || "").trim();
      var objectLabel = buildObjectLabel(objectLabelMode, objectName, objectId);

      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 || "BACnet Point");
        var middleDotSeparator = " " + String.fromCharCode(183) + " ";
        return labelParts.length
          ? labelParts.join(middleDotSeparator)
          : "BACnet Point";
      }

      var separator = " " + String.fromCharCode(183) + " ";
      return objectLabel && deviceLabel
        ? objectLabel + separator + deviceLabel
        : objectLabel || deviceLabel || objectId || "BACnet Point";
    }

    function setLabelCache(pointNode, controllerInfo, objectInfo) {
      var computedLabel = computePreviewLabel(
        pointNode,
        objectInfo || controllerInfo || {}
      );
      var nodeChanged = false;
      if (pointNode) {
        if (controllerInfo) {
          if (controllerInfo.controllerName != null) {
            if (
              Object.prototype.hasOwnProperty.call(pointNode, "controllerName")
            ) {
              if (pointNode.controllerName !== controllerInfo.controllerName) {
                pointNode.controllerName = controllerInfo.controllerName;
                nodeChanged = true;
              }
            }
            if (
              Object.prototype.hasOwnProperty.call(pointNode, "deviceName") &&
              !controllerInfo.deviceName
            ) {
              if (pointNode.deviceName !== controllerInfo.controllerName) {
                pointNode.deviceName = controllerInfo.controllerName;
                nodeChanged = true;
              }
            }
          }
          if (controllerInfo.controllerDeviceInstance != null) {
            if (
              Object.prototype.hasOwnProperty.call(
                pointNode,
                "controllerDeviceInstance"
              )
            ) {
              if (pointNode.controllerDeviceInstance !== controllerInfo.controllerDeviceInstance) {
                pointNode.controllerDeviceInstance =
                  controllerInfo.controllerDeviceInstance;
                nodeChanged = true;
              }
            }
            if (
              Object.prototype.hasOwnProperty.call(pointNode, "deviceInstance")
            ) {
              if (pointNode.deviceInstance !== controllerInfo.controllerDeviceInstance) {
                pointNode.deviceInstance =
                  controllerInfo.controllerDeviceInstance;
                nodeChanged = true;
              }
            }
          }
        }
        if (objectInfo) {
          if (
            objectInfo.objectName != null &&
            Object.prototype.hasOwnProperty.call(pointNode, "objectName")
          ) {
            if (pointNode.objectName !== objectInfo.objectName) {
              pointNode.objectName = objectInfo.objectName;
              nodeChanged = true;
            }
          }
          if (
            objectInfo.units != null &&
            Object.prototype.hasOwnProperty.call(pointNode, "units")
          ) {
            if (pointNode.units !== objectInfo.units) {
              pointNode.units = objectInfo.units;
              nodeChanged = true;
            }
          }
          if (
            objectInfo.description != null &&
            Object.prototype.hasOwnProperty.call(pointNode, "description")
          ) {
            if (pointNode.description !== objectInfo.description) {
              pointNode.description = objectInfo.description;
              nodeChanged = true;
            }
          }
        }
        if (pointNode.previewLabel !== computedLabel) {
          pointNode.previewLabel = computedLabel;
          nodeChanged = true;
        }
      }

      if (window.__bnPointLabelCache[pointNode.id] !== computedLabel) {
        window.__bnPointLabelCache[pointNode.id] = computedLabel;
      }
      
      // Mark node as changed so Node-RED updates the label (redraw will be called after all nodes are updated)
      if (nodeChanged) {
        pointNode.changed = true;
      }
    }


    
    function refreshAllPointLabels(onComplete) {
      try {

        var pointNodes = [];
        if (RED.nodes && typeof RED.nodes.filterNodes === "function") {
          pointNodes = pointNodes.concat(
            RED.nodes.filterNodes({ type: "bacnet-point-in" }) || []
          );
          pointNodes = pointNodes.concat(
            RED.nodes.filterNodes({ type: "bacnet-point-in-out" }) || []
          );
          var seenNodeIds = {};
          pointNodes = pointNodes.filter(function (pointNode) {
            if (!pointNode || !pointNode.id) return false;
            if (seenNodeIds[pointNode.id]) return false;
            seenNodeIds[pointNode.id] = true;
            return true;
          });
        }

        // Backend refreshMapping() has already updated node properties (objectName, controllerName, etc.)
        // Since labels can only be updated from HTML, we need to:
        // 1. Fetch latest node properties from RED.nodes.node() to get backend updates
        // 2. Update frontend node properties to match backend
        // 3. Clear label cache to force recalculation
        // 4. Trigger redraw so label() function runs with updated properties
        

        
        // First, fetch latest properties from backend nodes via HTTP endpoint
        // This ensures we get the actual backend values updated by refreshMapping()
        // RED.nodes.node() may return cached frontend values, so we need to fetch from backend
        // A single batch request returns every point node's properties at once
        // (one request per refresh instead of one request per node).
        var nodesUpdated = 0;

        $.ajax({
          url: "bacnet-point-in/nodePropertiesBatch",
          method: "GET",
          dataType: "json"
        })
        .done(function(backendPropsById) {
          if (!backendPropsById || typeof backendPropsById !== "object") return;
          pointNodes.forEach(function(pointNode) {
            if (!pointNode || !pointNode.id || pointNode._closed) return;
            var backendProps = backendPropsById[pointNode.id];
            if (backendProps && backendProps.nodeId === pointNode.id) {
              var updated = false;
              var oldObjectName = String(pointNode.objectName || "");
              var oldControllerName = String(pointNode.controllerName || "");
              var oldControllerDeviceInstance = String(pointNode.controllerDeviceInstance || "");
              var oldUnits = String(pointNode.units || "");
              var oldDescription = String(pointNode.description || "");

              // Update frontend node properties from backend
              if (backendProps.objectName !== oldObjectName) {
                pointNode.objectName = backendProps.objectName;
                updated = true;
              }
              if (backendProps.controllerName !== oldControllerName) {
                pointNode.controllerName = backendProps.controllerName;
                updated = true;
              }
              if (backendProps.controllerDeviceInstance !== oldControllerDeviceInstance) {
                pointNode.controllerDeviceInstance = backendProps.controllerDeviceInstance;
                updated = true;
              }
              if (backendProps.units !== oldUnits) {
                pointNode.units = backendProps.units;
                updated = true;
              }
              if (backendProps.description !== oldDescription) {
                pointNode.description = backendProps.description;
                updated = true;
              }

              if (updated) {
                nodesUpdated++;
              }

              // Always clear cache for this node to force label recalculation
              if (window.__bnPointLabelCache && window.__bnPointLabelCache[pointNode.id]) {
                delete window.__bnPointLabelCache[pointNode.id];
              }
            }
          });
        })
        .fail(function() {
          // If the batch fetch fails there is no reliable backend data;
          // labels are recalculated below from the current editor values
          // (the .always() handler clears every label cache and redraws).
        })
        .always(function() {
          
          // Clear all label caches to force label recalculation
          var cacheClearedCount = 0;
          if (window.__bnPointLabelCache) {
            Object.keys(window.__bnPointLabelCache).forEach(function(nodeId) {
              delete window.__bnPointLabelCache[nodeId];
              cacheClearedCount++;
            });
          }
          
          
          // Multiple redraw attempts to ensure labels update
          function forceRedraw() {
            try {
              if (RED.view && typeof RED.view.redraw === "function") {
                RED.view.redraw(true);
                return true;
              }
            } catch (e) {
            }
            return false;
          }
          
          // Immediate redraw
          forceRedraw();
          
          // Redraw after short delay
          setTimeout(function() {
            forceRedraw();
          }, 100);
          
          // Redraw after longer delay to catch any late updates
          setTimeout(function() {
            forceRedraw();
          }, 500);

          if (typeof onComplete === "function") onComplete();
        });

      } catch (refreshError) {
        if (typeof onComplete === "function") onComplete();
      }
    }

    // Debounced/coalesced entry point for refreshAllPointLabels: bursts of
    // events (e.g. hundreds of nodes:add during flow import) collapse into a
    // single trailing refresh, and refreshes never overlap - a request made
    // while one is in flight queues exactly one follow-up run.
    var __bnRefreshTimer = null;
    var __bnRefreshInFlight = false;
    var __bnRefreshQueued = false;
    var __bnRefreshCallbacks = [];
    var __bnFlowsLoaded = false;

    function runRefreshNow() {
      if (__bnRefreshInFlight) {
        __bnRefreshQueued = true;
        return;
      }
      __bnRefreshInFlight = true;
      var completionCallbacks = __bnRefreshCallbacks;
      __bnRefreshCallbacks = [];
      refreshAllPointLabels(function () {
        __bnRefreshInFlight = false;
        completionCallbacks.forEach(function (callback) {
          try {
            callback();
          } catch (_ignoredError) {}
        });
        if (__bnRefreshQueued) {
          __bnRefreshQueued = false;
          scheduleRefreshAllPointLabels();
        }
      });
    }

    // Optional onComplete callback fires once the refresh it was queued for
    // (or the follow-up run, if one was already in flight) has completed.
    function scheduleRefreshAllPointLabels(onComplete) {
      if (typeof onComplete === "function") __bnRefreshCallbacks.push(onComplete);
      if (__bnRefreshTimer) clearTimeout(__bnRefreshTimer);
      __bnRefreshTimer = setTimeout(function () {
        __bnRefreshTimer = null;
        runRefreshNow();
      }, 300);
    }

    function migrateOldBacnetPointNodes() {
      try {
        // Check for old bacnet-point nodes and migrate them to bacnet-point-in
        var allNodes = RED.nodes.filterNodes ? RED.nodes.filterNodes({ type: "bacnet-point" }) : [];
        if (allNodes && allNodes.length > 0) {
          var migrationCount = 0;
          allNodes.forEach(function(node) {
            if (node && node.type === "bacnet-point") {
              // Migrate to bacnet-point-in
              node.type = "bacnet-point-in";
              
              // Set default values for new properties if missing
              if (node.source === undefined || node.source === null) {
                node.source = "mqtt";
              }
              if (node.advancedMode === undefined) {
                node.advancedMode = false;
              }
              if (node.output_type === undefined) {
                node.output_type = "ip";
              }
              if (node.bypass_priority === undefined) {
                node.bypass_priority = false;
              }
              if (node.initialized === undefined) {
                node.initialized = false;
              }
              
              // Mark node as changed
              node.changed = true;
              migrationCount++;
            }
          });
          
          if (migrationCount > 0) {
            // Mark flow as dirty so user can deploy
            // Only mark as dirty if nodes were actually migrated (they will have changed = true)
            try {
              // Check if any migrated nodes have changed = true before setting dirty
              var hasChangedNodes = false;
              for (var i = 0; i < allNodes.length; i++) {
                var node = allNodes[i];
                if (node && node.type === "bacnet-point-in" && node.changed === true) {
                  hasChangedNodes = true;
                  break;
                }
              }
              if (hasChangedNodes && RED.nodes && typeof RED.nodes.dirty === "function") {
                RED.nodes.dirty(true);
              }
              RED.view.redraw(true);
            } catch (error) {
              console.error("Error marking flow as dirty after migration:", error);
            }
            
            // Show notification to user
            if (typeof RED.notify !== "undefined" && RED.notify.info) {
              RED.notify.info(
                "Migrated " + migrationCount + " bacnet-point node(s) to bacnet-point-in. Please deploy to apply changes.",
                { timeout: 8000 }
              );
            }
          }
        }
      } catch (error) {
        console.error("Error during bacnet-point migration:", error);
      }
    }

    RED.events.on("flows:loaded", function() {
      __bnFlowsLoaded = true;
      migrateOldBacnetPointNodes();
      scheduleRefreshAllPointLabels();
    });

    // Validate nodes immediately when they're added (including copy/paste)
    RED.events.on("nodes:add", function(addedNode) {
      try {
        if (!addedNode) return;
        if (addedNode.type === "bacnet-point-in" || addedNode.type === "bacnet-point-out") {
          // Validate the node immediately so it's recognized by HTTP endpoints
          if (typeof RED.editor !== "undefined" && RED.editor.validateNode) {
            RED.editor.validateNode(addedNode);
          }
          // Also trigger a redraw to ensure the node is properly registered
          // (debounced via a window-shared timer: one redraw per burst of
          // added nodes, even across the point-in and point-out handlers)
          if (window.__bnValidateRedrawTimer) clearTimeout(window.__bnValidateRedrawTimer);
          window.__bnValidateRedrawTimer = setTimeout(function() {
            window.__bnValidateRedrawTimer = null;
            if (RED.view && RED.view.redraw) {
              RED.view.redraw(true);
            }
          }, 50);
        }
      } catch (error) {
        console.error("Error validating node on add:", error);
      }
    });
    RED.events.on("deploy", function() {

      
      // Function to run the full refresh sequence
      function runRefreshSequence() {
         // Get all point nodes and update their label cache directly (like setLabelCacheFromFields does)
        var pointNodes = [];
        if (RED.nodes && typeof RED.nodes.filterNodes === "function") {
          pointNodes = RED.nodes.filterNodes({ type: "bacnet-point-in" }) || [];
          pointNodes = pointNodes.concat(RED.nodes.filterNodes({ type: "bacnet-point-in-out" }) || []);
        }
        
        // Update label cache for each node (this is what makes labels update)
        window.__bnPointLabelCache = window.__bnPointLabelCache || {};
        pointNodes.forEach(function(pointNode) {
          if (pointNode && pointNode.id) {
            // Force cache update by deleting it - label() will recalculate
            delete window.__bnPointLabelCache[pointNode.id];
          }
        });
        
        // Redraw immediately (like setLabelCacheFromFields does)
        if (RED.view && typeof RED.view.redraw === "function") {
          RED.view.redraw(true);
        }
        
        // Also call refresh to sync with backend
        try {
          scheduleRefreshAllPointLabels();
        } catch (e) {
        }
      }

      // Run immediately
      runRefreshSequence();

      // And retry once to account for backend startup delay
      setTimeout(runRefreshSequence, 1500);
    });
    
    // Refresh labels when nodes are added, removed, or changed (not on a timer)
    RED.events.on("nodes:add", function(addedNode) {
      // Skip during initial flow import: nodes:add fires once per imported
      // node and flows:loaded already triggers a single refresh afterwards.
      if (!__bnFlowsLoaded) return;
      if (addedNode && (addedNode.type === "bacnet-point-in" || addedNode.type === "bacnet-controller")) {
        scheduleRefreshAllPointLabels();
      }
    });

    RED.events.on("nodes:remove", function(removedNode) {
      if (removedNode && (removedNode.type === "bacnet-point-in" || removedNode.type === "bacnet-controller")) {
        scheduleRefreshAllPointLabels();
      }
    });

    RED.events.on("nodes:change", function(changedNodes) {
      // Refresh labels when controllers or points are modified
      var shouldRefresh = false;
      if (changedNodes && changedNodes.changed) {
        for (var i = 0; i < changedNodes.changed.length; i++) {
          var node = changedNodes.changed[i];
          if (node && (node.type === "bacnet-point-in" || node.type === "bacnet-controller")) {
            shouldRefresh = true;
            break;
          }
        }
      }
      if (shouldRefresh) {
        scheduleRefreshAllPointLabels();
      }
    });

    // Removed automatic polling - labels now only refresh on events
    // This prevents constant HTTP requests and 504 errors

    window.__bnPointForceRefresh = scheduleRefreshAllPointLabels;
    window.__bnPointBuildDeviceLabel = buildDeviceLabel;
    window.__bnPointBuildObjectLabel = buildObjectLabel;
  })();
</script>
<script type="text/javascript">
  (function () {
    if (window.__bnPointEditorV9) return;
    window.__bnPointEditorV9 = true;

    var buildDeviceLabel =
      window.__bnPointBuildDeviceLabel ||
      function (mode, name, instance) {
        var cleanName = String(name || "").trim();
        var cleanInstance = String(instance || "").trim();
        if (mode === "device") return cleanName || cleanInstance;
        if (mode === "both") {
          if (cleanName && cleanInstance)
            return cleanName + " (" + cleanInstance + ")";
          return cleanName || cleanInstance;
        }
        return cleanInstance || cleanName;
      };

    var buildObjectLabel =
      window.__bnPointBuildObjectLabel ||
      function (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 allowInitial() {
      return true;
    }

    // Register bacnet-point as an alias/migration target for bacnet-point-in
    // This ensures Node-RED recognizes old bacnet-point nodes in the editor
    // The JavaScript migration handler will convert them to bacnet-point-in
    RED.nodes.registerType("bacnet-point", {
      color: "#f3b567",
      defaults: {
        name: { value: "", label: "Name" },
        ocn_server_node: { type: "ocn-server", required: false },
        source: {
          value: "mqtt",
          required: true,
          label: "Source",
          validate: allowInitial,
        },
        topic: {
          value: "default",
          required: true,
          label: "Topic",
          validate: allowInitial,
        },
        objectType: { value: "", validate: allowInitial },
        objectInstance: { value: "", validate: allowInitial },
        objectName: { value: "" },
        description: { value: "" },
        units: { value: "" },
        autoLabel: { value: true },
        controller_guid: { value: "" },
        controllerName: { value: "" },
        controllerDeviceInstance: { value: "" },
        labelMode: { value: "instance" },
        objectLabelMode: { value: "name" },
        advancedMode: { value: false },
        device_instance: { value: "" },
        object_id: { value: "" },
        priority: { value: "9" },
        send_policy: { value: "On value changed" },
        output_type: { value: "ip" },
        bypass_priority: { value: false },
        initialized: { value: false },
      },
      inputs: 1,
      outputs: 2,
      icon: "node-red/arrow-in.svg",
      paletteLabel: function () {
        return this.advancedMode ? "Output" : "BACnet Point";
      },
      label: function () {
        // Use the same label logic as bacnet-point-in
        if (this.name && String(this.name).trim()) return this.name;

        if (this.advancedMode) {
          return (
            (
              (this.device_instance || "Output") +
              " " +
              (this.object_id || "")
            ).trim() || "Output"
          );
        }

        var labelCache =
          (window.__bnPointLabelCache && window.__bnPointLabelCache[this.id]) ||
          "";
        if (labelCache) return labelCache;

        var deviceLabel = buildDeviceLabel(
          this.labelMode || "instance",
          this.controllerName || "",
          this.controllerDeviceInstance || ""
        );
        var hasObjectType = this.objectType != null && String(this.objectType) !== "";
        var hasObjectInstance = this.objectInstance != null && String(this.objectInstance) !== "";
        var objectId = hasObjectType && hasObjectInstance
          ? (function () {
              var typeAbbr = "";
              var typeMap = {
                0: "AI", 1: "AO", 2: "AV", 3: "BI", 4: "BO", 5: "BV",
                12: "MI", 13: "MO", 14: "MV", 19: "MSV"
              };
              if (typeof this.objectType === "number") {
                typeAbbr = typeMap[this.objectType] || String(this.objectType);
              } else {
                var normalized = String(this.objectType || "").trim().toUpperCase();
                if (/^\d+$/.test(normalized)) {
                  typeAbbr = typeMap[parseInt(normalized, 10)] || normalized;
                } else {
                  typeAbbr = typeMap[normalized] || normalized;
                }
              }
              return typeAbbr + "-" + String(this.objectInstance || "");
            }.call(this))
          : "";
        var objectLabel = buildObjectLabel(
          this.objectLabelMode || "name",
          this.objectName || "",
          objectId
        );
        var separator = " " + String.fromCharCode(183) + " ";
        if (this.labelMode === "both") {
          var dot = " " + String.fromCharCode(183) + " ";
          var labelParts = [];
          if (objectLabel) labelParts.push(objectLabel);
          if (this.controllerName) labelParts.push(this.controllerName);
          if (this.controllerDeviceInstance) labelParts.push(this.controllerDeviceInstance);
          if (!labelParts.length) labelParts.push(objectId || this.objectName || "");
          return labelParts.length
            ? labelParts.join(dot)
            : objectLabel ||
              deviceLabel ||
              objectId ||
              "BACnet Point";
        } else {
          if (objectLabel && deviceLabel) {
            return objectLabel + separator + deviceLabel;
          } else if (objectLabel) {
            return objectLabel;
          } else if (deviceLabel) {
            return deviceLabel;
          } else {
            return objectId || this.objectName || "BACnet Point";
          }
        }
      },
      inputLabels: function () {
        return "Trigger";
      },
      outputLabels: function (outputIndex) {
        return outputIndex === 0 ? "Value (msg.payload)" : "Status flags";
      },
    });

    RED.nodes.registerType("bacnet-point-in", {
      color: "#f3b567",
      defaults: {
        name: { value: "", label: "Name" },
        ocn_server_node: { type: "ocn-server", required: false },
        source: {
          value: "mqtt",
          required: true,
          label: "Source",
          validate: allowInitial,
        },
        topic: {
          value: "default",
          required: true,
          label: "Topic",
          validate: allowInitial,
        },
        objectType: { value: "", validate: allowInitial },
        objectInstance: { value: "", validate: allowInitial },
        objectName: { value: "" },
        description: { value: "" },
        units: { value: "" },
        autoLabel: { value: true },
        controller_guid: { value: "" },
        controllerName: { value: "" },
        controllerDeviceInstance: { value: "" },
        labelMode: { value: "instance" },
        objectLabelMode: { value: "name" },
        advancedMode: { value: false },
        device_instance: { value: "" },
        object_id: { value: "" },
        priority: { value: "9" },
        send_policy: { value: "On value changed" },
        output_type: { value: "ip" },
        bypass_priority: { value: false },
        initialized: { value: false },
      },
      inputs: 1,
      outputs: 2,
      icon: "node-red/arrow-in.svg",
      paletteLabel: function () {
        return this.advancedMode ? "Output" : "BACnet Point";
      },
      label: function () {
        // Log label calculation for debugging (only first few calls to avoid spam)
        if (!window.__bnLabelCallCount) window.__bnLabelCallCount = 0;
        window.__bnLabelCallCount++;
        var shouldLog = window.__bnLabelCallCount <= 5 || (window.__bnLabelCallCount % 50 === 0);
        
        if (this.name && String(this.name).trim()) {
          return this.name;
        }

        if (this.advancedMode) {
          var advLabel = (
            (
              (this.device_instance || "Output") +
              " " +
              (this.object_id || "")
            ).trim() || "Output"
          );
          return advLabel;
        }

        // TEST: Always bypass cache to verify labels can update
        // var labelCache =
        //   (window.__bnPointLabelCache && window.__bnPointLabelCache[this.id]) ||
        //   "";
        // if (labelCache) {
        //   if (shouldLog) console.log("[bacnet-point-in] label() returning cached label:", labelCache, "for node:", this.id);
        //   return labelCache;
        // }
   

        var typeAbbreviationMap = {
          0: "AI",
          1: "AO",
          2: "AV",
          3: "BI",
          4: "BO",
          5: "BV",
          12: "PID",
          19: "MSV",
        };

        function toAbbrLocal(typeValue) {
          if (typeof typeValue === "number") {
            return typeAbbreviationMap[typeValue] || String(typeValue);
          }
          var normalizedType = String(typeValue || "")
            .trim()
            .toUpperCase();
          if (/^\d+$/.test(normalizedType))
            return typeAbbreviationMap[+normalizedType] || normalizedType;
          return typeAbbreviationMap[normalizedType] != null
            ? typeAbbreviationMap[normalizedType]
            : normalizedType;
        }

        // TEST: Append deploy counter to objectName to verify labels can update
        var deployCounter = window.__bnPointDeployCounter || 0;
        var objectName = this.objectName || "";
        var controllerName = (this.controllerName || "").trim();
        var controllerDeviceInstance = (
          this.controllerDeviceInstance || ""
        ).trim();
        var objectType = this.objectType;
        var objectInstance = this.objectInstance;
        var hasObjectId =
          objectType !== undefined &&
          objectType !== null &&
          String(objectType) !== "" &&
          objectInstance !== undefined &&
          objectInstance !== null &&
          String(objectInstance) !== "";
        var objectId = hasObjectId
          ? toAbbrLocal(objectType) + "-" + objectInstance
          : "";

        if (this.autoLabel || !this.name) {
          var labelMode = this.labelMode || "instance";
          var objectLabelMode = this.objectLabelMode || "name";
          var objectLabel = buildObjectLabel(
            objectLabelMode,
            objectName,
            objectId
          );
          var deviceLabel = buildDeviceLabel(
            labelMode,
            controllerName,
            controllerDeviceInstance
          );

          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 || "BACnet Point");
            var middleDotSeparator = " " + String.fromCharCode(183) + " ";
            return labelParts.length
              ? labelParts.join(middleDotSeparator)
              : "BACnet Point";
          }

          var separator = " " + String.fromCharCode(183) + " ";
          // TEST: Append deploy counter to verify labels are recalculated
          var deployCounter = window.__bnPointDeployCounter || 0;
          var counterSuffix = deployCounter > 0 ? " [" + deployCounter + "]" : "";
          
          if (objectLabel && deviceLabel)
            return objectLabel + counterSuffix + separator + deviceLabel;
          if (objectLabel) return objectLabel + counterSuffix;
          if (deviceLabel) return deviceLabel;
          return (objectId || objectName || "BACnet Point") + counterSuffix;
        }

        // Always return a string to prevent "replace" errors
        // TEST: Append deploy counter to verify labels are recalculated
        var deployCounter = window.__bnPointDeployCounter || 0;
        var counterSuffix = deployCounter > 0 ? " [" + deployCounter + "]" : "";
        return String(this.name || objectId || objectName || "BACnet Point") + counterSuffix;
      },
      inputLabels: function () {
        return "Trigger read";
      },
      outputLabels: function (outputIndex) {
        return outputIndex === 0 ? "Value (msg.payload)" : "Status flags";
      },

      oneditcancel: function () {
        function abortPending(requestHandle) {
          if (requestHandle && typeof requestHandle.abort === "function") {
            try {
              requestHandle.abort();
            } catch (_ignoredError) {}
          }
        }

        abortPending(this.__pendingLabelForRequest);
        abortPending(this.__pendingObjectsRequest);
        abortPending(this.__pendingResolveRequest);

        var editorNodeInstance = RED.nodes.node(this.id);
        var originalSnapshot = this.__instOriginal || {};
        if (editorNodeInstance) {
          editorNodeInstance.name = originalSnapshot.name || "";
          editorNodeInstance.autoLabel = !!originalSnapshot.autoLabel;
          editorNodeInstance.objectType = originalSnapshot.objectType;
          editorNodeInstance.objectInstance = originalSnapshot.objectInstance;
          editorNodeInstance.objectName = originalSnapshot.objectName;
          editorNodeInstance.description = originalSnapshot.description;
          editorNodeInstance.units = originalSnapshot.units;
          editorNodeInstance.controllerName =
            originalSnapshot.controllerName || "";
          editorNodeInstance.controllerDeviceInstance =
            originalSnapshot.controllerDeviceInstance || "";
          editorNodeInstance.labelMode =
            originalSnapshot.labelMode || "instance";
          editorNodeInstance.objectLabelMode =
            originalSnapshot.objectLabelMode || "name";
          editorNodeInstance.advancedMode = !!originalSnapshot.advancedMode;
          editorNodeInstance.device_instance =
            originalSnapshot.device_instance || "";
          editorNodeInstance.object_id = originalSnapshot.object_id || "";
          editorNodeInstance.priority = originalSnapshot.priority || "9";
          editorNodeInstance.send_policy =
            originalSnapshot.send_policy || "On value changed";
          editorNodeInstance.output_type = originalSnapshot.output_type || "ip";
          editorNodeInstance.bypass_priority =
            !!originalSnapshot.bypass_priority;
          editorNodeInstance.changed = !!originalSnapshot.changed;
          RED.view.redraw(true);
        }
      },

      oneditprepare: function () {
        var nodeId = this.id;
        var editorNodeInstance = RED.nodes.node(nodeId);
        var editContext = this;
        editContext.__pendingLabelForRequest = null;
        editContext.__pendingObjectsRequest = null;
        editContext.__pendingResolveRequest = null;

        this.__instOriginal = editorNodeInstance
          ? {
              name: editorNodeInstance.name,
              autoLabel: editorNodeInstance.autoLabel,
              objectType: editorNodeInstance.objectType,
              objectInstance: editorNodeInstance.objectInstance,
              objectName: editorNodeInstance.objectName,
              description: editorNodeInstance.description,
              units: editorNodeInstance.units,
              controllerName: editorNodeInstance.controllerName,
              controllerDeviceInstance:
                editorNodeInstance.controllerDeviceInstance,
              labelMode: editorNodeInstance.labelMode,
              objectLabelMode:
                editorNodeInstance.objectLabelMode || "name",
              advancedMode: !!editorNodeInstance.advancedMode,
              device_instance: editorNodeInstance.device_instance,
              object_id: editorNodeInstance.object_id,
              priority: editorNodeInstance.priority,
              send_policy: editorNodeInstance.send_policy,
              output_type: editorNodeInstance.output_type,
              bypass_priority: !!editorNodeInstance.bypass_priority,
              changed: !!editorNodeInstance.changed,
            }
          : null;

        if (RED.tabs && $("#bp-tabs").length) {
          var propertyTabs = RED.tabs.create({
            id: "bp-tabs",
            onchange: function (selectedTab) {
              $("#bp-tabs-content").children().hide();
              $("#" + selectedTab.id).show();
            },
          });
          propertyTabs.addTab({
            id: "bp-tab-props",
            label: "Properties",
            iconClass: "fa fa-sliders",
          });
          propertyTabs.addTab({
            id: "bp-tab-options",
            label: "Options",
            iconClass: "fa fa-cog",
          });
          propertyTabs.activateTab("bp-tab-props");
        }

        var abbreviationToNumericMap = {
          AI: 0,
          AO: 1,
          AV: 2,
          BI: 3,
          BO: 4,
          BV: 5,
          MI: 12,
          MO: 13,
          MV: 14,
          MSV: 19,
        };

        function fillAdvancedFromSelection() {
          var hiddenDeviceInstance = (
            $("#node-input-controllerDeviceInstance").val() || ""
          ).trim();
          var hiddenObjectType = (
            $("#node-input-objectType").val() || ""
          ).trim();
          var hiddenObjectInstance = (
            $("#node-input-objectInstance").val() || ""
          ).trim();
          var currentNodeInstance = RED.nodes.node(nodeId);

          if (hiddenDeviceInstance) {
            $("#node-input-device_instance").val(hiddenDeviceInstance);
            if (currentNodeInstance) {
              currentNodeInstance.device_instance = hiddenDeviceInstance;
            }
          }

          if (hiddenObjectType && hiddenObjectInstance) {
            var composedObjectId =
              toAbbrLocal(hiddenObjectType) + "-" + hiddenObjectInstance;
            $("#node-input-object_id").val(composedObjectId.toUpperCase());
            if (currentNodeInstance) {
              currentNodeInstance.object_id = composedObjectId.toUpperCase();
            }
          }
        }

        function parseObjectIdLocal(objectIdString) {
          var trimmedId = String(objectIdString || "")
            .trim()
            .toUpperCase();
          var match = trimmedId.match(/^([A-Z]+)\-([0-9]+)$/);
          if (!match) return null;
          var abbreviation = match[1];
          var instanceString = match[2];
          var mappedType =
            abbreviationToNumericMap &&
            Object.prototype.hasOwnProperty.call(
              abbreviationToNumericMap,
              abbreviation
            )
              ? abbreviationToNumericMap[abbreviation]
              : abbreviation;
          return { type: mappedType, instance: Number(instanceString) };
        }

        function applyAdvancedToHidden(forceApply) {
          if (!forceApply && !$("#node-input-advancedMode").is(":checked"))
            return;

          var deviceInstanceValue = (
            $("#node-input-device_instance").val() || ""
          ).trim();
          if (deviceInstanceValue) {
            $("#node-input-controllerDeviceInstance")
              .val(deviceInstanceValue)
              .trigger("change");
            var currentNodeInstance = RED.nodes.node(nodeId);
            if (currentNodeInstance) {
              currentNodeInstance.controllerDeviceInstance =
                deviceInstanceValue;
              currentNodeInstance.device_instance = deviceInstanceValue;
            }
          }

          var objectIdValue = ($("#node-input-object_id").val() || "").trim();
          var parsedId = parseObjectIdLocal(objectIdValue);
          if (parsedId) {
            $("#node-input-objectType").val(parsedId.type).trigger("change");
            $("#node-input-objectInstance")
              .val(parsedId.instance)
              .trigger("change");
            var currentNodeInstance2 = RED.nodes.node(nodeId);
            if (currentNodeInstance2) {
              currentNodeInstance2.objectType = parsedId.type;
              currentNodeInstance2.objectInstance = parsedId.instance;
              currentNodeInstance2.object_id = (
                toAbbrLocal(parsedId.type) +
                "-" +
                parsedId.instance
              ).toUpperCase();
            }
          }
          renderSelectedSummary();
          highlightCurrent();
        }

        function initAdvancedControls() {
          var $topicInput = $("#node-input-topic");
          if (
            $topicInput.length &&
            typeof $topicInput.typedInput === "function" &&
            !$topicInput.data("typedInput")
          ) {
            try {
              var topicOptions = typeof topics === "function" ? topics() : [];
              $topicInput.typedInput({
                types: [{ value: "", options: topicOptions }],
              });
            } catch (topicInitError) {}
          }

          fillAdvancedFromSelection();

          if (
            $("#node-input-advancedMode").is(":checked") &&
            window.localStorage
          ) {
            if (($("#node-input-device_instance").val() || "").trim() === "") {
              $("#node-input-device_instance").val(
                localStorage.getItem("default_device_instance") || ""
              );
            }
            if (($("#node-input-object_id").val() || "").trim() === "") {
              $("#node-input-object_id").val(
                localStorage.getItem("default_in_obj_id") || ""
              );
            }
          }

          applyAdvancedToHidden(true);
        }

        function toggleModeUI() {
          var isAdvancedMode = $("#node-input-advancedMode").is(":checked");
          if (isAdvancedMode) {
            $("#bp-props-modern").hide();
            $("#bp-props-legacy").show();
            initAdvancedControls();
          } else {
            $("#bp-props-modern").show();
            $("#bp-props-legacy").hide();
          }
        }

        $("#node-input-advancedMode").on("change", function () {
          toggleModeUI();
        });
        toggleModeUI();

        function updateOcnTrace() {
          var ocnId = "";
          var $ocnField = $("#node-input-ocn_server_node");
          if ($ocnField.length) {
            var rawOcnValue = $ocnField.val();
            if (typeof rawOcnValue === "string") {
              ocnId = rawOcnValue;
            } else if (rawOcnValue && typeof rawOcnValue === "object") {
              ocnId = rawOcnValue.id || rawOcnValue.value || "";
            }
          }
          if (!ocnId && editorNodeInstance) {
            var storedOcnValue = editorNodeInstance.ocn_server_node;
            if (storedOcnValue) {
              if (typeof storedOcnValue === "string") {
                ocnId = storedOcnValue;
              } else if (typeof storedOcnValue === "object") {
                ocnId = storedOcnValue.id || storedOcnValue.value || "";
              }
            }
          }
          $("#bp-current-ocn-id").text(ocnId ? ocnId : "None selected");
        }

        $("#node-input-ocn_server_node").on("change", function() {
          var currentNodeInstance = RED.nodes.node(nodeId);
          if (currentNodeInstance) {
            var previousOcnServerNode = currentNodeInstance.ocn_server_node;
            var newOcnServerNode = $(this).val() || "";
            if (previousOcnServerNode !== newOcnServerNode) {
              currentNodeInstance.ocn_server_node = newOcnServerNode;
              markDeployNeeded(currentNodeInstance);
            }
          }
          updateOcnTrace();
        });
        updateOcnTrace();

        $("#node-input-device_instance").on("input change", function () {
          applyAdvancedToHidden();
        });
        $("#node-input-labelMode").on("change", setLabelCacheFromFields);
        $("#node-input-objectLabelMode").on("change", setLabelCacheFromFields);
        $("#node-input-objectName").on("input change", setLabelCacheFromFields);
        $("#node-input-objectType").on("change", setLabelCacheFromFields);
        $("#node-input-objectInstance").on("change", setLabelCacheFromFields);
        $("#node-input-object_id").on("input change", function () {
          applyAdvancedToHidden();
        });

        var lastLoadedObjects = [];
        var isLoadingObjects = false;

        (function syncSourceSelectors() {
          var $legacySourceSelect = $("#node-input-source");
          var $simpleSourceSelect = $("#node-input-source-simple");
          if (!$legacySourceSelect.length || !$simpleSourceSelect.length)
            return;

          var suppressChangeSync = false;
          var initialSource = $legacySourceSelect.val() || "mqtt";
          $simpleSourceSelect.val(initialSource);

          $simpleSourceSelect.on("change", function () {
            if (suppressChangeSync) return;
            suppressChangeSync = true;
            $legacySourceSelect.val($(this).val()).trigger("change");
            suppressChangeSync = false;
          });

          $legacySourceSelect.on("change", function () {
            if (suppressChangeSync) return;
            suppressChangeSync = true;
            $simpleSourceSelect.val($(this).val());
            suppressChangeSync = false;
          });
        })();

        function toAbbrLocal(typeValue) {
          var typeAbbreviationMap = {
            0: "AI",
            1: "AO",
            2: "AV",
            3: "BI",
            4: "BO",
            5: "BV",
            12: "MI",
            13: "MO",
            14: "MV",
            19: "MSV",
          };
          if (typeof typeValue === "number") {
            return typeAbbreviationMap[typeValue] != null
              ? typeAbbreviationMap[typeValue]
              : String(typeValue);
          }
          var normalizedType = String(typeValue || "")
            .trim()
            .toUpperCase();
          if (/^\d+$/.test(normalizedType)) {
            var numericType = Number(normalizedType);
            return typeAbbreviationMap[numericType] != null
              ? typeAbbreviationMap[numericType]
              : normalizedType;
          }
          return typeAbbreviationMap[normalizedType] != null
            ? typeAbbreviationMap[normalizedType]
            : normalizedType;
        }

        function renderSelectedSummary() {
          var controllerDeviceInstance = (
            $("#node-input-controllerDeviceInstance").val() || ""
          ).trim();
          var controllerName = (
            $("#node-input-controllerName").val() || ""
          ).trim();
          var objectType = ($("#node-input-objectType").val() || "").trim();
          var objectInstance = (
            $("#node-input-objectInstance").val() || ""
          ).trim();
          var objectName = ($("#node-input-objectName").val() || "").trim();
          var objectId =
            objectType && objectInstance
              ? toAbbrLocal(objectType) + "-" + objectInstance
              : "";
          var summaryParts = [];
          var middleDotSeparator = " " + String.fromCharCode(183) + " ";

          if (controllerDeviceInstance)
            summaryParts.push(controllerDeviceInstance);
          if (controllerName) summaryParts.push(controllerName);
          if (objectId) summaryParts.push(objectId);
          if (objectName) summaryParts.push(objectName);

          var summaryText = summaryParts.join(middleDotSeparator);
          $("#nr-point-selected").text(summaryText || "None selected");
        }

        function setLabelCacheFromFields() {
          var controllerDeviceInstance = (
            $("#node-input-controllerDeviceInstance").val() || ""
          ).trim();
          var controllerName = (
            $("#node-input-controllerName").val() || ""
          ).trim();
          var objectName = ($("#node-input-objectName").val() || "").trim();
          var objectTypeValue = $("#node-input-objectType").val() || "";
          var objectInstanceValue = $("#node-input-objectInstance").val() || "";
          var objectId =
            objectTypeValue && objectInstanceValue
              ? toAbbrLocal(objectTypeValue) + "-" + objectInstanceValue
              : "";
          var labelMode = $("#node-input-labelMode").val() || "instance";
          var objectLabelMode =
            $("#node-input-objectLabelMode").val() || "name";
          var deviceLabel = buildDeviceLabel(
            labelMode,
            controllerName,
            controllerDeviceInstance
          );
          var objectLabel = buildObjectLabel(
            objectLabelMode,
            objectName,
            objectId
          );
          var computedLabel = "";

          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 || "");
            var middleDotSeparator = " " + String.fromCharCode(183) + " ";
            computedLabel = labelParts.join(middleDotSeparator);
          } else {
            var separator = " " + String.fromCharCode(183) + " ";
            if (objectLabel && deviceLabel)
              computedLabel = objectLabel + separator + deviceLabel;
            else if (objectLabel) computedLabel = objectLabel;
            else if (deviceLabel) computedLabel = deviceLabel;
            else computedLabel = objectId || objectName || "";
          }

          window.__bnPointLabelCache = window.__bnPointLabelCache || {};
          window.__bnPointLabelCache[nodeId] = computedLabel || "";
          RED.view.redraw(true);
          renderSelectedSummary();
        }

        function highlightCurrent() {
          var objectTypeValue = $("#node-input-objectType").val();
          var objectInstanceValue = $("#node-input-objectInstance").val();
          $(".nr-point-row").each(function () {
            var $rowElement = $(this);
            if (
              $rowElement.data("ot") == String(objectTypeValue) &&
              $rowElement.data("oi") == String(objectInstanceValue)
            ) {
              $rowElement.css("outline", "2px solid #4caf50");
            } else {
              $rowElement.css("outline", "none");
            }
          });
        }

        function markDeployNeeded(nodeInstanceToMark) {
          var nodeInstance = nodeInstanceToMark || RED.nodes.node(nodeId);
          if (!nodeInstance) return;
          nodeInstance.changed = true;
          try {
            RED.nodes.dirty(true);
          } catch (dirtyError) {}
          RED.view.redraw(true);
        }

        function renderFiltered(objectItems, queryText) {
          var $listContainer = $("#nr-point-list");
          $listContainer.empty();
          var objectArray = Array.isArray(objectItems) ? objectItems : [];
          var loweredQuery = (queryText || "").toLowerCase();

          if (loweredQuery) {
            objectArray = objectArray.filter(function (objectDescriptor) {
              var typeAbbreviation = toAbbrLocal(objectDescriptor.objectType);
              var composedAbbreviation =
                typeAbbreviation +
                "-" +
                String(objectDescriptor.objectInstance);
              var composedNumeric =
                String(objectDescriptor.objectType) +
                "-" +
                String(objectDescriptor.objectInstance);
              var searchBlob = [
                objectDescriptor.objectName || "",
                objectDescriptor.units || "",
                String(objectDescriptor.objectInstance || ""),
                String(objectDescriptor.objectType || ""),
                String(objectDescriptor.objectTypeName || ""),
                composedAbbreviation,
                composedNumeric,
                objectDescriptor.controllerName || "",
                objectDescriptor.controllerDeviceInstance || "",
              ]
                .join(" ")
                .toLowerCase();
              return searchBlob.includes(loweredQuery);
            });
          }

          if (!objectArray.length) {
            $listContainer.append(
              '<div style="padding:8px;color:#666">No objects</div>'
            );
            return;
          }

          objectArray.forEach(function (objectDescriptor) {
            var typeAbbreviation = toAbbrLocal(objectDescriptor.objectType);
            var composedObjectId =
              typeAbbreviation + "-" + String(objectDescriptor.objectInstance);

            var $rowElement = $(
              '<div class="nr-point-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>'
            );
            $rowElement
              .attr("data-ot", String(objectDescriptor.objectType))
              .attr("data-oi", String(objectDescriptor.objectInstance));
            $rowElement
              .data("ot", String(objectDescriptor.objectType))
              .data("oi", String(objectDescriptor.objectInstance));

            var $leftContainer = $('<div style="min-width:0"></div>');
            var titleText =
              (objectDescriptor.objectName || "") +
              (objectDescriptor.controllerName
                ? " " +
                  String.fromCharCode(183) +
                  " " +
                  objectDescriptor.controllerName
                : "");
            var $titleElement = $(
              '<div style="font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></div>'
            ).text(titleText);

            var presentValuePart =
              objectDescriptor.presentValue != null &&
              objectDescriptor.presentValue !== ""
                ? " " +
                  String.fromCharCode(183) +
                  " PV: " +
                  objectDescriptor.presentValue +
                  (objectDescriptor.units ? " " + objectDescriptor.units : "")
                : "";

            var $subtitleElement = $(
              '<div style="font-size:12px;color:#666"></div>'
            ).text(composedObjectId + presentValuePart);

            $leftContainer.append($titleElement).append($subtitleElement);
            $rowElement.append($leftContainer);

            $rowElement.on("click keypress", function (event) {
              if (event.type === "keypress" && event.key !== "Enter") return;

              $("#node-input-objectType")
                .val(objectDescriptor.objectType)
                .trigger("change");
              $("#node-input-objectInstance")
                .val(objectDescriptor.objectInstance)
                .trigger("change");
              $("#node-input-objectName")
                .val(objectDescriptor.objectName || "")
                .trigger("change");
              $("#node-input-description")
                .val(objectDescriptor.description || "")
                .trigger("change");
              $("#node-input-units")
                .val(objectDescriptor.units || "")
                .trigger("change");

              var currentNodeInstance = RED.nodes.node(nodeId);
              if (currentNodeInstance) {
                var beforeKey =
                  currentNodeInstance.objectType +
                  "|" +
                  currentNodeInstance.objectInstance;

                currentNodeInstance.objectType = objectDescriptor.objectType;
                currentNodeInstance.objectInstance =
                  objectDescriptor.objectInstance;
                currentNodeInstance.objectName =
                  objectDescriptor.objectName || "";
                currentNodeInstance.description =
                  objectDescriptor.description || "";
                currentNodeInstance.units = objectDescriptor.units || "";
                currentNodeInstance.autoLabel = true;
                currentNodeInstance.name = "";

                $("#node-input-controllerDeviceInstance")
                  .val(objectDescriptor.controllerDeviceInstance || "")
                  .trigger("change");
                currentNodeInstance.controllerDeviceInstance =
                  objectDescriptor.controllerDeviceInstance || "";
                currentNodeInstance.controllerName =
                  objectDescriptor.controllerName || "";

                var controllerNameCurrent = (
                  $("#node-input-controllerName").val() || ""
                ).trim();
                var controllerDeviceInstanceCurrent = (
                  $("#node-input-controllerDeviceInstance").val() || ""
                ).trim();
                var labelMode = $("#node-input-labelMode").val() || "instance";
                var objectLabelMode =
                  $("#node-input-objectLabelMode").val() || "name";
                var deviceLabel = buildDeviceLabel(
                  labelMode,
                  controllerNameCurrent,
                  controllerDeviceInstanceCurrent
                );
                var objectIdLocal =
                  toAbbrLocal(objectDescriptor.objectType) +
                  "-" +
                  String(objectDescriptor.objectInstance);
                var objectLabel = buildObjectLabel(
                  objectLabelMode,
                  currentNodeInstance.objectName,
                  objectIdLocal
                );
                var computedLabel;

                if (labelMode === "both") {
                  var labelParts = [];
                  if (objectLabel) labelParts.push(objectLabel);
                  if (controllerNameCurrent)
                    labelParts.push(controllerNameCurrent);
                  if (controllerDeviceInstanceCurrent)
                    labelParts.push(controllerDeviceInstanceCurrent);
                  if (!labelParts.length)
                    labelParts.push(
                      objectIdLocal || currentNodeInstance.objectName || ""
                    );
                  var middleDotSeparator = " " + String.fromCharCode(183) + " ";
                  computedLabel = labelParts.join(middleDotSeparator);
                } else {
                  var separator = " " + String.fromCharCode(183) + " ";
                  if (objectLabel && deviceLabel) {
                    computedLabel = objectLabel + separator + deviceLabel;
                  } else if (objectLabel) {
                    computedLabel = objectLabel;
                  } else if (deviceLabel) {
                    computedLabel = deviceLabel;
                  } else {
                    computedLabel =
                      objectIdLocal || currentNodeInstance.objectName || "";
                  }
                }

                window.__bnPointLabelCache = window.__bnPointLabelCache || {};
                window.__bnPointLabelCache[nodeId] = computedLabel || "";

                var afterKey =
                  currentNodeInstance.objectType +
                  "|" +
                  currentNodeInstance.objectInstance;
                if (beforeKey !== afterKey) {
                  markDeployNeeded(currentNodeInstance);
                } else {
                  RED.view.redraw(true);
                }
              }

              fillAdvancedFromSelection();
              renderSelectedSummary();
              highlightCurrent();

              setTimeout(function () {
                // Use bacnetHub service directly instead of making API calls
                var bacnetHubService = window.__bacnetHubService;
                var currentNode = RED.nodes.node ? RED.nodes.node(nodeId) : null;
                
                if (bacnetHubService && currentNode) {
                  var controllerGuid = String(currentNode.controller_guid || "");
                  var controllerSnapshot = controllerGuid
                    ? bacnetHubService.lookupControllerByGuid(controllerGuid)
                    : null;
                  
                  if (controllerSnapshot && controllerSnapshot.controller) {
                    var ctrl = controllerSnapshot.controller;
                    var controllerName = ctrl.deviceName || ctrl.modelName || "";
                    var controllerDeviceInstance = ctrl.deviceInstance != null 
                      ? String(ctrl.deviceInstance) 
                      : "";
                    
                    // Update form fields with latest controller info from snapshot
                    if (controllerName) {
                      $("#node-input-controllerName")
                        .val(controllerName)
                        .trigger("input");
                    }
                    if (controllerDeviceInstance) {
                      $("#node-input-controllerDeviceInstance")
                        .val(controllerDeviceInstance)
                        .trigger("input");
                      // Also sync device_instance to match controllerDeviceInstance
                      $("#node-input-device_instance")
                        .val(controllerDeviceInstance)
                        .trigger("input");
                    }
                    
                    // Get object info from snapshot if available
                    if (objectDescriptor.objectType != null && objectDescriptor.objectInstance != null && ctrl.objByKey) {
                      var numericType = Number(objectDescriptor.objectType);
                      var lookupKey = String(numericType) + "|" + String(objectDescriptor.objectInstance);
                      var objectEntry = ctrl.objByKey.get(lookupKey);
                      if (objectEntry && objectEntry.objectName) {
                        $("#node-input-objectName")
                          .val(objectEntry.objectName)
                          .trigger("input");
                      }
                    }
                    
                    setLabelCacheFromFields();
                  }
                }
              }, 10);
            });

            $listContainer.append($rowElement);
          });

          highlightCurrent();
        }

        function selectionExistsIn(objectArray) {
          var objectTypeValue = $("#node-input-objectType").val() || "";
          var objectInstanceValue = $("#node-input-objectInstance").val() || "";
          if (!objectTypeValue || !objectInstanceValue) return true;

          var mapAbbreviationToNumber = {
            AI: 0,
            AO: 1,
            AV: 2,
            BI: 3,
            BO: 4,
            BV: 5,
            PID: 12,
            MSV: 19,
          };

          function convertTypeToNumber(typeValue) {
            if (typeof typeValue === "number") return typeValue;
            var normalized = String(typeValue)
              .trim()
              .toUpperCase();
            if (/^\d+$/.test(normalized)) return +normalized;
            return mapAbbreviationToNumber[normalized] != null
              ? mapAbbreviationToNumber[normalized]
              : normalized;
          }

          var numericObjectType = convertTypeToNumber(objectTypeValue);

          for (var index = 0; index < objectArray.length; index++) {
            var descriptor = objectArray[index];
            if (
              String(descriptor.objectType) === String(numericObjectType) &&
              String(descriptor.objectInstance) === String(objectInstanceValue)
            ) {
              return true;
            }
          }
          return false;
        }

        function clearSelectionWithNotice() {
          $("#node-input-objectType").val("");
          $("#node-input-objectInstance").val("");
          $("#node-input-objectName").val("");
          $("#node-input-description").val("");
          $("#node-input-units").val("");
          $("#nr-point-selected").text(
            "This point no longer exists on the linked controller"
          );
        }

        function setLoading(isLoading) {
          isLoadingObjects = !!isLoading;
          $("#nr-point-loading").css(
            "display",
            isLoadingObjects ? "flex" : "none"
          );
        }

        function primeControllerMeta(nextCallback) {
          // Use bacnetHub service directly instead of making API calls
          var bacnetHubService = window.__bacnetHubService;
          var currentNode = RED.nodes.node ? RED.nodes.node(nodeId) : null;
          
          if (bacnetHubService && currentNode) {
            var controllerGuid = String(currentNode.controller_guid || "");
            var controllerSnapshot = controllerGuid
              ? bacnetHubService.lookupControllerByGuid(controllerGuid)
              : null;
            
            if (controllerSnapshot && controllerSnapshot.controller) {
              var ctrl = controllerSnapshot.controller;
              var controllerName = ctrl.deviceName || ctrl.modelName || "";
              var controllerDeviceInstance = ctrl.deviceInstance != null 
                ? String(ctrl.deviceInstance) 
                : "";
              
              if (controllerName) {
                $("#node-input-controllerName").val(controllerName);
              }
              if (controllerDeviceInstance) {
                $("#node-input-controllerDeviceInstance").val(controllerDeviceInstance);
                // Also sync device_instance to match controllerDeviceInstance
                $("#node-input-device_instance").val(controllerDeviceInstance);
              }
            }
          }
          
          renderSelectedSummary();
          updateOcnTrace();
          nextCallback();
        }

        function loadFromApi() {
          if (isLoadingObjects) return;
          setLoading(true);

          var requestedDeviceInstance = (
            $("#node-input-controllerDeviceInstance").val() || ""
          ).trim();

          if (
            editContext.__pendingObjectsRequest &&
            typeof editContext.__pendingObjectsRequest.abort === "function"
          ) {
            try {
              editContext.__pendingObjectsRequest.abort();
            } catch (_ignoredError) {}
          }

          editContext.__pendingObjectsRequest = $.ajax({
            url: "bacnet-point-in/objects",
            method: "GET",
            cache: false,
            data: { nodeId: nodeId, deviceInstance: requestedDeviceInstance },
            dataType: "text",
            timeout: 10000,
          })
            .done(function (_unusedUrl, _unusedStatus, xhrResponse) {
              var rawText =
                xhrResponse && typeof xhrResponse.responseText === "string"
                  ? xhrResponse.responseText
                  : "[]";
              try {
                rawText = rawText.replace(/^\)\]\}',?\s*/, "");
              } catch (stripError) {}
              var parsedArray = [];
              try {
                parsedArray = JSON.parse(rawText);
              } catch (parseError) {}
              lastLoadedObjects = Array.isArray(parsedArray) ? parsedArray : [];
              renderFiltered(lastLoadedObjects, $("#nr-point-search").val());
              if (
                lastLoadedObjects.length > 0 &&
                !selectionExistsIn(lastLoadedObjects)
              ) {
                clearSelectionWithNotice();
              }
            })
            .fail(function (xhrError) {
              function parseErrorMessage(x) {
                try {
                  if (x && typeof x.responseText === "string") {
                    var text = x.responseText.replace(/^\)\]\}',?\s*/, "");
                    var json = JSON.parse(text);
                    if (json && json.error) return String(json.error);
                  }
                } catch (parseError) {}
                return "HTTP " + (x && x.status ? x.status : "");
              }

              $("#nr-point-tip").text(parseErrorMessage(xhrError)).show();
              $("#nr-point-list").empty().html("No controller info received.");
            })
            .always(function () {
              setLoading(false);
              editContext.__pendingObjectsRequest = null;
            });
        }

        function debounce(fn, delayMs) {
          var timeoutId;
          return function () {
            var context = this;
            var args = arguments;
            clearTimeout(timeoutId);
            timeoutId = setTimeout(function () {
              fn.apply(context, args);
            }, delayMs);
          };
        }

        $("#nr-point-search").on(
          "input",
          debounce(function () {
            renderFiltered(lastLoadedObjects, this.value);
          }, 120)
        );

        $("#node-input-name").on("input", function () {
          var currentNodeInstance = RED.nodes.node(nodeId);
          if (currentNodeInstance) {
            currentNodeInstance.name = this.value;
            currentNodeInstance.autoLabel = this.value.trim() === "";
            RED.view.redraw(true);
          }
        });

        (function hydrateFromInstance() {
          if (!editorNodeInstance) return;

          $("#node-input-controllerName").val(
            editorNodeInstance.controllerName || ""
          );
          $("#node-input-controllerDeviceInstance").val(
            editorNodeInstance.controllerDeviceInstance || ""
          );
          $("#node-input-objectType").val(
            editorNodeInstance.objectType != null
              ? editorNodeInstance.objectType
              : ""
          );
          $("#node-input-objectInstance").val(
            editorNodeInstance.objectInstance != null
              ? editorNodeInstance.objectInstance
              : ""
          );
          $("#node-input-objectName").val(editorNodeInstance.objectName || "");
          $("#node-input-description").val(
            editorNodeInstance.description || ""
          );
          $("#node-input-units").val(editorNodeInstance.units || "");
          $("#node-input-labelMode").val(
            editorNodeInstance.labelMode || "instance"
          );
          $("#node-input-objectLabelMode").val(
            editorNodeInstance.objectLabelMode || "name"
          );
          $("#node-input-advancedMode").prop(
            "checked",
            !!editorNodeInstance.advancedMode
          );
          $("#node-input-device_instance").val(
            editorNodeInstance.device_instance || ""
          );
          $("#node-input-object_id").val(editorNodeInstance.object_id || "");
          $("#node-input-priority").val(editorNodeInstance.priority || "9");
          $("#node-input-send_policy").val(
            editorNodeInstance.send_policy || "On value changed"
          );
          $("#node-input-output_type").val(
            editorNodeInstance.output_type || "ip"
          );
          $("#node-input-bypass_priority").val(
            editorNodeInstance.bypass_priority ? "true" : ""
          );

          renderSelectedSummary();
          toggleModeUI();
          updateOcnTrace();
          initAdvancedControls();
        })();

        primeControllerMeta(function () {
          setTimeout(loadFromApi, 50);
          setTimeout(function () {
            if (
              editorNodeInstance &&
              editorNodeInstance.objectType != null &&
              editorNodeInstance.objectInstance != null
            ) {
              // Use bacnetHub service directly instead of making API calls
              var bacnetHubService = window.__bacnetHubService;
              var currentNode = RED.nodes.node ? RED.nodes.node(nodeId) : null;
              
              if (bacnetHubService && currentNode) {
                var controllerGuid = String(currentNode.controller_guid || "");
                var controllerSnapshot = controllerGuid
                  ? bacnetHubService.lookupControllerByGuid(controllerGuid)
                  : null;
                
                if (controllerSnapshot && controllerSnapshot.controller) {
                  var ctrl = controllerSnapshot.controller;
                  var controllerName = ctrl.deviceName || ctrl.modelName || "";
                  var controllerDeviceInstance = ctrl.deviceInstance != null 
                    ? String(ctrl.deviceInstance) 
                    : "";
                  
                  // Update form fields with latest controller info from snapshot
                  if (controllerName) {
                    $("#node-input-controllerName").val(controllerName);
                  }
                  if (controllerDeviceInstance) {
                    $("#node-input-controllerDeviceInstance").val(controllerDeviceInstance);
                    // Also sync device_instance to match controllerDeviceInstance
                    $("#node-input-device_instance").val(controllerDeviceInstance);
                  }
                  
                  // Get object info from snapshot if available
                  if (editorNodeInstance.objectType != null && editorNodeInstance.objectInstance != null && ctrl.objByKey) {
                    var numericType = Number(editorNodeInstance.objectType);
                    var lookupKey = String(numericType) + "|" + String(editorNodeInstance.objectInstance);
                    var objectEntry = ctrl.objByKey.get(lookupKey);
                    if (objectEntry && objectEntry.objectName) {
                      $("#node-input-objectName").val(objectEntry.objectName);
                    }
                  }
                  
                  renderSelectedSummary();
                }
              }
            }
          }, 120);
        });

      },

      oneditsave: function () {
        try {
          // Sync device_instance with controllerDeviceInstance before save
          // This ensures they stay in sync when controller changes
          var controllerDeviceInstanceValue = (
            $("#node-input-controllerDeviceInstance").val() || ""
          ).trim();
          if (controllerDeviceInstanceValue) {
            var currentDeviceInstance = (
              $("#node-input-device_instance").val() || ""
            ).trim();
            // If controllerDeviceInstance is set and differs from device_instance, sync them
            if (currentDeviceInstance !== controllerDeviceInstanceValue) {
              $("#node-input-device_instance").val(controllerDeviceInstanceValue);
            }
          }
          
          if (
            $("#node-input-advancedMode").is(":checked") &&
            window.localStorage
          ) {
            var deviceInstanceValue = (
              $("#node-input-device_instance").val() || ""
            ).trim();
            var objectIdValue = ($("#node-input-object_id").val() || "").trim();
            if (deviceInstanceValue) {
              localStorage.setItem(
                "default_device_instance",
                deviceInstanceValue
              );
            }
            if (objectIdValue) {
              localStorage.setItem("default_in_obj_id", objectIdValue);
            }
          }
        } catch (storageError) {}
      },
    });
  })();

  if (RED && RED.palette && RED.palette.hide) {
    RED.palette.hide('bacnet-point');
    RED.palette.hide('bacnet-point-in');
  }
</script>

<script type="text/html" data-template-name="bacnet-point-in">
  <div class="form-row"><ul id="bp-tabs" class="red-ui-tabs"></ul></div>
  <div id="bp-tabs-content">
    <div id="bp-tab-props" style="display:none">
      <div id="bp-props-modern">
        <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" style="margin-top:10px;">
          <span
            id="nr-point-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="margin:0;line-height:32px;">Objects</label>
          </div>
          <div
            id="nr-point-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-point-search"
            placeholder="Search name/type/instance"
            style="width:100%;margin:6px 0 8px 0;padding:6px 8px;border:1px solid #ddd;border-radius:6px"
          />
          <div id="nr-point-list-wrap" style="position:relative;">
            <div
              id="nr-point-list"
              style="height:240px;overflow:auto;border:1px solid #ddd;border-radius:8px;padding:6px"
            ></div>
            <div
              id="nr-point-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-objectType" />
        <input type="hidden" id="node-input-objectInstance" />
        <input type="hidden" id="node-input-objectName" />
        <input type="hidden" id="node-input-description" />
        <input type="hidden" id="node-input-units" />
        <input type="hidden" id="node-input-controllerName" />
        <input type="hidden" id="node-input-controllerDeviceInstance" />

        <div class="form-row" style="margin-top:20px;">
          <label for="node-input-source-simple"
            ><i class="fa fa-database"></i> Data Source</label
          >
          <select id="node-input-source-simple" style="width:70%;">
            <option value="mqtt">MQTT (Recommended)</option>
            <option value="api">API (HTTPS)</option>
          </select>
        </div>
      </div>

      <div id="bp-props-legacy" style="display:none">
        <div class="form-tips" style="margin-bottom:20px;">
          <b>Connection Settings</b>
        </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-tips" style="margin:20px 0;">
          <b>Device Settings</b>
        </div>
        <div style="padding-left:10px;border-left:3px solid #ddd;">
          <div class="form-row">
            <label for="node-input-device_instance"
              ><i class="fa fa-microchip"></i> Device Instance</label
            >
            <input
              type="text"
              id="node-input-device_instance"
              placeholder="e.g., 1234"
            />
          </div>
          <div class="form-row">
            <label for="node-input-object_id"
              ><i class="fa fa-cube"></i> Object ID</label
            >
            <input
              type="text"
              id="node-input-object_id"
              placeholder="e.g., AV-1"
            />
          </div>
        </div>
        <div class="form-tips" style="margin:20px 0;">
          <b>Communication Settings</b>
        </div>
        <div style="padding-left:10px;border-left:3px solid #ddd;">
          <div class="form-row">
            <label for="node-input-source"
              ><i class="fa fa-database"></i> Data Source</label
            >
            <select id="node-input-source" style="width:70%;">
              <option value="mqtt">MQTT (Recommended)</option>
              <option value="api">API (HTTPS)</option>
            </select>
          </div>
          <div class="form-row">
            <label for="node-input-topic"
              ><i class="fa fa-tags"></i> Topic</label
            >
            <input type="text" id="node-input-topic" />
          </div>
        </div>
        <div class="form-tips" style="margin-top:20px;">
          <p>
            <i class="fa fa-info-circle"></i> Tip: Device instance and object ID
            settings are saved as defaults for future nodes.
          </p>
        </div>
        <input type="hidden" id="node-input-priority" />
        <input type="hidden" id="node-input-send_policy" />
        <input type="hidden" id="node-input-output_type" />
        <input type="hidden" id="node-input-bypass_priority" />
      </div>
    </div>

    <div id="bp-tab-options" style="display:none">
      <div class="form-row">
        <label for="node-input-labelMode"
          ><i class="fa fa-tag"></i> Device Label</label
        >
        <select id="node-input-labelMode" style="width:70%">
          <option value="instance">Use Device Instance</option>
          <option value="device">Use Device Name</option>
          <option value="both">Use Device Name + Instance</option>
        </select>
      </div>
      <div class="form-row">
        <label for="node-input-objectLabelMode"
          ><i class="fa fa-tag"></i> Object Label</label
        >
        <select id="node-input-objectLabelMode" style="width:70%">
          <option value="name">Use Object Name</option>
          <option value="id">Use Object ID</option>
          <option value="both">Use Object Name + ID</option>
        </select>
      </div>
      <div class="form-row">
        <label><i class="fa fa-server"></i> OCN ID</label>
        <span
          id="bp-current-ocn-id"
          style="display:inline-block;width:70%;padding:6px 0;color:#555;word-break:break-all;"
          >None selected</span
        >
      </div>
      <div class="form-row">
        <label for="node-input-advancedMode"
          ><i class="fa fa-cogs"></i> Advanced mode</label
        >
        <input
          type="checkbox"
          id="node-input-advancedMode"
          style="width:auto;margin-top:0"
        />
      </div>
    </div>
  </div>
</script>

<script type="text/html" data-help-name="bacnet-point-in">
  <p>BACnet point node.</p>
</script>