homebridge-deconz
Version:
Homebridge plugin for deCONZ
1,397 lines (1,312 loc) • 45.5 kB
JavaScript
// homebridge-deconz/lib/DeconzAccessory/Gateway.js
// Copyright © 2022-2025 Erik Baauw. All rights reserved.
//
// Homebridge plugin for deCONZ.
import { timeout } from 'homebridge-lib'
import { AccessoryDelegate } from 'homebridge-lib/AccessoryDelegate'
import { OptionParser } from 'homebridge-lib/OptionParser'
import { ApiClient } from 'hb-deconz-tools/ApiClient'
import { ApiError } from 'hb-deconz-tools/ApiError'
import { WsClient } from 'hb-deconz-tools/WsClient'
import { Deconz } from '../Deconz/index.js'
import '../Deconz/Resource.js'
import '../Deconz/Device.js'
import { DeconzAccessory } from '../DeconzAccessory/index.js'
import { DeconzService } from '../DeconzService/index.js'
import '../DeconzService/Button.js'
import '../DeconzService/Gateway.js'
const { HttpError } = ApiClient
const migration = {
name: 'homebridge-deconz',
description: 'migration',
classid: 1
}
const rtypes = ['lights', 'sensors', 'groups', 'alarmsystems']
const periodicEvents = [
{ rate: 60, event: 1002 },
{ rate: 3600, event: 1004 },
{ rate: 86400, event: 1003 }
]
/** Delegate class for a deCONZ gateway.
* @extends AccessoryDelegate
* @memberof DeconzAccessory
*/
class Gateway extends AccessoryDelegate {
/** Instantiate a gateway delegate.
* @param {DeconzPlatform} platform - The platform plugin.
* @param {Object} params - Parameters.
* @param {Object} params.config - The response body of an unauthenticated
* GET `/config` (from {@link DeconzDiscovery#config config()}.
* @param {string} params.host - The gateway hostname or IP address and port.
*/
constructor (platform, params) {
super(platform, {
id: params.config.bridgeid,
name: params.config.name,
manufacturer: 'dresden elektronik',
model: params.config.modelid + ' / ' + params.config.devicename,
firmware: '0.0.0',
software: params.config.swversion,
category: platform.Accessory.Categories.BRIDGE
})
this.gateway = this
this.id = params.config.bridgeid
this.recommendedSoftware = this.platform.packageJson.engines.deCONZ
/** Persisted properties.
* @type {Object}
* @property {Object} config - Response body of unauthenticated
* GET `/config` (from {@link DeconzDiscovery#config config()}.
* @property {Object} fullState - The gateway's full state, from the
* last time the gateway was polled.
* @property {Object.<String, Object>} settingsById - The persisted settings, maintained through
* the Homebridge UI.
*/
this.context // eslint-disable-line no-unused-expressions
this.context.config = params.config
if (this.context.settingsById == null) {
this.context.settingsById = {}
}
// if (this.context.fullState != null) {
// this.analyseFullState(this.context.fullState, {
// analyseOnly: true,
// logUnsupported: true
// })
// }
this.addPropertyDelegate({
key: 'apiKey',
silent: true
}).on('didSet', (value) => {
this.client.apiKey = value
})
this.addPropertyDelegate({
key: 'autoExpose',
value: true,
silent: true
})
this.addPropertyDelegate({
key: 'brightnessAdjustment',
value: 1,
silent: true
})
this.addPropertyDelegate({
key: 'expose',
value: true,
silent: true
}).on('didSet', async (value) => {
try {
this.service.values.statusActive = value
if (value) {
await this.connect()
} else {
await this.reset()
}
} catch (error) { this.error(error) }
})
this.addPropertyDelegate({
key: 'exposeSchedules',
value: false,
silent: true
}).on('didSet', async (value) => {
this.pollNext = true
})
this.addPropertyDelegate({
key: 'heartrate',
value: 30,
silent: true
})
this.addPropertyDelegate({
key: 'host',
value: params.host,
silent: true
}).on('didSet', (value) => {
if (this.client != null) {
this.client.host = value
}
if (this.wsClient != null) {
this.wsClient.host = this.values.host.split(':')[0] +
':' + this.values.wsPort
}
})
this.addPropertyDelegate({
key: 'periodicEvents',
value: false,
silent: true
})
this.addPropertyDelegate({
key: 'restart',
value: false,
silent: true
}).on('didSet', async (value) => {
if (value) {
try {
await this.client.restart()
this.values.search = false
this.values.unlock = false
} catch (error) { this.warn(error) }
}
})
this.addPropertyDelegate({
key: 'search',
value: false,
silent: true
}).on('didSet', async (value) => {
this.service.values.search = value
if (value) {
try {
await this.client.search()
await timeout(120000)
this.values.search = false
} catch (error) { this.warn(error) }
}
})
this.addPropertyDelegate({
key: 'unlock',
value: false,
silent: true
}).on('didSet', async (value) => {
if (value) {
try {
await this.client.unlock()
await timeout(60000)
this.values.unlock = false
} catch (error) { this.warn(error) }
}
})
this.addPropertyDelegate({
key: 'wsPort',
value: 443,
silent: true
}).on('didSet', (value) => {
if (this.wsClient != null) {
this.wsClient.host = this.values.host.split(':')[0] +
':' + this.values.wsPort
}
})
this.log(
'%s %s gateway v%s', this.values.manufacturer, this.values.model,
this.values.software
)
if (this.values.software !== this.recommendedSoftware) {
this.warn('recommended version: deCONZ v%s', this.recommendedSoftware)
}
/** Map of Accessory delegates by id for the gateway.
* @type {Object<string, DeconzAccessory.Device>}
*/
this.accessoryById = {}
/** Map of Accessory delegates by rpath for the gateway.
* @type {Object<string, DeconzAccessory.Device>}
*/
this.accessoryByRpath = {}
this.defaultTransitionTime = 0.4
/** Map of errors by device ID trying to expose the corresponding accessory.
* @type {Object<string, Error>}
*/
this.exposeErrorById = {}
/** The service delegate for the Gateway Settings settings.
* @type {DeconzService.Gateway}
*/
this.service = new DeconzService.Gateway(this, {
name: this.name + ' Gateway',
primaryService: true,
host: params.host
})
/** The service delegate for the Stateless Programmable Switch service.
* @type {DeconzService.Button}
*/
this.buttonService = new DeconzService.Button(this, {
name: this.name + ' Button',
button: 1,
events: DeconzService.Button.SINGLE | DeconzService.Button.DOUBLE |
DeconzService.Button.LONG
})
/** The service delegates for the Schedule services.
* @type {Object<string, DeconzService.Schedule>}
*/
this.scheduleServicesByRid = {}
this.createClient()
this.createWsClient()
this.heartbeatEnabled = true
this
.on('identify', this.identify)
.once('heartbeat', (beat) => { this.initialBeat = beat })
.on('heartbeat', this.heartbeat)
.on('shutdown', this.shutdown)
}
get transitionTime () { return this.service.values.transitionTime }
async resetTransitionTime () {
if (this.resetting) {
return
}
this.resetting = true
await timeout(this.platform.config.waitTimeUpdate)
this.service.values.transitionTime = this.defaultTransitionTime
this.resetting = false
}
/** Log debug messages.
*/
identify () {
this.log(
'%s %s gateway v%s (%d accessories for %d devices, %d resources)',
this.values.manufacturer, this.values.model, this.values.software,
this.nAccessories, this.nDevices, this.nResourcesMonitored
)
if (this.values.software !== this.recommendedSoftware) {
this.warn('recommended version: deCONZ v%s', this.recommendedSoftware)
}
if (this.context.migration != null) {
this.log(
'migration: %s: %d resources',
this.context.migration, this.nResourcesMonitored
)
}
if (this.logLevel > 2) {
this.vdebug(
'%d gateway resouces: %j', this.nResources,
Object.keys(this.resourceByRpath).sort()
)
this.vdebug(
'%d gateway devices: %j', this.nDevices,
Object.keys(this.deviceById).sort()
)
this.vdebug(
'%d accessories: %j', this.nAccessories,
Object.keys(this.accessoryById).sort()
)
this.vdebug(
'monitoring %d resources: %j', this.nResourcesMonitored,
Object.keys(this.accessoryByRpath).sort()
)
const exposeErrors = Object.keys(this.exposeErrorById).sort()
this.vdebug(
'%d accessories with expose errors: %j', exposeErrors.length,
exposeErrors
)
const settings = Object.keys(this.context.settingsById).sort()
this.vdebug(
'settings: %d devices: %j', settings.length, settings)
}
}
/** Update properties from gateway announcement.
* @param {string} host - The gateway hostname or IP address and port.
* @param {Object} config - The response body of an unauthenticated
* GET `/config` (from {@link DeconzDiscovery#config config()}.
*/
async found (host, config) {
try {
this.values.host = host
this.context.config = config
this.values.software = config.swversion
if (!this.initialised) {
this.debug('initialising...')
await this.connect()
}
} catch (error) {
this.error(error)
}
}
async shutdown () {
this.service.values.statusActive = false
return this.wsClient.close()
}
/** Called every second.
* @param {integer} beat
*/
async heartbeat (beat) {
beat -= this.initialBeat
try {
if (this.values.periodicEvents && beat > 0) {
for (const { rate, event } of periodicEvents) {
if (beat % rate === 0) {
this.buttonService.update(event)
}
}
}
if (beat - this.pollBeat >= this.values.heartrate || this.pollNext) {
this.pollBeat = beat
await this.poll()
}
} catch (error) { this.error(error) }
}
update (config) {
this.values.software = config.swversion
this.values.firmware = parseInt(config.fwversion.slice(6, 8)) + '.' +
parseInt(config.fwversion.slice(2, 4), 16) + '.' +
parseInt(config.fwversion.slice(4, 6), 16)
this.values.wsPort = config.websocketport
this.service.update(config)
if (this.checkApiKeys) {
const myEntry = config.whitelist[this.values.apiKey]
for (const key in config.whitelist) {
if (key !== this.values.apiKey) {
const entry = config.whitelist[key]
if (entry.name === myEntry.name) {
this.warn('%s: potentially stale api key: %j', key, entry)
}
}
}
delete this.checkApiKeys
}
}
/** Create {@link DeconzAccessory.Gateway#client}.
*/
createClient () {
/** REST API client for the gateway.
* @type {DeconzClient}
*/
this.client = new ApiClient({
apiKey: this.values.apiKey,
config: this.context.config,
host: this.values.host,
maxSockets: this.platform.config.parallelRequests,
timeout: this.platform.config.timeout,
waitTimePut: this.platform.config.waitTimePut,
waitTimePutGroup: this.platform.config.waitTimePutGroup,
waitTimeResend: this.platform.config.waitTimeResend
})
this.client
.on('error', (error) => {
if (error instanceof HttpError) {
if (error.request.id !== this.requestId) {
this.log(
'request %d: %s %s%s', error.request.id,
error.request.method, error.request.resource,
error.request.body == null ? '' : ' ' + error.request.body
)
this.requestId = error.request.id
}
this.warn('request %s: %s', error.request.id, error)
return
}
this.warn(error)
})
.on('request', (request) => {
this.debug(
'request %d: %s %s%s', request.id,
request.method, request.resource,
request.body == null ? '' : ' ' + request.body
)
this.vdebug(
'request %s: %s %s%s', request.id,
request.method, request.url,
request.body == null ? '' : ' ' + request.body
)
})
.on('response', (response) => {
this.vdebug(
'request %d: response: %j', response.request.id,
response.body
)
this.debug(
'request %s: %d %s', response.request.id,
response.statusCode, response.statusMessage
)
})
}
/** Create {@link DeconzAccessory.Gateway#wsclient}.
*/
createWsClient () {
/** Client for gateway web socket notifications.
* @type {DeconzWsClient}
*/
this.wsClient = new WsClient({
host: this.values.host.split(':')[0] + ':' + this.values.wsPort,
retryTime: 15
})
this.wsClient
.on('error', (error) => {
this.warn('websocket communication error: %s', error)
})
.on('listening', (url) => {
this.log('websocket connected to %s', url)
})
.on('changed', (rtype, rid, body) => {
try {
const rpath = '/' + rtype + '/' + rid
this.vdebug('%s: changed: %j', rpath, body)
const accessory = this.accessoryByRpath[rpath]
if (accessory != null) {
/** Emitted when a change notificatoin for a resource has been
* received over the web socket.
* @event DeconzAccessory.Device#changed
* @param {string} rpath - The resource path.
* @param {Object} body - The resource body.
*/
accessory.emit('changed', rpath, body)
}
} catch (error) {
this.warn('websocket error: %s', error)
}
})
.on('added', (rtype, rid, body) => {
this.vdebug('/%s/%d: added: %j', rtype, rid, body)
this.pollNext = true
this.pollFullState = true
})
.on('deleted', (rtype, rid) => {
this.vdebug('/%s/%d: deleted', rtype, rid)
this.pollNext = true
this.pollFullState = true
})
.on('closed', (url, retryTime) => {
if (retryTime > 0) {
this.log(
'websocket connection to %s closed - retry in %ds', url, retryTime
)
} else {
this.log('websocket connection to %s closed', url)
}
})
}
/** Connect to the gateway.
*
* Try for two minutes to obtain an API key, when no API key is available.
* When the API key has been obtained, open the web socket, poll the
* gateway, and analyse the full state.
*/
async connect (retry = 0) {
if (!this.values.expose) {
this.warn('unlock gateway and set expose to obtain an API key')
return
}
try {
if (this.values.apiKey == null) {
this.values.apiKey =
await this.client.getApiKey('homebridge-deconz')
}
this.wsClient.listen()
this.service.values.restart = false
this.service.values.statusActive = true
this.checkApiKeys = true
for (const id in this.exposeErrorById) {
this.resetExposeError(id)
}
this.pollNext = true
this.pollFullState = true
} catch (error) {
if (
error instanceof ApiError && error.type === 101 && retry < 8
) {
this.log('unlock gateway to obtain API key - retrying in 15s')
await timeout(15000)
return this.connect(retry + 1)
}
this.error(error)
this.values.expose = false
}
}
/** Reset the gateway delegate.
*
* Delete the API key from the gateway.
* Close the web socket connection.
* Delete all accessories and services associated to devices exposed by
* the gateway.
*/
async reset () {
if (this.values.apiKey == null) {
return
}
try {
try {
await this.deleteMigration()
await this.client.deleteApiKey()
} catch (error) {}
this.values.apiKey = null
await this.wsClient.close()
for (const id in this.accessoryById) {
if (id !== this.id) {
this.deleteAccessory(id)
}
}
this.exposeErrors = {}
this.context.settingsById = {}
this.context.fullState = null
} catch (error) { this.error(error) }
}
// ===========================================================================
/** Blacklist or (re-)expose a gateway device.
*
* Delete the associated accessory. When blacklisted, add the associated
* device settings delegate to the Gateway accessory, otherwise (re-)add
* the associated accessory.
* @params {string} id - The device ID.
* @params {boolean} expose - Set to `false` to blacklist the device.
*/
exposeDevice (id, expose) {
if (id === this.id) {
throw new RangeError(`${id}: gateway ID`)
}
if (this.deviceById[id] == null) {
throw new RangeError(`${id}: unknown device ID`)
}
this.context.settingsById[id].expose = expose
this.pollNext = true
}
/** Re-expose an accessory.
*
* Delete the accessory delegate, but keep the HAP accessory, including
* the persisted context.
* The delegate will be re-created when the gateway is next polled.
* @params {string} id - The device ID.
* @params {boolean} expose - Set to `false` to blacklist the device.
*/
reExposeAccessory (id) {
if (id === this.id) {
throw new RangeError(`${id}: gateway ID`)
}
if (this.accessoryById[id] == null) {
throw new RangeError(`${id}: unknown accessory ID`)
}
this.deleteAccessory(id, true)
this.pollNext = true
}
/** On-demand import of a DeconzAccessory subclass.
* @params {string} type - The name of the class.
*/
async importAccessoryType (type) {
switch (type) {
case 'AirPurifier':
case 'Light':
case 'Sensor':
case 'Thermostat':
case 'WarningDevice':
case 'WindowCovering':
break
case 'Outlet':
case 'Switch':
type = 'Light'
break
default:
type = 'Sensor'
break
}
if (DeconzAccessory[type] == null) {
this.vdebug('importing DeconzAccessory.%s', type)
await import('../DeconzAccessory/' + type + '.js')
}
}
/** On-demand import of a a DeconzService subclass.
* @params {string} type - The name of the class.
*/
async importServiceType (type) {
if (DeconzService[type] == null) {
this.vdebug('importing DeconzService.%s', type)
await import('../DeconzService/' + type + '.js')
}
}
/** Add the accessory for the device.
* @params {string} id - The device ID.
* @return {?DeconzAccessory} - The accessory delegate.
*/
async addAccessory (id) {
if (id === this.id) {
throw new RangeError(`${id}: gateway ID`)
}
if (this.deviceById[id] == null) {
throw new RangeError(`${id}: unknown device ID`)
}
if (this.accessoryById[id] == null) {
const device = this.deviceById[id]
delete this.exposeErrorById[id]
const { body } = device.resource
this.log('%s: add accessory', body.name)
let { serviceName } = device.resource
await this.importAccessoryType(serviceName)
if (DeconzAccessory[serviceName] == null) {
// this.warn('%s: %s: accessory type not available', body.name, serviceName)
serviceName = 'Sensor'
}
if (this.context.settingsById[id]?.serviceName != null) {
await this.importServiceType(this.context.settingsById[id].serviceName)
}
for (const resourceServiceName of Object.keys(device.subtypesByServiceName)) {
await this.importServiceType(resourceServiceName)
}
if (device.hasBattery) {
await this.importServiceType('Battery')
}
const accessory = new DeconzAccessory[serviceName](this, device)
this.accessoryById[id] = accessory
this.monitorResources(accessory, true)
accessory.once('exposeError', (error) => {
accessory.warn(error)
this.exposeErrorById[id] = error
})
}
return this.accessoryById[id]
}
/** Delete the accessory delegate and associated HomeKit accessory.
* @params {string} id - The device ID.
* @params {boolean} [delegateOnly=false] - Delete the delegate, but keep the
* associated HomeKit accessory (including context).
*/
deleteAccessory (id, delegateOnly = false) {
if (id === this.id) {
throw new RangeError(`${id}: gateway ID`)
}
if (this.accessoryById[id] != null) {
this.monitorResources(this.accessoryById[id], false)
this.log(
'%s: delete accessory%s', this.accessoryById[id].name,
delegateOnly ? ' delegate' : ''
)
this.accessoryById[id].destroy(delegateOnly)
delete this.accessoryById[id]
if (this.exposeErrorById[id] != null) {
delete this.exposeErrorById[id]
} else if (!delegateOnly) {
const id = Object.keys(this.exposeErrorById)[0]
if (id != null) {
this.log(
'%s: resetting after expose error: %s', id, this.exposeErrorById[id]
)
this.deleteAccessory(id)
}
}
}
}
/** Enable / disable accessory events for resource.
* @param {DeconzAccessory.Device} accessory - The accessory delegate.
* @param {boolean} monitor - Enable or disable events.
*/
monitorResources (accessory, monitor = true) {
const { id, rpaths } = accessory
for (const rpath of rpaths) {
if (!monitor) {
accessory.debug('unsubscribe from %s', rpath)
delete this.accessoryByRpath[rpath]
} else if (this.accessoryByRpath[rpath] != null) {
accessory.warn(new Error('%s: already monitored by', rpath, id))
} else {
accessory.debug('subscribe to %s', rpath)
this.accessoryByRpath[rpath] = accessory
}
}
}
/** Reset expose error for device.
*
* Remove the un-exposed accessory, so it will be re-created on next poll.
* @params {string} id - The device ID.
*/
resetExposeError (id) {
this.log(
'%s: resetting after expose error: %s', id, this.exposeErrorById[id]
)
this.deleteAccessory(id)
}
/** Assert that migration resourcelink exists and is valid.
*/
async checkMigration () {
if (this.context.migration != null) {
try {
const response = await this.client.get(this.context.migration)
if (
response.name !== migration.name ||
response.description !== migration.description ||
response.classid !== migration.classid ||
response.owner !== this.client.apiKey
) {
// not my migration resourcelink
this.warn('%s: migration resourcelink no longer valid', this.context.migration)
this.context.migration = null
}
} catch (error) {
if (error.statusCode === 404) {
this.warn('%s: migration resourcelink no longer exists', this.context.migration)
this.context.migration = null
}
}
}
}
/** Create or update migration resourcelink.
*/
async updateMigration () {
await this.checkMigration()
if (this.context.migration == null) {
const response = await this.client.post('/resourcelinks', {
name: migration.name,
description: migration.description,
classid: migration.classid,
links: Object.keys(this.accessoryByRpath).sort()
})
this.context.migration = '/resourcelinks/' + response.success.id
} else {
await this.client.put(this.context.migration, {
links: Object.keys(this.accessoryByRpath).sort()
})
}
}
/** Delete migration resourcelink.
*/
async deleteMigration () {
await this.checkMigration()
if (this.context.migration != null) {
await this.client.delete(this.context.migration)
this.context.migration = null
}
}
// ===========================================================================
_deviceToMap (id, details = false) {
const device = this.deviceById[id]
if (device == null) {
return { status: 404 } // Not Found
}
const body = {
expose: details ? undefined : this.accessoryById[device.id] != null,
id: details ? device.id : undefined,
manufacturer: device.resource.manufacturer,
model: device.resource.model,
name: device.resource.body.name,
resources: device.rpaths,
settings: details
? {
expose: this.accessoryById[device.id] != null,
outlet: undefined, // expose as _Outlet_
switch: undefined, // expose as _Switch
valve: undefined // expose as _Valve_
}
: undefined,
type: device.resource.rtype,
zigbee: device.zigbee
}
return { status: 200, body }
}
async onUiGet (path) {
this.debug('ui request: GET %s', path.join('/'))
if (path.length === 0) {
const body = {
host: this.values.host,
id: this.id,
manufacturer: this.values.manufacturer,
model: this.values.model,
name: this.name,
settings: this.values.apiKey == null
? {
autoExpose: this.values.autoExpose,
expose: this.values.expose,
logLevel: this.values.logLevel
}
: {
autoExpose: this.values.autoExpose,
brightnessAdjustment: this.values.brightnessAdjustment * 100,
expose: this.values.expose,
exposeSchedules: this.values.exposeSchedules,
heartrate: this.values.heartrate,
logLevel: this.values.logLevel,
periodicEvents: this.values.periodicEvents,
restart: this.values.restart,
search: this.values.search,
unlock: this.values.unlock
}
}
return { status: 200, body }
}
if (path[0] === 'accessories') {
if (path.length === 1) {
const body = {}
for (const id of Object.keys(this.accessoryById).sort()) {
body[id] = this.accessoryById[id].onUiGet().body
}
return { status: 200, body }
}
if (path.length === 2) {
const id = path[1].replace(/:/g, '').toUpperCase()
if (this.accessoryById[id] == null) {
return { status: 404 } // Not Found
}
return this.accessoryById[id].onUiGet(true)
}
}
if (path[0] === 'devices') {
if (path.length === 1) {
const body = {}
for (const id of Object.keys(this.deviceById).sort()) {
body[id] = this._deviceToMap(id).body
}
return { status: 200, body }
}
if (path.length === 2) {
return this._deviceToMap(path[1].replace(/:/g, '').toUpperCase(), true)
}
}
return { status: 403 } // Forbidden
}
async onUiPut (path, body) {
this.debug('ui request: PUT %s %j', path.join('/'), body)
if (path.length === 0) {
return { status: 405 } // Method Not Allowed
}
if (path[0] === 'settings') {
const settings = {}
const optionParser = new OptionParser(settings, true)
optionParser
.on('userInputError', (error) => {
this.warn(error)
})
.boolKey('autoExpose')
.boolKey('expose')
.intKey('logLevel', 0, 3)
if (this.values.apiKey != null) {
optionParser
.intKey('brightnessAdjustment', 10, 100)
.boolKey('exposeSchedules')
.intKey('heartrate', 1, 60)
.boolKey('periodicEvents')
.boolKey('restart')
.boolKey('search')
.boolKey('unlock')
}
optionParser.parse(body)
const responseBody = {}
for (const key in settings) {
switch (key) {
case 'brightnessAdjustment':
this.values[key] = settings[key] / 100
responseBody[key] = this.values[key]
break
case 'autoExpose':
case 'expose':
case 'exposeSchedules':
case 'heartrate':
case 'logLevel':
case 'periodicEvents':
case 'restart':
case 'search':
case 'unlock':
this.values[key] = settings[key]
responseBody[key] = this.values[key]
break
default:
break
}
}
return { status: 200, body: responseBody }
}
if (path[0] === 'accessories') {
if (path.length < 3) {
return { status: 405 } // Method Not Allowed
}
if (path.length === 3 && path[2] === 'settings') {
const id = path[1].replace(/:/g, '').toUpperCase()
if (this.accessoryById[id] == null) {
return { status: 404 } // Not Found
}
return this.accessoryById[id].onUiPut(body)
}
}
if (path[0] === 'devices') {
if (path.length < 3) {
return { status: 405 } // Method Not Allowed
}
if (path.length === 3 && path[2] === 'settings') {
const id = path[1].replace(/:/g, '').toUpperCase()
if (this.deviceById[id] == null) {
return { status: 404 } // Not Found
}
if (body.expose != null) {
this.exposeDevice(id, body.expose)
return { status: 200, body: { expose: body.expose } }
}
return { status: 200 }
}
}
return { status: 403 } // Forbidden
}
// ===========================================================================
/** Poll the gateway.
*
* Periodically get the gateway full state and call
* {@link DeconzAccessory.Gateway#analyseFullState()}.<br>
*/
async poll () {
if (this.polling || this.values.apiKey == null) {
return
}
try {
this.polling = true
this.vdebug('%spolling...', this.pollNext ? 'priority ' : '')
if (this.context.fullState == null || this.pollFullState) {
const fullState = await this.client.get('/')
try {
fullState.groups[0] = await this.client.get('/groups/0')
} catch (error) {}
try {
fullState.alarmsystems = await this.client.get('/alarmsystems')
} catch (error) {
fullState.alarmsystems = {}
}
// FIX_ME: use introspect until buttons and events are reported as capability
fullState.introspectByRid = {}
for (const rid in fullState.sensors) {
const sensor = fullState.sensors[rid]
if (sensor.type === 'ZHASwitch') {
try {
fullState.introspectByRid[rid] = await this.client.get(
'/devices/' + sensor.uniqueid + '/state/buttonevent/introspect'
)
} catch (error) { }
}
}
// End FIX_ME
this.context.fullState = fullState
this.pollFullState = false
await this.analyseFullState(this.context.fullState, { logUnsupported: true })
} else {
const config = await this.client.get('/config')
if (config.bridgeid === this.id && config.UTC == null) {
this.values.expose = false
this.values.apiKey = null
await this.wsClient.close()
return
}
if (config.bridgeid === '0000000000000000' || config.fwversion === '0x00000000') {
this.warn('deCONZ not ready')
return
}
this.context.fullState.config = config
this.context.fullState.lights = await this.client.get('/lights')
this.context.fullState.sensors = await this.client.get('/sensors')
this.context.fullState.resourcelinks = await this.client.get('/resourcelinks')
if (this.nDevicesByRtype.groups > 0) {
this.context.fullState.groups = await this.client.get('/groups')
try {
this.context.fullState.groups[0] = await this.client.get('/groups/0')
} catch (error) {}
}
if (this.nDevicesByRtype.alarmsystems > 0) {
this.context.fullState.alarmsystems = await this.client.get('/alarmsystems')
}
if (this.values.exposeSchedules) {
this.context.fullState.schedules = await this.client.get('/schedules')
}
await this.analyseFullState(this.context.fullState)
}
} catch (error) {
this.error('poll error: %s', error)
} finally {
this.vdebug('polling done')
this.pollNext = false
this.polling = false
}
if (!this.initialised) {
this.initialised = true
this.debug('initialised')
this.emit('initialised')
}
}
/* Analyse blacklist resourcelinks.
*/
analyseResourcelinks (logUnsupported = false) {
const warn = (logUnsupported ? this.warn : this.vdebug).bind(this)
/** Blacklisted resources.
*
* Updated by
* {@link DeconzAccessory.Gateway#analyseBlacklist analyseBlacklist()}.
* @type {Object<string, boolean>}
*/
this.blacklist = {
lights: {},
sensors: {}
}
this.splitdevice = {
lights: {},
sensors: {}
}
for (const key in this.context.fullState.resourcelinks) {
const link = this.context.fullState.resourcelinks[key]
if (
link.name === 'homebridge-deconz' && link.links != null &&
link.description != null
) {
const type = link.description.toLowerCase()
switch (type) {
case 'migration':
break
case 'splitdevice':
case 'blacklist':
this.debug('/resourcelinks/%d: %d %s entries', key, link.links.length, type)
for (const resource of link.links) {
const rtype = resource.split('/')[1]
const rid = resource.split('/')[2]
if (this[type][rtype] == null) {
warn('/resourcelinks/%d: %s: ignoring unsupported %s resource', key, resource, type)
continue
}
this[type][rtype][rid] = true
}
break
default:
warn('/resourcelinks/%d: %s: ignoring unsupported resourcelink', key, type)
}
}
}
}
/** Analyse the peristed full state of the gateway,
* adding, re-configuring, and deleting delegates for corresponding HomeKit
* accessories and services.
*
* The analysis consists of the following steps:
* 1. Analyse the resources, updating:
* {@link DeconzAccessory.Gateway#deviceById deviceById},
* {@link DeconzAccessory.Gateway#deviceByRidByRtype deviceByRidByRtype},
* {@link DeconzAccessory.Gateway#nDevices nDevices},
* {@link DeconzAccessory.Gateway#nDevicesByRtype nDevicesByRtype},
* {@link DeconzAccessory.Gateway#nResources nResources},
* {@link DeconzAccessory.Gateway#resourceByRpath resourceByRpath}.
* 2. Analyse (pre-existing) _Device_ accessories, emitting
* {@link DeconzAccessory.Device#event.polled}, and calling
* {@link DeconzAccessory.Gateway#deleteAccessory deleteAccessory()} for
* stale accessories, corresponding to devices that have been deleted from
* the gateway, blacklisted, or excluded by device primary resource type.
* 3. Analysing supported devices with enabled device primary resource types,
* calling {@link DeconzAccessory.Gateway#addAccessory addAccessory()} for new
* _Device_ accessories, corresponding to devices added to the gateway,
* un-blacklisted, or included by device primary resource type, and calling
* {@link DeconzAccessory.Gateway#deleteAccessory deleteAccessory()} for
* accessories, corresponding to devices have been blacklisted.
* @param {Object} fullState - The gateway full state, as returned by
* {@link DeconzAccessory.Gateway#poll poll()}.
* @param {Object} params - Parameters
* @param {boolean} [params.logUnsupported=false] - Issue debug
* messsages for unsupported resources.
* @param {boolean} [params.analyseOnly=false]
*/
async analyseFullState (fullState, params = {}) {
/** Supported devices by device ID.
*
* Updated by
* {@link DeconzAccessory.Gateway#analyseFullState analyseFullState()}.
* @type {Object<string, Deconz.Device>}
*/
this.deviceById = {}
/** Supported resources by resource path.
*
* Updated by {@link DeconzAccessory.Gateway#analyseFullState analyseFullState()}.
* @type {Object<string, Deconz.Resource>}
*/
this.resourceByRpath = {}
/** Supported devices by resource ID by resource type, of the primary
* resource for the device.
*
* Updated by
* {@link DeconzAccessory.Gateway#analyseFullState analyseFullState()}.
* @type {Object<string, Object<string, Deconz.Device>>}
*/
this.deviceByRidByRtype = {}
/** Number of supported devices by resource type.
*
* Updated by
* {@link DeconzAccessory.Gateway#analyseFullState analyseFullState()}.
* @type {Object<string, integer>}
*/
this.nDevicesByRtype = {}
this.vdebug('analysing resources...')
this.analyseResourcelinks(params.logUnsupported)
for (const rtype of rtypes) {
this.deviceByRidByRtype[rtype] = {}
for (const rid in fullState[rtype]) {
try {
const body = fullState[rtype][rid]
this.analyseResource(rtype, rid, body, params.logUnsupported)
} catch (error) { this.error(error) }
}
}
/** Number of supported devices.
*
* Updated by
* {@link DeconzAccessory.Gateway#analyseFullState analyseFullState()}.
* @type {integer}
*/
this.nDevices = Object.keys(this.deviceById).length
/** Number of supported resources.
*
* Updated by
* {@link DeconzAccessory.Gateway#analyseFullState analyseFullState()}.
* @type {integer}
*/
this.nResources = Object.keys(this.resourceByRpath).length
this.vdebug('%d devices, %d resources', this.nDevices, this.nResources)
for (const id in this.deviceById) {
const device = this.deviceById[id]
const { rtype, rid } = device.resource
this.deviceByRidByRtype[rtype][rid] = device
}
for (const rtype of rtypes) {
this.nDevicesByRtype[rtype] =
Object.keys(this.deviceByRidByRtype[rtype]).length
this.vdebug('%d %s devices', this.nDevicesByRtype[rtype], rtype)
}
if (params.analyseOnly) {
return
}
this.update(fullState.config)
let changed = false
this.vdebug('analysing accessories...')
for (const id in this.accessoryById) {
try {
if (
this.deviceById[id] == null
) {
delete this.context.settingsById[id]
this.deleteAccessory(id)
changed = true
} else {
/** Emitted when the gateway has been polled.
* @event DeconzAccessory.Device#polled
* @param {Deconz.Device} device - The updated device.
*/
this.accessoryById[id].emit('polled', this.deviceById[id])
}
} catch (error) { this.error(error) }
}
for (const rtype of rtypes) {
this.vdebug('analysing %s devices...', rtype)
const rids = Object.keys(this.deviceByRidByRtype[rtype]).sort()
for (const rid of rids) {
try {
const { id, resource, zigbee } = this.deviceByRidByRtype[rtype][rid]
if (this.context.settingsById[id] == null) {
this.context.settingsById[id] = { expose: zigbee && this.values.autoExpose }
}
if (this.context.settingsById[id].expose) {
if (this.accessoryById[id] == null) {
const name = resource.body.name
if (zigbee && resource.body.type !== 'ZGPSwitch') {
const mac = resource.body.uniqueid.split('-')[0]
try {
const ddf = await this.client.get('/devices/' + mac + '/ddf')
if (ddf.status === 'Draft') {
this.warn('%s: exposed by legacy code', name)
} else if (ddf.status !== 'Gold') {
this.warn('%s: exposed by %s ddf', name, ddf.status.toLowerCase())
} else {
this.debug('%s: exposed by %s ddf', name, ddf.status.toLowerCase())
}
} catch (error) { }
} else {
this.debug('%s: exposed by legacy code', name)
}
await this.addAccessory(id)
changed = true
}
} else {
if (this.accessoryById[id] != null) {
this.deleteAccessory(id)
changed = true
}
}
} catch (error) { this.error(error) }
}
}
this.nAccessories = Object.keys(this.accessoryById).length
this.nResourcesMonitored = Object.keys(this.accessoryByRpath).length
this.nExposeErrors = Object.keys(this.exposeErrorById).length
if (this.nExposeErrors === 0) {
this.vdebug('%d accessories', this.nAccessories)
} else {
this.vdebug(
'%d accessories, %d expose errors', this.nAccessories, this.nExposeErrors
)
}
this.vdebug('analysing schedules...')
if (this.values.exposeSchedules) {
if (DeconzService.Schedule == null) {
await import('../DeconzService/Schedule.js')
}
for (const rid in fullState.schedules) {
if (this.scheduleServicesByRid[rid] == null) {
this.scheduleServicesByRid[rid] = new DeconzService.Schedule(
this, rid, fullState.schedules[rid]
)
}
this.scheduleServicesByRid[rid].update(fullState.schedules[rid])
}
}
for (const rid in this.scheduleServicesByRid) {
if (!this.values.exposeSchedules || fullState.schedules[rid] == null) {
this.scheduleServicesByRid[rid].destroy()
delete this.scheduleServicesByRid[rid]
}
}
if (changed) {
await this.updateMigration()
this.identify()
}
}
/** Anayse a gateway resource, updating
* {@link DeconzAccessory.Gateway#deviceById deviceById} and
* {@link DeconzAccessory.Gateway#resourceByRpath resourceByRpath} for
* supported resources.
*
* @param {string} rtype - The type of the resource:
* `groups`, `lights`, or `sensors`.
* @param {integer} rid - The resource ID of the resource.
* @param {object} body - The body of the resource.
* @param {boolean} logUnsupported - Issue a debug message for
* unsupported resources.
*/
analyseResource (rtype, rid, body, logUnsupported) {
const warn = (logUnsupported ? this.warn : this.vdebug).bind(this)
const debug = (logUnsupported ? this.debug : this.vdebug).bind(this)
// FIX_ME: use introspect until buttons and events are reported as capability
if (this.context.fullState.introspectByRid?.[rid] != null) {
body.introspect = this.context.fullState.introspectByRid[rid]
}
// End FIX_ME
const resource = new Deconz.Resource(this, rtype, rid, body)
const { id, serviceName } = resource
// FIX_ME: check introspect against whitelist
if (logUnsupported && resource.body.type === 'ZHASwitch') {
if (!resource.capabilities._introspect) {
this.warn(
'%s: /sensors/%d: %s by %s: no introspect',
id, rid, resource.model, resource.manufacturer
)
}
} else if (resource.capabilities._buttons != null) {
if (
JSON.stringify(resource.capabilities._buttons) !==
JSON.stringify(resource.capabilities.buttons) ||
resource.capabilities._namespace !== resource.capabilities.namespace
) {
this.debug(
'%s: /sensors/%d: %s by %s: whitelist vs introspect mismatch: %j',
id, rid, resource.model, resource.manufacturer, resource.capabilities
)
} else {
this.warn(
'%s: /sensors/%d: %s by %s: whitelist matches introspect',
id, rid, resource.model, resource.manufacturer
)
}
}
// End FIX_ME
if (this.blacklist[rtype]?.[rid]) {
debug('%s: /%s/%d: ignoring blacklisted resource', id, rtype, rid)
return
}
if (id === this.id || serviceName === '') {
debug(
'%s: /%s/%d: %s: ignoring unsupported %s type',
id, rtype, rid, body.type, rtype
)
return
}
if (serviceName == null) {
warn(
'%s: /%s/%d: %s: ignoring unknown %s type',
id, rtype, rid, body.type, rtype
)
return
}
if (this.deviceById[id] == null) {
this.deviceById[id] = new Deconz.Device(resource)
this.vdebug('%s: device', id)
} else {
this.deviceById[id].addResource(resource)
}
const { rpath } = resource
this.resourceByRpath[rpath] = resource
this.vdebug('%s: %s: device resource', id, rpath)
}
}
DeconzAccessory.Gateway = Gateway