BrainDevice.js

'use strict';

Object.defineProperty(exports, '__esModule', { value: true });

function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; }

var EventEmitter = _interopDefault(require('events'));
var Logger = require('./utils/Logger.js');
var defer = require('./utils/defer.js');
var genId = require('./utils/gen-id.js');
var systemDriverId = require('./utils/system-driver-id.js');

/**
 * Error thrown by {@link BrainDevice#setCustomState} when trying to set a custom state on a device that is not the system device
 * @class BrainDevice.ErrorNotSystemDevice
 */
class ErrorNotSystemDevice extends Error {}
/**
 * Error thrown by {@link BrainDevice#setCustomState} (and others) when trying to set a custom using a key that is not a valid ID or Name
 * @class BrainDevice.ErrorInvalidState
 */
class ErrorInvalidState extends Error {}

/**
 * Error thrown by {@link BrainDevice#sendCommand} when trying to send a command using a key that is not a valid ID or Name
 * @class BrainDevice.ErrorInvalidCommand
 */
class ErrorInvalidCommand extends Error {}

/**
 * Interface for a single device attached to a Brain. See {@link BrainClient#getDevice} on how to
 * get an instance of this class for a single device, or {@link BrainClient#getDevices} to get all
 * devices attached to the connected brain. 
 * 
 * BrainDevices have three main purposes:
 * * Enumerating *properties* about the device, such as `id` and `name` (properties are documented below under <a href='#BrainDevice'>the constructor</a>)
 * * Providing access to *commands* (enumerate and send commands)
 * * Providing access to *states* (enumerate and receive changes and set custom states)
 * 
 * Additional device overview information can be found the following tutorial:
 * * See: <a href='./tutorial-201-devices.html'>Basics/Working With Devices</a>
 * 
 * **<h3>Commands</h3>** 
 * See the following methods for more info on working with commands:
 * * Get all the commands available: {@link BrainDevice#getCommands}
 * * Send a command to the device: {@link BrainDevice#sendCommand}
 *
 * Related tutorial: 
 * * See: <a href='./tutorial-400-sendingcommands.html'>Basics/Sending Commands</a>
 * 
 * **<h3>States</h3>**
 * See the following methods for more info on working with states:
 * * Get all the commands available: {@link BrainDevice#getStates} 
 * * Send a custom state (only relevant for [System Devices]{@link BrainDevice#isSystemDevice}): {@link BrainDevice#setCustomState}
 * 
 * Related tutorial: 
 * * See: <a href='./tutorial-300-states.html'>Basics/Watching States</a>
 * 
 * **<h3>Listening for State Changes</h3><a name='statechanges'></a>**
 * State Changes are sent via the `BrainDevice.STATE_CHANGED` event. To listen state changes
 * on the device, just attach an event listener, like this:
 * 
 * ```javascript
 * someDevice.on(BrainDevice.STATE_CHANGED, change => {
 * 	console.log(`Device ${someDevice.id} > State ${change.id} is now "${change.normalizedValue}`);
 * })
 * ```
 *
 * It's important to realize the `BrainDevice.STATE_CHANGED` event fires for ALL states 
 * on the device. You must filter on the `id` property of the payload to see if the event 
 * represents a change of the state you are interested in.
 * 
 * What's in a state change payload? Glad you asked. Here you go:
 * ```javascript
 * const stateChangeExample = {
 * 	id: "SECOND_STATE", // The ID of the state, usually a grokable string like this
 * 	key: undefined, // The State Key - often unused, usually blank/undefined
 * 	name: "Current Second", // Human-readable state name
 * 	value: "58", // The current value of the state as a string
 * 	normalizedValue: "58", // The normalized value of the state (normalized by the Brain), also a string, usually identical to the `value` but not always.
 * }
 * ```
 * 
 * Also see related tutorial: <a href='./tutorial-300-states.html'>Basics/Watching States</a>
 * 
 * **<h3>Nota Bene</h3>**
 * *NOTE:* You should never call the constructor directly, devices will be created by 
 * the BrainClient when enumerating devices internally.
 * 
 * *NOTE:* This class is not exported directly, but accessible as `BrainClient.BrainDevice` if you ever need
 * to access it for typechecking, etc.
 * 
 * @property {string} id ID of the device
 * @property {string} name Name of the Device
 * @property {string} description Device description, possibly blank
 * @property {string} created_by User in Kramer Control that created the device in the Space
 * @property {string} created_date Date that this device was added to the Space in Kramer Control
 * @property {object} driver Simplified device driver, for internal use, but feel free to examine if interested. Used by all the state/command methods internally. 
 */
