hb-deconz-tools
Version:
Homebridge deCONZ Tools
224 lines (213 loc) • 8.35 kB
JavaScript
// 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 }