UNPKG

@blakegarner/node-red-adaptive-lighting

Version:
339 lines (287 loc) 14 kB
module.exports = function(RED) { "use strict"; const SunCalc = require('suncalc'); const _ = require('lodash'); const Validate = require('./validate.js'); function AdaptiveLightingNode(config) { RED.nodes.createNode(this,config); let node = this; this.perTopic = true; // Set in node properties. this.delay = 5000; this.adaptiveStepTransitionTime = 5000; // in milliseconds. this.topics = {}; // List of timer handlers (per topic). this.enabled = {}; // List of enable booleans (per topic). /** * Looks at the errors object (above) and checks for any actions * that need to be taken. Does not process warnings, that's done * at the end. * Marks node as done if there was an error. * @returns True if errors.error is set, false otherwise. */ function checkErrors(errors, msg, send, done) { if(errors.error !== undefined) { if(errors.status !== undefined) { node.status({fill: "red", shape: "dot", text:errors.status}); } if(done) { done(errors.error); } else { node.error(errors.error, msg); } return true; } return false; } /** * Looks at the errors object (above) and checks for any warning * or info objects that need reporting. Intended to be run at the * end of operation, once all necessary warnings/info has been * collected. Once complete, closes the node. * @returns True if errors.warn.length > 0, false otherwise. * */ function checkWarningsAndClose(errors, msg, send, done) { // node.status is a warning iff there were no errors and at least one warning. // node.status is info-only iff there were no errors and no warnings. if(errors.status !== undefined) { if(errors.error === undefined) { if(Array.isArray(errors.warn) && errors.warn.length > 0) { node.status({fill: "yellow", shape: "dot", text:errors.status}); } else { node.status({fill: "grey", shape: "dot", text:errors.status}); } } } else { // No status message. Clear any existing prompt. node.status({}); } if(Array.isArray(errors.warn)) { errors.warn.forEach(warning => { node.warn(errors.warn); }); } if(done) { done(); } return (Array.isArray(errors.warn) && (errors.warn.length > 0)); } function setTimer(handler, delay, clearHandler) { let timerID = setInterval(handler, delay); return { clear: function() { clearInterval(timerID); clearHandler(); }, trigger: function() { clearInterval(timerID); return handler(); } }; } function clearTimer(topic) { let timerTopic = (node.perTopic ? topic : "_none"); let timer = node.topics[timerTopic]; if(timer) { timer.clear(); } } function setupAction(msg, send, done) { const result = { "errors": { error: undefined, warn: [], status: undefined } }; const now_offset = Validate.GetNowOffset(msg, result.errors); if(checkErrors(result.errors, msg, send, done)) return result; else { result.now_offset = now_offset; result.now = new Date(); result.now.setMilliseconds(result.now.getMilliseconds() + now_offset); } const location = Validate.GetLocation(msg, result.errors); if(checkErrors(result.errors, msg, send, done)) return result; else result.location = location; // Need to update periodically. result.suntimes = SunCalc.getTimes(result.now, result.location.latitude, result.location.longitude); // Need to update periodically. const fades = Validate.GetFades(msg, result.suntimes, result.now, result.errors); if(checkErrors(result.errors, msg, send, done)) return result; else result.fades = fades; result.fades.forEach(vFade => { if(result.closestBefore === undefined || result.closestBefore.beforeTime <= vFade.beforeTime) { result.closestBefore = vFade; } if(result.closestAfter === undefined || result.closestAfter.afterTime > vFade.afterTime) { result.closestAfter = vFade; } }); return result; } function initialAction(validated, msg, send, done, errors) { if(node.enabled[validated.topic]) { let cloneMsg = RED.util.cloneMessage(msg); // Calculate fades and add to new or existing msg.payload.data object. // First check relevant objects exist. if(!cloneMsg) cloneMsg = {}; if(!cloneMsg.payload) cloneMsg.payload = {}; if(!cloneMsg.payload.data) cloneMsg.payload.data = {}; const vData = Validate.Fade(validated.closestBefore, validated.closestAfter, validated.now); Object.keys(vData).forEach(key => { cloneMsg.payload.data[key] = vData[key]; }); validated.lastData = vData; cloneMsg.alx_enabled = true; send(cloneMsg); } else { send(msg); } } function recurringAction(validated, msg, send, done) { // Recalculate `now`. validated.now = new Date() if(validated.now_offset) { validated.now.setMilliseconds(validated.now.getMilliseconds() + validated.now_offset); } // If `now` lies outside range, recalculate before/after values. // Also some sanity checks in case closestBefore/closestAfter not set properly. if(validated.closestBefore === undefined || validated.closestBefore.beforeTime === undefined || validated.closestAfter === undefined || validated.closestAfter.afterTime === undefined || validated.now < validated.closestBefore.beforeTime || validated.now > validated.closestAfter.afterTime) { // As we are using validated results, we can assume these will all function without error. // ... But it doesn't hurt to be sure. let errors = {error: undefined, warn: [], status: undefined}; delete validated.suntimes; delete validated.fades; delete validated.closestBefore; delete validated.closestAfter; validated.suntimes = SunCalc.getTimes(validated.now, validated.location.latitude, validated.location.longitude); validated.fades = Validate.GetFades(msg, validated.suntimes, validated.now, errors); if(errors.error !== undefined) { // Shouldn't happen! node.error("Unexpected error in background process: " + errors.error); return; } validated.fades.forEach(vFade => { if(validated.closestBefore === undefined || validated.closestBefore.beforeTime <= vFade.beforeTime) { validated.closestBefore = vFade; } if(validated.closestAfter === undefined || validated.closestAfter.afterTime > vFade.afterTime) { validated.closestAfter = vFade; } }); } // Suppress re-calculation if disabled. if(node.enabled[validated.topic]) { let cloneMsg = RED.util.cloneMessage(msg); // Calculate fades and add to new or existing msg.payload.data object. // First check relevant objects exist. if(!cloneMsg) cloneMsg = {}; if(!cloneMsg.payload) cloneMsg.payload = {}; if(!cloneMsg.payload.data) cloneMsg.payload.data = {}; const vData = Validate.Fade(validated.closestBefore, validated.closestAfter, validated.now); // Check if data has changed since last time. let lastDataEqual = true; if("lastData" in validated) { Object.keys(vData).forEach(key => { if(!_.isEqual(vData[key],validated.lastData[key])) { lastDataEqual = false; } }); } else { lastDataEqual = false; } // No need to send new message if it hasn't changed since last. if(!lastDataEqual) { // Copy data into cloneMsg: Object.keys(vData).forEach(key => { cloneMsg.payload.data[key] = vData[key]; }); validated.lastData = vData; cloneMsg.alx_enabled = true; // Set transition time (recurring msgs only). cloneMsg.payload.data.transition = node.adaptiveStepTransitionTime / 1000; send(cloneMsg); } } } function passThroughAction(msg, send, done) { send(msg); } node.on('input', function(msg, send, done) { // See blurb at the top of validate.js on the usage of these objects. const errors = { error: undefined, status: undefined, warn: [] }; // Check if active. let validatedActive = Validate.SwitchOn(msg); // Check if validatedActive.enabled flag has changed. let enabledChanged = false; if("enabled" in validatedActive) { // Setting given. if(validatedActive.topic in node.enabled) { enabledChanged = (node.enabled[validatedActive.topic] === validatedActive.enabled); } node.enabled[validatedActive.topic] = validatedActive.enabled; } else if(validatedActive.topic in node.enabled) { // Implicitly hasn't changed. Pull it from node.enabled[validatedActive.topic] validatedActive.enabled = node.enabled[validatedActive.topic]; } else { // Implicitly hasn't changed, never previously executed. Assume true as default. validatedActive.enabled = true; node.enabled[validatedActive.topic] = true; } // Three Cases: // 1) msg.validatedActive undefined or empty string. // -> Pass message on, do not cancel any timers. if(validatedActive.activate === undefined) { passThroughAction(msg, send, done); } // 2) msg.validatedActive is "turn_on", "on" or true. // -> Cancel existing timers, process msg and start a new timer. else if(validatedActive.activate) { // Parse input. let validatedInput = setupAction(msg, send, done); if(checkErrors(validatedInput.errors, msg, send, done)) return; validatedInput.topic = validatedActive.topic; // Cancel any running background processes on the same topic. clearTimer(validatedActive.topic); // Calculate and send the initial message. initialAction(validatedInput, msg, send, done); // Initiate the timer to send the recurring messages. node.topics[validatedActive.topic] = setTimer(function() { recurringAction(validatedInput, msg, send, done);}, node.delay, () => { delete node.topics[validatedActive.topic] } ); } // 3) msg.validatedActive is something else (such as "turn_off"). // -> Pass message on, cancel existing timers. else { // validatedActive.activate === false passThroughAction(msg, send, done); clearTimer(validatedActive.topic); } checkWarningsAndClose(errors, msg, send, done); }); node.on('close', function() { node.log(node.topics); const topicKeys = Object.keys(node.topics); topicKeys.forEach(key => { node.topics.key.clear(); }); node.log(node.topics); }); } RED.nodes.registerType("adaptive lighting",AdaptiveLightingNode); }