@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
HTML
<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"> </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">→</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