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