2 * Copyright (c) 2010-2020 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.IOException;
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.TimeUnit;
27 import javax.net.ssl.HostnameVerifier;
28 import javax.net.ssl.SSLSession;
29 import javax.ws.rs.client.Client;
30 import javax.ws.rs.client.ClientBuilder;
31 import javax.ws.rs.core.HttpHeaders;
32 import javax.ws.rs.sse.InboundSseEvent;
33 import javax.ws.rs.sse.SseEventSource;
35 import org.eclipse.jdt.annotation.NonNullByDefault;
36 import org.eclipse.jdt.annotation.Nullable;
37 import org.eclipse.jetty.client.HttpClient;
38 import org.eclipse.jetty.client.api.ContentResponse;
39 import org.eclipse.jetty.client.api.Request;
40 import org.eclipse.jetty.client.util.InputStreamContentProvider;
41 import org.eclipse.jetty.http.HttpMethod;
42 import org.eclipse.jetty.http.HttpStatus;
43 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabChannelTriggerEvent;
44 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabEvent;
45 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabEventPayload;
46 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabItem;
47 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabRestApi;
48 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabStatusInfo;
49 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabThing;
50 import org.openhab.binding.remoteopenhab.internal.exceptions.RemoteopenhabException;
51 import org.openhab.binding.remoteopenhab.internal.listener.RemoteopenhabItemsDataListener;
52 import org.openhab.binding.remoteopenhab.internal.listener.RemoteopenhabStreamingDataListener;
53 import org.openhab.binding.remoteopenhab.internal.listener.RemoteopenhabThingsDataListener;
54 import org.openhab.core.types.Command;
55 import org.osgi.service.jaxrs.client.SseEventSourceFactory;
56 import org.slf4j.Logger;
57 import org.slf4j.LoggerFactory;
59 import com.google.gson.Gson;
60 import com.google.gson.JsonSyntaxException;
63 * A client to use the openHAB REST API and to receive/parse events received from the openHAB REST API Server-Sent
66 * @author Laurent Garnier - Initial contribution
69 public class RemoteopenhabRestClient {
71 private static final int REQUEST_TIMEOUT = (int) TimeUnit.SECONDS.toMillis(30);
73 private final Logger logger = LoggerFactory.getLogger(RemoteopenhabRestClient.class);
75 private final ClientBuilder clientBuilder;
76 private final SseEventSourceFactory eventSourceFactory;
77 private final Gson jsonParser;
79 private final Object startStopLock = new Object();
80 private final List<RemoteopenhabStreamingDataListener> listeners = new CopyOnWriteArrayList<>();
81 private final List<RemoteopenhabItemsDataListener> itemsListeners = new CopyOnWriteArrayList<>();
82 private final List<RemoteopenhabThingsDataListener> thingsListeners = new CopyOnWriteArrayList<>();
84 private HttpClient httpClient;
85 private @Nullable String restUrl;
86 private @Nullable String restApiVersion;
87 private Map<String, @Nullable String> apiEndPointsUrls = new HashMap<>();
88 private @Nullable String topicNamespace;
89 private String accessToken;
90 private boolean trustedCertificate;
91 private boolean connected;
93 private @Nullable SseEventSource eventSource;
94 private long lastEventTimestamp;
96 public RemoteopenhabRestClient(final HttpClient httpClient, final ClientBuilder clientBuilder,
97 final SseEventSourceFactory eventSourceFactory, final Gson jsonParser) {
98 this.httpClient = httpClient;
99 this.clientBuilder = clientBuilder;
100 this.eventSourceFactory = eventSourceFactory;
101 this.jsonParser = jsonParser;
102 this.accessToken = "";
105 public void setHttpClient(HttpClient httpClient) {
106 this.httpClient = httpClient;
109 public String getRestUrl() throws RemoteopenhabException {
110 String url = restUrl;
112 throw new RemoteopenhabException("REST client not correctly setup");
117 public void setRestUrl(String restUrl) {
118 this.restUrl = restUrl;
121 public void setAccessToken(String accessToken) {
122 this.accessToken = accessToken;
125 public void setTrustedCertificate(boolean trustedCertificate) {
126 this.trustedCertificate = trustedCertificate;
129 public void tryApi() throws RemoteopenhabException {
131 String jsonResponse = executeUrl(HttpMethod.GET, getRestUrl(), "application/json", null, null);
132 if (jsonResponse.isEmpty()) {
133 throw new RemoteopenhabException("JSON response is empty");
135 RemoteopenhabRestApi restApi = jsonParser.fromJson(jsonResponse, RemoteopenhabRestApi.class);
136 restApiVersion = restApi.version;
137 logger.debug("REST API version = {}", restApiVersion);
138 apiEndPointsUrls.clear();
139 for (int i = 0; i < restApi.links.length; i++) {
140 apiEndPointsUrls.put(restApi.links[i].type, restApi.links[i].url);
142 logger.debug("REST API items = {}", apiEndPointsUrls.get("items"));
143 logger.debug("REST API things = {}", apiEndPointsUrls.get("things"));
144 logger.debug("REST API events = {}", apiEndPointsUrls.get("events"));
145 topicNamespace = restApi.runtimeInfo != null ? "openhab" : "smarthome";
146 logger.debug("topic namespace = {}", topicNamespace);
147 } catch (RemoteopenhabException | JsonSyntaxException e) {
148 throw new RemoteopenhabException("Failed to execute the root REST API: " + e.getMessage(), e);
152 public List<RemoteopenhabItem> getRemoteItems() throws RemoteopenhabException {
154 String url = String.format("%s?recursive=false", getRestApiUrl("items"));
155 String jsonResponse = executeUrl(HttpMethod.GET, url, "application/json", null, null);
156 if (jsonResponse.isEmpty()) {
157 throw new RemoteopenhabException("JSON response is empty");
159 return Arrays.asList(jsonParser.fromJson(jsonResponse, RemoteopenhabItem[].class));
160 } catch (RemoteopenhabException | JsonSyntaxException e) {
161 throw new RemoteopenhabException(
162 "Failed to get the list of remote items using the items REST API: " + e.getMessage(), e);
166 public String getRemoteItemState(String itemName) throws RemoteopenhabException {
168 String url = String.format("%s/%s/state", getRestApiUrl("items"), itemName);
169 return executeUrl(HttpMethod.GET, url, "text/plain", null, null);
170 } catch (RemoteopenhabException e) {
171 throw new RemoteopenhabException("Failed to get the state of remote item " + itemName
172 + " using the items REST API: " + e.getMessage(), e);
176 public void sendCommandToRemoteItem(String itemName, Command command) throws RemoteopenhabException {
178 String url = String.format("%s/%s", getRestApiUrl("items"), itemName);
179 InputStream stream = new ByteArrayInputStream(command.toFullString().getBytes(StandardCharsets.UTF_8));
180 executeUrl(HttpMethod.POST, url, "application/json", stream, "text/plain");
182 } catch (RemoteopenhabException | IOException e) {
183 throw new RemoteopenhabException("Failed to send command to the remote item " + itemName
184 + " using the items REST API: " + e.getMessage(), e);
188 public List<RemoteopenhabThing> getRemoteThings() throws RemoteopenhabException {
190 String jsonResponse = executeUrl(HttpMethod.GET, getRestApiUrl("things"), "application/json", null, null);
191 if (jsonResponse.isEmpty()) {
192 throw new RemoteopenhabException("JSON response is empty");
194 return Arrays.asList(jsonParser.fromJson(jsonResponse, RemoteopenhabThing[].class));
195 } catch (RemoteopenhabException | JsonSyntaxException e) {
196 throw new RemoteopenhabException(
197 "Failed to get the list of remote things using the things REST API: " + e.getMessage(), e);
201 public RemoteopenhabThing getRemoteThing(String uid) throws RemoteopenhabException {
203 String url = String.format("%s/%s", getRestApiUrl("things"), uid);
204 String jsonResponse = executeUrl(HttpMethod.GET, url, "application/json", null, null);
205 if (jsonResponse.isEmpty()) {
206 throw new RemoteopenhabException("JSON response is empty");
208 return Objects.requireNonNull(jsonParser.fromJson(jsonResponse, RemoteopenhabThing.class));
209 } catch (RemoteopenhabException | JsonSyntaxException e) {
210 throw new RemoteopenhabException(
211 "Failed to get the remote thing " + uid + " using the things REST API: " + e.getMessage(), e);
215 public @Nullable String getRestApiVersion() {
216 return restApiVersion;
219 private String getRestApiUrl(String endPoint) throws RemoteopenhabException {
220 String url = apiEndPointsUrls.get(endPoint);
221 return url != null ? url : getRestUrl() + "/" + endPoint;
224 public String getTopicNamespace() {
225 String namespace = topicNamespace;
226 return namespace != null ? namespace : "openhab";
229 public void start() {
230 synchronized (startStopLock) {
231 logger.debug("Opening EventSource");
233 logger.debug("EventSource started");
238 synchronized (startStopLock) {
239 logger.debug("Closing EventSource");
240 closeEventSource(0, TimeUnit.SECONDS);
241 logger.debug("EventSource stopped");
242 lastEventTimestamp = 0;
246 private SseEventSource createEventSource(String restSseUrl) {
248 // Avoid a timeout exception after 1 minute by setting the read timeout to 0 (infinite)
249 if (trustedCertificate) {
250 client = clientBuilder.sslContext(httpClient.getSslContextFactory().getSslContext())
251 .hostnameVerifier(new HostnameVerifier() {
253 public boolean verify(@Nullable String hostname, @Nullable SSLSession session) {
256 }).readTimeout(0, TimeUnit.SECONDS).register(new RemoteopenhabStreamingRequestFilter(accessToken))
259 client = clientBuilder.readTimeout(0, TimeUnit.SECONDS)
260 .register(new RemoteopenhabStreamingRequestFilter(accessToken)).build();
262 SseEventSource eventSource = eventSourceFactory.newSource(client.target(restSseUrl));
263 eventSource.register(this::onEvent, this::onError);
267 private void reopenEventSource() {
268 logger.debug("Reopening EventSource");
272 url = String.format("%s?topics=%s/items/*/*,%s/things/*/*,%s/channels/*/triggered", getRestApiUrl("events"),
273 getTopicNamespace(), getTopicNamespace(), getTopicNamespace());
274 } catch (RemoteopenhabException e) {
275 logger.debug("{}", e.getMessage());
279 closeEventSource(10, TimeUnit.SECONDS);
281 logger.debug("Opening new EventSource {}", url);
282 SseEventSource localEventSource = createEventSource(url);
283 localEventSource.open();
285 eventSource = localEventSource;
288 private void closeEventSource(long timeout, TimeUnit timeoutUnit) {
289 SseEventSource localEventSource = eventSource;
290 if (localEventSource != null) {
291 if (!localEventSource.isOpen()) {
292 logger.debug("Existing EventSource is already closed");
293 } else if (localEventSource.close(timeout, timeoutUnit)) {
294 logger.debug("Succesfully closed existing EventSource");
296 logger.debug("Failed to close existing EventSource");
303 public boolean addStreamingDataListener(RemoteopenhabStreamingDataListener listener) {
304 return listeners.add(listener);
307 public boolean removeStreamingDataListener(RemoteopenhabStreamingDataListener listener) {
308 return listeners.remove(listener);
311 public boolean addItemsDataListener(RemoteopenhabItemsDataListener listener) {
312 return itemsListeners.add(listener);
315 public boolean removeItemsDataListener(RemoteopenhabItemsDataListener listener) {
316 return itemsListeners.remove(listener);
319 public boolean addThingsDataListener(RemoteopenhabThingsDataListener listener) {
320 return thingsListeners.add(listener);
323 public boolean removeThingsDataListener(RemoteopenhabThingsDataListener listener) {
324 return thingsListeners.remove(listener);
327 public long getLastEventTimestamp() {
328 return lastEventTimestamp;
331 private void onEvent(InboundSseEvent inboundEvent) {
332 String name = inboundEvent.getName();
333 String data = inboundEvent.readData();
334 logger.trace("Received event name {} date {}", name, data);
336 lastEventTimestamp = System.currentTimeMillis();
338 logger.debug("Connected to streaming events");
340 listeners.forEach(listener -> listener.onConnected());
343 if (!"message".equals(name)) {
344 logger.debug("Received unhandled event with name '{}' and data '{}'", name, data);
349 RemoteopenhabEvent event = jsonParser.fromJson(data, RemoteopenhabEvent.class);
352 RemoteopenhabEventPayload payload;
353 RemoteopenhabItem item;
354 RemoteopenhabThing thing;
355 switch (event.type) {
356 case "ItemStateEvent":
357 itemName = extractItemNameFromTopic(event.topic, event.type, "state");
358 payload = jsonParser.fromJson(event.payload, RemoteopenhabEventPayload.class);
360 .forEach(listener -> listener.onItemStateEvent(itemName, payload.type, payload.value));
362 case "GroupItemStateChangedEvent":
363 itemName = extractItemNameFromTopic(event.topic, event.type, "statechanged");
364 payload = jsonParser.fromJson(event.payload, RemoteopenhabEventPayload.class);
366 .forEach(listener -> listener.onItemStateEvent(itemName, payload.type, payload.value));
368 case "ItemAddedEvent":
369 itemName = extractItemNameFromTopic(event.topic, event.type, "added");
370 item = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabItem.class));
371 itemsListeners.forEach(listener -> listener.onItemAdded(item));
373 case "ItemRemovedEvent":
374 itemName = extractItemNameFromTopic(event.topic, event.type, "removed");
375 item = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabItem.class));
376 itemsListeners.forEach(listener -> listener.onItemRemoved(item));
378 case "ItemUpdatedEvent":
379 itemName = extractItemNameFromTopic(event.topic, event.type, "updated");
380 RemoteopenhabItem[] updItem = jsonParser.fromJson(event.payload, RemoteopenhabItem[].class);
381 if (updItem.length == 2) {
382 itemsListeners.forEach(listener -> listener.onItemUpdated(updItem[0], updItem[1]));
384 logger.debug("Invalid payload for event type {} for topic {}", event.type, event.topic);
387 case "ThingStatusInfoChangedEvent":
388 thingUID = extractThingUIDFromTopic(event.topic, event.type, "statuschanged");
389 RemoteopenhabStatusInfo[] updStatus = jsonParser.fromJson(event.payload,
390 RemoteopenhabStatusInfo[].class);
391 if (updStatus.length == 2) {
392 thingsListeners.forEach(listener -> listener.onThingStatusUpdated(thingUID, updStatus[0]));
394 logger.debug("Invalid payload for event type {} for topic {}", event.type, event.topic);
397 case "ThingAddedEvent":
398 thingUID = extractThingUIDFromTopic(event.topic, event.type, "added");
399 thing = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabThing.class));
400 thingsListeners.forEach(listener -> listener.onThingAdded(thing));
402 case "ThingRemovedEvent":
403 thingUID = extractThingUIDFromTopic(event.topic, event.type, "removed");
404 thing = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabThing.class));
405 thingsListeners.forEach(listener -> listener.onThingRemoved(thing));
407 case "ChannelTriggeredEvent":
408 RemoteopenhabChannelTriggerEvent triggerEvent = jsonParser.fromJson(event.payload,
409 RemoteopenhabChannelTriggerEvent.class);
411 .forEach(listener -> listener.onChannelTriggered(triggerEvent.channel, triggerEvent.event));
413 case "ItemStatePredictedEvent":
414 case "ItemStateChangedEvent":
415 case "ItemCommandEvent":
416 case "ThingStatusInfoEvent":
417 case "ThingUpdatedEvent":
418 logger.trace("Ignored event type {} for topic {}", event.type, event.topic);
421 logger.debug("Unexpected event type {} for topic {}", event.type, event.topic);
424 } catch (RemoteopenhabException | JsonSyntaxException e) {
425 logger.debug("An exception occurred while processing the inbound '{}' event containg data: {}", name, data,
430 private void onError(Throwable error) {
431 logger.debug("Error occurred while receiving events", error);
432 listeners.forEach(listener -> listener.onError("Error occurred while receiving events"));
435 private String extractItemNameFromTopic(String topic, String eventType, String finalPart)
436 throws RemoteopenhabException {
437 String[] parts = topic.split("/");
438 int expectedNbParts = "GroupItemStateChangedEvent".equals(eventType) ? 5 : 4;
439 if (parts.length != expectedNbParts || !getTopicNamespace().equals(parts[0]) || !"items".equals(parts[1])
440 || !finalPart.equals(parts[parts.length - 1])) {
441 throw new RemoteopenhabException("Invalid event topic " + topic + " for event type " + eventType);
446 private String extractThingUIDFromTopic(String topic, String eventType, String finalPart)
447 throws RemoteopenhabException {
448 String[] parts = topic.split("/");
449 int expectedNbParts = 4;
450 if (parts.length != expectedNbParts || !getTopicNamespace().equals(parts[0]) || !"things".equals(parts[1])
451 || !finalPart.equals(parts[parts.length - 1])) {
452 throw new RemoteopenhabException("Invalid event topic " + topic + " for event type " + eventType);
457 public String executeUrl(HttpMethod httpMethod, String url, String acceptHeader, @Nullable InputStream content,
458 @Nullable String contentType) throws RemoteopenhabException {
459 final Request request = httpClient.newRequest(url).method(httpMethod).timeout(REQUEST_TIMEOUT,
460 TimeUnit.MILLISECONDS);
462 request.header(HttpHeaders.ACCEPT, acceptHeader);
463 if (!accessToken.isEmpty()) {
464 request.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
467 if (content != null && (HttpMethod.POST.equals(httpMethod) || HttpMethod.PUT.equals(httpMethod))
468 && contentType != null) {
469 try (final InputStreamContentProvider inputStreamContentProvider = new InputStreamContentProvider(
471 request.content(inputStreamContentProvider, contentType);
476 ContentResponse response = request.send();
477 int statusCode = response.getStatus();
478 if (statusCode >= HttpStatus.BAD_REQUEST_400) {
479 String statusLine = statusCode + " " + response.getReason();
480 throw new RemoteopenhabException("HTTP call failed: " + statusLine);
482 String encoding = response.getEncoding() != null ? response.getEncoding().replaceAll("\"", "").trim()
483 : StandardCharsets.UTF_8.name();
484 return new String(response.getContent(), encoding);
485 } catch (Exception e) {
486 throw new RemoteopenhabException(e);