2 * Copyright (c) 2010-2022 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.gardena.internal;
15 import java.util.Collection;
16 import java.util.HashMap;
17 import java.util.Iterator;
20 import java.util.concurrent.ConcurrentHashMap;
21 import java.util.concurrent.ExecutionException;
22 import java.util.concurrent.ScheduledExecutorService;
23 import java.util.concurrent.ScheduledFuture;
24 import java.util.concurrent.TimeUnit;
25 import java.util.concurrent.TimeoutException;
27 import org.eclipse.jdt.annotation.NonNullByDefault;
28 import org.eclipse.jdt.annotation.Nullable;
29 import org.eclipse.jetty.client.HttpClient;
30 import org.eclipse.jetty.client.api.ContentResponse;
31 import org.eclipse.jetty.client.api.Request;
32 import org.eclipse.jetty.client.util.AbstractTypedContentProvider;
33 import org.eclipse.jetty.client.util.FormContentProvider;
34 import org.eclipse.jetty.client.util.StringContentProvider;
35 import org.eclipse.jetty.http.HttpHeader;
36 import org.eclipse.jetty.http.HttpMethod;
37 import org.eclipse.jetty.util.Fields;
38 import org.eclipse.jetty.websocket.client.WebSocketClient;
39 import org.openhab.binding.gardena.internal.config.GardenaConfig;
40 import org.openhab.binding.gardena.internal.exception.GardenaDeviceNotFoundException;
41 import org.openhab.binding.gardena.internal.exception.GardenaException;
42 import org.openhab.binding.gardena.internal.model.DataItemDeserializer;
43 import org.openhab.binding.gardena.internal.model.dto.Device;
44 import org.openhab.binding.gardena.internal.model.dto.api.CreateWebSocketRequest;
45 import org.openhab.binding.gardena.internal.model.dto.api.DataItem;
46 import org.openhab.binding.gardena.internal.model.dto.api.Location;
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.WebSocket;
52 import org.openhab.binding.gardena.internal.model.dto.api.WebSocketCreatedResponse;
53 import org.openhab.binding.gardena.internal.model.dto.command.GardenaCommand;
54 import org.openhab.binding.gardena.internal.model.dto.command.GardenaCommandRequest;
55 import org.openhab.core.io.net.http.HttpClientFactory;
56 import org.openhab.core.io.net.http.WebSocketFactory;
57 import org.slf4j.Logger;
58 import org.slf4j.LoggerFactory;
60 import com.google.gson.Gson;
61 import com.google.gson.GsonBuilder;
62 import com.google.gson.JsonSyntaxException;
65 * {@link GardenaSmart} implementation to access Gardena smart system.
67 * @author Gerhard Riegler - Initial contribution
70 public class GardenaSmartImpl implements GardenaSmart, GardenaSmartWebSocketListener {
71 private final Logger logger = LoggerFactory.getLogger(GardenaSmartImpl.class);
73 private Gson gson = new GsonBuilder().registerTypeAdapter(DataItem.class, new DataItemDeserializer()).create();
75 private static final String URL_API_HUSQUARNA = "https://api.authentication.husqvarnagroup.dev/v1";
76 private static final String URL_API_GARDENA = "https://api.smart.gardena.dev/v1";
77 private static final String URL_API_TOKEN = URL_API_HUSQUARNA + "/oauth2/token";
78 private static final String URL_API_WEBSOCKET = URL_API_GARDENA + "/websocket";
79 private static final String URL_API_LOCATIONS = URL_API_GARDENA + "/locations";
80 private static final String URL_API_COMMAND = URL_API_GARDENA + "/command";
83 private GardenaConfig config;
84 private ScheduledExecutorService scheduler;
86 private Map<String, Device> allDevicesById = new HashMap<>();
87 private LocationsResponse locationsResponse;
88 private GardenaSmartEventListener eventListener;
90 private HttpClient httpClient;
91 private Map<String, GardenaSmartWebSocket> webSockets = new HashMap<>();
92 private @Nullable PostOAuth2Response token;
93 private boolean initialized = false;
94 private WebSocketClient webSocketClient;
96 private Set<Device> devicesToNotify = ConcurrentHashMap.newKeySet();
97 private @Nullable ScheduledFuture<?> deviceToNotifyFuture;
98 private @Nullable ScheduledFuture<?> newDeviceFuture;
100 public GardenaSmartImpl(String id, GardenaConfig config, GardenaSmartEventListener eventListener,
101 ScheduledExecutorService scheduler, HttpClientFactory httpClientFactory, WebSocketFactory webSocketFactory)
102 throws GardenaException {
104 this.config = config;
105 this.eventListener = eventListener;
106 this.scheduler = scheduler;
108 logger.debug("Starting GardenaSmart");
110 httpClient = httpClientFactory.createHttpClient(id);
111 httpClient.setConnectTimeout(config.getConnectionTimeout() * 1000L);
112 httpClient.setIdleTimeout(httpClient.getConnectTimeout());
115 String webSocketId = String.valueOf(hashCode());
116 webSocketClient = webSocketFactory.createWebSocketClient(webSocketId);
117 webSocketClient.setConnectTimeout(config.getConnectionTimeout() * 1000L);
118 webSocketClient.setStopTimeout(3000);
119 webSocketClient.setMaxIdleTimeout(150000);
120 webSocketClient.start();
122 // initially load access token
124 locationsResponse = loadLocations();
127 for (LocationDataItem location : locationsResponse.data) {
128 LocationResponse locationResponse = loadLocation(location.id);
129 if (locationResponse.included != null) {
130 for (DataItem<?> dataItem : locationResponse.included) {
131 handleDataItem(dataItem);
136 for (Device device : allDevicesById.values()) {
137 device.evaluateDeviceType();
142 } catch (GardenaException ex) {
144 // pass GardenaException to calling function
146 } catch (Exception ex) {
148 throw new GardenaException(ex.getMessage(), ex);
153 * Starts the websockets for each location.
155 private void startWebsockets() throws Exception {
156 for (LocationDataItem location : locationsResponse.data) {
157 WebSocketCreatedResponse webSocketCreatedResponse = getWebsocketInfo(location.id);
158 Location locationAttributes = location.attributes;
159 WebSocket webSocketAttributes = webSocketCreatedResponse.data.attributes;
160 if (locationAttributes == null || webSocketAttributes == null) {
163 String socketId = id + "-" + locationAttributes.name;
164 webSockets.put(location.id, new GardenaSmartWebSocket(this, webSocketClient, scheduler,
165 webSocketAttributes.url, token, socketId, location.id));
170 * Stops all websockets.
172 private void stopWebsockets() {
173 for (GardenaSmartWebSocket webSocket : webSockets.values()) {
180 * Communicates with Gardena smart home system and parses the result.
182 private <T> T executeRequest(HttpMethod method, String url, @Nullable Object content, @Nullable Class<T> result)
183 throws GardenaException {
185 AbstractTypedContentProvider contentProvider = null;
186 String contentType = "application/vnd.api+json";
187 if (content != null) {
188 if (content instanceof Fields) {
189 contentProvider = new FormContentProvider((Fields) content);
190 contentType = "application/x-www-form-urlencoded";
192 contentProvider = new StringContentProvider(gson.toJson(content));
196 if (logger.isTraceEnabled()) {
197 logger.trace(">>> {} {}, data: {}", method, url, content == null ? null : gson.toJson(content));
200 Request request = httpClient.newRequest(url).method(method).header(HttpHeader.CONTENT_TYPE, contentType)
201 .header(HttpHeader.ACCEPT, "application/vnd.api+json").header(HttpHeader.ACCEPT_ENCODING, "gzip");
203 if (!URL_API_TOKEN.equals(url)) {
205 final PostOAuth2Response token = this.token;
207 request.header("Authorization", token.tokenType + " " + token.accessToken);
208 request.header("Authorization-provider", token.provider);
210 request.header("X-Api-Key", config.getApiKey());
213 request.content(contentProvider);
214 ContentResponse contentResponse = request.send();
215 int status = contentResponse.getStatus();
216 if (logger.isTraceEnabled()) {
217 logger.trace("<<< status:{}, {}", status, contentResponse.getContentAsString());
220 if (status != 200 && status != 204 && status != 201 && status != 202) {
221 throw new GardenaException(String.format("Error %s %s, %s", status, contentResponse.getReason(),
222 contentResponse.getContentAsString()), status);
225 if (result == null) {
228 return (T) gson.fromJson(contentResponse.getContentAsString(), result);
229 } catch (InterruptedException | TimeoutException | ExecutionException ex) {
230 throw new GardenaException(ex.getMessage(), ex);
235 * Creates or refreshes the access token for the Gardena smart system.
237 private synchronized void verifyToken() throws GardenaException {
238 Fields fields = new Fields();
239 fields.add("client_id", config.getApiKey());
241 PostOAuth2Response token = this.token;
242 if (token == null || token.isRefreshTokenExpired()) {
244 logger.debug("Gardena API login using password, reason: {}",
245 token == null ? "no token available" : "refresh token expired");
246 fields.add("grant_type", "password");
247 fields.add("username", config.getEmail());
248 fields.add("password", config.getPassword());
249 token = executeRequest(HttpMethod.POST, URL_API_TOKEN, fields, PostOAuth2Response.class);
252 } else if (token.isAccessTokenExpired()) {
254 logger.debug("Gardena API login using refreshToken, reason: access token expired");
255 fields.add("grant_type", "refresh_token");
256 fields.add("refresh_token", token.refreshToken);
258 PostOAuth2Response tempToken = executeRequest(HttpMethod.POST, URL_API_TOKEN, fields,
259 PostOAuth2Response.class);
260 token.accessToken = tempToken.accessToken;
261 token.expiresIn = tempToken.expiresIn;
264 } catch (GardenaException ex) {
265 // refresh token issue
270 logger.debug("Gardena API token valid");
272 logger.debug("{}", token.toString());
276 * Loads all locations.
278 private LocationsResponse loadLocations() throws GardenaException {
279 return executeRequest(HttpMethod.GET, URL_API_LOCATIONS, null, LocationsResponse.class);
283 * Loads all devices for a given location.
285 private LocationResponse loadLocation(String locationId) throws GardenaException {
286 return executeRequest(HttpMethod.GET, URL_API_LOCATIONS + "/" + locationId, null, LocationResponse.class);
290 * Returns the websocket url for a given location.
292 private WebSocketCreatedResponse getWebsocketInfo(String locationId) throws GardenaException {
293 return executeRequest(HttpMethod.POST, URL_API_WEBSOCKET, new CreateWebSocketRequest(locationId),
294 WebSocketCreatedResponse.class);
301 public void dispose() {
302 logger.debug("Disposing GardenaSmart");
304 final ScheduledFuture<?> newDeviceFuture = this.newDeviceFuture;
305 if (newDeviceFuture != null) {
306 newDeviceFuture.cancel(true);
309 final ScheduledFuture<?> deviceToNotifyFuture = this.deviceToNotifyFuture;
310 if (deviceToNotifyFuture != null) {
311 deviceToNotifyFuture.cancel(true);
316 webSocketClient.stop();
317 } catch (Exception e) {
320 httpClient.destroy();
321 webSocketClient.destroy();
322 locationsResponse = new LocationsResponse();
323 allDevicesById.clear();
328 * Restarts all websockets.
331 public synchronized void restartWebsockets() {
332 logger.debug("Restarting GardenaSmart Webservices");
336 } catch (Exception ex) {
338 if (logger.isDebugEnabled()) {
339 logger.warn("Restarting GardenaSmart Webservices failed! Restarting binding", ex);
341 logger.warn("Restarting GardenaSmart Webservices failed: {}! Restarting binding", ex.getMessage());
343 eventListener.onError();
348 * Sets the dataItem from the websocket event into the correct device.
350 private void handleDataItem(final DataItem<?> dataItem) throws GardenaException {
351 final String deviceId = dataItem.getDeviceId();
352 Device device = allDevicesById.get(deviceId);
353 if (device == null && !(dataItem instanceof LocationDataItem)) {
354 device = new Device(deviceId);
355 allDevicesById.put(device.id, device);
358 newDeviceFuture = scheduler.schedule(() -> {
359 Device newDevice = allDevicesById.get(deviceId);
360 if (newDevice != null) {
361 newDevice.evaluateDeviceType();
362 if (newDevice.deviceType != null) {
363 eventListener.onNewDevice(newDevice);
366 }, 3, TimeUnit.SECONDS);
370 if (device != null) {
371 device.setDataItem(dataItem);
376 public void onWebSocketClose(String id) {
377 restartWebsocket(webSockets.get(id));
381 public void onWebSocketError(String id) {
382 restartWebsocket(webSockets.get(id));
385 private void restartWebsocket(@Nullable GardenaSmartWebSocket socket) {
386 synchronized (this) {
387 if (socket != null && !socket.isClosing()) {
388 // close socket, if still open
389 logger.info("Restarting GardenaSmart Webservice ({})", socket.getSocketID());
392 // if socket is already closing, exit function and do not restart socket
399 WebSocketCreatedResponse webSocketCreatedResponse = getWebsocketInfo(socket.getLocationID());
400 // only restart single socket, do not restart binding
401 WebSocket webSocketAttributes = webSocketCreatedResponse.data.attributes;
402 if (webSocketAttributes != null) {
403 socket.restart(webSocketAttributes.url);
405 } catch (Exception ex) {
406 // restart binding on error
407 logger.warn("Restarting GardenaSmart Webservice failed ({}): {}, restarting binding", socket.getSocketID(),
409 eventListener.onError();
414 public void onWebSocketMessage(String msg) {
416 DataItem<?> dataItem = gson.fromJson(msg, DataItem.class);
417 if (dataItem != null) {
418 handleDataItem(dataItem);
419 Device device = allDevicesById.get(dataItem.getDeviceId());
420 if (device != null && device.active) {
421 devicesToNotify.add(device);
423 // delay the deviceUpdated event to filter multiple events for the same device dataItem property
424 if (deviceToNotifyFuture == null) {
425 deviceToNotifyFuture = scheduler.schedule(() -> {
426 deviceToNotifyFuture = null;
427 Iterator<Device> notifyIterator = devicesToNotify.iterator();
428 while (notifyIterator.hasNext()) {
429 eventListener.onDeviceUpdated(notifyIterator.next());
430 notifyIterator.remove();
432 }, 1, TimeUnit.SECONDS);
436 } catch (GardenaException | JsonSyntaxException ex) {
437 logger.warn("Ignoring message: {}", ex.getMessage());
442 public Device getDevice(String deviceId) throws GardenaDeviceNotFoundException {
443 Device device = allDevicesById.get(deviceId);
444 if (device == null) {
445 throw new GardenaDeviceNotFoundException("Device with id " + deviceId + " not found");
451 public void sendCommand(DataItem<?> dataItem, GardenaCommand gardenaCommand) throws GardenaException {
452 executeRequest(HttpMethod.PUT, URL_API_COMMAND + "/" + dataItem.id, new GardenaCommandRequest(gardenaCommand),
457 public String getId() {
462 public Collection<Device> getAllDevices() {
463 return allDevicesById.values();