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.ecobee.internal.api;
15 import static org.openhab.binding.ecobee.internal.EcobeeBindingConstants.*;
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;
27 import java.util.concurrent.TimeoutException;
29 import org.apache.commons.lang.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;
57 import com.google.gson.Gson;
58 import com.google.gson.GsonBuilder;
59 import com.google.gson.JsonSyntaxException;
62 * The {@link EcobeeApi} is responsible for managing all communication with
63 * the Ecobee API service.
65 * @author Mark Hilbush - Initial contribution
68 public class EcobeeApi implements AccessTokenRefreshListener {
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();
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";
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;
83 public static final Properties HTTP_HEADERS;
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");
90 public static Gson getGson() {
94 private final Logger logger = LoggerFactory.getLogger(EcobeeApi.class);
96 private final EcobeeAccountBridgeHandler bridgeHandler;
98 private final String apiKey;
99 private int apiTimeout;
100 private final OAuthFactory oAuthFactory;
101 private final HttpClient httpClient;
103 private @NonNullByDefault({}) OAuthClientService oAuthClientService;
104 private @NonNullByDefault({}) EcobeeAuth ecobeeAuth;
106 private @Nullable AccessTokenResponse accessTokenResponse;
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;
116 createOAuthClientService();
119 public void createOAuthClientService() {
120 logger.debug("API: Creating OAuth Client Service");
121 OAuthClientService service = oAuthFactory.createOAuthClientService(
122 bridgeHandler.getThing().getUID().getAsString(), ECOBEE_TOKEN_URL, null, apiKey, "", ECOBEE_SCOPE,
124 service.addAccessTokenRefreshListener(this);
125 ecobeeAuth = new EcobeeAuth(bridgeHandler, apiKey, apiTimeout, service, httpClient);
126 oAuthClientService = service;
129 public void deleteOAuthClientService() {
130 logger.debug("API: Deleting OAuth Client Service");
131 oAuthClientService.removeAccessTokenRefreshListener(this);
132 oAuthFactory.deleteServiceAndAccessToken(bridgeHandler.getThing().getUID().getAsString());
135 public void closeOAuthClientService() {
136 logger.debug("API: Closing OAuth Client Service");
137 oAuthClientService.removeAccessTokenRefreshListener(this);
138 oAuthFactory.ungetOAuthService(bridgeHandler.getThing().getUID().getAsString());
142 * Check to see if the Ecobee authorization process is complete. This will be determined
143 * by requesting an AccessTokenResponse from the OHC OAuth service. If we get a valid
144 * response, then assume that the Ecobee authorization process is complete. Otherwise,
145 * start the Ecobee authorization process.
147 private boolean isAuthorized() {
148 boolean isAuthorized = false;
150 AccessTokenResponse localAccessTokenResponse = oAuthClientService.getAccessTokenResponse();
151 if (localAccessTokenResponse != null) {
152 logger.trace("API: Got AccessTokenResponse from OAuth service: {}", localAccessTokenResponse);
153 if (localAccessTokenResponse.isExpired(LocalDateTime.now(), TOKEN_EXPIRES_IN_BUFFER_SECONDS)) {
154 logger.debug("API: Token is expiring soon. Refresh it now");
155 localAccessTokenResponse = oAuthClientService.refreshToken();
157 ecobeeAuth.setState(EcobeeAuthState.COMPLETE);
160 logger.debug("API: Didn't get an AccessTokenResponse from OAuth service - doEcobeeAuthorization!!!");
161 if (ecobeeAuth.isComplete()) {
162 ecobeeAuth.setState(EcobeeAuthState.NEED_PIN);
165 accessTokenResponse = localAccessTokenResponse;
166 ecobeeAuth.doAuthorization();
167 } catch (OAuthException | IOException | RuntimeException e) {
168 logger.info("API: Got exception trying to get access token from OAuth service", e);
169 } catch (EcobeeAuthException e) {
170 logger.info("API: The Ecobee authorization process threw an exception", e);
171 ecobeeAuth.setState(EcobeeAuthState.NEED_PIN);
172 } catch (OAuthResponseException e) {
173 handleOAuthException(e);
178 private void handleOAuthException(OAuthResponseException e) {
179 if ("invalid_grant".equalsIgnoreCase(e.getError())) {
180 // Usually indicates that the refresh token is no longer valid and will require reauthorization
181 logger.warn("API: Received 'invalid_grant' error response. Please reauthorize application with Ecobee");
182 deleteOAuthClientService();
183 createOAuthClientService();
185 // Other errors may not require reauthorization and/or may not apply
186 logger.warn("API: Exception getting access token: error='{}', description='{}'", e.getError(),
187 e.getErrorDescription());
192 public void onAccessTokenResponse(AccessTokenResponse accessTokenResponse) {
195 public @Nullable SummaryResponseDTO performThermostatSummaryQuery() {
196 logger.debug("API: Perform thermostat summary query");
197 if (!isAuthorized()) {
200 SelectionDTO selection = new SelectionDTO();
201 selection.selectionType = SelectionType.REGISTERED;
202 selection.includeEquipmentStatus = Boolean.TRUE;
203 String requestJson = GSON.toJson(new ThermostatRequestDTO(selection), ThermostatRequestDTO.class);
204 String response = executeGet(ECOBEE_THERMOSTAT_SUMMARY_URL, requestJson);
205 if (response != null) {
207 SummaryResponseDTO summaryResponse = GSON.fromJson(response, SummaryResponseDTO.class);
208 if (isSuccess(summaryResponse)) {
209 return summaryResponse;
211 } catch (JsonSyntaxException e) {
212 logJSException(e, response);
218 public List<ThermostatDTO> queryRegisteredThermostats() {
219 return performThermostatQuery(null);
222 public List<ThermostatDTO> performThermostatQuery(final @Nullable Set<String> thermostatIds) {
223 logger.debug("API: Perform query on thermostat: '{}'", thermostatIds);
224 if (!isAuthorized()) {
225 return EMPTY_THERMOSTATS;
227 SelectionDTO selection = bridgeHandler.getSelection();
228 selection.setThermostats(thermostatIds);
229 String requestJson = GSON.toJson(new ThermostatRequestDTO(selection), ThermostatRequestDTO.class);
230 String response = executeGet(ECOBEE_THERMOSTAT_URL, requestJson);
231 if (response != null) {
233 ThermostatResponseDTO thermostatsResponse = GSON.fromJson(response, ThermostatResponseDTO.class);
234 if (isSuccess(thermostatsResponse)) {
235 return thermostatsResponse.thermostatList;
237 } catch (JsonSyntaxException e) {
238 logJSException(e, response);
241 return EMPTY_THERMOSTATS;
244 public boolean performThermostatFunction(FunctionRequest request) {
245 logger.debug("API: Perform function on thermostat: '{}'", request.selection.selectionMatch);
246 if (!isAuthorized()) {
249 return executePost(ECOBEE_THERMOSTAT_URL, GSON.toJson(request, FunctionRequest.class));
252 public boolean performThermostatUpdate(ThermostatUpdateRequestDTO request) {
253 logger.debug("API: Perform update on thermostat: '{}'", request.selection.selectionMatch);
254 if (!isAuthorized()) {
257 return executePost(ECOBEE_THERMOSTAT_UPDATE_URL, GSON.toJson(request, ThermostatUpdateRequestDTO.class));
260 private String buildQueryUrl(String baseUrl, String requestJson) throws UnsupportedEncodingException {
261 final StringBuilder urlBuilder = new StringBuilder(baseUrl);
262 urlBuilder.append("?json=");
263 urlBuilder.append(URLEncoder.encode(requestJson, StandardCharsets.UTF_8.toString()));
264 return urlBuilder.toString();
267 private @Nullable String executeGet(String url, String json) {
268 String response = null;
270 long startTime = System.currentTimeMillis();
271 logger.trace("API: Get Request json is '{}'", json);
272 response = HttpUtil.executeUrl("GET", buildQueryUrl(url, json), setHeaders(), null, null, apiTimeout);
273 logger.trace("API: Response took {} msec: {}", System.currentTimeMillis() - startTime, response);
274 } catch (IOException e) {
276 } catch (EcobeeAuthException e) {
277 logger.info("API: Unable to execute GET: {}", e.getMessage());
282 private boolean executePost(String url, String json) {
284 logger.trace("API: Post request json is '{}'", json);
285 long startTime = System.currentTimeMillis();
286 String response = HttpUtil.executeUrl("POST", url, setHeaders(), new ByteArrayInputStream(json.getBytes()),
287 "application/json", apiTimeout);
288 logger.trace("API: Response took {} msec: {}", System.currentTimeMillis() - startTime, response);
290 ThermostatResponseDTO thermostatsResponse = GSON.fromJson(response, ThermostatResponseDTO.class);
291 return isSuccess(thermostatsResponse);
292 } catch (JsonSyntaxException e) {
293 logJSException(e, response);
295 } catch (IOException e) {
297 } catch (EcobeeAuthException e) {
298 logger.info("API: Unable to execute POST: {}", e.getMessage());
303 private void logIOException(Exception e) {
304 Throwable rootCause = ExceptionUtils.getRootCause(e);
305 if (rootCause instanceof TimeoutException || rootCause instanceof EOFException) {
306 // These are "normal" errors and should be logged as DEBUG
307 logger.debug("API: Call to Ecobee API failed with exception: {}: {}", rootCause.getClass().getSimpleName(),
308 rootCause.getMessage());
310 // What's left are unexpected errors that should be logged as INFO with a full stack trace
311 logger.info("API: Call to Ecobee API failed", e);
315 private void logJSException(Exception e, String response) {
316 // The API sometimes returns an HTML page complaining of an SSL error
317 // Otherwise, this probably should be INFO level
318 logger.debug("API: JsonSyntaxException parsing response: {}", response, e);
321 private boolean isSuccess(@Nullable AbstractResponseDTO response) {
322 boolean success = true;
323 if (response == null) {
324 logger.info("API: Ecobee API returned null response");
326 } else if (response.status.code.intValue() != 0) {
327 logger.info("API: Ecobee API returned unsuccessful status: code={}, message={}", response.status.code,
328 response.status.message);
329 if (response.status.code == ECOBEE_DEAUTHORIZED_TOKEN) {
330 // Token has been deauthorized, so restart the authorization process from the beginning
331 logger.warn("API: Reset OAuth Client Service due to deauthorized token");
332 deleteOAuthClientService();
333 createOAuthClientService();
334 } else if (response.status.code == ECOBEE_TOKEN_EXPIRED) {
335 // Check isAuthorized again to see if we can get a valid token
336 logger.info("API: Unable to complete API call because token is expired");
337 if (!isAuthorized()) {
338 logger.warn("API: isAuthorized was NOT successful on second try");
346 private Properties setHeaders() throws EcobeeAuthException {
347 AccessTokenResponse atr = accessTokenResponse;
349 throw new EcobeeAuthException("Can not set auth header because access token is null");
351 Properties headers = new Properties();
352 headers.putAll(HTTP_HEADERS);
353 headers.put("Authorization", "Bearer " + atr.getAccessToken());