]> git.basschouten.com Git - openhab-addons.git/blob
0f185c8100ada1b09415f91a80bc09de16ae02a5
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 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.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;
59
60 import com.google.gson.Gson;
61 import com.google.gson.GsonBuilder;
62 import com.google.gson.JsonSyntaxException;
63
64 /**
65  * The {@link EcobeeApi} is responsible for managing all communication with
66  * the Ecobee API service.
67  *
68  * @author Mark Hilbush - Initial contribution
69  */
70 @NonNullByDefault
71 public class EcobeeApi {
72
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();
77
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";
81
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;
86
87     public static final Properties HTTP_HEADERS;
88     static {
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");
92     }
93
94     public static Gson getGson() {
95         return GSON;
96     }
97
98     private final Logger logger = LoggerFactory.getLogger(EcobeeApi.class);
99
100     private final EcobeeAccountBridgeHandler bridgeHandler;
101
102     private final String apiKey;
103     private int apiTimeout;
104     private final OAuthFactory oAuthFactory;
105     private final HttpClient httpClient;
106
107     private @NonNullByDefault({}) OAuthClientService oAuthClientService;
108     private @NonNullByDefault({}) EcobeeAuth ecobeeAuth;
109
110     private @Nullable AccessTokenResponse accessTokenResponse;
111
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;
119
120         createOAuthClientService();
121     }
122
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;
130     }
131
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);
136     }
137
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);
142     }
143
144     /**
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.
149      */
150     private boolean isAuthorized() {
151         boolean isAuthorized = false;
152         try {
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();
159                 }
160                 ecobeeAuth.setState(EcobeeAuthState.COMPLETE);
161                 isAuthorized = true;
162             } else {
163                 logger.debug("API: Didn't get an AccessTokenResponse from OAuth service - doEcobeeAuthorization!!!");
164                 if (ecobeeAuth.isComplete()) {
165                     ecobeeAuth.setState(EcobeeAuthState.NEED_PIN);
166                 }
167             }
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);
173             } else {
174                 logger.warn("API: Got {} trying to get access token from OAuth service: {}",
175                         e.getClass().getSimpleName(), e.getMessage());
176             }
177         } catch (EcobeeAuthException e) {
178             if (logger.isDebugEnabled()) {
179                 logger.warn("API: The Ecobee authorization process threw an exception", e);
180             } else {
181                 logger.warn("API: The Ecobee authorization process threw an exception: {}", e.getMessage());
182             }
183             ecobeeAuth.setState(EcobeeAuthState.NEED_PIN);
184         } catch (OAuthResponseException e) {
185             handleOAuthException(e);
186         }
187         return isAuthorized;
188     }
189
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();
196         } else {
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());
200         }
201     }
202
203     public @Nullable SummaryResponseDTO performThermostatSummaryQuery() {
204         logger.debug("API: Perform thermostat summary query");
205         if (!isAuthorized()) {
206             return null;
207         }
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) {
214             try {
215                 SummaryResponseDTO summaryResponse = GSON.fromJson(response, SummaryResponseDTO.class);
216                 if (isSuccess(summaryResponse)) {
217                     return summaryResponse;
218                 }
219             } catch (JsonSyntaxException e) {
220                 logJSException(e, response);
221             }
222         }
223         return null;
224     }
225
226     public List<ThermostatDTO> queryRegisteredThermostats() {
227         return performThermostatQuery(null);
228     }
229
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;
234         }
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) {
240             try {
241                 ThermostatResponseDTO thermostatsResponse = GSON.fromJson(response, ThermostatResponseDTO.class);
242                 if (thermostatsResponse != null && isSuccess(thermostatsResponse)) {
243                     if (thermostatsResponse.thermostatList != null) {
244                         return thermostatsResponse.thermostatList;
245                     }
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.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());
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                 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());
348                 }
349                 // Recreating the OAuthClientService seems to be the only way to handle this error
350                 closeOAuthClientService();
351                 createOAuthClientService();
352                 if (isAuthorized()) {
353                     return true;
354                 } else {
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");
358                 }
359             }
360         } else {
361             bridgeHandler.updateBridgeStatus(ThingStatus.ONLINE);
362             return true;
363         }
364         return false;
365     }
366
367     private Properties setHeaders() throws EcobeeAuthException {
368         AccessTokenResponse atr = accessTokenResponse;
369         if (atr == null) {
370             throw new EcobeeAuthException("Can not set auth header because access token is null");
371         }
372         Properties headers = new Properties();
373         headers.putAll(HTTP_HEADERS);
374         headers.put("Authorization", "Bearer " + atr.getAccessToken());
375         return headers;
376     }
377 }