plugins/nodeStateMonitor.js

'use strict';

const _isNil = require('lodash/isNil');
const _has = require('lodash/has');
const _isEmpty = require('lodash/isEmpty');
const _includes = require('lodash/includes');
const events = require('events');
const {default: PromiseQueue} = require('p-queue');
const Chance = require('chance');
const debug = require('debug')('k4-base-bridge:nodeStateMonitor');

let TimingUtility;
let MappingChangeMonitor;
let ModelChangeMonitor;

/**
 * @class
 * @description Plugin to observe and update node states
 */

class NodeStateMonitor {
	/**
	 * @constructor
	 * @param {Object} [opts] General settings for NodeStateMonitor
	 * @param {Adapter} adapter Adapter for which this is a plugin
	 */
	constructor(opts, adapter) {
		this._opts = opts;
		this._adapter = adapter;
		this.emitter = new events.EventEmitter();
		this._initCloseQueue = new PromiseQueue({
			concurrency: 1,
			autoStart: true
		});

		this._shouldReinitializeOnBridgeReady = false;
		if (this._opts &&
			this._opts.shouldReinitializeOnBridgeReady === true) {
			this._shouldReinitializeOnBridgeReady = true;
		}

		TimingUtility = this._adapter.bridge.classes.TimingUtility;
		MappingChangeMonitor = this._adapter.bridge.classes.MappingChangeMonitor;
		ModelChangeMonitor = this._adapter.bridge.classes.ModelChangeMonitor;

		this.mappingChangeMonitor = new MappingChangeMonitor(adapter);
		this.modelChangeMonitor = new ModelChangeMonitor(adapter);
	}

	get shouldReinitializeOnBridgeReady() {
		return this._shouldReinitializeOnBridgeReady;
	}

	/**
	 * @description The adapter instance
	 * @type {Adapter}
	 */
	set adapter(adapter) {
		this._adapter = adapter;
	}

	get adapter() {
		return this._adapter;
	}

	/**
	 * @description The options object for this NodeStateMonitor
	 * @type {Object}
	 */
	get opts() {
		return this._opts;
	}

	set opts(opts) {
		this._opts = opts;
	}

	/**
	 * @property {Object} constants
	 * @static
	 */
	static get constants() {
		return {
			resetModes: {
				STANDARD_DEAD: 'nodeDeadTimeout',
				FAILURE_RECOVERY: 'failureRecoveryTimeout'
			},
			nodeStates: {
				ALIVE: 'alive',
				DEAD: 'dead',
				UNKNOWN: 'unknown'
			},
			nodeStatusTypes: {
				DEAD: 0,
				ALIVE: 1,
				NOT_RESPONDING: 2
			},
			emittedEventTypes: {
				PING_READY: 'readyToPing'
			},
			misc: {
				MAX_RECOVERY_ATTEMPTS: 1
			}
		};
	}

	/**
	 * @description Obtains the latest nodeState from the device model node
	 * @param {String} devicePath The path to the device model node
	 * @throws {Error}
	 */
	getNodeStateByDevicePath(devicePath) {
		const deviceNode = this.adapter.k4.model.child(devicePath);
		if (_isNil(deviceNode)) {
			throw new Error(`Device node does not exist for: ${devicePath}`);
		} else if (_isNil(deviceNode.child('variables/nodeState'))) {
			throw new Error(`nodeState variable does not exist for: ${devicePath}`);
		} else {
			return deviceNode.child('variables/nodeState').value();
		}
	}

	/**
	 * @description Retrieves the current device node state for the specified
	 * node id
	 * @param {Number} nodeId
	 * @throws {Error}
	 */
	getNodeStateById(nodeId) {
		const devicePath = this.findDevicePathById(nodeId);
		const nodeState = this.getNodeStateByDevicePath(devicePath);
		return nodeState;
	}

	/**
	 * @description This method should be named as 'init' because Adapter
	 * instance calls the 'init' methods (if they exist) for all Adapter
	 * plugins
	 */
	init(options) {
		debug('[init] Starting Node State Monitor: ', options);
		this.havePolledThisWindow = false;

		if (!_isNil(options) && !_isNil(options.force) && options.force === true) {
			this._initCloseQueue.pause();
			this._initCloseQueue.clear();
			this._initCloseQueue.start();
		}

		return this._initCloseQueue.add(() => this.initRoutine());
	}

