node-red-contrib-chronos
Version:
Time-based Node-RED scheduling, repeating, queueing, routing, filtering and manipulating nodes
640 lines (564 loc) • 20.3 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.
*/
const PATTERN_TIME = /^(\d|0\d|1\d|2[0-3]):([0-5]\d)(?::([0-5]\d))?\s*(a|am|A|AM|p|pm|P|PM)?$/;
const PATTERN_DATE = /^([2-9]\d\d\d)-([1-9]|0[1-9]|1[0-2])-([1-9]|0[1-9]|[12]\d|3[01])$/;
const PATTERN_SUNTIME = /^sunrise|sunriseEnd|sunsetStart|sunset|goldenHour|goldenHourEnd|night|nightEnd|dawn|nauticalDawn|dusk|nauticalDusk|solarNoon|nadir$/;
const PATTERN_MOONTIME = /^rise|set$/;
const PATTERN_AUTO_TIME = /^(?:((?:\d|0\d|1\d|2[0-3]):(?:[0-5]\d)(?::(?:[0-5]\d))?\s*(?:a|am|A|AM|p|pm|P|PM)?)|(sunrise|sunriseEnd|sunsetStart|sunset|goldenHour|goldenHourEnd|night|nightEnd|dawn|nauticalDawn|dusk|nauticalDusk|solarNoon|nadir)|(rise|set)|(?:custom:([0-9a-zA-Z_]+)))$/;
const PATTERN_AUTO_DATETIME = /^(?:([0-9/.\-\s\u200F]+)\s)?(?:(sunrise|sunriseEnd|sunsetStart|sunset|goldenHour|goldenHourEnd|night|nightEnd|dawn|nauticalDawn|dusk|nauticalDusk|solarNoon|nadir)|(rise|set)|(?:custom:([0-9a-zA-Z_]+)))$/;
class TimeError extends Error
{
constructor(message, details)
{
super(message);
this.name = "TimeError";
this.details = details;
}
}
const moment = require("moment-timezone");
const sunCalc = require("suncalc");
require("./moment_locales.js");
function getMoment()
{
const args = Array.prototype.slice.call(arguments, 1);
let ret = undefined;
if (arguments[0].config.timezone)
{
args.push(arguments[0].config.timezone);
ret = moment.tz.apply(null, args);
}
else
{
ret = moment.apply(null, args);
}
ret._hasUserDate = false;
ret.hasUserDate = function(flag)
{
if (flag === undefined)
{
return this._hasUserDate;
}
else
{
this._hasUserDate = flag;
}
};
return ret;
}
function initCustomTimes(times)
{
times.forEach(time =>
{
sunCalc.addTime(time.angle, "__cust_" + time.riseName, "__cust_" + time.setName);
});
}
function validateTimeZone(node)
{
if (node.config.timezone)
{
return (moment.tz.zone(node.config.timezone) != null);
}
else
{
return true;
}
}
function printNodeInfo(node)
{
node.debug(
"Starting node with configuration '"
+ node.config.name + "' (latitude " + node.config.latitude + ", longitude " + node.config.longitude
+ (node.config.timezone ? ", timezone " + node.config.timezone + ")" : ")"));
}
function getCurrentTime(node)
{
const ret = getMoment(node);
ret.locale(node.locale);
return ret;
}
function getTimeFrom(node, source)
{
let ret = undefined;
if (typeof source == "string")
{
ret = getMoment(
node,
source, [
"H:mm", "HH:mm", // 24-hour format
"H:mm:ss", "HH:mm:ss", // 24-hour format (incl. seconds)
"h:mm a", "hh:mm a", // 12-hour format
"h:mm:ss a", "hh:mm:ss a", // 12-hour format (incl. seconds)
"L LT", // locale-specific date and time
"L LTS", // locale-specific date and time (incl. seconds)
moment.ISO_8601], // ISO 8601 datetime
node.locale,
true);
}
else if ((typeof source == "number") && (source >= 0))
{
if (source < 86400000) // value is interpreted as number of milliseconds since midnight
{
const time = moment.utc(source);
ret = getMoment(node).hour(time.hour()).minute(time.minute()).second(time.second()).millisecond(time.millisecond());
}
else
{
ret = getMoment(node, source);
}
}
else if ((typeof source == "object") && (source instanceof Date))
{
ret = getMoment(node, source);
}
if (!ret)
{
// fallback to have at least "something"
ret = moment.invalid();
}
ret.locale(node.locale);
return ret;
}
function getUserTime(RED, node, day, value, timeOnly = false)
{
let ret = undefined;
if (typeof value == "string")
{
if (PATTERN_TIME.test(value))
{
// first, try time-only strings
ret = getMoment(
node,
value, [
"H:mm", "HH:mm", // 24-hour format
"H:mm:ss", "HH:mm:ss", // 24-hour format (incl. seconds)
"h:mm a", "hh:mm a", // 12-hour format
"h:mm:ss a", "hh:mm:ss a"], // 12-hour format (incl. seconds)
true);
if (ret.isValid())
{
// and override the date with the given one
ret = ret.year(day.year()).month(day.month()).date(day.date());
}
}
else if (!timeOnly)
{
// if not time-only, try strings containing date and time
ret = getMoment(
node,
value, [
"L LT", // locale-specific date and time
"L LTS", // locale-specific date and time (incl. seconds)
moment.ISO_8601], // ISO 8601 datetime
node.locale,
true);
ret.hasUserDate(true);
}
}
else if ((typeof value == "number") && (value >= 0))
{
if (value < 86400000) // value is interpreted as number of milliseconds since midnight
{
const time = moment.utc(value);
ret = day.hour(time.hour()).minute(time.minute()).second(time.second()).millisecond(time.millisecond());
}
else if (!timeOnly)
{
ret = getMoment(node, value);
ret.hasUserDate(true);
}
}
if (!ret || !ret.isValid())
{
throw new TimeError(RED._("node-red-contrib-chronos/chronos-config:common.error.invalidTime"), {type: "time", value: value});
}
ret.locale(node.locale);
return ret;
}
function getUserDate(RED, node, value)
{
let ret = undefined;
if (PATTERN_DATE.test(value))
{
ret = getMoment(node, value, "YYYY-MM-DD");
ret.locale(node.locale);
}
if (!ret || !ret.isValid())
{
throw new TimeError(RED._("node-red-contrib-chronos/chronos-config:common.error.invalidDate"), {type: "date", value: value});
}
return ret;
}
function isValidUserTime(value, timeOnly = true)
{
return ((typeof value == "string") && ((timeOnly && PATTERN_TIME.test(value)) || (!timeOnly && value))) ||
((typeof value == "number") && (value >= 0) && ((value < (24 * 60 * 60 * 1000)) || !timeOnly));
}
function isValidUserDate(value)
{
return PATTERN_DATE.test(value);
}
function getSunTime(RED, node, day, type)
{
let sunTimes = sunCalc.getTimes(day.toDate(), node.config.latitude, node.config.longitude);
if (!(type in sunTimes))
{
throw new TimeError(RED._("node-red-contrib-chronos/chronos-config:common.error.invalidName"), {type: "sun", value: type});
}
let ret = null;
if (sunTimes[type])
{
ret = moment(sunTimes[type]);
if (!ret.isValid())
{
ret = null;
}
else
{
if (node.config.timezone)
{
ret.tz(node.config.timezone);
}
ret.locale(node.locale);
}
}
if (!ret)
{
throw new TimeError(RED._("node-red-contrib-chronos/chronos-config:common.error.unavailableTime"), {type: "sun", value: type});
}
return ret;
}
function getMoonTime(RED, node, day, type)
{
let moonTimes = sunCalc.getMoonTimes(day.toDate(), node.config.latitude, node.config.longitude);
let ret = null;
if (moonTimes[type])
{
ret = moment(moonTimes[type]);
if (!ret.isValid())
{
ret = null;
}
else
{
if (node.config.timezone)
{
ret.tz(node.config.timezone);
}
ret.locale(node.locale);
}
}
if (!ret)
{
throw new TimeError(RED._("node-red-contrib-chronos/chronos-config:common.error.unavailableTime"), {type: "moon", value: type, alwaysUp: moonTimes.alwaysUp, alwaysDown: moonTimes.alwaysDown});
}
return ret;
}
function getTime(RED, node, day, type, value)
{
let ret = undefined;
if (type == "time")
{
ret = getUserTime(RED, node, day, value);
}
else if ((type == "sun") || (type == "custom"))
{
ret = getSunTime(RED, node, day.set({"hour": 12, "minute": 0, "second": 0, "millisecond": 0}), (type == "custom") ? "__cust_" + value : value);
}
else if (type == "moon")
{
ret = getMoonTime(RED, node, day.set({"hour": 12, "minute": 0, "second": 0, "millisecond": 0}), value);
}
else if (type == "auto")
{
if (typeof value == "string")
{
let matches = value.match(PATTERN_AUTO_DATETIME);
if (matches)
{
let hasUserDate = false;
let date = undefined;
if (matches[1]) // date part
{
date = getMoment(
node,
matches[1], [
"L", // locale-specific date
"YYYY-MM-DD"], // ISO 8601 date
node.locale,
true);
}
if (!date)
{
date = day;
}
else
{
hasUserDate = true;
}
if (matches[2]) // time part (sun time)
{
ret = getSunTime(RED, node, date.set({"hour": 12, "minute": 0, "second": 0, "millisecond": 0}), matches[2]);
}
else if (matches[3]) // time part (moon time)
{
ret = getMoonTime(RED, node, date.set({"hour": 12, "minute": 0, "second": 0, "millisecond": 0}), matches[2]);
}
else if (matches[4]) // time part (custom sun time)
{
ret = getSunTime(RED, node, date.set({"hour": 12, "minute": 0, "second": 0, "millisecond": 0}), "__cust_" + matches[4]);
}
ret.hasUserDate(hasUserDate);
}
else
{
ret = getUserTime(RED, node, day, value);
}
}
else
{
ret = getUserTime(RED, node, day, value);
}
}
else if (type == "auto:time")
{
if (typeof value == "string")
{
let matches = value.match(PATTERN_AUTO_TIME);
if (matches)
{
if (matches[1]) // specific time
{
ret = getUserTime(RED, node, day, value, true);
}
else if (matches[2]) // sun time
{
ret = getSunTime(RED, node, day.set({"hour": 12, "minute": 0, "second": 0, "millisecond": 0}), matches[2]);
}
else if (matches[3]) // moon time
{
ret = getMoonTime(RED, node, day.set({"hour": 12, "minute": 0, "second": 0, "millisecond": 0}), matches[2]);
}
else if (matches[4]) // custom sun time
{
ret = getSunTime(RED, node, day.set({"hour": 12, "minute": 0, "second": 0, "millisecond": 0}), "__cust_" + matches[4]);
}
}
else
{
throw new TimeError(RED._("node-red-contrib-chronos/chronos-config:common.error.invalidTime"), {type: "time", value: value});
}
}
else
{
ret = getUserTime(RED, node, day, value, true);
}
}
return ret;
}
function retrieveTime(RED, node, msg, baseTime, type, value)
{
let ret = undefined;
if ((type == "env") || (type == "global") || (type == "flow") || (type == "msg"))
{
let ctxValue = undefined;
if (type == "env")
{
ctxValue = RED.util.evaluateNodeProperty(value, type, node);
if (!ctxValue)
{
ctxValue = value;
}
}
else if ((type == "global") || (type == "flow"))
{
const ctx = RED.util.parseContextStore(value);
ctxValue = node.context()[type].get(ctx.key, ctx.store);
}
else
{
ctxValue = RED.util.getMessageProperty(msg, value);
}
if (!ctxValue || ((typeof ctxValue != "number") && (typeof ctxValue != "string")))
{
throw new TimeError(
RED._("node-red-contrib-chronos/chronos-config:common.error.invalidTime"),
{type: type, value: ctxValue});
}
ret = getTime(
RED,
node,
baseTime,
"auto",
ctxValue);
if (!ret.isValid())
{
throw new TimeError(
RED._("node-red-contrib-chronos/chronos-config:common.error.invalidTime"),
{type: type, value: ctxValue});
}
}
else
{
ret = getTime(
RED,
node,
baseTime,
type,
value);
}
return ret;
}
function getDuration(node, input, unit)
{
const ret = moment.duration(input, unit);
ret.locale(node.locale);
return ret;
}
function applyOffset(time, offset, random)
{
return time.add(getRandomizedOffset(offset, random), "minutes");
}
function getJSONataExpression(RED, node, expr)
{
const expression = RED.util.prepareJSONataExpression(expr, node);
expression.assign("node", node.name || "");
expression.assign(
"config", {
name: node.config.name,
latitude: node.config.latitude,
longitude: node.config.longitude,
timezone: node.config.timezone,
locale: node.locale});
expression.registerFunction("millisecond", ts => { return getMoment(node, ts).millisecond(); }, "<(sn):n>");
expression.registerFunction("second", ts => { return getMoment(node, ts).second(); }, "<(sn):n>");
expression.registerFunction("minute", ts => { return getMoment(node, ts).minute(); }, "<(sn):n>");
expression.registerFunction("hour", ts => { return getMoment(node, ts).hour(); }, "<(sn):n>");
expression.registerFunction("day", ts => { return getMoment(node, ts).date(); }, "<(sn):n>");
expression.registerFunction("dayOfWeek", ts => { return getMoment(node, ts).weekday() + 1; }, "<(sn):n>");
expression.registerFunction("dayOfYear", ts => { return getMoment(node, ts).dayOfYear(); }, "<(sn):n>");
expression.registerFunction("week", ts => { return getMoment(node, ts).week(); }, "<(sn):n>");
expression.registerFunction("month", ts => { return getMoment(node, ts).month() + 1; }, "<(sn):n>");
expression.registerFunction("quarter", ts => { return getMoment(node, ts).quarter(); }, "<(sn):n>");
expression.registerFunction("year", ts => { return getMoment(node, ts).year(); }, "<(sn):n>");
expression.registerFunction("time", (ts, time, offset, random) => { return applyOffset(getTime(RED, node, getMoment(node, ts), "time", time), offset, random).valueOf(); }, "<(sn)(sn)n?(nb)?:n>");
expression.registerFunction("sunTime", (ts, pos, offset, random) => { return applyOffset(getTime(RED, node, getMoment(node, ts), "sun", pos), offset, random).valueOf(); }, "<(sn)sn?(nb)?:n>");
expression.registerFunction("moonTime", (ts, pos, offset, random) => { return applyOffset(getTime(RED, node, getMoment(node, ts), "moon", pos), offset, random).valueOf(); }, "<(sn)sn?(nb)?:n>");
return expression;
}
function evaluateJSONataExpression(RED, expr, msg)
{
return new Promise((resolve, reject) =>
{
RED.util.evaluateJSONataExpression(expr, msg, (err, res) =>
{
if (err)
{
reject(err);
}
else
{
resolve(res);
}
});
});
}
function evaluateNodeProperty(RED, node, value, type, msg)
{
return new Promise((resolve, reject) =>
{
RED.util.evaluateNodeProperty(value, type, node, msg, (err, val) =>
{
if (err)
{
reject(err);
}
else
{
resolve(val);
}
});
});
}
function getRandomizedOffset(offset, random)
{
let ret = (typeof offset == "number") ? offset : 0;
if (random === true) // backward compatibility to v1.26.1 and below
{
ret = Math.round(Math.random() * offset);
}
else if ((typeof random == "number") && (random > 0))
{
ret = Math.round(offset - (random / 2) + (Math.random() * random));
}
return ret;
}
function validateOffset(data)
{
if ((typeof data.offset != "undefined") && (typeof data.offset != "number"))
{
return false;
}
if ((typeof data.offset == "number") && ((data.offset < -300) || (data.offset > 300)))
{
return false;
}
if ((typeof data.random != "undefined") &&
(typeof data.random != "boolean") &&
(typeof data.random != "number"))
{
return false;
}
if ((typeof data.random == "number") && ((data.random < 0) || (data.random > 300)))
{
return false;
}
return true;
}
module.exports =
{
initCustomTimes: initCustomTimes,
validateTimeZone: validateTimeZone,
validateOffset: validateOffset,
printNodeInfo: printNodeInfo,
getCurrentTime: getCurrentTime,
getTimeFrom: getTimeFrom,
getUserTime: getUserTime,
getSunTime: getSunTime,
getMoonTime: getMoonTime,
getTime: getTime,
getUserDate: getUserDate,
getDuration: getDuration,
retrieveTime: retrieveTime,
isValidUserTime: isValidUserTime,
isValidUserDate: isValidUserDate,
getJSONataExpression: getJSONataExpression,
evaluateJSONataExpression: evaluateJSONataExpression,
evaluateNodeProperty: evaluateNodeProperty,
getRandomizedOffset: getRandomizedOffset,
TimeError: TimeError,
PATTERN_SUNTIME: PATTERN_SUNTIME,
PATTERN_MOONTIME: PATTERN_MOONTIME,
PATTERN_AUTO_TIME: PATTERN_AUTO_TIME,
PATTERN_AUTO_DATETIME: PATTERN_AUTO_DATETIME
};