]> git.basschouten.com Git - openhab-addons.git/blob
a5db07d1abada8170705fcfb340420070972fadf
[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.openhab.binding.gardena.internal.config.GardenaConfig;
41 import org.openhab.binding.gardena.internal.exception.GardenaDeviceNotFoundException;
42 import org.openhab.binding.gardena.internal.exception.GardenaException;
43 import org.openhab.binding.gardena.internal.model.DataItemDeserializer;
44 import org.openhab.binding.gardena.internal.model.dto.Device;
45 import org.openhab.binding.gardena.internal.model.dto.api.CreateWebSocketRequest;
46 import org.openhab.binding.gardena.internal.model.dto.api.DataItem;
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.WebSocketCreatedResponse;
52 import org.openhab.binding.gardena.internal.model.dto.command.GardenaCommand;
53 import org.openhab.binding.gardena.internal.model.dto.command.GardenaCommandRequest;
54 import org.openhab.core.io.net.http.HttpClientFactory;
55 import org.openhab.core.io.net.http.WebSocketFactory;
56 import org.slf4j.Logger;
57 import org.slf4j.LoggerFactory;
58
59 import com.google.gson.Gson;
60 import com.google.gson.GsonBuilder;
61 import com.google.gson.JsonSyntaxException;
62
63 /**
64  * {@link GardenaSmart} implementation to access Gardena smart system.
65  *
66  * @author Gerhard Riegler - Initial contribution
67  */
68 @NonNullByDefault
69 public class GardenaSmartImpl implements GardenaSmart, GardenaSmartWebSocketListener {
70     private final Logger logger = LoggerFactory.getLogger(GardenaSmartImpl.class);
71
72     private Gson gson = new GsonBuilder().registerTypeAdapter(DataItem.class, new DataItemDeserializer()).create();
73
74     private static final String URL_API_HUSQUARNA = "https://api.authentication.husqvarnagroup.dev/v1";
75     private static final String URL_API_GARDENA = "https://api.smart.gardena.dev/v1";
76     private static final String URL_API_TOKEN = URL_API_HUSQUARNA + "/oauth2/token";
77     private static final String URL_API_WEBSOCKET = URL_API_GARDENA + "/websocket";
78     private static final String URL_API_LOCATIONS = URL_API_GARDENA + "/locations";
79     private static final String URL_API_COMMAND = URL_API_GARDENA + "/command";
80
81     private String id;
82     private GardenaConfig config;
83     private ScheduledExecutorService scheduler;
84
85     private Map<String, Device> allDevicesById = new HashMap<>();
86     private LocationsResponse locationsResponse;
87     private GardenaSmartEventListener eventListener;
88
89     private HttpClient httpClient;
90     private List<GardenaSmartWebSocket> webSockets = new ArrayList<>();
91     private @Nullable PostOAuth2Response token;
92     private boolean initialized = false;
93     private WebSocketFactory webSocketFactory;
94
95     private Set<Device> devicesToNotify = ConcurrentHashMap.newKeySet();
96     private @Nullable ScheduledFuture<?> deviceToNotifyFuture;
97     private @Nullable ScheduledFuture<?> newDeviceFuture;
98
99     public GardenaSmartImpl(String id, GardenaConfig config, GardenaSmartEventListener eventListener,
100             ScheduledExecutorService scheduler, HttpClientFactory httpClientFactory, WebSocketFactory webSocketFactory)
101             throws GardenaException {
102         this.id = id;
103         this.config = config;
104         this.eventListener = eventListener;
105         this.scheduler = scheduler;
106         this.webSocketFactory = webSocketFactory;
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             // initially load access token
116             verifyToken();
117             locationsResponse = loadLocations();
118
119             // assemble devices
120             for (LocationDataItem location : locationsResponse.data) {
121                 LocationResponse locationResponse = loadLocation(location.id);
122                 if (locationResponse.included != null) {
123                     for (DataItem<?> dataItem : locationResponse.included) {
124                         handleDataItem(dataItem);
125                     }
126                 }
127             }
128
129             for (Device device : allDevicesById.values()) {
130                 device.evaluateDeviceType();
131             }
132
133             startWebsockets();
134             initialized = true;
135         } catch (Exception ex) {
136             dispose();
137             throw new GardenaException(ex.getMessage(), ex);
138         }
139     }
140
141     /**
142      * Starts the websockets for each location.
143      */
144     private void startWebsockets() throws Exception {
145         for (LocationDataItem location : locationsResponse.data) {
146             WebSocketCreatedResponse webSocketCreatedResponse = getWebsocketInfo(location.id);
147             String socketId = id + "-" + location.attributes.name;
148             webSockets.add(new GardenaSmartWebSocket(this, webSocketCreatedResponse, config, scheduler,
149                     webSocketFactory, token, socketId));
150         }
151     }
152
153     /**
154      * Stops all websockets.
155      */
156     private void stopWebsockets() {
157         for (GardenaSmartWebSocket webSocket : webSockets) {
158             webSocket.stop();
159         }
160         webSockets.clear();
161     }
162
163     /**
164      * Communicates with Gardena smart home system and parses the result.
165      */
166     private <T> T executeRequest(HttpMethod method, String url, @Nullable Object content, @Nullable Class<T> result)
167             throws GardenaException {
168         try {
169             AbstractTypedContentProvider contentProvider = null;
170             String contentType = "application/vnd.api+json";
171             if (content != null) {
172                 if (content instanceof Fields) {
173                     contentProvider = new FormContentProvider((Fields) content);
174                     contentType = "application/x-www-form-urlencoded";
175                 } else {
176                     contentProvider = new StringContentProvider(gson.toJson(content));
177                 }
178             }
179
180             if (logger.isTraceEnabled()) {
181                 logger.trace(">>> {} {}, data: {}", method, url, content == null ? null : gson.toJson(content));
182             }
183
184             Request request = httpClient.newRequest(url).method(method).header(HttpHeader.CONTENT_TYPE, contentType)
185                     .header(HttpHeader.ACCEPT, "application/vnd.api+json").header(HttpHeader.ACCEPT_ENCODING, "gzip");
186
187             if (!URL_API_TOKEN.equals(url)) {
188                 verifyToken();
189                 final PostOAuth2Response token = this.token;
190                 if (token != null) {
191                     request.header("Authorization", token.tokenType + " " + token.accessToken);
192                     request.header("Authorization-provider", token.provider);
193                 }
194                 request.header("X-Api-Key", config.getApiKey());
195             }
196
197             request.content(contentProvider);
198             ContentResponse contentResponse = request.send();
199             int status = contentResponse.getStatus();
200             if (logger.isTraceEnabled()) {
201                 logger.trace("<<< status:{}, {}", status, contentResponse.getContentAsString());
202             }
203
204             if (status != 200 && status != 204 && status != 201 && status != 202) {
205                 throw new GardenaException(String.format("Error %s %s, %s", status, contentResponse.getReason(),
206                         contentResponse.getContentAsString()));
207             }
208
209             if (result == null) {
210                 return (T) null;
211             }
212             return (T) gson.fromJson(contentResponse.getContentAsString(), result);
213         } catch (InterruptedException | TimeoutException | ExecutionException ex) {
214             throw new GardenaException(ex.getMessage(), ex);
215         }
216     }
217
218     /**
219      * Creates or refreshes the access token for the Gardena smart system.
220      */
221     private synchronized void verifyToken() throws GardenaException {
222         Fields fields = new Fields();
223         fields.add("client_id", config.getApiKey());
224
225         PostOAuth2Response token = this.token;
226         if (token == null || token.isRefreshTokenExpired()) {
227             // new token
228             logger.debug("Gardena API login using password, reason: {}",
229                     token == null ? "no token available" : "refresh token expired");
230             fields.add("grant_type", "password");
231             fields.add("username", config.getEmail());
232             fields.add("password", config.getPassword());
233             token = executeRequest(HttpMethod.POST, URL_API_TOKEN, fields, PostOAuth2Response.class);
234             token.postProcess();
235             this.token = token;
236         } else if (token.isAccessTokenExpired()) {
237             // refresh token
238             logger.debug("Gardena API login using refreshToken, reason: access token expired");
239             fields.add("grant_type", "refresh_token");
240             fields.add("refresh_token", token.refreshToken);
241             try {
242                 PostOAuth2Response tempToken = executeRequest(HttpMethod.POST, URL_API_TOKEN, fields,
243                         PostOAuth2Response.class);
244                 token.accessToken = tempToken.accessToken;
245                 token.expiresIn = tempToken.expiresIn;
246                 token.postProcess();
247                 this.token = token;
248             } catch (GardenaException ex) {
249                 // refresh token issue
250                 this.token = null;
251                 verifyToken();
252             }
253         } else {
254             logger.debug("Gardena API token valid");
255         }
256         logger.debug("{}", token.toString());
257     }
258
259     /**
260      * Loads all locations.
261      */
262     private LocationsResponse loadLocations() throws GardenaException {
263         return executeRequest(HttpMethod.GET, URL_API_LOCATIONS, null, LocationsResponse.class);
264     }
265
266     /**
267      * Loads all devices for a given location.
268      */
269     private LocationResponse loadLocation(String locationId) throws GardenaException {
270         return executeRequest(HttpMethod.GET, URL_API_LOCATIONS + "/" + locationId, null, LocationResponse.class);
271     }
272
273     /**
274      * Returns the websocket url for a given location.
275      */
276     private WebSocketCreatedResponse getWebsocketInfo(String locationId) throws GardenaException {
277         return executeRequest(HttpMethod.POST, URL_API_WEBSOCKET, new CreateWebSocketRequest(locationId),
278                 WebSocketCreatedResponse.class);
279     }
280
281     /**
282      * Stops the client.
283      */
284     @Override
285     public void dispose() {
286         logger.debug("Disposing GardenaSmart");
287
288         final ScheduledFuture<?> newDeviceFuture = this.newDeviceFuture;
289         if (newDeviceFuture != null) {
290             newDeviceFuture.cancel(true);
291         }
292
293         final ScheduledFuture<?> deviceToNotifyFuture = this.deviceToNotifyFuture;
294         if (deviceToNotifyFuture != null) {
295             deviceToNotifyFuture.cancel(true);
296         }
297         stopWebsockets();
298         try {
299             httpClient.stop();
300         } catch (Exception e) {
301             // ignore
302         }
303         httpClient.destroy();
304         locationsResponse = new LocationsResponse();
305         allDevicesById.clear();
306         initialized = false;
307     }
308
309     /**
310      * Restarts all websockets.
311      */
312     @Override
313     public synchronized void restartWebsockets() {
314         logger.debug("Restarting GardenaSmart Webservice");
315         stopWebsockets();
316         try {
317             startWebsockets();
318         } catch (Exception ex) {
319             logger.warn("Restarting GardenaSmart Webservice failed: {}, restarting binding", ex.getMessage());
320             eventListener.onError();
321         }
322     }
323
324     /**
325      * Sets the dataItem from the websocket event into the correct device.
326      */
327     private void handleDataItem(final DataItem<?> dataItem) throws GardenaException {
328         final String deviceId = dataItem.getDeviceId();
329         Device device = allDevicesById.get(deviceId);
330         if (device == null && !(dataItem instanceof LocationDataItem)) {
331             device = new Device(deviceId);
332             allDevicesById.put(device.id, device);
333
334             if (initialized) {
335                 newDeviceFuture = scheduler.schedule(() -> {
336                     Device newDevice = allDevicesById.get(deviceId);
337                     if (newDevice != null) {
338                         newDevice.evaluateDeviceType();
339                         if (newDevice.deviceType != null) {
340                             eventListener.onNewDevice(newDevice);
341                         }
342                     }
343                 }, 3, TimeUnit.SECONDS);
344             }
345         }
346
347         if (device != null) {
348             device.setDataItem(dataItem);
349         }
350     }
351
352     @Override
353     public void onWebSocketClose() {
354         restartWebsockets();
355     }
356
357     @Override
358     public void onWebSocketError() {
359         eventListener.onError();
360     }
361
362     @Override
363     public void onWebSocketMessage(String msg) {
364         try {
365             DataItem<?> dataItem = gson.fromJson(msg, DataItem.class);
366             if (dataItem != null) {
367                 handleDataItem(dataItem);
368                 Device device = allDevicesById.get(dataItem.getDeviceId());
369                 if (device != null && device.active) {
370                     devicesToNotify.add(device);
371
372                     // delay the deviceUpdated event to filter multiple events for the same device dataItem property
373                     if (deviceToNotifyFuture == null) {
374                         deviceToNotifyFuture = scheduler.schedule(() -> {
375                             deviceToNotifyFuture = null;
376                             Iterator<Device> notifyIterator = devicesToNotify.iterator();
377                             while (notifyIterator.hasNext()) {
378                                 eventListener.onDeviceUpdated(notifyIterator.next());
379                                 notifyIterator.remove();
380                             }
381                         }, 1, TimeUnit.SECONDS);
382                     }
383                 }
384             }
385         } catch (GardenaException | JsonSyntaxException ex) {
386             logger.warn("Ignoring message: {}", ex.getMessage());
387         }
388     }
389
390     @Override
391     public Device getDevice(String deviceId) throws GardenaDeviceNotFoundException {
392         Device device = allDevicesById.get(deviceId);
393         if (device == null) {
394             throw new GardenaDeviceNotFoundException("Device with id " + deviceId + " not found");
395         }
396         return device;
397     }
398
399     @Override
400     public void sendCommand(DataItem<?> dataItem, GardenaCommand gardenaCommand) throws GardenaException {
401         executeRequest(HttpMethod.PUT, URL_API_COMMAND + "/" + dataItem.id, new GardenaCommandRequest(gardenaCommand),
402                 null);
403     }
404
405     @Override
406     public String getId() {
407         return id;
408     }
409
410     @Override
411     public Collection<Device> getAllDevices() {
412         return allDevicesById.values();
413     }
414 }