]> git.basschouten.com Git - openhab-addons.git/blob
1c3511307f9bccae37eeb8411bf13fc4f6e09d4a
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2020 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.velux.internal.discovery;
14
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;
29 import java.util.Set;
30 import java.util.concurrent.Callable;
31 import java.util.concurrent.ExecutionException;
32 import java.util.concurrent.Future;
33 import java.util.concurrent.ScheduledExecutorService;
34
35 import org.eclipse.jdt.annotation.NonNullByDefault;
36 import org.eclipse.jdt.annotation.Nullable;
37 import org.slf4j.Logger;
38 import org.slf4j.LoggerFactory;
39
40 /**
41  * Class that uses Multicast DNS (mDNS) to discover Velux Bridges and return their ipv4 addresses
42  *
43  * @author Andrew Fiddian-Green - Initial contribution
44  */
45 @NonNullByDefault
46 public class VeluxBridgeFinder implements Closeable {
47
48     private final Logger logger = LoggerFactory.getLogger(VeluxBridgeFinder.class);
49
50     // timing constants
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;
56
57     // dns communication constants
58     private static final int MDNS_PORT = 5353;
59     private static final String MDNS_ADDR = "224.0.0.251";
60
61     // dns flag constants
62     private static final short FLAGS_QR = (short) 0x8000;
63     private static final short FLAGS_AA = 0x0400;
64
65     // dns message class constants
66     private static final short CLASS_IN = 0x0001;
67     private static final short CLASS_MASK = 0x7FFF;
68
69     // dns message type constants
70     private static final short TYPE_PTR = 0x000c;
71
72     private static final byte NULL = 0x00;
73
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_";
77
78     private short randomQueryId;
79     private ScheduledExecutorService executor;
80     private @Nullable Listener listener = null;
81
82     private class Listener implements Callable<Set<String>> {
83
84         private boolean interrupted = false;
85         private boolean started = false;
86
87         public void interrupt() {
88             interrupted = true;
89         }
90
91         public boolean hasStarted() {
92             return started;
93         }
94
95         /**
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
99          *
100          * @return a set of strings containing dotted IP addresses e.g. '123.123.123.123'
101          */
102         @Override
103         public Set<String> call() throws Exception {
104             final Set<String> ipAddresses = new HashSet<>();
105
106             // create a multicast listener socket
107             try (MulticastSocket rcvSocket = new MulticastSocket(MDNS_PORT)) {
108
109                 final byte[] rcvBytes = new byte[BUFFER_SIZE];
110                 final long finishTime = System.currentTimeMillis() + SEARCH_DURATION_MSECS;
111
112                 rcvSocket.setReuseAddress(true);
113                 rcvSocket.joinGroup(InetAddress.getByName(MDNS_ADDR));
114                 rcvSocket.setSoTimeout(SOCKET_TIMEOUT_MSECS);
115
116                 // tell the caller that we are ready to roll
117                 started = true;
118
119                 // loop until time out or internally or externally interrupted
120                 while ((System.currentTimeMillis() < finishTime) && (!interrupted) && (!Thread.interrupted())) {
121                     // read next packet
122                     DatagramPacket rcvPacket = new DatagramPacket(rcvBytes, rcvBytes.length);
123                     try {
124                         rcvSocket.receive(rcvPacket);
125                         if (isKlfLanResponse(rcvPacket.getData())) {
126                             ipAddresses.add(rcvPacket.getAddress().getHostAddress());
127                         }
128                     } catch (SocketTimeoutException e) {
129                         // time out is ok, continue listening
130                         continue;
131                     }
132                 }
133             } catch (IOException e) {
134                 logger.debug("listenerRunnable(): udp socket exception '{}'", e.getMessage());
135             }
136             // prevent caller waiting forever in case start up failed
137             started = true;
138             return ipAddresses;
139         }
140     }
141
142     /**
143      * Build an mDNS query package to query SERVICE_ID looking for host names
144      *
145      * @return a byte array containing the query datagram payload, or an empty array if failed
146      */
147     private byte[] buildQuery() {
148         ByteArrayOutputStream byteStream = new ByteArrayOutputStream(BUFFER_SIZE);
149         DataOutputStream dataStream = new DataOutputStream(byteStream);
150         try {
151             dataStream.writeShort(randomQueryId); // id
152             dataStream.writeShort(0); // flags
153             dataStream.writeShort(1); // qdCount
154             dataStream.writeShort(0); // anCount
155             dataStream.writeShort(0); // nsCount
156             dataStream.writeShort(0); // arCount
157             for (String segString : KLF_SERVICE_ID.split("\\.")) {
158                 byte[] segBytes = segString.getBytes(StandardCharsets.UTF_8);
159                 dataStream.writeByte(segBytes.length); // length
160                 dataStream.write(segBytes); // byte string
161             }
162             dataStream.writeByte(NULL); // end of name
163             dataStream.writeShort(TYPE_PTR); // type
164             dataStream.writeShort(CLASS_IN); // class
165             return byteStream.toByteArray();
166         } catch (IOException e) {
167             // fall through
168         }
169         return new byte[0];
170     }
171
172     /**
173      * Parse an mDNS response package and check if it is from a KLF bridge
174      *
175      * @param responsePayload a byte array containing the response datagram payload
176      * @return true if the response is from a KLF bridge
177      */
178     private boolean isKlfLanResponse(byte[] responsePayload) {
179         DataInputStream dataStream = new DataInputStream(new ByteArrayInputStream(responsePayload));
180         try {
181             // check if the package id matches the query
182             short id = dataStream.readShort();
183             if (id == randomQueryId) {
184                 short flags = dataStream.readShort();
185                 boolean isResponse = (flags & FLAGS_QR) == FLAGS_QR;
186                 boolean isAuthoritative = (flags & FLAGS_AA) == FLAGS_AA;
187
188                 // check if it is an authoritative response
189                 if (isResponse && isAuthoritative) {
190                     short qdCount = dataStream.readShort();
191                     short anCount = dataStream.readShort();
192
193                     dataStream.readShort(); // nsCount
194                     dataStream.readShort(); // arCount
195
196                     // check it is an answer (and not a query)
197                     if ((anCount == 0) || (qdCount != 0)) {
198                         return false;
199                     }
200
201                     // parse the answers
202                     for (short an = 0; an < anCount; an++) {
203                         // parse the name
204                         byte[] str = new byte[BUFFER_SIZE];
205                         int i = 0;
206                         int segLength;
207                         while ((segLength = dataStream.readByte()) > 0) {
208                             i += dataStream.read(str, i, segLength);
209                             str[i] = '.';
210                             i++;
211                         }
212                         String name = new String(str, 0, i, StandardCharsets.UTF_8);
213                         short typ = dataStream.readShort();
214                         short clazz = (short) (CLASS_MASK & dataStream.readShort());
215                         if (!(name.startsWith(KLF_SERVICE_ID)) || (typ != TYPE_PTR) || (clazz != CLASS_IN)) {
216                             return false;
217                         }
218
219                         // if we got here, the name and response type are valid
220                         dataStream.readInt(); // TTL
221                         dataStream.readShort(); // dataLen
222
223                         // parse the host name
224                         i = 0;
225                         while ((segLength = dataStream.readByte()) > 0) {
226                             i += dataStream.read(str, i, segLength);
227                             str[i] = '.';
228                             i++;
229                         }
230
231                         // check if the host name matches
232                         String host = new String(str, 0, i, StandardCharsets.UTF_8);
233                         if (host.startsWith(KLF_HOST_PREFIX)) {
234                             return true;
235                         }
236                     }
237                 }
238             }
239         } catch (IOException e) {
240             // fall through
241         }
242         return false;
243     }
244
245     /**
246      * Private synchronized method that searches for Velux Bridges and returns their IP addresses. Takes
247      * SEARCH_DURATION_MSECS to complete.
248      *
249      * @return a set of strings containing dotted IP addresses e.g. '123.123.123.123'
250      */
251     private synchronized Set<String> discoverBridgeIpAddresses() {
252         @Nullable
253         Set<String> result = null;
254
255         // create a random query id
256         Random random = new Random();
257         randomQueryId = (short) random.nextInt(Short.MAX_VALUE);
258
259         // create the listener task and start it
260         Listener listener = this.listener = new Listener();
261
262         // create a datagram socket
263         try (DatagramSocket socket = new DatagramSocket()) {
264             // prepare query packet
265             byte[] dnsBytes = buildQuery();
266             DatagramPacket dnsPacket = new DatagramPacket(dnsBytes, 0, dnsBytes.length,
267                     InetAddress.getByName(MDNS_ADDR), MDNS_PORT);
268
269             // create listener and wait until it has started
270             Future<Set<String>> future = executor.submit(listener);
271             while (!listener.hasStarted()) {
272                 Thread.sleep(SLEEP_MSECS);
273             }
274
275             // send the query several times
276             for (int i = 0; i < REPEAT_COUNT; i++) {
277                 // send the query several times
278                 socket.send(dnsPacket);
279                 Thread.sleep(SLEEP_MSECS);
280             }
281
282             // wait for the listener future to get the result
283             result = future.get();
284         } catch (InterruptedException | IOException | ExecutionException e) {
285             logger.debug("discoverBridgeIpAddresses(): unexpected exception '{}'", e.getMessage());
286         }
287
288         // clean up listener task (just in case) and return
289         listener.interrupt();
290         this.listener = null;
291         return result != null ? result : new HashSet<>();
292     }
293
294     /**
295      * Constructor
296      *
297      * @param executor the caller's task executor
298      */
299     public VeluxBridgeFinder(ScheduledExecutorService executor) {
300         this.executor = executor;
301     }
302
303     /**
304      * Interrupt the {@link Listener}
305      *
306      * @throws IOException (not)
307      */
308     @Override
309     public void close() throws IOException {
310         Listener listener = this.listener;
311         if (listener != null) {
312             listener.interrupt();
313             this.listener = null;
314         }
315     }
316
317     /**
318      * Static method to search for Velux Bridges and return their IP addresses. NOTE: it takes SEARCH_DURATION_MSECS to
319      * complete, so don't call it on the main thread!
320      *
321      * @return set of dotted IP address e.g. '123.123.123.123'
322      */
323     public static Set<String> discoverIpAddresses(ScheduledExecutorService scheduler) {
324         try (VeluxBridgeFinder finder = new VeluxBridgeFinder(scheduler)) {
325             return finder.discoverBridgeIpAddresses();
326         } catch (IOException e) {
327             return new HashSet<>();
328         }
329     }
330 }