'use strict';
const fs = require('fs');
const path = require('path');
const _debounce = require('lodash/debounce');
/**
* @class
* @description
* <br>The base bridge class used to create a bridge.
* <br> The adapter directory should contain your adapters index.jse, commands
* directory, and responses directory. See {@link Adapter Adapter} for details.
* <br>Example:
* <pre class="prettyprint">
* <code>const bridge = require('k4-base-bridge');
* const path = require('path');
* const K4 = require('k4');
*
* const k4 = new K4({
* url: 'auto'
* });
*
* const opts = {
* k4: k4,
* id: 'networkId',
* transport: {
* name: 'udp',
* protocol: 'udp6',
* receiver: {
* port: 10000,
* protocol: 'udp6'
* }
* },
* adapter: {
* file: path.resolve('./adapter/index.jse'),
* sequencer: {
* max: 255,
* min: 0,
* start: 'random'
* }
* }
* };
*
* bridge.start(opts);
* </code></pre>
*/
class Bridge {
/**
* @description Loads the base bridges classes
* @private
*/
loadClasses() {
this._classes = {};
this._devices = {};
// load all classes into module
const pathsToLoad = [`${__dirname}/classes/`, `${__dirname}/plugins/`, `${__dirname}/utilities/`];
pathsToLoad
.forEach((directoryPath) => {
fs.readdirSync(directoryPath)
.filter((fileName) => {
return (fileName.indexOf('.js') >= 0 ||
fileName.indexOf('.jse') >= 0);
})
.forEach((fileName) => {
const key = fileName.charAt(0).toUpperCase() +
fileName.slice(1).replace('.jse', '').replace('.js', '');
this._classes[key] = require(directoryPath + fileName);
if (this._k4) {
this._k4.logging.debug(`Loaded base bridge class: ${directoryPath}${fileName}`);
}
}, this);
if (this._k4) {
this._k4.logging.debug(`Base bridge classes loaded for: ${directoryPath}`);
}
}, this);
}
/**
* @typedef classesObj
* @type {Object}
* @property {Adapter} Adapter
* @property {Command} Command
* @property {Device} Device
* @property {Response} Response
* @property {Transport} Transport
*/
/**
* @description Contains the base bridge classes
* @type {classesObj}
*/
get classes() {
return this._classes;
}
/**
* @description Contains the devices that the module found in K4Model
* @type {Object}
*/
get devices() {
return this._devices;
}
/**
* @description Starts the bridge
* @param {Object} opts
* @param {Object} opts.k4 Instance of the K4Model
* @param {String} opts.id The name of the device property that contains the id (ex: networkId, nodeId, etc)
* @param {Object} opts.transport Transport configuration. See {@link Udp udp} or see {@link Serial serial}
* @param {Object} opts.adapter Adapter configuration. See {@link Adapter Adapter}
* @returns {Promise}
*/
async start(opts) {
this._opts = opts;
this._k4 = opts.k4;
this.loadClasses();
// load the adapter from the supplied dir
this._k4.logging.debug('Creating adapter', this._opts.adapter.dir);
let Adapter;
let file = path.resolve(process.cwd(), this._opts.adapter.file).replace(/\.jse?$/, '');
try {
Adapter = require(file);
} catch (e) {
this._k4.logging.fatal('Could not create adapter', e);
}
this._adapter = new Adapter(this._opts.adapter, this);
try {
const self = this;
await this.createTransport();
await this._adapter.init();
if (this._k4.ready !== true) {
await new Promise((resolve, reject) => {
this._k4.on('ready', function listenOnce() {
self._k4.off('ready', listenOnce);
resolve();
});
});
}
await this.ready();
this._k4.on('ready', _debounce(this.ready.bind(this), 5000, { trailing: true }));
} catch (e) {
this._k4.logging.error(`Adapter init failed: ${e.toString()}`, e instanceof Error ? e.stack.split('\n') : null);
if (e instanceof Error) {
e.message = 'Bridge startup failed: '.concat(e.message);
e.stack = `${e.stack}\nFrom previous ${new Error(e.message).stack.split('\n').slice(0, 2).join('\n')}\n`;
throw e; // Re-throw for caller
}
throw new Error('Bridge startup failed: '.concat(e.toString())); // Surface for caller
}
}
/**
* @description Creates the specified transport
* @private
* @returns {Promise}
*/
createTransport() {
// setup the transport
this._k4.logging.debug('Creating transport', this._opts.transport.name);
let Transport = require(__dirname + '/transports/' + this._opts.transport.name);
this._transport = new Transport(this._opts.transport, this._adapter);
return this._transport.init();
}
/**
* @description The adapter instance
* @type {Adapter}
*/
get adapter() {
return this._adapter;
}
/**
* @description The K4Model instance
* @type {Object}
*/
get k4() {
return this._k4;
}
/**
* @description Ready handler for K4Model
* @returns {Promise}
* @private
*/
ready() {
// ready can be called multiple times
this._k4.logging.system('Bridge ready() handler was called...', new Date().toISOString());
console.log('Bridge ready() handler was called...', new Date().toISOString());
if (this.started) {
if (!this._adapter) {
return Promise.resolve();
}
this._k4.logging.system('Bridge ready() handler will set device K4Model variable values to their last known state');
this.restoreDeviceModelVarsFromCache();
const pluginsToReinitialize = this._adapter.identifyReinitializablePlugins();
this._k4.logging.system('Bridge ready() handler will reinitialize certain plugins:', pluginsToReinitialize);
console.log('Bridge ready() handler will reinitialize certain plugins:', pluginsToReinitialize);
return this._adapter.tearDownPlugins(pluginsToReinitialize)
.then(() => this._adapter.initializePlugins(pluginsToReinitialize));
}
this.started = true;
this._k4.logging.system('Bridge ready() handler is setting up event handlers on the K4Model.');
// setup default handlers
this._k4.model.on('execute', this.execute, this);
this._k4.model.child('Devices').on('add', this.reloadDevices, this);
this._k4.model.child('Devices').on('remove', this.reloadDevices, this);
this._k4.device.child('variables/ready').set(false);
return this.reloadDevices()
.then(() => this._adapter.initializePlugins(Object.keys(this._adapter.plugins)));
}
/**
* @description Recreates devices list when K4Model ready event fires
* @returns {Promise}
* @private
*/
reloadDevices() {
// make sure device list matches model
let senders = this._k4.device.senders();
// add update
for (let i in senders) {
this.buildDevice(senders[i]);
}
// remove devices old devices
for (let i in this._devices) {
let found = false;
for (let j in senders) {
if (senders[j].path() == i) {
found = true;
break;
}
}
// no longer in senders, remove from devices
if (!found) {
this._devices[i].cleanup();
delete this._devices[i];
}
}
// add bridge device
this.buildDevice(this._k4.device);
this._k4.logging.info('Bridge is ready...');
return new Promise((resolve, reject) => {
setTimeout(() => {
this._k4.device.child('variables/ready').set(true);
resolve();
}, 3000);
});
}
/**
* @description Restores last-known K4Model variable values for devices
* @returns {Promise}
* @private
*/
restoreDeviceModelVarsFromCache() {
const devicePaths = Object.keys(this._devices);
for (let i = 0; i < devicePaths.length; i += 1) {
const deviceModelPath = devicePaths[i];
const deviceObj = this._devices[deviceModelPath];
if (deviceObj.isTemporaryDevice) {
continue;
}
deviceObj.restoreAllModelVarsFromCache();
}
}
/**
* @description Constructs device from K4Model device node
* @param {Object} node K4Model device node
* @private
*/
buildDevice(node) {
let idNode = node.child('properties/' + this._opts.id);
let id;
if (idNode) {
id = idNode.value();
}
if (node == this._k4.device) {
id = 'bridge';
}
if (!id) {
if (this._devices[node.path()]) {
delete this._devices[node.path()];
}
return;
}
if (!this._devices[node.path()]) {
this._devices[node.path()] = new this._classes.Device(this._adapter);
}
let device = this._devices[node.path()];
device.id = id;
device.model = node;
// set opts for commands, variables, events
let cls = node.cls();
let mapping = this._adapter.mapping;
if (mapping[cls]) {
device.mapping = mapping[cls];
}
this._k4.logging.debug('Bridge reloadDevice: ' + device.id, node.path());
}
/**
* @description Get a device by id
* @param {String} id The device id
* @private
* @returns {Device}
*/
getDevice(id) {
if (id === 'bridge' &&
this._k4.device.path()) {
return this._devices[this._k4.device.path()];
}
for (let i in this._devices) {
let device = this._devices[i];
if (device.model) {
let idNode = device.model.child('properties/' + this._opts.id);
if (idNode &&
idNode.value() === id) {
return device;
}
}
if (device.id === id) {
return device;
}
}
}
/**
* @description Handles execute commands from device node
* @param {Object} data Data from execute command
* @param {Function} callback The K4Model callback
* @private
*/
execute(data, callback) {
// execute commands on device
let commandNode = this._k4.model.child(data.path);
if (!commandNode) {
return callback('No such node.');
}
let node = commandNode.parent().parent();
if (!node) {
return callback('No such device.');
}
let device = this._devices[node.path()];
if (!device) {
return callback('No such bridge device.');
}
let command = device.getCommand(commandNode.name());
if (!command) {
return callback('No such bridge command.');
}
command.args = data.args;
command.device = device;
command.adapter = this._adapter;
this.executeCommand(device, command)
.then((execData) => callback(null, execData))
.catch((error) => {
this._k4.logging.error(`Bridge execute error: `, error instanceof Error ? error.stack.split('\n') : error.toString());
callback(Bridge.convertErrorToModelCompatibleFormat(error));
});
}
async executeCommand(device, commandObj) {
const address = await this._adapter.getAddress(device, commandObj);
const rawResponse = await this._adapter.send(address, commandObj);
// The response may be a promise, if a device is waiting on an async
// message back. If so, we wait on it
if (!Bridge.isPromise(rawResponse)) {
// Response was from a sync command
const response = rawResponse || {};
return response;
}
const respData = await rawResponse;
const resp = respData || {};
return resp;
}
/**
* @description Forwards command instances to the transport
* @param {String|Object} address Address for the transport
* @param {Command} command The command to send
* @returns {Promise}
* @private
*/
send(address, command) {
return this._transport.send(address, command);
}
static convertErrorToModelCompatibleFormat(rawErr) {
if (!rawErr instanceof Error) {
return rawErr;
}
const newObj = Object.assign({}, rawErr);
newObj.message = rawErr.message;
newObj.stack = rawErr.stack;
return newObj;
}
static isPromise(obj) {
return (!!obj &&
(typeof obj === 'object' || typeof obj === 'function') &&
typeof obj.then === 'function');
}
}
let bridge = new Bridge();
module.exports = bridge;