	initRoutine() {
		this._enableEventEmissions = false;

		return TimingUtility.setTimeoutPromise(1)
			.then(() => this.closeRoutine())
			.then(() => {
				this._asyncCriticalTaskQueue = new PromiseQueue({
					concurrency: 1,
					autoStart: true
				});

				this._rng = new Chance((process.hrtime()[0] * 1e9) + process.hrtime()[1]);

				this.pingTimersByDeviceNode = new Map();
				this.lastSeenTimersByDeviceNode = new Map();
				this.failureRecoveryAttemptsByDeviceNode = new Map();

				const startupPromise = this._asyncCriticalTaskQueue.add(() => {
					this.initializeFailureRecoveryAttemptCounts();
					this.initializeNodeStatesForAllDevices();
					this.setupNodeStatusAdapterEventListener();

					this.initializePingTimers();
					const initialPingPromises = this.initializeLastSeenTimers();
					return initialPingPromises
						.then(() => TimingUtility.setTimeoutPromise(2000))
						.then(() => {
							this._enableEventEmissions = true;
						});
				});

				this.mappingChangeMonitor.init();
				this.modelChangeMonitor.init();

				this.mappingChangeHandler = (devicesWithChangedMappings) => {
					this._asyncCriticalTaskQueue.add(() => {
						return TimingUtility.setTimeoutPromise(200)
							.then(() => {
								return this.onMappingChanged(devicesWithChangedMappings);
							});
					});
				};

				this.modelChangeHandler = (devicesWithChangedNodes) => {
					this._asyncCriticalTaskQueue.add(() => {
						return TimingUtility.setTimeoutPromise(200)
							.then(() => {
								return this.onModelChanged(devicesWithChangedNodes);
							});
					});
				};

				return startupPromise
					.then(() => {
						this.mappingChangeMonitor.emitter.on(MappingChangeMonitor.constants.MAPPING_CHANGED_EVENT_NAME, this.mappingChangeHandler);
						this.modelChangeMonitor.emitter.on(ModelChangeMonitor.constants.MODEL_NODES_CHANGED_EVENT_NAME, this.modelChangeHandler);
					});
			});
	}

	initializeNodeStatesForAllDevices() {
		debug('[initializeNodeStatesForAllDevices] Going to set initial node states for all devices');

		const bridgeDevicesList = this.adapter.getBridgeDevicesList();
		Object.keys(bridgeDevicesList)
			.forEach((devicePath) => {
				if (bridgeDevicesList[devicePath].isTemporaryDevice === true) {
					return;
				}

				const deviceModelNode = this.adapter.k4.model.child(devicePath);
				if (_isNil(deviceModelNode)) {
					return;
				}

				const deviceCls = deviceModelNode.cls();

				// NOTE: Do not want to monitor node states for the bridge itself
				if (deviceCls.includes('bridge')) {
					return;
				}
				this.updateNodeState(devicePath, NodeStateMonitor.constants.nodeStates.UNKNOWN);
			}, this);

		debug('[initializeNodeStatesForAllDevices] Finished settinginitial node states for all devices');
	}

	initializeFailureRecoveryAttemptCounts() {
		const bridgeDevicesList = this.adapter.getBridgeDevicesList();
		Object.keys(bridgeDevicesList)
			.forEach((devicePath) => {
				if (bridgeDevicesList[devicePath].isTemporaryDevice === true) {
					return;
				}

				const deviceModelNode = this.adapter.k4.model.child(devicePath);
				if (_isNil(deviceModelNode)) {
					return;
				}

				const deviceCls = deviceModelNode.cls();

				// NOTE: Do not want to monitor node states for the bridge itself
				if (deviceCls.includes('bridge')) {
					return;
				}

				this.failureRecoveryAttemptsByDeviceNode.set(devicePath, 0);
			}, this);
	}

	onMappingChanged(devicesWithChangedMappings) {
		debug('[onMappingChanged] Entered model change handler: ', devicesWithChangedMappings.length, new Date().toISOString());

		devicesWithChangedMappings
			.forEach((deviceChangeObj) => {
				try {
					switch (deviceChangeObj.changeType) {
						case MappingChangeMonitor.constants.DEVICE_ADDED: {
							this.adapter.k4.logging.system(`[NodeStateMonitor] Device mapping was added: ${deviceChangeObj.devicePath}`);

							const deviceModelNode = this.adapter.k4.model.child(deviceChangeObj.devicePath);
							if (_isNil(deviceModelNode)) {
								return;
							}

							const deviceCls = deviceModelNode.cls();
							if (deviceCls.includes('bridge')) {
								return;
							}

							// NOTE: Initializing nodeState to UNKNOWN at inception
							this.updateNodeState(deviceChangeObj.devicePath, NodeStateMonitor.constants.nodeStates.UNKNOWN);

							// NOTE: Set up timers for this new device
							const device = this.adapter.findBridgeDeviceByNode(deviceChangeObj.devicePath);
							if (_has(device, '_mapping')) {
								const nodeMonitoringSettings = this.extractNodeMonitoringSettings(device);
								this.resetLastSeenTimerForDevice(deviceChangeObj.devicePath, NodeStateMonitor.constants.resetModes.STANDARD_DEAD, nodeMonitoringSettings);

								// FIXME: Guarantee at least one ping on change
								if (this._enableEventEmissions) {
									this.emitter.emit('mappingDeviceAddedPing', deviceChangeObj.devicePath, Date.now());
								}
								this.issuePing(deviceChangeObj.devicePath);
								this.resetPingTimerForDevice(deviceChangeObj.devicePath, nodeMonitoringSettings);
							}
						} break;
						case MappingChangeMonitor.constants.DEVICE_MODIFIED_IN_PLACE: {
							this.adapter.k4.logging.system(`[NodeStateMonitor] Device mapping was modified in place: ${deviceChangeObj.devicePath}. Fields changed: ${deviceChangeObj.fieldsThatChanged}`);

							const deviceModelNode = this.adapter.k4.model.child(deviceChangeObj.devicePath);
							if (_isNil(deviceModelNode)) {
								return;
							}

							const deviceCls = deviceModelNode.cls();
							if (deviceCls.includes('bridge')) {
								return;
							}

							const device = this.adapter.findBridgeDeviceByNode(deviceChangeObj.devicePath);
							if (_has(device, '_mapping')) {
								const nodeMonitoringSettings = this.extractNodeMonitoringSettings(device);
								this.resetLastSeenTimerForDevice(deviceChangeObj.devicePath, NodeStateMonitor.constants.resetModes.STANDARD_DEAD, nodeMonitoringSettings);
								this.removePingTimerForDevice(deviceChangeObj.devicePath);

								// FIXME: Guarantee at least one ping on change
								if (this._enableEventEmissions) {
									this.emitter.emit('mappingDeviceChangedPing', deviceChangeObj.devicePath, Date.now());
								}
								this.issuePing(deviceChangeObj.devicePath);
								this.resetPingTimerForDevice(deviceChangeObj.devicePath, nodeMonitoringSettings);
							}
						} break;
						case MappingChangeMonitor.constants.DEVICE_REMOVED:
							this.adapter.k4.logging.system(`[NodeStateMonitor] Device mapping was removed: ${deviceChangeObj.devicePath}`);

							const deviceModelNode = this.adapter.k4.model.child(deviceChangeObj.devicePath);
							if (!_isNil(deviceModelNode)) {
								const deviceCls = deviceModelNode.cls();
								if (deviceCls.includes('bridge')) {
									return;
								}
							}

							// NOTE: Remove timers for this device
							this.removeLastSeenTimerForDevice(deviceChangeObj.devicePath);
							this.removePingTimerForDevice(deviceChangeObj.devicePath);
							break;
						default:
							break;
					}
				} catch (e) {
					this.adapter.k4.logging.error(`There was an issue handling an adapter mapping change. ${e.toString()}`, e instanceof Error ? e.stack.split('\n') : null);
				}
			}, this);
	}

