homey-api
Version:
639 lines (545 loc) • 18.5 kB
JavaScript
/* 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;