UNPKG

homee-api

Version:
602 lines (530 loc) 15.8 kB
/** * created by stfnhmplr (info@himpler.com) * a homee-api-wrapper * @LICENSE MIT */ const WebSocket = require('ws'); const axios = require('axios'); const qs = require('qs'); const shajs = require('sha.js'); const EventEmitter = require('events'); const debug = require('debug')('homee'); const Enums = require('./lib/enums'); const { updateList } = require('./lib/helpers'); class Homee extends EventEmitter { /** * * @param host {string} * @param user {string} * @param password {string} * @param cOptions {Object} */ constructor(host, user, password, cOptions = {}) { super(); const options = { device: 'homeeApi', reconnect: true, reconnectInterval: 5000, maxRetries: Infinity, }; // merge options Object.keys(cOptions).forEach((attr) => { if ({}.hasOwnProperty.call(cOptions, attr)) { options[attr] = cOptions[attr]; } }); this.host = host; this.user = user; this.password = password; this.device = options.device; this.deviceId = options.device .replace(/([a-z])([A-Z])/g, '$1-$2') .replace(/\s+/g, '-') .toLowerCase(); this.reconnectInterval = options.reconnectInterval; this.shouldReconnect = options.reconnect; this.maxRetries = options.maxRetries; this.nodes = []; this.groups = []; this.relationships = []; this.homeegrams = []; this.plans = []; this.ws = null; this.token = ''; this.expires = 0; this.connected = false; this.retries = 0; this.shouldClose = false; this.enums = Enums; } /** * query access token * @returns {Promise<any>} * @private */ getAccessToken() { debug('get access token'); const authBuffer = Buffer.from( `${this.user}:${shajs('sha512').update(this.password).digest('hex')}`, ); const options = { method: 'post', timeout: 2500, url: `${this.url()}/access_token`, headers: { Authorization: `Basic ${authBuffer.toString('base64')}`, 'Content-Type': 'application/x-www-form-urlencoded', }, data: qs.stringify({ device_name: this.device, device_hardware_id: this.deviceId, device_os: this.enums.CADeviceOS.CADeviceOSLinux, device_type: this.enums.CADeviceType.CADeviceTypeNone, device_app: this.enums.CADeviceApp.CADeviceAppHomee, }), }; return new Promise((resolve, reject) => { if (this.token && this.expires > Date.now()) { debug('token still valid'); return resolve(this.token); } if (this.retries) { debug('reconnect attempt #%d', this.retries); this.emit('reconnect', this.retries); } this.retries += 1; if (this.retries > this.maxRetries) { this.emit('maxRetries', this.maxRetries); return reject(new Error(`reached max retries (${this.maxRetries})`)); } return axios(options).then((res) => { const re = /^access_token=([0-z]+)&.*&expires=(\d+)$/; const matches = res.data.match(re); if (!matches) return reject(new Error('invalid response')); let expires; [, this.token, expires] = matches; this.expires = Date.now() + expires * 1000; debug(`received access token, valid until: ${new Date(this.expires).toISOString()}`); this.retries = 0; return resolve(this.token); }).catch((err) => { debug(`cannot receive access token, ${err}`); if (err.response) { return reject( new Error(`Request failed with status ${err.response.status} ${err.response.statusText}`), ); } return setTimeout(() => resolve(this.getAccessToken()), this.reconnectInterval * this.retries); }); }); } /** * connect to homee * @returns {Promise<any>} */ connect() { this.shouldClose = false; return new Promise((resolve, reject) => { this.getAccessToken() .then(() => { this.openWs(resolve, reject); }) .catch((err) => { reject(err); }); }); } /** * open a Websocket Connection to homee * @param resolve {function} * @param reject {function} * @private */ openWs(resolve, reject) { if (this.retries) { debug('reconnect attempt #%d', this.retries); this.emit('reconnect', this.retries); } this.retries += 1; if (this.retries > this.maxRetries) { debug('reached max retries %d', this.maxRetries); this.emit('maxRetries', this.maxRetries); return; } try { debug('trying to connect'); this.ws = new WebSocket( `${this.wsUrl()}/connection?access_token=${this.token}`, 'v2', // Sec-WebSocket-Protocol { protocolVersion: 13, origin: this.url(), handshakeTimeout: 5000, }, ); } catch (err) { debug('cannot open ws connection err: %s', err); if (typeof reject === 'function') reject(new Error(`cannot connect to homee${err}`)); setTimeout(() => this.openWs(), this.reconnectInterval * this.retries); } this.ws.on('open', () => { if (typeof resolve === 'function') resolve(); this.connected = true; this.retries = 1; this.emit('connected'); debug('connected to homee'); this.heartbeatHandler = this.startHearbeatHandler(); this.send('GET:all'); }); this.ws.on('message', (message) => { try { const parsedMessage = JSON.parse(message); this.handleMessage(parsedMessage); } catch (err) { debug(`can't parse json message ${message}`); this.emit('error', 'Received unexpected message from websocket'); } }); this.ws.on('close', (reason) => { if (!this.shouldClose && this.retries <= 1) debug('lost connection to homee'); this.stopHeartbeathandler(); this.connected = false; this.ws = null; this.emit('disconnected', reason); if (this.shouldReconnect && !this.shouldClose) { setTimeout( () => this.openWs(), this.reconnectInterval * this.retries, ); } }); this.ws.on('error', (err) => { debug('Websocket %s', err); this.emit('error', err.toString()); }); } /** * sends a raw message via websocket * @param {string} message the message, i.e. 'GET:nodes' */ send(message) { if (!this.connected || !this.ws) return; debug('sending message "%s" to homee', message); this.ws.send(message, (err) => { if (err) { debug('error sending message: %s', err); this.emit('error', `message could not be sent${err}`); } }); } /** * handle incoming message * @private * */ handleMessage(message) { let messageType; try { [messageType] = Object.keys(message); } catch (error) { debug('Error parsing incoming message %s', error); this.emit('error', error); return; } debug(`received message of type "${messageType}" from homee`); switch (messageType) { case 'all': debug(message); this.nodes = message.all.nodes; this.groups = message.all.groups; this.relationships = message.all.relationships; this.plans = message.all.plans; this.homeegrams = message.all.homeegrams; break; case 'attribute': this.handleAttributeChange(message.attribute); break; case 'group': updateList(this.groups, message.group); break; case 'groups': this.groups = message.groups; break; case 'node': updateList(this.nodes, message.node); break; case 'nodes': this.nodes = message.nodes; break; case 'relationship': updateList(this.relationships, message.relationship); break; case 'relationships': this.relationships = message.relationships; break; case 'homeegram': updateList(this.homeegrams, message.homeegram); break; case 'homeegrams': this.homeegrams = message.homeegrams; break; case 'plan': updateList(this.plans, message.plan); break; case 'plans': this.plans = message.plans; break; case 'attribute_history': case 'homeegram_history': case 'node_history': this.emit('history', messageType.replace('_history', ''), message[messageType]); break; default: debug(`No special handling for message of type "${messageType}"`); } // broadcast on specific channel const ignore = ['attribute', 'attribute_history', 'homeegram_history', 'node_history']; if (ignore.indexOf(messageType) === -1) this.emit(messageType, message[messageType]); // broadcast message this.emit('message', message); } /** * attaches the the node to an given attribute, * updates the attribute at the global node list and emits an event * @param attribute {Object} * @private */ handleAttributeChange(attribute) { debug(`attribute with id #${attribute.id} changed`); try { const nodeIndex = this.nodes.findIndex((node) => node.id === attribute.node_id); const attributeIndex = this.nodes[nodeIndex].attributes.findIndex( (a) => a.id === attribute.id, ); this.nodes[nodeIndex].attributes[attributeIndex] = attribute; this.emit('attribute', { ...attribute, node: this.nodes[nodeIndex] }); } catch (e) { debug('Cannot find node, emitting attribute only'); this.emit('attribute', attribute); } } /** * update attribute values * PUT:/nodes/1/attributes/1?target_value=50.5 * @param device_id {number} * @param attribute_id {number} * @param value {number} */ setValue(deviceId, attributeId, value) { debug( `trying to set ${value} as target_value for attribute #${attributeId} (device #${deviceId})`, ); if (typeof deviceId !== 'number') { this.emit('error', 'device_id must be a number'); return; } if (typeof attributeId !== 'number') { this.emit('error', 'attribute_id must be a number'); return; } if (typeof value !== 'number') { this.emit('error', 'value must be a number'); return; } this.send(`PUT:/nodes/${deviceId}/attributes/${attributeId}?target_value=${value}`); } /** * start heartbeat handler to monitor ws connection * @returns {number} * @private */ startHearbeatHandler() { debug('starting HearbeatHandler'); this.ws.on('pong', () => { debug('received pong'); this.connected = true; }); return setInterval(() => { debug('send ping'); if (this.ws && this.connected === false) { debug('did not receive pong, terminating connection...'); this.ws.terminate(); this.ws = null; debug('lost ping, try reconnect in %ds', this.reconnectInterval / 1000); return; } this.connected = false; if (this.ws) { this.ws.ping((err) => { if (err) debug('error sending ping command to homee: %s', err.toString()); }); } }, 30000); } /** * get attributes * @returns {Array} */ get attributes() { if (!this.nodes.length) return []; return this.nodes.map((n) => n.attributes).reduce((a, b) => a.concat(b), []); } /** * returns the nodes of a given group * @param group {string|number} * @returns {Array} */ getNodesByGroup(group) { if (!this.relationships) throw new Error('No relationships available'); let groupId; if (typeof group === 'string') { groupId = this.groups.find((g) => g.name === encodeURIComponent(group)).id; } else { groupId = group; } const nodeIds = this.relationships.filter((r) => r.group_id === groupId).map((r) => r.node_id); return this.nodes.filter((n) => nodeIds.indexOf(n.id) > -1); } /** * create a new group * * @param {string} name * @param {string} image */ createGroup(name, image = 'default') { this.send(`POST:groups?name=${name}&image=${image}`); } /** * delete a group * @param id {number} */ deleteGroup(id) { this.send(`DELETE:groups/${id}`); } /** * plays a homeegram * * @param id {number} Homeegram ID */ play(id) { debug('play homeegram #%d', id); this.send(`PUT:homeegrams/${id}?play=1`); } /** * activates a homeegram * @param id {number} */ activateHomeegram(id) { debug('activate homeegram #%d', id); this.send(`PUT:homeegrams/${id}?active=1`); } /** * deactivates a homeegram * @param id {number} */ deactivateHomeegram(id) { debug('deactivate homeegram #%d', id); this.send(`PUT:homeegrams/${id}?active=0`); } /** * stop heartbeathandler * @private */ stopHeartbeathandler() { if (!this.heartbeatHandler) return; clearInterval(this.heartbeatHandler); this.heartbeatHandler = null; debug('stopped HeartbeatHandler'); } /** * close connection */ disconnect() { this.shouldClose = true; if (this.ws) { this.ws.close(1000, 'closed by user request'); } debug('connection closed'); this.emit('disconnected', 'closed by user request'); } /** * retrieve history for node, attribute or homeegram * @param type "node", "attribute" or "homeegram" * @param id node id, attribute id, or homeegram id * @param from {timestamp} * @param till {timestamp} * @param limit {number} */ getHistory(type, id, from = null, till = null, limit = null) { debug('request history for %s #%d', type, id); let params = ''; if (from) params += `from=${Math.floor(from / 1000)}&`; if (till) params += `till=${Math.floor(till / 1000)}&`; if (limit) params += `limit=${limit}&`; switch (type) { case 'node': case 'homeegram': this.send(`GET:${type}s/${id}/history?${params}`); break; case 'attribute': { const attribute = [].concat(...this.nodes.map((n) => n.attributes)) .find((a) => a.id === id); this.send(`GET:nodes/${id}/attributes/${attribute.id}/history?${params}`); break; } default: this.emit( 'error', 'history is only available for type "node", "attribute" and "homeegram"', ); } } /** * retrieve diary entries * hint: you should use one or more parameters to shrink the result set * @param from {timestamp} * @param till {timestamp} * @param limit {number} */ getDiary(from = null, till = null, limit = null) { debug('request diary entries'); let params = ''; if (from) params += `from=${Math.floor(from / 1000)}&`; if (till) params += `till=${Math.floor(till / 1000)}&`; if (limit) params += `limit=${limit}&`; this.send(`GET:diary?${params}`); } /** * returns the base url * @returns {string} * @private */ url() { if (/^[0-z]{12}$/.test(this.host)) return `https://${this.host}.hom.ee`; return `http://${this.host}:7681`; } /** * returns the ws-url * @returns {string} * @private */ wsUrl() { if (/^[0-z]{12}$/.test(this.host)) return `wss://${this.host}.hom.ee`; return `ws://${this.host}:7681`; } getLog() { return new Promise((resolve, reject) => { const options = { timeout: 5000, url: `${this.url()}/logfile.log`, params: { access_token: this.token, }, }; axios(options) .then((res) => resolve(res.data)) .catch((err) => reject(err)); }); } } module.exports = Homee;