scheduledTrigger.mjs

/**
 * @description Manages a scheduled trigger event.
 * @copyright 2023-2023
 * @author Mike Price <dev.grumptech@gmail.com>
 * @module TimeTriggerModule
 * @requires debug
 * @see {@link https://github.com/debug-js/debug#readme}
 * @requires is-it-check
 * @see {@link https://github.com/evdama/is-it-check}
 * @requires grumptech-astrodata
 * @see {@link https://github.com/pricemi115/grumptech-astrodata#readme}
 */

// External dependencies and imports.
import _debugModule from 'debug';
import _is from 'is-it-check';
import {API_ASTRONOMICAL_DATA_EVENTS, AstronomicalDataAPI} from 'grumptech-astrodata';

// Internal dependencies
import {TRIGGER_STATES, TRIGGER_DAYS, ASTRONOMICAL_TRIGGERS, TIME_OFFSET_TYPES} from './triggerTypes.mjs';
import {TimeTrigger} from './timeTrigger.mjs';
import {TRIGGER_ACTIONS} from './triggerStateBase.mjs';

/**
 * @description Debugging function pointer for runtime related diagnostics.
 * @private
 */
const _debug = _debugModule('scheduled_trigger');

/**
 * @description Minimum day (Sunday)
 * @private
 */
const MIN_DAY = 0;
/**
 * @description Maximum day (Saturday)
 * @private
 */
const MAX_DAY = 6;
/**
 * @description Minimum minute
 * @private
 */
const MIN_MINUTE = 0;
/**
 * @description Maximum minute
 * @private
 */
const MAX_MINUTE = 59;
/**
 * @description Minimum hour
 * @private
 */
const MIN_HOUR = 0;
/**
 * @description Maximum hour
 * @private
 */
const MAX_HOUR = 23;

/**
 * @description Flag value for an invalid timeout
 * @private
 */
const INVALID_TIMEOUT_ID = -1;

/**
 * @description Tolerance for checking/validating active timeouts.
 * @private
 */
const REMAINING_TIME_TOLERANCE = 500/* milliseconds */;

/**
 * @description Period for checking/validating active timeouts.
 * @private
 */
const REMAINING_TIME_CHECK_PERIOD = 60000/* milliseconds */;

/**
 * @description Time Trigger state changed notification
 * @event module:TimeTriggerModule#event:state_changed
 * @type {object}
 * @param {string} e.uuid - Unique identifier of the trigger
 * @param {TRIGGER_STATES} e.new_state - New trigger state
 * @param {TRIGGER_STATES} e.old_state - Previous trigger state
 * @private
 */
/**
 * @description Time Trigger state notification
 * @event module:TimeTriggerModule#event:state_notify
 * @type {object}
 * @param {string} e.uuid - Unique identifier of the trigger
 * @param {TRIGGER_STATES} e.current_state - Current trigger state
 * @private
 */
/**
 * @description Trigger for scheduled events.
 * @augments TimeTrigger
 */
