]> git.basschouten.com Git - openhab-addons.git/blob
15df43128a7f903a485c541c064284d09c7af4af
[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 /**
43  * Tests for {@link HomeAssistantThingHandler}
44  *
45  * @author Anton Kharuzhy - Initial contribution
46  */
47 @ExtendWith(MockitoExtension.class)
48 @NonNullByDefault
49 public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
50     private static final int SUBSCRIBE_TIMEOUT = 10000;
51     private static final int ATTRIBUTE_RECEIVE_TIMEOUT = 2000;
52
53     private static final List<String> CONFIG_TOPICS = Arrays.asList("climate/0x847127fffe11dd6a_climate_zigbee2mqtt",
54             "switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt",
55
56             "sensor/0x1111111111111111_test_sensor_zigbee2mqtt", "camera/0x1111111111111111_test_camera_zigbee2mqtt",
57
58             "cover/0x2222222222222222_test_cover_zigbee2mqtt", "fan/0x2222222222222222_test_fan_zigbee2mqtt",
59             "light/0x2222222222222222_test_light_zigbee2mqtt", "lock/0x2222222222222222_test_lock_zigbee2mqtt");
60
61     private static final List<String> MQTT_TOPICS = CONFIG_TOPICS.stream()
62             .map(AbstractHomeAssistantTests::configTopicToMqtt).collect(Collectors.toList());
63
64     private @Mock @NonNullByDefault({}) ThingHandlerCallback callbackMock;
65     private @NonNullByDefault({}) HomeAssistantThingHandler thingHandler;
66     private @NonNullByDefault({}) HomeAssistantThingHandler nonSpyThingHandler;
67
68     @BeforeEach
69     public void setup() {
70         final var config = haThing.getConfiguration();
71
72         config.put(HandlerConfiguration.PROPERTY_BASETOPIC, HandlerConfiguration.DEFAULT_BASETOPIC);
73         config.put(HandlerConfiguration.PROPERTY_TOPICS, CONFIG_TOPICS);
74
75         when(callbackMock.getBridge(eq(BRIDGE_UID))).thenReturn(bridgeThing);
76
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);
83     }
84
85     @Test
86     public void testInitialize() {
87         // When initialize
88         thingHandler.initialize();
89
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());
94
95         // Expect subscription on each topic from config
96         MQTT_TOPICS.forEach(t -> {
97             verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).subscribe(eq(t), any());
98         });
99
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));
107
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());
112
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));
118
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());
123     }
124
125     /**
126      * Test where the same component is published twice to MQTT. The binding should handle this.
127      *
128      * @throws InterruptedException
129      */
130     @Test
131     public void testDuplicateComponentPublish() throws InterruptedException {
132         thingHandler.initialize();
133
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());
138
139         // Expect subscription on each topic from config
140         MQTT_TOPICS.forEach(t -> {
141             verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).subscribe(eq(t), any());
142         });
143
144         verify(thingHandler, never()).componentDiscovered(any(), any());
145         assertThat(haThing.getChannels().size(), CoreMatchers.is(0));
146
147         //
148         //
149         // Publish sensor components with identical payload except for
150         // change in "name" field. The binding should respect the latest discovery result.
151         //
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.
155         //
156         // In fact, only difference is actually "via_device" additional metadata field telling which OpenMQTTGateway
157         // published the discovery topic.
158         //
159         //
160
161         //
162         // 1. publish corridor temperature sensor
163         //
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);
175         });
176
177         //
178         // 2. publish outside temperature sensor
179         //
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);
191         });
192
193         //
194         // 3. publish corridor temperature sensor, this time with different name (openHAB channel label)
195         //
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();
203
204         waitForAssert(() -> {
205             assertThat("2 channel created", nonSpyThingHandler.getThing().getChannels().size() == 2);
206         });
207
208         //
209         // verify that both channels are there and the label corresponds to newer discovery topic payload
210         //
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"));
216
217         Channel outsideTempChannel = nonSpyThingHandler.getThing().getChannel("tempOutside_5Fsensor#sensor");
218         assertThat("Outside temperature channel is created", outsideTempChannel, CoreMatchers.notNullValue());
219
220         verify(thingHandler, times(2)).componentDiscovered(eq(new HaID(configTopicTempCorridor)), any(Sensor.class));
221
222         waitForAssert(() -> {
223             assertThat("2 channel created", nonSpyThingHandler.getThing().getChannels().size() == 2);
224         });
225     }
226
227     @Test
228     public void testDispose() {
229         thingHandler.initialize();
230
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());
235         });
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));
245
246         // When dispose
247         thingHandler.dispose();
248
249         // Expect unsubscription on each topic from config
250         MQTT_TOPICS.forEach(t -> {
251             verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).unsubscribe(eq(t), any());
252         });
253     }
254
255     @Test
256     public void testRemoveThing() {
257         thingHandler.initialize();
258
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());
263         });
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));
272
273         // When dispose
274         nonSpyThingHandler.handleRemoval();
275
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());
280     }
281
282     @Test
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));
290     }
291
292     @Test
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));
300     }
301
302     @Test
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));
310     }
311 }