mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-07-17 00:51:55 +00:00
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:
+2
-22
@@ -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};
|
||||
}),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Vendored
+1
-1
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user