export class ScheduledTrigger extends TimeTrigger {
    /**
     * @description Constructor
     * @param {object} config - Configuration data
     * @param {string} [config.identifier] - Identifier
     * @param {module:TriggerTypes.TRIGGER_DAYS} [config.days] - Bitmask of days of the week to schedule the trigger.
     * @param {ASTRONOMICAL_TRIGGERS} [config.astronomical_type] - Type of astronomical trigger. Not used if not astronomical.
     * @param {object} config.time - Structure of time to trip the trigger
     * @param {object} [config.time.astronomical_offset] - Offset from the astronomical event. Not used if not astronomical.
     * @param {TIME_OFFSET_TYPES} config.astronomical_offset.type - Type of offset.
     * @param {number} config.time.astronomical_offset.hour - Hour (0-23)
     * @param {number} config.time.astronomical_offset.minute - Minute (0-59)
     * @param {object} [config.time.nominal] - Nominal time to trip the trigger. Not used if astronomical.
     * @param {number} config.time.nominal.hour - Hour (0-23)
     * @param {number} config.time.nominal.minute - Minute (0-59)
     * @param {object} config.time.tolerance - Tolerance around the nominal time to trip the trigger.
     * @param {number} config.time.tolerance.hour - Hour (0-23)
     * @param {number} config.time.tolerance.minute - Minute (0-59)
     * @param {object} [config.duration] - Range of times for the tripped duration.
     * @param {number} config.duration.nominal - Minimum time, in milliseconds for the tripped duration.
     * @param {number} config.duration.tolerance - Maximum time, in milliseconds for the tripped duration.
     * @param {object} [config.location] - Location used when trigger is astronomical. ** Required for Astronomical Triggers.
     * @param {number} config.location.latitude - Latitude
     * @param {number} config.location.longitude - Longitude
     * @throws {TypeError} - Thrown if 'config' is invalid.
     * @throws {RangeError} - Thrown if 'config' is invalid.
     * @throws {Error} - Thrown if 'config.location' is invalid.
     * @class
     */
    constructor(config) {
        // Validate arguments
        if (_is.not.undefined(config)) {
            if ((_is.not.object(config) ||
                (_is.not.undefined(config.days) && _is.not.number(config.days)))) {
                throw new TypeError(`Invalid configuration.`);
            }
            if (_is.not.undefined(config.days) && _is.not.within(config.days, (TRIGGER_DAYS.Sunday-1), (TRIGGER_DAYS.AllDays+1))) {
                throw new RangeError(`Invalid configuration. Days=${config.days}`);
            }
            // Validate the astronomical type.
            if (_is.not.undefined(config.astronomical_type) &&
                _is.not.inArray(config.astronomical_type, Object.values(ASTRONOMICAL_TRIGGERS))) {
                throw new RangeError(`Invalid configuration. Astronomical Type=${config.astronomical_type}`);
            }
            if (_is.not.undefined(config.astronomical_type)) {
                const locValidator = new AstronomicalDataAPI();
                const validLoc = locValidator.IsLocationValid(config.location);
                if (_is.not.truthy(validLoc)) {
                    // Invalid location.
                    throw new Error(`Invalid location`);
                }
            }

            // Validate the time.
            ScheduledTrigger._checkTime(_is.not.undefined(config.astronomical_type), config.time);
        }

        // Initialize the base class.
        super(config);

        // Set the days.
        if (_is.not.undefined(config) &&
            _is.not.undefined(config.days)) {
            this._days = config.days;
        }
        else {
            // Set to the default of all days.
            this._days = TRIGGER_DAYS.AllDays;
        }

        // Set the astronomical type. (May be undefined)
        if (_is.not.undefined(config)) {
            this._astronomicalType = config.astronomical_type;
        }

        // Set the scheduled time.
        if (_is.not.undefined(config) &&
            _is.not.undefined(config.time)) {
            this._time = config.time;
            if (_is.undefined(this._time.nominal)) {
                this._time.nominal = {hour: -1, minute: -1};
            }
            if (_is.undefined(this._time.tolerance)) {
                this._time.tolerance = {hour: 0, minute: 0};
            }
        }
        else {
            // Set to the default to 1 Minute from now.
            const trigger = new Date();
            trigger.setMinutes(trigger.getMinutes() + 1);
            /* eslint-disable indent */
            this._time = {nominal:   {hour: trigger.getHours(), minute: trigger.getMinutes()},
                          tolerance: {hour: 0, minute: 0}};
            /* eslint-enable indent */
        }

        // Trigger type-specific initialization.
        if (_is.not.undefined(config) &&
            _is.not.undefined(this._astronomicalType)) {
            // Cache the location
            this._location = config.location;

            // Commpute and the astronomical offset in minutes
            let offsetPolarity = 0.0;
            switch (config.time.astronomical_offset.type) {
                case TIME_OFFSET_TYPES.TYPE_BEFORE: {
                    offsetPolarity = -1.0;
                    break;
                }
                case TIME_OFFSET_TYPES.TYPE_AFTER: {
                    offsetPolarity = 1.0;
                    break;
                }
                default: {
                    offsetPolarity = 0.0;
                    break;
                }
            }
            this._astronomicalOffset = offsetPolarity * ((config.time.astronomical_offset.hour * 60.0) +
                                                         config.time.astronomical_offset.minute);

            // Create the astronomical helpwe and register for events of interest.
            this._astroHelper = new AstronomicalDataAPI();
            this._astroHelper.on(API_ASTRONOMICAL_DATA_EVENTS.API_EVENT_DATA_REQUEST_COMPLETE, this._processAstronomicalResults.bind(this));
        }
        else {
            // Fixed schedule. Compute the trigger time delta now.
            this._triggerDelta = this._computeTriggerTimeDelta(this._time);
        }

        // Data for managing updates to the system time (ex. DST time changes)
        this._CB_checkTimeRemaining = this._on_CheckTimeRemainingTripped.bind(this);
        this._lastTimeRemaining = -1;
        this._checkTimeoutID = INVALID_TIMEOUT_ID;
        // Used to support unit tests
        this._remainingTimeCheckPeriod = REMAINING_TIME_CHECK_PERIOD;

        // Date of the last trip
        this._lastTripTime = undefined;
    }

