@janart19/node-red-fusebox
Version:
A comprehensive collection of custom nodes for interfacing with Fusebox automation controllers - data streams, energy management, and utilities
352 lines (294 loc) • 16 kB
JavaScript
// Implementation of an Inverter Control Node for Node-RED
// This node calculates a new setpoint for the inverter, taking into account grid and inverter limitations.
module.exports = function (RED) {
function InverterControlNode(config) {
RED.nodes.createNode(this, config);
const node = this;
// Configuration
const outputTopic = config.outputTopic;
// Individual topic configuration
const topicCurrentGridPower = config.topicCurrentGridPower;
const topicExternalSetpoint = config.topicExternalSetpoint;
const topicMaxGridImport = config.topicMaxGridImport;
const topicMaxGridExport = config.topicMaxGridExport;
const topicMaxInverterImport = config.topicMaxInverterImport;
const topicMaxInverterExport = config.topicMaxInverterExport;
const topicDebug = config.topicDebug;
// Create topics array for processing
const inverterTopics = [topicCurrentGridPower, topicExternalSetpoint, topicMaxGridImport, topicMaxGridExport, topicMaxInverterImport, topicMaxInverterExport, topicDebug];
const INVALID_VALUES = ["", null, undefined];
// Initialize node state variables
node.current_grid_power = null;
node.external_setpoint = null;
node.max_grid_import = 1500;
node.max_grid_export = -1500;
node.max_inverter_import = 1000;
node.max_inverter_export = -1000;
node.print_debug = false;
// State tracking variables
node.external_setpoint_prev = 0;
node.setpoint = 0;
node.on_inverter_limit = 0; // 1, -1, or 0
node.on("input", function (msg) {
try {
// Store the incoming message values
const processed = processMessage(msg);
if (processed === null) return; // Do not proceed
// Check if all required values are available
const validated = validateRequiredValues();
if (!validated) return; // Do not proceed
const new_setpoint = adjustSetpoint();
// Store the values updated during function execution
node.external_setpoint_prev = node.external_setpoint;
// Return the message with the output and metadata
const metadata = {
current_grid_power: node.current_grid_power,
external_setpoint: node.external_setpoint,
max_grid_import: node.max_grid_import,
max_grid_export: node.max_grid_export,
max_inverter_import: node.max_inverter_import,
max_inverter_export: node.max_inverter_export,
setpoint: node.setpoint,
on_inverter_limit: node.on_inverter_limit,
external_setpoint_prev: node.external_setpoint_prev
};
const outputMsg = createMsg(new_setpoint, outputTopic, metadata, msg);
node.send(outputMsg);
} catch (error) {
node.error(`Inverter Control error: ${error.message}`, msg);
node.status({ fill: "red", shape: "dot", text: `Error: ${error.message}` });
}
});
// Calculate new setpoint based on external setpoint, limited by grid and inverter capabilities
function adjustSetpoint() {
let ext_set_change, add_adjustment, new_setpoint;
let headroom, headroom_import, headroom_export;
if (node.external_setpoint !== node.external_setpoint_prev) {
ext_set_change = node.external_setpoint - node.external_setpoint_prev;
debug(`external_setpoint change ${ext_set_change} from ${node.external_setpoint_prev} to ${node.external_setpoint}`);
} else {
ext_set_change = 0;
}
if (node.current_grid_power > node.max_grid_import) {
// above import limit
add_adjustment = node.max_grid_import - node.current_grid_power;
debug(`import power overshoot by ${-add_adjustment}`);
} else if (node.current_grid_power < node.max_grid_export) {
// above export limit
add_adjustment = node.max_grid_export - node.current_grid_power;
debug(`export power overshoot by ${add_adjustment}`);
} else if (node.current_grid_power > 0 && node.current_grid_power < node.max_grid_import && node.setpoint < node.external_setpoint) {
// above import limit but setpoint adjusted
headroom = node.max_grid_import - node.current_grid_power;
add_adjustment = Math.min(headroom, ext_set_change); // limit to the value of setpoint < external_setpoint
debug(`add_adjustment ${add_adjustment} W, max of (headroom ${headroom}, ext_set_change ${ext_set_change})`);
} else if (node.current_grid_power < 0 && node.current_grid_power > node.max_grid_export && node.setpoint > node.external_setpoint) {
// below export limit but setpoint adjusted
headroom = node.max_grid_export - node.current_grid_power;
add_adjustment = Math.max(headroom, ext_set_change);
debug(`add_adjustment ${add_adjustment} W, min of (headroom ${headroom}, ext_set_change ${ext_set_change})`);
} else {
// no adjustments based on current power vs limits needed
add_adjustment = 0;
new_setpoint = node.external_setpoint;
}
if (add_adjustment !== 0) {
new_setpoint = node.setpoint + add_adjustment;
debug(`new_setpoint ${new_setpoint} = setpoint ${node.setpoint} + add_adjustment ${add_adjustment}`);
} else {
new_setpoint = node.external_setpoint;
debug(`no adjustment needed, new_setpoint ${new_setpoint} is the same as externally given`);
}
// Setpoint jump from limited state to unlimited must never exceed the headroom!
headroom_import = node.max_grid_import - node.current_grid_power;
headroom_export = node.max_grid_export - node.current_grid_power;
if (new_setpoint - node.setpoint > headroom_import) {
new_setpoint = node.setpoint + headroom_import; // assume setpoint is negative
debug(`new_setpoint ${new_setpoint}, change limited by grid import headroom ${headroom_import}!`);
if (new_setpoint > node.external_setpoint) {
new_setpoint = node.external_setpoint;
debug(`FIX: new_setpoint ${new_setpoint} limited to external_setpoint ${node.external_setpoint} instead of ${node.setpoint + headroom_import}!`);
}
} else if (new_setpoint - node.setpoint < headroom_export) {
new_setpoint = node.setpoint + headroom_export;
debug(`new_setpoint ${new_setpoint}, change limited by grid export headroom ${headroom_export}!`);
if (new_setpoint < node.external_setpoint) {
new_setpoint = node.external_setpoint;
debug(`FIX: new_setpoint ${new_setpoint} limited to external_setpoint ${node.external_setpoint} instead of ${node.setpoint + headroom_export}!`);
}
}
// Ensure the new setpoint does not exceed the inverter's capabilities
if (new_setpoint > node.max_inverter_import) {
new_setpoint = node.max_inverter_import;
node.on_inverter_limit = 1;
debug(`new_setpoint ${new_setpoint} limited by inverter import limit!`);
} else if (new_setpoint < node.max_inverter_export) {
new_setpoint = node.max_inverter_export;
node.on_inverter_limit = -1;
debug(`new_setpoint ${new_setpoint} limited by inverter export limit!`);
} else {
node.on_inverter_limit = 0;
}
if (node.setpoint !== new_setpoint) {
node.setpoint = new_setpoint; // may include cumulative adjustments
debug(`new setpoint ${node.setpoint} is set, on_inverter_limit ${node.on_inverter_limit}`);
}
new_setpoint = parseFloat(new_setpoint.toFixed(2)); // Round to 2 decimal places
// Update the node status
node.status({
fill: node.on_inverter_limit === 0 ? "green" : "yellow",
shape: "dot",
text: `Output${node.on_inverter_limit === 0 ? "" : " limited"}: ${new_setpoint} (${formatDate()})`
});
return new_setpoint;
}
// ===============
// Helper functions
// ===============
/**
* Check if the specified object is an object (not an array or null)
*/
function isObject(obj) {
return obj !== null && typeof obj === "object" && !Array.isArray(obj);
}
/**
* Log a debug message if the flag is enabled
*/
function debug(str) {
if (node.print_debug) {
node.debug(str);
}
}
/**
* Format the current date and time as DD/MM/YYYY HH:MM:SS
*/
function formatDate() {
const now = new Date();
return now.toLocaleString("en-GB", {
day: "2-digit",
month: "2-digit",
year: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false // Use 24-hour format
}); // 'en-GB' locale for DD/MM/YYYY format
}
/**
* Create a message with the specified payload, topic, and metadata
*/
function createMsg(payload, topic = null, metadata = null, originalMsg = {}) {
const msg = { ...originalMsg };
msg.payload = payload;
delete msg.topic;
if (topic) msg.topic = topic;
if (metadata) msg.metadata = metadata;
return msg;
}
/**
* Process the incoming message and store the value(s) in node properties
* This node can parse messages in 3 different formats.
* Return null if the message is unknown
*/
function processMessage(msg) {
// Find the topic in the incoming message
const msgTopic = msg.topic;
const topicIndex = inverterTopics.indexOf(msgTopic);
if (topicIndex !== -1) {
// Single topic format: msg = { topic: "topic-1", payload: 0.1 }
if (!isInvalidValue(msgTopic, msg.payload)) {
setNodePropertyByIndex(topicIndex, msg.payload);
}
} else {
// Check for object format: msg = { "topic-1": 0.1, "topic-2": 0.2, ...}
const matchingTopics = Object.keys(msg).filter((key) => inverterTopics.includes(key));
if (matchingTopics.length > 0) {
setVariables(matchingTopics, msg);
} else if (isObject(msg.payload)) {
// Check for nested payload format: msg = { payload: { "topic-1": 0.1, "topic-2": 0.2, ...} }
const nestedMatchingTopics = Object.keys(msg.payload).filter((key) => inverterTopics.includes(key));
if (nestedMatchingTopics.length > 0) {
setVariables(nestedMatchingTopics, msg.payload);
} else {
node.status({ fill: "grey", shape: "dot", text: `Unknown topic (${formatDate()})` });
return null;
}
} else {
node.status({ fill: "grey", shape: "dot", text: `Unknown topic (${formatDate()})` });
return null;
}
}
// Helper function to set node property by topic index
function setNodePropertyByIndex(index, value) {
switch (index) {
case 0:
node.current_grid_power = value;
break;
case 1:
node.external_setpoint = value;
break;
case 2:
node.max_grid_import = value;
break;
case 3:
node.max_grid_export = value;
break;
case 4:
node.max_inverter_import = value;
break;
case 5:
node.max_inverter_export = value;
break;
case 6:
node.print_debug = value;
break;
}
}
// Check if a value is missing or invalid
function isInvalidValue(topic, value) {
if (INVALID_VALUES.includes(value)) {
node.status({ fill: "red", shape: "dot", text: `Invalid value for ${topic} (${formatDate()})` });
return true; // Do not proceed if the value is missing
}
return false;
}
// Loop through the topics and store the values in node properties
function setVariables(topics = [], obj = {}) {
for (const t of topics) {
if (isInvalidValue(t, obj[t])) break;
const topicIndex = inverterTopics.indexOf(t);
if (topicIndex !== -1) {
setNodePropertyByIndex(topicIndex, obj[t]);
}
}
}
}
/**
* In case of missing or invalid values, the function returns either true or false
*/
function validateRequiredValues() {
const requiredValues = [node.current_grid_power, node.external_setpoint];
// Check if all required values are available
if (requiredValues.some((value) => INVALID_VALUES.includes(value))) {
const missingCount = requiredValues.filter((value) => INVALID_VALUES.includes(value)).length;
node.status({ fill: "yellow", shape: "dot", text: `Waiting for topics: ${missingCount}/2 (${formatDate()})` });
return false;
}
// Additional check that export limits are negative and import limits are positive
if ((node.max_grid_export !== null && node.max_grid_export > 0) || (node.max_inverter_export !== null && node.max_inverter_export > 0)) {
node.status({ fill: "red", shape: "dot", text: `Export limits must be <= 0 (${formatDate()})` });
return false;
}
if ((node.max_grid_import !== null && node.max_grid_import < 0) || (node.max_inverter_import !== null && node.max_inverter_import < 0)) {
node.status({ fill: "red", shape: "dot", text: `Import limits must be >= 0 (${formatDate()})` });
return false;
}
return true;
}
// Clean up on node removal
node.on("close", function () {
node.status({});
});
}
RED.nodes.registerType("fusebox-inverter-control", InverterControlNode);
};