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