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.nio.charset.StandardCharsets;
18 import java.util.concurrent.ExecutionException;
19 import java.util.concurrent.TimeUnit;
20 import java.util.concurrent.TimeoutException;
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;
48 import com.google.gson.JsonSyntaxException;
51 * The {@link ConnectedDriveProxy} This class holds the important constants for the BMW Connected Drive Authorization.
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
58 * @author Bernd Weymann - Initial contribution
59 * @author Norbert Truchsess - edit & send of charge profile
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;
71 * URLs taken from https://github.com/bimmerconnected/bimmer_connected/blob/master/bimmer_connected/const.py
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";
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;
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/";
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());
112 logger.debug("Simulation of {} not supported", url);
117 final String encoded = params == null || params.isEmpty() ? null
118 : UrlEncoded.encode(params, StandardCharsets.UTF_8, false);
119 final String completeUrl;
123 req = httpClient.POST(url);
124 if (encoded != null) {
125 req.content(new StringContentProvider(CONTENT_TYPE_URL_ENCODED, encoded, StandardCharsets.UTF_8));
128 completeUrl = encoded == null ? url : url + Constants.QUESTION + encoded;
129 req = httpClient.newRequest(completeUrl);
131 req.header(HttpHeader.AUTHORIZATION, getToken().getBearerToken());
132 req.header(HttpHeader.REFERER, BimmerConstants.REFERER_URL);
134 req.timeout(HTTP_TIMEOUT_SEC, TimeUnit.SECONDS).send(new BufferingResponseListener() {
135 @NonNullByDefault({})
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();
145 error.reason = result.getFailure().getMessage();
147 error.params = result.getRequest().getParams().toString();
148 logger.debug("HTTP Error {}", error.toString());
149 callback.onError(error);
151 if (callback instanceof StringResponseCallback) {
152 ((StringResponseCallback) callback).onResponse(getContentAsString());
153 } else if (callback instanceof ByteResponseCallback) {
154 ((ByteResponseCallback) callback).onResponse(getContent());
156 logger.error("unexpected reponse type {}", callback.getClass().getName());
163 public void get(String url, @Nullable MultiMap<String> params, ResponseCallback callback) {
164 call(url, false, params, callback);
167 public void post(String url, @Nullable MultiMap<String> params, ResponseCallback callback) {
168 call(url, true, params, callback);
171 public void requestVehicles(StringResponseCallback callback) {
172 get(vehicleUrl, null, callback);
175 public void requestVehcileStatus(VehicleConfiguration config, StringResponseCallback callback) {
176 get(baseUrl + config.vin + vehicleStatusAPI, null, callback);
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);
184 public void requestLastTrip(VehicleConfiguration config, StringResponseCallback callback) {
185 get(baseUrl + config.vin + lastTripAPI, null, callback);
188 public void requestAllTrips(VehicleConfiguration config, StringResponseCallback callback) {
189 get(baseUrl + config.vin + allTripsAPI, null, callback);
192 public void requestChargingProfile(VehicleConfiguration config, StringResponseCallback callback) {
193 get(baseUrl + config.vin + chargeAPI, null, callback);
196 public void requestDestinations(VehicleConfiguration config, StringResponseCallback callback) {
197 get(baseUrl + config.vin + destinationAPI, null, callback);
200 public void requestRangeMap(VehicleConfiguration config, @Nullable MultiMap<String> params,
201 StringResponseCallback callback) {
202 get(baseUrl + config.vin + rangeMapAPI, params, callback);
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);
214 private String getRegionServer() {
215 final String retVal = BimmerConstants.SERVER_MAP.get(configuration.region);
216 return retVal == null ? Constants.INVALID : retVal;
219 private String getAuthorizationValue() {
220 final String retVal = BimmerConstants.AUTHORIZATION_VALUE_MAP.get(configuration.region);
221 return retVal == null ? Constants.INVALID : retVal;
224 RemoteServiceHandler getRemoteServiceHandler(VehicleHandler vehicleHandler) {
225 return new RemoteServiceHandler(vehicleHandler, this);
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.
237 public Token getToken() {
238 if (token.isExpired() || !token.isValid()) {
245 * Authorize at BMW Connected Drive Portal and get Token
249 private synchronized void updateToken() {
250 if (!authHttpClient.isStarted()) {
252 authHttpClient.start();
253 } catch (Exception e) {
254 logger.warn("Auth Http Client cannot be started {}", e.getMessage());
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);
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));
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()) {
284 final AuthResponse authResponse = Converter.getGson().fromJson(stringContent,
286 if (authResponse != null) {
287 token.setToken(authResponse.accessToken);
288 token.setType(authResponse.tokenType);
289 token.setExpiration(authResponse.expiresIn);
291 logger.debug("not an Authorization response: {}", stringContent);
293 } catch (JsonSyntaxException jse) {
294 logger.debug("Authorization response unparsable: {}", stringContent);
297 logger.debug("Authorization response has no content");
300 logger.debug("Authorization status {} reason {}", contentResponse.getStatus(),
301 contentResponse.getReason());
303 } catch (InterruptedException | ExecutionException | TimeoutException e) {
304 logger.debug("Authorization exception: {}", e.getMessage());
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());