	onModelChanged(devicesWithChangedNodes) {
		debug('[onModelChanged] Entered model change handler: ', devicesWithChangedNodes.length, new Date().toISOString());

		devicesWithChangedNodes
			.forEach((deviceNodeChangeObj) => {
				this.adapter.k4.logging.debug(`[nodeStateMonitor]/onModelChanged -- Device Node Changed Path: ${deviceNodeChangeObj.devicePath}.`)

				if (!this.adapter.k4.model.child(deviceNodeChangeObj.devicePath)) {
					this.adapter.k4.logging.debug(`[nodeStateMonitor]/onModelChanged -- Device Node Changed Path: ${deviceNodeChangeObj.devicePath}. Does not exist on the K4Model`);
				}

				try {
					switch (deviceNodeChangeObj.changeType) {
						case ModelChangeMonitor.constants.DEVICE_ADDED: {
							this.adapter.k4.logging.system(`[NodeStateMonitor] Device node was added: ${deviceNodeChangeObj.devicePath}`);

							const deviceModelNode = this.adapter.k4.model.child(deviceNodeChangeObj.devicePath);
							if (_isNil(deviceModelNode)) {
								return;
							}

							const deviceCls = deviceModelNode.cls();

							// NOTE: Do not want to monitor node states for the bridge itself
							if (deviceCls.includes('bridge')) {
								return;
							}

							// NOTE: Initializing nodeState to UNKNOWN at inception
							this.updateNodeState(deviceNodeChangeObj.devicePath, NodeStateMonitor.constants.nodeStates.UNKNOWN);

							// NOTE: Set up timers for this new device
							const device = this.adapter.findBridgeDeviceByNode(deviceNodeChangeObj.devicePath);
							if (_has(device, '_mapping')) {
								const nodeMonitoringSettings = this.extractNodeMonitoringSettings(device);
								this.resetLastSeenTimerForDevice(deviceNodeChangeObj.devicePath, NodeStateMonitor.constants.resetModes.STANDARD_DEAD, nodeMonitoringSettings);

								// FIXME: Guarantee at least one ping on change
								if (this._enableEventEmissions) {
									this.emitter.emit('deviceNodeAddedPing', deviceNodeChangeObj.devicePath, Date.now());
								}
								this.issuePing(deviceNodeChangeObj.devicePath);
								this.resetPingTimerForDevice(deviceNodeChangeObj.devicePath, nodeMonitoringSettings);
							}
						} break;
						case ModelChangeMonitor.constants.DEVICE_MODIFIED_IN_PLACE: {
							this.adapter.k4.logging.system(`[NodeStateMonitor] Device node was modified in place: ${deviceNodeChangeObj.devicePath}. Fields changed: ${deviceNodeChangeObj.fieldsThatChanged}`);

							const deviceModelNode = this.adapter.k4.model.child(deviceNodeChangeObj.devicePath);
							if (_isNil(deviceModelNode)) {
								return;
							}

							const deviceCls = deviceModelNode.cls();

							// NOTE: Do not want to monitor node states for the bridge itself
							if (deviceCls.includes('bridge')) {
								return;
							}

							const device = this.adapter.findBridgeDeviceByNode(deviceNodeChangeObj.devicePath);

							if (!_isNil(deviceNodeChangeObj.fieldsThatChanged.find(modelField => modelField === 'properties'))) {
								if (_has(device, '_mapping')) {
									const nodeMonitoringSettings = this.extractNodeMonitoringSettings(device);
									this.resetLastSeenTimerForDevice(deviceNodeChangeObj.devicePath, NodeStateMonitor.constants.resetModes.STANDARD_DEAD, nodeMonitoringSettings);
									this.removePingTimerForDevice(deviceNodeChangeObj.devicePath);

									// FIXME: Guarantee at least one ping on change
									if (this._enableEventEmissions) {
										this.emitter.emit('deviceNodeChangedPing', deviceNodeChangeObj.devicePath, Date.now());
									}
									this.issuePing(deviceNodeChangeObj.devicePath);
									this.resetPingTimerForDevice(deviceNodeChangeObj.devicePath, nodeMonitoringSettings);
								}
							}
						} break;
						case ModelChangeMonitor.constants.DEVICE_REMOVED:
							this.adapter.k4.logging.system(`[NodeStateMonitor] Device node was removed: ${deviceNodeChangeObj.devicePath}`);

							const deviceModelNode = this.adapter.k4.model.child(deviceNodeChangeObj.devicePath);
							if (!_isNil(deviceModelNode)) {
								const deviceCls = deviceModelNode.cls();

								// NOTE: Do not want to monitor node states for the bridge itself
								if (deviceCls.includes('bridge')) {
									return;
								}
							}

							// NOTE: Remove timers for this device
							this.removeLastSeenTimerForDevice(deviceNodeChangeObj.devicePath);
							this.removePingTimerForDevice(deviceNodeChangeObj.devicePath);
							break;
						default:
							break;
					}
				} catch (e) {
					this.adapter.k4.logging.error(`[NodeStateMonitor] There was an issue handling an device nodes change. ${e.toString()}`, e instanceof Error ? e.stack.split('\n') : null);
				}
			}, this);
	}

