]> git.basschouten.com Git - openhab-addons.git/blob
af2164bf87b05f0d4fb68a4da4aee911210dd8e7
[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.bmwconnecteddrive.internal.handler;
14
15 import static org.openhab.binding.bmwconnecteddrive.internal.utils.HTTPConstants.*;
16
17 import java.nio.charset.StandardCharsets;
18 import java.util.concurrent.ExecutionException;
19 import java.util.concurrent.TimeUnit;
20 import java.util.concurrent.TimeoutException;
21
22 import org.eclipse.jdt.annotation.NonNullByDefault;
23 import org.eclipse.jdt.annotation.Nullable;
24 import org.eclipse.jetty.client.HttpClient;
25 import org.eclipse.jetty.client.api.ContentResponse;
26 import org.eclipse.jetty.client.api.Request;
27 import org.eclipse.jetty.client.api.Result;
28 import org.eclipse.jetty.client.util.BufferingResponseListener;
29 import org.eclipse.jetty.client.util.StringContentProvider;
30 import org.eclipse.jetty.http.HttpField;
31 import org.eclipse.jetty.http.HttpFields;
32 import org.eclipse.jetty.http.HttpHeader;
33 import org.eclipse.jetty.util.MultiMap;
34 import org.eclipse.jetty.util.UrlEncoded;
35 import org.openhab.binding.bmwconnecteddrive.internal.ConnectedDriveConfiguration;
36 import org.openhab.binding.bmwconnecteddrive.internal.VehicleConfiguration;
37 import org.openhab.binding.bmwconnecteddrive.internal.dto.NetworkError;
38 import org.openhab.binding.bmwconnecteddrive.internal.dto.auth.AuthResponse;
39 import org.openhab.binding.bmwconnecteddrive.internal.handler.simulation.Injector;
40 import org.openhab.binding.bmwconnecteddrive.internal.utils.BimmerConstants;
41 import org.openhab.binding.bmwconnecteddrive.internal.utils.Constants;
42 import org.openhab.binding.bmwconnecteddrive.internal.utils.Converter;
43 import org.openhab.binding.bmwconnecteddrive.internal.utils.ImageProperties;
44 import org.openhab.core.io.net.http.HttpClientFactory;
45 import org.slf4j.Logger;
46 import org.slf4j.LoggerFactory;
47
48 import com.google.gson.JsonSyntaxException;
49
50 /**
51  * The {@link ConnectedDriveProxy} This class holds the important constants for the BMW Connected Drive Authorization.
52  * They
53  * are taken from the Bimmercode from github {@link https://github.com/bimmerconnected/bimmer_connected}
54  * File defining these constants
55  * {@link https://github.com/bimmerconnected/bimmer_connected/blob/master/bimmer_connected/account.py}
56  * https://customer.bmwgroup.com/one/app/oauth.js
57  *
58  * @author Bernd Weymann - Initial contribution
59  * @author Norbert Truchsess - edit & send of charge profile
60  */
61 @NonNullByDefault
62 public class ConnectedDriveProxy {
63     private final Logger logger = LoggerFactory.getLogger(ConnectedDriveProxy.class);
64     private final Token token = new Token();
65     private final HttpClient httpClient;
66     private final HttpClient authHttpClient;
67     private final String legacyAuthUri;
68     private final ConnectedDriveConfiguration configuration;
69
70     /**
71      * URLs taken from https://github.com/bimmerconnected/bimmer_connected/blob/master/bimmer_connected/const.py
72      */
73     final String baseUrl;
74     final String vehicleUrl;
75     final String legacyUrl;
76     final String vehicleStatusAPI = "/status";
77     final String lastTripAPI = "/statistics/lastTrip";
78     final String allTripsAPI = "/statistics/allTrips";
79     final String chargeAPI = "/chargingprofile";
80     final String destinationAPI = "/destinations";
81     final String imageAPI = "/image";
82     final String rangeMapAPI = "/rangemap";
83     final String serviceExecutionAPI = "/executeService";
84     final String serviceExecutionStateAPI = "/serviceExecutionStatus";
85
86     public ConnectedDriveProxy(HttpClientFactory httpClientFactory, ConnectedDriveConfiguration config) {
87         httpClient = httpClientFactory.getCommonHttpClient();
88         authHttpClient = httpClientFactory.createHttpClient(AUTH_HTTP_CLIENT_NAME);
89         authHttpClient.setFollowRedirects(false);
90         configuration = config;
91
92         final StringBuilder legacyAuth = new StringBuilder();
93         legacyAuth.append("https://");
94         legacyAuth.append(BimmerConstants.AUTH_SERVER_MAP.get(configuration.region));
95         legacyAuth.append(BimmerConstants.OAUTH_ENDPOINT);
96         legacyAuthUri = legacyAuth.toString();
97         vehicleUrl = "https://" + getRegionServer() + "/webapi/v1/user/vehicles";
98         baseUrl = vehicleUrl + "/";
99         legacyUrl = "https://" + getRegionServer() + "/api/vehicle/dynamic/v1/";
100     }
101
102     private synchronized void call(final String url, final boolean post, final @Nullable MultiMap<String> params,
103             final ResponseCallback callback) {
104         // only executed in "simulation mode"
105         // SimulationTest.testSimulationOff() assures Injector is off when releasing
106         if (Injector.isActive()) {
107             if (url.equals(baseUrl)) {
108                 ((StringResponseCallback) callback).onResponse(Injector.getDiscovery());
109             } else if (url.endsWith(vehicleStatusAPI)) {
110                 ((StringResponseCallback) callback).onResponse(Injector.getStatus());
111             } else {
112                 logger.debug("Simulation of {} not supported", url);
113             }
114             return;
115         }
116         final Request req;
117         final String encoded = params == null || params.isEmpty() ? null
118                 : UrlEncoded.encode(params, StandardCharsets.UTF_8, false);
119         final String completeUrl;
120
121         if (post) {
122             completeUrl = url;
123             req = httpClient.POST(url);
124             if (encoded != null) {
125                 req.content(new StringContentProvider(CONTENT_TYPE_URL_ENCODED, encoded, StandardCharsets.UTF_8));
126             }
127         } else {
128             completeUrl = encoded == null ? url : url + Constants.QUESTION + encoded;
129             req = httpClient.newRequest(completeUrl);
130         }
131         req.header(HttpHeader.AUTHORIZATION, getToken().getBearerToken());
132         req.header(HttpHeader.REFERER, BimmerConstants.REFERER_URL);
133
134         req.timeout(HTTP_TIMEOUT_SEC, TimeUnit.SECONDS).send(new BufferingResponseListener() {
135             @NonNullByDefault({})
136             @Override
137             public void onComplete(Result result) {
138                 if (result.getResponse().getStatus() != 200) {
139                     NetworkError error = new NetworkError();
140                     error.url = completeUrl;
141                     error.status = result.getResponse().getStatus();
142                     if (result.getResponse().getReason() != null) {
143                         error.reason = result.getResponse().getReason();
144                     } else {
145                         error.reason = result.getFailure().getMessage();
146                     }
147                     error.params = result.getRequest().getParams().toString();
148                     logger.debug("HTTP Error {}", error.toString());
149                     callback.onError(error);
150                 } else {
151                     if (callback instanceof StringResponseCallback) {
152                         ((StringResponseCallback) callback).onResponse(getContentAsString());
153                     } else if (callback instanceof ByteResponseCallback) {
154                         ((ByteResponseCallback) callback).onResponse(getContent());
155                     } else {
156                         logger.error("unexpected reponse type {}", callback.getClass().getName());
157                     }
158                 }
159             }
160         });
161     }
162
163     public void get(String url, @Nullable MultiMap<String> params, ResponseCallback callback) {
164         call(url, false, params, callback);
165     }
166
167     public void post(String url, @Nullable MultiMap<String> params, ResponseCallback callback) {
168         call(url, true, params, callback);
169     }
170
171     public void requestVehicles(StringResponseCallback callback) {
172         get(vehicleUrl, null, callback);
173     }
174
175     public void requestVehcileStatus(VehicleConfiguration config, StringResponseCallback callback) {
176         get(baseUrl + config.vin + vehicleStatusAPI, null, callback);
177     }
178
179     public void requestLegacyVehcileStatus(VehicleConfiguration config, StringResponseCallback callback) {
180         // see https://github.com/jupe76/bmwcdapi/search?q=dynamic%2Fv1
181         get(legacyUrl + config.vin + "?offset=-60", null, callback);
182     }
183
184     public void requestLastTrip(VehicleConfiguration config, StringResponseCallback callback) {
185         get(baseUrl + config.vin + lastTripAPI, null, callback);
186     }
187
188     public void requestAllTrips(VehicleConfiguration config, StringResponseCallback callback) {
189         get(baseUrl + config.vin + allTripsAPI, null, callback);
190     }
191
192     public void requestChargingProfile(VehicleConfiguration config, StringResponseCallback callback) {
193         get(baseUrl + config.vin + chargeAPI, null, callback);
194     }
195
196     public void requestDestinations(VehicleConfiguration config, StringResponseCallback callback) {
197         get(baseUrl + config.vin + destinationAPI, null, callback);
198     }
199
200     public void requestRangeMap(VehicleConfiguration config, @Nullable MultiMap<String> params,
201             StringResponseCallback callback) {
202         get(baseUrl + config.vin + rangeMapAPI, params, callback);
203     }
204
205     public void requestImage(VehicleConfiguration config, ImageProperties props, ByteResponseCallback callback) {
206         final String localImageUrl = baseUrl + config.vin + imageAPI;
207         final MultiMap<String> dataMap = new MultiMap<String>();
208         dataMap.add("width", Integer.toString(props.size));
209         dataMap.add("height", Integer.toString(props.size));
210         dataMap.add("view", props.viewport);
211         get(localImageUrl, dataMap, callback);
212     }
213
214     private String getRegionServer() {
215         final String retVal = BimmerConstants.SERVER_MAP.get(configuration.region);
216         return retVal == null ? Constants.INVALID : retVal;
217     }
218
219     private String getAuthorizationValue() {
220         final String retVal = BimmerConstants.AUTHORIZATION_VALUE_MAP.get(configuration.region);
221         return retVal == null ? Constants.INVALID : retVal;
222     }
223
224     RemoteServiceHandler getRemoteServiceHandler(VehicleHandler vehicleHandler) {
225         return new RemoteServiceHandler(vehicleHandler, this);
226     }
227
228     // Token handling
229
230     /**
231      * Gets new token if old one is expired or invalid. In case of error the token remains.
232      * So if token refresh fails the corresponding requests will also fail and update the
233      * Thing status accordingly.
234      *
235      * @return token
236      */
237     public Token getToken() {
238         if (token.isExpired() || !token.isValid()) {
239             updateToken();
240         }
241         return token;
242     }
243
244     /**
245      * Authorize at BMW Connected Drive Portal and get Token
246      *
247      * @return
248      */
249     private synchronized void updateToken() {
250         if (!authHttpClient.isStarted()) {
251             try {
252                 authHttpClient.start();
253             } catch (Exception e) {
254                 logger.warn("Auth Http Client cannot be started {}", e.getMessage());
255                 return;
256             }
257         }
258
259         final Request req = authHttpClient.POST(legacyAuthUri);
260         req.header(HttpHeader.CONNECTION, KEEP_ALIVE);
261         req.header(HttpHeader.HOST, getRegionServer());
262         req.header(HttpHeader.AUTHORIZATION, getAuthorizationValue());
263         req.header(CREDENTIALS, BimmerConstants.CREDENTIAL_VALUES);
264         req.header(HttpHeader.REFERER, BimmerConstants.REFERER_URL);
265
266         final MultiMap<String> dataMap = new MultiMap<String>();
267         dataMap.add("grant_type", "password");
268         dataMap.add(SCOPE, BimmerConstants.SCOPE_VALUES);
269         dataMap.add(USERNAME, configuration.userName);
270         dataMap.add(PASSWORD, configuration.password);
271         req.content(new StringContentProvider(CONTENT_TYPE_URL_ENCODED,
272                 UrlEncoded.encode(dataMap, StandardCharsets.UTF_8, false), StandardCharsets.UTF_8));
273         try {
274             ContentResponse contentResponse = req.timeout(HTTP_TIMEOUT_SEC, TimeUnit.SECONDS).send();
275             // Status needs to be 302 - Response is stored in Header
276             if (contentResponse.getStatus() == 302) {
277                 final HttpFields fields = contentResponse.getHeaders();
278                 final HttpField field = fields.getField(HttpHeader.LOCATION);
279                 tokenFromUrl(field.getValue());
280             } else if (contentResponse.getStatus() == 200) {
281                 final String stringContent = contentResponse.getContentAsString();
282                 if (stringContent != null && !stringContent.isEmpty()) {
283                     try {
284                         final AuthResponse authResponse = Converter.getGson().fromJson(stringContent,
285                                 AuthResponse.class);
286                         if (authResponse != null) {
287                             token.setToken(authResponse.accessToken);
288                             token.setType(authResponse.tokenType);
289                             token.setExpiration(authResponse.expiresIn);
290                         } else {
291                             logger.debug("not an Authorization response: {}", stringContent);
292                         }
293                     } catch (JsonSyntaxException jse) {
294                         logger.debug("Authorization response unparsable: {}", stringContent);
295                     }
296                 } else {
297                     logger.debug("Authorization response has no content");
298                 }
299             } else {
300                 logger.debug("Authorization status {} reason {}", contentResponse.getStatus(),
301                         contentResponse.getReason());
302             }
303         } catch (InterruptedException | ExecutionException | TimeoutException e) {
304             logger.debug("Authorization exception: {}", e.getMessage());
305         }
306     }
307
308     void tokenFromUrl(String encodedUrl) {
309         final MultiMap<String> tokenMap = new MultiMap<String>();
310         UrlEncoded.decodeTo(encodedUrl, tokenMap, StandardCharsets.US_ASCII);
311         tokenMap.forEach((key, value) -> {
312             if (value.size() > 0) {
313                 String val = value.get(0);
314                 if (key.endsWith(ACCESS_TOKEN)) {
315                     token.setToken(val.toString());
316                 } else if (key.equals(EXPIRES_IN)) {
317                     token.setExpiration(Integer.parseInt(val.toString()));
318                 } else if (key.equals(TOKEN_TYPE)) {
319                     token.setType(val.toString());
320                 }
321             }
322         });
323     }
324 }