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.HashMap;
21 import java.util.List;
23 import java.util.Objects;
24 import java.util.concurrent.CopyOnWriteArrayList;
25 import java.util.concurrent.ExecutionException;
26 import java.util.concurrent.TimeUnit;
28 import javax.net.ssl.HostnameVerifier;
29 import javax.net.ssl.SSLSession;
30 import javax.ws.rs.client.Client;
31 import javax.ws.rs.client.ClientBuilder;
32 import javax.ws.rs.core.HttpHeaders;
33 import javax.ws.rs.sse.InboundSseEvent;
34 import javax.ws.rs.sse.SseEventSource;
36 import org.eclipse.jdt.annotation.NonNullByDefault;
37 import org.eclipse.jdt.annotation.Nullable;
38 import org.eclipse.jetty.client.HttpClient;
39 import org.eclipse.jetty.client.api.ContentResponse;
40 import org.eclipse.jetty.client.api.Request;
41 import org.eclipse.jetty.client.api.Response;
42 import org.eclipse.jetty.client.util.InputStreamResponseListener;
43 import org.eclipse.jetty.client.util.StringContentProvider;
44 import org.eclipse.jetty.http.HttpMethod;
45 import org.eclipse.jetty.http.HttpStatus;
46 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabChannelTriggerEvent;
47 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabEvent;
48 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabEventPayload;
49 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabItem;
50 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabRestApi;
51 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabStatusInfo;
52 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabThing;
53 import org.openhab.binding.remoteopenhab.internal.exceptions.RemoteopenhabException;
54 import org.openhab.binding.remoteopenhab.internal.listener.RemoteopenhabItemsDataListener;
55 import org.openhab.binding.remoteopenhab.internal.listener.RemoteopenhabStreamingDataListener;
56 import org.openhab.binding.remoteopenhab.internal.listener.RemoteopenhabThingsDataListener;
57 import org.openhab.core.types.Command;
58 import org.osgi.service.jaxrs.client.SseEventSourceFactory;
59 import org.slf4j.Logger;
60 import org.slf4j.LoggerFactory;
62 import com.google.gson.Gson;
63 import com.google.gson.JsonSyntaxException;
66 * A client to use the openHAB REST API and to receive/parse events received from the openHAB REST API Server-Sent
69 * @author Laurent Garnier - Initial contribution
72 public class RemoteopenhabRestClient {
74 private static final int REQUEST_TIMEOUT = (int) TimeUnit.SECONDS.toMillis(30);
76 private final Logger logger = LoggerFactory.getLogger(RemoteopenhabRestClient.class);
78 private final ClientBuilder clientBuilder;
79 private final SseEventSourceFactory eventSourceFactory;
80 private final Gson jsonParser;
82 private final Object startStopLock = new Object();
83 private final List<RemoteopenhabStreamingDataListener> listeners = new CopyOnWriteArrayList<>();
84 private final List<RemoteopenhabItemsDataListener> itemsListeners = new CopyOnWriteArrayList<>();
85 private final List<RemoteopenhabThingsDataListener> thingsListeners = new CopyOnWriteArrayList<>();
87 private HttpClient httpClient;
88 private @Nullable String restUrl;
89 private @Nullable String restApiVersion;
90 private Map<String, @Nullable String> apiEndPointsUrls = new HashMap<>();
91 private @Nullable String topicNamespace;
92 private String accessToken;
93 private boolean trustedCertificate;
94 private boolean connected;
95 private boolean completed;
97 private @Nullable SseEventSource eventSource;
98 private long lastEventTimestamp;
100 public RemoteopenhabRestClient(final HttpClient httpClient, final ClientBuilder clientBuilder,
101 final SseEventSourceFactory eventSourceFactory, final Gson jsonParser) {
102 this.httpClient = httpClient;
103 this.clientBuilder = clientBuilder;
104 this.eventSourceFactory = eventSourceFactory;
105 this.jsonParser = jsonParser;
106 this.accessToken = "";
109 public void setHttpClient(HttpClient httpClient) {
110 this.httpClient = httpClient;
113 public String getRestUrl() throws RemoteopenhabException {
114 String url = restUrl;
116 throw new RemoteopenhabException("REST client not correctly setup");
121 public void setRestUrl(String restUrl) {
122 this.restUrl = restUrl;
125 public void setAccessToken(String accessToken) {
126 this.accessToken = accessToken;
129 public void setTrustedCertificate(boolean trustedCertificate) {
130 this.trustedCertificate = trustedCertificate;
133 public void tryApi() throws RemoteopenhabException {
135 String jsonResponse = executeGetUrl(getRestUrl(), "application/json", false);
136 if (jsonResponse.isEmpty()) {
137 throw new RemoteopenhabException("JSON response is empty");
139 RemoteopenhabRestApi restApi = jsonParser.fromJson(jsonResponse, RemoteopenhabRestApi.class);
140 restApiVersion = restApi.version;
141 logger.debug("REST API version = {}", restApiVersion);
142 apiEndPointsUrls.clear();
143 for (int i = 0; i < restApi.links.length; i++) {
144 apiEndPointsUrls.put(restApi.links[i].type, restApi.links[i].url);
146 logger.debug("REST API items = {}", apiEndPointsUrls.get("items"));
147 logger.debug("REST API things = {}", apiEndPointsUrls.get("things"));
148 logger.debug("REST API events = {}", apiEndPointsUrls.get("events"));
149 topicNamespace = restApi.runtimeInfo != null ? "openhab" : "smarthome";
150 logger.debug("topic namespace = {}", topicNamespace);
151 } catch (RemoteopenhabException | JsonSyntaxException e) {
152 throw new RemoteopenhabException("Failed to execute the root REST API: " + e.getMessage(), e);
156 public List<RemoteopenhabItem> getRemoteItems(@Nullable String fields) throws RemoteopenhabException {
158 String url = String.format("%s?recursive=false", getRestApiUrl("items"));
159 if (fields != null) {
160 url += "&fields=" + fields;
162 boolean asyncReading = fields == null || Arrays.asList(fields.split(",")).contains("state");
163 String jsonResponse = executeGetUrl(url, "application/json", asyncReading);
164 if (jsonResponse.isEmpty()) {
165 throw new RemoteopenhabException("JSON response is empty");
167 return Arrays.asList(jsonParser.fromJson(jsonResponse, RemoteopenhabItem[].class));
168 } catch (RemoteopenhabException | JsonSyntaxException e) {
169 throw new RemoteopenhabException(
170 "Failed to get the list of remote items using the items REST API: " + e.getMessage(), e);
174 public String getRemoteItemState(String itemName) throws RemoteopenhabException {
176 String url = String.format("%s/%s/state", getRestApiUrl("items"), itemName);
177 return executeGetUrl(url, "text/plain", true);
178 } catch (RemoteopenhabException e) {
179 throw new RemoteopenhabException("Failed to get the state of remote item " + itemName
180 + " using the items REST API: " + e.getMessage(), e);
184 public void sendCommandToRemoteItem(String itemName, Command command) throws RemoteopenhabException {
186 String url = String.format("%s/%s", getRestApiUrl("items"), itemName);
187 executeUrl(HttpMethod.POST, url, "application/json", command.toFullString(), "text/plain", false, true);
188 } catch (RemoteopenhabException e) {
189 throw new RemoteopenhabException("Failed to send command to the remote item " + itemName
190 + " using the items REST API: " + e.getMessage(), e);
194 public List<RemoteopenhabThing> getRemoteThings() throws RemoteopenhabException {
196 String jsonResponse = executeGetUrl(getRestApiUrl("things"), "application/json", false);
197 if (jsonResponse.isEmpty()) {
198 throw new RemoteopenhabException("JSON response is empty");
200 return Arrays.asList(jsonParser.fromJson(jsonResponse, RemoteopenhabThing[].class));
201 } catch (RemoteopenhabException | JsonSyntaxException e) {
202 throw new RemoteopenhabException(
203 "Failed to get the list of remote things using the things REST API: " + e.getMessage(), e);
207 public RemoteopenhabThing getRemoteThing(String uid) throws RemoteopenhabException {
209 String url = String.format("%s/%s", getRestApiUrl("things"), uid);
210 String jsonResponse = executeGetUrl(url, "application/json", false);
211 if (jsonResponse.isEmpty()) {
212 throw new RemoteopenhabException("JSON response is empty");
214 return Objects.requireNonNull(jsonParser.fromJson(jsonResponse, RemoteopenhabThing.class));
215 } catch (RemoteopenhabException | JsonSyntaxException e) {
216 throw new RemoteopenhabException(
217 "Failed to get the remote thing " + uid + " using the things REST API: " + e.getMessage(), e);
221 public @Nullable String getRestApiVersion() {
222 return restApiVersion;
225 private String getRestApiUrl(String endPoint) throws RemoteopenhabException {
226 String url = apiEndPointsUrls.get(endPoint);
227 return url != null ? url : getRestUrl() + "/" + endPoint;
230 public String getTopicNamespace() {
231 String namespace = topicNamespace;
232 return namespace != null ? namespace : "openhab";
235 public void start() {
236 synchronized (startStopLock) {
237 logger.debug("Opening EventSource");
239 logger.debug("EventSource started");
243 public void stop(boolean waitingForCompletion) {
244 synchronized (startStopLock) {
245 logger.debug("Closing EventSource");
246 closeEventSource(waitingForCompletion);
247 logger.debug("EventSource stopped");
248 lastEventTimestamp = 0;
252 private SseEventSource createEventSource(String restSseUrl) {
254 // Avoid a timeout exception after 1 minute by setting the read timeout to 0 (infinite)
255 if (trustedCertificate) {
256 client = clientBuilder.sslContext(httpClient.getSslContextFactory().getSslContext())
257 .hostnameVerifier(new HostnameVerifier() {
259 public boolean verify(@Nullable String hostname, @Nullable SSLSession session) {
262 }).readTimeout(0, TimeUnit.SECONDS).register(new RemoteopenhabStreamingRequestFilter(accessToken))
265 client = clientBuilder.readTimeout(0, TimeUnit.SECONDS)
266 .register(new RemoteopenhabStreamingRequestFilter(accessToken)).build();
268 SseEventSource eventSource = eventSourceFactory.newSource(client.target(restSseUrl));
269 eventSource.register(this::onEvent, this::onError, this::onComplete);
273 private void reopenEventSource() {
274 logger.debug("Reopening EventSource");
278 url = String.format("%s?topics=%s/items/*/*,%s/things/*/*,%s/channels/*/triggered", getRestApiUrl("events"),
279 getTopicNamespace(), getTopicNamespace(), getTopicNamespace());
280 } catch (RemoteopenhabException e) {
281 logger.debug("{}", e.getMessage());
285 closeEventSource(true);
287 logger.debug("Opening new EventSource {}", url);
288 SseEventSource localEventSource = createEventSource(url);
289 localEventSource.open();
291 eventSource = localEventSource;
294 private void closeEventSource(boolean waitingForCompletion) {
295 SseEventSource localEventSource = eventSource;
296 if (localEventSource != null) {
297 if (!localEventSource.isOpen() || completed) {
298 logger.debug("Existing EventSource is already closed");
299 } else if (localEventSource.close(waitingForCompletion ? 10 : 0, TimeUnit.SECONDS)) {
300 logger.debug("Succesfully closed existing EventSource");
302 logger.debug("Failed to close existing EventSource");
309 public boolean addStreamingDataListener(RemoteopenhabStreamingDataListener listener) {
310 return listeners.add(listener);
313 public boolean removeStreamingDataListener(RemoteopenhabStreamingDataListener listener) {
314 return listeners.remove(listener);
317 public boolean addItemsDataListener(RemoteopenhabItemsDataListener listener) {
318 return itemsListeners.add(listener);
321 public boolean removeItemsDataListener(RemoteopenhabItemsDataListener listener) {
322 return itemsListeners.remove(listener);
325 public boolean addThingsDataListener(RemoteopenhabThingsDataListener listener) {
326 return thingsListeners.add(listener);
329 public boolean removeThingsDataListener(RemoteopenhabThingsDataListener listener) {
330 return thingsListeners.remove(listener);
333 public long getLastEventTimestamp() {
334 return lastEventTimestamp;
337 private void onEvent(InboundSseEvent inboundEvent) {
338 String name = inboundEvent.getName();
339 String data = inboundEvent.readData();
340 logger.trace("Received event name {} date {}", name, data);
342 lastEventTimestamp = System.currentTimeMillis();
344 logger.debug("Connected to streaming events");
346 listeners.forEach(listener -> listener.onConnected());
349 if (!"message".equals(name)) {
350 logger.debug("Received unhandled event with name '{}' and data '{}'", name, data);
355 RemoteopenhabEvent event = jsonParser.fromJson(data, RemoteopenhabEvent.class);
358 RemoteopenhabEventPayload payload;
359 RemoteopenhabItem item;
360 RemoteopenhabThing thing;
361 switch (event.type) {
362 case "ItemStateEvent":
363 itemName = extractItemNameFromTopic(event.topic, event.type, "state");
364 payload = jsonParser.fromJson(event.payload, RemoteopenhabEventPayload.class);
365 itemsListeners.forEach(
366 listener -> listener.onItemStateEvent(itemName, payload.type, payload.value, false));
368 case "ItemStateChangedEvent":
369 itemName = extractItemNameFromTopic(event.topic, event.type, "statechanged");
370 payload = jsonParser.fromJson(event.payload, RemoteopenhabEventPayload.class);
371 itemsListeners.forEach(
372 listener -> listener.onItemStateEvent(itemName, payload.type, payload.value, true));
374 case "GroupItemStateChangedEvent":
375 itemName = extractItemNameFromTopic(event.topic, event.type, "statechanged");
376 payload = jsonParser.fromJson(event.payload, RemoteopenhabEventPayload.class);
377 itemsListeners.forEach(
378 listener -> listener.onItemStateEvent(itemName, payload.type, payload.value, false));
380 case "ItemAddedEvent":
381 itemName = extractItemNameFromTopic(event.topic, event.type, "added");
382 item = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabItem.class));
383 itemsListeners.forEach(listener -> listener.onItemAdded(item));
385 case "ItemRemovedEvent":
386 itemName = extractItemNameFromTopic(event.topic, event.type, "removed");
387 item = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabItem.class));
388 itemsListeners.forEach(listener -> listener.onItemRemoved(item));
390 case "ItemUpdatedEvent":
391 itemName = extractItemNameFromTopic(event.topic, event.type, "updated");
392 RemoteopenhabItem[] updItem = jsonParser.fromJson(event.payload, RemoteopenhabItem[].class);
393 if (updItem.length == 2) {
394 itemsListeners.forEach(listener -> listener.onItemUpdated(updItem[0], updItem[1]));
396 logger.debug("Invalid payload for event type {} for topic {}", event.type, event.topic);
399 case "ThingStatusInfoChangedEvent":
400 thingUID = extractThingUIDFromTopic(event.topic, event.type, "statuschanged");
401 RemoteopenhabStatusInfo[] updStatus = jsonParser.fromJson(event.payload,
402 RemoteopenhabStatusInfo[].class);
403 if (updStatus.length == 2) {
404 thingsListeners.forEach(listener -> listener.onThingStatusUpdated(thingUID, updStatus[0]));
406 logger.debug("Invalid payload for event type {} for topic {}", event.type, event.topic);
409 case "ThingAddedEvent":
410 thingUID = extractThingUIDFromTopic(event.topic, event.type, "added");
411 thing = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabThing.class));
412 thingsListeners.forEach(listener -> listener.onThingAdded(thing));
414 case "ThingRemovedEvent":
415 thingUID = extractThingUIDFromTopic(event.topic, event.type, "removed");
416 thing = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabThing.class));
417 thingsListeners.forEach(listener -> listener.onThingRemoved(thing));
419 case "ChannelTriggeredEvent":
420 RemoteopenhabChannelTriggerEvent triggerEvent = jsonParser.fromJson(event.payload,
421 RemoteopenhabChannelTriggerEvent.class);
423 .forEach(listener -> listener.onChannelTriggered(triggerEvent.channel, triggerEvent.event));
425 case "ItemStatePredictedEvent":
426 case "ItemCommandEvent":
427 case "ThingStatusInfoEvent":
428 case "ThingUpdatedEvent":
429 logger.trace("Ignored event type {} for topic {}", event.type, event.topic);
432 logger.debug("Unexpected event type {} for topic {}", event.type, event.topic);
435 } catch (RemoteopenhabException | JsonSyntaxException e) {
436 logger.debug("An exception occurred while processing the inbound '{}' event containg data: {}", name, data,
441 private void onComplete() {
442 logger.debug("Disconnected from streaming events");
444 listeners.forEach(listener -> listener.onDisconnected());
447 private void onError(Throwable error) {
448 logger.debug("Error occurred while receiving events", error);
449 listeners.forEach(listener -> listener.onError("Error occurred while receiving events"));
452 private String extractItemNameFromTopic(String topic, String eventType, String finalPart)
453 throws RemoteopenhabException {
454 String[] parts = topic.split("/");
455 int expectedNbParts = "GroupItemStateChangedEvent".equals(eventType) ? 5 : 4;
456 if (parts.length != expectedNbParts || !getTopicNamespace().equals(parts[0]) || !"items".equals(parts[1])
457 || !finalPart.equals(parts[parts.length - 1])) {
458 throw new RemoteopenhabException("Invalid event topic " + topic + " for event type " + eventType);
463 private String extractThingUIDFromTopic(String topic, String eventType, String finalPart)
464 throws RemoteopenhabException {
465 String[] parts = topic.split("/");
466 int expectedNbParts = 4;
467 if (parts.length != expectedNbParts || !getTopicNamespace().equals(parts[0]) || !"things".equals(parts[1])
468 || !finalPart.equals(parts[parts.length - 1])) {
469 throw new RemoteopenhabException("Invalid event topic " + topic + " for event type " + eventType);
474 public String executeGetUrl(String url, String acceptHeader, boolean asyncReading) throws RemoteopenhabException {
475 return executeUrl(HttpMethod.GET, url, acceptHeader, null, null, asyncReading, true);
478 public String executeUrl(HttpMethod httpMethod, String url, String acceptHeader, @Nullable String content,
479 @Nullable String contentType, boolean asyncReading, boolean retryIfEOF) throws RemoteopenhabException {
480 final Request request = httpClient.newRequest(url).method(httpMethod).timeout(REQUEST_TIMEOUT,
481 TimeUnit.MILLISECONDS);
483 request.header(HttpHeaders.ACCEPT, acceptHeader);
484 if (!accessToken.isEmpty()) {
485 request.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
488 if (content != null && (HttpMethod.POST.equals(httpMethod) || HttpMethod.PUT.equals(httpMethod))
489 && contentType != null) {
490 request.content(new StringContentProvider(content), contentType);
495 InputStreamResponseListener listener = new InputStreamResponseListener();
496 request.send(listener);
497 Response response = listener.get(5, TimeUnit.SECONDS);
498 int statusCode = response.getStatus();
499 if (statusCode != HttpStatus.OK_200) {
500 response.abort(new Exception(response.getReason()));
501 String statusLine = statusCode + " " + response.getReason();
502 throw new RemoteopenhabException("HTTP call failed: " + statusLine);
504 ByteArrayOutputStream responseContent = new ByteArrayOutputStream();
505 try (InputStream input = listener.getInputStream()) {
506 input.transferTo(responseContent);
508 return new String(responseContent.toByteArray(), StandardCharsets.UTF_8.name());
510 ContentResponse response = request.send();
511 int statusCode = response.getStatus();
512 if (statusCode >= HttpStatus.BAD_REQUEST_400) {
513 String statusLine = statusCode + " " + response.getReason();
514 throw new RemoteopenhabException("HTTP call failed: " + statusLine);
516 String encoding = response.getEncoding() != null ? response.getEncoding().replaceAll("\"", "").trim()
517 : StandardCharsets.UTF_8.name();
518 return new String(response.getContent(), encoding);
520 } catch (RemoteopenhabException e) {
522 } catch (ExecutionException e) {
523 // After a long network outage, the first HTTP request will fail with an EOFException exception.
524 // We retry the request a second time in this case.
525 Throwable cause = e.getCause();
526 if (retryIfEOF && cause instanceof EOFException) {
527 logger.debug("EOFException - retry the request");
528 return executeUrl(httpMethod, url, acceptHeader, content, contentType, asyncReading, false);
530 throw new RemoteopenhabException(e);
532 } catch (Exception e) {
533 throw new RemoteopenhabException(e);