	/**
	 * @description Creates an EventListener to monitor updates of node statuses
	 * emitted by the Adapter instance
	 */
	setupNodeStatusAdapterEventListener() {
		if (!_isNil(this.adapter._emitter)) {
			this.nodeStatusEventListener = (nodeId, nodeStatus) => {
				this._asyncCriticalTaskQueue.add(() => {
					return TimingUtility.setTimeoutPromise(200)
						.then(() => {
							return this.onNodeStatusEvent(nodeId, nodeStatus);
						});
				});
			};
			this.adapter._emitter.on('nodeStatusUpdate', this.nodeStatusEventListener);
		}
	}

	/**
	 * @description Sends a noOp command to the device
	 * @param {String} devicePath Path to the device model node
	 */
	issuePing(devicePath) {
		debug(`[issuePing] Entered for device ${devicePath}: `, new Date().toISOString());
		const deviceModelNode = this.adapter.k4.model.child(devicePath);
		if (_isNil(deviceModelNode)) {
			return;
		}

		const deviceCls = deviceModelNode.cls();

		// NOTE: Do not want to monitor node states for the bridge itself
		if (deviceCls.includes('bridge')) {
			return;
		}


		const device = this.adapter.findBridgeDeviceByNode(devicePath);
		if (this._enableEventEmissions) {
			this.emitter.emit('readyToPing', devicePath, Date.now());
		}
		debug(`[issuePing] Issuing NoOp ping for device ${devicePath}: `, new Date().toISOString());
		this.adapter.sendCommand('NoOp', device, { ackRequested: true })
			.catch(err => this.adapter.k4.logging.system(`Had difficulty issuing ping for device: ${devicePath}. ${err.toString()}`, err instanceof Error ? err.stack.split('\n') : null));
	}

	/**
	 * @description Obtain the set of node (state) monitoring settings from
	 * mapping
	 * @param {Device} device The device whose nodeState is monitored
	 * @throws {Error}
	 * @returns {Object} The settings for monitoring nodeState for this device
	 */
	extractNodeMonitoringSettings(device) {
		let deviceNodeMonitoringSettings = {};
		if (!_isNil(device) &&
			!_isNil(device._mapping) &&
			!_isNil(device._mapping.nodeMonitoring)) {
			deviceNodeMonitoringSettings = device._mapping.nodeMonitoring;
		}

		if (_isEmpty(deviceNodeMonitoringSettings)) {
			throw new Error(`Unable to access device ${device.devicePath} mapping for node monitoring`);
		}

		return deviceNodeMonitoringSettings;
	}

	/**
	 * @description Determine the value to set for a specific node monitoring
	 * setting argument, giving primacy to K4Model property overrides
	 * @param {String} argName Name of the argument belonging to a node monitoring
	 * parameter
	 * @param {Device} device The device whose nodeState is monitored
	 * @param {Object} nodeMonitoringSettings The settings for monitoring
	 * @returns {any} The appropriate value the argument should be set to
	 */
	obtainNodeMonitorSettingArgVal(argName, device, nodeMonitoringSettings) {
		if (device.isTemporaryDevice) {
			return nodeMonitoringSettings[argName];
		}

		const modelOverrideVal = this.obtainNodeMonitorPropOverrideForArgVal(argName, device, nodeMonitoringSettings);
		if (!_isNil(modelOverrideVal)) {
			return modelOverrideVal;
		}

		// Return default argument value if there are no model property
		// overrides
		return nodeMonitoringSettings[argName];
	}

	/**
	 * @description Determine, if available, the K4Model property override value
	 * for a node monitoring argument overrides
	 * @param {String} argName Name of the argument belonging to a node monitoring
	 * parameter
	 * @param {Device} device The device whose nodeState is monitored
	 * @param {Object} nodeMonitoringSettings The settings for monitoring
	 * @returns {any} The appropriate value the argument should be set to
	 */
	obtainNodeMonitorPropOverrideForArgVal(argName, device, nodeMonitoringSettings) {
		let propOverrides = null;

		if (!_isNil(nodeMonitoringSettings) && !_isNil(nodeMonitoringSettings.property)) {
			propOverrides = nodeMonitoringSettings.property
				.find(prop => prop.argument === argName, this);
		}

		if (!_isNil(propOverrides)) {
			if (!device.model.child(`properties/${propOverrides.name}`)) {
				this.adapter.k4.logging.system(`K4Model property node to override with does not exist: ${propOverrides.name}`);
				return undefined;
			}

			const propOverrideValue = device.model.child(`properties/${propOverrides.name}`).value();
			return propOverrideValue;
		}
	}

