]> git.basschouten.com Git - openhab-addons.git/blob
147e82336cf5009229ca7e9a5b36161fc552590a
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.mqtt.homeassistant.internal.handler;
14
15 import static org.hamcrest.MatcherAssert.assertThat;
16 import static org.mockito.ArgumentMatchers.*;
17 import static org.mockito.Mockito.*;
18
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;
24
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
41 /**
42  * Tests for {@link HomeAssistantThingHandler}
43  *
44  * @author Anton Kharuzhy - Initial contribution
45  */
46 @ExtendWith(MockitoExtension.class)
47 @NonNullByDefault
48 public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
49     private static final int SUBSCRIBE_TIMEOUT = 10000;
50     private static final int ATTRIBUTE_RECEIVE_TIMEOUT = 2000;
51
52     private static final List<String> CONFIG_TOPICS = Arrays.asList("climate/0x847127fffe11dd6a_climate_zigbee2mqtt",
53             "switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt",
54
55             "sensor/0x1111111111111111_test_sensor_zigbee2mqtt", "camera/0x1111111111111111_test_camera_zigbee2mqtt",
56
57             "cover/0x2222222222222222_test_cover_zigbee2mqtt", "fan/0x2222222222222222_test_fan_zigbee2mqtt",
58             "light/0x2222222222222222_test_light_zigbee2mqtt", "lock/0x2222222222222222_test_lock_zigbee2mqtt");
59
60     private static final List<String> MQTT_TOPICS = CONFIG_TOPICS.stream()
61             .map(AbstractHomeAssistantTests::configTopicToMqtt).collect(Collectors.toList());
62
63     private @Mock @NonNullByDefault({}) ThingHandlerCallback callbackMock;
64     private @NonNullByDefault({}) HomeAssistantThingHandler thingHandler;
65     private @NonNullByDefault({}) HomeAssistantThingHandler nonSpyThingHandler;
66
67     @BeforeEach
68     public void setup() {
69         final var config = haThing.getConfiguration();
70
71         config.put(HandlerConfiguration.PROPERTY_BASETOPIC, HandlerConfiguration.DEFAULT_BASETOPIC);
72         config.put(HandlerConfiguration.PROPERTY_TOPICS, CONFIG_TOPICS);
73
74         when(callbackMock.getBridge(eq(BRIDGE_UID))).thenReturn(bridgeThing);
75
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);
82     }
83
84     @Test
85     public void testInitialize() {
86         // When initialize
87         thingHandler.initialize();
88
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());
93
94         // Expect subscription on each topic from config
95         MQTT_TOPICS.forEach(t -> {
96             verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).subscribe(eq(t), any());
97         });
98
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));
106
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());
111
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));
117
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());
122     }
123
124     /**
125      * Test where the same component is published twice to MQTT. The binding should handle this.
126      *
127      * @throws InterruptedException
128      */
129     @Test
130     public void testDuplicateComponentPublish() throws InterruptedException {
131         thingHandler.initialize();
132
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());
137
138         // Expect subscription on each topic from config
139         MQTT_TOPICS.forEach(t -> {
140             verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).subscribe(eq(t), any());
141         });
142
143         verify(thingHandler, never()).componentDiscovered(any(), any());
144         assertThat(haThing.getChannels().size(), CoreMatchers.is(0));
145
146         //
147         //
148         // Publish sensor components with identical payload except for
149         // change in "name" field. The binding should respect the latest discovery result.
150         //
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.
154         //
155         // In fact, only difference is actually "via_device" additional metadata field telling which OpenMQTTGateway
156         // published the discovery topic.
157         //
158         //
159
160         //
161         // 1. publish corridor temperature sensor
162         //
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);
174         });
175
176         //
177         // 2. publish outside temperature sensor
178         //
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);
190         });
191
192         //
193         // 3. publish corridor temperature sensor, this time with different name (openHAB channel label)
194         //
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();
202
203         waitForAssert(() -> {
204             assertThat("2 channel created", thingHandler.getThing().getChannels().size() == 2);
205         });
206
207         //
208         // verify that both channels are there and the label corresponds to newer discovery topic payload
209         //
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"));
215
216         Channel outsideTempChannel = nonSpyThingHandler.getThing().getChannel("tempOutside_5Fsensor#sensor");
217         assertThat("Outside temperature channel is created", outsideTempChannel, CoreMatchers.notNullValue());
218
219         verify(thingHandler, times(2)).componentDiscovered(eq(new HaID(configTopicTempCorridor)), any(Sensor.class));
220
221         waitForAssert(() -> {
222             assertThat("2 channel created", thingHandler.getThing().getChannels().size() == 2);
223         });
224     }
225
226     @Test
227     public void testDispose() {
228         thingHandler.initialize();
229
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());
234         });
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());
244
245         // When dispose
246         thingHandler.dispose();
247
248         // Expect unsubscription on each topic from config
249         MQTT_TOPICS.forEach(t -> {
250             verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).unsubscribe(eq(t), any());
251         });
252
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());
257     }
258
259     @Test
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));
267     }
268
269     @Test
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));
277     }
278
279     @Test
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));
287     }
288 }