index.js

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