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;
40 import org.openhab.core.types.StateDescription;
43 * Tests for {@link HomeAssistantThingHandler}
45 * @author Anton Kharuzhy - Initial contribution
47 @ExtendWith(MockitoExtension.class)
49 public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
50 private static final int SUBSCRIBE_TIMEOUT = 10000;
51 private static final int ATTRIBUTE_RECEIVE_TIMEOUT = 2000;
53 private static final List<String> CONFIG_TOPICS = Arrays.asList("climate/0x847127fffe11dd6a_climate_zigbee2mqtt",
54 "switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt",
56 "sensor/0x1111111111111111_test_sensor_zigbee2mqtt", "camera/0x1111111111111111_test_camera_zigbee2mqtt",
58 "cover/0x2222222222222222_test_cover_zigbee2mqtt", "fan/0x2222222222222222_test_fan_zigbee2mqtt",
59 "light/0x2222222222222222_test_light_zigbee2mqtt", "lock/0x2222222222222222_test_lock_zigbee2mqtt");
61 private static final List<String> MQTT_TOPICS = CONFIG_TOPICS.stream()
62 .map(AbstractHomeAssistantTests::configTopicToMqtt).collect(Collectors.toList());
64 private @Mock @NonNullByDefault({}) ThingHandlerCallback callbackMock;
65 private @NonNullByDefault({}) HomeAssistantThingHandler thingHandler;
66 private @NonNullByDefault({}) HomeAssistantThingHandler nonSpyThingHandler;
70 final var config = haThing.getConfiguration();
72 config.put(HandlerConfiguration.PROPERTY_BASETOPIC, HandlerConfiguration.DEFAULT_BASETOPIC);
73 config.put(HandlerConfiguration.PROPERTY_TOPICS, CONFIG_TOPICS);
75 when(callbackMock.getBridge(eq(BRIDGE_UID))).thenReturn(bridgeThing);
77 thingHandler = new HomeAssistantThingHandler(haThing, channelTypeProvider, stateDescriptionProvider,
78 channelTypeRegistry, transformationServiceProvider, SUBSCRIBE_TIMEOUT, ATTRIBUTE_RECEIVE_TIMEOUT);
79 thingHandler.setConnection(bridgeConnection);
80 thingHandler.setCallback(callbackMock);
81 nonSpyThingHandler = thingHandler;
82 thingHandler = spy(thingHandler);
86 public void testInitialize() {
88 thingHandler.initialize();
90 verify(callbackMock).statusUpdated(eq(haThing), any());
91 // Expect a call to the bridge status changed, the start, the propertiesChanged method
92 verify(thingHandler).bridgeStatusChanged(any());
93 verify(thingHandler, timeout(SUBSCRIBE_TIMEOUT)).start(any());
95 // Expect subscription on each topic from config
96 MQTT_TOPICS.forEach(t -> {
97 verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).subscribe(eq(t), any());
100 verify(thingHandler, never()).componentDiscovered(any(), any());
101 assertThat(haThing.getChannels().size(), CoreMatchers.is(0));
102 // Components discovered after messages in corresponding topics
103 var configTopic = "homeassistant/climate/0x847127fffe11dd6a_climate_zigbee2mqtt/config";
104 thingHandler.discoverComponents.processMessage(configTopic,
105 getResourceAsByteArray("component/configTS0601ClimateThermostat.json"));
106 verify(thingHandler, times(1)).componentDiscovered(eq(new HaID(configTopic)), any(Climate.class));
108 thingHandler.delayedProcessing.forceProcessNow();
109 assertThat(nonSpyThingHandler.getThing().getChannels().size(), CoreMatchers.is(6));
110 verify(stateDescriptionProvider, times(6)).setDescription(any(), any(StateDescription.class));
111 verify(channelTypeProvider, times(1)).putChannelGroupType(any());
113 configTopic = "homeassistant/switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt/config";
114 thingHandler.discoverComponents.processMessage(configTopic,
115 getResourceAsByteArray("component/configTS0601AutoLock.json"));
116 verify(thingHandler, times(2)).componentDiscovered(any(), any());
117 verify(thingHandler, times(1)).componentDiscovered(eq(new HaID(configTopic)), any(Switch.class));
119 thingHandler.delayedProcessing.forceProcessNow();
120 assertThat(nonSpyThingHandler.getThing().getChannels().size(), CoreMatchers.is(7));
121 verify(stateDescriptionProvider, atLeast(7)).setDescription(any(), any(StateDescription.class));
122 verify(channelTypeProvider, times(3)).putChannelGroupType(any());
126 * Test where the same component is published twice to MQTT. The binding should handle this.
128 * @throws InterruptedException
131 public void testDuplicateComponentPublish() throws InterruptedException {
132 thingHandler.initialize();
134 verify(callbackMock).statusUpdated(eq(haThing), any());
135 // Expect a call to the bridge status changed, the start, the propertiesChanged method
136 verify(thingHandler).bridgeStatusChanged(any());
137 verify(thingHandler, timeout(SUBSCRIBE_TIMEOUT)).start(any());
139 // Expect subscription on each topic from config
140 MQTT_TOPICS.forEach(t -> {
141 verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).subscribe(eq(t), any());
144 verify(thingHandler, never()).componentDiscovered(any(), any());
145 assertThat(haThing.getChannels().size(), CoreMatchers.is(0));
149 // Publish sensor components with identical payload except for
150 // change in "name" field. The binding should respect the latest discovery result.
152 // This simulates how multiple OpenMQTTGateway devices would publish
153 // the same discovery topics for a particular Bluetooth sensor, and thus "competing" with similar but slightly
154 // different discovery topics.
156 // In fact, only difference is actually "via_device" additional metadata field telling which OpenMQTTGateway
157 // published the discovery topic.
162 // 1. publish corridor temperature sensor
164 var configTopicTempCorridor = "homeassistant/sensor/tempCorridor/config";
165 thingHandler.discoverComponents.processMessage(configTopicTempCorridor, new String("{"//
166 + "\"temperature_state_topic\": \"+/+/BTtoMQTT/mysensor\","//
167 + "\"temperature_state_template\": \"{{ value_json.temperature }}\", "//
168 + "\"name\": \"CorridorTemp\", "//
169 + "\"unit_of_measurement\": \"°C\" "//
170 + "}").getBytes(StandardCharsets.UTF_8));
171 verify(thingHandler, times(1)).componentDiscovered(eq(new HaID(configTopicTempCorridor)), any(Sensor.class));
172 thingHandler.delayedProcessing.forceProcessNow();
173 waitForAssert(() -> {
174 assertThat("1 channel created", nonSpyThingHandler.getThing().getChannels().size() == 1);
178 // 2. publish outside temperature sensor
180 var configTopicTempOutside = "homeassistant/sensor/tempOutside/config";
181 thingHandler.discoverComponents.processMessage(configTopicTempOutside, new String("{"//
182 + "\"temperature_state_topic\": \"+/+/BTtoMQTT/mysensor\","//
183 + "\"temperature_state_template\": \"{{ value_json.temperature }}\", " //
184 + "\"name\": \"OutsideTemp\", "//
185 + "\"source\": \"gateway2\" "//
186 + "}").getBytes(StandardCharsets.UTF_8));
187 thingHandler.delayedProcessing.forceProcessNow();
188 verify(thingHandler, times(1)).componentDiscovered(eq(new HaID(configTopicTempOutside)), any(Sensor.class));
189 waitForAssert(() -> {
190 assertThat("2 channel created", nonSpyThingHandler.getThing().getChannels().size() == 2);
194 // 3. publish corridor temperature sensor, this time with different name (openHAB channel label)
196 thingHandler.discoverComponents.processMessage(configTopicTempCorridor, new String("{"//
197 + "\"temperature_state_topic\": \"+/+/BTtoMQTT/mysensor\","//
198 + "\"temperature_state_template\": \"{{ value_json.temperature }}\", "//
199 + "\"name\": \"CorridorTemp NEW\", "//
200 + "\"unit_of_measurement\": \"°C\" "//
201 + "}").getBytes(StandardCharsets.UTF_8));
202 thingHandler.delayedProcessing.forceProcessNow();
204 waitForAssert(() -> {
205 assertThat("2 channel created", nonSpyThingHandler.getThing().getChannels().size() == 2);
209 // verify that both channels are there and the label corresponds to newer discovery topic payload
211 Channel corridorTempChannel = nonSpyThingHandler.getThing().getChannel("tempCorridor_5Fsensor#sensor");
212 assertThat("Corridor temperature channel is created", corridorTempChannel, CoreMatchers.notNullValue());
213 Objects.requireNonNull(corridorTempChannel); // for compiler
214 assertThat("Corridor temperature channel is having the updated label from 2nd discovery topic publish",
215 corridorTempChannel.getLabel(), CoreMatchers.is("CorridorTemp NEW"));
217 Channel outsideTempChannel = nonSpyThingHandler.getThing().getChannel("tempOutside_5Fsensor#sensor");
218 assertThat("Outside temperature channel is created", outsideTempChannel, CoreMatchers.notNullValue());
220 verify(thingHandler, times(2)).componentDiscovered(eq(new HaID(configTopicTempCorridor)), any(Sensor.class));
222 waitForAssert(() -> {
223 assertThat("2 channel created", nonSpyThingHandler.getThing().getChannels().size() == 2);
228 public void testDispose() {
229 thingHandler.initialize();
231 // Expect subscription on each topic from config
232 CONFIG_TOPICS.forEach(t -> {
233 var fullTopic = HandlerConfiguration.DEFAULT_BASETOPIC + "/" + t + "/config";
234 verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).subscribe(eq(fullTopic), any());
236 thingHandler.discoverComponents.processMessage(
237 "homeassistant/climate/0x847127fffe11dd6a_climate_zigbee2mqtt/config",
238 getResourceAsByteArray("component/configTS0601ClimateThermostat.json"));
239 thingHandler.discoverComponents.processMessage(
240 "homeassistant/switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt/config",
241 getResourceAsByteArray("component/configTS0601AutoLock.json"));
242 thingHandler.delayedProcessing.forceProcessNow();
243 assertThat(nonSpyThingHandler.getThing().getChannels().size(), CoreMatchers.is(7));
244 verify(stateDescriptionProvider, atLeast(7)).setDescription(any(), any(StateDescription.class));
247 thingHandler.dispose();
249 // Expect unsubscription on each topic from config
250 MQTT_TOPICS.forEach(t -> {
251 verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).unsubscribe(eq(t), any());
256 public void testRemoveThing() {
257 thingHandler.initialize();
259 // Expect subscription on each topic from config
260 CONFIG_TOPICS.forEach(t -> {
261 var fullTopic = HandlerConfiguration.DEFAULT_BASETOPIC + "/" + t + "/config";
262 verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).subscribe(eq(fullTopic), any());
264 thingHandler.discoverComponents.processMessage(
265 "homeassistant/climate/0x847127fffe11dd6a_climate_zigbee2mqtt/config",
266 getResourceAsByteArray("component/configTS0601ClimateThermostat.json"));
267 thingHandler.discoverComponents.processMessage(
268 "homeassistant/switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt/config",
269 getResourceAsByteArray("component/configTS0601AutoLock.json"));
270 thingHandler.delayedProcessing.forceProcessNow();
271 assertThat(nonSpyThingHandler.getThing().getChannels().size(), CoreMatchers.is(7));
274 nonSpyThingHandler.handleRemoval();
276 // Expect channel descriptions removed, 6 for climate and 1 for switch
277 verify(stateDescriptionProvider, times(7)).remove(any());
278 // Expect channel group types removed, 1 for each component
279 verify(channelTypeProvider, times(2)).removeChannelGroupType(any());
283 public void testProcessMessageFromUnsupportedComponent() {
284 thingHandler.initialize();
285 thingHandler.discoverComponents.processMessage("homeassistant/unsupportedType/id_zigbee2mqtt/config",
286 "{}".getBytes(StandardCharsets.UTF_8));
287 // Ignore unsupported component
288 thingHandler.delayedProcessing.forceProcessNow();
289 assertThat(haThing.getChannels().size(), CoreMatchers.is(0));
293 public void testProcessMessageWithEmptyConfig() {
294 thingHandler.initialize();
295 thingHandler.discoverComponents.processMessage("homeassistant/sensor/id_zigbee2mqtt/config",
296 "".getBytes(StandardCharsets.UTF_8));
297 // Ignore component with empty config
298 thingHandler.delayedProcessing.forceProcessNow();
299 assertThat(haThing.getChannels().size(), CoreMatchers.is(0));
303 public void testProcessMessageWithBadFormatConfig() {
304 thingHandler.initialize();
305 thingHandler.discoverComponents.processMessage("homeassistant/sensor/id_zigbee2mqtt/config",
306 "{bad format}}".getBytes(StandardCharsets.UTF_8));
307 // Ignore component with bad format config
308 thingHandler.delayedProcessing.forceProcessNow();
309 assertThat(haThing.getChannels().size(), CoreMatchers.is(0));