]> git.basschouten.com Git - openhab-addons.git/blob
ebde376114ec6a719d3dfd52f5149f050da9a24a
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.remoteopenhab.internal.rest;
14
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;
21 import java.util.Map;
22 import java.util.Objects;
23 import java.util.concurrent.CopyOnWriteArrayList;
24 import java.util.concurrent.TimeUnit;
25
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;
33
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;
59
60 import com.google.gson.Gson;
61 import com.google.gson.JsonSyntaxException;
62
63 /**
64  * A client to use the openHAB REST API and to receive/parse events received from the openHAB REST API Server-Sent
65  * Events (SSE).
66  *
67  * @author Laurent Garnier - Initial contribution
68  */
69 @NonNullByDefault
70 public class RemoteopenhabRestClient {
71
72     private static final int REQUEST_TIMEOUT = (int) TimeUnit.SECONDS.toMillis(30);
73
74     private final Logger logger = LoggerFactory.getLogger(RemoteopenhabRestClient.class);
75
76     private final ClientBuilder clientBuilder;
77     private final SseEventSourceFactory eventSourceFactory;
78     private final Gson jsonParser;
79
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<>();
84
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;
94
95     private @Nullable SseEventSource eventSource;
96     private long lastEventTimestamp;
97
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 = "";
105     }
106
107     public void setHttpClient(HttpClient httpClient) {
108         this.httpClient = httpClient;
109     }
110
111     public String getRestUrl() throws RemoteopenhabException {
112         String url = restUrl;
113         if (url == null) {
114             throw new RemoteopenhabException("REST client not correctly setup");
115         }
116         return url;
117     }
118
119     public void setRestUrl(String restUrl) {
120         this.restUrl = restUrl;
121     }
122
123     public void setAccessToken(String accessToken) {
124         this.accessToken = accessToken;
125     }
126
127     public void setTrustedCertificate(boolean trustedCertificate) {
128         this.trustedCertificate = trustedCertificate;
129     }
130
131     public void tryApi() throws RemoteopenhabException {
132         try {
133             String jsonResponse = executeGetUrl(getRestUrl(), "application/json", false);
134             if (jsonResponse.isEmpty()) {
135                 throw new RemoteopenhabException("JSON response is empty");
136             }
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);
143             }
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);
151         }
152     }
153
154     public List<RemoteopenhabItem> getRemoteItems(@Nullable String fields) throws RemoteopenhabException {
155         try {
156             String url = String.format("%s?recursive=false", getRestApiUrl("items"));
157             if (fields != null) {
158                 url += "&fields=" + fields;
159             }
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");
164             }
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);
169         }
170     }
171
172     public String getRemoteItemState(String itemName) throws RemoteopenhabException {
173         try {
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);
179         }
180     }
181
182     public void sendCommandToRemoteItem(String itemName, Command command) throws RemoteopenhabException {
183         try {
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);
189         }
190     }
191
192     public List<RemoteopenhabThing> getRemoteThings() throws RemoteopenhabException {
193         try {
194             String jsonResponse = executeGetUrl(getRestApiUrl("things"), "application/json", false);
195             if (jsonResponse.isEmpty()) {
196                 throw new RemoteopenhabException("JSON response is empty");
197             }
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);
202         }
203     }
204
205     public RemoteopenhabThing getRemoteThing(String uid) throws RemoteopenhabException {
206         try {
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");
211             }
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);
216         }
217     }
218
219     public @Nullable String getRestApiVersion() {
220         return restApiVersion;
221     }
222
223     private String getRestApiUrl(String endPoint) throws RemoteopenhabException {
224         String url = apiEndPointsUrls.get(endPoint);
225         return url != null ? url : getRestUrl() + "/" + endPoint;
226     }
227
228     public String getTopicNamespace() {
229         String namespace = topicNamespace;
230         return namespace != null ? namespace : "openhab";
231     }
232
233     public void start() {
234         synchronized (startStopLock) {
235             logger.debug("Opening EventSource");
236             reopenEventSource();
237             logger.debug("EventSource started");
238         }
239     }
240
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;
247         }
248     }
249
250     private SseEventSource createEventSource(String restSseUrl) {
251         Client client;
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() {
256                         @Override
257                         public boolean verify(@Nullable String hostname, @Nullable SSLSession session) {
258                             return true;
259                         }
260                     }).readTimeout(0, TimeUnit.SECONDS).register(new RemoteopenhabStreamingRequestFilter(accessToken))
261                     .build();
262         } else {
263             client = clientBuilder.readTimeout(0, TimeUnit.SECONDS)
264                     .register(new RemoteopenhabStreamingRequestFilter(accessToken)).build();
265         }
266         SseEventSource eventSource = eventSourceFactory.newSource(client.target(restSseUrl));
267         eventSource.register(this::onEvent, this::onError, this::onComplete);
268         return eventSource;
269     }
270
271     private void reopenEventSource() {
272         logger.debug("Reopening EventSource");
273
274         String url;
275         try {
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());
280             return;
281         }
282
283         closeEventSource(true);
284
285         logger.debug("Opening new EventSource {}", url);
286         SseEventSource localEventSource = createEventSource(url);
287         localEventSource.open();
288
289         eventSource = localEventSource;
290     }
291
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");
299             } else {
300                 logger.debug("Failed to close existing EventSource");
301             }
302             eventSource = null;
303         }
304         connected = false;
305     }
306
307     public boolean addStreamingDataListener(RemoteopenhabStreamingDataListener listener) {
308         return listeners.add(listener);
309     }
310
311     public boolean removeStreamingDataListener(RemoteopenhabStreamingDataListener listener) {
312         return listeners.remove(listener);
313     }
314
315     public boolean addItemsDataListener(RemoteopenhabItemsDataListener listener) {
316         return itemsListeners.add(listener);
317     }
318
319     public boolean removeItemsDataListener(RemoteopenhabItemsDataListener listener) {
320         return itemsListeners.remove(listener);
321     }
322
323     public boolean addThingsDataListener(RemoteopenhabThingsDataListener listener) {
324         return thingsListeners.add(listener);
325     }
326
327     public boolean removeThingsDataListener(RemoteopenhabThingsDataListener listener) {
328         return thingsListeners.remove(listener);
329     }
330
331     public long getLastEventTimestamp() {
332         return lastEventTimestamp;
333     }
334
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);
339
340         lastEventTimestamp = System.currentTimeMillis();
341         if (!connected) {
342             logger.debug("Connected to streaming events");
343             connected = true;
344             listeners.forEach(listener -> listener.onConnected());
345         }
346
347         if (!"message".equals(name)) {
348             logger.debug("Received unhandled event with name '{}' and data '{}'", name, data);
349             return;
350         }
351
352         try {
353             RemoteopenhabEvent event = jsonParser.fromJson(data, RemoteopenhabEvent.class);
354             String itemName;
355             String thingUID;
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));
365                     break;
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));
371                     break;
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));
377                     break;
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));
382                     break;
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));
387                     break;
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]));
393                     } else {
394                         logger.debug("Invalid payload for event type {} for topic {}", event.type, event.topic);
395                     }
396                     break;
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]));
403                     } else {
404                         logger.debug("Invalid payload for event type {} for topic {}", event.type, event.topic);
405                     }
406                     break;
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));
411                     break;
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));
416                     break;
417                 case "ChannelTriggeredEvent":
418                     RemoteopenhabChannelTriggerEvent triggerEvent = jsonParser.fromJson(event.payload,
419                             RemoteopenhabChannelTriggerEvent.class);
420                     thingsListeners
421                             .forEach(listener -> listener.onChannelTriggered(triggerEvent.channel, triggerEvent.event));
422                     break;
423                 case "ItemStatePredictedEvent":
424                 case "ItemCommandEvent":
425                 case "ThingStatusInfoEvent":
426                 case "ThingUpdatedEvent":
427                     logger.trace("Ignored event type {} for topic {}", event.type, event.topic);
428                     break;
429                 default:
430                     logger.debug("Unexpected event type {} for topic {}", event.type, event.topic);
431                     break;
432             }
433         } catch (RemoteopenhabException | JsonSyntaxException e) {
434             logger.debug("An exception occurred while processing the inbound '{}' event containg data: {}", name, data,
435                     e);
436         }
437     }
438
439     private void onComplete() {
440         logger.debug("Disconnected from streaming events");
441         completed = true;
442         listeners.forEach(listener -> listener.onDisconnected());
443     }
444
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"));
448     }
449
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);
457         }
458         return parts[2];
459     }
460
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);
468         }
469         return parts[2];
470     }
471
472     public String executeGetUrl(String url, String acceptHeader, boolean asyncReading) throws RemoteopenhabException {
473         return executeUrl(HttpMethod.GET, url, acceptHeader, null, null, asyncReading);
474     }
475
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);
480
481         request.header(HttpHeaders.ACCEPT, acceptHeader);
482         if (!accessToken.isEmpty()) {
483             request.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
484         }
485
486         if (content != null && (HttpMethod.POST.equals(httpMethod) || HttpMethod.PUT.equals(httpMethod))
487                 && contentType != null) {
488             request.content(new StringContentProvider(content), contentType);
489         }
490
491         try {
492             if (asyncReading) {
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);
501                 }
502                 ByteArrayOutputStream responseContent = new ByteArrayOutputStream();
503                 try (InputStream input = listener.getInputStream()) {
504                     input.transferTo(responseContent);
505                 }
506                 return new String(responseContent.toByteArray(), StandardCharsets.UTF_8.name());
507             } else {
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);
513                 }
514                 String encoding = response.getEncoding() != null ? response.getEncoding().replaceAll("\"", "").trim()
515                         : StandardCharsets.UTF_8.name();
516                 return new String(response.getContent(), encoding);
517             }
518         } catch (RemoteopenhabException e) {
519             throw e;
520         } catch (Exception e) {
521             throw new RemoteopenhabException(e);
522         }
523     }
524 }