homebridge-deconz
Version:
Homebridge plugin for deCONZ
532 lines (472 loc) • 18.1 kB
JavaScript
// homebridge-deconz/cli/deconz.js
// Copyright © 2018-2025 Erik Baauw. All rights reserved.
//
// Command line interface to Homebridge deCONZ UI Server.
import { readFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { CommandLineParser } from 'hb-lib-tools/CommandLineParser'
import { CommandLineTool } from 'hb-lib-tools/CommandLineTool'
import { HttpClient } from 'hb-lib-tools/HttpClient'
import { JsonFormatter } from 'hb-lib-tools/JsonFormatter'
import { OptionParser } from 'hb-lib-tools/OptionParser'
const require = createRequire(import.meta.url)
const packageJson = require('../package.json')
const { b, u } = CommandLineTool
const { UsageError } = CommandLineParser
const usage = {
ui: `${b('ui')} [${b('-hVD')}] [${b('-U')} ${u('username')}] [${b('-G')} ${u('gateway')}] [${b('-t')} ${u('timeout')}] ${u('command')} [${u('argument')} ...]`,
discover: `${b('discover')} [${b('-hsnjuatlkv')}]`,
get: `${b('get')} [${b('-hsnjuatlkv')}] ${u('resource')}`,
put: `${b('put')} [${b('-h')}] ${u('resource')} ${u('body')}`
}
const description = {
ui: 'Command line interface to Homebridge deCONZ UI Server for dynamic settings.',
discover: 'Discover UI servers and gateways.',
get: 'Get dynamic settings.',
put: 'Update dynamic settings.'
}
const help = {
ui: `${description.ui}
Usage: ${usage.ui}
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 Homebridge deCONZ UI Server.
${b('-U')} ${u('username')}, ${b('--username=')}${u('username')}
Specify the username of the Homebridge instance. Default: first instance in config.json.
${b('-G')} ${u('gateway')}, ${b('--gateway=')}${u('gateway')}
Specify the id of the deCONZ gateway. Default: first gateway in cachedAccessories.
${b('-t')} ${u('timeout')}, ${b('--timeout=')}${u('timeout')}
Set timeout to ${u('timeout')} seconds instead of default ${b(5)}.
Commands:
${usage.discover}
${description.discover}
${usage.get}
${description.get}
${usage.put}
${description.put}
For more help, issue: ${b('ui')} ${u('command')} ${b('-h')}`,
discover: `${description.discover}
Usage: ${b('ui')} ${usage.discover}
Parameters:
${b('-h')}, ${b('--help')}
Print this help and exit.
${b('-s')}, ${b('--sortKeys')}
Sort object key/value pairs alphabetically on key.
${b('-n')}, ${b('-noWhiteSpace')}
Do not include spaces nor newlines in the output.
${b('-j')}, ${b('--jsonArray')}
Output a 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')}, ${b('--joinKeys')}
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')}, ${b('--ascii')}
Output path:value in plain text instead of JSON.
${b('-t')}, ${b('--topOnly')}
Limit output to top-level key/values.
${b('-l')}, ${b('--leavesOnly')}
Limit output to leaf (non-array, non-object) key/values.
${b('-k')}, ${b('--keysOnly')}
Limit output to keys. With ${b('-u')}, output a JSON array of paths.
${b('-v')}, ${b('--valuesOnly')}
Limit output to values. With ${b('-u')}, output a JSON array of values.`,
get: `${description.get}
Usage: ${b('ui')} ${usage.get}
Parameters:
${b('-h')}, ${b('--help')}
Print this help and exit.
${b('-s')}, ${b('--sortKeys')}
Sort object key/value pairs alphabetically on key.
${b('-n')}, ${b('-noWhiteSpace')}
Do not include spaces nor newlines in the output.
${b('-j')}, ${b('--jsonArray')}
Output a 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')}, ${b('--joinKeys')}
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')}, ${b('--ascii')}
Output path:value in plain text instead of JSON.
${b('-t')}, ${b('--topOnly')}
Limit output to top-level key/values.
${b('-l')}, ${b('--leavesOnly')}
Limit output to leaf (non-array, non-object) key/values.
${b('-k')}, ${b('--keysOnly')}
Limit output to keys. With ${b('-u')}, output a JSON array of paths.
${b('-v')}, ${b('--valuesOnly')}
Limit output to values. With ${b('-u')}, output a JSON array of values.
${u('resource')}
The resource to get:
${b('/')} Get gateway.
${b('/accessories')} List accessories.
${b('/accessories/')}${u('id')} Get accessory.
${b('/devices')} List devices.
${b('/devices/')}${u('id')} Get device.
${b('/gateways')} List gateways.
${b('/gateways/')}${u('gid')} Get gateway.
${b('/gateways/')}${u('gid')}${b('/accessories')} List accessories.
${b('/gateways/')}${u('gid')}${b('/accessories/')}${u('id')} Get accessory.
${b('/gateways/')}${u('gid')}${b('/devices')} List devices.
${b('/gateways/')}${u('gid')}${b('/devices/')}${u('id')} Get device.`,
put: `${description.put}
Usage: ${b('ui')} ${usage.put}
Parameters:
${b('-h')}, ${b('--help')}
Print this help and exit.
${u('resource')}
The resource to update:
${b('/')} Update gateway settings.
${b('/accessories/')}${u('id')} Update accessory settings.
${b('/devices/')}${u('id')} Update device settings.
${b('/gateways/')}${u('gid')} Update gateway settings.
${b('/gateways/')}${u('gid')}${b('/accessories/')}${u('id')} Update accessory settings.
${b('/gateways/')}${u('gid')}${b('/devices/')}${u('id')} Update device settings.
${u('body')}
The new settings as JSON string.`
}
class Main extends CommandLineTool {
constructor () {
super({ mode: 'command', debug: false })
this.usage = usage.deconz
}
parseArguments () {
const parser = new CommandLineParser(packageJson)
const clargs = {
options: {
timeout: 5
}
}
parser
.help('h', 'help', help.ui)
.version('V', 'version')
.flag('D', 'debug', () => {
if (this.debugEnabled) {
this.setOptions({ vdebug: true })
} else {
this.setOptions({ debug: true, chalk: true })
}
})
.option('U', 'username', (value) => {
clargs.username = OptionParser.toString(
'username', value, true, true
).toUpperCase()
if (!OptionParser.patterns.mac.test(clargs.username)) {
throw new UsageError(`${clargs.username}: invalid username`)
}
})
.option('G', 'gateway', (value) => {
clargs.gateway = OptionParser.toString(
'gateway', value, true, true
).toUpperCase()
})
.option('t', 'timeout', (value) => {
clargs.options.timeout = OptionParser.toInt(
'timeout', value, 1, 60, 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()
return clargs
}
async main () {
try {
await this._main()
} catch (error) {
if (error.request == null) {
this.error(error)
}
}
}
/** Read and parse a JSON file.
*
* @param {string} filename - The name of the JSON file.
* @returns {*} body - The contents of the JSON file as JavaScript object.
*/
async readJsonFile (filename) {
this.vdebug('reading %s', filename)
const text = await readFile(filename)
this.debug('%s: %d bytes', filename, text.length)
const body = JSON.parse(text)
this.vdebug('%s: %j', filename, body)
return body
}
/** Read Homebridge's cachedAccessories.
*
* @param {string} dir - The Homebridge user directory.
* @param {string} username - The username (mac address) of the Homebridge bridge.
* @param {string} platformName - The name of the platform.
* @returns {Array<SerialisedAccessory>} accessories - The serialsed accessories.
*/
async readCachedAccessories (dir, username, platformName) {
let filename = dir + '/accessories/cachedAccessories'
if (username != null) {
filename += '.' + username.replace(/:/g, '').toUpperCase()
}
const body = await this.readJsonFile(filename)
return body.filter((accessory) => {
return accessory.platform === platformName
})
}
/** Get platform entries from Homebridge's config.json file.
*
* @param {string} dir - The Homebridge user directory.
* @param {string} platformName - The name of the platform.
* @returns {Array<PlatformInfo>} platforms - Array of matching platforms.
*/
async _getPlatforms (dir, platformName) {
const filename = dir + '/config.json'
const config = await this.readJsonFile(filename)
const gateways = []
for (const platform of config.platforms) {
if (platform.platform !== platformName) {
continue
}
try {
const childBridge = platform._bridge != null
const username = childBridge ? platform._bridge.username : config.bridge.username
const cachedAccessories = await this.readCachedAccessories(
dir, childBridge ? username : null, platformName
)
const cachedGateways = cachedAccessories.filter((accessory) => {
return accessory.context.className === 'Gateway'
})
for (const gateway of cachedGateways) {
gateways.push({
platformName: platform.name == null ? platform.platform : platform.name,
username,
childBridge,
uiPort: gateway.context.uiPort,
gid: gateway.context.id,
name: gateway.context.name
})
}
} catch (error) { this.warn(error) }
}
return gateways
}
/** Get platform entries from Homebridge's config.json file.
*
* @param {string} platformName - The name of the platform.
* @returns {Array<PlatformInfo>} platforms - Array of matching platforms.
*/
async getPlatforms (platformName) {
if (process.env.HOMEBRIDGE_DIR != null) {
return this._getPlatforms(process.env.HOMEBRIDGE_DIR, platformName)
}
for (const dir of ['/var/lib/homebridge', process.env.HOME + '/.homebridge', '.']) {
try {
return await this._getPlatforms(dir, platformName)
} catch (error) {
if (error.code !== 'ENOENT') {
throw error
}
}
}
throw new Error('cannot find config.json - please set HOMEBRIDGE_DIR')
}
/** Create a client to the UI server.
*
* @param {integer} uiPort - The port for the UI server.
* @returns {HttpClient} client - The UI client
*/
async createUiClient (uiPort) {
const host = 'localhost:' + uiPort
const client = new HttpClient({
host,
json: true,
name: host,
timeout: this.clargs.options.timeout
})
client
.on('error', (error) => {
if (error.request.id !== this.requestId) {
if (error.request.body == null) {
this.log(
'%s: request %d: %s %s', error.request.name, error.request.id,
error.request.method, error.request.resource
)
} else {
this.log(
'%s: request %d: %s %s %s', error.request.name, error.request.id,
error.request.method, error.request.resource, error.request.body
)
}
this.requestId = error.request.id
}
this.error('%s: request %d: %s', error.request.name, error.request.id, error)
})
.on('request', (request) => {
if (request.body == null) {
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
)
} else {
this.debug(
'%s: request %d: %s %s %s', request.name, request.id,
request.method, request.resource, request.body
)
this.vdebug(
'%s: request %d: %s %s %s', request.name, request.id,
request.method, request.url, request.body
)
}
})
.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
)
})
const response = await client.get('/ping')
if (response.body !== 'pong') {
throw new Error(`${host}: cannot ping`)
}
return client
}
async _main () {
this.clargs = this.parseArguments()
this.name = 'ui ' + this.clargs.command
this.usage = `${b('ui')} ${usage[this.clargs.command]}`
let platforms = await this.getPlatforms('deCONZ')
if (platforms.length === 0) {
throw new Error('no UI server found')
}
if (this.clargs.username != null) {
platforms = platforms.filter((platform) => {
return platform.username === this.clargs.username
})
if (platforms.length === 0) {
throw new Error(`no UI server found for bridge ${this.clargs.username}`)
}
}
if (this.clargs.gateway != null) {
platforms = platforms.filter((platform) => {
return platform.gid === this.clargs.gateway
})
if (platforms.length === 0) {
throw new Error(`no UI server found for gateway ${this.clargs.gateway}`)
}
}
if (this.clargs.command === 'discover') {
return this.discover(platforms, this.clargs.args)
}
const { gid, uiPort } = platforms[0]
this.client = await this.createUiClient(uiPort)
this.gid = gid
return this[this.clargs.command](this.clargs.args)
}
async discover (platforms, ...args) {
const parser = new CommandLineParser(packageJson)
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 })
.parse(...args)
const jsonFormatter = new JsonFormatter(clargs.options)
this.print(jsonFormatter.stringify(platforms))
}
async get (...args) {
const parser = new CommandLineParser(packageJson)
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 })
.parameter('resource', (value) => {
clargs.resource = OptionParser.toPath('resource', value)
})
.parse(...args)
if (clargs.resource === '/') {
clargs.resource = '/gateways/' + this.gid
} else if (
clargs.resource.startsWith('/devices') ||
clargs.resource.startsWith('/accessories')
) {
clargs.resource = '/gateways/' + this.gid + clargs.resource
}
const { body } = await this.client.get(clargs.resource)
const jsonFormatter = new JsonFormatter(clargs.options)
this.print(jsonFormatter.stringify(body))
}
async put (...args) {
const parser = new CommandLineParser(packageJson)
const clargs = {
options: {}
}
parser
.help('h', 'help', help.put)
.parameter('resource', (value) => {
clargs.resource = OptionParser.toPath('resource', value)
})
.parameter('body', (value) => {
value = OptionParser.toString('body', value, true, true)
try {
clargs.body = JSON.parse(value)
} catch (error) {
throw new UsageError(error.message) // Covert TypeError to UsageError.
}
})
.parse(...args)
if (clargs.resource === '/') {
clargs.resource = '/gateways/' + this.gid
} else if (
clargs.resource.startsWith('/devices') ||
clargs.resource.startsWith('/accessories')
) {
clargs.resource = '/gateways/' + this.gid + clargs.resource
}
clargs.resource += '/settings'
const jsonFormatter = new JsonFormatter(clargs.options)
const { body } = await this.client.put(clargs.resource, clargs.body)
this.print(jsonFormatter.stringify(body))
}
}
new Main().main()