UNPKG

homebridge-deconz

Version:
350 lines (329 loc) 11.5 kB
// homebridge-deconz/lib/DeconzAccessory/index.js // Copyright © 2022-2026 Erik Baauw. All rights reserved. // // Homebridge plugin for deCONZ. import { AccessoryDelegate } from 'homebridge-lib/AccessoryDelegate' import { OptionParser } from 'homebridge-lib/OptionParser' import { ApiClient } from 'hb-deconz-tools/ApiClient' import { DeconzService } from '../DeconzService/index.js' import '../DeconzService/Button.js' const { HttpError } = ApiClient const { SINGLE, DOUBLE, LONG } = DeconzService.Button /** Abstract superclass for a delegate of a HomeKit accessory, * corresponding to a Zigbee or virtual device on a deCONZ gateway. * @extends AccessoryDelegate */ class DeconzAccessory extends AccessoryDelegate { /** Instantiate a delegate for an accessory corresponding to a device. * @param {DeconzAccessory.Gateway} gateway - The gateway. * @param {Deconz.Device} device - The device. * @param {Accessory.Category} category - The HomeKit accessory category. */ constructor (gateway, device, category) { super(gateway.platform, { id: device.id, name: device.resource.body.name, manufacturer: device.resource.manufacturer, model: device.resource.model, firmware: device.resource.firmware, category, logLevel: gateway.logLevel }) this.context.gid = gateway.id this.serviceByRpath = {} this.serviceBySubtype = {} this.servicesByServiceName = {} /** The gateway. * @type {DeconzAccessory.Gateway} */ this.gateway = gateway /** The accessory ID. * * This is the {@link Deconz.Device#id id} of the corresponding device. * @type {string} */ this.id = device.id /** The corresponding device. * @type {Deconz.Device} */ this.device = device /** The API client instance for the gateway. * @type {ApiClient} */ this.client = gateway.client this .on('polled', (device) => { let reExpose = false this.values.firmware = device.resource.firmware for (const subtype in device.resourceBySubtype) { const resource = device.resourceBySubtype[subtype] this.debug('%s: polled: %j', resource.rpath, resource.body) const service = this.serviceBySubtype[subtype] if (service == null) { this.log('%s: new resource: %j', resource.rpath, resource.body) reExpose = true } else { service.update(resource.body, resource.rpath) } } for (const subtype in this.serviceBySubtype) { const service = this.serviceBySubtype[subtype] const resource = device.resourceBySubtype[subtype] if (resource == null) { this.log('%s: resource deleted', service.rpath) reExpose = true } } if (reExpose) { this.gateway.reExposeAccessory(this.id) } }) .on('changed', (rpath, body) => { this.debug('%s: changed: %j', rpath, body) const service = this.serviceByRpath[rpath] if (service != null) { service.update(body, rpath) } }) .on('identify', async () => { try { await this.identify() } catch (error) { if (!(error instanceof HttpError)) { this.warn(error) } } }) } /** The primary resource of the device. * @type {Deconz.Resource} */ get resource () { return this.device.resource } /** List of resource paths of associated resources in order of prio. * @type {string[]} */ get rpaths () { return this.device.rpaths } async identify () { this.log( '%s %s v%s (%d resources)', this.values.manufacturer, this.values.model, this.values.firmware, this.rpaths.length ) this.debug('%d resources: %s', this.rpaths.length, this.rpaths.join(', ')) this.vdebug('device: %j', this.device) if (this.service != null) { await this.service.identify() } } createService (resource, params = {}) { if (resource == null) { return } if (params.serviceName == null) { params.serviceName = resource.serviceName } if (DeconzService[params.serviceName] == null) { this.warn( '%s: %s: service type not available', resource.rpath, params.serviceName ) return } this.debug( '%s: capabilities: %j', resource.rpath, resource.capabilities ) this.debug('%s: params: %j', resource.rpath, params) let service if (params.serviceName === 'AirQuality') { service = this.servicesByServiceName.AirQuality?.[0] if (service != null) { service.addResource(resource) } } else if (params.serviceName === 'Battery') { service = this.servicesByServiceName.Battery?.[0] } else if (params.serviceName === 'Consumption') { if (this.servicesByServiceName.Consumption?.[0] != null) { this.warn( '%s: ignoring additional ZHAConsumption resource - consider splitting the accessory', resource.rpath ) return } service = this.servicesByServiceName.Power?.[0] if (service != null) { service.addResource(resource) } } else if (params.serviceName === 'Power') { if (this.servicesByServiceName.Power?.[0] != null) { this.warn( '%s: ignoring additional ZHAPower resource - consider splitting the accessory', resource.rpath ) return } service = this.servicesByServiceName.Consumption?.[0] if (service != null) { service.addResource(resource) } } else if (params.serviceName === 'Label') { service = this.servicesByServiceName.Label?.[0] // Default button if (resource.capabilities.buttons == null) { if (service == null) { this.warn( '%s: unknown %s: %j', resource.rpath, resource.body.type, resource.body ) resource.capabilities.buttons = { 1: { label: 'Unknown Button', events: SINGLE | DOUBLE | LONG } } resource.capabilities.namespace = this.Characteristics.hap.ServiceLabelNamespace.ARABIC_NUMERALS } else { resource.capabilities.buttons = {} } } } if (service == null) { service = new DeconzService[params.serviceName](this, resource, { primaryService: params.primaryService }) } if (this.servicesByServiceName[params.serviceName] == null) { this.servicesByServiceName[params.serviceName] = [service] } else { this.servicesByServiceName[params.serviceName].push(service) } if (params.serviceName === 'Label') { service.createButtonServices(resource, params) } this.serviceBySubtype[resource.subtype] = service this.serviceByRpath[resource.rpath] = service if (resource.body.config?.battery !== undefined) { if (this.servicesByServiceName.Battery?.[0] == null) { this.servicesByServiceName.Battery = [new DeconzService.Battery(this, resource)] } service.batteryService = this.servicesByServiceName.Battery[0] } return service } onUiGet (details = false) { const resource = this.device.resourceBySubtype[this.device.primary] const body = { id: details ? this.id : undefined, manufacturer: this.values.manufacturer, model: this.values.model, name: this.name, resources: this.device.rpaths, settings: details ? { anyOn: this.device.resource.rtype === 'groups' ? this.values.anyOn : undefined, buttonRepeat: undefined, // map per button expose: true, exposeEffects: this.service.values.exposeEffects, exposeScenes: this.service.values.exposeScenes, multiClip: undefined, multiLight: undefined, logLevel: this.values.logLevel, lowBatteryThreshold: this.servicesByServiceName?.Battery?.[0].values.lowBatteryThreshold, // offset: this.servicesByServiceName?.Temperature?.[0].values.offset, pin: this.service.values.pin, serviceName: this.values.serviceName, venetianBlind: this.service.values.venetianBlind, useExternalTemperature: this.service.values.useExternalTemperature, wallSwitch: this.service.values.wallSwitch } : undefined, type: resource.rtype, zigbee: this.device.zigbee } return { status: 200, body } } onUiPut (body) { let reExpose = false const responseBody = {} for (const key in body) { try { let value switch (key) { case 'expose': value = OptionParser.toBool(key, body[key]) if (value) { reExpose = true } else { this.gateway.exposeDevice(this.id, value) } responseBody[key] = value continue // Settings for the primary service. case 'anyOn': case 'exposeEffects': case 'exposeScenes': case 'venetianBlind': if (this.service.values[key] != null) { value = OptionParser.toBool(key, body[key]) this.service.values[key] = value reExpose = true responseBody[key] = value continue } break case 'logLevel': value = OptionParser.toInt(key, body[key], 0, 3) this.values[key] = value responseBody[key] = value continue case 'lowBatteryThreshold': if (this.servicesByServiceName.Battery?.[0] != null) { value = OptionParser.toInt(key, body[key], 10, 100) this.servicesByServiceName.Battery[0].values[key] = value responseBody[key] = value continue } break case 'pin': if (this.service.values[key] != null) { value = OptionParser.toString(key, body[key]) this.service.values[key] = value responseBody[key] = value continue } break case 'serviceName': if (this.values.serviceName != null) { value = OptionParser.toString(key, body[key]) if (['Light', 'Outlet', 'Switch', 'Valve'].includes(value) == null) { throw new Error(`${value}: illegal serviceName`) } this.values.serviceName = value reExpose = true responseBody[key] = value continue } break case 'useExternalTemperature': case 'wallSwitch': if (this.service.values[key] != null) { value = OptionParser.toBool(key, body[key]) this.service.values[key] = value responseBody[key] = value continue } break default: break } this.warn('ui error: %s: invalid key', key) } catch (error) { this.warn('ui error: %s', error) } } if (reExpose) { this.gateway.reExposeAccessory(this.id) } return { status: 200, body: responseBody } } } export { DeconzAccessory }