]> git.basschouten.com Git - openhab-addons.git/blob
18e1375e8165975d2a470b4da6943a50896e11af
[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             logger.info("API: Exception getting access token: error='{}', description='{}'", e.getError(),
174                     e.getErrorDescription());
175             // How to handle the possible error codes?
176         }
177         return isAuthorized;
178     }
179
180     @Override
181     public void onAccessTokenResponse(AccessTokenResponse accessTokenResponse) {
182     }
183
184     public @Nullable SummaryResponseDTO performThermostatSummaryQuery() {
185         logger.debug("API: Perform thermostat summary query");
186         if (!isAuthorized()) {
187             return null;
188         }
189         SelectionDTO selection = new SelectionDTO();
190         selection.selectionType = SelectionType.REGISTERED;
191         selection.includeEquipmentStatus = Boolean.TRUE;
192         String requestJson = GSON.toJson(new ThermostatRequestDTO(selection), ThermostatRequestDTO.class);
193         String response = executeGet(ECOBEE_THERMOSTAT_SUMMARY_URL, requestJson);
194         if (response != null) {
195             try {
196                 SummaryResponseDTO summaryResponse = GSON.fromJson(response, SummaryResponseDTO.class);
197                 if (isSuccess(summaryResponse)) {
198                     return summaryResponse;
199                 }
200             } catch (JsonSyntaxException e) {
201                 logJSException(e, response);
202             }
203         }
204         return null;
205     }
206
207     public List<ThermostatDTO> queryRegisteredThermostats() {
208         return performThermostatQuery(null);
209     }
210
211     public List<ThermostatDTO> performThermostatQuery(final @Nullable Set<String> thermostatIds) {
212         logger.debug("API: Perform query on thermostat: '{}'", thermostatIds);
213         if (!isAuthorized()) {
214             return EMPTY_THERMOSTATS;
215         }
216         SelectionDTO selection = bridgeHandler.getSelection();
217         selection.setThermostats(thermostatIds);
218         String requestJson = GSON.toJson(new ThermostatRequestDTO(selection), ThermostatRequestDTO.class);
219         String response = executeGet(ECOBEE_THERMOSTAT_URL, requestJson);
220         if (response != null) {
221             try {
222                 ThermostatResponseDTO thermostatsResponse = GSON.fromJson(response, ThermostatResponseDTO.class);
223                 if (isSuccess(thermostatsResponse)) {
224                     return thermostatsResponse.thermostatList;
225                 }
226             } catch (JsonSyntaxException e) {
227                 logJSException(e, response);
228             }
229         }
230         return EMPTY_THERMOSTATS;
231     }
232
233     public boolean performThermostatFunction(FunctionRequest request) {
234         logger.debug("API: Perform function on thermostat: '{}'", request.selection.selectionMatch);
235         if (!isAuthorized()) {
236             return false;
237         }
238         return executePost(ECOBEE_THERMOSTAT_URL, GSON.toJson(request, FunctionRequest.class));
239     }
240
241     public boolean performThermostatUpdate(ThermostatUpdateRequestDTO request) {
242         logger.debug("API: Perform update on thermostat: '{}'", request.selection.selectionMatch);
243         if (!isAuthorized()) {
244             return false;
245         }
246         return executePost(ECOBEE_THERMOSTAT_UPDATE_URL, GSON.toJson(request, ThermostatUpdateRequestDTO.class));
247     }
248
249     private String buildQueryUrl(String baseUrl, String requestJson) throws UnsupportedEncodingException {
250         final StringBuilder urlBuilder = new StringBuilder(baseUrl);
251         urlBuilder.append("?json=");
252         urlBuilder.append(URLEncoder.encode(requestJson, StandardCharsets.UTF_8.toString()));
253         return urlBuilder.toString();
254     }
255
256     private @Nullable String executeGet(String url, String json) {
257         String response = null;
258         try {
259             long startTime = System.currentTimeMillis();
260             logger.trace("API: Get Request json is '{}'", json);
261             response = HttpUtil.executeUrl("GET", buildQueryUrl(url, json), setHeaders(), null, null, apiTimeout);
262             logger.trace("API: Response took {} msec: {}", System.currentTimeMillis() - startTime, response);
263         } catch (IOException e) {
264             logIOException(e);
265         } catch (EcobeeAuthException e) {
266             logger.info("API: Unable to execute GET: {}", e.getMessage());
267         }
268         return response;
269     }
270
271     private boolean executePost(String url, String json) {
272         try {
273             logger.trace("API: Post request json is '{}'", json);
274             long startTime = System.currentTimeMillis();
275             String response = HttpUtil.executeUrl("POST", url, setHeaders(), new ByteArrayInputStream(json.getBytes()),
276                     "application/json", apiTimeout);
277             logger.trace("API: Response took {} msec: {}", System.currentTimeMillis() - startTime, response);
278             try {
279                 ThermostatResponseDTO thermostatsResponse = GSON.fromJson(response, ThermostatResponseDTO.class);
280                 return isSuccess(thermostatsResponse);
281             } catch (JsonSyntaxException e) {
282                 logJSException(e, response);
283             }
284         } catch (IOException e) {
285             logIOException(e);
286         } catch (EcobeeAuthException e) {
287             logger.info("API: Unable to execute POST: {}", e.getMessage());
288         }
289         return false;
290     }
291
292     private void logIOException(Exception e) {
293         Throwable rootCause = ExceptionUtils.getRootCause(e);
294         if (rootCause instanceof TimeoutException || rootCause instanceof EOFException) {
295             // These are "normal" errors and should be logged as DEBUG
296             logger.debug("API: Call to Ecobee API failed with exception: {}: {}", rootCause.getClass().getSimpleName(),
297                     rootCause.getMessage());
298         } else {
299             // What's left are unexpected errors that should be logged as INFO with a full stack trace
300             logger.info("API: Call to Ecobee API failed", e);
301         }
302     }
303
304     private void logJSException(Exception e, String response) {
305         // The API sometimes returns an HTML page complaining of an SSL error
306         // Otherwise, this probably should be INFO level
307         logger.debug("API: JsonSyntaxException parsing response: {}", response, e);
308     }
309
310     private boolean isSuccess(@Nullable AbstractResponseDTO response) {
311         boolean success = true;
312         if (response == null) {
313             logger.info("API: Ecobee API returned null response");
314             success = false;
315         } else if (response.status.code.intValue() != 0) {
316             logger.info("API: Ecobee API returned unsuccessful status: code={}, message={}", response.status.code,
317                     response.status.message);
318             if (response.status.code == ECOBEE_DEAUTHORIZED_TOKEN) {
319                 // Token has been deauthorized, so restart the authorization process from the beginning
320                 logger.warn("API: Reset OAuth Client Service due to deauthorized token");
321                 deleteOAuthClientService();
322                 createOAuthClientService();
323             } else if (response.status.code == ECOBEE_TOKEN_EXPIRED) {
324                 // Check isAuthorized again to see if we can get a valid token
325                 logger.info("API: Unable to complete API call because token is expired");
326                 if (!isAuthorized()) {
327                     logger.warn("API: isAuthorized was NOT successful on second try");
328                 }
329             }
330             success = false;
331         }
332         return success;
333     }
334
335     private Properties setHeaders() throws EcobeeAuthException {
336         AccessTokenResponse atr = accessTokenResponse;
337         if (atr == null) {
338             throw new EcobeeAuthException("Can not set auth header because access token is null");
339         }
340         Properties headers = new Properties();
341         headers.putAll(HTTP_HEADERS);
342         headers.put("Authorization", "Bearer " + atr.getAccessToken());
343         return headers;
344     }
345 }