From b14e30f1ac07a20a021858957a4cf097b802c271 Mon Sep 17 00:00:00 2001 From: Mark Herwege Date: Thu, 30 Jan 2025 10:25:03 +0100 Subject: [PATCH 1/5] authentication Signed-off-by: Mark Herwege --- bundles/org.openhab.binding.mybmw/README.md | 18 +- .../internal/MyBMWBridgeConfiguration.java | 34 +- .../mybmw/internal/MyBMWConstants.java | 17 + .../mybmw/internal/MyBMWHandlerFactory.java | 17 +- .../console/MyBMWCommandExtension.java | 8 +- .../internal/discovery/VehicleDiscovery.java | 20 +- ...e.java => OAuthSettingsQueryResponse.java} | 14 +- .../internal/handler/MyBMWBridgeHandler.java | 180 +++++--- .../internal/handler/VehicleHandler.java | 103 +---- .../handler/auth/MyBMWAuthServlet.java | 110 +++++ .../handler/auth/MyBMWTokenController.java | 347 +++++++--------- .../mybmw/internal/handler/auth/Token.java | 86 ---- .../backend/JsonStringDeserializer.java | 7 +- .../handler/backend/MyBMWFileProxy.java | 35 +- .../handler/backend/MyBMWHttpProxy.java | 103 ++++- .../internal/handler/backend/MyBMWProxy.java | 5 +- .../handler/backend/NetworkException.java | 1 + .../backend/ResponseErrorContainer.java | 32 ++ .../mybmw/internal/utils/BimmerConstants.java | 1 - .../mybmw/internal/utils/HTTPConstants.java | 1 + .../utils/MyBMWConfigurationChecker.java | 34 -- .../resources/OH-INF/config/bridge-config.xml | 22 +- .../resources/OH-INF/i18n/mybmw.properties | 8 + .../resources/captcha/north_america_form.html | 50 +++ .../resources/captcha/rest_of_world_form.html | 50 +++ .../internal/handler/VehicleHandlerTest.java | 19 +- .../mybmw/internal/handler/auth/AuthTest.java | 393 ------------------ .../handler/backend/MyBMWHttpProxyTest.java | 59 +-- .../handler/backend/MyBMWProxyBackendIT.java | 223 ++++++---- .../mybmw/internal/util/FileReader.java | 16 +- .../utils/MyBMWConfigurationCheckerTest.java | 50 --- 31 files changed, 988 insertions(+), 1075 deletions(-) rename bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/dto/auth/{AuthQueryResponse.java => OAuthSettingsQueryResponse.java} (87%) create mode 100644 bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/auth/MyBMWAuthServlet.java delete mode 100644 bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/auth/Token.java create mode 100644 bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/backend/ResponseErrorContainer.java delete mode 100644 bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/utils/MyBMWConfigurationChecker.java create mode 100644 bundles/org.openhab.binding.mybmw/src/main/resources/captcha/north_america_form.html create mode 100644 bundles/org.openhab.binding.mybmw/src/main/resources/captcha/rest_of_world_form.html delete mode 100644 bundles/org.openhab.binding.mybmw/src/test/java/org/openhab/binding/mybmw/internal/handler/auth/AuthTest.java delete mode 100644 bundles/org.openhab.binding.mybmw/src/test/java/org/openhab/binding/mybmw/internal/utils/MyBMWConfigurationCheckerTest.java diff --git a/bundles/org.openhab.binding.mybmw/README.md b/bundles/org.openhab.binding.mybmw/README.md index 3f9ebf0c58be3..21f24ec3a620d 100644 --- a/bundles/org.openhab.binding.mybmw/README.md +++ b/bundles/org.openhab.binding.mybmw/README.md @@ -82,7 +82,6 @@ Properties will be attached to predefined vehicles if the VIN is matching. |-----------------|---------|--------------------------------------------------------------------------------------------------------| | userName | text | MyBMW Username | | password | text | MyBMW Password | -| hcaptchatoken | text | HCaptcha-Token for initial login (see https://bimmer-connected.readthedocs.io/en/latest/captcha.html) | | region | text | Select region in order to connect to the appropriate BMW server. | The region Configuration has 3 different options @@ -91,17 +90,28 @@ The region Configuration has 3 different options - _CHINA_ - _ROW_ (Rest of World) +At first initialization, follow the online instructions for login into the BMW API. + #### Advanced Configuration -| Parameter | Type | Description | -|-----------------|---------|---------------------------------------------------------| -| language | text | Channel data can be returned in the desired language | +| Parameter | Type | Description | +|-----------------|---------|--------------------------------------------------------------------------------------------------------| +| language | text | Channel data can be returned in the desired language | +| hcaptchatoken | text | HCaptcha-Token for initial login (see https://bimmer-connected.readthedocs.io/en/stable/captcha.html) | +| callbackIP | text | IP address for openHAB callback URL, defaults to IP of openHAB host | +| callbackPort | integer | Port Number for openHAB callback URL, default 8090 | Language is predefined as _AUTODETECT_. Some textual descriptions, date and times are delivered based on your local language. You can overwrite this setting with lowercase 2-letter [language code reagrding ISO 639](https://www.oracle.com/java/technologies/javase/jdk8-jre8-suported-locales.html) So if want your UI in english language place _en_ as desired language. +The initial login to the BMW API requires a Captcha Token. +At first configuration, you can set the Captcha Token as a configuration parameter manually. + +To set the Captcha Token online, a webpage is presented and a callback to the bridge is created temporarily on the hosts IP address and a default port. +If the port is already in use, or you have a complex network setup, you may have to override the defaults provided. + ### Thing Configuration Same configuration is needed for all things diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/MyBMWBridgeConfiguration.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/MyBMWBridgeConfiguration.java index 5d4a562c35f8c..1081be49d1b43 100644 --- a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/MyBMWBridgeConfiguration.java +++ b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/MyBMWBridgeConfiguration.java @@ -20,6 +20,7 @@ * * @author Bernd Weymann - Initial contribution * @author Martin Grassl - renamed and added hcaptchastring + * @author Mark Herwege - added authorisation servlet */ @NonNullByDefault public class MyBMWBridgeConfiguration { @@ -49,6 +50,16 @@ public class MyBMWBridgeConfiguration { */ private String hcaptchatoken = Constants.EMPTY; + /** + * the callback IP address for the authorisation servlet + */ + private String callbackIP = Constants.EMPTY; + + /** + * the callback port for the authorisation servlet + */ + private int callbackPort = 8090; + public String getRegion() { return region; } @@ -81,17 +92,34 @@ public void setLanguage(String language) { this.language = language; } - public String getHcaptchatoken() { + public String getHCaptchaToken() { return hcaptchatoken; } - public void setHcaptchatoken(String hcaptchatoken) { + public void setHCaptchaToken(String hcaptchatoken) { this.hcaptchatoken = hcaptchatoken; } + public String getCallbackIP() { + return Constants.EMPTY.equals(callbackIP) ? "" : callbackIP; + } + + public void setCallbackIP(String callbackIP) { + this.callbackIP = callbackIP; + } + + public int getCallbackPort() { + return callbackPort; + } + + public void setCallbackPort(int callbackPort) { + this.callbackPort = callbackPort; + } + @Override public String toString() { return "MyBMWBridgeConfiguration [region=" + region + ", userName=" + userName + ", password=" + password - + ", language=" + language + ", hcaptchatoken=" + hcaptchatoken + "]"; + + ", language=" + language + ", hcaptchatoken=" + hcaptchatoken + ", callbackAddress=" + callbackIP + + ":" + callbackPort + "]"; } } diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/MyBMWConstants.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/MyBMWConstants.java index 5933a7bcfe8d2..57d3cda00b16e 100644 --- a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/MyBMWConstants.java +++ b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/MyBMWConstants.java @@ -12,6 +12,7 @@ */ package org.openhab.binding.mybmw.internal; +import java.util.Map; import java.util.Set; import org.eclipse.jdt.annotation.NonNullByDefault; @@ -60,6 +61,22 @@ public interface MyBMWConstants { static final int DEFAULT_REFRESH_INTERVAL_MINUTES = 60; + static final String BASE_PATH = "/" + BINDING_ID + "/"; + + static final String HTML_SOURCE = "captcha/"; + static final String NORTH_AMERICA = "NORTH_AMERICA"; + static final String ROW = "ROW"; + static final Map CAPTCHA_HTML = Map.of(NORTH_AMERICA, HTML_SOURCE + "north_america_form.html", ROW, + HTML_SOURCE + "rest_of_world_form.html"); + + static final String STATUS_AUTH_NEEDED = "@text/mybmw.status.authorization-needed"; + static final String STATUS_USER_DETAILS_MISSING = "@text/mybmw.status.user-details-missing"; + static final String STATUS_REGION_MISSING = "@text/mybmw.status.region-missing"; + static final String STATUS_IP_MISSING = "@text/mybmw.status.ip-missing"; + static final String STATUS_VEHICLE_RETRIEVAL_ERROR = "@text/mybmw.status.vehicle-retrieval-error"; + static final String STATUS_NETWORK_ERROR = "@text/mybmw.status.network-error"; + static final String STATUS_QUOTA_ERROR = "@text/mybmw.status.quota-error"; + // See constants from bimmer-connected // https://github.com/bimmerconnected/bimmer_connected/blob/master/bimmer_connected/vehicle.py enum VehicleType { diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/MyBMWHandlerFactory.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/MyBMWHandlerFactory.java index f873caeed524c..d55f11ab95340 100644 --- a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/MyBMWHandlerFactory.java +++ b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/MyBMWHandlerFactory.java @@ -12,18 +12,19 @@ */ package org.openhab.binding.mybmw.internal; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.SUPPORTED_THING_SET; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.THING_TYPE_CONNECTED_DRIVE_ACCOUNT; +import static org.openhab.binding.mybmw.internal.MyBMWConstants.*; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.mybmw.internal.handler.MyBMWBridgeHandler; import org.openhab.binding.mybmw.internal.handler.MyBMWCommandOptionProvider; import org.openhab.binding.mybmw.internal.handler.VehicleHandler; +import org.openhab.core.auth.client.oauth2.OAuthFactory; import org.openhab.core.i18n.LocaleProvider; import org.openhab.core.i18n.LocationProvider; import org.openhab.core.i18n.TimeZoneProvider; import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.net.NetworkAddressService; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingTypeUID; @@ -33,6 +34,7 @@ import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Reference; +import org.osgi.service.http.HttpService; /** * The {@link MyBMWHandlerFactory} is responsible for creating things and thing @@ -45,18 +47,26 @@ @Component(configurationPid = "binding.mybmw", service = ThingHandlerFactory.class) public class MyBMWHandlerFactory extends BaseThingHandlerFactory { private final HttpClientFactory httpClientFactory; + private final OAuthFactory oAuthFactory; private final MyBMWCommandOptionProvider commandOptionProvider; + private final NetworkAddressService networkAddressService; + private final HttpService httpService; private final LocationProvider locationProvider; private final TimeZoneProvider timeZoneProvider; private final LocaleProvider localeProvider; @Activate public MyBMWHandlerFactory(final @Reference HttpClientFactory httpClientFactory, + final @Reference OAuthFactory oAuthFactory, final @Reference MyBMWCommandOptionProvider commandOptionProvider, + final @Reference NetworkAddressService networkAddressService, final @Reference HttpService httpService, final @Reference LocaleProvider localeProvider, final @Reference LocationProvider locationProvider, final @Reference TimeZoneProvider timeZoneProvider) { this.httpClientFactory = httpClientFactory; + this.oAuthFactory = oAuthFactory; this.commandOptionProvider = commandOptionProvider; + this.networkAddressService = networkAddressService; + this.httpService = httpService; this.locationProvider = locationProvider; this.timeZoneProvider = timeZoneProvider; this.localeProvider = localeProvider; @@ -71,7 +81,8 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { protected @Nullable ThingHandler createHandler(Thing thing) { ThingTypeUID thingTypeUID = thing.getThingTypeUID(); if (THING_TYPE_CONNECTED_DRIVE_ACCOUNT.equals(thingTypeUID)) { - return new MyBMWBridgeHandler((Bridge) thing, httpClientFactory, localeProvider); + return new MyBMWBridgeHandler((Bridge) thing, httpClientFactory, oAuthFactory, httpService, + networkAddressService, localeProvider); } else if (SUPPORTED_THING_SET.contains(thingTypeUID)) { return new VehicleHandler(thing, commandOptionProvider, locationProvider, timeZoneProvider, thingTypeUID.getId()); diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/console/MyBMWCommandExtension.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/console/MyBMWCommandExtension.java index e93d706926b50..2a3ec0cc3dc2b 100644 --- a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/console/MyBMWCommandExtension.java +++ b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/console/MyBMWCommandExtension.java @@ -12,8 +12,7 @@ */ package org.openhab.binding.mybmw.internal.console; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.BINDING_ID; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.THING_TYPE_CONNECTED_DRIVE_ACCOUNT; +import static org.openhab.binding.mybmw.internal.MyBMWConstants.*; import java.io.File; import java.io.FileOutputStream; @@ -253,7 +252,7 @@ private void writeJsonToFile(String pathString, String filename, String json) th // ensure full path exists File file = new File(path); - file.getParentFile().mkdirs(); + Objects.requireNonNull(file.getParentFile()).mkdirs(); final byte[] contents = json.getBytes(StandardCharsets.UTF_8); Files.write(file.toPath(), contents); @@ -313,8 +312,7 @@ public boolean complete(String[] args, int cursorArgumentIndex, int cursorPositi .filter(t -> THING_TYPE_CONNECTED_DRIVE_ACCOUNT.equals(t.getThingTypeUID()) && args[1].equals(t.getConfiguration().get("userName"))) .map(t -> t.getHandler()).findAny().get(); - List vehicles = handler != null ? handler.getMyBmwProxy().get().requestVehiclesBase() - : List.of(); + List vehicles = handler.getMyBmwProxy().get().requestVehiclesBase(); return new StringsCompleter( vehicles.stream().map(v -> v.getVin()).filter(Objects::nonNull).collect(Collectors.toList()), false).complete(args, cursorArgumentIndex, cursorPosition, candidates); diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/discovery/VehicleDiscovery.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/discovery/VehicleDiscovery.java index d2f721d53c65a..26fc7d4224091 100644 --- a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/discovery/VehicleDiscovery.java +++ b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/discovery/VehicleDiscovery.java @@ -83,29 +83,33 @@ public void discoverVehicles() { try { return prox.requestVehicles(); } catch (NetworkException e) { - throw new IllegalStateException("vehicles could not be discovered: " + e.getMessage(), e); + throw new IllegalStateException(e); } }); vehicleList.ifPresentOrElse(vehicles -> { - if (vehicles.size() > 0) { + if (!vehicles.isEmpty()) { thingHandler.vehicleDiscoverySuccess(); processVehicles(vehicles); } else { - logger.warn("no vehicle found, maybe because of network error"); - thingHandler.vehicleDiscoveryError(); + thingHandler.vehicleDiscoveryError(MyBMWConstants.STATUS_NETWORK_ERROR); } - }, () -> thingHandler.vehicleDiscoveryError()); + }, () -> thingHandler.vehicleDiscoveryError(Constants.EMPTY)); } catch (IllegalStateException ex) { - thingHandler.vehicleDiscoveryError(); + NetworkException ne = (NetworkException) ex.getCause(); + if (ne != null && (ne.getStatus() == 403 || ne.getStatus() == 429)) { + thingHandler.vehicleQuotaDiscoveryError(myBMWProxy.get().getNextQuota()); + } else { + thingHandler.vehicleDiscoveryError(MyBMWConstants.STATUS_NETWORK_ERROR); + } } } /** * this method is called by the bridgeHandler if the list of vehicles was retrieved successfully - * + * * it iterates through the list of existing things and checks if the vehicles found via the API * call are already known to OH. If not, it creates a new thing and puts it into the inbox - * + * * @param vehicleList */ private void processVehicles(List vehicleList) { diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/dto/auth/AuthQueryResponse.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/dto/auth/OAuthSettingsQueryResponse.java similarity index 87% rename from bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/dto/auth/AuthQueryResponse.java rename to bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/dto/auth/OAuthSettingsQueryResponse.java index c56e7c157cc50..30799537e617a 100644 --- a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/dto/auth/AuthQueryResponse.java +++ b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/dto/auth/OAuthSettingsQueryResponse.java @@ -14,13 +14,15 @@ import java.util.List; +import org.openhab.binding.mybmw.internal.utils.Constants; + /** - * The {@link AuthQueryResponse} Data Transfer Object + * The {@link OAuthSettingsQueryResponse} Data Transfer Object * * @author Bernd Weymann - Initial contribution * @author Martin Grassl - add toString for debugging */ -public class AuthQueryResponse { +public class OAuthSettingsQueryResponse { public String clientName;// ": "mybmwapp", public String clientSecret;// ": "c0e3393d-70a2-4f6f-9d3c-8530af64d552", public String clientId;// ": "31c357a0-7a1d-4590-aa99-33b97244d048", @@ -31,7 +33,7 @@ public class AuthQueryResponse { public String country;// ": "US", public String authorizationEndpoint;// ": "https://customer.bmwgroup.com/oneid/login", public String tokenEndpoint;// ": "https://customer.bmwgroup.com/gcdm/oauth/token", - public List scopes;// ;": [ + private List scopes;// ;": [ // "openid", // "profile", // "email", @@ -50,10 +52,14 @@ public class AuthQueryResponse { public List promptValues; // ": ["login"] /* * (non-Javadoc) - * + * * @see java.lang.Object#toString() */ + public String scopes() { + return String.join(Constants.SPACE, scopes); + } + @Override public String toString() { return "AuthQueryResponse [clientName=" + clientName + ", clientSecret=" + clientSecret + ", clientId=" diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/MyBMWBridgeHandler.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/MyBMWBridgeHandler.java index e4e9ffe1b899a..ea7c329af5fb1 100644 --- a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/MyBMWBridgeHandler.java +++ b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/MyBMWBridgeHandler.java @@ -12,22 +12,34 @@ */ package org.openhab.binding.mybmw.internal.handler; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; import java.util.Collection; import java.util.List; import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.client.HttpClient; import org.openhab.binding.mybmw.internal.MyBMWBridgeConfiguration; +import org.openhab.binding.mybmw.internal.MyBMWConstants; import org.openhab.binding.mybmw.internal.discovery.VehicleDiscovery; +import org.openhab.binding.mybmw.internal.handler.auth.MyBMWAuthServlet; import org.openhab.binding.mybmw.internal.handler.backend.MyBMWFileProxy; import org.openhab.binding.mybmw.internal.handler.backend.MyBMWHttpProxy; import org.openhab.binding.mybmw.internal.handler.backend.MyBMWProxy; import org.openhab.binding.mybmw.internal.utils.Constants; -import org.openhab.binding.mybmw.internal.utils.MyBMWConfigurationChecker; +import org.openhab.core.auth.client.oauth2.OAuthFactory; +import org.openhab.core.config.core.Configuration; import org.openhab.core.i18n.LocaleProvider; import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.net.NetUtil; +import org.openhab.core.net.NetworkAddressService; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.ThingStatus; @@ -35,6 +47,7 @@ import org.openhab.core.thing.binding.BaseBridgeHandler; import org.openhab.core.thing.binding.ThingHandlerService; import org.openhab.core.types.Command; +import org.osgi.service.http.HttpService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -55,16 +68,27 @@ public class MyBMWBridgeHandler extends BaseBridgeHandler { private final Logger logger = LoggerFactory.getLogger(MyBMWBridgeHandler.class); - private HttpClientFactory httpClientFactory; + private final HttpClient httpClient; + private final OAuthFactory oAuthFactory; + private final HttpService httpService; + private final NetworkAddressService networkAddressService; private Optional myBmwProxy = Optional.empty(); private Optional> initializerJob = Optional.empty(); private Optional vehicleDiscovery = Optional.empty(); private LocaleProvider localeProvider; - private Optional bmwBridgeConfiguration = Optional.empty(); - public MyBMWBridgeHandler(Bridge bridge, HttpClientFactory hcf, LocaleProvider localeProvider) { + private CompletableFuture isInitialized = new CompletableFuture<>(); + + private Optional authServlet = Optional.empty(); + private boolean tokenInitError = false; + + public MyBMWBridgeHandler(Bridge bridge, HttpClientFactory httpClientFactory, OAuthFactory oAuthFactory, + HttpService httpService, NetworkAddressService networkAddressService, LocaleProvider localeProvider) { super(bridge); - httpClientFactory = hcf; + this.httpClient = httpClientFactory.getCommonHttpClient(); + this.oAuthFactory = oAuthFactory; + this.httpService = httpService; + this.networkAddressService = networkAddressService; this.localeProvider = localeProvider; } @@ -81,53 +105,71 @@ public void handleCommand(ChannelUID channelUID, Command command) { @Override public void initialize() { + isInitialized = new CompletableFuture<>(); + tokenInitError = false; + logger.trace("MyBMWBridgeHandler.initialize"); updateStatus(ThingStatus.UNKNOWN); - this.bmwBridgeConfiguration = Optional.of(getConfigAs(MyBMWBridgeConfiguration.class)); + MyBMWBridgeConfiguration localBridgeConfiguration = getConfigAs(MyBMWBridgeConfiguration.class); - MyBMWBridgeConfiguration localBridgeConfiguration; + if (Constants.EMPTY.equals(localBridgeConfiguration.getUserName()) + || Constants.EMPTY.equals(localBridgeConfiguration.getPassword())) { + logger.warn("username or password no set"); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + MyBMWConstants.STATUS_USER_DETAILS_MISSING); + return; + } - if (bmwBridgeConfiguration.isPresent()) { - localBridgeConfiguration = bmwBridgeConfiguration.get(); - } else { - logger.warn("the bridge configuration could not be retrieved"); - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR); + if (Constants.EMPTY.equals(localBridgeConfiguration.getRegion())) { + logger.warn("region not set"); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + MyBMWConstants.STATUS_REGION_MISSING); return; } - if (localBridgeConfiguration.getLanguage().equals(Constants.LANGUAGE_AUTODETECT)) { - localBridgeConfiguration.setLanguage(localeProvider.getLocale().getLanguage().toLowerCase()); + Configuration config = super.editConfiguration(); + if (Constants.LANGUAGE_AUTODETECT.equals(localBridgeConfiguration.getLanguage())) { + config.put("language", localeProvider.getLocale().getLanguage().toLowerCase()); } - if (!MyBMWConfigurationChecker.checkInitialConfiguration(localBridgeConfiguration)) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR); - } else { - // there is no risk in this functionality as several steps have to happen to get the file proxy working: - // 1. environment variable ENVIRONMENT has to be available - // 2. username of the myBMW account must be set to "testuser" which is anyhow no valid username - // 3. the jar file must contain the fingerprints which will only happen if it has been built with the - // test-jar profile - String environment = System.getenv(ENVIRONMENT); - - if (environment == null) { - environment = ""; + String ipConfig = localBridgeConfiguration.getCallbackIP(); + if (Constants.EMPTY.equals(ipConfig) || NetUtil.getAllInterfaceAddresses().stream() + .map(cidr -> cidr.getAddress().getHostAddress()).noneMatch(a -> ipConfig.equals(a))) { + String ip = networkAddressService.getPrimaryIpv4HostAddress(); + if (ip != null) { + config.put("callbackIP", ipConfig); + } else { + logger.warn("the callback IP address could not be retrieved"); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + MyBMWConstants.STATUS_IP_MISSING); + return; } + } + super.updateConfiguration(config); + localBridgeConfiguration = getConfigAs(MyBMWBridgeConfiguration.class); - // this access has to be synchronized as the vehicleHandler as well as the bridge itself request the - // instance - Optional localProxy = getMyBmwProxy(); - localProxy.ifPresent(proxy -> proxy.setBridgeConfiguration(localBridgeConfiguration)); + // there is no risk in this functionality as several steps have to happen to get the file proxy working: + // 1. environment variable ENVIRONMENT has to be available + // 2. username of the myBMW account must be set to "testuser" which is anyhow no valid username + // 3. the jar file must contain the fingerprints which will only happen if it has been built with the + // test-jar profile + String environment = System.getenv(ENVIRONMENT); - initializerJob = Optional.of(scheduler.schedule(this::discoverVehicles, 2, TimeUnit.SECONDS)); + if (environment == null) { + environment = ""; } + + createMyBmwProxy(localBridgeConfiguration, environment); + initializerJob = Optional.of(scheduler.schedule(this::discoverVehicles, 2, TimeUnit.SECONDS)); + isInitialized.complete(true); } - private synchronized void createMyBmwProxy(MyBMWBridgeConfiguration config, String environment) { + private void createMyBmwProxy(MyBMWBridgeConfiguration config, String environment) { if (!myBmwProxy.isPresent()) { if (!(TEST.equals(environment) && TESTUSER.equals(config.getUserName()))) { - myBmwProxy = Optional.of(new MyBMWHttpProxy(httpClientFactory, config)); + myBmwProxy = Optional.of(new MyBMWHttpProxy(this, httpClient, oAuthFactory, config)); } else { - myBmwProxy = Optional.of(new MyBMWFileProxy(httpClientFactory, config)); + myBmwProxy = Optional.of(new MyBMWFileProxy(httpClient, config)); } logger.trace("MyBMWBridgeHandler proxy set"); } else { @@ -140,11 +182,27 @@ private synchronized void createMyBmwProxy(MyBMWBridgeConfiguration config, Stri public void dispose() { logger.trace("MyBMWBridgeHandler.dispose"); initializerJob.ifPresent(job -> job.cancel(true)); + authServlet.ifPresent(servlet -> servlet.dispose()); + authServlet = Optional.empty(); + isInitialized.cancel(true); } - public void vehicleDiscoveryError() { + public void vehicleDiscoveryError(String message) { logger.trace("MyBMWBridgeHandler.vehicleDiscoveryError"); - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Request vehicles failed"); + if (!tokenInitError) { + String errorMessage = message.isEmpty() ? MyBMWConstants.STATUS_VEHICLE_RETRIEVAL_ERROR : message; + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, errorMessage); + } + } + + public void vehicleQuotaDiscoveryError(Instant nextQuota) { + logger.trace("MyBMWBridgeHandler.vehicleQuotaDiscoveryError"); + if (!tokenInitError) { + String timeString = DateTimeFormatter.ofPattern("HH:mm:ss") + .format(LocalDateTime.ofInstant(nextQuota, ZoneId.systemDefault())); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + (MyBMWConstants.STATUS_QUOTA_ERROR + " [%s]").formatted(timeString)); + } } public void vehicleDiscoverySuccess() { @@ -158,29 +216,49 @@ private void discoverVehicles() { vehicleDiscovery.ifPresent(discovery -> discovery.discoverVehicles()); } + public void tokenInitError() { + Configuration config = super.editConfiguration(); + config.remove("hcaptchatoken"); + super.updateConfiguration(config); + + authServlet.ifPresent(servlet -> servlet.dispose()); + MyBMWAuthServlet servlet = new MyBMWAuthServlet(this, getConfigAs(MyBMWBridgeConfiguration.class).getRegion(), + httpService); + servlet.startListening(); + this.authServlet = Optional.of(servlet); + + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + (MyBMWConstants.STATUS_AUTH_NEEDED + " [ \"http(s)://:%s\" ]") + .formatted(servlet.getPath())); + tokenInitError = true; + } + @Override public Collection> getServices() { logger.trace("MyBMWBridgeHandler.getServices"); return List.of(VehicleDiscovery.class); } - public synchronized Optional getMyBmwProxy() { - logger.trace("MyBMWBridgeHandler.getProxy"); - - MyBMWBridgeConfiguration localBridgeConfiguration = null; - - if (bmwBridgeConfiguration.isPresent()) { - localBridgeConfiguration = bmwBridgeConfiguration.get(); - } else { - logger.warn("the bridge configuration could not be retrieved"); - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR); - throw new IllegalStateException("bridge handler - configuration is not available"); + public Optional getMyBmwProxy() { + // wait for initialization to complete + try { + isInitialized.get(); + } catch (InterruptedException | ExecutionException e) { + logger.debug("exception waiting for bridge initialization: {}", e.toString()); } + return myBmwProxy; + } - if (!myBmwProxy.isPresent()) { - createMyBmwProxy(localBridgeConfiguration, ENVIRONMENT); - } + public void setHCaptchaToken(String hCaptchaToken) { + Configuration config = super.editConfiguration(); + config.put("hcaptchatoken", hCaptchaToken); + super.updateConfiguration(config); - return myBmwProxy; + if (!hCaptchaToken.isEmpty()) { + initializerJob.ifPresent(job -> job.cancel(true)); + authServlet.ifPresent(servlet -> servlet.dispose()); + authServlet = Optional.empty(); + initialize(); + } } } diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/VehicleHandler.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/VehicleHandler.java index a446cbbf3ab2c..284f57b5b36ab 100644 --- a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/VehicleHandler.java +++ b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/VehicleHandler.java @@ -12,89 +12,7 @@ */ package org.openhab.binding.mybmw.internal.handler; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.ADDRESS; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.CHANNEL_GROUP_CHARGE_PROFILE; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.CHANNEL_GROUP_CHARGE_SESSION; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.CHANNEL_GROUP_CHARGE_STATISTICS; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.CHANNEL_GROUP_CHECK_CONTROL; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.CHANNEL_GROUP_DOORS; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.CHANNEL_GROUP_LOCATION; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.CHANNEL_GROUP_RANGE; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.CHANNEL_GROUP_REMOTE; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.CHANNEL_GROUP_SERVICE; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.CHANNEL_GROUP_STATUS; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.CHANNEL_GROUP_TIRES; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.CHANNEL_GROUP_UPDATE; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.CHANNEL_GROUP_VEHICLE_IMAGE; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.CHARGE_ENABLED; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.CHARGE_PROFILE_CLIMATE; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.CHARGE_PROFILE_CONTROL; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.CHARGE_PROFILE_LIMIT; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.CHARGE_PROFILE_MODE; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.CHARGE_PROFILE_PREFERENCE; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.CHARGE_PROFILE_TARGET; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.CHARGE_REMAINING; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.CHARGE_STATUS; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.CHARGING_UPDATE; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.CHECK_CONTROL; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.DATE; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.DETAILS; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.DOORS; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.DOOR_DRIVER_FRONT; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.DOOR_DRIVER_REAR; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.DOOR_PASSENGER_FRONT; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.DOOR_PASSENGER_REAR; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.ENERGY; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.ESTIMATED_FUEL_L_100KM; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.ESTIMATED_FUEL_MPG; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.FRONT_LEFT_CURRENT; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.FRONT_LEFT_TARGET; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.FRONT_RIGHT_CURRENT; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.FRONT_RIGHT_TARGET; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.GPS; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.HEADING; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.HOME_DISTANCE; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.HOOD; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.IMAGE_FORMAT; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.IMAGE_UPDATE; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.IMAGE_VIEWPORT; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.ISSUE; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.LAST_FETCHED; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.LAST_UPDATE; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.LOCK; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.MILEAGE; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.NAME; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.PLUG_CONNECTION; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.RANGE_ELECTRIC; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.RANGE_FUEL; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.RANGE_HYBRID; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.RANGE_RADIUS_ELECTRIC; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.RANGE_RADIUS_FUEL; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.RANGE_RADIUS_HYBRID; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.RAW; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.REAR_LEFT_CURRENT; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.REAR_LEFT_TARGET; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.REAR_RIGHT_CURRENT; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.REAR_RIGHT_TARGET; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.REMAINING_FUEL; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.REMOTE_SERVICE_COMMAND; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.REMOTE_STATE; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.SERVICE_DATE; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.SERVICE_MILEAGE; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.SESSIONS; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.SEVERITY; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.SOC; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.STATE_UPDATE; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.STATUS; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.SUBTITLE; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.SUNROOF; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.TITLE; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.TRUNK; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.WINDOWS; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.WINDOW_DOOR_DRIVER_FRONT; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.WINDOW_DOOR_DRIVER_REAR; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.WINDOW_DOOR_PASSENGER_FRONT; -import static org.openhab.binding.mybmw.internal.MyBMWConstants.WINDOW_DOOR_PASSENGER_REAR; +import static org.openhab.binding.mybmw.internal.MyBMWConstants.*; import java.time.DayOfWeek; import java.time.LocalTime; @@ -251,20 +169,23 @@ public void initialize() { if (bridge != null) { BridgeHandler handler = bridge.getHandler(); if (handler != null) { - proxy = ((MyBMWBridgeHandler) handler).getMyBmwProxy(); - remote = Optional.of(new RemoteServiceExecutor(this, proxy.get())); + // Can be long running if we have to wait for the bridge to be initialized + scheduler.submit(() -> { + proxy = ((MyBMWBridgeHandler) handler).getMyBmwProxy(); + remote = Optional.of(new RemoteServiceExecutor(this, proxy.get())); + + imageProperties = new ImageProperties(); + updateChannel(CHANNEL_GROUP_VEHICLE_IMAGE, IMAGE_VIEWPORT, + Converter.toTitleCase(imageProperties.viewport), null); + + startSchedule(vehicleConfiguration.get().getRefreshInterval()); + }); } else { logger.debug("Bridge Handler null"); } } else { logger.debug("Bridge null"); } - - imageProperties = new ImageProperties(); - updateChannel(CHANNEL_GROUP_VEHICLE_IMAGE, IMAGE_VIEWPORT, Converter.toTitleCase(imageProperties.viewport), - null); - - startSchedule(vehicleConfiguration.get().getRefreshInterval()); } private void startSchedule(int interval) { diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/auth/MyBMWAuthServlet.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/auth/MyBMWAuthServlet.java new file mode 100644 index 0000000000000..c496fe7cdcae4 --- /dev/null +++ b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/auth/MyBMWAuthServlet.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2010-2025 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.mybmw.internal.handler.auth; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.mybmw.internal.MyBMWConstants; +import org.openhab.binding.mybmw.internal.handler.MyBMWBridgeHandler; +import org.osgi.service.http.HttpService; +import org.osgi.service.http.NamespaceException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * {@link MyBMWAuthServlet} provides captcha html pages + * + * @author Mark Herwege - Initial contribution + */ +@NonNullByDefault +public class MyBMWAuthServlet extends HttpServlet { + private static final long serialVersionUID = 3817341543768551687L; + private static final String CONTENT_TYPE = "text/html;charset=UTF-8"; + + private final Logger logger = LoggerFactory.getLogger(MyBMWAuthServlet.class); + private final @NonNullByDefault({}) ClassLoader classLoader = MyBMWAuthServlet.class.getClassLoader(); + private final HttpService httpService; + private final MyBMWBridgeHandler bridgeHandler; + private String path = ""; + private String captchaHtml = ""; + + public MyBMWAuthServlet(MyBMWBridgeHandler bridgeHandler, String region, HttpService httpService) { + this.httpService = httpService; + this.bridgeHandler = bridgeHandler; + this.path = MyBMWConstants.BASE_PATH + bridgeHandler.getThing().getUID().getAsString(); + String captchaTemplate = MyBMWConstants.CAPTCHA_HTML.get(region); + if (captchaTemplate != null) { + try (InputStream stream = classLoader.getResourceAsStream(captchaTemplate)) { + captchaHtml = stream != null ? new String(stream.readAllBytes(), StandardCharsets.UTF_8) : ""; + } catch (IOException e) { + throw new IllegalArgumentException("No captcha html found for region " + region); + } + } + } + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + logger.debug("myBMW auth servlet received GET request {}.", request.getRequestURI()); + StringBuffer requestUrl = request.getRequestURL(); + if (requestUrl != null) { + response.setContentType(CONTENT_TYPE); + response.getWriter().append(captchaHtml); + response.getWriter().close(); + } else { + logger.warn("Unexpected: GET requestUrl is null"); + } + } + + @Override + protected void doPost(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + StringBuilder buffer = new StringBuilder(); + String line; + try (BufferedReader reader = request.getReader()) { + while ((line = reader.readLine()) != null) { + buffer.append(line); + } + logger.trace("myBMW auth servlet received POST content: {}", buffer.toString()); + this.bridgeHandler.setHCaptchaToken(buffer.toString().trim()); + } + } + + public void startListening() { + try { + httpService.registerServlet(path, this, null, httpService.createDefaultHttpContext()); + logger.info("Registered myBMW servlet at '{}'", path); + } catch (NamespaceException | ServletException e) { + logger.warn("Registering servlet failed:{}", e.getMessage()); + } + } + + public void dispose() { + logger.debug("Stopping myBMW Servlet {}", path); + httpService.unregister(path); + this.destroy(); + } + + public String getPath() { + return path; + } +} diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/auth/MyBMWTokenController.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/auth/MyBMWTokenController.java index 6c07ef1d22b0b..ddb6b173ce65a 100644 --- a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/auth/MyBMWTokenController.java +++ b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/auth/MyBMWTokenController.java @@ -12,49 +12,17 @@ */ package org.openhab.binding.mybmw.internal.handler.auth; -import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.API_OAUTH_CONFIG; -import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.APP_VERSIONS; -import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.AUTHORIZATION_CODE; -import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.AUTH_PROVIDER; -import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.BRAND_BMW; -import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.CHINA_LOGIN; -import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.CHINA_PUBLIC_KEY; -import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.EADRAX_SERVER_MAP; -import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.LOGIN_NONCE; -import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.OAUTH_ENDPOINT; -import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.OCP_APIM_KEYS; -import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.REFRESH_TOKEN; -import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.REGION_CHINA; -import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.USER_AGENT; -import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.X_USER_AGENT; -import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.AUTHORIZATION; -import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.CLIENT_ID; -import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.CODE; -import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.CODE_CHALLENGE; -import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.CODE_CHALLENGE_METHOD; -import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.CODE_VERIFIER; -import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.CONTENT_TYPE_URL_ENCODED; -import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.GRANT_TYPE; -import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.HCAPTCHA_TOKEN; -import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.HEADER_ACP_SUBSCRIPTION_KEY; -import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.HEADER_BMW_CORRELATION_ID; -import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.HEADER_X_CORRELATION_ID; -import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.HEADER_X_IDENTITY_PROVIDER; -import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.HEADER_X_USER_AGENT; -import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.NONCE; -import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.PASSWORD; -import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.REDIRECT_URI; -import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.RESPONSE_TYPE; -import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.SCOPE; -import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.STATE; -import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.USERNAME; +import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.*; +import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.*; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.security.KeyFactory; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.PublicKey; import java.security.spec.X509EncodedKeySpec; +import java.time.Instant; import java.util.Base64; import java.util.UUID; import java.util.concurrent.ExecutionException; @@ -73,13 +41,18 @@ import org.eclipse.jetty.util.MultiMap; import org.eclipse.jetty.util.UrlEncoded; import org.openhab.binding.mybmw.internal.MyBMWBridgeConfiguration; -import org.openhab.binding.mybmw.internal.dto.auth.AuthQueryResponse; -import org.openhab.binding.mybmw.internal.dto.auth.AuthResponse; import org.openhab.binding.mybmw.internal.dto.auth.ChinaPublicKeyResponse; import org.openhab.binding.mybmw.internal.dto.auth.ChinaTokenExpiration; import org.openhab.binding.mybmw.internal.dto.auth.ChinaTokenResponse; +import org.openhab.binding.mybmw.internal.dto.auth.OAuthSettingsQueryResponse; +import org.openhab.binding.mybmw.internal.handler.MyBMWBridgeHandler; import org.openhab.binding.mybmw.internal.handler.backend.JsonStringDeserializer; import org.openhab.binding.mybmw.internal.utils.Constants; +import org.openhab.core.auth.client.oauth2.AccessTokenResponse; +import org.openhab.core.auth.client.oauth2.OAuthClientService; +import org.openhab.core.auth.client.oauth2.OAuthException; +import org.openhab.core.auth.client.oauth2.OAuthFactory; +import org.openhab.core.auth.client.oauth2.OAuthResponseException; import org.openhab.core.util.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -93,19 +66,34 @@ * * @author Bernd Weymann - Initial contribution * @author Martin Grassl - extracted from myBmwProxy + * @author Mark Herwege - refactor to use OAuthFactory */ @NonNullByDefault public class MyBMWTokenController { private final Logger logger = LoggerFactory.getLogger(MyBMWTokenController.class); - private Token token = new Token(); + private MyBMWBridgeHandler bridgeHandler; private MyBMWBridgeConfiguration bridgeConfiguration; - private HttpClient httpClient; + private final HttpClient httpClient; + private final OAuthFactory oAuthFactory; - public MyBMWTokenController(MyBMWBridgeConfiguration configuration, HttpClient httpClient) { + private String oAuthClientServiceId; + private @Nullable OAuthClientService oAuthClientService = null; + private AccessTokenResponse tokenResponse = new AccessTokenResponse(); + private boolean waitingForInitialToken = false; + + private static final String SESSION_ID = UUID.randomUUID().toString(); + + public MyBMWTokenController(MyBMWBridgeHandler bridgeHandler, MyBMWBridgeConfiguration configuration, + HttpClient httpClient, OAuthFactory oAuthFactory) { + this.bridgeHandler = bridgeHandler; this.bridgeConfiguration = configuration; this.httpClient = httpClient; + this.oAuthFactory = oAuthFactory; + this.oAuthClientServiceId = bridgeHandler.getThing().getUID().getAsString(); + + this.oAuthClientService = oAuthFactory.getOAuthClientService(oAuthClientServiceId); } public synchronized void setBridgeConfiguration(MyBMWBridgeConfiguration bridgeConfiguration) { @@ -113,103 +101,104 @@ public synchronized void setBridgeConfiguration(MyBMWBridgeConfiguration bridgeC } /** - * Gets new token if old one is expired or invalid. In case of error the token - * remains. - * So if token refresh fails the corresponding requests will also fail and - * update the Thing status accordingly. + * Gets new token if old one is expired or invalid. In case of error the token remains. So if token refresh fails + * the corresponding requests will also fail and update the Thing status accordingly. * * @return token */ - public synchronized Token getToken() { - - logger.trace("getToken, current token {}", token.toString()); - - if (REGION_CHINA.equals(bridgeConfiguration.getRegion()) && !token.isValid()) { - // in China no hcaptchatoken is required - boolean tokenUpdateSuccess = false; - tokenUpdateSuccess = getAndUpdateTokenChina(); - if (!tokenUpdateSuccess) { - logger.warn("Authorization failed!"); - } - } else if (!bridgeConfiguration.getHcaptchatoken().isBlank()) { - // if the hcaptchastring is available, then a new login is triggered - - logger.trace("initial login, using captchatoken {}", bridgeConfiguration.getHcaptchatoken()); - - boolean tokenCreationSuccess = getInitialToken(); - - if (!tokenCreationSuccess) { - this.token = new Token(); + public synchronized AccessTokenResponse getToken() { + logger.trace("getToken"); + + if (waitingForInitialToken && !bridgeConfiguration.getHCaptchaToken().isBlank()) { + // if the hCaptchaToken is available, then a new login is triggered + logger.trace("initial login, using captchatoken {}", bridgeConfiguration.getHCaptchaToken()); + + boolean tokenCreationSuccess = loginROW(); + if (tokenCreationSuccess) { + waitingForInitialToken = false; + logger.trace("get inital token success"); + } else { logger.warn( - "initial Authentication failed, maybe request a new captcha token, see https://bimmer-connected.readthedocs.io/en/latest/captcha/rest_of_world.html!"); + "initial Authentication failed, maybe request a new captcha token, see https://bimmer-connected.readthedocs.io/en/stable/captcha.html!"); + bridgeHandler.tokenInitError(); + logger.trace("get inital token failed"); } // reset the token as it times out - bridgeConfiguration.setHcaptchatoken(Constants.EMPTY); - } else if (!token.isValid() && !Constants.EMPTY.equals(token.getRefreshToken())) { - // if the token is invalid, try to refresh the token - boolean tokenUpdateSuccess = false; - tokenUpdateSuccess = getUpdatedToken(); - - if (!tokenUpdateSuccess) { - logger.warn("Authorization failed!"); + bridgeHandler.setHCaptchaToken(Constants.EMPTY); + } else if (!waitingForInitialToken && tokenResponse.isExpired(Instant.now(), 5)) { + if (REGION_CHINA.equals(bridgeConfiguration.getRegion())) { + // in China no hcaptchatoken is required, so no need to wait for reinitialization + boolean tokenUpdateSuccess = getAndRefreshTokenChina(); + if (!tokenUpdateSuccess) { + logger.warn("Updating token failed!"); + } + } else { + // try to refresh the token + boolean tokenUpdateSuccess = refreshTokenROW(); + logger.trace("update token {}", tokenUpdateSuccess ? "success" : "failed"); + + if (!tokenUpdateSuccess) { + logger.warn("Updating token failed!"); + waitingForInitialToken = true; + + if (bridgeConfiguration.getHCaptchaToken().isBlank()) { + logger.warn( + "initial Authentication failed, request a new captcha token, see https://bimmer-connected.readthedocs.io/en/stable/captcha.html!"); + bridgeHandler.tokenInitError(); + } else { + getToken(); + } + } } } - logger.trace("getToken, new token {}", token.toString()); - - return token; + return tokenResponse; } /** - * Everything is caught by surrounding try catch - * - HTTP Exceptions - * - JSONSyntax Exceptions - * - potential NullPointer Exceptions - * - * @return true if the token was successfully updated + * @return true if the token was successfully retrieved */ - private synchronized boolean getInitialToken() { - + private boolean loginROW() { logger.trace("get initial token"); try { - /* - * Step 1) Get basic values for further queries + /** + * Step 1) Get OAuth2 settings for further queries */ - AuthQueryResponse aqr = getBasicAuthenticationValues(); + OAuthSettingsQueryResponse aqr = getOAuthSettings(); - /* + /** * Step 2) Calculate values for oauth base parameters */ - String codeVerifier = generateCodeVerifier(); + String codeVerifier = generateToken(86); String codeChallenge = generateCodeChallenge(codeVerifier); - String state = generateState(); + String state = generateToken(22); + String nonce = generateToken(22); MultiMap<@Nullable String> baseParams = new MultiMap<>(); baseParams.put(CLIENT_ID, aqr.clientId); baseParams.put(RESPONSE_TYPE, CODE); baseParams.put(REDIRECT_URI, aqr.returnUrl); baseParams.put(STATE, state); - baseParams.put(NONCE, LOGIN_NONCE); - baseParams.put(SCOPE, String.join(Constants.SPACE, aqr.scopes)); + baseParams.put(NONCE, nonce); + baseParams.put(SCOPE, aqr.scopes()); baseParams.put(CODE_CHALLENGE, codeChallenge); baseParams.put(CODE_CHALLENGE_METHOD, "S256"); /** - * Step 3) Authentication with username and password + * Step 3) Authentication with username and password, get authentication code */ String loginUrl = aqr.gcdmBaseUrl + OAUTH_ENDPOINT; Request loginRequest = httpClient.POST(loginUrl); loginRequest.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_URL_ENCODED); - loginRequest.header(HCAPTCHA_TOKEN, bridgeConfiguration.getHcaptchatoken()); + loginRequest.header(HCAPTCHA_TOKEN, bridgeConfiguration.getHCaptchaToken()); MultiMap<@Nullable String> loginParams = new MultiMap<>(baseParams); loginParams.put(GRANT_TYPE, AUTHORIZATION_CODE); loginParams.put(USERNAME, bridgeConfiguration.getUserName()); loginParams.put(PASSWORD, bridgeConfiguration.getPassword()); - logger.trace("loginParams {}", loginParams); loginRequest.content(new StringContentProvider(CONTENT_TYPE_URL_ENCODED, UrlEncoded.encode(loginParams, StandardCharsets.UTF_8, false), StandardCharsets.UTF_8)); ContentResponse loginResponse = loginRequest.send(); @@ -220,9 +209,10 @@ private synchronized boolean getInitialToken() { } String authCode = getAuthCode(loginResponse.getContentAsString()); + logger.trace("authentication code: {}", authCode); /** - * Step 4) Authenticate with code + * Step 4) With authentication code get code */ Request authRequest = httpClient.POST(loginUrl).followRedirects(false); MultiMap<@Nullable String> authParams = new MultiMap<>(baseParams); @@ -236,128 +226,82 @@ private synchronized boolean getInitialToken() { + ", Message: " + authResponse.getContentAsString(), authResponse); } String code = codeFromUrl(authResponse.getHeaders().get(HttpHeader.LOCATION)); + logger.trace("code: {}", code); /** - * Step 5) Request token + * Step 5) With code get token */ - Request codeRequest = httpClient.POST(aqr.tokenEndpoint); - String basicAuth = "Basic " - + Base64.getUrlEncoder().encodeToString((aqr.clientId + ":" + aqr.clientSecret).getBytes()); - codeRequest.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_URL_ENCODED); - codeRequest.header(AUTHORIZATION, basicAuth); - - MultiMap<@Nullable String> codeParams = new MultiMap<>(); - codeParams.put(CODE, code); - codeParams.put(CODE_VERIFIER, codeVerifier); - codeParams.put(REDIRECT_URI, aqr.returnUrl); - codeParams.put(GRANT_TYPE, AUTHORIZATION_CODE); - codeRequest.content(new StringContentProvider(CONTENT_TYPE_URL_ENCODED, - UrlEncoded.encode(codeParams, StandardCharsets.UTF_8, false), StandardCharsets.UTF_8)); - ContentResponse codeResponse = codeRequest.send(); - if (codeResponse.getStatus() != 200) { - throw new HttpResponseException("URL: " + codeRequest.getURI() + ", Error: " + codeResponse.getStatus() - + ", Message: " + codeResponse.getContentAsString(), codeResponse); + if (oAuthFactory.getOAuthClientService(oAuthClientServiceId) != null) { + oAuthFactory.ungetOAuthService(oAuthClientServiceId); } - AuthResponse ar = JsonStringDeserializer.deserializeString(codeResponse.getContentAsString(), - AuthResponse.class); - - logger.trace("Login response: {}", ar.toString()); + OAuthClientService oAuthClientService = oAuthFactory.createOAuthClientService(oAuthClientServiceId, + aqr.tokenEndpoint, loginUrl, aqr.clientId, aqr.clientSecret, aqr.scopes(), false); + this.oAuthClientService = oAuthClientService; - token.setType(ar.tokenType); - token.setToken(ar.accessToken); - token.setExpiration(ar.expiresIn); - token.setRefreshToken(ar.refreshToken); - token.setGcid(ar.gcid); + oAuthClientService.addExtraAuthField(CODE_VERIFIER, codeVerifier); + tokenResponse = oAuthClientService.getAccessTokenResponseByAuthorizationCode(code, aqr.returnUrl); return true; - } catch (Exception e) { - logger.warn("Authorization Exception: {}", e.getMessage()); + } catch (ExecutionException | OAuthException | IOException | OAuthResponseException | InterruptedException + | HttpResponseException | TimeoutException | NoSuchAlgorithmException e) { + logger.warn("Exception at login: {}", e.getMessage()); } return false; } /** * refresh the existing token - * + * * @return true if token has successfully been refreshed */ - private synchronized boolean getUpdatedToken() { - - logger.trace("getUpdatedToken"); - + private boolean refreshTokenROW() { + logger.trace("refreshToken"); + OAuthClientService oAuthClientService = this.oAuthClientService; + if (oAuthClientService == null) { + return false; + } try { - /* - * Step 1) Get basic values for further queries - */ - AuthQueryResponse aqr = getBasicAuthenticationValues(); - - /** - * Step 2) Request token - */ - Request codeRequest = httpClient.POST(aqr.tokenEndpoint); - String basicAuth = "Basic " - + Base64.getUrlEncoder().encodeToString((aqr.clientId + ":" + aqr.clientSecret).getBytes()); - codeRequest.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_URL_ENCODED); - codeRequest.header(AUTHORIZATION, basicAuth); - - MultiMap<@Nullable String> codeParams = new MultiMap<>(); - codeParams.put(SCOPE, String.join(Constants.SPACE, aqr.scopes)); - codeParams.put(REDIRECT_URI, aqr.returnUrl); - codeParams.put(GRANT_TYPE, REFRESH_TOKEN); - codeParams.put(REFRESH_TOKEN, token.getRefreshToken()); - codeRequest.content(new StringContentProvider(CONTENT_TYPE_URL_ENCODED, - UrlEncoded.encode(codeParams, StandardCharsets.UTF_8, false), StandardCharsets.UTF_8)); - ContentResponse codeResponse = codeRequest.send(); - if (codeResponse.getStatus() != 200) { - throw new HttpResponseException("URL: " + codeRequest.getURI() + ", Error: " + codeResponse.getStatus() - + ", Message: " + codeResponse.getContentAsString(), codeResponse); + AccessTokenResponse tokenResponse; + tokenResponse = oAuthClientService.getAccessTokenResponse(); + if (tokenResponse != null) { + this.tokenResponse = tokenResponse; + return true; } - AuthResponse ar = JsonStringDeserializer.deserializeString(codeResponse.getContentAsString(), - AuthResponse.class); - - logger.trace("Refresh response: {}", ar.toString()); - - token.setToken(ar.accessToken); - token.setExpiration(ar.expiresIn); - token.setRefreshToken(ar.refreshToken); - token.setGcid(ar.gcid); - - return true; - } catch (Exception e) { - logger.warn("Refresh Exception: ", e); + } catch (OAuthException | IOException | OAuthResponseException e) { + logger.warn("Exception refreshing token: ", e); } return false; } - private AuthQueryResponse getBasicAuthenticationValues() + private OAuthSettingsQueryResponse getOAuthSettings() throws InterruptedException, TimeoutException, ExecutionException { String uuidString = UUID.randomUUID().toString(); - String authValuesUrl = "https://" + EADRAX_SERVER_MAP.get(bridgeConfiguration.getRegion()) + API_OAUTH_CONFIG; - Request authValuesRequest = httpClient.newRequest(authValuesUrl); - authValuesRequest.header(HEADER_ACP_SUBSCRIPTION_KEY, OCP_APIM_KEYS.get(bridgeConfiguration.getRegion())); - authValuesRequest.header(HEADER_X_USER_AGENT, String.format(X_USER_AGENT, BRAND_BMW, + String oAuthSettingsUrl = "https://" + EADRAX_SERVER_MAP.get(bridgeConfiguration.getRegion()) + + API_OAUTH_CONFIG; + Request oAuthSettingsRequest = httpClient.newRequest(oAuthSettingsUrl); + oAuthSettingsRequest.header(HEADER_ACP_SUBSCRIPTION_KEY, OCP_APIM_KEYS.get(bridgeConfiguration.getRegion())); + oAuthSettingsRequest.header(HEADER_X_USER_AGENT, String.format(X_USER_AGENT, BRAND_BMW, APP_VERSIONS.get(bridgeConfiguration.getRegion()), bridgeConfiguration.getRegion())); - authValuesRequest.header(HEADER_X_IDENTITY_PROVIDER, AUTH_PROVIDER); - authValuesRequest.header(HEADER_X_CORRELATION_ID, uuidString); - authValuesRequest.header(HEADER_BMW_CORRELATION_ID, uuidString); - - ContentResponse authValuesResponse = authValuesRequest.send(); - if (authValuesResponse.getStatus() != 200) { - throw new HttpResponseException("URL: " + authValuesRequest.getURI() + ", Error: " - + authValuesResponse.getStatus() + ", Message: " + authValuesResponse.getContentAsString(), - authValuesResponse); + oAuthSettingsRequest.header(HEADER_X_IDENTITY_PROVIDER, AUTH_PROVIDER); + oAuthSettingsRequest.header(HEADER_X_CORRELATION_ID, uuidString); + oAuthSettingsRequest.header(HEADER_BMW_CORRELATION_ID, uuidString); + oAuthSettingsRequest.header(HEADER_BMW_SESSION_ID, SESSION_ID); + + ContentResponse oAuthSettingsRepsonse = oAuthSettingsRequest.send(); + if (oAuthSettingsRepsonse.getStatus() != 200) { + throw new HttpResponseException("URL: " + oAuthSettingsRequest.getURI() + ", Error: " + + oAuthSettingsRepsonse.getStatus() + ", Message: " + oAuthSettingsRepsonse.getContentAsString(), + oAuthSettingsRepsonse); } - AuthQueryResponse aqr = JsonStringDeserializer.deserializeString(authValuesResponse.getContentAsString(), - AuthQueryResponse.class); - - logger.trace("authQueryResponse: {}", aqr); + OAuthSettingsQueryResponse aqr = JsonStringDeserializer + .deserializeString(oAuthSettingsRepsonse.getContentAsString(), OAuthSettingsQueryResponse.class); return aqr; } - private String generateState() { - String stateBytes = StringUtils.getRandomAlphabetic(64).toLowerCase(); - return Base64.getUrlEncoder().withoutPadding().encodeToString(stateBytes.getBytes()); + private String generateToken(int length) { + String bytes = StringUtils.getRandomAlphabetic(length).toLowerCase(); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes.getBytes()); } private String generateCodeChallenge(String codeVerifier) throws NoSuchAlgorithmException { @@ -366,11 +310,6 @@ private String generateCodeChallenge(String codeVerifier) throws NoSuchAlgorithm return Base64.getUrlEncoder().withoutPadding().encodeToString(hash); } - private String generateCodeVerifier() { - String verfifierBytes = StringUtils.getRandomAlphabetic(64).toLowerCase(); - return Base64.getUrlEncoder().withoutPadding().encodeToString(verfifierBytes.getBytes()); - } - private String getAuthCode(String response) { String[] keys = response.split("&"); for (int i = 0; i < keys.length; i++) { @@ -398,7 +337,10 @@ private String codeFromUrl(String encodedUrl) { return codeFound.toString(); } - private synchronized boolean getAndUpdateTokenChina() { + /** + * @return true if the token was successfully updated + */ + private boolean getAndRefreshTokenChina() { try { /** * Step 1) get public key @@ -445,6 +387,7 @@ private synchronized boolean getAndUpdateTokenChina() { String jsonContent = "{ \"mobile\":\"" + bridgeConfiguration.getUserName() + "\", \"password\":\"" + encodedPassword + "\"}"; loginRequest.content(new StringContentProvider(jsonContent)); + Instant tokenCreatedOn = Instant.now(); ContentResponse tokenResponse = loginRequest.send(); if (tokenResponse.getStatus() != 200) { throw new HttpResponseException("URL: " + loginRequest.getURI() + ", Error: " @@ -463,10 +406,14 @@ private synchronized boolean getAndUpdateTokenChina() { String tokenJwtDecodeStr = new String(Base64.getUrlDecoder().decode(chunks[1])); ChinaTokenExpiration cte = JsonStringDeserializer.deserializeString(tokenJwtDecodeStr, ChinaTokenExpiration.class); - Token t = new Token(); - t.setToken(token); - t.setType(cat.data.tokenType); - t.setExpirationTotal(cte.exp); + + AccessTokenResponse t = new AccessTokenResponse(); + t.setAccessToken(token); + t.setTokenType(cat.data.tokenType); + t.setCreatedOn(tokenCreatedOn); + t.setExpiresIn(cte.exp); + + this.tokenResponse = t; return true; } catch (Exception e) { logger.warn("Authorization Exception: {}", e.getMessage()); diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/auth/Token.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/auth/Token.java deleted file mode 100644 index caccfd436692d..0000000000000 --- a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/auth/Token.java +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright (c) 2010-2025 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.mybmw.internal.handler.auth; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.openhab.binding.mybmw.internal.utils.Constants; - -/** - * The {@link Token} MyBMW Token storage - * - * @author Bernd Weymann - Initial contribution - * @author Martin Grassl - extracted to own class - */ -@NonNullByDefault -public class Token { - private String token = Constants.EMPTY; - private String tokenType = Constants.EMPTY; - private String refreshToken = Constants.EMPTY; - private String gcid = Constants.EMPTY; - - private long expiration = 0; - - public String getBearerToken() { - return token.equals(Constants.EMPTY) ? Constants.EMPTY - : new StringBuilder(tokenType).append(Constants.SPACE).append(token).toString(); - } - - public void setToken(String token) { - this.token = token; - } - - public void setExpiration(int expiration) { - this.expiration = System.currentTimeMillis() / 1000 + expiration; - } - - public void setExpirationTotal(long expiration) { - this.expiration = expiration; - } - - public void setType(String type) { - tokenType = type; - } - - public String getRefreshToken() { - return refreshToken; - } - - public void setRefreshToken(String refreshToken) { - this.refreshToken = refreshToken; - } - - public String getGcid() { - return gcid; - } - - public void setGcid(String gcid) { - this.gcid = gcid; - } - - /** - * check if the token is valid - for enough buffer it is not valid if it expires in <10s - * - * @return - */ - public boolean isValid() { - return (!token.equals(Constants.EMPTY) && !tokenType.equals(Constants.EMPTY) - && !refreshToken.equals(Constants.EMPTY) && (this.expiration - System.currentTimeMillis() / 1000) > 10); - } - - @Override - public String toString() { - return "Token [token=" + token + ", tokenType=" + tokenType + ", refreshToken=" + refreshToken + ", gcid=" - + gcid + ", expiration=" + expiration + "] - is valid " + isValid() + ", will expire in s " - + (this.expiration - System.currentTimeMillis() / 1000); - } -} diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/backend/JsonStringDeserializer.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/backend/JsonStringDeserializer.java index 1430af1a48a12..40918b4c43dc8 100644 --- a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/backend/JsonStringDeserializer.java +++ b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/backend/JsonStringDeserializer.java @@ -16,6 +16,7 @@ import java.util.Arrays; import java.util.List; +import org.eclipse.jdt.annotation.NonNull; import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.mybmw.internal.dto.charge.ChargingSessionsContainer; import org.openhab.binding.mybmw.internal.dto.charge.ChargingStatisticsContainer; @@ -29,9 +30,9 @@ import com.google.gson.JsonSyntaxException; /** - * + * * deserialization of a JSON string to a Java Object - * + * * @author Martin Grassl - initial contribution */ @NonNullByDefault @@ -91,7 +92,7 @@ public static ExecutionStatusContainer getExecutionStatus(String executionStatus } } - public static T deserializeString(String toBeDeserialized, Class deserializedClass) { + public static T deserializeString(String toBeDeserialized, Class<@NonNull T> deserializedClass) { return GSON.fromJson(toBeDeserialized, deserializedClass); } } diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/backend/MyBMWFileProxy.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/backend/MyBMWFileProxy.java index 662c370ba04e6..cf28ad92661ed 100644 --- a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/backend/MyBMWFileProxy.java +++ b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/backend/MyBMWFileProxy.java @@ -16,10 +16,13 @@ import java.io.File; import java.io.IOException; import java.io.InputStreamReader; +import java.time.Instant; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.client.HttpClient; import org.openhab.binding.mybmw.internal.MyBMWBridgeConfiguration; import org.openhab.binding.mybmw.internal.dto.charge.ChargingSessionsContainer; import org.openhab.binding.mybmw.internal.dto.charge.ChargingStatisticsContainer; @@ -30,7 +33,6 @@ import org.openhab.binding.mybmw.internal.handler.enums.RemoteService; import org.openhab.binding.mybmw.internal.utils.BimmerConstants; import org.openhab.binding.mybmw.internal.utils.ImageProperties; -import org.openhab.core.io.net.http.HttpClientFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -38,9 +40,9 @@ * This class is for local testing. You have to configure a connected account with username = "testuser" and password = * vehicle to be tested (e.g. BEV, ICE, BEV2, MILD_HYBRID,...) * The respective files are loaded from the resources folder - * + * * You have to set the environment variable "ENVIRONMENT" to the value "test" - * + * * @author Bernd Weymann - Initial contribution * @author Martin Grassl - refactoring */ @@ -57,16 +59,18 @@ public class MyBMWFileProxy implements MyBMWProxy { private static final String REMOTE_SERVICES_CALL = File.separator + "remote_service_call.json"; private static final String REMOTE_SERVICES_STATE = File.separator + "remote_service_status.json"; - public MyBMWFileProxy(HttpClientFactory httpClientFactory, MyBMWBridgeConfiguration bridgeConfiguration) { + public MyBMWFileProxy(HttpClient httpClient, MyBMWBridgeConfiguration bridgeConfiguration) { logger.trace("MyBMWFileProxy - initialize"); vehicleToBeTested = bridgeConfiguration.getPassword(); } + @Override public void setBridgeConfiguration(MyBMWBridgeConfiguration bridgeConfiguration) { logger.trace("MyBMWFileProxy - update bridge"); vehicleToBeTested = bridgeConfiguration.getPassword(); } + @Override public List requestVehicles() throws NetworkException { List vehicles = new ArrayList<>(); List vehiclesBase = requestVehiclesBase(); @@ -89,11 +93,13 @@ public List requestVehicles() throws NetworkException { * * @param brand */ + @Override public List requestVehiclesBase(String brand) throws NetworkException { String vehicleResponseString = requestVehiclesBaseJson(brand); return JsonStringDeserializer.getVehicleBaseList(vehicleResponseString); } + @Override public String requestVehiclesBaseJson(String brand) throws NetworkException { String vehicleResponseString = fileToString(VEHICLES_BASE); return vehicleResponseString; @@ -104,6 +110,7 @@ public String requestVehiclesBaseJson(String brand) throws NetworkException { * * @param callback */ + @Override public List requestVehiclesBase() throws NetworkException { List vehicles = new ArrayList<>(); @@ -121,6 +128,7 @@ public List requestVehiclesBase() throws NetworkException { * @param props * @return */ + @Override public byte[] requestImage(String vin, String brand, ImageProperties props) throws NetworkException { return "".getBytes(); } @@ -131,11 +139,13 @@ public byte[] requestImage(String vin, String brand, ImageProperties props) thro * @param baseVehicle * @return */ + @Override public VehicleStateContainer requestVehicleState(String vin, String brand) throws NetworkException { String vehicleStateResponseString = requestVehicleStateJson(vin, brand); return JsonStringDeserializer.getVehicleState(vehicleStateResponseString); } + @Override public String requestVehicleStateJson(String vin, String brand) throws NetworkException { String vehicleStateResponseString = fileToString(VEHICLES_STATE); return vehicleStateResponseString; @@ -145,11 +155,13 @@ public String requestVehicleStateJson(String vin, String brand) throws NetworkEx * request charge statistics for electric vehicles * */ + @Override public ChargingStatisticsContainer requestChargeStatistics(String vin, String brand) throws NetworkException { String chargeStatisticsResponseString = requestChargeStatisticsJson(vin, brand); return JsonStringDeserializer.getChargingStatistics(new String(chargeStatisticsResponseString)); } + @Override public String requestChargeStatisticsJson(String vin, String brand) throws NetworkException { String chargeStatisticsResponseString = fileToString(CHARGING_STATISTICS); return chargeStatisticsResponseString; @@ -159,21 +171,25 @@ public String requestChargeStatisticsJson(String vin, String brand) throws Netwo * request charge sessions for electric vehicles * */ + @Override public ChargingSessionsContainer requestChargeSessions(String vin, String brand) throws NetworkException { String chargeSessionsResponseString = requestChargeSessionsJson(vin, brand); return JsonStringDeserializer.getChargingSessions(chargeSessionsResponseString); } + @Override public String requestChargeSessionsJson(String vin, String brand) throws NetworkException { String chargeSessionsResponseString = fileToString(CHARGING_SESSIONS); return chargeSessionsResponseString; } + @Override public ExecutionStatusContainer executeRemoteServiceCall(String vin, String brand, RemoteService service) throws NetworkException { return JsonStringDeserializer.getExecutionStatus(fileToString(REMOTE_SERVICES_CALL)); } + @Override public ExecutionStatusContainer executeRemoteServiceStatusCall(String brand, String eventId) throws NetworkException { return JsonStringDeserializer.getExecutionStatus(fileToString(REMOTE_SERVICES_STATE)); @@ -181,9 +197,9 @@ public ExecutionStatusContainer executeRemoteServiceStatusCall(String brand, Str private String fileToString(String filename) { logger.trace("reading file {}", RESPONSES + vehicleToBeTested + filename); - try (BufferedReader br = new BufferedReader(new InputStreamReader( - MyBMWFileProxy.class.getClassLoader().getResourceAsStream(RESPONSES + vehicleToBeTested + filename), - "UTF-8"))) { + try (BufferedReader br = new BufferedReader( + new InputStreamReader(Objects.requireNonNull(MyBMWFileProxy.class.getClassLoader()) + .getResourceAsStream(RESPONSES + vehicleToBeTested + filename), "UTF-8"))) { StringBuilder buf = new StringBuilder(); String sCurrentLine; @@ -197,4 +213,9 @@ private String fileToString(String filename) { return ""; } } + + @Override + public Instant getNextQuota() { + return Instant.now(); + } } diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/backend/MyBMWHttpProxy.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/backend/MyBMWHttpProxy.java index a7ed8e55df4d7..36c458d39efc5 100644 --- a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/backend/MyBMWHttpProxy.java +++ b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/backend/MyBMWHttpProxy.java @@ -14,11 +14,18 @@ import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; @@ -35,6 +42,7 @@ import org.openhab.binding.mybmw.internal.dto.vehicle.Vehicle; import org.openhab.binding.mybmw.internal.dto.vehicle.VehicleBase; import org.openhab.binding.mybmw.internal.dto.vehicle.VehicleStateContainer; +import org.openhab.binding.mybmw.internal.handler.MyBMWBridgeHandler; import org.openhab.binding.mybmw.internal.handler.auth.MyBMWTokenController; import org.openhab.binding.mybmw.internal.handler.enums.RemoteService; import org.openhab.binding.mybmw.internal.utils.BimmerConstants; @@ -42,7 +50,8 @@ import org.openhab.binding.mybmw.internal.utils.Converter; import org.openhab.binding.mybmw.internal.utils.HTTPConstants; import org.openhab.binding.mybmw.internal.utils.ImageProperties; -import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.auth.client.oauth2.AccessTokenResponse; +import org.openhab.core.auth.client.oauth2.OAuthFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -61,11 +70,17 @@ */ @NonNullByDefault public class MyBMWHttpProxy implements MyBMWProxy { + private static final Pattern TIME_PATTERN = Pattern.compile("\\b(\\d{1,2}:\\d{2}:\\d{2})\\b"); + private static final Pattern DURATION_PATTERN = Pattern.compile("\\b(\\d+)"); + private final Logger logger = LoggerFactory.getLogger(MyBMWHttpProxy.class); + private final HttpClient httpClient; private MyBMWBridgeConfiguration bridgeConfiguration; MyBMWTokenController myBMWTokenController; + private Instant nextQuota = Instant.now(); + /** * URLs taken from * https://github.com/bimmerconnected/bimmer_connected/blob/master/bimmer_connected/const.py @@ -75,11 +90,12 @@ public class MyBMWHttpProxy implements MyBMWProxy { private final String remoteCommandUrl; private final String remoteStatusUrl; - public MyBMWHttpProxy(HttpClientFactory httpClientFactory, MyBMWBridgeConfiguration bridgeConfiguration) { + public MyBMWHttpProxy(MyBMWBridgeHandler bridgeHandler, HttpClient httpClient, OAuthFactory oAuthFactory, + MyBMWBridgeConfiguration bridgeConfiguration) { logger.trace("MyBMWHttpProxy - initialize"); - httpClient = httpClientFactory.getCommonHttpClient(); + this.httpClient = httpClient; - myBMWTokenController = new MyBMWTokenController(bridgeConfiguration, httpClient); + myBMWTokenController = new MyBMWTokenController(bridgeHandler, bridgeConfiguration, httpClient, oAuthFactory); this.bridgeConfiguration = bridgeConfiguration; @@ -102,9 +118,10 @@ public void setBridgeConfiguration(MyBMWBridgeConfiguration bridgeConfiguration) /** * requests all vehicles - * + * * @return list of vehicles */ + @Override public List requestVehicles() throws NetworkException { List vehicles = new ArrayList<>(); List vehiclesBase = requestVehiclesBase(); @@ -128,6 +145,7 @@ public List requestVehicles() throws NetworkException { * @param brand * @return the vehicles of one brand */ + @Override public List requestVehiclesBase(String brand) throws NetworkException { String vehicleResponseString = requestVehiclesBaseJson(brand); return JsonStringDeserializer.getVehicleBaseList(vehicleResponseString); @@ -139,6 +157,7 @@ public List requestVehiclesBase(String brand) throws NetworkExcepti * @param brand * @return the base vehicle information as JSON string */ + @Override public String requestVehiclesBaseJson(String brand) throws NetworkException { byte[] vehicleResponse = get(vehicleUrl, brand, null, HTTPConstants.CONTENT_TYPE_JSON); String vehicleResponseString = new String(vehicleResponse, Charset.defaultCharset()); @@ -150,6 +169,7 @@ public String requestVehiclesBaseJson(String brand) throws NetworkException { * * @return the list of vehicles */ + @Override public List requestVehiclesBase() throws NetworkException { List vehicles = new ArrayList<>(); @@ -158,8 +178,11 @@ public List requestVehiclesBase() throws NetworkException { vehicles.addAll(requestVehiclesBase(brand)); Thread.sleep(10000); - } catch (Exception e) { - logger.warn("error retrieving the base vehicles for brand {}: {}", brand, e.getMessage()); + } catch (NetworkException ne) { + logger.warn("error retrieving the base vehicles for brand {}: {}", brand, ne.getMessage()); + throw ne; + } catch (InterruptedException ie) { + throw new NetworkException(ie); } } @@ -174,6 +197,7 @@ public List requestVehiclesBase() throws NetworkException { * @param props the image properties * @return the image as a byte array */ + @Override public byte[] requestImage(String vin, String brand, ImageProperties props) throws NetworkException { final String localImageUrl = "https://" + BimmerConstants.EADRAX_SERVER_MAP.get(bridgeConfiguration.getRegion()) + BimmerConstants.IMAGE_URL.replace(BimmerConstants.PARAM_VIN, vin) + props.viewport; @@ -187,6 +211,7 @@ public byte[] requestImage(String vin, String brand, ImageProperties props) thro * @param brand * @return the vehicle state */ + @Override public VehicleStateContainer requestVehicleState(String vin, String brand) throws NetworkException { String vehicleStateResponseString = requestVehicleStateJson(vin, brand); return JsonStringDeserializer.getVehicleState(vehicleStateResponseString); @@ -199,6 +224,7 @@ public VehicleStateContainer requestVehicleState(String vin, String brand) throw * @param brand * @return the vehicle state as string */ + @Override public String requestVehicleStateJson(String vin, String brand) throws NetworkException { byte[] vehicleStateResponse = get(vehicleStateUrl, brand, vin, HTTPConstants.CONTENT_TYPE_JSON); String vehicleStateResponseString = new String(vehicleStateResponse, Charset.defaultCharset()); @@ -207,11 +233,12 @@ public String requestVehicleStateJson(String vin, String brand) throws NetworkEx /** * request charge statistics for electric vehicles - * + * * @param vin * @param brand * @return the charge statistics */ + @Override public ChargingStatisticsContainer requestChargeStatistics(String vin, String brand) throws NetworkException { String chargeStatisticsResponseString = requestChargeStatisticsJson(vin, brand); return JsonStringDeserializer.getChargingStatistics(new String(chargeStatisticsResponseString)); @@ -219,11 +246,12 @@ public ChargingStatisticsContainer requestChargeStatistics(String vin, String br /** * request charge statistics for electric vehicles as JSON - * + * * @param vin * @param brand * @return the charge statistics as JSON string */ + @Override public String requestChargeStatisticsJson(String vin, String brand) throws NetworkException { MultiMap<@Nullable String> chargeStatisticsParams = new MultiMap<>(); chargeStatisticsParams.put("vin", vin); @@ -243,6 +271,7 @@ public String requestChargeStatisticsJson(String vin, String brand) throws Netwo * @param brand * @return the charge sessions */ + @Override public ChargingSessionsContainer requestChargeSessions(String vin, String brand) throws NetworkException { String chargeSessionsResponseString = requestChargeSessionsJson(vin, brand); return JsonStringDeserializer.getChargingSessions(chargeSessionsResponseString); @@ -255,6 +284,7 @@ public ChargingSessionsContainer requestChargeSessions(String vin, String brand) * @param brand * @return the charge sessions as JSON string */ + @Override public String requestChargeSessionsJson(String vin, String brand) throws NetworkException { MultiMap<@Nullable String> chargeSessionsParams = new MultiMap<>(); chargeSessionsParams.put("vin", vin); @@ -276,6 +306,7 @@ public String requestChargeSessionsJson(String vin, String brand) throws Network * @param service the service which should be executed * @return the running service execution for status checks */ + @Override public ExecutionStatusContainer executeRemoteServiceCall(String vin, String brand, RemoteService service) throws NetworkException { String executionUrl = remoteCommandUrl + vin + "/" + service.getCommand(); @@ -292,6 +323,7 @@ public ExecutionStatusContainer executeRemoteServiceCall(String vin, String bran * @param eventid the ID of the currently running service execution * @return the running service execution for status checks */ + @Override public ExecutionStatusContainer executeRemoteServiceStatusCall(String brand, String eventId) throws NetworkException { String executionUrl = remoteStatusUrl + Constants.QUESTION + "eventId=" + eventId; @@ -345,6 +377,10 @@ private synchronized byte[] call(final String url, final boolean post, final Str final @Nullable String vin, final String contentType, final @Nullable String body) throws NetworkException { byte[] responseByteArray = "".getBytes(); + if (Instant.now().isBefore(nextQuota)) { + throw new NetworkException(url, -1, "Quota Exceeded, waiting for quota renewal", body); + } + // return in case of unknown brand if (!BimmerConstants.REQUESTED_BRANDS.contains(brand.toLowerCase())) { logger.warn("Unknown Brand {}", brand); @@ -352,7 +388,12 @@ private synchronized byte[] call(final String url, final boolean post, final Str } // if no token is available, no request can be triggered - if (Constants.EMPTY.equals(myBMWTokenController.getToken().getBearerToken())) { + AccessTokenResponse tokenResponse = myBMWTokenController.getToken(); + String bearerToken = tokenResponse.getAccessToken(); + bearerToken = (bearerToken == null) || bearerToken.isEmpty() ? Constants.EMPTY + : new StringBuilder(tokenResponse.getTokenType()).append(Constants.SPACE).append(bearerToken) + .toString(); + if (tokenResponse.isExpired(Instant.now(), 1)) { logger.warn("The login failed, no token is available"); throw new NetworkException("The login failed, no token is available"); } @@ -365,7 +406,7 @@ private synchronized byte[] call(final String url, final boolean post, final Str req = httpClient.newRequest(url); } - req.header(HttpHeader.AUTHORIZATION, myBMWTokenController.getToken().getBearerToken()); + req.header(HttpHeader.AUTHORIZATION, bearerToken); req.header(HTTPConstants.HEADER_X_USER_AGENT, String.format(BimmerConstants.X_USER_AGENT, brand.toLowerCase(), BimmerConstants.APP_VERSIONS.get(bridgeConfiguration.getRegion()), bridgeConfiguration.getRegion())); req.header(HttpHeader.ACCEPT_LANGUAGE, bridgeConfiguration.getLanguage()); @@ -375,6 +416,7 @@ private synchronized byte[] call(final String url, final boolean post, final Str try { ContentResponse response = req.timeout(HTTPConstants.HTTP_TIMEOUT_SEC, TimeUnit.SECONDS).send(); if (response.getStatus() >= 300) { + setNextQuotaAttempt(response); responseByteArray = "".getBytes(); NetworkException exception = new NetworkException(url, response.getStatus(), ResponseContentAnonymizer.anonymizeResponseContent(response.getContentAsString()), body); @@ -405,6 +447,45 @@ private synchronized byte[] call(final String url, final boolean post, final Str return responseByteArray; } + private void setNextQuotaAttempt(ContentResponse response) { + int status = response.getStatus(); + String message = JsonStringDeserializer.deserializeString(response.getContentAsString(), + ResponseErrorContainer.class).message; + Duration delay = Duration.ofSeconds(5); // minimum wait of 5 seconds + switch (status) { + case 403: + // response contains the amount of time before quota is replenished + try { + Matcher matcher = TIME_PATTERN.matcher(message); + if (matcher.find()) { + LocalTime time = LocalTime.parse(matcher.group(1), DateTimeFormatter.ISO_LOCAL_TIME); + delay = Duration.between(LocalTime.MIDNIGHT, time); + } + } catch (DateTimeParseException e) { + // we couldn't parse, continue with default + } + break; + case 429: + // response may contain the number of seconds to wait before quota is replenished + Matcher matcher = DURATION_PATTERN.matcher(message); + if (matcher.find()) { + delay = Duration.ofSeconds(Long.valueOf(matcher.group(1))); + } else { + delay = Duration.ofMinutes(5); + } + break; + default: + return; + } + logger.debug("next call allowed in {}:{}:{}", delay.toHours(), delay.toMinutesPart(), delay.toSecondsPart()); + nextQuota = Instant.now().plus(delay); + } + + @Override + public Instant getNextQuota() { + return nextQuota; + } + private void logResponse(@Nullable String url, @Nullable String fingerprint, @Nullable String body) { logger.debug("###### Request URL - BEGIN ######"); logger.debug("{}", url); diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/backend/MyBMWProxy.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/backend/MyBMWProxy.java index c1b816dbff889..ec0fe5aaf5a27 100644 --- a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/backend/MyBMWProxy.java +++ b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/backend/MyBMWProxy.java @@ -12,6 +12,7 @@ */ package org.openhab.binding.mybmw.internal.handler.backend; +import java.time.Instant; import java.util.List; import org.eclipse.jdt.annotation.NonNullByDefault; @@ -27,7 +28,7 @@ /** * this is the interface for requesting the myBMW responses - * + * * @author Martin Grassl - Initial Contribution */ @NonNullByDefault @@ -92,4 +93,6 @@ ExecutionStatusContainer executeRemoteServiceCall(String vin, String brand, Remo throws NetworkException; ExecutionStatusContainer executeRemoteServiceStatusCall(String brand, String eventId) throws NetworkException; + + Instant getNextQuota(); } diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/backend/NetworkException.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/backend/NetworkException.java index abc2fe595da3f..0539b0aa800fb 100644 --- a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/backend/NetworkException.java +++ b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/backend/NetworkException.java @@ -35,6 +35,7 @@ public NetworkException() { } public NetworkException(String url, int status, @Nullable String reason, @Nullable String body) { + super(reason); this.url = url; this.status = status; this.reason = reason != null ? reason : ""; diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/backend/ResponseErrorContainer.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/backend/ResponseErrorContainer.java new file mode 100644 index 0000000000000..d2a13d98c48cb --- /dev/null +++ b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/backend/ResponseErrorContainer.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2010-2025 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.mybmw.internal.handler.backend; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.mybmw.internal.utils.Constants; + +/** + * The {@link ResponseErrorContainer} Data Transfer Object + * + * @author Mark Herwege - Initial contribution + */ +@NonNullByDefault +public class ResponseErrorContainer { + public String status = Constants.EMPTY; + public String message = Constants.EMPTY; + + @Override + public String toString() { + return "ResponseError [status=" + status + ", message=" + message + "]"; + } +} diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/utils/BimmerConstants.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/utils/BimmerConstants.java index 1734a397df691..17b7387c50501 100644 --- a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/utils/BimmerConstants.java +++ b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/utils/BimmerConstants.java @@ -68,7 +68,6 @@ public interface BimmerConstants { // see const.py of bimmer_constants: user-agent; brand; app_version; region static final String X_USER_AGENT = "android(AP2A.240605.024);%s;%s;%s"; - static final String LOGIN_NONCE = "login_nonce"; static final String AUTHORIZATION_CODE = "authorization_code"; static final String REFRESH_TOKEN = "refresh_token"; diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/utils/HTTPConstants.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/utils/HTTPConstants.java index 8d0af5180d12b..7143c324a3a82 100644 --- a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/utils/HTTPConstants.java +++ b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/utils/HTTPConstants.java @@ -57,5 +57,6 @@ public interface HTTPConstants { static final String HEADER_X_IDENTITY_PROVIDER = "x-identity-provider"; static final String HEADER_X_CORRELATION_ID = "x-correlation-id"; static final String HEADER_BMW_CORRELATION_ID = "bmw-correlation-id"; + static final String HEADER_BMW_SESSION_ID = "bmw-session-id"; static final String HEADER_BMW_VIN = "bmw-vin"; } diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/utils/MyBMWConfigurationChecker.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/utils/MyBMWConfigurationChecker.java deleted file mode 100644 index b98e1a6db6989..0000000000000 --- a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/utils/MyBMWConfigurationChecker.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (c) 2010-2025 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.mybmw.internal.utils; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.openhab.binding.mybmw.internal.MyBMWBridgeConfiguration; - -/** - * - * checks if the configuration is valid - * - * @author Bernd Weymann - Initial contribution - * @author Martin Grassl - extracted to own class - */ -@NonNullByDefault -public final class MyBMWConfigurationChecker { - public static boolean checkInitialConfiguration(MyBMWBridgeConfiguration config) { - if (config.getUserName().isBlank() || config.getPassword().isBlank() || config.getHcaptchatoken().isBlank()) { - return false; - } else { - return BimmerConstants.EADRAX_SERVER_MAP.containsKey(config.getRegion()); - } - } -} diff --git a/bundles/org.openhab.binding.mybmw/src/main/resources/OH-INF/config/bridge-config.xml b/bundles/org.openhab.binding.mybmw/src/main/resources/OH-INF/config/bridge-config.xml index c198709b659e1..1ace6c847c213 100644 --- a/bundles/org.openhab.binding.mybmw/src/main/resources/OH-INF/config/bridge-config.xml +++ b/bundles/org.openhab.binding.mybmw/src/main/resources/OH-INF/config/bridge-config.xml @@ -14,11 +14,6 @@ MyBMW Password password - - - Captcha-Token for login (see https://bimmer-connected.readthedocs.io/en/stable/captcha.html) - hcaptchatoken - Select Region in order to connect to the appropriate BMW Server @@ -35,5 +30,22 @@ true AUTODETECT + + + Captcha-Token for login (see https://bimmer-connected.readthedocs.io/en/stable/captcha.html) + true + + + + IP address for openHAB callback URL, defaults to IP of openHAB host + network-address + true + + + + Port Number for openHAB callback URL, default 8090 + 8090 + true + diff --git a/bundles/org.openhab.binding.mybmw/src/main/resources/OH-INF/i18n/mybmw.properties b/bundles/org.openhab.binding.mybmw/src/main/resources/OH-INF/i18n/mybmw.properties index 8a501110b04f4..da3efed7c7655 100644 --- a/bundles/org.openhab.binding.mybmw/src/main/resources/OH-INF/i18n/mybmw.properties +++ b/bundles/org.openhab.binding.mybmw/src/main/resources/OH-INF/i18n/mybmw.properties @@ -266,3 +266,11 @@ channel-type.mybmw.window-start-channel.description = Start time of charging win channel-type.mybmw.window-start-channel.label = Window Start Time channel-type.mybmw.window-start-channel.state.pattern = %1$tH:%1$tM channel-type.mybmw.windows-channel.label = Overall Window Status + +mybmw.status.authorization-needed = You need to complete a captcha to continue: {0} +mybmw.status.user-details-missing = Username or password configuration is missing +mybmw.status.region-missing = Region configuration is missing +mybmw.status.ip-missing = The callback ip of the the openHAB host could not be retrieved +mybmw.status.vehicle-retrieval-error = Retrieving vehicles failed +mybmw.status.network-error = Retrieving vehicles failed, possible network error +mybmw.status.quota-error = Out of quota, refresh at {0} diff --git a/bundles/org.openhab.binding.mybmw/src/main/resources/captcha/north_america_form.html b/bundles/org.openhab.binding.mybmw/src/main/resources/captcha/north_america_form.html new file mode 100644 index 0000000000000..3ec324ed2c6ef --- /dev/null +++ b/bundles/org.openhab.binding.mybmw/src/main/resources/captcha/north_america_form.html @@ -0,0 +1,50 @@ + + + + + + Form with hCaptcha + + +

+
+ +
+
+ +

+ +
+ + +
+
+

+ + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.mybmw/src/main/resources/captcha/rest_of_world_form.html b/bundles/org.openhab.binding.mybmw/src/main/resources/captcha/rest_of_world_form.html new file mode 100644 index 0000000000000..d3b70f5f8c559 --- /dev/null +++ b/bundles/org.openhab.binding.mybmw/src/main/resources/captcha/rest_of_world_form.html @@ -0,0 +1,50 @@ + + + + + + Form with hCaptcha + + +

+
+ +
+
+ +

+ +
+ + +
+
+

+ + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.mybmw/src/test/java/org/openhab/binding/mybmw/internal/handler/VehicleHandlerTest.java b/bundles/org.openhab.binding.mybmw/src/test/java/org/openhab/binding/mybmw/internal/handler/VehicleHandlerTest.java index 9cb3ecf6392da..113c77e61790a 100644 --- a/bundles/org.openhab.binding.mybmw/src/test/java/org/openhab/binding/mybmw/internal/handler/VehicleHandlerTest.java +++ b/bundles/org.openhab.binding.mybmw/src/test/java/org/openhab/binding/mybmw/internal/handler/VehicleHandlerTest.java @@ -12,15 +12,8 @@ */ package org.openhab.binding.mybmw.internal.handler; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.fail; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; import java.lang.reflect.Field; import java.lang.reflect.Method; @@ -28,8 +21,11 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; +import javax.measure.quantity.Pressure; + import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.junit.jupiter.api.Test; @@ -165,6 +161,7 @@ private void trace() { } } + @SuppressWarnings("unchecked") @Test public void testPressureConversion() { try { @@ -172,9 +169,9 @@ public void testPressureConversion() { calculatePressureMethod.setAccessible(true); State state = (State) calculatePressureMethod.invoke(vehicleHandler, 110); assertInstanceOf(QuantityType.class, state); - assertEquals(1.1, ((QuantityType) state).doubleValue()); + assertEquals(1.1, Objects.requireNonNull((QuantityType) state).doubleValue()); state = (State) calculatePressureMethod.invoke(vehicleHandler, 280); - assertEquals(2.8, ((QuantityType) state).doubleValue()); + assertEquals(2.8, Objects.requireNonNull(((QuantityType) state)).doubleValue()); state = (State) calculatePressureMethod.invoke(vehicleHandler, -1); assertInstanceOf(UnDefType.class, state); diff --git a/bundles/org.openhab.binding.mybmw/src/test/java/org/openhab/binding/mybmw/internal/handler/auth/AuthTest.java b/bundles/org.openhab.binding.mybmw/src/test/java/org/openhab/binding/mybmw/internal/handler/auth/AuthTest.java deleted file mode 100644 index 55a03559d3c93..0000000000000 --- a/bundles/org.openhab.binding.mybmw/src/test/java/org/openhab/binding/mybmw/internal/handler/auth/AuthTest.java +++ /dev/null @@ -1,393 +0,0 @@ -/* - * Copyright (c) 2010-2025 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.mybmw.internal.handler.auth; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; -import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.AUTHORIZATION; -import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.CODE; -import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.CONTENT_TYPE_JSON; -import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.CONTENT_TYPE_URL_ENCODED; -import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.HEADER_X_USER_AGENT; - -import java.nio.charset.StandardCharsets; -import java.security.KeyFactory; -import java.security.MessageDigest; -import java.security.PublicKey; -import java.security.spec.X509EncodedKeySpec; -import java.util.Base64; - -import javax.crypto.Cipher; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jetty.client.HttpClient; -import org.eclipse.jetty.client.api.ContentResponse; -import org.eclipse.jetty.client.api.Request; -import org.eclipse.jetty.client.util.StringContentProvider; -import org.eclipse.jetty.http.HttpHeader; -import org.eclipse.jetty.util.MultiMap; -import org.eclipse.jetty.util.UrlEncoded; -import org.eclipse.jetty.util.ssl.SslContextFactory; -import org.junit.jupiter.api.Test; -import org.openhab.binding.mybmw.internal.MyBMWBridgeConfiguration; -import org.openhab.binding.mybmw.internal.dto.auth.AuthQueryResponse; -import org.openhab.binding.mybmw.internal.dto.auth.AuthResponse; -import org.openhab.binding.mybmw.internal.dto.auth.ChinaPublicKeyResponse; -import org.openhab.binding.mybmw.internal.dto.auth.ChinaTokenExpiration; -import org.openhab.binding.mybmw.internal.dto.auth.ChinaTokenResponse; -import org.openhab.binding.mybmw.internal.handler.backend.JsonStringDeserializer; -import org.openhab.binding.mybmw.internal.util.FileReader; -import org.openhab.binding.mybmw.internal.utils.BimmerConstants; -import org.openhab.binding.mybmw.internal.utils.Constants; -import org.openhab.binding.mybmw.internal.utils.Converter; -import org.openhab.core.io.net.http.HttpClientFactory; -import org.openhab.core.util.StringUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * The {@link AuthTest} test authorization flow - * - * @author Bernd Weymann - Initial contribution - * @author Martin Grassl - moved to other package and updated for v2 - */ -@NonNullByDefault -class AuthTest { - private final Logger logger = LoggerFactory.getLogger(AuthTest.class); - - @Test - public void testAuth() { - String user = "usr"; - String pwd = "pwd"; - - SslContextFactory.Client sslContextFactory = new SslContextFactory.Client(); - HttpClient authHttpClient = new HttpClient(sslContextFactory); - try { - authHttpClient.start(); - Request firstRequest = authHttpClient - .newRequest("https://" + BimmerConstants.EADRAX_SERVER_MAP.get(BimmerConstants.REGION_ROW) - + "/eadrax-ucs/v1/presentation/oauth/config"); - firstRequest.header("ocp-apim-subscription-key", - BimmerConstants.OCP_APIM_KEYS.get(BimmerConstants.REGION_ROW)); - firstRequest.header("x-user-agent", "android(v1.07_20200330);bmw;1.7.0(11152)"); - - ContentResponse firstResponse = firstRequest.send(); - logger.info(firstResponse.getContentAsString()); - AuthQueryResponse aqr = JsonStringDeserializer.deserializeString(firstResponse.getContentAsString(), - AuthQueryResponse.class); - - String verifierBytes = StringUtils.getRandomAlphabetic(64).toLowerCase(); - String codeVerifier = Base64.getUrlEncoder().withoutPadding().encodeToString(verifierBytes.getBytes()); - - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] hash = digest.digest(codeVerifier.getBytes(StandardCharsets.UTF_8)); - String codeChallenge = Base64.getUrlEncoder().withoutPadding().encodeToString(hash); - - String stateBytes = StringUtils.getRandomAlphabetic(16).toLowerCase(); - String state = Base64.getUrlEncoder().withoutPadding().encodeToString(stateBytes.getBytes()); - - String authUrl = aqr.gcdmBaseUrl + BimmerConstants.OAUTH_ENDPOINT; - logger.info(authUrl); - Request loginRequest = authHttpClient.POST(authUrl); - loginRequest.header("Content-Type", "application/x-www-form-urlencoded"); - - MultiMap baseParams = new MultiMap<>(); - baseParams.put("client_id", aqr.clientId); - baseParams.put("response_type", "code"); - baseParams.put("redirect_uri", aqr.returnUrl); - baseParams.put("state", state); - baseParams.put("nonce", "login_nonce"); - baseParams.put("scope", String.join(" ", aqr.scopes)); - baseParams.put("code_challenge", codeChallenge); - baseParams.put("code_challenge_method", "S256"); - - MultiMap loginParams = new MultiMap<>(baseParams); - loginParams.put("grant_type", "authorization_code"); - loginParams.put("username", user); - loginParams.put("password", pwd); - loginRequest.content(new StringContentProvider(CONTENT_TYPE_URL_ENCODED, - UrlEncoded.encode(loginParams, StandardCharsets.UTF_8, false), StandardCharsets.UTF_8)); - ContentResponse secondResonse = loginRequest.send(); - logger.info(secondResonse.getContentAsString()); - String authCode = getAuthCode(secondResonse.getContentAsString()); - logger.info(authCode); - - MultiMap authParams = new MultiMap<>(baseParams); - authParams.put("authorization", authCode); - Request authRequest = authHttpClient.POST(authUrl).followRedirects(false); - authRequest.header("Content-Type", "application/x-www-form-urlencoded"); - authRequest.content(new StringContentProvider(CONTENT_TYPE_URL_ENCODED, - UrlEncoded.encode(authParams, StandardCharsets.UTF_8, false), StandardCharsets.UTF_8)); - ContentResponse authResponse = authRequest.send(); - logger.info("{}", authResponse.getHeaders()); - logger.info("Response " + authResponse.getHeaders().get(HttpHeader.LOCATION)); - String code = AuthTest.codeFromUrl(authResponse.getHeaders().get(HttpHeader.LOCATION)); - logger.info("Code " + code); - logger.info("Auth"); - - logger.info(aqr.tokenEndpoint); - - Request codeRequest = authHttpClient.POST(aqr.tokenEndpoint); - String basicAuth = "Basic " - + Base64.getUrlEncoder().encodeToString((aqr.clientId + ":" + aqr.clientSecret).getBytes()); - logger.info(basicAuth); - codeRequest.header("Content-Type", "application/x-www-form-urlencoded"); - codeRequest.header(AUTHORIZATION, basicAuth); - - MultiMap codeParams = new MultiMap<>(); - codeParams.put("code", code); - codeParams.put("code_verifier", codeVerifier); - codeParams.put("redirect_uri", aqr.returnUrl); - codeParams.put("grant_type", "authorization_code"); - codeRequest.content(new StringContentProvider(CONTENT_TYPE_URL_ENCODED, - UrlEncoded.encode(codeParams, StandardCharsets.UTF_8, false), StandardCharsets.UTF_8)); - ContentResponse codeResponse = codeRequest.send(); - logger.info(codeResponse.getContentAsString()); - AuthResponse ar = JsonStringDeserializer.deserializeString(codeResponse.getContentAsString(), - AuthResponse.class); - Token t = new Token(); - t.setType(ar.tokenType); - t.setToken(ar.accessToken); - t.setExpiration(ar.expiresIn); - logger.info(t.getBearerToken()); - - /** - * REQUEST CONTENT - */ - HttpClient apiHttpClient = new HttpClient(sslContextFactory); - apiHttpClient.start(); - - MultiMap vehicleParams = new MultiMap<>(); - vehicleParams.put("tireGuardMode", "ENABLED"); - vehicleParams.put("appDateTime", Long.toString(System.currentTimeMillis())); - vehicleParams.put("apptimezone", "60"); - - String params = UrlEncoded.encode(vehicleParams, StandardCharsets.UTF_8, false); - - String vehicleUrl = "https://" + BimmerConstants.EADRAX_SERVER_MAP.get(BimmerConstants.REGION_ROW) - + "/eadrax-vcs/v1/vehicles"; - logger.info(vehicleUrl); - Request vehicleRequest = apiHttpClient.newRequest(vehicleUrl + "?" + params); - - vehicleRequest.header(HttpHeader.AUTHORIZATION, t.getBearerToken()); - vehicleRequest.header("accept", "application/json"); - vehicleRequest.header("accept-language", "de"); - vehicleRequest.header("x-user-agent", "android(v1.07_20200330);bmw;1.7.0(11152)"); - - ContentResponse vehicleResponse = vehicleRequest.send(); - logger.info("Vehicle Status {} {}", vehicleResponse.getStatus(), vehicleResponse.getContentAsString()); - - /** - * CHARGE STATISTICS - */ - MultiMap chargeStatisticsParams = new MultiMap<>(); - chargeStatisticsParams.put("vin", "WBY1Z81040V905639"); - chargeStatisticsParams.put("currentDate", Converter.getCurrentISOTime()); - params = UrlEncoded.encode(chargeStatisticsParams, StandardCharsets.UTF_8, false); - - String chargeStatisticsUrl = "https://" + BimmerConstants.EADRAX_SERVER_MAP.get(BimmerConstants.REGION_ROW) - + "/eadrax-chs/v1/charging-statistics"; - Request chargeStatisticsRequest = apiHttpClient.newRequest(chargeStatisticsUrl) - .param("vin", "WBY1Z81040V905639").param("currentDate", Converter.getCurrentISOTime()); - logger.info("{}", chargeStatisticsUrl); - - chargeStatisticsRequest.header(HttpHeader.AUTHORIZATION, t.getBearerToken()); - chargeStatisticsRequest.header("accept", "application/json"); - chargeStatisticsRequest.header("x-user-agent", "android(v1.07_20200330);bmw;1.7.0(11152)"); - chargeStatisticsRequest.header("accept-language", "de"); - - logger.info("{}", params); - chargeStatisticsRequest - .content(new StringContentProvider(CONTENT_TYPE_URL_ENCODED, params, StandardCharsets.UTF_8)); - - ContentResponse chargeStatisticsResponse = chargeStatisticsRequest.send(); - logger.info("{}", chargeStatisticsResponse.getStatus()); - logger.info("{}", chargeStatisticsResponse.getReason()); - logger.info("{}", chargeStatisticsResponse.getContentAsString()); - - /** - * CHARGE SESSIONS - */ - MultiMap chargeSessionsParams = new MultiMap<>(); - chargeSessionsParams.put("vin", "WBY1Z81040V905639"); - chargeSessionsParams.put("maxResults", "40"); - chargeSessionsParams.put("include_date_picker", "true"); - - params = UrlEncoded.encode(chargeSessionsParams, StandardCharsets.UTF_8, false); - - String chargeSessionsUrl = "https://" + BimmerConstants.EADRAX_SERVER_MAP.get(BimmerConstants.REGION_ROW) - + "/eadrax-chs/v1/charging-sessions"; - Request chargeSessionsRequest = apiHttpClient.newRequest(chargeSessionsUrl + "?" + params); - logger.info("{}", chargeSessionsUrl); - - chargeSessionsRequest.header(HttpHeader.AUTHORIZATION, t.getBearerToken()); - chargeSessionsRequest.header("accept", "application/json"); - chargeSessionsRequest.header("x-user-agent", "android(v1.07_20200330);bmw;1.7.0(11152)"); - chargeSessionsRequest.header("accept-language", "de"); - - logger.info("{}", params); - - ContentResponse chargeSessionsResponse = chargeSessionsRequest.send(); - logger.info("{}", chargeSessionsResponse.getStatus()); - logger.info("{}", chargeSessionsResponse.getReason()); - logger.info("{}", chargeSessionsResponse.getContentAsString()); - - String chargingControlUrl = "https://" + BimmerConstants.EADRAX_SERVER_MAP.get(BimmerConstants.REGION_ROW) - + "/eadrax-vrccs/v2/presentation/remote-commands/WBY1Z81040V905639/charging-control"; - Request chargingControlRequest = apiHttpClient.POST(chargingControlUrl); - logger.info("{}", chargingControlUrl); - - chargingControlRequest.header(HttpHeader.AUTHORIZATION, t.getBearerToken()); - chargingControlRequest.header("accept", "application/json"); - chargingControlRequest.header("x-user-agent", "android(v1.07_20200330);bmw;1.7.0(11152)"); - chargingControlRequest.header("accept-language", "de"); - chargingControlRequest.header("Content-Type", CONTENT_TYPE_JSON); - - } catch (Exception e) { - logger.error("{}", e.getMessage()); - } - } - - 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 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.isEmpty()) { - String val = value.get(0); - if (key.endsWith(CODE)) { - codeFound.append(val); - } - } - }); - return codeFound.toString(); - } - - @Test - public void testJWTDeserialze() { - String accessTokenResponseStr = FileReader.fileToString("responses/auth/auth_cn_login_pwd.json"); - ChinaTokenResponse cat = JsonStringDeserializer.deserializeString(accessTokenResponseStr, - ChinaTokenResponse.class); - - // https://www.baeldung.com/java-jwt-token-decode - String token = cat.data.accessToken; - String[] chunks = token.split("\\."); - String tokenJwtDecodeStr = new String(Base64.getUrlDecoder().decode(chunks[1])); - ChinaTokenExpiration cte = JsonStringDeserializer.deserializeString(tokenJwtDecodeStr, - ChinaTokenExpiration.class); - Token t = new Token(); - t.setToken(token); - t.setType(cat.data.tokenType); - t.setExpirationTotal(cte.exp); - assertEquals( - "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJEVU1NWSQxJEEkMTYzNzcwNzkxNjc4MiIsIm5iZiI6MTYzNzcwNzkxNiwiZXhwIjoxNjM3NzExMjE2LCJpYXQiOjE2Mzc3MDc5MTZ9.hpi-P97W68g7avGwu9dcBRapIsaG4F8MwOdPHe6PuTA", - t.getBearerToken(), "Token"); - } - - public void testChina() { - SslContextFactory.Client sslContextFactory = new SslContextFactory.Client(); - HttpClient authHttpClient = new HttpClient(sslContextFactory); - try { - authHttpClient.start(); - HttpClientFactory mockHCF = mock(HttpClientFactory.class); - when(mockHCF.getCommonHttpClient()).thenReturn(authHttpClient); - MyBMWBridgeConfiguration config = new MyBMWBridgeConfiguration(); - config.setRegion(BimmerConstants.REGION_CHINA); - config.setUserName("Hello User"); - config.setPassword("Hello Password"); - MyBMWTokenController tokenHandler = new MyBMWTokenController(config, authHttpClient); - Token token = tokenHandler.getToken(); - assertNotNull(token); - assertNotNull(token.getBearerToken()); - } catch (Exception e) { - logger.warn("Exception: " + e.getMessage()); - } - } - - @Test - public void testPublicKey() { - String publicKeyResponseStr = FileReader.fileToString("responses/auth/china-key.json"); - ChinaPublicKeyResponse pkr = JsonStringDeserializer.deserializeString(publicKeyResponseStr, - ChinaPublicKeyResponse.class); - String publicKeyStr = pkr.data.value; - String publicKeyPEM = publicKeyStr.replace("-----BEGIN PUBLIC KEY-----", "") - .replaceAll(System.lineSeparator(), "").replace("-----END PUBLIC KEY-----", "").replace("\\r", "") - .replace("\\n", "").trim(); - byte[] encoded = Base64.getDecoder().decode(publicKeyPEM); - X509EncodedKeySpec spec = new X509EncodedKeySpec(encoded); - KeyFactory kf; - try { - kf = KeyFactory.getInstance("RSA"); - PublicKey publicKey = kf.generatePublic(spec); - // https://www.thexcoders.net/java-ciphers-rsa/ - Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); - cipher.init(Cipher.ENCRYPT_MODE, publicKey); - byte[] encryptedBytes = cipher.doFinal("Hello World".getBytes()); - String encodedPassword = Base64.getEncoder().encodeToString(encryptedBytes); - logger.info(encodedPassword); - } catch (Exception e) { - assertTrue(false, "Excpetion: " + e.getMessage()); - } - } - - public void testChinaToken() { - SslContextFactory.Client sslContextFactory = new SslContextFactory.Client(); - HttpClient authHttpClient = new HttpClient(sslContextFactory); - try { - authHttpClient.start(); - String url = "https://" + BimmerConstants.EADRAX_SERVER_MAP.get(BimmerConstants.REGION_CHINA) - + BimmerConstants.CHINA_PUBLIC_KEY; - Request oauthQueryRequest = authHttpClient.newRequest(url); - oauthQueryRequest.header(HEADER_X_USER_AGENT, - String.format(BimmerConstants.BRAND_BMW, BimmerConstants.BRAND_BMW, BimmerConstants.REGION_ROW)); - - ContentResponse publicKeyResponse = oauthQueryRequest.send(); - ChinaPublicKeyResponse pkr = JsonStringDeserializer - .deserializeString(publicKeyResponse.getContentAsString(), ChinaPublicKeyResponse.class); - // https://stackoverflow.com/questions/11410770/load-rsa-public-key-from-file - - String publicKeyStr = pkr.data.value; - - String publicKeyPEM = publicKeyStr.replace("-----BEGIN PUBLIC KEY-----", "") - .replaceAll(System.lineSeparator(), "").replace("-----END PUBLIC KEY-----", ""); - byte[] encoded = Base64.getDecoder().decode(publicKeyPEM); - X509EncodedKeySpec spec = new X509EncodedKeySpec(encoded); - - KeyFactory kf = KeyFactory.getInstance("RSA"); - PublicKey publicKey = kf.generatePublic(spec); - // https://www.thexcoders.net/java-ciphers-rsa/ - Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); - cipher.init(Cipher.ENCRYPT_MODE, publicKey); - byte[] encryptedBytes = cipher.doFinal("Hello World".getBytes()); - String encodedPassword = Base64.getEncoder().encodeToString(encryptedBytes); - logger.info(encodedPassword); - } catch (Exception e) { - assertTrue(false, "Excpetion: " + e.getMessage()); - } - } -} diff --git a/bundles/org.openhab.binding.mybmw/src/test/java/org/openhab/binding/mybmw/internal/handler/backend/MyBMWHttpProxyTest.java b/bundles/org.openhab.binding.mybmw/src/test/java/org/openhab/binding/mybmw/internal/handler/backend/MyBMWHttpProxyTest.java index af8b09d4c06b6..3db0051f2ad8c 100644 --- a/bundles/org.openhab.binding.mybmw/src/test/java/org/openhab/binding/mybmw/internal/handler/backend/MyBMWHttpProxyTest.java +++ b/bundles/org.openhab.binding.mybmw/src/test/java/org/openhab/binding/mybmw/internal/handler/backend/MyBMWHttpProxyTest.java @@ -12,14 +12,11 @@ */ package org.openhab.binding.mybmw.internal.handler.backend; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.fail; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyString; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.doReturn; +import java.time.Instant; import java.util.List; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeoutException; @@ -30,19 +27,23 @@ import org.eclipse.jetty.client.api.Request; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; import org.mockito.Mockito; import org.openhab.binding.mybmw.internal.MyBMWBridgeConfiguration; import org.openhab.binding.mybmw.internal.dto.remote.ExecutionStatusContainer; import org.openhab.binding.mybmw.internal.dto.vehicle.Vehicle; import org.openhab.binding.mybmw.internal.dto.vehicle.VehicleBase; import org.openhab.binding.mybmw.internal.dto.vehicle.VehicleStateContainer; +import org.openhab.binding.mybmw.internal.handler.MyBMWBridgeHandler; import org.openhab.binding.mybmw.internal.handler.auth.MyBMWTokenController; -import org.openhab.binding.mybmw.internal.handler.auth.Token; import org.openhab.binding.mybmw.internal.handler.enums.RemoteService; import org.openhab.binding.mybmw.internal.util.FileReader; import org.openhab.binding.mybmw.internal.utils.BimmerConstants; import org.openhab.binding.mybmw.internal.utils.ImageProperties; -import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.auth.client.oauth2.AccessTokenResponse; +import org.openhab.core.auth.client.oauth2.OAuthFactory; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ThingUID; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -50,7 +51,7 @@ /** * this test tests the different MyBMWProxy request types (GET, POST) and their errors (SUCCESS, other) - * + * * @author Martin Grassl - initial contribution */ @NonNullByDefault @@ -162,14 +163,17 @@ void testSuccessfulImage() { @Test void testSuccessfulGetVehicles() { - HttpClientFactory httpClientFactoryMock = Mockito.mock(HttpClientFactory.class); HttpClient httpClientMock = Mockito.mock(HttpClient.class); - Mockito.when(httpClientFactoryMock.getCommonHttpClient()).thenReturn(httpClientMock); + OAuthFactory oAuthFactoryMock = Mockito.mock(OAuthFactory.class); + MyBMWBridgeHandler myBMWBridgeHandlerMock = Mockito.mock(MyBMWBridgeHandler.class); + Bridge myBMWBridgeMock = Mockito.mock(Bridge.class); + Mockito.when(myBMWBridgeHandlerMock.getThing()).thenReturn(myBMWBridgeMock); + Mockito.when(myBMWBridgeMock.getUID()).thenReturn(new ThingUID("mybmw", "bridge", "test")); MyBMWBridgeConfiguration myBMWBridgeConfiguration = new MyBMWBridgeConfiguration(); - MyBMWHttpProxy myBMWProxyMock = Mockito - .spy(new MyBMWHttpProxy(httpClientFactoryMock, myBMWBridgeConfiguration)); + MyBMWHttpProxy myBMWProxyMock = Mockito.spy( + new MyBMWHttpProxy(myBMWBridgeHandlerMock, httpClientMock, oAuthFactoryMock, myBMWBridgeConfiguration)); String vehiclesBaseString = FileReader.fileToString("responses/BEV/vehicles_base.json"); List baseVehicles = JsonStringDeserializer.getVehicleBaseList(vehiclesBaseString); @@ -195,18 +199,24 @@ void testSuccessfulGetVehicles() { } MyBMWHttpProxy generateMyBmwProxy(int statuscode, String responseContent) { - HttpClientFactory httpClientFactoryMock = Mockito.mock(HttpClientFactory.class); HttpClient httpClientMock = Mockito.mock(HttpClient.class); + OAuthFactory oAuthFactoryMock = Mockito.mock(OAuthFactory.class); + MyBMWBridgeHandler myBMWBridgeHandlerMock = Mockito.mock(MyBMWBridgeHandler.class); + Bridge myBMWBridgeMock = Mockito.mock(Bridge.class); + Mockito.when(myBMWBridgeHandlerMock.getThing()).thenReturn(myBMWBridgeMock); + Mockito.when(myBMWBridgeMock.getUID()).thenReturn(new ThingUID("mybmw", "bridge", "test")); Request requestMock = Mockito.mock(Request.class); MyBMWTokenController bmwTokenControllerMock = Mockito.mock(MyBMWTokenController.class); - Mockito.when(httpClientMock.newRequest(Mockito.anyString())).thenReturn(requestMock); - Mockito.when(httpClientMock.POST(Mockito.anyString())).thenReturn(requestMock); - - Token token = Mockito.mock(Token.class); - Mockito.when(token.getBearerToken()).thenReturn("blah"); + Mockito.when(httpClientMock.newRequest(ArgumentMatchers.anyString())).thenReturn(requestMock); + Mockito.when(httpClientMock.POST(ArgumentMatchers.anyString())).thenReturn(requestMock); + + AccessTokenResponse token = new AccessTokenResponse(); + token.setAccessToken("token"); + token.setTokenType("Bearer"); + token.setCreatedOn(Instant.now()); + token.setExpiresIn(50); Mockito.when(bmwTokenControllerMock.getToken()).thenReturn(token); MyBMWBridgeConfiguration myBMWBridgeConfiguration = new MyBMWBridgeConfiguration(); - Mockito.when(httpClientFactoryMock.getCommonHttpClient()).thenReturn(httpClientMock); ContentResponse responseMock = Mockito.mock(ContentResponse.class); Mockito.when(responseMock.getStatus()).thenReturn(statuscode); @@ -215,15 +225,12 @@ MyBMWHttpProxy generateMyBmwProxy(int statuscode, String responseContent) { try { Mockito.when(requestMock.timeout(anyLong(), any())).thenReturn(requestMock); Mockito.when(requestMock.send()).thenReturn(responseMock); - } catch (InterruptedException e1) { - logger.error(e1.getMessage(), e1); - } catch (TimeoutException e1) { - logger.error(e1.getMessage(), e1); - } catch (ExecutionException e1) { + } catch (InterruptedException | TimeoutException | ExecutionException e1) { logger.error(e1.getMessage(), e1); } - MyBMWHttpProxy proxy = new MyBMWHttpProxy(httpClientFactoryMock, myBMWBridgeConfiguration); + MyBMWHttpProxy proxy = Mockito.spy( + new MyBMWHttpProxy(myBMWBridgeHandlerMock, httpClientMock, oAuthFactoryMock, myBMWBridgeConfiguration)); proxy.myBMWTokenController = bmwTokenControllerMock; return proxy; } diff --git a/bundles/org.openhab.binding.mybmw/src/test/java/org/openhab/binding/mybmw/internal/handler/backend/MyBMWProxyBackendIT.java b/bundles/org.openhab.binding.mybmw/src/test/java/org/openhab/binding/mybmw/internal/handler/backend/MyBMWProxyBackendIT.java index ec2c959f6eb20..c70d7c4a59c08 100644 --- a/bundles/org.openhab.binding.mybmw/src/test/java/org/openhab/binding/mybmw/internal/handler/backend/MyBMWProxyBackendIT.java +++ b/bundles/org.openhab.binding.mybmw/src/test/java/org/openhab/binding/mybmw/internal/handler/backend/MyBMWProxyBackendIT.java @@ -12,9 +12,7 @@ */ package org.openhab.binding.mybmw.internal.handler.backend; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.*; import java.io.File; import java.io.IOException; @@ -27,8 +25,8 @@ import org.eclipse.jetty.http2.client.HTTP2Client; import org.eclipse.jetty.util.ssl.SslContextFactory; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.mockito.Mockito; import org.openhab.binding.mybmw.internal.MyBMWBridgeConfiguration; import org.openhab.binding.mybmw.internal.dto.charge.ChargingSessionsContainer; import org.openhab.binding.mybmw.internal.dto.charge.ChargingStatisticsContainer; @@ -36,11 +34,21 @@ import org.openhab.binding.mybmw.internal.dto.vehicle.Vehicle; import org.openhab.binding.mybmw.internal.dto.vehicle.VehicleBase; import org.openhab.binding.mybmw.internal.dto.vehicle.VehicleStateContainer; +import org.openhab.binding.mybmw.internal.handler.MyBMWBridgeHandler; import org.openhab.binding.mybmw.internal.handler.enums.ExecutionState; import org.openhab.binding.mybmw.internal.handler.enums.RemoteService; import org.openhab.binding.mybmw.internal.utils.BimmerConstants; import org.openhab.binding.mybmw.internal.utils.ImageProperties; +import org.openhab.core.auth.client.oauth2.OAuthFactory; +import org.openhab.core.auth.oauth2client.internal.OAuthFactoryImpl; +import org.openhab.core.auth.oauth2client.internal.OAuthStoreHandler; +import org.openhab.core.auth.oauth2client.internal.OAuthStoreHandlerImpl; import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.storage.Storage; +import org.openhab.core.storage.StorageService; +import org.openhab.core.test.storage.VolatileStorage; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ThingUID; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -53,32 +61,64 @@ * CONNECTED_USER * CONNECTED_PASSWORD * HCAPTCHA_TOKEN - * - * if you want to execute the tests, please set the env variables and remove the disabled annotation + * VEHICLE_COUNT + * VIN + * BRAND + * + * if you want to execute the tests, please set the env variables or static fields with the same name. * * @author Martin Grassl - initial contribution + * @author Mark Herwege - adapted to be able to always compile and run */ @NonNullByDefault public class MyBMWProxyBackendIT { + // The following block of static constants can be used instead of environment variables with the same name. + private static final String CONNECTED_USER = ""; + private static final String CONNECTED_PASSWORD = ""; + private static final String HCAPTCHA_TOKEN = ""; + private static final int VEHICLE_COUNT = 0; // number of vehicles in account + private static final String VIN = ""; // vin to use for testing images on account + private static final String BRAND = ""; // brand to use for testing images on account (bmw or mini) + + private int vehicleCount; + private final Logger logger = LoggerFactory.getLogger(MyBMWProxyBackendIT.class); - public MyBMWProxy initializeProxy() { + private @Nullable MyBMWProxy myBMWProxy; + + public @Nullable MyBMWProxy initializeProxy() { String connectedUser = System.getenv("CONNECTED_USER"); + connectedUser = connectedUser != null ? connectedUser : CONNECTED_USER; String connectedPassword = System.getenv("CONNECTED_PASSWORD"); - String hCaptchaString = System.getenv("HCAPTCHA_TOKEN"); - assertNotNull(connectedUser); - assertNotNull(connectedPassword); - assertNotNull(hCaptchaString); + connectedPassword = connectedPassword != null ? connectedPassword : CONNECTED_PASSWORD; + String hCaptchaToken = System.getenv("HCAPTCHA_TOKEN"); + hCaptchaToken = hCaptchaToken != null ? hCaptchaToken : HCAPTCHA_TOKEN; + String vehicleCount = System.getenv("VEHICLE_COUNT"); + this.vehicleCount = vehicleCount != null ? Integer.valueOf(vehicleCount) : VEHICLE_COUNT; + + if (connectedUser.isEmpty() || connectedPassword.isEmpty() || hCaptchaToken.isEmpty()) { + return null; + } MyBMWBridgeConfiguration configuration = new MyBMWBridgeConfiguration(); configuration.setLanguage("de-DE"); configuration.setRegion(BimmerConstants.REGION_ROW); configuration.setUserName(connectedUser); configuration.setPassword(connectedPassword); - configuration.setHcaptchatoken(hCaptchaString); - - return new MyBMWHttpProxy(new MyHttpClientFactory(), configuration); + configuration.setHCaptchaToken(hCaptchaToken); + + HttpClientFactory clientFactory = new MyHttpClientFactory(); + StorageService storageService = new MyStorageService(); + OAuthStoreHandler oAuthStoreHandler = new OAuthStoreHandlerImpl(storageService); + OAuthFactory oAuthFactory = new OAuthFactoryImpl(clientFactory, oAuthStoreHandler); + MyBMWBridgeHandler myBMWBridgeHandlerMock = Mockito.mock(MyBMWBridgeHandler.class); + Bridge myBMWBridgeMock = Mockito.mock(Bridge.class); + Mockito.when(myBMWBridgeHandlerMock.getThing()).thenReturn(myBMWBridgeMock); + Mockito.when(myBMWBridgeMock.getUID()).thenReturn(new ThingUID("mybmw", "bridge", "test")); + + return new MyBMWHttpProxy(myBMWBridgeHandlerMock, clientFactory.createHttpClient("test"), oAuthFactory, + configuration); } @BeforeEach @@ -93,8 +133,26 @@ public void setupLogger() { } @Test - public void testSequence() { - MyBMWProxy myBMWProxy = initializeProxy(); + public void testAll() { + myBMWProxy = initializeProxy(); + + if (myBMWProxy == null) { + logger.info("not running backend integration tests, no test credentials provided"); + return; + } + + // Do it all in one wrapper test to reuse the same proxy. If done separately, the proxy will be created multiple + // times with the same hCaptchaToken. This does not work. + testGetVehicles(); + testSequence(); + testGetImages(); + } + + private void testSequence() { + MyBMWProxy myBMWProxy = this.myBMWProxy; + if (myBMWProxy == null) { + return; + } // get list of vehicles List vehicles = null; @@ -102,10 +160,11 @@ public void testSequence() { vehicles = myBMWProxy.requestVehiclesBase(); } catch (NetworkException e) { fail(e.getReason(), e); + return; } assertNotNull(vehicles); - assertEquals(2, vehicles.size()); + assertEquals(vehicleCount, vehicles.size()); for (VehicleBase vehicleBase : vehicles) { assertNotNull(vehicleBase.getVin()); @@ -186,24 +245,36 @@ public void testSequence() { } } - @Test - public void testGetImages() { - MyBMWProxy myBMWProxy = initializeProxy(); + private void testGetImages() { + MyBMWProxy myBMWProxy = this.myBMWProxy; + if (myBMWProxy == null) { + return; + } + + String vin = System.getenv("VIN"); + vin = vin != null ? vin : VIN; + String brand = System.getenv("BRAND"); + brand = brand != null ? brand : BRAND; + + if (vin.isEmpty() || brand.isEmpty()) { + return; + } ImageProperties imageProperties = new ImageProperties(); try { imageProperties.viewport = "VehicleStatus"; - byte[] bmwImage = myBMWProxy.requestImage("please_set_here_your_vin", "bmw", imageProperties); + byte[] bmwImage = myBMWProxy.requestImage(vin, brand, imageProperties); Files.write(new File("./" + imageProperties.viewport + ".jpg").toPath(), bmwImage); assertNotNull(bmwImage); } catch (NetworkException | IOException e) { logger.error("error retrieving image", e); + return; } try { imageProperties.viewport = "SideViewLeft"; - byte[] bmwImage = myBMWProxy.requestImage("please_set_here_your_vin", "bmw", imageProperties); + byte[] bmwImage = myBMWProxy.requestImage(vin, brand, imageProperties); Files.write(new File("./" + imageProperties.viewport + ".jpg").toPath(), bmwImage); assertNotNull(bmwImage); } catch (NetworkException | IOException e) { @@ -212,7 +283,7 @@ public void testGetImages() { try { imageProperties.viewport = "AngleSideViewForty"; - byte[] bmwImage = myBMWProxy.requestImage("please_set_here_your_vin", "bmw", imageProperties); + byte[] bmwImage = myBMWProxy.requestImage(vin, brand, imageProperties); Files.write(new File("./" + imageProperties.viewport + ".jpg").toPath(), bmwImage); assertNotNull(bmwImage); } catch (NetworkException | IOException e) { @@ -221,7 +292,7 @@ public void testGetImages() { try { imageProperties.viewport = "FrontView"; - byte[] bmwImage = myBMWProxy.requestImage("please_set_here_your_vin", "bmw", imageProperties); + byte[] bmwImage = myBMWProxy.requestImage(vin, brand, imageProperties); Files.write(new File("./" + imageProperties.viewport + ".jpg").toPath(), bmwImage); assertNotNull(bmwImage); } catch (NetworkException | IOException e) { @@ -230,7 +301,7 @@ public void testGetImages() { try { imageProperties.viewport = "FrontLeft"; - byte[] bmwImage = myBMWProxy.requestImage("please_set_here_your_vin", "bmw", imageProperties); + byte[] bmwImage = myBMWProxy.requestImage(vin, brand, imageProperties); Files.write(new File("./" + imageProperties.viewport + ".jpg").toPath(), bmwImage); assertNotNull(bmwImage); } catch (NetworkException | IOException e) { @@ -239,7 +310,7 @@ public void testGetImages() { try { imageProperties.viewport = "FrontRight"; - byte[] bmwImage = myBMWProxy.requestImage("please_set_here_your_vin", "bmw", imageProperties); + byte[] bmwImage = myBMWProxy.requestImage(vin, brand, imageProperties); Files.write(new File("./" + imageProperties.viewport + ".jpg").toPath(), bmwImage); assertNotNull(bmwImage); } catch (NetworkException | IOException e) { @@ -248,7 +319,7 @@ public void testGetImages() { try { imageProperties.viewport = "RearView"; - byte[] bmwImage = myBMWProxy.requestImage("please_set_here_your_vin", "bmw", imageProperties); + byte[] bmwImage = myBMWProxy.requestImage(vin, brand, imageProperties); Files.write(new File("./" + imageProperties.viewport + ".jpg").toPath(), bmwImage); assertNotNull(bmwImage); } catch (NetworkException | IOException e) { @@ -256,72 +327,82 @@ public void testGetImages() { } } - @Test - @Disabled - public void testGetVehicles() { - MyBMWProxy myBMWProxy = initializeProxy(); + private void testGetVehicles() { + MyBMWProxy myBMWProxy = this.myBMWProxy; + if (myBMWProxy == null) { + return; + } try { List vehicles = myBMWProxy.requestVehicles(); logger.warn(ResponseContentAnonymizer.anonymizeResponseContent(new Gson().toJson(vehicles))); assertNotNull(vehicles); - assertEquals(2, vehicles.size()); + assertEquals(vehicleCount, vehicles.size()); } catch (NetworkException e) { fail(e.getReason(), e); } } -} -/** - * @author Martin Grassl - initial contribution - */ -@NonNullByDefault -class MyHttpClientFactory implements HttpClientFactory { + /** + * @author Martin Grassl - initial contribution + */ + class MyHttpClientFactory implements HttpClientFactory { - private final Logger logger = LoggerFactory.getLogger(MyHttpClientFactory.class); + private final Logger logger = LoggerFactory.getLogger(MyHttpClientFactory.class); - @Override - public HttpClient createHttpClient(String consumerName) { - // Instantiate and configure the SslContextFactory - SslContextFactory.Client sslContextFactory = new SslContextFactory.Client(); + @Override + public HttpClient createHttpClient(String consumerName) { + // Instantiate and configure the SslContextFactory + SslContextFactory.Client sslContextFactory = new SslContextFactory.Client(); - // Instantiate HttpClient with the SslContextFactory - HttpClient httpClient = new HttpClient(sslContextFactory); + // Instantiate HttpClient with the SslContextFactory + HttpClient httpClient = new HttpClient(sslContextFactory); - // Configure HttpClient, for example: - httpClient.setFollowRedirects(false); + // Configure HttpClient, for example: + httpClient.setFollowRedirects(false); - // Start HttpClient - try { - httpClient.start(); - } catch (Exception e) { - logger.error(e.getMessage(), e); + // Start HttpClient + try { + httpClient.start(); + } catch (Exception e) { + logger.error(e.getMessage(), e); + } + + return httpClient; } - return httpClient; - } + @Override + public HttpClient getCommonHttpClient() { + return createHttpClient("test"); + } - @Override - public HttpClient getCommonHttpClient() { - return createHttpClient("test"); - } + @Override + public HTTP2Client createHttp2Client(String arg0) { + throw new UnsupportedOperationException("Unimplemented method 'createHttp2Client'"); + } - @Override - public HTTP2Client createHttp2Client(String arg0) { - // TODO Auto-generated method stub - throw new UnsupportedOperationException("Unimplemented method 'createHttp2Client'"); - } + @Override + public HTTP2Client createHttp2Client(String arg0, @Nullable SslContextFactory arg1) { + throw new UnsupportedOperationException("Unimplemented method 'createHttp2Client'"); + } - @Override - public HTTP2Client createHttp2Client(String arg0, @Nullable SslContextFactory arg1) { - // TODO Auto-generated method stub - throw new UnsupportedOperationException("Unimplemented method 'createHttp2Client'"); + @Override + public HttpClient createHttpClient(String arg0, @Nullable SslContextFactory arg1) { + throw new UnsupportedOperationException("Unimplemented method 'createHttpClient'"); + } } - @Override - public HttpClient createHttpClient(String arg0, @Nullable SslContextFactory arg1) { - // TODO Auto-generated method stub - throw new UnsupportedOperationException("Unimplemented method 'createHttpClient'"); + class MyStorageService implements StorageService { + + @Override + public Storage getStorage(String name) { + return new VolatileStorage(); + } + + @Override + public Storage getStorage(String name, @Nullable ClassLoader classLoader) { + return getStorage("test"); + } } } diff --git a/bundles/org.openhab.binding.mybmw/src/test/java/org/openhab/binding/mybmw/internal/util/FileReader.java b/bundles/org.openhab.binding.mybmw/src/test/java/org/openhab/binding/mybmw/internal/util/FileReader.java index 560d5bd94f014..902cc93e4555d 100644 --- a/bundles/org.openhab.binding.mybmw/src/test/java/org/openhab/binding/mybmw/internal/util/FileReader.java +++ b/bundles/org.openhab.binding.mybmw/src/test/java/org/openhab/binding/mybmw/internal/util/FileReader.java @@ -19,6 +19,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.util.Objects; import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.mybmw.internal.utils.Constants; @@ -34,20 +35,20 @@ public class FileReader { /** * reads a file into a string - * + * * @param filename * @return */ public static String fileToString(String filename) { - try (BufferedReader br = new BufferedReader( - new InputStreamReader(FileReader.class.getClassLoader().getResourceAsStream(filename), "UTF-8"))) { + try (BufferedReader br = new BufferedReader(new InputStreamReader( + Objects.requireNonNull(FileReader.class.getClassLoader()).getResourceAsStream(filename), "UTF-8"))) { StringBuilder buf = new StringBuilder(); String sCurrentLine; while ((sCurrentLine = br.readLine()) != null) { buf.append(sCurrentLine); } - return buf != null ? buf.toString() : ""; + return buf.toString(); } catch (IOException e) { fail("Read failure " + filename, e); } @@ -56,7 +57,7 @@ public static String fileToString(String filename) { /** * reads a file into a byte[] - * + * * @param filename * @return */ @@ -64,8 +65,9 @@ public static byte[] fileToByteArray(String filename) { File file = new File(filename); byte[] bytes = new byte[(int) file.length()]; - try (InputStream is = (FileReader.class.getClassLoader().getResourceAsStream(filename))) { - is.read(bytes); + try (InputStream is = (Objects.requireNonNull(FileReader.class.getClassLoader()) + .getResourceAsStream(filename))) { + Objects.requireNonNull(is).read(bytes); } catch (IOException e) { fail("Read failure " + filename, e); } diff --git a/bundles/org.openhab.binding.mybmw/src/test/java/org/openhab/binding/mybmw/internal/utils/MyBMWConfigurationCheckerTest.java b/bundles/org.openhab.binding.mybmw/src/test/java/org/openhab/binding/mybmw/internal/utils/MyBMWConfigurationCheckerTest.java deleted file mode 100644 index 73163b0386866..0000000000000 --- a/bundles/org.openhab.binding.mybmw/src/test/java/org/openhab/binding/mybmw/internal/utils/MyBMWConfigurationCheckerTest.java +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright (c) 2010-2025 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.mybmw.internal.utils; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.junit.jupiter.api.Test; -import org.openhab.binding.mybmw.internal.MyBMWBridgeConfiguration; - -/** - * - * checks if the configuration checker works fine - * - * @author Bernd Weymann - Initial contribution - * @author Martin Grassl - renamed - */ -@NonNullByDefault -public class MyBMWConfigurationCheckerTest { - @Test - void testCheckConfiguration() { - MyBMWBridgeConfiguration myBMWBridgeConfiguration = new MyBMWBridgeConfiguration(); - assertFalse(MyBMWConfigurationChecker.checkInitialConfiguration(myBMWBridgeConfiguration)); - myBMWBridgeConfiguration.setUserName("a"); - assertFalse(MyBMWConfigurationChecker.checkInitialConfiguration(myBMWBridgeConfiguration)); - myBMWBridgeConfiguration.setPassword("b"); - assertFalse(MyBMWConfigurationChecker.checkInitialConfiguration(myBMWBridgeConfiguration)); - myBMWBridgeConfiguration.setHcaptchatoken("d"); - assertFalse(MyBMWConfigurationChecker.checkInitialConfiguration(myBMWBridgeConfiguration)); - myBMWBridgeConfiguration.setRegion("c"); - assertFalse(MyBMWConfigurationChecker.checkInitialConfiguration(myBMWBridgeConfiguration)); - myBMWBridgeConfiguration.setRegion(BimmerConstants.REGION_NORTH_AMERICA); - assertTrue(MyBMWConfigurationChecker.checkInitialConfiguration(myBMWBridgeConfiguration)); - myBMWBridgeConfiguration.setRegion(BimmerConstants.REGION_ROW); - assertTrue(MyBMWConfigurationChecker.checkInitialConfiguration(myBMWBridgeConfiguration)); - myBMWBridgeConfiguration.setRegion(BimmerConstants.REGION_CHINA); - assertTrue(MyBMWConfigurationChecker.checkInitialConfiguration(myBMWBridgeConfiguration)); - } -} From a575c5bf52641c2ec8cff6926f121391ed501e38 Mon Sep 17 00:00:00 2001 From: Mark Herwege Date: Mon, 10 Feb 2025 11:51:25 +0100 Subject: [PATCH 2/5] review feedback Signed-off-by: Mark Herwege --- .../openhab/binding/mybmw/internal/MyBMWConstants.java | 8 ++++---- .../mybmw/internal/handler/MyBMWBridgeHandler.java | 1 + .../mybmw/internal/handler/auth/MyBMWAuthServlet.java | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/MyBMWConstants.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/MyBMWConstants.java index 57d3cda00b16e..f1432645a9ef9 100644 --- a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/MyBMWConstants.java +++ b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/MyBMWConstants.java @@ -61,13 +61,13 @@ public interface MyBMWConstants { static final int DEFAULT_REFRESH_INTERVAL_MINUTES = 60; - static final String BASE_PATH = "/" + BINDING_ID + "/"; + static final String LOCAL_OPENHAB_BASE_PATH = "/" + BINDING_ID + "/"; - static final String HTML_SOURCE = "captcha/"; + static final String CAPTCHA_URL_ROOT = "captcha/"; static final String NORTH_AMERICA = "NORTH_AMERICA"; static final String ROW = "ROW"; - static final Map CAPTCHA_HTML = Map.of(NORTH_AMERICA, HTML_SOURCE + "north_america_form.html", ROW, - HTML_SOURCE + "rest_of_world_form.html"); + static final Map CAPTCHA_HTML = Map.of(NORTH_AMERICA, CAPTCHA_URL_ROOT + "north_america_form.html", ROW, + CAPTCHA_URL_ROOT + "rest_of_world_form.html"); static final String STATUS_AUTH_NEEDED = "@text/mybmw.status.authorization-needed"; static final String STATUS_USER_DETAILS_MISSING = "@text/mybmw.status.user-details-missing"; diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/MyBMWBridgeHandler.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/MyBMWBridgeHandler.java index ea7c329af5fb1..1c42941c0e75e 100644 --- a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/MyBMWBridgeHandler.java +++ b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/MyBMWBridgeHandler.java @@ -145,6 +145,7 @@ public void initialize() { return; } } + // Update the central configuration and get the updates configuration back super.updateConfiguration(config); localBridgeConfiguration = getConfigAs(MyBMWBridgeConfiguration.class); diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/auth/MyBMWAuthServlet.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/auth/MyBMWAuthServlet.java index c496fe7cdcae4..d968d9b354efd 100644 --- a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/auth/MyBMWAuthServlet.java +++ b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/auth/MyBMWAuthServlet.java @@ -50,7 +50,7 @@ public class MyBMWAuthServlet extends HttpServlet { public MyBMWAuthServlet(MyBMWBridgeHandler bridgeHandler, String region, HttpService httpService) { this.httpService = httpService; this.bridgeHandler = bridgeHandler; - this.path = MyBMWConstants.BASE_PATH + bridgeHandler.getThing().getUID().getAsString(); + this.path = MyBMWConstants.LOCAL_OPENHAB_BASE_PATH + bridgeHandler.getThing().getUID().getAsString(); String captchaTemplate = MyBMWConstants.CAPTCHA_HTML.get(region); if (captchaTemplate != null) { try (InputStream stream = classLoader.getResourceAsStream(captchaTemplate)) { From b0453e82c4a423028376d92e80850a0329a78e60 Mon Sep 17 00:00:00 2001 From: Mark Herwege Date: Mon, 10 Feb 2025 12:02:27 +0100 Subject: [PATCH 3/5] remove support for China Signed-off-by: Mark Herwege --- bundles/org.openhab.binding.mybmw/README.md | 5 +- .../internal/dto/auth/ChinaAccessToken.java | 29 ---- .../internal/dto/auth/ChinaPublicKey.java | 25 ---- .../dto/auth/ChinaPublicKeyResponse.java | 25 ---- .../dto/auth/ChinaTokenExpiration.java | 25 ---- .../internal/dto/auth/ChinaTokenResponse.java | 25 ---- .../handler/auth/MyBMWTokenController.java | 129 ++---------------- .../mybmw/internal/utils/BimmerConstants.java | 11 +- .../resources/OH-INF/config/bridge-config.xml | 1 - .../resources/OH-INF/i18n/mybmw.properties | 1 - 10 files changed, 21 insertions(+), 255 deletions(-) delete mode 100644 bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/dto/auth/ChinaAccessToken.java delete mode 100644 bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/dto/auth/ChinaPublicKey.java delete mode 100644 bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/dto/auth/ChinaPublicKeyResponse.java delete mode 100644 bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/dto/auth/ChinaTokenExpiration.java delete mode 100644 bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/dto/auth/ChinaTokenResponse.java diff --git a/bundles/org.openhab.binding.mybmw/README.md b/bundles/org.openhab.binding.mybmw/README.md index 21f24ec3a620d..8d5ffb24b1b9b 100644 --- a/bundles/org.openhab.binding.mybmw/README.md +++ b/bundles/org.openhab.binding.mybmw/README.md @@ -21,6 +21,8 @@ Please note **this isn't a real-time binding**. If a door is opened the state isn't transmitted and changed immediately. It's not a flaw in the binding itself because the state in BMW's own MyBMW App is also updated with some delay. +This binding does not support China. + ## Supported Things ### Bridge @@ -84,10 +86,9 @@ Properties will be attached to predefined vehicles if the VIN is matching. | password | text | MyBMW Password | | region | text | Select region in order to connect to the appropriate BMW server. | -The region Configuration has 3 different options +The region Configuration has 2 different options - _NORTH_AMERICA_ -- _CHINA_ - _ROW_ (Rest of World) At first initialization, follow the online instructions for login into the BMW API. diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/dto/auth/ChinaAccessToken.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/dto/auth/ChinaAccessToken.java deleted file mode 100644 index cc1f873c3a1cd..0000000000000 --- a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/dto/auth/ChinaAccessToken.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright (c) 2010-2025 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.mybmw.internal.dto.auth; - -import org.openhab.binding.mybmw.internal.utils.Constants; - -import com.google.gson.annotations.SerializedName; - -/** - * The {@link ChinaAccessToken} Data Transfer Object - * - * @author Bernd Weymann - Initial contribution - */ -public class ChinaAccessToken { - @SerializedName("access_token") - public String accessToken = Constants.EMPTY; - @SerializedName("token_type") - public String tokenType = Constants.EMPTY; -} diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/dto/auth/ChinaPublicKey.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/dto/auth/ChinaPublicKey.java deleted file mode 100644 index 58e17243d47eb..0000000000000 --- a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/dto/auth/ChinaPublicKey.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (c) 2010-2025 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.mybmw.internal.dto.auth; - -/** - * The {@link ChinaPublicKey} Data Transfer Object - * - * @author Bernd Weymann - Initial contribution - */ -public class ChinaPublicKey { - public String value;// ": "-----BEGIN PUBLIC - // KEY-----\r\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCteEZFIGa2z5cj7sAmX40y8/ige01T2r+VUzkMshAYwotZFvrVWZLQ6W9+ltvINJoRfZEZkmdP2lsidhqj1H1+RWyC78ear7Fm6xd9Gp9LnKtVVBJRM/9cBRg0AGiTJ7IO/x6MpKkBxxHmProFqPI40hueunV85RlaPBrjZVNIpQIDAQAB\r\n-----END - // PUBLIC KEY-----", - public String expires;// ": "3600" -} diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/dto/auth/ChinaPublicKeyResponse.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/dto/auth/ChinaPublicKeyResponse.java deleted file mode 100644 index 466139d2465f2..0000000000000 --- a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/dto/auth/ChinaPublicKeyResponse.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (c) 2010-2025 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.mybmw.internal.dto.auth; - -/** - * The {@link ChinaPublicKeyResponse} Data Transfer Object - * - * @author Bernd Weymann - Initial contribution - */ -public class ChinaPublicKeyResponse { - public ChinaPublicKey data; - public int code;// ":200, - public String error;// ":false, - public String description;// ":"ok" -} diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/dto/auth/ChinaTokenExpiration.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/dto/auth/ChinaTokenExpiration.java deleted file mode 100644 index c250233d38483..0000000000000 --- a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/dto/auth/ChinaTokenExpiration.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (c) 2010-2025 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.mybmw.internal.dto.auth; - -/** - * The {@link ChinaTokenExpiration} Data Transfer Object - * - * @author Bernd Weymann - Initial contribution - */ -public class ChinaTokenExpiration { - public String jti;// ":"DUMMY$1$A$1637707916782", - public long nbf;// ":1637707916, - public long exp;// ":1637711216, - public long iat;// ":1637707916} -} diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/dto/auth/ChinaTokenResponse.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/dto/auth/ChinaTokenResponse.java deleted file mode 100644 index 9d3f27876c425..0000000000000 --- a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/dto/auth/ChinaTokenResponse.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (c) 2010-2025 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.mybmw.internal.dto.auth; - -/** - * The {@link ChinaTokenResponse} Data Transfer Object - * - * @author Bernd Weymann - Initial contribution - */ -public class ChinaTokenResponse { - public ChinaAccessToken data; - public int code;// ":200, - public String error;// ":false, - public String description;// ":"ok" -} diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/auth/MyBMWTokenController.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/auth/MyBMWTokenController.java index ddb6b173ce65a..3e022835cebd4 100644 --- a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/auth/MyBMWTokenController.java +++ b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/handler/auth/MyBMWTokenController.java @@ -17,19 +17,14 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.security.KeyFactory; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; -import java.security.PublicKey; -import java.security.spec.X509EncodedKeySpec; import java.time.Instant; import java.util.Base64; import java.util.UUID; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeoutException; -import javax.crypto.Cipher; - import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jetty.client.HttpClient; @@ -41,9 +36,6 @@ import org.eclipse.jetty.util.MultiMap; import org.eclipse.jetty.util.UrlEncoded; import org.openhab.binding.mybmw.internal.MyBMWBridgeConfiguration; -import org.openhab.binding.mybmw.internal.dto.auth.ChinaPublicKeyResponse; -import org.openhab.binding.mybmw.internal.dto.auth.ChinaTokenExpiration; -import org.openhab.binding.mybmw.internal.dto.auth.ChinaTokenResponse; import org.openhab.binding.mybmw.internal.dto.auth.OAuthSettingsQueryResponse; import org.openhab.binding.mybmw.internal.handler.MyBMWBridgeHandler; import org.openhab.binding.mybmw.internal.handler.backend.JsonStringDeserializer; @@ -67,6 +59,7 @@ * @author Bernd Weymann - Initial contribution * @author Martin Grassl - extracted from myBmwProxy * @author Mark Herwege - refactor to use OAuthFactory + * @author Mark Herwege - remove China */ @NonNullByDefault public class MyBMWTokenController { @@ -127,28 +120,20 @@ public synchronized AccessTokenResponse getToken() { // reset the token as it times out bridgeHandler.setHCaptchaToken(Constants.EMPTY); } else if (!waitingForInitialToken && tokenResponse.isExpired(Instant.now(), 5)) { - if (REGION_CHINA.equals(bridgeConfiguration.getRegion())) { - // in China no hcaptchatoken is required, so no need to wait for reinitialization - boolean tokenUpdateSuccess = getAndRefreshTokenChina(); - if (!tokenUpdateSuccess) { - logger.warn("Updating token failed!"); - } - } else { - // try to refresh the token - boolean tokenUpdateSuccess = refreshTokenROW(); - logger.trace("update token {}", tokenUpdateSuccess ? "success" : "failed"); - - if (!tokenUpdateSuccess) { - logger.warn("Updating token failed!"); - waitingForInitialToken = true; - - if (bridgeConfiguration.getHCaptchaToken().isBlank()) { - logger.warn( - "initial Authentication failed, request a new captcha token, see https://bimmer-connected.readthedocs.io/en/stable/captcha.html!"); - bridgeHandler.tokenInitError(); - } else { - getToken(); - } + // try to refresh the token + boolean tokenUpdateSuccess = refreshTokenROW(); + logger.trace("update token {}", tokenUpdateSuccess ? "success" : "failed"); + + if (!tokenUpdateSuccess) { + logger.warn("Updating token failed!"); + waitingForInitialToken = true; + + if (bridgeConfiguration.getHCaptchaToken().isBlank()) { + logger.warn( + "initial Authentication failed, request a new captcha token, see https://bimmer-connected.readthedocs.io/en/stable/captcha.html!"); + bridgeHandler.tokenInitError(); + } else { + getToken(); } } } @@ -336,88 +321,4 @@ private String codeFromUrl(String encodedUrl) { }); return codeFound.toString(); } - - /** - * @return true if the token was successfully updated - */ - private boolean getAndRefreshTokenChina() { - try { - /** - * Step 1) get public key - */ - String publicKeyUrl = "https://" + EADRAX_SERVER_MAP.get(REGION_CHINA) + CHINA_PUBLIC_KEY; - Request oauthQueryRequest = httpClient.newRequest(publicKeyUrl); - oauthQueryRequest.header(HttpHeader.USER_AGENT, USER_AGENT); - oauthQueryRequest.header(HEADER_X_USER_AGENT, String.format(X_USER_AGENT, BRAND_BMW, - APP_VERSIONS.get(bridgeConfiguration.getRegion()), bridgeConfiguration.getRegion())); - ContentResponse publicKeyResponse = oauthQueryRequest.send(); - if (publicKeyResponse.getStatus() != 200) { - throw new HttpResponseException("URL: " + oauthQueryRequest.getURI() + ", Error: " - + publicKeyResponse.getStatus() + ", Message: " + publicKeyResponse.getContentAsString(), - publicKeyResponse); - } - ChinaPublicKeyResponse pkr = JsonStringDeserializer - .deserializeString(publicKeyResponse.getContentAsString(), ChinaPublicKeyResponse.class); - - /** - * Step 2) Encode password with public key - */ - // https://www.baeldung.com/java-read-pem-file-keys - String publicKeyStr = pkr.data.value; - String publicKeyPEM = publicKeyStr.replace("-----BEGIN PUBLIC KEY-----", "") - .replaceAll(System.lineSeparator(), "").replace("-----END PUBLIC KEY-----", "").replace("\\r", "") - .replace("\\n", "").trim(); - byte[] encoded = Base64.getDecoder().decode(publicKeyPEM); - X509EncodedKeySpec spec = new X509EncodedKeySpec(encoded); - KeyFactory kf = KeyFactory.getInstance("RSA"); - PublicKey publicKey = kf.generatePublic(spec); - // https://www.thexcoders.net/java-ciphers-rsa/ - Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); - cipher.init(Cipher.ENCRYPT_MODE, publicKey); - byte[] encryptedBytes = cipher.doFinal(bridgeConfiguration.getPassword().getBytes()); - String encodedPassword = Base64.getEncoder().encodeToString(encryptedBytes); - - /** - * Step 3) Send Auth with encoded password - */ - String tokenUrl = "https://" + EADRAX_SERVER_MAP.get(REGION_CHINA) + CHINA_LOGIN; - Request loginRequest = httpClient.POST(tokenUrl); - loginRequest.header(HEADER_X_USER_AGENT, String.format(X_USER_AGENT, BRAND_BMW, - APP_VERSIONS.get(bridgeConfiguration.getRegion()), bridgeConfiguration.getRegion())); - String jsonContent = "{ \"mobile\":\"" + bridgeConfiguration.getUserName() + "\", \"password\":\"" - + encodedPassword + "\"}"; - loginRequest.content(new StringContentProvider(jsonContent)); - Instant tokenCreatedOn = Instant.now(); - ContentResponse tokenResponse = loginRequest.send(); - if (tokenResponse.getStatus() != 200) { - throw new HttpResponseException("URL: " + loginRequest.getURI() + ", Error: " - + tokenResponse.getStatus() + ", Message: " + tokenResponse.getContentAsString(), - tokenResponse); - } - String authCode = getAuthCode(tokenResponse.getContentAsString()); - - /** - * Step 4) Decode access token - */ - ChinaTokenResponse cat = JsonStringDeserializer.deserializeString(authCode, ChinaTokenResponse.class); - String token = cat.data.accessToken; - // https://www.baeldung.com/java-jwt-token-decode - String[] chunks = token.split("\\."); - String tokenJwtDecodeStr = new String(Base64.getUrlDecoder().decode(chunks[1])); - ChinaTokenExpiration cte = JsonStringDeserializer.deserializeString(tokenJwtDecodeStr, - ChinaTokenExpiration.class); - - AccessTokenResponse t = new AccessTokenResponse(); - t.setAccessToken(token); - t.setTokenType(cat.data.tokenType); - t.setCreatedOn(tokenCreatedOn); - t.setExpiresIn(cte.exp); - - this.tokenResponse = t; - return true; - } catch (Exception e) { - logger.warn("Authorization Exception: {}", e.getMessage()); - } - return false; - } } diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/utils/BimmerConstants.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/utils/BimmerConstants.java index 17b7387c50501..c2db61ef5fc29 100644 --- a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/utils/BimmerConstants.java +++ b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/utils/BimmerConstants.java @@ -28,12 +28,12 @@ * * @author Bernd Weymann - Initial contribution * @author Martin Grassl - update to v2 API + * @author Mark Herwege - remove China */ @NonNullByDefault public interface BimmerConstants { static final String REGION_NORTH_AMERICA = "NORTH_AMERICA"; - static final String REGION_CHINA = "CHINA"; static final String REGION_ROW = "ROW"; static final String BRAND_BMW = "bmw"; @@ -46,24 +46,19 @@ public interface BimmerConstants { static final String EADRAX_SERVER_NORTH_AMERICA = "cocoapi.bmwgroup.us"; static final String EADRAX_SERVER_ROW = "cocoapi.bmwgroup.com"; - static final String EADRAX_SERVER_CHINA = "myprofile.bmw.com.cn"; 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); + REGION_ROW, EADRAX_SERVER_ROW); static final String OCP_APIM_KEY_NORTH_AMERICA = "31e102f5-6f7e-7ef3-9044-ddce63891362"; static final String OCP_APIM_KEY_ROW = "4f1c85a3-758f-a37d-bbb6-f8704494acfa"; static final Map OCP_APIM_KEYS = Map.of(REGION_NORTH_AMERICA, OCP_APIM_KEY_NORTH_AMERICA, REGION_ROW, OCP_APIM_KEY_ROW); - static final String CHINA_PUBLIC_KEY = "/eadrax-coas/v1/cop/publickey"; - static final String CHINA_LOGIN = "/eadrax-coas/v2/login/pwd"; - // Http variables static final String APP_VERSION_NORTH_AMERICA = "4.9.2(36892)"; static final String APP_VERSION_ROW = "4.9.2(36892)"; - static final String APP_VERSION_CHINA = "4.9.2(36892)"; static final Map APP_VERSIONS = Map.of(REGION_NORTH_AMERICA, APP_VERSION_NORTH_AMERICA, REGION_ROW, - APP_VERSION_ROW, REGION_CHINA, APP_VERSION_CHINA); + APP_VERSION_ROW); static final String USER_AGENT = "Dart/2.16 (dart:io)"; // see const.py of bimmer_constants: user-agent; brand; app_version; region static final String X_USER_AGENT = "android(AP2A.240605.024);%s;%s;%s"; diff --git a/bundles/org.openhab.binding.mybmw/src/main/resources/OH-INF/config/bridge-config.xml b/bundles/org.openhab.binding.mybmw/src/main/resources/OH-INF/config/bridge-config.xml index 1ace6c847c213..01a28a925694d 100644 --- a/bundles/org.openhab.binding.mybmw/src/main/resources/OH-INF/config/bridge-config.xml +++ b/bundles/org.openhab.binding.mybmw/src/main/resources/OH-INF/config/bridge-config.xml @@ -19,7 +19,6 @@ Select Region in order to connect to the appropriate BMW Server - ROW diff --git a/bundles/org.openhab.binding.mybmw/src/main/resources/OH-INF/i18n/mybmw.properties b/bundles/org.openhab.binding.mybmw/src/main/resources/OH-INF/i18n/mybmw.properties index da3efed7c7655..4e8dd5f069f4f 100644 --- a/bundles/org.openhab.binding.mybmw/src/main/resources/OH-INF/i18n/mybmw.properties +++ b/bundles/org.openhab.binding.mybmw/src/main/resources/OH-INF/i18n/mybmw.properties @@ -12,7 +12,6 @@ thing-type.config.mybmw.bridge.hcaptchatoken.description = Captcha-Token for the thing-type.config.mybmw.bridge.hcaptchatoken.label = Captcha-Token thing-type.config.mybmw.bridge.region.description = Select Region in order to connect to the appropriate BMW Server thing-type.config.mybmw.bridge.region.label = Region -thing-type.config.mybmw.bridge.region.option.CHINA = China thing-type.config.mybmw.bridge.region.option.NORTH_AMERICA = North America thing-type.config.mybmw.bridge.region.option.ROW = Rest of the World thing-type.config.mybmw.bridge.userName.description = MyBMW Username From cffea680460113907b742ff856bd9db8de1bc70a Mon Sep 17 00:00:00 2001 From: Mark Herwege Date: Mon, 10 Feb 2025 13:04:59 +0100 Subject: [PATCH 4/5] constants improvements Signed-off-by: Mark Herwege --- .../org/openhab/binding/mybmw/internal/MyBMWConstants.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/MyBMWConstants.java b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/MyBMWConstants.java index f1432645a9ef9..344842813dd43 100644 --- a/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/MyBMWConstants.java +++ b/bundles/org.openhab.binding.mybmw/src/main/java/org/openhab/binding/mybmw/internal/MyBMWConstants.java @@ -61,14 +61,15 @@ public interface MyBMWConstants { static final int DEFAULT_REFRESH_INTERVAL_MINUTES = 60; + // Captcha servlet constants static final String LOCAL_OPENHAB_BASE_PATH = "/" + BINDING_ID + "/"; - static final String CAPTCHA_URL_ROOT = "captcha/"; static final String NORTH_AMERICA = "NORTH_AMERICA"; static final String ROW = "ROW"; - static final Map CAPTCHA_HTML = Map.of(NORTH_AMERICA, CAPTCHA_URL_ROOT + "north_america_form.html", ROW, - CAPTCHA_URL_ROOT + "rest_of_world_form.html"); + static final Map CAPTCHA_HTML = Map.of(NORTH_AMERICA, CAPTCHA_URL_ROOT + "north_america_form.html", + ROW, CAPTCHA_URL_ROOT + "rest_of_world_form.html"); + // Thing status detail messages static final String STATUS_AUTH_NEEDED = "@text/mybmw.status.authorization-needed"; static final String STATUS_USER_DETAILS_MISSING = "@text/mybmw.status.user-details-missing"; static final String STATUS_REGION_MISSING = "@text/mybmw.status.region-missing"; From 9228edb4ad9ae3da736ed09d78bb76858a2211cc Mon Sep 17 00:00:00 2001 From: lsiepel Date: Wed, 12 Feb 2025 21:47:57 +0100 Subject: [PATCH 5/5] Update bundles/org.openhab.binding.mybmw/README.md Signed-off-by: lsiepel --- bundles/org.openhab.binding.mybmw/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.mybmw/README.md b/bundles/org.openhab.binding.mybmw/README.md index 8d5ffb24b1b9b..691800c8b3409 100644 --- a/bundles/org.openhab.binding.mybmw/README.md +++ b/bundles/org.openhab.binding.mybmw/README.md @@ -21,7 +21,7 @@ Please note **this isn't a real-time binding**. If a door is opened the state isn't transmitted and changed immediately. It's not a flaw in the binding itself because the state in BMW's own MyBMW App is also updated with some delay. -This binding does not support China. +This binding does not support the region: China. ## Supported Things