@hoobs/hue
Version:
HOOBS plugin for Philips Hue and deCONZ
364 lines (337 loc) • 11 kB
JavaScript
// homebridge-hue/lib/HueClient.js
//
// Homebridge plug-in for Philips Hue and/or deCONZ.
// Copyright © 2018-2020 Erik Baauw. All rights reserved.
//
// Philips Hue API client connection.
'use strict'
const debug = require('debug')
const homebridgeLib = require('homebridge-lib')
const os = require('os')
const semver = require('semver')
const xml2js = require('xml2js')
let id = 0
/** Hue (compatible) API client.
*/
class HueClient {
/** Create a new HueClient instance.
*/
constructor (options = {}) {
this._debug = debug('HueClient' + ++id)
this._debug('constructor(%j)', options)
this._options = {
keepAlive: false,
maxSockets: 20,
timeout: 5
}
const optionParser = new homebridgeLib.OptionParser(this._options)
optionParser.stringKey('bridgeid', true)
optionParser.stringKey('fingerprint', true)
optionParser.boolKey('forceHttp')
optionParser.stringKey('host', true)
optionParser.boolKey('keepAlive')
optionParser.intKey('maxSockets', 1, 20)
optionParser.boolKey('phoscon')
optionParser.intKey('timeout', 1, 60)
optionParser.stringKey('username', true)
optionParser.parse(options)
if (this._options.fingerprint != null) {
this._options.https = true
}
this._debug('constructor() => %j', this._options)
}
// Return the bridgeid
get bridgeid () {
return this._options.bridgeid
}
// Return the fingerprint of the bridge SSL certificate.
get fingerprint () {
return this._options.fingerprint
}
// Return true iff connected to a deCONZ gateway.
get isDeconz () {
return this._options.isDeconz
}
// Return true iff connected to a Hue bridge.
get isHue () {
return this._options.isHue
}
/** Connect to Hue bridge or deCONZ gateway or compatible API.
*/
async connect () {
this._debug('connect()')
const config = await this.config()
if (this._options.bridgeid == null) {
this._options.bridgeid = config.bridgeid
} else if (config.bridgeid !== this._options.bridgeid) {
throw new Error('bridgeid mismatch')
}
if (
config.bridgeid.substring(0, 6) === '001788' ||
config.bridgeid.substring(0, 6) === 'ECB5FA'
) {
if (semver.gte(config.apiversion, '1.24.0')) {
this._options.https = true
}
this._options.isHue = true
} else if (config.bridgeid.substring(0, 6) === '00212E') {
this._options.isDeconz = true
}
this._debug('connect() => %j', this._options)
}
// ===========================================================================
// Retrieve resource.
async get (resource) {
this._debug('get(%j)', resource)
if (typeof resource !== 'string' || resource[0] !== '/') {
throw new TypeError(`${resource}: invalid resource`)
}
let path = resource.substring(1).split('/')
switch (path[0]) {
case 'lights':
if (path.length === 3 && path[2] === 'connectivity2') {
path = []
break
}
// falls through
case 'groups':
if (path.length >= 3 && path[2] === 'scenes') {
resource = '/' + path.shift() + '/' + path.shift() + '/' + path.shift()
if (path.length >= 1) {
resource += '/' + path.shift()
}
break
}
// falls through
case 'schedules':
case 'scenes':
case 'sensors':
case 'rules':
case 'resourcelinks':
case 'touchlink':
if (path.length > 2) {
resource = '/' + path.shift() + '/' + path.shift()
break
}
path = []
break
case 'config':
case 'capabilities':
if (path.length > 1) {
resource = '/' + path.shift()
break
}
// falls through
default:
path = []
break
}
let response = await this._request('get', resource)
for (const key of path) {
if (typeof response === 'object' && response != null) {
response = response[key]
}
}
if (response == null && path.length > 0) {
throw new Error(
`/${path.join('/')}: not found in resource ${resource}`
)
}
this._debug('get(%j, %j) => %j', resource, path, response)
return response
}
// Update resource.
async put (resource, body) {
this._debug('put(%j, %j)', resource, body)
const response = await this._request('put', resource, body)
this._debug('put(%j, %j) => %j', resource, body, response)
if (Array.isArray(response)) {
const result = {}
for (const id in response) {
const obj = response[id].success
if (obj) {
const key = Object.keys(obj)[0]
const path = key.split('/')
result[path[path.length - 1]] = obj[key]
}
}
return result
}
return response
}
// Create resource.
async post (resource, body) {
this._debug('post(%j, %j)', resource, body)
const response = await this._request('post', resource, body)
this._debug('post(%j, %j) => %j', resource, body, response)
if (Array.isArray(response) && response[0] && response[0].success) {
const obj = response[0].success
if (typeof obj === 'object' && obj != null) {
const key = Object.keys(obj)[0]
const resp = {}
resp[key] = obj[key]
return resp
}
}
return response
}
// Delete resource.
async delete (resource, body) {
this._debug('delete(%j, %j)', resource, body)
const response = await this._request('delete', resource, body)
this._debug('delete(%j, %j) => %j', resource, body, response)
if (Array.isArray(response) && response[0] && response[0].success) {
const s = response[0].success
if (typeof s === 'string' && s.split(' ').length === 2) {
return s.split(' ')[0]
}
}
return response
}
// ===========================================================================
// Do an unauthenticated get of /config and cache the result.
async config () {
this._debug('config()')
const client = new homebridgeLib.HttpClient({
host: this._options.host,
json: true,
path: '/api',
timeout: this._options.timeout
})
const { body } = await client.get('/config')
this._debug('config() => %j', body)
return body
}
// Get the description.xml, converted to json.
async description () {
this._debug('description()')
const options = {
host: this._options.host,
timeout: this._options.timeout
}
if (this._options.https) {
options.https = true
options.checkCertificate = this._checkCertificate.bind(this)
}
const client = new homebridgeLib.HttpClient(options)
const { body } = await client.get('/description.xml')
const xmlOptions = { explicitArray: false }
const result = await xml2js.parseStringPromise(body, xmlOptions)
this._debug('description() => %j', result)
return result
}
// ===========================================================================
// Create a username.
async createuser (application) {
if (typeof application !== 'string' || application === '') {
throw new TypeError(`${application}: invalid application name`)
}
this._debug('createUsername(%j)', application)
const username = this._options.username
const body = { devicetype: `${application}#${os.hostname().split('.')[0]}` }
this._options.username = null
try {
const response = await this.post('/', body)
this._options.username = response.username
this._client.path = '/api/' + this._options.username
this._debug('createUsername() => %j', this._options.username)
return this._options.username
} catch (error) {
this._options.username = username
throw (error)
}
}
// Unlock the gateway to allow creating a new username.
async unlock () {
if (this.isDeconz) {
return this.put('/config', { unlock: 60 })
}
return this.put('/config', { linkbutton: true })
}
// Initiate a touchlink.
async touchlink () {
if (this.isDeconz) {
return this.post('/touchlink/scan')
}
return this.put('/config', { touchlink: true })
}
// Search for new devices.
async search () {
if (this.isDeconz) {
return this.put('/config', { permitjoin: 120 })
}
return this.post('/lights')
}
// ===========================================================================
// Check Hue bridge SSL certificate
_checkCertificate (cert) {
this._debug('_checkCertificate()')
if (Object.keys(cert).length > 0) {
if (
cert.subject == null ||
cert.subject.C !== 'NL' ||
cert.subject.O !== 'Philips Hue' ||
cert.subject.CN.toUpperCase() !== this._options.bridgeid ||
cert.issuer == null ||
cert.issuer.C !== 'NL' ||
cert.issuer.O !== 'Philips Hue' || (
cert.issuer.CN.toUpperCase() !== this._options.bridgeid &&
cert.issuer.CN !== 'root-bridge'
) ||
('00' + cert.serialNumber).slice(-16) !== this._options.bridgeid
) {
this._debug('certificate: %j', cert)
throw new Error('invalid SSL certificate')
}
if (this._options.fingerprint == null) {
this._options.fingerprint = cert.fingerprint256
this._debug('fingerprint: %s', this._options.fingerprint)
} else if (cert.fingerprint256 !== this._options.fingerprint) {
this._debug('certificate: %j', cert)
throw new Error('SSL certificate fingerprint mismatch')
}
}
this._debug('_checkCertificate() => ok')
}
// Issue REST API request to bridge/gateway.
async _request (method, resource, body = null) {
this._debug('request(%s, %s, %j)', method, resource, body)
if (this._client == null) {
const options = {
host: this._options.host,
json: true,
keepAlive: this._options.keepAlive,
maxSockets: this._options.maxSockets,
path: '/api',
timeout: this._options.timeout
}
if (this._options.phoscon) {
options.headers = { Accept: 'application/vnd.ddel.v1' }
}
if (this._options.username) {
options.path += '/' + this._options.username
}
if (this._options.https && !this._options.forceHttp) {
options.https = true
options.checkCertificate = this._checkCertificate.bind(this)
}
if (this._options.isDeconz) {
options.validStatusCodes = [200, 400, 403, 404]
}
this._client = new homebridgeLib.HttpClient(options)
}
const response = await this._client.request(method, resource, body)
if (Array.isArray(response.body)) {
for (const id in response.body) {
const e = response.body[id].error
if (e) {
const err = new Error(e.description)
err.code = '' + e.type
throw err
}
}
}
return response.body
}
}
module.exports = HueClient