diff --git a/bundles/org.openhab.binding.boschindego/README.md b/bundles/org.openhab.binding.boschindego/README.md index f0bfa18c53888..f5f35e888d401 100644 --- a/bundles/org.openhab.binding.boschindego/README.md +++ b/bundles/org.openhab.binding.boschindego/README.md @@ -10,12 +10,21 @@ Currently the binding supports _**indego**_ mowers as a thing type with these | Parameter | Description | Default | |--------------------|-------------------------------------------------------------------|---------| -| username | Username for the Bosch Indego account | | -| password | Password for the Bosch Indego account | | | refresh | The number of seconds between refreshing device state when idle | 180 | | stateActiveRefresh | The number of seconds between refreshing device state when active | 30 | | cuttingTimeRefresh | The number of minutes between refreshing last/next cutting time | 60 | +### Authorization + +To authorize, please follow these steps: + +- In your browser, go to the [Bosch SingleKey ID login page](https://prodindego.b2clogin.com/prodindego.onmicrosoft.com/b2c_1a_signup_signin/oauth2/v2.0/authorize?redirect_uri=com.bosch.indegoconnect://login&client_id=65bb8c9d-1070-4fb4-aa95-853618acc876&response_type=code&scope=openid%20offline_access%20https://prodindego.onmicrosoft.com/indego-mobile-api/Indego.Mower.User). +- Select "Bosch ID", enter your e-mail address and password and clock "Log-in". +- In your browser, open Developer Tools. +- With developer tools showing in the right, go to [Bosch SingleKey ID login page](https://prodindego.b2clogin.com/prodindego.onmicrosoft.com/b2c_1a_signup_signin/oauth2/v2.0/authorize?redirect_uri=com.bosch.indegoconnect://login&client_id=65bb8c9d-1070-4fb4-aa95-853618acc876&response_type=code&scope=openid%20offline_access%20https://prodindego.onmicrosoft.com/indego-mobile-api/Indego.Mower.User) again. +- "Please wait..." should now be displayed. +- + ## Channels | Channel | Item Type | Description | Writeable | diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/BoschIndegoHandlerFactory.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/BoschIndegoHandlerFactory.java index 878b6cc43da0e..d9d902125c366 100644 --- a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/BoschIndegoHandlerFactory.java +++ b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/BoschIndegoHandlerFactory.java @@ -18,6 +18,7 @@ import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jetty.client.HttpClient; import org.openhab.binding.boschindego.internal.handler.BoschIndegoHandler; +import org.openhab.core.auth.client.oauth2.OAuthFactory; import org.openhab.core.i18n.LocaleProvider; import org.openhab.core.i18n.TimeZoneProvider; import org.openhab.core.i18n.TranslationProvider; @@ -37,21 +38,25 @@ * handlers. * * @author Jonas Fleck - Initial contribution + * @author Jacob Laursen - Replaced authorization by OAuth2 */ @NonNullByDefault @Component(service = ThingHandlerFactory.class, configurationPid = "binding.boschindego") public class BoschIndegoHandlerFactory extends BaseThingHandlerFactory { private final HttpClient httpClient; + private final OAuthFactory oAuthFactory; private final BoschIndegoTranslationProvider translationProvider; private final TimeZoneProvider timeZoneProvider; @Activate public BoschIndegoHandlerFactory(@Reference HttpClientFactory httpClientFactory, - final @Reference TranslationProvider i18nProvider, final @Reference LocaleProvider localeProvider, - final @Reference TimeZoneProvider timeZoneProvider, ComponentContext componentContext) { + final @Reference OAuthFactory oAuthFactory, final @Reference TranslationProvider i18nProvider, + final @Reference LocaleProvider localeProvider, final @Reference TimeZoneProvider timeZoneProvider, + ComponentContext componentContext) { super.activate(componentContext); this.httpClient = httpClientFactory.getCommonHttpClient(); + this.oAuthFactory = oAuthFactory; this.translationProvider = new BoschIndegoTranslationProvider(i18nProvider, localeProvider); this.timeZoneProvider = timeZoneProvider; } @@ -66,7 +71,7 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { ThingTypeUID thingTypeUID = thing.getThingTypeUID(); if (THING_TYPE_INDEGO.equals(thingTypeUID)) { - return new BoschIndegoHandler(thing, httpClient, translationProvider, timeZoneProvider); + return new BoschIndegoHandler(thing, httpClient, oAuthFactory, translationProvider, timeZoneProvider); } return null; diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/IndegoController.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/IndegoController.java index 03f177b31d036..87ff1a4991bdf 100644 --- a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/IndegoController.java +++ b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/IndegoController.java @@ -12,10 +12,9 @@ */ package org.openhab.binding.boschindego.internal; -import java.net.URI; +import java.io.IOException; import java.time.Duration; import java.time.Instant; -import java.util.Base64; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeoutException; @@ -33,13 +32,12 @@ import org.openhab.binding.boschindego.internal.dto.DeviceCommand; import org.openhab.binding.boschindego.internal.dto.PredictiveAdjustment; import org.openhab.binding.boschindego.internal.dto.PredictiveStatus; -import org.openhab.binding.boschindego.internal.dto.request.AuthenticationRequest; import org.openhab.binding.boschindego.internal.dto.request.SetStateRequest; -import org.openhab.binding.boschindego.internal.dto.response.AuthenticationResponse; import org.openhab.binding.boschindego.internal.dto.response.DeviceCalendarResponse; import org.openhab.binding.boschindego.internal.dto.response.DeviceStateResponse; import org.openhab.binding.boschindego.internal.dto.response.ErrorResponse; import org.openhab.binding.boschindego.internal.dto.response.LocationWeatherResponse; +import org.openhab.binding.boschindego.internal.dto.response.Mower; import org.openhab.binding.boschindego.internal.dto.response.OperatingDataResponse; import org.openhab.binding.boschindego.internal.dto.response.PredictiveLastCuttingResponse; import org.openhab.binding.boschindego.internal.dto.response.PredictiveNextCuttingResponse; @@ -48,7 +46,13 @@ import org.openhab.binding.boschindego.internal.exceptions.IndegoInvalidCommandException; import org.openhab.binding.boschindego.internal.exceptions.IndegoInvalidResponseException; import org.openhab.binding.boschindego.internal.exceptions.IndegoTimeoutException; +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.library.types.RawType; +import org.osgi.framework.FrameworkUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -69,19 +73,24 @@ @NonNullByDefault public class IndegoController { - private static final String BASE_URL = "https://api.indego.iot.bosch-si.com/api/v1/"; - private static final URI BASE_URI = URI.create(BASE_URL); + private static final String BASE_URL = "https://api.indego-cloud.iot.bosch-si.com/api/v1/"; private static final String SERIAL_NUMBER_SUBPATH = "alms/"; - private static final String SSO_COOKIE_NAME = "BOSCH_INDEGO_SSO"; - private static final String CONTEXT_HEADER_NAME = "x-im-context-id"; private static final String CONTENT_TYPE_HEADER = "application/json"; + private static final String BEARER = "Bearer "; + + private static final String BSK_CLIENT_ID = "65bb8c9d-1070-4fb4-aa95-853618acc876"; + private static final String BSK_AUTH_URI = "https://prodindego.b2clogin.com/prodindego.onmicrosoft.com/b2c_1a_signup_signin/oauth2/v2.0/authorize"; + private static final String BSK_TOKEN_URI = "https://prodindego.b2clogin.com/prodindego.onmicrosoft.com/b2c_1a_signup_signin/oauth2/v2.0/token"; + private static final String BSK_REDIRECT_URI = "com.bosch.indegoconnect://login"; + private static final String BSK_SCOPE = "openid offline_access https://prodindego.onmicrosoft.com/indego-mobile-api/Indego.Mower.User"; + private final Logger logger = LoggerFactory.getLogger(IndegoController.class); - private final String basicAuthenticationHeader; private final Gson gson = new Gson(); private final HttpClient httpClient; - - private IndegoSession session = new IndegoSession(); + private final String userAgent; + private String serialNumber = ""; + private OAuthClientService oAuthClientService; /** * Initialize the controller instance. @@ -89,130 +98,49 @@ public class IndegoController { * @param username the username for authenticating * @param password the password */ - public IndegoController(HttpClient httpClient, String username, String password) { + public IndegoController(HttpClient httpClient, OAuthFactory oAuthFactory) { this.httpClient = httpClient; - basicAuthenticationHeader = "Basic " - + Base64.getEncoder().encodeToString((username + ":" + password).getBytes()); - } - - /** - * Authenticate with server and store session context and serial number. - * - * @throws IndegoAuthenticationException if request was rejected as unauthorized - * @throws IndegoException if any communication or parsing error occurred - */ - private void authenticate() throws IndegoAuthenticationException, IndegoException { - int status = 0; - try { - Request request = httpClient.newRequest(BASE_URL + "authenticate").method(HttpMethod.POST) - .header(HttpHeader.AUTHORIZATION, basicAuthenticationHeader); - - AuthenticationRequest authRequest = new AuthenticationRequest(); - authRequest.device = ""; - authRequest.osType = "Android"; - authRequest.osVersion = "4.0"; - authRequest.deviceManufacturer = "unknown"; - authRequest.deviceType = "unknown"; - String json = gson.toJson(authRequest); - request.content(new StringContentProvider(json)); - request.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_HEADER); - - if (logger.isTraceEnabled()) { - logger.trace("POST request for {}", BASE_URL + "authenticate"); - } - - ContentResponse response = sendRequest(request); - status = response.getStatus(); - if (status == HttpStatus.UNAUTHORIZED_401) { - throw new IndegoAuthenticationException("Authentication was rejected"); - } - if (!HttpStatus.isSuccess(status)) { - throw new IndegoAuthenticationException("The request failed with HTTP error: " + status); - } - - String jsonResponse = response.getContentAsString(); - if (jsonResponse.isEmpty()) { - throw new IndegoInvalidResponseException("No content returned", status); - } - logger.trace("JSON response: '{}'", jsonResponse); - - AuthenticationResponse authenticationResponse = gson.fromJson(jsonResponse, AuthenticationResponse.class); - if (authenticationResponse == null) { - throw new IndegoInvalidResponseException("Response could not be parsed as AuthenticationResponse", - status); - } - session = new IndegoSession(authenticationResponse.contextId, authenticationResponse.serialNumber, - getContextExpirationTimeFromCookie()); - logger.debug("Initialized session {}", session); - } catch (JsonParseException e) { - throw new IndegoInvalidResponseException("Error parsing AuthenticationResponse", e, status); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IndegoException(e); - } catch (TimeoutException | ExecutionException e) { - throw new IndegoException(e); - } + this.oAuthClientService = oAuthFactory.createOAuthClientService("org.openhab.binding.boschindego", + BSK_TOKEN_URI, BSK_AUTH_URI, BSK_CLIENT_ID, null, BSK_SCOPE, false); + userAgent = "openHAB " + FrameworkUtil.getBundle(this.getClass()).getVersion().toString(); } - /** - * Get context expiration time as a calculated {@link Instant} relative to now. - * The information is obtained from max age in the Bosch Indego SSO cookie. - * Please note that this cookie is only sent initially when authenticating, so - * the value will not be subject to any updates. - * - * @return expiration time as {@link Instant} or {@link Instant#MIN} if not present - */ - private Instant getContextExpirationTimeFromCookie() { - return httpClient.getCookieStore().get(BASE_URI).stream().filter(c -> SSO_COOKIE_NAME.equals(c.getName())) - .findFirst().map(c -> { - return Instant.now().plusSeconds(c.getMaxAge()); - }).orElseGet(() -> { - return Instant.MIN; - }); + private String getAuthorizationUrl() throws OAuthException { + return oAuthClientService.getAuthorizationUrl(BSK_REDIRECT_URI, BSK_SCOPE, null); } - /** - * Deauthenticate session. This method should be called as part of cleanup to reduce - * lingering sessions. This can potentially avoid killed sessions in situation with - * multiple clients (e.g. openHAB and mobile app) if restrictions on concurrent - * number of sessions would be put on the service. - * - * @throws IndegoException if any communication or parsing error occurred - */ - public void deauthenticate() throws IndegoException { - if (session.isValid()) { - deleteRequest("authenticate"); - session.invalidate(); + private String getAuthorizationHeader() throws IndegoException { + final AccessTokenResponse accessTokenResponse; + try { + accessTokenResponse = oAuthClientService.getAccessTokenResponse(); + } catch (OAuthException | OAuthResponseException e) { + logger.debug("Error fetching access token: {}", e.getMessage(), e); + try { + String url = getAuthorizationUrl(); + throw new IndegoAuthenticationException( + "Error fetching access token. Invalid authcode? Please generate a new one: " + url, e); + } catch (OAuthException ignore) { + throw new IndegoAuthenticationException( + "Error fetching access token. Invalid authcode? Please generate a new one", e); + } + } catch (IOException e) { + throw new IndegoException(String.format("An unexpected IOException occurred: %s", e.getMessage()), e); + } + if (accessTokenResponse == null || accessTokenResponse.getAccessToken() == null + || accessTokenResponse.getAccessToken().isEmpty()) { + throw new IndegoAuthenticationException("No access token. Is this thing authorized?"); } + if (accessTokenResponse.getRefreshToken() == null || accessTokenResponse.getRefreshToken().isEmpty()) { + throw new IndegoAuthenticationException("No refresh token. Please reauthorize"); + } + return BEARER + accessTokenResponse.getAccessToken(); } - /** - * Wraps {@link #getRequest(String, Class)} into an authenticated session. - * - * @param path the relative path to which the request should be sent - * @param dtoClass the DTO class to which the JSON result should be deserialized - * @return the deserialized DTO from the JSON response - * @throws IndegoAuthenticationException if request was rejected as unauthorized - * @throws IndegoTimeoutException if device cannot be reached (gateway timeout error) - * @throws IndegoException if any communication or parsing error occurred - */ - private T getRequestWithAuthentication(String path, Class dtoClass) - throws IndegoAuthenticationException, IndegoTimeoutException, IndegoException { - if (!session.isValid()) { - authenticate(); - } + public void authorizeByAuthorizationCode(String authCode) throws IndegoAuthenticationException { try { - logger.debug("Session {} valid, skipping authentication", session); - return getRequest(path, dtoClass); - } catch (IndegoAuthenticationException e) { - if (logger.isTraceEnabled()) { - logger.trace("Context rejected", e); - } else { - logger.debug("Context rejected: {}", e.getMessage()); - } - session.invalidate(); - authenticate(); - return getRequest(path, dtoClass); + oAuthClientService.getAccessTokenResponseByAuthorizationCode(authCode, BSK_REDIRECT_URI); + } catch (OAuthException | OAuthResponseException | IOException e) { + throw new IndegoAuthenticationException("Failed to authorize by authorization code " + authCode, e); } } @@ -230,8 +158,8 @@ private T getRequest(String path, Class dtoClass) throws IndegoAuthenticationException, IndegoTimeoutException, IndegoException { int status = 0; try { - Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET).header(CONTEXT_HEADER_NAME, - session.getContextId()); + Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET) + .header(HttpHeader.AUTHORIZATION, getAuthorizationHeader()).agent(userAgent); if (logger.isTraceEnabled()) { logger.trace("GET request for {}", BASE_URL + path); } @@ -243,7 +171,7 @@ private T getRequest(String path, Class dtoClass) } if (status == HttpStatus.UNAUTHORIZED_401) { // This will currently not happen because "WWW-Authenticate" header is missing; see below. - throw new IndegoAuthenticationException("Context rejected"); + throw new IndegoAuthenticationException("Unauthorized"); } if (status == HttpStatus.GATEWAY_TIMEOUT_504) { throw new IndegoTimeoutException("Gateway timeout"); @@ -274,45 +202,17 @@ private T getRequest(String path, Class dtoClass) Response response = ((HttpResponseException) cause).getResponse(); if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) { /* - * When contextId is not valid, the service will respond with HTTP code 401 without - * any "WWW-Authenticate" header, violating RFC 7235. Jetty will then throw - * HttpResponseException. We need to handle this in order to attempt - * reauthentication. + * The service may respond with HTTP code 401 without any "WWW-Authenticate" + * header, violating RFC 7235. Jetty will then throw HttpResponseException. + * We need to handle this in order to attempt reauthentication. */ - throw new IndegoAuthenticationException("Context rejected", e); + throw new IndegoAuthenticationException("Unauthorized", e); } } throw new IndegoException(e); } } - /** - * Wraps {@link #getRawRequest(String)} into an authenticated session. - * - * @param path the relative path to which the request should be sent - * @return the raw data from the response - * @throws IndegoAuthenticationException if request was rejected as unauthorized - * @throws IndegoException if any communication or parsing error occurred - */ - private RawType getRawRequestWithAuthentication(String path) throws IndegoAuthenticationException, IndegoException { - if (!session.isValid()) { - authenticate(); - } - try { - logger.debug("Session {} valid, skipping authentication", session); - return getRawRequest(path); - } catch (IndegoAuthenticationException e) { - if (logger.isTraceEnabled()) { - logger.trace("Context rejected", e); - } else { - logger.debug("Context rejected: {}", e.getMessage()); - } - session.invalidate(); - authenticate(); - return getRawRequest(path); - } - } - /** * Sends a GET request to the server and returns the raw response. * @@ -324,8 +224,8 @@ private RawType getRawRequestWithAuthentication(String path) throws IndegoAuthen private RawType getRawRequest(String path) throws IndegoAuthenticationException, IndegoException { int status = 0; try { - Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET).header(CONTEXT_HEADER_NAME, - session.getContextId()); + Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET) + .header(HttpHeader.AUTHORIZATION, getAuthorizationHeader()).agent(userAgent); if (logger.isTraceEnabled()) { logger.trace("GET request for {}", BASE_URL + path); } @@ -384,22 +284,7 @@ private RawType getRawRequest(String path) throws IndegoAuthenticationException, */ private void putRequestWithAuthentication(String path, Object requestDto) throws IndegoAuthenticationException, IndegoException { - if (!session.isValid()) { - authenticate(); - } - try { - logger.debug("Session {} valid, skipping authentication", session); - putPostRequest(HttpMethod.PUT, path, requestDto); - } catch (IndegoAuthenticationException e) { - if (logger.isTraceEnabled()) { - logger.trace("Context rejected", e); - } else { - logger.debug("Context rejected: {}", e.getMessage()); - } - session.invalidate(); - authenticate(); - putPostRequest(HttpMethod.PUT, path, requestDto); - } + putPostRequest(HttpMethod.PUT, path, requestDto); } /** @@ -409,23 +294,8 @@ private void putRequestWithAuthentication(String path, Object requestDto) * @throws IndegoAuthenticationException if request was rejected as unauthorized * @throws IndegoException if any communication or parsing error occurred */ - private void postRequestWithAuthentication(String path) throws IndegoAuthenticationException, IndegoException { - if (!session.isValid()) { - authenticate(); - } - try { - logger.debug("Session {} valid, skipping authentication", session); - putPostRequest(HttpMethod.POST, path, null); - } catch (IndegoAuthenticationException e) { - if (logger.isTraceEnabled()) { - logger.trace("Context rejected", e); - } else { - logger.debug("Context rejected: {}", e.getMessage()); - } - session.invalidate(); - authenticate(); - putPostRequest(HttpMethod.POST, path, null); - } + private void postRequest(String path) throws IndegoAuthenticationException, IndegoException { + putPostRequest(HttpMethod.POST, path, null); } /** @@ -441,8 +311,8 @@ private void putPostRequest(HttpMethod method, String path, @Nullable Object req throws IndegoAuthenticationException, IndegoException { try { Request request = httpClient.newRequest(BASE_URL + path).method(method) - .header(CONTEXT_HEADER_NAME, session.getContextId()) - .header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_HEADER); + .header(HttpHeader.AUTHORIZATION, getAuthorizationHeader()) + .header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_HEADER).agent(userAgent); if (requestDto != null) { String payload = gson.toJson(requestDto); request.content(new StringContentProvider(payload)); @@ -502,32 +372,6 @@ private void putPostRequest(HttpMethod method, String path, @Nullable Object req } } - /** - * Sends a DELETE request to the server. - * - * @param path the relative path to which the request should be sent - * @throws IndegoException if any communication or parsing error occurred - */ - private void deleteRequest(String path) throws IndegoException { - try { - Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.DELETE) - .header(CONTEXT_HEADER_NAME, session.getContextId()); - if (logger.isTraceEnabled()) { - logger.trace("DELETE request for {}", BASE_URL + path); - } - ContentResponse response = sendRequest(request); - int status = response.getStatus(); - if (!HttpStatus.isSuccess(status)) { - throw new IndegoException("The request failed with error: " + status); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IndegoException(e); - } catch (TimeoutException | ExecutionException e) { - throw new IndegoException(e); - } - } - /** * Send request. This method exists for the purpose of avoiding multiple calls to * the server at the same time. @@ -551,11 +395,14 @@ private synchronized ContentResponse sendRequest(Request request) * @throws IndegoException if any communication or parsing error occurred */ public synchronized String getSerialNumber() throws IndegoAuthenticationException, IndegoException { - if (!session.isInitialized()) { - logger.debug("Session not yet initialized when serial number was requested; authenticating..."); - authenticate(); + if (!serialNumber.isEmpty()) { + return serialNumber; } - return session.getSerialNumber(); + + Mower[] mowers = getRequest(SERIAL_NUMBER_SUBPATH, Mower[].class); + serialNumber = mowers[0].serialNumber; + + return serialNumber; } /** @@ -566,8 +413,7 @@ public synchronized String getSerialNumber() throws IndegoAuthenticationExceptio * @throws IndegoException if any communication or parsing error occurred */ public DeviceStateResponse getState() throws IndegoAuthenticationException, IndegoException { - return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/state", - DeviceStateResponse.class); + return getRequest(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/state", DeviceStateResponse.class); } /** @@ -580,7 +426,7 @@ public DeviceStateResponse getState() throws IndegoAuthenticationException, Inde * @throws IndegoException if any communication or parsing error occurred */ public DeviceStateResponse getState(Duration timeout) throws IndegoAuthenticationException, IndegoException { - return getRequestWithAuthentication( + return getRequest( SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/state?longpoll=true&timeout=" + timeout.getSeconds(), DeviceStateResponse.class); } @@ -596,7 +442,7 @@ public DeviceStateResponse getState(Duration timeout) throws IndegoAuthenticatio */ public OperatingDataResponse getOperatingData() throws IndegoAuthenticationException, IndegoTimeoutException, IndegoException { - return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/operatingData", + return getRequest(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/operatingData", OperatingDataResponse.class); } @@ -608,7 +454,7 @@ public OperatingDataResponse getOperatingData() * @throws IndegoException if any communication or parsing error occurred */ public RawType getMap() throws IndegoAuthenticationException, IndegoException { - return getRawRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/map"); + return getRawRequest(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/map"); } /** @@ -619,8 +465,8 @@ public RawType getMap() throws IndegoAuthenticationException, IndegoException { * @throws IndegoException if any communication or parsing error occurred */ public DeviceCalendarResponse getCalendar() throws IndegoAuthenticationException, IndegoException { - DeviceCalendarResponse calendar = getRequestWithAuthentication( - SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/calendar", DeviceCalendarResponse.class); + DeviceCalendarResponse calendar = getRequest(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/calendar", + DeviceCalendarResponse.class); return calendar; } @@ -647,7 +493,7 @@ public void sendCommand(DeviceCommand command) * @throws IndegoException if any communication or parsing error occurred */ public LocationWeatherResponse getWeather() throws IndegoAuthenticationException, IndegoException { - return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/weather", + return getRequest(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/weather", LocationWeatherResponse.class); } @@ -659,8 +505,7 @@ public LocationWeatherResponse getWeather() throws IndegoAuthenticationException * @throws IndegoException if any communication or parsing error occurred */ public int getPredictiveAdjustment() throws IndegoAuthenticationException, IndegoException { - return getRequestWithAuthentication( - SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/useradjustment", + return getRequest(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/useradjustment", PredictiveAdjustment.class).adjustment; } @@ -686,7 +531,7 @@ public void setPredictiveAdjustment(final int adjust) throws IndegoAuthenticatio * @throws IndegoException if any communication or parsing error occurred */ public boolean getPredictiveMoving() throws IndegoAuthenticationException, IndegoException { - return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive", + return getRequest(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive", PredictiveStatus.class).enabled; } @@ -712,8 +557,7 @@ public void setPredictiveMoving(final boolean enable) throws IndegoAuthenticatio */ public @Nullable Instant getPredictiveLastCutting() throws IndegoAuthenticationException, IndegoException { try { - return getRequestWithAuthentication( - SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/lastcutting", + return getRequest(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/lastcutting", PredictiveLastCuttingResponse.class).getLastCutting(); } catch (IndegoInvalidResponseException e) { if (e.getHttpStatusCode() == HttpStatus.NO_CONTENT_204) { @@ -732,8 +576,7 @@ public void setPredictiveMoving(final boolean enable) throws IndegoAuthenticatio */ public @Nullable Instant getPredictiveNextCutting() throws IndegoAuthenticationException, IndegoException { try { - return getRequestWithAuthentication( - SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/nextcutting", + return getRequest(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/nextcutting", PredictiveNextCuttingResponse.class).getNextCutting(); } catch (IndegoInvalidResponseException e) { if (e.getHttpStatusCode() == HttpStatus.NO_CONTENT_204) { @@ -751,7 +594,7 @@ public void setPredictiveMoving(final boolean enable) throws IndegoAuthenticatio * @throws IndegoException if any communication or parsing error occurred */ public DeviceCalendarResponse getPredictiveExclusionTime() throws IndegoAuthenticationException, IndegoException { - return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/calendar", + return getRequest(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/calendar", DeviceCalendarResponse.class); } @@ -776,7 +619,7 @@ public void setPredictiveExclusionTime(final DeviceCalendarResponse calendar) * @throws IndegoException if any communication or parsing error occurred */ public void requestPosition(int count, int interval) throws IndegoAuthenticationException, IndegoException { - postRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/requestPosition?count=" + count - + "&interval=" + interval); + postRequest(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/requestPosition?count=" + count + "&interval=" + + interval); } } diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/IndegoSession.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/IndegoSession.java deleted file mode 100644 index d1cfe3d3f3820..0000000000000 --- a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/IndegoSession.java +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Copyright (c) 2010-2023 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.boschindego.internal; - -import java.time.Duration; -import java.time.Instant; - -import org.eclipse.jdt.annotation.NonNullByDefault; - -/** - * Session for storing Bosch Indego context information. - * - * @author Jacob Laursen - Initial contribution - */ -@NonNullByDefault -public class IndegoSession { - - private static final Duration DEFAULT_EXPIRATION_PERIOD = Duration.ofSeconds(10); - - private String contextId; - private String serialNumber; - private Instant expirationTime; - - public IndegoSession() { - this("", "", Instant.MIN); - } - - public IndegoSession(String contextId, String serialNumber, Instant expirationTime) { - this.contextId = contextId; - this.serialNumber = serialNumber; - this.expirationTime = expirationTime.equals(Instant.MIN) ? Instant.now().plus(DEFAULT_EXPIRATION_PERIOD) - : expirationTime; - } - - /** - * Get context id for HTTP requests (headers "x-im-context-id: " and - * "Cookie: BOSCH_INDEGO_SSO="). - * - * @return current context id - */ - public String getContextId() { - return contextId; - } - - /** - * Get serial number of device. - * - * @return serial number - */ - public String getSerialNumber() { - return serialNumber; - } - - /** - * Get expiration time of session as {@link Instant}. - * - * @return expiration time - */ - public Instant getExpirationTime() { - return expirationTime; - } - - /** - * Check if session is initialized, i.e. has serial number. - * - * @see #isValid() - * @return true if session is initialized - */ - public boolean isInitialized() { - return !serialNumber.isEmpty(); - } - - /** - * Check if session is valid, i.e. has not yet expired. - * - * @return true if session is still valid - */ - public boolean isValid() { - return !contextId.isEmpty() && expirationTime.isAfter(Instant.now()); - } - - /** - * Invalidate session. - */ - public void invalidate() { - contextId = ""; - expirationTime = Instant.MIN; - } - - @Override - public String toString() { - return String.format("%s (serialNumber %s, expirationTime %s)", contextId, serialNumber, expirationTime); - } -} diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/config/BoschIndegoConfiguration.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/config/BoschIndegoConfiguration.java index 5c90c4a1af2de..b62b2a1ead288 100644 --- a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/config/BoschIndegoConfiguration.java +++ b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/config/BoschIndegoConfiguration.java @@ -13,7 +13,6 @@ package org.openhab.binding.boschindego.internal.config; import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; /** * Configuration for the Bosch Indego thing. @@ -22,8 +21,6 @@ */ @NonNullByDefault public class BoschIndegoConfiguration { - public @Nullable String username; - public @Nullable String password; public long refresh = 180; public long stateActiveRefresh = 30; public long cuttingTimeRefresh = 60; diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/console/BoschIndegoCommandExtension.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/console/BoschIndegoCommandExtension.java new file mode 100644 index 0000000000000..66c693e46ff2c --- /dev/null +++ b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/console/BoschIndegoCommandExtension.java @@ -0,0 +1,91 @@ +/** + * Copyright (c) 2010-2023 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.boschindego.internal.console; + +import java.util.Arrays; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.boschindego.internal.BoschIndegoBindingConstants; +import org.openhab.binding.boschindego.internal.exceptions.IndegoAuthenticationException; +import org.openhab.binding.boschindego.internal.handler.BoschIndegoHandler; +import org.openhab.core.io.console.Console; +import org.openhab.core.io.console.ConsoleCommandCompleter; +import org.openhab.core.io.console.StringsCompleter; +import org.openhab.core.io.console.extensions.AbstractConsoleCommandExtension; +import org.openhab.core.io.console.extensions.ConsoleCommandExtension; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingRegistry; +import org.openhab.core.thing.binding.ThingHandler; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +/** + * The {@link BoschIndegoCommandExtension} is responsible for handling console commands + * + * @author Jacob Laursen - Initial contribution + */ +@NonNullByDefault +@Component(service = ConsoleCommandExtension.class) +public class BoschIndegoCommandExtension extends AbstractConsoleCommandExtension implements ConsoleCommandCompleter { + + private static final String AUTHORIZE = "authorize"; + private static final StringsCompleter SUBCMD_COMPLETER = new StringsCompleter(List.of(AUTHORIZE), false); + + private final ThingRegistry thingRegistry; + + @Activate + public BoschIndegoCommandExtension(final @Reference ThingRegistry thingRegistry) { + super(BoschIndegoBindingConstants.BINDING_ID, "Interact with the Bosch Indego binding."); + this.thingRegistry = thingRegistry; + } + + @Override + public void execute(String[] args, Console console) { + if (args.length != 2 || !AUTHORIZE.equals(args[0])) { + printUsage(console); + return; + } + + for (Thing thing : thingRegistry.getAll()) { + ThingHandler thingHandler = thing.getHandler(); + if (thingHandler instanceof BoschIndegoHandler indegoHandler) { + try { + indegoHandler.authorize(args[1]); + } catch (IndegoAuthenticationException e) { + console.println("Authorization error: " + e.getMessage()); + } + } + } + } + + @Override + public List getUsages() { + return Arrays.asList(buildCommandUsage(AUTHORIZE, "authorize by authorization code")); + } + + @Override + public @Nullable ConsoleCommandCompleter getCompleter() { + return this; + } + + @Override + public boolean complete(String[] args, int cursorArgumentIndex, int cursorPosition, List candidates) { + if (cursorArgumentIndex <= 0) { + return SUBCMD_COMPLETER.complete(args, cursorArgumentIndex, cursorPosition, candidates); + } + return false; + } +} diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/AuthenticationResponse.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/Mower.java similarity index 81% rename from bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/AuthenticationResponse.java rename to bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/Mower.java index 5963407e7d57b..c1c08e0d8bbd1 100644 --- a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/AuthenticationResponse.java +++ b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/Mower.java @@ -15,16 +15,15 @@ import com.google.gson.annotations.SerializedName; /** - * Response from authenticating with server. + * Mower serial number and status. * * @author Jacob Laursen - Initial contribution */ -public class AuthenticationResponse { - - public String contextId; - - public String userId; +public class Mower { @SerializedName("alm_sn") public String serialNumber; + + @SerializedName("alm_status") + public int status; } diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/handler/BoschIndegoHandler.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/handler/BoschIndegoHandler.java index a4ad3169078fb..80999f0d12e08 100644 --- a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/handler/BoschIndegoHandler.java +++ b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/handler/BoschIndegoHandler.java @@ -37,6 +37,7 @@ import org.openhab.binding.boschindego.internal.exceptions.IndegoException; import org.openhab.binding.boschindego.internal.exceptions.IndegoInvalidCommandException; import org.openhab.binding.boschindego.internal.exceptions.IndegoTimeoutException; +import org.openhab.core.auth.client.oauth2.OAuthFactory; import org.openhab.core.i18n.TimeZoneProvider; import org.openhab.core.library.types.DateTimeType; import org.openhab.core.library.types.DecimalType; @@ -81,6 +82,7 @@ public class BoschIndegoHandler extends BaseThingHandler { private final Logger logger = LoggerFactory.getLogger(BoschIndegoHandler.class); private final HttpClient httpClient; + private final OAuthFactory oAuthFactory; private final BoschIndegoTranslationProvider translationProvider; private final TimeZoneProvider timeZoneProvider; @@ -99,10 +101,11 @@ public class BoschIndegoHandler extends BaseThingHandler { private int stateActiveRefreshIntervalSeconds; private int currentRefreshIntervalSeconds; - public BoschIndegoHandler(Thing thing, HttpClient httpClient, BoschIndegoTranslationProvider translationProvider, - TimeZoneProvider timeZoneProvider) { + public BoschIndegoHandler(Thing thing, HttpClient httpClient, OAuthFactory oAuthFactory, + BoschIndegoTranslationProvider translationProvider, TimeZoneProvider timeZoneProvider) { super(thing); this.httpClient = httpClient; + this.oAuthFactory = oAuthFactory; this.translationProvider = translationProvider; this.timeZoneProvider = timeZoneProvider; } @@ -113,21 +116,8 @@ public void initialize() { BoschIndegoConfiguration config = getConfigAs(BoschIndegoConfiguration.class); stateInactiveRefreshIntervalSeconds = (int) config.refresh; stateActiveRefreshIntervalSeconds = (int) config.stateActiveRefresh; - String username = config.username; - String password = config.password; - if (username == null || username.isBlank()) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, - "@text/offline.conf-error.missing-username"); - return; - } - if (password == null || password.isBlank()) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, - "@text/offline.conf-error.missing-password"); - return; - } - - controller = new IndegoController(httpClient, username, password); + controller = new IndegoController(httpClient, oAuthFactory); updateStatus(ThingStatus.UNKNOWN); previousStateCode = Optional.empty(); @@ -136,6 +126,13 @@ public void initialize() { config.cuttingTimeRefresh, TimeUnit.MINUTES); } + public void authorize(String authCode) throws IndegoAuthenticationException { + logger.info("Attempting to authorize using code"); + controller.authorizeByAuthorizationCode(authCode); + logger.info("Authorization completed successfully"); + rescheduleStatePoll(0, stateInactiveRefreshIntervalSeconds); + } + private boolean rescheduleStatePoll(int delaySeconds, int refreshIntervalSeconds) { ScheduledFuture statePollFuture = this.statePollFuture; if (statePollFuture != null) { @@ -172,14 +169,6 @@ public void dispose() { pollFuture.cancel(true); } this.cuttingTimeFuture = null; - - scheduler.execute(() -> { - try { - controller.deauthenticate(); - } catch (IndegoException e) { - logger.debug("Deauthentication failed", e); - } - }); } @Override @@ -280,6 +269,7 @@ private void refreshStateWithExceptionHandling() { try { refreshState(); } catch (IndegoAuthenticationException e) { + logger.warn("Failed to authenticate: {}", e.getMessage()); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/offline.comm-error.authentication-failure"); } catch (IndegoTimeoutException e) { diff --git a/bundles/org.openhab.binding.boschindego/src/main/resources/OH-INF/i18n/boschindego.properties b/bundles/org.openhab.binding.boschindego/src/main/resources/OH-INF/i18n/boschindego.properties index e1c26776345af..ec5aa6ac43e0c 100644 --- a/bundles/org.openhab.binding.boschindego/src/main/resources/OH-INF/i18n/boschindego.properties +++ b/bundles/org.openhab.binding.boschindego/src/main/resources/OH-INF/i18n/boschindego.properties @@ -12,14 +12,10 @@ thing-type.boschindego.indego.description = Indego which supports the connect fe thing-type.config.boschindego.indego.cuttingTimeRefresh.label = Cutting Time Refresh Interval thing-type.config.boschindego.indego.cuttingTimeRefresh.description = The number of minutes between refreshing last/next cutting time. -thing-type.config.boschindego.indego.password.label = Password -thing-type.config.boschindego.indego.password.description = Password for the Bosch Indego account. thing-type.config.boschindego.indego.refresh.label = Idle Refresh Interval thing-type.config.boschindego.indego.refresh.description = The number of seconds between refreshing device state when idle. thing-type.config.boschindego.indego.stateActiveRefresh.label = Active Refresh Interval thing-type.config.boschindego.indego.stateActiveRefresh.description = The number of seconds between refreshing device state when active. -thing-type.config.boschindego.indego.username.label = Username -thing-type.config.boschindego.indego.username.description = Username for the Bosch Indego account. # channel types @@ -53,10 +49,8 @@ channel-type.boschindego.textualstate.label = Textual State # thing status descriptions -offline.comm-error.authentication-failure = The login credentials are wrong or another client is connected to your Indego account +offline.comm-error.authentication-failure = Failed to authenticate with Bosch SingleKey ID offline.comm-error.unreachable = Device is unreachable -offline.conf-error.missing-password = Password missing -offline.conf-error.missing-username = Username missing # indego states diff --git a/bundles/org.openhab.binding.boschindego/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.boschindego/src/main/resources/OH-INF/thing/thing-types.xml index 89cf71f5177bc..d6f13dc17fafa 100644 --- a/bundles/org.openhab.binding.boschindego/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.boschindego/src/main/resources/OH-INF/thing/thing-types.xml @@ -24,15 +24,6 @@ - - - Username for the Bosch Indego account. - - - password - - Password for the Bosch Indego account. - The number of seconds between refreshing device state when idle.