feat: Add Home Assistant scene discovery (#19838)

* Add device info for Zigbee Groups

* Move Scene and getScenes to utils.ts

* Add auto-discovery for group scenes

* Update scene auto-discovery on change

* Add support for single endpoint scenes

* Add test for onScenesChanged

* Move Scene to types.d.ts

* Allow optional object_id_postfix key in payload

* Add scene name to object_id for nicer HA entity_id
This commit is contained in:
Der Mundschenk & Compagnie
2023-11-26 19:38:53 +01:00
committed by GitHub
parent 51a93418fa
commit 838629ca42
5 changed files with 118 additions and 26 deletions
+2 -22
View File
@@ -15,7 +15,6 @@ import fs from 'fs';
const requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/(.*)`);
type Scene = {id: number, name: string};
type DefinitionPayload = {
model: string, vendor: string, description: string, exposes: zhc.DefinitionExpose[], supports_ota:
boolean, icon: string, options: zhc.DefinitionExpose[],
@@ -605,25 +604,6 @@ export default class Bridge extends Extension {
'bridge/info', stringify(payload), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
}
private getScenes(entity: zh.Endpoint | zh.Group): Scene[] {
const scenes: {[id: number]: Scene} = {};
const endpoints = utils.isEndpoint(entity) ? [entity] : entity.members;
const groupID = utils.isEndpoint(entity) ? 0 : entity.groupID;
for (const endpoint of endpoints) {
for (const [key, data] of Object.entries(endpoint.meta?.scenes || {})) {
const split = key.split('_');
const sceneID = parseInt(split[0], 10);
const sceneGroupID = parseInt(split[1], 10);
if (sceneGroupID === groupID) {
scenes[sceneID] = {id: sceneID, name: (data as KeyValue).name || `Scene ${sceneID}`};
}
}
}
return Object.values(scenes);
}
async publishDevices(): Promise<void> {
interface Data {
bindings: {cluster: string, target: {type: string, endpoint?: number, ieee_address?: string, id?: number}}[]
@@ -636,7 +616,7 @@ export default class Bridge extends Extension {
const endpoints: {[s: number]: Data} = {};
for (const endpoint of device.zh.endpoints) {
const data: Data = {
scenes: this.getScenes(endpoint),
scenes: utils.getScenes(endpoint),
bindings: [],
configured_reportings: [],
clusters: {
@@ -695,7 +675,7 @@ export default class Bridge extends Extension {
id: g.ID,
friendly_name: g.ID === 901 ? 'default_bind_group' : g.name,
description: g.options.description,
scenes: this.getScenes(g.zh),
scenes: utils.getScenes(g.zh),
members: g.zh.members.map((e) => {
return {ieee_address: e.getDevice().ieeeAddr, endpoint: e.ID};
}),
+45
View File
@@ -99,6 +99,7 @@ export default class HomeAssistant extends Extension {
this.eventBus.onDeviceJoined(this, this.onZigbeeEvent);
this.eventBus.onDeviceInterview(this, this.onZigbeeEvent);
this.eventBus.onDeviceMessage(this, this.onZigbeeEvent);
this.eventBus.onScenesChanged(this, this.onScenesChanged);
this.eventBus.onEntityOptionsChanged(this, (data) => this.discover(data.entity, true));
this.mqtt.subscribe(this.statusTopic);
@@ -1206,6 +1207,27 @@ export default class HomeAssistant extends Extension {
configs.push(updateSensor);
}
// Discover scenes.
const endpointsOrGroups = isDevice ? entity.zh.endpoints : [entity.zh];
endpointsOrGroups.forEach((endpointOrGroup) => {
utils.getScenes(endpointOrGroup).forEach((scene) => {
const sceneEntry: DiscoveryEntry = {
type: 'scene',
object_id: `scene_${scene.id}`,
mockProperties: [],
discovery_payload: {
name: `${scene.name}`,
state_topic: false,
command_topic: true,
payload_on: `{ "scene_recall": ${scene.id} }`,
object_id_postfix: `_${scene.name.replace(/\s+/g, '_').toLowerCase()}`,
},
};
configs.push(sceneEntry);
});
});
if (isDevice && entity.options.hasOwnProperty('legacy') && !entity.options.legacy) {
configs = configs.filter((c) => c !== sensorClick);
}
@@ -1289,6 +1311,11 @@ export default class HomeAssistant extends Extension {
payload.object_id += `_${config.object_id}`;
}
// Allow customization of the `payload.object_id` without touching the other uses of `config.object_id`
// (e.g. for setting the `payload.unique_id` and as an internal key).
payload.object_id = `${payload.object_id}${payload.object_id_postfix ?? ''}`;
delete payload.object_id_postfix;
// Set unique_id
payload.unique_id = `${entity.options.ID}_${config.object_id}_${settings.get().mqtt.base_topic}`;
@@ -1520,6 +1547,21 @@ export default class HomeAssistant extends Extension {
this.discover(data.device);
}
@bind onScenesChanged(): void {
// Re-trigger MQTT discovery of all devices and groups, similar to bridge.ts
for (const entity of [...this.zigbee.devices(), ...this.zigbee.groups()]) {
// First, clear existing scene discovery topics
logger.debug(`Clearing Home Assistant scene discovery topics for '${entity.name}'`);
this.discovered[this.getDiscoverKey(entity)]?.topics.forEach((topic) => {
if (topic.startsWith('scene')) {
this.mqtt.publish(topic, null, {retain: true, qos: 1}, this.discoveryTopic, false, false);
}
});
this.discover(entity, true);
}
}
private getDevicePayload(entity: Device | Group): KeyValue {
const identifierPostfix = entity.isGroup() ?
`zigbee2mqtt_${this.getEncodedBaseTopic()}` : 'zigbee2mqtt';
@@ -1540,6 +1582,9 @@ export default class HomeAssistant extends Extension {
payload.model = `${entity.definition.description} (${entity.definition.model})`;
payload.manufacturer = entity.definition.vendor;
payload.sw_version = entity.zh.softwareBuildID;
} else {
payload.model = 'Group';
payload.manufacturer = 'Zigbee2MQTT';
}
if (settings.get().frontend?.url) {
+1 -1
View File
@@ -46,6 +46,7 @@ declare global {
// Types
interface MQTTResponse {data: KeyValue, status: 'error' | 'ok', error?: string, transaction?: string}
interface MQTTOptions {qos?: QoS, retain?: boolean, properties?: {messageExpiryInterval: number}}
type Scene = {id: number, name: string};
type StateChangeReason = 'publishDebounce' | 'groupOptimistic' | 'lastSeenChanged' | 'publishCached';
type PublishEntityState = (entity: Device | Group, payload: KeyValue,
stateChangeReason?: StateChangeReason) => Promise<void>;
@@ -316,4 +317,3 @@ declare global {
qos?: 0 | 1 | 2,
}
}
+20 -1
View File
@@ -404,11 +404,30 @@ function computeSettingsToChange(current: KeyValue, new_: KeyValue): KeyValue {
return newSettings;
}
function getScenes(entity: zh.Endpoint | zh.Group): Scene[] {
const scenes: {[id: number]: Scene} = {};
const endpoints = isEndpoint(entity) ? [entity] : entity.members;
const groupID = isEndpoint(entity) ? 0 : entity.groupID;
for (const endpoint of endpoints) {
for (const [key, data] of Object.entries(endpoint.meta?.scenes || {})) {
const split = key.split('_');
const sceneID = parseInt(split[0], 10);
const sceneGroupID = parseInt(split[1], 10);
if (sceneGroupID === groupID) {
scenes[sceneID] = {id: sceneID, name: (data as KeyValue).name || `Scene ${sceneID}`};
}
}
}
return Object.values(scenes);
}
export default {
endpointNames, capitalize, getZigbee2MQTTVersion, getDependencyVersion, formatDate, objectHasProperties,
equalsPartial, getObjectProperty, getResponse, parseJSON, loadModuleFromText, loadModuleFromFile,
getExternalConvertersDefinitions, removeNullPropertiesFromObject, toNetworkAddressHex, toSnakeCase,
parseEntityID, isEndpoint, isZHGroup, hours, minutes, seconds, validateFriendlyName, sleep,
sanitizeImageParameter, isAvailabilityEnabledForEntity, publishLastSeen, availabilityPayload,
getAllFiles, filterProperties, flatten, arrayUnique, clone, computeSettingsToChange,
getAllFiles, filterProperties, flatten, arrayUnique, clone, computeSettingsToChange, getScenes,
};
+50 -2
View File
@@ -53,7 +53,7 @@ describe('HomeAssistant extension', () => {
const duplicated = [];
require('zigbee-herdsman-converters').devices.forEach((d) => {
const exposes = typeof d.exposes == 'function' ? d.exposes() : d.exposes;
const device = {definition: d, isDevice: () => true, options: {}, exposes: () => exposes};
const device = {definition: d, isDevice: () => true, options: {}, exposes: () => exposes, zh: {endpoints: []}};
const configs = extension.getConfigs(device);
const cfg_type_object_ids = [];
@@ -87,6 +87,8 @@ describe('HomeAssistant extension', () => {
"identifiers":["zigbee2mqtt_1221051039810110150109113116116_9"],
"name":"ha_discovery_group",
"sw_version": version,
"model": "Group",
"manufacturer": "Zigbee2MQTT",
},
"max_mireds": 454,
"min_mireds": 250,
@@ -130,6 +132,8 @@ describe('HomeAssistant extension', () => {
"identifiers":["zigbee2mqtt_1221051039810110150109113116116_9"],
"name":"ha_discovery_group",
"sw_version": version,
"model": "Group",
"manufacturer": "Zigbee2MQTT",
},
"json_attributes_topic":"zigbee2mqtt/ha_discovery_group",
"name":null,
@@ -1349,6 +1353,8 @@ describe('HomeAssistant extension', () => {
"identifiers":["zigbee2mqtt_1221051039810110150109113116116_9"],
"name":"ha_discovery_group_new",
"sw_version": version,
"model": "Group",
"manufacturer": "Zigbee2MQTT",
},
"json_attributes_topic":"zigbee2mqtt/ha_discovery_group_new",
"max_mireds": 454,
@@ -1887,6 +1893,8 @@ describe('HomeAssistant extension', () => {
"identifiers":["zigbee2mqtt_1221051039810110150109113116116_9"],
"name":"ha_discovery_group",
"sw_version": version,
"model": "Group",
"manufacturer": "Zigbee2MQTT",
},
"json_attributes_topic":"zigbee2mqtt/ha_discovery_group",
"max_mireds": 454,
@@ -1938,6 +1946,8 @@ describe('HomeAssistant extension', () => {
"identifiers":["zigbee2mqtt_1221051039810110150109113116116_9"],
"name":"ha_discovery_group",
"sw_version": version,
"model": "Group",
"manufacturer": "Zigbee2MQTT",
},
"max_mireds": 454,
"min_mireds": 250,
@@ -2108,4 +2118,42 @@ describe('HomeAssistant extension', () => {
expect.any(Function),
);
});
});
it('Should rediscover scenes when a scene is changed', async () => {
const device = controller.zigbee.resolveEntity(zigbeeHerdsman.devices.bulb_color_2);
MQTT.publish.mockClear();
controller.eventBus.emitScenesChanged();
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith(
`homeassistant/scene/${device.ID}/scene_1/config`,
null,
{retain: true, qos: 1},
expect.any(Function),
);
const payload = {
'name': 'Chill scene',
'command_topic': 'zigbee2mqtt/bulb_color_2/set',
'payload_on': '{ "scene_recall": 1 }',
'json_attributes_topic': 'zigbee2mqtt/bulb_color_2',
'object_id': 'bulb_color_2_1_chill_scene',
'unique_id': '0x000b57fffec6a5b4_scene_1_zigbee2mqtt',
'device': {
'identifiers': [ 'zigbee2mqtt_0x000b57fffec6a5b4' ],
'name': 'bulb_color_2',
'sw_version': '5.127.1.26581',
'model': 'Hue Go (7146060PH)',
'manufacturer': 'Philips',
},
'origin': origin,
'availability': [ { 'topic': 'zigbee2mqtt/bridge/state' } ]
}
expect(MQTT.publish).toHaveBeenCalledWith(
`homeassistant/scene/${device.ID}/scene_1/config`,
stringify(payload),
{retain: true, qos: 1},
expect.any(Function),
);
});
});