'use strict';
const fs = require('fs');
const path = require('path');
const events = require('events');
const chokidarFileWatching = require('chokidar');
const _isEqual = require('lodash/isEqual');
const _isNil = require('lodash/isNil');
const _lowerFirst = require('lodash/lowerFirst');
const _upperFirst = require('lodash/upperFirst');
const _isString = require('lodash/isString');
const _get = require('lodash/get');
/**
* @class
* @description The base class of the adapter. Project adapters should extend this class.
*
* <br>The base bridge module creates a bridge by automatically binding the K4Model to your adapter class. The adapter directory is passed into the adapter opts as a relative path.
*
* <br>The directory structure should be as follows:
* <pre class="prettyprint"><code> - index.js
* |- commands
* - switchMultilevelOn.js
* - switchMultilevelOff.js
* |- responses
* - switchMultilevelReport.js
* - mapping.json
* </code></pre>
*
* <br> The Adapter Mapping file in mapping.json should look like the following (possibly more complex than this)
*
* <pre class="prettyprint"><code>{
* "com.k4connect.zwave.dimmer": {
* "commands": {
* "on": "SwitchMultilevelOn",
* "off": "SwitchMultilevelOff"
* },
* "variables": {
* "level": "switchMultilevelReport"
* }
* }
* }
* </code></pre>
*
* <b>Command Flow</b>
*
* When a command is executed on the K4Model, it is processed through the module using this flow:
*
* <ul>
* <li>Call the Base Bridge K4Model execute() handler</li>
* <li>Use the K4Model path to identify the matching Device instance on the bridge</li>
* <li>If a Device instance exists, use its Mapping definition to create the appropriate Command instance</li>
* <li>Once a Command instance is created, the Core Bridge Class sets the Command instance args object, Device, and Adapter references</li>
* <li>The Adapter <a href="#getAddress">getAddress()</a> method is called to get the destination device address (if applicable) for the transport</li>
* <li>The Command is passed to the Adapter <a href="#send">send()</a> method. The <a href="Command.html#buildBody">command.buildBody()</a> should be called to generate the data buffer</li>
* <li> (if applicable): The Command is registered with the Sequencer plugin or is added to the Queue. See <a href="Sequencer.html#next">next()</a> or <a href="Queue.html#add">add()</a>, respectively.</li>
* <li>The Core Bridge <a href="Bridge.html#send">send()</a> method is called by the main Adapter logic or via the Queue plugin, depending on what the Bridge is configured for.</li>
* <li>The Core Bridge <a href="Bridge.html#send">send()</a> method hands over the Command to the Transport.</li>
* <li>The Transport <a href="Transport.html#send">sends</a> the Command data buffer to the destination</li>
* </ul>
*
* <b>Response Flow</b>
*
* When data is received from the transport, it is processed through the module using this flow:
*
* <ul>
* <li>The Queue is given first attempt to handle the data (if applicable)
* <ul>
* <li>Queue checks the data for a termination (success or error) string. If waiting on more data, stores the current data without calling any handler. If neither of those is true, the queue will not handle the data.</li>
* <li>If the data has the termination string, and a handler exists for it, the handler callback function is called.</li>
* </ul>
* </li>
* <li>The Sequencer is given second attempt to handle the data (if applicable)
* <ul>
* <li>Sequencer calls adapter <a href="#getSequence">getSequence()</a> to retrieve a sequence id from the data. If null is returned, the sequencer will not handle the data.</li>
* <li>If a sequence id is returned, and a handler is registered for that sequence id, that specific handler callback function is called (and cleared).</li>
* </ul>
* </li>
* <li>If neither the queue nor the sequencer can handle the data, each Response class is called with <a href="Response.html#.fromData">fromData()</a>
* <ul>
* <li>The Response <a href="Response.html#.fromData">fromData()</a> method should return null if it cannot handle the data, or return an instance of itself if it can.</li>
* <li>The Adapter <a href="#getId">getId()</a> method is called to retrieve the device id of the data</li>
* <li>The Response instance address and device references are set.</li>
* <li>The Response instance <a href="#Response.html#init">init()</a> method is called, which parses the data and marshals it into a friendlier format (but of flexible type).</li>
* </ul>
* </li>
* <li>Response is passed to the appropriate Device instance
* <ul>
* <li>The Response <a href="Response.html#value">value()</a> method is called to obtain the marshaled, parsed Response data.</li>
* <li>The Device instance emits an event to listeners (if any exist) that a Response has been received. The emitted event name is <code>received--${response.name}</code>, where the `response.name` refers to the Response instance name <a href="Response.html#name">getter</a></li>
* <li>The Device instance looks up within its <a href="Device.html#mapping">mapping</a> to update any relevant K4Model variables or to fire any matching K4Model events</li>
* </ul>
* </li>
* </ul>
*/
const SUPPORTED_PLUGIN_NAMES = [
'sequencer',
'queue',
'nodeStateMonitor',
'configurator',
'pollingManager',
'pairer'
];
class Adapter {
/**
* @constructor
* @param {Object} opts Adapter settings (e.g. file, plugins)
* @param {String} opts.file The relative path to your adapter file
* @param {Object} [opts.sequencer] Request an optional {@link Sequencer Sequencer} plugin
* @param {Object} [opts.queue] Request an optional {@link Queue Queue} plugin
* @param {Object} [opts.nodeStateMonitor] Request an optional {@link NodeStateMonitor NodeStateMonitor} plugin
* @param {Object} [opts.configurator] Request an optional {@link Configurator Configurator} plugin
* @param {Object} [opts.pollingManager] Request an optional {@link PollingManager PollingManager} plugin
* @param {Object} [opts.pairer] Request an optional {@link Pairer Pairer} plugin
* @param {Bridge} bridge The Bridge instance associated with this adapter
*
*/
constructor(opts, bridge) {
this._opts = opts;
this._k4 = bridge.k4;
this._bridge = bridge;
this._plugins = {};
this._emitter = new events.EventEmitter();
this.adapterId = Symbol('adapter-id');
if (!this._mappingChangeWatchersMap) {
this._mappingChangeWatchersMap = new Map();
}
for (let i = 0; i < SUPPORTED_PLUGIN_NAMES.length; i += 1) {
const currentPluginName = SUPPORTED_PLUGIN_NAMES[i];
const pluginOpts = this.opts[_lowerFirst(currentPluginName)];
if (pluginOpts) {
this.plugins[_lowerFirst(currentPluginName)] = new this.bridge.classes[_upperFirst(currentPluginName)](pluginOpts, this);
}
}
this.load();
}
/**
* @description Called to initialize the adapter
*/
init() {
try {
this._currentMappingObj = this.readMapping();
this._mapping = this.loadMapping();
this._previousMappingObj = this._currentMappingObj;
this.setupMappingChangeWatcher();
this.setupDeviceNodesWatcher();
} catch (e) {
throw new Error(e);
}
}
/**
* @description Called to tear down the adapter
*/
close() {
try {
this.removeMappingChangeWatcher();
this.removeDeviceNodesWatcher();
} catch (e) {
console.log('Difficulty tearing down Adapter instance', e);
}
}
/**
* @description Ingests the mapping JSON file
* @returns {Object} The object form of the mapping JSON file
* @private
* @throws {Error}
*/
readMapping() {
const file = fs.readFileSync(path.join(path.dirname(this.opts.file), 'mapping.json'));
const mappingObj = JSON.parse(file);
if (!mappingObj) {
throw new Error('Unable to parse mapping JSON');
}
return mappingObj;
}
/**
* @description Makes the ingested mapping available to the bridge.
* @returns {Object} The usable, bridge-compatible mapping
* @throws {Error}
*/
loadMapping() {
try {
const mappingObj = this.readMapping();
return this.loadMappingCommandClasses(mappingObj);
} catch (e) {
throw new Error(e);
}
}
/**
* @description Inserts the appropriate actual command
* classes themselves to replace the command class names in original mapping
* @param {Object} rawMappingObj The mapping object directly parsed from a
* JSON file.
*/
loadMappingCommandClasses(rawMappingObj) {
const finalizedMappingObj = rawMappingObj;
Object.keys(finalizedMappingObj)
.forEach((deviceCls) => {
const device = finalizedMappingObj[deviceCls];
if (device.commands) {
Object.keys(device.commands)
.forEach((modelCmdName) => {
const bridgeCommandName = device.commands[modelCmdName];
device.commands[modelCmdName] = this.classes.commands[bridgeCommandName];
}, this);
}
}, this);
return finalizedMappingObj;
}
/**
* @description Gets the current mapping for a given
* device, with the raw command class names as opposed to the classes themselves.
* @param {String} devicePath
* @throws {Error}
* @returns {Object}
*/
extractMappingObjForDevice(devicePath) {
if (_isNil(this._currentMappingObj)) {
throw new Error('Current mapping object is not stored anywhere');
}
const deviceNode = this.k4.model.child(devicePath);
if (_isNil(deviceNode)) {
throw new Error('The specified device does not exist on the model');
}
const foundMapping = Object.keys(this._currentMappingObj).find(mappingCls => mappingCls === deviceNode.cls());
if (_isNil(foundMapping)) {
return null;
}
return this._currentMappingObj[foundMapping];
}
/**
* @description Initialize the plugins that are specified, if said plugins
* support being initialized.
* @param {Array} pluginNames
* @returns {Promise<Array>} Responses from the plugin initialization
* routines that were executed.
*/
initializePlugins(pluginNames) {
if (!Array.isArray(pluginNames)) {
return Promise.all([]);
}
const pluginInitPromises = pluginNames
.filter((plugin) => {
const initFun = this._plugins[plugin].init;
return (typeof initFun === 'function');
}, this)
.map((pluginName) => this._plugins[pluginName].init());
return Promise.all(pluginInitPromises);
}
/**
* @description Check to see which plugins are indicated for
* reinitialization.
* @returns {Array} List of plugin names that are marked for
* reinitialization.
*/
identifyReinitializablePlugins() {
const activePluginNames = Object.keys(this._plugins);
if (!Array.isArray(activePluginNames)) {
return [];
}
return activePluginNames
.filter((pluginName) => {
const isReinitializable = this._plugins[pluginName].shouldReinitializeOnBridgeReady;
return (isReinitializable === true);
}, this);
}
/**
* @description Tear down the plugins that are specified, if said plugins
* support being torn-down.
* @param {Array} pluginNames
* @returns {Promise<Array>} Responses from the plugin tear-down routines
* that were executed.
*/
tearDownPlugins(pluginNames) {
if (!Array.isArray(pluginNames)) {
return Promise.all([]);
}
// Tear down plugins if necessary
const pluginTeardownPromises = pluginNames
.filter((plugin) => {
const closeFunc = this._plugins[plugin].close;
return (typeof closeFunc === 'function');
}, this)
.map((pluginName) => this._plugins[pluginName].close({
force: true
}));
return Promise.all(pluginTeardownPromises);
}
/**
* @description Returns the device object associated with
* a K4Model node, or fails with an exception.
* @param {String|K4.Child} node
* @returns {Device}
* @throws {Error}
*/
findBridgeDeviceByNode(node) {
let devicePath = node;
if (typeof node !== 'string') {
devicePath = node.path();
}
const device = this.bridge.devices[devicePath];
if (!device) {
throw new Error('Device does not exist on bridge');
}
return device;
}
/**
* @description Obtain the bridge device path based on the supplied nodeId
* @param {String|Integer} nodeId
* @returns {String} The device path
*/
getBridgeDevicePathById(nodeId) {
const foundDevice = this.bridge.getDevice(nodeId);
if (!_isNil(foundDevice)) {
return foundDevice.devicePath;
}
}
/**
* @description Obtain the current list of bridge devices
* @returns {Device[]}
*/
getBridgeDevicesList() {
return this.bridge.devices;
}
/**
* @description Creates a file watcher for the mapping
* JSON file. If that file changes, the watcher will check if the actual
* contents have changed. If yes, then the bridge devices list is refreshed
* and the Adapter emits an event to all subscribers indicating a mapping
* change.
* @private
*/
setupMappingChangeWatcher() {
// Do not create a listener if it already exists
if (this._mappingChangeWatchersMap.has(this.adapterId)) {
return;
}
const changeWatcher = chokidarFileWatching.watch(path.dirname(this.opts.file), {
awaitWriteFinish: {
stabilityThreshold: 2000,
pollInterval: 100
},
atomic: true,
depth: 0
});
changeWatcher.on('error', error => this.k4.logging.error('Issue with mapping change file watcher', error));
changeWatcher.on('change', (changedFilePath) => {
if (changedFilePath.indexOf('mapping.json') < 0) {
return;
}
try {
this._currentMappingObj = this.readMapping();
let actuallyChanged = false;
if (!_isEqual(this._currentMappingObj, this._previousMappingObj)) {
actuallyChanged = true;
this.emitter.emit('mappingFileWatcherChangeObserved', actuallyChanged);
this._mapping = this.loadMapping();
this.bridge.reloadDevices()
.then(() => this.bridge.classes.TimingUtility.setTimeoutPromise(500))
.then(() => {
this.emitter.emit('mappingChangedAlert');
})
.catch(e => this.k4.logging.error(`Mapping change watch error on reloadDevices: ${e.toString()}`, e instanceof Error ? e.stack.split('\n') : null));
} else {
this.emitter.emit('mappingFileWatcherChangeObserved', actuallyChanged);
}
this._previousMappingObj = JSON.parse(JSON.stringify(this._currentMappingObj, null, '\t'));
} catch (e) {
this.k4.logging.error(`Mapping change watch error on loading mapping: ${e.toString()}`, e instanceof Error ? e.stack.split('\n') : null);
}
});
this._mappingChangeWatchersMap.set(this.adapterId, changeWatcher);
}
/**
* @description Unregisters the file watcher for the
* mapping JSON file if the file watcher exists. If yes, the Adapter emits
* an event to all subscribers indicating that the watcher is closed.
* @private
*/
removeMappingChangeWatcher() {
// Do not need to remove a non-existent listener
if (_isNil(this._mappingChangeWatchersMap) || !this._mappingChangeWatchersMap.has(this.adapterId)) {
return;
}
const changeWatcher = this._mappingChangeWatchersMap.get(this.adapterId);
changeWatcher.close();
this.emitter.emit('mappingWatcherClosed');
this._mappingChangeWatchersMap.delete(this.adapterId);
}
/**
* @description Creates an EventListener to monitor changes to the device
* nodes list
* @private
*/
setupDeviceNodesWatcher() {
try {
if (!this._deviceNodesWatchersMap) {
this._deviceNodesWatchersMap = new Map();
}
// Do not create a listener if it already exists
if (this._deviceNodesWatchersMap.has(this.adapterId)) {
return;
}
this._deviceNodesWatchersMap.set(this.adapterId, this.onDeviceNodesChange.bind(this));
// NOTE: (IMPORTANT) Due to K4Model event handling being suppressed
// for parent nodes if a child node already has a handler registered
// for it, SHOULD NOT register any other handlers for 'log' events
// aside from this one, at this or at child node levels.
this.k4.model.on('log', this._deviceNodesWatchersMap.get(this.adapterId));
} catch (e) {
this.k4.logging.error(`Was not able to setup the device nodes list watcher. Error: ${e.toString()}`, e instanceof Error ? e.stack.split('\n') : null);
}
}
/**
* @description Tears down the EventListener to monitor
* changes to the device nodes list
* @private
*/
removeDeviceNodesWatcher() {
// Do not need to remove a non-existent listener
if (_isNil(this._deviceNodesWatchersMap) || !this._deviceNodesWatchersMap.has(this.adapterId)) {
return;
}
try {
this.k4.model.off('log', this._deviceNodesWatchersMap.get(this.adapterId));
this.emitter.emit('deviceNodesWatcherClosed', '');
} catch (e) {
this.k4.logging.error(`Was not able to remove the device nodes list watcher. Error: ${e.toString()}`, e instanceof Error ? e.stack.split('\n') : null);
}
}
checkIfModelLogEventBelongsToBridge(logEventData) {
const logEventRawPath = _get(logEventData, 'path') || '';
let changeBelongsToCurrentBridge = false;
if (!_isNil(this.k4.device.senders())) {
changeBelongsToCurrentBridge = this.k4.device.senders()
.filter((sender) => {
const senderPath = sender.path();
return (_isString(senderPath) && senderPath.length > 0);
})
.some((sender) => {
const eventType = _get(logEventData, 'type') || '';
const nodeName = _get(logEventData, 'name') || '';
const nodeDestination = _get(logEventData, 'destination') || '';
let logEventPathToCheck = logEventRawPath;
if (eventType === 'move') {
logEventPathToCheck = path.join(nodeDestination, nodeName);
} else if (eventType === 'rename') {
logEventPathToCheck = path.join(path.dirname(logEventRawPath), nodeName);
}
const doesEventBelongToThisSender = logEventPathToCheck.includes(sender.path()) || sender.path().includes(logEventPathToCheck);
return doesEventBelongToThisSender;
});
}
if (logEventRawPath.includes(this.k4.device.path())) {
if (logEventRawPath !== `${this.k4.device.path()}/variables/ready`) {
changeBelongsToCurrentBridge = true;
}
}
return changeBelongsToCurrentBridge;
}
onDeviceNodesChange(logEventData) {
const changeBelongsToCurrentBridge = this.checkIfModelLogEventBelongsToBridge(logEventData);
if (changeBelongsToCurrentBridge === true) {
// NOTE: Do not reload devices on variable changes, because
// variables may change often, and thereby cause frequent reloads.
if (logEventData.path.includes('/variables')) {
this.bridge.classes.TimingUtility.setTimeoutPromise(500)
.then(() => this.emitter.emit('deviceNodesChangedAlert', ''));
} else {
this.bridge.reloadDevices()
.then(() => this.bridge.classes.TimingUtility.setTimeoutPromise(500))
.then(() => {
this.emitter.emit('deviceNodesChangedAlert', '');
})
.catch(e => this.k4.logging.error('Model change watch error on reloadDevices', e instanceof Error ? e.stack : e.toString()));
}
}
}
/**
* @description Resolves the device id from received data. Resolve with the id or 'bridge'
* @param {Object} address The address the data was received from
* @param {Buffer} data The data received
* @param {Response} response The response matching the data
* @returns {Promise}
* @abstract
*/
getId(address, data, response) {
throw new Error('Must be implemented by subclass!');
}
/**
* @description Resolves the transport address for a device
* @param {Device} device The device the command was sent to
* @param {Command} command The command being sent
* @returns {Promise}
* @abstract
*/
getAddress(device, command) {
throw new Error('Must be implemented by subclass!');
}
/**
* @description Loads the adapter classes
* @private
*/
load() {
// load commands and responses into module
this._classes = {
commands: {},
responses: {}
};
this.bridge.classes.commands = this.classes.commands;
this.bridge.classes.responses = this.classes.responses;
this.loadDir('commands');
this.loadDir('responses');
}
/**
* @description Loads classes from directory (may involve recursive call)
* @param {String} type The directory to load: "commands" or "responses"
* @private
*/
loadDir(type, dir, obj) {
let files;
// if not passed from a recursive call, determine the adapter base dir
if (!dir) {
dir = this.opts.file.split('/');
dir.splice(-1);
dir = dir.join('/');
dir = path.resolve(dir, type);
}
// if not passed from a recursive call, set obj to the correct classes destination
if (!obj) {
obj = this.classes[type];
}
try {
files = fs.readdirSync(dir);
} catch (e) {
this.k4.logging.fatal(`Could not create adapter class: ${type}. Error: ${e.toString()}`, e instanceof Error ? e.stack.split('\n') : null);
}
let directories = [];
for (let i in files) {
let f = files[i];
if (fs.statSync(dir + '/' + f).isDirectory()) {
directories.push(f); // save for later - we want to do files first
}
// skip non javascript files
if (f.indexOf('.js') === -1 &&
f.indexOf('.jse') === -1) {
continue;
}
// build uppercase class name from filename
let key = f.charAt(0).toUpperCase() + f.slice(1).replace('.jse', '').replace('.js', '');
// add to classes object
obj[key] = require(path.resolve(dir, f));
}
for (let i in directories) {
let d = directories[i];
let full = dir + '/' + d;
// build the object recursively - ex: commands.com.brand
let parts = d.split('.');
let target = obj;
for (let j in parts) {
let part = parts[j];
// set the classes object on each depth until we reach the last part
if (!target[part]) {
target[part] = {};
target = target[part];
}
}
// recursively call to process directory contents
this.loadDir(type, full, target);
}
}
/**
* @description Build a command by name and call adapter send
* @param {String|Function} command The name of the command or the command class
* @param {Device} device The destination device for this command
* @param {Object|Array} [args] The command args
* @returns {Promise}
*/
async sendCommand(command, device, args) {
let Command;
if (typeof command === 'string') {
// support command names like com.company.Command
let obj = this.classes.commands;
let parts = command.split('.');
for (let i in parts) {
let part = parts[i];
obj = obj[part];
}
Command = obj;
} else {
Command = command;
}
// construct the new command
let c = new Command();
c.adapter = this;
// set any args
if (args) {
c.args = args;
}
// set the device
if (device) {
c.device = device;
}
// get the address, send the command and return promise
const address = await this.getAddress(device, c);
return this.send(address, c);
}
/**
* @description Sends a command through the adapter
* @param {Object} address The destination device
* @param {Command} command The command being sent
* @returns {Promise}
* @abstract
*/
send(address, command) {
throw new Error('Must be implemented by subclass!');
}
/**
* @description Processes received data. Attempts sequencer first, then iterates across all adapter response classes
* @param {Object} address The address the data was received from
* @param {Buffer} data The data received
* @private
*/
received(address, data) {
// check to see if queue is waiting on this packet
if (this.plugins.queue && this.plugins.queue.received(data)) {
return;
}
// check to see if sequencer is waiting on this packet
if (this.plugins.sequencer && this.plugins.sequencer.received(data)) {
return;
}
let promises = this.getResponses(address, data);
Promise.all(promises)
.catch((e) => {
this.k4.logging.error(`Receive error: ${e.toString()}`, e instanceof Error ? e.stack.split('\n') : null);
});
}
/**
* @description Generates a list of responses that returned non-null from fromData() method
* @param {String|Object} address The address the data was received from
* @param {Buffer} data The data received
* @return {Promise[]} List of promises
* @private
*/
getResponses(address, data) {
return Object.keys(this.classes.responses)
.map((key) => {
let item = this.classes.responses[key];
return this.getResponse(address, data, item);
}, this);
}
/**
* @description Tests an individual response for non-null return from fromData() method
* @param {String|Object} address The address the data was received from
* @param {Buffer} data The data received
* @param {Response} item The response to compare
* @return {Promise}
* @private
*/
async getResponse(address, data, item) {
let response = item.fromData(data, this);
if (!response) {
return;
}
response.adapter = this;
response.address = address;
const id = await this.getId(address, data, response);
this.k4.logging.debug('Adapter getId:', id);
if (id === 'bridge') {
let device = this.bridge.devices[this.k4.device.path()];
response.device = device;
response.init();
device.received(response);
return;
}
for (let j in this.bridge.devices) {
let device = this.bridge.devices[j];
if (device.id == id) {
response.device = device;
response.init();
// Allow additional adapter plugins to
// harness the response
Object.keys(this.plugins)
.map(pluginName => this.plugins[pluginName], this)
.forEach((plugin) => {
if (typeof plugin['signalReceived'] === 'function') {
plugin.signalReceived(response, device);
}
}, this);
device.received(response);
break;
}
}
}
/**
* @description Resolves any matching sequence id from incoming data.
* Returns null if no sequence id found. Not required if a sequencer is
* not used.
* @param {Buffer} data The data received
* @returns {String|null}
* @abstract
*/
getSequence() {
throw new Error('Must be implemented by subclass!');
}
/**
* @typedef adapterClassesObj
* @type {Object}
* @property {Command[]} commands
* @property {Response[]} responses
*/
/**
* @description The adapter's classes
* @type {adapterClassesObj}
*/
get classes() {
return this._classes;
}
/**
* @description The opts object of this adapter
* @type {Object}
*/
get opts() {
return this._opts;
}
/**
* @description The adapters mapping object
* @type {Object}
*/
get mapping() {
return this._mapping;
}
/**
* @description The K4Model instance
* @type {Object}
*/
get k4() {
return this._k4;
}
/**
* @description The instances of the adapter plugins (queue, sequencer,
* etc.)
* @type {Object}
*/
get plugins() {
return this._plugins;
}
/**
* @description The bridge instance
* @type {Bridge}
*/
get bridge() {
return this._bridge;
}
/**
* @description This Adapter's Event Emitter
* @type {EventEmitter}
*/
get emitter() {
return this._emitter;
}
};
module.exports = Adapter;