    /**
     * @description Enter Idle State
     * @returns {boolean} Always return true.
     */
    EnterIdle() {
        // Reset the date of the last trip
        this._lastTripTime = new Date(0);

        // Defer to the base class.
        super.EnterIdle();

        return true;
    }

    /**
     * @description Enter Arming State
     * @returns {boolean} Always return true.
     */
    EnterArming() {
        // If the scheduled trigger is not configured as astronomical,
        // defer to the base class.
        if (_is.undefined(this._astroHelper)) {
            // This will automatically kick us to Armed.
            super.EnterArming();
        }
        else {
            // Astronomical setting. Just perform the state change.
            this._doStateChange(this._armingState);
        }

        return true;
    }

    /**
     * @description Enter Tripped State
     * @returns {boolean} Always return true.
     */
    EnterTripped() {
        // Update the last trip time.
        this._lastTripTime = new Date();

        // Clear the check timer, we don't need it anymore.
        this._clearCheck();

        // Defer to the base class.
        super.EnterTripped();

        return true;
    }

    /**
     * @description Generates new timeout values for the timer.
     * @returns {void}
     * @private
     */
    GenerateNewTimerValues() {
        // If the scheduled trigger is not configured as astronomical,
        // generate the next time now.
        if (_is.undefined(this._astroHelper)) {
            this._doGenerateNewTimerValues();
        }
        else {
            // Get th current time
            const now = new Date();
            this._makeAstronomicalRequest(now);
        }
    }

    /**
     * @description Helper to start the trigger timer.
     * @param {number} timeout - Timeout period, in milliseconds
     * @returns {void}
     * @throws {TypeError} - thrown if timeout is not a number
     * @throws {RangeError} - thrown if the timeout is not positive
     * @private
     */
    DoStart(timeout) {
        // Defer to base class.
        super.DoStart(timeout);

        // Initialize the last known amount of time remaining.
        this._lastTimeRemaining = this.TimeRemaining;
        this._remainingTimeCheckPeriod = REMAINING_TIME_CHECK_PERIOD;
        this._checkTimeoutID = setInterval(this._CB_checkTimeRemaining, this._remainingTimeCheckPeriod);
    }

    /**
     * @description Helper to stop the trigger timer.
     * @returns {void}
     * @private
     */
    _doStop() {
        // Defer to base class.
        super._doStop();

        // Clear the check .
        this._clearCheck();
    }

