'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;