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