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