	/**
	 * @description Ensure that lastSeenTimers (i.e. last-heard-from-device
	 * timers) are set up properly.
	 * @returns {Promise}
	 */
	initializeLastSeenTimers() {
		const initialPingsIssuedPromises = [];
		const bridgeDevicesList = this.adapter.getBridgeDevicesList();
		Object.keys(bridgeDevicesList)
			.forEach((devicePath) => {
				if (bridgeDevicesList[devicePath].isTemporaryDevice === true) {
					return;
				}

				const deviceModelNode = this.adapter.k4.model.child(devicePath);
				if (_isNil(deviceModelNode)) {
					return;
				}

				const deviceCls = this.adapter.k4.model.child(devicePath).cls();

				// NOTE: Do not want to monitor node states for the bridge itself
				if (deviceCls.includes('bridge')) {
					return;
				}

				try {
					const nodeMonitoringSettings = this.extractNodeMonitoringSettings(this.adapter.findBridgeDeviceByNode(devicePath));
					this.resetLastSeenTimerForDevice(devicePath, NodeStateMonitor.constants.resetModes.STANDARD_DEAD, nodeMonitoringSettings);

					// FIXME: Guarantee at least one ping on change
					initialPingsIssuedPromises.push(this.issuePing(devicePath));
				} catch (e) {
					this.adapter.k4.logging.info(`Could not set up lastSeen timer. Device ${devicePath} does not have node monitoring settings. ${e.toString()}`, e instanceof Error ? e.stack.split('\n') : null);
				}
			}, this);

		if (initialPingsIssuedPromises.length === 0) {
			return Promise.resolve([]);
		}

		return Promise.all(initialPingsIssuedPromises);
	}

	initializePingTimers() {
		let disableAutoPingsGlobally = false;
		if (!_isNil(this._opts) &&
			!_isNil(this._opts.disableAutoPingsGlobally)) {
			disableAutoPingsGlobally = this._opts.disableAutoPingsGlobally;
		}

		if (disableAutoPingsGlobally === true) {
			return;
		}

		const bridgeDevicesList = this.adapter.getBridgeDevicesList();

		Object.keys(bridgeDevicesList)
			.forEach((devicePath) => {
				if (bridgeDevicesList[devicePath].isTemporaryDevice === true) {
					return;
				}

				const deviceModelNode = this.adapter.k4.model.child(devicePath);
				if (_isNil(deviceModelNode)) {
					return;
				}

				const deviceCls = deviceModelNode.cls();

				// NOTE: Do not want to monitor node states for the bridge itself
				if (deviceCls.includes('bridge')) {
					return;
				}

				try {
					const nodeMonitoringSettings = this.extractNodeMonitoringSettings(this.adapter.findBridgeDeviceByNode(devicePath));

					let enableAutoPing = true;
					if (!_isNil(nodeMonitoringSettings) &&
						!_isNil(nodeMonitoringSettings.enableAutoPing)) {
						enableAutoPing = nodeMonitoringSettings.enableAutoPing;
					}

					if (enableAutoPing === false) {
						this.adapter.k4.logging.debug(`[NodeStateMonitor] Auto pings were disabled for ${devicePath}. Skipping ping timer initialization.`);
						return;
					}

					this.resetPingTimerForDevice(devicePath, nodeMonitoringSettings);
				} catch (e) {
					this.adapter.k4.logging.info(`Could not set up ping timer. Device ${devicePath} does not have node monitoring settings. ${e.toString()}`, e instanceof Error ? e.stack.split('\n') : null);
				}
			}, this);
	}

	// FIXME: Want to account for bridge devices list changes
	prunePingTimers() {
		const bridgeDevicesListPaths = Object.keys(this.adapter.getBridgeDevicesList());
		this.pingTimersByDeviceNode
			.forEach((pingTimer, devicePath) => {
				if (!_includes(bridgeDevicesListPaths, devicePath)) {
					this.removePingTimerForDevice(devicePath);
				}
			});
	}

	pruneLastSeenTimers() {
		const bridgeDevicesListPaths = Object.keys(this.adapter.getBridgeDevicesList());
		this.lastSeenTimersByDeviceNode
			.forEach((lastSeenTimer, devicePath) => {
				if (!_includes(bridgeDevicesListPaths, devicePath)) {
					this.removeLastSeenTimerForDevice(devicePath);
				}
			});
	}

