diff --git a/bundles/org.openhab.binding.boschindego/README.md b/bundles/org.openhab.binding.boschindego/README.md index ae73f8588ed58..b6fb11a6cd55a 100644 --- a/bundles/org.openhab.binding.boschindego/README.md +++ b/bundles/org.openhab.binding.boschindego/README.md @@ -4,28 +4,38 @@ 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 -| Parameter | Description | Default | -|--------------------|-------------------------------------------------------------------|---------| -| 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 | +There are no parameters for the bridge. +However, the bridge is used for managing the [SingleKey ID](https://singlekey-id.com/) digital identity. -### Authorization +#### Authorization To authorize, please follow these steps: -- In your browser, go to the [Bosch SingleKey ID login page](https://prodindego.b2clogin.com/prodindego.onmicrosoft.com/b2c_1a_signup_signin/oauth2/v2.0/authorize?redirect_uri=com.bosch.indegoconnect://login&client_id=65bb8c9d-1070-4fb4-aa95-853618acc876&response_type=code&scope=openid%20offline_access%20https://prodindego.onmicrosoft.com/indego-mobile-api/Indego.Mower.User). -- Select "Bosch ID", enter your e-mail address and password and clock "Log-in". +- In your browser, 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 in the right, go to [Bosch SingleKey ID login page](https://prodindego.b2clogin.com/prodindego.onmicrosoft.com/b2c_1a_signup_signin/oauth2/v2.0/authorize?redirect_uri=com.bosch.indegoconnect://login&client_id=65bb8c9d-1070-4fb4-aa95-853618acc876&response_type=code&scope=openid%20offline_access%20https://prodindego.onmicrosoft.com/indego-mobile-api/Indego.Mower.User) again. +- With developer tools showing in the right, go to [Bosch Indego login page](https://prodindego.b2clogin.com/prodindego.onmicrosoft.com/b2c_1a_signup_signin/oauth2/v2.0/authorize?redirect_uri=com.bosch.indegoconnect://login&client_id=65bb8c9d-1070-4fb4-aa95-853618acc876&response_type=code&scope=openid%20offline_access%20https://prodindego.onmicrosoft.com/indego-mobile-api/Indego.Mower.User) again. - "Please wait..." should now be displayed. - Find the `authresp` and copy the code: `com.bosch.indegoconnect://login/?code=` - Use the openHAB console to authorize with this code: `openhab:boschindego authorize ` +### `indego` Thing Configuration + +| Parameter | Description | Default | 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 | Channel | Item Type | Description | Writeable | @@ -90,7 +100,10 @@ To authorize, please follow these steps: ### `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 diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/BoschIndegoBindingConstants.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/BoschIndegoBindingConstants.java index c775e47967635..11be02c7dae8b 100644 --- a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/BoschIndegoBindingConstants.java +++ b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/BoschIndegoBindingConstants.java @@ -29,6 +29,7 @@ public class BoschIndegoBindingConstants { public static final String BINDING_ID = "boschindego"; // List of all Thing Type UIDs + public static final ThingTypeUID THING_TYPE_ACCOUNT = new ThingTypeUID(BINDING_ID, "account"); public static final ThingTypeUID THING_TYPE_INDEGO = new ThingTypeUID(BINDING_ID, "indego"); // List of all Channel ids @@ -47,7 +48,7 @@ public class BoschIndegoBindingConstants { public static final String GARDEN_SIZE = "gardenSize"; public static final String GARDEN_MAP = "gardenMap"; - public static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_INDEGO); + public static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_ACCOUNT, THING_TYPE_INDEGO); // Bosch SingleKey ID OAuth2 private static final String BSK_BASE_URI = "https://prodindego.b2clogin.com/prodindego.onmicrosoft.com/b2c_1a_signup_signin/oauth2/v2.0/"; diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/BoschIndegoHandlerFactory.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/BoschIndegoHandlerFactory.java index d9d902125c366..115421889690b 100644 --- a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/BoschIndegoHandlerFactory.java +++ b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/BoschIndegoHandlerFactory.java @@ -12,17 +12,23 @@ */ 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 java.util.Hashtable; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.boschindego.internal.discovery.IndegoDiscoveryService; +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.config.discovery.DiscoveryService; 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; @@ -70,8 +76,14 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { protected @Nullable ThingHandler createHandler(Thing thing) { ThingTypeUID thingTypeUID = thing.getThingTypeUID(); - if (THING_TYPE_INDEGO.equals(thingTypeUID)) { - return new BoschIndegoHandler(thing, httpClient, oAuthFactory, translationProvider, timeZoneProvider); + if (THING_TYPE_ACCOUNT.equals(thingTypeUID)) { + var accountHandler = new BoschAccountHandler((Bridge) thing, httpClient, oAuthFactory); + var discoveryService = new IndegoDiscoveryService(accountHandler); + bundleContext.registerService(DiscoveryService.class.getName(), discoveryService, new Hashtable<>()); + + return accountHandler; + } else if (THING_TYPE_INDEGO.equals(thingTypeUID)) { + return new BoschIndegoHandler(thing, httpClient, translationProvider, timeZoneProvider); } return null; diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/IndegoController.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/IndegoController.java index 09a1f6f0c4736..5e1c716fb0625 100644 --- a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/IndegoController.java +++ b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/IndegoController.java @@ -15,8 +15,8 @@ import static org.openhab.binding.boschindego.internal.BoschIndegoBindingConstants.*; import java.io.IOException; -import java.time.Duration; -import java.time.Instant; +import java.util.Arrays; +import java.util.Collection; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeoutException; @@ -31,18 +31,8 @@ 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.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.ErrorResponse; -import org.openhab.binding.boschindego.internal.dto.response.LocationWeatherResponse; import org.openhab.binding.boschindego.internal.dto.response.Mower; -import org.openhab.binding.boschindego.internal.dto.response.OperatingDataResponse; -import org.openhab.binding.boschindego.internal.dto.response.PredictiveLastCuttingResponse; -import org.openhab.binding.boschindego.internal.dto.response.PredictiveNextCuttingResponse; import org.openhab.binding.boschindego.internal.exceptions.IndegoAuthenticationException; import org.openhab.binding.boschindego.internal.exceptions.IndegoException; import org.openhab.binding.boschindego.internal.exceptions.IndegoInvalidCommandException; @@ -61,21 +51,16 @@ 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 { + 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 SERIAL_NUMBER_SUBPATH = "alms/"; private static final String CONTENT_TYPE_HEADER = "application/json"; private static final String BEARER = "Bearer "; @@ -85,13 +70,12 @@ public class IndegoController { private final HttpClient httpClient; private final OAuthClientService oAuthClientService; private final String userAgent; - private String serialNumber = ""; /** * 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, OAuthClientService oAuthClientService) { this.httpClient = httpClient; @@ -99,6 +83,19 @@ public IndegoController(HttpClient httpClient, OAuthClientService oAuthClientSer userAgent = "openHAB " + FrameworkUtil.getBundle(this.getClass()).getVersion().toString(); } + /** + * 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 + */ + public Collection getSerialNumbers() throws IndegoAuthenticationException, IndegoException { + Mower[] mowers = getRequest(SERIAL_NUMBER_SUBPATH, Mower[].class); + + return Arrays.stream(mowers).map(m -> m.serialNumber).toList(); + } + private String getAuthorizationUrl() { try { return oAuthClientService.getAuthorizationUrl(BSK_REDIRECT_URI, BSK_SCOPE, null); @@ -142,7 +139,7 @@ private String getAuthorizationHeader() throws IndegoException { * @throws IndegoTimeoutException if device cannot be reached (gateway timeout error) * @throws IndegoException if any communication or parsing error occurred */ - private T getRequest(String path, Class dtoClass) + protected T getRequest(String path, Class dtoClass) throws IndegoAuthenticationException, IndegoTimeoutException, IndegoException { int status = 0; try { @@ -209,7 +206,7 @@ private T getRequest(String path, Class dtoClass) * @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) @@ -270,7 +267,7 @@ private RawType getRawRequest(String path) throws IndegoAuthenticationException, * @throws IndegoAuthenticationException if request was rejected as unauthorized * @throws IndegoException if any communication or parsing error occurred */ - private void putRequestWithAuthentication(String path, Object requestDto) + protected void putRequestWithAuthentication(String path, Object requestDto) throws IndegoAuthenticationException, IndegoException { putPostRequest(HttpMethod.PUT, path, requestDto); } @@ -282,7 +279,7 @@ private void putRequestWithAuthentication(String path, Object requestDto) * @throws IndegoAuthenticationException if request was rejected as unauthorized * @throws IndegoException if any communication or parsing error occurred */ - private void postRequest(String path) throws IndegoAuthenticationException, IndegoException { + protected void postRequest(String path) throws IndegoAuthenticationException, IndegoException { putPostRequest(HttpMethod.POST, path, null); } @@ -295,7 +292,7 @@ private void postRequest(String path) throws IndegoAuthenticationException, Inde * @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) @@ -370,244 +367,8 @@ private void putPostRequest(HttpMethod method, String path, @Nullable Object req * @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 (!serialNumber.isEmpty()) { - return serialNumber; - } - - Mower[] mowers = getRequest(SERIAL_NUMBER_SUBPATH, Mower[].class); - serialNumber = mowers[0].serialNumber; - - return 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 + 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 getRequest( - 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 getRequest(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 getRawRequest(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 = getRequest(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 getRequest(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 getRequest(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 getRequest(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 getRequest(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 getRequest(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 getRequest(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 { - postRequest(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/requestPosition?count=" + count + "&interval=" - + interval); - } } diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/IndegoDeviceController.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/IndegoDeviceController.java new file mode 100644 index 0000000000000..a93a80c6948f6 --- /dev/null +++ b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/IndegoDeviceController.java @@ -0,0 +1,281 @@ +/** + * 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/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. + * + * @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/config/BoschIndegoConfiguration.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/config/BoschIndegoConfiguration.java index b62b2a1ead288..20c9df8bd9ee3 100644 --- a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/config/BoschIndegoConfiguration.java +++ b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/config/BoschIndegoConfiguration.java @@ -21,6 +21,7 @@ */ @NonNullByDefault public class BoschIndegoConfiguration { + 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 index 66c693e46ff2c..d99f317c98e40 100644 --- 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 @@ -19,7 +19,7 @@ import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.boschindego.internal.BoschIndegoBindingConstants; import org.openhab.binding.boschindego.internal.exceptions.IndegoAuthenticationException; -import org.openhab.binding.boschindego.internal.handler.BoschIndegoHandler; +import org.openhab.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; @@ -61,9 +61,9 @@ public void execute(String[] args, Console console) { for (Thing thing : thingRegistry.getAll()) { ThingHandler thingHandler = thing.getHandler(); - if (thingHandler instanceof BoschIndegoHandler indegoHandler) { + if (thingHandler instanceof BoschAccountHandler accountHandler) { try { - indegoHandler.authorize(args[1]); + accountHandler.authorize(args[1]); } catch (IndegoAuthenticationException e) { console.println("Authorization error: " + e.getMessage()); } diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/discovery/IndegoDiscoveryService.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/discovery/IndegoDiscoveryService.java new file mode 100644 index 0000000000000..7e7dbb00c36b0 --- /dev/null +++ b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/discovery/IndegoDiscoveryService.java @@ -0,0 +1,86 @@ +/** + * 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.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.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 { + + private static final int TIMEOUT_SECONDS = 60; + + private final Logger logger = LoggerFactory.getLogger(IndegoDiscoveryService.class); + private final BoschAccountHandler accountHandler; + + public IndegoDiscoveryService(BoschAccountHandler accountHandler) { + super(Set.of(THING_TYPE_ACCOUNT), TIMEOUT_SECONDS, false); + this.accountHandler = accountHandler; + } + + @Override + public Set getSupportedThingTypes() { + return Set.of(THING_TYPE_INDEGO); + } + + @Override + public void startScan() { + try { + Collection serialNumbers = accountHandler.getSerialNumbers(); + + ThingUID bridgeUID = accountHandler.getThing().getUID(); + for (String serialNumber : serialNumbers) { + ThingUID thingUID = new ThingUID(THING_TYPE_INDEGO, bridgeUID, serialNumber); + DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUID) + .withProperty(Thing.PROPERTY_SERIAL_NUMBER, serialNumber).withBridge(bridgeUID) + .withRepresentationProperty(Thing.PROPERTY_SERIAL_NUMBER) + .withLabel("Indego (" + serialNumber + ")").build(); + + thingDiscovered(discoveryResult); + } + } catch (IndegoException e) { + logger.debug("Failed to retrieve serial numbers: {}", e.getMessage()); + } + } + + @Override + protected synchronized void stopScan() { + super.stopScan(); + removeOlderResults(getTimestampOfLastScan()); + } + + @Override + public void deactivate() { + removeOlderResults(Instant.now().getEpochSecond()); + } +} diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/handler/BoschAccountHandler.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/handler/BoschAccountHandler.java new file mode 100644 index 0000000000000..a38b63aa7319f --- /dev/null +++ b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/handler/BoschAccountHandler.java @@ -0,0 +1,117 @@ +/** + * 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 org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.boschindego.internal.IndegoController; +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.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(this.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() { + this.oAuthFactory.ungetOAuthService(this.getThing().getUID().getAsString()); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + } + + public void authorize(String authCode) throws IndegoAuthenticationException { + logger.info("Attempting to authorize using authorization code"); + + try { + oAuthClientService.getAccessTokenResponseByAuthorizationCode(authCode, BSK_REDIRECT_URI); + } catch (OAuthException | OAuthResponseException | IOException e) { + throw new IndegoAuthenticationException("Failed to authorize by authorization code " + authCode, e); + } + + logger.info("Authorization completed successfully"); + + updateStatus(ThingStatus.ONLINE); + } + + public OAuthClientService getOAuthClientService() { + return oAuthClientService; + } + + public Collection getSerialNumbers() throws IndegoException { + return controller.getSerialNumbers(); + } +} diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/handler/BoschIndegoHandler.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/handler/BoschIndegoHandler.java index 1fa6a066ca514..4f8d652f4be3c 100644 --- a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/handler/BoschIndegoHandler.java +++ b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/handler/BoschIndegoHandler.java @@ -14,7 +14,6 @@ import static org.openhab.binding.boschindego.internal.BoschIndegoBindingConstants.*; -import java.io.IOException; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.Instant; @@ -29,7 +28,7 @@ import org.eclipse.jetty.client.HttpClient; import org.openhab.binding.boschindego.internal.BoschIndegoTranslationProvider; import org.openhab.binding.boschindego.internal.DeviceStatus; -import org.openhab.binding.boschindego.internal.IndegoController; +import org.openhab.binding.boschindego.internal.IndegoDeviceController; import org.openhab.binding.boschindego.internal.config.BoschIndegoConfiguration; import org.openhab.binding.boschindego.internal.dto.DeviceCommand; import org.openhab.binding.boschindego.internal.dto.response.DeviceStateResponse; @@ -39,9 +38,6 @@ 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.auth.client.oauth2.OAuthException; -import org.openhab.core.auth.client.oauth2.OAuthFactory; -import org.openhab.core.auth.client.oauth2.OAuthResponseException; import org.openhab.core.i18n.TimeZoneProvider; import org.openhab.core.library.types.DateTimeType; import org.openhab.core.library.types.DecimalType; @@ -52,11 +48,14 @@ import org.openhab.core.library.types.StringType; import org.openhab.core.library.unit.SIUnits; import org.openhab.core.library.unit.Units; +import org.openhab.core.thing.Bridge; import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingStatus; import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.ThingStatusInfo; import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.thing.binding.ThingHandler; import org.openhab.core.types.Command; import org.openhab.core.types.RefreshType; import org.openhab.core.types.UnDefType; @@ -86,16 +85,14 @@ public class BoschIndegoHandler extends BaseThingHandler { private final Logger logger = LoggerFactory.getLogger(BoschIndegoHandler.class); private final HttpClient httpClient; - private final OAuthFactory oAuthFactory; private final BoschIndegoTranslationProvider translationProvider; private final TimeZoneProvider timeZoneProvider; private @NonNullByDefault({}) OAuthClientService oAuthClientService; - private @NonNullByDefault({}) IndegoController controller; + private @NonNullByDefault({}) IndegoDeviceController controller; private @Nullable ScheduledFuture statePollFuture; private @Nullable ScheduledFuture cuttingTimePollFuture; private @Nullable ScheduledFuture cuttingTimeFuture; - private boolean propertiesInitialized; private Optional previousStateCode = Optional.empty(); private @Nullable RawType cachedMap; private Instant cachedMapTimestamp = Instant.MIN; @@ -106,62 +103,66 @@ public class BoschIndegoHandler extends BaseThingHandler { private int stateActiveRefreshIntervalSeconds; private int currentRefreshIntervalSeconds; - public BoschIndegoHandler(Thing thing, HttpClient httpClient, OAuthFactory oAuthFactory, - BoschIndegoTranslationProvider translationProvider, TimeZoneProvider timeZoneProvider) { + public BoschIndegoHandler(Thing thing, HttpClient httpClient, BoschIndegoTranslationProvider translationProvider, + TimeZoneProvider timeZoneProvider) { super(thing); this.httpClient = httpClient; - this.oAuthFactory = oAuthFactory; this.translationProvider = translationProvider; this.timeZoneProvider = timeZoneProvider; } @Override public void initialize() { - logger.debug("Initializing Indego handler"); BoschIndegoConfiguration config = getConfigAs(BoschIndegoConfiguration.class); stateInactiveRefreshIntervalSeconds = (int) config.refresh; stateActiveRefreshIntervalSeconds = (int) config.stateActiveRefresh; - this.oAuthClientService = oAuthFactory.createOAuthClientService(this.getThing().getUID().getAsString(), - BSK_TOKEN_URI, BSK_AUTH_URI, BSK_CLIENT_ID, null, BSK_SCOPE, false); + Bridge bridge = getBridge(); + if (bridge == null) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, + "@text/offline.conf-error.missing-brige"); + return; + } - controller = new IndegoController(httpClient, oAuthClientService); + 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-brige"); + return; + } + + 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); } - 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"); - - // Trigger immediate state refresh. - ScheduledFuture statePollFuture = this.statePollFuture; - if (statePollFuture != null) { - statePollFuture.cancel(true); - this.statePollFuture = null; + @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); } - rescheduleStatePoll(0, stateInactiveRefreshIntervalSeconds); } - private boolean rescheduleStatePoll(int delaySeconds, int refreshIntervalSeconds) { + 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); @@ -174,7 +175,6 @@ private boolean rescheduleStatePoll(int delaySeconds, int refreshIntervalSeconds @Override public void dispose() { - logger.debug("Disposing Indego handler"); ScheduledFuture pollFuture = this.statePollFuture; if (pollFuture != null) { pollFuture.cancel(true); @@ -190,8 +190,6 @@ public void dispose() { pollFuture.cancel(true); } this.cuttingTimeFuture = null; - - this.oAuthFactory.ungetOAuthService(this.getThing().getUID().getAsString()); } @Override @@ -304,11 +302,6 @@ private void refreshStateWithExceptionHandling() { } private void refreshState() throws IndegoAuthenticationException, IndegoException { - if (!propertiesInitialized) { - getThing().setProperty(Thing.PROPERTY_SERIAL_NUMBER, controller.getSerialNumber()); - propertiesInitialized = true; - } - DeviceStateResponse state = controller.getState(); DeviceStatus deviceStatus = DeviceStatus.fromCode(state.state); updateState(state); @@ -364,7 +357,7 @@ private void rescheduleStatePollAccordingToState(DeviceStatus deviceStatus) { } else { refreshIntervalSeconds = stateInactiveRefreshIntervalSeconds; } - if (rescheduleStatePoll(refreshIntervalSeconds, refreshIntervalSeconds)) { + if (rescheduleStatePoll(refreshIntervalSeconds, refreshIntervalSeconds, false)) { // After job has been rescheduled, request operating data one last time on next poll. // This is needed to update battery values after a charging cycle has completed. operatingDataTimestamp = Instant.MIN; diff --git a/bundles/org.openhab.binding.boschindego/src/main/resources/OH-INF/i18n/boschindego.properties b/bundles/org.openhab.binding.boschindego/src/main/resources/OH-INF/i18n/boschindego.properties index ec5aa6ac43e0c..ed87209c34783 100644 --- a/bundles/org.openhab.binding.boschindego/src/main/resources/OH-INF/i18n/boschindego.properties +++ b/bundles/org.openhab.binding.boschindego/src/main/resources/OH-INF/i18n/boschindego.properties @@ -5,6 +5,8 @@ addon.boschindego.description = This is the binding for Bosch Indego Connect law # thing types +thing-type.boschindego.account.label = SingleKey ID +thing-type.boschindego.account.description = SingleKey ID account thing-type.boschindego.indego.label = Bosch Indego thing-type.boschindego.indego.description = Indego which supports the connect feature. @@ -14,6 +16,8 @@ thing-type.config.boschindego.indego.cuttingTimeRefresh.label = Cutting Time Ref thing-type.config.boschindego.indego.cuttingTimeRefresh.description = The number of minutes between refreshing last/next cutting time. 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. @@ -49,6 +53,9 @@ channel-type.boschindego.textualstate.label = Textual State # thing status descriptions +offline.conf-error.missing-brige = 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 diff --git a/bundles/org.openhab.binding.boschindego/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.boschindego/src/main/resources/OH-INF/thing/thing-types.xml index d6f13dc17fafa..85a005481f8d0 100644 --- a/bundles/org.openhab.binding.boschindego/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.boschindego/src/main/resources/OH-INF/thing/thing-types.xml @@ -4,9 +4,19 @@ xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0" xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd"> + + + SingleKey ID account + + + + + + Indego which supports the connect feature. + @@ -23,7 +33,14 @@ + + serialNumber + + + + The serial number of the connected Indego mower. + The number of seconds between refreshing device state when idle.