UNPKG

hb-deconz-tools

Version:
224 lines (213 loc) 8.35 kB
// hb-deconz-tools/lib/WsClient.js // // Homebridge deCONZ Tools. // Copyright © 2018-2026 Erik Baauw. All rights reserved. import { EventEmitter, once } from 'node:events' import WebSocket from 'ws' import { timeout } from 'hb-lib-tools' import { OptionParser } from 'hb-lib-tools/OptionParser' /** Client for deCONZ gateway web socket notifications. * <br>See {@link WsClient}. * @name WsClient * @type {Class} * @memberof module:hb-deconz-tools */ /** Client for web socket notifications by a deCONZ gateway. * * See the * [deCONZ](https://dresden-elektronik.github.io/deconz-rest-doc/endpoints/websocket/) * documentation for a better understanding of the web socket notifications. * @memberof Deconz */ class WsClient extends EventEmitter { /** Instantiate a new web socket client. * @param {object} params - Parameters. * @param {string} [params.host='localhost:443'] - IP address or hostname * and port of the web socket server. * @param {boolean} [params.wss=false] - Use secure web socket connection. * @param {?string} params.fingerprint - The fingerprint of the pinned * self-signed SSL certificate of the deCONZ gateway. * @param {integer} [params.retryTime=10] - Time (in seconds) to try and * reconnect when the server connection has been closed. * @param {boolean} [params.raw=false] - Issue raw events instead of parsing * them.<br> * When specified, {@link WsClient#event:notification notification} * events are emitted, in lieu of {@link WsClient#event:changed changed}, * {@link WsClient#event:added added}, and * {@link WsClient#event:sceneRecall sceneRecall} events. */ constructor (params = {}) { super() this.config = { hostname: 'localhost', port: 443, retryTime: 15 } const optionParser = new OptionParser(this.config) optionParser .hostKey() .boolKey('wss') .stringKey('fingerprint') .instanceKey('logger') .intKey('retryTime', 0, 120) .boolKey('raw') .parse(params) for (const f of ['warn', 'log', 'debug', 'vdebug', 'vvdebug']) { this[f] = this.config.logger?.[f]?.bind(this.config.logger) ?? (() => {}) } } /** The hostname or IP address and port of the web socket server. * @type {string} */ get host () { return this.config.hostname + ':' + this.config.port } set host (host) { const { hostname, port } = OptionParser.toHost('host', host) this.config.hostname = hostname this.config.port = port } /** Connect to the web socket server, and listen notifications. */ listen () { this.reconnect = true const protocol = this.config.wss ? 'wss' : 'ws' const url = protocol + '://' + this.config.hostname + ':' + this.config.port const options = { family: 4 } if (this.config.wss && this.config.fingerprint != null) { options.rejectUnauthorized = false } this.ws = new WebSocket(url, options) this.ws .on('error', (error) => { this.warn(error) }) .on('open', () => { if (this._error) { this.close() return } this.debug('listening on %s', url) }) .on('upgrade', (response) => { if (!this.config.wss || this.config.fingerprint == null) { return } const cert = response.socket.getPeerCertificate() if (cert.fingerprint512 !== this.config.fingerprint) { this._error = true this.warn('SSL certificate fingerprint mismatch') } }) .on('message', (data, flags) => { try { const obj = JSON.parse(data.toString()) if (!this.config.raw) { if (obj.r === 'groups' && obj.id === 0xFFF0.toString()) { // Workaround for deCONZ bug: events for `/groups/0` contain // the Zigbee group ID, instead of the resource ID. obj.id = '0' } if (obj.t === 'event') { switch (obj.e) { case 'changed': if (obj.r !== null && obj.id !== null) { let body if (obj.attr != null) { body = obj.attr } else if (obj.capabilities != null) { body = { capabilities: obj.capabilities } } else if (obj.config != null) { body = { config: obj.config } } else if (obj.state != null) { body = { state: obj.state } } /** Emitted when a `changed` notification has been received. * * Note that the deCONZ gateway sends different * notifications for top-level, `state`, and `config` * attributes. * Consequenly, the `body` only contains one of these. * @event WsClient#changed * @param {string} rtype - The resource type of the changed * resource. * @param {integer} rid - The resource ID of the changed * resource. * @param {object} body - The body of the changed resource. */ this.emit('changed', obj.r, obj.id, body) return } break case 'added': if (obj.r !== null && obj.id !== null) { /** Emitted when an `added` notification has been received. * @event WsClient#added * @param {string} rtype - The resource type of the added * resource. * @param {integer} rid - The resource ID of the added * resource. * @param {object} body - The body of the added resource. */ this.emit('added', obj.r, obj.id, obj[obj.r.slice(0, -1)]) return } break case 'deleted': if (obj.r !== null && obj.id !== null) { /** Emitted when an `deleted` notification has been received. * @event WsClient#deleted * @param {string} rtype - The resource type of the deleted * resource. * @param {integer} rid - The resource ID of the deleted * resource. */ this.emit('deleted', obj.r, obj.id) return } break case 'scene-called': if (obj.gid != null && obj.scid != null) { const resource = '/groups/' + obj.gid + '/scenes/' + obj.scid /** Emitted when an `sceneRecall` notification has been received. * @event WsClient#sceneRecall * @param {string} resource - The scene resource. */ this.emit('sceneRecall', resource) return } break default: break } } } /** Emitted when an unknown notification has been received, or when * `params.raw` was specified to the * {@link WsClient constructor}. * @event WsClient#notification * @param {object} notification - The raw notification. */ this.emit('notification', obj) } catch (error) { this.warn(error) } }) .on('close', async () => { if (this.ws != null) { this.ws.removeAllListeners() delete this.ws } const retryTime = this.reconnect ? this.config.retryTime : 0 this.debug('connection to %s closed', url) if (retryTime > 0) { await timeout(retryTime * 1000) return this.listen() } }) } /** Close the web socket connection. */ async close () { if (this.ws != null) { this.reconnect = false this.ws.close() await once(this.ws, 'close') } } } export { WsClient }