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.InputStream;
17 import java.nio.charset.StandardCharsets;
18 import java.util.Arrays;
19 import java.util.HashMap;
20 import java.util.List;
22 import java.util.Objects;
23 import java.util.concurrent.CopyOnWriteArrayList;
24 import java.util.concurrent.TimeUnit;
26 import javax.net.ssl.HostnameVerifier;
27 import javax.net.ssl.SSLSession;
28 import javax.ws.rs.client.Client;
29 import javax.ws.rs.client.ClientBuilder;
30 import javax.ws.rs.core.HttpHeaders;
31 import javax.ws.rs.sse.InboundSseEvent;
32 import javax.ws.rs.sse.SseEventSource;
34 import org.eclipse.jdt.annotation.NonNullByDefault;
35 import org.eclipse.jdt.annotation.Nullable;
36 import org.eclipse.jetty.client.HttpClient;
37 import org.eclipse.jetty.client.api.ContentResponse;
38 import org.eclipse.jetty.client.api.Request;
39 import org.eclipse.jetty.client.api.Response;
40 import org.eclipse.jetty.client.util.InputStreamResponseListener;
41 import org.eclipse.jetty.client.util.StringContentProvider;
42 import org.eclipse.jetty.http.HttpMethod;
43 import org.eclipse.jetty.http.HttpStatus;
44 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabChannelTriggerEvent;
45 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabEvent;
46 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabEventPayload;
47 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabItem;
48 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabRestApi;
49 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabStatusInfo;
50 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabThing;
51 import org.openhab.binding.remoteopenhab.internal.exceptions.RemoteopenhabException;
52 import org.openhab.binding.remoteopenhab.internal.listener.RemoteopenhabItemsDataListener;
53 import org.openhab.binding.remoteopenhab.internal.listener.RemoteopenhabStreamingDataListener;
54 import org.openhab.binding.remoteopenhab.internal.listener.RemoteopenhabThingsDataListener;
55 import org.openhab.core.types.Command;
56 import org.osgi.service.jaxrs.client.SseEventSourceFactory;
57 import org.slf4j.Logger;
58 import org.slf4j.LoggerFactory;
60 import com.google.gson.Gson;
61 import com.google.gson.JsonSyntaxException;
64 * A client to use the openHAB REST API and to receive/parse events received from the openHAB REST API Server-Sent
67 * @author Laurent Garnier - Initial contribution
70 public class RemoteopenhabRestClient {
72 private static final int REQUEST_TIMEOUT = (int) TimeUnit.SECONDS.toMillis(30);
74 private final Logger logger = LoggerFactory.getLogger(RemoteopenhabRestClient.class);
76 private final ClientBuilder clientBuilder;
77 private final SseEventSourceFactory eventSourceFactory;
78 private final Gson jsonParser;
80 private final Object startStopLock = new Object();
81 private final List<RemoteopenhabStreamingDataListener> listeners = new CopyOnWriteArrayList<>();
82 private final List<RemoteopenhabItemsDataListener> itemsListeners = new CopyOnWriteArrayList<>();
83 private final List<RemoteopenhabThingsDataListener> thingsListeners = new CopyOnWriteArrayList<>();
85 private HttpClient httpClient;
86 private @Nullable String restUrl;
87 private @Nullable String restApiVersion;
88 private Map<String, @Nullable String> apiEndPointsUrls = new HashMap<>();
89 private @Nullable String topicNamespace;
90 private String accessToken;
91 private boolean trustedCertificate;
92 private boolean connected;
93 private boolean completed;
95 private @Nullable SseEventSource eventSource;
96 private long lastEventTimestamp;
98 public RemoteopenhabRestClient(final HttpClient httpClient, final ClientBuilder clientBuilder,
99 final SseEventSourceFactory eventSourceFactory, final Gson jsonParser) {
100 this.httpClient = httpClient;
101 this.clientBuilder = clientBuilder;
102 this.eventSourceFactory = eventSourceFactory;
103 this.jsonParser = jsonParser;
104 this.accessToken = "";
107 public void setHttpClient(HttpClient httpClient) {
108 this.httpClient = httpClient;
111 public String getRestUrl() throws RemoteopenhabException {
112 String url = restUrl;
114 throw new RemoteopenhabException("REST client not correctly setup");
119 public void setRestUrl(String restUrl) {
120 this.restUrl = restUrl;
123 public void setAccessToken(String accessToken) {
124 this.accessToken = accessToken;
127 public void setTrustedCertificate(boolean trustedCertificate) {
128 this.trustedCertificate = trustedCertificate;
131 public void tryApi() throws RemoteopenhabException {
133 String jsonResponse = executeGetUrl(getRestUrl(), "application/json", false);
134 if (jsonResponse.isEmpty()) {
135 throw new RemoteopenhabException("JSON response is empty");
137 RemoteopenhabRestApi restApi = jsonParser.fromJson(jsonResponse, RemoteopenhabRestApi.class);
138 restApiVersion = restApi.version;
139 logger.debug("REST API version = {}", restApiVersion);
140 apiEndPointsUrls.clear();
141 for (int i = 0; i < restApi.links.length; i++) {
142 apiEndPointsUrls.put(restApi.links[i].type, restApi.links[i].url);
144 logger.debug("REST API items = {}", apiEndPointsUrls.get("items"));
145 logger.debug("REST API things = {}", apiEndPointsUrls.get("things"));
146 logger.debug("REST API events = {}", apiEndPointsUrls.get("events"));
147 topicNamespace = restApi.runtimeInfo != null ? "openhab" : "smarthome";
148 logger.debug("topic namespace = {}", topicNamespace);
149 } catch (RemoteopenhabException | JsonSyntaxException e) {
150 throw new RemoteopenhabException("Failed to execute the root REST API: " + e.getMessage(), e);
154 public List<RemoteopenhabItem> getRemoteItems(@Nullable String fields) throws RemoteopenhabException {
156 String url = String.format("%s?recursive=false", getRestApiUrl("items"));
157 if (fields != null) {
158 url += "&fields=" + fields;
160 boolean asyncReading = fields == null || Arrays.asList(fields.split(",")).contains("state");
161 String jsonResponse = executeGetUrl(url, "application/json", asyncReading);
162 if (jsonResponse.isEmpty()) {
163 throw new RemoteopenhabException("JSON response is empty");
165 return Arrays.asList(jsonParser.fromJson(jsonResponse, RemoteopenhabItem[].class));
166 } catch (RemoteopenhabException | JsonSyntaxException e) {
167 throw new RemoteopenhabException(
168 "Failed to get the list of remote items using the items REST API: " + e.getMessage(), e);
172 public String getRemoteItemState(String itemName) throws RemoteopenhabException {
174 String url = String.format("%s/%s/state", getRestApiUrl("items"), itemName);
175 return executeGetUrl(url, "text/plain", true);
176 } catch (RemoteopenhabException e) {
177 throw new RemoteopenhabException("Failed to get the state of remote item " + itemName
178 + " using the items REST API: " + e.getMessage(), e);
182 public void sendCommandToRemoteItem(String itemName, Command command) throws RemoteopenhabException {
184 String url = String.format("%s/%s", getRestApiUrl("items"), itemName);
185 executeUrl(HttpMethod.POST, url, "application/json", command.toFullString(), "text/plain", false);
186 } catch (RemoteopenhabException e) {
187 throw new RemoteopenhabException("Failed to send command to the remote item " + itemName
188 + " using the items REST API: " + e.getMessage(), e);
192 public List<RemoteopenhabThing> getRemoteThings() throws RemoteopenhabException {
194 String jsonResponse = executeGetUrl(getRestApiUrl("things"), "application/json", false);
195 if (jsonResponse.isEmpty()) {
196 throw new RemoteopenhabException("JSON response is empty");
198 return Arrays.asList(jsonParser.fromJson(jsonResponse, RemoteopenhabThing[].class));
199 } catch (RemoteopenhabException | JsonSyntaxException e) {
200 throw new RemoteopenhabException(
201 "Failed to get the list of remote things using the things REST API: " + e.getMessage(), e);
205 public RemoteopenhabThing getRemoteThing(String uid) throws RemoteopenhabException {
207 String url = String.format("%s/%s", getRestApiUrl("things"), uid);
208 String jsonResponse = executeGetUrl(url, "application/json", false);
209 if (jsonResponse.isEmpty()) {
210 throw new RemoteopenhabException("JSON response is empty");
212 return Objects.requireNonNull(jsonParser.fromJson(jsonResponse, RemoteopenhabThing.class));
213 } catch (RemoteopenhabException | JsonSyntaxException e) {
214 throw new RemoteopenhabException(
215 "Failed to get the remote thing " + uid + " using the things REST API: " + e.getMessage(), e);
219 public @Nullable String getRestApiVersion() {
220 return restApiVersion;
223 private String getRestApiUrl(String endPoint) throws RemoteopenhabException {
224 String url = apiEndPointsUrls.get(endPoint);
225 return url != null ? url : getRestUrl() + "/" + endPoint;
228 public String getTopicNamespace() {
229 String namespace = topicNamespace;
230 return namespace != null ? namespace : "openhab";
233 public void start() {
234 synchronized (startStopLock) {
235 logger.debug("Opening EventSource");
237 logger.debug("EventSource started");
241 public void stop(boolean waitingForCompletion) {
242 synchronized (startStopLock) {
243 logger.debug("Closing EventSource");
244 closeEventSource(waitingForCompletion);
245 logger.debug("EventSource stopped");
246 lastEventTimestamp = 0;
250 private SseEventSource createEventSource(String restSseUrl) {
252 // Avoid a timeout exception after 1 minute by setting the read timeout to 0 (infinite)
253 if (trustedCertificate) {
254 client = clientBuilder.sslContext(httpClient.getSslContextFactory().getSslContext())
255 .hostnameVerifier(new HostnameVerifier() {
257 public boolean verify(@Nullable String hostname, @Nullable SSLSession session) {
260 }).readTimeout(0, TimeUnit.SECONDS).register(new RemoteopenhabStreamingRequestFilter(accessToken))
263 client = clientBuilder.readTimeout(0, TimeUnit.SECONDS)
264 .register(new RemoteopenhabStreamingRequestFilter(accessToken)).build();
266 SseEventSource eventSource = eventSourceFactory.newSource(client.target(restSseUrl));
267 eventSource.register(this::onEvent, this::onError, this::onComplete);
271 private void reopenEventSource() {
272 logger.debug("Reopening EventSource");
276 url = String.format("%s?topics=%s/items/*/*,%s/things/*/*,%s/channels/*/triggered", getRestApiUrl("events"),
277 getTopicNamespace(), getTopicNamespace(), getTopicNamespace());
278 } catch (RemoteopenhabException e) {
279 logger.debug("{}", e.getMessage());
283 closeEventSource(true);
285 logger.debug("Opening new EventSource {}", url);
286 SseEventSource localEventSource = createEventSource(url);
287 localEventSource.open();
289 eventSource = localEventSource;
292 private void closeEventSource(boolean waitingForCompletion) {
293 SseEventSource localEventSource = eventSource;
294 if (localEventSource != null) {
295 if (!localEventSource.isOpen() || completed) {
296 logger.debug("Existing EventSource is already closed");
297 } else if (localEventSource.close(waitingForCompletion ? 10 : 0, TimeUnit.SECONDS)) {
298 logger.debug("Succesfully closed existing EventSource");
300 logger.debug("Failed to close existing EventSource");
307 public boolean addStreamingDataListener(RemoteopenhabStreamingDataListener listener) {
308 return listeners.add(listener);
311 public boolean removeStreamingDataListener(RemoteopenhabStreamingDataListener listener) {
312 return listeners.remove(listener);
315 public boolean addItemsDataListener(RemoteopenhabItemsDataListener listener) {
316 return itemsListeners.add(listener);
319 public boolean removeItemsDataListener(RemoteopenhabItemsDataListener listener) {
320 return itemsListeners.remove(listener);
323 public boolean addThingsDataListener(RemoteopenhabThingsDataListener listener) {
324 return thingsListeners.add(listener);
327 public boolean removeThingsDataListener(RemoteopenhabThingsDataListener listener) {
328 return thingsListeners.remove(listener);
331 public long getLastEventTimestamp() {
332 return lastEventTimestamp;
335 private void onEvent(InboundSseEvent inboundEvent) {
336 String name = inboundEvent.getName();
337 String data = inboundEvent.readData();
338 logger.trace("Received event name {} date {}", name, data);
340 lastEventTimestamp = System.currentTimeMillis();
342 logger.debug("Connected to streaming events");
344 listeners.forEach(listener -> listener.onConnected());
347 if (!"message".equals(name)) {
348 logger.debug("Received unhandled event with name '{}' and data '{}'", name, data);
353 RemoteopenhabEvent event = jsonParser.fromJson(data, RemoteopenhabEvent.class);
356 RemoteopenhabEventPayload payload;
357 RemoteopenhabItem item;
358 RemoteopenhabThing thing;
359 switch (event.type) {
360 case "ItemStateEvent":
361 itemName = extractItemNameFromTopic(event.topic, event.type, "state");
362 payload = jsonParser.fromJson(event.payload, RemoteopenhabEventPayload.class);
363 itemsListeners.forEach(
364 listener -> listener.onItemStateEvent(itemName, payload.type, payload.value, false));
366 case "ItemStateChangedEvent":
367 itemName = extractItemNameFromTopic(event.topic, event.type, "statechanged");
368 payload = jsonParser.fromJson(event.payload, RemoteopenhabEventPayload.class);
369 itemsListeners.forEach(
370 listener -> listener.onItemStateEvent(itemName, payload.type, payload.value, true));
372 case "GroupItemStateChangedEvent":
373 itemName = extractItemNameFromTopic(event.topic, event.type, "statechanged");
374 payload = jsonParser.fromJson(event.payload, RemoteopenhabEventPayload.class);
375 itemsListeners.forEach(
376 listener -> listener.onItemStateEvent(itemName, payload.type, payload.value, false));
378 case "ItemAddedEvent":
379 itemName = extractItemNameFromTopic(event.topic, event.type, "added");
380 item = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabItem.class));
381 itemsListeners.forEach(listener -> listener.onItemAdded(item));
383 case "ItemRemovedEvent":
384 itemName = extractItemNameFromTopic(event.topic, event.type, "removed");
385 item = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabItem.class));
386 itemsListeners.forEach(listener -> listener.onItemRemoved(item));
388 case "ItemUpdatedEvent":
389 itemName = extractItemNameFromTopic(event.topic, event.type, "updated");
390 RemoteopenhabItem[] updItem = jsonParser.fromJson(event.payload, RemoteopenhabItem[].class);
391 if (updItem.length == 2) {
392 itemsListeners.forEach(listener -> listener.onItemUpdated(updItem[0], updItem[1]));
394 logger.debug("Invalid payload for event type {} for topic {}", event.type, event.topic);
397 case "ThingStatusInfoChangedEvent":
398 thingUID = extractThingUIDFromTopic(event.topic, event.type, "statuschanged");
399 RemoteopenhabStatusInfo[] updStatus = jsonParser.fromJson(event.payload,
400 RemoteopenhabStatusInfo[].class);
401 if (updStatus.length == 2) {
402 thingsListeners.forEach(listener -> listener.onThingStatusUpdated(thingUID, updStatus[0]));
404 logger.debug("Invalid payload for event type {} for topic {}", event.type, event.topic);
407 case "ThingAddedEvent":
408 thingUID = extractThingUIDFromTopic(event.topic, event.type, "added");
409 thing = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabThing.class));
410 thingsListeners.forEach(listener -> listener.onThingAdded(thing));
412 case "ThingRemovedEvent":
413 thingUID = extractThingUIDFromTopic(event.topic, event.type, "removed");
414 thing = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabThing.class));
415 thingsListeners.forEach(listener -> listener.onThingRemoved(thing));
417 case "ChannelTriggeredEvent":
418 RemoteopenhabChannelTriggerEvent triggerEvent = jsonParser.fromJson(event.payload,
419 RemoteopenhabChannelTriggerEvent.class);
421 .forEach(listener -> listener.onChannelTriggered(triggerEvent.channel, triggerEvent.event));
423 case "ItemStatePredictedEvent":
424 case "ItemCommandEvent":
425 case "ThingStatusInfoEvent":
426 case "ThingUpdatedEvent":
427 logger.trace("Ignored event type {} for topic {}", event.type, event.topic);
430 logger.debug("Unexpected event type {} for topic {}", event.type, event.topic);
433 } catch (RemoteopenhabException | JsonSyntaxException e) {
434 logger.debug("An exception occurred while processing the inbound '{}' event containg data: {}", name, data,
439 private void onComplete() {
440 logger.debug("Disconnected from streaming events");
442 listeners.forEach(listener -> listener.onDisconnected());
445 private void onError(Throwable error) {
446 logger.debug("Error occurred while receiving events", error);
447 listeners.forEach(listener -> listener.onError("Error occurred while receiving events"));
450 private String extractItemNameFromTopic(String topic, String eventType, String finalPart)
451 throws RemoteopenhabException {
452 String[] parts = topic.split("/");
453 int expectedNbParts = "GroupItemStateChangedEvent".equals(eventType) ? 5 : 4;
454 if (parts.length != expectedNbParts || !getTopicNamespace().equals(parts[0]) || !"items".equals(parts[1])
455 || !finalPart.equals(parts[parts.length - 1])) {
456 throw new RemoteopenhabException("Invalid event topic " + topic + " for event type " + eventType);
461 private String extractThingUIDFromTopic(String topic, String eventType, String finalPart)
462 throws RemoteopenhabException {
463 String[] parts = topic.split("/");
464 int expectedNbParts = 4;
465 if (parts.length != expectedNbParts || !getTopicNamespace().equals(parts[0]) || !"things".equals(parts[1])
466 || !finalPart.equals(parts[parts.length - 1])) {
467 throw new RemoteopenhabException("Invalid event topic " + topic + " for event type " + eventType);
472 public String executeGetUrl(String url, String acceptHeader, boolean asyncReading) throws RemoteopenhabException {
473 return executeUrl(HttpMethod.GET, url, acceptHeader, null, null, asyncReading);
476 public String executeUrl(HttpMethod httpMethod, String url, String acceptHeader, @Nullable String content,
477 @Nullable String contentType, boolean asyncReading) throws RemoteopenhabException {
478 final Request request = httpClient.newRequest(url).method(httpMethod).timeout(REQUEST_TIMEOUT,
479 TimeUnit.MILLISECONDS);
481 request.header(HttpHeaders.ACCEPT, acceptHeader);
482 if (!accessToken.isEmpty()) {
483 request.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
486 if (content != null && (HttpMethod.POST.equals(httpMethod) || HttpMethod.PUT.equals(httpMethod))
487 && contentType != null) {
488 request.content(new StringContentProvider(content), contentType);
493 InputStreamResponseListener listener = new InputStreamResponseListener();
494 request.send(listener);
495 Response response = listener.get(5, TimeUnit.SECONDS);
496 int statusCode = response.getStatus();
497 if (statusCode != HttpStatus.OK_200) {
498 response.abort(new Exception(response.getReason()));
499 String statusLine = statusCode + " " + response.getReason();
500 throw new RemoteopenhabException("HTTP call failed: " + statusLine);
502 ByteArrayOutputStream responseContent = new ByteArrayOutputStream();
503 try (InputStream input = listener.getInputStream()) {
504 input.transferTo(responseContent);
506 return new String(responseContent.toByteArray(), StandardCharsets.UTF_8.name());
508 ContentResponse response = request.send();
509 int statusCode = response.getStatus();
510 if (statusCode >= HttpStatus.BAD_REQUEST_400) {
511 String statusLine = statusCode + " " + response.getReason();
512 throw new RemoteopenhabException("HTTP call failed: " + statusLine);
514 String encoding = response.getEncoding() != null ? response.getEncoding().replaceAll("\"", "").trim()
515 : StandardCharsets.UTF_8.name();
516 return new String(response.getContent(), encoding);
518 } catch (RemoteopenhabException e) {
520 } catch (Exception e) {
521 throw new RemoteopenhabException(e);