2 * Copyright (c) 2010-2024 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);
76 private static final int REQUEST_TIMEOUT_MS = 10_000;
78 private Gson gson = new GsonBuilder().registerTypeAdapter(DataItem.class, new DataItemDeserializer()).create();
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";
87 private final String id;
88 private final GardenaConfig config;
89 private final ScheduledExecutorService scheduler;
91 private final Map<String, Device> allDevicesById = new HashMap<>();
92 private @Nullable LocationsResponse locationsResponse = null;
93 private final GardenaSmartEventListener eventListener;
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;
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<>();
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;
115 String name = ThingWebClientUtil.buildWebClientConsumerName(uid, null);
116 httpClient = httpClientFactory.createHttpClient(name);
117 httpClient.setConnectTimeout(config.getConnectionTimeout() * 1000L);
118 httpClient.setIdleTimeout(httpClient.getConnectTimeout());
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);
126 logger.debug("Starting GardenaSmart");
129 webSocketClient.start();
131 // initially load access token
133 LocationsResponse locationsResponse = loadLocations();
134 this.locationsResponse = locationsResponse;
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);
148 for (Device device : allDevicesById.values()) {
149 device.evaluateDeviceType();
154 } catch (GardenaException ex) {
156 // pass GardenaException to calling function
158 } catch (Exception ex) {
160 throw new GardenaException(ex.getMessage(), ex);
165 * Starts the websockets for each location.
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) {
177 String socketId = id + "-" + locationAttributes.name;
178 webSockets.put(location.id, new GardenaSmartWebSocket(this, webSocketClient, scheduler,
179 webSocketAttributes.url, token, socketId, location.id));
185 * Stops all websockets.
187 private void stopWebsockets() {
188 for (GardenaSmartWebSocket webSocket : webSockets.values()) {
195 * Communicates with Gardena smart home system and parses the result.
197 private <T> T executeRequest(HttpMethod method, String url, @Nullable Object content, @Nullable Class<T> result)
198 throws GardenaException {
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";
207 contentProvider = new StringContentProvider(gson.toJson(content));
211 if (logger.isTraceEnabled()) {
212 logger.trace(">>> {} {}, data: {}", method, url, content == null ? null : gson.toJson(content));
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");
219 if (!URL_API_TOKEN.equals(url)) {
221 final PostOAuth2Response token = this.token;
223 request.header("Authorization", token.tokenType + " " + token.accessToken);
225 request.header("X-Api-Key", config.getApiKey());
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());
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);
240 if (result == null) {
243 return (T) gson.fromJson(contentResponse.getContentAsString(), result);
244 } catch (InterruptedException | TimeoutException | ExecutionException ex) {
245 throw new GardenaException(ex.getMessage(), ex);
250 * Creates or refreshes the access token for the Gardena smart system.
252 private synchronized void verifyToken() throws GardenaException {
253 Fields fields = new Fields();
254 fields.add("client_id", config.getApiKey());
256 PostOAuth2Response token = this.token;
257 if (token == null || token.isRefreshTokenExpired()) {
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);
266 } else if (token.isAccessTokenExpired()) {
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);
272 PostOAuth2Response tempToken = executeRequest(HttpMethod.POST, URL_API_TOKEN, fields,
273 PostOAuth2Response.class);
274 token.accessToken = tempToken.accessToken;
275 token.expiresIn = tempToken.expiresIn;
278 } catch (GardenaException ex) {
279 // refresh token issue
284 logger.debug("Gardena API token valid");
286 logger.debug("{}", token.toString());
290 * Loads all locations.
292 private LocationsResponse loadLocations() throws GardenaException {
293 return executeRequest(HttpMethod.GET, URL_API_LOCATIONS, null, LocationsResponse.class);
297 * Loads all devices for a given location.
299 private LocationResponse loadLocation(String locationId) throws GardenaException {
300 return executeRequest(HttpMethod.GET, URL_API_LOCATIONS + "/" + locationId, null, LocationResponse.class);
304 * Returns the websocket url for a given location.
306 private WebSocketCreatedResponse getWebsocketInfo(String locationId) throws GardenaException {
307 return executeRequest(HttpMethod.POST, URL_API_WEBSOCKET, new CreateWebSocketRequest(locationId),
308 WebSocketCreatedResponse.class);
315 public void dispose() {
316 logger.debug("Disposing GardenaSmart");
318 synchronized (newDeviceTasksLock) {
319 for (ScheduledFuture<?> task : newDeviceTasks) {
320 if (!task.isDone()) {
324 newDeviceTasks.clear();
326 synchronized (deviceUpdateTaskLock) {
327 devicesToNotify.clear();
328 ScheduledFuture<?> task = deviceUpdateTask;
332 deviceUpdateTask = null;
337 webSocketClient.stop();
338 } catch (Exception e) {
341 httpClient.destroy();
342 webSocketClient.destroy();
343 allDevicesById.clear();
344 locationsResponse = null;
348 * Restarts all websockets.
351 public synchronized void restartWebsockets() {
352 logger.debug("Restarting GardenaSmart Webservices");
356 } catch (Exception ex) {
358 if (logger.isDebugEnabled()) {
359 logger.warn("Restarting GardenaSmart Webservices failed! Restarting binding", ex);
361 logger.warn("Restarting GardenaSmart Webservices failed: {}! Restarting binding", ex.getMessage());
363 eventListener.onError();
368 * Sets the dataItem from the websocket event into the correct device.
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);
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(() -> {
383 Device newDevice = allDevicesById.get(deviceId);
384 if (newDevice != null) {
385 newDevice.evaluateDeviceType();
386 if (newDevice.deviceType != null) {
387 eventListener.onNewDevice(newDevice);
391 }, 3, TimeUnit.SECONDS));
395 if (device != null) {
396 device.setDataItem(dataItem);
401 public void onWebSocketClose(String id) {
402 restartWebsocket(webSockets.get(id));
406 public void onWebSocketError(String id) {
407 restartWebsocket(webSockets.get(id));
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());
417 // if socket is already closing, exit function and do not restart socket
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);
430 } catch (Exception ex) {
431 // restart binding on error
432 logger.warn("Restarting GardenaSmart Webservice failed ({}): {}, restarting binding", socket.getSocketID(),
434 eventListener.onError();
439 public void onWebSocketMessage(String msg) {
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);
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);
457 } catch (GardenaException | JsonSyntaxException ex) {
458 logger.warn("Ignoring message: {}", ex.getMessage());
463 * Helper scheduler task to update devices
465 private void notifyDevicesUpdated() {
466 synchronized (deviceUpdateTaskLock) {
468 Iterator<Device> notifyIterator = devicesToNotify.iterator();
469 while (notifyIterator.hasNext()) {
470 eventListener.onDeviceUpdated(notifyIterator.next());
471 notifyIterator.remove();
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");
487 public void sendCommand(DataItem<?> dataItem, GardenaCommand gardenaCommand) throws GardenaException {
488 executeRequest(HttpMethod.PUT, URL_API_COMMAND + "/" + dataItem.id, new GardenaCommandRequest(gardenaCommand),
493 public String getId() {
498 public Collection<Device> getAllDevices() {
499 return allDevicesById.values();