]> git.basschouten.com Git - openhab-addons.git/blob
1b40d12f85ed6071e0be84d4ceec5cd92d35d4df
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 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.remoteopenhab.internal.rest;
14
15 import java.io.ByteArrayOutputStream;
16 import java.io.EOFException;
17 import java.io.IOException;
18 import java.io.InputStream;
19 import java.nio.charset.StandardCharsets;
20 import java.util.Arrays;
21 import java.util.Base64;
22 import java.util.HashMap;
23 import java.util.List;
24 import java.util.Map;
25 import java.util.Objects;
26 import java.util.concurrent.CopyOnWriteArrayList;
27 import java.util.concurrent.ExecutionException;
28 import java.util.concurrent.TimeUnit;
29 import java.util.concurrent.TimeoutException;
30
31 import javax.net.ssl.HostnameVerifier;
32 import javax.net.ssl.SSLSession;
33 import javax.ws.rs.client.Client;
34 import javax.ws.rs.client.ClientBuilder;
35 import javax.ws.rs.core.HttpHeaders;
36 import javax.ws.rs.sse.InboundSseEvent;
37 import javax.ws.rs.sse.SseEventSource;
38
39 import org.eclipse.jdt.annotation.NonNullByDefault;
40 import org.eclipse.jdt.annotation.Nullable;
41 import org.eclipse.jetty.client.HttpClient;
42 import org.eclipse.jetty.client.api.ContentResponse;
43 import org.eclipse.jetty.client.api.Request;
44 import org.eclipse.jetty.client.api.Response;
45 import org.eclipse.jetty.client.util.InputStreamResponseListener;
46 import org.eclipse.jetty.client.util.StringContentProvider;
47 import org.eclipse.jetty.http.HttpMethod;
48 import org.eclipse.jetty.http.HttpStatus;
49 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabChannelDescriptionChangedEvent;
50 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabChannelTriggerEvent;
51 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabCommandDescription;
52 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabCommandOptions;
53 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabEvent;
54 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabEventPayload;
55 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabItem;
56 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabRestApi;
57 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabStateDescription;
58 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabStateOptions;
59 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabStatusInfo;
60 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabThing;
61 import org.openhab.binding.remoteopenhab.internal.exceptions.RemoteopenhabException;
62 import org.openhab.binding.remoteopenhab.internal.listener.RemoteopenhabItemsDataListener;
63 import org.openhab.binding.remoteopenhab.internal.listener.RemoteopenhabStreamingDataListener;
64 import org.openhab.binding.remoteopenhab.internal.listener.RemoteopenhabThingsDataListener;
65 import org.openhab.core.types.Command;
66 import org.osgi.service.jaxrs.client.SseEventSourceFactory;
67 import org.slf4j.Logger;
68 import org.slf4j.LoggerFactory;
69
70 import com.google.gson.Gson;
71 import com.google.gson.JsonSyntaxException;
72
73 /**
74  * A client to use the openHAB REST API and to receive/parse events received from the openHAB REST API Server-Sent
75  * Events (SSE).
76  *
77  * @author Laurent Garnier - Initial contribution
78  */
79 @NonNullByDefault
80 public class RemoteopenhabRestClient {
81
82     private static final int REQUEST_TIMEOUT = (int) TimeUnit.SECONDS.toMillis(30);
83
84     private final Logger logger = LoggerFactory.getLogger(RemoteopenhabRestClient.class);
85
86     private final ClientBuilder clientBuilder;
87     private final SseEventSourceFactory eventSourceFactory;
88     private final Gson jsonParser;
89
90     private final Object startStopLock = new Object();
91     private final List<RemoteopenhabStreamingDataListener> listeners = new CopyOnWriteArrayList<>();
92     private final List<RemoteopenhabItemsDataListener> itemsListeners = new CopyOnWriteArrayList<>();
93     private final List<RemoteopenhabThingsDataListener> thingsListeners = new CopyOnWriteArrayList<>();
94
95     private HttpClient httpClient;
96     private @Nullable String restUrl;
97     private @Nullable String restApiVersion;
98     private Map<String, @Nullable String> apiEndPointsUrls = new HashMap<>();
99     private @Nullable String topicNamespace;
100     private boolean authenticateAnyway;
101     private String accessToken;
102     private String credentialToken;
103     private boolean trustedCertificate;
104     private boolean connected;
105     private boolean completed;
106
107     private @Nullable SseEventSource eventSource;
108     private long lastEventTimestamp;
109
110     public RemoteopenhabRestClient(final HttpClient httpClient, final ClientBuilder clientBuilder,
111             final SseEventSourceFactory eventSourceFactory, final Gson jsonParser) {
112         this.httpClient = httpClient;
113         this.clientBuilder = clientBuilder;
114         this.eventSourceFactory = eventSourceFactory;
115         this.jsonParser = jsonParser;
116         this.accessToken = "";
117         this.credentialToken = "";
118     }
119
120     public void setHttpClient(HttpClient httpClient) {
121         this.httpClient = httpClient;
122     }
123
124     public String getRestUrl() throws RemoteopenhabException {
125         String url = restUrl;
126         if (url == null) {
127             throw new RemoteopenhabException("REST client not correctly setup");
128         }
129         return url;
130     }
131
132     public void setRestUrl(String restUrl) {
133         this.restUrl = restUrl;
134     }
135
136     public void setAuthenticationData(boolean authenticateAnyway, String accessToken, String username,
137             String password) {
138         this.authenticateAnyway = authenticateAnyway;
139         this.accessToken = accessToken;
140         if (username.isBlank() || password.isBlank()) {
141             this.credentialToken = "";
142         } else {
143             String token = username + ":" + password;
144             this.credentialToken = Base64.getEncoder().encodeToString(token.getBytes(StandardCharsets.UTF_8));
145         }
146     }
147
148     public void setTrustedCertificate(boolean trustedCertificate) {
149         this.trustedCertificate = trustedCertificate;
150     }
151
152     public void tryApi() throws RemoteopenhabException {
153         try {
154             String jsonResponse = executeGetUrl(getRestUrl(), "application/json", false, false);
155             if (jsonResponse.isEmpty()) {
156                 throw new RemoteopenhabException("JSON response is empty");
157             }
158             RemoteopenhabRestApi restApi = jsonParser.fromJson(jsonResponse, RemoteopenhabRestApi.class);
159             restApiVersion = restApi.version;
160             logger.debug("REST API version = {}", restApiVersion);
161             apiEndPointsUrls.clear();
162             for (int i = 0; i < restApi.links.length; i++) {
163                 apiEndPointsUrls.put(restApi.links[i].type, restApi.links[i].url);
164             }
165             logger.debug("REST API items = {}", apiEndPointsUrls.get("items"));
166             logger.debug("REST API things = {}", apiEndPointsUrls.get("things"));
167             logger.debug("REST API events = {}", apiEndPointsUrls.get("events"));
168             topicNamespace = restApi.runtimeInfo != null ? "openhab" : "smarthome";
169             logger.debug("topic namespace = {}", topicNamespace);
170         } catch (RemoteopenhabException | JsonSyntaxException e) {
171             throw new RemoteopenhabException("Failed to execute the root REST API: " + e.getMessage(), e);
172         }
173     }
174
175     public List<RemoteopenhabItem> getRemoteItems(@Nullable String fields) throws RemoteopenhabException {
176         try {
177             String url = String.format("%s?recursive=false", getRestApiUrl("items"));
178             if (fields != null) {
179                 url += "&fields=" + fields;
180             }
181             boolean asyncReading = fields == null || Arrays.asList(fields.split(",")).contains("state");
182             String jsonResponse = executeGetUrl(url, "application/json", false, asyncReading);
183             if (jsonResponse.isEmpty()) {
184                 throw new RemoteopenhabException("JSON response is empty");
185             }
186             return Arrays.asList(jsonParser.fromJson(jsonResponse, RemoteopenhabItem[].class));
187         } catch (RemoteopenhabException | JsonSyntaxException e) {
188             throw new RemoteopenhabException(
189                     "Failed to get the list of remote items using the items REST API: " + e.getMessage(), e);
190         }
191     }
192
193     public String getRemoteItemState(String itemName) throws RemoteopenhabException {
194         try {
195             String url = String.format("%s/%s/state", getRestApiUrl("items"), itemName);
196             return executeGetUrl(url, "text/plain", false, true);
197         } catch (RemoteopenhabException e) {
198             throw new RemoteopenhabException("Failed to get the state of remote item " + itemName
199                     + " using the items REST API: " + e.getMessage(), e);
200         }
201     }
202
203     public void sendCommandToRemoteItem(String itemName, Command command) throws RemoteopenhabException {
204         try {
205             String url = String.format("%s/%s", getRestApiUrl("items"), itemName);
206             executeUrl(HttpMethod.POST, url, "application/json", command.toFullString(), "text/plain", false, false,
207                     true);
208         } catch (RemoteopenhabException e) {
209             throw new RemoteopenhabException("Failed to send command to the remote item " + itemName
210                     + " using the items REST API: " + e.getMessage(), e);
211         }
212     }
213
214     public List<RemoteopenhabThing> getRemoteThings() throws RemoteopenhabException {
215         try {
216             String jsonResponse = executeGetUrl(getRestApiUrl("things"), "application/json", true, false);
217             if (jsonResponse.isEmpty()) {
218                 throw new RemoteopenhabException("JSON response is empty");
219             }
220             return Arrays.asList(jsonParser.fromJson(jsonResponse, RemoteopenhabThing[].class));
221         } catch (RemoteopenhabException | JsonSyntaxException e) {
222             throw new RemoteopenhabException(
223                     "Failed to get the list of remote things using the things REST API: " + e.getMessage(), e);
224         }
225     }
226
227     public RemoteopenhabThing getRemoteThing(String uid) throws RemoteopenhabException {
228         try {
229             String url = String.format("%s/%s", getRestApiUrl("things"), uid);
230             String jsonResponse = executeGetUrl(url, "application/json", true, false);
231             if (jsonResponse.isEmpty()) {
232                 throw new RemoteopenhabException("JSON response is empty");
233             }
234             return Objects.requireNonNull(jsonParser.fromJson(jsonResponse, RemoteopenhabThing.class));
235         } catch (RemoteopenhabException | JsonSyntaxException e) {
236             throw new RemoteopenhabException(
237                     "Failed to get the remote thing " + uid + " using the things REST API: " + e.getMessage(), e);
238         }
239     }
240
241     public @Nullable String getRestApiVersion() {
242         return restApiVersion;
243     }
244
245     private String getRestApiUrl(String endPoint) throws RemoteopenhabException {
246         String url = apiEndPointsUrls.get(endPoint);
247         if (url == null) {
248             url = getRestUrl();
249             if (!url.endsWith("/")) {
250                 url += "/";
251             }
252             url += endPoint;
253         }
254         return url;
255     }
256
257     public String getTopicNamespace() {
258         String namespace = topicNamespace;
259         return namespace != null ? namespace : "openhab";
260     }
261
262     public void start() {
263         synchronized (startStopLock) {
264             logger.debug("Opening EventSource");
265             reopenEventSource();
266             logger.debug("EventSource started");
267         }
268     }
269
270     public void stop(boolean waitingForCompletion) {
271         synchronized (startStopLock) {
272             logger.debug("Closing EventSource");
273             closeEventSource(waitingForCompletion);
274             logger.debug("EventSource stopped");
275             lastEventTimestamp = 0;
276         }
277     }
278
279     private SseEventSource createEventSource(String restSseUrl) {
280         String credentialToken = restSseUrl.startsWith("https:") || authenticateAnyway ? this.credentialToken : "";
281
282         RemoteopenhabStreamingRequestFilter filter;
283         boolean filterRegistered = clientBuilder.getConfiguration()
284                 .isRegistered(RemoteopenhabStreamingRequestFilter.class);
285         if (filterRegistered) {
286             filter = clientBuilder.getConfiguration().getInstances().stream()
287                     .filter(instance -> instance instanceof RemoteopenhabStreamingRequestFilter)
288                     .map(instance -> (RemoteopenhabStreamingRequestFilter) instance).findAny().orElseThrow();
289         } else {
290             filter = new RemoteopenhabStreamingRequestFilter();
291         }
292         filter.setCredentialToken(restSseUrl, credentialToken);
293
294         Client client;
295         // Avoid a timeout exception after 1 minute by setting the read timeout to 0 (infinite)
296         if (trustedCertificate) {
297             HostnameVerifier alwaysValidHostname = new HostnameVerifier() {
298                 @Override
299                 public boolean verify(@Nullable String hostname, @Nullable SSLSession session) {
300                     return true;
301                 }
302             };
303             if (filterRegistered) {
304                 client = clientBuilder.sslContext(httpClient.getSslContextFactory().getSslContext())
305                         .hostnameVerifier(alwaysValidHostname).readTimeout(0, TimeUnit.SECONDS).build();
306             } else {
307                 client = clientBuilder.sslContext(httpClient.getSslContextFactory().getSslContext())
308                         .hostnameVerifier(alwaysValidHostname).readTimeout(0, TimeUnit.SECONDS).register(filter)
309                         .build();
310             }
311         } else {
312             if (filterRegistered) {
313                 client = clientBuilder.readTimeout(0, TimeUnit.SECONDS).build();
314             } else {
315                 client = clientBuilder.readTimeout(0, TimeUnit.SECONDS).register(filter).build();
316             }
317         }
318
319         SseEventSource eventSource = eventSourceFactory.newSource(client.target(restSseUrl));
320         eventSource.register(this::onEvent, this::onError, this::onComplete);
321         return eventSource;
322     }
323
324     private void reopenEventSource() {
325         logger.debug("Reopening EventSource");
326
327         String url;
328         try {
329             url = String.format(
330                     "%s?topics=%s/items/*/*,%s/things/*/*,%s/channels/*/triggered,openhab/channels/*/descriptionchanged",
331                     getRestApiUrl("events"), getTopicNamespace(), getTopicNamespace(), getTopicNamespace());
332         } catch (RemoteopenhabException e) {
333             logger.debug("{}", e.getMessage());
334             return;
335         }
336
337         closeEventSource(true);
338
339         logger.debug("Opening new EventSource {}", url);
340         SseEventSource localEventSource = createEventSource(url);
341         localEventSource.open();
342
343         eventSource = localEventSource;
344     }
345
346     private void closeEventSource(boolean waitingForCompletion) {
347         SseEventSource localEventSource = eventSource;
348         if (localEventSource != null) {
349             if (!localEventSource.isOpen() || completed) {
350                 logger.debug("Existing EventSource is already closed");
351             } else if (localEventSource.close(waitingForCompletion ? 10 : 0, TimeUnit.SECONDS)) {
352                 logger.debug("Succesfully closed existing EventSource");
353             } else {
354                 logger.debug("Failed to close existing EventSource");
355             }
356             eventSource = null;
357         }
358         connected = false;
359     }
360
361     public boolean addStreamingDataListener(RemoteopenhabStreamingDataListener listener) {
362         return listeners.add(listener);
363     }
364
365     public boolean removeStreamingDataListener(RemoteopenhabStreamingDataListener listener) {
366         return listeners.remove(listener);
367     }
368
369     public boolean addItemsDataListener(RemoteopenhabItemsDataListener listener) {
370         return itemsListeners.add(listener);
371     }
372
373     public boolean removeItemsDataListener(RemoteopenhabItemsDataListener listener) {
374         return itemsListeners.remove(listener);
375     }
376
377     public boolean addThingsDataListener(RemoteopenhabThingsDataListener listener) {
378         return thingsListeners.add(listener);
379     }
380
381     public boolean removeThingsDataListener(RemoteopenhabThingsDataListener listener) {
382         return thingsListeners.remove(listener);
383     }
384
385     public long getLastEventTimestamp() {
386         return lastEventTimestamp;
387     }
388
389     private void onEvent(InboundSseEvent inboundEvent) {
390         String name = inboundEvent.getName();
391         String data = inboundEvent.readData();
392         logger.trace("Received event name {} data {}", name, data);
393
394         lastEventTimestamp = System.currentTimeMillis();
395         if (!connected) {
396             logger.debug("Connected to streaming events");
397             connected = true;
398             listeners.forEach(listener -> listener.onConnected());
399         }
400
401         if (!"message".equals(name)) {
402             logger.debug("Received unhandled event with name '{}' and data '{}'", name, data);
403             return;
404         }
405
406         try {
407             RemoteopenhabEvent event = jsonParser.fromJson(data, RemoteopenhabEvent.class);
408             String itemName;
409             String thingUID;
410             RemoteopenhabEventPayload payload;
411             RemoteopenhabItem item;
412             RemoteopenhabThing thing;
413             switch (event.type) {
414                 case "ItemStateEvent":
415                     itemName = extractItemNameFromTopic(event.topic, event.type, "state");
416                     payload = jsonParser.fromJson(event.payload, RemoteopenhabEventPayload.class);
417                     itemsListeners.forEach(
418                             listener -> listener.onItemStateEvent(itemName, payload.type, payload.value, false));
419                     break;
420                 case "ItemStateChangedEvent":
421                     itemName = extractItemNameFromTopic(event.topic, event.type, "statechanged");
422                     payload = jsonParser.fromJson(event.payload, RemoteopenhabEventPayload.class);
423                     itemsListeners.forEach(
424                             listener -> listener.onItemStateEvent(itemName, payload.type, payload.value, true));
425                     break;
426                 case "GroupItemStateChangedEvent":
427                     itemName = extractItemNameFromTopic(event.topic, event.type, "statechanged");
428                     payload = jsonParser.fromJson(event.payload, RemoteopenhabEventPayload.class);
429                     itemsListeners.forEach(
430                             listener -> listener.onItemStateEvent(itemName, payload.type, payload.value, false));
431                     break;
432                 case "ItemAddedEvent":
433                     itemName = extractItemNameFromTopic(event.topic, event.type, "added");
434                     item = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabItem.class));
435                     itemsListeners.forEach(listener -> listener.onItemAdded(item));
436                     break;
437                 case "ItemRemovedEvent":
438                     itemName = extractItemNameFromTopic(event.topic, event.type, "removed");
439                     item = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabItem.class));
440                     itemsListeners.forEach(listener -> listener.onItemRemoved(item));
441                     break;
442                 case "ItemUpdatedEvent":
443                     itemName = extractItemNameFromTopic(event.topic, event.type, "updated");
444                     RemoteopenhabItem[] updItem = jsonParser.fromJson(event.payload, RemoteopenhabItem[].class);
445                     if (updItem.length == 2) {
446                         itemsListeners.forEach(listener -> listener.onItemUpdated(updItem[0], updItem[1]));
447                     } else {
448                         logger.debug("Invalid payload for event type {} for topic {}", event.type, event.topic);
449                     }
450                     break;
451                 case "ThingStatusInfoChangedEvent":
452                     thingUID = extractThingUIDFromTopic(event.topic, event.type, "statuschanged");
453                     RemoteopenhabStatusInfo[] updStatus = jsonParser.fromJson(event.payload,
454                             RemoteopenhabStatusInfo[].class);
455                     if (updStatus.length == 2) {
456                         thingsListeners.forEach(listener -> listener.onThingStatusUpdated(thingUID, updStatus[0]));
457                     } else {
458                         logger.debug("Invalid payload for event type {} for topic {}", event.type, event.topic);
459                     }
460                     break;
461                 case "ThingAddedEvent":
462                     thingUID = extractThingUIDFromTopic(event.topic, event.type, "added");
463                     thing = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabThing.class));
464                     thingsListeners.forEach(listener -> listener.onThingAdded(thing));
465                     break;
466                 case "ThingRemovedEvent":
467                     thingUID = extractThingUIDFromTopic(event.topic, event.type, "removed");
468                     thing = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabThing.class));
469                     thingsListeners.forEach(listener -> listener.onThingRemoved(thing));
470                     break;
471                 case "ChannelTriggeredEvent":
472                     RemoteopenhabChannelTriggerEvent triggerEvent = jsonParser.fromJson(event.payload,
473                             RemoteopenhabChannelTriggerEvent.class);
474                     thingsListeners
475                             .forEach(listener -> listener.onChannelTriggered(triggerEvent.channel, triggerEvent.event));
476                     break;
477                 case "ChannelDescriptionChangedEvent":
478                     RemoteopenhabStateDescription stateDescription = new RemoteopenhabStateDescription();
479                     RemoteopenhabCommandDescription commandDescription = new RemoteopenhabCommandDescription();
480                     RemoteopenhabChannelDescriptionChangedEvent descriptionChanged = Objects.requireNonNull(
481                             jsonParser.fromJson(event.payload, RemoteopenhabChannelDescriptionChangedEvent.class));
482                     switch (descriptionChanged.field) {
483                         case "STATE_OPTIONS":
484                             RemoteopenhabStateOptions stateOptions = Objects.requireNonNull(
485                                     jsonParser.fromJson(descriptionChanged.value, RemoteopenhabStateOptions.class));
486                             stateDescription.options = stateOptions.options;
487                             break;
488                         case "COMMAND_OPTIONS":
489                             RemoteopenhabCommandOptions commandOptions = Objects.requireNonNull(
490                                     jsonParser.fromJson(descriptionChanged.value, RemoteopenhabCommandOptions.class));
491                             commandDescription.commandOptions = commandOptions.options;
492                             break;
493                         default:
494                             break;
495                     }
496                     if (stateDescription.options != null || commandDescription.commandOptions != null) {
497                         descriptionChanged.linkedItemNames.forEach(linkedItemName -> {
498                             RemoteopenhabItem item1 = new RemoteopenhabItem();
499                             item1.name = linkedItemName;
500                             item1.stateDescription = stateDescription;
501                             item1.commandDescription = commandDescription;
502                             itemsListeners.forEach(listener -> listener.onItemOptionsUpdatedd(item1));
503                         });
504                     }
505                     break;
506                 case "ItemStatePredictedEvent":
507                 case "ItemCommandEvent":
508                 case "ThingStatusInfoEvent":
509                 case "ThingUpdatedEvent":
510                     logger.trace("Ignored event type {} for topic {}", event.type, event.topic);
511                     break;
512                 default:
513                     logger.debug("Unexpected event type {} for topic {}", event.type, event.topic);
514                     break;
515             }
516         } catch (RemoteopenhabException | JsonSyntaxException e) {
517             logger.debug("An exception occurred while processing the inbound '{}' event containg data: {}", name, data,
518                     e);
519         }
520     }
521
522     private void onComplete() {
523         logger.debug("Disconnected from streaming events");
524         completed = true;
525         listeners.forEach(listener -> listener.onDisconnected());
526     }
527
528     private void onError(Throwable error) {
529         logger.debug("Error occurred while receiving events", error);
530         listeners.forEach(listener -> listener.onError("Error occurred while receiving events"));
531     }
532
533     private String extractItemNameFromTopic(String topic, String eventType, String finalPart)
534             throws RemoteopenhabException {
535         String[] parts = topic.split("/");
536         int expectedNbParts = "GroupItemStateChangedEvent".equals(eventType) ? 5 : 4;
537         if (parts.length != expectedNbParts || !getTopicNamespace().equals(parts[0]) || !"items".equals(parts[1])
538                 || !finalPart.equals(parts[parts.length - 1])) {
539             throw new RemoteopenhabException("Invalid event topic " + topic + " for event type " + eventType);
540         }
541         return parts[2];
542     }
543
544     private String extractThingUIDFromTopic(String topic, String eventType, String finalPart)
545             throws RemoteopenhabException {
546         String[] parts = topic.split("/");
547         int expectedNbParts = 4;
548         if (parts.length != expectedNbParts || !getTopicNamespace().equals(parts[0]) || !"things".equals(parts[1])
549                 || !finalPart.equals(parts[parts.length - 1])) {
550             throw new RemoteopenhabException("Invalid event topic " + topic + " for event type " + eventType);
551         }
552         return parts[2];
553     }
554
555     public String executeGetUrl(String url, String acceptHeader, boolean provideAccessToken, boolean asyncReading)
556             throws RemoteopenhabException {
557         return executeUrl(HttpMethod.GET, url, acceptHeader, null, null, provideAccessToken, asyncReading, true);
558     }
559
560     public String executeUrl(HttpMethod httpMethod, String url, String acceptHeader, @Nullable String content,
561             @Nullable String contentType, boolean provideAccessToken, boolean asyncReading, boolean retryIfEOF)
562             throws RemoteopenhabException {
563         final Request request = httpClient.newRequest(url).method(httpMethod)
564                 .timeout(REQUEST_TIMEOUT, TimeUnit.MILLISECONDS).followRedirects(false)
565                 .header(HttpHeaders.ACCEPT, acceptHeader);
566
567         if (url.startsWith("https:") || authenticateAnyway) {
568             boolean useAlternativeHeader = false;
569             if (!credentialToken.isEmpty()) {
570                 request.header(HttpHeaders.AUTHORIZATION, "Basic " + credentialToken);
571                 useAlternativeHeader = true;
572             }
573             if (provideAccessToken && !accessToken.isEmpty()) {
574                 if (useAlternativeHeader) {
575                     request.header("X-OPENHAB-TOKEN", accessToken);
576                 } else {
577                     request.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
578                 }
579             }
580         }
581
582         if (content != null && (HttpMethod.POST.equals(httpMethod) || HttpMethod.PUT.equals(httpMethod))
583                 && contentType != null) {
584             request.content(new StringContentProvider(content), contentType);
585         }
586
587         logger.debug("Request {} {}", request.getMethod(), request.getURI());
588
589         try {
590             if (asyncReading) {
591                 InputStreamResponseListener listener = new InputStreamResponseListener();
592                 request.send(listener);
593                 Response response = listener.get(5, TimeUnit.SECONDS);
594                 int statusCode = response.getStatus();
595                 if (statusCode != HttpStatus.OK_200) {
596                     response.abort(new Exception(response.getReason()));
597                     String statusLine = statusCode + " " + response.getReason();
598                     throw new RemoteopenhabException("HTTP call failed: " + statusLine);
599                 }
600                 ByteArrayOutputStream responseContent = new ByteArrayOutputStream();
601                 try (InputStream input = listener.getInputStream()) {
602                     input.transferTo(responseContent);
603                 }
604                 return new String(responseContent.toByteArray(), StandardCharsets.UTF_8.name());
605             } else {
606                 ContentResponse response = request.send();
607                 int statusCode = response.getStatus();
608                 if (statusCode == HttpStatus.MOVED_PERMANENTLY_301 || statusCode == HttpStatus.FOUND_302) {
609                     String locationHeader = response.getHeaders().get(HttpHeaders.LOCATION);
610                     if (locationHeader != null && !locationHeader.isBlank()) {
611                         logger.debug("The remopte server redirected the request to this URL: {}", locationHeader);
612                         return executeUrl(httpMethod, locationHeader, acceptHeader, content, contentType,
613                                 provideAccessToken, asyncReading, retryIfEOF);
614                     } else {
615                         String statusLine = statusCode + " " + response.getReason();
616                         throw new RemoteopenhabException("HTTP call failed: " + statusLine);
617                     }
618                 } else if (statusCode >= HttpStatus.BAD_REQUEST_400) {
619                     String statusLine = statusCode + " " + response.getReason();
620                     throw new RemoteopenhabException("HTTP call failed: " + statusLine);
621                 }
622                 String encoding = response.getEncoding() != null ? response.getEncoding().replaceAll("\"", "").trim()
623                         : StandardCharsets.UTF_8.name();
624                 return new String(response.getContent(), encoding);
625             }
626         } catch (RemoteopenhabException e) {
627             throw e;
628         } catch (ExecutionException e) {
629             // After a long network outage, the first HTTP request will fail with an EOFException exception.
630             // We retry the request a second time in this case.
631             Throwable cause = e.getCause();
632             if (retryIfEOF && cause instanceof EOFException) {
633                 logger.debug("EOFException - retry the request");
634                 return executeUrl(httpMethod, url, acceptHeader, content, contentType, provideAccessToken, asyncReading,
635                         false);
636             } else {
637                 throw new RemoteopenhabException(e);
638             }
639         } catch (IOException | TimeoutException e) {
640             throw new RemoteopenhabException(e);
641         } catch (InterruptedException e) {
642             Thread.currentThread().interrupt();
643             throw new RemoteopenhabException(e);
644         }
645     }
646 }