hb-hue-tools
Version:
Homebridge Hue Tools
1,116 lines (989 loc) • 34.8 kB
JavaScript
// hb-hue-tools/lib/PhTool.js
//
// Homebridge plug-in for Philips Hue.
// Copyright © 2018-2025 Erik Baauw. All rights reserved.
//
// Command line interface to Philips Hue API.
import { readFileSync, writeFileSync } from 'node:fs'
import { CommandLineParser } from 'hb-lib-tools/CommandLineParser'
import { CommandLineTool } from 'hb-lib-tools/CommandLineTool'
import { JsonFormatter } from 'hb-lib-tools/JsonFormatter'
import { OptionParser } from 'hb-lib-tools/OptionParser'
import { HueClient } from './HueClient.js'
import { HueDiscovery } from './HueDiscovery.js'
const { b, u } = CommandLineTool
const { UsageError } = CommandLineParser
const usage = {
ph: `${b('ph')} [${b('-hVD')}] [${b('-H')} ${u('hostname')}] [${b('-K')} ${u('apiKey')}] [${b('-t')} ${u('timeout')}] ${u('command')} [${u('argument')} ...]`,
get: `${b('get')} [${b('-hsnjuatlkv')}] [${u('path')}]`,
put: `${b('put')} [${b('-hv')}] ${u('resource')} [${u('body')}]`,
post: `${b('post')} [${b('-hv')}] ${u('resource')} [${u('body')}]`,
delete: `${b('delete')} [${b('-hv')}] ${u('resource')} [${u('body')}]`,
eventlog: `${b('eventlog')} [${b('-hnrs')}] [${b('-v')} [${b('1')}|${b('2')}]]`,
discover: `${b('discover')} [${b('-hS')}]`,
config: `${b('config')} [${b('-hs')}]`,
description: `${b('description')} [${b('-hs')}]`,
getApiKey: `${b('getApiKey')} [${b('-hv')}]`,
getApplicationKey: `${b('getApplicationKey')} [${b('-hv')}]`,
unlock: `${b('unlock')} [${b('-hv')}]`,
touchlink: `${b('touchlink')} [${b('-hv')}]`,
search: `${b('search')} [${b('-hv')}]`,
outlet: `${b('outlet')} [${b('-hv')}]`,
switch: `${b('switch')} [${b('-hv')}]`,
probe: `${b('probe')} [${b('-hv')}] [${b('-t')} ${u('timeout')}] ${u('light')}`,
restart: `${b('restart')} [${b('-hv')}]`
}
const description = {
ph: 'Command line interface to Philips Hue API.',
get: `Retrieve ${u('path')} from bridge.`,
put: `Update ${u('resource')} on bridge with ${u('body')}.`,
post: `Create ${u('resource')} on bridge with ${u('body')}.`,
delete: `Delete ${u('resource')} from bridge with ${u('body')}.`,
eventlog: 'Log events from the Hue API v2 event stream.',
discover: 'Discover Hue bridges.',
config: 'Retrieve Hue bridge configuration (unauthenticated).',
description: 'Retrieve Hue bridge description.',
getApiKey: 'Create Hue bridge API key.',
getApplicationKey: 'Get the Hue bridge API v2 application key.',
unlock: 'Unlock Hue bridge so a new API apiKey can be created.',
touchlink: 'Initiate a touchlink.',
search: 'Initiate a seach for new devices.',
outlet: 'Create/update outlet resourcelink.',
switch: 'Create/update switch resourcelink.',
probe: `Probe ${u('light')} for supported colour (temperature) range.`,
restart: 'Restart Hue bridge.'
}
const help = {
ph: `${description.ph}
Usage: ${usage.ph}
Parameters:
${b('-h')}, ${b('--help')}
Print this help and exit.
${b('-V')}, ${b('--version')}
Print version and exit.
${b('-D')}, ${b('--debug')}
Print debug messages for communication with the Hue bridge.
${b('-f')}, ${b('--forceHttp')}
Use plain HTTP instead of HTTPS to communicate with the gen-2 Hue bridge.
${b('-H')} ${u('hostname')}, ${b('--host=')}${u('hostname')}
Connect to ${u('hostname')}.
${b('-K')} ${u('apiKey')}, ${b('--apiKey=')}${u('apiKey')}
Use ${u('apiKey')} instead of the apiKey saved in ${b('~/.ph')}.
${b('-t')} ${u('timeout')}, ${b('--timeout=')}${u('timeout')}
Set timeout to ${u('timeout')} seconds instead of default ${b(5)}.
Commands:
${usage.get}
${description.get}
${usage.put}
${description.put}
${usage.post}
${description.post}
${usage.delete}
${description.delete}
${usage.eventlog}
${description.eventlog}
${usage.discover}
${description.discover}
${usage.config}
${description.config}
${usage.description}
${description.description}
${usage.getApiKey}
${description.getApiKey}
${usage.unlock}
${description.unlock}
${usage.touchlink}
${description.touchlink}
${usage.search}
${description.search}
${usage.outlet}
${description.outlet}
${usage.switch}
${description.switch}
${usage.probe}
${description.probe}
${usage.restart}
${description.restart}
For more help, issue: ${b('ph')} ${u('command')} ${b('-h')}`,
get: `${description.ph}
Usage: ${b('ph')} ${usage.get}
${description.get}
Parameters:
${b('-h')} Print this help and exit.
${b('-s')} Sort object key/value pairs alphabetically on key.
${b('-n')} Do not include spaces nor newlines in output.
${b('-j')} Output JSON array of objects for each key/value pair.
Each object contains two key/value pairs: key "keys" with an array
of keys as value and key "value" with the value as value.
${b('-u')} Output JSON array of objects for each key/value pair.
Each object contains one key/value pair: the path (concatenated
keys separated by '/') as key and the value as value.
${b('-a')} Output path:value in plain text instead of JSON.
${b('-t')} Limit output to top-level key/values.
${b('-l')} Limit output to leaf (non-array, non-object) key/values.
${b('-k')} Limit output to keys. With -u output JSON array of paths.
${b('-v')} Limit output to values. With -u output JSON array of values.
${u('path')} Path to retrieve from the Hue bridge.`,
put: `${description.ph}
Usage: ${b('ph')} ${usage.put}
${description.put}
Parameters:
${b('-h')} Print this help and exit.
${b('-v')} Verbose.
${u('resource')} Resource to update.
${u('body')} Body in JSON.`,
post: `${description.ph}
Usage: ${b('ph')} ${usage.post}
${description.post}
Parameters:
${b('-h')} Print this help and exit.
${b('-v')} Verbose.
${u('resource')} Resource to update.
${u('body')} Body in JSON.`,
delete: `${description.ph}
Usage: ${b('ph')} ${usage.delete}
${description.delete}
Parameters:
${b('-h')} Print this help and exit.
${b('-v')} Verbose.
${u('resource')} Resource to update.
${u('body')} Body in JSON.`,
eventlog: `${description.ph}
Usage: ${b('ph')} ${usage.eventlog}
${description.eventlog}
Parameters:
${b('-h')} Print this help and exit.
${b('-n')} Do not retry when connection is closed.
${b('-r')} Do not parse events, output raw event data.
${b('-s')} Do not output timestamps (useful when running as service).
${b('-v')} Format events for API v1 (default) or for API v2.`,
discover: `${description.ph}
Usage: ${b('ph')} ${usage.discover}
${description.discover}
Parameters:
${b('-h')} Print this help and exit.
${b('-S')} Stealth mode, only use local discovery.`,
config: `${description.ph}
Usage: ${b('ph')} ${usage.config}
${description.config}
Parameters:
${b('-h')} Print this help and exit.
${b('-s')} Sort object key/value pairs alphabetically on key.`,
description: `${description.ph}
Usage: ${b('ph')} ${usage.description}
${description.description}
Parameters:
${b('-h')} Print this help and exit.
${b('-s')} Sort object key/value pairs alphabetically on key.`,
getApiKey: `${description.ph}
Usage: ${b('ph')} ${usage.getApiKey}
${description.getApiKey}
You need to press the linkbutton on the Hue bridge prior to issuing this command.
The apiKey is saved to ${b('~/.ph')}.
Parameters:
${b('-h')} Print this help and exit.
${b('-v')} Verbose.`,
getApplicationKey: `${description.ph}
Usage: ${b('ph')} ${usage.getApplicationKey}
${description.getApplicationKey}
Parameters:
${b('-h')} Print this help and exit.
${b('-v')} Verbose.`,
unlock: `${description.ph}
Usage: ${b('ph')} ${usage.unlock}
${description.unlock}
This is the equivalent of pressing the linkbutton on the Hue bridge.
Parameters:
${b('-h')} Print this help and exit.
${b('-v')} Verbose.`,
touchlink: `${description.ph}
Usage: ${b('ph')} ${usage.touchlink}
${description.touchlink}
Parameters:
${b('-h')} Print this help and exit.
${b('-v')} Verbose.`,
search: `${description.ph}
Usage: ${b('ph')} ${usage.search}
${description.search}
Parameters:
${b('-h')} Print this help and exit.
${b('-v')} Verbose.`,
outlet: `${description.ph}
Usage: ${b('ph')} ${usage.outlet}
${description.outlet}
The outlet resourcelink indicates which lights (and groups) homebridge-hue
exposes as Outlet (instead of Lightbulb).
Parameters:
${b('-h')} Print this help and exit.
${b('-v')} Verbose.`,
switch: `${description.ph}
Usage: ${b('ph')} ${usage.switch}
${description.switch}
The switch resourcelink indicates which lights (and groups) homebridge-hue
exposes as Switch (instead of Lightbulb).
Parameters:
${b('-h')} Print this help and exit.
${b('-v')} Verbose.`,
probe: `${description.ph}
Usage: ${b('ph')} ${usage.probe}
${description.probe}
Parameters:
${b('-h')} Print this help and exit.
${b('-v')} Verbose.
${b('-t')} ${u('timeout')} Timeout after ${u('timeout')} minutes (default: 5).
${u('light')} Light resource to probe.`,
restart: `${description.ph}
Usage: ${b('ph')} ${usage.restart}
${description.restart}
Parameters:
${b('-h')} Print this help and exit.
${b('-v')} Verbose.`
}
class PhTool extends CommandLineTool {
constructor (pkgJson) {
super({ mode: 'command', debug: false })
this.pkgJson = pkgJson
this.usage = usage.ph
try {
this.readBridges()
} catch (error) {
if (error.code !== 'ENOENT') {
this.error(error)
}
this.bridges = {}
}
}
// ===========================================================================
readBridges () {
const text = readFileSync(process.env.HOME + '/.ph')
try {
this.bridges = JSON.parse(text)
} catch (error) {
this.warn('%s/.ph: file corrupted', process.env.HOME)
this.bridges = {}
}
// Convert old format
const staleBridgeIds = []
let converted = false
for (const bridgeId in this.bridges) {
if (/^00212E[0-9A-F]{10}$/.test(bridgeId)) {
// delete stale apiKey for deCONZ
converted = true
staleBridgeIds.push(bridgeId)
} if (typeof this.bridges[bridgeId] === 'string') {
converted = true
this.bridges[bridgeId] = { apiKey: this.bridges[bridgeId] }
} else if (this.bridges[bridgeId].username != null) {
converted = true
this.bridges[bridgeId].apiKey = this.bridges[bridgeId].username
delete this.bridges[bridgeId].username
}
}
for (const bridgeId of staleBridgeIds) {
delete this.bridges[bridgeId]
}
if (converted) {
this.writeBridges()
}
}
writeBridges () {
const jsonFormatter = new JsonFormatter(
{ noWhiteSpace: true, sortKeys: true }
)
const text = jsonFormatter.stringify(this.bridges)
writeFileSync(process.env.HOME + '/.ph', text, { mode: 0o600 })
}
parseArguments () {
const parser = new CommandLineParser(this.pkgJson)
const clargs = {
options: {
forceHttp: false,
host: process.env.PH_HOST || 'localhost',
timeout: 5
}
}
parser
.help('h', 'help', help.ph)
.version('V', 'version')
.option('H', 'hostname', (value) => {
clargs.options.host = value
})
.flag('D', 'debug', () => {
if (this.debugEnabled) {
this.setOptions({ vdebug: true })
} else {
this.setOptions({ debug: true, chalk: true })
}
})
.flag('f', 'forceHttp', () => {
clargs.options.forceHttp = true
})
.option('t', 'timeout', (value) => {
clargs.options.timeout = OptionParser.toInt(
'timeout', value, 1, 60, true
)
})
.option('u', 'username', (value) => {
this.warn('-u: deprecated, use -K')
clargs.options.apiKey = OptionParser.toString(
'apiKey', value, true, true
)
})
.option('K', 'apiKey', (value) => {
clargs.options.apiKey = OptionParser.toString(
'apiKey', value, true, true
)
})
.parameter('command', (value) => {
if (usage[value] == null || typeof this[value] !== 'function') {
throw new UsageError(`${value}: unknown command`)
}
clargs.command = value
})
.remaining((list) => { clargs.args = list })
parser
.parse()
const { port } = OptionParser.toHost('hostname', clargs.options.host, false, true)
if (port != null) {
throw new UsageError(`hostname: ${clargs.options.host}: not a valid hostname or IPv4 address`)
}
return clargs
}
async main () {
try {
await this._main()
} catch (error) {
if (error.request == null) {
this.error(error)
}
}
}
async _main () {
const clargs = this.parseArguments()
this.hueDiscovery = new HueDiscovery({
forceHttp: clargs.options.forceHttp,
timeout: clargs.options.timeout
})
this.hueDiscovery
.on('error', (error) => {
this.log(
'%s: request %d: %s %s', error.request.name,
error.request.id, error.request.method, error.request.resource
)
this.warn(
'%s: request %d: %s', error.request.name, error.request.id, error
)
})
.on('request', (request) => {
this.debug(
'%s: request %d: %s %s', request.name,
request.id, request.method, request.resource
)
this.vdebug(
'%s: request %d: %s %s', request.name,
request.id, request.method, request.url
)
})
.on('response', (response) => {
this.vdebug(
'%s: request %d: response: %j', response.request.name,
response.request.id, response.body
)
this.debug(
'%s: request %d: %d %s', response.request.name,
response.request.id, response.statusCode, response.statusMessage
)
})
.on('found', (name, id, address) => {
this.debug('%s: found %s at %s', name, id, address)
})
.on('searching', (name, host) => {
this.debug('%s: listening on %s', name, host)
})
.on('searchDone', (name) => { this.debug('%s: search done', name) })
if (clargs.command === 'discover') {
return this.discover(clargs.args)
}
try {
this.bridgeConfig = await this.hueDiscovery.config(clargs.options.host)
} catch (error) {
if (error.request == null) {
// this.error(error)
}
this.fatal('%s: not a Hue bridge', clargs.options.host)
return
}
if (clargs.command === 'config') {
return this.config(clargs.args)
}
clargs.options.config = this.bridgeConfig
this.bridgeid = this.bridgeConfig.bridgeid
if (clargs.options.apiKey == null) {
if (
this.bridges[this.bridgeid] != null &&
this.bridges[this.bridgeid].apiKey != null
) {
clargs.options.apiKey = this.bridges[this.bridgeid].apiKey
} else if (process.env.PH_API_KEY != null) {
clargs.options.apiKey = process.env.PH_API_KEY
} else if (process.env.PH_USERNAME != null) {
this.warn('PH_USERNAME is deprecated, use PH_API_KEY')
clargs.options.apiKey = process.env.PH_USERNAME
}
}
if (
this.bridges[this.bridgeid] != null &&
this.bridges[this.bridgeid].fingerprint != null
) {
clargs.options.fingerprint = this.bridges[this.bridgeid].fingerprint
}
if (clargs.options.apiKey == null && clargs.command !== 'getApiKey') {
let args = ''
if (
clargs.options.host !== 'localhost' &&
clargs.options.host !== process.env.PH_HOST
) {
args += ' -H ' + clargs.options.host
}
await this.fatal(
'missing apiKey - press link button and run "ph%s getApiKey"', args
)
}
this.hueClient = new HueClient(clargs.options)
this.hueClient
.on('error', (error) => {
if (error.request.id !== this.requestId) {
if (error.request.body == null) {
this.log(
'request %d: %s %s', error.request.id,
error.request.method, error.request.resource
)
} else {
this.log(
'request %d: %s %s %s', error.request.id,
error.request.method, error.request.resource, error.request.body
)
}
this.requestId = error.request.id
}
if (error.nonCritical) {
this.warn('request %d: %s', error.request.id, error)
} else {
this.error('request %d: %s', error.request.id, error)
}
})
.on('request', (request) => {
if (request.body == null) {
this.debug(
'request %d: %s %s', request.id, request.method, request.resource
)
this.vdebug(
'request %d: %s %s', request.id, request.method, request.url
)
} else {
this.debug(
'request %d: %s %s %s', request.id,
request.method, request.resource, request.body
)
this.vdebug(
'request %d: %s %s %s', request.id,
request.method, request.url, request.body
)
}
})
.on('response', (response) => {
this.vdebug(
'request %d: response: %j', response.request.id, response.body
)
this.debug(
'request %d: %d %s', response.request.id,
response.statusCode, response.statusMessage
)
})
this.options = clargs.options
this.name = 'ph ' + clargs.command
this.usage = `${b('ph')} ${usage[clargs.command]}`
return this[clargs.command](clargs.args)
}
// ===== GET =================================================================
async get (...args) {
const parser = new CommandLineParser(this.pkgJson)
const clargs = {
options: {}
}
parser
.help('h', 'help', help.get)
.flag('s', 'sortKeys', () => { clargs.options.sortKeys = true })
.flag('n', 'noWhiteSpace', () => {
clargs.options.noWhiteSpace = true
})
.flag('j', 'jsonArray', () => { clargs.options.noWhiteSpace = true })
.flag('u', 'joinKeys', () => { clargs.options.joinKeys = true })
.flag('a', 'ascii', () => { clargs.options.ascii = true })
.flag('t', 'topOnly', () => { clargs.options.topOnly = true })
.flag('l', 'leavesOnly', () => { clargs.options.leavesOnly = true })
.flag('k', 'keysOnly', () => { clargs.options.keysOnly = true })
.flag('v', 'valuesOnly', () => { clargs.options.valuesOnly = true })
.remaining((list) => {
if (list.length > 1) {
throw new UsageError('too many paramters')
}
clargs.resource = list.length === 0
? '/'
: OptionParser.toPath('resource', list[0])
})
.parse(...args)
const jsonFormatter = new JsonFormatter(clargs.options)
const response = await this.hueClient.get(clargs.resource)
this.print(jsonFormatter.stringify(response))
}
// ===== PUT, POST, DELETE ===================================================
async resourceCommand (command, ...args) {
const parser = new CommandLineParser(this.pkgJson)
const clargs = {
options: {}
}
parser
.help('h', 'help', help[command])
.flag('v', 'verbose', () => { clargs.options.verbose = true })
.parameter('resource', (resource) => {
clargs.resource = OptionParser.toPath('resource', resource)
if (clargs.resource === '/') {
throw new UsageError(`/: invalid resource for ${command}`)
}
})
.remaining((list) => {
if (list.length > 1) {
throw new Error('too many paramters')
}
if (list.length === 1) {
try {
clargs.body = JSON.parse(list[0])
} catch (error) {
throw new Error(error.message) // Covert TypeError to Error.
}
}
})
.parse(...args)
const response = await this.hueClient[command](clargs.resource, clargs.body)
const jsonFormatter = new JsonFormatter()
if (clargs.options.verbose || response.success == null) {
this.print(jsonFormatter.stringify(response.body))
return
}
if (command !== 'put') {
if (response.success.id != null) {
this.print(jsonFormatter.stringify(response.success.id))
} else {
this.print(jsonFormatter.stringify(response.success))
}
return
}
this.print(jsonFormatter.stringify(response.success))
}
async put (...args) {
return this.resourceCommand('put', ...args)
}
async post (...args) {
return this.resourceCommand('post', ...args)
}
async delete (...args) {
return this.resourceCommand('delete', ...args)
}
// ===========================================================================
async destroy () {
if (this.eventStream != null) {
await this.eventStream.close()
}
}
async eventlog (...args) {
const parser = new CommandLineParser(this.pkgJson)
let mode = 'daemon'
const options = {
version: 1
}
parser
.help('h', 'help', help.eventlog)
.flag('n', 'noretry', () => { options.retryTime = 0 })
.flag('r', 'raw', () => { options.raw = true })
.flag('s', 'service', () => { mode = 'service' })
.option('v', 'version', (value) => {
options.version = OptionParser.toInt('version', value, 1, 2, true)
})
.parse(...args)
this.jsonFormatter = new JsonFormatter(
mode === 'service' ? { noWhiteSpace: true } : {}
)
if (this.hueClient.isHue2) {
const { EventStreamClient } = await import('./EventStreamClient.js')
this.eventStream = new EventStreamClient(this.hueClient, options)
this.setOptions({ mode })
this.eventStream
.on('error', (error) => {
this.log(
'request %d: %s %s', error.request.id,
error.request.method, error.request.resource
)
this.warn('request %d: %s', error.request.id, error)
})
.on('request', (request) => {
if (request.body == null) {
this.debug(
'request %d: %s %s', request.id, request.method, request.resource
)
this.vdebug(
'request %d: %s %s', request.id, request.method, request.url
)
} else {
this.debug(
'request %d: %s %s %s', request.id,
request.method, request.resource, request.body
)
this.vdebug(
'request %d: %s %s %s', request.id,
request.method, request.url, request.body
)
}
})
.on('response', (response) => {
if (response.body != null) {
this.vdebug(
'request %d: response: %j', response.request.id, response.body
)
}
this.debug(
'request %d: %d %s', response.request.id,
response.statusCode, response.statusMessage
)
})
.on('listening', (url) => { this.log('listening on %s', url) })
.on('closed', (url) => { this.log('connection to %s closed', url) })
.on('changed', (resource, body) => {
this.log('%s: %s', resource, this.jsonFormatter.stringify(body))
})
.on('notification', (body) => {
if (options.raw) {
this.log(this.jsonFormatter.stringify(body))
} else {
this.debug(this.jsonFormatter.stringify(body))
}
})
.on('data', (s) => { this.vdebug('data: %s', s) })
await this.eventStream.init()
this.eventStream.listen()
} else {
await this.fatal('eventlog: only supported for Hue bridge with API v2')
}
}
async simpleCommand (command, ...args) {
const parser = new CommandLineParser(this.pkgJson)
const clargs = {
options: {}
}
parser
.help('h', 'help', help[command])
.flag('v', 'verbose', () => { clargs.options.verbose = true })
.parse(...args)
const response = await this.hueClient[command]()
const jsonFormatter = new JsonFormatter()
for (const error of response.errors) {
this.warn('api error %d: %s', error.type, error.description)
}
if (clargs.options.verbose || response.success == null) {
this.print(jsonFormatter.stringify(response.body))
return
}
if (response.success.id != null) {
this.print(jsonFormatter.stringify(response.success.id))
return
}
if (response.success != null) {
this.print(jsonFormatter.stringify(response.success))
return
}
this.print(jsonFormatter.stringify(response.body))
}
async discover (...args) {
const parser = new CommandLineParser(this.pkgJson)
const params = {}
parser
.help('h', 'help', help.discover)
.flag('S', 'stealth', () => { params.stealth = true })
.parse(...args)
const jsonFormatter = new JsonFormatter({ sortKeys: true })
const bridges = await this.hueDiscovery.discover(params)
this.print(jsonFormatter.stringify(bridges))
}
async config (...args) {
const parser = new CommandLineParser(this.pkgJson)
const options = {}
parser
.help('h', 'help', help.config)
.flag('s', 'sortKeys', () => { options.sortKeys = true })
.parse(...args)
const jsonFormatter = new JsonFormatter(options)
const json = jsonFormatter.stringify(this.bridgeConfig)
this.print(json)
}
async description (...args) {
const parser = new CommandLineParser(this.pkgJson)
const options = {}
parser
.help('h', 'help', help.description)
.flag('s', 'sortKeys', () => { options.sortKeys = true })
.parse(...args)
const response = await this.hueDiscovery.description(this.options.host)
const jsonFormatter = new JsonFormatter(options)
const json = jsonFormatter.stringify(response)
this.print(json)
}
async getApiKey (...args) {
const parser = new CommandLineParser(this.pkgJson)
const jsonFormatter = new JsonFormatter(
{ noWhiteSpace: true, sortKeys: true }
)
parser
.help('h', 'help', help.getApiKey)
.parse(...args)
const apiKey = await this.hueClient.getApiKey('ph')
this.print(jsonFormatter.stringify(apiKey))
this.bridges[this.bridgeid] = { apiKey }
if (this.hueClient.fingerprint != null) {
this.bridges[this.bridgeid].fingerprint = this.hueClient.fingerprint
}
this.writeBridges()
}
async getApplicationKey (...args) {
const parser = new CommandLineParser(this.pkgJson)
const clargs = {
options: {}
}
parser
.help('h', 'help', help.getApplicationKey)
.flag('v', 'verbose', () => { clargs.options.verbose = true })
.parse(...args)
const response = await this.hueClient.getApplicationKey()
const jsonFormatter = new JsonFormatter()
this.print(jsonFormatter.stringify(response))
}
async unlock (...args) {
return this.simpleCommand('unlock', ...args)
}
async touchlink (...args) {
return this.simpleCommand('touchlink', ...args)
}
async search (...args) {
return this.simpleCommand('search', ...args)
}
async outlet (...args) {
const parser = new CommandLineParser(this.pkgJson)
const clargs = {}
parser
.help('h', 'help', help.outlet)
.flag('v', 'verbose', () => { clargs.verbose = true })
.parse(...args)
let outlet
const lights = await this.hueClient.get('/lights')
const resourcelinks = await this.hueClient.get('/resourcelinks')
for (const id in resourcelinks) {
const link = resourcelinks[id]
if (link.name === 'homebridge-hue' && link.description === 'outlet') {
outlet = id
}
}
if (outlet == null) {
const body = {
name: 'homebridge-hue',
classid: 1,
description: 'outlet',
links: []
}
const response = await this.hueClient.post('/resourcelinks', body)
for (const error of response.errors) {
this.warn('api error %d: %s', error.type, error.description)
}
outlet = response.success.id
}
const body = {
links: []
}
for (const id in lights) {
if (lights[id].type.includes('plug')) {
body.links.push(`/lights/${id}`)
}
}
await this.hueClient.put(`/resourcelinks/${outlet}`, body)
clargs.verbose && this.log(
'/resourcelinks/%s: %d outlets', outlet, body.links.length
)
}
async switch (...args) {
const parser = new CommandLineParser(this.pkgJson)
const clargs = {}
parser
.help('h', 'help', help.switch)
.flag('v', 'verbose', () => { clargs.verbose = true })
.parse(...args)
let outlet
const lights = await this.hueClient.get('/lights')
const resourcelinks = await this.hueClient.get('/resourcelinks')
for (const id in resourcelinks) {
const link = resourcelinks[id]
if (link.name === 'homebridge-hue' && link.description === 'switch') {
outlet = id
}
}
if (outlet == null) {
const body = {
name: 'homebridge-hue',
classid: 1,
description: 'switch',
links: []
}
const response = await this.hueClient.post('/resourcelinks', body)
for (const error of response.errors) {
this.warn('api error %d: %s', error.type, error.description)
}
outlet = response.success.id
}
const body = {
links: []
}
for (const id in lights) {
if (lights[id].type.toLowerCase().includes('on/off')) {
body.links.push(`/lights/${id}`)
}
}
await this.hueClient.put(`/resourcelinks/${outlet}`, body)
clargs.verbose && this.log(
'/resourcelinks/%s: %d switches', outlet, body.links.length
)
}
async probe (...args) {
const parser = new CommandLineParser(this.pkgJson)
const clargs = {
maxCount: 60
}
parser
.help('h', 'help', help.probe)
.flag('v', 'verbose', () => { clargs.verbose = true })
.option('t', 'timeout', (value, key) => {
OptionParser.toInt('timeout', value, 1, 10, true)
clargs.maxCount = value * 12
})
.parameter('light', (value) => {
if (value.substring(0, 8) !== '/lights/') {
throw new UsageError(`${value}: invalid light`)
}
clargs.light = value
})
.parse(...args)
const light = await this.hueClient.get(clargs.light)
async function probeCt (name, value) {
clargs.verbose && this.log(`${clargs.light}: ${name} ...\\c`)
await this.hueClient.put(clargs.light + '/state', { ct: value })
let count = 0
return new Promise((resolve, reject) => {
const interval = setInterval(async () => {
const ct = await this.hueClient.get(clargs.light + '/state/ct')
if (ct !== value || ++count > clargs.maxCount) {
clearInterval(interval)
clargs.verbose && this.logc(
count > clargs.maxCount ? ' timeout' : ' done'
)
return resolve(ct)
}
clargs.verbose && this.logc('.\\c')
}, 5000)
})
}
async function probeXy (name, value) {
clargs.verbose && this.log(`${clargs.light}: ${name} ...\\c`)
await this.hueClient.put(clargs.light + '/state', { xy: value })
let count = 0
return new Promise((resolve, reject) => {
const interval = setInterval(async () => {
const xy = await this.hueClient.get(clargs.light + '/state/xy')
if (
xy[0] !== value[0] || xy[1] !== value[1] ||
++count > clargs.maxCount
) {
clearInterval(interval)
clargs.verbose && this.logc(
count > clargs.maxCount ? ' timeout' : ' done'
)
return resolve(xy)
}
clargs.verbose && this.logc('.\\c')
}, 5000)
})
}
this.verbose && this.log(
'%s: %s %s %s "%s"', clargs.light, light.manufacturername,
light.modelid, light.type, light.name
)
const response = {
manufacturername: light.manufacturername,
modelid: light.modelid,
type: light.type,
bri: light.state.bri != null
}
await this.hueClient.put(clargs.light + '/state', { on: true })
if (light.state.ct != null) {
response.ct = {}
response.ct.min = await probeCt.call(this, 'cool', 1)
response.ct.max = await probeCt.call(this, 'warm', 1000)
}
if (light.state.xy != null) {
const zero = 0.0001
const one = 0.9961
response.xy = {}
response.xy.r = await probeXy.call(this, 'red', [one, zero])
response.xy.g = await probeXy.call(this, 'green', [zero, one])
response.xy.b = await probeXy.call(this, 'blue', [zero, zero])
}
await this.hueClient.put(clargs.light + '/state', { on: light.state.on })
this.jsonFormatter = new JsonFormatter()
const json = this.jsonFormatter.stringify(response)
this.print(json)
}
async restart (...args) {
const parser = new CommandLineParser(this.pkgJson)
const clargs = {}
parser
.help('h', 'help', help.restart)
.flag('v', 'verbose', () => { clargs.verbose = true })
.parse(...args)
if (this.hueClient.isHue) {
const response = await this.hueClient.put('/config', { reboot: true })
if (!response.success.reboot) {
return false
}
} else {
await this.fatal('restart: only supported for Hue bridge')
}
clargs.verbose && this.log('restarting ...\\c')
return new Promise((resolve, reject) => {
let busy = false
const interval = setInterval(async () => {
try {
if (!busy) {
busy = true
const bridgeid = await this.hueClient.get('/config/bridgeid')
if (bridgeid === this.bridgeid) {
clearInterval(interval)
clargs.verbose && this.logc(' done')
return resolve(true)
}
busy = false
}
} catch (error) {
busy = false
}
clargs.verbose && this.logc('.\\c')
}, 2500)
})
}
}
export { PhTool }