]> git.basschouten.com Git - openhab-addons.git/commitdiff
[boschindego] Implement OAuth2 authorization (#14745)
authorJacob Laursen <jacob-github@vindvejr.dk>
Sat, 15 Apr 2023 07:39:17 +0000 (09:39 +0200)
committerGitHub <noreply@github.com>
Sat, 15 Apr 2023 07:39:17 +0000 (09:39 +0200)
Signed-off-by: Jacob Laursen <jacob-github@vindvejr.dk>
15 files changed:
bundles/org.openhab.binding.boschindego/README.md
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/BoschIndegoBindingConstants.java
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/BoschIndegoHandlerFactory.java
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/IndegoController.java
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/IndegoDeviceController.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/IndegoSession.java [deleted file]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/config/BoschIndegoConfiguration.java
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/console/BoschIndegoCommandExtension.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/discovery/IndegoDiscoveryService.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/AuthenticationResponse.java [deleted file]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/Mower.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/handler/BoschAccountHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/handler/BoschIndegoHandler.java
bundles/org.openhab.binding.boschindego/src/main/resources/OH-INF/i18n/boschindego.properties
bundles/org.openhab.binding.boschindego/src/main/resources/OH-INF/thing/thing-types.xml

index f0bfa18c538883c9208a739d14d195e1397b4428..95e617acd0f559681c8e848eb736f57612a68768 100644 (file)
@@ -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=<copy this>`
+- Use the openHAB console to authorize with this code: `openhab:boschindego authorize <paste code>`
+
+### `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
index 5090df37a58d01bfb3bbc14c93b576f228885a69..11be02c7dae8b57228fef40227f880337b39e94b 100644 (file)
@@ -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<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_INDEGO);
+    public static final Set<ThingTypeUID> 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";
 }
index 878b6cc43da0e783b19e45a05801e0d984424b8a..676bc6a2f8ae3e40276a1d56a0811e2b70a44f13 100644 (file)
  */
 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 @@ import org.osgi.service.component.annotations.Reference;
  * 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 class BoschIndegoHandlerFactory extends BaseThingHandlerFactory {
     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);
         }
 