	/**
	 * @description Updates the K4Model variable for the specified
	 * device's node state
	 * @param {String} devicePath Path to the device model node
	 * @param {String} nodeState The raw, local node state for this device
	 */
	updateNodeState(devicePath, nodeState) {
		debug(`[updateNodeState] Node State updated to ${nodeState} for device ${devicePath}`);

		const deviceModelNode = this.adapter.k4.model.child(devicePath);
		if (_isNil(deviceModelNode)) {
			return;
		}

		const deviceCls = deviceModelNode.cls();

		// NOTE: Do not want to monitor node states for the bridge itself
		if (deviceCls.includes('bridge')) {
			return Promise.resolve();
		}

		const nodeId = (!_isNil(this.adapter.k4.model.child(`${devicePath}/properties/networkId`))) ? this.adapter.k4.model.child(`${devicePath}/properties/networkId`).value() : null;

		// NOTE: Disabled event emission for nodeState UNKNOWN so that tests
		// do not break without actually failing.
		if (nodeState !== NodeStateMonitor.constants.nodeStates.UNKNOWN) {
			if (this._enableEventEmissions) {
				this.emitter.emit('nodeStateUpdate', devicePath, nodeState, Date.now());
			}
		}

		this.adapter.k4.model.child(`${devicePath}/variables/nodeState`).set(nodeState);
	}

	/**
	 * @description Unregisters listener from
	 * node status update events emitted by the Adapter instance
	 */
	removeNodeStatusAdapterEventListener() {
		if (!_isNil(this.nodeStatusEventListener) && !_isNil(this.adapter._emitter)) {
			this.adapter._emitter.removeListener('nodeStatusUpdate', this.nodeStatusEventListener);
			this.nodeStatusEventListener = null;
		}
	}

	/**
	 * @description Handles the updating of node states and timer
	 * renewals
	 * @param {Number} nodeId The nodeId of the device whose node status was
	 * updated
	 * @param {any} nodeStatus The emitted status for this node
	 */
	onNodeStatusEvent(nodeId, nodeStatus) {
		try {
			const devicePath = this.findDevicePathById(nodeId);
			const deviceModelNode = this.adapter.k4.model.child(devicePath);
			debug(`[onNodeStatusEvent] Got node status: ${nodeStatus} for ${devicePath}`);
			if (_isNil(deviceModelNode)) {
				throw new Error('K4Model node does not exist');
			}

			const deviceCls = deviceModelNode.cls();

			// NOTE: Do not want to monitor node states for the bridge itself
			if (deviceCls.includes('bridge')) {
				return;
			}

			const device = this.adapter.findBridgeDeviceByNode(devicePath);

			switch (nodeStatus) {
				case NodeStateMonitor.constants.nodeStatusTypes.ALIVE: {
					this.updateNodeState(devicePath, NodeStateMonitor.constants.nodeStates.ALIVE);

					const nodeMonitoringSettings = this.extractNodeMonitoringSettings(device);
					this.resetLastSeenTimerForDevice(devicePath, NodeStateMonitor.constants.resetModes.STANDARD_DEAD, nodeMonitoringSettings);
					this.resetPingTimerForDevice(devicePath, nodeMonitoringSettings);
				} break;
				case NodeStateMonitor.constants.nodeStatusTypes.NOT_RESPONDING: {
					const nodeMonitoringSettings = this.extractNodeMonitoringSettings(device);
					const timeoutDurationForFailureRecovery = this.obtainNodeMonitorSettingArgVal(NodeStateMonitor.constants.resetModes.FAILURE_RECOVERY, device, nodeMonitoringSettings);

					debug(`[onNodeStatusEvent] For device ${devicePath}, failure recovery timeout: ${timeoutDurationForFailureRecovery}`);

					if (!_isNil(timeoutDurationForFailureRecovery)) {
						const failureRecoveryAttemptsCount = this.getFailureRecoveryAttemptsTally(devicePath);
						if (failureRecoveryAttemptsCount < NodeStateMonitor.constants.misc.MAX_RECOVERY_ATTEMPTS) {
							debug(`[onNodeStatusEvent] Initiating failure recovery attempt for device ${devicePath}. Failure recovery attempts count: ${failureRecoveryAttemptsCount}`);
							this.incrementFailureRecoveryAttemptTally(devicePath);
							this.resetLastSeenTimerForDevice(devicePath, NodeStateMonitor.constants.resetModes.FAILURE_RECOVERY, nodeMonitoringSettings);
							this.resetPingTimerForDevice(devicePath, nodeMonitoringSettings);
						}
					}
				} break;

				// TODO: Currently do not have any situations where we mark
				// the node status as dead immediately on message arrival.

				// case NodeStateMonitor.constants.nodeStatusTypes.DEAD:
				// 	this.updateNodeState(devicePath, NodeStateMonitor.constants.nodeStates.DEAD);
				// 	break;
				default:
					break;
			}
		} catch (e) {
			this.adapter.k4.logging.system(`[nodeStateMonitor]/onNodeStatusEvent--Not able to update node status for nodeId: ${nodeId}. ${e.toString()}`, e instanceof Error ? e.stack.split('\n') : null);
		}
	}

	/**
	 * @description Update the count of how many times a specified device
	 * node has progressed to the failure recovery stage during its lifetime
	 * @param {String} devicePath
	 */
	incrementFailureRecoveryAttemptTally(devicePath) {
		debug(`[incrementFailureRecoveryAttemptTally] Going to tally another failure recovery attempt for device ${devicePath}`);
		if (!this.failureRecoveryAttemptsByDeviceNode.has(devicePath)) {
			this.failureRecoveryAttemptsByDeviceNode.set(devicePath, 0);
		}

		const currentNumAttempts = this.failureRecoveryAttemptsByDeviceNode.get(devicePath);
		this.failureRecoveryAttemptsByDeviceNode.set(devicePath, 1 + currentNumAttempts);
	}

