'use strict';
const _isEqual = require('lodash/isEqual');
const _isNil = require('lodash/isNil');
const _isObject = require('lodash/isObject');
const _has = require('lodash/has');
const events = require('events');
const callbackManager = require('async');
const {default: PromiseQueue} = require('p-queue');
let TimingUtility;
let MappingChangeMonitor;
let ModelChangeMonitor;
/**
* @class
* @description Plugin to manage setting device configuration values.
*/
class Configurator {
/**
* @constructor
* @param {Object} [opts] General settings for Configurator
* @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._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;
}
/**
* @property {Object} constants
* @static
*/
static get constants() {
return {
errorTypes: {
RESPONSE_FALSE: new Error('RESPONSE_FALSE'),
TIMEOUT: new Error('TIMEOUT')
},
eventNames: {
REAL_TIME_CONFIG_RESPONSE: 'received--realTimeResponseHandler',
CONFIGURE_DEVICE_METHOD_RESPONSE: 'received--configureDevice-method'
},
misc: {
REAL_TIME_RECONFIGURATION_DEBOUNCE_MSEC: 2000,
DEFAULT_RETRY_LIMIT: 3,
DEFAULT_CONFIG_COMMAND_TIMEOUT_MSEC: 5000,
DEFAULT_RETRY_OVERHANG_MSEC: 30
},
modelFieldNames: {
CONFIGURATION_SYNCED_VARIABLE_NAME: 'configurationSynced'
}
};
}
/**
* @description This method should be named as 'init' because Adapter
* instance calls the 'init' methods (if they exist) for all Adapter
* plugins
*/
async init() {
// Run tear down routine to ensure clean working state
await this.close();
this.configParamSyncedStatusesByDevice = new Map();
this.setupAllDevicesSyncStatusTrackers();
// FIXME: Mapping and model changes
this.mappingChangeMonitor.init();
this.mappingChangeHandler = this.onMappingChanged.bind(this);
this.mappingChangeMonitor.emitter.on(MappingChangeMonitor.constants.MAPPING_CHANGED_EVENT_NAME, this.mappingChangeHandler);
this.modelChangeMonitor.init();
this.modelChangeHandler = this.onModelChanged.bind(this);
this.modelChangeMonitor.emitter.on(ModelChangeMonitor.constants.MODEL_NODES_CHANGED_EVENT_NAME, this.modelChangeHandler);
this.asyncTaskQueue = new PromiseQueue({
concurrency: 1,
autoStart: true
});
this.paramsInProgressOfBeingConfigured = new Map();
// NOTE: Making the real-time configuration syncing optional
let enableRealTimeConfigSyncing = false;
if (!_isNil(this._opts) &&
!_isNil(this._opts.enableRealTimeConfigSyncing)) {
enableRealTimeConfigSyncing = this._opts.enableRealTimeConfigSyncing;
}
if (enableRealTimeConfigSyncing === true) {
this.realTimeConfigResponseHandler = this.onConfigResponseReceived.bind(this);
this.emitter.on(Configurator.constants.eventNames.REAL_TIME_CONFIG_RESPONSE, this.realTimeConfigResponseHandler);
}
}
/**
* @description Tear down method for all configurator listeners
*/
async close() {
if (!_isNil(this.paramsInProgressOfBeingConfigured)) {
this.paramsInProgressOfBeingConfigured.clear();
}
if (!_isNil(this.realTimeConfigResponseHandler)) {
this.emitter.removeListener(Configurator.constants.eventNames.REAL_TIME_CONFIG_RESPONSE, this.realTimeConfigResponseHandler);
this.realTimeConfigResponseHandler = null;
}
this.emitter.removeAllListeners(Configurator.constants.eventNames.CONFIGURE_DEVICE_METHOD_RESPONSE);
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;
}
this.mappingChangeMonitor.close();
this.modelChangeMonitor.close();
if (!_isNil(this.configParamSyncedStatusesByDevice)) {
this.configParamSyncedStatusesByDevice.clear();
}
if (!_isNil(this.asyncTaskQueue)) {
this.asyncTaskQueue.pause();
this.asyncTaskQueue.clear();
return this.asyncTaskQueue.onIdle();
}
}
/**
* @description Update configuration if the device mapping
* has changed. Assumes this is an existing device whose mapping has been
* modified. Also, device should have been configured at least once before.
* @param {Object[]} devicesWithChangedMappings
*/
onMappingChanged(devicesWithChangedMappings) {
devicesWithChangedMappings
.forEach((deviceChangeObj) => {
try {
switch (deviceChangeObj.changeType) {
case MappingChangeMonitor.constants.DEVICE_ADDED:
this.adapter.k4.logging.info(`Configurator -- Device mapping was added: ${deviceChangeObj.devicePath}`);
let configureDeviceOnAdditionToBridge = false;
if (!_isNil(this._opts) &&
!_isNil(this._opts.configureDeviceOnAdditionToBridge)) {
configureDeviceOnAdditionToBridge = this._opts.configureDeviceOnAdditionToBridge;
}
if (configureDeviceOnAdditionToBridge === true) {
const currentDevice = this.adapter.findBridgeDeviceByNode(deviceChangeObj.devicePath);
this.configParamSyncStatusRenewal(currentDevice);
this.asyncTaskQueue.add(() => this.configureDevice(currentDevice))
.catch(e => this.adapter.k4.logging.system(`Unable to successfully execute configureDevice ${deviceChangeObj.devicePath} on mapping change. ${e.toString()}`, e instanceof Error ? e.stack.split('\n') : null));
}
break;
case MappingChangeMonitor.constants.DEVICE_MODIFIED_IN_PLACE: {
this.adapter.k4.logging.info(`Configurator -- Device mapping was changed: ${deviceChangeObj.devicePath}`);
const currentDevice = this.adapter.findBridgeDeviceByNode(deviceChangeObj.devicePath);
this.configParamSyncStatusRenewal(currentDevice);
this.asyncTaskQueue.add(() => this.configureDevice(currentDevice))
.catch(e => this.adapter.k4.logging.system(`Unable to successfully execute configureDevice ${deviceChangeObj.devicePath} on mapping change. ${e.toString()}`, e instanceof Error ? e.stack.split('\n') : null));
} break;
case MappingChangeMonitor.constants.DEVICE_REMOVED:
this.adapter.k4.logging.info(`Configurator -- Device mapping was removed: ${deviceChangeObj.devicePath}`);
break;
default:
break;
}
} catch (e) {
this.adapter.k4.logging.system(`Unable to prepare and/or issue device re-configuration ${deviceChangeObj.devicePath} on mapping change. ${e.toString()}`, e instanceof Error ? e.stack.split('\n') : null);
}
}, this);
}
/**
* @description Handler/wrapper for tasks on device nodes
* changes. Currently triggers re-configure if the commands, properties,
* events, or variable field names change.
* @param {Object[]} deviceNodesThatChanged The device to configure
*/
onModelChanged(deviceNodesThatChanged) {
deviceNodesThatChanged
.forEach((deviceNodeChangeObj) => {
try {
switch (deviceNodeChangeObj.changeType) {
case ModelChangeMonitor.constants.DEVICE_ADDED: {
this.adapter.k4.logging.info(`Configurator -- Device node was added: ${deviceNodeChangeObj.devicePath}`);
const currentDevice = this.adapter.findBridgeDeviceByNode(deviceNodeChangeObj.devicePath);
this.adapter.k4.logging.debug('Attempting to re-configure device on model change...');
this.configParamSyncStatusRenewal(currentDevice);
this.asyncTaskQueue.add(() => this.configureDevice(currentDevice))
.catch(e => this.adapter.k4.logging.system(`Unable to successfully execute configureDevice ${deviceNodeChangeObj.devicePath} on model change. ${e.toString()}`, e instanceof Error ? e.stack.split('\n') : null));
} break;
case ModelChangeMonitor.constants.DEVICE_MODIFIED_IN_PLACE: {
this.adapter.k4.logging.info(`Configurator -- Device node was changed: ${deviceNodeChangeObj.devicePath}`);
const currentDevice = this.adapter.findBridgeDeviceByNode(deviceNodeChangeObj.devicePath);
// NOTE: Making the trigger for re-configuring on device node change
// stricter, so that only changes in model properties will cause it
if (!_isNil(deviceNodeChangeObj.fieldsThatChanged.find(modelField => modelField === 'properties'))) {
this.adapter.k4.logging.debug('Attempting to re-configure device on model change...');
this.configParamSyncStatusRenewal(currentDevice);
this.asyncTaskQueue.add(() => this.configureDevice(currentDevice))
.catch(e => this.adapter.k4.logging.system(`Unable to successfully execute configureDevice ${deviceNodeChangeObj.devicePath} on model change. ${e.toString()}`, e instanceof Error ? e.stack.split('\n') : null));
}
} break;
case ModelChangeMonitor.constants.DEVICE_REMOVED:
this.adapter.k4.logging.info(`Configurator -- Device node was removed: ${deviceNodeChangeObj.devicePath}`);
break;
default:
break;
}
} catch (e) {
this.adapter.k4.logging.system(`Unable to prepare and/or issue device re-configuration ${deviceNodeChangeObj.devicePath} on device nodes change. ${e.toString()}`, e instanceof Error ? e.stack.split('\n') : null);
}
});
}
setupAllDevicesSyncStatusTrackers() {
const devicesOnBridge = this.adapter.getBridgeDevicesList();
Object.keys(devicesOnBridge)
.map(devicePath => devicesOnBridge[devicePath])
.forEach((device) => {
this.configParamSyncStatusRenewal(device);
});
}
/**
* @description This is the entry point for a consumer of
* configurator services. It is only run once, because listeners are set
* up and are able to self-manage from then onwards.
* @param {Device} device The device to configure
* @returns {Promise} If configuration succeeds, resolves to true
*/
async setupDeviceConfig(device) {
// Send appropriate configuration commands with arguments
const retObj = await this.asyncTaskQueue.add(() => this.configureDevice(device));
this.configParamSyncStatusRenewal(device);
return retObj.configSucceeded;
}
/**
* @description Acts as beacon; on a response, adapter will call
* this, which will then notify other configurator listeners
* @param {Response} response The pre-initialized response object
* @param {Device} device The device associated with the response
*/
signalReceived(response, device) {
this.emitter.emit(Configurator.constants.eventNames.REAL_TIME_CONFIG_RESPONSE, response, device);
this.emitter.emit(Configurator.constants.eventNames.CONFIGURE_DEVICE_METHOD_RESPONSE, response, device);
}
/**
* @description The real-time handler for configuration
* related responses. Will update configuration sync statuses, and attempt
* to reconfigure if necessary.
* @param {Response} response The pre-initialized response object
* @param {Device} device The device associated with the response
*/
async onConfigResponseReceived(response, device) {
try {
if (_isNil(device) ||
_isNil(device._mapping) ||
_isNil(device._mapping.config) ||
_isNil(device._mapping.config.commands)) {
this.adapter.k4.logging.warning(`[Configurator] Skipping real-time response check for response ${response.name} because Device ${device.devicePath || 'NIL'} does not have any config mapping commands`);
return;
}
const matchingConfigParam = this.extractConfigCommands(device)
.find(configParam => this.checkIfConfigParamMatchesResponse(response, configParam), this);
if (_isNil(matchingConfigParam)) {
this.adapter.k4.logging.warning(`Did not find a matching configuration parameter for response name ${response.name} of device ${device.devicePath}`);
return;
}
const currentParamSyncStatus = this.verifyConfigResponse(response, device, [matchingConfigParam]).configWasCorrect;
this.configParamSyncStatusRenewal(device);
this.updateConfigParamSyncStatus(device, matchingConfigParam, currentParamSyncStatus, response.value);
const allConfigParamsSyncStatus = this.checkAllConfigParamsSynced(device);
if (_isNil(allConfigParamsSyncStatus)) {
this.adapter.k4.logging.warning(`The holistic config parameter sync status was not defined for device ${device.devicePath}`);
return;
}
const allParamsSynced = allConfigParamsSyncStatus[0];
const aberrantConfigParams = allConfigParamsSyncStatus[1];
// NOTE: Update the K4Model with the current overall configuration sync status
this.adapter.k4.model.child(`${device.devicePath}/variables/${Configurator.constants.modelFieldNames.CONFIGURATION_SYNCED_VARIABLE_NAME}`).set(allParamsSynced);
if (allParamsSynced !== false) {
return;
}
this.adapter.k4.logging.info(`[Configurator] On config response received, found aberrant config params to resync: ${aberrantConfigParams.map(p => JSON.stringify(p, null, '\t'))}`);
// NOTE: Goal here is to not re-issue configuration for
// config parameters that are unsynced, but are already
// being re-configured at this very instant.
let paramsToReconfigure = aberrantConfigParams;
if (!_isNil(this.paramsInProgressOfBeingConfigured) && this.paramsInProgressOfBeingConfigured.has(device.devicePath) && !_isNil(this.paramsInProgressOfBeingConfigured.get(device.devicePath))) {
const paramsInProgressForCurrentDevice = this.paramsInProgressOfBeingConfigured.get(device.devicePath)
.map(confParam => this.getParedConfigParamFromFull(confParam));
const whichAberrantParamsAreNotAlreadyBeingConfigured = aberrantConfigParams
.map(aberrantCfgParam => this.getParedConfigParamFromFull(aberrantCfgParam))
.filter((paredAberrantParam) => {
const isAlreadyBeingConfigured = paramsInProgressForCurrentDevice.find(paramInProgress => _isEqual(paredAberrantParam, paramInProgress));
return !isAlreadyBeingConfigured;
});
if (!_isNil(whichAberrantParamsAreNotAlreadyBeingConfigured)) {
paramsToReconfigure = whichAberrantParamsAreNotAlreadyBeingConfigured;
}
}
if (_isNil(paramsToReconfigure) ||
paramsToReconfigure.length === 0) {
this.adapter.k4.logging.info('Config parameters are not all synced, but the unsynced parameters are already in the process of re-configuration');
return;
}
await TimingUtility.setTimeoutPromise(Configurator.constants.misc.REAL_TIME_RECONFIGURATION_DEBOUNCE_MSEC);
await this.asyncTaskQueue.add(async () => {
const allParamsSyncedCheck = this.checkAllConfigParamsSynced(device);
if (!_isNil(allParamsSyncedCheck) &&
allParamsSyncedCheck[0] === true) {
return true;
}
return this.configureDevice(
device,
paramsToReconfigure.map(paredAberrantParam => this.getFullConfigParamFromPared(paredAberrantParam, device), this)
);
});
} catch (err) {
this.adapter.k4.logging.error(`Error on re-configuring device ${device.devicePath} on config response received`, err instanceof Error ? err.stack.split('\n') : err.toString());
}
}
getFullConfigParamFromPared(paredConfigParam, device) {
return this.extractConfigCommands(device)
.find(fullConfigParam => (fullConfigParam.command === paredConfigParam.command) &&
(fullConfigParam.response === paredConfigParam.response) &&
_isEqual(fullConfigParam.arguments, paredConfigParam.arguments), this);
}
getParedConfigParamFromFull(fullConfigParam) {
return {
command: fullConfigParam.command,
response: fullConfigParam.response,
arguments: fullConfigParam.arguments
};
}
// NOTE: This does not refresh the config parameters sync statuses, it only
// reduces the existing statuses to an overall status
checkAllConfigParamsSynced(device) {
if (!this.configParamSyncedStatusesByDevice.has(device.devicePath)) {
this.adapter.k4.logging.system(`Device ${device.devicePath} config parameter statuses tracker does not exist`);
return null;
}
if (this.configParamSyncedStatusesByDevice.get(device.devicePath).length === 0) {
this.adapter.k4.logging.system(`Device ${device.devicePath} has ZERO config parameters statuses tracked`);
return null;
}
return this.configParamSyncedStatusesByDevice.get(device.devicePath)
.reduce((syncStatusAccumulator, configParamStatusObj) => {
const allSynced = syncStatusAccumulator[0];
const deviantConfigParams = syncStatusAccumulator[1];
const currentConfigParamStatus = configParamStatusObj.syncStatus;
const currentConfigParam = configParamStatusObj.configParam;
if (currentConfigParamStatus === false) {
deviantConfigParams.push(currentConfigParam);
}
return [allSynced && currentConfigParamStatus, deviantConfigParams];
}, [true, []]);
}
getConfigParamSyncStatus(device, configParamToRetrieve) {
if (!this.configParamSyncedStatusesByDevice.has(device.devicePath)) {
this.configParamSyncedStatusesByDevice.set(device.devicePath, []);
}
const paredDownConfigParam = this.getParedConfigParamFromFull(configParamToRetrieve);
const targetParamStatusObj = this.configParamSyncedStatusesByDevice.get(device.devicePath)
.find(paramStatusObj => _isEqual(paramStatusObj.configParam, paredDownConfigParam), this);
if (_isNil(targetParamStatusObj)) {
throw new Error(`Sync status object for config param ${JSON.stringify(paredDownConfigParam)} was not found`);
}
return targetParamStatusObj;
}
configParamSyncStatusRenewal(device) {
this.pruneConfigParamSyncStatuses(device);
this.addUntrackedConfigParamSyncStatuses(device);
this.refreshAllConfigParamSyncStatuses(device);
}
pruneConfigParamSyncStatuses(device) {
if (!this.configParamSyncedStatusesByDevice.has(device.devicePath)) {
this.adapter.k4.logging.info(`Device ${device.devicePath} config parameter statuses tracker does not exist. Will not prune.`);
return;
}
if (this.configParamSyncedStatusesByDevice.get(device.devicePath).length === 0) {
this.adapter.k4.logging.info(`Device ${device.devicePath} has ZERO config parameters statuses tracked. Will not prune.`);
return;
}
const syncStatusesForCurrentDevice = this.configParamSyncedStatusesByDevice.get(device.devicePath);
const syncStatusObjsToKeep = [];
// NOTE: Preserving the statuses of only the config parameters that are
// in the mapping. Will discard any sync status objects that do not
// exist on the mapping.
if (_isNil(device) ||
_isNil(device._mapping) ||
_isNil(device._mapping.config) ||
_isNil(device._mapping.config.commands)) {
if (!_isNil(device) && !_isNil(device.model)) {
this.adapter.k4.logging.warning(`[Configurator] Skipping pruneConfigParamSyncStatuses() because Device ${device.devicePath} mapping does not have any config commands`);
} else {
this.adapter.k4.logging.warning(`[Configurator] Skipping pruneConfigParamSyncStatuses() because mapping for NIL Device does not have any config commands`);
}
return;
}
try {
this.extractConfigCommands(device)
.map(mappingConfigParam => this.getParedConfigParamFromFull(mappingConfigParam))
.forEach((paredMappingCfgParam) => {
const getSyncStatusObjOrNull = syncStatusesForCurrentDevice.find(syncStatusObj => _isEqual(syncStatusObj.configParam, paredMappingCfgParam));
if (!_isNil(getSyncStatusObjOrNull)) {
syncStatusObjsToKeep.push(getSyncStatusObjOrNull);
}
});
this.configParamSyncedStatusesByDevice.set(device.devicePath, syncStatusObjsToKeep);
} catch (e) {
this.adapter.k4.logging.info(`Issues encountered when pruning config paramters to sync status tracker for Device ${device.devicePath}. Error is: ${e.toString()}`, e instanceof Error ? e.stack.split('\n') : null);
}
}
addUntrackedConfigParamSyncStatuses(device) {
if (!this.configParamSyncedStatusesByDevice.has(device.devicePath)) {
this.configParamSyncedStatusesByDevice.set(device.devicePath, []);
this.adapter.k4.logging.info(`Device ${device.devicePath} config parameter statuses tracker does not exist. Will create the tracker.`);
}
const syncStatusesForCurrentDevice = this.configParamSyncedStatusesByDevice.get(device.devicePath);
if (_isNil(device) ||
_isNil(device._mapping) ||
_isNil(device._mapping.config) ||
_isNil(device._mapping.config.commands)) {
if (!_isNil(device) && !_isNil(device.model)) {
this.adapter.k4.logging.warning(`[Configurator] Skipping addUntrackedConfigParamSyncStatuses() because Device ${device.devicePath} mapping does not have any config commands`);
} else {
this.adapter.k4.logging.warning(`[Configurator] Skipping addUntrackedConfigParamSyncStatuses() because mapping for NIL Device does not have any config commands`);
}
return;
}
try {
this.extractConfigCommands(device)
.map(mappingConfigParam => this.getParedConfigParamFromFull(mappingConfigParam))
.forEach((paredMappingCfgParam) => {
// If current config parameter from mapping does not exist in
// the sync statuses tracker, then add it and set its sync
// status to false with an unknown (null) mostRecentValue.
const getSyncStatusObjOrNull = syncStatusesForCurrentDevice.find(syncStatusObj => _isEqual(syncStatusObj.configParam, paredMappingCfgParam));
if (_isNil(getSyncStatusObjOrNull)) {
this.updateConfigParamSyncStatus(device, paredMappingCfgParam, false, null);
}
});
} catch (e) {
this.adapter.k4.logging.info(`Issues encountered when adding new config paramters to sync status tracker for Device ${device.devicePath}. Error is: ${e.toString()}`, e instanceof Error ? e.stack.split('\n') : null);
}
}
refreshAllConfigParamSyncStatuses(device) {
if (!this.configParamSyncedStatusesByDevice.has(device.devicePath)) {
this.adapter.k4.logging.info(`Device ${device.devicePath} config parameter statuses tracker does not exist. Will not prune.`);
return;
}
if (this.configParamSyncedStatusesByDevice.get(device.devicePath).length === 0) {
this.adapter.k4.logging.info(`Device ${device.devicePath} has ZERO config parameters statuses tracked. Will not refresh.`);
return;
}
const syncStatusesForCurrentDevice = this.configParamSyncedStatusesByDevice.get(device.devicePath);
// NOTE: This assumes that the config param sync statuses are pruned,
// and one-to-one with the config params that exist in the mapping.
const refreshedSyncStatusesForDevice = syncStatusesForCurrentDevice
.map((syncStatusObj) => {
const fullConfigParam = this.getFullConfigParamFromPared(syncStatusObj.configParam, device);
const updatedSyncStatusObj = syncStatusObj;
updatedSyncStatusObj.syncStatus = this.validateConfigParamValue(device, fullConfigParam, syncStatusObj.mostRecentValue).allSyncedForParam;
return updatedSyncStatusObj;
});
this.configParamSyncedStatusesByDevice.set(device.devicePath, refreshedSyncStatusesForDevice);
}
updateConfigParamSyncStatus(device, configParamToUpdate, syncStatusToUpdate, latestValueSet) {
if (!this.configParamSyncedStatusesByDevice.has(device.devicePath)) {
this.configParamSyncedStatusesByDevice.set(device.devicePath, []);
}
const paredDownConfigParam = this.getParedConfigParamFromFull(configParamToUpdate);
const trackedConfigParamsList = this.configParamSyncedStatusesByDevice.get(device.devicePath);
const paramStatusObj = {
configParam: paredDownConfigParam,
syncStatus: syncStatusToUpdate,
mostRecentValue: latestValueSet
};
// Keep all the config parameter sync trackers that are not the
// one whose status is being updated
let updatedTrackedConfigParamsList = trackedConfigParamsList
.filter(parameterStatusObj => !_isEqual(parameterStatusObj.configParam, paredDownConfigParam), this);
updatedTrackedConfigParamsList.push(paramStatusObj);
this.configParamSyncedStatusesByDevice.set(device.devicePath, updatedTrackedConfigParamsList);
}
checkIfConfigParamMatchesResponse(configResponse, configParam) {
if (configParam.response !== configResponse.name) {
return false;
}
if (_isNil(configResponse.arguments)) {
this.adapter.k4.logging.warning(`Config Response object (${configResponse.name}) does not have required arguments getter`);
return false;
}
// NOTE: Small caveat here. The ordering of key-value pairs in the
// response arguments object and the configParam arguments object
// is not necessarily the same. Should not affect the current usage,
// however.
const configParamNonValueArguments = {};
const configResponseNonValueArguments = {};
Object.keys(configResponse.arguments)
.filter(argName => (argName !== 'configurationValue' && argName !== 'default'), this)
.forEach((argName) => {
configResponseNonValueArguments[argName] = configResponse.arguments[argName];
});
Object.keys(configParam.arguments)
.filter(argName => (argName !== 'configurationValue' && argName !== 'default'), this)
.forEach((argName) => {
configParamNonValueArguments[argName] = configParam.arguments[argName];
});
const doesConfigFormatMatch = _isEqual(
Object.keys(configParamNonValueArguments).sort(),
Object.keys(configResponseNonValueArguments).sort()
);
const doConfigResponseArgsMatch = Object.keys(configResponseNonValueArguments)
.reduce((allMatched, currentArgName) => {
return allMatched &&
(configResponseNonValueArguments[currentArgName] === configParamNonValueArguments[currentArgName])
}, true);
return (doesConfigFormatMatch && doConfigResponseArgsMatch);
}
/**
* @description Checks that the specified configuration
* parameter value is correct, as defined by the mapping and model settings
* @param {Device} device
* @param {Object} configParam
* @param {any} actualValue
* @returns {Object}
*/
validateConfigParamValue(device, configParam, actualValue) {
let allSyncedForParam = null;
const cmdArgs = configParam.arguments;
if (_has(cmdArgs, 'configurationValue')) {
const expectedConfigValue = this.obtainConfigArgVal('configurationValue', device, configParam);
if (_isNil(expectedConfigValue)) {
allSyncedForParam = true;
} else {
allSyncedForParam = (actualValue === expectedConfigValue);
}
return {
allSyncedForParam: allSyncedForParam,
configParam: configParam,
actualConfigValue: actualValue,
expectedConfigValue: expectedConfigValue
};
}
this.adapter.k4.logging.info(`Skipping config verification because mapping does not specify config value for this device: ${device.devicePath}`);
return {
allSyncedForParam: allSyncedForParam,
configParam: configParam
};
}
/**
* @description Helper function to check latest
* configuration against the K4Model
* @param response The pre-initialized response object
* @param device The device associated with the response
* @param {Array} [relevantConfigParams] The specific config parameters to
* inspect. Will default to all config parameters for this device.
* @returns {Object} Contains the Boolean checked status of whether the actual
* and expected config values matched, and a list of Objects containing the
* raw expected and actual config values.
*/
verifyConfigResponse(response, device, relevantConfigParams) {
const configParams = this.extractConfigCommands(device);
let relevantParams = relevantConfigParams;
if (_isNil(relevantParams)) {
relevantParams = configParams
.filter(configParam => this.checkIfConfigParamMatchesResponse(response, configParam), this);
}
const configParamValidatedStatusList = relevantParams
.map(configParam => this.validateConfigParamValue(device, configParam, response.value), this);
const configWasCorrect = configParamValidatedStatusList
.reduce((accumulator, currentVal) => {
if (accumulator === null) {
return currentVal.allSyncedForParam;
}
return accumulator && currentVal.allSyncedForParam;
}, null);
return {
configWasCorrect: configWasCorrect,
configParamValidatedStatusList: configParamValidatedStatusList
};
}
/**
* @description Set configuration for this device and checks
* whether configuration was set correctly. Bridge must have already been
* populated with this device.
* @param {Device} device The device to configure
* @param {Object[]} [configCommandsList] Specific config commands to call
* @returns {Promise} Resolves to true if configuration was set correctly
*/
async configureDevice(device, configCommandsList) {
this.adapter.k4.logging.system(`Attempting to configure device: ${device.devicePath}`);
let configCommands;
if (_isNil(configCommandsList) &&
(_isNil(device._mapping) ||
_isNil(device._mapping.config) ||
_isNil(device._mapping.config.commands))) {
if (device) {
if (device.devicePath) {
this.adapter.k4.logging.warning(`[Configurator] Skipping configureDevice because Device ${device.devicePath} was not supplied any config commands`);
} else {
this.adapter.k4.logging.warning('[Configurator] Skipping configureDevice because Device NIL was not supplied any config commands');
}
}
return {
configSucceeded: true,
successfulConfigCommands: [],
failedConfigCommands: []
};
}
configCommands = configCommandsList || this.extractConfigCommands(device);
this.paramsInProgressOfBeingConfigured.set(device.devicePath, configCommands.map(configCmd => this.getParedConfigParamFromFull(configCmd)));
const succeededConfigCommands = [];
const failedConfigCommands = [];
const generateCmdQueueOpts = {
configCommandTimeoutMsec: Configurator.constants.misc.DEFAULT_CONFIG_COMMAND_TIMEOUT_MSEC,
retryLimit: Configurator.constants.misc.DEFAULT_RETRY_LIMIT,
retryOverhangMsec: Configurator.constants.misc.DEFAULT_RETRY_OVERHANG_MSEC
};
if (!_isNil(device._mapping) && !_isNil(device._mapping.config)) {
if (!_isNil(device._mapping.config.commandTimeoutMsec)) {
generateCmdQueueOpts.configCommandTimeoutMsec = device._mapping.config.commandTimeoutMsec;
}
if (!_isNil(device._mapping.config.allowedNumConfigAttempts)) {
generateCmdQueueOpts.retryLimit = device._mapping.config.allowedNumConfigAttempts;
}
if (!_isNil(device._mapping.config.retryOverhangMsec)) {
generateCmdQueueOpts.retryOverhangMsec = device._mapping.config.commandRetryOverhangMsec;
}
}
const configCommandQueue = await this.generateConfigCommandQueue(device, configCommands, generateCmdQueueOpts);
let cumulativeConfigSucceeded = true;
for (let i = 0; (cumulativeConfigSucceeded && i < configCommandQueue.length); i += 1) {
const currentConfigCmdFunc = configCommandQueue[i];
const currentConfigCmdRetObj = await currentConfigCmdFunc();
let currentConfigSuccessBoolean = currentConfigCmdRetObj.configSucceeded;
switch (currentConfigSuccessBoolean) {
case true:
if (!_isNil(currentConfigCmdRetObj.commandToSend)) {
succeededConfigCommands.push(currentConfigCmdRetObj.commandToSend);
}
cumulativeConfigSucceeded = cumulativeConfigSucceeded && true;
break;
case false:
failedConfigCommands.push(currentConfigCmdRetObj.commandToSend);
cumulativeConfigSucceeded = false;
break;
default:
this.adapter.k4.logging.warning(`[Configurator] Undefined behavior. Config command did not succeed, fail, or timeout deterministically: ${JSON.stringify(currentConfigCmdRetObj)}`);
throw new Error('Undefined behavior. Config command did not succeed, fail, or timeout deterministically');
}
}
this.paramsInProgressOfBeingConfigured.set(device.devicePath, null);
// NOTE: Update the K4Model with the latest overall configuration
// sync status
try {
const configurationSyncedModelNode = this.adapter.k4.model.child(`${device.devicePath}/variables/configurationSynced`);
if (_isNil(configurationSyncedModelNode)) {
throw new Error('configurationSynced K4Model variable node does not exist for this device');
}
configurationSyncedModelNode.set(cumulativeConfigSucceeded);
} catch (configurationSyncedStatusUpdateErr) {
this.adapter.k4.logging.warning(`[Configurator] Unable to set the configurationSynced variable on the model for ${device.devicePath}. Value would have been: ${cumulativeConfigSucceeded}`, configurationSyncedStatusUpdateErr);
}
return {
configSucceeded: cumulativeConfigSucceeded,
successfulConfigCommands: succeededConfigCommands,
failedConfigCommands: failedConfigCommands
};
}
/**
* @description Creates the list of functions that
* will be invoked to issue the configuration for a device.
* @param {Device} device
* @param {Object[]} configCommands
* @param {Object} retryOptions
* @returns {Array<function(): Promise>} An Array of Functions that return Promises
*/
generateConfigCommandQueue(device, configCommands, retryOptions) {
let configCommandQueue = [];
let configCommandTimeoutMsec = Configurator.constants.misc.DEFAULT_CONFIG_COMMAND_TIMEOUT_MSEC;
let retryLimit = Configurator.constants.misc.DEFAULT_RETRY_LIMIT;
let retryOverhangMsec = Configurator.constants.misc.DEFAULT_RETRY_OVERHANG_MSEC;
if (!_isNil(retryOptions)) {
if (!_isNil(retryOptions.configCommandTimeoutMsec)) {
configCommandTimeoutMsec = retryOptions.configCommandTimeoutMsec;
}
if (!_isNil(retryOptions.retryLimit)) {
retryLimit = retryOptions.retryLimit;
}
if (!_isNil(retryOptions.retryOverhangMsec)) {
retryOverhangMsec = retryOptions.retryOverhangMsec;
}
}
configCommandQueue = configCommands.map(originalConfigParam => () => {
// Use K4Model properties to override config args
const cmdArgs = originalConfigParam.arguments;
Object.keys(cmdArgs)
.forEach((argName) => {
cmdArgs[argName] = this.obtainConfigArgVal(argName, device, originalConfigParam);
}, this);
const configParamWithOverrides = Object.assign(
originalConfigParam,
{
arguments: cmdArgs
}
);
// NOTE: Do not send configuration command if the configuration
// value itself is never defined or is set to null, to reduce
// unnecessary traffic
if (_isNil(cmdArgs) ||
_isNil(cmdArgs.configurationValue)) {
return Promise.resolve({
configSucceeded: true,
commandToSend: null
});
}
// NOTE: Determine which command should be issued
const cmdToSend = configParamWithOverrides.command;
const attemptToConfigure = (callback) => {
const configurationTimer = setTimeout(() => {
callback(Configurator.constants.errorTypes.TIMEOUT);
this.emitter.removeAllListeners('received--configureDevice-method');
}, configCommandTimeoutMsec);
function onReceivedHandler(resp, rcvDevice) {
if (device.devicePath === rcvDevice.devicePath
&& resp.name === configParamWithOverrides.response) {
this.emitter.removeAllListeners('received--configureDevice-method');
clearTimeout(configurationTimer);
const matchingConfigParam = this.extractConfigCommands(rcvDevice)
.find(cfgParam => this.checkIfConfigParamMatchesResponse(resp, cfgParam), this);
const verificationInfoObj = this.verifyConfigResponse(resp, rcvDevice, [matchingConfigParam]);
const verified = verificationInfoObj.configWasCorrect;
this.updateConfigParamSyncStatus(device, matchingConfigParam, verified, resp.value);
if (verified === true) {
callback(null, verificationInfoObj);
} else {
callback([Configurator.constants.errorTypes.RESPONSE_FALSE, verificationInfoObj]);
}
}
}
this.emitter.on('received--configureDevice-method', onReceivedHandler.bind(this));
// Call appropriate configuration command
this.adapter.k4.logging.info(`Starting configuration attempt with command ${cmdToSend} for device ${device.devicePath}`);
this.adapter.sendCommand(cmdToSend, device, cmdArgs)
.catch(e => this.adapter.k4.logging.error(`There was an error on issuing the configuration command ${cmdToSend} for device ${device.devicePath}`, e instanceof Error ? e.stack.split('\n') : e.toString()));
};
return new Promise((resolve, reject) => {
callbackManager.retry(
{
times: retryLimit,
interval: retryOverhangMsec,
errorFilter: (err) => {
return (err.message === Configurator.constants.errorTypes.RESPONSE_FALSE.message ||
(err.message === Configurator.constants.errorTypes.TIMEOUT.message));
}
},
attemptToConfigure,
(err, successObj) => {
if (!err) {
this.adapter.k4.logging.info(`Successful configuration command ${cmdToSend} to device ${device.devicePath}.`, successObj);
return resolve({
configSucceeded: successObj.configWasCorrect,
commandToSend: configParamWithOverrides
});
}
const errObj = Array.isArray(err) ? err[0] : err;
if (errObj.message === Configurator.constants.errorTypes.TIMEOUT.message) {
this.adapter.k4.logging.system(`Configuration send-receive timed out for command ${cmdToSend} to device ${device.devicePath}`);
return resolve({
configSucceeded: false,
commandToSend: configParamWithOverrides
});
}
if (errObj.message === Configurator.constants.errorTypes.RESPONSE_FALSE.message) {
const errFeedback = Array.isArray(err) ? err[1] : null;
if (!_isNil(errFeedback)) {
this.adapter.k4.logging.system(`Config command ${cmdToSend} sent, but reported current config value does not match expected, for device ${device.devicePath}. Please refer to the following: `, errFeedback);
} else {
this.adapter.k4.logging.system(`Config command ${cmdToSend} sent, but reported current config value does not match expected, for device ${device.devicePath}`);
}
return resolve({
configSucceeded: false,
commandToSend: configParamWithOverrides
});
}
reject(err);
}
);
});
}, this);
return configCommandQueue;
}
/**
* @description Obtain the set of configuration commands
* for a given device
* @param {Device} device The device to configure
* @throws {Error}
* @returns {Array} The set of configuration commands
*/
extractConfigCommands(device) {
if (!device._mapping || !device._mapping.config || !device._mapping.config.commands) {
throw new Error(`Unable to access mapping for configuration commands for device: ${device.devicePath}`);
}
return device._mapping.config.commands;
}
/**
* @description Determine the value to set for a specific
* configuration parameter argument, giving primacy to K4Model property
* overrides
* @param {String} argName Name of the argument belonging to a config
* parameter
* @param {Device} device The device to configure
* @param {Object} configParam The configuration parameter itself
* @returns {any} The appropriate value the argument should be set to
*/
obtainConfigArgVal(argName, device, configParam) {
if (device.isTemporaryDevice) {
return configParam.arguments[argName];
}
const modelOverrideVal = this.obtainConfigPropOverrideForArgVal(argName, device, configParam);
if (!_isNil(modelOverrideVal)) {
return modelOverrideVal;
}
// Return default argument value if there are no model property
// overrides
return configParam.arguments[argName];
}
/**
* @description Determine, if available, the
* K4Model property override value for a configuration argument
* overrides
* @param {String} argName Name of the argument belonging to a config
* parameter
* @param {Device} device The device to configure
* @param {Object} configParam The configuration parameter itself
* @returns {any} The appropriate value the argument should be set to
*/
obtainConfigPropOverrideForArgVal(argName, device, configParam) {
let propOverrides = null;
if (!_isNil(configParam) && !_isNil(configParam.property)) {
propOverrides = configParam.property
.find(prop => prop.argument === argName, this);
}
if (!_isNil(propOverrides)) {
if (!device.model.child(`properties/${propOverrides.name}`)) {
throw new Error(`K4Model property node to override with does not exist: ${propOverrides.name}`);
}
const propOverrideValue = device.model.child(`properties/${propOverrides.name}`).value();
if (!_isNil(propOverrides) &&
!_isNil(propOverrides.options) &&
_isObject(propOverrides.options)) {
const translatedPropOverrideValue = propOverrides.options[`${propOverrideValue}`];
if (!_isNil(translatedPropOverrideValue)) {
return translatedPropOverrideValue;
}
}
return propOverrideValue;
}
}
/**
* @description Given a K4Model property that overrides a
* config parameter argument, determine the configuration parameter(s)
* which make use of that K4Model property
* @param {String} propName Name of the property node on K4Model
* @param {Device} device The device to configure
* @returns {Array}
*/
findConfigParamsByProp(propName, device) {
const configCommands = this.extractConfigCommands(device);
return configCommands
.filter(cmd => cmd.property.find(prop => prop.name === propName, this), this);
}
};
module.exports = Configurator;