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.CoreMatchers.is;
16 import static org.hamcrest.CoreMatchers.notNullValue;
17 import static org.hamcrest.MatcherAssert.assertThat;
18 import static org.mockito.ArgumentMatchers.*;
19 import static org.mockito.Mockito.*;
21 import java.nio.charset.StandardCharsets;
22 import java.util.Arrays;
23 import java.util.List;
24 import java.util.Objects;
25 import java.util.stream.Collectors;
27 import org.eclipse.jdt.annotation.NonNullByDefault;
28 import org.junit.jupiter.api.BeforeEach;
29 import org.junit.jupiter.api.Test;
30 import org.junit.jupiter.api.extension.ExtendWith;
31 import org.mockito.Mock;
32 import org.mockito.junit.jupiter.MockitoExtension;
33 import org.openhab.binding.mqtt.homeassistant.internal.AbstractHomeAssistantTests;
34 import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannelType;
35 import org.openhab.binding.mqtt.homeassistant.internal.HaID;
36 import org.openhab.binding.mqtt.homeassistant.internal.HandlerConfiguration;
37 import org.openhab.binding.mqtt.homeassistant.internal.component.Climate;
38 import org.openhab.binding.mqtt.homeassistant.internal.component.Sensor;
39 import org.openhab.binding.mqtt.homeassistant.internal.component.Switch;
40 import org.openhab.core.config.core.Configuration;
41 import org.openhab.core.library.CoreItemFactory;
42 import org.openhab.core.thing.Channel;
43 import org.openhab.core.thing.ChannelUID;
44 import org.openhab.core.thing.binding.ThingHandlerCallback;
45 import org.openhab.core.thing.binding.builder.ChannelBuilder;
46 import org.openhab.core.thing.binding.builder.ThingBuilder;
47 import org.openhab.core.types.StateDescription;
49 import com.hubspot.jinjava.Jinjava;
52 * Tests for {@link HomeAssistantThingHandler}
54 * @author Anton Kharuzhy - Initial contribution
56 @ExtendWith(MockitoExtension.class)
58 public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
59 private static final int SUBSCRIBE_TIMEOUT = 10000;
60 private static final int ATTRIBUTE_RECEIVE_TIMEOUT = 2000;
62 private static final List<String> CONFIG_TOPICS = Arrays.asList("climate/0x847127fffe11dd6a_climate_zigbee2mqtt",
63 "switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt",
65 "sensor/0x1111111111111111_test_sensor_zigbee2mqtt", "camera/0x1111111111111111_test_camera_zigbee2mqtt",
67 "cover/0x2222222222222222_test_cover_zigbee2mqtt", "fan/0x2222222222222222_test_fan_zigbee2mqtt",
68 "light/0x2222222222222222_test_light_zigbee2mqtt", "lock/0x2222222222222222_test_lock_zigbee2mqtt");
70 private static final List<String> MQTT_TOPICS = CONFIG_TOPICS.stream()
71 .map(AbstractHomeAssistantTests::configTopicToMqtt).collect(Collectors.toList());
73 private @Mock @NonNullByDefault({}) ThingHandlerCallback callbackMock;
74 private @NonNullByDefault({}) HomeAssistantThingHandler thingHandler;
75 private @NonNullByDefault({}) HomeAssistantThingHandler nonSpyThingHandler;
79 final var config = haThing.getConfiguration();
81 config.put(HandlerConfiguration.PROPERTY_BASETOPIC, HandlerConfiguration.DEFAULT_BASETOPIC);
82 config.put(HandlerConfiguration.PROPERTY_TOPICS, CONFIG_TOPICS);
84 when(callbackMock.getBridge(eq(BRIDGE_UID))).thenReturn(bridgeThing);
89 protected void setupThingHandler() {
90 thingHandler = new HomeAssistantThingHandler(haThing, channelTypeProvider, stateDescriptionProvider,
91 channelTypeRegistry, new Jinjava(), SUBSCRIBE_TIMEOUT, ATTRIBUTE_RECEIVE_TIMEOUT);
92 thingHandler.setConnection(bridgeConnection);
93 thingHandler.setCallback(callbackMock);
94 nonSpyThingHandler = thingHandler;
95 thingHandler = spy(thingHandler);
99 public void testInitialize() {
101 thingHandler.initialize();
103 verify(callbackMock).statusUpdated(eq(haThing), any());
104 // Expect a call to the bridge status changed, the start, the propertiesChanged method
105 verify(thingHandler).bridgeStatusChanged(any());
106 verify(thingHandler, timeout(SUBSCRIBE_TIMEOUT)).start(any());
108 // Expect subscription on each topic from config
109 MQTT_TOPICS.forEach(t -> {
110 verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).subscribe(eq(t), any());
113 verify(thingHandler, never()).componentDiscovered(any(), any());
114 assertThat(haThing.getChannels().size(), is(0));
115 // Components discovered after messages in corresponding topics
116 var configTopic = "homeassistant/climate/0x847127fffe11dd6a_climate_zigbee2mqtt/config";
117 thingHandler.discoverComponents.processMessage(configTopic,
118 getResourceAsByteArray("component/configTS0601ClimateThermostat.json"));
119 verify(thingHandler, times(1)).componentDiscovered(eq(new HaID(configTopic)), any(Climate.class));
121 thingHandler.delayedProcessing.forceProcessNow();
122 assertThat(nonSpyThingHandler.getThing().getChannels().size(), is(6));
123 verify(stateDescriptionProvider, times(6)).setDescription(any(), any(StateDescription.class));
124 verify(channelTypeProvider, times(1)).putChannelGroupType(any());
126 configTopic = "homeassistant/switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt/config";
127 thingHandler.discoverComponents.processMessage(configTopic,
128 getResourceAsByteArray("component/configTS0601AutoLock.json"));
129 verify(thingHandler, times(2)).componentDiscovered(any(), any());
130 verify(thingHandler, times(1)).componentDiscovered(eq(new HaID(configTopic)), any(Switch.class));
132 thingHandler.delayedProcessing.forceProcessNow();
133 assertThat(nonSpyThingHandler.getThing().getChannels().size(), is(7));
134 verify(stateDescriptionProvider, atLeast(7)).setDescription(any(), any(StateDescription.class));
135 verify(channelTypeProvider, times(3)).putChannelGroupType(any());
139 * Test where the same component is published twice to MQTT. The binding should handle this.
141 * @throws InterruptedException
144 public void testDuplicateComponentPublish() throws InterruptedException {
145 thingHandler.initialize();
147 verify(callbackMock).statusUpdated(eq(haThing), any());
148 // Expect a call to the bridge status changed, the start, the propertiesChanged method
149 verify(thingHandler).bridgeStatusChanged(any());
150 verify(thingHandler, timeout(SUBSCRIBE_TIMEOUT)).start(any());
152 // Expect subscription on each topic from config
153 MQTT_TOPICS.forEach(t -> {
154 verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).subscribe(eq(t), any());
157 verify(thingHandler, never()).componentDiscovered(any(), any());
158 assertThat(haThing.getChannels().size(), is(0));
162 // Publish sensor components with identical payload except for
163 // change in "name" field. The binding should respect the latest discovery result.
165 // This simulates how multiple OpenMQTTGateway devices would publish
166 // the same discovery topics for a particular Bluetooth sensor, and thus "competing" with similar but slightly
167 // different discovery topics.
169 // In fact, only difference is actually "via_device" additional metadata field telling which OpenMQTTGateway
170 // published the discovery topic.
175 // 1. publish corridor temperature sensor
177 var configTopicTempCorridor = "homeassistant/sensor/tempCorridor/config";
178 thingHandler.discoverComponents.processMessage(configTopicTempCorridor, new String("{"//
179 + "\"temperature_state_topic\": \"+/+/BTtoMQTT/mysensor\","//
180 + "\"temperature_state_template\": \"{{ value_json.temperature }}\", "//
181 + "\"name\": \"CorridorTemp\", "//
182 + "\"unit_of_measurement\": \"°C\" "//
183 + "}").getBytes(StandardCharsets.UTF_8));
184 verify(thingHandler, times(1)).componentDiscovered(eq(new HaID(configTopicTempCorridor)), any(Sensor.class));
185 thingHandler.delayedProcessing.forceProcessNow();
186 waitForAssert(() -> {
187 assertThat("1 channel created", nonSpyThingHandler.getThing().getChannels().size() == 1);
191 // 2. publish outside temperature sensor
193 var configTopicTempOutside = "homeassistant/sensor/tempOutside/config";
194 thingHandler.discoverComponents.processMessage(configTopicTempOutside, new String("{"//
195 + "\"temperature_state_topic\": \"+/+/BTtoMQTT/mysensor\","//
196 + "\"temperature_state_template\": \"{{ value_json.temperature }}\", " //
197 + "\"name\": \"OutsideTemp\", "//
198 + "\"source\": \"gateway2\" "//
199 + "}").getBytes(StandardCharsets.UTF_8));
200 thingHandler.delayedProcessing.forceProcessNow();
201 verify(thingHandler, times(1)).componentDiscovered(eq(new HaID(configTopicTempOutside)), any(Sensor.class));
202 waitForAssert(() -> {
203 assertThat("2 channel created", nonSpyThingHandler.getThing().getChannels().size() == 2);
207 // 3. publish corridor temperature sensor, this time with different name (openHAB channel label)
209 thingHandler.discoverComponents.processMessage(configTopicTempCorridor, new String("{"//
210 + "\"temperature_state_topic\": \"+/+/BTtoMQTT/mysensor\","//
211 + "\"temperature_state_template\": \"{{ value_json.temperature }}\", "//
212 + "\"name\": \"CorridorTemp NEW\", "//
213 + "\"unit_of_measurement\": \"°C\" "//
214 + "}").getBytes(StandardCharsets.UTF_8));
215 thingHandler.delayedProcessing.forceProcessNow();
217 waitForAssert(() -> {
218 assertThat("2 channel created", nonSpyThingHandler.getThing().getChannels().size() == 2);
222 // verify that both channels are there and the label corresponds to newer discovery topic payload
224 Channel corridorTempChannel = nonSpyThingHandler.getThing().getChannel("tempCorridor_5Fsensor#sensor");
225 assertThat("Corridor temperature channel is created", corridorTempChannel, notNullValue());
226 Objects.requireNonNull(corridorTempChannel); // for compiler
227 assertThat("Corridor temperature channel is having the updated label from 2nd discovery topic publish",
228 corridorTempChannel.getLabel(), is("CorridorTemp NEW"));
230 Channel outsideTempChannel = nonSpyThingHandler.getThing().getChannel("tempOutside_5Fsensor#sensor");
231 assertThat("Outside temperature channel is created", outsideTempChannel, notNullValue());
233 verify(thingHandler, times(2)).componentDiscovered(eq(new HaID(configTopicTempCorridor)), any(Sensor.class));
235 waitForAssert(() -> {
236 assertThat("2 channel created", nonSpyThingHandler.getThing().getChannels().size() == 2);
241 public void testDispose() {
242 thingHandler.initialize();
244 // Expect subscription on each topic from config
245 CONFIG_TOPICS.forEach(t -> {
246 var fullTopic = HandlerConfiguration.DEFAULT_BASETOPIC + "/" + t + "/config";
247 verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).subscribe(eq(fullTopic), any());
249 thingHandler.discoverComponents.processMessage(
250 "homeassistant/climate/0x847127fffe11dd6a_climate_zigbee2mqtt/config",
251 getResourceAsByteArray("component/configTS0601ClimateThermostat.json"));
252 thingHandler.discoverComponents.processMessage(
253 "homeassistant/switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt/config",
254 getResourceAsByteArray("component/configTS0601AutoLock.json"));
255 thingHandler.delayedProcessing.forceProcessNow();
256 assertThat(nonSpyThingHandler.getThing().getChannels().size(), is(7));
257 verify(stateDescriptionProvider, atLeast(7)).setDescription(any(), any(StateDescription.class));
260 thingHandler.dispose();
262 // Expect unsubscription on each topic from config
263 MQTT_TOPICS.forEach(t -> {
264 verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).unsubscribe(eq(t), any());
269 public void testRemoveThing() {
270 thingHandler.initialize();
272 // Expect subscription on each topic from config
273 CONFIG_TOPICS.forEach(t -> {
274 var fullTopic = HandlerConfiguration.DEFAULT_BASETOPIC + "/" + t + "/config";
275 verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).subscribe(eq(fullTopic), any());
277 thingHandler.discoverComponents.processMessage(
278 "homeassistant/climate/0x847127fffe11dd6a_climate_zigbee2mqtt/config",
279 getResourceAsByteArray("component/configTS0601ClimateThermostat.json"));
280 thingHandler.discoverComponents.processMessage(
281 "homeassistant/switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt/config",
282 getResourceAsByteArray("component/configTS0601AutoLock.json"));
283 thingHandler.delayedProcessing.forceProcessNow();
284 assertThat(nonSpyThingHandler.getThing().getChannels().size(), is(7));
287 nonSpyThingHandler.handleRemoval();
289 // Expect channel descriptions removed, 6 for climate and 1 for switch
290 verify(stateDescriptionProvider, times(7)).remove(any());
291 // Expect channel group types removed, 1 for each component
292 verify(channelTypeProvider, times(2)).removeChannelGroupType(any());
296 public void testProcessMessageFromUnsupportedComponent() {
297 thingHandler.initialize();
298 thingHandler.discoverComponents.processMessage("homeassistant/unsupportedType/id_zigbee2mqtt/config",
299 "{}".getBytes(StandardCharsets.UTF_8));
300 // Ignore unsupported component
301 thingHandler.delayedProcessing.forceProcessNow();
302 assertThat(haThing.getChannels().size(), is(0));
306 public void testProcessMessageWithEmptyConfig() {
307 thingHandler.initialize();
308 thingHandler.discoverComponents.processMessage("homeassistant/sensor/id_zigbee2mqtt/config",
309 "".getBytes(StandardCharsets.UTF_8));
310 // Ignore component with empty config
311 thingHandler.delayedProcessing.forceProcessNow();
312 assertThat(haThing.getChannels().size(), is(0));
316 public void testProcessMessageWithBadFormatConfig() {
317 thingHandler.initialize();
318 thingHandler.discoverComponents.processMessage("homeassistant/sensor/id_zigbee2mqtt/config",
319 "{bad format}}".getBytes(StandardCharsets.UTF_8));
320 // Ignore component with bad format config
321 thingHandler.delayedProcessing.forceProcessNow();
322 assertThat(haThing.getChannels().size(), is(0));
326 public void testRestoreComponentFromChannelConfig() {
327 Configuration thingConfiguration = new Configuration();
328 thingConfiguration.put("topics", List.of("switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt/switch"));
330 Configuration channelConfiguration = new Configuration();
331 channelConfiguration.put("component", "switch");
332 channelConfiguration.put("objectid", List.of("switch"));
333 channelConfiguration.put("nodeid", "0x847127fffe11dd6a_auto_lock_zigbee2mqtt");
334 channelConfiguration.put("config", List.of("""
336 "command_topic": "zigbee2mqtt/th1/set/auto_lock",
337 "name": "th1 auto lock",
338 "state_topic": "zigbee2mqtt/th1",
339 "unique_id": "0x847127fffe11dd6a_auto_lock_zigbee2mqtt"
343 ChannelBuilder channelBuilder = ChannelBuilder
344 .create(new ChannelUID(haThing.getUID(), "switch"), CoreItemFactory.SWITCH)
345 .withType(ComponentChannelType.SWITCH.getChannelTypeUID()).withConfiguration(channelConfiguration);
347 haThing = ThingBuilder.create(HA_TYPE_UID, HA_UID).withBridge(BRIDGE_UID).withChannel(channelBuilder.build())
348 .withConfiguration(thingConfiguration).build();
349 haThing.setProperty("newStyleChannels", "true");
352 thingHandler.initialize();
353 assertThat(thingHandler.getComponents().size(), is(1));
354 assertThat(thingHandler.getComponents().keySet().iterator().next(), is("switch"));
355 assertThat(thingHandler.getComponents().values().iterator().next().getClass(), is(Switch.class));