diff --git a/lib/extension/homeassistant.ts b/lib/extension/homeassistant.ts index bedf17d9e5..f52c9cfa51 100644 --- a/lib/extension/homeassistant.ts +++ b/lib/extension/homeassistant.ts @@ -41,6 +41,13 @@ const ACTION_PATTERNS: string[] = [ "^(?dial_rotate)_(?left|right)_(?step|slow|fast)$", "^(?brightness_step)(?:_(?up|down))?$", ]; + +/** + * Properties that are stateless events: when published, we clear them from state and + * republish to a dedicated MQTT topic for device triggers (same behavior as action). + */ +const STATELESS_EVENT_PROPERTIES: ReadonlyArray = ["action", "notificationComplete"]; + const ACCESS_STATE = 0b001; const ACCESS_SET = 0b010; const GROUP_SUPPORTED_TYPES: ReadonlyArray = ["light", "switch", "lock", "cover"]; @@ -1414,24 +1421,30 @@ export class HomeAssistant extends Extension { } /** - * Publish an empty value for click and action payload, in this way Home Assistant - * can use Home Assistant entities in automations. - * https://github.com/Koenkk/zigbee2mqtt/issues/959#issuecomment-480341347 + * Stateless event properties: clear from state after publish and republish to a dedicated + * topic so they behave as one-off events, not retained state. + * - action: clear only when legacyActionSensor (for HA automations). + * https://github.com/Koenkk/zigbee2mqtt/issues/959#issuecomment-480341347 + * - notificationComplete and others: always clear. */ - if (this.legacyActionSensor && data.message.action) { - await this.publishEntityState(data.entity, {action: ""}); - } + for (const key of STATELESS_EVENT_PROPERTIES) { + const value = data.message[key]; + if (value === undefined || value === "") continue; - /** - * Implements the MQTT device trigger (https://www.home-assistant.io/integrations/device_trigger.mqtt/) - * The MQTT device trigger does not support JSON parsing, so it cannot listen to zigbee2mqtt/my_device - * Whenever a device publish an {action: *} we discover an MQTT device trigger sensor - * and republish it to zigbee2mqtt/my_device/action - */ - if (settings.get().advanced.output === "json" && entity.isDevice() && entity.definition && data.message.action) { - const value = data.message.action.toString(); - await this.publishDeviceTriggerDiscover(entity, "action", value); - await this.mqtt.publish(`${data.entity.name}/action`, value, {}); + const shouldClear = key === "action" ? this.legacyActionSensor : true; + if (shouldClear) { + await this.publishEntityState(data.entity, {[key]: ""}); + } + + /** + * MQTT device trigger: republish to zigbee2mqtt/device/{key} so device triggers work. + * https://www.home-assistant.io/integrations/device_trigger.mqtt/ + */ + if (settings.get().advanced.output === "json" && entity.isDevice() && entity.definition) { + const valueStr = value.toString(); + await this.publishDeviceTriggerDiscover(entity, key, valueStr); + await this.mqtt.publish(`${data.entity.name}/${key}`, valueStr, {}); + } } } diff --git a/test/extensions/homeassistant.test.ts b/test/extensions/homeassistant.test.ts index 53379565d8..8bc10362a8 100644 --- a/test/extensions/homeassistant.test.ts +++ b/test/extensions/homeassistant.test.ts @@ -2167,6 +2167,53 @@ describe("Extension: HomeAssistant", () => { expect(mockMQTTPublishAsync.mock.calls.filter((c) => c[1] === "single")).toHaveLength(1); }); + it("Should publish notificationComplete as stateless event with device trigger and clear state", async () => { + settings.set(["advanced", "output"], "json"); + mockMQTTPublishAsync.mockClear(); + + const device = getZ2MEntity(devices.WXKG11LM); + await controller.publishEntityState(device, {notificationComplete: "LED_1"}); + await flushPromises(); + + const discoverPayload = { + automation_type: "trigger", + type: "notificationComplete", + subtype: "LED_1", + payload: "LED_1", + topic: "zigbee2mqtt/button/notificationComplete", + origin: origin, + device: { + identifiers: ["zigbee2mqtt_0x0017880104e45520"], + name: "button", + model: "Wireless mini switch", + model_id: "WXKG11LM", + manufacturer: "Aqara", + via_device: "zigbee2mqtt_bridge_0x00124b00120144ae", + }, + }; + + expect(mockMQTTPublishAsync).toHaveBeenCalledWith( + "homeassistant/device_automation/0x0017880104e45520/notificationComplete_LED_1/config", + stringify(discoverPayload), + {retain: true, qos: 1}, + ); + expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/button/notificationComplete", "LED_1", expect.any(Object)); + + const jsonToButton = mockMQTTPublishAsync.mock.calls.filter((c) => c[0] === "zigbee2mqtt/button" && typeof c[1] === "string"); + expect(JSON.parse(jsonToButton[jsonToButton.length - 1][1])).toMatchObject({notificationComplete: ""}); + }); + + it("Should not publish notificationComplete device_automation when output is not json", async () => { + settings.set(["advanced", "output"], "attribute"); + mockMQTTPublishAsync.mockClear(); + + const device = getZ2MEntity(devices.WXKG11LM); + await controller.publishEntityState(device, {notificationComplete: "LED_1"}); + await flushPromises(); + + expect(mockMQTTPublishAsync.mock.calls.some((c) => c[0].includes("device_automation") && c[0].includes("notificationComplete"))).toBe(false); + }); + it("Should not discover device_automation when disabled", async () => { settings.set(["device_options"], { homeassistant: {device_automation: null},