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,149 lines (991 loc) 54.4 kB
<script type="text/javascript"> RED.nodes.registerType("fusebox-read-data-streams", { category: "fusebox", color: "#57db83", defaults: { name: { value: "" }, controller: { value: "", type: "fusebox-controller", required: true }, outputMode: { value: "", validate: (v) => ["all", "change"].includes(v) }, msgType: { value: "", validate: (v) => ["separate", "together", "split"].includes(v) }, mappings: { value: [] }, // Store multiple mappings outputs: { value: 1 } // Node output ports }, // Define the inputs of the node inputs: 1, icon: "node-red/switch.svg", label: function () { const n = (this.mappings || []).length; return this.name || (n ? `parse data streams: ${n}` : "parse data streams"); }, paletteLabel: () => "parse data streams", outputLabels: function (i) { return this.msgType === "split" && this.mappings && this.mappings[i] ? this.mappings[i].topic || `output #${i + 1}` : null; }, // Update form fields 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-msgType").val(node.msgType); $("#node-input-outputMode").val(node.outputMode); // Load controller data $("#node-input-controller").change(queryControllerConfig); // Define event listeners for form fields $("#node-input-msgType").change(defineOutputPorts); $("#mappings-container, #node-input-msgType, #node-input-outputMode").change(updateTipText); // Initialize the form fields initializeEditableList(); defineOutputPorts(); function defineOutputPorts() { const msgType = $("#node-input-msgType").val(); const items = $("#mappings-container").editableList("items"); node.outputs = msgType === "split" ? items.length : 1; } // 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((d) => { _controller = d; }) .fail(() => { 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); }); } let currentSortColumn = "datastream"; function initializeEditableList() { $("#mappings-container").editableList({ removable: true, sortable: true, header: $("<div>").append( $.parseHTML(` <div style="width:22px"></div> <div class="read-data-streams-list" style="text-align:center;white-space:normal;flex:1"> <div class="checkbox-container">Manual entry</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-dataType">Data type</div> <div class="node-input-index">Member index</div> <div class="node-input-coefficient">Coefficient</div> <span class="node-input-arrow">&nbsp;&nbsp;</span> <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> <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 () { $("#mappings-container").editableList("empty"); defineOutputPorts(); updateTipText(); } }, { label: "auto-generate entries", icon: "fa fa-refresh", title: "automatically generate rows for all available controller data streams", click: function () { const hasServices = _controller.filteredServices && Object.keys(_controller.filteredServices).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); updateTipText(); defineOutputPorts(); refreshPlusButtonFor(container); // ensure button shows/updates }, removeItem: function (data) { updateTipText(); defineOutputPorts(); refreshAllPlusButtons(); } }); 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 triggerSort() { const items = $("#mappings-container").editableList("items"); const data = []; items.each(function () { const c = $(this), e = getRowElements(c); data.push({ manual: e.manualElement.is(":checked"), keyNameSelect: e.keySelectElement.val(), keyNameManual: e.keyManualElement.val(), dataType: e.dataTypeElement.val(), index: parseInt(e.indexElement.val()) || 0, coefficient: parseFloat(e.coefficientElement.val()) || 1, topic: e.topicElement.val() }); }); data.sort((a, b) => { let cmp = 0; if (currentSortColumn === "datastream") { const an = (a.manual ? a.keyNameManual : a.keyNameSelect) || ""; const bn = (b.manual ? b.keyNameManual : b.keyNameSelect) || ""; cmp = an.toLowerCase().localeCompare(bn.toLowerCase()); } else { cmp = (a.topic || "").toLowerCase().localeCompare((b.topic || "").toLowerCase()); } if (cmp === 0) cmp = a.index - b.index; return cmp; }); $("#mappings-container").editableList("empty"); $("#mappings-container").editableList("addItems", data); refreshAllPlusButtons(); } function initializeAutocomplete(element, onSelect) { element .autocomplete({ minLength: 0, source: function (req, res) { const term = req.term.toLowerCase(); const sel = getTopics(); res(sel?.filter((o) => o.label.toLowerCase().includes(term)) || []); }, focus: (e) => e.preventDefault(), select: function (e, ui) { e.preventDefault(); element.val(ui.item.topic); 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 lab = term ? item.label.replace( new RegExp("(" + term.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&") + ")", "ig"), '<strong style="color:#e65;font-weight:bold">$1</strong>' ) : item.label; return $("<li></li>").data("ui-autocomplete-item", item).append(`<div>${lab}</div>`).appendTo(ul); }; } // Return a list of topics from the controller configuration // Structure: [ { key: "ABC", member: 1, topic: test/1, label: ... } ] function getTopics() { const allTopics = _controller?.formattedTopics || []; const rows = getListElements(); const topics = rows.map((row) => row.topicElement.val()); // Only include topics that are not already used const filteredTopics = allTopics.filter((topic) => { return !topics.includes(topic.topic); }); 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, member = obj.member; const manual = elements.manualElement.is(":checked"); if (manual || !key || !member) return; if (topicRelatedToService(`${key}.${member}`)) { elements.keySelectElement.val(key).trigger("change"); elements.dataTypeElement.val("value").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, mem] = topic.split("."); const m = parseInt(mem); return fs?.[key] && Number.isInteger(m) && m >= 1 && m <= 16; } // Append the row to the container function addRowElements(container, row) { const rowHtml = ` <div class="read-data-streams-list"> <div class="checkbox-container"> <input type="checkbox" class="node-input-manual" ${row.manual === true ? "checked" : ""}> <div class="helper-text"></div> </div> <select class="node-input-keyNameSelect"></select> <input type="text" class="node-input-keyNameManual" placeholder="Manual entry, e.g. ABCW" value="${row.keyNameManual || ""}"> <select class="node-input-dataType"> <option value="">Data type...</option> <option value="value" ${row.dataType === "value" ? "selected" : ""}>Value</option> <option value="status" ${row.dataType === "status" ? "selected" : ""}>Status</option> <option value="timestamp" ${row.dataType === "timestamp" ? "selected" : ""}>Timestamp</option> </select> <input type="number" class="node-input-index" placeholder="Member" value="${row.index || ""}" min="1" max="16"> <input type="number" class="node-input-coefficient" placeholder="Coef." value="${row.coefficient || ""}" step="0.01"> <span class="node-input-arrow">&#8594;</span> <input type="text" class="node-input-topic" placeholder="Topic" value="${row.topic || ""}"> <div class="row-actions"></div> </div>`; container.append(rowHtml); } function refreshPlusButtonFor(container) { // remove any existing container.find(".add-member-dropdown").remove(); const el = getRowElements(container); const manual = el.manualElement.is(":checked"); const key = manual ? el.keyManualElement.val() : el.keySelectElement.val(); const idx = parseInt(el.indexElement.val(), 10); if (manual || !key || !Number.isInteger(idx)) return; // Get all members for this datastream const allUsedForThisKey = getUsedIndicesForDataStream(key); const allAvailableForThisKey = _controller?.channels?.[key] ? Object.values(_controller.channels[key]) .map((m) => parseInt(m.member, 10)) .sort((a, b) => a - b) : []; const missingForThisKey = allAvailableForThisKey.filter((i) => !allUsedForThisKey.includes(i)); console.log(`DEBUG: ${key}.${idx} - Calculating missing members:`, { allUsedForThisKey: allUsedForThisKey, allAvailableForThisKey: allAvailableForThisKey, missingForThisKey: missingForThisKey, missingCount: missingForThisKey.length, // Detailed comparison usedDetails: allUsedForThisKey.map((i) => ({ value: i, type: typeof i })), availableDetails: allAvailableForThisKey.map((i) => ({ value: i, type: typeof i })), filterTest: allAvailableForThisKey.map((i) => ({ value: i, includes: allUsedForThisKey.includes(i), strictEquals: allUsedForThisKey.some((used) => used === i) })) }); if (missingForThisKey.length === 0) return; // All members added, no plus button needed // Only show plus button on the LAST member of this datastream const maxUsedIndex = Math.max(...allUsedForThisKey); if (idx !== maxUsedIndex) return; // Not the last member, no plus button // Find the next missing member after the current index const nextMissingIndex = missingForThisKey.find((i) => i > idx); if (!nextMissingIndex) return; // No next member available console.log(`Creating dropdown for ${key}.${idx} (last member):`, { missingForThisKey: missingForThisKey, nextMissingIndex: nextMissingIndex, allUsedForThisKey: allUsedForThisKey, maxUsedIndex: maxUsedIndex }); // Create dropdown for member selection 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); container.find(".row-actions").append(dropdown); dropdown.on("change", function (e) { e.preventDefault(); e.stopPropagation(); const action = $(this).val(); if (action === "next") { // Find the member object for the next missing index const availableMembers = findAvailableMembers(key, idx); const nextMember = availableMembers.find((m) => m.index === nextMissingIndex); if (nextMember) { addNextMember(container, { keyNameSelect: key, dataType: el.dataTypeElement.val() }, nextMember); } } else if (action === "all") { // Use only the actually missing members const availableMembers = findAvailableMembers(key, idx); const allMissingMembers = missingForThisKey.map((index) => availableMembers.find((m) => m.index === index)).filter((m) => m); // Remove undefined entries console.log(`DEBUG: Adding all missing members for ${key}:`, { missingForThisKey: missingForThisKey, availableMembers: availableMembers, allMissingMembers: allMissingMembers, allMissingMembersCount: allMissingMembers.length }); addAllMissingMembers(container, { keyNameSelect: key, dataType: el.dataTypeElement.val() }, allMissingMembers); } $(this).val(""); // Reset dropdown }); } function refreshAllPlusButtons() { $("#mappings-container") .editableList("items") .each(function () { refreshPlusButtonFor($(this)); }); } function findAvailableMembers(keyNameSelect, currentIndex) { if (!keyNameSelect) return []; const members = _controller?.channels?.[keyNameSelect] || {}; const avail = Object.values(members) .map((m) => parseInt(m.member, 10)) .sort((a, b) => a - b); const used = getUsedIndicesForDataStream(keyNameSelect); // Find ALL missing members (not just those after current index) const availableIndices = avail.filter((i) => !used.includes(i)); return availableIndices.map((index) => { const member = Object.values(members).find((m) => parseInt(m.member, 10) === index); return { index: index, desc: member?.desc || "" }; }); } function findNextAvailableMember(keyNameSelect, currentIndex) { const availableMembers = findAvailableMembers(keyNameSelect, currentIndex); // Return the first available member (lowest index) return availableMembers.length > 0 ? availableMembers[0] : null; } function getUsedIndicesForDataStream(key) { const used = []; $("#mappings-container") .editableList("items") .each(function () { const c = $(this), e = getRowElements(c); const manual = e.manualElement.is(":checked"); const k = manual ? e.keyManualElement.val() : e.keySelectElement.val(); const i = parseInt(e.indexElement.val(), 10); if (k === key && Number.isInteger(i)) used.push(i); }); return used; } function addNextMember(container, currentRow, nextMember) { const key = currentRow.keyNameSelect; const svc = _controller?.filteredServices?.[key]; if (!svc) { RED.notify(`Service not found for data stream: ${key}`, { type: "error", timeout: 3000 }); return; } const newRow = { manual: false, keyNameSelect: key, keyNameManual: "", dataType: currentRow.dataType || "value", index: nextMember.index, coefficient: svc.conv_coef || 1, topic: `${key}.${nextMember.index}` }; $("#mappings-container").editableList("addItems", [newRow]); defineOutputPorts(); updateTipText(); setTimeout(() => { triggerSort(); setTimeout(refreshAllPlusButtons, 200); // Additional delay after sorting }, 100); RED.notify(`Added member ${nextMember.index}${nextMember.desc ? ` (${nextMember.desc})` : ""} for ${key}`, { type: "success", timeout: 3000 }); } function addAllMissingMembers(container, currentRow, availableMembers) { const key = currentRow.keyNameSelect; const svc = _controller?.filteredServices?.[key]; if (!svc) { RED.notify(`Service not found for data stream: ${key}`, { type: "error", timeout: 3000 }); return; } // Double-check: only add members that are actually missing const currentlyUsed = getUsedIndicesForDataStream(key); const actuallyMissing = availableMembers.filter((member) => !currentlyUsed.includes(member.index)); console.log(`DEBUG: addAllMissingMembers for ${key}:`, { availableMembers: availableMembers, currentlyUsed: currentlyUsed, actuallyMissing: actuallyMissing, actuallyMissingCount: actuallyMissing.length }); if (actuallyMissing.length === 0) { RED.notify(`All members for ${key} are already added`, { type: "warning", timeout: 3000 }); return; } const newRows = actuallyMissing.map((member) => ({ manual: false, keyNameSelect: key, keyNameManual: "", dataType: currentRow.dataType || "value", index: member.index, coefficient: svc.conv_coef || 1, topic: `${key}.${member.index}` })); console.log( `Adding ${newRows.length} missing members for ${key}:`, newRows.map((r) => r.topic) ); $("#mappings-container").editableList("addItems", newRows); defineOutputPorts(); updateTipText(); setTimeout(() => { triggerSort(); setTimeout(refreshAllPlusButtons, 200); // Additional delay after sorting }, 100); RED.notify(`Added ${newRows.length} members for ${key}`, { type: "success", timeout: 3000 }); } // Get references to the created elements function getRowElements(container) { return { manualElement: container.find(".node-input-manual"), keySelectElement: container.find(".node-input-keyNameSelect"), keyManualElement: container.find(".node-input-keyNameManual"), dataTypeElement: container.find(".node-input-dataType"), indexElement: container.find(".node-input-index"), coefficientElement: container.find(".node-input-coefficient"), topicElement: container.find(".node-input-topic") }; } // Format the newly created / intialized row's elements function formatRow(e, row) { const fs = _controller.filteredServices || {}; const keyNames = sortKeysByServiceNames(fs); const sel = e.keySelectElement; const selectedKey = row.keyNameSelect; sel.empty().append($(`<option value="">Select data stream...</option>`)); keyNames.forEach((key) => { const name = fs[key]?.servicename || "???"; sel.append($(`<option value="${key}" ${selectedKey === key ? "selected" : ""}>${name} (${key})</option>`)); }); updateKeySelect(e); validateKeyName(e); populateIndex(e); updateIndex(e, row); validateIndex(e); validateDataType(e); updateCoefficient(e); validateCoefficient(e); validateTopic(e); } // Attach events to the row elements function attachRowEvents(e, container) { e.manualElement.on("change", function () { updateKeySelect(e); validateKeyName(e); populateIndex(e); updateIndex(e); validateIndex(e); updateCoefficient(e); validateCoefficient(e); refreshPlusButtonFor(container); }); e.keySelectElement.on("change", function () { validateKeyName(e); populateIndex(e); validateIndex(e); updateCoefficient(e); validateCoefficient(e); updateTopic(e); validateTopic(e); refreshPlusButtonFor(container); }); e.keyManualElement.on("change input", function () { populateIndex(e); validateIndex(e); updateCoefficient(e); validateCoefficient(e); refreshPlusButtonFor(container); }); e.dataTypeElement.on("change", function () { populateIndex(e); updateIndex(e); validateIndex(e); validateDataType(e); updateCoefficient(e); validateCoefficient(e); refreshPlusButtonFor(container); }); e.indexElement.on("input", function () { validateIndex(e); refreshPlusButtonFor(container); }); e.coefficientElement.on("input", function () { validateCoefficient(e); }); e.topicElement.on("input", function () { validateTopic(e); }); } // Hide the service key selection dropdown if manual is checked function updateKeySelect(e) { const manual = e.manualElement.is(":checked"); const helper = e.manualElement.closest(".checkbox-container").find(".helper-text"); if (manual) { e.keySelectElement.val("").hide(); e.keyManualElement.show(); helper.text("manual"); } else { e.keyManualElement.val("").hide(); e.keySelectElement.show(); helper.text("from controller"); } } // Hide the index field if the type is not 'value' function updateIndex(e, row = {}) { const dt = e.dataTypeElement.val(); const prev = row.index || ""; if (dt === "value") { e.indexElement.prop("disabled", false).val(prev); } else { e.indexElement.prop("disabled", true).val(""); } } // Update coefficient field based on the selected key function updateCoefficient(e) { const dt = e.dataTypeElement.val(); const ks = e.keySelectElement.val(); const km = e.keyManualElement.val(); const fs = _controller.filteredServices || {}; const k = ks || km; if (k && fs[k]) { e.coefficientElement.val(fs[k]?.conv_coef || 1).prop("disabled", true); } else { if (!e.coefficientElement.val()) { e.coefficientElement.val("1"); } e.coefficientElement.prop("disabled", false); } if (dt !== "value") { e.coefficientElement.val("").prop("disabled", true); } } // Update the index element based on the selected key function populateIndex(e) { const dt = e.dataTypeElement.val(); if (dt !== "value") return; const manual = e.manualElement.is(":checked"); const ks = e.keySelectElement.val(); const km = e.keyManualElement.val(); const activeKey = manual ? km : ks; let el = e.indexElement; const prev = el.val(); // Check if element already has a data-id const existingId = el.attr("data-id"); // Use existing ID if available, otherwise generate a new one const id = existingId || Math.random().toString(36).substr(2, 9); const members = !manual ? _controller?.channels?.[ks] : undefined; if (!manual && ks && members) { const options = Object.values(members).map((m) => { const idx = m.member; const desc = findValueBySvcNameAndMember(_controller.channels, ks, idx, "desc"); return `<option value="${idx}" ${prev == idx ? "selected" : ""}>${idx}${desc ? ` (${desc})` : ``}</option>`; }); el.replaceWith(`<select data-id="${id}" class="node-input-index">${options.join("")}</select>`); } else { const keyForLimits = activeKey || ""; const minMax = keyForLimits.endsWith("S") ? 'min="1" max="1"' : 'min="1" max="32"'; el.replaceWith(`<input data-id="${id}" type="number" class="node-input-index" placeholder="Member" value="${el.val()}" ${minMax}>`); } el = e.indexElement = $(`.node-input-index[data-id="${id}"]`); // Update the reference using the custom attribute // Reattach the event listener to the new element el.on("change", function () { updateTipText(); updateTopic(e); validateTopic(e); refreshPlusButtonFor(el.closest(".red-ui-editableList-item-content")); }); el.on("input", function () { validateIndex(e); const container = el.closest(".red-ui-editableList-item-content"); if (container.length) { refreshPlusButtonFor(container); } }); } // Update the topic element based on the selected key and member function updateTopic(e) { const manual = e.manualElement.is(":checked"); const keyManual = e.keyManualElement.val(); const keySelect = e.keySelectElement.val(); const key = manual ? keyManual : keySelect; const idx = e.indexElement.val(); const t = e.topicElement.val(); if (!key || !idx) return; // Default topic format: "key.member" if (t === "" || (!manual && topicRelatedToService(t))) { e.topicElement.val(`${key}.${idx}`); } } // Update tip text based on current settings function updateTipText() { const rows = getListElements(); const topics = rows.map((r) => r.topicElement.val()); const topic1 = topics[0] || "topic1"; const topic2 = topics[1] || "topic2"; const msgType = $("#node-input-msgType").val(); $("#node-input-tip-text-1").text(`Routing ${rows.length} values from controller (${_controller.uniqueId || "???"})'s data streams.`); $("#node-input-tip-text-2").text(`The payload will be divided by the coefficient (if applicable).`); $("#node-input-tip-text-3").text( `Outgoing message format: ${msgType === "together" ? `{"${topic1}": float, "${topic2}": int, ...}` : `{"topic": str, "payload": float}`}` ); } // Custom validation functions below function validateKeyName(e) { const manual = e.manualElement.is(":checked"); const ks = e.keySelectElement.val(); const km = e.keyManualElement.val(); const invalid = ["", undefined, null]; let ok; if (manual) { ok = !invalid.includes(km); e.keyManualElement.css("border-color", ok ? "" : "red"); e.keySelectElement.css("border-color", ""); } else { ok = !invalid.includes(ks); e.keySelectElement.css("border-color", ok ? "" : "red"); e.keyManualElement.css("border-color", ""); } } function validateIndex(e) { const idx = e.indexElement.val(), dt = e.dataTypeElement.val(); const ok = dt !== "value" || isValidIndex(idx, e); e.indexElement.css("border-color", ok ? "" : "red"); } function validateCoefficient(e) { const c = e.coefficientElement.val(), dt = e.dataTypeElement.val(); const ok = dt !== "value" || isValidFloat(c); e.coefficientElement.css("border-color", ok ? "" : "red"); } function validateDataType(e) { const ok = ["value", "status", "timestamp"].includes(e.dataTypeElement.val()); e.dataTypeElement.css("border-color", ok ? "" : "red"); } function validateTopic(e) { const t = e.topicElement.val(); const ok = !(t === "" || t === undefined || t === null); e.topicElement.css("border-color", ok ? "" : "red"); } function isValidIndex(i, elements) { const n = parseInt(i); if (!Number.isInteger(n)) return false; // Get the datastream key to check validation rules const manual = elements.manualElement.is(":checked"); const key = manual ? elements.keyManualElement.val() : elements.keySelectElement.val(); if (!key) return false; // If key ends with 'S', only index 0 is allowed if (key.endsWith("S")) { return n === 1; } // For other keys, index must be 1-32 return n >= 1 && n <= 32; } function isValidFloat(v) { return !isNaN(parseFloat(v)); } // Data stream helper functions below // Sort alphabetically, all keys starting with "_" are to be in the end function sortKeysByServiceNames(obj) { const ks = Object.keys(obj); const a = ks.filter((k) => !obj[k]?.servicename.startsWith("_")); const b = ks.filter((k) => obj[k]?.servicename.startsWith("_")); a.sort((x, y) => obj[x]?.servicename?.localeCompare(obj[y]?.servicename)); b.sort((x, y) => obj[x]?.servicename?.localeCompare(obj[y]?.servicename)); return a.concat(b); } function findValueBySvcNameAndMember(ch = {}, name, member, key) { return ch?.[name]?.[member]?.[key] ?? null; } // Auto-generate mappings for all available services function autoGenerateMappings() { const fs = _controller.filteredServices || {}; const keys = sortKeysByServiceNames(fs); const list = []; // Create a mapping for each key and its members $("#mappings-container").editableList("empty"); keys.forEach((k) => { const svc = fs[k]; if (!svc) return; // Get all members for this service key const members = _controller?.channels?.[k] || {}; // Add an entry for each member Object.values(members).forEach((m) => { const idx = m.member; list.push({ manual: false, keyNameSelect: k, keyNameManual: "", dataType: "value", index: idx, coefficient: svc?.conv_coef || 1, topic: `${k}.${idx}` }); }); }); // Add all mappings to the list $("#mappings-container").editableList("addItems", list); defineOutputPorts(); updateTipText(); RED.notify("Generated " + list.length + " data stream mappings", { type: "success", timeout: 3000 }); refreshAllPlusButtons(); } // Iterate over each mapping row and store the elements function getListElements() { const out = []; $("#mappings-container") .editableList("items") .each(function () { out.push(getRowElements($(this))); }); return out; } }, oneditsave: function () { const node = this; node.name = $("#node-input-name").val(); node.controller = $("#node-input-controller").val(); node.outputMode = $("#node-input-outputMode").val(); node.msgType = $("#node-input-msgType").val(); const mappings = []; $("#mappings-container") .editableList("items") .each(function () { const c = $(this); mappings.push({ manual: c.find(".node-input-manual").is(":checked"), keyNameSelect: c.find(".node-input-keyNameSelect").val(), keyNameManual: c.find(".node-input-keyNameManual").val(), dataType: c.find(".node-input-dataType").val(), index: parseInt(c.find(".node-input-index").val()), coefficient: parseFloat(c.find(".node-input-coefficient").val()), topic: c.find(".node-input-topic").val() }); }); node.mappings = mappings; } }); </script> <!-- Define style for the form fields --> <style type="text/css"> .read-data-streams-div .form-row { margin-bottom: 10px; } .read-data-streams-div .form-row label { width: 33% !important; vertical-align: middle; } .read-data-streams-div .form-row div, .read-data-streams-div .form-row input, .read-data-streams-div .form-row select { max-width: 66% !important; } .read-data-streams-div .form-row select { width: 66% !important; } .read-data-streams-div .form-tips { max-width: 100% !important; text-align: center; } .read-data-streams-list { overflow: hidden; white-space: nowrap; display: flex; align-items: center; } .read-data-streams-list .checkbox-container { text-align: center; min-width: 35px; width: 10%; } .read-data-streams-list .helper-text { font-size: 10px; line-height: 12px; text-wrap: wrap; color: #666; } .read-data-streams-list .node-input-manual { min-width: 16px; min-height: 16px; } .read-data-streams-list .node-input-keyNameManual { font-size: 12px !important; min-width: 150px; width: 35%; } .read-data-streams-list .node-input-keyNameSelect { font-size: 12px; min-width: 150px; width: 35%; } .read-data-streams-list .node-input-dataType { font-size: 12px; min-width: 100px; width: 15%; } .read-data-streams-list .node-input-index { font-size: 12px !important; min-width: 225px; width: 30%; } .read-data-streams-list .node-input-coefficient { font-size: 12px !important; min-width: 65px; width: 10%; } .read-data-streams-list .node-input-arrow { font-size: 18px; margin-left: 10px; margin-right: 5px; } .read-data-streams-list .node-input-topic { font-size: 12px !important; min-width: 100px; width: 15%; } .read-data-streams-div .row-actions { display: inline-flex !important; align-items: center !important; gap: 4px !important; margin-left: 4px !important; width: 40px !important; justify-content: flex-start !important; } /* Style for header sort triangles */ .read-data-streams-list .sort-triangle { font-size: 10px; color: #999; transition: color 0.2s ease; } .read-data-streams-list .sort-triangle.active { color: #2196f3; font-weight: bold; } .read-data-streams-list .sort-triangle:hover { color: #666; } /* Autocomplete widget styling */ .ui-autocomplete { max-height: 250px; overflow-y: auto; overflow-x: hidden; z-index: 2000; background: #fff; border: 1px solid #ccc; border-radius: 3px; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); } .ui-menu-item { font-size: 12px; padding: 5px; cursor: pointer; position: relative; } .ui-menu-item:hover { background-color: #f5f5f5; } /* Style the add member button and dropdown */ .read-data-streams-div .add-member-dropdown { background: #4caf50 !important; border: none !important; color: white !important; padding: 0px 1px !important; border-radius: 2px !important; cursor: pointer !important; font-size: 12px !important; font-weight: bold !important; margin-right: 4px !important; width: 24px !important; min-width: 4px !important; text-align: center !important; height: 20px !important; line-height: 20px !important; -webkit-appearance: none !important; -moz-appearance: none !important; appearance: none !important; } .read-data-streams-div .add-member-dropdown option { background: white !important; color: black !important; } .read-data-streams-div .add-member-dropdown:hover { background: #45a049 !important; } /* Editable list style */ .read-data-streams-div .red-ui-editableList-header { background-color: #80808014; font-weight: bold; display: flex; } .read-data-streams-div .red-ui-editableList-container { min-height: 50px; } .read-data-streams-div .red-ui-editableList { margin-bottom: 10px; min-width: 650px; } /* Style row remove button: red with white X */ .read-data-streams-div .red-ui-editableList-item-remove { background: #d32f2f !important; color: white !important; } .read-data-streams-div .red-ui-editableList-item-remove:hover { background: #b71c1c !important; } .read-data-streams-div .red-ui-editableList-item-remove i { color: white !important; } /* Style buttons below the table */ .read-data-streams-div .red-ui-editableList-addButton { height: 30px !important; line-height: 20px !important; font-size: 12px !important; border-radius: 5px !important; padding: 0 10px !important; } </style> <!-- Form fields for the node --> <script type="text/html" data-template-name="fusebox-read-data-streams"> <div class="read-data-streams-div"> <div class="form-row"> <label for="node-input-name"><i class="fa fa-tag"></i> Name</label> <input type="text" id="node-input-name" placeholder="Name" /> </div> <div class="form-row"> <label for="node-input-controller"><i class="fa f