]> git.basschouten.com Git - openhab-addons.git/blob
a6b7ca7ccd0d8505cd5ed9f68b3ac618c1a35a05
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.remoteopenhab.internal.rest;
14
15 import java.io.ByteArrayOutputStream;
16 import java.io.InputStream;
17 import java.nio.charset.StandardCharsets;
18 import java.util.Arrays;
19 import java.util.HashMap;
20 import java.util.List;
21 import java.util.Map;
22 import java.util.Objects;
23 import java.util.concurrent.CopyOnWriteArrayList;
24 import java.util.concurrent.TimeUnit;
25
26 import javax.net.ssl.HostnameVerifier;
27 import javax.net.ssl.SSLSession;
28 import javax.ws.rs.client.Client;
29 import javax.ws.rs.client.ClientBuilder;
30 import javax.ws.rs.core.HttpHeaders;
31 import javax.ws.rs.sse.InboundSseEvent;
32 import javax.ws.rs.sse.SseEventSource;
33
34 import org.eclipse.jdt.annotation.NonNullByDefault;
35 import org.eclipse.jdt.annotation.Nullable;
36 import org.eclipse.jetty.client.HttpClient;
37 import org.eclipse.jetty.client.api.ContentResponse;
38 import org.eclipse.jetty.client.api.Request;
39 import org.eclipse.jetty.client.api.Response;
40 import org.eclipse.jetty.client.util.InputStreamResponseListener;
41 import org.eclipse.jetty.client.util.StringContentProvider;
42 import org.eclipse.jetty.http.HttpMethod;
43 import org.eclipse.jetty.http.HttpStatus;
44 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabChannelTriggerEvent;
45 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabEvent;
46 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabEventPayload;
47 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabItem;
48 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabRestApi;
49 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabStatusInfo;
50 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabThing;
51 import org.openhab.binding.remoteopenhab.internal.exceptions.RemoteopenhabException;
52 import org.openhab.binding.remoteopenhab.internal.listener.RemoteopenhabItemsDataListener;
53 import org.openhab.binding.remoteopenhab.internal.listener.RemoteopenhabStreamingDataListener;
54 import org.openhab.binding.remoteopenhab.internal.listener.RemoteopenhabThingsDataListener;
55 import org.openhab.core.types.Command;
56 import org.osgi.service.jaxrs.client.SseEventSourceFactory;
57 import org.slf4j.Logger;
58 import org.slf4j.LoggerFactory;
59
60 import com.google.gson.Gson;
61 import com.google.gson.JsonSyntaxException;
62
63 /**
64  * A client to use the openHAB REST API and to receive/parse events received from the openHAB REST API Server-Sent
65  * Events (SSE).
66  *
67  * @author Laurent Garnier - Initial contribution
68  */
69 @NonNullByDefault
70 public class RemoteopenhabRestClient {
71
72     private static final int REQUEST_TIMEOUT = (int) TimeUnit.SECONDS.toMillis(30);
73
74     private final Logger logger = LoggerFactory.getLogger(RemoteopenhabRestClient.class);
75
76     private final ClientBuilder clientBuilder;
77     private final SseEventSourceFactory eventSourceFactory;
78     private final Gson jsonParser;
79
80     private final Object startStopLock = new Object();
81     private final List<RemoteopenhabStreamingDataListener> listeners = new CopyOnWriteArrayList<>();
82     private final List<RemoteopenhabItemsDataListener> itemsListeners = new CopyOnWriteArrayList<>();
83     private final List<RemoteopenhabThingsDataListener> thingsListeners = new CopyOnWriteArrayList<>();
84
85     private HttpClient httpClient;
86     private @Nullable String restUrl;
87     private @Nullable String restApiVersion;
88     private Map<String, @Nullable String> apiEndPointsUrls = new HashMap<>();
89     private @Nullable String topicNamespace;
90     private String accessToken;
91     private boolean trustedCertificate;
92     private boolean connected;
93
94     private @Nullable SseEventSource eventSource;
95     private long lastEventTimestamp;
96
97     public RemoteopenhabRestClient(final HttpClient httpClient, final ClientBuilder clientBuilder,
98             final SseEventSourceFactory eventSourceFactory, final Gson jsonParser) {
99         this.httpClient = httpClient;
100         this.clientBuilder = clientBuilder;
101         this.eventSourceFactory = eventSourceFactory;
102         this.jsonParser = jsonParser;
103         this.accessToken = "";
104     }
105
106     public void setHttpClient(HttpClient httpClient) {
107         this.httpClient = httpClient;
108     }
109
110     public String getRestUrl() throws RemoteopenhabException {
111         String url = restUrl;
112         if (url == null) {
113             throw new RemoteopenhabException("REST client not correctly setup");
114         }
115         return url;
116     }
117
118     public void setRestUrl(String restUrl) {
119         this.restUrl = restUrl;
120     }
121
122     public void setAccessToken(String accessToken) {
123         this.accessToken = accessToken;
124     }
125
126     public void setTrustedCertificate(boolean trustedCertificate) {
127         this.trustedCertificate = trustedCertificate;
128     }
129
130     public void tryApi() throws RemoteopenhabException {
131         try {
132             String jsonResponse = executeGetUrl(getRestUrl(), "application/json", false);
133             if (jsonResponse.isEmpty()) {
134                 throw new RemoteopenhabException("JSON response is empty");
135             }
136             RemoteopenhabRestApi restApi = jsonParser.fromJson(jsonResponse, RemoteopenhabRestApi.class);
137             restApiVersion = restApi.version;
138             logger.debug("REST API version = {}", restApiVersion);
139             apiEndPointsUrls.clear();
140             for (int i = 0; i < restApi.links.length; i++) {
141                 apiEndPointsUrls.put(restApi.links[i].type, restApi.links[i].url);
142             }
143             logger.debug("REST API items = {}", apiEndPointsUrls.get("items"));
144             logger.debug("REST API things = {}", apiEndPointsUrls.get("things"));
145             logger.debug("REST API events = {}", apiEndPointsUrls.get("events"));
146             topicNamespace = restApi.runtimeInfo != null ? "openhab" : "smarthome";
147             logger.debug("topic namespace = {}", topicNamespace);
148         } catch (RemoteopenhabException | JsonSyntaxException e) {
149             throw new RemoteopenhabException("Failed to execute the root REST API: " + e.getMessage(), e);
150         }
151     }
152
153     public List<RemoteopenhabItem> getRemoteItems(@Nullable String fields) throws RemoteopenhabException {
154         try {
155             String url = String.format("%s?recursive=false", getRestApiUrl("items"));
156             if (fields != null) {
157                 url += "&fields=" + fields;
158             }
159             boolean asyncReading = fields == null || Arrays.asList(fields.split(",")).contains("state");
160             String jsonResponse = executeGetUrl(url, "application/json", asyncReading);
161             if (jsonResponse.isEmpty()) {
162                 throw new RemoteopenhabException("JSON response is empty");
163             }
164             return Arrays.asList(jsonParser.fromJson(jsonResponse, RemoteopenhabItem[].class));
165         } catch (RemoteopenhabException | JsonSyntaxException e) {
166             throw new RemoteopenhabException(
167                     "Failed to get the list of remote items using the items REST API: " + e.getMessage(), e);
168         }
169     }
170
171     public String getRemoteItemState(String itemName) throws RemoteopenhabException {
172         try {
173             String url = String.format("%s/%s/state", getRestApiUrl("items"), itemName);
174             return executeGetUrl(url, "text/plain", true);
175         } catch (RemoteopenhabException e) {
176             throw new RemoteopenhabException("Failed to get the state of remote item " + itemName
177                     + " using the items REST API: " + e.getMessage(), e);
178         }
179     }
180
181     public void sendCommandToRemoteItem(String itemName, Command command) throws RemoteopenhabException {
182         try {
183             String url = String.format("%s/%s", getRestApiUrl("items"), itemName);
184             executeUrl(HttpMethod.POST, url, "application/json", command.toFullString(), "text/plain", false);
185         } catch (RemoteopenhabException 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 = executeGetUrl(getRestApiUrl("things"), "application/json", false);
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 = executeGetUrl(url, "application/json", false);
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.forEach(
363                             listener -> listener.onItemStateEvent(itemName, payload.type, payload.value, false));
364                     break;
365                 case "ItemStateChangedEvent":
366                     itemName = extractItemNameFromTopic(event.topic, event.type, "statechanged");
367                     payload = jsonParser.fromJson(event.payload, RemoteopenhabEventPayload.class);
368                     itemsListeners.forEach(
369                             listener -> listener.onItemStateEvent(itemName, payload.type, payload.value, true));
370                     break;
371                 case "GroupItemStateChangedEvent":
372                     itemName = extractItemNameFromTopic(event.topic, event.type, "statechanged");
373                     payload = jsonParser.fromJson(event.payload, RemoteopenhabEventPayload.class);
374                     itemsListeners.forEach(
375                             listener -> listener.onItemStateEvent(itemName, payload.type, payload.value, false));
376                     break;
377                 case "ItemAddedEvent":
378                     itemName = extractItemNameFromTopic(event.topic, event.type, "added");
379                     item = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabItem.class));
380                     itemsListeners.forEach(listener -> listener.onItemAdded(item));
381                     break;
382                 case "ItemRemovedEvent":
383                     itemName = extractItemNameFromTopic(event.topic, event.type, "removed");
384                     item = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabItem.class));
385                     itemsListeners.forEach(listener -> listener.onItemRemoved(item));
386                     break;
387                 case "ItemUpdatedEvent":
388                     itemName = extractItemNameFromTopic(event.topic, event.type, "updated");
389                     RemoteopenhabItem[] updItem = jsonParser.fromJson(event.payload, RemoteopenhabItem[].class);
390                     if (updItem.length == 2) {
391                         itemsListeners.forEach(listener -> listener.onItemUpdated(updItem[0], updItem[1]));
392                     } else {
393                         logger.debug("Invalid payload for event type {} for topic {}", event.type, event.topic);
394                     }
395                     break;
396                 case "ThingStatusInfoChangedEvent":
397                     thingUID = extractThingUIDFromTopic(event.topic, event.type, "statuschanged");
398                     RemoteopenhabStatusInfo[] updStatus = jsonParser.fromJson(event.payload,
399                             RemoteopenhabStatusInfo[].class);
400                     if (updStatus.length == 2) {
401                         thingsListeners.forEach(listener -> listener.onThingStatusUpdated(thingUID, updStatus[0]));
402                     } else {
403                         logger.debug("Invalid payload for event type {} for topic {}", event.type, event.topic);
404                     }
405                     break;
406                 case "ThingAddedEvent":
407                     thingUID = extractThingUIDFromTopic(event.topic, event.type, "added");
408                     thing = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabThing.class));
409                     thingsListeners.forEach(listener -> listener.onThingAdded(thing));
410                     break;
411                 case "ThingRemovedEvent":
412                     thingUID = extractThingUIDFromTopic(event.topic, event.type, "removed");
413                     thing = Objects.requireNonNull(jsonParser.fromJson(event.payload, RemoteopenhabThing.class));
414                     thingsListeners.forEach(listener -> listener.onThingRemoved(thing));
415                     break;
416                 case "ChannelTriggeredEvent":
417                     RemoteopenhabChannelTriggerEvent triggerEvent = jsonParser.fromJson(event.payload,
418                             RemoteopenhabChannelTriggerEvent.class);
419                     thingsListeners
420                             .forEach(listener -> listener.onChannelTriggered(triggerEvent.channel, triggerEvent.event));
421                     break;
422                 case "ItemStatePredictedEvent":
423                 case "ItemCommandEvent":
424                 case "ThingStatusInfoEvent":
425                 case "ThingUpdatedEvent":
426                     logger.trace("Ignored event type {} for topic {}", event.type, event.topic);
427                     break;
428                 default:
429                     logger.debug("Unexpected event type {} for topic {}", event.type, event.topic);
430                     break;
431             }
432         } catch (RemoteopenhabException | JsonSyntaxException e) {
433             logger.debug("An exception occurred while processing the inbound '{}' event containg data: {}", name, data,
434                     e);
435         }
436     }
437
438     private void onError(Throwable error) {
439         logger.debug("Error occurred while receiving events", error);
440         listeners.forEach(listener -> listener.onError("Error occurred while receiving events"));
441     }
442
443     private String extractItemNameFromTopic(String topic, String eventType, String finalPart)
444             throws RemoteopenhabException {
445         String[] parts = topic.split("/");
446         int expectedNbParts = "GroupItemStateChangedEvent".equals(eventType) ? 5 : 4;
447         if (parts.length != expectedNbParts || !getTopicNamespace().equals(parts[0]) || !"items".equals(parts[1])
448                 || !finalPart.equals(parts[parts.length - 1])) {
449             throw new RemoteopenhabException("Invalid event topic " + topic + " for event type " + eventType);
450         }
451         return parts[2];
452     }
453
454     private String extractThingUIDFromTopic(String topic, String eventType, String finalPart)
455             throws RemoteopenhabException {
456         String[] parts = topic.split("/");
457         int expectedNbParts = 4;
458         if (parts.length != expectedNbParts || !getTopicNamespace().equals(parts[0]) || !"things".equals(parts[1])
459                 || !finalPart.equals(parts[parts.length - 1])) {
460             throw new RemoteopenhabException("Invalid event topic " + topic + " for event type " + eventType);
461         }
462         return parts[2];
463     }
464
465     public String executeGetUrl(String url, String acceptHeader, boolean asyncReading) throws RemoteopenhabException {
466         return executeUrl(HttpMethod.GET, url, acceptHeader, null, null, asyncReading);
467     }
468
469     public String executeUrl(HttpMethod httpMethod, String url, String acceptHeader, @Nullable String content,
470             @Nullable String contentType, boolean asyncReading) throws RemoteopenhabException {
471         final Request request = httpClient.newRequest(url).method(httpMethod).timeout(REQUEST_TIMEOUT,
472                 TimeUnit.MILLISECONDS);
473
474         request.header(HttpHeaders.ACCEPT, acceptHeader);
475         if (!accessToken.isEmpty()) {
476             request.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
477         }
478
479         if (content != null && (HttpMethod.POST.equals(httpMethod) || HttpMethod.PUT.equals(httpMethod))
480                 && contentType != null) {
481             request.content(new StringContentProvider(content), contentType);
482         }
483
484         try {
485             if (asyncReading) {
486                 InputStreamResponseListener listener = new InputStreamResponseListener();
487                 request.send(listener);
488                 Response response = listener.get(5, TimeUnit.SECONDS);
489                 int statusCode = response.getStatus();
490                 if (statusCode != HttpStatus.OK_200) {
491                     response.abort(new Exception(response.getReason()));
492                     String statusLine = statusCode + " " + response.getReason();
493                     throw new RemoteopenhabException("HTTP call failed: " + statusLine);
494                 }
495                 ByteArrayOutputStream responseContent = new ByteArrayOutputStream();
496                 try (InputStream input = listener.getInputStream()) {
497                     input.transferTo(responseContent);
498                 }
499                 return new String(responseContent.toByteArray(), StandardCharsets.UTF_8.name());
500             } else {
501                 ContentResponse response = request.send();
502                 int statusCode = response.getStatus();
503                 if (statusCode >= HttpStatus.BAD_REQUEST_400) {
504                     String statusLine = statusCode + " " + response.getReason();
505                     throw new RemoteopenhabException("HTTP call failed: " + statusLine);
506                 }
507                 String encoding = response.getEncoding() != null ? response.getEncoding().replaceAll("\"", "").trim()
508                         : StandardCharsets.UTF_8.name();
509                 return new String(response.getContent(), encoding);
510             }
511         } catch (RemoteopenhabException e) {
512             throw e;
513         } catch (Exception e) {
514             throw new RemoteopenhabException(e);
515         }
516     }
517 }