2 * Copyright (c) 2010-2021 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) {
136 throw new GardenaException(ex.getMessage(), ex);
141 * Starts the websockets for each location.
143 private void startWebsockets() throws Exception {
144 for (LocationDataItem location : locationsResponse.data) {
145 WebSocketCreatedResponse webSocketCreatedResponse = getWebsocketInfo(location.id);
146 String socketId = id + "-" + location.attributes.name;
147 webSockets.add(new GardenaSmartWebSocket(this, webSocketCreatedResponse, config, scheduler,
148 webSocketFactory, token, socketId));
153 * Stops all websockets.
155 private void stopWebsockets() {
156 for (GardenaSmartWebSocket webSocket : webSockets) {
163 * Communicates with Gardena smart home system and parses the result.
165 private <T> T executeRequest(HttpMethod method, String url, @Nullable Object content, @Nullable Class<T> result)
166 throws GardenaException {
168 AbstractTypedContentProvider contentProvider = null;
169 String contentType = "application/vnd.api+json";
170 if (content != null) {
171 if (content instanceof Fields) {
172 contentProvider = new FormContentProvider((Fields) content);
173 contentType = "application/x-www-form-urlencoded";
175 contentProvider = new StringContentProvider(gson.toJson(content));
179 if (logger.isTraceEnabled()) {
180 logger.trace(">>> {} {}, data: {}", method, url, content == null ? null : gson.toJson(content));
183 Request request = httpClient.newRequest(url).method(method).header(HttpHeader.CONTENT_TYPE, contentType)
184 .header(HttpHeader.ACCEPT, "application/vnd.api+json").header(HttpHeader.ACCEPT_ENCODING, "gzip");
186 if (!URL_API_TOKEN.equals(url)) {
188 final PostOAuth2Response token = this.token;
190 request.header("Authorization", token.tokenType + " " + token.accessToken);
191 request.header("Authorization-provider", token.provider);
193 request.header("X-Api-Key", config.getApiKey());
196 request.content(contentProvider);
197 ContentResponse contentResponse = request.send();
198 int status = contentResponse.getStatus();
199 if (logger.isTraceEnabled()) {
200 logger.trace("<<< status:{}, {}", status, contentResponse.getContentAsString());
203 if (status != 200 && status != 204 && status != 201 && status != 202) {
204 throw new GardenaException(String.format("Error %s %s, %s", status, contentResponse.getReason(),
205 contentResponse.getContentAsString()));
208 if (result == null) {
211 return (T) gson.fromJson(contentResponse.getContentAsString(), result);
212 } catch (InterruptedException | TimeoutException | ExecutionException ex) {
213 throw new GardenaException(ex.getMessage(), ex);
218 * Creates or refreshes the access token for the Gardena smart system.
220 private synchronized void verifyToken() throws GardenaException {
221 Fields fields = new Fields();
222 fields.add("client_id", config.getApiKey());
224 PostOAuth2Response token = this.token;
225 if (token == null || token.isRefreshTokenExpired()) {
227 logger.debug("Gardena API login using password, reason: {}",
228 token == null ? "no token available" : "refresh token expired");
229 fields.add("grant_type", "password");
230 fields.add("username", config.getEmail());
231 fields.add("password", config.getPassword());
232 token = executeRequest(HttpMethod.POST, URL_API_TOKEN, fields, PostOAuth2Response.class);
235 } else if (token.isAccessTokenExpired()) {
237 logger.debug("Gardena API login using refreshToken, reason: access token expired");
238 fields.add("grant_type", "refresh_token");
239 fields.add("refresh_token", token.refreshToken);
241 PostOAuth2Response tempToken = executeRequest(HttpMethod.POST, URL_API_TOKEN, fields,
242 PostOAuth2Response.class);
243 token.accessToken = tempToken.accessToken;
244 token.expiresIn = tempToken.expiresIn;
247 } catch (GardenaException ex) {
248 // refresh token issue
253 logger.debug("Gardena API token valid");
255 logger.debug("{}", token.toString());
259 * Loads all locations.
261 private LocationsResponse loadLocations() throws GardenaException {
262 return executeRequest(HttpMethod.GET, URL_API_LOCATIONS, null, LocationsResponse.class);
266 * Loads all devices for a given location.
268 private LocationResponse loadLocation(String locationId) throws GardenaException {
269 return executeRequest(HttpMethod.GET, URL_API_LOCATIONS + "/" + locationId, null, LocationResponse.class);
273 * Returns the websocket url for a given location.
275 private WebSocketCreatedResponse getWebsocketInfo(String locationId) throws GardenaException {
276 return executeRequest(HttpMethod.POST, URL_API_WEBSOCKET, new CreateWebSocketRequest(locationId),
277 WebSocketCreatedResponse.class);
284 public void dispose() {
285 logger.debug("Disposing GardenaSmart");
287 final ScheduledFuture<?> newDeviceFuture = this.newDeviceFuture;
288 if (newDeviceFuture != null) {
289 newDeviceFuture.cancel(true);
292 final ScheduledFuture<?> deviceToNotifyFuture = this.deviceToNotifyFuture;
293 if (deviceToNotifyFuture != null) {
294 deviceToNotifyFuture.cancel(true);
299 } catch (Exception e) {
302 httpClient.destroy();
303 locationsResponse = new LocationsResponse();
304 allDevicesById.clear();
309 * Restarts all websockets.
312 public synchronized void restartWebsockets() {
313 logger.debug("Restarting GardenaSmart Webservice");
317 } catch (Exception ex) {
318 logger.warn("Restarting GardenaSmart Webservice failed: {}, restarting binding", ex.getMessage());
319 eventListener.onError();
324 * Sets the dataItem from the websocket event into the correct device.
326 private void handleDataItem(final DataItem<?> dataItem) throws GardenaException {
327 final String deviceId = dataItem.getDeviceId();
328 Device device = allDevicesById.get(deviceId);
329 if (device == null && !(dataItem instanceof LocationDataItem)) {
330 device = new Device(deviceId);
331 allDevicesById.put(device.id, device);
334 newDeviceFuture = scheduler.schedule(() -> {
335 Device newDevice = allDevicesById.get(deviceId);
336 if (newDevice != null) {
337 newDevice.evaluateDeviceType();
338 if (newDevice.deviceType != null) {
339 eventListener.onNewDevice(newDevice);
342 }, 3, TimeUnit.SECONDS);
346 if (device != null) {
347 device.setDataItem(dataItem);
352 public void onWebSocketClose() {
357 public void onWebSocketError() {
358 eventListener.onError();
362 public void onWebSocketMessage(String msg) {
364 DataItem<?> dataItem = gson.fromJson(msg, DataItem.class);
365 if (dataItem != null) {
366 handleDataItem(dataItem);
367 Device device = allDevicesById.get(dataItem.getDeviceId());
368 if (device != null && device.active) {
369 devicesToNotify.add(device);
371 // delay the deviceUpdated event to filter multiple events for the same device dataItem property
372 if (deviceToNotifyFuture == null) {
373 deviceToNotifyFuture = scheduler.schedule(() -> {
374 deviceToNotifyFuture = null;
375 Iterator<Device> notifyIterator = devicesToNotify.iterator();
376 while (notifyIterator.hasNext()) {
377 eventListener.onDeviceUpdated(notifyIterator.next());
378 notifyIterator.remove();
380 }, 1, TimeUnit.SECONDS);
384 } catch (GardenaException | JsonSyntaxException ex) {
385 logger.warn("Ignoring message: {}", ex.getMessage());
390 public Device getDevice(String deviceId) throws GardenaDeviceNotFoundException {
391 Device device = allDevicesById.get(deviceId);
392 if (device == null) {
393 throw new GardenaDeviceNotFoundException("Device with id " + deviceId + " not found");
399 public void sendCommand(DataItem<?> dataItem, GardenaCommand gardenaCommand) throws GardenaException {
400 executeRequest(HttpMethod.PUT, URL_API_COMMAND + "/" + dataItem.id, new GardenaCommandRequest(gardenaCommand),
405 public String getId() {
410 public Collection<Device> getAllDevices() {
411 return allDevicesById.values();