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