From d68f93f965d463425e2c6276c38ffb3c4f7ee3d6 Mon Sep 17 00:00:00 2001 From: Rohan Kapoor Date: Sat, 11 Apr 2026 15:55:37 -0700 Subject: [PATCH 1/3] feat(ha): treat notificationComplete as stateless event like action Extend Home Assistant extension handling so notificationComplete follows the same pattern as action: clear from cached state after publish (always, unlike action which clears only with legacyActionSensor), publish the value to a dedicated MQTT topic for device triggers, and run device trigger discovery. Adds STATELESS_EVENT_PROPERTIES to list event-like state keys; skip empty values to avoid re-entrant clears. Supports Inovelli ledEffectComplete (zigbee-herdsman-converters) without overloading the action property. Made-with: Cursor --- lib/extension/homeassistant.ts | 45 ++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 16 deletions(-) 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, {}); + } } } From 71a0ce8f3606b665f17ce4258c1f0ede59d23d58 Mon Sep 17 00:00:00 2001 From: Rohan Kapoor Date: Sat, 11 Apr 2026 16:02:48 -0700 Subject: [PATCH 2/3] test(ha): cover stateless notificationComplete handling Add integration tests for STATELESS_EVENT_PROPERTIES: MQTT device trigger discovery, dedicated topic, state clear on json output, and no device_automation when output is attribute. Made-with: Cursor --- test/extensions/homeassistant.test.ts | 49 +++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/test/extensions/homeassistant.test.ts b/test/extensions/homeassistant.test.ts index 53379565d8..7726e932d1 100644 --- a/test/extensions/homeassistant.test.ts +++ b/test/extensions/homeassistant.test.ts @@ -2167,6 +2167,55 @@ 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}, From f5caeb3a536711a3d7aae160b701408bbab54742 Mon Sep 17 00:00:00 2001 From: Rohan Kapoor Date: Sat, 11 Apr 2026 18:27:56 -0700 Subject: [PATCH 3/3] style(ha): format notificationComplete test for biome Made-with: Cursor --- test/extensions/homeassistant.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/extensions/homeassistant.test.ts b/test/extensions/homeassistant.test.ts index 7726e932d1..8bc10362a8 100644 --- a/test/extensions/homeassistant.test.ts +++ b/test/extensions/homeassistant.test.ts @@ -2211,9 +2211,7 @@ describe("Extension: HomeAssistant", () => { 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); + 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 () => {