hb-deconz-tools
Version:
Homebridge deCONZ Tools
236 lines (225 loc) • 7.34 kB
JavaScript
// hb-deconz-tools/lib/Discovery.js
//
// Homebridge deCONZ Tools.
// Copyright © 2018-2026 Erik Baauw. All rights reserved.
import { HttpClient } from 'hb-lib-tools/HttpClient'
import { OptionParser } from 'hb-lib-tools/OptionParser'
/** 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.
*/
class Discovery {
/** Create a new instance.
* @param {object} params - Parameters.
* @param {?*} params.logger - Logger to use for logging. If null, no logging is done.
* @param {integer} [params.timeout=5] - Timeout (in seconds) for requests.
*/
constructor (params = {}) {
this._options = {
timeout: 5
}
const optionParser = new OptionParser(this._options)
optionParser
.intKey('timeout', 1, 60)
.instanceKey('logger')
.parse(params)
for (const f of ['warn', 'log', 'debug', 'vdebug', 'vvdebug']) {
this[f] = this._options.logger?.[f]?.bind(this._options.logger) ?? (() => {})
}
}
/** 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.
* @param {boolean} [https=false] - Connect to the deCONZ gateway over HTTPS.
* @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, https = false) {
const options = {
host,
https,
json: true,
name: host + ' config',
logger: this._options.logger,
path: '/api',
selfSignedCertificate: https,
timeout: this._options.timeout
}
const client = new HttpClient(options)
let retry = false
client
.on('error', (error) => {
if (
error.message === 'socket hang up' ||
error.message === 'Client network socket disconnected before secure TLS connection was established'
) {
retry = true
}
})
try {
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')) {
body.https = https
return body
}
throw new Error(`${body.bridgeid}: not a RaspBee/ConBee mac address`)
}
throw new Error('not a deCONZ gateway')
} catch (error) {
if (retry) {
return this.config(host, !https)
}
throw error
}
}
/** 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.
* @param {boolean} [https=false] - Connect to the deCONZ gateway over HTTPS.
* @return {object} response - The description, converted to JavaScript.
* @throws {Error} In case of error.
*/
async description (host, https = false) {
if (this.xmlParser == null) {
const { Parser } = await import('xml2js')
this.xmlParser = new Parser({ explicitArray: false })
}
const options = {
host,
https,
logger: this._options.logger,
name: host + ' description',
selfSignedCertificate: https,
timeout: this._options.timeout,
xmlParser: async (xml) => { return this.xmlParser.parseStringPromise(xml) }
}
const client = new HttpClient(options)
const { body } = await client.get('/description.xml')
return body
}
/** 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.foundMap = {}
this.gatewayMap = {}
this.jobs = []
this.jobs.push(this.#upnp())
if (!stealth) {
this.jobs.push(this.#nupnp())
}
for (const job of this.jobs) {
await job
}
return this.gatewayMap
}
#found (name, id, host) {
this.debug('%s: found %s at %s', name, id, host)
if (this.foundMap[host] == null) {
this.foundMap[host] = id
this.jobs.push(
this.config(host).then((config) => {
this.description(host).then((description) => {
if (description?.root?.device?.['deconz:info']?.httpsPort != null) {
config.https = true
host = host.split(':')[0] + ':' + description.root.device['deconz:info'].httpsPort
}
this.gatewayMap[host] = config
}).catch((error) => {
if (error.request == null) {
this.warn(error)
}
})
}).catch((error) => {
delete this.foundMap[host]
if (error.request == null) {
this.warn(error)
}
})
)
}
}
async #upnp () {
if (this.upnpClient == null) {
const { UpnpClient } = await import('hb-lib-tools/UpnpClient')
this.upnpClient = new UpnpClient({
filter: (message) => {
return /^[0-9A-F]{16}$/.test(message['gwid.phoscon.de'])
},
logger: this,
timeout: this._options.timeout
})
this.upnpClient
.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)
}
})
}
await this.upnpClient.search()
}
async #nupnp (options) {
const name = 'phoscon.de'
if (this.client == null) {
this.client = new HttpClient({
host: name,
https: true,
json: true,
name,
logger: this._options.logger,
path: '/discover',
timeout: this._options.timeout
})
}
try {
const { body } = await this.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(name, gateway.id.toUpperCase(), host)
}
}
} catch (error) {
if (error instanceof HttpClient.HttpError) {
return
}
this.warn(error)
}
}
}
export { Discovery }