    /**
     * @description Helper to clear the check timer
     * @returns {void}
     * @private
     */
    _clearCheck() {
        // Clear the check .
        if (this._checkTimeoutID !== INVALID_TIMEOUT_ID) {
            clearInterval(this._checkTimeoutID);
            this._checkTimeoutID = INVALID_TIMEOUT_ID;
        }
    }

    /**
     * @description Helper to post a request for astronomical results.
     * @param {Date} date - Date for the request.
     * @returns {void}
     * @private
     */
    _makeAstronomicalRequest(date) {
        // Astronomical setting. Request the astronomical data.
        this._astroHelper.RequestAstronomicalOneDayData({id: 'gt_trigr', date: date, location: this._location});
    }

    /**
     * @description - Event handler to process the astronomical request results and manage the dtate.
     * @param {object} e - Event notification data
     * @param {boolean} e.status - Error indicator (false==no error)
     * @param {object=} e.data - Object containing astronomical results.
     * @param {boolean=} e.data.valid - Flag indicating if the astronomical results are valid
     * @param {string=} e.data.APIVersion - US Naval Observatory API Version
     * @param {string=} e.data.Type - Response Type
     * @param {Date=} e.data.date - Date of the request data in local time.
     * @param {number=} e.data.latitude - Latitude
     * @param {number=} e.data.longitude - Longitude
     * @param {string=} e.data.lunar_phase - Lunar phase
     * @param {Date=} e.data.twilight_start - Date of the start of twilight in local time.
     * @param {Date=} e.data.twilight_end - Date of the end of twilight in local time.
     * @param {Date=} e.data.solar_rise - Date of the sunrise in local time.
     * @param {Date=} e.data.solar_set - Date of the sunset in local time.
     * @param {Date=} e.data.solar_transit - Date of the solar transit in local time.
     * @param {Date=} e.data.lunar_rise - Date of the moon rise in local time.
     * @param {Date=} e.data.lunar_set - Date of the moon set in local time.
     * @param {Date=} e.data.lunar_transit - Date of the lunar transit in local time.
     * @returns {void}
     * @private
     */
    _processAstronomicalResults(e) {
        _debug(`Received Astro Results. status=${e.status}`);

        // Manage the state change
        // Note: Even if there was an error getting the astronimical results,
        //       when we have previously established the event time, we will just use
        //       the previous results.
        if (((_is.undefined(e) || _is.undefined(e.status) ||
              _is.truthy(e.status)))) {
            if (_is.not.undefined(this._time) &&
                _is.not.undefined(this._time.nominal)) {
                _debug(`Issue getting astro results. Defer to previous event settings.`);
                // Update the new timer values using the default.
                this._triggerDelta = this._computeTriggerTimeDelta(this._time);
                this._doGenerateNewTimerValues();
                // Move on
                setImmediate(() => {
                    this._currentState.Evaluate(TRIGGER_ACTIONS.Next);
                });
            }
            else {
                // Abort.
                this._currentState.Evaluate(TRIGGER_ACTIONS.Abort);
            }
        }
        else {
            // Get the astronomical time.
            const triggerDate = this._getAstroDateTime(e.data);

            // Extract the time of the desired 'phenomena', if the results indicate so.
            if (e.data.valid) {
                // Set the 'nominal' time
                if (_is.not.undefined(triggerDate)) {
                    this._time.nominal.hour = triggerDate.getHours();
                    this._time.nominal.minute = triggerDate.getMinutes();
                }
            }

            if ((this.Timeout > 0) ||
                (_is.not.undefined(triggerDate))) {
                // Update the new timer values.
                this._triggerDelta = this._computeTriggerTimeDelta(this._time);
                this._doGenerateNewTimerValues();

                // Ensure that the resulting trigger occurs on the same day as our request.
                const astroDay = e.data.date;
                const tripDate = new Date(Date.now() + this._timeout.nominal);

                if ((astroDay.getFullYear() == tripDate.getFullYear()) &&
                    (astroDay.getMonth() == tripDate.getMonth()) &&
                    (astroDay.getDate() == tripDate.getDate())) {
                    // Move on
                    setImmediate(() => {
                        this._currentState.Evaluate(TRIGGER_ACTIONS.Next);
                    });
                }
                else {
                    // The event has passed. Make another request.
                    _debug(`Requery for trigger time: day=${tripDate.toString()}`);
                    // Decouple the request.
                    setImmediate(() => {
                        this._makeAstronomicalRequest(tripDate);
                    });
                }
            }
            else {
                // Do we have a default time to work with? Could be the previous trigger time.
                // Note: The desired trigger may be invalid on this particular date, so use what we have.
                if (_is.not.undefined(this._time) &&
                    _is.not.undefined(this._time.nominal)) {
                    // Update the new timer values using the last known values.
                    this._triggerDelta = this._computeTriggerTimeDelta(this._time);
                    this._doGenerateNewTimerValues();
                    // Move on
                    setImmediate(() => {
                        this._currentState.Evaluate(TRIGGER_ACTIONS.Next);
                    });
                }
                else {
                    // Abort.
                    this._currentState.Evaluate(TRIGGER_ACTIONS.Abort);
                }
            }
        }
    }

