2 * Copyright (c) 2010-2023 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.mybmw.internal.handler;
15 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.*;
17 import java.nio.charset.StandardCharsets;
18 import java.security.KeyFactory;
19 import java.security.MessageDigest;
20 import java.security.PublicKey;
21 import java.security.spec.X509EncodedKeySpec;
22 import java.util.Base64;
23 import java.util.Optional;
24 import java.util.concurrent.TimeUnit;
26 import javax.crypto.Cipher;
28 import org.eclipse.jdt.annotation.NonNullByDefault;
29 import org.eclipse.jdt.annotation.Nullable;
30 import org.eclipse.jetty.client.HttpClient;
31 import org.eclipse.jetty.client.HttpResponseException;
32 import org.eclipse.jetty.client.api.ContentResponse;
33 import org.eclipse.jetty.client.api.Request;
34 import org.eclipse.jetty.client.api.Result;
35 import org.eclipse.jetty.client.util.BufferingResponseListener;
36 import org.eclipse.jetty.client.util.StringContentProvider;
37 import org.eclipse.jetty.http.HttpHeader;
38 import org.eclipse.jetty.util.MultiMap;
39 import org.eclipse.jetty.util.UrlEncoded;
40 import org.openhab.binding.mybmw.internal.MyBMWConfiguration;
41 import org.openhab.binding.mybmw.internal.VehicleConfiguration;
42 import org.openhab.binding.mybmw.internal.dto.auth.AuthQueryResponse;
43 import org.openhab.binding.mybmw.internal.dto.auth.AuthResponse;
44 import org.openhab.binding.mybmw.internal.dto.auth.ChinaPublicKeyResponse;
45 import org.openhab.binding.mybmw.internal.dto.auth.ChinaTokenExpiration;
46 import org.openhab.binding.mybmw.internal.dto.auth.ChinaTokenResponse;
47 import org.openhab.binding.mybmw.internal.dto.network.NetworkError;
48 import org.openhab.binding.mybmw.internal.handler.simulation.Injector;
49 import org.openhab.binding.mybmw.internal.utils.BimmerConstants;
50 import org.openhab.binding.mybmw.internal.utils.Constants;
51 import org.openhab.binding.mybmw.internal.utils.Converter;
52 import org.openhab.binding.mybmw.internal.utils.HTTPConstants;
53 import org.openhab.binding.mybmw.internal.utils.ImageProperties;
54 import org.openhab.core.io.net.http.HttpClientFactory;
55 import org.openhab.core.util.StringUtils;
56 import org.slf4j.Logger;
57 import org.slf4j.LoggerFactory;
60 * The {@link MyBMWProxy} This class holds the important constants for the BMW Connected Drive Authorization.
61 * They are taken from the Bimmercode from github <a href="https://github.com/bimmerconnected/bimmer_connected">
62 * https://github.com/bimmerconnected/bimmer_connected</a>.
63 * File defining these constants
64 * <a href="https://github.com/bimmerconnected/bimmer_connected/blob/master/bimmer_connected/account.py">
65 * https://github.com/bimmerconnected/bimmer_connected/blob/master/bimmer_connected/account.py</a>
66 * <a href="https://customer.bmwgroup.com/one/app/oauth.js">https://customer.bmwgroup.com/one/app/oauth.js</a>
68 * @author Bernd Weymann - Initial contribution
69 * @author Norbert Truchsess - edit and send of charge profile
72 public class MyBMWProxy {
73 private final Logger logger = LoggerFactory.getLogger(MyBMWProxy.class);
74 private Optional<RemoteServiceHandler> remoteServiceHandler = Optional.empty();
75 private final Token token = new Token();
76 private final HttpClient httpClient;
77 private final MyBMWConfiguration configuration;
80 * URLs taken from https://github.com/bimmerconnected/bimmer_connected/blob/master/bimmer_connected/const.py
82 final String vehicleUrl;
83 final String remoteCommandUrl;
84 final String remoteStatusUrl;
85 final String serviceExecutionAPI = "/executeService";
86 final String serviceExecutionStateAPI = "/serviceExecutionStatus";
87 final String remoteServiceEADRXstatusUrl = BimmerConstants.API_REMOTE_SERVICE_BASE_URL
88 + "eventStatus?eventId={event_id}";
90 public MyBMWProxy(HttpClientFactory httpClientFactory, MyBMWConfiguration config) {
91 httpClient = httpClientFactory.getCommonHttpClient();
92 configuration = config;
94 vehicleUrl = "https://" + BimmerConstants.EADRAX_SERVER_MAP.get(configuration.region)
95 + BimmerConstants.API_VEHICLES;
97 remoteCommandUrl = "https://" + BimmerConstants.EADRAX_SERVER_MAP.get(configuration.region)
98 + BimmerConstants.API_REMOTE_SERVICE_BASE_URL;
99 remoteStatusUrl = remoteCommandUrl + "eventStatus";
102 public synchronized void call(final String url, final boolean post, final @Nullable String encoding,
103 final @Nullable String params, final String brand, 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(vehicleUrl)) {
108 ((StringResponseCallback) callback).onResponse(Injector.getDiscovery());
109 } else if (url.endsWith(vehicleUrl)) {
110 ((StringResponseCallback) callback).onResponse(Injector.getStatus());
112 logger.debug("Simulation of {} not supported", url);
117 // return in case of unknown brand
118 if (!BimmerConstants.ALL_BRANDS.contains(brand.toLowerCase())) {
119 logger.warn("Unknown Brand {}", brand);
124 final String completeUrl;
128 req = httpClient.POST(url);
129 if (encoding != null) {
130 req.header(HttpHeader.CONTENT_TYPE, encoding);
131 if (CONTENT_TYPE_URL_ENCODED.equals(encoding)) {
132 req.content(new StringContentProvider(CONTENT_TYPE_URL_ENCODED, params, StandardCharsets.UTF_8));
133 } else if (CONTENT_TYPE_JSON_ENCODED.equals(encoding)) {
134 req.content(new StringContentProvider(CONTENT_TYPE_JSON_ENCODED, params, StandardCharsets.UTF_8));
138 completeUrl = params == null ? url : url + Constants.QUESTION + params;
139 req = httpClient.newRequest(completeUrl);
141 req.header(HttpHeader.AUTHORIZATION, getToken().getBearerToken());
142 req.header(HTTPConstants.X_USER_AGENT,
143 String.format(BimmerConstants.X_USER_AGENT, brand, configuration.region));
144 req.header(HttpHeader.ACCEPT_LANGUAGE, configuration.language);
145 if (callback instanceof ByteResponseCallback) {
146 req.header(HttpHeader.ACCEPT, "image/png");
148 req.header(HttpHeader.ACCEPT, CONTENT_TYPE_JSON_ENCODED);
151 req.timeout(HTTP_TIMEOUT_SEC, TimeUnit.SECONDS).send(new BufferingResponseListener() {
152 @NonNullByDefault({})
154 public void onComplete(Result result) {
155 if (result.getResponse().getStatus() != 200) {
156 NetworkError error = new NetworkError();
157 error.url = completeUrl;
158 error.status = result.getResponse().getStatus();
159 if (result.getResponse().getReason() != null) {
160 error.reason = result.getResponse().getReason();
162 error.reason = result.getFailure().getMessage();
164 error.params = result.getRequest().getParams().toString();
165 logger.debug("HTTP Error {}", error.toString());
166 callback.onError(error);
168 if (callback instanceof StringResponseCallback responseCallback) {
169 responseCallback.onResponse(getContentAsString());
170 } else if (callback instanceof ByteResponseCallback responseCallback) {
171 responseCallback.onResponse(getContent());
173 logger.error("unexpected reponse type {}", callback.getClass().getName());
180 public void get(String url, @Nullable String coding, @Nullable String params, final String brand,
181 ResponseCallback callback) {
182 call(url, false, coding, params, brand, callback);
185 public void post(String url, @Nullable String coding, @Nullable String params, final String brand,
186 ResponseCallback callback) {
187 call(url, true, coding, params, brand, callback);
191 * request all vehicles for one specific brand
196 public void requestVehicles(String brand, StringResponseCallback callback) {
197 // calculate necessary parameters for query
198 MultiMap<String> vehicleParams = new MultiMap<String>();
199 vehicleParams.put(BimmerConstants.TIRE_GUARD_MODE, Constants.ENABLED);
200 vehicleParams.put(BimmerConstants.APP_DATE_TIME, Long.toString(System.currentTimeMillis()));
201 vehicleParams.put(BimmerConstants.APP_TIMEZONE, Integer.toString(Converter.getOffsetMinutes()));
202 String params = UrlEncoded.encode(vehicleParams, StandardCharsets.UTF_8, false);
203 get(vehicleUrl + "?" + params, null, null, brand, callback);
207 * request vehicles for all possible brands
211 public void requestVehicles(StringResponseCallback callback) {
212 BimmerConstants.ALL_BRANDS.forEach(brand -> {
213 requestVehicles(brand, callback);
217 public void requestImage(VehicleConfiguration config, ImageProperties props, ByteResponseCallback callback) {
218 final String localImageUrl = "https://" + BimmerConstants.EADRAX_SERVER_MAP.get(configuration.region)
219 + "/eadrax-ics/v3/presentation/vehicles/" + config.vin + "/images?carView=" + props.viewport;
220 get(localImageUrl, null, null, config.vehicleBrand, callback);
224 * request charge statistics for electric vehicles
228 public void requestChargeStatistics(VehicleConfiguration config, StringResponseCallback callback) {
229 MultiMap<String> chargeStatisticsParams = new MultiMap<String>();
230 chargeStatisticsParams.put("vin", config.vin);
231 chargeStatisticsParams.put("currentDate", Converter.getCurrentISOTime());
232 String params = UrlEncoded.encode(chargeStatisticsParams, StandardCharsets.UTF_8, false);
233 String chargeStatisticsUrl = "https://" + BimmerConstants.EADRAX_SERVER_MAP.get(configuration.region)
234 + "/eadrax-chs/v1/charging-statistics?" + params;
235 get(chargeStatisticsUrl, null, null, config.vehicleBrand, callback);
239 * request charge statistics for electric vehicles
243 public void requestChargeSessions(VehicleConfiguration config, StringResponseCallback callback) {
244 MultiMap<String> chargeSessionsParams = new MultiMap<String>();
245 chargeSessionsParams.put("vin", "WBY1Z81040V905639");
246 chargeSessionsParams.put("maxResults", "40");
247 chargeSessionsParams.put("include_date_picker", "true");
248 String params = UrlEncoded.encode(chargeSessionsParams, StandardCharsets.UTF_8, false);
249 String chargeSessionsUrl = "https://" + BimmerConstants.EADRAX_SERVER_MAP.get(configuration.region)
250 + "/eadrax-chs/v1/charging-sessions?" + params;
252 get(chargeSessionsUrl, null, null, config.vehicleBrand, callback);
255 RemoteServiceHandler getRemoteServiceHandler(VehicleHandler vehicleHandler) {
256 remoteServiceHandler = Optional.of(new RemoteServiceHandler(vehicleHandler, this));
257 return remoteServiceHandler.get();
263 * Gets new token if old one is expired or invalid. In case of error the token remains.
264 * So if token refresh fails the corresponding requests will also fail and update the
265 * Thing status accordingly.
269 public Token getToken() {
270 if (!token.isValid()) {
271 boolean tokenUpdateSuccess = false;
272 switch (configuration.region) {
273 case BimmerConstants.REGION_CHINA:
274 tokenUpdateSuccess = updateTokenChina();
276 case BimmerConstants.REGION_NORTH_AMERICA:
277 tokenUpdateSuccess = updateToken();
279 case BimmerConstants.REGION_ROW:
280 tokenUpdateSuccess = updateToken();
283 logger.warn("Region {} not supported", configuration.region);
286 if (!tokenUpdateSuccess) {
287 logger.debug("Authorization failed!");
294 * Everything is catched by surroundig try catch
296 * - JSONSyntax Exceptions
297 * - potential NullPointer Exceptions
301 @SuppressWarnings("null")
302 public synchronized boolean updateToken() {
305 * Step 1) Get basic values for further queries
307 String authValuesUrl = "https://" + BimmerConstants.EADRAX_SERVER_MAP.get(configuration.region)
308 + BimmerConstants.API_OAUTH_CONFIG;
309 Request authValuesRequest = httpClient.newRequest(authValuesUrl).timeout(HTTP_TIMEOUT_SEC,
311 authValuesRequest.header(ACP_SUBSCRIPTION_KEY, BimmerConstants.OCP_APIM_KEYS.get(configuration.region));
312 authValuesRequest.header(X_USER_AGENT,
313 String.format(BimmerConstants.X_USER_AGENT, BimmerConstants.BRAND_BMW, configuration.region));
315 ContentResponse authValuesResponse = authValuesRequest.send();
316 if (authValuesResponse.getStatus() != 200) {
317 throw new HttpResponseException("URL: " + authValuesRequest.getURI() + ", Error: "
318 + authValuesResponse.getStatus() + ", Message: " + authValuesResponse.getContentAsString(),
321 AuthQueryResponse aqr = Converter.getGson().fromJson(authValuesResponse.getContentAsString(),
322 AuthQueryResponse.class);
325 * Step 2) Calculate values for base parameters
327 String verfifierBytes = StringUtils.getRandomAlphabetic(64).toLowerCase();
328 String codeVerifier = Base64.getUrlEncoder().withoutPadding().encodeToString(verfifierBytes.getBytes());
329 MessageDigest digest = MessageDigest.getInstance("SHA-256");
330 byte[] hash = digest.digest(codeVerifier.getBytes(StandardCharsets.UTF_8));
331 String codeChallange = Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
332 String stateBytes = StringUtils.getRandomAlphabetic(16).toLowerCase();
333 String state = Base64.getUrlEncoder().withoutPadding().encodeToString(stateBytes.getBytes());
335 MultiMap<String> baseParams = new MultiMap<String>();
336 baseParams.put(CLIENT_ID, aqr.clientId);
337 baseParams.put(RESPONSE_TYPE, CODE);
338 baseParams.put(REDIRECT_URI, aqr.returnUrl);
339 baseParams.put(STATE, state);
340 baseParams.put(NONCE, BimmerConstants.LOGIN_NONCE);
341 baseParams.put(SCOPE, String.join(Constants.SPACE, aqr.scopes));
342 baseParams.put(CODE_CHALLENGE, codeChallange);
343 baseParams.put(CODE_CHALLENGE_METHOD, "S256");
346 * Step 3) Authorization with username and password
348 String loginUrl = aqr.gcdmBaseUrl + BimmerConstants.OAUTH_ENDPOINT;
349 Request loginRequest = httpClient.POST(loginUrl).timeout(HTTP_TIMEOUT_SEC, TimeUnit.SECONDS);
350 loginRequest.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_URL_ENCODED);
352 MultiMap<String> loginParams = new MultiMap<String>(baseParams);
353 loginParams.put(GRANT_TYPE, BimmerConstants.AUTHORIZATION_CODE);
354 loginParams.put(USERNAME, configuration.userName);
355 loginParams.put(PASSWORD, configuration.password);
356 loginRequest.content(new StringContentProvider(CONTENT_TYPE_URL_ENCODED,
357 UrlEncoded.encode(loginParams, StandardCharsets.UTF_8, false), StandardCharsets.UTF_8));
358 ContentResponse loginResponse = loginRequest.send();
359 if (loginResponse.getStatus() != 200) {
360 throw new HttpResponseException("URL: " + loginRequest.getURI() + ", Error: "
361 + loginResponse.getStatus() + ", Message: " + loginResponse.getContentAsString(),
364 String authCode = getAuthCode(loginResponse.getContentAsString());
367 * Step 4) Authorize with code
369 Request authRequest = httpClient.POST(loginUrl).followRedirects(false).timeout(HTTP_TIMEOUT_SEC,
371 MultiMap<String> authParams = new MultiMap<String>(baseParams);
372 authParams.put(AUTHORIZATION, authCode);
373 authRequest.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_URL_ENCODED);
374 authRequest.content(new StringContentProvider(CONTENT_TYPE_URL_ENCODED,
375 UrlEncoded.encode(authParams, StandardCharsets.UTF_8, false), StandardCharsets.UTF_8));
376 ContentResponse authResponse = authRequest.send();
377 if (authResponse.getStatus() != 302) {
378 throw new HttpResponseException("URL: " + authRequest.getURI() + ", Error: " + authResponse.getStatus()
379 + ", Message: " + authResponse.getContentAsString(), authResponse);
381 String code = MyBMWProxy.codeFromUrl(authResponse.getHeaders().get(HttpHeader.LOCATION));
384 * Step 5) Request token
386 Request codeRequest = httpClient.POST(aqr.tokenEndpoint).timeout(HTTP_TIMEOUT_SEC, TimeUnit.SECONDS);
387 String basicAuth = "Basic "
388 + Base64.getUrlEncoder().encodeToString((aqr.clientId + ":" + aqr.clientSecret).getBytes());
389 codeRequest.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_URL_ENCODED);
390 codeRequest.header(AUTHORIZATION, basicAuth);
392 MultiMap<String> codeParams = new MultiMap<String>();
393 codeParams.put(CODE, code);
394 codeParams.put(CODE_VERIFIER, codeVerifier);
395 codeParams.put(REDIRECT_URI, aqr.returnUrl);
396 codeParams.put(GRANT_TYPE, BimmerConstants.AUTHORIZATION_CODE);
397 codeRequest.content(new StringContentProvider(CONTENT_TYPE_URL_ENCODED,
398 UrlEncoded.encode(codeParams, StandardCharsets.UTF_8, false), StandardCharsets.UTF_8));
399 ContentResponse codeResponse = codeRequest.send();
400 if (codeResponse.getStatus() != 200) {
401 throw new HttpResponseException("URL: " + codeRequest.getURI() + ", Error: " + codeResponse.getStatus()
402 + ", Message: " + codeResponse.getContentAsString(), codeResponse);
404 AuthResponse ar = Converter.getGson().fromJson(codeResponse.getContentAsString(), AuthResponse.class);
405 token.setType(ar.tokenType);
406 token.setToken(ar.accessToken);
407 token.setExpiration(ar.expiresIn);
409 } catch (Exception e) {
410 logger.warn("Authorization Exception: {}", e.getMessage());
415 private String getAuthCode(String response) {
416 String[] keys = response.split("&");
417 for (int i = 0; i < keys.length; i++) {
418 if (keys[i].startsWith(AUTHORIZATION)) {
419 String authCode = keys[i].split("=")[1];
420 authCode = authCode.split("\"")[0];
424 return Constants.EMPTY;
427 public static String codeFromUrl(String encodedUrl) {
428 final MultiMap<String> tokenMap = new MultiMap<String>();
429 UrlEncoded.decodeTo(encodedUrl, tokenMap, StandardCharsets.US_ASCII);
430 final StringBuilder codeFound = new StringBuilder();
431 tokenMap.forEach((key, value) -> {
432 if (!value.isEmpty()) {
433 String val = value.get(0);
434 if (key.endsWith(CODE)) {
435 codeFound.append(val);
439 return codeFound.toString();
442 @SuppressWarnings("null")
443 public synchronized boolean updateTokenChina() {
446 * Step 1) get public key
448 String publicKeyUrl = "https://" + BimmerConstants.EADRAX_SERVER_MAP.get(BimmerConstants.REGION_CHINA)
449 + BimmerConstants.CHINA_PUBLIC_KEY;
450 Request oauthQueryRequest = httpClient.newRequest(publicKeyUrl).timeout(HTTP_TIMEOUT_SEC, TimeUnit.SECONDS);
451 oauthQueryRequest.header(HttpHeader.USER_AGENT, BimmerConstants.USER_AGENT);
452 oauthQueryRequest.header(X_USER_AGENT,
453 String.format(BimmerConstants.X_USER_AGENT, BimmerConstants.BRAND_BMW, configuration.region));
454 ContentResponse publicKeyResponse = oauthQueryRequest.send();
455 if (publicKeyResponse.getStatus() != 200) {
456 throw new HttpResponseException("URL: " + oauthQueryRequest.getURI() + ", Error: "
457 + publicKeyResponse.getStatus() + ", Message: " + publicKeyResponse.getContentAsString(),
460 ChinaPublicKeyResponse pkr = Converter.getGson().fromJson(publicKeyResponse.getContentAsString(),
461 ChinaPublicKeyResponse.class);
464 * Step 2) Encode password with public key
466 // https://www.baeldung.com/java-read-pem-file-keys
467 String publicKeyStr = pkr.data.value;
468 String publicKeyPEM = publicKeyStr.replace("-----BEGIN PUBLIC KEY-----", "")
469 .replaceAll(System.lineSeparator(), "").replace("-----END PUBLIC KEY-----", "").replace("\\r", "")
470 .replace("\\n", "").trim();
471 byte[] encoded = Base64.getDecoder().decode(publicKeyPEM);
472 X509EncodedKeySpec spec = new X509EncodedKeySpec(encoded);
473 KeyFactory kf = KeyFactory.getInstance("RSA");
474 PublicKey publicKey = kf.generatePublic(spec);
475 // https://www.thexcoders.net/java-ciphers-rsa/
476 Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
477 cipher.init(Cipher.ENCRYPT_MODE, publicKey);
478 byte[] encryptedBytes = cipher.doFinal(configuration.password.getBytes());
479 String encodedPassword = Base64.getEncoder().encodeToString(encryptedBytes);
482 * Step 3) Send Auth with encoded password
484 String tokenUrl = "https://" + BimmerConstants.EADRAX_SERVER_MAP.get(BimmerConstants.REGION_CHINA)
485 + BimmerConstants.CHINA_LOGIN;
486 Request loginRequest = httpClient.POST(tokenUrl).timeout(HTTP_TIMEOUT_SEC, TimeUnit.SECONDS);
487 loginRequest.header(X_USER_AGENT,
488 String.format(BimmerConstants.X_USER_AGENT, BimmerConstants.BRAND_BMW, configuration.region));
489 String jsonContent = "{ \"mobile\":\"" + configuration.userName + "\", \"password\":\"" + encodedPassword
491 loginRequest.content(new StringContentProvider(jsonContent));
492 ContentResponse tokenResponse = loginRequest.send();
493 if (tokenResponse.getStatus() != 200) {
494 throw new HttpResponseException("URL: " + loginRequest.getURI() + ", Error: "
495 + tokenResponse.getStatus() + ", Message: " + tokenResponse.getContentAsString(),
498 String authCode = getAuthCode(tokenResponse.getContentAsString());
501 * Step 4) Decode access token
503 ChinaTokenResponse cat = Converter.getGson().fromJson(authCode, ChinaTokenResponse.class);
504 String token = cat.data.accessToken;
505 // https://www.baeldung.com/java-jwt-token-decode
506 String[] chunks = token.split("\\.");
507 String tokenJwtDecodeStr = new String(Base64.getUrlDecoder().decode(chunks[1]));
508 ChinaTokenExpiration cte = Converter.getGson().fromJson(tokenJwtDecodeStr, ChinaTokenExpiration.class);
509 Token t = new Token();
511 t.setType(cat.data.tokenType);
512 t.setExpirationTotal(cte.exp);
514 } catch (Exception e) {
515 logger.warn("Authorization Exception: {}", e.getMessage());