UNPKG

hb-deconz-tools

Version:
252 lines (239 loc) 7.92 kB
// hb-deconz-tools/lib/Discovery.js // // Homebridge deCONZ Tools. // Copyright © 2018-2025 Erik Baauw. All rights reserved. import { EventEmitter, once } from 'node:events' import xml2js from 'xml2js' import { HttpClient } from 'hb-lib-tools/HttpClient' import { OptionParser } from 'hb-lib-tools/OptionParser' import { UpnpClient } from 'hb-lib-tools/UpnpClient' /** deCONZ gateway disovery. * <br>See {@link Discovery}. * @name Discovery * @type {Class} * @memberof module:hb-deconz-tools */ /** Class for discovery of deCONZ gateways. * * See the [deCONZ API](https://dresden-elektronik.github.io/deconz-rest-doc/) * documentation for a better understanding of the API. * @extends EventEmitter */ class Discovery extends EventEmitter { /** Create a new instance. * @param {object} params - Parameters. * @param {boolean} [params.forceHttp=false] - Use plain HTTP instead of HTTPS. * @param {integer} [params.timeout=5] - Timeout (in seconds) for requests. */ constructor (params = {}) { super() this._options = { forceHttp: false, timeout: 5 } const optionParser = new OptionParser(this._options) optionParser .boolKey('forceHttp') .intKey('timeout', 1, 60) .parse(params) } /** Issue an unauthenticated GET request of `/api/config` to given host. * * @param {string} host - The IP address or hostname and port of the deCONZ * gateway. * @return {object|null} response - The JSON response body converted to * JavaScript, or null when the response doesn't come from deCONZ. * @throws {HttpError} In case of error. */ async config (host) { const client = new HttpClient({ host, json: true, path: '/api', timeout: this._options.timeout }) client .on('error', (error) => { /** Emitted when an error has occured. * * @event Discovery#error * @param {HttpError} error - The error. */ this.emit('error', error) }) .on('request', (request) => { /** Emitted when request has been sent. * * @event Discovery#request * @param {HttpRequest} request - The request. */ this.emit('request', request) }) .on('response', (response) => { /** Emitted when a valid response has been received. * * @event Discovery#response * @param {HttpResponse} response - The response. */ this.emit('response', response) }) const { body } = await client.get('/config') if ( body != null && typeof body === 'object' && typeof body.apiversion === 'string' && /[0-9A-Fa-f]{16}/.test(body.bridgeid) && typeof body.devicename === 'string' && typeof body.name === 'string' && typeof body.swversion === 'string' ) { if (body.bridgeid.startsWith('00212E')) { return body } throw new Error(`${body.bridgeid}: not a RaspBee/ConBee mac address`) } throw new Error('not a deCONZ gateway') } /** Issue an unauthenticated GET request of `/description.xml` to given host. * * @param {string} host - The IP address or hostname and port of the deCONZ gateway. * @return {object} response - The description, converted to JavaScript. * @throws {Error} In case of error. */ async description (host) { const options = { host, timeout: this._options.timeout } const client = new HttpClient(options) client .on('error', (error) => { this.emit('error', error) }) .on('request', (request) => { this.emit('request', request) }) .on('response', (response) => { this.emit('response', response) }) const { body } = await client.get('/description.xml') const xmlOptions = { explicitArray: false } const result = await xml2js.parseStringPromise(body, xmlOptions) return result } /** Discover deCONZ gateways. * * Queries the Phoscon portal for known gateways and does a local search * over UPnP. * Calls {@link Discovery#config config()} for each discovered gateway * for verification. * @param {boolean} [stealth=false] - Don't query discovery portals. * @return {object} response - Response object with a key/value pair per * found gateway. The key is the host (IP address or hostname and port), * the value is the return value of {@link Discovery#config config()}. */ async discover (stealth = false) { this.gatewayMap = {} this.jobs = [] this.jobs.push(this._upnp()) if (!stealth) { this.jobs.push(this._nupnp({ name: 'phoscon.de', https: !this._options.forceHttp, host: 'phoscon.de', path: '/discover' })) } for (const job of this.jobs) { await job } return this.gatewayMap } _found (name, id, host) { /** Emitted when a potential gateway has been found. * @event Discovery#found * @param {string} name - The name of the search method. * @param {string} bridgeid - The ID of the gateway. * @param {string} host - The IP address/hostname and port of the gateway * or gateway. */ this.emit('found', name, id, host) if (this.gatewayMap[host] == null) { this.gatewayMap[host] = id this.jobs.push( this.config(host).then((config) => { this.gatewayMap[host] = config }).catch((error) => { delete this.gatewayMap[host] if (error.request == null) { this.emit('error', error) } }) ) } } async _upnp () { const upnpClient = new UpnpClient({ filter: (message) => { return /^[0-9A-F]{16}$/.test(message['gwid.phoscon.de']) }, timeout: this._options.timeout }) upnpClient .on('error', (error) => { this.emit('error', error) }) .on('searching', (host) => { /** Emitted when search has started. * * @event Discovery#searching * @param {string} name - The name of the search method. * @param {string} host - The IP address and port from which the * search was started. */ this.emit('searching', 'upnp', host) }) .on('request', (request) => { request.name = 'upnp' this.emit('request', request) }) .on('deviceFound', (address, obj, message) => { let host const a = obj.location.split('/') if (a.length > 3 && a[2] != null) { host = a[2] const b = host.split(':') const port = parseInt(b[1]) if (port === 80) { host = b[0] } this._found('upnp', obj['gwid.phoscon.de'], host) } }) upnpClient.search() await once(upnpClient, 'searchDone') /** Emitted when UPnP search has concluded. * * @event Discovery#searchDone */ this.emit('searchDone', 'upnp') } async _nupnp (options) { options.json = true options.timeout = this._options.timeout const client = new HttpClient(options) client .on('error', (error) => { this.emit('error', error) }) .on('request', (request) => { this.emit('request', request) }) .on('response', (response) => { this.emit('response', response) }) try { const { body } = await client.get() if (Array.isArray(body)) { for (const gateway of body) { let host = gateway.internalipaddress if (gateway.internalport != null && gateway.internalport !== 80) { host += ':' + gateway.internalport } this._found(options.name, gateway.id.toUpperCase(), host) } } } catch (error) { if (error instanceof HttpClient.HttpError) { return } this.emit('error', error) } } } export { Discovery }