2 * Copyright (c) 2010-2021 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.bmwconnecteddrive.internal.handler;
15 import static org.openhab.binding.bmwconnecteddrive.internal.utils.HTTPConstants.*;
17 import java.io.BufferedReader;
18 import java.io.IOException;
19 import java.io.InputStreamReader;
20 import java.io.OutputStream;
21 import java.net.HttpURLConnection;
23 import java.net.URLDecoder;
24 import java.nio.charset.Charset;
25 import java.nio.charset.StandardCharsets;
26 import java.util.Optional;
27 import java.util.concurrent.ExecutionException;
28 import java.util.concurrent.TimeUnit;
29 import java.util.concurrent.TimeoutException;
31 import org.eclipse.jdt.annotation.NonNullByDefault;
32 import org.eclipse.jdt.annotation.Nullable;
33 import org.eclipse.jetty.client.HttpClient;
34 import org.eclipse.jetty.client.api.ContentResponse;
35 import org.eclipse.jetty.client.api.Request;
36 import org.eclipse.jetty.client.api.Result;
37 import org.eclipse.jetty.client.util.BufferingResponseListener;
38 import org.eclipse.jetty.client.util.StringContentProvider;
39 import org.eclipse.jetty.http.HttpHeader;
40 import org.eclipse.jetty.util.MultiMap;
41 import org.eclipse.jetty.util.UrlEncoded;
42 import org.openhab.binding.bmwconnecteddrive.internal.ConnectedDriveConfiguration;
43 import org.openhab.binding.bmwconnecteddrive.internal.VehicleConfiguration;
44 import org.openhab.binding.bmwconnecteddrive.internal.dto.NetworkError;
45 import org.openhab.binding.bmwconnecteddrive.internal.dto.auth.AuthResponse;
46 import org.openhab.binding.bmwconnecteddrive.internal.handler.simulation.Injector;
47 import org.openhab.binding.bmwconnecteddrive.internal.utils.BimmerConstants;
48 import org.openhab.binding.bmwconnecteddrive.internal.utils.Constants;
49 import org.openhab.binding.bmwconnecteddrive.internal.utils.Converter;
50 import org.openhab.binding.bmwconnecteddrive.internal.utils.ImageProperties;
51 import org.openhab.core.io.net.http.HttpClientFactory;
52 import org.slf4j.Logger;
53 import org.slf4j.LoggerFactory;
56 * The {@link ConnectedDriveProxy} This class holds the important constants for the BMW Connected Drive Authorization.
58 * are taken from the Bimmercode from github {@link https://github.com/bimmerconnected/bimmer_connected}
59 * File defining these constants
60 * {@link https://github.com/bimmerconnected/bimmer_connected/blob/master/bimmer_connected/account.py}
61 * https://customer.bmwgroup.com/one/app/oauth.js
63 * @author Bernd Weymann - Initial contribution
64 * @author Norbert Truchsess - edit & send of charge profile
67 public class ConnectedDriveProxy {
68 private final Logger logger = LoggerFactory.getLogger(ConnectedDriveProxy.class);
69 private Optional<RemoteServiceHandler> remoteServiceHandler = Optional.empty();
70 private final Token token = new Token();
71 private final HttpClient httpClient;
72 private final HttpClient authHttpClient;
73 private final ConnectedDriveConfiguration configuration;
76 * URLs taken from https://github.com/bimmerconnected/bimmer_connected/blob/master/bimmer_connected/const.py
79 final String vehicleUrl;
80 final String legacyUrl;
81 final String remoteCommandUrl;
82 final String remoteStatusUrl;
83 final String navigationAPIUrl;
84 final String vehicleStatusAPI = "/status";
85 final String lastTripAPI = "/statistics/lastTrip";
86 final String allTripsAPI = "/statistics/allTrips";
87 final String chargeAPI = "/chargingprofile";
88 final String destinationAPI = "/destinations";
89 final String imageAPI = "/image";
90 final String rangeMapAPI = "/rangemap";
91 final String serviceExecutionAPI = "/executeService";
92 final String serviceExecutionStateAPI = "/serviceExecutionStatus";
93 public static final String REMOTE_SERVICE_EADRAX_BASE_URL = "/eadrax-vrccs/v2/presentation/remote-commands/"; // '/{vin}/{service_type}'
94 final String remoteServiceEADRXstatusUrl = REMOTE_SERVICE_EADRAX_BASE_URL + "eventStatus?eventId={event_id}";
95 final String vehicleEADRXPoiUrl = "/eadrax-dcs/v1/send-to-car/send-to-car";
97 public ConnectedDriveProxy(HttpClientFactory httpClientFactory, ConnectedDriveConfiguration config) {
98 httpClient = httpClientFactory.getCommonHttpClient();
99 authHttpClient = httpClientFactory.createHttpClient(AUTH_HTTP_CLIENT_NAME);
100 configuration = config;
102 vehicleUrl = "https://" + BimmerConstants.API_SERVER_MAP.get(configuration.region) + "/webapi/v1/user/vehicles";
103 baseUrl = vehicleUrl + "/";
104 legacyUrl = "https://" + BimmerConstants.API_SERVER_MAP.get(configuration.region) + "/api/vehicle/dynamic/v1/";
105 navigationAPIUrl = "https://" + BimmerConstants.API_SERVER_MAP.get(configuration.region)
106 + "/api/vehicle/navigation/v1/";
107 remoteCommandUrl = "https://" + BimmerConstants.EADRAX_SERVER_MAP.get(configuration.region)
108 + REMOTE_SERVICE_EADRAX_BASE_URL;
109 remoteStatusUrl = remoteCommandUrl + "eventStatus";
112 public synchronized void call(final String url, final boolean post, final @Nullable String encoding,
113 final @Nullable String params, final ResponseCallback callback) {
114 // only executed in "simulation mode"
115 // SimulationTest.testSimulationOff() assures Injector is off when releasing
116 if (Injector.isActive()) {
117 if (url.equals(baseUrl)) {
118 ((StringResponseCallback) callback).onResponse(Injector.getDiscovery());
119 } else if (url.endsWith(vehicleStatusAPI)) {
120 ((StringResponseCallback) callback).onResponse(Injector.getStatus());
122 logger.debug("Simulation of {} not supported", url);
127 final String completeUrl;
131 req = httpClient.POST(url);
132 if (encoding != null) {
133 if (CONTENT_TYPE_URL_ENCODED.equals(encoding)) {
134 req.content(new StringContentProvider(CONTENT_TYPE_URL_ENCODED, params, StandardCharsets.UTF_8));
135 } else if (CONTENT_TYPE_JSON_ENCODED.equals(encoding)) {
136 req.header(HttpHeader.CONTENT_TYPE, encoding);
137 req.content(new StringContentProvider(CONTENT_TYPE_JSON_ENCODED, params, StandardCharsets.UTF_8));
141 completeUrl = params == null ? url : url + Constants.QUESTION + params;
142 req = httpClient.newRequest(completeUrl);
144 req.header(HttpHeader.AUTHORIZATION, getToken().getBearerToken());
145 req.header(HttpHeader.REFERER, BimmerConstants.LEGACY_REFERER_URL);
147 req.timeout(HTTP_TIMEOUT_SEC, TimeUnit.SECONDS).send(new BufferingResponseListener() {
148 @NonNullByDefault({})
150 public void onComplete(Result result) {
151 if (result.getResponse().getStatus() != 200) {
152 NetworkError error = new NetworkError();
153 error.url = completeUrl;
154 error.status = result.getResponse().getStatus();
155 if (result.getResponse().getReason() != null) {
156 error.reason = result.getResponse().getReason();
158 error.reason = result.getFailure().getMessage();
160 error.params = result.getRequest().getParams().toString();
161 logger.debug("HTTP Error {}", error.toString());
162 callback.onError(error);
164 if (callback instanceof StringResponseCallback) {
165 ((StringResponseCallback) callback).onResponse(getContentAsString());
166 } else if (callback instanceof ByteResponseCallback) {
167 ((ByteResponseCallback) callback).onResponse(getContent());
169 logger.error("unexpected reponse type {}", callback.getClass().getName());
176 public void get(String url, @Nullable String coding, @Nullable String params, ResponseCallback callback) {
177 call(url, false, coding, params, callback);
180 public void post(String url, @Nullable String coding, @Nullable String params, ResponseCallback callback) {
181 call(url, true, coding, params, callback);
184 public void requestVehicles(StringResponseCallback callback) {
185 get(vehicleUrl, null, null, callback);
188 public void requestVehcileStatus(VehicleConfiguration config, StringResponseCallback callback) {
189 get(baseUrl + config.vin + vehicleStatusAPI, null, null, callback);
192 public void requestLegacyVehcileStatus(VehicleConfiguration config, StringResponseCallback callback) {
193 // see https://github.com/jupe76/bmwcdapi/search?q=dynamic%2Fv1
194 get(legacyUrl + config.vin + "?offset=-60", null, null, callback);
197 public void requestLNavigation(VehicleConfiguration config, StringResponseCallback callback) {
198 // see https://github.com/jupe76/bmwcdapi/search?q=dynamic%2Fv1
199 get(navigationAPIUrl + config.vin, null, null, callback);
202 public void requestLastTrip(VehicleConfiguration config, StringResponseCallback callback) {
203 get(baseUrl + config.vin + lastTripAPI, null, null, callback);
206 public void requestAllTrips(VehicleConfiguration config, StringResponseCallback callback) {
207 get(baseUrl + config.vin + allTripsAPI, null, null, callback);
210 public void requestChargingProfile(VehicleConfiguration config, StringResponseCallback callback) {
211 get(baseUrl + config.vin + chargeAPI, null, null, callback);
214 public void requestDestinations(VehicleConfiguration config, StringResponseCallback callback) {
215 get(baseUrl + config.vin + destinationAPI, null, null, callback);
218 public void requestRangeMap(VehicleConfiguration config, @Nullable MultiMap<String> params,
219 StringResponseCallback callback) {
220 get(baseUrl + config.vin + rangeMapAPI, CONTENT_TYPE_URL_ENCODED,
221 UrlEncoded.encode(params, StandardCharsets.UTF_8, false), callback);
224 public void requestImage(VehicleConfiguration config, ImageProperties props, ByteResponseCallback callback) {
225 final String localImageUrl = baseUrl + config.vin + imageAPI;
226 final MultiMap<String> dataMap = new MultiMap<String>();
227 dataMap.add("width", Integer.toString(props.size));
228 dataMap.add("height", Integer.toString(props.size));
229 dataMap.add("view", props.viewport);
231 get(localImageUrl, CONTENT_TYPE_URL_ENCODED, UrlEncoded.encode(dataMap, StandardCharsets.UTF_8, false),
235 RemoteServiceHandler getRemoteServiceHandler(VehicleHandler vehicleHandler) {
236 remoteServiceHandler = Optional.of(new RemoteServiceHandler(vehicleHandler, this));
237 return remoteServiceHandler.get();
243 * Gets new token if old one is expired or invalid. In case of error the token remains.
244 * So if token refresh fails the corresponding requests will also fail and update the
245 * Thing status accordingly.
249 public Token getToken() {
250 if (!token.isValid()) {
251 if (configuration.preferMyBmw) {
252 if (!updateToken()) {
253 if (!updateLegacyToken()) {
254 logger.debug("Authorization failed!");
258 if (!updateLegacyToken()) {
259 if (!updateToken()) {
260 logger.debug("Authorization failed!");
265 remoteServiceHandler.ifPresent(serviceHandler -> {
266 serviceHandler.setMyBmwApiUsage(token.isMyBmwApiUsage());
271 public synchronized boolean updateToken() {
272 if (BimmerConstants.REGION_CHINA.equals(configuration.region)) {
273 // region China currently not supported for MyBMW API
274 logger.debug("Region {} not supported yet for MyBMW Login", BimmerConstants.REGION_CHINA);
277 if (!startAuthClient()) {
280 String authUri = "https://" + BimmerConstants.AUTH_SERVER_MAP.get(configuration.region)
281 + BimmerConstants.OAUTH_ENDPOINT;
283 Request authRequest = authHttpClient.POST(authUri);
284 authRequest.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_URL_ENCODED);
286 MultiMap<String> authChallenge = getTokenBaseValues();
287 authChallenge.addAllValues(getTokenAuthValues());
288 String authEncoded = UrlEncoded.encode(authChallenge, Charset.defaultCharset(), false);
289 authRequest.content(new StringContentProvider(authEncoded));
291 ContentResponse authResponse = authRequest.timeout(HTTP_TIMEOUT_SEC, TimeUnit.SECONDS).send();
292 String authResponseString = URLDecoder.decode(authResponse.getContentAsString(), Charset.defaultCharset());
293 String authCode = getAuthCode(authResponseString);
294 if (!Constants.EMPTY.equals(authCode)) {
295 MultiMap<String> codeChallenge = getTokenBaseValues();
296 codeChallenge.put(AUTHORIZATION, authCode);
298 Request codeRequest = authHttpClient.POST(authUri).followRedirects(false);
299 codeRequest.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_URL_ENCODED);
300 String codeEncoded = UrlEncoded.encode(codeChallenge, Charset.defaultCharset(), false);
301 codeRequest.content(new StringContentProvider(codeEncoded));
302 ContentResponse codeResponse = codeRequest.timeout(HTTP_TIMEOUT_SEC, TimeUnit.SECONDS).send();
303 String code = ConnectedDriveProxy.codeFromUrl(codeResponse.getHeaders().get(HttpHeader.LOCATION));
306 String tokenUrl = "https://" + BimmerConstants.AUTH_SERVER_MAP.get(configuration.region)
307 + BimmerConstants.TOKEN_ENDPOINT;
309 Request tokenRequest = authHttpClient.POST(tokenUrl).followRedirects(false);
310 tokenRequest.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_URL_ENCODED);
311 tokenRequest.header(HttpHeader.AUTHORIZATION,
312 BimmerConstants.AUTHORIZATION_VALUE_MAP.get(configuration.region));
313 String tokenEncoded = UrlEncoded.encode(getTokenValues(code), Charset.defaultCharset(), false);
314 tokenRequest.content(new StringContentProvider(tokenEncoded));
315 ContentResponse tokenResponse = tokenRequest.timeout(HTTP_TIMEOUT_SEC, TimeUnit.SECONDS).send();
316 AuthResponse authResponseJson = Converter.getGson().fromJson(tokenResponse.getContentAsString(),
318 token.setToken(authResponseJson.accessToken);
319 token.setType(authResponseJson.tokenType);
320 token.setExpiration(authResponseJson.expiresIn);
321 token.setMyBmwApiUsage(true);
324 } catch (InterruptedException | ExecutionException |
326 TimeoutException e) {
327 logger.debug("Authorization exception: {}", e.getMessage());
332 private boolean startAuthClient() {
333 if (!authHttpClient.isStarted()) {
335 authHttpClient.start();
336 } catch (Exception e) {
337 logger.error("Auth HttpClient start failed!");
344 private MultiMap<String> getTokenBaseValues() {
345 MultiMap<String> baseValues = new MultiMap<String>();
346 baseValues.add(CLIENT_ID, Constants.EMPTY + BimmerConstants.CLIENT_ID.get(configuration.region));
347 baseValues.add(RESPONSE_TYPE, CODE);
348 baseValues.add(REDIRECT_URI, BimmerConstants.REDIRECT_URI_VALUE);
349 baseValues.add("state", Constants.EMPTY + BimmerConstants.STATE.get(configuration.region));
350 baseValues.add("nonce", "login_nonce");
351 baseValues.add(SCOPE, BimmerConstants.SCOPE_VALUES);
355 private MultiMap<String> getTokenAuthValues() {
356 MultiMap<String> authValues = new MultiMap<String>();
357 authValues.add(GRANT_TYPE, "authorization_code");
358 authValues.add(USERNAME, configuration.userName);
359 authValues.add(PASSWORD, configuration.password);
363 private MultiMap<String> getTokenValues(String code) {
364 MultiMap<String> tokenValues = new MultiMap<String>();
365 tokenValues.put(CODE, code);
366 tokenValues.put("code_verifier", Constants.EMPTY + BimmerConstants.CODE_VERIFIER.get(configuration.region));
367 tokenValues.put(REDIRECT_URI, BimmerConstants.REDIRECT_URI_VALUE);
368 tokenValues.put(GRANT_TYPE, "authorization_code");
372 private String getAuthCode(String response) {
373 String[] keys = response.split("&");
374 for (int i = 0; i < keys.length; i++) {
375 if (keys[i].startsWith(AUTHORIZATION)) {
376 String authCode = keys[i].split("=")[1];
377 authCode = authCode.split("\"")[0];
381 return Constants.EMPTY;
384 public synchronized boolean updateLegacyToken() {
385 logger.debug("updateLegacyToken");
388 * The authorization with Jetty HttpClient doens't work anymore
389 * When calling Jetty with same headers and content a ConcurrentExcpetion is thrown
390 * So fallback legacy authorization will stay on java.net handling
392 String authUri = "https://" + BimmerConstants.AUTH_SERVER_MAP.get(configuration.region)
393 + BimmerConstants.OAUTH_ENDPOINT;
394 URL url = new URL(authUri);
395 HttpURLConnection.setFollowRedirects(false);
396 HttpURLConnection con = (HttpURLConnection) url.openConnection();
397 con.setRequestMethod("POST");
398 con.setRequestProperty(HttpHeader.CONTENT_TYPE.toString(), CONTENT_TYPE_URL_ENCODED);
399 con.setRequestProperty(HttpHeader.CONNECTION.toString(), KEEP_ALIVE);
400 con.setRequestProperty(HttpHeader.HOST.toString(),
401 BimmerConstants.API_SERVER_MAP.get(configuration.region));
402 con.setRequestProperty(HttpHeader.AUTHORIZATION.toString(),
403 BimmerConstants.LEGACY_AUTHORIZATION_VALUE_MAP.get(configuration.region));
404 con.setRequestProperty(CREDENTIALS, BimmerConstants.LEGACY_CREDENTIAL_VALUES);
405 con.setDoOutput(true);
407 OutputStream os = con.getOutputStream();
408 byte[] input = getAuthEncodedData().getBytes("utf-8");
409 os.write(input, 0, input.length);
411 BufferedReader br = new BufferedReader(new InputStreamReader(con.getInputStream(), "utf-8"));
412 StringBuilder response = new StringBuilder();
413 String responseLine = null;
414 while ((responseLine = br.readLine()) != null) {
415 response.append(responseLine.trim());
417 token.setMyBmwApiUsage(false);
418 return tokenFromUrl(con.getHeaderField(HttpHeader.LOCATION.toString()));
419 } catch (IOException e) {
420 logger.warn("{}", e.getMessage());
425 public boolean tokenFromUrl(String encodedUrl) {
426 final MultiMap<String> tokenMap = new MultiMap<String>();
427 UrlEncoded.decodeTo(encodedUrl, tokenMap, StandardCharsets.US_ASCII);
428 tokenMap.forEach((key, value) -> {
429 if (value.size() > 0) {
430 String val = value.get(0);
431 if (key.endsWith(ACCESS_TOKEN)) {
432 token.setToken(val.toString());
433 } else if (key.equals(EXPIRES_IN)) {
434 token.setExpiration(Integer.parseInt(val.toString()));
435 } else if (key.equals(TOKEN_TYPE)) {
436 token.setType(val.toString());
440 logger.info("Token valid? {}", token.isValid());
441 return token.isValid();
444 public static String codeFromUrl(String encodedUrl) {
445 final MultiMap<String> tokenMap = new MultiMap<String>();
446 UrlEncoded.decodeTo(encodedUrl, tokenMap, StandardCharsets.US_ASCII);
447 final StringBuilder codeFound = new StringBuilder();
448 tokenMap.forEach((key, value) -> {
449 if (value.size() > 0) {
450 String val = value.get(0);
451 if (key.endsWith(CODE)) {
452 codeFound.append(val.toString());
456 return codeFound.toString();
459 private String getAuthEncodedData() {
460 MultiMap<String> dataMap = new MultiMap<String>();
461 dataMap.add(CLIENT_ID, BimmerConstants.LEGACY_CLIENT_ID);
462 dataMap.add(RESPONSE_TYPE, TOKEN);
463 dataMap.add(REDIRECT_URI, BimmerConstants.LEGACY_REDIRECT_URI_VALUE);
464 dataMap.add(SCOPE, BimmerConstants.LEGACY_SCOPE_VALUES);
465 dataMap.add(USERNAME, configuration.userName);
466 dataMap.add(PASSWORD, configuration.password);
467 return UrlEncoded.encode(dataMap, Charset.defaultCharset(), false);