2 * Copyright (c) 2010-2024 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.mqtt.homeassistant.internal.handler;
15 import static org.hamcrest.MatcherAssert.assertThat;
16 import static org.mockito.ArgumentMatchers.*;
17 import static org.mockito.Mockito.*;
19 import java.nio.charset.StandardCharsets;
20 import java.util.Arrays;
21 import java.util.List;
22 import java.util.Objects;
23 import java.util.stream.Collectors;
25 import org.eclipse.jdt.annotation.NonNullByDefault;
26 import org.hamcrest.CoreMatchers;
27 import org.junit.jupiter.api.BeforeEach;
28 import org.junit.jupiter.api.Test;
29 import org.junit.jupiter.api.extension.ExtendWith;
30 import org.mockito.Mock;
31 import org.mockito.junit.jupiter.MockitoExtension;
32 import org.openhab.binding.mqtt.homeassistant.internal.AbstractHomeAssistantTests;
33 import org.openhab.binding.mqtt.homeassistant.internal.HaID;
34 import org.openhab.binding.mqtt.homeassistant.internal.HandlerConfiguration;
35 import org.openhab.binding.mqtt.homeassistant.internal.component.Climate;
36 import org.openhab.binding.mqtt.homeassistant.internal.component.Sensor;
37 import org.openhab.binding.mqtt.homeassistant.internal.component.Switch;
38 import org.openhab.core.thing.Channel;
39 import org.openhab.core.thing.binding.ThingHandlerCallback;
42 * Tests for {@link HomeAssistantThingHandler}
44 * @author Anton Kharuzhy - Initial contribution
46 @ExtendWith(MockitoExtension.class)
48 public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
49 private static final int SUBSCRIBE_TIMEOUT = 10000;
50 private static final int ATTRIBUTE_RECEIVE_TIMEOUT = 2000;
52 private static final List<String> CONFIG_TOPICS = Arrays.asList("climate/0x847127fffe11dd6a_climate_zigbee2mqtt",
53 "switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt",
55 "sensor/0x1111111111111111_test_sensor_zigbee2mqtt", "camera/0x1111111111111111_test_camera_zigbee2mqtt",
57 "cover/0x2222222222222222_test_cover_zigbee2mqtt", "fan/0x2222222222222222_test_fan_zigbee2mqtt",
58 "light/0x2222222222222222_test_light_zigbee2mqtt", "lock/0x2222222222222222_test_lock_zigbee2mqtt");
60 private static final List<String> MQTT_TOPICS = CONFIG_TOPICS.stream()
61 .map(AbstractHomeAssistantTests::configTopicToMqtt).collect(Collectors.toList());
63 private @Mock @NonNullByDefault({}) ThingHandlerCallback callbackMock;
64 private @NonNullByDefault({}) HomeAssistantThingHandler thingHandler;
65 private @NonNullByDefault({}) HomeAssistantThingHandler nonSpyThingHandler;
69 final var config = haThing.getConfiguration();
71 config.put(HandlerConfiguration.PROPERTY_BASETOPIC, HandlerConfiguration.DEFAULT_BASETOPIC);
72 config.put(HandlerConfiguration.PROPERTY_TOPICS, CONFIG_TOPICS);
74 when(callbackMock.getBridge(eq(BRIDGE_UID))).thenReturn(bridgeThing);
76 thingHandler = new HomeAssistantThingHandler(haThing, channelTypeProvider, transformationServiceProvider,
77 SUBSCRIBE_TIMEOUT, ATTRIBUTE_RECEIVE_TIMEOUT);
78 thingHandler.setConnection(bridgeConnection);
79 thingHandler.setCallback(callbackMock);
80 nonSpyThingHandler = thingHandler;
81 thingHandler = spy(thingHandler);
85 public void testInitialize() {
87 thingHandler.initialize();
89 verify(callbackMock).statusUpdated(eq(haThing), any());
90 // Expect a call to the bridge status changed, the start, the propertiesChanged method
91 verify(thingHandler).bridgeStatusChanged(any());
92 verify(thingHandler, timeout(SUBSCRIBE_TIMEOUT)).start(any());
94 // Expect subscription on each topic from config
95 MQTT_TOPICS.forEach(t -> {
96 verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).subscribe(eq(t), any());
99 verify(thingHandler, never()).componentDiscovered(any(), any());
100 assertThat(haThing.getChannels().size(), CoreMatchers.is(0));
101 // Components discovered after messages in corresponding topics
102 var configTopic = "homeassistant/climate/0x847127fffe11dd6a_climate_zigbee2mqtt/config";
103 thingHandler.discoverComponents.processMessage(configTopic,
104 getResourceAsByteArray("component/configTS0601ClimateThermostat.json"));
105 verify(thingHandler, times(1)).componentDiscovered(eq(new HaID(configTopic)), any(Climate.class));
107 thingHandler.delayedProcessing.forceProcessNow();
108 assertThat(haThing.getChannels().size(), CoreMatchers.is(6));
109 verify(channelTypeProvider, times(6)).setChannelType(any(), any());
110 verify(channelTypeProvider, times(1)).setChannelGroupType(any(), any());
112 configTopic = "homeassistant/switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt/config";
113 thingHandler.discoverComponents.processMessage(configTopic,
114 getResourceAsByteArray("component/configTS0601AutoLock.json"));
115 verify(thingHandler, times(2)).componentDiscovered(any(), any());
116 verify(thingHandler, times(1)).componentDiscovered(eq(new HaID(configTopic)), any(Switch.class));
118 thingHandler.delayedProcessing.forceProcessNow();
119 assertThat(haThing.getChannels().size(), CoreMatchers.is(7));
120 verify(channelTypeProvider, times(7)).setChannelType(any(), any());
121 verify(channelTypeProvider, times(2)).setChannelGroupType(any(), any());
125 * Test where the same component is published twice to MQTT. The binding should handle this.
127 * @throws InterruptedException
130 public void testDuplicateComponentPublish() throws InterruptedException {
131 thingHandler.initialize();
133 verify(callbackMock).statusUpdated(eq(haThing), any());
134 // Expect a call to the bridge status changed, the start, the propertiesChanged method
135 verify(thingHandler).bridgeStatusChanged(any());
136 verify(thingHandler, timeout(SUBSCRIBE_TIMEOUT)).start(any());
138 // Expect subscription on each topic from config
139 MQTT_TOPICS.forEach(t -> {
140 verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).subscribe(eq(t), any());
143 verify(thingHandler, never()).componentDiscovered(any(), any());
144 assertThat(haThing.getChannels().size(), CoreMatchers.is(0));
148 // Publish sensor components with identical payload except for
149 // change in "name" field. The binding should respect the latest discovery result.
151 // This simulates how multiple OpenMQTTGateway devices would publish
152 // the same discovery topics for a particular Bluetooth sensor, and thus "competing" with similar but slightly
153 // different discovery topics.
155 // In fact, only difference is actually "via_device" additional metadata field telling which OpenMQTTGateway
156 // published the discovery topic.
161 // 1. publish corridor temperature sensor
163 var configTopicTempCorridor = "homeassistant/sensor/tempCorridor/config";
164 thingHandler.discoverComponents.processMessage(configTopicTempCorridor, new String("{"//
165 + "\"temperature_state_topic\": \"+/+/BTtoMQTT/mysensor\","//
166 + "\"temperature_state_template\": \"{{ value_json.temperature }}\", "//
167 + "\"name\": \"CorridorTemp\", "//
168 + "\"unit_of_measurement\": \"°C\" "//
169 + "}").getBytes(StandardCharsets.UTF_8));
170 verify(thingHandler, times(1)).componentDiscovered(eq(new HaID(configTopicTempCorridor)), any(Sensor.class));
171 thingHandler.delayedProcessing.forceProcessNow();
172 waitForAssert(() -> {
173 assertThat("1 channel created", thingHandler.getThing().getChannels().size() == 1);
177 // 2. publish outside temperature sensor
179 var configTopicTempOutside = "homeassistant/sensor/tempOutside/config";
180 thingHandler.discoverComponents.processMessage(configTopicTempOutside, new String("{"//
181 + "\"temperature_state_topic\": \"+/+/BTtoMQTT/mysensor\","//
182 + "\"temperature_state_template\": \"{{ value_json.temperature }}\", " //
183 + "\"name\": \"OutsideTemp\", "//
184 + "\"source\": \"gateway2\" "//
185 + "}").getBytes(StandardCharsets.UTF_8));
186 thingHandler.delayedProcessing.forceProcessNow();
187 verify(thingHandler, times(1)).componentDiscovered(eq(new HaID(configTopicTempOutside)), any(Sensor.class));
188 waitForAssert(() -> {
189 assertThat("2 channel created", thingHandler.getThing().getChannels().size() == 2);
193 // 3. publish corridor temperature sensor, this time with different name (openHAB channel label)
195 thingHandler.discoverComponents.processMessage(configTopicTempCorridor, new String("{"//
196 + "\"temperature_state_topic\": \"+/+/BTtoMQTT/mysensor\","//
197 + "\"temperature_state_template\": \"{{ value_json.temperature }}\", "//
198 + "\"name\": \"CorridorTemp NEW\", "//
199 + "\"unit_of_measurement\": \"°C\" "//
200 + "}").getBytes(StandardCharsets.UTF_8));
201 thingHandler.delayedProcessing.forceProcessNow();
203 waitForAssert(() -> {
204 assertThat("2 channel created", thingHandler.getThing().getChannels().size() == 2);
208 // verify that both channels are there and the label corresponds to newer discovery topic payload
210 Channel corridorTempChannel = nonSpyThingHandler.getThing().getChannel("tempCorridor_5Fsensor#sensor");
211 assertThat("Corridor temperature channel is created", corridorTempChannel, CoreMatchers.notNullValue());
212 Objects.requireNonNull(corridorTempChannel); // for compiler
213 assertThat("Corridor temperature channel is having the updated label from 2nd discovery topic publish",
214 corridorTempChannel.getLabel(), CoreMatchers.is("CorridorTemp NEW"));
216 Channel outsideTempChannel = nonSpyThingHandler.getThing().getChannel("tempOutside_5Fsensor#sensor");
217 assertThat("Outside temperature channel is created", outsideTempChannel, CoreMatchers.notNullValue());
219 verify(thingHandler, times(2)).componentDiscovered(eq(new HaID(configTopicTempCorridor)), any(Sensor.class));
221 waitForAssert(() -> {
222 assertThat("2 channel created", thingHandler.getThing().getChannels().size() == 2);
227 public void testDispose() {
228 thingHandler.initialize();
230 // Expect subscription on each topic from config
231 CONFIG_TOPICS.forEach(t -> {
232 var fullTopic = HandlerConfiguration.DEFAULT_BASETOPIC + "/" + t + "/config";
233 verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).subscribe(eq(fullTopic), any());
235 thingHandler.discoverComponents.processMessage(
236 "homeassistant/climate/0x847127fffe11dd6a_climate_zigbee2mqtt/config",
237 getResourceAsByteArray("component/configTS0601ClimateThermostat.json"));
238 thingHandler.discoverComponents.processMessage(
239 "homeassistant/switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt/config",
240 getResourceAsByteArray("component/configTS0601AutoLock.json"));
241 thingHandler.delayedProcessing.forceProcessNow();
242 assertThat(haThing.getChannels().size(), CoreMatchers.is(7));
243 verify(channelTypeProvider, times(7)).setChannelType(any(), any());
246 thingHandler.dispose();
248 // Expect unsubscription on each topic from config
249 MQTT_TOPICS.forEach(t -> {
250 verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).unsubscribe(eq(t), any());
253 // Expect channel types removed, 6 for climate and 1 for switch
254 verify(channelTypeProvider, times(7)).removeChannelType(any());
255 // Expect channel group types removed, 1 for each component
256 verify(channelTypeProvider, times(2)).removeChannelGroupType(any());
260 public void testProcessMessageFromUnsupportedComponent() {
261 thingHandler.initialize();
262 thingHandler.discoverComponents.processMessage("homeassistant/unsupportedType/id_zigbee2mqtt/config",
263 "{}".getBytes(StandardCharsets.UTF_8));
264 // Ignore unsupported component
265 thingHandler.delayedProcessing.forceProcessNow();
266 assertThat(haThing.getChannels().size(), CoreMatchers.is(0));
270 public void testProcessMessageWithEmptyConfig() {
271 thingHandler.initialize();
272 thingHandler.discoverComponents.processMessage("homeassistant/sensor/id_zigbee2mqtt/config",
273 "".getBytes(StandardCharsets.UTF_8));
274 // Ignore component with empty config
275 thingHandler.delayedProcessing.forceProcessNow();
276 assertThat(haThing.getChannels().size(), CoreMatchers.is(0));
280 public void testProcessMessageWithBadFormatConfig() {
281 thingHandler.initialize();
282 thingHandler.discoverComponents.processMessage("homeassistant/sensor/id_zigbee2mqtt/config",
283 "{bad format}}".getBytes(StandardCharsets.UTF_8));
284 // Ignore component with bad format config
285 thingHandler.delayedProcessing.forceProcessNow();
286 assertThat(haThing.getChannels().size(), CoreMatchers.is(0));