class BrainDevice extends EventEmitter {

	// [internal] Create a new BrainDevice. NOTE: You should never call the constructor directly,
	// this will be called by the BrainClient when enumerating devices internally.
	constructor(brainClient, deviceDataWithDriver) {
		super();

		// Store client ref
		this._client = brainClient;

		// Enum commands and states
		this._updateData(deviceDataWithDriver);
		
		// So we don't call multiple _watchStateChanges when getState/getStates
		this._hasStateChanges = false;
	}

	_updateData(deviceDataWithDriver) {
		// For testing...
		this.revisedDataReceived = true; 

		Object.assign(this, deviceDataWithDriver);

		this._enumCustomStates();
		this._enumDriver();
	}

	_enumCustomStates() {
		this._statesById = {};
		this._statesByName = {};
		
		// For each returning later
		this._customStatesById = {};
		
		if(!this.isSystemDevice()) {
			return;
		}

		this.custom_states.forEach(data => {
			const state = this._normalizeState(data, true);	
			this._statesById[state.id]       = state;
			this._statesByName[state.name]   = state;
			this._customStatesById[state.id] = state;
		});
	}

	_normalizeState({
		name,
		reference_id,
		primitive_type,
		customData,
	}, _isCustomState) {
		const state = {
			name,
			id:   reference_id,
			type: primitive_type,
			value: null,
			normalizedValue: null,
		};
		if(_isCustomState) {
			Object.assign(state, {
				customData,
				_isCustomState,
			});
		}
		return state;
	}

	_enumDriver() {
		if(!this.driver) {
			return;
		}

		this._commandsById = {};
		this._commandsByName = {};

		Object.values(this.driver).forEach(category => {
			const catInfo = {
				name: category.name,
				id: category.reference_id,
			};

			// enum states
			Object.values(category.states).forEach(data => {
				const state = this._normalizeState(data);
				state.category = catInfo;
				this._statesById[state.id]     = state;
				this._statesByName[state.name] = state;
			});

			// enum commands
			Object.values(category.commands).forEach(({
				capability: {
					reference_id: capabilityId,
					name: capabilityName,
				},
				name,
				reference_id: id,
				staticParams,
				dynamicParams,
			}) => {
				const command = {
					category: catInfo,
					capability: {
						id: capabilityId,
						name: capabilityName
					},
					id,
					name,
					params: {},
					states: {},
				};

				// extract state refs from dynamicParams
				(dynamicParams || []).forEach(({
					name,
					state: {
						reference_id: stateId
					}
				}) => {
					const state = this._statesById[stateId];
					command.params[name] = { state };
					command.states[stateId] = state;
				});

				// extract static params
				(staticParams || []).forEach(({
					name,
					constraints,
					parameter_type: type
				}) => {
					command.params[name] = {
						constraints,
						type
					};
				});

				this._commandsById[id]     = command;
				this._commandsByName[name] = command;
			});

		});
	}

	/**
	 * Returns true if this is the system device for this Brain.
	 * 
	 * The `System Device` is a virtual device provided internally by the Brain
	 * and it provides core services such as time, weather, brain status, 
	 * custom states, etc. Only the System Device can have custom states.
	 * 
	 * @returns {boolean} `true` if this is the system device
	 */
	isSystemDevice() {
		return this.device_driver_id === systemDriverId.default;
	}

	// JIT download of driver, only for devices actually used in the project
	async _ensureDriver() {
		if(!this.driver) {
			this.driver = await this._client._getDriver(this);
			this._enumDriver();
		}
	}