index 03f177b31d03647e396df9ff8a0ccd800bf2f782..5e1c716fb062549b48fc96e29efac07f6156de0f 100644 (file)
  */
 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.client.util.StringContentProvider;
 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.Gson;
 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<String> 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> T getRequestWithAuthentication(String path, Class<? extends T> 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 @@ public class IndegoController {
      * @throws IndegoTimeoutException if device cannot be reached (gateway timeout error)
      * @throws IndegoException if any communication or parsing error occurred
      */
-    private <T> T getRequest(String path, Class<? extends T> dtoClass)
+    protected <T> T getRequest(String path, Class<? extends T> 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 @@ public class IndegoController {
             }
             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 @@ public class IndegoController {
                 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 @@ public class IndegoController {
      * @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 @@ public class IndegoController {
      * @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 @@ public class IndegoController {
      * @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 @@ public class IndegoController {
      * @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 @@ public class IndegoController {
         }
     }
 
-    /**
-     * 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 @@ public class IndegoController {
      * @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 (file)
index 0000000..a6f0162
--- /dev/null
@@ -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 <a href=
+ *      "https://github.com/zazaz-de/iot-device-bosch-indego-controller">zazaz-de/iot-device-bosch-indego-controller</a>
+ * 
+ * @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 (file)
index d1cfe3d..0000000
+++ /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: <contextId>" and
-     * "Cookie: BOSCH_INDEGO_SSO=<contextId>").
-     * 
-     * @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);
-    }
-}
index 5c90c4a1af2deb7399556e0b6e200e627eea722a..20c9df8bd9ee3572231a699fffdef9a18e8a743e 100644 (file)
@@ -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 @@ import org.eclipse.jdt.annotation.Nullable;
  */
 @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 (file)
index 0000000..d99f317
--- /dev/null
@@ -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<String> 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<String> 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 (file)
index 0000000..4d9d911
--- /dev/null
@@ -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<ThingTypeUID> getSupportedThingTypes() {
+        return Set.of(THING_TYPE_INDEGO);
+    }
+
+    @Override
+    public void startScan() {
+        try {
+            Collection<String> 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/AuthenticationResponse.java
deleted file mode 100644 (file)
index 5963407..0000000
+++ /dev/null
@@ -1,30 +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.dto.response;
-
-import com.google.gson.annotations.SerializedName;
-
-/**
- * Response from authenticating with server.
- * 
- * @author Jacob Laursen - Initial contribution
- */
-public class AuthenticationResponse {
-
-    public String contextId;
-
-    public String userId;
-
-    @SerializedName("alm_sn")
-    public String serialNumber;
-}
diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/Mower.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/Mower.java
new file mode 100644 (file)
index 0000000..c1c08e0
--- /dev/null
@@ -0,0 +1,29 @@
+/**
+ * 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.dto.response;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Mower serial number and status.
+ * 
+ * @author Jacob Laursen - Initial contribution
+ */
+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 (file)
index 0000000..8b30b62
--- /dev/null
@@ -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<Class<? extends ThingHandlerService>> 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<String> getSerialNumbers() throws IndegoException {
+        return controller.getSerialNumbers();
+    }
+}
index a4ad3169078fb9c3d9e88441d19122f0d8544289..f57798bbca76f83af3926bef0a131b581db58939 100644 (file)
@@ -28,7 +28,7 @@ import org.eclipse.jdt.annotation.Nullable;
 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.IndegoAuthenticationE
 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.RawType;
 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<Integer> previousStateCode = Optional.empty();
     private @Nullable RawType cachedMap;
     private Instant cachedMapTimestamp = Instant.MIN;
@@ -109,41 +113,56 @@ public class BoschIndegoHandler extends BaseThingHandler {
 
     @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 @@ public class BoschIndegoHandler extends BaseThingHandler {
 
     @Override
     public void dispose() {
-        logger.debug("Disposing Indego handler");
         ScheduledFuture<?> pollFuture = this.statePollFuture;
         if (pollFuture != null) {
             pollFuture.cancel(true);
@@ -172,14 +190,6 @@ public class BoschIndegoHandler extends BaseThingHandler {
             pollFuture.cancel(true);
         }
         this.cuttingTimeFuture = null;
-
-        scheduler.execute(() -> {
-            try {
-                controller.deauthenticate();
-            } catch (IndegoException e) {
-                logger.debug("Deauthentication failed", e);
-            }
-        });
     }
 
     @Override
@@ -280,6 +290,7 @@ public class BoschIndegoHandler extends BaseThingHandler {
         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 @@ public class BoschIndegoHandler extends BaseThingHandler {
     }
 
     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 @@ public class BoschIndegoHandler extends BaseThingHandler {
         } 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;
index e1c26776345af1bfa5db112a683266183abc63bb..8d7cccf4ef32128728f61857966bcf01f385139c 100644 (file)
@@ -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
 
index 89cf71f5177bc9183dada84da9900f8bcc5880e1..85a005481f8d0cead32d31d4e98d264216259da5 100644 (file)
@@ -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">
 
+       <bridge-type id="account">
+               <label>SingleKey ID</label>
+               <description>SingleKey ID account</description>
+       </bridge-type>
+
        <thing-type id="indego">
+               <supported-bridge-type-refs>
+                       <bridge-type-ref id="account"/>
+               </supported-bridge-type-refs>
+
                <label>Bosch Indego</label>
                <description>Indego which supports the connect feature.</description>
+
                <channels>
                        <channel id="state" typeId="state"/>
                        <channel id="textualstate" typeId="textualstate"/>
                        <channel id="gardenSize" typeId="gardenSize"/>
                        <channel id="gardenMap" typeId="gardenMap"/>
                </channels>
+
+               <representation-property>serialNumber</representation-property>
+
                <config-description>
-                       <parameter name="username" type="text" required="true">
-                               <label>Username</label>
-                               <description>Username for the Bosch Indego account.</description>
-                       </parameter>
-                       <parameter name="password" type="text" required="true">
-                               <context>password</context>
-                               <label>Password</label>
-                               <description>Password for the Bosch Indego account.</description>
+                       <parameter name="serialNumber" type="text" required="true">
+                               <label>Serial Number</label>
+                               <description>The serial number of the connected Indego mower.</description>
                        </parameter>
                        <parameter name="refresh" type="integer" min="60">
                                <label>Idle Refresh Interval</label>