node-red-contrib-chronos
Version:
Time-based Node-RED scheduling, repeating, queueing, routing, filtering and manipulating nodes
1,095 lines (949 loc) • 37.6 kB
JavaScript
/*
* Copyright (c) 2020 - 2026 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 ChronosSchedulerNode(settings)
{
const chronos = require("./common/chronos.js");
const cronosjs = require("cronosjs");
const node = this;
RED.nodes.createNode(node, settings);
node.RED = RED;
node.name = settings.name;
node.config = RED.nodes.getNode(settings.config);
node.initializing = true;
node.eventTimesPending = false;
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"));
return;
}
if (!chronos.validateConfiguration(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"));
return;
}
if (settings.schedule.length == 0)
{
node.status({fill: "red", shape: "dot", text: "scheduler.status.noSchedule"});
node.error(RED._("scheduler.error.noSchedule"));
return;
}
chronos.printNodeInfo(node);
node.status({});
node.disabledSchedule = (typeof settings.disabled == "undefined") ? false : settings.disabled;
node.delayMessages = (typeof settings.delayOnStart == "undefined") ? true : settings.delayOnStart;
node.schedule = [];
for (let i=0; i<settings.schedule.length; ++i)
{
node.schedule.push({id: i+1, config: {trigger: settings.schedule[i].trigger, output: settings.schedule[i].output}});
if ("port" in settings.schedule[i])
{
node.schedule[i].port = settings.schedule[i].port;
}
else if ("port" in settings.schedule[i].output) // backward compatibility to v1.8.1 and below
{
node.schedule[i].port = settings.schedule[i].output.port;
}
else
{
node.schedule[i].port = 0;
}
}
node.ports = [];
for (let i=0; i<settings.outputs; ++i)
{
node.ports.push(null);
}
if (settings.nextEventPort)
{
node.nextEventMsg = {payload: undefined};
node.nextEventMsg.events = Array(node.schedule.length).fill(undefined);
}
let valid = true;
for (let i=0; i<node.schedule.length; ++i)
{
let data = node.schedule[i];
// check for presence of variable name
if (((data.config.trigger.type == "env") || (data.config.trigger.type == "global") || (data.config.trigger.type == "flow"))
&& !data.config.trigger.value)
{
valid = false;
break;
}
// check for valid user time
if ((data.config.trigger.type == "time") && !chronos.isValidUserTime(data.config.trigger.value))
{
valid = false;
break;
}
if ((data.config.trigger.type == "crontab") && !cronosjs.validate(data.config.trigger.value, {strict: true}))
{
valid = false;
break;
}
if ("type" in data.config.output) // backward compatibility, v1.8.x configurations have empty output or only port property under output
{
if (data.config.output.type == "fullMsg")
{
if (data.config.output.contentType === "jsonata")
{
try
{
data.expression = chronos.getJSONataExpression(node, data.config.output.value);
}
catch (e)
{
node.error(e.message);
node.debug("JSONata code: " + e.code + " position: " + e.position + " token: " + e.token + " value: " + e.value);
valid = false;
break;
}
}
}
else
{
if (!data.config.output.property.name)
{
valid = false;
break;
}
if ((data.config.output.property.type == "num") &&
(+data.config.output.property.value !== +data.config.output.property.value))
{
valid = false;
break;
}
if (data.config.output.property.type == "jsonata")
{
try
{
data.expression = chronos.getJSONataExpression(node, data.config.output.property.value);
}
catch (e)
{
node.error(e.message);
node.debug("JSONata code: " + e.code + " position: " + e.position + " token: " + e.token + " value: " + e.value);
valid = false;
break;
}
}
}
}
}
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"));
return;
}
updateStatus();
node.on("close", () =>
{
stopEvents();
});
node.on("input", async(msg, send, done) =>
{
if (!send || !done) // Node-RED 0.x not supported anymore
{
return;
}
if (typeof msg.payload == "boolean")
{
if (msg.payload)
{
node.disabledSchedule = false;
startEvents();
}
else
{
stopEvents();
node.disabledSchedule = true;
}
updateStatus();
done();
}
else if (typeof msg.payload == "string")
{
if (msg.payload == "toggle")
{
toggleEvents();
}
else if (msg.payload == "reload")
{
resetNextEventMsg();
reloadEvents();
}
else if (msg.payload == "trigger")
{
await triggerEvents(false);
}
else if (msg.payload == "trigger:forced")
{
await triggerEvents(true);
}
else if (msg.payload == "trigger:next")
{
let nextEvent = getNextEvent();
if (nextEvent)
{
await triggerEvent(nextEvent, false);
}
}
updateStatus();
done();
}
else if (Array.isArray(msg.payload))
{
let numEnabled = 0;
for (let i=0; (i<msg.payload.length) && (i<node.schedule.length); ++i)
{
if (typeof msg.payload[i] == "boolean")
{
if (msg.payload[i])
{
startEvent(node.schedule[i]);
}
else
{
stopEvent(node.schedule[i]);
}
}
else if (typeof msg.payload[i] == "string")
{
if (msg.payload[i] == "toggle")
{
toggleEvent(node.schedule[i]);
}
else if (msg.payload[i] == "reload")
{
resetNextEventMsg(i);
reloadEvent(node.schedule[i]);
}
else if (msg.payload[i] == "trigger")
{
await triggerEvent(node.schedule[i], false);
}
else if (msg.payload[i] == "trigger:forced")
{
await triggerEvent(node.schedule[i], true);
}
}
else if ((typeof msg.payload[i] == "object") && (msg.payload[i] != null))
{
let data = node.schedule[i];
if (validateFullStructuredContextData(msg.payload[i]))
{
data.orig = {trigger: data.config.trigger, output: data.config.output};
data.config.trigger = msg.payload[i].trigger;
data.config.output = msg.payload[i].output;
startEvent(data, true);
}
else if (validateStructuredContextData(msg.payload[i]))
{
data.orig = {trigger: data.config.trigger};
data.config.trigger = msg.payload[i];
startEvent(data, true);
}
else
{
msg.errorDetails = {property: "msg.payload[" + i + "]"};
node.error(RED._("scheduler.error.invalidMsgEvent"), msg);
}
}
if ("triggerTime" in node.schedule[i])
{
numEnabled++;
}
}
node.disabledSchedule = (numEnabled == 0);
startTimer();
updateStatus();
done();
}
else
{
updateStatus();
done(RED._("node-red-contrib-chronos/chronos-config:common.error.invalidInput"));
}
});
if (node.delayMessages)
{
setTimeout(() =>
{
node.delayMessages = false;
if (node.startQueue)
{
for (const entry of node.startQueue)
{
sendMessage(entry);
}
delete node.startQueue;
}
}, (settings.onStartDelay || 0.1) * 1000);
}
if (!node.disabledSchedule)
{
startEvents();
updateStatus();
}
function startEvents()
{
node.debug("Starting events");
for (const data of node.schedule)
{
startEvent(data);
}
startTimer();
}
function stopEvents()
{
node.debug("Stopping events");
for (const data of node.schedule)
{
stopEvent(data);
}
stopTimer();
}
function toggleEvents()
{
node.debug("Toggling events");
let enabled = true;
for (const data of node.schedule)
{
toggleEvent(data);
if ("triggerTime" in data)
{
enabled = false;
}
}
node.disabledSchedule = enabled;
startTimer();
}
function reloadEvents()
{
node.debug("Rescheduling events");
for (const data of node.schedule)
{
reloadEvent(data);
}
startTimer();
}
async function triggerEvents(forced)
{
node.debug("Triggering events" + (forced ? " (forced)" : ""));
for (const data of node.schedule)
{
await triggerEvent(data, forced);
}
}
function startEvent(data, keepOrig = false)
{
stopEvent(data, keepOrig);
if ((data.config.trigger.type == "env") ||
(data.config.trigger.type == "global") ||
(data.config.trigger.type == "flow"))
{
let ctxData;
if (data.config.trigger.type == "env")
{
if (typeof data.config.trigger.value == "string")
{
ctxData = RED.util.evaluateNodeProperty(
data.config.trigger.value,
data.config.trigger.type,
node);
if (!ctxData)
{
ctxData = data.config.trigger.value;
}
}
else
{
ctxData = data.config.trigger.value;
}
}
else
{
const ctx = RED.util.parseContextStore(data.config.trigger.value);
ctxData = node.context()[data.config.trigger.type].get(ctx.key, ctx.store);
}
if (validateFlatContextData(ctxData))
{
data.orig = {trigger: data.config.trigger};
data.config.trigger = {type: "auto:time", value: ctxData};
}
else if (validateFullStructuredContextData(ctxData))
{
data.orig = {trigger: data.config.trigger, output: data.config.output};
data.config.trigger = ctxData.trigger;
data.config.output = ctxData.output;
}
else if (("type" in data.config.output) // backward compatibility, v1.8.x configurations have empty output or only port property under output
&& validateStructuredContextData(ctxData))
{
data.orig = {trigger: data.config.trigger};
data.config.trigger = ctxData;
}
else
{
node.error(RED._("scheduler.error.invalidCtxEvent"), {errorDetails: {event: data.id, type: data.config.trigger.type, value: ctxData}});
return;
}
}
setUpEvent(data);
}
function stopEvent(data, keepOrig = false)
{
if (("orig" in data) && !keepOrig)
{
if ("trigger" in data.orig)
{
data.config.trigger = data.orig.trigger;
}
if ("output" in data.orig)
{
data.config.output = data.orig.output;
}
delete data.orig;
}
if ("task" in data)
{
node.debug("[Event:" + data.id + "] Stopping cron task");
data.task.stop();
delete data.task;
}
delete data.triggerTime;
}
function toggleEvent(data)
{
if ("triggerTime" in data)
{
stopEvent(data);
}
else
{
startEvent(data);
}
}
function reloadEvent(data)
{
if ("triggerTime" in data)
{
startEvent(data);
}
}
async function triggerEvent(data, forced)
{
if (("triggerTime" in data) || forced)
{
await produceOutput(data, false);
}
}
function setUpEvent(data, prevTriggerTime = undefined)
{
try
{
node.trace("[Event:" + data.id + "] Event specification: " + JSON.stringify(data.config));
if (data.config.trigger.type == "crontab")
{
const expression = cronosjs.CronosExpression.parse(
data.config.trigger.value, {
timezone: chronos.getTimeZone(node),
skipRepeatedHour: node.config.skipRepeatedHour,
missingHour: node.config.missingHour});
let firstTrigger = expression.nextDate();
if (firstTrigger)
{
data.triggerTime = chronos.getTimeFrom(node, firstTrigger);
data.task = new cronosjs.CronosTask(expression);
data.task.on("run", async() =>
{
node.trace("[Event:" + data.id + "] Cron task expired");
await produceOutput(data, false);
let nextTrigger = expression.nextDate();
if (nextTrigger)
{
data.triggerTime = chronos.getTimeFrom(node, nextTrigger);
}
else
{
delete data.triggerTime;
}
updateStatus();
});
node.debug("[Event:" + data.id + "] Starting cron task with first trigger at " + data.triggerTime.format("YYYY-MM-DD HH:mm:ss (Z)"));
data.task.start();
}
}
else
{
const now = chronos.getCurrentTime(node);
data.triggerTime = chronos.getTime(node, prevTriggerTime ? prevTriggerTime.clone().add(1, "days") : now.clone(), data.config.trigger.type, data.config.trigger.value);
if (typeof data.config.trigger.offset == "number")
{
const offset = chronos.getRandomizedOffset(data.config.trigger.offset, data.config.trigger.random);
data.triggerTime.add(offset, "minutes");
}
if (data.triggerTime.isBefore(now))
{
node.trace("[Event:" + data.id + "] Trigger time before current time, adding one day");
if (data.config.trigger.type == "time")
{
data.triggerTime.add(1, "days");
}
else
{
data.triggerTime = chronos.getTime(node, data.triggerTime.add(1, "days"), data.config.trigger.type, data.config.trigger.value);
if (typeof data.config.trigger.offset == "number")
{
const offset = chronos.getRandomizedOffset(data.config.trigger.offset, data.config.trigger.random);
data.triggerTime.add(offset, "minutes");
}
}
}
node.debug("[Event:" + data.id + "] Event triggers at " + data.triggerTime.format("YYYY-MM-DD HH:mm:ss.SSS (Z)"));
}
if (prevTriggerTime)
{
updateStatus();
}
}
catch (e)
{
if (e instanceof chronos.TimeError)
{
node.error(e.message, {errorDetails: e.details});
}
else
{
throw e;
}
// set to null in order distinguish from disabled / not started state
data.triggerTime = null;
}
}
function startTimer()
{
let next = undefined;
let events = undefined;
stopTimer();
if (!node.disabledSchedule)
{
for (const data of node.schedule)
{
if ((data.config.trigger.type != "crontab") && data.triggerTime)
{
if (next)
{
if (data.triggerTime.isBefore(next, "second"))
{
next = data.triggerTime;
events = [data];
}
else if (data.triggerTime.isSame(next, "second"))
{
// group all events that trigger within one second
events.push(data);
}
}
else
{
next = data.triggerTime;
events = [data];
}
}
}
if (next)
{
if (events.length > 1)
{
// align trigger time to full seconds if multiple events are grouped
next.milliseconds(0);
}
node.debug("Starting timer for trigger at " + next.format("YYYY-MM-DD HH:mm:ss.SSS (Z)"));
const sched = chronos.getCurrentTime(node);
const delay = next.diff(sched);
if (delay >= 0)
{
node.timer = setTimeout(async() =>
{
node.debug("Timer with ID " + node.timer + " expired");
const now = chronos.getCurrentTime(node);
if (now.isBefore(next))
{
// when running is a docker environment, it can happen that timers
// run too fast and therefore expire too early
node.debug("Timer expired too early, ignoring");
}
else
{
for (const data of events)
{
await produceOutput(data);
setUpEvent(data, next);
}
}
startTimer();
}, delay);
node.debug("Successfully started timer with ID " + node.timer + " at " + sched.format("YYYY-MM-DD HH:mm:ss.SSS (Z)") + " waiting " + delay + " milliseconds");
}
else
{
node.error("Unexpected negative timeout of " + delay + " milliseconds");
}
}
}
}
function stopTimer()
{
if (node.timer)
{
node.debug("Stopping timer with ID " + node.timer);
clearTimeout(node.timer);
delete node.timer;
}
}
async function produceOutput(data)
{
try
{
if ((data.config.output.type == "global") || (data.config.output.type == "flow"))
{
let value = undefined;
if (data.config.output.property.type === "jsonata")
{
value = await getJSONataValue(data.expression, data.id, data.config.trigger, data.config.output.property.value);
}
else
{
value = await getOutputValue(data.config.output.property.value, data.config.output.property.type);
}
const ctx = RED.util.parseContextStore(data.config.output.property.name);
node.context()[data.config.output.type].set(ctx.key, value, ctx.store);
}
else if (data.config.output.type == "msg")
{
const msg = {};
if (data.config.output.property.type === "jsonata")
{
const value = await getJSONataValue(data.expression, data.id, data.config.trigger, data.config.output.property.value);
RED.util.setMessageProperty(msg, data.config.output.property.name, value, true);
}
else
{
const value = await getOutputValue(data.config.output.property.value, data.config.output.property.type);
RED.util.setMessageProperty(msg, data.config.output.property.name, value, true);
}
sendOrQueue(msg, data.port, false);
}
else if (data.config.output.type == "fullMsg")
{
let msg = undefined;
if (data.config.output.contentType === "jsonata")
{
msg = await getJSONataValue(data.expression, data.id, data.config.trigger, data.config.output.value);
}
else
{
msg = await getOutputValue(data.config.output.value, data.config.output.contentType);
}
if (typeof msg != "object")
{
const details = {event: data.id, result: msg};
throw new chronos.TimeError(RED._("node-red-contrib-chronos/chronos-config:common.error.notObject"), details);
}
sendOrQueue(msg, data.port, false);
}
}
catch (e)
{
if (e instanceof chronos.TimeError)
{
node.error(e.message, {errorDetails: e.details});
}
else
{
node.error(e.message, {errorDetails: {event: data.id}});
}
}
}
async function getOutputValue(value, type)
{
let ret;
if (type)
{
ret = await chronos.evaluateNodeProperty(node, value, type, {});
}
else
{
ret = value;
}
return ret;
}
async function getJSONataValue(expression, id, trigger, source)
{
try
{
expression.assign(
"event", {
id: id,
trigger: trigger});
return await chronos.evaluateJSONataExpression(RED, expression, {});
}
catch (e)
{
const details = {event: id, expression: source, code: e.code, description: e.message, position: e.position, token: e.token};
throw new chronos.TimeError(RED._("node-red-contrib-chronos/chronos-config:common.error.evaluationFailed"), details);
}
}
function sendOrQueue(msg, port, notification)
{
if (msg)
{
const entry = {msg: msg, port: port, notification: notification};
if (node.delayMessages)
{
if (node.startQueue)
{
node.startQueue.push(entry);
}
else
{
node.startQueue = [entry];
}
}
else
{
sendMessage(entry);
}
}
}
function sendMessage(entry)
{
if (node.ports.length > 1)
{
node.ports[entry.port] = entry.msg;
node.send(node.ports);
node.ports[entry.port] = null;
}
else
{
node.send(entry.msg);
}
}
function validateFlatContextData(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 validateStructuredContextData(data)
{
if ((typeof data != "object") || !data)
{
return false;
}
if ((typeof data.type != "string") || !/^time|sun|moon|custom|crontab$/.test(data.type))
{
return false;
}
if (((typeof data.value != "string") && (typeof data.value != "number")) ||
((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)) ||
((data.type == "crontab") && !cronosjs.validate(data.value, {strict: true})))
{
return false;
}
if (!chronos.validateOffset(data))
{
return false;
}
return true;
}
function validateFullStructuredContextData(data)
{
if ((typeof data != "object") || !data)
{
return false;
}
if (!validateStructuredContextData(data.trigger))
{
return false;
}
if ((typeof data.output != "object") || !data.output)
{
return false;
}
if ((typeof data.output.type != "string") || !/^global|flow|msg|fullMsg$/.test(data.output.type))
{
return false;
}
if ((data.output.type == "fullMsg") && ((typeof data.output.value != "object") || !data.output.value))
{
return false;
}
if ((data.output.type == "fullMsg") && (typeof data.output.contentType != "undefined"))
{
return false;
}
if (data.output.type != "fullMsg")
{
if ((typeof data.output.property != "object") || !data.output.property)
{
return false;
}
if ((typeof data.output.property.name != "string") || !data.output.property.name)
{
return false;
}
if ((typeof data.output.property.type != "undefined") && (data.output.property.type !== "date"))
{
return false;
}
if ((data.output.property.type !== "date") && (typeof data.output.property.value == "undefined"))
{
return false;
}
if ((data.output.property.type === "date") &&
!((typeof data.output.property.value == "undefined") || /^iso|object$/.test(data.output.property.value)))
{
return false;
}
}
return true;
}
function resetNextEventMsg(index)
{
if (settings.nextEventPort)
{
if (typeof index == "number")
{
node.nextEventMsg.events[index] = undefined;
}
else
{
node.nextEventMsg.payload = undefined;
node.nextEventMsg.events.fill(undefined);
}
}
}
function getNextEvent()
{
let nextEvent = undefined;
node.schedule.forEach(data =>
{
if (data.triggerTime)
{
if (nextEvent)
{
if (data.triggerTime.isBefore(nextEvent.triggerTime))
{
nextEvent = data;
}
}
else
{
nextEvent = data;
}
}
});
return nextEvent;
}
function updateStatus()
{
if (node.disabledSchedule)
{
node.status({fill: "grey", shape: "dot", text: "scheduler.status.disabledSchedule"});
resetNextEventMsg();
}
else
{
let nextEvent = getNextEvent();
if (nextEvent)
{
node.status({
fill: "green",
shape: "dot",
text: RED._("scheduler.status.nextEvent") + " " + nextEvent.triggerTime.calendar()});
if (settings.nextEventPort)
{
let changed = false;
for (let i=0; i<node.schedule.length; ++i)
{
let data = node.schedule[i];
if (data.triggerTime)
{
let ts = data.triggerTime.valueOf();
if (node.nextEventMsg.events[i] !== ts)
{
node.nextEventMsg.events[i] = ts;
changed = true;
}
}
else if (node.nextEventMsg.events[i] !== null)
{
node.nextEventMsg.events[i] = null;
changed = true;
}
}
let ts = nextEvent.triggerTime.valueOf();
if (node.nextEventMsg.payload !== ts)
{
node.nextEventMsg.payload = ts;
changed = true;
}
if (changed)
{
sendOrQueue(node.nextEventMsg, node.ports.length - 1, true);
}
}
}
else
{
node.status({fill: "yellow", shape: "dot", text: "scheduler.status.noTime"});
resetNextEventMsg();
}
}
}
}
RED.nodes.registerType("chronos-scheduler", ChronosSchedulerNode);
};