UNPKG

@janart19/node-red-fusebox

Version:

A comprehensive collection of custom nodes for interfacing with Fusebox automation controllers - data streams, energy management, and utilities

1,025 lines (871 loc) 98.6 kB
<script type="text/javascript"> RED.nodes.registerType("fusebox-write-data-streams", { category: "fusebox", color: "#f5706c", defaults: { name: { value: "" }, controller: { value: "", type: "fusebox-controller", required: true }, outputMode: { value: "", validate: function (v) { return ["all", "change"].includes(v); } }, payloadType: { value: "dynamic", validate: function (v) { return ["static", "dynamic"].includes(v); } }, gatingMs: { value: 1500, validate: function (v) { const n = parseInt(v); return Number.isInteger(n) && n >= 0; } }, // Gate duration in milliseconds mappings: { value: [] } // Store multiple mappings }, inputs: 1, outputs: 1, icon: "font-awesome/fa-send", label: function () { const mappings = this.mappings; let defaultName = `send data streams`; if (mappings) defaultName = `send data streams: ${mappings.length}`; return this.name || defaultName; }, paletteLabel: function () { return "send data streams"; }, oneditprepare: function () { const node = this; let _controller = {}; let _lastControllerId = null; // Load existing mappings or initialize an empty array const mappings = node.mappings || []; // Populate form with node values $("#node-input-name").val(node.name); $("#node-input-controller").val(node.controller); $("#node-input-outputMode").val(node.outputMode); $("#node-input-payloadType").val(node.payloadType); $("#node-input-gatingMs").val(node.gatingMs); // Load controller data $("#node-input-controller").change(queryControllerConfig); // Define event listeners for form fields $("#mappings-container, #node-input-payloadType, #node-input-outputMode").change(function () { updateTipText(); }); $("#node-input-payloadType").change(function () { const rows = getListElements(); for (const elements of rows) { populatePayload(elements); validatePayload(elements); } }); // Initialize the form fields let currentSortColumn = "topic"; // Declare at function scope level initializeEditableList(); // Query variables related to controller configuration function queryControllerConfig() { const controllerId = $("#node-input-controller").val(); if (_lastControllerId === controllerId) return; _lastControllerId = controllerId; $.getJSON(`fusebox/controller-config?id=${controllerId}`) .done(function (data) { _controller = data; // Log available datastreams to console for debugging const filteredServices = data.filteredServices || {}; const channels = data.channels || {}; const writableServices = data.writableServices || {}; const filteredKeys = Object.keys(filteredServices).sort(); const channelKeys = Object.keys(channels).sort(); const writableKeys = Object.keys(writableServices).sort(); // Find datastreams in channels but NOT in filteredServices const missingFromFiltered = channelKeys.filter((k) => !filteredKeys.includes(k)); if (missingFromFiltered.length > 0) { console.log( "Write Data Streams: ⚠ Datastreams in channels but MISSING from filteredServices (excluded by backend):", missingFromFiltered.length, missingFromFiltered ); } // Find datastreams in filteredServices but NOT in writableServices const inFilteredButNotWritable = filteredKeys.filter((k) => !writableKeys.includes(k)); if (inFilteredButNotWritable.length > 0) { console.log( "Write Data Streams: ⚠ Datastreams in filteredServices but not in writableServices:", inFilteredButNotWritable.length, inFilteredButNotWritable ); } // Final list shown in dropdown (all from filteredServices) console.log("Write Data Streams: Final list of datastreams available for write selection:", filteredKeys.length, filteredKeys); }) .fail(function () { console.error("Failed to query controller configuration!"); RED.notify("Failed to query controller configuration!", { type: "error", timeout: 3000 }); _controller = {}; }) .always(function () { $("#mappings-container").editableList("empty"); $("#mappings-container").editableList("addItems", mappings); updateTipText(); setTimeout(() => { triggerSort(); refreshAllPlusButtons(); }, 200); }); } // --- Helpers for 'Add next' & 'Add all' --- function readAllRows() { const rows = []; $("#mappings-container") .editableList("items") .each(function () { const c = $(this); rows.push({ keyNameSelect: c.find(".node-input-keyNameSelect").val() || "", keyNameManual: c.find(".node-input-keyNameManual").val() || "", index: parseInt(c.find(".node-input-index").val() || "0", 10) || 0 }); }); return rows; } function getUsedIndicesForKey(key) { const rows = readAllRows(); return new Set( rows .filter((r) => (r.keyNameSelect || r.keyNameManual) === key) .map((r) => r.index) .filter((n) => Number.isInteger(n) && n > 0) ); } function computeMissingForKey(key) { const used = Array.from(getUsedIndicesForKey(key)).sort((a, b) => a - b); const missing = []; let expect = 1; for (const n of used) { while (expect < n) { missing.push(expect); expect++; } expect = n + 1; } // also offer the immediate next missing.push(expect); return missing; } // Loop detection in editor - find conflicting keys across ALL flows // Store on node object so it's accessible in oneditsave if (!node._loopConflictKeys) { node._loopConflictKeys = new Set(); } let loopConflictKeys = node._loopConflictKeys; function detectLoopsInEditor() { loopConflictKeys.clear(); const writeKeys = new Set(); // Get keys from current node's mappings const currentMappings = node.mappings || []; currentMappings.forEach((m) => { const key = m.keyNameSelect || m.keyNameManual; if (key) writeKeys.add(key); }); // Find read nodes in ANY flow (cross-tab via link nodes) RED.nodes.eachNode(function (otherNode) { if (otherNode.type === "fusebox-read-data-streams") { const readMappings = otherNode.mappings || []; readMappings.forEach((m) => { const key = m.keyNameSelect || m.keyNameManual; if (key && writeKeys.has(key)) { loopConflictKeys.add(key); } }); } }); console.log("Loop detection (all tabs): conflicting keys:", Array.from(loopConflictKeys)); } // Run loop detection detectLoopsInEditor(); // Show warning banner if loops detected if (loopConflictKeys.size > 0) { const banner = $(`<div class="loop-warning-banner" style="margin:0 0 15px 0;width:100%;max-width:none;"> <div style="background-color:#fff3cd;border:2px solid #ff9800;border-radius:4px;padding:10px;box-sizing:border-box;"> <div style="display:flex;align-items:flex-start;gap:12px;"> <i class="fa fa-exclamation-triangle" style="color:#ff9800;font-size:18px;flex-shrink:0;margin-top:2px;"></i> <div style="flex:1;min-width:0;overflow:hidden;"> <strong style="color:#856404;font-size:13px;display:block;margin-bottom:8px;">Potential Feedback Loop Detected!</strong> <div style="color:#856404;margin-bottom:10px;word-break:break-word;white-space:normal;"> Found potential loops for: <strong>${Array.from(loopConflictKeys).join(", ")}</strong> </div> <div style="color:#856404;font-size:85%;line-height:1.5;white-space:normal;"> <strong>Note:</strong> This detection compares topics only, not actual wiring. If these nodes are not connected, no loop exists. </div> <div style="color:#856404;margin-top:8px;font-size:85%;line-height:1.5;white-space:normal;"> <strong>If there IS a loop, options to prevent rapid updates:</strong> <ul style="margin:4px 0 0 0;padding-left:20px;"> <li>Enable "Gate loop" checkbox for these topics (recommended), or</li> <li>Remove looping datastream members from read or write node</li> </ul> </div> <div style="margin-top:12px;padding-top:12px;border-top:1px solid #e0b95f;"> <button type="button" id="analyze-flow-btn" class="red-ui-button" style="background:#ff9800;color:white;font-weight:bold;"> <i class="fa fa-search"></i> Analyze Actual Flow Wiring </button> <span id="analyze-flow-msg" style="margin-left:10px;font-size:85%;color:#856404;"></span> </div> </div> </div> </div> </div>`); $("#node-input-gatingMs").closest(".form-row").after(banner); // Add handler for Analyze Flow button $("#analyze-flow-btn").on("click", function () { const analyzerNodes = []; RED.nodes.eachNode(function (n) { if (n.type === "fusebox-flow-analyzer") { analyzerNodes.push(n); } }); if (analyzerNodes.length === 0) { $("#analyze-flow-msg") .html('<i class="fa fa-info-circle"></i> No Flow Analyzer node found. Add a "fusebox-flow-analyzer" node to your workspace for deep analysis.') .css("color", "#d9534f"); setTimeout(() => $("#analyze-flow-msg").fadeOut(), 5000); } else { const analyzer = analyzerNodes[0]; $("#analyze-flow-msg").html('<i class="fa fa-spinner fa-spin"></i> Running deep flow analysis...').css("color", "#5cb85c").show(); // Trigger the analyzer node $.ajax({ url: "inject/" + analyzer.id, type: "POST", success: function (resp) { $("#analyze-flow-msg").html('<i class="fa fa-check"></i> Analysis complete! Check the Flow Analyzer node output.').css("color", "#5cb85c"); setTimeout(() => $("#analyze-flow-msg").fadeOut(), 3000); }, error: function (jqXHR, textStatus, errorThrown) { let errMsg = "Analysis failed"; if (jqXHR.status == 404) { errMsg = "Flow Analyzer node not deployed yet. Deploy first, then try again."; } else if (jqXHR.status == 500) { errMsg = "Analysis error: " + textStatus; } $("#analyze-flow-msg") .html('<i class="fa fa-exclamation-triangle"></i> ' + errMsg) .css("color", "#d9534f"); setTimeout(() => $("#analyze-flow-msg").fadeOut(), 5000); } }); } }); } // Initialize the EditableList widget function initializeEditableList() { $("#mappings-container").editableList({ removable: true, sortable: true, header: $("<div>").append( $.parseHTML(` <div style="width:22px"></div> <div class="write-data-streams-list" style="text-align:center;white-space:normal;flex:1"> <div class="node-input-topic" id="sort-header-topic" style="cursor:pointer;user-select:none;"> Topic <span class="sort-triangle" id="sort-triangle-topic">▲</span> </div> <span class="node-input-arrow">&nbsp;&nbsp;</span> <div class="checkbox-container">Manual</div> <div class="checkbox-container">Gate loop</div> <div class="node-input-keyNameManual" id="sort-header-datastream" style="cursor:pointer;user-select:none;"> Data stream name <span class="sort-triangle" id="sort-triangle-datastream">▲</span> </div> <div class="node-input-index">Member index</div> <div class="node-input-channelType">Channel type</div> <div class="node-input-coefficient">Coefficient</div> <div class="node-input-payload">Payload</div> <div class="row-actions"></div> </div> <div style="width:28px"></div> `) ), buttons: [ { label: "delete all", icon: "fa fa-trash", title: "delete all data stream mappings", click: function (e) { $("#mappings-container").editableList("empty"); updateTipText(); } }, { label: "auto-generate entries", icon: "fa fa-refresh", title: "automatically generate rows for all available controller data streams", click: function (e) { const hasServices = _controller.writableServices && Object.keys(_controller.writableServices).length > 0; if (!hasServices) { RED.notify("No available data streams to auto-generate mappings!", { type: "warning", timeout: 3000 }); return; } autoGenerateMappings(); } } ], addItem: function (container, index, row) { addRowElements(container, row); const el = getRowElements(container); formatRow(el, row); attachRowEvents(el, container); // Initialize autocomplete for the topic input in this row initializeAutocomplete(el.topicElement, onAutocompleteSelect); // Add refresh for plus button refreshPlusButtonFor(container); setTimeout(() => { refreshAllPlusButtons(); }, 200); updateTipText(); setTimeout(() => { refreshAllPlusButtons(); }, 100); }, removeItem: function (data) { // Called after successful removal updateTipText(); refreshAllPlusButtons(); } }); // Initialize sorting functionality setTimeout(function () { $("#sort-header-datastream").on("click", function () { if (currentSortColumn !== "datastream") { currentSortColumn = "datastream"; updateSortIndicators(); triggerSort(); } }); $("#sort-header-topic").on("click", function () { if (currentSortColumn !== "topic") { currentSortColumn = "topic"; updateSortIndicators(); triggerSort(); } }); updateSortIndicators(); triggerSort(); }, 100); } function updateSortIndicators() { $(".sort-triangle").removeClass("active"); $(`#sort-triangle-${currentSortColumn}`).addClass("active"); } function refreshAllPlusButtons() { const items = $("#mappings-container").editableList("items"); items.each(function () { refreshPlusButtonFor($(this)); }); } function refreshPlusButtonFor(container) { // remove any existing old-style helpers and new dropdowns container.find(".add-member-dropdown").remove(); container.find(".index-helpers").remove(); // Remove old style buttons const el = getRowElements(container); const manual = el.manualElement?.is(":checked"); if (manual) { return; } const key = el.keySelectElement.val(); const idx = parseInt(el.indexElement.val(), 10); if (!key || !Number.isInteger(idx)) { return; } // Get all members for this datastream const allUsedForThisKey = getUsedIndicesForDataStream(key); // Apply writability rules: "s"/"s!"/"r" always writable, "h"/"c"/"h!"/"c!" if _output is true const allMembers = _controller?.channels?.[key] ? Object.values(_controller.channels[key]) : []; const writableMembers = allMembers.filter((member) => { const regtype = member.regtype; const hasOutput = member._output === true; if (["s", "s!", "r"].includes(regtype)) return true; if (["h", "c", "h!", "c!"].includes(regtype)) return hasOutput; return false; }); const allAvailableForThisKey = writableMembers.map((m) => parseInt(m.member, 10)).sort((a, b) => a - b); const missingForThisKey = allAvailableForThisKey.filter((i) => !allUsedForThisKey.includes(i)); // Find max used index to know if this is the last member const maxUsedIndex = allUsedForThisKey.length > 0 ? Math.max(...allUsedForThisKey) : 0; const isLastMember = idx === maxUsedIndex; // Only show button if this is the last member AND there are missing members if (!isLastMember || missingForThisKey.length === 0) { return; } // Find the next missing member AFTER the current index (like read node) const nextMissingIndex = missingForThisKey.find((i) => i > idx); if (!nextMissingIndex) { return; // No next member available } // Insert dropdown before remove button const actionsDiv = container.find(".row-actions"); // Build dropdown HTML like read node let dropdownHtml = '<select class="add-member-dropdown" title="Add members for ' + key + '">'; dropdownHtml += '<option value="" selected disabled>+</option>'; dropdownHtml += '<option value="next">Add next member (' + nextMissingIndex + ")</option>"; if (missingForThisKey.length > 1) { dropdownHtml += '<option value="all">Add all missing (' + missingForThisKey.length + ")</option>"; } dropdownHtml += "</select>"; const dropdown = $(dropdownHtml); dropdown.on("change", function (e) { e.preventDefault(); e.stopPropagation(); const action = $(this).val(); if (action === "next") { addNextMember(key, nextMissingIndex); } else if (action === "all") { // Recompute what's actually missing NOW (not when button was created) const currentlyUsed = getUsedIndicesForDataStream(key); const allAvailableForThisKey = _controller?.channels?.[key] ? Object.values(_controller.channels[key]) .map((m) => parseInt(m.member, 10)) .sort((a, b) => a - b) : []; const actuallyMissingNow = allAvailableForThisKey.filter((i) => !currentlyUsed.includes(i)); addAllMissingMembers(key, actuallyMissingNow); } // Reset dropdown $(this).val(""); }); actionsDiv.append(dropdown); } // Helper function to determine channel type for a given key and index function determineChannelType(key, idx) { const channels = _controller?.channels || {}; if (!channels[key]) { return "ao"; // Default fallback } const channel = channels[key]; const memberObjects = Object.values(channel); const member = memberObjects.find((m) => parseInt(m.member, 10) === idx); if (!member) { return "ao"; // Default fallback } // Determine channel type based on member properties // Input/read registers (s, s!, r) -> AI or DI if (["s", "s!", "r"].includes(member.regtype)) { if (member._type === "analogue") { return "ai"; } else if (member._type === "discrete") { return "di"; } else { return "ai"; // Default for input if type unclear } } // Output registers (h, c, h!, c!) -> AO or DO (only if _output is true) else if (["h", "c", "h!", "c!"].includes(member.regtype) && member._output) { if (member._type === "analogue") { return "ao"; } else if (member._type === "discrete") { return "do"; } else { return "ao"; // Default for output if type unclear } } else { return "ao"; // Default fallback } } function addNextMember(key, idx) { const channelType = determineChannelType(key, idx); const row = { manual: false, gateLoop: false, keyNameSelect: key, index: idx.toString(), topic: `${key}.${idx}`, channelType: channelType, coefficient: "1", payload: "" }; $("#mappings-container").editableList("addItems", [row]); // Give it time for the item to be added to the DOM before refreshing setTimeout(() => { refreshAllPlusButtons(); }, 150); } function addAllMissingMembers(key, indices) { // Recompute what's missing NOW (not from when the button was clicked) const currentlyUsed = getUsedIndicesForDataStream(key); // Get all available members const allAvailableForThisKey = _controller?.channels?.[key] ? Object.values(_controller.channels[key]) .map((m) => parseInt(m.member, 10)) .sort((a, b) => a - b) : []; // Filter to only actually missing ones const actuallyMissing = indices.filter((idx) => allAvailableForThisKey.includes(idx) && !currentlyUsed.includes(idx)); if (actuallyMissing.length === 0) { return; } // Build all rows first, determining channel type for each member dynamically const newRows = actuallyMissing.map((idx) => { const channelType = determineChannelType(key, idx); return { manual: false, gateLoop: false, keyNameSelect: key, index: idx.toString(), topic: `${key}.${idx}`, channelType: channelType, coefficient: "1", payload: "" }; }); $("#mappings-container").editableList("addItems", newRows); // Give it time for the items to be added to the DOM before refreshing setTimeout(() => { refreshAllPlusButtons(); }, 150); } function getUsedIndicesForDataStream(key) { const used = []; const items = $("#mappings-container").editableList("items"); items.each(function () { const c = $(this); const el = getRowElements(c); const manual = el.manualElement?.is(":checked"); const rowKey = manual ? el.keyManualElement.val() : el.keySelectElement.val(); const idxVal = el.indexElement.val(); const idx = parseInt(idxVal, 10); if (rowKey === key && Number.isInteger(idx) && idx >= 0) { used.push(idx); } }); const result = [...new Set(used)].sort((a, b) => a - b); return result; } function triggerSort() { const currentItems = []; $("#mappings-container") .editableList("items") .each(function () { const container = $(this); const el = getRowElements(container); const manual = el.manualElement?.is(":checked"); const gateLoop = el.gateLoopElement?.is(":checked"); const keyNameSelect = el.keySelectElement.val(); const keyNameManual = el.keyManualElement.val(); const key = manual ? keyNameManual : keyNameSelect; const idx = parseInt(el.indexElement.val(), 10); const topic = el.topicElement.val(); const channelType = el.channelTypeElement.val(); const coefficient = el.coefficientElement.val(); const payload = el.payloadElement.val(); currentItems.push({ manual: !!manual, gateLoop: !!gateLoop, key: key || "", keyNameSelect, keyNameManual, channelType, coefficient, payload, index: idx || 0, topic: topic || "", container: container }); }); // Sort based on current column currentItems.sort((a, b) => { let primary; if (currentSortColumn === "datastream") { primary = a.key.localeCompare(b.key); } else { primary = a.topic.localeCompare(b.topic); } if (primary !== 0) return primary; // Secondary sort by index return a.index - b.index; }); // Rebuild list with sorted data $("#mappings-container").editableList("empty"); currentItems.forEach((item) => { const data = { manual: item.manual, gateLoop: item.gateLoop, keyNameSelect: item.keyNameSelect, keyNameManual: item.keyNameManual, channelType: item.channelType, index: item.index ? item.index.toString() : "", coefficient: item.coefficient, payload: item.payload, topic: item.topic }; $("#mappings-container").editableList("addItem", data); }); setTimeout(refreshAllPlusButtons, 100); } // Initialize autocomplete for topic inputs in the editable list function initializeAutocomplete(element, onSelect = null) { element .autocomplete({ minLength: 0, source: function (request, response) { const term = request.term.toLowerCase(); const selection = getTopics(); const matches = selection?.filter((obj) => { return obj && obj.label && obj.label.toLowerCase().indexOf(term) > -1; }) || []; response(matches); }, focus: function (event, ui) { // Don't change the input value on hover/focus event.preventDefault(); }, select: function (event, ui) { event.preventDefault(); element.val(ui.item.topic); // Call the onSelect callback if provided if (onSelect) onSelect(element, ui.item); } }) .on("focus", function () { element.autocomplete("search", element.val() || ""); }) .autocomplete("instance")._renderItem = function (ul, item) { const term = this.term.trim(); const label = item?.label || item?.topic || ""; let highlightedLabel = label; if (term) { const regex = new RegExp("(" + term.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&") + ")", "ig"); highlightedLabel = label.replace(regex, '<strong style="color:#e65; font-weight:bold">$1</strong>'); } return $("<li></li>").data("ui-autocomplete-item", item).append(`<div>${highlightedLabel}</div>`).appendTo(ul); }; } // Return a list of topics from the controller configuration // Structure: [ { key: "ABC", member: 1, topic: test/1, label: ... } ] // TODO: revert back to prev version? Unnecessary complexity here, already implemented elsewhere: writableServices function getTopics() { const allTopics = _controller?.formattedTopics || []; const channels = _controller?.channels || {}; // Filter out any null/undefined topics first const validTopics = allTopics.filter((topic) => topic && topic.key && topic.member !== undefined); const filteredTopics = validTopics.filter((topic) => { const key = topic.key; const member = topic.member; // Check if datastream exists in channels (unfiltered list) if (!channels[key]) return false; // Apply writability rules: "s"/"s!"/"r" always writable, "h"/"c"/"h!"/"c!" only if _output is true // Find the member in channels to check its regtype const channelMembers = channels[key] || {}; const memberObj = Object.values(channelMembers).find((m) => parseInt(m.member, 10) === member); if (!memberObj) return false; // Member not found in channels const regtype = memberObj.regtype; const hasOutput = memberObj._output === true; if (["s", "s!", "r"].includes(regtype)) { return true; // Input/read registers are always writable } if (["h", "c", "h!", "c!"].includes(regtype)) { return hasOutput; // Holding/coil (with or without !) writable if _output is true } return false; // Unknown regtype }); return filteredTopics; } // Further processing of a row after a topic is selected from the autocomplete function onAutocompleteSelect(element, obj = {}) { const container = element.closest(".red-ui-editableList-item-content"); let elements = getRowElements(container); validateTopic(elements); // Try to set the key and index fields according to the selected topic const key = obj.key; const member = obj.member; const manual = elements.manualElement.is(":checked"); if (manual || !key || !member) return; if (topicRelatedToService(`${key}.${member}`)) { elements.keySelectElement.val(key).trigger("change"); // NB! Because populateIndex() changes the element, find the latest elements = getRowElements(container); elements.indexElement.val(String(member)).trigger("input").trigger("change"); } } // Return true if the topic is related to a service function topicRelatedToService(topic) { const fs = _controller?.filteredServices; if (!fs || !topic || !topic.includes(".")) return false; const key = topic.split(".")[0]; const member = topic.split(".")[1]; return fs?.[key] && Number.isInteger(parseInt(member)) && parseInt(member) >= 1 && parseInt(member) <= 16; } // Append the row to the container function addRowElements(container, row) { // Check if this key has loop conflicts const key = row.keyNameSelect || row.keyNameManual; const loopDetected = (node._loopConflictKeys || new Set()).has(key); // TODO: fix layout const rowHtml = ` <div class="write-data-streams-list"> <input type="text" class="node-input-topic" placeholder="Topic" value="${row.topic || ""}"> <span class="node-input-arrow">&#8594;</span> <div class="checkbox-container"> <input type="checkbox" class="node-input-manual" ${row.manual === true ? "checked" : ""}> <div class="helper-text">Manual</div> </div> <div class="checkbox-container"> <input type="checkbox" class="node-input-gateLoop" ${row.gateLoop === true ? "checked" : ""} ${loopDetected ? "" : "disabled"} title="${ loopDetected ? "Loop detected with read node" : "No loop detected" }"> <div class="helper-text">Gate loop</div> </div> <select class="node-input-keyNameSelect"></select> <input type="text" class="node-input-keyNameManual" placeholder="Manual entry, e.g. ABCW" value="${row.keyNameManual || ""}"> <input type="number" class="node-input-index" placeholder="Member" value="${row.index || ""}" min="1" max="16"> <select class="node-input-channelType"> <option value="">Channel type...</option> <option value="ai" ${row.channelType === "ai" ? "selected" : ""}>Analogue input (AI)</option> <option value="ao" ${row.channelType === "ao" ? "selected" : ""}>Analogue output (AO)</option> <option value="di" ${row.channelType === "di" ? "selected" : ""}>Discrete input (DI)</option> <option value="do" ${row.channelType === "do" ? "selected" : ""}>Discrete output (DO)</option> </select> <input type="number" class="node-input-coefficient" placeholder="Coef." value="${row.coefficient || ""}" step="0.01"> <input type="number" class="node-input-payload" placeholder="Payload" value="${row.payload || ""}" step="0.01"> <div class="row-actions"></div> </div>`; container.append(rowHtml); } // Get references to the created elements function getRowElements(container) { return { topicElement: container.find(".node-input-topic"), manualElement: container.find(".node-input-manual"), gateLoopElement: container.find(".node-input-gateLoop"), keyManualElement: container.find(".node-input-keyNameManual"), keySelectElement: container.find(".node-input-keyNameSelect"), indexElement: container.find(".node-input-index"), channelTypeElement: container.find(".node-input-channelType"), coefficientElement: container.find(".node-input-coefficient"), payloadElement: container.find(".node-input-payload") }; } // Format the newly created / intialized row's elements // TODO: revert back to prev version? Unnecessary complexity here, already implemented elsewhere: writableServices function formatRow(elements, row) { const channels = _controller.channels || {}; const filteredServices = _controller.filteredServices || {}; // Still used for metadata (servicename, conv_coef) // Apply our own writability filtering to channels (unfiltered list from backend) // A datastream is writable if it has at least one writable member // Rules: s/s!/r always writable; h/c/h!/c! writable if _output is true const writableDatastreams = {}; Object.keys(channels).forEach((key) => { const keyChannels = channels[key] || {}; const members = Object.values(keyChannels); // Check if this datastream has at least one writable member const hasWritableMember = members.some((member) => { const regtype = member.regtype; const hasOutput = member._output === true; // Always writable: input registers and read registers if (["s", "s!", "r"].includes(regtype)) { return true; } // Holding/coil registers (with or without !) are writable if _output is true if (["h", "c", "h!", "c!"].includes(regtype)) { return hasOutput; } return false; }); if (hasWritableMember) { writableDatastreams[key] = filteredServices[key] || { servicename: key }; // Use metadata if available } }); const keySelectElement = elements.keySelectElement; const selectedKey = row.keyNameSelect; const manual = row.manual === true; const gateLoop = row.gateLoop === true; if (elements.manualElement.length) { elements.manualElement.prop("checked", manual); } if (elements.gateLoopElement.length) { elements.gateLoopElement.prop("checked", gateLoop); } const filteredSvcKeys = sortKeysByServiceNames(writableDatastreams); // Log to system console for debugging console.log("Write Data Streams: Available datastreams for write selection:", filteredSvcKeys); console.log("Write Data Streams: Total count:", filteredSvcKeys.length); if (filteredSvcKeys.length > 0) { console.log("Write Data Streams: First 10 datastreams:", filteredSvcKeys.slice(0, 10)); } clearDropdown(keySelectElement, { text: "Select data stream..." }); // Populate the key selection dropdown with available keys filteredSvcKeys.forEach((key) => { const serviceName = writableDatastreams[key]?.servicename || filteredServices[key]?.servicename || key; const text = `${serviceName} (${key})`; const el = $(`<option value="${key}" ${selectedKey === key ? "selected" : ""}>${text}</option>`); keySelectElement.append(el); }); // Explicitly set the dropdown value if selectedKey is provided if (selectedKey) { keySelectElement.val(selectedKey); } updateKeyName(elements); validateKeyName(elements); // Pass row to populateIndex so it can use row.index as fallback populateIndex(elements, row); // Also explicitly set the value after populateIndex (matching read node pattern) updateIndex(elements, row); validateIndex(elements); populateChannelType(elements, row); // Auto-determines and sets channel type (read-only) updateChannelType(elements, row); validateChannelType(elements); populatePayload(elements); validatePayload(elements); updateCoefficient(elements); validateCoefficient(elements); // Auto-generate topic from key and index after both are set // Use a small delay to ensure index dropdown value is set in DOM setTimeout(() => { updateTopic(elements); validateTopic(elements); }, 10); } // Attach events to the row elements function attachRowEvents(elements = {}, container) { elements.manualElement.on("change", function () { updateKeyName(elements); validateKeyName(elements); populateIndex(elements, {}); validateIndex(elements); populateChannelType(elements, {}); validateChannelType(elements); populatePayload(elements); validatePayload(elements); updateCoefficient(elements); validateCoefficient(elements); updateTopic(elements); validateTopic(elements); refreshPlusButtonFor(container); }); elements.keySelectElement.on("change", function () { validateKeyName(elements); populateIndex(elements, {}); validateIndex(elements); populateChannelType(elements, {}); // Auto-determine channel type validateChannelType(elements); populatePayload(elements); validatePayload(elements); updateCoefficient(elements); validateCoefficient(elements); updateTopic(elements); validateTopic(elements); refreshPlusButtonFor(container); }); elements.keyManualElement.on("change input", function () { validateKeyName(elements); populateChannelType(elements, {}); validateChannelType(elements); populatePayload(elements); validatePayload(elements); updateCoefficient(elements); validateCoefficient(elements); updateTopic(elements); validateTopic(elements); refreshPlusButtonFor(container); }); elements.indexElement.on("change", function () {