UNPKG

homey-api

Version:
639 lines (545 loc) 18.5 kB
/* eslint-disable no-multi-assign */ 'use strict'; const EventEmitter = require('../../EventEmitter'); const Util = require('../../Util'); const HomeyAPIError = require('../HomeyAPIError'); // eslint-disable-next-line no-unused-vars const Item = require('./Item'); /** * @class * @hideconstructor * @extends EventEmitter * @memberof HomeyAPIV3 */ class Manager extends EventEmitter { static ID = null; // Set by HomeyAPIV3.js static CRUD = {}; constructor({ homey, items, operations }) { super(); Object.defineProperty(this, '__homey', { value: homey, enumerable: false, writable: false, }); Object.defineProperty(this, 'itemClasses', { value: Object.entries(items).reduce((obj, [itemName, item]) => { const ItemClass = this.constructor.CRUD[itemName] ? this.constructor.CRUD[itemName] : (() => { return class extends Item {}; })(); ItemClass.ID = item.id; obj[itemName] = ItemClass; return obj; }, {}), enumerable: false, writable: false, }); Object.defineProperty(this, 'itemNames', { value: Object.entries(items).reduce((obj, [itemName, item]) => { obj[item.id] = itemName; return obj; }, {}), enumerable: false, writable: false, }); Object.defineProperty(this, '__connected', { value: false, enumerable: false, writable: true, }); Object.defineProperty(this, '__cache', { value: Object.values(items).reduce( (obj, item) => ({ ...obj, [item.id]: {}, }), {} ), enumerable: false, writable: false, }); Object.defineProperty(this, '__cacheAllComplete', { value: Object.values(items).reduce( (obj, item) => ({ ...obj, [item.id]: false, }), {} ), enumerable: false, writable: false, }); Object.defineProperty(this, '__pendingCalls', { value: {}, enumerable: false, writable: false, }); // Create methods for (const [operationId, operation] of Object.entries(operations)) { Object.defineProperty( this, // Name method __super__foo if there's an override method this[operationId] ? `__super__${operationId}` : operationId, { value: async ({ $validate = true, $cache = true, $updateCache = true, $timeout = operation.timeout ?? 5000, $socket = operation.socket ?? true, $body = {}, $query = {}, $headers = {}, shouldRetry, ...args } = {}) => { let { path } = operation; let body = { ...$body }; const query = { ...$query }; const headers = { ...$headers }; // Verify & Transform parameters if (operation.parameters) { // Parse Parameters for (const [parameterId, parameter] of Object.entries(operation.parameters)) { const value = args[parameterId]; // Validate the parameter if ($validate) { if (parameter.required === true && typeof value === 'undefined') { throw new Error(`Missing Parameter: ${parameterId}`); } if (typeof value !== 'undefined') { if (parameter.in !== 'query' && parameter.type === 'string' && typeof value !== 'string') { throw new Error(`Invalid Parameter Type: ${parameterId}. Got: ${typeof value}. Expected: string`); } if (parameter.type === 'number' && typeof value !== 'number') { throw new Error(`Invalid Parameter Type: ${parameterId}. Got: ${typeof value}. Expected: number`); } if (parameter.type === 'boolean' && typeof value !== 'boolean') { throw new Error( `Invalid Parameter Type: ${parameterId}. Got: ${typeof value}. Expected: boolean` ); } if (parameter.type === 'object' && typeof value !== 'object') { throw new Error(`Invalid Parameter Type: ${parameterId}. Got: ${typeof value}. Expected: object`); } if (parameter.type === 'array' && !Array.isArray(value)) { throw new Error(`Invalid Parameter Type: ${parameterId}. Got: ${typeof value}. Expected: array`); } if (Array.isArray(parameter.type)) { // TODO } } } // Set the parameter if (typeof value !== 'undefined') { switch (parameter.in) { case 'path': { path = path.replace(`:${parameterId}`, value); break; } case 'body': { if (parameter.root) { body = value; } else { body[parameterId] = value; } break; } case 'query': { query[parameterId] = value; break; } default: { throw new Error(`Invalid 'in': ${parameter.in}`); } } } } } // Append query to path if (Object.keys(query).length > 0) { const queryString = Util.serializeQueryObject(query); path = `${path}?${queryString}`; } const pendingCallId = `${operationId}::${path}`; if ( operation.method.toLowerCase() === 'get' && $cache === true && this.__pendingCalls[pendingCallId] != null && Object.keys(body).length === 0 ) { this.__debug(`Reusing pending call ${pendingCallId}`); const result = await this.__pendingCalls[pendingCallId]; return result; } const pendingCall = this.__request({ $validate, $cache, $updateCache, $timeout, $socket, operationId, operation, path, body, query, headers, shouldRetry, ...args, }); if ( operation.method.toLowerCase() === 'get' && $cache === true && this.__pendingCalls[pendingCallId] == null && Object.keys(body).length === 0 ) { this.__pendingCalls[pendingCallId] = pendingCall; this.__pendingCalls[pendingCallId] .catch(() => { // We do nothing with the error here the caller is responsible. We just want to // cleanup the pending call. }) .finally(() => { delete this.__pendingCalls[pendingCallId]; }); } const result = await pendingCall; return result; }, } ); } } async __request({ $cache, $updateCache, $timeout, $socket, operationId, operation, path, body, headers, shouldRetry, ...args }) { let result; const benchmark = Util.benchmark(); // If connected to Socket.io, // try to get the CRUD Item from Cache. if (this.isConnected() && operation.crud && $cache === true) { const itemId = this.itemClasses[operation.crud.item].ID; switch (operation.crud.type) { case 'getOne': { if (this.__cache[itemId][args.id]) { return this.__cache[itemId][args.id]; } break; } case 'getAll': { if (this.__cache[itemId] && this.__cacheAllComplete[itemId]) { return this.__cache[itemId]; } break; } default: break; } } // If Homey is connected to Socket.io, // send the API request to socket.io. // This is about ~2x faster than HTTP if (this.homey.isConnected() && $socket === true) { result = await Util.timeout( new Promise((resolve, reject) => { this.__debug(`IO ${operationId}`); this.homey.__homeySocket.emit( 'api', { args, operation: operationId, uri: this.uri, }, (err, result) => { if (err != null) { if (typeof err === 'object') { err = new HomeyAPIError( { stack: err.stack, error: err.error, error_description: err.error_description, }, err.statusCode || err.code || 500 ); } else if (typeof err === 'string') { err = new HomeyAPIError( { error: err, }, 500 ); } return reject(err); } return resolve(result); } ); }), $timeout ); } else { // Get from HTTP result = await this.homey.call({ $timeout, headers, body, path: `/api/manager/${this.constructor.ID}${path}`, method: operation.method, shouldRetry, }); } // Transform and cache output if this is a CRUD call if (operation.crud) { const ItemClass = this.itemClasses[operation.crud.item]; switch (operation.crud.type) { case 'getOne': { let props = { ...result }; props = ItemClass.transformGet(props); const item = new ItemClass({ id: props.id, homey: this.homey, manager: this, properties: props, }); if (this.isConnected() && $updateCache === true) { this.__cache[ItemClass.ID][item.id] = item; } return item; } case 'getAll': { const items = {}; // Add all to cache for (let props of Object.values(result)) { props = ItemClass.transformGet(props); if (this.isConnected() && $updateCache === true && this.__cache[ItemClass.ID][props.id]) { items[props.id] = this.__cache[ItemClass.ID][props.id]; items[props.id].__update(props); } else { items[props.id] = new ItemClass({ id: props.id, homey: this.homey, manager: this, properties: props, }); if (this.isConnected() && $updateCache === true) { this.__cache[ItemClass.ID][props.id] = items[props.id]; } } } // Find and delete deleted items from cache if (this.__cache[ItemClass.ID] && $updateCache === true) { for (const cachedItem of Object.values(this.__cache[ItemClass.ID])) { if (!items[cachedItem.id]) { delete this.__cache[ItemClass.ID][cachedItem.id]; } } } // Mark cache as complete if (this.isConnected() && $updateCache === true) { this.__cacheAllComplete[ItemClass.ID] = true; } return items; } case 'createOne': case 'updateOne': { let item = null; let props = { ...result }; props = ItemClass.transformGet(props); if (this.isConnected() && $updateCache === true && this.__cache[ItemClass.ID][props.id]) { item = this.__cache[ItemClass.ID][props.id]; item.__update(props); } else { item = new ItemClass({ id: props.id, homey: this.homey, manager: this, properties: { ...props }, }); if (this.isConnected() && $updateCache === true) { this.__cache[ItemClass.ID][props.id] = item; } } return item; } case 'deleteOne': { if (this.isConnected() && $updateCache === true && this.__cache[ItemClass.ID][args.id]) { this.__cache[ItemClass.ID][args.id].destroy(); delete this.__cache[ItemClass.ID][args.id]; } return undefined; } default: break; } } this.__debug(`${operationId} took ${benchmark()}ms`); return result; } /** * The Homey of the Manager. * @type {HomeyAPIV3} */ get homey() { return this.__homey; } /** * The URI of the Item, e.g. `homey:manager:bar`. * @type {String} */ get uri() { return `homey:manager:${this.constructor.ID}`; } __debug(...props) { this.homey.__debug(`[Manager${this.constructor.ID[0].toUpperCase() + this.constructor.ID.slice(1)}]`, ...props); } /** * If this manager's namespace is connected to Socket.io. * @returns {Boolean} */ isConnected() { return this.__connected === true; } /** * Connect to this manager's Socket.io namespace. * @returns {Promise<void>} */ async connect() { this.__debug('connect'); // If disconnecting, await that first try { await this.__disconnectPromise; // eslint-disable-next-line no-empty } catch (err) {} if (this.__connectPromise) { await this.__connectPromise; return; } if (this.io) { await this.io; return; } this.__connectPromise = Promise.resolve().then(async () => { if (!this.io) { this.io = this.homey.subscribe(this.uri, { onConnect: () => { this.__debug('onConnect'); this.__connected = true; }, onDisconnect: reason => { this.__debug(`onDisconnect Reason:${reason}`); this.__connected = false; // Disable for now. We should probably only set the cache to invalid. // Clear CRUD Item cache // for (const itemId of Object.keys(this.__cache)) { // this.__cache[itemId] = {}; // this.__cacheAllComplete[itemId] = false; // } }, onReconnect: () => { this.__debug(`onReconnect`); this.__connected = true; }, onEvent: (event, data) => { this.__debug('onEvent', event); // Transform & add to cache if this is a CRUD event if (event.endsWith('.create') || event.endsWith('.update') || event.endsWith('.delete')) { const [itemId, operation] = event.split('.'); const itemName = this.itemNames[itemId]; const ItemClass = this.itemClasses[itemName]; switch (operation) { case 'create': { const props = ItemClass.transformGet(data); const item = new ItemClass({ id: props.id, homey: this.homey, manager: this, properties: props, }); this.__cache[ItemClass.ID][props.id] = item; return this.emit(event, item); } case 'update': { const props = ItemClass.transformGet(data); if (this.__cache[ItemClass.ID][props.id]) { const item = this.__cache[ItemClass.ID][props.id]; item.__update(props); return this.emit(event, item); } break; } case 'delete': { const props = ItemClass.transformGet(data); if (this.__cache[ItemClass.ID][props.id]) { const item = this.__cache[ItemClass.ID][props.id]; item.__delete(); delete this.__cache[ItemClass.ID][item.id]; return this.emit(event, { id: item.id, }); } break; } default: break; } } // Fire event listeners this.emit(event, data); }, }); } await this.io; }); // Delete the connecting Promise this.__connectPromise .catch(() => { delete this.io; }) .finally(() => { delete this.__connectPromise; }); await this.__connectPromise; } /** * Disconnect from this manager's Socket.io namespace. * @returns {Promise<void>} */ async disconnect() { this.__debug('disconnect'); // If connecting, await that first try { await this.__connectPromise; // eslint-disable-next-line no-empty } catch (err) {} this.__disconnectPromise = Promise.resolve().then(async () => { this.__connected = false; if (this.io) { await this.io.then(io => io.unsubscribe()).catch(err => this.__debug('Error Disconnecting:', err)); delete this.io; } }); // Delete the disconnecting Promise this.__disconnectPromise .catch(() => {}) .finally(() => { delete this.__disconnectPromise; }); await this.__disconnectPromise; } /** * Destroy this Manager by cleaning up all references, unbinding event listeners and disconnecting from the Socket.io namespace. */ destroy() { // Clear cache for (const id of Object.keys(this.__cache)) { this.__cache[id] = {}; } for (const id of Object.keys(this.__cacheAllComplete)) { this.__cacheAllComplete[id] = false; } // Remove all event listeners this.removeAllListeners(); // Disconnect from Socket.io this.disconnect().catch(() => {}); } } module.exports = Manager;