UNPKG

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

Version:
1,874 lines 71.7 kB
<!-- point-out.html -->
<script type="text/javascript">
  var buildDeviceDisplayLabel =
    window.__bnPointBuildDeviceLabel ||
    function (labelMode, deviceName, deviceInstance) {
      var cleanDeviceName = String(deviceName || "").trim();
      var cleanDeviceInstance = String(deviceInstance || "").trim();
      if (labelMode === "device") return cleanDeviceName || cleanDeviceInstance;
      if (labelMode === "both") {
        var middleDot = " " + String.fromCharCode(183) + " ";
        if (cleanDeviceName && cleanDeviceInstance) {
          return cleanDeviceName + middleDot + cleanDeviceInstance;
        }
        return cleanDeviceName || cleanDeviceInstance;
      }
      return cleanDeviceInstance || cleanDeviceName;
    };

  var buildObjectDisplayLabel =
    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;
    };
  window.__bnPointBuildObjectLabel = buildObjectDisplayLabel;

  RED.nodes.registerType("bacnet-point-out", {
    color: "#f3b567",
    defaults: {
      name: { value: "", label: "Name" },
      ocn_server_node: { type: "ocn-server", required: false },
      mode: { value: "list", required: true, label: "Mode" },
      advancedMode: { value: false },
      allow_msg_override: { value: false },
      acknowledged_mqtt: { value: true },
      output_type: { value: "ip", required: true, label: "Output Type" },
      topic: { value: "default", required: true, label: "Topic" },
      objectType: { value: "" },
      objectInstance: { value: "" },
      object_id: { value: "" },
      objectName: { value: "" },
      description: { value: "" },
      units: { value: "" },
      deviceInstance: { value: "" },
      deviceName: { value: "" },
      networkNumber: { value: "" },
      priority: { value: "9" },
      bypass_priority: { value: false },
      send_policy: { value: "Always" },
      autoLabel: { value: true },
      labelMode: { value: "instance" },
      objectLabelMode: { value: "name" },
      controller_guid: { value: "" },
    },
    inputs: 1,
    outputs: 2,
    align: "right",
    inputLabels: function () {
      return "Value to write (msg.payload)";
    },
    icon: "font-awesome/fa-arrow-circle-right",
    paletteLabel: function () {
      return "BACnet Point Out";
    },
    label: function () {
      if (this.name && String(this.name).trim()) return this.name;
      
      if (this.previewLabel) {
        return this.previewLabel;
      }
      
      var cache =
        (window.__bnPointLabelCache && window.__bnPointLabelCache[this.id]) ||
        "";
      if (cache) {
        return cache;
      }

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

      var objectName = (this.objectName || "").trim();
      var labelMode = this.labelMode || "instance";
      var objectLabelMode = this.objectLabelMode || "name";
      var deviceLabel = buildDeviceDisplayLabel(
        labelMode,
        this.deviceName,
        this.deviceInstance
      );
      var hasObjectType =
        this.objectType != null && String(this.objectType) !== "";
      var hasObjectInstance =
        this.objectInstance != null && String(this.objectInstance) !== "";
      var objectId =
        hasObjectType && hasObjectInstance
          ? convertTypeToAbbrLocal(this.objectType) + "-" + this.objectInstance
          : "";
      var objectLabel = buildObjectDisplayLabel(
        objectLabelMode,
        objectName,
        objectId
      );

      if (this.autoLabel || !this.name) {
        var finalLabel = "BACnet Point Out";
        if (labelMode === "both") {
          var parts = [];
          if (objectLabel) parts.push(objectLabel);
          if (this.deviceName) parts.push(this.deviceName);
          if (this.deviceInstance) parts.push(this.deviceInstance);
          if (!parts.length) parts.push(objectId || objectName || "");
          var middleDot = " " + String.fromCharCode(183) + " ";
          finalLabel = parts.length
            ? parts.join(middleDot)
            : objectId || objectName || deviceLabel || "BACnet Point Out";
        } else {
          var separator = " " + String.fromCharCode(183) + " ";
          if (objectLabel && deviceLabel) finalLabel = objectLabel + separator + deviceLabel;
          else if (objectLabel) finalLabel = objectLabel;
          else if (deviceLabel) finalLabel = deviceLabel;
          else if (objectId) finalLabel = objectId;
        }
        
        return finalLabel;
      }
      return this.name;
    },
    outputLabels: function (outputIndex) {
      return outputIndex === 0 ? "Requested write value" : "Actual present value";
    },
    oneditprepare: function () {
      var nodeId = this.id;
      var nodeInstance = RED.nodes.node(nodeId);
      window.__bnPointLabelCache = window.__bnPointLabelCache || {};
      this._savedState = nodeInstance
        ? {
            labelMode: nodeInstance.labelMode || "instance",
            objectLabelMode: nodeInstance.objectLabelMode || "name",
            hadPreviewLabel: Object.prototype.hasOwnProperty.call(
              nodeInstance,
              "previewLabel"
            ),
            previewLabel: nodeInstance.previewLabel,
            hadLabelCache: Object.prototype.hasOwnProperty.call(
              window.__bnPointLabelCache,
              nodeId
            ),
            labelCache: window.__bnPointLabelCache[nodeId],
          }
        : null;
      var lastDiscoveredObjects = [];
      var isLoadingObjects = false;

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

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

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

      function getFormControllerState() {
        return {
          di: ($("#node-input-deviceInstance").val() || "").trim(),
          dn: ($("#node-input-deviceName").val() || "").trim(),
        };
      }

      function renderSelectedPointPreview() {
        var controllerState = getFormControllerState();
        var objectTypeValue = $("#node-input-objectType").val() || "";
        var objectInstanceValue = $("#node-input-objectInstance").val() || "";
        var objectNameValue = ($("#node-input-objectName").val() || "").trim();
        var objectId =
          objectTypeValue && objectInstanceValue
            ? convertObjectTypeToAbbr(objectTypeValue) +
              "-" +
              objectInstanceValue
            : "";
        var previewParts = [];
        if (controllerState.di) previewParts.push(controllerState.di);
        if (controllerState.dn) previewParts.push(controllerState.dn);
        if (objectId) previewParts.push(objectId);
        else if (objectInstanceValue) previewParts.push(objectInstanceValue);
        if (objectNameValue) previewParts.push(objectNameValue);
        var separator = " " + String.fromCharCode(183) + " ";
        var previewSummary = previewParts.join(separator);
        $("#nr-point-selected").text(previewSummary || "None selected");
        highlightCurrentSelection();
      }

      function updatePreviewLabelFromFields() {
        var currentNodeInstance = RED.nodes.node(nodeId);
        if (!currentNodeInstance) return;

        var labelMode = $("#node-input-labelMode").val() || "instance";
        var objectLabelMode = $("#node-input-objectLabelMode").val() || "name";
        var objectNameValue = ($("#node-input-objectName").val() || "").trim();
        var controllerState = getFormControllerState();
        var objectTypeValue = $("#node-input-objectType").val() || "";
        var objectInstanceValue = $("#node-input-objectInstance").val() || "";
        var objectId =
          objectTypeValue && objectInstanceValue
            ? convertObjectTypeToAbbr(objectTypeValue) +
              "-" +
              objectInstanceValue
            : "";
        var deviceLabel = buildDeviceDisplayLabel(
          labelMode,
          controllerState.dn,
          controllerState.di
        );
        var objectLabel = buildObjectDisplayLabel(
          objectLabelMode,
          objectNameValue,
          objectId
        );

        currentNodeInstance.labelMode = labelMode;
        currentNodeInstance.objectLabelMode = objectLabelMode;

        var previewLabel;
        if (labelMode === "both") {
          var labelParts = [];
          if (objectLabel) labelParts.push(objectLabel);
          if (controllerState.dn) labelParts.push(controllerState.dn);
          if (controllerState.di) labelParts.push(controllerState.di);
          if (!labelParts.length)
            labelParts.push(objectId || objectNameValue || "");
          var middleDot = " " + String.fromCharCode(183) + " ";
          previewLabel = labelParts.length
            ? labelParts.join(middleDot)
            : deviceLabel || objectId || "";
        } else {
          var separator = " " + String.fromCharCode(183) + " ";
          if (objectLabel && deviceLabel) {
            previewLabel = objectLabel + separator + deviceLabel;
          } else if (objectLabel) {
            previewLabel = objectLabel;
          } else if (deviceLabel) {
            previewLabel = deviceLabel;
          } else {
            previewLabel = objectId || objectNameValue || "";
          }
        }

        currentNodeInstance.previewLabel = previewLabel;
        window.__bnPointLabelCache = window.__bnPointLabelCache || {};
        window.__bnPointLabelCache[nodeId] =
          currentNodeInstance.previewLabel || "";
        RED.view.redraw(true);
        highlightCurrentSelection();
      }

      function parseObjectIdString(objectIdString) {
        var normalized = String(objectIdString || "")
          .trim()
          .toUpperCase();
        var match = normalized.match(/^([A-Z]+)\-([0-9]+)$/);
        if (!match) return null;
        return {
          type: convertObjectTypeToNumeric(match[1]),
          instance: Number(match[2]),
        };
      }

      function syncAdvancedFromSelection() {
        var hiddenDeviceInstance = (
          $("#node-input-deviceInstance").val() || ""
        ).trim();
        var hiddenObjectType = ($("#node-input-objectType").val() || "").trim();
        var hiddenObjectInstance = (
          $("#node-input-objectInstance").val() || ""
        ).trim();

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

        if ($("#node-input-object_id").length) {
          if (hiddenObjectType && hiddenObjectInstance) {
            var objectId = (
              convertObjectTypeToAbbr(hiddenObjectType) +
              "-" +
              hiddenObjectInstance
            ).toUpperCase();
            $("#node-input-object_id").val(objectId);
            if (nodeInstance) nodeInstance.object_id = objectId;
          }
        }

        if (nodeInstance) {
          nodeInstance.device_instance = hiddenDeviceInstance;
          nodeInstance.deviceInstance = hiddenDeviceInstance;
        }
      }

      function syncHiddenFieldsFromAdvanced(force) {
        var isAdvanced = $("#node-input-advancedMode").is(":checked");
        if (!force && !isAdvanced) return;

        var advancedDeviceInstance = (
          $("#node-input-device_instance").val() || ""
        ).trim();
        $("#node-input-deviceInstance")
          .val(advancedDeviceInstance)
          .trigger("change");
        $("#node-input-controllerDeviceInstance").val(advancedDeviceInstance);

        var instanceRef = nodeInstance || RED.nodes.node(nodeId);
        if (instanceRef) {
          instanceRef.deviceInstance = advancedDeviceInstance;
          instanceRef.controllerDeviceInstance = advancedDeviceInstance;
          instanceRef.device_instance = advancedDeviceInstance;
        }

        var objectId = ($("#node-input-object_id").val() || "")
          .trim()
          .toUpperCase();
        $("#node-input-object_id").val(objectId);

        var parsedObjectId = parseObjectIdString(objectId);
        if (parsedObjectId) {
          $("#node-input-objectType")
            .val(parsedObjectId.type)
            .trigger("change");
          $("#node-input-objectInstance")
            .val(parsedObjectId.instance)
            .trigger("change");

          if (instanceRef) {
            instanceRef.objectType = parsedObjectId.type;
            instanceRef.objectInstance = parsedObjectId.instance;
            instanceRef.object_id = objectId;
          }
        }

        renderSelectedPointPreview();
        highlightCurrentSelection();
      }

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

        syncAdvancedFromSelection();

        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_out_obj_id") || ""
            );
          }
        }

        syncHiddenFieldsFromAdvanced(true);
      }

      function hydrateAdvancedFromNodeInstance() {
        if (!nodeInstance) return;

        var advancedState =
          !!nodeInstance.advancedMode || nodeInstance.mode === "advanced";

        $("#node-input-advancedMode").prop("checked", advancedState);
        $("#node-input-mode").val(advancedState ? "advanced" : "list");
        nodeInstance.advancedMode = advancedState;
        nodeInstance.mode = advancedState ? "advanced" : "list";

        $("#node-input-device_instance").val(
          nodeInstance.device_instance || nodeInstance.deviceInstance || ""
        );

        var baseObjectId = nodeInstance.object_id;
        if (
          !baseObjectId &&
          nodeInstance.objectType != null &&
          nodeInstance.objectInstance != null
        ) {
          baseObjectId = (
            convertObjectTypeToAbbr(nodeInstance.objectType) +
            "-" +
            nodeInstance.objectInstance
          ).toUpperCase();
        }
        $("#node-input-object_id").val(baseObjectId || "");

        if ($("#node-input-priority").data("typedInput")) {
          $("#node-input-priority").typedInput(
            "value",
            nodeInstance.priority || "9"
          );
        } else {
          $("#node-input-priority").val(nodeInstance.priority || "9");
        }

        if ($("#node-input-send_policy").data("typedInput")) {
          $("#node-input-send_policy").typedInput(
            "value",
            nodeInstance.send_policy || "Always"
          );
        } else {
          $("#node-input-send_policy").val(
            nodeInstance.send_policy || "Always"
          );
        }

        $("#node-input-output_type").val(nodeInstance.output_type || "ip");
        $("#node-input-acknowledged_mqtt, #node-input-acknowledged_mqtt-simple").prop(
          "checked",
          !!nodeInstance.acknowledged_mqtt
        );
        $("#node-input-labelMode").val(nodeInstance.labelMode || "instance");
        $("#node-input-objectLabelMode").val(
          nodeInstance.objectLabelMode || "name"
        );
        $("#node-input-bypass_priority").prop(
          "checked",
          !!nodeInstance.bypass_priority
        );
        $("#node-input-allow_msg_override").prop(
          "checked",
          !!nodeInstance.allow_msg_override
        );

        if (nodeInstance.ocn_server_node) {
          $("#node-input-ocn_server_node").val(
            typeof nodeInstance.ocn_server_node === "object" &&
              nodeInstance.ocn_server_node.id
              ? nodeInstance.ocn_server_node.id
              : nodeInstance.ocn_server_node
          );
        }

        syncAdvancedFromSelection();
        syncHiddenFieldsFromAdvanced(true);
        updateOcnServerTrace();
      }

      function getConfigNodeId(configValue) {
        if (configValue == null) return "";
        if (typeof configValue === "string") return configValue;
        if (typeof configValue === "object") {
          if (typeof configValue.id === "string") return configValue.id;
          if (typeof configValue.value === "string") return configValue.value;
        }
        return "";
      }

      function updateOcnServerTrace() {
        var ocnConfigId = getConfigNodeId(
          $("#node-input-ocn_server_node").val()
        );
        if (!ocnConfigId && nodeInstance) {
          ocnConfigId = getConfigNodeId(nodeInstance.ocn_server_node);
        }
        $("#bpo-current-ocn-id").text(
          ocnConfigId ? ocnConfigId : "None selected"
        );
      }

      function highlightCurrentSelection() {
        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(convertObjectTypeToNumeric(objectTypeValue)) &&
            rowElement.data("oi") == String(objectInstanceValue)
          ) {
            rowElement.css("outline", "2px solid #4caf50");
          } else {
            rowElement.css("outline", "none");
          }
        });
      }

      function renderFilteredObjectList(objectList, searchQuery) {
        var container = $("#nr-point-list");
        container.empty();

        var objectsArray = Array.isArray(objectList) ? objectList.slice() : [];
        var filterText = (searchQuery || "").toLowerCase();
        var controllerState = getFormControllerState();

        if (filterText) {
          objectsArray = objectsArray.filter(function (objectInfo) {
            var abbr = convertObjectTypeToAbbr(objectInfo.objectType);
            var composedAbbr = abbr + "-" + String(objectInfo.objectInstance);
            var composedNumeric =
              String(objectInfo.objectType) +
              "-" +
              String(objectInfo.objectInstance);
            var searchableText = [
              objectInfo.controllerDeviceInstance || controllerState.di || "",
              objectInfo.controllerName || controllerState.dn || "",
              composedAbbr,
              composedNumeric,
              objectInfo.objectName || "",
              objectInfo.units || "",
              String(objectInfo.objectInstance || ""),
              String(objectInfo.objectType || ""),
              String(objectInfo.objectTypeName || ""),
            ]
              .join(" ")
              .toLowerCase();
            return searchableText.includes(filterText);
          });
        }

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

        // Sort so AI objects (type 0) appear at the end
        objectsArray.sort(function (a, b) {
          var aType = convertObjectTypeToNumeric(a.objectType);
          var bType = convertObjectTypeToNumeric(b.objectType);
          var aIsAI = aType === 0;
          var bIsAI = bType === 0;
          if (aIsAI && !bIsAI) return 1;
          if (!aIsAI && bIsAI) return -1;
          return 0;
        });

        objectsArray.forEach(function (objectInfo) {
          var abbr = convertObjectTypeToAbbr(objectInfo.objectType);
          var objectId = abbr + "-" + String(objectInfo.objectInstance);
          var deviceInstance =
            objectInfo.controllerDeviceInstance != null &&
            objectInfo.controllerDeviceInstance !== ""
              ? String(objectInfo.controllerDeviceInstance)
              : controllerState.di;
          var deviceName =
            objectInfo.controllerName != null &&
            objectInfo.controllerName !== ""
              ? String(objectInfo.controllerName)
              : controllerState.dn;

          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(objectInfo.objectType))
            .attr("data-oi", String(objectInfo.objectInstance));
          rowElement
            .data("ot", String(objectInfo.objectType))
            .data("oi", String(objectInfo.objectInstance));

          var leftContent = $('<div style="min-width:0"></div>');
          var middleDot = " " + String.fromCharCode(183) + " ";

          var titleParts = [];
          if (objectInfo.objectName) titleParts.push(objectInfo.objectName);
          if (deviceName) titleParts.push(deviceName);

          var titleElement = $(
            '<div style="font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></div>'
          ).text(titleParts.join(middleDot));

          var subtitleParts = [objectId];
          if (deviceInstance) subtitleParts.push(deviceInstance);
          if (
            objectInfo.presentValue != null &&
            objectInfo.presentValue !== ""
          ) {
            var pvText =
              "PV: " +
              objectInfo.presentValue +
              (objectInfo.units ? " " + objectInfo.units : "");
            subtitleParts.push(pvText);
          }

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

          leftContent.append(titleElement).append(subtitleElement);
          rowElement.append(leftContent);

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

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

            if (deviceInstance) {
              $("#node-input-deviceInstance")
                .val(deviceInstance)
                .trigger("input");
            }
            if (deviceName) {
              $("#node-input-deviceName").val(deviceName).trigger("input");
            }

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

              selectedNodeInstance.objectType = objectInfo.objectType;
              selectedNodeInstance.objectInstance = objectInfo.objectInstance;
              selectedNodeInstance.objectName = objectInfo.objectName || "";
              selectedNodeInstance.description = objectInfo.description || "";
              selectedNodeInstance.units = objectInfo.units || "";
              selectedNodeInstance.deviceInstance = deviceInstance || "";
              selectedNodeInstance.deviceName = deviceName || "";
              selectedNodeInstance.autoLabel = true;
              selectedNodeInstance.name = "";

              var labelMode = $("#node-input-labelMode").val() || "instance";
              var objectLabelMode =
                $("#node-input-objectLabelMode").val() || "name";
              selectedNodeInstance.labelMode = labelMode;
              selectedNodeInstance.objectLabelMode = objectLabelMode;

              var deviceLabel = buildDeviceDisplayLabel(
                labelMode,
                deviceName,
                deviceInstance
              );
              var objectIdForPreview = abbr + "-" + objectInfo.objectInstance;
              var objectLabel = buildObjectDisplayLabel(
                objectLabelMode,
                selectedNodeInstance.objectName,
                objectIdForPreview
              );

              var previewLabel;
              if (labelMode === "both") {
                var labelParts = [];
                if (objectLabel) labelParts.push(objectLabel);
                if (deviceName) labelParts.push(deviceName);
                if (deviceInstance) labelParts.push(deviceInstance);
                if (!labelParts.length) {
                  labelParts.push(
                    objectIdForPreview || selectedNodeInstance.objectName || ""
                  );
                }
                var middleDotLabel = " " + String.fromCharCode(183) + " ";
                previewLabel = labelParts.length
                  ? labelParts.join(middleDotLabel)
                  : deviceLabel || objectIdForPreview || "";
              } else {
                var separator = " " + String.fromCharCode(183) + " ";
                if (objectLabel && deviceLabel) {
                  previewLabel = objectLabel + separator + deviceLabel;
                } else if (objectLabel) {
                  previewLabel = objectLabel;
                } else if (deviceLabel) {
                  previewLabel = deviceLabel;
                } else {
                  previewLabel =
                    objectIdForPreview || selectedNodeInstance.objectName || "";
                }
              }

              selectedNodeInstance.previewLabel = previewLabel;
              var afterKey =
                selectedNodeInstance.objectType +
                "|" +
                selectedNodeInstance.objectInstance;

              if (beforeKey !== afterKey) {
                selectedNodeInstance.changed = true;
                RED.nodes.dirty(true);
              }

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

            syncAdvancedFromSelection();
            syncHiddenFieldsFromAdvanced(true);
            updateOcnServerTrace();
            renderSelectedPointPreview();

            // 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-deviceName")
                    .val(controllerName)
                    .trigger("input");
                }
                if (controllerDeviceInstance) {
                  $("#node-input-deviceInstance")
                    .val(controllerDeviceInstance)
                    .trigger("input");
                }
                
                // Get object info from snapshot if available
                if (objectInfo.objectType != null && objectInfo.objectInstance != null && ctrl.objByKey) {
                  var numericType = Number(objectInfo.objectType);
                  var lookupKey = String(numericType) + "|" + String(objectInfo.objectInstance);
                  var objectEntry = ctrl.objByKey.get(lookupKey);
                  if (objectEntry && objectEntry.objectName) {
                    $("#node-input-objectName")
                      .val(objectEntry.objectName)
                      .trigger("input");
                  }
                }
                
                updatePreviewLabelFromFields();
              }
            }
          });

          container.append(rowElement);
        });

        highlightCurrentSelection();
      }

      function loadObjectsFromApi() {
        if (isLoadingObjects) return;
        isLoadingObjects = true;

        $("#nr-point-loading").css("display", "flex");

        $.ajax({
          url: "bacnet-point-out/objects",
          method: "GET",
          cache: false,
          data: { nodeId: nodeId },
          dataType: "text",
        })
          .done(function (_unused, _status, xhr) {
            var responseText = xhr.responseText || "[]";
            try {
              responseText = responseText.replace(/^\)\]\}',?\s*/, "");
            } catch (e) {}
            var parsedArray = [];
            try {
              parsedArray = JSON.parse(responseText);
            } catch (e) {}
            lastDiscoveredObjects = Array.isArray(parsedArray)
              ? parsedArray
              : [];
            renderFilteredObjectList(
              lastDiscoveredObjects,
              $("#nr-point-search").val()
            );
            $("#nr-point-tip").hide();
          })
          .fail(function (xhr) {
            function parseErrorMessage(httpResponse) {
              try {
                if (
                  httpResponse &&
                  typeof httpResponse.responseText === "string"
                ) {
                  var text = httpResponse.responseText.replace(
                    /^\)\]\}',?\s*/,
                    ""
                  );
                  var parsed = JSON.parse(text);
                  if (parsed && parsed.error) {
                    return String(parsed.error);
                  }
                }
              } catch (e) {}
              return (
                "HTTP " +
                (httpResponse && httpResponse.status ? httpResponse.status : "")
              );
            }

            $("#nr-point-tip").text(parseErrorMessage(xhr)).show();
            $("#nr-point-list").empty().html("No objects");
          })
          .always(function () {
            isLoadingObjects = false;
            $("#nr-point-loading").css("display", "none");
          });
      }

      function toggleModeUserInterface() {
        var isAdvanced = $("#node-input-advancedMode").is(":checked");
        $("#node-input-mode").val(isAdvanced ? "advanced" : "list");

        if (nodeInstance) {
          nodeInstance.advancedMode = isAdvanced;
          nodeInstance.mode = isAdvanced ? "advanced" : "list";
        }

        if (!isAdvanced) {
          $("#nr-mode-list").show();
          $("#nr-simple-comm").show();
          $("#nr-mode-advanced").hide();
          syncHiddenFieldsFromAdvanced(true);
        } else {
          $("#nr-mode-list").hide();
          $("#nr-simple-comm").hide();
          $("#nr-mode-advanced").show();
          initializeAdvancedControls();
        }
        updateOcnServerTrace();
      }

      $("#nr-point-search").on("input", function () {
            
        renderFilteredObjectList(lastDiscoveredObjects, this.value);
      });

      $("#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);
        }
      });

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

      $(
        "#node-input-objectName,#node-input-objectType,#node-input-objectInstance,#node-input-deviceInstance,#node-input-deviceName,#node-input-labelMode,#node-input-objectLabelMode"
      ).on("input change", function () {
        updatePreviewLabelFromFields();
        renderSelectedPointPreview();
      });


      $("#node-input-priority").typedInput({
        types: [
          {
            options: [
              { value: "1", label: "Manual Life Safety (1)" },
              { value: "2", label: "Automatic Life Safety (2)" },
              { value: "3", label: "3" },
              { value: "4", label: "4" },
              { value: "5", label: "Critical Equipment Control (5)" },
              { value: "6", label: "Minimum On/Off (6)" },
              { value: "7", label: "OCN Special Event (7)" },
              { value: "8", label: "Manual Operator (8)" },
              { value: "9", label: "OCN Reserved (9)" },
              { value: "10", label: "10" },
              { value: "11", label: "11" },
              { value: "12", label: "12" },
              { value: "13", label: "13" },
              { value: "14", label: "14" },
              { value: "15", label: "15" },
              { value: "16", label: "16" },
            ],
          },
        ],
      });

      $("#node-input-send_policy").typedInput({
        types: [
          {
            value: false,
            options: [
              { value: "Always", label: "Always" },
              { value: "On value changed", label: "On value changed" },
            ],
          },
        ],
      });

      $("#node-input-priority").typedInput(
        "value",
        (nodeInstance && nodeInstance.priority) || "9"
      );
      $("#node-input-send_policy").typedInput(
        "value",
        (nodeInstance && nodeInstance.send_policy) || "Always"
      );

      (function initSimpleCommLinks() {
        function linkSelect(simpleSelector, advancedSelector, options) {
          var simpleElement = $(simpleSelector);
          var advancedElement = $(advancedSelector);
          if (!simpleElement.length || !advancedElement.length) return;

          var suppressSync = false;

          function readAdvancedValue() {
            if (
              options &&
              options.typed &&
              advancedElement.data("typedInput")
            ) {
              return advancedElement.typedInput("value");
            }
            return advancedElement.val();
          }

          function writeAdvancedValue(value) {
            if (
              options &&
              options.typed &&
              advancedElement.data("typedInput")
            ) {
              advancedElement.typedInput("value", value);
              advancedElement.trigger("change");
            } else {
              advancedElement.val(value).trigger("change");
            }
          }

          function syncFromAdvanced() {
            if (suppressSync) return;
            suppressSync = true;
            var value = readAdvancedValue();
            if (value == null) value = "";
            simpleElement.val(String(value));
            suppressSync = false;
          }

          simpleElement.on("change", function () {
            if (suppressSync) return;
            suppressSync = true;
            writeAdvancedValue($(this).val());
            suppressSync = false;
          });

          advancedElement.on("change", syncFromAdvanced);
          syncFromAdvanced();
        }

        function linkCheckbox(simpleSelector, advancedSelector) {
          var simpleElement = $(simpleSelector);
          var advancedElement = $(advancedSelector);
          if (!simpleElement.length || !advancedElement.length) return;

          var suppressSync = false;

          function syncFromAdvanced() {
            if (suppressSync) return;
            suppressSync = true;
            simpleElement.prop("checked", advancedElement.is(":checked"));
            suppressSync = false;
          }

          simpleElement.on("change", function () {
            if (suppressSync) return;
            suppressSync = true;
            advancedElement
              .prop("checked", $(this).is(":checked"))
              .trigger("change");
            suppressSync = false;
          });

          advancedElement.on("change", syncFromAdvanced);
          syncFromAdvanced();
        }

        linkSelect("#node-input-output_type-simple", "#node-input-output_type");
        linkSelect(
          "#node-input-send_policy-simple",
          "#node-input-send_policy",
          { typed: true }
        );
        linkSelect("#node-input-priority-simple", "#node-input-priority", {
          typed: true,
        });
        linkCheckbox(
          "#node-input-bypass_priority-simple",
          "#node-input-bypass_priority"
        );
        linkCheckbox(
          "#node-input-acknowledged_mqtt-simple",
          "#node-input-acknowledged_mqtt"
        );
      })();

      function handleAdvancedFieldChange() {
        syncHiddenFieldsFromAdvanced();
        if (nodeInstance) {
          var previousOutputType = nodeInstance.output_type;
          var previousOcnServerNode = nodeInstance.ocn_server_node;
          nodeInstance.priority =
            $("#node-input-priority").typedInput("value") ||
            nodeInstance.priority;
          nodeInstance.send_policy =
            $("#node-input-send_policy").typedInput("value") ||
            nodeInstance.send_policy;
          var outputTypeValue = $("#node-input-output_type-simple").val() || 
                                $("#node-input-output_type").val() || 
                                nodeInstance.output_type;
          nodeInstance.output_type = outputTypeValue;
          nodeInstance.bypass_priority = $("#node-input-bypass_priority").is(
            ":checked"
          );
          nodeInstance.allow_msg_override = $(
            "#node-input-allow_msg_override"
          ).is(":checked");
          var ocnValue = $("#node-input-ocn_server_node").val();
          nodeInstance.ocn_server_node = ocnValue || "";
          var hasChanged = previousOutputType !== nodeInstance.output_type || 
                          previousOcnServerNode !== nodeInstance.ocn_server_node;
          if (hasChanged) {
            nodeInstance.changed = true;
            try {
              RED.nodes.dirty(true);
            } catch (error) {}
          }
        }
        updateOcnServerTrace();
      }

      $(
        "#node-input-device_instance, #node-input-object_id, #node-input-output_type, #node-input-ocn_server_node"
      ).on("input change", handleAdvancedFieldChange);
      $("#node-input-bypass_priority, #node-input-allow_msg_override, #node-input-acknowledged_mqtt, #node-input-acknowledged_mqtt-simple").on(
        "change",
        handleAdvancedFieldChange
      );
      $("#node-input-priority").on("change", handleAdvancedFieldChange);
      $("#node-input-send_policy").on("change", handleAdvancedFieldChange);
      $("#node-input-output_type-simple").on("change", handleAdvancedFieldChange);

      if (nodeInstance) {
        hydrateAdvancedFromNodeInstance();
      } else {
        $("#node-input-advancedMode").prop("checked", false);
        $("#node-input-mode").val("list");
      }

      toggleModeUserInterface();
      renderSelectedPointPreview();

      if (!$("#node-input-advancedMode").is(":checked")) {
        setTimeout(loadObjectsFromApi, 20);
        $("#nr-point-list").html("Loading objects...");
      } else {
        syncHiddenFieldsFromAdvanced(true);
      }
    },
    oneditcancel: function () {
      var editorNodeInstance = RED.nodes.node(this.id);
      var saved = this._savedState;
      if (editorNodeInstance && saved) {
        editorNodeInstance.labelMode = saved.labelMode;
        editorNodeInstance.objectLabelMode = saved.objectLabelMode;
        if (saved.hadPreviewLabel) {
          editorNodeInstance.previewLabel = saved.previewLabel;
        } else {
          try {
            delete editorNodeInstance.previewLabel;
          } catch (_e) {}
        }
        window.__bnPointLabelCache = window.__bnPointLabelCache || {};
        if (saved.hadLabelCache) {
          window.__bnPointLabelCache[this.id] = saved.labelCache;
        } else {
          try {
            delete window.__bnPointLabelCache[this.id];
          } catch (_e) {}
        }
      }
      this._savedState = null;
      if (RED.view && RED.view.redraw) {
        RED.view.redraw(true);
      }
    },
    oneditsave: function () {
      this._savedState = null;
      this.acknowledged_mqtt = $("#node-input-acknowledged_mqtt").is(":checked");
      try {
        if (
          $("#node-input-advancedMode").is(":checked") &&
          window.localStorage
        ) {
          var deviceInstance = (
            $("#node-input-device_instance").val() || ""
          ).trim();
          var objectId = ($("#node-input-object_id").val() || "")
            .trim()
            .toUpperCase();
          if (deviceInstance) {
            localStorage.setItem("default_device_instance", deviceInstance);
          }
          if (objectId) {
            localStorage.setItem("default_out_obj_id", objectId);
          }
        }
      } catch (_e) {}
    },
  });

  // Validate nodes immediately when they're added (including copy/paste)
  // This ensures HTTP endpoints can find the node without needing to deploy
  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);
        }
        // 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);
    }
  });

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

