/* 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}'`) await 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))) { await 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) { newMsg.payload.result = exportTask(task, false) } else { newMsg.error = `${cmd.name} not found` } sendCommandResponse(newMsg) } break case 'list-': // multiple case 'status-': // multiple { const results = [] if (node.tasks) { for (let index = 0; index < node.tasks.length; index++) { const task = node.tasks[index] if (task && (cmdAll || taskFilterMatch(task, cmdFilter))) { const result = {} result.config = exportTask(task, true) result.status = getTaskStatus(node, task, { includeSolarStateOffset: true }) results.push(result) } } } newMsg.payload.result = results sendCommandResponse(newMsg) } break case 'export-': // multiple { const results = [] if (node.tasks) { for (let index = 0; index < node.tasks.length; index++) { const task = node.tasks[index] if (cmdAll || taskFilterMatch(task, cmdFilter)) { results.push(exportTask(task, false)) } } } newMsg.payload.result = results sendCommandResponse(newMsg) } break case 'add': // single case 'update': // single await updateTask(node, cmd, msg) updateNextStatus(node, true) requestSerialisation()// update persistence break case 'clear': case 'remove-': // multiple case 'delete-': // multiple deleteAllTasks(node, cmdFilter) updateNextStatus(node, true) requestSerialisation()// update persistence break case 'remove': // single case 'delete': // single deleteTask(node, cmd.name) updateNextStatus(node, true) requestSerialisation()// update persistence break case 'start': // single await startTaskByName(node, cmd.name) updateNextStatus(node, true) requestSerialisation()// update persistence break case 'start-': // multiple await startAllTasks(node, cmdFilter) updateNextStatus(node, true) requestSerialisation()// update persistence break case 'stop': // single case 'pause': // single stopTask(node, cmd.name, cmd.command === 'stop') updateNextStatus(node, true) requestSerialisation()// update persistence break case 'stop-': // multiple case 'pause-': { const resetCounter = cmd.command.startsWith('stop-') stopAllTasks(node, resetCounter, cmdFilter) updateNextStatus(node, true) requestSerialisation()// update persistence } break case 'next': if (node.tasks && node.tasks.length) { // gather statuses const statuses = [] for (let index = 0; index < node.tasks.length; index++) { const task = node.tasks[index] const result = {} result.config = exportTask(task, true) result.status = getTaskStatus(node, task, { includeSolarStateOffset: true }) statuses.push(result) } const next = statuses.length && statuses.reduce((a, b) => a.status.nextDate < b.status.nextDate ? a : b) if (next) { newMsg.payload = { name: next.config.name, topic: next.config.topic, next: next.status.nextDate, nextLocal: next.status.nextDateTZ, timeZone: next.status.serverTimeZone, when: next.status.description, msUntil: next.status.nextDate.valueOf() - next.status.serverTime.valueOf(), description: next.status.nextDescription } } else { newMsg.payload = {} } } else { newMsg.payload = {} } sendCommandResponse(newMsg) break case 'debug': { const task = getTask(node, cmd.name) const thisDebug = getTaskStatus(node, task, { includeSolarStateOffset: true }) thisDebug.name = task.name thisDebug.topic = task.node_topic thisDebug.expressionType = task.node_expressionType thisDebug.expression = task.node_expression thisDebug.location = task.node_location thisDebug.offset = task.node_offset thisDebug.solarType = task.node_solarType thisDebug.solarEvents = task.node_solarEvents newMsg.payload = thisDebug sendCommandResponse(newMsg) } break case 'debug-': { // multiple const results = [] if (node.tasks) { for (let index = 0; index < node.tasks.length; index++) { const task = node.tasks[index] if (cmdAll || taskFilterMatch(task, cmdFilter)) { const thisDebug = getTaskStatus(node, task, { includeSolarStateOffset: true }) thisDebug.name = task.name thisDebug.topic = task.node_topic thisDebug.expressionType = task.node_expressionType thisDebug.expression = task.node_expression thisDebug.location = task.node_location thisDebug.offset = task.node_offset thisDebug.solarType = task.node_solarType thisDebug.solarEvents = task.node_solarEvents results.push(thisDebug) } } } newMsg.payload = results sendCommandResponse(newMsg) } break } } done() } catch (error) { done(error) // node.error(error,msg); } }) /** * Get a task by name * @param {Object} node - the cronplus node * @param {*} name - the name of the task * @returns {cronosjs.CronosTask} the found task or null */ function getTask (node, name) { const task = node.tasks.find(function (task) { return task.name === name }) return task } async function refreshTasks (node, tasks) { const _tasks = tasks || node.tasks if (_tasks && _tasks.length) { try { const tasksToRefresh = _tasks.filter(function (task) { return task._sequence || (task.isRunning && task._expression && !isTaskFinished(task)) }) if (!tasksToRefresh || !tasksToRefresh.length) { return null } for (let index = 0; index < _tasks.length; index++) { const task = _tasks[index] if (task.node_expressionType === 'cron') { task.stop() task.start() } else { await updateTask(node, task.node_opt, null) } } } catch (e) { } updateNextStatus(node) } } function taskFilterMatch (task, filter) { if (!task) return false // eslint-disable-next-line eqeqeq const isActive = function (task) { return isTaskFinished(task) == false && task.isRunning == true } // eslint-disable-next-line eqeqeq const isInactive = function (task) { return isTaskFinished(task) || task.isRunning == false } // eslint-disable-next-line eqeqeq const isStatic = function (task) { return (task.isStatic == true || task.isDynamic == false) } // eslint-disable-next-line eqeqeq const isDynamic = function (task) { return (task.isDynamic == true || task.isStatic == false) } switch (filter) { case 'all': return true case 'static': return isStatic(task) case 'dynamic': return isDynamic(task) case 'active': return isActive(task) case 'inactive': return isInactive(task) case 'active-dynamic': return isActive(task) && isDynamic(task) case 'active-static': return isActive(task) && isStatic(task) case 'inactive-dynamic': return isInactive(task) && isDynamic(task) case 'inactive-static': return isInactive(task) && isStatic(task) } return false } function stopTask (node, name, resetCounter) { const task = getTask(node, name) if (task) { task.stop() if (resetCounter) { task.node_count = 0 } } return task } function stopAllTasks (node, resetCounter, filter) { if (node.tasks) { for (let index = 0; index < node.tasks.length; index++) { const task = node.tasks[index] if (task) { let skip = false if (filter) skip = (taskFilterMatch(task, filter) === false) if (!skip) { task.stop() if (resetCounter) { task.node_count = 0 } } } } } } async function startTaskByName (node, name) { const task = getTask(node, name) return await startTask(node, task) } /** * Start a cron task * @param {Object} node - the cronplus node * @param {cronosjs.CronosTask} task - the task to start * @returns {Promise} the started task */ async function startTask (node, task) { if (task) { try { if (isTaskFinished(task)) { task.node_count = 0 } task.stop()// prevent bug where calling start without first calling stop causes events to bunch up task.start() // lets see if this is a solar task that failed to start... if (!task.isRunning && task.node_opt && task.node_opt.expressionType === 'solar') { // solar tasks that are paused when its _time_ passes miss the call to `update` // which is responsible for generating the next occurrence // lets try updating it now await updateTask(node, task.node_opt, null) } } catch (e) { node.debug(`startTask error: ${e.message}`) } } return task } async function startAllTasks (node, filter) { const promises = [] if (node.tasks) { const taskNames = node.tasks.map(t => t.name) for (const name of taskNames) { const task = getTask(node, name) let skip = false if (filter) skip = (taskFilterMatch(task, filter) === false) if (!skip && task) { promises.push(startTaskByName(node, name)) } } } return Promise.all(promises) } function deleteAllTasks (node, filter) { if (node.tasks) { for (let index = 0; index < node.tasks.length; index++) { try { const task = node.tasks[index] if (task) { let skip = false if (filter) skip = (taskFilterMatch(task, filter) === false) if (!skip) { _deleteTask(task) node.tasks[index] = null node.tasks.splice(index, 1) index-- } } // eslint-disable-next-line no-empty } catch (error) { } } } } function deleteTask (node, name) { let task = getTask(node, name) if (task) { _deleteTask(task) node.tasks = node.tasks.filter(t => t && t.name !== name) task = null } } /** * Delete a task * @param {cronosjs.CronosTask} task - the task to delete */ function _deleteTask (task) { try { task.off('run') task.off('ended') task.off('started') task.off('stopped') task.stop() task = null // eslint-disable-next-line no-empty } catch (error) {} } async function updateTask (node, options, msg) { if (!options || typeof options !== 'object') { node.warn('schedule settings are not valid', msg) return null } // eslint-disable-next-line eqeqeq if (Array.isArray(options) == false) { options = [options] } for (let index = 0; index < options.length; index++) { const opt = options[index] opt.payloadType = opt.payloadType || opt.type if (opt.payloadType == null && typeof opt.payload === 'string' && opt.payload.length) { opt.payloadType = 'str' } opt.payloadType = opt.payloadType || 'default' delete opt.type try { validateOpt(opt) } catch (error) { node.warn(error, msg) return null } } for (let index = 0; index < options.length; index++) { const opt = options[index] const task = getTask(node, opt.name) const isDynamic = !task || task.isDynamic // let isStatic = task && task.isStatic; let opCount = 0; let modified = false if (task) { modified = true opCount = (task.node_count || 0) if (Object.prototype.hasOwnProperty.call(opt, 'count') && typeof opt.count === 'number' && opt.count >= 0) { opCount = opt.count } opt.count = opCount deleteTask(node, opt.name) } const taskCount = node.tasks ? node.tasks.length : 0 const taskIndex = task && node.fanOut ? (task.node_index || 0) : taskCount const t = await createTask(node, opt, taskIndex, !isDynamic) if (t) { if (modified) t.node_modified = true t.node_count = opCount t.isDynamic = isDynamic } } requestSerialisation()// request persistent state be written } async function createTask (node, opt, index, _static) { opt = opt || {} try { node.debug(`createTask - index: ${index}, static: ${_static}, opt: ${JSON.stringify(opt)}`) } catch (error) { node.error(error) } applyOptionDefaults(node, opt, index) try { validateOpt(opt) } catch (error) { node.warn(error) const indicator = _static ? 'dot' : 'ring' node.status({ fill: 'red', shape: indicator, text: error.message }) return null } const cronOpts = node.timeZone ? { timezone: node.timeZone } : undefined let task if (opt.expressionType === 'cron') { const expression = cronosjs.CronosExpression.parse(opt.expression, cronOpts) task = new cronosjs.CronosTask(expression) } else if (opt.expressionType === 'solar') { if (node.defaultLocationType === 'env' || node.defaultLocationType === 'fixed') { opt.locationType = node.defaultLocationType opt.location = await evaluateNodeProperty(node.defaultLocation, node.defaultLocationType, node) } else { // per schedule opt.location = await evaluateNodeProperty(opt.location, 'str', node) } const ds = parseSolarTimes(opt) task = ds.task task.node_solarEventTimes = ds.solarEventTimes } else { const ds = parseDateSequence(opt.expression) task = ds.task } task.isDynamic = !_static task.isStatic = _static task.name = '' + opt.name task.node_topic = opt.topic task.node_expressionType = opt.expressionType task.node_expression = opt.expression task.node_payloadType = opt.payloadType task.node_payload = opt.payload task.node_locationType = opt.locationType task.node_location = opt.location task.node_solarType = opt.solarType task.node_solarEvents = opt.solarEvents task.node_offset = opt.offset task.node_index = index task.node_opt = opt task.node_limit = typeof opt.limit === 'number' ? opt.limit : 0 task.node_count = typeof opt.count === 'number' ? opt.count : 0 if (task.node_limit > 0 && task.node_count > task.node_limit) { task.node_count = task.node_limit } task.stop() task.on('run', (timestamp) => { node.debug(`running '${task.name}' ~ '${task.node_topic}'\n now time ${new Date()}\n crontime ${new Date(timestamp)}`) const indicator = task.isDynamic ? 'ring' : 'dot' node.status({ fill: 'green', shape: indicator, text: 'Running ' + formatShortDateTimeWithTZ(timestamp, node.timeZone) }) if (isTaskFinished(task)) { process.nextTick(function () { // using nextTick is a work around for an issue (#3) in cronosjs where the job restarts itself after this event handler has exited task.stop() updateNextStatus(node) }) return } task.node_count = task.node_count + 1// ++ stops at 2147483647 sendMsg(node, task, timestamp) process.nextTick(async function () { if (task.node_expressionType === 'solar') { await updateTask(node, task.node_opt, null) } requestSerialisation()// request persistent state be written }) }) .on('ended', () => { node.debug(`ended '${task.name}' ~ '${task.node_topic}'`) updateNextStatus(node) requestSerialisation()// request persistent state be written }) .on('started', () => { node.debug(`started '${task.name}' ~ '${task.node_topic}'`) process.nextTick(function () { updateNextStatus(node) }) requestSerialisation()// request persistent state be written }) .on('stopped', () => { node.debug(`stopped '${task.name}' ~ '${task.node_topic}'`) updateNextStatus(node) requestSerialisation()// request persistent state be written }) task.stop()// prevent bug where calling start without first calling stop causes events to bunch up if (opt.dontStartTheTask === true || isTaskFinished(task)) { // do not start the task } else { task.start() } node.tasks.push(task) return task } function requestSerialisation () { if (node.serialisationRequestBusy) { return } node.queuedSerialisationRequest = Date.now() } async function serialise () { let filePath = '' try { if (!node.persistDynamic) { return } if (!FSAvailable && node.storeName === 'file') { return } const dynNodes = node.tasks.filter((e) => e && e.isDynamic) const statNodes = node.tasks.filter((e) => e && e.isDynamic !== true) const exp = (task) => exportTask(task, true) const dynNodesExp = dynNodes.map(exp) const statNodesExp = statNodes.map(exp) const state = { version: 2, dynamicSchedules: dynNodesExp, staticSchedules: statNodesExp } if (node.storeName === 'file') { try { filePath = getPersistFilePath() const fileData = JSON.stringify(state) fs.writeFileSync(filePath, fileData) } catch (err) { node.error(`An error occurred while writing state to file '${filePath}' - (${err.message})`) // return } } else { const contextKey = 'state' const storeName = node.storeName || 'default' if (!contextAvailable || STORE_NAMES.indexOf(storeName) === -1) { return } await contextSet(node.context(), contextKey, state, storeName) } /* if(!dynNodesExp || !dynNodesExp.length){ //FUTURE TODO: Sanity check before deletion //and only if someone asks for it :) //other wise, file clean up is a manual task fs.unlinkSync(filePath); return; } */ } catch (e) { node.error(`Error saving persistence data. ${e.message}`) } finally { node.queuedSerialisationRequest = null } } async function deserialise () { let filePath = '' try { if (!node.persistDynamic) { return } if (!FSAvailable && node.storeName === 'file') { return } const restoreState = async (state) => { if (!state) { return // nothing to add } if (state.version !== 1 && state.version !== 2) { throw new Error('Invalid version - cannot load dynamic schedules') } if (state.version === 2 && state.staticSchedules && state.staticSchedules.length) { for (let iOpt = 0; iOpt < state.staticSchedules.length; iOpt++) { const opt = state.staticSchedules[iOpt] const task = node.tasks.find(e => e.name === opt.name) if (task) { task.node_count = opt.count } if (opt.isRunning === false) { stopTask(node, opt.name) } else if (opt.isRunning === true) { startTaskByName(node, opt.name) } } updateNodeNextInfo(node) } if (state.version === 1) { state.dynamicSchedules = state.schedules } if (state.dynamicSchedules && state.dynamicSchedules.length) { for (let iOpt = 0; iOpt < state.dynamicSchedules.length; iOpt++) { const opt = state.dynamicSchedules[iOpt] let task opt.name = opt.name || opt.topic if (state.version === 1) { task = await createTask(node, opt, iOpt, false) } else if (state.version === 2) { opt.dontStartTheTask = !opt.isRunning task = await createTask(node, opt, iOpt, false) if (task) { task.node_count = opt.count task.isRunning = opt.isRunning task.isDynamic = true } if (opt.isRunning === false) { stopTask(node, opt.name) } else if (opt.isRunning === true) { startTaskByName(node, opt.name) } } } updateNodeNextInfo(node) } } if (node.storeName === 'file') { filePath = getPersistFilePath() if (fs.existsSync(filePath)) { const fileData = fs.readFileSync(filePath) const state = JSON.parse(fileData) await restoreState(state) } else { RED.log.debug(`cron-plus: no persistence data found for node '${node.id}'.`) } } else { // use context const storeName = node.storeName || 'default' const contextKey = 'state' if (!contextAvailable || !STORE_NAMES.indexOf(storeName)) { return } const state = await contextGet(node.context(), contextKey, storeName) await restoreState(state) } } catch (error) { node.error(`cron-plus: Error loading persistence data '${filePath}'. ${error.message}`) } } function getPersistFilePath () { const fileName = `node-${node.id}.json` return path.join(persistPath, fileName) } function updateNextStatus (node, force) { const now = new Date() updateNodeNextInfo(node, now) if (node.statusUpdatePending === true) { if (force) { node.statusUpdatePending = false } else { return } } if (node.tasks) { const indicator = node.nextIndicator || 'dot' if (node.nextDate) { const d = formatShortDateTimeWithTZ(node.nextDate, node.timeZone) || 'Never' node.status({ fill: 'blue', shape: indicator, text: (node.nextEvent || 'Next') + ': ' + d }) } else if (node.tasks && node.tasks.length) { node.status({ fill: 'grey', shape: indicator, text: 'All stopped' }) } else { node.status({ }) // no tasks } } else { node.status({}) } } function getNextTask (tasks) { try { const now = new Date() if (!tasks || !tasks.length) { return null } const runningTasks = tasks.filter(function (task) { const finished = isTaskFinished(task) return task.isRunning && (task._expression || task._sequence) && !finished }) if (!runningTasks || !runningTasks.length) { return null } let nextToRunTask if (runningTasks.length === 1) { // let x = (runningTasks[0]._expression || runningTasks[0]._sequence) nextToRunTask = runningTasks[0] // d = x.nextDate(now); } else { nextToRunTask = runningTasks.reduce(function (prev, current) { // let p, c; if (!prev) return current if (!current) return prev const px = (prev._expression || prev._sequence) const cx = (current._expression || current._sequence) return (px.nextDate(now) < cx.nextDate(now)) ? prev : current }) } return nextToRunTask } catch (error) { node.debug(error) } return null } function generateSendMsg (node, msg, type, index) { const outputCount = node.outputs const fanOut = node.fanOut const hasCommandOutputPin = !!((node.commandResponseMsgOutput === 'output2' || fanOut)) const optionCount = node.options ? node.options.length : 0 let staticOutputPinIndex = 0 let dynOutputPinIndex = 0 let cmdOutputPin = 0 if (fanOut) { dynOutputPinIndex = optionCount cmdOutputPin = optionCount + 1 staticOutputPinIndex = index || 0 } if (!fanOut && hasCommandOutputPin) { cmdOutputPin = 1 } let idx = 0 switch (type) { case 'static': idx = staticOutputPinIndex break case 'dynamic': idx = dynOutputPinIndex break case 'command-response': idx = cmdOutputPin break } const arr = Array(outputCount || (idx + 1)) arr.fill(null) arr[idx] = msg return arr } } function evaluateNodeProperty (value, type, node, msg) { return new Promise(function (resolve, reject) { RED.util.evaluateNodeProperty(value, type, node, msg, function (e, r) { if (e) { reject(e) } else { resolve(r) } }) }) } function contextGet (context, contextKey, storeName) { return new Promise(function (resolve, reject) { context.get(contextKey, storeName, function (e, r) { if (e) { reject(e) } else { resolve(r) } }) }) } function contextSet (context, contextKey, value, storeName) { return new Promise(function (resolve, reject) { context.set(contextKey, value, storeName, function (e, r) { if (e) { reject(e) } else { resolve() } }) }) } function getStoreNames () { const stores = ['', 'file'] if (!RED.settings.contextStorage) { return stores } if (typeof RED.settings.contextStorage !== 'object') { return stores } return [...stores, ...Object.keys(RED.settings.contextStorage)] } RED.nodes.registerType('cronplus', CronPlus) RED.httpAdmin.post('/cronplusinject/:id', RED.auth.needsPermission('cronplus.write'), function (req, res) { const node = RED.nodes.getNode(req.params.id) if (node != null) { try { node.receive() res.sendStatus(200) } catch (err) { res.sendStatus(500) node.error(RED._('inject.failed', { error: err.toString() })) } } else { res.sendStatus(404) } }) RED.httpAdmin.post('/cronplus/:id/:operation', RED.auth.needsPermission('cronplus.read'), async function (req, res) { // console.log("/cronplus", req.body); try { const operation = req.params.operation const node = RED.nodes.getNode(req.params.id) if (operation === 'expressionTip') { const timeZone = req.body.timeZone ? req.body.timeZone : undefined const expressionType = req.body.expressionType ? req.body.expressionType : undefined const opts = { expression: req.body.expression } if (timeZone) opts.timezone = timeZone if (expressionType) { opts.expressionType = expressionType if (opts.expressionType === 'solar') { opts.solarType = req.body.solarType || '' opts.solarEvents = req.body.solarEvents || '' let pos = '' const fakeNode = () => { const n = { id: req.body.nodeId, _flow: { } // something - get the flow object based on req.body.flowId fc65972c8d3b1d68 } return n } if (req.body.defaultLocationType === 'env') { if (req.body.env) { for (let index = 0; index < req.body.env.length; index++) { const envVar = req.body.env[index] if (envVar.name === req.body.defaultLocation) { pos = await evaluateNodeProperty(envVar.value, envVar.type, fakeNode()) break } } } if (!pos && node) { pos = await evaluateNodeProperty(node.defaultLocation, node.defaultLocationType, node) } } else if (req.body.defaultLocationType === 'fixed') { if (node) { pos = await evaluateNodeProperty(req.body.defaultLocation, req.body.defaultLocationType, node) } else { pos = await evaluateNodeProperty(req.body.defaultLocation, req.body.defaultLocationType, fakeNode()) } } else { // per schedule let loc = (req.body.location + '').trim() let locType = 'str' if (/\$\{(.+)\}/.test(loc)) { locType = 'env' loc = /\$\{(.+)\}/.exec(loc)[1] } if (locType === 'env') { for (let index = 0; index < req.body.env.length; index++) { const envVar = req.body.env[index] if (envVar.name === loc) { pos = await evaluateNodeProperty(envVar.value, envVar.type, node || fakeNode()) break } } } else { pos = await evaluateNodeProperty(loc, 'str', node || fakeNode()) } } opts.location = pos || '' opts.offset = req.body.offset || 0 } } const exp = (opts.expressionType === 'solar') ? opts.location : opts.expression const h = _describeExpression(exp, opts.expressionType, opts.timezone, opts.offset, opts.solarType, opts.solarEvents, null, { locationType: opts.locationType || opts.defaultLocationType, defaultLocationType: opts.defaultLocationType, defaultLocation: opts.defaultLocation }) let r = null if (opts.expressionType === 'solar') { const times = h.eventTimes && h.eventTimes.slice(1) r = { ...opts, // description: desc, description: h.description, // next: next, next: h.nextEventTimeOffset, // nextEventDesc: nextEventDesc, nextEventDesc: h.nextEvent, // prettyNext: prettyNext, prettyNext: h.prettyNext, // nextDates: nextDates nextDates: times } } else { const times = h.nextDates && h.nextDates.slice(1) r = { ...opts, description: h.description, // next: next, next: h.nextDate, // nextEventDesc: nextEventDesc, nextEventDesc: h.nextDescription, // prettyNext: prettyNext, prettyNext: h.prettyNext, // nextDates: nextDates nextDates: times } } res.json(r) } else if (operation === 'getDynamic') { if (!node) { res.json([]) return } const dynNodes = node.tasks.filter((e) => e && e.isDynamic) const exp = (t) => { return { config: exportTask(t, false), status: getTaskStatus(node, t, { includeSolarStateOffset: true }) } } const dynNodesExp = dynNodes.map(exp) res.json(dynNodesExp) } else if (operation === 'tz') { res.json(timeZones) } } catch (err) { res.sendStatus(500) console.debug(err) } }) } /** * Array of timezones */ const timeZones = [ { code: 'CI', latLon: '+0519-00402', tz: 'Africa/Abidjan', UTCOffset: '+00:00', UTCDSTOffset: '+00:00' }, { code: 'GH', latLon: '+0533-00013', tz: 'Africa/Accra', UTCOffset: '+00:00', UTCDSTOffset: '+00:00' }, { code: 'DZ', latLon: '3950', tz: 'Africa/Algiers', UTCOffset: '+01:00', UTCDSTOffset: '+01:00' }, { code: 'GW', latLon: '+1151-01535', tz: 'Africa/Bissau', UTCOffset: '+00:00', UTCDSTOffset: '+00:00' }, { code: 'EG', latLon: '6118', tz: 'Africa/Cairo', UTCOffset: '+02:00', UTCDSTOffset: '+02:00' }, { code: 'MA', latLon: '+3339-00735', tz: 'Africa/Casablanca', UTCOffset: '+01:00', UTCDSTOffset: '+01:00' }, { code: 'ES', latLon: '+3553-00519', tz: 'Africa/Ceuta', UTCOffset: '+01:00', UTCDSTOffset: '+02:00' }, { code: 'EH', latLon: '+2709-01312', tz: 'Africa/El_Aaiun', UTCOffset: '+00:00', UTCDSTOffset: '+01:00' }, { code: 'ZA', latLon: '-2615+02800', tz: 'Africa/Johannesburg', UTCOffset: '+02:00', UTCDSTOffset: '+02:00' }, { code: 'SS', latLon: '3587', tz: 'Africa/Juba', UTCOffset: '+03:00', UTCDSTOffset: '+03:00' }, { code: 'SD', latLon: '4768', tz: 'Africa/Khartoum', UTCOffset: '+02:00', UTCDSTOffset: '+02:00' }, { code: 'NG', latLon: '951', tz: 'Africa/Lagos', UTCOffset: '+01:00', UTCDSTOffset: '+01:00' }, { code: 'MZ', latLon: '-2558+03235', tz: 'Africa/Maputo', UTCOffset: '+02:00', UTCDSTOffset: '+02:00' }, { code: 'LR', latLon: '+0618-01047', tz: 'Africa/Monrovia', UTCOffset: '+00:00', UTCDSTOffset: '+00:00' }, { code: 'KE', latLon: '-0117+03649', tz: 'Africa/Nairobi', UTCOffset: '+03:00', UTCDSTOffset: '+03:00' }, { code: 'TD', latLon: '2710', tz: 'Africa/Ndjamena', UTCOffset: '+01:00', UTCDSTOffset: '+01:00' }, { code: 'LY', latLon: '4565', tz: 'Africa/Tripoli', UTCOffset: '+02:00', UTCDSTOffset: '+02:00' }, { code: 'TN', latLon: '4659', tz: 'Africa/Tunis', UTCOffset: '+01:00', UTCDSTOffset: '+01:00' }, { code: 'NA', latLon: '-2234+01706', tz: 'Africa/Windhoek', UTCOffset: '+02:00', UTCDSTOffset: '+02:00' }, { code: 'US', latLon: '+515248-1763929', tz: 'America/Adak', UTCOffset: '-10:00', UTCDSTOffset: '-09:00' }, { code: 'US', latLon: '+611305-1495401', tz: 'America/Anchorage', UTCOffset: '-09:00', UTCDSTOffset: '-08:00' }, { code: 'BR', latLon: '-0712-04812', tz: 'America/Araguaina', UTCOffset: '-03:00', UTCDSTOffset: '-03:00' }, { code: 'AR', latLon: '-3436-05827', tz: 'America/Argentina/Buenos_Aires', UTCOffset: '-03:00', UTCDSTOffset: '-03:00' }, { code: 'AR', latLon: '-2828-06547', tz: 'America/Argentina/Catamarca', UTCOffset: '-03:00', UTCDSTOffset: '-03:00' }, { code: 'AR', latLon: '-3124-06411', tz: 'America/Argentina/Cordoba', UTCOffset: '-03:00', UTCDSTOffset: '-03:00' }, { code: 'AR', latLon: '-2411-06518', tz: 'America/Argentina/Jujuy', UTCOffset: '-03:00', UTCDSTOffset: '-03:00' }, { code: 'AR', latLon: '-2926-06651', tz: 'America/Argentina/La_Rioja', UTCOffset: '-03:00', UTCDSTOffset: '-03:00' }, { code: 'AR', latLon: '-3253-06849', tz: 'America/Argentina/Mendoza', UTCOffset: '-03:00', UTCDSTOffset: '-03:00' }, { code: 'AR', latLon: '-5138-06913', tz: 'America/Argentina/Rio_Gallegos', UTCOffset: '-03:00', UTCDSTOffset: '-03:00' }, { code: 'AR', latLon: '-2447-06525', tz: 'America/Argentina/Salta', UTCOffset: '-03:00', UTCDSTOffset: '-03:00' }, { code: 'AR', latLon: '-3132-06831', tz: 'America/Argentina/San_Juan', UTCOffset: '-03:00', UTCDSTOffset: '-03:00' }, { code: 'AR', latLon: '-3319-06621', tz: 'America/Argentina/San_Luis', UTCOffset: '-03:00', UTCDSTOffset: '-03:00' }, { code: 'AR', latLon: '-2649-06513', tz: 'America/Argentina/Tucuman', UTCOffset: '-03:00', UTCDSTOffset: '-03:00' }, { code: 'AR', latLon: '-5448-06818', tz: 'America/Argentina/Ushuaia', UTCOffset: '-03:00', UTCDSTOffset: '-03:00' }, { code: 'PY', latLon: '-2516-05740', tz: 'America/Asuncion', UTCOffset: '-04:00', UTCDSTOffset: '-03:00' }, { code: 'CA', latLon: '+484531-0913718', tz: 'America/Atikokan', UTCOffset: '-05:00', UTCDSTOffset: '-05:00' }, { code: 'BR', latLon: '-1259-03831', tz: 'America/Bahia', UTCOffset: '-03:00', UTCDSTOffset: '-03:00' }, { code: 'MX', latLon: '+2048-10515', tz: 'America/Bahia_Banderas', UTCOffset: '-06:00', UTCDSTOffset: '-05:00' }, { code: 'BB', latLon: '+1306-05937', tz: 'America/Barbados', UTCOffset: '-04:00', UTCDSTOffset: '-04:00' }, { code: 'BR', latLon: '-0127-04829', tz: 'America/Belem', UTCOffset: '-03:00', UTCDSTOffset: '-03:00' }, { code: 'BZ', latLon: '+1730-08812', tz: 'America/Belize', UTCOffset: '-06:00', UTCDSTOffset: '-06:00' }, { code: 'CA', latLon: '+5125-05707', tz: 'America/Blanc-Sablon', UTCOffset: '-04:00', UTCDSTOffset: '-04:00' }, { code: 'BR', latLon: '+0249-06040', tz: 'America/Boa_Vista', UTCOffset: '-04:00', UTCDSTOffset: '-04:00' }, { code: 'CO', latLon: '+0436-07405', tz: 'America/Bogota', UTCOffset: '-05:00', UTCDSTOffset: '-05:00' }, { code: 'US', latLon: '+433649-1161209', tz: 'America/Boise', UTCOffset: '-07:00', UTCDSTOffset: '-06:00' }, { code: 'CA', latLon: '+690650-1050310', tz: 'America/Cambridge_Bay', UTCOffset: '-07:00', UTCDSTOffset: '-06:00' }, { code: 'BR', latLon: '-2027-05437', tz: 'America/Campo_Grande', UTCOffset: '-04:00', UTCDSTOffset: '-04:00' }, { code: 'MX', latLon: '+2105-08646', tz: 'America/Cancun', UTCOffset: '-05:00', UTCDSTOffset: '-05:00' }, { code: 'VE', latLon: '+1030-06656', tz: 'America/Caracas', UTCOffset: '-04:00', UTCDSTOffset: '-04:00' }, { code: 'GF', latLon: '+0456-05220', tz: 'America/Cayenne', UTCOffset: '-03:00', UTCDSTOffset: '-03:00' }, { code: 'US', latLon: '+4151-08739', tz: 'America/Chicago', UTCOffset: '-06:00', UTCDSTOffset: '-05:00' }, { code: 'MX', latLon: '+2838-10605', tz: 'America/Chihuahua', UTCOffset: '-07:00', UTCDSTOffset: '-06:00' }, { code: 'CR', latLon: '+0956-08405', tz: 'America/Costa_Rica', UTCOffset: '-06:00', UTCDSTOffset: '-06:00' }, { code: 'CA', latLon: '+4906-11631', tz: 'America/Creston', UTCOffset: '-07:00', UTCDSTOffset: '-07:00' }, { code: 'BR', latLon: '-1535-05605', tz: 'America/Cuiaba', UTCOffset: '-04:00', UTCDSTOffset: '-04:00' }, { code: 'CW', latLon: '+1211-06900', tz: 'America/Curacao', UTCOffset: '-04:00', UTCDSTOffset: '-04:00' }, { code: 'GL', latLon: '+7646-01840', tz: 'America/Danmarkshavn', UTCOffset: '+00:00', UTCDSTOffset: '+00:00' }, { code: 'CA', latLon: '+6404-13925', tz: 'America/Dawson', UTCOffset: '-08:00', UTCDSTOffset: '-07:00' }, { code: 'CA', latLon: '+5946-12014', tz: 'America/Dawson_Creek', UTCOffset: '-07:00', UTCDSTOffset: '-07:00' }, { code: 'US', latLon: '+394421-1045903', tz: 'America/Denver', UTCOffset: '-07:00', UTCDSTOffset: '-06:00' }, { code: 'US', latLon: '+421953-0830245', tz: 'America/Detroit', UTCOffset: '-05:00', UTCDSTOffset: '-04:00' }, { code: 'CA', latLon: '+5333-11328', tz: 'America/Edmonton', UTCOffset: '-07:00', UTCDSTOffset: '-06:00' }, { code: 'BR', latLon: '-0640-06952', tz: 'America/Eirunepe', UTCOffset: '-05:00', UTCDSTOffset: '-05:00' }, { code: 'SV', latLon: '+1342-08912', tz: 'America/El_Salvador', UTCOffset: '-06:00', UTCDSTOffset: '-06:00' }, { code: 'CA', latLon: '+5848-12242', tz: 'America/Fort_Nelson', UTCOffset: '-07:00', UTCDSTOffset: '-07:00' }, { code: 'BR', latLon: '-0343-03830', tz: 'America/Fortaleza', UTCOffset: '-03:00', UTCDSTOffset: '-03:00' }, { code: 'CA', latLon: '+4612-05957', tz: 'America/Glace_Bay', UTCOffset: '-04:00', UTCDSTOffset: '-03:00' }, { code: 'GL', latLon: '+6411-05144', tz: 'America/Godthab', UTCOffset: '-03:00', UTCDSTOffset: '-02:00' }, { code: 'CA', latLon: '+5320-06025', tz: 'America/Goose_Bay', UTCOffset: '-04:00', UTCDSTOffset: '-03:00' }, { code: 'TC', latLon: '+2128-07108', tz: 'America/Grand_Turk', UTCOffset: '-05:00', UTCDSTOffset: '-04:00' }, { code: 'GT', latLon: '+1438-09031', tz: 'America/Guatemala', UTCOffset: '-06:00', UTCDSTOffset: '-06:00' }, { code: 'EC', latLon: '-0210-07950', tz: 'America/Guayaquil', UTCOffset: '-05:00', UTCDSTOffset: '-05:00' }, { code: 'GY', latLon: '+0648-05810', tz: 'America/Guyana', UTCOffset: '-04:00', UTCDSTOffset: '-04:00' }, { code: 'CA', latLon: '+4439-06336', tz: 'America/Halifax', UTCOffset: '-04:00', UTCDSTOffset: '-03:00' }, { code: 'CU', latLon: '+2308-08222', tz: 'America/Havana', UTCOffset: '-05:00', UTCDSTOffset: '-04:00' }, { code: 'MX', latLon: '+2904-11058', tz: 'America/Hermosillo', UTCOffset: '-07:00', UTCDSTOffset: '-07:00' }, { code: 'US', latLon: '+394606-0860929', tz: 'America/Indiana/Indianapolis', UTCOffset: '-05:00', UTCDSTOffset: '-04:00' }, { code: 'US', latLon: '+411745-0863730', tz: 'America/Indiana/Knox', UTCOffset: '-06:00', UTCDSTOffset: '-05:00' }, { code: 'US', latLon: '+382232-0862041', tz: 'America/Indiana/Marengo', UTCOffset: '-05:00', UTCDSTOffset: '-04:00' }, { code: 'US', latLon: '+382931-0871643', tz: 'America/Indiana/Petersburg', UTCOffset: '-05:00', UTCDSTOffset: '-04:00' }, { code: 'US', latLon: '+375711-0864541', tz: 'America/Indiana/Tell_City', UTCOffset: '-06:00', UTCDSTOffset: '-05:00' }, { code: 'US', latLon: '+384452-0850402', tz: 'America/Indiana/Vevay', UTCOffset: '-05:00', UTCDSTOffset: '-04:00' }, { code: 'US', latLon: '+384038-0873143', tz: 'America/Indiana/Vincennes', UTCOffset: '-05:00', UTCDSTOffset: '-04:00' }, { code: 'US', latLon: '+410305-0863611', tz: 'America/Indiana/Winamac', UTCOffset: '-05:00', UTCDSTOffset: '-04:00' }, { code: 'CA', latLon: '+682059-13343', tz: 'America/Inuvik', UTCOffset: '-07:00', UTCDSTOffset: '-06:00' }, { code: 'CA', latLon: '+6344-06828', tz: 'America/Iqaluit', UTCOffset: '-05:00', UTCDSTOffset: '-04:00' }, { code: 'JM', latLon: '+175805-0764736', tz: 'America/Jamaica', UTCOffset: '-05:00', UTCDSTOffset: '-05:00' }, { code: 'US', latLon: '+581807-1342511', tz: 'America/Juneau', UTCOffset: '-09:00', UTCDSTOffset: '-08:00' }, { code: 'US', latLon: '+381515-0854534', tz: 'America/Kentucky/Louisville', UTCOffset: '-05:00', UTCDSTOffset: '-04:00' }, { code: 'US', latLon: '+364947-0845057', tz: 'America/Kentucky/Monticello', UTCOffset: '-05:00', UTCDSTOffset: '-04:00' }, { code: 'BO', latLon: '-1630-06809', tz: 'America/La_Paz', UTCOffset: '-04:00', UTCDSTOffset: '-04:00' }, { code: 'PE', latLon: '-1203-07703', tz: 'America/Lima', UTCOffset: '-05:00', UTCDSTOffset: '-05:00' }, { code: 'US', latLon: '+340308-1181434', tz: 'America/Los_Angeles', UTCOffset: '-08:00', UTCDSTOffset: '-07:00' }, { code: 'BR', latLon: '-0940-03543', tz: 'America/Maceio', UTCOffset: '-03:00', UTCDSTOffset: '-03:00' }, { code: 'NI', latLon: '+1209-08617', tz: 'America/Managua', UTCOffset: '-06:00', UTCDSTOffset: '-06:00' }, { code: 'BR', latLon: '-0308-06001', tz: 'America/Manaus', UTCOffset: '-04:00', UTCDSTOffset: '-04:00' }, { code: 'MQ', latLon: '+1436-06105', tz: 'America/Martinique', UTCOffset: '-04:00', UTCDSTOffset: '-04:00' }, { code: 'MX', latLon: '+2550-09730', tz: 'America/Matamoros', UTCOffset: '-06:00', UTCDSTOffset: '-05:00' }, { code: 'MX', latLon: '+2313-10625', tz: 'America/Mazatlan', UTCOffset: '-07:00', UTCDSTOffset: '-06:00' }, { code: 'US', latLon: '+450628-0873651', tz: 'America/Menominee', UTCOffset: '-06:00', UTCDSTOffset: '-05:00' }, { code: 'MX', latLon: '+2058-08937', tz: 'America/Merida', UTCOffset: '-06:00', UTCDSTOffset: '-05:00' }, { code: 'US', latLon: '+550737-1313435', tz: 'America/Metlakatla', UTCOffset: '-09:00', UTCDSTOffset: '-08:00' }, { code: 'MX', latLon: '+1924-09909', tz: 'America/Mexico_City', UTCOffset: '-06:00', UTCDSTOffset: '-05:00' }, { code: 'PM', latLon: '+4703-05620', tz: 'America/Miquelon', UTCOffset: '-03:00', UTCDSTOffset: '-02:00' }, { code: 'CA', latLon: '+4606-06447', tz: 'America/Moncton', UTCOffset: '-04:00', UTCDSTOffset: '-03:00' }, { code: 'MX', latLon: '+2540-10019', tz: 'America/Monterrey', UTCOffset: '-06:00', UTCDSTOffset: '-05:00' }, { code: 'UY', latLon: '-3453-05611', tz: 'America/Montevideo', UTCOffset: '-03:00', UTCDSTOffset: '-03:00' }, { code: 'BS', latLon: '+2505-07721', tz: 'America/Nassau', UTCOffset: '-05:00', UTCDSTOffset: '-04:00' }, { code: 'US', latLon: '+404251-0740023', tz: 'America/New_York', UTCOffset: '-05:00', UTCDSTOffset: '-04:00' }, { code: 'CA', latLon: '+4901-08816', tz: 'America/Nipigon', UTCOffset: '-05:00', UTCDSTOffset: '-04:00' }, { code: 'US', latLon: '+643004-1652423', tz: 'America/Nome', UTCOffset: '-09:00', UTCDSTOffset: '-08:00' }, { code: 'BR', latLon: '-0351-03225', tz: 'America/Noronha', UTCOffset: '-02:00', UTCDSTOffset: '-02:00' }, { code: 'US', latLon: '+471551-1014640', tz: 'America/North_Dakota/Beulah', UTCOffset: '-06:00', UTCDSTOffset: '-05:00' }, { code: 'US', latLon: '+470659-1011757', tz: 'America/North_Dakota/Center', UTCOffset: '-06:00', UTCDSTOffset: '-05:00' }, { code: 'US', latLon: '+465042-1012439', tz: 'America/North_Dakota/New_Salem', UTCOffset: '-06:00', UTCDSTOffset: '-05:00' }, { code: 'MX', latLon: '+2934-10425', tz: 'America/Ojinaga', UTCOffset: '-07:00', UTCDSTOffset: '-06:00' }, { code: 'PA', latLon: '+0858-07932', tz: 'America/Panama', UTCOffset: '-05:00', UTCDSTOffset: '-05:00' }, { code: 'CA', latLon: '+6608-06544', tz: 'America/Pangnirtung', UTCOffset: '-05:00', UTCDSTOffset: '-04:00' }, { code: 'SR', latLon: '+0550-05510', tz: 'America/Paramaribo', UTCOffset: '-03:00', UTCDSTOffset: '-03:00' }, { code: 'US', latLon: '+332654-1120424', tz: 'America/Phoenix', UTCOffset: '-07:00', UTCDSTOffset: '-07:00' }, { code: 'TT', latLon: '+1039-06131', tz: 'America/Port_of_Spain', UTCOffset: '-04:00', UTCDSTOffset: '-04:00' }, { code: 'HT', latLon: '+1832-07220', tz: 'America/Port-au-Prince', UTCOffset: '-05:00', UTCDSTOffset: '-04:00' }, { code: 'BR', latLon: '-0846-06354', tz: 'America/Porto_Velho', UTCOffset: '-04:00', UTCDSTOffset: '-04:00' }, { code: 'PR', latLon: '+182806-0660622', tz: 'America/Puerto_Rico', UTCOffset: '-04:00', UTCDSTOffset: '-04:00' }, { code: 'CL', latLon: '-5309-07055', tz: 'America/Punta_Arenas', UTCOffset: '-03:00', UTCDSTOffset: '-03:00' }, { code: 'CA', latLon: '+4843-09434', tz: 'America/Rainy_River', UTCOffset: '-06:00', UTCDSTOffset: '-05:00' }, { code: 'CA', latLon: '+6249-0920459', tz: 'America/Rankin_Inlet', UTCOffset: '-06:00', UTCDSTOffset: '-05:00' }, { code: 'BR', latLon: '-0803-03454', tz: 'America/Recife', UTCOffset: '-03:00', UTCDSTOffset: '-03:00' }, { code: 'CA', latLon: '+5024-10439', tz: 'America/Regina', UTCOffset: '-06:00', UTCDSTOffset: '-06:00' }, { code: 'CA', latLon: '+744144-0944945', tz: 'America/Resolute', UTCOffset: '-06:00', UTCDSTOffset: '-05:00' }, { code: 'BR', latLon: '-0958-06748', tz: 'America/Rio_Branco', UTCOffset: '-05:00', UTCDSTOffset: '-05:00' }, { code: 'BR', latLon: '-0226-05452', tz: 'America/Santarem', UTCOffset: '-03:00', UTCDSTOffset: '-03:00' }, { code: 'CL', latLon: '-3327-07040', tz: 'America/Santiago', UTCOffset: '-04:00', UTCDSTOffset: '-03:00' }, { code: 'DO', latLon: '+1828-06954', tz: 'America/Santo_Domingo', UTCOffset: '-04:00', UTCDSTOffset: '-04:00' }, { code: 'BR', latLon: '-2332-04637', tz: 'America/Sao_Paulo', UTCOffset: '-03:00', UTCDSTOffset: '-03:00' }, { code: 'GL', latLon: '+7029-02158', tz: 'America/Scoresbysund', UTCOffset: '-01:00', UTCDSTOffset: '+00:00' }, { code: 'US', latLon: '+571035-1351807', tz: 'America/Sitka', UTCOffset: '-09:00', UTCDSTOffset: '-08:00' }, { code: 'CA', latLon: '+4734-05243', tz: 'America/St_Johns', UTCOffset: '-03:30', UTCDSTOffset: '-02:30' }, { code: 'CA', latLon: '+5017-10750', tz: 'America/Swift_Current', UTCOffset: '-06:00', UTCDSTOffset: '-06:00' }, { code: 'HN', latLon: '+1406-08713', tz: 'America/Tegucigalpa', UTCOffset: '-06:00', UTCDSTOffset: '-06:00' }, { code: 'GL', latLon: '+7634-06847', tz: 'America/Thule', UTCOffset: '-04:00', UTCDSTOffset: '-03:00' }, { code: 'CA', latLon: '+4823-08915', tz: 'America/Thunder_Bay', UTCOffset: '-05:00', UTCDSTOffset: '-04:00' }, { code: 'MX', latLon: '+3232-11701', tz: 'America/Tijuana', UTCOffset: '-08:00', UTCDSTOffset: '-07:00' }, { code: 'CA', latLon: '+4339-07923', tz: 'America/Toronto', UTCOffset: '-05:00', UTCDSTOffset: '-04:00' }, { code: 'CA', latLon: '+4916-12307', tz: 'America/Vancouver', UTCOffset: '-08:00', UTCDSTOffset: '-07:00' }, { code: 'CA', latLon: '+6043-13503', tz: 'America/Whitehorse', UTCOffset: '-08:00', UTCDSTOffset: '-07:00' }, { code: 'CA', latLon: '+4953-09709', tz: 'America/Winnipeg', UTCOffset: '-06:00', UTCDSTOffset: '-05:00' }, { code: 'US', latLon: '+593249-1394338', tz: 'America/Yakutat', UTCOffset: '-09:00', UTCDSTOffset: '-08:00' }, { code: 'CA', latLon: '+6227-11421', tz: 'America/Yellowknife', UTCOffset: '-07:00', UTCDSTOffset: '-06:00' }, { code: 'AQ', latLon: '-6617+11031', tz: 'Antarctica/Casey', UTCOffset: '+11:00', UTCDSTOffset: '+11:00' }, { code: 'AQ', latLon: '-6835+07758', tz: 'Antarctica/Davis', UTCOffset: '+07:00', UTCDSTOffset: '+07:00' }, { code: 'AQ', latLon: '-6640+14001', tz: 'Antarctica/DumontDUrville', UTCOffset: '+10:00', UTCDSTOffset: '+10:00' }, { code: 'AU', latLon: '-5430+15857', tz: 'Antarctica/Macquarie', UTCOffset: '+11:00', UTCDSTOffset: '+11:00' }, { code: 'AQ', latLon: '-6736+06253', tz: 'Antarctica/Mawson', UTCOffset: '+05:00', UTCDSTOffset: '+05:00' }, { code: 'AQ', latLon: '-6448-06406', tz: 'Antarctica/Palmer', UTCOffset: '-03:00', UTCDSTOffset: '-03:00' }, { code: 'AQ', latLon: '-6734-06808', tz: 'Antarctica/Rothera', UTCOffset: '-03:00', UTCDSTOffset: '-03:00' }, { code: 'AQ', latLon: '-690022+0393524', tz: 'Antarctica/Syowa', UTCOffset: '+03:00', UTCDSTOffset: '+03:00' }, { code: 'AQ', latLon: '-720041+0023206', tz: 'Antarctica/Troll', UTCOffset: '+00:00', UTCDSTOffset: '+02:00' }, { code: 'AQ', latLon: '-7824+10654', tz: 'Antarctica/Vostok', UTCOffset: '+06:00', UTCDSTOffset: '+06:00' }, { code: 'KZ', latLon: '11972', tz: 'Asia/Almaty', UTCOffset: '+06:00', UTCDSTOffset: '+06:00' }, { code: 'JO', latLon: '6713', tz: 'Asia/Amman', UTCOffset: '+02:00', UTCDSTOffset: '+03:00' }, { code: 'RU', latLon: '24174', tz: 'Asia/Anadyr', UTCOffset: '+12:00', UTCDSTOffset: '+12:00' }, { code: 'KZ', latLon: '9447', tz: 'Asia/Aqtau', UTCOffset: '+05:00', UTCDSTOffset: '+05:00' }, { code: 'KZ', latLon: '10727', tz: 'Asia/Aqtobe', UTCOffset: '+05:00', UTCDSTOffset: '+05:00' }, { code: 'TM', latLon: '9580', tz: 'Asia/Ashgabat', UTCOffset: '+05:00', UTCDSTOffset: '+05:00' }, { code: 'KZ', latLon: '9863', tz: 'Asia/Atyrau', UTCOffset: '+05:00', UTCDSTOffset: '+05:00' }, { code: 'IQ', latLon: '7746', tz: 'Asia/Baghdad', UTCOffset: '+03:00', UTCDSTOffset: '+03:00' }, { code: 'AZ', latLon: '8974', tz: 'Asia/Baku', UTCOffset: '+04:00', UTCDSTOffset: '+04:00' }, { code: 'TH', latLon: '11376', tz: 'Asia/Bangkok', UTCOffset: '+07:00', UTCDSTOffset: '+07:00' }, { code: 'RU', latLon: '13667', tz: 'Asia/Barnaul', UTCOffset: '+07:00', UTCDSTOffset: '+07:00' }, { code: 'LB', latLon: '6883', tz: 'Asia/Beirut', UTCOffset: '+02:00', UTCDSTOffset: '+03:00' }, { code: 'KG', latLon: '11690', tz: 'Asia/Bishkek', UTCOffset: '+06:00', UTCDSTOffset: '+06:00' }, { code: 'BN', latLon: '11911', tz: 'Asia/Brunei', UTCOffset: '+08:00', UTCDSTOffset: '+08:00' }, { code: 'RU', latLon: '16531', tz: 'Asia/Chita', UTCOffset: '+09:00', UTCDSTOffset: '+09:00' }, { code: 'MN', latLon: '16234', tz: 'Asia/Choibalsan', UTCOffset: '+08:00', UTCDSTOffset: '+08:00' }, { code: 'LK', latLon: '8607', tz: 'Asia/Colombo', UTCOffset: '+05:30', UTCDSTOffset: '+05:30' }, { code: 'SY', latLon: '6948', tz: 'Asia/Damascus', UTCOffset: '+02:00', UTCDSTOffset: '+03:00' }, { code: 'BD', latLon: '11368', tz: 'Asia/Dhaka', UTCOffset: '+06:00', UTCDSTOffset: '+06:00' }, { code: 'TL', latLon: '-0833+12535', tz: 'Asia/Dili', UTCOffset: '+09:00', UTCDSTOffset: '+09:00' }, { code: 'AE', latLon: '8036', tz: 'Asia/Dubai', UTCOffset: '+04:00', UTCDSTOffset: '+04:00' }, { code: 'TJ', latLon: '10683', tz: 'Asia/Dushanbe', UTCOffset: '+05:00', UTCDSTOffset: '+05:00' }, { code: 'CY', latLon: '6864', tz: 'Asia/Famagusta', UTCOffset: '+02:00', UTCDSTOffset: '+02:00' }, { code: 'PS', latLon: '6558', tz: 'Asia/Gaza', UTCOffset: '+02:00', UTCDSTOffset: '+03:00' }, { code: 'PS', latLon: '353674', tz: 'Asia/Hebron', UTCOffset: '+02:00', UTCDSTOffset: '+03:00' }, { code: 'VN', latLon: '11685', tz: 'Asia/Ho_Chi_Minh', UTCOffset: '+07:00', UTCDSTOffset: '+07:00' }, { code: 'HK', latLon: '13626', tz: 'Asia/Hong_Kong', UTCOffset: '+08:00', UTCDSTOffset: '+08:00' }, { code: 'MN', latLon: '13940', tz: 'Asia/Hovd', UTCOffset: '+07:00', UTCDSTOffset: '+07:00' }, { code: 'RU', latLon: '15636', tz: 'Asia/Irkutsk', UTCOffset: '+08:00', UTCDSTOffset: '+08:00' }, { code: 'ID', latLon: '-0610+10648', tz: 'Asia/Jakarta', UTCOffset: '+07:00', UTCDSTOffset: '+07:00' }, { code: 'ID', latLon: '-0232+14042', tz: 'Asia/Jayapura', UTCOffset: '+09:00', UTCDSTOffset: '+09:00' }, { code: 'IL', latLon: '665976', tz: 'Asia/Jerusalem', UTCOffset: '+02:00', UTCDSTOffset: '+03:00' }, { code: 'AF', latLon: '10343', tz: 'Asia/Kabul', UTCOffset: '+04:30', UTCDSTOffset: '+04:30' }, { code: 'RU', latLon: '21140', tz: 'Asia/Kamchatka', UTCOffset: '+12:00', UTCDSTOffset: '+12:00' }, { code: 'PK', latLon: '9155', tz: 'Asia/Karachi', UTCOffset: '+05:00', UTCDSTOffset: '+05:00' }, { code: 'NP', latLon: '11262', tz: 'Asia/Kathmandu', UTCOffset: '+05:45', UTCDSTOffset: '+05:45' }, { code: 'RU', latLon: '1977237', tz: 'Asia/Khandyga', UTCOffset: '+09:00', UTCDSTOffset: '+09:00' }, { code: 'IN', latLon: '11054', tz: 'Asia/Kolkata', UTCOffset: '+05:30', UTCDSTOffset: '+05:30' }, { code: 'RU', latLon: '14851', tz: 'Asia/Krasnoyarsk', UTCOffset: '+07:00', UTCDSTOffset: '+07:00' }, { code: 'MY', latLon: '10452', tz: 'Asia/Kuala_Lumpur', UTCOffset: '+08:00', UTCDSTOffset: '+08:00' }, { code: 'MY', latLon: '11153', tz: 'Asia/Kuching', UTCOffset: '+08:00', UTCDSTOffset: '+08:00' }, { code: 'MO', latLon: '13549', tz: 'Asia/Macau', UTCOffset: '+08:00', UTCDSTOffset: '+08:00' }, { code: 'RU', latLon: '20982', tz: 'Asia/Magadan', UTCOffset: '+11:00', UTCDSTOffset: '+11:00' }, { code: 'ID', latLon: '-0507+11924', tz: 'Asia/Makassar', UTCOffset: '+08:00', UTCDSTOffset: '+08:00' }, { code: 'PH', latLon: '13535', tz: 'Asia/Manila', UTCOffset: '+08:00', UTCDSTOffset: '+08:00' }, { code: 'RU', latLon: '14052', tz: 'Asia/Novokuznetsk', UTCOffset: '+07:00', UTCDSTOffset: '+07:00' }, { code: 'RU', latLon: '13757', tz: 'Asia/Novosibirsk', UTCOffset: '+07:00', UTCDSTOffset: '+07:00' }, { code: 'RU', latLon: '12824', tz: 'Asia/Omsk', UTCOffset: '+06:00', UTCDSTOffset: '+06:00' }, { code: 'KZ', latLon: '10234', tz: 'Asia/Oral', UTCOffset: '+05:00', UTCDSTOffset: '+05:00' }, { code: 'ID', latLon: '-0002+10920', tz: 'Asia/Pontianak', UTCOffset: '+07:00', UTCDSTOffset: '+07:00' }, { code: 'KP', latLon: '16446', tz: 'Asia/Pyongyang', UTCOffset: '+09:00', UTCDSTOffset: '+09:00' }, { code: 'QA', latLon: '7649', tz: 'Asia/Qatar', UTCOffset: '+03:00', UTCDSTOffset: '+03:00' }, { code: 'KZ', latLon: '10976', tz: 'Asia/Qyzylorda', UTCOffset: '+05:00', UTCDSTOffset: '+05:00' }, { code: 'SA', latLon: '7081', tz: 'Asia/Riyadh', UTCOffset: '+03:00', UTCDSTOffset: '+03:00' }, { code: 'RU', latLon: '18900', tz: 'Asia/Sakhalin', UTCOffset: '+11:00', UTCDSTOffset: '+11:00' }, { code: 'UZ', latLon: '10588', tz: 'Asia/Samarkand', UTCOffset: '+05:00', UTCDSTOffset: '+05:00' }, { code: 'KR', latLon: '16391', tz: 'Asia/Seoul', UTCOffset: '+09:00', UTCDSTOffset: '+09:00' }, { code: 'CN', latLon: '15242', tz: 'Asia/Shanghai', UTCOffset: '+08:00', UTCDSTOffset: '+08:00' }, { code: 'SG', latLon: '10468', tz: 'Asia/Singapore', UTCOffset: '+08:00', UTCDSTOffset: '+08:00' }, { code: 'RU', latLon: '22071', tz: 'Asia/Srednekolymsk', UTCOffset: '+11:00', UTCDSTOffset: '+11:00' }, { code: 'TW', latLon: '14633', tz: 'Asia/Taipei', UTCOffset: '+08:00', UTCDSTOffset: '+08:00' }, { code: 'UZ', latLon: '11038', tz: 'Asia/Tashkent', UTCOffset: '+05:00', UTCDSTOffset: '+05:00' }, { code: 'GE', latLon: '8592', tz: 'Asia/Tbilisi', UTCOffset: '+04:00', UTCDSTOffset: '+04:00' }, { code: 'IR', latLon: '8666', tz: 'Asia/Tehran', UTCOffset: '+03:30', UTCDSTOffset: '+04:30' }, { code: 'BT', latLon: '11667', tz: 'Asia/Thimphu', UTCOffset: '+06:00', UTCDSTOffset: '+06:00' }, { code: 'JP', latLon: '1748357', tz: 'Asia/Tokyo', UTCOffset: '+09:00', UTCDSTOffset: '+09:00' }, { code: 'RU', latLon: '14088', tz: 'Asia/Tomsk', UTCOffset: '+07:00', UTCDSTOffset: '+07:00' }, { code: 'MN', latLon: '15408', tz: 'Asia/Ulaanbaatar', UTCOffset: '+08:00', UTCDSTOffset: '+08:00' }, { code: 'CN', latLon: '13083', tz: 'Asia/Urumqi', UTCOffset: '+06:00', UTCDSTOffset: '+06:00' }, { code: 'RU', latLon: '2074673', tz: 'Asia/Ust-Nera', UTCOffset: '+10:00', UTCDSTOffset: '+10:00' }, { code: 'RU', latLon: '17466', tz: 'Asia/Vladivostok', UTCOffset: '+10:00', UTCDSTOffset: '+10:00' }, { code: 'RU', latLon: '19140', tz: 'Asia/Yakutsk', UTCOffset: '+09:00', UTCDSTOffset: '+09:00' }, { code: 'MM', latLon: '11257', tz: 'Asia/Yangon', UTCOffset: '+06:30', UTCDSTOffset: '+06:30' }, { code: 'RU', latLon: '11687', tz: 'Asia/Yekaterinburg', UTCOffset: '+05:00', UTCDSTOffset: '+05:00' }, { code: 'AM', latLon: '8441', tz: 'Asia/Yerevan', UTCOffset: '+04:00', UTCDSTOffset: '+04:00' }, { code: 'PT', latLon: '+3744-02540', tz: 'Atlantic/Azores', UTCOffset: '-01:00', UTCDSTOffset: '+00:00' }, { code: 'BM', latLon: '+3217-06446', tz: 'Atlantic/Bermuda', UTCOffset: '-04:00', UTCDSTOffset: '-03:00' }, { code: 'ES', latLon: '+2806-01524', tz: 'Atlantic/Canary', UTCOffset: '+00:00', UTCDSTOffset: '+01:00' }, { code: 'CV', latLon: '+1455-02331', tz: 'Atlantic/Cape_Verde', UTCOffset: '-01:00', UTCDSTOffset: '-01:00' }, { code: 'FO', latLon: '+6201-00646', tz: 'Atlantic/Faroe', UTCOffset: '+00:00', UTCDSTOffset: '+01:00' }, { code: 'PT', latLon: '+3238-01654', tz: 'Atlantic/Madeira', UTCOffset: '+00:00', UTCDSTOffset: '+01:00' }, { code: 'IS', latLon: '+6409-02151', tz: 'Atlantic/Reykjavik', UTCOffset: '+00:00', UTCDSTOffset: '+00:00' }, { code: 'GS', latLon: '-5416-03632', tz: 'Atlantic/South_Georgia', UTCOffset: '-02:00', UTCDSTOffset: '-02:00' }, { code: 'FK', latLon: '-5142-05751', tz: 'Atlantic/Stanley', UTCOffset: '-03:00', UTCDSTOffset: '-03:00' }, { code: 'AU', latLon: '-3455+13835', tz: 'Australia/Adelaide', UTCOffset: '+09:30', UTCDSTOffset: '+10:30' }, { code: 'AU', latLon: '-2728+15302', tz: 'Australia/Brisbane', UTCOffset: '+10:00', UTCDSTOffset: '+10:00' }, { code: 'AU', latLon: '-3157+14127', tz: 'Australia/Broken_Hill', UTCOffset: '+09:30', UTCDSTOffset: '+10:30' }, { code: 'AU', latLon: '-3956+14352', tz: 'Australia/Currie', UTCOffset: '+10:00', UTCDSTOffset: '+11:00' }, { code: 'AU', latLon: '-1228+13050', tz: 'Australia/Darwin', UTCOffset: '+09:30', UTCDSTOffset: '+09:30' }, { code: 'AU', latLon: '-3143+12852', tz: 'Australia/Eucla', UTCOffset: '+08:45', UTCDSTOffset: '+08:45' }, { code: 'AU', latLon: '-4253+14719', tz: 'Australia/Hobart', UTCOffset: '+10:00', UTCDSTOffset: '+11:00' }, { code: 'AU', latLon: '-2016+14900', tz: 'Australia/Lindeman', UTCOffset: '+10:00', UTCDSTOffset: '+10:00' }, { code: 'AU', latLon: '-3133+15905', tz: 'Australia/Lord_Howe', UTCOffset: '+10:30', UTCDSTOffset: '+11:00' }, { code: 'AU', latLon: '-3749+14458', tz: 'Australia/Melbourne', UTCOffset: '+10:00', UTCDSTOffset: '+11:00' }, { code: 'AU', latLon: '-3157+11551', tz: 'Australia/Perth', UTCOffset: '+08:00', UTCDSTOffset: '+08:00' }, { code: 'AU', latLon: '-3352+15113', tz: 'Australia/Sydney', UTCOffset: '+10:00', UTCDSTOffset: '+11:00' }, { code: 'NL', latLon: '5676', tz: 'Europe/Amsterdam', UTCOffset: '+01:00', UTCDSTOffset: '+02:00' }, { code: 'AD', latLon: '4361', tz: 'Europe/Andorra', UTCOffset: '+01:00', UTCDSTOffset: '+02:00' }, { code: 'RU', latLon: '9424', tz: 'Europe/Astrakhan', UTCOffset: '+04:00', UTCDSTOffset: '+04:00' }, { code: 'GR', latLon: '6101', tz: 'Europe/Athens', UTCOffset: '+02:00', UTCDSTOffset: '+03:00' }, { code: 'RS', latLon: '6480', tz: 'Europe/Belgrade', UTCOffset: '+01:00', UTCDSTOffset: '+02:00' }, { code: 'DE', latLon: '6552', tz: 'Europe/Berlin', UTCOffset: '+01:00', UTCDSTOffset: '+02:00' }, { code: 'BE', latLon: '5470', tz: 'Europe/Brussels', UTCOffset: '+01:00', UTCDSTOffset: '+02:00' }, { code: 'RO', latLon: '7032', tz: 'Europe/Bucharest', UTCOffset: '+02:00', UTCDSTOffset: '+03:00' }, { code: 'HU', latLon: '6635', tz: 'Europe/Budapest', UTCOffset: '+01:00', UTCDSTOffset: '+02:00' }, { code: 'MD', latLon: '7550', tz: 'Europe/Chisinau', UTCOffset: '+02:00', UTCDSTOffset: '+03:00' }, { code: 'DK', latLon: '6775', tz: 'Europe/Copenhagen', UTCOffset: '+01:00', UTCDSTOffset: '+02:00' }, { code: 'IE', latLon: '+5320-00615', tz: 'Europe/Dublin', UTCOffset: '+00:00', UTCDSTOffset: '+01:00' }, { code: 'GI', latLon: '+3608-00521', tz: 'Europe/Gibraltar', UTCOffset: '+01:00', UTCDSTOffset: '+02:00' }, { code: 'FI', latLon: '8468', tz: 'Europe/Helsinki', UTCOffset: '+02:00', UTCDSTOffset: '+03:00' }, { code: 'TR', latLon: '6959', tz: 'Europe/Istanbul', UTCOffset: '+03:00', UTCDSTOffset: '+03:00' }, { code: 'RU', latLon: '7473', tz: 'Europe/Kaliningrad', UTCOffset: '+02:00', UTCDSTOffset: '+02:00' }, { code: 'UA', latLon: '8057', tz: 'Europe/Kiev', UTCOffset: '+02:00', UTCDSTOffset: '+03:00' }, { code: 'RU', latLon: '10775', tz: 'Europe/Kirov', UTCOffset: '+03:00', UTCDSTOffset: '+03:00' }, { code: 'PT', latLon: '+3843-00908', tz: 'Europe/Lisbon', UTCOffset: '+00:00', UTCDSTOffset: '+01:00' }, { code: 'GB', latLon: '+513030-0000731', tz: 'Europe/London', UTCOffset: '+00:00', UTCDSTOffset: '+01:00' }, { code: 'LU', latLon: '5545', tz: 'Europe/Luxembourg', UTCOffset: '+01:00', UTCDSTOffset: '+02:00' }, { code: 'ES', latLon: '+4024-00341', tz: 'Europe/Madrid', UTCOffset: '+01:00', UTCDSTOffset: '+02:00' }, { code: 'MT', latLon: '4985', tz: 'Europe/Malta', UTCOffset: '+01:00', UTCDSTOffset: '+02:00' }, { code: 'BY', latLon: '8088', tz: 'Europe/Minsk', UTCOffset: '+03:00', UTCDSTOffset: '+03:00' }, { code: 'MC', latLon: '5065', tz: 'Europe/Monaco', UTCOffset: '+01:00', UTCDSTOffset: '+02:00' }, { code: 'RU', latLon: '928225', tz: 'Europe/Moscow', UTCOffset: '+03:00', UTCDSTOffset: '+03:00' }, { code: 'CY', latLon: '6832', tz: 'Asia/Nicosia', UTCOffset: '+02:00', UTCDSTOffset: '+03:00' }, { code: 'NO', latLon: '7000', tz: 'Europe/Oslo', UTCOffset: '+01:00', UTCDSTOffset: '+02:00' }, { code: 'FR', latLon: '5072', tz: 'Europe/Paris', UTCOffset: '+01:00', UTCDSTOffset: '+02:00' }, { code: 'CZ', latLon: '6431', tz: 'Europe/Prague', UTCOffset: '+01:00', UTCDSTOffset: '+02:00' }, { code: 'LV', latLon: '8063', tz: 'Europe/Riga', UTCOffset: '+02:00', UTCDSTOffset: '+03:00' }, { code: 'IT', latLon: '5383', tz: 'Europe/Rome', UTCOffset: '+01:00', UTCDSTOffset: '+02:00' }, { code: 'RU', latLon: '10321', tz: 'Europe/Samara', UTCOffset: '+04:00', UTCDSTOffset: '+04:00' }, { code: 'RU', latLon: '9736', tz: 'Europe/Saratov', UTCOffset: '+04:00', UTCDSTOffset: '+04:00' }, { code: 'UA', latLon: '7863', tz: 'Europe/Simferopol', UTCOffset: '+03:00', UTCDSTOffset: '+03:00' }, { code: 'BG', latLon: '6560', tz: 'Europe/Sofia', UTCOffset: '+02:00', UTCDSTOffset: '+03:00' }, { code: 'SE', latLon: '7723', tz: 'Europe/Stockholm', UTCOffset: '+01:00', UTCDSTOffset: '+02:00' }, { code: 'EE', latLon: '8370', tz: 'Europe/Tallinn', UTCOffset: '+02:00', UTCDSTOffset: '+03:00' }, { code: 'AL', latLon: '6070', tz: 'Europe/Tirane', UTCOffset: '+01:00', UTCDSTOffset: '+02:00' }, { code: 'RU', latLon: '10244', tz: 'Europe/Ulyanovsk', UTCOffset: '+04:00', UTCDSTOffset: '+04:00' }, { code: 'UA', latLon: '7055', tz: 'Europe/Uzhgorod', UTCOffset: '+02:00', UTCDSTOffset: '+03:00' }, { code: 'AT', latLon: '6433', tz: 'Europe/Vienna', UTCOffset: '+01:00', UTCDSTOffset: '+02:00' }, { code: 'LT', latLon: '7960', tz: 'Europe/Vilnius', UTCOffset: '+02:00', UTCDSTOffset: '+03:00' }, { code: 'RU', latLon: '9269', tz: 'Europe/Volgograd', UTCOffset: '+04:00', UTCDSTOffset: '+04:00' }, { code: 'PL', latLon: '7315', tz: 'Europe/Warsaw', UTCOffset: '+02:00', UTCDSTOffset: '+03:00' }, { code: 'UA', latLon: '8260', tz: 'Europe/Zaporozhye', UTCOffset: '+02:00', UTCDSTOffset: '+03:00' }, { code: 'CH', latLon: '5555', tz: 'Europe/Zurich', UTCOffset: '+01:00', UTCDSTOffset: '+02:00' }, { code: 'IO', latLon: '-0720+07225', tz: 'Indian/Chagos', UTCOffset: '+06:00', UTCDSTOffset: '+06:00' }, { code: 'CX', latLon: '-1025+10543', tz: 'Indian/Christmas', UTCOffset: '+07:00', UTCDSTOffset: '+07:00' }, { code: 'CC', latLon: '-1210+09655', tz: 'Indian/Cocos', UTCOffset: '+06:30', UTCDSTOffset: '+06:30' }, { code: 'TF', latLon: '-492110+0701303', tz: 'Indian/Kerguelen', UTCOffset: '+05:00', UTCDSTOffset: '+05:00' }, { code: 'SC', latLon: '-0440+05528', tz: 'Indian/Mahe', UTCOffset: '+04:00', UTCDSTOffset: '+04:00' }, { code: 'MV', latLon: '7740', tz: 'Indian/Maldives', UTCOffset: '+05:00', UTCDSTOffset: '+05:00' }, { code: 'MU', latLon: '-2010+05730', tz: 'Indian/Mauritius', UTCOffset: '+04:00', UTCDSTOffset: '+04:00' }, { code: 'RE', latLon: '-2052+05528', tz: 'Indian/Reunion', UTCOffset: '+04:00', UTCDSTOffset: '+04:00' }, { code: 'WS', latLon: '-1350-17144', tz: 'Pacific/Apia', UTCOffset: '+13:00', UTCDSTOffset: '+14:00' }, { code: 'NZ', latLon: '-3652+17446', tz: 'Pacific/Auckland', UTCOffset: '+12:00', UTCDSTOffset: '+13:00' }, { code: 'PG', latLon: '-0613+15534', tz: 'Pacific/Bougainville', UTCOffset: '+11:00', UTCDSTOffset: '+11:00' }, { code: 'NZ', latLon: '-4357-17633', tz: 'Pacific/Chatham', UTCOffset: '+12:45', UTCDSTOffset: '+13:45' }, { code: 'FM', latLon: '15872', tz: 'Pacific/Chuuk', UTCOffset: '+10:00', UTCDSTOffset: '+10:00' }, { code: 'CL', latLon: '-2709-10926', tz: 'Pacific/Easter', UTCOffset: '-06:00', UTCDSTOffset: '-05:00' }, { code: 'VU', latLon: '-1740+16825', tz: 'Pacific/Efate', UTCOffset: '+11:00', UTCDSTOffset: '+11:00' }, { code: 'KI', latLon: '-0308-17105', tz: 'Pacific/Enderbury', UTCOffset: '+13:00', UTCDSTOffset: '+13:00' }, { code: 'TK', latLon: '-0922-17114', tz: 'Pacific/Fakaofo', UTCOffset: '+13:00', UTCDSTOffset: '+13:00' }, { code: 'FJ', latLon: '-1808+17825', tz: 'Pacific/Fiji', UTCOffset: '+12:00', UTCDSTOffset: '+13:00' }, { code: 'TV', latLon: '-0831+17913', tz: 'Pacific/Funafuti', UTCOffset: '+12:00', UTCDSTOffset: '+12:00' }, { code: 'EC', latLon: '-0054-08936', tz: 'Pacific/Galapagos', UTCOffset: '-06:00', UTCDSTOffset: '-06:00' }, { code: 'PF', latLon: '-2308-13457', tz: 'Pacific/Gambier', UTCOffset: '-09:00', UTCDSTOffset: '-09:00' }, { code: 'SB', latLon: '-0932+16012', tz: 'Pacific/Guadalcanal', UTCOffset: '+11:00', UTCDSTOffset: '+11:00' }, { code: 'GU', latLon: '15773', tz: 'Pacific/Guam', UTCOffset: '+10:00', UTCDSTOffset: '+10:00' }, { code: 'US', latLon: '+211825-1575130', tz: 'Pacific/Honolulu', UTCOffset: '-10:00', UTCDSTOffset: '-10:00' }, { code: 'KI', latLon: '+0152-15720', tz: 'Pacific/Kiritimati', UTCOffset: '+14:00', UTCDSTOffset: '+14:00' }, { code: 'FM', latLon: '16778', tz: 'Pacific/Kosrae', UTCOffset: '+11:00', UTCDSTOffset: '+11:00' }, { code: 'MH', latLon: '17625', tz: 'Pacific/Kwajalein', UTCOffset: '+12:00', UTCDSTOffset: '+12:00' }, { code: 'MH', latLon: '17821', tz: 'Pacific/Majuro', UTCOffset: '+12:00', UTCDSTOffset: '+12:00' }, { code: 'PF', latLon: '-0900-13930', tz: 'Pacific/Marquesas', UTCOffset: '-09:30', UTCDSTOffset: '-09:30' }, { code: 'NR', latLon: '-0031+16655', tz: 'Pacific/Nauru', UTCOffset: '+12:00', UTCDSTOffset: '+12:00' }, { code: 'NU', latLon: '-1901-16955', tz: 'Pacific/Niue', UTCOffset: '-11:00', UTCDSTOffset: '-11:00' }, { code: 'NF', latLon: '-2903+16758', tz: 'Pacific/Norfolk', UTCOffset: '+11:00', UTCDSTOffset: '+11:00' }, { code: 'NC', latLon: '-2216+16627', tz: 'Pacific/Noumea', UTCOffset: '+11:00', UTCDSTOffset: '+11:00' }, { code: 'AS', latLon: '-1416-17042', tz: 'Pacific/Pago_Pago', UTCOffset: '-11:00', UTCDSTOffset: '-11:00' }, { code: 'PW', latLon: '14149', tz: 'Pacific/Palau', UTCOffset: '+09:00', UTCDSTOffset: '+09:00' }, { code: 'PN', latLon: '-2504-13005', tz: 'Pacific/Pitcairn', UTCOffset: '-08:00', UTCDSTOffset: '-08:00' }, { code: 'FM', latLon: '16471', tz: 'Pacific/Pohnpei', UTCOffset: '+11:00', UTCDSTOffset: '+11:00' }, { code: 'PG', latLon: '-0930+14710', tz: 'Pacific/Port_Moresby', UTCOffset: '+10:00', UTCDSTOffset: '+10:00' }, { code: 'CK', latLon: '-2114-15946', tz: 'Pacific/Rarotonga', UTCOffset: '-10:00', UTCDSTOffset: '-10:00' }, { code: 'PF', latLon: '-1732-14934', tz: 'Pacific/Tahiti', UTCOffset: '-10:00', UTCDSTOffset: '-10:00' }, { code: 'KI', latLon: '17425', tz: 'Pacific/Tarawa', UTCOffset: '+12:00', UTCDSTOffset: '+12:00' }, { code: 'TO', latLon: '-2110-17510', tz: 'Pacific/Tongatapu', UTCOffset: '+13:00', UTCDSTOffset: '+14:00' }, { code: 'UM', latLon: '18554', tz: 'Pacific/Wake', UTCOffset: '+12:00', UTCDSTOffset: '+12:00' }, { code: 'WF', latLon: '-1318-17610', tz: 'Pacific/Wallis', UTCOffset: '+12:00', UTCDSTOffset: '+12:00' } ]