]> git.basschouten.com Git - openhab-addons.git/blob
a7d250579aa1569fde07cc1bd935c4c1b72e3594
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2022 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.io.BufferedReader;
18 import java.io.IOException;
19 import java.io.InputStreamReader;
20 import java.io.OutputStream;
21 import java.net.HttpURLConnection;
22 import java.net.URL;
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;
30
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;
54
55 /**
56  * The {@link ConnectedDriveProxy} This class holds the important constants for the BMW Connected Drive Authorization.
57  * They
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
62  *
63  * @author Bernd Weymann - Initial contribution
64  * @author Norbert Truchsess - edit & send of charge profile
65  */
66 @NonNullByDefault
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;
74
75     /**
76      * URLs taken from https://github.com/bimmerconnected/bimmer_connected/blob/master/bimmer_connected/const.py
77      */
78     final String baseUrl;
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";
96
97     public ConnectedDriveProxy(HttpClientFactory httpClientFactory, ConnectedDriveConfiguration config) {
98         httpClient = httpClientFactory.getCommonHttpClient();
99         authHttpClient = httpClientFactory.createHttpClient(AUTH_HTTP_CLIENT_NAME);
100         configuration = config;
101
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";
110     }
111
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());
121             } else {
122                 logger.debug("Simulation of {} not supported", url);
123             }
124             return;
125         }
126         final Request req;
127         final String completeUrl;
128
129         if (post) {
130             completeUrl = url;
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));
138                 }
139             }
140         } else {
141             completeUrl = params == null ? url : url + Constants.QUESTION + params;
142             req = httpClient.newRequest(completeUrl);
143         }
144         req.header(HttpHeader.AUTHORIZATION, getToken().getBearerToken());
145         req.header(HttpHeader.REFERER, BimmerConstants.LEGACY_REFERER_URL);
146
147         req.timeout(HTTP_TIMEOUT_SEC, TimeUnit.SECONDS).send(new BufferingResponseListener() {
148             @NonNullByDefault({})
149             @Override
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();
157                     } else {
158                         error.reason = result.getFailure().getMessage();
159                     }
160                     error.params = result.getRequest().getParams().toString();
161                     logger.debug("HTTP Error {}", error.toString());
162                     callback.onError(error);
163                 } else {
164                     if (callback instanceof StringResponseCallback) {
165                         ((StringResponseCallback) callback).onResponse(getContentAsString());
166                     } else if (callback instanceof ByteResponseCallback) {
167                         ((ByteResponseCallback) callback).onResponse(getContent());
168                     } else {
169                         logger.error("unexpected reponse type {}", callback.getClass().getName());
170                     }
171                 }
172             }
173         });
174     }
175
176     public void get(String url, @Nullable String coding, @Nullable String params, ResponseCallback callback) {
177         call(url, false, coding, params, callback);
178     }
179
180     public void post(String url, @Nullable String coding, @Nullable String params, ResponseCallback callback) {
181         call(url, true, coding, params, callback);
182     }
183
184     public void requestVehicles(StringResponseCallback callback) {
185         get(vehicleUrl, null, null, callback);
186     }
187
188     public void requestVehcileStatus(VehicleConfiguration config, StringResponseCallback callback) {
189         get(baseUrl + config.vin + vehicleStatusAPI, null, null, callback);
190     }
191
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);
195     }
196
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);
200     }
201
202     public void requestLastTrip(VehicleConfiguration config, StringResponseCallback callback) {
203         get(baseUrl + config.vin + lastTripAPI, null, null, callback);
204     }
205
206     public void requestAllTrips(VehicleConfiguration config, StringResponseCallback callback) {
207         get(baseUrl + config.vin + allTripsAPI, null, null, callback);
208     }
209
210     public void requestChargingProfile(VehicleConfiguration config, StringResponseCallback callback) {
211         get(baseUrl + config.vin + chargeAPI, null, null, callback);
212     }
213
214     public void requestDestinations(VehicleConfiguration config, StringResponseCallback callback) {
215         get(baseUrl + config.vin + destinationAPI, null, null, callback);
216     }
217
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);
222     }
223
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);
230
231         get(localImageUrl, CONTENT_TYPE_URL_ENCODED, UrlEncoded.encode(dataMap, StandardCharsets.UTF_8, false),
232                 callback);
233     }
234
235     RemoteServiceHandler getRemoteServiceHandler(VehicleHandler vehicleHandler) {
236         remoteServiceHandler = Optional.of(new RemoteServiceHandler(vehicleHandler, this));
237         return remoteServiceHandler.get();
238     }
239
240     // Token handling
241
242     /**
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.
246      *
247      * @return token
248      */
249     public Token getToken() {
250         if (!token.isValid()) {
251             if (configuration.preferMyBmw) {
252                 if (!updateToken()) {
253                     if (!updateLegacyToken()) {
254                         logger.debug("Authorization failed!");
255                     }
256                 }
257             } else {
258                 if (!updateLegacyToken()) {
259                     if (!updateToken()) {
260                         logger.debug("Authorization failed!");
261                     }
262                 }
263             }
264         }
265         remoteServiceHandler.ifPresent(serviceHandler -> {
266             serviceHandler.setMyBmwApiUsage(token.isMyBmwApiUsage());
267         });
268         return token;
269     }
270
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);
275             return false;
276         }
277         if (!startAuthClient()) {
278             return false;
279         } // else continue
280         String authUri = "https://" + BimmerConstants.AUTH_SERVER_MAP.get(configuration.region)
281                 + BimmerConstants.OAUTH_ENDPOINT;
282
283         Request authRequest = authHttpClient.POST(authUri);
284         authRequest.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_URL_ENCODED);
285
286         MultiMap<String> authChallenge = getTokenBaseValues();
287         authChallenge.addAllValues(getTokenAuthValues());
288         String authEncoded = UrlEncoded.encode(authChallenge, Charset.defaultCharset(), false);
289         authRequest.content(new StringContentProvider(authEncoded));
290         try {
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);
297
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));
304
305                 // Get Token
306                 String tokenUrl = "https://" + BimmerConstants.AUTH_SERVER_MAP.get(configuration.region)
307                         + BimmerConstants.TOKEN_ENDPOINT;
308
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(),
317                         AuthResponse.class);
318                 token.setToken(authResponseJson.accessToken);
319                 token.setType(authResponseJson.tokenType);
320                 token.setExpiration(authResponseJson.expiresIn);
321                 token.setMyBmwApiUsage(true);
322                 return true;
323             }
324         } catch (InterruptedException | ExecutionException |
325
326                 TimeoutException e) {
327             logger.debug("Authorization exception: {}", e.getMessage());
328         }
329         return false;
330     }
331
332     private boolean startAuthClient() {
333         if (!authHttpClient.isStarted()) {
334             try {
335                 authHttpClient.start();
336             } catch (Exception e) {
337                 logger.error("Auth HttpClient start failed!");
338                 return false;
339             }
340         }
341         return true;
342     }
343
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);
352         return baseValues;
353     }
354
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);
360         return authValues;
361     }
362
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");
369         return tokenValues;
370     }
371
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];
378                 return authCode;
379             }
380         }
381         return Constants.EMPTY;
382     }
383
384     public synchronized boolean updateLegacyToken() {
385         logger.debug("updateLegacyToken");
386         try {
387             /**
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
391              */
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);
406
407             OutputStream os = con.getOutputStream();
408             byte[] input = getAuthEncodedData().getBytes("utf-8");
409             os.write(input, 0, input.length);
410
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());
416             }
417             token.setMyBmwApiUsage(false);
418             return tokenFromUrl(con.getHeaderField(HttpHeader.LOCATION.toString()));
419         } catch (IOException e) {
420             logger.warn("{}", e.getMessage());
421         }
422         return false;
423     }
424
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());
437                 }
438             }
439         });
440         logger.info("Token valid? {}", token.isValid());
441         return token.isValid();
442     }
443
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());
453                 }
454             }
455         });
456         return codeFound.toString();
457     }
458
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);
468     }
469 }