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 <koenkanters94@gmail.com>
This commit is contained in:
Valentin
2021-12-22 19:31:41 +00:00
committed by GitHub
co-authored by Koen Kanters
parent 41b150af82
commit cd5dd39971
8 changed files with 49 additions and 37 deletions
+1 -1
View File
@@ -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,
+11 -10
View File
@@ -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<Group> = 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];
}
});
+18 -17
View File
@@ -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')));
+1 -1
View File
@@ -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(', '),
+9
View File
@@ -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);
+1 -1
View File
@@ -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<void>;
onEvent?: (type: string, data: KeyValue, device: zh.Device, settings: KeyValue) => Promise<void>;
ota?: {
+6 -6
View File
@@ -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_');
+2 -1
View File
@@ -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 = [];