diff --git a/bundles/org.openhab.binding.boschindego/README.md b/bundles/org.openhab.binding.boschindego/README.md index f0bfa18c53888..95e617acd0f55 100644 --- a/bundles/org.openhab.binding.boschindego/README.md +++ b/bundles/org.openhab.binding.boschindego/README.md @@ -4,17 +4,37 @@ This is the Binding for Bosch Indego Connect lawn mowers. Thank´s to zazaz-de who found out how the API works. His [Java Library](https://github.com/zazaz-de/iot-device-bosch-indego-controller) made this Binding possible. +## Discovery + +When the bridge is authorized, the binding can automatically discover Indego mowers connected to the SingleKey ID account. + ## Thing Configuration -Currently the binding supports _**indego**_ mowers as a thing type with these configuration parameters: +### `account` Bridge Configuration + +There are no parameters for the bridge. +However, the bridge is used for managing the [SingleKey ID](https://singlekey-id.com/) digital identity. + +#### Authorization + +To authorize, please follow these steps: + +- In your browser, go to the [Bosch Indego 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 click "Log-in". +- In your browser, open Developer Tools. +- With developer tools showing on the right, go to [Bosch Indego 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. +- Find the `authresp` and copy the code: `com.bosch.indegoconnect://login/?code=` +- Use the openHAB console to authorize with this code: `openhab:boschindego authorize ` + +### `indego` Thing Configuration -| 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 | +| Parameter | Description | Default | Required | +|--------------------|-------------------------------------------------------------------|---------|----------| +| serialNumber | The serial number of the connected Indego mower | | yes | +| refresh | The number of seconds between refreshing device state when idle | 180 | no | +| stateActiveRefresh | The number of seconds between refreshing device state when active | 30 | no | +| cuttingTimeRefresh | The number of minutes between refreshing last/next cutting time | 60 | no | ## Channels @@ -80,26 +100,29 @@ Currently the binding supports _**indego**_ mowers as a thing type with these ### `indego.things` File ```java -boschindego:indego:lawnmower [username="mail@example.com", password="idontneedtocutthelawnagain", refresh=120] +Bridge boschindego:account:singlekey { + Things: + Thing indego lawnmower [serialNumber="1234567890", refresh=120] +} ``` ### `indego.items` File ```java -Number Indego_State { channel="boschindego:indego:lawnmower:state" } -Number Indego_ErrorCode { channel="boschindego:indego:lawnmower:errorcode" } -Number Indego_StateCode { channel="boschindego:indego:lawnmower:statecode" } -String Indego_TextualState { channel="boschindego:indego:lawnmower:textualstate" } -Number Indego_Ready { channel="boschindego:indego:lawnmower:ready" } -Dimmer Indego_Mowed { channel="boschindego:indego:lawnmower:mowed" } -DateTime Indego_LastCutting { channel="boschindego:indego:lawnmower:lastCutting" } -DateTime Indego_NextCutting { channel="boschindego:indego:lawnmower:nextCutting" } -Number:ElectricPotential Indego_BatteryVoltage { channel="boschindego:indego:lawnmower:batteryVoltage" } -Number Indego_BatteryLevel { channel="boschindego:indego:lawnmower:batteryLevel" } -Switch Indego_LowBattery { channel="boschindego:indego:lawnmower:lowBattery" } -Number:Temperature Indego_BatteryTemperature { channel="boschindego:indego:lawnmower:batteryTemperature" } -Number:Area Indego_GardenSize { channel="boschindego:indego:lawnmower:gardenSize" } -Image Indego_GardenMap { channel="boschindego:indego:lawnmower:gardenMap" } +Number Indego_State { channel="boschindego:indego:singlekey:lawnmower:state" } +Number Indego_ErrorCode { channel="boschindego:indego:singlekey:lawnmower:errorcode" } +Number Indego_StateCode { channel="boschindego:indego:singlekey:lawnmower:statecode" } +String Indego_TextualState { channel="boschindego:indego:singlekey:lawnmower:textualstate" } +Number Indego_Ready { channel="boschindego:indego:singlekey:lawnmower:ready" } +Dimmer Indego_Mowed { channel="boschindego:indego:singlekey:lawnmower:mowed" } +DateTime Indego_LastCutting { channel="boschindego:indego:singlekey:lawnmower:lastCutting" } +DateTime Indego_NextCutting { channel="boschindego:indego:singlekey:lawnmower:nextCutting" } +Number:ElectricPotential Indego_BatteryVoltage { channel="boschindego:indego:singlekey:lawnmower:batteryVoltage" } +Number Indego_BatteryLevel { channel="boschindego:indego:singlekey:lawnmower:batteryLevel" } +Switch Indego_LowBattery { channel="boschindego:indego:singlekey:lawnmower:lowBattery" } +Number:Temperature Indego_BatteryTemperature { channel="boschindego:indego:singlekey:lawnmower:batteryTemperature" } +Number:Area Indego_GardenSize { channel="boschindego:indego:singlekey:lawnmower:gardenSize" } +Image Indego_GardenMap { channel="boschindego:indego:singlekey:lawnmower:gardenMap" } ``` ### `indego.sitemap` File diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/BoschIndegoBindingConstants.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/BoschIndegoBindingConstants.java index 5090df37a58d0..11be02c7dae8b 100644 --- a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/BoschIndegoBindingConstants.java +++ b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/BoschIndegoBindingConstants.java @@ -29,6 +29,7 @@ public class BoschIndegoBindingConstants { public static final String BINDING_ID = "boschindego"; // List of all Thing Type UIDs + public static final ThingTypeUID THING_TYPE_ACCOUNT = new ThingTypeUID(BINDING_ID, "account"); public static final ThingTypeUID THING_TYPE_INDEGO = new ThingTypeUID(BINDING_ID, "indego"); // List of all Channel ids @@ -47,5 +48,13 @@ public class BoschIndegoBindingConstants { public static final String GARDEN_SIZE = "gardenSize"; public static final String GARDEN_MAP = "gardenMap"; - public static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_INDEGO); + public static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_ACCOUNT, THING_TYPE_INDEGO); + + // Bosch SingleKey ID OAuth2 + private static final String BSK_BASE_URI = "https://prodindego.b2clogin.com/prodindego.onmicrosoft.com/b2c_1a_signup_signin/oauth2/v2.0/"; + public static final String BSK_CLIENT_ID = "65bb8c9d-1070-4fb4-aa95-853618acc876"; + public static final String BSK_AUTH_URI = BSK_BASE_URI + "authorize"; + public static final String BSK_TOKEN_URI = BSK_BASE_URI + "token"; + public static final String BSK_REDIRECT_URI = "com.bosch.indegoconnect://login"; + public static final String BSK_SCOPE = "openid offline_access https://prodindego.onmicrosoft.com/indego-mobile-api/Indego.Mower.User"; } 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..676bc6a2f8ae3 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 @@ -12,16 +12,19 @@ */ package org.openhab.binding.boschindego.internal; -import static org.openhab.binding.boschindego.internal.BoschIndegoBindingConstants.THING_TYPE_INDEGO; +import static org.openhab.binding.boschindego.internal.BoschIndegoBindingConstants.*; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.boschindego.internal.handler.BoschAccountHandler; 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; import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingTypeUID; import org.openhab.core.thing.binding.BaseThingHandlerFactory; @@ -37,21 +40,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; } @@ -65,7 +72,9 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { protected @Nullable ThingHandler createHandler(Thing thing) { ThingTypeUID thingTypeUID = thing.getThingTypeUID(); - if (THING_TYPE_INDEGO.equals(thingTypeUID)) { + if (THING_TYPE_ACCOUNT.equals(thingTypeUID)) { + return new BoschAccountHandler((Bridge) thing, httpClient, oAuthFactory); + } else if (THING_TYPE_INDEGO.equals(thingTypeUID)) { return new BoschIndegoHandler(thing, httpClient, translationProvider, timeZoneProvider); } 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..5e1c716fb0625 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,11 @@ */ package org.openhab.binding.boschindego.internal; -import java.net.URI; -import java.time.Duration; -import java.time.Instant; -import java.util.Base64; +import static org.openhab.binding.boschindego.internal.BoschIndegoBindingConstants.*; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeoutException; @@ -30,25 +31,19 @@ import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.http.HttpMethod; import org.eclipse.jetty.http.HttpStatus; -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.OperatingDataResponse; -import org.openhab.binding.boschindego.internal.dto.response.PredictiveLastCuttingResponse; -import org.openhab.binding.boschindego.internal.dto.response.PredictiveNextCuttingResponse; +import org.openhab.binding.boschindego.internal.dto.response.Mower; import org.openhab.binding.boschindego.internal.exceptions.IndegoAuthenticationException; import org.openhab.binding.boschindego.internal.exceptions.IndegoException; 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.OAuthResponseException; import org.openhab.core.library.types.RawType; +import org.osgi.framework.FrameworkUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -56,164 +51,82 @@ import com.google.gson.JsonParseException; /** - * Controller for communicating with a Bosch Indego device through Bosch services. - * This class provides methods for retrieving state information as well as controlling - * the device. - * - * The implementation is based on zazaz-de/iot-device-bosch-indego-controller, but - * rewritten from scratch to use Jetty HTTP client for HTTP communication and GSON for - * JSON parsing. Thanks to Oliver Schünemann for providing the original implementation. + * Controller for communicating with a Bosch Indego services. * * @author Jacob Laursen - Initial contribution */ @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 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"; + protected static final String SERIAL_NUMBER_SUBPATH = "alms/"; + + private static final String BASE_URL = "https://api.indego-cloud.iot.bosch-si.com/api/v1/"; private static final String CONTENT_TYPE_HEADER = "application/json"; + private static final String BEARER = "Bearer "; + 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 OAuthClientService oAuthClientService; + private final String userAgent; /** * Initialize the controller instance. * - * @param username the username for authenticating - * @param password the password + * @param httpClient the HttpClient for communicating with the service + * @param oAuthClientService the OAuthClientService for authorization */ - public IndegoController(HttpClient httpClient, String username, String password) { + public IndegoController(HttpClient httpClient, OAuthClientService oAuthClientService) { this.httpClient = httpClient; - basicAuthenticationHeader = "Basic " - + Base64.getEncoder().encodeToString((username + ":" + password).getBytes()); + this.oAuthClientService = oAuthClientService; + userAgent = "openHAB " + FrameworkUtil.getBundle(this.getClass()).getVersion().toString(); } /** - * Authenticate with server and store session context and serial number. - * + * Gets serial numbers of all the associated Indego devices. + * + * @return the serial numbers of the devices * @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"); - } + public Collection getSerialNumbers() throws IndegoAuthenticationException, IndegoException { + Mower[] mowers = getRequest(SERIAL_NUMBER_SUBPATH, Mower[].class); - 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); - } - } - - /** - * 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; - }); + return Arrays.stream(mowers).map(m -> m.serialNumber).toList(); } - /** - * 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 getAuthorizationUrl() { + try { + return oAuthClientService.getAuthorizationUrl(BSK_REDIRECT_URI, BSK_SCOPE, null); + } catch (OAuthException e) { + return ""; } } - /** - * 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(); - } + private String getAuthorizationHeader() throws IndegoException { + final AccessTokenResponse accessTokenResponse; 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); + accessTokenResponse = oAuthClientService.getAccessTokenResponse(); + } catch (OAuthException | OAuthResponseException e) { + logger.debug("Error fetching access token: {}", e.getMessage(), e); + throw new IndegoAuthenticationException( + "Error fetching access token. Invalid authcode? Please generate a new one -> " + + getAuthorizationUrl(), + e); + } catch (IOException e) { + throw new IndegoException("An unexpected IOException occurred: " + e.getMessage(), e); + } + if (accessTokenResponse == null || accessTokenResponse.getAccessToken() == null + || accessTokenResponse.getAccessToken().isEmpty()) { + throw new IndegoAuthenticationException( + "No access token. Is this thing authorized? -> " + getAuthorizationUrl()); + } + if (accessTokenResponse.getRefreshToken() == null || accessTokenResponse.getRefreshToken().isEmpty()) { + throw new IndegoAuthenticationException("No refresh token. Please reauthorize -> " + getAuthorizationUrl()); } + + return BEARER + accessTokenResponse.getAccessToken(); } /** @@ -226,12 +139,12 @@ private T getRequestWithAuthentication(String path, Class dtoCl * @throws IndegoTimeoutException if device cannot be reached (gateway timeout error) * @throws IndegoException if any communication or parsing error occurred */ - private T getRequest(String path, Class dtoClass) + protected 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 +156,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 +187,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. * @@ -321,11 +206,11 @@ private RawType getRawRequestWithAuthentication(String path) throws IndegoAuthen * @throws IndegoAuthenticationException if request was rejected as unauthorized * @throws IndegoException if any communication or parsing error occurred */ - private RawType getRawRequest(String path) throws IndegoAuthenticationException, IndegoException { + protected 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); } @@ -382,24 +267,9 @@ private RawType getRawRequest(String path) throws IndegoAuthenticationException, * @throws IndegoAuthenticationException if request was rejected as unauthorized * @throws IndegoException if any communication or parsing error occurred */ - private void putRequestWithAuthentication(String path, Object requestDto) + protected 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 +279,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); - } + protected void postRequest(String path) throws IndegoAuthenticationException, IndegoException { + putPostRequest(HttpMethod.POST, path, null); } /** @@ -437,12 +292,12 @@ private void postRequestWithAuthentication(String path) throws IndegoAuthenticat * @throws IndegoAuthenticationException if request was rejected as unauthorized * @throws IndegoException if any communication or parsing error occurred */ - private void putPostRequest(HttpMethod method, String path, @Nullable Object requestDto) + protected void putPostRequest(HttpMethod method, String path, @Nullable Object requestDto) 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 +357,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. @@ -538,245 +367,8 @@ private void deleteRequest(String path) throws IndegoException { * @throws TimeoutException if send times out * @throws ExecutionException if execution fails */ - private synchronized ContentResponse sendRequest(Request request) + protected synchronized ContentResponse sendRequest(Request request) throws InterruptedException, TimeoutException, ExecutionException { return request.send(); } - - /** - * Gets serial number of the associated Indego device - * - * @return the serial number of the device - * @throws IndegoAuthenticationException if request was rejected as unauthorized - * @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(); - } - return session.getSerialNumber(); - } - - /** - * Queries the device state from the server. - * - * @return the device state - * @throws IndegoAuthenticationException if request was rejected as unauthorized - * @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); - } - - /** - * Queries the device state from the server. This overload will return when the state - * has changed, or the timeout has been reached. - * - * @param timeout Maximum time to wait for response - * @return the device state - * @throws IndegoAuthenticationException if request was rejected as unauthorized - * @throws IndegoException if any communication or parsing error occurred - */ - public DeviceStateResponse getState(Duration timeout) throws IndegoAuthenticationException, IndegoException { - return getRequestWithAuthentication( - SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/state?longpoll=true&timeout=" + timeout.getSeconds(), - DeviceStateResponse.class); - } - - /** - * Queries the device operating data from the server. - * Server will request this directly from the device, so operation might be slow. - * - * @return the device state - * @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 - */ - public OperatingDataResponse getOperatingData() - throws IndegoAuthenticationException, IndegoTimeoutException, IndegoException { - return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/operatingData", - OperatingDataResponse.class); - } - - /** - * Queries the map generated by the device from the server. - * - * @return the garden map - * @throws IndegoAuthenticationException if request was rejected as unauthorized - * @throws IndegoException if any communication or parsing error occurred - */ - public RawType getMap() throws IndegoAuthenticationException, IndegoException { - return getRawRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/map"); - } - - /** - * Queries the calendar. - * - * @return the calendar - * @throws IndegoAuthenticationException if request was rejected as unauthorized - * @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); - return calendar; - } - - /** - * Sends a command to the Indego device. - * - * @param command the control command to send to the device - * @throws IndegoAuthenticationException if request was rejected as unauthorized - * @throws IndegoInvalidCommandException if the command was not processed correctly - * @throws IndegoException if any communication or parsing error occurred - */ - public void sendCommand(DeviceCommand command) - throws IndegoAuthenticationException, IndegoInvalidCommandException, IndegoException { - SetStateRequest request = new SetStateRequest(); - request.state = command.getActionCode(); - putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/state", request); - } - - /** - * Queries the predictive weather forecast. - * - * @return the weather forecast DTO - * @throws IndegoAuthenticationException if request was rejected as unauthorized - * @throws IndegoException if any communication or parsing error occurred - */ - public LocationWeatherResponse getWeather() throws IndegoAuthenticationException, IndegoException { - return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/weather", - LocationWeatherResponse.class); - } - - /** - * Queries the predictive adjustment. - * - * @return the predictive adjustment - * @throws IndegoAuthenticationException if request was rejected as unauthorized - * @throws IndegoException if any communication or parsing error occurred - */ - public int getPredictiveAdjustment() throws IndegoAuthenticationException, IndegoException { - return getRequestWithAuthentication( - SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/useradjustment", - PredictiveAdjustment.class).adjustment; - } - - /** - * Sets the predictive adjustment. - * - * @param adjust the predictive adjustment - * @throws IndegoAuthenticationException if request was rejected as unauthorized - * @throws IndegoException if any communication or parsing error occurred - */ - public void setPredictiveAdjustment(final int adjust) throws IndegoAuthenticationException, IndegoException { - final PredictiveAdjustment adjustment = new PredictiveAdjustment(); - adjustment.adjustment = adjust; - putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/useradjustment", - adjustment); - } - - /** - * Queries predictive moving. - * - * @return predictive moving - * @throws IndegoAuthenticationException if request was rejected as unauthorized - * @throws IndegoException if any communication or parsing error occurred - */ - public boolean getPredictiveMoving() throws IndegoAuthenticationException, IndegoException { - return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive", - PredictiveStatus.class).enabled; - } - - /** - * Sets predictive moving. - * - * @param enable - * @throws IndegoAuthenticationException if request was rejected as unauthorized - * @throws IndegoException if any communication or parsing error occurred - */ - public void setPredictiveMoving(final boolean enable) throws IndegoAuthenticationException, IndegoException { - final PredictiveStatus status = new PredictiveStatus(); - status.enabled = enable; - putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive", status); - } - - /** - * Queries predictive last cutting as {@link Instant}. - * - * @return predictive last cutting - * @throws IndegoAuthenticationException if request was rejected as unauthorized - * @throws IndegoException if any communication or parsing error occurred - */ - public @Nullable Instant getPredictiveLastCutting() throws IndegoAuthenticationException, IndegoException { - try { - return getRequestWithAuthentication( - SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/lastcutting", - PredictiveLastCuttingResponse.class).getLastCutting(); - } catch (IndegoInvalidResponseException e) { - if (e.getHttpStatusCode() == HttpStatus.NO_CONTENT_204) { - return null; - } - throw e; - } - } - - /** - * Queries predictive next cutting as {@link Instant}. - * - * @return predictive next cutting - * @throws IndegoAuthenticationException if request was rejected as unauthorized - * @throws IndegoException if any communication or parsing error occurred - */ - public @Nullable Instant getPredictiveNextCutting() throws IndegoAuthenticationException, IndegoException { - try { - return getRequestWithAuthentication( - SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/nextcutting", - PredictiveNextCuttingResponse.class).getNextCutting(); - } catch (IndegoInvalidResponseException e) { - if (e.getHttpStatusCode() == HttpStatus.NO_CONTENT_204) { - return null; - } - throw e; - } - } - - /** - * Queries predictive exclusion time. - * - * @return predictive exclusion time DTO - * @throws IndegoAuthenticationException if request was rejected as unauthorized - * @throws IndegoException if any communication or parsing error occurred - */ - public DeviceCalendarResponse getPredictiveExclusionTime() throws IndegoAuthenticationException, IndegoException { - return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/calendar", - DeviceCalendarResponse.class); - } - - /** - * Sets predictive exclusion time. - * - * @param calendar calendar DTO - * @throws IndegoAuthenticationException if request was rejected as unauthorized - * @throws IndegoException if any communication or parsing error occurred - */ - public void setPredictiveExclusionTime(final DeviceCalendarResponse calendar) - throws IndegoAuthenticationException, IndegoException { - putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/calendar", calendar); - } - - /** - * Request map position updates for the next ({@link count} * {@link interval}) number of seconds. - * - * @param count Number of updates - * @param interval Number of seconds between updates - * @throws IndegoAuthenticationException if request was rejected as unauthorized - * @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); - } } diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/IndegoDeviceController.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/IndegoDeviceController.java new file mode 100644 index 0000000000000..a6f016230f82d --- /dev/null +++ b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/IndegoDeviceController.java @@ -0,0 +1,284 @@ +/** + * 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; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.http.HttpStatus; +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.SetStateRequest; +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.LocationWeatherResponse; +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; +import org.openhab.binding.boschindego.internal.exceptions.IndegoAuthenticationException; +import org.openhab.binding.boschindego.internal.exceptions.IndegoException; +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.OAuthClientService; +import org.openhab.core.library.types.RawType; + +/** + * Controller for communicating with a Bosch Indego device through Bosch services. + * This class provides methods for retrieving state information as well as controlling + * the device. + * + * The implementation is based on zazaz-de's iot-device-bosch-indego-controller, but + * rewritten from scratch to use Jetty HTTP client for HTTP communication and GSON for + * JSON parsing. Thanks to Oliver Schünemann for providing the original implementation. + * + * @see zazaz-de/iot-device-bosch-indego-controller + * + * @author Jacob Laursen - Initial contribution + */ +@NonNullByDefault +public class IndegoDeviceController extends IndegoController { + + private String serialNumber; + + /** + * Initialize the controller instance. + * + * @param httpClient the HttpClient for communicating with the service + * @param oAuthClientService the OAuthClientService for authorization + * @param serialNumber the serial number of the device instance + */ + public IndegoDeviceController(HttpClient httpClient, OAuthClientService oAuthClientService, String serialNumber) { + super(httpClient, oAuthClientService); + if (serialNumber.isBlank()) { + throw new IllegalArgumentException("Serial number must be provided"); + } + this.serialNumber = serialNumber; + } + + /** + * Queries the device state from the server. + * + * @return the device state + * @throws IndegoAuthenticationException if request was rejected as unauthorized + * @throws IndegoException if any communication or parsing error occurred + */ + public DeviceStateResponse getState() throws IndegoAuthenticationException, IndegoException { + return getRequest(SERIAL_NUMBER_SUBPATH + serialNumber + "/state", DeviceStateResponse.class); + } + + /** + * Queries the device state from the server. This overload will return when the state + * has changed, or the timeout has been reached. + * + * @param timeout maximum time to wait for response + * @return the device state + * @throws IndegoAuthenticationException if request was rejected as unauthorized + * @throws IndegoException if any communication or parsing error occurred + */ + public DeviceStateResponse getState(Duration timeout) throws IndegoAuthenticationException, IndegoException { + return getRequest(SERIAL_NUMBER_SUBPATH + serialNumber + "/state?longpoll=true&timeout=" + timeout.getSeconds(), + DeviceStateResponse.class); + } + + /** + * Queries the device operating data from the server. + * Server will request this directly from the device, so operation might be slow. + * + * @return the device state + * @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 + */ + public OperatingDataResponse getOperatingData() + throws IndegoAuthenticationException, IndegoTimeoutException, IndegoException { + return getRequest(SERIAL_NUMBER_SUBPATH + serialNumber + "/operatingData", OperatingDataResponse.class); + } + + /** + * Queries the map generated by the device from the server. + * + * @return the garden map + * @throws IndegoAuthenticationException if request was rejected as unauthorized + * @throws IndegoException if any communication or parsing error occurred + */ + public RawType getMap() throws IndegoAuthenticationException, IndegoException { + return getRawRequest(SERIAL_NUMBER_SUBPATH + serialNumber + "/map"); + } + + /** + * Queries the calendar. + * + * @return the calendar + * @throws IndegoAuthenticationException if request was rejected as unauthorized + * @throws IndegoException if any communication or parsing error occurred + */ + public DeviceCalendarResponse getCalendar() throws IndegoAuthenticationException, IndegoException { + DeviceCalendarResponse calendar = getRequest(SERIAL_NUMBER_SUBPATH + serialNumber + "/calendar", + DeviceCalendarResponse.class); + return calendar; + } + + /** + * Sends a command to the Indego device. + * + * @param command the control command to send to the device + * @throws IndegoAuthenticationException if request was rejected as unauthorized + * @throws IndegoInvalidCommandException if the command was not processed correctly + * @throws IndegoException if any communication or parsing error occurred + */ + public void sendCommand(DeviceCommand command) + throws IndegoAuthenticationException, IndegoInvalidCommandException, IndegoException { + SetStateRequest request = new SetStateRequest(); + request.state = command.getActionCode(); + putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + serialNumber + "/state", request); + } + + /** + * Queries the predictive weather forecast. + * + * @return the weather forecast DTO + * @throws IndegoAuthenticationException if request was rejected as unauthorized + * @throws IndegoException if any communication or parsing error occurred + */ + public LocationWeatherResponse getWeather() throws IndegoAuthenticationException, IndegoException { + return getRequest(SERIAL_NUMBER_SUBPATH + serialNumber + "/predictive/weather", LocationWeatherResponse.class); + } + + /** + * Queries the predictive adjustment. + * + * @return the predictive adjustment + * @throws IndegoAuthenticationException if request was rejected as unauthorized + * @throws IndegoException if any communication or parsing error occurred + */ + public int getPredictiveAdjustment() throws IndegoAuthenticationException, IndegoException { + return getRequest(SERIAL_NUMBER_SUBPATH + serialNumber + "/predictive/useradjustment", + PredictiveAdjustment.class).adjustment; + } + + /** + * Sets the predictive adjustment. + * + * @param adjust the predictive adjustment + * @throws IndegoAuthenticationException if request was rejected as unauthorized + * @throws IndegoException if any communication or parsing error occurred + */ + public void setPredictiveAdjustment(final int adjust) throws IndegoAuthenticationException, IndegoException { + final PredictiveAdjustment adjustment = new PredictiveAdjustment(); + adjustment.adjustment = adjust; + putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + serialNumber + "/predictive/useradjustment", adjustment); + } + + /** + * Queries predictive moving. + * + * @return predictive moving + * @throws IndegoAuthenticationException if request was rejected as unauthorized + * @throws IndegoException if any communication or parsing error occurred + */ + public boolean getPredictiveMoving() throws IndegoAuthenticationException, IndegoException { + return getRequest(SERIAL_NUMBER_SUBPATH + serialNumber + "/predictive", PredictiveStatus.class).enabled; + } + + /** + * Sets predictive moving. + * + * @param enable + * @throws IndegoAuthenticationException if request was rejected as unauthorized + * @throws IndegoException if any communication or parsing error occurred + */ + public void setPredictiveMoving(final boolean enable) throws IndegoAuthenticationException, IndegoException { + final PredictiveStatus status = new PredictiveStatus(); + status.enabled = enable; + putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + serialNumber + "/predictive", status); + } + + /** + * Queries predictive last cutting as {@link Instant}. + * + * @return predictive last cutting + * @throws IndegoAuthenticationException if request was rejected as unauthorized + * @throws IndegoException if any communication or parsing error occurred + */ + public @Nullable Instant getPredictiveLastCutting() throws IndegoAuthenticationException, IndegoException { + try { + return getRequest(SERIAL_NUMBER_SUBPATH + serialNumber + "/predictive/lastcutting", + PredictiveLastCuttingResponse.class).getLastCutting(); + } catch (IndegoInvalidResponseException e) { + if (e.getHttpStatusCode() == HttpStatus.NO_CONTENT_204) { + return null; + } + throw e; + } + } + + /** + * Queries predictive next cutting as {@link Instant}. + * + * @return predictive next cutting + * @throws IndegoAuthenticationException if request was rejected as unauthorized + * @throws IndegoException if any communication or parsing error occurred + */ + public @Nullable Instant getPredictiveNextCutting() throws IndegoAuthenticationException, IndegoException { + try { + return getRequest(SERIAL_NUMBER_SUBPATH + serialNumber + "/predictive/nextcutting", + PredictiveNextCuttingResponse.class).getNextCutting(); + } catch (IndegoInvalidResponseException e) { + if (e.getHttpStatusCode() == HttpStatus.NO_CONTENT_204) { + return null; + } + throw e; + } + } + + /** + * Queries predictive exclusion time. + * + * @return predictive exclusion time DTO + * @throws IndegoAuthenticationException if request was rejected as unauthorized + * @throws IndegoException if any communication or parsing error occurred + */ + public DeviceCalendarResponse getPredictiveExclusionTime() throws IndegoAuthenticationException, IndegoException { + return getRequest(SERIAL_NUMBER_SUBPATH + serialNumber + "/predictive/calendar", DeviceCalendarResponse.class); + } + + /** + * Sets predictive exclusion time. + * + * @param calendar calendar DTO + * @throws IndegoAuthenticationException if request was rejected as unauthorized + * @throws IndegoException if any communication or parsing error occurred + */ + public void setPredictiveExclusionTime(final DeviceCalendarResponse calendar) + throws IndegoAuthenticationException, IndegoException { + putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + serialNumber + "/predictive/calendar", calendar); + } + + /** + * Request map position updates for the next ({@link count} * {@link interval}) number of seconds. + * + * @param count number of updates + * @param interval number of seconds between updates + * @throws IndegoAuthenticationException if request was rejected as unauthorized + * @throws IndegoException if any communication or parsing error occurred + */ + public void requestPosition(int count, int interval) throws IndegoAuthenticationException, IndegoException { + postRequest(SERIAL_NUMBER_SUBPATH + serialNumber + "/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..20c9df8bd9ee3 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,7 @@ */ @NonNullByDefault public class BoschIndegoConfiguration { - public @Nullable String username; - public @Nullable String password; + public String serialNumber = ""; 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..d99f317c98e40 --- /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.BoschAccountHandler; +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 BoschAccountHandler accountHandler) { + try { + accountHandler.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/discovery/IndegoDiscoveryService.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/discovery/IndegoDiscoveryService.java new file mode 100644 index 0000000000000..4d9d911403e92 --- /dev/null +++ b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/discovery/IndegoDiscoveryService.java @@ -0,0 +1,101 @@ +/** + * 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.discovery; + +import static org.openhab.binding.boschindego.internal.BoschIndegoBindingConstants.*; + +import java.time.Instant; +import java.util.Collection; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.boschindego.internal.exceptions.IndegoException; +import org.openhab.binding.boschindego.internal.handler.BoschAccountHandler; +import org.openhab.core.config.discovery.AbstractDiscoveryService; +import org.openhab.core.config.discovery.DiscoveryResult; +import org.openhab.core.config.discovery.DiscoveryResultBuilder; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.thing.binding.ThingHandlerService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link IndegoDiscoveryService} is responsible for discovering Indego mowers. + * + * @author Jacob Laursen - Initial contribution + */ +@NonNullByDefault +public class IndegoDiscoveryService extends AbstractDiscoveryService implements ThingHandlerService { + + private static final int TIMEOUT_SECONDS = 60; + + private final Logger logger = LoggerFactory.getLogger(IndegoDiscoveryService.class); + + private @NonNullByDefault({}) BoschAccountHandler accountHandler; + + public IndegoDiscoveryService() { + super(Set.of(THING_TYPE_ACCOUNT), TIMEOUT_SECONDS, false); + } + + @Override + public @Nullable ThingHandler getThingHandler() { + return accountHandler; + } + + @Override + public void setThingHandler(ThingHandler handler) { + if (handler instanceof BoschAccountHandler accountHandler) { + this.accountHandler = accountHandler; + } + } + + @Override + public Set getSupportedThingTypes() { + return Set.of(THING_TYPE_INDEGO); + } + + @Override + public void startScan() { + try { + Collection serialNumbers = accountHandler.getSerialNumbers(); + + ThingUID bridgeUID = accountHandler.getThing().getUID(); + for (String serialNumber : serialNumbers) { + ThingUID thingUID = new ThingUID(THING_TYPE_INDEGO, bridgeUID, serialNumber); + DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUID) + .withProperty(Thing.PROPERTY_SERIAL_NUMBER, serialNumber).withBridge(bridgeUID) + .withRepresentationProperty(Thing.PROPERTY_SERIAL_NUMBER) + .withLabel("Indego (" + serialNumber + ")").build(); + + thingDiscovered(discoveryResult); + } + } catch (IndegoException e) { + logger.debug("Failed to retrieve serial numbers: {}", e.getMessage()); + } + } + + @Override + protected synchronized void stopScan() { + super.stopScan(); + removeOlderResults(getTimestampOfLastScan()); + } + + @Override + public void deactivate() { + removeOlderResults(Instant.now().getEpochSecond()); + } +} 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/BoschAccountHandler.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/handler/BoschAccountHandler.java new file mode 100644 index 0000000000000..8b30b621e26ab --- /dev/null +++ b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/handler/BoschAccountHandler.java @@ -0,0 +1,125 @@ +/** + * 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.handler; + +import static org.openhab.binding.boschindego.internal.BoschIndegoBindingConstants.*; + +import java.io.IOException; +import java.util.Collection; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.boschindego.internal.IndegoController; +import org.openhab.binding.boschindego.internal.discovery.IndegoDiscoveryService; +import org.openhab.binding.boschindego.internal.exceptions.IndegoAuthenticationException; +import org.openhab.binding.boschindego.internal.exceptions.IndegoException; +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.thing.Bridge; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.binding.BaseBridgeHandler; +import org.openhab.core.thing.binding.ThingHandlerService; +import org.openhab.core.types.Command; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link BoschAccountHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Jacob Laursen - Initial contribution + */ +@NonNullByDefault +public class BoschAccountHandler extends BaseBridgeHandler { + + private final Logger logger = LoggerFactory.getLogger(BoschAccountHandler.class); + private final OAuthFactory oAuthFactory; + + private OAuthClientService oAuthClientService; + private IndegoController controller; + + public BoschAccountHandler(Bridge bridge, HttpClient httpClient, OAuthFactory oAuthFactory) { + super(bridge); + + this.oAuthFactory = oAuthFactory; + + oAuthClientService = oAuthFactory.createOAuthClientService(getThing().getUID().getAsString(), BSK_TOKEN_URI, + BSK_AUTH_URI, BSK_CLIENT_ID, null, BSK_SCOPE, false); + controller = new IndegoController(httpClient, oAuthClientService); + } + + @Override + public void initialize() { + updateStatus(ThingStatus.UNKNOWN); + + scheduler.execute(() -> { + try { + AccessTokenResponse accessTokenResponse = this.oAuthClientService.getAccessTokenResponse(); + if (accessTokenResponse == null) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, + "@text/offline.conf-error.oauth2-unauthorized"); + } else { + updateStatus(ThingStatus.ONLINE); + } + } catch (OAuthException | OAuthResponseException e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, + "@text/offline.conf-error.oauth2-unauthorized"); + } catch (IOException e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, + "@text/offline.comm-error.oauth2-authorization-failed"); + } + }); + } + + @Override + public void dispose() { + oAuthFactory.ungetOAuthService(this.getThing().getUID().getAsString()); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + } + + @Override + public Collection> getServices() { + return List.of(IndegoDiscoveryService.class); + } + + public void authorize(String authCode) throws IndegoAuthenticationException { + logger.info("Attempting to authorize using authorization code"); + + try { + oAuthClientService.getAccessTokenResponseByAuthorizationCode(authCode, BSK_REDIRECT_URI); + } catch (OAuthException | OAuthResponseException | IOException e) { + throw new IndegoAuthenticationException("Failed to authorize by authorization code " + authCode, e); + } + + logger.info("Authorization completed successfully"); + + updateStatus(ThingStatus.ONLINE); + } + + public OAuthClientService getOAuthClientService() { + return oAuthClientService; + } + + public Collection getSerialNumbers() throws IndegoException { + return controller.getSerialNumbers(); + } +} 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..f57798bbca76f 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 @@ -28,7 +28,7 @@ import org.eclipse.jetty.client.HttpClient; import org.openhab.binding.boschindego.internal.BoschIndegoTranslationProvider; import org.openhab.binding.boschindego.internal.DeviceStatus; -import org.openhab.binding.boschindego.internal.IndegoController; +import org.openhab.binding.boschindego.internal.IndegoDeviceController; import org.openhab.binding.boschindego.internal.config.BoschIndegoConfiguration; import org.openhab.binding.boschindego.internal.dto.DeviceCommand; import org.openhab.binding.boschindego.internal.dto.response.DeviceStateResponse; @@ -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.OAuthClientService; import org.openhab.core.i18n.TimeZoneProvider; import org.openhab.core.library.types.DateTimeType; import org.openhab.core.library.types.DecimalType; @@ -47,11 +48,14 @@ import org.openhab.core.library.types.StringType; import org.openhab.core.library.unit.SIUnits; import org.openhab.core.library.unit.Units; +import org.openhab.core.thing.Bridge; import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingStatus; import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.ThingStatusInfo; import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.thing.binding.ThingHandler; import org.openhab.core.types.Command; import org.openhab.core.types.RefreshType; import org.openhab.core.types.UnDefType; @@ -84,11 +88,11 @@ public class BoschIndegoHandler extends BaseThingHandler { private final BoschIndegoTranslationProvider translationProvider; private final TimeZoneProvider timeZoneProvider; - private @NonNullByDefault({}) IndegoController controller; + private @NonNullByDefault({}) OAuthClientService oAuthClientService; + private @NonNullByDefault({}) IndegoDeviceController controller; private @Nullable ScheduledFuture statePollFuture; private @Nullable ScheduledFuture cuttingTimePollFuture; private @Nullable ScheduledFuture cuttingTimeFuture; - private boolean propertiesInitialized; private Optional previousStateCode = Optional.empty(); private @Nullable RawType cachedMap; private Instant cachedMapTimestamp = Instant.MIN; @@ -109,41 +113,56 @@ public BoschIndegoHandler(Thing thing, HttpClient httpClient, BoschIndegoTransla @Override public void initialize() { - logger.debug("Initializing Indego handler"); 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()) { + Bridge bridge = getBridge(); + if (bridge == null) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, - "@text/offline.conf-error.missing-username"); + "@text/offline.conf-error.missing-bridge"); return; } - if (password == null || password.isBlank()) { + + ThingHandler handler = bridge.getHandler(); + if (handler instanceof BoschAccountHandler accountHandler) { + this.oAuthClientService = accountHandler.getOAuthClientService(); + } else { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, - "@text/offline.conf-error.missing-password"); + "@text/offline.conf-error.missing-bridge"); return; } - controller = new IndegoController(httpClient, username, password); + this.updateProperty(Thing.PROPERTY_SERIAL_NUMBER, config.serialNumber); + + controller = new IndegoDeviceController(httpClient, oAuthClientService, config.serialNumber); updateStatus(ThingStatus.UNKNOWN); previousStateCode = Optional.empty(); - rescheduleStatePoll(0, stateInactiveRefreshIntervalSeconds); + rescheduleStatePoll(0, stateInactiveRefreshIntervalSeconds, false); this.cuttingTimePollFuture = scheduler.scheduleWithFixedDelay(this::refreshCuttingTimesWithExceptionHandling, 0, config.cuttingTimeRefresh, TimeUnit.MINUTES); } - private boolean rescheduleStatePoll(int delaySeconds, int refreshIntervalSeconds) { + @Override + public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) { + if (bridgeStatusInfo.getStatus() == ThingStatus.ONLINE + && getThing().getStatusInfo().getStatus() == ThingStatus.OFFLINE) { + // Trigger immediate state refresh upon authorization success. + rescheduleStatePoll(0, stateInactiveRefreshIntervalSeconds, true); + } else if (bridgeStatusInfo.getStatus() == ThingStatus.OFFLINE) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE); + } + } + + private boolean rescheduleStatePoll(int delaySeconds, int refreshIntervalSeconds, boolean force) { ScheduledFuture statePollFuture = this.statePollFuture; if (statePollFuture != null) { - if (refreshIntervalSeconds == currentRefreshIntervalSeconds) { + if (!force && refreshIntervalSeconds == currentRefreshIntervalSeconds) { // No change. return false; } - statePollFuture.cancel(false); + statePollFuture.cancel(force); } logger.debug("Scheduling state refresh job with {}s interval and {}s delay", refreshIntervalSeconds, delaySeconds); @@ -156,7 +175,6 @@ private boolean rescheduleStatePoll(int delaySeconds, int refreshIntervalSeconds @Override public void dispose() { - logger.debug("Disposing Indego handler"); ScheduledFuture pollFuture = this.statePollFuture; if (pollFuture != null) { pollFuture.cancel(true); @@ -172,14 +190,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 +290,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) { @@ -291,11 +302,6 @@ private void refreshStateWithExceptionHandling() { } private void refreshState() throws IndegoAuthenticationException, IndegoException { - if (!propertiesInitialized) { - getThing().setProperty(Thing.PROPERTY_SERIAL_NUMBER, controller.getSerialNumber()); - propertiesInitialized = true; - } - DeviceStateResponse state = controller.getState(); DeviceStatus deviceStatus = DeviceStatus.fromCode(state.state); updateState(state); @@ -351,7 +357,7 @@ private void rescheduleStatePollAccordingToState(DeviceStatus deviceStatus) { } else { refreshIntervalSeconds = stateInactiveRefreshIntervalSeconds; } - if (rescheduleStatePoll(refreshIntervalSeconds, refreshIntervalSeconds)) { + if (rescheduleStatePoll(refreshIntervalSeconds, refreshIntervalSeconds, false)) { // After job has been rescheduled, request operating data one last time on next poll. // This is needed to update battery values after a charging cycle has completed. operatingDataTimestamp = Instant.MIN; 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..8d7cccf4ef321 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 @@ -5,6 +5,8 @@ addon.boschindego.description = This is the binding for Bosch Indego Connect law # thing types +thing-type.boschindego.account.label = SingleKey ID +thing-type.boschindego.account.description = SingleKey ID account thing-type.boschindego.indego.label = Bosch Indego thing-type.boschindego.indego.description = Indego which supports the connect feature. @@ -12,14 +14,12 @@ 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.serialNumber.label = Serial Number +thing-type.config.boschindego.indego.serialNumber.description = The serial number of the connected Indego mower. 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 +53,11 @@ 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.conf-error.missing-bridge = No bridge configured +offline.conf-error.oauth2-unauthorized = Unauthorized +offline.comm-error.oauth2-authorization-failed = Failed to authorize +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..85a005481f8d0 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 @@ -4,9 +4,19 @@ xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0" xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd"> + + + SingleKey ID account + + + + + + Indego which supports the connect feature. + @@ -23,15 +33,13 @@ + + serialNumber + - - - Username for the Bosch Indego account. - - - password - - Password for the Bosch Indego account. + + + The serial number of the connected Indego mower.