diff --git a/bundles/org.openhab.binding.bmwconnecteddrive/README.md b/bundles/org.openhab.binding.bmwconnecteddrive/README.md index 2ed6771af160b..8036f5618593d 100644 --- a/bundles/org.openhab.binding.bmwconnecteddrive/README.md +++ b/bundles/org.openhab.binding.bmwconnecteddrive/README.md @@ -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 diff --git a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/ConnectedDriveConfiguration.java b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/ConnectedDriveConfiguration.java index c082bff346edb..ca17261734053 100644 --- a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/ConnectedDriveConfiguration.java +++ b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/ConnectedDriveConfiguration.java @@ -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; } diff --git a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/ConnectedDriveConstants.java b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/ConnectedDriveConstants.java index 31e22c4a09337..3e95c2c22aedf 100644 --- a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/ConnectedDriveConstants.java +++ b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/ConnectedDriveConstants.java @@ -119,10 +119,12 @@ public enum ChargingPreference { 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 enum ChargingPreference { // 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"; diff --git a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/dto/auth/AuthResponse.java b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/dto/auth/AuthResponse.java index 7363d49890583..ab5ce5063d9f2 100644 --- a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/dto/auth/AuthResponse.java +++ b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/dto/auth/AuthResponse.java @@ -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; + } } diff --git a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/dto/navigation/NavigationContainer.java b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/dto/navigation/NavigationContainer.java new file mode 100644 index 0000000000000..8a78d9c953066 --- /dev/null +++ b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/dto/navigation/NavigationContainer.java @@ -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 +} diff --git a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/dto/remote/ExecutionStatusContainer.java b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/dto/remote/ExecutionStatusContainer.java index eca56f1dd8adf..458138dd26a90 100644 --- a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/dto/remote/ExecutionStatusContainer.java +++ b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/dto/remote/ExecutionStatusContainer.java @@ -19,4 +19,7 @@ */ public class ExecutionStatusContainer { public ExecutionStatus executionStatus; + public String eventId; + public String creationTime; + public String eventStatus; } diff --git a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/handler/ConnectedDriveBridgeHandler.java b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/handler/ConnectedDriveBridgeHandler.java index 5c7ac485d5bfe..9d35e53b18fd9 100644 --- a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/handler/ConnectedDriveBridgeHandler.java +++ b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/handler/ConnectedDriveBridgeHandler.java @@ -16,6 +16,7 @@ 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.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 void initialize() { 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 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 void requestVehicles() { 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 String getDiscoveryFingerprint() { }); return Converter.getGson().toJson(container); } + } else { + logger.debug("container.vehicles is null"); } } } catch (JsonParseException jpe) { @@ -172,7 +189,8 @@ public void onResponse(@Nullable String response) { } }); } - return Converter.getGson().toJson(container); + } else { + troubleshootFingerprint = Optional.of(Constants.EMPTY_JSON); } } catch (JsonParseException jpe) { logger.debug("Fingerprint parse exception {}", jpe.getMessage()); diff --git a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/handler/ConnectedDriveProxy.java b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/handler/ConnectedDriveProxy.java index af2164bf87b05..dd383a3a470dc 100644 --- a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/handler/ConnectedDriveProxy.java +++ b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/handler/ConnectedDriveProxy.java @@ -14,7 +14,16 @@ 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.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.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 @@ @NonNullByDefault public class ConnectedDriveProxy { private final Logger logger = LoggerFactory.getLogger(ConnectedDriveProxy.class); + private Optional 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 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 @@ private synchronized void call(final String url, final boolean post, final @Null 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 void onComplete(Result result) { }); } - public void get(String url, @Nullable MultiMap 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 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 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 void requestImage(VehicleConfiguration config, ImageProperties props, Byt 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 @@ RemoteServiceHandler getRemoteServiceHandler(VehicleHandler vehicleHandler) { * @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 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 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; } } + return true; + } - 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); + private MultiMap getTokenBaseValues() { + MultiMap baseValues = new MultiMap(); + 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; + } - final MultiMap dataMap = new MultiMap(); - 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)); + private MultiMap getTokenAuthValues() { + MultiMap authValues = new MultiMap(); + authValues.add(GRANT_TYPE, "authorization_code"); + authValues.add(USERNAME, configuration.userName); + authValues.add(PASSWORD, configuration.password); + return authValues; + } + + private MultiMap getTokenValues(String code) { + MultiMap tokenValues = new MultiMap(); + 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 { - 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()); + /** + * 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()); } - } catch (InterruptedException | ExecutionException | TimeoutException e) { - logger.debug("Authorization exception: {}", e.getMessage()); + token.setMyBmwApiUsage(false); + return tokenFromUrl(con.getHeaderField(HttpHeader.LOCATION.toString())); + } catch (IOException e) { + logger.warn("{}", e.getMessage()); } + return false; } - void tokenFromUrl(String encodedUrl) { + public boolean tokenFromUrl(String encodedUrl) { final MultiMap tokenMap = new MultiMap(); UrlEncoded.decodeTo(encodedUrl, tokenMap, StandardCharsets.US_ASCII); tokenMap.forEach((key, value) -> { @@ -320,5 +437,33 @@ void tokenFromUrl(String encodedUrl) { } } }); + logger.info("Token valid? {}", token.isValid()); + return token.isValid(); + } + + public static String codeFromUrl(String encodedUrl) { + final MultiMap tokenMap = new MultiMap(); + 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 dataMap = new MultiMap(); + 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); } } diff --git a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/handler/RemoteServiceHandler.java b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/handler/RemoteServiceHandler.java index fc1999d46aac0..60aa4aa432d9e 100644 --- a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/handler/RemoteServiceHandler.java +++ b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/handler/RemoteServiceHandler.java @@ -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 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> stateJob = Optional.empty(); private Optional serviceExecuting = Optional.empty(); + private Optional executingEventId = Optional.empty(); + private boolean myBmwApiUsage = false; public enum ExecutionState { READY, @@ -69,21 +77,23 @@ public enum ExecutionState { } 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 String getCommand() { 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 dataMap = new MultiMap(); - dataMap.add(SERVICE_TYPE, service.name()); - if (data.length > 0) { - dataMap.add(DATA, data[0]); + if (myBmwApiUsage) { + final MultiMap dataMap = new MultiMap(); + 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 dataMap = new MultiMap(); + 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 void getState() { handler.getData(); } counter++; - final MultiMap dataMap = new MultiMap(); - dataMap.add(SERVICE_TYPE, service); - proxy.get(serviceExecutionStateAPI, dataMap, this); + if (myBmwApiUsage) { + final MultiMap dataMap = new MultiMap(); + 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 dataMap = new MultiMap(); + 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 void onResponse(@Nullable String result) { 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 void onError(NetworkError error) { private void reset() { serviceExecuting = Optional.empty(); + executingEventId = Optional.empty(); counter = 0; } @@ -196,4 +257,8 @@ public void cancel() { }); } } + + public void setMyBmwApiUsage(boolean b) { + myBmwApiUsage = b; + } } diff --git a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/handler/Token.java b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/handler/Token.java index 22e42170410d0..f8645197757c1 100644 --- a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/handler/Token.java +++ b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/handler/Token.java @@ -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 void setExpiration(int expiration) { 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; } } diff --git a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/handler/VehicleChannelHandler.java b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/handler/VehicleChannelHandler.java index 6ff369b1714b7..d15a53402a5c5 100644 --- a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/handler/VehicleChannelHandler.java +++ b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/handler/VehicleChannelHandler.java @@ -120,7 +120,7 @@ protected void updateChannel(final String group, final String id, final State st } protected void updateCheckControls(List 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 @@ protected void selectCheckControl(int index) { protected void updateServices(List 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 @@ protected void selectService(int index) { protected void updateDestinations(List 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 @@ protected void updateVehicleStatus(VehicleStatus vStatus) { // 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 @@ protected void updateVehicleStatus(VehicleStatus vStatus) { // 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 qtElectricRange = QuantityType.valueOf(vStatus.remainingRangeElectric, @@ -454,9 +458,21 @@ protected void updateVehicleStatus(VehicleStatus vStatus) { imperial ? Converter.getMiles(qtElectricRange) : qtElectricRange); updateChannel(CHANNEL_GROUP_RANGE, RANGE_RADIUS_ELECTRIC, imperial ? Converter.getMiles(qtElectricRadius) : qtElectricRadius); + + maxTotalRange += vStatus.maxRangeElectric; + QuantityType qtMaxElectricRange = QuantityType.valueOf(vStatus.maxRangeElectric, + Constants.KILOMETRE_UNIT); + QuantityType 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 qtFuelRange = QuantityType.valueOf(vStatus.remainingRangeFuel, Constants.KILOMETRE_UNIT); QuantityType qtFuelRadius = QuantityType @@ -470,10 +486,17 @@ protected void updateVehicleStatus(VehicleStatus vStatus) { QuantityType qtHybridRange = QuantityType.valueOf(totalRange, Constants.KILOMETRE_UNIT); QuantityType qtHybridRadius = QuantityType.valueOf(Converter.guessRangeRadius(totalRange), Constants.KILOMETRE_UNIT); + QuantityType qtMaxHybridRange = QuantityType.valueOf(maxTotalRange, Constants.KILOMETRE_UNIT); + QuantityType 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 @@ protected void updateVehicleStatus(VehicleStatus vStatus) { // 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, diff --git a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/handler/VehicleHandler.java b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/handler/VehicleHandler.java index 035f0c1b6e74b..d9e9cb3571643 100644 --- a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/handler/VehicleHandler.java +++ b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/handler/VehicleHandler.java @@ -30,6 +30,7 @@ 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.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 void getData() { 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 void onResponse(@Nullable String content) { @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(); diff --git a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/utils/BimmerConstants.java b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/utils/BimmerConstants.java index 5c0ed6438faef..0d202922fce03 100644 --- a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/utils/BimmerConstants.java +++ b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/utils/BimmerConstants.java @@ -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 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 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/authenticate"; + public static final String TOKEN_ENDPOINT = "/oauth/token"; - public static final String OAUTH_ENDPOINT = "/oauth/token"; + 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 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 SERVER_MAP = Map.of(REGION_NORTH_AMERICA, SERVER_NORTH_AMERICA, - REGION_CHINA, SERVER_CHINA, REGION_ROW, SERVER_ROW); + 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 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 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 AUTHORIZATION_VALUE_MAP = Map.of(REGION_NORTH_AMERICA, + public static final Map 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 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 AUTHORIZATION_VALUE_MAP = Map.of(REGION_NORTH_AMERICA, + "Basic NTQzOTRhNGItYjZjMS00NWZlLWI3YjItOGZkM2FhOTI1M2FhOmQ5MmYzMWMwLWY1NzktNDRmNS1hNzdkLTk2NmY4ZjAwZTM1MQ==", + REGION_CHINA, + "Basic blF2NkNxdHhKdVhXUDc0eGYzQ0p3VUVQOjF6REh4NnVuNGNEanliTEVOTjNreWZ1bVgya0VZaWdXUGNRcGR2RFJwSUJrN3JPSg==", + REGION_ROW, + "Basic MzFjMzU3YTAtN2ExZC00NTkwLWFhOTktMzNiOTcyNDRkMDQ4OmMwZTMzOTNkLTcwYTItNGY2Zi05ZDNjLTg1MzBhZjY0ZDU1Mg=="); + + public static final Map CODE_VERIFIER = Map.of(REGION_NORTH_AMERICA, + "BKDarcVUpgymBDCgHDH0PwwMfzycDxu1joeklioOhwXA", REGION_CHINA, Constants.EMPTY, REGION_ROW, + "7PsmfPS5MpaNt0jEcPpi-B7M7u0gs1Nzw6ex0Y9pa-0"); + + public static final Map 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 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"; } diff --git a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/utils/Converter.java b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/utils/Converter.java index bc6e3fc75bef6..4e783d137568f 100644 --- a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/utils/Converter.java +++ b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/utils/Converter.java @@ -251,10 +251,14 @@ public static String transformLegacyStatus(@Nullable VehicleAttributesContainer 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; diff --git a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/utils/HTTPConstants.java b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/utils/HTTPConstants.java index 62f878691a238..8dd139fe0d102 100644 --- a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/utils/HTTPConstants.java +++ b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/java/org/openhab/binding/bmwconnecteddrive/internal/utils/HTTPConstants.java @@ -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"; diff --git a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/config/bridge-config.xml b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/config/bridge-config.xml index 66abc977fe925..3e61f843ea292 100644 --- a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/config/bridge-config.xml +++ b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/config/bridge-config.xml @@ -24,5 +24,11 @@ ROW + + + Prefer *MyBMW* API instead of *BMW Connected Drive* + true + false + diff --git a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/i18n/bmwconnecteddrive_de.properties b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/i18n/bmwconnecteddrive_de.properties index ab6509c50d7f1..1c203a9d03403 100644 --- a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/i18n/bmwconnecteddrive_de.properties +++ b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/i18n/bmwconnecteddrive_de.properties @@ -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 diff --git a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/thing/ev-range-channel-group.xml b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/thing/ev-range-channel-group.xml index c50b50633cda5..e73d81f78e6cf 100644 --- a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/thing/ev-range-channel-group.xml +++ b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/thing/ev-range-channel-group.xml @@ -9,8 +9,11 @@ - + + + + diff --git a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/thing/ev-vehicle-status-group.xml b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/thing/ev-vehicle-status-group.xml index 74fbaa8c79a68..cbc5f820c3be3 100644 --- a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/thing/ev-vehicle-status-group.xml +++ b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/thing/ev-vehicle-status-group.xml @@ -13,9 +13,11 @@ + + diff --git a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/thing/hybrid-range-channel-group.xml b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/thing/hybrid-range-channel-group.xml index a260df0b9f6b2..44edc9a03119e 100644 --- a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/thing/hybrid-range-channel-group.xml +++ b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/thing/hybrid-range-channel-group.xml @@ -9,12 +9,17 @@ + - + + + - + + + diff --git a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/thing/range-channel-types.xml b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/thing/range-channel-types.xml index b289d9bdeca85..6a2cfa730e87c 100644 --- a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/thing/range-channel-types.xml +++ b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/thing/range-channel-types.xml @@ -13,6 +13,11 @@ + + Number:Length + + + Number:Length @@ -23,17 +28,32 @@ + + Number:Length + + + Number:Dimensionless + + Number:Power + + + Number:Volume + Number:Length + + + + Number:Length @@ -48,4 +68,9 @@ + + Number:Length + + + diff --git a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/thing/vehicle-status-channel-types.xml b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/thing/vehicle-status-channel-types.xml index 1fa005aff175e..2f3a8a670cb81 100644 --- a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/thing/vehicle-status-channel-types.xml +++ b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/thing/vehicle-status-channel-types.xml @@ -38,6 +38,11 @@ + + String + + + Number:Time @@ -48,4 +53,8 @@ + + String + + diff --git a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/thing/vehicle-status-group.xml b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/thing/vehicle-status-group.xml index f8e99a6c407eb..36bcafd18a00b 100644 --- a/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/thing/vehicle-status-group.xml +++ b/bundles/org.openhab.binding.bmwconnecteddrive/src/main/resources/OH-INF/thing/vehicle-status-group.xml @@ -14,6 +14,7 @@ + diff --git a/bundles/org.openhab.binding.bmwconnecteddrive/src/test/java/org/openhab/binding/bmwconnecteddrive/internal/dto/StatusWrapper.java b/bundles/org.openhab.binding.bmwconnecteddrive/src/test/java/org/openhab/binding/bmwconnecteddrive/internal/dto/StatusWrapper.java index acd54c5478fb5..793307d9f1dc5 100644 --- a/bundles/org.openhab.binding.bmwconnecteddrive/src/test/java/org/openhab/binding/bmwconnecteddrive/internal/dto/StatusWrapper.java +++ b/bundles/org.openhab.binding.bmwconnecteddrive/src/test/java/org/openhab/binding/bmwconnecteddrive/internal/dto/StatusWrapper.java @@ -164,6 +164,20 @@ private void checkResult(ChannelUID channelUID, State state) { 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 @@ private void checkResult(ChannelUID channelUID, State state) { 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 @@ private void checkResult(ChannelUID channelUID, State state) { 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 @@ private void checkResult(ChannelUID channelUID, State state) { 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 @@ private void checkResult(ChannelUID channelUID, State state) { .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 @@ private void checkResult(ChannelUID channelUID, State state) { "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 @@ private void checkResult(ChannelUID channelUID, State state) { 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; diff --git a/bundles/org.openhab.binding.bmwconnecteddrive/src/test/java/org/openhab/binding/bmwconnecteddrive/internal/handler/AuthTest.java b/bundles/org.openhab.binding.bmwconnecteddrive/src/test/java/org/openhab/binding/bmwconnecteddrive/internal/handler/AuthTest.java deleted file mode 100644 index 3b34f58610c05..0000000000000 --- a/bundles/org.openhab.binding.bmwconnecteddrive/src/test/java/org/openhab/binding/bmwconnecteddrive/internal/handler/AuthTest.java +++ /dev/null @@ -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 authServers = BimmerConstants.AUTH_SERVER_MAP; - assertEquals(3, authServers.size(), "Number of Servers"); - Map 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()); - } -} diff --git a/bundles/org.openhab.binding.bmwconnecteddrive/src/test/java/org/openhab/binding/bmwconnecteddrive/internal/handler/VehicleTests.java b/bundles/org.openhab.binding.bmwconnecteddrive/src/test/java/org/openhab/binding/bmwconnecteddrive/internal/handler/VehicleTests.java index 91221b930d572..78b2d14a6b83f 100644 --- a/bundles/org.openhab.binding.bmwconnecteddrive/src/test/java/org/openhab/binding/bmwconnecteddrive/internal/handler/VehicleTests.java +++ b/bundles/org.openhab.binding.bmwconnecteddrive/src/test/java/org/openhab/binding/bmwconnecteddrive/internal/handler/VehicleTests.java @@ -51,11 +51,11 @@ 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; diff --git a/bundles/org.openhab.binding.bmwconnecteddrive/src/test/resources/responses/auth_response.json b/bundles/org.openhab.binding.bmwconnecteddrive/src/test/resources/responses/auth_response.json new file mode 100644 index 0000000000000..6d768df7251f0 --- /dev/null +++ b/bundles/org.openhab.binding.bmwconnecteddrive/src/test/resources/responses/auth_response.json @@ -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..*" +} \ No newline at end of file diff --git a/bundles/org.openhab.binding.bmwconnecteddrive/src/test/resources/responses/tokenResponse.json b/bundles/org.openhab.binding.bmwconnecteddrive/src/test/resources/responses/tokenResponse.json new file mode 100644 index 0000000000000..b173c9e7c6f26 --- /dev/null +++ b/bundles/org.openhab.binding.bmwconnecteddrive/src/test/resources/responses/tokenResponse.json @@ -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" +} \ No newline at end of file