main.mjs

/**
 * @description Manages a time-based trigger event.
 * @copyright 2023-2023
 * @author Mike Price <dev.grumptech@gmail.com>
 * @module HomebridgeAPIModule
 * @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}
 * @requires grumptech-astrodata
 * @see {@link https://github.com/pricemi115/grumptech-astrodata#readme}
 */

/*
 * IMPORTANT NOTICE
 *
 * One thing you need to take care of is, that you never ever ever import anything directly from the
 * "homebridge" module (or the "hap-nodejs" module).
 * The import block below may seem like we do exactly that, but actually those imports are only used
 * for types and interfaces and will disappear once the code is compiled to Javascript.
 * In fact you can check that by running `npm run build` and opening the compiled Javascript file in
 * the `dist` folder.
 * You will notice that the file does not contain a `... = require("homebridge");` statement
 * anywhere in the code.
 *
 * The contents of the import statement below MUST ONLY be used for type annotation or accessing
 * things like CONST ENUMS, which is a special case as they get replaced by the actual value and do
 * not remain as a reference in the compiled code.
 * Meaning normal enums are bad, const enums can be used.
 *
 * You MUST NOT import anything else which remains as a reference in the code, as this will result
 * in a `... = require("homebridge");` to be compiled into the final Javascript code.
 * This typically leads to unexpected behavior at runtime, as in many cases it won't be able to
 * find the module or will import another instance of homebridge causing collisions.
 *
 * To mitigate this the {@link API | Homebridge API} exposes the whole suite of HAP-NodeJS inside
 * the `hap` property of the api object, which can be acquired for example in the initializer
 * function. This reference can be stored and used to access all exported varia bles and classes
 * from HAP-NodeJS.
 */
/*
import {
    API,
    APIEvent,
    CharacteristicEventTypes,
    CharacteristicSetCallback,
    CharacteristicValue,
    DynamicPlatformPlugin,
    HAP,
    Logging,
    PlatformAccessory,
    PlatformAccessoryEvent,
    PlatformConfig,
    Service,
  } from "homebridge";
*/

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

// Internal dependency and imports
import {TimeTrigger} from './timeTrigger.mjs';
import {ScheduledTrigger} from './scheduledTrigger.mjs';
import {TRIGGER_STATES, TRIGGER_EVENTS, TRIGGER_DAYS, TRIGGER_TYPES, TIME_OFFSET_TYPES} from './triggerTypes.mjs';

// Internal complex types
/**
 * @typedef {object} DueTime - Due Time
 * @property {string} due_time - Local time string
 * @property {number} due_dow  - Day of the week.
 */

// Internal Constants
// History:
//          v1:     Initial release
//          v1.1:   Added Time Remaining and Dur Time Services
/**
 * @description Version history of the plugin.
 * @private
 */
const ACCESSORY_VERSION = 2.0;

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

/**
 * @description Package Information
 */
const _PackageInfo = {CONFIG_INFO: PLACEHOLDER_CONFIG_INFO, PLUGIN_VER: 'PLACEHOLDER_VERSION'};

/**
 * @description Platform accessory reference
 * @private
 */
let _PlatformAccessory;
/**
 * @description Reference to the NodeJS Homekit Applicaiton Platform.
 * @private
 */
let _hap;

/**
 * @description Service identification information
 * @readonly
 * @private
 * @enum {object}
 * @property {object} CONTROL - Service information for the control switch for each trigger.
 * @property {string} CONTROL.uuid - Unique identifier for the control switch.
 * @property {string} CONTROL.name - Name of the control switch.
 * @property {string} CONTROL.udst - User defined subtype for the control switch.
 * @property {object} MOTION - Service information for the motion status for each trigger.
 * @property {string} MOTION.uuid - Unique identifier for the motion status.
 * @property {string} MOTION.name - Name of the motion status.
 * @property {string} MOTION.udst - User defined subtype for the motion status.
 */
const SERVICE_INFO = {
    CONTROL         : {uuid:`4A11F55C B51E 4E17 9170 7295B0731F67`, legacy_uuid:`4A11F55C-B51E_4E17_9170_7295B0731F67`, name:`Trigger Control`,      udst:`TriggerControl`},
    MOTION          : {uuid:`26BCECB8 5477 4198 83F0 768F79CC2951`, legacy_uuid:`26BCECB8-5477-4198-83F0-768F79CC2951`, name:`Motion Status`,        udst:`MotionStatus`},
    /* The TimeInformation Service was previously depricated/not supported by the Home app. Not with Homebridge v2, this service has been removed.*/
    /* This entry is left intact for the legacy UUID so that clean-up can be performed upon upgrade.                                              */
    DUE_TIME        : {uuid:`OBSOLETE`,                             legacy_uuid:`f9ff7190-4bba-4079-86ce-acca24870921`, name:`Due Time`,             udst:`DueTime`},
    TIME_REMAINING  : {uuid:`168456ad 4f4b 45ac 8299 aaa080cb13cc`, legacy_uuid:`168456ad-4f4b-45ac-8299-aaa080cb13cc`, name:`Time Remainng Status`, udst:`TimeRemainingStatus`},
};

/**
 * @description Homebridge platform for managing the Triggers
 */
