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