]> git.basschouten.com Git - openhab-addons.git/blob
28135c4c773872a939ea3ff66e4db0e125eb1202
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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                 final byte[] rcvBytes = new byte[BUFFER_SIZE];
109                 final long finishTime = System.currentTimeMillis() + SEARCH_DURATION_MSECS;
110
111                 rcvSocket.setReuseAddress(true);
112                 rcvSocket.joinGroup(InetAddress.getByName(MDNS_ADDR));
113                 rcvSocket.setSoTimeout(SOCKET_TIMEOUT_MSECS);
114
115                 // tell the caller that we are ready to roll
116                 started = true;
117
118                 // loop until time out or internally or externally interrupted
119                 while ((System.currentTimeMillis() < finishTime) && (!interrupted) && (!Thread.interrupted())) {
120                     // read next packet
121                     DatagramPacket rcvPacket = new DatagramPacket(rcvBytes, rcvBytes.length);
122                     try {
123                         rcvSocket.receive(rcvPacket);
124                         if (isKlfLanResponse(rcvPacket.getData())) {
125                             ipAddresses.add(rcvPacket.getAddress().getHostAddress());
126                         }
127                     } catch (SocketTimeoutException e) {
128                         // time out is ok, continue listening
129                         continue;
130                     }
131                 }
132             } catch (IOException e) {
133                 logger.debug("listenerRunnable(): udp socket exception '{}'", e.getMessage());
134             }
135             // prevent caller waiting forever in case start up failed
136             started = true;
137             return ipAddresses;
138         }
139     }
140
141     /**
142      * Build an mDNS query package to query SERVICE_ID looking for host names
143      *
144      * @return a byte array containing the query datagram payload, or an empty array if failed
145      */
146     private byte[] buildQuery() {
147         ByteArrayOutputStream byteStream = new ByteArrayOutputStream(BUFFER_SIZE);
148         DataOutputStream dataStream = new DataOutputStream(byteStream);
149         try {
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
160             }
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) {
166             // fall through
167         }
168         return new byte[0];
169     }
170
171     /**
172      * Parse an mDNS response package and check if it is from a KLF bridge
173      *
174      * @param responsePayload a byte array containing the response datagram payload
175      * @return true if the response is from a KLF bridge
176      */
177     private boolean isKlfLanResponse(byte[] responsePayload) {
178         DataInputStream dataStream = new DataInputStream(new ByteArrayInputStream(responsePayload));
179         try {
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;
186
187                 // check if it is an authoritative response
188                 if (isResponse && isAuthoritative) {
189                     short qdCount = dataStream.readShort();
190                     short anCount = dataStream.readShort();
191
192                     dataStream.readShort(); // nsCount
193                     dataStream.readShort(); // arCount
194
195                     // check it is an answer (and not a query)
196                     if ((anCount == 0) || (qdCount != 0)) {
197                         return false;
198                     }
199
200                     // parse the answers
201                     for (short an = 0; an < anCount; an++) {
202                         // parse the name
203                         byte[] str = new byte[BUFFER_SIZE];
204                         int i = 0;
205                         int segLength;
206                         while ((segLength = dataStream.readByte()) > 0) {
207                             i += dataStream.read(str, i, segLength);
208                             str[i] = '.';
209                             i++;
210                         }
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)) {
215                             return false;
216                         }
217
218                         // if we got here, the name and response type are valid
219                         dataStream.readInt(); // TTL
220                         dataStream.readShort(); // dataLen
221
222                         // parse the host name
223                         i = 0;
224                         while ((segLength = dataStream.readByte()) > 0) {
225                             i += dataStream.read(str, i, segLength);
226                             str[i] = '.';
227                             i++;
228                         }
229
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)) {
233                             return true;
234                         }
235                     }
236                 }
237             }
238         } catch (IOException e) {
239             // fall through
240         }
241         return false;
242     }
243
244     /**
245      * Private synchronized method that searches for Velux Bridges and returns their IP addresses. Takes
246      * SEARCH_DURATION_MSECS to complete.
247      *
248      * @return a set of strings containing dotted IP addresses e.g. '123.123.123.123'
249      */
250     private synchronized Set<String> discoverBridgeIpAddresses() {
251         @Nullable
252         Set<String> result = null;
253
254         // create a random query id
255         Random random = new Random();
256         randomQueryId = (short) random.nextInt(Short.MAX_VALUE);
257
258         // create the listener task and start it
259         Listener listener = this.listener = new Listener();
260
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);
267
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);
272             }
273
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);
279             }
280
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());
285         }
286
287         // clean up listener task (just in case) and return
288         listener.interrupt();
289         this.listener = null;
290         return result != null ? result : new HashSet<>();
291     }
292
293     /**
294      * Constructor
295      *
296      * @param executor the caller's task executor
297      */
298     public VeluxBridgeFinder(ScheduledExecutorService executor) {
299         this.executor = executor;
300     }
301
302     /**
303      * Interrupt the {@link Listener}
304      *
305      * @throws IOException (not)
306      */
307     @Override
308     public void close() throws IOException {
309         Listener listener = this.listener;
310         if (listener != null) {
311             listener.interrupt();
312             this.listener = null;
313         }
314     }
315
316     /**
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!
319      *
320      * @return set of dotted IP address e.g. '123.123.123.123'
321      */
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<>();
327         }
328     }
329 }