2 * Copyright (c) 2010-2021 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.remoteopenhab.internal.rest;
15 import java.io.ByteArrayOutputStream;
16 import java.io.EOFException;
17 import java.io.InputStream;
18 import java.nio.charset.StandardCharsets;
19 import java.util.Arrays;
20 import java.util.Base64;
21 import java.util.HashMap;
22 import java.util.List;
24 import java.util.Objects;
25 import java.util.concurrent.CopyOnWriteArrayList;
26 import java.util.concurrent.ExecutionException;
27 import java.util.concurrent.TimeUnit;
29 import javax.net.ssl.HostnameVerifier;
30 import javax.net.ssl.SSLSession;
31 import javax.ws.rs.client.Client;
32 import javax.ws.rs.client.ClientBuilder;
33 import javax.ws.rs.core.HttpHeaders;
34 import javax.ws.rs.sse.InboundSseEvent;
35 import javax.ws.rs.sse.SseEventSource;
37 import org.eclipse.jdt.annotation.NonNullByDefault;
38 import org.eclipse.jdt.annotation.Nullable;
39 import org.eclipse.jetty.client.HttpClient;
40 import org.eclipse.jetty.client.api.ContentResponse;
41 import org.eclipse.jetty.client.api.Request;
42 import org.eclipse.jetty.client.api.Response;
43 import org.eclipse.jetty.client.util.InputStreamResponseListener;
44 import org.eclipse.jetty.client.util.StringContentProvider;
45 import org.eclipse.jetty.http.HttpMethod;
46 import org.eclipse.jetty.http.HttpStatus;
47 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabChannelTriggerEvent;
48 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabEvent;
49 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabEventPayload;
50 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabItem;
51 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabRestApi;
52 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabStatusInfo;
53 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabThing;
54 import org.openhab.binding.remoteopenhab.internal.exceptions.RemoteopenhabException;
55 import org.openhab.binding.remoteopenhab.internal.listener.RemoteopenhabItemsDataListener;
56 import org.openhab.binding.remoteopenhab.internal.listener.RemoteopenhabStreamingDataListener;
57 import org.openhab.binding.remoteopenhab.internal.listener.RemoteopenhabThingsDataListener;
58 import org.openhab.core.types.Command;
59 import org.osgi.service.jaxrs.client.SseEventSourceFactory;
60 import org.slf4j.Logger;
61 import org.slf4j.LoggerFactory;
63 import com.google.gson.Gson;
64 import com.google.gson.JsonSyntaxException;
67 * A client to use the openHAB REST API and to receive/parse events received from the openHAB REST API Server-Sent
70 * @author Laurent Garnier - Initial contribution
73 public class RemoteopenhabRestClient {
75 private static final int REQUEST_TIMEOUT = (int) TimeUnit.SECONDS.toMillis(30);
77 private final Logger logger = LoggerFactory.getLogger(RemoteopenhabRestClient.class);
79 private final ClientBuilder clientBuilder;
80 private final SseEventSourceFactory eventSourceFactory;
81 private final Gson jsonParser;
83 private final Object startStopLock = new Object();
84 private final List<RemoteopenhabStreamingDataListener> listeners = new CopyOnWriteArrayList<>();
85 private final List<RemoteopenhabItemsDataListener> itemsListeners = new CopyOnWriteArrayList<>();
86 private final List<RemoteopenhabThingsDataListener> thingsListeners = new CopyOnWriteArrayList<>();
88 private HttpClient httpClient;
89 private @Nullable String restUrl;
90 private @Nullable String restApiVersion;
91 private Map<String, @Nullable String> apiEndPointsUrls = new HashMap<>();
92 private @Nullable String topicNamespace;
93 private boolean authenticateAnyway;
94 private String accessToken;
95 private String credentialToken;
96 private boolean trustedCertificate;
97 private boolean connected;
98 private boolean completed;
100 private @Nullable SseEventSource eventSource;
101 private long lastEventTimestamp;
103 public RemoteopenhabRestClient(final HttpClient httpClient, final ClientBuilder clientBuilder,
104 final SseEventSourceFactory eventSourceFactory, final Gson jsonParser) {
105 this.httpClient = httpClient;
106 this.clientBuilder = clientBuilder;
107 this.eventSourceFactory = eventSourceFactory;
108 this.jsonParser = jsonParser;
109 this.accessToken = "";
110 this.credentialToken = "";
113 public void setHttpClient(HttpClient httpClient) {
114 this.httpClient = httpClient;
117 public String getRestUrl() throws RemoteopenhabException {
118 String url = restUrl;
120 throw new RemoteopenhabException("REST client not correctly setup");
125 public void setRestUrl(String restUrl) {
126 this.restUrl = restUrl;
129 public void setAuthenticationData(boolean authenticateAnyway, String accessToken, String username,
131 this.authenticateAnyway = authenticateAnyway;
132 this.accessToken = accessToken;
133 if (username.isBlank() || password.isBlank()) {
134 this.credentialToken = "";
136 String token = username + ":" + password;
137 this.credentialToken = Base64.getEncoder().encodeToString(token.getBytes(StandardCharsets.UTF_8));
141 public void setTrustedCertificate(boolean trustedCertificate) {
142 this.trustedCertificate = trustedCertificate;
145 public void tryApi() throws RemoteopenhabException {
147 String jsonResponse = executeGetUrl(getRestUrl(), "application/json", false, false);
148 if (jsonResponse.isEmpty()) {
149 throw new RemoteopenhabException("JSON response is empty");
151 RemoteopenhabRestApi restApi = jsonParser.fromJson(jsonResponse, RemoteopenhabRestApi.class);
152 restApiVersion = restApi.version;
153 logger.debug("REST API version = {}", restApiVersion);
154 apiEndPointsUrls.clear();
155 for (int i = 0; i < restApi.links.length; i++) {
156 apiEndPointsUrls.put(restApi.links[i].type, restApi.links[i].url);
158 logger.debug("REST API items = {}", apiEndPointsUrls.get("items"));
159 logger.debug("REST API things = {}", apiEndPointsUrls.get("things"));
160 logger.debug("REST API events = {}", apiEndPointsUrls.get("events"));
161 topicNamespace = restApi.runtimeInfo != null ? "openhab" : "smarthome";
162 logger.debug("topic namespace = {}", topicNamespace);
163 } catch (RemoteopenhabException | JsonSyntaxException e) {
164 throw new RemoteopenhabException("Failed to execute the root REST API: " + e.getMessage(), e);
168 public List<RemoteopenhabItem> getRemoteItems(@Nullable String fields) throws RemoteopenhabException {
170 String url = String.format("%s?recursive=false", getRestApiUrl("items"));
171 if (fields != null) {
172 url += "&fields=" + fields;
174 boolean asyncReading = fields == null || Arrays.asList(fields.split(",")).contains("state");
175 String jsonResponse = executeGetUrl(url, "application/json", false, asyncReading);
176 if (jsonResponse.isEmpty()) {
177 throw new RemoteopenhabException("JSON response is empty");
179 return Arrays.asList(jsonParser.fromJson(jsonResponse, RemoteopenhabItem[].class));
180 } catch (RemoteopenhabException | JsonSyntaxException e) {
181 throw new RemoteopenhabException(
182 "Failed to get the list of remote items using the items REST API: " + e.getMessage(), e);
186 public String getRemoteItemState(String itemName) throws RemoteopenhabException {
188 String url = String.format("%s/%s/state", getRestApiUrl("items"), itemName);
189 return executeGetUrl(url, "text/plain", false, true);
190 } catch (RemoteopenhabException e) {
191 throw new RemoteopenhabException("Failed to get the state of remote item " + itemName
192 + " using the items REST API: " + e.getMessage(), e);
196 public void sendCommandToRemoteItem(String itemName, Command command) throws RemoteopenhabException {
198 String url = String.format("%s/%s", getRestApiUrl("items"), itemName);
199 executeUrl(HttpMethod.POST, url, "application/json", command.toFullString(), "text/plain", false, false,
201 } catch (RemoteopenhabException e) {
202 throw new RemoteopenhabException("Failed to send command to the remote item " + itemName
203 + " using the items REST API: " + e.getMessage(), e);
207 public List<RemoteopenhabThing> getRemoteThings() throws RemoteopenhabException {
209 String jsonResponse = executeGetUrl(getRestApiUrl("things"), "application/json", true, false);
210 if (jsonResponse.isEmpty()) {
211 throw new RemoteopenhabException("JSON response is empty");
213 return Arrays.asList(jsonParser.fromJson(jsonResponse, RemoteopenhabThing[].class));
214 } catch (RemoteopenhabException | JsonSyntaxException e) {
215 throw new RemoteopenhabException(
216 "Failed to get the list of remote things using the things REST API: " + e.getMessage(), e);
220 public RemoteopenhabThing getRemoteThing(String uid) throws RemoteopenhabException {
222 String url = String.format("%s/%s", getRestApiUrl("things"), uid);
223 String jsonResponse = executeGetUrl(url, "application/json", true, false);
224 if (jsonResponse.isEmpty()) {
225 throw new RemoteopenhabException("JSON response is empty");
227 return Objects.requireNonNull(jsonParser.fromJson(jsonResponse, RemoteopenhabThing.class));
228 } catch (RemoteopenhabException | JsonSyntaxException e) {
229 throw new RemoteopenhabException(
230 "Failed to get the remote thing " + uid + " using the things REST API: " + e.getMessage(), e);
234 public @Nullable String getRestApiVersion() {
235 return restApiVersion;
238 private String getRestApiUrl(String endPoint) throws RemoteopenhabException {
239 String url = apiEndPointsUrls.get(endPoint);
242 if (!url.endsWith("/")) {
250 public String getTopicNamespace() {
251 String namespace = topicNamespace;
252 return namespace != null ? namespace : "openhab";
255 public void start() {
256 synchronized (startStopLock) {
257 logger.debug("Opening EventSource");
259 logger.debug("EventSource started");
263 public void stop(boolean waitingForCompletion) {
264 synchronized (startStopLock) {
265 logger.debug("Closing EventSource");
266 closeEventSource(waitingForCompletion);
267 logger.debug("EventSource stopped");
268 lastEventTimestamp = 0;
272 private SseEventSource createEventSource(String restSseUrl) {
273 String credentialToken = restSseUrl.startsWith("https:") || authenticateAnyway ? this.credentialToken : "";
275 // Avoid a timeout exception after 1 minute by setting the read timeout to 0 (infinite)
276 if (trustedCertificate) {
277 client = clientBuilder.sslContext(httpClient.getSslContextFactory().getSslContext())
278 .hostnameVerifier(new HostnameVerifier() {
280 public boolean verify(@Nullable String hostname, @Nullable SSLSession session) {
283 }).readTimeout(0, TimeUnit.SECONDS)
284 .register(new RemoteopenhabStreamingRequestFilter(credentialToken)).build();
286 client = clientBuilder.readTimeout(0, TimeUnit.SECONDS)
287 .register(new RemoteopenhabStreamingRequestFilter(credentialToken)).build();
289 SseEventSource eventSource = eventSourceFactory.newSource(client.target(restSseUrl));
290 eventSource.register(this::onEvent, this::onError, this::onComplete);
294 private void reopenEventSource() {
295 logger.debug("Reopening EventSource");
299 url = String.format("%s?topics=%s/items/*/*,%s/things/*/*,%s/channels/*/triggered", getRestApiUrl("events"),
300 getTopicNamespace(), getTopicNamespace(), getTopicNamespace());
301 } catch (RemoteopenhabException e) {
302 logger.debug("{}", e.getMessage());
306 closeEventSource(true);
308 logger.debug("Opening new EventSource {}", url);
309 SseEventSource localEventSource = createEventSource(url);
310 localEventSource.open();
312 eventSource = localEventSource;
315 private void closeEventSource(boolean waitingForCompletion) {
316 SseEventSource localEventSource = eventSource;
317 if (localEventSource != null) {
318 if (!localEventSource.isOpen() || completed) {
319 logger.debug("Existing EventSource is already closed");
320 } else if (localEventSource.close(waitingForCompletion ? 10 : 0, TimeUnit.SECONDS)) {
321 logger.debug("Succesfully closed existing EventSource");
323 logger.debug("Failed to close existing EventSource");
330 public boolean addStreamingDataListener(RemoteopenhabStreamingDataListener listener) {
331 return listeners.add(listener);
334 public boolean removeStreamingDataListener(RemoteopenhabStreamingDataListener listener) {
335 return listeners.remove(listener);
338 public boolean addItemsDataListener(RemoteopenhabItemsDataListener listener) {
339 return itemsListeners.add(listener);
342 public boolean removeItemsDataListener(RemoteopenhabItemsDataListener listener) {
343 return itemsListeners.remove(listener);
346 public boolean addThingsDataListener(RemoteopenhabThingsDataListener listener) {
347 return thingsListeners.add(listener);
350 public boolean removeThingsDataListener(RemoteopenhabThingsDataListener listener) {
351 return thingsListeners.remove(listener);
354 public long getLastEventTimestamp() {
355 return lastEventTimestamp;
358 private void onEvent(InboundSseEvent inboundEvent) {
359 String name = inboundEvent.getName();
360 String data = inboundEvent.readData();
361 logger.trace("Received event name {} date {}", name, data);
363 lastEventTimestamp = System.currentTimeMillis();
365 logger.debug("Connected to streaming events");
367 listeners.forEach(listener -> listener.onConnected());
370 if (!"message".equals(name)) {
371 logger.debug("Received unhandled event with name '{}' and data '{}'", name, data);
376 RemoteopenhabEvent event = jsonParser.fromJson(data, RemoteopenhabEvent.class);
379 RemoteopenhabEventPayload payload;
380 RemoteopenhabItem item;
381 RemoteopenhabThing thing;
382 switch (event.type) {
383 case "ItemStateEvent":
384 itemName = extractItemNameFromTopic(event.topic, event.type, "state");
385 payload = jsonParser.fromJson(event.payload, RemoteopenhabEventPayload.class);
386 itemsListeners.forEach(
387 listener -> listener.onItemStateEvent(itemName, payload.type, payload.value, false));
389 case "ItemStateChangedEvent":
390 itemName = extractItemNameFromTopic(event.topic, event.type, "statechanged");
391 payload = jsonParser.fromJson(event.payload, RemoteopenhabEventPayload.class);
392 itemsListeners.forEach(
393 listener -> listener.onItemStateEvent(itemName, payload.type, payload.value, true));
395 case "GroupItemStateChangedEvent":
396 itemName = extractItemNameFromTopic(event.topic, event.type, "statechanged");
397 payload = jsonParser.fromJson(event.payload, RemoteopenhabEventPayload.class);
398 itemsListeners.forEach(
399 listener -> listener.onItemStateEvent(itemName, payload.type, payload.value, false));
401 case "ItemAddedEvent":
402 itemName = extractItemNameFromTopic(event.topic, event.type, "added");
403 item = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabItem.class));
404 itemsListeners.forEach(listener -> listener.onItemAdded(item));
406 case "ItemRemovedEvent":
407 itemName = extractItemNameFromTopic(event.topic, event.type, "removed");
408 item = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabItem.class));
409 itemsListeners.forEach(listener -> listener.onItemRemoved(item));
411 case "ItemUpdatedEvent":
412 itemName = extractItemNameFromTopic(event.topic, event.type, "updated");
413 RemoteopenhabItem[] updItem = jsonParser.fromJson(event.payload, RemoteopenhabItem[].class);
414 if (updItem.length == 2) {
415 itemsListeners.forEach(listener -> listener.onItemUpdated(updItem[0], updItem[1]));
417 logger.debug("Invalid payload for event type {} for topic {}", event.type, event.topic);
420 case "ThingStatusInfoChangedEvent":
421 thingUID = extractThingUIDFromTopic(event.topic, event.type, "statuschanged");
422 RemoteopenhabStatusInfo[] updStatus = jsonParser.fromJson(event.payload,
423 RemoteopenhabStatusInfo[].class);
424 if (updStatus.length == 2) {
425 thingsListeners.forEach(listener -> listener.onThingStatusUpdated(thingUID, updStatus[0]));
427 logger.debug("Invalid payload for event type {} for topic {}", event.type, event.topic);
430 case "ThingAddedEvent":
431 thingUID = extractThingUIDFromTopic(event.topic, event.type, "added");
432 thing = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabThing.class));
433 thingsListeners.forEach(listener -> listener.onThingAdded(thing));
435 case "ThingRemovedEvent":
436 thingUID = extractThingUIDFromTopic(event.topic, event.type, "removed");
437 thing = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabThing.class));
438 thingsListeners.forEach(listener -> listener.onThingRemoved(thing));
440 case "ChannelTriggeredEvent":
441 RemoteopenhabChannelTriggerEvent triggerEvent = jsonParser.fromJson(event.payload,
442 RemoteopenhabChannelTriggerEvent.class);
444 .forEach(listener -> listener.onChannelTriggered(triggerEvent.channel, triggerEvent.event));
446 case "ItemStatePredictedEvent":
447 case "ItemCommandEvent":
448 case "ThingStatusInfoEvent":
449 case "ThingUpdatedEvent":
450 logger.trace("Ignored event type {} for topic {}", event.type, event.topic);
453 logger.debug("Unexpected event type {} for topic {}", event.type, event.topic);
456 } catch (RemoteopenhabException | JsonSyntaxException e) {
457 logger.debug("An exception occurred while processing the inbound '{}' event containg data: {}", name, data,
462 private void onComplete() {
463 logger.debug("Disconnected from streaming events");
465 listeners.forEach(listener -> listener.onDisconnected());
468 private void onError(Throwable error) {
469 logger.debug("Error occurred while receiving events", error);
470 listeners.forEach(listener -> listener.onError("Error occurred while receiving events"));
473 private String extractItemNameFromTopic(String topic, String eventType, String finalPart)
474 throws RemoteopenhabException {
475 String[] parts = topic.split("/");
476 int expectedNbParts = "GroupItemStateChangedEvent".equals(eventType) ? 5 : 4;
477 if (parts.length != expectedNbParts || !getTopicNamespace().equals(parts[0]) || !"items".equals(parts[1])
478 || !finalPart.equals(parts[parts.length - 1])) {
479 throw new RemoteopenhabException("Invalid event topic " + topic + " for event type " + eventType);
484 private String extractThingUIDFromTopic(String topic, String eventType, String finalPart)
485 throws RemoteopenhabException {
486 String[] parts = topic.split("/");
487 int expectedNbParts = 4;
488 if (parts.length != expectedNbParts || !getTopicNamespace().equals(parts[0]) || !"things".equals(parts[1])
489 || !finalPart.equals(parts[parts.length - 1])) {
490 throw new RemoteopenhabException("Invalid event topic " + topic + " for event type " + eventType);
495 public String executeGetUrl(String url, String acceptHeader, boolean provideAccessToken, boolean asyncReading)
496 throws RemoteopenhabException {
497 return executeUrl(HttpMethod.GET, url, acceptHeader, null, null, provideAccessToken, asyncReading, true);
500 public String executeUrl(HttpMethod httpMethod, String url, String acceptHeader, @Nullable String content,
501 @Nullable String contentType, boolean provideAccessToken, boolean asyncReading, boolean retryIfEOF)
502 throws RemoteopenhabException {
503 final Request request = httpClient.newRequest(url).method(httpMethod)
504 .timeout(REQUEST_TIMEOUT, TimeUnit.MILLISECONDS).followRedirects(false)
505 .header(HttpHeaders.ACCEPT, acceptHeader);
507 if (url.startsWith("https:") || authenticateAnyway) {
508 boolean useAlternativeHeader = false;
509 if (!credentialToken.isEmpty()) {
510 request.header(HttpHeaders.AUTHORIZATION, "Basic " + credentialToken);
511 useAlternativeHeader = true;
513 if (provideAccessToken && !accessToken.isEmpty()) {
514 if (useAlternativeHeader) {
515 request.header("X-OPENHAB-TOKEN", accessToken);
517 request.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
522 if (content != null && (HttpMethod.POST.equals(httpMethod) || HttpMethod.PUT.equals(httpMethod))
523 && contentType != null) {
524 request.content(new StringContentProvider(content), contentType);
527 logger.debug("Request {} {}", request.getMethod(), request.getURI());
531 InputStreamResponseListener listener = new InputStreamResponseListener();
532 request.send(listener);
533 Response response = listener.get(5, TimeUnit.SECONDS);
534 int statusCode = response.getStatus();
535 if (statusCode != HttpStatus.OK_200) {
536 response.abort(new Exception(response.getReason()));
537 String statusLine = statusCode + " " + response.getReason();
538 throw new RemoteopenhabException("HTTP call failed: " + statusLine);
540 ByteArrayOutputStream responseContent = new ByteArrayOutputStream();
541 try (InputStream input = listener.getInputStream()) {
542 input.transferTo(responseContent);
544 return new String(responseContent.toByteArray(), StandardCharsets.UTF_8.name());
546 ContentResponse response = request.send();
547 int statusCode = response.getStatus();
548 if (statusCode == HttpStatus.MOVED_PERMANENTLY_301 || statusCode == HttpStatus.FOUND_302) {
549 String locationHeader = response.getHeaders().get(HttpHeaders.LOCATION);
550 if (locationHeader != null && !locationHeader.isBlank()) {
551 logger.debug("The remopte server redirected the request to this URL: {}", locationHeader);
552 return executeUrl(httpMethod, locationHeader, acceptHeader, content, contentType,
553 provideAccessToken, asyncReading, retryIfEOF);
555 String statusLine = statusCode + " " + response.getReason();
556 throw new RemoteopenhabException("HTTP call failed: " + statusLine);
558 } else if (statusCode >= HttpStatus.BAD_REQUEST_400) {
559 String statusLine = statusCode + " " + response.getReason();
560 throw new RemoteopenhabException("HTTP call failed: " + statusLine);
562 String encoding = response.getEncoding() != null ? response.getEncoding().replaceAll("\"", "").trim()
563 : StandardCharsets.UTF_8.name();
564 return new String(response.getContent(), encoding);
566 } catch (RemoteopenhabException e) {
568 } catch (ExecutionException e) {
569 // After a long network outage, the first HTTP request will fail with an EOFException exception.
570 // We retry the request a second time in this case.
571 Throwable cause = e.getCause();
572 if (retryIfEOF && cause instanceof EOFException) {
573 logger.debug("EOFException - retry the request");
574 return executeUrl(httpMethod, url, acceptHeader, content, contentType, provideAccessToken, asyncReading,
577 throw new RemoteopenhabException(e);
579 } catch (Exception e) {
580 throw new RemoteopenhabException(e);