UNPKG

smart-nodes

Version:

Controls light, shutters and more. Includes common used logic and statistic nodes to control your home.

586 lines (481 loc) 19.7 kB
module.exports = function (RED) { "use strict"; function ShutterComplexControlNode(config) { const node = this; RED.nodes.createNode(node, config); // ################### // # Class constants # // ################### const ACTION_UP = 0; const ACTION_DOWN = 1; const ACTION_STOP = 2; const ACTION_POSITION = 3; // ####################### // # Global help objects # // ####################### const smart_context = require("../persistence.js")(RED); const helper = require("../smart_helper.js"); // ############################ // # Used from text-exec node # // ############################ if (typeof config.exec_text_names == "string") node.exec_text_names = config.exec_text_names.split(",").map(n => n.trim().toLowerCase()); else node.exec_text_names = []; // ##################### // # persistent values # // ##################### var node_settings = helper.cloneObject({ last_position: 0, // 0 = opened, 100 = closed last_direction_up: true, // remember last direction for toggle action last_position_before_alarm: 0, // remember position to restore on alarm off event alarm_active: false, // remember if alarm is on or off }, smart_context.get(node.id)); // ########################## // # Backward compatibility # // ########################## if (typeof config.max_time != "undefined") { config.max_time_up = config.max_time; config.max_time_down = config.max_time; delete config.max_time; } // ################## // # Dynamic config # // ################## let max_time_up = parseInt(config.max_time_up || 60, 10); let max_time_down = parseInt(config.max_time_down || 60, 10); let revert_time_ms = parseInt(config.revert_time_ms || 100, 10); let alarm_action = config.alarm_action || "NOTHING"; let alarm_off_action = config.alarm_off_action || "NOTHING"; // ################## // # Runtime values # // ################## // Here the setTimeout return value is stored to stop the shutter. // That means if it is null, the shutter is stopped. let timeout = null; // The local time when the shutter starts moving. // This is needed to calc the new position of the shutter. let on_time = null; // The local time when the shutter was stopped last time. // This is used to measure the revert time. let off_time = null; // This is the return value of setTimeout when the shutter has to wait until the reverse time is finished. let wait_timeout = null; // ######################### // # Central node handling # // ######################### var event = "node:" + config.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 () { startAction(ACTION_STOP); RED.events.off(event, handler); }); // ##################### // # Private functions # // ##################### // This is the main function which handles all topics that was received. let handleTopic = msg => { helper.log(node, "handle topic:", msg); let real_topic = helper.getRealTopic(msg.topic, "toggle", ["up", "up_stop", "down", "down_stop", "stop", "toggle", "up_down", "position", "alarm"]); // skip if button is released if (msg.payload === false && ["up", "up_stop", "down", "down_stop", "stop", "toggle"].includes(real_topic)) return; // Convert up_down from HA UI to next command if (real_topic == "up_down") { if (msg.payload) real_topic = "down"; else real_topic = "up"; delete msg.payload; } // Correct next topic to avoid handling up_stop, down_stop or toggle separately. if (timeout != null && ["up_stop", "down_stop", "toggle"].includes(real_topic)) { real_topic = "stop"; } else if (timeout == null) { // shutter is not running, set next command depending on topic switch (real_topic) { case "up_stop": real_topic = "up"; break; case "down_stop": real_topic = "down"; break; case "toggle": real_topic = node_settings.last_direction_up ? "down" : "up"; break; } } helper.log(node, "handle real topic: " + real_topic); switch (real_topic) { case "debug": helper.nodeDebug(node, { node_settings, max_time_up, max_time_down, revert_time_ms, alarm_action, alarm_off_action, }); break; case "up": startAction(ACTION_UP, msg.time_on ?? null, msg.exact ?? null); break; case "stop": startAction(ACTION_STOP); break; case "down": startAction(ACTION_DOWN, msg.time_on ?? null, msg.exact ?? null); break; case "position": startAction(ACTION_POSITION, msg.payload ?? null); break; case "alarm": // Make sure it is bool msg.payload = !!msg.payload; // No alarm change, do nothing if (node_settings.alarm_active == msg.payload) return; node_settings.alarm_active = msg.payload; if (node_settings.alarm_active) handleAlarmOn(); else handleAlarmOff(); break; } }; /** * This function is called when the alarm changes from off to on */ let handleAlarmOn = () => { switch (alarm_action) { case "UP": startAction(ACTION_UP, null, null, true); break; case "DOWN": startAction(ACTION_DOWN, null, null, true); break; } } /** * This function is called when the alarm changes from on to off */ let handleAlarmOff = () => { switch (alarm_off_action) { case "NOTHING": break; case "UP": startAction(ACTION_UP); break; case "DOWN": startAction(ACTION_DOWN); break; case "LAST": startAction(ACTION_POSITION, node_settings.last_position_before_alarm); break; } } /** * This functions stops the shutter if needed and starts the requested action. * * @param {int} action One of ACTION_UP, ACTION_STOP, ACTION_DOWN or ACTION_POSITION. * @param {string|float|null} data For ACTION_POSITION this is the position, for ACTION_UP and ACTION_DOWN it is the max run time. * @param {boolean?} exact If true, the exact given time is used, otherwise some offset at 0% and 100% is used to ensure the shutter reach the end. * @param {boolean?} ignoreAlarm If true, the action is performed even the alarm is activated. The default is false. */ let startAction = (action, data = null, exact = false, ignoreAlarm = false) => { helper.log(node, "startAction", { action, data, exact, ignoreAlarm }); // Nothing allowed if alarm is on if (ignoreAlarm === false && node_settings.alarm_active) return; // Variable declarations let run_time_ms; let needStop = false; let now = Date.now(); let currentPosition = calcNewPosition(); // handle 0% and 100% as UP and DOWN command if (action == ACTION_POSITION && data === 0) { action = ACTION_UP; data = null; } else if (action == ACTION_POSITION && data === 100) { action = ACTION_DOWN; data = null; } // Stop waiting for revert time, it will be restarted later with the correct following action if (wait_timeout != null) { clearTimeout(wait_timeout); wait_timeout = null; } // Shutter is running, check if it has to be stopped first if (timeout != null) { switch (action) { case ACTION_UP: if (node_settings.last_direction_up === false) needStop = true; break; case ACTION_DOWN: if (node_settings.last_direction_up === true) needStop = true; break; case ACTION_STOP: needStop = true; break; case ACTION_POSITION: // Wrong direction, do stop if (node_settings.last_direction_up === true && currentPosition < data) needStop = true; if (node_settings.last_direction_up === false && currentPosition > data) needStop = true; break; } } // Stop shutter if needed and save new positions if (needStop) { clearTimeout(timeout); off_time = Date.now(); timeout = null; if (!node_settings.alarm_active) node_settings.last_position_before_alarm = currentPosition; node_settings.last_position = currentPosition; sendToOutput(false, false); } // Determine needed runtime switch (action) { case ACTION_STOP: // Already stopped, nothing more to do return; case ACTION_UP: // data is the run time if (data == null) run_time_ms = node_settings.last_position * max_time_up / 100 * 1000; else run_time_ms = helper.getTimeInMsFromString(data); break; case ACTION_DOWN: // data is the run time if (data == null) run_time_ms = (100 - node_settings.last_position) * max_time_down / 100 * 1000; else run_time_ms = helper.getTimeInMsFromString(data); break; case ACTION_POSITION: // data is the position in percent if (data == null) { helper.warn(this, "Try to set position without giving a new position"); return; } // Make sure it is in range 0-100 data = Math.min(100, Math.max(0, data)); // Convert to UP/DOWN with a specific time if (data < currentPosition) { run_time_ms = (currentPosition - data) / 100 * max_time_up * 1000; action = ACTION_UP; } else // if (data > currentPosition) { action = ACTION_DOWN; run_time_ms = (data - currentPosition) / 100 * max_time_down * 1000; } break; } if (exact !== true && data == null) { // Run at least for 5 seconds if (run_time_ms < 5000) run_time_ms = 5000; } let dirChange = ((action == ACTION_UP && !node_settings.last_direction_up) || (action == ACTION_DOWN && node_settings.last_direction_up)); // Just to make sure there is no mistake if (run_time_ms < 0) run_time_ms = 0; if (timeout != null) { // This happens if the time needs to be changed, but the direction is the same clearTimeout(timeout); helper.log(node, "stop after " + run_time_ms + "ms"); off_time = Date.now(); on_time = off_time; node_settings.last_position = currentPosition; timeout = setTimeout(() => { // Just stop after the new time startAction(ACTION_STOP, null, null, ignoreAlarm); timeout = null; smart_context.set(node.id, node_settings); setStatus(); }, run_time_ms); } else if (off_time + revert_time_ms - now > 0 && dirChange) { // revert time is not fully passed helper.log(node, "revert time is not fully passed, wait for " + (off_time + revert_time_ms - now) + "ms"); wait_timeout = setTimeout(() => { wait_timeout = null; startAction(action, data, exact, ignoreAlarm); smart_context.set(node.id, node_settings); setStatus(); }, off_time + revert_time_ms - now); } else if (run_time_ms == 0) { // Do nothing. // This can happen if exact = true but the shutter is already at the target position } else { // The shutter can finally be started. switch (action) { case ACTION_UP: helper.log(node, "start ACTION_UP"); sendToOutput(true, false); break; case ACTION_DOWN: helper.log(node, "start ACTION_DOWN"); sendToOutput(false, true); break; } if (!node_settings.alarm_active) node_settings.last_position_before_alarm = currentPosition; on_time = Date.now(); helper.log(node, "stop after " + run_time_ms + "ms"); timeout = setTimeout(() => { startAction(ACTION_STOP, null, null, ignoreAlarm); timeout = null; smart_context.set(node.id, node_settings); setStatus(); }, run_time_ms); } } /** * * @param {*} up * @param {*} down * @returns */ let sendToOutput = (up, down) => { helper.log(node, "sendToOutput", { up, down }); if (up && down) { console.error("Fatal exception, Cannot send up and down at the same time."); return; } if (up) node_settings.last_direction_up = true; else if (down) node_settings.last_direction_up = false; node.send([{ payload: up }, { payload: down }, { payload: node_settings.last_position }]); // Inform central nodes that shutter is running/stopped notifyCentral(up || down); } /** * * @returns */ let calcNewPosition = () => { let now = Date.now(); if (timeout == null) return node_settings.last_position; // Change position while running, // Calculate current position first if (node_settings.last_direction_up) { let change_percentage = (now - on_time) / 1000 / max_time_up * 100; return Math.max(0, node_settings.last_position - change_percentage); } else { let change_percentage = (now - on_time) / 1000 / max_time_down * 100; return Math.min(100, node_settings.last_position + change_percentage); } } /** * Set the current node status */ let setStatus = () => { let fill = node_settings.alarm_active ? "red" : "green"; let shape = timeout != null ? "ring" : "dot"; // collect all texts and join later with a comma let texts = []; if (node_settings.alarm_active) texts.push("ALARM"); if (timeout == null) texts.push("Stopped"); else if (node_settings.last_direction_up) texts.push("Up"); else texts.push("Down"); texts.push("Position: " + node_settings.last_position?.toFixed(0) + "%"); node.status({ fill, shape, text: helper.getCurrentTimeForStatus() + ": " + texts.join(", ") }); } /** * Notify all connected central nodes * @param {boolean} state The state if the shutter is running * @returns */ let notifyCentral = state => { 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 }); }); }; // For security reason, stop shutter at node start wait_timeout = setTimeout(() => { wait_timeout = null; sendToOutput(false, false); notifyCentral(false); if (node_settings.alarm_active) handleAlarmOn(); setStatus(); }, 10000); } RED.nodes.registerType("smart_shutter-complex-control", ShutterComplexControlNode); };