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