2 * Copyright (c) 2010-2020 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;
16 import java.util.concurrent.*;
18 import org.eclipse.jdt.annotation.NonNullByDefault;
19 import org.eclipse.jdt.annotation.Nullable;
20 import org.eclipse.jetty.client.HttpClient;
21 import org.eclipse.jetty.client.api.ContentResponse;
22 import org.eclipse.jetty.client.api.Request;
23 import org.eclipse.jetty.client.util.AbstractTypedContentProvider;
24 import org.eclipse.jetty.client.util.FormContentProvider;
25 import org.eclipse.jetty.client.util.StringContentProvider;
26 import org.eclipse.jetty.http.HttpHeader;
27 import org.eclipse.jetty.http.HttpMethod;
28 import org.eclipse.jetty.util.Fields;
29 import org.openhab.binding.gardena.internal.config.GardenaConfig;
30 import org.openhab.binding.gardena.internal.exception.GardenaDeviceNotFoundException;
31 import org.openhab.binding.gardena.internal.exception.GardenaException;
32 import org.openhab.binding.gardena.internal.model.DataItemDeserializer;
33 import org.openhab.binding.gardena.internal.model.dto.Device;
34 import org.openhab.binding.gardena.internal.model.dto.api.*;
35 import org.openhab.binding.gardena.internal.model.dto.command.GardenaCommand;
36 import org.openhab.binding.gardena.internal.model.dto.command.GardenaCommandRequest;
37 import org.openhab.core.io.net.http.HttpClientFactory;
38 import org.openhab.core.io.net.http.WebSocketFactory;
39 import org.slf4j.Logger;
40 import org.slf4j.LoggerFactory;
42 import com.google.gson.Gson;
43 import com.google.gson.GsonBuilder;
44 import com.google.gson.JsonSyntaxException;
47 * {@link GardenaSmart} implementation to access Gardena smart system.
49 * @author Gerhard Riegler - Initial contribution
52 public class GardenaSmartImpl implements GardenaSmart, GardenaSmartWebSocketListener {
53 private final Logger logger = LoggerFactory.getLogger(GardenaSmartImpl.class);
55 private Gson gson = new GsonBuilder().registerTypeAdapter(DataItem.class, new DataItemDeserializer()).create();
57 private static final String URL_API_HUSQUARNA = "https://api.authentication.husqvarnagroup.dev/v1";
58 private static final String URL_API_GARDENA = "https://api.smart.gardena.dev/v1";
59 private static final String URL_API_TOKEN = URL_API_HUSQUARNA + "/oauth2/token";
60 private static final String URL_API_WEBSOCKET = URL_API_GARDENA + "/websocket";
61 private static final String URL_API_LOCATIONS = URL_API_GARDENA + "/locations";
62 private static final String URL_API_COMMAND = URL_API_GARDENA + "/command";
65 private GardenaConfig config;
66 private ScheduledExecutorService scheduler;
68 private Map<String, Device> allDevicesById = new HashMap<>();
69 private LocationsResponse locationsResponse;
70 private GardenaSmartEventListener eventListener;
72 private HttpClient httpClient;
73 private List<GardenaSmartWebSocket> webSockets = new ArrayList<>();
74 private @Nullable PostOAuth2Response token;
75 private boolean initialized = false;
76 private WebSocketFactory webSocketFactory;
78 private Set<Device> devicesToNotify = ConcurrentHashMap.newKeySet();
79 private @Nullable ScheduledFuture<?> deviceToNotifyFuture;
80 private @Nullable ScheduledFuture<?> newDeviceFuture;
82 public GardenaSmartImpl(String id, GardenaConfig config, GardenaSmartEventListener eventListener,
83 ScheduledExecutorService scheduler, HttpClientFactory httpClientFactory, WebSocketFactory webSocketFactory)
84 throws GardenaException {
87 this.eventListener = eventListener;
88 this.scheduler = scheduler;
89 this.webSocketFactory = webSocketFactory;
91 logger.debug("Starting GardenaSmart");
93 httpClient = httpClientFactory.createHttpClient(id);
94 httpClient.setConnectTimeout(config.getConnectionTimeout() * 1000L);
95 httpClient.setIdleTimeout(httpClient.getConnectTimeout());
98 // initially load access token
100 locationsResponse = loadLocations();
103 for (LocationDataItem location : locationsResponse.data) {
104 LocationResponse locationResponse = loadLocation(location.id);
105 if (locationResponse.included != null) {
106 for (DataItem<?> dataItem : locationResponse.included) {
107 handleDataItem(dataItem);
112 for (Device device : allDevicesById.values()) {
113 device.evaluateDeviceType();
118 } catch (Exception ex) {
119 throw new GardenaException(ex.getMessage(), ex);
124 * Starts the websockets for each location.
126 private void startWebsockets() throws Exception {
127 for (LocationDataItem location : locationsResponse.data) {
128 WebSocketCreatedResponse webSocketCreatedResponse = getWebsocketInfo(location.id);
129 String socketId = id + "-" + location.attributes.name;
130 webSockets.add(new GardenaSmartWebSocket(this, webSocketCreatedResponse, config, scheduler,
131 webSocketFactory, token, socketId));
136 * Stops all websockets.
138 private void stopWebsockets() {
139 for (GardenaSmartWebSocket webSocket : webSockets) {
146 * Communicates with Gardena smart home system and parses the result.
148 private <T> T executeRequest(HttpMethod method, String url, @Nullable Object content, @Nullable Class<T> result)
149 throws GardenaException {
151 AbstractTypedContentProvider contentProvider = null;
152 String contentType = "application/vnd.api+json";
153 if (content != null) {
154 if (content instanceof Fields) {
155 contentProvider = new FormContentProvider((Fields) content);
156 contentType = "application/x-www-form-urlencoded";
158 contentProvider = new StringContentProvider(gson.toJson(content));
162 if (logger.isTraceEnabled()) {
163 logger.trace(">>> {} {}, data: {}", method, url, content == null ? null : gson.toJson(content));
166 Request request = httpClient.newRequest(url).method(method).header(HttpHeader.CONTENT_TYPE, contentType)
167 .header(HttpHeader.ACCEPT, "application/vnd.api+json").header(HttpHeader.ACCEPT_ENCODING, "gzip");
169 if (!URL_API_TOKEN.equals(url)) {
171 final PostOAuth2Response token = this.token;
173 request.header("Authorization", token.tokenType + " " + token.accessToken);
174 request.header("Authorization-provider", token.provider);
176 request.header("X-Api-Key", config.getApiKey());
179 request.content(contentProvider);
180 ContentResponse contentResponse = request.send();
181 int status = contentResponse.getStatus();
182 if (logger.isTraceEnabled()) {
183 logger.trace("<<< status:{}, {}", status, contentResponse.getContentAsString());
186 if (status != 200 && status != 204 && status != 201 && status != 202) {
187 throw new GardenaException(String.format("Error %s %s, %s", status, contentResponse.getReason(),
188 contentResponse.getContentAsString()));
191 if (result == null) {
194 return (T) gson.fromJson(contentResponse.getContentAsString(), result);
195 } catch (InterruptedException | TimeoutException | ExecutionException ex) {
196 throw new GardenaException(ex.getMessage(), ex);
201 * Creates or refreshes the access token for the Gardena smart system.
203 private synchronized void verifyToken() throws GardenaException {
204 Fields fields = new Fields();
205 fields.add("client_id", config.getApiKey());
207 PostOAuth2Response token = this.token;
208 if (token == null || token.isRefreshTokenExpired()) {
210 logger.debug("Gardena API login using password, reason: {}",
211 token == null ? "no token available" : "refresh token expired");
212 fields.add("grant_type", "password");
213 fields.add("username", config.getEmail());
214 fields.add("password", config.getPassword());
215 token = executeRequest(HttpMethod.POST, URL_API_TOKEN, fields, PostOAuth2Response.class);
218 } else if (token.isAccessTokenExpired()) {
220 logger.debug("Gardena API login using refreshToken, reason: access token expired");
221 fields.add("grant_type", "refresh_token");
222 fields.add("refresh_token", token.refreshToken);
224 PostOAuth2Response tempToken = executeRequest(HttpMethod.POST, URL_API_TOKEN, fields,
225 PostOAuth2Response.class);
226 token.accessToken = tempToken.accessToken;
227 token.expiresIn = tempToken.expiresIn;
230 } catch (GardenaException ex) {
231 // refresh token issue
236 logger.debug("Gardena API token valid");
238 logger.debug("{}", token.toString());
242 * Loads all locations.
244 private LocationsResponse loadLocations() throws GardenaException {
245 return executeRequest(HttpMethod.GET, URL_API_LOCATIONS, null, LocationsResponse.class);
249 * Loads all devices for a given location.
251 private LocationResponse loadLocation(String locationId) throws GardenaException {
252 return executeRequest(HttpMethod.GET, URL_API_LOCATIONS + "/" + locationId, null, LocationResponse.class);
256 * Returns the websocket url for a given location.
258 private WebSocketCreatedResponse getWebsocketInfo(String locationId) throws GardenaException {
259 return executeRequest(HttpMethod.POST, URL_API_WEBSOCKET, new CreateWebSocketRequest(locationId),
260 WebSocketCreatedResponse.class);
266 public void dispose() {
267 logger.debug("Disposing GardenaSmart");
269 final ScheduledFuture<?> newDeviceFuture = this.newDeviceFuture;
270 if (newDeviceFuture != null) {
271 newDeviceFuture.cancel(true);
274 final ScheduledFuture<?> deviceToNotifyFuture = this.deviceToNotifyFuture;
275 if (deviceToNotifyFuture != null) {
276 deviceToNotifyFuture.cancel(true);
281 } catch (Exception e) {
284 httpClient.destroy();
285 locationsResponse = new LocationsResponse();
286 allDevicesById.clear();
291 * Restarts all websockets.
294 public synchronized void restartWebsockets() {
295 logger.debug("Restarting GardenaSmart Webservice");
299 } catch (Exception ex) {
300 logger.warn("Restarting GardenaSmart Webservice failed: {}, restarting binding", ex.getMessage());
301 eventListener.onError();
306 * Sets the dataItem from the websocket event into the correct device.
308 private void handleDataItem(final DataItem<?> dataItem) throws GardenaException {
309 final String deviceId = dataItem.getDeviceId();
310 Device device = allDevicesById.get(deviceId);
311 if (device == null && !(dataItem instanceof LocationDataItem)) {
312 device = new Device(deviceId);
313 allDevicesById.put(device.id, device);
316 newDeviceFuture = scheduler.schedule(() -> {
317 Device newDevice = allDevicesById.get(deviceId);
318 if (newDevice != null) {
319 newDevice.evaluateDeviceType();
320 if (newDevice.deviceType != null) {
321 eventListener.onNewDevice(newDevice);
324 }, 3, TimeUnit.SECONDS);
328 if (device != null) {
329 device.setDataItem(dataItem);
334 public void onWebSocketClose() {
339 public void onWebSocketError() {
340 eventListener.onError();
344 public void onWebSocketMessage(String msg) {
346 DataItem<?> dataItem = gson.fromJson(msg, DataItem.class);
347 if (dataItem != null) {
348 handleDataItem(dataItem);
349 Device device = allDevicesById.get(dataItem.getDeviceId());
350 if (device != null && device.active) {
351 devicesToNotify.add(device);
353 // delay the deviceUpdated event to filter multiple events for the same device dataItem property
354 if (deviceToNotifyFuture == null) {
355 deviceToNotifyFuture = scheduler.schedule(() -> {
356 deviceToNotifyFuture = null;
357 Iterator<Device> notifyIterator = devicesToNotify.iterator();
358 while (notifyIterator.hasNext()) {
359 eventListener.onDeviceUpdated(notifyIterator.next());
360 notifyIterator.remove();
362 }, 1, TimeUnit.SECONDS);
366 } catch (GardenaException | JsonSyntaxException ex) {
367 logger.warn("Ignoring message: {}", ex.getMessage());
372 public Device getDevice(String deviceId) throws GardenaDeviceNotFoundException {
373 Device device = allDevicesById.get(deviceId);
374 if (device == null) {
375 throw new GardenaDeviceNotFoundException("Device with id " + deviceId + " not found");
381 public void sendCommand(DataItem<?> dataItem, GardenaCommand gardenaCommand) throws GardenaException {
382 executeRequest(HttpMethod.PUT, URL_API_COMMAND + "/" + dataItem.id, new GardenaCommandRequest(gardenaCommand),
387 public String getId() {
392 public Collection<Device> getAllDevices() {
393 return allDevicesById.values();