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;
42 import com.hubspot.jinjava.Jinjava;
45 * Tests for {@link HomeAssistantThingHandler}
47 * @author Anton Kharuzhy - Initial contribution
49 @ExtendWith(MockitoExtension.class)
51 public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
52 private static final int SUBSCRIBE_TIMEOUT = 10000;
53 private static final int ATTRIBUTE_RECEIVE_TIMEOUT = 2000;
55 private static final List<String> CONFIG_TOPICS = Arrays.asList("climate/0x847127fffe11dd6a_climate_zigbee2mqtt",
56 "switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt",
58 "sensor/0x1111111111111111_test_sensor_zigbee2mqtt", "camera/0x1111111111111111_test_camera_zigbee2mqtt",
60 "cover/0x2222222222222222_test_cover_zigbee2mqtt", "fan/0x2222222222222222_test_fan_zigbee2mqtt",
61 "light/0x2222222222222222_test_light_zigbee2mqtt", "lock/0x2222222222222222_test_lock_zigbee2mqtt");
63 private static final List<String> MQTT_TOPICS = CONFIG_TOPICS.stream()
64 .map(AbstractHomeAssistantTests::configTopicToMqtt).collect(Collectors.toList());
66 private @Mock @NonNullByDefault({}) ThingHandlerCallback callbackMock;
67 private @NonNullByDefault({}) HomeAssistantThingHandler thingHandler;
68 private @NonNullByDefault({}) HomeAssistantThingHandler nonSpyThingHandler;
72 final var config = haThing.getConfiguration();
74 config.put(HandlerConfiguration.PROPERTY_BASETOPIC, HandlerConfiguration.DEFAULT_BASETOPIC);
75 config.put(HandlerConfiguration.PROPERTY_TOPICS, CONFIG_TOPICS);
77 when(callbackMock.getBridge(eq(BRIDGE_UID))).thenReturn(bridgeThing);
79 thingHandler = new HomeAssistantThingHandler(haThing, channelTypeProvider, stateDescriptionProvider,
80 channelTypeRegistry, new Jinjava(), SUBSCRIBE_TIMEOUT, ATTRIBUTE_RECEIVE_TIMEOUT);
81 thingHandler.setConnection(bridgeConnection);
82 thingHandler.setCallback(callbackMock);
83 nonSpyThingHandler = thingHandler;
84 thingHandler = spy(thingHandler);
88 public void testInitialize() {
90 thingHandler.initialize();
92 verify(callbackMock).statusUpdated(eq(haThing), any());
93 // Expect a call to the bridge status changed, the start, the propertiesChanged method
94 verify(thingHandler).bridgeStatusChanged(any());
95 verify(thingHandler, timeout(SUBSCRIBE_TIMEOUT)).start(any());
97 // Expect subscription on each topic from config
98 MQTT_TOPICS.forEach(t -> {
99 verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).subscribe(eq(t), any());
102 verify(thingHandler, never()).componentDiscovered(any(), any());
103 assertThat(haThing.getChannels().size(), CoreMatchers.is(0));
104 // Components discovered after messages in corresponding topics
105 var configTopic = "homeassistant/climate/0x847127fffe11dd6a_climate_zigbee2mqtt/config";
106 thingHandler.discoverComponents.processMessage(configTopic,
107 getResourceAsByteArray("component/configTS0601ClimateThermostat.json"));
108 verify(thingHandler, times(1)).componentDiscovered(eq(new HaID(configTopic)), any(Climate.class));
110 thingHandler.delayedProcessing.forceProcessNow();
111 assertThat(nonSpyThingHandler.getThing().getChannels().size(), CoreMatchers.is(6));
112 verify(stateDescriptionProvider, times(6)).setDescription(any(), any(StateDescription.class));
113 verify(channelTypeProvider, times(1)).putChannelGroupType(any());
115 configTopic = "homeassistant/switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt/config";
116 thingHandler.discoverComponents.processMessage(configTopic,
117 getResourceAsByteArray("component/configTS0601AutoLock.json"));
118 verify(thingHandler, times(2)).componentDiscovered(any(), any());
119 verify(thingHandler, times(1)).componentDiscovered(eq(new HaID(configTopic)), any(Switch.class));
121 thingHandler.delayedProcessing.forceProcessNow();
122 assertThat(nonSpyThingHandler.getThing().getChannels().size(), CoreMatchers.is(7));
123 verify(stateDescriptionProvider, atLeast(7)).setDescription(any(), any(StateDescription.class));
124 verify(channelTypeProvider, times(3)).putChannelGroupType(any());
128 * Test where the same component is published twice to MQTT. The binding should handle this.
130 * @throws InterruptedException
133 public void testDuplicateComponentPublish() throws InterruptedException {
134 thingHandler.initialize();
136 verify(callbackMock).statusUpdated(eq(haThing), any());
137 // Expect a call to the bridge status changed, the start, the propertiesChanged method
138 verify(thingHandler).bridgeStatusChanged(any());
139 verify(thingHandler, timeout(SUBSCRIBE_TIMEOUT)).start(any());
141 // Expect subscription on each topic from config
142 MQTT_TOPICS.forEach(t -> {
143 verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).subscribe(eq(t), any());
146 verify(thingHandler, never()).componentDiscovered(any(), any());
147 assertThat(haThing.getChannels().size(), CoreMatchers.is(0));
151 // Publish sensor components with identical payload except for
152 // change in "name" field. The binding should respect the latest discovery result.
154 // This simulates how multiple OpenMQTTGateway devices would publish
155 // the same discovery topics for a particular Bluetooth sensor, and thus "competing" with similar but slightly
156 // different discovery topics.
158 // In fact, only difference is actually "via_device" additional metadata field telling which OpenMQTTGateway
159 // published the discovery topic.
164 // 1. publish corridor temperature sensor
166 var configTopicTempCorridor = "homeassistant/sensor/tempCorridor/config";
167 thingHandler.discoverComponents.processMessage(configTopicTempCorridor, new String("{"//
168 + "\"temperature_state_topic\": \"+/+/BTtoMQTT/mysensor\","//
169 + "\"temperature_state_template\": \"{{ value_json.temperature }}\", "//
170 + "\"name\": \"CorridorTemp\", "//
171 + "\"unit_of_measurement\": \"°C\" "//
172 + "}").getBytes(StandardCharsets.UTF_8));
173 verify(thingHandler, times(1)).componentDiscovered(eq(new HaID(configTopicTempCorridor)), any(Sensor.class));
174 thingHandler.delayedProcessing.forceProcessNow();
175 waitForAssert(() -> {
176 assertThat("1 channel created", nonSpyThingHandler.getThing().getChannels().size() == 1);
180 // 2. publish outside temperature sensor
182 var configTopicTempOutside = "homeassistant/sensor/tempOutside/config";
183 thingHandler.discoverComponents.processMessage(configTopicTempOutside, new String("{"//
184 + "\"temperature_state_topic\": \"+/+/BTtoMQTT/mysensor\","//
185 + "\"temperature_state_template\": \"{{ value_json.temperature }}\", " //
186 + "\"name\": \"OutsideTemp\", "//
187 + "\"source\": \"gateway2\" "//
188 + "}").getBytes(StandardCharsets.UTF_8));
189 thingHandler.delayedProcessing.forceProcessNow();
190 verify(thingHandler, times(1)).componentDiscovered(eq(new HaID(configTopicTempOutside)), any(Sensor.class));
191 waitForAssert(() -> {
192 assertThat("2 channel created", nonSpyThingHandler.getThing().getChannels().size() == 2);
196 // 3. publish corridor temperature sensor, this time with different name (openHAB channel label)
198 thingHandler.discoverComponents.processMessage(configTopicTempCorridor, new String("{"//
199 + "\"temperature_state_topic\": \"+/+/BTtoMQTT/mysensor\","//
200 + "\"temperature_state_template\": \"{{ value_json.temperature }}\", "//
201 + "\"name\": \"CorridorTemp NEW\", "//
202 + "\"unit_of_measurement\": \"°C\" "//
203 + "}").getBytes(StandardCharsets.UTF_8));
204 thingHandler.delayedProcessing.forceProcessNow();
206 waitForAssert(() -> {
207 assertThat("2 channel created", nonSpyThingHandler.getThing().getChannels().size() == 2);
211 // verify that both channels are there and the label corresponds to newer discovery topic payload
213 Channel corridorTempChannel = nonSpyThingHandler.getThing().getChannel("tempCorridor_5Fsensor#sensor");
214 assertThat("Corridor temperature channel is created", corridorTempChannel, CoreMatchers.notNullValue());
215 Objects.requireNonNull(corridorTempChannel); // for compiler
216 assertThat("Corridor temperature channel is having the updated label from 2nd discovery topic publish",
217 corridorTempChannel.getLabel(), CoreMatchers.is("CorridorTemp NEW"));
219 Channel outsideTempChannel = nonSpyThingHandler.getThing().getChannel("tempOutside_5Fsensor#sensor");
220 assertThat("Outside temperature channel is created", outsideTempChannel, CoreMatchers.notNullValue());
222 verify(thingHandler, times(2)).componentDiscovered(eq(new HaID(configTopicTempCorridor)), any(Sensor.class));
224 waitForAssert(() -> {
225 assertThat("2 channel created", nonSpyThingHandler.getThing().getChannels().size() == 2);
230 public void testDispose() {
231 thingHandler.initialize();
233 // Expect subscription on each topic from config
234 CONFIG_TOPICS.forEach(t -> {
235 var fullTopic = HandlerConfiguration.DEFAULT_BASETOPIC + "/" + t + "/config";
236 verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).subscribe(eq(fullTopic), any());
238 thingHandler.discoverComponents.processMessage(
239 "homeassistant/climate/0x847127fffe11dd6a_climate_zigbee2mqtt/config",
240 getResourceAsByteArray("component/configTS0601ClimateThermostat.json"));
241 thingHandler.discoverComponents.processMessage(
242 "homeassistant/switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt/config",
243 getResourceAsByteArray("component/configTS0601AutoLock.json"));
244 thingHandler.delayedProcessing.forceProcessNow();
245 assertThat(nonSpyThingHandler.getThing().getChannels().size(), CoreMatchers.is(7));
246 verify(stateDescriptionProvider, atLeast(7)).setDescription(any(), any(StateDescription.class));
249 thingHandler.dispose();
251 // Expect unsubscription on each topic from config
252 MQTT_TOPICS.forEach(t -> {
253 verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).unsubscribe(eq(t), any());
258 public void testRemoveThing() {
259 thingHandler.initialize();
261 // Expect subscription on each topic from config
262 CONFIG_TOPICS.forEach(t -> {
263 var fullTopic = HandlerConfiguration.DEFAULT_BASETOPIC + "/" + t + "/config";
264 verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).subscribe(eq(fullTopic), any());
266 thingHandler.discoverComponents.processMessage(
267 "homeassistant/climate/0x847127fffe11dd6a_climate_zigbee2mqtt/config",
268 getResourceAsByteArray("component/configTS0601ClimateThermostat.json"));
269 thingHandler.discoverComponents.processMessage(
270 "homeassistant/switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt/config",
271 getResourceAsByteArray("component/configTS0601AutoLock.json"));
272 thingHandler.delayedProcessing.forceProcessNow();
273 assertThat(nonSpyThingHandler.getThing().getChannels().size(), CoreMatchers.is(7));
276 nonSpyThingHandler.handleRemoval();
278 // Expect channel descriptions removed, 6 for climate and 1 for switch
279 verify(stateDescriptionProvider, times(7)).remove(any());
280 // Expect channel group types removed, 1 for each component
281 verify(channelTypeProvider, times(2)).removeChannelGroupType(any());
285 public void testProcessMessageFromUnsupportedComponent() {
286 thingHandler.initialize();
287 thingHandler.discoverComponents.processMessage("homeassistant/unsupportedType/id_zigbee2mqtt/config",
288 "{}".getBytes(StandardCharsets.UTF_8));
289 // Ignore unsupported component
290 thingHandler.delayedProcessing.forceProcessNow();
291 assertThat(haThing.getChannels().size(), CoreMatchers.is(0));
295 public void testProcessMessageWithEmptyConfig() {
296 thingHandler.initialize();
297 thingHandler.discoverComponents.processMessage("homeassistant/sensor/id_zigbee2mqtt/config",
298 "".getBytes(StandardCharsets.UTF_8));
299 // Ignore component with empty config
300 thingHandler.delayedProcessing.forceProcessNow();
301 assertThat(haThing.getChannels().size(), CoreMatchers.is(0));
305 public void testProcessMessageWithBadFormatConfig() {
306 thingHandler.initialize();
307 thingHandler.discoverComponents.processMessage("homeassistant/sensor/id_zigbee2mqtt/config",
308 "{bad format}}".getBytes(StandardCharsets.UTF_8));
309 // Ignore component with bad format config
310 thingHandler.delayedProcessing.forceProcessNow();
311 assertThat(haThing.getChannels().size(), CoreMatchers.is(0));