utilities/mappingChangeMonitor.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 Adapter mapping. Emits
 * events to indicate which devices have experienced changes in their mappings.
 */
class MappingChangeMonitor {
	/**
	 * @constructor
	 * @param {Adapter} adapter
	 */
	constructor(adapter) {
		this.adapter = adapter;
		this.emitter = new events.EventEmitter();
		this.cachedMappingByDevice = new Map();
	}

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

	/**
	 * @description Set up Adapter mapping change listener and initialize
	 * mapping cache.
	 */
	init() {
		this.cacheAllBridgeDeviceMappings();
		this.setupAdapterMappingListener();
	}

	/**
	 * @description Tear down method: unsubscribe adapter mapping change
	 * listener and clear mapping cache.
	 */
	close() {
		this.emitter.removeAllListeners();
		this.removeAdapterMappingListener();

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

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

	/**
	 * @description Creates an EventListener to monitor
	 * changes to Adapter mapping
	 */
	setupAdapterMappingListener() {
		if (!_isNil(this.adapter._emitter)) {
			this.removeAdapterMappingListener();

			this.adapterMappingListener = this.onAdapterMappingChange.bind(this);
			this.adapter._emitter.on('mappingChangedAlert', this.adapterMappingListener);

			this.adapterMappingWatcherClosedListener = this.onAdapterMappingWatcherClosed.bind(this);
			this.adapter._emitter.on('mappingWatcherClosed', this.adapterMappingWatcherClosedListener);
		}
	}

	/**
	 * @description Creates an EventListener to monitor
	 * changes to Adapter mapping
	 * @private
	 */
	onAdapterMappingWatcherClosed() {
		this.removeAdapterMappingListener();
	}

	/**
	 * @description removeAdapterMappingListener Unregisters listener to Adapter
	 * mapping change events
	 */
	removeAdapterMappingListener() {
		if (_isNil(this.adapter._emitter)) {
			return;
		}

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

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

	/**
	 * @description cacheCurrentDeviceMapping Adds the current device mapping
	 * to the cache.
	 * @param {String} devicePath Path to the K4Model node for the device
	 */
	cacheCurrentDeviceMapping(devicePath) {
		try {
			if (_isNil(this.cachedMappingByDevice)) {
				this.adapter.k4.logging.info(`Device mapping cache does not exist for device: ${devicePath}. Creating now.`);
				this.cachedMappingByDevice = new Map();
			}

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

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

	/**
	 * @description onAdapterMappingChanged Handles the cached mapping to current
	 * mapping comparison. Emits an event to all subscribers if the mapping has
	 * actually changed.
	 */
	onAdapterMappingChange() {
		if (_isNil(this.cachedMappingByDevice)) {
			this.adapter.k4.logging.info('Adapter mapping changed, but mapping cache does not exist. Now creating a new cache.');
			this.cachedMappingByDevice = new Map();
		}

		let devicesWithChangedMappings = [];
		devicesWithChangedMappings = devicesWithChangedMappings.concat(
			this.identifyDevicesWithChangedMappings(),
			this.identifyDevicesInCacheNotOnBridge(),
			this.identifyDevicesOnBridgeNotInCache()
		);


		if (devicesWithChangedMappings.length > 0) {
			this.emitter.emit(MappingChangeMonitor.constants.MAPPING_CHANGED_EVENT_NAME, devicesWithChangedMappings);
		}

		// Stop tracking mappings for all devices no longer on the bridge.
		this.pruneMappingsCache();

		// Start tracking mappings for all devices newly added to bridge.
		this.cacheAllBridgeDeviceMappings();
	}

	/**
	 * @description identifyDevicesWithChangedMappings Determines which devices
	 * that exist in the cache and on the bridge whose mappings have changed.
	 * @returns {Object[]} List of devices whose existence has not changed, but
	 * whose mappings may have been modified.
	 */
	identifyDevicesWithChangedMappings() {
		const devicesWhoseMappingChanged = [];
		this.cachedMappingByDevice
			.forEach((cachedMapping, devicePath) => {
				try {
					const currentDeviceMappingObj = this.adapter.extractMappingObjForDevice(devicePath);
					const cachedDeviceMappingObj = JSON.parse(cachedMapping);

					if (!_isEqual(currentDeviceMappingObj, cachedDeviceMappingObj)) {
						if (_isNil(currentDeviceMappingObj) && !_isNil(cachedDeviceMappingObj)) {
							devicesWhoseMappingChanged.push({
								devicePath: devicePath,
								changeType: MappingChangeMonitor.constants.DEVICE_REMOVED,
								fieldsThatChanged: []
							});
						} else if (!_isNil(currentDeviceMappingObj) && _isNil(cachedDeviceMappingObj)) {
							devicesWhoseMappingChanged.push({
								devicePath: devicePath,
								changeType: MappingChangeMonitor.constants.DEVICE_ADDED,
								fieldsThatChanged: []
							});
						} else {
							devicesWhoseMappingChanged.push({
								devicePath: devicePath,
								changeType: MappingChangeMonitor.constants.DEVICE_MODIFIED_IN_PLACE,
								fieldsThatChanged: this.checkWhichFieldsHaveChanged(cachedDeviceMappingObj, currentDeviceMappingObj)
							});
						}
					}
				} catch (e) {
					this.adapter.k4.logging.info(`Was not able to assess mapping change for device: ${devicePath}`);
				}
			}, this);

		return devicesWhoseMappingChanged;
	}

	/**
	 * @description checkWhichFieldsHaveChanged Determine which fields are
	 * actually different between two instances of a device mapping.
	 * @param {Object} previousDeviceMappingObj The previous state of the
	 * mapping for a device.
	 * @param {Object} currentDeviceMappingObj The current state of the mapping
	 * for a device.
	 * @param {String[]} [fieldsToCompare] The potential mapping fields that
	 * could have changed.
	 * @returns {String[]} The list of model fields which have actually changed
	 */
	checkWhichFieldsHaveChanged(previousDeviceMappingObj, currentDeviceMappingObj, fieldsToCompare) {
		if (_isNil(previousDeviceMappingObj) || _isNil(currentDeviceMappingObj)) {
			return [];
		}

		let mappingFieldsToCompare = fieldsToCompare;

		if (_isNil(mappingFieldsToCompare)) {
			const setOfMappingFieldsToCompare = new Set([].concat(
				Object.keys(previousDeviceMappingObj),
				Object.keys(currentDeviceMappingObj)
			));

			mappingFieldsToCompare = [];
			setOfMappingFieldsToCompare.forEach(fieldName => mappingFieldsToCompare.push(fieldName));
		}

		const fieldsToCheckDeepValues = mappingFieldsToCompare.slice();

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

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

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

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

				return false;
			}, this);
	}

	/**
	 * @description identifyDevicesInCacheNotOnBridge Determines which devices
	 * that exist in the cache are no longer on the bridge.
	 * @returns {Object[]} List of devices in the cache and not currently on the
	 * bridge.
	 */
	identifyDevicesInCacheNotOnBridge() {
		const devicesNotOnBridge = [];
		this.cachedMappingByDevice
			.forEach((cachedMapping, devicePath) => {
				try {
					this.adapter.findBridgeDeviceByNode(devicePath);
				} catch (e) {
					this.adapter.k4.logging.info(`Was not able to assess mapping 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: MappingChangeMonitor.constants.DEVICE_REMOVED,
							fieldsThatChanged: []
						});
					}
				}
			}, this);

		return devicesNotOnBridge;
	}

	/**
	 * @description Determines which devices that exist on the bridge have not
	 * yet been cached.
	 * @returns {Object[]} List of devices on the bridge and not currently
	 * cached.
	 */
	identifyDevicesOnBridgeNotInCache() {
		const devicesNotInCache = [];
		Object.keys(this.adapter.getBridgeDevicesList())
			.forEach((bridgeDevicePath) => {
				// Device exists on the bridge, but not in the cache
				if (!this.cachedMappingByDevice.has(bridgeDevicePath)) {
					devicesNotInCache.push({
						devicePath: bridgeDevicePath,
						changeType: MappingChangeMonitor.constants.DEVICE_ADDED,
						fieldsThatChanged: []
					});
				}
			}, this);

		return devicesNotInCache;
	}

	/**
	 * @description Stores all the bridge device mappings in the mappings cache.
	 */
	cacheAllBridgeDeviceMappings() {
		try {
			const devicesOnBridge = this.adapter.getBridgeDevicesList();
			if (!_isNil(devicesOnBridge)) {
				Object.keys(devicesOnBridge)
					.forEach(devicePath => this.cacheCurrentDeviceMapping(devicePath), this);
			}
		} catch (err) {
			this.adapter.k4.logging.info('Was not able to cache device mappings');
		}
	}

	/**
	 * @description Removes cache of mappings for devices that no longer exist
	 * on the bridge (and therefore, the current adapter mapping)
	 */
	pruneMappingsCache() {
		try {
			const devicesOnBridge = this.adapter.getBridgeDevicesList();
			if (!_isNil(devicesOnBridge) && !_isNil(this.cachedMappingByDevice)) {
				const bridgeDevicePathsList = Object.keys(devicesOnBridge);

				this.cachedMappingByDevice
					.forEach((v, cachedDevicePath) => {
						// Remove cached mapping for device if it is no longer
						// on the bridge.
						if (_isNil(bridgeDevicePathsList.find(bridgeDevicePath => _isEqual(bridgeDevicePath, cachedDevicePath)))) {
							this.uncacheDeviceMapping(cachedDevicePath);
						}
					});
			}
		} catch (err) {
			this.adapter.k4.logging.info('Was not able to prune cached device mappings');
		}
	}
};

module.exports = MappingChangeMonitor;