class TimeTriggerPlatform {
    /**
     * @description Constructor
     * @param {object} log - Regerence to the log for logging in the Homebridge Context
     * @param {object} config - Reference to the platform configuration (from config.json)
     * @param {object} api - Reference to the Homebridge API
     * @throws {TypeError} - thrown if the configuration is invalid.
     */
    constructor(log, config, api) {
        /* Cache the arguments. */
        this._log     = log;
        this._config  = config;
        this._api     = api;

        /* My local data */
        this._name = this._config.name;

        /* Bind Handlers */
        this._bindDoInitialization          = this._doInitialization.bind(this);
        this._bindDestructorNormal          = this._destructor.bind(this, {cleanup: true});
        this._bindDestructorAbnormal        = this._destructor.bind(this, {exit: true});
        this._CB_TriggerStateChanged        = this._handleTriggerStateChanged.bind(this);
        this._CB_TriggerStateNotify         = this._handleTriggerStateNotify.bind(this);

        /* Log our creation */
        this._log('Creating TimeTriggerPlatform');

        /* Create an empty map for our accessories and time triggers */
        this._triggers = new Map();

        // Create triggers based upon the configuration.
        if (_is.not.undefined(config) &&
            _is.not.undefined(config.settings) && _is.not.undefined(config.settings.triggers) &&
            _is.array(config.settings.triggers)) {
            this._configValid = true;
            config.settings.triggers.forEach((triggerSettings, index) => {
                // Get the identifier.
                let identifier = null;
                if (_is.not.undefined(triggerSettings.trigger_identifier) &&
                    _is.string(triggerSettings.trigger_identifier) && (triggerSettings.trigger_identifier.length > 0) ) {
                    identifier = triggerSettings.trigger_identifier;
                }

                // Determine if this trigger identifier is already in use.
                let identifierUsed = false;
                this._triggers.forEach((item, index) => {
                    if (_is.existy(item.trigger) &&
                        _is.equal(item.trigger.Signature, identifier)) {
                        identifierUsed = true;
                    }
                });

                // Is the identifier free?
                if (!identifierUsed) {
                    // Get the trigger type.
                    let triggerType = -1;
                    if (_is.not.undefined(triggerSettings.trigger_type) && _is.number(triggerSettings.trigger_type) &&
                        (Object.values(TRIGGER_TYPES).indexOf(triggerSettings.trigger_type) >= 0)) {
                        triggerType = triggerSettings.trigger_type;
                    }
                    else {
                        throw new TypeError(`Configuration is invalid. Trigger Type.`);
                    }

                    // Build the trigger configuration.
                    const triggerConfig = {signature: identifier};

                    // Common trigger configuration data.
                    triggerConfig.trip_limit = 0;
                    if (_is.not.undefined(triggerSettings.trip_limit) &&
                        _is.number(triggerSettings.trip_limit) && _is.positive(triggerSettings.trip_limit)) {
                        triggerConfig.trip_limit = triggerSettings.trip_limit;
                    }

                    triggerConfig.duration = {nominal: 250, tolerance: 0};
                    if (_is.not.undefined(triggerSettings.duration) && _is.object(triggerSettings.duration)) {
                        // Duration - Nominal
                        if (_is.not.undefined(triggerSettings.duration.nominal) && _is.number(triggerSettings.duration.nominal) &&
                            _is.positive(triggerSettings.duration.nominal) &&_is.above(triggerSettings.duration.nominal, 249)) {
                            // Set the nominal
                            triggerConfig.duration.nominal = triggerSettings.duration.nominal;
                        }
                        // Duration - Tolerance
                        if (_is.not.undefined(triggerSettings.duration.tolerance) && _is.number(triggerSettings.duration.tolerance) &&
                            _is.not.negative(triggerSettings.duration.tolerance)) {
                            // Set the maximum
                            triggerConfig.duration.tolerance = triggerSettings.duration.tolerance;
                        }
                    }
                    // Trigger type specific configuration.
                    switch (triggerType) {
                        case TRIGGER_TYPES.TimedTrigger: {
                            // Extract the appropriate configuration.
                            triggerConfig.timeout = {nominal: 60000, tolerance: 0};
                            if (_is.not.undefined(triggerSettings.timeout) && _is.object(triggerSettings.timeout)) {
                                // Timeout - Nominal
                                if (_is.not.undefined(triggerSettings.timeout.nominal) && _is.number(triggerSettings.timeout.nominal) &&
                                    _is.positive(triggerSettings.timeout.nominal)) {
                                    // Set the nominal
                                    triggerConfig.timeout.nominal = triggerSettings.timeout.nominal;
                                }
                                // Timeout - Tolerance
                                if (_is.not.undefined(triggerSettings.timeout.tolerance) && _is.number(triggerSettings.timeout.tolerance) &&
                                    _is.not.negative(triggerSettings.timeout.tolerance)) {
                                    // Set the tolerance
                                    triggerConfig.timeout.tolerance = triggerSettings.timeout.tolerance;
                                }
                            }
                        }
                        // eslint-disable-next-line indent
                        break;

                        case TRIGGER_TYPES.ScheduledTrigger: {
                            // Extract the appropriate configuration.
                            // Days that the trigger should trip.
                            triggerConfig.days = TRIGGER_DAYS.AllDays;
                            if (_is.not.undefined(triggerSettings.days) && _is.number(triggerSettings.days) &&
                                (Object.values(TRIGGER_DAYS).indexOf(triggerSettings.days) >= 0)) {
                                triggerConfig.days = triggerSettings.days;
                            }
                            // Astronomical Type
                            if (_is.not.undefined(triggerSettings.is_astronomical) && _is.boolean(triggerSettings.is_astronomical)) {
                                if (_is.not.undefined(triggerSettings.astronomical_type) && _is.string(triggerSettings.astronomical_type)) {
                                    // Only set the astronomical type if this is an astronomical trigger.
                                    if (triggerSettings.is_astronomical) {
                                        triggerConfig.astronomical_type = triggerSettings.astronomical_type;
                                    }
                                }
                            }
                            // Astronomical Location
                            if (_is.not.undefined(triggerConfig.astronomical_type)) {
                                triggerConfig.location = {latitude: 0, longitude: 0};
                                if (_is.not.undefined(triggerSettings.location)) {
                                    // Validate the location
                                    const locValidator = new AstronomicalDataAPI();
                                    const validLoc = locValidator.IsLocationValid(triggerSettings.location);
                                    if (_is.not.truthy(validLoc)) {
                                        // Invalid location.
                                        throw new Error(`Invalid location`);
                                    }

                                    triggerConfig.location = triggerSettings.location;
                                }
                            }

                            // Trigger nominal/default time
                            triggerConfig.time = {nominal: {hour: 12, minute: 0}, tolerance: {hour: 0, minute: 0}};

                            // Astronomical Type
                            if (_is.not.undefined(triggerConfig.astronomical_type)) {
                                triggerConfig.time.astronomical_offset = {type: TIME_OFFSET_TYPES.TYPE_NONE, hour: 0, minute: 0, tolerance: {hour: 0, minute: 0}};
                            }
                            if (_is.not.undefined(triggerSettings.time) && _is.object(triggerSettings.time)) {
                                // Time Window - Astronomical Offset
                                if (_is.not.undefined(triggerConfig.astronomical_type) &&
                                    _is.not.undefined(triggerSettings.time.astronomical_offset) && _is.object(triggerSettings.time.astronomical_offset)) {
                                    // Type
                                    if (_is.not.undefined(triggerSettings.time.astronomical_offset.type) && _is.string(triggerSettings.time.astronomical_offset.type)) {
                                        // Set the type
                                        triggerConfig.time.astronomical_offset.type = triggerSettings.time.astronomical_offset.type;
                                    }
                                    // Hour
                                    if (_is.not.undefined(triggerSettings.time.astronomical_offset.hour) && _is.number(triggerSettings.time.astronomical_offset.hour) &&
                                        _is.within(triggerSettings.time.astronomical_offset.hour, -1, 6)) {
                                        // Set the astronomical offset hour
                                        triggerConfig.time.astronomical_offset.hour = triggerSettings.time.astronomical_offset.hour;
                                    }
                                    // Minute
                                    if (_is.not.undefined(triggerSettings.time.astronomical_offset.minute) && _is.number(triggerSettings.time.astronomical_offset.minute) &&
                                        _is.within(triggerSettings.time.astronomical_offset.minute, -1, 60)) {
                                        // Set the astronomical offset minute
                                        triggerConfig.time.astronomical_offset.minute = triggerSettings.time.astronomical_offset.minute;
                                    }
                                }
                                // Time Window - Nominal
                                if (_is.not.undefined(triggerSettings.time.nominal) && _is.object(triggerSettings.time.nominal)) {
                                    // Hour
                                    if (_is.not.undefined(triggerSettings.time.nominal.hour) && _is.number(triggerSettings.time.nominal.hour) &&
                                        _is.within(triggerSettings.time.nominal.hour, -1, 24)) {
                                        // Set the nominal
                                        triggerConfig.time.nominal.hour = triggerSettings.time.nominal.hour;
                                    }
                                    // Minute
                                    if (_is.not.undefined(triggerSettings.time.nominal.minute) && _is.number(triggerSettings.time.nominal.minute) &&
                                        _is.within(triggerSettings.time.nominal.minute, -1, 60)) {
                                        // Set the nominal
                                        triggerConfig.time.nominal.minute = triggerSettings.time.nominal.minute;
                                    }
                                }
                                // Time Window - Tolerance
                                if (_is.not.undefined(triggerSettings.time.tolerance) && _is.object(triggerSettings.time.tolerance)) {
                                    // Hour
                                    if (_is.not.undefined(triggerSettings.time.tolerance.hour) && _is.number(triggerSettings.time.tolerance.hour) &&
                                        _is.within(triggerSettings.time.tolerance.hour, -1, 24)) {
                                        // Update the tolerance
                                        triggerConfig.time.tolerance.hour = triggerSettings.time.tolerance.hour;
                                    }
                                    // Minute
                                    if (_is.not.undefined(triggerSettings.time.tolerance.minute) && _is.number(triggerSettings.time.tolerance.minute) &&
                                        _is.within(triggerSettings.time.tolerance.minute, -1, 60)) {
                                        // Update the tolerance
                                        triggerConfig.time.tolerance.minute = triggerSettings.time.tolerance.minute;
                                    }
                                }
                            }
                        }
                        // eslint-disable-next-line indent
                        break;

                        default: {
                            throw new TypeError(`Configuration is invalid. Unknown TriggerType. ${triggerSettings.trigger_type}`);
                        }
                        // eslint-disable-next-line indent
                        break;
                    }

                    // Construct the appropriate trigger with the configuration.
                    let trigger = null;
                    switch (triggerType) {
                        case TRIGGER_TYPES.TimedTrigger: {
                            trigger = new TimeTrigger(triggerConfig);
                        }
                        // eslint-disable-next-line indent
                        break;

                        case TRIGGER_TYPES.ScheduledTrigger: {
                            trigger = new ScheduledTrigger(triggerConfig);
                        }
                        // eslint-disable-next-line indent
                        break;

                        default: {
                            throw new TypeError(`Configuration is invalid. Unknown TriggerType. ${triggerSettings.trigger_type}`);
                        }
                        // eslint-disable-next-line indent
                        break;
                    }
                    // Append the trigget to our map, keyed off the unique identification.
                    this._triggers.set(trigger.Identifier, {accessory: null, trigger: trigger});
                }
                else {
                    this.log(`Trigger Identifier '${identifier}' already used.`);
                }
            });
        }
        else {
            this._log('Configuration is invalid.');
            this._configValid = false;
        }

        // Register for the Did Finish Launching event
        this._api.on('didFinishLaunching', this._bindDoInitialization);
        this._api.on('shutdown', this._bindDestructorNormal);

        // Register for shutdown events.
        // do something when app is closing
        process.on('exit', this._bindDestructorNormal);
        // catches uncaught exceptions
        process.on('uncaughtException', this._bindDestructorAbnormal);
    }

