UNPKG

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
/* 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) {