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;
15 import static org.hamcrest.CoreMatchers.is;
16 import static org.hamcrest.MatcherAssert.assertThat;
17 import static org.junit.jupiter.api.Assertions.*;
18 import static org.mockito.ArgumentMatchers.*;
19 import static org.mockito.Mockito.*;
21 import java.util.ArrayList;
22 import java.util.Collections;
23 import java.util.List;
24 import java.util.concurrent.CompletableFuture;
25 import java.util.concurrent.CountDownLatch;
26 import java.util.concurrent.ScheduledExecutorService;
27 import java.util.concurrent.ScheduledThreadPoolExecutor;
28 import java.util.concurrent.TimeUnit;
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.junit.jupiter.api.AfterEach;
32 import org.junit.jupiter.api.BeforeEach;
33 import org.junit.jupiter.api.Disabled;
34 import org.junit.jupiter.api.Test;
35 import org.junit.jupiter.api.extension.ExtendWith;
36 import org.mockito.Mock;
37 import org.mockito.invocation.InvocationOnMock;
38 import org.mockito.junit.jupiter.MockitoExtension;
39 import org.mockito.junit.jupiter.MockitoSettings;
40 import org.mockito.quality.Strictness;
41 import org.openhab.binding.mqtt.generic.ChannelState;
42 import org.openhab.binding.mqtt.generic.tools.ChildMap;
43 import org.openhab.binding.mqtt.generic.tools.WaitForTopicValue;
44 import org.openhab.binding.mqtt.homie.internal.handler.HomieThingHandler;
45 import org.openhab.binding.mqtt.homie.internal.homie300.Device;
46 import org.openhab.binding.mqtt.homie.internal.homie300.DeviceAttributes;
47 import org.openhab.binding.mqtt.homie.internal.homie300.DeviceAttributes.ReadyState;
48 import org.openhab.binding.mqtt.homie.internal.homie300.DeviceCallback;
49 import org.openhab.binding.mqtt.homie.internal.homie300.Node;
50 import org.openhab.binding.mqtt.homie.internal.homie300.NodeAttributes;
51 import org.openhab.binding.mqtt.homie.internal.homie300.Property;
52 import org.openhab.binding.mqtt.homie.internal.homie300.PropertyAttributes;
53 import org.openhab.binding.mqtt.homie.internal.homie300.PropertyAttributes.DataTypeEnum;
54 import org.openhab.binding.mqtt.homie.internal.homie300.PropertyHelper;
55 import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
56 import org.openhab.core.io.transport.mqtt.MqttConnectionObserver;
57 import org.openhab.core.io.transport.mqtt.MqttConnectionState;
58 import org.openhab.core.library.types.OnOffType;
59 import org.openhab.core.library.types.QuantityType;
60 import org.openhab.core.library.unit.SIUnits;
61 import org.openhab.core.types.UnDefType;
64 * A full implementation test, that starts the embedded MQTT broker and publishes a homie device tree.
66 * @author David Graeff - Initial contribution
68 @ExtendWith(MockitoExtension.class)
69 @MockitoSettings(strictness = Strictness.LENIENT)
71 public class HomieImplementationTest extends MqttOSGiTest {
72 private static final String BASE_TOPIC = "homie";
73 private static final String DEVICE_ID = ThingChannelConstants.TEST_HOME_THING.getId();
74 private static final String DEVICE_TOPIC = BASE_TOPIC + "/" + DEVICE_ID;
76 private @NonNullByDefault({}) MqttBrokerConnection homieConnection;
77 private int registeredTopics = 100;
79 // The handler is not tested here, so just mock the callback
80 private @Mock @NonNullByDefault({}) DeviceCallback callback;
82 // A handler mock is required to verify that channel value changes have been received
83 private @Mock @NonNullByDefault({}) HomieThingHandler handler;
85 private @NonNullByDefault({}) ScheduledExecutorService scheduler;
88 * Create an observer that fails the test as soon as the broker client connection changes its connection state
89 * to something else then CONNECTED.
91 private MqttConnectionObserver failIfChange = (state, error) -> assertThat(state,
92 is(MqttConnectionState.CONNECTED));
94 private String propertyTestTopic = "";
98 public void beforeEach() throws Exception {
101 homieConnection = createBrokerConnection("homie");
103 // If the connection state changes in between -> fail
104 homieConnection.addConnectionObserver(failIfChange);
106 List<CompletableFuture<Boolean>> futures = new ArrayList<>();
107 futures.add(publish(DEVICE_TOPIC + "/$homie", "3.0"));
108 futures.add(publish(DEVICE_TOPIC + "/$name", "Name"));
109 futures.add(publish(DEVICE_TOPIC + "/$state", "ready"));
110 futures.add(publish(DEVICE_TOPIC + "/$nodes", "testnode"));
112 // Add homie node topics
113 final String testNode = DEVICE_TOPIC + "/testnode";
114 futures.add(publish(testNode + "/$name", "Testnode"));
115 futures.add(publish(testNode + "/$type", "Type"));
116 futures.add(publish(testNode + "/$properties", "temperature,doorbell,testRetain"));
118 // Add homie property topics
119 final String property = testNode + "/temperature";
120 futures.add(publish(property, "10"));
121 futures.add(publish(property + "/$name", "Testprop"));
122 futures.add(publish(property + "/$settable", "true"));
123 futures.add(publish(property + "/$unit", "°C"));
124 futures.add(publish(property + "/$datatype", "float"));
125 futures.add(publish(property + "/$format", "-100:100"));
127 final String propertyBellTopic = testNode + "/doorbell";
128 futures.add(publish(propertyBellTopic + "/$name", "Doorbell"));
129 futures.add(publish(propertyBellTopic + "/$settable", "false"));
130 futures.add(publish(propertyBellTopic + "/$retained", "false"));
131 futures.add(publish(propertyBellTopic + "/$datatype", "boolean"));
133 this.propertyTestTopic = testNode + "/testRetain";
134 futures.add(publish(propertyTestTopic + "/$name", "Test"));
135 futures.add(publish(propertyTestTopic + "/$settable", "true"));
136 futures.add(publish(propertyTestTopic + "/$retained", "false"));
137 futures.add(publish(propertyTestTopic + "/$datatype", "boolean"));
139 registeredTopics = futures.size();
140 CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).get(5, TimeUnit.SECONDS);
142 scheduler = new ScheduledThreadPoolExecutor(6);
147 public void afterEach() throws Exception {
148 if (homieConnection != null) {
149 homieConnection.removeConnectionObserver(failIfChange);
150 homieConnection.stop().get(5, TimeUnit.SECONDS);
152 if (scheduler != null) {
153 scheduler.shutdownNow();
159 public void retrieveAllTopics() throws Exception {
160 // four topics are not under /testnode !
161 CountDownLatch c = new CountDownLatch(registeredTopics - 4);
162 homieConnection.subscribe(DEVICE_TOPIC + "/testnode/#", (topic, payload) -> c.countDown()).get(5,
164 assertTrue(c.await(5, TimeUnit.SECONDS),
165 "Connection " + homieConnection.getClientId() + " not retrieving all topics ");
169 public void retrieveOneAttribute() throws Exception {
170 WaitForTopicValue watcher = new WaitForTopicValue(homieConnection, DEVICE_TOPIC + "/$homie");
171 assertThat(watcher.waitForTopicValue(1000), is("3.0"));
174 @SuppressWarnings("null")
175 @Disabled("Temporarily disabled: unstable")
177 public void retrieveAttributes() throws Exception {
178 assertThat(homieConnection.hasSubscribers(), is(false));
180 Node node = new Node(DEVICE_TOPIC, "testnode", ThingChannelConstants.TEST_HOME_THING, callback,
181 new NodeAttributes());
182 Property property = spy(
183 new Property(DEVICE_TOPIC + "/testnode", node, "temperature", callback, new PropertyAttributes()));
185 // Create a scheduler
186 ScheduledExecutorService scheduler = new ScheduledThreadPoolExecutor(4);
188 property.subscribe(homieConnection, scheduler, 500).get();
190 assertThat(property.attributes.settable, is(true));
191 assertThat(property.attributes.retained, is(true));
192 assertThat(property.attributes.name, is("Testprop"));
193 assertThat(property.attributes.unit, is("°C"));
194 assertThat(property.attributes.datatype, is(DataTypeEnum.float_));
195 waitForAssert(() -> assertThat(property.attributes.format, is("-100:100")));
196 verify(property, timeout(500).atLeastOnce()).attributesReceived();
198 // Receive property value
199 ChannelState channelState = spy(property.getChannelState());
200 PropertyHelper.setChannelState(property, channelState);
202 property.startChannel(homieConnection, scheduler, 500).get();
203 verify(channelState).start(any(), any(), anyInt());
204 verify(channelState, timeout(500)).processMessage(any(), any());
205 verify(callback).updateChannelState(any(), any());
207 assertThat(property.getChannelState().getCache().getChannelState(),
208 is(new QuantityType<>(10, SIUnits.CELSIUS)));
210 property.stop().get();
211 assertThat(homieConnection.hasSubscribers(), is(false));
214 // Inject a spy'ed property
215 public Property createSpyProperty(InvocationOnMock invocation) {
216 final Node node = (Node) invocation.getMock();
217 final String id = (String) invocation.getArguments()[0];
218 return spy(node.createProperty(id, spy(new PropertyAttributes())));
221 // Inject a spy'ed node
222 public Node createSpyNode(InvocationOnMock invocation) {
223 final Device device = (Device) invocation.getMock();
224 final String id = (String) invocation.getArguments()[0];
226 Node node = spy(device.createNode(id, spy(new NodeAttributes())));
227 // Intercept creating a property in the next call and inject a spy'ed property.
228 doAnswer(this::createSpyProperty).when(node).createProperty(any());
232 @SuppressWarnings("null")
233 @Disabled("Temporarily disabled: unstable")
235 public void parseHomieTree() throws Exception {
236 // Create a Homie Device object. Because spied Nodes are required for call verification,
237 // the full Device constructor need to be used and a ChildMap object need to be created manually.
238 ChildMap<Node> nodeMap = new ChildMap<>();
240 new Device(ThingChannelConstants.TEST_HOME_THING, callback, new DeviceAttributes(), nodeMap));
242 // Intercept creating a node in initialize()->start() and inject a spy'ed node.
243 doAnswer(this::createSpyNode).when(device).createNode(any());
245 // initialize the device, subscribe and wait.
246 device.initialize(BASE_TOPIC, DEVICE_ID, Collections.emptyList());
247 device.subscribe(homieConnection, scheduler, 1500).get();
249 assertThat(device.isInitialized(), is(true));
251 // Check device attributes
252 assertThat(device.attributes.homie, is("3.0"));
253 assertThat(device.attributes.name, is("Name"));
254 assertThat(device.attributes.state, is(ReadyState.ready));
255 assertThat(device.attributes.nodes.length, is(1));
256 verify(device, times(4)).attributeChanged(any(), any(), any(), any(), anyBoolean());
257 verify(callback).readyStateChanged(eq(ReadyState.ready));
258 verify(device).attributesReceived(any(), any(), anyInt());
261 assertThat(device.nodes.size(), is(1));
263 // Check node and node attributes
264 Node node = device.nodes.get("testnode");
265 verify(node).subscribe(any(), any(), anyInt());
266 verify(node).attributesReceived(any(), any(), anyInt());
267 verify(node.attributes).subscribeAndReceive(any(), any(), anyString(), any(), anyInt());
268 assertThat(node.attributes.type, is("Type"));
269 assertThat(node.attributes.name, is("Testnode"));
272 assertThat(node.properties.size(), is(3));
274 // Check property and property attributes
275 Property property = node.properties.get("temperature");
276 assertThat(property.attributes.settable, is(true));
277 assertThat(property.attributes.retained, is(true));
278 assertThat(property.attributes.name, is("Testprop"));
279 assertThat(property.attributes.unit, is("°C"));
280 assertThat(property.attributes.datatype, is(DataTypeEnum.float_));
281 assertThat(property.attributes.format, is("-100:100"));
282 verify(property).attributesReceived();
283 assertNotNull(property.getChannelState());
284 assertThat(property.getType().getState().getMinimum().intValue(), is(-100));
285 assertThat(property.getType().getState().getMaximum().intValue(), is(100));
287 // Check property and property attributes
288 Property propertyBell = node.properties.get("doorbell");
289 verify(propertyBell).attributesReceived();
290 assertThat(propertyBell.attributes.settable, is(false));
291 assertThat(propertyBell.attributes.retained, is(false));
292 assertThat(propertyBell.attributes.name, is("Doorbell"));
293 assertThat(propertyBell.attributes.datatype, is(DataTypeEnum.boolean_));
295 // The device->node->property tree is ready. Now subscribe to property values.
296 device.startChannels(homieConnection, scheduler, 50, handler).get();
297 assertThat(propertyBell.getChannelState().isStateful(), is(false));
298 assertThat(propertyBell.getChannelState().getCache().getChannelState(), is(UnDefType.UNDEF));
299 assertThat(property.getChannelState().getCache().getChannelState(),
300 is(new QuantityType<>(10, SIUnits.CELSIUS)));
302 property = node.properties.get("testRetain");
303 WaitForTopicValue watcher = new WaitForTopicValue(brokerConnection, propertyTestTopic + "/set");
304 // Watch the topic. Publish a retain=false value to MQTT
305 property.getChannelState().publishValue(OnOffType.OFF).get();
306 assertThat(watcher.waitForTopicValue(10000), is("false"));
308 // Publish a retain=false value to MQTT.
309 property.getChannelState().publishValue(OnOffType.ON).get();
310 // No value is expected to be retained on this MQTT topic
311 waitForAssert(() -> {
312 WaitForTopicValue w = new WaitForTopicValue(brokerConnection, propertyTestTopic + "/set");
313 assertNull(w.waitForTopicValue(50));