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