[mercedesme] New authorization process (#18342)

* change auth process

Signed-off-by: Bernd Weymann <bernd.weymann@gmail.com>
main
Bernd Weymann 2025-03-04 22:08:38 +01:00 committed by GitHub
parent a0bae2fd31
commit 79a3c1b242
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 249 additions and 683 deletions

View File

@ -7,12 +7,11 @@ This binding provides access to your Mercedes Benz vehicle like _Mercedes Me_ Sm
First time users shall follow the following sequence
1. Setup and configure [Bridge](#bridge-configuration)
2. Follow the [Bridge Authorization](#bridge-authorization) process
3. [Discovery](#discovery) shall find now vehicles associated to your account
4. Add your vehicle from discovery and [configure](#thing-configuration) it with correct VIN
5. Connect your desired items in UI or [text-configuration](#full-example)
6. Optional: you can [Discover your Vehicle](#discover-your-vehicle) more deeply
7. In case of problems check [Troubleshooting](#troubleshooting) section
2. [Discovery](#discovery) shall find now vehicles associated to your account
3. Add your vehicle from discovery and [configure](#thing-configuration) it with correct VIN
4. Connect your desired items in UI or [text-configuration](#full-example)
5. Optional: you can [Discover your Vehicle](#discover-your-vehicle) more deeply
6. In case of problems check [Troubleshooting](#troubleshooting) section
## Supported Things
@ -35,14 +34,20 @@ There's no manual discovery!
Bridge needs configuration in order to connect properly to your Mercedes Me account.
| Name | Type | Description | Default | Required | Advanced |
|-----------------|---------|-----------------------------------------|-------------|----------|----------|
| email | text | Mercedes Me registered email Address | N/A | yes | no |
| pin | text | Mercedes Me Smartphone App PIN | N/A | no | no |
| region | text | Your region | EU | yes | no |
| refreshInterval | integer | API refresh interval | 15 | yes | no |
| callbackIP | text | IP Address of openHAB Device | N/A | yes | yes |
| callbackPort | integer | Port Number of openHAB Device | N/A | yes | yes |
| Name | Type | Description | Default | Required |
|-------------------|---------|---------------------------------------------|---------------------------|----------|
| email | text | Mercedes Me registered email Address | N/A | yes |
| refreshToken | text | Refresh Token from MB Token Requester app | takeover previous token | yes |
| pin | text | Mercedes Me Smartphone App PIN | N/A | no |
| region | text | Your region | EU | yes |
| refreshInterval | integer | API refresh interval | 15 | yes |
`refreshToken` is needed to get access to your Mercedes Me account.
Users already running this binding can stay on default value `takeover previous token`.
New users need to generate `refreshToken` with [MB Token Requester app]( https://github.com/ReneNulschDE/mbapi2020/wiki/How%E2%80%90to:-create-the-access-and-refresh-token ).
It simulates the Mercedes Me application *only for authorization process* on your computer, **not your openHAB system!**
The generated *refresh token* has to be pasted into the bridge configuration.
The generated *token* can be ignored!
Set `region` to your location
@ -63,46 +68,6 @@ Commands protected by PIN
- Open / Ventilate Windows
- Open / Lift Sunroof
IP `callbackIP` and port `callbackPort` will be auto-detected.
If you're running on server with more than one network interface please select manually.
### Bridge Authorization
Authorization is needed to activate the Bridge which is connected to your Mercedes Me Account.
The Bridge will indicate in the status headline if authorization is needed including the URL which needs to be opened in your browser.
Three steps are needed
1. Open the mentioned URL like 192.168.x.x:8090/mb-auth
Opening this URL will request a PIN which will be send to your configured email.
Check your Mail Account if you received the PIN.
Click on _Continue_ to proceed with Step 2.
2. Enter your PIN in the shown field.
Leave GUID as identifier as it is.
Click on _Submit_ button.
3. Confirmation shall be shown that authorization was successful.
In case of non successful authorization check your log for errors.
Below screenshots are illustrating the authorization flow.
### After Bridge Setup
<img src="./doc/OH-Step0.png" width="500" height="240"/>
### Authorization Step 1
<img src="./doc/OH-Step1.png" width="500" height="200"/>
### Authorization Step 2
<img src="./doc/OH-Step2.png" width="500" height="200"/>
### Authorization Step 3
<img src="./doc/OH-Step3.png" width="400" height="130"/>
## Thing Configuration
| Name | Type | Description | Default | Required | Advanced |
@ -814,7 +779,7 @@ Keep these 3 channels disconnected during normal operation.
### Things file
```java
Bridge mercedesme:account:4711 "Mercedes Me John Doe" [ email="YOUR_MAIL_ADDRESS", region="EU", pin=9876, refreshInterval=15] {
Bridge mercedesme:account:4711 "Mercedes Me John Doe" [ email="YOUR_MAIL_ADDRESS", region="EU", pin=9876, refreshToken="abc", refreshInterval=15] {
Thing bev eqa "Mercedes EQA" [ vin="VEHICLE_VIN", batteryCapacity=66.5]
}
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@ -268,7 +268,6 @@ public class Constants {
public static final String OH_CHANNEL_CONSTANT = "constant";
public static final String OH_CHANNEL_BONUS_RANGE = "bonus";
public static final String CALLBACK_ENDPOINT = "/mb-auth";
// https://developer.mercedes-benz.com/content-page/api_migration_guide
public static final String IMAGE_BASE_URL = "https://api.mercedes-benz.com/vehicle_images/v2";
public static final String IMAGE_EXTERIOR_RESOURCE_URL = IMAGE_BASE_URL + "/vehicles/%s";
@ -278,9 +277,7 @@ public class Constants {
public static final String STATUS_EMAIL_MISSING = ".status.email-missing";
public static final String STATUS_REGION_MISSING = ".status.region-missing";
public static final String STATUS_REFRESH_INVALID = ".status.refresh-invalid";
public static final String STATUS_IP_MISSING = ".status.ip-missing";
public static final String STATUS_PORT_MISSING = ".status.port-missing";
public static final String STATUS_SERVER_RESTART = ".status.server-restart";
public static final String STATUS_REFRESH_TOKEN_MISSING = ".status.refresh-token-missing";
public static final String STATUS_BRIDGE_MISSING = ".status.bridge-missing";
public static final String SPACE = " ";
@ -346,7 +343,6 @@ public class Constants {
public static final String MAX_SOC_KEY = "maxsoc";
public static final String AUTO_UNLOCK_KEY = "autolock";
public static final String JUNIT_SERVER_ADDR = "http://999.999.999.999:99999/mb-auth";
public static final String JUNIT_TOKEN = "junitTestToken";
public static final String JUNIT_REFRESH_TOKEN = "junitRefreshToken";
}

View File

@ -31,7 +31,6 @@ import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.i18n.UnitProvider;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.items.MetadataRegistry;
import org.openhab.core.net.NetworkAddressService;
import org.openhab.core.storage.StorageService;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.Thing;
@ -65,7 +64,6 @@ public class MercedesMeHandlerFactory extends BaseThingHandlerFactory {
private final MercedesMeDiscoveryService discoveryService;
private final MercedesMeCommandOptionProvider mmcop;
private final MercedesMeStateOptionProvider mmsop;
private final NetworkAddressService networkService;
private @Nullable ServiceRegistration<?> discoveryServiceReg;
private @Nullable MercedesMeMetadataAdjuster mdAdjuster;
@ -76,10 +74,8 @@ public class MercedesMeHandlerFactory extends BaseThingHandlerFactory {
final @Reference LocaleProvider lp, final @Reference LocationProvider locationP,
final @Reference TimeZoneProvider tzp, final @Reference MercedesMeCommandOptionProvider cop,
final @Reference MercedesMeStateOptionProvider sop, final @Reference UnitProvider up,
final @Reference MetadataRegistry mdr, final @Reference ItemChannelLinkRegistry iclr,
final @Reference NetworkAddressService nas) {
final @Reference MetadataRegistry mdr, final @Reference ItemChannelLinkRegistry iclr) {
this.storageService = storageService;
networkService = nas;
localeProvider = lp;
locationProvider = locationP;
mmcop = cop;
@ -112,8 +108,7 @@ public class MercedesMeHandlerFactory extends BaseThingHandlerFactory {
discoveryServiceReg = bundleContext.registerService(DiscoveryService.class.getName(), discoveryService,
null);
}
return new AccountHandler((Bridge) thing, discoveryService, httpClient, localeProvider, storageService,
networkService);
return new AccountHandler((Bridge) thing, discoveryService, httpClient, localeProvider, storageService);
} else if (THING_TYPE_BEV.equals(thingTypeUID) || THING_TYPE_COMB.equals(thingTypeUID)
|| THING_TYPE_HYBRID.equals(thingTypeUID)) {
return new VehicleHandler(thing, locationProvider, mmcop, mmsop);

View File

@ -26,9 +26,7 @@ public class AccountConfiguration {
public String email = NOT_SET;
public String region = NOT_SET;
public String refreshToken = "takeover previous token";
public String pin = NOT_SET;
public int refreshInterval = 15;
public String callbackIP = NOT_SET;
public int callbackPort = -1;
}

View File

@ -33,6 +33,6 @@ public class TokenResponse {
@SerializedName("token_type")
public String tokenType = Constants.NOT_SET;
@SerializedName("expires_in")
public int expiresIn;
public String createdOn = Instant.now().toString();
public int expiresIn = 0;
public String createdOn = Instant.MIN.toString();
}

View File

@ -38,15 +38,12 @@ import org.json.JSONObject;
import org.openhab.binding.mercedesme.internal.Constants;
import org.openhab.binding.mercedesme.internal.config.AccountConfiguration;
import org.openhab.binding.mercedesme.internal.discovery.MercedesMeDiscoveryService;
import org.openhab.binding.mercedesme.internal.server.AuthServer;
import org.openhab.binding.mercedesme.internal.server.AuthService;
import org.openhab.binding.mercedesme.internal.server.MBWebsocket;
import org.openhab.binding.mercedesme.internal.utils.Utils;
import org.openhab.core.auth.client.oauth2.AccessTokenRefreshListener;
import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.i18n.LocaleProvider;
import org.openhab.core.net.NetworkAddressService;
import org.openhab.core.storage.Storage;
import org.openhab.core.storage.StorageService;
import org.openhab.core.thing.Bridge;
@ -80,7 +77,6 @@ public class AccountHandler extends BaseBridgeHandler implements AccessTokenRefr
private static final String COMMAND_APPENDIX = "-commands";
private final Logger logger = LoggerFactory.getLogger(AccountHandler.class);
private final NetworkAddressService networkService;
private final MercedesMeDiscoveryService discoveryService;
private final HttpClient httpClient;
private final LocaleProvider localeProvider;
@ -89,8 +85,6 @@ public class AccountHandler extends BaseBridgeHandler implements AccessTokenRefr
private final Map<String, VEPUpdate> vepUpdateMap = new HashMap<>();
private final Map<String, Map<String, Object>> capabilitiesMap = new HashMap<>();
private Optional<AuthServer> server = Optional.empty();
private Optional<AuthService> authService = Optional.empty();
private Optional<ScheduledFuture<?>> refreshScheduler = Optional.empty();
private List<byte[]> eventQueue = new ArrayList<>();
private boolean updateRunning = false;
@ -99,16 +93,16 @@ public class AccountHandler extends BaseBridgeHandler implements AccessTokenRefr
private String commandCapabilitiesEndpoint = "/v1/vehicle/%s/capabilities/commands";
private String poiEndpoint = "/v1/vehicle/%s/route";
Optional<AuthService> authService = Optional.empty();
final MBWebsocket ws;
Optional<AccountConfiguration> config = Optional.empty();
AccountConfiguration config = new AccountConfiguration();
@Nullable
ClientMessage message;
public AccountHandler(Bridge bridge, MercedesMeDiscoveryService mmds, HttpClient hc, LocaleProvider lp,
StorageService store, NetworkAddressService nas) {
StorageService store) {
super(bridge);
discoveryService = mmds;
networkService = nas;
ws = new MBWebsocket(this);
httpClient = hc;
localeProvider = lp;
@ -122,83 +116,39 @@ public class AccountHandler extends BaseBridgeHandler implements AccessTokenRefr
@Override
public void initialize() {
updateStatus(ThingStatus.UNKNOWN);
config = Optional.of(getConfigAs(AccountConfiguration.class));
autodetectCallback();
config = getConfigAs(AccountConfiguration.class);
String configValidReason = configValid();
if (!configValidReason.isEmpty()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, configValidReason);
} else {
String callbackUrl = Utils.getCallbackAddress(config.get().callbackIP, config.get().callbackPort);
thing.setProperty("callbackUrl", callbackUrl);
server = Optional.of(new AuthServer(httpClient, config.get(), callbackUrl));
authService = Optional
.of(new AuthService(this, httpClient, config.get(), localeProvider.getLocale(), storage));
if (!server.get().start()) {
String textKey = Constants.STATUS_TEXT_PREFIX + thing.getThingTypeUID().getId()
+ Constants.STATUS_SERVER_RESTART;
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE,
textKey + " [\"" + thing.getProperties().get("callbackUrl") + "\"]");
} else {
refreshScheduler = Optional.of(scheduler.scheduleWithFixedDelay(this::refresh, 0,
config.get().refreshInterval, TimeUnit.MINUTES));
}
authService = Optional.of(new AuthService(this, httpClient, config, localeProvider.getLocale(), storage,
config.refreshToken));
refreshScheduler = Optional
.of(scheduler.scheduleWithFixedDelay(this::refresh, 0, config.refreshInterval, TimeUnit.MINUTES));
}
}
public void refresh() {
if (server.isPresent()) {
if (!Constants.NOT_SET.equals(authService.get().getToken())) {
ws.run();
} else {
// all failed - start manual authorization
String textKey = Constants.STATUS_TEXT_PREFIX + thing.getThingTypeUID().getId()
+ Constants.STATUS_AUTH_NEEDED;
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
textKey + " [\"" + thing.getProperties().get("callbackUrl") + "\"]");
}
if (!Constants.NOT_SET.equals(authService.get().getToken())) {
ws.run();
} else {
// server not running - fix first
// all failed - start manual authorization
String textKey = Constants.STATUS_TEXT_PREFIX + thing.getThingTypeUID().getId()
+ Constants.STATUS_SERVER_RESTART;
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR, textKey);
+ Constants.STATUS_AUTH_NEEDED;
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, textKey);
}
}
private void autodetectCallback() {
// if Callback IP and Callback Port are not set => autodetect these values
config = Optional.of(getConfigAs(AccountConfiguration.class));
Configuration updateConfig = super.editConfiguration();
if (!updateConfig.containsKey("callbackPort")) {
updateConfig.put("callbackPort", Utils.getFreePort());
} else {
Utils.addPort(config.get().callbackPort);
}
if (!updateConfig.containsKey("callbackIP")) {
String ip = networkService.getPrimaryIpv4HostAddress();
if (ip != null) {
updateConfig.put("callbackIP", ip);
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE,
"@text/mercedesme.account.status.ip-autodetect-failure");
}
}
super.updateConfiguration(updateConfig);
// get new config after update
config = Optional.of(getConfigAs(AccountConfiguration.class));
}
private String configValid() {
config = Optional.of(getConfigAs(AccountConfiguration.class));
config = getConfigAs(AccountConfiguration.class);
String textKey = Constants.STATUS_TEXT_PREFIX + thing.getThingTypeUID().getId();
if (Constants.NOT_SET.equals(config.get().callbackIP)) {
return textKey + Constants.STATUS_IP_MISSING;
} else if (config.get().callbackPort == -1) {
return textKey + Constants.STATUS_PORT_MISSING;
} else if (Constants.NOT_SET.equals(config.get().email)) {
if (Constants.NOT_SET.equals(config.refreshToken)) {
return textKey + Constants.STATUS_REFRESH_TOKEN_MISSING;
} else if (Constants.NOT_SET.equals(config.email)) {
return textKey + Constants.STATUS_EMAIL_MISSING;
} else if (Constants.NOT_SET.equals(config.get().region)) {
} else if (Constants.NOT_SET.equals(config.region)) {
return textKey + Constants.STATUS_REGION_MISSING;
} else if (config.get().refreshInterval <= 01) {
} else if (config.refreshInterval < 5) {
return textKey + Constants.STATUS_REFRESH_INVALID;
} else {
return Constants.EMPTY;
@ -207,13 +157,6 @@ public class AccountHandler extends BaseBridgeHandler implements AccessTokenRefr
@Override
public void dispose() {
if (server.isPresent()) {
AuthServer authServer = server.get();
authServer.stop();
authServer.dispose();
server = Optional.empty();
Utils.removePort(config.get().callbackPort);
}
refreshScheduler.ifPresent(schedule -> {
if (!schedule.isCancelled()) {
schedule.cancel(true);
@ -223,6 +166,13 @@ public class AccountHandler extends BaseBridgeHandler implements AccessTokenRefr
eventQueue.clear();
}
@Override
public void handleRemoval() {
storage.remove(config.email);
authService = Optional.empty();
super.handleRemoval();
}
/**
* https://next.openhab.org/javadoc/latest/org/openhab/core/auth/client/oauth2/package-summary.html
*/
@ -230,27 +180,16 @@ public class AccountHandler extends BaseBridgeHandler implements AccessTokenRefr
public void onAccessTokenResponse(AccessTokenResponse tokenResponse) {
if (!Constants.NOT_SET.equals(tokenResponse.getAccessToken())) {
scheduler.schedule(this::refresh, 2, TimeUnit.SECONDS);
} else if (server.isEmpty()) {
// server not running - fix first
String textKey = Constants.STATUS_TEXT_PREFIX + thing.getThingTypeUID().getId()
+ Constants.STATUS_SERVER_RESTART;
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, textKey);
} else {
// all failed - start manual authorization
String textKey = Constants.STATUS_TEXT_PREFIX + thing.getThingTypeUID().getId()
+ Constants.STATUS_AUTH_NEEDED;
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
textKey + " [\"" + thing.getProperties().get("callbackUrl") + "\"]");
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, textKey);
}
}
@Override
public String toString() {
return Integer.toString(config.get().callbackPort);
}
public String getWSUri() {
return Utils.getWebsocketServer(config.get().region);
return Utils.getWebsocketServer(config.region);
}
public ClientUpgradeRequest getClientUpgradeRequest() {
@ -260,12 +199,12 @@ public class AccountHandler extends BaseBridgeHandler implements AccessTokenRefr
request.setHeader("X-TrackingId", UUID.randomUUID().toString());
request.setHeader("Ris-Os-Name", Constants.RIS_OS_NAME);
request.setHeader("Ris-Os-Version", Constants.RIS_OS_VERSION);
request.setHeader("Ris-Sdk-Version", Utils.getRisSDKVersion(config.get().region));
request.setHeader("Ris-Sdk-Version", Utils.getRisSDKVersion(config.region));
request.setHeader("X-Locale",
localeProvider.getLocale().getLanguage() + "-" + localeProvider.getLocale().getCountry()); // de-DE
request.setHeader("User-Agent", Utils.getApplication(config.get().region));
request.setHeader("X-Applicationname", Utils.getUserAgent(config.get().region));
request.setHeader("Ris-Application-Version", Utils.getRisApplicationVersion(config.get().region));
request.setHeader("User-Agent", Utils.getApplication(config.region));
request.setHeader("X-Applicationname", Utils.getUserAgent(config.region));
request.setHeader("Ris-Application-Version", Utils.getRisApplicationVersion(config.region));
return request;
}
@ -452,8 +391,7 @@ public class AccountHandler extends BaseBridgeHandler implements AccessTokenRefr
Map<String, Object> featureMap = new HashMap<>();
try {
// add vehicle capabilities
String capabilitiesUrl = Utils.getRestAPIServer(config.get().region)
+ String.format(capabilitiesEndpoint, vin);
String capabilitiesUrl = Utils.getRestAPIServer(config.region) + String.format(capabilitiesEndpoint, vin);
Request capabilitiesRequest = httpClient.newRequest(capabilitiesUrl);
authService.get().addBasicHeaders(capabilitiesRequest);
capabilitiesRequest.header("X-SessionId", UUID.randomUUID().toString());
@ -489,7 +427,7 @@ public class AccountHandler extends BaseBridgeHandler implements AccessTokenRefr
}
// add command capabilities
String commandCapabilitiesUrl = Utils.getRestAPIServer(config.get().region)
String commandCapabilitiesUrl = Utils.getRestAPIServer(config.region)
+ String.format(commandCapabilitiesEndpoint, vin);
Request commandCapabilitiesRequest = httpClient.newRequest(commandCapabilitiesUrl);
authService.get().addBasicHeaders(commandCapabilitiesRequest);
@ -557,7 +495,7 @@ public class AccountHandler extends BaseBridgeHandler implements AccessTokenRefr
*/
public void sendPoi(String vin, JSONObject poi) {
String poiUrl = Utils.getRestAPIServer(config.get().region) + String.format(poiEndpoint, vin);
String poiUrl = Utils.getRestAPIServer(config.region) + String.format(poiEndpoint, vin);
Request poiRequest = httpClient.POST(poiUrl);
authService.get().addBasicHeaders(poiRequest);
poiRequest.header("X-SessionId", UUID.randomUUID().toString());

View File

@ -216,7 +216,7 @@ public class VehicleHandler extends BaseThingHandler {
var crBuilder = CommandRequest.newBuilder().setVin(config.get().vin).setRequestId(UUID.randomUUID().toString());
String group = channelUID.getGroupId();
String channel = channelUID.getIdWithoutGroup();
String pin = accountHandler.get().config.get().pin;
String pin = accountHandler.get().config.pin;
if (group == null) {
logger.trace("No command {} found for {}", command, channel);
return;

View File

@ -1,106 +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.mercedesme.internal.server;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.server.Connector;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.ServerConnector;
import org.eclipse.jetty.servlet.ServletHandler;
import org.openhab.binding.mercedesme.internal.Constants;
import org.openhab.binding.mercedesme.internal.config.AccountConfiguration;
import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* {@link AuthServer} provides HTTP Server to show servlet content of the authentication process
*
* @author Bernd Weymann - Initial contribution
*/
@NonNullByDefault
public class AuthServer {
private static final Logger LOGGER = LoggerFactory.getLogger(AuthServer.class);
private static final Map<Integer, AuthServer> SERVER_MAP = new HashMap<>();
private static final AccessTokenResponse INVALID_ACCESS_TOKEN = new AccessTokenResponse();
private final HttpClient httpClient;
private Optional<Server> server = Optional.empty();
private AccountConfiguration config;
public String callbackUrl;
public AuthServer(HttpClient hc, AccountConfiguration config, String callbackUrl) {
httpClient = hc;
SERVER_MAP.put(Integer.valueOf(config.callbackPort), this);
this.config = config;
this.callbackUrl = callbackUrl;
INVALID_ACCESS_TOKEN.setAccessToken(Constants.EMPTY);
}
public void dispose() {
SERVER_MAP.remove(Integer.valueOf(config.callbackPort));
}
public boolean start() {
// avoid real server start for unit tests
if (server.isPresent() || Constants.JUNIT_SERVER_ADDR.equals(callbackUrl)) {
return true;
}
server = Optional.of(new Server());
ServerConnector connector = new ServerConnector(server.get());
connector.setPort(config.callbackPort);
server.get().setConnectors(new Connector[] { connector });
ServletHandler servletHandler = new ServletHandler();
server.get().setHandler(servletHandler);
servletHandler.addServletWithMapping(AuthServlet.class, Constants.CALLBACK_ENDPOINT);
try {
server.get().start();
return true;
} catch (Exception e) {
LOGGER.trace("Cannot start Callback Server for port {}, Error {}", config.callbackPort, e.getMessage());
server = Optional.empty();
return false;
}
}
public void stop() {
try {
if (server.isPresent()) {
server.get().stop();
server = Optional.empty();
}
} catch (Exception e) {
LOGGER.trace("Cannot start Callback Server for port {}, Error {}", config.callbackPort, e.getMessage());
}
}
@Nullable
public static AuthServer getServer(int port) {
return SERVER_MAP.get(port);
}
public HttpClient getHttpClient() {
return httpClient;
}
public String getRegion() {
return config.region;
}
}

View File

@ -16,9 +16,7 @@ import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
@ -33,7 +31,6 @@ import org.eclipse.jetty.client.util.StringContentProvider;
import org.eclipse.jetty.http.HttpHeader;
import org.openhab.binding.mercedesme.internal.Constants;
import org.openhab.binding.mercedesme.internal.config.AccountConfiguration;
import org.openhab.binding.mercedesme.internal.dto.PINRequest;
import org.openhab.binding.mercedesme.internal.dto.TokenResponse;
import org.openhab.binding.mercedesme.internal.utils.Utils;
import org.openhab.core.auth.client.oauth2.AccessTokenRefreshListener;
@ -42,6 +39,8 @@ import org.openhab.core.storage.Storage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.JsonSyntaxException;
/**
* {@link AuthService} helpers for token management
*
@ -49,23 +48,19 @@ import org.slf4j.LoggerFactory;
*/
@NonNullByDefault
public class AuthService {
public static final AccessTokenResponse INVALID_TOKEN = new AccessTokenResponse();
private static final int EXPIRATION_BUFFER = 5;
private static final Map<Integer, AuthService> AUTH_MAP = new HashMap<>();
private final Logger logger = LoggerFactory.getLogger(AuthService.class);
AccessTokenRefreshListener listener;
private AccessTokenRefreshListener listener;
private AccountConfiguration config;
private AccessTokenResponse token = Utils.INVALID_TOKEN;
private Storage<String> storage;
private HttpClient httpClient;
private String identifier;
private AccountConfiguration config;
private Locale locale;
private Storage<String> storage;
private AccessTokenResponse token;
public AuthService(AccessTokenRefreshListener atrl, HttpClient hc, AccountConfiguration ac, Locale l,
Storage<String> store) {
INVALID_TOKEN.setAccessToken(Constants.NOT_SET);
INVALID_TOKEN.setRefreshToken(Constants.NOT_SET);
Storage<String> store, String refreshToken) {
listener = atrl;
httpClient = hc;
config = ac;
@ -73,129 +68,53 @@ public class AuthService {
locale = l;
storage = store;
// restore token
String storedObject = storage.get(identifier);
if (storedObject == null) {
token = INVALID_TOKEN;
listener.onAccessTokenResponse(token);
} else {
token = Utils.fromString(storedObject);
if (token.isExpired(Instant.now(), EXPIRATION_BUFFER)) {
if (!Constants.NOT_SET.equals(token.getRefreshToken())) {
refreshToken();
listener.onAccessTokenResponse(token);
} else {
token = INVALID_TOKEN;
listener.onAccessTokenResponse(token);
// restore token from persistence if available
String storedToken = storage.get(identifier);
if (storedToken != null) {
// returns INVALID_TOKEN in case of an error
logger.trace("MB-Auth {} Restore token from persistence", prefix());
try {
logger.trace("MB-Auth {} storedToken {}", prefix(), storedToken);
TokenResponse tokenResponseJson = Utils.GSON.fromJson(storedToken, TokenResponse.class);
token = decodeToken(tokenResponseJson);
if (!tokenIsValid()) {
token = Utils.INVALID_TOKEN;
storage.remove(identifier);
logger.trace("MB-Auth {} invalid storedToken {}", prefix(), storedToken);
}
} else {
listener.onAccessTokenResponse(token);
} catch (JsonSyntaxException jse) {
// fallback of non human readable base64 token persistence
logger.debug("MB-Auth {} Fallback token decoding", prefix());
token = Utils.fromString(storedToken);
}
} else {
// initialize token with refresh token from configuration and expiration 0
// this will trigger an immediately refresh of the token
logger.trace("MB-Auth {} Create token from config", prefix());
token = new AccessTokenResponse();
token.setAccessToken(refreshToken);
token.setRefreshToken(refreshToken);
token.setExpiresIn(0);
}
AUTH_MAP.put(config.callbackPort, this);
}
@Nullable
public static AuthService getAuthService(Integer key) {
return AUTH_MAP.get(key);
public synchronized String getToken() {
if (token.isExpired(Instant.now(), EXPIRATION_BUFFER)) {
if (tokenIsValid()) {
refreshToken();
}
}
return token.getAccessToken();
}
/**
*
* @return guid from request to create token in next step
*/
public String requestPin() {
String configUrl = Utils.getAuthConfigURL(config.region);
String sessionId = UUID.randomUUID().toString();
Request configRequest = httpClient.newRequest(configUrl);
addBasicHeaders(configRequest);
configRequest.header("X-Trackingid", UUID.randomUUID().toString());
configRequest.header("X-Sessionid", sessionId);
try {
ContentResponse cr = configRequest.timeout(Constants.REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS).send();
if (cr.getStatus() == 200) {
logger.trace("{} Config Request PIN fine {} {}", prefix(), cr.getStatus(), cr.getContentAsString());
} else {
logger.trace("{} Failed to request config for pin {} {}", prefix(), cr.getStatus(),
cr.getContentAsString());
return Constants.NOT_SET;
}
} catch (InterruptedException | TimeoutException | ExecutionException e) {
logger.trace("{} Failed to request config for pin {}", prefix(), e.getMessage());
return Constants.NOT_SET;
}
String url = Utils.getAuthURL(config.region);
Request req = httpClient.POST(url);
addBasicHeaders(req);
req.header("X-Trackingid", UUID.randomUUID().toString());
req.header("X-Sessionid", sessionId);
PINRequest pr = new PINRequest(config.email, locale.getCountry());
req.header(HttpHeader.CONTENT_TYPE, "application/json");
logger.trace("{} payload {}", url, Utils.GSON.toJson(pr));
req.content(new StringContentProvider(Utils.GSON.toJson(pr), "utf-8"));
try {
ContentResponse cr = req.timeout(Constants.REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS).send();
if (cr.getStatus() == 200) {
logger.trace("{} Request PIN fine {} {}", prefix(), cr.getStatus(), cr.getContentAsString());
return pr.nonce;
} else {
logger.trace("{} Failed to request pin {} {}", prefix(), cr.getStatus(), cr.getContentAsString());
}
} catch (InterruptedException | TimeoutException | ExecutionException e) {
logger.trace("{} Failed to request pin {}", prefix(), e.getMessage());
}
return Constants.NOT_SET;
}
public boolean requestToken(String password) {
try {
// Request + headers
String url = Utils.getTokenUrl(config.region);
Request req = httpClient.POST(url);
addBasicHeaders(req);
req.header("Stage", "prod");
req.header("X-Device-Id", UUID.randomUUID().toString());
req.header("X-Request-Id", UUID.randomUUID().toString());
// Content URL form
String clientId = "client_id="
+ URLEncoder.encode(Utils.getLoginAppId(config.region), StandardCharsets.UTF_8.toString());
String grantAttribute = "grant_type=password";
String userAttribute = "username=" + URLEncoder.encode(config.email, StandardCharsets.UTF_8.toString());
String passwordAttribute = "password=" + URLEncoder.encode(password, StandardCharsets.UTF_8.toString());
String scopeAttribute = "scope=" + URLEncoder.encode(Constants.SCOPE, StandardCharsets.UTF_8.toString());
String content = clientId + "&" + grantAttribute + "&" + userAttribute + "&" + passwordAttribute + "&"
+ scopeAttribute;
req.header(HttpHeader.CONTENT_TYPE, "application/x-www-form-urlencoded");
req.content(new StringContentProvider(content));
// Send
ContentResponse cr = req.timeout(Constants.REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS).send();
if (cr.getStatus() == 200) {
String responseString = cr.getContentAsString();
saveTokenResponse(responseString);
listener.onAccessTokenResponse(token);
return true;
} else {
logger.trace("{} Failed to get token {} {}", prefix(), cr.getStatus(), cr.getContentAsString());
}
} catch (InterruptedException | TimeoutException | ExecutionException | UnsupportedEncodingException e) {
logger.trace("{} Failed to get token {}", prefix(), e.getMessage());
}
return false;
}
public void refreshToken() {
private void refreshToken() {
logger.trace("MB-Auth {} refreshToken", prefix());
try {
String url = Utils.getTokenUrl(config.region);
Request req = httpClient.POST(url);
req.header("X-Device-Id", UUID.randomUUID().toString());
req.header("X-Request-Id", UUID.randomUUID().toString());
// Content URL form
String grantAttribute = "grant_type=refresh_token";
String refreshTokenAttribute = "refresh_token="
+ URLEncoder.encode(token.getRefreshToken(), StandardCharsets.UTF_8.toString());
@ -203,35 +122,79 @@ public class AuthService {
req.header(HttpHeader.CONTENT_TYPE, "application/x-www-form-urlencoded");
req.content(new StringContentProvider(content));
// Send
ContentResponse cr = req.timeout(Constants.REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS).send();
if (cr.getStatus() == 200) {
saveTokenResponse(cr.getContentAsString());
listener.onAccessTokenResponse(token);
int tokenResponseStatus = cr.getStatus();
String tokenResponse = cr.getContentAsString();
if (tokenResponseStatus == 200) {
TokenResponse tokenResponseJson = Utils.GSON.fromJson(tokenResponse, TokenResponse.class);
if (tokenResponseJson != null) {
// response doesn't contain creation date time so set it manually
tokenResponseJson.createdOn = Instant.now().toString();
// a new refresh token is delivered optional
// if not set in response take old one
if (Constants.NOT_SET.equals(tokenResponseJson.refreshToken)) {
tokenResponseJson.refreshToken = token.getRefreshToken();
}
token = decodeToken(tokenResponseJson);
if (tokenIsValid()) {
String tokenStore = Utils.GSON.toJson(tokenResponseJson);
logger.debug("MB-Auth {} refreshToken result {}", prefix(), token.toString());
storage.put(identifier, tokenStore);
} else {
token = Utils.INVALID_TOKEN;
storage.remove(identifier);
logger.warn("MB-Auth {} Refresh token delivered invalid result {} {}", prefix(),
tokenResponseStatus, tokenResponse);
}
} else {
logger.debug("MB-Auth {} token refersh delivered not parsable result {}", prefix(), tokenResponse);
token = Utils.INVALID_TOKEN;
}
} else {
logger.trace("{} Failed to refresh token {} {}", prefix(), cr.getStatus(), cr.getContentAsString());
token = Utils.INVALID_TOKEN;
/**
* 1) remove token from storage
* 2) listener will be informed about INVALID_TOKEN and bridge will go OFFLINE
* 3) user needs to update refreshToken configuration parameter
*/
storage.remove(identifier);
logger.warn("MB-Auth {} Failed to refresh token {} {}", prefix(), tokenResponseStatus, tokenResponse);
}
} catch (InterruptedException | TimeoutException | ExecutionException | UnsupportedEncodingException e) {
logger.trace("{} Failed to refresh token {}", prefix(), e.getMessage());
listener.onAccessTokenResponse(token);
} catch (InterruptedException | TimeoutException | ExecutionException | UnsupportedEncodingException
| JsonSyntaxException e) {
logger.info("{} Failed to refresh token {}", prefix(), e.getMessage());
}
}
public String getToken() {
if (token.isExpired(Instant.now(), EXPIRATION_BUFFER)) {
if (!Constants.NOT_SET.equals(token.getRefreshToken())) {
refreshToken();
// token shall be updated now - retry expired check
if (token.isExpired(Instant.now(), EXPIRATION_BUFFER)) {
token = INVALID_TOKEN;
listener.onAccessTokenResponse(token);
return Constants.NOT_SET;
}
private AccessTokenResponse decodeToken(@Nullable TokenResponse tokenJson) {
if (tokenJson != null) {
AccessTokenResponse atr = new AccessTokenResponse();
atr.setCreatedOn(Instant.parse(tokenJson.createdOn));
atr.setExpiresIn(tokenJson.expiresIn);
atr.setAccessToken(tokenJson.accessToken);
if (!Constants.NOT_SET.equals(tokenJson.refreshToken)) {
atr.setRefreshToken(tokenJson.refreshToken);
} else {
token = INVALID_TOKEN;
logger.trace("{} Refresh token empty", prefix());
// Preserve refresh token if available
if (!Constants.NOT_SET.equals(token.getRefreshToken())) {
atr.setRefreshToken(token.getRefreshToken());
} else {
logger.debug("MB-Auth {} Neither new nor old refresh token available", prefix());
return Utils.INVALID_TOKEN;
}
}
atr.setTokenType("Bearer");
atr.setScope(Constants.SCOPE);
return atr;
} else {
logger.debug("MB-Auth {} Neither Token Response is null", prefix());
}
return token.getAccessToken();
return Utils.INVALID_TOKEN;
}
private boolean tokenIsValid() {
return !Constants.NOT_SET.equals(token.getAccessToken()) && !Constants.NOT_SET.equals(token.getRefreshToken());
}
public void addBasicHeaders(Request req) {
@ -244,30 +207,6 @@ public class AuthService {
req.header("Ris-Application-Version", Utils.getRisApplicationVersion(config.region));
}
private void saveTokenResponse(String response) {
TokenResponse tr = Utils.GSON.fromJson(response, TokenResponse.class);
AccessTokenResponse atr = new AccessTokenResponse();
if (tr != null) {
atr.setAccessToken(tr.accessToken);
atr.setCreatedOn(Instant.now());
atr.setExpiresIn(tr.expiresIn);
// Preserve refresh token if available
if (Constants.NOT_SET.equals(tr.refreshToken) && !Constants.NOT_SET.equals(token.getRefreshToken())) {
atr.setRefreshToken(token.getRefreshToken());
} else if (!Constants.NOT_SET.equals(tr.refreshToken)) {
atr.setRefreshToken(tr.refreshToken);
} else {
logger.trace("{} Neither new nor old refresh token available", prefix());
}
atr.setTokenType("Bearer");
atr.setScope(Constants.SCOPE);
storage.put(identifier, Utils.toString(atr));
token = atr;
} else {
logger.trace("{} Token Response is null", prefix());
}
}
private String prefix() {
return "[" + config.email + "] ";
}

View File

@ -1,104 +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.mercedesme.internal.server;
import java.io.IOException;
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.mercedesme.internal.Constants;
/**
* {@link AuthServlet} provides simple HTML pages for authorization workflow
*
* @author Bernd Weymann - Initial contribution
*/
@SuppressWarnings("serial")
@NonNullByDefault
public class AuthServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
AuthService myAuthService = AuthService.getAuthService(request.getLocalPort());
String guid = request.getParameter(Constants.GUID);
String pin = request.getParameter(Constants.PIN);
if (guid == null && pin == null && myAuthService != null) {
// request PIN
String requestVal = myAuthService.requestPin();
if (!Constants.NOT_SET.equals(requestVal)) {
response.setStatus(HttpServletResponse.SC_OK);
response.getWriter().println("<HTML>");
response.getWriter().println("<BODY>");
response.getWriter().println("<H1>Step 1 - PIN Requested</H1>");
response.getWriter().println("<BR>");
response.getWriter().println("PIN was requested and should be present in your EMail Inbox<BR>");
response.getWriter()
.println("Check first if you received the PIN and then continue with the below Link<BR>");
response.getWriter().println("<a href=\"" + Constants.CALLBACK_ENDPOINT + "?guid=" + requestVal
+ "\">Click here to continue with Step 2</a>");
response.getWriter().println("</BODY>");
response.getWriter().println("</HTML>");
} else {
response.setStatus(HttpServletResponse.SC_OK);
response.getWriter().println("<HTML>");
response.getWriter().println("<BODY>");
response.getWriter().println("Something went wrong<BR>");
response.getWriter().println("</BODY>");
response.getWriter().println("</HTML>");
}
} else if (guid != null && pin == null && myAuthService != null) {
// show insert PIN input field
response.setContentType("text/html");
response.setStatus(HttpServletResponse.SC_OK);
response.getWriter().println("<HTML>");
response.getWriter().println("<BODY>");
response.getWriter().println("<H1>Step 2 - Enter PIN</H1>");
response.getWriter().println("<BR>");
response.getWriter().println("Enter PIN in second input field - leave guid as it is!<BR>");
response.getWriter().println("<form action=\"" + Constants.CALLBACK_ENDPOINT + "\">");
response.getWriter().println("<BR>");
response.getWriter().println("<label for=\"GUID\">GUID</label>");
response.getWriter().println("<input type=\"text\" id=\"guid\" name=\"guid\" value=\"" + guid + "\">");
response.getWriter().println("<BR>");
response.getWriter().println("<label for=\"PIN\">PIN</label>");
response.getWriter().println("<input type=\"text\" id=\"pin\" name=\"pin\" placeholder=\"Your PIN\">");
response.getWriter().println("<BR>");
response.getWriter().println("<input type=\"submit\" value=\"Submit\">");
response.getWriter().println("</form>");
response.getWriter().println("</BODY>");
response.getWriter().println("</HTML>");
} else if (guid != null && pin != null && myAuthService != null) {
// call getToken and show result
boolean result = myAuthService.requestToken(guid + ":" + pin);
response.setContentType("text/html");
response.setStatus(HttpServletResponse.SC_OK);
response.getWriter().println("<HTML>");
response.getWriter().println("<BODY>");
response.getWriter().println("<H1>Step 3 - Save Token</H1>");
response.getWriter().println("<BR>");
if (result) {
response.getWriter().println("Success - everything done!<BR>");
} else {
response.getWriter().println("Failure - Please check logs for further analysis!<BR>");
}
response.getWriter().println("</BODY>");
response.getWriter().println("</HTML>");
}
}
}

View File

@ -93,11 +93,11 @@ public class MBWebsocket {
client.setStopTimeout(CONNECT_TIMEOUT_MS);
ClientUpgradeRequest request = accountHandler.getClientUpgradeRequest();
String websocketURL = accountHandler.getWSUri();
logger.trace("Websocket start {}", websocketURL);
if (Constants.JUNIT_TOKEN.equals(request.getHeader("Authorization"))) {
// avoid unit test requesting real websocket - simply return
return;
}
logger.trace("Websocket start {}", websocketURL);
client.start();
client.connect(this, new URI(websocketURL), request);
while (keepAlive || Instant.now().isBefore(runTill)) {
@ -177,7 +177,8 @@ public class MBWebsocket {
}
} else {
if (!b) {
// after keep alive is finished add 5 minutes to cover e.g. door events after trip is finished
// after keep alive is finished add 5 minutes to cover e.g. door events after
// trip is finished
runTill = Instant.now().plusMillis(KEEP_ALIVE_ADDON);
logger.trace("Websocket - keep alive stop - run till {}", runTill.toString());
}
@ -199,8 +200,10 @@ public class MBWebsocket {
* https://community.openhab.org/t/mercedes-me/136866/12
* Release Websocket thread as early as possible to avoid execeptions
*
* 1. Websocket thread responsible for reading stream in bytes and enqueue for AccountHandler.
* 2. AccountHamdler thread responsible for encoding proto message. In case of update enqueue proto message
* 1. Websocket thread responsible for reading stream in bytes and enqueue for
* AccountHandler.
* 2. AccountHamdler thread responsible for encoding proto message. In case of
* update enqueue proto message
* at VehicleHandöer
* 3. VehicleHandler responsible to update channels
*/
@ -225,6 +228,7 @@ public class MBWebsocket {
@OnWebSocketError
public void onError(Throwable t) {
logger.debug("Error during web socket connection - {}", t.getMessage());
accountHandler.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"@text/mercedesme.account.status.websocket-failure [\"" + t.getMessage() + "\"]");
}

View File

@ -13,10 +13,8 @@
package org.openhab.binding.mercedesme.internal.utils;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
@ -37,7 +35,6 @@ import org.json.JSONArray;
import org.json.JSONObject;
import org.openhab.binding.mercedesme.internal.Constants;
import org.openhab.binding.mercedesme.internal.MercedesMeHandlerFactory;
import org.openhab.binding.mercedesme.internal.server.AuthService;
import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
import org.openhab.core.i18n.LocaleProvider;
import org.openhab.core.i18n.TimeZoneProvider;
@ -78,14 +75,13 @@ public class Utils {
private static final int R = 6371; // Radius of the earth
private static int port = 8090;
private static TimeZoneProvider timeZoneProvider = new TimeZoneProvider() {
public static TimeZoneProvider timeZoneProvider = new TimeZoneProvider() {
@Override
public ZoneId getTimeZone() {
return ZoneId.systemDefault();
}
};
private static LocaleProvider localeProvider = new LocaleProvider() {
public static LocaleProvider localeProvider = new LocaleProvider() {
@Override
public Locale getLocale() {
return Locale.getDefault();
@ -94,10 +90,13 @@ public class Utils {
public static final Gson GSON = new Gson();
public static final Map<String, Integer> ZONE_HASHMAP = new HashMap<>();
public static final Map<String, Integer> PROGRAM_HASHMAP = new HashMap<>();
public static final AccessTokenResponse INVALID_TOKEN = new AccessTokenResponse();
public static void initialize(TimeZoneProvider tzp, LocaleProvider lp) {
timeZoneProvider = tzp;
localeProvider = lp;
INVALID_TOKEN.setAccessToken(Constants.NOT_SET);
INVALID_TOKEN.setRefreshToken(Constants.NOT_SET);
}
/**
@ -138,27 +137,6 @@ public class Utils {
return port;
}
/**
* Register port for an AccountHandler
*/
public static synchronized void addPort(int portNr) {
if (PORTS.contains(portNr) && portNr != 99999) {
LOGGER.warn("Port {} already occupied", portNr);
}
PORTS.add(portNr);
}
/**
* Unregister port for an AccountHandler
*/
public static synchronized void removePort(int portNr) {
PORTS.remove(Integer.valueOf(portNr));
}
public static String getCallbackAddress(String callbackIP, int callbackPort) {
return "http://" + callbackIP + Constants.COLON + callbackPort + Constants.CALLBACK_ENDPOINT;
}
/**
* Calculate REST API server address according to region
*
@ -286,41 +264,6 @@ public class Utils {
}
}
/**
* Calculate authorization config URL as pre-configuration prior to authorization call
*
* @param region - configured region
* @return authorization config URL as String
*/
public static String getAuthConfigURL(String region) {
return getRestAPIServer(region) + "/v1/config";
}
/**
* Calculate login app id according to region
*
* @param region - configured region
* @return login app id as String
*/
public static String getLoginAppId(String region) {
switch (region) {
case Constants.REGION_CHINA:
return Constants.LOGIN_APP_ID_CN;
default:
return Constants.LOGIN_APP_ID;
}
}
/**
* Calculate authorization URL for authorization call
*
* @param region - configured region
* @return authorization URL as String
*/
public static String getAuthURL(String region) {
return getRestAPIServer(region) + "/v1/login";
}
/**
* Calculate token URL for getting token
*
@ -337,6 +280,7 @@ public class Utils {
* @param token - Base64 String from storage
* @return AccessTokenResponse decoded from String, invalid token otherwise
*/
@Deprecated
public static AccessTokenResponse fromString(String token) {
try {
byte[] data = Base64.getDecoder().decode(token);
@ -347,25 +291,7 @@ public class Utils {
} catch (IOException | ClassNotFoundException e) {
LOGGER.warn("Error converting string to token {}", e.getMessage());
}
return AuthService.INVALID_TOKEN;
}
/**
* Encode AccessTokenResponse as Base64 String for storage
*
* @param token - AccessTokenResponse to convert
*/
public static String toString(AccessTokenResponse token) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(token);
oos.close();
return Base64.getEncoder().encodeToString(baos.toByteArray());
} catch (IOException e) {
LOGGER.warn("Error converting token to string {}", e.getMessage());
}
return Constants.NOT_SET;
return INVALID_TOKEN;
}
/**

View File

@ -19,6 +19,11 @@
<option value="CN">China</option>
</options>
</parameter>
<parameter name="refreshToken" type="text" required="true">
<label>Refresh Token</label>
<description>Refresh Token from MB Token Requester app</description>
<default>"takeover previous token"</default>
</parameter>
<parameter name="pin" type="text" required="false">
<label>PIN</label>
<description>PIN for commands</description>
@ -29,15 +34,5 @@
<description>Refresh Interval in Minutes</description>
<default>15</default>
</parameter>
<parameter name="callbackIP" type="text">
<label>Callback IP Address</label>
<description>IP address for openHAB callback URL</description>
<advanced>true</advanced>
</parameter>
<parameter name="callbackPort" type="integer">
<label>Callback Port Number</label>
<description>Port Number for openHAB callback URL</description>
<advanced>true</advanced>
</parameter>
</config-description>
</config-description:config-descriptions>

View File

@ -19,16 +19,14 @@ thing-type.mercedesme.hybrid.description = Conventional Fuel Vehicle with suppor
thing-type.config.mercedesme.bev.batteryCapacity.label = Battery Capacity
thing-type.config.mercedesme.bev.batteryCapacity.description = Battery capacity in kWh of vehicle
thing-type.config.mercedesme.bev.vin.label = Vehicle Identification Number
thing-type.config.mercedesme.bridge.callbackIP.label = Callback IP Address
thing-type.config.mercedesme.bridge.callbackIP.description = IP address for openHAB callback URL
thing-type.config.mercedesme.bridge.callbackPort.label = Callback Port Number
thing-type.config.mercedesme.bridge.callbackPort.description = Port Number for openHAB callback URL
thing-type.config.mercedesme.bridge.email.label = MercedesMe EMail
thing-type.config.mercedesme.bridge.email.description = EMail address for MercedesMe account
thing-type.config.mercedesme.bridge.pin.label = PIN
thing-type.config.mercedesme.bridge.pin.description = PIN for commands
thing-type.config.mercedesme.bridge.refreshInterval.label = Refresh Interval
thing-type.config.mercedesme.bridge.refreshInterval.description = Refresh Interval in Minutes
thing-type.config.mercedesme.bridge.refreshToken.label = Refresh Token
thing-type.config.mercedesme.bridge.refreshToken.description = Refresh Token from MB Token Requester app
thing-type.config.mercedesme.bridge.region.label = Region
thing-type.config.mercedesme.bridge.region.option.EU = Europe
thing-type.config.mercedesme.bridge.region.option.NA = North America
@ -374,13 +372,10 @@ longitudeDescription = Longitude of the location
# thing status types
mercedesme.account.status.authorization-needed = Manual Authorization needed at {0}
mercedesme.account.status.authorization-needed = Generate new refresh token
mercedesme.account.status.refresh-token-missing = Refresh token missing
mercedesme.account.status.email-missing = EMail missing
mercedesme.account.status.region-missing = Region missing
mercedesme.account.status.refresh-invalid = Refresh Interval Invalid
mercedesme.account.status.ip-missing = Callback IP missing
mercedesme.account.status.port-missing = Callback Port missing
mercedesme.account.status.server-restart = Disable and enable Bridge to restart Authorization Server
mercedesme.vehicle.status.bridge-missing = Bridge not set
mercedesme.account.status.ip-autodetect-failure = Callback IP cannot be detected
mercedesme.account.status.websocket-failure = Websocket Exception: Reason: {0}

View File

@ -13,12 +13,18 @@
package org.openhab.binding.mercedesme;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;
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.junit.jupiter.api.Test;
import org.openhab.binding.mercedesme.internal.Constants;
import org.openhab.binding.mercedesme.internal.handler.AccountHandlerMock;
@ -33,7 +39,7 @@ import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.internal.BridgeImpl;
/**
* {@link StatusTests} sequencess for testing ThingStatus
* {@link StatusTests} sequences for testing ThingStatus
*
* @author Bernd Weymann - Initial contribution
*/
@ -41,23 +47,41 @@ import org.openhab.core.thing.internal.BridgeImpl;
class StatusTests {
public static void tearDown(AccountHandlerMock ahm) {
// ahm.setCallback(null);
ahm.dispose();
try {
Thread.sleep(250);
} catch (InterruptedException e) {
fail();
}
ahm.dispose();
}
public static HttpClient getHttpClient(int tokenResponseCode) {
Utils.initialize(Utils.timeZoneProvider, Utils.localeProvider);
HttpClient httpClient = mock(HttpClient.class);
try {
Request clientRequest = mock(Request.class);
when(httpClient.POST(anyString())).thenReturn(clientRequest);
when(clientRequest.header(anyString(), anyString())).thenReturn(clientRequest);
when(clientRequest.content(any())).thenReturn(clientRequest);
when(clientRequest.timeout(anyLong(), any())).thenReturn(clientRequest);
ContentResponse response = mock(ContentResponse.class);
when(response.getStatus()).thenReturn(tokenResponseCode);
String tokenResponse = FileReader.readFileInString("src/test/resources/json/TokenResponse.json");
when(response.getContentAsString()).thenReturn(tokenResponse);
when(clientRequest.send()).thenReturn(response);
} catch (InterruptedException | TimeoutException | ExecutionException e) {
fail(e.getMessage());
}
return httpClient;
}
@Test
void testInvalidConfig() {
BridgeImpl bi = new BridgeImpl(new ThingTypeUID("test", "account"), "MB");
Map<String, Object> config = new HashMap<>();
config.put("callbackIP", "999.999.999.999");
config.put("callbackPort", "99999");
config.put("refreshToken", Constants.JUNIT_REFRESH_TOKEN);
bi.setConfiguration(new Configuration(config));
AccountHandlerMock ahm = new AccountHandlerMock(bi, null);
AccountHandlerMock ahm = new AccountHandlerMock(bi, null, getHttpClient(404));
ThingCallbackListener tcl = new ThingCallbackListener();
ahm.setCallback(tcl);
ahm.initialize();
@ -83,6 +107,7 @@ class StatusTests {
tcl = new ThingCallbackListener();
ahm.setCallback(tcl);
ahm.initialize();
ahm.refreshToken();
tsi = tcl.getThingStatus();
assertEquals(ThingStatus.OFFLINE, tsi.getStatus(), "Auth offline");
assertEquals(ThingStatusDetail.COMMUNICATION_ERROR, tsi.getStatusDetail(), "Auth detail");
@ -107,13 +132,13 @@ class StatusTests {
config.put("refreshInterval", Integer.MAX_VALUE);
config.put("region", "row");
config.put("email", "a@b.c");
config.put("callbackIP", "999.999.999.999");
config.put("callbackPort", "99999");
config.put("refreshToken", "abc");
bi.setConfiguration(new Configuration(config));
AccountHandlerMock ahm = new AccountHandlerMock(bi, null);
AccountHandlerMock ahm = new AccountHandlerMock(bi, null, getHttpClient(404));
ThingCallbackListener tcl = new ThingCallbackListener();
ahm.setCallback(tcl);
ahm.initialize();
ahm.refreshToken();
ThingStatusInfo tsi = tcl.getThingStatus();
assertEquals(ThingStatus.OFFLINE, tsi.getStatus(), "Auth Offline");
assertEquals(ThingStatusDetail.COMMUNICATION_ERROR, tsi.getStatusDetail(), "Auth details");
@ -140,17 +165,10 @@ class StatusTests {
config.put("refreshInterval", Integer.MAX_VALUE);
config.put("region", "row");
config.put("email", "a@b.c");
config.put("callbackIP", "999.999.999.999");
config.put("callbackPort", "99999");
config.put("refreshToken", "abc");
bi.setConfiguration(new Configuration(config));
AccessTokenResponse token = new AccessTokenResponse();
token.setExpiresIn(3000);
token.setAccessToken(Constants.JUNIT_TOKEN);
token.setRefreshToken(Constants.JUNIT_REFRESH_TOKEN);
token.setCreatedOn(Instant.now());
token.setTokenType("Bearer");
token.setScope(Constants.SCOPE);
AccountHandlerMock ahm = new AccountHandlerMock(bi, Utils.toString(token));
String tokenResponse = FileReader.readFileInString("src/test/resources/json/TokenResponse.json");
AccountHandlerMock ahm = new AccountHandlerMock(bi, tokenResponse, getHttpClient(200));
ThingCallbackListener tcl = new ThingCallbackListener();
ahm.setCallback(tcl);
ahm.initialize();

View File

@ -15,7 +15,6 @@ package org.openhab.binding.mercedesme.internal.handler;
import static org.mockito.Mockito.mock;
import java.util.Locale;
import java.util.Optional;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
@ -26,7 +25,6 @@ import org.openhab.binding.mercedesme.internal.Constants;
import org.openhab.binding.mercedesme.internal.config.AccountConfiguration;
import org.openhab.binding.mercedesme.internal.discovery.MercedesMeDiscoveryService;
import org.openhab.core.i18n.LocaleProvider;
import org.openhab.core.net.NetworkAddressService;
import org.openhab.core.storage.Storage;
import org.openhab.core.storage.StorageService;
import org.openhab.core.test.storage.VolatileStorageService;
@ -54,18 +52,17 @@ public class AccountHandlerMock extends AccountHandler {
public AccountHandlerMock() {
super(mock(Bridge.class), mock(MercedesMeDiscoveryService.class), mock(HttpClient.class),
mock(LocaleProvider.class), mock(StorageService.class), mock(NetworkAddressService.class));
config = Optional.of(new AccountConfiguration());
mock(LocaleProvider.class), mock(StorageService.class));
config = new AccountConfiguration();
}
public AccountHandlerMock(Bridge b, @Nullable String storedObject) {
super(b, mock(MercedesMeDiscoveryService.class), mock(HttpClient.class), localeProvider, storageService,
mock(NetworkAddressService.class));
public AccountHandlerMock(Bridge b, @Nullable String storedObject, HttpClient httpClient) {
super(b, mock(MercedesMeDiscoveryService.class), httpClient, localeProvider, storageService);
if (storedObject != null) {
Storage<String> storage = storageService.getStorage(Constants.BINDING_ID);
storage.put("a@b.c", storedObject);
}
config = Optional.of(new AccountConfiguration());
config = new AccountConfiguration();
}
@Override
@ -94,4 +91,8 @@ public class AccountHandlerMock extends AccountHandler {
public void connect() {
super.ws.onConnect(mock(Session.class));
}
public void refreshToken() {
authService.get().getToken();
}
}

View File

@ -21,6 +21,7 @@ import java.util.Map;
import java.util.Optional;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.openhab.binding.mercedesme.FileReader;
import org.openhab.binding.mercedesme.internal.Constants;
@ -61,6 +62,11 @@ class VehicleHandlerTest {
private static final int EVENT_STORAGE_COUNT = HVAC_UPDATE_COUNT + POSITIONING_UPDATE_COUNT + ECOSCORE_UPDATE_COUNT
+ 76;
@BeforeAll
public static void init() {
Utils.initialize(Utils.timeZoneProvider, Utils.localeProvider);
}
public static Map<String, Object> createBEV() {
Thing thingMock = mock(Thing.class);
when(thingMock.getThingTypeUID()).thenReturn(Constants.THING_TYPE_BEV);

View File

@ -1,9 +1,9 @@
{
"accessToken": "Tkn",
"tokenType": "Bearer",
"expiresIn": 7199,
"refreshToken": "RfrshTkn",
"access_token": "junitTestToken",
"token_type": "Bearer",
"expires_in": 7199,
"refresh_token": "RfrshTkn",
"scope": "openid email phone profile offline_access ciam-uid",
"state": null,
"createdOn": "2023-10-04T01:47:08.007038393Z"
"created_on": "2023-10-04T01:47:08.007038393Z"
}