	async _ensureStateValues(specificStates = null, force=false) {
		await this._ensureDriver();
		if(!this._hasStateChanges || force) {
			
			if(!this._statePromise) {
				this._statePromise = defer.default();
			}

			this._specificStates = specificStates;
			this._watchStateChanges();
			
			await this._statePromise;
		}
	}

	/**
	 * Get a hash of state IDs => state objects for this device
	 * 
	 * Also see related tutorial: <a href='./tutorial-300-states.html'>Basics/Device States</a>
	 *
	 * @returns {object} Object containing keys of state IDs and values being the state info
	 */
	async getStates() {
		await  this._ensureStateValues();
		return this._statesById;
	}

	/**
	 * Returns an object containing only custom states. If this is not the system device, returns null.
	 * 
	 * For more information on Custom States, <a href='./tutorial-300-states.html'>see the "Device States" tutorial</a>.
	 */
	async getCustomStates() {
		await this._ensureStateValues();
		if(!this.isSystemDevice())
			return null;
		return this._customStatesById;
	}

	/**
	 * Gets information about the state, including current `value` and `normalizedValue`
	 *
	 * Also see related tutorial: <a href='./tutorial-300-states.html'>Basics/Device States</a>
	 * 
	 * @param {string} key State ID or State Name
	 * @returns {object|null} Object describing the state or `null` if the state doesn't exist
	 */
	async getState(key) {
		await  this._ensureStateValues();
		return this._statesById[key] || this._statesByName[key];
	}

	/**
	 * Get hash of commands with keys being the ID and the values being the info about the command
	 * 
	 * NOTE: This command is now async, so you must `await` the result. This is async so we can JIT download
	 * the driver instead of downloading every driver for every device in a space.
	 *
	 * Also see related tutorial: <a href='./tutorial-400-sendingcommands.html'>Basics/Sending Commands</a>
	 * 
	 * See {@link BrainDevice#getCommand} for documentation on what each command looks like.
	 * 
	 */
	async getCommands() {
		await this._ensureDriver();
		return this._commandsById;
	}

	/**
	 * Get an object describing the requested command, or `null` if if the command doesn't exist. This is the same object
	 * as returned from {@link BrainDevice#getCommands} as the value associated with each command ID.
	 * 
	 * NOTE: This command is now async, so you must `await` the result. This is async so we can JIT download
	 * the driver instead of downloading every driver for every device in a space.
	 * 
	 * An example return value from this method would look like:
	 * ```javascript
	 * {
	 * 	id: "SET_SYSTEM_USE",
	 * 	name: "Set System Use",
	 * 	params: { ... },
	 * 	states: { ... },
	 * 	category: { ... },
	 * 	capability: { ... },
	 * }
	 * ```
	 * The `params` key provides info for you as the programmer to know what params the command expects. See more discussion on this key below. 
	 * The `states`, `category`, and `capability` keys are internal keys that provide internal config information.
	 * 
	 * The `params` object looks like:
	 *
	 * ```javascript
	 * {
	 * 	...
	 * 	POWER: {
	 * 		type: 'boolean',
	 * 		constraints: { ... },
	 * 	},
	 * 	SYSTEM: {
	 * 		state: { ... },
	 * 	},
	 * 	...
	 * }
	 * ```
	 * 
	 * For a params object like the above, you would pass those params to {@link BrainDevice#sendCommand} like this:
	 * ```javascript
	 * device.sendCommand(SOME_COMMAND, {
	 * 	POWER: 'ON',
	 * 	SYSTEM: '50'
	 * })
	 * ```
	 * 
	 * The above `params` object example shows two different types of params:
	 * * Dynamic (state-based)
	 * * Static (not state-based)
	 * 
	 * Dynamic (state-based) params change an associated state, and that state provides type hint info. Whereas static (non state-based) params do not change any states and contain all the type hint info in themselves as shown above.
	 *
	 * See the associated [examples/command-info.js]{@link https://github.com/kramer-control/brain-client/blob/master/examples/command-info.js} for a complete example showing how to get the `params` from the command.
	 * 
	 * Also see related tutorial: <a href='./tutorial-400-sendingcommands.html'>Basics/Sending Commands</a>
	 * 
	 * @param {string} key Command ID or Name
	 * @returns {object|null} Returns an object describing the command or `null` if the command doesn't exist
	 */
	async getCommand(key) {
		await this._ensureDriver();
		return this._commandsById[key] || this._commandsByName[key];
	}

