// biome-ignore assist/source/organizeImports: import mocks first import {afterAll, beforeAll, beforeEach, describe, expect, it, test, vi} from "vitest"; import * as data from "../mocks/data"; import {mockLogger} from "../mocks/logger"; import {events as mockMQTTEvents, mockMQTTPublishAsync, mockMQTTSubscribeAsync, mockMQTTUnsubscribeAsync} from "../mocks/mqtt"; import * as mockSleep from "../mocks/sleep"; import {flushPromises, getZhcBaseDefinitions} from "../mocks/utils"; import type {Device as ZhDevice} from "../mocks/zigbeeHerdsman"; import {devices, groups, events as mockZHEvents} from "../mocks/zigbeeHerdsman"; import assert from "node:assert"; import stringify from "json-stable-stringify-without-jsonify"; import type {MockInstance} from "vitest"; import * as zhc from "zigbee-herdsman-converters"; import type {KeyValueAny} from "zigbee-herdsman-converters/lib/types"; import {Controller} from "../../lib/controller"; import HomeAssistant from "../../lib/extension/homeassistant"; import type Device from "../../lib/model/device"; import type Group from "../../lib/model/group"; import * as settings from "../../lib/util/settings"; const mocksClear = [mockMQTTPublishAsync, mockLogger.debug, mockLogger.warning, mockLogger.error]; describe("Extension: HomeAssistant", () => { let controller: Controller; let version: string; let z2m_version: string; let extension: HomeAssistant; const origin = {name: "Zigbee2MQTT", sw: "", url: "https://www.zigbee2mqtt.io"}; const resetExtension = async (runTimers = true): Promise => { await controller.removeExtension(controller.getExtension("HomeAssistant")!); for (const mock of mocksClear) mock.mockClear(); await controller.addExtension(new HomeAssistant(...controller.extensionArgs)); extension = controller.getExtension("HomeAssistant")! as HomeAssistant; if (runTimers) { await vi.runOnlyPendingTimersAsync(); } }; const resetDiscoveryPayloads = (id: string): void => { // Change discovered payload, otherwise it's not re-published because it's the same. // @ts-expect-error private const messages = extension.discovered[id].messages; for (const key in messages) { messages[key].payload = "changed"; } }; const clearDiscoveredTrigger = (id: string): void => { // @ts-expect-error private extension.discovered[id].triggers = new Set(); }; const getZ2MEntity = (zhDeviceOrGroup: string | number | ZhDevice): Device | Group => { return controller.zigbee.resolveEntity(zhDeviceOrGroup)!; }; beforeAll(async () => { const {getZigbee2MQTTVersion} = await import("../../lib/util/utils.js"); z2m_version = (await getZigbee2MQTTVersion()).version; version = `Zigbee2MQTT ${z2m_version}`; origin.sw = z2m_version; vi.useFakeTimers(); settings.set(["homeassistant"], {enabled: true}); data.writeDefaultConfiguration(); settings.reRead(); data.writeEmptyState(); mockMQTTPublishAsync.mockClear(); mockSleep.mock(); controller = new Controller(vi.fn(), vi.fn()); await controller.start(); }); afterAll(async () => { mockSleep.restore(); await controller?.stop(); await flushPromises(); vi.useRealTimers(); }); beforeEach(async () => { data.writeDefaultConfiguration(); settings.reRead(); settings.set(["homeassistant"], {enabled: true}); data.writeEmptyState(); // @ts-expect-error private controller.state.load(); await resetExtension(); await flushPromises(); }); it("Should not have duplicate type/object_ids in a mapping", async () => { const duplicated: string[] = []; for (const baseDefinition of await getZhcBaseDefinitions()) { const d = zhc.prepareDefinition(baseDefinition); const exposes = typeof d.exposes === "function" ? d.exposes({isDummyDevice: true}, {}) : d.exposes; const device = { definition: d, isDevice: (): boolean => true, isGroup: (): boolean => false, endpoint: () => undefined, options: {}, exposes: (): unknown[] => exposes, zh: {endpoints: []}, }; // @ts-expect-error private const configs = extension.getConfigs(device); const cfgTypeObjectIds: string[] = []; for (const config of configs) { const id = `${config.type}/${config.object_id}`; if (cfgTypeObjectIds.includes(id)) { // A dynamic function must exposes all possible attributes for the docs if (typeof d.exposes !== "function") { duplicated.push(d.model); } } else { cfgTypeObjectIds.push(id); } } } expect(duplicated).toStrictEqual([]); }); it("Should mark thermostat configuration toggles as config entities", () => { const switchExposes = [ new zhc.Switch().withLabel("Auto lock").withState("auto_lock", false, "Enable/disable auto lock", zhc.access.STATE_SET, "AUTO", "MANUAL"), new zhc.Switch().withLabel("Away mode").withState("away_mode", false, "Enable/disable away mode", zhc.access.STATE_SET), new zhc.Switch().withLabel("Valve detection").withState("valve_detection", true, "Valve detection", zhc.access.STATE_SET), new zhc.Switch() .withLabel("Window detection") .withState("window_detection", true, "Enables/disables window detection", zhc.access.STATE_SET), ]; const binaryExposes = [ new zhc.Binary("frost_protection", zhc.access.STATE_SET, "ON", "OFF").withDescription("Anti-freeze protection"), new zhc.Binary("heating_stop", zhc.access.STATE_SET, "ON", "OFF").withDescription("Heating stop"), new zhc.Binary("away_mode", zhc.access.STATE_SET, "ON", "OFF").withDescription("Away mode"), new zhc.Binary("window_detection", zhc.access.STATE_SET, "ON", "OFF").withDescription("Open window detection"), ]; const getDiscoveryConfigs = (expose: zhc.Expose): KeyValueAny[] => { const device = { definition: {}, isDevice: (): boolean => true, isGroup: (): boolean => false, endpoint: () => undefined, options: {}, exposes: (): zhc.Expose[] => [expose], zh: {endpoints: []}, }; // @ts-expect-error private method and minimal test device return extension.getConfigs(device); }; for (const expose of switchExposes) { const [config] = getDiscoveryConfigs(expose); expect(config.type).toStrictEqual("switch"); expect(config.object_id).toStrictEqual(expose.features[0].property); expect(config.discovery_payload.entity_category).toStrictEqual("config"); expect(config.discovery_payload.command_topic_postfix).toStrictEqual(expose.features[0].property); } for (const expose of binaryExposes) { const [config] = getDiscoveryConfigs(expose); expect(config.type).toStrictEqual("switch"); expect(config.object_id).toStrictEqual(`switch_${expose.name}`); expect(config.discovery_payload.entity_category).toStrictEqual("config"); expect(config.discovery_payload.command_topic_postfix).toStrictEqual(expose.property); } }); it("Should mark device settings as config entities", () => { const getDiscoveryConfigs = (expose: zhc.Expose): KeyValueAny[] => { const device = { definition: {}, isDevice: (): boolean => true, isGroup: (): boolean => false, endpoint: () => undefined, options: {}, exposes: (): zhc.Expose[] => [expose], zh: {endpoints: []}, }; // @ts-expect-error private method and minimal test device return extension.getConfigs(device); }; const enumExposes = [ new zhc.Enum("set_limits", zhc.access.STATE_SET, ["START", "END", "RESET"]), new zhc.Enum("motor_direction", zhc.access.STATE_SET, ["forward", "back"]), new zhc.Enum("temperature_unit", zhc.access.STATE_SET, ["celsius", "fahrenheit"]), ]; for (const expose of enumExposes) { const [config] = getDiscoveryConfigs(expose); expect(config.type).toStrictEqual("select"); expect(config.object_id).toStrictEqual(expose.property); expect(config.discovery_payload.entity_category).toStrictEqual("config"); } const binaryExposes = [ new zhc.Binary("tilt_mode", zhc.access.STATE_SET, "ON", "OFF"), new zhc.Binary("calibration_left", zhc.access.STATE_SET, "ON", "OFF"), new zhc.Binary("motor_reversal_right", zhc.access.STATE_SET, "ON", "OFF"), new zhc.Binary("enable_display", zhc.access.STATE_SET, "ON", "OFF"), new zhc.Binary("indicator", zhc.access.STATE_SET, "ON", "OFF"), ]; for (const expose of binaryExposes) { const [config] = getDiscoveryConfigs(expose); expect(config.type).toStrictEqual("switch"); expect(config.object_id).toStrictEqual(`switch_${expose.property}`); expect(config.discovery_payload.entity_category).toStrictEqual("config"); } const numericExposes = [ new zhc.Numeric("calibration_time_left", zhc.access.STATE_SET), new zhc.Numeric("comfort_temperature_min", zhc.access.STATE_SET), new zhc.Numeric("comfort_humidity_max", zhc.access.STATE_SET), new zhc.Numeric("measurement_interval", zhc.access.STATE_SET), new zhc.Numeric("minimum_range", zhc.access.STATE_SET), new zhc.Numeric("maximum_range", zhc.access.STATE_SET), new zhc.Numeric("detection_delay", zhc.access.STATE_SET), new zhc.Numeric("fading_time", zhc.access.STATE_SET), new zhc.Numeric("large_motion_detection_sensitivity", zhc.access.STATE_SET), new zhc.Numeric("medium_motion_detection_distance", zhc.access.STATE_SET), new zhc.Numeric("small_detection_sensitivity", zhc.access.STATE_SET), new zhc.Numeric("soil_calibration", zhc.access.STATE_SET), new zhc.Numeric("soil_sampling", zhc.access.STATE_SET), new zhc.Numeric("soil_warning", zhc.access.STATE_SET), ]; for (const expose of numericExposes) { const [config] = getDiscoveryConfigs(expose); expect(config.type).toStrictEqual("number"); expect(config.object_id).toStrictEqual(expose.property); expect(config.discovery_payload.entity_category).toStrictEqual("config"); } const [textConfig] = getDiscoveryConfigs(new zhc.Text("schedule_settings", zhc.access.STATE_SET)); expect(textConfig.type).toStrictEqual("text"); expect(textConfig.object_id).toStrictEqual("schedule_settings"); expect(textConfig.discovery_payload.entity_category).toStrictEqual("config"); }); it("Should apply expose-level Home Assistant discovery metadata", () => { const createDevice = (exposes: zhc.Expose[]): Device => ({ definition: {}, isDevice: (): boolean => true, isGroup: (): boolean => false, endpoint: () => undefined, options: {}, exposes: (): zhc.Expose[] => exposes, zh: {endpoints: []}, }) as Device; const voltageExpose = new zhc.Numeric("voltage", zhc.access.STATE).withUnit("V"); Object.assign(voltageExpose, { homeassistant: { type: "valve", entityCategory: "diagnostic", deviceClass: "voltage", enabledByDefault: false, icon: "mdi:flash", }, }); // @ts-expect-error private const configs = extension.getConfigs(createDevice([voltageExpose])); expect(configs.find((config) => config.object_id === "voltage")?.discovery_payload).toMatchObject({ device_class: "voltage", enabled_by_default: false, entity_category: "diagnostic", icon: "mdi:flash", }); expect(configs.find((config) => config.object_id === "voltage")?.discovery_payload).not.toHaveProperty("type"); }); it("Should discover devices and groups", async () => { settings.set(["homeassistant", "experimental_event_entities"], true); settings.set(["groups", "9", "homeassistant"], {name: "HA Discovery Group", icon: "mdi:lightbulb-group"}); await resetExtension(); let payload; payload = { availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}], brightness: true, brightness_scale: 254, command_topic: "zigbee2mqtt/ha_discovery_group/set", device: { identifiers: ["zigbee2mqtt_1221051039810110150109113116116_9"], name: "HA Discovery Group", sw_version: version, model: "Group", manufacturer: "Zigbee2MQTT", via_device: "zigbee2mqtt_bridge_0x00124b00120144ae", }, icon: "mdi:lightbulb-group", max_mireds: 454, min_mireds: 250, name: null, schema: "json", state_topic: "zigbee2mqtt/ha_discovery_group", supported_color_modes: ["xy", "color_temp"], effect: true, effect_list: [ "blink", "breathe", "okay", "channel_change", "candle", "fireplace", "colorloop", "sunset", "sunrise", "sparkle", "opal", "glisten", "underwater", "cosmos", "sunbeam", "enchant", "none", "finish_effect", "stop_effect", "stop_hue_effect", ], object_id: "ha_discovery_group", default_entity_id: "light.ha_discovery_group", unique_id: "9_light_zigbee2mqtt", group: ["0x000b57fffec6a5b4_light_zigbee2mqtt", "0x000b57fffec6a5b7_light_zigbee2mqtt"], origin: origin, }; expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/light/1221051039810110150109113116116_9/light/config", stringify(payload), { retain: true, qos: 1, }); payload = { availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}], brightness: true, brightness_scale: 254, command_topic: "zigbee2mqtt/bulb_enddevice/set", device: { identifiers: ["zigbee2mqtt_0x0017880104e45553"], manufacturer: "Sengled", model: "Element classic (A19)", model_id: "E11-G13", name: "bulb_enddevice", via_device: "zigbee2mqtt_bridge_0x00124b00120144ae", }, name: null, object_id: "bulb_enddevice", default_entity_id: "light.bulb_enddevice", origin: origin, schema: "json", state_topic: "zigbee2mqtt/bulb_enddevice", supported_color_modes: ["brightness"], unique_id: "0x0017880104e45553_light_zigbee2mqtt", }; expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/light/0x0017880104e45553/light/config", stringify(payload), { retain: true, qos: 1, }); payload = { availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}], command_topic: "zigbee2mqtt/ha_discovery_group/set", device: { identifiers: ["zigbee2mqtt_1221051039810110150109113116116_9"], name: "HA Discovery Group", sw_version: version, model: "Group", manufacturer: "Zigbee2MQTT", via_device: "zigbee2mqtt_bridge_0x00124b00120144ae", }, icon: "mdi:lightbulb-group", name: null, payload_off: "OFF", payload_on: "ON", state_topic: "zigbee2mqtt/ha_discovery_group", object_id: "ha_discovery_group", default_entity_id: "switch.ha_discovery_group", unique_id: "9_switch_zigbee2mqtt", group: ["0x0017880104e45542_switch_right_zigbee2mqtt"], origin: origin, value_template: '{{ value_json["state"] }}', }; expect(mockMQTTPublishAsync).toHaveBeenCalledWith( "homeassistant/switch/1221051039810110150109113116116_9/switch/config", stringify(payload), {retain: true, qos: 1}, ); payload = { unit_of_measurement: "°C", device_class: "temperature", state_class: "measurement", value_template: '{{ value_json["temperature"] }}', state_topic: "zigbee2mqtt/weather_sensor", object_id: "weather_sensor_temperature", default_entity_id: "sensor.weather_sensor_temperature", unique_id: "0x0017880104e45522_temperature_zigbee2mqtt", origin: origin, device: { identifiers: ["zigbee2mqtt_0x0017880104e45522"], name: "weather_sensor", model: "Temperature and humidity sensor", model_id: "WSDCGQ11LM", manufacturer: "Aqara", via_device: "zigbee2mqtt_bridge_0x00124b00120144ae", }, availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}], enabled_by_default: true, }; expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/sensor/0x0017880104e45522/temperature/config", stringify(payload), { retain: true, qos: 1, }); payload = { unit_of_measurement: "%", device_class: "humidity", state_class: "measurement", value_template: '{{ value_json["humidity"] }}', state_topic: "zigbee2mqtt/weather_sensor", object_id: "weather_sensor_humidity", default_entity_id: "sensor.weather_sensor_humidity", unique_id: "0x0017880104e45522_humidity_zigbee2mqtt", origin: origin, enabled_by_default: true, device: { identifiers: ["zigbee2mqtt_0x0017880104e45522"], name: "weather_sensor", model: "Temperature and humidity sensor", model_id: "WSDCGQ11LM", manufacturer: "Aqara", via_device: "zigbee2mqtt_bridge_0x00124b00120144ae", }, availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}], }; expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/sensor/0x0017880104e45522/humidity/config", stringify(payload), { retain: true, qos: 1, }); payload = { unit_of_measurement: "hPa", device_class: "atmospheric_pressure", state_class: "measurement", value_template: '{{ value_json["pressure"] }}', state_topic: "zigbee2mqtt/weather_sensor", object_id: "weather_sensor_pressure", default_entity_id: "sensor.weather_sensor_pressure", unique_id: "0x0017880104e45522_pressure_zigbee2mqtt", origin: origin, enabled_by_default: true, device: { identifiers: ["zigbee2mqtt_0x0017880104e45522"], name: "weather_sensor", model: "Temperature and humidity sensor", model_id: "WSDCGQ11LM", manufacturer: "Aqara", via_device: "zigbee2mqtt_bridge_0x00124b00120144ae", }, availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}], }; expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/sensor/0x0017880104e45522/pressure/config", stringify(payload), { retain: true, qos: 1, }); payload = { unit_of_measurement: "%", device_class: "battery", state_class: "measurement", value_template: '{{ value_json["battery"] }}', state_topic: "zigbee2mqtt/weather_sensor", object_id: "weather_sensor_battery", default_entity_id: "sensor.weather_sensor_battery", unique_id: "0x0017880104e45522_battery_zigbee2mqtt", origin: origin, enabled_by_default: true, entity_category: "diagnostic", device: { identifiers: ["zigbee2mqtt_0x0017880104e45522"], name: "weather_sensor", model: "Temperature and humidity sensor", model_id: "WSDCGQ11LM", manufacturer: "Aqara", via_device: "zigbee2mqtt_bridge_0x00124b00120144ae", }, availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}], }; expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/sensor/0x0017880104e45522/battery/config", stringify(payload), { retain: true, qos: 1, }); payload = { icon: "mdi:signal", enabled_by_default: false, entity_category: "diagnostic", unit_of_measurement: "lqi", state_class: "measurement", value_template: '{{ value_json["linkquality"] }}', state_topic: "zigbee2mqtt/weather_sensor", name: "Linkquality", object_id: "weather_sensor_linkquality", default_entity_id: "sensor.weather_sensor_linkquality", unique_id: "0x0017880104e45522_linkquality_zigbee2mqtt", origin: origin, device: { identifiers: ["zigbee2mqtt_0x0017880104e45522"], name: "weather_sensor", model: "Temperature and humidity sensor", model_id: "WSDCGQ11LM", manufacturer: "Aqara", via_device: "zigbee2mqtt_bridge_0x00124b00120144ae", }, availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}], }; expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/sensor/0x0017880104e45522/linkquality/config", stringify(payload), { retain: true, qos: 1, }); payload = { availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}], command_topic: "zigbee2mqtt/wall_switch_double/left/set", device: { identifiers: ["zigbee2mqtt_0x0017880104e45542"], manufacturer: "Aqara", model: "Smart wall switch (no neutral, double rocker)", model_id: "QBKG03LM", name: "wall_switch_double", via_device: "zigbee2mqtt_bridge_0x00124b00120144ae", }, name: "Left", payload_off: "OFF", payload_on: "ON", state_topic: "zigbee2mqtt/wall_switch_double", object_id: "wall_switch_double_left", default_entity_id: "switch.wall_switch_double_left", unique_id: "0x0017880104e45542_switch_left_zigbee2mqtt", origin: origin, value_template: '{{ value_json["state_left"] }}', }; expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/switch/0x0017880104e45542/switch_left/config", stringify(payload), { retain: true, qos: 1, }); payload = { availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}], command_topic: "zigbee2mqtt/wall_switch_double/right/set", device: { identifiers: ["zigbee2mqtt_0x0017880104e45542"], manufacturer: "Aqara", model: "Smart wall switch (no neutral, double rocker)", model_id: "QBKG03LM", name: "wall_switch_double", via_device: "zigbee2mqtt_bridge_0x00124b00120144ae", }, name: "Right", payload_off: "OFF", payload_on: "ON", state_topic: "zigbee2mqtt/wall_switch_double", object_id: "wall_switch_double_right", default_entity_id: "switch.wall_switch_double_right", unique_id: "0x0017880104e45542_switch_right_zigbee2mqtt", origin: origin, value_template: '{{ value_json["state_right"] }}', }; expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/switch/0x0017880104e45542/switch_right/config", stringify(payload), { retain: true, qos: 1, }); payload = { availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}], brightness: true, brightness_scale: 254, supported_color_modes: ["color_temp"], min_mireds: 250, max_mireds: 454, command_topic: "zigbee2mqtt/bulb/set", device: { identifiers: ["zigbee2mqtt_0x000b57fffec6a5b2"], manufacturer: "IKEA", model: "TRADFRI bulb E26/E27, white spectrum, globe, opal, 980 lm", model_id: "LED1545G12", name: "bulb", via_device: "zigbee2mqtt_bridge_0x00124b00120144ae", }, effect: true, effect_list: ["blink", "breathe", "okay", "channel_change", "finish_effect", "stop_effect"], name: null, schema: "json", state_topic: "zigbee2mqtt/bulb", object_id: "bulb", default_entity_id: "light.bulb", unique_id: "0x000b57fffec6a5b2_light_zigbee2mqtt", origin: origin, }; expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/light/0x000b57fffec6a5b2/light/config", stringify(payload), { retain: true, qos: 1, }); payload = { availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}], device: { identifiers: ["zigbee2mqtt_0x0017880104e45520"], manufacturer: "Aqara", model: "Wireless mini switch", model_id: "WXKG11LM", name: "button", via_device: "zigbee2mqtt_bridge_0x00124b00120144ae", }, event_types: ["single", "double", "triple", "quadruple", "hold", "release"], icon: "mdi:gesture-double-tap", name: "Action", object_id: "button_action", default_entity_id: "event.button_action", origin, state_topic: "zigbee2mqtt/button", unique_id: "0x0017880104e45520_action_zigbee2mqtt", // Needs to be updated whenever one of the ACTION_*_PATTERN constants changes. value_template: "{% set patterns = [\n{\"pattern\": '^(?P