diff --git a/README.md b/README.md index f6d1ba6..d162ba4 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,9 @@ By configuring a `powerTopic` it is possible to combine devices to a singe HomeK # Zigbee2MQTT -It is also possible to controll devices using Zigbee2MQTT gateway/bridge. This is useful if you want to combine Zigbee and Tasmota devices using the `powerTopic`. Currently only `Switch` and `Lightbulb` accessories are supported. Supported features are queried directly from Zigbe2MQTT and configured automatically. +It is also possible to controll devices using Zigbee2MQTT gateway/bridge. This is useful if you want to combine Zigbee and Tasmota devices using the `powerTopic`. + +Almost all accessory-types are supported but currently some characteristics are not mapped correctly (work in progress). Supported features are queried directly from Zigbe2MQTT and configured automatically. # Binding with Zigbee2Tasmota diff --git a/src/mqttClient.ts b/src/mqttClient.ts index 07c12ea..3d5a1d9 100644 --- a/src/mqttClient.ts +++ b/src/mqttClient.ts @@ -22,6 +22,8 @@ type DeviceHandler = { callback: DeviceCallback; }; +export const DEFALT_TIMEOUT = 5000; + export class MQTTClient { private topicHandlers: Array = []; @@ -179,25 +181,26 @@ export class MQTTClient { this.log.debug('MQTT: Published: %s %s', topic, message); } - submit(topic: string, message: string, responseTopic = topic, timeOut = 2000): Promise { - return new Promise((resolve: (message) => void, reject) => { - const startTS = Date.now(); - - const handlerId = this.subscribeTopic(responseTopic, msg => { + read(topic: string, timeout?: number, messageDump?: boolean): Promise { + return new Promise((resolve: (message: string) => void, reject) => { + const start = Date.now(); + const handlerId = this.subscribeTopic(topic, message => { clearTimeout(timer); - resolve(msg); - }, true); - + resolve(message); + }, messageDump === undefined ? true : messageDump, true); const timer = setTimeout(() => { - this.log.error('MQTT: Submit: timeout after %sms %s', - Date.now() - startTS, message); if (handlerId !== undefined) { this.unsubscribe(handlerId); } - reject('MQTT: Submit timeout'); - }, timeOut); - this.publish(topic, message); + const elapsed = Date.now() - start; + reject(`MQTT: Read timeout after ${elapsed}ms`); + }, timeout === undefined ? DEFALT_TIMEOUT : timeout); }); } + + submit(topic: string, message: string, responseTopic = topic, timeout?: number, messageDump?: boolean): Promise { + this.publish(topic, message); + return this.read(responseTopic, timeout, messageDump); + } } diff --git a/src/platform.ts b/src/platform.ts index 931c915..edb885f 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -7,7 +7,7 @@ import { ZbBridgeDevice } from './zbBridgeAccessory'; import { ZbBridgeLightbulb } from './zbBridgeLightbulb'; import { ZbBridgeSwitch } from './zbBridgeSwitch'; import { ZbBridgeSensor } from './zbBridgeSensor'; -import { Zigbee2MQTTAcessory, Z2MDevice, Zigbee2MQTTDevice } from './zigbee2MQTTAcessory'; +import { Zigbee2MQTTAcessory, Zigbee2MQTTDevice } from './zigbee2MQTTAcessory'; export class TasmotaZbBridgePlatform implements DynamicPlatformPlugin { public readonly Service: typeof Service = this.api.hap.Service; @@ -15,8 +15,6 @@ export class TasmotaZbBridgePlatform implements DynamicPlatformPlugin { public readonly mqttClient = new MQTTClient(this.log, this.config); // cached accessories public readonly accessories: PlatformAccessory[] = []; - // zigbee2mqtt devices - public zigbee2mqttDevices: Z2MDevice[] = []; constructor( public readonly log: Logger, @@ -29,15 +27,7 @@ export class TasmotaZbBridgePlatform implements DynamicPlatformPlugin { log.debug('Executed didFinishLaunching callback'); this.cleanupCachedDevices(); if (Array.isArray(this.config.zigbee2mqttDevices) && this.config.zigbee2mqttDevices.length > 0) { - if (config.zigbee2mqttTopic === undefined) { - config.zigbee2mqttTopic = 'zigbee2mqtt'; - } - this.mqttClient.subscribeTopic(config.zigbee2mqttTopic + '/bridge/devices', message => { - const devices: Z2MDevice[] = JSON.parse(message); - this.zigbee2mqttDevices = devices; - this.log.info('Found %s zigbee2mqtt devices', devices.length); - this.discoverZigbee2MQTTDevices(); - }, false, true); + this.discoverZigbee2MQTTDevices(); } if (Array.isArray(this.config.zbBridgeDevices) && this.config.zbBridgeDevices.length > 0) { this.discoverZbBridgeDevices(); @@ -63,9 +53,8 @@ export class TasmotaZbBridgePlatform implements DynamicPlatformPlugin { } zigbee2MQTTDeviceUUID(device: Zigbee2MQTTDevice): string { - const identificator = device.ieee_address + - (device.powerTopic || '') + - (device.powerType || ''); + const identificator = 'z2m' + device.ieee_address + + (device.powerTopic || ''); return this.api.hap.uuid.generate(identificator); } @@ -92,16 +81,6 @@ export class TasmotaZbBridgePlatform implements DynamicPlatformPlugin { } } - createZigbee2MQTTAcessory(accessory: PlatformAccessory) { - const device = this.zigbee2mqttDevices.find(d => d.ieee_address === accessory.context.device.addr); - if (device !== undefined) { - const serviceName = Zigbee2MQTTAcessory.getServiceName(device); - if (serviceName !== undefined) { - new Zigbee2MQTTAcessory(this, accessory, serviceName); - } - } - } - restoreAccessory(uuid: string, name: string): { restored: boolean; accessory: PlatformAccessory } { const existingAccessory = this.accessories.find(accessory => accessory.UUID === uuid); if (existingAccessory) { @@ -129,26 +108,39 @@ export class TasmotaZbBridgePlatform implements DynamicPlatformPlugin { } } - discoverZigbee2MQTTDevices() { - for (const device of this.config.zigbee2mqttDevices) { - if ((device)?.ieee_address && (device)?.name) { - const z2mDevice = this.zigbee2mqttDevices.find(d => d.ieee_address === device.ieee_address); - if (z2mDevice !== undefined) { - const serviceName = Zigbee2MQTTAcessory.getServiceName(z2mDevice); - if (serviceName !== undefined) { - const { restored, accessory } = this.restoreAccessory(this.zigbee2MQTTDeviceUUID(device), device.name); + async discoverZigbee2MQTTDevices() { + if (this.config.zigbee2mqttTopic === undefined) { + this.config.zigbee2mqttTopic = 'zigbee2mqtt'; + } + try { + const bridgeDevicesTopic = this.config.zigbee2mqttTopic + '/bridge/devices'; + const message = await this.mqttClient.read(bridgeDevicesTopic, undefined, false); + const z2m_devices: Zigbee2MQTTDevice[] = JSON.parse(message); + if (!Array.isArray(z2m_devices)) { + throw (`topic (${bridgeDevicesTopic}) parse error`); + } + this.log.info('Found %s Zigbee2MQTT devices', z2m_devices.length); + + for (const configured of this.config.zigbee2mqttDevices) { + if (configured.ieee_address && configured.name) { + const device = z2m_devices.find(d => d.ieee_address === configured.ieee_address); + if (device !== undefined) { + device.homekit_name = configured.name; + if (configured.powerTopic !== undefined) { + device.powerTopic = configured.powerTopic + '/' + (configured.powerType || 'POWER'); + } + const { restored, accessory } = this.restoreAccessory(this.zigbee2MQTTDeviceUUID(device), configured.name); accessory.context.device = device; - new Zigbee2MQTTAcessory(this, accessory, serviceName); - this.log.info('%s Zigbee2MQTTAcessory accessory: %s (%s) - %s', - restored ? 'Restoring' : 'Adding', device.name, device.ieee_address, serviceName); - } else { - this.log.error('Ignored unsupported Zigbee2MQTT device %s (%s)', device.name, device.ieee_address); + new Zigbee2MQTTAcessory(this, accessory); + this.log.info('%s Zigbee2MQTTAcessory accessory: %s (%s)', + restored ? 'Restoring' : 'Adding', configured.name, configured.ieee_address); } + } else { + this.log.error('Ignored invalid Zigbee2MQTT configuration: %s', JSON.stringify(configured)); } - } else { - this.log.error('Ignored Zigbee2MQTT device configuration: ', JSON.stringify(device)); - continue; } + } catch (err) { + this.log.error(`Zigbee2MQTT devices initialization failed: ${err}`); } } diff --git a/src/zigbee2MQTTAcessory.ts b/src/zigbee2MQTTAcessory.ts index 556f89b..f2969ca 100644 --- a/src/zigbee2MQTTAcessory.ts +++ b/src/zigbee2MQTTAcessory.ts @@ -7,13 +7,69 @@ import { import { TasmotaZbBridgePlatform } from './platform'; import { Zigbee2MQTTCharacteristic } from './zigbee2MQTTCharacteristic'; -const FEATURES = [ - { service: 'Lightbulb', features: ['brightness', 'color_temp', 'color_xy', 'color_hs'] }, - { service: 'Switch', features: ['state'] }, -]; +const EXPOSES = { + // Specific exposes + light: { + service: 'Lightbulb', features: { + state: 'On', + brightness: 'Brightness', + color_temp: 'ColorTemperature', + color_hs: { features: { hue: 'Hue', saturation: 'Saturation' } }, + }, + }, + switch: { + service: 'Switch', features: { + state: 'On', + }, + }, + fan: { + service: 'Fan', features: { + state: 'On', + mode: 'RotationSpeed', + }, + }, + cover: { + service: 'WindowCovering', features: { + state: 'PositionState', + position: 'CurrentPosition', + tilt: 'CurrentHorizontalTiltAngle', + }, + }, + lock: { + service: 'LockMechanism', features: { + state: 'LockTargetState', + lock_state: 'LockCurrentState', + }, + }, + climate: { + service: 'Thermostat', + features: { + local_temperature: 'CurrentTemperature', + current_heating_setpoint: 'TargetTemperature', + occupied_heating_setpoint: 'TargetTemperature', + system_mode: 'TargetHeatingCoolingState', + running_state: 'CurrentHeatingCoolingState', + }, + }, + // Generic exposes + battery: { service: 'Battery', characteristic: 'BatteryLevel' }, + battery_low: { service: 'Battery', characteristic: 'StatusLowBattery' }, + temperature: { service: 'TemperatureSensor', characteristic: 'CurrentTemperature' }, + humidity: { service: 'HumiditySensor', characteristic: 'CurrentRelativeHumidity' }, + illuminance_lux: { service: 'LightSensor', characteristic: 'CurrentAmbientLightLevel' }, + contact: { service: 'ContactSensor', characteristic: 'ContactSensorState' }, + occupancy: { service: 'OccupancySensor', characteristic: 'OccupancyDetected' }, + vibration: { service: 'MotionSensor', characteristic: 'MotionDetected' }, + smoke: { service: 'SmokeSensor', characteristic: 'SmokeDetected' }, + carbon_monoxide: { service: 'CarbonMonoxideSensor', characteristic: 'CarbonMonoxideDetected' }, + water_leak: { service: 'LeakSensor', characteristic: 'LeakDetected' }, + gas: { service: 'LeakSensor', characteristic: 'LeakDetected' }, +}; -export type Z2MExposeFeature = { - name: string; +export type Z2MExpose = { + type?: string; + name?: string; + features?: Z2MExpose[]; endpoint?: string; property: string; value_max?: number; @@ -26,21 +82,6 @@ export type Z2MExposeFeature = { access: number; }; -export type Z2MExpose = { - type: string; - name?: string; - features?: Z2MExposeFeature[]; - endpoint?: string; - values?: string[]; - value_off?: string; - value_on?: string; - access: number; - property: string; - unit?: string; - value_min?: number; - value_max?: number; -}; - export type Z2MDeviceDefinition = { description: string; exposes: Z2MExpose[]; @@ -50,7 +91,8 @@ export type Z2MDeviceDefinition = { vendor: string; }; -export type Z2MDevice = { +export type Zigbee2MQTTDevice = { + // Zigbee2MQTT ieee_address: string; friendly_name: string; definition: Z2MDeviceDefinition; @@ -60,229 +102,188 @@ export type Z2MDevice = { power_source: string; software_build_id: string; supported: boolean; -}; - -export type Zigbee2MQTTDevice = { - ieee_address: string; - name: string; + // accessory added + homekit_name: string; powerTopic?: string; - powerType?: string; }; export class Zigbee2MQTTAcessory { - private service: Service; - private powerTopic?: string; - private ieee_address: string; - private deviceFriendlyName = 'Unknown'; - private characteristics: { [key: string]: Zigbee2MQTTCharacteristic } = {}; + private characteristics = {}; + private device: Zigbee2MQTTDevice; constructor( readonly platform: TasmotaZbBridgePlatform, readonly accessory: PlatformAccessory, - readonly serviceName: string, ) { - if (this.accessory.context.device.powerTopic !== undefined) { - this.powerTopic = this.accessory.context.device.powerTopic + '/' + (this.accessory.context.device.powerType || 'POWER'); - } - this.ieee_address = this.accessory.context.device.ieee_address; - - const service = this.platform.Service[serviceName]; - if (service === undefined) { - throw new Error('Unknown service: ' + serviceName); - } - this.service = this.accessory.getService(service) || this.accessory.addService(service); - this.service.setCharacteristic(this.platform.Characteristic.Name, accessory.context.device.name); - - const device = platform.zigbee2mqttDevices.find(d => d.ieee_address === this.ieee_address); - if (device !== undefined) { - //this.log('device: %s', JSON.stringify(device, null, ' ')); - this.deviceFriendlyName = device.friendly_name; - const service = this.accessory.getService(this.platform.Service.AccessoryInformation); - if (service !== undefined) { - service - .setCharacteristic(this.platform.Characteristic.Manufacturer, device.manufacturer) - .setCharacteristic(this.platform.Characteristic.Model, device.model_id) - .setCharacteristic(this.platform.Characteristic.SerialNumber, this.ieee_address); - if (device.software_build_id) { - service.setCharacteristic(this.platform.Characteristic.FirmwareRevision, device.software_build_id); + this.device = this.accessory.context.device; + //this.log('device: %s', JSON.stringify(device)); + for (const exposed of this.device.definition.exposes) { + if (exposed.type !== undefined && exposed.features !== undefined) { + const specificExpose = EXPOSES[exposed.type]; + const service = this.createService(this.device.homekit_name, specificExpose.service); + for (const feature of exposed.features) { + const featureCharacteristic = specificExpose.features[feature.name]; + if (featureCharacteristic !== undefined) { + if (feature.type === 'composite' && feature.features !== undefined) { + for (const compositeFeature of feature.features) { + const compositeCharacteristic = specificExpose.features[feature.name].features[compositeFeature.name]; + this.createCharacteristic(service, compositeCharacteristic, compositeFeature, feature.property); + } + } else { + this.createCharacteristic(service, featureCharacteristic, feature); + } + } + } + } else if (exposed.name !== undefined) { + const genericExpose = EXPOSES[exposed.name]; + if (genericExpose !== undefined) { + const service = this.createService(this.device.homekit_name, genericExpose.service); + this.createCharacteristic(service, genericExpose.characteristic, exposed); } - this.log('Manufacturer: %s, Model: %s', - device.manufacturer, - device.model_id, - ); } + } - const features = Zigbee2MQTTAcessory.getExposedFeatures(device).map(f => f.name); - this.log('Exposes: ' + JSON.stringify(features)); - if (features.includes('state')) { - this.registerStateHandler(); - } - if (features.includes('brightness')) { - this.registerBrightnessHandler(); - } - if (features.includes('color_temp')) { - this.registerColorTempHandler(); - } - if (features.includes('color_hs')) { - this.registerHueHandler(); - this.registerSaturationHandler(); + const infoService = this.accessory.getService(this.platform.Service.AccessoryInformation); + if (infoService !== undefined) { + const serialNumber = this.device.ieee_address.replace('0x', '').toUpperCase(); + infoService + .setCharacteristic(this.platform.Characteristic.Manufacturer, this.device.manufacturer) + .setCharacteristic(this.platform.Characteristic.Model, this.device.model_id) + .setCharacteristic(this.platform.Characteristic.SerialNumber, serialNumber); + if (this.device.software_build_id) { + infoService.setCharacteristic(this.platform.Characteristic.FirmwareRevision, this.device.software_build_id); } + this.log('Manufacturer: %s, Model: %s', + this.device.manufacturer, + this.device.model_id, + ); + } - // subscribe to device status updates - this.platform.mqttClient.subscribeTopic( - this.platform.config.zigbee2mqttTopic + '/' + device.friendly_name, message => { - const msg = JSON.parse(message); - //this.log('state changed: %s', JSON.stringify(msg, null, ' ')); - if (msg.state !== undefined && this.powerTopic === undefined) { - this.characteristics['state']?.update(msg.state === 'ON'); - } - if (msg.brightness !== undefined) { - this.characteristics['brightness']?.update(Zigbee2MQTTCharacteristic.mapMaxValue(msg.brightness, 254, 100)); - } - if (msg.color_temp !== undefined && msg.color_mode === 'color_temp') { - this.characteristics['color_temp']?.update(msg.color_temp); - } - if (msg.color !== undefined && msg.color_mode === 'hs') { - if (msg.color.hue) { - this.characteristics['hue']?.update(msg.color.hue); - } - if (msg.color.saturation) { - this.characteristics['saturation']?.update(msg.color.saturation); - } - } - }); - //Subscribe for the power topic updates - if (this.powerTopic !== undefined) { - this.platform.mqttClient.subscribeTopic('stat/' + this.powerTopic, message => { - //this.log('power state changed: %s', message); - this.characteristics['state']?.update((message === 'ON')); - }); - // request initial state - this.platform.mqttClient.publish('cmnd/' + this.powerTopic, ''); - } + // subscribe to device status updates + this.platform.mqttClient.subscribeTopic( + this.platform.config.zigbee2mqttTopic + '/' + this.device.friendly_name, message => { + this.iterateStateMessage(JSON.parse(message)); + }); + //Subscribe for the power topic updates + if (this.device.powerTopic !== undefined) { + this.platform.mqttClient.subscribeTopic('stat/' + this.device.powerTopic, message => { + this.log('power state changed: %s', message); + //this.characteristics['state']?.update((message === 'ON')); + }); // request initial state - this.get('state'); + this.platform.mqttClient.publish('cmnd/' + this.device.powerTopic, ''); } + // request initial state + this.get('state'); } - log(message: string, ...parameters: unknown[]): void { - this.platform.log.debug('%s (%s) ' + message, - this.accessory.context.device.name, this.ieee_address, - ...parameters, - ); - } - - registerStateHandler() { - const state = new Zigbee2MQTTCharacteristic(this.platform, this.accessory, this.service, 'On', false); - state.willGet = () => { - if (this.powerTopic !== undefined) { - this.platform.mqttClient.publish('cmnd/' + this.powerTopic, ''); - } else { - this.get('state'); - } - return undefined; - }; - state.willSet = value => { - const state = value ? 'ON' : 'OFF'; - if (this.powerTopic !== undefined) { - this.platform.mqttClient.publish('cmnd/' + this.powerTopic, state); + iterateStateMessage(msg: object, path?: string) { + for (const [key, value] of Object.entries(msg)) { + if (typeof value === 'object') { + const ignore = (key === 'color' && msg['color_mode'] === 'color_temp'); + if (!ignore) { + this.iterateStateMessage(value, key); + } } else { - this.set('state', state); + const fullPath = (path ? path + '.' : '') + key; + //this.log(`update for: ${fullPath}: ${value} color_mode: ${msg['color_mode']}`); + const characteristic = this.getObjectByPath(this.characteristics, fullPath); + const ignore = (key === 'color_temp' && msg['color_mode'] !== 'color_temp'); + if (!ignore) { + (characteristic)?.update(this.mapGetValue(key, value)); + } } - }; - this.characteristics['state'] = state; + } + } + + createService(homekitName: string, serviceName: string): Service { + const serviceByName = this.platform.Service[serviceName]; + const service = this.accessory.getService(serviceByName) || this.accessory.addService(serviceByName); + service.setCharacteristic(this.platform.Characteristic.Name, homekitName); + //this.log('service: %s', serviceName); + return service; } - registerBrightnessHandler() { - const brightness = new Zigbee2MQTTCharacteristic(this.platform, this.accessory, this.service, 'Brightness', 100); - brightness.willGet = () => { - this.get('brightness'); - return undefined; - }; - brightness.willSet = value => { - this.set('brightness', Zigbee2MQTTCharacteristic.mapMaxValue(value as number, 100, 254)); - }; - this.characteristics['brightness'] = brightness; + createCharacteristic(service: Service, characteristicName: string, exposed: Z2MExpose, propertyPath?: string) { + const characteristic = new Zigbee2MQTTCharacteristic(this.platform, this.accessory, service, characteristicName); + const path = (propertyPath !== undefined ? propertyPath + '.' : '') + exposed.property; + if ((exposed.access & 2) === 2) { + characteristic.onGet = () => { + if (path === 'state' && this.device.powerTopic !== undefined) { + this.platform.mqttClient.publish('cmnd/' + this.device.powerTopic, ''); + } else { + this.get(path); + } + return undefined; + }; + } + if ((exposed.access & 3) === 3) { + characteristic.onSet = value => { + const mappedValue = this.mapSetValue(exposed.property, value); + if (path === 'state' && this.device.powerTopic !== undefined) { + this.platform.mqttClient.publish('cmnd/' + this.device.powerTopic, mappedValue as string); + } else { + this.set(path, mappedValue); + } + }; + } + //this.log('characteristic: %s (%s)', characteristicName, path); + //this.log('characteristic: %s (%s) exposed: %s', characteristicName, path, JSON.stringify(exposed)); + this.setObjectByPath(this.characteristics, path, characteristic); } - registerColorTempHandler() { - const colorTemp = new Zigbee2MQTTCharacteristic(this.platform, this.accessory, this.service, 'ColorTemperature', 370); - colorTemp.willGet = () => { - this.get('color_temp'); - return undefined; - }; - colorTemp.willSet = value => { - this.set('color_temp', value); - }; - this.characteristics['color_temp'] = colorTemp; + // homebridge -> Zigbee2MQTT + mapSetValue(property: string, value: CharacteristicValue): CharacteristicValue { + switch (property) { + case 'state': return (value ? 'ON' : 'OFF'); + case 'brightness': return Zigbee2MQTTCharacteristic.mapMaxValue(value as number, 100, 254); + case 'contact': return !value; + } + return value; } - registerHueHandler() { - const hue = new Zigbee2MQTTCharacteristic(this.platform, this.accessory, this.service, 'Hue', 20); - hue.willGet = () => { - this.get('color/hue'); - return undefined; - }; - hue.willSet = value => { - this.set('color/hue', value); - }; - this.characteristics['hue'] = hue; + // Zigbee2MQTT -> homebridge + mapGetValue(property: string, value: CharacteristicValue): CharacteristicValue { + switch (property) { + case 'state': return (value === 'ON'); + case 'brightness': return Zigbee2MQTTCharacteristic.mapMaxValue(value as number, 254, 100); + case 'contact': return !value; + } + return value; } - registerSaturationHandler() { - const saturation = new Zigbee2MQTTCharacteristic(this.platform, this.accessory, this.service, 'Saturation', 20); - saturation.willGet = () => { - this.get('color/saturation'); - return undefined; - }; - saturation.willSet = value => { - this.set('color/saturation', value); - }; - this.characteristics['saturation'] = saturation; + log(message: string, ...parameters: unknown[]): void { + this.platform.log.debug('%s (%s) ' + message, + this.device.homekit_name, this.device.ieee_address, + ...parameters, + ); } getObjectByPath(obj: object, path: string): object | undefined { - return path.split('/').reduce((a, v) => a ? a[v] : undefined, obj); + return path.split('.').reduce((a, v) => a ? a[v] : undefined, obj); } - setObjectByPath(obj: object, path: string, value: CharacteristicValue) { - const lastKey = path.substring(path.lastIndexOf('/') + 1); - path.split('/').reduce((a, v) => a[v] = v === lastKey ? value : a[v] ? a[v] : {}, obj); + setObjectByPath(obj: object, path: string, value: object | CharacteristicValue) { + const lastKey = path.substring(path.lastIndexOf('.') + 1); + path.split('.').reduce((a, v) => a[v] = v === lastKey ? value : a[v] ? a[v] : {}, obj); } - get(feature: string) { + get(path: string) { const obj = {}; - this.setObjectByPath(obj, feature, ''); + this.setObjectByPath(obj, path, ''); this.platform.mqttClient.publish( - `${this.platform.config.zigbee2mqttTopic}/${this.deviceFriendlyName}/get`, + `${this.platform.config.zigbee2mqttTopic}/${this.device.friendly_name}/get`, JSON.stringify(obj), ); } - set(feature: string, value: CharacteristicValue) { + set(path: string, value: CharacteristicValue) { const obj = {}; - this.setObjectByPath(obj, feature, value); + this.setObjectByPath(obj, path, value); this.platform.mqttClient.publish( - `${this.platform.config.zigbee2mqttTopic}/${this.deviceFriendlyName}/set`, + `${this.platform.config.zigbee2mqttTopic}/${this.device.friendly_name}/set`, JSON.stringify(obj), ); } - static getExposedFeatures(device: Z2MDevice): Z2MExposeFeature[] { - const exposes = device.definition.exposes.find(e => e.features); - if (exposes !== undefined && exposes.features !== undefined) { - return exposes.features; - } - return []; - } - - static getServiceName(device: Z2MDevice): string | undefined { - const exposedFeatures = Zigbee2MQTTAcessory.getExposedFeatures(device).map(f => f.name); - for (const set of FEATURES) { - if (exposedFeatures.some(f => set.features.includes(f))) { - return set.service; - } - } - } - } diff --git a/src/zigbee2MQTTCharacteristic.ts b/src/zigbee2MQTTCharacteristic.ts index aefd8ad..68feb4b 100644 --- a/src/zigbee2MQTTCharacteristic.ts +++ b/src/zigbee2MQTTCharacteristic.ts @@ -3,6 +3,7 @@ import { PlatformAccessory, CharacteristicValue, HAPStatus, + CharacteristicProps, } from 'homebridge'; import { TasmotaZbBridgePlatform } from './platform'; @@ -11,29 +12,38 @@ import { ZbBridgeAccessory } from './zbBridgeAccessory'; const UPDATE_TIMEOUT = 2000; export class Zigbee2MQTTCharacteristic { - private value: CharacteristicValue; + public props: CharacteristicProps; + public value: CharacteristicValue; private setValue: CharacteristicValue; private setTs: number; private updateTs: number; - public willGet?: (value: CharacteristicValue) => CharacteristicValue | undefined; - public willSet?: (value: CharacteristicValue) => void; + public onGet?: () => CharacteristicValue | undefined; + public onSet?: (value: CharacteristicValue) => void; constructor( readonly platform: TasmotaZbBridgePlatform, readonly accessory: PlatformAccessory, readonly service: Service, - readonly characteristic: string, - readonly initial: CharacteristicValue, + readonly characteristicName: string, ) { - this.value = initial; - this.setValue = initial; + this.value = 0; + this.setValue = 0; this.setTs = Date.now() - UPDATE_TIMEOUT; this.updateTs = Date.now(); - this.service.getCharacteristic(this.platform.Characteristic[this.characteristic]) - .onGet(this.onGet.bind(this)) - .onSet(this.onSet.bind(this)); + const characteristic = this.service.getCharacteristic(this.platform.Characteristic[this.characteristicName]); + if (characteristic !== undefined) { + this.props = characteristic.props; + //this.log('characteristic props: %s', JSON.stringify(this.props)); + //this.platform.api.hap.Perms.PAIRED_READ + //this.platform.api.hap.Perms.PAIRED_WRITE + characteristic + .onGet(this.onGetValue.bind(this)) + .onSet(this.onSetValue.bind(this)); + } else { + throw (`Unable to initialize characteristic: ${this.characteristicName}`); + } } private timeouted(ts: number): boolean { @@ -65,7 +75,7 @@ export class Zigbee2MQTTCharacteristic { return ignored; } - private async onGet(): Promise { + private async onGetValue(): Promise { const updated = (this.updateTs >= this.setTs); const timeouted = this.timeouted(this.setTs); @@ -74,8 +84,8 @@ export class Zigbee2MQTTCharacteristic { let value: CharacteristicValue | undefined = notUpdated ? this.setValue : this.value; - if (needsUpdate && this.willGet !== undefined) { - value = this.willGet(value); + if (needsUpdate && this.onGet !== undefined) { + value = this.onGet(); } if (value === undefined) { throw new this.platform.api.hap.HapStatusError(HAPStatus.OPERATION_TIMED_OUT); @@ -83,14 +93,20 @@ export class Zigbee2MQTTCharacteristic { if (value !== this.value) { this.value = value; } + if (this.props.minValue && value < this.props.minValue) { + value = this.props.minValue; + } + if (this.props.maxValue && value > this.props.maxValue) { + value = this.props.maxValue; + } return value; } - private async onSet(value: CharacteristicValue) { + private async onSetValue(value: CharacteristicValue) { this.setValue = value; this.setTs = Date.now(); - if (this.willSet !== undefined) { - this.willSet(value); + if (this.onSet !== undefined) { + this.onSet(value); } } @@ -99,16 +115,15 @@ export class Zigbee2MQTTCharacteristic { if (value !== undefined) { const updateIgnored = this.updateValue(value); if (!updateIgnored) { - this.service.getCharacteristic(this.platform.Characteristic[this.characteristic]).updateValue(value); - statusText += ` ${this.characteristic}: ${value}`; + this.service.getCharacteristic(this.platform.Characteristic[this.characteristicName]).updateValue(value); + statusText += ` ${this.characteristicName}: ${value}`; } } return statusText; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - log(message: string, ...parameters: any[]): void { - this.platform.log.debug(this.accessory.context.device.name + ':' + this.characteristic + ' ' + message, + log(message: string, ...parameters: unknown[]): void { + this.platform.log.debug(this.accessory.context.device.homekit_name + ':' + this.characteristicName + ' ' + message, ...parameters, ); }