	/**
	 * Set a custom state to a given value
	 * 
	 * **NOTE:** The concept of "Custom States" (and hence, this method) is only relevant on the System Device ({@link BrainDevice#isSystemDevice} must return `true`)
	 * otherwise calling this method will throw an error.
	 * 
	 * For more information on device states and custom states, see the <a href='./tutorial-300-states.html'>Basics/Device States</a> tutorial.
	 * 
	 * @param {string} key State ID or Name - throws {@link BrainDevice.ErrorInvalidState} if the ID/Name is not a defined custom state (must be defined in the KC Builder)
	 * @param {any}    value Any valid value
	 * @throws {BrainDevice.ErrorNotSystemDevice} {@link BrainDevice.ErrorNotSystemDevice} if the device is not a system device
	 * @throws {BrainDevice.ErrorInvalidState} {@link BrainDevice.ErrorInvalidState} if ID/Name is not a defined custom state value 
	 */
	async setCustomState(key, value) {
		if(!this.isSystemDevice()) {
			throw new ErrorNotSystemDevice("Not a system device");
		}

		const state = key.id ? key : this.getState(key);
		if(!state) {
			throw new ErrorInvalidState("Invalid state key " + key + " - does not match any known custom state Name or ID");
		}

		if(!state._isCustomState) {
			throw new ErrorInvalidState("State " + key + " is not a custom state, you cannot set states directly that are not custom states");
		}

		const macro = { 
			id:      genId.default(),
			type:    'send_macro_message',
			actions: [{
				type:          "state_change",
				device_id:     this.id,
				capability_id: "CUSTOM_STATES",
				category_id:   "CUSTOM_STATES",
				state_id:      state.id,
				state_name:    state.name,
				static_parameters: [{
					// "New_Value" is the required name for setting custom states.
					// If not found, Brain will not set the state
					"name":  "New_Value",
					"value": (value + ""), // force-stringify since Brain does not handle literal numbers 
				}],
			}],
		};

		// send the macro
		this._client.wrapApiCall('send-macro', macro);

		// wait for next update from brain
		this._hasStateChanges = false;
		await this._ensureStateValues();

		return state;
	}

	/**
	 * Execute command and return any changed states.
	 * 
	 * **Example Usage**
	 * ```javascript
	 * device.sendCommand('SEND_SYSTEM_USE', {
	 *	SYSTEM_STATE: 'ON'
	 * });
	 * ```
	 * 
	 * Also see the related tutorial: <a href='./tutorial-400-sendingcommands.html'>Basics/Sending Commands</a>
	 * 
	 * @param {string|object} key Command ID, command Name, or command object - throws {@link BrainDevice.ErrorInvalidCommand} if given a command ID or name that doesn't exist
	 * @param {object} params Key/value object of params for the command
	 * @throws {BrainDevice.ErrorInvalidCommand} Throws {@link BrainDevice.ErrorInvalidCommand} if given ID/Name not a defined command
	 */
	async sendCommand(key, params={}) {
		// console.log(` * send command > ${key} > start`);
		// await this._ensureStateValues(null, true);

		const command = key.id ? key : await this.getCommand(key);
		if(!command) {
			throw new ErrorInvalidCommand("Invalid command key " + key + " - does not match any known command Name or ID");
		}

		const macro = {
			id:   genId.default(),
			type: 'send_macro_message',
			actions: [{
				type:               "command",
				capability_id:      command.capability.id,
				category_id:        command.category.id,
				command_id:         command.id,
				command_name:       command.name,
				device_driver_id:   this.device_driver_id,
				device_id:          this.id,
				dynamic_parameters: [],
				gesture:            "",
				static_parameters:  Object.keys(params).map(name => {
					return {
						id:    genId.default(),
						name:  name.toUpperCase(),
						value: params[name] + "",
					}
				}),
			}]
		};

		// send the macro
		this._client.wrapApiCall('send-macro', macro);

		// console.log(` * send command > ${key} > command.states`, command.states);

		// Setup specific hash so flags can be set
		const specificStates = {};
		Object.keys(command.states).forEach(id => specificStates[id] = false);
		
		// wait for next update from brain
		await this._ensureStateValues(specificStates, true);

		const results = {};

		Object.keys(command.states).forEach(id => {
			results[id] = this._statesById[id].value;
		});

		// console.log(` * send command > ${key} > end`);
		return results;
	}


