]> git.basschouten.com Git - openhab-addons.git/blob
fab3d7cf3edabb74ab4c608765cd96e06e5710f6
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2020 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.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;
23 import java.util.Map;
24 import java.util.Objects;
25 import java.util.concurrent.CopyOnWriteArrayList;
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.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;
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
96     private @Nullable SseEventSource eventSource;
97     private long lastEventTimestamp;
98
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 = "";
106     }
107
108     public void setHttpClient(HttpClient httpClient) {
109         this.httpClient = httpClient;
110     }
111
112     public String getRestUrl() throws RemoteopenhabException {
113         String url = restUrl;
114         if (url == null) {
115             throw new RemoteopenhabException("REST client not correctly setup");
116         }
117         return url;
118     }
119
120     public void setRestUrl(String restUrl) {
121         this.restUrl = restUrl;
122     }
123
124     public void setAccessToken(String accessToken) {
125         this.accessToken = accessToken;
126     }
127
128     public void setTrustedCertificate(boolean trustedCertificate) {
129         this.trustedCertificate = trustedCertificate;
130     }
131
132     public void tryApi() throws RemoteopenhabException {
133         try {
134             String jsonResponse = executeGetUrl(getRestUrl(), "application/json", false);
135             if (jsonResponse.isEmpty()) {
136                 throw new RemoteopenhabException("JSON response is empty");
137             }
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);
144             }
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);
152         }
153     }
154
155     public List<RemoteopenhabItem> getRemoteItems(@Nullable String fields) throws RemoteopenhabException {
156         try {
157             String url = String.format("%s?recursive=false", getRestApiUrl("items"));
158             if (fields != null) {
159                 url += "&fields=" + fields;
160             }
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");
165             }
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);
170         }
171     }
172
173     public String getRemoteItemState(String itemName) throws RemoteopenhabException {
174         try {
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);
180         }
181     }
182
183     public void sendCommandToRemoteItem(String itemName, Command command) throws RemoteopenhabException {
184         try {
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);
188             stream.close();
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);
192         }
193     }
194
195     public List<RemoteopenhabThing> getRemoteThings() throws RemoteopenhabException {
196         try {
197             String jsonResponse = executeGetUrl(getRestApiUrl("things"), "application/json", false);
198             if (jsonResponse.isEmpty()) {
199                 throw new RemoteopenhabException("JSON response is empty");
200             }
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);
205         }
206     }
207
208     public RemoteopenhabThing getRemoteThing(String uid) throws RemoteopenhabException {
209         try {
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");
214             }
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);
219         }
220     }
221
222     public @Nullable String getRestApiVersion() {
223         return restApiVersion;
224     }
225
226     private String getRestApiUrl(String endPoint) throws RemoteopenhabException {
227         String url = apiEndPointsUrls.get(endPoint);
228         return url != null ? url : getRestUrl() + "/" + endPoint;
229     }
230
231     public String getTopicNamespace() {
232         String namespace = topicNamespace;
233         return namespace != null ? namespace : "openhab";
234     }
235
236     public void start() {
237         synchronized (startStopLock) {
238             logger.debug("Opening EventSource");
239             reopenEventSource();
240             logger.debug("EventSource started");
241         }
242     }
243
244     public void stop() {
245         synchronized (startStopLock) {
246             logger.debug("Closing EventSource");
247             closeEventSource(0, TimeUnit.SECONDS);
248             logger.debug("EventSource stopped");
249             lastEventTimestamp = 0;
250         }
251     }
252
253     private SseEventSource createEventSource(String restSseUrl) {
254         Client client;
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() {
259                         @Override
260                         public boolean verify(@Nullable String hostname, @Nullable SSLSession session) {
261                             return true;
262                         }
263                     }).readTimeout(0, TimeUnit.SECONDS).register(new RemoteopenhabStreamingRequestFilter(accessToken))
264                     .build();
265         } else {
266             client = clientBuilder.readTimeout(0, TimeUnit.SECONDS)
267                     .register(new RemoteopenhabStreamingRequestFilter(accessToken)).build();
268         }
269         SseEventSource eventSource = eventSourceFactory.newSource(client.target(restSseUrl));
270         eventSource.register(this::onEvent, this::onError);
271         return eventSource;
272     }
273
274     private void reopenEventSource() {
275         logger.debug("Reopening EventSource");
276
277         String url;
278         try {
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());
283             return;
284         }
285
286         closeEventSource(10, TimeUnit.SECONDS);
287
288         logger.debug("Opening new EventSource {}", url);
289         SseEventSource localEventSource = createEventSource(url);
290         localEventSource.open();
291
292         eventSource = localEventSource;
293     }
294
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");
302             } else {
303                 logger.debug("Failed to close existing EventSource");
304             }
305             eventSource = null;
306         }
307         connected = false;
308     }
309
310     public boolean addStreamingDataListener(RemoteopenhabStreamingDataListener listener) {
311         return listeners.add(listener);
312     }
313
314     public boolean removeStreamingDataListener(RemoteopenhabStreamingDataListener listener) {
315         return listeners.remove(listener);
316     }
317
318     public boolean addItemsDataListener(RemoteopenhabItemsDataListener listener) {
319         return itemsListeners.add(listener);
320     }
321
322     public boolean removeItemsDataListener(RemoteopenhabItemsDataListener listener) {
323         return itemsListeners.remove(listener);
324     }
325
326     public boolean addThingsDataListener(RemoteopenhabThingsDataListener listener) {
327         return thingsListeners.add(listener);
328     }
329
330     public boolean removeThingsDataListener(RemoteopenhabThingsDataListener listener) {
331         return thingsListeners.remove(listener);
332     }
333
334     public long getLastEventTimestamp() {
335         return lastEventTimestamp;
336     }
337
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);
342
343         lastEventTimestamp = System.currentTimeMillis();
344         if (!connected) {
345             logger.debug("Connected to streaming events");
346             connected = true;
347             listeners.forEach(listener -> listener.onConnected());
348         }
349
350         if (!"message".equals(name)) {
351             logger.debug("Received unhandled event with name '{}' and data '{}'", name, data);
352             return;
353         }
354
355         try {
356             RemoteopenhabEvent event = jsonParser.fromJson(data, RemoteopenhabEvent.class);
357             String itemName;
358             String thingUID;
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));
368                     break;
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));
374                     break;
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));
380                     break;
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));
385                     break;
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));
390                     break;
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]));
396                     } else {
397                         logger.debug("Invalid payload for event type {} for topic {}", event.type, event.topic);
398                     }
399                     break;
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]));
406                     } else {
407                         logger.debug("Invalid payload for event type {} for topic {}", event.type, event.topic);
408                     }
409                     break;
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));
414                     break;
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));
419                     break;
420                 case "ChannelTriggeredEvent":
421                     RemoteopenhabChannelTriggerEvent triggerEvent = jsonParser.fromJson(event.payload,
422                             RemoteopenhabChannelTriggerEvent.class);
423                     thingsListeners
424                             .forEach(listener -> listener.onChannelTriggered(triggerEvent.channel, triggerEvent.event));
425                     break;
426                 case "ItemStatePredictedEvent":
427                 case "ItemCommandEvent":
428                 case "ThingStatusInfoEvent":
429                 case "ThingUpdatedEvent":
430                     logger.trace("Ignored event type {} for topic {}", event.type, event.topic);
431                     break;
432                 default:
433                     logger.debug("Unexpected event type {} for topic {}", event.type, event.topic);
434                     break;
435             }
436         } catch (RemoteopenhabException | JsonSyntaxException e) {
437             logger.debug("An exception occurred while processing the inbound '{}' event containg data: {}", name, data,
438                     e);
439         }
440     }
441
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"));
445     }
446
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);
454         }
455         return parts[2];
456     }
457
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);
465         }
466         return parts[2];
467     }
468
469     public String executeGetUrl(String url, String acceptHeader, boolean asyncReading) throws RemoteopenhabException {
470         return executeUrl(HttpMethod.GET, url, acceptHeader, null, null, asyncReading);
471     }
472
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);
477
478         request.header(HttpHeaders.ACCEPT, acceptHeader);
479         if (!accessToken.isEmpty()) {
480             request.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
481         }
482
483         if (content != null && (HttpMethod.POST.equals(httpMethod) || HttpMethod.PUT.equals(httpMethod))
484                 && contentType != null) {
485             try (final InputStreamContentProvider inputStreamContentProvider = new InputStreamContentProvider(
486                     content)) {
487                 request.content(inputStreamContentProvider, contentType);
488             }
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 }