    /**
     * @description Destructor
     * @param {object} options - Typically containing a "cleanup" or "exit" member.
     * @param {object} err - The source of the event trigger.
     * @returns {void}
     * @async
     * @private
     */
    async _destructor(options, err) {
        // Is there an indication that the system is either exiting or needs to
        // be cleaned up?
        if ((options.exit) || (options.cleanup)) {
            this._log.debug('Terminating the triggers.');
            this._log.debug(err);
            // Iterate over all of the triggers and unsubscribe from the events.
            this._triggers.forEach((value, key)=>{
                if (_is.object(value) &&
                    (_is.not.undefined(value.trigger) && (value.trigger instanceof TimeTrigger))) {
                    this._log.debug(`Unsubscribing trigger: '${value.trigger.Identifier}'`);
                    value.trigger.off(TRIGGER_EVENTS.EVENT_STATE_CHANGED, this._CB_TriggerStateChanged);
                    value.trigger.off(TRIGGER_EVENTS.EVENT_STATE_NOTIFY, this._CB_TriggerStateNotify);
                }
            });
        }
        // Lastly eliminate myself.
        delete this;
    }

    /**
     * @description Event handler when the system has loaded the platform.
     * @returns {void}
     * @throws {RangeError} - thrown if an orphaned accessory is found after purge.
     * @async
     * @private
     */
    async _doInitialization() {
        this._log(`Homebridge Plug-In ${_PackageInfo.CONFIG_INFO.platform} has finished launching.`);

        if (_is.falsy(this._configValid)) {
            this._log("Skipping initialization due to invaid configuration.");
            return;
        }

        // Body -------

        // Flush any accessories as needed.
        const accessoriesToRemove = [];
        for (const item of this._triggers.values()) {
            let removed = false;
            if (_is.not.undefined(item.accessory) &&
                (item.accessory instanceof _PlatformAccessory)) {
                const accessory = item.accessory;
                // Check if the accessory version cannot be managed
                if (!this._managePluginVersion(accessory)) {
                    // This accessory needs to be replaced.
                    accessoriesToRemove.push(accessory);
                    removed = true;
                }
                // If this accessory was not previously removed, does the accessory have a matching trigger?
                if (!removed) {
                    this._log.debug(`Checking to see if accessory '${accessory.displayName}' is an orphan.`);
                    let isOrphan = true;
                    for (const item of this._triggers.values()) {
                        if (Object.prototype.hasOwnProperty.call(accessory.context, 'ID') &&
                            _is.existy(item.trigger) &&
                            (item.trigger instanceof TimeTrigger) &&
                            (_is.equal(item.trigger.Signature, accessory.context.ID))) {
                            // Accessory is not an orphan.
                            isOrphan = false;
                            break;
                        }
                    }
                    if (isOrphan) {
                        this._log.debug(`Accessory '${accessory.displayName}' is an orphan and should be purged.`);
                        // This accessory needs to be removed.
                        accessoriesToRemove.push(accessory);
                    }
                }
            }
        }
        // Perform the cleanup.
        accessoriesToRemove.forEach((accessory) => {
            this._removeAccessory(accessory);
        });

        // Manage the accessories and triggers.
        for (const item of this._triggers.values()) {
            let accessory = item.accessory;
            if (_is.existy(item.trigger) &&
                (item.trigger instanceof TimeTrigger)) {
                // Is this trigger new?
                if (_is.not.existy(accessory)) {
                    // There is no matching accessory for this trigger.
                    // Create and register an accessory.
                    accessory = this._addTriggerAccessory(item.trigger.Identifier);
                }

                // Register for the trigger events.
                item.trigger.on(TRIGGER_EVENTS.EVENT_STATE_CHANGED, this._CB_TriggerStateChanged);
                item.trigger.on(TRIGGER_EVENTS.EVENT_STATE_NOTIFY,  this._CB_TriggerStateNotify);

                // Is the accessory active?
                if (this._getAccessorySwitchState(accessory)) {
                    // Start the Trigger.
                    item.trigger.Start();
                }
            }
            else if (_is.existy(accessory)) {
                this._log.debug(`Unexpected orphaned trigger '${accessory.displayName}.`);
                throw new RangeError(`Orphaned accessory is found.`);
            }
        }
    }

    /**
     * @description Homebridge API invoked after restoring cached accessorues from disk.
     * @param {_PlatformAccessory} accessory - Accessory to be configured.
     * @returns {void}
     * @throws {TypeError} - thrown if 'accessory' is not a PlatformAccessory
     */
    configureAccessory(accessory) {
        if (_is.falsy(this._configValid)){
            this._log("Skipping accessory configuration due to invaid configuration.");
            return;
        }
        // Body -----
        
        // Validate the argument(s)
        if ((_is.undefined(accessory)) ||
            (!(accessory instanceof _PlatformAccessory))) {
            throw new TypeError('accessory must be a PlatformAccessory');
        }

        // Is this accessory already registered?
        let found = false;
        for (const item of this._triggers.values()) {
            if (_is.not.undefined(item.accessory) &&
                (item.accessory instanceof _PlatformAccessory)) {
                if (item.accessory === accessory) {
                    found = true;
                    break;
                }
            }
        }
        if (!found) {
            // Configure the accessory (also registers it.)
            try {
                this._configureAccessory(accessory, false);
            }
            catch (error) {
                this._log.debug(`Unable to configure accessory '${accessory.displayName}'. Version:${accessory.context.VERSION}. Error:${error}`);
                // Store the acessory without a trigger.
                this._triggers.set(accessory.contect.ID, {accessory: accessory, trigger: null});
            }
        }
    }