	/**
	 * Attach an event listener to this device. If you pass the `STATE_CHANGED` event
	 * as the event name, the attached {@link BrainClient} will automatically inform the brain
	 * to start sending state changes for this device.
	 * 
	 * Note: The `STATE_CHANGED` event fires for ALL states on the device. You must filter
	 * on the `id` property of the payload to see if the event represents a change of the state
	 * you are interested in.
	 * > Related: See notes at the top of <a href='#statechanges'>this file titled "Listening for State Changes</a>
	 * 
	 * This overrides [EventEmitter]{@link https://nodejs.org/api/events.html#events_class_eventemitter}'s `on` function to intercept the event name,
	 * but still useses `EventEmitter` to handle events, so you can use the 
	 * inheritted `off` from `EventEmitter` to stop listening for changes.
	 * 
	 * @param {string} event Name of the event to watch
	 * @param {function} callback Your event handler callback
	 */
	async on(event, callback) {
		super.on(event, callback);

		if(event === BrainDevice.STATE_CHANGED) {
			await this._ensureDriver();
			this._watchStateChanges();
			// console.warn("[Device] listener attached, starting watch for ", this.id, this.name)
		}
	}

	/**
	 * Remove an event listener from this device. If you pass the `STATE_CHANGED` event
	 * as the event name, the attached {@link BrainClient} will automatically 
	 * unsubscribe from state changes for this device if there are no other event listeners
	 * for the `STATE_CHANGED` event.
	 * 
	 * This overrides [EventEmitter]{@link https://nodejs.org/api/events.html#events_class_eventemitter}'s `off` function to intercept the event name,
	 * but still useses `EventEmitter` to handle events, so you can use the 
	 * inheritted `on` from `EventEmitter` to start listening for changes again.
	 * 
	 * @param {string} event Name of the event to stop listening from
	 * @param {function} callback Your event handler callback (must be identical to what you passed to {@link BrainDevice#on})
	 */
	off(event, callback) {
		super.off(event, callback);

		const sc = BrainDevice.STATE_CHANGED;
		if(event === sc) {
			// EventEmitter stores it's listeners in this._events, so we
			// just use that to check for all events disconnected
			if(!this._events[sc] || this._events[sc].length <= 0) {
				// console.error("[Device] ALL listeners removed", this.id, this.name, sc)
				this._unwatchStateChanges();
			}
		}
	}

	// [internal] Manually ask for state changes from the brain. However, you should not need to call this
	// in practice. Just call `<device>.on("STATE_CHANGED", () => {})` and BrainDevice will
	// call this internally.
	_watchStateChanges() {
		if(this._watchStateRequested) {
			return;
		}
		this._watchStateRequested = true;

		this._client.watchStates(this.id);
	}

	_unwatchStateChanges() {
		this._watchStateRequested = false;
		// Final 'true' arg is 'unwatch' - tells the brain to unsubscribe
		// this client from changes to this device
		this._client.watchStates(this.id, [], true);
	}

	_reconnected() {
		if(this._watchStateRequested) {
			// TODO: Add test coverage of this branch/situation
			console.log("[Device] reconnected, reqeusting state watch again for ", this.id, this.name);
			this._client.watchStates(this.id);
		}
	}

