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