UNPKG

hb-hue-tools

Version:
635 lines (600 loc) 21.5 kB
// hb-hue-tools/lib/HueClient.js // // Homebridge plug-in for Philips Hue. // Copyright © 2018-2026 Erik Baauw. All rights reserved. import { once } from 'node:events' import { hostname } from 'node:os' import { timeout } from 'hb-lib-tools' import { HttpClient } from 'hb-lib-tools/HttpClient' import { OptionParser } from 'hb-lib-tools/OptionParser' import { HueError } from 'hb-hue-tools/HueError' import { HueResponse } from 'hb-hue-tools/HueResponse' const hueMacPrefixes = ['001788', 'ECB5FA', 'C42996'] const apiV1Resources = [ 'capabilities', 'config', 'info', 'lights', 'groups', 'schedules', 'scenes', 'sensors', 'rules', 'resourcelinks' ] // Estmate the number of Zigbee messages resulting from PUTting body. function numberOfZigbeeMessages (body = {}) { let n = 0 if (Object.keys(body).includes('on')) { n++ } if ( Object.keys(body).includes('bri') || Object.keys(body).includes('bri_inc') ) { n++ } if ( Object.keys(body).includes('xy') || Object.keys(body).includes('ct') || Object.keys(body).includes('hue') || Object.keys(body).includes('sat') || Object.keys(body).includes('effect') ) { n++ } return n === 0 ? 1 : n } /** REST API client for Hue bridge with API v1 and compatible servers. * * See the [Hue API v1](https://developers.meethue.com/develop/hue-api/) * documentation for a better understanding of the API. * @extends HttpClient */ class HueClient extends HttpClient { static get HueError () { return HueError } static get HueResponse () { return HueResponse } /** Check for Hue bridge. * @param {object} config - The bridge public configuration, * @returns {boolean} */ static isHueBridge (config) { return /BSB00[1-3]/.test(config.modelid) && hueMacPrefixes.includes(config.bridgeid.slice(0, 6)) } /** Check if Hue bridge supports the Hue API v2. * @param {object} config - The bridge public configuration, * @returns {boolean} */ static isHue2Bridge (config) { return HueClient.isHueBridge(config) && BigInt(config.swversion) >= 1948086000n } /** SSL certificate of Hue bridge root CA, see * [Using HTTPS](https://developers.meethue.com/develop/application-design-guidance/using-https/). * @type {string} */ static get rootCertificates () { return [ `-----BEGIN CERTIFICATE----- MIICMjCCAdigAwIBAgIUO7FSLbaxikuXAljzVaurLXWmFw4wCgYIKoZIzj0EAwIw OTELMAkGA1UEBhMCTkwxFDASBgNVBAoMC1BoaWxpcHMgSHVlMRQwEgYDVQQDDAty b290LWJyaWRnZTAiGA8yMDE3MDEwMTAwMDAwMFoYDzIwMzgwMTE5MDMxNDA3WjA5 MQswCQYDVQQGEwJOTDEUMBIGA1UECgwLUGhpbGlwcyBIdWUxFDASBgNVBAMMC3Jv b3QtYnJpZGdlMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEjNw2tx2AplOf9x86 aTdvEcL1FU65QDxziKvBpW9XXSIcibAeQiKxegpq8Exbr9v6LBnYbna2VcaK0G22 jOKkTqOBuTCBtjAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNV HQ4EFgQUZ2ONTFrDT6o8ItRnKfqWKnHFGmQwdAYDVR0jBG0wa4AUZ2ONTFrDT6o8 ItRnKfqWKnHFGmShPaQ7MDkxCzAJBgNVBAYTAk5MMRQwEgYDVQQKDAtQaGlsaXBz IEh1ZTEUMBIGA1UEAwwLcm9vdC1icmlkZ2WCFDuxUi22sYpLlwJY81Wrqy11phcO MAoGCCqGSM49BAMCA0gAMEUCIEBYYEOsa07TH7E5MJnGw557lVkORgit2Rm1h3B2 sFgDAiEA1Fj/C3AN5psFMjo0//mrQebo0eKd3aWRx+pQY08mk48= -----END CERTIFICATE-----`, `-----BEGIN CERTIFICATE----- MIIBzDCCAXOgAwIBAgICEAAwCgYIKoZIzj0EAwIwPDELMAkGA1UEBhMCTkwxFDAS BgNVBAoMC1NpZ25pZnkgSHVlMRcwFQYDVQQDDA5IdWUgUm9vdCBDQSAwMTAgFw0y NTAyMjUwMDAwMDBaGA8yMDUwMTIzMTIzNTk1OVowPDELMAkGA1UEBhMCTkwxFDAS BgNVBAoMC1NpZ25pZnkgSHVlMRcwFQYDVQQDDA5IdWUgUm9vdCBDQSAwMTBZMBMG ByqGSM49AgEGCCqGSM49AwEHA0IABFfOO0jfSAUXGQ9kjEDzyBrcMQ3ItyA5krE+ cyvb1Y3xFti7KlAad8UOnAx0FBLn7HZrlmIwm1QnX0fK3LPM13mjYzBhMB0GA1Ud DgQWBBTF1pSpsCASX/z0VHLigxU2CAaqoTAfBgNVHSMEGDAWgBTF1pSpsCASX/z0 VHLigxU2CAaqoTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggq hkjOPQQDAgNHADBEAiAk7duT+IHbOGO4UUuGLAEpyYejGZK9Z7V9oSfnvuQ5BQIg IYSgwwxHXm73/JgcU9lAM6c8Bmu3UE3kBIUwBs1qXFw= -----END CERTIFICATE-----` ] } /** Create a new instance of a HueClient. * * The caller is expected to verify that the given host is a reachable Hue * bridge, by calling * {@link HueDiscovery#config HueDiscovery#config()} and passing the * response as `params.config`.<br> * The caller is expected to persist the API key, * passing it as `params.apiKey`. * If no API key is known {@link HueClient#getApiKey getApiKey()} can * be called to create one.<br> // * The client is expected to persist the fingerprint of the self-signed SSL // * certificate of gen-2 Hue bridge, passing it as `params.fingerprint`. // * If no `fingerprint` is known, it will be pinned on the first request to // * the Hue bridge, typically the call to // * {@link HueClient#getApiKey getApiKey()}. // * It can be obtained through the {@link HueClient#fingerprint fingerprint} // * property. * * @param {object} params - Parameters. * @param {?string} params.apiKey - The API key of the Hue bridge. * @param {object} params.config - The bridge public configuration, * i.e. the response of {@link HueDiscovery#config HueDiscovery#config()}. // * @param {?string} params.fingerprint - The fingerprint of the pinned // * self-signed SSL certificate of the Hue bridge // * with firmware v1.24.0 or greater. * @param {boolean} [params.forceHttp=false] - Force HTTP instead of HTTPS * for Hue bridge with firmware v1.24.0 and greater. * @param {!string} params.host - Hostname/IP address of the Hue bridge. * @param {boolean} [params.keepAlive=false] - Keep server connection(s) * open. * @param {?*} params.logger - Logger for messages. * @param {integer} [params.maxSockets=20] - Throttle requests to maximum * number of parallel connections. * @param {integer} [params.timeout=5] - Request timeout (in seconds). * @param {integer} [params.waitTimePut=50] - The time (in milliseconds), * after sending a PUT request, to wait before sending another PUT request. * @param {integer} [params.waitTimePutGroup=1000] - The time (in * milliseconds), after sending a PUT request, to wait before sending * another PUT request. * @param {integer} [params.waitTimeResend=300] - The time, in milliseconds, * to wait before resending a request after an ECONNRESET, an http status * 503, or an api 901 error. */ constructor (params = {}) { const _options = { keepAlive: false, maxSockets: 20, path: '/api', timeout: 5, waitTimePut: 50, waitTimePutGroup: 1000, waitTimeResend: 300 } const optionParser = new OptionParser(_options) optionParser .stringKey('apiKey') .objectKey('config', true) // .stringKey('fingerprint', true) .boolKey('forceHttp') .hostKey('host') .boolKey('keepAlive') .intKey('maxSockets', 1, 20) .instanceKey('logger') .intKey('timeout', 1, 60) .intKey('waitTimePut', 0, 50) .intKey('waitTimePutGroup', 0, 1000) .intKey('waitTimeResend', 0, 1000) .parse(params) // if (_options.fingerprint != null) { // _options.https = true // } _options.isHue = false if (HueClient.isHueBridge(_options.config)) { _options.isHue = true if (BigInt(_options.config.swversion) >= 1804201116n) { _options.https = true } if (BigInt(_options.config.swversion) >= 1948086000n) { _options.isHue2 = true } } if (_options.apiKey) { _options.path += '/' + _options.apiKey _options.headers = { 'hue-application-key': _options.apiKey } } const options = { host: _options.hostname, json: true, keepAlive: _options.keepAlive, logger: _options.logger, maxSockets: _options.maxSockets, timeout: _options.timeout, validStatusCodes: [200, 207] } if (_options.https && !_options.forceHttp) { options.https = true // options.selfSignedCertificate = true options.ca = HueClient.rootCertificates options.checkServerIdentity = (hostname, cert) => { return this.checkServerIdentity(hostname, cert) } } super(options) this._options = _options this.waitForIt = false this.setMaxListeners(30) } /** The ID (mac address) of the Hue bridge. * @type {string} * @readonly */ get bridgeId () { return this._options.config.bridgeid } // /** The fingerprint of the self-signed SSL certificate of the Hue bridge with // * firmware v1.24.0 or greater. // * // * @type {string} // */ // get fingerprint () { return this._options.fingerprint } // set fingerprint (value) { this._options.fingerprint = value } /** True when connected to a Hue bridge. * @type {boolean} * @readonly */ get isHue () { return this._options.isHue } /** True when connected to a Hue bridge with API v2. * @type {boolean} * @readonly */ get isHue2 () { return this._options.isHue2 } /** The API key. * @type {string} */ get apiKey () { return this._options.apiKey } set apiKey (value) { this._options.apiKey = value this._options.headers = { 'hue-application-key': value } this._options.path = '/api' if (value != null) { this._options.path += '/' + value } } // =========================================================================== /** Issue a GET request of `/api/`_apiKey_`/`_resource_ (API v1) or of * `/clip/v2/resource/`_resource_ (API v2). * * @param {string} resource - The resource.<br> * This might be a resource as exposed by the API, e.g. `/lights/1/state`, * or an attribute returned by the API, e.g. `/lights/1/state/on`. * @return {*} response - The JSON response body converted to JavaScript. * @throws {HueError} In case of error. */ async get (resource) { if (typeof resource !== 'string' || resource[0] !== '/') { throw new TypeError(`${resource}: invalid resource`) } let request = this.request.bind(this) this.path = this._options.path let path = resource.slice(1).split('/') switch (path[0]) { case '': case 'capabilities': case 'config': if (path.length > 1) { resource = '/' + path.shift() break } path = [] break case 'info': case 'lights': case 'groups': case 'schedules': case 'scenes': case 'sensors': case 'rules': case 'resourcelinks': if (path.length > 2) { resource = '/' + path.shift() + '/' + path.shift() break } path = [] break default: this.path = path[0] === 'resource' ? '/clip/v2' : '/clip/v2/resource' request = this.request2.bind(this) if (path.length >= 2) { resource = '/' + path.shift() + '/' + path.shift() path.unshift('0') // dereference array of only 1 element break } path = [] break } let { body } = await request('GET', resource) for (const key of path) { if (typeof body === 'object' && body != null) { body = body[key] } } if (body == null && path.length > 0) { throw new Error( `/${path.join('/')}: not found in resource ${resource}` ) } return body } /** Issue a PUT request to `/api/`_apiKey_`/`_resource_. * * HueClient throttles the number of PUT requests to limit the Zigbee traffic * to 20 unicast messsages per seconds, or 1 broadcast message per second, * delaying the request when needed. * @param {string} resource - The resource. * @param {*} body - The body, which will be converted to JSON. * @return {HueResponse} response - The response. * @throws {HueError} In case of error, except for non-critical API errors. */ async put (resource, body) { if (this.waitForIt) { while (this.waitForIt) { try { await once(this, '_go') } catch (error) {} } } const timeout = numberOfZigbeeMessages(body) * ( resource.startsWith('/groups') ? this._options.waitTimePutGroup : this._options.waitTimePut ) if (timeout > 0) { this.waitForIt = true setTimeout(() => { this.waitForIt = false this.emit('_go') }, timeout) } if (apiV1Resources.includes(resource.slice(1).split('/')[0])) { this.path = this._options.path return this.request('PUT', resource, body) } else { this.path = '/clip/v2/resource' return this.request2('PUT', resource, body) } } /** Issue a POST request to `/api/`_apiKey_`/`_resource_. * * @param {string} resource - The resource. * @param {*} body - The body, which will be converted to JSON. * @return {HueResponse} response - The response. * @throws {HueError} In case of error. */ async post (resource, body) { if (apiV1Resources.includes(resource.slice(1).split('/')[0])) { this.path = this._options.path return this.request('POST', resource, body) } else { this.path = '/clip/v2/resource' return this.request2('POST', resource, body) } } /** Issue a DELETE request of `/api/`_apiKey_`/`_resource_. * @param {string} resource - The resource. * @param {*} body - The body, which will be converted to JSON. * @return {HueResponse} response - The response. * @throws {HueError} In case of error. */ async delete (resource, body) { if (apiV1Resources.includes(resource.slice(1).split('/')[0])) { this.path = this._options.path return this.request('DELETE', resource, body) } else { this.path = '/clip/v2/resource' return this.request2('DELETE', resource, body) } } // =========================================================================== /** Create an API key and set {@link HueClient#apiKey apiKey}. * * Calls {@link HueClient#post post()} to issue a POST request to `/api`. * * Before calling `getApiKey`, the link button on the Hue bridge must be * pressed. * @return {string} apiKey - The newly created API key. * @throws {HueError} In case of error. */ async getApiKey (application) { if (typeof application !== 'string' || application === '') { throw new TypeError(`${application}: invalid application name`) } const apiKey = this._options.apiKey const body = { devicetype: `${application}#${hostname().split('.')[0]}`, generateclientkey: true // TODO check gen-1 Hue bridge. } this.apiKey = null try { this.path = '/api' const response = await this.request('POST', '/', body) this.apiKey = response.success.username return this.apiKey } catch (error) { this.apiKey = apiKey throw (error) } } /** Return the PSK identity for setting up the DTLS connection. * * @return {string} applicationId - The Application ID. * @throws {HueError} In case of error. */ async getApplicationId () { this.path = '/' const { headers } = await this.request2('GET', '/auth/v1') return headers['hue-application-id'] } /** Unlock the bridge to allow creating a new API key. * * Calls {@link HueClient#put put()} to issue a PUT request to * `/api/`_apiKey_`/config`. * This is the API equivalent of pressing the link button on the Hue bridge. * * Note that as of firmware v1.31.0, the gen-2 Hue bridge no longer allows * unlocking the bridge through the API. * @return {HueResponse} response - The response. * @throws {HueError} In case of error. */ async unlock () { return this.put('/config', { linkbutton: true }) } /** Initiate a touchlink pairing. * * Calls {@link HueClient#put put()} to issue * a PUT request to `/api/`_apiKey_`/config` to initiate touchlink pairing. * This is the API equivalent of holding the link button on the Hue bridge. * * @return {HueResponse} response - The response. * @throws {HueError} In case of error. */ async touchlink () { return this.put('/config', { touchlink: true }) } /** Search for new devices. * * Calls {@link HueClient#post post()} to issue a POST request to * `/api/`_apiKey_`/lights`, to enable pairing of new Zigbee devices. * * To see the newly paired devices, issue a GET request of * `/api/`_apiKey_`/lights/new` and/or `/api/`_apiKey_`/sensor/new` * @return {HueResponse} response - The response. * @throws {HueError} In case of error. */ async search () { return this.post('/lights') } /** Restart the bridge. * Calls {@link HueClient#put put()} to issue a PUT request to * `/api/`_apiKey_`/config`, to reboot the Hue bridge. * * @return {HueResponse} response - The response. * @throws {HueError} In case of error. */ async restart () { return this.put('/config', { reboot: true }) } // =========================================================================== /** Check Hue bridge server identity * @params {string} hostname - The hostname of the Hue bridge. * @params {object} cert - The SSL certificate of the Hue bridge. * @returns {Error} For invalid SSL certificate. */ checkServerIdentity (hostname, cert) { // if (this._options.fingerprint != null) { // if (cert.fingerprint256 !== this._options.fingerprint) { // return new Error('SSL certificate fingerprint mismatch') // } // return // } if ( cert.subject == null || cert.subject.C !== 'NL' || cert.subject.O !== 'Philips Hue' || cert.subject.CN.toUpperCase() !== this.bridgeId || ('00' + cert.serialNumber).slice(-16) !== this.bridgeId ) { return new Error('invalid SSL certificate') } if ( cert.issuer == null || cert.issuer.C !== 'NL' || cert.issuer.O !== 'Philips Hue' || ( cert.issuer.CN.toUpperCase() !== this.bridgeId && cert.issuer.CN !== 'root-bridge' ) ) { return new Error('invalid issuer certificate') } // Pin certificate. // this._options.fingerprint = cert.fingerprint256 } /** Issue an API v1 HTTP(S) request to the Hue bridge. * * This method does the heavy lifting for {@link HueClient#get get()}, * {@link HueClient#put put()}, {@link HueClient#post post()}, and * {@link HueClient#delete delete()}. * It shouldn't be called directly. * * @param {string} method - The method for the request. * @param {!string} resource - The resource for the request. * @param {?*} body - The body for the request. * @return {HueResponse} response - The response. * @throws {HueError} In case of error. */ async request (method, resource, body = null, retry = 0) { try { const httpResponse = await super.request(method, resource, body) if (httpResponse.headers['content-length'] === '0') { httpResponse.body = null } const response = new HueResponse(httpResponse) for (const error of response.errors) { /** Emitted for each API error returned by the Hue bridge. * * @event HueApiClient#error * @param {HueApiError} error - The error. */ this.emit('error', error) if (!error.nonCritical) { throw error } } return response } catch (error) { if ( error.code === 'ECONNRESET' || error.statusCode === 503 || error.type === 901 ) { if (error.request != null && this._options.waitTimeResend > 0 && retry < 5) { error.message += ' - retry in ' + this._options.waitTimeResend + 'ms' this.emit('error', error) await timeout(this._options.waitTimeResend) return this.request(method, resource, body, retry + 1) } } throw error } } /** Issue an API v2 HTTPS request to the Hue bridge. * * This method does the heavy lifting for {@link HueClient#get get()}, * {@link HueClient#put put()}, {@link HueClient#post post()}, and * {@link HueClient#delete delete()}. * It shouldn't be called directly. * * @param {string} method - The method for the request. * @param {!string} resource - The resource for the request. * @param {?*} body - The body for the request. * @return {HueResponse} response - The response. * @throws {HueError} In case of error. */ async request2 (method, resource, body = null, retry = 0) { try { const httpResponse = await super.request( method, resource, body, this._options.headers ) const response = new HueResponse(httpResponse) for (const error of response.errors) { this.emit('error', error) if (!error.nonCritical) { throw error } } if (method === 'GET') { response.body = response.body.data } return response } catch (error) { if ( error.code === 'ECONNRESET' || error.statusCode === 503 || error.type === 901 ) { if (error.request != null && this._options.waitTimeResend > 0 && retry < 5) { error.message += ' - retry in ' + this._options.waitTimeResend + 'ms' this.emit('error', error) await timeout(this._options.waitTimeResend) return this.request(method, resource, body, retry + 1) } } throw error } } } export { HueClient }