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