smart-nodes
Version:
Controls light, shutters and more. Includes common used logic and statistic nodes to control your home.
720 lines (589 loc) • 25.7 kB
JavaScript
module.exports = function (RED)
{
"use strict";
function MixingValveNode(config)
{
const node = this;
RED.nodes.createNode(node, config);
// ###################
// # Class constants #
// ###################
const ADJUST_OPEN = 0;
const ADJUST_CLOSE = 1;
const OUTPUT_MODE_OPEN_CLOSE = "OPEN_CLOSE";
const OUTPUT_MODE_PERCENTAGE = "PERCENTAGE";
// #######################
// # Global help objects #
// #######################
const smart_context = require("../persistence.js")(RED);
const helper = require("../smart_helper.js");
// #####################
// # persistent values #
// #####################
var node_settings = helper.cloneObject({
enabled: config.enabled,
setpoint: config.setpoint,
off_mode: config.off_mode,
valve_mode: config.valve_mode,
current_temperature: null,
last_position: null,
last_enabled_sended: null,
alarm_active: false,
config_change_date: config.config_change_date,
critical_temp_max: config.critical_temp_max,
crit_temp_change_percent: config.crit_temp_change_percent,
}, smart_context.get(node.id, config.config_change_date));
// Ensure correct types
node_settings.enabled = !!node_settings.enabled;
node_settings.setpoint = parseFloat(node_settings.setpoint);
if (isNaN(node_settings.setpoint) || !isFinite(node_settings.setpoint))
node_settings.setpoint = 20;
if (!node_settings.critical_temp_max)
{
node_settings.critical_temp_max = null;
}
else
{
node_settings.critical_temp_max = parseFloat(node_settings.critical_temp_max);
if (isNaN(node_settings.critical_temp_max) || !isFinite(node_settings.critical_temp_max))
node_settings.critical_temp_max = null;
}
node_settings.crit_temp_change_percent = parseFloat(node_settings.crit_temp_change_percent);
if (isNaN(node_settings.crit_temp_change_percent) || !isFinite(node_settings.crit_temp_change_percent))
node_settings.crit_temp_change_percent = 0;
// Remove old settings
delete node_settings.precision;
delete node_settings.max_change_percent;
delete node_settings.max_change_temp_difference;
delete node_settings.min_change_time;
// ##################
// # Dynamic config #
// ##################
let time_total_s = config.time_total;
let time_sampling_s = config.time_sampling;
let output_mode = config.output_mode || OUTPUT_MODE_OPEN_CLOSE;
let force_position = null;
let alarm_action = config.alarm_action || "NOTHING"; // NOTHING | OPEN | CLOSE
let precision = parseInt(config.precision || 1, 10);
let max_change_percent = parseInt(config.max_change_percent || 2, 10);
let max_change_temp_difference = parseInt(config.max_change_temp_difference || 20, 10);
let min_change_time = parseInt(config.min_change_time || 0, 10);
// ##################
// # Runtime values #
// ##################
// Here the setInterval return value is stored which is used to recheck the valve position
let sampling_interval = null;
// Here the setTimeout return value is stored to stop the calibration time.
let calibration_timeout = null;
// Here the setTimeout return value is stored to stop the valve adjustment.
let changing_timeout = null;
// Store the direction of the adjustment
let adjusting = null;
// The start time of the adjustment to count the change
let adjusting_start_time = null;
// #########################
// # Central node handling #
// #########################
var event = "node:" + node.id;
var handler = function (msg)
{
node.receive(msg);
}
RED.events.on(event, handler);
// ###############
// # Node events #
// ###############
node.on("input", function (msg)
{
handleTopic(msg);
setStatus();
smart_context.set(node.id, node_settings);
});
node.on("close", function ()
{
stopSampling();
if (sampling_interval !== null)
{
if (sampling_interval !== -1)
clearInterval(sampling_interval);
sampling_interval = null;
}
if (calibration_timeout !== null)
{
clearTimeout(calibration_timeout);
calibration_timeout = null;
}
if (changing_timeout !== null)
{
clearTimeout(changing_timeout);
changing_timeout = null;
}
stopChanging();
});
// #####################
// # Private functions #
// #####################
// This is the main function which handles all topics that was received.
let handleTopic = msg =>
{
notifyCentral(msg.topic);
let real_topic = helper.getTopicName(msg.topic);
if (real_topic == null)
node.warn("No topic set");
if (real_topic.startsWith("set_state"))
real_topic = real_topic.replace("set_state", "set");
if (real_topic == "set_inverted")
{
real_topic = "set";
msg.payload = !helper.toBool(msg.payload);
}
if (real_topic == "set")
real_topic = helper.toBool(msg.payload) ? "enable" : "disable";
switch (real_topic)
{
case "debug":
helper.nodeDebug(node, {
node_settings,
force_position,
current_mode: calibration_timeout !== null ? "CALIBRATION" : (changing_timeout !== null ? (adjusting == ADJUST_OPEN ? "OPENING" : "CLOSING") : "IDLE"),
enabled: (node_settings.enabled ? "ENABLED" : "DISABLED"),
heating_mode: node_settings.valve_mode,
current_temperature: node_settings.current_temperature,
output_mode,
precision,
max_change_percent,
max_change_temp_difference,
sampling_interval,
calibration_timeout,
changing_timeout,
adjusting,
adjusting_start_time,
});
break;
case "on":
case "enable":
if (node_settings.enabled)
{
helper.log(node, "Already enabled");
break;
}
node_settings.enabled = true;
// If in alarm mode, just save enabled state, but don't start changing
if (node_settings.alarm_active)
return;
stopChanging();
startSampling();
break;
case "off":
case "disable":
if (!node_settings.enabled)
break;
node_settings.enabled = false;
// If in alarm mode, just save enabled state, but don't start changing
if (node_settings.alarm_active)
return;
stopChanging();
stopSampling();
doOffMode();
break;
case "setpoint":
let new_setpoint = parseFloat(msg.payload);
if (isNaN(new_setpoint) || !isFinite(new_setpoint))
{
// helper.warn(this, "Invalid payload: " + msg.payload);
return;
}
node_settings.setpoint = new_setpoint;
break;
case "off_mode":
switch (msg.payload)
{
case "NOTHING":
case "OPEN":
case "CLOSE":
node_settings.off_mode = msg.payload;
if (!node_settings.enabled)
doOffMode();
break;
default:
helper.warn(this, "Invalid off_mode: " + msg.payload);
return;
}
break;
case "valve_mode":
switch (msg.payload)
{
case "HEATING":
case "COOLING":
node_settings.valve_mode = msg.payload;
setStatus();
break;
default:
helper.warn(this, "Invalid valve_mode: " + msg.payload);
return;
}
break;
case "current_temperature":
let new_temp = parseFloat(msg.payload);
if (isNaN(new_temp) || !isFinite(new_temp))
{
// helper.warn(this, "Invalid payload for node_settings.current_temperature: " + msg.payload);
return;
}
node_settings.current_temperature = new_temp;
// check if critical temperature is set and exceeded
if (node_settings.critical_temp_max !== null && node_settings.crit_temp_change_percent > 0 && node_settings.current_temperature > node_settings.critical_temp_max)
{
force_position = node_settings.last_position - node_settings.crit_temp_change_percent;
force_position = Math.max(force_position, 0);
stopSampling();
startSampling();
}
break;
case "calibrate":
calibrate();
break;
case "alarm":
// Make sure it is bool
msg.payload = helper.toBool(msg.payload);
// No alarm change -> nothing to do
if (node_settings.alarm_active == msg.payload)
break;
node_settings.alarm_active = msg.payload;
if (node_settings.alarm_active)
{
switch (alarm_action)
{
case "OPEN":
force_position = 100;
break;
case "CLOSE":
force_position = 0;
break;
default:
case "NOTHING":
// Don't use alarm mode
node_settings.alarm_active = false;
return;
}
stopSampling();
startSampling();
}
else
{
force_position = null;
stopSampling();
stopChanging();
if (node_settings.enabled)
startSampling();
else
doOffMode();
}
break;
default:
helper.warn(this, "Invalid topic: " + real_topic);
return;
}
notifyCentral(node_settings.enabled);
}
let startSampling = () =>
{
if (!node_settings.enabled && !node_settings.alarm_active)
{
helper.log(node, "Node is disabled, cannot start sampling");
return;
}
// Wait for calibration first
if (node_settings.last_position === null || calibration_timeout !== null)
{
helper.log(node, "Cannot start sampling yet");
return;
}
// sampling is already running, so do nothing
if (sampling_interval !== null)
{
helper.log(node, "Sampling already running");
return;
}
// start sampling
if (force_position !== null)
{
switch (output_mode)
{
case OUTPUT_MODE_OPEN_CLOSE:
sampling_interval = -1; // mark as running
helper.log(node, "Force position to " + force_position.toFixed(1) + "%");
// Under precision % difference => do nothing
if (Math.abs(node_settings.last_position - force_position) <= precision)
{
helper.log(node, "No Force position needed");
force_position = null;
return;
}
let adjustAction = ADJUST_CLOSE;
if (node_settings.last_position < force_position)
adjustAction = ADJUST_OPEN;
let time_ms = Math.abs(node_settings.last_position - force_position) * time_total_s * 1000 / 100;
helper.log(node, "Start force changing " + adjustAction + " for " + time_ms + " ms");
startChanging(adjustAction, time_ms);
return;
case OUTPUT_MODE_PERCENTAGE:
// Output mode percentage
node_settings.last_position = force_position;
force_position = null;
node.send({ payload: node_settings.last_position });
smart_context.set(node.id, node_settings);
setStatus();
}
}
sampling_interval = setInterval(sample, time_sampling_s * 1000);
setStatus();
}
let stopSampling = () =>
{
// Wait for calibration first
if (node_settings.last_position === null || calibration_timeout !== null)
return;
// sampling is not running, so do nothing
if (sampling_interval === null)
return;
// stop sampling
clearInterval(sampling_interval);
sampling_interval = null;
setStatus();
}
let sample = () =>
{
// No current temperature available or in calibration => no action
if (node_settings.current_temperature === null || calibration_timeout !== null || !node_settings.enabled)
{
helper.log(node, "No sample possible", {
current_temperature: node_settings.current_temperature,
calibration_timeout,
enabled: node_settings.enabled
});
return;
}
// +/- 1°C => already good enough, do nothing
let temp_diff = Math.abs(node_settings.current_temperature - node_settings.setpoint);
if (temp_diff < precision)
return;
// 0 °C diff => 0% change
// for max_change_temp_difference (default: 20 °C) diff => max_change_percent (default: 2%) change
let change_percentage = helper.scale(
Math.min(temp_diff, max_change_temp_difference),
0,
max_change_temp_difference,
0,
max_change_percent
);
// calculate direction
let adjustAction = ADJUST_CLOSE;
if (node_settings.current_temperature < node_settings.setpoint)
{
if (node_settings.valve_mode == "HEATING")
adjustAction = ADJUST_OPEN;
}
else
{
if (node_settings.valve_mode == "COOLING")
adjustAction = ADJUST_OPEN;
}
switch (output_mode)
{
case OUTPUT_MODE_PERCENTAGE:
if (adjustAction == ADJUST_CLOSE)
node_settings.last_position -= change_percentage;
else
node_settings.last_position += change_percentage;
// Only values from 0 to 100 are allowed
node_settings.last_position = Math.min(Math.max(node_settings.last_position, 0), 100);
node.send({ payload: node_settings.last_position });
smart_context.set(node.id, node_settings);
setStatus();
return;
case OUTPUT_MODE_OPEN_CLOSE:
break;
}
// Calculate change time in ms
// Change time in ms
let moving_time = (time_total_s * 1000 / 100) * change_percentage;
if (moving_time < min_change_time)
moving_time = min_change_time;
// start moving
startChanging(adjustAction, moving_time);
}
let startChanging = (adjustAction, time_ms) =>
{
if (output_mode !== OUTPUT_MODE_OPEN_CLOSE)
{
helper.log(node, "startChanging is only supported in OPEN/CLOSE output mode");
return;
}
// Already changing
if (changing_timeout !== null)
return;
helper.log(node, "Start changing", adjustAction, time_ms)
stopChanging();
adjusting_start_time = Date.now();
if (adjustAction == ADJUST_OPEN)
node.send([{ payload: true }, { payload: false }, null]);
else
node.send([{ payload: false }, { payload: true }, null]);
adjusting = adjustAction;
changing_timeout = setTimeout(() =>
{
changing_timeout = null;
stopChanging();
adjusting = null;
setStatus();
notifyCentral(node_settings.enabled);
}, time_ms);
setStatus();
}
let stopChanging = () =>
{
// Stop any runnting timeout
if (changing_timeout !== null)
{
if (force_position !== null)
{
force_position = null;
sampling_interval = null;
startSampling();
}
clearTimeout(changing_timeout);
changing_timeout = null;
}
// No changing in progress
if (adjusting_start_time == null)
return;
// Calculate moved percentage
let time_passed = (Date.now() - adjusting_start_time) / 1000;
adjusting_start_time = null;
let changed_value = (time_passed / time_total_s) * 100; // calculate in % value (0-100)
if (adjusting == ADJUST_OPEN)
node_settings.last_position += changed_value;
else
node_settings.last_position -= changed_value;
// Only values from 0 to 100 are allowed
node_settings.last_position = Math.min(Math.max(node_settings.last_position, 0), 100);
smart_context.set(node.id, node_settings);
node.send([{ payload: false }, { payload: false }, { payload: node_settings.last_position }]);
setStatus();
}
let calibrate = () =>
{
if (output_mode !== OUTPUT_MODE_OPEN_CLOSE)
{
node.warn("Calibration is only supported in OPEN/CLOSE output mode");
return;
}
// start closing
node.send([{ payload: false }, { payload: true }, null]);
calibration_timeout = setTimeout(() =>
{
// stop closing
node.send([{ payload: false }, { payload: false }, null]);
node_settings.last_position = 0;
smart_context.set(node.id, node_settings);
// Calibration finished, start sampling if enabled
calibration_timeout = null;
if (node_settings.enabled)
startSampling();
else
stopSampling();
setStatus();
notifyCentral(node_settings.enabled);
}, time_total_s * 1000);
}
let doOffMode = () =>
{
switch (node_settings.off_mode)
{
case "OPEN":
switch (output_mode)
{
case OUTPUT_MODE_OPEN_CLOSE:
startChanging(ADJUST_OPEN, time_total_s * 1000);
break;
case OUTPUT_MODE_PERCENTAGE:
node_settings.last_position = 100;
node.send({ payload: node_settings.last_position });
smart_context.set(node.id, node_settings);
setStatus();
break;
}
break;
case "CLOSE":
switch (output_mode)
{
case OUTPUT_MODE_OPEN_CLOSE:
startChanging(ADJUST_CLOSE, time_total_s * 1000);
break;
case OUTPUT_MODE_PERCENTAGE:
node_settings.last_position = 0;
node.send({ payload: node_settings.last_position });
smart_context.set(node.id, node_settings);
setStatus();
break;
}
case "NOTHING":
default:
break;
}
}
let setStatus = () =>
{
if (calibration_timeout !== null)
node.status({ fill: "yellow", shape: "ring", text: helper.getCurrentTimeForStatus() + ": In calibration" });
else if (changing_timeout != null)
node.status({ fill: node_settings.enabled ? "green" : "red", shape: "ring", text: helper.getCurrentTimeForStatus() + ": " + (node_settings.alarm_active ? "ALARM - " : "") + (node_settings.valve_mode == "HEATING" ? "🔥" : "❄️") + " " + (adjusting == ADJUST_OPEN ? "Opening" : "Closing") + ", Set: " + helper.toFixed(node_settings.setpoint, 1) + "°C, Cur: " + helper.toFixed(node_settings.current_temperature, 1) + "°C, Pos: " + helper.toFixed(node_settings.last_position, 1) + "%" });
else
node.status({ fill: node_settings.enabled ? "green" : "red", shape: "dot", text: helper.getCurrentTimeForStatus() + ": " + (node_settings.alarm_active ? "ALARM - " : "") + (node_settings.valve_mode == "HEATING" ? "🔥" : "❄️") + " Set: " + helper.toFixed(node_settings.setpoint, 1) + "°C, Cur: " + helper.toFixed(node_settings.current_temperature, 1) + "°C, Pos: " + helper.toFixed(node_settings.last_position, 1) + "%" });
}
/**
* Notify all connected central nodes
* @param {boolean} state The state if the valve is enabled
*/
let notifyCentral = state =>
{
// helper.log(node, "notifyCentral", config.links, node);
if (!config.links)
return;
config.links.forEach(link =>
{
// helper.log(node, link, { source: node.id, state: state });
RED.events.emit("node:" + link, { source: node.id, state: state });
});
}
if (node_settings.last_position === null)
{
node_settings.last_position = 50;
}
else if (node_settings.alarm_active)
{
switch (alarm_action)
{
case "OPEN":
force_position = 100;
break;
case "CLOSE":
force_position = 0;
break;
default:
case "NOTHING":
// Don't use alarm mode
node_settings.alarm_active = false;
return;
}
startSampling();
}
else if (node_settings.enabled)
{
startSampling();
}
setStatus();
}
RED.nodes.registerType("smart_mixing-valve", MixingValveNode);
}