hb-hue-tools
Version:
Homebridge Hue Tools
253 lines (240 loc) • 8.03 kB
JavaScript
// homebridge-hue/lib/HueDiscovery.js
//
// Homebridge plug-in for Philips Hue.
// Copyright © 2018-2026 Erik Baauw. All rights reserved.
import { EventEmitter } from 'node:events'
import { HttpClient } from 'hb-lib-tools/HttpClient'
import { OptionParser } from 'hb-lib-tools/OptionParser'
/** Class for discovery of Hue bridges.
*
* See the [Hue API](https://developers.meethue.com/develop/get-started-2/)
* documentation for a better understanding of the API.
* @extends EventEmitter
*/
class HueDiscovery extends EventEmitter {
/** Create a new instance.
* @param {object} params - Parameters.
* @param {boolean} [params.forceHttp=false] - Use plain HTTP instead of HTTPS.
* @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 = {}) {
super()
this._options = {
forceHttp: false,
timeout: 5
}
const optionParser = new OptionParser(this._options)
optionParser
.boolKey('forceHttp')
.instanceKey('logger')
.intKey('timeout', 1, 60)
.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 Hue bridge.
* @return {object} response - The JSON response body converted to JavaScript.
* @throws {HttpError} In case of error.
*/
async config (host) {
const { hostname, port } = OptionParser.toHost('host', host)
const https = port === 443
const client = new HttpClient({
host: hostname,
https,
json: true,
name: host + ' config',
logger: this._options.logger,
path: '/api',
selfSignedCertificate: https,
timeout: this._options.timeout,
validStatusCodes: https ? [200] : [200, 301]
})
const { body, request, statusCode } = await client.get('/config')
if (statusCode === 301) {
return this.config(hostname + ':443')
}
if (
body == null || typeof body !== 'object' ||
typeof body.apiversion !== 'string' ||
!/[0-9A-Fa-f]{16}/.test(body.bridgeid) ||
typeof body.name !== 'string' ||
typeof body.swversion !== 'string'
) {
const error = new Error('invalid response')
error.request = request
this.emit('error', error)
throw error
}
if (/^00212E[0-9A-F]{10}$/.test(body.bridgeid)) {
const error = new Error(`${host}: deCONZ gateway no longer supported`)
error.request = request
this.emit('error', error)
throw error
}
return body
}
/** Issue an unauthenticated GET request of `/description.xml` to given host.
* Note that the Hue Bridge Pro no longer seems to provide this endpoint.
*
* @param {string} host - The IP address or hostname and port of the Hue bridge.
* @return {object} response - The description, converted to JavaScript.
* @throws {Error} In case of error.
*/
async description (host) {
if (this.xmlParser == null) {
const { Parser } = await import('xml2js')
this.xmlParser = new Parser({ explicitArray: false })
}
const { hostname, port } = OptionParser.toHost('host', host)
const https = port === 443
const options = {
host: hostname,
https,
logger: this._options.logger,
name: host + ' description',
selfSignedCertificate: https,
timeout: this._options.timeout,
validStatusCodes: https ? [200] : [200, 301],
xmlParser: async (xml) => { return this.xmlParser.parseStringPromise(xml) }
}
const client = new HttpClient(options)
const { body, statusCode } = await client.get('/description.xml')
if (statusCode === 301) {
return this.description(hostname + ':443')
}
return body
}
/** Discover Hue bridges.
*
* Queries the MeetHue portal for known bridges and does a local search over
* mDNS (Bonjour) and UPnP.
* Calls {@link HueDiscovery#config config()} for each discovered bridge
* for verification.
* @param {object} params - Parameters.
* @param {boolean} [params.stealth=false] - Don't query discovery portals.
* @return {object} response - Response object with a key/value pair per
* found bridge. The key is the host (IP address or hostname), the value is
* the return value of {@link HueDiscovery#config config()}.
*/
async discover (params = {}) {
const options = {
stealth: false
}
const optionParser = new OptionParser(options)
optionParser
.boolKey('stealth')
.parse(params)
this.bridgeMap = {}
this.jobs = []
this.jobs.push(this.#mdns())
this.jobs.push(this.#upnp())
if (!options.stealth) {
this.jobs.push(this.#nupnp())
}
for (const job of this.jobs) {
await job
}
return this.bridgeMap
}
#found (name, id, host) {
this.debug('%s: found %s at %s', name, id, host)
/** Emitted when a potential bridge has been found.
* @event HueDiscovery#found
* @param {string} name - The name of the search method.
* @param {string} bridgeid - The ID of the bridge.
* @param {string} host - The IP address/hostname of the bridge.
*/
this.emit('found', name, id, host)
const { hostname } = OptionParser.toHost('host', host)
if (this.bridgeMap[hostname] == null) {
this.bridgeMap[hostname] = id
this.jobs.push(
this.config(host).then((config) => {
this.bridgeMap[hostname] = config
}).catch((error) => {
delete this.bridgeMap[hostname]
if (error.request == null) {
this.emit('error', error)
}
})
)
}
}
async #mdns () {
if (this.mdnsClient == null) {
const { MdnsClient } = await import('hb-lib-tools/MdnsClient')
this.mdnsClient = new MdnsClient({
logger: this._options.logger,
serviceType: 'hue',
timeout: this._options.timeout
})
this.mdnsClient
.on('serviceUp', (address, message) => {
this.#found('mdns', message.txt.bridgeid.toUpperCase(), address)
})
}
await this.mdnsClient.search()
}
async #upnp () {
if (this.upnpClient == null) {
const { UpnpClient } = await import('hb-lib-tools/UpnpClient')
this.upnpClient = new UpnpClient({
filter: (message) => {
return /^(001788|ECB5FA)[0-9A-F]{10}$/.test(message['hue-bridgeid'])
},
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['hue-bridgeid'], host)
}
})
}
await this.upnpClient.search()
}
async #nupnp () {
const name = 'meethue.com'
if (this.client == null) {
this.client = new HttpClient({
host: 'discovery.meethue.com',
https: !this._options.forceHttp,
json: true,
name,
logger: this._options.logger,
timeout: this._options.timeout
})
}
try {
const { body } = await this.client.get()
if (Array.isArray(body)) {
for (const bridge of body) {
this.#found(
name, bridge.id.toUpperCase(),
bridge.internalipaddress + ':' + (bridge.port ?? 80)
)
}
}
} catch (error) {
if (error instanceof HttpClient.HttpError) {
return
}
this.warn(error)
}
}
}
export { HueDiscovery }