2 * Copyright (c) 2010-2023 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.homie.internal.handler;
15 import static org.eclipse.jdt.annotation.Checks.requireNonNull;
16 import static org.hamcrest.CoreMatchers.is;
17 import static org.hamcrest.MatcherAssert.assertThat;
18 import static org.junit.jupiter.api.Assertions.*;
19 import static org.mockito.ArgumentMatchers.*;
20 import static org.mockito.Mockito.*;
21 import static org.openhab.binding.mqtt.homie.internal.handler.ThingChannelConstants.TEST_HOMIE_THING;
23 import java.lang.reflect.Field;
24 import java.util.ArrayList;
25 import java.util.List;
27 import java.util.concurrent.CompletableFuture;
28 import java.util.concurrent.ExecutionException;
29 import java.util.concurrent.ScheduledExecutorService;
30 import java.util.concurrent.ScheduledFuture;
31 import java.util.concurrent.TimeUnit;
33 import org.eclipse.jdt.annotation.NonNullByDefault;
34 import org.eclipse.jdt.annotation.Nullable;
35 import org.junit.jupiter.api.BeforeEach;
36 import org.junit.jupiter.api.Test;
37 import org.junit.jupiter.api.extension.ExtendWith;
38 import org.mockito.Mock;
39 import org.mockito.invocation.InvocationOnMock;
40 import org.mockito.junit.jupiter.MockitoExtension;
41 import org.mockito.junit.jupiter.MockitoSettings;
42 import org.mockito.quality.Strictness;
43 import org.openhab.binding.mqtt.generic.ChannelState;
44 import org.openhab.binding.mqtt.generic.MqttChannelTypeProvider;
45 import org.openhab.binding.mqtt.generic.mapping.AbstractMqttAttributeClass;
46 import org.openhab.binding.mqtt.generic.mapping.SubscribeFieldToMQTTtopic;
47 import org.openhab.binding.mqtt.generic.tools.ChildMap;
48 import org.openhab.binding.mqtt.generic.tools.DelayedBatchProcessing;
49 import org.openhab.binding.mqtt.generic.values.Value;
50 import org.openhab.binding.mqtt.handler.AbstractBrokerHandler;
51 import org.openhab.binding.mqtt.homie.ChannelStateHelper;
52 import org.openhab.binding.mqtt.homie.ThingHandlerHelper;
53 import org.openhab.binding.mqtt.homie.generic.internal.MqttBindingConstants;
54 import org.openhab.binding.mqtt.homie.internal.homie300.Device;
55 import org.openhab.binding.mqtt.homie.internal.homie300.DeviceAttributes;
56 import org.openhab.binding.mqtt.homie.internal.homie300.DeviceAttributes.ReadyState;
57 import org.openhab.binding.mqtt.homie.internal.homie300.Node;
58 import org.openhab.binding.mqtt.homie.internal.homie300.NodeAttributes;
59 import org.openhab.binding.mqtt.homie.internal.homie300.Property;
60 import org.openhab.binding.mqtt.homie.internal.homie300.PropertyAttributes;
61 import org.openhab.binding.mqtt.homie.internal.homie300.PropertyAttributes.DataTypeEnum;
62 import org.openhab.core.config.core.Configuration;
63 import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
64 import org.openhab.core.library.types.StringType;
65 import org.openhab.core.thing.Channel;
66 import org.openhab.core.thing.Thing;
67 import org.openhab.core.thing.ThingStatus;
68 import org.openhab.core.thing.ThingStatusDetail;
69 import org.openhab.core.thing.ThingStatusInfo;
70 import org.openhab.core.thing.binding.ThingHandlerCallback;
71 import org.openhab.core.thing.binding.builder.ThingBuilder;
72 import org.openhab.core.thing.type.ChannelKind;
73 import org.openhab.core.thing.type.ThingTypeRegistry;
74 import org.openhab.core.types.Command;
75 import org.openhab.core.types.RefreshType;
76 import org.openhab.core.types.TypeParser;
79 * Tests cases for {@link HomieThingHandler}.
81 * @author David Graeff - Initial contribution
83 @ExtendWith(MockitoExtension.class)
84 @MockitoSettings(strictness = Strictness.LENIENT)
86 public class HomieThingHandlerTests {
88 private @Mock @NonNullByDefault({}) AbstractBrokerHandler bridgeHandlerMock;
89 private @Mock @NonNullByDefault({}) ThingHandlerCallback callbackMock;
90 private @Mock @NonNullByDefault({}) MqttBrokerConnection connectionMock;
91 private @Mock @NonNullByDefault({}) ScheduledExecutorService schedulerMock;
92 private @Mock @NonNullByDefault({}) ScheduledFuture<?> scheduledFutureMock;
93 private @Mock @NonNullByDefault({}) ThingTypeRegistry thingTypeRegistryMock;
95 private @NonNullByDefault({}) Thing thing;
96 private @NonNullByDefault({}) HomieThingHandler thingHandler;
98 private final MqttChannelTypeProvider channelTypeProvider = new MqttChannelTypeProvider(thingTypeRegistryMock);
100 private final String deviceID = ThingChannelConstants.TEST_HOMIE_THING.getId();
101 private final String deviceTopic = "homie/" + deviceID;
103 // A completed future is returned for a subscribe call to the attributes
104 private CompletableFuture<@Nullable Void> future = CompletableFuture.completedFuture(null);
107 public void setUp() {
108 final ThingStatusInfo thingStatus = new ThingStatusInfo(ThingStatus.ONLINE, ThingStatusDetail.NONE, null);
110 final Configuration config = new Configuration();
111 config.put("basetopic", "homie");
112 config.put("deviceid", deviceID);
114 thing = ThingBuilder.create(MqttBindingConstants.HOMIE300_MQTT_THING, TEST_HOMIE_THING.getId())
115 .withConfiguration(config).build();
116 thing.setStatusInfo(thingStatus);
118 // Return the mocked connection object if the bridge handler is asked for it
119 when(bridgeHandlerMock.getConnectionAsync()).thenReturn(CompletableFuture.completedFuture(connectionMock));
121 doReturn(CompletableFuture.completedFuture(true)).when(connectionMock).subscribe(any(), any());
122 doReturn(CompletableFuture.completedFuture(true)).when(connectionMock).unsubscribe(any(), any());
123 doReturn(CompletableFuture.completedFuture(true)).when(connectionMock).unsubscribeAll();
124 doReturn(CompletableFuture.completedFuture(true)).when(connectionMock).publish(any(), any(), anyInt(),
127 doReturn(false).when(scheduledFutureMock).isDone();
128 doReturn(scheduledFutureMock).when(schedulerMock).schedule(any(Runnable.class), anyLong(), any(TimeUnit.class));
130 final HomieThingHandler handler = new HomieThingHandler(thing, channelTypeProvider, 1000, 30, 5);
131 thingHandler = spy(handler);
132 thingHandler.setCallback(callbackMock);
133 final Device device = new Device(thing.getUID(), thingHandler, spy(new DeviceAttributes()),
134 spy(new ChildMap<>()));
135 thingHandler.setInternalObjects(spy(device),
136 spy(new DelayedBatchProcessing<>(500, thingHandler, schedulerMock)));
138 // Return the bridge handler if the thing handler asks for it
139 doReturn(bridgeHandlerMock).when(thingHandler).getBridgeHandler();
141 // We are by default online
142 doReturn(thingStatus).when(thingHandler).getBridgeStatus();
146 public void initialize() {
147 assertThat(thingHandler.device.isInitialized(), is(false));
148 // // A completed future is returned for a subscribe call to the attributes
149 doReturn(future).when(thingHandler.device.attributes).subscribeAndReceive(any(), any(), anyString(), any(),
151 doReturn(future).when(thingHandler.device.attributes).unsubscribe();
152 // Prevent a call to accept, that would update our thing.
153 doNothing().when(thingHandler).accept(any());
154 // Pretend that a device state change arrived.
155 thingHandler.device.attributes.state = ReadyState.ready;
157 verify(callbackMock, times(0)).statusUpdated(eq(thing), any());
159 thingHandler.initialize();
161 // Expect a call to the bridge status changed, the start, the propertiesChanged method
162 verify(thingHandler).bridgeStatusChanged(any());
163 verify(thingHandler).start(any());
164 verify(thingHandler).readyStateChanged(any());
165 verify(thingHandler.device.attributes).subscribeAndReceive(any(), any(),
166 argThat(arg -> deviceTopic.equals(arg)), any(), anyInt());
168 assertThat(thingHandler.device.isInitialized(), is(true));
170 verify(callbackMock).statusUpdated(eq(thing), argThat(arg -> ThingStatus.ONLINE.equals(arg.getStatus())
171 && ThingStatusDetail.NONE.equals(arg.getStatusDetail())));
175 public void initializeGeneralTimeout() throws InterruptedException {
176 // A non completed future is returned for a subscribe call to the attributes
177 doReturn(future).when(thingHandler.device.attributes).subscribeAndReceive(any(), any(), anyString(), any(),
179 doReturn(future).when(thingHandler.device.attributes).unsubscribe();
181 // Prevent a call to accept, that would update our thing.
182 doNothing().when(thingHandler).accept(any());
184 thingHandler.initialize();
186 verify(callbackMock).statusUpdated(eq(thing), argThat(arg -> ThingStatus.OFFLINE.equals(arg.getStatus())
187 && ThingStatusDetail.COMMUNICATION_ERROR.equals(arg.getStatusDetail())));
191 public void initializeNoStateReceived() throws InterruptedException {
192 // A completed future is returned for a subscribe call to the attributes
193 doReturn(future).when(thingHandler.device.attributes).subscribeAndReceive(any(), any(), anyString(), any(),
195 doReturn(future).when(thingHandler.device.attributes).unsubscribe();
197 // Prevent a call to accept, that would update our thing.
198 doNothing().when(thingHandler).accept(any());
200 thingHandler.initialize();
201 assertThat(thingHandler.device.isInitialized(), is(true));
203 verify(callbackMock).statusUpdated(eq(thing), argThat(arg -> ThingStatus.OFFLINE.equals(arg.getStatus())
204 && ThingStatusDetail.GONE.equals(arg.getStatusDetail())));
207 @SuppressWarnings("null")
209 public void handleCommandRefresh() {
210 // Create mocked homie device tree with one node and one read-only property
211 Node node = thingHandler.device.createNode("node", spy(new NodeAttributes()));
212 doReturn(future).when(node.attributes).subscribeAndReceive(any(), any(), anyString(), any(), anyInt());
213 doReturn(future).when(node.attributes).unsubscribe();
214 node.attributes.name = "testnode";
216 Property property = node.createProperty("property", spy(new PropertyAttributes()));
217 doReturn(future).when(property.attributes).subscribeAndReceive(any(), any(), anyString(), any(), anyInt());
218 doReturn(future).when(property.attributes).unsubscribe();
219 property.attributes.name = "testprop";
220 property.attributes.datatype = DataTypeEnum.string_;
221 property.attributes.settable = false;
222 property.attributesReceived();
223 node.properties.put(property.propertyID, property);
224 thingHandler.device.nodes.put(node.nodeID, node);
226 ThingHandlerHelper.setConnection(thingHandler, connectionMock);
227 // we need to set a channel value first, undefined values ignored on REFRESH
228 property.getChannelState().getCache().update(new StringType("testString"));
230 thingHandler.handleCommand(property.channelUID, RefreshType.REFRESH);
231 verify(callbackMock).stateUpdated(argThat(arg -> property.channelUID.equals(arg)),
232 argThat(arg -> property.getChannelState().getCache().getChannelState().equals(arg)));
235 @SuppressWarnings("null")
237 public void handleCommandUpdate() {
238 // Create mocked homie device tree with one node and one writable property
239 Node node = thingHandler.device.createNode("node", spy(new NodeAttributes()));
240 doReturn(future).when(node.attributes).subscribeAndReceive(any(), any(), anyString(), any(), anyInt());
241 doReturn(future).when(node.attributes).unsubscribe();
242 node.attributes.name = "testnode";
244 Property property = node.createProperty("property", spy(new PropertyAttributes()));
245 doReturn(future).when(property.attributes).subscribeAndReceive(any(), any(), anyString(), any(), anyInt());
246 doReturn(future).when(property.attributes).unsubscribe();
247 property.attributes.name = "testprop";
248 property.attributes.datatype = DataTypeEnum.string_;
249 property.attributes.settable = true;
250 property.attributesReceived();
251 node.properties.put(property.propertyID, property);
252 thingHandler.device.nodes.put(node.nodeID, node);
254 ChannelState channelState = requireNonNull(property.getChannelState());
255 assertNotNull(channelState);
256 ChannelStateHelper.setConnection(channelState, connectionMock);// Pretend we called start()
257 ThingHandlerHelper.setConnection(thingHandler, connectionMock);
259 StringType updateValue = new StringType("UPDATE");
260 thingHandler.handleCommand(property.channelUID, updateValue);
262 assertThat(property.getChannelState().getCache().getChannelState().toString(), is("UPDATE"));
263 verify(connectionMock, times(1)).publish(any(), any(), anyInt(), anyBoolean());
265 // Check non writable property
266 property.attributes.settable = false;
267 property.attributesReceived();
269 Value value = property.getChannelState().getCache();
270 Command command = TypeParser.parseCommand(value.getSupportedCommandTypes(), "OLDVALUE");
271 if (command != null) {
272 property.getChannelState().getCache().update(command);
273 // Try to update with new value
274 updateValue = new StringType("SOMETHINGNEW");
275 thingHandler.handleCommand(property.channelUID, updateValue);
276 // Expect old value and no MQTT publish
277 assertThat(property.getChannelState().getCache().getChannelState().toString(), is("OLDVALUE"));
278 verify(connectionMock, times(1)).publish(any(), any(), anyInt(), anyBoolean());
282 public Object createSubscriberAnswer(InvocationOnMock invocation) {
283 final AbstractMqttAttributeClass attributes = (AbstractMqttAttributeClass) invocation.getMock();
284 final ScheduledExecutorService scheduler = (ScheduledExecutorService) invocation.getArguments()[0];
285 final Field field = (Field) invocation.getArguments()[1];
286 final String topic = (String) invocation.getArguments()[2];
287 final boolean mandatory = (boolean) invocation.getArguments()[3];
288 final SubscribeFieldToMQTTtopic s = spy(
289 new SubscribeFieldToMQTTtopic(scheduler, field, attributes, topic, mandatory));
290 doReturn(CompletableFuture.completedFuture(true)).when(s).subscribeAndReceive(any(), anyInt());
294 public Property createSpyProperty(String propertyID, Node node) {
295 // Create a property with the same ID and insert it instead
296 Property property = spy(node.createProperty(propertyID, spy(new PropertyAttributes())));
297 doAnswer(this::createSubscriberAnswer).when(property.attributes).createSubscriber(any(), any(), any(),
299 property.attributes.name = "testprop";
300 property.attributes.datatype = DataTypeEnum.string_;
305 public Node createSpyNode(String propertyID, Device device) {
307 Node node = spy(device.createNode("node", spy(new NodeAttributes())));
308 doReturn(future).when(node.attributes).subscribeAndReceive(any(), any(), anyString(), any(), anyInt());
309 doReturn(future).when(node.attributes).unsubscribe();
310 node.attributes.name = "testnode";
311 node.attributes.properties = new String[] { "property" };
312 doAnswer(this::createSubscriberAnswer).when(node.attributes).createSubscriber(any(), any(), any(),
315 // Intercept creating a property in the next call and inject a spy'ed property.
316 doAnswer(i -> createSpyProperty("property", node)).when(node).createProperty(any());
322 public void propertiesChanged() throws InterruptedException, ExecutionException {
323 thingHandler.device.initialize("homie", "device", new ArrayList<>());
324 ThingHandlerHelper.setConnection(thingHandler, connectionMock);
326 // Create mocked homie device tree with one node and one property
327 doAnswer(this::createSubscriberAnswer).when(thingHandler.device.attributes).createSubscriber(any(), any(),
328 any(), anyBoolean());
330 thingHandler.device.attributes.state = ReadyState.ready;
331 thingHandler.device.attributes.name = "device";
332 thingHandler.device.attributes.homie = "3.0";
333 thingHandler.device.attributes.nodes = new String[] { "node" };
335 // Intercept creating a node in initialize()->start() and inject a spy'ed node.
336 doAnswer(i -> createSpyNode("node", thingHandler.device)).when(thingHandler.device).createNode(any());
338 verify(thingHandler, times(0)).nodeAddedOrChanged(any());
339 verify(thingHandler, times(0)).propertyAddedOrChanged(any());
341 thingHandler.initialize();
343 assertThat(thingHandler.device.isInitialized(), is(true));
345 verify(thingHandler).propertyAddedOrChanged(any());
346 verify(thingHandler).nodeAddedOrChanged(any());
348 verify(thingHandler.device).subscribe(any(), any(), anyInt());
349 verify(thingHandler.device).attributesReceived(any(), any(), anyInt());
351 assertNotNull(thingHandler.device.nodes.get("node").properties.get("property"));
353 assertTrue(thingHandler.delayedProcessing.isArmed());
355 // Simulate waiting for the delayed processor
356 thingHandler.delayedProcessing.forceProcessNow();
358 // Called for the updated property + for the new channels
359 verify(callbackMock, atLeast(2)).thingUpdated(any());
361 final List<Channel> channels = thingHandler.getThing().getChannels();
362 assertThat(channels.size(), is(1));
363 assertThat(channels.get(0).getLabel(), is("testprop"));
364 assertThat(channels.get(0).getKind(), is(ChannelKind.STATE));
366 final Map<String, String> properties = thingHandler.getThing().getProperties();
367 assertThat(properties.get(MqttBindingConstants.HOMIE_PROPERTY_VERSION), is("3.0"));
368 assertThat(properties.size(), is(1));