    /**
     * @description Generates new timeout values for the timer.
     * @returns {void}
     * @private
     */
    _doGenerateNewTimerValues() {
        // Determine the current day so we can see when the next alarm is due.
        const now = new Date();
        const dayOfWeek = now.getDay();
        const date = now.getDate();

        if (dayOfWeek > MAX_DAY) {
            throw new RangeError(`Unexpected day. day=${dayOfWeek}`);
        }

        // Build an array of candidates.
        let count = 0;
        const triggerDays = [];
        let activeTriggeredDayIndex = 0;
        let nextTriggerDay = dayOfWeek;
        while (count <= MAX_DAY) {
            const candidate = (1 << nextTriggerDay);
            if ((candidate & this._days) !== 0) {
                // Add the candidate
                triggerDays.push(nextTriggerDay);
            }

            nextTriggerDay++;
            if (nextTriggerDay > MAX_DAY) {
                nextTriggerDay = 0;
            }

            count++;
        }

        // Make a Date for the minimum trigger.
        let dateMin = new Date(now);
        // Update the times appropriately.
        dateMin.setHours(this._time.nominal.hour);
        dateMin.setMinutes(this._time.nominal.minute);
        dateMin.setSeconds(0);
        dateMin.setMilliseconds(0);
        // Adjust for the tolerance
        dateMin.setTime(dateMin.getTime() - (this._triggerDelta/2));

        // Get the number of days from now until the minimum trigger time
        const deltaDaysMin = this._computeDeltaDays(dayOfWeek, triggerDays[activeTriggeredDayIndex]);
        dateMin.setDate(date + deltaDaysMin);

        // Made a Date for the maximum trigger.
        const dateMax = new Date(dateMin);
        // Adjust for the tolerance
        dateMax.setTime(dateMax.getTime() + this._triggerDelta);

        // Manage the trigger window
        if (dateMin < now) {
            let rescheduleTrigger = false;
            if (dateMax >= now) {
                // Since we are within the originally scheduled window,
                // make sure the last trigger was not within the original window
                if ((dateMin <= this._lastTripTime) &&
                    (dateMax >= this._lastTripTime)) {
                    // Move to the next configured day.
                    rescheduleTrigger = true;
                }
                else {
                    // The scheduled minimum trigger has already occured, set the minumum to now.
                    dateMin = now;
                }
            }
            else {
                rescheduleTrigger = true;
            }
            // Reschedule the trigger.
            if (_is.truthy(rescheduleTrigger)) {
                let offsetDays = 0;
                if (triggerDays.length > (activeTriggeredDayIndex + 1)) {
                    activeTriggeredDayIndex += 1;
                    offsetDays = this._computeDeltaDays(dayOfWeek, triggerDays[activeTriggeredDayIndex]);
                }
                else {
                    // The trigger is only once per week
                    offsetDays = MAX_DAY + 1;
                }

                // Update the trigger ranges
                dateMin.setDate(dateMin.getDate() + offsetDays);
                dateMax.setDate(dateMax.getDate() + offsetDays);
            }
        }

        // Update the trigger timeout window.
        // Compute the minumum time, in milliseconds, for the trigger.
        const minTime = dateMin - now;
        // Compute the maximum time, in milliseconds, for the trigger.
        const maxTime = dateMax - now;
        // Compute the tolerancce, in milliseconds for the trigger
        const toleranceTime = ((maxTime - minTime)/2);
        // Compute the nominal time, in milliseconds, for the trigger.
        let nominalTime = minTime + toleranceTime;
        // Cap the nominal at 0.
        if ((nominalTime < 0) && ((nominalTime+toleranceTime) >= 0)) {
            nominalTime = 0;
        }
        this._timeout = {nominal: nominalTime, tolerance: toleranceTime};

        // Defer to the base class.
        super.GenerateNewTimerValues();
    }

