[boschindego] Implement OAuth2 authorization (#14812)

* Implement OAuth2 authorization
* Backport to Java 11/openHAB 3.4

Signed-off-by: Jacob Laursen <jacob-github@vindvejr.dk>
This commit is contained in:
Jacob Laursen 2023-04-15 13:11:42 +02:00 committed by GitHub
parent 198b0b76ba
commit 009a461ee5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 819 additions and 675 deletions

View 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
| 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 |
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 | 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

View File

@ -1,4 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

View 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";
}

View File

@ -12,16 +12,19 @@
*/
package org.openhab.binding.boschindego.internal;
import static org.openhab.binding.boschindego.internal.BoschIndegoBindingConstants.THING_TYPE_INDEGO;
import static org.openhab.binding.boschindego.internal.BoschIndegoBindingConstants.*;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.openhab.binding.boschindego.internal.handler.BoschAccountHandler;
import org.openhab.binding.boschindego.internal.handler.BoschIndegoHandler;
import org.openhab.core.auth.client.oauth2.OAuthFactory;
import org.openhab.core.i18n.LocaleProvider;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.i18n.TranslationProvider;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
@ -37,21 +40,25 @@ 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);
}

View File

@ -12,10 +12,11 @@
*/
package org.openhab.binding.boschindego.internal;
import java.net.URI;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import static org.openhab.binding.boschindego.internal.BoschIndegoBindingConstants.*;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collection;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;
@ -30,25 +31,19 @@ import org.eclipse.jetty.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;
public Collection<String> 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 {
Request request = httpClient.newRequest(BASE_URL + "authenticate").method(HttpMethod.POST)
.header(HttpHeader.AUTHORIZATION, basicAuthenticationHeader);
AuthenticationRequest authRequest = new AuthenticationRequest();
authRequest.device = "";
authRequest.osType = "Android";
authRequest.osVersion = "4.0";
authRequest.deviceManufacturer = "unknown";
authRequest.deviceType = "unknown";
String json = gson.toJson(authRequest);
request.content(new StringContentProvider(json));
request.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_HEADER);
if (logger.isTraceEnabled()) {
logger.trace("POST request for {}", BASE_URL + "authenticate");
}
ContentResponse response = sendRequest(request);
status = response.getStatus();
if (status == HttpStatus.UNAUTHORIZED_401) {
throw new IndegoAuthenticationException("Authentication was rejected");
}
if (!HttpStatus.isSuccess(status)) {
throw new IndegoAuthenticationException("The request failed with HTTP error: " + status);
}
String jsonResponse = response.getContentAsString();
if (jsonResponse.isEmpty()) {
throw new IndegoInvalidResponseException("No content returned", status);
}
logger.trace("JSON response: '{}'", jsonResponse);
AuthenticationResponse authenticationResponse = gson.fromJson(jsonResponse, AuthenticationResponse.class);
if (authenticationResponse == null) {
throw new IndegoInvalidResponseException("Response could not be parsed as AuthenticationResponse",
status);
}
session = new IndegoSession(authenticationResponse.contextId, authenticationResponse.serialNumber,
getContextExpirationTimeFromCookie());
logger.debug("Initialized session {}", session);
} catch (JsonParseException e) {
throw new IndegoInvalidResponseException("Error parsing AuthenticationResponse", e, status);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IndegoException(e);
} catch (TimeoutException | ExecutionException e) {
throw new IndegoException(e);
return oAuthClientService.getAuthorizationUrl(BSK_REDIRECT_URI, BSK_SCOPE, null);
} catch (OAuthException e) {
return "";
}
}
/**
* 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;
});
}
/**
* 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();
}
}
/**
* 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);
}
}

View File

@ -0,0 +1,284 @@
/**
* Copyright (c) 2010-2022 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);
}
}

View File

@ -1,104 +0,0 @@
/**
* Copyright (c) 2010-2022 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);
}
}

View 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;

View File

@ -0,0 +1,91 @@
/**
* Copyright (c) 2010-2022 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) {
try {
((BoschAccountHandler) thingHandler).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;
}
}

View File

@ -0,0 +1,101 @@
/**
* Copyright (c) 2010-2022 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) {
this.accountHandler = (BoschAccountHandler) handler;
}
}
@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());
}
}

View File

@ -15,16 +15,15 @@ package org.openhab.binding.boschindego.internal.dto.response;
import com.google.gson.annotations.SerializedName;
/**
* Response from authenticating with server.
* Mower serial number and status.
*
* @author Jacob Laursen - Initial contribution
*/
public class AuthenticationResponse {
public String contextId;
public String userId;
public class Mower {
@SerializedName("alm_sn")
public String serialNumber;
@SerializedName("alm_status")
public int status;
}

View File

@ -0,0 +1,125 @@
/**
* Copyright (c) 2010-2022 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();
}
}

View 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");
return;
}
if (password == null || password.isBlank()) {
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);
ThingHandler handler = bridge.getHandler();
if (handler instanceof BoschAccountHandler) {
this.oAuthClientService = ((BoschAccountHandler) handler).getOAuthClientService();
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
"@text/offline.conf-error.missing-bridge");
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);
}
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;

View File

@ -5,6 +5,8 @@ binding.boschindego.description = This is the binding for Bosch Indego Connect l
# 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

View 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"/>
@ -23,15 +33,13 @@
<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>