<script type="text/html" data-template-name="bacnet-point-out">
  <div class="form-row"><ul id="bpo-tabs" class="red-ui-tabs"></ul></div>
  <div id="bpo-tabs-content">
    <div id="bpo-tab-props" 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" 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>
      <input type="hidden" id="node-input-deviceName" />
      <div id="nr-mode-list">
        <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:8px"
          />
          <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>
      </div>
      <div id="nr-simple-comm" style="margin-top:20px;">
        <div class="form-tips" style="margin-bottom:10px;">
          <b>Priority Settings</b>
        </div>
        <div class="form-row">
          <label for="node-input-priority-simple"
            ><i class="fa fa-sort-numeric-asc"></i> Priority</label
          >
          <select id="node-input-priority-simple" style="width:70%;">
            <option value="1">Manual Life Safety (1)</option>
            <option value="2">Automatic Life Safety (2)</option>
            <option value="3">3</option>
            <option value="4">4</option>
            <option value="5">Critical Equipment Control (5)</option>
            <option value="6">Minimum On/Off (6)</option>
            <option value="7">OCN Special Event (7)</option>
            <option value="8">Manual Operator (8)</option>
            <option value="9">OCN Reserved (9)</option>
            <option value="10">10</option>
            <option value="11">11</option>
            <option value="12">12</option>
            <option value="13">13</option>
            <option value="14">14</option>
            <option value="15">15</option>
            <option value="16">16</option>
          </select>
        </div>
        <div class="form-row">
          <label for="node-input-bypass_priority-simple"
            ><i class="fa fa-random"></i> Bypass Priority</label
          >
          <input
            type="checkbox"
            id="node-input-bypass_priority-simple"
            style="width:auto;margin-top:0;"
          />
        </div>
        <div class="form-tips" style="margin:20px 0 10px;">
          <b>Communication Settings</b>
        </div>
        <div class="form-row" style="margin-top:10px;">
          <label for="node-input-output_type-simple"
            ><i class="fa fa-exchange"></i> Protocol</label
          >
          <select id="node-input-output_type-simple" style="width:70%;">
            <option value="ip">IP</option>
            <option value="mstp">MSTP</option>
          </select>
        </div>
        <div class="form-row" style="margin-top:10px;">
          <label for="node-input-send_policy-simple"
            ><i class="fa fa-paper-plane"></i> Send Policy</label
          >
          <select id="node-input-send_policy-simple" style="width:70%;">
            <option value="Always">Always</option>
            <option value="On value changed">On value changed</option>
          </select>
        </div>
        <div class="form-row" style="display:flex;align-items:center;margin-top:10px;">
          <label for="node-input-acknowledged_mqtt-simple" style="display:flex;align-items:center;flex-shrink:0;margin:0;cursor:pointer;">
            <i class="fa fa-arrow-left" style="margin-right:6px;flex-shrink:0;"></i>
            <span>Acknowledged (MQTT)</span>
          </label>
          <input type="checkbox" id="node-input-acknowledged_mqtt-simple" style="width:auto;margin:0 0 0 12px;flex-shrink:0;" />
        </div>
        <div style="margin-top:10px;padding:10px 12px;border:1px solid #ddd;background:#fff6df;border-radius:6px;font-size:14px;line-height:1.5;color:#7a5b00;">
          <i class="fa fa-info-circle" style="margin-right:6px;"></i><b>Tip:</b> Third-party controllers must have a polling policy configured in OCN<br>for the acknowledge feature to work.
        </div>
      </div>
      <div id="nr-mode-advanced" 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., 213001"
          />
        </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 class="form-row">
          <label for="node-input-priority"
            ><i class="fa fa-sort-numeric-asc"></i> Priority</label
          >
          <input type="text" id="node-input-priority" />
        </div>
        <div class="form-row">
          <label for="node-input-bypass_priority"
            ><i class="fa fa-random"></i> Bypass Priority</label
          >
          <input
            type="checkbox"
            id="node-input-bypass_priority"
            style="width:auto;margin-top:0;"
          />
        </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" style="margin-top:10px;">
          <label for="node-input-output_type"
            ><i class="fa fa-exchange"></i> Protocol</label
          >
          <select id="node-input-output_type" style="width:70%;">
            <option value="ip">IP</option>
            <option value="mstp">MSTP</option>
          </select>
        </div>
        <div class="form-row" style="margin-top:10px;">
          <label for="node-input-send_policy"
            ><i class="fa fa-paper-plane"></i> Send Policy</label
          >
          <input type="text" id="node-input-send_policy" />
        </div>
        <div class="form-row">
          <label for="node-input-allow_msg_override"
            ><i class="fa fa-exchange"></i> Allow msg overrides</label
          >
          <input
            type="checkbox"
            id="node-input-allow_msg_override"
            style="width:auto;margin-top:0;"
          />
        </div>
        <div class="form-row">
          <label for="node-input-acknowledged_mqtt"
            ><i class="fa fa-arrow-left"></i> Acknowledged (MQTT)</label
          >
          <input type="checkbox" id="node-input-acknowledged_mqtt" style="width:auto;margin:0 0 0 12px;flex-shrink:0;" />
        </div>
        <div style="margin-top:10px;padding:10px 12px;border:1px solid #ddd;background:#fff6df;border-radius:6px;font-size:14px;line-height:1.5;color:#7a5b00;">
          <i class="fa fa-info-circle" style="margin-right:6px;"></i><b>Tip:</b> Third-party controllers must have a polling policy configured in OCN<br>for the acknowledge feature to work.
        </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>
    </div>
    </div>
    <input type="hidden" id="node-input-deviceInstance" />
    <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-networkNumber" />
    <input type="hidden" id="node-input-controllerDeviceInstance" />
    <input type="hidden" id="node-input-controllerName" />
    <div id="bpo-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="min-width:160px">
          <option value="instance" selected>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="min-width:160px">
          <option value="name" selected>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="bpo-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>
      <input type="hidden" id="node-input-mode" value="list" />
    </div>
  </div>
