From cd5dd399714efdc880f1188595a7ee6da77f6074 Mon Sep 17 00:00:00 2001 From: Valentin Date: Wed, 22 Dec 2021 20:31:41 +0100 Subject: [PATCH] Dynamically expose attributes (#10132) * Dynamically expose attributes * Rollback options in configure step Use event instead, as proposed in https://github.com/Koenkk/zigbee2mqtt/pull/10132/files#r771801648 * Simplify code * Switch to already existing event * Fix all unit tests * Remove redundant code * Perfect rollback * Fix typescript types * Updates * Updates * Updates * updates * Improve Co-authored-by: Koen Kanters --- lib/extension/bridge.ts | 2 +- lib/extension/groups.ts | 21 ++++++++++---------- lib/extension/homeassistant.ts | 35 +++++++++++++++++----------------- lib/extension/networkMap.ts | 2 +- lib/model/device.ts | 9 +++++++++ lib/types/types.d.ts | 2 +- test/bridge.test.js | 12 ++++++------ test/homeassistant.test.js | 3 ++- 8 files changed, 49 insertions(+), 37 deletions(-) diff --git a/lib/extension/bridge.ts b/lib/extension/bridge.ts index 2e32d307f..1a9da8278 100644 --- a/lib/extension/bridge.ts +++ b/lib/extension/bridge.ts @@ -688,7 +688,7 @@ export default class Bridge extends Extension { model: device.definition.model, vendor: device.definition.vendor, description: device.definition.description, - exposes: device.definition.exposes, + exposes: device.exposes(), supports_ota: !!device.definition.ota, options: device.definition.options, icon, diff --git a/lib/extension/groups.ts b/lib/extension/groups.ts index 74b873bfc..15ac30c29 100644 --- a/lib/extension/groups.ts +++ b/lib/extension/groups.ts @@ -13,17 +13,17 @@ const topicRegex = const legacyTopicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/(.+)/(remove|add|remove_all)$`); const legacyTopicRegexRemoveAll = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/remove_all$`); -const stateProperties: {[s: string]: (value: string, definition: zhc.Definition) => boolean} = { +const stateProperties: {[s: string]: (value: string, exposes: zhc.DefinitionExpose[]) => boolean} = { 'state': () => true, - 'brightness': (value, definition) => - !!definition.exposes.find((e) => e.type === 'light' && e.features.find((f) => f.name === 'brightness')), - 'color_temp': (value, definition) => - !!definition.exposes.find((e) => e.type === 'light' && e.features.find((f) => f.name === 'color_temp')), - 'color': (value, definition) => - !!definition.exposes.find((e) => e.type === 'light' && + 'brightness': (value, exposes) => + !!exposes.find((e) => e.type === 'light' && e.features.find((f) => f.name === 'brightness')), + 'color_temp': (value, exposes) => + !!exposes.find((e) => e.type === 'light' && e.features.find((f) => f.name === 'color_temp')), + 'color': (value, exposes) => + !!exposes.find((e) => e.type === 'light' && e.features.find((f) => f.name === 'color_xy' || f.name === 'color_hs')), - 'color_mode': (value, definition) => - !!definition.exposes.find((e) => e.type === 'light' && ( + 'color_mode': (value, exposes) => + !!exposes.find((e) => e.type === 'light' && ( (e.features.find((f) => f.name === `color_${value}`)) || (value === 'color_temp' && e.features.find((f) => f.name === 'color_temp')) )), }; @@ -143,9 +143,10 @@ export default class Groups extends Extension { const groupsToPublish: Set = new Set(); for (const member of entity.zh.members) { const device = this.zigbee.resolveEntity(member.getDevice()) as Device; + const exposes = device.exposes(); const memberPayload: KeyValue = {}; Object.keys(payload).forEach((key) => { - if (stateProperties[key](payload[key], device.definition)) { + if (stateProperties[key](payload[key], exposes)) { memberPayload[key] = payload[key]; } }); diff --git a/lib/extension/homeassistant.ts b/lib/extension/homeassistant.ts index 1c846382e..b0bff1581 100644 --- a/lib/extension/homeassistant.ts +++ b/lib/extension/homeassistant.ts @@ -107,7 +107,7 @@ export default class HomeAssistant extends Extension { } private exposeToConfig(exposes: zhc.DefinitionExpose[], entityType: 'device' | 'group', - definition?: zhc.Definition): DiscoveryEntry[] { + definition?: zhc.Definition, definitionExposes?: zhc.DefinitionExpose[]): DiscoveryEntry[] { // For groups an array of exposes (of the same type) is passed, this is to determine e.g. what features // to use for a bulb (e.g. color_xy/color_temp) assert(entityType === 'group' || exposes.length === 1, 'Multiple exposes for device not allowed'); @@ -162,7 +162,7 @@ export default class HomeAssistant extends Extension { discoveryEntry.discovery_payload.min_mireds = min; } - const effect = definition && definition.exposes.find((e) => e.type === 'enum' && e.name === 'effect'); + const effect = definitionExposes?.find((e) => e.type === 'enum' && e.name === 'effect'); if (effect) { discoveryEntry.discovery_payload.effect = true; discoveryEntry.discovery_payload.effect_list = effect.values; @@ -879,8 +879,8 @@ export default class HomeAssistant extends Extension { let configs: DiscoveryEntry[] = []; if (isDevice) { - for (const expose of entity.definition.exposes) { - configs.push(...this.exposeToConfig([expose], 'device', entity.definition)); + for (const expose of entity.exposes()) { + configs.push(...this.exposeToConfig([expose], 'device', entity.definition, entity.exposes())); } for (const mapping of legacyMapping) { @@ -898,20 +898,21 @@ export default class HomeAssistant extends Extension { } else { // group const exposesByType: {[s: string]: zhc.DefinitionExpose[]} = {}; - entity.membersDefinitions().forEach((definition) => { - for (const expose of definition.exposes.filter((e) => groupSupportedTypes.includes(e.type))) { - let key = expose.type; - if (['switch', 'lock', 'cover'].includes(expose.type) && expose.endpoint) { - // A device can have multiple of these types which have to discovered seperately. - // e.g. switch with property state and valve_detection. - const state = expose.features.find((f) => f.name === 'state'); - key += featurePropertyWithoutEndpoint(state); - } + entity.zh.members.map((e) => this.zigbee.resolveEntity(e.getDevice()) as Device) + .filter((d) => d.definition).forEach((device) => { + for (const expose of device.exposes().filter((e) => groupSupportedTypes.includes(e.type))) { + let key = expose.type; + if (['switch', 'lock', 'cover'].includes(expose.type) && expose.endpoint) { + // A device can have multiple of these types which have to discovered seperately. + // e.g. switch with property state and valve_detection. + const state = expose.features.find((f) => f.name === 'state'); + key += featurePropertyWithoutEndpoint(state); + } - if (!exposesByType[key]) exposesByType[key] = []; - exposesByType[key].push(expose); - } - }); + if (!exposesByType[key]) exposesByType[key] = []; + exposesByType[key].push(expose); + } + }); configs = [].concat(...Object.values(exposesByType) .map((exposes) => this.exposeToConfig(exposes, 'group'))); diff --git a/lib/extension/networkMap.ts b/lib/extension/networkMap.ts index 774433841..81d624b53 100644 --- a/lib/extension/networkMap.ts +++ b/lib/extension/networkMap.ts @@ -267,7 +267,7 @@ export default class NetworkMap extends Extension { model: device.definition.model, vendor: device.definition.vendor, description: device.definition.description, - supports: Array.from(new Set((device.definition.exposes).map((e) => { + supports: Array.from(new Set((device.exposes()).map((e) => { return e.hasOwnProperty('name') ? e.name : `${e.type} (${e.features.map((f) => f.name).join(', ')})`; }))).join(', '), diff --git a/lib/model/device.ts b/lib/model/device.ts index 20a97167e..f22b8db92 100644 --- a/lib/model/device.ts +++ b/lib/model/device.ts @@ -23,6 +23,15 @@ export default class Device { this.zh = device; } + exposes(): zhc.DefinitionExpose[] { + /* istanbul ignore if */ + if (typeof this.definition.exposes == 'function') { + return this.definition.exposes(this.zh, this.settings); + } else { + return this.definition.exposes; + } + } + ensureInSettings(): void { if (this.zh.type !== 'Coordinator' && !settings.getDevice(this.zh.ieeeAddr)) { settings.addDevice(this.zh.ieeeAddr); diff --git a/lib/types/types.d.ts b/lib/types/types.d.ts index 2a973565f..bd545ca83 100644 --- a/lib/types/types.d.ts +++ b/lib/types/types.d.ts @@ -114,7 +114,7 @@ declare global { description: string options: zhc.DefinitionExpose[], vendor: string - exposes: DefinitionExpose[] + exposes: DefinitionExpose[] | ((device: zh.Device, options: KeyValue) => DefinitionExpose[]) configure?: (device: zh.Device, coordinatorEndpoint: zh.Endpoint, logger: Logger) => Promise; onEvent?: (type: string, data: KeyValue, device: zh.Device, settings: KeyValue) => Promise; ota?: { diff --git a/test/bridge.test.js b/test/bridge.test.js index 34e4d6d58..2243ccc01 100644 --- a/test/bridge.test.js +++ b/test/bridge.test.js @@ -1129,31 +1129,31 @@ describe('Bridge', () => { const svg_icon = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDo'; const icon_link = 'https://www.zigbee2mqtt.io/images/devices/ZNCZ02LM.jpg'; definition.icon = icon_link; - let payload = bridge.getDefinitionPayload({...device, zh: device, definition, settings: {}}); + let payload = bridge.getDefinitionPayload({...device, zh: device, definition, exposes: () => definition.exposes, settings: {}}); expect(payload).not.toBeUndefined() expect(payload['icon']).not.toBeUndefined() expect(payload.icon).toBe(icon_link); definition.icon = icon_link; - payload = bridge.getDefinitionPayload({...device, zh: device, definition, settings: {icon: svg_icon}}); + payload = bridge.getDefinitionPayload({...device, zh: device, definition, exposes: () => definition.exposes, settings: {icon: svg_icon}}); expect(payload).not.toBeUndefined() expect(payload['icon']).not.toBeUndefined() expect(payload.icon).toBe(svg_icon); definition.icon = '_${model}_'; - payload = bridge.getDefinitionPayload({...device, zh: device, definition, settings: {}}); + payload = bridge.getDefinitionPayload({...device, zh: device, definition, exposes: () => definition.exposes, settings: {}}); expect(payload).not.toBeUndefined() expect(payload['icon']).not.toBeUndefined() expect(payload.icon).toBe('_lumi.plug_'); definition.icon = '_${model}_${zigbeeModel}_'; - payload = bridge.getDefinitionPayload({...device, zh: device, definition, settings: {}}); + payload = bridge.getDefinitionPayload({...device, zh: device, definition, exposes: () => definition.exposes, settings: {}}); expect(payload).not.toBeUndefined() expect(payload['icon']).not.toBeUndefined() expect(payload.icon).toBe('_lumi.plug_lumi.plug_'); definition.icon = svg_icon; - payload = bridge.getDefinitionPayload({...device, zh: device, definition, settings: {}}); + payload = bridge.getDefinitionPayload({...device, zh: device, definition, exposes: () => definition.exposes, settings: {}}); expect(payload).not.toBeUndefined() expect(payload['icon']).not.toBeUndefined() expect(payload.icon).toBe(svg_icon); @@ -1161,7 +1161,7 @@ describe('Bridge', () => { device.modelID = '?._Z\\NC+Z02*LM'; definition.model = '&&&&*+'; definition.icon = '_${model}_${zigbeeModel}_'; - payload = bridge.getDefinitionPayload({...device, zh: device, definition, settings: {}}); + payload = bridge.getDefinitionPayload({...device, zh: device, definition, exposes: () => definition.exposes, settings: {}}); expect(payload).not.toBeUndefined() expect(payload['icon']).not.toBeUndefined() expect(payload.icon).toBe('_------_-._Z-NC-Z02-LM_'); diff --git a/test/homeassistant.test.js b/test/homeassistant.test.js index 65b1f4db8..a340bc01d 100644 --- a/test/homeassistant.test.js +++ b/test/homeassistant.test.js @@ -49,7 +49,8 @@ describe('HomeAssistant extension', () => { it('Should not have duplicate type/object_ids in a mapping', () => { const duplicated = []; require('zigbee-herdsman-converters').devices.forEach((d) => { - const device = {definition: d, isDevice: () => true, settings: {}}; + const exposes = typeof d.exposes == 'function' ? d.exposes() : d.exposes; + const device = {definition: d, isDevice: () => true, settings: {}, exposes: () => exposes}; const configs = extension.getConfigs(device); const cfg_type_object_ids = [];