diff --git a/lib/extension/bridge.ts b/lib/extension/bridge.ts index 2974f2d2..d4483f64 100644 --- a/lib/extension/bridge.ts +++ b/lib/extension/bridge.ts @@ -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 { 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}; }), diff --git a/lib/extension/homeassistant.ts b/lib/extension/homeassistant.ts index 65c4def1..0ea4bf53 100644 --- a/lib/extension/homeassistant.ts +++ b/lib/extension/homeassistant.ts @@ -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) { diff --git a/lib/types/types.d.ts b/lib/types/types.d.ts index 48d866c5..db81bb03 100644 --- a/lib/types/types.d.ts +++ b/lib/types/types.d.ts @@ -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; @@ -316,4 +317,3 @@ declare global { qos?: 0 | 1 | 2, } } - diff --git a/lib/util/utils.ts b/lib/util/utils.ts index 684ff9ad..78d6f32d 100644 --- a/lib/util/utils.ts +++ b/lib/util/utils.ts @@ -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, }; diff --git a/test/homeassistant.test.js b/test/homeassistant.test.js index c07133e6..b91d12cc 100644 --- a/test/homeassistant.test.js +++ b/test/homeassistant.test.js @@ -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), + ); + }); +}); \ No newline at end of file