timeTrigger.mjs

/**
 * @description Manages a time-based 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 events
 * @see {@link https://nodejs.org/dist/latest-v16.x/docs/api/events.html#events}
 * @requires crypto
 * @see {@link https://nodejs.org/dist/latest-v16.x/docs/api/crypto.html#crypto}
 * @requires is-it-check
 * @see {@link https://github.com/evdama/is-it-check}
 */

// External dependencies and imports.
import EventEmitter from 'events';
import * as _crypto from 'crypto';
import _debugModule from 'debug';
import _is from 'is-it-check';

// Internal dependencies
import {TRIGGER_STATES, TRIGGER_EVENTS} from './triggerTypes.mjs';
import {TRIGGER_ACTIONS, TriggerStateBase} from './triggerStateBase.mjs';
import {TriggerStateIdle} from './triggerStateIdle.mjs';
import {TriggerStateArming} from './triggerStateArming.mjs';
import {TriggerStateArmed} from './triggerStateArmed.mjs';
import {TriggerStateTripped} from './triggerStateTripped.mjs';

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

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

/**
 * @description Default timeout, in milliseconds
 * @private
 */
const DEFAULT_TIMEOUT_MS = {nominal: 10000, tolerance: 0};

/**
 * @description Default, in milli-seconds, for the duration of the tripped state
 * @private
 */
const DEFAULT_TRIP_DURATION_MS = {nominal: 250, tolerance: 0};

/**
 * @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 periodic events.
 * @augments EventEmitter
 */
export class TimeTrigger extends EventEmitter {
    /**
     * @description Constructor
     * @param {object} config - Configuration data
     * @param {string} config.signature - Identifier intended to be used for persistence, but cannot be guaranteed to be unique.
     * @param {object=} config.timeout - Configuration of the timeout interval.
     * @param {number} config.timeout.nominal - Nominal time, in milliseconds, for the timeout.
     * @param {number} config.timeout.tolerance - Tolerance, in milliseconds, for the timeout.
     * @param {object=} config.duration - Configuration of the tripped duration.
     * @param {number} config.duration.nominal - Nominal time, in milliseconds, for the tripped duration.
     * @param {number} config.duration.tolerance - Tolerance, in milliseconds, for the tripped duration.
     * @param {number} config.trip_limit - Limit on the number of sequential trip events permitted.
     * @throws {TypeError} - Thrown if 'config' is invalid.
     * @class
     */
    constructor(config) {
        // Validate arguments
        if (_is.not.undefined(config)) {
            if (_is.not.object(config)) {
                throw new TypeError(`Invalid configuration.`);
            }
            if (_is.not.undefined(config.timeout)) {
                TimeTrigger._checkRange(config.timeout);
            }
            if (_is.not.undefined(config.duration)) {
                TimeTrigger._checkRange(config.duration);
            }
            if (_is.not.undefined(config.trip_limit)) {
                if (_is.not.number(config.trip_limit)) {
                    throw new TypeError(`Invalid configuration - trip limit`);
                }
                else if (_is.negative(config.trip_limit)) {
                    throw new RangeError(`Invalid configuration - trip limit`);
                }
            }
        }

        // Initialize the base class.
        super();

        // Create the states.
        this._idleState = new TriggerStateIdle({owner: this});
        this._armingState = new TriggerStateArming({owner: this});
        this._armedState = new TriggerStateArmed({owner: this});
        this._trippedState = new TriggerStateTripped({owner: this});

        // Initialize at the idle state.
        this._currentState = this._idleState;

        // Callbacks bound to this object.
        this._CB__timerTripped = this._on_timerTripped.bind(this);

        this._timeoutID = INVALID_TIMEOUT_ID;

        // Generate a new identifier
        this._uuid = _crypto.randomUUID({disableEntropyCache: true});

        // Get the identifier from the configuration.
        if (_is.existy(config) &&
            _is.string(config.signature) &&
            (config.signature.length > 0)) {
            this._signature = config.signature;
            this._name = config.signature;
        }
        else {
            // Identifier was either not provided or is invalid.
            const hash = _crypto.createHash('sha256');
            if (_is.existy(config)) {
                hash.update(JSON.stringify(config));
            }
            else {
                hash.update(`No config`);
            }
            this._signature = hash.digest('hex').toLowerCase();
            this._name = this._signature.slice(0, 6);
        }

        // Use the timeout provided or generate a new one.
        if (_is.undefined(config) ||
            _is.undefined(config.timeout)) {
            // Use the default
            this._timeout = DEFAULT_TIMEOUT_MS;
        }
        else {
            // Use the identifier provided.
            this._timeout = config.timeout;
        }

        // Use the tripped duration provided or generate a new one.
        if (_is.undefined(config) ||
            _is.undefined(config.duration)) {
            // Use the default
            this._trippedDuration = DEFAULT_TRIP_DURATION_MS;
        }
        else {
            // Use the identifier provided.
            this._trippedDuration = config.duration;
        }

        if (_is.undefined(config) ||
            _is.undefined(config.trip_limit)) {
            // Use the default (no limit)
            this._tripLimit = 0;
        }
        else {
            // Use the limit provided.
            this._tripLimit = config.trip_limit;
        }

        this._timeout_ms = -1;
        this._trippedDuration_ms = -1;
        this._tripCount = 0;
        this._expectedTripTime = new Date();

        // Force a decoupled transition into the Idle State.
        this._initializing = true;
        setImmediate(() => {
            this.EnterIdle();
        });
    }

