]> git.basschouten.com Git - openhab-addons.git/blob
cc6d38e78dc92472280ce4a160c2d42eceff7802
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 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             throw new GardenaException(ex.getMessage(), ex);
137         }
138     }
139
140     /**
141      * Starts the websockets for each location.
142      */
143     private void startWebsockets() throws Exception {
144         for (LocationDataItem location : locationsResponse.data) {
145             WebSocketCreatedResponse webSocketCreatedResponse = getWebsocketInfo(location.id);
146             String socketId = id + "-" + location.attributes.name;
147             webSockets.add(new GardenaSmartWebSocket(this, webSocketCreatedResponse, config, scheduler,
148                     webSocketFactory, token, socketId));
149         }
150     }
151
152     /**
153      * Stops all websockets.
154      */
155     private void stopWebsockets() {
156         for (GardenaSmartWebSocket webSocket : webSockets) {
157             webSocket.stop();
158         }
159         webSockets.clear();
160     }
161
162     /**
163      * Communicates with Gardena smart home system and parses the result.
164      */
165     private <T> T executeRequest(HttpMethod method, String url, @Nullable Object content, @Nullable Class<T> result)
166             throws GardenaException {
167         try {
168             AbstractTypedContentProvider contentProvider = null;
169             String contentType = "application/vnd.api+json";
170             if (content != null) {
171                 if (content instanceof Fields) {
172                     contentProvider = new FormContentProvider((Fields) content);
173                     contentType = "application/x-www-form-urlencoded";
174                 } else {
175                     contentProvider = new StringContentProvider(gson.toJson(content));
176                 }
177             }
178
179             if (logger.isTraceEnabled()) {
180                 logger.trace(">>> {} {}, data: {}", method, url, content == null ? null : gson.toJson(content));
181             }
182
183             Request request = httpClient.newRequest(url).method(method).header(HttpHeader.CONTENT_TYPE, contentType)
184                     .header(HttpHeader.ACCEPT, "application/vnd.api+json").header(HttpHeader.ACCEPT_ENCODING, "gzip");
185
186             if (!URL_API_TOKEN.equals(url)) {
187                 verifyToken();
188                 final PostOAuth2Response token = this.token;
189                 if (token != null) {
190                     request.header("Authorization", token.tokenType + " " + token.accessToken);
191                     request.header("Authorization-provider", token.provider);
192                 }
193                 request.header("X-Api-Key", config.getApiKey());
194             }
195
196             request.content(contentProvider);
197             ContentResponse contentResponse = request.send();
198             int status = contentResponse.getStatus();
199             if (logger.isTraceEnabled()) {
200                 logger.trace("<<< status:{}, {}", status, contentResponse.getContentAsString());
201             }
202
203             if (status != 200 && status != 204 && status != 201 && status != 202) {
204                 throw new GardenaException(String.format("Error %s %s, %s", status, contentResponse.getReason(),
205                         contentResponse.getContentAsString()));
206             }
207
208             if (result == null) {
209                 return (T) null;
210             }
211             return (T) gson.fromJson(contentResponse.getContentAsString(), result);
212         } catch (InterruptedException | TimeoutException | ExecutionException ex) {
213             throw new GardenaException(ex.getMessage(), ex);
214         }
215     }
216
217     /**
218      * Creates or refreshes the access token for the Gardena smart system.
219      */
220     private synchronized void verifyToken() throws GardenaException {
221         Fields fields = new Fields();
222         fields.add("client_id", config.getApiKey());
223
224         PostOAuth2Response token = this.token;
225         if (token == null || token.isRefreshTokenExpired()) {
226             // new token
227             logger.debug("Gardena API login using password, reason: {}",
228                     token == null ? "no token available" : "refresh token expired");
229             fields.add("grant_type", "password");
230             fields.add("username", config.getEmail());
231             fields.add("password", config.getPassword());
232             token = executeRequest(HttpMethod.POST, URL_API_TOKEN, fields, PostOAuth2Response.class);
233             token.postProcess();
234             this.token = token;
235         } else if (token.isAccessTokenExpired()) {
236             // refresh token
237             logger.debug("Gardena API login using refreshToken, reason: access token expired");
238             fields.add("grant_type", "refresh_token");
239             fields.add("refresh_token", token.refreshToken);
240             try {
241                 PostOAuth2Response tempToken = executeRequest(HttpMethod.POST, URL_API_TOKEN, fields,
242                         PostOAuth2Response.class);
243                 token.accessToken = tempToken.accessToken;
244                 token.expiresIn = tempToken.expiresIn;
245                 token.postProcess();
246                 this.token = token;
247             } catch (GardenaException ex) {
248                 // refresh token issue
249                 this.token = null;
250                 verifyToken();
251             }
252         } else {
253             logger.debug("Gardena API token valid");
254         }
255         logger.debug("{}", token.toString());
256     }
257
258     /**
259      * Loads all locations.
260      */
261     private LocationsResponse loadLocations() throws GardenaException {
262         return executeRequest(HttpMethod.GET, URL_API_LOCATIONS, null, LocationsResponse.class);
263     }
264
265     /**
266      * Loads all devices for a given location.
267      */
268     private LocationResponse loadLocation(String locationId) throws GardenaException {
269         return executeRequest(HttpMethod.GET, URL_API_LOCATIONS + "/" + locationId, null, LocationResponse.class);
270     }
271
272     /**
273      * Returns the websocket url for a given location.
274      */
275     private WebSocketCreatedResponse getWebsocketInfo(String locationId) throws GardenaException {
276         return executeRequest(HttpMethod.POST, URL_API_WEBSOCKET, new CreateWebSocketRequest(locationId),
277                 WebSocketCreatedResponse.class);
278     }
279
280     /**
281      * Stops the client.
282      */
283     @Override
284     public void dispose() {
285         logger.debug("Disposing GardenaSmart");
286
287         final ScheduledFuture<?> newDeviceFuture = this.newDeviceFuture;
288         if (newDeviceFuture != null) {
289             newDeviceFuture.cancel(true);
290         }
291
292         final ScheduledFuture<?> deviceToNotifyFuture = this.deviceToNotifyFuture;
293         if (deviceToNotifyFuture != null) {
294             deviceToNotifyFuture.cancel(true);
295         }
296         stopWebsockets();
297         try {
298             httpClient.stop();
299         } catch (Exception e) {
300             // ignore
301         }
302         httpClient.destroy();
303         locationsResponse = new LocationsResponse();
304         allDevicesById.clear();
305         initialized = false;
306     }
307
308     /**
309      * Restarts all websockets.
310      */
311     @Override
312     public synchronized void restartWebsockets() {
313         logger.debug("Restarting GardenaSmart Webservice");
314         stopWebsockets();
315         try {
316             startWebsockets();
317         } catch (Exception ex) {
318             logger.warn("Restarting GardenaSmart Webservice failed: {}, restarting binding", ex.getMessage());
319             eventListener.onError();
320         }
321     }
322
323     /**
324      * Sets the dataItem from the websocket event into the correct device.
325      */
326     private void handleDataItem(final DataItem<?> dataItem) throws GardenaException {
327         final String deviceId = dataItem.getDeviceId();
328         Device device = allDevicesById.get(deviceId);
329         if (device == null && !(dataItem instanceof LocationDataItem)) {
330             device = new Device(deviceId);
331             allDevicesById.put(device.id, device);
332
333             if (initialized) {
334                 newDeviceFuture = scheduler.schedule(() -> {
335                     Device newDevice = allDevicesById.get(deviceId);
336                     if (newDevice != null) {
337                         newDevice.evaluateDeviceType();
338                         if (newDevice.deviceType != null) {
339                             eventListener.onNewDevice(newDevice);
340                         }
341                     }
342                 }, 3, TimeUnit.SECONDS);
343             }
344         }
345
346         if (device != null) {
347             device.setDataItem(dataItem);
348         }
349     }
350
351     @Override
352     public void onWebSocketClose() {
353         restartWebsockets();
354     }
355
356     @Override
357     public void onWebSocketError() {
358         eventListener.onError();
359     }
360
361     @Override
362     public void onWebSocketMessage(String msg) {
363         try {
364             DataItem<?> dataItem = gson.fromJson(msg, DataItem.class);
365             if (dataItem != null) {
366                 handleDataItem(dataItem);
367                 Device device = allDevicesById.get(dataItem.getDeviceId());
368                 if (device != null && device.active) {
369                     devicesToNotify.add(device);
370
371                     // delay the deviceUpdated event to filter multiple events for the same device dataItem property
372                     if (deviceToNotifyFuture == null) {
373                         deviceToNotifyFuture = scheduler.schedule(() -> {
374                             deviceToNotifyFuture = null;
375                             Iterator<Device> notifyIterator = devicesToNotify.iterator();
376                             while (notifyIterator.hasNext()) {
377                                 eventListener.onDeviceUpdated(notifyIterator.next());
378                                 notifyIterator.remove();
379                             }
380                         }, 1, TimeUnit.SECONDS);
381                     }
382                 }
383             }
384         } catch (GardenaException | JsonSyntaxException ex) {
385             logger.warn("Ignoring message: {}", ex.getMessage());
386         }
387     }
388
389     @Override
390     public Device getDevice(String deviceId) throws GardenaDeviceNotFoundException {
391         Device device = allDevicesById.get(deviceId);
392         if (device == null) {
393             throw new GardenaDeviceNotFoundException("Device with id " + deviceId + " not found");
394         }
395         return device;
396     }
397
398     @Override
399     public void sendCommand(DataItem<?> dataItem, GardenaCommand gardenaCommand) throws GardenaException {
400         executeRequest(HttpMethod.PUT, URL_API_COMMAND + "/" + dataItem.id, new GardenaCommandRequest(gardenaCommand),
401                 null);
402     }
403
404     @Override
405     public String getId() {
406         return id;
407     }
408
409     @Override
410     public Collection<Device> getAllDevices() {
411         return allDevicesById.values();
412     }
413 }