    /**
     * @description Performs accessory configuration and internal 'registration' (appending to our list).
     *              Opportunity to setup event handlers for characteristics and update values (as needed).
     * @param {_PlatformAccessory} accessory - Accessory to be configured/registered
     * @param {boolean} reconfig - flag indicating if this is a re-configuration
     * @returns {void}
     * @throws {TypeError} - thrown if 'accessory' is not a PlatformAccessory
     * @private
     */
    _configureAccessory(accessory, reconfig) {
        if ((_is.undefined(accessory)) ||
            (!(accessory instanceof _PlatformAccessory))) {
            throw new TypeError(`accessory must be a PlatformAccessory`);
        }

        this._log.debug(`Configuring accessory '${accessory.displayName}'.`);

        // Get the accessory identifier from the context.
        const id = accessory.context.ID;

        if (!reconfig) {
            // Register to handle the Identify request for the accessory.
            accessory.on(_PlatformAccessory.PlatformAccessoryEvent.IDENTIFY, () => {
                this._log('%s identified!', accessory.displayName);
            });
        }

        // Does this accessory have a Switch service?
        let switchState = true;
        const serviceSwitch = accessory.getService(_hap.Service.Switch);
        if (_is.existy(serviceSwitch) &&
            (serviceSwitch instanceof _hap.Service.Switch)) {
            // Set the switch to the stored setting (the default is on).
            const theSettings = accessory.context.SETTINGS;
            if ((theSettings !== undefined) &&
                _is.object(theSettings) &&
                (Object.prototype.hasOwnProperty.call(theSettings, 'SwitchState') &&
                _is.boolean(theSettings.SwitchState))) {
                // Modify the settings
                switchState = theSettings.SwitchState;
            }
            serviceSwitch.updateCharacteristic(_hap.Characteristic.On, switchState);
            if (!reconfig) {
                // Get the 'On' characteristic for the switch.
                const charOn = serviceSwitch.getCharacteristic(_hap.Characteristic.On);
                // (re)Register for the "get" event notification.
                charOn.off('get', this._handleOnGet.bind(this, id));
                charOn.on('get',  this._handleOnGet.bind(this, id));
                // (re)Register for the "set" event notification.
                charOn.off('set', this._handleOnSet.bind(this, id));
                charOn.on('set',  this._handleOnSet.bind(this, id));
            }
        }

        // Does the accessory have a LightSensor service.
        const serviceLightSensor = accessory.getService(_hap.Service.LightSensor);
        if (_is.existy(serviceLightSensor) &&
            (serviceLightSensor instanceof _hap.Service.LightSensor)) {
            // Get the 'Current Ambient Light Level' characteristic for the light sensor.
            const charLightLevel = serviceLightSensor.getCharacteristic(_hap.Characteristic.CurrentAmbientLightLevel);
            // (re)Register for the "get" event notification.
            charLightLevel.off('get', this._handleCurrentAmbientLightLevelGet.bind(this, id));
            charLightLevel.on('get',  this._handleCurrentAmbientLightLevelGet.bind(this, id));
        }

        // Update the names of each service.
        const infoItems = [SERVICE_INFO.CONTROL, SERVICE_INFO.MOTION, SERVICE_INFO.TIME_REMAINING];
        for (const item of infoItems) {
            const service = accessory.getServiceById(item.uuid, item.udst);
            if (_is.existy(service)) {
                service.updateCharacteristic(_hap.Characteristic.Name, `${item.name}-(${accessory.displayName})`);
            }
        }

        // Initialize the motion sensor.
        try {
            const today = new Date();
            this._log.debug(`Updating services. Accessory(${accessory.displayName})`);
            this._updateMotionSensorService(accessory, SERVICE_INFO.MOTION, {active: switchState, motion: false});
            this._updateLightSensorService(accessory, SERVICE_INFO.TIME_REMAINING, {active: switchState, lightlevel: 0.0001});

            // Update the accessory information
            this._updateAccessoryInfo(accessory, {model: 'GrumpTech Time-Based Triggers', serialnum: id});
            this._updateNextTripInfo(accessory,  {due_time: 'Unknown', due_dow: (today.getDay() + 1)});

            // Find the unused trigger with the matching id.
            let found = false;
            this._triggers.forEach((item, index) => {
                if (_is.existy(item.trigger) && (item.trigger instanceof TimeTrigger) &&
                    _is.equal(item.trigger.Signature, id) &&
                    (_is.not.existy(item.accessory) || (reconfig))) {
                    // Match found.
                    found = true;
                    // Register the accessory.
                    this._log.debug(`Adding accessory '${accessory.displayName}' to the triggers list. Count:${this._triggers.size}`);
                    item.accessory = accessory;
                }
            });
            if (!found) {
                // Accessory appears to be an orphan.
                this._log.debug(`Adding ORPHAN accessory '${accessory.displayName}' to the triggers list. Count:${this._triggers.size}`);
                this._triggers.set(id, {accessory: accessory, trigger: null});
            }
        }
        catch (error) {
            this._log.debug(`Error configuring accessory '${accessory.displayName}'. Error:'${error}'`);
        }
    }

    /**
     * @description - Manage plugin version upgrading if possible
     * @param {_PlatformAccessory} accessory - Accessory to be configured.
     * @returns {boolean} - false if the accessory version cannot me managed. true, otherwise.
     * @throws {TypeError} - thrown if 'accessory' is not a PlatformAccessory
     * @private
     */
    _managePluginVersion(accessory) {
        // Validate the argument(s)
        if ((_is.undefined(accessory)) ||
            (!(accessory instanceof _PlatformAccessory))) {
            throw new TypeError('accessory must be a PlatformAccessory');
        }
        let versionManaged = false;

        // Accessory is from a prior version and needs to be replaced.
        if (Object.prototype.hasOwnProperty.call(accessory.context, 'VERSION') &&
            _is.number(accessory.context.VERSION)) {
            // Compute the version delta.
            const versionDelta = ACCESSORY_VERSION - accessory.context.VERSION;

            if (_is.not.negative(versionDelta)) {
                let needsReconfiguration = false;

                // At current version
                if (_is.equal(accessory.context.VERSION, ACCESSORY_VERSION)) {
                    // Update the flag
                    versionManaged = true;
                }
                // Upgrading...
                else if (_is.above(ACCESSORY_VERSION, accessory.context.VERSION)) {
                    this._log.debug(`Accessory '${accessory.displayName}' upgrading from v${accessory.context.VERSION} to v${ACCESSORY_VERSION}`);

                    // Upgrading accessories prior to v1.1
                    if (_is.under(accessory.context.VERSION, 1.1)) {
                        this._log.debug(`Accessory '${accessory.displayName}' performing v${accessory.context.VERSION}...v1.1 upgrade.`);

                        // Append the services added since v1.0
                        // Added in v1.1
                        accessory.addService(_hap.Service.LightSensor,     SERVICE_INFO.TIME_REMAINING.uuid, SERVICE_INFO.TIME_REMAINING.udst);
                    }

                    // Upgrading accessories prior to v2.0
                    if (_is.under(accessory.context.VERSION, 2.0)) {
                        this._log.debug(`Accessory '${accessory.displayName}' performing v${accessory.context.VERSION}...v2.0 upgrade.`);

                        // Rename existing services to be HB v2 compliant.
                        const accessorySwitchService = accessory.getServiceById(SERVICE_INFO.CONTROL.legacy_uuid, SERVICE_INFO.CONTROL.udst);
                        if (accessorySwitchService !== undefined) {
                            this._log.debug(`Service '${accessorySwitchService.displayName}' updating to ${SERVICE_INFO.CONTROL.uuid}`);
                            accessorySwitchService.displayName = SERVICE_INFO.CONTROL.uuid;
                        }
                        const accessoryMotionService = accessory.getServiceById(SERVICE_INFO.MOTION.legacy_uuid, SERVICE_INFO.MOTION.udst);
                        if (accessoryMotionService !== undefined) {
                            this._log.debug(`Service '${accessoryMotionService.displayName}' updating to ${SERVICE_INFO.MOTION.uuid}`);
                            accessoryMotionService.displayName = SERVICE_INFO.MOTION.uuid;
                        }
                        const accessoryLightService = accessory.getServiceById(SERVICE_INFO.TIME_REMAINING.legacy_uuid, SERVICE_INFO.TIME_REMAINING.udst);
                        if (accessoryLightService !== undefined) {
                            this._log.debug(`Service '${accessoryLightService.displayName}' updating to ${SERVICE_INFO.TIME_REMAINING.uuid}`);
                            accessoryLightService.displayName = SERVICE_INFO.TIME_REMAINING.uuid;
                        }
                        // Remove unsupported services, if present.
                        const accessoryTimeService = accessory.getServiceById(SERVICE_INFO.DUE_TIME.legacy_uuid, SERVICE_INFO.DUE_TIME.udst);
                        if (accessoryTimeService !== undefined) {
                            this._log.debug(`Removing Service '${accessoryTimeService.displayName}'`);
                            // TimeIndormation service has been depricated and removed in Homebridge v2
                            accessory.removeService(accessoryTimeService);
                        }
                    }

                    // Update the version of the accessory. This is used for depersistence
                    accessory.context.VERSION = ACCESSORY_VERSION;

                    // Flag for re-configuration
                    needsReconfiguration = true;

                    // Update the flag
                    versionManaged = true;
                }
                // Downgrading...
                else {
                    this._log.debug(`Accessory '${accessory.displayName}' downgrade is not unsupported Current:v${accessory.context.VERSION} PLUGIN:v${ACCESSORY_VERSION}`);
                }

                // Reconfigure if needed.
                if (versionManaged && needsReconfiguration) {
                    this._log.debug(`Reconfiguring accessory '${accessory.displayName}'`);
                    this._configureAccessory(accessory, true);
                }
            }
            else {
                this._log.debug(`Accessory '${accessory.displayName}' downgrading from v${accessory.context.VERSION} to v${ACCESSORY_VERSION}`);
            }
        }
        else {
            this._log.debug(`Invalid accessory version for '${accessory.displayName}'. Version:'${accessory.context.VERSION}'`);
        }

        return versionManaged;
    }