</script>

<script type="text/javascript">
  (function () {
    function refreshAllPointOutLabels(onComplete) {
      try {
        var pointNodes = [];
        if (RED.nodes && typeof RED.nodes.filterNodes === "function") {
          pointNodes = RED.nodes.filterNodes({ type: "bacnet-point-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, deviceName, deviceInstance, 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
        // 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-out/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 oldDeviceName = String(pointNode.deviceName || "");
                var oldDeviceInstance = String(pointNode.deviceInstance || "");
                var oldUnits = String(pointNode.units || "");
                var oldDescription = String(pointNode.description || "");
                
                // Update frontend node properties from backend
                // Note: The backend returns "deviceName" but the node property might be "deviceName" or "controllerName" depending on usage context
                // In bacnet-point-out.js defaults it is deviceName.
                
                // Update the actual node object that Node-RED uses for rendering
                // Get fresh reference to ensure we're updating the right object
                var actualNode = RED.nodes.node ? RED.nodes.node(pointNode.id) : pointNode;
                if (!actualNode) actualNode = pointNode;
                
                if (backendProps.objectName !== oldObjectName) {
                  actualNode.objectName = backendProps.objectName;
                  pointNode.objectName = backendProps.objectName; // Also update the loop variable
                  // Don't set changed = true here - these are backend sync updates, not user changes
                  updated = true;
                }
                
                // Handle device/controller name - try both property sets for robustness
                var newDeviceName = backendProps.deviceName || backendProps.controllerName;
                if (newDeviceName && newDeviceName !== oldDeviceName) {
                  actualNode.deviceName = newDeviceName;
                  pointNode.deviceName = newDeviceName; // Also update the loop variable
                  // Don't set changed = true here - these are backend sync updates, not user changes
                  updated = true;
                }
                
                // Handle device instance - try both property sets
                var newDeviceInstance = backendProps.deviceInstance || backendProps.controllerDeviceInstance;
                if (newDeviceInstance && newDeviceInstance !== oldDeviceInstance) {
                   actualNode.deviceInstance = newDeviceInstance;
                   pointNode.deviceInstance = newDeviceInstance; // Also update the loop variable
                   // Don't set changed = true here - these are backend sync updates, not user changes
                   updated = true;
                }
                
                // Always clear cache and previewLabel for the actual node
                if (window.__bnPointLabelCache && window.__bnPointLabelCache[actualNode.id]) {
                  delete window.__bnPointLabelCache[actualNode.id];
                }
                if (actualNode.previewLabel) {
                  delete actualNode.previewLabel;
                }
                
                if (backendProps.units !== oldUnits) {
                  pointNode.units = backendProps.units;
                  updated = true;
                }
                if (backendProps.description !== oldDescription) {
                  pointNode.description = backendProps.description;
                  updated = true;
                }
                
                if (updated) {
                  nodesUpdated++;
                }
                
                // Cache clearing moved above to use actualNode
            }
          });
        })
        .fail(function() {
          // If the batch fetch fails there is no reliable backend data;
          // labels are recalculated below from the current editor values.
        })
        .always(function() {
          
          // Clear all label caches and previewLabels to force label recalculation
          if (window.__bnPointLabelCache) {
            Object.keys(window.__bnPointLabelCache).forEach(function(nodeId) {
              if (RED.nodes.node(nodeId) && RED.nodes.node(nodeId).type === 'bacnet-point-out') {
                  delete window.__bnPointLabelCache[nodeId];
              }
            });
          }
          
          // Also clear previewLabels for all point-out nodes
          pointNodes.forEach(function(pointNode) {
            if (pointNode && pointNode.id && pointNode.previewLabel) {
              delete pointNode.previewLabel;
            }
          });

          function forceRedraw() {
             try {
               if (RED.view && typeof RED.view.redraw === "function") {
                  RED.view.redraw(true);
                  return true;
               }
             } catch (e) {
             }
             return false;
          }
          
          // Redraw with a delay to ensure labels update
          forceRedraw();
          setTimeout(function() {
            forceRedraw();
          }, 200);

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

    // Debounced/coalesced entry point for refreshAllPointOutLabels: 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 __bnOutRefreshTimer = null;
    var __bnOutRefreshInFlight = false;
    var __bnOutRefreshQueued = false;
    var __bnOutRefreshCallbacks = [];
    var __bnOutFlowsLoaded = false;

    function runOutRefreshNow() {
      if (__bnOutRefreshInFlight) {
        __bnOutRefreshQueued = true;
        return;
      }
      __bnOutRefreshInFlight = true;
      var completionCallbacks = __bnOutRefreshCallbacks;
      __bnOutRefreshCallbacks = [];
      refreshAllPointOutLabels(function () {
        __bnOutRefreshInFlight = false;
        completionCallbacks.forEach(function (callback) {
          try {
            callback();
          } catch (_ignoredError) {}
        });
        if (__bnOutRefreshQueued) {
          __bnOutRefreshQueued = false;
          scheduleRefreshAllPointOutLabels();
        }
      });
    }

    // 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 scheduleRefreshAllPointOutLabels(onComplete) {
      if (typeof onComplete === "function") __bnOutRefreshCallbacks.push(onComplete);
      if (__bnOutRefreshTimer) clearTimeout(__bnOutRefreshTimer);
      __bnOutRefreshTimer = setTimeout(function () {
        __bnOutRefreshTimer = null;
        runOutRefreshNow();
      }, 300);
    }
    
    function buildPreviewLabel(pointNode) {
      var labelMode = pointNode.labelMode || "instance";
      var objectLabelMode = pointNode.objectLabelMode || "name";
      var deviceLabel = buildDeviceDisplayLabel(
        labelMode,
        pointNode.deviceName,
        pointNode.deviceInstance
      );
      var objectId = "";
      if (pointNode.objectType != null && pointNode.objectInstance != null) {
        var TYPE_MAP = {
          0: "AI", 1: "AO", 2: "AV", 3: "BI", 4: "BO", 5: "BV",
          12: "MI", 13: "MO", 14: "MV", 19: "MSV"
        };
        var typeAbbr = TYPE_MAP[pointNode.objectType] || String(pointNode.objectType);
        objectId = typeAbbr + "-" + pointNode.objectInstance;
      }
      var objectLabel = buildObjectDisplayLabel(
        objectLabelMode,
        pointNode.objectName,
        objectId
      );
      
      if (labelMode === "both") {
        var labelParts = [];
        if (objectLabel) labelParts.push(objectLabel);
        if (pointNode.deviceName) labelParts.push(pointNode.deviceName);
        if (pointNode.deviceInstance) labelParts.push(pointNode.deviceInstance);
        if (!labelParts.length) labelParts.push(objectId || pointNode.objectName || "");
        var middleDot = " " + String.fromCharCode(183) + " ";
        return labelParts.length ? labelParts.join(middleDot) : deviceLabel || objectId || "";
      } else {
        var separator = " " + String.fromCharCode(183) + " ";
        if (objectLabel && deviceLabel) {
          return objectLabel + separator + deviceLabel;
        } else if (objectLabel) {
          return objectLabel;
        } else if (deviceLabel) {
          return deviceLabel;
        } else {
          return objectId || pointNode.objectName || "";
        }
      }
    }

    window.__bnPointOutForceRefresh = scheduleRefreshAllPointOutLabels;

    // One batch refresh after the initial flow import completes
    RED.events.on("flows:loaded", function() {
      __bnOutFlowsLoaded = true;
      scheduleRefreshAllPointOutLabels();
    });

    // Refresh labels on deploy
    // Refresh labels on deploy with retries to account for backend startup delay
    RED.events.on("deploy", function() {
      // Function to run the full refresh sequence
      function runRefreshSequence() {
        // Add delay to ensure backend refreshMapping() completes before fetching properties
        // refreshMapping() runs with 200ms delay, so wait a bit longer to ensure it completes
        // Get all point-out 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-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];
            
            // Also force wipe previewLabel, which takes precedence
            if (pointNode.previewLabel) {
              delete pointNode.previewLabel;
            }
          }
        });
        
        // Redraw immediately to refresh labels (cache clearing is enough to force recalculation)
        if (RED.view && typeof RED.view.redraw === "function") {
          RED.view.redraw(true);
        }
        
        // Also call refresh to sync with backend
        try {
          scheduleRefreshAllPointOutLabels();
        } catch (e) {
          // Error calling refreshAllPointOutLabels
        }
        

      }

      // Wait for backend refreshMapping() to complete (it runs with 200ms delay)
      // Then run refresh sequence with additional delay to ensure backend updates are persisted
      setTimeout(runRefreshSequence, 500);
      
      // Retry once more 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 (!__bnOutFlowsLoaded) return;
      if (addedNode && (addedNode.type === "bacnet-point-out" || addedNode.type === "bacnet-controller")) {
        scheduleRefreshAllPointOutLabels();
      }
    });

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

    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-out" || node.type === "bacnet-controller")) {
            shouldRefresh = true;
            break;
          }
        }
      }
      if (shouldRefresh) {
        scheduleRefreshAllPointOutLabels();
      }
    });
  })();
</script>

<script type="text/html" data-help-name="bacnet-point-out">
  <p>
    Select a point from a controller in List mode, or enter fields in Advanced
    mode. Output 1 emits the requested write. Output 2 emits the actual value.
  </p>
</script>