hb-hue-tools
Version:
Homebridge Hue Tools
273 lines (259 loc) • 8.78 kB
JavaScript
// homebridge-hue/lib/HueDiscovery.js
//
// Homebridge plug-in for Philips Hue.
// Copyright © 2018-2025 Erik Baauw. All rights reserved.
import { EventEmitter, once } from 'node:events'
import { timeout } from 'hb-lib-tools'
import { Bonjour } from 'hb-lib-tools/Bonjour'
import { HttpClient } from 'hb-lib-tools/HttpClient'
import { OptionParser } from 'hb-lib-tools/OptionParser'
import { UpnpClient } from 'hb-lib-tools/UpnpClient'
import { parseStringPromise } from 'xml2js'
/** 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 {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 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 } = OptionParser.toHost('host', host)
const client = new HttpClient({
host: hostname,
json: true,
path: '/api',
timeout: this._options.timeout
})
client
.on('error', (error) => {
/** Emitted when an error has occured.
*
* @event HueDiscovery#error
* @param {HttpError} error - The error.
*/
this.emit('error', error)
})
.on('request', (request) => {
/** Emitted when request has been sent.
*
* @event HueDiscovery#request
* @param {HttpRequest} request - The request.
*/
this.emit('request', request)
})
.on('response', (response) => {
/** Emitted when a valid response has been received.
*
* @event HueDiscovery#response
* @param {HttpResponse} response - The response.
*/
this.emit('response', response)
})
const { body, request } = await client.get('/config')
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.
*
* @param {string} host - The IP address or hostname of the Hue bridge.
* @return {object} response - The description, converted to JavaScript.
* @throws {Error} In case of error.
*/
async description (host) {
const { hostname } = OptionParser.toHost('host', host)
const options = {
host: hostname,
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 parseStringPromise(body, xmlOptions)
return result
}
/** 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({
name: 'meethue.com',
https: !this._options.forceHttp,
host: 'discovery.meethue.com'
}))
}
for (const job of this.jobs) {
await job
}
return this.bridgeMap
}
_found (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)
if (this.bridgeMap[host] == null) {
this.bridgeMap[host] = id
this.jobs.push(
this.config(host).then((config) => {
this.bridgeMap[host] = config
}).catch((error) => {
delete this.bridgeMap[host]
if (error.request == null) {
this.emit('error', error)
}
})
)
}
}
async _mdns () {
const bonjour4 = new Bonjour()
this.emit('searching', 'mdns', '224.0.0.251:5353')
const browser4 = bonjour4.find({ type: 'hue' })
browser4.on('up', (obj) => {
this._found('mdns', obj.txt.bridgeid.toUpperCase(), obj.referer.address)
})
await timeout(this._options.timeout * 1000)
this.emit('searchDone', 'mdns')
bonjour4.destroy()
}
async _upnp () {
const upnpClient = new UpnpClient({
filter: (message) => {
return /^(001788|ECB5FA)[0-9A-F]{10}$/.test(message['hue-bridgeid'])
},
timeout: this._options.timeout
})
upnpClient
.on('error', (error) => { this.emit('error', error) })
.on('searching', (host) => {
/** Emitted when UPnP search has started.
*
* @event HueDiscovery#searching
* @param {string} name - The name of the search method: mdns or upnp.
* @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['hue-bridgeid'], host)
}
})
upnpClient.search()
await once(upnpClient, 'searchDone')
/** Emitted when UPnP search has concluded.
*
* @event HueDiscovery#searchDone
* @param {string} name - The name of the search method: mdns or upnp.
*/
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 bridge of body) {
let host = bridge.internalipaddress
if (bridge.internalport != null && bridge.internalport !== 80) {
host += ':' + bridge.internalport
}
this._found(options.name, bridge.id.toUpperCase(), host)
}
}
} catch (error) {
if (error instanceof HttpClient.HttpError) {
return
}
this.emit('error', error)
}
}
}
export { HueDiscovery }