2 * Copyright (c) 2010-2023 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.ArrayList;
16 import java.util.Collection;
17 import java.util.HashMap;
18 import java.util.Iterator;
19 import java.util.List;
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;
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;
64 import com.google.gson.Gson;
65 import com.google.gson.GsonBuilder;
66 import com.google.gson.JsonSyntaxException;
69 * {@link GardenaSmart} implementation to access Gardena smart system.
71 * @author Gerhard Riegler - Initial contribution
74 public class GardenaSmartImpl implements GardenaSmart, GardenaSmartWebSocketListener {
75 private final Logger logger = LoggerFactory.getLogger(GardenaSmartImpl.class);
77 private Gson gson = new GsonBuilder().registerTypeAdapter(DataItem.class, new DataItemDeserializer()).create();
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";
86 private final String id;
87 private final GardenaConfig config;
88 private final ScheduledExecutorService scheduler;
90 private final Map<String, Device> allDevicesById = new HashMap<>();
91 private @Nullable LocationsResponse locationsResponse = null;
92 private final GardenaSmartEventListener eventListener;
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;
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<>();
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;
114 String name = ThingWebClientUtil.buildWebClientConsumerName(uid, null);
115 httpClient = httpClientFactory.createHttpClient(name);
116 httpClient.setConnectTimeout(config.getConnectionTimeout() * 1000L);
117 httpClient.setIdleTimeout(httpClient.getConnectTimeout());
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);
125 logger.debug("Starting GardenaSmart");
128 webSocketClient.start();
130 // initially load access token
132 LocationsResponse locationsResponse = loadLocations();
133 this.locationsResponse = locationsResponse;
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);
147 for (Device device : allDevicesById.values()) {
148 device.evaluateDeviceType();
153 } catch (GardenaException ex) {
155 // pass GardenaException to calling function
157 } catch (Exception ex) {
159 throw new GardenaException(ex.getMessage(), ex);
164 * Starts the websockets for each location.
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) {
176 String socketId = id + "-" + locationAttributes.name;
177 webSockets.put(location.id, new GardenaSmartWebSocket(this, webSocketClient, scheduler,
178 webSocketAttributes.url, token, socketId, location.id));
184 * Stops all websockets.
186 private void stopWebsockets() {
187 for (GardenaSmartWebSocket webSocket : webSockets.values()) {
194 * Communicates with Gardena smart home system and parses the result.
196 private <T> T executeRequest(HttpMethod method, String url, @Nullable Object content, @Nullable Class<T> result)
197 throws GardenaException {
199 AbstractTypedContentProvider contentProvider = null;
200 String contentType = "application/vnd.api+json";
201 if (content != null) {
202 if (content instanceof Fields contentAsFields) {
203 contentProvider = new FormContentProvider(contentAsFields);
204 contentType = "application/x-www-form-urlencoded";
206 contentProvider = new StringContentProvider(gson.toJson(content));
210 if (logger.isTraceEnabled()) {
211 logger.trace(">>> {} {}, data: {}", method, url, content == null ? null : gson.toJson(content));
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");
217 if (!URL_API_TOKEN.equals(url)) {
219 final PostOAuth2Response token = this.token;
221 request.header("Authorization", token.tokenType + " " + token.accessToken);
223 request.header("X-Api-Key", config.getApiKey());
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());
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);
238 if (result == null) {
241 return (T) gson.fromJson(contentResponse.getContentAsString(), result);
242 } catch (InterruptedException | TimeoutException | ExecutionException ex) {
243 throw new GardenaException(ex.getMessage(), ex);
248 * Creates or refreshes the access token for the Gardena smart system.
250 private synchronized void verifyToken() throws GardenaException {
251 Fields fields = new Fields();
252 fields.add("client_id", config.getApiKey());
254 PostOAuth2Response token = this.token;
255 if (token == null || token.isRefreshTokenExpired()) {
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);
264 } else if (token.isAccessTokenExpired()) {
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);
270 PostOAuth2Response tempToken = executeRequest(HttpMethod.POST, URL_API_TOKEN, fields,
271 PostOAuth2Response.class);
272 token.accessToken = tempToken.accessToken;
273 token.expiresIn = tempToken.expiresIn;
276 } catch (GardenaException ex) {
277 // refresh token issue
282 logger.debug("Gardena API token valid");
284 logger.debug("{}", token.toString());
288 * Loads all locations.
290 private LocationsResponse loadLocations() throws GardenaException {
291 return executeRequest(HttpMethod.GET, URL_API_LOCATIONS, null, LocationsResponse.class);
295 * Loads all devices for a given location.
297 private LocationResponse loadLocation(String locationId) throws GardenaException {
298 return executeRequest(HttpMethod.GET, URL_API_LOCATIONS + "/" + locationId, null, LocationResponse.class);
302 * Returns the websocket url for a given location.
304 private WebSocketCreatedResponse getWebsocketInfo(String locationId) throws GardenaException {
305 return executeRequest(HttpMethod.POST, URL_API_WEBSOCKET, new CreateWebSocketRequest(locationId),
306 WebSocketCreatedResponse.class);
313 public void dispose() {
314 logger.debug("Disposing GardenaSmart");
316 synchronized (newDeviceTasksLock) {
317 for (ScheduledFuture<?> task : newDeviceTasks) {
318 if (!task.isDone()) {
322 newDeviceTasks.clear();
324 synchronized (deviceUpdateTaskLock) {
325 devicesToNotify.clear();
326 ScheduledFuture<?> task = deviceUpdateTask;
330 deviceUpdateTask = null;
335 webSocketClient.stop();
336 } catch (Exception e) {
339 httpClient.destroy();
340 webSocketClient.destroy();
341 allDevicesById.clear();
342 locationsResponse = null;
346 * Restarts all websockets.
349 public synchronized void restartWebsockets() {
350 logger.debug("Restarting GardenaSmart Webservices");
354 } catch (Exception ex) {
356 if (logger.isDebugEnabled()) {
357 logger.warn("Restarting GardenaSmart Webservices failed! Restarting binding", ex);
359 logger.warn("Restarting GardenaSmart Webservices failed: {}! Restarting binding", ex.getMessage());
361 eventListener.onError();
366 * Sets the dataItem from the websocket event into the correct device.
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);
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(() -> {
381 Device newDevice = allDevicesById.get(deviceId);
382 if (newDevice != null) {
383 newDevice.evaluateDeviceType();
384 if (newDevice.deviceType != null) {
385 eventListener.onNewDevice(newDevice);
389 }, 3, TimeUnit.SECONDS));
393 if (device != null) {
394 device.setDataItem(dataItem);
399 public void onWebSocketClose(String id) {
400 restartWebsocket(webSockets.get(id));
404 public void onWebSocketError(String id) {
405 restartWebsocket(webSockets.get(id));
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());
415 // if socket is already closing, exit function and do not restart socket
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);
428 } catch (Exception ex) {
429 // restart binding on error
430 logger.warn("Restarting GardenaSmart Webservice failed ({}): {}, restarting binding", socket.getSocketID(),
432 eventListener.onError();
437 public void onWebSocketMessage(String msg) {
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);
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);
455 } catch (GardenaException | JsonSyntaxException ex) {
456 logger.warn("Ignoring message: {}", ex.getMessage());
461 * Helper scheduler task to update devices
463 private void notifyDevicesUpdated() {
464 synchronized (deviceUpdateTaskLock) {
466 Iterator<Device> notifyIterator = devicesToNotify.iterator();
467 while (notifyIterator.hasNext()) {
468 eventListener.onDeviceUpdated(notifyIterator.next());
469 notifyIterator.remove();
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");
485 public void sendCommand(DataItem<?> dataItem, GardenaCommand gardenaCommand) throws GardenaException {
486 executeRequest(HttpMethod.PUT, URL_API_COMMAND + "/" + dataItem.id, new GardenaCommandRequest(gardenaCommand),
491 public String getId() {
496 public Collection<Device> getAllDevices() {
497 return allDevicesById.values();