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.velux.internal.discovery;
15 import java.io.ByteArrayInputStream;
16 import java.io.ByteArrayOutputStream;
17 import java.io.Closeable;
18 import java.io.DataInputStream;
19 import java.io.DataOutputStream;
20 import java.io.IOException;
21 import java.net.DatagramPacket;
22 import java.net.DatagramSocket;
23 import java.net.InetAddress;
24 import java.net.MulticastSocket;
25 import java.net.SocketTimeoutException;
26 import java.nio.charset.StandardCharsets;
27 import java.util.HashSet;
28 import java.util.Random;
30 import java.util.concurrent.Callable;
31 import java.util.concurrent.ExecutionException;
32 import java.util.concurrent.Future;
33 import java.util.concurrent.ScheduledExecutorService;
35 import org.eclipse.jdt.annotation.NonNullByDefault;
36 import org.eclipse.jdt.annotation.Nullable;
37 import org.slf4j.Logger;
38 import org.slf4j.LoggerFactory;
41 * Class that uses Multicast DNS (mDNS) to discover Velux Bridges and return their ipv4 addresses
43 * @author Andrew Fiddian-Green - Initial contribution
46 public class VeluxBridgeFinder implements Closeable {
48 private final Logger logger = LoggerFactory.getLogger(VeluxBridgeFinder.class);
51 private static final int BUFFER_SIZE = 256;
52 private static final int SLEEP_MSECS = 100;
53 private static final int SOCKET_TIMEOUT_MSECS = 500;
54 private static final int SEARCH_DURATION_MSECS = 5000;
55 private static final int REPEAT_COUNT = 3;
57 // dns communication constants
58 private static final int MDNS_PORT = 5353;
59 private static final String MDNS_ADDR = "224.0.0.251";
62 private static final short FLAGS_QR = (short) 0x8000;
63 private static final short FLAGS_AA = 0x0400;
65 // dns message class constants
66 private static final short CLASS_IN = 0x0001;
67 private static final short CLASS_MASK = 0x7FFF;
69 // dns message type constants
70 private static final short TYPE_PTR = 0x000c;
72 private static final byte NULL = 0x00;
74 // Velux bridge identifiers
75 private static final String KLF_SERVICE_ID = "_http._tcp.local";
76 private static final String KLF_HOST_PREFIX = "VELUX_KLF_";
78 private short randomQueryId;
79 private ScheduledExecutorService executor;
80 private @Nullable Listener listener = null;
82 private class Listener implements Callable<Set<String>> {
84 private boolean interrupted = false;
85 private boolean started = false;
87 public void interrupt() {
91 public boolean hasStarted() {
96 * Listens for Velux Bridges and returns their IP addresses. It loops for SEARCH_DURATION_MSECS or until
97 * 'interrupt()' or 'Thread.interrupted()' are called when it terminates early after the next socket read
98 * timeout i.e. after SOCKET_TIMEOUT_MSECS
100 * @return a set of strings containing dotted IP addresses e.g. '123.123.123.123'
103 public Set<String> call() throws Exception {
104 final Set<String> ipAddresses = new HashSet<>();
106 // create a multicast listener socket
107 try (MulticastSocket rcvSocket = new MulticastSocket(MDNS_PORT)) {
108 final byte[] rcvBytes = new byte[BUFFER_SIZE];
109 final long finishTime = System.currentTimeMillis() + SEARCH_DURATION_MSECS;
111 rcvSocket.setReuseAddress(true);
112 rcvSocket.joinGroup(InetAddress.getByName(MDNS_ADDR));
113 rcvSocket.setSoTimeout(SOCKET_TIMEOUT_MSECS);
115 // tell the caller that we are ready to roll
118 // loop until time out or internally or externally interrupted
119 while ((System.currentTimeMillis() < finishTime) && (!interrupted) && (!Thread.interrupted())) {
121 DatagramPacket rcvPacket = new DatagramPacket(rcvBytes, rcvBytes.length);
123 rcvSocket.receive(rcvPacket);
124 if (isKlfLanResponse(rcvPacket.getData())) {
125 ipAddresses.add(rcvPacket.getAddress().getHostAddress());
127 } catch (SocketTimeoutException e) {
128 // time out is ok, continue listening
132 } catch (IOException e) {
133 logger.debug("listenerRunnable(): udp socket exception '{}'", e.getMessage());
135 // prevent caller waiting forever in case start up failed
142 * Build an mDNS query package to query SERVICE_ID looking for host names
144 * @return a byte array containing the query datagram payload, or an empty array if failed
146 private byte[] buildQuery() {
147 ByteArrayOutputStream byteStream = new ByteArrayOutputStream(BUFFER_SIZE);
148 DataOutputStream dataStream = new DataOutputStream(byteStream);
150 dataStream.writeShort(randomQueryId); // id
151 dataStream.writeShort(0); // flags
152 dataStream.writeShort(1); // qdCount
153 dataStream.writeShort(0); // anCount
154 dataStream.writeShort(0); // nsCount
155 dataStream.writeShort(0); // arCount
156 for (String segString : KLF_SERVICE_ID.split("\\.")) {
157 byte[] segBytes = segString.getBytes(StandardCharsets.UTF_8);
158 dataStream.writeByte(segBytes.length); // length
159 dataStream.write(segBytes); // byte string
161 dataStream.writeByte(NULL); // end of name
162 dataStream.writeShort(TYPE_PTR); // type
163 dataStream.writeShort(CLASS_IN); // class
164 return byteStream.toByteArray();
165 } catch (IOException e) {
172 * Parse an mDNS response package and check if it is from a KLF bridge
174 * @param responsePayload a byte array containing the response datagram payload
175 * @return true if the response is from a KLF bridge
177 private boolean isKlfLanResponse(byte[] responsePayload) {
178 DataInputStream dataStream = new DataInputStream(new ByteArrayInputStream(responsePayload));
180 // check if the package id matches the query
181 short id = dataStream.readShort();
182 if (id == randomQueryId) {
183 short flags = dataStream.readShort();
184 boolean isResponse = (flags & FLAGS_QR) == FLAGS_QR;
185 boolean isAuthoritative = (flags & FLAGS_AA) == FLAGS_AA;
187 // check if it is an authoritative response
188 if (isResponse && isAuthoritative) {
189 short qdCount = dataStream.readShort();
190 short anCount = dataStream.readShort();
192 dataStream.readShort(); // nsCount
193 dataStream.readShort(); // arCount
195 // check it is an answer (and not a query)
196 if ((anCount == 0) || (qdCount != 0)) {
201 for (short an = 0; an < anCount; an++) {
203 byte[] str = new byte[BUFFER_SIZE];
206 while ((segLength = dataStream.readByte()) > 0) {
207 i += dataStream.read(str, i, segLength);
211 String name = new String(str, 0, i, StandardCharsets.UTF_8);
212 short typ = dataStream.readShort();
213 short clazz = (short) (CLASS_MASK & dataStream.readShort());
214 if (!(name.startsWith(KLF_SERVICE_ID)) || (typ != TYPE_PTR) || (clazz != CLASS_IN)) {
218 // if we got here, the name and response type are valid
219 dataStream.readInt(); // TTL
220 dataStream.readShort(); // dataLen
222 // parse the host name
224 while ((segLength = dataStream.readByte()) > 0) {
225 i += dataStream.read(str, i, segLength);
230 // check if the host name matches
231 String host = new String(str, 0, i, StandardCharsets.UTF_8);
232 if (host.startsWith(KLF_HOST_PREFIX)) {
238 } catch (IOException e) {
245 * Private synchronized method that searches for Velux Bridges and returns their IP addresses. Takes
246 * SEARCH_DURATION_MSECS to complete.
248 * @return a set of strings containing dotted IP addresses e.g. '123.123.123.123'
250 private synchronized Set<String> discoverBridgeIpAddresses() {
252 Set<String> result = null;
254 // create a random query id
255 Random random = new Random();
256 randomQueryId = (short) random.nextInt(Short.MAX_VALUE);
258 // create the listener task and start it
259 Listener listener = this.listener = new Listener();
261 // create a datagram socket
262 try (DatagramSocket socket = new DatagramSocket()) {
263 // prepare query packet
264 byte[] dnsBytes = buildQuery();
265 DatagramPacket dnsPacket = new DatagramPacket(dnsBytes, 0, dnsBytes.length,
266 InetAddress.getByName(MDNS_ADDR), MDNS_PORT);
268 // create listener and wait until it has started
269 Future<Set<String>> future = executor.submit(listener);
270 while (!listener.hasStarted()) {
271 Thread.sleep(SLEEP_MSECS);
274 // send the query several times
275 for (int i = 0; i < REPEAT_COUNT; i++) {
276 // send the query several times
277 socket.send(dnsPacket);
278 Thread.sleep(SLEEP_MSECS);
281 // wait for the listener future to get the result
282 result = future.get();
283 } catch (InterruptedException | IOException | ExecutionException e) {
284 logger.debug("discoverBridgeIpAddresses(): unexpected exception '{}'", e.getMessage());
287 // clean up listener task (just in case) and return
288 listener.interrupt();
289 this.listener = null;
290 return result != null ? result : new HashSet<>();
296 * @param executor the caller's task executor
298 public VeluxBridgeFinder(ScheduledExecutorService executor) {
299 this.executor = executor;
303 * Interrupt the {@link Listener}
305 * @throws IOException (not)
308 public void close() throws IOException {
309 Listener listener = this.listener;
310 if (listener != null) {
311 listener.interrupt();
312 this.listener = null;
317 * Static method to search for Velux Bridges and return their IP addresses. NOTE: it takes SEARCH_DURATION_MSECS to
318 * complete, so don't call it on the main thread!
320 * @return set of dotted IP address e.g. '123.123.123.123'
322 public static Set<String> discoverIpAddresses(ScheduledExecutorService scheduler) {
323 try (VeluxBridgeFinder finder = new VeluxBridgeFinder(scheduler)) {
324 return finder.discoverBridgeIpAddresses();
325 } catch (IOException e) {
326 return new HashSet<>();