@janart19/node-red-fusebox
Version:
A collection of Fusebox-specific custom nodes for Node-RED
332 lines (272 loc) • 14.4 kB
JavaScript
// Implements a lamp control node that can handle multiple input channels
// Each input channel can have its own behavior, controlling the same output
// The output state is determined by combining the effects of all active inputs
// Optional delay measurement tracks time from input change to confirmed output change
module.exports = function (RED) {
function LampControlNode(config) {
RED.nodes.createNode(this, config);
const node = this;
node.name = config.name;
// Basic configuration
node.timeout = parseInt(config.timeout) || 0;
node.outputTopic = config.outputTopic;
node.mappings = config.mappings || [];
// State variables
node.actout = false; // Current output state
node.lastInputs = {}; // Track last state of each input
node.tsOn = 0; // When turned on
node.tsProcess = null; // When output change was requested (for delay measurement)
node.measureDelay = true; // Enable/disable delay measurement
node.pendingChange = null; // Track pending output change for delay measurement
// Constants for mtype bits
// These bits control the behavior of each input channel
const BITS = {
UP: 1, // bit 0: Respond to high level or rising edge
DOWN: 2, // bit 1: Respond to low level or falling edge
ON: 4, // bit 2: Output action bit 0
OFF: 8, // bit 3: Output action bit 1 (ON+OFF = invert)
EDGE: 16, // bit 4: Is this an edge trigger (change) rather than level
EXTEND: 32 // bit 5: Extend timeout when input triggers
};
// Listen for input messages
node.on("input", function (msg) {
// Find which input channel this message is for
const inputs = node.mappings.filter((mapping) => mapping.topic === msg.topic);
// In case of multiple topics with the same name, keep old value same for all
let oldValue = null;
if (inputs.length > 0) {
oldValue = node.lastInputs[msg.topic];
}
for (const input of inputs) {
// Verify payload is valid
if (!["string", "number", "boolean"].includes(typeof msg?.payload)) {
node.warn(`Invalid payload type for topic ${input.topic}`);
node.status({ fill: "yellow", shape: "dot", text: `Invalid payload type for ${input.topic}` });
return;
}
// Convert boolean payloads to 1/0
if (typeof msg.payload === "boolean") {
msg.payload = msg.payload ? 1 : 0;
}
// Extract value from input topic
const newValue = parseInt(msg.payload) === 1;
// Update cache with the new value
node.lastInputs[input.topic] = newValue;
node.debug(`Incoming input ${input.topic} value: ${oldValue} -> ${newValue}`);
// If no initialized level-trigger rows exist, skip processing
const overwriteExist = overwriteRowsExist();
const initializedOverwriteRows = findInitializedOverwriteRows();
if (overwriteExist && initializedOverwriteRows.length === 0) {
node.debug(`Discarded input ${input.topic} due to no initialized level-trigger rows`);
node.status({ fill: "grey", shape: "dot", text: `No initialized level-trigger rows (${formatDate()})` });
return;
}
// If a level-trigger input is active, and incoming msg topic is not related to that row, discard
const activeOverwrite = findActiveOverwriteRow();
if (activeOverwrite && activeOverwrite.topic !== input.topic) {
node.debug(`Discarded input ${input.topic} due to active level-trigger input`);
node.status({ fill: "grey", shape: "dot", text: `Discarded input due to active level-trigger (${formatDate()})` });
return;
}
// Calculate new output state based on the input
const oldOut = node.actout;
let newOut = calculateOutput(input, oldValue, newValue);
if (newOut !== null) {
if (newOut !== oldOut) {
node.actout = newOut;
// Reset timeout if output is turned ON
if (newOut) {
node.tsOn = Date.now();
node.debug(`Output turned ON, timeout set to ${node.timeout}s`);
}
// Record timestamp for delay measurement if enabled
if (node.measureDelay) {
node.tsProcess = Date.now();
node.pendingChange = newOut;
}
}
node.debug(`Output state changed: ${oldOut} -> ${newOut} (triggered by ${input.topic})`);
node.status({ fill: "green", shape: "dot", text: `Output ${newOut ? "ON" : "OFF"} (${formatDate()})` });
// Send output message
node.send({
topic: node.outputTopic,
payload: newOut ? 1 : 0
});
}
}
if (msg.topic === node.outputTopic && node.measureDelay && node.pendingChange !== null) {
// This is a response to our output change - measure the delay
const value = parseInt(msg.payload) === 1;
if (value === node.pendingChange) {
let delay = (Date.now() - node.tsProcess) / 1000;
node.debug(`Output change confirmed, delay: ${delay.toFixed(3)}s`);
node.pendingChange = null;
}
} else if (msg.topic !== node.outputTopic) {
// node.warn(`Unknown topic: ${msg.topic}`);
// node.status({ fill: "yellow", shape: "dot", text: `Unknown topic: ${msg.topic}` });
}
});
// Return whether at least one level-trigger (overwrite) row exists
function overwriteRowsExist() {
for (const mapping of node.mappings) {
const mtype = mapping.mtype;
const isEdgeTrigger = Boolean(mtype & BITS.EDGE);
// This is a level-based input that can overwrite other inputs
if (!isEdgeTrigger) {
return true;
}
}
return false;
}
// Find level-trigger rows that have received input values
function findInitializedOverwriteRows() {
const rows = [];
for (const mapping of node.mappings) {
const mtype = mapping.mtype;
const topic = mapping.topic;
const value = node.lastInputs[topic];
const isEdgeTrigger = Boolean(mtype & BITS.EDGE);
// This is a level-based input
if (!isEdgeTrigger) {
if (value !== undefined) {
rows.push(mapping);
}
}
}
return rows;
}
// Find the first active rows which overwrites other signals
function findActiveOverwriteRow() {
const rows = [];
for (const mapping of node.mappings) {
const topic = mapping.topic;
const mtype = mapping.mtype;
const value = node.lastInputs[topic];
// Check if this is a level (not edge) trigger
const isEdgeTrigger = Boolean(mtype & BITS.EDGE);
// It's a level-based input
if (!isEdgeTrigger) {
if (mtype & BITS.UP && value) {
rows.push(mapping);
} else if (mtype & BITS.DOWN && !value) {
rows.push(mapping);
}
}
}
if (rows.length === 0) {
return null; // No active level-trigger rows
}
// If multiple rows are active, return the first one
return rows[0];
}
// Calculate output based on all active inputs
// Each input can affect the output based on its configuration
function calculateOutput(input, oldValue, newValue) {
const mtype = input.mtype;
const oldOut = node.actout;
let newOut = null;
const hasChanged = oldValue !== newValue;
node.debug(`Processing ${input.topic}: value=${newValue}, mtype=${mtype}`);
// Check if this is an edge or level trigger
const isEdgeTrigger = Boolean(mtype & BITS.EDGE);
// Extract output action (ON, OFF, or INVERT)
const outputAction = (mtype >> 2) & 3; // Extract bits 2-3
// Check if we should respond to this input state
let shouldRespond = false;
// Edge trigger - respond on changes
if (isEdgeTrigger) {
// If both UP and DOWN bits are set (on-any-change), respond to any change in value
if (mtype & BITS.UP && mtype & BITS.DOWN && hasChanged) {
shouldRespond = true;
node.debug(`Any edge change detected on ${input.topic}`);
} else if (mtype & BITS.UP && !oldValue && newValue) {
// Rising edge: previous=false, current=true
shouldRespond = true;
node.debug(`Rising edge detected on ${input.topic}`);
} else if (mtype & BITS.DOWN && oldValue && !newValue) {
// Falling edge: previous=true, current=false
shouldRespond = true;
node.debug(`Falling edge detected on ${input.topic}`);
} else {
// No change detected, do not respond
node.debug(`No edge change detected on ${input.topic}`);
}
} else {
// Level trigger - respond to current state
if (mtype & BITS.UP && newValue) {
shouldRespond = true;
node.debug(`High level detected on ${input.topic}`);
} else if (mtype & BITS.DOWN && !newValue) {
shouldRespond = true;
node.debug(`Low level detected on ${input.topic}`);
} else {
// No level change detected, do not respond
node.debug(`No level change detected on ${input.topic}`);
}
}
// If we should respond, apply the output action
if (shouldRespond) {
switch (outputAction) {
case 1: // ON (bit 2 set, bit 3 clear)
// Extend bit is only applicable for edge triggers
if (isEdgeTrigger && mtype & BITS.EXTEND && oldOut) {
// Don't change output state, just extend the timeout
node.tsOn = Date.now(); // Extend timeout
node.debug(`Timeout extended by ${input.topic}`);
node.status({ fill: "green", shape: "ring", text: `Timeout extended (${formatDate()})` });
} else if (isEdgeTrigger && mtype & BITS.EXTEND && !oldOut) {
node.status({ fill: "grey", shape: "ring", text: `Timeout not extened (${formatDate()})` });
} else if (!(mtype & BITS.EXTEND)) {
// Otherwise, turn output ON
newOut = true;
}
break;
case 2: // OFF (bit 2 clear, bit 3 set)
newOut = false;
break;
case 3: // INVERT (bit 2 set, bit 3 set)
newOut = !oldOut;
break;
}
}
return newOut;
}
// Check timeout periodically
if (node.timeout > 0) {
setInterval(() => {
if (node.actout && Date.now() - node.tsOn > node.timeout * 1000) {
const activeOverwrite = findActiveOverwriteRow();
// Edge case: if a level-trigger input is active, never turn off
if (activeOverwrite) {
node.status({ fill: "green", shape: "ring", text: `Level-trigger active, timeout disabled (${formatDate()})` });
return;
}
node.actout = false;
node.status({ fill: "grey", shape: "dot", text: `Output OFF after timeout (${formatDate()})` });
node.debug(`Timeout reached (${node.timeout}s), turning output OFF`);
node.send({
topic: node.outputTopic,
payload: 0
});
}
}, 1000);
}
// Format the current date and time as DD/MM/YYYY HH:MM:SS
function formatDate() {
const now = new Date();
const options = {
day: "2-digit",
month: "2-digit",
year: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false // Use 24-hour format
};
return now.toLocaleString("en-GB", options); // 'en-GB' locale for DD/MM/YYYY format
}
}
// Node definition
RED.nodes.registerType("fusebox-lamp-control", LampControlNode);
};