mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-07-02 01:51:38 +00:00
3312 lines
145 KiB
TypeScript
3312 lines
145 KiB
TypeScript
// 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<void> => {
|
|
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<button>(?:button_)?[a-z0-9]+)_(?P<action>(?:press|hold)(?:_release)?)$', \"groups\": [\"button\", \"action\"]},\n{\"pattern\": '^(?P<action>recall|scene)_(?P<scene>[0-2][0-9]{0,2})$', \"groups\": [\"action\", \"scene\"]},\n{\"pattern\": '^(?P<actionPrefix>region_)(?P<region>[1-9]|10)_(?P<action>enter|leave|occupied|unoccupied)$', \"groups\": [\"actionPrefix\", \"region\", \"action\"]},\n{\"pattern\": '^(?P<action>dial_rotate)_(?P<direction>left|right)_(?P<speed>step|slow|fast)$', \"groups\": [\"action\", \"direction\", \"speed\"]},\n{\"pattern\": '^(?P<action>brightness_step)(?:_(?P<direction>up|down))?$', \"groups\": [\"action\", \"direction\"]}\n] %}\n{% set action_value = value_json.action|default('') %}\n{% set ns = namespace(r=[('action', action_value)]) %}\n{% for p in patterns %}\n {% set m = action_value|regex_findall(p.pattern) %}\n {% if m[0] is undefined %}{% continue %}{% endif %}\n {% for key, value in zip(p.groups, m[0]) %}\n {% set ns.r = ns.r|rejectattr(0, 'eq', key)|list + [(key, value)] %}\n {% endfor %}\n{% endfor %}\n{% if (ns.r|selectattr(0, 'eq', 'actionPrefix')|first) is defined %}\n {% set ns.r = ns.r|rejectattr(0, 'eq', 'action')|list + [('action', ns.r|selectattr(0, 'eq', 'actionPrefix')|map(attribute=1)|first + ns.r|selectattr(0, 'eq', 'action')|map(attribute=1)|first)] %}\n{% endif %}\n{% set ns.r = ns.r + [('event_type', ns.r|selectattr(0, 'eq', 'action')|map(attribute=1)|first)] %}\n{{dict.from_keys(ns.r|rejectattr(0, 'in', ('action', 'actionPrefix'))|reject('eq', ('event_type', None))|reject('eq', ('event_type', '')))|to_json}}",
|
|
};
|
|
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/event/0x0017880104e45520/action/config", stringify(payload), {
|
|
retain: true,
|
|
qos: 1,
|
|
});
|
|
|
|
// Should NOT discovery leagcy action sensor as option is not enabled.
|
|
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith("homeassistant/sensor/0x0017880104e45520/action/config", expect.any(String), {
|
|
retain: true,
|
|
qos: 1,
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["recall_1", {action: "recall", scene: "1"}],
|
|
["recall_*", {action: "recall", scene: "wildcard"}],
|
|
["on", {action: "on"}],
|
|
["on_1", {action: "on_1"}],
|
|
["release_left", {action: "release_left"}],
|
|
["region_1_enter", {action: "region_enter", region: "1"}],
|
|
["region_*_leave", {action: "region_leave", region: "wildcard"}],
|
|
["left_press", {action: "press", button: "left"}],
|
|
["left_press_release", {action: "press_release", button: "left"}],
|
|
["right_hold", {action: "hold", button: "right"}],
|
|
["right_hold_release", {action: "hold_release", button: "right"}],
|
|
["button_4_hold_release", {action: "hold_release", button: "button_4"}],
|
|
["dial_rotate_left_step", {action: "dial_rotate", direction: "left", speed: "step"}],
|
|
["dial_rotate_right_fast", {action: "dial_rotate", direction: "right", speed: "fast"}],
|
|
["brightness_step_up", {action: "brightness_step", direction: "up"}],
|
|
["brightness_stop", {action: "brightness_stop"}],
|
|
])("Should parse action names correctly", (action, expected) => {
|
|
expect(extension.parseActionValue(action)).toStrictEqual(expected);
|
|
});
|
|
|
|
it("Should not discovery devices which are already discovered", async () => {
|
|
await resetExtension(false);
|
|
const topic1 = "homeassistant/sensor/0x0017880104e45522/humidity/config";
|
|
const payload1 = stringify({
|
|
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 }}"}],
|
|
});
|
|
const topic2 = "homeassistant/device_automation/0x0017880104e45522/action_double/config";
|
|
const payload2 = stringify({
|
|
automation_type: "trigger",
|
|
type: "action",
|
|
subtype: "double",
|
|
payload: "double",
|
|
topic: "zigbee2mqtt/weather_sensor_renamed/action",
|
|
origin: origin,
|
|
device: {
|
|
identifiers: ["zigbee2mqtt_0x0017880104e45522"],
|
|
name: "weather_sensor_renamed",
|
|
model: "Temperature and humidity sensor",
|
|
model_id: "WSDCGQ11LM",
|
|
manufacturer: "Aqara",
|
|
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
|
},
|
|
});
|
|
|
|
// Should subscribe to `homeassistant/#` to find out what devices are already discovered.
|
|
expect(mockMQTTSubscribeAsync).toHaveBeenCalledWith("homeassistant/#");
|
|
|
|
// Retained Home Assistant discovery message arrives
|
|
await mockMQTTEvents.message(topic1, payload1);
|
|
await mockMQTTEvents.message(topic2, payload2);
|
|
|
|
await vi.runOnlyPendingTimersAsync();
|
|
|
|
// Should unsubscribe to not receive all messages that are going to be published to `homeassistant/#` again.
|
|
expect(mockMQTTUnsubscribeAsync).toHaveBeenCalledWith("homeassistant/#");
|
|
|
|
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith(topic1, expect.anything(), expect.any(Object));
|
|
// Device automation should not be cleared
|
|
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith(topic2, "", expect.any(Object));
|
|
expect(mockLogger.debug).toHaveBeenCalledWith(`Skipping discovery of 'sensor/0x0017880104e45522/humidity/config', already discovered`);
|
|
});
|
|
|
|
it("Should discover devices with precision", async () => {
|
|
settings.set(["devices", "0x0017880104e45522"], {
|
|
humidity_precision: 0,
|
|
temperature_precision: 1,
|
|
pressure_precision: 2,
|
|
friendly_name: "weather_sensor",
|
|
retain: false,
|
|
});
|
|
|
|
await resetExtension();
|
|
|
|
let payload;
|
|
await flushPromises();
|
|
|
|
payload = {
|
|
unit_of_measurement: "°C",
|
|
device_class: "temperature",
|
|
state_class: "measurement",
|
|
enabled_by_default: true,
|
|
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 }}"}],
|
|
};
|
|
|
|
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",
|
|
enabled_by_default: true,
|
|
object_id: "weather_sensor_pressure",
|
|
default_entity_id: "sensor.weather_sensor_pressure",
|
|
unique_id: "0x0017880104e45522_pressure_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/pressure/config", stringify(payload), {
|
|
retain: true,
|
|
qos: 1,
|
|
});
|
|
});
|
|
|
|
it("Should discover devices with overridden user configuration", async () => {
|
|
settings.set(["devices", "0x0017880104e45522"], {
|
|
homeassistant: {
|
|
expire_after: 30,
|
|
icon: "mdi:test",
|
|
temperature: {
|
|
expire_after: 90,
|
|
device: {
|
|
manufacturer: "From Aqara",
|
|
sw_version: "test",
|
|
},
|
|
},
|
|
humidity: {
|
|
unique_id: null,
|
|
},
|
|
device: {
|
|
manufacturer: "Not from Aqara",
|
|
model: "custom model",
|
|
model_id: "custom id",
|
|
},
|
|
},
|
|
friendly_name: "weather_sensor",
|
|
retain: false,
|
|
});
|
|
|
|
await resetExtension();
|
|
|
|
let payload;
|
|
await flushPromises();
|
|
|
|
payload = {
|
|
unit_of_measurement: "°C",
|
|
device_class: "temperature",
|
|
state_class: "measurement",
|
|
value_template: '{{ value_json["temperature"] }}',
|
|
state_topic: "zigbee2mqtt/weather_sensor",
|
|
enabled_by_default: true,
|
|
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",
|
|
sw_version: "test",
|
|
model: "custom model",
|
|
model_id: "custom id",
|
|
manufacturer: "From Aqara",
|
|
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
|
},
|
|
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
|
expire_after: 90,
|
|
icon: "mdi:test",
|
|
};
|
|
|
|
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",
|
|
enabled_by_default: true,
|
|
device: {
|
|
identifiers: ["zigbee2mqtt_0x0017880104e45522"],
|
|
name: "weather_sensor",
|
|
model: "custom model",
|
|
model_id: "custom id",
|
|
manufacturer: "Not from Aqara",
|
|
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
|
},
|
|
origin: origin,
|
|
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
|
expire_after: 30,
|
|
icon: "mdi:test",
|
|
object_id: "weather_sensor_humidity",
|
|
default_entity_id: "sensor.weather_sensor_humidity",
|
|
};
|
|
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/sensor/0x0017880104e45522/humidity/config", stringify(payload), {
|
|
retain: true,
|
|
qos: 1,
|
|
});
|
|
});
|
|
|
|
it("Should discover devices with overridden name", async () => {
|
|
settings.set(["devices", "0x0017880104e45522"], {
|
|
homeassistant: {
|
|
name: "Weather Sensor",
|
|
},
|
|
friendly_name: "weather_sensor",
|
|
retain: false,
|
|
});
|
|
|
|
await resetExtension();
|
|
|
|
let payload;
|
|
await flushPromises();
|
|
|
|
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,
|
|
});
|
|
});
|
|
|
|
it("Should discover devices with overridden user configuration affecting type and object_id", async () => {
|
|
settings.set(["devices", "0x0017880104e45541"], {
|
|
friendly_name: "my_switch",
|
|
homeassistant: {
|
|
switch: {
|
|
type: "light",
|
|
object_id: "light",
|
|
default_entity_id: "light.light",
|
|
},
|
|
light: {
|
|
type: "this should be ignored",
|
|
name: "my_light_name_override",
|
|
},
|
|
},
|
|
});
|
|
|
|
await resetExtension();
|
|
|
|
await flushPromises();
|
|
|
|
const payload = {
|
|
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
|
command_topic: "zigbee2mqtt/my_switch/set",
|
|
device: {
|
|
identifiers: ["zigbee2mqtt_0x0017880104e45541"],
|
|
manufacturer: "Aqara",
|
|
model: "Smart wall switch (no neutral, single rocker)",
|
|
model_id: "QBKG04LM",
|
|
name: "my_switch",
|
|
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
|
},
|
|
name: "my_light_name_override",
|
|
payload_off: "OFF",
|
|
payload_on: "ON",
|
|
state_topic: "zigbee2mqtt/my_switch",
|
|
object_id: "my_switch",
|
|
default_entity_id: "light.my_switch",
|
|
unique_id: "0x0017880104e45541_light_zigbee2mqtt",
|
|
origin: origin,
|
|
value_template: '{{ value_json["state"] }}',
|
|
};
|
|
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/light/0x0017880104e45541/light/config", stringify(payload), {
|
|
retain: true,
|
|
qos: 1,
|
|
});
|
|
});
|
|
|
|
it("Shouldnt discover devices when homeassistant null is set in device options", async () => {
|
|
settings.set(["devices", "0x0017880104e45522"], {
|
|
homeassistant: null,
|
|
friendly_name: "weather_sensor",
|
|
retain: false,
|
|
});
|
|
|
|
await resetExtension();
|
|
await flushPromises();
|
|
|
|
const topics = mockMQTTPublishAsync.mock.calls.map((c) => c[0]);
|
|
expect(topics).not.toContain("homeassistant/sensor/0x0017880104e45522/humidity/config");
|
|
expect(topics).not.toContain("homeassistant/sensor/0x0017880104e45522/temperature/config");
|
|
});
|
|
|
|
it("Shouldnt discover sensor when set to null", async () => {
|
|
mockLogger.error.mockClear();
|
|
settings.set(["devices", "0x0017880104e45522"], {
|
|
homeassistant: {humidity: null},
|
|
friendly_name: "weather_sensor",
|
|
retain: false,
|
|
});
|
|
|
|
await resetExtension();
|
|
|
|
const topics = mockMQTTPublishAsync.mock.calls.map((c) => c[0]);
|
|
expect(topics).not.toContain("homeassistant/sensor/0x0017880104e45522/humidity/config");
|
|
expect(topics).toContain("homeassistant/sensor/0x0017880104e45522/temperature/config");
|
|
});
|
|
|
|
it("Should discover devices with fan", () => {
|
|
const payload = {
|
|
state_topic: "zigbee2mqtt/fan",
|
|
state_value_template: "{{ value_json.fan_state }}",
|
|
command_topic: "zigbee2mqtt/fan/set/fan_state",
|
|
percentage_state_topic: "zigbee2mqtt/fan",
|
|
percentage_command_topic: "zigbee2mqtt/fan/set/fan_mode",
|
|
percentage_value_template: "{{ {'off':0, 'low':1, 'medium':2, 'high':3, 'on':4}[value_json[\"fan_mode\"]] | default('None') }}",
|
|
percentage_command_template: "{{ {0:'off', 1:'low', 2:'medium', 3:'high', 4:'on'}[value] | default('') }}",
|
|
preset_mode_state_topic: "zigbee2mqtt/fan",
|
|
preset_mode_command_topic: "zigbee2mqtt/fan/set/fan_mode",
|
|
preset_mode_value_template: "{{ value_json[\"fan_mode\"] if value_json[\"fan_mode\"] in ['smart'] else 'None' | default('None') }}",
|
|
preset_modes: ["smart"],
|
|
speed_range_min: 1,
|
|
speed_range_max: 4,
|
|
name: null,
|
|
object_id: "fan",
|
|
default_entity_id: "fan.fan",
|
|
unique_id: "0x0017880104e45548_fan_zigbee2mqtt",
|
|
origin: origin,
|
|
device: {
|
|
identifiers: ["zigbee2mqtt_0x0017880104e45548"],
|
|
name: "fan",
|
|
model: "Universal wink enabled white ceiling fan premier remote control",
|
|
model_id: "99432",
|
|
manufacturer: "Hampton Bay",
|
|
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
|
},
|
|
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
|
};
|
|
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/fan/0x0017880104e45548/fan/config", stringify(payload), {
|
|
retain: true,
|
|
qos: 1,
|
|
});
|
|
});
|
|
|
|
it("Should discover siren (HS2WD-E)", () => {
|
|
const payload = {
|
|
available_tones: ["emergency"],
|
|
support_duration: true,
|
|
optimistic: true,
|
|
command_topic: "zigbee2mqtt/siren/set",
|
|
command_template:
|
|
'{"warning": {"mode": "{{ tone | default(\'emergency\') }}", ' +
|
|
'"level": "' +
|
|
"{% if volume_level is defined %}" +
|
|
"{% if volume_level | float <= 0.25 %}low" +
|
|
"{% elif volume_level | float <= 0.5 %}medium" +
|
|
"{% elif volume_level | float <= 0.75 %}high" +
|
|
"{% else %}very_high{% endif %}" +
|
|
'{% else %}medium{% endif %}", ' +
|
|
'"duration": {{ duration | default(10) }}}}',
|
|
command_off_template: '{"warning": {"mode": "stop"}}',
|
|
name: null,
|
|
object_id: "siren",
|
|
default_entity_id: "siren.siren",
|
|
unique_id: "0x0017880104e45549_siren_zigbee2mqtt",
|
|
origin: origin,
|
|
device: {
|
|
identifiers: ["zigbee2mqtt_0x0017880104e45549"],
|
|
name: "siren",
|
|
model: "Smart siren",
|
|
model_id: "HS2WD-E",
|
|
manufacturer: "Heiman",
|
|
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
|
},
|
|
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
|
};
|
|
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/siren/0x0017880104e45549/siren/config", stringify(payload), {
|
|
retain: true,
|
|
qos: 1,
|
|
});
|
|
});
|
|
|
|
it("Should discover devices with speed-controlled fan", () => {
|
|
const payload = {
|
|
state_topic: "zigbee2mqtt/fanbee",
|
|
state_value_template: "{{ value_json.state }}",
|
|
command_topic: "zigbee2mqtt/fanbee/set/state",
|
|
percentage_state_topic: "zigbee2mqtt/fanbee",
|
|
percentage_command_topic: "zigbee2mqtt/fanbee/set/speed",
|
|
percentage_value_template: "{{ value_json[\"speed\"] | default('None') }}",
|
|
percentage_command_template: "{{ value | default('') }}",
|
|
speed_range_min: 1,
|
|
speed_range_max: 254,
|
|
name: null,
|
|
object_id: "fanbee",
|
|
default_entity_id: "fan.fanbee",
|
|
unique_id: "0x00124b00cfcf3298_fan_zigbee2mqtt",
|
|
origin: origin,
|
|
device: {
|
|
identifiers: ["zigbee2mqtt_0x00124b00cfcf3298"],
|
|
name: "fanbee",
|
|
model: "Fan with valve",
|
|
model_id: "FanBee",
|
|
manufacturer: "Lorenz Brun",
|
|
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
|
},
|
|
availability: [
|
|
{
|
|
topic: "zigbee2mqtt/bridge/state",
|
|
value_template: "{{ value_json.state }}",
|
|
},
|
|
],
|
|
};
|
|
|
|
const idx = mockMQTTPublishAsync.mock.calls.findIndex((c) => c[0] === "homeassistant/fan/0x00124b00cfcf3298/fan/config");
|
|
expect(idx).not.toBe(-1);
|
|
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[idx][1])).toStrictEqual(payload);
|
|
});
|
|
|
|
it("Should discover thermostat devices", () => {
|
|
const payload = {
|
|
action_template:
|
|
"{% set values = {None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'} %}{{ values[value_json[\"running_state\"]] }}",
|
|
action_topic: "zigbee2mqtt/TS0601_thermostat",
|
|
availability: [
|
|
{
|
|
topic: "zigbee2mqtt/bridge/state",
|
|
value_template: "{{ value_json.state }}",
|
|
},
|
|
],
|
|
current_temperature_template: '{{ value_json["local_temperature"] }}',
|
|
current_temperature_topic: "zigbee2mqtt/TS0601_thermostat",
|
|
device: {
|
|
identifiers: ["zigbee2mqtt_0x0017882104a44559"],
|
|
manufacturer: "Tuya",
|
|
model: "Radiator valve with thermostat",
|
|
model_id: "TS0601_thermostat",
|
|
name: "TS0601_thermostat",
|
|
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
|
},
|
|
preset_mode_command_topic: "zigbee2mqtt/TS0601_thermostat/set/preset",
|
|
preset_modes: ["schedule", "manual", "boost", "complex", "comfort", "eco", "away"],
|
|
preset_mode_value_template: '{{ value_json["preset"] }}',
|
|
preset_mode_state_topic: "zigbee2mqtt/TS0601_thermostat",
|
|
max_temp: "35",
|
|
min_temp: "5",
|
|
mode_command_topic: "zigbee2mqtt/TS0601_thermostat/set/system_mode",
|
|
mode_state_template: '{{ value_json["system_mode"] }}',
|
|
mode_state_topic: "zigbee2mqtt/TS0601_thermostat",
|
|
modes: ["heat", "auto", "off"],
|
|
name: null,
|
|
temp_step: 0.5,
|
|
temperature_command_topic: "zigbee2mqtt/TS0601_thermostat/set/current_heating_setpoint",
|
|
temperature_state_template: '{{ value_json["current_heating_setpoint"] }}',
|
|
temperature_state_topic: "zigbee2mqtt/TS0601_thermostat",
|
|
temperature_unit: "C",
|
|
object_id: "ts0601_thermostat",
|
|
default_entity_id: "climate.ts0601_thermostat",
|
|
unique_id: "0x0017882104a44559_climate_zigbee2mqtt",
|
|
origin: origin,
|
|
};
|
|
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/climate/0x0017882104a44559/climate/config", stringify(payload), {
|
|
retain: true,
|
|
qos: 1,
|
|
});
|
|
});
|
|
|
|
it("Should discover thermostat devices with read-only PI heating demand", () => {
|
|
const payload = {
|
|
availability: [
|
|
{
|
|
topic: "zigbee2mqtt/bridge/state",
|
|
value_template: "{{ value_json.state }}",
|
|
},
|
|
],
|
|
default_entity_id: "sensor.thermostat_pi_heating_demand",
|
|
device: {
|
|
identifiers: ["zigbee2mqtt_0x0017880104e45550"],
|
|
manufacturer: "eCozy",
|
|
model: "Smart heating thermostat",
|
|
model_id: "1TST-EU",
|
|
name: "thermostat",
|
|
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
|
},
|
|
entity_category: "diagnostic",
|
|
icon: "mdi:radiator",
|
|
name: "PI heating demand",
|
|
object_id: "thermostat_pi_heating_demand",
|
|
origin: origin,
|
|
state_topic: "zigbee2mqtt/thermostat",
|
|
unique_id: "0x0017880104e45550_pi_heating_demand_zigbee2mqtt",
|
|
unit_of_measurement: "%",
|
|
value_template: '{{ value_json["pi_heating_demand"] }}',
|
|
};
|
|
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/sensor/0x0017880104e45550/pi_heating_demand/config", stringify(payload), {
|
|
retain: true,
|
|
qos: 1,
|
|
});
|
|
});
|
|
|
|
it("Should discover thermostat devices with writable PI heating demand", () => {
|
|
const payload = {
|
|
availability: [
|
|
{
|
|
topic: "zigbee2mqtt/bridge/state",
|
|
value_template: "{{ value_json.state }}",
|
|
},
|
|
],
|
|
command_topic: "zigbee2mqtt/bosch_radiator/set/pi_heating_demand",
|
|
default_entity_id: "number.bosch_radiator_pi_heating_demand",
|
|
device: {
|
|
identifiers: ["zigbee2mqtt_0x18fc2600000d7ae2"],
|
|
manufacturer: "Bosch",
|
|
model: "Radiator thermostat II",
|
|
model_id: "BTH-RA",
|
|
name: "bosch_radiator",
|
|
sw_version: "3.05.09",
|
|
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
|
},
|
|
icon: "mdi:radiator",
|
|
max: 100,
|
|
min: 0,
|
|
name: "PI heating demand",
|
|
object_id: "bosch_radiator_pi_heating_demand",
|
|
origin: origin,
|
|
state_topic: "zigbee2mqtt/bosch_radiator",
|
|
unique_id: "0x18fc2600000d7ae2_pi_heating_demand_zigbee2mqtt",
|
|
unit_of_measurement: "%",
|
|
value_template: '{{ value_json["pi_heating_demand"] }}',
|
|
};
|
|
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/number/0x18fc2600000d7ae2/pi_heating_demand/config", stringify(payload), {
|
|
retain: true,
|
|
qos: 1,
|
|
});
|
|
});
|
|
|
|
it("Should discover Bosch BTH-RA with a compatibility mapping", () => {
|
|
const payload = {
|
|
action_template:
|
|
"{% set values = {None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'} %}{{ values[value_json[\"running_state\"]] }}",
|
|
action_topic: "zigbee2mqtt/bosch_radiator",
|
|
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
|
current_temperature_template: '{{ value_json["local_temperature"] }}',
|
|
current_temperature_topic: "zigbee2mqtt/bosch_radiator",
|
|
device: {
|
|
identifiers: ["zigbee2mqtt_0x18fc2600000d7ae2"],
|
|
manufacturer: "Bosch",
|
|
model: "Radiator thermostat II",
|
|
model_id: "BTH-RA",
|
|
name: "bosch_radiator",
|
|
sw_version: "3.05.09",
|
|
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
|
},
|
|
max_temp: "30",
|
|
min_temp: "5",
|
|
mode_command_template: `{% set values = { 'auto':'schedule','heat':'manual','off':'pause'} %}{"operating_mode": "{{ values[value] if value in values.keys() else 'pause' }}"}`,
|
|
mode_command_topic: "zigbee2mqtt/bosch_radiator/set",
|
|
mode_state_template:
|
|
"{% set values = {'schedule':'auto','manual':'heat','pause':'off'} %}{% set value = value_json.operating_mode %}{{ values[value] if value in values.keys() else 'off' }}",
|
|
mode_state_topic: "zigbee2mqtt/bosch_radiator",
|
|
modes: ["off", "heat", "auto"],
|
|
name: null,
|
|
object_id: "bosch_radiator",
|
|
default_entity_id: "climate.bosch_radiator",
|
|
origin: origin,
|
|
temp_step: 0.5,
|
|
temperature_command_topic: "zigbee2mqtt/bosch_radiator/set/occupied_heating_setpoint",
|
|
temperature_state_template: '{{ value_json["occupied_heating_setpoint"] }}',
|
|
temperature_state_topic: "zigbee2mqtt/bosch_radiator",
|
|
temperature_unit: "C",
|
|
unique_id: "0x18fc2600000d7ae2_climate_zigbee2mqtt",
|
|
};
|
|
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/climate/0x18fc2600000d7ae2/climate/config", stringify(payload), {
|
|
qos: 1,
|
|
retain: true,
|
|
});
|
|
});
|
|
|
|
it("Should apply user configuration after converter compatibility mapping", async () => {
|
|
settings.set(["devices", "0x18fc2600000d7ae2", "homeassistant", "climate"], {
|
|
modes: ["off", "heat", "auto"],
|
|
mode_command_template: null,
|
|
});
|
|
|
|
await resetExtension();
|
|
await flushPromises();
|
|
|
|
const call = mockMQTTPublishAsync.mock.calls.find((c) => c[0] === "homeassistant/climate/0x18fc2600000d7ae2/climate/config");
|
|
expect(call).toBeDefined();
|
|
const payload = JSON.parse(call![1] as string);
|
|
|
|
expect(payload.modes).toStrictEqual(["off", "heat", "auto"]);
|
|
expect(payload.mode_command_template).toBeUndefined();
|
|
expect(payload.mode_command_topic).toStrictEqual("zigbee2mqtt/bosch_radiator/set");
|
|
});
|
|
|
|
it("does not throw when discovery payload override throws", async () => {
|
|
const bosch = getZ2MEntity(devices["RBSH-TRV0-ZB-EU"]) as Device;
|
|
assert(typeof bosch.definition?.meta?.overrideHaDiscoveryPayload === "function");
|
|
const overrideSpy = vi.spyOn(bosch.definition.meta, "overrideHaDiscoveryPayload") as MockInstance;
|
|
|
|
overrideSpy.mockImplementation((payload) => {
|
|
if (payload.mode_command_topic?.endsWith("/system_mode")) {
|
|
throw new Error("Failed");
|
|
}
|
|
});
|
|
|
|
await resetExtension();
|
|
|
|
const payload = {
|
|
action_template:
|
|
"{% set values = {None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'} %}{{ values[value_json[\"running_state\"]] }}",
|
|
action_topic: "zigbee2mqtt/bosch_radiator",
|
|
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
|
current_temperature_template: '{{ value_json["local_temperature"] }}',
|
|
current_temperature_topic: "zigbee2mqtt/bosch_radiator",
|
|
device: {
|
|
identifiers: ["zigbee2mqtt_0x18fc2600000d7ae2"],
|
|
manufacturer: "Bosch",
|
|
model: "Radiator thermostat II",
|
|
model_id: "BTH-RA",
|
|
name: "bosch_radiator",
|
|
sw_version: "3.05.09",
|
|
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
|
},
|
|
max_temp: "30",
|
|
min_temp: "5",
|
|
mode_command_topic: "zigbee2mqtt/bosch_radiator/set/system_mode",
|
|
mode_state_template: '{{ value_json["system_mode"] }}',
|
|
mode_state_topic: "zigbee2mqtt/bosch_radiator",
|
|
modes: ["heat"],
|
|
name: null,
|
|
object_id: "bosch_radiator",
|
|
default_entity_id: "climate.bosch_radiator",
|
|
origin: origin,
|
|
temp_step: 0.5,
|
|
temperature_command_topic: "zigbee2mqtt/bosch_radiator/set/occupied_heating_setpoint",
|
|
temperature_state_template: '{{ value_json["occupied_heating_setpoint"] }}',
|
|
temperature_state_topic: "zigbee2mqtt/bosch_radiator",
|
|
temperature_unit: "C",
|
|
unique_id: "0x18fc2600000d7ae2_climate_zigbee2mqtt",
|
|
};
|
|
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/climate/0x18fc2600000d7ae2/climate/config", stringify(payload), {
|
|
qos: 1,
|
|
retain: true,
|
|
});
|
|
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Failed to override HA discovery payload"));
|
|
|
|
overrideSpy.mockRestore();
|
|
});
|
|
|
|
it("passes device options to discovery payload overrides", async () => {
|
|
const bosch = getZ2MEntity(devices["RBSH-TRV0-ZB-EU"]) as Device;
|
|
assert(typeof bosch.definition?.meta?.overrideHaDiscoveryPayload === "function");
|
|
const overrideSpy = vi.spyOn(bosch.definition.meta, "overrideHaDiscoveryPayload") as MockInstance;
|
|
settings.set(["devices", "0x18fc2600000d7ae2", "discovery_option_marker"], "passed");
|
|
|
|
overrideSpy.mockImplementation((payload, options) => {
|
|
if (payload.mode_command_topic?.endsWith("/system_mode")) {
|
|
payload.discovery_option_marker = options?.discovery_option_marker;
|
|
}
|
|
});
|
|
|
|
await resetExtension();
|
|
|
|
expect(overrideSpy).toHaveBeenCalledWith(expect.any(Object), expect.objectContaining({discovery_option_marker: "passed"}));
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
|
"homeassistant/climate/0x18fc2600000d7ae2/climate/config",
|
|
expect.stringContaining('"discovery_option_marker":"passed"'),
|
|
{qos: 1, retain: true},
|
|
);
|
|
|
|
overrideSpy.mockRestore();
|
|
});
|
|
|
|
it("Should discover Bosch BTH-RM230Z with a current_humidity attribute", () => {
|
|
const payload = {
|
|
action_template:
|
|
"{% set values = {None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'} %}{{ values[value_json[\"running_state\"]] }}",
|
|
action_topic: "zigbee2mqtt/bosch_rm230z",
|
|
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
|
current_humidity_template: '{{ value_json["humidity"] }}',
|
|
current_humidity_topic: "zigbee2mqtt/bosch_rm230z",
|
|
current_temperature_template: '{{ value_json["local_temperature"] }}',
|
|
current_temperature_topic: "zigbee2mqtt/bosch_rm230z",
|
|
default_entity_id: "climate.bosch_rm230z",
|
|
device: {
|
|
identifiers: ["zigbee2mqtt_0x18fc2600000d7ae3"],
|
|
manufacturer: "Bosch",
|
|
model: "Room thermostat II 230V",
|
|
model_id: "BTH-RM230Z",
|
|
name: "bosch_rm230z",
|
|
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
|
},
|
|
max_temp: "30",
|
|
min_temp: "5",
|
|
mode_command_topic: "zigbee2mqtt/bosch_rm230z/set",
|
|
mode_state_template:
|
|
"{% set active_modes = ['heat'] %}{% set fallback_mode = 'heat' %}{% set values = {'schedule':'auto','pause':'off'} %}{% set value = value_json.operating_mode %}{% set mode = value_json.system_mode %}{% if value == 'manual' %}{{ mode if mode in active_modes else fallback_mode }}{% else %}{{ values[value] if value in values.keys() else 'off' }}{% endif %}",
|
|
mode_command_template:
|
|
"{% set active_modes = ['heat'] %}{% set values = {'auto':'schedule','off':'pause'} %}{% if value in active_modes %}{\"operating_mode\": \"manual\", \"system_mode\": \"{{ value }}\"}{% else %}{\"operating_mode\": \"{{ values[value] if value in values.keys() else 'pause' }}\"}{% endif %}",
|
|
mode_state_topic: "zigbee2mqtt/bosch_rm230z",
|
|
modes: ["off", "heat", "auto"],
|
|
name: null,
|
|
object_id: "bosch_rm230z",
|
|
origin,
|
|
temp_step: 0.5,
|
|
temperature_high_command_topic: "zigbee2mqtt/bosch_rm230z/set/occupied_cooling_setpoint",
|
|
temperature_high_state_template: '{{ value_json["occupied_cooling_setpoint"] }}',
|
|
temperature_high_state_topic: "zigbee2mqtt/bosch_rm230z",
|
|
temperature_low_command_topic: "zigbee2mqtt/bosch_rm230z/set/occupied_heating_setpoint",
|
|
temperature_low_state_template: '{{ value_json["occupied_heating_setpoint"] }}',
|
|
temperature_low_state_topic: "zigbee2mqtt/bosch_rm230z",
|
|
temperature_unit: "C",
|
|
unique_id: "0x18fc2600000d7ae3_climate_zigbee2mqtt",
|
|
};
|
|
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/climate/0x18fc2600000d7ae3/climate/config", stringify(payload), {
|
|
qos: 1,
|
|
retain: true,
|
|
});
|
|
});
|
|
|
|
it("Should discover seperate temperature sensor for thermostat", () => {
|
|
const payload = {
|
|
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
|
default_entity_id: "sensor.bosch_rm230z_local_temperature",
|
|
device: {
|
|
identifiers: ["zigbee2mqtt_0x18fc2600000d7ae3"],
|
|
manufacturer: "Bosch",
|
|
model: "Room thermostat II 230V",
|
|
model_id: "BTH-RM230Z",
|
|
name: "bosch_rm230z",
|
|
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
|
},
|
|
device_class: "temperature",
|
|
object_id: "bosch_rm230z_local_temperature",
|
|
origin,
|
|
state_class: "measurement",
|
|
state_topic: "zigbee2mqtt/bosch_rm230z",
|
|
unique_id: "0x18fc2600000d7ae3_local_temperature_zigbee2mqtt",
|
|
unit_of_measurement: "°C",
|
|
value_template: '{{ value_json["local_temperature"] }}',
|
|
};
|
|
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/sensor/0x18fc2600000d7ae3/local_temperature/config", stringify(payload), {
|
|
qos: 1,
|
|
retain: true,
|
|
});
|
|
});
|
|
|
|
it("Should discover climate with cooling-only setpoint", () => {
|
|
const climateExpose = new zhc.Climate()
|
|
.withSetpoint("occupied_cooling_setpoint", 16, 32, 0.5)
|
|
.withLocalTemperature()
|
|
.withSystemMode(["off", "cool", "auto"]);
|
|
const device = {
|
|
definition: {},
|
|
isDevice: (): boolean => true,
|
|
isGroup: (): boolean => false,
|
|
endpoint: () => undefined,
|
|
options: {},
|
|
exposes: (): zhc.Expose[] => [climateExpose],
|
|
zh: {endpoints: []},
|
|
} as Device;
|
|
|
|
// @ts-expect-error private
|
|
const configs = extension.getConfigs(device);
|
|
const climate = configs.find((c) => c.type === "climate");
|
|
expect(climate).toBeDefined();
|
|
expect(climate!.discovery_payload).toMatchObject({
|
|
temperature_command_topic: "occupied_cooling_setpoint",
|
|
temperature_state_template: '{{ value_json["occupied_cooling_setpoint"] }}',
|
|
temperature_state_topic: true,
|
|
min_temp: "16",
|
|
max_temp: "32",
|
|
temp_step: 0.5,
|
|
});
|
|
expect(climate!.discovery_payload).not.toHaveProperty("temperature_low_command_topic");
|
|
expect(climate!.discovery_payload).not.toHaveProperty("temperature_high_command_topic");
|
|
});
|
|
|
|
it("Should discover devices with cover_position", () => {
|
|
let payload;
|
|
|
|
payload = {
|
|
command_topic: "zigbee2mqtt/smart vent/set",
|
|
position_topic: "zigbee2mqtt/smart vent",
|
|
set_position_topic: "zigbee2mqtt/smart vent/set",
|
|
set_position_template: '{ "position": {{ position }} }',
|
|
position_template: '{{ value_json["position"] }}',
|
|
state_topic: "zigbee2mqtt/smart vent",
|
|
value_template: '{{ value_json["state"] }}',
|
|
state_open: "OPEN",
|
|
state_closed: "CLOSE",
|
|
state_stopped: "STOP",
|
|
name: null,
|
|
object_id: "smart_vent",
|
|
default_entity_id: "cover.smart_vent",
|
|
unique_id: "0x0017880104e45551_cover_zigbee2mqtt",
|
|
origin: origin,
|
|
device: {
|
|
identifiers: ["zigbee2mqtt_0x0017880104e45551"],
|
|
name: "smart vent",
|
|
model: "Smart vent",
|
|
model_id: "SV01",
|
|
manufacturer: "Keen Home",
|
|
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
|
},
|
|
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
|
};
|
|
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/cover/0x0017880104e45551/cover/config", stringify(payload), {
|
|
retain: true,
|
|
qos: 1,
|
|
});
|
|
|
|
payload = {
|
|
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
|
command_topic: "zigbee2mqtt/zigfred_plus/l6/set",
|
|
device: {
|
|
identifiers: ["zigbee2mqtt_0xf4ce368a38be56a1"],
|
|
manufacturer: "Siglis",
|
|
model: "zigfred plus smart in-wall switch",
|
|
model_id: "ZFP-1A-CH",
|
|
name: "zigfred_plus",
|
|
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
|
},
|
|
name: "L6",
|
|
position_template: '{{ value_json["position"] }}',
|
|
position_topic: "zigbee2mqtt/zigfred_plus/l6",
|
|
set_position_template: '{ "position_l6": {{ position }} }',
|
|
set_position_topic: "zigbee2mqtt/zigfred_plus/l6/set",
|
|
state_stopped: "STOP",
|
|
state_closed: "CLOSE",
|
|
state_open: "OPEN",
|
|
state_topic: "zigbee2mqtt/zigfred_plus/l6",
|
|
tilt_command_topic: "zigbee2mqtt/zigfred_plus/l6/set/tilt",
|
|
tilt_status_template: '{{ value_json["tilt"] }}',
|
|
tilt_status_topic: "zigbee2mqtt/zigfred_plus/l6",
|
|
object_id: "zigfred_plus_l6",
|
|
default_entity_id: "cover.zigfred_plus_l6",
|
|
unique_id: "0xf4ce368a38be56a1_cover_l6_zigbee2mqtt",
|
|
origin: origin,
|
|
value_template: '{{ value_json["state"] }}',
|
|
};
|
|
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/cover/0xf4ce368a38be56a1/cover_l6/config", stringify(payload), {
|
|
retain: true,
|
|
qos: 1,
|
|
});
|
|
});
|
|
|
|
it("Should discover dual cover devices", () => {
|
|
const payload_left = {
|
|
availability: [
|
|
{
|
|
topic: "zigbee2mqtt/bridge/state",
|
|
value_template: "{{ value_json.state }}",
|
|
},
|
|
],
|
|
command_topic: "zigbee2mqtt/0xa4c138018cf95021/left/set",
|
|
device: {
|
|
identifiers: ["zigbee2mqtt_0xa4c138018cf95021"],
|
|
manufacturer: "Girier",
|
|
model: "Dual smart curtain switch",
|
|
model_id: "TS130F_GIRIER_DUAL",
|
|
name: "0xa4c138018cf95021",
|
|
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
|
},
|
|
name: "Left",
|
|
object_id: "0xa4c138018cf95021_left",
|
|
default_entity_id: "cover.0xa4c138018cf95021_left",
|
|
origin: origin,
|
|
position_template: '{{ value_json["position"] }}',
|
|
position_topic: "zigbee2mqtt/0xa4c138018cf95021/left",
|
|
set_position_template: '{ "position_left": {{ position }} }',
|
|
set_position_topic: "zigbee2mqtt/0xa4c138018cf95021/left/set",
|
|
state_closing: "DOWN",
|
|
state_opening: "UP",
|
|
state_stopped: "STOP",
|
|
state_topic: "zigbee2mqtt/0xa4c138018cf95021/left",
|
|
unique_id: "0xa4c138018cf95021_cover_left_zigbee2mqtt",
|
|
value_template: '{% if "moving" in value_json and value_json["moving"] %} {{ value_json["moving"] }} {% else %} STOP {% endif %}',
|
|
};
|
|
const payload_right = {
|
|
availability: [
|
|
{
|
|
topic: "zigbee2mqtt/bridge/state",
|
|
value_template: "{{ value_json.state }}",
|
|
},
|
|
],
|
|
command_topic: "zigbee2mqtt/0xa4c138018cf95021/right/set",
|
|
device: {
|
|
identifiers: ["zigbee2mqtt_0xa4c138018cf95021"],
|
|
manufacturer: "Girier",
|
|
model: "Dual smart curtain switch",
|
|
model_id: "TS130F_GIRIER_DUAL",
|
|
name: "0xa4c138018cf95021",
|
|
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
|
},
|
|
name: "Right",
|
|
object_id: "0xa4c138018cf95021_right",
|
|
default_entity_id: "cover.0xa4c138018cf95021_right",
|
|
origin: origin,
|
|
position_template: '{{ value_json["position"] }}',
|
|
position_topic: "zigbee2mqtt/0xa4c138018cf95021/right",
|
|
set_position_template: '{ "position_right": {{ position }} }',
|
|
set_position_topic: "zigbee2mqtt/0xa4c138018cf95021/right/set",
|
|
state_closing: "DOWN",
|
|
state_opening: "UP",
|
|
state_stopped: "STOP",
|
|
state_topic: "zigbee2mqtt/0xa4c138018cf95021/right",
|
|
unique_id: "0xa4c138018cf95021_cover_right_zigbee2mqtt",
|
|
value_template: '{% if "moving" in value_json and value_json["moving"] %} {{ value_json["moving"] }} {% else %} STOP {% endif %}',
|
|
};
|
|
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/cover/0xa4c138018cf95021/cover_left/config", stringify(payload_left), {
|
|
retain: true,
|
|
qos: 1,
|
|
});
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/cover/0xa4c138018cf95021/cover_right/config", stringify(payload_right), {
|
|
retain: true,
|
|
qos: 1,
|
|
});
|
|
});
|
|
|
|
it("Should discover devices with custom homeassistant.discovery_topic", async () => {
|
|
settings.set(["homeassistant", "discovery_topic"], "my_custom_discovery_topic");
|
|
await resetExtension();
|
|
|
|
const payload = {
|
|
unit_of_measurement: "°C",
|
|
device_class: "temperature",
|
|
state_class: "measurement",
|
|
value_template: '{{ value_json["temperature"] }}',
|
|
state_topic: "zigbee2mqtt/weather_sensor",
|
|
enabled_by_default: true,
|
|
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 }}"}],
|
|
};
|
|
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
|
"my_custom_discovery_topic/sensor/0x0017880104e45522/temperature/config",
|
|
stringify(payload),
|
|
{retain: true, qos: 1},
|
|
);
|
|
});
|
|
|
|
it("Should throw error when starting with attributes output", async () => {
|
|
settings.set(["advanced", "output"], "attribute");
|
|
settings.set(["homeassistant"], {enabled: true});
|
|
const controller = new Controller(vi.fn(), vi.fn());
|
|
|
|
await expect(async () => {
|
|
await controller.start();
|
|
}).rejects.toThrow("Home Assistant integration is not possible with attribute output!");
|
|
});
|
|
|
|
it("Should throw error when homeassistant.discovery_topic equals the mqtt.base_topic", async () => {
|
|
settings.set(["mqtt", "base_topic"], "homeassistant");
|
|
const controller = new Controller(vi.fn(), vi.fn());
|
|
|
|
await expect(async () => {
|
|
await controller.start();
|
|
}).rejects.toThrow("'homeassistant.discovery_topic' cannot not be equal to the 'mqtt.base_topic' (got 'homeassistant')");
|
|
});
|
|
|
|
it("Should warn when starting with cache_state false", async () => {
|
|
settings.set(["advanced", "cache_state"], false);
|
|
mockLogger.warning.mockClear();
|
|
await resetExtension();
|
|
expect(mockLogger.warning).toHaveBeenCalledWith("In order for Home Assistant integration to work properly set `cache_state: true");
|
|
});
|
|
|
|
it("Should set missing values to null", async () => {
|
|
// https://github.com/Koenkk/zigbee2mqtt/issues/6987
|
|
const device = devices.WSDCGQ11LM;
|
|
const data = {measuredValue: -85};
|
|
const payload = {
|
|
data,
|
|
cluster: "msTemperatureMeasurement",
|
|
device,
|
|
endpoint: device.getEndpoint(1),
|
|
type: "attributeReport",
|
|
linkquality: 10,
|
|
};
|
|
mockMQTTPublishAsync.mockClear();
|
|
await mockZHEvents.message(payload);
|
|
await flushPromises();
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
|
"zigbee2mqtt/weather_sensor",
|
|
stringify({battery: null, humidity: null, linkquality: null, pressure: null, temperature: -0.85, voltage: null}),
|
|
{retain: false, qos: 1},
|
|
);
|
|
});
|
|
|
|
it("Should copy hue/saturtion to h/s if present", async () => {
|
|
const device = devices.bulb_color;
|
|
const data = {currentHue: 0, currentSaturation: 254};
|
|
const payload = {data, cluster: "lightingColorCtrl", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
|
|
mockMQTTPublishAsync.mockClear();
|
|
await mockZHEvents.message(payload);
|
|
await flushPromises();
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
|
"zigbee2mqtt/bulb_color",
|
|
stringify({
|
|
color: {hue: 0, saturation: 100, h: 0, s: 100},
|
|
color_mode: "hs",
|
|
effect: null,
|
|
effect_color: null,
|
|
effect_speed: null,
|
|
linkquality: null,
|
|
state: null,
|
|
power_on_behavior: null,
|
|
update: {state: null, installed_version: -1, latest_version: -1},
|
|
}),
|
|
{retain: false, qos: 0},
|
|
);
|
|
});
|
|
|
|
it("Should not copy hue/saturtion if properties are missing", async () => {
|
|
const device = devices.bulb_color;
|
|
const data = {currentX: 29991, currentY: 26872};
|
|
const payload = {data, cluster: "lightingColorCtrl", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
|
|
mockMQTTPublishAsync.mockClear();
|
|
await mockZHEvents.message(payload);
|
|
await flushPromises();
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
|
"zigbee2mqtt/bulb_color",
|
|
stringify({
|
|
color: {x: 0.4576, y: 0.41},
|
|
color_mode: "xy",
|
|
effect: null,
|
|
effect_color: null,
|
|
effect_speed: null,
|
|
linkquality: null,
|
|
state: null,
|
|
power_on_behavior: null,
|
|
update: {state: null, installed_version: -1, latest_version: -1},
|
|
}),
|
|
{retain: false, qos: 0},
|
|
);
|
|
});
|
|
|
|
it("Should not copy hue/saturtion if color is missing", async () => {
|
|
const device = devices.bulb_color;
|
|
const data = {onOff: 1};
|
|
const payload = {data, cluster: "genOnOff", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
|
|
mockMQTTPublishAsync.mockClear();
|
|
await mockZHEvents.message(payload);
|
|
await flushPromises();
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
|
"zigbee2mqtt/bulb_color",
|
|
stringify({
|
|
linkquality: null,
|
|
effect: null,
|
|
effect_color: null,
|
|
effect_speed: null,
|
|
state: "ON",
|
|
power_on_behavior: null,
|
|
update: {state: null, installed_version: -1, latest_version: -1},
|
|
}),
|
|
{retain: false, qos: 0},
|
|
);
|
|
});
|
|
|
|
it("Shouldt discover when already discovered", async () => {
|
|
const device = devices.WSDCGQ11LM;
|
|
const data = {measuredValue: -85};
|
|
const payload = {
|
|
data,
|
|
cluster: "msTemperatureMeasurement",
|
|
device,
|
|
endpoint: device.getEndpoint(1),
|
|
type: "attributeReport",
|
|
linkquality: 10,
|
|
};
|
|
mockMQTTPublishAsync.mockClear();
|
|
await mockZHEvents.message(payload);
|
|
await flushPromises();
|
|
// 1 publish is the publish from receive
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("Should discover when not discovered yet", async () => {
|
|
// @ts-expect-error private
|
|
extension.discovered = {};
|
|
const device = devices.WSDCGQ11LM;
|
|
const data = {measuredValue: -85};
|
|
const payload = {
|
|
data,
|
|
cluster: "msTemperatureMeasurement",
|
|
device,
|
|
endpoint: device.getEndpoint(1),
|
|
type: "attributeReport",
|
|
linkquality: 10,
|
|
};
|
|
mockMQTTPublishAsync.mockClear();
|
|
await mockZHEvents.message(payload);
|
|
await flushPromises();
|
|
const payloadHA = {
|
|
unit_of_measurement: "°C",
|
|
device_class: "temperature",
|
|
enabled_by_default: true,
|
|
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 }}"}],
|
|
};
|
|
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/sensor/0x0017880104e45522/temperature/config", stringify(payloadHA), {
|
|
retain: true,
|
|
qos: 1,
|
|
});
|
|
});
|
|
|
|
it("Shouldnt discover when device leaves", async () => {
|
|
// @ts-expect-error private
|
|
extension.discovered = {};
|
|
const device = devices.bulb;
|
|
const payload = {ieeeAddr: device.ieeeAddr};
|
|
mockMQTTPublishAsync.mockClear();
|
|
await mockZHEvents.deviceLeave(payload);
|
|
await flushPromises();
|
|
});
|
|
|
|
it("Should discover when options change", async () => {
|
|
const device = getZ2MEntity(devices.bulb)! as Device;
|
|
assert("ieeeAddr" in device);
|
|
resetDiscoveryPayloads(device.ieeeAddr);
|
|
mockMQTTPublishAsync.mockClear();
|
|
controller.eventBus.emitEntityOptionsChanged({entity: device, from: {}, to: {test: 123}});
|
|
await flushPromises();
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(`homeassistant/light/${device.ID}/light/config`, expect.any(String), expect.any(Object));
|
|
});
|
|
|
|
it("Should send all status when home assistant comes online (default topic)", async () => {
|
|
data.writeDefaultState();
|
|
// @ts-expect-error private
|
|
extension.state.load();
|
|
await resetExtension();
|
|
expect(mockMQTTSubscribeAsync).toHaveBeenCalledWith("homeassistant/status");
|
|
await flushPromises();
|
|
mockMQTTPublishAsync.mockClear();
|
|
await mockMQTTEvents.message("homeassistant/status", "online");
|
|
await flushPromises();
|
|
await vi.runOnlyPendingTimersAsync();
|
|
await flushPromises();
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/state", stringify({state: "online"}), {retain: true, qos: 1});
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
|
"zigbee2mqtt/bulb",
|
|
stringify({
|
|
state: "ON",
|
|
color_options: null,
|
|
brightness: 50,
|
|
color_temp: 370,
|
|
effect: null,
|
|
identify: null,
|
|
linkquality: 99,
|
|
power_on_behavior: null,
|
|
update: {state: null, installed_version: -1, latest_version: -1},
|
|
}),
|
|
{retain: true, qos: 0},
|
|
);
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
|
"zigbee2mqtt/remote",
|
|
stringify({
|
|
action_duration: null,
|
|
battery: null,
|
|
brightness: 255,
|
|
linkquality: null,
|
|
update: {state: null, installed_version: -1, latest_version: -1},
|
|
}),
|
|
{retain: true, qos: 0},
|
|
);
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/group_1", stringify({state: "ON"}), {retain: false, qos: 0});
|
|
});
|
|
|
|
it("Should send all status when home assistant comes online", async () => {
|
|
data.writeDefaultState();
|
|
// @ts-expect-error private
|
|
extension.state.load();
|
|
await resetExtension();
|
|
expect(mockMQTTSubscribeAsync).toHaveBeenCalledWith("homeassistant/status");
|
|
mockMQTTPublishAsync.mockClear();
|
|
await mockMQTTEvents.message("homeassistant/status", "online");
|
|
await flushPromises();
|
|
await vi.runOnlyPendingTimersAsync();
|
|
await flushPromises();
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/state", stringify({state: "online"}), {retain: true, qos: 1});
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
|
"zigbee2mqtt/bulb",
|
|
stringify({
|
|
state: "ON",
|
|
color_options: null,
|
|
brightness: 50,
|
|
color_temp: 370,
|
|
effect: null,
|
|
identify: null,
|
|
linkquality: 99,
|
|
power_on_behavior: null,
|
|
update: {state: null, installed_version: -1, latest_version: -1},
|
|
}),
|
|
{retain: true, qos: 0},
|
|
);
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
|
"zigbee2mqtt/remote",
|
|
stringify({
|
|
action_duration: null,
|
|
battery: null,
|
|
brightness: 255,
|
|
linkquality: null,
|
|
update: {state: null, installed_version: -1, latest_version: -1},
|
|
}),
|
|
{retain: true, qos: 0},
|
|
);
|
|
});
|
|
|
|
it("Shouldnt send all status when home assistant comes offline", async () => {
|
|
data.writeDefaultState();
|
|
// @ts-expect-error private
|
|
extension.state.load();
|
|
await resetExtension();
|
|
await flushPromises();
|
|
mockMQTTPublishAsync.mockClear();
|
|
await mockMQTTEvents.message("homeassistant/status", "offline");
|
|
await flushPromises();
|
|
await vi.runOnlyPendingTimersAsync();
|
|
await flushPromises();
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/health", expect.any(String), expect.any(Object));
|
|
});
|
|
|
|
it("Shouldnt send all status when home assistant comes online with different topic", async () => {
|
|
data.writeDefaultState();
|
|
// @ts-expect-error private
|
|
extension.state.load();
|
|
await resetExtension();
|
|
mockMQTTPublishAsync.mockClear();
|
|
await mockMQTTEvents.message("homeassistant/status_different", "offline");
|
|
await flushPromises();
|
|
await vi.runOnlyPendingTimersAsync();
|
|
await flushPromises();
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/health", expect.any(String), expect.any(Object));
|
|
});
|
|
|
|
it("Should discover devices with availability", async () => {
|
|
settings.set(["availability"], {enabled: true});
|
|
await resetExtension();
|
|
|
|
const payload = {
|
|
unit_of_measurement: "°C",
|
|
device_class: "temperature",
|
|
enabled_by_default: true,
|
|
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_mode: "all",
|
|
availability: [
|
|
{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"},
|
|
{topic: "zigbee2mqtt/weather_sensor/availability", value_template: "{{ value_json.state }}"},
|
|
],
|
|
};
|
|
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/sensor/0x0017880104e45522/temperature/config", stringify(payload), {
|
|
retain: true,
|
|
qos: 1,
|
|
});
|
|
});
|
|
|
|
it("Should clear discovery when device is removed", async () => {
|
|
mockMQTTPublishAsync.mockClear();
|
|
mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/remove", "weather_sensor");
|
|
await flushPromises();
|
|
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/sensor/0x0017880104e45522/temperature/config", "", {retain: true, qos: 1});
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/sensor/0x0017880104e45522/humidity/config", "", {retain: true, qos: 1});
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/sensor/0x0017880104e45522/pressure/config", "", {retain: true, qos: 1});
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/sensor/0x0017880104e45522/battery/config", "", {retain: true, qos: 1});
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/sensor/0x0017880104e45522/linkquality/config", "", {retain: true, qos: 1});
|
|
});
|
|
|
|
it("Should clear discovery when group is removed", async () => {
|
|
mockMQTTPublishAsync.mockClear();
|
|
mockMQTTEvents.message("zigbee2mqtt/bridge/request/group/remove", stringify({id: "ha_discovery_group"}));
|
|
await flushPromises();
|
|
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/light/1221051039810110150109113116116_9/light/config", "", {
|
|
retain: true,
|
|
qos: 1,
|
|
});
|
|
});
|
|
|
|
it("Should refresh discovery when device is renamed", async () => {
|
|
await mockMQTTEvents.message(
|
|
"homeassistant/device_automation/0x0017880104e45522/action_double/config",
|
|
stringify({topic: "zigbee2mqtt/weather_sensor/action"}),
|
|
);
|
|
await flushPromises();
|
|
mockMQTTPublishAsync.mockClear();
|
|
mockMQTTEvents.message(
|
|
"zigbee2mqtt/bridge/request/device/rename",
|
|
stringify({from: "weather_sensor", to: "weather_sensor_renamed", homeassistant_rename: true}),
|
|
);
|
|
await flushPromises();
|
|
await vi.runOnlyPendingTimersAsync();
|
|
await flushPromises();
|
|
|
|
const payload = {
|
|
unit_of_measurement: "°C",
|
|
device_class: "temperature",
|
|
state_class: "measurement",
|
|
enabled_by_default: true,
|
|
value_template: '{{ value_json["temperature"] }}',
|
|
state_topic: "zigbee2mqtt/weather_sensor_renamed",
|
|
object_id: "weather_sensor_renamed_temperature",
|
|
default_entity_id: "sensor.weather_sensor_renamed_temperature",
|
|
origin: origin,
|
|
unique_id: "0x0017880104e45522_temperature_zigbee2mqtt",
|
|
device: {
|
|
identifiers: ["zigbee2mqtt_0x0017880104e45522"],
|
|
name: "weather_sensor_renamed",
|
|
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/temperature/config", stringify(payload), {
|
|
retain: true,
|
|
qos: 1,
|
|
});
|
|
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/sensor/0x0017880104e45522/temperature/config", "", {retain: true, qos: 1});
|
|
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
|
"homeassistant/device_automation/0x0017880104e45522/action_double/config",
|
|
stringify({
|
|
automation_type: "trigger",
|
|
type: "action",
|
|
subtype: "double",
|
|
payload: "double",
|
|
topic: "zigbee2mqtt/weather_sensor_renamed/action",
|
|
origin: origin,
|
|
device: {
|
|
identifiers: ["zigbee2mqtt_0x0017880104e45522"],
|
|
name: "weather_sensor_renamed",
|
|
model: "Temperature and humidity sensor",
|
|
model_id: "WSDCGQ11LM",
|
|
manufacturer: "Aqara",
|
|
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
|
},
|
|
}),
|
|
{retain: true, qos: 1},
|
|
);
|
|
});
|
|
|
|
it("Should refresh discovery when group is renamed", async () => {
|
|
mockMQTTPublishAsync.mockClear();
|
|
mockMQTTEvents.message(
|
|
"zigbee2mqtt/bridge/request/group/rename",
|
|
stringify({from: "ha_discovery_group", to: "ha_discovery_group_new", homeassistant_rename: true}),
|
|
);
|
|
await flushPromises();
|
|
await vi.runOnlyPendingTimersAsync();
|
|
await flushPromises();
|
|
|
|
const payload = {
|
|
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
|
brightness: true,
|
|
brightness_scale: 254,
|
|
command_topic: "zigbee2mqtt/ha_discovery_group_new/set",
|
|
device: {
|
|
identifiers: ["zigbee2mqtt_1221051039810110150109113116116_9"],
|
|
name: "ha_discovery_group_new",
|
|
sw_version: version,
|
|
model: "Group",
|
|
manufacturer: "Zigbee2MQTT",
|
|
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
|
},
|
|
max_mireds: 454,
|
|
min_mireds: 250,
|
|
name: null,
|
|
schema: "json",
|
|
state_topic: "zigbee2mqtt/ha_discovery_group_new",
|
|
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_new",
|
|
default_entity_id: "light.ha_discovery_group_new",
|
|
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,
|
|
});
|
|
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/light/1221051039810110150109113116116_9/light/config", "", {
|
|
retain: true,
|
|
qos: 1,
|
|
});
|
|
});
|
|
|
|
it("Shouldnt refresh discovery when device is renamed and homeassistant_rename is false", async () => {
|
|
mockMQTTPublishAsync.mockClear();
|
|
mockMQTTEvents.message(
|
|
"zigbee2mqtt/bridge/request/device/rename",
|
|
stringify({from: "weather_sensor", to: "weather_sensor_renamed", homeassistant_rename: false}),
|
|
);
|
|
await flushPromises();
|
|
|
|
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith("homeassistant/sensor/0x0017880104e45522/temperature/config", "", {
|
|
retain: true,
|
|
qos: 1,
|
|
});
|
|
|
|
const payload = {
|
|
unit_of_measurement: "°C",
|
|
device_class: "temperature",
|
|
state_class: "measurement",
|
|
enabled_by_default: true,
|
|
value_template: '{{ value_json["temperature"] }}',
|
|
state_topic: "zigbee2mqtt/weather_sensor_renamed",
|
|
object_id: "weather_sensor_renamed_temperature",
|
|
default_entity_id: "sensor.weather_sensor_renamed_temperature",
|
|
unique_id: "0x0017880104e45522_temperature_zigbee2mqtt",
|
|
origin: origin,
|
|
device: {
|
|
identifiers: ["zigbee2mqtt_0x0017880104e45522"],
|
|
name: "weather_sensor_renamed",
|
|
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/temperature/config", stringify(payload), {
|
|
retain: true,
|
|
qos: 1,
|
|
});
|
|
});
|
|
|
|
it("Should discover update when device supports it", () => {
|
|
const payload = {
|
|
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
|
command_topic: "zigbee2mqtt/bridge/request/device/ota_update/update",
|
|
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",
|
|
},
|
|
device_class: "firmware",
|
|
entity_category: "config",
|
|
entity_picture: "https://github.com/Koenkk/zigbee2mqtt/raw/master/images/logo.png",
|
|
name: null,
|
|
object_id: "bulb",
|
|
default_entity_id: "update.bulb",
|
|
origin,
|
|
payload_install: `{"id": "0x000b57fffec6a5b2"}`,
|
|
state_topic: "zigbee2mqtt/bulb",
|
|
unique_id: "0x000b57fffec6a5b2_update_zigbee2mqtt",
|
|
value_template:
|
|
"{\"latest_version\":\"{{ value_json['update']['latest_version'] }}\",\"installed_version\":\"{{ value_json['update']['installed_version'] }}\",\"update_percentage\":{{ value_json['update'].get('progress', 'null') }},\"in_progress\":{{ (value_json['update']['state'] == 'updating')|lower }}}",
|
|
};
|
|
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/update/0x000b57fffec6a5b2/update/config", stringify(payload), {
|
|
retain: true,
|
|
qos: 1,
|
|
});
|
|
});
|
|
|
|
it("Should discover trigger when action is published", async () => {
|
|
const discovered = mockMQTTPublishAsync.mock.calls.filter((c) => c[0].includes("0x0017880104e45520")).map((c) => c[0]);
|
|
expect(discovered.length).toBe(5);
|
|
|
|
mockMQTTPublishAsync.mockClear();
|
|
|
|
const device = devices.WXKG11LM;
|
|
const payload1 = {data: {onOff: 1}, cluster: "genOnOff", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
|
|
await mockZHEvents.message(payload1);
|
|
await flushPromises();
|
|
|
|
const discoverPayloadAction = {
|
|
automation_type: "trigger",
|
|
type: "action",
|
|
subtype: "single",
|
|
payload: "single",
|
|
topic: "zigbee2mqtt/button/action",
|
|
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/action_single/config",
|
|
stringify(discoverPayloadAction),
|
|
{retain: true, qos: 1},
|
|
);
|
|
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
|
"zigbee2mqtt/button",
|
|
stringify({
|
|
action: "single",
|
|
battery: null,
|
|
linkquality: null,
|
|
voltage: null,
|
|
power_outage_count: null,
|
|
device_temperature: null,
|
|
}),
|
|
{retain: false, qos: 0},
|
|
);
|
|
|
|
// Should only discover it once
|
|
mockMQTTPublishAsync.mockClear();
|
|
await mockZHEvents.message(payload1);
|
|
await flushPromises();
|
|
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith(
|
|
"homeassistant/device_automation/0x0017880104e45520/action_single/config",
|
|
stringify(discoverPayloadAction),
|
|
{retain: true, qos: 1},
|
|
);
|
|
|
|
// Shouldn't rediscover when already discovered in previous session
|
|
clearDiscoveredTrigger("0x0017880104e45520");
|
|
await mockMQTTEvents.message(
|
|
"homeassistant/device_automation/0x0017880104e45520/action_double/config",
|
|
stringify({topic: "zigbee2mqtt/button/action"}),
|
|
);
|
|
await mockMQTTEvents.message(
|
|
"homeassistant/device_automation/0x0017880104e45520/action_double/config",
|
|
stringify({topic: "zigbee2mqtt/button/action"}),
|
|
);
|
|
await flushPromises();
|
|
mockMQTTPublishAsync.mockClear();
|
|
const payload2 = {data: {32768: 2}, cluster: "genOnOff", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
|
|
await mockZHEvents.message(payload2);
|
|
await flushPromises();
|
|
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith(
|
|
"homeassistant/device_automation/0x0017880104e45520/action_double/config",
|
|
expect.any(String),
|
|
expect.any(Object),
|
|
);
|
|
|
|
// Should rediscover when already discovered in previous session but with different name
|
|
clearDiscoveredTrigger("0x0017880104e45520");
|
|
await mockMQTTEvents.message(
|
|
"homeassistant/device_automation/0x0017880104e45520/action_double/config",
|
|
stringify({topic: "zigbee2mqtt/button_other_name/action"}),
|
|
);
|
|
await flushPromises();
|
|
mockMQTTPublishAsync.mockClear();
|
|
await mockZHEvents.message(payload2);
|
|
await flushPromises();
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
|
"homeassistant/device_automation/0x0017880104e45520/action_double/config",
|
|
expect.any(String),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
test.each(["attribute_and_json", "json", "attribute"])("Should publish /action for MQTT device trigger", async (output) => {
|
|
settings.set(["advanced", "output"], output);
|
|
mockMQTTPublishAsync.mockClear();
|
|
|
|
const device = devices.WXKG11LM;
|
|
const payload1 = {data: {onOff: 1}, cluster: "genOnOff", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
|
|
await mockZHEvents.message(payload1);
|
|
await flushPromises();
|
|
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/button/action", "single", expect.any(Object));
|
|
expect(mockMQTTPublishAsync.mock.calls.filter((c) => c[1] === "single")).toHaveLength(1);
|
|
});
|
|
|
|
it("Should not discover device_automation when disabled", async () => {
|
|
settings.set(["device_options"], {
|
|
homeassistant: {device_automation: null},
|
|
});
|
|
await resetExtension();
|
|
mockMQTTPublishAsync.mockClear();
|
|
|
|
const device = devices.WXKG11LM;
|
|
const payload1 = {data: {onOff: 1}, cluster: "genOnOff", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
|
|
await mockZHEvents.message(payload1);
|
|
await flushPromises();
|
|
|
|
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith(
|
|
"homeassistant/device_automation/0x0017880104e45520/action_single/config",
|
|
expect.any(String),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it("Should enable experimental event entities", async () => {
|
|
settings.set(["homeassistant", "experimental_event_entities"], true);
|
|
settings.set(["devices", "0x0017880104e45520"], {
|
|
friendly_name: "button",
|
|
retain: false,
|
|
});
|
|
await resetExtension();
|
|
|
|
const 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: 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<button>(?:button_)?[a-z0-9]+)_(?P<action>(?:press|hold)(?:_release)?)$', \"groups\": [\"button\", \"action\"]},\n{\"pattern\": '^(?P<action>recall|scene)_(?P<scene>[0-2][0-9]{0,2})$', \"groups\": [\"action\", \"scene\"]},\n{\"pattern\": '^(?P<actionPrefix>region_)(?P<region>[1-9]|10)_(?P<action>enter|leave|occupied|unoccupied)$', \"groups\": [\"actionPrefix\", \"region\", \"action\"]},\n{\"pattern\": '^(?P<action>dial_rotate)_(?P<direction>left|right)_(?P<speed>step|slow|fast)$', \"groups\": [\"action\", \"direction\", \"speed\"]},\n{\"pattern\": '^(?P<action>brightness_step)(?:_(?P<direction>up|down))?$', \"groups\": [\"action\", \"direction\"]}\n] %}\n{% set action_value = value_json.action|default('') %}\n{% set ns = namespace(r=[('action', action_value)]) %}\n{% for p in patterns %}\n {% set m = action_value|regex_findall(p.pattern) %}\n {% if m[0] is undefined %}{% continue %}{% endif %}\n {% for key, value in zip(p.groups, m[0]) %}\n {% set ns.r = ns.r|rejectattr(0, 'eq', key)|list + [(key, value)] %}\n {% endfor %}\n{% endfor %}\n{% if (ns.r|selectattr(0, 'eq', 'actionPrefix')|first) is defined %}\n {% set ns.r = ns.r|rejectattr(0, 'eq', 'action')|list + [('action', ns.r|selectattr(0, 'eq', 'actionPrefix')|map(attribute=1)|first + ns.r|selectattr(0, 'eq', 'action')|map(attribute=1)|first)] %}\n{% endif %}\n{% set ns.r = ns.r + [('event_type', ns.r|selectattr(0, 'eq', 'action')|map(attribute=1)|first)] %}\n{{dict.from_keys(ns.r|rejectattr(0, 'in', ('action', 'actionPrefix'))|reject('eq', ('event_type', None))|reject('eq', ('event_type', '')))|to_json}}",
|
|
};
|
|
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/event/0x0017880104e45520/action/config", stringify(payload), {
|
|
retain: true,
|
|
qos: 1,
|
|
});
|
|
});
|
|
|
|
it("Should republish payload to postfix topic with lightWithPostfix config", async () => {
|
|
mockMQTTPublishAsync.mockClear();
|
|
|
|
await mockMQTTEvents.message("zigbee2mqtt/U202DST600ZB/l2/set", stringify({state: "ON", brightness: 20}));
|
|
await flushPromises();
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
|
"zigbee2mqtt/U202DST600ZB",
|
|
stringify({
|
|
state_l2: "ON",
|
|
brightness_l2: 20,
|
|
linkquality: null,
|
|
state_l1: null,
|
|
effect_l1: null,
|
|
effect_l2: null,
|
|
power_on_behavior_l1: null,
|
|
power_on_behavior_l2: null,
|
|
}),
|
|
{qos: 0, retain: false},
|
|
);
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
|
"zigbee2mqtt/U202DST600ZB/l2",
|
|
stringify({state: "ON", brightness: 20, effect: null, power_on_behavior: null}),
|
|
{},
|
|
);
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
|
"zigbee2mqtt/U202DST600ZB/l1",
|
|
stringify({state: null, effect: null, power_on_behavior: null}),
|
|
{},
|
|
);
|
|
});
|
|
|
|
it("Shouldnt crash in onPublishEntityState on group publish", async () => {
|
|
mockLogger.error.mockClear();
|
|
mockMQTTPublishAsync.mockClear();
|
|
const group = groups.group_1;
|
|
group.members.push(devices.bulb_color.getEndpoint(1)!);
|
|
|
|
await mockMQTTEvents.message("zigbee2mqtt/group_1/set", stringify({state: "ON"}));
|
|
await flushPromises();
|
|
expect(mockLogger.error).toHaveBeenCalledTimes(0);
|
|
group.members.pop();
|
|
});
|
|
|
|
it("Should clear outdated configs", async () => {
|
|
// Non-existing group -> clear
|
|
mockMQTTPublishAsync.mockClear();
|
|
await mockMQTTEvents.message(
|
|
"homeassistant/light/1221051039810110150109113116116_91231/light/config",
|
|
stringify({availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}]}),
|
|
);
|
|
await flushPromises();
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/light/1221051039810110150109113116116_91231/light/config", "", {
|
|
qos: 1,
|
|
retain: true,
|
|
});
|
|
|
|
// Existing group -> dont clear
|
|
mockMQTTPublishAsync.mockClear();
|
|
await mockMQTTEvents.message(
|
|
"homeassistant/light/1221051039810110150109113116116_9/light/config",
|
|
stringify({availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}]}),
|
|
);
|
|
await flushPromises();
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
|
|
|
|
// Existing group with old topic structure (1.20.0) -> clear
|
|
mockMQTTPublishAsync.mockClear();
|
|
await mockMQTTEvents.message(
|
|
"homeassistant/light/9/light/config",
|
|
stringify({availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}]}),
|
|
);
|
|
await flushPromises();
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/light/9/light/config", "", {qos: 1, retain: true});
|
|
|
|
// Existing group, non existing config -> clear
|
|
mockMQTTPublishAsync.mockClear();
|
|
await mockMQTTEvents.message(
|
|
"homeassistant/light/1221051039810110150109113116116_9/switch/config",
|
|
stringify({availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}]}),
|
|
);
|
|
await flushPromises();
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/light/1221051039810110150109113116116_9/switch/config", "", {
|
|
qos: 1,
|
|
retain: true,
|
|
});
|
|
|
|
// Non-existing device -> clear
|
|
mockMQTTPublishAsync.mockClear();
|
|
await mockMQTTEvents.message(
|
|
"homeassistant/sensor/0x123/temperature/config",
|
|
stringify({availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}]}),
|
|
);
|
|
await flushPromises();
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/sensor/0x123/temperature/config", "", {qos: 1, retain: true});
|
|
|
|
// Existing device -> don't clear
|
|
mockMQTTPublishAsync.mockClear();
|
|
await mockMQTTEvents.message(
|
|
"homeassistant/update/0x000b57fffec6a5b2/update/config",
|
|
stringify({availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}]}),
|
|
);
|
|
await flushPromises();
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
|
|
|
|
// Non-existing device of different instance -> don't clear
|
|
mockMQTTPublishAsync.mockClear();
|
|
await mockMQTTEvents.message(
|
|
"homeassistant/sensor/0x123/temperature/config",
|
|
stringify({availability: [{topic: "zigbee2mqtt_different/bridge/state"}]}),
|
|
);
|
|
await flushPromises();
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
|
|
|
|
// Existing device but non-existing config -> don't clear
|
|
mockMQTTPublishAsync.mockClear();
|
|
await mockMQTTEvents.message(
|
|
"homeassistant/sensor/0x000b57fffec6a5b2/update/config",
|
|
stringify({availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}]}),
|
|
);
|
|
await flushPromises();
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1);
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/sensor/0x000b57fffec6a5b2/update/config", "", {qos: 1, retain: true});
|
|
|
|
// Non-existing device but invalid payload -> clear
|
|
mockMQTTPublishAsync.mockClear();
|
|
await mockMQTTEvents.message("homeassistant/sensor/0x123/temperature/config", "1}3");
|
|
await flushPromises();
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
|
|
|
|
// Existing device, device automation -> don't clear
|
|
mockMQTTPublishAsync.mockClear();
|
|
await mockMQTTEvents.message(
|
|
"homeassistant/device_automation/0x000b57fffec6a5b2/action_button_3_single/config",
|
|
stringify({topic: "zigbee2mqtt/0x000b57fffec6a5b2/availability"}),
|
|
);
|
|
await flushPromises();
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
|
|
|
|
// Device automation of different instance -> don't clear
|
|
mockMQTTPublishAsync.mockClear();
|
|
await mockMQTTEvents.message(
|
|
"homeassistant/device_automation/0x000b57fffec6a5b2_not_existing/action_button_3_single/config",
|
|
stringify({topic: "zigbee2mqtt_different/0x000b57fffec6a5b2_not_existing/availability"}),
|
|
);
|
|
await flushPromises();
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
|
|
|
|
// Device was flagged to be excluded from homeassistant discovery
|
|
settings.set(["devices", "0x000b57fffec6a5b2", "homeassistant"], null);
|
|
await resetExtension();
|
|
mockMQTTPublishAsync.mockClear();
|
|
|
|
await mockMQTTEvents.message(
|
|
"homeassistant/update/0x000b57fffec6a5b2/update/config",
|
|
stringify({availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}]}),
|
|
);
|
|
await flushPromises();
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/update/0x000b57fffec6a5b2/update/config", "", {qos: 1, retain: true});
|
|
mockMQTTPublishAsync.mockClear();
|
|
await mockMQTTEvents.message(
|
|
"homeassistant/device_automation/0x000b57fffec6a5b2/action_button_3_single/config",
|
|
stringify({topic: "zigbee2mqtt/0x000b57fffec6a5b2/availability"}),
|
|
);
|
|
await flushPromises();
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/device_automation/0x000b57fffec6a5b2/action_button_3_single/config", "", {
|
|
qos: 1,
|
|
retain: true,
|
|
});
|
|
});
|
|
|
|
it("Should rediscover group when device is added to it", async () => {
|
|
resetDiscoveryPayloads("9");
|
|
mockMQTTPublishAsync.mockClear();
|
|
mockMQTTEvents.message(
|
|
"zigbee2mqtt/bridge/request/group/members/add",
|
|
stringify({group: "ha_discovery_group", device: "wall_switch_double", endpoint: "left"}),
|
|
);
|
|
await flushPromises();
|
|
|
|
const 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",
|
|
},
|
|
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,
|
|
});
|
|
});
|
|
|
|
it("Should discover with json availability payload value_template", () => {
|
|
const 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",
|
|
},
|
|
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,
|
|
});
|
|
});
|
|
|
|
it("Should discover with availability offline when device is disabled", async () => {
|
|
settings.set(["devices", "0x000b57fffec6a5b2", "disabled"], true);
|
|
|
|
await resetExtension();
|
|
|
|
const payload = {
|
|
availability: [
|
|
{
|
|
topic: "zigbee2mqtt/bridge/state",
|
|
value_template: `{{ "offline" }}`,
|
|
},
|
|
],
|
|
brightness: true,
|
|
brightness_scale: 254,
|
|
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"],
|
|
max_mireds: 454,
|
|
min_mireds: 250,
|
|
name: null,
|
|
schema: "json",
|
|
state_topic: "zigbee2mqtt/bulb",
|
|
supported_color_modes: ["color_temp"],
|
|
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,
|
|
});
|
|
});
|
|
|
|
it("Should discover last_seen when enabled", async () => {
|
|
settings.set(["advanced", "last_seen"], "ISO_8601");
|
|
await resetExtension();
|
|
|
|
const payload = {
|
|
availability: [
|
|
{
|
|
topic: "zigbee2mqtt/bridge/state",
|
|
value_template: "{{ value_json.state }}",
|
|
},
|
|
],
|
|
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",
|
|
},
|
|
enabled_by_default: false,
|
|
icon: "mdi:clock",
|
|
name: "Last seen",
|
|
state_topic: "zigbee2mqtt/bulb",
|
|
object_id: "bulb_last_seen",
|
|
default_entity_id: "sensor.bulb_last_seen",
|
|
unique_id: "0x000b57fffec6a5b2_last_seen_zigbee2mqtt",
|
|
origin: origin,
|
|
value_template: "{{ value_json.last_seen }}",
|
|
device_class: "timestamp",
|
|
entity_category: "diagnostic",
|
|
};
|
|
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/sensor/0x000b57fffec6a5b2/last_seen/config", stringify(payload), {
|
|
retain: true,
|
|
qos: 1,
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
// Windfront includes the instance ID in the URL.
|
|
["zigbee2mqtt-windfront", "http://zigbee.mqtt/#/device/0/0x0017880104e45522/info"],
|
|
["zigbee2mqtt-frontend", "http://zigbee.mqtt/#/device/0x0017880104e45522/info"],
|
|
])("Should discover devices with configuration url (%s)", async (packageName: string, expectedUrl: string) => {
|
|
settings.set(["frontend", "package"], packageName);
|
|
settings.set(["frontend", "url"], "http://zigbee.mqtt");
|
|
|
|
await resetExtension();
|
|
await flushPromises();
|
|
|
|
const payload = {
|
|
unit_of_measurement: "°C",
|
|
device_class: "temperature",
|
|
state_class: "measurement",
|
|
enabled_by_default: true,
|
|
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",
|
|
configuration_url: expectedUrl,
|
|
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
|
},
|
|
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
|
};
|
|
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/sensor/0x0017880104e45522/temperature/config", stringify(payload), {
|
|
retain: true,
|
|
qos: 1,
|
|
});
|
|
});
|
|
|
|
it("Should rediscover scenes when a scene is changed", async () => {
|
|
// Device/endpoint scenes.
|
|
const device = getZ2MEntity(devices.bulb_color_2)! as Device;
|
|
assert("ieeeAddr" in device);
|
|
resetDiscoveryPayloads(device.ieeeAddr);
|
|
|
|
mockMQTTPublishAsync.mockClear();
|
|
controller.eventBus.emitScenesChanged({entity: device});
|
|
await flushPromises();
|
|
|
|
// Discovery messages for scenes have been purged.
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/scene/0x000b57fffec6a5b4/scene_1/config", "", {retain: true, qos: 1});
|
|
await vi.runOnlyPendingTimersAsync();
|
|
await flushPromises();
|
|
|
|
let payload: KeyValueAny = {
|
|
name: "Chill scene",
|
|
command_topic: "zigbee2mqtt/bulb_color_2/set",
|
|
payload_on: '{ "scene_recall": 1 }',
|
|
object_id: "bulb_color_2_1_chill_scene",
|
|
default_entity_id: "scene.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",
|
|
model_id: "7146060PH",
|
|
manufacturer: "Philips",
|
|
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
|
},
|
|
origin: origin,
|
|
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
|
};
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/scene/0x000b57fffec6a5b4/scene_1/config", stringify(payload), {
|
|
retain: true,
|
|
qos: 1,
|
|
});
|
|
|
|
// Group scenes.
|
|
const group = getZ2MEntity("ha_discovery_group") as Group;
|
|
resetDiscoveryPayloads("9");
|
|
|
|
mockMQTTPublishAsync.mockClear();
|
|
controller.eventBus.emitScenesChanged({entity: group});
|
|
await flushPromises();
|
|
|
|
// Discovery messages for scenes have been purged.
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/scene/1221051039810110150109113116116_9/scene_4/config", "", {
|
|
retain: true,
|
|
qos: 1,
|
|
});
|
|
await vi.runOnlyPendingTimersAsync();
|
|
await flushPromises();
|
|
|
|
payload = {
|
|
name: "Scene 4",
|
|
command_topic: "zigbee2mqtt/ha_discovery_group/set",
|
|
payload_on: '{ "scene_recall": 4 }',
|
|
object_id: "ha_discovery_group_4_scene_4",
|
|
default_entity_id: "scene.ha_discovery_group_4_scene_4",
|
|
unique_id: "9_scene_4_zigbee2mqtt",
|
|
device: {
|
|
identifiers: ["zigbee2mqtt_1221051039810110150109113116116_9"],
|
|
name: "ha_discovery_group",
|
|
sw_version: version,
|
|
model: "Group",
|
|
manufacturer: "Zigbee2MQTT",
|
|
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
|
},
|
|
origin: origin,
|
|
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
|
};
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
|
"homeassistant/scene/1221051039810110150109113116116_9/scene_4/config",
|
|
stringify(payload),
|
|
{retain: true, qos: 1},
|
|
);
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(7);
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/health", expect.any(String), expect.any(Object));
|
|
});
|
|
|
|
it("Should not clear bridge entities unnecessarily", async () => {
|
|
mockMQTTPublishAsync.mockClear();
|
|
|
|
const topic = "homeassistant/button/1221051039810110150109113116116_0x00124b00120144ae/restart/config";
|
|
const payload = {
|
|
name: "Restart",
|
|
object_id: "zigbee2mqtt_bridge_restart",
|
|
default_entity_id: "light.zigbee2mqtt_bridge_restart",
|
|
unique_id: "bridge_0x00124b00120144ae_restart_zigbee2mqtt",
|
|
device_class: "restart",
|
|
command_topic: "zigbee2mqtt/bridge/request/restart",
|
|
payload_press: "",
|
|
origin: origin,
|
|
device: {
|
|
name: "Zigbee2MQTT Bridge",
|
|
identifiers: ["zigbee2mqtt_bridge_0x00124b00120144ae"],
|
|
manufacturer: "Zigbee2MQTT",
|
|
model: "Bridge",
|
|
hw_version: "z-Stack 20190425",
|
|
sw_version: z2m_version,
|
|
},
|
|
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
|
availability_mode: "all",
|
|
};
|
|
|
|
controller.eventBus.emitMQTTMessage({
|
|
topic: topic,
|
|
message: stringify(payload),
|
|
});
|
|
await flushPromises();
|
|
|
|
expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith(topic, "", {retain: true, qos: 1});
|
|
});
|
|
|
|
it("Should discover bridge entities", () => {
|
|
const devicePayload = {
|
|
name: "Zigbee2MQTT Bridge",
|
|
identifiers: ["zigbee2mqtt_bridge_0x00124b00120144ae"],
|
|
manufacturer: "Zigbee2MQTT",
|
|
model: "Bridge",
|
|
hw_version: "z-Stack 20190425",
|
|
sw_version: z2m_version,
|
|
};
|
|
|
|
// Binary sensors.
|
|
let payload;
|
|
payload = {
|
|
name: "Connection state",
|
|
object_id: "zigbee2mqtt_bridge_connection_state",
|
|
default_entity_id: "binary_sensor.zigbee2mqtt_bridge_connection_state",
|
|
entity_category: "diagnostic",
|
|
device_class: "connectivity",
|
|
unique_id: "bridge_0x00124b00120144ae_connection_state_zigbee2mqtt",
|
|
state_topic: "zigbee2mqtt/bridge/state",
|
|
value_template: "{{ value_json.state }}",
|
|
payload_on: "online",
|
|
payload_off: "offline",
|
|
origin: origin,
|
|
device: devicePayload,
|
|
};
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
|
"homeassistant/binary_sensor/1221051039810110150109113116116_0x00124b00120144ae/connection_state/config",
|
|
stringify(payload),
|
|
{retain: true, qos: 1},
|
|
);
|
|
|
|
payload = {
|
|
name: "Restart required",
|
|
object_id: "zigbee2mqtt_bridge_restart_required",
|
|
default_entity_id: "binary_sensor.zigbee2mqtt_bridge_restart_required",
|
|
entity_category: "diagnostic",
|
|
device_class: "problem",
|
|
enabled_by_default: false,
|
|
unique_id: "bridge_0x00124b00120144ae_restart_required_zigbee2mqtt",
|
|
state_topic: "zigbee2mqtt/bridge/info",
|
|
value_template: "{{ value_json.restart_required }}",
|
|
payload_on: true,
|
|
payload_off: false,
|
|
origin: origin,
|
|
device: devicePayload,
|
|
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
|
availability_mode: "all",
|
|
};
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
|
"homeassistant/binary_sensor/1221051039810110150109113116116_0x00124b00120144ae/restart_required/config",
|
|
stringify(payload),
|
|
{retain: true, qos: 1},
|
|
);
|
|
|
|
// Buttons.
|
|
payload = {
|
|
name: "Restart",
|
|
object_id: "zigbee2mqtt_bridge_restart",
|
|
default_entity_id: "button.zigbee2mqtt_bridge_restart",
|
|
unique_id: "bridge_0x00124b00120144ae_restart_zigbee2mqtt",
|
|
device_class: "restart",
|
|
command_topic: "zigbee2mqtt/bridge/request/restart",
|
|
payload_press: "",
|
|
origin: origin,
|
|
device: devicePayload,
|
|
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
|
availability_mode: "all",
|
|
};
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
|
"homeassistant/button/1221051039810110150109113116116_0x00124b00120144ae/restart/config",
|
|
stringify(payload),
|
|
{retain: true, qos: 1},
|
|
);
|
|
|
|
// Selects.
|
|
payload = {
|
|
name: "Log level",
|
|
object_id: "zigbee2mqtt_bridge_log_level",
|
|
default_entity_id: "select.zigbee2mqtt_bridge_log_level",
|
|
entity_category: "config",
|
|
unique_id: "bridge_0x00124b00120144ae_log_level_zigbee2mqtt",
|
|
state_topic: "zigbee2mqtt/bridge/info",
|
|
value_template: "{{ value_json.log_level | lower }}",
|
|
command_topic: "zigbee2mqtt/bridge/request/options",
|
|
command_template: '{"options": {"advanced": {"log_level": "{{ value }}" } } }',
|
|
options: settings.LOG_LEVELS,
|
|
origin: origin,
|
|
device: devicePayload,
|
|
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
|
availability_mode: "all",
|
|
};
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
|
"homeassistant/select/1221051039810110150109113116116_0x00124b00120144ae/log_level/config",
|
|
stringify(payload),
|
|
{retain: true, qos: 1},
|
|
);
|
|
|
|
// Sensors.
|
|
payload = {
|
|
name: "Version",
|
|
object_id: "zigbee2mqtt_bridge_version",
|
|
default_entity_id: "sensor.zigbee2mqtt_bridge_version",
|
|
entity_category: "diagnostic",
|
|
icon: "mdi:zigbee",
|
|
unique_id: "bridge_0x00124b00120144ae_version_zigbee2mqtt",
|
|
state_topic: "zigbee2mqtt/bridge/info",
|
|
value_template: "{{ value_json.version }}",
|
|
origin: origin,
|
|
device: devicePayload,
|
|
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
|
availability_mode: "all",
|
|
};
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
|
"homeassistant/sensor/1221051039810110150109113116116_0x00124b00120144ae/version/config",
|
|
stringify(payload),
|
|
{retain: true, qos: 1},
|
|
);
|
|
|
|
payload = {
|
|
name: "Coordinator version",
|
|
object_id: "zigbee2mqtt_bridge_coordinator_version",
|
|
default_entity_id: "sensor.zigbee2mqtt_bridge_coordinator_version",
|
|
entity_category: "diagnostic",
|
|
enabled_by_default: false,
|
|
icon: "mdi:chip",
|
|
unique_id: "bridge_0x00124b00120144ae_coordinator_version_zigbee2mqtt",
|
|
state_topic: "zigbee2mqtt/bridge/info",
|
|
value_template: "{{ value_json.coordinator.meta.revision }}",
|
|
origin: origin,
|
|
device: devicePayload,
|
|
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
|
availability_mode: "all",
|
|
};
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
|
"homeassistant/sensor/1221051039810110150109113116116_0x00124b00120144ae/coordinator_version/config",
|
|
stringify(payload),
|
|
{retain: true, qos: 1},
|
|
);
|
|
|
|
payload = {
|
|
name: "Network map",
|
|
object_id: "zigbee2mqtt_bridge_network_map",
|
|
default_entity_id: "sensor.zigbee2mqtt_bridge_network_map",
|
|
entity_category: "diagnostic",
|
|
enabled_by_default: false,
|
|
unique_id: "bridge_0x00124b00120144ae_network_map_zigbee2mqtt",
|
|
state_topic: "zigbee2mqtt/bridge/response/networkmap",
|
|
value_template: "{{ now().strftime('%Y-%m-%d %H:%M:%S') }}",
|
|
json_attributes_topic: "zigbee2mqtt/bridge/response/networkmap",
|
|
json_attributes_template: "{{ value_json.data.value | tojson }}",
|
|
origin: origin,
|
|
device: devicePayload,
|
|
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
|
availability_mode: "all",
|
|
};
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
|
"homeassistant/sensor/1221051039810110150109113116116_0x00124b00120144ae/network_map/config",
|
|
stringify(payload),
|
|
{retain: true, qos: 1},
|
|
);
|
|
|
|
// Switches.
|
|
payload = {
|
|
name: "Permit join",
|
|
object_id: "zigbee2mqtt_bridge_permit_join",
|
|
default_entity_id: "switch.zigbee2mqtt_bridge_permit_join",
|
|
icon: "mdi:human-greeting-proximity",
|
|
unique_id: "bridge_0x00124b00120144ae_permit_join_zigbee2mqtt",
|
|
state_topic: "zigbee2mqtt/bridge/info",
|
|
value_template: "{{ value_json.permit_join | lower }}",
|
|
command_topic: "zigbee2mqtt/bridge/request/permit_join",
|
|
state_on: "true",
|
|
state_off: "false",
|
|
payload_on: '{"time": 254}',
|
|
payload_off: '{"time": 0}',
|
|
origin: origin,
|
|
device: devicePayload,
|
|
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
|
availability_mode: "all",
|
|
};
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
|
|
"homeassistant/switch/1221051039810110150109113116116_0x00124b00120144ae/permit_join/config",
|
|
stringify(payload),
|
|
{retain: true, qos: 1},
|
|
);
|
|
});
|
|
|
|
it("Should remove discovery entries for removed exposes when device options change", async () => {
|
|
mockMQTTPublishAsync.mockClear();
|
|
mockMQTTEvents.message(
|
|
"zigbee2mqtt/bridge/request/device/options",
|
|
stringify({id: "0xf4ce368a38be56a1", options: {dimmer_1_enabled: "false", dimmer_1_dimming_enabled: "false"}}),
|
|
);
|
|
await flushPromises();
|
|
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/light/0xf4ce368a38be56a1/light_l2/config", "", {retain: true, qos: 1});
|
|
});
|
|
|
|
it("Should publish discovery message when a converter announces changed exposes", async () => {
|
|
mockMQTTPublishAsync.mockClear();
|
|
const device = devices["BMCT-SLZ"];
|
|
const data = {deviceMode: 0};
|
|
const msg = {data, cluster: "boschEnergyDevice", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
|
|
resetDiscoveryPayloads("0x18fc26000000cafe");
|
|
await mockZHEvents.message(msg);
|
|
await flushPromises();
|
|
const payload = {
|
|
availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}],
|
|
command_topic: "zigbee2mqtt/0x18fc26000000cafe/set/device_mode",
|
|
device: {
|
|
identifiers: ["zigbee2mqtt_0x18fc26000000cafe"],
|
|
manufacturer: "Bosch",
|
|
model: "Light/shutter control unit II",
|
|
model_id: "BMCT-SLZ",
|
|
name: "0x18fc26000000cafe",
|
|
via_device: "zigbee2mqtt_bridge_0x00124b00120144ae",
|
|
},
|
|
entity_category: "config",
|
|
icon: "mdi:tune",
|
|
name: "Device mode",
|
|
object_id: "0x18fc26000000cafe_device_mode",
|
|
default_entity_id: "select.0x18fc26000000cafe_device_mode",
|
|
options: ["light", "shutter", "disabled"],
|
|
origin: origin,
|
|
state_topic: "zigbee2mqtt/0x18fc26000000cafe",
|
|
unique_id: "0x18fc26000000cafe_device_mode_zigbee2mqtt",
|
|
value_template: '{{ value_json["device_mode"] }}',
|
|
};
|
|
expect(mockMQTTPublishAsync).toHaveBeenCalledWith("homeassistant/select/0x18fc26000000cafe/device_mode/config", stringify(payload), {
|
|
retain: true,
|
|
qos: 1,
|
|
});
|
|
});
|
|
|
|
it("Legacy action sensor", async () => {
|
|
settings.set(["homeassistant", "legacy_action_sensor"], true);
|
|
await resetExtension();
|
|
|
|
// Should discover action sensor as a diagnostic helper instead of a primary entity.
|
|
const actionDiscovery = mockMQTTPublishAsync.mock.calls.find(([topic]) => topic === "homeassistant/sensor/0x0017880104e45520/action/config");
|
|
assert(actionDiscovery);
|
|
expect(JSON.parse(actionDiscovery[1])).toMatchObject({
|
|
entity_category: "diagnostic",
|
|
name: "Action",
|
|
object_id: "button_action",
|
|
});
|
|
expect(actionDiscovery[2]).toStrictEqual({retain: true, qos: 1});
|
|
|
|
// Should counter an action payload with an empty payload
|
|
mockMQTTPublishAsync.mockClear();
|
|
const device = devices.WXKG11LM;
|
|
const payload = {data: {onOff: 1}, cluster: "genOnOff", device, endpoint: device.getEndpoint(1), type: "attributeReport", linkquality: 10};
|
|
await mockZHEvents.message(payload);
|
|
await flushPromises();
|
|
expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual("zigbee2mqtt/button");
|
|
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[0][1])).toStrictEqual({
|
|
action: "single",
|
|
battery: null,
|
|
linkquality: null,
|
|
voltage: null,
|
|
power_outage_count: null,
|
|
device_temperature: null,
|
|
});
|
|
expect(mockMQTTPublishAsync.mock.calls[0][2]).toStrictEqual({qos: 0, retain: false});
|
|
expect(mockMQTTPublishAsync.mock.calls[1][0]).toStrictEqual("zigbee2mqtt/button");
|
|
expect(JSON.parse(mockMQTTPublishAsync.mock.calls[1][1])).toStrictEqual({
|
|
action: "",
|
|
battery: null,
|
|
linkquality: null,
|
|
voltage: null,
|
|
power_outage_count: null,
|
|
device_temperature: null,
|
|
});
|
|
expect(mockMQTTPublishAsync.mock.calls[1][2]).toStrictEqual({qos: 0, retain: false});
|
|
expect(mockMQTTPublishAsync.mock.calls[2][0]).toStrictEqual("homeassistant/device_automation/0x0017880104e45520/action_single/config");
|
|
expect(mockMQTTPublishAsync.mock.calls[3][0]).toStrictEqual("zigbee2mqtt/button/action");
|
|
});
|
|
|
|
it("prevents mismatching setting/extension state", async () => {
|
|
settings.set(["homeassistant", "enabled"], true);
|
|
await resetExtension();
|
|
|
|
await expect(async () => {
|
|
await controller.enableDisableExtension(false, "HomeAssistant");
|
|
}).rejects.toThrow("Tried to disable HomeAssistant extension enabled in settings");
|
|
|
|
settings.set(["homeassistant", "enabled"], false);
|
|
|
|
await expect(async () => {
|
|
await controller.enableDisableExtension(true, "HomeAssistant");
|
|
}).rejects.toThrow("Tried to enable HomeAssistant extension disabled in settings");
|
|
|
|
settings.set(["homeassistant", "enabled"], false);
|
|
await controller.enableDisableExtension(false, "HomeAssistant");
|
|
|
|
await vi.waitFor(() => controller.getExtension("HomeAssistant") === undefined);
|
|
});
|
|
});
|