    /**
     * @description Helper to compute the number of days between day numbers.
     * @param {number} dayReference - Day of the week for the reference day
     * @param {number} dayTarget - Day of the week for the reference day
     * @returns {number} - Number of days
     * @throws {TypeError} - Thrown if the types are not as expected.
     * @throws {RangeError} - Thrown if either the argument is not in the range [0..6]
     * @private
     */
    _computeDeltaDays(dayReference, dayTarget) {
        if (_is.not.number(dayReference) ||
            _is.not.number(dayTarget)) {
            throw new TypeError(`Invalid day of week.`);
        }
        if (_is.not.within(dayReference, (MIN_DAY-1), (MAX_DAY+1)) ||
            _is.not.within(dayTarget, (MIN_DAY-1), (MAX_DAY+1))) {
            throw new ReferenceError(`Invalid day of week.`);
        }

        let dayDelta = 0;
        if (dayTarget >= dayReference) {
            dayDelta = dayTarget - dayReference;
        }
        else {
            dayDelta = (MAX_DAY - dayReference) + (dayTarget - MIN_DAY) + 1;
        }

        return dayDelta;
    }

    /**
     * @description Helper to validate time configuration parameters,
     * @param {object} time - Structure of time to trip the trigger
     * @param {object} time.nominal - Nominal time to trip the trigger.
     * @param {number} time.nominal.hour - Hour (0-23)
     * @param {number} time.nominal.minute - Minute (0-59)
     * @param {object} time.tolerance - Tolerance time around nominal to trip the trigger.
     * @param {number} time.tolerance.hour - Hour (0-23)
     * @param {number} time.tolerance.minute - Minute (0-59)
     * @returns {number} - total milliseconds, accounting for the tolerance, for the trigger.
     * @private
     */
    _computeTriggerTimeDelta(time) {
        // Make Dates for the nominal trigger.
        const nominalTrigger = new Date();
        nominalTrigger.setHours(time.nominal.hour);
        nominalTrigger.setMinutes(time.nominal.minute);
        nominalTrigger.setSeconds(0);
        nominalTrigger.setMilliseconds(0);
        // Compute the minimum trigger (earliest possible trip)
        const minTrigger = new Date(nominalTrigger);
        minTrigger.setHours(minTrigger.getHours() - time.tolerance.hour);
        minTrigger.setMinutes(minTrigger.getMinutes() - time.tolerance.minute);
        // Compute the maximum trigger (latest possible trip)
        const maxTrigger = new Date(nominalTrigger);
        maxTrigger.setHours(maxTrigger.getHours() + time.tolerance.hour);
        maxTrigger.setMinutes(maxTrigger.getMinutes() + time.tolerance.minute);

        // Compute the time between the triggers.
        if (maxTrigger < minTrigger) {
            // Roll the max trigger forward by one day.
            maxTrigger.setDate(maxTrigger.getDate() + 1);
        }

        // Compute the number of milliseconds between the minimum and maximum.
        const triggerDelta = maxTrigger - minTrigger;
        _debug(`Trigger delta in milliseconds: ${triggerDelta}`);

        return triggerDelta;
    }

