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