]> git.basschouten.com Git - openhab-addons.git/blob
ab469881547f927aea6003d1b39f809a87b6987f
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 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 import org.openhab.core.types.StateDescription;
41
42 import com.hubspot.jinjava.Jinjava;
43
44 /**
45  * Tests for {@link HomeAssistantThingHandler}
46  *
47  * @author Anton Kharuzhy - Initial contribution
48  */
49 @ExtendWith(MockitoExtension.class)
50 @NonNullByDefault
51 public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
52     private static final int SUBSCRIBE_TIMEOUT = 10000;
53     private static final int ATTRIBUTE_RECEIVE_TIMEOUT = 2000;
54
55     private static final List<String> CONFIG_TOPICS = Arrays.asList("climate/0x847127fffe11dd6a_climate_zigbee2mqtt",
56             "switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt",
57
58             "sensor/0x1111111111111111_test_sensor_zigbee2mqtt", "camera/0x1111111111111111_test_camera_zigbee2mqtt",
59
60             "cover/0x2222222222222222_test_cover_zigbee2mqtt", "fan/0x2222222222222222_test_fan_zigbee2mqtt",
61             "light/0x2222222222222222_test_light_zigbee2mqtt", "lock/0x2222222222222222_test_lock_zigbee2mqtt");
62
63     private static final List<String> MQTT_TOPICS = CONFIG_TOPICS.stream()
64             .map(AbstractHomeAssistantTests::configTopicToMqtt).collect(Collectors.toList());
65
66     private @Mock @NonNullByDefault({}) ThingHandlerCallback callbackMock;
67     private @NonNullByDefault({}) HomeAssistantThingHandler thingHandler;
68     private @NonNullByDefault({}) HomeAssistantThingHandler nonSpyThingHandler;
69
70     @BeforeEach
71     public void setup() {
72         final var config = haThing.getConfiguration();
73
74         config.put(HandlerConfiguration.PROPERTY_BASETOPIC, HandlerConfiguration.DEFAULT_BASETOPIC);
75         config.put(HandlerConfiguration.PROPERTY_TOPICS, CONFIG_TOPICS);
76
77         when(callbackMock.getBridge(eq(BRIDGE_UID))).thenReturn(bridgeThing);
78
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);
85     }
86
87     @Test
88     public void testInitialize() {
89         // When initialize
90         thingHandler.initialize();
91
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());
96
97         // Expect subscription on each topic from config
98         MQTT_TOPICS.forEach(t -> {
99             verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).subscribe(eq(t), any());
100         });
101
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));
109
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());
114
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));
120
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());
125     }
126
127     /**
128      * Test where the same component is published twice to MQTT. The binding should handle this.
129      *
130      * @throws InterruptedException
131      */
132     @Test
133     public void testDuplicateComponentPublish() throws InterruptedException {
134         thingHandler.initialize();
135
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());
140
141         // Expect subscription on each topic from config
142         MQTT_TOPICS.forEach(t -> {
143             verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).subscribe(eq(t), any());
144         });
145
146         verify(thingHandler, never()).componentDiscovered(any(), any());
147         assertThat(haThing.getChannels().size(), CoreMatchers.is(0));
148
149         //
150         //
151         // Publish sensor components with identical payload except for
152         // change in "name" field. The binding should respect the latest discovery result.
153         //
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.
157         //
158         // In fact, only difference is actually "via_device" additional metadata field telling which OpenMQTTGateway
159         // published the discovery topic.
160         //
161         //
162
163         //
164         // 1. publish corridor temperature sensor
165         //
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);
177         });
178
179         //
180         // 2. publish outside temperature sensor
181         //
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);
193         });
194
195         //
196         // 3. publish corridor temperature sensor, this time with different name (openHAB channel label)
197         //
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();
205
206         waitForAssert(() -> {
207             assertThat("2 channel created", nonSpyThingHandler.getThing().getChannels().size() == 2);
208         });
209
210         //
211         // verify that both channels are there and the label corresponds to newer discovery topic payload
212         //
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"));
218
219         Channel outsideTempChannel = nonSpyThingHandler.getThing().getChannel("tempOutside_5Fsensor#sensor");
220         assertThat("Outside temperature channel is created", outsideTempChannel, CoreMatchers.notNullValue());
221
222         verify(thingHandler, times(2)).componentDiscovered(eq(new HaID(configTopicTempCorridor)), any(Sensor.class));
223
224         waitForAssert(() -> {
225             assertThat("2 channel created", nonSpyThingHandler.getThing().getChannels().size() == 2);
226         });
227     }
228
229     @Test
230     public void testDispose() {
231         thingHandler.initialize();
232
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());
237         });
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));
247
248         // When dispose
249         thingHandler.dispose();
250
251         // Expect unsubscription on each topic from config
252         MQTT_TOPICS.forEach(t -> {
253             verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).unsubscribe(eq(t), any());
254         });
255     }
256
257     @Test
258     public void testRemoveThing() {
259         thingHandler.initialize();
260
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());
265         });
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));
274
275         // When dispose
276         nonSpyThingHandler.handleRemoval();
277
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());
282     }
283
284     @Test
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));
292     }
293
294     @Test
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));
302     }
303
304     @Test
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));
312     }
313 }