    /**
     * @description Create and register an accessory for the trigger.
     * @param {string} id identifier for the trigger.
     * @returns {_PlatformAccessory} accessory added.
     * @throws {TypeError} - thrown when 'id' is not a string.
     * @throws {RangeError} - thrown when 'id' length is 0
     * @throws {Error} - thrown when an accessory with 'id' is already registered.
     * @throws {Error} - thrown when there is no matching trigger.
     * @private
     */
    _addTriggerAccessory(id) {
        // Validate arguments
        if (_is.not.string(id)) {
            throw new TypeError(`id must be a string`);
        }
        if (id.length <= 0) {
            throw new RangeError(`id must be a non-zero length string.`);
        }

        const candidate = this._triggers.get(id);
        if (_is.not.existy(candidate)) {
            throw new Error(`'${id}' is not registered at all.`);
        }
        if (_is.not.existy(candidate.trigger)) {
            throw new Error(`Trigger Accessory '${id}' has no trigger.`);
        }
        if (_is.existy(candidate.accessory)) {
            throw new Error(`Trigger Accessory '${id}' is already registered.`);
        }

        // Get the trigger associated with this 'id'.
        if (_is.not.existy(candidate) || _is.not.existy(candidate.trigger)) {
            throw new Error(`Trigger Accessory '${id}' has no trigger.`);
        }

        this._log.debug(`Adding new accessory: id:'${id}'`);

        // Create the platform accesory
        // uuid must be generated from a unique but not changing data source.
        const uuid = _hap.uuid.generate(candidate.trigger.Signature);

        const accessory = new _PlatformAccessory(candidate.trigger.Name, uuid);

        // Add the identifier to the accessory's context. Used for remapping on depersistence.
        accessory.context.ID = candidate.trigger.Signature;
        // Mark the version of the accessory. This is used for depersistence
        accessory.context.VERSION = ACCESSORY_VERSION;
        // Create accessory persisted settings
        accessory.context.SETTINGS = {SwitchState: true};

        // Create our services.
        accessory.addService(_hap.Service.Switch,          SERVICE_INFO.CONTROL.uuid,        SERVICE_INFO.CONTROL.udst);
        accessory.addService(_hap.Service.MotionSensor,    SERVICE_INFO.MOTION.uuid,         SERVICE_INFO.MOTION.udst);
        accessory.addService(_hap.Service.LightSensor,     SERVICE_INFO.TIME_REMAINING.uuid, SERVICE_INFO.TIME_REMAINING.udst);

        try {
            // Configure the accessory
            this._configureAccessory(accessory, false);
        }
        catch (error) {
            this._log.debug(`Error when configuring accessory.`);
            this._log.debug(error);
        }

        this._api.registerPlatformAccessories(_PackageInfo.CONFIG_INFO.plugin, _PackageInfo.CONFIG_INFO.platform, [accessory]);

        return accessory;
    }

    /**
     * @description Remove/destroy an accessory
     * @param {_PlatformAccessory} accessory - accessory to be removed.
     * @returns {void}
     * @throws {TypeError} - Thrown when 'accessory' is not an instance of _PlatformAccessory.
     * @throws {RangeError} - Thrown when a 'accessory' is not registered.
     * @private
     */
    _removeAccessory(accessory) {
        // Validate arguments
        if ((accessory === undefined) || !(accessory instanceof _PlatformAccessory)) {
            throw new TypeError(`Accessory must be a PlatformAccessory`);
        }
        let found = false;
        this._triggers.forEach((item, index) => {
            if (!found && (item.accessory === accessory)) {
                found = true;
            }
        });
        if (!found) {
            throw new RangeError(`Accessory '${accessory.displayName}' is not registered.`);
        }

        this._log.debug(`Removing accessory '${accessory.displayName}'`);

        // Get the accessory identifier from the context.
        const id = accessory.context.ID;

        // Event Handler cleanup.
        accessory.removeAllListeners(_PlatformAccessory.PlatformAccessoryEvent.IDENTIFY);
        // Iterate through all the services on the accessory
        for (const service of accessory.services) {
            // Is this service a Switch?
            if (service instanceof _hap.Service.Switch) {
                // Get the On characteristic.
                const charOn = service.getCharacteristic(_hap.Characteristic.On);
                // Unregister for the "get" event notification.
                charOn.off('get', this._handleOnGet.bind(this, {accessory: accessory, service_id: id}));
                // Unregister for the "get" event notification.
                charOn.off('set', this._handleOnSet.bind(this, {accessory: accessory, service_id: id}));
            }
            else if (service instanceof _hap.Service.LightSensor) {
                // Get the 'Current Ambient Light Level' characteristic for the light sensor.
                const charLightLevel = service.getCharacteristic(_hap.Characteristic.CurrentAmbientLightLevel);
                // Unregister for the "get" event notification.
                charLightLevel.off('get', this._handleCurrentAmbientLightLevelGet.bind(this, id));
            }
        }

        /* Unregister the accessory */
        this._api.unregisterPlatformAccessories(_PackageInfo.CONFIG_INFO.plugin, _PackageInfo.CONFIG_INFO.platform, [accessory]);
        /* remove the accessory from our mapping */
        found = false;
        this._triggers.forEach((item, index) => {
            if (!found && (item.accessory === accessory)) {
                item.accessory = null;
                found = true;
            }
        });
    }

    /**
     * @description Update common information for an accessory
     * @param {_PlatformAccessory} accessory - accessory to be updated.
     * @param {object} info - accessory information.
     * @param {string | Error} info.model - accessory model number
     * @param {string | Error} info.serialnum - accessory serial number.
     * @returns {void}
     * @throws {TypeError} - Thrown when 'accessory' is not an instance of _PlatformAccessory.
     * @throws {TypeError} - Thrown when 'info' is not undefined, does not have the 'model' or
     *                       'serialnum' properties or the properties are not of the expected type.
     * @private
     */
    _updateAccessoryInfo(accessory, info) {
        // Validate arguments
        if ((accessory === undefined) || !(accessory instanceof _PlatformAccessory)) {
            throw new TypeError('Accessory must be a PlatformAccessory');
        }
        if ((info === undefined) ||
            (!Object.prototype.hasOwnProperty.call(info, 'model'))     || ((typeof(info.model)      !== 'string') || (info.model instanceof Error)) ||
            (!Object.prototype.hasOwnProperty.call(info, 'serialnum')) || ((typeof(info.serialnum)  !== 'string') || (info.serialnum instanceof Error))) {
            throw new TypeError('info must be an object with properties named \'model\' and \'serialnum\' that are either strings or Error');
        }

        /* Get the accessory info service. */
        const accessoryInfoService = accessory.getService(_hap.Service.AccessoryInformation);
        if (accessoryInfoService !== undefined) {
            /* Manufacturer */
            accessoryInfoService.updateCharacteristic(_hap.Characteristic.Manufacturer, 'GrumpTech');

            /* Model */
            accessoryInfoService.updateCharacteristic(_hap.Characteristic.Model, info.model);

            /* Serial Number */
            accessoryInfoService.updateCharacteristic(_hap.Characteristic.SerialNumber, info.serialnum);

            /* Software Revision */
            accessoryInfoService.updateCharacteristic(_hap.Characteristic.SoftwareRevision, `${ACCESSORY_VERSION}`);

            /* Firmware/Plugin Revision */
            accessoryInfoService.updateCharacteristic(_hap.Characteristic.FirmwareRevision, `${_PackageInfo.PLUGIN_VER}`);
        }
    }