    /**
     * @description Read-only property accessor for the trigger state
     * @returns {TRIGGER_STATES} - Trigger state.
     */
    get State() {
        return this._currentState.State;
    }

    /**
     * @description Read-only property accessor for the trigger identifier
     * @returns {string} - Trigger identifier.
     */
    get Identifier() {
        return this._uuid;
    }

    /**
     * @description Read-only property accessor for the trigger signature
     * @returns {string} - Trigger signature.
     */
    get Signature() {
        return this._signature;
    }

    /**
     * @description Read-only property accessor for the trigger name
     * @returns {string} - Trigger name.
     */
    get Name() {
        return this._name;
    }

    /**
     * @description Read property accessor for the timeout of the trigger
     * @returns {number} - Timeout in milliseconds.
     */
    get Timeout() {
        return this._timeout_ms;
    }

    /**
     * @description Read property accessor for the tripped duration of the trigger
     * @returns {number} - Duration in milliseconds.
     */
    get Duration() {
        return this._trippedDuration_ms;
    }

    /**
     * @description Read property accessor for the time remaining until the next trip event
     * @returns {number} - Time remaining in milliseconds.
     */
    get TimeRemaining() {
        // Compute the time remaining until the next trip event.
        const now = new Date();
        let timeRemaining = this._expectedTripTime - now;
        // Cap the time remaining at 0.
        if (timeRemaining < 0) {
            timeRemaining = 0;
        }

        return timeRemaining;
    }

    /**
     * @description API to start/restart the timer.
     * @returns {void}
     */
    Start() {
        // Decouple the request if we are still initializing.
        if (this._initializing) {
            setImmediate(() => {
                this.Start();
            });
        }
        else {
            // Stop the trigger to bring us to the Idle State.
            if (!(this._currentState instanceof TriggerStateIdle)) {
                this.Stop();
            }

            // Manage the state
            this._currentState.Evaluate(TRIGGER_ACTIONS.Next);
        }
    }

    /**
     * @description API to stop the timer.
     * @returns {void}
     */
    Stop() {
        // Abort the current state.
        this._currentState.Evaluate(TRIGGER_ACTIONS.Abort);
    }

    /**
     * @description Enter Idle State
     * @returns {boolean} Always return true.
     */
    EnterIdle() {
        // Reset the trip count
        this._tripCount = 0;

        // Stop the timer.
        this._doStop();

        // Manage the state.
        this._doStateChange(this._idleState);

        // Clear the initializing flag.
        this._initializing = false;

        return true;
    }

    /**
     * @description Enter Arming State
     * @returns {boolean} Always return true.
     */
    EnterArming() {
        // Manage the state.
        this._doStateChange(this._armingState);

        // Jump to the next state.
        setImmediate(() => {
            // Ensure that we are not transitioning.
            this._currentState.Evaluate(TRIGGER_ACTIONS.Next);
        });

        return true;
    }

    /**
     * @description Enter Armed State
     * @returns {boolean} Always return true.
     */
    EnterArmed() {
        // Manage the state.
        this._doStateChange(this._armedState);

        return true;
    }

    /**
     * @description Enter Tripped State
     * @returns {boolean} Always return true.
     */
    EnterTripped() {
        // Increment the trip count
        this._tripCount++;

        // Manage the state.
        this._doStateChange(this._trippedState);

        return true;
    }

    /**
     * @description Read property accessor indicating if the trip limit has been exceeded
     * @returns {boolean} - true if the trip limit has been exceeded
     * @private
     */
    get IsTripLimitExpired() {
        // Default to the trip limit being 1 or more.
        let limitExceeded = (this._tripLimit > 0);
        if (limitExceeded) {
            // If the trip limit is set, has the trip count exceeded the limit?
            limitExceeded = (this._tripCount >= this._tripLimit);
        }

        return limitExceeded;
    }

    /**
     * @description Generates new timeout values for the timer.
     * @returns {void}
     * @private
     */
    GenerateNewTimerValues() {
        this._timeout_ms = TimeTrigger._getNextValue(this._timeout);
        this._trippedDuration_ms = TimeTrigger._getNextValue(this._trippedDuration);

        _debug(`New Timer Values for Trigger(${this.Name}): Timeout=${this._timeout_ms} Duration=${this._trippedDuration_ms}`);
    }