	/**
	 * @description Retrieve the count of how many times a specified device
	 * node has progressed to the failure recovery stage during its lifetime
	 * @param {String} devicePath
	 * @returns {Number}
	 * @throws {Error}
	 */
	getFailureRecoveryAttemptsTally(devicePath) {
		if (!this.failureRecoveryAttemptsByDeviceNode.has(devicePath)) {
			this.failureRecoveryAttemptsByDeviceNode.set(devicePath, 0);
		}

		return this.failureRecoveryAttemptsByDeviceNode.get(devicePath);
	}

	/**
	 * @description Resets the timer for when to send the
	 * next noOp ping to the device.
	 * @param {String} devicePath Path to the device model node
	 * @param {Object} [nodeMonitoringSettings]
	 * @throws {Error}
	 */
	resetPingTimerForDevice(devicePath, nodeMonitoringSettings) {
		debug(`[resetPingTimerForDevice] Going to reset ping timer for Device ${devicePath}`);

		let disableAutoPingsGlobally = false;
		if (!_isNil(this._opts) &&
			!_isNil(this._opts.disableAutoPingsGlobally)) {
			disableAutoPingsGlobally = this._opts.disableAutoPingsGlobally;
		}

		if (disableAutoPingsGlobally === true) {
			return;
		}

		const device = this.adapter.findBridgeDeviceByNode(devicePath);
		const deviceNodeMonitoringSettings = !_isNil(nodeMonitoringSettings) ? nodeMonitoringSettings : this.extractNodeMonitoringSettings(device);

		let enableAutoPing = true;
		if (!_isNil(deviceNodeMonitoringSettings) &&
			!_isNil(deviceNodeMonitoringSettings.enableAutoPing)) {
			enableAutoPing = deviceNodeMonitoringSettings.enableAutoPing;
		}

		if (enableAutoPing === false) {
			this.adapter.k4.logging.debug(`[NodeStateMonitor] Auto pings were disabled for ${devicePath}. Skipping ping timer reset.`);
			return;
		}

		const pingIntervalSec = this.obtainNodeMonitorSettingArgVal('pingInterval', device, deviceNodeMonitoringSettings);
		const pingAfterDead = this.obtainNodeMonitorSettingArgVal('pingAfterDead', device, deviceNodeMonitoringSettings);

		if (_isNil(pingIntervalSec)) {
			throw new Error(`Ping interval was not specified for device: ${devicePath}`);
		}

		if (this.pingTimersByDeviceNode.has(devicePath)) {
			const currentTimer = this.pingTimersByDeviceNode.get(devicePath);
			clearInterval(currentTimer);
			debug(`[resetPingTimerForDevice] Device ${devicePath} pingTimer REMOVED: `, new Date().toISOString(), currentTimer.customId);
			this.pingTimersByDeviceNode.delete(devicePath);
		}

		const timerCustomId = this._rng.guid({ version: 4 });

		debug(`[resetPingTimerForDevice] Device ${devicePath} pingTimer CREATED: `, pingIntervalSec, new Date().toISOString(), timerCustomId);

		const pingTimerObj = setInterval(() => {
			debug(`[resetPingTimerForDevice] Device ${devicePath} pingTimer EXPIRED: `, new Date().toISOString(), timerCustomId);

			try {
				if (pingAfterDead === true || this.getNodeStateByDevicePath(devicePath) !== NodeStateMonitor.constants.nodeStates.DEAD) {
					this.issuePing(devicePath);
				}
			} catch (e) {
				this.adapter.k4.logging.system(`Failed to issue ping for device: ${devicePath}. ${e.toString()}`, e instanceof Error ? e.stack.split('\n') : null);
			}
		}, 1000 * pingIntervalSec);

		pingTimerObj.customId = timerCustomId;

		this.pingTimersByDeviceNode.set(devicePath, pingTimerObj);
	}