    /**
     * @description Internal function to perform accessory configuration for Motion Sensor services.
     * @param {_PlatformAccessory} accessory - Accessory to be configured.
     * @param {object} serviceInfo - Name information of the service to be configured.
     * @param {string} serviceInfo.uuid   - UUID of the service
     * @param {string} serviceInfo.name   - Name of the service.
     * @param {string} serviceInfo.udst   - User Defined Sub-Type of the service.
     * @param {object} values             - Object containing the values being set
     * @param {boolean | Error} values.active      - true if active.
     * @param {boolean | Error} values.motion      - true if a motion is detected
     * @returns {void}
     * @throws {TypeError} - thrown if 'accessory' is not a PlatformAccessory
     * @throws {TypeError} - thrown if 'serviceInfo' does not conform to a serviceInfo item.
     * @throws {TypeError} - thrown if 'values' is not an object or does not contain the expected fields.
     * @throws {Error} - thrown if the service for the serviceName is not a Carbon Dioxide Sensor.
     * @private
     */
    _updateMotionSensorService(accessory, serviceInfo, values) {
        if (_is.not.existy(accessory) ||
            (!(accessory instanceof _PlatformAccessory))) {
            throw new TypeError(`accessory must be a PlatformAccessory`);
        }
        if (_is.not.existy(serviceInfo) ||
            _is.not.object(serviceInfo) ||
            (!Object.prototype.hasOwnProperty.call(serviceInfo, 'uuid') || _is.not.string(serviceInfo.uuid) || (serviceInfo.uuid.length <= 0)) ||
            (!Object.prototype.hasOwnProperty.call(serviceInfo, 'name') || _is.not.string(serviceInfo.name) || (serviceInfo.name.length <= 0)) ||
            (!Object.prototype.hasOwnProperty.call(serviceInfo, 'udst') || _is.not.string(serviceInfo.udst) || (serviceInfo.udst.length <= 0))   ) {
            throw new TypeError(`serviceName does not conform to a SERVICE_INFO item.`);
        }
        if (_is.not.existy(values) || _is.not.object(values) ||
            (!Object.prototype.hasOwnProperty.call(values, 'active')) || (_is.not.boolean(values.active) && !(values.active instanceof Error)) ||
            (!Object.prototype.hasOwnProperty.call(values, 'motion')) || (_is.not.boolean(values.motion) && !(values.motion instanceof Error))   ) {
            throw new TypeError(`values must be an object with properties named 'active' (boolean or Error) and 'motion' (boolean or Error)`);
        }

        // Attempt to get the named service and validate that it is a Carbon Dioxie Sensor
        const serviceMotion = accessory.getServiceById(serviceInfo.uuid, serviceInfo.udst);
        if (_is.existy(serviceMotion) &&
            (serviceMotion instanceof _hap.Service.MotionSensor)) {
            try {
                // Set the characteristics.
                serviceMotion.updateCharacteristic(_hap.Characteristic.StatusActive,     values.active);
                serviceMotion.updateCharacteristic(_hap.Characteristic.MotionDetected,   values.motion);
                serviceMotion.updateCharacteristic(_hap.Characteristic.StatusFault,      _hap.Characteristic.StatusFault.NO_FAULT);
                serviceMotion.updateCharacteristic(_hap.Characteristic.StatusLowBattery, _hap.Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL);
                serviceMotion.updateCharacteristic(_hap.Characteristic.StatusTampered,   _hap.Characteristic.StatusTampered.NOT_TAMPERED);
            }
            catch (err) {
                this._log.debug(`Error setting characteristics for '${accessory.displayName}'. Error: ${err}`);
            }
        }
        else {
            this._log.debug(`No service: Accessory '${accessory.displayName}'`);
        }
    }

    /**
     * @description Internal function to update accessory ServiceInfo data with the next trip information.
     * @param {_PlatformAccessory} accessory - Accessory to be configured.
     * @param {DueTime} values            - Object containing due time
     * @returns {void}
     * @throws {TypeError} - thrown if 'accessory' is not a PlatformAccessory
     * @throws {TypeError} - thrown if 'serviceInfo' does not conform to a serviceInfo item.
     * @throws {TypeError} - thrown if 'values' is not an object or does not contain the expected fields.
     * @throws {Error} - thrown if the service for the serviceName is not a Carbon Dioxide Sensor.
     * @private
     */
    _updateNextTripInfo(accessory, values) {
        if (_is.not.existy(accessory) ||
            (!(accessory instanceof _PlatformAccessory))) {
            throw new TypeError(`accessory must be a PlatformAccessory`);
        }
        if (_is.not.existy(values) || _is.not.object(values) ||
            (!Object.prototype.hasOwnProperty.call(values, 'due_time')) || (_is.not.string(values.due_time) && !(values.due_time instanceof Error)) ||
            (!Object.prototype.hasOwnProperty.call(values, 'due_dow')) || (_is.not.number(values.due_dow) && !(values.due_dow instanceof Error)) ) {
            throw new TypeError(`values must be an object with properties named 'due_time' (string or Error) and 'due_dow' (number or Error)`);
        }

        /* Get the accessory info service. */
        const accessoryInfoService = accessory.getService(_hap.Service.AccessoryInformation);
        if (accessoryInfoService !== undefined) {
            /* Product Data */
            const data = `Next trip: ${values.due_time} on day #${values.due_dow}`;
            accessoryInfoService.updateCharacteristic(_hap.Characteristic.ProductData, data);
        }
    }

    /**
     * @description Internal function to perform accessory configuration for Light Sensor services.
     * @param {_PlatformAccessory} accessory - Accessory to be configured.
     * @param {object} serviceInfo - Name information of the service to be configured.
     * @param {string} serviceInfo.uuid   - UUID of the service
     * @param {string} serviceInfo.name   - Name of the service.
     * @param {string} serviceInfo.udst   - User Defined Sub-Type of the service.
     * @param {object} values             - Object containing the values being set
     * @param {boolean | Error} values.active      - true if active.
     * @param {number | Error}  values.lightlevel  - light level
     * @returns {void}
     * @throws {TypeError} - thrown if 'accessory' is not a PlatformAccessory
     * @throws {TypeError} - thrown if 'serviceInfo' does not conform to a serviceInfo item.
     * @throws {TypeError} - thrown if 'values' is not an object or does not contain the expected fields.
     * @throws {Error} - thrown if the service for the serviceName is not a Carbon Dioxide Sensor.
     * @private
     */
    _updateLightSensorService(accessory, serviceInfo, values) {
        if (_is.not.existy(accessory) ||
            (!(accessory instanceof _PlatformAccessory))) {
            throw new TypeError(`accessory must be a PlatformAccessory`);
        }
        if (_is.not.existy(serviceInfo) ||
            _is.not.object(serviceInfo) ||
            (!Object.prototype.hasOwnProperty.call(serviceInfo, 'uuid') || _is.not.string(serviceInfo.uuid) || (serviceInfo.uuid.length <= 0)) ||
            (!Object.prototype.hasOwnProperty.call(serviceInfo, 'name') || _is.not.string(serviceInfo.name) || (serviceInfo.name.length <= 0)) ||
            (!Object.prototype.hasOwnProperty.call(serviceInfo, 'udst') || _is.not.string(serviceInfo.udst) || (serviceInfo.udst.length <= 0))   ) {
            throw new TypeError(`serviceName does not conform to a SERVICE_INFO item.`);
        }
        if (_is.not.existy(values) || _is.not.object(values) ||
            (!Object.prototype.hasOwnProperty.call(values, 'active')) || (_is.not.boolean(values.active) && !(values.active instanceof Error)) ||
            (!Object.prototype.hasOwnProperty.call(values, 'lightlevel')) || (_is.not.number(values.lightlevel) && !(values.lightlevel instanceof Error)) ) {
            throw new TypeError(`values must be an object with properties named 'lightlevel' (number or Error) and 'motion' (boolean or Error)`);
        }

        // Attempt to get the named service and validate that it is a Carbon Dioxie Sensor
        const serviceLight = accessory.getServiceById(serviceInfo.uuid, serviceInfo.udst);
        if (_is.existy(serviceLight) &&
            (serviceLight instanceof _hap.Service.LightSensor)) {
            try {
                // Set the characteristics.
                serviceLight.updateCharacteristic(_hap.Characteristic.StatusActive,             values.active);
                serviceLight.updateCharacteristic(_hap.Characteristic.CurrentAmbientLightLevel, values.lightlevel);
                serviceLight.updateCharacteristic(_hap.Characteristic.StatusFault,              _hap.Characteristic.StatusFault.NO_FAULT);
                serviceLight.updateCharacteristic(_hap.Characteristic.StatusLowBattery,         _hap.Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL);
                serviceLight.updateCharacteristic(_hap.Characteristic.StatusTampered,           _hap.Characteristic.StatusTampered.NOT_TAMPERED);
            }
            catch (err) {
                this._log.debug(`Error setting characteristics for '${accessory.displayName}'. Error: ${err}`);
            }
        }
        else {
            this._log.debug(`No service: Accessory '${accessory.displayName}'`);
        }
    }

