Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 29 additions & 16 deletions lib/extension/homeassistant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ const ACTION_PATTERNS: string[] = [
"^(?<action>dial_rotate)_(?<direction>left|right)_(?<speed>step|slow|fast)$",
"^(?<action>brightness_step)(?:_(?<direction>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<string> = ["action", "notificationComplete"];

const ACCESS_STATE = 0b001;
const ACCESS_SET = 0b010;
const GROUP_SUPPORTED_TYPES: ReadonlyArray<string> = ["light", "switch", "lock", "cover"];
Expand Down Expand Up @@ -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, {});
}
}
}

Expand Down
47 changes: 47 additions & 0 deletions test/extensions/homeassistant.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
Loading