]> git.basschouten.com Git - openhab-addons.git/blob
102b5170ed6cb9f1411b8223e8ea47fd74690295
[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.hue.internal.connection;
14
15 import java.io.BufferedReader;
16 import java.io.ByteArrayInputStream;
17 import java.io.Closeable;
18 import java.io.IOException;
19 import java.io.InputStreamReader;
20 import java.io.Reader;
21 import java.net.InetSocketAddress;
22 import java.nio.ByteBuffer;
23 import java.nio.charset.StandardCharsets;
24 import java.time.Duration;
25 import java.time.Instant;
26 import java.util.ArrayList;
27 import java.util.Arrays;
28 import java.util.List;
29 import java.util.Map;
30 import java.util.Objects;
31 import java.util.Optional;
32 import java.util.Properties;
33 import java.util.concurrent.CompletableFuture;
34 import java.util.concurrent.ConcurrentHashMap;
35 import java.util.concurrent.ExecutionException;
36 import java.util.concurrent.Future;
37 import java.util.concurrent.Semaphore;
38 import java.util.concurrent.TimeUnit;
39 import java.util.concurrent.TimeoutException;
40 import java.util.concurrent.locks.Lock;
41 import java.util.concurrent.locks.ReadWriteLock;
42 import java.util.concurrent.locks.ReentrantReadWriteLock;
43 import java.util.stream.Collectors;
44
45 import javax.ws.rs.core.MediaType;
46
47 import org.eclipse.jdt.annotation.NonNullByDefault;
48 import org.eclipse.jdt.annotation.Nullable;
49 import org.eclipse.jetty.client.HttpClient;
50 import org.eclipse.jetty.client.api.ContentResponse;
51 import org.eclipse.jetty.client.api.Request;
52 import org.eclipse.jetty.client.util.StringContentProvider;
53 import org.eclipse.jetty.http.HttpFields;
54 import org.eclipse.jetty.http.HttpHeader;
55 import org.eclipse.jetty.http.HttpMethod;
56 import org.eclipse.jetty.http.HttpStatus;
57 import org.eclipse.jetty.http.HttpURI;
58 import org.eclipse.jetty.http.HttpVersion;
59 import org.eclipse.jetty.http.MetaData;
60 import org.eclipse.jetty.http.MetaData.Response;
61 import org.eclipse.jetty.http2.ErrorCode;
62 import org.eclipse.jetty.http2.api.Session;
63 import org.eclipse.jetty.http2.api.Stream;
64 import org.eclipse.jetty.http2.client.HTTP2Client;
65 import org.eclipse.jetty.http2.frames.DataFrame;
66 import org.eclipse.jetty.http2.frames.GoAwayFrame;
67 import org.eclipse.jetty.http2.frames.HeadersFrame;
68 import org.eclipse.jetty.http2.frames.PingFrame;
69 import org.eclipse.jetty.http2.frames.ResetFrame;
70 import org.eclipse.jetty.util.Callback;
71 import org.eclipse.jetty.util.Promise.Completable;
72 import org.eclipse.jetty.util.ssl.SslContextFactory;
73 import org.openhab.binding.hue.internal.dto.CreateUserRequest;
74 import org.openhab.binding.hue.internal.dto.SuccessResponse;
75 import org.openhab.binding.hue.internal.dto.clip2.BridgeConfig;
76 import org.openhab.binding.hue.internal.dto.clip2.Event;
77 import org.openhab.binding.hue.internal.dto.clip2.Resource;
78 import org.openhab.binding.hue.internal.dto.clip2.ResourceReference;
79 import org.openhab.binding.hue.internal.dto.clip2.Resources;
80 import org.openhab.binding.hue.internal.dto.clip2.enums.ResourceType;
81 import org.openhab.binding.hue.internal.exceptions.ApiException;
82 import org.openhab.binding.hue.internal.exceptions.HttpUnauthorizedException;
83 import org.openhab.binding.hue.internal.handler.Clip2BridgeHandler;
84 import org.openhab.core.io.net.http.HttpClientFactory;
85 import org.openhab.core.io.net.http.HttpUtil;
86 import org.slf4j.Logger;
87 import org.slf4j.LoggerFactory;
88
89 import com.google.gson.Gson;
90 import com.google.gson.JsonArray;
91 import com.google.gson.JsonElement;
92 import com.google.gson.JsonParseException;
93 import com.google.gson.JsonParser;
94 import com.google.gson.JsonSyntaxException;
95
96 /**
97  * This class handles HTTP and SSE connections to/from a Hue Bridge running CLIP 2.
98  *
99  * It uses the following connection mechanisms:
100  *
101  * <li>The primary communication uses HTTP 2 streams over a shared permanent HTTP 2 session.</li>
102  * <li>The 'registerApplicationKey()' method uses HTTP/1.1 over the OH common Jetty client.</li>
103  * <li>The 'isClip2Supported()' static method uses HTTP/1.1 over the OH common Jetty client via 'HttpUtil'.</li>
104  *
105  * @author Andrew Fiddian-Green - Initial Contribution
106  */
107 @NonNullByDefault
108 public class Clip2Bridge implements Closeable {
109
110     /**
111      * Base (abstract) adapter for listening to HTTP 2 stream events.
112      *
113      * It implements a CompletableFuture by means of which the caller can wait for the response data to come in. And
114      * which, in the case of fatal errors, gets completed exceptionally.
115      *
116      * It handles the following fatal error events by notifying the containing class:
117      *
118      * <li>onHeaders() HTTP unauthorized codes</li>
119      */
120     private abstract class BaseStreamListenerAdapter<T> extends Stream.Listener.Adapter {
121         protected final CompletableFuture<T> completable = new CompletableFuture<T>();
122         private String contentType = "UNDEFINED";
123         private int status;
124
125         protected T awaitResult() throws ExecutionException, InterruptedException, TimeoutException {
126             return completable.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
127         }
128
129         /**
130          * Return the HTTP content type.
131          *
132          * @return content type e.g. 'application/json'
133          */
134         protected String getContentType() {
135             return contentType;
136         }
137
138         /**
139          * Return the HTTP status code.
140          *
141          * @return status code e.g. 200
142          */
143         protected int getStatus() {
144             return status;
145         }
146
147         /**
148          * Handle an HTTP2 error.
149          *
150          * @param error the type of error.
151          * @param session the session on which the error occurred.
152          */
153         protected void handleHttp2Error(Http2Error error, Session session) {
154             Http2Exception e = new Http2Exception(error);
155             if (Http2Error.UNAUTHORIZED.equals(error)) {
156                 // for external error handling, abstract authorization errors into a separate exception
157                 completable.completeExceptionally(new HttpUnauthorizedException("HTTP 2 request not authorized"));
158             } else {
159                 completable.completeExceptionally(e);
160             }
161             fatalErrorDelayed(this, e, session);
162         }
163
164         /**
165          * Check the reply headers to see whether the request was authorised.
166          */
167         @Override
168         public void onHeaders(@Nullable Stream stream, @Nullable HeadersFrame frame) {
169             Objects.requireNonNull(stream);
170             Objects.requireNonNull(frame);
171             MetaData metaData = frame.getMetaData();
172             if (metaData.isResponse()) {
173                 Response responseMetaData = (Response) metaData;
174                 contentType = responseMetaData.getFields().get(HttpHeader.CONTENT_TYPE).toLowerCase();
175                 status = responseMetaData.getStatus();
176                 switch (status) {
177                     case HttpStatus.UNAUTHORIZED_401:
178                     case HttpStatus.FORBIDDEN_403:
179                         handleHttp2Error(Http2Error.UNAUTHORIZED, stream.getSession());
180                     default:
181                 }
182             }
183         }
184     }
185
186     /**
187      * Adapter for listening to regular HTTP 2 GET/PUT request stream events.
188      *
189      * It assembles the incoming text data into an HTTP 'content' entity. And when the last data frame arrives, it
190      * returns the full content by completing the CompletableFuture with that data.
191      *
192      * In addition to those handled by the parent, it handles the following fatal error events by notifying the
193      * containing class:
194      *
195      * <li>onIdleTimeout()</li>
196      * <li>onTimeout()</li>
197      */
198     private class ContentStreamListenerAdapter extends BaseStreamListenerAdapter<String> {
199         private final DataFrameCollector content = new DataFrameCollector();
200
201         @Override
202         public void onData(@Nullable Stream stream, @Nullable DataFrame frame, @Nullable Callback callback) {
203             Objects.requireNonNull(frame);
204             Objects.requireNonNull(callback);
205             synchronized (this) {
206                 content.append(frame.getData());
207                 if (frame.isEndStream() && !completable.isDone()) {
208                     completable.complete(content.contentAsString().trim());
209                     content.reset();
210                 }
211             }
212             callback.succeeded();
213         }
214
215         @Override
216         public boolean onIdleTimeout(@Nullable Stream stream, @Nullable Throwable x) {
217             Objects.requireNonNull(stream);
218             handleHttp2Error(Http2Error.IDLE, stream.getSession());
219             return true;
220         }
221
222         @Override
223         public void onTimeout(@Nullable Stream stream, @Nullable Throwable x) {
224             Objects.requireNonNull(stream);
225             handleHttp2Error(Http2Error.TIMEOUT, stream.getSession());
226         }
227     }
228
229     /**
230      * Class to collect incoming ByteBuffer data from HTTP 2 Data frames.
231      */
232     private static class DataFrameCollector {
233         private byte[] buffer = new byte[512];
234         private int usedSize = 0;
235
236         public void append(ByteBuffer data) {
237             int dataCapacity = data.capacity();
238             int neededSize = usedSize + dataCapacity;
239             if (neededSize > buffer.length) {
240                 int newSize = (dataCapacity < 4096) ? neededSize : Math.max(2 * buffer.length, neededSize);
241                 buffer = Arrays.copyOf(buffer, newSize);
242             }
243             data.get(buffer, usedSize, dataCapacity);
244             usedSize += dataCapacity;
245         }
246
247         public String contentAsString() {
248             return new String(buffer, 0, usedSize, StandardCharsets.UTF_8);
249         }
250
251         public Reader contentStreamReader() {
252             return new InputStreamReader(new ByteArrayInputStream(buffer, 0, usedSize), StandardCharsets.UTF_8);
253         }
254
255         public void reset() {
256             usedSize = 0;
257         }
258     }
259
260     /**
261      * Adapter for listening to SSE event stream events.
262      *
263      * It receives the incoming text lines. Receipt of the first data line causes the CompletableFuture to complete. It
264      * then parses subsequent data according to the SSE specification. If the line starts with a 'data:' message, it
265      * adds the data to the list of strings. And if the line is empty (i.e. the last line of an event), it passes the
266      * full set of strings to the owner via a call-back method.
267      *
268      * The stream must be permanently connected, so it ignores onIdleTimeout() events.
269      *
270      * The parent class handles most fatal errors, but since the event stream is supposed to be permanently connected,
271      * the following events are also considered as fatal:
272      *
273      * <li>onClosed()</li>
274      * <li>onReset()</li>
275      */
276     private class EventStreamListenerAdapter extends BaseStreamListenerAdapter<Boolean> {
277         private final DataFrameCollector eventData = new DataFrameCollector();
278
279         @Override
280         public void onClosed(@Nullable Stream stream) {
281             Objects.requireNonNull(stream);
282             handleHttp2Error(Http2Error.CLOSED, stream.getSession());
283         }
284
285         @Override
286         public void onData(@Nullable Stream stream, @Nullable DataFrame frame, @Nullable Callback callback) {
287             Objects.requireNonNull(frame);
288             Objects.requireNonNull(callback);
289             synchronized (this) {
290                 eventData.append(frame.getData());
291                 BufferedReader reader = new BufferedReader(eventData.contentStreamReader());
292                 @SuppressWarnings("null")
293                 List<String> receivedLines = reader.lines().collect(Collectors.toList());
294
295                 // a blank line marks the end of an SSE message
296                 boolean endOfMessage = !receivedLines.isEmpty()
297                         && receivedLines.get(receivedLines.size() - 1).isBlank();
298
299                 if (endOfMessage) {
300                     eventData.reset();
301                     // receipt of ANY message means the event stream is established
302                     if (!completable.isDone()) {
303                         completable.complete(Boolean.TRUE);
304                     }
305                     // append any 'data' field values to the event message
306                     StringBuilder eventContent = new StringBuilder();
307                     for (String receivedLine : receivedLines) {
308                         if (receivedLine.startsWith("data:")) {
309                             eventContent.append(receivedLine.substring(5).stripLeading());
310                         }
311                     }
312                     if (eventContent.length() > 0) {
313                         onEventData(eventContent.toString().trim());
314                     }
315                 }
316             }
317             callback.succeeded();
318         }
319
320         @Override
321         public boolean onIdleTimeout(@Nullable Stream stream, @Nullable Throwable x) {
322             return false;
323         }
324
325         @Override
326         public void onReset(@Nullable Stream stream, @Nullable ResetFrame frame) {
327             Objects.requireNonNull(stream);
328             handleHttp2Error(Http2Error.RESET, stream.getSession());
329         }
330     }
331
332     /**
333      * Enum of potential fatal HTTP 2 session/stream errors.
334      */
335     private enum Http2Error {
336         CLOSED,
337         FAILURE,
338         TIMEOUT,
339         RESET,
340         IDLE,
341         GO_AWAY,
342         UNAUTHORIZED;
343     }
344
345     /**
346      * Private exception for handling HTTP 2 stream and session errors.
347      */
348     @SuppressWarnings("serial")
349     private static class Http2Exception extends ApiException {
350         public final Http2Error error;
351
352         public Http2Exception(Http2Error error) {
353             this(error, null);
354         }
355
356         public Http2Exception(Http2Error error, @Nullable Throwable cause) {
357             super("HTTP 2 stream " + error.toString().toLowerCase(), cause);
358             this.error = error;
359         }
360     }
361
362     /**
363      * Adapter for listening to HTTP 2 session status events.
364      *
365      * The session must be permanently connected, so it ignores onIdleTimeout() events.
366      * It also handles the following fatal events by notifying the containing class:
367      *
368      * <li>onClose()</li>
369      * <li>onFailure()</li>
370      * <li>onGoAway()</li>
371      * <li>onReset()</li>
372      */
373     private class SessionListenerAdapter extends Session.Listener.Adapter {
374
375         @Override
376         public void onClose(@Nullable Session session, @Nullable GoAwayFrame frame) {
377             Objects.requireNonNull(session);
378             fatalErrorDelayed(this, new Http2Exception(Http2Error.CLOSED), session);
379         }
380
381         @Override
382         public void onFailure(@Nullable Session session, @Nullable Throwable failure) {
383             Objects.requireNonNull(session);
384             fatalErrorDelayed(this, new Http2Exception(Http2Error.FAILURE), session);
385         }
386
387         /**
388          * The Hue bridge uses the 'nginx' web server which sends HTTP2 GO_AWAY frames after a certain number (normally
389          * 999) of GET/PUT calls. This is normal behaviour so we just start a new thread to close and reopen the
390          * session.
391          */
392         @Override
393         public void onGoAway(@Nullable Session session, @Nullable GoAwayFrame frame) {
394             Objects.requireNonNull(session);
395             if (http2Session == session) {
396                 Thread recreateThread = new Thread(() -> recreateSession());
397                 Clip2Bridge.this.recreateThread = recreateThread;
398                 recreateThread.start();
399             }
400         }
401
402         @Override
403         public boolean onIdleTimeout(@Nullable Session session) {
404             return false;
405         }
406
407         @Override
408         public void onPing(@Nullable Session session, @Nullable PingFrame frame) {
409             Objects.requireNonNull(session);
410             Objects.requireNonNull(frame);
411             if (http2Session == session) {
412                 checkAliveOk();
413                 if (!frame.isReply()) {
414                     session.ping(new PingFrame(true), Callback.NOOP);
415                 }
416             }
417         }
418
419         @Override
420         public void onReset(@Nullable Session session, @Nullable ResetFrame frame) {
421             Objects.requireNonNull(session);
422             fatalErrorDelayed(this, new Http2Exception(Http2Error.RESET), session);
423         }
424     }
425
426     /**
427      * Synchronizer for accessing the HTTP2 session object. This method wraps the 'sessionUseCreateLock' ReadWriteLock
428      * so that GET/PUT methods can access the session on multiple concurrent threads via the 'read' access lock, yet are
429      * forced to wait if the session is being created via its single thread access 'write' lock.
430      */
431     private class SessionSynchronizer implements AutoCloseable {
432         private final Optional<Lock> lockOptional;
433
434         SessionSynchronizer(boolean requireExclusiveAccess) throws InterruptedException {
435             Lock lock = requireExclusiveAccess ? sessionUseCreateLock.writeLock() : sessionUseCreateLock.readLock();
436             lockOptional = lock.tryLock(TIMEOUT_SECONDS, TimeUnit.SECONDS) ? Optional.of(lock) : Optional.empty();
437         }
438
439         @Override
440         public void close() {
441             lockOptional.ifPresent(lock -> lock.unlock());
442         }
443     }
444
445     /**
446      * Enum showing the online state of the session connection.
447      */
448     private static enum State {
449         /**
450          * Session closed
451          */
452         CLOSED,
453         /**
454          * Session open for HTTP calls only
455          */
456         PASSIVE,
457         /**
458          * Session open for HTTP calls and actively receiving SSE events
459          */
460         ACTIVE;
461     }
462
463     /**
464      * Class for throttling HTTP GET and PUT requests to prevent overloading the Hue bridge.
465      * <p>
466      * The Hue Bridge can get confused if they receive too many HTTP requests in a short period of time (e.g. on start
467      * up), or if too many HTTP sessions are opened at the same time, which cause it to respond with an HTML error page.
468      * So this class a) waits to acquire permitCount (or no more than MAX_CONCURRENT_SESSIONS) stream permits, and b)
469      * throttles the requests to a maximum of one per REQUEST_INTERVAL_MILLISECS.
470      */
471     private class Throttler implements AutoCloseable {
472         private final int permitCount;
473
474         /**
475          * @param permitCount indicates how many stream permits to be acquired.
476          * @throws InterruptedException
477          */
478         Throttler(int permitCount) throws InterruptedException {
479             this.permitCount = permitCount;
480             streamMutex.acquire(permitCount);
481             long delay;
482             synchronized (Clip2Bridge.this) {
483                 Instant now = Instant.now();
484                 delay = lastRequestTime
485                         .map(t -> Math.max(0, Duration.between(now, t).toMillis() + REQUEST_INTERVAL_MILLISECS))
486                         .orElse(0L);
487                 lastRequestTime = Optional.of(now.plusMillis(delay));
488             }
489             Thread.sleep(delay);
490         }
491
492         @Override
493         public void close() {
494             streamMutex.release(permitCount);
495         }
496     }
497
498     private static final Logger LOGGER = LoggerFactory.getLogger(Clip2Bridge.class);
499
500     private static final String APPLICATION_ID = "org-openhab-binding-hue-clip2";
501     private static final String APPLICATION_KEY = "hue-application-key";
502
503     private static final String EVENT_STREAM_ID = "eventStream";
504     private static final String FORMAT_URL_CONFIG = "http://%s/api/0/config";
505     private static final String FORMAT_URL_RESOURCE = "https://%s/clip/v2/resource/";
506     private static final String FORMAT_URL_REGISTER = "http://%s/api";
507     private static final String FORMAT_URL_EVENTS = "https://%s/eventstream/clip/v2";
508
509     private static final long CLIP2_MINIMUM_VERSION = 1948086000L;
510
511     public static final int TIMEOUT_SECONDS = 10;
512     private static final int CHECK_ALIVE_SECONDS = 300;
513     private static final int REQUEST_INTERVAL_MILLISECS = 50;
514     private static final int MAX_CONCURRENT_STREAMS = 3;
515
516     private static final ResourceReference BRIDGE = new ResourceReference().setType(ResourceType.BRIDGE);
517
518     /**
519      * Static method to attempt to connect to a Hue Bridge, get its software version, and check if it is high enough to
520      * support the CLIP 2 API.
521      *
522      * @param hostName the bridge IP address.
523      * @return true if bridge is online and it supports CLIP 2, or false if it is online and does not support CLIP 2.
524      * @throws IOException if unable to communicate with the bridge.
525      * @throws NumberFormatException if the bridge firmware version is invalid.
526      */
527     public static boolean isClip2Supported(String hostName) throws IOException {
528         String response;
529         Properties headers = new Properties();
530         headers.put(HttpHeader.ACCEPT, MediaType.APPLICATION_JSON);
531         response = HttpUtil.executeUrl("GET", String.format(FORMAT_URL_CONFIG, hostName), headers, null, null,
532                 TIMEOUT_SECONDS * 1000);
533         BridgeConfig config = new Gson().fromJson(response, BridgeConfig.class);
534         if (Objects.nonNull(config)) {
535             String swVersion = config.swversion;
536             if (Objects.nonNull(swVersion)) {
537                 try {
538                     if (Long.parseLong(swVersion) >= CLIP2_MINIMUM_VERSION) {
539                         return true;
540                     }
541                 } catch (NumberFormatException e) {
542                     LOGGER.debug("isClip2Supported() swVersion '{}' is not a number", swVersion);
543                 }
544             }
545         }
546         return false;
547     }
548
549     private final HttpClient httpClient;
550     private final HTTP2Client http2Client;
551     private final String hostName;
552     private final String baseUrl;
553     private final String eventUrl;
554     private final String registrationUrl;
555     private final String applicationKey;
556     private final Clip2BridgeHandler bridgeHandler;
557     private final Gson jsonParser = new Gson();
558     private final Semaphore streamMutex = new Semaphore(MAX_CONCURRENT_STREAMS, true); // i.e. fair
559     private final ReadWriteLock sessionUseCreateLock = new ReentrantReadWriteLock(true); // i.e. fair
560     private final Map<Integer, Future<?>> fatalErrorTasks = new ConcurrentHashMap<>();
561
562     private boolean recreatingSession;
563     private boolean closing;
564     private State onlineState = State.CLOSED;
565     private Optional<Instant> lastRequestTime = Optional.empty();
566     private Instant sessionExpireTime = Instant.MAX;
567
568     private @Nullable Session http2Session;
569     private @Nullable Thread recreateThread;
570     private @Nullable Future<?> checkAliveTask;
571
572     /**
573      * Constructor.
574      *
575      * @param httpClientFactory the OH core HttpClientFactory.
576      * @param bridgeHandler the bridge handler.
577      * @param hostName the host name (ip address) of the Hue bridge
578      * @param applicationKey the application key.
579      * @throws ApiException if unable to open Jetty HTTP/2 client.
580      */
581     public Clip2Bridge(HttpClientFactory httpClientFactory, Clip2BridgeHandler bridgeHandler, String hostName,
582             String applicationKey) throws ApiException {
583         LOGGER.debug("Clip2Bridge()");
584         httpClient = httpClientFactory.getCommonHttpClient();
585         http2Client = httpClientFactory.createHttp2Client("hue-clip2", httpClient.getSslContextFactory());
586         http2Client.setConnectTimeout(Clip2Bridge.TIMEOUT_SECONDS * 1000);
587         http2Client.setIdleTimeout(-1);
588         startHttp2Client();
589         this.bridgeHandler = bridgeHandler;
590         this.hostName = hostName;
591         this.applicationKey = applicationKey;
592         baseUrl = String.format(FORMAT_URL_RESOURCE, hostName);
593         eventUrl = String.format(FORMAT_URL_EVENTS, hostName);
594         registrationUrl = String.format(FORMAT_URL_REGISTER, hostName);
595     }
596
597     /**
598      * Cancel the given task.
599      *
600      * @param cancelTask the task to be cancelled (may be null)
601      * @param mayInterrupt allows cancel() to interrupt the thread.
602      */
603     private void cancelTask(@Nullable Future<?> cancelTask, boolean mayInterrupt) {
604         if (Objects.nonNull(cancelTask)) {
605             cancelTask.cancel(mayInterrupt);
606         }
607     }
608
609     /**
610      * Send a ping to the Hue bridge to check that the session is still alive.
611      */
612     private void checkAlive() {
613         if (onlineState == State.CLOSED) {
614             return;
615         }
616         LOGGER.debug("checkAlive()");
617         Session session = http2Session;
618         if (Objects.nonNull(session)) {
619             session.ping(new PingFrame(false), Callback.NOOP);
620             if (Instant.now().isAfter(sessionExpireTime)) {
621                 fatalError(this, new Http2Exception(Http2Error.TIMEOUT), session.hashCode());
622             }
623         }
624     }
625
626     /**
627      * Connection is ok, so reschedule the session check alive expire time. Called in response to incoming ping frames
628      * from the bridge.
629      */
630     protected void checkAliveOk() {
631         LOGGER.debug("checkAliveOk()");
632         sessionExpireTime = Instant.now().plusSeconds(CHECK_ALIVE_SECONDS * 2);
633     }
634
635     /**
636      * Close the connection.
637      */
638     @Override
639     public void close() {
640         closing = true;
641         Thread recreateThread = this.recreateThread;
642         if (Objects.nonNull(recreateThread) && recreateThread.isAlive()) {
643             recreateThread.interrupt();
644         }
645         close2();
646         try {
647             stopHttp2Client();
648         } catch (ApiException e) {
649         }
650     }
651
652     /**
653      * Private method to close the connection.
654      */
655     private void close2() {
656         synchronized (this) {
657             LOGGER.debug("close2()");
658             boolean notifyHandler = onlineState == State.ACTIVE && !closing && !recreatingSession;
659             onlineState = State.CLOSED;
660             synchronized (fatalErrorTasks) {
661                 fatalErrorTasks.values().forEach(task -> cancelTask(task, true));
662                 fatalErrorTasks.clear();
663             }
664             cancelTask(checkAliveTask, true);
665             checkAliveTask = null;
666             closeEventStream();
667             closeSession();
668             if (notifyHandler) {
669                 bridgeHandler.onConnectionOffline();
670             }
671         }
672     }
673
674     /**
675      * Close the event stream(s) if necessary.
676      */
677     private void closeEventStream() {
678         Session session = http2Session;
679         if (Objects.nonNull(session)) {
680             final int sessionId = session.hashCode();
681             session.getStreams().stream().filter(s -> Objects.nonNull(s.getAttribute(EVENT_STREAM_ID)) && !s.isReset())
682                     .forEach(s -> {
683                         int streamId = s.getId();
684                         LOGGER.debug("closeEventStream() sessionId:{}, streamId:{}", sessionId, streamId);
685                         s.reset(new ResetFrame(streamId, ErrorCode.CANCEL_STREAM_ERROR.code), Callback.NOOP);
686                     });
687         }
688     }
689
690     /**
691      * Close the HTTP 2 session if necessary.
692      */
693     private void closeSession() {
694         Session session = http2Session;
695         if (Objects.nonNull(session)) {
696             LOGGER.debug("closeSession() sessionId:{}, openStreamCount:{}", session.hashCode(),
697                     session.getStreams().size());
698             session.close(ErrorCode.NO_ERROR.code, "closeSession", Callback.NOOP);
699         }
700         http2Session = null;
701     }
702
703     /**
704      * Close the given stream.
705      *
706      * @param stream to be closed.
707      */
708     private void closeStream(@Nullable Stream stream) {
709         if (Objects.nonNull(stream) && !stream.isReset()) {
710             stream.reset(new ResetFrame(stream.getId(), ErrorCode.NO_ERROR.code), Callback.NOOP);
711         }
712     }
713
714     /**
715      * Method that is called back in case of fatal stream or session events. The error is only processed if the
716      * connection is online, not in process of closing, and the identities of the current session and the session that
717      * caused the error are the same. In other words it ignores errors relating to expired sessions.
718      *
719      * @param listener the entity that caused this method to be called.
720      * @param cause the type of exception that caused the error.
721      * @param sessionId the identity of the session on which the error occurred.
722      */
723     private synchronized void fatalError(Object listener, Http2Exception cause, int sessionId) {
724         if (onlineState == State.CLOSED || closing) {
725             return;
726         }
727         Session session = http2Session;
728         if (Objects.isNull(session) || session.hashCode() != sessionId) {
729             return;
730         }
731         String listenerId = listener.getClass().getSimpleName();
732         if (listener instanceof ContentStreamListenerAdapter) {
733             // on GET / PUT requests the caller handles errors and closes the stream; the session is still OK
734             LOGGER.debug("fatalError() listener:{}, sessionId:{}, error:{} => ignoring", listenerId, sessionId,
735                     cause.error);
736         } else {
737             if (LOGGER.isDebugEnabled()) {
738                 LOGGER.debug("fatalError() listener:{}, sessionId:{}, error:{} => closing", listenerId, sessionId,
739                         cause.error, cause);
740             } else {
741                 LOGGER.warn("Fatal error '{}' from '{}' => closing session.", cause.error, listenerId);
742             }
743             close2();
744         }
745     }
746
747     /**
748      * Method that is called back in case of fatal stream or session events. Schedules fatalError() to be called after a
749      * delay in order to prevent sequencing issues.
750      *
751      * @param listener the entity that caused this method to be called.
752      * @param cause the type of exception that caused the error.
753      * @param session the session on which the error occurred.
754      */
755     protected void fatalErrorDelayed(Object listener, Http2Exception cause, Session session) {
756         synchronized (fatalErrorTasks) {
757             final int index = fatalErrorTasks.size();
758             final int sessionId = session.hashCode();
759             fatalErrorTasks.put(index, bridgeHandler.getScheduler().schedule(() -> {
760                 fatalError(listener, cause, sessionId);
761                 fatalErrorTasks.remove(index);
762             }, 1, TimeUnit.SECONDS));
763         }
764     }
765
766     /**
767      * HTTP GET a Resources object, for a given resource Reference, from the Hue Bridge. The reference is a class
768      * comprising a resource type and an id. If the id is a specific resource id then only the one specific resource
769      * is returned, whereas if it is null then all resources of the given resource type are returned.
770      *
771      * It wraps the getResourcesImpl() method in a try/catch block, and transposes any HttpUnAuthorizedException into an
772      * ApiException. Such transposition should never be required in reality since by the time this method is called, the
773      * connection will surely already have been authorised.
774      *
775      * @param reference the Reference class to get.
776      * @return a Resource object containing either a list of Resources or a list of Errors.
777      * @throws ApiException if anything fails.
778      * @throws InterruptedException
779      */
780     public Resources getResources(ResourceReference reference) throws ApiException, InterruptedException {
781         if (onlineState == State.CLOSED && !recreatingSession) {
782             throw new ApiException("Connection is closed");
783         }
784         return getResourcesImpl(reference);
785     }
786
787     /**
788      * Internal method to send an HTTP 2 GET request to the Hue Bridge and process its response. Uses a Throttler to
789      * prevent too many concurrent calls, and to prevent too frequent calls on the Hue bridge server. Also uses a
790      * SessionSynchronizer to delay accessing the session while it is being recreated.
791      *
792      * @param reference the Reference class to get.
793      * @return a Resource object containing either a list of Resources or a list of Errors.
794      * @throws HttpUnauthorizedException if the request was refused as not authorised or forbidden.
795      * @throws ApiException if the communication failed, or an unexpected result occurred.
796      * @throws InterruptedException
797      */
798     private Resources getResourcesImpl(ResourceReference reference)
799             throws HttpUnauthorizedException, ApiException, InterruptedException {
800         // work around for issue #15468 (and similar)
801         ResourceType resourceType = reference.getType();
802         if (resourceType == ResourceType.ERROR) {
803             LOGGER.warn("Resource '{}' type '{}' unknown => GET aborted", reference.getId(), resourceType);
804             return new Resources();
805         }
806         Stream stream = null;
807         try (Throttler throttler = new Throttler(1);
808                 SessionSynchronizer sessionSynchronizer = new SessionSynchronizer(false)) {
809             Session session = getSession();
810             String url = getUrl(reference);
811             LOGGER.trace("GET {} HTTP/2", url);
812             HeadersFrame headers = prepareHeaders(url, MediaType.APPLICATION_JSON);
813             Completable<@Nullable Stream> streamPromise = new Completable<>();
814             ContentStreamListenerAdapter contentStreamListener = new ContentStreamListenerAdapter();
815             session.newStream(headers, streamPromise, contentStreamListener);
816             // wait for stream to be opened
817             stream = Objects.requireNonNull(streamPromise.get(TIMEOUT_SECONDS, TimeUnit.SECONDS));
818             // wait for HTTP response contents
819             String contentJson = contentStreamListener.awaitResult();
820             String contentType = contentStreamListener.getContentType();
821             int status = contentStreamListener.getStatus();
822             LOGGER.trace("HTTP/2 {} (Content-Type: {}) << {}", status, contentType, contentJson);
823             if (status != HttpStatus.OK_200) {
824                 throw new ApiException(String.format("Unexpected HTTP status '%d'", status));
825             }
826             if (!MediaType.APPLICATION_JSON.equals(contentType)) {
827                 throw new ApiException("Unexpected Content-Type: " + contentType);
828             }
829             try {
830                 Resources resources = Objects.requireNonNull(jsonParser.fromJson(contentJson, Resources.class));
831                 if (LOGGER.isDebugEnabled()) {
832                     resources.getErrors().forEach(error -> LOGGER.debug("Resources error:{}", error));
833                 }
834                 return resources;
835             } catch (JsonParseException e) {
836                 throw new ApiException("Parsing error", e);
837             }
838         } catch (ExecutionException e) {
839             Throwable cause = e.getCause();
840             if (cause instanceof HttpUnauthorizedException) {
841                 throw (HttpUnauthorizedException) cause;
842             }
843             throw new ApiException("Error sending request", e);
844         } catch (TimeoutException e) {
845             throw new ApiException("Error sending request", e);
846         } finally {
847             closeStream(stream);
848         }
849     }
850
851     /**
852      * Safe access to the session object.
853      *
854      * @return the session.
855      * @throws ApiException if session is null or closed.
856      */
857     private Session getSession() throws ApiException {
858         Session session = http2Session;
859         if (Objects.isNull(session) || session.isClosed()) {
860             throw new ApiException("HTTP/2 session is null or closed");
861         }
862         return session;
863     }
864
865     /**
866      * Build a full path to a server end point, based on a Reference class instance. If the reference contains only
867      * a resource type, the method returns the end point url to get all resources of the given resource type. Whereas if
868      * it also contains an id, the method returns the end point url to get the specific single resource with that type
869      * and id.
870      *
871      * @param reference a Reference class instance.
872      * @return the complete end point url.
873      */
874     private String getUrl(ResourceReference reference) {
875         String url = baseUrl + reference.getType().name().toLowerCase();
876         String id = reference.getId();
877         return Objects.isNull(id) || id.isEmpty() ? url : url + "/" + id;
878     }
879
880     /**
881      * The event stream calls this method when it has received text data. It parses the text as JSON into a list of
882      * Event entries, converts the list of events to a list of resources, and forwards that list to the bridge
883      * handler.
884      *
885      * @param data the incoming (presumed to be JSON) text.
886      */
887     protected void onEventData(String data) {
888         if (onlineState != State.ACTIVE && !recreatingSession) {
889             return;
890         }
891         if (LOGGER.isTraceEnabled()) {
892             LOGGER.trace("onEventData() data:{}", data);
893         } else {
894             LOGGER.debug("onEventData() data length:{}", data.length());
895         }
896         JsonElement jsonElement;
897         try {
898             jsonElement = JsonParser.parseString(data);
899         } catch (JsonSyntaxException e) {
900             LOGGER.debug("onEventData() invalid data '{}'", data, e);
901             return;
902         }
903         if (!(jsonElement instanceof JsonArray)) {
904             LOGGER.debug("onEventData() data is not a JsonArray {}", data);
905             return;
906         }
907         List<Event> events;
908         try {
909             events = jsonParser.fromJson(jsonElement, Event.EVENT_LIST_TYPE);
910         } catch (JsonParseException e) {
911             LOGGER.debug("onEventData() parsing error json:{}", data, e);
912             return;
913         }
914         if (Objects.isNull(events) || events.isEmpty()) {
915             LOGGER.debug("onEventData() event list is null or empty");
916             return;
917         }
918         List<Resource> resources = new ArrayList<>();
919         events.forEach(event -> resources.addAll(event.getData()));
920         if (resources.isEmpty()) {
921             LOGGER.debug("onEventData() resource list is empty");
922             return;
923         }
924         resources.forEach(resource -> resource.markAsSparse());
925         bridgeHandler.onResourcesEvent(resources);
926     }
927
928     /**
929      * Open the HTTP 2 session and the event stream.
930      *
931      * @throws ApiException if there was a communication error.
932      * @throws InterruptedException
933      */
934     public void open() throws ApiException, InterruptedException {
935         LOGGER.debug("open()");
936         openPassive();
937         openActive();
938         bridgeHandler.onConnectionOnline();
939     }
940
941     /**
942      * Make the session active, by opening an HTTP 2 SSE event stream (if necessary).
943      *
944      * @throws ApiException if an error was encountered.
945      * @throws InterruptedException
946      */
947     private void openActive() throws ApiException, InterruptedException {
948         synchronized (this) {
949             openEventStream();
950             onlineState = State.ACTIVE;
951         }
952     }
953
954     /**
955      * Open the check alive task if necessary.
956      */
957     private void openCheckAliveTask() {
958         Future<?> task = checkAliveTask;
959         if (Objects.isNull(task) || task.isCancelled() || task.isDone()) {
960             LOGGER.debug("openCheckAliveTask()");
961             cancelTask(checkAliveTask, false);
962             checkAliveTask = bridgeHandler.getScheduler().scheduleWithFixedDelay(() -> checkAlive(),
963                     CHECK_ALIVE_SECONDS, CHECK_ALIVE_SECONDS, TimeUnit.SECONDS);
964         }
965     }
966
967     /**
968      * Implementation to open an HTTP 2 SSE event stream if necessary.
969      *
970      * @throws ApiException if an error was encountered.
971      * @throws InterruptedException
972      */
973     private void openEventStream() throws ApiException, InterruptedException {
974         Session session = getSession();
975         if (session.getStreams().stream().anyMatch(stream -> Objects.nonNull(stream.getAttribute(EVENT_STREAM_ID)))) {
976             return;
977         }
978         LOGGER.trace("GET {} HTTP/2", eventUrl);
979         Stream stream = null;
980         try {
981             HeadersFrame headers = prepareHeaders(eventUrl, MediaType.SERVER_SENT_EVENTS);
982             Completable<@Nullable Stream> streamPromise = new Completable<>();
983             EventStreamListenerAdapter eventStreamListener = new EventStreamListenerAdapter();
984             session.newStream(headers, streamPromise, eventStreamListener);
985             // wait for stream to be opened
986             stream = Objects.requireNonNull(streamPromise.get(TIMEOUT_SECONDS, TimeUnit.SECONDS));
987             stream.setIdleTimeout(0);
988             stream.setAttribute(EVENT_STREAM_ID, session);
989             // wait for "hi" from the bridge
990             eventStreamListener.awaitResult();
991             LOGGER.debug("openEventStream() sessionId:{} streamId:{}", session.hashCode(), stream.getId());
992         } catch (ExecutionException | TimeoutException e) {
993             if (Objects.nonNull(stream) && !stream.isReset()) {
994                 stream.reset(new ResetFrame(stream.getId(), ErrorCode.HTTP_CONNECT_ERROR.code), Callback.NOOP);
995             }
996             throw new ApiException("Error opening event stream", e);
997         }
998     }
999
1000     /**
1001      * Private method to open the HTTP 2 session in passive mode.
1002      *
1003      * @throws ApiException if there was a communication error.
1004      * @throws InterruptedException
1005      */
1006     private void openPassive() throws ApiException, InterruptedException {
1007         synchronized (this) {
1008             LOGGER.debug("openPassive()");
1009             onlineState = State.CLOSED;
1010             openSession();
1011             openCheckAliveTask();
1012             onlineState = State.PASSIVE;
1013         }
1014     }
1015
1016     /**
1017      * Open the HTTP 2 session if necessary.
1018      *
1019      * @throws ApiException if it was not possible to create and connect the session.
1020      * @throws InterruptedException
1021      */
1022     private void openSession() throws ApiException, InterruptedException {
1023         Session session = http2Session;
1024         if (Objects.nonNull(session) && !session.isClosed()) {
1025             return;
1026         }
1027         try {
1028             InetSocketAddress address = new InetSocketAddress(hostName, 443);
1029             SessionListenerAdapter sessionListener = new SessionListenerAdapter();
1030             Completable<@Nullable Session> sessionPromise = new Completable<>();
1031             http2Client.connect(http2Client.getBean(SslContextFactory.class), address, sessionListener, sessionPromise);
1032             // wait for the (SSL) session to be opened
1033             session = Objects.requireNonNull(sessionPromise.get(TIMEOUT_SECONDS, TimeUnit.SECONDS));
1034             LOGGER.debug("openSession() sessionId:{}", session.hashCode());
1035             http2Session = session;
1036             checkAliveOk(); // initialise the session timeout window
1037         } catch (ExecutionException | TimeoutException e) {
1038             throw new ApiException("Error opening HTTP/2 session", e);
1039         }
1040     }
1041
1042     /**
1043      * Helper class to create a HeadersFrame for a standard HTTP GET request.
1044      *
1045      * @param url the server url.
1046      * @param acceptContentType the accepted content type for the response.
1047      * @return the HeadersFrame.
1048      */
1049     private HeadersFrame prepareHeaders(String url, String acceptContentType) {
1050         return prepareHeaders(url, acceptContentType, "GET", -1, null);
1051     }
1052
1053     /**
1054      * Helper class to create a HeadersFrame for a more exotic HTTP request.
1055      *
1056      * @param url the server url.
1057      * @param acceptContentType the accepted content type for the response.
1058      * @param method the HTTP request method.
1059      * @param contentLength the length of the content e.g. for a PUT call.
1060      * @param contentType the respective content type.
1061      * @return the HeadersFrame.
1062      */
1063     private HeadersFrame prepareHeaders(String url, String acceptContentType, String method, long contentLength,
1064             @Nullable String contentType) {
1065         HttpFields fields = new HttpFields();
1066         fields.put(HttpHeader.ACCEPT, acceptContentType);
1067         if (contentType != null) {
1068             fields.put(HttpHeader.CONTENT_TYPE, contentType);
1069         }
1070         if (contentLength >= 0) {
1071             fields.putLongField(HttpHeader.CONTENT_LENGTH, contentLength);
1072         }
1073         fields.put(APPLICATION_KEY, applicationKey);
1074         return new HeadersFrame(new MetaData.Request(method, new HttpURI(url), HttpVersion.HTTP_2, fields), null,
1075                 contentLength <= 0);
1076     }
1077
1078     /**
1079      * Use an HTTP/2 PUT command to send a resource to the server. Uses a Throttler to prevent too many concurrent
1080      * calls, and to prevent too frequent calls on the Hue bridge server. Also uses a SessionSynchronizer to delay
1081      * accessing the session while it is being recreated.
1082      *
1083      * @param resource the resource to put.
1084      * @throws ApiException if something fails.
1085      * @throws InterruptedException
1086      */
1087     public void putResource(Resource resource) throws ApiException, InterruptedException {
1088         Stream stream = null;
1089         try (Throttler throttler = new Throttler(MAX_CONCURRENT_STREAMS);
1090                 SessionSynchronizer sessionSynchronizer = new SessionSynchronizer(false)) {
1091             Session session = getSession();
1092             String requestJson = jsonParser.toJson(resource);
1093             ByteBuffer requestBytes = ByteBuffer.wrap(requestJson.getBytes(StandardCharsets.UTF_8));
1094             String url = getUrl(new ResourceReference().setId(resource.getId()).setType(resource.getType()));
1095             HeadersFrame headers = prepareHeaders(url, MediaType.APPLICATION_JSON, "PUT", requestBytes.capacity(),
1096                     MediaType.APPLICATION_JSON);
1097             LOGGER.trace("PUT {} HTTP/2 >> {}", url, requestJson);
1098             Completable<@Nullable Stream> streamPromise = new Completable<>();
1099             ContentStreamListenerAdapter contentStreamListener = new ContentStreamListenerAdapter();
1100             session.newStream(headers, streamPromise, contentStreamListener);
1101             // wait for stream to be opened
1102             stream = Objects.requireNonNull(streamPromise.get(TIMEOUT_SECONDS, TimeUnit.SECONDS));
1103             stream.data(new DataFrame(stream.getId(), requestBytes, true), Callback.NOOP);
1104             // wait for HTTP response
1105             String contentJson = contentStreamListener.awaitResult();
1106             String contentType = contentStreamListener.getContentType();
1107             int status = contentStreamListener.getStatus();
1108             LOGGER.trace("HTTP/2 {} (Content-Type: {}) << {}", status, contentType, contentJson);
1109             if (status != HttpStatus.OK_200) {
1110                 throw new ApiException(String.format("Unexpected HTTP status '%d'", status));
1111             }
1112             if (!MediaType.APPLICATION_JSON.equals(contentType)) {
1113                 throw new ApiException("Unexpected Content-Type: " + contentType);
1114             }
1115             try {
1116                 Resources resources = Objects.requireNonNull(jsonParser.fromJson(contentJson, Resources.class));
1117                 if (LOGGER.isDebugEnabled()) {
1118                     resources.getErrors().forEach(error -> LOGGER.debug("putResource() resources error:{}", error));
1119                 }
1120             } catch (JsonParseException e) {
1121                 LOGGER.debug("putResource() parsing error json:{}", contentJson, e);
1122                 throw new ApiException("Parsing error", e);
1123             }
1124         } catch (ExecutionException | TimeoutException e) {
1125             throw new ApiException("Error sending PUT request", e);
1126         } finally {
1127             closeStream(stream);
1128         }
1129     }
1130
1131     /**
1132      * Close and re-open the session. Called when the server sends a GO_AWAY message. Acquires a SessionSynchronizer
1133      * 'write' lock to ensure single thread access while the new session is being created. Therefore it waits for any
1134      * already running GET/PUT method calls, which have a 'read' lock, to complete. And also causes any new GET/PUT
1135      * method calls to wait until this method releases the 'write' lock again. Whereby such GET/PUT calls are postponed
1136      * to the new session.
1137      */
1138     private synchronized void recreateSession() {
1139         try (SessionSynchronizer sessionSynchronizer = new SessionSynchronizer(true)) {
1140             LOGGER.debug("recreateSession()");
1141             recreatingSession = true;
1142             State onlineState = this.onlineState;
1143             close2();
1144             stopHttp2Client();
1145             //
1146             startHttp2Client();
1147             openPassive();
1148             if (onlineState == State.ACTIVE) {
1149                 openActive();
1150             }
1151         } catch (ApiException | InterruptedException e) {
1152             if (LOGGER.isDebugEnabled()) {
1153                 LOGGER.debug("recreateSession() exception", e);
1154             } else {
1155                 LOGGER.warn("recreateSession() {}: {}", e.getClass().getSimpleName(), e.getMessage());
1156             }
1157         } finally {
1158             recreatingSession = false;
1159             LOGGER.debug("recreateSession() done");
1160         }
1161     }
1162
1163     /**
1164      * Try to register the application key with the hub. Use the given application key if one is provided; otherwise the
1165      * hub will create a new one. Note: this requires an HTTP 1.1 client call.
1166      *
1167      * @param oldApplicationKey existing application key if any i.e. may be empty.
1168      * @return the existing or a newly created application key.
1169      * @throws HttpUnauthorizedException if the registration failed.
1170      * @throws ApiException if there was a communications error.
1171      * @throws InterruptedException
1172      */
1173     public String registerApplicationKey(@Nullable String oldApplicationKey)
1174             throws HttpUnauthorizedException, ApiException, InterruptedException {
1175         LOGGER.debug("registerApplicationKey()");
1176         String json = jsonParser.toJson((Objects.isNull(oldApplicationKey) || oldApplicationKey.isEmpty())
1177                 ? new CreateUserRequest(APPLICATION_ID)
1178                 : new CreateUserRequest(oldApplicationKey, APPLICATION_ID));
1179         Request httpRequest = httpClient.newRequest(registrationUrl).method(HttpMethod.POST)
1180                 .timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
1181                 .content(new StringContentProvider(json), MediaType.APPLICATION_JSON);
1182         ContentResponse contentResponse;
1183         try {
1184             LOGGER.trace("POST {} HTTP/1.1 >> {}", registrationUrl, json);
1185             contentResponse = httpRequest.send();
1186         } catch (TimeoutException | ExecutionException e) {
1187             throw new ApiException("HTTP processing error", e);
1188         }
1189         int httpStatus = contentResponse.getStatus();
1190         json = contentResponse.getContentAsString().trim();
1191         LOGGER.trace("HTTP/1.1 {} {} << {}", httpStatus, contentResponse.getReason(), json);
1192         if (httpStatus != HttpStatus.OK_200) {
1193             throw new ApiException(String.format("HTTP bad response '%d'", httpStatus));
1194         }
1195         try {
1196             List<SuccessResponse> entries = jsonParser.fromJson(json, SuccessResponse.GSON_TYPE);
1197             if (Objects.nonNull(entries) && !entries.isEmpty()) {
1198                 SuccessResponse response = entries.get(0);
1199                 Map<String, Object> responseSuccess = response.success;
1200                 if (Objects.nonNull(responseSuccess)) {
1201                     String newApplicationKey = (String) responseSuccess.get("username");
1202                     if (Objects.nonNull(newApplicationKey)) {
1203                         return newApplicationKey;
1204                     }
1205                 }
1206             }
1207         } catch (JsonParseException e) {
1208             LOGGER.debug("registerApplicationKey() parsing error json:{}", json, e);
1209         }
1210         throw new HttpUnauthorizedException("Application key registration failed");
1211     }
1212
1213     private void startHttp2Client() throws ApiException {
1214         try {
1215             http2Client.start();
1216         } catch (Exception e) {
1217             throw new ApiException("Error starting HTTP/2 client", e);
1218         }
1219     }
1220
1221     private void stopHttp2Client() throws ApiException {
1222         try {
1223             http2Client.stop();
1224         } catch (Exception e) {
1225             throw new ApiException("Error stopping HTTP/2 client", e);
1226         }
1227     }
1228
1229     /**
1230      * Test the Hue Bridge connection state by attempting to connect and trying to execute a basic command that requires
1231      * authentication.
1232      *
1233      * @throws HttpUnauthorizedException if it was possible to connect but not to authenticate.
1234      * @throws ApiException if it was not possible to connect.
1235      * @throws InterruptedException
1236      */
1237     public void testConnectionState() throws HttpUnauthorizedException, ApiException, InterruptedException {
1238         LOGGER.debug("testConnectionState()");
1239         try {
1240             openPassive();
1241             getResourcesImpl(BRIDGE);
1242         } catch (ApiException e) {
1243             close2();
1244             throw e;
1245         }
1246     }
1247 }