]> git.basschouten.com Git - openhab-addons.git/blob
edccf6a945da20ac047536e2e945d89d9d557e22
[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.gardena.internal;
14
15 import java.util.*;
16 import java.util.concurrent.*;
17
18 import org.eclipse.jdt.annotation.NonNullByDefault;
19 import org.eclipse.jdt.annotation.Nullable;
20 import org.eclipse.jetty.client.HttpClient;
21 import org.eclipse.jetty.client.api.ContentResponse;
22 import org.eclipse.jetty.client.api.Request;
23 import org.eclipse.jetty.client.util.AbstractTypedContentProvider;
24 import org.eclipse.jetty.client.util.FormContentProvider;
25 import org.eclipse.jetty.client.util.StringContentProvider;
26 import org.eclipse.jetty.http.HttpHeader;
27 import org.eclipse.jetty.http.HttpMethod;
28 import org.eclipse.jetty.util.Fields;
29 import org.openhab.binding.gardena.internal.config.GardenaConfig;
30 import org.openhab.binding.gardena.internal.exception.GardenaDeviceNotFoundException;
31 import org.openhab.binding.gardena.internal.exception.GardenaException;
32 import org.openhab.binding.gardena.internal.model.DataItemDeserializer;
33 import org.openhab.binding.gardena.internal.model.dto.Device;
34 import org.openhab.binding.gardena.internal.model.dto.api.*;
35 import org.openhab.binding.gardena.internal.model.dto.command.GardenaCommand;
36 import org.openhab.binding.gardena.internal.model.dto.command.GardenaCommandRequest;
37 import org.openhab.core.io.net.http.HttpClientFactory;
38 import org.openhab.core.io.net.http.WebSocketFactory;
39 import org.slf4j.Logger;
40 import org.slf4j.LoggerFactory;
41
42 import com.google.gson.Gson;
43 import com.google.gson.GsonBuilder;
44 import com.google.gson.JsonSyntaxException;
45
46 /**
47  * {@link GardenaSmart} implementation to access Gardena smart system.
48  *
49  * @author Gerhard Riegler - Initial contribution
50  */
51 @NonNullByDefault
52 public class GardenaSmartImpl implements GardenaSmart, GardenaSmartWebSocketListener {
53     private final Logger logger = LoggerFactory.getLogger(GardenaSmartImpl.class);
54
55     private Gson gson = new GsonBuilder().registerTypeAdapter(DataItem.class, new DataItemDeserializer()).create();
56
57     private static final String URL_API_HUSQUARNA = "https://api.authentication.husqvarnagroup.dev/v1";
58     private static final String URL_API_GARDENA = "https://api.smart.gardena.dev/v1";
59     private static final String URL_API_TOKEN = URL_API_HUSQUARNA + "/oauth2/token";
60     private static final String URL_API_WEBSOCKET = URL_API_GARDENA + "/websocket";
61     private static final String URL_API_LOCATIONS = URL_API_GARDENA + "/locations";
62     private static final String URL_API_COMMAND = URL_API_GARDENA + "/command";
63
64     private String id;
65     private GardenaConfig config;
66     private ScheduledExecutorService scheduler;
67
68     private Map<String, Device> allDevicesById = new HashMap<>();
69     private LocationsResponse locationsResponse;
70     private GardenaSmartEventListener eventListener;
71
72     private HttpClient httpClient;
73     private List<GardenaSmartWebSocket> webSockets = new ArrayList<>();
74     private @Nullable PostOAuth2Response token;
75     private boolean initialized = false;
76     private WebSocketFactory webSocketFactory;
77
78     private Set<Device> devicesToNotify = ConcurrentHashMap.newKeySet();
79     private @Nullable ScheduledFuture<?> deviceToNotifyFuture;
80     private @Nullable ScheduledFuture<?> newDeviceFuture;
81
82     public GardenaSmartImpl(String id, GardenaConfig config, GardenaSmartEventListener eventListener,
83             ScheduledExecutorService scheduler, HttpClientFactory httpClientFactory, WebSocketFactory webSocketFactory)
84             throws GardenaException {
85         this.id = id;
86         this.config = config;
87         this.eventListener = eventListener;
88         this.scheduler = scheduler;
89         this.webSocketFactory = webSocketFactory;
90
91         logger.debug("Starting GardenaSmart");
92         try {
93             httpClient = httpClientFactory.createHttpClient(id);
94             httpClient.setConnectTimeout(config.getConnectionTimeout() * 1000L);
95             httpClient.setIdleTimeout(httpClient.getConnectTimeout());
96             httpClient.start();
97
98             // initially load access token
99             verifyToken();
100             locationsResponse = loadLocations();
101
102             // assemble devices
103             for (LocationDataItem location : locationsResponse.data) {
104                 LocationResponse locationResponse = loadLocation(location.id);
105                 if (locationResponse.included != null) {
106                     for (DataItem<?> dataItem : locationResponse.included) {
107                         handleDataItem(dataItem);
108                     }
109                 }
110             }
111
112             for (Device device : allDevicesById.values()) {
113                 device.evaluateDeviceType();
114             }
115
116             startWebsockets();
117             initialized = true;
118         } catch (Exception ex) {
119             throw new GardenaException(ex.getMessage(), ex);
120         }
121     }
122
123     /**
124      * Starts the websockets for each location.
125      */
126     private void startWebsockets() throws Exception {
127         for (LocationDataItem location : locationsResponse.data) {
128             WebSocketCreatedResponse webSocketCreatedResponse = getWebsocketInfo(location.id);
129             String socketId = id + "-" + location.attributes.name;
130             webSockets.add(new GardenaSmartWebSocket(this, webSocketCreatedResponse, config, scheduler,
131                     webSocketFactory, token, socketId));
132         }
133     }
134
135     /**
136      * Stops all websockets.
137      */
138     private void stopWebsockets() {
139         for (GardenaSmartWebSocket webSocket : webSockets) {
140             webSocket.stop();
141         }
142         webSockets.clear();
143     }
144
145     /**
146      * Communicates with Gardena smart home system and parses the result.
147      */
148     private <T> T executeRequest(HttpMethod method, String url, @Nullable Object content, @Nullable Class<T> result)
149             throws GardenaException {
150         try {
151             AbstractTypedContentProvider contentProvider = null;
152             String contentType = "application/vnd.api+json";
153             if (content != null) {
154                 if (content instanceof Fields) {
155                     contentProvider = new FormContentProvider((Fields) content);
156                     contentType = "application/x-www-form-urlencoded";
157                 } else {
158                     contentProvider = new StringContentProvider(gson.toJson(content));
159                 }
160             }
161
162             if (logger.isTraceEnabled()) {
163                 logger.trace(">>> {} {}, data: {}", method, url, content == null ? null : gson.toJson(content));
164             }
165
166             Request request = httpClient.newRequest(url).method(method).header(HttpHeader.CONTENT_TYPE, contentType)
167                     .header(HttpHeader.ACCEPT, "application/vnd.api+json").header(HttpHeader.ACCEPT_ENCODING, "gzip");
168
169             if (!URL_API_TOKEN.equals(url)) {
170                 verifyToken();
171                 final PostOAuth2Response token = this.token;
172                 if (token != null) {
173                     request.header("Authorization", token.tokenType + " " + token.accessToken);
174                     request.header("Authorization-provider", token.provider);
175                 }
176                 request.header("X-Api-Key", config.getApiKey());
177             }
178
179             request.content(contentProvider);
180             ContentResponse contentResponse = request.send();
181             int status = contentResponse.getStatus();
182             if (logger.isTraceEnabled()) {
183                 logger.trace("<<< status:{}, {}", status, contentResponse.getContentAsString());
184             }
185
186             if (status != 200 && status != 204 && status != 201 && status != 202) {
187                 throw new GardenaException(String.format("Error %s %s, %s", status, contentResponse.getReason(),
188                         contentResponse.getContentAsString()));
189             }
190
191             if (result == null) {
192                 return (T) null;
193             }
194             return (T) gson.fromJson(contentResponse.getContentAsString(), result);
195         } catch (InterruptedException | TimeoutException | ExecutionException ex) {
196             throw new GardenaException(ex.getMessage(), ex);
197         }
198     }
199
200     /**
201      * Creates or refreshes the access token for the Gardena smart system.
202      */
203     private synchronized void verifyToken() throws GardenaException {
204         Fields fields = new Fields();
205         fields.add("client_id", config.getApiKey());
206
207         PostOAuth2Response token = this.token;
208         if (token == null || token.isRefreshTokenExpired()) {
209             // new token
210             logger.debug("Gardena API login using password, reason: {}",
211                     token == null ? "no token available" : "refresh token expired");
212             fields.add("grant_type", "password");
213             fields.add("username", config.getEmail());
214             fields.add("password", config.getPassword());
215             token = executeRequest(HttpMethod.POST, URL_API_TOKEN, fields, PostOAuth2Response.class);
216             token.postProcess();
217             this.token = token;
218         } else if (token.isAccessTokenExpired()) {
219             // refresh token
220             logger.debug("Gardena API login using refreshToken, reason: access token expired");
221             fields.add("grant_type", "refresh_token");
222             fields.add("refresh_token", token.refreshToken);
223             try {
224                 PostOAuth2Response tempToken = executeRequest(HttpMethod.POST, URL_API_TOKEN, fields,
225                         PostOAuth2Response.class);
226                 token.accessToken = tempToken.accessToken;
227                 token.expiresIn = tempToken.expiresIn;
228                 token.postProcess();
229                 this.token = token;
230             } catch (GardenaException ex) {
231                 // refresh token issue
232                 this.token = null;
233                 verifyToken();
234             }
235         } else {
236             logger.debug("Gardena API token valid");
237         }
238         logger.debug("{}", token.toString());
239     }
240
241     /**
242      * Loads all locations.
243      */
244     private LocationsResponse loadLocations() throws GardenaException {
245         return executeRequest(HttpMethod.GET, URL_API_LOCATIONS, null, LocationsResponse.class);
246     }
247
248     /**
249      * Loads all devices for a given location.
250      */
251     private LocationResponse loadLocation(String locationId) throws GardenaException {
252         return executeRequest(HttpMethod.GET, URL_API_LOCATIONS + "/" + locationId, null, LocationResponse.class);
253     }
254
255     /**
256      * Returns the websocket url for a given location.
257      */
258     private WebSocketCreatedResponse getWebsocketInfo(String locationId) throws GardenaException {
259         return executeRequest(HttpMethod.POST, URL_API_WEBSOCKET, new CreateWebSocketRequest(locationId),
260                 WebSocketCreatedResponse.class);
261     }
262
263     /**
264      * Stops the client.
265      */
266     public void dispose() {
267         logger.debug("Disposing GardenaSmart");
268
269         final ScheduledFuture<?> newDeviceFuture = this.newDeviceFuture;
270         if (newDeviceFuture != null) {
271             newDeviceFuture.cancel(true);
272         }
273
274         final ScheduledFuture<?> deviceToNotifyFuture = this.deviceToNotifyFuture;
275         if (deviceToNotifyFuture != null) {
276             deviceToNotifyFuture.cancel(true);
277         }
278         stopWebsockets();
279         try {
280             httpClient.stop();
281         } catch (Exception e) {
282             // ignore
283         }
284         httpClient.destroy();
285         locationsResponse = new LocationsResponse();
286         allDevicesById.clear();
287         initialized = false;
288     }
289
290     /**
291      * Restarts all websockets.
292      */
293     @Override
294     public synchronized void restartWebsockets() {
295         logger.debug("Restarting GardenaSmart Webservice");
296         stopWebsockets();
297         try {
298             startWebsockets();
299         } catch (Exception ex) {
300             logger.warn("Restarting GardenaSmart Webservice failed: {}, restarting binding", ex.getMessage());
301             eventListener.onError();
302         }
303     }
304
305     /**
306      * Sets the dataItem from the websocket event into the correct device.
307      */
308     private void handleDataItem(final DataItem<?> dataItem) throws GardenaException {
309         final String deviceId = dataItem.getDeviceId();
310         Device device = allDevicesById.get(deviceId);
311         if (device == null && !(dataItem instanceof LocationDataItem)) {
312             device = new Device(deviceId);
313             allDevicesById.put(device.id, device);
314
315             if (initialized) {
316                 newDeviceFuture = scheduler.schedule(() -> {
317                     Device newDevice = allDevicesById.get(deviceId);
318                     if (newDevice != null) {
319                         newDevice.evaluateDeviceType();
320                         if (newDevice.deviceType != null) {
321                             eventListener.onNewDevice(newDevice);
322                         }
323                     }
324                 }, 3, TimeUnit.SECONDS);
325             }
326         }
327
328         if (device != null) {
329             device.setDataItem(dataItem);
330         }
331     }
332
333     @Override
334     public void onWebSocketClose() {
335         restartWebsockets();
336     }
337
338     @Override
339     public void onWebSocketError() {
340         eventListener.onError();
341     }
342
343     @Override
344     public void onWebSocketMessage(String msg) {
345         try {
346             DataItem<?> dataItem = gson.fromJson(msg, DataItem.class);
347             if (dataItem != null) {
348                 handleDataItem(dataItem);
349                 Device device = allDevicesById.get(dataItem.getDeviceId());
350                 if (device != null && device.active) {
351                     devicesToNotify.add(device);
352
353                     // delay the deviceUpdated event to filter multiple events for the same device dataItem property
354                     if (deviceToNotifyFuture == null) {
355                         deviceToNotifyFuture = scheduler.schedule(() -> {
356                             deviceToNotifyFuture = null;
357                             Iterator<Device> notifyIterator = devicesToNotify.iterator();
358                             while (notifyIterator.hasNext()) {
359                                 eventListener.onDeviceUpdated(notifyIterator.next());
360                                 notifyIterator.remove();
361                             }
362                         }, 1, TimeUnit.SECONDS);
363                     }
364                 }
365             }
366         } catch (GardenaException | JsonSyntaxException ex) {
367             logger.warn("Ignoring message: {}", ex.getMessage());
368         }
369     }
370
371     @Override
372     public Device getDevice(String deviceId) throws GardenaDeviceNotFoundException {
373         Device device = allDevicesById.get(deviceId);
374         if (device == null) {
375             throw new GardenaDeviceNotFoundException("Device with id " + deviceId + " not found");
376         }
377         return device;
378     }
379
380     @Override
381     public void sendCommand(DataItem<?> dataItem, GardenaCommand gardenaCommand) throws GardenaException {
382         executeRequest(HttpMethod.PUT, URL_API_COMMAND + "/" + dataItem.id, new GardenaCommandRequest(gardenaCommand),
383                 null);
384     }
385
386     @Override
387     public String getId() {
388         return id;
389     }
390
391     @Override
392     public Collection<Device> getAllDevices() {
393         return allDevicesById.values();
394     }
395 }