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.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.concurrent.TimeUnit;
45 import java.util.function.Consumer;
46 import java.util.stream.Collectors;
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;
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;
75 * Advertises a Hue compatible bridge via UPNP and provides the announced /description.xml http endpoint.
77 * @author Dan Cunningham - Initial contribution
78 * @author David Graeff - Rewritten
80 @SuppressWarnings("serial")
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 {
88 * Used by async IO. This is our context object class.
90 static class ClientRecord {
91 public @Nullable SocketAddress clientAddress;
92 public ByteBuffer buffer = ByteBuffer.allocate(1000);
95 public static final String DISCOVERY_FILE = "/description.xml";
97 // jUPNP shares port 1900, but since this is multicast, we can also bind to it
98 public static final int UPNP_PORT = 1900;
100 * Send a keep alive every 2 minutes
102 private static final int CACHE_MSECS = 120 * 1000;
104 private final Logger logger = LoggerFactory.getLogger(UpnpServer.class);
106 public final InetAddress MULTI_ADDR_IPV4;
107 public final InetAddress MULTI_ADDR_IPV6;
108 private String[] stVersions = { "", "", "" };
109 private String notifyMsg = "";
111 //// objects, set within activate()
112 protected @NonNullByDefault({}) String xmlDoc;
113 protected @NonNullByDefault({}) String xmlDocWithAddress;
114 private @NonNullByDefault({}) String baseurl;
118 protected @NonNullByDefault({}) ConfigStore cs;
120 protected @NonNullByDefault({}) HttpService httpService;
123 protected @NonNullByDefault({}) ClientBuilder clientBuilder;
125 public boolean overwriteReadyToFalse = false;
127 private HueEmulationConfigWithRuntime config;
128 protected CompletableFuture<@Nullable HueEmulationConfigWithRuntime> configChangeFuture = CompletableFuture
129 .completedFuture(config);
131 private List<SelfTestReport> selfTests = new ArrayList<>();
132 private final Executor executor;
135 * Creates a server instance.
136 * UPnP IPv4/v6 multicast addresses are determined.
138 public UpnpServer() {
139 this(ForkJoinPool.commonPool());
142 public UpnpServer(Executor executor) {
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);
151 this.executor = executor;
155 * this object is also a servlet for providing /description.xml, the UPnP discovery result
157 @NonNullByDefault({})
159 protected void service(HttpServletRequest req, HttpServletResponse resp) throws IOException {
160 if (xmlDocWithAddress == null || xmlDocWithAddress.isEmpty()) {
161 resp.sendError(HttpServletResponse.SC_NOT_FOUND);
164 try (PrintWriter out = resp.getWriter()) {
165 resp.setContentType("application/xml");
166 out.write(xmlDocWithAddress);
171 * Server to send UDP packets onto the network when requested by a Hue API compatible device.
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
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");
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);
196 httpService.unregister(DISCOVERY_FILE);
197 } catch (RuntimeException ignore) {
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);
206 if (cs.isReady() && !overwriteReadyToFalse) {
211 private void useAddressPort(HueEmulationConfigWithRuntime r) {
212 final String urlBase = "http://" + r.addressString + ":" + r.port;
213 this.baseurl = urlBase + DISCOVERY_FILE;
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,
224 cs.ds.config.apiversion, cs.ds.config.bridgeid, // version, bridgeid
225 stVersions[i], config.config.uuid);
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
235 xmlDocWithAddress = String.format(xmlDoc, urlBase, r.addressString, cs.ds.config.bridgeid, cs.ds.config.uuid,
236 cs.ds.config.devicename);
239 protected @Nullable HueEmulationConfigWithRuntime performAddressTest(
240 @Nullable HueEmulationConfigWithRuntime config) {
241 if (config == null) {
242 return null; // Config hasn't changed
246 Client client = clientBuilder.connectTimeout(1, TimeUnit.SECONDS).readTimeout(1, TimeUnit.SECONDS).build();
250 boolean selfTestOnPort80;
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;
259 // Prefer port 80 if possible and if not overwritten by discoveryHttpPort
260 config.port = config.config.discoveryHttpPort == 0 && selfTestOnPort80 ? 80 : config.port;
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] + "]";
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));
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));
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}.
298 * @throws IllegalStateException If the {@link ConfigStore}s IP is invalid this exception is thrown.
300 protected @Nullable HueEmulationConfigWithRuntime createConfiguration(
301 @Nullable HueEmulationConfigWithRuntime ignoredParameter) throws IllegalStateException {
302 HueEmulationConfigWithRuntime r;
304 r = new HueEmulationConfigWithRuntime(this, cs.getConfig(), cs.ds.config.ipaddress, MULTI_ADDR_IPV4,
306 } catch (UnknownHostException e) {
307 logger.warn("The picked default IP address is not valid: {}", e.getMessage());
308 throw new IllegalStateException(e);
314 * Apply the given runtime configuration by stopping the current udp thread, shutting down the socket and restarting
317 protected @Nullable HueEmulationConfigWithRuntime applyConfiguration(
318 @Nullable HueEmulationConfigWithRuntime newRuntimeConfig) {
319 if (newRuntimeConfig == null) {
320 return null;// Config hasn't changed
323 this.config = newRuntimeConfig;
324 useAddressPort(config);
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.
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.
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.
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);
350 configChangeFuture = root.thenApply(this::createConfiguration)
351 .thenApplyAsync(this::performAddressTest, executor).thenApply(this::applyConfiguration)
354 }).whenComplete((HueEmulationConfigWithRuntime config, @Nullable Throwable e) -> {
356 logger.warn("Upnp server: Address test failed", e);
362 * Stops the upnp server from running
365 public void deactivate() {
368 httpService.unregister(DISCOVERY_FILE);
369 } catch (IllegalArgumentException ignore) {
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?
383 String data = new String(clntRec.buffer.array(), StandardCharsets.UTF_8);
384 if (!data.startsWith("M-SEARCH")) {
388 try (DatagramSocket sendSocket = new DatagramSocket()) {
389 sendUPNPDatagrams(sendSocket, recAddress.getAddress(), recAddress.getPort());
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);
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());
406 private void sendUPNPNotify(DatagramSocket sendSocket, InetAddress address, int port) {
407 DatagramPacket response = new DatagramPacket(notifyMsg.getBytes(), notifyMsg.length(), address, port);
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());
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;
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
427 config = threadContext;
428 threadContext.asyncIOselector = selector;
430 for (InetAddress address : cs.getDiscoveryIps()) {
431 NetworkInterface networkInterface = NetworkInterface.getByInetAddress(address);
432 if (networkInterface == null) {
435 if (address instanceof Inet4Address && channelV4 != null) {
436 channelV4.join(MULTI_ADDR_IPV4, networkInterface);
438 } else if (channelV6 != null) {
439 channelV6.join(MULTI_ADDR_IPV6, networkInterface);
443 if (!hasIPv4 && !hasIPv6) {
444 logger.warn("Could not join upnp multicast network!");
446 .completeExceptionally(new IllegalStateException("Could not join upnp multicast network!"));
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);
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);
465 threadContext.future.complete(threadContext);
466 Instant time = Instant.now();
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()) {
480 if (time.plusMillis(CACHE_MSECS - 200).isBefore(Instant.now())) {
481 logger.trace("upnp thread send periodic announcement");
482 time = Instant.now();
484 try (DatagramSocket sendSocket = new DatagramSocket(new InetSocketAddress(config.address, 0))) {
485 sendUPNPNotify(sendSocket, MULTI_ADDR_IPV4, UPNP_PORT);
489 try (DatagramSocket sendSocket = new DatagramSocket()) {
490 sendUPNPNotify(sendSocket, MULTI_ADDR_IPV6, UPNP_PORT);
495 } catch (ClosedSelectorException ignored) {
496 } catch (IOException e) {
497 logger.warn("Socket error with UPNP server", e);
498 threadContext.future.completeExceptionally(e);
500 threadContext.asyncIOselector = null;
505 private DatagramChannel createBoundDataGramChannelOrNull(StandardProtocolFamily family) throws IOException {
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) {
515 * The upnp server performs some self-tests
519 public List<SelfTestReport> selfTests() {
523 public String getBaseURL() {
527 public int getDefaultport() {
531 public boolean upnpAnnouncementThreadRunning() {
532 return config.asyncIOselector != null;