mirror of
https://github.com/openhab/openhab-addons.git
synced 2025-01-10 23:22:02 +01:00
[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:
parent
198b0b76ba
commit
009a461ee5
@ -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.
|
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.
|
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
|
## Thing Configuration
|
||||||
|
|
||||||
Currently the binding supports _**indego**_ mowers as a thing type with these configuration parameters:
|
### `account` Bridge Configuration
|
||||||
|
|
||||||
| Parameter | Description | Default |
|
There are no parameters for the bridge.
|
||||||
|--------------------|-------------------------------------------------------------------|---------|
|
However, the bridge is used for managing the [SingleKey ID](https://singlekey-id.com/) digital identity.
|
||||||
| username | Username for the Bosch Indego account | |
|
|
||||||
| password | Password for the Bosch Indego account | |
|
#### Authorization
|
||||||
| refresh | The number of seconds between refreshing device state when idle | 180 |
|
|
||||||
| stateActiveRefresh | The number of seconds between refreshing device state when active | 30 |
|
To authorize, please follow these steps:
|
||||||
| cuttingTimeRefresh | The number of minutes between refreshing last/next cutting time | 60 |
|
|
||||||
|
- 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
|
## Channels
|
||||||
|
|
||||||
@ -80,26 +100,29 @@ Currently the binding supports _**indego**_ mowers as a thing type with these
|
|||||||
### `indego.things` File
|
### `indego.things` File
|
||||||
|
|
||||||
```java
|
```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
|
### `indego.items` File
|
||||||
|
|
||||||
```java
|
```java
|
||||||
Number Indego_State { channel="boschindego:indego:lawnmower:state" }
|
Number Indego_State { channel="boschindego:indego:singlekey:lawnmower:state" }
|
||||||
Number Indego_ErrorCode { channel="boschindego:indego:lawnmower:errorcode" }
|
Number Indego_ErrorCode { channel="boschindego:indego:singlekey:lawnmower:errorcode" }
|
||||||
Number Indego_StateCode { channel="boschindego:indego:lawnmower:statecode" }
|
Number Indego_StateCode { channel="boschindego:indego:singlekey:lawnmower:statecode" }
|
||||||
String Indego_TextualState { channel="boschindego:indego:lawnmower:textualstate" }
|
String Indego_TextualState { channel="boschindego:indego:singlekey:lawnmower:textualstate" }
|
||||||
Number Indego_Ready { channel="boschindego:indego:lawnmower:ready" }
|
Number Indego_Ready { channel="boschindego:indego:singlekey:lawnmower:ready" }
|
||||||
Dimmer Indego_Mowed { channel="boschindego:indego:lawnmower:mowed" }
|
Dimmer Indego_Mowed { channel="boschindego:indego:singlekey:lawnmower:mowed" }
|
||||||
DateTime Indego_LastCutting { channel="boschindego:indego:lawnmower:lastCutting" }
|
DateTime Indego_LastCutting { channel="boschindego:indego:singlekey:lawnmower:lastCutting" }
|
||||||
DateTime Indego_NextCutting { channel="boschindego:indego:lawnmower:nextCutting" }
|
DateTime Indego_NextCutting { channel="boschindego:indego:singlekey:lawnmower:nextCutting" }
|
||||||
Number:ElectricPotential Indego_BatteryVoltage { channel="boschindego:indego:lawnmower:batteryVoltage" }
|
Number:ElectricPotential Indego_BatteryVoltage { channel="boschindego:indego:singlekey:lawnmower:batteryVoltage" }
|
||||||
Number Indego_BatteryLevel { channel="boschindego:indego:lawnmower:batteryLevel" }
|
Number Indego_BatteryLevel { channel="boschindego:indego:singlekey:lawnmower:batteryLevel" }
|
||||||
Switch Indego_LowBattery { channel="boschindego:indego:lawnmower:lowBattery" }
|
Switch Indego_LowBattery { channel="boschindego:indego:singlekey:lawnmower:lowBattery" }
|
||||||
Number:Temperature Indego_BatteryTemperature { channel="boschindego:indego:lawnmower:batteryTemperature" }
|
Number:Temperature Indego_BatteryTemperature { channel="boschindego:indego:singlekey:lawnmower:batteryTemperature" }
|
||||||
Number:Area Indego_GardenSize { channel="boschindego:indego:lawnmower:gardenSize" }
|
Number:Area Indego_GardenSize { channel="boschindego:indego:singlekey:lawnmower:gardenSize" }
|
||||||
Image Indego_GardenMap { channel="boschindego:indego:lawnmower:gardenMap" }
|
Image Indego_GardenMap { channel="boschindego:indego:singlekey:lawnmower:gardenMap" }
|
||||||
```
|
```
|
||||||
|
|
||||||
### `indego.sitemap` File
|
### `indego.sitemap` 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>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
@ -29,6 +29,7 @@ public class BoschIndegoBindingConstants {
|
|||||||
public static final String BINDING_ID = "boschindego";
|
public static final String BINDING_ID = "boschindego";
|
||||||
|
|
||||||
// List of all Thing Type UIDs
|
// 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");
|
public static final ThingTypeUID THING_TYPE_INDEGO = new ThingTypeUID(BINDING_ID, "indego");
|
||||||
|
|
||||||
// List of all Channel ids
|
// List of all Channel ids
|
||||||
@ -47,5 +48,13 @@ public class BoschIndegoBindingConstants {
|
|||||||
public static final String GARDEN_SIZE = "gardenSize";
|
public static final String GARDEN_SIZE = "gardenSize";
|
||||||
public static final String GARDEN_MAP = "gardenMap";
|
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";
|
||||||
}
|
}
|
||||||
|
@ -12,16 +12,19 @@
|
|||||||
*/
|
*/
|
||||||
package org.openhab.binding.boschindego.internal;
|
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.NonNullByDefault;
|
||||||
import org.eclipse.jdt.annotation.Nullable;
|
import org.eclipse.jdt.annotation.Nullable;
|
||||||
import org.eclipse.jetty.client.HttpClient;
|
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.binding.boschindego.internal.handler.BoschIndegoHandler;
|
||||||
|
import org.openhab.core.auth.client.oauth2.OAuthFactory;
|
||||||
import org.openhab.core.i18n.LocaleProvider;
|
import org.openhab.core.i18n.LocaleProvider;
|
||||||
import org.openhab.core.i18n.TimeZoneProvider;
|
import org.openhab.core.i18n.TimeZoneProvider;
|
||||||
import org.openhab.core.i18n.TranslationProvider;
|
import org.openhab.core.i18n.TranslationProvider;
|
||||||
import org.openhab.core.io.net.http.HttpClientFactory;
|
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.Thing;
|
||||||
import org.openhab.core.thing.ThingTypeUID;
|
import org.openhab.core.thing.ThingTypeUID;
|
||||||
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
|
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
|
||||||
@ -37,21 +40,25 @@ import org.osgi.service.component.annotations.Reference;
|
|||||||
* handlers.
|
* handlers.
|
||||||
*
|
*
|
||||||
* @author Jonas Fleck - Initial contribution
|
* @author Jonas Fleck - Initial contribution
|
||||||
|
* @author Jacob Laursen - Replaced authorization by OAuth2
|
||||||
*/
|
*/
|
||||||
@NonNullByDefault
|
@NonNullByDefault
|
||||||
@Component(service = ThingHandlerFactory.class, configurationPid = "binding.boschindego")
|
@Component(service = ThingHandlerFactory.class, configurationPid = "binding.boschindego")
|
||||||
public class BoschIndegoHandlerFactory extends BaseThingHandlerFactory {
|
public class BoschIndegoHandlerFactory extends BaseThingHandlerFactory {
|
||||||
|
|
||||||
private final HttpClient httpClient;
|
private final HttpClient httpClient;
|
||||||
|
private final OAuthFactory oAuthFactory;
|
||||||
private final BoschIndegoTranslationProvider translationProvider;
|
private final BoschIndegoTranslationProvider translationProvider;
|
||||||
private final TimeZoneProvider timeZoneProvider;
|
private final TimeZoneProvider timeZoneProvider;
|
||||||
|
|
||||||
@Activate
|
@Activate
|
||||||
public BoschIndegoHandlerFactory(@Reference HttpClientFactory httpClientFactory,
|
public BoschIndegoHandlerFactory(@Reference HttpClientFactory httpClientFactory,
|
||||||
final @Reference TranslationProvider i18nProvider, final @Reference LocaleProvider localeProvider,
|
final @Reference OAuthFactory oAuthFactory, final @Reference TranslationProvider i18nProvider,
|
||||||
final @Reference TimeZoneProvider timeZoneProvider, ComponentContext componentContext) {
|
final @Reference LocaleProvider localeProvider, final @Reference TimeZoneProvider timeZoneProvider,
|
||||||
|
ComponentContext componentContext) {
|
||||||
super.activate(componentContext);
|
super.activate(componentContext);
|
||||||
this.httpClient = httpClientFactory.getCommonHttpClient();
|
this.httpClient = httpClientFactory.getCommonHttpClient();
|
||||||
|
this.oAuthFactory = oAuthFactory;
|
||||||
this.translationProvider = new BoschIndegoTranslationProvider(i18nProvider, localeProvider);
|
this.translationProvider = new BoschIndegoTranslationProvider(i18nProvider, localeProvider);
|
||||||
this.timeZoneProvider = timeZoneProvider;
|
this.timeZoneProvider = timeZoneProvider;
|
||||||
}
|
}
|
||||||
@ -65,7 +72,9 @@ public class BoschIndegoHandlerFactory extends BaseThingHandlerFactory {
|
|||||||
protected @Nullable ThingHandler createHandler(Thing thing) {
|
protected @Nullable ThingHandler createHandler(Thing thing) {
|
||||||
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
|
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);
|
return new BoschIndegoHandler(thing, httpClient, translationProvider, timeZoneProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -12,10 +12,11 @@
|
|||||||
*/
|
*/
|
||||||
package org.openhab.binding.boschindego.internal;
|
package org.openhab.binding.boschindego.internal;
|
||||||
|
|
||||||
import java.net.URI;
|
import static org.openhab.binding.boschindego.internal.BoschIndegoBindingConstants.*;
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.Instant;
|
import java.io.IOException;
|
||||||
import java.util.Base64;
|
import java.util.Arrays;
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.concurrent.ExecutionException;
|
import java.util.concurrent.ExecutionException;
|
||||||
import java.util.concurrent.TimeoutException;
|
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.HttpHeader;
|
||||||
import org.eclipse.jetty.http.HttpMethod;
|
import org.eclipse.jetty.http.HttpMethod;
|
||||||
import org.eclipse.jetty.http.HttpStatus;
|
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.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.IndegoAuthenticationException;
|
||||||
import org.openhab.binding.boschindego.internal.exceptions.IndegoException;
|
import org.openhab.binding.boschindego.internal.exceptions.IndegoException;
|
||||||
import org.openhab.binding.boschindego.internal.exceptions.IndegoInvalidCommandException;
|
import org.openhab.binding.boschindego.internal.exceptions.IndegoInvalidCommandException;
|
||||||
import org.openhab.binding.boschindego.internal.exceptions.IndegoInvalidResponseException;
|
import org.openhab.binding.boschindego.internal.exceptions.IndegoInvalidResponseException;
|
||||||
import org.openhab.binding.boschindego.internal.exceptions.IndegoTimeoutException;
|
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.openhab.core.library.types.RawType;
|
||||||
|
import org.osgi.framework.FrameworkUtil;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
@ -56,164 +51,82 @@ import com.google.gson.Gson;
|
|||||||
import com.google.gson.JsonParseException;
|
import com.google.gson.JsonParseException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Controller for communicating with a Bosch Indego device through Bosch services.
|
* Controller for communicating with a Bosch Indego 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
|
* @author Jacob Laursen - Initial contribution
|
||||||
*/
|
*/
|
||||||
@NonNullByDefault
|
@NonNullByDefault
|
||||||
public class IndegoController {
|
public class IndegoController {
|
||||||
|
|
||||||
private static final String BASE_URL = "https://api.indego.iot.bosch-si.com/api/v1/";
|
protected static final String SERIAL_NUMBER_SUBPATH = "alms/";
|
||||||
private static final URI BASE_URI = URI.create(BASE_URL);
|
|
||||||
private 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 SSO_COOKIE_NAME = "BOSCH_INDEGO_SSO";
|
|
||||||
private static final String CONTEXT_HEADER_NAME = "x-im-context-id";
|
|
||||||
private static final String CONTENT_TYPE_HEADER = "application/json";
|
private static final String CONTENT_TYPE_HEADER = "application/json";
|
||||||
|
|
||||||
|
private static final String BEARER = "Bearer ";
|
||||||
|
|
||||||
private final Logger logger = LoggerFactory.getLogger(IndegoController.class);
|
private final Logger logger = LoggerFactory.getLogger(IndegoController.class);
|
||||||
private final String basicAuthenticationHeader;
|
|
||||||
private final Gson gson = new Gson();
|
private final Gson gson = new Gson();
|
||||||
private final HttpClient httpClient;
|
private final HttpClient httpClient;
|
||||||
|
private final OAuthClientService oAuthClientService;
|
||||||
private IndegoSession session = new IndegoSession();
|
private final String userAgent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the controller instance.
|
* Initialize the controller instance.
|
||||||
*
|
*
|
||||||
* @param username the username for authenticating
|
* @param httpClient the HttpClient for communicating with the service
|
||||||
* @param password the password
|
* @param oAuthClientService the OAuthClientService for authorization
|
||||||
*/
|
*/
|
||||||
public IndegoController(HttpClient httpClient, String username, String password) {
|
public IndegoController(HttpClient httpClient, OAuthClientService oAuthClientService) {
|
||||||
this.httpClient = httpClient;
|
this.httpClient = httpClient;
|
||||||
basicAuthenticationHeader = "Basic "
|
this.oAuthClientService = oAuthClientService;
|
||||||
+ Base64.getEncoder().encodeToString((username + ":" + password).getBytes());
|
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 IndegoAuthenticationException if request was rejected as unauthorized
|
||||||
* @throws IndegoException if any communication or parsing error occurred
|
* @throws IndegoException if any communication or parsing error occurred
|
||||||
*/
|
*/
|
||||||
private void authenticate() throws IndegoAuthenticationException, IndegoException {
|
public Collection<String> getSerialNumbers() throws IndegoAuthenticationException, IndegoException {
|
||||||
int status = 0;
|
Mower[] mowers = getRequest(SERIAL_NUMBER_SUBPATH, Mower[].class);
|
||||||
|
|
||||||
|
return Arrays.stream(mowers).map(m -> m.serialNumber).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getAuthorizationUrl() {
|
||||||
try {
|
try {
|
||||||
Request request = httpClient.newRequest(BASE_URL + "authenticate").method(HttpMethod.POST)
|
return oAuthClientService.getAuthorizationUrl(BSK_REDIRECT_URI, BSK_SCOPE, null);
|
||||||
.header(HttpHeader.AUTHORIZATION, basicAuthenticationHeader);
|
} catch (OAuthException e) {
|
||||||
|
return "";
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private String getAuthorizationHeader() throws IndegoException {
|
||||||
* Get context expiration time as a calculated {@link Instant} relative to now.
|
final AccessTokenResponse accessTokenResponse;
|
||||||
* 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();
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
logger.debug("Session {} valid, skipping authentication", session);
|
accessTokenResponse = oAuthClientService.getAccessTokenResponse();
|
||||||
return getRequest(path, dtoClass);
|
} catch (OAuthException | OAuthResponseException e) {
|
||||||
} catch (IndegoAuthenticationException e) {
|
logger.debug("Error fetching access token: {}", e.getMessage(), e);
|
||||||
if (logger.isTraceEnabled()) {
|
throw new IndegoAuthenticationException(
|
||||||
logger.trace("Context rejected", e);
|
"Error fetching access token. Invalid authcode? Please generate a new one -> "
|
||||||
} else {
|
+ getAuthorizationUrl(),
|
||||||
logger.debug("Context rejected: {}", e.getMessage());
|
e);
|
||||||
}
|
} catch (IOException e) {
|
||||||
session.invalidate();
|
throw new IndegoException("An unexpected IOException occurred: " + e.getMessage(), e);
|
||||||
authenticate();
|
|
||||||
return getRequest(path, dtoClass);
|
|
||||||
}
|
}
|
||||||
|
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 IndegoTimeoutException if device cannot be reached (gateway timeout error)
|
||||||
* @throws IndegoException if any communication or parsing error occurred
|
* @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 {
|
throws IndegoAuthenticationException, IndegoTimeoutException, IndegoException {
|
||||||
int status = 0;
|
int status = 0;
|
||||||
try {
|
try {
|
||||||
Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET).header(CONTEXT_HEADER_NAME,
|
Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET)
|
||||||
session.getContextId());
|
.header(HttpHeader.AUTHORIZATION, getAuthorizationHeader()).agent(userAgent);
|
||||||
if (logger.isTraceEnabled()) {
|
if (logger.isTraceEnabled()) {
|
||||||
logger.trace("GET request for {}", BASE_URL + path);
|
logger.trace("GET request for {}", BASE_URL + path);
|
||||||
}
|
}
|
||||||
@ -243,7 +156,7 @@ public class IndegoController {
|
|||||||
}
|
}
|
||||||
if (status == HttpStatus.UNAUTHORIZED_401) {
|
if (status == HttpStatus.UNAUTHORIZED_401) {
|
||||||
// This will currently not happen because "WWW-Authenticate" header is missing; see below.
|
// 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) {
|
if (status == HttpStatus.GATEWAY_TIMEOUT_504) {
|
||||||
throw new IndegoTimeoutException("Gateway timeout");
|
throw new IndegoTimeoutException("Gateway timeout");
|
||||||
@ -274,45 +187,17 @@ public class IndegoController {
|
|||||||
Response response = ((HttpResponseException) cause).getResponse();
|
Response response = ((HttpResponseException) cause).getResponse();
|
||||||
if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
|
if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
|
||||||
/*
|
/*
|
||||||
* When contextId is not valid, the service will respond with HTTP code 401 without
|
* The service may respond with HTTP code 401 without any "WWW-Authenticate"
|
||||||
* any "WWW-Authenticate" header, violating RFC 7235. Jetty will then throw
|
* header, violating RFC 7235. Jetty will then throw HttpResponseException.
|
||||||
* HttpResponseException. We need to handle this in order to attempt
|
* We need to handle this in order to attempt reauthentication.
|
||||||
* reauthentication.
|
|
||||||
*/
|
*/
|
||||||
throw new IndegoAuthenticationException("Context rejected", e);
|
throw new IndegoAuthenticationException("Unauthorized", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new IndegoException(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.
|
* 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 IndegoAuthenticationException if request was rejected as unauthorized
|
||||||
* @throws IndegoException if any communication or parsing error occurred
|
* @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;
|
int status = 0;
|
||||||
try {
|
try {
|
||||||
Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET).header(CONTEXT_HEADER_NAME,
|
Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET)
|
||||||
session.getContextId());
|
.header(HttpHeader.AUTHORIZATION, getAuthorizationHeader()).agent(userAgent);
|
||||||
if (logger.isTraceEnabled()) {
|
if (logger.isTraceEnabled()) {
|
||||||
logger.trace("GET request for {}", BASE_URL + path);
|
logger.trace("GET request for {}", BASE_URL + path);
|
||||||
}
|
}
|
||||||
@ -382,24 +267,9 @@ public class IndegoController {
|
|||||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||||
* @throws IndegoException if any communication or parsing error occurred
|
* @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 {
|
throws IndegoAuthenticationException, IndegoException {
|
||||||
if (!session.isValid()) {
|
putPostRequest(HttpMethod.PUT, path, requestDto);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -409,23 +279,8 @@ public class IndegoController {
|
|||||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||||
* @throws IndegoException if any communication or parsing error occurred
|
* @throws IndegoException if any communication or parsing error occurred
|
||||||
*/
|
*/
|
||||||
private void postRequestWithAuthentication(String path) throws IndegoAuthenticationException, IndegoException {
|
protected void postRequest(String path) throws IndegoAuthenticationException, IndegoException {
|
||||||
if (!session.isValid()) {
|
putPostRequest(HttpMethod.POST, path, null);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -437,12 +292,12 @@ public class IndegoController {
|
|||||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||||
* @throws IndegoException if any communication or parsing error occurred
|
* @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 {
|
throws IndegoAuthenticationException, IndegoException {
|
||||||
try {
|
try {
|
||||||
Request request = httpClient.newRequest(BASE_URL + path).method(method)
|
Request request = httpClient.newRequest(BASE_URL + path).method(method)
|
||||||
.header(CONTEXT_HEADER_NAME, session.getContextId())
|
.header(HttpHeader.AUTHORIZATION, getAuthorizationHeader())
|
||||||
.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_HEADER);
|
.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_HEADER).agent(userAgent);
|
||||||
if (requestDto != null) {
|
if (requestDto != null) {
|
||||||
String payload = gson.toJson(requestDto);
|
String payload = gson.toJson(requestDto);
|
||||||
request.content(new StringContentProvider(payload));
|
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
|
* Send request. This method exists for the purpose of avoiding multiple calls to
|
||||||
* the server at the same time.
|
* the server at the same time.
|
||||||
@ -538,245 +367,8 @@ public class IndegoController {
|
|||||||
* @throws TimeoutException if send times out
|
* @throws TimeoutException if send times out
|
||||||
* @throws ExecutionException if execution fails
|
* @throws ExecutionException if execution fails
|
||||||
*/
|
*/
|
||||||
private synchronized ContentResponse sendRequest(Request request)
|
protected synchronized ContentResponse sendRequest(Request request)
|
||||||
throws InterruptedException, TimeoutException, ExecutionException {
|
throws InterruptedException, TimeoutException, ExecutionException {
|
||||||
return request.send();
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
@ -13,7 +13,6 @@
|
|||||||
package org.openhab.binding.boschindego.internal.config;
|
package org.openhab.binding.boschindego.internal.config;
|
||||||
|
|
||||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||||
import org.eclipse.jdt.annotation.Nullable;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for the Bosch Indego thing.
|
* Configuration for the Bosch Indego thing.
|
||||||
@ -22,8 +21,7 @@ import org.eclipse.jdt.annotation.Nullable;
|
|||||||
*/
|
*/
|
||||||
@NonNullByDefault
|
@NonNullByDefault
|
||||||
public class BoschIndegoConfiguration {
|
public class BoschIndegoConfiguration {
|
||||||
public @Nullable String username;
|
public String serialNumber = "";
|
||||||
public @Nullable String password;
|
|
||||||
public long refresh = 180;
|
public long refresh = 180;
|
||||||
public long stateActiveRefresh = 30;
|
public long stateActiveRefresh = 30;
|
||||||
public long cuttingTimeRefresh = 60;
|
public long cuttingTimeRefresh = 60;
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
@ -15,16 +15,15 @@ package org.openhab.binding.boschindego.internal.dto.response;
|
|||||||
import com.google.gson.annotations.SerializedName;
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Response from authenticating with server.
|
* Mower serial number and status.
|
||||||
*
|
*
|
||||||
* @author Jacob Laursen - Initial contribution
|
* @author Jacob Laursen - Initial contribution
|
||||||
*/
|
*/
|
||||||
public class AuthenticationResponse {
|
public class Mower {
|
||||||
|
|
||||||
public String contextId;
|
|
||||||
|
|
||||||
public String userId;
|
|
||||||
|
|
||||||
@SerializedName("alm_sn")
|
@SerializedName("alm_sn")
|
||||||
public String serialNumber;
|
public String serialNumber;
|
||||||
|
|
||||||
|
@SerializedName("alm_status")
|
||||||
|
public int status;
|
||||||
}
|
}
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
@ -28,7 +28,7 @@ import org.eclipse.jdt.annotation.Nullable;
|
|||||||
import org.eclipse.jetty.client.HttpClient;
|
import org.eclipse.jetty.client.HttpClient;
|
||||||
import org.openhab.binding.boschindego.internal.BoschIndegoTranslationProvider;
|
import org.openhab.binding.boschindego.internal.BoschIndegoTranslationProvider;
|
||||||
import org.openhab.binding.boschindego.internal.DeviceStatus;
|
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.config.BoschIndegoConfiguration;
|
||||||
import org.openhab.binding.boschindego.internal.dto.DeviceCommand;
|
import org.openhab.binding.boschindego.internal.dto.DeviceCommand;
|
||||||
import org.openhab.binding.boschindego.internal.dto.response.DeviceStateResponse;
|
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.IndegoException;
|
||||||
import org.openhab.binding.boschindego.internal.exceptions.IndegoInvalidCommandException;
|
import org.openhab.binding.boschindego.internal.exceptions.IndegoInvalidCommandException;
|
||||||
import org.openhab.binding.boschindego.internal.exceptions.IndegoTimeoutException;
|
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.i18n.TimeZoneProvider;
|
||||||
import org.openhab.core.library.types.DateTimeType;
|
import org.openhab.core.library.types.DateTimeType;
|
||||||
import org.openhab.core.library.types.DecimalType;
|
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.types.StringType;
|
||||||
import org.openhab.core.library.unit.SIUnits;
|
import org.openhab.core.library.unit.SIUnits;
|
||||||
import org.openhab.core.library.unit.Units;
|
import org.openhab.core.library.unit.Units;
|
||||||
|
import org.openhab.core.thing.Bridge;
|
||||||
import org.openhab.core.thing.ChannelUID;
|
import org.openhab.core.thing.ChannelUID;
|
||||||
import org.openhab.core.thing.Thing;
|
import org.openhab.core.thing.Thing;
|
||||||
import org.openhab.core.thing.ThingStatus;
|
import org.openhab.core.thing.ThingStatus;
|
||||||
import org.openhab.core.thing.ThingStatusDetail;
|
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.BaseThingHandler;
|
||||||
|
import org.openhab.core.thing.binding.ThingHandler;
|
||||||
import org.openhab.core.types.Command;
|
import org.openhab.core.types.Command;
|
||||||
import org.openhab.core.types.RefreshType;
|
import org.openhab.core.types.RefreshType;
|
||||||
import org.openhab.core.types.UnDefType;
|
import org.openhab.core.types.UnDefType;
|
||||||
@ -84,11 +88,11 @@ public class BoschIndegoHandler extends BaseThingHandler {
|
|||||||
private final BoschIndegoTranslationProvider translationProvider;
|
private final BoschIndegoTranslationProvider translationProvider;
|
||||||
private final TimeZoneProvider timeZoneProvider;
|
private final TimeZoneProvider timeZoneProvider;
|
||||||
|
|
||||||
private @NonNullByDefault({}) IndegoController controller;
|
private @NonNullByDefault({}) OAuthClientService oAuthClientService;
|
||||||
|
private @NonNullByDefault({}) IndegoDeviceController controller;
|
||||||
private @Nullable ScheduledFuture<?> statePollFuture;
|
private @Nullable ScheduledFuture<?> statePollFuture;
|
||||||
private @Nullable ScheduledFuture<?> cuttingTimePollFuture;
|
private @Nullable ScheduledFuture<?> cuttingTimePollFuture;
|
||||||
private @Nullable ScheduledFuture<?> cuttingTimeFuture;
|
private @Nullable ScheduledFuture<?> cuttingTimeFuture;
|
||||||
private boolean propertiesInitialized;
|
|
||||||
private Optional<Integer> previousStateCode = Optional.empty();
|
private Optional<Integer> previousStateCode = Optional.empty();
|
||||||
private @Nullable RawType cachedMap;
|
private @Nullable RawType cachedMap;
|
||||||
private Instant cachedMapTimestamp = Instant.MIN;
|
private Instant cachedMapTimestamp = Instant.MIN;
|
||||||
@ -109,41 +113,56 @@ public class BoschIndegoHandler extends BaseThingHandler {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void initialize() {
|
public void initialize() {
|
||||||
logger.debug("Initializing Indego handler");
|
|
||||||
BoschIndegoConfiguration config = getConfigAs(BoschIndegoConfiguration.class);
|
BoschIndegoConfiguration config = getConfigAs(BoschIndegoConfiguration.class);
|
||||||
stateInactiveRefreshIntervalSeconds = (int) config.refresh;
|
stateInactiveRefreshIntervalSeconds = (int) config.refresh;
|
||||||
stateActiveRefreshIntervalSeconds = (int) config.stateActiveRefresh;
|
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,
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
|
||||||
"@text/offline.conf-error.missing-username");
|
"@text/offline.conf-error.missing-bridge");
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (password == null || password.isBlank()) {
|
|
||||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
|
|
||||||
"@text/offline.conf-error.missing-password");
|
|
||||||
return;
|
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);
|
updateStatus(ThingStatus.UNKNOWN);
|
||||||
previousStateCode = Optional.empty();
|
previousStateCode = Optional.empty();
|
||||||
rescheduleStatePoll(0, stateInactiveRefreshIntervalSeconds);
|
rescheduleStatePoll(0, stateInactiveRefreshIntervalSeconds, false);
|
||||||
this.cuttingTimePollFuture = scheduler.scheduleWithFixedDelay(this::refreshCuttingTimesWithExceptionHandling, 0,
|
this.cuttingTimePollFuture = scheduler.scheduleWithFixedDelay(this::refreshCuttingTimesWithExceptionHandling, 0,
|
||||||
config.cuttingTimeRefresh, TimeUnit.MINUTES);
|
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;
|
ScheduledFuture<?> statePollFuture = this.statePollFuture;
|
||||||
if (statePollFuture != null) {
|
if (statePollFuture != null) {
|
||||||
if (refreshIntervalSeconds == currentRefreshIntervalSeconds) {
|
if (!force && refreshIntervalSeconds == currentRefreshIntervalSeconds) {
|
||||||
// No change.
|
// No change.
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
statePollFuture.cancel(false);
|
statePollFuture.cancel(force);
|
||||||
}
|
}
|
||||||
logger.debug("Scheduling state refresh job with {}s interval and {}s delay", refreshIntervalSeconds,
|
logger.debug("Scheduling state refresh job with {}s interval and {}s delay", refreshIntervalSeconds,
|
||||||
delaySeconds);
|
delaySeconds);
|
||||||
@ -156,7 +175,6 @@ public class BoschIndegoHandler extends BaseThingHandler {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void dispose() {
|
public void dispose() {
|
||||||
logger.debug("Disposing Indego handler");
|
|
||||||
ScheduledFuture<?> pollFuture = this.statePollFuture;
|
ScheduledFuture<?> pollFuture = this.statePollFuture;
|
||||||
if (pollFuture != null) {
|
if (pollFuture != null) {
|
||||||
pollFuture.cancel(true);
|
pollFuture.cancel(true);
|
||||||
@ -172,14 +190,6 @@ public class BoschIndegoHandler extends BaseThingHandler {
|
|||||||
pollFuture.cancel(true);
|
pollFuture.cancel(true);
|
||||||
}
|
}
|
||||||
this.cuttingTimeFuture = null;
|
this.cuttingTimeFuture = null;
|
||||||
|
|
||||||
scheduler.execute(() -> {
|
|
||||||
try {
|
|
||||||
controller.deauthenticate();
|
|
||||||
} catch (IndegoException e) {
|
|
||||||
logger.debug("Deauthentication failed", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -280,6 +290,7 @@ public class BoschIndegoHandler extends BaseThingHandler {
|
|||||||
try {
|
try {
|
||||||
refreshState();
|
refreshState();
|
||||||
} catch (IndegoAuthenticationException e) {
|
} catch (IndegoAuthenticationException e) {
|
||||||
|
logger.warn("Failed to authenticate: {}", e.getMessage());
|
||||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
||||||
"@text/offline.comm-error.authentication-failure");
|
"@text/offline.comm-error.authentication-failure");
|
||||||
} catch (IndegoTimeoutException e) {
|
} catch (IndegoTimeoutException e) {
|
||||||
@ -291,11 +302,6 @@ public class BoschIndegoHandler extends BaseThingHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void refreshState() throws IndegoAuthenticationException, IndegoException {
|
private void refreshState() throws IndegoAuthenticationException, IndegoException {
|
||||||
if (!propertiesInitialized) {
|
|
||||||
getThing().setProperty(Thing.PROPERTY_SERIAL_NUMBER, controller.getSerialNumber());
|
|
||||||
propertiesInitialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
DeviceStateResponse state = controller.getState();
|
DeviceStateResponse state = controller.getState();
|
||||||
DeviceStatus deviceStatus = DeviceStatus.fromCode(state.state);
|
DeviceStatus deviceStatus = DeviceStatus.fromCode(state.state);
|
||||||
updateState(state);
|
updateState(state);
|
||||||
@ -351,7 +357,7 @@ public class BoschIndegoHandler extends BaseThingHandler {
|
|||||||
} else {
|
} else {
|
||||||
refreshIntervalSeconds = stateInactiveRefreshIntervalSeconds;
|
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.
|
// 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.
|
// This is needed to update battery values after a charging cycle has completed.
|
||||||
operatingDataTimestamp = Instant.MIN;
|
operatingDataTimestamp = Instant.MIN;
|
||||||
|
@ -5,6 +5,8 @@ binding.boschindego.description = This is the binding for Bosch Indego Connect l
|
|||||||
|
|
||||||
# thing types
|
# 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.label = Bosch Indego
|
||||||
thing-type.boschindego.indego.description = Indego which supports the connect feature.
|
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.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.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.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.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.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.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
|
# channel types
|
||||||
|
|
||||||
@ -53,10 +53,11 @@ channel-type.boschindego.textualstate.label = Textual State
|
|||||||
|
|
||||||
# thing status descriptions
|
# 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.comm-error.unreachable = Device is unreachable
|
||||||
offline.conf-error.missing-password = Password missing
|
|
||||||
offline.conf-error.missing-username = Username missing
|
|
||||||
|
|
||||||
# indego states
|
# indego states
|
||||||
|
|
||||||
|
@ -4,9 +4,19 @@
|
|||||||
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
|
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">
|
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">
|
<thing-type id="indego">
|
||||||
|
<supported-bridge-type-refs>
|
||||||
|
<bridge-type-ref id="account"/>
|
||||||
|
</supported-bridge-type-refs>
|
||||||
|
|
||||||
<label>Bosch Indego</label>
|
<label>Bosch Indego</label>
|
||||||
<description>Indego which supports the connect feature.</description>
|
<description>Indego which supports the connect feature.</description>
|
||||||
|
|
||||||
<channels>
|
<channels>
|
||||||
<channel id="state" typeId="state"/>
|
<channel id="state" typeId="state"/>
|
||||||
<channel id="textualstate" typeId="textualstate"/>
|
<channel id="textualstate" typeId="textualstate"/>
|
||||||
@ -23,15 +33,13 @@
|
|||||||
<channel id="gardenSize" typeId="gardenSize"/>
|
<channel id="gardenSize" typeId="gardenSize"/>
|
||||||
<channel id="gardenMap" typeId="gardenMap"/>
|
<channel id="gardenMap" typeId="gardenMap"/>
|
||||||
</channels>
|
</channels>
|
||||||
|
|
||||||
|
<representation-property>serialNumber</representation-property>
|
||||||
|
|
||||||
<config-description>
|
<config-description>
|
||||||
<parameter name="username" type="text" required="true">
|
<parameter name="serialNumber" type="text" required="true">
|
||||||
<label>Username</label>
|
<label>Serial Number</label>
|
||||||
<description>Username for the Bosch Indego account.</description>
|
<description>The serial number of the connected Indego mower.</description>
|
||||||
</parameter>
|
|
||||||
<parameter name="password" type="text" required="true">
|
|
||||||
<context>password</context>
|
|
||||||
<label>Password</label>
|
|
||||||
<description>Password for the Bosch Indego account.</description>
|
|
||||||
</parameter>
|
</parameter>
|
||||||
<parameter name="refresh" type="integer" min="60">
|
<parameter name="refresh" type="integer" min="60">
|
||||||
<label>Idle Refresh Interval</label>
|
<label>Idle Refresh Interval</label>
|
||||||
|
Loading…
Reference in New Issue
Block a user