]> git.basschouten.com Git - openhab-addons.git/blob
a973b76b848a8e0a07b3e672c41217c26c86e992
[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.io.hueemulation.internal.upnp;
14
15 import java.io.BufferedReader;
16 import java.io.IOException;
17 import java.io.InputStream;
18 import java.io.InputStreamReader;
19 import java.io.PrintWriter;
20 import java.net.DatagramPacket;
21 import java.net.DatagramSocket;
22 import java.net.Inet4Address;
23 import java.net.Inet6Address;
24 import java.net.InetAddress;
25 import java.net.InetSocketAddress;
26 import java.net.NetworkInterface;
27 import java.net.SocketAddress;
28 import java.net.StandardProtocolFamily;
29 import java.net.StandardSocketOptions;
30 import java.net.UnknownHostException;
31 import java.nio.ByteBuffer;
32 import java.nio.channels.ClosedSelectorException;
33 import java.nio.channels.DatagramChannel;
34 import java.nio.channels.SelectionKey;
35 import java.nio.channels.Selector;
36 import java.nio.charset.StandardCharsets;
37 import java.time.Instant;
38 import java.util.ArrayList;
39 import java.util.Iterator;
40 import java.util.List;
41 import java.util.concurrent.CompletableFuture;
42 import java.util.concurrent.Executor;
43 import java.util.concurrent.ForkJoinPool;
44 import java.util.concurrent.TimeUnit;
45 import java.util.function.Consumer;
46 import java.util.stream.Collectors;
47
48 import javax.servlet.ServletException;
49 import javax.servlet.http.HttpServlet;
50 import javax.servlet.http.HttpServletRequest;
51 import javax.servlet.http.HttpServletResponse;
52 import javax.ws.rs.ProcessingException;
53 import javax.ws.rs.client.Client;
54 import javax.ws.rs.client.ClientBuilder;
55 import javax.ws.rs.core.Response;
56
57 import org.eclipse.jdt.annotation.NonNullByDefault;
58 import org.eclipse.jdt.annotation.Nullable;
59 import org.openhab.io.hueemulation.internal.ConfigStore;
60 import org.osgi.service.component.annotations.Activate;
61 import org.osgi.service.component.annotations.Component;
62 import org.osgi.service.component.annotations.ConfigurationPolicy;
63 import org.osgi.service.component.annotations.Deactivate;
64 import org.osgi.service.component.annotations.Reference;
65 import org.osgi.service.event.Event;
66 import org.osgi.service.event.EventAdmin;
67 import org.osgi.service.event.EventConstants;
68 import org.osgi.service.event.EventHandler;
69 import org.osgi.service.http.HttpService;
70 import org.osgi.service.http.NamespaceException;
71 import org.slf4j.Logger;
72 import org.slf4j.LoggerFactory;
73
74 /**
75  * Advertises a Hue compatible bridge via UPNP and provides the announced /description.xml http endpoint.
76  *
77  * @author Dan Cunningham - Initial contribution
78  * @author David Graeff - Rewritten
79  */
80 @SuppressWarnings("serial")
81 @NonNullByDefault
82 @Component(immediate = false, // Don't start the upnp server on its own. Must be pulled in by HueEmulationService.
83         configurationPolicy = ConfigurationPolicy.IGNORE, property = {
84                 EventConstants.EVENT_TOPIC + "=" + ConfigStore.EVENT_ADDRESS_CHANGED }, //
85         service = { UpnpServer.class, EventHandler.class })
86 public class UpnpServer extends HttpServlet implements Consumer<HueEmulationConfigWithRuntime>, EventHandler {
87     /**
88      * Used by async IO. This is our context object class.
89      */
90     static class ClientRecord {
91         public @Nullable SocketAddress clientAddress;
92         public ByteBuffer buffer = ByteBuffer.allocate(1000);
93     }
94
95     public static final String DISCOVERY_FILE = "/description.xml";
96
97     // jUPNP shares port 1900, but since this is multicast, we can also bind to it
98     public static final int UPNP_PORT = 1900;
99     /**
100      * Send a keep alive every 2 minutes
101      */
102     private static final int CACHE_MSECS = 120 * 1000;
103
104     private final Logger logger = LoggerFactory.getLogger(UpnpServer.class);
105
106     public final InetAddress MULTI_ADDR_IPV4;
107     public final InetAddress MULTI_ADDR_IPV6;
108     private String[] stVersions = { "", "", "" };
109     private String notifyMsg = "";
110
111     //// objects, set within activate()
112     protected @NonNullByDefault({}) String xmlDoc;
113     protected @NonNullByDefault({}) String xmlDocWithAddress;
114     private @NonNullByDefault({}) String baseurl;
115
116     //// services
117     @Reference
118     protected @NonNullByDefault({}) ConfigStore cs;
119     @Reference
120     protected @NonNullByDefault({}) HttpService httpService;
121
122     @Reference
123     protected @NonNullByDefault({}) ClientBuilder clientBuilder;
124
125     public boolean overwriteReadyToFalse = false;
126
127     private HueEmulationConfigWithRuntime config;
128     protected CompletableFuture<@Nullable HueEmulationConfigWithRuntime> configChangeFuture = CompletableFuture
129             .completedFuture(config);
130
131     private List<SelfTestReport> selfTests = new ArrayList<>();
132     private final Executor executor;
133
134     /**
135      * Creates a server instance.
136      * UPnP IPv4/v6 multicast addresses are determined.
137      */
138     public UpnpServer() {
139         this(ForkJoinPool.commonPool());
140     }
141
142     public UpnpServer(Executor executor) {
143         try {
144             MULTI_ADDR_IPV4 = InetAddress.getByName("239.255.255.250");
145             MULTI_ADDR_IPV6 = InetAddress.getByName("ff02::c");
146             config = new HueEmulationConfigWithRuntime(this, null, MULTI_ADDR_IPV4, MULTI_ADDR_IPV6);
147         } catch (UnknownHostException e) {
148             throw new IllegalStateException(e);
149         }
150
151         this.executor = executor;
152     }
153
154     /**
155      * this object is also a servlet for providing /description.xml, the UPnP discovery result
156      */
157     @NonNullByDefault({})
158     @Override
159     protected void service(HttpServletRequest req, HttpServletResponse resp) throws IOException {
160         if (xmlDocWithAddress == null || xmlDocWithAddress.isEmpty()) {
161             resp.sendError(HttpServletResponse.SC_NOT_FOUND);
162             return;
163         }
164         try (PrintWriter out = resp.getWriter()) {
165             resp.setContentType("application/xml");
166             out.write(xmlDocWithAddress);
167         }
168     }
169
170     /**
171      * Server to send UDP packets onto the network when requested by a Hue API compatible device.
172      *
173      * @param relativePath The URI path where the discovery xml document can be retrieved
174      * @param config The hue datastore. Contains the bridgeid and uuid.
175      * @param address IP to advertise for UPNP
176      * @throws IOException
177      * @throws NamespaceException
178      * @throws ServletException
179      */
180     @Activate
181     protected void activate() {
182         InputStream resourceAsStream = getClass().getClassLoader().getResourceAsStream("discovery.xml");
183         if (resourceAsStream == null) {
184             logger.warn("Could not start Hue Emulation service: discovery.xml not found");
185             return;
186         }
187         try (InputStreamReader r = new InputStreamReader(resourceAsStream, StandardCharsets.UTF_8);
188                 BufferedReader br = new BufferedReader(r)) {
189             xmlDoc = br.lines().collect(Collectors.joining("\n"));
190         } catch (IOException e) {
191             logger.warn("Could not start Hue Emulation UPNP server: {}", e.getMessage(), e);
192             return;
193         }
194
195         try {
196             httpService.unregister(DISCOVERY_FILE);
197         } catch (RuntimeException ignore) {
198         }
199
200         try {
201             httpService.registerServlet(DISCOVERY_FILE, this, null, null);
202         } catch (ServletException | NamespaceException e) {
203             logger.warn("Could not start Hue Emulation UPNP server: {}", e.getMessage(), e);
204         }
205
206         if (cs.isReady() && !overwriteReadyToFalse) {
207             handleEvent(null);
208         }
209     }
210
211     private void useAddressPort(HueEmulationConfigWithRuntime r) {
212         final String urlBase = "http://" + r.addressString + ":" + r.port;
213         this.baseurl = urlBase + DISCOVERY_FILE;
214
215         final String[] stVersions = { "upnp:rootdevice", "urn:schemas-upnp-org:device:basic:1",
216                 "uuid:" + config.config.uuid };
217         for (int i = 0; i < stVersions.length; ++i) {
218             this.stVersions[i] = String.format(
219                     "HTTP/1.1 200 OK\r\n" + "HOST: %s:%d\r\n" + "EXT:\r\n" + "CACHE-CONTROL: max-age=%d\r\n"
220                             + "LOCATION: %s\r\n" + "SERVER: Linux/3.14.0 UPnP/1.0 IpBridge/%s\r\n"
221                             + "hue-bridgeid: %s\r\n" + "ST: %s\r\n" + "USN: uuid:%s\r\n\r\n",
222                     r.getMulticastAddress(), UPNP_PORT, CACHE_MSECS / 1000, baseurl, // host:port,
223                                                                                      // cache,location
224                     cs.ds.config.apiversion, cs.ds.config.bridgeid, // version, bridgeid
225                     stVersions[i], config.config.uuid);
226         }
227
228         this.notifyMsg = String.format(
229                 "NOTIFY * HTTP/1.1\r\n" + "HOST: %s:%d\r\n" + "CACHE-CONTROL: max-age=%d\r\n" + "LOCATION: %s\r\n"
230                         + "SERVER: Linux/3.14.0 UPnP/1.0 IpBridge/%s\r\nNTS: ssdp:alive\r\nNT: upnp:rootdevice\r\n"
231                         + "USN: uuid:%s::upnp:rootdevice\r\n" + "hue-bridgeid: %s\r\n\r\n",
232                 r.getMulticastAddress(), UPNP_PORT, CACHE_MSECS / 1000, baseurl, // host:port, cache,location
233                 cs.ds.config.apiversion, config.config.uuid, cs.ds.config.bridgeid);// version, uuid, bridgeid
234
235         xmlDocWithAddress = String.format(xmlDoc, urlBase, r.addressString, cs.ds.config.bridgeid, cs.ds.config.uuid,
236                 cs.ds.config.devicename);
237     }
238
239     protected @Nullable HueEmulationConfigWithRuntime performAddressTest(
240             @Nullable HueEmulationConfigWithRuntime config) {
241         if (config == null) {
242             return null; // Config hasn't changed
243         }
244
245         selfTests.clear();
246         Client client = clientBuilder.connectTimeout(1, TimeUnit.SECONDS).readTimeout(1, TimeUnit.SECONDS).build();
247         Response response;
248         String url = "";
249         try {
250             boolean selfTestOnPort80;
251             try {
252                 response = client.target("http://" + cs.ds.config.ipaddress + ":80" + DISCOVERY_FILE).request().get();
253                 selfTestOnPort80 = response.getStatus() == 200
254                         && response.readEntity(String.class).contains(cs.ds.config.bridgeid);
255             } catch (ProcessingException ignored) {
256                 selfTestOnPort80 = false;
257             }
258
259             // Prefer port 80 if possible and if not overwritten by discoveryHttpPort
260             config.port = config.config.discoveryHttpPort == 0 && selfTestOnPort80 ? 80 : config.port;
261
262             // Test on all assigned interface addresses on org.osgi.service.http.port as well as port 80
263             // Other services might run on port 80, so we search for our bridge ID in the returned document.
264             for (InetAddress address : cs.getDiscoveryIps()) {
265                 String ip = address.getHostAddress();
266                 if (address instanceof Inet6Address) {
267                     ip = "[" + ip.split("%")[0] + "]";
268                 }
269                 try {
270                     url = "http://" + ip + ":" + config.port + DISCOVERY_FILE;
271                     response = client.target(url).request().get();
272                     boolean isOurs = response.readEntity(String.class).contains(cs.ds.config.bridgeid);
273                     selfTests.add(new SelfTestReport(url, response.getStatus() == 200, isOurs));
274                 } catch (ProcessingException e) {
275                     logger.debug("Self test fail on {}: {}", url, e.getMessage());
276                     selfTests.add(SelfTestReport.failed(url));
277                 }
278                 try {
279                     url = "http://" + ip + DISCOVERY_FILE; // Port 80
280                     response = client.target(url).request().get();
281                     boolean isOurs = response.readEntity(String.class).contains(cs.ds.config.bridgeid);
282                     selfTests.add(new SelfTestReport(url, response.getStatus() == 200, isOurs));
283                 } catch (ProcessingException e) {
284                     logger.debug("Self test fail on {}: {}", url, e.getMessage());
285                     selfTests.add(SelfTestReport.failed(url));
286                 }
287             }
288         } finally {
289             client.close();
290         }
291         return config;
292     }
293
294     /**
295      * Create and return new runtime configuration based on {@link ConfigStore}s current configuration.
296      * Return null if the configuration has not changed compared to {@link #config}.
297      *
298      * @throws IllegalStateException If the {@link ConfigStore}s IP is invalid this exception is thrown.
299      */
300     protected @Nullable HueEmulationConfigWithRuntime createConfiguration(
301             @Nullable HueEmulationConfigWithRuntime ignoredParameter) throws IllegalStateException {
302         HueEmulationConfigWithRuntime r;
303         try {
304             r = new HueEmulationConfigWithRuntime(this, cs.getConfig(), cs.ds.config.ipaddress, MULTI_ADDR_IPV4,
305                     MULTI_ADDR_IPV6);
306         } catch (UnknownHostException e) {
307             logger.warn("The picked default IP address is not valid: {}", e.getMessage());
308             throw new IllegalStateException(e);
309         }
310         return r;
311     }
312
313     /**
314      * Apply the given runtime configuration by stopping the current udp thread, shutting down the socket and restarting
315      * the thread.
316      */
317     protected @Nullable HueEmulationConfigWithRuntime applyConfiguration(
318             @Nullable HueEmulationConfigWithRuntime newRuntimeConfig) {
319         if (newRuntimeConfig == null) {
320             return null;// Config hasn't changed
321         }
322         config.dispose();
323         this.config = newRuntimeConfig;
324         useAddressPort(config);
325         return config;
326     }
327
328     /**
329      * We have a hard dependency on the {@link ConfigStore} and that it has initialized the Hue DataStore config
330      * completely. That initialization happens asynchronously and therefore we cannot rely on OSGi activate/modified
331      * state changes. Instead the {@link EventAdmin} is used and we listen for the
332      * {@link ConfigStore#EVENT_ADDRESS_CHANGED} event that is fired as soon as the config is ready.
333      * <p>
334      * To be really sure that we are called here, this is also issued by the main service after it has received the
335      * configuration ready event and depending on service start order we are also called by our own activate() method
336      * when the configuration is already ready at that time.
337      * <p>
338      * Therefore this method is "synchronized" and chains a completable future for each call to re-evaluate the config
339      * after the former future has finished.
340      */
341     @Override
342     public synchronized void handleEvent(@Nullable Event event) {
343         CompletableFuture<@Nullable HueEmulationConfigWithRuntime> root;
344         // There is either already a running future, then chain a new one
345         if (!configChangeFuture.isDone()) {
346             root = configChangeFuture;
347         } else { // Or there is none -> create a new one
348             root = CompletableFuture.completedFuture(null);
349         }
350         configChangeFuture = root.thenApply(this::createConfiguration)
351                 .thenApplyAsync(this::performAddressTest, executor).thenApply(this::applyConfiguration)
352                 .thenCompose(c -> {
353                     return c.startNow();
354                 }).whenComplete((HueEmulationConfigWithRuntime config, @Nullable Throwable e) -> {
355                     if (e != null) {
356                         logger.warn("Upnp server: Address test failed", e);
357                     }
358                 });
359     }
360
361     /**
362      * Stops the upnp server from running
363      */
364     @Deactivate
365     public void deactivate() {
366         config.dispose();
367         try {
368             httpService.unregister(DISCOVERY_FILE);
369         } catch (IllegalArgumentException ignore) {
370         }
371     }
372
373     private void handleRead(SelectionKey key) throws IOException {
374         logger.trace("upnp thread handle received message");
375         DatagramChannel channel = (DatagramChannel) key.channel();
376         ClientRecord clntRec = (ClientRecord) key.attachment();
377         clntRec.buffer.clear(); // Prepare buffer for receiving
378         clntRec.clientAddress = channel.receive(clntRec.buffer);
379         InetSocketAddress recAddress = (InetSocketAddress) clntRec.clientAddress;
380         if (recAddress == null) { // Did we receive something?
381             return;
382         }
383         String data = new String(clntRec.buffer.array(), StandardCharsets.UTF_8);
384         if (!data.startsWith("M-SEARCH")) {
385             return;
386         }
387
388         try (DatagramSocket sendSocket = new DatagramSocket()) {
389             sendUPNPDatagrams(sendSocket, recAddress.getAddress(), recAddress.getPort());
390         }
391     }
392
393     private void sendUPNPDatagrams(DatagramSocket sendSocket, InetAddress address, int port) {
394         logger.trace("upnp thread send announcement");
395         for (String msg : stVersions) {
396             DatagramPacket response = new DatagramPacket(msg.getBytes(), msg.length(), address, port);
397             try {
398                 logger.trace("Sending to {}:{}", address.getHostAddress(), port);
399                 sendSocket.send(response);
400             } catch (IOException e) {
401                 logger.warn("Could not send UPNP response: {}", e.getMessage());
402             }
403         }
404     }
405
406     private void sendUPNPNotify(DatagramSocket sendSocket, InetAddress address, int port) {
407         DatagramPacket response = new DatagramPacket(notifyMsg.getBytes(), notifyMsg.length(), address, port);
408         try {
409             logger.trace("Sending to {}:{}", address.getHostAddress(), port);
410             sendSocket.send(response);
411         } catch (IOException e) {
412             logger.warn("Could not send UPNP response: {}", e.getMessage());
413         }
414     }
415
416     @Override
417     public void accept(HueEmulationConfigWithRuntime threadContext) {
418         logger.info("Hue Emulation UPNP server started on {}:{}", threadContext.addressString, threadContext.port);
419         boolean hasIPv4 = false;
420         boolean hasIPv6 = false;
421
422         try (Selector selector = Selector.open();
423                 DatagramChannel channelV4 = createBoundDataGramChannelOrNull(StandardProtocolFamily.INET);
424                 DatagramChannel channelV6 = createBoundDataGramChannelOrNull(StandardProtocolFamily.INET6)) {
425             // Set global config to thread local config. Otherwise upnpAnnouncementThreadRunning() will report wrong
426             // results.
427             config = threadContext;
428             threadContext.asyncIOselector = selector;
429
430             for (InetAddress address : cs.getDiscoveryIps()) {
431                 NetworkInterface networkInterface = NetworkInterface.getByInetAddress(address);
432                 if (networkInterface == null) {
433                     continue;
434                 }
435                 if (address instanceof Inet4Address && channelV4 != null) {
436                     channelV4.join(MULTI_ADDR_IPV4, networkInterface);
437                     hasIPv4 = true;
438                 } else if (channelV6 != null) {
439                     channelV6.join(MULTI_ADDR_IPV6, networkInterface);
440                     hasIPv6 = true;
441                 }
442             }
443             if (!hasIPv4 && !hasIPv6) {
444                 logger.warn("Could not join upnp multicast network!");
445                 threadContext.future
446                         .completeExceptionally(new IllegalStateException("Could not join upnp multicast network!"));
447                 return;
448             }
449
450             if (hasIPv4) {
451                 channelV4.configureBlocking(false);
452                 channelV4.register(selector, SelectionKey.OP_READ, new ClientRecord());
453                 try (DatagramSocket sendSocket = new DatagramSocket(new InetSocketAddress(config.address, 0))) {
454                     sendUPNPDatagrams(sendSocket, MULTI_ADDR_IPV4, UPNP_PORT);
455                 }
456             }
457             if (hasIPv6) {
458                 channelV6.configureBlocking(false);
459                 channelV6.register(selector, SelectionKey.OP_READ, new ClientRecord());
460                 try (DatagramSocket sendSocket = new DatagramSocket()) {
461                     sendUPNPDatagrams(sendSocket, MULTI_ADDR_IPV6, UPNP_PORT);
462                 }
463             }
464
465             threadContext.future.complete(threadContext);
466             Instant time = Instant.now();
467
468             while (selector.isOpen()) { // Run forever, receiving and echoing datagrams
469                 // Wait for task or until timeout expires
470                 selector.select(CACHE_MSECS);
471                 Iterator<SelectionKey> keyIter = selector.selectedKeys().iterator();
472                 while (keyIter.hasNext()) {
473                     SelectionKey key = keyIter.next();
474                     if (key.isReadable()) {
475                         handleRead(key);
476                     }
477                     keyIter.remove();
478                 }
479
480                 if (time.plusMillis(CACHE_MSECS - 200).isBefore(Instant.now())) {
481                     logger.trace("upnp thread send periodic announcement");
482                     time = Instant.now();
483                     if (hasIPv4) {
484                         try (DatagramSocket sendSocket = new DatagramSocket(new InetSocketAddress(config.address, 0))) {
485                             sendUPNPNotify(sendSocket, MULTI_ADDR_IPV4, UPNP_PORT);
486                         }
487                     }
488                     if (hasIPv6) {
489                         try (DatagramSocket sendSocket = new DatagramSocket()) {
490                             sendUPNPNotify(sendSocket, MULTI_ADDR_IPV6, UPNP_PORT);
491                         }
492                     }
493                 }
494             }
495         } catch (ClosedSelectorException ignored) {
496         } catch (IOException e) {
497             logger.warn("Socket error with UPNP server", e);
498             threadContext.future.completeExceptionally(e);
499         } finally {
500             threadContext.asyncIOselector = null;
501         }
502     }
503
504     @Nullable
505     private DatagramChannel createBoundDataGramChannelOrNull(StandardProtocolFamily family) throws IOException {
506         try {
507             return DatagramChannel.open(family).setOption(StandardSocketOptions.SO_REUSEADDR, true)
508                     .setOption(StandardSocketOptions.IP_MULTICAST_LOOP, true).bind(new InetSocketAddress(UPNP_PORT));
509         } catch (UnsupportedOperationException uoe) {
510             return null;
511         }
512     }
513
514     /**
515      * The upnp server performs some self-tests
516      *
517      * @return
518      */
519     public List<SelfTestReport> selfTests() {
520         return selfTests;
521     }
522
523     public String getBaseURL() {
524         return baseurl;
525     }
526
527     public int getDefaultport() {
528         return config.port;
529     }
530
531     public boolean upnpAnnouncementThreadRunning() {
532         return config.asyncIOselector != null;
533     }
534 }