[BMWConnectedDrive] Authorization fix + channel enhancements (#11213) (#11263) (#11264)

This commit is contained in:
Bernd Weymann 2021-10-03 08:15:07 +02:00 committed by GitHub
parent 437fb7f336
commit af007f4369
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 756 additions and 263 deletions

View File

@ -95,6 +95,14 @@ The region Configuration has 3 different options
* _CHINA_
* _ROW_ (Rest of World)
#### Advanced Configuration
| Parameter | Type | Description |
|-----------------|---------|--------------------------------------------------------------------|
| preferMyBmw | boolean | Prefer *MyBMW* API instead of *BMW Connected Drive* |
### Thing Configuration
Same configuration is needed for all things
@ -147,8 +155,10 @@ Reflects overall status of the vehicle.
| Next Service Date | service-date | DateTime | Date of upcoming service |
| Mileage till Next Service | service-mileage | Number:Length | Mileage till upcoming service |
| Check Control | check-control | String | Presence of active warning messages |
| Plug Connection Status | plug-connection | String | Only available for phev, bev_rex and bev |
| Charging Status | charge | String | Only available for phev, bev_rex and bev |
| Last Status Timestamp | last-update | DateTime | Date and time of last status update |
| Last Status Update Reason | last-update-reason | DateTime | Date and time of last status update |
Overall Door Status values
@ -180,6 +190,27 @@ Charging Status values
* _Charging Goal reached_
* _Waiting For Charging_
Last update reasons
* _CHARGING_DONE_
* _CHARGING_INTERRUPED_
* _CHARGING_PAUSED
* _CHARGING_STARTED_
* _CYCLIC_RECHARGING_
* _DISCONNECTED_
* _DOOR_STATE_CHANGED_
* _NO_CYCLIC_RECHARGING_
* _NO_LSC_TRIGGER_
* _ON_DEMAND_
* _PREDICTION_UPDATE_
* _TEMPORARY_POWER_SUPPLY_FAILURE_
* _UNKNOWN_
* _VEHICLE_MOVING_
* _VEHICLE_SECURED_
* _VEHICLE_SHUTDOWN_
* _VEHICLE_SHUTDOWN_SECURED_
* _VEHICLE_UNSECURED_
#### Services
Group for all upcoming services with description, service date and/or service mileage.
@ -253,17 +284,20 @@ See description [Range vs Range Radius](#range-vs-range-radius) to get more info
* Availability according to table
* Read-only values
| Channel Label | Channel ID | Type | conv | phev | bev_rex | bev |
|-----------------------|-----------------------|----------------------|------|------|---------|-----|
| Mileage | mileage | Number:Length | X | X | X | X |
| Fuel Range | range-fuel | Number:Length | X | X | X | |
| Battery Range | range-electric | Number:Length | | X | X | X |
| Hybrid Range | range-hybrid | Number:Length | | X | X | |
| Battery Charge Level | soc | Number:Dimensionless | | X | X | X |
| Remaining Fuel | remaining-fuel | Number:Volume | X | X | X | |
| Fuel Range Radius | range-radius-fuel | Number:Length | X | X | X | |
| Electric Range Radius | range-radius-electric | Number:Length | | X | X | X |
| Hybrid Range Radius | range-radius-hybrid | Number:Length | | X | X | |
| Channel Label | Channel ID | Type | conv | phev | bev_rex | bev |
|---------------------------|-------------------------|----------------------|------|------|---------|-----|
| Mileage | mileage | Number:Length | X | X | X | X |
| Fuel Range | range-fuel | Number:Length | X | X | X | |
| Battery Range | range-electric | Number:Length | | X | X | X |
| Max Battery Range | range-electric-max | Number:Length | | X | X | X |
| Hybrid Range | range-hybrid | Number:Length | | X | X | |
| Battery Charge Level | soc | Number:Dimensionless | | X | X | X |
| Max Battery Capacity | soc-max | Number:Power | | | X | X | X |
| Remaining Fuel | remaining-fuel | Number:Volume | X | X | X | |
| Fuel Range Radius | range-radius-fuel | Number:Length | X | X | X | |
| Electric Range Radius | range-radius-electric | Number:Length | | X | X | X |
| Hybrid Range Radius | range-radius-hybrid | Number:Length | | X | X | |
| Max Hybrid Range Radius | range-radius-hybrid-max | Number:Length | | X | X | |
#### Charge Profile

View File

@ -37,4 +37,9 @@ public class ConnectedDriveConfiguration {
* BMW Connected Drive Password
*/
public String password = Constants.EMPTY;
/**
* Prefer MyBMW API instead of BMW Connected Drive
*/
public boolean preferMyBmw = false;
}

View File

@ -119,10 +119,12 @@ public class ConnectedDriveConstants {
public static final String SERVICE_DATE = "service-date";
public static final String SERVICE_MILEAGE = "service-mileage";
public static final String CHECK_CONTROL = "check-control";
public static final String PLUG_CONNECTION = "plug-connection";
public static final String CHARGE_STATUS = "charge";
public static final String CHARGE_END_REASON = "reason";
public static final String CHARGE_REMAINING = "remaining";
public static final String LAST_UPDATE = "last-update";
public static final String LAST_UPDATE_REASON = "last-update-reason";
// Door Details
public static final String DOOR_DRIVER_FRONT = "driver-front";
@ -161,13 +163,18 @@ public class ConnectedDriveConstants {
// Range
public static final String RANGE_HYBRID = "hybrid";
public static final String RANGE_HYBRID_MAX = "hybrid-max";
public static final String RANGE_ELECTRIC = "electric";
public static final String RANGE_ELECTRIC_MAX = "electric-max";
public static final String SOC = "soc";
public static final String SOC_MAX = "soc-max";
public static final String RANGE_FUEL = "fuel";
public static final String REMAINING_FUEL = "remaining-fuel";
public static final String RANGE_RADIUS_ELECTRIC = "radius-electric";
public static final String RANGE_RADIUS_ELECTRIC_MAX = "radius-electric-max";
public static final String RANGE_RADIUS_FUEL = "radius-fuel";
public static final String RANGE_RADIUS_HYBRID = "radius-hybrid";
public static final String RANGE_RADIUS_HYBRID_MAX = "radius-hybrid-max";
// Last Trip
public static final String DURATION = "duration";

View File

@ -26,4 +26,9 @@ public class AuthResponse {
public String tokenType;
@SerializedName("expires_in")
public int expiresIn;
@Override
public String toString() {
return "Token " + accessToken + " type " + tokenType + " expires in " + expiresIn;
}
}

View File

@ -0,0 +1,31 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bmwconnecteddrive.internal.dto.navigation;
/**
* The {@link NavigationContainer} Data Transfer Object
*
* @author Bernd Weymann - Initial contribution
*/
public class NavigationContainer {
// "latitude": 56.789,
// "longitude": 8.765,
// "isoCountryCode": "DEU",
// "auxPowerRegular": 1.4,
// "auxPowerEcoPro": 1.2,
// "auxPowerEcoProPlus": 0.4,
// "soc": 25.952999114990234,
// "pendingUpdate": false,
// "vehicleTracking": true,
public double socmax;// ": 29.84
}

View File

@ -19,4 +19,7 @@ package org.openhab.binding.bmwconnecteddrive.internal.dto.remote;
*/
public class ExecutionStatusContainer {
public ExecutionStatus executionStatus;
public String eventId;
public String creationTime;
public String eventStatus;
}

View File

@ -16,6 +16,7 @@ import static org.openhab.binding.bmwconnecteddrive.internal.utils.Constants.ANO
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
@ -33,9 +34,11 @@ import org.openhab.binding.bmwconnecteddrive.internal.utils.Converter;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.BaseBridgeHandler;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerService;
import org.openhab.core.types.Command;
import org.slf4j.Logger;
@ -73,23 +76,34 @@ public class ConnectedDriveBridgeHandler extends BaseBridgeHandler implements St
troubleshootFingerprint = Optional.empty();
updateStatus(ThingStatus.UNKNOWN);
ConnectedDriveConfiguration config = getConfigAs(ConnectedDriveConfiguration.class);
logger.debug("Prefer MyBMW API {}", config.preferMyBmw);
if (!checkConfiguration(config)) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR);
} else {
proxy = Optional.of(new ConnectedDriveProxy(httpClientFactory, config));
// give the system some time to create all predefined Vehicles
// check with API call if bridge is online
initializerJob = Optional.of(scheduler.schedule(this::requestVehicles, 5, TimeUnit.SECONDS));
initializerJob = Optional.of(scheduler.schedule(this::requestVehicles, 2, TimeUnit.SECONDS));
Bridge b = super.getThing();
List<Thing> children = b.getThings();
logger.debug("Update {} things", children.size());
children.forEach(entry -> {
ThingHandler th = entry.getHandler();
if (th != null) {
th.dispose();
th.initialize();
} else {
logger.debug("Handler is null");
}
});
}
}
public static boolean checkConfiguration(ConnectedDriveConfiguration config) {
if (Constants.EMPTY.equals(config.userName) || Constants.EMPTY.equals(config.password)) {
return false;
} else if (BimmerConstants.AUTH_SERVER_MAP.containsKey(config.region)) {
return true;
} else {
return false;
return BimmerConstants.AUTH_SERVER_MAP.containsKey(config.region);
}
}
@ -102,6 +116,7 @@ public class ConnectedDriveBridgeHandler extends BaseBridgeHandler implements St
proxy.ifPresent(prox -> prox.requestVehicles(this));
}
// https://www.bmw-connecteddrive.de/api/me/vehicles/v2?all=true&brand=BM
public String getDiscoveryFingerprint() {
return troubleshootFingerprint.map(fingerprint -> {
VehiclesContainer container = null;
@ -127,6 +142,8 @@ public class ConnectedDriveBridgeHandler extends BaseBridgeHandler implements St
});
return Converter.getGson().toJson(container);
}
} else {
logger.debug("container.vehicles is null");
}
}
} catch (JsonParseException jpe) {
@ -172,7 +189,8 @@ public class ConnectedDriveBridgeHandler extends BaseBridgeHandler implements St
}
});
}
return Converter.getGson().toJson(container);
} else {
troubleshootFingerprint = Optional.of(Constants.EMPTY_JSON);
}
} catch (JsonParseException jpe) {
logger.debug("Fingerprint parse exception {}", jpe.getMessage());

View File

@ -14,7 +14,16 @@ package org.openhab.binding.bmwconnecteddrive.internal.handler;
import static org.openhab.binding.bmwconnecteddrive.internal.utils.HTTPConstants.*;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLDecoder;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
@ -27,8 +36,6 @@ import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.client.api.Result;
import org.eclipse.jetty.client.util.BufferingResponseListener;
import org.eclipse.jetty.client.util.StringContentProvider;
import org.eclipse.jetty.http.HttpField;
import org.eclipse.jetty.http.HttpFields;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.util.MultiMap;
import org.eclipse.jetty.util.UrlEncoded;
@ -45,8 +52,6 @@ import org.openhab.core.io.net.http.HttpClientFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.JsonSyntaxException;
/**
* The {@link ConnectedDriveProxy} This class holds the important constants for the BMW Connected Drive Authorization.
* They
@ -61,10 +66,10 @@ import com.google.gson.JsonSyntaxException;
@NonNullByDefault
public class ConnectedDriveProxy {
private final Logger logger = LoggerFactory.getLogger(ConnectedDriveProxy.class);
private Optional<RemoteServiceHandler> remoteServiceHandler = Optional.empty();
private final Token token = new Token();
private final HttpClient httpClient;
private final HttpClient authHttpClient;
private final String legacyAuthUri;
private final ConnectedDriveConfiguration configuration;
/**
@ -73,6 +78,9 @@ public class ConnectedDriveProxy {
final String baseUrl;
final String vehicleUrl;
final String legacyUrl;
final String remoteCommandUrl;
final String remoteStatusUrl;
final String navigationAPIUrl;
final String vehicleStatusAPI = "/status";
final String lastTripAPI = "/statistics/lastTrip";
final String allTripsAPI = "/statistics/allTrips";
@ -82,25 +90,27 @@ public class ConnectedDriveProxy {
final String rangeMapAPI = "/rangemap";
final String serviceExecutionAPI = "/executeService";
final String serviceExecutionStateAPI = "/serviceExecutionStatus";
public static final String REMOTE_SERVICE_EADRAX_BASE_URL = "/eadrax-vrccs/v2/presentation/remote-commands/"; // '/{vin}/{service_type}'
final String remoteServiceEADRXstatusUrl = REMOTE_SERVICE_EADRAX_BASE_URL + "eventStatus?eventId={event_id}";
final String vehicleEADRXPoiUrl = "/eadrax-dcs/v1/send-to-car/send-to-car";
public ConnectedDriveProxy(HttpClientFactory httpClientFactory, ConnectedDriveConfiguration config) {
httpClient = httpClientFactory.getCommonHttpClient();
authHttpClient = httpClientFactory.createHttpClient(AUTH_HTTP_CLIENT_NAME);
authHttpClient.setFollowRedirects(false);
configuration = config;
final StringBuilder legacyAuth = new StringBuilder();
legacyAuth.append("https://");
legacyAuth.append(BimmerConstants.AUTH_SERVER_MAP.get(configuration.region));
legacyAuth.append(BimmerConstants.OAUTH_ENDPOINT);
legacyAuthUri = legacyAuth.toString();
vehicleUrl = "https://" + getRegionServer() + "/webapi/v1/user/vehicles";
vehicleUrl = "https://" + BimmerConstants.API_SERVER_MAP.get(configuration.region) + "/webapi/v1/user/vehicles";
baseUrl = vehicleUrl + "/";
legacyUrl = "https://" + getRegionServer() + "/api/vehicle/dynamic/v1/";
legacyUrl = "https://" + BimmerConstants.API_SERVER_MAP.get(configuration.region) + "/api/vehicle/dynamic/v1/";
navigationAPIUrl = "https://" + BimmerConstants.API_SERVER_MAP.get(configuration.region)
+ "/api/vehicle/navigation/v1/";
remoteCommandUrl = "https://" + BimmerConstants.EADRAX_SERVER_MAP.get(configuration.region)
+ REMOTE_SERVICE_EADRAX_BASE_URL;
remoteStatusUrl = remoteCommandUrl + "eventStatus";
}
private synchronized void call(final String url, final boolean post, final @Nullable MultiMap<String> params,
final ResponseCallback callback) {
public synchronized void call(final String url, final boolean post, final @Nullable String encoding,
final @Nullable String params, final ResponseCallback callback) {
// only executed in "simulation mode"
// SimulationTest.testSimulationOff() assures Injector is off when releasing
if (Injector.isActive()) {
@ -114,22 +124,25 @@ public class ConnectedDriveProxy {
return;
}
final Request req;
final String encoded = params == null || params.isEmpty() ? null
: UrlEncoded.encode(params, StandardCharsets.UTF_8, false);
final String completeUrl;
if (post) {
completeUrl = url;
req = httpClient.POST(url);
if (encoded != null) {
req.content(new StringContentProvider(CONTENT_TYPE_URL_ENCODED, encoded, StandardCharsets.UTF_8));
if (encoding != null) {
if (CONTENT_TYPE_URL_ENCODED.equals(encoding)) {
req.content(new StringContentProvider(CONTENT_TYPE_URL_ENCODED, params, StandardCharsets.UTF_8));
} else if (CONTENT_TYPE_JSON_ENCODED.equals(encoding)) {
req.header(HttpHeader.CONTENT_TYPE, encoding);
req.content(new StringContentProvider(CONTENT_TYPE_JSON_ENCODED, params, StandardCharsets.UTF_8));
}
}
} else {
completeUrl = encoded == null ? url : url + Constants.QUESTION + encoded;
completeUrl = params == null ? url : url + Constants.QUESTION + params;
req = httpClient.newRequest(completeUrl);
}
req.header(HttpHeader.AUTHORIZATION, getToken().getBearerToken());
req.header(HttpHeader.REFERER, BimmerConstants.REFERER_URL);
req.header(HttpHeader.REFERER, BimmerConstants.LEGACY_REFERER_URL);
req.timeout(HTTP_TIMEOUT_SEC, TimeUnit.SECONDS).send(new BufferingResponseListener() {
@NonNullByDefault({})
@ -160,46 +173,52 @@ public class ConnectedDriveProxy {
});
}
public void get(String url, @Nullable MultiMap<String> params, ResponseCallback callback) {
call(url, false, params, callback);
public void get(String url, @Nullable String coding, @Nullable String params, ResponseCallback callback) {
call(url, false, coding, params, callback);
}
public void post(String url, @Nullable MultiMap<String> params, ResponseCallback callback) {
call(url, true, params, callback);
public void post(String url, @Nullable String coding, @Nullable String params, ResponseCallback callback) {
call(url, true, coding, params, callback);
}
public void requestVehicles(StringResponseCallback callback) {
get(vehicleUrl, null, callback);
get(vehicleUrl, null, null, callback);
}
public void requestVehcileStatus(VehicleConfiguration config, StringResponseCallback callback) {
get(baseUrl + config.vin + vehicleStatusAPI, null, callback);
get(baseUrl + config.vin + vehicleStatusAPI, null, null, callback);
}
public void requestLegacyVehcileStatus(VehicleConfiguration config, StringResponseCallback callback) {
// see https://github.com/jupe76/bmwcdapi/search?q=dynamic%2Fv1
get(legacyUrl + config.vin + "?offset=-60", null, callback);
get(legacyUrl + config.vin + "?offset=-60", null, null, callback);
}
public void requestLNavigation(VehicleConfiguration config, StringResponseCallback callback) {
// see https://github.com/jupe76/bmwcdapi/search?q=dynamic%2Fv1
get(navigationAPIUrl + config.vin, null, null, callback);
}
public void requestLastTrip(VehicleConfiguration config, StringResponseCallback callback) {
get(baseUrl + config.vin + lastTripAPI, null, callback);
get(baseUrl + config.vin + lastTripAPI, null, null, callback);
}
public void requestAllTrips(VehicleConfiguration config, StringResponseCallback callback) {
get(baseUrl + config.vin + allTripsAPI, null, callback);
get(baseUrl + config.vin + allTripsAPI, null, null, callback);
}
public void requestChargingProfile(VehicleConfiguration config, StringResponseCallback callback) {
get(baseUrl + config.vin + chargeAPI, null, callback);
get(baseUrl + config.vin + chargeAPI, null, null, callback);
}
public void requestDestinations(VehicleConfiguration config, StringResponseCallback callback) {
get(baseUrl + config.vin + destinationAPI, null, callback);
get(baseUrl + config.vin + destinationAPI, null, null, callback);
}
public void requestRangeMap(VehicleConfiguration config, @Nullable MultiMap<String> params,
StringResponseCallback callback) {
get(baseUrl + config.vin + rangeMapAPI, params, callback);
get(baseUrl + config.vin + rangeMapAPI, CONTENT_TYPE_URL_ENCODED,
UrlEncoded.encode(params, StandardCharsets.UTF_8, false), callback);
}
public void requestImage(VehicleConfiguration config, ImageProperties props, ByteResponseCallback callback) {
@ -208,21 +227,14 @@ public class ConnectedDriveProxy {
dataMap.add("width", Integer.toString(props.size));
dataMap.add("height", Integer.toString(props.size));
dataMap.add("view", props.viewport);
get(localImageUrl, dataMap, callback);
}
private String getRegionServer() {
final String retVal = BimmerConstants.SERVER_MAP.get(configuration.region);
return retVal == null ? Constants.INVALID : retVal;
}
private String getAuthorizationValue() {
final String retVal = BimmerConstants.AUTHORIZATION_VALUE_MAP.get(configuration.region);
return retVal == null ? Constants.INVALID : retVal;
get(localImageUrl, CONTENT_TYPE_URL_ENCODED, UrlEncoded.encode(dataMap, StandardCharsets.UTF_8, false),
callback);
}
RemoteServiceHandler getRemoteServiceHandler(VehicleHandler vehicleHandler) {
return new RemoteServiceHandler(vehicleHandler, this);
remoteServiceHandler = Optional.of(new RemoteServiceHandler(vehicleHandler, this));
return remoteServiceHandler.get();
}
// Token handling
@ -235,77 +247,182 @@ public class ConnectedDriveProxy {
* @return token
*/
public Token getToken() {
if (token.isExpired() || !token.isValid()) {
updateToken();
if (!token.isValid()) {
if (configuration.preferMyBmw) {
if (!updateToken()) {
if (!updateLegacyToken()) {
logger.debug("Authorization failed!");
}
}
} else {
if (!updateLegacyToken()) {
if (!updateToken()) {
logger.debug("Authorization failed!");
}
}
}
}
remoteServiceHandler.ifPresent(serviceHandler -> {
serviceHandler.setMyBmwApiUsage(token.isMyBmwApiUsage());
});
return token;
}
/**
* Authorize at BMW Connected Drive Portal and get Token
*
* @return
*/
private synchronized void updateToken() {
public synchronized boolean updateToken() {
if (BimmerConstants.REGION_CHINA.equals(configuration.region)) {
// region China currently not supported for MyBMW API
logger.debug("Region {} not supported yet for MyBMW Login", BimmerConstants.REGION_CHINA);
return false;
}
if (!startAuthClient()) {
return false;
} // else continue
String authUri = "https://" + BimmerConstants.AUTH_SERVER_MAP.get(configuration.region)
+ BimmerConstants.OAUTH_ENDPOINT;
Request authRequest = authHttpClient.POST(authUri);
authRequest.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_URL_ENCODED);
MultiMap<String> authChallenge = getTokenBaseValues();
authChallenge.addAllValues(getTokenAuthValues());
String authEncoded = UrlEncoded.encode(authChallenge, Charset.defaultCharset(), false);
authRequest.content(new StringContentProvider(authEncoded));
try {
ContentResponse authResponse = authRequest.timeout(HTTP_TIMEOUT_SEC, TimeUnit.SECONDS).send();
String authResponseString = URLDecoder.decode(authResponse.getContentAsString(), Charset.defaultCharset());
String authCode = getAuthCode(authResponseString);
if (authCode != Constants.EMPTY) {
MultiMap<String> codeChallenge = getTokenBaseValues();
codeChallenge.put(AUTHORIZATION, authCode);
Request codeRequest = authHttpClient.POST(authUri).followRedirects(false);
codeRequest.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_URL_ENCODED);
String codeEncoded = UrlEncoded.encode(codeChallenge, Charset.defaultCharset(), false);
codeRequest.content(new StringContentProvider(codeEncoded));
ContentResponse codeResponse = codeRequest.timeout(HTTP_TIMEOUT_SEC, TimeUnit.SECONDS).send();
String code = ConnectedDriveProxy.codeFromUrl(codeResponse.getHeaders().get(HttpHeader.LOCATION));
// Get Token
String tokenUrl = "https://" + BimmerConstants.AUTH_SERVER_MAP.get(configuration.region)
+ BimmerConstants.TOKEN_ENDPOINT;
Request tokenRequest = authHttpClient.POST(tokenUrl).followRedirects(false);
tokenRequest.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_URL_ENCODED);
tokenRequest.header(HttpHeader.AUTHORIZATION,
BimmerConstants.AUTHORIZATION_VALUE_MAP.get(configuration.region));
String tokenEncoded = UrlEncoded.encode(getTokenValues(code), Charset.defaultCharset(), false);
tokenRequest.content(new StringContentProvider(tokenEncoded));
ContentResponse tokenResponse = tokenRequest.timeout(HTTP_TIMEOUT_SEC, TimeUnit.SECONDS).send();
AuthResponse authResponseJson = Converter.getGson().fromJson(tokenResponse.getContentAsString(),
AuthResponse.class);
token.setToken(authResponseJson.accessToken);
token.setType(authResponseJson.tokenType);
token.setExpiration(authResponseJson.expiresIn);
token.setMyBmwApiUsage(true);
return true;
}
} catch (InterruptedException | ExecutionException |
TimeoutException e) {
logger.debug("Authorization exception: {}", e.getMessage());
}
return false;
}
private boolean startAuthClient() {
if (!authHttpClient.isStarted()) {
try {
authHttpClient.start();
} catch (Exception e) {
logger.warn("Auth Http Client cannot be started {}", e.getMessage());
return;
logger.error("Auth HttpClient start failed!");
return false;
}
}
final Request req = authHttpClient.POST(legacyAuthUri);
req.header(HttpHeader.CONNECTION, KEEP_ALIVE);
req.header(HttpHeader.HOST, getRegionServer());
req.header(HttpHeader.AUTHORIZATION, getAuthorizationValue());
req.header(CREDENTIALS, BimmerConstants.CREDENTIAL_VALUES);
req.header(HttpHeader.REFERER, BimmerConstants.REFERER_URL);
final MultiMap<String> dataMap = new MultiMap<String>();
dataMap.add("grant_type", "password");
dataMap.add(SCOPE, BimmerConstants.SCOPE_VALUES);
dataMap.add(USERNAME, configuration.userName);
dataMap.add(PASSWORD, configuration.password);
req.content(new StringContentProvider(CONTENT_TYPE_URL_ENCODED,
UrlEncoded.encode(dataMap, StandardCharsets.UTF_8, false), StandardCharsets.UTF_8));
try {
ContentResponse contentResponse = req.timeout(HTTP_TIMEOUT_SEC, TimeUnit.SECONDS).send();
// Status needs to be 302 - Response is stored in Header
if (contentResponse.getStatus() == 302) {
final HttpFields fields = contentResponse.getHeaders();
final HttpField field = fields.getField(HttpHeader.LOCATION);
tokenFromUrl(field.getValue());
} else if (contentResponse.getStatus() == 200) {
final String stringContent = contentResponse.getContentAsString();
if (stringContent != null && !stringContent.isEmpty()) {
try {
final AuthResponse authResponse = Converter.getGson().fromJson(stringContent,
AuthResponse.class);
if (authResponse != null) {
token.setToken(authResponse.accessToken);
token.setType(authResponse.tokenType);
token.setExpiration(authResponse.expiresIn);
} else {
logger.debug("not an Authorization response: {}", stringContent);
}
} catch (JsonSyntaxException jse) {
logger.debug("Authorization response unparsable: {}", stringContent);
}
} else {
logger.debug("Authorization response has no content");
}
} else {
logger.debug("Authorization status {} reason {}", contentResponse.getStatus(),
contentResponse.getReason());
}
} catch (InterruptedException | ExecutionException | TimeoutException e) {
logger.debug("Authorization exception: {}", e.getMessage());
}
return true;
}
void tokenFromUrl(String encodedUrl) {
private MultiMap<String> getTokenBaseValues() {
MultiMap<String> baseValues = new MultiMap<String>();
baseValues.add(CLIENT_ID, Constants.EMPTY + BimmerConstants.CLIENT_ID.get(configuration.region));
baseValues.add(RESPONSE_TYPE, CODE);
baseValues.add(REDIRECT_URI, BimmerConstants.REDIRECT_URI_VALUE);
baseValues.add("state", Constants.EMPTY + BimmerConstants.STATE.get(configuration.region));
baseValues.add("nonce", "login_nonce");
baseValues.add(SCOPE, BimmerConstants.SCOPE_VALUES);
return baseValues;
}
private MultiMap<String> getTokenAuthValues() {
MultiMap<String> authValues = new MultiMap<String>();
authValues.add(GRANT_TYPE, "authorization_code");
authValues.add(USERNAME, configuration.userName);
authValues.add(PASSWORD, configuration.password);
return authValues;
}
private MultiMap<String> getTokenValues(String code) {
MultiMap<String> tokenValues = new MultiMap<String>();
tokenValues.put(CODE, code);
tokenValues.put("code_verifier", Constants.EMPTY + BimmerConstants.CODE_VERIFIER.get(configuration.region));
tokenValues.put(REDIRECT_URI, BimmerConstants.REDIRECT_URI_VALUE);
tokenValues.put(GRANT_TYPE, "authorization_code");
return tokenValues;
}
private String getAuthCode(String response) {
String[] keys = response.split("&");
for (int i = 0; i < keys.length; i++) {
if (keys[i].startsWith(AUTHORIZATION)) {
String authCode = keys[i].split("=")[1];
authCode = authCode.split("\"")[0];
return authCode;
}
}
return Constants.EMPTY;
}
public synchronized boolean updateLegacyToken() {
logger.debug("updateLegacyToken");
try {
/**
* The authorization with Jetty HttpClient doens't work anymore
* When calling Jetty with same headers and content a ConcurrentExcpetion is thrown
* So fallback legacy authorization will stay on java.net handling
*/
String authUri = "https://" + BimmerConstants.AUTH_SERVER_MAP.get(configuration.region)
+ BimmerConstants.OAUTH_ENDPOINT;
URL url = new URL(authUri);
HttpURLConnection.setFollowRedirects(false);
HttpURLConnection con = (HttpURLConnection) url.openConnection();
con.setRequestMethod("POST");
con.setRequestProperty(HttpHeader.CONTENT_TYPE.toString(), CONTENT_TYPE_URL_ENCODED);
con.setRequestProperty(HttpHeader.CONNECTION.toString(), KEEP_ALIVE);
con.setRequestProperty(HttpHeader.HOST.toString(),
BimmerConstants.API_SERVER_MAP.get(configuration.region));
con.setRequestProperty(HttpHeader.AUTHORIZATION.toString(),
BimmerConstants.LEGACY_AUTHORIZATION_VALUE_MAP.get(configuration.region));
con.setRequestProperty(CREDENTIALS, BimmerConstants.LEGACY_CREDENTIAL_VALUES);
con.setDoOutput(true);
OutputStream os = con.getOutputStream();
byte[] input = getAuthEncodedData().getBytes("utf-8");
os.write(input, 0, input.length);
BufferedReader br = new BufferedReader(new InputStreamReader(con.getInputStream(), "utf-8"));
StringBuilder response = new StringBuilder();
String responseLine = null;
while ((responseLine = br.readLine()) != null) {
response.append(responseLine.trim());
}
token.setMyBmwApiUsage(false);
return tokenFromUrl(con.getHeaderField(HttpHeader.LOCATION.toString()));
} catch (IOException e) {
logger.warn("{}", e.getMessage());
}
return false;
}
public boolean tokenFromUrl(String encodedUrl) {
final MultiMap<String> tokenMap = new MultiMap<String>();
UrlEncoded.decodeTo(encodedUrl, tokenMap, StandardCharsets.US_ASCII);
tokenMap.forEach((key, value) -> {
@ -320,5 +437,33 @@ public class ConnectedDriveProxy {
}
}
});
logger.info("Token valid? {}", token.isValid());
return token.isValid();
}
public static String codeFromUrl(String encodedUrl) {
final MultiMap<String> tokenMap = new MultiMap<String>();
UrlEncoded.decodeTo(encodedUrl, tokenMap, StandardCharsets.US_ASCII);
final StringBuilder codeFound = new StringBuilder();
tokenMap.forEach((key, value) -> {
if (value.size() > 0) {
String val = value.get(0);
if (key.endsWith(CODE)) {
codeFound.append(val.toString());
}
}
});
return codeFound.toString();
}
private String getAuthEncodedData() {
MultiMap<String> dataMap = new MultiMap<String>();
dataMap.add(CLIENT_ID, BimmerConstants.LEGACY_CLIENT_ID);
dataMap.add(RESPONSE_TYPE, TOKEN);
dataMap.add(REDIRECT_URI, BimmerConstants.LEGACY_REDIRECT_URI_VALUE);
dataMap.add(SCOPE, BimmerConstants.LEGACY_SCOPE_VALUES);
dataMap.add(USERNAME, configuration.userName);
dataMap.add(PASSWORD, configuration.password);
return UrlEncoded.encode(dataMap, Charset.defaultCharset(), false);
}
}

