2 * Copyright (c) 2010-2024 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.net.URLEncoder;
21 import java.nio.charset.StandardCharsets;
22 import java.time.Instant;
23 import java.time.LocalDateTime;
24 import java.util.List;
25 import java.util.Properties;
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.openhab.binding.ecobee.internal.dto.AbstractResponseDTO;
33 import org.openhab.binding.ecobee.internal.dto.SelectionDTO;
34 import org.openhab.binding.ecobee.internal.dto.SelectionType;
35 import org.openhab.binding.ecobee.internal.dto.thermostat.InstantDeserializer;
36 import org.openhab.binding.ecobee.internal.dto.thermostat.LocalDateTimeDeserializer;
37 import org.openhab.binding.ecobee.internal.dto.thermostat.ThermostatDTO;
38 import org.openhab.binding.ecobee.internal.dto.thermostat.ThermostatRequestDTO;
39 import org.openhab.binding.ecobee.internal.dto.thermostat.ThermostatResponseDTO;
40 import org.openhab.binding.ecobee.internal.dto.thermostat.ThermostatUpdateRequestDTO;
41 import org.openhab.binding.ecobee.internal.dto.thermostat.summary.RevisionDTO;
42 import org.openhab.binding.ecobee.internal.dto.thermostat.summary.RevisionDTODeserializer;
43 import org.openhab.binding.ecobee.internal.dto.thermostat.summary.RunningDTO;
44 import org.openhab.binding.ecobee.internal.dto.thermostat.summary.RunningDTODeserializer;
45 import org.openhab.binding.ecobee.internal.dto.thermostat.summary.SummaryResponseDTO;
46 import org.openhab.binding.ecobee.internal.function.FunctionRequest;
47 import org.openhab.binding.ecobee.internal.handler.EcobeeAccountBridgeHandler;
48 import org.openhab.binding.ecobee.internal.util.ExceptionUtils;
49 import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
50 import org.openhab.core.auth.client.oauth2.OAuthClientService;
51 import org.openhab.core.auth.client.oauth2.OAuthException;
52 import org.openhab.core.auth.client.oauth2.OAuthFactory;
53 import org.openhab.core.auth.client.oauth2.OAuthResponseException;
54 import org.openhab.core.io.net.http.HttpUtil;
55 import org.openhab.core.thing.ThingStatus;
56 import org.openhab.core.thing.ThingStatusDetail;
57 import org.slf4j.Logger;
58 import org.slf4j.LoggerFactory;
60 import com.google.gson.Gson;
61 import com.google.gson.GsonBuilder;
62 import com.google.gson.JsonSyntaxException;
65 * The {@link EcobeeApi} is responsible for managing all communication with
66 * the Ecobee API service.
68 * @author Mark Hilbush - Initial contribution
71 public class EcobeeApi {
73 private static final Gson GSON = new GsonBuilder().registerTypeAdapter(Instant.class, new InstantDeserializer())
74 .registerTypeAdapter(LocalDateTime.class, new LocalDateTimeDeserializer())
75 .registerTypeAdapter(RevisionDTO.class, new RevisionDTODeserializer())
76 .registerTypeAdapter(RunningDTO.class, new RunningDTODeserializer()).create();
78 private static final String ECOBEE_THERMOSTAT_URL = ECOBEE_BASE_URL + "1/thermostat";
79 private static final String ECOBEE_THERMOSTAT_SUMMARY_URL = ECOBEE_BASE_URL + "1/thermostatSummary";
80 private static final String ECOBEE_THERMOSTAT_UPDATE_URL = ECOBEE_THERMOSTAT_URL + "?format=json";
82 // These errors from the API will require an Ecobee authorization
83 private static final int ECOBEE_TOKEN_EXPIRED = 14;
84 private static final int ECOBEE_DEAUTHORIZED_TOKEN = 16;
85 private static final int TOKEN_EXPIRES_IN_BUFFER_SECONDS = 120;
87 public static final Properties HTTP_HEADERS;
89 HTTP_HEADERS = new Properties();
90 HTTP_HEADERS.put("Content-Type", "application/json;charset=UTF-8");
91 HTTP_HEADERS.put("User-Agent", "openhab-ecobee-api/2.0");
94 public static Gson getGson() {
98 private final Logger logger = LoggerFactory.getLogger(EcobeeApi.class);
100 private final EcobeeAccountBridgeHandler bridgeHandler;
102 private final String apiKey;
103 private int apiTimeout;
104 private final OAuthFactory oAuthFactory;
105 private final HttpClient httpClient;
107 private @NonNullByDefault({}) OAuthClientService oAuthClientService;
108 private @NonNullByDefault({}) EcobeeAuth ecobeeAuth;
110 private @Nullable AccessTokenResponse accessTokenResponse;
112 public EcobeeApi(final EcobeeAccountBridgeHandler bridgeHandler, final String apiKey, final int apiTimeout,
113 org.openhab.core.auth.client.oauth2.OAuthFactory oAuthFactory, HttpClient httpClient) {
114 this.bridgeHandler = bridgeHandler;
115 this.apiKey = apiKey;
116 this.apiTimeout = apiTimeout;
117 this.oAuthFactory = oAuthFactory;
118 this.httpClient = httpClient;
120 createOAuthClientService();
123 public void createOAuthClientService() {
124 String bridgeUID = bridgeHandler.getThing().getUID().getAsString();
125 logger.debug("API: Creating OAuth Client Service for {}", bridgeUID);
126 OAuthClientService service = oAuthFactory.createOAuthClientService(bridgeUID, ECOBEE_TOKEN_URL, null, apiKey,
127 "", ECOBEE_SCOPE, false);
128 ecobeeAuth = new EcobeeAuth(bridgeHandler, apiKey, apiTimeout, service, httpClient);
129 oAuthClientService = service;
132 public void deleteOAuthClientService() {
133 String bridgeUID = bridgeHandler.getThing().getUID().getAsString();
134 logger.debug("API: Deleting OAuth Client Service for {}", bridgeUID);
135 oAuthFactory.deleteServiceAndAccessToken(bridgeUID);
138 public void closeOAuthClientService() {
139 String bridgeUID = bridgeHandler.getThing().getUID().getAsString();
140 logger.debug("API: Closing OAuth Client Service for {}", bridgeUID);
141 oAuthFactory.ungetOAuthService(bridgeUID);
145 * Check to see if the Ecobee authorization process is complete. This will be determined
146 * by requesting an AccessTokenResponse from the OHC OAuth service. If we get a valid
147 * response, then assume that the Ecobee authorization process is complete. Otherwise,
148 * start the Ecobee authorization process.
150 private boolean isAuthorized() {
151 boolean isAuthorized = false;
153 AccessTokenResponse localAccessTokenResponse = oAuthClientService.getAccessTokenResponse();
154 if (localAccessTokenResponse != null) {
155 logger.trace("API: Got AccessTokenResponse from OAuth service: {}", localAccessTokenResponse);
156 if (localAccessTokenResponse.isExpired(Instant.now(), TOKEN_EXPIRES_IN_BUFFER_SECONDS)) {
157 logger.debug("API: Token is expiring soon. Refresh it now");
158 localAccessTokenResponse = oAuthClientService.refreshToken();
160 ecobeeAuth.setState(EcobeeAuthState.COMPLETE);
163 logger.debug("API: Didn't get an AccessTokenResponse from OAuth service - doEcobeeAuthorization!!!");
164 if (ecobeeAuth.isComplete()) {
165 ecobeeAuth.setState(EcobeeAuthState.NEED_PIN);
168 accessTokenResponse = localAccessTokenResponse;
169 ecobeeAuth.doAuthorization();
170 } catch (OAuthException | IOException | RuntimeException e) {
171 if (logger.isDebugEnabled()) {
172 logger.warn("API: Got exception trying to get access token from OAuth service", e);
174 logger.warn("API: Got {} trying to get access token from OAuth service: {}",
175 e.getClass().getSimpleName(), e.getMessage());
177 } catch (EcobeeAuthException e) {
178 if (logger.isDebugEnabled()) {
179 logger.warn("API: The Ecobee authorization process threw an exception", e);
181 logger.warn("API: The Ecobee authorization process threw an exception: {}", e.getMessage());
183 ecobeeAuth.setState(EcobeeAuthState.NEED_PIN);
184 } catch (OAuthResponseException e) {
185 handleOAuthException(e);
190 private void handleOAuthException(OAuthResponseException e) {
191 if ("invalid_grant".equalsIgnoreCase(e.getError())) {
192 // Usually indicates that the refresh token is no longer valid and will require reauthorization
193 logger.debug("API: Received 'invalid_grant' error response. Please reauthorize application with Ecobee");
194 deleteOAuthClientService();
195 createOAuthClientService();
197 // Other errors may not require reauthorization and/or may not apply
198 logger.debug("API: Exception getting access token: error='{}', description='{}'", e.getError(),
199 e.getErrorDescription());
203 public @Nullable SummaryResponseDTO performThermostatSummaryQuery() {
204 logger.debug("API: Perform thermostat summary query");
205 if (!isAuthorized()) {
208 SelectionDTO selection = new SelectionDTO();
209 selection.selectionType = SelectionType.REGISTERED;
210 selection.includeEquipmentStatus = Boolean.TRUE;
211 String requestJson = GSON.toJson(new ThermostatRequestDTO(selection), ThermostatRequestDTO.class);
212 String response = executeGet(ECOBEE_THERMOSTAT_SUMMARY_URL, requestJson);
213 if (response != null) {
215 SummaryResponseDTO summaryResponse = GSON.fromJson(response, SummaryResponseDTO.class);
216 if (isSuccess(summaryResponse)) {
217 return summaryResponse;
219 } catch (JsonSyntaxException e) {
220 logJSException(e, response);
226 public List<ThermostatDTO> queryRegisteredThermostats() {
227 return performThermostatQuery(null);
230 public List<ThermostatDTO> performThermostatQuery(final @Nullable Set<String> thermostatIds) {
231 logger.debug("API: Perform query on thermostat: '{}'", thermostatIds);
232 if (!isAuthorized()) {
233 return EMPTY_THERMOSTATS;
235 SelectionDTO selection = bridgeHandler.getSelection();
236 selection.setThermostats(thermostatIds);
237 String requestJson = GSON.toJson(new ThermostatRequestDTO(selection), ThermostatRequestDTO.class);
238 String response = executeGet(ECOBEE_THERMOSTAT_URL, requestJson);
239 if (response != null) {
241 ThermostatResponseDTO thermostatsResponse = GSON.fromJson(response, ThermostatResponseDTO.class);
242 if (thermostatsResponse != null && isSuccess(thermostatsResponse)) {
243 if (thermostatsResponse.thermostatList != null) {
244 return thermostatsResponse.thermostatList;
247 } catch (JsonSyntaxException e) {
248 logJSException(e, response);
251 return EMPTY_THERMOSTATS;
254 public boolean performThermostatFunction(FunctionRequest request) {
255 logger.debug("API: Perform function on thermostat: '{}'", request.selection.selectionMatch);
256 if (!isAuthorized()) {
259 return executePost(ECOBEE_THERMOSTAT_URL, GSON.toJson(request, FunctionRequest.class));
262 public boolean performThermostatUpdate(ThermostatUpdateRequestDTO request) {
263 logger.debug("API: Perform update on thermostat: '{}'", request.selection.selectionMatch);
264 if (!isAuthorized()) {
267 return executePost(ECOBEE_THERMOSTAT_UPDATE_URL, GSON.toJson(request, ThermostatUpdateRequestDTO.class));
270 private String buildQueryUrl(String baseUrl, String requestJson) {
271 final StringBuilder urlBuilder = new StringBuilder(baseUrl);
272 urlBuilder.append("?json=");
273 urlBuilder.append(URLEncoder.encode(requestJson, StandardCharsets.UTF_8));
274 return urlBuilder.toString();
277 private @Nullable String executeGet(String url, String json) {
278 String response = null;
280 long startTime = System.currentTimeMillis();
281 logger.trace("API: Get Request json is '{}'", json);
282 response = HttpUtil.executeUrl("GET", buildQueryUrl(url, json), setHeaders(), null, null, apiTimeout);
283 logger.trace("API: Response took {} msec: {}", System.currentTimeMillis() - startTime, response);
284 } catch (IOException e) {
286 } catch (EcobeeAuthException e) {
287 logger.debug("API: Unable to execute GET: {}", e.getMessage());
292 private boolean executePost(String url, String json) {
294 logger.trace("API: Post request json is '{}'", json);
295 long startTime = System.currentTimeMillis();
296 String response = HttpUtil.executeUrl("POST", url, setHeaders(), new ByteArrayInputStream(json.getBytes()),
297 "application/json", apiTimeout);
298 logger.trace("API: Response took {} msec: {}", System.currentTimeMillis() - startTime, response);
300 ThermostatResponseDTO thermostatsResponse = GSON.fromJson(response, ThermostatResponseDTO.class);
301 return isSuccess(thermostatsResponse);
302 } catch (JsonSyntaxException e) {
303 logJSException(e, response);
305 } catch (IOException e) {
307 } catch (EcobeeAuthException e) {
308 logger.debug("API: Unable to execute POST: {}", e.getMessage());
313 private void logIOException(Exception e) {
314 Throwable rootCause = ExceptionUtils.getRootThrowable(e);
315 if (rootCause instanceof TimeoutException || rootCause instanceof EOFException) {
316 // These are "normal" errors and should be logged as DEBUG
317 logger.debug("API: Call to Ecobee API failed with exception: {}: {}", rootCause.getClass().getSimpleName(),
318 rootCause.getMessage());
320 // What's left are unexpected errors that should be logged as WARN with a full stack trace
321 logger.warn("API: Call to Ecobee API failed", e);
325 private void logJSException(Exception e, String response) {
326 // The API sometimes returns an HTML page complaining of an SSL error
327 logger.debug("API: JsonSyntaxException parsing response: {}", response, e);
330 private boolean isSuccess(@Nullable AbstractResponseDTO response) {
331 if (response == null) {
332 logger.debug("API: Ecobee API returned null response");
333 } else if (response.status.code.intValue() != 0) {
334 logger.debug("API: Ecobee API returned unsuccessful status: code={}, message={}", response.status.code,
335 response.status.message);
336 if (response.status.code == ECOBEE_DEAUTHORIZED_TOKEN) {
337 // Token has been deauthorized, so restart the authorization process from the beginning
338 logger.warn("API: Reset OAuth Client Service due to deauthorized token");
339 deleteOAuthClientService();
340 createOAuthClientService();
341 } else if (response.status.code == ECOBEE_TOKEN_EXPIRED) {
342 logger.debug("API: Unable to complete API call because token is expired. Try to refresh the token...");
343 // Log some additional debug information about the current AccessTokenResponse
344 AccessTokenResponse localAccessTokenResponse = accessTokenResponse;
345 if (localAccessTokenResponse != null) {
346 logger.debug("API: AccessTokenResponse created on: {}", localAccessTokenResponse.getCreatedOn());
347 logger.debug("API: AccessTokenResponse expires in: {}", localAccessTokenResponse.getExpiresIn());
349 // Recreating the OAuthClientService seems to be the only way to handle this error
350 closeOAuthClientService();
351 createOAuthClientService();
352 if (isAuthorized()) {
355 logger.warn("API: isAuthorized was NOT successful on second try");
356 bridgeHandler.updateBridgeStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
357 "Unable to refresh access token");
361 bridgeHandler.updateBridgeStatus(ThingStatus.ONLINE);
367 private Properties setHeaders() throws EcobeeAuthException {
368 AccessTokenResponse atr = accessTokenResponse;
370 throw new EcobeeAuthException("Can not set auth header because access token is null");
372 Properties headers = new Properties();
373 headers.putAll(HTTP_HEADERS);
374 headers.put("Authorization", "Bearer " + atr.getAccessToken());