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.ByteArrayInputStream;
16 import java.io.ByteArrayOutputStream;
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.HashMap;
22 import java.util.List;
24 import java.util.Objects;
25 import java.util.concurrent.CopyOnWriteArrayList;
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.InputStreamContentProvider;
43 import org.eclipse.jetty.client.util.InputStreamResponseListener;
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;
96 private @Nullable SseEventSource eventSource;
97 private long lastEventTimestamp;
99 public RemoteopenhabRestClient(final HttpClient httpClient, final ClientBuilder clientBuilder,
100 final SseEventSourceFactory eventSourceFactory, final Gson jsonParser) {
101 this.httpClient = httpClient;
102 this.clientBuilder = clientBuilder;
103 this.eventSourceFactory = eventSourceFactory;
104 this.jsonParser = jsonParser;
105 this.accessToken = "";
108 public void setHttpClient(HttpClient httpClient) {
109 this.httpClient = httpClient;
112 public String getRestUrl() throws RemoteopenhabException {
113 String url = restUrl;
115 throw new RemoteopenhabException("REST client not correctly setup");
120 public void setRestUrl(String restUrl) {
121 this.restUrl = restUrl;
124 public void setAccessToken(String accessToken) {
125 this.accessToken = accessToken;
128 public void setTrustedCertificate(boolean trustedCertificate) {
129 this.trustedCertificate = trustedCertificate;
132 public void tryApi() throws RemoteopenhabException {
134 String jsonResponse = executeGetUrl(getRestUrl(), "application/json", false);
135 if (jsonResponse.isEmpty()) {
136 throw new RemoteopenhabException("JSON response is empty");
138 RemoteopenhabRestApi restApi = jsonParser.fromJson(jsonResponse, RemoteopenhabRestApi.class);
139 restApiVersion = restApi.version;
140 logger.debug("REST API version = {}", restApiVersion);
141 apiEndPointsUrls.clear();
142 for (int i = 0; i < restApi.links.length; i++) {
143 apiEndPointsUrls.put(restApi.links[i].type, restApi.links[i].url);
145 logger.debug("REST API items = {}", apiEndPointsUrls.get("items"));
146 logger.debug("REST API things = {}", apiEndPointsUrls.get("things"));
147 logger.debug("REST API events = {}", apiEndPointsUrls.get("events"));
148 topicNamespace = restApi.runtimeInfo != null ? "openhab" : "smarthome";
149 logger.debug("topic namespace = {}", topicNamespace);
150 } catch (RemoteopenhabException | JsonSyntaxException e) {
151 throw new RemoteopenhabException("Failed to execute the root REST API: " + e.getMessage(), e);
155 public List<RemoteopenhabItem> getRemoteItems(@Nullable String fields) throws RemoteopenhabException {
157 String url = String.format("%s?recursive=false", getRestApiUrl("items"));
158 if (fields != null) {
159 url += "&fields=" + fields;
161 boolean asyncReading = fields == null || Arrays.asList(fields.split(",")).contains("state");
162 String jsonResponse = executeGetUrl(url, "application/json", asyncReading);
163 if (jsonResponse.isEmpty()) {
164 throw new RemoteopenhabException("JSON response is empty");
166 return Arrays.asList(jsonParser.fromJson(jsonResponse, RemoteopenhabItem[].class));
167 } catch (RemoteopenhabException | JsonSyntaxException e) {
168 throw new RemoteopenhabException(
169 "Failed to get the list of remote items using the items REST API: " + e.getMessage(), e);
173 public String getRemoteItemState(String itemName) throws RemoteopenhabException {
175 String url = String.format("%s/%s/state", getRestApiUrl("items"), itemName);
176 return executeGetUrl(url, "text/plain", true);
177 } catch (RemoteopenhabException e) {
178 throw new RemoteopenhabException("Failed to get the state of remote item " + itemName
179 + " using the items REST API: " + e.getMessage(), e);
183 public void sendCommandToRemoteItem(String itemName, Command command) throws RemoteopenhabException {
185 String url = String.format("%s/%s", getRestApiUrl("items"), itemName);
186 InputStream stream = new ByteArrayInputStream(command.toFullString().getBytes(StandardCharsets.UTF_8));
187 executeUrl(HttpMethod.POST, url, "application/json", stream, "text/plain", false);
189 } catch (RemoteopenhabException | IOException e) {
190 throw new RemoteopenhabException("Failed to send command to the remote item " + itemName
191 + " using the items REST API: " + e.getMessage(), e);
195 public List<RemoteopenhabThing> getRemoteThings() throws RemoteopenhabException {
197 String jsonResponse = executeGetUrl(getRestApiUrl("things"), "application/json", false);
198 if (jsonResponse.isEmpty()) {
199 throw new RemoteopenhabException("JSON response is empty");
201 return Arrays.asList(jsonParser.fromJson(jsonResponse, RemoteopenhabThing[].class));
202 } catch (RemoteopenhabException | JsonSyntaxException e) {
203 throw new RemoteopenhabException(
204 "Failed to get the list of remote things using the things REST API: " + e.getMessage(), e);
208 public RemoteopenhabThing getRemoteThing(String uid) throws RemoteopenhabException {
210 String url = String.format("%s/%s", getRestApiUrl("things"), uid);
211 String jsonResponse = executeGetUrl(url, "application/json", false);
212 if (jsonResponse.isEmpty()) {
213 throw new RemoteopenhabException("JSON response is empty");
215 return Objects.requireNonNull(jsonParser.fromJson(jsonResponse, RemoteopenhabThing.class));
216 } catch (RemoteopenhabException | JsonSyntaxException e) {
217 throw new RemoteopenhabException(
218 "Failed to get the remote thing " + uid + " using the things REST API: " + e.getMessage(), e);
222 public @Nullable String getRestApiVersion() {
223 return restApiVersion;
226 private String getRestApiUrl(String endPoint) throws RemoteopenhabException {
227 String url = apiEndPointsUrls.get(endPoint);
228 return url != null ? url : getRestUrl() + "/" + endPoint;
231 public String getTopicNamespace() {
232 String namespace = topicNamespace;
233 return namespace != null ? namespace : "openhab";
236 public void start() {
237 synchronized (startStopLock) {
238 logger.debug("Opening EventSource");
240 logger.debug("EventSource started");
245 synchronized (startStopLock) {
246 logger.debug("Closing EventSource");
247 closeEventSource(0, TimeUnit.SECONDS);
248 logger.debug("EventSource stopped");
249 lastEventTimestamp = 0;
253 private SseEventSource createEventSource(String restSseUrl) {
255 // Avoid a timeout exception after 1 minute by setting the read timeout to 0 (infinite)
256 if (trustedCertificate) {
257 client = clientBuilder.sslContext(httpClient.getSslContextFactory().getSslContext())
258 .hostnameVerifier(new HostnameVerifier() {
260 public boolean verify(@Nullable String hostname, @Nullable SSLSession session) {
263 }).readTimeout(0, TimeUnit.SECONDS).register(new RemoteopenhabStreamingRequestFilter(accessToken))
266 client = clientBuilder.readTimeout(0, TimeUnit.SECONDS)
267 .register(new RemoteopenhabStreamingRequestFilter(accessToken)).build();
269 SseEventSource eventSource = eventSourceFactory.newSource(client.target(restSseUrl));
270 eventSource.register(this::onEvent, this::onError);
274 private void reopenEventSource() {
275 logger.debug("Reopening EventSource");
279 url = String.format("%s?topics=%s/items/*/*,%s/things/*/*,%s/channels/*/triggered", getRestApiUrl("events"),
280 getTopicNamespace(), getTopicNamespace(), getTopicNamespace());
281 } catch (RemoteopenhabException e) {
282 logger.debug("{}", e.getMessage());
286 closeEventSource(10, TimeUnit.SECONDS);
288 logger.debug("Opening new EventSource {}", url);
289 SseEventSource localEventSource = createEventSource(url);
290 localEventSource.open();
292 eventSource = localEventSource;
295 private void closeEventSource(long timeout, TimeUnit timeoutUnit) {
296 SseEventSource localEventSource = eventSource;
297 if (localEventSource != null) {
298 if (!localEventSource.isOpen()) {
299 logger.debug("Existing EventSource is already closed");
300 } else if (localEventSource.close(timeout, timeoutUnit)) {
301 logger.debug("Succesfully closed existing EventSource");
303 logger.debug("Failed to close existing EventSource");
310 public boolean addStreamingDataListener(RemoteopenhabStreamingDataListener listener) {
311 return listeners.add(listener);
314 public boolean removeStreamingDataListener(RemoteopenhabStreamingDataListener listener) {
315 return listeners.remove(listener);
318 public boolean addItemsDataListener(RemoteopenhabItemsDataListener listener) {
319 return itemsListeners.add(listener);
322 public boolean removeItemsDataListener(RemoteopenhabItemsDataListener listener) {
323 return itemsListeners.remove(listener);
326 public boolean addThingsDataListener(RemoteopenhabThingsDataListener listener) {
327 return thingsListeners.add(listener);
330 public boolean removeThingsDataListener(RemoteopenhabThingsDataListener listener) {
331 return thingsListeners.remove(listener);
334 public long getLastEventTimestamp() {
335 return lastEventTimestamp;
338 private void onEvent(InboundSseEvent inboundEvent) {
339 String name = inboundEvent.getName();
340 String data = inboundEvent.readData();
341 logger.trace("Received event name {} date {}", name, data);
343 lastEventTimestamp = System.currentTimeMillis();
345 logger.debug("Connected to streaming events");
347 listeners.forEach(listener -> listener.onConnected());
350 if (!"message".equals(name)) {
351 logger.debug("Received unhandled event with name '{}' and data '{}'", name, data);
356 RemoteopenhabEvent event = jsonParser.fromJson(data, RemoteopenhabEvent.class);
359 RemoteopenhabEventPayload payload;
360 RemoteopenhabItem item;
361 RemoteopenhabThing thing;
362 switch (event.type) {
363 case "ItemStateEvent":
364 itemName = extractItemNameFromTopic(event.topic, event.type, "state");
365 payload = jsonParser.fromJson(event.payload, RemoteopenhabEventPayload.class);
366 itemsListeners.forEach(
367 listener -> listener.onItemStateEvent(itemName, payload.type, payload.value, false));
369 case "ItemStateChangedEvent":
370 itemName = extractItemNameFromTopic(event.topic, event.type, "statechanged");
371 payload = jsonParser.fromJson(event.payload, RemoteopenhabEventPayload.class);
372 itemsListeners.forEach(
373 listener -> listener.onItemStateEvent(itemName, payload.type, payload.value, true));
375 case "GroupItemStateChangedEvent":
376 itemName = extractItemNameFromTopic(event.topic, event.type, "statechanged");
377 payload = jsonParser.fromJson(event.payload, RemoteopenhabEventPayload.class);
378 itemsListeners.forEach(
379 listener -> listener.onItemStateEvent(itemName, payload.type, payload.value, false));
381 case "ItemAddedEvent":
382 itemName = extractItemNameFromTopic(event.topic, event.type, "added");
383 item = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabItem.class));
384 itemsListeners.forEach(listener -> listener.onItemAdded(item));
386 case "ItemRemovedEvent":
387 itemName = extractItemNameFromTopic(event.topic, event.type, "removed");
388 item = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabItem.class));
389 itemsListeners.forEach(listener -> listener.onItemRemoved(item));
391 case "ItemUpdatedEvent":
392 itemName = extractItemNameFromTopic(event.topic, event.type, "updated");
393 RemoteopenhabItem[] updItem = jsonParser.fromJson(event.payload, RemoteopenhabItem[].class);
394 if (updItem.length == 2) {
395 itemsListeners.forEach(listener -> listener.onItemUpdated(updItem[0], updItem[1]));
397 logger.debug("Invalid payload for event type {} for topic {}", event.type, event.topic);
400 case "ThingStatusInfoChangedEvent":
401 thingUID = extractThingUIDFromTopic(event.topic, event.type, "statuschanged");
402 RemoteopenhabStatusInfo[] updStatus = jsonParser.fromJson(event.payload,
403 RemoteopenhabStatusInfo[].class);
404 if (updStatus.length == 2) {
405 thingsListeners.forEach(listener -> listener.onThingStatusUpdated(thingUID, updStatus[0]));
407 logger.debug("Invalid payload for event type {} for topic {}", event.type, event.topic);
410 case "ThingAddedEvent":
411 thingUID = extractThingUIDFromTopic(event.topic, event.type, "added");
412 thing = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabThing.class));
413 thingsListeners.forEach(listener -> listener.onThingAdded(thing));
415 case "ThingRemovedEvent":
416 thingUID = extractThingUIDFromTopic(event.topic, event.type, "removed");
417 thing = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabThing.class));
418 thingsListeners.forEach(listener -> listener.onThingRemoved(thing));
420 case "ChannelTriggeredEvent":
421 RemoteopenhabChannelTriggerEvent triggerEvent = jsonParser.fromJson(event.payload,
422 RemoteopenhabChannelTriggerEvent.class);
424 .forEach(listener -> listener.onChannelTriggered(triggerEvent.channel, triggerEvent.event));
426 case "ItemStatePredictedEvent":
427 case "ItemCommandEvent":
428 case "ThingStatusInfoEvent":
429 case "ThingUpdatedEvent":
430 logger.trace("Ignored event type {} for topic {}", event.type, event.topic);
433 logger.debug("Unexpected event type {} for topic {}", event.type, event.topic);
436 } catch (RemoteopenhabException | JsonSyntaxException e) {
437 logger.debug("An exception occurred while processing the inbound '{}' event containg data: {}", name, data,
442 private void onError(Throwable error) {
443 logger.debug("Error occurred while receiving events", error);
444 listeners.forEach(listener -> listener.onError("Error occurred while receiving events"));
447 private String extractItemNameFromTopic(String topic, String eventType, String finalPart)
448 throws RemoteopenhabException {
449 String[] parts = topic.split("/");
450 int expectedNbParts = "GroupItemStateChangedEvent".equals(eventType) ? 5 : 4;
451 if (parts.length != expectedNbParts || !getTopicNamespace().equals(parts[0]) || !"items".equals(parts[1])
452 || !finalPart.equals(parts[parts.length - 1])) {
453 throw new RemoteopenhabException("Invalid event topic " + topic + " for event type " + eventType);
458 private String extractThingUIDFromTopic(String topic, String eventType, String finalPart)
459 throws RemoteopenhabException {
460 String[] parts = topic.split("/");
461 int expectedNbParts = 4;
462 if (parts.length != expectedNbParts || !getTopicNamespace().equals(parts[0]) || !"things".equals(parts[1])
463 || !finalPart.equals(parts[parts.length - 1])) {
464 throw new RemoteopenhabException("Invalid event topic " + topic + " for event type " + eventType);
469 public String executeGetUrl(String url, String acceptHeader, boolean asyncReading) throws RemoteopenhabException {
470 return executeUrl(HttpMethod.GET, url, acceptHeader, null, null, asyncReading);
473 public String executeUrl(HttpMethod httpMethod, String url, String acceptHeader, @Nullable InputStream content,
474 @Nullable String contentType, boolean asyncReading) throws RemoteopenhabException {
475 final Request request = httpClient.newRequest(url).method(httpMethod).timeout(REQUEST_TIMEOUT,
476 TimeUnit.MILLISECONDS);
478 request.header(HttpHeaders.ACCEPT, acceptHeader);
479 if (!accessToken.isEmpty()) {
480 request.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
483 if (content != null && (HttpMethod.POST.equals(httpMethod) || HttpMethod.PUT.equals(httpMethod))
484 && contentType != null) {
485 try (final InputStreamContentProvider inputStreamContentProvider = new InputStreamContentProvider(
487 request.content(inputStreamContentProvider, 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);