    /**
     * @description Helper to extract the configured date.
     * @param {object=} astroData - Object containing astronomical results.
     * @param {boolean=} astroData.valid - Flag indicating if the astronomical results are valid
     * @param {string=} astroData.APIVersion - US Naval Observatory API Version
     * @param {string=} astroData.Type - Response Type
     * @param {Date=} astroData.date - Date of the request data in local time.
     * @param {number=} astroData.latitude - Latitude
     * @param {number=} astroData.longitude - Longitude
     * @param {string=} astroData.lunar_phase - Lunar phase
     * @param {Date=} astroData.twilight_start - Date of the start of twilight in local time.
     * @param {Date=} astroData.twilight_end - Date of the end of twilight in local time.
     * @param {Date=} astroData.solar_rise - Date of the sunrise in local time.
     * @param {Date=} astroData.solar_set - Date of the sunset in local time.
     * @param {Date=} astroData.solar_transit - Date of the solar transit in local time.
     * @param {Date=} astroData.lunar_rise - Date of the moon rise in local time.
     * @param {Date=} astroData.lunar_set - Date of the moon set in local time.
     * @param {Date=} astroData.lunar_transit - Date of the lunar transit in local time.
     * @returns {Date | undefined} - Date of the configured astronomical event. Undefined if not an astronomical trigger.
     * @private
     */
    _getAstroDateTime(astroData) {
        let date = undefined;

        if (_is.not.undefined(astroData) &&
            _is.not.undefined(this._astronomicalType) &&
            _is.truthy(astroData.valid)) {
            switch (this._astronomicalType) {
                case ASTRONOMICAL_TRIGGERS.ASTRONOMICAL_LUNAR_TRANSIT: {
                    date = astroData.lunar_transit;
                    break;
                }
                case ASTRONOMICAL_TRIGGERS.ASTRONOMICAL_MOON_RISE: {
                    date = astroData.lunar_rise;
                    break;
                }
                case ASTRONOMICAL_TRIGGERS.ASTRONOMICAL_MOON_SET: {
                    date = astroData.lunar_set;
                    break;
                }
                case ASTRONOMICAL_TRIGGERS.ASTRONOMICAL_SOALAR_TRANSIT: {
                    date = astroData.solar_transit;
                    break;
                }
                case ASTRONOMICAL_TRIGGERS.ASTRONOMICAL_SUNRISE: {
                    date = astroData.solar_rise;
                    break;
                }
                case ASTRONOMICAL_TRIGGERS.ASTRONOMICAL_SUNSET: {
                    date = astroData.solar_set;
                    break;
                }
                case ASTRONOMICAL_TRIGGERS.ASTRONOMICAL_TWILIGHT_START: {
                    date = astroData.twilight_start;
                    break;
                }
                case ASTRONOMICAL_TRIGGERS.ASTRONOMICAL_TWILIGHT_END: {
                    date = astroData.twilight_end;
                    break;
                }
                default: {
                    // Not handled.
                    break;
                }
            }
        }

        // Adjust for the offset, if appropriate.
        if (_is.not.undefined(date) &&
            _is.not.undefined(this._astronomicalOffset)) {
            date.setMinutes(date.getMinutes() + this._astronomicalOffset);
        }

        return date;
    }