	/**
	 * @description Resets/refills the timer for the
	 * device when it has been responsive/active
	 * @param {String} devicePath Path to the device model node
	 * @param {String} [resetMode] The type of timeout to refill the timer using
	 * @param {Object} [nodeMonitoringSettings] The settings object for node
	 * monitoring timeouts, intervals, etc.
	 * @throws {Error}
	 */
	resetLastSeenTimerForDevice(devicePath, resetMode, nodeMonitoringSettings) {
		debug(`[resetLastSeenTimerForDevice] Going to reset last seen timer for device ${devicePath}.`, new Date().toISOString(), this.lastSeenTimersByDeviceNode.keys());

		const device = this.adapter.findBridgeDeviceByNode(devicePath);
		const deviceNodeMonitoringSettings = !_isNil(nodeMonitoringSettings) ? nodeMonitoringSettings : this.extractNodeMonitoringSettings(device);

		let nodeDeadTimeout = this.obtainNodeMonitorSettingArgVal(NodeStateMonitor.constants.resetModes.STANDARD_DEAD, device, deviceNodeMonitoringSettings);
		debug(`[resetLastSeenTimerForDevice] Obtaining nodeDeadTimeout for device ${devicePath}. Value is: ${nodeDeadTimeout}`);

		if (!_isNil(resetMode)) {
			const timeoutForSpecifiedMode = this.obtainNodeMonitorSettingArgVal(resetMode, device, deviceNodeMonitoringSettings);
			debug(`[resetLastSeenTimerForDevice] Obtaining timeoutForSpecifiedMode resetMode: ${resetMode} for device ${devicePath}. Value is: ${timeoutForSpecifiedMode}`);

			if (!_isNil(timeoutForSpecifiedMode)) {
				nodeDeadTimeout = timeoutForSpecifiedMode;
			}
		}

		if (_isNil(nodeDeadTimeout)) {
			throw new Error(`Unable to obtain node dead timeout of reset mode ${resetMode} for device: ${devicePath}`);
		}

		debug(`[resetLastSeenTimerForDevice] Device ${devicePath} has lastSeenTimer already?: `, this.lastSeenTimersByDeviceNode.has(devicePath));

		if (this.lastSeenTimersByDeviceNode.has(devicePath)) {
			const currentTimer = this.lastSeenTimersByDeviceNode.get(devicePath);
			clearTimeout(currentTimer);
			debug(`[resetLastSeenTimerForDevice] Device ${devicePath} lastSeenTimer REMOVED: `, new Date().toISOString(), currentTimer.customId);
			this.lastSeenTimersByDeviceNode.delete(devicePath);
		}

		const timerCustomId = this._rng.guid({ version: 4 });

		debug(`[resetLastSeenTimerForDevice] Device ${devicePath} lastSeenTimer CREATED: `, new Date().toISOString(), timerCustomId);
		const timerObj = setTimeout(() => {
			debug(`[resetLastSeenTimerForDevice] Device ${devicePath} lastSeenTimer EXPIRED: `, new Date().toISOString(), timerCustomId);
			this.updateNodeState(devicePath, NodeStateMonitor.constants.nodeStates.DEAD);
		}, 1000 * nodeDeadTimeout);

		timerObj.customId = timerCustomId;

		this.lastSeenTimersByDeviceNode.set(devicePath, timerObj);
		debug(`[resetLastSeenTimerForDevice] Finished adding last seen timer Map entry for device ${devicePath}.`, new Date().toISOString(), this.lastSeenTimersByDeviceNode.keys());
	}

	/**
	 * @description Identifies the device model node given nodeId
	 * @param {Number} nodeId The target nodeId
	 * @returns {String} The path to the device node corresponding to the nodeId
	 * @throws {Error}
	 */
	findDevicePathById(nodeId) {
		const devicePath = this.adapter.getBridgeDevicePathById(nodeId);
		if (_isNil(devicePath)) {
			throw new Error('Unable to find device path for this id: ' + nodeId);
		}

		return devicePath;
	}

	removeLastSeenTimerForDevice(devicePath) {
		if (!this.lastSeenTimersByDeviceNode.has(devicePath)) {
			return;
		}

		const currentTimer = this.lastSeenTimersByDeviceNode.get(devicePath);
		clearTimeout(currentTimer);
		this.lastSeenTimersByDeviceNode.delete(devicePath);
	}

	removePingTimerForDevice(devicePath) {
		if (!this.pingTimersByDeviceNode.has(devicePath)) {
			return;
		}

		const currentTimer = this.pingTimersByDeviceNode.get(devicePath);
		clearInterval(currentTimer);
		this.pingTimersByDeviceNode.delete(devicePath);
	}

	removePingTimers() {
		if (!_isNil(this.pingTimersByDeviceNode)) {
			this.pingTimersByDeviceNode
				.forEach((v, devicePath) => this.removePingTimerForDevice(devicePath), this);

			this.pingTimersByDeviceNode.clear();
		}
	}

	removeLastSeenTimers() {
		if (!_isNil(this.lastSeenTimersByDeviceNode)) {
			this.lastSeenTimersByDeviceNode
				.forEach((v, devicePath) => this.removeLastSeenTimerForDevice(devicePath), this);

			this.lastSeenTimersByDeviceNode.clear();
		}
	}

	close(options) {
		debug('[close] Tearing down Node State Monitor: ', options);

		this.closeRoutine.bind(this);

		if (!_isNil(options) &&
			!_isNil(options.force) &&
			options.force === true) {
			this._initCloseQueue.pause();
			this._initCloseQueue.clear();
			this._initCloseQueue.start();
		}

		return this._initCloseQueue.add(() => this.closeRoutine());
	}

	/**
	 * @description Tear down method
	 */
	closeRoutine() {
		if (!_isNil(this._asyncCriticalTaskQueue)) {
			this._asyncCriticalTaskQueue.pause();
			this._asyncCriticalTaskQueue.clear();
			this._asyncCriticalTaskQueue = null;
		}

		this._enableEventEmissions = false;

		this.removePingTimers();
		this.removeLastSeenTimers();
		this.emitter.removeAllListeners();
		this.removeNodeStatusAdapterEventListener();

		if (!_isNil(this.mappingChangeHandler)) {
			this.mappingChangeMonitor.emitter.removeListener(MappingChangeMonitor.constants.MAPPING_CHANGED_EVENT_NAME, this.mappingChangeHandler);
			this.mappingChangeHandler = null;
		}

		if (!_isNil(this.modelChangeHandler)) {
			this.modelChangeMonitor.emitter.removeListener(ModelChangeMonitor.constants.MODEL_NODES_CHANGED_EVENT_NAME, this.modelChangeHandler);
			this.modelChangeHandler = null;
		}

		if (!_isNil(this.failureRecoveryAttemptsByDeviceNode)) {
			this.failureRecoveryAttemptsByDeviceNode.clear();
		}

		this.mappingChangeMonitor.close();
		this.modelChangeMonitor.close();
	}
};

module.exports = NodeStateMonitor;