utilities/modelChangeMonitor.js

'use strict';

const events = require('events');
const _isNil = require('lodash/isNil');
const _isEqual = require('lodash/isEqual');
const _includes = require('lodash/includes');
const _has = require('lodash/has');

/**
 * @class
 * @description Checks for changes in the K4Model nodes. Emits
 * events to indicate which device nodes have changed.
 */

class ModelChangeMonitor {
	/**
	 * @constructor
	 * @param {Adapter} adapter
	 */
	constructor(adapter) {
		this.adapter = adapter;
	}

	/**
	 * @property {Object} constants
	 * @static
	 */
	static get constants() {
		return {
			DEVICE_MODIFIED_IN_PLACE: 'device-modified-in-place',
			DEVICE_REMOVED: 'device-removed',
			DEVICE_ADDED: 'device-added',
			MODEL_NODES_CHANGED_EVENT_NAME: 'device-model-nodes-changed'
		};
	}

	/**
	 * @description Set up K4Model change listeners and initialize model cache.
	 */
	init() {
		this.emitter = new events.EventEmitter();
		this.cachedDeviceNodes = new Map();
		this.cacheAllBridgeDeviceNodes();
		this.setupAdapterDevicesChangeListener();
	}

	/**
	 * @description Creates an EventListener to monitor changes to the device
	 * nodes list
	 */
	setupAdapterDevicesChangeListener() {
		if (_isNil(this.adapter._emitter)) {
			this.adapter.k4.logging.info('Did not set up a devices change listener, because this Adapter has not been configured to have an EventEmitter.');
			return;
		}

		this.removeAdapterDevicesChangeListener();

		this.deviceNodesChangeListener = this.deviceNodesChangedHandler.bind(this);
		this.adapter._emitter.on('deviceNodesChangedAlert', this.deviceNodesChangeListener);

		this.adapterDevicesChangeWatcherClosedListener = this.onAdapterDevicesChangeWatcherClosed.bind(this);
		this.adapter._emitter.on('deviceNodesWatcherClosed', this.adapterDevicesChangeWatcherClosedListener);
	}

	onAdapterDevicesChangeWatcherClosed() {
		this.removeAdapterDevicesChangeListener();
	}

	/**
	 * @description Unregisters listener to Adapter mapping change events
	 */
	removeAdapterDevicesChangeListener() {
		if (_isNil(this.adapter._emitter)) {
			this.adapter.k4.logging.info('Skipping devices change listener removal step, because this Adapter has not been configured to have an EventEmitter.');
			return;
		}

		if (!_isNil(this.deviceNodesChangeListener)) {
			this.adapter._emitter.removeListener('deviceNodesChangedAlert', this.deviceNodesChangeListener);
			this.deviceNodesChangeListener = null;
		}

		if (!_isNil(this.adapterDevicesChangeWatcherClosedListener)) {
			this.adapter._emitter.removeListener('deviceNodesWatcherClosed', this.adapterDevicesChangeWatcherClosedListener);
			this.adapterDevicesChangeWatcherClosedListener = null;
		}
	}

	/**
	 * @description Adds the current device model to the cache.
	 * @param {String} devicePath Path to the K4Model node for the device
	 */
	cacheCurrentDeviceModelNode(devicePath) {
		try {
			const currentDevice = this.adapter.findBridgeDeviceByNode(devicePath);

			if (_isNil(this.cachedDeviceNodes)) {
				this.adapter.k4.logging.info(`Device model cache does not exist for device: ${devicePath}. Creating now.`);
				this.cachedDeviceNodes = new Map();
			}

			const deviceModelJSON = JSON.stringify(currentDevice.model.value(), null, 4);
			this.cachedDeviceNodes.set(devicePath, deviceModelJSON);
		} catch (e) {
			this.adapter.k4.logging.error(`Unable to cache device model for device at: ${devicePath}. Error is: ${e.toString()}`, e instanceof Error ? e.stack.split('\n') : null);
		}
	}

	/**
	 * @description Removes the current device model from the cache.
	 * @param {String} devicePath Path to the K4Model node for the device
	 */
	uncacheDeviceModel(devicePath) {
		if (!_isNil(this.cachedDeviceNodes) && this.cachedDeviceNodes.has(devicePath)) {
			this.cachedDeviceNodes.delete(devicePath);
		}
	}

