node-red-contrib-chronos
Version:
Time-based Node-RED scheduling, repeating, queueing, routing, filtering and manipulating nodes
1,038 lines (903 loc) • 40.2 kB
JavaScript
/*
* Copyright (c) 2020 - 2025 Jens-Uwe Rossbach
*
* This code is licensed under the MIT License.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
module.exports = function(RED)
{
function ChronosStateNode(settings)
{
const chronos = require("./common/chronos.js");
const sfUtils = require("./common/sfutils.js");
const node = this;
RED.nodes.createNode(node, settings);
node.name = settings.name;
node.config = RED.nodes.getNode(settings.config);
node.locale = ("lang" in RED.settings) ? RED.settings.lang : require("os-locale").sync();
if (!node.config)
{
node.status({fill: "red", shape: "dot", text: "node-red-contrib-chronos/chronos-config:common.status.noConfig"});
node.error(RED._("node-red-contrib-chronos/chronos-config:common.error.noConfig"));
}
else if (Number.isNaN(node.config.latitude) || Number.isNaN(node.config.longitude))
{
node.status({fill: "red", shape: "dot", text: "node-red-contrib-chronos/chronos-config:common.status.invalidConfig"});
node.error(RED._("node-red-contrib-chronos/chronos-config:common.error.invalidConfig"));
}
else if (!chronos.validateTimeZone(node))
{
node.status({fill: "red", shape: "dot", text: "node-red-contrib-chronos/chronos-config:common.status.invalidConfig"});
node.error(RED._("node-red-contrib-chronos/chronos-config:common.error.invalidConfig"));
}
else if ((settings.outputType != "fullMsg") && !settings.outputValue)
{
node.status({fill: "red", shape: "dot", text: "node-red-contrib-chronos/chronos-config:common.status.invalidConfig"});
node.error(RED._("node-red-contrib-chronos/chronos-config:common.error.invalidConfig"));
}
else if (settings.states.length == 0)
{
node.status({fill: "red", shape: "dot", text: "state.status.noStates"});
node.error(RED._("state.error.noStates"));
}
else
{
chronos.printNodeInfo(node);
node.status({});
let valid = true;
node.states = [];
for (let i=0; i<settings.states.length; ++i)
{
let data = undefined;
const trigger = loadTrigger(settings.states[i].trigger);
if (trigger)
{
data = {id: i+1, trigger: trigger, state: settings.states[i].state, triggerConfig: settings.states[i].trigger};
node.trace("[State:" + data.id + "] Trigger configuration: " + JSON.stringify(data.triggerConfig));
node.trace("[State:" + data.id + "] Trigger specification: " + JSON.stringify(data.trigger));
}
else
{
valid = false;
break;
}
if ((data.state.type == "num") && (+data.state.value !== +data.state.value))
{
valid = false;
break;
}
node.states.push(data);
}
if (valid)
{
node.outputType = settings.outputType;
if (settings.outputType == "fullMsg")
{
try
{
node.outputValue = chronos.getJSONataExpression(RED, node, settings.outputValue);
}
catch (e)
{
node.error(e.message);
node.debug("JSONata code: " + e.code + " position: " + e.position + " token: " + e.token + " value: " + e.value);
valid = false;
}
}
else
{
node.outputValue = settings.outputValue;
}
}
if (valid && (settings.evaluationType == "jsonata"))
{
try
{
node.evaluationExpression = RED.util.prepareJSONataExpression(settings.evaluation, node);
}
catch (e)
{
node.error(e.message);
node.debug("JSONata code: " + e.code + " position: " + e.position + " token: " + e.token + " value: " + e.value);
valid = false;
}
}
if (!valid)
{
node.status({fill: "red", shape: "dot", text: "node-red-contrib-chronos/chronos-config:common.status.invalidConfig"});
node.error(RED._("node-red-contrib-chronos/chronos-config:common.error.invalidConfig"));
}
else
{
node.conditions = settings.conditions;
node.evaluation = settings.evaluation;
node.evaluationType = settings.evaluationType;
node.passiveMode = settings.passiveMode;
node.paused = false;
node.currentState = {};
node.on("close", () =>
{
cancelTimer();
});
restoreCurrentState(false).then(() =>
{
node.on("input", async(msg, send, done) =>
{
if (!send || !done) // Node-RED 0.x not supported anymore
{
return;
}
if (msg.topic === "trigger")
{
if (node.passiveMode)
{
const triggerTime = chronos.getCurrentTime(node);
if ((typeof msg.payload == "number") &&
(msg.payload >= 1) &&
(msg.payload <= node.states.length))
{
const targetState = node.states[msg.payload-1];
if ((!node.currentState.data || (node.currentState.data.id != targetState.id)) &&
(await evalConditions(triggerTime)))
{
await switchState({data: targetState, triggerTime: triggerTime});
outputCurrentState();
updateStatus();
}
}
else if (node.currentState.data)
{
const targetState = getState(triggerTime, triggerTime, false, true);
if (targetState &&
(targetState.data.id != node.currentState.data.id) &&
(await evalConditions(targetState.triggerTime)))
{
await switchState(targetState);
outputCurrentState();
updateStatus();
}
}
}
done();
}
else if (msg.topic === "get")
{
outputCurrentState();
done();
}
else if (msg.topic === "getid")
{
if (node.currentState.data)
{
msg.payload = node.currentState.data.id;
send(msg);
}
done();
}
else if (msg.topic === "set")
{
if ((typeof msg.payload == "number") &&
(msg.payload >= 1) &&
(msg.payload <= node.states.length))
{
const targetState = node.states[msg.payload-1];
if (!node.currentState.data ||
(node.currentState.data.id != targetState.id))
{
if (node.currentState.data)
{
// reload trigger of old state before new state is activated
reloadTrigger(node.currentState.data);
}
node.currentState.data = targetState;
node.currentState.since = chronos.getCurrentTime(node);
if ("timeout" in msg)
{
let timeout = 0;
if (typeof msg.timeout == "number")
{
// msg.timeout number is interpreted as minutes
timeout = msg.timeout * 60000;
}
else if (typeof msg.timeout == "object")
{
if (typeof msg.timeout.hours == "number")
{
timeout += msg.timeout.hours * 3600000;
}
if (typeof msg.timeout.minutes == "number")
{
timeout += msg.timeout.minutes * 60000;
}
if (typeof msg.timeout.seconds == "number")
{
timeout += msg.timeout.seconds * 1000;
}
}
if ((timeout >= 1000) && (timeout <= 86400000))
{
cancelTimer();
node.debug("[State:" + node.currentState.data.id + "] Starting timer for timeout of " + timeout + " milliseconds");
node.currentState.timer = setTimeout(resetCurrentState, timeout);
node.debug("[State:" + node.currentState.data.id + "] Successfully started timer with ID " + node.currentState.timer);
node.currentState.until = node.currentState.since.clone();
node.currentState.until.add(timeout, "milliseconds");
}
}
else
{
node.currentState.until = await getNextTrigger();
}
outputCurrentState();
updateStatus();
}
}
done();
}
else if (msg.topic === "reset")
{
resetCurrentState();
done();
}
else if (msg.topic === "reload")
{
reload();
done();
}
else if (msg.topic === "pause")
{
pause();
done();
}
else if (msg.topic === "resume")
{
resume();
done();
}
else if (msg.topic === "configure")
{
let reset = false;
if (msg.payload && (typeof msg.payload == "object"))
{
if ("states" in msg.payload)
{
if (msg.payload.states && Array.isArray(msg.payload.states))
{
for (let i=0; (i<msg.payload.states.length) && (i<node.states.length); ++i)
{
if (msg.payload.states[i] && (typeof msg.payload.states[i] == "object"))
{
if (("trigger" in msg.payload.states[i]) && validateStructuredTriggerData(msg.payload.states[i].trigger))
{
node.states[i].triggerConfig = msg.payload.states[i].trigger;
node.states[i].trigger = prepareTrigger(msg.payload.states[i].trigger);
reset = true;
}
if (("state" in msg.payload.states[i]) && validateStateData(msg.payload.states[i].state))
{
node.states[i].state = msg.payload.states[i].state;
reset = true;
}
}
}
}
}
if ("conditions" in msg.payload)
{
if (msg.payload.conditions && Array.isArray(msg.payload.conditions))
{
for (let i=0; (i<msg.payload.conditions.length) && (i<node.conditions.length); ++i)
{
try
{
node.conditions[i] = sfUtils.convertCondition(RED, node, msg.payload.conditions[i], i+1);
reset = true;
}
catch (e)
{
if (e instanceof node.chronos.TimeError)
{
const errMsg = RED.util.cloneMessage(msg);
if (e.details)
{
errMsg.errorDetails = e.details;
}
node.error(e.message, errMsg);
}
else
{
node.error(e.message);
node.debug(e.stack);
}
}
}
}
}
}
if (reset)
{
resetCurrentState();
}
done();
}
else
{
done(RED._("node-red-contrib-chronos/chronos-config:common.error.invalidInput"));
}
});
setUpTimer();
updateStatus();
if (settings.outputOnStart)
{
setTimeout(() =>
{
node.receive({topic: "get"});
}, (settings.outputOnStartDelay || 0.1) * 1000);
}
});
}
}
function loadTrigger(source)
{
let trigger = undefined;
if ((source.type == "env") ||
(source.type == "global") ||
(source.type == "flow"))
{
let ctxData = undefined;
if (source.type == "env")
{
if (typeof source.value == "string")
{
ctxData = RED.util.evaluateNodeProperty(
source.value,
source.type,
node);
if (!ctxData)
{
ctxData = source.value;
}
}
else
{
ctxData = source.value;
}
}
else
{
const ctx = RED.util.parseContextStore(source.value);
ctxData = node.context()[source.type].get(ctx.key, ctx.store);
}
if (validateFlatTriggerData(ctxData))
{
trigger = prepareTrigger({
type: "auto:time",
value: ctxData,
offset: source.offset,
random: source.random});
}
else if (validateStructuredTriggerData(ctxData))
{
trigger = prepareTrigger(ctxData);
}
else
{
node.error(RED._("state.error.invalidCtxTrigger"), {errorDetails: {type: source.type, value: ctxData}});
}
}
else if (validateStructuredTriggerData(source))
{
trigger = prepareTrigger(source);
}
return trigger;
}
function prepareTrigger(data)
{
const trigger = {};
trigger.type = data.type;
if (data.type != "manual")
{
trigger.value = data.value;
if (typeof data.offset == "number")
{
trigger.offset = chronos.getRandomizedOffset(data.offset, data.random);
trigger.random = data.random;
}
else
{
trigger.offset = 0;
trigger.random = 0;
}
}
return trigger;
}
function reload()
{
node.debug("Reloading states");
node.states.forEach(data =>
{
reloadTrigger(data);
});
setUpTimer();
}
function reloadTrigger(data)
{
const trigger = loadTrigger(data.triggerConfig);
if (trigger)
{
data.trigger = trigger;
node.trace("[State:" + data.id + "] Trigger configuration: " + JSON.stringify(data.triggerConfig));
node.trace("[State:" + data.id + "] Trigger specification: " + JSON.stringify(data.trigger));
}
}
function pause()
{
if (!node.passiveMode && !node.paused)
{
node.debug("Pausing state changes");
node.paused = true;
cancelTimer();
updateStatus();
}
}
function resume()
{
if (!node.passiveMode && node.paused)
{
node.debug("Resuming state changes");
node.paused = false;
setUpTimer();
updateStatus();
}
}
function resetCurrentState()
{
cancelTimer();
node.currentState = {};
restoreCurrentState(true, false).then(() =>
{
setUpTimer();
updateStatus();
});
}
async function restoreCurrentState(output, ignoreRandomOffset = true)
{
const now = chronos.getCurrentTime(node);
const baseDate = await getApplicableDate(now.clone(), true);
// always assume a random offset of 0 when restoring last state to avoid weird effects
let restoredState = getState(baseDate, now, ignoreRandomOffset, true);
if (!restoredState && baseDate && baseDate.isSame(now, "day"))
{
// all state triggers are in the future -> search for last state yesterday and earlier
restoredState = getState(
await getApplicableDate(
baseDate.subtract(1, "days"),
true),
now, ignoreRandomOffset, true);
}
if (restoredState)
{
const prevState = node.currentState.data;
node.currentState.data = restoredState.data;
node.currentState.since = restoredState.triggerTime;
node.currentState.until = await getNextTrigger();
if (output && (!prevState || (prevState.id != node.currentState.data.id)))
{
outputCurrentState();
}
}
}
async function outputCurrentState()
{
if (node.currentState.data)
{
await outputState();
}
}
function getState(baseTime, today, ignoreRandomOffset, reverse, precision)
{
let stateTriggerTime = undefined;
let stateData = undefined;
if (baseTime)
{
const isSameDay = baseTime.isSame(today, "day");
node.states.forEach(data =>
{
if (data.trigger.type != "manual") // ignore manual triggers
{
try
{
let triggerTime = chronos.getTime(RED, node, baseTime.clone(), data.trigger.type, data.trigger.value);
if ((data.trigger.offset != 0) && (!data.trigger.random || !ignoreRandomOffset))
{
triggerTime.add(data.trigger.offset, "minutes");
}
if (reverse)
{
if (stateTriggerTime)
{
if ((!isSameDay || triggerTime.isSameOrBefore(baseTime, precision)) &&
triggerTime.isAfter(stateTriggerTime, precision))
{
stateTriggerTime = triggerTime;
stateData = data;
}
}
else if (!isSameDay || triggerTime.isSameOrBefore(baseTime, precision))
{
stateTriggerTime = triggerTime;
stateData = data;
}
}
else if (stateTriggerTime)
{
if ((!isSameDay || triggerTime.isAfter(baseTime, precision)) &&
triggerTime.isBefore(stateTriggerTime, precision))
{
stateTriggerTime = triggerTime;
stateData = data;
}
}
else if (!isSameDay || triggerTime.isAfter(baseTime, precision))
{
stateTriggerTime = triggerTime;
stateData = data;
}
}
catch(e)
{
if (e instanceof chronos.TimeError)
{
node.debug(e.message);
}
else
{
node.error(e.message);
node.debug(e.stack);
}
}
}
});
}
return stateData ? {data: stateData, triggerTime: stateTriggerTime} : undefined;
}
async function getNextTrigger()
{
const now = chronos.getCurrentTime(node);
const baseDate = await getApplicableDate(now.clone());
let nextState = getState(baseDate, now, false, false);
if (!nextState && baseDate && baseDate.isSame(now, "day"))
{
// all state triggers are in the past -> search for next state tomorrow and later
nextState = getState(
await getApplicableDate(baseDate.add(1, "days")),
now, false, false);
}
return nextState ? nextState.triggerTime : undefined;
}
function getNextState()
{
const now = chronos.getCurrentTime(node);
let nextState = getState(now, now, false, false);
if (!nextState)
{
// all state triggers are in the past -> search for next state tomorrow
nextState = getState(now.clone().add(1, "days"), now, false, false);
}
return nextState;
}
async function getApplicableDate(baseDate, reverse = false)
{
let day = 0;
while (day < 366)
{
if (await evalConditions(baseDate))
{
break;
}
else if (reverse)
{
baseDate.subtract(1, "days");
}
else
{
baseDate.add(1, "days");
}
++day;
}
return (day < 366) ? baseDate : undefined;
}
async function evalConditions(baseTime)
{
let result = true;
const condResults = [];
for (let i=0; i<node.conditions.length; ++i)
{
result = sfUtils.evaluateDateCondition(baseTime, node.conditions[i]);
if (node.evaluationType == "jsonata")
{
condResults.push(result);
}
else if (((node.evaluationType == "and") && !result)
|| ((node.evaluationType == "or") && result))
{
break;
}
}
if ((condResults.length > 0) && (node.evaluationType == "jsonata"))
{
try
{
node.evaluationExpression.assign("condition", condResults);
result = await chronos.evaluateJSONataExpression(RED, node.evaluationExpression, {});
if (typeof result != "boolean")
{
node.error(RED._("node-red-contrib-chronos/chronos-config:common.error.notBoolean"));
result = false;
}
}
catch (e)
{
node.error(
RED._("node-red-contrib-chronos/chronos-config:common.error.evaluationFailed"), {
errorDetails: {
code: e.code,
description: e.message,
position: e.position,
token: e.token
}
});
result = false;
}
}
return result;
}
async function switchState(targetState)
{
if (node.currentState.data)
{
// reload trigger of old state before new state is activated
reloadTrigger(node.currentState.data);
}
node.currentState.data = targetState.data;
node.currentState.since = targetState.triggerTime;
node.currentState.until = await getNextTrigger();
}
function setUpTimer()
{
if (!node.passiveMode && !node.paused)
{
const state = getNextState();
if (state)
{
cancelTimer();
node.debug("[State:" + state.data.id + "] Starting timer for trigger at " + state.triggerTime.format("YYYY-MM-DD HH:mm:ss (Z)"));
node.currentState.timer = setTimeout(async() =>
{
node.trace("[State:" + state.data.id + "] Timer with ID " + node.currentState.timer + " expired");
delete node.currentState.timer;
if (await evalConditions(state.triggerTime))
{
await switchState(state);
if (node.currentState.timer)
{
clearTimeout(node.currentState.timer);
delete node.currentState.timer;
}
outputState();
}
setUpTimer();
updateStatus();
}, state.triggerTime.diff(chronos.getCurrentTime(node)));
node.debug("[State:" + state.data.id + "] Successfully started timer with ID " + node.currentState.timer);
}
}
}
function cancelTimer()
{
if (node.currentState.timer)
{
node.debug("Cancelling active timer with ID " + node.currentState.timer);
clearTimeout(node.currentState.timer);
delete node.currentState.timer;
}
}
async function outputState()
{
try
{
let value = undefined;
if (node.currentState.data.state.type)
{
value = await chronos.evaluateNodeProperty(
RED, node,
node.currentState.data.state.value,
node.currentState.data.state.type,
{});
}
else
{
value = node.currentState.data.state.value;
}
if ((node.outputType == "global") || (node.outputType == "flow"))
{
const ctx = RED.util.parseContextStore(node.outputValue);
node.context()[node.outputType].set(ctx.key, value, ctx.store);
}
else if (node.outputType == "msg")
{
const msg = {};
RED.util.setMessageProperty(msg, node.outputValue, value, true);
node.send(msg);
}
else if (node.outputType == "fullMsg")
{
const msg = await getJSONataValue(value);
if (typeof msg != "object")
{
throw new chronos.TimeError(
RED._("node-red-contrib-chronos/chronos-config:common.error.notObject"),
{event: node.currentState.data.id, result: msg});
}
node.send(msg);
}
}
catch (e)
{
if (e instanceof chronos.TimeError)
{
node.error(e.message, {errorDetails: e.details});
}
else
{
node.error(e.message, {});
}
}
}
async function getJSONataValue(value)
{
try
{
node.outputValue.assign(
"state", {
id: node.currentState.data.id,
trigger: node.currentState.data.trigger,
value: value,
since: node.currentState.since.valueOf(),
until: node.currentState.until ? node.currentState.until.valueOf() : null});
return await chronos.evaluateJSONataExpression(RED, node.outputValue, {});
}
catch (e)
{
throw new chronos.TimeError(
RED._("node-red-contrib-chronos/chronos-config:common.error.evaluationFailed"), {
event: node.currentState.data.id,
code: e.code,
description: e.message,
position: e.position,
token: e.token});
}
}
function validateFlatTriggerData(data)
{
if ((typeof data != "string") && (typeof data != "number"))
{
return false;
}
if ((typeof data == "string") && !data)
{
return false;
}
if ((typeof data == "string") && !chronos.PATTERN_AUTO_TIME.test(data))
{
return false;
}
if ((typeof data == "number") && ((data < 0) || (data >= 86400000)))
{
return false;
}
return true;
}
function validateStructuredTriggerData(data)
{
if ((typeof data != "object") || !data)
{
return false;
}
if ((typeof data.type != "string") || !/^time|sun|moon|custom|manual$/.test(data.type))
{
return false;
}
if ((data.type != "manual") && (typeof data.value != "string") && (typeof data.value != "number"))
{
return false;
}
if (((data.type == "time") && !chronos.isValidUserTime(data.value)) ||
((data.type == "sun") && !chronos.PATTERN_SUNTIME.test(data.value)) ||
((data.type == "moon") && !chronos.PATTERN_MOONTIME.test(data.value)))
{
return false;
}
if (!chronos.validateOffset(data))
{
return false;
}
return true;
}
function validateStateData(data)
{
if ((typeof data != "object") || !data)
{
return false;
}
if ((typeof data.type != "undefined") && (data.type !== "date"))
{
return false;
}
if ((data.type !== "date") && (typeof data.value == "undefined"))
{
return false;
}
if ((data.type === "date") &&
!((typeof data.value == "undefined") || /^iso|object$/.test(data.value)))
{
return false;
}
return true;
}
async function updateStatus()
{
if (node.currentState.data)
{
let stateValue = undefined;
if ((node.currentState.data.state.type == "date") ||
(typeof node.currentState.data.state.value == "object"))
{
stateValue = RED._("state.status.currentState");
}
else if (typeof node.currentState.data.state.value == "string")
{
stateValue =
((node.currentState.data.state.value.length > 10)
? node.currentState.data.state.value.substr(0, 10) + "..."
: node.currentState.data.state.value);
}
else
{
stateValue = node.currentState.data.state.value;
}
const until = (node.currentState.until && !node.paused) ? node.currentState.until.calendar() : undefined;
if (until)
{
node.status({fill: "green", shape: "dot", text: "»" + stateValue + "« " + RED._("state.status.until") + " " + until});
}
else
{
node.status({fill: "green", shape: "dot", text: stateValue});
}
}
else
{
node.status({fill: "grey", shape: "dot", text: "state.status.noState"});
}
}
}
RED.nodes.registerType("chronos-state", ChronosStateNode);
};