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