	// [internal] Called from BrainClient when receiving a state_change_event
	// with this device's device ID
	async processStateChanges(stateChangeList) {
		await this._ensureDriver();
		this._hasStateChanges = true;

		/* Sample:
			[
				{
					category_id: 'VOLUME',
					category_name: 'Volume',
					device_driver_id: 'c2fa61a6-5d45-4a3b-a3e2-60d3899802df',
					device_driver_version: 3,
					device_id: '098793c1-b73e-4dc2-99d4-b7be0308a24a',
					device_name: 'Jay System Clock - Other - TCP_UDP',
					feedback_codes: [Array],
					is_state_changed: false,
					log_changes: true,
					logging_threshold: 0,
					state_id: 'STATE_1',
					state_key: '',
					state_name: 'Volume',
					state_normalized_value: '0.55',
					state_value: '55'
				}
			]
		*/
		stateChangeList.forEach(change => {
			const { 
				state_id: id, 
				state_key: key, 
				state_name: name,
				state_normalized_value: normalizedValue,
				state_value: value
			} = change;

			const state = this._statesById[id];
			// if(id === 'TEMP_UNIT_STATE') {
				// console.log(`${Date.now()}: stateChangeInternal: ..checking: state id=${id}:`, normalizedValue);
			// }

			if (state) {
				state.value = value;

				let newValue;
				if(state.type === 'number') {
					newValue = parseFloat(normalizedValue);
				} else {
					newValue = normalizedValue;
				}

				if (state.normalizedValue !== newValue) {
					const oldValue = state.normalizedValue;
					state.normalizedValue = newValue;
					
					this.emit(BrainDevice.STATE_CHANGED, state); //normalizedChange)

					// if(id === 'TEMP_UNIT_STATE') {
					// 	console.log(`${Date.now()}: stateChangeInternal: **CHANGED** id=${id}: normalizedValue:`, normalizedValue, ', oldValue: ', oldValue);
					// }

					// Logger.getDefaultLogger().d(BrainDevice.LOG_TAG, "Device state changed, normalizedChange=", normalizedChange);

					if (this._statePromise) {
						let completed = true;

						if (this._specificStates) {
							completed = false;
							if(this._specificStates[id] !== undefined) {
								this._specificStates[id] = true;
								// Only completed if all _specificStates set to true indicating all states are received
								const remainingStates = Object.values(this._specificStates).filter(flag => flag === false);
								completed = remainingStates.length === 0;

								// if(id === 'TEMP_UNIT_STATE') {
								// 	console.log("[___SPECIFIC_STATES___]", { id, specificStates: this._specificStates, completed, value });
								// 	console.log(" ** ", remainingStates);
								// }
							}
						}

						if(completed) {
							// console.log(` * process state changes, has _statePromise, but now completed, specificStates is:`, this._specificStates);
							this._statePromise.resolve();
							this._statePromise = null;
							this._specificStates = null;
						}
					}
				}
			} else {
				// console.log("State ID not found in internal enum:", id, Object.keys(this._statesById));
				if(!this.warnedMissing) {
					this.warnedMissing = {};
				}
				
				if(!this.warnedMissing[id]) {
					this.warnedMissing[id] = true;
					Logger.default.getDefaultLogger().e(BrainDevice.LOG_TAG, "State ID not found in internal enum:", id, Object.keys(this._statesById));
				}
			}
		});
	}
}


Object.assign(BrainDevice, {
	ErrorNotSystemDevice,

	/**
	 * @property {string} STATE_CHANGED - Static class property, event name that is emitted when a state on 
	 * this device changes on the Brain. Use like: `BrainDevice.STATE_CHANGED`. See notes at the top of <a href='#statechanges'>this file titled "Listening for State Changes</a>.
	 * @memberof BrainDevice
	 */
	STATE_CHANGED: "STATE_CHANGED",

	// Internal prop used for logging
	LOG_TAG: "BrainDevice",
});

exports.default = BrainDevice;
//# sourceMappingURL=BrainDevice.js.map