]> git.basschouten.com Git - openhab-addons.git/blob
2425df59508b29a2d8b54848da9ed87b1b2aa80e
[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 apiSecret, reason: {}",
245                     token == null ? "no token available" : "refresh token expired");
246             fields.add("grant_type", "client_credentials");
247             fields.add("client_secret", config.getApiSecret());
248             token = executeRequest(HttpMethod.POST, URL_API_TOKEN, fields, PostOAuth2Response.class);
249             token.postProcess();
250             this.token = token;
251         } else if (token.isAccessTokenExpired()) {
252             // refresh token
253             logger.debug("Gardena API login using refreshToken, reason: access token expired");
254             fields.add("grant_type", "refresh_token");
255             fields.add("refresh_token", token.refreshToken);
256             try {
257                 PostOAuth2Response tempToken = executeRequest(HttpMethod.POST, URL_API_TOKEN, fields,
258                         PostOAuth2Response.class);
259                 token.accessToken = tempToken.accessToken;
260                 token.expiresIn = tempToken.expiresIn;
261                 token.postProcess();
262                 this.token = token;
263             } catch (GardenaException ex) {
264                 // refresh token issue
265                 this.token = null;
266                 verifyToken();
267             }
268         } else {
269             logger.debug("Gardena API token valid");
270         }
271         logger.debug("{}", token.toString());
272     }
273
274     /**
275      * Loads all locations.
276      */
277     private LocationsResponse loadLocations() throws GardenaException {
278         return executeRequest(HttpMethod.GET, URL_API_LOCATIONS, null, LocationsResponse.class);
279     }
280
281     /**
282      * Loads all devices for a given location.
283      */
284     private LocationResponse loadLocation(String locationId) throws GardenaException {
285         return executeRequest(HttpMethod.GET, URL_API_LOCATIONS + "/" + locationId, null, LocationResponse.class);
286     }
287
288     /**
289      * Returns the websocket url for a given location.
290      */
291     private WebSocketCreatedResponse getWebsocketInfo(String locationId) throws GardenaException {
292         return executeRequest(HttpMethod.POST, URL_API_WEBSOCKET, new CreateWebSocketRequest(locationId),
293                 WebSocketCreatedResponse.class);
294     }
295
296     /**
297      * Stops the client.
298      */
299     @Override
300     public void dispose() {
301         logger.debug("Disposing GardenaSmart");
302
303         final ScheduledFuture<?> newDeviceFuture = this.newDeviceFuture;
304         if (newDeviceFuture != null) {
305             newDeviceFuture.cancel(true);
306         }
307
308         final ScheduledFuture<?> deviceToNotifyFuture = this.deviceToNotifyFuture;
309         if (deviceToNotifyFuture != null) {
310             deviceToNotifyFuture.cancel(true);
311         }
312         stopWebsockets();
313         try {
314             httpClient.stop();
315             webSocketClient.stop();
316         } catch (Exception e) {
317             // ignore
318         }
319         httpClient.destroy();
320         webSocketClient.destroy();
321         locationsResponse = new LocationsResponse();
322         allDevicesById.clear();
323         initialized = false;
324     }
325
326     /**
327      * Restarts all websockets.
328      */
329     @Override
330     public synchronized void restartWebsockets() {
331         logger.debug("Restarting GardenaSmart Webservices");
332         stopWebsockets();
333         try {
334             startWebsockets();
335         } catch (Exception ex) {
336             // restart binding
337             if (logger.isDebugEnabled()) {
338                 logger.warn("Restarting GardenaSmart Webservices failed! Restarting binding", ex);
339             } else {
340                 logger.warn("Restarting GardenaSmart Webservices failed: {}! Restarting binding", ex.getMessage());
341             }
342             eventListener.onError();
343         }
344     }
345
346     /**
347      * Sets the dataItem from the websocket event into the correct device.
348      */
349     private void handleDataItem(final DataItem<?> dataItem) throws GardenaException {
350         final String deviceId = dataItem.getDeviceId();
351         Device device = allDevicesById.get(deviceId);
352         if (device == null && !(dataItem instanceof LocationDataItem)) {
353             device = new Device(deviceId);
354             allDevicesById.put(device.id, device);
355
356             if (initialized) {
357                 newDeviceFuture = scheduler.schedule(() -> {
358                     Device newDevice = allDevicesById.get(deviceId);
359                     if (newDevice != null) {
360                         newDevice.evaluateDeviceType();
361                         if (newDevice.deviceType != null) {
362                             eventListener.onNewDevice(newDevice);
363                         }
364                     }
365                 }, 3, TimeUnit.SECONDS);
366             }
367         }
368
369         if (device != null) {
370             device.setDataItem(dataItem);
371         }
372     }
373
374     @Override
375     public void onWebSocketClose(String id) {
376         restartWebsocket(webSockets.get(id));
377     }
378
379     @Override
380     public void onWebSocketError(String id) {
381         restartWebsocket(webSockets.get(id));
382     }
383
384     private void restartWebsocket(@Nullable GardenaSmartWebSocket socket) {
385         synchronized (this) {
386             if (socket != null && !socket.isClosing()) {
387                 // close socket, if still open
388                 logger.info("Restarting GardenaSmart Webservice ({})", socket.getSocketID());
389                 socket.stop();
390             } else {
391                 // if socket is already closing, exit function and do not restart socket
392                 return;
393             }
394         }
395
396         try {
397             Thread.sleep(3000);
398             WebSocketCreatedResponse webSocketCreatedResponse = getWebsocketInfo(socket.getLocationID());
399             // only restart single socket, do not restart binding
400             WebSocket webSocketAttributes = webSocketCreatedResponse.data.attributes;
401             if (webSocketAttributes != null) {
402                 socket.restart(webSocketAttributes.url);
403             }
404         } catch (Exception ex) {
405             // restart binding on error
406             logger.warn("Restarting GardenaSmart Webservice failed ({}): {}, restarting binding", socket.getSocketID(),
407                     ex.getMessage());
408             eventListener.onError();
409         }
410     }
411
412     @Override
413     public void onWebSocketMessage(String msg) {
414         try {
415             DataItem<?> dataItem = gson.fromJson(msg, DataItem.class);
416             if (dataItem != null) {
417                 handleDataItem(dataItem);
418                 Device device = allDevicesById.get(dataItem.getDeviceId());
419                 if (device != null && device.active) {
420                     devicesToNotify.add(device);
421
422                     // delay the deviceUpdated event to filter multiple events for the same device dataItem property
423                     if (deviceToNotifyFuture == null) {
424                         deviceToNotifyFuture = scheduler.schedule(() -> {
425                             deviceToNotifyFuture = null;
426                             Iterator<Device> notifyIterator = devicesToNotify.iterator();
427                             while (notifyIterator.hasNext()) {
428                                 eventListener.onDeviceUpdated(notifyIterator.next());
429                                 notifyIterator.remove();
430                             }
431                         }, 1, TimeUnit.SECONDS);
432                     }
433                 }
434             }
435         } catch (GardenaException | JsonSyntaxException ex) {
436             logger.warn("Ignoring message: {}", ex.getMessage());
437         }
438     }
439
440     @Override
441     public Device getDevice(String deviceId) throws GardenaDeviceNotFoundException {
442         Device device = allDevicesById.get(deviceId);
443         if (device == null) {
444             throw new GardenaDeviceNotFoundException("Device with id " + deviceId + " not found");
445         }
446         return device;
447     }
448
449     @Override
450     public void sendCommand(DataItem<?> dataItem, GardenaCommand gardenaCommand) throws GardenaException {
451         executeRequest(HttpMethod.PUT, URL_API_COMMAND + "/" + dataItem.id, new GardenaCommandRequest(gardenaCommand),
452                 null);
453     }
454
455     @Override
456     public String getId() {
457         return id;
458     }
459
460     @Override
461     public Collection<Device> getAllDevices() {
462         return allDevicesById.values();
463     }
464 }