]> git.basschouten.com Git - openhab-addons.git/blob
84ded57155eaff8da49bf7b505e3223d2e232cb9
[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.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;
22 import java.util.Map;
23 import java.util.Objects;
24 import java.util.concurrent.CopyOnWriteArrayList;
25 import java.util.concurrent.TimeUnit;
26
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;
34
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;
58
59 import com.google.gson.Gson;
60 import com.google.gson.JsonSyntaxException;
61
62 /**
63  * A client to use the openHAB REST API and to receive/parse events received from the openHAB REST API Server-Sent
64  * Events (SSE).
65  *
66  * @author Laurent Garnier - Initial contribution
67  */
68 @NonNullByDefault
69 public class RemoteopenhabRestClient {
70
71     private static final int REQUEST_TIMEOUT = (int) TimeUnit.SECONDS.toMillis(30);
72
73     private final Logger logger = LoggerFactory.getLogger(RemoteopenhabRestClient.class);
74
75     private final ClientBuilder clientBuilder;
76     private final SseEventSourceFactory eventSourceFactory;
77     private final Gson jsonParser;
78
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<>();
83
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;
92
93     private @Nullable SseEventSource eventSource;
94     private long lastEventTimestamp;
95
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 = "";
103     }
104
105     public void setHttpClient(HttpClient httpClient) {
106         this.httpClient = httpClient;
107     }
108
109     public String getRestUrl() throws RemoteopenhabException {
110         String url = restUrl;
111         if (url == null) {
112             throw new RemoteopenhabException("REST client not correctly setup");
113         }
114         return url;
115     }
116
117     public void setRestUrl(String restUrl) {
118         this.restUrl = restUrl;
119     }
120
121     public void setAccessToken(String accessToken) {
122         this.accessToken = accessToken;
123     }
124
125     public void setTrustedCertificate(boolean trustedCertificate) {
126         this.trustedCertificate = trustedCertificate;
127     }
128
129     public void tryApi() throws RemoteopenhabException {
130         try {
131             String jsonResponse = executeUrl(HttpMethod.GET, getRestUrl(), "application/json", null, null);
132             if (jsonResponse.isEmpty()) {
133                 throw new RemoteopenhabException("JSON response is empty");
134             }
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);
141             }
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);
149         }
150     }
151
152     public List<RemoteopenhabItem> getRemoteItems(@Nullable String fields) throws RemoteopenhabException {
153         try {
154             String url = String.format("%s?recursive=false", getRestApiUrl("items"));
155             if (fields != null) {
156                 url += "&fields=" + fields;
157             }
158             String jsonResponse = executeUrl(HttpMethod.GET, url, "application/json", null, null);
159             if (jsonResponse.isEmpty()) {
160                 throw new RemoteopenhabException("JSON response is empty");
161             }
162             return Arrays.asList(jsonParser.fromJson(jsonResponse, RemoteopenhabItem[].class));
163         } catch (RemoteopenhabException | JsonSyntaxException e) {
164             throw new RemoteopenhabException(
165                     "Failed to get the list of remote items using the items REST API: " + e.getMessage(), e);
166         }
167     }
168
169     public String getRemoteItemState(String itemName) throws RemoteopenhabException {
170         try {
171             String url = String.format("%s/%s/state", getRestApiUrl("items"), itemName);
172             return executeUrl(HttpMethod.GET, url, "text/plain", null, null);
173         } catch (RemoteopenhabException e) {
174             throw new RemoteopenhabException("Failed to get the state of remote item " + itemName
175                     + " using the items REST API: " + e.getMessage(), e);
176         }
177     }
178
179     public void sendCommandToRemoteItem(String itemName, Command command) throws RemoteopenhabException {
180         try {
181             String url = String.format("%s/%s", getRestApiUrl("items"), itemName);
182             InputStream stream = new ByteArrayInputStream(command.toFullString().getBytes(StandardCharsets.UTF_8));
183             executeUrl(HttpMethod.POST, url, "application/json", stream, "text/plain");
184             stream.close();
185         } catch (RemoteopenhabException | IOException e) {
186             throw new RemoteopenhabException("Failed to send command to the remote item " + itemName
187                     + " using the items REST API: " + e.getMessage(), e);
188         }
189     }
190
191     public List<RemoteopenhabThing> getRemoteThings() throws RemoteopenhabException {
192         try {
193             String jsonResponse = executeUrl(HttpMethod.GET, getRestApiUrl("things"), "application/json", null, null);
194             if (jsonResponse.isEmpty()) {
195                 throw new RemoteopenhabException("JSON response is empty");
196             }
197             return Arrays.asList(jsonParser.fromJson(jsonResponse, RemoteopenhabThing[].class));
198         } catch (RemoteopenhabException | JsonSyntaxException e) {
199             throw new RemoteopenhabException(
200                     "Failed to get the list of remote things using the things REST API: " + e.getMessage(), e);
201         }
202     }
203
204     public RemoteopenhabThing getRemoteThing(String uid) throws RemoteopenhabException {
205         try {
206             String url = String.format("%s/%s", getRestApiUrl("things"), uid);
207             String jsonResponse = executeUrl(HttpMethod.GET, url, "application/json", null, null);
208             if (jsonResponse.isEmpty()) {
209                 throw new RemoteopenhabException("JSON response is empty");
210             }
211             return Objects.requireNonNull(jsonParser.fromJson(jsonResponse, RemoteopenhabThing.class));
212         } catch (RemoteopenhabException | JsonSyntaxException e) {
213             throw new RemoteopenhabException(
214                     "Failed to get the remote thing " + uid + " using the things REST API: " + e.getMessage(), e);
215         }
216     }
217
218     public @Nullable String getRestApiVersion() {
219         return restApiVersion;
220     }
221
222     private String getRestApiUrl(String endPoint) throws RemoteopenhabException {
223         String url = apiEndPointsUrls.get(endPoint);
224         return url != null ? url : getRestUrl() + "/" + endPoint;
225     }
226
227     public String getTopicNamespace() {
228         String namespace = topicNamespace;
229         return namespace != null ? namespace : "openhab";
230     }
231
232     public void start() {
233         synchronized (startStopLock) {
234             logger.debug("Opening EventSource");
235             reopenEventSource();
236             logger.debug("EventSource started");
237         }
238     }
239
240     public void stop() {
241         synchronized (startStopLock) {
242             logger.debug("Closing EventSource");
243             closeEventSource(0, TimeUnit.SECONDS);
244             logger.debug("EventSource stopped");
245             lastEventTimestamp = 0;
246         }
247     }
248
249     private SseEventSource createEventSource(String restSseUrl) {
250         Client client;
251         // Avoid a timeout exception after 1 minute by setting the read timeout to 0 (infinite)
252         if (trustedCertificate) {
253             client = clientBuilder.sslContext(httpClient.getSslContextFactory().getSslContext())
254                     .hostnameVerifier(new HostnameVerifier() {
255                         @Override
256                         public boolean verify(@Nullable String hostname, @Nullable SSLSession session) {
257                             return true;
258                         }
259                     }).readTimeout(0, TimeUnit.SECONDS).register(new RemoteopenhabStreamingRequestFilter(accessToken))
260                     .build();
261         } else {
262             client = clientBuilder.readTimeout(0, TimeUnit.SECONDS)
263                     .register(new RemoteopenhabStreamingRequestFilter(accessToken)).build();
264         }
265         SseEventSource eventSource = eventSourceFactory.newSource(client.target(restSseUrl));
266         eventSource.register(this::onEvent, this::onError);
267         return eventSource;
268     }
269
270     private void reopenEventSource() {
271         logger.debug("Reopening EventSource");
272
273         String url;
274         try {
275             url = String.format("%s?topics=%s/items/*/*,%s/things/*/*,%s/channels/*/triggered", getRestApiUrl("events"),
276                     getTopicNamespace(), getTopicNamespace(), getTopicNamespace());
277         } catch (RemoteopenhabException e) {
278             logger.debug("{}", e.getMessage());
279             return;
280         }
281
282         closeEventSource(10, TimeUnit.SECONDS);
283
284         logger.debug("Opening new EventSource {}", url);
285         SseEventSource localEventSource = createEventSource(url);
286         localEventSource.open();
287
288         eventSource = localEventSource;
289     }
290
291     private void closeEventSource(long timeout, TimeUnit timeoutUnit) {
292         SseEventSource localEventSource = eventSource;
293         if (localEventSource != null) {
294             if (!localEventSource.isOpen()) {
295                 logger.debug("Existing EventSource is already closed");
296             } else if (localEventSource.close(timeout, timeoutUnit)) {
297                 logger.debug("Succesfully closed existing EventSource");
298             } else {
299                 logger.debug("Failed to close existing EventSource");
300             }
301             eventSource = null;
302         }
303         connected = false;
304     }
305
306     public boolean addStreamingDataListener(RemoteopenhabStreamingDataListener listener) {
307         return listeners.add(listener);
308     }
309
310     public boolean removeStreamingDataListener(RemoteopenhabStreamingDataListener listener) {
311         return listeners.remove(listener);
312     }
313
314     public boolean addItemsDataListener(RemoteopenhabItemsDataListener listener) {
315         return itemsListeners.add(listener);
316     }
317
318     public boolean removeItemsDataListener(RemoteopenhabItemsDataListener listener) {
319         return itemsListeners.remove(listener);
320     }
321
322     public boolean addThingsDataListener(RemoteopenhabThingsDataListener listener) {
323         return thingsListeners.add(listener);
324     }
325
326     public boolean removeThingsDataListener(RemoteopenhabThingsDataListener listener) {
327         return thingsListeners.remove(listener);
328     }
329
330     public long getLastEventTimestamp() {
331         return lastEventTimestamp;
332     }
333
334     private void onEvent(InboundSseEvent inboundEvent) {
335         String name = inboundEvent.getName();
336         String data = inboundEvent.readData();
337         logger.trace("Received event name {} date {}", name, data);
338
339         lastEventTimestamp = System.currentTimeMillis();
340         if (!connected) {
341             logger.debug("Connected to streaming events");
342             connected = true;
343             listeners.forEach(listener -> listener.onConnected());
344         }
345
346         if (!"message".equals(name)) {
347             logger.debug("Received unhandled event with name '{}' and data '{}'", name, data);
348             return;
349         }
350
351         try {
352             RemoteopenhabEvent event = jsonParser.fromJson(data, RemoteopenhabEvent.class);
353             String itemName;
354             String thingUID;
355             RemoteopenhabEventPayload payload;
356             RemoteopenhabItem item;
357             RemoteopenhabThing thing;
358             switch (event.type) {
359                 case "ItemStateEvent":
360                     itemName = extractItemNameFromTopic(event.topic, event.type, "state");
361                     payload = jsonParser.fromJson(event.payload, RemoteopenhabEventPayload.class);
362                     itemsListeners
363                             .forEach(listener -> listener.onItemStateEvent(itemName, payload.type, payload.value));
364                     break;
365                 case "GroupItemStateChangedEvent":
366                     itemName = extractItemNameFromTopic(event.topic, event.type, "statechanged");
367                     payload = jsonParser.fromJson(event.payload, RemoteopenhabEventPayload.class);
368                     itemsListeners
369                             .forEach(listener -> listener.onItemStateEvent(itemName, payload.type, payload.value));
370                     break;
371                 case "ItemAddedEvent":
372                     itemName = extractItemNameFromTopic(event.topic, event.type, "added");
373                     item = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabItem.class));
374                     itemsListeners.forEach(listener -> listener.onItemAdded(item));
375                     break;
376                 case "ItemRemovedEvent":
377                     itemName = extractItemNameFromTopic(event.topic, event.type, "removed");
378                     item = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabItem.class));
379                     itemsListeners.forEach(listener -> listener.onItemRemoved(item));
380                     break;
381                 case "ItemUpdatedEvent":
382                     itemName = extractItemNameFromTopic(event.topic, event.type, "updated");
383                     RemoteopenhabItem[] updItem = jsonParser.fromJson(event.payload, RemoteopenhabItem[].class);
384                     if (updItem.length == 2) {
385                         itemsListeners.forEach(listener -> listener.onItemUpdated(updItem[0], updItem[1]));
386                     } else {
387                         logger.debug("Invalid payload for event type {} for topic {}", event.type, event.topic);
388                     }
389                     break;
390                 case "ThingStatusInfoChangedEvent":
391                     thingUID = extractThingUIDFromTopic(event.topic, event.type, "statuschanged");
392                     RemoteopenhabStatusInfo[] updStatus = jsonParser.fromJson(event.payload,
393                             RemoteopenhabStatusInfo[].class);
394                     if (updStatus.length == 2) {
395                         thingsListeners.forEach(listener -> listener.onThingStatusUpdated(thingUID, updStatus[0]));
396                     } else {
397                         logger.debug("Invalid payload for event type {} for topic {}", event.type, event.topic);
398                     }
399                     break;
400                 case "ThingAddedEvent":
401                     thingUID = extractThingUIDFromTopic(event.topic, event.type, "added");
402                     thing = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabThing.class));
403                     thingsListeners.forEach(listener -> listener.onThingAdded(thing));
404                     break;
405                 case "ThingRemovedEvent":
406                     thingUID = extractThingUIDFromTopic(event.topic, event.type, "removed");
407                     thing = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabThing.class));
408                     thingsListeners.forEach(listener -> listener.onThingRemoved(thing));
409                     break;
410                 case "ChannelTriggeredEvent":
411                     RemoteopenhabChannelTriggerEvent triggerEvent = jsonParser.fromJson(event.payload,
412                             RemoteopenhabChannelTriggerEvent.class);
413                     thingsListeners
414                             .forEach(listener -> listener.onChannelTriggered(triggerEvent.channel, triggerEvent.event));
415                     break;
416                 case "ItemStatePredictedEvent":
417                 case "ItemStateChangedEvent":
418                 case "ItemCommandEvent":
419                 case "ThingStatusInfoEvent":
420                 case "ThingUpdatedEvent":
421                     logger.trace("Ignored event type {} for topic {}", event.type, event.topic);
422                     break;
423                 default:
424                     logger.debug("Unexpected event type {} for topic {}", event.type, event.topic);
425                     break;
426             }
427         } catch (RemoteopenhabException | JsonSyntaxException e) {
428             logger.debug("An exception occurred while processing the inbound '{}' event containg data: {}", name, data,
429                     e);
430         }
431     }
432
433     private void onError(Throwable error) {
434         logger.debug("Error occurred while receiving events", error);
435         listeners.forEach(listener -> listener.onError("Error occurred while receiving events"));
436     }
437
438     private String extractItemNameFromTopic(String topic, String eventType, String finalPart)
439             throws RemoteopenhabException {
440         String[] parts = topic.split("/");
441         int expectedNbParts = "GroupItemStateChangedEvent".equals(eventType) ? 5 : 4;
442         if (parts.length != expectedNbParts || !getTopicNamespace().equals(parts[0]) || !"items".equals(parts[1])
443                 || !finalPart.equals(parts[parts.length - 1])) {
444             throw new RemoteopenhabException("Invalid event topic " + topic + " for event type " + eventType);
445         }
446         return parts[2];
447     }
448
449     private String extractThingUIDFromTopic(String topic, String eventType, String finalPart)
450             throws RemoteopenhabException {
451         String[] parts = topic.split("/");
452         int expectedNbParts = 4;
453         if (parts.length != expectedNbParts || !getTopicNamespace().equals(parts[0]) || !"things".equals(parts[1])
454                 || !finalPart.equals(parts[parts.length - 1])) {
455             throw new RemoteopenhabException("Invalid event topic " + topic + " for event type " + eventType);
456         }
457         return parts[2];
458     }
459
460     public String executeUrl(HttpMethod httpMethod, String url, String acceptHeader, @Nullable InputStream content,
461             @Nullable String contentType) throws RemoteopenhabException {
462         final Request request = httpClient.newRequest(url).method(httpMethod).timeout(REQUEST_TIMEOUT,
463                 TimeUnit.MILLISECONDS);
464
465         request.header(HttpHeaders.ACCEPT, acceptHeader);
466         if (!accessToken.isEmpty()) {
467             request.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
468         }
469
470         if (content != null && (HttpMethod.POST.equals(httpMethod) || HttpMethod.PUT.equals(httpMethod))
471                 && contentType != null) {
472             try (final InputStreamContentProvider inputStreamContentProvider = new InputStreamContentProvider(
473                     content)) {
474                 request.content(inputStreamContentProvider, contentType);
475             }
476         }
477
478         try {
479             ContentResponse response = request.send();
480             int statusCode = response.getStatus();
481             if (statusCode >= HttpStatus.BAD_REQUEST_400) {
482                 String statusLine = statusCode + " " + response.getReason();
483                 throw new RemoteopenhabException("HTTP call failed: " + statusLine);
484             }
485             String encoding = response.getEncoding() != null ? response.getEncoding().replaceAll("\"", "").trim()
486                     : StandardCharsets.UTF_8.name();
487             return new String(response.getContent(), encoding);
488         } catch (Exception e) {
489             throw new RemoteopenhabException(e);
490         }
491     }
492 }