    /**
     * @description Event handler for the "get" event for the Switch.On characteristic.
     * @param {string} id - id of the accessory switch service being querried.
     * @param {Function} callback - Function callback for homebridge.
     * @returns {void}
     * @throws {TypeError} - thrown when 'id' is not a non-zero string.
     * @throws {Error} - Thrown when there is no accessory keyed with 'id'
     * @private
     */
    _handleOnGet(id, callback) {
        // Validate arguments
        if ((id === undefined) ||
            (typeof(id) !== 'string') || (id.length <= 0)) {
            throw new TypeError(`id must be a non-zero length string.`);
        }

        let status = null;
        let result = new Error(`id:${id} has no matching accessory`);
        this._triggers.forEach((item, index) => {
            if (_is.existy(item.accessory) &&
                _is.equal(item.accessory.context.ID, id)) {
                // Get the accessory for this id.
                const accessory = item.accessory;

                // Get the accessory for this id.
                this._log.debug(`Trigger '${accessory.displayName}' Get Request.`);

                try {
                    result = this._getAccessorySwitchState(accessory);
                }
                catch (err) {
                    this._log.debug(`  Unexpected error encountered: ${err.message}`);
                    result = false;
                    status = new Error(`Accessory ${accessory.displayName} is not ressponding.`);
                }
            }
        });

        // Invoke the callback function with our result.
        callback(status, result);
    }

    /**
     * @description Event handler for the "set" event for the Switch.On characteristic.
     * @param {string} id - id of the accessory switch service being set.
     * @param {boolean} value - new/requested state of the switch
     * @param {Function} callback - Function callback for homebridge.
     * @returns {void}
     * @throws {TypeError} - thrown when 'id' is not a non-zero string.
     * @throws {Error} - Thrown when there is no accessory keyed with 'id'
     * @private
     */
    _handleOnSet(id, value, callback) {
        // Validate arguments
        if ((id === undefined) ||
            (typeof(id) !== 'string') || (id.length <= 0)) {
            throw new TypeError(`id must be a non-zero length string.`);
        }

        this._log.debug(`Attempting to set trigger ${id} to ${value}`);
        let status = null;
        this._triggers.forEach((item, index) => {
            if (_is.existy(item.accessory) &&
                _is.equal(item.accessory.context.ID, id)) {
                const accessory = item.accessory;

                // Determine if this event is from a network target accessory.
                const isValidTrigger = _is.existy(item.trigger);

                // Store the state of the switch so that when the plugin is restarted, we will restore the
                // switch state as it was last set. But only do this for Triggers.
                if (isValidTrigger) {
                    const theSettings = accessory.context.SETTINGS;
                    if (_is.existy(theSettings) &&
                        _is.object(theSettings) &&
                        (Object.prototype.hasOwnProperty.call(theSettings, 'SwitchState')) &&
                        _is.boolean(theSettings.SwitchState)) {
                        // Modify the settings
                        theSettings.SwitchState = value;
                    }
                    // Store the updated settings.
                    accessory.context.SETTINGS = theSettings;
                }

                try {
                    // Is there a matching trigger for this 'id'?
                    if (isValidTrigger) {
                        const trigger = item.trigger;

                        // Note: State Change and State Notification events
                        //       will update the active and motion detected characteristics.
                        //       No need to worry about these here.
                        if (value) {
                            // Start the trigger !!
                            trigger.Start();
                        }
                        else {
                            // Note: Even after turning the trigger off, there will be at least one more event coming in.
                            trigger.Stop();
                        }
                    }
                    else {
                        this._log.debug(`Accessory ID:${id} has no matching trigger.`);
                        status =  new Error(`id:${id} has no matching trigger.`);
                    }
                }
                catch (err) {
                    this._log.debug(`Unexpected error encountered: ${err.message}`);

                    status = new Error(`Accessory ${accessory.displayName} is not ressponding.`);
                }
            }
        });

        callback(status);
    }

    /**
     * @description Event handler for the "get" event for the LightSensor.CurrentAmbientLightLevel characteristic.
     * @param {string} id - id of the accessory switch service being querried.
     * @param {Function} callback - Function callback for homebridge.
     * @returns {void}
     * @throws {TypeError} - thrown when 'id' is not a non-zero string.
     * @throws {Error} - Thrown when there is no accessory keyed with 'id'
     * @private
     */
    _handleCurrentAmbientLightLevelGet(id, callback) {
        // Validate arguments
        if ((id === undefined) ||
            (typeof(id) !== 'string') || (id.length <= 0)) {
            throw new TypeError(`id must be a non-zero length string.`);
        }

        let status = null;
        let result = new Error(`id:${id} has no matching accessory`);
        this._triggers.forEach((item, index) => {
            if (_is.existy(item.accessory) &&
                _is.equal(item.accessory.context.ID, id)) {
                // Get the accessory for this id.
                const accessory = item.accessory;

                // Get the accessory for this id.
                this._log.debug(`Trigger '${accessory.displayName}' Get CurrentAmbientLightLevel Request.`);

                try {
                    // Get the amount of time remaining
                    if (_is.existy(item.trigger)) {
                        result = this._getTimeRemaining(item.trigger);
                        this._log.debug(`Trigger '${accessory.displayName}' TimeRemaining=${result}.`);
                    }
                    else {
                        result = new Error(`id:${id} has no matching trigger`);
                    }
                }
                catch (err) {
                    this._log.debug(`  Unexpected error encountered: ${err.message}`);
                    result = false;
                    status = new Error(`Accessory ${accessory.displayName} is not ressponding.`);
                }
            }
        });

        // Invoke the callback function with our result.
        callback(status, result);
    }

    /**
     * @description Get the due time for the next trigger event
     * @param {object} trigger - trigger being querried.
     * @returns {DueTime} - Due time of the next trigger event.
     * @throws {TypeError} - TThrown when 'trigger' is not an instance of TimeTrigger.
     * @private
     */
    _getNextDueTime(trigger) {
        // Validate arguments
        if ((_is.not.existy(trigger)) || !(trigger instanceof TimeTrigger)) {
            throw new TypeError(`trigger must be a Time Trigger`);
        }

        // Get the amount of time remaining
        const timeRemaining = trigger.TimeRemaining;

        // Create the due time.
        const dueTime = new Date(Date.now() + timeRemaining);

        return {due_time: dueTime.toString(), due_dow: (dueTime.getDay() + 1)};
    }

    /**
     * @description Get the amount of time remaining for the specified trigger
     * @param {object} trigger - trigger being querried.
     * @returns {number} the time, in minutes, remaining.
     * @throws {TypeError} - TThrown when 'trigger' is not an instance of TimeTrigger.
     * @private
     */
    _getTimeRemaining(trigger) {
        const MIN_TIME = 0.0001;
        const MAX_TIME = 100000.0;

        // Validate arguments
        if ((_is.not.existy(trigger)) || !(trigger instanceof TimeTrigger)) {
            throw new TypeError(`trigger must be a Time Trigger`);
        }

        // Get the amount of time remaining
        let timeRemaining = trigger.TimeRemaining;
        // Convert from milliseconds to minutes.
        timeRemaining *= (1.0 /* milliseconds */ / (1000.0 /* milliseconds/second */ * 60.0 /* seconds/minute */));

        // Cap the result.
        if (timeRemaining < MIN_TIME) {
            timeRemaining = MIN_TIME;
        }
        else if (timeRemaining > MAX_TIME) {
            timeRemaining = MAX_TIME;
        }

        return timeRemaining;
    }

