2 * Copyright (c) 2010-2020 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.io.hueemulation.internal.upnp;
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.function.Consumer;
45 import java.util.stream.Collectors;
47 import javax.servlet.ServletException;
48 import javax.servlet.http.HttpServlet;
49 import javax.servlet.http.HttpServletRequest;
50 import javax.servlet.http.HttpServletResponse;
51 import javax.ws.rs.ProcessingException;
52 import javax.ws.rs.client.Client;
53 import javax.ws.rs.client.ClientBuilder;
54 import javax.ws.rs.core.Response;
56 import org.eclipse.jdt.annotation.NonNullByDefault;
57 import org.eclipse.jdt.annotation.Nullable;
58 import org.glassfish.jersey.client.ClientConfig;
59 import org.glassfish.jersey.client.ClientProperties;
60 import org.openhab.io.hueemulation.internal.ConfigStore;
61 import org.osgi.service.component.annotations.Activate;
62 import org.osgi.service.component.annotations.Component;
63 import org.osgi.service.component.annotations.ConfigurationPolicy;
64 import org.osgi.service.component.annotations.Deactivate;
65 import org.osgi.service.component.annotations.Reference;
66 import org.osgi.service.event.Event;
67 import org.osgi.service.event.EventAdmin;
68 import org.osgi.service.event.EventConstants;
69 import org.osgi.service.event.EventHandler;
70 import org.osgi.service.http.HttpService;
71 import org.osgi.service.http.NamespaceException;
72 import org.slf4j.Logger;
73 import org.slf4j.LoggerFactory;
76 * Advertises a Hue compatible bridge via UPNP and provides the announced /description.xml http endpoint.
78 * @author Dan Cunningham - Initial contribution
79 * @author David Graeff - Rewritten
81 @SuppressWarnings("serial")
83 @Component(immediate = false, // Don't start the upnp server on its own. Must be pulled in by HueEmulationService.
84 configurationPolicy = ConfigurationPolicy.IGNORE, property = {
85 EventConstants.EVENT_TOPIC + "=" + ConfigStore.EVENT_ADDRESS_CHANGED,
86 "com.eclipsesource.jaxrs.publish=false" }, //
87 service = { UpnpServer.class, EventHandler.class })
88 public class UpnpServer extends HttpServlet implements Consumer<HueEmulationConfigWithRuntime>, EventHandler {
90 * Used by async IO. This is our context object class.
92 static class ClientRecord {
93 public @Nullable SocketAddress clientAddress;
94 public ByteBuffer buffer = ByteBuffer.allocate(1000);
97 public static final String DISCOVERY_FILE = "/description.xml";
99 // jUPNP shares port 1900, but since this is multicast, we can also bind to it
100 public static final int UPNP_PORT = 1900;
102 * Send a keep alive every 2 minutes
104 private static final int CACHE_MSECS = 120 * 1000;
106 private final Logger logger = LoggerFactory.getLogger(UpnpServer.class);
108 public final InetAddress MULTI_ADDR_IPV4;
109 public final InetAddress MULTI_ADDR_IPV6;
110 private String[] stVersions = { "", "", "" };
111 private String notifyMsg = "";
113 //// objects, set within activate()
114 protected @NonNullByDefault({}) String xmlDoc;
115 protected @NonNullByDefault({}) String xmlDocWithAddress;
116 private @NonNullByDefault({}) String baseurl;
120 protected @NonNullByDefault({}) ConfigStore cs;
122 protected @NonNullByDefault({}) HttpService httpService;
124 public boolean overwriteReadyToFalse = false;
126 private HueEmulationConfigWithRuntime config;
127 protected CompletableFuture<@Nullable HueEmulationConfigWithRuntime> configChangeFuture = CompletableFuture
128 .completedFuture(config);
130 private List<SelfTestReport> selfTests = new ArrayList<>();
131 private final Executor executor;
134 * Creates a server instance.
135 * UPnP IPv4/v6 multicast addresses are determined.
137 public UpnpServer() {
138 this(ForkJoinPool.commonPool());
141 public UpnpServer(Executor executor) {
143 MULTI_ADDR_IPV4 = InetAddress.getByName("239.255.255.250");
144 MULTI_ADDR_IPV6 = InetAddress.getByName("ff02::c");
145 config = new HueEmulationConfigWithRuntime(this, null, MULTI_ADDR_IPV4, MULTI_ADDR_IPV6);
146 } catch (UnknownHostException e) {
147 throw new IllegalStateException(e);
150 this.executor = executor;
154 * this object is also a servlet for providing /description.xml, the UPnP discovery result
156 @NonNullByDefault({})
158 protected void service(HttpServletRequest req, HttpServletResponse resp) throws IOException {
159 if (xmlDocWithAddress == null || xmlDocWithAddress.isEmpty()) {
160 resp.sendError(HttpServletResponse.SC_NOT_FOUND);
163 try (PrintWriter out = resp.getWriter()) {
164 resp.setContentType("application/xml");
165 out.write(xmlDocWithAddress);
170 * Server to send UDP packets onto the network when requested by a Hue API compatible device.
172 * @param relativePath The URI path where the discovery xml document can be retrieved
173 * @param config The hue datastore. Contains the bridgeid and uuid.
174 * @param address IP to advertise for UPNP
175 * @throws IOException
176 * @throws NamespaceException
177 * @throws ServletException
180 protected void activate() {
181 InputStream resourceAsStream = getClass().getClassLoader().getResourceAsStream("discovery.xml");
182 if (resourceAsStream == null) {
183 logger.warn("Could not start Hue Emulation service: discovery.xml not found");
186 try (InputStreamReader r = new InputStreamReader(resourceAsStream, StandardCharsets.UTF_8);
187 BufferedReader br = new BufferedReader(r)) {
188 xmlDoc = br.lines().collect(Collectors.joining("\n"));
189 } catch (IOException e) {
190 logger.warn("Could not start Hue Emulation UPNP server: {}", e.getMessage(), e);
195 httpService.unregister(DISCOVERY_FILE);
196 } catch (IllegalArgumentException ignore) {
200 httpService.registerServlet(DISCOVERY_FILE, this, null, null);
201 } catch (ServletException | NamespaceException e) {
202 logger.warn("Could not start Hue Emulation UPNP server: {}", e.getMessage(), e);
205 if (cs.isReady() && !overwriteReadyToFalse) {
210 private void useAddressPort(HueEmulationConfigWithRuntime r) {
211 final String urlBase = "http://" + r.addressString + ":" + r.port;
212 this.baseurl = urlBase + DISCOVERY_FILE;
214 final String[] stVersions = { "upnp:rootdevice", "urn:schemas-upnp-org:device:basic:1",
215 "uuid:" + config.config.uuid };
216 for (int i = 0; i < stVersions.length; ++i) {
217 this.stVersions[i] = String.format(
218 "HTTP/1.1 200 OK\r\n" + "HOST: %s:%d\r\n" + "EXT:\r\n" + "CACHE-CONTROL: max-age=%d\r\n"
219 + "LOCATION: %s\r\n" + "SERVER: Linux/3.14.0 UPnP/1.0 IpBridge/%s\r\n"
220 + "hue-bridgeid: %s\r\n" + "ST: %s\r\n" + "USN: uuid:%s\r\n\r\n",
221 r.getMulticastAddress(), UPNP_PORT, CACHE_MSECS / 1000, baseurl, // host:port,
223 cs.ds.config.apiversion, cs.ds.config.bridgeid, // version, bridgeid
224 stVersions[i], config.config.uuid);
227 this.notifyMsg = String.format(
228 "NOTIFY * HTTP/1.1\r\n" + "HOST: %s:%d\r\n" + "CACHE-CONTROL: max-age=%d\r\n" + "LOCATION: %s\r\n"
229 + "SERVER: Linux/3.14.0 UPnP/1.0 IpBridge/%s\r\nNTS: ssdp:alive\r\nNT: upnp:rootdevice\r\n"
230 + "USN: uuid:%s::upnp:rootdevice\r\n" + "hue-bridgeid: %s\r\n\r\n",
231 r.getMulticastAddress(), UPNP_PORT, CACHE_MSECS / 1000, baseurl, // host:port, cache,location
232 cs.ds.config.apiversion, config.config.uuid, cs.ds.config.bridgeid);// version, uuid, bridgeid
234 xmlDocWithAddress = String.format(xmlDoc, urlBase, r.addressString, cs.ds.config.bridgeid, cs.ds.config.uuid,
235 cs.ds.config.devicename);
238 protected @Nullable HueEmulationConfigWithRuntime performAddressTest(
239 @Nullable HueEmulationConfigWithRuntime config) {
240 if (config == null) {
241 return null; // Config hasn't changed
246 ClientConfig configuration = new ClientConfig();
247 configuration = configuration.property(ClientProperties.CONNECT_TIMEOUT, 1000);
248 configuration = configuration.property(ClientProperties.READ_TIMEOUT, 1000);
249 Client client = ClientBuilder.newClient(configuration);
253 boolean selfTestOnPort80;
255 response = client.target("http://" + cs.ds.config.ipaddress + ":80" + DISCOVERY_FILE).request().get();
256 selfTestOnPort80 = response.getStatus() == 200
257 && response.readEntity(String.class).contains(cs.ds.config.bridgeid);
258 } catch (ProcessingException ignored) {
259 selfTestOnPort80 = false;
262 // Prefer port 80 if possible and if not overwritten by discoveryHttpPort
263 config.port = config.config.discoveryHttpPort == 0 && selfTestOnPort80 ? 80 : config.port;
265 // Test on all assigned interface addresses on org.osgi.service.http.port as well as port 80
266 // Other services might run on port 80, so we search for our bridge ID in the returned document.
267 for (InetAddress address : cs.getDiscoveryIps()) {
268 String ip = address.getHostAddress();
269 if (address instanceof Inet6Address) {
270 ip = "[" + ip.split("%")[0] + "]";
273 url = "http://" + ip + ":" + config.port + DISCOVERY_FILE;
274 response = client.target(url).request().get();
275 boolean isOurs = response.readEntity(String.class).contains(cs.ds.config.bridgeid);
276 selfTests.add(new SelfTestReport(url, response.getStatus() == 200, isOurs));
277 } catch (ProcessingException e) {
278 logger.debug("Self test fail on {}: {}", url, e.getMessage());
279 selfTests.add(SelfTestReport.failed(url));
282 url = "http://" + ip + DISCOVERY_FILE; // Port 80
283 response = client.target(url).request().get();
284 boolean isOurs = response.readEntity(String.class).contains(cs.ds.config.bridgeid);
285 selfTests.add(new SelfTestReport(url, response.getStatus() == 200, isOurs));
286 } catch (ProcessingException e) {
287 logger.debug("Self test fail on {}: {}", url, e.getMessage());
288 selfTests.add(SelfTestReport.failed(url));
298 * Create and return new runtime configuration based on {@link ConfigStore}s current configuration.
299 * Return null if the configuration has not changed compared to {@link #config}.
301 * @throws IllegalStateException If the {@link ConfigStore}s IP is invalid this exception is thrown.
303 protected @Nullable HueEmulationConfigWithRuntime createConfiguration(
304 @Nullable HueEmulationConfigWithRuntime ignoredParameter) throws IllegalStateException {
305 HueEmulationConfigWithRuntime r;
307 r = new HueEmulationConfigWithRuntime(this, cs.getConfig(), cs.ds.config.ipaddress, MULTI_ADDR_IPV4,
309 } catch (UnknownHostException e) {
310 logger.warn("The picked default IP address is not valid: {}", e.getMessage());
311 throw new IllegalStateException(e);
317 * Apply the given runtime configuration by stopping the current udp thread, shutting down the socket and restarting
320 protected @Nullable HueEmulationConfigWithRuntime applyConfiguration(
321 @Nullable HueEmulationConfigWithRuntime newRuntimeConfig) {
322 if (newRuntimeConfig == null) {
323 return null;// Config hasn't changed
326 this.config = newRuntimeConfig;
327 useAddressPort(config);
332 * We have a hard dependency on the {@link ConfigStore} and that it has initialized the Hue DataStore config
333 * completely. That initialization happens asynchronously and therefore we cannot rely on OSGi activate/modified
334 * state changes. Instead the {@link EventAdmin} is used and we listen for the
335 * {@link ConfigStore#EVENT_ADDRESS_CHANGED} event that is fired as soon as the config is ready.
337 * To be really sure that we are called here, this is also issued by the main service after it has received the
338 * configuration ready event and depending on service start order we are also called by our own activate() method
339 * when the configuration is already ready at that time.
341 * Therefore this method is "synchronized" and chains a completable future for each call to re-evaluate the config
342 * after the former future has finished.
345 public synchronized void handleEvent(@Nullable Event event) {
346 CompletableFuture<@Nullable HueEmulationConfigWithRuntime> root;
347 // There is either already a running future, then chain a new one
348 if (!configChangeFuture.isDone()) {
349 root = configChangeFuture;
350 } else { // Or there is none -> create a new one
351 root = CompletableFuture.completedFuture(null);
353 configChangeFuture = root.thenApply(this::createConfiguration)
354 .thenApplyAsync(this::performAddressTest, executor).thenApply(this::applyConfiguration)
355 .thenCompose(config::startNow)
356 .whenComplete((HueEmulationConfigWithRuntime config, @Nullable Throwable e) -> {
358 logger.warn("Upnp server: Address test failed", e);
364 * Stops the upnp server from running
367 public void deactivate() {
370 httpService.unregister(DISCOVERY_FILE);
371 } catch (IllegalArgumentException ignore) {
375 private void handleRead(SelectionKey key) throws IOException {
376 logger.trace("upnp thread handle received message");
377 DatagramChannel channel = (DatagramChannel) key.channel();
378 ClientRecord clntRec = (ClientRecord) key.attachment();
379 clntRec.buffer.clear(); // Prepare buffer for receiving
380 clntRec.clientAddress = channel.receive(clntRec.buffer);
381 InetSocketAddress recAddress = (InetSocketAddress) clntRec.clientAddress;
382 if (recAddress == null) { // Did we receive something?
385 String data = new String(clntRec.buffer.array(), StandardCharsets.UTF_8);
386 if (!data.startsWith("M-SEARCH")) {
390 try (DatagramSocket sendSocket = new DatagramSocket()) {
391 sendUPNPDatagrams(sendSocket, recAddress.getAddress(), recAddress.getPort());
395 private void sendUPNPDatagrams(DatagramSocket sendSocket, InetAddress address, int port) {
396 logger.trace("upnp thread send announcement");
397 for (String msg : stVersions) {
398 DatagramPacket response = new DatagramPacket(msg.getBytes(), msg.length(), address, port);
400 logger.trace("Sending to {}:{}", address.getHostAddress(), port);
401 sendSocket.send(response);
402 } catch (IOException e) {
403 logger.warn("Could not send UPNP response: {}", e.getMessage());
408 private void sendUPNPNotify(DatagramSocket sendSocket, InetAddress address, int port) {
409 DatagramPacket response = new DatagramPacket(notifyMsg.getBytes(), notifyMsg.length(), address, port);
411 logger.trace("Sending to {}:{}", address.getHostAddress(), port);
412 sendSocket.send(response);
413 } catch (IOException e) {
414 logger.warn("Could not send UPNP response: {}", e.getMessage());
419 public void accept(HueEmulationConfigWithRuntime threadContext) {
420 logger.info("Hue Emulation UPNP server started on {}:{}", threadContext.addressString, threadContext.port);
421 boolean hasIPv4 = false;
422 boolean hasIPv6 = false;
424 try (Selector selector = Selector.open();
425 DatagramChannel channelV4 = createBoundDataGramChannelOrNull(StandardProtocolFamily.INET);
426 DatagramChannel channelV6 = createBoundDataGramChannelOrNull(StandardProtocolFamily.INET6)) {
427 // Set global config to thread local config. Otherwise upnpAnnouncementThreadRunning() will report wrong
429 config = threadContext;
430 threadContext.asyncIOselector = selector;
432 for (InetAddress address : cs.getDiscoveryIps()) {
433 NetworkInterface networkInterface = NetworkInterface.getByInetAddress(address);
434 if (networkInterface == null) {
437 if (address instanceof Inet4Address && channelV4 != null) {
438 channelV4.join(MULTI_ADDR_IPV4, networkInterface);
440 } else if (channelV6 != null) {
441 channelV6.join(MULTI_ADDR_IPV6, networkInterface);
445 if (!hasIPv4 && !hasIPv6) {
446 logger.warn("Could not join upnp multicast network!");
448 .completeExceptionally(new IllegalStateException("Could not join upnp multicast network!"));
453 channelV4.configureBlocking(false);
454 channelV4.register(selector, SelectionKey.OP_READ, new ClientRecord());
455 try (DatagramSocket sendSocket = new DatagramSocket(new InetSocketAddress(config.address, 0))) {
456 sendUPNPDatagrams(sendSocket, MULTI_ADDR_IPV4, UPNP_PORT);
460 channelV6.configureBlocking(false);
461 channelV6.register(selector, SelectionKey.OP_READ, new ClientRecord());
462 try (DatagramSocket sendSocket = new DatagramSocket()) {
463 sendUPNPDatagrams(sendSocket, MULTI_ADDR_IPV6, UPNP_PORT);
467 threadContext.future.complete(threadContext);
468 Instant time = Instant.now();
470 while (selector.isOpen()) { // Run forever, receiving and echoing datagrams
471 // Wait for task or until timeout expires
472 selector.select(CACHE_MSECS);
473 Iterator<SelectionKey> keyIter = selector.selectedKeys().iterator();
474 while (keyIter.hasNext()) {
475 SelectionKey key = keyIter.next();
476 if (key.isReadable()) {
482 if (time.plusMillis(CACHE_MSECS - 200).isBefore(Instant.now())) {
483 logger.trace("upnp thread send periodic announcement");
484 time = Instant.now();
486 try (DatagramSocket sendSocket = new DatagramSocket(new InetSocketAddress(config.address, 0))) {
487 sendUPNPNotify(sendSocket, MULTI_ADDR_IPV4, UPNP_PORT);
491 try (DatagramSocket sendSocket = new DatagramSocket()) {
492 sendUPNPNotify(sendSocket, MULTI_ADDR_IPV6, UPNP_PORT);
497 } catch (ClosedSelectorException ignored) {
498 } catch (IOException e) {
499 logger.warn("Socket error with UPNP server", e);
500 threadContext.future.completeExceptionally(e);
502 threadContext.asyncIOselector = null;
507 private DatagramChannel createBoundDataGramChannelOrNull(StandardProtocolFamily family) throws IOException {
509 return DatagramChannel.open(family).setOption(StandardSocketOptions.SO_REUSEADDR, true)
510 .setOption(StandardSocketOptions.IP_MULTICAST_LOOP, true).bind(new InetSocketAddress(UPNP_PORT));
511 } catch (UnsupportedOperationException uoe) {
517 * The upnp server performs some self-tests
521 public List<SelfTestReport> selfTests() {
525 public String getBaseURL() {
529 public int getDefaultport() {
533 public boolean upnpAnnouncementThreadRunning() {
534 return config.asyncIOselector != null;