@janart19/node-red-fusebox
Version:
A comprehensive collection of custom nodes for interfacing with Fusebox automation controllers - data streams, energy management, and utilities
380 lines (320 loc) • 16.9 kB
HTML
<script type="text/javascript">
RED.nodes.registerType("fusebox-flow-validator", {
category: "fusebox utils",
color: "#E8E8E8",
icon: "font-awesome/fa-check-circle",
defaults: {
name: { value: "" },
enabled: { value: true },
includeTabs: { value: "" },
excludeTabs: { value: "" }
},
inputs: 0,
outputs: 0,
label: function () {
return this.name || "flow validator";
},
paletteLabel: "flow validator",
oneditprepare: function () {
const node = this;
// Initialize UI elements
const enabledCheckbox = $("#node-input-enabled");
// Validate button
const testButton = $('<button type="button" id="test-validation" class="red-ui-button red-ui-button-small" style="margin-top: 10px;">Validate</button>');
enabledCheckbox.closest(".form-row").after(testButton);
// Results display area
const resultsDiv = $(
'<div id="validation-results" style="margin-top: 10px; padding: 10px; border: 1px solid #ccc; border-radius: 3px; background-color: #f9f9f9; max-height: 200px; overflow-y: auto;"></div>'
);
testButton.after(resultsDiv);
// Validate function
function runValidation() {
// Check if validation is enabled
const enabled = $("#node-input-enabled").is(":checked");
if (!enabled) {
const resultsDiv = $("#validation-results");
resultsDiv.html('<div style="color: grey; font-style: italic;">⛔ Validation is disabled</div>');
return;
}
const results = validateFlowTopics();
displayResults(results);
}
// Display validation results
function displayResults(results) {
const resultsDiv = $("#validation-results");
resultsDiv.empty();
if (results.warnings.length === 0 && results.errors.length === 0) {
resultsDiv.html('<div style="color: green; font-weight: bold;">✅ All topics validated successfully!</div>');
return;
}
let html = "";
if (results.errors.length > 0) {
html += '<div style="color: red; font-weight: bold;">❌ Errors:</div>';
results.errors.forEach((error) => {
html += `<div style="color: red; margin-left: 10px;">${error}</div>`;
});
}
if (results.warnings.length > 0) {
html += '<div style="color: orange; font-weight: bold;">⚠️ Warnings:</div>';
results.warnings.forEach((warning) => {
html += `<div style="color: orange; margin-left: 10px;">${warning}</div>`;
});
}
resultsDiv.html(html);
}
// Validation logic - UNIVERSAL APPROACH
function validateFlowTopics() {
console.log("Flow Validator HTML: Starting validation...");
const publishers = new Map(); // topic -> [node1, node2, ...]
const listeners = new Map(); // topic -> [node1, node2, ...]
const errors = [];
const warnings = [];
// Get filter settings from form
const includeTabs = $("#node-input-includeTabs")
.val()
.split(",")
.map((t) => t.trim())
.filter((t) => t);
const excludeTabs = $("#node-input-excludeTabs")
.val()
.split(",")
.map((t) => t.trim())
.filter((t) => t);
// Collect all existing tab names
const existingTabs = new Set();
RED.nodes.eachWorkspace(function (ws) {
existingTabs.add(ws.label);
});
// Validate filter tab names exist
includeTabs.forEach((tabName) => {
if (!existingTabs.has(tabName)) {
errors.push(`• Include filter error: Tab "${tabName}" does not exist`);
}
});
excludeTabs.forEach((tabName) => {
if (!existingTabs.has(tabName)) {
errors.push(`• Exclude filter error: Tab "${tabName}" does not exist`);
}
});
// If there are filter errors, return early
if (errors.length > 0) {
return { errors, warnings };
}
// Scan all nodes for publishers and listeners - UNIVERSAL APPROACH
RED.nodes.eachNode(function (scanNode) {
// Skip disabled flows by default
if (scanNode.z) {
// scanNode.z is the tab ID
const tab = RED.nodes.workspace(scanNode.z);
if (tab && tab.disabled) {
return; // Skip nodes in disabled flows
}
// Tab filtering: skip nodes not in included tabs or in excluded tabs
const tabName = tab ? tab.label : scanNode.z;
// Skip if includeTabs is specified and this tab is not included
if (includeTabs.length > 0 && !includeTabs.includes(tabName)) {
return;
}
// Skip if excludeTabs is specified and this tab is excluded
if (excludeTabs.length > 0 && excludeTabs.includes(tabName)) {
return;
}
}
// Generic topic extraction - works for any node type
const topics = [];
// Extract topics from common node properties
if (scanNode.topic) topics.push(scanNode.topic);
if (scanNode.roomTickTopic) topics.push(scanNode.roomTickTopic);
if (scanNode.globalTickTopic) topics.push(scanNode.globalTickTopic);
if (scanNode.setpointTopic) topics.push(scanNode.setpointTopic);
if (scanNode.returnTopic) topics.push(scanNode.returnTopic);
if (scanNode.forceOpenTopic) topics.push(scanNode.forceOpenTopic);
if (scanNode.forceCloseTopic) topics.push(scanNode.forceCloseTopic);
if (scanNode.valveCmdTopic) topics.push(scanNode.valveCmdTopic);
if (scanNode.coolingModeTopic) topics.push(scanNode.coolingModeTopic);
if (scanNode.dewPointTopic) topics.push(scanNode.dewPointTopic);
if (scanNode.tRetOut) topics.push(scanNode.tRetOut);
// UNIVERSAL: Determine publisher/listener based on node configuration and wiring
// A node is a PUBLISHER if:
// 1. It has outputs (can send messages)
// 2. It has topics configured (will publish to those topics)
// 3. It's not a pure input node (has some output capability)
// A node is a LISTENER if:
// 1. It has inputs (can receive messages)
// 2. It has topics configured (will listen to those topics)
// 3. It's not a pure output node (has some input capability)
const hasOutputs = scanNode.outputs > 0;
const hasInputs = scanNode.inputs > 0;
const hasTopics = topics.length > 0;
// Universal logic: if it has outputs and topics, it's a publisher
// if it has inputs and topics, it's a listener
const isPublisher = hasOutputs && hasTopics;
const isListener = hasInputs && hasTopics;
// Add topics to appropriate maps
topics.forEach((topic) => {
if (topic && topic.trim()) {
if (isPublisher) {
publishers.set(topic, [...(publishers.get(topic) || []), scanNode]);
}
if (isListener) {
listeners.set(topic, [...(listeners.get(topic) || []), scanNode]);
}
}
});
});
// UNIVERSAL: Check for publisher-listener mismatches
const unlistenedTopics = [];
const unpublishedTopics = [];
publishers.forEach((nodes, topic) => {
if (!listeners.has(topic)) {
const nodeNames = nodes.map((n) => {
const tab = n.z ? RED.nodes.workspace(n.z) : null;
const tabName = tab ? tab.label : n.z || "unknown";
return `${n.name || n.id} (${tabName})`;
});
unlistenedTopics.push(`• Topic '${topic}' is published by ${nodes.length} node(s), but has no listeners:`);
nodeNames.forEach((nodeName) => {
unlistenedTopics.push(` • ${nodeName}`);
});
}
});
listeners.forEach((nodes, topic) => {
if (!publishers.has(topic)) {
const nodeNames = nodes.map((n) => {
const tab = n.z ? RED.nodes.workspace(n.z) : null;
const tabName = tab ? tab.label : n.z || "unknown";
return `${n.name || n.id} (${tabName})`;
});
unpublishedTopics.push(`• Topic '${topic}' is listened by ${nodes.length} node(s), but has no publishers:`);
nodeNames.forEach((nodeName) => {
unpublishedTopics.push(` • ${nodeName}`);
});
}
});
// Add warnings in organized format
if (unlistenedTopics.length > 0) {
warnings.push(`1) Unlistened topics published (${unlistenedTopics.length}):`);
warnings.push(...unlistenedTopics.map((topic) => ` ${topic}`));
}
if (unpublishedTopics.length > 0) {
warnings.push(`2) Unpublished topics listened (${unpublishedTopics.length}):`);
warnings.push(...unpublishedTopics.map((topic) => ` ${topic}`));
}
return { errors, warnings };
}
// Bind test button
testButton.on("click", runValidation);
// Re-run validation when filters change
$("#node-input-enabled").on("change", runValidation);
// Run initial validation
runValidation();
}
});
</script>
<!-- Define style for the form fields -->
<style type="text/css">
.flow-validator-div .form-row {
margin-bottom: 10px;
}
.flow-validator-div .form-row label {
width: 33% ;
vertical-align: middle;
}
.flow-validator-div .form-row div,
.flow-validator-div .form-row input,
.flow-validator-div .form-row textarea {
max-width: 66% ;
}
.flow-validator-div .help-text {
font-size: 0.8em;
color: #666;
margin-top: 1px;
margin-bottom: 6px;
}
.flow-validator-div .form-divider {
border-top: 1px solid #ccc;
margin: 5px 0;
}
.flow-validator-div #validation-results {
font-family: monospace;
font-size: 0.95em;
white-space: pre-wrap;
}
/* Style Validate button */
.flow-validator-div .red-ui-button-small {
height: 24px ;
font-size: 14px ;
border-radius: 5px ;
padding: 0 5px ;
background-color: #f9f9f9 ;
}
</style>
<!-- Form fields for the node -->
<script type="text/html" data-template-name="fusebox-flow-validator">
<div class="flow-validator-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 class="help-text">Configuration name for this flow validator.</div>
</div>
<div class="form-divider"></div>
<b>Validation Settings</b>
<div class="form-row">
<label for="node-input-enabled"><i class="fa fa-toggle-on"></i> Enabled</label>
<input type="checkbox" id="node-input-enabled" />
<div class="help-text">Enable flow validation.</div>
</div>
<div class="form-divider"></div>
<b>Validation Scope</b>
<div class="form-row">
<label for="node-input-includeTabs"><i class="fa fa-filter"></i> Include tabs (optional)</label>
<input type="text" id="node-input-includeTabs" placeholder="tab1,tab2,tab3" />
<div class="help-text">Comma-separated list of tab names to include in validation. Leave empty to validate all enabled tabs.</div>
<div class="help-text"><b>Note:</b> Disabled flows are automatically excluded.</div>
</div>
<div class="form-row">
<label for="node-input-excludeTabs"><i class="fa fa-ban"></i> Exclude tabs (optional)</label>
<input type="text" id="node-input-excludeTabs" placeholder="tab1,tab2,tab3" />
<div class="help-text">Comma-separated list of tab names to exclude from validation. Leave empty to validate all tabs.</div>
</div>
</div>
</script>
<!-- Define node description -->
<script type="text/html" data-help-name="fusebox-flow-validator">
<h3>Universal Flow Validator</h3>
<p>Validates any Node-RED flow to ensure proper publisher-listener relationships. Universal and reusable across different installations.</p>
<h4>Core Features</h4>
<ul>
<li><b>Universal Publisher-Listener Matching:</b> Ensures all topics have both publishers and listeners</li>
<li><b>Automatic Topic Discovery:</b> Scans all node types and extracts topics automatically</li>
<li><b>Deploy-Time Validation:</b> Runs automatically during flow deployment</li>
<li><b>Custom Pattern Support:</b> Optional regex patterns for additional validation</li>
</ul>
<h4>How It Works</h4>
<ul>
<li><b>Publisher Detection:</b> Identifies nodes that publish topics (inject, mqtt out, room-loop, etc.)</li>
<li><b>Listener Detection:</b> Identifies nodes that listen to topics (mqtt in, floor-loop, function, debug, etc.)</li>
<li><b>Topic Matching:</b> Ensures every published topic has at least one listener</li>
<li><b>Orphan Detection:</b> Finds topics with publishers but no listeners, or listeners but no publishers</li>
</ul>
<h4>Universal Application</h4>
<ul>
<li><b>Heating Systems:</b> Validates room-loop → floor-loop topic relationships</li>
<li><b>MQTT Networks:</b> Ensures MQTT publishers have subscribers</li>
<li><b>Sensor Networks:</b> Validates sensor data flow</li>
<li><b>Any Custom System:</b> Works with any Node-RED topic-based architecture</li>
</ul>
<h4>Usage</h4>
<ol>
<li>Add this configuration node to your flow</li>
<li>Configure validation settings (optional custom patterns)</li>
<li>Test validation using the "Validate" button</li>
<li>Deploy flows - validation runs automatically if enabled</li>
</ol>
<h4>Validation Results</h4>
<ul>
<li><b>Warnings:</b> Topics with mismatched publishers/listeners</li>
<li><b>Errors:</b> Invalid custom patterns (if used)</li>
<li><b>Success:</b> All topics have proper publisher-listener relationships</li>
</ul>
</script>