View File

@ -13,7 +13,9 @@
package org.openhab.binding.bmwconnecteddrive.internal.handler;
import static org.openhab.binding.bmwconnecteddrive.internal.ConnectedDriveConstants.*;
import static org.openhab.binding.bmwconnecteddrive.internal.utils.HTTPConstants.*;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
@ -21,6 +23,7 @@ import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.util.MultiMap;
import org.eclipse.jetty.util.UrlEncoded;
import org.openhab.binding.bmwconnecteddrive.internal.VehicleConfiguration;
import org.openhab.binding.bmwconnecteddrive.internal.dto.NetworkError;
import org.openhab.binding.bmwconnecteddrive.internal.dto.remote.ExecutionStatusContainer;
@ -45,6 +48,7 @@ public class RemoteServiceHandler implements StringResponseCallback {
private final Logger logger = LoggerFactory.getLogger(RemoteServiceHandler.class);
private static final String SERVICE_TYPE = "serviceType";
private static final String EVENT_ID = "eventId";
private static final String DATA = "data";
// after 6 retries the state update will give up
private static final int GIVEUP_COUNTER = 6;
@ -52,12 +56,16 @@ public class RemoteServiceHandler implements StringResponseCallback {
private final ConnectedDriveProxy proxy;
private final VehicleHandler handler;
private final String legacyServiceExecutionAPI;
private final String legacyServiceExecutionStateAPI;
private final String serviceExecutionAPI;
private final String serviceExecutionStateAPI;
private int counter = 0;
private Optional<ScheduledFuture<?>> stateJob = Optional.empty();
private Optional<String> serviceExecuting = Optional.empty();
private Optional<String> executingEventId = Optional.empty();
private boolean myBmwApiUsage = false;
public enum ExecutionState {
READY,
@ -69,21 +77,23 @@ public class RemoteServiceHandler implements StringResponseCallback {
}
public enum RemoteService {
LIGHT_FLASH(REMOTE_SERVICE_LIGHT_FLASH, "Flash Lights"),
VEHICLE_FINDER(REMOTE_SERVICE_VEHICLE_FINDER, "Vehicle Finder"),
DOOR_LOCK(REMOTE_SERVICE_DOOR_LOCK, "Door Lock"),
DOOR_UNLOCK(REMOTE_SERVICE_DOOR_UNLOCK, "Door Unlock"),
HORN_BLOW(REMOTE_SERVICE_HORN, "Horn Blow"),
CLIMATE_NOW(REMOTE_SERVICE_AIR_CONDITIONING, "Climate Control"),
CHARGE_NOW(REMOTE_SERVICE_CHARGE_NOW, "Start Charging"),
CHARGING_CONTROL(REMOTE_SERVICE_CHARGING_CONTROL, "Send Charging Profile");
LIGHT_FLASH(REMOTE_SERVICE_LIGHT_FLASH, "Flash Lights", "light-flash"),
VEHICLE_FINDER(REMOTE_SERVICE_VEHICLE_FINDER, "Vehicle Finder", "vehicle-finder"),
DOOR_LOCK(REMOTE_SERVICE_DOOR_LOCK, "Door Lock", "door-lock"),
DOOR_UNLOCK(REMOTE_SERVICE_DOOR_UNLOCK, "Door Unlock", "door-unlock"),
HORN_BLOW(REMOTE_SERVICE_HORN, "Horn Blow", "horn-blow"),
CLIMATE_NOW(REMOTE_SERVICE_AIR_CONDITIONING, "Climate Control", "air-conditioning"),
CHARGE_NOW(REMOTE_SERVICE_CHARGE_NOW, "Start Charging", "charge-now"),
CHARGING_CONTROL(REMOTE_SERVICE_CHARGING_CONTROL, "Send Charging Profile", "charging-control");
private final String command;
private final String label;
private final String remoteCommand;
RemoteService(final String command, final String label) {
RemoteService(final String command, final String label, final String remoteCommand) {
this.command = command;
this.label = label;
this.remoteCommand = remoteCommand;
}
public String getCommand() {
@ -93,30 +103,49 @@ public class RemoteServiceHandler implements StringResponseCallback {
public String getLabel() {
return label;
}
public String getRemoteCommand() {
return remoteCommand;
}
}
public RemoteServiceHandler(VehicleHandler vehicleHandler, ConnectedDriveProxy connectedDriveProxy) {
handler = vehicleHandler;
proxy = connectedDriveProxy;
final VehicleConfiguration config = handler.getConfiguration().get();
serviceExecutionAPI = proxy.baseUrl + config.vin + proxy.serviceExecutionAPI;
serviceExecutionStateAPI = proxy.baseUrl + config.vin + proxy.serviceExecutionStateAPI;
legacyServiceExecutionAPI = proxy.baseUrl + config.vin + proxy.serviceExecutionAPI;
legacyServiceExecutionStateAPI = proxy.baseUrl + config.vin + proxy.serviceExecutionStateAPI;
serviceExecutionAPI = proxy.remoteCommandUrl + config.vin + "/";
serviceExecutionStateAPI = proxy.remoteStatusUrl;
}
boolean execute(RemoteService service, String... data) {
synchronized (this) {
if (serviceExecuting.isPresent()) {
logger.debug("Execution rejected - {} still pending", serviceExecuting.get());
// only one service executing
return false;
}
serviceExecuting = Optional.of(service.name());
}
final MultiMap<String> dataMap = new MultiMap<String>();
dataMap.add(SERVICE_TYPE, service.name());
if (data.length > 0) {
dataMap.add(DATA, data[0]);
if (myBmwApiUsage) {
final MultiMap<String> dataMap = new MultiMap<String>();
if (data.length > 0) {
dataMap.add(DATA, data[0]);
proxy.post(serviceExecutionAPI + service.getRemoteCommand(), CONTENT_TYPE_JSON_ENCODED,
"{CHARGING_PROFILE:" + data[0] + "}", this);
} else {
proxy.post(serviceExecutionAPI + service.getRemoteCommand(), null, null, this);
}
} else {
final MultiMap<String> dataMap = new MultiMap<String>();
dataMap.add(SERVICE_TYPE, service.name());
if (data.length > 0) {
dataMap.add(DATA, data[0]);
}
proxy.post(legacyServiceExecutionAPI, CONTENT_TYPE_URL_ENCODED,
UrlEncoded.encode(dataMap, StandardCharsets.UTF_8, false), this);
}
proxy.post(serviceExecutionAPI, dataMap, this);
return true;
}
@ -130,9 +159,19 @@ public class RemoteServiceHandler implements StringResponseCallback {
handler.getData();
}
counter++;
final MultiMap<String> dataMap = new MultiMap<String>();
dataMap.add(SERVICE_TYPE, service);
proxy.get(serviceExecutionStateAPI, dataMap, this);
if (myBmwApiUsage) {
final MultiMap<String> dataMap = new MultiMap<String>();
dataMap.add(EVENT_ID, executingEventId.get());
final String encoded = dataMap == null || dataMap.isEmpty() ? null
: UrlEncoded.encode(dataMap, StandardCharsets.UTF_8, false);
proxy.post(serviceExecutionStateAPI + Constants.QUESTION + encoded, null, null, this);
} else {
final MultiMap<String> dataMap = new MultiMap<String>();
dataMap.add(SERVICE_TYPE, service);
proxy.get(legacyServiceExecutionStateAPI, CONTENT_TYPE_URL_ENCODED,
UrlEncoded.encode(dataMap, StandardCharsets.UTF_8, false), this);
}
}, () -> {
logger.warn("No Service executed to get state");
});
@ -145,15 +184,36 @@ public class RemoteServiceHandler implements StringResponseCallback {
if (result != null) {
try {
ExecutionStatusContainer esc = Converter.getGson().fromJson(result, ExecutionStatusContainer.class);
if (esc != null && esc.executionStatus != null) {
String status = esc.executionStatus.status;
synchronized (this) {
handler.updateRemoteExecutionStatus(serviceExecuting.orElse(null), status);
if (ExecutionState.EXECUTED.name().equals(status)) {
// refresh loop ends - update of status handled in the normal refreshInterval. Earlier
// update doesn't show better results!
reset();
return;
if (esc != null) {
if (esc.executionStatus != null) {
// handling of BMW ConnectedDrive updates
String status = esc.executionStatus.status;
if (status != null) {
synchronized (this) {
handler.updateRemoteExecutionStatus(serviceExecuting.orElse(null), status);
if (ExecutionState.EXECUTED.name().equals(status)) {
// refresh loop ends - update of status handled in the normal refreshInterval.
// Earlier
// update doesn't show better results!
reset();
return;
}
}
}
} else if (esc.eventId != null) {
// store event id for further MyBMW updates
executingEventId = Optional.of(esc.eventId);
} else if (esc.eventStatus != null) {
// update status for MyBMW API
synchronized (this) {
handler.updateRemoteExecutionStatus(serviceExecuting.orElse(null), esc.eventStatus);
if (ExecutionState.EXECUTED.name().equals(esc.eventStatus)) {
// refresh loop ends - update of status handled in the normal refreshInterval.
// Earlier
// update doesn't show better results!
reset();
return;
}
}
}
}
@ -183,6 +243,7 @@ public class RemoteServiceHandler implements StringResponseCallback {
private void reset() {
serviceExecuting = Optional.empty();
executingEventId = Optional.empty();
counter = 0;
}
@ -196,4 +257,8 @@ public class RemoteServiceHandler implements StringResponseCallback {
});
}
}
public void setMyBmwApiUsage(boolean b) {
myBmwApiUsage = b;
}
}

View File

@ -25,6 +25,15 @@ public class Token {
private String token = Constants.EMPTY;
private String tokenType = Constants.EMPTY;
private long expiration = 0;
private boolean myBmwApiUsage = false;
public boolean isMyBmwApiUsage() {
return myBmwApiUsage;
}
public void setMyBmwApiUsage(boolean myBmwAppUsage) {
this.myBmwApiUsage = myBmwAppUsage;
}
public String getBearerToken() {
return new StringBuilder(tokenType).append(Constants.SPACE).append(token).toString();
@ -38,18 +47,17 @@ public class Token {
this.expiration = System.currentTimeMillis() / 1000 + expiration;
}
/**
* @return true if Token expires in less than 1 second
*/
public boolean isExpired() {
return (expiration - System.currentTimeMillis() / 1000) < 1;
}
public void setType(String type) {
tokenType = type;
}
public boolean isValid() {
return (!token.equals(Constants.EMPTY) && !tokenType.equals(Constants.EMPTY) && expiration > 0);
return (!token.equals(Constants.EMPTY) && !tokenType.equals(Constants.EMPTY)
&& (this.expiration - System.currentTimeMillis() / 1000) > 1);
}
@Override
public String toString() {
return tokenType + token;
}
}

View File

@ -120,7 +120,7 @@ public abstract class VehicleChannelHandler extends BaseThingHandler {
}
protected void updateCheckControls(List<CCMMessage> ccl) {
if (ccl.size() == 0) {
if (ccl.isEmpty()) {
// No Check Control available - show not active
CCMMessage ccm = new CCMMessage();
ccm.ccmDescriptionLong = Constants.NO_ENTRIES;
@ -169,7 +169,7 @@ public abstract class VehicleChannelHandler extends BaseThingHandler {
protected void updateServices(List<CBSMessage> sl) {
// if list is empty add "undefined" element
if (sl.size() == 0) {
if (sl.isEmpty()) {
CBSMessage cbsm = new CBSMessage();
cbsm.cbsType = Constants.NO_ENTRIES;
cbsm.cbsDescription = Constants.NO_ENTRIES;
@ -225,7 +225,7 @@ public abstract class VehicleChannelHandler extends BaseThingHandler {
protected void updateDestinations(List<Destination> dl) {
// if list is empty add "undefined" element
if (dl.size() == 0) {
if (dl.isEmpty()) {
Destination dest = new Destination();
dest.city = Constants.NO_ENTRIES;
dest.lat = -1;
@ -417,6 +417,9 @@ public abstract class VehicleChannelHandler extends BaseThingHandler {
// last update Time
updateChannel(CHANNEL_GROUP_STATUS, LAST_UPDATE,
DateTimeType.valueOf(Converter.getLocalDateTime(VehicleStatusUtils.getUpdateTime(vStatus))));
// last update reason
updateChannel(CHANNEL_GROUP_STATUS, LAST_UPDATE_REASON,
StringType.valueOf(Converter.toTitleCase(vStatus.updateReason)));
Doors doorState = null;
try {
@ -442,7 +445,8 @@ public abstract class VehicleChannelHandler extends BaseThingHandler {
// Range values
// based on unit of length decide if range shall be reported in km or miles
float totalRange = 0;
double totalRange = 0;
double maxTotalRange = 0;
if (isElectric) {
totalRange += vStatus.remainingRangeElectric;
QuantityType<Length> qtElectricRange = QuantityType.valueOf(vStatus.remainingRangeElectric,
@ -454,9 +458,21 @@ public abstract class VehicleChannelHandler extends BaseThingHandler {
imperial ? Converter.getMiles(qtElectricRange) : qtElectricRange);
updateChannel(CHANNEL_GROUP_RANGE, RANGE_RADIUS_ELECTRIC,
imperial ? Converter.getMiles(qtElectricRadius) : qtElectricRadius);
maxTotalRange += vStatus.maxRangeElectric;
QuantityType<Length> qtMaxElectricRange = QuantityType.valueOf(vStatus.maxRangeElectric,
Constants.KILOMETRE_UNIT);
QuantityType<Length> qtMaxElectricRadius = QuantityType
.valueOf(Converter.guessRangeRadius(vStatus.maxRangeElectric), Constants.KILOMETRE_UNIT);
updateChannel(CHANNEL_GROUP_RANGE, RANGE_ELECTRIC_MAX,
imperial ? Converter.getMiles(qtMaxElectricRange) : qtMaxElectricRange);
updateChannel(CHANNEL_GROUP_RANGE, RANGE_RADIUS_ELECTRIC_MAX,
imperial ? Converter.getMiles(qtMaxElectricRadius) : qtMaxElectricRadius);
}
if (hasFuel) {
totalRange += vStatus.remainingRangeFuel;
maxTotalRange += vStatus.remainingRangeFuel;
QuantityType<Length> qtFuelRange = QuantityType.valueOf(vStatus.remainingRangeFuel,
Constants.KILOMETRE_UNIT);
QuantityType<Length> qtFuelRadius = QuantityType
@ -470,10 +486,17 @@ public abstract class VehicleChannelHandler extends BaseThingHandler {
QuantityType<Length> qtHybridRange = QuantityType.valueOf(totalRange, Constants.KILOMETRE_UNIT);
QuantityType<Length> qtHybridRadius = QuantityType.valueOf(Converter.guessRangeRadius(totalRange),
Constants.KILOMETRE_UNIT);
QuantityType<Length> qtMaxHybridRange = QuantityType.valueOf(maxTotalRange, Constants.KILOMETRE_UNIT);
QuantityType<Length> qtMaxHybridRadius = QuantityType.valueOf(Converter.guessRangeRadius(maxTotalRange),
Constants.KILOMETRE_UNIT);
updateChannel(CHANNEL_GROUP_RANGE, RANGE_HYBRID,
imperial ? Converter.getMiles(qtHybridRange) : qtHybridRange);
updateChannel(CHANNEL_GROUP_RANGE, RANGE_RADIUS_HYBRID,
imperial ? Converter.getMiles(qtHybridRadius) : qtHybridRadius);
updateChannel(CHANNEL_GROUP_RANGE, RANGE_HYBRID_MAX,
imperial ? Converter.getMiles(qtMaxHybridRange) : qtMaxHybridRange);
updateChannel(CHANNEL_GROUP_RANGE, RANGE_RADIUS_HYBRID_MAX,
imperial ? Converter.getMiles(qtMaxHybridRadius) : qtMaxHybridRadius);
}
updateChannel(CHANNEL_GROUP_RANGE, MILEAGE,
@ -488,6 +511,12 @@ public abstract class VehicleChannelHandler extends BaseThingHandler {
// Charge Values
if (isElectric) {
if (vStatus.connectionStatus != null) {
updateChannel(CHANNEL_GROUP_STATUS, PLUG_CONNECTION,
StringType.valueOf(Converter.toTitleCase(vStatus.connectionStatus)));
} else {
updateChannel(CHANNEL_GROUP_STATUS, PLUG_CONNECTION, UnDefType.NULL);
}
if (vStatus.chargingStatus != null) {
if (Constants.INVALID.equals(vStatus.chargingStatus)) {
updateChannel(CHANNEL_GROUP_STATUS, CHARGE_STATUS,

View File

@ -30,6 +30,7 @@ import org.openhab.binding.bmwconnecteddrive.internal.action.BMWConnectedDriveAc
import org.openhab.binding.bmwconnecteddrive.internal.dto.DestinationContainer;
import org.openhab.binding.bmwconnecteddrive.internal.dto.NetworkError;
import org.openhab.binding.bmwconnecteddrive.internal.dto.compat.VehicleAttributesContainer;
import org.openhab.binding.bmwconnecteddrive.internal.dto.navigation.NavigationContainer;
import org.openhab.binding.bmwconnecteddrive.internal.dto.statistics.AllTrips;
import org.openhab.binding.bmwconnecteddrive.internal.dto.statistics.AllTripsContainer;
import org.openhab.binding.bmwconnecteddrive.internal.dto.statistics.LastTrip;
@ -50,8 +51,10 @@ import org.openhab.core.io.net.http.HttpUtil;
import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.RawType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.library.unit.Units;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
@ -86,6 +89,7 @@ public class VehicleHandler extends VehicleChannelHandler {
private ImageProperties imageProperties = new ImageProperties();
VehicleStatusCallback vehicleStatusCallback = new VehicleStatusCallback();
StringResponseCallback oldVehicleStatusCallback = new LegacyVehicleStatusCallback();
StringResponseCallback navigationCallback = new NavigationStatusCallback();
StringResponseCallback lastTripCallback = new LastTripCallback();
StringResponseCallback allTripsCallback = new AllTripsCallback();
StringResponseCallback chargeProfileCallback = new ChargeProfilesCallback();
@ -275,6 +279,8 @@ public class VehicleHandler extends VehicleChannelHandler {
prox.requestVehcileStatus(config, vehicleStatusCallback);
}
addCallback(vehicleStatusCallback);
prox.requestLNavigation(config, navigationCallback);
addCallback(navigationCallback);
if (isSupported(Constants.STATISTICS)) {
prox.requestLastTrip(config, lastTripCallback);
prox.requestAllTrips(config, allTripsCallback);
@ -677,11 +683,31 @@ public class VehicleHandler extends VehicleChannelHandler {
@Override
public void onError(NetworkError error) {
logger.debug("{}", error.toString());
vehicleStatusCallback.onError(error);
}
}
public class NavigationStatusCallback implements StringResponseCallback {
@Override
public void onResponse(@Nullable String content) {
if (content != null) {
try {
NavigationContainer nav = Converter.getGson().fromJson(content, NavigationContainer.class);
updateChannel(CHANNEL_GROUP_RANGE, SOC_MAX, QuantityType.valueOf(nav.socmax, Units.KILOWATT_HOUR));
} catch (JsonSyntaxException jse) {
logger.debug("{}", jse.getMessage());
}
}
removeCallback(this);
}
@Override
public void onError(NetworkError error) {
logger.debug("{}", error.toString());
removeCallback(this);
}
}
private void handleChargeProfileCommand(ChannelUID channelUID, Command command) {
if (chargeProfileEdit.isEmpty()) {
chargeProfileEdit = getChargeProfileWrapper();

View File

@ -34,32 +34,68 @@ public class BimmerConstants {
public static final String REGION_ROW = "ROW";
// https://github.com/bimmerconnected/bimmer_connected/blob/master/bimmer_connected/country_selector.py
public static final String AUTH_SERVER_NORTH_AMERICA = "b2vapi.bmwgroup.us/gcdm";
public static final String AUTH_SERVER_CHINA = "b2vapi.bmwgroup.cn/gcdm";
public static final String AUTH_SERVER_ROW = "b2vapi.bmwgroup.com/gcdm";
public static final Map<String, String> AUTH_SERVER_MAP = Map.of(REGION_NORTH_AMERICA, AUTH_SERVER_NORTH_AMERICA,
REGION_CHINA, AUTH_SERVER_CHINA, REGION_ROW, AUTH_SERVER_ROW);
public static final String LEGACY_AUTH_SERVER_NORTH_AMERICA = "login.bmwusa.com/gcdm";
public static final String LEGACY_AUTH_SERVER_CHINA = "customer.bmwgroup.cn/gcdm";
public static final String LEGACY_AUTH_SERVER_ROW = "customer.bmwgroup.com/gcdm";
public static final Map<String, String> LEGACY_AUTH_SERVER_MAP = Map.of(REGION_NORTH_AMERICA,
LEGACY_AUTH_SERVER_NORTH_AMERICA, REGION_CHINA, LEGACY_AUTH_SERVER_CHINA, REGION_ROW,
LEGACY_AUTH_SERVER_ROW);
public static final String OAUTH_ENDPOINT = "/oauth/token";
public static final String OAUTH_ENDPOINT = "/oauth/authenticate";
public static final String TOKEN_ENDPOINT = "/oauth/token";
public static final String SERVER_NORTH_AMERICA = "b2vapi.bmwgroup.us";
public static final String SERVER_CHINA = "b2vapi.bmwgroup.cn:8592";
public static final String SERVER_ROW = "b2vapi.bmwgroup.com";
public static final Map<String, String> SERVER_MAP = Map.of(REGION_NORTH_AMERICA, SERVER_NORTH_AMERICA,
REGION_CHINA, SERVER_CHINA, REGION_ROW, SERVER_ROW);
public static final String API_SERVER_NORTH_AMERICA = "b2vapi.bmwgroup.us";
public static final String API_SERVER_CHINA = "b2vapi.bmwgroup.cn:8592";
public static final String API_SERVER_ROW = "b2vapi.bmwgroup.com";
public static final String EADRAX_SERVER_NORTH_AMERICA = "cocoapi.bmwgroup.us";
public static final String EADRAX_SERVER_ROW = "cocoapi.bmwgroup.com";
public static final String EADRAX_SERVER_CHINA = Constants.EMPTY;
public static final Map<String, String> EADRAX_SERVER_MAP = Map.of(REGION_NORTH_AMERICA,
EADRAX_SERVER_NORTH_AMERICA, REGION_CHINA, EADRAX_SERVER_CHINA, REGION_ROW, EADRAX_SERVER_ROW);
public static final Map<String, String> API_SERVER_MAP = Map.of(REGION_NORTH_AMERICA, API_SERVER_NORTH_AMERICA,
REGION_CHINA, API_SERVER_CHINA, REGION_ROW, API_SERVER_ROW);
// see https://github.com/bimmerconnected/bimmer_connected/pull/252/files
public static final Map<String, String> AUTHORIZATION_VALUE_MAP = Map.of(REGION_NORTH_AMERICA,
public static final Map<String, String> LEGACY_AUTHORIZATION_VALUE_MAP = Map.of(REGION_NORTH_AMERICA,
"Basic ZDc2NmI1MzctYTY1NC00Y2JkLWEzZGMtMGNhNTY3MmQ3ZjhkOjE1ZjY5N2Y2LWE1ZDUtNGNhZC05OWQ5LTNhMTViYzdmMzk3Mw==",
REGION_CHINA,
"Basic blF2NkNxdHhKdVhXUDc0eGYzQ0p3VUVQOjF6REh4NnVuNGNEanliTEVOTjNreWZ1bVgya0VZaWdXUGNRcGR2RFJwSUJrN3JPSg==",
REGION_ROW,
"Basic ZDc2NmI1MzctYTY1NC00Y2JkLWEzZGMtMGNhNTY3MmQ3ZjhkOjE1ZjY5N2Y2LWE1ZDUtNGNhZC05OWQ5LTNhMTViYzdmMzk3Mw==");
public static final String CREDENTIAL_VALUES = "nQv6CqtxJuXWP74xf3CJwUEP:1zDHx6un4cDjybLENN3kyfumX2kEYigWPcQpdvDRpIBk7rOJ";
public static final String REDIRECT_URI_VALUE = "https://www.bmw-connecteddrive.com/app/static/external-dispatch.html";
public static final String SCOPE_VALUES = "authenticate_user vehicle_data remote_services";
public static final String LEGACY_CREDENTIAL_VALUES = "nQv6CqtxJuXWP74xf3CJwUEP:1zDHx6un4cDjybLENN3kyfumX2kEYigWPcQpdvDRpIBk7rOJ";
public static final String REFERER_URL = "https://www.bmw-connecteddrive.de/app/index.html";
public static final String LEGACY_REDIRECT_URI_VALUE = "https://www.bmw-connecteddrive.com/app/static/external-dispatch.html";
public static final String LEGACY_SCOPE_VALUES = "authenticate_user vehicle_data remote_services";
public static final String LEGACY_CLIENT_ID = "dbf0a542-ebd1-4ff0-a9a7-55172fbfce35";
public static final String LEGACY_REFERER_URL = "https://www.bmw-connecteddrive.de/app/index.html";
public static final String AUTH_SERVER_NORTH_AMERICA = "login.bmwusa.com/gcdm";
public static final String AUTH_SERVER_CHINA = "customer.bmwgroup.cn/gcdm";
public static final String AUTH_SERVER_ROW = "customer.bmwgroup.com/gcdm";
public static final Map<String, String> AUTH_SERVER_MAP = Map.of(REGION_NORTH_AMERICA, AUTH_SERVER_NORTH_AMERICA,
REGION_CHINA, AUTH_SERVER_CHINA, REGION_ROW, AUTH_SERVER_ROW);
public static final Map<String, String> AUTHORIZATION_VALUE_MAP = Map.of(REGION_NORTH_AMERICA,
"Basic NTQzOTRhNGItYjZjMS00NWZlLWI3YjItOGZkM2FhOTI1M2FhOmQ5MmYzMWMwLWY1NzktNDRmNS1hNzdkLTk2NmY4ZjAwZTM1MQ==",
REGION_CHINA,
"Basic blF2NkNxdHhKdVhXUDc0eGYzQ0p3VUVQOjF6REh4NnVuNGNEanliTEVOTjNreWZ1bVgya0VZaWdXUGNRcGR2RFJwSUJrN3JPSg==",
REGION_ROW,
"Basic MzFjMzU3YTAtN2ExZC00NTkwLWFhOTktMzNiOTcyNDRkMDQ4OmMwZTMzOTNkLTcwYTItNGY2Zi05ZDNjLTg1MzBhZjY0ZDU1Mg==");
public static final Map<String, String> CODE_VERIFIER = Map.of(REGION_NORTH_AMERICA,
"BKDarcVUpgymBDCgHDH0PwwMfzycDxu1joeklioOhwXA", REGION_CHINA, Constants.EMPTY, REGION_ROW,
"7PsmfPS5MpaNt0jEcPpi-B7M7u0gs1Nzw6ex0Y9pa-0");
public static final Map<String, String> CLIENT_ID = Map.of(REGION_NORTH_AMERICA,
"54394a4b-b6c1-45fe-b7b2-8fd3aa9253aa", REGION_CHINA, Constants.EMPTY, REGION_ROW,
"31c357a0-7a1d-4590-aa99-33b97244d048");
public static final Map<String, String> STATE = Map.of(REGION_NORTH_AMERICA, "rgastJbZsMtup49-Lp0FMQ", REGION_CHINA,
Constants.EMPTY, REGION_ROW, "cEG9eLAIi6Nv-aaCAniziE_B6FPoobva3qr5gukilYw");
public static final String REDIRECT_URI_VALUE = "com.bmw.connected://oauth";
public static final String SCOPE_VALUES = "openid profile email offline_access smacc vehicle_data perseus dlm svds cesim vsapi remote_services fupo authenticate_user";
}

View File

@ -251,10 +251,14 @@ public class Converter {
vs.remainingRangeFuelMls = attributesMap.beRemainingRangeFuelMile;
vs.remainingFuel = attributesMap.remainingFuel;
vs.chargingLevelHv = attributesMap.chargingLevelHv;
vs.maxRangeElectric = attributesMap.beMaxRangeElectric;
vs.maxRangeElectricMls = attributesMap.beMaxRangeElectricMile;
vs.chargingStatus = attributesMap.chargingHVStatus;
vs.connectionStatus = attributesMap.connectorStatus;
vs.lastChargingEndReason = attributesMap.lastChargingEndReason;
vs.updateTime = attributesMap.updateTimeConverted;
vs.updateReason = attributesMap.lastUpdateReason;
Position p = new Position();
p.lat = attributesMap.gpsLat;

View File

@ -25,12 +25,15 @@ public class HTTPConstants {
public static final String AUTH_HTTP_CLIENT_NAME = "AuthHttpClient";
public static final String CONTENT_TYPE_URL_ENCODED = "application/x-www-form-urlencoded";
public static final String CONTENT_TYPE_JSON = "application/json";
public static final String CONTENT_TYPE_JSON_ENCODED = "application/json";
public static final String KEEP_ALIVE = "Keep-Alive";
public static final String CLIENT_ID = "client_id";
public static final String RESPONSE_TYPE = "response_type";
public static final String TOKEN = "token";
public static final String CODE = "code";
public static final String REDIRECT_URI = "redirect_uri";
public static final String AUTHORIZATION = "authorization";
public static final String GRANT_TYPE = "grant_type";
public static final String SCOPE = "scope";
public static final String CREDENTIALS = "Credentials";
public static final String USERNAME = "username";

View File

@ -24,5 +24,11 @@
</options>
<default>ROW</default>
</parameter>
<parameter name="preferMyBmw" type="boolean" required="false">
<label>Prefer MyBMW API</label>
<description>Prefer *MyBMW* API instead of *BMW Connected Drive*</description>
<advanced>true</advanced>
<default>false</default>
</parameter>
</config-description>
</config-description:config-descriptions>

View File

@ -5,12 +5,17 @@ binding.bmwconnecteddrive.description = Zeigt die Fahrzeugdaten
# bridge types
thing-type.bmwconnecteddrive.account.label = BMW ConnectedDrive Benutzerkonto
thing-type.bmwconnecteddrive.account.description = Zugriff auf das BMW ConnectedDrive Portal für einen Benutzer
thing-type.config.bmwconnecteddrive.account.userName = Benutzername für das ConnectedDrive Portal
thing-type.config.bmwconnecteddrive.account.password = Passwort für das ConnectedDrive Portal
thing-type.config.bmwconnecteddrive.account.region = Auswahl Ihrer Region zur Verbindung mit dem korrekten BMW Server
thing-type.config.bmwconnecteddrive.account.userName.label = Benutzername
thing-type.config.bmwconnecteddrive.account.userName.description = Benutzername für das ConnectedDrive Portal
thing-type.config.bmwconnecteddrive.account.password.label = Passwort
thing-type.config.bmwconnecteddrive.account.password.description = Passwort für das ConnectedDrive Portal
thing-type.config.bmwconnecteddrive.account.region.label = Region
thing-type.config.bmwconnecteddrive.account.region.description = Auswahl Ihrer Region zur Verbindung mit dem korrekten BMW Server
thing-type.config.bmwconnecteddrive.account.region.option.NORTH_AMERICA = Nordamerika
thing-type.config.bmwconnecteddrive.account.region.option.CHINA = China
thing-type.config.bmwconnecteddrive.account.region.option.ROW = Rest der Welt
thing-type.config.bmwconnecteddrive.account.preferMyBmw.label = Benutze MyBMW API
thing-type.config.bmwconnecteddrive.account.preferMyBmw.description = Benutzung des MyBMW API anstelle der BMW ConnectedDrive API
# thing types
thing-type.bmwconnecteddrive.bev_rex.label = Elektrofahrzeug mit REX
@ -143,8 +148,10 @@ channel-type.bmwconnecteddrive.next-service-date-channel.label = N
channel-type.bmwconnecteddrive.next-service-mileage-channel.label = Nächster Service in Kilometern
channel-type.bmwconnecteddrive.check-control-channel.label = Warnung Aktiv
channel-type.bmwconnecteddrive.charging-status-channel.label = Ladezustand
channel-type.bmwconnecteddrive.plug-connection-channel.label = Ladestecker
channel-type.bmwconnecteddrive.charging-remaining-channel.label = Verbleibende Ladezeit
channel-type.bmwconnecteddrive.last-update-channel.label = Letzte Aktualisierung
channel-type.bmwconnecteddrive.last-update-reason-channel.label = Grund der letzten Aktualisierung
channel-type.bmwconnecteddrive.driver-front-channel.label = Fahrertür
channel-type.bmwconnecteddrive.driver-rear-channel.label = Fahrertür Hinten
@ -161,13 +168,18 @@ channel-type.bmwconnecteddrive.sunroof-channel.label = Schiebedach
channel-type.bmwconnecteddrive.mileage-channel.label = Tachostand
channel-type.bmwconnecteddrive.range-hybrid-channel.label = Hybride Reichweite
channel-type.bmwconnecteddrive.range-hybrid-max-channel.label = Hybride Reichweite bei voller Ladung
channel-type.bmwconnecteddrive.range-electric-channel.label = Elektrische Reichweite
channel-type.bmwconnecteddrive.range-electric-max-channel.label = Elektrische Reichweite bei voller Ladung
channel-type.bmwconnecteddrive.soc-channel.label = Batterie Ladestand
channel-type.bmwconnecteddrive.soc-max-channel.label = Maximale Batteriekapazität
channel-type.bmwconnecteddrive.range-fuel-channel.label = Verbrenner Reichweite
channel-type.bmwconnecteddrive.remaining-fuel-channel.label = Tankstand
channel-type.bmwconnecteddrive.range-radius-electric-channel.label = Elektrischer Reichweiten-Radius
channel-type.bmwconnecteddrive.range-radius-electric-max-channel.label = Elektrischer Reichweiten-Radius bei voller Ladung
channel-type.bmwconnecteddrive.range-radius-fuel-channel.label = Verbrenner Reichweiten-Radius
channel-type.bmwconnecteddrive.range-radius-hybrid-channel.label = Hybrider Reichweiten-Radius
channel-type.bmwconnecteddrive.range-radius-hybrid-max-channel.label = Hybrider Reichweiten-Radius bei voller Ladung
channel-type.bmwconnecteddrive.service-name-channel.label = Service
channel-type.bmwconnecteddrive.service-details-channel.label = Service Details

View File

@ -9,8 +9,11 @@
<channels>
<channel id="mileage" typeId="mileage-channel"/>
<channel id="electric" typeId="range-electric-channel"/>
<channel id="soc" typeId="soc-channel"/>
<channel id="radius-electric" typeId="range-radius-electric-channel"/>
<channel id="electric-max" typeId="range-electric-max-channel"/>
<channel id="radius-electric-max" typeId="range-radius-electric-max-channel"/>
<channel id="soc" typeId="soc-channel"/>
<channel id="soc-max" typeId="soc-max-channel"/>
</channels>
</channel-group-type>
</thing:thing-descriptions>

View File

@ -13,9 +13,11 @@
<channel id="service-date" typeId="next-service-date-channel"/>
<channel id="service-mileage" typeId="next-service-mileage-channel"/>
<channel id="check-control" typeId="check-control-channel"/>
<channel id="plug-connection" typeId="plug-connection-channel"/>
<channel id="charge" typeId="charging-status-channel"/>
<channel id="remaining" typeId="charging-remaining-channel"/>
<channel id="last-update" typeId="last-update-channel"/>
<channel id="last-update-reason" typeId="last-update-reason-channel"/>
</channels>
</channel-group-type>
</thing:thing-descriptions>

View File

@ -9,12 +9,17 @@
<channels>
<channel id="mileage" typeId="mileage-channel"/>
<channel id="hybrid" typeId="range-hybrid-channel"/>
<channel id="hybrid-max" typeId="range-hybrid-max-channel"/>
<channel id="electric" typeId="range-electric-channel"/>
<channel id="soc" typeId="soc-channel"/>
<channel id="radius-electric" typeId="range-radius-electric-channel"/>
<channel id="electric-max" typeId="range-electric-max-channel"/>
<channel id="radius-electric-max" typeId="range-radius-electric-max-channel"/>
<channel id="fuel" typeId="range-fuel-channel"/>
<channel id="remaining-fuel" typeId="remaining-fuel-channel"/>
<channel id="radius-electric" typeId="range-radius-electric-channel"/>
<channel id="radius-hybrid" typeId="range-radius-hybrid-channel"/>
<channel id="radius-hybrid-max" typeId="range-radius-hybrid-max-channel"/>
<channel id="soc" typeId="soc-channel"/>
<channel id="soc-max" typeId="soc-max-channel"/>
</channels>
</channel-group-type>
</thing:thing-descriptions>

View File

@ -13,6 +13,11 @@
<label>Electric Range</label>
<state pattern="%d %unit%" readOnly="true"/>
</channel-type>
<channel-type id="range-electric-max-channel">
<item-type>Number:Length</item-type>
<label>Electric Range if Fully Charged</label>
<state pattern="%d %unit%" readOnly="true"/>
</channel-type>
<channel-type id="range-fuel-channel">
<item-type>Number:Length</item-type>
<label>Fuel Range</label>
@ -23,17 +28,32 @@
<label>Hybrid Range</label>
<state pattern="%d %unit%" readOnly="true"/>
</channel-type>
<channel-type id="range-hybrid-max-channel">
<item-type>Number:Length</item-type>
<label>Hybrid Range if Fully Charged</label>
<state pattern="%d %unit%" readOnly="true"/>
</channel-type>
<channel-type id="soc-channel">
<item-type>Number:Dimensionless</item-type>
<label>Battery Charge Level</label>
<state pattern="%d %unit%" readOnly="true"/>
</channel-type>
<channel-type id="soc-max-channel">
<item-type>Number:Power</item-type>
<label>Max Battery Capacity</label>
<state pattern="%.1f %unit%" readOnly="true"/>
</channel-type>
<channel-type id="remaining-fuel-channel">
<item-type>Number:Volume</item-type>
<label>Remaining Fuel</label>
<state pattern="%d %unit%" readOnly="true"/>
</channel-type>
<channel-type id="range-radius-electric-channel">
<item-type>Number:Length</item-type>
<label>Electric Range Radius if Fully Charged</label>
<state pattern="%.0f %unit%" readOnly="true"/>
</channel-type>
<channel-type id="range-radius-electric-max-channel">
<item-type>Number:Length</item-type>
<label>Electric Range Radius</label>
<state pattern="%.0f %unit%" readOnly="true"/>
@ -48,4 +68,9 @@
<label>Hybrid Range Radius</label>
<state pattern="%.0f %unit%" readOnly="true"/>
</channel-type>
<channel-type id="range-radius-hybrid-max-channel">
<item-type>Number:Length</item-type>
<label>Hybrid Range Radius if Fully Charged</label>
<state pattern="%.0f %unit%" readOnly="true"/>
</channel-type>
</thing:thing-descriptions>

View File

@ -38,6 +38,11 @@
<label>Charging Status</label>
<state readOnly="true"/>
</channel-type>
<channel-type id="plug-connection-channel">
<item-type>String</item-type>
<label>Plug Connection Status</label>
<state readOnly="true"/>
</channel-type>
<channel-type id="charging-remaining-channel">
<item-type>Number:Time</item-type>
<label>Remaining Charging Time</label>
@ -48,4 +53,8 @@
<label>Last Status Timestamp</label>
<state pattern="%1$tA, %1$td.%1$tm. %1$tH:%1$tM" readOnly="true"/>
</channel-type>
<channel-type id="last-update-reason-channel">
<item-type>String</item-type>
<label>Last Status Timestamp Reason</label>
</channel-type>
</thing:thing-descriptions>

View File

@ -14,6 +14,7 @@
<channel id="service-mileage" typeId="next-service-mileage-channel"/>
<channel id="check-control" typeId="check-control-channel"/>
<channel id="last-update" typeId="last-update-channel"/>
<channel id="last-update-reason" typeId="last-update-reason-channel"/>
</channels>
</channel-group-type>
</thing:thing-descriptions>

View File

@ -164,6 +164,20 @@ public class StatusWrapper {
ALLOWED_KM_ROUND_DEVIATION, "Mileage");
}
break;
case RANGE_ELECTRIC_MAX:
assertTrue(isElectric, "Is Eelctric");
assertTrue(state instanceof QuantityType);
qt = ((QuantityType) state);
if (imperial) {
assertEquals(ImperialUnits.MILE, qt.getUnit(), "Miles");
assertEquals(Converter.round(qt.floatValue()), Converter.round(vStatus.maxRangeElectricMls),
ALLOWED_MILE_CONVERSION_DEVIATION, "Mileage");
} else {
assertEquals(KILOMETRE, qt.getUnit(), "KM");
assertEquals(Converter.round(qt.floatValue()), Converter.round(vStatus.maxRangeElectric),
ALLOWED_KM_ROUND_DEVIATION, "Mileage");
}
break;
case RANGE_FUEL:
assertTrue(hasFuel, "Has Fuel");
if (!(state instanceof UnDefType)) {
@ -196,6 +210,22 @@ public class StatusWrapper {
ALLOWED_KM_ROUND_DEVIATION, "Mileage");
}
break;
case RANGE_HYBRID_MAX:
assertTrue(isHybrid, "Is Hybrid");
assertTrue(state instanceof QuantityType);
qt = ((QuantityType) state);
if (imperial) {
assertEquals(ImperialUnits.MILE, qt.getUnit(), "Miles");
assertEquals(Converter.round(qt.floatValue()),
Converter.round(vStatus.maxRangeElectricMls + vStatus.remainingRangeFuelMls),
ALLOWED_MILE_CONVERSION_DEVIATION, "Mileage");
} else {
assertEquals(KILOMETRE, qt.getUnit(), "KM");
assertEquals(Converter.round(qt.floatValue()),
Converter.round(vStatus.maxRangeElectric + vStatus.remainingRangeFuel),
ALLOWED_KM_ROUND_DEVIATION, "Mileage");
}
break;
case REMAINING_FUEL:
assertTrue(hasFuel, "Has Fuel");
assertTrue(state instanceof QuantityType);
@ -212,6 +242,14 @@ public class StatusWrapper {
assertEquals(Converter.round(vStatus.chargingLevelHv), Converter.round(qt.floatValue()), 0.01,
"Charge Level");
break;
case SOC_MAX:
assertTrue(isElectric, "Is Eelctric");
assertTrue(state instanceof QuantityType);
qt = ((QuantityType) state);
assertEquals(Units.KILOWATT_HOUR, qt.getUnit(), "kw/h");
assertEquals(Converter.round(vStatus.chargingLevelHv), Converter.round(qt.floatValue()), 0.01,
"SOC Max");
break;
case LOCK:
assertTrue(state instanceof StringType);
st = (StringType) state;
@ -274,6 +312,12 @@ public class StatusWrapper {
assertEquals(Units.MINUTE, qtt.getUnit(), "Minutes");
}
break;
case PLUG_CONNECTION:
assertTrue(state instanceof StringType);
st = (StringType) state;
wanted = StringType.valueOf(Converter.toTitleCase(vStatus.connectionStatus));
assertEquals(wanted.toString(), st.toString(), "Plug Connection State");
break;
case LAST_UPDATE:
assertTrue(state instanceof DateTimeType);
dtt = (DateTimeType) state;
@ -281,6 +325,12 @@ public class StatusWrapper {
.valueOf(Converter.getLocalDateTime(VehicleStatusUtils.getUpdateTime(vStatus)));
assertEquals(expected.toString(), dtt.toString(), "Last Update");
break;
case LAST_UPDATE_REASON:
assertTrue(state instanceof StringType);
st = (StringType) state;
wanted = StringType.valueOf(Converter.toTitleCase(vStatus.updateReason));
assertEquals(wanted.toString(), st.toString(), "Last Update");
break;
case GPS:
assertTrue(state instanceof PointType);
pt = (PointType) state;
@ -306,6 +356,18 @@ public class StatusWrapper {
"Range Radius Electric km");
}
break;
case RANGE_RADIUS_ELECTRIC_MAX:
assertTrue(state instanceof QuantityType);
assertTrue(isElectric);
qt = (QuantityType) state;
if (imperial) {
assertEquals(Converter.guessRangeRadius(vStatus.maxRangeElectricMls), qt.floatValue(), 1,
"Range Radius Electric mi");
} else {
assertEquals(Converter.guessRangeRadius(vStatus.maxRangeElectric), qt.floatValue(), 0.1,
"Range Radius Electric km");
}
break;
case RANGE_RADIUS_FUEL:
assertTrue(state instanceof QuantityType);
assertTrue(hasFuel);
@ -333,6 +395,19 @@ public class StatusWrapper {
qt.floatValue(), ALLOWED_KM_ROUND_DEVIATION, "Range Radius Hybrid km");
}
break;
case RANGE_RADIUS_HYBRID_MAX:
assertTrue(state instanceof QuantityType);
assertTrue(isHybrid);
qt = (QuantityType) state;
if (imperial) {
assertEquals(
Converter.guessRangeRadius(vStatus.maxRangeElectricMls + vStatus.remainingRangeFuelMls),
qt.floatValue(), ALLOWED_MILE_CONVERSION_DEVIATION, "Range Radius Hybrid Max mi");
} else {
assertEquals(Converter.guessRangeRadius(vStatus.maxRangeElectric + vStatus.remainingRangeFuel),
qt.floatValue(), ALLOWED_KM_ROUND_DEVIATION, "Range Radius Hybrid Max km");
}
break;
case DOOR_DRIVER_FRONT:
assertTrue(state instanceof StringType);
st = (StringType) state;

View File

@ -1,75 +0,0 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bmwconnecteddrive.internal.handler;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.*;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jetty.client.HttpClient;
import org.junit.jupiter.api.Test;
import org.openhab.binding.bmwconnecteddrive.internal.ConnectedDriveConfiguration;
import org.openhab.binding.bmwconnecteddrive.internal.utils.BimmerConstants;
import org.openhab.binding.bmwconnecteddrive.internal.utils.HTTPConstants;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link AuthTest} is responsible for handling commands, which are
* sent to one of the channels.
*
* @author Bernd Weymann - Initial contribution
*/
@NonNullByDefault
public class AuthTest {
private final Logger logger = LoggerFactory.getLogger(VehicleHandler.class);
@Test
public void testAuthServerMap() {
Map<String, String> authServers = BimmerConstants.AUTH_SERVER_MAP;
assertEquals(3, authServers.size(), "Number of Servers");
Map<String, String> api = BimmerConstants.SERVER_MAP;
assertEquals(3, api.size(), "Number of Servers");
}
@Test
public void testTokenDecoding() {
String headerValue = "https://www.bmw-connecteddrive.com/app/static/external-dispatch.html#access_token=SfXKgkEXeeFJkVqdD4XMmfUU224MRuyh&token_type=Bearer&expires_in=7199";
HttpClientFactory hcf = mock(HttpClientFactory.class);
when(hcf.getCommonHttpClient()).thenReturn(mock(HttpClient.class));
when(hcf.createHttpClient(HTTPConstants.AUTH_HTTP_CLIENT_NAME)).thenReturn(mock(HttpClient.class));
ConnectedDriveConfiguration config = new ConnectedDriveConfiguration();
config.region = BimmerConstants.REGION_ROW;
ConnectedDriveProxy dcp = new ConnectedDriveProxy(hcf, config);
dcp.tokenFromUrl(headerValue);
Token t = dcp.getToken();
assertEquals("Bearer SfXKgkEXeeFJkVqdD4XMmfUU224MRuyh", t.getBearerToken(), "Token");
}
public void testRealTokenUpdate() {
ConnectedDriveConfiguration config = new ConnectedDriveConfiguration();
config.region = BimmerConstants.REGION_ROW;
config.userName = "bla";
config.password = "blub";
HttpClientFactory hcf = mock(HttpClientFactory.class);
when(hcf.getCommonHttpClient()).thenReturn(mock(HttpClient.class));
when(hcf.createHttpClient(HTTPConstants.AUTH_HTTP_CLIENT_NAME)).thenReturn(mock(HttpClient.class));
ConnectedDriveProxy dcp = new ConnectedDriveProxy(hcf, config);
Token t = dcp.getToken();
logger.info("Token {}", t.getBearerToken());
logger.info("Expires {}", t.isExpired());
}
}

View File

@ -51,11 +51,11 @@ import org.slf4j.LoggerFactory;
public class VehicleTests {
private final Logger logger = LoggerFactory.getLogger(VehicleHandler.class);
private static final int STATUS_ELECTRIC = 9;
private static final int STATUS_CONV = 7;
private static final int RANGE_HYBRID = 9;
private static final int STATUS_ELECTRIC = 12;
private static final int STATUS_CONV = 8;
private static final int RANGE_HYBRID = 12;
private static final int RANGE_CONV = 4;
private static final int RANGE_ELECTRIC = 4;
private static final int RANGE_ELECTRIC = 5;
private static final int DOORS = 12;
private static final int CHECK_EMPTY = 3;
private static final int CHECK_AVAILABLE = 3;

View File

@ -0,0 +1,3 @@
{
"redirect_to": "redirect_uri=com.bmw.connected://oauth?client_id=31c357a0-7a1d-4590-aa99-33b97244d048&response_type=code&scope=openid profile email offline_access smacc vehicle_data perseus dlm svds cesim vsapi remote_services fupo authenticate_user&state=cEG9eLAIi6Nv-aaCAniziE_B6FPoobva3qr5gukilYw&authorization=XaTJvSCZePkXsQ3zLMbPyG2XpRo.*AAJTSQACMDIAAlNLABw2TmhkS25qQTQzc1lqUHdOYzNjanFZK1pkU2M9AAR0eXBlAANDVFMAAlMxAAIwMQ..*"
}

View File

@ -0,0 +1,8 @@
{
"token_type": "Bearer",
"access_token": "Iw-U6XS5zSeArLauaI-Ec6WFs88",
"refresh_token": "V3OAHd_foseD2nzTFV5_SsaMzGU",
"scope": "smacc vehicle_data perseus dlm svds openid profile vsapi remote_services authenticate_user cesim offline_access email fupo",
"expires_in": 3599,
"id_token": "eyJ0eXAiOiJKV1QiLCJraWQiOiIydGFUMUlOdTJFVE1QZFd4UWpIR3UyV3Q2T0E9IiwiYWxnIjoiUlMyNTYifQ.eyJhdF9oYXNoIjoiTGh1ZGZhT0pUOTBvYlNjYVhuN2RQUSIsInN1YiI6Im1hcmlrYS53ZXltYW5uQGdtYWlsLmNvbSIsImF1ZGl0VHJhY2tpbmdJZCI6ImJlNjcxM2M3LTY4NjgtNGU4My04NjIyLTg4ODMyNjg2MmU1OC0yODg4MDcwNDYiLCJnY2lkIjoiZDdjNTU5NjctNzQ5ZC00NjNiLTlhN2UtMTQ3ZGEwMmZiMzQ0IiwiaXNzIjoiaHR0cHM6Ly9jdXN0b21lci5ibXdncm91cC5jb20vYW0vb2F1dGgyIiwidG9rZW5OYW1lIjoiaWRfdG9rZW4iLCJhY3IiOiIwIiwiYXpwIjoiMzFjMzU3YTAtN2ExZC00NTkwLWFhOTktMzNiOTcyNDRkMDQ4IiwiYXV0aF90aW1lIjoxNjMwODYxOTE3LCJleHAiOjE2MzA4NjU1MTcsImlhdCI6MTYzMDg2MTkxNywiZW1haWwiOiJtYXJpa2Eud2V5bWFubkBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6Ik1BSUxfQUNUSVZFIiwiaG9tZV9tYXJrZXQiOiJERSIsImdpdmVuX25hbWUiOiJNYXJpa2EiLCJub25jZSI6ImxvZ2luX25vbmNlIiwiYXVkIjoiMzFjMzU3YTAtN2ExZC00NTkwLWFhOTktMzNiOTcyNDRkMDQ4IiwiY19oYXNoIjoiZVU5MjYyRTZiLUFlNzFfWHd2eWkwdyIsIm9yZy5mb3JnZXJvY2sub3BlbmlkY29ubmVjdC5vcHMiOiJwLWFnaGZMdlh1S29IcnNReTd1Z05xVEQyVEkiLCJzX2hhc2giOiJwcXpwa0pfS09mQ2htTTg4dFVLcExRIiwicmVhbG0iOiIvY3VzdG9tZXIiLCJzYWx1dGF0aW9uIjoiU0FMX01TIiwidG9rZW5UeXBlIjoiSldUVG9rZW4iLCJmYW1pbHlfbmFtZSI6IldleW1hbm4ifQ.LJxHE4BeUNh0YxhMIyF_LUa8hsAaGZ2VZot15vp_5SQWQvfGoC0KMgjuHawc-7CK01yDppR5awX2FwCsec3DemSUVvKeyjSg_of785dvCNsvcx9kvio-7nwet_6Acrv0bUlmpOtvN6GZpxE6NZi-ZkbEnw8KzrZvS8t6AgAv7dEeqPgVneZNu9XDSUM81QhS1X21FFGbyPD-9RnLt401Ft5WeKi4kN1ViCP7OkvpSOfRU3p4lv3fbsdoAoWU11Lp80TBYir8nJL-kykA076UK6qnks8zTFx1TlpPV0Nou5NgmqyLOprFaWk-9AG3gjhEYC2yLBMzQLHb8t2UYgAfUQ"
}