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.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.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;
59 import com.google.gson.Gson;
60 import com.google.gson.GsonBuilder;
61 import com.google.gson.JsonSyntaxException;
64 * {@link GardenaSmart} implementation to access Gardena smart system.
66 * @author Gerhard Riegler - Initial contribution
69 public class GardenaSmartImpl implements GardenaSmart, GardenaSmartWebSocketListener {
70 private final Logger logger = LoggerFactory.getLogger(GardenaSmartImpl.class);
72 private Gson gson = new GsonBuilder().registerTypeAdapter(DataItem.class, new DataItemDeserializer()).create();
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";
82 private GardenaConfig config;
83 private ScheduledExecutorService scheduler;
85 private Map<String, Device> allDevicesById = new HashMap<>();
86 private LocationsResponse locationsResponse;
87 private GardenaSmartEventListener eventListener;
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;
95 private Set<Device> devicesToNotify = ConcurrentHashMap.newKeySet();
96 private @Nullable ScheduledFuture<?> deviceToNotifyFuture;
97 private @Nullable ScheduledFuture<?> newDeviceFuture;
99 public GardenaSmartImpl(String id, GardenaConfig config, GardenaSmartEventListener eventListener,
100 ScheduledExecutorService scheduler, HttpClientFactory httpClientFactory, WebSocketFactory webSocketFactory)
101 throws GardenaException {
103 this.config = config;
104 this.eventListener = eventListener;
105 this.scheduler = scheduler;
106 this.webSocketFactory = webSocketFactory;
108 logger.debug("Starting GardenaSmart");
110 httpClient = httpClientFactory.createHttpClient(id);
111 httpClient.setConnectTimeout(config.getConnectionTimeout() * 1000L);
112 httpClient.setIdleTimeout(httpClient.getConnectTimeout());
115 // initially load access token
117 locationsResponse = loadLocations();
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);
129 for (Device device : allDevicesById.values()) {
130 device.evaluateDeviceType();
135 } catch (Exception ex) {
137 throw new GardenaException(ex.getMessage(), ex);
142 * Starts the websockets for each location.
144 private void startWebsockets() throws Exception {
145 for (LocationDataItem location : locationsResponse.data) {
146 WebSocketCreatedResponse webSocketCreatedResponse = getWebsocketInfo(location.id);
147 String socketId = id + "-" + location.attributes.name;
148 webSockets.add(new GardenaSmartWebSocket(this, webSocketCreatedResponse, config, scheduler,
149 webSocketFactory, token, socketId));
154 * Stops all websockets.
156 private void stopWebsockets() {
157 for (GardenaSmartWebSocket webSocket : webSockets) {
164 * Communicates with Gardena smart home system and parses the result.
166 private <T> T executeRequest(HttpMethod method, String url, @Nullable Object content, @Nullable Class<T> result)
167 throws GardenaException {
169 AbstractTypedContentProvider contentProvider = null;
170 String contentType = "application/vnd.api+json";
171 if (content != null) {
172 if (content instanceof Fields) {
173 contentProvider = new FormContentProvider((Fields) content);
174 contentType = "application/x-www-form-urlencoded";
176 contentProvider = new StringContentProvider(gson.toJson(content));
180 if (logger.isTraceEnabled()) {
181 logger.trace(">>> {} {}, data: {}", method, url, content == null ? null : gson.toJson(content));
184 Request request = httpClient.newRequest(url).method(method).header(HttpHeader.CONTENT_TYPE, contentType)
185 .header(HttpHeader.ACCEPT, "application/vnd.api+json").header(HttpHeader.ACCEPT_ENCODING, "gzip");
187 if (!URL_API_TOKEN.equals(url)) {
189 final PostOAuth2Response token = this.token;
191 request.header("Authorization", token.tokenType + " " + token.accessToken);
192 request.header("Authorization-provider", token.provider);
194 request.header("X-Api-Key", config.getApiKey());
197 request.content(contentProvider);
198 ContentResponse contentResponse = request.send();
199 int status = contentResponse.getStatus();
200 if (logger.isTraceEnabled()) {
201 logger.trace("<<< status:{}, {}", status, contentResponse.getContentAsString());
204 if (status != 200 && status != 204 && status != 201 && status != 202) {
205 throw new GardenaException(String.format("Error %s %s, %s", status, contentResponse.getReason(),
206 contentResponse.getContentAsString()));
209 if (result == null) {
212 return (T) gson.fromJson(contentResponse.getContentAsString(), result);
213 } catch (InterruptedException | TimeoutException | ExecutionException ex) {
214 throw new GardenaException(ex.getMessage(), ex);
219 * Creates or refreshes the access token for the Gardena smart system.
221 private synchronized void verifyToken() throws GardenaException {
222 Fields fields = new Fields();
223 fields.add("client_id", config.getApiKey());
225 PostOAuth2Response token = this.token;
226 if (token == null || token.isRefreshTokenExpired()) {
228 logger.debug("Gardena API login using password, reason: {}",
229 token == null ? "no token available" : "refresh token expired");
230 fields.add("grant_type", "password");
231 fields.add("username", config.getEmail());
232 fields.add("password", config.getPassword());
233 token = executeRequest(HttpMethod.POST, URL_API_TOKEN, fields, PostOAuth2Response.class);
236 } else if (token.isAccessTokenExpired()) {
238 logger.debug("Gardena API login using refreshToken, reason: access token expired");
239 fields.add("grant_type", "refresh_token");
240 fields.add("refresh_token", token.refreshToken);
242 PostOAuth2Response tempToken = executeRequest(HttpMethod.POST, URL_API_TOKEN, fields,
243 PostOAuth2Response.class);
244 token.accessToken = tempToken.accessToken;
245 token.expiresIn = tempToken.expiresIn;
248 } catch (GardenaException ex) {
249 // refresh token issue
254 logger.debug("Gardena API token valid");
256 logger.debug("{}", token.toString());
260 * Loads all locations.
262 private LocationsResponse loadLocations() throws GardenaException {
263 return executeRequest(HttpMethod.GET, URL_API_LOCATIONS, null, LocationsResponse.class);
267 * Loads all devices for a given location.
269 private LocationResponse loadLocation(String locationId) throws GardenaException {
270 return executeRequest(HttpMethod.GET, URL_API_LOCATIONS + "/" + locationId, null, LocationResponse.class);
274 * Returns the websocket url for a given location.
276 private WebSocketCreatedResponse getWebsocketInfo(String locationId) throws GardenaException {
277 return executeRequest(HttpMethod.POST, URL_API_WEBSOCKET, new CreateWebSocketRequest(locationId),
278 WebSocketCreatedResponse.class);
285 public void dispose() {
286 logger.debug("Disposing GardenaSmart");
288 final ScheduledFuture<?> newDeviceFuture = this.newDeviceFuture;
289 if (newDeviceFuture != null) {
290 newDeviceFuture.cancel(true);
293 final ScheduledFuture<?> deviceToNotifyFuture = this.deviceToNotifyFuture;
294 if (deviceToNotifyFuture != null) {
295 deviceToNotifyFuture.cancel(true);
300 } catch (Exception e) {
303 httpClient.destroy();
304 locationsResponse = new LocationsResponse();
305 allDevicesById.clear();
310 * Restarts all websockets.
313 public synchronized void restartWebsockets() {
314 logger.debug("Restarting GardenaSmart Webservice");
318 } catch (Exception ex) {
319 logger.warn("Restarting GardenaSmart Webservice failed: {}, restarting binding", ex.getMessage());
320 eventListener.onError();
325 * Sets the dataItem from the websocket event into the correct device.
327 private void handleDataItem(final DataItem<?> dataItem) throws GardenaException {
328 final String deviceId = dataItem.getDeviceId();
329 Device device = allDevicesById.get(deviceId);
330 if (device == null && !(dataItem instanceof LocationDataItem)) {
331 device = new Device(deviceId);
332 allDevicesById.put(device.id, device);
335 newDeviceFuture = scheduler.schedule(() -> {
336 Device newDevice = allDevicesById.get(deviceId);
337 if (newDevice != null) {
338 newDevice.evaluateDeviceType();
339 if (newDevice.deviceType != null) {
340 eventListener.onNewDevice(newDevice);
343 }, 3, TimeUnit.SECONDS);
347 if (device != null) {
348 device.setDataItem(dataItem);
353 public void onWebSocketClose() {
358 public void onWebSocketError() {
359 eventListener.onError();
363 public void onWebSocketMessage(String msg) {
365 DataItem<?> dataItem = gson.fromJson(msg, DataItem.class);
366 if (dataItem != null) {
367 handleDataItem(dataItem);
368 Device device = allDevicesById.get(dataItem.getDeviceId());
369 if (device != null && device.active) {
370 devicesToNotify.add(device);
372 // delay the deviceUpdated event to filter multiple events for the same device dataItem property
373 if (deviceToNotifyFuture == null) {
374 deviceToNotifyFuture = scheduler.schedule(() -> {
375 deviceToNotifyFuture = null;
376 Iterator<Device> notifyIterator = devicesToNotify.iterator();
377 while (notifyIterator.hasNext()) {
378 eventListener.onDeviceUpdated(notifyIterator.next());
379 notifyIterator.remove();
381 }, 1, TimeUnit.SECONDS);
385 } catch (GardenaException | JsonSyntaxException ex) {
386 logger.warn("Ignoring message: {}", ex.getMessage());
391 public Device getDevice(String deviceId) throws GardenaDeviceNotFoundException {
392 Device device = allDevicesById.get(deviceId);
393 if (device == null) {
394 throw new GardenaDeviceNotFoundException("Device with id " + deviceId + " not found");
400 public void sendCommand(DataItem<?> dataItem, GardenaCommand gardenaCommand) throws GardenaException {
401 executeRequest(HttpMethod.PUT, URL_API_COMMAND + "/" + dataItem.id, new GardenaCommandRequest(gardenaCommand),
406 public String getId() {
411 public Collection<Device> getAllDevices() {
412 return allDevicesById.values();