]> git.basschouten.com Git - openhab-addons.git/blob
93c28d5e3f9821d08ea13911f27b21c67ae2b349
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.ecobee.internal.api;
14
15 import static org.openhab.binding.ecobee.internal.EcobeeBindingConstants.*;
16
17 import java.io.ByteArrayInputStream;
18 import java.io.EOFException;
19 import java.io.IOException;
20 import java.io.UnsupportedEncodingException;
21 import java.net.URLEncoder;
22 import java.nio.charset.StandardCharsets;
23 import java.time.LocalDateTime;
24 import java.util.List;
25 import java.util.Properties;
26 import java.util.Set;
27 import java.util.concurrent.TimeoutException;
28
29 import org.apache.commons.lang3.exception.ExceptionUtils;
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.eclipse.jetty.client.HttpClient;
33 import org.openhab.binding.ecobee.internal.dto.AbstractResponseDTO;
34 import org.openhab.binding.ecobee.internal.dto.SelectionDTO;
35 import org.openhab.binding.ecobee.internal.dto.SelectionType;
36 import org.openhab.binding.ecobee.internal.dto.thermostat.ThermostatDTO;
37 import org.openhab.binding.ecobee.internal.dto.thermostat.ThermostatRequestDTO;
38 import org.openhab.binding.ecobee.internal.dto.thermostat.ThermostatResponseDTO;
39 import org.openhab.binding.ecobee.internal.dto.thermostat.ThermostatUpdateRequestDTO;
40 import org.openhab.binding.ecobee.internal.dto.thermostat.summary.RevisionDTO;
41 import org.openhab.binding.ecobee.internal.dto.thermostat.summary.RevisionDTODeserializer;
42 import org.openhab.binding.ecobee.internal.dto.thermostat.summary.RunningDTO;
43 import org.openhab.binding.ecobee.internal.dto.thermostat.summary.RunningDTODeserializer;
44 import org.openhab.binding.ecobee.internal.dto.thermostat.summary.SummaryResponseDTO;
45 import org.openhab.binding.ecobee.internal.function.FunctionRequest;
46 import org.openhab.binding.ecobee.internal.handler.EcobeeAccountBridgeHandler;
47 import org.openhab.core.auth.client.oauth2.AccessTokenRefreshListener;
48 import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
49 import org.openhab.core.auth.client.oauth2.OAuthClientService;
50 import org.openhab.core.auth.client.oauth2.OAuthException;
51 import org.openhab.core.auth.client.oauth2.OAuthFactory;
52 import org.openhab.core.auth.client.oauth2.OAuthResponseException;
53 import org.openhab.core.io.net.http.HttpUtil;
54 import org.slf4j.Logger;
55 import org.slf4j.LoggerFactory;
56
57 import com.google.gson.Gson;
58 import com.google.gson.GsonBuilder;
59 import com.google.gson.JsonSyntaxException;
60
61 /**
62  * The {@link EcobeeApi} is responsible for managing all communication with
63  * the Ecobee API service.
64  *
65  * @author Mark Hilbush - Initial contribution
66  */
67 @NonNullByDefault
68 public class EcobeeApi implements AccessTokenRefreshListener {
69
70     private static final Gson GSON = new GsonBuilder().setDateFormat("yyyy-MM-dd HH:mm:ss")
71             .registerTypeAdapter(RevisionDTO.class, new RevisionDTODeserializer())
72             .registerTypeAdapter(RunningDTO.class, new RunningDTODeserializer()).create();
73
74     private static final String ECOBEE_THERMOSTAT_URL = ECOBEE_BASE_URL + "1/thermostat";
75     private static final String ECOBEE_THERMOSTAT_SUMMARY_URL = ECOBEE_BASE_URL + "1/thermostatSummary";
76     private static final String ECOBEE_THERMOSTAT_UPDATE_URL = ECOBEE_THERMOSTAT_URL + "?format=json";
77
78     // These errors from the API will require an Ecobee authorization
79     private static final int ECOBEE_TOKEN_EXPIRED = 14;
80     private static final int ECOBEE_DEAUTHORIZED_TOKEN = 16;
81     private static final int TOKEN_EXPIRES_IN_BUFFER_SECONDS = 120;
82
83     public static final Properties HTTP_HEADERS;
84     static {
85         HTTP_HEADERS = new Properties();
86         HTTP_HEADERS.put("Content-Type", "application/json;charset=UTF-8");
87         HTTP_HEADERS.put("User-Agent", "openhab-ecobee-api/2.0");
88     }
89
90     public static Gson getGson() {
91         return GSON;
92     }
93
94     private final Logger logger = LoggerFactory.getLogger(EcobeeApi.class);
95
96     private final EcobeeAccountBridgeHandler bridgeHandler;
97
98     private final String apiKey;
99     private int apiTimeout;
100     private final OAuthFactory oAuthFactory;
101     private final HttpClient httpClient;
102
103     private @NonNullByDefault({}) OAuthClientService oAuthClientService;
104     private @NonNullByDefault({}) EcobeeAuth ecobeeAuth;
105
106     private @Nullable AccessTokenResponse accessTokenResponse;
107
108     public EcobeeApi(final EcobeeAccountBridgeHandler bridgeHandler, final String apiKey, final int apiTimeout,
109             org.openhab.core.auth.client.oauth2.OAuthFactory oAuthFactory, HttpClient httpClient) {
110         this.bridgeHandler = bridgeHandler;
111         this.apiKey = apiKey;
112         this.apiTimeout = apiTimeout;
113         this.oAuthFactory = oAuthFactory;
114         this.httpClient = httpClient;
115
116         createOAuthClientService();
117     }
118
119     public void createOAuthClientService() {
120         String bridgeUID = bridgeHandler.getThing().getUID().getAsString();
121         logger.debug("API: Creating OAuth Client Service for {}", bridgeUID);
122         OAuthClientService service = oAuthFactory.createOAuthClientService(bridgeUID, ECOBEE_TOKEN_URL, null, apiKey,
123                 "", ECOBEE_SCOPE, false);
124         service.addAccessTokenRefreshListener(this);
125         ecobeeAuth = new EcobeeAuth(bridgeHandler, apiKey, apiTimeout, service, httpClient);
126         oAuthClientService = service;
127     }
128
129     public void deleteOAuthClientService() {
130         String bridgeUID = bridgeHandler.getThing().getUID().getAsString();
131         logger.debug("API: Deleting OAuth Client Service for {}", bridgeUID);
132         oAuthClientService.removeAccessTokenRefreshListener(this);
133         oAuthFactory.deleteServiceAndAccessToken(bridgeUID);
134     }
135
136     public void closeOAuthClientService() {
137         String bridgeUID = bridgeHandler.getThing().getUID().getAsString();
138         logger.debug("API: Closing OAuth Client Service for {}", bridgeUID);
139         oAuthClientService.removeAccessTokenRefreshListener(this);
140         oAuthFactory.ungetOAuthService(bridgeUID);
141     }
142
143     /**
144      * Check to see if the Ecobee authorization process is complete. This will be determined
145      * by requesting an AccessTokenResponse from the OHC OAuth service. If we get a valid
146      * response, then assume that the Ecobee authorization process is complete. Otherwise,
147      * start the Ecobee authorization process.
148      */
149     private boolean isAuthorized() {
150         boolean isAuthorized = false;
151         try {
152             AccessTokenResponse localAccessTokenResponse = oAuthClientService.getAccessTokenResponse();
153             if (localAccessTokenResponse != null) {
154                 logger.trace("API: Got AccessTokenResponse from OAuth service: {}", localAccessTokenResponse);
155                 if (localAccessTokenResponse.isExpired(LocalDateTime.now(), TOKEN_EXPIRES_IN_BUFFER_SECONDS)) {
156                     logger.debug("API: Token is expiring soon. Refresh it now");
157                     localAccessTokenResponse = oAuthClientService.refreshToken();
158                 }
159                 ecobeeAuth.setState(EcobeeAuthState.COMPLETE);
160                 isAuthorized = true;
161             } else {
162                 logger.debug("API: Didn't get an AccessTokenResponse from OAuth service - doEcobeeAuthorization!!!");
163                 if (ecobeeAuth.isComplete()) {
164                     ecobeeAuth.setState(EcobeeAuthState.NEED_PIN);
165                 }
166             }
167             accessTokenResponse = localAccessTokenResponse;
168             ecobeeAuth.doAuthorization();
169         } catch (OAuthException | IOException | RuntimeException e) {
170             if (logger.isDebugEnabled()) {
171                 logger.warn("API: Got exception trying to get access token from OAuth service", e);
172             } else {
173                 logger.warn("API: Got {} trying to get access token from OAuth service: {}",
174                         e.getClass().getSimpleName(), e.getMessage());
175             }
176         } catch (EcobeeAuthException e) {
177             if (logger.isDebugEnabled()) {
178                 logger.warn("API: The Ecobee authorization process threw an exception", e);
179             } else {
180                 logger.warn("API: The Ecobee authorization process threw an exception: {}", e.getMessage());
181             }
182             ecobeeAuth.setState(EcobeeAuthState.NEED_PIN);
183         } catch (OAuthResponseException e) {
184             handleOAuthException(e);
185         }
186         return isAuthorized;
187     }
188
189     private void handleOAuthException(OAuthResponseException e) {
190         if ("invalid_grant".equalsIgnoreCase(e.getError())) {
191             // Usually indicates that the refresh token is no longer valid and will require reauthorization
192             logger.debug("API: Received 'invalid_grant' error response. Please reauthorize application with Ecobee");
193             deleteOAuthClientService();
194             createOAuthClientService();
195         } else {
196             // Other errors may not require reauthorization and/or may not apply
197             logger.debug("API: Exception getting access token: error='{}', description='{}'", e.getError(),
198                     e.getErrorDescription());
199         }
200     }
201
202     @Override
203     public void onAccessTokenResponse(AccessTokenResponse accessTokenResponse) {
204     }
205
206     public @Nullable SummaryResponseDTO performThermostatSummaryQuery() {
207         logger.debug("API: Perform thermostat summary query");
208         if (!isAuthorized()) {
209             return null;
210         }
211         SelectionDTO selection = new SelectionDTO();
212         selection.selectionType = SelectionType.REGISTERED;
213         selection.includeEquipmentStatus = Boolean.TRUE;
214         String requestJson = GSON.toJson(new ThermostatRequestDTO(selection), ThermostatRequestDTO.class);
215         String response = executeGet(ECOBEE_THERMOSTAT_SUMMARY_URL, requestJson);
216         if (response != null) {
217             try {
218                 SummaryResponseDTO summaryResponse = GSON.fromJson(response, SummaryResponseDTO.class);
219                 if (isSuccess(summaryResponse)) {
220                     return summaryResponse;
221                 }
222             } catch (JsonSyntaxException e) {
223                 logJSException(e, response);
224             }
225         }
226         return null;
227     }
228
229     public List<ThermostatDTO> queryRegisteredThermostats() {
230         return performThermostatQuery(null);
231     }
232
233     public List<ThermostatDTO> performThermostatQuery(final @Nullable Set<String> thermostatIds) {
234         logger.debug("API: Perform query on thermostat: '{}'", thermostatIds);
235         if (!isAuthorized()) {
236             return EMPTY_THERMOSTATS;
237         }
238         SelectionDTO selection = bridgeHandler.getSelection();
239         selection.setThermostats(thermostatIds);
240         String requestJson = GSON.toJson(new ThermostatRequestDTO(selection), ThermostatRequestDTO.class);
241         String response = executeGet(ECOBEE_THERMOSTAT_URL, requestJson);
242         if (response != null) {
243             try {
244                 ThermostatResponseDTO thermostatsResponse = GSON.fromJson(response, ThermostatResponseDTO.class);
245                 if (isSuccess(thermostatsResponse)) {
246                     return thermostatsResponse.thermostatList;
247                 }
248             } catch (JsonSyntaxException e) {
249                 logJSException(e, response);
250             }
251         }
252         return EMPTY_THERMOSTATS;
253     }
254
255     public boolean performThermostatFunction(FunctionRequest request) {
256         logger.debug("API: Perform function on thermostat: '{}'", request.selection.selectionMatch);
257         if (!isAuthorized()) {
258             return false;
259         }
260         return executePost(ECOBEE_THERMOSTAT_URL, GSON.toJson(request, FunctionRequest.class));
261     }
262
263     public boolean performThermostatUpdate(ThermostatUpdateRequestDTO request) {
264         logger.debug("API: Perform update on thermostat: '{}'", request.selection.selectionMatch);
265         if (!isAuthorized()) {
266             return false;
267         }
268         return executePost(ECOBEE_THERMOSTAT_UPDATE_URL, GSON.toJson(request, ThermostatUpdateRequestDTO.class));
269     }
270
271     private String buildQueryUrl(String baseUrl, String requestJson) throws UnsupportedEncodingException {
272         final StringBuilder urlBuilder = new StringBuilder(baseUrl);
273         urlBuilder.append("?json=");
274         urlBuilder.append(URLEncoder.encode(requestJson, StandardCharsets.UTF_8.toString()));
275         return urlBuilder.toString();
276     }
277
278     private @Nullable String executeGet(String url, String json) {
279         String response = null;
280         try {
281             long startTime = System.currentTimeMillis();
282             logger.trace("API: Get Request json is '{}'", json);
283             response = HttpUtil.executeUrl("GET", buildQueryUrl(url, json), setHeaders(), null, null, apiTimeout);
284             logger.trace("API: Response took {} msec: {}", System.currentTimeMillis() - startTime, response);
285         } catch (IOException e) {
286             logIOException(e);
287         } catch (EcobeeAuthException e) {
288             logger.debug("API: Unable to execute GET: {}", e.getMessage());
289         }
290         return response;
291     }
292
293     private boolean executePost(String url, String json) {
294         try {
295             logger.trace("API: Post request json is '{}'", json);
296             long startTime = System.currentTimeMillis();
297             String response = HttpUtil.executeUrl("POST", url, setHeaders(), new ByteArrayInputStream(json.getBytes()),
298                     "application/json", apiTimeout);
299             logger.trace("API: Response took {} msec: {}", System.currentTimeMillis() - startTime, response);
300             try {
301                 ThermostatResponseDTO thermostatsResponse = GSON.fromJson(response, ThermostatResponseDTO.class);
302                 return isSuccess(thermostatsResponse);
303             } catch (JsonSyntaxException e) {
304                 logJSException(e, response);
305             }
306         } catch (IOException e) {
307             logIOException(e);
308         } catch (EcobeeAuthException e) {
309             logger.debug("API: Unable to execute POST: {}", e.getMessage());
310         }
311         return false;
312     }
313
314     private void logIOException(Exception e) {
315         Throwable rootCause = ExceptionUtils.getRootCause(e);
316         if (rootCause instanceof TimeoutException || rootCause instanceof EOFException) {
317             // These are "normal" errors and should be logged as DEBUG
318             logger.debug("API: Call to Ecobee API failed with exception: {}: {}", rootCause.getClass().getSimpleName(),
319                     rootCause.getMessage());
320         } else {
321             // What's left are unexpected errors that should be logged as WARN with a full stack trace
322             logger.warn("API: Call to Ecobee API failed", e);
323         }
324     }
325
326     private void logJSException(Exception e, String response) {
327         // The API sometimes returns an HTML page complaining of an SSL error
328         logger.debug("API: JsonSyntaxException parsing response: {}", response, e);
329     }
330
331     private boolean isSuccess(@Nullable AbstractResponseDTO response) {
332         if (response == null) {
333             logger.debug("API: Ecobee API returned null response");
334         } else if (response.status.code.intValue() != 0) {
335             logger.debug("API: Ecobee API returned unsuccessful status: code={}, message={}", response.status.code,
336                     response.status.message);
337             if (response.status.code == ECOBEE_DEAUTHORIZED_TOKEN) {
338                 // Token has been deauthorized, so restart the authorization process from the beginning
339                 logger.warn("API: Reset OAuth Client Service due to deauthorized token");
340                 deleteOAuthClientService();
341                 createOAuthClientService();
342             } else if (response.status.code == ECOBEE_TOKEN_EXPIRED) {
343                 // Check isAuthorized again to see if we can get a valid token
344                 logger.debug("API: Unable to complete API call because token is expired");
345                 if (isAuthorized()) {
346                     return true;
347                 } else {
348                     logger.debug("API: isAuthorized was NOT successful on second try");
349                 }
350             }
351         } else {
352             return true;
353         }
354         return false;
355     }
356
357     private Properties setHeaders() throws EcobeeAuthException {
358         AccessTokenResponse atr = accessTokenResponse;
359         if (atr == null) {
360             throw new EcobeeAuthException("Can not set auth header because access token is null");
361         }
362         Properties headers = new Properties();
363         headers.putAll(HTTP_HEADERS);
364         headers.put("Authorization", "Bearer " + atr.getAccessToken());
365         return headers;
366     }
367 }