    /**
     * @description Get the value of the Service.Switch.On characteristic value
     * @param {object} accessory - accessory being querried.
     * @returns {boolean} the value of the On characteristic (true or false)
     * @throws {TypeError} - TThrown when 'accessory' is not an instance of _PlatformAccessory.
     * @throws {Error}  - Thrown when the On characteristic cannot be found on the accessory.
     * @private
     */
    _getAccessorySwitchState(accessory) {
        // Validate arguments
        if ((_is.not.existy(accessory)) || !(accessory instanceof _PlatformAccessory)) {
            throw new TypeError(`Accessory must be a PlatformAccessory`);
        }

        let result = false;
        const serviceSwitch = accessory.getService(_hap.Service.Switch);
        if (_is.existy(serviceSwitch)) {
            const charOn = serviceSwitch.getCharacteristic(_hap.Characteristic.On);
            if (_is.existy(charOn)) {
                result = charOn.value;
            }
            else {
                throw new Error(`The Switch service of accessory ${accessory.displayName} does not have an On charactristic.`);
            }
        }
        else {
            throw new Error(`Accessory ${accessory.displayName} does not have a Switch service.`);
        }

        return result;
    }

    /**
     * @description Event handler for the trigger state changed event.
     * @param {object} e - Event data
     * @param {string} e.uuid - Identifier of the trigger raising the event.
     * @param {TRIGGER_STATES} e.new_state - New state of the trigger
     * @param {TRIGGER_STATES} e.old_state - Old state of the trigger
     * @returns {void}
     * @throws {TypeError} - thrown when the arguments are invalid
     * @throws {RangeError} - thrown when the uuid is not associated with a known trigger.
     * @private
     */
    _handleTriggerStateChanged(e) {
        if (_is.not.object(e) ||
            (_is.undefined(e.uuid) || _is.not.string(e.uuid)) ||
            (_is.undefined(e.new_state) || _is.not.number(e.new_state)) ||
            (_is.undefined(e.old_state) || _is.not.number(e.old_state))) {
            throw new TypeError(`Invalid state changed event.`);
        }
        if (!this._triggers.has(e.uuid)) {
            this._log(`Unknown state change event. uuid:${e.uuid}`);
            throw new RangeError(`Unknown state change event. uuid:${e.uuid}`);
        }

        // Determine if there is motion or not.
        const isMotion = (e.new_state === TRIGGER_STATES.Tripped);
        const isActive = (e.new_state !== TRIGGER_STATES.Inactive);

        // Get the accessory.
        const match = this._triggers.get(e.uuid);
        if (_is.existy(match) &&
            _is.existy(match.accessory)) {
            // Update the characteristics
            try {
                this._log(`TriggerStateChanged: Updating motion status for trigger ${match.accessory.displayName}. active=${isActive} motion=${isMotion}`);
                this._updateMotionSensorService(match.accessory, SERVICE_INFO.MOTION, {active: isActive, motion: isMotion});

                // Get the switch state for this accessory
                const switchState = this._getAccessorySwitchState(match.accessory);
                // Is the switch state inconsistent with the trigger active state?
                if (switchState !== isActive) {
                    // Sync the switch state to the active state.
                    const serviceSwitch = match.accessory.getService(_hap.Service.Switch);
                    if (_is.existy(serviceSwitch) &&
                        (serviceSwitch instanceof _hap.Service.Switch)) {
                        // Make sure that the `set` event handler gets called so that the switch persistence
                        // state is updated.
                        this._log(`TriggerStateChanged: Updating switch status for trigger ${match.accessory.displayName} from ${switchState} to ${isActive}`);
                        serviceSwitch.setCharacteristic(_hap.Characteristic.On, isActive);
                    }
                }

                if (_is.existy(match.trigger)) {
                    // Get the amount of time remaining
                    const timeRemaining = this._getTimeRemaining(match.trigger);
                    this._log(`TriggerStateChanged: Updating time remaining for trigger ${match.accessory.displayName}. active=${isActive} timeRemaining=${timeRemaining}`);
                    this._updateLightSensorService(match.accessory, SERVICE_INFO.TIME_REMAINING, {active: isActive, lightlevel: timeRemaining});

                    // Get the due time of the next event.
                    const dueTime = this._getNextDueTime(match.trigger);
                    this._log(`TriggerStateChanged: Updating due time for trigger ${match.accessory.displayName}. dueTime.time=${dueTime.due_time} dueTime.dow=${dueTime.due_dow}`);
                    this._updateNextTripInfo(match.accessory, dueTime);
                }

                this._log(`TriggerStateChanged: Update complete.`);
            }
            catch {
            }
        }
        else {
            this._log(`TriggerStateChanged: Accessory for ${e.uuid} not found.`);
        }
    }

    /**
     * @description Event handler for the trigger state notify event.
     * @param {object} e - Event data
     * @param {string} e.uuid - Identifier of the trigger raising the event.
     * @param {TRIGGER_STATES} e.current_state - New state of the trigger
     * @returns {void}
     * @throws {TypeError} - thrown when the arguments are invalid
     * @throws {RangeError} - thrown when the uuid is not associated with a known trigger.
     * @private
     */
    _handleTriggerStateNotify(e) {
        if (_is.not.object(e) ||
            (_is.undefined(e.uuid) || _is.not.string(e.uuid)) ||
            (_is.undefined(e.current_state) || _is.not.number(e.current_state))) {
            throw new TypeError(`Invalid state notify event.`);
        }
        if (!this._triggers.has(e.uuid)) {
            this._log(`Unknown state notify event. uuid:${e.uuid}`);
            throw new RangeError(`Unknown state notify event. uuid:${e.uuid}`);
        }

        // Determine if there is motion or not.
        const isMotion = (e.current_state === TRIGGER_STATES.Tripped);
        const isActive = (e.current_state !== TRIGGER_STATES.Inactive);

        // Get the accessory.
        const match = this._triggers.get(e.uuid);
        if (_is.existy(match) &&
            _is.existy(match.accessory)) {
            // Update the characteristics
            try {
                this._log(`TriggerStateNotify: Updating motion status for trigger ${match.accessory.displayName}. active=${isActive} motion=${isMotion}`);
                this._updateMotionSensorService(match.accessory, SERVICE_INFO.MOTION, {active: isActive, motion: isMotion});

                if (_is.existy(match.trigger)) {
                    // Get the amount of time remaining
                    const timeRemaining = this._getTimeRemaining(match.trigger);
                    this._log(`TriggerStateNotify: Updating time remaining for trigger ${match.accessory.displayName}. active=${isActive} timeRemaining=${timeRemaining}`);
                    this._updateLightSensorService(match.accessory, SERVICE_INFO.TIME_REMAINING, {active: isActive, lightlevel: timeRemaining});

                    // Get the due time of the next event.
                    const dueTime = this._getNextDueTime(match.trigger);
                    this._log(`TriggerStateNotify: Updating due time for trigger ${match.accessory.displayName}. dueTime.time=${dueTime.due_time} dueTime.dow=${dueTime.due_dow}`);
                    this._updateNextTripInfo(match.accessory, dueTime);
                }
            }
            catch {
            }
            this._log(`TriggerStateNotify: Update complete.`);
        }
        else {
            this._log(`TriggerStateNotify: Accessory for ${e.uuid} not found.`);
        }
    }
}

/**
 * @description Exported default function for Homebridge integration.
 * @param {object} homebridgeAPI - reference to the Homebridge API.
 * @returns {void}
 */
export default (homebridgeAPI) => {
    _debug(`homebridge API version: v${homebridgeAPI.version}`);

    // Accessory must be created from PlatformAccessory Constructor
    _PlatformAccessory  = homebridgeAPI.platformAccessory;
    if (!Object.prototype.hasOwnProperty.call(_PlatformAccessory, 'PlatformAccessoryEvent')) {
        // Append the PlatformAccessoryEvent.IDENTITY enum to the platform accessory reference.
        // This allows us to not need to import anything from 'homebridge'.
        const platformAccessoryEvent = {
            IDENTIFY: 'identify',
        };

        _PlatformAccessory.PlatformAccessoryEvent = platformAccessoryEvent;
    }

    // Cache the reference to hap-nodejs
    _hap = homebridgeAPI.hap;

    // Register the paltform.
    _debug(`Registering platform: ${_PackageInfo.CONFIG_INFO.platform}`);
    homebridgeAPI.registerPlatform(_PackageInfo.CONFIG_INFO.platform, TimeTriggerPlatform);
};