    /**
     * @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) {
        if (_is.not.number(timeout)) {
            throw new TypeError(`'timeout' is not a number`);
        }
        if (_is.not.positive(timeout)) {
            throw new RangeError(`'timeout' is not positive: ${timeout}`);
        }

        // Sanity. Stop, if needed.
        this._doStop();

        // Set the timer.
        this._timeoutID = setTimeout(this._CB__timerTripped, timeout);
        _debug(`timeTrigger::_doStart: timeoutID=${this._timeoutID} delay=${timeout}`);

        // Compute the expected trip time
        this._expectedTripTime = new Date(Date.now() + timeout);
    }

    /**
     * @description Helper to stop the trigger timer.
     * @returns {void}
     * @private
     */
    _doStop() {
        // Clear the timer.
        _debug(`timeTrigger::_doStop: timeoutID=${this._timeoutID}`);
        if (this._timeoutID !== INVALID_TIMEOUT_ID) {
            clearTimeout(this._timeoutID);
            this._timeoutID = INVALID_TIMEOUT_ID;
        }
    }

    /**
     * @description Event handler for the timer tripping
     * @returns {void}
     * @private
     */
    _on_timerTripped() {
        _debug(`timeTrigger::_on_timerTripped: timeoutID=${this._timeoutID}`);
        // Reset the timer id
        this._timeoutID = INVALID_TIMEOUT_ID;

        // Manage the state
        this._currentState.Evaluate(TRIGGER_ACTIONS.Next);
    }

    /**
     * @description Manage state changes.
     * @param {TriggerStateBase} state - New state
     * @returns {void}
     * @fires module:TimeTriggerModule#event:state_changed
     * @fires module:TimeTriggerModule#event:state_notify
     * @throws {TypeError} - Thrown if 'state' is not a TRIGGER_STATES value.
     * @private
     */
    _doStateChange(state) {
        // Validate the arguments
        if (!(state instanceof TriggerStateBase)) {
            throw new TypeError(`Invalid State: ${state}`);
        }

        // Check for a change in state
        if (state.State !== this._currentState.State) {
            // Decouple the state change.
            setImmediate(() => {
                try {
                    // Cache the old state.
                    const oldState = this._currentState;
                    // Exit the current state, providing it the new state.
                    this._currentState.OnExit(state);
                    // Update the state.
                    this._currentState = state;
                    // Enter the new state.
                    this._currentState.OnEntrance(oldState);

                    // Raise the state changed event.
                    this.emit(TRIGGER_EVENTS.EVENT_STATE_CHANGED, {uuid: this.Identifier, old_state: oldState.State, new_state: this.State});
                }
                catch (err) {
                    _debug(`Illegal state transition. ${err.message}`);
                }
            });
        }
        else {
            // No state change necessary.
            // Raise the state notify event, as a convenience to the client.
            this.emit(TRIGGER_EVENTS.EVENT_STATE_NOTIFY, {uuid: this.Identifier, current_state: this.State});
        }
    }

    /**
     * @description Helper to validate range configuration parameters,
     * @param {object} range - range object to be validated.
     * @param {number} range.nominal - Nominal value
     * @param {number} range.tolerance - Tolerance value
     * @returns {void}
     * @throws {TypeError} - Thrown if the types are not as expected.
     * @throws {RangeError} - Thrown if either the nominal or tolerance are negative.
     * @private
     */
    static _checkRange(range) {
        if (_is.not.undefined(range)) {
            if (_is.not.object(range) ||
                (_is.not.undefined(range.nominal) && _is.not.number(range.nominal)) ||
                (_is.not.undefined(range.tolerance) && _is.not.number(range.tolerance))) {
                throw new TypeError(`range is invalid.`);
            }
            if (_is.negative(range.nominal) ||
                _is.negative(range.tolerance)) {
                throw new RangeError(`range is invalid.`);
            }
        }
    }

    /**
     * @description Helper to get the next value from range specified
     * @param {object} range - Configuration parameter
     * @param {number} range.nominal - Nominal value
     * @param {number} range.tolerance - Tolerance value
     * @returns {number} - next value from the range
     * @private
     */
    static _getNextValue(range) {
        // Validate
        TimeTrigger._checkRange(range);

        // Compute the absolute minimum possible, capping at 0.
        const minimum = range.nominal - range.tolerance;

        // Compute the absolute maximum possible.
        const maximum = range.nominal + range.tolerance;

        // Compute the next value from the range.
        let nextVal = (Math.floor(Math.random() * (maximum - minimum))) + minimum;
        // Cap at 0
        if (_is.negative(nextVal)) {
            nextVal = 0;
        }

        return nextVal;
    }
}