]> git.basschouten.com Git - openhab-addons.git/blob
a294e2215d2c34407f933e6ed31a6f8ef86dcac2
[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.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;
22 import java.util.Map;
23 import java.util.Objects;
24 import java.util.concurrent.CopyOnWriteArrayList;
25 import java.util.concurrent.ExecutionException;
26 import java.util.concurrent.TimeUnit;
27
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;
35
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;
61
62 import com.google.gson.Gson;
63 import com.google.gson.JsonSyntaxException;
64
65 /**
66  * A client to use the openHAB REST API and to receive/parse events received from the openHAB REST API Server-Sent
67  * Events (SSE).
68  *
69  * @author Laurent Garnier - Initial contribution
70  */
71 @NonNullByDefault
72 public class RemoteopenhabRestClient {
73
74     private static final int REQUEST_TIMEOUT = (int) TimeUnit.SECONDS.toMillis(30);
75
76     private final Logger logger = LoggerFactory.getLogger(RemoteopenhabRestClient.class);
77
78     private final ClientBuilder clientBuilder;
79     private final SseEventSourceFactory eventSourceFactory;
80     private final Gson jsonParser;
81
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<>();
86
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;
96
97     private @Nullable SseEventSource eventSource;
98     private long lastEventTimestamp;
99
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 = "";
107     }
108
109     public void setHttpClient(HttpClient httpClient) {
110         this.httpClient = httpClient;
111     }
112
113     public String getRestUrl() throws RemoteopenhabException {
114         String url = restUrl;
115         if (url == null) {
116             throw new RemoteopenhabException("REST client not correctly setup");
117         }
118         return url;
119     }
120
121     public void setRestUrl(String restUrl) {
122         this.restUrl = restUrl;
123     }
124
125     public void setAccessToken(String accessToken) {
126         this.accessToken = accessToken;
127     }
128
129     public void setTrustedCertificate(boolean trustedCertificate) {
130         this.trustedCertificate = trustedCertificate;
131     }
132
133     public void tryApi() throws RemoteopenhabException {
134         try {
135             String jsonResponse = executeGetUrl(getRestUrl(), "application/json", false);
136             if (jsonResponse.isEmpty()) {
137                 throw new RemoteopenhabException("JSON response is empty");
138             }
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);
145             }
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);
153         }
154     }
155
156     public List<RemoteopenhabItem> getRemoteItems(@Nullable String fields) throws RemoteopenhabException {
157         try {
158             String url = String.format("%s?recursive=false", getRestApiUrl("items"));
159             if (fields != null) {
160                 url += "&fields=" + fields;
161             }
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");
166             }
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);
171         }
172     }
173
174     public String getRemoteItemState(String itemName) throws RemoteopenhabException {
175         try {
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);
181         }
182     }
183
184     public void sendCommandToRemoteItem(String itemName, Command command) throws RemoteopenhabException {
185         try {
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);
191         }
192     }
193
194     public List<RemoteopenhabThing> getRemoteThings() throws RemoteopenhabException {
195         try {
196             String jsonResponse = executeGetUrl(getRestApiUrl("things"), "application/json", false);
197             if (jsonResponse.isEmpty()) {
198                 throw new RemoteopenhabException("JSON response is empty");
199             }
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);
204         }
205     }
206
207     public RemoteopenhabThing getRemoteThing(String uid) throws RemoteopenhabException {
208         try {
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");
213             }
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);
218         }
219     }
220
221     public @Nullable String getRestApiVersion() {
222         return restApiVersion;
223     }
224
225     private String getRestApiUrl(String endPoint) throws RemoteopenhabException {
226         String url = apiEndPointsUrls.get(endPoint);
227         return url != null ? url : getRestUrl() + "/" + endPoint;
228     }
229
230     public String getTopicNamespace() {
231         String namespace = topicNamespace;
232         return namespace != null ? namespace : "openhab";
233     }
234
235     public void start() {
236         synchronized (startStopLock) {
237             logger.debug("Opening EventSource");
238             reopenEventSource();
239             logger.debug("EventSource started");
240         }
241     }
242
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;
249         }
250     }
251
252     private SseEventSource createEventSource(String restSseUrl) {
253         Client client;
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() {
258                         @Override
259                         public boolean verify(@Nullable String hostname, @Nullable SSLSession session) {
260                             return true;
261                         }
262                     }).readTimeout(0, TimeUnit.SECONDS).register(new RemoteopenhabStreamingRequestFilter(accessToken))
263                     .build();
264         } else {
265             client = clientBuilder.readTimeout(0, TimeUnit.SECONDS)
266                     .register(new RemoteopenhabStreamingRequestFilter(accessToken)).build();
267         }
268         SseEventSource eventSource = eventSourceFactory.newSource(client.target(restSseUrl));
269         eventSource.register(this::onEvent, this::onError, this::onComplete);
270         return eventSource;
271     }
272
273     private void reopenEventSource() {
274         logger.debug("Reopening EventSource");
275
276         String url;
277         try {
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());
282             return;
283         }
284
285         closeEventSource(true);
286
287         logger.debug("Opening new EventSource {}", url);
288         SseEventSource localEventSource = createEventSource(url);
289         localEventSource.open();
290
291         eventSource = localEventSource;
292     }
293
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");
301             } else {
302                 logger.debug("Failed to close existing EventSource");
303             }
304             eventSource = null;
305         }
306         connected = false;
307     }
308
309     public boolean addStreamingDataListener(RemoteopenhabStreamingDataListener listener) {
310         return listeners.add(listener);
311     }
312
313     public boolean removeStreamingDataListener(RemoteopenhabStreamingDataListener listener) {
314         return listeners.remove(listener);
315     }
316
317     public boolean addItemsDataListener(RemoteopenhabItemsDataListener listener) {
318         return itemsListeners.add(listener);
319     }
320
321     public boolean removeItemsDataListener(RemoteopenhabItemsDataListener listener) {
322         return itemsListeners.remove(listener);
323     }
324
325     public boolean addThingsDataListener(RemoteopenhabThingsDataListener listener) {
326         return thingsListeners.add(listener);
327     }
328
329     public boolean removeThingsDataListener(RemoteopenhabThingsDataListener listener) {
330         return thingsListeners.remove(listener);
331     }
332
333     public long getLastEventTimestamp() {
334         return lastEventTimestamp;
335     }
336
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);
341
342         lastEventTimestamp = System.currentTimeMillis();
343         if (!connected) {
344             logger.debug("Connected to streaming events");
345             connected = true;
346             listeners.forEach(listener -> listener.onConnected());
347         }
348
349         if (!"message".equals(name)) {
350             logger.debug("Received unhandled event with name '{}' and data '{}'", name, data);
351             return;
352         }
353
354         try {
355             RemoteopenhabEvent event = jsonParser.fromJson(data, RemoteopenhabEvent.class);
356             String itemName;
357             String thingUID;
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));
367                     break;
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));
373                     break;
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));
379                     break;
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));
384                     break;
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));
389                     break;
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]));
395                     } else {
396                         logger.debug("Invalid payload for event type {} for topic {}", event.type, event.topic);
397                     }
398                     break;
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]));
405                     } else {
406                         logger.debug("Invalid payload for event type {} for topic {}", event.type, event.topic);
407                     }
408                     break;
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));
413                     break;
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));
418                     break;
419                 case "ChannelTriggeredEvent":
420                     RemoteopenhabChannelTriggerEvent triggerEvent = jsonParser.fromJson(event.payload,
421                             RemoteopenhabChannelTriggerEvent.class);
422                     thingsListeners
423                             .forEach(listener -> listener.onChannelTriggered(triggerEvent.channel, triggerEvent.event));
424                     break;
425                 case "ItemStatePredictedEvent":
426                 case "ItemCommandEvent":
427                 case "ThingStatusInfoEvent":
428                 case "ThingUpdatedEvent":
429                     logger.trace("Ignored event type {} for topic {}", event.type, event.topic);
430                     break;
431                 default:
432                     logger.debug("Unexpected event type {} for topic {}", event.type, event.topic);
433                     break;
434             }
435         } catch (RemoteopenhabException | JsonSyntaxException e) {
436             logger.debug("An exception occurred while processing the inbound '{}' event containg data: {}", name, data,
437                     e);
438         }
439     }
440
441     private void onComplete() {
442         logger.debug("Disconnected from streaming events");
443         completed = true;
444         listeners.forEach(listener -> listener.onDisconnected());
445     }
446
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"));
450     }
451
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);
459         }
460         return parts[2];
461     }
462
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);
470         }
471         return parts[2];
472     }
473
474     public String executeGetUrl(String url, String acceptHeader, boolean asyncReading) throws RemoteopenhabException {
475         return executeUrl(HttpMethod.GET, url, acceptHeader, null, null, asyncReading, true);
476     }
477
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);
482
483         request.header(HttpHeaders.ACCEPT, acceptHeader);
484         if (!accessToken.isEmpty()) {
485             request.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
486         }
487
488         if (content != null && (HttpMethod.POST.equals(httpMethod) || HttpMethod.PUT.equals(httpMethod))
489                 && contentType != null) {
490             request.content(new StringContentProvider(content), contentType);
491         }
492
493         try {
494             if (asyncReading) {
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);
503                 }
504                 ByteArrayOutputStream responseContent = new ByteArrayOutputStream();
505                 try (InputStream input = listener.getInputStream()) {
506                     input.transferTo(responseContent);
507                 }
508                 return new String(responseContent.toByteArray(), StandardCharsets.UTF_8.name());
509             } else {
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);
515                 }
516                 String encoding = response.getEncoding() != null ? response.getEncoding().replaceAll("\"", "").trim()
517                         : StandardCharsets.UTF_8.name();
518                 return new String(response.getContent(), encoding);
519             }
520         } catch (RemoteopenhabException e) {
521             throw 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);
529             } else {
530                 throw new RemoteopenhabException(e);
531             }
532         } catch (Exception e) {
533             throw new RemoteopenhabException(e);
534         }
535     }
536 }