    /**
     * @description Event handler for checking and validating remaining time.
     * @returns {void}
     * @private
     */
    _on_CheckTimeRemainingTripped() {
        // Get the time remaining
        const timeRemaining = this.TimeRemaining;
        // Compute the expected time remaining
        const expectedTimeRemaining = this._lastTimeRemaining - this._remainingTimeCheckPeriod;
        // Update last known remaining time.
        this._lastTimeRemaining = timeRemaining;
        // Check the difference bwtween the actual and expected time remaining.
        if (_is.not.negative(timeRemaining) && _is.not.negative(expectedTimeRemaining) &&
            _is.above(Math.abs(timeRemaining - expectedTimeRemaining), REMAINING_TIME_TOLERANCE)) {
            _debug(`Remaining time out of spec: Actual=${timeRemaining} Expected=${expectedTimeRemaining}`);

            if (timeRemaining <= 0) {
                // Trigger has elapsed.

                // Kill current timer(s)
                this._doStop();

                // Move on
                setImmediate(() => {
                    _debug(`Forcing 'Next' state change`);
                    this._currentState.Evaluate(TRIGGER_ACTIONS.Next);
                });
            }
            else {
                // Reset the timer based on expectations.
                _debug(`Resetting timer to ${expectedTimeRemaining}`);
                this.DoStart(expectedTimeRemaining);
                // Force a state notification
                this._doStateChange(this._currentState);
            }
        }
    }

    /**
     * @description Helper to validate time configuration parameters,
     * @param {boolean} isAstronomical - Flag indicating that the time object is astronomical (or not).
     * @param {object} time - Structure of time to trip the trigger
     * @param {object} [time.nominal] - Nominal time to trip the trigger. Not used if astronomical.
     * @param {number} time.nominal.hour - Hour (0-23)
     * @param {number} time.nominal.minute - Minute (0-59)
     * @param {object} [time.astronomical_offset] - Offset from the astronomical event. Not used if not astronomical.
     * @param {number} time.astronomical_offset.hour - Hour (0-23)
     * @param {number} time.astronomical_offset.minute - Minute (0-59)
     * @param {object} time.tolerance - Tolerance time around nominal to trip the trigger.
     * @param {number} time.tolerance.hour - Hour (0-23)
     * @param {number} time.tolerance.minute - Minute (0-59)
     * @returns {void}
     * @throws {TypeError} - Thrown if the types are not as expected.
     * @throws {RangeError} - Thrown if either the hour or minute are out of range.
     * @private
     */
    static _checkTime(isAstronomical, time) {
        if (_is.not.undefined(time)) {
            if (_is.not.object(time) ||
                _is.not.boolean(isAstronomical) ||
                (_is.truthy(isAstronomical) &&
                 (_is.not.object(time.astronomical_offset) ||
                  _is.not.number(time.astronomical_offset.hour) ||
                  _is.not.number(time.astronomical_offset.minute))) ||
                _is.not.object(time.nominal) ||
                _is.not.number(time.nominal.hour) ||
                _is.not.number(time.nominal.minute) ||
                _is.not.object(time.tolerance) ||
                _is.not.number(time.tolerance.hour) ||
                _is.not.number(time.tolerance.minute)) {
                throw new TypeError(`time is invalid.`);
            }
            if ((_is.truthy(isAstronomical) &&
                 (_is.not.within(time.astronomical_offset.hour, (MIN_HOUR-1), (MAX_HOUR+1)) ||
                  _is.not.within(time.astronomical_offset.minute, (MIN_MINUTE-1), (MAX_MINUTE+1)))) ||
                _is.not.within(time.nominal.hour, (MIN_HOUR-1), (MAX_HOUR+1)) ||
                _is.not.within(time.nominal.minute, (MIN_MINUTE-1), (MAX_MINUTE+1)) ||
                _is.not.within(time.tolerance.hour, (MIN_HOUR-1), (MAX_HOUR+1)) ||
                _is.not.within(time.tolerance.minute, (MIN_MINUTE-1), (MAX_MINUTE+1))) {
                throw new RangeError(`range is invalid.`);
            }
        }
    }
}