]> git.basschouten.com Git - openhab-addons.git/blob
26932da0396813ba10cd3c17608b503b132ec804
[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.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.*;
20
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;
26
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;
48
49 import com.hubspot.jinjava.Jinjava;
50
51 /**
52  * Tests for {@link HomeAssistantThingHandler}
53  *
54  * @author Anton Kharuzhy - Initial contribution
55  */
56 @ExtendWith(MockitoExtension.class)
57 @NonNullByDefault
58 public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
59     private static final int SUBSCRIBE_TIMEOUT = 10000;
60     private static final int ATTRIBUTE_RECEIVE_TIMEOUT = 2000;
61
62     private static final List<String> CONFIG_TOPICS = Arrays.asList("climate/0x847127fffe11dd6a_climate_zigbee2mqtt",
63             "switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt",
64
65             "sensor/0x1111111111111111_test_sensor_zigbee2mqtt", "camera/0x1111111111111111_test_camera_zigbee2mqtt",
66
67             "cover/0x2222222222222222_test_cover_zigbee2mqtt", "fan/0x2222222222222222_test_fan_zigbee2mqtt",
68             "light/0x2222222222222222_test_light_zigbee2mqtt", "lock/0x2222222222222222_test_lock_zigbee2mqtt");
69
70     private static final List<String> MQTT_TOPICS = CONFIG_TOPICS.stream()
71             .map(AbstractHomeAssistantTests::configTopicToMqtt).collect(Collectors.toList());
72
73     private @Mock @NonNullByDefault({}) ThingHandlerCallback callbackMock;
74     private @NonNullByDefault({}) HomeAssistantThingHandler thingHandler;
75     private @NonNullByDefault({}) HomeAssistantThingHandler nonSpyThingHandler;
76
77     @BeforeEach
78     public void setup() {
79         final var config = haThing.getConfiguration();
80
81         config.put(HandlerConfiguration.PROPERTY_BASETOPIC, HandlerConfiguration.DEFAULT_BASETOPIC);
82         config.put(HandlerConfiguration.PROPERTY_TOPICS, CONFIG_TOPICS);
83
84         when(callbackMock.getBridge(eq(BRIDGE_UID))).thenReturn(bridgeThing);
85
86         setupThingHandler();
87     }
88
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);
96     }
97
98     @Test
99     public void testInitialize() {
100         // When initialize
101         thingHandler.initialize();
102
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());
107
108         // Expect subscription on each topic from config
109         MQTT_TOPICS.forEach(t -> {
110             verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).subscribe(eq(t), any());
111         });
112
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));
120
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());
125
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));
131
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());
136     }
137
138     /**
139      * Test where the same component is published twice to MQTT. The binding should handle this.
140      *
141      * @throws InterruptedException
142      */
143     @Test
144     public void testDuplicateComponentPublish() throws InterruptedException {
145         thingHandler.initialize();
146
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());
151
152         // Expect subscription on each topic from config
153         MQTT_TOPICS.forEach(t -> {
154             verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).subscribe(eq(t), any());
155         });
156
157         verify(thingHandler, never()).componentDiscovered(any(), any());
158         assertThat(haThing.getChannels().size(), is(0));
159
160         //
161         //
162         // Publish sensor components with identical payload except for
163         // change in "name" field. The binding should respect the latest discovery result.
164         //
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.
168         //
169         // In fact, only difference is actually "via_device" additional metadata field telling which OpenMQTTGateway
170         // published the discovery topic.
171         //
172         //
173
174         //
175         // 1. publish corridor temperature sensor
176         //
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);
188         });
189
190         //
191         // 2. publish outside temperature sensor
192         //
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);
204         });
205
206         //
207         // 3. publish corridor temperature sensor, this time with different name (openHAB channel label)
208         //
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();
216
217         waitForAssert(() -> {
218             assertThat("2 channel created", nonSpyThingHandler.getThing().getChannels().size() == 2);
219         });
220
221         //
222         // verify that both channels are there and the label corresponds to newer discovery topic payload
223         //
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"));
229
230         Channel outsideTempChannel = nonSpyThingHandler.getThing().getChannel("tempOutside_5Fsensor#sensor");
231         assertThat("Outside temperature channel is created", outsideTempChannel, notNullValue());
232
233         verify(thingHandler, times(2)).componentDiscovered(eq(new HaID(configTopicTempCorridor)), any(Sensor.class));
234
235         waitForAssert(() -> {
236             assertThat("2 channel created", nonSpyThingHandler.getThing().getChannels().size() == 2);
237         });
238     }
239
240     @Test
241     public void testDispose() {
242         thingHandler.initialize();
243
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());
248         });
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));
258
259         // When dispose
260         thingHandler.dispose();
261
262         // Expect unsubscription on each topic from config
263         MQTT_TOPICS.forEach(t -> {
264             verify(bridgeConnection, timeout(SUBSCRIBE_TIMEOUT)).unsubscribe(eq(t), any());
265         });
266     }
267
268     @Test
269     public void testRemoveThing() {
270         thingHandler.initialize();
271
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());
276         });
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));
285
286         // When dispose
287         nonSpyThingHandler.handleRemoval();
288
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());
293     }
294
295     @Test
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));
303     }
304
305     @Test
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));
313     }
314
315     @Test
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));
323     }
324
325     @Test
326     public void testRestoreComponentFromChannelConfig() {
327         Configuration thingConfiguration = new Configuration();
328         thingConfiguration.put("topics", List.of("switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt/switch"));
329
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("""
335                     {
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"
340                     }
341                 """));
342
343         ChannelBuilder channelBuilder = ChannelBuilder
344                 .create(new ChannelUID(haThing.getUID(), "switch"), CoreItemFactory.SWITCH)
345                 .withType(ComponentChannelType.SWITCH.getChannelTypeUID()).withConfiguration(channelConfiguration);
346
347         haThing = ThingBuilder.create(HA_TYPE_UID, HA_UID).withBridge(BRIDGE_UID).withChannel(channelBuilder.build())
348                 .withConfiguration(thingConfiguration).build();
349         haThing.setProperty("newStyleChannels", "true");
350
351         setupThingHandler();
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));
356     }
357 }