	deviceNodesChangedHandler() {
		this.adapter.k4.logging.info('Detected devices node change');

		if (_isNil(this.cachedDeviceNodes)) {
			this.adapter.k4.logging.info('Device model nodes changed, but model cache does not exist. Now creating a new cache.');
			this.cachedDeviceNodes = new Map();
		}

		let devicesWithChangedNodes = [];
		devicesWithChangedNodes = devicesWithChangedNodes.concat(
			this.identifyDevicesWithChangedNodes(),
			this.identifyDevicesInCacheNotOnBridge(),
			this.identifyDevicesOnBridgeNotInCache()
		);

		if (devicesWithChangedNodes.length > 0) {
			this.emitter.emit(ModelChangeMonitor.constants.MODEL_NODES_CHANGED_EVENT_NAME, devicesWithChangedNodes);
		}

		// Stop tracking device nodes no longer on the bridge.
		this.pruneDeviceNodesCache();

		// Start tracking device nodes newly added to bridge.
		this.cacheAllBridgeDeviceNodes();
	}

	/**
	 * @description Compare last known snapshots of device nodes with the current
	 * state-of-things, and indicate what has changed
	 * @returns {Object[]}
	 */
	identifyDevicesWithChangedNodes() {
		const deviceNodesThatChanged = [];
		this.cachedDeviceNodes
			.forEach((cachedNode, devicePath) => {
				try {
					const currentDevice = this.adapter.findBridgeDeviceByNode(devicePath);
					const currentDeviceNodeObj = JSON.parse(JSON.stringify(currentDevice.model.value(), null, 4));
					const cachedDeviceNodeObj = JSON.parse(cachedNode);

					if (!_isEqual(currentDeviceNodeObj, cachedDeviceNodeObj)) {
						const setOfModelFieldsToCompare = new Set([].concat(
							['commands', 'properties', 'events', 'variables'],
							Object.keys(cachedDeviceNodeObj),
							Object.keys(currentDeviceNodeObj)
						));

						const modelFieldsToCompare = [];
						setOfModelFieldsToCompare.forEach(fieldName => modelFieldsToCompare.push(fieldName));

						const changedModelFields = this.checkWhichModelFieldsHaveChanged(cachedDeviceNodeObj, currentDeviceNodeObj, modelFieldsToCompare);
						if (!_isNil(changedModelFields) && changedModelFields.length > 0) {
							deviceNodesThatChanged.push({
								devicePath: devicePath,
								changeType: ModelChangeMonitor.constants.DEVICE_MODIFIED_IN_PLACE,
								fieldsThatChanged: changedModelFields
							});
						}
					}
				} catch (e) {
					this.adapter.k4.logging.info(`Was not able to assess model node change for device: ${devicePath}`);
				}
			}, this);

		return deviceNodesThatChanged;
	}

	/**
	 * @description Find devices that there is a snapshot for, but do not exist
	 * on the bridge currently
	 * @returns {Object[]}
	 */
	identifyDevicesInCacheNotOnBridge() {
		const devicesNotOnBridge = [];
		this.cachedDeviceNodes
			.forEach((cachedModel, devicePath) => {
				try {
					this.adapter.findBridgeDeviceByNode(devicePath);
				} catch (e) {
					this.adapter.k4.logging.info(`Was not able to assess model node change for device: ${devicePath}`);
					if (e.toString().includes('Device does not exist on bridge')) {
						// Device exists in the cache but not on the bridge
						devicesNotOnBridge.push({
							devicePath: devicePath,
							changeType: ModelChangeMonitor.constants.DEVICE_REMOVED,
							fieldsThatChanged: []
						});
					}
				}
			}, this);

		return devicesNotOnBridge;
	}

	/**
	 * @description Find devices that exist on the bridge, but do not yet have
	 * a snapshot for
	 * @returns {Object[]}
	 */
	identifyDevicesOnBridgeNotInCache() {
		const devicesNotInCache = [];
		Object.keys(this.adapter.getBridgeDevicesList())
			.forEach((bridgeDevicePath) => {
				// Device exists on the bridge, but not in the cache
				if (!this.cachedDeviceNodes.has(bridgeDevicePath)) {
					devicesNotInCache.push({
						devicePath: bridgeDevicePath,
						changeType: ModelChangeMonitor.constants.DEVICE_ADDED,
						fieldsThatChanged: []
					});
				}
			}, this);

		return devicesNotInCache;
	}

