]> git.basschouten.com Git - openhab-addons.git/blob
31f2102ff65e3cb66e28c333be9d05fe9f3005db
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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                 }
219                 request.header("X-Api-Key", config.getApiKey());
220             }
221
222             request.content(contentProvider);
223             ContentResponse contentResponse = request.send();
224             int status = contentResponse.getStatus();
225             if (logger.isTraceEnabled()) {
226                 logger.trace("<<< status:{}, {}", status, contentResponse.getContentAsString());
227             }
228
229             if (status != 200 && status != 204 && status != 201 && status != 202) {
230                 throw new GardenaException(String.format("Error %s %s, %s", status, contentResponse.getReason(),
231                         contentResponse.getContentAsString()), status);
232             }
233
234             if (result == null) {
235                 return (T) null;
236             }
237             return (T) gson.fromJson(contentResponse.getContentAsString(), result);
238         } catch (InterruptedException | TimeoutException | ExecutionException ex) {
239             throw new GardenaException(ex.getMessage(), ex);
240         }
241     }
242
243     /**
244      * Creates or refreshes the access token for the Gardena smart system.
245      */
246     private synchronized void verifyToken() throws GardenaException {
247         Fields fields = new Fields();
248         fields.add("client_id", config.getApiKey());
249
250         PostOAuth2Response token = this.token;
251         if (token == null || token.isRefreshTokenExpired()) {
252             // new token
253             logger.debug("Gardena API login using apiSecret, reason: {}",
254                     token == null ? "no token available" : "refresh token expired");
255             fields.add("grant_type", "client_credentials");
256             fields.add("client_secret", config.getApiSecret());
257             token = executeRequest(HttpMethod.POST, URL_API_TOKEN, fields, PostOAuth2Response.class);
258             token.postProcess();
259             this.token = token;
260         } else if (token.isAccessTokenExpired()) {
261             // refresh token
262             logger.debug("Gardena API login using refreshToken, reason: access token expired");
263             fields.add("grant_type", "refresh_token");
264             fields.add("refresh_token", token.refreshToken);
265             try {
266                 PostOAuth2Response tempToken = executeRequest(HttpMethod.POST, URL_API_TOKEN, fields,
267                         PostOAuth2Response.class);
268                 token.accessToken = tempToken.accessToken;
269                 token.expiresIn = tempToken.expiresIn;
270                 token.postProcess();
271                 this.token = token;
272             } catch (GardenaException ex) {
273                 // refresh token issue
274                 this.token = null;
275                 verifyToken();
276             }
277         } else {
278             logger.debug("Gardena API token valid");
279         }
280         logger.debug("{}", token.toString());
281     }
282
283     /**
284      * Loads all locations.
285      */
286     private LocationsResponse loadLocations() throws GardenaException {
287         return executeRequest(HttpMethod.GET, URL_API_LOCATIONS, null, LocationsResponse.class);
288     }
289
290     /**
291      * Loads all devices for a given location.
292      */
293     private LocationResponse loadLocation(String locationId) throws GardenaException {
294         return executeRequest(HttpMethod.GET, URL_API_LOCATIONS + "/" + locationId, null, LocationResponse.class);
295     }
296
297     /**
298      * Returns the websocket url for a given location.
299      */
300     private WebSocketCreatedResponse getWebsocketInfo(String locationId) throws GardenaException {
301         return executeRequest(HttpMethod.POST, URL_API_WEBSOCKET, new CreateWebSocketRequest(locationId),
302                 WebSocketCreatedResponse.class);
303     }
304
305     /**
306      * Stops the client.
307      */
308     @Override
309     public void dispose() {
310         logger.debug("Disposing GardenaSmart");
311         initialized = false;
312         synchronized (newDeviceTasksLock) {
313             for (ScheduledFuture<?> task : newDeviceTasks) {
314                 if (!task.isDone()) {
315                     task.cancel(true);
316                 }
317             }
318             newDeviceTasks.clear();
319         }
320         synchronized (deviceUpdateTaskLock) {
321             devicesToNotify.clear();
322             ScheduledFuture<?> task = deviceUpdateTask;
323             if (task != null) {
324                 task.cancel(true);
325             }
326             deviceUpdateTask = null;
327         }
328         stopWebsockets();
329         try {
330             httpClient.stop();
331             webSocketClient.stop();
332         } catch (Exception e) {
333             // ignore
334         }
335         httpClient.destroy();
336         webSocketClient.destroy();
337         allDevicesById.clear();
338         locationsResponse = null;
339     }
340
341     /**
342      * Restarts all websockets.
343      */
344     @Override
345     public synchronized void restartWebsockets() {
346         logger.debug("Restarting GardenaSmart Webservices");
347         stopWebsockets();
348         try {
349             startWebsockets();
350         } catch (Exception ex) {
351             // restart binding
352             if (logger.isDebugEnabled()) {
353                 logger.warn("Restarting GardenaSmart Webservices failed! Restarting binding", ex);
354             } else {
355                 logger.warn("Restarting GardenaSmart Webservices failed: {}! Restarting binding", ex.getMessage());
356             }
357             eventListener.onError();
358         }
359     }
360
361     /**
362      * Sets the dataItem from the websocket event into the correct device.
363      */
364     private void handleDataItem(final DataItem<?> dataItem) throws GardenaException {
365         final String deviceId = dataItem.getDeviceId();
366         Device device = allDevicesById.get(deviceId);
367         if (device == null && !(dataItem instanceof LocationDataItem)) {
368             device = new Device(deviceId);
369             allDevicesById.put(device.id, device);
370
371             synchronized (newDeviceTasksLock) {
372                 // remove prior completed tasks from the list
373                 newDeviceTasks.removeIf(task -> task.isDone());
374                 // add a new scheduled task to the list
375                 newDeviceTasks.add(scheduler.schedule(() -> {
376                     if (initialized) {
377                         Device newDevice = allDevicesById.get(deviceId);
378                         if (newDevice != null) {
379                             newDevice.evaluateDeviceType();
380                             if (newDevice.deviceType != null) {
381                                 eventListener.onNewDevice(newDevice);
382                             }
383                         }
384                     }
385                 }, 3, TimeUnit.SECONDS));
386             }
387         }
388
389         if (device != null) {
390             device.setDataItem(dataItem);
391         }
392     }
393
394     @Override
395     public void onWebSocketClose(String id) {
396         restartWebsocket(webSockets.get(id));
397     }
398
399     @Override
400     public void onWebSocketError(String id) {
401         restartWebsocket(webSockets.get(id));
402     }
403
404     private void restartWebsocket(@Nullable GardenaSmartWebSocket socket) {
405         synchronized (this) {
406             if (socket != null && !socket.isClosing()) {
407                 // close socket, if still open
408                 logger.info("Restarting GardenaSmart Webservice ({})", socket.getSocketID());
409                 socket.stop();
410             } else {
411                 // if socket is already closing, exit function and do not restart socket
412                 return;
413             }
414         }
415
416         try {
417             Thread.sleep(3000);
418             WebSocketCreatedResponse webSocketCreatedResponse = getWebsocketInfo(socket.getLocationID());
419             // only restart single socket, do not restart binding
420             WebSocket webSocketAttributes = webSocketCreatedResponse.data.attributes;
421             if (webSocketAttributes != null) {
422                 socket.restart(webSocketAttributes.url);
423             }
424         } catch (Exception ex) {
425             // restart binding on error
426             logger.warn("Restarting GardenaSmart Webservice failed ({}): {}, restarting binding", socket.getSocketID(),
427                     ex.getMessage());
428             eventListener.onError();
429         }
430     }
431
432     @Override
433     public void onWebSocketMessage(String msg) {
434         try {
435             DataItem<?> dataItem = gson.fromJson(msg, DataItem.class);
436             if (dataItem != null) {
437                 handleDataItem(dataItem);
438                 Device device = allDevicesById.get(dataItem.getDeviceId());
439                 if (device != null && device.active) {
440                     synchronized (deviceUpdateTaskLock) {
441                         devicesToNotify.add(device);
442
443                         // delay the deviceUpdated event to filter multiple events for the same device dataItem property
444                         ScheduledFuture<?> task = this.deviceUpdateTask;
445                         if (task == null || task.isDone()) {
446                             deviceUpdateTask = scheduler.schedule(() -> notifyDevicesUpdated(), 1, TimeUnit.SECONDS);
447                         }
448                     }
449                 }
450             }
451         } catch (GardenaException | JsonSyntaxException ex) {
452             logger.warn("Ignoring message: {}", ex.getMessage());
453         }
454     }
455
456     /**
457      * Helper scheduler task to update devices
458      */
459     private void notifyDevicesUpdated() {
460         synchronized (deviceUpdateTaskLock) {
461             if (initialized) {
462                 Iterator<Device> notifyIterator = devicesToNotify.iterator();
463                 while (notifyIterator.hasNext()) {
464                     eventListener.onDeviceUpdated(notifyIterator.next());
465                     notifyIterator.remove();
466                 }
467             }
468         }
469     }
470
471     @Override
472     public Device getDevice(String deviceId) throws GardenaDeviceNotFoundException {
473         Device device = allDevicesById.get(deviceId);
474         if (device == null) {
475             throw new GardenaDeviceNotFoundException("Device with id " + deviceId + " not found");
476         }
477         return device;
478     }
479
480     @Override
481     public void sendCommand(DataItem<?> dataItem, GardenaCommand gardenaCommand) throws GardenaException {
482         executeRequest(HttpMethod.PUT, URL_API_COMMAND + "/" + dataItem.id, new GardenaCommandRequest(gardenaCommand),
483                 null);
484     }
485
486     @Override
487     public String getId() {
488         return id;
489     }
490
491     @Override
492     public Collection<Device> getAllDevices() {
493         return allDevicesById.values();
494     }
495 }