]> git.basschouten.com Git - openhab-addons.git/blob
a974d2978b03a741ecf7af58bcfe9cb971ed397c
[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.util.concurrent.ExecutionException;
18 import java.util.concurrent.TimeUnit;
19 import java.util.concurrent.TimeoutException;
20
21 import org.eclipse.jdt.annotation.NonNullByDefault;
22 import org.eclipse.jdt.annotation.Nullable;
23 import org.eclipse.jetty.client.HttpClient;
24 import org.eclipse.jetty.client.HttpResponseException;
25 import org.eclipse.jetty.client.api.ContentResponse;
26 import org.eclipse.jetty.client.api.Request;
27 import org.eclipse.jetty.http.HttpStatus;
28 import org.openhab.binding.ecobee.internal.dto.oauth.AuthorizeResponseDTO;
29 import org.openhab.binding.ecobee.internal.dto.oauth.TokenResponseDTO;
30 import org.openhab.binding.ecobee.internal.handler.EcobeeAccountBridgeHandler;
31 import org.openhab.binding.ecobee.internal.util.ExceptionUtils;
32 import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
33 import org.openhab.core.auth.client.oauth2.OAuthClientService;
34 import org.openhab.core.auth.client.oauth2.OAuthException;
35 import org.openhab.core.thing.ThingStatus;
36 import org.openhab.core.thing.ThingStatusDetail;
37 import org.slf4j.Logger;
38 import org.slf4j.LoggerFactory;
39
40 import com.google.gson.JsonSyntaxException;
41
42 /**
43  * The {@link EcobeeAuth} performs the initial OAuth authorization
44  * with the Ecobee authorization servers. Once this process is complete, the
45  * AccessTokenResponse will be imported into the OHC OAuth Client Service. At
46  * that point, the OHC OAuth service will be responsible for refreshing tokens.
47  *
48  * @author Mark Hilbush - Initial contribution
49  */
50 @NonNullByDefault
51 public class EcobeeAuth {
52
53     private final Logger logger = LoggerFactory.getLogger(EcobeeAuth.class);
54
55     private final EcobeeAccountBridgeHandler bridgeHandler;
56     private final String apiKey;
57     private final int apiTimeout;
58     private final OAuthClientService oAuthClientService;
59     private final HttpClient httpClient;
60
61     private EcobeeAuthState state;
62
63     private @Nullable AuthorizeResponseDTO authResponse;
64
65     private long pinExpirationTime;
66
67     /**
68      * The authorization code needed to make the first-time request
69      * of the refresh and access tokens. Obtained from the call to {@code authorize()}.
70      */
71     private @Nullable String code;
72
73     public EcobeeAuth(EcobeeAccountBridgeHandler bridgeHandler, String apiKey, int apiTimeout,
74             OAuthClientService oAuthClientService, HttpClient httpClient) {
75         this.apiKey = apiKey;
76         this.apiTimeout = apiTimeout;
77         this.oAuthClientService = oAuthClientService;
78         this.httpClient = httpClient;
79         this.bridgeHandler = bridgeHandler;
80         pinExpirationTime = 0;
81         state = EcobeeAuthState.NEED_PIN;
82         authResponse = null;
83     }
84
85     public void setState(EcobeeAuthState newState) {
86         if (newState != state) {
87             logger.debug("EcobeeAuth: Change state from {} to {}", state, newState);
88             state = newState;
89         }
90     }
91
92     public boolean isComplete() {
93         return state == EcobeeAuthState.COMPLETE;
94     }
95
96     public EcobeeAuthState doAuthorization() throws EcobeeAuthException {
97         switch (state) {
98             case NEED_PIN:
99                 authorize();
100                 break;
101             case NEED_TOKEN:
102                 getTokens();
103                 break;
104             case COMPLETE:
105                 bridgeHandler.updateBridgeStatus(ThingStatus.ONLINE);
106                 break;
107         }
108         return state;
109     }
110
111     /**
112      * Call the Ecobee authorize endpoint to get the authorization code and PIN
113      * that will be used a) validate the application in the the Ecobee user web portal,
114      * and b) make the first time request for the access and refresh tokens.
115      * Warnings are suppressed to avoid the Gson.fromJson warnings.
116      */
117     @SuppressWarnings({ "null", "unused" })
118     private void authorize() throws EcobeeAuthException {
119         logger.debug("EcobeeAuth: State is {}: Executing step: 'authorize'", state);
120         StringBuilder url = new StringBuilder(ECOBEE_AUTHORIZE_URL);
121         url.append("?response_type=ecobeePin");
122         url.append("&client_id=").append(apiKey);
123         url.append("&scope=").append(ECOBEE_SCOPE);
124
125         logger.trace("EcobeeAuth: Getting authorize URL={}", url);
126         String response = executeUrl("GET", url.toString());
127         logger.trace("EcobeeAuth: Auth response: {}", response);
128
129         try {
130             authResponse = EcobeeApi.getGson().fromJson(response, AuthorizeResponseDTO.class);
131             if (authResponse == null) {
132                 logger.debug("EcobeeAuth: Got null authorize response from Ecobee API");
133                 setState(EcobeeAuthState.NEED_PIN);
134             } else {
135                 String error = authResponse.error;
136                 if (error != null && !error.isEmpty()) {
137                     throw new EcobeeAuthException(error + ": " + authResponse.errorDescription);
138                 }
139                 code = authResponse.code;
140                 writeLogMessage(authResponse.pin, authResponse.expiresIn);
141                 setPinExpirationTime(authResponse.expiresIn.longValue());
142                 updateBridgeStatus();
143                 setState(EcobeeAuthState.NEED_TOKEN);
144             }
145         } catch (JsonSyntaxException e) {
146             logger.info("EcobeeAuth: Exception while parsing authorize response: {}", e.getMessage());
147             setState(EcobeeAuthState.NEED_PIN);
148         }
149     }
150
151     /**
152      * Call the Ecobee token endpoint to get the access and refresh tokens. Once successfully retrieved,
153      * the access and refresh tokens will be injected into the OHC OAuth service.
154      * Warnings are suppressed to avoid the Gson.fromJson warnings.
155      */
156     @SuppressWarnings({ "null", "unused" })
157     private void getTokens() throws EcobeeAuthException {
158         logger.debug("EcobeeAuth: State is {}: Executing step: 'getToken'", state);
159         StringBuilder url = new StringBuilder(ECOBEE_TOKEN_URL);
160         url.append("?grant_type=ecobeePin");
161         url.append("&code=").append(code);
162         url.append("&client_id=").append(apiKey);
163
164         logger.trace("EcobeeAuth: Posting token URL={}", url);
165         String response = executeUrl("POST", url.toString());
166         logger.trace("EcobeeAuth: Got a valid token response: {}", response);
167
168         TokenResponseDTO tokenResponse = EcobeeApi.getGson().fromJson(response, TokenResponseDTO.class);
169         if (tokenResponse == null) {
170             logger.debug("EcobeeAuth: Got null token response from Ecobee API");
171             updateBridgeStatus();
172             setState(isPinExpired() ? EcobeeAuthState.NEED_PIN : EcobeeAuthState.NEED_TOKEN);
173             return;
174         }
175         String error = tokenResponse.error;
176         if (error != null && !error.isEmpty()) {
177             throw new EcobeeAuthException(error + ": " + tokenResponse.errorDescription);
178         }
179         AccessTokenResponse accessTokenResponse = new AccessTokenResponse();
180         accessTokenResponse.setRefreshToken(tokenResponse.refreshToken);
181         accessTokenResponse.setAccessToken(tokenResponse.accessToken);
182         accessTokenResponse.setScope(tokenResponse.scope);
183         accessTokenResponse.setTokenType(tokenResponse.tokenType);
184         accessTokenResponse.setExpiresIn(tokenResponse.expiresIn);
185         try {
186             logger.debug("EcobeeAuth: Importing AccessTokenResponse into oAuthClientService!!!");
187             oAuthClientService.importAccessTokenResponse(accessTokenResponse);
188             bridgeHandler.updateBridgeStatus(ThingStatus.ONLINE);
189             setState(EcobeeAuthState.COMPLETE);
190             return;
191         } catch (OAuthException e) {
192             logger.info("EcobeeAuth: Got OAuthException", e);
193             // No other processing needed here
194         }
195         updateBridgeStatus();
196         setState(isPinExpired() ? EcobeeAuthState.NEED_PIN : EcobeeAuthState.NEED_TOKEN);
197     }
198
199     private void writeLogMessage(String pin, Integer expiresIn) {
200         logger.info("#################################################################");
201         logger.info("# Ecobee: U S E R   I N T E R A C T I O N   R E Q U I R E D !!");
202         logger.info("# Go to the Ecobee web portal, then:");
203         logger.info("# Enter PIN '{}' in My Apps within {} minutes.", pin, expiresIn);
204         logger.info("# NOTE: All API attempts will fail in the meantime.");
205         logger.info("#################################################################");
206     }
207
208     private void updateBridgeStatus() {
209         AuthorizeResponseDTO response = authResponse;
210         if (response != null) {
211             bridgeHandler.updateBridgeStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
212                     String.format("Enter PIN '%s' in MyApps. PIN expires in %d minutes", response.pin,
213                             getMinutesUntilPinExpiration()));
214         }
215     }
216
217     private void setPinExpirationTime(long expiresIn) {
218         pinExpirationTime = expiresIn + TimeUnit.MILLISECONDS.toMinutes(System.currentTimeMillis());
219     }
220
221     private long getMinutesUntilPinExpiration() {
222         return pinExpirationTime - TimeUnit.MILLISECONDS.toMinutes(System.currentTimeMillis());
223     }
224
225     private boolean isPinExpired() {
226         return getMinutesUntilPinExpiration() <= 0;
227     }
228
229     private @Nullable String executeUrl(String method, String url) {
230         Request request = httpClient.newRequest(url);
231         request.timeout(apiTimeout, TimeUnit.MILLISECONDS);
232         request.method(method);
233         EcobeeApi.HTTP_HEADERS.forEach((k, v) -> request.header((String) k, (String) v));
234
235         try {
236             ContentResponse contentResponse = request.send();
237             switch (contentResponse.getStatus()) {
238                 case HttpStatus.OK_200:
239                     return contentResponse.getContentAsString();
240                 case HttpStatus.BAD_REQUEST_400:
241                     logger.debug("BAD REQUEST(400) response received: {}", contentResponse.getContentAsString());
242                     return contentResponse.getContentAsString();
243                 case HttpStatus.UNAUTHORIZED_401:
244                     logger.debug("UNAUTHORIZED(401) response received: {}", contentResponse.getContentAsString());
245                     return contentResponse.getContentAsString();
246                 case HttpStatus.NO_CONTENT_204:
247                     logger.debug("HTTP response 204: No content. Check configuration");
248                     break;
249                 default:
250                     logger.debug("HTTP {} failed: {}, {}", method, contentResponse.getStatus(),
251                             contentResponse.getReason());
252                     break;
253             }
254         } catch (TimeoutException e) {
255             logger.debug("TimeoutException: Call to Ecobee API timed out");
256         } catch (ExecutionException e) {
257             if (ExceptionUtils.getRootThrowable(e) instanceof HttpResponseException) {
258                 logger.info("Awaiting entry of PIN in Ecobee portal. Expires in {} minutes",
259                         getMinutesUntilPinExpiration());
260             } else {
261                 logger.debug("ExecutionException on call to Ecobee authorization API", e);
262             }
263         } catch (InterruptedException e) {
264             logger.debug("InterruptedException on call to Ecobee authorization API: {}", e.getMessage());
265             Thread.currentThread().interrupt();
266         }
267         return null;
268     }
269 }