]> git.basschouten.com Git - openhab-addons.git/blob
e39607c1e2c08e55c1a45a7a493c3966920f487a
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2020 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.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;
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         logger.debug("API: Creating OAuth Client Service");
121         OAuthClientService service = oAuthFactory.createOAuthClientService(
122                 bridgeHandler.getThing().getUID().getAsString(), ECOBEE_TOKEN_URL, null, apiKey, "", ECOBEE_SCOPE,
123                 false);
124         service.addAccessTokenRefreshListener(this);
125         ecobeeAuth = new EcobeeAuth(bridgeHandler, apiKey, apiTimeout, service, httpClient);
126         oAuthClientService = service;
127     }
128
129     public void deleteOAuthClientService() {
130         logger.debug("API: Deleting OAuth Client Service");
131         oAuthClientService.removeAccessTokenRefreshListener(this);
132         oAuthFactory.deleteServiceAndAccessToken(bridgeHandler.getThing().getUID().getAsString());
133     }
134
135     public void closeOAuthClientService() {
136         logger.debug("API: Closing OAuth Client Service");
137         oAuthClientService.removeAccessTokenRefreshListener(this);
138         oAuthFactory.ungetOAuthService(bridgeHandler.getThing().getUID().getAsString());
139     }
140
141     /**
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.
146      */
147     private boolean isAuthorized() {
148         boolean isAuthorized = false;
149         try {
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();
156                 }
157                 ecobeeAuth.setState(EcobeeAuthState.COMPLETE);
158                 isAuthorized = true;
159             } else {
160                 logger.debug("API: Didn't get an AccessTokenResponse from OAuth service - doEcobeeAuthorization!!!");
161                 if (ecobeeAuth.isComplete()) {
162                     ecobeeAuth.setState(EcobeeAuthState.NEED_PIN);
163                 }
164             }
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);
174         }
175         return isAuthorized;
176     }
177
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();
184         } else {
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());
188         }
189     }
190
191     @Override
192     public void onAccessTokenResponse(AccessTokenResponse accessTokenResponse) {
193     }
194
195     public @Nullable SummaryResponseDTO performThermostatSummaryQuery() {
196         logger.debug("API: Perform thermostat summary query");
197         if (!isAuthorized()) {
198             return null;
199         }
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) {
206             try {
207                 SummaryResponseDTO summaryResponse = GSON.fromJson(response, SummaryResponseDTO.class);
208                 if (isSuccess(summaryResponse)) {
209                     return summaryResponse;
210                 }
211             } catch (JsonSyntaxException e) {
212                 logJSException(e, response);
213             }
214         }
215         return null;
216     }
217
218     public List<ThermostatDTO> queryRegisteredThermostats() {
219         return performThermostatQuery(null);
220     }
221
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;
226         }
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) {
232             try {
233                 ThermostatResponseDTO thermostatsResponse = GSON.fromJson(response, ThermostatResponseDTO.class);
234                 if (isSuccess(thermostatsResponse)) {
235                     return thermostatsResponse.thermostatList;
236                 }
237             } catch (JsonSyntaxException e) {
238                 logJSException(e, response);
239             }
240         }
241         return EMPTY_THERMOSTATS;
242     }
243
244     public boolean performThermostatFunction(FunctionRequest request) {
245         logger.debug("API: Perform function on thermostat: '{}'", request.selection.selectionMatch);
246         if (!isAuthorized()) {
247             return false;
248         }
249         return executePost(ECOBEE_THERMOSTAT_URL, GSON.toJson(request, FunctionRequest.class));
250     }
251
252     public boolean performThermostatUpdate(ThermostatUpdateRequestDTO request) {
253         logger.debug("API: Perform update on thermostat: '{}'", request.selection.selectionMatch);
254         if (!isAuthorized()) {
255             return false;
256         }
257         return executePost(ECOBEE_THERMOSTAT_UPDATE_URL, GSON.toJson(request, ThermostatUpdateRequestDTO.class));
258     }
259
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();
265     }
266
267     private @Nullable String executeGet(String url, String json) {
268         String response = null;
269         try {
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) {
275             logIOException(e);
276         } catch (EcobeeAuthException e) {
277             logger.info("API: Unable to execute GET: {}", e.getMessage());
278         }
279         return response;
280     }
281
282     private boolean executePost(String url, String json) {
283         try {
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);
289             try {
290                 ThermostatResponseDTO thermostatsResponse = GSON.fromJson(response, ThermostatResponseDTO.class);
291                 return isSuccess(thermostatsResponse);
292             } catch (JsonSyntaxException e) {
293                 logJSException(e, response);
294             }
295         } catch (IOException e) {
296             logIOException(e);
297         } catch (EcobeeAuthException e) {
298             logger.info("API: Unable to execute POST: {}", e.getMessage());
299         }
300         return false;
301     }
302
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());
309         } else {
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);
312         }
313     }
314
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);
319     }
320
321     private boolean isSuccess(@Nullable AbstractResponseDTO response) {
322         boolean success = true;
323         if (response == null) {
324             logger.info("API: Ecobee API returned null response");
325             success = false;
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");
339                 }
340             }
341             success = false;
342         }
343         return success;
344     }
345
346     private Properties setHeaders() throws EcobeeAuthException {
347         AccessTokenResponse atr = accessTokenResponse;
348         if (atr == null) {
349             throw new EcobeeAuthException("Can not set auth header because access token is null");
350         }
351         Properties headers = new Properties();
352         headers.putAll(HTTP_HEADERS);
353         headers.put("Authorization", "Bearer " + atr.getAccessToken());
354         return headers;
355     }
356 }