	cacheAllBridgeDeviceNodes() {
		try {
			const devicesOnBridge = this.adapter.getBridgeDevicesList();
			if (!_isNil(devicesOnBridge)) {
				Object.keys(devicesOnBridge)
					.filter(devicePath => devicesOnBridge[devicePath].isTemporaryDevice === false)
					.forEach(devicePath => this.cacheCurrentDeviceModelNode(devicePath), this);
			}
		} catch (err) {
			this.adapter.k4.logging.info(`Was not able to cache device node: ${err.toString()}`, err instanceof Error ? err.stack.split('\n') : null);
		}
	}

	/**
	 * @description Removes cache of devices nodes that no longer exist on the
	 * bridge.
	 */
	pruneDeviceNodesCache() {
		try {
			const devicesOnBridge = this.adapter.getBridgeDevicesList();
			if (!_isNil(devicesOnBridge) && !_isNil(this.cachedDeviceNodes)) {
				const bridgeDevicePathsList = Object.keys(devicesOnBridge);

				this.cachedDeviceNodes
					.forEach((v, cachedDevicePath) => {
						if (_isNil(bridgeDevicePathsList.find(bridgeDevicePath => _isEqual(bridgeDevicePath, cachedDevicePath)))) {
							this.cachedDeviceNodes.delete(cachedDevicePath);
						}
					});
			}
		} catch (err) {
			this.adapter.k4.logging.info(`Was not able to prune cached device nodes: ${err.toString()}`, err instanceof Error ? err.stack.split('\n') : null);
		}
	}

	/**
	 * @description Determine which fields are actually different between two
	 * instances of a device node.
	 * @param {Object} oldDeviceNodeObj The previous state of the model node for a
	 * device.
	 * @param {Object} currentDeviceNodeObj The current state of the model node for a
	 * device.
	 * @param {String[]} [modelFieldsToCompare] The potential model fields that
	 * could have changed.
	 * @returns {String[]} The list of model fields which have actually changed
	 */
	checkWhichModelFieldsHaveChanged(oldDeviceNodeObj, currentDeviceNodeObj, modelFieldsToCompare) {
		let modelFieldsToCheck = modelFieldsToCompare;
		if (_isNil(modelFieldsToCheck)) {
			modelFieldsToCheck = ['commands', 'properties', 'events', 'variables'];
		}

		const fieldsToCheckDeepValues = ['commands', 'properties', 'events'];

		return modelFieldsToCheck
			.filter((fieldName) => {
				if (_has(oldDeviceNodeObj, fieldName)) {
					if (!_has(currentDeviceNodeObj, fieldName)) {
						this.adapter.k4.logging.debug(`Field ${fieldName} exists on cached node, but not on current node.`);
						return true;
					}

					const currentDeviceField = currentDeviceNodeObj[fieldName];
					const currentDeviceFieldNames = Object.keys(currentDeviceField);

					const cachedDeviceField = oldDeviceNodeObj[fieldName];
					const cachedDeviceFieldNames = Object.keys(cachedDeviceField);

					if (_includes(fieldsToCheckDeepValues, fieldName) && !_isEqual(currentDeviceField, cachedDeviceField)) {
						this.adapter.k4.logging.debug(`Device node children deep changed FROM: ${JSON.stringify(cachedDeviceField, null, '\t')}...TO: ${JSON.stringify(currentDeviceField, null, '\t')}`);
						return true;
					} else if (!_isEqual(currentDeviceFieldNames, cachedDeviceFieldNames)) {
						this.adapter.k4.logging.debug(`Device node children names changed FROM: ${cachedDeviceFieldNames}...TO: ${currentDeviceFieldNames}`);
						return true;
					}
				} else if (_has(currentDeviceNodeObj, fieldName)) { // Current field exists, cached field does not
					this.adapter.k4.logging.debug(`Field ${fieldName} exists on current node, but not on cached node.`);
					return true;
				}

				return false;
			}, this);
	}

	/**
	 * @description close Tear down method: unsubscribe device model change
	 * listeners and clear device model cache.
	 */
	close() {
		if (!_isNil(this.emitter)) {
			this.emitter.removeAllListeners();
		}

		this.removeAdapterDevicesChangeListener();

		const devicesList = this.adapter.getBridgeDevicesList();
		if (!_isNil(devicesList)) {
			Object.keys(devicesList)
				.forEach((devicePath) => {
					this.uncacheDeviceModel(devicePath);
				}, this);
		}

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

module.exports = ModelChangeMonitor;