node-red-contrib-cron-plus
Version:
A flexible scheduler (cron, solar events, fixed dates) node for Node-RED with full dynamic control and time zone support
1,165 lines (1,096 loc) • 130 kB
JavaScript
/*
MIT License
Copyright (c) 2019, 2020, 2021, 2022 Steve-Mcl
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.
*/
const cronstrue = require('cronstrue')
const cronosjs = require('cronosjs')
const prettyMs = require('pretty-ms')
const coordParser = require('coord-parser')
const SunCalc = require('suncalc2')
const path = require('path')
const fs = require('fs')
SunCalc.addTime(-18, 'nightEnd', 'nightStart')
SunCalc.addTime(-6, 'civilDawn', 'civilDusk')
SunCalc.addTime(6, 'morningGoldenHourEnd', 'eveningGoldenHourStart')
const PERMITTED_SOLAR_EVENTS = [
'nightEnd',
// "astronomicalDawn",
'nauticalDawn',
'civilDawn',
// "morningGoldenHourStart",
'sunrise',
'sunriseEnd',
'morningGoldenHourEnd',
'solarNoon',
'eveningGoldenHourStart',
'sunsetStart',
'sunset',
// "eveningGoldenHourEnd",
'civilDusk',
'nauticalDusk',
// "astronomicalDusk",
'nightStart',
'nadir'
]
// accepted commands using topic as the command & (in compatible cases, the payload is the schedule name)
// commands not supported by topic are : add/update & describe
const controlTopics = [
{ command: 'trigger', payloadIsName: true },
{ command: 'status', payloadIsName: true },
{ command: 'list', payloadIsName: true },
{ command: 'export', payloadIsName: true },
{ command: 'stop', payloadIsName: true },
{ command: 'stop-all', payloadIsName: false },
{ command: 'stop-all-dynamic', payloadIsName: false },
{ command: 'stop-all-static', payloadIsName: false },
{ command: 'pause', payloadIsName: true },
{ command: 'pause-all', payloadIsName: false },
{ command: 'pause-all-dynamic', payloadIsName: false },
{ command: 'pause-all-static', payloadIsName: false },
{ command: 'start', payloadIsName: true },
{ command: 'start-all', payloadIsName: false },
{ command: 'start-all-dynamic', payloadIsName: false },
{ command: 'start-all-static', payloadIsName: false },
{ command: 'clear', payloadIsName: false },
{ command: 'remove', payloadIsName: true },
{ command: 'delete', payloadIsName: true },
{ command: 'debug', payloadIsName: true },
{ command: 'next', payloadIsName: false }
]
const addExtendedControlTopics = function (baseCommand) {
controlTopics.push({ command: `${baseCommand}-all`, payloadIsName: false })
controlTopics.push({ command: `${baseCommand}-all-dynamic`, payloadIsName: false })
controlTopics.push({ command: `${baseCommand}-all-static`, payloadIsName: false })
controlTopics.push({ command: `${baseCommand}-active`, payloadIsName: false })
controlTopics.push({ command: `${baseCommand}-active-dynamic`, payloadIsName: false })
controlTopics.push({ command: `${baseCommand}-active-static`, payloadIsName: false })
controlTopics.push({ command: `${baseCommand}-inactive`, payloadIsName: false })
controlTopics.push({ command: `${baseCommand}-inactive-dynamic`, payloadIsName: false })
controlTopics.push({ command: `${baseCommand}-inactive-static`, payloadIsName: false })
}
addExtendedControlTopics('trigger')
addExtendedControlTopics('status')
addExtendedControlTopics('export')
addExtendedControlTopics('list')
addExtendedControlTopics('remove')
addExtendedControlTopics('delete')
addExtendedControlTopics('debug')
/**
* Humanize a cron express
* @param {string} expression the CRON expression to humanize
* @returns {string}
* A human readable version of the expression
*/
const humanizeCron = function (expression, locale) {
try {
const opt = { use24HourTimeFormat: true }
if (locale) opt.locale = locale
return cronstrue.toString(expression, opt)
} catch (error) {
return `Cannot parse expression '${expression}'`
}
}
/**
* Validate a schedule options. Returns true if OK otherwise throws an appropriate error
* @param {object} opt the options object to validate
* @param {boolean} permitDefaults allow certain items to be a default (missing value)
* @returns {boolean}
*/
function validateOpt (opt, permitDefaults = true) {
if (!opt) {
throw new Error('Schedule options are undefined')
}
if (!opt.name) {
throw new Error('Schedule name property missing')
}
if (!opt.expressionType || opt.expressionType === 'cron' || opt.expressionType === 'dates') { // cron
if (!opt.expression) {
throw new Error(`Schedule '${opt.name}' - expression property missing`)
}
let valid = false
try {
valid = cronosjs.validate(opt.expression)
if (valid) { opt.expressionType = 'cron' }
} catch (error) {
console.debug(error)
}
try {
if (!valid) {
valid = isDateSequence(opt.expression)
if (valid) { opt.expressionType = 'dates' }
}
} catch (error) {
console.debug(error)
}
if (!valid) {
throw new Error(`Schedule '${opt.name}' - expression '${opt.expression}' must be either a cron expression, a date, an a array of dates or a CSV of dates`)
}
} else if (opt.expressionType === 'solar') {
if (!opt.offset) {
opt.offset = 0
}
if (opt.locationType === 'fixed' || opt.locationType === 'env') {
// location comes from node
} else {
if (!opt.location) {
throw new Error(`Schedule '${opt.name}' - location property missing`)
}
}
if (opt.solarType !== 'selected' && opt.solarType !== 'all') {
throw new Error(`Schedule '${opt.name}' - solarType property invalid or mising. Must be either "all" or "selected"`)
}
if (opt.solarType === 'selected') {
if (!opt.solarEvents) {
throw new Error(`Schedule '${opt.name}' - solarEvents property missing`)
}
let solarEvents
if (typeof opt.solarEvents === 'string') {
solarEvents = opt.solarEvents.split(',')
} else if (Array.isArray(opt.solarEvents)) {
solarEvents = opt.solarEvents
} else {
throw new Error(`Schedule '${opt.name}' - solarEvents property is invalid`)
}
if (!solarEvents.length) {
throw new Error(`Schedule '${opt.name}' - solarEvents property is empty`)
}
for (let index = 0; index < solarEvents.length; index++) {
const element = solarEvents[index].trim()
if (!PERMITTED_SOLAR_EVENTS.includes(element)) {
throw new Error(`Schedule '${opt.name}' - solarEvents entry '${element}' is invalid`)
}
}
}
} else {
throw new Error(`Schedule '${opt.name}' - invalid schedule type '${opt.expressionType}'. Expected expressionType to be 'cron', 'dates' or 'solar'`)
}
if (permitDefaults) {
opt.payload = ((opt.payload === null || opt.payload === '') && opt.payloadType === 'num') ? 0 : opt.payload
opt.payload = ((opt.payload === null || opt.payload === '') && opt.payloadType === 'str') ? '' : opt.payload
opt.payload = ((opt.payload === null || opt.payload === '') && opt.payloadType === 'bool') ? false : opt.payload
}
if (!opt.payloadType === 'default' && opt.payload === null) {
throw new Error(`Schedule '${opt.name}' - payload property missing`)
}
const okTypes = ['default', 'flow', 'global', 'str', 'num', 'bool', 'json', 'jsonata', 'bin', 'date', 'env']
// eslint-disable-next-line eqeqeq
const typeOK = okTypes.find(el => { return el == opt.payloadType })
if (!typeOK) {
throw new Error(`Schedule '${opt.name}' - type property '${opt.payloadType}' is not valid. Must be one of the following... ${okTypes.join(',')}`)
}
return true
}
/**
* Tests if a string or array of date like items are a date or date sequence
* @param {String|Array} data An array of date like entries or a CSV string of dates
*/
function isDateSequence (data) {
try {
const ds = parseDateSequence(data)
return (ds && ds.isDateSequence)
// eslint-disable-next-line no-empty
} catch (error) { }
return false
}
/**
* Returns an object describing the parameters.
* @param {string} expression The expressions or coordinates to use
* @param {string} expressionType The expression type ("cron" | "solar" | "dates")
* @param {string} timeZone An optional timezone to use
* @param {number} offset An optional offset to apply
* @param {string} solarType Specifies either "all" or "selected" - related to solarEvents property
* @param {string} solarEvents a CSV of solar events to be included
* @param {date} time Optional time to use (defaults to Date.now() if excluded)
*/
function _describeExpression (expression, expressionType, timeZone, offset, solarType, solarEvents, time, opts) {
const now = time ? new Date(time) : new Date()
opts = opts || {}
let result = { description: undefined, nextDate: undefined, nextDescription: undefined, prettyNext: 'Never' }
const cronOpts = timeZone ? { timezone: timeZone } : undefined
let ds = null
let dsOk = false
let exOk = false
// let now = new Date();
if (solarType === 'all') {
solarEvents = PERMITTED_SOLAR_EVENTS.join(',')
}
if (expressionType === 'solar') {
const opt = {
locationType: opts.locationType || opts.defaultLocationType,
defaultLocationType: opts.defaultLocationType,
defaultLocation: opts.defaultLocation,
expressionType,
location: expression,
offset: offset || 0,
name: 'dummy',
solarType,
solarEvents,
payloadType: 'default',
payload: ''
}
if (validateOpt(opt)) {
const pos = coordParser(opt.location)
const offset = isNumber(opt.offset) ? parseInt(opt.offset) : 0
const nowOffset = new Date(now.getTime() - offset * 60000)
result = getSolarTimes(pos.lat, pos.lon, 0, solarEvents, now, offset)
// eslint-disable-next-line eqeqeq
if (opts.includeSolarStateOffset && offset != 0) {
const ssOffset = getSolarTimes(pos.lat, pos.lon, 0, solarEvents, nowOffset, 0)
result.solarStateOffset = ssOffset.solarState
}
result.offset = offset
result.now = now
result.nowOffset = nowOffset
ds = parseDateSequence(result.eventTimes.map((event) => event.timeOffset))
dsOk = ds && ds.isDateSequence
}
} else {
if (expressionType === 'cron' || expressionType === '') {
exOk = cronosjs.validate(expression)
} else {
ds = parseDateSequence(expression)
dsOk = ds.isDateSequence
}
if (!exOk && !dsOk) {
result.description = 'Invalid expression'
return result
}
}
if (dsOk) {
const task = ds.task
const dates = ds.dates
const dsFutureDates = dates.filter(d => d >= now)
const count = dsFutureDates ? dsFutureDates.length : 0
result.description = 'Date sequence with fixed dates'
if (task && task._sequence && count) {
result.nextDate = dsFutureDates[0]
const ms = result.nextDate.valueOf() - now.valueOf()
result.prettyNext = (result.nextEvent ? result.nextEvent + ' ' : '') + `in ${prettyMs(ms, { secondsDecimalDigits: 0, verbose: true })}`
if (expressionType === 'solar') {
if (solarType === 'all') {
result.description = 'All Solar Events'
} else {
result.description = "Solar Events: '" + solarEvents.split(',').join(', ') + "'"
}
} else {
if (count === 1) {
result.description = 'One time at ' + formatShortDateTimeWithTZ(result.nextDate, timeZone)
} else {
result.description = count + ' Date Sequences starting at ' + formatShortDateTimeWithTZ(result.nextDate, timeZone)
}
result.nextDates = dsFutureDates.slice(0, 5)
}
}
}
if (exOk) {
const ex = cronosjs.CronosExpression.parse(expression, cronOpts)
const next = ex.nextDate()
if (next) {
const ms = next.valueOf() - now.valueOf()
result.prettyNext = `in ${prettyMs(ms, { secondsDecimalDigits: 0, verbose: true })}`
try {
result.nextDates = ex.nextNDates(now, 5)
} catch (error) {
console.debug(error)
}
}
result.description = humanizeCron(expression)
result.nextDate = next
}
return result
}
/**
* Returns a formatted string based on the provided tz.
* If tz is not specified, then Date.toString() is used
* @param {Date | string | number} date The date to format
* @param {string} [tz] Timezone to use (exclude to use system)
* @returns {string}
* The formatted date or empty string if `date` is null|undefined
*/
function formatShortDateTimeWithTZ (date, tz) {
if (!date) {
return ''
}
let dateString
const o = {
timeZone: tz || undefined,
timeZoneName: 'short',
hourCycle: 'h23',
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}
try {
dateString = new Intl.DateTimeFormat('default', o).format(new Date(date))
} catch (error) {
dateString = 'Error. Check timezone setting'
}
return dateString
}
/**
* Determine if a variable is a number
* @param {string|number} n The string or number to test
* @returns {boolean}
*/
function isNumber (n) {
return !isNaN(parseFloat(n)) && isFinite(n)
}
/**
* Determine if a variable is a valid object
* NOTE: Arrays are also objects - be sure to use Array.isArray if you need to know the difference
* @param {*} o The variable to test
* @returns {boolean}
*/
function isObject (o) {
return (typeof o === 'object' && o !== null)
}
/**
* Determine if a variable is a valid date
* @param {*} d The variable to test
* @returns {boolean}
*/
function isValidDateObject (d) {
return d instanceof Date && !isNaN(d)
}
/**
* Determine if a variable is a cron like string
* @param {string} expression The variable to test
* @returns {boolean}
*/
function isCronLike (expression) {
if (typeof expression !== 'string') return false
if (expression.includes('*')) return true
const cleaned = expression.replace(/\s\s+/g, ' ')
const spaces = cleaned.split(' ')
return spaces.length >= 4 && spaces.length <= 6
}
/**
* Apply defaults to the cron schedule object
* @param {integer} optionIndex An index number to use for defaults
* @param {object} option The option object to update
*/
function applyOptionDefaults (node, option, optionIndex) {
if (isObject(option) === false) {
return// no point in continuing
}
optionIndex = optionIndex == null ? 0 : optionIndex
// eslint-disable-next-line eqeqeq
if (option.expressionType == '') {
if (isDateSequence(option.expression)) {
option.expressionType = 'dates'
} else {
option.expressionType = 'cron'// if empty, default to cron
}
} else if (['cron', 'dates', 'solar'].indexOf(option.expressionType) < 0) {
// if expressionType is not cron or solar - it might be sunrise or sunset from an older version
if (option.expressionType === 'sunrise') {
option.solarEvents = option.solarEvents || 'sunrise'
option.expressionType = 'solar'
} else if (option.expressionType === 'sunset') {
option.solarEvents = option.solarEvents || 'sunset'
option.expressionType = 'solar'
} else {
option.expressionType = 'cron'
}
}
option.name = option.name || 'schedule' + (optionIndex + 1)
option.topic = option.topic || option.name
option.payloadType = option.payloadType || option.type
if (option.payloadType == null && typeof option.payload === 'string' && option.payload.length) {
option.payloadType = 'str'
}
option.payloadType = option.payloadType || 'default'
delete option.type
if (option.expressionType === 'cron' && !option.expression) option.expression = '0 * * * * * *'
if (option.expressionType === 'solar') {
if (!option.solarType) option.solarType = option.solarEvents ? 'selected' : 'all'
if (!option.solarEvents) option.solarEvents = 'sunrise,sunset'
if (!option.location) option.location = ''
option.locationType = node.defaultLocationType
}
}
function parseDateSequence (expression) {
const result = { isDateSequence: false, expression }
let dates = expression
if (typeof expression === 'string') {
const spl = expression.split(',')
for (let index = 0; index < spl.length; index++) {
spl[index] = spl[index].trim()
if (isCronLike(spl[index])) {
return result// fail
}
}
dates = spl.map(x => {
if (isNumber(x)) {
x = parseInt(x)
}
const d = new Date(x)
return d
})
}
const ds = new cronosjs.CronosTask(dates)
if (ds && ds._sequence) {
result.dates = ds._sequence._dates
result.task = ds
result.isDateSequence = true
}
return result
}
function parseSolarTimes (opt) {
// opt.location = location || ''
const pos = coordParser(opt.location || '0.0,0.0')
const offset = opt.offset ? parseInt(opt.offset) : 0
const date = opt.date ? new Date(opt.date) : new Date()
const events = opt.solarType === 'all' ? PERMITTED_SOLAR_EVENTS : opt.solarEvents
const result = getSolarTimes(pos.lat, pos.lon, 0, events, date, offset)
const task = parseDateSequence(result.eventTimes.map((o) => o.timeOffset))
task.solarEventTimes = result
return task
}
function getSolarTimes (lat, lng, elevation, solarEvents, startDate = null, offset = 0) {
// performance.mark('Start');
const solarEventsPast = [...PERMITTED_SOLAR_EVENTS]
const solarEventsFuture = [...PERMITTED_SOLAR_EVENTS]
const solarEventsArr = []
// get list of usable solar events into solarEventsArr
let solarEventsArrTemp = []
if (typeof solarEvents === 'string') {
solarEventsArrTemp = solarEvents.split(',')
} else if (Array.isArray(solarEvents)) {
solarEventsArrTemp = [...solarEvents]
} else {
throw new Error('solarEvents must be a CSV or Array')
}
for (let index = 0; index < solarEventsArrTemp.length; index++) {
const se = solarEventsArrTemp[index].trim()
if (PERMITTED_SOLAR_EVENTS.includes(se)) {
solarEventsArr.push(se)
}
}
offset = isNumber(offset) ? parseInt(offset) : 0
elevation = isNumber(elevation) ? parseInt(elevation) : 0// not used for now
startDate = startDate ? new Date(startDate) : new Date()
let scanDate = new Date(startDate.toDateString()) // new Date(startDate); //scanDate = new Date(startDate.toDateString())
scanDate.setDate(scanDate.getDate() + 1)// fwd one day to catch times behind of scan day
let loopMonitor = 0
const result = []
// performance.mark('initEnd')
// performance.measure('Start to Now', 'Start', 'initEnd')
// performance.mark('FirstScanStart');
// first scan backwards to get prior solar events
while (loopMonitor < 3 && solarEventsPast.length) {
loopMonitor++
const timesIteration1 = SunCalc.getTimes(scanDate, lat, lng)
// timesIteration1 = new SolarCalc(scanDate,lat,lng);
for (let index = 0; index < solarEventsPast.length; index++) {
const se = solarEventsPast[index]
const seTime = timesIteration1[se]
const seTimeOffset = new Date(seTime.getTime() + offset * 60000)
if (isValidDateObject(seTimeOffset) && seTimeOffset <= startDate) {
result.push({ event: se, time: seTime, timeOffset: seTimeOffset })
solarEventsPast.splice(index, 1)// remove that item
index--
}
}
scanDate.setDate(scanDate.getDate() - 1)
}
scanDate = new Date(startDate.toDateString())
scanDate.setDate(scanDate.getDate() - 1)// back one day to catch times ahead of current day
loopMonitor = 0
// now scan forwards to get future events
while (loopMonitor < 183 && solarEventsFuture.length) {
loopMonitor++
const timesIteration2 = SunCalc.getTimes(scanDate, lat, lng)
// timesIteration2 = new SolarCalc(scanDate,lat,lng);
for (let index = 0; index < solarEventsFuture.length; index++) {
const se = solarEventsFuture[index]
const seTime = timesIteration2[se]
const seTimeOffset = new Date(seTime.getTime() + offset * 60000)
if (isValidDateObject(seTimeOffset) && seTimeOffset > startDate) {
result.push({ event: se, time: seTime, timeOffset: seTimeOffset })
solarEventsFuture.splice(index, 1)// remove that item
index--
}
}
scanDate.setDate(scanDate.getDate() + 1)
}
// performance.mark('SecondScanEnd');
// performance.measure('FirstScanEnd to SecondScanEnd', 'FirstScanEnd', 'SecondScanEnd');
// sort the results to get a timeline
const sorted = result.sort((a, b) => {
if (a.time < b.time) {
return -1
} else if (a.time > b.time) {
return 1
} else {
return 0
}
})
// now scan through sorted solar events to determine day/night/twilight etc
let state = ''; const solarState = {}
for (let index = 0; index < sorted.length; index++) {
const event = sorted[index]
if (event.time < startDate) {
switch (event.event) {
case 'nightEnd':
state = 'Astronomical Twilight'// todo: i18n
updateSolarState(solarState, state, 'rise', false, false, true, false, false, false, false)
break
// case "astronomicalDawn":
// state = "Astronomical Twilight";//todo: i18n
// updateSolarState(solarState,state,"rise",false,false,true,false,false,false,false);
// break;
case 'nauticalDawn':
state = 'Nautical Twilight'
updateSolarState(solarState, state, 'rise', false, false, false, true, false, false, false)
break
case 'civilDawn':
state = 'Civil Twilight'
updateSolarState(solarState, state, 'rise', false, false, false, false, true, true, false)
break
// case "morningGoldenHourStart":
// updateSolarState(solarState,null,"rise",false,false,false,false,true,true,false);
// break;
case 'sunrise':
state = 'Civil Twilight'
updateSolarState(solarState, state, 'rise', false, false, false, false, true, true, false)
break
case 'sunriseEnd':
state = 'Day'
updateSolarState(solarState, state, 'rise', true, false, false, false, false, true, false)
break
case 'morningGoldenHourEnd':
state = 'Day'
updateSolarState(solarState, state, 'rise', true, false, false, false, false, false, false)
break
case 'solarNoon':
updateSolarState(solarState, null, 'fall')
break
case 'eveningGoldenHourStart':
state = 'Day'
updateSolarState(solarState, state, 'fall', true, false, false, false, false, false, true)
break
case 'sunsetStart':
state = 'Day'
updateSolarState(solarState, state, 'fall', true, false, false, false, false, false, true)
break
case 'sunset':
state = 'Civil Twilight'
updateSolarState(solarState, state, 'fall', false, false, false, false, true, false, true)
break
// case "eveningGoldenHourEnd":
// state = "Nautical Twilight";
// updateSolarState(solarState,state,"fall",false,false,false,false,true,false,false);
// break;
case 'civilDusk':
state = 'Nautical Twilight'
updateSolarState(solarState, state, 'fall', false, false, false, true, false, false, false)
break
case 'nauticalDusk':
state = 'Astronomical Twilight'
updateSolarState(solarState, state, 'fall', false, false, true, false, false, false, false)
break
// case "astronomicalDusk":
case 'night':
case 'nightStart':
state = 'Night'
updateSolarState(solarState, state, 'fall', false, true, false, false, false, false, false)
break
case 'nadir':
updateSolarState(solarState, null, 'rise')
break
}
} else {
break
}
}
// update final states
updateSolarState(solarState)// only sending `stateObject` makes updateSolarState() compute dawn/dusk etc
// now filter to only events of interest
const futureEvents = sorted.filter((e) => e && e.timeOffset >= startDate)
const wantedFutureEvents = []
for (let index = 0; index < futureEvents.length; index++) {
const fe = futureEvents[index]
if (solarEventsArr.includes(fe.event)) {
wantedFutureEvents.push(fe)
}
}
const nextType = wantedFutureEvents[0].event
const nextTime = wantedFutureEvents[0].time
const nextTimeOffset = wantedFutureEvents[0].timeOffset
// performance.mark('End')
// performance.measure('SecondScanEnd to End', 'SecondScanEnd', 'End')
// performance.measure('Start to End', 'Start', 'End')
return {
solarState,
nextEvent: nextType,
nextEventTime: nextTime,
nextEventTimeOffset: nextTimeOffset,
eventTimes: wantedFutureEvents
// allTimes: sorted,
// eventTimesByType: resultCategories
}
function updateSolarState (stateObject, state, direction, day, night,
astrologicalTwilight, nauticalTwilight, civilTwilight,
morningGoldenHour, eveningGoldenHour) {
if (arguments.length > 1) {
if (state) stateObject.state = state
stateObject.direction = direction
if (arguments.length > 3) {
stateObject.day = day
stateObject.night = night
stateObject.astrologicalTwilight = astrologicalTwilight
stateObject.nauticalTwilight = nauticalTwilight
stateObject.civilTwilight = civilTwilight
stateObject.goldenHour = morningGoldenHour || eveningGoldenHour
stateObject.twilight = stateObject.astrologicalTwilight || stateObject.nauticalTwilight || stateObject.civilTwilight
}
return
}
stateObject.morningTwilight = stateObject.direction === 'rise' && stateObject.twilight
stateObject.eveningTwilight = stateObject.direction === 'fall' && stateObject.twilight
stateObject.dawn = stateObject.direction === 'rise' && stateObject.civilTwilight
stateObject.dusk = stateObject.direction === 'fall' && stateObject.civilTwilight
stateObject.morningGoldenHour = stateObject.direction === 'rise' && stateObject.goldenHour
stateObject.eveningGoldenHour = stateObject.direction === 'fall' && stateObject.goldenHour
}
}
function exportTask (task, includeStatus) {
const o = {
topic: task.node_topic || task.name,
name: task.name || task.node_topic,
index: task.node_index,
payloadType: task.node_payloadType,
payload: task.node_payload,
limit: task.node_limit || null,
expressionType: task.node_expressionType
}
if (o.expressionType === 'solar') {
o.solarType = task.node_solarType
o.solarEvents = task.node_solarEvents
o.location = task.node_location
o.offset = task.node_offset
} else {
o.expression = task.node_expression
}
if (includeStatus) {
o.isDynamic = task.isDynamic === true
o.modified = task.node_modified === true
o.isRunning = task.isRunning === true
o.count = task.node_count
}
return o
}
function isTaskFinished (_task) {
if (!_task) return true
return _task.node_limit ? _task.node_count >= _task.node_limit : false
}
function getTaskStatus (node, task, opts) {
opts = opts || {}
opts.locationType = node.defaultLocationType
opts.defaultLocation = node.defaultLocation
opts.defaultLocationType = node.defaultLocationType
const sol = task.node_expressionType === 'solar'
const exp = sol ? task.node_location : task.node_expression
const h = _describeExpression(exp, task.node_expressionType, node.timeZone, task.node_offset, task.node_solarType, task.node_solarEvents, null, opts)
let nextDescription = null
let nextDate = null
const running = !isTaskFinished(task)
if (running) {
// nextDescription = h.nextDescription;
nextDescription = h.prettyNext
nextDate = sol ? h.nextEventTimeOffset : h.nextDate
}
let tz = node.timeZone
let localTZ = ''
try {
localTZ = Intl.DateTimeFormat().resolvedOptions().timeZone
if (!tz) tz = localTZ
// eslint-disable-next-line no-empty
} catch (error) { }
const r = {
type: task.isDynamic ? 'dynamic' : 'static',
modified: !!task.modified,
isRunning: running && task.isRunning,
count: task.node_count,
limit: task.node_limit,
nextDescription,
nextDate: running ? nextDate : null,
nextDateTZ: running ? formatShortDateTimeWithTZ(nextDate, tz) : null,
timeZone: tz,
serverTime: new Date(),
serverTimeZone: localTZ,
description: h.description
}
if (sol) {
r.solarState = h.solarState
if (h.offset) r.solarStateOffset = h.solarStateOffset
r.solarTimes = running ? h.eventTimes : null
r.nextDescription = running ? nextDescription : null// r.solarTimes && (r.solarTimes[0].event + " " + r.nextDescription);
}
return r
}
let userDir = ''
let persistPath = ''
let FSAvailable = false
let contextAvailable = false
const cronplusDir = 'cronplusdata'
module.exports = function (RED) {
const STORE_NAMES = getStoreNames()
// when running tests, RED.settings.userDir & RED.settings.settingsFile (amongst others) are undefined
const testMode = typeof RED.settings.userDir === 'undefined' && typeof RED.settings.settingsFile === 'undefined'
if (testMode) {
FSAvailable = false
contextAvailable = false
} else {
userDir = RED.settings.userDir || ''
persistPath = path.join(userDir, cronplusDir)
try {
if (!fs.existsSync(persistPath)) {
fs.mkdirSync(persistPath)
}
FSAvailable = fs.existsSync(persistPath)
} catch (e) {
if (e.code !== 'EEXIST') {
RED.log.error(`cron-plus: Error creating persistence folder '${persistPath}'. ${e.message}`)
FSAvailable = false
}
}
contextAvailable = STORE_NAMES.length > 2 // 1st 2 are 'none' and 'file', any others are context stores
}
function CronPlus (config) {
RED.nodes.createNode(this, config)
const node = this
node.name = config.name
node.payloadType = config.payloadType || config.type || 'default'
delete config.type
node.payload = config.payload
node.crontab = config.crontab
node.outputField = config.outputField || 'payload'
node.timeZone = config.timeZone
node.options = config.options
node.commandResponseMsgOutput = config.commandResponseMsgOutput || 'output1'
node.defaultLocation = config.defaultLocation
node.defaultLocationType = config.defaultLocationType
node.outputs = 1
node.fanOut = false
node.queuedSerialisationRequest = null
node.serialisationRequestBusy = null
setInterval(async function () {
if (node.serialisationRequestBusy) return
if (node.queuedSerialisationRequest) {
node.serialisationRequestBusy = node.queuedSerialisationRequest
await serialise()
node.queuedSerialisationRequest = null
node.serialisationRequestBusy = null
}
}, 2500) // 2.5 seconds
// convert node.persistDynamic to a getter/setter
Object.defineProperty(node, 'persistDynamic', {
get: function () {
return !!node.storeName
}
})
// inherit/upgrade deprecated properties from config
const hasStoreNameProperty = Object.prototype.hasOwnProperty.call(config, 'storeName') && typeof config.storeName === 'string'
const hasDeprecatedPersistDynamic = Object.prototype.hasOwnProperty.call(config, 'persistDynamic') && typeof config.persistDynamic === 'boolean'
if (hasStoreNameProperty) {
// not an upgrade - let use this property
node.storeName = config.storeName
} else if (hasDeprecatedPersistDynamic) {
// upgrade from older version
node.storeName = config.persistDynamic ? 'file' : ''
} else {
// default
node.storeName = ''
}
if (node.storeName && node.storeName !== 'file' && STORE_NAMES.indexOf(node.storeName) < 0) {
node.warn(`Invalid store name specified '${node.storeName}' - state will not be persisted for this node`)
contextAvailable = false
}
if (config.commandResponseMsgOutput === 'output2') {
node.outputs = 2 // 1 output pins (all messages), 2 outputs (schedules out of pin1, command responses out of pin2)
} else if (config.commandResponseMsgOutput === 'fanOut') {
node.outputs = 2 + (node.options ? node.options.length : 0)
node.fanOut = true
} else {
config.commandResponseMsgOutput = 'output1'
}
node.statusUpdatePending = false
const MAX_CLOCK_DIFF = Number(RED.settings.CRONPLUS_MAX_CLOCK_DIFF || process.env.CRONPLUS_MAX_CLOCK_DIFF || 5000)
const clockMonitor = setInterval(async function timeChecker () {
const oldTime = timeChecker.oldTime || new Date()
const newTime = new Date()
const timeDiff = newTime - oldTime
timeChecker.oldTime = newTime
if (Math.abs(timeDiff) >= MAX_CLOCK_DIFF) {
node.log('System Time Change Detected - refreshing schedules! If the system time was not changed then this typically occurs due to blocking code elsewhere in your application')
await refreshTasks(node)
}
}, 1000)
const setProperty = function (msg, field, value) {
const set = (obj, path, val) => {
const keys = path.split('.')
const lastKey = keys.pop()
// eslint-disable-next-line no-return-assign
const lastObj = keys.reduce((obj, key) =>
obj[key] = obj[key] || {},
obj)
lastObj[lastKey] = val
}
set(msg, field, value)
}
const updateNodeNextInfo = (node, now) => {
const t = getNextTask(node.tasks)
if (t) {
const indicator = t.isDynamic ? 'ring' : 'dot'
const nx = (t._expression || t._sequence)
node.nextDate = nx.nextDate(now)
node.nextEvent = t.name
node.nextIndicator = indicator
if (t.node_solarEventTimes && t.node_solarEventTimes.nextEvent) {
node.nextEvent = t.node_solarEventTimes.nextEvent
}
} else {
node.nextDate = null
node.nextEvent = ''
node.nextIndicator = ''
}
}
const updateDoneStatus = (node, task) => {
let indicator = 'dot'
if (task) {
indicator = node.nextIndicator || 'dot'
}
node.status({ fill: 'green', shape: indicator, text: 'Done: ' + formatShortDateTimeWithTZ(Date.now(), node.timeZone) })
// node.nextDate = getNextTask(node.tasks);
const now = new Date()
updateNodeNextInfo(node, now)
const next = node.nextDate ? new Date(node.nextDate).valueOf() : (Date.now() + 5001)
const msTillNext = next - now
if (msTillNext > 5000) {
node.statusUpdatePending = true
setTimeout(function () {
node.statusUpdatePending = false
updateNextStatus(node, true)
}, 4000)
}
}
const sendMsg = async (node, task, cronTimestamp, manualTrigger) => {
const msg = { cronplus: {} }
msg.topic = task.node_topic
msg.cronplus.triggerTimestamp = cronTimestamp
const se = task.node_expressionType === 'solar' ? node.nextEvent : ''
msg.cronplus.status = getTaskStatus(node, task, { includeSolarStateOffset: true })
if (se) msg.cronplus.status.solarEvent = se
msg.cronplus.config = exportTask(task)
if (manualTrigger) msg.manualTrigger = true
msg.scheduledEvent = !msg.manualTrigger
const indicator = node.nextIndicator || 'dot'
const taskType = task.isDynamic ? 'dynamic' : 'static'
const index = task.node_index || 0
node.status({ fill: 'green', shape: indicator, text: 'Schedule Started' })
try {
if (task.node_payloadType !== 'flow' && task.node_payloadType !== 'global') {
let pl
if ((task.node_payloadType == null && task.node_payload === '') || task.node_payloadType === 'date') {
pl = Date.now()
} else if (task.node_payloadType == null) {
pl = task.node_payload
} else if (task.node_payloadType === 'none') {
pl = ''
} else if (task.node_payloadType === 'json' && isObject(task.node_payload)) {
pl = task.node_payload
} else if (task.node_payloadType === 'bin' && Array.isArray(task.node_payload)) {
pl = Buffer.from(task.node_payload)
} else if (task.node_payloadType === 'default') {
pl = msg.cronplus
delete msg.cronplus // To delete or not?
} else {
pl = await evaluateNodeProperty(task.node_payload, task.node_payloadType, node, msg)
}
setProperty(msg, node.outputField, pl)
node.send(generateSendMsg(node, msg, taskType, index))
updateDoneStatus(node, task)
} else {
const res = await evaluateNodeProperty(task.node_payload, task.node_payloadType, node, msg)
setProperty(msg, node.outputField, res)
node.send(generateSendMsg(node, msg, taskType, index))
updateDoneStatus(node, task)
}
} catch (err) {
node.error(err, msg)
}
}
(async function () {
try {
node.status({})
node.nextDate = null
if (!node.options) {
node.status({ fill: 'grey', shape: 'dot', text: 'Nothing set' })
return
}
node.tasks = []
for (let iOpt = 0; iOpt < node.options.length; iOpt++) {
const opt = node.options[iOpt]
opt.name = opt.name || opt.topic
node.statusUpdatePending = true// prevent unnecessary status updates while loading
await createTask(node, opt, iOpt, true)
}
// now load dynamic schedules from file
await deserialise()
setTimeout(() => {
updateNextStatus(node, true)
}, 200)
} catch (err) {
if (node.tasks) {
node.tasks.forEach(task => task.stop())
}
node.status({ fill: 'red', shape: 'dot', text: 'Error creating schedule' })
node.error(err)
}
})()
node.on('close', async function (done) {
try {
await serialise()
} catch (error) {
node.error(error)
}
deleteAllTasks(this)
if (clockMonitor) clearInterval(clockMonitor)
if (done && typeof done === 'function') done()
})
this.on('input', async function (msg, send, done) {
send = send || function () { node.send.apply(node, arguments) }
done = done || function (err) {
if (err) {
node.error(err, msg)
}
}
// is this an button press?...
if (!msg.payload && !msg.topic) { // TODO: better method of differentiating between bad input and button press
await sendMsg(node, node.tasks[0], Date.now(), true)
done()
return
}
const controlTopic = controlTopics.find(ct => ct.command === msg.topic)
let payload = msg.payload
if (controlTopic) {
if (controlTopic.payloadIsName) {
if (!payload || typeof payload !== 'string') {
node.error(`Invalid payload! Control topic '${msg.topic}' expects the name of the schedule to be in msg.payload`, msg)
return
}
// emulate the cmd object
payload = {
command: controlTopic.command,
name: payload
}
} else {
payload = {
command: controlTopic.command
}
}
}
if (typeof payload !== 'object') {
return
}
try {
let input = payload
if (Array.isArray(payload) === false) {
input = [input]
}
const sendCommandResponse = function (msg) {
send(generateSendMsg(node, msg, 'command-response'))
}
for (let i = 0; i < input.length; i++) {
const cmd = input[i]
const action = cmd.command || ''
// let newMsg = {topic: msg.topic, payload:{command:cmd, result:{}}};
const newMsg = RED.util.cloneMessage(msg)
newMsg.payload = { command: cmd, result: {} }
const cmdAll = action.endsWith('-all')
const cmdAllStatic = action.endsWith('-all-static')
const cmdAllDynamic = action.endsWith('-all-dynamic')
const cmdActive = action.endsWith('-active')
const cmdInactive = action.endsWith('-inactive')
const cmdActiveDynamic = action.includes('-active-dynamic')
const cmdActiveStatic = action.includes('-active-static')
const cmdInactiveDynamic = action.includes('-inactive-dynamic')
const cmdInactiveStatic = action.includes('-inactive-static')
let cmdFilter = null
const actionParts = action.split('-')
let mainAction = actionParts[0]
if (actionParts.length > 1) mainAction += '-'
if (cmdAllDynamic) {
cmdFilter = 'dynamic'
} else if (cmdAllStatic) {
cmdFilter = 'static'
} else if (cmdActive) {
cmdFilter = 'active'
} else if (cmdInactive) {
cmdFilter = 'inactive'
} else if (cmdActiveDynamic) {
cmdFilter = 'active-dynamic'
} else if (cmdActiveStatic) {
cmdFilter = 'active-static'
} else if (cmdInactiveDynamic) {
cmdFilter = 'inactive-dynamic'
} else if (cmdInactiveStatic) {
cmdFilter = 'inactive-static'
}
switch (mainAction) {
case 'trigger': // single
{
const tt = getTask(node, cmd.name)
if (!tt) throw new Error(`Manual Trigger failed. Cannot find schedule named '${cmd.name}'`)
sendMsg(node, tt, Date.now(), true)
}
break
case 'trigger-': // multiple
if (node.tasks) {
for (let index = 0; index < node.tasks.length; index++) {
const task = node.tasks[index]
if (task && (cmdAll || taskFilterMatch(task, cmdFilter))) {
sendMsg(node, task, Date.now(), true)
}
}
}
break
case 'describe': // single
{
const exp = (cmd.expressionType === 'solar') ? cmd.location : cmd.expression
applyOptionDefaults(node, cmd)
newMsg.payload.result = _describeExpression(exp, cmd.expressionType, cmd.timeZone || node.timeZone, cmd.offset, cmd.solarType, cmd.solarEvents, cmd.time, { includeSolarStateOffset: true, locationType: node.node_locationType })
sendCommandResponse(newMsg)
}
break
case 'list': // single
case 'status': // single
{
const task = getTask(node, cmd.name)
if (task) {
newMsg.payload.result.config = exportTask(task, true)
newMsg.payload.result.status = getTaskStatus(node, task, { includeSolarStateOffset: true })
} else {
newMsg.error = `${cmd.name} not found`
}
sendCommandResponse(newMsg)
}
updateNextStatus(node, true)
break
case 'export': // single
{
const task = getTask(node, cmd.name)
if (task) {