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